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 / overview.js
overview.js 7.3K
1import { overviewTargetSize } from "./constants.js";2import { elements } from "./dom.js";3import { aimGlobe, globeCamera } from "./globe.js";4import { activeExtent, releaseCameraReturn, settleView, tileExists } from "./navigation.js";5import { equirectMapping } from "./semconv.js";6import { saveSession } from "./session.js";7import { state } from "./state.js";8import { clamp } from "./util.js";910export function renderOverview() {11 const lens = state.lens;12 const canvas = elements.overviewCanvas;13 const context = canvas.getContext("2d");14 const extent = activeExtent();15 const world = state.volume.tileGrid.size;16 const tileSize = state.volume.tileGrid.tileSize;17 const width = extent[2] - extent[0];18 const height = extent[3] - extent[1];1920 let level = 0;21 while (level < lens.fullZoom &&22 (Math.max(width, height) / world) * tileSize * (2 ** level) < overviewTargetSize) {23 level++;24 }25 const scale = (tileSize * (2 ** level)) / world;26 canvas.width = Math.max(1, Math.round(width * scale));27 canvas.height = Math.max(1, Math.round(height * scale));28 context.clearRect(0, 0, canvas.width, canvas.height);29 if (lens.background) {30 context.fillStyle = lens.background;31 context.fillRect(0, 0, canvas.width, canvas.height);32 }3334 const run = ++state.overviewRun;35 const format = lens.formats[level];36 const first = Math.floor((extent[0] / world) * (2 ** level));37 const last = Math.ceil((extent[2] / world) * (2 ** level));38 const top = Math.floor((-extent[3] / world) * (2 ** level));39 const bottom = Math.ceil((-extent[1] / world) * (2 ** level));40 for (let y = top; y < bottom; y++) {41 for (let x = first; x < last; x++) {42 if (!format || !tileExists(lens, level, x, y)) continue;43 const image = new Image();44 image.onload = () => {45 if (state.overviewRun !== run) return;46 context.drawImage(47 image,48 x * tileSize - extent[0] * scale,49 y * tileSize + extent[3] * scale,50 tileSize,51 tileSize,52 );53 };54 image.src = `${state.volume.base}/tiles/${encodeURIComponent(lens.tiles)}/${level}/${x}/${y}.${format}`;55 }56 }57 updateOverviewViewport();58}5960// Hidden while the whole map is on screen: a locator that says "all of it"61// tells the reader nothing they cannot already see.62export function updateOverviewViewport() {63 // On the globe half the planet is always out of sight, so the locator is64 // always worth having: a reticle at the point the camera faces, on the65 // same flat chart the corner has always drawn.66 const camera = globeCamera();67 if (camera) {68 const mapping = equirectMapping(state.world);69 if (!mapping || !state.lens) return;70 const extent = activeExtent();71 const [worldX, worldY] = mapping.toWorld(camera.lat, camera.lng);72 const canvas = elements.overviewCanvas;73 const across = clamp((worldX - extent[0]) / (extent[2] - extent[0]), 0, 1);74 const down = clamp((worldY + extent[3]) / (extent[3] - extent[1]), 0, 1);75 state.overviewKey = "";76 elements.overviewShelf.hidden = false;77 const box = elements.overviewViewport.style;78 const size = 22;79 box.left = `${across * canvas.width - size / 2}px`;80 box.top = `${down * canvas.height - size / 2}px`;81 box.width = `${size}px`;82 box.height = `${size}px`;83 return;84 }85 const view = state.engine?.getView();86 if (!view || !state.lens) return;87 const extent = activeExtent();88 const visible = view.calculateExtent(state.engine.getSize());89 const width = extent[2] - extent[0];90 const height = extent[3] - extent[1];91 // Fitting the map lands on its extent to within a fraction of a pixel, so an92 // exact comparison reports the whole map as not quite visible and leaves a93 // locator on screen that marks the entire map.94 const slack = (view.getResolution() || 0) * 4;95 const covered =96 visible[0] <= extent[0] + slack && visible[1] <= extent[1] + slack &&97 visible[2] >= extent[2] - slack && visible[3] >= extent[3] - slack;98 const key = covered ? "hidden" : visible.map(Math.round).join(",");99 if (key === state.overviewKey) return;100 state.overviewKey = key;101 // The handle goes with it: there is nothing to send for while the whole map102 // is already on screen.103 elements.overviewShelf.hidden = covered;104 if (covered) return;105106 const canvas = elements.overviewCanvas;107 const left = clamp((visible[0] - extent[0]) / width, 0, 1);108 const right = clamp((visible[2] - extent[0]) / width, 0, 1);109 const upper = clamp((extent[3] - visible[3]) / height, 0, 1);110 const lower = clamp((extent[3] - visible[1]) / height, 0, 1);111 const box = elements.overviewViewport.style;112 box.left = `${left * canvas.width}px`;113 box.top = `${upper * canvas.height}px`;114 // A floor keeps the rectangle findable when the view is deep enough that a115 // true-to-scale box would be a couple of pixels.116 box.width = `${Math.max(8, (right - left) * canvas.width)}px`;117 box.height = `${Math.max(8, (lower - upper) * canvas.height)}px`;118}119120// Put away rather than switched off: the overview keeps drawing, and the handle121// it leaves behind is the whole of the way back. A reader who wants the corner122// of their map usually wants it for that map's whole session, so the choice is123// remembered with the rest of what a volume is left in.124export function setOverviewDocked(docked, remember = true) {125 state.overviewDocked = docked;126 // A sweep in progress ends where it is rather than following the panel out.127 if (docked && state.overviewPointer !== null) {128 state.overviewPointer = null;129 settleView();130 }131 elements.overviewShelf.classList.toggle("is-docked", docked);132 elements.overviewDock.setAttribute("aria-expanded", String(!docked));133 const label = docked ? "Bring the overview back" : "Put the overview away";134 elements.overviewDock.setAttribute("aria-label", label);135 elements.overviewDock.title = label;136 if (remember) saveSession();137}138139// The press that opens a gesture eases across, so a plain click reads as a140// move rather than a cut. Everything after it lands at once: a view that eased141// its way to each new position would trail behind the hand steering it.142export function overviewNavigate(event, ease) {143 // Steering from the corner is steering: whatever view a panel's jump was144 // holding is the reader's to give up here as much as on the map itself.145 releaseCameraReturn();146 const rect = elements.overviewCanvas.getBoundingClientRect();147 const extent = activeExtent();148 const fraction = (value, low, high) => clamp((value - low) / (high - low), 0, 1);149 // The same press steers whichever pane is showing: the chart's window, or150 // the globe turned to face the point under the finger.151 if (state.globeActive) {152 const mapping = equirectMapping(state.world);153 if (!mapping) return;154 const worldX = extent[0] + fraction(event.clientX, rect.left, rect.right) * (extent[2] - extent[0]);155 const worldY = -extent[3] + fraction(event.clientY, rect.top, rect.bottom) * (extent[3] - extent[1]);156 const [lat, lng] = mapping.toLatLng(worldX, worldY);157 aimGlobe(lat, lng, ease);158 return;159 }160 const view = state.engine?.getView();161 if (!view || !state.lens) return;162 const center = [163 extent[0] + fraction(event.clientX, rect.left, rect.right) * (extent[2] - extent[0]),164 extent[3] - fraction(event.clientY, rect.top, rect.bottom) * (extent[3] - extent[1]),165 ];166 if (ease) {167 view.animate({ center, duration: 180 });168 return;169 }170 view.cancelAnimations();171 view.setCenter(center);172}