// dwg-counting frontend (() => { const $ = (id) => document.getElementById(id); const sections = { upload: $("s-upload"), processing: $("s-processing"), symbols: $("s-symbols"), blocks: $("s-blocks"), results: $("s-results"), }; const show = (name) => { for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== name); }; let state = { jobId: null, symbols: [], selected: new Set(), results: [] }; // Upload handlers const fileInput = $("file-input"); const dropZone = $("drop-zone"); $("browse-btn").addEventListener("click", () => fileInput.click()); fileInput.addEventListener("change", (e) => e.target.files[0] && upload(e.target.files[0])); ["dragenter", "dragover"].forEach((ev) => dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); })); ["dragleave", "drop"].forEach((ev) => dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.remove("drag-over"); })); dropZone.addEventListener("drop", (e) => e.dataTransfer.files[0] && upload(e.dataTransfer.files[0])); async function upload(file) { show("processing"); $("processing-title").textContent = "Připravuji výkres…"; const fd = new FormData(); fd.append("file", file); try { // Skip auto-detection by default — user defines symbols manually const r = await fetch("api/upload?auto_detect=false", { method: "POST", body: fd, }); if (!r.ok) throw new Error((await r.json()).detail || r.statusText); const data = await r.json(); state.jobId = data.job_id; if (data.mode === "blocks") { state.blocks = data.blocks || []; state.blockSel = new Set(); state.regionActive = false; renderBlocks(data); show("blocks"); return; } state.symbols = data.symbols || []; state.selected = new Set(); renderSymbols(); show("symbols"); } catch (err) { alert("Chyba: " + err.message); show("upload"); } } $("auto-detect-btn").addEventListener("click", async () => { if (!state.jobId) return; show("processing"); $("processing-title").textContent = "Hledám legendu pomocí AI…"; try { const r = await fetch(`api/auto-detect/${state.jobId}`, { method: "POST" }); if (!r.ok) throw new Error((await r.json()).detail || r.statusText); const data = await r.json(); state.symbols = data.symbols || []; renderSymbols(); show("symbols"); } catch (err) { alert("Chyba: " + err.message); show("symbols"); } }); $("add-symbol-btn").addEventListener("click", () => { openAddSymbolModal(); }); $("upload-symbol-btn").addEventListener("click", () => { $("symbol-file-input").click(); }); $("symbol-file-input").addEventListener("change", async (e) => { const f = e.target.files[0]; if (!f) return; const name = prompt("Název symbolu:", f.name.replace(/\.[a-z]+$/i, "")); if (!name) return; const fd = new FormData(); fd.append("file", f); fd.append("description", name); try { const r = await fetch(`api/symbols/${state.jobId}/upload`, { method: "POST", body: fd }); if (!r.ok) throw new Error((await r.json()).detail || r.statusText); const sym = await r.json(); state.symbols.push(sym); renderSymbols(); } catch (err) { alert("Chyba: " + err.message); } e.target.value = ""; }); function renderSymbols() { const meta = $("symbols-meta"); if (!state.symbols.length) { meta.textContent = 'Klikněte „+ Přidat symbol" a vyznačte ve výkresu, co chcete počítat. Nebo zkuste „Auto-detekce z legendy" pro automatický návrh.'; $("symbols-grid").innerHTML = ""; return; } meta.textContent = `${state.symbols.length} symbolů. Zaškrtněte k spočítání, ✎ upravit, 🗑 smazat.`; const grid = $("symbols-grid"); grid.innerHTML = ""; for (const s of state.symbols) { const card = document.createElement("div"); card.className = "symbol-card"; card.innerHTML = `
${s.id}
${escapeHtml(s.description || "")}
`; const cb = card.querySelector("input"); cb.addEventListener("change", () => { if (cb.checked) { state.selected.add(s.id); card.classList.add("selected"); } else { state.selected.delete(s.id); card.classList.remove("selected"); } $("count-btn").disabled = state.selected.size === 0; }); // Click on row toggles checkbox (except on edit btn / thumb) card.addEventListener("click", (e) => { if (e.target.closest(".symbol-edit") || e.target === cb) return; cb.checked = !cb.checked; cb.dispatchEvent(new Event("change")); }); card.querySelector(".symbol-edit").addEventListener("click", (e) => { e.stopPropagation(); openCropModal(s); }); card.querySelector(".symbol-debug").addEventListener("click", (e) => { e.stopPropagation(); openDebugModal(s); }); card.querySelector(".symbol-delete").addEventListener("click", async (e) => { e.stopPropagation(); if (!confirm(`Smazat symbol "${s.description || s.id}"?`)) return; try { const r = await fetch(`api/symbols/${state.jobId}/${s.id}`, { method: "DELETE" }); if (!r.ok) throw new Error("delete failed"); state.symbols = state.symbols.filter(x => x.id !== s.id); state.selected.delete(s.id); renderSymbols(); $("count-btn").disabled = state.selected.size === 0; } catch (err) { alert("Chyba: " + err.message); } }); grid.appendChild(card); } } // Add-new-symbol modal: same crop modal, but uses full drawing image and // asks for a name. function openAddSymbolModal() { const modal = $("crop-modal"); const img = $("crop-img"); const sel = $("crop-selection"); const wrap = $("crop-canvas-wrap"); $("crop-modal-title").textContent = "Nový symbol — vyznačte oblast ve výkresu"; $("crop-modal-hint").textContent = "DOPORUČENO: najděte symbol PŘÍMO ve výkresu (ne v legendě) — barvy a tloušťka čar tam přesně odpovídají tomu, co budeme hledat. Vyznačte těsný rámeček kolem jedné instance symbolu a pojmenujte ho."; $("crop-name-row").classList.remove("hidden"); $("crop-name").value = ""; img.src = `api/drawing/${state.jobId}`; sel.style.display = "none"; $("crop-save").disabled = true; cropState = { mode: "create", sym: null, bbox: null, dragging: false }; modal.classList.remove("hidden"); attachCropHandlers(wrap, img, sel); } // ── Crop modal logic ────────────────────── let cropState = null; function openCropModal(sym) { const modal = $("crop-modal"); const img = $("crop-img"); const sel = $("crop-selection"); const wrap = $("crop-canvas-wrap"); $("crop-modal-title").textContent = `Upravit symbol: ${sym.description || sym.id}`; $("crop-modal-hint").textContent = "Označte oblast obsahující pouze grafický symbol (bez popisu)."; $("crop-name-row").classList.add("hidden"); // For editing: use the full drawing so user can pick any instance img.src = `api/drawing/${state.jobId}`; sel.style.display = "none"; $("crop-save").disabled = true; cropState = { mode: "edit", sym, bbox: null, dragging: false }; modal.classList.remove("hidden"); attachCropHandlers(wrap, img, sel); return; } // Crop modal: zoom + pan + bbox selection. // • Wheel = zoom in/out at cursor (1.2× per notch). // • Left-drag = draw selection rectangle. // • Shift+drag or middle-button drag = pan. // • Selection box is tracked in image-natural pixels, so it stays // attached to the same image region across zoom changes. // • Initial scale is "fit": the whole drawing visible in the modal. function attachCropHandlers(wrap, img, sel) { let scale = 1; let curBox = null; // {x,y,w,h} in image-natural pixels (or null) let dragging = false; let startX = 0, startY = 0; // drag start in image-natural pixels let panning = false; let panFromX = 0, panFromY = 0, panScrollX = 0, panScrollY = 0; const setBtnState = () => { const ok = !!curBox && curBox.w >= 3 && curBox.h >= 3; $("crop-save").disabled = !ok; }; function clientToImg(clientX, clientY) { const rect = img.getBoundingClientRect(); return { x: (clientX - rect.left) / scale, y: (clientY - rect.top) / scale }; } function drawSel() { if (!curBox || curBox.w <= 0 || curBox.h <= 0) { sel.style.display = "none"; return; } sel.style.display = "block"; sel.style.left = (curBox.x * scale) + "px"; sel.style.top = (curBox.y * scale) + "px"; sel.style.width = (curBox.w * scale) + "px"; sel.style.height = (curBox.h * scale) + "px"; } function applyScale() { img.style.width = (img.naturalWidth * scale) + "px"; img.style.height = "auto"; drawSel(); } function setScale(newScale, anchorClientX, anchorClientY) { newScale = Math.max(0.05, Math.min(20, newScale)); const imgRect = img.getBoundingClientRect(); const wrapRect = wrap.getBoundingClientRect(); // Image-natural coords under the cursor BEFORE the zoom. const imgCx = (anchorClientX - imgRect.left) / scale; const imgCy = (anchorClientY - imgRect.top) / scale; // Cursor position relative to the wrap viewport. const cx = anchorClientX - wrapRect.left; const cy = anchorClientY - wrapRect.top; scale = newScale; applyScale(); // Keep the same image point under the cursor after the resize. wrap.scrollLeft = imgCx * scale - cx; wrap.scrollTop = imgCy * scale - cy; } function fitToModal() { const wrapRect = wrap.getBoundingClientRect(); const sx = wrapRect.width / img.naturalWidth; const sy = wrapRect.height / img.naturalHeight; scale = Math.min(sx, sy, 1); applyScale(); wrap.scrollLeft = 0; wrap.scrollTop = 0; } // Reset every time a new image loads. img.addEventListener("load", () => { curBox = null; sel.style.display = "none"; // Wait one frame so wrap has its measured size. requestAnimationFrame(fitToModal); setBtnState(); }); // If the image is already loaded (cached), fit now too. if (img.complete && img.naturalWidth) { curBox = null; requestAnimationFrame(fitToModal); } function onMouseDown(e) { if (e.button === 1 || (e.button === 0 && e.shiftKey)) { panning = true; panFromX = e.clientX; panFromY = e.clientY; panScrollX = wrap.scrollLeft; panScrollY = wrap.scrollTop; wrap.style.cursor = "grabbing"; e.preventDefault(); return; } if (e.button !== 0) return; const p = clientToImg(e.clientX, e.clientY); dragging = true; startX = p.x; startY = p.y; curBox = { x: p.x, y: p.y, w: 0, h: 0 }; drawSel(); e.preventDefault(); } function onMouseMove(e) { if (panning) { wrap.scrollLeft = panScrollX - (e.clientX - panFromX); wrap.scrollTop = panScrollY - (e.clientY - panFromY); return; } if (!dragging) return; const p = clientToImg(e.clientX, e.clientY); curBox = { x: Math.min(p.x, startX), y: Math.min(p.y, startY), w: Math.abs(p.x - startX), h: Math.abs(p.y - startY), }; drawSel(); } function onMouseUp() { if (panning) { panning = false; wrap.style.cursor = ""; } if (!dragging) return; dragging = false; if (!curBox || curBox.w < 3 || curBox.h < 3) { curBox = null; sel.style.display = "none"; } else { const W = img.naturalWidth, H = img.naturalHeight; cropState.bbox = { x: curBox.x / W, y: curBox.y / H, w: curBox.w / W, h: curBox.h / H, }; } setBtnState(); } function onWheel(e) { e.preventDefault(); const factor = e.deltaY < 0 ? 1.2 : 1 / 1.2; setScale(scale * factor, e.clientX, e.clientY); } wrap.onmousedown = onMouseDown; wrap.onmousemove = onMouseMove; wrap.onmouseup = onMouseUp; wrap.onmouseleave = onMouseUp; // Replace any prior wheel listener (close/reopen of the modal). if (wrap._onWheel) wrap.removeEventListener("wheel", wrap._onWheel); wrap._onWheel = onWheel; wrap.addEventListener("wheel", onWheel, { passive: false }); // Expose for toolbar buttons. wrap._zoom = { in: () => setScale(scale * 1.5, wrap.getBoundingClientRect().left + wrap.clientWidth / 2, wrap.getBoundingClientRect().top + wrap.clientHeight / 2), out: () => setScale(scale / 1.5, wrap.getBoundingClientRect().left + wrap.clientWidth / 2, wrap.getBoundingClientRect().top + wrap.clientHeight / 2), fit: fitToModal, one: () => { scale = 1; applyScale(); }, }; } function closeCropModal() { $("crop-modal").classList.add("hidden"); cropState = null; } async function openDebugModal(sym) { const modal = $("debug-modal"); const body = $("debug-body"); $("debug-title").textContent = `Debug: ${sym.description || sym.id}`; body.innerHTML = '

Načítám diagnostiku…

'; modal.classList.remove("hidden"); try { const r = await fetch(`api/debug/${state.jobId}/${sym.id}`); if (!r.ok) throw new Error(await r.text()); const info = await r.json(); const tmplURL = `api/symbol/${state.jobId}/${sym.id}?v=${sym._v||0}`; const procURL = `api/debug-template/${state.jobId}/${sym.id}?v=${sym._v||0}&t=${Date.now()}`; const drawURL = `api/drawing/${state.jobId}`; const matches = info.matches_at_threshold || {}; const maxMatchCount = Math.max(1, ...Object.values(matches)); const threshRows = Object.entries(matches).map(([t, n]) => `
práh ${t}
${n} shod
`).join(""); const inkPct = info.template_total_pixels ? ((info.template_ink_pixels / info.template_total_pixels) * 100).toFixed(1) : "?"; body.innerHTML = `

