map-annotation-tool / app /static /component.js
marijanic's picture
Show MapPool strength as percentage
cf2b72d verified
Raw History Blame Contribute Delete
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);