PageSourceSearch

https://wwmmap.pages.dev/js/map_2026/gflatmap.js?v=1

js wwmmap.pages.dev collected 2026-10-03 06:05:50 UTC 128,101 bytes, 3,405 lines download raw bytes

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;">&times;</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;">&times;</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.