// dwg-counting frontend (() => { const $ = (id) => document.getElementById(id); const sections = { upload: $("s-upload"), processing: $("s-processing"), symbols: $("s-symbols"), 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; 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; } function attachCropHandlers(wrap, img, sel) { // Mouse coords relative to the IMAGE's top-left (in image-pixel space, // which is also wrap's content space since image is at native scale and // sits at content origin (0,0) in the relative-positioned wrap). function localXY(e) { const rect = img.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; } function onMouseDown(e) { const p = localXY(e); cropState.startX = p.x; cropState.startY = p.y; cropState.dragging = true; sel.style.display = "block"; sel.style.left = p.x + "px"; sel.style.top = p.y + "px"; sel.style.width = "0px"; sel.style.height = "0px"; } function onMouseMove(e) { if (!cropState || !cropState.dragging) return; const p = localXY(e); const left = Math.min(p.x, cropState.startX); const top = Math.min(p.y, cropState.startY); const w = Math.abs(p.x - cropState.startX); const h = Math.abs(p.y - cropState.startY); sel.style.left = left + "px"; sel.style.top = top + "px"; sel.style.width = w + "px"; sel.style.height = h + "px"; } function onMouseUp() { if (!cropState) return; cropState.dragging = false; const rect = img.getBoundingClientRect(); const dispW = rect.width, dispH = rect.height; const left = parseFloat(sel.style.left), top = parseFloat(sel.style.top); const w = parseFloat(sel.style.width), h = parseFloat(sel.style.height); if (w < 5 || h < 5) { sel.style.display = "none"; $("crop-save").disabled = true; return; } cropState.bbox = { x: left / dispW, y: top / dispH, w: w / dispW, h: h / dispH, }; $("crop-save").disabled = false; } wrap.onmousedown = onMouseDown; wrap.onmousemove = onMouseMove; wrap.onmouseup = onMouseUp; wrap.onmouseleave = onMouseUp; } 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; 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 || []; 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 || ""; tr.innerHTML = ` ${escapeHtml(r.description || "")} ${r.count} ${conf || "—"} ${escapeHtml(r.notes || "")}`; tb.appendChild(tr); } } $("export-btn").addEventListener("click", () => { window.location.href = `/api/export/${state.jobId}`; }); $("pdf-btn").addEventListener("click", () => { window.location.href = `/api/export-pdf/${state.jobId}`; }); $("back-btn").addEventListener("click", () => show("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])); } })();