zakladna implementacia by Codex

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