1import { worldText } from "./catalog.js";2import { applicableSystems } from "./cellsystems/index.js";3import { geohashCellAt } from "./cellsystems/geohash.js";4import { collectionFor, geometryContainsCoordinate } from "./collections.js";5import { elements } from "./dom.js";6import { syncLegendCheckboxes, syncSectionSwitches } from "./legend.js";7import { holdCameraReturn, returnCamera, viewMaxZoom } from "./navigation.js";8import { applyPinFilters, onActiveShard, refreshPrioritySource } from "./features.js";9import { renderSearchResults, revealDock } from "./search.js";10import { featureAttributeRows, geoMapping } from "./semconv.js";11import { state } from "./state.js";12import { applyCategoryVisual, applyCategoryGlyph, colorFor, initials } from "./theme.js";13import { cleanDescription, formatNumber } from "./util.js";1415export function revealPin(pin) {16 state.hiddenCollections.delete(pin.category.id);17 syncLegendCheckboxes();18 applyPinFilters();19 syncSectionSwitches();20 showPin(pin, true);21}2223export function showPin(pin, focus = false) {24 state.selectedPin = pin;25 state.selectedZone = null;26 elements.detailCoordinatesField.hidden = false;27 // Anything else drawing the selection -- the globe -- hears about it the28 // same moment the chart's layers do.29 document.dispatchEvent(new Event("atlas:selection"));30 refreshPrioritySource();31 state.layers.pins.changed();32 state.layers.pinLabels.changed();33 state.layers.priority.changed();34 elements.detailTitle.textContent = pin.location.title;35 elements.detailCategory.textContent =36 [pin.category.group, pin.category.title].filter(Boolean).join(" / ");37 elements.detailDescription.textContent = "";38 elements.detailLinks.hidden = true;39 clearFeatureRows();40 fillPinText(pin);41 elements.detailID.textContent = String(pin.location.id);42 elements.detailCoordinates.textContent =43 `${pin.location.lat.toFixed(6)}, ${pin.location.lng.toFixed(6)}`;44 // A latitude of this map's own is hard to hold in the head and harder to45 // compare, so the pin also names the cell it stands in -- the same three46 // characters the grid draws and the navigator takes, which say where a place47 // is by saying which part of the map it is in. A pin off the ground the grid48 // divides has no cell to name, and says nothing rather than the nearest one.49 const cell = geohashCellAt(pin.coordinate);50 elements.detailCell.textContent = cell;51 elements.detailCellField.hidden = !cell;52 // Every other system that can divide this map speaks for the point in53 // its own address, each layer owning the attribute it mints: one row54 // per system, after the geohash row the markup already keeps.55 for (const stray of elements.detail.querySelectorAll("[data-cell-system-row]")) {56 stray.remove();57 }58 for (const system of applicableSystems(state.world)) {59 if (system.slug === "geohash") continue;60 const row = system.locate(pin.coordinate, state.world);61 if (!row) continue;62 const field = document.createElement("div");63 field.dataset.cellSystemRow = system.slug;64 const term = document.createElement("dt");65 term.textContent = row.label;66 const value = document.createElement("dd");67 value.textContent = row.value;68 field.append(term, value);69 elements.detailCellField.after(field);70 }71 // A map composed from several sources says where a pin came from. The72 // payload's merge account names every contribution; a pin it does not name73 // is the map's own, and says nothing rather than guessing.74 const source = pinSource(pin);75 elements.detailSource.textContent = source;76 elements.detailSourceField.hidden = !source;77 applyCategoryVisual(elements.detailDot, pin.category);78 applyCategoryGlyph(elements.detailDot, pin.category, initials(pin.category.title));79 elements.detail.hidden = false;80 revealDock();81 renderSearchResults();82 // A pin clicked on the map is already in front of the reader; only a pin83 // reached for from a panel is worth moving the camera for, and only that84 // move is worth being able to undo.85 if (focus) {86 holdCameraReturn();87 const view = state.engine.getView();88 view.animate({89 center: pin.coordinate,90 zoom: Math.min(viewMaxZoom(state.lens), Math.max(view.getZoom() || 0, 4)),91 duration: 220,92 });93 }94}9596// showFeature opens the panel on a shape feature: its title and collection,97// its prose when the archive carries any -- fetched the same lazy way a98// pin's is -- its measure on the ground where the world declares a geographic99// mapping, and whatever attributes it carries in its own right. Ground has100// no single coordinate or cell, so those rows step aside.101export function showFeature(zone) {102 state.selectedPin = null;103 state.selectedZone = zone;104 document.dispatchEvent(new Event("atlas:selection"));105 refreshPrioritySource();106 state.layers.pins.changed();107 state.layers.priority.changed();108 // A quiet zone speaks its name for as long as it is the one being read109 // about, so selection repaints the chips.110 state.layers.zoneTitles.changed();111 state.layers.zoneTitleDetail.changed();112 const collection = collectionFor(zone);113 elements.detailTitle.textContent = zone.title;114 elements.detailCategory.textContent =115 [collection?.group, collection?.title].filter(Boolean).join(" / ") ||116 zone.subtitle || "Zone";117 elements.detailDescription.textContent = "";118 elements.detailLinks.hidden = true;119 elements.detailLinks.replaceChildren();120 elements.detailID.textContent = String(zone.id);121 elements.detailCoordinatesField.hidden = true;122 elements.detailCellField.hidden = true;123 for (const stray of elements.detail.querySelectorAll("[data-cell-system-row]")) {124 stray.remove();125 }126 const origin = state.world?.merged?.find((account) => account.origin)?.source || "";127 elements.detailSource.textContent = origin;128 elements.detailSourceField.hidden = !origin;129 elements.detailDot.textContent = "";130 elements.detailDot.removeAttribute("style");131 elements.detailDot.style.setProperty("background", colorFor(zone.id));132 clearFeatureRows();133 renderFeatureRows([...featureMeasureRows(zone, collection), ...featureAttributeRows(zone.attrs)]);134 if (zone.hasText) void fillZoneText(zone);135 else elements.detailDescription.textContent = "No description is included in the archive.";136 elements.detail.hidden = false;137 revealDock();138 renderSearchResults();139}140141// featureMeasureRows says what a feature's ground amounts to, in the units of142// the planet -- so only where the world declares a mapping back to one. An143// area gets its extent and how many visible locations stand inside it; a path144// gets its length.145function featureMeasureRows(zone, collection) {146 const record = state.zoneRecords.get(zone.id);147 if (!record || !collection) return [];148 const rows = [];149 const mapping = geoMapping(state.world);150 if (collection.kind === "area") {151 if (mapping) {152 const km2 = record.geometries.reduce((sum, geometry) => sum + geometryAreaKm2(geometry, mapping), 0);153 if (km2 > 0) rows.push({ label: "Area", value: formatKm2(km2) });154 }155 const inside = state.features.filter((pin) =>156 !pin.filteredHidden && onActiveShard(pin.location) &&157 record.geometries.some((geometry) => geometryContainsCoordinate(geometry, pin.coordinate))).length;158 rows.push({ label: "Locations inside", value: formatNumber(inside) });159 }160 if (collection.kind === "path" && mapping) {161 const km = record.geometries.reduce((sum, geometry) => sum + geometryLengthKm(geometry, mapping), 0);162 if (km > 0) rows.push({ label: "Length", value: formatKm(km) });163 }164 return rows;165}166167// The measures below flatten small grounds locally: latitude and longitude168// come back through the world's declared mapping, and a city-sized ring is169// near enough planar that the shoelace over locally scaled kilometres is the170// honest figure.171const kmPerDegreeLat = 110.574;172173function toLatLng(mapping, [x, y]) {174 return mapping.toLatLng(x, -y);175}176177function ringAreaKm2(ring, mapping) {178 const points = ring.map((position) => toLatLng(mapping, position));179 if (points.length < 3) return 0;180 const midLat = points.reduce((sum, point) => sum + point[0], 0) / points.length;181 const kmPerDegreeLng = 111.320 * Math.cos((midLat * Math.PI) / 180);182 let doubled = 0;183 for (let index = 0, prior = points.length - 1; index < points.length; prior = index++) {184 const [latA, lngA] = points[prior];185 const [latB, lngB] = points[index];186 doubled += lngA * kmPerDegreeLng * latB * kmPerDegreeLat -187 lngB * kmPerDegreeLng * latA * kmPerDegreeLat;188 }189 return Math.abs(doubled) / 2;190}191192function geometryAreaKm2(geometry, mapping) {193 const polygons = geometry.getType() === "MultiPolygon"194 ? geometry.getPolygons()195 : geometry.getType() === "Polygon" ? [geometry] : [];196 let total = 0;197 for (const polygon of polygons) {198 polygon.getLinearRings().forEach((ring, index) => {199 const area = ringAreaKm2(ring.getCoordinates(), mapping);200 total += index === 0 ? area : -area;201 });202 }203 return Math.max(0, total);204}205206function geometryLengthKm(geometry, mapping) {207 const lines = geometry.getType() === "MultiLineString"208 ? geometry.getCoordinates()209 : geometry.getType() === "LineString" ? [geometry.getCoordinates()] : [];210 let total = 0;211 for (const line of lines) {212 for (let index = 1; index < line.length; index++) {213 const [latA, lngA] = toLatLng(mapping, line[index - 1]);214 const [latB, lngB] = toLatLng(mapping, line[index]);215 const midLat = ((latA + latB) / 2) * (Math.PI / 180);216 const dx = (lngB - lngA) * 111.320 * Math.cos(midLat);217 const dy = (latB - latA) * kmPerDegreeLat;218 total += Math.hypot(dx, dy);219 }220 }221 return total;222}223224function formatKm2(km2) {225 return `${km2 >= 10 ? formatNumber(Math.round(km2)) : km2.toFixed(2)} km²`;226}227228function formatKm(km) {229 if (km < 1) return `${Math.round(km * 1000)} m`;230 return `${km >= 10 ? formatNumber(Math.round(km)) : km.toFixed(1)} km`;231}232233// The card's own rows -- a feature's measures and attributes -- live in the234// same definition list the fixed rows do, marked so the next selection can235// sweep them.236function clearFeatureRows() {237 for (const stray of elements.detail.querySelectorAll("[data-feature-row]")) {238 stray.remove();239 }240}241242function renderFeatureRows(rows) {243 let anchor = elements.detailSourceField;244 for (const row of rows) {245 const field = document.createElement("div");246 field.dataset.featureRow = "";247 const term = document.createElement("dt");248 term.textContent = row.label;249 const value = document.createElement("dd");250 value.textContent = row.value;251 field.append(term, value);252 anchor.after(field);253 anchor = field;254 }255}256257// The words belonging to a zone arrive with the same text payload a pin's258// do, and the same rule holds: a zone closed or changed while they were on259// the way is left alone.260async function fillZoneText(zone) {261 const text = await worldText();262 if (state.selectedZone !== zone) return;263 const entry = text[String(zone.id)] || {};264 elements.detailDescription.textContent =265 cleanDescription(entry.d) || "No description is included in the archive.";266}267268// pinSource reads a pin's provenance out of the map's merge account: a pin in269// a source-titled group or adopted into a native category came from that270// source, a pin another source matched is the origin's word corroborated, and271// every other pin is the origin's alone. The index is built once per map and272// thrown away with it.273function pinSource(pin) {274 const merged = state.world?.merged;275 if (!merged?.length) return "";276 const origin = merged.find((account) => account.origin)?.source || "";277 if (state.pinSourceIndex?.world !== state.world) {278 const byID = new Map();279 for (const account of merged) {280 for (const adopted of account.adopted || []) {281 byID.set(adopted.d, account.source);282 }283 for (const pair of account.matched || []) {284 const confirmed = `confirmed by ${account.source}`;285 byID.set(pair.w, origin ? `${origin} · ${confirmed}` : confirmed);286 }287 }288 state.pinSourceIndex = { world: state.world, byID };289 }290 for (const account of merged) {291 if (!account.origin && pin.category.group === account.source) return account.source;292 }293 return state.pinSourceIndex.byID.get(pin.location.id) || origin;294}295296// The words belonging to a pin are fetched the first time one is opened, so a297// map that is only ever looked at never pays for them. The panel opens on what298// is already known and fills in when they arrive; a pin closed or changed in299// the meantime is left alone.300export async function fillPinText(pin) {301 const text = await worldText();302 if (state.selectedPin !== pin) return;303 const entry = text[String(pin.location.id)] || {};304 pin.location.description = entry.d || "";305 pin.location.links = entry.l || [];306 elements.detailDescription.textContent =307 cleanDescription(entry.d) || "No description is included in the archive.";308 // A point feature's attributes travel with its text, so its card rows --309 // the HUC-12 of a pin that knows its subwatershed -- arrive with the words.310 clearFeatureRows();311 renderFeatureRows(featureAttributeRows(entry.a));312 renderDetailLinks(pin);313}314315// The source wrote these as mapgenie URLs. They are rebuilt as in-app jumps so316// they still work with no network, and dropped when the target is not on this317// map.318export function renderDetailLinks(pin) {319 const links = (pin.location.links || []).filter((link) => state.featureByID.has(link.locationId));320 elements.detailLinks.hidden = links.length === 0;321 if (!links.length) {322 elements.detailLinks.replaceChildren();323 return;324 }325 const fragment = document.createDocumentFragment();326 for (const link of links) {327 const button = document.createElement("button");328 button.type = "button";329 button.className = "detail-link";330 button.dataset.location = String(link.locationId);331 button.textContent = link.title;332 fragment.append(button);333 }334 elements.detailLinks.replaceChildren(fragment);335}336337export function closeDetail() {338 const hadSelection = state.selectedPin !== null || state.selectedZone !== null;339 state.selectedPin = null;340 state.selectedZone = null;341 document.dispatchEvent(new Event("atlas:selection"));342 if (state.sources) {343 refreshPrioritySource();344 state.layers.pins.changed();345 state.layers.pinLabels.changed();346 state.layers.priority.changed();347 // A quiet zone that spoke while selected falls silent again.348 state.layers.zoneTitles.changed();349 state.layers.zoneTitleDetail.changed();350 }351 elements.detail.hidden = true;352 if (hadSelection) renderSearchResults();353 // The card is done with the camera it borrowed. If the reader has steered354 // since, or never left where they were, there is nothing held and this is355 // no move at all.356 returnCamera();357}