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