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 / navigation.js
navigation.js 17.7K
1import { intersects } from "ol/extent.js";2import XYZ from "ol/source/XYZ.js";3import TileGrid from "ol/tilegrid/TileGrid.js";45import { state } from "./state.js";6import { visiblePins, visibleZones } from "./visibility.js";7import { elements, populateSelect } from "./dom.js";8import { overzoomLevels } from "./constants.js";9import { loadWorld } from "./catalog.js";10import { refreshCatalog } from "./library.js";11import { readSession, saveSession, writeRoute } from "./session.js";12import { createView, initializeMap, resolutions } from "./engine.js";13import { renderOverview, setOverviewDocked } from "./overview.js";14import { setDockFolded } from "./search.js";15import { renderGrid } from "./grid.js";16import { syncGlobe } from "./globe.js";17import { renderShapes } from "./areas.js";18import { renderLegend } from "./legend.js";19import {20 applyPinFilters,21 buildFeatures,22 refreshPrioritySource,23 setHoveredPin,24} from "./features.js";25import { closeDetail } from "./detail.js";26import { iconOutsetColor } from "./theme.js";27import { clamp, formatNumber } from "./util.js";2829export async function selectVolume(slug, worldSlug) {30 state.volume = state.catalog.volumes.find((volume) => volume.slug === slug) || state.catalog.volumes[0];31 // The engine exists once there is a volume to size its world square from: every volume32 // carries its own tile grid now that each arrives in its own bundle.33 if (!state.engine) initializeMap();34 // Only an explicitly named world overrides what this volume remembers.35 if (!worldSlug && !state.restore) state.restore = readSession(state.volume.slug);36 elements.volume.value = state.volume.slug;37 populateSelect(elements.world, state.volume.worlds, "title");38 elements.world.disabled = state.volume.worlds.length === 1;39 elements.world.title = elements.world.disabled ? "This volume has one world" : "Choose a world";40 // Returning to a volume returns to where it was left, so wandering off to41 // another volume and back does not cost the reader their place.42 const remembered = state.restore?.volume === state.volume.slug ? state.restore : null;43 const wanted = (worldSlug && state.volume.worlds.find((item) => item.slug === worldSlug)) ||44 (remembered && state.volume.worlds.find((item) => item.slug === remembered.world));45 await selectWorld((wanted || state.volume.worlds[0]).slug);46}4748export async function selectWorld(slug) {49 const entry = state.volume.worlds.find((world) => world.slug === slug) || state.volume.worlds[0];50 // A world opened while another is still arriving must not be overtaken by it.51 const run = ++state.worldRun;52 elements.loading.hidden = false;53 let loaded;54 try {55 loaded = await loadWorld(entry);56 } catch (error) {57 // The bundle moved underneath the catalog: refetching the catalog brings58 // the new build's URLs, and the refresh re-selects this world through them.59 // The error is spoken either way -- a coding fault in the load path would60 // otherwise wear the same silence as a stale stamp, forever.61 console.error(`world ${entry.slug} failed to load`, error);62 if (state.worldRun === run) {63 elements.loading.hidden = true;64 void refreshCatalog();65 }66 return;67 }68 if (state.worldRun !== run) return;6970 state.settling = true;71 state.world = loaded;72 document.documentElement.style.setProperty("--icon-outset-color", iconOutsetColor());73 elements.world.value = state.world.slug;74 populateSelect(elements.lens, state.world.lenses, "name");75 elements.lensField.hidden = state.world.lenses.length < 2;76 state.styleCache.clear();77 setHoveredPin(null);78 state.gridCell = "";79 state.gridSystem = "geohash";80 state.hiddenCollections.clear();81 state.labelOverrides.clear();82 // Zones are a navigation aid, not the primary filter surface: keep boundaries83 // drawn but fold their section away so pin groups stay above the fold. The84 // ungrouped shape collections that section holds start unfolded, so their85 // feature indexes are there the moment the section is opened -- and in the86 // DOM for anything that reaches for a zone row without unfolding first.87 state.collapsedSections.clear();88 state.collapsedSections.add("zones");89 state.expandedCollections.clear();90 for (const collection of state.world.collections) {91 if (collection.kind !== "point" && !collection.group) {92 state.expandedCollections.add(collection.id);93 }94 if (!collection.visible) state.hiddenCollections.add(collection.id);95 }96 const restore = state.restore?.world === state.world.slug ? state.restore : null;97 if (restore) {98 state.hiddenCollections = new Set(restore.hidden);99 state.collapsedSections = new Set(restore.collapsed);100 state.expandedCollections = new Set(restore.expanded || []);101 state.labelOverrides = new Map(restore.labels || []);102 }103 // Where the corner of the screen is wanted is a preference about the volume104 // rather than about one of its maps, so it carries across them.105 setOverviewDocked(Boolean(state.restore?.overviewDocked), false);106 // A map opens on the map: the panel is away until a pin or a search gives it107 // something to hold. A volume that remembers otherwise is a reader who has108 // already said where they want it.109 setDockFolded(state.restore ? Boolean(state.restore.dockFolded) : true, false);110 state.dockDismissed = Boolean(state.restore?.dockDismissed);111 state.search = "";112 elements.search.value = "";113 elements.dock.hidden = false;114 // A way back into the map being left behind is no way back at all.115 releaseCameraReturn();116 closeDetail();117 renderLegend();118 renderShapes();119 buildFeatures();120 selectLens(restore ? clamp(restore.lens, 0, state.world.lenses.length - 1) : 0, true);121 // A map that declares itself a sphere offers the globe; every map opens122 // on the chart either way.123 syncGlobe();124 // The remembered arrangement has been spent. Kept, it would be read again by125 // the next thing that asks: a lens swap would drop the reader back where126 // the session opened, and switching to another volume would hand that volume the127 // dock and overview this one was left with instead of its own -- selectVolume128 // only reads a volume's session when nothing is held here.129 state.restore = null;130 elements.sidebar.classList.remove("is-open");131 elements.mobileLegend.setAttribute("aria-expanded", "false");132 writeRoute();133 state.settling = false;134 saveSession();135}136137export function selectLens(index, resetView = false) {138 const priorLens = state.lens;139 state.lens = state.world.lenses[index] || state.world.lenses[0];140 const lens = state.lens;141 const carried = resetView ? null : carryViewAcrossShards(priorLens, lens);142 const maxViewZoom = viewMaxZoom(lens);143 elements.lens.value = String(state.world.lenses.indexOf(state.lens));144 if (resetView) state.engine.setView(createView(maxViewZoom, activeExtent()));145 else {146 // The view is rebuilt around the new lens's own window -- shards do147 // not share one -- but keeps the place the reader was carried to.148 const previous = state.engine.getView();149 const held = createView(maxViewZoom, activeExtent());150 held.setCenter(previous.getCenter());151 held.setZoom(previous.getZoom() || 0);152 state.engine.setView(held);153 }154155 const tileRun = ++state.tileRun;156 let pending = 0;157 state.tileStats = { requested: 0, loaded: 0, errors: 0, peakPending: 0 };158 elements.loading.hidden = false;159 const trackLoading = (source) => {160 source.on("tileloadstart", () => {161 if (state.tileRun !== tileRun) return;162 pending++;163 state.tileStats.requested++;164 state.tileStats.peakPending = Math.max(state.tileStats.peakPending, pending);165 });166 const tileFinished = (failed) => {167 if (state.tileRun !== tileRun) return;168 pending = Math.max(0, pending - 1);169 if (failed) state.tileStats.errors++;170 else state.tileStats.loaded++;171 if (pending === 0 && state.tileStats.loaded > 0) elements.loading.hidden = true;172 };173 source.on("tileloadend", () => tileFinished(false));174 source.on("tileloaderror", () => tileFinished(true));175 return source;176 };177178 const buildSource = (maxLevel, wanted) => trackLoading(new XYZ({179 projection: state.projection,180 tileGrid: new TileGrid({181 extent: [0, -state.volume.tileGrid.size, state.volume.tileGrid.size, 0],182 origin: [0, 0],183 // Keep the source grid native. Views may overzoom this last resolution,184 // but no nonexistent tile level is requested.185 resolutions: resolutions(maxLevel),186 tileSize: state.volume.tileGrid.tileSize,187 }),188 cacheSize: 64,189 interpolate: lens.interpolate,190 transition: 0,191 wrapX: false,192 tileUrlFunction: ([zoom, x, y]) => {193 const format = lens.formats[zoom];194 if (!format || x < 0 || y < 0 || !wanted(zoom, x, y)) return undefined;195 return `${state.volume.base}/tiles/${encodeURIComponent(lens.tiles)}/${zoom}/${x}/${y}.${format}`;196 },197 }));198199 const fullZoom = lens.fullZoom ?? lens.maxZoom;200 const base = buildSource(fullZoom, (zoom, x, y) => tileExists(lens, zoom, x, y));201 const previous = state.layers.raster.getSource();202 state.layers.raster.setSource(base);203 state.layers.raster.setExtent(activeExtent());204 // Tiles matching the map's background were never written; painting that205 // colour behind the layer makes their absence invisible.206 state.layers.raster.setBackground(lens.background || undefined);207 previous?.clear();208209 const previousDetail = state.layers.rasterDetail.getSource();210 if (lens.maxZoom > fullZoom) {211 state.layers.rasterDetail.setSource(buildSource(212 lens.maxZoom,213 (zoom, x, y) => zoom > fullZoom && tileExists(lens, zoom, x, y),214 ));215 state.layers.rasterDetail.setExtent(activeExtent());216 state.layers.rasterDetail.setVisible(true);217 } else {218 state.layers.rasterDetail.setVisible(false);219 }220 previousDetail?.clear();221 renderGrid();222 if (state.gridEnabled) {223 refreshPrioritySource();224 state.layers.pins.changed();225 state.layers.priority.changed();226 }227 state.overviewKey = "";228 renderOverview();229 // Only a world being opened has a view to restore. A lens is a different230 // picture of the ground the reader is already looking at -- spring for231 // summer, one layer of a split map for another -- so the swap is made232 // underneath them and everything else, the view included, stays as it is.233 const resume = resetView && state.restore?.world === state.world.slug ? state.restore : null;234 // Shapes and pins are built before a lens is chosen, so on a split world the235 // first render shows every layer at once. Comparing against what was actually236 // rendered catches that as well as a later switch between layers.237 if (state.renderedShard !== (lens.shard || 0)) {238 renderShapes();239 applyPinFilters();240 }241 if (resume?.center && Number.isFinite(resume.zoom)) {242 // Resuming: land exactly where the reader left the view, rather than243 // fitting the map and losing where they were.244 const view = state.engine.getView();245 view.setCenter(resume.center);246 view.setZoom(resume.zoom);247 state.fitZoom = resume.zoom;248 updateVisibleCount();249 } else if (resetView) {250 requestAnimationFrame(fitMap);251 } else if (carried) {252 state.engine.getView().animate({ center: carried, duration: 200 });253 updateVisibleCount();254 } else {255 updateVisibleCount();256 }257 saveSession();258}259260// Levels are sparse: background tiles are never written, and levels above261// fullZoom exist only where the capture reached. Asking for a tile that was262// never emitted would be a wasted request and a 404, so consult the coverage263// bitset first and let OpenLayers show the parent tile instead.264export function tileExists(lens, zoom, x, y) {265 const coverage = lens.coverage?.[zoom];266 if (!coverage) return true;267 const column = x - coverage.x;268 const row = y - coverage.y;269 if (column < 0 || row < 0 || column >= coverage.w || row >= coverage.h) return false;270 const bits = coverageBits(lens, zoom, coverage);271 const index = row * coverage.w + column;272 return (bits[index >> 3] & (1 << (index & 7))) !== 0;273}274275export function coverageBits(lens, zoom, coverage) {276 if (!coverage.decoded) {277 const binary = atob(coverage.bits);278 const bytes = new Uint8Array(binary.length);279 for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);280 coverage.decoded = bytes;281 }282 return coverage.decoded;283}284285export function viewMaxZoom(lens) {286 return lens.maxZoom + overzoomLevels;287}288289// What a view owes the rest of the page once it has come to rest.290export function settleView() {291 updateVisibleCount();292 saveSession();293}294295// The layers of a split map are the same ground at different heights, stacked296// down one sheet. Switching between them should leave the reader over the same297// place, so the view is carried across by its position within each layer's box298// rather than by its absolute coordinates.299export function carryViewAcrossShards(previous, next) {300 if (!previous || !next || !previous.shard || !next.shard) return null;301 if (previous.shard === next.shard || !previous.bounds || !next.bounds) return null;302 const view = state.engine?.getView();303 const centre = view?.getCenter();304 if (!centre) return null;305 const across = (centre[0] - previous.bounds.x) / previous.bounds.width;306 const down = (-centre[1] - previous.bounds.y) / previous.bounds.height;307 return [308 next.bounds.x + clamp(across, 0, 1) * next.bounds.width,309 -(next.bounds.y + clamp(down, 0, 1) * next.bounds.height),310 ];311}312313export function toggleSidebar() {314 const shell = document.querySelector(".app-shell");315 const collapsed = shell.classList.toggle("sidebar-collapsed");316 elements.mobileLegend.setAttribute("aria-expanded", String(!collapsed));317 // The map keeps its own idea of how big it is.318 requestAnimationFrame(() => state.engine?.updateSize());319}320321export function activeExtent() {322 const size = state.volume.tileGrid.size;323 const bounds = state.lens?.bounds || { x: 0, y: 0, width: size, height: size };324 return [bounds.x, -(bounds.y + bounds.height), bounds.x + bounds.width, -bounds.y];325}326327export function fitMap() {328 if (!state.engine || !state.lens) return;329 releaseCameraReturn();330 const view = state.engine.getView();331 view.fit(activeExtent(), {332 size: state.engine.getSize(),333 padding: [36, 36, 36, 36],334 maxZoom: state.lens.maxZoom,335 duration: 0,336 });337 state.fitZoom = view.getZoom() || 0;338 updateVisibleCount();339}340341export function changeZoom(delta) {342 releaseCameraReturn();343 const view = state.engine.getView();344 const current = view.getZoom() || 0;345 view.animate({346 zoom: clamp(current + delta, state.lens.minZoom, viewMaxZoom(state.lens)),347 duration: 140,348 });349}350351// The footer counts features of every kind: points by their coordinate, shape352// features by whether their ground reaches into the view. It used to read353// "N features enabled ยท M in view", which asked the reader to hold two words354// for two different sets and told them neither: "enabled" was the legend's355// ledger, "in view" the window over it, and the pair read as a distinction356// without a difference. It is one set counted twice now -- the features the357// map is drawing, and how many of them the window is over -- said as one358// sentence, and the number after "of" is the same number the dock puts at the359// top of its list.360export function updateVisibleCount() {361 const zoom = state.engine?.getView().getZoom();362 elements.viewport.dataset.zoom = Number.isFinite(zoom) ? zoom.toFixed(3) : "";363 if (!state.engine || !state.world) {364 elements.visibleCount.textContent = "No features shown";365 return;366 }367 const extent = state.engine.getView().calculateExtent(state.engine.getSize());368 const pins = visiblePins();369 const zones = visibleZones();370 const shown = pins.length + zones.length;371 if (!shown) {372 elements.visibleCount.textContent = "No features shown";373 return;374 }375 let inView = 0;376 for (const pin of pins) {377 const [x, y] = pin.coordinate;378 if (x >= extent[0] && x <= extent[2] && y >= extent[1] && y <= extent[3]) inView++;379 }380 for (const record of zones) {381 if (intersects(record.extent, extent)) inView++;382 }383 elements.visibleCount.textContent = `${formatNumber(inView)} of ` +384 `${formatNumber(shown)} ${shown === 1 ? "feature" : "features"} in view`;385}386387// A jump made from a panel is a detour. The reader asked to look at one388// feature, not to give up the view they were reading it against -- and until389// now the only way back from a row in the list was to find their place again390// by hand. The camera the jump left is held for as long as the card is open,391// and closing the card hands it back.392//393// Each jump holds afresh, overwriting whatever the last one left: closing a394// card undoes that card's own move and nothing older. Reading three rows in395// turn steps back through them one view at a time, and none of the three can396// hand back a place from before the reader went looking. Anything that steers397// the map -- a drag, the wheel, the zoom buttons, the overview, a cell -- lets398// the hold go entirely, because after that the view they are in is the view399// they chose, and returning from it would be the map arguing with them.400export function holdCameraReturn() {401 if (!state.engine) return;402 const view = state.engine.getView();403 const center = view.getCenter();404 const zoom = view.getZoom();405 if (!center || !Number.isFinite(zoom)) return;406 state.cameraReturn = {407 center: center.slice(),408 zoom,409 world: state.world,410 lens: state.lens,411 };412}413414export function releaseCameraReturn() {415 state.cameraReturn = null;416}417418export function returnCamera() {419 const held = state.cameraReturn;420 state.cameraReturn = null;421 if (!held || !state.engine) return;422 // A view belonging to another map, or to another layer of this one, is not423 // this camera's to give back.424 if (held.world !== state.world || held.lens !== state.lens) return;425 state.engine.getView().animate({426 center: held.center,427 zoom: held.zoom,428 duration: 220,429 });430}