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.