Files
AI_portal/dwg-counting/static/app.js
2026-07-07 13:16:26 +02:00

882 lines
35 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 = `
<input type="checkbox" data-id="${s.id}">
<img class="symbol-thumb" src="api/symbol/${state.jobId}/${s.id}?v=${s._v||0}" alt=""
onerror="this.style.visibility='hidden'">
<div class="symbol-info">
<div class="symbol-id">${s.id}</div>
<div class="symbol-desc">${escapeHtml(s.description || "")}</div>
</div>
<button class="symbol-edit" type="button" title="Upravit výřez">✎</button>
<button class="symbol-debug" type="button" title="Debug: zobrazit šablonu a skóre">🔍</button>
<button class="symbol-delete" type="button" title="Smazat symbol">🗑</button>`;
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 = '<p style="padding:20px">Načítám diagnostiku…</p>';
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]) => `
<div>práh ${t}</div>
<div class="debug-bar"><div class="debug-bar-fill" style="width:${Math.min(100,(n/maxMatchCount)*100)}%"></div></div>
<div>${n} shod</div>`).join("");
const inkPct = info.template_total_pixels
? ((info.template_ink_pixels / info.template_total_pixels) * 100).toFixed(1)
: "?";
body.innerHTML = `
<div class="debug-section">
<h4>Obrazy</h4>
<div class="debug-thumbs">
<div class="debug-thumb">
<a href="${tmplURL}" target="_blank"><img src="${tmplURL}" alt=""></a>
<span>Šablona (uložená)</span>
</div>
<div class="debug-thumb">
<a href="${procURL}" target="_blank"><img src="${procURL}" alt=""></a>
<span>Po předzpracování<br>(co matcher vidí)</span>
</div>
<div class="debug-thumb">
<a href="${drawURL}" target="_blank">otevřít celý výkres ↗</a>
<span>${info.drawing_size.join(" × ")} px</span>
</div>
</div>
</div>
<div class="debug-section">
<h4>Měření</h4>
<div class="debug-kv">
<strong>Šablona originál</strong><span>${info.template_size.join(" × ")} px</span>
<strong>Šablona po ořezu</strong><span>${info.template_cropped_size.join(" × ")} px</span>
<strong>Inkoust v šabloně</strong><span>${info.template_ink_pixels} / ${info.template_total_pixels} px (${inkPct}%)</span>
<strong>Výkres</strong><span>${info.drawing_size.join(" × ")} px</span>
<strong>Nejlepší skóre</strong><span>${info.max_score?.toFixed(3) ?? "—"}</span>
</div>
</div>
<div class="debug-section">
<h4>Shody při různých prazích (bez rotací, scale 1.0)</h4>
<div class="debug-thresholds">${threshRows}</div>
<p style="margin-top:8px; color: var(--text-tertiary); font-size:12px">
Aktuálně používaný práh pro počítání: <strong>0.75</strong>.
Skutečné počty (s rotacemi 0/90/180/270° a třemi scale faktory) jsou typicky nižší kvůli dedupli­kaci.
</p>
</div>
<div class="debug-section" id="debug-interpret"></div>
`;
// 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 = "<h4>Interpretace</h4>" +
interpret.map(t => `<p>${t}</p>`).join("");
} catch (err) {
body.innerHTML = `<p style="color:#dc2626; padding:12px">Chyba: ${err.message}</p>`;
}
}
$("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"
? ""
: `<img class="symbol-thumb" style="width:40px;height:40px" src="api/symbol/${state.jobId}/${r.id}" alt="" onerror="this.style.display='none'">`;
tr.innerHTML = `
<td>${thumb}</td>
<td>${escapeHtml(r.description || "")}</td>
<td><strong>${r.count}</strong></td>
<td class="confidence-${conf}">${conf || "—"}</td>
<td>${escapeHtml(r.notes || "")}</td>`;
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 =
'<div style="background:var(--bg,#fff);color:var(--fg,#111);' +
'padding:28px 36px;border-radius:12px;text-align:center;' +
'box-shadow:0 8px 32px rgba(0,0,0,0.3);max-width:480px">' +
'<div class="spinner" style="margin:0 auto 16px"></div>' +
'<div id="busy-title" style="font-size:18px;font-weight:600;margin-bottom:6px"></div>' +
'<div id="busy-sub" style="font-size:14px;opacity:0.75"></div>' +
'<div id="busy-elapsed" style="font-size:12px;opacity:0.55;margin-top:10px"></div>' +
"</div>";
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) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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 = `
<div class="block-thumb">
<img loading="lazy" src="api/block-thumb/${state.jobId}/${b.idx}" alt="">
</div>
<div class="block-info">
<div class="block-name" title="${escapeHtml(b.name)}">${escapeHtml(b.name)}</div>
<div class="block-count">${b.count}×</div>
</div>`;
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);
});
})();