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