zakladna implementacia by Codex
This commit is contained in:
383
frontend/src/stores/mosaic.ts
Normal file
383
frontend/src/stores/mosaic.ts
Normal file
@ -0,0 +1,383 @@
|
||||
import { computed, markRaw, reactive, ref, shallowRef } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type {
|
||||
ColorGroup,
|
||||
MosaicRegion,
|
||||
MosaicSettings,
|
||||
ProjectFile,
|
||||
ToastMessage,
|
||||
} from '@/types/mosaic'
|
||||
import { floodFillRegion } from '@/utils/floodFill'
|
||||
import { groupRegionsByColor, refreshGroupColors, sortGroupsByLightness } from '@/utils/colorGroups'
|
||||
import { rgbToLab } from '@/utils/colors'
|
||||
import { buildOccupancy, createRegionMask } 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',
|
||||
}
|
||||
|
||||
const cloneRegions = (regions: MosaicRegion[]): MosaicRegion[] => regions.map((region) => markRaw({
|
||||
...region,
|
||||
color: { ...region.color },
|
||||
labColor: { ...region.labColor },
|
||||
boundingBox: { ...region.boundingBox },
|
||||
labelPosition: { ...region.labelPosition },
|
||||
// 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 occupancy = buildOccupancy(imageWidth.value, imageHeight.value, regions.value.map((region) => region.mask))
|
||||
if (occupancy[y * imageWidth.value + x]) {
|
||||
notify('Táto plocha už bola označená.', 'warning')
|
||||
return
|
||||
}
|
||||
isAnalyzing.value = true
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
const result = floodFillRegion(imageData.value as ImageData, x, y, {
|
||||
tolerance: settings.floodTolerance,
|
||||
darkBoundaryThreshold: settings.darkBoundaryThreshold,
|
||||
connectivity: settings.connectivity,
|
||||
occupancy,
|
||||
})
|
||||
if (!result || result.pixelCount < 12) {
|
||||
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,
|
||||
groupId: '',
|
||||
mask: result.mask,
|
||||
})
|
||||
regions.value = [...regions.value, 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, project.settings)
|
||||
imageName.value = project.imageName
|
||||
imageWidth.value = project.dimensions.width
|
||||
imageHeight.value = project.dimensions.height
|
||||
regions.value = project.regions.map((region) => markRaw({
|
||||
...region,
|
||||
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)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user