zakladna implementacia by Codex

This commit is contained in:
2026-08-01 07:28:21 +02:00
parent 1b71803407
commit a5973a5168
21 changed files with 1629 additions and 46 deletions

View File

@ -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.

View File

@ -1,10 +1,12 @@
<!DOCTYPE html>
<html lang="">
<html lang="sk">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
<meta name="theme-color" content="#f8fafc">
<meta name="description" content="Lokálna analýza farebných plôch a palety mozaiky.">
<title>Mozaic analýza farebných plôch</title>
</head>
<body>
<div id="app"></div>

View File

@ -1,11 +1,5 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import EditorView from '@/views/EditorView.vue'
</script>
<template>
<h1>You did it!</h1>
<p>
Visit <a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to read the
documentation
</p>
</template>
<style scoped></style>
<template><EditorView /></template>

View File

@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { ColorGroup } from '@/types/mosaic'
import { rgbToHex } from '@/utils/colors'
import { useMosaicStore } from '@/stores/mosaic'
const props = defineProps<{ group: ColorGroup; selected: boolean; totalPixels: number }>()
const store = useMosaicStore()
const mergeTarget = ref('')
const otherGroups = computed(() => store.groups.filter((group) => group.id !== props.group.id))
function merge(): void {
if (mergeTarget.value) store.mergeGroups(props.group.id, mergeTarget.value)
}
</script>
<template>
<article class="group-card" :class="{ selected }">
<button class="group-main" type="button" @click="store.selectedGroupId = selected ? null : group.id">
<span class="number">{{ group.number }}</span>
<span class="swatch" :style="{ backgroundColor: rgbToHex(group.color) }" />
<span class="group-data">
<b>{{ rgbToHex(group.color) }}</b>
<small>RGB {{ group.color.r }}, {{ group.color.g }}, {{ group.color.b }} · L {{ group.labColor.l.toFixed(1) }}</small>
</span>
<span class="count">{{ group.regionIds.length }}×</span>
</button>
<div v-if="selected" class="group-details">
<span>{{ totalPixels.toLocaleString('sk-SK') }} pixelov</span>
<label class="color-edit">Farba <input type="color" :value="rgbToHex(group.color)" @change="store.setGroupColor(group.id, ($event.target as HTMLInputElement).value)" /></label>
<div v-if="otherGroups.length" class="merge-row">
<select v-model="mergeTarget" aria-label="Cieľová skupina">
<option value="">Zlúčiť do</option>
<option v-for="target in otherGroups" :key="target.id" :value="target.id">Skupina {{ target.number }} · {{ rgbToHex(target.color) }}</option>
</select>
<button class="button small" type="button" :disabled="!mergeTarget" @click="merge">Zlúčiť</button>
</div>
</div>
</article>
</template>
<style scoped>
.group-card { margin-top: 7px; overflow: hidden; border: 1px solid #dce2e9; border-radius: 9px; background: white; }
.group-card.selected { border-color: #7aa7ee; box-shadow: 0 0 0 2px #dbeafe; }
.group-main { display: grid; width: 100%; grid-template-columns: 27px 29px minmax(0,1fr) auto; align-items: center; gap: 8px; padding: 9px; border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; }
.number { display: grid; width: 25px; height: 25px; place-items: center; border-radius: 50%; background: #eef2f6; color: #334155; font-size: .73rem; font-weight: 750; }
.swatch { width: 27px; height: 27px; border: 1px solid #cbd5e1; border-radius: 6px; }
.group-data { min-width: 0; }
.group-data b, .group-data small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.group-data b { font-size: .77rem; }
.group-data small { margin-top: 2px; color: #64748b; font-size: .65rem; }
.count { color: #64748b; font-size: .71rem; }
.group-details { display: grid; gap: 8px; padding: 8px 9px; border-top: 1px solid #e6eaf0; background: #f8fafc; color: #64748b; font-size: .69rem; }
.color-edit { display: flex; align-items: center; justify-content: space-between; }
.color-edit input { width: 48px; height: 26px; padding: 1px; border: 1px solid #cbd5e1; border-radius: 5px; background: white; }
.merge-row { display: flex; gap: 6px; }
.merge-row select { min-width: 0; flex: 1; }
</style>

View File

@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useMosaicStore } from '@/stores/mosaic'
import ColorGroupItem from './ColorGroupItem.vue'
const store = useMosaicStore()
const { groups, regions, selectedGroupId } = storeToRefs(store)
const regionMap = computed(() => new Map(regions.value.map((region) => [region.id, region])))
const pixelsFor = (ids: string[]) => ids.reduce((sum, id) => sum + (regionMap.value.get(id)?.pixelCount ?? 0), 0)
</script>
<template>
<section class="panel-section">
<div class="section-heading">
<h2>Farebné skupiny</h2>
<div class="heading-actions">
<button v-if="groups.length" type="button" @click="store.regroup()">Prepočítať</button>
<span>{{ groups.length }}</span>
</div>
</div>
<p v-if="!groups.length" class="empty-copy">Po kliknutí do obrázka sa tu zobrazia farby zoradené od najtmavšej.</p>
<ColorGroupItem
v-for="group in groups"
:key="group.id"
:group="group"
:selected="group.id === selectedGroupId"
:total-pixels="pixelsFor(group.regionIds)"
/>
</section>
</template>
<style scoped>
.heading-actions { display: flex; align-items: center; gap: 8px; }
.heading-actions button { padding: 0; border: 0; background: transparent; color: #2563eb; font-size: .66rem; cursor: pointer; }
.heading-actions span { color: #718096; font-size: .7rem; }
</style>

View File

@ -0,0 +1,47 @@
<script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useMosaicStore } from '@/stores/mosaic'
defineEmits<{ resetViewport: [] }>()
const store = useMosaicStore()
const { hasImage, canUndo, canRedo, regions } = storeToRefs(store)
const imageInput = ref<HTMLInputElement | null>(null)
const projectInput = ref<HTMLInputElement | null>(null)
function loadImage(files: FileList | null): void {
const file = files?.[0]
if (file) void store.loadImage(file, file.name)
}
function importProject(files: FileList | null): void {
const file = files?.[0]
if (file) void store.importProjectFile(file)
}
</script>
<template>
<header class="toolbar">
<div class="brand"><span class="brand-mark">M</span><span>Mozaic</span></div>
<div class="toolbar-actions">
<button class="button primary" type="button" @click="imageInput?.click()">Načítať obrázok</button>
<button class="icon-button" type="button" :disabled="!canUndo" title="Späť (Ctrl+Z)" @click="store.undo"></button>
<button class="icon-button" type="button" :disabled="!canRedo" title="Znova (Ctrl+Y)" @click="store.redo"></button>
<button class="button" type="button" :disabled="!hasImage" @click="$emit('resetViewport')">Prispôsobiť</button>
<span class="divider" />
<button class="button" type="button" @click="projectInput?.click()">Import JSON</button>
<button class="button danger-quiet" type="button" :disabled="!regions.length" @click="store.clearResults">Vymazať výsledky</button>
</div>
<input ref="imageInput" hidden type="file" accept="image/png,image/jpeg,image/webp" @change="loadImage(($event.target as HTMLInputElement).files)" />
<input ref="projectInput" hidden type="file" accept="application/json,.json" @change="importProject(($event.target as HTMLInputElement).files)" />
</header>
</template>
<style scoped>
.toolbar { position: sticky; top: 0; z-index: 10; display: flex; min-height: 58px; align-items: center; gap: 24px; padding: 8px 16px; border-bottom: 1px solid #dbe1e8; background: rgba(255,255,255,.96); }
.brand { display: flex; align-items: center; gap: 9px; color: #172033; font-weight: 760; letter-spacing: -.02em; }
.brand-mark { display: grid; width: 30px; height: 30px; place-items: center; border-radius: 8px; background: #1e293b; color: white; }
.toolbar-actions { display: flex; min-width: 0; align-items: center; gap: 7px; overflow-x: auto; }
.divider { width: 1px; height: 26px; margin: 0 2px; background: #dbe1e8; }
@media (max-width: 700px) { .toolbar { align-items: flex-start; gap: 10px; padding: 8px 10px; } .brand span:last-child { display: none; } }
</style>

View File

@ -0,0 +1,48 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useMosaicStore } from '@/stores/mosaic'
import { canvasToBlob, createPaletteCsv, downloadBlob, renderExportCanvas } from '@/utils/projectExport'
const store = useMosaicStore()
const { imageCanvas, imageName, imageWidth, imageHeight, regions, groups, settings } = storeToRefs(store)
const baseName = () => (imageName.value.replace(/\.[^.]+$/, '').replace(/[^\p{L}\p{N}_-]+/gu, '-') || 'mozaika')
async function exportPng(): Promise<void> {
if (!imageCanvas.value) return store.notify('Najprv načítajte obrázok.', 'warning')
try {
const canvas = renderExportCanvas(imageCanvas.value, { width: imageWidth.value, height: imageHeight.value }, regions.value, groups.value, settings.value.exportOverlay)
downloadBlob(await canvasToBlob(canvas), `${baseName()}-ocislovane.png`)
} catch (error) { store.notify(error instanceof Error ? error.message : 'Export PNG zlyhal.', 'error') }
}
function exportCsv(): void {
if (!groups.value.length) return store.notify('Paleta je zatiaľ prázdna.', 'warning')
downloadBlob(new Blob([createPaletteCsv(groups.value, regions.value)], { type: 'text/csv;charset=utf-8' }), `${baseName()}-paleta.csv`)
}
async function exportJson(): Promise<void> {
if (!imageWidth.value) return store.notify('Nie je čo exportovať.', 'warning')
const project = await store.serializeProject(true)
downloadBlob(new Blob([JSON.stringify(project)], { type: 'application/json' }), `${baseName()}-projekt.json`)
if (!project.imageDataUrl) store.notify('Obrázok je väčší než 5 MB. Pri importe JSON ho bude potrebné znova vybrať.', 'info')
}
</script>
<template>
<section class="panel-section export-section">
<h2>Export</h2>
<label class="check"><input v-model="settings.exportOverlay" type="checkbox" /> Zahrnúť farebné prekrytie do PNG</label>
<div class="export-grid">
<button class="button" type="button" :disabled="!imageCanvas" @click="exportPng">Očíslované PNG</button>
<button class="button" type="button" :disabled="!groups.length" @click="exportCsv">Paleta CSV</button>
<button class="button" type="button" :disabled="!imageWidth" @click="exportJson">Projekt JSON</button>
</div>
</section>
</template>
<style scoped>
.export-section { border-bottom: 0; }
.check { display: flex; align-items: center; gap: 7px; margin-top: 10px; color: #475569; font-size: .72rem; }
.export-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 11px; }
.export-grid .button:last-child { grid-column: 1 / -1; }
</style>

View File

@ -0,0 +1,39 @@
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits<{ file: [file: File] }>()
const isDragging = ref(false)
const input = ref<HTMLInputElement | null>(null)
function accept(files: FileList | null): void {
const file = files?.[0]
if (file) emit('file', file)
}
</script>
<template>
<div
class="dropzone"
:class="{ dragging: isDragging }"
@dragenter.prevent="isDragging = true"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="isDragging = false; accept($event.dataTransfer?.files ?? null)"
>
<div class="drop-icon" aria-hidden="true"></div>
<h2>Načítajte obrázok mozaiky</h2>
<p>Presuňte sem PNG, JPEG alebo WebP, prípadne ho vyberte z počítača.</p>
<button class="button primary" type="button" @click="input?.click()">Vybrať obrázok</button>
<small>Obrázok zostáva vo vašom prehliadači a nikam sa neodosiela.</small>
<input ref="input" hidden type="file" accept="image/png,image/jpeg,image/webp" @change="accept(($event.target as HTMLInputElement).files)" />
</div>
</template>
<style scoped>
.dropzone { position: absolute; inset: 18px; display: grid; place-content: center; justify-items: center; gap: 12px; padding: 32px; border: 2px dashed #cbd5e1; border-radius: 14px; background: #f8fafc; text-align: center; color: #475569; transition: border-color .12s, background .12s; }
.dropzone.dragging { border-color: #2563eb; background: #eff6ff; }
.dropzone h2 { margin: 0; color: #172033; font-size: 1.15rem; }
.dropzone p { max-width: 430px; margin: 0; }
.dropzone small { margin-top: 8px; color: #64748b; }
.drop-icon { display: grid; width: 52px; height: 52px; place-items: center; border-radius: 50%; background: #e2e8f0; color: #334155; font-size: 30px; font-weight: 300; }
</style>

View File

@ -0,0 +1,175 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useMosaicStore } from '@/stores/mosaic'
import { rgbToHex, relativeLuminance } from '@/utils/colors'
import { forEachMaskRun } from '@/utils/regionMask'
import { useCanvasViewport } from '@/composables/useCanvasViewport'
import ImageDropzone from './ImageDropzone.vue'
const store = useMosaicStore()
const { imageCanvas, imageWidth, imageHeight, regions, groups, selectedGroupId, selectedRegionId, settings, isAnalyzing } = storeToRefs(store)
const host = ref<HTMLDivElement | null>(null)
const canvas = ref<HTMLCanvasElement | null>(null)
const { viewport, fit, zoomAt } = useCanvasViewport()
let observer: ResizeObserver | null = null
let dragStart: { x: number; y: number; viewportX: number; viewportY: number } | null = null
let moved = false
function resize(): void {
const element = canvas.value
const container = host.value
if (!element || !container) return
const rectangle = container.getBoundingClientRect()
const ratio = window.devicePixelRatio || 1
element.width = Math.max(1, Math.round(rectangle.width * ratio))
element.height = Math.max(1, Math.round(rectangle.height * ratio))
element.style.width = `${rectangle.width}px`
element.style.height = `${rectangle.height}px`
draw()
}
function resetViewport(): void {
const rectangle = host.value?.getBoundingClientRect()
if (!rectangle) return
fit(rectangle.width, rectangle.height, imageWidth.value, imageHeight.value)
draw()
}
function draw(): void {
const element = canvas.value
if (!element) return
const context = element.getContext('2d')
if (!context) return
const ratio = window.devicePixelRatio || 1
const cssWidth = element.width / ratio
const cssHeight = element.height / ratio
context.setTransform(ratio, 0, 0, ratio, 0, 0)
context.clearRect(0, 0, cssWidth, cssHeight)
context.fillStyle = '#e7ebf0'
context.fillRect(0, 0, cssWidth, cssHeight)
if (!imageCanvas.value) return
context.save()
context.translate(viewport.x, viewport.y)
context.scale(viewport.scale, viewport.scale)
context.imageSmoothingEnabled = viewport.scale < 3
context.drawImage(imageCanvas.value, 0, 0)
const groupMap = new Map(groups.value.map((group) => [group.id, group]))
for (const region of regions.value) {
const group = groupMap.get(region.groupId)
if (!group) continue
const selected = group.id === selectedGroupId.value
if (!selected && selectedGroupId.value && settings.value.otherGroupsMode === 'hidden') continue
context.globalAlpha = selected ? 0.52 : selectedGroupId.value && settings.value.otherGroupsMode === 'dim' ? 0.08 : 0.24
context.fillStyle = rgbToHex(group.color)
forEachMaskRun(region.mask, (y, xStart, xEnd) => context.fillRect(xStart, y, xEnd - xStart + 1, 1))
}
context.restore()
context.save()
context.setTransform(ratio, 0, 0, ratio, 0, 0)
context.textAlign = 'center'
context.textBaseline = 'middle'
context.font = '700 14px system-ui, sans-serif'
for (const region of regions.value) {
const group = groupMap.get(region.groupId)
if (!group) continue
if (selectedGroupId.value && settings.value.otherGroupsMode === 'hidden' && group.id !== selectedGroupId.value) continue
const x = viewport.x + region.labelPosition.x * viewport.scale
const y = viewport.y + region.labelPosition.y * viewport.scale
const active = region.id === selectedRegionId.value
context.beginPath()
context.arc(x, y, active ? 13 : 11, 0, Math.PI * 2)
context.fillStyle = relativeLuminance(group.color) > 0.45 ? 'rgba(255,255,255,.94)' : 'rgba(15,23,42,.9)'
context.fill()
if (active) {
context.lineWidth = 2
context.strokeStyle = '#2563eb'
context.stroke()
}
context.fillStyle = relativeLuminance(group.color) > 0.45 ? '#0f172a' : '#ffffff'
context.fillText(String(group.number), x, y + 0.5)
}
context.restore()
}
function pointerDown(event: PointerEvent): void {
if (!imageCanvas.value) return
canvas.value?.setPointerCapture(event.pointerId)
dragStart = { x: event.clientX, y: event.clientY, viewportX: viewport.x, viewportY: viewport.y }
moved = false
}
function pointerMove(event: PointerEvent): void {
if (!dragStart) return
const dx = event.clientX - dragStart.x
const dy = event.clientY - dragStart.y
if (Math.hypot(dx, dy) > 4) moved = true
if (moved) {
viewport.x = dragStart.viewportX + dx
viewport.y = dragStart.viewportY + dy
draw()
}
}
function pointerUp(event: PointerEvent): void {
const start = dragStart
dragStart = null
if (!start || moved || !canvas.value || !imageCanvas.value) return
const rectangle = canvas.value.getBoundingClientRect()
const x = Math.floor((event.clientX - rectangle.left - viewport.x) / viewport.scale)
const y = Math.floor((event.clientY - rectangle.top - viewport.y) / viewport.scale)
if (x < 0 || y < 0 || x >= imageWidth.value || y >= imageHeight.value) return
store.addRegionAt(x, y)
}
function wheel(event: WheelEvent): void {
if (!imageCanvas.value || !canvas.value) return
event.preventDefault()
const rectangle = canvas.value.getBoundingClientRect()
zoomAt(event.deltaY < 0 ? 1.13 : 1 / 1.13, event.clientX - rectangle.left, event.clientY - rectangle.top)
draw()
}
watch([imageCanvas, imageWidth, imageHeight], async () => { await nextTick(); resetViewport() })
watch([regions, groups, selectedGroupId, selectedRegionId, () => settings.value.otherGroupsMode], draw, { deep: true })
onMounted(() => {
observer = new ResizeObserver(resize)
if (host.value) observer.observe(host.value)
resize()
})
onBeforeUnmount(() => observer?.disconnect())
defineExpose({ resetViewport })
</script>
<template>
<div ref="host" class="canvas-host" :class="{ analyzing: isAnalyzing }">
<canvas
ref="canvas"
aria-label="Editor mozaiky"
@pointerdown="pointerDown"
@pointermove="pointerMove"
@pointerup="pointerUp"
@pointercancel="dragStart = null"
@wheel="wheel"
/>
<ImageDropzone v-if="!imageCanvas" @file="store.loadImage($event, $event.name)" />
<div v-if="isAnalyzing" class="busy"><span class="spinner" />Analyzujem plochu</div>
<div v-if="imageCanvas" class="canvas-help">Kliknutie označí plochu · ťahanie posúva · koliesko približuje</div>
</div>
</template>
<style scoped>
.canvas-host { position: relative; width: 100%; height: 100%; min-height: 460px; overflow: hidden; background: #e7ebf0; }
canvas { display: block; touch-action: none; cursor: crosshair; }
.canvas-host:active canvas { cursor: grabbing; }
.canvas-host.analyzing canvas { cursor: progress; }
.canvas-help { position: absolute; right: 12px; bottom: 12px; padding: 6px 9px; border: 1px solid rgba(148,163,184,.6); border-radius: 7px; background: rgba(255,255,255,.88); color: #475569; font-size: .72rem; pointer-events: none; backdrop-filter: blur(4px); }
.busy { position: absolute; top: 14px; left: 50%; display: flex; align-items: center; gap: 8px; transform: translateX(-50%); padding: 8px 12px; border-radius: 8px; background: #0f172a; color: white; font-size: .82rem; box-shadow: 0 5px 20px #0f172a33; }
.spinner { width: 13px; height: 13px; border: 2px solid #ffffff55; border-top-color: white; border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 800px) { .canvas-host { min-height: 55vh; } .canvas-help { display: none; } }
</style>

View File

@ -0,0 +1,51 @@
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useMosaicStore } from '@/stores/mosaic'
import { rgbToHex } from '@/utils/colors'
const store = useMosaicStore()
const { regions, groups, selectedGroupId, selectedRegionId } = storeToRefs(store)
const visibleRegions = computed(() => selectedGroupId.value
? regions.value.filter((region) => region.groupId === selectedGroupId.value)
: regions.value)
</script>
<template>
<section class="panel-section">
<div class="section-heading">
<h2>{{ selectedGroupId ? 'Plochy vo vybranej skupine' : 'Rozpoznané plochy' }}</h2>
<span>{{ visibleRegions.length }}</span>
</div>
<p v-if="!visibleRegions.length" class="empty-copy">Zatiaľ nie je označená žiadna plocha.</p>
<article
v-for="(region, index) in visibleRegions"
:key="region.id"
class="region-row"
:class="{ selected: region.id === selectedRegionId }"
@click="store.selectedRegionId = region.id; store.selectedGroupId = region.groupId"
>
<span class="region-index">{{ index + 1 }}</span>
<span class="region-info"><b>{{ rgbToHex(region.color) }}</b><small>{{ region.pixelCount.toLocaleString('sk-SK') }} px</small></span>
<select :value="region.groupId" aria-label="Farebná skupina" @click.stop @change="store.moveRegion(region.id, ($event.target as HTMLSelectElement).value)">
<option v-for="group in groups" :key="group.id" :value="group.id">Sk. {{ group.number }}</option>
</select>
<button class="row-action" type="button" title="Oddeliť do novej skupiny" :disabled="(groups.find((g) => g.id === region.groupId)?.regionIds.length ?? 0) < 2" @click.stop="store.splitRegion(region.id)">Rozdeliť</button>
<button class="delete-button" type="button" title="Odstrániť plochu" @click.stop="store.removeRegion(region.id)">×</button>
</article>
</section>
</template>
<style scoped>
.region-row { display: grid; grid-template-columns: 22px minmax(64px,1fr) 62px auto 25px; align-items: center; gap: 6px; margin-top: 6px; padding: 7px; border: 1px solid #e2e7ed; border-radius: 8px; cursor: pointer; }
.region-row.selected { border-color: #7aa7ee; background: #eff6ff; }
.region-index { color: #94a3b8; font-size: .67rem; }
.region-info { min-width: 0; }
.region-info b, .region-info small { display: block; font-size: .68rem; }
.region-info small { margin-top: 2px; color: #64748b; }
.region-row select { min-width: 0; padding: 5px 3px; font-size: .67rem; }
.row-action { border: 0; background: transparent; color: #2563eb; font-size: .64rem; cursor: pointer; }
.row-action:disabled { color: #aab4c1; cursor: default; }
.delete-button { width: 25px; height: 25px; padding: 0; border: 0; border-radius: 5px; background: transparent; color: #64748b; font-size: 18px; cursor: pointer; }
.delete-button:hover { background: #fee2e2; color: #b91c1c; }
</style>

View File

@ -0,0 +1,65 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useMosaicStore } from '@/stores/mosaic'
const store = useMosaicStore()
const { settings } = storeToRefs(store)
function updateColorTolerance(event: Event): void {
store.setColorTolerance(Number((event.target as HTMLInputElement).value))
}
</script>
<template>
<section class="panel-section">
<h2>Nastavenia analýzy</h2>
<label class="range-field">
<span><b>Tolerancia výberu</b><output>{{ settings.floodTolerance }}</output></span>
<input v-model.number="settings.floodTolerance" type="range" min="2" max="100" step="1" />
<small>Vyššia hodnota spojí väčšie farebné odchýlky v jednej ploche.</small>
</label>
<label class="range-field">
<span><b>Podobnosť farieb (ΔE)</b><output>{{ settings.colorTolerance }}</output></span>
<input :value="settings.colorTolerance" type="range" min="1" max="40" step="1" @change="updateColorTolerance" />
<small>Po zmene sa farebné skupiny automaticky prepočítajú.</small>
</label>
<div class="field-row">
<label>
<span>Susednosť</span>
<select v-model.number="settings.connectivity">
<option :value="4">4-smery</option>
<option :value="8">8-smerov</option>
</select>
</label>
<label>
<span>Ostatné skupiny</span>
<select v-model="settings.otherGroupsMode">
<option value="normal">Normálne</option>
<option value="dim">Stlmiť</option>
<option value="hidden">Skryť</option>
</select>
</label>
</div>
<details>
<summary>Rozšírené</summary>
<label class="range-field compact">
<span><b>Prahová tmavosť obrysu</b><output>{{ settings.darkBoundaryThreshold.toFixed(3) }}</output></span>
<input v-model.number="settings.darkBoundaryThreshold" type="range" min="0" max="0.2" step="0.005" />
</label>
</details>
</section>
</template>
<style scoped>
.range-field { display: grid; gap: 7px; margin-top: 14px; }
.range-field > span { display: flex; justify-content: space-between; gap: 12px; font-size: .78rem; }
.range-field b { font-weight: 650; }
.range-field output { min-width: 34px; padding: 1px 6px; border-radius: 5px; background: #e8edf3; color: #334155; text-align: center; font-variant-numeric: tabular-nums; }
.range-field small { color: #718096; font-size: .69rem; line-height: 1.35; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin-top: 14px; }
.field-row label { display: grid; gap: 5px; color: #475569; font-size: .72rem; }
select { width: 100%; }
details { margin-top: 13px; color: #475569; font-size: .76rem; }
summary { cursor: pointer; }
.compact { margin-bottom: 4px; }
</style>

View File

@ -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 }
}

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

View File

@ -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<MosaicRegion, 'mask'> {
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
}

View File

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

View File

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

View File

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

View File

@ -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<Blob> {
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<string> {
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<ProjectFile> {
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<ProjectFile>
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'
}

View File

@ -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<IDBDatabase> {
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<void> {
const database = await openDatabase()
await new Promise<void>((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<StoredProject | null> {
const database = await openDatabase()
const value = await new Promise<StoredProject | undefined>((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<void> {
const database = await openDatabase()
await new Promise<void>((resolve, reject) => {
const request = database.transaction(STORE, 'readwrite').objectStore(STORE).delete(LAST_PROJECT)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
database.close()
}

View File

@ -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
}

View File

@ -0,0 +1,109 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useMosaicStore } from '@/stores/mosaic'
import EditorToolbar from '@/components/EditorToolbar.vue'
import MosaicCanvas from '@/components/MosaicCanvas.vue'
import SettingsPanel from '@/components/SettingsPanel.vue'
import ColorGroupList from '@/components/ColorGroupList.vue'
import RegionList from '@/components/RegionList.vue'
import ExportPanel from '@/components/ExportPanel.vue'
const store = useMosaicStore()
const { imageName, imageWidth, imageHeight, regions, groups, toasts, hasRecovery, recoveryTimestamp } = storeToRefs(store)
const mosaicCanvas = ref<InstanceType<typeof MosaicCanvas> | null>(null)
function keyboard(event: KeyboardEvent): void {
const target = event.target as HTMLElement | null
if (target?.matches('input, select, textarea, [contenteditable="true"]')) return
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
event.preventDefault()
if (event.shiftKey) store.redo()
else store.undo()
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'y') {
event.preventDefault()
store.redo()
} else if (event.key === 'Delete' && store.selectedRegionId) {
event.preventDefault()
store.removeRegion(store.selectedRegionId)
} else if (event.key === 'Escape') {
store.selectedRegionId = null
store.selectedGroupId = null
}
}
onMounted(() => {
window.addEventListener('keydown', keyboard)
void store.checkRecovery()
})
onBeforeUnmount(() => window.removeEventListener('keydown', keyboard))
</script>
<template>
<div class="app-shell">
<EditorToolbar @reset-viewport="mosaicCanvas?.resetViewport()" />
<div v-if="hasRecovery" class="recovery-banner">
<span><b>Nájdená rozpracovaná práca.</b> Uložená {{ new Date(recoveryTimestamp).toLocaleString('sk-SK') }}.</span>
<div><button class="button primary" type="button" @click="store.restoreRecovery">Pokračovať</button><button class="button" type="button" @click="store.dismissRecovery">Zahodiť</button></div>
</div>
<main class="workspace">
<section class="editor-pane"><MosaicCanvas ref="mosaicCanvas" /></section>
<aside class="control-panel">
<section class="panel-section image-info">
<div class="section-heading"><h2>Obrázok</h2><span class="local-badge">Iba lokálne</span></div>
<template v-if="imageWidth">
<b class="filename" :title="imageName">{{ imageName }}</b>
<div class="stats"><span>{{ imageWidth }} × {{ imageHeight }} px</span><span>{{ regions.length }} plôch</span><span>{{ groups.length }} skupín</span></div>
</template>
<p v-else class="empty-copy">Načítajte obrázok a klikajte dovnútra samostatných farebných plôch.</p>
</section>
<SettingsPanel />
<ColorGroupList />
<RegionList />
<ExportPanel />
</aside>
</main>
<div class="toast-stack" aria-live="polite">
<button v-for="toast in toasts" :key="toast.id" type="button" class="toast" :class="toast.type" @click="store.dismissToast(toast.id)">{{ toast.text }}</button>
</div>
</div>
</template>
<style>
:root { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #1e293b; background: #f2f4f7; font-synthesis: none; text-rendering: optimizeLegibility; }
* { box-sizing: border-box; }
html, body, #app { min-width: 320px; min-height: 100%; margin: 0; }
body { min-height: 100vh; overflow: hidden; }
button, input, select { font: inherit; }
button:focus-visible, input:focus-visible, select:focus-visible, canvas:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.app-shell { min-height: 100vh; }
.workspace { display: grid; height: calc(100vh - 58px); grid-template-columns: minmax(0, 1fr) 360px; }
.editor-pane { min-width: 0; min-height: 0; border-right: 1px solid #d9e0e7; }
.control-panel { min-width: 0; overflow-y: auto; background: #f8fafc; }
.panel-section { padding: 15px; border-bottom: 1px solid #dfe5eb; }
.panel-section h2 { margin: 0; color: #263244; font-size: .82rem; font-weight: 720; letter-spacing: .01em; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.section-heading > span { color: #718096; font-size: .7rem; }
.empty-copy { margin: 10px 0 0; color: #718096; font-size: .73rem; line-height: 1.5; }
.button { min-height: 31px; padding: 6px 10px; border: 1px solid #cbd5e1; border-radius: 7px; background: #fff; color: #334155; font-size: .73rem; font-weight: 600; white-space: nowrap; cursor: pointer; }
.button:hover:not(:disabled) { border-color: #94a3b8; background: #f8fafc; }
.button.primary { border-color: #1d4ed8; background: #2563eb; color: white; }
.button.primary:hover:not(:disabled) { border-color: #1e40af; background: #1d4ed8; }
.button.danger-quiet { color: #b42318; }
.button.small { min-height: 28px; padding: 4px 8px; font-size: .67rem; }
.button:disabled, .icon-button:disabled { opacity: .42; cursor: not-allowed; }
.icon-button { display: grid; width: 32px; height: 32px; place-items: center; padding: 0; border: 1px solid #cbd5e1; border-radius: 7px; background: white; color: #334155; font-size: 19px; cursor: pointer; }
select { min-height: 31px; padding: 5px 7px; border: 1px solid #cbd5e1; border-radius: 7px; background: white; color: #334155; font-size: .72rem; }
input[type="range"] { width: 100%; accent-color: #2563eb; }
input[type="checkbox"] { accent-color: #2563eb; }
.image-info .filename { display: block; overflow: hidden; margin-top: 9px; color: #334155; font-size: .76rem; text-overflow: ellipsis; white-space: nowrap; }
.stats { display: flex; flex-wrap: wrap; gap: 5px 11px; margin-top: 7px; color: #64748b; font-size: .68rem; }
.local-badge { padding: 3px 6px; border-radius: 9px; background: #e8f5ee; color: #247044 !important; font-weight: 650; }
.recovery-banner { position: fixed; z-index: 30; top: 68px; left: 50%; display: flex; max-width: min(680px, calc(100vw - 24px)); align-items: center; gap: 16px; transform: translateX(-50%); padding: 11px 13px; border: 1px solid #93b4e8; border-radius: 10px; background: #eff6ff; box-shadow: 0 10px 30px #0f172a22; color: #334155; font-size: .76rem; }
.recovery-banner > div { display: flex; gap: 6px; }
.toast-stack { position: fixed; z-index: 50; right: 16px; bottom: 16px; display: grid; width: min(370px, calc(100vw - 32px)); gap: 8px; }
.toast { padding: 11px 13px; border: 1px solid #cbd5e1; border-left-width: 4px; border-radius: 8px; background: white; box-shadow: 0 8px 25px #0f172a22; color: #334155; font-size: .76rem; line-height: 1.4; text-align: left; cursor: pointer; }
.toast.success { border-left-color: #16a34a; }.toast.warning { border-left-color: #d97706; }.toast.error { border-left-color: #dc2626; }.toast.info { border-left-color: #2563eb; }
@media (max-width: 950px) { .workspace { grid-template-columns: minmax(0,1fr) 330px; } }
@media (max-width: 800px) { body { overflow: auto; } .workspace { height: auto; grid-template-columns: 1fr; } .editor-pane { height: 58vh; border-right: 0; border-bottom: 1px solid #d9e0e7; } .control-panel { overflow: visible; } .recovery-banner { align-items: flex-start; } }
</style>