PageSourceSearch

https://hypercities.com/js/map-view.js?v=map-focus-layout-3

js hypercities.com collected 2026-10-03 21:38:55 UTC 23,476 bytes, 502 lines download raw bytes

1import { asPolygon, containsCoordinate, tileDiagnostic, tileTemplate } from "./data.js?v=live-map-csv-1";
2
3// HyperCities book-cover red, with a quieter shade for unselected extents.
4const RED = [166, 42, 38];
5const RED_BRIGHT = [218, 56, 51];
6const ESRI_WORLD_IMAGERY = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}";
7const GOOGLE_ROAD_MAP = "https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}";
8const NOTE_ICON = `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="40" height="48" viewBox="0 0 40 48"><path d="M20 2c-10 0-18 8-18 18 0 13 18 26 18 26s18-13 18-26C38 10 30 2 20 2Z" fill="#da3833" stroke="#f5eee4" stroke-width="2"/><path d="M12 15h16M12 20h16M12 25h11" stroke="#fffaf0" stroke-width="2" stroke-linecap="round"/></svg>')}`;
9const NOTE_ICON_ACTIVE = `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="40" height="48" viewBox="0 0 40 48"><path d="M20 2c-10 0-18 8-18 18 0 13 18 26 18 26s18-13 18-26C38 10 30 2 20 2Z" fill="#fffaf0" stroke="#da3833" stroke-width="3"/><path d="M12 15h16M12 20h16M12 25h11" stroke="#a62a26" stroke-width="2.4" stroke-linecap="round"/></svg>')}`;
10const NOTE_ICON_PENDING = `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="40" height="48" viewBox="0 0 40 48"><path d="M20 2c-10 0-18 8-18 18 0 13 18 26 18 26s18-13 18-26C38 10 30 2 20 2Z" fill="#111212" stroke="#da3833" stroke-width="2"/><path d="M12 15h16M12 20h16M12 25h11" stroke="#fffaf0" stroke-width="2" stroke-linecap="round"/></svg>')}`;
11
12function grayscale(color, dimming = 0.45) {
13  if (typeof color !== "string") return null;
14  let red; let green; let blue; let alpha = null;
15  const hex = color.match(/^#([0-9a-f]{3,8})$/i);
16  const rgb = color.match(/^rgba?\(([^)]+)\)$/i);
17  if (hex) {
18    const value = hex[1].length <= 4
19      ? [...hex[1]].map((character) => character + character).join("")
20      : hex[1];
21    red = Number.parseInt(value.slice(0, 2), 16);
22    green = Number.parseInt(value.slice(2, 4), 16);
23    blue = Number.parseInt(value.slice(4, 6), 16);
24    alpha = value.length === 8 ? Number.parseInt(value.slice(6, 8), 16) / 255 : null;
25  } else if (rgb) {
26    const values = rgb[1].split(",").map((value) => value.trim());
27    [red, green, blue] = values.slice(0, 3).map(Number);
28    alpha = values[3] === undefined ? null : Number(values[3]);
29  } else return null;
30  if (![red, green, blue].every(Number.isFinite)) return null;
31  const lightness = Math.round((red * 0.2126 + green * 0.7152 + blue * 0.0722) * dimming);
32  return alpha === null ? `rgb(${lightness}, ${lightness}, ${lightness})` : `rgba(${lightness}, ${lightness}, ${lightness}, ${alpha})`;
33}
34
35export class MapView {
36  constructor({ maps, onCore, onDepth, onPreview, onFieldEncounter, onInteraction, onTileStatus, onBasemapChange, onRasterOpacityChange, onAnnotationPlace, onAnnotationSelect, onAnnotationViewMove }) {
37    Object.assign(this, { maps, onCore, onDepth, onPreview, onFieldEncounter, onInteraction, onTileStatus, onBasemapChange, onRasterOpacityChange, onAnnotationPlace, onAnnotationSelect, onAnnotationViewMove, hoverIds: new Set(), annotations: [] });
38    this.rasterLayerId = "historical-raster";
39    this.rasterSourceId = "historical-raster-source";
40    this.satelliteLayerId = "esri-world-imagery";
41    this.satelliteSourceId = "esri-world-imagery-source";
42    this.googleLayerId = "google-road-map";
43    this.googleSourceId = "google-road-map-source";
44    this.basemapMode = "dark";
45    this.rasterOpacity = 1;
46  }
47
48  async init() {
49    // Prepare the final palette before MapLibre can render its first frame.
50    const response = await fetch("https://basemaps.cartocdn.com/gl/dark-matter-nolabels-gl-style/style.json");
51    if (!response.ok) throw new Error(`Basemap style could not load (${response.status}).`);
52    const style = this.neutraliseBasemap(await response.json());
53    this.map = new maplibregl.Map({
54      container: "map",
55      style,
56      center: [10, 27], zoom: 1.7, minZoom: 0, attributionControl: false,
57      // Arrow keys belong to HyperCities: up/down move through the TimeWell
58      // and left/right follow the current conceptual path, never the basemap.
59      keyboard: false
60    });
61    await new Promise((resolve) => this.map.once("load", resolve));
62    this.darkBasemapLayerIds = (this.map.getStyle().layers || []).map((layer) => layer.id);
63    this.installSatelliteBasemap();
64    this.installGoogleBasemap();
65    this.overlay = new deck.MapboxOverlay({ interleaved: true, layers: [] });
66    this.map.addControl(this.overlay);
67    this.map.on("mousemove", (event) => this.handleMove(event.lngLat));
68    this.map.on("click", (event) => { if (!this.annotationMode) this.handleClick(event.lngLat); });
69    // A note is placed on the visible raster, so use the canvas's own click
70    // coordinates while this mode is armed. It remains reliable when a deck.gl
71    // layer is present and avoids treating a map pan as a placement.
72    this.map.getCanvas().addEventListener("click", (event) => {
73      if (!this.annotationMode) return;
74      const bounds = this.map.getCanvas().getBoundingClientRect();
75      this.handleClick(this.map.unproject([event.clientX - bounds.left, event.clientY - bounds.top]));
76    }, true);
77    this.map.on("move", () => this.onAnnotationViewMove?.());
78    this.map.on("zoomstart", (event) => { this.visitorZooming = Boolean(event.originalEvent); });
79    this.map.on("zoomend", () => {
80      if (this.coreActive && this.visitorZooming) this.onInteraction?.("zoom-change");
81      this.visitorZooming = false;
82    });
83    this.map.on("error", (event) => this.handleMapError(event));
84    this.map.on("sourcedata", (event) => {
85      if (event.sourceId === this.rasterSourceId && event.isSourceLoaded && this.activeTile) {
86        this.onTileStatus({ ...this.activeTile, state: "loaded", message: `${this.activeTile.message} Current requested tiles loaded over HTTPS.` });
87      }
88    });
89    this.renderFootprints();
90    this.onBasemapChange?.({ mode: this.basemapMode });
91    this.onRasterOpacityChange?.({ opacity: this.rasterOpacity, hasHistorical: false });
92  }
93
94  installSatelliteBasemap() {
95    if (!this.map.getSource(this.satelliteSourceId)) {
96      this.map.addSource(this.satelliteSourceId, {
97        type: "raster",
98        tiles: [ESRI_WORLD_IMAGERY],
99        tileSize: 256,
100        maxzoom: 19,
101        attribution: "© Esri"
102      });
103    }
104    if (!this.map.getLayer(this.satelliteLayerId)) {
105      this.map.addLayer({
106        id: this.satelliteLayerId,
107        type: "raster",
108        source: this.satelliteSourceId,
109        layout: { visibility: "none" }
110      });
111    }
112  }
113
114  installGoogleBasemap() {
115    if (!this.map.getSource(this.googleSourceId)) {
116      this.map.addSource(this.googleSourceId, {
117        type: "raster",
118        tiles: [GOOGLE_ROAD_MAP],
119        tileSize: 256,
120        maxzoom: 21,
121        attribution: "Map data © Google"
122      });
123    }
124    if (!this.map.getLayer(this.googleLayerId)) {
125      this.map.addLayer({
126        id: this.googleLayerId,
127        type: "raster",
128        source: this.googleSourceId,
129        layout: { visibility: "none" }
130      });
131    }
132  }
133
134  setBasemap(mode) {
135    if (!this.map || !["dark", "satellite", "google"].includes(mode)) return;
136    const satellite = mode === "satellite";
137    const google = mode === "google";
138    this.darkBasemapLayerIds.forEach((id) => {
139      if (!this.map.getLayer(id)) return;
140      this.map.setLayoutProperty(id, "visibility", satellite || google ? "none" : "visible");
141    });
142    if (this.map.getLayer(this.satelliteLayerId)) {
143      this.map.setLayoutProperty(this.satelliteLayerId, "visibility", satellite ? "visible" : "none");
144    }
145    if (this.map.getLayer(this.googleLayerId)) {
146      this.map.setLayoutProperty(this.googleLayerId, "visibility", google ? "visible" : "none");
147    }
148    this.basemapMode = mode;
149    this.onBasemapChange?.({ mode });
150  }
151
152  setRasterOpacity(opacity) {
153    const next = Math.max(0, Math.min(1, Number(opacity)));
154    if (!Number.isFinite(next)) return;
155    this.rasterOpacity = next;
156    if (this.map?.getLayer(this.rasterLayerId)) {
157      this.map.setPaintProperty(this.rasterLayerId, "raster-opacity", next);
158    }
159    this.onRasterOpacityChange?.({ opacity: next, hasHistorical: Boolean(this.map?.getLayer(this.rasterLayerId)) });
160  }
161
162  neutraliseBasemap(style) {
163    const colorProperties = {
164      background: ["background-color"],
165      fill: ["fill-color", "fill-outline-color"],
166      line: ["line-color"],
167      circle: ["circle-color", "circle-stroke-color"],
168      symbol: ["text-color", "icon-color"]
169    };
170    for (const layer of style.layers || []) {
171      const paint = layer.paint ||= {};
172      // Carto defines the most visible country line as a zoom-stop expression,
173      // not a literal color. Handle that pair explicitly so it cannot bypass
174      // the generic colour neutralisation below.
175      if (layer.id === "boundary_country_outline" || layer.id === "boundary_country_inner") {
176        paint["line-color"] = "rgb(48, 48, 48)";
177        paint["line-opacity"] = layer.id === "boundary_country_inner" ? 0.34 : 0.16;
178        continue;
179      }
180      for (const property of colorProperties[layer.type] || []) {
181        // Country and administrative boundary lines are context, never a
182        // competing graphic system. Keep fills unchanged, but dim linework
183        // more strongly than the rest of the neutral basemap.
184        const neutral = grayscale(paint[property], layer.type === "line" ? 0.24 : 0.45);
185        if (neutral) paint[property] = neutral;
186      }
187      if (layer.type === "raster") paint["raster-saturation"] = -1;
188    }
189    return style;
190  }
191
192  handleMove(lngLat) {
193    if (this.coreActive) return;
194    const matches = this.maps.filter((map) => containsCoordinate(map, lngLat));
195    const ids = new Set(matches.map((map) => map.id));
196    const changed = ids.size !== this.hoverIds.size || [...ids].some((id) => !this.hoverIds.has(id));
197    if (changed) {
198      this.hoverIds = ids;
199      this.onDepth(matches, lngLat);
200      this.onFieldEncounter?.(matches, this.map.project(lngLat), lngLat);
201      this.renderFootprints();
202    }
203    this.onPreview(matches, lngLat);
204  }
205
206  handleClick(lngLat) {
207    if (this.coreActive && this.annotationMode) {
208      if (this.selectedMap) this.onAnnotationPlace?.(lngLat);
209      return;
210    }
211    if (!this.coreActive) this.onCore(this.maps.filter((map) => containsCoordinate(map, lngLat)), lngLat);
212  }
213
214  handleMapError(event) {
215    if (event.sourceId === this.rasterSourceId || event.error?.message?.includes(this.rasterSourceId)
215) {
216      this.onTileStatus({ state: "failed", message: "The browser could not load this raster source; the footprint remains available." });
217    }
218  }
219
220  forwardTimewellGesture(event) {
221    const canvas = this.map?.getCanvas();
222    if (!canvas) return;
223    const common = {
224      bubbles: true,
225      cancelable: true,
226      clientX: event.clientX,
227      clientY: event.clientY,
228      screenX: event.screenX,
229      screenY: event.screenY,
230      ctrlKey: event.ctrlKey,
231      shiftKey: event.shiftKey,
232      altKey: event.altKey,
233      metaKey: event.metaKey
234    };
235    if (event.type === "wheel") {
236      canvas.dispatchEvent(new WheelEvent("wheel", {
237        ...common,
238        deltaX: event.deltaX,
239        deltaY: event.deltaY,
240        deltaZ: event.deltaZ,
241        deltaMode: event.deltaMode
242      }));
243      return;
244    }
245    if (event.type === "click") {
246      canvas.dispatchEvent(new MouseEvent("click", common));
247      return;
248    }
249    const pointerInit = {
250      ...common,
251      button: event.button,
252      buttons: event.buttons,
253      pointerId: event.pointerId,
254      pointerType: event.pointerType,
255      isPrimary: event.isPrimary,
256      pressure: event.pressure
257    };
258    canvas.dispatchEvent(new PointerEvent(event.type, pointerInit));
259    // MapLibre's mouse gesture handler remains the reliable route on desktop;
260    // synthetic PointerEvents do not generate the browser's compatibility
261    // MouseEvents by themselves.
262    if (event.pointerType === "mouse") {
263      const mouseType = { pointerdown: "mousedown", pointermove: "mousemove", pointerup: "mouseup", pointercancel: "mouseup" }[event.type];
264      if (mouseType) canvas.dispatchEvent(new MouseEvent(mouseType, pointerInit));
265    }
266  }
267
268  renderFootprints() {
269    const layers = [new deck.PolygonLayer({
270      // Once a raster is selected, only its own footprint remains. The other
271      // archival extents return when the visitor surfaces to the map field.
272      id: "historical-map-extents", data: this.selectedMapId ? [this.selectedMap] : this.maps, pickable: true, stroked: true, filled: false,
273      getPolygon: (map) => asPolygon(map),
274      getLineColor: (map) => map.id === this.timewellHoverId
275          ? [...RED_BRIGHT, 230]
276        : map.id === this.selectedMapId
277          ? [241, 237, 228, 230]
278        : this.coreIds?.has(map.id)
279          ? [...RED_BRIGHT, 135]
280        : this.hoverIds.has(map.id)
281            ? [...RED_BRIGHT, 198]
282            : [...RED, 81],
283      getLineWidth: (map) => map.id === this.timewellHoverId ? 2.2 : map.id === this.selectedMapId ? 2.5 : this.coreIds?.has(map.id) ? 1.4 : 1,
284      lineWidthUnits: "pixels",
285      updateTriggers: { getLineColor: [[...this.hoverIds].join(","), this.selectedMapId, this.timewellHoverId, [...(this.coreIds || [])].join(",")] }
286    })];
287    if (this.coreCoordinate) {
288      layers.push(new deck.ScatterplotLayer({
289        id: "core-origin", data: [this.coreCoordinate], pickable: false, filled: true, stroked: true,
290        getPosition: (point) => [point.lng, point.lat], getRadius: 18, radiusUnits: "pixels",
291        getFillColor: [...RED, 50], getLineColor: [...RED_BRIGHT, 255], getLineWidth: 2,
292        parameters: { depthTest: false }
293      }));
294    }
295    if (this.annotationsVisible && this.annotations.length) {
296      const crowded = this.annotations.length >= 50;
297      const noteSize = crowded ? 18 : this.annotations.length >= 20 ? 21 : 26;
298      const active = this.annotations.find((annotation) => annotation.id === this.activeAnnotationId);
299      layers.push(new deck.IconLayer({
300        id: "map-annotations",
301        data: active ? this.annotations.filter((annotation) => annotation.id !== active.id) : this.annotations,
302        pickable: !this.annotationMode,
303        getPosition: (annotation) => [annotation.context.point.lng, annotation.context.point.lat],
304        getIcon: () => ({ url: NOTE_ICON, width: 40, height: 48, anchorX: 20, anchorY: 46 }),
305        getSize: (annotation) => annotation.id === this.hoverAnnotationId ? noteSize + 5 : noteSize,
306        getColor: () => active ? [255, 255, 255, 175] : [255, 255, 255, 255],
307        sizeUnits: "pixels",
308        onHover: (info) => {
309          const next = info.object?.id || null;
310          if (next === this.hoverAnnotationId) return;
311          this.hoverAnnotationId = next;
312          this.map.getCanvas().style.cursor = next ? "pointer" : "";
313          this.renderFootprints();
314        },
315        onClick: (info) => { if (info.object) this.onAnnotationSelect?.(info.object); },
316        updateTriggers: { getSize: [this.hoverAnnotationId, noteSize], getColor: [this.activeAnnotationId] }
317      }));
318      if (active) {
319        layers.push(new deck.ScatterplotLayer({
320          id: "active-annotation-halo", data: [active], pickable: false, filled: true, stroked: true,
321          getPosition: (annotation) => [annotation.context.point.lng, annotation.context.point.lat],
322          getRadius: noteSize + 15, radiusUnits: "pixels", getFillColor: [8, 9, 9, 95],
323          getLineColor: [255, 250, 240, 245], getLineWidth: 2.5, lineWidthUnits: "pixels"
324        }));
325        layers.push(new deck.IconLayer({
326          id: "active-map-annotation", data: [active], pickable: !this.annotationMode,
327          getPosition: (annotation) => [annotation.context.point.lng, annotation.context.point.lat],
328          getIcon: () => ({ url: NOTE_ICON_ACTIVE, width: 40, height: 48, anchorX: 20, anchorY: 46 }),
329          getSize: noteSize + 14, sizeUnits: "pixels",
330          onClick: (info) => { if (info.object) this.onAnnotationSelect?.(info.object); }
331        }));
332      }
333    }
334    if (this.pendingAnnotationPoint) {
335      layers.push(new deck.IconLayer({
336        id: "pending-map-annotation", data: [this.pendingAnnotationPoint], pickable: false,
337        getPosition: (point) => [point.lng, point.lat],
338        getIcon: () => ({ url: NOTE_ICON_PENDING, width: 40, height: 48, anchorX: 20, anchorY: 46 }),
339        getSize: 32, sizeUnits: "pixels"
340      }));
341    }
342    this.overlay.setProps({ layers });
343  }
344
345  enterCore(lngLat, maps = []) {
346    this.coreActive = true;
347    this.coreCoordinate = lngLat;
348    this.coreIds = new Set(maps.map((map) => map.id));
349    this.hoverIds = new Set();
350    this.map.getContainer().classList.add("field-cored");
351    this.renderFootprints();
352  }
353
354  highlightTimewellMap(map) {
355    const nextId = map?.id || null;
356    if (nextId === this.timewellHoverId) return;
357    this.timewellHoverId = nextId;
358    this.renderFootprints();
359  }
360
361  showRaster(map, { focus = true } = {}) {
362    this.removeRaster();
363    this.selectedMapId = map.id;
364    this.selectedMap = map;
365    this.renderFootprints();
366    const diagnostic = tileDiagnostic(map);
367    this.activeTile = diagnostic;
368    this.onTileStatus(diagnostic);
369    const template = tileTemplate(map);
370    if (!template) {
371      this.onRasterOpacityChange?.({ opacity: this.rasterOpacity, hasHistorical: false });
372      if (focus) this.focusMap(map);
373      return;
374    }
375    try {
376      this.map.addSource(this.rasterSourceId, {
377        type: "raster", tiles: [template], tileSize: 256,
378        minzoom: Math.max(0, map.minZoom), maxzoom: Math.max(map.minZoom || 0, map.maxZoom || 22), bounds: map.bbox
379      });
380      this.map.addLayer({ id: this.rasterLayerId, type: "raster", source: this.rasterSourceId, paint: { "raster-opacity": this.rasterOpacity } });
381      this.onRasterOpacityChange?.({ opacity: this.rasterOpacity, hasHistorical: true });
382      this.onTileStatus({ ...diagnostic, state: "loading", message: `${diagnostic.message} Browser is requesting secure raster tiles.` });
383    } catch (error) {
384      this.onTileStatus({ state: "failed", message: `Raster configuration failed: ${error.message}` });
385    }
386    // A selection remains a geographic movement even if its archival source
387    // fails. Respect the source minimum: below it, valid tiles stay invisible.
388    if (focus) this.focusMap(map);
389  }
390
391  focusMap(map, { point = null } = {}) {
392    const bounds = [[map.bbox[0], map.bbox[1]], [map.bbox[2], map.bbox[3]]];
393    const padding = this.focusPadding();
394    const camera = this.map.cameraForBounds(bounds, { padding, maxZoom: 19 });
395    if (!camera) return;
396    const pointTarget = point && containsCoordinate(map, point) ? [point.lng, point.lat] : null;
397    const target = pointTarget || camera.center;
398    // The footprint is the primary spatial promise of a map selection: always
399    // fit its complete bounds into the unobscured viewport. Raster minzoom is
400    // a tile-service hint and must not zoom past the map's geographic extent.
401    // Leave a little air around the record after fitting, especially for
402    // unusually wide or tall archival sheets.
403    const options = { center: target, zoom: Math.max(0, camera.zoom - 0.12), duration: 620 };
404    if (pointTarget) {
405      options.offset = [(padding.left - padding.right) / 2, (padding.top - padding.bottom) / 2];
406    }
407    this.map.easeTo(options);
408  }
409
410  focusPadding() {
411    const mapRect = this.map.getContainer().getBoundingClientRect();
412    const visibleRect = (selector) => {
413      const element = document.querySelector(selector);
414      if (!element || element.hidden || getComputedStyle(element).display === "none") return null;
415      const rect = element.getBoundingClientRect();
416      return rect.width > 0 && rect.height > 0 ? rect : null;
417    };
418    const overlapsRight = (rect) => rect && rect.right > mapRect.left && rect.left < mapRect.right
419      ? Math.max(0, mapRect.right - Math.max(mapRect.left, rect.left))
420      : 0;
421    const well = visibleRect("#core-renderer");
422    const ground = visibleRect(".ground-control");
423    const encounter = visibleRect(".encounter");
424    const masthead = visibleRect(".masthead");
425    const right = Math.ceil(Math.max(overlapsRight(well), overlapsRight(ground)) + 28);
426    const top = Math.ceil(Math.max(52, masthead ? masthead.bottom - mapRect.top + 18 : 52));
427    // The map footprint can occupy the large open field above the selected-map
428    // card. Reserving that card's vertical band keeps every selected boundary
429    // visible without wasting map area to its left/right footprint.
430    const bottom = encounter && encounter.bottom > mapRect.top && encounter.top < mapRect.bottom
431      ? Math.ceil(Math.max(36, mapRect.bottom - encounter.top + 24))
432      : 48;
433    return { top, right, bottom, left: 32 };
434  }
435
436  removeRaster() {
437    if (!this.map || !this.map.isStyleLoaded()) return;
438    if (this.map.getLayer(this.rasterLayerId)) this.map.removeLayer(this.rasterLayerId);
439    if (this.map.getSource(this.rasterSourceId)) this.map.removeSource(this.rasterSourceId);
440    this.activeTile = null;
441    this.onRasterOpacityChange?.({ opacity: this.rasterOpacity, hasHistorical: false });
442  }
443
444  leaveCore() {
445    this.removeRaster();
446    this.setAnnotationMode(false);
447    this.annotations = [];
448    this.pendingAnnotationPoint = null;
449    this.annotationsVisible = false;
450    this.activeAnnotationId = null;
451    this.coreActive = false;
452    this.coreCoordinate = null;
453    this.coreIds = null;
454    this.selectedMapId = null;
455    this.selectedMap = null;
456    this.timewellHoverId = null;
457    this.map.getContainer().classList.remove("field-cored");
458    this.renderFootprints();
459  }
460
461  setAnnotations(annotations = [], { visible = true, activeId = null } = {}) {
462    this.annotations = Array.isArray(annotations) ? annotations : [];
463    this.annotationsVisible = Boolean(visible);
464    this.activeAnnotationId = activeId || null;
465    this.hoverAnnotationId = null;
466    if (this.map) this.map.getCanvas().style.cursor = "";
467    this.renderFootprints();
468  }
469
470  setAnnotationVisibility(visible) {
471    this.annotationsVisible = Boolean(visible);
472    if (!visible) { this.hoverAnnotationId = null; this.map?.getCanvas().style.setProperty("cursor", ""); }
473    this.renderFootprints();
474  }
475
476  setAnnotationMode(active) {
477    this.annotationMode = Boolean(active);
478    if (active) { this.hoverAnnotationId = null; this.map?.getCanvas().style.setProperty("cursor", ""); }
479    this.map?.getContainer().classList.toggle("annotation-mode", this.annotationMode);
480  }
481
482  setPendingAnnotation(point = null) {
483    this.pendingAnnotationPoint = point;
484    this.renderFootprints();
485  }
486
487  projectPoint(point) {
488    return this.map?.project(point) || null;
489  }
490
491  focusAnnotation(point) {
492    if (!this.map || !point) return;
493    const width = this.map.getContainer().clientWidth;
494    this.map.easeTo({ center: [point.lng, point.lat], offset: [-Math.min(155, width * .16), 0], duration: 520 });
495  }
496
497  getAnnotationCamera() {
498    if (!this.map) return null;
499    const center = this.map.getCenter();
500    return { lng: center.lng, lat: center.lat, zoom: this.map.getZoom(), bearing: this.map.getBearing(), pitch: this.map.getPitch() };
501  }
502}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.