1/* 2 * GFlatMap - WebGL Flat Game Map Library 3 * Drop-in replacement for MapLibre GL for flat/grid-based game maps. 4 * Copyright (C) 2026 WwmMap Team 5 * 6 * Features: 7 * - Flat (non-Mercator) tile grid rendering via WebGL 8 * - GeoJSON sources with symbol, circle, line, fill layers 9 * - All markers/shapes rendered in GL context for performance 10 * - HTML Popup and Marker overlays 11 * - Underground / multi-layer map support 12 * - Dirty-flag repaint (only redraws on state change) 13 * - MapLibre-compatible API surface 14 */ 15 16// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 17// §1 CONSTANTS & UTILITIES 18// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 19const TILE_SIZE = 256; 20const WORLD_RANGE = 360; // default world range; overridable per-map via `worldRange` option 21 22function clamp(v, lo, hi) { 23 return v < lo ? lo : v > hi ? hi : v; 24} 25 26function parseColor(css) { 27 if (!css) return [0, 0, 0, 1]; 28 if (css.startsWith("#")) { 29 let hex = css.slice(1); 30 if (hex.length === 3) 31 hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; 32 const n = parseInt(hex, 16); 33 return [(n >> 16) & 255, (n >> 8) & 255, n & 255, 1]; 34 } 35 if (css.startsWith("rgb")) { 36 const m = css.match(/[\d.]+/g); 37 if (m) 38 return [+m[0], +m[1], +m[2], m[3] !== undefined ? +m[3] : 1]; 39 } 40 return [0, 0, 0, 1]; 41} 42 43const _colorCache = Object.create(null); 44function colorToVec4(css) { 45 const hit = _colorCache[css]; 46 if (hit) return hit; 47 const c = parseColor(css); 48 const v = [c[0] / 255, c[1] / 255, c[2] / 255, c[3]]; 49 if (css) _colorCache[css] = v; 50 return v; 51} 52 53function pointInPolygon(px, py, polygon) { 54 let inside = false; 55 for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { 56 const xi = polygon[i][0], 57 yi = polygon[i][1]; 58 const xj = polygon[j][0], 59 yj = polygon[j][1]; 60 if ( 61 yi > py !== yj > py && 62 px < ((xj - xi) * (py - yi)) / (yj - yi) + xi 63 ) { 64 inside = !inside; 65 } 66 } 67 return inside; 68} 69 70/** Simple polygon triangulation via fan from centroid. */ 71function triangulate(coords) { 72 const n = coords.length; 73 if (n < 3) return []; 74 let cx = 0, 75 cy = 0; 76 for (let i = 0; i < n; i++) { 77 cx += coords[i][0]; 78 cy += coords[i][1]; 79 } 80 cx /= n; 81 cy /= n; 82 const out = []; 83 for (let i = 0; i < n; i++) { 84 const j = (i + 1) % n; 85 out.push( 86 cx, 87 cy, 88 coords[i][0], 89 coords[i][1], 90 coords[j][0], 91 coords[j][1], 92 ); 93 } 94 return out; // flat [x,y, x,y, x,y, ...] â each 6 floats = one tri 95} 96 97// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 98// §2 FLAT PROJECTION 99// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 100// Linear flat projection â no Mercator distortion. 101// Configurable world range via `worldRange` map option (default 360). 102// Coordinate (0,0) maps to the center of the tile grid. 103// 104// pixelX = (lng + wr/2) / wr à worldSize 105// pixelY = (wr/2 â lat) / wr à worldSize (y grows downward) 106// worldSize = TILE_SIZE à 2^zoom 107// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 108let _wsTileSize = TILE_SIZE; 109const _wsTable = new Float64Array(32); 110for (let i = 0; i < 32; i++) _wsTable[i] = TILE_SIZE * (1 << i); 111 112function worldSize(zoom) { 113 if (zoom === (zoom | 0) && zoom >= 0 && zoom < 32) 114 return _wsTable[zoom]; 115 return _wsTileSize * Math.pow(2, zoom); 116} 117 118function lngToPixelX(lng, ws, wr) { 119 wr = wr || WORLD_RANGE; 120 return ((lng + wr / 2) / wr) * ws; 121} 122function latToPixelY(lat, ws, wr) { 123 wr = wr || WORLD_RANGE; 124 return ((wr / 2 - lat) / wr) * ws; 125} 126function pixelXToLng(px, ws, wr) { 127 wr = wr || WORLD_RANGE; 128 return (px / ws) * wr - wr / 2; 129} 130function pixelYToLat(py, ws, wr) { 131 wr = wr || WORLD_RANGE; 132 return wr / 2 - (py / ws) * wr; 133} 134 135// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 136// §3 WEBGL HELPERS 137// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
138function compileShader(gl, type, src) { 139 const s = gl.createShader(type); 140 gl.shaderSource(s, src); 141 gl.compileShader(s); 142 if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { 143 console.error("Shader compile error:", gl.getShaderInfoLog(s)); 144 gl.deleteShader(s); 145 return null; 146 } 147 return s; 148} 149 150function createProgram(gl, vsSrc, fsSrc) { 151 const vs = compileShader(gl, gl.VERTEX_SHADER, vsSrc); 152 const fs = compileShader(gl, gl.FRAGMENT_SHADER, fsSrc); 153 const prog = gl.createProgram(); 154 gl.attachShader(prog, vs); 155 gl.attachShader(prog, fs); 156 gl.linkProgram(prog); 157 if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { 158 console.error("Program link error:", gl.getProgramInfoLog(prog)); 159 return null; 160 } 161 return prog; 162} 163 164// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 165// §4 SHADER SOURCES 166// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 167const TEXTURE_VS = ` 168attribute vec2 a_pos; 169attribute vec2 a_uv; 170attribute float a_opacity; 171uniform mat4 u_matrix; 172varying vec2 v_uv; 173varying float v_opacity; 174void main() { 175 gl_Position = u_matrix * vec4(a_pos, 0.0, 1.0); 176 v_uv = a_uv; 177 v_opacity = a_opacity; 178}`; 179 180const TEXTURE_FS = ` 181precision mediump float; 182varying vec2 v_uv; 183varying float v_opacity; 184uniform sampler2D u_texture; 185void main() { 186 vec4 c = texture2D(u_texture, v_uv); 187 gl_FragColor = vec4(c.rgb, c.a * v_opacity); 188}`; 189 190const COLOR_VS = ` 191attribute vec2 a_pos; 192attribute vec4 a_color; 193uniform mat4 u_matrix; 194varying vec4 v_color; 195void main() { 196 gl_Position = u_matrix * vec4(a_pos, 0.0, 1.0); 197 v_color = a_color; 198}`; 199 200const COLOR_FS = ` 201precision mediump float; 202varying vec4 v_color; 203void main() { 204 gl_FragColor = v_color; 205}`; 206 207const CIRCLE_VS = ` 208attribute vec2 a_pos; 209attribute float a_radius; 210attribute vec4 a_color; 211attribute float a_strokeWidth; 212attribute vec4 a_strokeColor; 213uniform mat4 u_matrix; 214uniform float u_pixelRatio; 215varying vec4 v_color; 216varying vec4 v_strokeColor; 217varying float v_radius; 218varying float v_strokeWidth; 219void main() { 220 gl_Position = u_matrix * vec4(a_pos, 0.0, 1.0); 221 gl_PointSize = (a_radius * 2.0 + a_strokeWidth * 2.0) * u_pixelRatio; 222 v_color = a_color; 223 v_strokeColor = a_strokeColor; 224 v_radius = a_radius; 225 v_strokeWidth = a_strokeWidth; 226}`; 227 228const CIRCLE_FS = ` 229precision mediump float; 230varying vec4 v_color; 231varying vec4 v_strokeColor; 232varying float v_radius; 233varying float v_strokeWidth; 234void main() { 235 float total = v_radius + v_strokeWidth; 236 float dist = length(gl_PointCoord - vec2(0.5)) * total * 2.0; 237 if (dist > total) discard; 238 if (dist > v_radius) { 239 float a = smoothstep(total, total - 1.0, dist); 240 gl_FragColor = vec4(v_strokeColor.rgb, v_strokeColor.a * a); 241 } else { 242 float a = smoothstep(v_radius, v_radius - 1.0, dist); 243 gl_FragColor = vec4(v_color.rgb, v_color.a); 244 } 245}`; 246 247// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 248// §5 TEXTURE CACHE (images â WebGL textures) 249// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 250class TextureCache { 251 constructor(gl) { 252 this.gl = gl; 253 this._textures = new Map(); 254 } 255 has(key) { 256 return this._textures.has(key); 257 } 258 get(key) { 259 return this._textures.get(key); 260 } 261 262 upload(key, image) { 263 const gl = this.gl; 264 if (this._textures.has(key)) return this._textures.get(key); 265 const tex = gl.createTexture(); 266 gl.bindTexture(gl.TEXTURE_2D, tex); 267 gl.texParameteri( 268 gl.TEXTURE_2D, 269 gl.TEXTURE_WRAP_S, 270 gl.CLAMP_TO_EDGE, 271 ); 272 gl.texParameteri( 273 gl.TEXTURE_2D, 274 gl.TEXTURE_WRAP_T, 275 gl.CLAMP_TO_EDGE, 276 ); 277 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); 278 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); 279 gl.texImage2D( 280 gl.TEXTURE_2D, 281 0, 282 gl.RGBA, 283 gl.RGBA, 284 gl.UNSIGNED_BYTE, 285 image, 286 ); 287 const entry = { 288 tex,
289 w: image.width || image.naturalWidth || TILE_SIZE, 290 h: image.height || image.naturalHeight || TILE_SIZE, 291 }; 292 this._textures.set(key, entry); 293 return entry; 294 } 295 296 delete(key) { 297 const e = this._textures.get(key); 298 if (e) { 299 this.gl.deleteTexture(e.tex); 300 this._textures.delete(key); 301 } 302 } 303} 304 305// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 306// §6 TILE MANAGER 307// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 308class TileManager { 309 constructor(gl, texCache) { 310 this.gl = gl; 311 this.texCache = texCache; 312 this._loading = new Map(); 313 this._failed = new Set(); 314 this._pendingUploads = []; // decoded bitmaps waiting for GPU upload 315 this._activeLoads = 0; 316 this.maxConcurrent = 6; // max parallel image fetches 317 this._queue = []; // queued URLs waiting to start loading 318 this.maxCacheSize = 512; 319 this.maxUploadsPerFrame = 4; // limit GPU uploads per frame to avoid jank 320 this.onTileLoaded = null; 321 } 322 323 getTile(url) { 324 if (this.texCache.has(url)) return this.texCache.get(url); 325 if (this._loading.has(url) || this._failed.has(url)) return null; 326 this._loading.set(url, true); 327 328 if (this._activeLoads < this.maxConcurrent) { 329 this._startLoad(url); 330 } else { 331 this._queue.push(url); 332 } 333 return null; 334 } 335 336 _startLoad(url) { 337 this._activeLoads++; 338 fetch(url) 339 .then((res) => { 340 if (!res.ok) throw new Error(res.status); 341 return res.blob(); 342 }) 343 .then((blob) => createImageBitmap(blob)) 344 .then((bitmap) => { 345 this._loading.delete(url); 346 this._pendingUploads.push({ url, bitmap }); 347 this._activeLoads--; 348 this._drainQueue(); 349 if (this.onTileLoaded) this.onTileLoaded(); 350 }) 351 .catch(() => { 352 this._loading.delete(url); 353 this._failed.add(url); 354 this._activeLoads--; 355 this._drainQueue(); 356 }); 357 } 358 359 _drainQueue() { 360 while ( 361 this._queue.length > 0 && 362 this._activeLoads < this.maxConcurrent 363 ) { 364 const url = this._queue.shift(); 365 if (this._loading.has(url)) { 366 this._startLoad(url); 367 } else { 368 // was cancelled (e.g. pruned) while queued â skip 369 } 370 } 371 } 372 373 /** Called once per frame before render to upload a limited batch to the GPU */ 374 flushUploads() { 375 const n = Math.min( 376 this._pendingUploads.length, 377 this.maxUploadsPerFrame, 378 ); 379 if (n === 0) return false; 380 for (let i = 0; i < n; i++) { 381 const { url, bitmap } = this._pendingUploads[i]; 382 this.texCache.upload(url, bitmap); 383 bitmap.close(); // free decoded bitmap memory 384 } 385 this._pendingUploads.splice(0, n); 386 return true; // signal that more frames may be needed 387 } 388 389 hasPendingUploads() { 390 return this._pendingUploads.length > 0; 391 } 392 393 prune(keepKeys) { 394 const all = Array.from(this.texCache._textures.keys()).filter( 395 (k) => k.startsWith("http") || k.includes("/"), 396 ); 397 if (all.length <= this.maxCacheSize) return; 398 for (const k of all) { 399 if (!keepKeys.has(k)) { 400 this.texCache.delete(k); 401 if ( 402 this.texCache._textures.size <= 403 this.maxCacheSize * 0.75 404 ) 405 break; 406 } 407 } 408 } 409} 410 411// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 412// §7 GEOJSON SOURCE 413// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 414class GeoJSONSource { 415 constructor(id, options) { 416 this.id = id; 417 this.type = "geojson"; 418 this._data = options.data || { 419 type: "FeatureCollection", 420 features: [], 421 }; 422 this.promoteId = options.promoteId || null; 423 this.sourceType = 424 options.sourceType || options.source || options.kind || null; 425 this._featureStates = new Map(); 426 this._dirty = true; 427 this._dataVersion = 0; 428 this._markerTileIndexCache = null; 429 this._markerIndexMeta = null; 430 } 431 setData(data) { 432 this._data = data; 433 this._dirty = true; 434 this._dataVersion++; 435 this._markerTileIndexCache = null; 436 this._markerIndexMeta = null; 437 } 438 getData() { 439 return this._data; 440 } 441 getFeatures() { 442 const d = this._data; 443 if (!d) return []; 444 if (d.type === "FeatureCollection") return d.features || []; 445 if (d.type === "Feature") return [d]; 446 return []; 447 } 448 449 isMarkerSource() { 450 if (this.sourceType === "marker") return true; 451 return this.id.toLowerCase().includes("marker"); 452 } 453 454 shouldOptimizeMarkers(minCount = 100) { 455 return ( 456 this.isMarkerSource() && this.getFeatures().length > minCount 457 ); 458 } 459 460 _buildMarkerTileIndex(tileZoom, wr) { 461 const features = this.getFeatures(); 462 const numTiles = Math.pow(2, tileZoom); 463 const tiles = new Map(); 464 const others = []; 465 466 for (const f of features) { 467 if (!f.geometry || f.geometry.type !== "Point") { 468 others.push(f); 469 continue; 470 } 471 const [lng, lat] = f.geometry.coordinates; 472 const tx = clamp( 473 Math.floor(((lng + wr / 2) / wr) * numTiles), 474 0, 475 numTiles - 1, 476 ); 477 const ty = clamp( 478 Math.floor(((wr / 2 - lat) / wr) * numTiles), 479 0, 480 numTiles - 1, 481 );
482 const key = tx + ":" + ty; 483 if (!tiles.has(key)) tiles.set(key, []); 484 tiles.get(key).push(f); 485 } 486 487 this._markerTileIndexCache = { tileZoom, wr, tiles, others }; 488 this._markerIndexMeta = { dataVersion: this._dataVersion }; 489 } 490 491 getFeaturesForTileRange(tileZoom, minTX, maxTX, minTY, maxTY, wr) { 492 const needRebuild = 493 !this._markerTileIndexCache || 494 !this._markerIndexMeta || 495 this._markerIndexMeta.dataVersion !== this._dataVersion || 496 this._markerTileIndexCache.tileZoom !== tileZoom || 497 this._markerTileIndexCache.wr !== wr; 498 499 if (needRebuild) this._buildMarkerTileIndex(tileZoom, wr); 500 501 const out = []; 502 const { tiles, others } = this._markerTileIndexCache; 503 for (let ty = minTY; ty <= maxTY; ty++) { 504 for (let tx = minTX; tx <= maxTX; tx++) { 505 const arr = tiles.get(tx + ":" + ty); 506 if (arr && arr.length) out.push(...arr); 507 } 508 } 509 if (others.length) out.push(...others); 510 return out; 511 } 512} 513 514class RasterSource { 515 constructor(id, options) { 516 this.id = id; 517 this.type = "raster"; 518 this.tiles = options.tiles || []; 519 this.tileSize = options.tileSize || TILE_SIZE; 520 this.bounds = options.bounds || null; 521 this.minzoom = options.minzoom ?? 0; 522 this.maxzoom = options.maxzoom ?? 22; 523 this.underground = options.underground ?? 0; 524 this.gridSize = options.gridSize || 0; // flat grid: N tiles per axis (0 = use pyramid) 525 this.gameTileSize = options.gameTileSize || 0; // original game tile pixel size 526 } 527} 528 529class ImageSource { 530 constructor(id, options) { 531 this.id = id; 532 this.type = "image"; 533 this.images = options.images || []; // [{id, visible, url, bounds:[west,south,east,north]}] 534 this.underground = options.underground ?? 0; 535 } 536} 537 538// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 539// §8 LAYER DEFINITIONS 540// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 541class Layer { 542 constructor(def) { 543 this.id = def.id; 544 this.type = def.type; 545 this.source = def.source; 546 this.layout = def.layout || {}; 547 this.paint = def.paint || {}; 548 this.filter = def.filter || null; 549 this.minzoom = def.minzoom ?? 0; 550 this.maxzoom = def.maxzoom ?? 24; 551 this.visible = true; 552 } 553} 554 555// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 556// §9 EVENT EMITTER 557// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 558class EventEmitter { 559 constructor() { 560 this._handlers = {}; 561 } 562 on(type, layerOrCb, cb) { 563 if (typeof layerOrCb === "function") { 564 cb = layerOrCb; 565 layerOrCb = null; 566 } 567 const key = layerOrCb ? `${type}::${layerOrCb}` : type; 568 (this._handlers[key] || (this._handlers[key] = [])).push(cb); 569 } 570 off(type, layerOrCb, cb) { 571 if (typeof layerOrCb === "function") { 572 cb = layerOrCb; 573 layerOrCb = null; 574 } 575 const key = layerOrCb ? `${type}::${layerOrCb}` : type; 576 const arr = this._handlers[key]; 577 if (arr) { 578 const idx = arr.indexOf(cb); 579 if (idx >= 0) arr.splice(idx, 1); 580 } 581 } 582 _fire(type, data, layerIds) { 583 const generic = this._handlers[type]; 584 if (generic) generic.forEach((fn) => fn(data)); 585 if (layerIds) { 586 for (const lid of layerIds) { 587 const arr = this._handlers[`${type}::${lid}`]; 588 if (arr) arr.forEach((fn) => fn(data)); 589 } 590 } 591 } 592} 593 594// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 595// §10 TEXT TEXTURE HELPER 596// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 597const _textCanvas = document.createElement("canvas"); 598const _textCtx = _textCanvas.getContext("2d"); 599 600function renderTextToCanvas(text, fontSize, color, haloColor, haloWidth) { 601 const dpr = window.devicePixelRatio || 1;
602 const size = Math.ceil(fontSize * dpr); 603 _textCtx.font = `${size}px "Segoe UI", Arial, sans-serif`; 604 const metrics = _textCtx.measureText(text); 605 const w = Math.ceil(metrics.width + haloWidth * 2 * dpr + 4); 606 const h = Math.ceil(size * 1.4 + haloWidth * 2 * dpr + 4); 607 _textCanvas.width = w; 608 _textCanvas.height = h; 609 _textCtx.clearRect(0, 0, w, h); 610 _textCtx.font = `${size}px "Segoe UI", Arial, sans-serif`; 611 _textCtx.textBaseline = "middle"; 612 _textCtx.textAlign = "center"; 613 if (haloWidth > 0) { 614 _textCtx.strokeStyle = haloColor || "#ffffff"; 615 _textCtx.lineWidth = haloWidth * 2 * dpr; 616 _textCtx.lineJoin = "round"; 617 _textCtx.strokeText(text, w / 2, h / 2); 618 } 619 _textCtx.fillStyle = color || "#000000"; 620 _textCtx.fillText(text, w / 2, h / 2); 621 return { canvas: _textCanvas, width: w, height: h }; 622} 623 624// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 625// §11 EXPRESSION EVALUATOR (minimal MapLibre expression subset) 626// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 627const _exprKey = Symbol("compiledExpr"); 628 629/** Compile an expression array into a reusable (feature, featureState) => value function. */ 630function compileExpr(expr) { 631 if (expr === undefined || expr === null) { 632 const v = expr; 633 return () => v; 634 } 635 if (typeof expr !== "object" || !Array.isArray(expr)) { 636 const v = expr; 637 return () => v; 638 } 639 640 const cached = expr[_exprKey]; 641 if (cached) return cached; 642 643 let fn; 644 const op = expr[0]; 645 646 switch (op) { 647 case "get": { 648 const key = expr[1]; 649 fn = (f) => (f.properties ? f.properties[key] : undefined); 650 break; 651 } 652 case "has": { 653 const key = expr[1]; 654 fn = (f) => 655 f.properties ? f.properties[key] !== undefined : false; 656 break; 657 } 658 case "to-string": { 659 const inner = compileExpr(expr[1]); 660 fn = (f, fs) => String(inner(f, fs)); 661 break; 662 } 663 case "to-number": { 664 const inner = compileExpr(expr[1]); 665 fn = (f, fs) => Number(inner(f, fs)); 666 break; 667 } 668 case "literal": { 669 const val = expr[1]; 670 fn = () => val; 671 break; 672 } 673 case "boolean": { 674 const subs = []; 675 for (let i = 1; i < expr.length; i++) 676 subs.push(compileExpr(expr[i])); 677 const n = subs.length; 678 fn = (f, fs) => { 679 for (let i = 0; i < n; i++) { 680 const v = subs[i](f, fs); 681 if (typeof v === "boolean") return v; 682 } 683 return false; 684 }; 685 break; 686 } 687 case "case": { 688 const conds = [], 689 results = []; 690 for (let i = 1; i < expr.length - 1; i += 2) { 691 conds.push(compileExpr(expr[i])); 692 results.push(compileExpr(expr[i + 1])); 693 } 694 const fallback = compileExpr(expr[expr.length - 1]); 695 const n = conds.length; 696 fn = (f, fs) => { 697 for (let i = 0; i < n; i++) { 698 if (conds[i](f, fs)) return results[i](f, fs); 699 } 700 return fallback(f, fs); 701 }; 702 break; 703 } 704 case "==": { 705 const left = compileExpr(expr[1]); 706 const right = compileExpr(expr[2]); 707 // Optimise the common ['==', ['get', key], literal] pattern 708 if (Array.isArray(expr[2]) || typeof expr[2] === "object") { 709 fn = (f, fs) => left(f, fs) === right(f, fs); 710 } else { 711 const rVal = expr[2]; 712 fn = (f, fs) => left(f, fs) === rVal; 713 } 714 break; 715 } 716 case "!=": { 717 const left = compileExpr(expr[1]); 718 const right = compileExpr(expr[2]); 719 if (Array.isArray(expr[2]) || typeof expr[2] === "object") { 720 fn = (f, fs) => left(f, fs) !== right(f, fs); 721 } else { 722 const rVal = expr[2]; 723 fn = (f, fs) => left(f, fs) !== rVal; 724 } 725 break; 726 } 727 case "feature-state": {
728 const key = expr[1]; 729 fn = (f, fs) => (fs ? fs[key] : undefined); 730 break; 731 } 732 case "all": { 733 const subs = []; 734 for (let i = 1; i < expr.length; i++) 735 subs.push(compileExpr(expr[i])); 736 const n = subs.length; 737 fn = (f, fs) => { 738 for (let i = 0; i < n; i++) 739 if (!subs[i](f, fs)) return false; 740 return true; 741 }; 742 break; 743 } 744 case "any": { 745 const subs = []; 746 for (let i = 1; i < expr.length; i++) 747 subs.push(compileExpr(expr[i])); 748 const n = subs.length; 749 fn = (f, fs) => { 750 for (let i = 0; i < n; i++) 751 if (subs[i](f, fs)) return true; 752 return false; 753 }; 754 break; 755 } 756 case "$type": 757 fn = (f) => (f.geometry ? f.geometry.type : ""); 758 break; 759 default: { 760 const val = expr; 761 fn = () => val; 762 } 763 } 764 765 expr[_exprKey] = fn; 766 return fn; 767} 768 769function evalProp(propValue, feature, featureState, defaultVal) { 770 if (propValue === undefined) return defaultVal; 771 if (Array.isArray(propValue) && typeof propValue[0] === "string") { 772 let fn = propValue[_exprKey]; 773 if (!fn) fn = compileExpr(propValue); 774 const v = fn(feature, featureState); 775 return v !== undefined ? v : defaultVal; 776 } 777 return propValue; 778} 779let WORLD_SIZE_CACHE = 0; 780// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 781// §12 MAIN MAP CLASS 782// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 783class GFlatMapMap extends EventEmitter { 784 constructor(options) { 785 super(); 786 this._container = 787 typeof options.container === "string" 788 ? document.getElementById(options.container) 789 : options.container; 790 791 // View state 792 this._center = options.center 793 ? [options.center[0], options.center[1]] 794 : [0, 0]; 795 this._zoom = options.zoom ?? 10; 796 this._ws = WORLD_SIZE_CACHE = worldSize(this._zoom); 797 this._minZoom = options.minZoom ?? 0; 798 this._maxZoom = options.maxZoom ?? 22; 799 this._maxBounds = null; 800 this._wr = options.worldRange ?? WORLD_RANGE; 801 this._undergroundLayer = 0; // 0 = surface 802 803 // Sources & Layers 804 this._sources = {}; 805 this._layers = []; 806 this._sourceViewCache = new Map(); 807 this._symbolVertexCache = new Map(); 808 this._circleVertexBuffer = new Float32Array(0); 809 this._lineVertexBuffer = null; 810 this._contextMenuHandler = null; 811 812 // Interaction quality scaling (mobile/touch) 813 this._isTouchDevice = 814 "ontouchstart" in window || 815 (navigator?.maxTouchPoints || 0) > 0; 816 this._isInteracting = false; 817 this._interactionDprScale = options.interactionDprScale ?? 0.75; 818 this._interactionRestoreDelay = 819 options.interactionRestoreDelay ?? 120; 820 this._interactionRestoreTimer = null; 821 822 // Images (icon name â HTMLImageElement/Canvas) 823 this._images = {}; 824 825 // Dirty flag 826 this._dirty = true; 827 this._easeAnim = null; 828 this._loopRunning = false; 829 830 // Create canvas 831 this._canvas = document.createElement("canvas"); 832 this._canvas.style.width = "100%"; 833 this._canvas.style.height = "100%"; 834 this._canvas.style.display = "block"; 835 this._canvas.style.touchAction = "none"; 836 this._container.appendChild(this._canvas); 837 838 // HTML overlay container (popups, markers) 839 this._overlayContainer = document.createElement("div"); 840 this._overlayContainer.style.cssText = 841 "position:absolute;top:0;left:0;width:100%;height:100%;" + 842 "pointer-events:none;overflow:hidden;"; 843 this._container.style.position = "relative"; 844 this._container.appendChild(this._overlayContainer); 845
846 // Background clear colour (used in _render; overridable per-map) 847 this._clearColor = options.clearColor 848 ? colorToVec4(options.clearColor) 849 : [0.12, 0.12, 0.14, 1.0]; 850 851 // Dungeon fog / vignette overlay 852 this._fogEl = document.createElement("div"); 853 this._fogEl.className = "gflatmap-fog-overlay"; 854 this._fogEl.style.cssText = 855 "position:absolute;inset:0;pointer-events:none;" + 856 "opacity:0;transition:opacity 0.4s ease;"; 857 this._container.appendChild(this._fogEl); 858 if (options.fogEffect) this.setFogEffect(true, options.fogColor); 859 860 this._popups = new Set(); 861 this._markers = new Set(); 862 863 // WebGL 864 const glOpts = { 865 alpha: false, 866 antialias: true, 867 premultipliedAlpha: true, 868 preserveDrawingBuffer: false, 869 }; 870 this.gl = 871 this._canvas.getContext("webgl", glOpts) || 872 this._canvas.getContext("experimental-webgl", glOpts); 873 if (!this.gl) { 874 console.error("GFlatMap: WebGL not available"); 875 this.lastError = "WebGL not available"; 876 this.lastErrorCode = "webgl_unavailable"; 877 return; 878 } 879 880 this._texCache = new TextureCache(this.gl); 881 this._tileManager = new TileManager(this.gl, this._texCache); 882 this._tileManager.onTileLoaded = () => this._setDirty(); 883 884 this._setupGL(); 885 this._setupInput(); 886 887 // Resize observer 888 this._resizeObserver = new ResizeObserver(() => 889 this._handleResize(this._resizeAnchor), 890 ); 891 this._resizeObserver.observe(this._container); 892 this._handleResize(); 893 894 // Initial style 895 if (options.style) this._applyStyle(options.style); 896 897 // Control containers 898 this._controlContainers = {}; 899 900 // Render loop 901 this._startLoop(); 902 903 // Fire 'load' next frame 904 requestAnimationFrame(() => { 905 this._fire("load"); 906 this._setDirty(); 907 }); 908 } 909 910 setResizeAnchor(anchor) { 911 if (["top-left", "top-right"].includes(anchor)) { 912 this._resizeAnchor = anchor; 913 } else { 914 console.warn( 915 `Invalid resize anchor "${anchor}", expected "top-left" or "top-right". Defaulting to "top-left".`, 916 ); 917 this._resizeAnchor = "top-left"; 918 } 919 } 920 921 /* ââ GL bootstrap âââââââââââââââââââââââââââââââââââââââââââââââ */ 922 _setupGL() { 923 const gl = this.gl; 924 this._progTex = createProgram(gl, TEXTURE_VS, TEXTURE_FS); 925 this._progColor = createProgram(gl, COLOR_VS, COLOR_FS); 926 this._progCircle = createProgram(gl, CIRCLE_VS, CIRCLE_FS); 927 928 this._texLocs = { 929 a_pos: gl.getAttribLocation(this._progTex, "a_pos"), 930 a_uv: gl.getAttribLocation(this._progTex, "a_uv"), 931 a_opacity: gl.getAttribLocation(this._progTex, "a_opacity"), 932 u_matrix: gl.getUniformLocation(this._progTex, "u_matrix"), 933 u_texture: gl.getUniformLocation(this._progTex, "u_texture"), 934 }; 935 this._colorLocs = { 936 a_pos: gl.getAttribLocation(this._progColor, "a_pos"), 937 a_color: gl.getAttribLocation(this._progColor, "a_color"), 938 u_matrix: gl.getUniformLocation(this._progColor, "u_matrix"), 939 }; 940 this._circleLocs = { 941 a_pos: gl.getAttribLocation(this._progCircle, "a_pos"), 942 a_radius: gl.getAttribLocation(this._progCircle, "a_radius"), 943 a_color: gl.getAttribLocation(this._progCircle, "a_color"), 944 a_strokeWidth: gl.getAttribLocation( 945 this._progCircle, 946 "a_strokeWidth", 947 ), 948 a_strokeColor: gl.getAttribLocation( 949 this._progCircle, 950 "a_strokeColor", 951 ), 952 u_matrix: gl.getUniformLocation(this._progCircle, "u_matrix"), 953 u_pixelRatio: gl.getUniformLocation( 954 this._progCircle, 955 "u_pixelRatio", 956 ), 957 }; 958 959 this._dynBuffer = gl.createBuffer(); 960 this._dynColorBuffer = gl.createBuffer(); 961 this._dynCircleBuffer = gl.createBuffer(); 962 963 gl.enable(gl.BLEND); 964 gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); 965 } 966 967 /* ââ Style parsing ââââââââââââââââââââââââââââââââââââââââââââââ */ 968 _applyStyle(style) { 969 if (!style) return; 970 if (style.sources) { 971 for (const [id, def] of Object.entries(style.sources)) 972 this.addSource(id, def); 973 } 974 if (style.layers) { 975 for (const ld of style.layers) this.addLayer(ld); 976 } 977 } 978 979 /* ââ Resize âââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 980 _handleResize(anchor = "top-left") { 981 const dpr = this._getTargetDpr(); 982 const rect = this._container.getBoundingClientRect(); 983 const w = Math.round(rect.width); 984 const h = Math.round(rect.height); 985 if (this._width === w && this._height === h && this._dpr === dpr) 986 return; 987 988 const oldW = this._width; 989 const oldH = this._height; 990 991 let anchorLng, anchorLat; 992 993 if (oldW && oldH) { 994 const ws = this._ws; 995 const cx = this._lngToPx(this._center[0], ws); 996 const cy = this._latToPy(this._center[1], ws); 997 998 if (anchor === "top-left") { 999 anchorLng = this._pxToLng(cx - oldW / 2, ws); 1000 anchorLat = this._pyToLat(cy - oldH / 2, ws); 1001 } else if (anchor === "top-right") { 1002 anchorLng = this._pxToLng(cx + oldW / 2, ws); 1003 anchorLat = this._pyToLat(cy - oldH / 2, ws); 1004 } 1005 } 1006 1007 this._width = w; 1008 this._height = h; 1009 this._dpr = dpr; 1010 1011 this._canvas.width = Math.round(w * dpr); 1012 this._canvas.height = Math.round(h * dpr); 1013 this._canvas.style.width = w + "px"; 1014 this._canvas.style.height = h + "px"; 1015 1016 this.gl.viewport(0, 0, this._canvas.width, this._canvas.height); 1017 1018 // Re-anchor after resize 1019 if (oldW && oldH && anchorLng !== undefined) { 1020 const ws = this._ws; 1021 const ax = this._lngToPx(anchorLng, ws); 1022 const ay = this._latToPy(anchorLat, ws); 1023 1024 if (anchor === "top-left") { 1025 this._center = [ 1026 this._pxToLng(ax + w / 2, ws), 1027 this._pyToLat(ay + h / 2, ws), 1028 ]; 1029 } else if (anchor === "top-right") { 1030 this._center = [ 1031 this._pxToLng(ax - w / 2, ws), 1032 this._pyToLat(ay + h / 2, ws), 1033 ]; 1034 } 1035 } 1036 1037 this._setDirty(); 1038 this._fire("resize"); 1039 } 1040 1041 _getTargetDpr() { 1042 const dpr = window.devicePixelRatio || 1; 1043 if (!this._isTouchDevice || !this._isInteracting) return dpr; 1044 return Math.max(1, dpr * this._interactionDprScale); 1045 } 1046 1047 _beginInteraction() { 1048 if (!this._isTouchDevice) return; 1049 if (this._interactionRestoreTimer) { 1050 clearTimeout(this._interactionRestoreTimer); 1051 this._interactionRestoreTimer = null; 1052 } 1053 if (this._isInteracting) return; 1054 this._isInteracting = true; 1055 this._handleResize(); 1056 this._setDirty(); 1057 } 1058 1059 _endInteractionSoon() { 1060 if (!this._isTouchDevice) return; 1061 if (this._interactionRestoreTimer) 1062 clearTimeout(this._interactionRestoreTimer); 1063 this._interactionRestoreTimer = setTimeout(() => { 1064 this._interactionRestoreTimer = null; 1065 if (!this._isInteracting) return; 1066 this._isInteracting = false; 1067 this._handleResize(); 1068 this._setDirty(); 1069 }, this._interactionRestoreDelay); 1070 } 1071 resize(anchor) { 1072 this._handleResize(anchor); 1073 } 1074 1075 /* ââ Instance projection (uses per-map worldRange) ââââââââââââââ */ 1076 _lngToPx(lng, ws) { 1077 return lngToPixelX(lng, ws, this._wr); 1078 } 1079 _latToPy(lat, ws) { 1080 return latToPixelY(lat, ws, this._wr); 1081 } 1082 _pxToLng(px, ws) { 1083 return pixelXToLng(px, ws, this._wr); 1084 } 1085 _pyToLat(py, ws) { 1086 return pixelYToLat(py, ws, this._wr); 1087 } 1088 1089 /* ââ Projection âââââââââââââââââââââââââââââââââââââââââââââââââ */ 1090 /** LngLat â screen pixel */ 1091 project(lngLat) { 1092 const lng = Array.isArray(lngLat) ? lngLat[0] : lngLat.lng; 1093 const lat = Array.isArray(lngLat) ? lngLat[1] : lngLat.lat; 1094 const ws = this._ws; 1095 const cx = this._lngToPx(this._center[0], ws); 1096 const cy = this._latToPy(this._center[1], ws); 1097 return { 1098 x: this._lngToPx(lng, ws) - cx + this._width / 2, 1099 y: this._latToPy(lat, ws) - cy + this._height / 2, 1100 }; 1101 } 1102 1103 /** Screen pixel â LngLat */ 1104 unproject(point) { 1105 const px = Array.isArray(point) ? point[0] : point.x; 1106 const py = Array.isArray(point) ? point[1] : point.y; 1107 const ws = this._ws; 1108 const cx = this._lngToPx(this._center[0], ws); 1109 const cy = this._latToPy(this._center[1], ws); 1110 return { 1111 lng: this._pxToLng(cx + px - this._width / 2, ws), 1112 lat: this._pyToLat(cy + py - this._height / 2, ws), 1113 wrap() { 1114 return this; 1115 }, 1116 toArray() { 1117 return [this.lng, this.lat]; 1118 }, 1119 }; 1120 } 1121
1122 /** Orthographic matrix: world-pixel â clip space */ 1123 _buildMatrix() { 1124 const ws = this._ws; 1125 const cx = this._lngToPx(this._center[0], ws); 1126 const cy = this._latToPy(this._center[1], ws); 1127 const hw = this._width / 2; 1128 const hh = this._height / 2; 1129 const l = cx - hw, 1130 r = cx + hw; 1131 const t = cy - hh, 1132 b = cy + hh; 1133 const sx = 2 / (r - l); 1134 const sy = 2 / (t - b); 1135 const tx = -(r + l) / (r - l); 1136 const ty = -(t + b) / (t - b); 1137 return new Float32Array([ 1138 sx, 1139 0, 1140 0, 1141 0, 1142 0, 1143 sy, 1144 0, 1145 0, 1146 0, 1147 0, 1148 -1, 1149 0, 1150 tx, 1151 ty, 1152 0, 1153 1, 1154 ]); 1155 } 1156 1157 /* ââ Dirty / Render loop ââââââââââââââââââââââââââââââââââââââââ */ 1158 _setDirty() { 1159 this._dirty = true; 1160 if (!this._loopRunning) { 1161 this._startLoop(); 1162 } 1163 } 1164 1165 _startLoop() { 1166 if (this._loopRunning) return; 1167 this._loopRunning = true; 1168 1169 const loop = () => { 1170 this._tickAnimation(); 1171 // Flush pending GPU texture uploads (batched to avoid jank) 1172 if (this._tileManager.flushUploads()) { 1173 this._dirty = true; // new textures uploaded, need repaint 1174 } 1175 if (this._dirty) { 1176 this._dirty = false; 1177 this._render(); 1178 } 1179 // Keep running if more uploads pending, animation active, or still dirty 1180 if ( 1181 this._tileManager.hasPendingUploads() || 1182 this._easeAnim || 1183 this._dirty 1184 ) { 1185 this._frameId = requestAnimationFrame(loop); 1186 } else { 1187 // Pause loop when idle 1188 this._loopRunning = false; 1189 this._frameId = null; 1190 } 1191 }; 1192 this._frameId = requestAnimationFrame(loop); 1193 } 1194 1195 _tickAnimation() { 1196 if (!this._easeAnim) return; 1197 const a = this._easeAnim; 1198 const t = clamp((performance.now() - a.start) / a.duration, 0, 1); 1199 const ease = t * (2 - t); // ease-out quad 1200 this._center[0] = 1201 a.fromCenter[0] + (a.toCenter[0] - a.fromCenter[0]) * ease; 1202 this._center[1] = 1203 a.fromCenter[1] + (a.toCenter[1] - a.fromCenter[1]) * ease; 1204 if (a.toZoom !== undefined) 1205 this._zoom = a.fromZoom + (a.toZoom - a.fromZoom) * ease; 1206 this._clampCenter(); 1207 this._setDirty(); 1208 if (t >= 1) { 1209 this._easeAnim = null; 1210 this._fire("moveend"); 1211 } 1212 } 1213 1214 /* ââ Public API: View âââââââââââââââââââââââââââââââââââââââââââ */ 1215 getCenter() { 1216 return { lng: this._center[0], lat: this._center[1] }; 1217 } 1218 getZoom() { 1219 return this._zoom; 1220 } 1221 getBearing() { 1222 return 0; 1223 } 1224 getPitch() { 1225 return 0; 1226 } 1227 1228 setCenter(c) { 1229 this._center = Array.isArray(c) ? [c[0], c[1]] : [c.lng, c.lat]; 1230 this._clampCenter(); 1231 this._setDirty(); 1232 } 1233 setZoom(z) { 1234 this._zoom = clamp(z, this._minZoom, this._maxZoom); 1235 this._clampCenter(); 1236 this._setDirty(); 1237 } 1238 setMinZoom(z) { 1239 this._minZoom = z; 1240 this._zoom = Math.max(this._zoom, z); 1241 this._ws = WORLD_SIZE_CACHE = worldSize(this._zoom); 1242 this._setDirty(); 1243 } 1244 setMaxZoom(z) { 1245 this._maxZoom = z; 1246 this._zoom = Math.min(this._zoom, z); 1247 this._ws = WORLD_SIZE_CACHE = worldSize(this._zoom); 1248 this._setDirty(); 1249 } 1250 setWorldRange(wr) { 1251 this._wr = wr; 1252 this._setDirty(); 1253 } 1254 1255 /** 1256 * Change the WebGL clear (background) colour for this map. 1257 * @param {string} cssColor e.g. '#08080f' or 'rgba(8,8,15,1)' 1258 */
1259 setBackground(cssColor) { 1260 this._clearColor = colorToVec4(cssColor); 1261 this._setDirty(); 1262 } 1263 1264 /** 1265 * Enable or disable the dungeon fog / vignette overlay. 1266 * @param {boolean} enabled 1267 * @param {string} [color] CSS colour for the radial gradient centre 1268 * (default dark-blue semi-transparent) 1269 */ 1270 setFogEffect(enabled, color) { 1271 if (!this._fogEl) return; 1272 if (enabled) { 1273 const c = color || "rgba(5,5,20,0.72)"; 1274 // Two-layer radial gradient: inner transparent â mid-fog â outer solid dark 1275 this._fogEl.style.background = 1276 `radial-gradient(ellipse at center,` + 1277 ` transparent 0%, transparent 28%,` + 1278 ` ${c} 62%, rgba(2,2,10,0.92) 100%)`; 1279 this._fogEl.style.opacity = "1"; 1280 } else { 1281 this._fogEl.style.opacity = "0"; 1282 } 1283 } 1284 setContextMenuHandler(handler) { 1285 this._contextMenuHandler = 1286 typeof handler === "function" ? handler : null; 1287 return this; 1288 } 1289 clearContextMenuHandler() { 1290 this._contextMenuHandler = null; 1291 return this; 1292 } 1293 setMaxBounds(b) { 1294 if (!b) { 1295 this._maxBounds = null; 1296 return; 1297 } 1298 // Accept [[sw],[ne]] or [w,s,e,n] 1299 if (Array.isArray(b) && Array.isArray(b[0])) { 1300 this._maxBounds = [b[0][0], b[0][1], b[1][0], b[1][1]]; 1301 } else { 1302 this._maxBounds = b; 1303 } 1304 this._clampCenter(); 1305 this._setDirty(); 1306 } 1307 1308 _clampCenter() { 1309 this._zoom = clamp(this._zoom, this._minZoom, this._maxZoom); 1310 this._ws = WORLD_SIZE_CACHE = worldSize(this._zoom); 1311 if (this._maxBounds) { 1312 const [minLng, minLat, maxLng, maxLat] = this._maxBounds; 1313 this._center[0] = clamp(this._center[0], minLng, maxLng); 1314 this._center[1] = clamp(this._center[1], minLat, maxLat); 1315 } 1316 } 1317 1318 easeTo(options) { 1319 const c = options.center; 1320 const z = options.zoom; 1321 const animate = options.animate !== false; 1322 const duration = options.duration ?? 300; 1323 if (!animate || duration <= 0) { 1324 if (c) 1325 this._center = Array.isArray(c) 1326 ? [c[0], c[1]] 1327 : [c.lng, c.lat]; 1328 if (z !== undefined) 1329 this._zoom = clamp(z, this._minZoom, this._maxZoom); 1330 this._clampCenter(); 1331 this._setDirty(); 1332 this._fire("moveend"); 1333 return this; 1334 } 1335 this._easeAnim = { 1336 fromCenter: [...this._center], 1337 toCenter: c 1338 ? Array.isArray(c) 1339 ? [c[0], c[1]] 1340 : [c.lng, c.lat] 1341 : [...this._center], 1342 fromZoom: this._zoom, 1343 toZoom: 1344 z !== undefined 1345 ? clamp(z, this._minZoom, this._maxZoom) 1346 : undefined, 1347 start: performance.now(), 1348 duration, 1349 }; 1350 this._setDirty(); 1351 return this; 1352 } 1353 1354 panBy(offset, options) { 1355 const ws = this._ws; 1356 const dLng = (-offset[0] / ws) * this._wr; 1357 const dLat = (offset[1] / ws) * this._wr; 1358 if (options && options.animate === false) { 1359 this._center[0] += dLng; 1360 this._center[1] += dLat; 1361 this._clampCenter(); 1362 this._setDirty(); 1363 } else { 1364 this.easeTo({ 1365 center: [this._center[0] + dLng, this._center[1] + dLat], 1366 duration: 0, 1367 }); 1368 } 1369 } 1370 1371 fitBounds(bounds, options) { 1372 const padding = (options && options.padding) || 0; 1373 let minLng, minLat, maxLng, maxLat; 1374 if (Array.isArray(bounds[0])) { 1375 [[minLng, minLat], [maxLng, maxLat]] = bounds; 1376 } else { 1377 [minLng, minLat, maxLng, maxLat] = bounds; 1378 } 1379 const cLng = (minLng + maxLng) / 2; 1380 const cLat = (minLat + maxLat) / 2; 1381 const dLng = Math.abs(maxLng - minLng) || 0.001; 1382 const dLat = Math.abs(maxLat - minLat) || 0.001; 1383 const pw = this._width - padding * 2; 1384 const ph = this._height - padding * 2; 1385 if (pw <= 0 || ph <= 0) return; 1386 const zoomX = Math.log2((pw * this._wr) / (dLng * TILE_SIZE)); 1387 const zoomY = Math.log2((ph * this._wr) / (dLat * TILE_SIZE)); 1388 const zoom = clamp( 1389 Math.min(zoomX, zoomY), 1390 this._minZoom, 1391 this._maxZoom, 1392 ); 1393 this.easeTo({ 1394 center: [cLng, cLat], 1395 zoom, 1396 duration: options?.duration ?? 300, 1397 }); 1398 } 1399 1400 /* ââ Underground layer ââââââââââââââââââââââââââââââââââââââââââ */ 1401 setUndergroundLayer(idx) { 1402 if (this._undergroundLayer !== idx) { 1403 this._undergroundLayer = idx; 1404 this._setDirty(); 1405 this._fire("undergroundChanged", { layer: idx }); 1406 } 1407 } 1408 getUndergroundLayer() { 1409 return this._undergroundLayer; 1410 } 1411 1412 /* ââ Source API ââââââââââââââââââââââââââââââââââââââââââââââââââ */ 1413 addSource(id, options) { 1414 if (options.type === "raster") { 1415 this._sources[id] = new RasterSource(id, options); 1416 } else if (options.type === "image") { 1417 this._sources[id] = new ImageSource(id, options); 1418 } else { 1419 this._sources[id] = new GeoJSONSource(id, options); 1420 } 1421 this._sourceViewCache.delete(id); 1422 this._setDirty(); 1423 } 1424 getSource(id) { 1425 const s = this._sources[id]; 1426 if (!s) return null; 1427 if (s instanceof GeoJSONSource) { 1428 const map = this; 1429 return { 1430 setData(data) { 1431 s.setData(data); 1432 map._setDirty(); 1433 }, 1434 _inner: s, 1435 }; 1436 } 1437 return s; 1438 } 1439 removeSource(id) { 1440 this._sourceViewCache.delete(id); 1441 delete this._sources[id]; 1442 this._setDirty(); 1443 } 1444 1445 /* ââ Layer API ââââââââââââââââââââââââââââââââââââââââââââââââââ */ 1446 addLayer(def, beforeId) { 1447 const layer = new Layer(def); 1448 if (beforeId) { 1449 const idx = this._layers.findIndex((l) => l.id === beforeId); 1450 if (idx >= 0) this._layers.splice(idx, 0, layer); 1451 else this._layers.push(layer); 1452 } else { 1453 this._layers.push(layer); 1454 } 1455 this._setDirty(); 1456 } 1457 getLayer(id) { 1458 return this._layers.find((l) => l.id === id) || null; 1459 } 1460 removeLayer(id) { 1461 this._layers = this._layers.filter((l) => l.id !== id); 1462 this._setDirty(); 1463 } 1464 moveLayer(id, beforeId) { 1465 const layer = this._layers.find((l) => l.id === id); 1466 if (!layer) return; 1467 this._layers = this._layers.filter((l) => l.id !== id); 1468 if (beforeId) { 1469 const idx = this._layers.findIndex((l) => l.id === beforeId); 1470 if (idx >= 0) this._layers.splice(idx, 0, layer); 1471 else this._layers.push(layer); 1472 } else { 1473 this._layers.push(layer); 1474 } 1475 this._setDirty(); 1476 } 1477 1478 /* ââ Image API ââââââââââââââââââââââââââââââââââââââââââââââââââ */ 1479 addImage(name, image) { 1480 this._images[name] = image; 1481 if ( 1482 image instanceof HTMLImageElement || 1483 image instanceof HTMLCanvasElement || 1484 image instanceof ImageBitmap 1485 ) { 1486 this._texCache.upload("img:" + name, image); 1487 } 1488 this._setDirty(); 1489 } 1490 loadImage(url, callback) { 1491 const img = new Image(); 1492 img.crossOrigin = "anonymous"; 1493 img.onload = () => callback(null, img); 1494 img.onerror = (e) => 1495 callback(e || new Error("Failed to load: " + url)); 1496 img.src = url; 1497 } 1498 hasImage(name) { 1499 return !!this._images[name]; 1500 } 1501 1502 /* ââ Feature State ââââââââââââââââââââââââââââââââââââââââââââââ */ 1503 setFeatureState(target, state) { 1504 const src = this._sources[target.source]; 1505 if (!src || !(src instanceof GeoJSONSource)) return; 1506 const existing = src._featureStates.get(target.id) || {}; 1507 src._featureStates.set(target.id, { ...existing, ...state }); 1508 this._setDirty(); 1509 } 1510 getFeatureState(target) { 1511 const src = this._sources[target.source]; 1512 if (!src || !(src instanceof GeoJSONSource)) return {}; 1513 return src._featureStates.get(target.id) || {}; 1514 } 1515 1516 /* ââ Query ââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 1517 queryRenderedFeatures(point, options) { 1518 const sx = 1519 point instanceof MouseEvent 1520 ? point.offsetX 1521 : (point.x ?? point[0]); 1522 const sy = 1523 point instanceof MouseEvent 1524 ? point.offsetY 1525 : (point.y ?? point[1]); 1526 const layerFilter = options?.layers 1527 ? new Set(options.layers) 1528 : null; 1529 const results = []; 1530 1531 for (let i = this._layers.length - 1; i >= 0; i--) { 1532 const layer = this._layers[i];
1533 if (layerFilter && !layerFilter.has(layer.id)) continue; 1534 if (layer.type !== "symbol" && layer.type !== "circle") 1535 continue; 1536 const src = this._sources[layer.source]; 1537 if (!src || !(src instanceof GeoJSONSource)) continue; 1538 if (!src.isMarkerSource()) continue; 1539 const hitR = 1540 layer.type === "circle" 1541 ? layer.paint["circle-radius"] || 10 1542 : 16; 1543 for (const f of this._getVisibleFeaturesForSource(src)) { 1544 if (!f.geometry || f.geometry.type !== "Point") continue; 1545 const [lng, lat] = f.geometry.coordinates; 1546 const s = this.project([lng, lat]); 1547 if ((s.x - sx) ** 2 + (s.y - sy) ** 2 <= hitR * hitR) { 1548 results.push({ 1549 type: "Feature", 1550 geometry: f.geometry, 1551 properties: { ...f.properties }, 1552 layer: { id: layer.id }, 1553 source: layer.source, 1554 }); 1555 } 1556 } 1557 } 1558 return results; 1559 } 1560 1561 /* ââ Canvas / container ââââââââââââââââââââââââââââââââââââââââââ */ 1562 getCanvas() { 1563 return this._canvas; 1564 } 1565 getContainer() { 1566 return this._container; 1567 } 1568 1569 /* ââ Controls ââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 1570 addControl(control, position) { 1571 if (!this._controlContainers[position]) { 1572 const div = document.createElement("div"); 1573 div.className = "gflatmap-ctrl gflatmap-ctrl-" + position; 1574 const pos = { 1575 "top-left": "top:10px;left:10px;", 1576 "top-right": "top:10px;right:10px;", 1577 "bottom-left": "bottom:10px;left:10px;", 1578 "bottom-right": "bottom:30px;right:10px;", 1579 }; 1580 div.style.cssText = 1581 "position:absolute;z-index:2;display:flex;flex-direction:column;gap:4px;" + 1582 (pos[position] || ""); 1583 this._container.appendChild(div); 1584 this._controlContainers[position] = div; 1585 } 1586 if (control?.onAdd) { 1587 const el = control.onAdd(this); 1588 if (el) this._controlContainers[position].appendChild(el); 1589 } 1590 } 1591 1592 /* ââ Input handling ââââââââââââââââââââââââââââââââââââââââââââââ */ 1593 _setupInput() { 1594 let dragging = false, 1595 lastX = 0, 1596 lastY = 0; 1597 let dragStartX = 0, 1598 dragStartY = 0, 1599 dragMoved = false; 1600 let pinchDist0 = null, 1601 pinchZoom0 = null; 1602 let pendingTouchDx = 0, 1603 pendingTouchDy = 0, 1604 touchPanRaf = 0; 1605 let touchVelX = 0, 1606 touchVelY = 0, 1607 lastTouchMoveTs = 0; 1608 let touchHadMultiTouch = false; 1609 const activeTouchPointers = new Set(); 1610 const canvas = this._canvas; 1611 1612 const isMultiTouch = () => activeTouchPointers.size >= 2; 1613 1614 const flushTouchPan = () => { 1615 touchPanRaf = 0; 1616 if (!pendingTouchDx && !pendingTouchDy) return; 1617 const dx = pendingTouchDx; 1618 const dy = pendingTouchDy; 1619 pendingTouchDx = 0; 1620 pendingTouchDy = 0; 1621 const ws = this._ws; 1622 this._center[0] -= (dx / ws) * this._wr; 1623 this._center[1] += (dy / ws) * this._wr; 1624 this._clampCenter(); 1625 this._setDirty(); 1626 this._fire("move"); 1627 }; 1628 1629 const scheduleTouchPan = () => { 1630 if (touchPanRaf) return; 1631 touchPanRaf = requestAnimationFrame(flushTouchPan); 1632 }; 1633 1634 const flushTouchPanNow = () => { 1635 if (touchPanRaf) { 1636 cancelAnimationFrame(touchPanRaf); 1637 touchPanRaf = 0; 1638 } 1639 flushTouchPan(); 1640 }; 1641 1642 const endPointerDrag = ( 1643 pointerId, 1644 pointerType, 1645 allowInertia = true, 1646 ) => { 1647 if (!dragging) return; 1648 flushTouchPanNow(); 1649 dragging = false; 1650 if (pointerId !== undefined) { 1651 try { 1652 canvas.releasePointerCapture(pointerId); 1653 } catch (_) {} 1654 } 1655 1656 let usedInertia = false; 1657 if ( 1658 allowInertia && 1659 pointerType === "touch" && 1660 dragMoved && 1661 !touchHadMultiTouch 1662 ) { 1663 const speed = Math.hypot(touchVelX, touchVelY); // px/ms 1664 if (speed > 0.02) { 1665 const inertiaMs = 110; 1666 let pxX = touchVelX * inertiaMs; 1667 let pxY = touchVelY * inertiaMs; 1668 const dist = Math.hypot(pxX, pxY); 1669 const maxDist = 220; 1670 if (dist > maxDist) { 1671 const s = maxDist / dist; 1672 pxX *= s; 1673 pxY *= s; 1674 } 1675 const ws = this._ws; 1676 const targetCenter = [ 1677 this._center[0] - (pxX / ws) * this._wr, 1678 this._center[1] + (pxY / ws) * this._wr, 1679 ]; 1680 this.easeTo({ 1681 center: targetCenter, 1682 duration: inertiaMs, 1683 animate: true, 1684 }); 1685 usedInertia = true; 1686 } 1687 } 1688 1689 touchVelX = 0; 1690 touchVelY = 0; 1691 lastTouchMoveTs = 0; 1692 touchHadMultiTouch = false; 1693 1694 if (!usedInertia) this._fire("moveend"); 1695 }; 1696 1697 canvas.addEventListener("pointerdown", (e) => { 1698 if (e.pointerType === "touch") { 1699 activeTouchPointers.add(e.pointerId); 1700 this._beginInteraction(); 1701 if (activeTouchPointers.size >= 2) { 1702 touchHadMultiTouch = true; 1703 } else { 1704 touchVelX = 0; 1705 touchVelY = 0; 1706 lastTouchMoveTs = performance.now(); 1707 touchHadMultiTouch = false; 1708 } 1709 } 1710 if (isMultiTouch()) { 1711 endPointerDrag(e.pointerId, e.pointerType, false); 1712 return; 1713 } 1714 if (e.button !== 0) return; 1715 dragging = true; 1716 dragMoved = false; 1717 lastX = dragStartX = e.clientX; 1718 lastY = dragStartY = e.clientY; 1719 canvas.setPointerCapture(e.pointerId); 1720 }); 1721 1722 canvas.addEventListener("pointermove", (e) => { 1723 if (!dragging) return; 1724 if (isMultiTouch()) { 1725 touchHadMultiTouch = true; 1726 endPointerDrag(e.pointerId, e.pointerType, false); 1727 return; 1728 } 1729 const dx = e.clientX - lastX; 1730 const dy = e.clientY - lastY; 1731 lastX = e.clientX; 1732 lastY = e.clientY; 1733 if ( 1734 Math.abs(e.clientX - dragStartX) > 3 || 1735 Math.abs(e.clientY - dragStartY) > 3 1736 ) 1737 dragMoved = true; 1738 if (e.pointerType === "touch") { 1739 const now = performance.now(); 1740 const dt = lastTouchMoveTs ? now - lastTouchMoveTs : 0; 1741 if (dt > 0 && dt < 80) { 1742 const instVX = dx / dt; 1743 const instVY = dy / dt; 1744 touchVelX = touchVelX * 0.7 + instVX * 0.3; 1745 touchVelY = touchVelY * 0.7 + instVY * 0.3; 1746 } 1747 lastTouchMoveTs = now; 1748 pendingTouchDx += dx; 1749 pendingTouchDy += dy; 1750 scheduleTouchPan(); 1751 return; 1752 } 1753 const ws = this._ws; 1754 this._center[0] -= (dx / ws) * this._wr; 1755 this._center[1] += (dy / ws) * this._wr; 1756 this._clampCenter(); 1757 this._setDirty(); 1758 this._fire("move"); 1759 }); 1760 1761 canvas.addEventListener("pointerup", (e) => { 1762 if (e.pointerType === "touch") { 1763 activeTouchPointers.delete(e.pointerId); 1764 if (activeTouchPointers.size === 0) 1765 this._endInteractionSoon(); 1766 } 1767 endPointerDrag(e.pointerId, e.pointerType, true); 1768 }); 1769 1770 canvas.addEventListener("pointercancel", (e) => { 1771 if (e.pointerType === "touch") { 1772 activeTouchPointers.delete(e.pointerId); 1773 if (activeTouchPointers.size === 0) 1774 this._endInteractionSoon(); 1775 } 1776 endPointerDrag(e.pointerId, e.pointerType, false); 1777 }); 1778 1779 // Wheel zoom (towards cursor) 1780 canvas.addEventListener( 1781 "wheel", 1782 (e) => { 1783 e.preventDefault(); 1784 this._beginInteraction(); 1785 this._endInteractionSoon(); 1786 const delta = -e.deltaY * 0.002;
1787 const newZoom = clamp( 1788 this._zoom + delta, 1789 this._minZoom, 1790 this._maxZoom, 1791 ); 1792 const rect = canvas.getBoundingClientRect(); 1793 const mx = e.clientX - rect.left; 1794 const my = e.clientY - rect.top; 1795 const before = this.unproject([mx, my]); 1796 this._zoom = newZoom; 1797 this._ws = worldSize(newZoom); 1798 const after = this.unproject([mx, my]); 1799 this._center[0] -= after.lng - before.lng; 1800 this._center[1] -= after.lat - before.lat; 1801 this._clampCenter(); 1802 this._setDirty(); 1803 this._fire("zoom"); 1804 this._fire("move"); 1805 }, 1806 { passive: false }, 1807 ); 1808 1809 // Touch pinch 1810 const touches = new Map(); 1811 canvas.addEventListener( 1812 "touchstart", 1813 (e) => { 1814 this._beginInteraction(); 1815 for (const t of e.changedTouches) 1816 touches.set(t.identifier, { 1817 x: t.clientX, 1818 y: t.clientY, 1819 }); 1820 if (touches.size === 2) { 1821 const pts = [...touches.values()]; 1822 pinchDist0 = Math.hypot( 1823 pts[1].x - pts[0].x, 1824 pts[1].y - pts[0].y, 1825 ); 1826 pinchZoom0 = this._zoom; 1827 } 1828 }, 1829 { passive: true }, 1830 ); 1831 canvas.addEventListener( 1832 "touchmove", 1833 (e) => { 1834 for (const t of e.changedTouches) 1835 if (touches.has(t.identifier)) 1836 touches.set(t.identifier, { 1837 x: t.clientX, 1838 y: t.clientY, 1839 }); 1840 if (touches.size === 2 && pinchDist0) { 1841 const pts = [...touches.values()]; 1842 const dist = Math.hypot( 1843 pts[1].x - pts[0].x, 1844 pts[1].y - pts[0].y, 1845 ); 1846 const midX = (pts[0].x + pts[1].x) * 0.5; 1847 const midY = (pts[0].y + pts[1].y) * 0.5; 1848 const rect = canvas.getBoundingClientRect(); 1849 const mx = midX - rect.left; 1850 const my = midY - rect.top; 1851 const before = this.unproject([mx, my]); 1852 this._zoom = clamp( 1853 pinchZoom0 + Math.log2(dist / pinchDist0), 1854 this._minZoom, 1855 this._maxZoom, 1856 ); 1857 this._ws = worldSize(this._zoom); 1858 const after = this.unproject([mx, my]); 1859 this._center[0] -= after.lng - before.lng; 1860 this._center[1] -= after.lat - before.lat; 1861 this._clampCenter(); 1862 this._setDirty(); 1863 this._fire("zoom"); 1864 this._fire("move"); 1865 } 1866 }, 1867 { passive: true }, 1868 ); 1869 canvas.addEventListener("touchend", (e) => { 1870 for (const t of e.changedTouches) touches.delete(t.identifier); 1871 if (touches.size < 2) { 1872 pinchDist0 = null; 1873 pinchZoom0 = null; 1874 } 1875 if (touches.size === 0) this._endInteractionSoon(); 1876 }); 1877 1878 // Click (only if not dragged) 1879 canvas.addEventListener("click", (e) => { 1880 if (dragMoved) return; 1881 const rect = canvas.getBoundingClientRect(); 1882 const x = e.clientX - rect.left; 1883 const y = e.clientY - rect.top; 1884 const lngLat = this.unproject([x, y]); 1885 const point = { x, y }; 1886 1887 // Layer-specific handlers (top â bottom) 1888 for (let i = this._layers.length - 1; i >= 0; i--) { 1889 const layer = this._layers[i];
1890 const key = `click::${layer.id}`; 1891 if (!this._handlers[key] || !this._handlers[key].length) 1892 continue; 1893 const hits = this.queryRenderedFeatures(point, { 1894 layers: [layer.id], 1895 }); 1896 if (hits.length) { 1897 this._fire( 1898 "click", 1899 { 1900 type: "click", 1901 point, 1902 lngLat, 1903 features: hits, 1904 originalEvent: e, 1905 preventDefault() {}, 1906 defaultPrevented: false, 1907 }, 1908 [layer.id], 1909 ); 1910 return; 1911 } 1912 } 1913 // Generic click â close any open popups 1914 for (const p of this._popups) p.remove(); 1915 this._fire("click", { 1916 type: "click", 1917 point, 1918 lngLat, 1919 features: [], 1920 originalEvent: e, 1921 preventDefault() {}, 1922 defaultPrevented: false, 1923 }); 1924 }); 1925 1926 // Right-click / context menu 1927 canvas.addEventListener("contextmenu", (e) => { 1928 e.preventDefault(); 1929 const rect = canvas.getBoundingClientRect(); 1930 const x = e.clientX - rect.left; 1931 const y = e.clientY - rect.top; 1932 const lngLat = this.unproject([x, y]); 1933 const point = { x, y }; 1934 1935 // Layer-specific handlers (top â bottom) 1936 for (let i = this._layers.length - 1; i >= 0; i--) { 1937 const layer = this._layers[i]; 1938 const key = `contextmenu::${layer.id}`; 1939 if (!this._handlers[key] || !this._handlers[key].length) 1940 continue; 1941 const hits = this.queryRenderedFeatures(point, { 1942 layers: [layer.id], 1943 }); 1944 if (hits.length) { 1945 const evt = { 1946 type: "contextmenu", 1947 point, 1948 lngLat, 1949 features: hits, 1950 originalEvent: e, 1951 defaultPrevented: false, 1952 preventDefault() { 1953 this.defaultPrevented = true; 1954 }, 1955 }; 1956 this._fire("contextmenu", evt, [layer.id]); 1957 if (this._contextMenuHandler) 1958 this._contextMenuHandler(evt); 1959 return; 1960 } 1961 } 1962 1963 const evt = { 1964 type: "contextmenu", 1965 point, 1966 lngLat, 1967 features: [], 1968 originalEvent: e, 1969 defaultPrevented: false, 1970 preventDefault() { 1971 this.defaultPrevented = true; 1972 }, 1973 }; 1974 this._fire("contextmenu", evt); 1975 if (this._contextMenuHandler) this._contextMenuHandler(evt); 1976 }); 1977 1978 // MouseEnter / MouseLeave per layer 1979 const hovered = new Map(); 1980 canvas.addEventListener("mousemove", (e) => { 1981 const rect = canvas.getBoundingClientRect(); 1982 const point = { 1983 x: e.clientX - rect.left, 1984 y: e.clientY - rect.top, 1985 }; 1986 for (const layer of this._layers) { 1987 const eKey = `mouseenter::${layer.id}`; 1988 const lKey = `mouseleave::${layer.id}`; 1989 const hasE = this._handlers[eKey]?.length; 1990 const hasL = this._handlers[lKey]?.length; 1991 if (!hasE && !hasL) continue; 1992 const hits = this.queryRenderedFeatures(point, { 1993 layers: [layer.id], 1994 }); 1995 const was = hovered.has(layer.id); 1996 const is = hits.length > 0;
1997 if (is && !was) { 1998 hovered.set(layer.id, true); 1999 if (hasE) 2000 this._fire( 2001 "mouseenter", 2002 { type: "mouseenter", point, features: hits }, 2003 [layer.id], 2004 ); 2005 } else if (!is && was) { 2006 hovered.delete(layer.id); 2007 if (hasL) 2008 this._fire( 2009 "mouseleave", 2010 { type: "mouseleave", point, features: [] }, 2011 [layer.id], 2012 ); 2013 } 2014 } 2015 }); 2016 } 2017 2018 /* ââ Render ââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 2019 _render() { 2020 const gl = this.gl; 2021 gl.viewport(0, 0, this._canvas.width, this._canvas.height); 2022 const cc = this._clearColor; 2023 gl.clearColor(cc[0], cc[1], cc[2], cc[3]); 2024 gl.clear(gl.COLOR_BUFFER_BIT); 2025 2026 const matrix = this._buildMatrix(); 2027 2028 for (const layer of this._layers) { 2029 if (!layer.visible) continue; 2030 if (this._zoom < layer.minzoom || this._zoom > layer.maxzoom) 2031 continue; 2032 const src = this._sources[layer.source]; 2033 if (!src) continue; 2034 2035 if (layer.type === "raster" && src instanceof RasterSource) { 2036 // Surface tiles (underground===0) always render; other raster sources need exact match 2037 if ( 2038 src.underground !== 0 && 2039 src.underground !== this._undergroundLayer 2040 ) 2041 continue; 2042 if (src.gridSize) { 2043 this._renderFlatRasterLayer(gl, matrix, src, layer); 2044 } else { 2045 this._renderRasterLayer(gl, matrix, src, layer); 2046 } 2047 } else if ( 2048 layer.type === "raster" && 2049 src instanceof ImageSource 2050 ) { 2051 // Underground images render as overlay when their level is selected 2052 if (src.underground !== this._undergroundLayer) continue; 2053 if (this._undergroundLayer === 0) continue; // don't show underground images on surface 2054 this._renderImageSource(gl, matrix, src, layer); 2055 } else if (src instanceof GeoJSONSource) { 2056 switch (layer.type) { 2057 case "symbol": 2058 this._renderSymbolLayer(gl, matrix, src, layer); 2059 break; 2060 case "circle": 2061 this._renderCircleLayer(gl, matrix, src, layer); 2062 break; 2063 case "line": 2064 this._renderLineLayer(gl, matrix, src, layer); 2065 break; 2066 case "fill": 2067 this._renderFillLayer(gl, matrix, src, layer); 2068 break; 2069 } 2070 } 2071 } 2072 this._updateOverlays(); 2073 } 2074 2075 /* ââ Raster tile layer ââââââââââââââââââââââââââââââââââââââââââ */ 2076 _renderRasterLayer(gl, matrix, src, layer) { 2077 const z = this._zoom; 2078 const ws = this._ws; 2079 const tileZoom = clamp(Math.round(z), src.minzoom, src.maxzoom); 2080 const tileWs = worldSize(tileZoom); 2081 const scale = Math.pow(2, z - tileZoom); 2082 const numTiles = Math.pow(2, tileZoom); 2083 2084 const cx = this._lngToPx(this._center[0], tileWs); 2085 const cy = this._latToPy(this._center[1], tileWs); 2086 const hw = this._width / 2 / scale; 2087 const hh = this._height / 2 / scale; 2088 2089 const minTX = Math.max(0, Math.floor((cx - hw) / TILE_SIZE)); 2090 const maxTX = Math.min( 2091 numTiles - 1, 2092 Math.floor((cx + hw) / TILE_SIZE), 2093 ); 2094 const minTY = Math.max(0, Math.floor((cy - hh) / TILE_SIZE)); 2095 const maxTY = Math.min( 2096 numTiles - 1, 2097 Math.floor((cy + hh) / TILE_SIZE), 2098 ); 2099 2100 gl.useProgram(this._progTex); 2101 gl.uniformMatrix4fv(this._texLocs.u_matrix, false, matrix
2101); 2102 gl.uniform1i(this._texLocs.u_texture, 0); 2103 gl.activeTexture(gl.TEXTURE0); 2104 2105 const keepKeys = new Set(); 2106 2107 for (let ty = minTY; ty <= maxTY; ty++) { 2108 for (let tx = minTX; tx <= maxTX; tx++) { 2109 const url = src.tiles[0] 2110 .replace("{z}", tileZoom) 2111 .replace("{x}", tx) 2112 .replace("{y}", ty); 2113 keepKeys.add(url); 2114 2115 let entry = this._tileManager.getTile(url); 2116 2117 // Fallback to parent tile while loading 2118 if (!entry) { 2119 for (let dz = 1; dz <= 3; dz++) { 2120 const pz = tileZoom - dz; 2121 if (pz < src.minzoom) break; 2122 const ptx = tx >> dz; 2123 const pty = ty >> dz; 2124 const pUrl = src.tiles[0] 2125 .replace("{z}", pz) 2126 .replace("{x}", ptx) 2127 .replace("{y}", pty); 2128 const pe = this._texCache.get(pUrl); 2129 if (pe) { 2130 this._drawTileSub( 2131 gl, 2132 pe, 2133 tileZoom, 2134 tx, 2135 ty, 2136 pz, 2137 ptx, 2138 pty, 2139 ws, 2140 ); 2141 entry = null; 2142 break; 2143 } 2144 } 2145 if (!entry) continue; 2146 } 2147 2148 if (entry) { 2149 const rx0 = this._lngToPx(
2150 this._pxToLng(tx * TILE_SIZE, tileWs), 2151 ws, 2152 ); 2153 const ry0 = this._latToPy( 2154 this._pyToLat(ty * TILE_SIZE, tileWs), 2155 ws, 2156 ); 2157 const rx1 = this._lngToPx( 2158 this._pxToLng((tx + 1) * TILE_SIZE, tileWs), 2159 ws, 2160 ); 2161 const ry1 = this._latToPy( 2162 this._pyToLat((ty + 1) * TILE_SIZE, tileWs), 2163 ws, 2164 ); 2165 this._drawTexQuad( 2166 gl, 2167 entry.tex, 2168 rx0, 2169 ry0, 2170 rx1, 2171 ry1, 2172 1, 2173 ); 2174 } 2175 } 2176 } 2177 this._tileManager.prune(keepKeys); 2178 } 2179 2180 /* ââ Flat grid raster layer with LOD ââââââââââââââââââââââââââââ */ 2181 /* NÃN game tiles with downscaled copies at 2/, 4/, 8/ subdirs. */ 2182 /* URL template uses {s} for LOD prefix (''=full, '2/', '4/', '8/')*/ 2183 /* Picks the best LOD for current zoom; falls back to coarser */ 2184 /* cached tiles while the ideal LOD loads. */ 2185 _renderFlatRasterLayer(gl, matrix, src, layer) { 2186 const ws = this._ws; // total world pixels at zoom z 2187 const N = src.gridSize; // tiles per axis 2188 const T = src.gameTileSize || 1024; // original tile pixel size 2189 const tw = ws / N; // tile screen-pixel size 2190 2191 // Pick LOD: largest power-of-2 downscale where image >= screen pixels 2192 // lod s â image is T/s px. Want T/s >= tw â s <= T/tw. 2193 const idealScale = Math.max(1, T / tw); 2194 const lod = Math.min( 2195 8, 2196 Math.pow(2, Math.floor(Math.log2(idealScale))), 2197 ); 2198 const sPrefix = lod <= 1 ? "" : lod + "/"; 2199 2200 // Viewport centre in pixel space at current zoom 2201 const cx = this._lngToPx(this._center[0], ws); 2202 const cy = this._latToPy(this._center[1], ws); 2203 const hw = this._width / 2; 2204 const hh = this._height / 2; 2205 2206 // Visible game-tile range 2207 const minCol = Math.max(0, Math.floor((cx - hw) / tw)); 2208 const maxCol = Math.min(N - 1, Math.floor((cx + hw) / tw)); 2209 const minRow = Math.max(0, Math.floor((cy - hh) / tw)); 2210 const maxRow = Math.min(N - 1, Math.floor((cy + hh) / tw)); 2211 2212 gl.useProgram(this._progTex); 2213 gl.uniformMatrix4fv(this._texLocs.u_matrix, false, matrix); 2214 gl.uniform1i(this._texLocs.u_texture, 0); 2215 gl.activeTexture(gl.TEXTURE0); 2216 2217 const keepKeys = new Set(); 2218 2219 for (let row = minRow; row <= maxRow; row++) { 2220 for (let col = minCol; col <= maxCol; col++) { 2221 const url = src.tiles[0] 2222 .replace("{s}", sPrefix) 2223 .replace("{x}", col) 2224 .replace("{y}", row); 2225 keepKeys.add(url); 2226 2227 let entry = this._tileManager.getTile(url); 2228 2229 // While ideal LOD loads, fall back to coarser cached tiles 2230 if (!entry) { 2231 // Trigger load of coarsest (8x) for fast placeholder 2232 if (lod < 8) { 2233 const c8 = src.tiles[0] 2234 .replace("{s}", "8/") 2235 .replace("{x}", col) 2236 .replace("{y}", row); 2237 keepKeys.add(c8); 2238 this._tileManager.getTile(c8); 2239 } 2240 // Find best available coarser tile from cache 2241 for ( 2242 let fs = lod <= 1 ? 2 : lod * 2; 2243 fs <= 8; 2244 fs *= 2 2245 ) { 2246 const fUrl = src.tiles[0] 2247 .replace("{s}", fs + "/") 2248 .replace("{x}", col) 2249 .replace("{y}", row); 2250 const fe = this._texCache.get(fUrl); 2251 if (fe) { 2252 entry = fe; 2253 keepKeys.add(fUrl); 2254 break; 2255 } 2256 } 2257 } 2258 2259 if (!entry) continue; 2260 2261 const px0 = col * tw; 2262 const py0 = row * tw; 2263 const px1 = (col + 1) * tw; 2264 const py1 = (row + 1) * tw; 2265 this._drawTexQuad(gl, entry.tex, px0, py0, px1, py1, 1); 2266 } 2267 } 2268 this._tileManager.prune(keepKeys); 2269 } 2270 2271 /* ââ Image source (positioned images, e.g. underground maps) ââ */ 2272 _renderImageSource(gl, matrix, src, layer) { 2273 const ws = this._ws; 2274 const cx = this._lngToPx(this._center[0], ws); 2275 const cy = this._latToPy(this._center[1], ws); 2276 const hw = this._width / 2;
2277 const hh = this._height / 2; 2278 2279 gl.useProgram(this._progTex); 2280 gl.uniformMatrix4fv(this._texLocs.u_matrix, false, matrix); 2281 gl.uniform1i(this._texLocs.u_texture, 0); 2282 gl.activeTexture(gl.TEXTURE0); 2283 2284 const keepKeys = new Set(); 2285 2286 for (const img of src.images) { 2287 if (img.visible === false) continue; // allow per-image visibility toggle 2288 // bounds = [west, south, east, north] in lng/lat 2289 const x0 = this._lngToPx(img.bounds[0], ws); 2290 const y0 = this._latToPy(img.bounds[3], ws); // north â top 2291 const x1 = this._lngToPx(img.bounds[2], ws); 2292 const y1 = this._latToPy(img.bounds[1], ws); // south â bottom 2293 2294 // Viewport culling 2295 if ( 2296 x1 < cx - hw || 2297 x0 > cx + hw || 2298 y1 < cy - hh || 2299 y0 > cy + hh 2300 ) 2301 continue; 2302 2303 keepKeys.add(img.url); 2304 const entry = this._tileManager.getTile(img.url); 2305 if (!entry) continue; 2306 2307 this._drawTexQuad(gl, entry.tex, x0, y0, x1, y1, 1); 2308 } 2309 this._tileManager.prune(keepKeys); 2310 } 2311 2312 _drawTileSub(gl, parentEntry, tileZ, tx, ty, pz, ptx, pty, renderWs) { 2313 const dz = tileZ - pz; 2314 const sub = Math.pow(2, dz); 2315 const u0 = (tx - ptx * sub) / sub; 2316 const v0 = (ty - pty * sub) / sub; 2317 const u1 = u0 + 1 / sub; 2318 const v1 = v0 + 1 / sub; 2319 const tileWs = worldSize(tileZ); 2320 const rx0 = this._lngToPx(
2321 this._pxToLng(tx * TILE_SIZE, tileWs), 2322 renderWs, 2323 ); 2324 const ry0 = this._latToPy( 2325 this._pyToLat(ty * TILE_SIZE, tileWs), 2326 renderWs, 2327 ); 2328 const rx1 = this._lngToPx( 2329 this._pxToLng((tx + 1) * TILE_SIZE, tileWs), 2330 renderWs, 2331 ); 2332 const ry1 = this._latToPy( 2333 this._pyToLat((ty + 1) * TILE_SIZE, tileWs), 2334 renderWs, 2335 ); 2336 this._drawTexQuadUV( 2337 gl, 2338 parentEntry.tex, 2339 rx0, 2340 ry0, 2341 rx1, 2342 ry1, 2343 u0, 2344 v0, 2345 u1, 2346 v1, 2347 1, 2348 ); 2349 } 2350 2351 _drawTexQuad(gl, tex, x0, y0, x1, y1, opacity) { 2352 this._drawTexQuadUV(gl, tex, x0, y0, x1, y1, 0, 0, 1, 1, opacity); 2353 } 2354 2355 _drawTexQuadUV(gl, tex, x0, y0, x1, y1, u0, v0, u1, v1, opacity) { 2356 const v = new Float32Array([ 2357 x0, 2358 y0, 2359 u0, 2360 v0, 2361 opacity, 2362 x1, 2363 y0, 2364 u1, 2365 v0, 2366 opacity, 2367 x0, 2368 y1, 2369 u0, 2370 v1, 2371 opacity, 2372 x1, 2373 y0, 2374 u1, 2375 v0, 2376 opacity, 2377 x1, 2378 y1, 2379 u1, 2380 v1, 2381 opacity, 2382 x0, 2383 y1, 2384 u0, 2385 v1, 2386 opacity, 2387 ]); 2388 gl.bindBuffer(gl.ARRAY_BUFFER, this._dynBuffer); 2389 gl.bufferData(gl.ARRAY_BUFFER, v, gl.DYNAMIC_DRAW); 2390 const S = 20; // stride = 5Ã4 2391 gl.enableVertexAttribArray(this._texLocs.a_pos); 2392 gl.vertexAttribPointer( 2393 this._texLocs.a_pos, 2394 2, 2395 gl.FLOAT, 2396 false, 2397 S, 2398 0, 2399 ); 2400 gl.enableVertexAttribArray(this._texLocs.a_uv); 2401 gl.vertexAttribPointer( 2402 this._texLocs.a_uv, 2403 2, 2404 gl.FLOAT, 2405 false, 2406 S, 2407 8, 2408 ); 2409 gl.enableVertexAttribArray(this._texLocs.a_opacity); 2410 gl.vertexAttribPointer( 2411 this._texLocs.a_opacity, 2412 1, 2413 gl.FLOAT, 2414 false, 2415 S, 2416 16, 2417 ); 2418 gl.bindTexture(gl.TEXTURE_2D, tex); 2419 gl.drawArrays(gl.TRIANGLES, 0, 6); 2420 } 2421 2422 _getReusableFloat32(cache, key, minLength) { 2423 let arr = cache.get(key); 2424 if (!arr || arr.length < minLength) { 2425 const grown = arr 2426 ? Math.max(minLength, arr.length * 2) 2427 : minLength; 2428 arr = new Float32Array(grown); 2429 cache.set(key, arr); 2430 } 2431 return arr; 2432 } 2433 2434 /* ââ Symbol layer (icons + text) ââââââââââââââââââââââââââââââââ */ 2435 _renderSymbolLayer(gl, matrix, src, layer) { 2436 const features = this._getVisibleFeaturesForSource(src); 2437 if (!features.length) return; 2438 const ws = this._ws; 2439 2440 const iconImageExpr = layer.layout["icon-image"]; 2441 const iconSizeExpr = layer.layout["icon-size"] ?? 1; 2442 const iconOpacityExpr = layer.paint["icon-opacity"] ?? 1; 2443 const iconOffset = layer.layout["icon-offset"]; 2444 const textFieldExpr = layer.layout["text-field"]; 2445 const textSizeExpr = layer.layout["text-size"] ?? 14; 2446 const textColorExpr = layer.paint["text-color"] ?? "#000000"; 2447 const textHaloC = layer.paint["text-halo-color"] ?? "#ffffff"; 2448 const textHaloW = layer.paint["text-halo-width"] ?? 0; 2449 const textOpacityExpr = layer.paint["text-opacity"] ?? 1; 2450 2451 gl.useProgram(this._progTex); 2452 gl.uniformMatrix4fv(this._texLocs.u_matrix, false, matrix); 2453 gl.uniform1i(this._texLocs.u_texture, 0); 2454 gl.activeTexture(gl.TEXTURE0); 2455 2456 // Batch by icon image 2457 const batches = new Map(); 2458 const textItems = []; 2459 2460 for (const f of features) { 2461 if (!f.geometry) continue; 2462 let lng, lat; 2463 if (f.geometry.type === "Point") { 2464 const coords = f.geometry.coordinates; 2465 lng = coords[0]; 2466 lat = coords[1]; 2467 } else if (f.geometry.type === "Polygon") { 2468 const ring = f.geometry.coordinates[0]; 2469 if (!ring || ring.length === 0) continue; 2470 let sx = 0, 2471 sy = 0, 2472 n = ring.length; 2473 if ( 2474 n > 1 && 2475 ring[0][0] === ring[n - 1][0] && 2476 ring[0][1] === ring[n - 1][1] 2477 ) 2478 n--; 2479 for (let i = 0; i < n; i++) { 2480 sx += ring[i][0]; 2481 sy += ring[i][1]; 2482 } 2483 lng = sx / n; 2484 lat = sy / n; 2485 } else if (f.geometry.type === "MultiPolygon") { 2486 const ring = f.geometry.coordinates[0]?.[0]; 2487 if (!ring || ring.length === 0) continue; 2488 let sx = 0, 2489 sy = 0, 2490 n = ring.length; 2491 if ( 2492 n > 1 && 2493 ring[0][0] === ring[n - 1][0] && 2494 ring[0][1] === ring[n - 1][1] 2495 ) 2496 n--; 2497 for (let i = 0; i < n; i++) { 2498 sx += ring[i][0]; 2499 sy += ring[i][1]; 2500 } 2501 lng = sx / n; 2502 lat = sy / n; 2503 } else { 2504 continue; 2505 } 2506 const px = this._lngToPx(lng, ws); 2507 const py = this._latToPy(lat, ws); 2508 const fs = this._getFeatureState(src, f); 2509 2510 if (iconImageExpr) { 2511 const name = String(evalProp(iconImageExpr, f, fs, "")); 2512 const size = evalProp(iconSizeExpr, f, fs, 1); 2513 let opa = evalProp(iconOpacityExpr, f, fs, 1); 2514 if (typeof opa !== "number") opa = 1; 2515 let ox = 0, 2516 oy = 0; 2517 if (iconOffset) { 2518 const o = evalProp(iconOffset, f, fs, [0, 0]); 2519 if (Array.isArray(o)) { 2520 ox = o[0] * size; 2521 oy = o[1] * size; 2522 } 2523 } 2524 let batch = batches.get(name); 2525 if (!batch) { 2526 batch = []; 2527 batches.set(name, batch); 2528 } 2529 batch.push(px, py, opa, size, ox, oy); 2530 } 2531 2532 if (textFieldExpr) { 2533 const txt = evalProp(textFieldExpr, f, fs, ""); 2534 if (txt) { 2535 const tSize = evalProp(textSizeExpr, f, fs, 14); 2536 const tCol = evalProp(textColorExpr, f, fs, "#000"); 2537 const hCol = evalProp(textHaloC, f, fs, "#fff"); 2538 const hW = evalProp(textHaloW, f, fs, 0); 2539 const tOpa = evalProp(textOpacityExpr, f, fs, 1); 2540 let tox = 0, 2541 toy = 0; 2542 const to = layer.layout["text-offset"]; 2543 if (to) { 2544 const o = evalProp(to, f, fs, [0, 0]); 2545 if (Array.isArray(o)) { 2546 tox = o[0] * tSize; 2547 toy = o[1] * tSize; 2548 } 2549 } 2550 textItems.push({ 2551 px, 2552 py, 2553 txt, 2554 tSize, 2555 tCol,
2556 hCol, 2557 hW, 2558 tOpa, 2559 tox, 2560 toy, 2561 }); 2562 } 2563 } 2564 } 2565 2566 // Draw icon batches 2567 for (const [name, items] of batches) { 2568 const te = this._texCache.get("img:" + name); 2569 if (!te) continue; 2570 const itemCount = items.length / 6; 2571 const arr = this._getReusableFloat32( 2572 this._symbolVertexCache, 2573 name, 2574 itemCount * 30, 2575 ); 2576 let oi = 0; 2577 for (let j = 0; j < items.length; j += 6) { 2578 const size = items[j + 3]; 2579 const hw = (te.w * size) / 2; 2580 const hh = (te.h * size) / 2; 2581 const cx = items[j] + items[j + 4], 2582 cy = items[j + 1] + items[j + 5]; 2583 const o = items[j + 2]; 2584 arr[oi++] = cx - hw; 2585 arr[oi++] = cy - hh; 2586 arr[oi++] = 0; 2587 arr[oi++] = 0; 2588 arr[oi++] = o; 2589 arr[oi++] = cx + hw; 2590 arr[oi++] = cy - hh; 2591 arr[oi++] = 1; 2592 arr[oi++] = 0; 2593 arr[oi++] = o; 2594 arr[oi++] = cx - hw; 2595 arr[oi++] = cy + hh; 2596 arr[oi++] = 0; 2597 arr[oi++] = 1; 2598 arr[oi++] = o; 2599 arr[oi++] = cx + hw; 2600 arr[oi++] = cy - hh; 2601 arr[oi++] = 1; 2602 arr[oi++] = 0; 2603 arr[oi++] = o; 2604 arr[oi++] = cx + hw; 2605 arr[oi++] = cy + hh; 2606 arr[oi++] = 1; 2607 arr[oi++] = 1; 2608 arr[oi++] = o; 2609 arr[oi++] = cx - hw; 2610 arr[oi++] = cy + hh; 2611 arr[oi++] = 0; 2612 arr[oi++] = 1; 2613 arr[oi++] = o; 2614 } 2615 gl.bindBuffer(gl.ARRAY_BUFFER, this._dynBuffer); 2616 gl.bufferData( 2617 gl.ARRAY_BUFFER, 2618 arr.subarray(0, oi), 2619 gl.DYNAMIC_DRAW, 2620 ); 2621 const S = 20; 2622 gl.enableVertexAttribArray(this._texLocs.a_pos); 2623 gl.vertexAttribPointer( 2624 this._texLocs.a_pos, 2625 2, 2626 gl.FLOAT, 2627 false, 2628 S, 2629 0, 2630 ); 2631 gl.enableVertexAttribArray(this._texLocs.a_uv); 2632 gl.vertexAttribPointer( 2633 this._texLocs.a_uv, 2634 2, 2635 gl.FLOAT, 2636 false, 2637 S, 2638 8, 2639 ); 2640 gl.enableVertexAttribArray(this._texLocs.a_opacity); 2641 gl.vertexAttribPointer( 2642 this._texLocs.a_opacity, 2643 1, 2644 gl.FLOAT, 2645 false, 2646 S, 2647 16, 2648 ); 2649 gl.bindTexture(gl.TEXTURE_2D, te.tex); 2650 gl.drawArrays(gl.TRIANGLES, 0, oi / 5); 2651 } 2652 2653 // Draw text 2654 if (textItems.length) this._renderTextItems(gl, matrix, textItems); 2655 } 2656 2657 _getFeatureState(src, feature) { 2658 if (!src._featureStates?.size) return null; 2659 let id = null; 2660 if (src.promoteId && feature.properties) 2661 id = feature.properties[src.promoteId]; 2662 else if (feature.id !== undefined) id = feature.id; 2663 else if (feature.properties?.id !== undefined) 2664 id = feature.properties.id; 2665 return id != null ? src._featureStates.get(id) || null : null; 2666 } 2667 2668 _renderTextItems(gl, matrix, items) { 2669 gl.useProgram(this._progTex); 2670 gl.uniformMatrix4fv(this._texLocs.u_matrix, false, matrix); 2671 gl.uniform1i(this._texLocs.u_texture, 0); 2672 gl.activeTexture(gl.TEXTURE0); 2673 2674 for (const it of items) { 2675 const key = `txt:${it.txt}:${it.tSize}:${it.tCol}:${it.hCol}:${it.hW}`; 2676 let te = this._texCache.get(key); 2677 if (!te) { 2678 const r = renderTextToCanvas( 2679 it.txt, 2680 it.tSize, 2681 it.tCol, 2682 it.hCol, 2683 it.hW, 2684 ); 2685 const c = document.createElement("canvas"); 2686 c.width = r.width; 2687 c.height = r.height; 2688 c.getContext("2d").drawImage( 2689 r.canvas, 2690 0, 2691 0, 2692 r.width, 2693 r.height, 2694 0, 2695 0, 2696 r.width, 2697 r.height, 2698 ); 2699 te = this._texCache.upload(key, c); 2700 } 2701 const dpr = window.devicePixelRatio || this._dpr || 1; 2702 const hw = te.w / dpr / 2, 2703 hh = te.h / dpr / 2; 2704 const cx = it.px + it.tox, 2705 cy = it.py + it.toy; 2706 this._drawTexQuad( 2707 gl, 2708 te.tex, 2709 cx - hw, 2710 cy - hh, 2711 cx + hw, 2712 cy + hh, 2713 it.tOpa, 2714 ); 2715 } 2716 } 2717 2718 /* ââ Circle layer âââââââââââââââââââââââââââââââââââââââââââââââ */ 2719 _renderCircleLayer(gl, matrix, src, layer) { 2720 const features = this._getVisibleFeaturesForSource(src); 2721 if (!features.length) return; 2722 const ws = this._ws; 2723 2724 gl.useProgram(this._progCircle); 2725 gl.uniformMatrix4fv(this._circleLocs.u_matrix, false, matrix
2725); 2726 gl.uniform1f(this._circleLocs.u_pixelRatio, 1); 2727 2728 const rExpr = layer.paint["circle-radius"] ?? 5; 2729 const cExpr = layer.paint["circle-color"] ?? "#000"; 2730 const oExpr = layer.paint["circle-opacity"] ?? 1; 2731 const swExpr = layer.paint["circle-stroke-width"] ?? 0; 2732 const scExpr = layer.paint["circle-stroke-color"] ?? "#000"; 2733 const soExpr = layer.paint["circle-stroke-opacity"] ?? 1; 2734 2735 const needFloats = features.length * 12; 2736 if (this._circleVertexBuffer.length < needFloats) { 2737 this._circleVertexBuffer = new Float32Array(needFloats); 2738 } 2739 const arr = this._circleVertexBuffer; 2740 let oi = 0; 2741 for (const f of features) { 2742 if (!f.geometry || f.geometry.type !== "Point") continue; 2743 const [lng, lat] = f.geometry.coordinates; 2744 const fs = this._getFeatureState(src, f); 2745 const rad = evalProp(rExpr, f, fs, 5); 2746 const col = colorToVec4(evalProp(cExpr, f, fs, "#000")); 2747 let opa = evalProp(oExpr, f, fs, 1); 2748 if (typeof opa !== "number") opa = 1; 2749 const sw = evalProp(swExpr, f, fs, 0); 2750 const sc = colorToVec4(evalProp(scExpr, f, fs, "#000")); 2751 let so = evalProp(soExpr, f, fs, 1); 2752 if (typeof so !== "number") so = 1; 2753 if (opa <= 0 && so <= 0) continue; 2754 arr[oi++] = this._lngToPx(lng, ws); 2755 arr[oi++] = this._latToPy(lat, ws); 2756 arr[oi++] = rad; 2757 arr[oi++] = col[0]; 2758 arr[oi++] = col[1]; 2759 arr[oi++] = col[2]; 2760 arr[oi++] = opa * col[3]; 2761 arr[oi++] = sw; 2762 arr[oi++] = sc[0]; 2763 arr[oi++] = sc[1]; 2764 arr[oi++] = sc[2]; 2765 arr[oi++] = so * sc[3]; 2766 } 2767 if (oi === 0) return; 2768 2769 gl.bindBuffer(gl.ARRAY_BUFFER, this._dynCircleBuffer); 2770 gl.bufferData( 2771 gl.ARRAY_BUFFER, 2772 arr.subarray(0, oi), 2773 gl.DYNAMIC_DRAW, 2774 ); 2775 const S = 48; // 12Ã4 2776 gl.enableVertexAttribArray(this._circleLocs.a_pos); 2777 gl.vertexAttribPointer( 2778 this._circleLocs.a_pos, 2779 2, 2780 gl.FLOAT, 2781 false, 2782 S, 2783 0, 2784 ); 2785 gl.enableVertexAttribArray(this._circleLocs.a_radius); 2786 gl.vertexAttribPointer( 2787 this._circleLocs.a_radius, 2788 1, 2789 gl.FLOAT, 2790 false, 2791 S, 2792 8, 2793 ); 2794 gl.enableVertexAttribArray(this._circleLocs.a_color); 2795 gl.vertexAttribPointer( 2796 this._circleLocs.a_color, 2797 4, 2798 gl.FLOAT, 2799 false, 2800 S, 2801 12, 2802 ); 2803 gl.enableVertexAttribArray(this._circleLocs.a_strokeWidth); 2804 gl.vertexAttribPointer( 2805 this._circleLocs.a_strokeWidth, 2806 1, 2807 gl.FLOAT, 2808 false, 2809 S, 2810 28, 2811 ); 2812 gl.enableVertexAttribArray(this._circleLocs.a_strokeColor); 2813 gl.vertexAttribPointer( 2814 this._circleLocs.a_strokeColor, 2815 4, 2816 gl.FLOAT, 2817 false, 2818 S, 2819 32, 2820 ); 2821 gl.drawArrays(gl.POINTS, 0, oi / 12); 2822 gl.disableVertexAttribArray(this._circleLocs.a_radius); 2823 gl.disableVertexAttribArray(this._circleLocs.a_strokeWidth); 2824 gl.disableVertexAttribArray(this._circleLocs.a_strokeColor); 2825 } 2826 2827 /* ââ Line layer âââââââââââââââââââââââââââââââââââââââââââââââââ */ 2828 _renderLineLayer(gl, matrix, src, layer) { 2829 const features = src.getFeatures(); 2830 if (!features.length) return; 2831 const ws = this._ws; 2832 const lc = colorToVec4(layer.paint["line-color"] || "#000"); 2833 const lw = layer.paint["line-width"] ?? 1; 2834 const lo = layer.paint["line-opacity"] ?? 1; 2835 2836 gl.useProgram(this._progColor); 2837 gl.uniformMatrix4fv(this._colorLocs.u_matrix, false, matrix
2837); 2838 2839 // Count total line segments for pre-allocation 2840 let segCount = 0; 2841 for (const f of features) { 2842 if (!f.geometry) continue; 2843 for (const ring of this._lineCoords(f.geometry)) { 2844 const n = ring.length - 1; 2845 if (n > 0) segCount += n; 2846 } 2847 } 2848 if (segCount === 0) return; 2849 2850 // 6 vertices per segment, 6 floats per vertex = 36 floats per segment 2851 const need = segCount * 36; 2852 if ( 2853 !this._lineVertexBuffer || 2854 this._lineVertexBuffer.length < need 2855 ) { 2856 this._lineVertexBuffer = new Float32Array( 2857 Math.max(need, (this._lineVertexBuffer?.length || 0) * 2), 2858 ); 2859 } 2860 const arr = this._lineVertexBuffer; 2861 let oi = 0; 2862 2863 const r = lc[0], 2864 g = lc[1], 2865 b = lc[2], 2866 a = lc[3] * lo; 2867 for (const f of features) { 2868 if (!f.geometry) continue; 2869 for (const ring of this._lineCoords(f.geometry)) { 2870 for (let i = 0; i < ring.length - 1; i++) { 2871 const px0 = this._lngToPx(ring[i][0], ws); 2872 const py0 = this._latToPy(ring[i][1], ws); 2873 const px1 = this._lngToPx(ring[i + 1][0], ws); 2874 const py1 = this._latToPy(ring[i + 1][1], ws); 2875 const dx = px1 - px0, 2876 dy = py1 - py0; 2877 const len = Math.sqrt(dx * dx + dy * dy); 2878 if (len < 0.001) continue; 2879 const nx = ((-dy / len) * lw) / 2, 2880 ny = ((dx / len) * lw) / 2; 2881 arr[oi++] = px0 + nx; 2882 arr[oi++] = py0 + ny; 2883 arr[oi++] = r; 2884 arr[oi++] = g; 2885 arr[oi++] = b; 2886 arr[oi++] = a; 2887 arr[oi++] = px0 - nx; 2888 arr[oi++] = py0 - ny; 2889 arr[oi++] = r; 2890 arr[oi++] = g; 2891 arr[oi++] = b; 2892 arr[oi++] = a; 2893 arr[oi++] = px1 + nx; 2894 arr[oi++] = py1 + ny; 2895 arr[oi++] = r; 2896 arr[oi++] = g; 2897 arr[oi++] = b; 2898 arr[oi++] = a; 2899 arr[oi++] = px0 - nx; 2900 arr[oi++] = py0 - ny; 2901 arr[oi++] = r; 2902 arr[oi++] = g; 2903 arr[oi++] = b; 2904 arr[oi++] = a; 2905 arr[oi++] = px1 - nx; 2906 arr[oi++] = py1 - ny; 2907 arr[oi++] = r; 2908 arr[oi++] = g; 2909 arr[oi++] = b; 2910 arr[oi++] = a; 2911 arr[oi++] = px1 + nx; 2912 arr[oi++] = py1 + ny; 2913 arr[oi++] = r; 2914 arr[oi++] = g; 2915 arr[oi++] = b; 2916 arr[oi++] = a; 2917 } 2918 } 2919 } 2920 if (oi === 0) return; 2921 gl.bindBuffer(gl.ARRAY_BUFFER, this._dynColorBuffer); 2922 gl.bufferData( 2923 gl.ARRAY_BUFFER, 2924 arr.subarray(0, oi), 2925 gl.DYNAMIC_DRAW, 2926 ); 2927 const S = 24; 2928 gl.enableVertexAttribArray(this._colorLocs.a_pos); 2929 gl.vertexAttribPointer( 2930 this._colorLocs.a_pos, 2931 2, 2932 gl.FLOAT, 2933 false, 2934 S, 2935 0, 2936 ); 2937 gl.enableVertexAttribArray(this._colorLocs.a_color); 2938 gl.vertexAttribPointer( 2939 this._colorLocs.a_color, 2940 4, 2941 gl.FLOAT, 2942 false, 2943 S, 2944 8, 2945 ); 2946 gl.drawArrays(gl.TRIANGLES, 0, oi / 6); 2947 } 2948 2949 _lineCoords(geom) { 2950 switch (geom.type) { 2951 case "Polygon": 2952 return geom.coordinates; 2953 case "MultiPolygon": 2954 return geom.coordinates.flat(); 2955 case "LineString": 2956 return [geom.coordinates]; 2957 case "MultiLineString": 2958 return geom.coordinates; 2959 default: 2960 return []; 2961 } 2962 } 2963 2964 /* ââ Fill layer âââââââââââââââââââââââââââââââââââââââââââââââââ */ 2965 _renderFillLayer(gl, matrix, src, layer) { 2966 const features = src.getFeatures(); 2967 if (!features.length) return; 2968 const ws = this._ws; 2969 const fc = colorToVec4(layer.paint["fill-color"] || "#000"); 2970 const fo = layer.paint["fill-opacity"] ?? 1; 2971 2972 gl.useProgram(this._progColor); 2973 gl.uniformMatrix4fv(this._colorLocs.u_matrix, false, matrix
2973); 2974 2975 const data = []; 2976 for (const f of features) { 2977 if (!f.geometry) continue; 2978 let rings = []; 2979 if (f.geometry.type === "Polygon") 2980 rings = f.geometry.coordinates; 2981 else if (f.geometry.type === "MultiPolygon") 2982 for (const p of f.geometry.coordinates) rings.push(...p); 2983 2984 for (const ring of rings) { 2985 const pts = ring.map((c) => [ 2986 this._lngToPx(c[0], ws), 2987 this._latToPy(c[1], ws), 2988 ]); 2989 const tri = triangulate(pts); 2990 const r = fc[0], 2991 g = fc[1], 2992 b = fc[2], 2993 a = fc[3] * fo; 2994 for (let i = 0; i < tri.length; i += 2) 2995 data.push(tri[i], tri[i + 1], r, g, b, a); 2996 } 2997 } 2998 if (!data.length) return; 2999 const arr = new Float32Array(data); 3000 gl.bindBuffer(gl.ARRAY_BUFFER, this._dynColorBuffer); 3001 gl.bufferData(gl.ARRAY_BUFFER, arr, gl.DYNAMIC_DRAW); 3002 const S = 24; 3003 gl.enableVertexAttribArray(this._colorLocs.a_pos); 3004 gl.vertexAttribPointer( 3005 this._colorLocs.a_pos, 3006 2, 3007 gl.FLOAT, 3008 false, 3009 S, 3010 0, 3011 ); 3012 gl.enableVertexAttribArray(this._colorLocs.a_color); 3013 gl.vertexAttribPointer( 3014 this._colorLocs.a_color, 3015 4, 3016 gl.FLOAT, 3017 false, 3018 S, 3019 8, 3020 ); 3021 gl.drawArrays(gl.TRIANGLES, 0, arr.length / 6); 3022 } 3023 3024 /* ââ Overlay updates ââââââââââââââââââââââââââââââââââââââââââââ */ 3025 _updateOverlays() { 3026 for (const p of this._popups) p._updatePosition(); 3027 for (const m of this._markers) m._updatePosition(); 3028 } 3029 3030 _getVisibleTileRange() { 3031 const tileZoom = Math.max(0, Math.round(this._zoom)); 3032 const tileWs = worldSize(tileZoom); 3033 const numTiles = Math.pow(2, tileZoom); 3034 const scale = Math.pow(2, this._zoom - tileZoom); 3035 const tilePadding = 1; 3036 3037 const cx = this._lngToPx(this._center[0], tileWs); 3038 const cy = this._latToPy(this._center[1], tileWs); 3039 const hw = this._width / 2 / scale; 3040 const hh = this._height / 2 / scale; 3041 3042 const minTX = Math.max( 3043 0, 3044 Math.floor((cx - hw) / TILE_SIZE) - tilePadding, 3045 ); 3046 const maxTX = Math.min( 3047 numTiles - 1, 3048 Math.floor((cx + hw) / TILE_SIZE) + tilePadding, 3049 ); 3050 const minTY = Math.max( 3051 0, 3052 Math.floor((cy - hh) / TILE_SIZE) - tilePadding, 3053 ); 3054 const maxTY = Math.min( 3055 numTiles - 1, 3056 Math.floor((cy + hh) / TILE_SIZE) + tilePadding, 3057 ); 3058 3059 return { tileZoom, minTX, maxTX, minTY, maxTY }; 3060 } 3061 3062 _getVisibleFeaturesForSource(src) { 3063 if (!(src instanceof GeoJSONSource)) return []; 3064 if (!src.shouldOptimizeMarkers(100)) { 3065 this._sourceViewCache.delete(src.id); 3066 return src.getFeatures(); 3067 } 3068 3069 const vr = this._getVisibleTileRange(); 3070 const tileKey = `${vr.tileZoom}:${vr.minTX}:${vr.maxTX}:${vr.minTY}:${vr.maxTY}`; 3071 let cache = this._sourceViewCache.get(src.id); 3072 3073 if ( 3074 cache && 3075 cache.dataVersion === src._dataVersion && 3076 cache.appliedTileKey === tileKey 3077 ) { 3078 return cache.features; 3079 } 3080 3081 const features = src.getFeaturesForTileRange( 3082 vr.tileZoom, 3083 vr.minTX, 3084 vr.maxTX, 3085 vr.minTY, 3086 vr.maxTY, 3087 this._wr, 3088 ); 3089 3090 if (!cache) { 3091 cache = { 3092 appliedTileKey: tileKey, 3093 dataVersion: src._dataVersion, 3094 features, 3095 }; 3096 this._sourceViewCache.set(src.id, cache); 3097 } else {
3098 cache.appliedTileKey = tileKey; 3099 cache.dataVersion = src._dataVersion; 3100 cache.features = features; 3101 } 3102 return features; 3103 } 3104 3105 /* ââ Cleanup ââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 3106 remove() { 3107 this._loopRunning = false; 3108 if (this._frameId) cancelAnimationFrame(this._frameId); 3109 if (this._resizeObserver) this._resizeObserver.disconnect(); 3110 if (this._interactionRestoreTimer) 3111 clearTimeout(this._interactionRestoreTimer); 3112 this._canvas.remove(); 3113 this._overlayContainer.remove(); 3114 for (const k in this._controlContainers) 3115 this._controlContainers[k].remove(); 3116 } 3117} 3118 3119// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3120// §13 POPUP 3121// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3122class GFlatMapPopup { 3123 constructor(options) { 3124 this._options = options || {}; 3125 this._offset = this._options.offset || 0; 3126 this._lngLat = null; 3127 this._html = ""; 3128 this._map = null; 3129 this._el = null; 3130 this._handlers = {}; 3131 this._isOpen = false; 3132 } 3133 setLngLat(ll) { 3134 this._lngLat = Array.isArray(ll) ? { lng: ll[0], lat: ll[1] } : ll; 3135 if (this._map) this._updatePosition(); 3136 return this; 3137 } 3138 setHTML(html) { 3139 this._html = html; 3140 if (this._el) { 3141 this._el.querySelector(".gflatmap-popup-content").innerHTML = 3142 `<button class="maplibregl-popup-close-button gflatmap-popup-close" 3143 style="position:absolute;top:10px;right:10px;background:none;border:none; 3144 font-size:24px;cursor:pointer;z-index:1;">×</button>${html}`; 3145 this._el 3146 .querySelector(".gflatmap-popup-close") 3147 .addEventListener("click", () => this.remove()); 3148 } 3149 return this; 3150 } 3151 addTo(map) { 3152 this._map = map; 3153 this._isOpen = true; 3154 map._popups.add(this); 3155 this._el = document.createElement("div"); 3156 this._el.className = "maplibregl-popup gflatmap-popup"; 3157 this._el.style.cssText = 3158 "position:absolute;left:0;top:0;pointer-events:auto;z-index:10;"; 3159 this._el.innerHTML = ` 3160 <div class="maplibregl-popup-tip gflatmap-tip-top"></div> 3161 <div class="maplibregl-popup-content gflatmap-popup-content" 3162 style="background:#fff;border-radius:4px;padding:10px; 3163 box-shadow:0 2px 10px rgba(0,0,0,.3);max-width:300px;position:relative;"> 3164 <button class="maplibregl-popup-close-button gflatmap-popup-close" 3165 style="position:absolute;top:10px;right:10px;background:none;border:none; 3166 font-size:24px;cursor:pointer;z-index:1;">×</button> 3167 ${this._html} 3168 </div>`; 3169 this._el 3170 .querySelector(".gflatmap-popup-close") 3171 .addEventListener("click", () => this.remove()); 3172 map._overlayContainer.appendChild(this._el); 3173 this._updatePosition(); 3174 map._setDirty(); 3175 return this; 3176 } 3177 remove() { 3178 if (this._el?.parentNode) 3179 this._el.parentNode.removeChild(this._el); 3180 if (this._map) this._map._popups.delete(this); 3181 this._isOpen = false; 3182 this._fireEvent("close"); 3183 return this; 3184 } 3185 isOpen() { 3186 return this._isOpen; 3187 } 3188 on(evt, fn) { 3189 (this._handlers[evt] ||= []).push(fn); 3190 return this; 3191 }
3192 off(evt, fn) { 3193 const a = this._handlers[evt]; 3194 if (a) { 3195 const i = a.indexOf(fn); 3196 if (i >= 0) a.splice(i, 1); 3197 } 3198 return this; 3199 } 3200 _fireEvent(evt) { 3201 (this._handlers[evt] || []).forEach((fn) => fn()); 3202 } 3203 _updatePosition() { 3204 if (!this._map || !this._lngLat || !this._el) return; 3205 const s = this._map.project([this._lngLat.lng, this._lngLat.lat]); 3206 const off = typeof this._offset === "number" ? this._offset : 0; 3207 const container = this._map._overlayContainer; 3208 const cw = container.clientWidth; 3209 const ch = container.clientHeight; 3210 3211 // Measure popup size (reset position so measurement is accurate) 3212 this._el.style.left = "0px"; 3213 this._el.style.top = "0px"; 3214 this._el.style.transform = "none"; 3215 const pw = this._el.offsetWidth; 3216 const ph = this._el.offsetHeight; 3217 3218 const tipEl = this._el.querySelector(".maplibregl-popup-tip"); 3219 const tipH = 10; // tip triangle height 3220 const margin = 4; 3221 3222 // Determine vertical anchor: prefer above marker, fall back to below 3223 let anchorY, ty; 3224 if (s.y - off - ph - tipH >= margin) { 3225 anchorY = "top"; 3226 ty = s.y - off - ph - tipH; 3227 } else { 3228 anchorY = "bottom"; 3229 ty = s.y + off + tipH; 3230 } 3231 3232 // Horizontal: center on marker, then clamp within viewport 3233 let tx = s.x - pw / 2; 3234 tx = clamp(tx, margin, Math.max(margin, cw - pw - margin)); 3235 ty = clamp(ty, margin, Math.max(margin, ch - ph - margin)); 3236 3237 // Apply position via left/top (not transform, to avoid origin issues) 3238 this._el.style.left = tx + "px"; 3239 this._el.style.top = ty + "px"; 3240 3241 // Point the tip arrow at the marker's screen-x, relative to the popup box 3242 if (tipEl) { 3243 const tipX = clamp(s.x - tx, 12, Math.max(12, pw - 12)); 3244 tipEl.className = 3245 anchorY === "top" 3246 ? "maplibregl-popup-tip gflatmap-tip-top" 3247 : "maplibregl-popup-tip gflatmap-tip-bottom"; 3248 tipEl.style.left = tipX + "px"; 3249 } 3250 } 3251} 3252 3253// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3254// §14 MARKER (HTML overlay) 3255// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3256class GFlatMapMarker { 3257 constructor(options) { 3258 this._options = options || {}; 3259 this._lngLat = { lng: 0, lat: 0 }; 3260 this._rotation = 0; 3261 this._map = null; 3262 this._interactive = this._options.interactive !== false; 3263 3264 this._el = 3265 this._options.element || 3266 (() => { 3267 const d = document.createElement("div"); 3268 d.innerHTML = 3269 '<svg width="24" height="36" viewBox="0 0 24 36">' + 3270 '<path d="M12 0C5.4 0 0 5.4 0 12c0 9 12 24 12 24s12-15 12-24C24 5.4 18.6 0 12 0z"' + 3271 ' fill="#e74c3c" stroke="#fff" stroke-width="2"/>' + 3272 '<circle cx="12" cy="12" r="5" fill="#fff"/></svg>'; 3273 return d; 3274 })(); 3275 const pointerEvents = this._interactive ? "auto" : "none"; 3276 const cursor = this._interactive ? "pointer" : "default"; 3277 this._el.style.cssText += `;pointer-events:${pointerEvents};cursor:${cursor};line-height:0;`; 3278 3279 this._wrapper = document.createElement("div"); 3280 this._wrapper.style.cssText = `position:absolute;pointer-events:${pointerEvents};z-index:5;`; 3281 this._wrapper.appendChild(this._el); 3282 } 3283 setLngLat(ll) { 3284 this._lngLat = Array.isArray(ll) ? { lng: ll[0], lat: ll[1] } : ll; 3285 if (this._map) this._updatePosition(); 3286 return this; 3287 } 3288 getLngLat() { 3289 return { lng: this._lngLat.lng, lat: this._lngLat.lat }; 3290 } 3291 getElement() { 3292 return this._el; 3293 } 3294 setRotation(deg) { 3295 this._rotation = deg; 3296 if (this._el) this._el.style.transform = `rotate(${deg}deg)`; 3297 return this; 3298 } 3299 addTo(map) { 3300 this._map = map; 3301 map._markers.add(this); 3302 map._overlayContainer.appendChild(this._wrapper); 3303 this._updatePosition(); 3304 map._setDirty(); 3305 return this; 3306 } 3307 remove() { 3308 if (this._wrapper.parentNode) 3309 this._wrapper.parentNode.removeChild(this._wrapper); 3310 if (this._map) this._map._markers.delete(this); 3311 return this; 3312 } 3313 _updatePosition() { 3314 if (!this._map) return; 3315 const s = this._map.project([this._lngLat.lng, this._lngLat.lat]); 3316 this._wrapper.style.transform = `translate(${s.x}px,${s.y}px) translate(-50%,-50%)`; 3317 } 3318} 3319 3320// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3321// §15 NAVIGATION CONTROL 3322// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3323class GFlatMapNavigationControl { 3324 constructor(options) { 3325 this._options = options || {}; 3326 }
3327 onAdd(map) { 3328 this._map = map; 3329 const c = document.createElement("div"); 3330 c.className = "gflatmap-ctrl-nav"; 3331 c.style.cssText = 3332 "display:flex;flex-direction:column;background:#fff;border-radius:4px;" + 3333 "box-shadow:0 1px 4px rgba(0,0,0,.3);overflow:hidden;"; 3334 const bs = 3335 "width:30px;height:30px;border:none;background:#fff;cursor:pointer;" + 3336 "font-size:18px;display:flex;align-items:center;justify-content:center;"; 3337 const zi = document.createElement("button"); 3338 zi.innerHTML = "+"; 3339 zi.style.cssText = bs + "border-bottom:1px solid #eee;"; 3340 zi.title = "Zoom in"; 3341 zi.addEventListener("click", () => 3342 map.easeTo({ zoom: map.getZoom() + 1 }), 3343 ); 3344 const zo = document.createElement("button"); 3345 zo.innerHTML = "â"; 3346 zo.style.cssText = bs; 3347 zo.title = "Zoom out"; 3348 zo.addEventListener("click", () => 3349 map.easeTo({ zoom: map.getZoom() - 1 }), 3350 ); 3351 c.appendChild(zi); 3352 c.appendChild(zo); 3353 return c; 3354 } 3355} 3356 3357// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3358// §16 FULLSCREEN CONTROL 3359// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3360class GFlatMapFullscreenControl { 3361 constructor() {} 3362 onAdd(map) { 3363 const btn = document.createElement("button"); 3364 btn.className = "gflatmap-ctrl-fullscreen"; 3365 btn.style.cssText = 3366 "width:30px;height:30px;border:none;background:#fff;border-radius:4px;" + 3367 "cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,.3);display:flex;align-items:center;" + 3368 "justify-content:center;font-size:14px;"; 3369 btn.innerHTML = "â¶"; 3370 btn.title = "Toggle fullscreen"; 3371 btn.addEventListener("click", () => { 3372 if (document.fullscreenElement) document.exitFullscreen(); 3373 else map.getContainer().requestFullscreen(); 3374 }); 3375 return btn; 3376 } 3377} 3378 3379// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3380// §17 EXPORT â also set window.maplibregl for drop-in compat 3381// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3382const GFlatMap = { 3383 Map: GFlatMapMap, 3384 Popup: GFlatMapPopup, 3385 Marker: GFlatMapMarker, 3386 NavigationControl: GFlatMapNavigationControl, 3387 FullscreenControl: GFlatMapFullscreenControl, 3388 lngToPixelX, 3389 latToPixelY, 3390 pixelXToLng, 3391 pixelYToLat, 3392 worldSize, 3393}; 3394 3395window.GFlatMap = GFlatMap; 3396window.maplibregl = GFlatMap; 3397 3398export default GFlatMap; 3399export { 3400 GFlatMapMap as Map, 3401 GFlatMapPopup as Popup, 3402 GFlatMapMarker as Marker, 3403 GFlatMapNavigationControl as NavigationControl, 3404 GFlatMapFullscreenControl as FullscreenControl, 3405};
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.