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 deduplikaci.
`;
// 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 = "
`;
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);
});
})();