Obrazy

Šablona (uložená)
Po předzpracování
(co matcher vidí)
otevřít celý výkres ↗ ${info.drawing_size.join(" × ")} px

Měření

Šablona originál${info.template_size.join(" × ")} px Šablona po ořezu${info.template_cropped_size.join(" × ")} px Inkoust v šabloně${info.template_ink_pixels} / ${info.template_total_pixels} px (${inkPct}%) Výkres${info.drawing_size.join(" × ")} px Nejlepší skóre${info.max_score?.toFixed(3) ?? "—"}

Shody při různých prazích (bez rotací, scale 1.0)

${threshRows}

Aktuálně používaný práh pro počítání: 0.75. Skutečné počty (s rotacemi 0/90/180/270° a třemi scale faktory) jsou typicky nižší kvůli dedupli­kaci.

`; // Heuristic interpretation const interpret = []; if (info.template_ink_pixels < 30) interpret.push('⚠ Šablona obsahuje příliš málo „inkoustu" — pravděpodobně jste vybrali převážně bílou plochu. Vyznačte rámeček těsně kolem čar symbolu.'); if (Math.min(...info.template_cropped_size) < 12) interpret.push("⚠ Šablona je velmi malá (<12 px). Malé šablony hlásí spoustu falešných shod nebo žádné. Zkuste přidat trochu okolí."); if ((info.max_score ?? 0) < 0.5) interpret.push("⚠ Nejlepší skóre v celém výkresu je velmi nízké — symbol vypadá v plánu jinak než v šabloně. Zkuste vyznačit symbol přímo z plánu (ne z legendy), nebo zkuste jinou variantu/orientaci."); else if ((info.max_score ?? 0) < 0.7) interpret.push("ℹ Nejlepší skóre ≈ " + info.max_score.toFixed(2) + ". Práh 0.75 je nad maximem — zkuste snížit práh nebo upravit šablonu těsněji."); else interpret.push("✓ Symbol se v plánu vyskytuje. Pokud nejsou shody, může jít o problém s rotacemi nebo měřítkem."); $("debug-interpret").innerHTML = "

Interpretace

" + interpret.map(t => `

