Files
Mozaic/frontend/src/stores/mosaic.ts
igor 47a4c1195f zobrazenie pozicii cisel na mieste kliknutia,
pridane toleracnie 0 pre presny pixel a 1 pre okolie pixela
2026-08-01 19:59:14 +02:00

422 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
}
})