Spaces:
Running
Running
Download app/static/component.js from marijanic/map-annotation-tool: direct link, hf CLI and curl.
- Browser
- Download file 32.3 kB
-
https://huggingface.co/spaces/marijanic/map-annotation-tool/resolve/main/app/static/component.js
- Command line
-
hf download hf://spaces/marijanic/map-annotation-tool/app/static/component.js
-
curl -L -o component.js https://huggingface.co/spaces/marijanic/map-annotation-tool/resolve/main/app/static/component.js
32.3 kB
| const boot = typeof props !== "undefined" ? props.value : globalThis.MAT_BOOTSTRAP; | |
| const appElement = typeof element !== "undefined" ? element : globalThis.MAT_ELEMENT; | |
| if (!boot || !appElement) throw new Error("annotation bootstrap or root element is missing"); | |
| const $ = (id) => appElement.querySelector(`[data-id="${id}"]`); | |
| const COLORS = ["#e6194b", "#3cb44b", "#4363d8", "#f58231", "#911eb4", "#008080", "#f032e6"]; | |
| const TYPES = [ | |
| { key: "aerial", label: "Aerial", hotkey: "1", color: "#795548" }, | |
| { key: "drawing_scan", label: "Drawing/scan", hotkey: "2", color: "#9c27b0" }, | |
| { key: "thematic", label: "Thematic", hotkey: "3", color: "#e65100" }, | |
| { key: "basemap_plain", label: "Plain basemap", hotkey: "4", color: "#1976d2" }, | |
| { key: "unsure", label: "Unsure", hotkey: "5", color: "#607d8b" }, | |
| ]; | |
| const SEP = "\u001f"; | |
| const keyOf = (row) => `${row.dataset_id}${SEP}${row.figure_id}`; | |
| const stable = (value) => { | |
| if (value === null || typeof value !== "object") return JSON.stringify(value); | |
| if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`; | |
| return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${stable(value[k])}`).join(",")}}`; | |
| }; | |
| const clone = (value) => value == null ? value : JSON.parse(JSON.stringify(value)); | |
| const now = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); | |
| const figures = boot.figures.map(row => ({ ...row, key: keyOf(row) })); | |
| const figureByKey = new Map(figures.map(row => [row.key, row])); | |
| const canonicalRows = new Map(boot.canonical.records.map(row => [keyOf(row), row])); | |
| const baseHashes = new Map(Object.entries(boot.canonical.record_sha256)); | |
| let drafts = new Map(); | |
| let handle = ""; | |
| let current = null; | |
| let row = null; | |
| let currentDraft = null; | |
| let currentStale = false; | |
| let gcps = []; | |
| let pending = null; | |
| let dirty = false; | |
| let bbox = null; | |
| let corners = null; | |
| let imgOverlay = null; | |
| let bboxRect = null, cornerPoly = null, warpOverlay = null; | |
| if (boot.github_repository_url) { | |
| $("repository").href = boot.github_repository_url; | |
| $("repository").hidden = false; | |
| } | |
| function openDb() { | |
| return new Promise((resolve, reject) => { | |
| const request = indexedDB.open("map-annotation-tool", 1); | |
| request.onupgradeneeded = () => request.result.createObjectStore("drafts", { keyPath: "id" }); | |
| request.onsuccess = () => resolve(request.result); | |
| request.onerror = () => reject(request.error); | |
| }); | |
| } | |
| const db = await openDb(); | |
| function txRequest(mode, action) { | |
| return new Promise((resolve, reject) => { | |
| const tx = db.transaction("drafts", mode); | |
| const request = action(tx.objectStore("drafts")); | |
| request.onsuccess = () => resolve(request.result); | |
| request.onerror = () => reject(request.error); | |
| }); | |
| } | |
| const workspacePrefix = () => `${boot.dataset.id}|${boot.dataset.revision}|${handle}|`; | |
| const draftId = (key) => workspacePrefix() + key; | |
| async function loadDrafts() { | |
| drafts.clear(); | |
| if (!handle) return; | |
| for (const item of await txRequest("readonly", store => store.getAll())) { | |
| if (item.id.startsWith(workspacePrefix())) drafts.set(item.key, item); | |
| } | |
| } | |
| async function putDraft(item) { | |
| item.id = draftId(item.key); | |
| await txRequest("readwrite", store => store.put(item)); | |
| drafts.set(item.key, item); | |
| } | |
| async function deleteDraft(key) { | |
| await txRequest("readwrite", store => store.delete(draftId(key))); | |
| drafts.delete(key); | |
| } | |
| function setStatus(message, warn = false) { | |
| $("status").textContent = message; | |
| $("status").classList.toggle("warn", warn); | |
| } | |
| function requireHandle() { | |
| if (handle) return true; | |
| setStatus("enter a contributor ID at top left, then click Start or press Enter", true); | |
| $("handle").focus(); | |
| return false; | |
| } | |
| function validHandle(value) { | |
| return /^(?!.*--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(value); | |
| } | |
| async function setHandle() { | |
| const value = $("handle").value.trim().replace(/^@/, ""); | |
| if (!validHandle(value)) return setStatus("use 1–39 letters, numbers, or hyphens", true); | |
| if (dirty && !confirm("Switch contributor and discard the visible unsaved GCP edit?")) return; | |
| handle = value; | |
| localStorage.setItem("map-annotation-tool-handle", value); | |
| await loadDrafts(); | |
| dirty = false; | |
| renderList(); updateCounts(); | |
| if (current) await loadImage(current.key); | |
| setStatus(`working as @${handle}`); | |
| } | |
| const leftMap = L.map($("left-map"), { crs: L.CRS.Simple, minZoom: -6, maxZoom: 6, | |
| attributionControl: false, zoomSnap: 0.25 }); | |
| const rightMap = L.map($("right-map"), { minZoom: 2, maxZoom: 19 }); | |
| const osm = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", | |
| { maxZoom: 19, attribution: "© OpenStreetMap" }).addTo(rightMap); | |
| const sat = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", | |
| { maxZoom: 19, attribution: "Esri World Imagery" }); | |
| L.control.layers({ OSM: osm, Satellite: sat }).addTo(rightMap); | |
| rightMap.setView([-12.82, 45.14], 11); | |
| const pxToLL = (x, y) => L.latLng(current.height - y, x); | |
| const llToPx = (ll) => ({ x: ll.lng, y: current.height - ll.lat }); | |
| const icon = (n, color) => L.divIcon({ className: "gcp-icon", | |
| html: `<div style="background:${color}">${n}</div>`, iconSize: [22, 22], iconAnchor: [11, 11] }); | |
| function solve3(matrix, values) { | |
| const a = matrix.map((values_, i) => values_.concat([values[i]])); | |
| for (let col = 0; col < 3; col++) { | |
| let pivot = col; | |
| for (let r = col + 1; r < 3; r++) if (Math.abs(a[r][col]) > Math.abs(a[pivot][col])) pivot = r; | |
| if (Math.abs(a[pivot][col]) < 1e-12) return null; | |
| [a[col], a[pivot]] = [a[pivot], a[col]]; | |
| for (let r = 0; r < 3; r++) if (r !== col) { | |
| const factor = a[r][col] / a[col][col]; | |
| for (let k = col; k < 4; k++) a[r][k] -= factor * a[col][k]; | |
| } | |
| } | |
| return [a[0][3] / a[0][0], a[1][3] / a[1][1], a[2][3] / a[2][2]]; | |
| } | |
| function fit() { | |
| const points = gcps.filter(g => g.img && g.geo); | |
| if (points.length < 2) return null; | |
| if (points.length === 2) { | |
| const [p, q] = points, dx = q.img.x - p.img.x, dy = q.img.y - p.img.y; | |
| if (Math.abs(dx) < 1e-9 || Math.abs(dy) < 1e-9) return null; | |
| const a = (q.geo.lon - p.geo.lon) / dx, e = (q.geo.lat - p.geo.lat) / dy; | |
| return { lon: [a, 0, p.geo.lon - a * p.img.x], lat: [0, e, p.geo.lat - e * p.img.y] }; | |
| } | |
| let sxx = 0, sxy = 0, sx = 0, syy = 0, sy = 0; | |
| let bxl = 0, byl = 0, bl = 0, bxa = 0, bya = 0, ba = 0; | |
| for (const g of points) { | |
| const x = g.img.x, y = g.img.y; | |
| sxx += x*x; sxy += x*y; sx += x; syy += y*y; sy += y; | |
| bxl += x*g.geo.lon; byl += y*g.geo.lon; bl += g.geo.lon; | |
| bxa += x*g.geo.lat; bya += y*g.geo.lat; ba += g.geo.lat; | |
| } | |
| const m = [[sxx,sxy,sx],[sxy,syy,sy],[sx,sy,points.length]]; | |
| const lon = solve3(m, [bxl,byl,bl]), lat = solve3(m, [bxa,bya,ba]); | |
| return lon && lat ? { lon, lat } : null; | |
| } | |
| const applyFit = (t, x, y) => ({ lon: t.lon[0]*x+t.lon[1]*y+t.lon[2], lat: t.lat[0]*x+t.lat[1]*y+t.lat[2] }); | |
| function serializeGcps() { return gcps.filter(g => g.img && g.geo).map(g => ({ img: clone(g.img), geo: clone(g.geo) })); } | |
| function clearPending() { if (pending?.marker) pending.map.removeLayer(pending.marker); pending = null; } | |
| function clearGcps(render = true) { | |
| for (const g of gcps) { if (g.lm) leftMap.removeLayer(g.lm); if (g.rm) rightMap.removeLayer(g.rm); } | |
| gcps = []; clearPending(); if (render && current) recompute(); | |
| } | |
| function setPending(side, coords, map, latlng) { | |
| dirty = true; | |
| if (!pending) pending = {}; | |
| pending[side] = coords; | |
| if (pending.marker) pending.marker.setLatLng(latlng); | |
| else pending.marker = L.marker(latlng, { opacity: .6, icon: icon("?", "#888") }).addTo(map); | |
| pending.map = map; | |
| } | |
| function addGcp(img, geo, persist = true) { | |
| const g = { img: clone(img), geo: clone(geo) }, color = COLORS[gcps.length % COLORS.length]; | |
| g.lm = L.marker(pxToLL(img.x, img.y), { draggable: true, icon: icon(gcps.length + 1, color) }).addTo(leftMap); | |
| g.rm = L.marker([geo.lat, geo.lon], { draggable: true, icon: icon(gcps.length + 1, color) }).addTo(rightMap); | |
| g.lm.on("drag dragend", () => { g.img = llToPx(g.lm.getLatLng()); changedGcps(); }); | |
| g.rm.on("drag dragend", () => { const ll = g.rm.getLatLng(); g.geo = { lon: ll.lng, lat: ll.lat }; changedGcps(); }); | |
| gcps.push(g); recompute(); | |
| if (persist) changedGcps(); | |
| } | |
| function renumber() { gcps.forEach((g, i) => { const ic = icon(i + 1, COLORS[i % COLORS.length]); g.lm.setIcon(ic); g.rm.setIcon(ic); }); } | |
| function removeGcp(index) { const g = gcps[index]; leftMap.removeLayer(g.lm); rightMap.removeLayer(g.rm); gcps.splice(index, 1); renumber(); changedGcps(); } | |
| leftMap.on("click", event => { | |
| if (!current) return; | |
| const point = llToPx(event.latlng); | |
| if (pending?.geo) { const geo = pending.geo; clearPending(); addGcp(point, geo); } | |
| else setPending("img", point, leftMap, event.latlng); | |
| }); | |
| rightMap.on("click", event => { | |
| if (!current) return; | |
| const geo = { lon: event.latlng.lng, lat: event.latlng.lat }; | |
| if (pending?.img) { const img = pending.img; clearPending(); addGcp(img, geo); } | |
| else setPending("geo", geo, rightMap, event.latlng); | |
| }); | |
| function renderTable() { | |
| $("gcp-rows").innerHTML = ""; | |
| gcps.forEach((g, i) => { | |
| const tr = document.createElement("tr"), color = COLORS[i % COLORS.length]; | |
| tr.innerHTML = `<td style="text-align:left"><span class="swatch" style="background:${color}"></span>${i+1}</td>`+ | |
| `<td>${g.img.x.toFixed(0)}</td><td>${g.img.y.toFixed(0)}</td><td>${g.geo.lon.toFixed(5)}</td>`+ | |
| `<td>${g.geo.lat.toFixed(5)}</td><td class="del" title="delete">✕</td>`; | |
| tr.querySelector(".del").onclick = () => removeGcp(i); | |
| $("gcp-rows").appendChild(tr); | |
| }); | |
| } | |
| function recompute() { | |
| renderTable(); | |
| for (const layer of [bboxRect, cornerPoly, warpOverlay]) if (layer) rightMap.removeLayer(layer); | |
| bboxRect = cornerPoly = warpOverlay = null; bbox = corners = null; | |
| const transform = fit(), count = serializeGcps().length; | |
| if (!transform) { | |
| $("bbox").textContent = "—"; $("km").textContent = ""; $("save").disabled = true; | |
| if (current) setStatus(count < 2 ? "add ≥ 2 control points" : "points are collinear", true); | |
| return; | |
| } | |
| corners = [[0,0],[current.width,0],[current.width,current.height],[0,current.height]].map(p => applyFit(transform, ...p)); | |
| const lons = corners.map(p => p.lon), lats = corners.map(p => p.lat); | |
| bbox = [Math.min(...lats), Math.max(...lats), Math.min(...lons), Math.max(...lons)]; | |
| cornerPoly = L.polygon(corners.map(p => [p.lat,p.lon]), { color:"#2a7ade", weight:1, fill:false, dashArray:"4" }).addTo(rightMap); | |
| bboxRect = L.rectangle([[bbox[0],bbox[2]],[bbox[1],bbox[3]]], { color:"#e6194b", weight:2, fill:false }).addTo(rightMap); | |
| const opacity = parseFloat($("opacity").value); | |
| if (opacity > 0) warpOverlay = L.imageOverlay(current.media_url, [[bbox[0],bbox[2]],[bbox[1],bbox[3]]], { opacity }).addTo(rightMap); | |
| const mid = (bbox[0]+bbox[1])/2, width = Math.abs(bbox[3]-bbox[2])*111*Math.max(Math.abs(Math.cos(mid*Math.PI/180)),1e-6); | |
| const height = Math.abs(bbox[1]-bbox[0])*111; | |
| $("bbox").textContent = `[${bbox.map(v => v.toFixed(5)).join(", ")}]`; | |
| $("km").textContent = `~ ${width.toFixed(2)} × ${height.toFixed(2)} km`; | |
| $("save").disabled = count < 3 || currentStale; | |
| setStatus(currentStale ? "stale draft — discard and reapply" : count < 3 ? "preview only — add a third GCP" : "", currentStale || count < 3); | |
| } | |
| function seedRow(classification = "map") { | |
| const base = row || canonicalRows.get(current.key); | |
| if (base) return clone(base); | |
| const stamp = now(); | |
| return { schema_version:"1.0.0", dataset_id:current.dataset_id, figure_id:current.figure_id, | |
| classification, georef_status: classification === "not_map" ? "not_applicable" : "pending", | |
| source_document:current.source_document, | |
| image:{ path:current.media_path, width:current.width, height:current.height, sha256:current.sha256 }, | |
| map_type:null, georeference:null, | |
| annotation:{ source:"human_hf_space", annotator:handle, created_at:stamp, updated_at:stamp } }; | |
| } | |
| function touch(next) { | |
| const stamp = now(), original = canonicalRows.get(current.key); | |
| next.annotation = { source:"human_hf_space", annotator:handle, | |
| created_at: next.annotation?.created_at || original?.annotation?.created_at || stamp, updated_at:stamp }; | |
| return next; | |
| } | |
| function isStale(draft) { | |
| if (!draft) return false; | |
| const base = baseHashes.has(draft.key) ? baseHashes.get(draft.key) : null; | |
| const live = canonicalRows.get(draft.key); | |
| return draft.base_sha256 !== base && stable(draft.row) !== stable(live); | |
| } | |
| async function persistRow(next, working = serializeGcps()) { | |
| if (!requireHandle() || currentStale) return false; | |
| row = touch(next); | |
| const prior = drafts.get(current.key); | |
| const item = { key:current.key, base_sha256:prior ? prior.base_sha256 : (baseHashes.get(current.key) || null), | |
| row:clone(row), working_gcps:clone(working) }; | |
| await putDraft(item); currentDraft = item; dirty = false; | |
| renderList(); updateCounts(); paintWorkflow(); return true; | |
| } | |
| async function changedGcps() { | |
| dirty = true; | |
| if (!requireHandle() || currentStale) return; | |
| const next = seedRow("map"); | |
| if (next.classification === "not_map" || next.classification === "unsure") { | |
| next.classification = "map"; next.georef_status = "pending"; next.georeference = null; | |
| } | |
| await persistRow(next, serializeGcps()); | |
| } | |
| async function saveGeometry() { | |
| if (!bbox || serializeGcps().length < 3 || !requireHandle() || currentStale) return; | |
| const next = seedRow("map"); next.classification = "map"; next.georef_status = "complete"; | |
| next.georeference = { method:"affine", bbox:clone(bbox), corners:corners.map(p => [p.lon,p.lat]), gcps:serializeGcps() }; | |
| await persistRow(next, null); dirty = false; setStatus("saved in browser ✓"); | |
| } | |
| async function setReview(classification) { | |
| if (!requireHandle() || !current || currentStale) return; | |
| const next = seedRow(classification); next.classification = classification; | |
| if (classification !== "map") { | |
| next.georeference = null; next.georef_status = classification === "not_map" ? "not_applicable" : "pending"; | |
| if (classification === "not_map") next.map_type = null; | |
| clearGcps(); | |
| } else if (!next.georeference) { | |
| next.georef_status = "pending"; | |
| } | |
| await persistRow(next, classification === "map" ? serializeGcps() : []); | |
| setStatus("review saved in browser ✓"); paintWorkflow(); | |
| } | |
| async function setHard() { | |
| if (!requireHandle() || !current || currentStale) return; | |
| const next = seedRow("map"); next.classification = "map"; next.georef_status = "not_possible"; next.georeference = null; | |
| clearGcps(); await persistRow(next, []); setStatus("marked not possible ✓"); | |
| } | |
| async function setMapType(type) { | |
| if (!requireHandle() || !current || currentStale) return; | |
| const next = seedRow("map"); next.map_type = next.map_type === type ? null : type; | |
| if (next.classification === "not_map") { next.classification = "map"; next.georef_status = "pending"; } | |
| await persistRow(next, serializeGcps()); paintWorkflow(); setStatus("type saved in browser ✓"); | |
| } | |
| async function removeGeoref() { | |
| if (!current || currentStale || !requireHandle() || !confirm("Remove all GCPs and geometry? Classification and map type will be preserved.")) return; | |
| const next = seedRow("map"); next.classification = "map"; next.georef_status = "pending"; next.georeference = null; | |
| clearGcps(); await persistRow(next, []); setStatus("georeference removed ✓"); | |
| } | |
| async function discardDraft() { | |
| if (!current || !drafts.has(current.key) || !confirm("Discard this browser draft and restore the latest canonical annotation?")) return; | |
| await deleteDraft(current.key); await loadImage(current.key); setStatus("draft discarded"); | |
| } | |
| function paintTypes() { | |
| const selected = row?.map_type || null; | |
| appElement.querySelectorAll(".chip").forEach(chip => { | |
| const on = chip.dataset.type === selected; chip.classList.toggle("on", on); | |
| chip.setAttribute("aria-pressed", String(on)); | |
| chip.style.background = on ? chip.dataset.color : ""; | |
| }); | |
| } | |
| function setNote(id, text, kind = "") { | |
| $(id).textContent = text; | |
| $(id).classList.toggle("ok", kind === "ok"); | |
| $(id).classList.toggle("need", kind === "need"); | |
| } | |
| function formatDetectorStrength(detector) { | |
| const margin = Number(detector?.margin); | |
| if (!Number.isFinite(margin)) return null; | |
| return `${(100 / (1 + Math.exp(-Math.abs(margin)))).toFixed(0)}%`; | |
| } | |
| function paintWorkflow() { | |
| if (!current) return; | |
| const reviewButtons = { map: $("map"), not_map: $("not-map"), unsure: $("unsure") }; | |
| for (const button of Object.values(reviewButtons)) { | |
| button.classList.remove("active", "suggested"); | |
| button.setAttribute("aria-pressed", "false"); | |
| } | |
| const detector = current.detector; | |
| const humanClassification = row?.classification || null; | |
| if (humanClassification) { | |
| reviewButtons[humanClassification].classList.add("active"); | |
| reviewButtons[humanClassification].setAttribute("aria-pressed", "true"); | |
| setNote("review-state", `human: ${humanClassification === "not_map" ? "not a map" : humanClassification}`, "ok"); | |
| } else if (detector?.label && reviewButtons[detector.label]) { | |
| reviewButtons[detector.label].classList.add("suggested"); | |
| setNote("review-state", "unverified", "need"); | |
| } else { | |
| setNote("review-state", "review needed", "need"); | |
| } | |
| const strength = formatDetectorStrength(detector); | |
| $("prediction").textContent = detector | |
| ? `MapPool ${detector.label === "not_map" ? "not a map" : "map"}${strength ? ` (${strength})` : ""}` | |
| : "MapPool unavailable"; | |
| $("prediction").title = detector && strength | |
| ? `Uncalibrated prediction strength; raw decision margin: ${Number(detector.margin).toFixed(3)}` | |
| : ""; | |
| $("prediction").classList.toggle("unverified", !humanClassification && Boolean(detector)); | |
| paintTypes(); | |
| if (row?.classification === "map") { | |
| const selectedType = TYPES.find(type => type.key === row.map_type); | |
| setNote("type-state", selectedType ? `set: ${selectedType.label}` : "type needed", selectedType ? "ok" : "need"); | |
| } else { | |
| setNote("type-state", humanClassification ? "not applicable" : "after map review"); | |
| } | |
| $("hard").classList.toggle("active", row?.classification === "map" && row?.georef_status === "not_possible"); | |
| $("hard").setAttribute("aria-pressed", String(row?.classification === "map" && row?.georef_status === "not_possible")); | |
| $("save").classList.toggle("active", row?.classification === "map" && row?.georef_status === "complete"); | |
| if (row?.classification !== "map") setNote("georef-state", humanClassification ? "not applicable" : "after map review"); | |
| else if (row.georef_status === "complete") setNote("georef-state", "complete", "ok"); | |
| else if (row.georef_status === "not_possible") setNote("georef-state", "not possible"); | |
| else setNote("georef-state", "georeference needed", "need"); | |
| } | |
| for (const type of TYPES) { | |
| const chip = document.createElement("span"); chip.className = "chip"; chip.dataset.type = type.key; | |
| chip.dataset.color = type.color; chip.textContent = `${type.hotkey} ${type.label}`; chip.onclick = () => setMapType(type.key); | |
| $("types").appendChild(chip); | |
| } | |
| function effective(key) { return drafts.get(key)?.row || canonicalRows.get(key) || null; } | |
| function done(record) { return record && (record.classification === "not_map" || ["complete","not_possible"].includes(record.georef_status)); } | |
| function compareFigures(a,b) { | |
| const pdf = a.source_document.localeCompare(b.source_document, undefined, {numeric:true}); | |
| if (pdf) return pdf; | |
| const order = $("queue-order").value; | |
| if (order !== "figure") { | |
| const am = Number.isFinite(Number(a.detector?.margin)) ? Number(a.detector.margin) : (order === "margin-desc" ? -Infinity : Infinity); | |
| const bm = Number.isFinite(Number(b.detector?.margin)) ? Number(b.detector.margin) : (order === "margin-desc" ? -Infinity : Infinity); | |
| if (am !== bm) return order === "margin-desc" ? bm-am : am-bm; | |
| } | |
| return a.figure_id.localeCompare(b.figure_id, undefined, {numeric:true}) || a.key.localeCompare(b.key); | |
| } | |
| function updateCounts() { | |
| const complete = figures.filter(f => done(effective(f.key))).length; | |
| $("counts").textContent = `${complete} / ${figures.length} reviewed · ${drafts.size} browser drafts`; | |
| } | |
| function addBadge(container, text, kind, title) { | |
| const badge = document.createElement("span"); | |
| badge.className = `queue-badge ${kind}`; badge.textContent = text; badge.title = title; | |
| container.appendChild(badge); | |
| } | |
| function renderFigureBadges(container, figure) { | |
| const record = effective(figure.key); | |
| if (!record) { | |
| if (figure.detector) { | |
| const label = figure.detector.label === "map" ? "map" : "non-map"; | |
| addBadge(container, `◇ AI ${label}`, "ai", `Unverified MapPool suggestion: ${label}`); | |
| } else addBadge(container, "◇ review", "need", "Human classification needed"); | |
| return; | |
| } | |
| if (record.classification === "not_map") { | |
| addBadge(container, "✓ non-map", "ok", "Human-reviewed: not a map"); return; | |
| } | |
| if (record.classification === "unsure") { | |
| addBadge(container, "? unsure", "need", "Human-reviewed: unsure"); return; | |
| } | |
| addBadge(container, "✓ map", "ok", "Human-reviewed: map"); | |
| addBadge(container, record.map_type ? "✓ type" : "! type needed", record.map_type ? "ok" : "need", | |
| record.map_type ? `Map type set: ${record.map_type}` : "Map type needed"); | |
| if (record.georef_status === "complete") addBadge(container, "✓ georef", "ok", "Georeference complete"); | |
| else if (record.georef_status === "not_possible") addBadge(container, "– georef N/A", "neutral", "Georeference not possible"); | |
| else addBadge(container, "! georef needed", "need", "Georeference needed"); | |
| } | |
| function renderList() { | |
| const query = $("filter").value.toLowerCase(), onlyPending = $("pending-only").checked, pred = $("prediction-filter").value; | |
| $("list").innerHTML = ""; let lastPdf = null; | |
| figures.filter(f => { | |
| const detector = f.detector?.label || "none"; | |
| return (!onlyPending || !done(effective(f.key))) && (!pred || detector === pred) && | |
| (!query || f.source_document.toLowerCase().includes(query) || f.figure_id.toLowerCase().includes(query)); | |
| }).sort(compareFigures).forEach(f => { | |
| if (f.source_document !== lastPdf) { | |
| const header = document.createElement("div"); header.className = "pdfgroup"; header.textContent = f.source_document; | |
| $("list").appendChild(header); lastPdf = f.source_document; | |
| } | |
| const item = document.createElement("div"), draft = drafts.get(f.key); | |
| item.className = "item" + (done(effective(f.key)) ? " done" : "") + (draft ? " draft" : "") + | |
| (draft && isStale(draft) ? " stale" : "") + (current?.key === f.key ? " sel" : ""); | |
| const number = f.figure_id.match(/^image_(\d+)/)?.[1] || f.figure_id.slice(0,12); | |
| const name = document.createElement("span"), badges = document.createElement("span"); | |
| name.className = "item-name"; name.textContent = `fig ${number} · ${f.figure_id.slice(-10)}`; name.title = f.figure_id; | |
| badges.className = "item-badges"; renderFigureBadges(badges, f); item.append(name, badges); | |
| item.onclick = () => { if (!dirty || confirm("Discard the visible unsaved GCP edit?")) loadImage(f.key); }; | |
| $("list").appendChild(item); | |
| }); | |
| } | |
| async function loadImage(key) { | |
| clearGcps(false); current = figureByKey.get(key); currentDraft = drafts.get(key) || null; | |
| currentStale = isStale(currentDraft); row = clone(currentDraft?.row || canonicalRows.get(key) || null); dirty = false; | |
| if (imgOverlay) leftMap.removeLayer(imgOverlay); | |
| imgOverlay = L.imageOverlay(current.media_url, [[0,0],[current.height,current.width]]).addTo(leftMap); | |
| leftMap.fitBounds([[0,0],[current.height,current.width]]); | |
| $("title").textContent = current.figure_id.slice(0,20) + "…"; | |
| $("figmeta").textContent = `${current.source_document} · ${current.width}×${current.height}`; | |
| const savedGcps = currentDraft && Array.isArray(currentDraft.working_gcps) | |
| ? currentDraft.working_gcps : row?.georeference?.gcps || []; | |
| for (const g of savedGcps) addGcp(g.img, g.geo, false); | |
| if (!savedGcps.length) recompute(); | |
| if (row?.georeference?.bbox && !savedGcps.length) { | |
| const b = row.georeference.bbox; rightMap.fitBounds([[b[0],b[2]],[b[1],b[3]]]); | |
| } else if (bbox) rightMap.fitBounds([[bbox[0],bbox[2]],[bbox[1],bbox[3]]]); | |
| paintWorkflow(); renderList(); | |
| if (currentStale) setStatus("stale draft — discard it and reapply against latest main", true); | |
| } | |
| function nextPending() { | |
| const ordered = [...figures].sort(compareFigures), start = current ? ordered.findIndex(f => f.key === current.key) + 1 : 0; | |
| for (let offset=0; offset<ordered.length; offset++) { const f=ordered[(start+offset)%ordered.length]; if (!done(effective(f.key))) return loadImage(f.key); } | |
| setStatus("no pending figures"); | |
| } | |
| function downloadJson(payload, filename) { | |
| const blob = new Blob([JSON.stringify(payload, null, 2) + "\n"], {type:"application/json"}); | |
| const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = filename; link.click(); | |
| setTimeout(() => URL.revokeObjectURL(link.href), 1000); | |
| } | |
| function validRecord(value) { | |
| if (!value || value.schema_version !== "1.0.0" || !value.dataset_id || !value.figure_id) return false; | |
| if (!["map","not_map","unsure"].includes(value.classification)) return false; | |
| if (!["complete","pending","not_possible","not_applicable"].includes(value.georef_status)) return false; | |
| if (!value.image?.path || value.image.path.startsWith("/") || value.image.path.includes("..") || value.image.path.includes("\\")) return false; | |
| if (value.annotation?.source !== "human_hf_space" || value.annotation?.annotator !== handle) return false; | |
| if (value.classification === "not_map") return value.georef_status === "not_applicable" && value.map_type === null && value.georeference === null; | |
| if (value.classification === "unsure") return value.georef_status === "pending" && value.georeference === null; | |
| if (value.georef_status === "complete") return value.georeference?.method === "affine" && value.georeference.gcps?.length >= 3 && value.georeference.bbox?.length === 4; | |
| return value.georef_status !== "not_applicable" && value.georeference === null; | |
| } | |
| async function exportSubmission() { | |
| if (!requireHandle()) return; | |
| const stale = [...drafts.values()].filter(isStale); | |
| if (stale.length) return setStatus(`${stale.length} stale draft(s) must be discarded and reapplied`, true); | |
| const changes = [...drafts.values()].filter(d => stable(d.row) !== stable(canonicalRows.get(d.key) || null)).map(d => ({ | |
| dataset_id:d.row.dataset_id, figure_id:d.row.figure_id, base_sha256:d.base_sha256, record:d.row, | |
| })).sort((a,b) => `${a.dataset_id}/${a.figure_id}`.localeCompare(`${b.dataset_id}/${b.figure_id}`)); | |
| if (!changes.length) return setStatus("no changed annotations to export", true); | |
| const invalid = changes.filter(change => !validRecord(change.record)); | |
| if (invalid.length) return setStatus(`${invalid.length} invalid draft(s) cannot be exported`, true); | |
| const stamp = now(), compact = stamp.replace(/[-:]/g, "").replace("T", "-").replace("Z", "Z"); | |
| const payload = { submission_schema_version:"1.0.0", contributor:handle, generated_at:stamp, | |
| dataset:clone(boot.dataset), canonical:{url:boot.canonical.url, sha256:boot.canonical.sha256}, changes }; | |
| downloadJson(payload, `${handle}-${compact}.json`); | |
| setStatus(`downloaded ${changes.length} change(s); send the JSON to the maintainer or submit it by PR`); | |
| } | |
| function backupDrafts() { | |
| if (!requireHandle() || !drafts.size) return setStatus("no browser drafts to back up", true); | |
| const stamp = now(), payload = { backup_schema_version:"1.0.0", contributor:handle, | |
| generated_at:stamp, dataset:clone(boot.dataset), drafts:[...drafts.values()].map(item => ({ | |
| key:item.key, base_sha256:item.base_sha256, row:item.row, working_gcps:item.working_gcps ?? null, | |
| })) }; | |
| downloadJson(payload, `${handle}-draft-backup-${stamp.slice(0,10)}.json`); | |
| setStatus(`backed up ${drafts.size} browser draft(s)`); | |
| } | |
| async function resetAll() { | |
| if (!requireHandle() || !drafts.size || !confirm(`Discard all ${drafts.size} browser drafts for @${handle}? Export a backup first if needed.`)) return; | |
| for (const key of [...drafts.keys()]) await deleteDraft(key); | |
| if (current) await loadImage(current.key); renderList(); updateCounts(); setStatus("all browser drafts discarded"); | |
| } | |
| async function importSubmission(file) { | |
| if (!file || !requireHandle()) return; | |
| try { | |
| const payload = JSON.parse(await file.text()); | |
| if (payload.contributor !== handle) throw new Error(`file belongs to @${payload.contributor}`); | |
| if (payload.dataset.id !== boot.dataset.id || payload.dataset.revision !== boot.dataset.revision) throw new Error("dataset revision does not match"); | |
| let incoming; | |
| if (payload.submission_schema_version === "1.0.0" && Array.isArray(payload.changes)) { | |
| incoming = payload.changes.map(change => ({ key:`${change.dataset_id}${SEP}${change.figure_id}`, | |
| base_sha256:change.base_sha256, row:change.record, working_gcps:null })); | |
| } else if (payload.backup_schema_version === "1.0.0" && Array.isArray(payload.drafts)) { | |
| incoming = payload.drafts; | |
| } else throw new Error("not a submission export or browser backup"); | |
| for (const draft of incoming) { | |
| const item = { key:draft.key, base_sha256:draft.base_sha256, row:draft.row, | |
| working_gcps:draft.working_gcps ?? null }; | |
| if (!figureByKey.has(item.key)) throw new Error(`unknown figure key ${item.key}`); | |
| await putDraft(item); | |
| } | |
| renderList(); updateCounts(); if (current) await loadImage(current.key); | |
| setStatus(`imported ${incoming.length} draft(s)`); | |
| } catch (error) { setStatus(`import failed: ${error.message}`, true); } | |
| $("import").value = ""; | |
| } | |
| $("set-handle").onclick = setHandle; $("filter").oninput = renderList; | |
| $("handle").addEventListener("keydown", event => { | |
| if (event.key === "Enter") { event.preventDefault(); setHandle(); } | |
| }); | |
| $("pending-only").onchange = renderList; $("prediction-filter").onchange = renderList; | |
| $("queue-order").onchange = renderList; | |
| $("map").onclick = () => setReview("map"); $("unsure").onclick = () => setReview("unsure"); | |
| $("not-map").onclick = () => setReview("not_map"); $("hard").onclick = setHard; | |
| $("clear").onclick = () => { if (current && requireHandle()) { clearGcps(); changedGcps(); } }; | |
| $("remove").onclick = removeGeoref; $("reset").onclick = discardDraft; $("next").onclick = nextPending; | |
| $("save").onclick = saveGeometry; $("export").onclick = exportSubmission; | |
| $("backup").onclick = backupDrafts; $("reset-all").onclick = resetAll; | |
| $("import").onchange = event => importSubmission(event.target.files[0]); $("opacity").oninput = recompute; | |
| appElement.addEventListener("click", event => { | |
| const control = event.target.closest("button, .chip, .import"); | |
| if (!control || !appElement.contains(control)) return; | |
| control.classList.remove("clicked"); void control.offsetWidth; control.classList.add("clicked"); | |
| setTimeout(() => control.classList.remove("clicked"), 350); | |
| }); | |
| appElement.addEventListener("keydown", event => { | |
| if (["INPUT","SELECT","TEXTAREA"].includes(event.target.tagName) || event.ctrlKey || event.metaKey || event.altKey) return; | |
| const actions = { p:setHandle, m:()=>setReview("map"), u:()=>setReview("unsure"), x:()=>setReview("not_map"), | |
| h:setHard, c:()=>{ if(current&&requireHandle()){clearGcps();changedGcps();}}, r:removeGeoref, | |
| d:discardDraft, n:nextPending, s:saveGeometry, e:exportSubmission, b:backupDrafts, | |
| a:resetAll, i:()=>$("import").click() }; | |
| const key = event.key.toLowerCase(); | |
| if (actions[key]) { event.preventDefault(); actions[key](); return; } | |
| const type = TYPES.find(t => t.hotkey === key); if (type) { event.preventDefault(); setMapType(type.key); } | |
| }); | |
| handle = localStorage.getItem("map-annotation-tool-handle") || ""; $("handle").value = handle; | |
| await loadDrafts(); figures.sort(compareFigures); renderList(); updateCounts(); | |
| if (handle) setStatus(`working as @${handle}`); else setStatus("enter a contributor ID at top left to begin", true); | |
| setTimeout(() => { leftMap.invalidateSize(); rightMap.invalidateSize(); }, 100); | |