Apps: - dwg-rooms: extract room numbers from DWG/DXF - dwg-counting: count symbols in PDF drawings (OpenCV template matching) - contract-check: review PDF contracts against a checklist (Claude vision + Tesseract OCR fallback) - email-drafter: bullet notes → polished Czech/English business emails - invoice-extractor: PDF/image invoice → structured data → Excel - translator: Czech-first translator across 19 languages with tone control - vv-check: find inconsistent unit prices across VV sheets in one workbook - vv-compare: diff original vs new VV files (changes / added / removed) - feature-request: portal users submit ideas + sample files Infrastructure: - LiteLLM gateway with per-app virtual keys + budgets - Langfuse observability - Geist font, shared theme, cross-subdomain back link + theme sync via cookie/URL - Caddy reverse proxy on *.klas.chat
443 lines
18 KiB
JavaScript
443 lines
18 KiB
JavaScript
// dwg-counting frontend
|
||
(() => {
|
||
const $ = (id) => document.getElementById(id);
|
||
const sections = {
|
||
upload: $("s-upload"),
|
||
processing: $("s-processing"),
|
||
symbols: $("s-symbols"),
|
||
results: $("s-results"),
|
||
};
|
||
const show = (name) => {
|
||
for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== name);
|
||
};
|
||
|
||
let state = { jobId: null, symbols: [], selected: new Set(), results: [] };
|
||
|
||
// Upload handlers
|
||
const fileInput = $("file-input");
|
||
const dropZone = $("drop-zone");
|
||
$("browse-btn").addEventListener("click", () => fileInput.click());
|
||
fileInput.addEventListener("change", (e) => e.target.files[0] && upload(e.target.files[0]));
|
||
["dragenter", "dragover"].forEach((ev) =>
|
||
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); }));
|
||
["dragleave", "drop"].forEach((ev) =>
|
||
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.remove("drag-over"); }));
|
||
dropZone.addEventListener("drop", (e) => e.dataTransfer.files[0] && upload(e.dataTransfer.files[0]));
|
||
|
||
async function upload(file) {
|
||
show("processing");
|
||
$("processing-title").textContent = "Připravuji výkres…";
|
||
const fd = new FormData();
|
||
fd.append("file", file);
|
||
try {
|
||
// Skip auto-detection by default — user defines symbols manually
|
||
const r = await fetch("/api/upload?auto_detect=false", {
|
||
method: "POST", body: fd,
|
||
});
|
||
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
|
||
const data = await r.json();
|
||
state.jobId = data.job_id;
|
||
state.symbols = data.symbols || [];
|
||
state.selected = new Set();
|
||
renderSymbols();
|
||
show("symbols");
|
||
} catch (err) {
|
||
alert("Chyba: " + err.message);
|
||
show("upload");
|
||
}
|
||
}
|
||
|
||
$("auto-detect-btn").addEventListener("click", async () => {
|
||
if (!state.jobId) return;
|
||
show("processing");
|
||
$("processing-title").textContent = "Hledám legendu pomocí AI…";
|
||
try {
|
||
const r = await fetch(`/api/auto-detect/${state.jobId}`, { method: "POST" });
|
||
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
|
||
const data = await r.json();
|
||
state.symbols = data.symbols || [];
|
||
renderSymbols();
|
||
show("symbols");
|
||
} catch (err) {
|
||
alert("Chyba: " + err.message);
|
||
show("symbols");
|
||
}
|
||
});
|
||
|
||
$("add-symbol-btn").addEventListener("click", () => {
|
||
openAddSymbolModal();
|
||
});
|
||
|
||
$("upload-symbol-btn").addEventListener("click", () => {
|
||
$("symbol-file-input").click();
|
||
});
|
||
$("symbol-file-input").addEventListener("change", async (e) => {
|
||
const f = e.target.files[0];
|
||
if (!f) return;
|
||
const name = prompt("Název symbolu:", f.name.replace(/\.[a-z]+$/i, ""));
|
||
if (!name) return;
|
||
const fd = new FormData();
|
||
fd.append("file", f);
|
||
fd.append("description", name);
|
||
try {
|
||
const r = await fetch(`/api/symbols/${state.jobId}/upload`, { method: "POST", body: fd });
|
||
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
|
||
const sym = await r.json();
|
||
state.symbols.push(sym);
|
||
renderSymbols();
|
||
} catch (err) { alert("Chyba: " + err.message); }
|
||
e.target.value = "";
|
||
});
|
||
|
||
function renderSymbols() {
|
||
const meta = $("symbols-meta");
|
||
if (!state.symbols.length) {
|
||
meta.textContent = 'Klikněte „+ Přidat symbol" a vyznačte ve výkresu, co chcete počítat. Nebo zkuste „Auto-detekce z legendy" pro automatický návrh.';
|
||
$("symbols-grid").innerHTML = "";
|
||
return;
|
||
}
|
||
meta.textContent = `${state.symbols.length} symbolů. Zaškrtněte k spočítání, ✎ upravit, 🗑 smazat.`;
|
||
const grid = $("symbols-grid");
|
||
grid.innerHTML = "";
|
||
for (const s of state.symbols) {
|
||
const card = document.createElement("div");
|
||
card.className = "symbol-card";
|
||
card.innerHTML = `
|
||
<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;
|
||
}
|
||
|
||
function attachCropHandlers(wrap, img, sel) {
|
||
|
||
// Mouse coords relative to the IMAGE's top-left (in image-pixel space,
|
||
// which is also wrap's content space since image is at native scale and
|
||
// sits at content origin (0,0) in the relative-positioned wrap).
|
||
function localXY(e) {
|
||
const rect = img.getBoundingClientRect();
|
||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||
}
|
||
|
||
function onMouseDown(e) {
|
||
const p = localXY(e);
|
||
cropState.startX = p.x;
|
||
cropState.startY = p.y;
|
||
cropState.dragging = true;
|
||
sel.style.display = "block";
|
||
sel.style.left = p.x + "px";
|
||
sel.style.top = p.y + "px";
|
||
sel.style.width = "0px";
|
||
sel.style.height = "0px";
|
||
}
|
||
function onMouseMove(e) {
|
||
if (!cropState || !cropState.dragging) return;
|
||
const p = localXY(e);
|
||
const left = Math.min(p.x, cropState.startX);
|
||
const top = Math.min(p.y, cropState.startY);
|
||
const w = Math.abs(p.x - cropState.startX);
|
||
const h = Math.abs(p.y - cropState.startY);
|
||
sel.style.left = left + "px";
|
||
sel.style.top = top + "px";
|
||
sel.style.width = w + "px";
|
||
sel.style.height = h + "px";
|
||
}
|
||
function onMouseUp() {
|
||
if (!cropState) return;
|
||
cropState.dragging = false;
|
||
const rect = img.getBoundingClientRect();
|
||
const dispW = rect.width, dispH = rect.height;
|
||
const left = parseFloat(sel.style.left), top = parseFloat(sel.style.top);
|
||
const w = parseFloat(sel.style.width), h = parseFloat(sel.style.height);
|
||
if (w < 5 || h < 5) {
|
||
sel.style.display = "none";
|
||
$("crop-save").disabled = true;
|
||
return;
|
||
}
|
||
cropState.bbox = {
|
||
x: left / dispW,
|
||
y: top / dispH,
|
||
w: w / dispW,
|
||
h: h / dispH,
|
||
};
|
||
$("crop-save").disabled = false;
|
||
}
|
||
|
||
wrap.onmousedown = onMouseDown;
|
||
wrap.onmousemove = onMouseMove;
|
||
wrap.onmouseup = onMouseUp;
|
||
wrap.onmouseleave = onMouseUp;
|
||
}
|
||
|
||
function closeCropModal() {
|
||
$("crop-modal").classList.add("hidden");
|
||
cropState = null;
|
||
}
|
||
|
||
async function openDebugModal(sym) {
|
||
const modal = $("debug-modal");
|
||
const body = $("debug-body");
|
||
$("debug-title").textContent = `Debug: ${sym.description || sym.id}`;
|
||
body.innerHTML = '<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;
|
||
try {
|
||
if (cropState.mode === "create") {
|
||
const name = ($("crop-name").value || "").trim();
|
||
if (!name) { alert("Zadejte název symbolu."); return; }
|
||
const r = await fetch(`/api/symbols/${state.jobId}`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
bbox: cropState.bbox,
|
||
description: name,
|
||
source: "drawing",
|
||
}),
|
||
});
|
||
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
|
||
const newSym = await r.json();
|
||
state.symbols.push(newSym);
|
||
} else {
|
||
const r = await fetch(`/api/symbol/${state.jobId}/${cropState.sym.id}/recrop`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ bbox: cropState.bbox }),
|
||
});
|
||
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
|
||
cropState.sym._v = (cropState.sym._v || 0) + 1;
|
||
}
|
||
renderSymbols();
|
||
closeCropModal();
|
||
} catch (err) {
|
||
alert("Chyba: " + err.message);
|
||
}
|
||
});
|
||
|
||
// Threshold slider live readout
|
||
const thrSlider = $("threshold-slider");
|
||
const thrValue = $("threshold-value");
|
||
thrSlider.addEventListener("input", () => {
|
||
thrValue.textContent = parseFloat(thrSlider.value).toFixed(2);
|
||
});
|
||
|
||
$("count-btn").addEventListener("click", async () => {
|
||
show("processing");
|
||
$("processing-title").textContent = `Počítám ${state.selected.size} symbolů…`;
|
||
try {
|
||
const r = await fetch(`/api/count/${state.jobId}`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
symbol_ids: [...state.selected],
|
||
threshold: parseFloat(thrSlider.value),
|
||
}),
|
||
});
|
||
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
|
||
const data = await r.json();
|
||
state.results = data.results || [];
|
||
renderResults();
|
||
show("results");
|
||
} catch (err) {
|
||
alert("Chyba: " + err.message);
|
||
show("symbols");
|
||
}
|
||
});
|
||
|
||
function renderResults() {
|
||
const total = state.results.reduce((a, r) => a + (r.count || 0), 0);
|
||
$("results-meta").textContent = `Celkem ${total} symbolů ve ${state.results.length} kategoriích.`;
|
||
const tb = $("results-tbody");
|
||
tb.innerHTML = "";
|
||
for (const r of state.results) {
|
||
const tr = document.createElement("tr");
|
||
const conf = r.confidence || "";
|
||
tr.innerHTML = `
|
||
<td><img class="symbol-thumb" style="width:40px;height:40px" src="/api/symbol/${state.jobId}/${r.id}" alt=""></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);
|
||
}
|
||
}
|
||
|
||
$("export-btn").addEventListener("click", () => {
|
||
window.location.href = `/api/export/${state.jobId}`;
|
||
});
|
||
$("pdf-btn").addEventListener("click", () => {
|
||
window.location.href = `/api/export-pdf/${state.jobId}`;
|
||
});
|
||
|
||
$("back-btn").addEventListener("click", () => show("symbols"));
|
||
$("reset-btn").addEventListener("click", () => {
|
||
state = { jobId: null, symbols: [], selected: new Set(), results: [] };
|
||
fileInput.value = "";
|
||
show("upload");
|
||
});
|
||
|
||
function escapeHtml(s) {
|
||
return String(s).replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||
}
|
||
})();
|