clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

archive/golden-reference
atlas / frontend / src / areas.js
areas.js 12.5K
1import Feature from "ol/Feature.js";2import { createEmpty, extend, getCenter } from "ol/extent.js";3import Point from "ol/geom/Point.js";4import MultiLineString from "ol/geom/MultiLineString.js";5import MultiPolygon from "ol/geom/MultiPolygon.js";6import Polygon from "ol/geom/Polygon.js";78import { anyShapeCollectionVisible, collectionOf, isCollectionHidden } from "./collections.js";9import { elements } from "./dom.js";10import { state } from "./state.js";11import { showFeature } from "./detail.js";12import { collectionID, syncLegendCheckboxes, syncSectionSwitches } from "./legend.js";13import { holdCameraReturn, viewMaxZoom } from "./navigation.js";14import { onActiveShard, refreshPinRendering, updateZonePinFocus } from "./features.js";15import { labelPolicy } from "./semconv.js";16import { colorFor } from "./theme.js";1718// renderShapes lays the world's shape features -- areas and paths both --19// onto the chart: their geometries, their title anchors, and the records20// everything downstream reads them by.21export function renderShapes() {22 state.renderedShard = state.lens?.shard || 0;23 state.sources.zones.clear();24 state.sources.zoneTitles.clear();25 state.zoneRecords.clear();26 state.highlightedZones.clear();27 state.focusedZoneID = null;28 state.zoneTitleCount = 0;29 syncZoneLayers();3031 for (const collection of state.world.collections) {32 if (collection.kind === "point") continue;33 for (const zone of collection.features || []) {34 if (!onActiveShard(zone)) continue;35 // Each shape feature learns its collection's id here, once, so36 // everything downstream -- styles, filters, the label ladder -- can37 // ask the feature alone.38 zone.collectionId = collection.id;39 const zoneExtent = createEmpty();40 const geometries = [];41 let hasGeometry = false;42 for (const rawGeometry of zone.geometry || []) {43 const geometry = projectZoneGeometry(rawGeometry);44 if (!geometry) continue;45 hasGeometry = true;46 geometries.push(geometry);47 extend(zoneExtent, geometry.getExtent());48 state.sources.zones.addFeature(new Feature({49 geometry,50 zone,51 color: colorFor(zone.id),52 child: zone.parent != null,53 }));54 }55 if (!hasGeometry) continue;56 const color = colorFor(zone.id);57 const center = zone.center ? project(zone.center.lat, zone.center.lng) : getCenter(zoneExtent);58 const span = Math.max(zoneExtent[2] - zoneExtent[0], zoneExtent[3] - zoneExtent[1]);59 state.sources.zoneTitles.addFeature(new Feature({60 geometry: new Point(center),61 zone,62 color,63 child: zone.parent != null,64 span,65 priority: (zone.parent == null ? 2_000_000 : 1_000_000) + Math.round(span),66 }));67 state.zoneRecords.set(zone.id, {68 zone,69 collection,70 extent: zoneExtent.slice(),71 geometries,72 color,73 });74 }75 }76 recountZoneTitles();77 renderZoneScrim();78 renderZoneIndex();79}8081// Only names that draw on their own crowd the map, so only they press on82// the threshold that starts thinning them. A hundred quiet zones cost the83// spoken ones nothing. Recounted whenever the policy moves -- the legend's84// label toggle changes the answer without rebuilding a single zone.85export function recountZoneTitles() {86 state.zoneTitleCount = state.sources.zoneTitles.getFeatures()87 .filter((feature) => labelPolicy(feature.get("zone")) !== "quiet").length;88}8990// Highlighting a zone is a request to look at one place, so everything else91// recedes: the whole map is covered and the zone is cut back out of it.92// Shading only the neighbouring zones demoted them rather than raising the93// zone asked for, and left the unclaimed space between them at full strength.94export function renderZoneScrim() {95 state.sources.zoneScrim.clear();96 if (!state.highlightedZones.size) return;97 const [left, bottom, right, top] = state.projection.getExtent();98 // Rings alternate direction with their depth so the fill counts its way in99 // and out: the map, then the zone as a hole, then any hole of the zone's own100 // back to solid.101 const rings = [orientRing([102 [left, bottom], [right, bottom], [right, top], [left, top], [left, bottom],103 ], true)];104 for (const zoneID of state.highlightedZones) {105 for (const geometry of state.zoneRecords.get(zoneID)?.geometries || []) {106 // A line zone has no ring to cut from the scrim: the highlighted path107 // draws above it instead, at its own weight.108 if (geometry.getType() === "MultiLineString") continue;109 const polygons = geometry.getType() === "MultiPolygon"110 ? geometry.getPolygons()111 : [geometry];112 for (const polygon of polygons) {113 polygon.getLinearRings().forEach((ring, index) => {114 rings.push(orientRing(ring.getCoordinates(), index > 0));115 });116 }117 }118 }119 state.sources.zoneScrim.addFeature(new Feature({ geometry: new Polygon(rings) }));120}121122// Twice the signed area of a ring: negative when its points run clockwise.123export function orientRing(ring, clockwise) {124 let area = 0;125 for (let index = 0, prior = ring.length - 1; index < ring.length; prior = index++) {126 area += ring[prior][0] * ring[index][1] - ring[index][0] * ring[prior][1];127 }128 return (area < 0) === clockwise ? ring : ring.slice().reverse();129}130131// The feature index under an unfolded shape row: one button per zone, in the132// parent-first order of the ground itself, each container listing its own133// collection's features.134export function renderZoneIndex() {135 for (const container of elements.legend.querySelectorAll(".feature-index")) {136 const fragment = document.createDocumentFragment();137 for (const { zone, depth } of orderedZones(collectionID(container.dataset.featureIndex))) {138 const record = state.zoneRecords.get(zone.id);139 if (!record) continue;140 const button = document.createElement("button");141 button.type = "button";142 button.className = "zone-index-item";143 button.dataset.zone = String(zone.id);144 button.style.setProperty("--zone-color", record.color);145 button.style.setProperty("--zone-depth", String(depth));146 button.setAttribute("aria-pressed", "false");147 button.title = `${zone.title}: click to jump; right-click to toggle highlight`;148149 const marker = document.createElement("span");150 marker.className = "zone-index-marker";151 marker.setAttribute("aria-hidden", "true");152 const title = document.createElement("span");153 title.textContent = zone.title;154 button.append(marker, title);155 fragment.append(button);156 }157 container.replaceChildren(fragment);158 }159}160161export function orderedZones(collectionId) {162 const zones = [...state.zoneRecords.values()]163 .filter((record) => record.zone.collectionId === collectionId)164 .map((record) => record.zone);165 const zoneIDs = new Set(zones.map((zone) => zone.id));166 const children = new Map();167 for (const zone of zones) {168 const parentID = zoneIDs.has(zone.parent) ? zone.parent : null;169 if (!children.has(parentID)) children.set(parentID, []);170 children.get(parentID).push(zone);171 }172 for (const entries of children.values()) {173 entries.sort((left, right) => left.title.localeCompare(right.title, undefined, {174 numeric: true,175 sensitivity: "base",176 }));177 }178179 const ordered = [];180 const visited = new Set();181 const append = (zone, depth) => {182 if (visited.has(zone.id)) return;183 visited.add(zone.id);184 ordered.push({ zone, depth });185 for (const child of children.get(zone.id) || []) append(child, depth + 1);186 };187 for (const zone of children.get(null) || []) append(zone, 0);188 for (const zone of zones) append(zone, 0);189 return ordered;190}191192// The four zone layers answer the ledger the same way the pins do: drawn193// while their collection is shown, and away while it is hidden. Hiding also194// withdraws the hidden collection's highlights -- a filter must not keep195// conditioning the map from under a layer the reader has put away.196export function syncZoneLayers() {197 const visible = anyShapeCollectionVisible();198 state.layers.zoneScrim.setVisible(visible);199 state.layers.zones.setVisible(visible);200 state.layers.zoneTitles.setVisible(visible);201 state.layers.zoneTitleDetail.setVisible(visible);202 let pruned = false;203 for (const zoneID of [...state.highlightedZones]) {204 const record = state.zoneRecords.get(zoneID);205 if (!record || isCollectionHidden(collectionOf(record.zone))) {206 state.highlightedZones.delete(zoneID);207 pruned = true;208 }209 }210 if (pruned) {211 updateZonePinFocus();212 renderZoneScrim();213 updateZoneIndexState();214 }215 // Which features draw is each feature's own style to answer, and the216 // answer may just have changed while the layers' visibility did not:217 // soloing one collection keeps the layers up but must repaint them, or218 // the last frame -- every collection drawn -- keeps standing.219 state.layers.zones.changed();220 state.layers.zoneTitles.changed();221 state.layers.zoneTitleDetail.changed();222}223224export function jumpToZone(zoneID) {225 const record = state.zoneRecords.get(zoneID);226 if (!record) return;227 state.focusedZoneID = zoneID;228 updateZoneIndexState();229 showFeature(record.zone);230 // Reached for from a list, the same way a location is: the view it costs is231 // given back when the card closes.232 holdCameraReturn();233 state.engine.getView().fit(record.extent, {234 size: state.engine.getSize(),235 padding: [54, 54, 54, 54],236 maxZoom: viewMaxZoom(state.lens),237 duration: 220,238 });239 elements.sidebar.classList.remove("is-open");240 elements.mobileLegend.setAttribute("aria-expanded", "false");241}242243export function toggleZoneHighlight(zoneID) {244 const record = state.zoneRecords.get(zoneID);245 if (!record) return;246 if (state.highlightedZones.has(zoneID)) state.highlightedZones.delete(zoneID);247 else {248 state.highlightedZones.add(zoneID);249 // Highlighting unhides: asking to look at a zone and keeping its layer250 // away cannot both be meant.251 if (state.hiddenCollections.delete(collectionOf(record.zone))) {252 syncLegendCheckboxes();253 syncSectionSwitches();254 }255 syncZoneLayers();256 }257 state.hoveredPin = null;258 updateZonePinFocus();259 refreshPinRendering();260 updateZoneIndexState();261 renderZoneScrim();262 state.layers.zones.changed();263 state.layers.zoneTitles.changed();264 state.layers.zoneTitleDetail.changed();265}266267export function updateZoneIndexState() {268 for (const container of elements.legend.querySelectorAll(".feature-index")) {269 container.classList.toggle("has-highlights", state.highlightedZones.size > 0);270 }271 for (const button of elements.legend.querySelectorAll("[data-zone]")) {272 const zoneID = Number(button.dataset.zone);273 const highlighted = state.highlightedZones.has(zoneID);274 button.classList.toggle("is-highlighted", highlighted);275 button.classList.toggle("is-current", state.focusedZoneID === zoneID);276 button.setAttribute("aria-pressed", String(highlighted));277 }278}279280export function projectZoneGeometry(rawGeometry) {281 if (!rawGeometry?.coordinates) return null;282 const point = ([longitude, latitude]) => project(latitude, longitude);283 if (rawGeometry.type === "Polygon") {284 return new Polygon(rawGeometry.coordinates.map((ring) => ring.map(point)));285 }286 if (rawGeometry.type === "MultiPolygon") {287 return new MultiPolygon(288 rawGeometry.coordinates.map((polygon) => polygon.map((ring) => ring.map(point))),289 );290 }291 // A path zone is a line and a weight: the geometry stays the line, and the292 // zone's declared stroke width says how wide it is drawn and clicked.293 if (rawGeometry.type === "MultiLineString") {294 return new MultiLineString(rawGeometry.coordinates.map((line) => line.map(point)));295 }296 return null;297}298299// A pin arrives as a latitude and longitude in the source tile grid, which is300// not the same grid for every volume: most worlds are cut from a 32-tile square at301// zoom 13, and a map cut from a window of its own carries where that window302// sits. Both describe the same world square, so everything downstream of this303// -- the view, the overview, the geohash grid -- works in units either way.304export function project(latitude, longitude) {305 const grid = mapTileGrid();306 const worldTiles = 2 ** grid.sourceZoom;307 const xTile = ((longitude + 180) / 360) * worldTiles;308 const latitudeRadians = latitude * Math.PI / 180;309 const yTile = (1 - Math.asinh(Math.tan(latitudeRadians)) / Math.PI) / 2 * worldTiles;310 return [311 (xTile - grid.firstTile) * grid.tileSize,312 -(yTile - grid.firstTile) * grid.tileSize,313 ];314}315316export function mapTileGrid() {317 const shared = state.volume.tileGrid;318 const own = state.world?.grid;319 return own ? { ...shared, ...own } : shared;320}