zakladna implementacia by Codex
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useMosaicStore } from '@/stores/mosaic'
|
||||
|
||||
defineEmits<{ resetViewport: [] }>()
|
||||
const store = useMosaicStore()
|
||||
const { hasImage, canUndo, canRedo, regions } = storeToRefs(store)
|
||||
const imageInput = ref<HTMLInputElement | null>(null)
|
||||
const projectInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function loadImage(files: FileList | null): void {
|
||||
const file = files?.[0]
|
||||
if (file) void store.loadImage(file, file.name)
|
||||
}
|
||||
|
||||
function importProject(files: FileList | null): void {
|
||||
const file = files?.[0]
|
||||
if (file) void store.importProjectFile(file)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="toolbar">
|
||||
<div class="brand"><span class="brand-mark">M</span><span>Mozaic</span></div>
|
||||
<div class="toolbar-actions">
|
||||
<button class="button primary" type="button" @click="imageInput?.click()">Načítať obrázok</button>
|
||||
<button class="icon-button" type="button" :disabled="!canUndo" title="Späť (Ctrl+Z)" @click="store.undo">↶</button>
|
||||
<button class="icon-button" type="button" :disabled="!canRedo" title="Znova (Ctrl+Y)" @click="store.redo">↷</button>
|
||||
<button class="button" type="button" :disabled="!hasImage" @click="$emit('resetViewport')">Prispôsobiť</button>
|
||||
<span class="divider" />
|
||||
<button class="button" type="button" @click="projectInput?.click()">Import JSON</button>
|
||||
<button class="button danger-quiet" type="button" :disabled="!regions.length" @click="store.clearResults">Vymazať výsledky</button>
|
||||
</div>
|
||||
<input ref="imageInput" hidden type="file" accept="image/png,image/jpeg,image/webp" @change="loadImage(($event.target as HTMLInputElement).files)" />
|
||||
<input ref="projectInput" hidden type="file" accept="application/json,.json" @change="importProject(($event.target as HTMLInputElement).files)" />
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { position: sticky; top: 0; z-index: 10; display: flex; min-height: 58px; align-items: center; gap: 24px; padding: 8px 16px; border-bottom: 1px solid #dbe1e8; background: rgba(255,255,255,.96); }
|
||||
.brand { display: flex; align-items: center; gap: 9px; color: #172033; font-weight: 760; letter-spacing: -.02em; }
|
||||
.brand-mark { display: grid; width: 30px; height: 30px; place-items: center; border-radius: 8px; background: #1e293b; color: white; }
|
||||
.toolbar-actions { display: flex; min-width: 0; align-items: center; gap: 7px; overflow-x: auto; }
|
||||
.divider { width: 1px; height: 26px; margin: 0 2px; background: #dbe1e8; }
|
||||
@media (max-width: 700px) { .toolbar { align-items: flex-start; gap: 10px; padding: 8px 10px; } .brand span:last-child { display: none; } }
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useMosaicStore } from '@/stores/mosaic'
|
||||
import { canvasToBlob, createPaletteCsv, downloadBlob, renderExportCanvas } from '@/utils/projectExport'
|
||||
|
||||
const store = useMosaicStore()
|
||||
const { imageCanvas, imageName, imageWidth, imageHeight, regions, groups, settings } = storeToRefs(store)
|
||||
const baseName = () => (imageName.value.replace(/\.[^.]+$/, '').replace(/[^\p{L}\p{N}_-]+/gu, '-') || 'mozaika')
|
||||
|
||||
async function exportPng(): Promise<void> {
|
||||
if (!imageCanvas.value) return store.notify('Najprv načítajte obrázok.', 'warning')
|
||||
try {
|
||||
const canvas = renderExportCanvas(imageCanvas.value, { width: imageWidth.value, height: imageHeight.value }, regions.value, groups.value, settings.value.exportOverlay)
|
||||
downloadBlob(await canvasToBlob(canvas), `${baseName()}-ocislovane.png`)
|
||||
} catch (error) { store.notify(error instanceof Error ? error.message : 'Export PNG zlyhal.', 'error') }
|
||||
}
|
||||
|
||||
function exportCsv(): void {
|
||||
if (!groups.value.length) return store.notify('Paleta je zatiaľ prázdna.', 'warning')
|
||||
downloadBlob(new Blob([createPaletteCsv(groups.value, regions.value)], { type: 'text/csv;charset=utf-8' }), `${baseName()}-paleta.csv`)
|
||||
}
|
||||
|
||||
async function exportJson(): Promise<void> {
|
||||
if (!imageWidth.value) return store.notify('Nie je čo exportovať.', 'warning')
|
||||
const project = await store.serializeProject(true)
|
||||
downloadBlob(new Blob([JSON.stringify(project)], { type: 'application/json' }), `${baseName()}-projekt.json`)
|
||||
if (!project.imageDataUrl) store.notify('Obrázok je väčší než 5 MB. Pri importe JSON ho bude potrebné znova vybrať.', 'info')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel-section export-section">
|
||||
<h2>Export</h2>
|
||||
<label class="check"><input v-model="settings.exportOverlay" type="checkbox" /> Zahrnúť farebné prekrytie do PNG</label>
|
||||
<div class="export-grid">
|
||||
<button class="button" type="button" :disabled="!imageCanvas" @click="exportPng">Očíslované PNG</button>
|
||||
<button class="button" type="button" :disabled="!groups.length" @click="exportCsv">Paleta CSV</button>
|
||||
<button class="button" type="button" :disabled="!imageWidth" @click="exportJson">Projekt JSON</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.export-section { border-bottom: 0; }
|
||||
.check { display: flex; align-items: center; gap: 7px; margin-top: 10px; color: #475569; font-size: .72rem; }
|
||||
.export-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 11px; }
|
||||
.export-grid .button:last-child { grid-column: 1 / -1; }
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useMosaicStore } from '@/stores/mosaic'
|
||||
import { rgbToHex, relativeLuminance } from '@/utils/colors'
|
||||
import { forEachMaskRun } from '@/utils/regionMask'
|
||||
import { useCanvasViewport } from '@/composables/useCanvasViewport'
|
||||
import ImageDropzone from './ImageDropzone.vue'
|
||||
|
||||
const store = useMosaicStore()
|
||||
const { imageCanvas, imageWidth, imageHeight, regions, groups, selectedGroupId, selectedRegionId, settings, isAnalyzing } = storeToRefs(store)
|
||||
const host = ref<HTMLDivElement | null>(null)
|
||||
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||
const { viewport, fit, zoomAt } = useCanvasViewport()
|
||||
let observer: ResizeObserver | null = null
|
||||
let dragStart: { x: number; y: number; viewportX: number; viewportY: number } | null = null
|
||||
let moved = false
|
||||
|
||||
function resize(): void {
|
||||
const element = canvas.value
|
||||
const container = host.value
|
||||
if (!element || !container) return
|
||||
const rectangle = container.getBoundingClientRect()
|
||||
const ratio = window.devicePixelRatio || 1
|
||||
element.width = Math.max(1, Math.round(rectangle.width * ratio))
|
||||
element.height = Math.max(1, Math.round(rectangle.height * ratio))
|
||||
element.style.width = `${rectangle.width}px`
|
||||
element.style.height = `${rectangle.height}px`
|
||||
draw()
|
||||
}
|
||||
|
||||
function resetViewport(): void {
|
||||
const rectangle = host.value?.getBoundingClientRect()
|
||||
if (!rectangle) return
|
||||
fit(rectangle.width, rectangle.height, imageWidth.value, imageHeight.value)
|
||||
draw()
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
const element = canvas.value
|
||||
if (!element) return
|
||||
const context = element.getContext('2d')
|
||||
if (!context) return
|
||||
const ratio = window.devicePixelRatio || 1
|
||||
const cssWidth = element.width / ratio
|
||||
const cssHeight = element.height / ratio
|
||||
context.setTransform(ratio, 0, 0, ratio, 0, 0)
|
||||
context.clearRect(0, 0, cssWidth, cssHeight)
|
||||
context.fillStyle = '#e7ebf0'
|
||||
context.fillRect(0, 0, cssWidth, cssHeight)
|
||||
if (!imageCanvas.value) return
|
||||
|
||||
context.save()
|
||||
context.translate(viewport.x, viewport.y)
|
||||
context.scale(viewport.scale, viewport.scale)
|
||||
context.imageSmoothingEnabled = viewport.scale < 3
|
||||
context.drawImage(imageCanvas.value, 0, 0)
|
||||
const groupMap = new Map(groups.value.map((group) => [group.id, group]))
|
||||
for (const region of regions.value) {
|
||||
const group = groupMap.get(region.groupId)
|
||||
if (!group) continue
|
||||
const selected = group.id === selectedGroupId.value
|
||||
if (!selected && selectedGroupId.value && settings.value.otherGroupsMode === 'hidden') continue
|
||||
context.globalAlpha = selected ? 0.52 : selectedGroupId.value && settings.value.otherGroupsMode === 'dim' ? 0.08 : 0.24
|
||||
context.fillStyle = rgbToHex(group.color)
|
||||
forEachMaskRun(region.mask, (y, xStart, xEnd) => context.fillRect(xStart, y, xEnd - xStart + 1, 1))
|
||||
}
|
||||
context.restore()
|
||||
|
||||
context.save()
|
||||
context.setTransform(ratio, 0, 0, ratio, 0, 0)
|
||||
context.textAlign = 'center'
|
||||
context.textBaseline = 'middle'
|
||||
context.font = '700 14px system-ui, sans-serif'
|
||||
for (const region of regions.value) {
|
||||
const group = groupMap.get(region.groupId)
|
||||
if (!group) continue
|
||||
if (selectedGroupId.value && settings.value.otherGroupsMode === 'hidden' && group.id !== selectedGroupId.value) continue
|
||||
const x = viewport.x + region.labelPosition.x * viewport.scale
|
||||
const y = viewport.y + region.labelPosition.y * viewport.scale
|
||||
const active = region.id === selectedRegionId.value
|
||||
context.beginPath()
|
||||
context.arc(x, y, active ? 13 : 11, 0, Math.PI * 2)
|
||||
context.fillStyle = relativeLuminance(group.color) > 0.45 ? 'rgba(255,255,255,.94)' : 'rgba(15,23,42,.9)'
|
||||
context.fill()
|
||||
if (active) {
|
||||
context.lineWidth = 2
|
||||
context.strokeStyle = '#2563eb'
|
||||
context.stroke()
|
||||
}
|
||||
context.fillStyle = relativeLuminance(group.color) > 0.45 ? '#0f172a' : '#ffffff'
|
||||
context.fillText(String(group.number), x, y + 0.5)
|
||||
}
|
||||
context.restore()
|
||||
}
|
||||
|
||||
function pointerDown(event: PointerEvent): void {
|
||||
if (!imageCanvas.value) return
|
||||
canvas.value?.setPointerCapture(event.pointerId)
|
||||
dragStart = { x: event.clientX, y: event.clientY, viewportX: viewport.x, viewportY: viewport.y }
|
||||
moved = false
|
||||
}
|
||||
|
||||
function pointerMove(event: PointerEvent): void {
|
||||
if (!dragStart) return
|
||||
const dx = event.clientX - dragStart.x
|
||||
const dy = event.clientY - dragStart.y
|
||||
if (Math.hypot(dx, dy) > 4) moved = true
|
||||
if (moved) {
|
||||
viewport.x = dragStart.viewportX + dx
|
||||
viewport.y = dragStart.viewportY + dy
|
||||
draw()
|
||||
}
|
||||
}
|
||||
|
||||
function pointerUp(event: PointerEvent): void {
|
||||
const start = dragStart
|
||||
dragStart = null
|
||||
if (!start || moved || !canvas.value || !imageCanvas.value) return
|
||||
const rectangle = canvas.value.getBoundingClientRect()
|
||||
const x = Math.floor((event.clientX - rectangle.left - viewport.x) / viewport.scale)
|
||||
const y = Math.floor((event.clientY - rectangle.top - viewport.y) / viewport.scale)
|
||||
if (x < 0 || y < 0 || x >= imageWidth.value || y >= imageHeight.value) return
|
||||
store.addRegionAt(x, y)
|
||||
}
|
||||
|
||||
function wheel(event: WheelEvent): void {
|
||||
if (!imageCanvas.value || !canvas.value) return
|
||||
event.preventDefault()
|
||||
const rectangle = canvas.value.getBoundingClientRect()
|
||||
zoomAt(event.deltaY < 0 ? 1.13 : 1 / 1.13, event.clientX - rectangle.left, event.clientY - rectangle.top)
|
||||
draw()
|
||||
}
|
||||
|
||||
watch([imageCanvas, imageWidth, imageHeight], async () => { await nextTick(); resetViewport() })
|
||||
watch([regions, groups, selectedGroupId, selectedRegionId, () => settings.value.otherGroupsMode], draw, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
observer = new ResizeObserver(resize)
|
||||
if (host.value) observer.observe(host.value)
|
||||
resize()
|
||||
})
|
||||
onBeforeUnmount(() => observer?.disconnect())
|
||||
|
||||
defineExpose({ resetViewport })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="host" class="canvas-host" :class="{ analyzing: isAnalyzing }">
|
||||
<canvas
|
||||
ref="canvas"
|
||||
aria-label="Editor mozaiky"
|
||||
@pointerdown="pointerDown"
|
||||
@pointermove="pointerMove"
|
||||
@pointerup="pointerUp"
|
||||
@pointercancel="dragStart = null"
|
||||
@wheel="wheel"
|
||||
/>
|
||||
<ImageDropzone v-if="!imageCanvas" @file="store.loadImage($event, $event.name)" />
|
||||
<div v-if="isAnalyzing" class="busy"><span class="spinner" />Analyzujem plochu…</div>
|
||||
<div v-if="imageCanvas" class="canvas-help">Kliknutie označí plochu · ťahanie posúva · koliesko približuje</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.canvas-host { position: relative; width: 100%; height: 100%; min-height: 460px; overflow: hidden; background: #e7ebf0; }
|
||||
canvas { display: block; touch-action: none; cursor: crosshair; }
|
||||
.canvas-host:active canvas { cursor: grabbing; }
|
||||
.canvas-host.analyzing canvas { cursor: progress; }
|
||||
.canvas-help { position: absolute; right: 12px; bottom: 12px; padding: 6px 9px; border: 1px solid rgba(148,163,184,.6); border-radius: 7px; background: rgba(255,255,255,.88); color: #475569; font-size: .72rem; pointer-events: none; backdrop-filter: blur(4px); }
|
||||
.busy { position: absolute; top: 14px; left: 50%; display: flex; align-items: center; gap: 8px; transform: translateX(-50%); padding: 8px 12px; border-radius: 8px; background: #0f172a; color: white; font-size: .82rem; box-shadow: 0 5px 20px #0f172a33; }
|
||||
.spinner { width: 13px; height: 13px; border: 2px solid #ffffff55; border-top-color: white; border-radius: 50%; animation: spin .7s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (max-width: 800px) { .canvas-host { min-height: 55vh; } .canvas-help { display: none; } }
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useMosaicStore } from '@/stores/mosaic'
|
||||
|
||||
const store = useMosaicStore()
|
||||
const { settings } = storeToRefs(store)
|
||||
|
||||
function updateColorTolerance(event: Event): void {
|
||||
store.setColorTolerance(Number((event.target as HTMLInputElement).value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel-section">
|
||||
<h2>Nastavenia analýzy</h2>
|
||||
<label class="range-field">
|
||||
<span><b>Tolerancia výberu</b><output>{{ settings.floodTolerance }}</output></span>
|
||||
<input v-model.number="settings.floodTolerance" type="range" min="2" max="100" step="1" />
|
||||
<small>Vyššia hodnota spojí väčšie farebné odchýlky v jednej ploche.</small>
|
||||
</label>
|
||||
<label class="range-field">
|
||||
<span><b>Podobnosť farieb (ΔE)</b><output>{{ settings.colorTolerance }}</output></span>
|
||||
<input :value="settings.colorTolerance" type="range" min="1" max="40" step="1" @change="updateColorTolerance" />
|
||||
<small>Po zmene sa farebné skupiny automaticky prepočítajú.</small>
|
||||
</label>
|
||||
<div class="field-row">
|
||||
<label>
|
||||
<span>Susednosť</span>
|
||||
<select v-model.number="settings.connectivity">
|
||||
<option :value="4">4-smery</option>
|
||||
<option :value="8">8-smerov</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Ostatné skupiny</span>
|
||||
<select v-model="settings.otherGroupsMode">
|
||||
<option value="normal">Normálne</option>
|
||||
<option value="dim">Stlmiť</option>
|
||||
<option value="hidden">Skryť</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<details>
|
||||
<summary>Rozšírené</summary>
|
||||
<label class="range-field compact">
|
||||
<span><b>Prahová tmavosť obrysu</b><output>{{ settings.darkBoundaryThreshold.toFixed(3) }}</output></span>
|
||||
<input v-model.number="settings.darkBoundaryThreshold" type="range" min="0" max="0.2" step="0.005" />
|
||||
</label>
|
||||
</details>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.range-field { display: grid; gap: 7px; margin-top: 14px; }
|
||||
.range-field > span { display: flex; justify-content: space-between; gap: 12px; font-size: .78rem; }
|
||||
.range-field b { font-weight: 650; }
|
||||
.range-field output { min-width: 34px; padding: 1px 6px; border-radius: 5px; background: #e8edf3; color: #334155; text-align: center; font-variant-numeric: tabular-nums; }
|
||||
.range-field small { color: #718096; font-size: .69rem; line-height: 1.35; }
|
||||
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin-top: 14px; }
|
||||
.field-row label { display: grid; gap: 5px; color: #475569; font-size: .72rem; }
|
||||
select { width: 100%; }
|
||||
details { margin-top: 13px; color: #475569; font-size: .76rem; }
|
||||
summary { cursor: pointer; }
|
||||
.compact { margin-bottom: 4px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user