diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7606786 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{"python.defaultInterpreterPath":"python"} \ No newline at end of file diff --git a/src/kweb/layout_server.py b/src/kweb/layout_server.py index 3bdf322..53a388d 100755 --- a/src/kweb/layout_server.py +++ b/src/kweb/layout_server.py @@ -3,10 +3,11 @@ import asyncio import base64 import json -from collections.abc import Callable +import math from collections import defaultdict +from collections.abc import Callable from pathlib import Path -from typing import Any, TypeAlias, Literal +from typing import Any, Literal, TypeAlias from urllib.parse import parse_qs # NOTE: import db to enable stream format readers @@ -118,6 +119,7 @@ class LayoutViewServerEndpoint(WebSocketEndpoint): meta_splitter: str root: Path max_rdb_limit: int = 100 + note_category: str = "kweb-note" def __init_subclass__( cls, @@ -175,6 +177,73 @@ def mode_dump(self) -> list[str]: def annotation_dump(self) -> list[str]: return [d[1] for d in self.layout_view.annotation_templates()] + def viewport_point_to_layout(self, x: float, y: float) -> db.DPoint: + trans = self.layout_view.viewport_trans().inverted() + return trans * db.DPoint(x, y) + + def measurement_dump(self) -> list[dict[str, object]]: + measurements: list[dict[str, object]] = [] + for annotation in self.layout_view.each_annotation(): + if annotation.style != lay.Annotation.StyleRuler: + continue + try: + start = annotation.seg_p1(0) + end = annotation.seg_p2(0) + except RuntimeError: + continue + dx = end.x - start.x + dy = end.y - start.y + length = math.hypot(dx, dy) + angle = math.degrees(math.atan2(dy, dx)) if length else 0.0 + label = "" + try: + label = annotation.text() + except TypeError: + # Some annotations might not provide text() when detached + label = "" + measurements.append( + { + "id": annotation.id(), + "p1": {"x": start.x, "y": start.y}, + "p2": {"x": end.x, "y": end.y}, + "dx": dx, + "dy": dy, + "length": length, + "angle": angle, + "label": label, + } + ) + return measurements + + def note_dump(self) -> list[dict[str, object]]: + notes: list[dict[str, object]] = [] + for annotation in self.layout_view.each_annotation(): + if annotation.category != self.note_category: + continue + point = annotation.p1 + text = "" + try: + text = str(annotation.text()) + except Exception: + text = "" + notes.append( + { + "id": annotation.id(), + "text": text, + "position": {"x": point.x, "y": point.y}, + } + ) + return notes + + async def send_measurements(self, websocket: WebSocket) -> None: + await websocket.send_json( + { + "msg": "measurement-update", + "measurements": self.measurement_dump(), + "notes": self.note_dump(), + } + ) + def current_cell(self) -> db.Cell: cv = self.layout_view.active_cellview() ci = cv.cell_index @@ -466,6 +535,7 @@ async def connection(self, websocket: WebSocket, path: str | None = None) -> Non websocket=websocket, cell_index=self.current_cell().cell_index(), ) + await self.send_measurements(websocket) await self.send_metainfo( cell=self.current_cell(), websocket=websocket, @@ -476,6 +546,7 @@ async def connection(self, websocket: WebSocket, path: str | None = None) -> Non websocket=websocket, cell_index=0, ) + await self.send_measurements(websocket) if loaded_rdb: await websocket.send_text( @@ -498,6 +569,13 @@ async def connection(self, websocket: WebSocket, path: str | None = None) -> Non asyncio.create_task(self.timer(websocket)) + def notify_measurements() -> None: + asyncio.create_task(self.send_measurements(websocket)) + + self.layout_view.on_annotations_changed = notify_measurements # type: ignore[assignment] + self.layout_view.on_annotation_changed = notify_measurements # type: ignore[assignment] + self.layout_view.on_annotation_selection_changed = notify_measurements # type: ignore[assignment] + async def get_records( self, category_id: int | None, cell_id: int | None ) -> Iterator[rdb.RdbItem]: @@ -575,6 +653,7 @@ async def reader(self, websocket: WebSocket, data: str) -> None: self.layout_view.resize(js["width"], js["height"]) case "clear-annotations": self.layout_view.clear_annotations() + await self.send_measurements(websocket) case "select-ruler": ruler = js["value"] self.layout_view.set_config("current-ruler-template", str(ruler)) @@ -640,11 +719,41 @@ async def reader(self, websocket: WebSocket, data: str) -> None: case "ci-s": await self.set_current_cell(js["ci"], websocket) self.layout_view.zoom_fit() + await self.send_measurements(websocket) case "cell-s": await self.set_current_cell(js["cell"], websocket) self.layout_view.zoom_fit() + await self.send_measurements(websocket) case "zoom-f": self.layout_view.zoom_fit() + case "add-annotation": + text = str(js.get("text", "")).strip() + if text: + x = js.get("x") + y = js.get("y") + if x is None or y is None: + x = self.layout_view.viewport_width() / 2 + y = self.layout_view.viewport_height() / 2 + try: + point = self.viewport_point_to_layout(float(x), float(y)) + except (TypeError, ValueError): + return + note = lay.Annotation() + note.category = self.note_category + note.style = lay.Annotation.StyleCrossBoth + note.p1 = point + note.p2 = point + note.fmt = text + mag = self.layout_view.viewport_trans().mag or 1.0 + offset = 20.0 / mag + note.text_x = point.x + offset + note.text_y = point.y + offset + try: + note.snap = False # type: ignore[attr-defined] + except AttributeError: + pass + self.layout_view.insert_annotation(note) + await self.send_measurements(websocket) case "rdb-records": item_iter = await self.get_records( category_id=js["category_id"], cell_id=js["cell_id"] @@ -685,6 +794,7 @@ async def reader(self, websocket: WebSocket, data: str) -> None: websocket=websocket, cell_index=self.current_cell().cell_index(), ) + await self.send_measurements(websocket) async def _send_loaded(self, websocket: WebSocket, cell_index: int = 0) -> None: await websocket.send_json( diff --git a/src/kweb/static/client.css b/src/kweb/static/client.css index 6c081e6..55bfede 100644 --- a/src/kweb/static/client.css +++ b/src/kweb/static/client.css @@ -132,3 +132,78 @@ ul, #myUL { width: 80%; height: 80%; } + +.layer-controls .btn, +.layer-controls .form-control, +.layer-controls .form-select, +.layer-controls .input-group-text { + font-size: 0.75rem; +} + +.layer-hidden { + opacity: 0.4; +} + +.layer-filter-hidden { + display: none !important; +} + +#measurement-overlay .measurement-box { + background: rgba(0, 0, 0, 0.72); + border-radius: 0.75rem; + padding: 0.75rem 1rem; + color: #f8f9fa; + backdrop-filter: blur(4px); + box-shadow: 0 0.75rem 2rem rgba(0, 0, 0, 0.35); +} + +#measurement-overlay .measurement-summary { + font-weight: 500; +} + +#measurement-list { + display: flex; + flex-direction: column; + gap: 0.35rem; + max-height: 10rem; + overflow-y: auto; +} + +#measurement-list .measurement-row { + border-top: 1px solid rgba(255, 255, 255, 0.08); + padding-top: 0.35rem; +} + +#measurement-list .measurement-row:first-child { + border-top: 0; + padding-top: 0; +} + +#measurement-list .measurement-values { + font-family: "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.75rem; +} + +#note-list { + display: flex; + flex-direction: column; + gap: 0.35rem; + max-height: 8rem; + overflow-y: auto; + margin-top: 0.5rem; +} + +#note-list .note-row { + border-top: 1px solid rgba(255, 255, 255, 0.08); + padding-top: 0.35rem; +} + +#note-list .note-row:first-child { + border-top: 0; + padding-top: 0; +} + +#note-list .note-values { + font-family: "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.75rem; +} diff --git a/src/kweb/static/viewer.js b/src/kweb/static/viewer.js index 4f2b44a..cace6f7 100644 --- a/src/kweb/static/viewer.js +++ b/src/kweb/static/viewer.js @@ -22,6 +22,112 @@ const rdbCell = document.getElementById("rdbCell"); const rdbItems = document.getElementById("rdbItems"); +const layerSearchInput = document.getElementById("layerSearchInput"); +const layerClearSearch = document.getElementById("layerClearSearch"); +const layerShowAllButton = document.getElementById("layerShowAll"); +const layerHideAllButton = document.getElementById("layerHideAll"); +const layerPresetSelect = document.getElementById("layerPresetSelect"); +const layerSavePresetButton = document.getElementById("layerSavePreset"); +const layerDeletePresetButton = document.getElementById("layerDeletePreset"); +const layerSwitchToggle = document.getElementById("layerEmptySwitch"); + +const measurementOverlay = document.getElementById("measurement-overlay"); +const measurementSummary = document.getElementById("measurement-summary"); +const measurementList = document.getElementById("measurement-list"); +const measurementExportButton = document.getElementById("measurement-export"); +const noteSummary = document.getElementById("note-summary"); +const noteList = document.getElementById("note-list"); + +const layerPresetsStorageKey = "kweb-layer-presets"; +let layerTree = []; +let layerFilterTerm = ""; +let layerPresets = loadLayerPresets(); +let suppressPresetChangeEvent = false; +let measurementData = []; +let noteData = []; +let lastPointer = { x: null, y: null }; + +if (layerSwitchToggle) { + layerSwitchToggle.addEventListener("change", () => renderLayerTable()); +} + +if (layerSearchInput) { + layerSearchInput.addEventListener("input", (event) => { + layerFilterTerm = event.target.value || ""; + applyLayerFilter(); + }); +} + +if (layerClearSearch) { + layerClearSearch.addEventListener("click", () => { + if (layerSearchInput) { + layerSearchInput.value = ""; + } + layerFilterTerm = ""; + applyLayerFilter(); + }); +} + +if (layerShowAllButton) { + layerShowAllButton.addEventListener("click", () => { + setAllLayersVisibility(true); + }); +} + +if (layerHideAllButton) { + layerHideAllButton.addEventListener("click", () => { + setAllLayersVisibility(false); + }); +} + +if (layerPresetSelect) { + layerPresetSelect.addEventListener("change", (event) => { + if (suppressPresetChangeEvent) { + return; + } + const name = event.target.value; + if (name) { + applyLayerPreset(name); + } + }); +} + +if (layerSavePresetButton) { + layerSavePresetButton.addEventListener("click", () => { + const presetName = prompt("Preset name", ""); + if (!presetName) { + return; + } + const trimmed = presetName.trim(); + if (!trimmed) { + return; + } + layerPresets[trimmed] = collectLayerVisibilities(layerTree); + persistLayerPresets(); + refreshLayerPresetOptions(trimmed); + }); +} + +if (layerDeletePresetButton) { + layerDeletePresetButton.addEventListener("click", () => { + const name = layerPresetSelect ? layerPresetSelect.value : ""; + if (!name) { + return; + } + if (confirm(`Delete preset "${name}"?`)) { + delete layerPresets[name]; + persistLayerPresets(); + refreshLayerPresetOptions(); + } + }); +} + +refreshLayerPresetOptions(); + +if (measurementExportButton) { + measurementExportButton.addEventListener("click", exportMeasurementsAsCsv); +} + async function initializeWebSocket() { await new Promise((resolve) => { // Installs a handler called when the connection is established @@ -64,6 +170,8 @@ socket.onmessage = async function(evt) { alert(js.details); } else if (js.msg == "rdb-items") { await updateRdbItems(js.items); + } else if (js.msg == "measurement-update") { + updateAnnotationsOverlay(js.measurements, js.notes); } } else if (initialized) { @@ -83,6 +191,8 @@ function mouseEventToJSON(canvas, type, evt) { let rect = canvas.getBoundingClientRect(); let x = evt.clientX - rect.left; let y = evt.clientY - rect.top; + lastPointer.x = x; + lastPointer.y = y; let keys = 0; if (evt.shiftKey) { keys += 1; @@ -205,6 +315,7 @@ function showMenu(modes, annotations) { }); let menuElement = document.getElementById("menu"); + menuElement.replaceChildren(); let clearRulers = document.createElement("button"); clearRulers.id = "clearRulers"; @@ -215,6 +326,13 @@ function showMenu(modes, annotations) { socket.send(JSON.stringify({ msg: "clear-annotations" })); }; menuElement.appendChild(clearRulers); + let addNote = document.createElement("button"); + addNote.id = "addNote"; + addNote.textContent = "Add Note"; + addNote.className = "col-auto btn btn-primary mx-2"; + addNote.setAttribute("type", "button"); + addNote.onclick = openAnnotationDialog; + menuElement.appendChild(addNote); let zoomFit= document.createElement("button"); zoomFit.id = "zoomFit"; zoomFit.textContent = "Zoom Fit"; @@ -410,25 +528,432 @@ function appendCells(parentelement, cells, current_index, addpadding=false) { } // Updates the layer list function showLayers(layers) { + layerTree = layers; + renderLayerTable(); +} - let layerElement = document.getElementById("layers-tab-pane"); - let layerButtons = document.getElementById("layer-buttons"); - - let layerSwitch = document.getElementById("layerEmptySwitch"); +function renderLayerTable() { + const layerElement = document.getElementById("layers-tab-pane"); + const layerButtons = document.getElementById("layer-buttons"); + if (!layerElement || !layerButtons) { + return; + } - let layerTable = document.getElementById("table-layer") || document.createElement("div"); + let layerTable = document.getElementById("table-layer"); + if (!layerTable) { + layerTable = document.createElement("div"); layerTable.id = "table-layer"; layerTable.className = "container-fluid text-left px-0 pb-2"; - layerTable.replaceChildren(); + } + + if (layerTable.parentElement !== layerElement) { layerElement.replaceChildren(layerButtons, layerTable); + } - appendLayers(layerTable, layers, addempty=!layerSwitch.checked, addpaddings=true); - layerSwitch.addEventListener("change", function() { layerTable.replaceChildren(); - appendLayers(layerTable, layers, addempty=!this.checked, addpaddings=true); + + if (!Array.isArray(layerTree) || layerTree.length === 0) { + applyLayerFilter(); + return; + } + + const includeEmptyLayers = !(layerSwitchToggle && layerSwitchToggle.checked); + appendLayers(layerTable, layerTree, includeEmptyLayers, true); + applyLayerFilter(); +} + +function applyLayerFilter() { + const term = (layerFilterTerm || "").trim().toLowerCase(); + const rootAccordions = document.querySelectorAll("#table-layer .accordion[data-layer-id]"); + if (!rootAccordions.length) { + return; + } + rootAccordions.forEach((accordion) => { + filterLayerElement(accordion, term); + }); +} + +function filterLayerElement(element, term) { + if (!element || !element.dataset) { + return false; + } + const layerName = (element.dataset.layerName || "").toLowerCase(); + const layerSource = (element.dataset.layerSource || "").toLowerCase(); + const matchesSelf = !term || layerName.includes(term) || layerSource.includes(term); + + const body = element.querySelector(":scope > .accordion-item > .accordion-collapse > .accordion-body"); + let childMatches = false; + if (body) { + const childAccordions = body.querySelectorAll(":scope > .accordion[data-layer-id]"); + childMatches = Array.from(childAccordions).map((child) => filterLayerElement(child, term)).some(Boolean); + } + + const shouldShow = matchesSelf || childMatches; + element.classList.toggle("layer-filter-hidden", !shouldShow); + const parentRow = element.parentElement; + if (parentRow && parentRow.classList && parentRow.classList.contains("row")) { + parentRow.classList.toggle("layer-filter-hidden", !shouldShow); + } + return shouldShow; +} + +function setAllLayersVisibility(visible) { + if (!Array.isArray(layerTree) || !layerTree.length) { + return; + } + setVisibilityRecursively(layerTree, visible); + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ msg: "layer-v-all", value: visible })); + } + renderLayerTable(); +} + +function setVisibilityRecursively(layers, visible) { + layers.forEach((layer) => { + layer.v = visible; + updateLayerVisibilityClassById(layer.id, visible); + if (Array.isArray(layer.children) && layer.children.length > 0) { + setVisibilityRecursively(layer.children, visible); + } + }); +} + +function updateLayerVisibilityClassById(layerId, visible) { + const element = document.getElementById("layergroup-" + layerId); + if (element) { + element.classList.toggle("layer-hidden", !visible); + } +} + +function updateLayerVisibilityInTree(layers, layerId, visible) { + if (!Array.isArray(layers)) { + return false; + } + for (const layer of layers) { + if (layer.id === layerId) { + layer.v = visible; + updateLayerVisibilityClassById(layerId, visible); + return true; + } + if (Array.isArray(layer.children) && updateLayerVisibilityInTree(layer.children, layerId, visible)) { + return true; + } + } + return false; +} + +function collectLayerVisibilities(layers, result = {}) { + if (!Array.isArray(layers)) { + return result; + } + layers.forEach((layer) => { + result[layer.id] = Boolean(layer.v); + if (Array.isArray(layer.children) && layer.children.length > 0) { + collectLayerVisibilities(layer.children, result); + } + }); + return result; +} + +function loadLayerPresets() { + try { + const raw = window.localStorage.getItem(layerPresetsStorageKey); + if (!raw) { + return {}; + } + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") { + return parsed; + } + } catch (err) { + console.warn("Error loading layer presets", err); + } + return {}; +} + +function persistLayerPresets() { + try { + window.localStorage.setItem(layerPresetsStorageKey, JSON.stringify(layerPresets)); + } catch (err) { + console.warn("Error saving layer presets", err); + } +} + +function refreshLayerPresetOptions(selectedName = "") { + if (!layerPresetSelect) { + return; + } + suppressPresetChangeEvent = true; + layerPresetSelect.replaceChildren(); + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "Select preset"; + layerPresetSelect.appendChild(placeholder); + Object.keys(layerPresets) + .sort((a, b) => a.localeCompare(b)) + .forEach((name) => { + const option = document.createElement("option"); + option.value = name; + option.textContent = name; + layerPresetSelect.appendChild(option); + }); + if (selectedName && layerPresets[selectedName]) { + layerPresetSelect.value = selectedName; + } else { + layerPresetSelect.value = ""; + } + suppressPresetChangeEvent = false; +} + +function applyLayerPreset(name) { + const preset = layerPresets[name]; + if (!preset) { + return; + } + const current = collectLayerVisibilities(layerTree); + Object.entries(preset).forEach(([id, desired]) => { + const numericId = Number(id); + if (current[id] !== desired) { + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ msg: "layer-v", id: numericId, value: desired })); + } + updateLayerVisibilityInTree(layerTree, numericId, desired); + } }); + renderLayerTable(); +} +function updateAnnotationsOverlay(measurements, notes) { + measurementData = Array.isArray(measurements) ? measurements : []; + noteData = Array.isArray(notes) ? notes : []; + renderAnnotationsOverlay(); } + +function renderAnnotationsOverlay() { + if (!measurementOverlay || !measurementList || !measurementSummary) { + return; + } + + measurementList.replaceChildren(); + if (noteList) { + noteList.replaceChildren(); + } + + const hasMeasurements = measurementData.length > 0; + const hasNotes = noteData.length > 0; + + if (!hasMeasurements && !hasNotes) { + measurementOverlay.classList.add("d-none"); + measurementSummary.textContent = "No active measurements"; + if (noteSummary) { + noteSummary.textContent = "No notes"; + } + if (measurementExportButton) { + measurementExportButton.disabled = true; + } + return; + } + + measurementOverlay.classList.remove("d-none"); + if (measurementExportButton) { + measurementExportButton.disabled = !hasMeasurements; + } + + if (hasMeasurements) { + measurementSummary.textContent = + measurementData.length === 1 + ? "1 active measurement" + : `${measurementData.length} active measurements`; + } else { + measurementSummary.textContent = "No active measurements"; + } + + measurementData.forEach((measurement, index) => { + const row = document.createElement("div"); + row.className = "measurement-row d-flex justify-content-between align-items-center gap-2"; + + const label = document.createElement("span"); + label.className = "measurement-label text-truncate"; + const labelText = + measurement.label && measurement.label.trim().length > 0 + ? measurement.label + : `Ruler ${index + 1}`; + label.textContent = labelText; + + const values = document.createElement("span"); + values.className = "measurement-values text-end text-nowrap"; + const lengthText = formatMicrons(measurement.length); + const dxText = formatMicrons(measurement.dx); + const dyText = formatMicrons(measurement.dy); + const angleText = formatAngle(measurement.angle); + values.textContent = `L=${lengthText}µm Δx=${dxText}µm Δy=${dyText}µm θ=${angleText}°`; + + row.appendChild(label); + row.appendChild(values); + measurementList.appendChild(row); + }); + + if (noteSummary) { + noteSummary.textContent = hasNotes + ? noteData.length === 1 + ? "1 note" + : `${noteData.length} notes` + : "No notes"; + } + + if (noteList && hasNotes) { + noteData.forEach((note, index) => { + const row = document.createElement("div"); + row.className = "note-row d-flex justify-content-between align-items-center gap-2"; + + const label = document.createElement("span"); + label.className = "note-label text-truncate"; + const labelText = + note.text && note.text.trim().length > 0 ? note.text : `Note ${index + 1}`; + label.textContent = labelText; + + const coords = document.createElement("span"); + coords.className = "note-values text-end text-nowrap"; + const xText = formatMicrons(note.position?.x); + const yText = formatMicrons(note.position?.y); + coords.textContent = `(${xText}µm, ${yText}µm)`; + + row.appendChild(label); + row.appendChild(coords); + noteList.appendChild(row); + }); + } +} + +function formatMicrons(value) { + if (!Number.isFinite(value)) { + return "-"; + } + const abs = Math.abs(value); + let decimals = 3; + if (abs >= 1000) { + decimals = 0; + } else if (abs >= 100) { + decimals = 1; + } else if (abs >= 10) { + decimals = 2; + } + return value.toFixed(decimals); +} + +function formatAngle(value) { + if (!Number.isFinite(value)) { + return "-"; + } + return value.toFixed(1); +} + +function exportMeasurementsAsCsv() { + if (!measurementData.length) { + return; + } + const header = [ + "id", + "label", + "length_um", + "dx_um", + "dy_um", + "angle_deg", + "p1_x_um", + "p1_y_um", + "p2_x_um", + "p2_y_um", + ]; + const rows = measurementData.map((measurement) => { + const cells = [ + measurement.id, + `"${((measurement.label || "") + "").replace(/"/g, '""')}"`, + csvNumber(measurement.length), + csvNumber(measurement.dx), + csvNumber(measurement.dy), + csvNumber(measurement.angle), + csvNumber(measurement.p1?.x), + csvNumber(measurement.p1?.y), + csvNumber(measurement.p2?.x), + csvNumber(measurement.p2?.y), + ]; + return cells.join(","); + }); + const csvContent = [header.join(","), ...rows].join("\n"); + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "kweb_measurements.csv"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +function csvNumber(value) { + if (!Number.isFinite(value)) { + return ""; + } + return value.toFixed(6); +} + +const messages = { + en: { + annotationPrompt: "Note (the last cursor position will be used)", + }, + es: { + annotationPrompt: "Nota (se usará la última posición del cursor)", + }, +}; + +function getUserLanguage() { + return navigator.language?.slice(0, 2) || "en"; +} + +function getMessage(key) { + const lang = getUserLanguage(); + if (messages[lang]?.[key]) { + return messages[lang][key]; + } + if (messages.en?.[key]) { + return messages.en[key]; + } + return ""; +} + +function getPointerCoordinates() { + let { x, y } = lastPointer; + if (!Number.isFinite(x) || !Number.isFinite(y)) { + x = canvas ? canvas.clientWidth / 2 : 0; + y = canvas ? canvas.clientHeight / 2 : 0; + } + return { x, y }; +} + +function openAnnotationDialog() { + if (!canvas || !socket || socket.readyState !== WebSocket.OPEN) { + return; + } + const text = prompt(getMessage("annotationPrompt"), ""); + if (text === null) { + return; + } + const trimmed = text.trim(); + if (!trimmed) { + return; + } + const pointer = getPointerCoordinates(); + socket.send( + JSON.stringify({ + msg: "add-annotation", + x: pointer.x, + y: pointer.y, + text: trimmed, + }) + ); +} + // create table rows for each layer function appendLayers(parentelement, layers, addempty=false, addpaddings = false) { @@ -451,6 +976,12 @@ function appendLayers(parentelement, layers, addempty=false, addpaddings = false accordion.className = "accordion accordion-flush ps-2 pe-0"; } accordion.id = "layergroup-" + l.id; + accordion.dataset.layerId = l.id; + accordion.dataset.layerName = (l.name || "").toLowerCase(); + accordion.dataset.layerSource = (l.s || "").toLowerCase(); + if (!l.v) { + accordion.classList.add("layer-hidden"); + } layerRow.appendChild(accordion); @@ -550,6 +1081,12 @@ function appendLayers(parentelement, layers, addempty=false, addpaddings = false accordion.className = "accordion accordion-flush ps-2 pe-0"; } accordion.id = "layergroup-" + l.id; + accordion.dataset.layerId = l.id; + accordion.dataset.layerName = (l.name || "").toLowerCase(); + accordion.dataset.layerSource = (l.s || "").toLowerCase(); + if (!l.v) { + accordion.classList.add("layer-hidden"); + } layerRow.appendChild(accordion); accordion_item = document.createElement("div"); @@ -575,7 +1112,10 @@ function appendLayers(parentelement, layers, addempty=false, addpaddings = false function updateLayerImages(layers) { layers.forEach(function(l) { let layer_image = document.getElementById("layer-img-"+l.id); + if (layer_image) { layer_image.src = "data:image/png;base64," + l.img; + } + updateLayerVisibilityInTree(layerTree, l.id, l.v); if ("children" in l) { updateLayerImages(l.children); diff --git a/src/kweb/templates/viewer.html b/src/kweb/templates/viewer.html index ee2ceea..0eb06cf 100644 --- a/src/kweb/templates/viewer.html +++ b/src/kweb/templates/viewer.html @@ -9,6 +9,7 @@ + @@ -26,6 +27,31 @@ id="floating-buttons">
+
+
+
+ Annotations +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filter + + +
+
+ + +
+
+ Presets + + + +
+