882 lines
35 KiB
JavaScript
882 lines
35 KiB
JavaScript
// 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 deduplikaci.
|
||
</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) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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);
|
||
});
|
||
})();
|