Compare commits
9 Commits
8eaab76514
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e10d9dd31 | |||
| 27f96afb89 | |||
| dc7341c97f | |||
| 47a4c1195f | |||
| d753697840 | |||
| 6bcac21652 | |||
| fc877c99ce | |||
| a5973a5168 | |||
| 1b71803407 |
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/dist
|
||||
45
AGENTS.md
Normal file
@ -0,0 +1,45 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
The application lives in `frontend/`; run Node and npm commands there. Vue source is under `frontend/src/`:
|
||||
|
||||
- `components/` contains the canvas editor, toolbar, settings, lists, and export UI.
|
||||
- `views/` owns page-level layout; `stores/mosaic.ts` is the primary Pinia state and workflow coordinator.
|
||||
- `utils/` contains framework-light color conversion, flood fill, RLE masks, persistence, and export logic.
|
||||
- `types/` defines shared TypeScript models; `composables/` contains viewport behavior.
|
||||
- `assets/` is for bundled assets, while `public/` contains favicons and static files copied unchanged.
|
||||
|
||||
Repository documentation is in `README.md`; screenshots belong in `doc/`. Generated `frontend/dist/` and root `dist/` outputs are not source files.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
From `frontend/`:
|
||||
|
||||
```bash
|
||||
npm install # install dependencies
|
||||
npm run dev # start the Vite development server
|
||||
npm run type-check # run vue-tsc
|
||||
npm run lint # run Oxlint and ESLint with automatic fixes
|
||||
npm run format # format src/ with Prettier
|
||||
npm run build # type-check and create frontend/dist/
|
||||
npm run preview # serve the production build locally
|
||||
```
|
||||
|
||||
Node.js must satisfy `^22.18.0 || >=24.12.0`. The root `build.bat` creates the deployable root `dist/` on Windows.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
Use Vue 3 Composition API and `<script setup lang="ts">`. Indent with two spaces, omit semicolons, prefer single quotes, and keep lines near 100 characters. Use PascalCase for Vue components (`MosaicCanvas.vue`), camelCase for functions and variables, and `useXxx` for composables. Keep UI text Slovak and code identifiers English. Avoid `any`; share types from `src/types/`. Keep large `ImageData`, `Blob`, canvas, and typed-array values shallow or marked raw.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
No test runner or coverage threshold is currently configured. Every change must pass `npm run type-check`, `npm run lint`, and `npm run build`. Keep algorithmic logic in `utils/` so it can be tested without browser automation. If a test framework is introduced, use focused `*.test.ts` files and cover color math, flood fill, RLE operations, serialization, and backward compatibility.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
History uses short, descriptive Slovak commit subjects (for example, `aktualizovany README.md`) rather than Conventional Commits. Keep each commit focused and describe the outcome. Pull requests should include a concise summary, verification commands and results, linked issue when applicable, and screenshots for visible UI changes. Call out project-format or IndexedDB compatibility changes explicitly.
|
||||
|
||||
## Security & Deployment
|
||||
|
||||
Image processing must remain local to the browser; do not add uploads or external APIs without explicit approval. `deploy.bat` targets a specific remote host and deletes its deployment directory before syncing—never run it without repository-owner authorization.
|
||||
237
README.md
@ -1,2 +1,239 @@
|
||||
# Mozaic
|
||||
|
||||
<img src="frontend/public/icon-192.png" alt="Ikona aplikácie Mozaic" width="144" align="left" hspace="16" style="margin: 0 16px 8px 0;">
|
||||
|
||||
**Online verzia:** [https://mozaic.tpsoft.org](https://mozaic.tpsoft.org)
|
||||
|
||||
Mozaic je webová aplikácia na prácu s fotografiami mozaík, maľovaných predlôh a ďalších obrazov rozdelených na samostatné farebné plochy. Používateľ kliknutím označuje jednotlivé uzavreté plochy a aplikácia pre každú z nich nájde súvislú oblasť, vypočíta reprezentatívnu farbu a priradí ju do farebnej skupiny.
|
||||
|
||||
Cieľom je uľahčiť identifikáciu podobných farieb, zoradiť ich od najtmavšej po najsvetlejšiu a očíslovať príslušné plochy. Takýto prehľad môže pomôcť pri plánovaní miešania farieb a pri systematickom maľovaní alebo skladaní mozaiky po jednotlivých farebných skupinách.
|
||||
|
||||
Aplikácia je určená najmä autorom mozaík, maliarom a každému, kto potrebuje z členitého farebného návrhu pripraviť praktickú paletu a očíslovanú pracovnú predlohu. Aktuálna verzia je lokálne fungujúce MVP: plochy sa označujú jednotlivo kliknutím, nejde o automatické rozpoznanie všetkých plôch celého obrázka naraz.
|
||||
|
||||
<br clear="left">
|
||||
|
||||
## Ukážka rozhrania
|
||||
|
||||

|
||||
|
||||
## Na čo Mozaic slúži
|
||||
|
||||
Typický pracovný postup začína načítaním fotografie alebo návrhu mozaiky. Používateľ klikne dovnútra uzavretej farebnej plochy a Mozaic pomocou flood fill algoritmu vyhľadá jej súvislé pixely. Z viacerých vnútorných pixelov určí reprezentatívnu farbu, porovná ju s už nájdenými farbami a zaradí plochu do zodpovedajúcej skupiny.
|
||||
|
||||
Farebné skupiny sa automaticky číslujú a zoraďujú podľa svetlosti. Výberom skupiny možno zvýrazniť všetky jej plochy a ostatné plochy ponechať, stlmiť alebo skryť. Používateľ tak môže pri práci s fyzickou mozaikou alebo obrazom spracovať všetky plochy jedného čísla, potom prejsť k svetlejšej farbe a zvolenú farbu postupne meniť alebo zosvetľovať. Aplikácia samotná nesleduje stav vymaľovania jednotlivých plôch.
|
||||
|
||||
Výsledok možno upraviť, exportovať ako očíslovaný obrázok a uložiť spolu s farebnou paletou alebo projektovými dátami na neskoršie pokračovanie.
|
||||
|
||||
## Hlavné funkcie
|
||||
|
||||
- **Načítanie obrázka:** podporované sú súbory PNG, JPEG a WebP vybrané cez tlačidlo alebo pretiahnuté do vyznačenej plochy.
|
||||
- **Canvas editor:** obrázok sa zobrazí so zachovaným pomerom strán; koliesko myši mení priblíženie, ťahanie posúva pohľad a tlačidlo `Prispôsobiť` obnoví vhodné zobrazenie.
|
||||
- **Označenie plochy kliknutím:** kliknutie do obrázka spustí iteratívny flood fill nad pixelmi pôvodného rozlíšenia.
|
||||
- **Nastaviteľný výber:** používateľ môže meniť toleranciu výberu v rozsahu 0 až 100, 4- alebo 8-smerovú susednosť a prah tmavosti obrysov.
|
||||
- **Reprezentatívna farba:** farba nevychádza iba z kliknutého pixelu; počíta sa medián vzoriek z vnútra nájdenej plochy.
|
||||
- **Zoskupovanie podobných farieb:** plochy sa porovnávajú v priestore CIE Lab pomocou farebnej vzdialenosti Delta E 1976. Prah podobnosti je nastaviteľný.
|
||||
- **Zoradenie a číslovanie:** skupiny sa automaticky zoradia podľa hodnoty svetlosti `L` od najtmavšej po najsvetlejšiu a ich čísla sa zobrazia na označených plochách.
|
||||
- **Pozícia čísla:** v rozšírených nastaveniach možno zapnúť `Zobrazovať čísla na pozícii kliknutia`. Bez tejto voľby aplikácia naďalej používa automaticky vypočítanú vnútornú pozíciu.
|
||||
- **Zvýraznenie skupiny:** vybraná skupina sa na obrázku zvýrazní; ostatné skupiny možno zobraziť normálne, stlmiť alebo skryť.
|
||||
- **Manuálne opravy:** plochu možno odstrániť, presunúť do inej skupiny alebo oddeliť do novej skupiny. Skupiny možno zlúčiť a ich reprezentatívnu farbu zmeniť.
|
||||
- **Prepočítanie skupín:** tlačidlo `Prepočítať` znovu vytvorí skupiny podľa aktuálnej tolerancie podobnosti.
|
||||
- **Undo a redo:** história obsahuje najviac 30 krokov pre pridanie alebo odstránenie plochy, presuny, rozdelenie a zlúčenie skupín a vymazanie výsledkov.
|
||||
- **Export PNG:** vytvorí obrázok v pôvodnom rozlíšení s číslami skupín a voliteľným polopriehľadným farebným prekrytím.
|
||||
- **Export CSV:** uloží zoradenú paletu s HEX, RGB a Lab hodnotami, počtom plôch a celkovým počtom pixelov.
|
||||
- **Export a import JSON:** uloží nastavenia, plochy, skupiny, rozmery a komprimované masky projektu. Import sa spúšťa tlačidlom `Import JSON`.
|
||||
- **Automatická obnova:** posledná rozpracovaná práca sa priebežne ukladá do IndexedDB a po obnovení stránky ju aplikácia ponúkne na pokračovanie.
|
||||
|
||||
## Ako aplikáciu používať
|
||||
|
||||
1. **Otvorte aplikáciu.** Pri lokálnom vývoji ju sprístupní Vite na adrese uvedenej v termináli.
|
||||
2. **Načítajte obrázok.** Použite `Načítať obrázok`, `Vybrať obrázok` alebo presuňte súbor PNG, JPEG či WebP do editora.
|
||||
3. **Nastavte toleranciu výberu.** Hodnota `0` vyberá iba susedné pixely s presne rovnakou RGB farbou ako kliknutý pixel. Hodnota `1` používa ako referenciu medián farby z okolia 5 × 5 pixelov. Od hodnoty `2` sa používa pôvodný spôsob porovnania s farbou kliknutého pixelu. Ak výber uniká cez hranice, hodnotu znížte; ak sa plocha vyberie iba čiastočne, zvýšte ju.
|
||||
4. **Označujte plochy.** Klikajte dovnútra jednotlivých uzavretých plôch. Ťahaním obrázok posúvajte a kolieskom ho približujte alebo odďaľujte. Ak chcete čísla ponechať priamo na miestach kliknutia, v sekcii `Rozšírené` zapnite `Zobrazovať čísla na pozícii kliknutia`.
|
||||
5. **Skontrolujte farebné skupiny.** Pravý panel zobrazuje HEX, RGB, svetlosť a počet plôch každej skupiny. Zoradenie od najtmavšej po najsvetlejšiu prebieha automaticky.
|
||||
6. **Upravte podobnosť farieb.** Posuvník `Podobnosť farieb (ΔE)` určuje, ktoré farby sa spoja. Po jeho zmene sa skupiny prepočítajú.
|
||||
7. **Zvýraznite pracovnú skupinu.** Kliknite na farebnú skupinu a v nastavení `Ostatné skupiny` vyberte normálne zobrazenie, stlmenie alebo skrytie.
|
||||
8. **Opravte nepresnosti.** V zozname plôch možno plochu preradiť, rozdeliť do novej skupiny alebo odstrániť. V detaile skupiny možno zmeniť farbu alebo skupinu zlúčiť s inou.
|
||||
9. **Exportujte výsledok.** Použite `Očíslované PNG`, `Paleta CSV` alebo `Projekt JSON`. Pred PNG exportom možno zapnúť možnosť `Zahrnúť farebné prekrytie do PNG`.
|
||||
|
||||
Projekt JSON možno neskôr otvoriť cez `Import JSON`. Ak súbor neobsahuje pôvodný obrázok, aplikácia požiada o jeho opätovný výber; rozmery sa musia zhodovať s uloženým projektom.
|
||||
|
||||
### Klávesové skratky
|
||||
|
||||
| Skratka | Funkcia |
|
||||
| --- | --- |
|
||||
| `Ctrl+Z` | krok späť |
|
||||
| `Ctrl+Y` alebo `Ctrl+Shift+Z` | krok znova |
|
||||
| `Delete` | odstránenie vybratej plochy |
|
||||
| `Escape` | zrušenie výberu plochy a skupiny |
|
||||
|
||||
## Ako aplikácia určuje farby
|
||||
|
||||
Po kliknutí Mozaic určí referenčnú farbu a hľadá susedné pixely s dostatočne podobnou RGB farbou. Pri tolerancii `0` je referenciou presná farba kliknutého pixelu a farebná vzdialenosť musí byť nulová. Pri tolerancii `1` sa pre každý kanál R, G a B vypočíta medián z okolia `<-2, +2>` v oboch osiach, teda najviac z 25 pixelov. Medián obmedzuje vplyv jedného šumového alebo kompresného artefaktu. Pri tolerancii `2` a vyššej je referenciou opäť farba kliknutého pixelu.
|
||||
|
||||
Tmavé pixely pod nastaveným prahom slúžia pri farebnom výbere ako bariéra, čo pomáha zabrániť pretečeniu cez tmavé obrysové čiary. Ak pixel už patrí staršiemu regiónu, ale jeho farba sa podľa aktuálnej tolerancie líši, nová plocha sa môže zo starej masky oddeliť. Pri rovnakej farbe aplikácia zabráni duplicitnému označeniu.
|
||||
|
||||
Reprezentatívna farba sa neurčuje z jediného bodu. Algoritmus zbiera vzorky z vnútorných častí scanline segmentov, obmedzuje ich počet a pre každý RGB kanál vypočíta medián. Tým sa znižuje vplyv okrajov, drobného šumu a časti kompresných artefaktov.
|
||||
|
||||
Na porovnanie plôch sa RGB farby prevedú cez XYZ do priestoru CIE Lab. V ňom sa vypočíta Delta E 1976 (`ΔE76`), ktorá vyjadruje vzdialenosť dvoch farieb. Plochy pod nastaveným prahom sa zoskupia a skupiny sa zoradia podľa zložky `L`, teda približnej svetlosti.
|
||||
|
||||
## Ochrana súkromia
|
||||
|
||||
Obrázky sa načítavajú a analyzujú výhradne lokálne v prehliadači. Projekt nemá backend, databázový server ani cloudové API a obrázky nikam neodosiela.
|
||||
|
||||
Rozpracovaný stav sa môže ukladať iba do lokálneho IndexedDB úložiska daného prehliadača. Exportované PNG, CSV a JSON súbory sa vytvárajú v prehliadači a sťahujú priamo do zariadenia používateľa.
|
||||
|
||||
## Tipy pre kvalitný výsledok
|
||||
|
||||
- Použite ostrú fotografiu s dostatočným rozlíšením a jasne uzavretými hranicami plôch.
|
||||
- Fotografujte čo najkolmejšie na obraz, aby sa obmedzilo perspektívne skreslenie.
|
||||
- Uprednostnite rovnomerné neutrálne osvetlenie bez tieňov, farebných odrazov a lesklých odleskov.
|
||||
- Ak výber preteká cez obrys, znížte toleranciu výberu alebo upravte prah tmavosti obrysu.
|
||||
- Ak sa jedna plocha rozdelí na viac výberov, toleranciu mierne zvýšte alebo použite kvalitnejší zdrojový obrázok.
|
||||
- Automaticky vytvorené farebné skupiny pred exportom skontrolujte a podľa potreby manuálne opravte.
|
||||
- Pri farebne kritickej práci používajte fotografiu so správnym vyvážením bielej; aplikácia nevykonáva kalibráciu fotoaparátu ani monitora.
|
||||
|
||||
## Aktuálne obmedzenia
|
||||
|
||||
- Všetky plochy sa označujú postupne kliknutím. Automatická segmentácia celého obrázka nie je implementovaná.
|
||||
- Flood fill funguje najlepšie na uzavretých plochách s výraznými hranicami. Medzera v obryse môže spôsobiť pretečenie výberu.
|
||||
- Jemné prechody, textúry, tiene, odlesky a JPEG artefakty môžu jednu plochu rozdeliť alebo ovplyvniť jej farbu.
|
||||
- Tmavá hranica sa rozpoznáva iba podľa nastavenej svetlosti; zložité alebo farebné obrysy môžu vyžadovať inú toleranciu.
|
||||
- Analýza prebieha na hlavnom vlákne bez Web Workera. Veľké súvislé plochy preto môžu dočasne znížiť odozvu rozhrania.
|
||||
- Pri obrázkoch nad 24 miliónov pixelov aplikácia zobrazí výkonnostné upozornenie. Obrázky nad 80 miliónov pixelov odmietne.
|
||||
- Výber pokrývajúci viac než 92 % obrázka sa považuje za pravdepodobnú chybu a neuloží sa. Pri toleranciách `0` a `1` je povolený aj jednopixelový región; od tolerancie `2` sa regióny menšie než 12 pixelov neuložia.
|
||||
- Výsledná farba závisí od kvality a osvetlenia fotografie; nejde o fyzicky kalibrované meranie farby.
|
||||
- Projektový JSON obsahuje pôvodný obrázok iba vtedy, keď má najviac 5 MiB. Pri väčšom obrázku ho treba pri importe znovu vybrať v rovnakých rozmeroch.
|
||||
|
||||
---
|
||||
|
||||
# Technické informácie
|
||||
|
||||
## Použité technológie
|
||||
|
||||
- [Vue 3](https://vuejs.org/) s Composition API a komponentmi `<script setup lang="ts">`
|
||||
- TypeScript
|
||||
- Vite
|
||||
- Pinia pre globálny stav editora
|
||||
- Vue Router; projekt ho inicializuje, aktuálne však nemá definované aplikačné trasy
|
||||
- HTML Canvas API a natívne browser API (`ImageData`, `createImageBitmap`, `IndexedDB`, `FileReader`, Blob a Object URL)
|
||||
- ESLint, Oxlint a Prettier pre kontrolu a formátovanie kódu
|
||||
|
||||
Aplikácia nepoužíva backend, OpenCV ani externú knižnicu na spracovanie obrázkov.
|
||||
|
||||
## Požiadavky
|
||||
|
||||
- Node.js presne podľa `engines` v `frontend/package.json`: `^22.18.0 || >=24.12.0`
|
||||
- npm nie je v projekte pevne pripnuté; použite verziu dodanú s podporovanou verziou Node.js
|
||||
- moderný prehliadač s podporou Canvas 2D, `createImageBitmap`, IndexedDB a ES modulov
|
||||
|
||||
Ďalšie systémové služby ani databázový server nie sú potrebné.
|
||||
|
||||
## Inštalácia a lokálne spustenie
|
||||
|
||||
Po klonovaní alebo otvorení repozitára prejdite do frontendového projektu, nainštalujte závislosti a spustite vývojový server:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Vite štandardne sprístupní aplikáciu na `http://localhost:5173/`. Ak je port obsadený, zvolí iný a vypíše jeho adresu v termináli.
|
||||
|
||||
## Produkčný build
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
Skript spustí TypeScript kontrolu a produkčný Vite build paralelne. Výsledné statické súbory sa vytvoria v `frontend/dist/`.
|
||||
|
||||
Lokálny náhľad hotového buildu:
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Kontrola kvality kódu
|
||||
|
||||
Všetky dostupné kontrolné skripty sú definované v `frontend/package.json`:
|
||||
|
||||
```bash
|
||||
npm run type-check
|
||||
npm run lint
|
||||
npm run format
|
||||
```
|
||||
|
||||
- `type-check` spúšťa `vue-tsc --build`.
|
||||
- `lint` postupne spúšťa Oxlint a ESLint. Obe lint úlohy používajú automatické opravy, takže môžu zmeniť kontrolované súbory.
|
||||
- `format` formátuje obsah `frontend/src/` pomocou Prettieru.
|
||||
|
||||
Projekt momentálne nemá nakonfigurovaný testovací runner ani npm skript `test`.
|
||||
|
||||
## Štruktúra projektu
|
||||
|
||||
```text
|
||||
Mozaic/
|
||||
├── frontend/
|
||||
│ ├── public/ # verejné favicony a ikony
|
||||
│ ├── src/
|
||||
│ │ ├── assets/ # zdrojové vizuálne súbory
|
||||
│ │ ├── components/ # editor, toolbar a pravý ovládací panel
|
||||
│ │ ├── composables/ # ovládanie canvas viewportu
|
||||
│ │ ├── router/ # inicializácia Vue Routera
|
||||
│ │ ├── stores/ # Pinia store a stav projektu
|
||||
│ │ ├── types/ # TypeScript dátové modely
|
||||
│ │ ├── utils/ # farby, flood fill, masky, export a IndexedDB
|
||||
│ │ ├── views/ # hlavné rozloženie editora
|
||||
│ │ ├── App.vue
|
||||
│ │ └── main.ts
|
||||
│ ├── index.html
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.app.json
|
||||
│ └── vite.config.ts
|
||||
├── LICENSE
|
||||
└── README.md
|
||||
```
|
||||
|
||||
Najdôležitejšie implementačné body:
|
||||
|
||||
- `src/stores/mosaic.ts` koordinuje obrázok, plochy, skupiny, výber, históriu, import/export a automatické uloženie.
|
||||
- `src/components/MosaicCanvas.vue` vykresľuje obrázok, prekrytia a čísla a prepočítava udalosti ukazovateľa na obrazové súradnice.
|
||||
- `src/utils/floodFill.ts` obsahuje iteratívny scanline flood fill.
|
||||
- `src/utils/colors.ts` obsahuje prevody RGB → XYZ → Lab, `ΔE76`, svetlosť a HEX formátovanie.
|
||||
- `src/utils/colorGroups.ts` zoskupuje plochy a zoraďuje skupiny podľa svetlosti.
|
||||
- `src/utils/regionMask.ts` pracuje s pamäťovo úspornou RLE reprezentáciou masiek.
|
||||
- `src/utils/projectExport.ts` a `src/utils/projectStorage.ts` zabezpečujú exporty a lokálne ukladanie.
|
||||
|
||||
## Technický princíp fungovania
|
||||
|
||||
1. Prehliadač dekóduje súbor pomocou `createImageBitmap` a vykreslí ho do interného canvasu v pôvodnom rozlíšení.
|
||||
2. Store načíta `ImageData` raz a uchová ho mimo hlbokej reaktivity. Zobrazený canvas používa samostatný viewport pre zoom a posun.
|
||||
3. Kliknutie sa prepočíta zo súradníc obrazovky na súradnice pôvodného obrázka.
|
||||
4. Referenčná RGB farba závisí od tolerancie: presný kliknutý pixel pri `0`, medián 5 × 5 pri `1` a kliknutý pixel pri hodnotách od `2`. Iteratívny scanline flood fill potom vyhľadá súvislú oblasť podľa rovnakej euklidovskej RGB tolerancie, zvolenej susednosti, prahu tmavého obrysu a masiek už označených plôch.
|
||||
5. Výsledok sa uloží ako RLE riadky v `Uint32Array`. Zároveň sa vypočíta počet pixelov, bounding box a automatická vnútorná `labelPosition`; samostatná `userClickPosition` uchová obrazové súradnice kliknutia.
|
||||
6. Z najviac 8 000 vnútorných vzoriek sa vypočíta medián RGB kanálov reprezentatívnej farby.
|
||||
7. RGB farba sa prevedie do CIE Lab a porovná s existujúcimi skupinami pomocou `ΔE76`.
|
||||
8. Skupiny dostanú váženú reprezentatívnu farbu, zoradia sa podľa Lab `L` a prečíslujú sa.
|
||||
9. Canvas aj PNG export vykreslia číslo na `labelPosition` alebo, pri zapnutom nastavení, na `userClickPosition`. Staršie regióny bez kliknutej pozície bezpečne použijú `labelPosition`.
|
||||
10. Export vytvorí výsledné dáta priamo v prehliadači bez serverovej komunikácie.
|
||||
|
||||
## Dáta a ukladanie
|
||||
|
||||
Aktívny stav spravuje Pinia store. Veľké objekty `HTMLCanvasElement`, `ImageData`, `Blob`, masky a zoznam plôch sa ukladajú cez `shallowRef` alebo `markRaw`, aby ich Vue zbytočne neobalilo hlbokou reaktivitou.
|
||||
|
||||
Po načítaní obrázka a po zmenách výsledkov sa projekt s krátkym oneskorením uloží do IndexedDB databázy `mozaic-analyzer`, úložiska `projects`, pod kľúčom `last-project`. Automatická záloha obsahuje serializovaný projekt aj pôvodný obrazový Blob. Pri ďalšom otvorení aplikácia ponúkne obnovenie alebo zahodenie poslednej práce. Ak prehliadač IndexedDB nepovolí, chyba automatického uloženia neblokuje editor.
|
||||
|
||||
Exportovaný JSON obsahuje verziu formátu, dátum vytvorenia, názov a rozmery obrázka, nastavenia, farebné skupiny a plochy vrátane RLE masiek, farieb, počtu pixelov, bounding boxov, automatických pozícií čísiel a pozícií kliknutia. Ukladá sa aj nastavenie `useUserClickPositionForLabels`. Starší projekt bez týchto polí použije hodnotu `false` a automatickú `labelPosition`. Obrázok do 5 MiB sa vloží ako data URL. Pri väčšom obrázku JSON obsahuje iba projektové dáta a pri importe treba znovu vybrať pôvodný obrázok s rovnakými rozmermi.
|
||||
|
||||
História undo/redo je iba v pamäti aktuálnej relácie a nie je súčasťou exportovaného projektu.
|
||||
|
||||
## Vývojové poznámky
|
||||
|
||||
- Analýza vždy používa pixely pôvodného rozlíšenia; zoom ovplyvňuje iba zobrazenie.
|
||||
- RLE maska ukladá trojice `y`, `xStart`, `xEnd` namiesto objektu pre každý pixel.
|
||||
- Masky sú po vytvorení nemenné, preto ich história môže bezpečne zdieľať bez kopírovania veľkých binárnych polí.
|
||||
- Výpočtová logika je oddelená od Vue komponentov v `src/utils/`, čo umožňuje jej neskoršie samostatné testovanie.
|
||||
- Flood fill aktuálne beží na hlavnom vlákne. Pri prípadnom presune do Web Workera treba zachovať prácu s pôvodným `ImageData` a minimalizovať kopírovanie veľkých polí.
|
||||
- Router je zapojený, no hlavná aplikácia sa momentálne vykresľuje priamo cez `App.vue` a nevyužíva samostatné trasy.
|
||||
|
||||
122
build.bat
Normal file
@ -0,0 +1,122 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
|
||||
set "ROOT=%~dp0"
|
||||
if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%"
|
||||
|
||||
set "DIST_DIR=%ROOT%\dist"
|
||||
set "DIST_PUBLIC=%DIST_DIR%\public"
|
||||
set "DIST_APP=%DIST_PUBLIC%"
|
||||
set "FRONTEND_DIR=%ROOT%\frontend"
|
||||
set "BACKEND_DIR=%ROOT%\backend"
|
||||
|
||||
echo [1/8] Cleaning dist...
|
||||
if exist "%DIST_DIR%" (
|
||||
rmdir /S /Q "%DIST_DIR%"
|
||||
if exist "%DIST_DIR%" (
|
||||
echo ERROR: Failed to remove "%DIST_DIR%".
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
mkdir "%DIST_DIR%" >nul 2>&1 || (
|
||||
echo ERROR: Failed to create "%DIST_DIR%".
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [2/8] Installing backend dependencies... SKIPED
|
||||
@REM pushd "%BACKEND_DIR%" >nul 2>&1 || (
|
||||
@REM echo ERROR: Backend directory not found: "%BACKEND_DIR%".
|
||||
@REM exit /b 1
|
||||
@REM )
|
||||
|
||||
@REM call composer install --no-dev --optimize-autoloader
|
||||
@REM set "COMPOSER_EXIT=%ERRORLEVEL%"
|
||||
@REM popd >nul
|
||||
@REM if not "%COMPOSER_EXIT%"=="0" (
|
||||
@REM echo ERROR: composer install failed.
|
||||
@REM exit /b 1
|
||||
@REM )
|
||||
|
||||
echo [3/8] Regenerating frontend API client... SKIPED
|
||||
@REM call php "%BACKEND_DIR%\scripts\buildTypeScript.php"
|
||||
@REM if not "%ERRORLEVEL%"=="0" (
|
||||
@REM echo ERROR: TypeScript API client generation failed.
|
||||
@REM exit /b 1
|
||||
@REM )
|
||||
|
||||
echo [4/8] Building frontend...
|
||||
pushd "%FRONTEND_DIR%" >nul 2>&1 || (
|
||||
echo ERROR: Frontend directory not found: "%FRONTEND_DIR%".
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if exist "%FRONTEND_DIR%\node_modules" (
|
||||
echo - npm install
|
||||
call npm.cmd install
|
||||
) else (
|
||||
echo - npm ci
|
||||
call npm.cmd ci
|
||||
)
|
||||
set "NPM_INSTALL_EXIT=%ERRORLEVEL%"
|
||||
if not "%NPM_INSTALL_EXIT%"=="0" (
|
||||
popd >nul
|
||||
echo ERROR: npm dependency install failed.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo - npm run build
|
||||
call npm.cmd run build
|
||||
set "NPM_BUILD_EXIT=%ERRORLEVEL%"
|
||||
if not "%NPM_BUILD_EXIT%"=="0" (
|
||||
popd >nul
|
||||
echo ERROR: npm run build failed.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist "%FRONTEND_DIR%\dist" (
|
||||
popd >nul
|
||||
echo ERROR: Frontend build output not found at "%FRONTEND_DIR%\dist".
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
popd >nul
|
||||
|
||||
echo [5/8] Copy backend root to dist/... SKIPED
|
||||
@REM call :RunRobocopy "%BACKEND_DIR%" "%DIST_DIR%" /E /R:2 /W:1 /NFL /NDL /NJH /NJS /NP ^
|
||||
@REM /XD "%BACKEND_DIR%\.git" "%BACKEND_DIR%\.vscode" "%BACKEND_DIR%\tests" "%BACKEND_DIR%\node_modules" "%BACKEND_DIR%\frontend" "%BACKEND_DIR%\dist" ^
|
||||
@REM /XF ".env" ".env.*"
|
||||
@REM if errorlevel 1 exit /b 1
|
||||
|
||||
echo [6/8] Copy frontend/dist to dist/public...
|
||||
call :RunRobocopy "%FRONTEND_DIR%\dist" "%DIST_APP%" /E /R:2 /W:1 /NFL /NDL /NJH /NJS /NP
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
echo [7/8] Applying .env.production (if present)...
|
||||
set "ENV_NOTE=No backend/.env.production found"
|
||||
if exist "%BACKEND_DIR%\.env.production" (
|
||||
copy /Y "%BACKEND_DIR%\.env.production" "%DIST_DIR%\.env" >nul
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to copy backend/.env.production to dist/.env.
|
||||
exit /b 1
|
||||
)
|
||||
set "ENV_NOTE=Copied backend/.env.production to dist/.env"
|
||||
)
|
||||
|
||||
echo [8/8] Summary
|
||||
echo - Frontend build: OK
|
||||
echo - Backend root copied to: "%DIST_DIR%" (excluding excluded dirs)
|
||||
echo - Frontend assets copied to: "%DIST_APP%"
|
||||
echo - Env: %ENV_NOTE%
|
||||
echo - DocumentRoot should be: "%DIST_PUBLIC%"
|
||||
echo - Frontend app is served from: "%DIST_APP%"
|
||||
echo DONE "%DIST_DIR%"
|
||||
exit /b 0
|
||||
|
||||
:RunRobocopy
|
||||
robocopy %*
|
||||
if errorlevel 8 (
|
||||
echo ERROR: robocopy failed with exit code %ERRORLEVEL%.
|
||||
exit /b 1
|
||||
)
|
||||
exit /b 0
|
||||
9
deploy.bat
Normal file
@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
|
||||
call php d:\www\sftpsync\src\sftpsync.php --host vihorlat.tpsoft.org --user igor ^
|
||||
--delete-dir /storage/tpsoft.org/mozaic/dist ^
|
||||
--sync d:/www/Mozaic/dist /storage/tpsoft.org/mozaic/dist ^
|
||||
--skip .git ^
|
||||
--print-relative
|
||||
|
||||
echo ✔️ Done.
|
||||
BIN
doc/Screenshot-2026-08-01-100022.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
doc/Screenshot-2026-08-01-200944.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
8
frontend/.editorconfig
Normal file
@ -0,0 +1,8 @@
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
max_line_length = 100
|
||||
1
frontend/.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
39
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,39 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
10
frontend/.oxlintrc.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
|
||||
"env": {
|
||||
"browser": true
|
||||
},
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
}
|
||||
}
|
||||
6
frontend/.prettierrc.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
9
frontend/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"oxc.oxc-vscode",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
24
frontend/README.md
Normal file
@ -0,0 +1,24 @@
|
||||
# Mozaic – lokálna analýza farebných plôch
|
||||
|
||||
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.
|
||||
|
||||
Všetky obrázky a výpočty zostávajú výhradne v prehliadači. Aplikácia nemá backend a obrázky nikam neodosiela.
|
||||
|
||||
## Spustenie
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Produkčný build a kontroly:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
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
frontend/env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
26
frontend/eslint.config.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||
import skipFormatting from 'eslint-config-prettier/flat'
|
||||
|
||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{vue,ts,mts,tsx}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
...pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
|
||||
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||
|
||||
skipFormatting,
|
||||
)
|
||||
18
frontend/index.html
Normal file
@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="sk">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="theme-color" content="#f8fafc">
|
||||
<meta name="description" content="Lokálna analýza farebných plôch a palety mozaiky.">
|
||||
<title>Mozaic</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
5097
frontend/package-lock.json
generated
Normal file
45
frontend/package.json
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint": "run-s \"lint:*\"",
|
||||
"lint:oxlint": "oxlint . --fix",
|
||||
"lint:eslint": "eslint . --fix --cache",
|
||||
"format": "prettier --write --experimental-cli src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^4.0.2",
|
||||
"vue": "^3.5.40",
|
||||
"vue-router": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/node": "^24.13.3",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/eslint-config-typescript": "^14.9.0",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.73.0",
|
||||
"eslint-plugin-vue": "~10.9.2",
|
||||
"jiti": "^2.7.0",
|
||||
"npm-run-all2": "^9.0.2",
|
||||
"oxlint": "~1.73.0",
|
||||
"prettier": "3.9.5",
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.1.5",
|
||||
"vite-plugin-vue-devtools": "^8.1.5",
|
||||
"vue-eslint-parser": "^10.4.1",
|
||||
"vue-tsc": "^3.3.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.12.0"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
frontend/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
15
frontend/public/favicon.svg
Normal file
@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Mozaic">
|
||||
<defs>
|
||||
<clipPath id="tile-frame">
|
||||
<rect x="36" y="36" width="440" height="440" rx="88"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<rect x="16" y="16" width="480" height="480" rx="112" fill="#172033"/>
|
||||
<g clip-path="url(#tile-frame)" stroke="#172033" stroke-width="24" stroke-linejoin="round">
|
||||
<path d="M24 24h226l-24 210L24 202Z" fill="#F16C5B"/>
|
||||
<path d="M250 24h238v184L226 234Z" fill="#36A99A"/>
|
||||
<path d="M24 202l202 32-24 254H24Z" fill="#4386D8"/>
|
||||
<path d="M226 234l104-34 70 126-142 80-56-92Z" fill="#F2C14E"/>
|
||||
<path d="M330 200l158 8v280H202l56-82 142-80Z" fill="#8A6FBC"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 709 B |
BIN
frontend/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
frontend/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
5
frontend/src/App.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import EditorView from '@/views/EditorView.vue'
|
||||
</script>
|
||||
|
||||
<template><EditorView /></template>
|
||||
15
frontend/src/assets/mozaic-logo.svg
Normal file
@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Mozaic">
|
||||
<defs>
|
||||
<clipPath id="tile-frame">
|
||||
<rect x="36" y="36" width="440" height="440" rx="88"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<rect x="16" y="16" width="480" height="480" rx="112" fill="#172033"/>
|
||||
<g clip-path="url(#tile-frame)" stroke="#172033" stroke-width="24" stroke-linejoin="round">
|
||||
<path d="M24 24h226l-24 210L24 202Z" fill="#F16C5B"/>
|
||||
<path d="M250 24h238v184L226 234Z" fill="#36A99A"/>
|
||||
<path d="M24 202l202 32-24 254H24Z" fill="#4386D8"/>
|
||||
<path d="M226 234l104-34 70 126-142 80-56-92Z" fill="#F2C14E"/>
|
||||
<path d="M330 200l158 8v280H202l56-82 142-80Z" fill="#8A6FBC"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 709 B |
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
@ -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>
|
||||
48
frontend/src/components/EditorToolbar.vue
Normal file
@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useMosaicStore } from '@/stores/mosaic'
|
||||
import mozaicLogo from '@/assets/mozaic-logo.svg'
|
||||
|
||||
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"><img class="brand-mark" :src="mozaicLogo" alt="" width="32" height="32" /><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: block; width: 32px; height: 32px; flex: 0 0 32px; }
|
||||
.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>
|
||||
59
frontend/src/components/ExportPanel.vue
Normal file
@ -0,0 +1,59 @@
|
||||
<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,
|
||||
settings.value.useUserClickPositionForLabels,
|
||||
)
|
||||
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')
|
||||
try {
|
||||
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')
|
||||
} catch (error) {
|
||||
store.notify(error instanceof Error ? error.message : 'Export projektu JSON zlyhal.', 'error')
|
||||
}
|
||||
}
|
||||
</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
@ -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>
|
||||
183
frontend/src/components/MosaicCanvas.vue
Normal file
@ -0,0 +1,183 @@
|
||||
<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, resolveRegionLabelPosition } 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 labelPosition = resolveRegionLabelPosition(region, settings.value.useUserClickPositionForLabels)
|
||||
const x = viewport.x + labelPosition.x * viewport.scale
|
||||
const y = viewport.y + 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,
|
||||
() => settings.value.useUserClickPositionForLabels,
|
||||
], 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
@ -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>
|
||||
70
frontend/src/components/SettingsPanel.vue
Normal file
@ -0,0 +1,70 @@
|
||||
<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="0" 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>
|
||||
<label class="check-field">
|
||||
<input v-model="settings.useUserClickPositionForLabels" type="checkbox" />
|
||||
<span>Zobrazovať čísla na pozícii kliknutia</span>
|
||||
</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; }
|
||||
.check-field { display: flex; align-items: center; gap: 7px; margin-top: 12px; color: #475569; cursor: pointer; }
|
||||
</style>
|
||||
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 }
|
||||
}
|
||||
12
frontend/src/main.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
8
frontend/src/router/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [],
|
||||
})
|
||||
|
||||
export default router
|
||||
12
frontend/src/stores/counter.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment }
|
||||
})
|
||||
421
frontend/src/stores/mosaic.ts
Normal file
@ -0,0 +1,421 @@
|
||||
import { computed, markRaw, reactive, ref, shallowRef, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type {
|
||||
ColorGroup,
|
||||
MosaicRegion,
|
||||
MosaicSettings,
|
||||
ProjectFile,
|
||||
ToastMessage,
|
||||
} from '@/types/mosaic'
|
||||
import { floodFillRegion, getFloodFillReferenceColor } from '@/utils/floodFill'
|
||||
import { groupRegionsByColor, refreshGroupColors, sortGroupsByLightness } from '@/utils/colorGroups'
|
||||
import { colorDistance, rgbToLab } from '@/utils/colors'
|
||||
import {
|
||||
buildOccupancy,
|
||||
createRegionMask,
|
||||
maskContains,
|
||||
subtractRegionMask,
|
||||
summarizeRegionMask,
|
||||
} from '@/utils/regionMask'
|
||||
import { createProjectFile, isProjectFile } from '@/utils/projectExport'
|
||||
import { clearLastProject, loadLastProject, saveLastProject } from '@/utils/projectStorage'
|
||||
|
||||
interface HistorySnapshot {
|
||||
regions: MosaicRegion[]
|
||||
groups: ColorGroup[]
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: MosaicSettings = {
|
||||
floodTolerance: 34,
|
||||
colorTolerance: 12,
|
||||
darkBoundaryThreshold: 0.045,
|
||||
connectivity: 4,
|
||||
exportOverlay: false,
|
||||
otherGroupsMode: 'dim',
|
||||
useUserClickPositionForLabels: false,
|
||||
}
|
||||
|
||||
const cloneRegions = (regions: MosaicRegion[]): MosaicRegion[] => regions.map((region) => markRaw({
|
||||
...region,
|
||||
color: { ...region.color },
|
||||
labColor: { ...region.labColor },
|
||||
boundingBox: { ...region.boundingBox },
|
||||
labelPosition: { ...region.labelPosition },
|
||||
userClickPosition: region.userClickPosition ? { ...region.userClickPosition } : undefined,
|
||||
// Masks are immutable; sharing their typed arrays keeps the 30-step history compact.
|
||||
mask: region.mask,
|
||||
}))
|
||||
|
||||
const cloneGroups = (groups: ColorGroup[]): ColorGroup[] => groups.map((group) => ({
|
||||
...group,
|
||||
color: { ...group.color },
|
||||
labColor: { ...group.labColor },
|
||||
regionIds: [...group.regionIds],
|
||||
}))
|
||||
|
||||
export const useMosaicStore = defineStore('mosaic', () => {
|
||||
const imageCanvas = shallowRef<HTMLCanvasElement | null>(null)
|
||||
const imageData = shallowRef<ImageData | null>(null)
|
||||
const imageBlob = shallowRef<Blob | null>(null)
|
||||
const imageName = ref('')
|
||||
const imageWidth = ref(0)
|
||||
const imageHeight = ref(0)
|
||||
const regions = shallowRef<MosaicRegion[]>([])
|
||||
const groups = ref<ColorGroup[]>([])
|
||||
const selectedGroupId = ref<string | null>(null)
|
||||
const selectedRegionId = ref<string | null>(null)
|
||||
const settings = reactive<MosaicSettings>({ ...DEFAULT_SETTINGS })
|
||||
const toasts = ref<ToastMessage[]>([])
|
||||
const isAnalyzing = ref(false)
|
||||
const hasRecovery = ref(false)
|
||||
const recoveryTimestamp = ref('')
|
||||
const undoStack = shallowRef<HistorySnapshot[]>([])
|
||||
const redoStack = shallowRef<HistorySnapshot[]>([])
|
||||
let recoveryProject: ProjectFile | null = null
|
||||
let recoveryBlob: Blob | null = null
|
||||
let autosaveTimer = 0
|
||||
|
||||
const hasImage = computed(() => Boolean(imageCanvas.value && imageData.value))
|
||||
const canUndo = computed(() => undoStack.value.length > 0)
|
||||
const canRedo = computed(() => redoStack.value.length > 0)
|
||||
|
||||
function notify(text: string, type: ToastMessage['type'] = 'info'): void {
|
||||
const id = crypto.randomUUID()
|
||||
toasts.value.push({ id, text, type })
|
||||
window.setTimeout(() => dismissToast(id), 5000)
|
||||
}
|
||||
|
||||
function dismissToast(id: string): void {
|
||||
toasts.value = toasts.value.filter((toast) => toast.id !== id)
|
||||
}
|
||||
|
||||
function snapshot(): HistorySnapshot {
|
||||
return { regions: cloneRegions(regions.value), groups: cloneGroups(groups.value) }
|
||||
}
|
||||
|
||||
function recordHistory(): void {
|
||||
undoStack.value = [...undoStack.value.slice(-29), snapshot()]
|
||||
redoStack.value = []
|
||||
}
|
||||
|
||||
function restoreSnapshot(value: HistorySnapshot): void {
|
||||
regions.value = cloneRegions(value.regions)
|
||||
groups.value = cloneGroups(value.groups)
|
||||
selectedGroupId.value = null
|
||||
selectedRegionId.value = null
|
||||
scheduleAutosave()
|
||||
}
|
||||
|
||||
function undo(): void {
|
||||
const previous = undoStack.value.at(-1)
|
||||
if (!previous) return
|
||||
redoStack.value = [...redoStack.value, snapshot()].slice(-30)
|
||||
undoStack.value = undoStack.value.slice(0, -1)
|
||||
restoreSnapshot(previous)
|
||||
}
|
||||
|
||||
function redo(): void {
|
||||
const next = redoStack.value.at(-1)
|
||||
if (!next) return
|
||||
undoStack.value = [...undoStack.value, snapshot()].slice(-30)
|
||||
redoStack.value = redoStack.value.slice(0, -1)
|
||||
restoreSnapshot(next)
|
||||
}
|
||||
|
||||
async function decodeImage(blob: Blob): Promise<HTMLCanvasElement> {
|
||||
const bitmap = await createImageBitmap(blob)
|
||||
if (bitmap.width * bitmap.height > 80_000_000) {
|
||||
bitmap.close()
|
||||
throw new Error('Obrázok je príliš veľký (limit je 80 miliónov pixelov). Skúste menšiu verziu.')
|
||||
}
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = bitmap.width
|
||||
canvas.height = bitmap.height
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true })
|
||||
if (!context) throw new Error('Prehliadač nepodporuje Canvas 2D.')
|
||||
context.drawImage(bitmap, 0, 0)
|
||||
bitmap.close()
|
||||
return canvas
|
||||
}
|
||||
|
||||
async function loadImage(file: Blob, filename = 'obrazok'): Promise<void> {
|
||||
if (file instanceof File && !['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
|
||||
notify('Podporované sú iba obrázky PNG, JPEG a WebP.', 'error')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const canvas = await decodeImage(file)
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true })
|
||||
if (!context) throw new Error('Prehliadač nepodporuje Canvas 2D.')
|
||||
const pendingImportedProject = regions.value.length > 0 && !imageCanvas.value
|
||||
if (pendingImportedProject && (imageWidth.value !== canvas.width || imageHeight.value !== canvas.height)) {
|
||||
notify(`Projekt očakáva obrázok ${imageWidth.value} × ${imageHeight.value} px. Vybraný obrázok má ${canvas.width} × ${canvas.height} px.`, 'error')
|
||||
return
|
||||
}
|
||||
const replacingMatchingImage = pendingImportedProject
|
||||
imageCanvas.value = markRaw(canvas)
|
||||
imageData.value = markRaw(context.getImageData(0, 0, canvas.width, canvas.height))
|
||||
imageBlob.value = markRaw(file)
|
||||
imageName.value = filename
|
||||
imageWidth.value = canvas.width
|
||||
imageHeight.value = canvas.height
|
||||
if (!replacingMatchingImage) {
|
||||
regions.value = []
|
||||
groups.value = []
|
||||
undoStack.value = []
|
||||
redoStack.value = []
|
||||
}
|
||||
if (canvas.width * canvas.height > 24_000_000) {
|
||||
notify('Obrázok je veľmi veľký. Analýza rozsiahlych plôch môže chvíľu trvať.', 'warning')
|
||||
} else {
|
||||
notify('Obrázok bol načítaný iba lokálne v prehliadači.', 'success')
|
||||
}
|
||||
scheduleAutosave()
|
||||
} catch (error) {
|
||||
notify(error instanceof Error ? error.message : 'Obrázok je poškodený alebo ho nemožno načítať.', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function addRegionAt(x: number, y: number): void {
|
||||
if (!imageData.value || isAnalyzing.value) return
|
||||
const floodTolerance = settings.floodTolerance
|
||||
const minimumRegionPixelCount = floodTolerance <= 1 ? 1 : 12
|
||||
const referenceColor = getFloodFillReferenceColor(imageData.value, x, y, floodTolerance)
|
||||
const existingRegion = regions.value.find((region) => maskContains(region.mask, x, y))
|
||||
if (existingRegion && colorDistance(existingRegion.color, referenceColor) <= floodTolerance) {
|
||||
notify('Táto plocha už bola označená.', 'warning')
|
||||
return
|
||||
}
|
||||
const occupancy = buildOccupancy(
|
||||
imageWidth.value,
|
||||
imageHeight.value,
|
||||
regions.value.filter((region) => region.id !== existingRegion?.id).map((region) => region.mask),
|
||||
)
|
||||
isAnalyzing.value = true
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
const result = floodFillRegion(imageData.value as ImageData, x, y, {
|
||||
tolerance: floodTolerance,
|
||||
darkBoundaryThreshold: settings.darkBoundaryThreshold,
|
||||
connectivity: settings.connectivity,
|
||||
occupancy,
|
||||
})
|
||||
if (!result || result.pixelCount < minimumRegionPixelCount) {
|
||||
notify('Nájdená plocha je príliš malá. Skúste iný bod alebo vyššiu toleranciu.', 'warning')
|
||||
return
|
||||
}
|
||||
if (result.pixelCount > imageWidth.value * imageHeight.value * 0.92) {
|
||||
notify('Výber pokrýva takmer celý obrázok. Znížte toleranciu alebo kliknite dovnútra ohraničenej plochy.', 'warning')
|
||||
return
|
||||
}
|
||||
recordHistory()
|
||||
const region: MosaicRegion = markRaw({
|
||||
id: crypto.randomUUID(),
|
||||
color: result.color,
|
||||
labColor: rgbToLab(result.color),
|
||||
pixelCount: result.pixelCount,
|
||||
boundingBox: result.boundingBox,
|
||||
labelPosition: result.labelPosition,
|
||||
userClickPosition: { x, y },
|
||||
groupId: '',
|
||||
mask: result.mask,
|
||||
})
|
||||
const nextRegions = regions.value.flatMap((item) => {
|
||||
if (item.id !== existingRegion?.id) return [item]
|
||||
const remainingMask = subtractRegionMask(item.mask, result.mask)
|
||||
const summary = remainingMask ? summarizeRegionMask(remainingMask) : null
|
||||
if (!remainingMask || !summary || summary.pixelCount < minimumRegionPixelCount) return []
|
||||
return [markRaw({
|
||||
...item,
|
||||
mask: remainingMask,
|
||||
pixelCount: summary.pixelCount,
|
||||
boundingBox: summary.boundingBox,
|
||||
labelPosition: summary.labelPosition,
|
||||
userClickPosition: item.userClickPosition && maskContains(remainingMask, item.userClickPosition.x, item.userClickPosition.y)
|
||||
? item.userClickPosition
|
||||
: undefined,
|
||||
})]
|
||||
})
|
||||
regions.value = [...nextRegions, region]
|
||||
regroup(false)
|
||||
selectedRegionId.value = region.id
|
||||
selectedGroupId.value = regions.value.find((item) => item.id === region.id)?.groupId ?? null
|
||||
scheduleAutosave()
|
||||
} finally {
|
||||
isAnalyzing.value = false
|
||||
}
|
||||
}, 20)
|
||||
}
|
||||
|
||||
function regroup(withHistory = true): void {
|
||||
if (withHistory && regions.value.length) recordHistory()
|
||||
groups.value = groupRegionsByColor(regions.value, settings.colorTolerance)
|
||||
const regionToGroup = new Map(groups.value.flatMap((group) => group.regionIds.map((id) => [id, group.id])))
|
||||
regions.value = regions.value.map((region) => markRaw({ ...region, groupId: regionToGroup.get(region.id) ?? '' }))
|
||||
selectedGroupId.value = null
|
||||
scheduleAutosave()
|
||||
}
|
||||
|
||||
function setColorTolerance(value: number): void {
|
||||
settings.colorTolerance = value
|
||||
regroup()
|
||||
}
|
||||
|
||||
function removeRegion(id: string): void {
|
||||
if (!regions.value.some((region) => region.id === id)) return
|
||||
recordHistory()
|
||||
regions.value = regions.value.filter((region) => region.id !== id)
|
||||
groups.value = refreshGroupColors(groups.value.map((group) => ({ ...group, regionIds: group.regionIds.filter((regionId) => regionId !== id) })), regions.value)
|
||||
selectedRegionId.value = null
|
||||
scheduleAutosave()
|
||||
}
|
||||
|
||||
function moveRegion(regionId: string, targetGroupId: string): void {
|
||||
const region = regions.value.find((item) => item.id === regionId)
|
||||
if (!region || region.groupId === targetGroupId || !groups.value.some((group) => group.id === targetGroupId)) return
|
||||
recordHistory()
|
||||
regions.value = regions.value.map((item) => item.id === regionId ? markRaw({ ...item, groupId: targetGroupId }) : item)
|
||||
groups.value = refreshGroupColors(groups.value.map((group) => ({
|
||||
...group,
|
||||
regionIds: group.id === targetGroupId
|
||||
? [...group.regionIds, regionId]
|
||||
: group.regionIds.filter((id) => id !== regionId),
|
||||
})), regions.value)
|
||||
scheduleAutosave()
|
||||
}
|
||||
|
||||
function splitRegion(regionId: string): void {
|
||||
const region = regions.value.find((item) => item.id === regionId)
|
||||
const oldGroup = groups.value.find((group) => group.id === region?.groupId)
|
||||
if (!region || !oldGroup || oldGroup.regionIds.length <= 1) return
|
||||
recordHistory()
|
||||
const newGroup: ColorGroup = {
|
||||
id: crypto.randomUUID(), number: groups.value.length + 1, color: { ...region.color },
|
||||
labColor: { ...region.labColor }, regionIds: [region.id],
|
||||
}
|
||||
regions.value = regions.value.map((item) => item.id === region.id ? markRaw({ ...item, groupId: newGroup.id }) : item)
|
||||
groups.value = refreshGroupColors([...groups.value.map((group) => ({ ...group, regionIds: group.regionIds.filter((id) => id !== region.id) })), newGroup], regions.value)
|
||||
selectedGroupId.value = newGroup.id
|
||||
scheduleAutosave()
|
||||
}
|
||||
|
||||
function mergeGroups(sourceId: string, targetId: string): void {
|
||||
if (sourceId === targetId) return
|
||||
const source = groups.value.find((group) => group.id === sourceId)
|
||||
const target = groups.value.find((group) => group.id === targetId)
|
||||
if (!source || !target) return
|
||||
recordHistory()
|
||||
regions.value = regions.value.map((region) => source.regionIds.includes(region.id) ? markRaw({ ...region, groupId: targetId }) : region)
|
||||
groups.value = refreshGroupColors(groups.value
|
||||
.filter((group) => group.id !== sourceId)
|
||||
.map((group) => group.id === targetId ? { ...group, regionIds: [...group.regionIds, ...source.regionIds] } : group), regions.value)
|
||||
selectedGroupId.value = targetId
|
||||
scheduleAutosave()
|
||||
}
|
||||
|
||||
function setGroupColor(groupId: string, hex: string): void {
|
||||
const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hex)
|
||||
if (!match) return
|
||||
recordHistory()
|
||||
const color = { r: Number.parseInt(match[1] ?? '0', 16), g: Number.parseInt(match[2] ?? '0', 16), b: Number.parseInt(match[3] ?? '0', 16) }
|
||||
groups.value = sortGroupsByLightness(groups.value.map((group) => group.id === groupId ? { ...group, color, labColor: rgbToLab(color), customColor: true } : group))
|
||||
scheduleAutosave()
|
||||
}
|
||||
|
||||
function clearResults(): void {
|
||||
if (!regions.value.length) return
|
||||
recordHistory()
|
||||
regions.value = []
|
||||
groups.value = []
|
||||
selectedGroupId.value = null
|
||||
selectedRegionId.value = null
|
||||
scheduleAutosave()
|
||||
}
|
||||
|
||||
function serializeProject(includeSmallImage = false): Promise<ProjectFile> {
|
||||
return createProjectFile({
|
||||
imageName: imageName.value, imageBlob: imageBlob.value,
|
||||
dimensions: { width: imageWidth.value, height: imageHeight.value },
|
||||
settings: { ...settings }, regions: regions.value, groups: groups.value, includeSmallImage,
|
||||
})
|
||||
}
|
||||
|
||||
async function applyProject(project: ProjectFile, blob: Blob | null = null): Promise<void> {
|
||||
Object.assign(settings, DEFAULT_SETTINGS, project.settings, {
|
||||
useUserClickPositionForLabels: project.settings.useUserClickPositionForLabels ?? false,
|
||||
})
|
||||
imageName.value = project.imageName
|
||||
imageWidth.value = project.dimensions.width
|
||||
imageHeight.value = project.dimensions.height
|
||||
regions.value = project.regions.map((region) => markRaw({
|
||||
...region,
|
||||
userClickPosition: region.userClickPosition ? { ...region.userClickPosition } : undefined,
|
||||
mask: createRegionMask(region.mask.width, region.mask.height, region.mask.runs),
|
||||
}))
|
||||
groups.value = cloneGroups(project.groups)
|
||||
undoStack.value = []
|
||||
redoStack.value = []
|
||||
imageCanvas.value = null
|
||||
imageData.value = null
|
||||
imageBlob.value = null
|
||||
if (blob) await loadImage(blob, project.imageName)
|
||||
else if (project.imageDataUrl) {
|
||||
const response = await fetch(project.imageDataUrl)
|
||||
await loadImage(await response.blob(), project.imageName)
|
||||
} else notify('Projekt je načítaný. Vyberte pôvodný obrázok s rovnakými rozmermi.', 'warning')
|
||||
}
|
||||
|
||||
async function importProjectFile(file: File): Promise<void> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(await file.text())
|
||||
if (!isProjectFile(parsed)) throw new Error('Súbor nemá platný formát projektu Mozaic.')
|
||||
await applyProject(parsed)
|
||||
notify('Projekt bol úspešne importovaný.', 'success')
|
||||
} catch (error) {
|
||||
notify(error instanceof Error ? error.message : 'JSON projekt je poškodený.', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAutosave(): void {
|
||||
window.clearTimeout(autosaveTimer)
|
||||
autosaveTimer = window.setTimeout(async () => {
|
||||
if (!imageWidth.value) return
|
||||
try { await saveLastProject(await serializeProject(false), imageBlob.value) } catch { /* Private mode may block IndexedDB. */ }
|
||||
}, 800)
|
||||
}
|
||||
|
||||
async function checkRecovery(): Promise<void> {
|
||||
try {
|
||||
const stored = await loadLastProject()
|
||||
if (!stored) return
|
||||
recoveryProject = stored.project
|
||||
recoveryBlob = stored.imageBlob
|
||||
recoveryTimestamp.value = stored.savedAt
|
||||
hasRecovery.value = true
|
||||
} catch { /* IndexedDB is optional. */ }
|
||||
}
|
||||
|
||||
async function restoreRecovery(): Promise<void> {
|
||||
if (!recoveryProject) return
|
||||
await applyProject(recoveryProject, recoveryBlob)
|
||||
hasRecovery.value = false
|
||||
notify('Posledná rozpracovaná práca bola obnovená.', 'success')
|
||||
}
|
||||
|
||||
async function dismissRecovery(): Promise<void> {
|
||||
hasRecovery.value = false
|
||||
recoveryProject = null
|
||||
recoveryBlob = null
|
||||
await clearLastProject().catch(() => undefined)
|
||||
}
|
||||
|
||||
watch(settings, scheduleAutosave)
|
||||
|
||||
return {
|
||||
imageCanvas, imageData, imageBlob, imageName, imageWidth, imageHeight, regions, groups,
|
||||
selectedGroupId, selectedRegionId, settings, toasts, isAnalyzing, hasRecovery, recoveryTimestamp,
|
||||
hasImage, canUndo, canRedo, loadImage, addRegionAt, regroup, setColorTolerance, removeRegion,
|
||||
moveRegion, splitRegion, mergeGroups, setGroupColor, clearResults, undo, redo, notify,
|
||||
dismissToast, serializeProject, importProjectFile, checkRecovery, restoreRecovery, dismissRecovery,
|
||||
}
|
||||
})
|
||||
91
frontend/src/types/mosaic.ts
Normal file
@ -0,0 +1,91 @@
|
||||
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
|
||||
userClickPosition?: 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'
|
||||
useUserClickPositionForLabels: boolean
|
||||
}
|
||||
|
||||
export type SerializedMosaicSettings = Omit<MosaicSettings, 'useUserClickPositionForLabels'> & {
|
||||
useUserClickPositionForLabels?: boolean
|
||||
}
|
||||
|
||||
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: SerializedMosaicSettings
|
||||
regions: SerializedRegion[]
|
||||
groups: ColorGroup[]
|
||||
}
|
||||
|
||||
export interface ToastMessage {
|
||||
id: string
|
||||
type: 'info' | 'success' | 'warning' | 'error'
|
||||
text: string
|
||||
}
|
||||
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
@ -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),
|
||||
}
|
||||
}
|
||||
155
frontend/src/utils/floodFill.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import type { BoundingBox, Point, RegionMask, RGBColor } from '@/types/mosaic'
|
||||
import { colorDistance, relativeLuminance } from './colors'
|
||||
import { createRegionMask, summarizeRegionMask } 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 getFloodFillReferenceColor(
|
||||
image: ImageData,
|
||||
startX: number,
|
||||
startY: number,
|
||||
tolerance: number,
|
||||
): RGBColor {
|
||||
const { width, height, data } = image
|
||||
const startIndex = startY * width + startX
|
||||
if (tolerance !== 1) return pixelColor(data, startIndex)
|
||||
|
||||
const reds: number[] = []
|
||||
const greens: number[] = []
|
||||
const blues: number[] = []
|
||||
for (let y = Math.max(0, startY - 2); y <= Math.min(height - 1, startY + 2); y += 1) {
|
||||
for (let x = Math.max(0, startX - 2); x <= Math.min(width - 1, startX + 2); x += 1) {
|
||||
const color = pixelColor(data, y * width + x)
|
||||
reds.push(color.r)
|
||||
greens.push(color.g)
|
||||
blues.push(color.b)
|
||||
}
|
||||
}
|
||||
return { r: median(reds), g: median(greens), b: median(blues) }
|
||||
}
|
||||
|
||||
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 referenceColor = getFloodFillReferenceColor(image, startX, startY, options.tolerance)
|
||||
const referenceIsDark = relativeLuminance(referenceColor) <= 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
|
||||
|
||||
const isAccepted = (index: number) => {
|
||||
if (visited[index] || options.occupancy?.[index]) return false
|
||||
if (index === startIndex) return true
|
||||
const color = pixelColor(data, index)
|
||||
if (!referenceIsDark && relativeLuminance(color) <= options.darkBoundaryThreshold) return false
|
||||
return colorDistance(color, referenceColor) <= 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)
|
||||
|
||||
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(referenceColor.r)
|
||||
greens.push(referenceColor.g)
|
||||
blues.push(referenceColor.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 mask = createRegionMask(width, height, orderedRuns.flatMap((run) => run))
|
||||
const summary = summarizeRegionMask(mask)
|
||||
if (!summary) return null
|
||||
|
||||
return {
|
||||
mask,
|
||||
pixelCount,
|
||||
boundingBox: summary.boundingBox,
|
||||
labelPosition: summary.labelPosition,
|
||||
color: { r: median(reds), g: median(greens), b: median(blues) },
|
||||
}
|
||||
}
|
||||
159
frontend/src/utils/projectExport.ts
Normal file
@ -0,0 +1,159 @@
|
||||
import type {
|
||||
ColorGroup,
|
||||
MosaicRegion,
|
||||
MosaicSettings,
|
||||
ProjectDimensions,
|
||||
ProjectFile,
|
||||
} from '@/types/mosaic'
|
||||
import { relativeLuminance, rgbToHex } from './colors'
|
||||
import { forEachMaskRun, resolveRegionLabelPosition } 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.hidden = true
|
||||
document.body.append(link)
|
||||
try {
|
||||
link.click()
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
link.remove()
|
||||
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,
|
||||
useUserClickPositionForLabels = false,
|
||||
): 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 labelPosition = resolveRegionLabelPosition(region, useUserClickPositionForLabels)
|
||||
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, labelPosition.x, labelPosition.y)
|
||||
context.fillText(label, labelPosition.x, 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 },
|
||||
userClickPosition: region.userClickPosition ? { ...region.userClickPosition } : undefined,
|
||||
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
@ -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()
|
||||
}
|
||||
111
frontend/src/utils/regionMask.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { markRaw } from 'vue'
|
||||
import type { BoundingBox, MosaicRegion, Point, RegionMask } from '@/types/mosaic'
|
||||
|
||||
export interface RegionMaskSummary {
|
||||
pixelCount: number
|
||||
boundingBox: BoundingBox
|
||||
labelPosition: Point
|
||||
}
|
||||
|
||||
export function resolveRegionLabelPosition(
|
||||
region: MosaicRegion,
|
||||
useUserClickPositionForLabels: boolean,
|
||||
): Point {
|
||||
return useUserClickPositionForLabels
|
||||
? region.userClickPosition ?? region.labelPosition
|
||||
: region.labelPosition
|
||||
}
|
||||
|
||||
export function createRegionMask(width: number, height: number, runs: number[]): RegionMask {
|
||||
return markRaw({ width, height, runs: markRaw(Uint32Array.from(runs)) })
|
||||
}
|
||||
|
||||
export function subtractRegionMask(source: RegionMask, removed: RegionMask): RegionMask | null {
|
||||
const removedByRow = new Map<number, Array<[number, number]>>()
|
||||
forEachMaskRun(removed, (y, xStart, xEnd) => {
|
||||
const row = removedByRow.get(y) ?? []
|
||||
row.push([xStart, xEnd])
|
||||
removedByRow.set(y, row)
|
||||
})
|
||||
|
||||
const remainingRuns: number[] = []
|
||||
forEachMaskRun(source, (y, xStart, xEnd) => {
|
||||
let segments: Array<[number, number]> = [[xStart, xEnd]]
|
||||
for (const [cutStart, cutEnd] of removedByRow.get(y) ?? []) {
|
||||
const nextSegments: Array<[number, number]> = []
|
||||
for (const [segmentStart, segmentEnd] of segments) {
|
||||
if (cutEnd < segmentStart || cutStart > segmentEnd) {
|
||||
nextSegments.push([segmentStart, segmentEnd])
|
||||
continue
|
||||
}
|
||||
if (cutStart > segmentStart) nextSegments.push([segmentStart, cutStart - 1])
|
||||
if (cutEnd < segmentEnd) nextSegments.push([cutEnd + 1, segmentEnd])
|
||||
}
|
||||
segments = nextSegments
|
||||
}
|
||||
for (const [segmentStart, segmentEnd] of segments) remainingRuns.push(y, segmentStart, segmentEnd)
|
||||
})
|
||||
|
||||
return remainingRuns.length ? createRegionMask(source.width, source.height, remainingRuns) : null
|
||||
}
|
||||
|
||||
export function summarizeRegionMask(mask: RegionMask): RegionMaskSummary | null {
|
||||
let pixelCount = 0
|
||||
let minX = mask.width
|
||||
let minY = mask.height
|
||||
let maxX = -1
|
||||
let maxY = -1
|
||||
const runs: Array<[number, number, number]> = []
|
||||
forEachMaskRun(mask, (y, xStart, xEnd) => {
|
||||
runs.push([y, xStart, xEnd])
|
||||
pixelCount += xEnd - xStart + 1
|
||||
minX = Math.min(minX, xStart)
|
||||
minY = Math.min(minY, y)
|
||||
maxX = Math.max(maxX, xEnd)
|
||||
maxY = Math.max(maxY, y)
|
||||
})
|
||||
if (!pixelCount) return null
|
||||
|
||||
const centerY = (minY + maxY) / 2
|
||||
const labelRun = runs.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
|
||||
}, runs[0] ?? [0, 0, 0])
|
||||
|
||||
return {
|
||||
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] },
|
||||
}
|
||||
}
|
||||
|
||||
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
@ -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>
|
||||
18
frontend/tsconfig.app.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
// Extra safety for array and object lookups, but may have false positives.
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
// Path mapping for cleaner imports.
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
11
frontend/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
27
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,27 @@
|
||||
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||
// Bundler mode provides a smoother developer experience.
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
||||
"types": ["node"],
|
||||
|
||||
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||
"noEmit": true,
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
18
frontend/vite.config.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
})
|
||||