1import OLMap from "ol/Map.js";2import View from "ol/View.js";3import { defaults as defaultControls } from "ol/control/defaults.js";4import { defaults as defaultInteractions } from "ol/interaction/defaults.js";5import TileLayer from "ol/layer/Tile.js";6import VectorLayer from "ol/layer/Vector.js";7import Projection from "ol/proj/Projection.js";8import VectorSource from "ol/source/Vector.js";910import { collectionOf, isCollectionHidden } from "./collections.js";11import { elements } from "./dom.js";12import { state } from "./state.js";13import {14 featureOrder,15 gridStyle,16 pinFeatureStyle,17 pinLabelFeatureStyle,18 priorityFeatureStyle,19 zonePinFeatureStyle,20 zoneScrimStyle,21 zoneStyle,22 zoneTitleDetailStyle,23 zoneTitleStyle,24} from "./styles.js";25import { releaseCameraReturn, settleView } from "./navigation.js";26import { updateOverviewViewport } from "./overview.js";27import { selectGridCell } from "./grid.js";28import { setHoveredPin } from "./features.js";29import { showFeature, showPin } from "./detail.js";3031// A vector layer left to itself stretches the last frame it drew through an32// animation, which leaves markers the wrong size mid-zoom and blank ground33// wherever a pan lands. These are all drawn afresh each frame instead, so a34// view that is being steered shows what it is passing over.35export function eagerVector(options) {36 return new VectorLayer({ updateWhileAnimating: true, ...options });37}3839export function initializeMap() {40 const size = state.volume.tileGrid.size;41 const worldExtent = [0, -size, size, 0];42 state.projection = new Projection({43 code: "ATLAS:PIXELS",44 units: "pixels",45 extent: worldExtent,46 });47 state.sources = {48 grid: new VectorSource({ wrapX: false }),49 gridContext: new VectorSource({ wrapX: false }),50 zoneScrim: new VectorSource({ wrapX: false }),51 zones: new VectorSource({ wrapX: false }),52 zoneTitles: new VectorSource({ wrapX: false }),53 pins: new VectorSource({ wrapX: false }),54 priority: new VectorSource({ wrapX: false }),55 };56 state.layers = {57 raster: new TileLayer({ zIndex: 0 }),58 // Levels above the complete one are only captured in patches. They ride on59 // top of the base layer so the fully-covered pyramid still shows through60 // wherever the deep capture has a gap.61 rasterDetail: new TileLayer({ zIndex: 1 }),62 grid: eagerVector({63 source: state.sources.grid,64 style: gridStyle,65 renderOrder: featureOrder,66 renderBuffer: 40,67 zIndex: 5,68 }),69 // Under the zones and over the map: the dimming is something the map is70 // seen through, not something drawn on top of the zone that was asked for.71 zoneScrim: eagerVector({72 source: state.sources.zoneScrim,73 style: zoneScrimStyle,74 zIndex: 6,75 }),76 zones: eagerVector({77 source: state.sources.zones,78 style: zoneStyle,79 renderBuffer: 32,80 zIndex: 10,81 }),82 zoneTitles: eagerVector({83 source: state.sources.zoneTitles,84 style: zoneTitleStyle,85 renderOrder: featureOrder,86 renderBuffer: 160,87 zIndex: 20,88 }),89 zoneTitleDetail: eagerVector({90 source: state.sources.zoneTitles,91 style: zoneTitleDetailStyle,92 renderOrder: featureOrder,93 renderBuffer: 160,94 zIndex: 44,95 }),96 // Nothing is decluttered. Dropping whatever overlaps makes a crowded area97 // quietly show less than it holds, for text labels as much as for markers;98 // zooming in to separate them is the reader's job.99 pins: eagerVector({100 source: state.sources.pins,101 style: pinFeatureStyle,102 renderOrder: featureOrder,103 renderBuffer: 48,104 zIndex: 40,105 }),106 zonePins: eagerVector({107 source: state.sources.pins,108 style: zonePinFeatureStyle,109 renderOrder: featureOrder,110 renderBuffer: 48,111 zIndex: 42,112 }),113 pinLabels: eagerVector({114 source: state.sources.pins,115 style: pinLabelFeatureStyle,116 renderOrder: featureOrder,117 renderBuffer: 180,118 zIndex: 45,119 }),120 gridContext: eagerVector({121 source: state.sources.gridContext,122 style: gridStyle,123 renderOrder: featureOrder,124 renderBuffer: 40,125 zIndex: 48,126 }),127 priority: eagerVector({128 source: state.sources.priority,129 style: priorityFeatureStyle,130 renderOrder: featureOrder,131 renderBuffer: 220,132 zIndex: 50,133 }),134 };135 state.engine = new OLMap({136 target: elements.viewport,137 layers: Object.values(state.layers),138 controls: defaultControls({139 attribution: false,140 rotate: false,141 zoom: false,142 }),143 interactions: defaultInteractions({144 onFocusOnly: false,145 }),146 view: createView(5),147 });148149 state.engine.on("movestart", () => {150 state.engine.getViewport().classList.add("is-dragging");151 });152 state.engine.on("moveend", () => {153 state.engine.getViewport().classList.remove("is-dragging");154 // A view set straight to a new centre has arrived by the next frame, so a155 // sweep across the overview finishes its journey many times over. Counting156 // the pins in view and writing the session down are worth doing once the157 // hand steering it comes to rest, which is what settleView is for.158 if (state.overviewPointer !== null) return;159 settleView();160 });161 // The locator follows the view as it moves rather than only once it settles.162 // Repeated calls that would draw the same rectangle cost nothing.163 state.engine.on("postrender", updateOverviewViewport);164 // A hand on the map is the reader choosing where they are, which retires165 // whatever view a panel's jump was holding for them: dragging, the wheel,166 // and the double-click that zooms all say the same thing.167 state.engine.on("pointerdrag", releaseCameraReturn);168 state.engine.on("dblclick", releaseCameraReturn);169 // OpenLayers can condition wheel handling on focus when its target is170 // keyboard-focusable. Refocus during the wheel's capture phase so returning171 // from any sidebar control never costs a discarded scroll gesture.172 elements.viewport.addEventListener("wheel", () => {173 releaseCameraReturn();174 if (document.activeElement !== elements.viewport) {175 elements.viewport.focus({ preventScroll: true });176 }177 }, { capture: true, passive: true });178 state.engine.on("singleclick", (event) => {179 if (state.gridEnabled) {180 const cell = state.engine.forEachFeatureAtPixel(181 event.pixel,182 // The cell the reader is already in lies over its own children, and183 // answering for them would mean the grid could not be descended.184 (feature) => {185 const under = feature.get("gridCell");186 return under && under.role !== "leaf" && under.role !== "scope" ? under : null;187 },188 {189 hitTolerance: 1,190 layerFilter: (layer) =>191 layer === state.layers.grid || layer === state.layers.gridContext,192 },193 );194 if (cell) {195 selectGridCell(cell.hash);196 return;197 }198 }199 const pin = state.engine.forEachFeatureAtPixel(200 event.pixel,201 (feature, layer) => (isAnnotationLayer(layer) && feature.get("pin")) || null,202 { hitTolerance: 5, layerFilter: isAnnotationLayer },203 );204 if (pin) {205 showPin(pin);206 return;207 }208 // A click that lands on no marker falls through to the ground itself:209 // the topmost shape feature under the pointer opens the same card its210 // legend row does. A hidden collection's ground is not drawn and so211 // answers no hit-test, but the rule is spelled out here rather than212 // left riding on a renderer detail.213 const zone = state.engine.forEachFeatureAtPixel(214 event.pixel,215 (feature) => {216 const ground = feature.get("zone");217 return ground && !isCollectionHidden(collectionOf(ground)) ? ground : null;218 },219 { layerFilter: (layer) => layer === state.layers.zones },220 );221 if (zone) showFeature(zone);222 });223 state.engine.on("pointermove", (event) => {224 if (event.dragging) {225 setHoveredPin(null);226 return;227 }228 const hit = state.engine.forEachFeatureAtPixel(229 event.pixel,230 (feature, layer) => (isAnnotationLayer(layer) && feature.get("pin")) || null,231 { hitTolerance: 4, layerFilter: isAnnotationLayer },232 );233 setHoveredPin(hit || null);234 const gridHit = state.gridEnabled && state.engine.hasFeatureAtPixel(event.pixel, {235 layerFilter: (layer) =>236 layer === state.layers.grid || layer === state.layers.gridContext,237 });238 state.engine.getTargetElement().style.cursor = hit || gridHit ? "pointer" : "";239 });240 state.engine.getViewport().addEventListener("pointerleave", () => setHoveredPin(null));241}242243// Every layer a pin can be drawn in, including the two it moves to while a zone244// is highlighted. Leaving those out left the pins of the highlighted zone --245// the only ones still on screen -- answering neither the pointer nor a click.246export function isAnnotationLayer(layer) {247 return layer === state.layers.pins || layer === state.layers.zonePins ||248 layer === state.layers.pinLabels || layer === state.layers.priority;249}250251// createView holds the camera to the map rather than to the world square252// it is cut from: a map narrower or shorter than the square -- a wikimap's253// margin, a planet's 2:1 sheet -- centers in the pane when the whole of it254// is on screen, instead of hugging the square's top corner across the dead255// half nothing is drawn in.256export function createView(maxZoom, extent = null) {257 const size = state.volume.tileGrid.size;258 const held = extent || [0, -size, size, 0];259 return new View({260 projection: state.projection,261 center: [(held[0] + held[2]) / 2, (held[1] + held[3]) / 2],262 resolutions: resolutions(maxZoom),263 minZoom: 0,264 maxZoom,265 extent: held,266 constrainResolution: false,267 smoothResolutionConstraint: false,268 showFullExtent: true,269 zoom: 0,270 });271}272273export function resolutions(maxZoom) {274 const base = state.volume.tileGrid.size / state.volume.tileGrid.tileSize;275 return Array.from({ length: maxZoom + 1 }, (_, zoom) => base / (2 ** zoom));276}