422 lines
17 KiB
TypeScript
422 lines
17 KiB
TypeScript
import { computed, markRaw, reactive, ref, shallowRef, watch } from 'vue'
|
||
import { defineStore } from 'pinia'
|
||
import type {
|
||
ColorGroup,
|
||
MosaicRegion,
|
||
MosaicSettings,
|
||
ProjectFile,
|
||
ToastMessage,
|
||
} from '@/types/mosaic'
|
||
import { floodFillRegion, getFloodFillReferenceColor } from '@/utils/floodFill'
|
||
import { groupRegionsByColor, refreshGroupColors, sortGroupsByLightness } from '@/utils/colorGroups'
|
||
import { colorDistance, rgbToLab } from '@/utils/colors'
|
||
import {
|
||
buildOccupancy,
|
||
createRegionMask,
|
||
maskContains,
|
||
subtractRegionMask,
|
||
summarizeRegionMask,
|
||
} from '@/utils/regionMask'
|
||
import { createProjectFile, isProjectFile } from '@/utils/projectExport'
|
||
import { clearLastProject, loadLastProject, saveLastProject } from '@/utils/projectStorage'
|
||
|
||
interface HistorySnapshot {
|
||
regions: MosaicRegion[]
|
||
groups: ColorGroup[]
|
||
}
|
||
|
||
const DEFAULT_SETTINGS: MosaicSettings = {
|
||
floodTolerance: 34,
|
||
colorTolerance: 12,
|
||
darkBoundaryThreshold: 0.045,
|
||
connectivity: 4,
|
||
exportOverlay: false,
|
||
otherGroupsMode: 'dim',
|
||
useUserClickPositionForLabels: false,
|
||
}
|
||
|
||
const cloneRegions = (regions: MosaicRegion[]): MosaicRegion[] => regions.map((region) => markRaw({
|
||
...region,
|
||
color: { ...region.color },
|
||
labColor: { ...region.labColor },
|
||
boundingBox: { ...region.boundingBox },
|
||
labelPosition: { ...region.labelPosition },
|
||
userClickPosition: region.userClickPosition ? { ...region.userClickPosition } : undefined,
|
||
// Masks are immutable; sharing their typed arrays keeps the 30-step history compact.
|
||
mask: region.mask,
|
||
}))
|
||
|
||
const cloneGroups = (groups: ColorGroup[]): ColorGroup[] => groups.map((group) => ({
|
||
...group,
|
||
color: { ...group.color },
|
||
labColor: { ...group.labColor },
|
||
regionIds: [...group.regionIds],
|
||
}))
|
||
|
||
export const useMosaicStore = defineStore('mosaic', () => {
|
||
const imageCanvas = shallowRef<HTMLCanvasElement | null>(null)
|
||
const imageData = shallowRef<ImageData | null>(null)
|
||
const imageBlob = shallowRef<Blob | null>(null)
|
||
const imageName = ref('')
|
||
const imageWidth = ref(0)
|
||
const imageHeight = ref(0)
|
||
const regions = shallowRef<MosaicRegion[]>([])
|
||
const groups = ref<ColorGroup[]>([])
|
||
const selectedGroupId = ref<string | null>(null)
|
||
const selectedRegionId = ref<string | null>(null)
|
||
const settings = reactive<MosaicSettings>({ ...DEFAULT_SETTINGS })
|
||
const toasts = ref<ToastMessage[]>([])
|
||
const isAnalyzing = ref(false)
|
||
const hasRecovery = ref(false)
|
||
const recoveryTimestamp = ref('')
|
||
const undoStack = shallowRef<HistorySnapshot[]>([])
|
||
const redoStack = shallowRef<HistorySnapshot[]>([])
|
||
let recoveryProject: ProjectFile | null = null
|
||
let recoveryBlob: Blob | null = null
|
||
let autosaveTimer = 0
|
||
|
||
const hasImage = computed(() => Boolean(imageCanvas.value && imageData.value))
|
||
const canUndo = computed(() => undoStack.value.length > 0)
|
||
const canRedo = computed(() => redoStack.value.length > 0)
|
||
|
||
function notify(text: string, type: ToastMessage['type'] = 'info'): void {
|
||
const id = crypto.randomUUID()
|
||
toasts.value.push({ id, text, type })
|
||
window.setTimeout(() => dismissToast(id), 5000)
|
||
}
|
||
|
||
function dismissToast(id: string): void {
|
||
toasts.value = toasts.value.filter((toast) => toast.id !== id)
|
||
}
|
||
|
||
function snapshot(): HistorySnapshot {
|
||
return { regions: cloneRegions(regions.value), groups: cloneGroups(groups.value) }
|
||
}
|
||
|
||
function recordHistory(): void {
|
||
undoStack.value = [...undoStack.value.slice(-29), snapshot()]
|
||
redoStack.value = []
|
||
}
|
||
|
||
function restoreSnapshot(value: HistorySnapshot): void {
|
||
regions.value = cloneRegions(value.regions)
|
||
groups.value = cloneGroups(value.groups)
|
||
selectedGroupId.value = null
|
||
selectedRegionId.value = null
|
||
scheduleAutosave()
|
||
}
|
||
|
||
function undo(): void {
|
||
const previous = undoStack.value.at(-1)
|
||
if (!previous) return
|
||
redoStack.value = [...redoStack.value, snapshot()].slice(-30)
|
||
undoStack.value = undoStack.value.slice(0, -1)
|
||
restoreSnapshot(previous)
|
||
}
|
||
|
||
function redo(): void {
|
||
const next = redoStack.value.at(-1)
|
||
if (!next) return
|
||
undoStack.value = [...undoStack.value, snapshot()].slice(-30)
|
||
redoStack.value = redoStack.value.slice(0, -1)
|
||
restoreSnapshot(next)
|
||
}
|
||
|
||
async function decodeImage(blob: Blob): Promise<HTMLCanvasElement> {
|
||
const bitmap = await createImageBitmap(blob)
|
||
if (bitmap.width * bitmap.height > 80_000_000) {
|
||
bitmap.close()
|
||
throw new Error('Obrázok je príliš veľký (limit je 80 miliónov pixelov). Skúste menšiu verziu.')
|
||
}
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = bitmap.width
|
||
canvas.height = bitmap.height
|
||
const context = canvas.getContext('2d', { willReadFrequently: true })
|
||
if (!context) throw new Error('Prehliadač nepodporuje Canvas 2D.')
|
||
context.drawImage(bitmap, 0, 0)
|
||
bitmap.close()
|
||
return canvas
|
||
}
|
||
|
||
async function loadImage(file: Blob, filename = 'obrazok'): Promise<void> {
|
||
if (file instanceof File && !['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
|
||
notify('Podporované sú iba obrázky PNG, JPEG a WebP.', 'error')
|
||
return
|
||
}
|
||
try {
|
||
const canvas = await decodeImage(file)
|
||
const context = canvas.getContext('2d', { willReadFrequently: true })
|
||
if (!context) throw new Error('Prehliadač nepodporuje Canvas 2D.')
|
||
const pendingImportedProject = regions.value.length > 0 && !imageCanvas.value
|
||
if (pendingImportedProject && (imageWidth.value !== canvas.width || imageHeight.value !== canvas.height)) {
|
||
notify(`Projekt očakáva obrázok ${imageWidth.value} × ${imageHeight.value} px. Vybraný obrázok má ${canvas.width} × ${canvas.height} px.`, 'error')
|
||
return
|
||
}
|
||
const replacingMatchingImage = pendingImportedProject
|
||
imageCanvas.value = markRaw(canvas)
|
||
imageData.value = markRaw(context.getImageData(0, 0, canvas.width, canvas.height))
|
||
imageBlob.value = markRaw(file)
|
||
imageName.value = filename
|
||
imageWidth.value = canvas.width
|
||
imageHeight.value = canvas.height
|
||
if (!replacingMatchingImage) {
|
||
regions.value = []
|
||
groups.value = []
|
||
undoStack.value = []
|
||
redoStack.value = []
|
||
}
|
||
if (canvas.width * canvas.height > 24_000_000) {
|
||
notify('Obrázok je veľmi veľký. Analýza rozsiahlych plôch môže chvíľu trvať.', 'warning')
|
||
} else {
|
||
notify('Obrázok bol načítaný iba lokálne v prehliadači.', 'success')
|
||
}
|
||
scheduleAutosave()
|
||
} catch (error) {
|
||
notify(error instanceof Error ? error.message : 'Obrázok je poškodený alebo ho nemožno načítať.', 'error')
|
||
}
|
||
}
|
||
|
||
function addRegionAt(x: number, y: number): void {
|
||
if (!imageData.value || isAnalyzing.value) return
|
||
const floodTolerance = settings.floodTolerance
|
||
const minimumRegionPixelCount = floodTolerance <= 1 ? 1 : 12
|
||
const referenceColor = getFloodFillReferenceColor(imageData.value, x, y, floodTolerance)
|
||
const existingRegion = regions.value.find((region) => maskContains(region.mask, x, y))
|
||
if (existingRegion && colorDistance(existingRegion.color, referenceColor) <= floodTolerance) {
|
||
notify('Táto plocha už bola označená.', 'warning')
|
||
return
|
||
}
|
||
const occupancy = buildOccupancy(
|
||
imageWidth.value,
|
||
imageHeight.value,
|
||
regions.value.filter((region) => region.id !== existingRegion?.id).map((region) => region.mask),
|
||
)
|
||
isAnalyzing.value = true
|
||
window.setTimeout(() => {
|
||
try {
|
||
const result = floodFillRegion(imageData.value as ImageData, x, y, {
|
||
tolerance: floodTolerance,
|
||
darkBoundaryThreshold: settings.darkBoundaryThreshold,
|
||
connectivity: settings.connectivity,
|
||
occupancy,
|
||
})
|
||
if (!result || result.pixelCount < minimumRegionPixelCount) {
|
||
notify('Nájdená plocha je príliš malá. Skúste iný bod alebo vyššiu toleranciu.', 'warning')
|
||
return
|
||
}
|
||
if (result.pixelCount > imageWidth.value * imageHeight.value * 0.92) {
|
||
notify('Výber pokrýva takmer celý obrázok. Znížte toleranciu alebo kliknite dovnútra ohraničenej plochy.', 'warning')
|
||
return
|
||
}
|
||
recordHistory()
|
||
const region: MosaicRegion = markRaw({
|
||
id: crypto.randomUUID(),
|
||
color: result.color,
|
||
labColor: rgbToLab(result.color),
|
||
pixelCount: result.pixelCount,
|
||
boundingBox: result.boundingBox,
|
||
labelPosition: result.labelPosition,
|
||
userClickPosition: { x, y },
|
||
groupId: '',
|
||
mask: result.mask,
|
||
})
|
||
const nextRegions = regions.value.flatMap((item) => {
|
||
if (item.id !== existingRegion?.id) return [item]
|
||
const remainingMask = subtractRegionMask(item.mask, result.mask)
|
||
const summary = remainingMask ? summarizeRegionMask(remainingMask) : null
|
||
if (!remainingMask || !summary || summary.pixelCount < minimumRegionPixelCount) return []
|
||
return [markRaw({
|
||
...item,
|
||
mask: remainingMask,
|
||
pixelCount: summary.pixelCount,
|
||
boundingBox: summary.boundingBox,
|
||
labelPosition: summary.labelPosition,
|
||
userClickPosition: item.userClickPosition && maskContains(remainingMask, item.userClickPosition.x, item.userClickPosition.y)
|
||
? item.userClickPosition
|
||
: undefined,
|
||
})]
|
||
})
|
||
regions.value = [...nextRegions, region]
|
||
regroup(false)
|
||
selectedRegionId.value = region.id
|
||
selectedGroupId.value = regions.value.find((item) => item.id === region.id)?.groupId ?? null
|
||
scheduleAutosave()
|
||
} finally {
|
||
isAnalyzing.value = false
|
||
}
|
||
}, 20)
|
||
}
|
||
|
||
function regroup(withHistory = true): void {
|
||
if (withHistory && regions.value.length) recordHistory()
|
||
groups.value = groupRegionsByColor(regions.value, settings.colorTolerance)
|
||
const regionToGroup = new Map(groups.value.flatMap((group) => group.regionIds.map((id) => [id, group.id])))
|
||
regions.value = regions.value.map((region) => markRaw({ ...region, groupId: regionToGroup.get(region.id) ?? '' }))
|
||
selectedGroupId.value = null
|
||
scheduleAutosave()
|
||
}
|
||
|
||
function setColorTolerance(value: number): void {
|
||
settings.colorTolerance = value
|
||
regroup()
|
||
}
|
||
|
||
function removeRegion(id: string): void {
|
||
if (!regions.value.some((region) => region.id === id)) return
|
||
recordHistory()
|
||
regions.value = regions.value.filter((region) => region.id !== id)
|
||
groups.value = refreshGroupColors(groups.value.map((group) => ({ ...group, regionIds: group.regionIds.filter((regionId) => regionId !== id) })), regions.value)
|
||
selectedRegionId.value = null
|
||
scheduleAutosave()
|
||
}
|
||
|
||
function moveRegion(regionId: string, targetGroupId: string): void {
|
||
const region = regions.value.find((item) => item.id === regionId)
|
||
if (!region || region.groupId === targetGroupId || !groups.value.some((group) => group.id === targetGroupId)) return
|
||
recordHistory()
|
||
regions.value = regions.value.map((item) => item.id === regionId ? markRaw({ ...item, groupId: targetGroupId }) : item)
|
||
groups.value = refreshGroupColors(groups.value.map((group) => ({
|
||
...group,
|
||
regionIds: group.id === targetGroupId
|
||
? [...group.regionIds, regionId]
|
||
: group.regionIds.filter((id) => id !== regionId),
|
||
})), regions.value)
|
||
scheduleAutosave()
|
||
}
|
||
|
||
function splitRegion(regionId: string): void {
|
||
const region = regions.value.find((item) => item.id === regionId)
|
||
const oldGroup = groups.value.find((group) => group.id === region?.groupId)
|
||
if (!region || !oldGroup || oldGroup.regionIds.length <= 1) return
|
||
recordHistory()
|
||
const newGroup: ColorGroup = {
|
||
id: crypto.randomUUID(), number: groups.value.length + 1, color: { ...region.color },
|
||
labColor: { ...region.labColor }, regionIds: [region.id],
|
||
}
|
||
regions.value = regions.value.map((item) => item.id === region.id ? markRaw({ ...item, groupId: newGroup.id }) : item)
|
||
groups.value = refreshGroupColors([...groups.value.map((group) => ({ ...group, regionIds: group.regionIds.filter((id) => id !== region.id) })), newGroup], regions.value)
|
||
selectedGroupId.value = newGroup.id
|
||
scheduleAutosave()
|
||
}
|
||
|
||
function mergeGroups(sourceId: string, targetId: string): void {
|
||
if (sourceId === targetId) return
|
||
const source = groups.value.find((group) => group.id === sourceId)
|
||
const target = groups.value.find((group) => group.id === targetId)
|
||
if (!source || !target) return
|
||
recordHistory()
|
||
regions.value = regions.value.map((region) => source.regionIds.includes(region.id) ? markRaw({ ...region, groupId: targetId }) : region)
|
||
groups.value = refreshGroupColors(groups.value
|
||
.filter((group) => group.id !== sourceId)
|
||
.map((group) => group.id === targetId ? { ...group, regionIds: [...group.regionIds, ...source.regionIds] } : group), regions.value)
|
||
selectedGroupId.value = targetId
|
||
scheduleAutosave()
|
||
}
|
||
|
||
function setGroupColor(groupId: string, hex: string): void {
|
||
const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hex)
|
||
if (!match) return
|
||
recordHistory()
|
||
const color = { r: Number.parseInt(match[1] ?? '0', 16), g: Number.parseInt(match[2] ?? '0', 16), b: Number.parseInt(match[3] ?? '0', 16) }
|
||
groups.value = sortGroupsByLightness(groups.value.map((group) => group.id === groupId ? { ...group, color, labColor: rgbToLab(color), customColor: true } : group))
|
||
scheduleAutosave()
|
||
}
|
||
|
||
function clearResults(): void {
|
||
if (!regions.value.length) return
|
||
recordHistory()
|
||
regions.value = []
|
||
groups.value = []
|
||
selectedGroupId.value = null
|
||
selectedRegionId.value = null
|
||
scheduleAutosave()
|
||
}
|
||
|
||
function serializeProject(includeSmallImage = false): Promise<ProjectFile> {
|
||
return createProjectFile({
|
||
imageName: imageName.value, imageBlob: imageBlob.value,
|
||
dimensions: { width: imageWidth.value, height: imageHeight.value },
|
||
settings: { ...settings }, regions: regions.value, groups: groups.value, includeSmallImage,
|
||
})
|
||
}
|
||
|
||
async function applyProject(project: ProjectFile, blob: Blob | null = null): Promise<void> {
|
||
Object.assign(settings, DEFAULT_SETTINGS, project.settings, {
|
||
useUserClickPositionForLabels: project.settings.useUserClickPositionForLabels ?? false,
|
||
})
|
||
imageName.value = project.imageName
|
||
imageWidth.value = project.dimensions.width
|
||
imageHeight.value = project.dimensions.height
|
||
regions.value = project.regions.map((region) => markRaw({
|
||
...region,
|
||
userClickPosition: region.userClickPosition ? { ...region.userClickPosition } : undefined,
|
||
mask: createRegionMask(region.mask.width, region.mask.height, region.mask.runs),
|
||
}))
|
||
groups.value = cloneGroups(project.groups)
|
||
undoStack.value = []
|
||
redoStack.value = []
|
||
imageCanvas.value = null
|
||
imageData.value = null
|
||
imageBlob.value = null
|
||
if (blob) await loadImage(blob, project.imageName)
|
||
else if (project.imageDataUrl) {
|
||
const response = await fetch(project.imageDataUrl)
|
||
await loadImage(await response.blob(), project.imageName)
|
||
} else notify('Projekt je načítaný. Vyberte pôvodný obrázok s rovnakými rozmermi.', 'warning')
|
||
}
|
||
|
||
async function importProjectFile(file: File): Promise<void> {
|
||
try {
|
||
const parsed: unknown = JSON.parse(await file.text())
|
||
if (!isProjectFile(parsed)) throw new Error('Súbor nemá platný formát projektu Mozaic.')
|
||
await applyProject(parsed)
|
||
notify('Projekt bol úspešne importovaný.', 'success')
|
||
} catch (error) {
|
||
notify(error instanceof Error ? error.message : 'JSON projekt je poškodený.', 'error')
|
||
}
|
||
}
|
||
|
||
function scheduleAutosave(): void {
|
||
window.clearTimeout(autosaveTimer)
|
||
autosaveTimer = window.setTimeout(async () => {
|
||
if (!imageWidth.value) return
|
||
try { await saveLastProject(await serializeProject(false), imageBlob.value) } catch { /* Private mode may block IndexedDB. */ }
|
||
}, 800)
|
||
}
|
||
|
||
async function checkRecovery(): Promise<void> {
|
||
try {
|
||
const stored = await loadLastProject()
|
||
if (!stored) return
|
||
recoveryProject = stored.project
|
||
recoveryBlob = stored.imageBlob
|
||
recoveryTimestamp.value = stored.savedAt
|
||
hasRecovery.value = true
|
||
} catch { /* IndexedDB is optional. */ }
|
||
}
|
||
|
||
async function restoreRecovery(): Promise<void> {
|
||
if (!recoveryProject) return
|
||
await applyProject(recoveryProject, recoveryBlob)
|
||
hasRecovery.value = false
|
||
notify('Posledná rozpracovaná práca bola obnovená.', 'success')
|
||
}
|
||
|
||
async function dismissRecovery(): Promise<void> {
|
||
hasRecovery.value = false
|
||
recoveryProject = null
|
||
recoveryBlob = null
|
||
await clearLastProject().catch(() => undefined)
|
||
}
|
||
|
||
watch(settings, scheduleAutosave)
|
||
|
||
return {
|
||
imageCanvas, imageData, imageBlob, imageName, imageWidth, imageHeight, regions, groups,
|
||
selectedGroupId, selectedRegionId, settings, toasts, isAnalyzing, hasRecovery, recoveryTimestamp,
|
||
hasImage, canUndo, canRedo, loadImage, addRegionAt, regroup, setColorTolerance, removeRegion,
|
||
moveRegion, splitRegion, mergeGroups, setGroupColor, clearResults, undo, redo, notify,
|
||
dismissToast, serializeProject, importProjectFile, checkRecovery, restoreRecovery, dismissRecovery,
|
||
}
|
||
})
|