${t}

`).join(""); } catch (err) { body.innerHTML = `

Chyba: ${err.message}

`; } } $("debug-close").addEventListener("click", () => $("debug-modal").classList.add("hidden")); $("debug-ok").addEventListener("click", () => $("debug-modal").classList.add("hidden")); $("crop-close").addEventListener("click", closeCropModal); $("crop-cancel").addEventListener("click", closeCropModal); $("crop-save").addEventListener("click", async () => { if (!cropState || !cropState.bbox) return; if (cropState.mode === "region" || cropState.mode === "region-area") return; // handled separately by the capture-phase dispatcher try { if (cropState.mode === "create") { const name = ($("crop-name").value || "").trim(); if (!name) { alert("Zadejte název symbolu."); return; } const r = await fetch(`api/symbols/${state.jobId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ bbox: cropState.bbox, description: name, source: "drawing", }), }); if (!r.ok) throw new Error((await r.json()).detail || r.statusText); const newSym = await r.json(); state.symbols.push(newSym); } else { const r = await fetch(`api/symbol/${state.jobId}/${cropState.sym.id}/recrop`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ bbox: cropState.bbox }), }); if (!r.ok) throw new Error((await r.json()).detail || r.statusText); cropState.sym._v = (cropState.sym._v || 0) + 1; } renderSymbols(); closeCropModal(); } catch (err) { alert("Chyba: " + err.message); } }); // Threshold slider live readout const thrSlider = $("threshold-slider"); const thrValue = $("threshold-value"); thrSlider.addEventListener("input", () => { thrValue.textContent = parseFloat(thrSlider.value).toFixed(2); }); $("count-btn").addEventListener("click", async () => { show("processing"); $("processing-title").textContent = `Počítám ${state.selected.size} symbolů…`; try { const r = await fetch(`api/count/${state.jobId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ symbol_ids: [...state.selected], threshold: parseFloat(thrSlider.value), }), }); if (!r.ok) throw new Error((await r.json()).detail || r.statusText); const data = await r.json(); state.results = data.results || []; state.resultsBack = "symbols"; renderResults(); show("results"); } catch (err) { alert("Chyba: " + err.message); show("symbols"); } }); function renderResults() { const total = state.results.reduce((a, r) => a + (r.count || 0), 0); $("results-meta").textContent = `Celkem ${total} symbolů ve ${state.results.length} kategoriích.`; const tb = $("results-tbody"); tb.innerHTML = ""; for (const r of state.results) { const tr = document.createElement("tr"); const conf = r.confidence || ""; const thumb = r.id === "region" ? "" : ``; tr.innerHTML = ` ${thumb} ${escapeHtml(r.description || "")} ${r.count} ${conf || "—"} ${escapeHtml(r.notes || "")}`; tb.appendChild(tr); } } // Stream a slow file download with an in-flight overlay so the user // knows the server is working (DWG → PNG → annotated PDF takes minutes // on dense site plans). Falls back to a clear error message instead of // a silent browser spinner. function showBusyOverlay(title, sub) { let o = document.getElementById("busy-overlay"); if (!o) { o = document.createElement("div"); o.id = "busy-overlay"; o.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.55);" + "display:flex;align-items:center;justify-content:center;" + "z-index:9999;backdrop-filter:blur(2px);"; o.innerHTML = '
' + '
' + '
' + '
' + '
' + "
"; document.body.appendChild(o); } o.querySelector("#busy-title").textContent = title; o.querySelector("#busy-sub").textContent = sub || ""; const elapsedEl = o.querySelector("#busy-elapsed"); elapsedEl.textContent = "Uplynulo: 0 s"; const t0 = Date.now(); const timer = setInterval(() => { const s = Math.floor((Date.now() - t0) / 1000); const m = Math.floor(s / 60); elapsedEl.textContent = m ? `Uplynulo: ${m} min ${s % 60} s` : `Uplynulo: ${s} s`; }, 1000); o.style.display = "flex"; return () => { clearInterval(timer); o.style.display = "none"; }; } async function downloadWithProgress(url, label, btn, sub) { const wasDisabled = btn ? btn.disabled : false; const oldLabel = btn ? btn.textContent : ""; if (btn) { btn.disabled = true; btn.textContent = label + "…"; } const hideOverlay = showBusyOverlay(label, sub || "Server pracuje, prosím vyčkejte."); try { const r = await fetch(url); if (!r.ok) { let msg = r.statusText; try { msg = (await r.json()).detail || msg; } catch (_) {} throw new Error(msg); } const cd = r.headers.get("content-disposition") || ""; const m = cd.match(/filename="?([^"]+)"?/); const filename = m ? m[1] : "soubor"; const blob = await r.blob(); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 60000); } catch (err) { alert("Chyba při stahování: " + err.message); } finally { hideOverlay(); if (btn) { btn.disabled = wasDisabled; btn.textContent = oldLabel; } } } $("export-btn").addEventListener("click", (e) => { downloadWithProgress(`api/export/${state.jobId}`, "Vytvářím Excel", e.currentTarget, "Sestavuji tabulku s nalezenými symboly."); }); $("pdf-btn").addEventListener("click", (e) => { downloadWithProgress(`api/export-pdf/${state.jobId}`, "Vytvářím PDF s vyznačením", e.currentTarget, "U velkých výkresů to může trvat několik minut — server rastruje výkres ve vysokém rozlišení a kreslí značky."); }); $("back-btn").addEventListener("click", () => show(state.resultsBack || "symbols")); $("reset-btn").addEventListener("click", () => { state = { jobId: null, symbols: [], selected: new Set(), results: [] }; fileInput.value = ""; show("upload"); }); function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); } // ── Block picker (DWG/DXF exact counting) ─────────────────── function renderBlocks(data) { const grid = $("blocks-grid"); const showNoise = $("blocks-shownoise").checked; const list = state.blocks.filter((b) => showNoise || !b.noise); $("blocks-meta").textContent = `${data.total_instances} symbolů celkem · ${state.blocks.length} bloků` + (showNoise ? "" : ` · ${state.blocks.length - list.length} pomocných skryto`); grid.innerHTML = ""; for (const b of list) { const card = document.createElement("div"); card.className = "block-card" + (state.blockSel.has(b.idx) ? " is-sel" : ""); card.innerHTML = `
${escapeHtml(b.name)}
${b.count}×
`; card.addEventListener("click", () => { if (state.blockSel.has(b.idx)) state.blockSel.delete(b.idx); else state.blockSel.add(b.idx); card.classList.toggle("is-sel"); updateBlockTotal(); }); grid.appendChild(card); } updateBlockTotal(); } function updateBlockTotal() { const sel = state.blocks.filter((b) => state.blockSel.has(b.idx)); const total = sel.reduce((a, b) => a + b.count, 0); $("blocks-total").textContent = sel.length ? `Vybráno ${sel.length} bloků · celkem ${total} symbolů` : "Nic nevybráno"; const dis = sel.length === 0; $("blocks-export-btn").disabled = dis; $("blocks-pdf-btn").disabled = dis; } async function commitBlockSelection() { const idxs = [...state.blockSel]; if (!idxs.length) return false; const r = await fetch(`api/count-blocks/${state.jobId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ idxs }), }); if (!r.ok) { alert("Chyba: " + ((await r.json()).detail || r.statusText)); return false; } return true; } $("blocks-shownoise").addEventListener("change", () => renderBlocks( { total_instances: state.blocks.reduce((a, b) => a + b.count, 0) })); $("blocks-reset-btn").addEventListener("click", () => { state = { jobId: null, symbols: [], selected: new Set(), results: [] }; fileInput.value = ""; show("upload"); }); $("blocks-export-btn").addEventListener("click", async (e) => { // Region results are already stored server-side — export directly. if (state.regionActive || await commitBlockSelection()) downloadWithProgress(`api/export/${state.jobId}`, "Vytvářím Excel", e.currentTarget, "Sestavuji tabulku s nalezenými symboly."); }); $("blocks-pdf-btn").addEventListener("click", async (e) => { if (state.regionActive || await commitBlockSelection()) downloadWithProgress(`api/export-pdf/${state.jobId}`, "Vytvářím PDF s vyznačením", e.currentTarget, "U velkých výkresů to může trvat několik minut — server rastruje výkres ve vysokém rozlišení a kreslí značky."); }); // Crop modal zoom toolbar — delegates to the wrap's exposed handlers. function cropZoom(action) { const wrap = $("crop-canvas-wrap"); if (wrap && wrap._zoom && typeof wrap._zoom[action] === "function") wrap._zoom[action](); } $("crop-zoom-in").addEventListener("click", () => cropZoom("in")); $("crop-zoom-out").addEventListener("click", () => cropZoom("out")); $("crop-zoom-fit").addEventListener("click", () => cropZoom("fit")); $("crop-zoom-one").addEventListener("click", () => cropZoom("one")); // ── Region-select vector matching (non-block symbols) ─────── // Two stages: (1) box a coarse AREA on the overview, (2) the server // re-renders just that area at high DPI and the user precisely boxes // the SYMBOL on it. Stage 2's pixel coords go through the zoom // transform on the server, so symbol coords map correctly back to // model space even though they were measured on a sub-region image. function enterRegionAreaStage() { const modal = $("crop-modal"); const img = $("crop-img"); const sel = $("crop-selection"); const wrap = $("crop-canvas-wrap"); $("crop-modal-title").textContent = "Krok 1 / 2 — vyznačte oblast"; $("crop-modal-hint").textContent = "Vyznačte přibližnou oblast, ve které je hledaný symbol. " + "V dalším kroku oblast přiblížíme a vyberete přesně samotný symbol."; $("crop-name-row").classList.add("hidden"); $("crop-back-btn").classList.add("hidden"); $("crop-threshold-row").classList.add("hidden"); sel.style.display = "none"; $("crop-save").disabled = true; $("crop-save").textContent = "Pokračovat"; cropState = { mode: "region-area", bbox: null, dragging: false }; img.onload = () => { $("crop-modal-hint").textContent = "Vyznačte přibližnou oblast, ve které je hledaný symbol. " + "Bude přiblížena ve vysokém rozlišení."; $("crop-modal-hint").style.color = ""; }; img.onerror = async () => { let detail = "Server vrátil chybu."; try { const r = await fetch(`api/render/${state.jobId}`); if (r.status === 404) { detail = "Výkres už není v paměti serveru (server byl mezitím " + "restartován). Zavřete toto okno a nahrajte výkres znovu."; } else if (!r.ok) { try { detail = (await r.json()).detail || r.statusText; } catch (_) { detail = r.statusText; } } } catch (_) {} $("crop-modal-hint").textContent = "Chyba: " + detail; $("crop-modal-hint").style.color = "var(--danger, #c0392b)"; }; img.src = `api/render/${state.jobId}?t=${Date.now()}`; modal.classList.remove("hidden"); attachCropHandlers(wrap, img, sel); } $("region-match-btn").addEventListener("click", enterRegionAreaStage); async function runZoomRegion() { if (!cropState || !cropState.bbox) return; const btn = $("crop-save"); btn.disabled = true; btn.textContent = "Přibližuji…"; const hide = showBusyOverlay("Přibližuji oblast", "Renderuji oblast ve vysokém rozlišení."); try { const r = await fetch(`api/zoom-region/${state.jobId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ bbox: cropState.bbox }), }); if (!r.ok) throw new Error((await r.json()).detail || r.statusText); // Transition to stage 2. const img = $("crop-img"); const sel = $("crop-selection"); const wrap = $("crop-canvas-wrap"); $("crop-modal-title").textContent = "Krok 2 / 2 — vyznačte symbol"; $("crop-modal-hint").textContent = "Nakreslete těsný rámeček kolem jedné instance symbolu. " + "Najdeme všechny vizuálně podobné výskyty (i otočené a zrcadlené). " + "Citlivost nastavte níže."; sel.style.display = "none"; btn.textContent = "Najít symbol"; btn.disabled = true; $("crop-back-btn").classList.remove("hidden"); $("crop-threshold-row").classList.remove("hidden"); cropState = { mode: "region", bbox: null, dragging: false }; img.src = `api/zoom-render/${state.jobId}?t=${Date.now()}`; attachCropHandlers(wrap, img, sel); } catch (err) { alert("Chyba: " + err.message); btn.disabled = false; btn.textContent = "Pokračovat"; } finally { hide(); } } async function runRegionMatch() { if (!cropState || !cropState.bbox) return; const btn = $("crop-save"); btn.disabled = true; btn.textContent = "Hledám…"; const hide = showBusyOverlay("Hledám symbol", "Porovnávám vyznačený symbol s celým výkresem."); try { const r = await fetch(`api/match-raster/${state.jobId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ bbox: cropState.bbox, threshold: parseFloat($("crop-threshold").value), }), }); if (!r.ok) throw new Error((await r.json()).detail || r.statusText); const d = await r.json(); state.regionActive = true; closeCropModal(); $("crop-threshold-row").classList.add("hidden"); $("crop-save").textContent = "Uložit výřez"; // Show the dedicated results view (count + Excel + annotated PDF). state.results = [{ id: "region", description: "Vyznačený symbol (práh " + (+$("crop-threshold").value).toFixed(2) + ")", count: d.count, notes: d.count ? "" : "žádné shody nenalezeny", }]; state.resultsBack = "blocks"; renderResults(); show("results"); } catch (err) { alert("Chyba: " + err.message); btn.disabled = false; btn.textContent = "Najít symbol"; } finally { hide(); } } // Crop modal "Save" button dispatches by current stage. Capture phase // so it runs before the symbol-create flow (which no-ops on mode!=create). $("crop-save").addEventListener("click", () => { if (!cropState) return; if (cropState.mode === "region-area") runZoomRegion(); else if (cropState.mode === "region") runRegionMatch(); }, true); $("crop-back-btn").addEventListener("click", () => { if (cropState && (cropState.mode === "region" || cropState.mode === "region-area")) enterRegionAreaStage(); }); // Region threshold slider live value. const cropThr = $("crop-threshold"); if (cropThr) cropThr.addEventListener("input", () => { $("crop-threshold-value").textContent = (+cropThr.value).toFixed(2); }); })();