wip: local portal changes (backup before disk cleanup)

This commit is contained in:
Ondřej Glaser
2026-07-07 13:16:26 +02:00
parent 48cef99257
commit 79b17be817
774 changed files with 8592 additions and 245 deletions

View File

@@ -5,6 +5,7 @@
upload: $("s-upload"),
processing: $("s-processing"),
symbols: $("s-symbols"),
blocks: $("s-blocks"),
results: $("s-results"),
};
const show = (name) => {
@@ -31,12 +32,20 @@
fd.append("file", file);
try {
// Skip auto-detection by default — user defines symbols manually
const r = await fetch("/api/upload?auto_detect=false", {
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();
@@ -52,7 +61,7 @@
show("processing");
$("processing-title").textContent = "Hledám legendu pomocí AI…";
try {
const r = await fetch(`/api/auto-detect/${state.jobId}`, { method: "POST" });
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 || [];
@@ -80,7 +89,7 @@
fd.append("file", f);
fd.append("description", name);
try {
const r = await fetch(`/api/symbols/${state.jobId}/upload`, { method: "POST", body: fd });
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);
@@ -104,7 +113,7 @@
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=""
<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>
@@ -137,7 +146,7 @@
e.stopPropagation();
if (!confirm(`Smazat symbol "${s.description || s.id}"?`)) return;
try {
const r = await fetch(`/api/symbols/${state.jobId}/${s.id}`, { method: "DELETE" });
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);
@@ -161,7 +170,7 @@
"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}`;
img.src = `api/drawing/${state.jobId}`;
sel.style.display = "none";
$("crop-save").disabled = true;
cropState = { mode: "create", sym: null, bbox: null, dragging: false };
@@ -182,7 +191,7 @@
"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}`;
img.src = `api/drawing/${state.jobId}`;
sel.style.display = "none";
$("crop-save").disabled = true;
cropState = { mode: "edit", sym, bbox: null, dragging: false };
@@ -191,64 +200,176 @@
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;
// 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 setBtnState = () => {
const ok = !!curBox && curBox.w >= 3 && curBox.h >= 3;
$("crop-save").disabled = !ok;
};
function clientToImg(clientX, clientY) {
const rect = img.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
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) {
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;
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;
}
cropState.bbox = {
x: left / dispW,
y: top / dispH,
w: w / dispW,
h: h / dispH,
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),
};
$("crop-save").disabled = false;
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() {
@@ -263,12 +384,12 @@
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}`);
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 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]) => `
@@ -342,11 +463,13 @@
$("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}`, {
const r = await fetch(`api/symbols/${state.jobId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -359,7 +482,7 @@
const newSym = await r.json();
state.symbols.push(newSym);
} else {
const r = await fetch(`/api/symbol/${state.jobId}/${cropState.sym.id}/recrop`, {
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 }),
@@ -385,7 +508,7 @@
show("processing");
$("processing-title").textContent = `Počítám ${state.selected.size} symbolů…`;
try {
const r = await fetch(`/api/count/${state.jobId}`, {
const r = await fetch(`api/count/${state.jobId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -396,6 +519,7 @@
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) {
@@ -412,8 +536,11 @@
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><img class="symbol-thumb" style="width:40px;height:40px" src="/api/symbol/${state.jobId}/${r.id}" alt=""></td>
<td>${thumb}</td>
<td>${escapeHtml(r.description || "")}</td>
<td><strong>${r.count}</strong></td>
<td class="confidence-${conf}">${conf || "—"}</td>
@@ -422,14 +549,89 @@
}
}
$("export-btn").addEventListener("click", () => {
window.location.href = `/api/export/${state.jobId}`;
// 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", () => {
window.location.href = `/api/export-pdf/${state.jobId}`;
$("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("symbols"));
$("back-btn").addEventListener("click", () => show(state.resultsBack || "symbols"));
$("reset-btn").addEventListener("click", () => {
state = { jobId: null, symbols: [], selected: new Set(), results: [] };
fileInput.value = "";
@@ -439,4 +641,241 @@
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);
});
})();

View File

@@ -187,6 +187,23 @@
background: #fff;
cursor: crosshair;
}
.crop-toolbar {
display: flex;
gap: 6px;
align-items: center;
margin: 8px 0;
flex-wrap: wrap;
}
.crop-toolbar .btn-sm {
padding: 4px 10px;
font-size: 13px;
min-width: 36px;
}
.crop-toolbar .crop-hint {
font-size: 12px;
color: var(--text-tertiary);
margin-left: auto;
}
#crop-img {
display: block;
max-width: none;
@@ -299,3 +316,54 @@
height: 100%;
background: var(--primary);
}
/* ── Block picker (DWG/DXF exact counting) ───────────── */
.blocks-hint {
font-size: 13px; color: var(--text-tertiary);
background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary));
border: 1px solid var(--border-default);
border-radius: 8px; padding: 10px 14px; margin: 4px 0 14px;
}
.blocks-shownoise {
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--text-secondary); cursor: pointer;
}
.blocks-total {
font-size: 13px; font-weight: 600; color: var(--text-primary);
margin-bottom: 12px;
}
.blocks-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
.block-card {
border: 1px solid var(--border-default);
border-radius: 10px; background: var(--card);
cursor: pointer; overflow: hidden;
transition: border-color .15s, box-shadow .15s, background .15s;
}
.block-card:hover { border-color: var(--border-strong); }
.block-card.is-sel {
border-color: var(--primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 30%, transparent);
background: color-mix(in srgb, var(--primary) 5%, var(--card));
}
.block-thumb {
aspect-ratio: 1 / 1; background: #fff;
display: flex; align-items: center; justify-content: center;
border-bottom: 1px solid var(--border-default);
}
.block-thumb img { max-width: 88%; max-height: 88%; object-fit: contain; }
.block-info {
display: flex; align-items: center; justify-content: space-between;
gap: 8px; padding: 8px 10px;
}
.block-name {
font-size: 12.5px; color: var(--text-secondary);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.block-count {
font-size: 12.5px; font-weight: 700; color: var(--primary);
flex-shrink: 0;
}

View File

@@ -4,45 +4,77 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Počítání symbolů z PDF výkresu | Colsys AI</title>
<base href="/apps/dwg-counting/">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="/static/styles.css">
<link rel="stylesheet" href="/static/extra.css">
<link rel="stylesheet" href="static/styles.css?v=blocks11">
<link rel="stylesheet" href="static/extra.css?v=blocks11">
<script>
// Theme handling — runs before first paint to prevent flash.
// Priority: URL ?theme= → localStorage → portal_theme cookie → system.
// Theme bootstrap — read source-of-truth in this order:
// ?theme= URL → portal_theme cookie → app_theme localStorage → OS
// The cookie is set by the portal landing on every resolvedTheme change,
// so it always reflects the portal's current state.
(function () {
var t = null;
try {
var url = new URL(window.location.href);
var p = url.searchParams.get("theme");
var p = new URL(window.location.href).searchParams.get("theme");
if (p === "dark" || p === "light") t = p;
} catch (e) {}
if (!t) {
try { t = localStorage.getItem("app_theme"); } catch (e) {}
}
if (!t) {
var m = document.cookie.match(/(?:^|;\s*)portal_theme=([^;]+)/);
if (m) t = decodeURIComponent(m[1]);
}
if (!t) { try { t = localStorage.getItem("app_theme"); } catch (e) {} }
if (!t && typeof matchMedia !== "undefined") {
try {
t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
} catch (e) {}
}
if (t === "dark" || t === "light") {
document.documentElement.classList.add(t);
try { localStorage.setItem("app_theme", t); } catch (e) {}
}
})();
// bfcache rehydration — re-sync the class when the page is restored
// (e.g. user toggled theme on the portal, then hit Back).
// Same as pageshow, but fires when the user switches tabs.
// Covers: portal tab toggles theme → user comes back to app tab.
document.addEventListener("visibilitychange", function () {
if (document.visibilityState !== "visible") return;
var m = document.cookie.match(/(?:^|;\s*)portal_theme=([^;]+)/);
if (!m) return;
var t = decodeURIComponent(m[1]);
if (t !== "dark" && t !== "light") return;
var cls = document.documentElement.classList;
if (cls.contains(t)) return;
cls.remove(t === "dark" ? "light" : "dark");
cls.add(t);
try { localStorage.setItem("app_theme", t); } catch (e) {}
});
window.addEventListener("pageshow", function () {
var m = document.cookie.match(/(?:^|;\s*)portal_theme=([^;]+)/);
if (!m) return;
var t = decodeURIComponent(m[1]);
if (t !== "dark" && t !== "light") return;
var cls = document.documentElement.classList;
if (cls.contains(t)) return;
cls.remove(t === "dark" ? "light" : "dark");
cls.add(t);
try { localStorage.setItem("app_theme", t); } catch (e) {}
});
</script>
</head>
<body>
<header class="header">
<div class="header-inner">
<a href="https://ai.klas.chat" class="brand">
<a href="/" class="brand">
<span class="brand-icon">C</span>
<span class="brand-name">Colsys <span class="brand-ai">AI</span></span>
</a>
<span class="header-crumb">Počítání symbolů z PDF výkresu</span>
<a href="https://ai.klas.chat" class="back-link" title="Zpět na portál">
<a href="/" class="back-link" title="Zpět na portál">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m15 18-6-6 6-6"/>
</svg>
@@ -69,11 +101,11 @@
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 16.5V4.5m0 0-3.75 3.75M12 4.5l3.75 3.75M4.5 19.5h15"/>
</svg>
<p class="drop-text">Přetáhněte PDF výkres sem</p>
<p class="drop-text">Přetáhněte výkres sem (PDF, DWG nebo DXF)</p>
<p class="drop-or">nebo</p>
<button class="btn btn-secondary" id="browse-btn" type="button">Vybrat soubor</button>
<p class="drop-formats">Podporovaný formát: .pdf</p>
<input type="file" id="file-input" accept=".pdf" style="display:none">
<p class="drop-formats">Podporované formáty: .pdf, .dwg, .dxf</p>
<input type="file" id="file-input" accept=".pdf,.dwg,.dxf" style="display:none">
</div>
</section>
@@ -121,6 +153,34 @@
<div class="symbols-grid" id="symbols-grid"></div>
</section>
<!-- ── Block picker (DWG/DXF — exact counting) ─────── -->
<section id="s-blocks" class="hidden">
<div class="results-header">
<div>
<h2 class="results-title">Vyberte symboly k spočítání</h2>
<p class="results-meta" id="blocks-meta"></p>
</div>
<div class="results-actions">
<button class="btn btn-secondary" id="blocks-reset-btn" type="button">Nahrát jiný</button>
<label class="blocks-shownoise">
<input type="checkbox" id="blocks-shownoise"> Zobrazit i pomocné bloky
</label>
<button class="btn btn-secondary" id="region-match-btn" type="button">Symbol není blok? Vyznačit ve výkresu</button>
<button class="btn btn-secondary" id="blocks-pdf-btn" type="button" disabled>PDF s vyznačením</button>
<button class="btn btn-primary" id="blocks-export-btn" type="button" disabled>Exportovat Excel</button>
</div>
</div>
<p class="blocks-hint">
Výkres je CAD soubor — počty jsou <strong>přesné</strong>, čtené přímo
z dat (žádné falešné nálezy). Klikněte na bloky, které představují
hledaný symbol. Pokud symbol není samostatný blok, použijte
<strong>„Vyznačit ve výkresu"</strong> a označte ho přímo — najdeme
všechny jeho přesné kopie (i otočené/zrcadlené).
</p>
<div class="blocks-total" id="blocks-total"></div>
<div class="blocks-grid" id="blocks-grid"></div>
</section>
<!-- ── Debug modal ───────────────────────── -->
<div id="debug-modal" class="modal hidden">
<div class="modal-content" style="max-width:760px">
@@ -147,11 +207,25 @@
<label for="crop-name">Název symbolu:</label>
<input type="text" id="crop-name" placeholder="např. Zásuvka 230V">
</div>
<div class="crop-toolbar">
<button class="btn btn-secondary btn-sm" id="crop-zoom-in" type="button" title="Přiblížit (kolečko myši)">+</button>
<button class="btn btn-secondary btn-sm" id="crop-zoom-out" type="button" title="Oddálit">−</button>
<button class="btn btn-secondary btn-sm" id="crop-zoom-fit" type="button" title="Celý výkres">Vejít</button>
<button class="btn btn-secondary btn-sm" id="crop-zoom-one" type="button" title="100 % (skutečná velikost)">100&nbsp;%</button>
<span class="crop-hint">Kolečko = přiblížit · Shift+tažení = posunout · Tažení = vybrat</span>
</div>
<div class="crop-canvas-wrap" id="crop-canvas-wrap">
<img id="crop-img" alt="" draggable="false">
<div id="crop-selection"></div>
</div>
<div class="threshold-row hidden" id="crop-threshold-row">
<label for="crop-threshold">Citlivost (práh shody):</label>
<input type="range" id="crop-threshold" min="0.40" max="0.95" step="0.01" value="0.70">
<span id="crop-threshold-value">0.70</span>
<span class="threshold-hint">nižší = víc nálezů (i falešných), vyšší = jen přesné shody</span>
</div>
<div class="modal-actions">
<button class="btn btn-secondary hidden" id="crop-back-btn" type="button">Zpět na celý výkres</button>
<button class="btn btn-secondary" id="crop-cancel" type="button">Zrušit</button>
<button class="btn btn-primary" id="crop-save" type="button" disabled>Uložit výřez</button>
</div>
@@ -200,6 +274,6 @@
</main>
<script src="/static/app.js"></script>
<script src="static/app.js?v=blocks11"></script>
</body>
</html>