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: `
${n}
`, 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 = `${i+1}`+ `${g.img.x.toFixed(0)}${g.img.y.toFixed(0)}${g.geo.lon.toFixed(5)}`+ `${g.geo.lat.toFixed(5)}✕`; 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 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);