diff --git a/frontend/README.md b/frontend/README.md
index f190e00..ec91b6e 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -1,48 +1,24 @@
-# frontend
+# Mozaic – lokálna analýza farebných plôch
-This template should help get you started developing with Vue 3 in Vite.
+Vue 3 aplikácia na označovanie uzavretých farebných plôch pomocou flood fill, zoskupovanie podobných farieb v CIE Lab a export očíslovaného výsledku. Podporuje PNG, JPEG a WebP, manuálne úpravy skupín, undo/redo, export PNG/CSV/JSON a obnovu poslednej práce z IndexedDB.
-## Recommended IDE Setup
+Všetky obrázky a výpočty zostávajú výhradne v prehliadači. Aplikácia nemá backend a obrázky nikam neodosiela.
-[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
-
-## Recommended Browser Setup
-
-- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- - [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- - [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
-- Firefox:
- - [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- - [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
-
-## Type Support for `.vue` Imports in TS
-
-TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
-
-## Customize configuration
-
-See [Vite Configuration Reference](https://vite.dev/config/).
-
-## Project Setup
+## Spustenie
```sh
npm install
-```
-
-### Compile and Hot-Reload for Development
-
-```sh
npm run dev
```
-### Type-Check, Compile and Minify for Production
+Produkčný build a kontroly:
```sh
npm run build
-```
-
-### Lint with [ESLint](https://eslint.org/)
-
-```sh
+npm run type-check
npm run lint
```
+
+## Použitie
+
+Načítajte obrázok, klikajte dovnútra ohraničených farebných plôch a podľa potreby upravte toleranciu. Ťahaním sa obrázok posúva a kolieskom myši približuje. Výsledok možno exportovať ako očíslované PNG, paletu CSV alebo projekt JSON.
diff --git a/frontend/index.html b/frontend/index.html
index 9e5fc8f..85a0e0a 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1,10 +1,12 @@
-
+
- Vite App
+
+
+ Mozaic – analýza farebných plôch
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index abfd315..71a9b9a 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -1,11 +1,5 @@
-
+
-
- You did it!
-
- Visit vuejs.org to read the
- documentation
-
-
-
-
+
diff --git a/frontend/src/components/ColorGroupItem.vue b/frontend/src/components/ColorGroupItem.vue
new file mode 100644
index 0000000..2c16759
--- /dev/null
+++ b/frontend/src/components/ColorGroupItem.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+ {{ group.number }}
+
+
+ {{ rgbToHex(group.color) }}
+ RGB {{ group.color.r }}, {{ group.color.g }}, {{ group.color.b }} · L {{ group.labColor.l.toFixed(1) }}
+
+ {{ group.regionIds.length }}×
+
+
+
{{ totalPixels.toLocaleString('sk-SK') }} pixelov
+
Farba
+
+
+ Zlúčiť do…
+ Skupina {{ target.number }} · {{ rgbToHex(target.color) }}
+
+ Zlúčiť
+
+
+
+
+
+
diff --git a/frontend/src/components/ColorGroupList.vue b/frontend/src/components/ColorGroupList.vue
new file mode 100644
index 0000000..b6cc2b8
--- /dev/null
+++ b/frontend/src/components/ColorGroupList.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
+
Farebné skupiny
+
+ Prepočítať
+ {{ groups.length }}
+
+
+ Po kliknutí do obrázka sa tu zobrazia farby zoradené od najtmavšej.
+
+
+
+
+
diff --git a/frontend/src/components/EditorToolbar.vue b/frontend/src/components/EditorToolbar.vue
new file mode 100644
index 0000000..fcd9139
--- /dev/null
+++ b/frontend/src/components/EditorToolbar.vue
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/ExportPanel.vue b/frontend/src/components/ExportPanel.vue
new file mode 100644
index 0000000..3ebc434
--- /dev/null
+++ b/frontend/src/components/ExportPanel.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/ImageDropzone.vue b/frontend/src/components/ImageDropzone.vue
new file mode 100644
index 0000000..2aaa582
--- /dev/null
+++ b/frontend/src/components/ImageDropzone.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
Načítajte obrázok mozaiky
+
Presuňte sem PNG, JPEG alebo WebP, prípadne ho vyberte z počítača.
+
Vybrať obrázok
+
Obrázok zostáva vo vašom prehliadači a nikam sa neodosiela.
+
+
+
+
+
diff --git a/frontend/src/components/MosaicCanvas.vue b/frontend/src/components/MosaicCanvas.vue
new file mode 100644
index 0000000..0e09e78
--- /dev/null
+++ b/frontend/src/components/MosaicCanvas.vue
@@ -0,0 +1,175 @@
+
+
+
+
+
+
+
Analyzujem plochu…
+
Kliknutie označí plochu · ťahanie posúva · koliesko približuje
+
+
+
+
diff --git a/frontend/src/components/RegionList.vue b/frontend/src/components/RegionList.vue
new file mode 100644
index 0000000..e9e0c66
--- /dev/null
+++ b/frontend/src/components/RegionList.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
+
{{ selectedGroupId ? 'Plochy vo vybranej skupine' : 'Rozpoznané plochy' }}
+ {{ visibleRegions.length }}
+
+ Zatiaľ nie je označená žiadna plocha.
+
+ {{ index + 1 }}
+ {{ rgbToHex(region.color) }} {{ region.pixelCount.toLocaleString('sk-SK') }} px
+
+ Sk. {{ group.number }}
+
+ Rozdeliť
+ ×
+
+
+
+
+
diff --git a/frontend/src/components/SettingsPanel.vue b/frontend/src/components/SettingsPanel.vue
new file mode 100644
index 0000000..ca35df1
--- /dev/null
+++ b/frontend/src/components/SettingsPanel.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/composables/useCanvasViewport.ts b/frontend/src/composables/useCanvasViewport.ts
new file mode 100644
index 0000000..c36b6e3
--- /dev/null
+++ b/frontend/src/composables/useCanvasViewport.ts
@@ -0,0 +1,24 @@
+import { reactive } from 'vue'
+
+export function useCanvasViewport() {
+ const viewport = reactive({ scale: 1, x: 0, y: 0 })
+
+ function fit(containerWidth: number, containerHeight: number, imageWidth: number, imageHeight: number): void {
+ if (!imageWidth || !imageHeight) return
+ viewport.scale = Math.min((containerWidth - 40) / imageWidth, (containerHeight - 40) / imageHeight, 1)
+ viewport.scale = Math.max(0.02, viewport.scale)
+ viewport.x = (containerWidth - imageWidth * viewport.scale) / 2
+ viewport.y = (containerHeight - imageHeight * viewport.scale) / 2
+ }
+
+ function zoomAt(factor: number, x: number, y: number): void {
+ const nextScale = Math.max(0.03, Math.min(24, viewport.scale * factor))
+ const imageX = (x - viewport.x) / viewport.scale
+ const imageY = (y - viewport.y) / viewport.scale
+ viewport.x = x - imageX * nextScale
+ viewport.y = y - imageY * nextScale
+ viewport.scale = nextScale
+ }
+
+ return { viewport, fit, zoomAt }
+}
diff --git a/frontend/src/stores/mosaic.ts b/frontend/src/stores/mosaic.ts
new file mode 100644
index 0000000..1c38fd6
--- /dev/null
+++ b/frontend/src/stores/mosaic.ts
@@ -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(null)
+ const imageData = shallowRef(null)
+ const imageBlob = shallowRef(null)
+ const imageName = ref('')
+ const imageWidth = ref(0)
+ const imageHeight = ref(0)
+ const regions = shallowRef([])
+ const groups = ref([])
+ const selectedGroupId = ref(null)
+ const selectedRegionId = ref(null)
+ const settings = reactive({ ...DEFAULT_SETTINGS })
+ const toasts = ref([])
+ const isAnalyzing = ref(false)
+ const hasRecovery = ref(false)
+ const recoveryTimestamp = ref('')
+ const undoStack = shallowRef([])
+ const redoStack = shallowRef([])
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ if (!recoveryProject) return
+ await applyProject(recoveryProject, recoveryBlob)
+ hasRecovery.value = false
+ notify('Posledná rozpracovaná práca bola obnovená.', 'success')
+ }
+
+ async function dismissRecovery(): Promise {
+ 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,
+ }
+})
diff --git a/frontend/src/types/mosaic.ts b/frontend/src/types/mosaic.ts
new file mode 100644
index 0000000..613f971
--- /dev/null
+++ b/frontend/src/types/mosaic.ts
@@ -0,0 +1,85 @@
+export interface RGBColor {
+ r: number
+ g: number
+ b: number
+}
+
+export interface LabColor {
+ l: number
+ a: number
+ b: number
+}
+
+export interface Point {
+ x: number
+ y: number
+}
+
+export interface BoundingBox {
+ x: number
+ y: number
+ width: number
+ height: number
+}
+
+/** Triplets of y, inclusive xStart and inclusive xEnd. */
+export interface RegionMask {
+ width: number
+ height: number
+ runs: Uint32Array
+}
+
+export interface MosaicRegion {
+ id: string
+ color: RGBColor
+ labColor: LabColor
+ pixelCount: number
+ boundingBox: BoundingBox
+ labelPosition: Point
+ groupId: string
+ mask: RegionMask
+}
+
+export interface ColorGroup {
+ id: string
+ number: number
+ color: RGBColor
+ labColor: LabColor
+ regionIds: string[]
+ customColor?: boolean
+}
+
+export interface MosaicSettings {
+ floodTolerance: number
+ colorTolerance: number
+ darkBoundaryThreshold: number
+ connectivity: 4 | 8
+ exportOverlay: boolean
+ otherGroupsMode: 'normal' | 'dim' | 'hidden'
+}
+
+export interface ProjectDimensions {
+ width: number
+ height: number
+}
+
+export interface SerializedRegion extends Omit {
+ mask: { width: number; height: number; runs: number[] }
+}
+
+export interface ProjectFile {
+ version: 1
+ createdAt: string
+ imageName: string
+ imageDataUrl?: string
+ dimensions: ProjectDimensions
+ settings: MosaicSettings
+ regions: SerializedRegion[]
+ groups: ColorGroup[]
+}
+
+export interface ToastMessage {
+ id: string
+ type: 'info' | 'success' | 'warning' | 'error'
+ text: string
+}
diff --git a/frontend/src/utils/colorGroups.ts b/frontend/src/utils/colorGroups.ts
new file mode 100644
index 0000000..63c84bf
--- /dev/null
+++ b/frontend/src/utils/colorGroups.ts
@@ -0,0 +1,50 @@
+import type { ColorGroup, MosaicRegion, RGBColor } from '@/types/mosaic'
+import { averageColors, deltaE76, rgbToLab } from './colors'
+
+export function sortGroupsByLightness(groups: ColorGroup[]): ColorGroup[] {
+ return [...groups]
+ .sort((first, second) => first.labColor.l - second.labColor.l)
+ .map((group, index) => ({ ...group, number: index + 1 }))
+}
+
+export function groupRegionsByColor(regions: MosaicRegion[], tolerance: number): ColorGroup[] {
+ const groups: ColorGroup[] = []
+ const ordered = [...regions].sort((first, second) => first.labColor.l - second.labColor.l)
+ for (const region of ordered) {
+ const group = groups.find((candidate) => deltaE76(candidate.labColor, region.labColor) <= tolerance)
+ if (group) {
+ group.regionIds.push(region.id)
+ const members = regionColors(group.regionIds, regions)
+ group.color = averageColors(members)
+ group.labColor = rgbToLab(group.color)
+ } else {
+ groups.push({
+ id: crypto.randomUUID(),
+ number: groups.length + 1,
+ color: { ...region.color },
+ labColor: { ...region.labColor },
+ regionIds: [region.id],
+ })
+ }
+ }
+ return sortGroupsByLightness(groups)
+}
+
+function regionColors(regionIds: string[], regions: MosaicRegion[]): Array<{ color: RGBColor; weight: number }> {
+ const idSet = new Set(regionIds)
+ return regions
+ .filter((region) => idSet.has(region.id))
+ .map((region) => ({ color: region.color, weight: region.pixelCount }))
+}
+
+export function refreshGroupColors(groups: ColorGroup[], regions: MosaicRegion[]): ColorGroup[] {
+ return sortGroupsByLightness(
+ groups
+ .filter((group) => group.regionIds.length)
+ .map((group) => {
+ if (group.customColor) return { ...group, regionIds: [...group.regionIds] }
+ const color = averageColors(regionColors(group.regionIds, regions))
+ return { ...group, color, labColor: rgbToLab(color), regionIds: [...group.regionIds] }
+ }),
+ )
+}
diff --git a/frontend/src/utils/colors.ts b/frontend/src/utils/colors.ts
new file mode 100644
index 0000000..fd9ee57
--- /dev/null
+++ b/frontend/src/utils/colors.ts
@@ -0,0 +1,63 @@
+import type { LabColor, RGBColor } from '@/types/mosaic'
+
+const clampByte = (value: number) => Math.max(0, Math.min(255, Math.round(value)))
+
+export function rgbToHex(color: RGBColor): string {
+ const channel = (value: number) => clampByte(value).toString(16).padStart(2, '0')
+ return `#${channel(color.r)}${channel(color.g)}${channel(color.b)}`.toUpperCase()
+}
+
+export function rgbToXyz(color: RGBColor): { x: number; y: number; z: number } {
+ const linearize = (value: number) => {
+ const channel = clampByte(value) / 255
+ return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
+ }
+ const r = linearize(color.r)
+ const g = linearize(color.g)
+ const b = linearize(color.b)
+ return {
+ x: (r * 0.4124564 + g * 0.3575761 + b * 0.1804375) * 100,
+ y: (r * 0.2126729 + g * 0.7151522 + b * 0.072175) * 100,
+ z: (r * 0.0193339 + g * 0.119192 + b * 0.9503041) * 100,
+ }
+}
+
+export function xyzToLab(xyz: { x: number; y: number; z: number }): LabColor {
+ const transform = (value: number) => {
+ const delta = 6 / 29
+ return value > delta ** 3 ? Math.cbrt(value) : value / (3 * delta ** 2) + 4 / 29
+ }
+ const x = transform(xyz.x / 95.047)
+ const y = transform(xyz.y / 100)
+ const z = transform(xyz.z / 108.883)
+ return { l: 116 * y - 16, a: 500 * (x - y), b: 200 * (y - z) }
+}
+
+export function rgbToLab(color: RGBColor): LabColor {
+ return xyzToLab(rgbToXyz(color))
+}
+
+export function deltaE76(first: LabColor, second: LabColor): number {
+ return Math.hypot(first.l - second.l, first.a - second.a, first.b - second.b)
+}
+
+export function relativeLuminance(color: RGBColor): number {
+ const linearize = (value: number) => {
+ const channel = clampByte(value) / 255
+ return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
+ }
+ return 0.2126 * linearize(color.r) + 0.7152 * linearize(color.g) + 0.0722 * linearize(color.b)
+}
+
+export function colorDistance(first: RGBColor, second: RGBColor): number {
+ return Math.hypot(first.r - second.r, first.g - second.g, first.b - second.b)
+}
+
+export function averageColors(colors: Array<{ color: RGBColor; weight: number }>): RGBColor {
+ const total = colors.reduce((sum, item) => sum + item.weight, 0) || 1
+ return {
+ r: Math.round(colors.reduce((sum, item) => sum + item.color.r * item.weight, 0) / total),
+ g: Math.round(colors.reduce((sum, item) => sum + item.color.g * item.weight, 0) / total),
+ b: Math.round(colors.reduce((sum, item) => sum + item.color.b * item.weight, 0) / total),
+ }
+}
diff --git a/frontend/src/utils/floodFill.ts b/frontend/src/utils/floodFill.ts
new file mode 100644
index 0000000..f7925cc
--- /dev/null
+++ b/frontend/src/utils/floodFill.ts
@@ -0,0 +1,141 @@
+import type { BoundingBox, Point, RegionMask, RGBColor } from '@/types/mosaic'
+import { colorDistance, relativeLuminance } from './colors'
+import { createRegionMask } from './regionMask'
+
+export interface FloodFillOptions {
+ tolerance: number
+ darkBoundaryThreshold: number
+ connectivity: 4 | 8
+ occupancy?: Uint8Array
+}
+
+export interface FloodFillResult {
+ mask: RegionMask
+ pixelCount: number
+ boundingBox: BoundingBox
+ labelPosition: Point
+ color: RGBColor
+}
+
+const pixelColor = (data: Uint8ClampedArray, index: number): RGBColor => ({
+ r: data[index * 4] ?? 0,
+ g: data[index * 4 + 1] ?? 0,
+ b: data[index * 4 + 2] ?? 0,
+})
+
+const median = (values: number[]): number => {
+ values.sort((first, second) => first - second)
+ return values[Math.floor(values.length / 2)] ?? 0
+}
+
+export function floodFillRegion(
+ image: ImageData,
+ startX: number,
+ startY: number,
+ options: FloodFillOptions,
+): FloodFillResult | null {
+ const { width, height, data } = image
+ if (startX < 0 || startY < 0 || startX >= width || startY >= height) return null
+ const startIndex = startY * width + startX
+ if (options.occupancy?.[startIndex]) return null
+
+ const seed = pixelColor(data, startIndex)
+ const seedIsDark = relativeLuminance(seed) <= options.darkBoundaryThreshold
+ const visited = new Uint8Array(width * height)
+ const stack: number[] = [startIndex]
+ const runs: number[] = []
+ const reds: number[] = []
+ const greens: number[] = []
+ const blues: number[] = []
+ let pixelCount = 0
+ let minX = width
+ let minY = height
+ let maxX = 0
+ let maxY = 0
+
+ const isAccepted = (index: number) => {
+ if (visited[index] || options.occupancy?.[index]) return false
+ const color = pixelColor(data, index)
+ if (!seedIsDark && relativeLuminance(color) <= options.darkBoundaryThreshold) return false
+ return colorDistance(color, seed) <= options.tolerance
+ }
+
+ const sample = (index: number) => {
+ const color = pixelColor(data, index)
+ if (reds.length < 8000) {
+ reds.push(color.r)
+ greens.push(color.g)
+ blues.push(color.b)
+ return
+ }
+ const replacement = Math.floor(Math.random() * pixelCount)
+ if (replacement < 8000) {
+ reds[replacement] = color.r
+ greens[replacement] = color.g
+ blues[replacement] = color.b
+ }
+ }
+
+ while (stack.length) {
+ const index = stack.pop()
+ if (index === undefined || !isAccepted(index)) continue
+ const y = Math.floor(index / width)
+ let left = index % width
+ let right = left
+ while (left > 0 && isAccepted(y * width + left - 1)) left -= 1
+ while (right + 1 < width && isAccepted(y * width + right + 1)) right += 1
+
+ for (let x = left; x <= right; x += 1) {
+ const pixelIndex = y * width + x
+ visited[pixelIndex] = 1
+ pixelCount += 1
+ // Avoid both ends of the scanline, where outlines and JPEG artifacts are most likely.
+ if (x >= left + 2 && x <= right - 2) sample(pixelIndex)
+ }
+ runs.push(y, left, right)
+ minX = Math.min(minX, left)
+ maxX = Math.max(maxX, right)
+ minY = Math.min(minY, y)
+ maxY = Math.max(maxY, y)
+
+ for (const neighborY of [y - 1, y + 1]) {
+ if (neighborY < 0 || neighborY >= height) continue
+ const extra = options.connectivity === 8 ? 1 : 0
+ const scanStart = Math.max(0, left - extra)
+ const scanEnd = Math.min(width - 1, right + extra)
+ let insideCandidate = false
+ for (let x = scanStart; x <= scanEnd; x += 1) {
+ const neighborIndex = neighborY * width + x
+ const candidate = isAccepted(neighborIndex)
+ if (candidate && !insideCandidate) stack.push(neighborIndex)
+ insideCandidate = candidate
+ }
+ }
+ }
+ if (!pixelCount) return null
+ if (!reds.length) {
+ reds.push(seed.r)
+ greens.push(seed.g)
+ blues.push(seed.b)
+ }
+
+ const orderedRuns: Array<[number, number, number]> = []
+ for (let index = 0; index < runs.length; index += 3) {
+ orderedRuns.push([runs[index] ?? 0, runs[index + 1] ?? 0, runs[index + 2] ?? 0])
+ }
+ orderedRuns.sort((first, second) => first[0] - second[0] || first[1] - second[1])
+ const centerY = (minY + maxY) / 2
+ const labelRun = orderedRuns.reduce((best, run) => {
+ const score = run[2] - run[1] - Math.abs(run[0] - centerY) * 0.2
+ const bestScore = best[2] - best[1] - Math.abs(best[0] - centerY) * 0.2
+ return score > bestScore ? run : best
+ }, orderedRuns[0] ?? [startY, startX, startX])
+
+ return {
+ mask: createRegionMask(width, height, orderedRuns.flatMap((run) => run)),
+ pixelCount,
+ boundingBox: { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 },
+ labelPosition: { x: Math.round((labelRun[1] + labelRun[2]) / 2), y: labelRun[0] },
+ color: { r: median(reds), g: median(greens), b: median(blues) },
+ }
+}
diff --git a/frontend/src/utils/projectExport.ts b/frontend/src/utils/projectExport.ts
new file mode 100644
index 0000000..5305d9b
--- /dev/null
+++ b/frontend/src/utils/projectExport.ts
@@ -0,0 +1,148 @@
+import type {
+ ColorGroup,
+ MosaicRegion,
+ MosaicSettings,
+ ProjectDimensions,
+ ProjectFile,
+} from '@/types/mosaic'
+import { relativeLuminance, rgbToHex } from './colors'
+import { forEachMaskRun } from './regionMask'
+
+export function downloadBlob(blob: Blob, filename: string): void {
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = url
+ link.download = filename
+ link.click()
+ window.setTimeout(() => URL.revokeObjectURL(url), 1000)
+}
+
+export async function canvasToBlob(canvas: HTMLCanvasElement): Promise {
+ return new Promise((resolve, reject) => {
+ canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('PNG sa nepodarilo vytvoriť.'))), 'image/png')
+ })
+}
+
+export function renderExportCanvas(
+ source: CanvasImageSource,
+ dimensions: ProjectDimensions,
+ regions: MosaicRegion[],
+ groups: ColorGroup[],
+ includeOverlay: boolean,
+): HTMLCanvasElement {
+ const canvas = document.createElement('canvas')
+ canvas.width = dimensions.width
+ canvas.height = dimensions.height
+ const context = canvas.getContext('2d')
+ if (!context) throw new Error('Prehliadač nepodporuje Canvas 2D.')
+ context.drawImage(source, 0, 0, dimensions.width, dimensions.height)
+ const groupMap = new Map(groups.map((group) => [group.id, group]))
+
+ if (includeOverlay) {
+ context.save()
+ context.globalAlpha = 0.24
+ for (const region of regions) {
+ const group = groupMap.get(region.groupId)
+ if (!group) continue
+ context.fillStyle = rgbToHex(group.color)
+ forEachMaskRun(region.mask, (y, xStart, xEnd) => context.fillRect(xStart, y, xEnd - xStart + 1, 1))
+ }
+ context.restore()
+ }
+
+ const fontSize = Math.max(14, Math.round(Math.min(dimensions.width, dimensions.height) / 55))
+ context.textAlign = 'center'
+ context.textBaseline = 'middle'
+ context.font = `700 ${fontSize}px system-ui, sans-serif`
+ for (const region of regions) {
+ const group = groupMap.get(region.groupId)
+ if (!group) continue
+ const label = String(group.number)
+ context.lineWidth = Math.max(2, fontSize / 7)
+ context.strokeStyle = relativeLuminance(group.color) > 0.45 ? 'rgba(255,255,255,.9)' : 'rgba(0,0,0,.8)'
+ context.fillStyle = relativeLuminance(group.color) > 0.45 ? '#111827' : '#FFFFFF'
+ context.strokeText(label, region.labelPosition.x, region.labelPosition.y)
+ context.fillText(label, region.labelPosition.x, region.labelPosition.y)
+ }
+ return canvas
+}
+
+const csvCell = (value: string | number) => `"${String(value).replaceAll('"', '""')}"`
+
+export function createPaletteCsv(groups: ColorGroup[], regions: MosaicRegion[]): string {
+ const header = ['Poradie', 'Číslo skupiny', 'HEX', 'R', 'G', 'B', 'Lab L', 'Lab a', 'Lab b', 'Počet plôch', 'Počet pixelov']
+ const regionMap = new Map(regions.map((region) => [region.id, region]))
+ const rows = groups.map((group, index) => {
+ const pixels = group.regionIds.reduce((sum, id) => sum + (regionMap.get(id)?.pixelCount ?? 0), 0)
+ return [
+ index + 1,
+ group.number,
+ rgbToHex(group.color),
+ group.color.r,
+ group.color.g,
+ group.color.b,
+ group.labColor.l.toFixed(2),
+ group.labColor.a.toFixed(2),
+ group.labColor.b.toFixed(2),
+ group.regionIds.length,
+ pixels,
+ ].map(csvCell).join(',')
+ })
+ return `\uFEFF${[header.map(csvCell).join(','), ...rows].join('\r\n')}`
+}
+
+async function blobToDataUrl(blob: Blob): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onload = () => resolve(String(reader.result))
+ reader.onerror = () => reject(reader.error ?? new Error('Súbor sa nepodarilo načítať.'))
+ reader.readAsDataURL(blob)
+ })
+}
+
+export async function createProjectFile(input: {
+ imageName: string
+ imageBlob: Blob | null
+ dimensions: ProjectDimensions
+ settings: MosaicSettings
+ regions: MosaicRegion[]
+ groups: ColorGroup[]
+ includeSmallImage?: boolean
+}): Promise {
+ const project: ProjectFile = {
+ version: 1,
+ createdAt: new Date().toISOString(),
+ imageName: input.imageName,
+ dimensions: { ...input.dimensions },
+ settings: { ...input.settings },
+ regions: input.regions.map((region) => ({
+ ...region,
+ color: { ...region.color },
+ labColor: { ...region.labColor },
+ boundingBox: { ...region.boundingBox },
+ labelPosition: { ...region.labelPosition },
+ mask: { width: region.mask.width, height: region.mask.height, runs: Array.from(region.mask.runs) },
+ })),
+ groups: input.groups.map((group) => ({
+ ...group,
+ color: { ...group.color },
+ labColor: { ...group.labColor },
+ regionIds: [...group.regionIds],
+ })),
+ }
+ if (input.includeSmallImage && input.imageBlob && input.imageBlob.size <= 5 * 1024 * 1024) {
+ project.imageDataUrl = await blobToDataUrl(input.imageBlob)
+ }
+ return project
+}
+
+export function isProjectFile(value: unknown): value is ProjectFile {
+ if (!value || typeof value !== 'object') return false
+ const project = value as Partial
+ return project.version === 1 &&
+ typeof project.imageName === 'string' &&
+ typeof project.dimensions?.width === 'number' &&
+ typeof project.dimensions.height === 'number' &&
+ Array.isArray(project.regions) && Array.isArray(project.groups) &&
+ typeof project.settings?.floodTolerance === 'number'
+}
diff --git a/frontend/src/utils/projectStorage.ts b/frontend/src/utils/projectStorage.ts
new file mode 100644
index 0000000..f3f9e29
--- /dev/null
+++ b/frontend/src/utils/projectStorage.ts
@@ -0,0 +1,52 @@
+import type { ProjectFile } from '@/types/mosaic'
+
+const DATABASE = 'mozaic-analyzer'
+const STORE = 'projects'
+const LAST_PROJECT = 'last-project'
+
+interface StoredProject {
+ project: ProjectFile
+ imageBlob: Blob | null
+ savedAt: string
+}
+
+function openDatabase(): Promise {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(DATABASE, 1)
+ request.onupgradeneeded = () => request.result.createObjectStore(STORE)
+ request.onsuccess = () => resolve(request.result)
+ request.onerror = () => reject(request.error)
+ })
+}
+
+export async function saveLastProject(project: ProjectFile, imageBlob: Blob | null): Promise {
+ const database = await openDatabase()
+ await new Promise((resolve, reject) => {
+ const transaction = database.transaction(STORE, 'readwrite')
+ transaction.objectStore(STORE).put({ project, imageBlob, savedAt: new Date().toISOString() } satisfies StoredProject, LAST_PROJECT)
+ transaction.oncomplete = () => resolve()
+ transaction.onerror = () => reject(transaction.error)
+ })
+ database.close()
+}
+
+export async function loadLastProject(): Promise {
+ const database = await openDatabase()
+ const value = await new Promise((resolve, reject) => {
+ const request = database.transaction(STORE, 'readonly').objectStore(STORE).get(LAST_PROJECT)
+ request.onsuccess = () => resolve(request.result as StoredProject | undefined)
+ request.onerror = () => reject(request.error)
+ })
+ database.close()
+ return value ?? null
+}
+
+export async function clearLastProject(): Promise {
+ const database = await openDatabase()
+ await new Promise((resolve, reject) => {
+ const request = database.transaction(STORE, 'readwrite').objectStore(STORE).delete(LAST_PROJECT)
+ request.onsuccess = () => resolve()
+ request.onerror = () => reject(request.error)
+ })
+ database.close()
+}
diff --git a/frontend/src/utils/regionMask.ts b/frontend/src/utils/regionMask.ts
new file mode 100644
index 0000000..781656e
--- /dev/null
+++ b/frontend/src/utils/regionMask.ts
@@ -0,0 +1,36 @@
+import { markRaw } from 'vue'
+import type { RegionMask } from '@/types/mosaic'
+
+export function createRegionMask(width: number, height: number, runs: number[]): RegionMask {
+ return markRaw({ width, height, runs: markRaw(Uint32Array.from(runs)) })
+}
+
+export function forEachMaskRun(
+ mask: RegionMask,
+ callback: (y: number, xStart: number, xEnd: number) => void,
+): void {
+ for (let index = 0; index < mask.runs.length; index += 3) {
+ callback(mask.runs[index] ?? 0, mask.runs[index + 1] ?? 0, mask.runs[index + 2] ?? 0)
+ }
+}
+
+export function buildOccupancy(width: number, height: number, masks: RegionMask[]): Uint8Array {
+ const occupancy = new Uint8Array(width * height)
+ for (const mask of masks) {
+ forEachMaskRun(mask, (y, xStart, xEnd) => {
+ occupancy.fill(1, y * width + xStart, y * width + xEnd + 1)
+ })
+ }
+ return occupancy
+}
+
+export function maskContains(mask: RegionMask, x: number, y: number): boolean {
+ for (let index = 0; index < mask.runs.length; index += 3) {
+ const runY = mask.runs[index] ?? -1
+ if (runY > y) return false
+ if (runY === y && x >= (mask.runs[index + 1] ?? 0) && x <= (mask.runs[index + 2] ?? -1)) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/frontend/src/views/EditorView.vue b/frontend/src/views/EditorView.vue
new file mode 100644
index 0000000..baf7a50
--- /dev/null
+++ b/frontend/src/views/EditorView.vue
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
Nájdená rozpracovaná práca. Uložená {{ new Date(recoveryTimestamp).toLocaleString('sk-SK') }}.
+
Pokračovať Zahodiť
+
+
+
+
+
+
+ {{ toast.text }}
+
+
+
+
+