ixmaps-gl 0.2.30 → 0.2.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +39 -7
  2. package/ixmaps-gl.js +1129 -128
  3. package/package.json +1 -1
package/ixmaps-gl.js CHANGED
@@ -100,7 +100,7 @@
100
100
  // ---------------------------------------------------------------
101
101
  // this engine's release (= package.json "version", checked by
102
102
  // test/unit/version.test.mjs); ixmaps.glVersion, and logged once at start
103
- const IXMAPS_GL_VERSION = '0.2.30';
103
+ const IXMAPS_GL_VERSION = '0.2.33';
104
104
 
105
105
  const LIB_URLS = {
106
106
  // Bumped 3.6.2 -> 5.x (2026-09-21, globe-projection compat fix): native
@@ -135,6 +135,11 @@
135
135
  // supplied by MapLibre GL JS itself, nothing this engine manages).
136
136
  deck: 'https://unpkg.com/deck.gl@9.4.0/dist.min.js',
137
137
  mustache: 'https://unpkg.com/mustache@4.2.0/mustache.min.js',
138
+ // .type("KDE") draws its bands with d3-contour; loaded on the first KDE theme only, and evaluated
139
+ // privately (ensureD3Contour) — never as a script tag: d3-array would overwrite the functions of
140
+ // a d3 v3 the page may already have (flat's user charts load d3.v3)
141
+ d3Array: 'https://cdn.jsdelivr.net/npm/d3-array@3.2.4/dist/d3-array.min.js',
142
+ d3Contour: 'https://cdn.jsdelivr.net/npm/d3-contour@4.0.2/dist/d3-contour.min.js',
138
143
  // loaded by default, as flat does (its resource list loads
139
144
  // ../data.js/data.js — from its CDN build, this URL); every data format
140
145
  // except GeoJSON/TopoJSON is read by it, see fetchLayerData
@@ -217,7 +222,7 @@
217
222
  // byte-grep of the shipped file (deck.gl's own @deck.gl/maplibre
218
223
  // overview page claims ES-modules-only, which the actual UMD bundle
219
224
  // contradicts).
220
- let IconLayer, ScatterplotLayer, GeoJsonLayer, MapLibreOverlay, TextLayer, PathLayer, PathStyleExtension;
225
+ let IconLayer, ScatterplotLayer, GeoJsonLayer, MapLibreOverlay, TextLayer, PathLayer, PathStyleExtension, HeatmapLayer;
221
226
  // PieSliceLayer (see makePieSliceLayer) and BarShapeLayer
222
227
  // (makeBarShapeLayer) are derived from ScatterplotLayer once deck.gl is there
223
228
  let PieSliceLayer, BarShapeLayer, FlowPathLayer;
@@ -608,7 +613,20 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
608
613
  global.Mustache ? Promise.resolve() : loadScript(LIB_URLS.mustache),
609
614
  [...document.styleSheets].some(s => s.href === LIB_URLS.maplibreCss) ? Promise.resolve() : loadStylesheet(LIB_URLS.maplibreCss)
610
615
  ]).then(() => {
611
- ({ IconLayer, ScatterplotLayer, GeoJsonLayer, MapLibreOverlay, TextLayer, PathLayer, PathStyleExtension } = global.deck);
616
+ ({ IconLayer, ScatterplotLayer, GeoJsonLayer, MapLibreOverlay, TextLayer, PathLayer, PathStyleExtension, HeatmapLayer } = global.deck);
617
+ // deck.gl 9 gamma-encodes the layer-level `opacity` prop in its `layer` shader module
618
+ // (Math.pow(opacity, 1/2.2); deck 8 and flat's SVG fill-opacity are linear), so a declared
619
+ // fillopacity 0.2 would draw as 0.48. Hand deck opacity ** 2.2: it draws exactly the declared alpha.
620
+ // Alpha inside a color array (getFillColor [r,g,b,a]) is linear and stays as it is.
621
+ const linearOpacity = Cls => new Proxy(Cls, {
622
+ construct(target, args, newTarget) {
623
+ const fixed = args.map(p => (p && typeof p === 'object' && typeof p.opacity === 'number' && p.opacity >= 0 && p.opacity < 1)
624
+ ? Object.assign({}, p, { opacity: Math.pow(p.opacity, 2.2) }) : p);
625
+ return Reflect.construct(target, fixed, newTarget);
626
+ }
627
+ });
628
+ [IconLayer, ScatterplotLayer, GeoJsonLayer, TextLayer, PathLayer, HeatmapLayer] =
629
+ [IconLayer, ScatterplotLayer, GeoJsonLayer, TextLayer, PathLayer, HeatmapLayer].map(linearOpacity);
612
630
  PieSliceLayer = makePieSliceLayer(ScatterplotLayer);
613
631
  BarShapeLayer = makeBarShapeLayer(ScatterplotLayer);
614
632
  FlowPathLayer = makeFlowPathLayer(PathLayer);
@@ -1856,12 +1874,31 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1856
1874
  })(g && g.coordinates);
1857
1875
  return minX <= maxX ? [(minX + maxX) / 2, (minY + maxY) / 2] : null;
1858
1876
  }
1877
+ // flat finds a geometry id field whatever its letter case (the comuni topojson has "pro_com_t", the page
1878
+ // binds id "pro_com_T"): the property name of a FEATURE runtime's shapes that matches the binding, else
1879
+ // the binding as written. Cached per runtime and name
1880
+ function geometryIdField(rt, name) {
1881
+ if (!name || !rt) return name;
1882
+ const cache = rt._idFieldCache || (rt._idFieldCache = {});
1883
+ if (cache[name] !== undefined) return cache[name];
1884
+ const feats = rt.features || [];
1885
+ let found = name;
1886
+ const has = f => f.properties && Object.prototype.hasOwnProperty.call(f.properties, name);
1887
+ if (!feats.slice(0, 50).some(has)) {
1888
+ const low = String(name).toLowerCase();
1889
+ for (const f of feats.slice(0, 50)) {
1890
+ const k = f.properties && Object.keys(f.properties).find(key => key.toLowerCase() === low);
1891
+ if (k) { found = k; break; }
1892
+ }
1893
+ }
1894
+ return (cache[name] = found);
1895
+ }
1859
1896
  // id → position of one FEATURE runtime's shapes (flat's getNodePosition,
1860
1897
  // maptheme.js 24702-24784): its shape (flatShapeWins) at flat's shape
1861
1898
  // center (flatShapeCenter)
1862
1899
  function featurePositionIndex(rt) {
1863
1900
  if (rt._positionIndex && rt._positionIndexOf === rt.features) return rt._positionIndex;
1864
- const idField = rt.binding && rt.binding.id;
1901
+ const idField = geometryIdField(rt, rt.binding && rt.binding.id);
1865
1902
  const best = new Map();
1866
1903
  if (idField) {
1867
1904
  for (const f of rt.features) {
@@ -1942,7 +1979,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1942
1979
  function flatShapeAreaKm2(featureRt, f) {
1943
1980
  const sizeField = featureRt.binding && featureRt.binding.size;
1944
1981
  if (sizeField) {
1945
- const v = parseFloat(f.properties && f.properties[sizeField]);
1982
+ const v = scanValue(f.properties && f.properties[sizeField]);
1946
1983
  return isFinite(v) ? v / 1000000 : NaN;
1947
1984
  }
1948
1985
  return geodesicPolygonAreaKm2(f.geometry);
@@ -2001,6 +2038,415 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2001
2038
  return isFinite(o) ? Math.max(0, Math.min(1, o)) : 0;
2002
2039
  }
2003
2040
 
2041
+ // flat's CHART|SYMBOL DOPACITY (maptheme.js 22142-22145): (v-min)^(1/pow) /
2042
+ // (max-min)^(1/pow) · fillopacity(|| 1) · dopacityscale, clamped to 0..1 —
2043
+ // min/max are the theme-wide extremes of the cell values, one pair for all
2044
+ // cells (not the CHOROPLETH|DOPACITY median ramp, not a per-class max)
2045
+ function symbolDopacityAlpha(style, v, min, max, fillopacity) {
2046
+ const pow = 1 / (styleNum(style.dopacitypow) || 1);
2047
+ const scale = styleNum(style.dopacityscale) || 1;
2048
+ const fo = fillopacity || 1;
2049
+ const o = Math.pow(v - min, pow) / Math.pow(max - min, pow) * fo * scale;
2050
+ return isFinite(o) ? Math.max(0, Math.min(1, o)) : fo;
2051
+ }
2052
+
2053
+ // .type("KDE") — kernel density estimate as stacked bands (the fire pages' KDE, done in the
2054
+ // engine). The pure parts: bandwidth by zoom, the points, the density grid, the bands.
2055
+ //
2056
+ // The value of a grid cell is what the pages computed by brute force — the sum over the points
2057
+ // of weight · exp(−d² / 2h²), d the distance in km, h the bandwidth — but here by BINNING each
2058
+ // weight into its four nearest cells (bilinear) and blurring the grid with a separable Gaussian
2059
+ // (σ = h in cells): O(points + cells · kernel) instead of O(cells · points). Thresholds are
2060
+ // fractions of the maximum, so only the shape of the density matters, not its absolute scale.
2061
+
2062
+ // flat zoom → bandwidth (km) and the cell budget of the grid, as the fire pages chose them
2063
+ const KDE_ZOOM_TABLE = [ // [from flat zoom, bandwidth km, max cells]
2064
+ [16, 0.04, 1000000], [15, 0.08, 800000], [14, 0.12, 600000], [13, 0.30, 300000],
2065
+ [12, 0.80, 100000], [11, 2.00, 40000], [10, 5.00, 25000], [9, 10.0, 20000]
2066
+ ];
2067
+ const KDE_FAR_ZOOM = [24.0, 15000];
2068
+ const KDE_DEFAULT_LEVELS = [0.15, 0.30, 0.50, 0.70];
2069
+ const KDE_MIN_POINTS = 5; // fewer points have no density to speak of (the pages' rule)
2070
+ const KDE_MARGIN_SIGMAS = 3; // the grid reaches 3 bandwidths beyond the outermost points
2071
+ const KDE_KM_PER_DEG = 111.3; // the pages' constant
2072
+
2073
+ function kdeZoomRow(flatZoom) {
2074
+ const z = Number(flatZoom);
2075
+ const row = KDE_ZOOM_TABLE.find(r => z >= r[0]);
2076
+ return row ? [row[1], row[2]] : KDE_FAR_ZOOM;
2077
+ }
2078
+ function kdeBandwidthKm(flatZoom) { return kdeZoomRow(flatZoom)[0]; }
2079
+ function kdeMaxCells(flatZoom) { return kdeZoomRow(flatZoom)[1]; }
2080
+
2081
+ // style.kdelevels: the threshold fractions of the maximum, as an array or "0.2,0.5" — numbers in
2082
+ // (0, 1], ascending, each once; anything unusable gives the default
2083
+ function kdeLevels(style) {
2084
+ let v = style && style.kdelevels;
2085
+ if (typeof v === 'string') v = v.split(/[,\s|]+/).filter(Boolean);
2086
+ if (!Array.isArray(v)) return KDE_DEFAULT_LEVELS.slice();
2087
+ const out = [...new Set(v.map(Number).filter(x => isFinite(x) && x > 0 && x <= 1))].sort((a, b) => a - b);
2088
+ return out.length ? out : KDE_DEFAULT_LEVELS.slice();
2089
+ }
2090
+
2091
+ // the records' positions and weights as one Float64Array [lng, lat, weight, …] (cheap to hand to
2092
+ // a worker); `bbox` [west, south, east, north] keeps only what lies inside it
2093
+ function kdePoints(features, binding, bbox) {
2094
+ const out = [];
2095
+ for (const f of features) {
2096
+ const c = f.geometry && f.geometry.coordinates;
2097
+ if (!c || typeof c[0] !== 'number' || !isFinite(c[0]) || !isFinite(c[1])) continue;
2098
+ if (bbox && (c[0] < bbox[0] || c[0] > bbox[2] || c[1] < bbox[1] || c[1] > bbox[3])) continue;
2099
+ const w = heatmapWeight(binding, f.properties || {});
2100
+ if (w > 0) out.push(c[0], c[1], w);
2101
+ }
2102
+ return Float64Array.from(out);
2103
+ }
2104
+
2105
+ // The density grid of `points` ([lng, lat, w, …]) at bandwidth `bwKm`, on about `maxCells` cells
2106
+ // that are square in km. Returns { values (row-major, row j at latitude s + (j + 0.5)·stepLat),
2107
+ // nx, ny, w0, s, stepLng, stepLat, max }, or null for fewer than KDE_MIN_POINTS points. Cells
2108
+ // coarser than the bandwidth still conserve the weights (binning, not sampling).
2109
+ // SELF-CONTAINED on purpose: its source is handed to a Web Worker (Function.prototype.toString),
2110
+ // so it uses nothing from this file.
2111
+ function kdeGrid(points, bwKm, maxCells) {
2112
+ const n = points.length / 3;
2113
+ if (n < 5 || !(bwKm > 0)) return null;
2114
+ const KM = 111.3;
2115
+ let w0 = Infinity, e = -Infinity, s = Infinity, nn = -Infinity;
2116
+ for (let i = 0; i < points.length; i += 3) {
2117
+ const lng = points[i], lat = points[i + 1];
2118
+ if (lng < w0) w0 = lng; if (lng > e) e = lng;
2119
+ if (lat < s) s = lat; if (lat > nn) nn = lat;
2120
+ }
2121
+ const kmLng = Math.max(KM * Math.cos(((s + nn) / 2) * Math.PI / 180), 0.001);
2122
+ const margin = 3 * bwKm;
2123
+ s -= margin / KM; nn += margin / KM; w0 -= margin / kmLng; e += margin / kmLng;
2124
+ const wKm = (e - w0) * kmLng, hKm = (nn - s) * KM;
2125
+ const cell = Math.sqrt(wKm * hKm / Math.max(maxCells, 25));
2126
+ const nx = Math.max(5, Math.ceil(wKm / cell)), ny = Math.max(5, Math.ceil(hKm / cell));
2127
+ const stepLng = (e - w0) / nx, stepLat = (nn - s) / ny;
2128
+
2129
+ // bin: each weight into its four nearest cell centers (cell i is centered at (i + 0.5) · step)
2130
+ let a = new Float32Array(nx * ny);
2131
+ const put = (ix, iy, v) => {
2132
+ if (ix < 0) ix = 0; else if (ix > nx - 1) ix = nx - 1;
2133
+ if (iy < 0) iy = 0; else if (iy > ny - 1) iy = ny - 1;
2134
+ a[iy * nx + ix] += v;
2135
+ };
2136
+ for (let i = 0; i < points.length; i += 3) {
2137
+ const cx = (points[i] - w0) / stepLng - 0.5, cy = (points[i + 1] - s) / stepLat - 0.5;
2138
+ const ix = Math.floor(cx), iy = Math.floor(cy), fx = cx - ix, fy = cy - iy, w = points[i + 2];
2139
+ put(ix, iy, w * (1 - fx) * (1 - fy)); put(ix + 1, iy, w * fx * (1 - fy));
2140
+ put(ix, iy + 1, w * (1 - fx) * fy); put(ix + 1, iy + 1, w * fx * fy);
2141
+ }
2142
+
2143
+ // separable Gaussian, peak 1 (σ in cells per axis, kernel to 3σ; no blur when σ is below a third of a cell)
2144
+ const kernel = sigma => {
2145
+ const r = sigma < 0.33 ? 0 : Math.ceil(3 * sigma);
2146
+ const k = new Float32Array(2 * r + 1);
2147
+ for (let d = -r; d <= r; d++) k[d + r] = Math.exp(-(d * d) / (2 * sigma * sigma));
2148
+ return { k, r };
2149
+ };
2150
+ const kx = kernel(bwKm / (stepLng * kmLng)), ky = kernel(bwKm / (stepLat * KM));
2151
+ let b = new Float32Array(nx * ny);
2152
+ for (let j = 0; j < ny; j++) {
2153
+ const row = j * nx;
2154
+ for (let i = 0; i < nx; i++) {
2155
+ let v = 0;
2156
+ const lo = Math.max(0, i - kx.r), hi = Math.min(nx - 1, i + kx.r);
2157
+ for (let t = lo; t <= hi; t++) v += a[row + t] * kx.k[t - i + kx.r];
2158
+ b[row + i] = v;
2159
+ }
2160
+ }
2161
+ a = new Float32Array(nx * ny);
2162
+ let max = 0;
2163
+ for (let j = 0; j < ny; j++) {
2164
+ const lo = Math.max(0, j - ky.r), hi = Math.min(ny - 1, j + ky.r);
2165
+ for (let i = 0; i < nx; i++) {
2166
+ let v = 0;
2167
+ for (let t = lo; t <= hi; t++) v += b[t * nx + i] * ky.k[t - j + ky.r];
2168
+ a[j * nx + i] = v;
2169
+ if (v > max) max = v;
2170
+ }
2171
+ }
2172
+ return { values: a, nx, ny, w0, s, stepLng, stepLat, max };
2173
+ }
2174
+
2175
+ // The bands of a kdeGrid: one MultiPolygon feature per threshold (fraction · max), each the area
2176
+ // at or above it — so the bands stack, the highest on top — in lng/lat. `makeContours` is
2177
+ // d3.contours (d3-contour), handed in so this stays pure. d3 puts the value of cell (i, j) at
2178
+ // (i + 0.5, j + 0.5), the same convention as the grid, hence the plain scaling by the steps.
2179
+ function kdeBands(grid, fractions, makeContours) {
2180
+ const empty = { type: 'FeatureCollection', features: [] };
2181
+ if (!grid || !(grid.max > 0) || !fractions.length) return empty;
2182
+ const thresholds = fractions.map(f => f * grid.max);
2183
+ const bands = makeContours().size([grid.nx, grid.ny]).thresholds(thresholds)(grid.values);
2184
+ const features = [];
2185
+ bands.forEach((mp, k) => {
2186
+ if (!mp.coordinates || !mp.coordinates.length) return;
2187
+ features.push({
2188
+ type: 'Feature',
2189
+ properties: { level: k, fraction: fractions[k], value: mp.value },
2190
+ geometry: {
2191
+ type: 'MultiPolygon',
2192
+ coordinates: mp.coordinates.map(poly => poly.map(ring => ring.map(p => [grid.w0 + p[0] * grid.stepLng, grid.s + p[1] * grid.stepLat])))
2193
+ }
2194
+ });
2195
+ });
2196
+ return { type: 'FeatureCollection', features };
2197
+ }
2198
+
2199
+ // The worker's whole source: kdeGrid alone (it uses nothing else of this file), answering
2200
+ // {id, points, bwKm, maxCells} with {id, grid}, the grid's values handed back by transfer.
2201
+ function kdeWorkerSource() {
2202
+ return `const kdeGrid = ${kdeGrid.toString()};
2203
+ self.onmessage = e => {
2204
+ const { id, points, bwKm, maxCells } = e.data;
2205
+ const grid = kdeGrid(points, bwKm, maxCells);
2206
+ self.postMessage({ id, grid }, grid ? [grid.values.buffer] : []);
2207
+ };`;
2208
+ }
2209
+
2210
+ // kdeGrid off the main thread: one shared Web Worker, built from kdeWorkerSource. Without a Worker
2211
+ // (a page that forbids blob workers, a test) or when it fails, the same function runs inline.
2212
+ let _kdeWorker = null, _kdeSeq = 0;
2213
+ const _kdePending = new Map();
2214
+ // tests: forget the worker and the loaded d3-contour
2215
+ function kdeReset() { if (_kdeWorker && _kdeWorker.terminate) _kdeWorker.terminate(); _kdeWorker = null; _kdePending.clear(); _d3ContourPromise = null; }
2216
+ // tests: the deck.gl layer classes a runtime builds with, without loading deck.gl
2217
+ function setDeckLayerClassesForTests(classes) { if (classes.GeoJsonLayer) GeoJsonLayer = classes.GeoJsonLayer; }
2218
+ function kdeCompute(points, bwKm, maxCells) {
2219
+ return new Promise(resolve => {
2220
+ try {
2221
+ if (_kdeWorker === null && typeof global.Worker === 'function' && typeof global.Blob === 'function' && global.URL && global.URL.createObjectURL) {
2222
+ const w = new global.Worker(global.URL.createObjectURL(new global.Blob([kdeWorkerSource()], { type: 'text/javascript' })));
2223
+ w.onmessage = e => { const p = _kdePending.get(e.data.id); if (p) { _kdePending.delete(e.data.id); p.resolve(e.data.grid); } };
2224
+ w.onerror = () => {
2225
+ _kdeWorker = false; // no worker from now on: whatever is waiting is computed here
2226
+ const waiting = [..._kdePending.values()]; _kdePending.clear();
2227
+ waiting.forEach(p => p.resolve(kdeGrid(...p.args)));
2228
+ };
2229
+ _kdeWorker = w;
2230
+ }
2231
+ } catch (err) { _kdeWorker = false; }
2232
+ if (!_kdeWorker) { resolve(kdeGrid(points, bwKm, maxCells)); return; }
2233
+ const id = ++_kdeSeq;
2234
+ _kdePending.set(id, { resolve, args: [points, bwKm, maxCells] });
2235
+ _kdeWorker.postMessage({ id, points, bwKm, maxCells });
2236
+ });
2237
+ }
2238
+
2239
+ // d3.contours of d3-contour 4 (+ d3-array 3), fetched once and evaluated PRIVATELY with a
2240
+ // CommonJS `require`: nothing is added to a window.d3 the page may have (d3-array would overwrite
2241
+ // the same-named functions of a d3 v3). Resolves to the `contours` function.
2242
+ let _d3ContourPromise = null;
2243
+ function ensureD3Contour() {
2244
+ if (!_d3ContourPromise) {
2245
+ const get = url => global.fetch(url).then(r => { if (!r.ok) throw new Error(`[ixmaps-gl] failed to load ${url}`); return r.text(); });
2246
+ const run = (src, req) => { const module = { exports: {} }; new Function('module', 'exports', 'require', src)(module, module.exports, req); return module.exports; };
2247
+ _d3ContourPromise = Promise.all([get(LIB_URLS.d3Array), get(LIB_URLS.d3Contour)]).then(([arraySrc, contourSrc]) => {
2248
+ const d3Array = run(arraySrc, () => { throw new Error('[ixmaps-gl] d3-array has no dependencies'); });
2249
+ const d3Contour = run(contourSrc, name => { if (name === 'd3-array') return d3Array; throw new Error(`[ixmaps-gl] d3-contour requires ${name}`); });
2250
+ if (typeof d3Contour.contours !== 'function') throw new Error('[ixmaps-gl] d3-contour: no contours()');
2251
+ return d3Contour.contours;
2252
+ }).catch(err => { _d3ContourPromise = null; throw err; });
2253
+ }
2254
+ return _d3ContourPromise;
2255
+ }
2256
+
2257
+ // A color ramp of `steps` RGB colors from a colorscheme, low → high: a generated scheme
2258
+ // (["5","white","blue"]) is flat's own sweep, a list of colors is taken as given (interpolated),
2259
+ // ONE color is a ramp of itself; `fallback` when nothing in it can be read. Shared by HEATMAP
2260
+ // (colorRange) and KDE (one color per band).
2261
+ function colorRampRgb(colorscheme, steps, fallback) {
2262
+ let rgb;
2263
+ if (isGeneratedColorScheme(colorscheme)) {
2264
+ rgb = flatColorSweep(colorscheme[1], colorscheme[2], Number(colorscheme[0]) || steps, colorscheme[3], colorscheme[4]) || [];
2265
+ } else {
2266
+ const list = Array.isArray(colorscheme) ? colorscheme : colorscheme ? [colorscheme] : [];
2267
+ rgb = list.map(c => parseCssColor(String(c))).filter(Boolean);
2268
+ }
2269
+ if (!rgb.length) rgb = [fallback];
2270
+ return Array.from({ length: steps }, (_, i) => {
2271
+ const t = steps > 1 ? i / (steps - 1) : 1;
2272
+ const x = t * (rgb.length - 1), k = Math.min(rgb.length - 2, Math.floor(x));
2273
+ const c = rgb.length === 1 ? rgb[0] : rgb[k].map((v, j) => Math.round(v + (rgb[k + 1][j] - v) * (x - k)));
2274
+ return [c[0], c[1], c[2]];
2275
+ });
2276
+ }
2277
+
2278
+ // .type("HEATMAP") — deck.gl HeatmapLayer's colorRange (low → high density, RGBA): the ramp of
2279
+ // `steps` colors with an alpha that rises from 0 over the first part of it, so the tails of a
2280
+ // kernel fade out instead of ending in a hard edge.
2281
+ function heatmapColorRange(colorscheme, steps = 6) {
2282
+ return colorRampRgb(colorscheme, steps, [229, 57, 53]).map((c, i) => {
2283
+ const t = steps > 1 ? i / (steps - 1) : 1;
2284
+ return [c[0], c[1], c[2], Math.round(255 * Math.min(1, t * 2.5))];
2285
+ });
2286
+ }
2287
+
2288
+ // .type("KDE") — the color of each band, lowest threshold first (the fire pages' #dd0088 when the
2289
+ // colorscheme says nothing usable)
2290
+ function kdeBandColors(colorscheme, count) { return colorRampRgb(colorscheme, count, [221, 0, 136]); }
2291
+
2292
+ // .type("HEATMAP") — the weight of one record: its size field if bound, else its value field
2293
+ // (a text value field just counts the record), 1 for "$item$"; below 0 or not a number → 0
2294
+ function heatmapWeight(binding, props) {
2295
+ const b = binding || {};
2296
+ const fromSize = !!b.size && b.size !== '$item$';
2297
+ const field = fromSize ? b.size
2298
+ : (b.size === '$item$' || !b.value || b.value === '$item$' || String(b.value).includes('|')) ? null : b.value;
2299
+ if (!field) return 1;
2300
+ const w = scanValue(props[field]);
2301
+ if (isFinite(w)) return w > 0 ? w : 0;
2302
+ return fromSize ? 0 : 1;
2303
+ }
2304
+
2305
+ // ── .type("MLHEATMAP") — MapLibre's NATIVE heatmap layer (type "heatmap"). The pure parts: the
2306
+ // points, the weight that counts 1, the radius by zoom, the paint. Unlike HEATMAP (deck.gl's
2307
+ // HeatmapLayer, which aggregates the whole dataset into one 2048² texture whenever the view
2308
+ // changes and scales the density to the maximum in view), MapLibre keeps the points in tile
2309
+ // buffers, evaluates radius and intensity from the zoom on the GPU at draw time and reads the
2310
+ // density ABSOLUTELY through the color ramp (clamped at 1): zooming costs nothing and a cell means
2311
+ // the same at every zoom. The weight is `w`, the density 1 is reached by a point of weight
2312
+ // weightmax (and more by overlapping points).
2313
+ const MLHEATMAP_MIN_RADIUS_PX = 0.5;
2314
+ const MLHEATMAP_MAX_RADIUS_PX = 400;
2315
+
2316
+ // the records as GeoJSON points {w: weight}, for the native layer's source (weights ≤ 0 and
2317
+ // records without a position are left out)
2318
+ function mlHeatmapPoints(features, binding) {
2319
+ const out = [];
2320
+ for (const f of features) {
2321
+ const c = f.geometry && f.geometry.coordinates;
2322
+ if (!c || typeof c[0] !== 'number' || !isFinite(c[0]) || !isFinite(c[1])) continue;
2323
+ const w = heatmapWeight(binding, f.properties || {});
2324
+ if (w > 0) out.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [c[0], c[1]] }, properties: { w } });
2325
+ }
2326
+ return { type: 'FeatureCollection', features: out };
2327
+ }
2328
+
2329
+ // the weight that counts 1: style.weightmax, else the largest weight of the records (1 for none)
2330
+ function mlHeatmapWeightMax(features, binding, style) {
2331
+ const given = styleNum(style && style.weightmax);
2332
+ if (given > 0) return given;
2333
+ let max = 0;
2334
+ for (const f of features) {
2335
+ const c = f.geometry && f.geometry.coordinates;
2336
+ if (!c || typeof c[0] !== 'number' || !isFinite(c[0]) || !isFinite(c[1])) continue;
2337
+ const w = heatmapWeight(binding, f.properties || {});
2338
+ if (w > max) max = w;
2339
+ }
2340
+ return max > 0 ? max : 1;
2341
+ }
2342
+
2343
+ // heatmap-radius: `radiusPx` at the reference zoom `zoomRef`, growing with the map like every object
2344
+ // (objectZoomFactor: radiusPx · 2^((zoom − zoomRef) / pow)), kept between 0.5 and 400 px. An
2345
+ // exponential interpolation of base 2^(1/pow) between the two zooms where the limits are reached is
2346
+ // exactly that curve, so MapLibre evaluates it from the zoom without any rebuild. A number when the
2347
+ // radius does not follow the zoom (objectscaling not dynamic) or is already at a limit everywhere.
2348
+ function mlHeatmapRadius(radiusPx, zoomRef, pow, dynamic) {
2349
+ const r0 = radiusPx > 0 ? radiusPx : 30;
2350
+ const clamp = r => Math.max(MLHEATMAP_MIN_RADIUS_PX, Math.min(MLHEATMAP_MAX_RADIUS_PX, r));
2351
+ if (!dynamic) return clamp(r0);
2352
+ const p = pow > 0 ? pow : 1;
2353
+ const zoomOf = r => zoomRef + p * Math.log2(r / r0); // the zoom at which the radius is r
2354
+ const radiusAt = z => r0 * Math.pow(2, (z - zoomRef) / p);
2355
+ const zLow = Math.max(0, zoomOf(MLHEATMAP_MIN_RADIUS_PX));
2356
+ const zCap = Math.min(24, zoomOf(MLHEATMAP_MAX_RADIUS_PX));
2357
+ if (!(zCap > zLow + 1e-6)) return clamp(radiusAt(zLow));
2358
+ return ['interpolate', ['exponential', Math.pow(2, 1 / p)], ['zoom'], zLow, clamp(radiusAt(zLow)), zCap, clamp(radiusAt(zCap))];
2359
+ }
2360
+
2361
+ // the paint of the native heatmap layer. Colors: the colorscheme as a ramp over the densities
2362
+ // 0.2 … 1 (transparent at 0, like MapLibre's own example, so the tails fade out); a scheme of one
2363
+ // color fades in by alpha instead. style: intensity (default 1), fillopacity (default 1).
2364
+ function mlHeatmapPaint(style, weightMax, radius) {
2365
+ const st = style || {};
2366
+ const ramp = colorRampRgb(st.colorscheme, 5, [229, 57, 53]);
2367
+ const single = ramp.every(c => c.join() === ramp[0].join());
2368
+ const color = ['interpolate', ['linear'], ['heatmap-density'], 0, `rgba(${ramp[0].join(',')},0)`];
2369
+ ramp.forEach((c, i) => {
2370
+ const d = (i + 1) / 5;
2371
+ color.push(d, single ? `rgba(${c.join(',')},${d})` : `rgb(${c.join(',')})`);
2372
+ });
2373
+ const intensity = styleNum(st.intensity), opacity = styleNum(st.fillopacity);
2374
+ return {
2375
+ 'heatmap-weight': ['interpolate', ['linear'], ['get', 'w'], 0, 0, weightMax > 0 ? weightMax : 1, 1],
2376
+ 'heatmap-intensity': intensity > 0 ? intensity : 1,
2377
+ 'heatmap-color': color,
2378
+ 'heatmap-radius': radius,
2379
+ 'heatmap-opacity': opacity > 0 ? Math.min(opacity, 1) : 1
2380
+ };
2381
+ }
2382
+
2383
+ // The native heatmap layers of the MLHEATMAP themes, kept in step with the runtimes (as the WMS
2384
+ // raster layers are): per theme one GeoJSON source of weighted points and one `heatmap` layer, put
2385
+ // BEFORE the first deck.gl layer (those are MapLibre custom layers) so the symbols stay on top.
2386
+ // Recomputed only when something changed — the records (filter, reload), the binding, the weight
2387
+ // max — and the paint only when its values changed; a new style (setMapType) drops sources and
2388
+ // layers, so a missing one is simply added again from the cached points. `owned` holds the layer ids
2389
+ // this function added, so those of removed themes can be taken away.
2390
+ // (MapLibre's basemap-opacity pass, applyBasemapOpacity, only knows fill/line/raster/circle/symbol
2391
+ // types: a heatmap layer is not faded with the basemap.)
2392
+ const _mlSourceIds = new WeakMap();
2393
+ let _mlSourceSeq = 0;
2394
+ const mlHeatLayerId = name => 'ixmaps-mlheat:' + String(name).replace(/[^a-zA-Z0-9_-]/g, '_');
2395
+ function mlFirstCustomLayerId(map) {
2396
+ const order = typeof map.getLayersOrder === 'function' ? map.getLayersOrder() : (map.getStyle().layers || []).map(l => l.id);
2397
+ return order.find(id => { const l = map.getLayer(id); return l && l.type === 'custom'; });
2398
+ }
2399
+ function syncNativeHeatmaps(map, runtimes, owned) {
2400
+ if (!map || typeof map.getLayer !== 'function') return;
2401
+ const desired = runtimes.filter(rt => rt.flags && rt.flags.has('MLHEATMAP'));
2402
+ if (!desired.length && !owned.size) return;
2403
+ const wanted = new Set(desired.map(rt => mlHeatLayerId(rt.name)));
2404
+ [...owned].forEach(id => {
2405
+ if (wanted.has(id)) return;
2406
+ owned.delete(id);
2407
+ try { if (map.getLayer(id)) map.removeLayer(id); } catch (e) { /* already gone */ }
2408
+ try { if (map.getSource(id)) map.removeSource(id); } catch (e) { /* already gone */ }
2409
+ });
2410
+ desired.forEach(rt => {
2411
+ const id = mlHeatLayerId(rt.name);
2412
+ const st = rt._mlHeat || (rt._mlHeat = {});
2413
+ try {
2414
+ const records = rt._activeFeatures || rt.features || [];
2415
+ if (!_mlSourceIds.has(records)) _mlSourceIds.set(records, ++_mlSourceSeq);
2416
+ const style = rt.style || {};
2417
+ const dataKey = JSON.stringify([rt._dataGen, _mlSourceIds.get(records), rt.binding]);
2418
+ const weightKey = JSON.stringify([dataKey, style.weightmax]);
2419
+ if (st.weightKey !== weightKey) { st.weightMax = mlHeatmapWeightMax(records, rt.binding, style); st.weightKey = weightKey; }
2420
+ if (st.dataKey !== dataKey || !st.fc) {
2421
+ st.fc = mlHeatmapPoints(records, rt.binding);
2422
+ st.dataKey = dataKey;
2423
+ const src = map.getSource(id);
2424
+ if (src && typeof src.setData === 'function') src.setData(st.fc);
2425
+ }
2426
+ if (!map.getSource(id)) map.addSource(id, { type: 'geojson', data: st.fc });
2427
+ owned.add(id);
2428
+ // the radius follows the zoom like every object (objectscaling), from `radiuspx` at the reference zoom
2429
+ const mo = rt.mapOptions || {};
2430
+ const radius = mlHeatmapRadius((styleNum(style.radiuspx) || 30) * (styleNum(style.scale) || 1),
2431
+ resolveZoomReference(mo), resolveDynamicScalePow(mo), String(mo.objectscaling || 'dynamic') === 'dynamic');
2432
+ const paint = mlHeatmapPaint(style, st.weightMax, radius);
2433
+ const paintKey = JSON.stringify(paint);
2434
+ if (!map.getLayer(id)) {
2435
+ map.addLayer({ id, type: 'heatmap', source: id, paint }, mlFirstCustomLayerId(map));
2436
+ st.paintKey = paintKey;
2437
+ } else if (st.paintKey !== paintKey) {
2438
+ Object.keys(paint).forEach(k => map.setPaintProperty(id, k, paint[k]));
2439
+ st.paintKey = paintKey;
2440
+ }
2441
+ const visibility = rt._hidden ? 'none' : 'visible';
2442
+ if ((map.getLayoutProperty(id, 'visibility') || 'visible') !== visibility) map.setLayoutProperty(id, 'visibility', visibility);
2443
+ st.failed = false;
2444
+ } catch (err) {
2445
+ if (!st.failed) { st.failed = true; console.warn(`[ixmaps-gl] layer "${rt.name}": MLHEATMAP not drawn:`, err && err.message ? err.message : err); }
2446
+ }
2447
+ });
2448
+ }
2449
+
2004
2450
  // a choropleth shape's outline color: a LINE shape strokes with its value
2005
2451
  // color; any other shape keeps the theme's outline, hidden while a class
2006
2452
  // is isolated and the shape is not in it
@@ -2033,7 +2479,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2033
2479
  // specific FEATURE runtime as a donor, not just sharing its name
2034
2480
  // coincidentally.
2035
2481
  geomRt._isChoroplethGeometryDonor = true;
2036
- const idField = geomRt.binding.id;
2482
+ // the donor's own style (outline) — the layer registry is keyed by name,
2483
+ // so the same-named CHOROPLETH replaces the donor there and can't find it
2484
+ Object.defineProperty(spec, '_donorStyle', { value: geomRt.style, configurable: true });
2485
+ const idField = geometryIdField(geomRt, geomRt.binding.id);
2037
2486
  const lookupField = spec.binding.lookup;
2038
2487
  if (!idField || !lookupField) {
2039
2488
  throw new Error(`[ixmaps-gl] CHOROPLETH layer "${spec.name}" needs .binding({lookup}), and its FEATURE base needs .binding({id})`);
@@ -2757,6 +3206,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2757
3206
  if (this._filterExpr != null) style.filter = this._filterExpr;
2758
3207
  if (this._titleField != null) style.title = this._titleField;
2759
3208
  if (this._lookupfield != null) style.lookupfield = this._lookupfield;
3209
+ // flat's .data() (htmlgui_flat.js ~1827): a geojson/topojson layer with no type of its own is
3210
+ // a FEATURES base layer ("FEATURES|NOLEGEND", lookup on its geometry) — the base a CHOROPLETH
3211
+ // of the same name needs. Only when neither .type() nor style.type says otherwise
3212
+ if (!style.type && this._data && /geojson|topojson/i.test(String(this._data.type || ''))) {
3213
+ style.type = 'FEATURES|NOLEGEND';
3214
+ if (style.lookupfield == null) style.lookupfield = 'geometry';
3215
+ }
2760
3216
  let data = this._data;
2761
3217
  if (this._query != null || this._process != null) {
2762
3218
  data = Object.assign({}, this._data);
@@ -2820,7 +3276,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2820
3276
  // edit it by hand; `npm run unit` fails when it drifts.
2821
3277
  // ---------------------------------------------------------------
2822
3278
  // <grammar:binding-aliases>
2823
- // generated from ixmaps-grammar 0.1.28 (ixmaps-flat 1.0.42, 2b978d5 2026-10-02) — 52 aliases
3279
+ // generated from ixmaps-grammar 0.1.30 (ixmaps-flat 1.0.42, 2b978d5 2026-10-02) — 52 aliases
2824
3280
  const FLAT_BINDING_ALIASES = {
2825
3281
  "aggregation": "style.aggregationfield",
2826
3282
  "aggregationfield": "style.aggregationfield",
@@ -2882,7 +3338,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2882
3338
  // both — so .style({tooltip}) is a tooltip and .meta({fillopacity}) a style
2883
3339
  // property. GENERATED by test/sync-grammar.mjs — do not edit.
2884
3340
  // <grammar:meta-keys>
2885
- // generated from ixmaps-grammar 0.1.28 — 5 meta keys
3341
+ // generated from ixmaps-grammar 0.1.30 — 5 meta keys
2886
3342
  const FLAT_META_KEYS = ["description","name","snippet","title","tooltip"];
2887
3343
  // </grammar:meta-keys>
2888
3344
 
@@ -3122,7 +3578,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3122
3578
  'sizepow', 'rangescale', 'minvalue', 'maxvalue', 'markersize', 'boxopacity', 'outlierscale', 'valuescale',
3123
3579
  'brightness', 'fractionscale', 'dopacityscale', 'dopacitypow', 'gridwidthpx', 'textscale', 'rangecentervalue',
3124
3580
  'shadowblur', 'shadowdx', 'shadowdy', 'maxshadow', 'offsetx', 'offsety', 'gridx', 'boxmargin', 'borderwidth',
3125
- 'borderradius', 'maxcharts', 'archeight', 'alignmargin'];
3581
+ 'borderradius', 'maxcharts', 'archeight', 'alignmargin',
3582
+ // HEATMAP / MLHEATMAP / KDE (a project JSON writes them as strings)
3583
+ 'radiuspx', 'intensity', 'threshold', 'weightmax', 'bandwidth', 'bandwidthscale', 'bandwidthzoompow', 'bandwidthzoomref'];
3126
3584
  function toNumberIfNumeric(v) {
3127
3585
  if (typeof v !== 'string' || !v.trim()) return v;
3128
3586
  const n = Number(v);
@@ -3197,6 +3655,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3197
3655
  delete style.title;
3198
3656
 
3199
3657
  const flags = new Set(typeStr ? typeStr.split('|') : []);
3658
+ // flat's theme configurator appends "CENTER|DOMINANT" to a categorical type without a
3659
+ // separator ("...|CATEGORICALCENTER|DOMINANT"); flat's substring tests read that as
3660
+ // CATEGORICAL + CENTER, so split a glued CATEGORICAL prefix back into its flags
3661
+ [...flags].forEach(f => {
3662
+ if (f.length > 11 && f.startsWith('CATEGORICAL')) { flags.add('CATEGORICAL'); flags.add(f.slice(11)); }
3663
+ });
3200
3664
  if (flags.has('BUBBLE')) flags.add('SYMBOL');
3201
3665
  // flat tests its base type as a substring (szFlag.match(/CHOROPLETH/)),
3202
3666
  // so "CHOROPLETHE" is a choropleth there too
@@ -3500,9 +3964,23 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3500
3964
  if (t === entry.name || (entry.aliases || []).includes(t)) return entry;
3501
3965
  }
3502
3966
  }
3967
+ // keyless vector styles by NAME (exact match, before the dark/light rule): flat's own VT_VOYAGER names
3968
+ // resolve to the CARTO Voyager style they are made from; the rest are further keyless MapLibre styles
3969
+ const KEYLESS_VECTOR_BASEMAPS = [
3970
+ { name: 'CARTO - Voyager', aliases: ['VT_VOYAGER'], url: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json' },
3971
+ { name: 'CARTO - Voyager (no labels)', aliases: ['VT_VOYAGER_LIGHT'], url: 'https://basemaps.cartocdn.com/gl/voyager-nolabels-gl-style/style.json' },
3972
+ { name: 'CARTO - Positron (no labels)', url: 'https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json' },
3973
+ { name: 'CARTO - Dark Matter (no labels)', url: 'https://basemaps.cartocdn.com/gl/dark-matter-nolabels-gl-style/style.json' },
3974
+ { name: 'OpenFreeMap - Liberty', url: 'https://tiles.openfreemap.org/styles/liberty' },
3975
+ { name: 'OpenFreeMap - Bright', url: 'https://tiles.openfreemap.org/styles/bright' },
3976
+ { name: 'OpenFreeMap - Positron', url: 'https://tiles.openfreemap.org/styles/positron' },
3977
+ ];
3503
3978
  function resolveBasemapStyleUrl(mapType) {
3504
3979
  const raster = flatRasterBasemap(mapType);
3505
3980
  if (raster) return buildRasterBasemapStyle(raster);
3981
+ const typeName = typeof mapType === 'string' ? mapType.trim() : '';
3982
+ const vector = KEYLESS_VECTOR_BASEMAPS.find(e => e.name === typeName || (e.aliases || []).includes(typeName));
3983
+ if (vector) return vector.url;
3506
3984
  return typeof mapType === 'string' && /dark|black|night|matter/i.test(mapType) ? BASEMAP_STYLE_URLS.dark : BASEMAP_STYLE_URLS.light;
3507
3985
  }
3508
3986
  // Real ixmaps-flat's legend look (tools/legend.js 3773-3845, via
@@ -3989,18 +4467,34 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3989
4467
  }
3990
4468
  // flat's click-to-action hook (ui/js/tools/tooltip_to_action.js): ixmaps.onItemAction(szId, item, themeObj) on a
3991
4469
  // click on an item — item.szSelectionId is the value of the theme's selection field — and onItemAction(null, null)
3992
- // on a click on the map background
3993
- function callItemAction(rt, object) {
4470
+ // on a click on the map background — gl adds the click position as third argument there:
4471
+ // onItemAction(null, null, { lngLat: {lng, lat}, point: {x, y} }) (flat passes none)
4472
+ function clickPosition(info) {
4473
+ const c = info && info.coordinate;
4474
+ if (!c || !isFinite(c[0]) || !isFinite(c[1])) return undefined;
4475
+ return { lngLat: { lng: c[0], lat: c[1] }, point: { x: info.x, y: info.y } };
4476
+ }
4477
+ function callItemAction(rt, object, info) {
3994
4478
  const hook = pageIxmaps() && pageIxmaps().onItemAction;
3995
- if (typeof hook !== 'function') return;
3996
4479
  try {
3997
- if (!rt) { hook.call(global.ixmaps, null, null); return; }
4480
+ const pos = clickPosition(info);
4481
+ if (!rt) { if (typeof hook === 'function') hook.call(global.ixmaps, null, null, pos); return; }
3998
4482
  const field = selectionFieldOf(rt);
3999
4483
  const props = object.properties || {};
4000
4484
  // a single record, or an aggregated item's first one
4001
4485
  const raw = props.raw || props.firstRaw || props.titleRaw || (props.raws && props.raws[0]) || props;
4002
4486
  const value = field && raw ? raw[field] : null;
4003
- if (value == null) return;
4487
+ // the map's "click" event, for EVERY item as in flat (szId = theme::itemKey): the key is the selection
4488
+ // field's value, else the feature's id, else its position "lng,lat" — a theme bound by geometry only
4489
+ // (no id/lookup field) has no selection value. gl adds the record and the click position.
4490
+ if (builder.__emitItemClick) {
4491
+ const g = object.geometry, a = anchorCoordOf(g) || (g && Array.isArray(g.coordinates) && g.coordinates[0]);
4492
+ const key = value != null ? String(value)
4493
+ : (object.id != null ? String(object.id) : (a && typeof a[0] === 'number' ? a[0] + ',' + a[1] : ''));
4494
+ builder.__emitItemClick(Object.assign({ szId: themeIdOf(rt) + '::' + key, id: key, theme: themeIdOf(rt), record: raw }, pos));
4495
+ }
4496
+ // flat's onItemAction needs the theme's selection value
4497
+ if (value == null || typeof hook !== 'function') return;
4004
4498
  hook.call(global.ixmaps, themeIdOf(rt) + '::' + value, { szSelectionId: String(value) }, getThemeObj(themeIdOf(rt)));
4005
4499
  } catch (err) { console.error('[ixmaps-gl] onItemAction failed:', err); }
4006
4500
  }
@@ -4009,7 +4503,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4009
4503
  if (info && info.object && info.layer) {
4010
4504
  const rt = findRuntimeForLayerId(info.layer.id);
4011
4505
  if (rt) {
4012
- callItemAction(rt, info.object);
4506
+ callItemAction(rt, info.object, info);
4013
4507
  // Only a Point geometry's own coordinates ARE an
4014
4508
  // [lng,lat] pair (every point/bubble/dot/chart-cluster
4015
4509
  // layer) — a CHOROPLETH polygon's geometry.coordinates is
@@ -4040,7 +4534,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4040
4534
  }
4041
4535
  }
4042
4536
  // clicked empty map space (or a non-tooltippable layer) -> unpin
4043
- callItemAction(null, null);
4537
+ callItemAction(null, null, picked);
4044
4538
  pinned = null;
4045
4539
  pinnedTooltipEl.style.display = 'none';
4046
4540
  return false;
@@ -4318,7 +4812,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4318
4812
  // category rows: its panel is the text legend (title / snippet / description)
4319
4813
  const countOnlyLegend = rt => !rt.categoryLabels && rt.flags.has('CATEGORICAL') && !rt.binding.value;
4320
4814
  const legendApplies = rt => (countOnlyLegend(rt) || (rt.categoryLabels && rt.categoryLabels.length))
4321
- && (!(rt.flags.has('FEATURE') || rt.flags.has('FEATURES')) || rt.flags.has('CATEGORICAL')) && !rt.flags.has('NOLEGEND');
4815
+ && (!(rt.flags.has('FEATURE') || rt.flags.has('FEATURES')) || rt.flags.has('CATEGORICAL')) && !rt.flags.has('NOLEGEND')
4816
+ // KDE and HEATMAP draw a density, not classes of the value: the range legend that a numeric
4817
+ // value would get is meaningless for them (no legend for these, as the fire pages)
4818
+ && !rt.flags.has('KDE') && !rt.flags.has('HEATMAP') && !rt.flags.has('MLHEATMAP');
4322
4819
  // One theme's panel — at build time for every theme, and again for a
4323
4820
  // theme defined later (map.layer(...) in a myMap.then(...) chain,
4324
4821
  // defineLayer, loadProject); removeTheme removes it with the theme.
@@ -4419,7 +4916,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4419
4916
  // no SUM) falls back to a per-category record COUNT.
4420
4917
  // a VECTOR / BEZIER flow legend sums its size field (flat's rows
4421
4918
  // show the flows' summed sizes, measured on the twin page)
4422
- const useSum = flatFlag(rt.flags, 'SUM') && (rt.style.valuefield || ((rt._isVectorChart() || rt._isAggregatedPie()) && rt.binding.size));
4919
+ // a categorical symbol/bubble sized by a column: SUM sums that column per category (flat:
4920
+ // partsA[i].nSum accumulates each item's nSize, maptheme.js 16649; legend.js 677); a size
4921
+ // of "$item$" is the count anyway
4922
+ const useSum = flatFlag(rt.flags, 'SUM') && (rt.style.valuefield
4923
+ || ((rt._isVectorChart() || rt._isAggregatedPie()) && rt.binding.size)
4924
+ || (rt.flags.has('CATEGORICAL') && rt.binding.size && rt.binding.size !== '$item$'));
4423
4925
  const valueField = rt.style.valuefield || rt.binding.size;
4424
4926
  // legendunits wins over the theme's general-purpose units
4425
4927
  // (used elsewhere for tooltips, e.g. _renderItemChartHtml) —
@@ -4502,7 +5004,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4502
5004
  if (!c || typeof c[0] !== 'number') return;
4503
5005
  const [lng, lat] = c;
4504
5006
  if (!inViewport(lng, lat, bbox, globeCenter)) return;
4505
- rt._multiFields.forEach((k, i) => { totals[i] += parseFloat(f.properties[k]) || 0; });
5007
+ rt._multiFields.forEach((k, i) => { totals[i] += scanValue(f.properties[k]) || 0; });
4506
5008
  });
4507
5009
  maxTotal = Math.max(0, ...totals);
4508
5010
  order = totals.map((v, i) => i).sort((a, b) => totals[b] - totals[a]);
@@ -4515,7 +5017,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4515
5017
  // with whatever the country filter is currently narrowing
4516
5018
  // the map down to, not just with the viewport/hemisphere.
4517
5019
  (rt._activeFeatures || rt.features).forEach(f => {
4518
- const idx = rt.categoryIndexByLabel ? rt.categoryIndexByLabel.get(f.properties[rt.binding.value]) : null;
5020
+ const idx = rt.categoryIndexByLabel ? rt._resolveClassIndex(f.properties[rt.binding.value]) : null;
4519
5021
  if (idx == null) return;
4520
5022
  // a polygon (FEATURE|CATEGORICAL) is counted at its first vertex
4521
5023
  let pt = f.geometry && f.geometry.coordinates;
@@ -4523,7 +5025,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4523
5025
  if (!Array.isArray(pt)) return;
4524
5026
  const [lng, lat] = pt;
4525
5027
  if (!inViewport(lng, lat, bbox, globeCenter)) return;
4526
- totals[idx] += useSum ? (parseFloat(f.properties[valueField]) || 0) : 1;
5028
+ totals[idx] += useSum ? (scanValue(f.properties[valueField]) || 0) : 1;
4527
5029
  });
4528
5030
  maxTotal = Math.max(0, ...totals);
4529
5031
  order = totals.map((v, i) => i).sort((a, b) => totals[b] - totals[a]);
@@ -5039,6 +5541,23 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5039
5541
  return { addLegendPanel, setLegendOption, updateSubTheme, layoutLegends };
5040
5542
  }
5041
5543
 
5544
+ // flat's map event names (htmlgui_flat.js mapApi.on, with its aliases) and the ones this engine fires:
5545
+ // viewchange (every view change end), zoomend / moveend ({ nZoom, zoomChanged, panChanged }, as flat),
5546
+ // ready (the map has loaded), click (an item: { szId, id, theme } + lngLat/point), layerdraw ({ id }) and,
5547
+ // as a gl addition, mapclick (every click on the map, background included: { lngLat, point })
5548
+ const MAP_EVENT_ALIAS = { zoompan: 'viewchange', itemover: 'mouseover', itemclick: 'click', itemout: 'mouseout', mapready: 'ready',
5549
+ drawtheme: 'layerdraw', newtheme: 'layeradd', removetheme: 'layerremove' };
5550
+ const MAP_EVENTS_WIRED = new Set(['viewchange', 'zoomend', 'moveend', 'ready', 'click', 'mapclick', 'layerdraw']);
5551
+ function splitMapEvents(events) {
5552
+ return String(events == null ? '' : events).trim().toLowerCase().split(/\s+/).filter(Boolean).map(e => MAP_EVENT_ALIAS[e] || e);
5553
+ }
5554
+ const _warnedMapEvents = new Set();
5555
+ function warnUnwiredMapEvent(ev) {
5556
+ if (MAP_EVENTS_WIRED.has(ev) || _warnedMapEvents.has(ev)) return;
5557
+ _warnedMapEvents.add(ev);
5558
+ console.warn(`[ixmaps-gl] map event "${ev}" is not implemented — the handler is accepted but never fires (supported: ${[...MAP_EVENTS_WIRED].join(', ')})`);
5559
+ }
5560
+
5042
5561
  class MapBuilder {
5043
5562
  constructor(containerId, options) {
5044
5563
  this.containerId = containerId;
@@ -5103,15 +5622,21 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5103
5622
  }
5104
5623
  // .Layer() — alias of .layer()
5105
5624
  Layer(nameOrBuilder) { return this.layer(nameOrBuilder); }
5106
- // GL-PORT COMPAT: real ixmaps-flat's map.on("layerdraw", cb) — only
5107
- // "layerdraw" is actually wired up (see build()'s own note on how
5108
- // it's translated into onRedraw + a synthetic per-runtime event); any
5109
- // OTHER event name is accepted and stored but never fired —
5110
- // recognized rather than throwing, the same "known but inert" pattern
5111
- // KNOWN_INERT_FLAGS already uses for unimplemented type flags.
5625
+ // flat's mapApi.on(events, cb) (htmlgui_flat.js 1631), "a b" for several names; see MAP_EVENTS for the
5626
+ // ones wired here. Any other name is accepted (never throws) but never fires, and says so once on the console.
5627
+ // Registered before the map is built, they are attached by build().
5112
5628
  on(event, cb) {
5113
5629
  this._pendingEvents = this._pendingEvents || [];
5114
- this._pendingEvents.push([event, cb]);
5630
+ for (const ev of splitMapEvents(event)) {
5631
+ warnUnwiredMapEvent(ev);
5632
+ this._pendingEvents.push([ev, cb]);
5633
+ }
5634
+ return this;
5635
+ }
5636
+ // flat's mapApi.off(events, cb), before the map is built (after it: the handle's off)
5637
+ off(event, cb) {
5638
+ const names = new Set(splitMapEvents(event));
5639
+ this._pendingEvents = (this._pendingEvents || []).filter(([ev, f]) => !(names.has(ev) && f === cb));
5115
5640
  return this;
5116
5641
  }
5117
5642
 
@@ -5533,20 +6058,29 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5533
6058
  map.on('style.load', refreshControlLook);
5534
6059
  this._setAttribution = text => attributionBox.set(text);
5535
6060
 
5536
- if (this._legendHtml) {
6061
+ // the page legend (map.legend(html)), or the legend a loaded project
6062
+ // carries in its map part (applyProjectMap) — flat shows the project's
6063
+ // legend in the same pane, so the project's replaces the page's
6064
+ let pageLegendEl = null;
6065
+ const showPageLegend = html => {
6066
+ if (pageLegendEl) { pageLegendEl.remove(); pageLegendEl = null; }
6067
+ this._legendHtml = html;
6068
+ if (!html) return;
5537
6069
  const legendEl = document.createElement('div');
5538
6070
  // a page legend (map.legend(html)) is positioned by the map's
5539
6071
  // own align option — the same rule that places the native stack
5540
6072
  legendEl.style.cssText = 'position:absolute;' + legendAlignCssFor(this.mapOptions) + 'z-index:5;pointer-events:none';
5541
6073
  // flat's legend takes the pane's html OR a URL to it (htmlgui):
5542
6074
  // a value without a tag is fetched, its html shown
5543
- if (/<[a-z!]/i.test(this._legendHtml)) legendEl.innerHTML = this._legendHtml;
5544
- else fetch(this._legendHtml).then(r => r.ok ? r.text() : '').then(t => { legendEl.innerHTML = t; }).catch(() => {});
6075
+ if (/<[a-z!]/i.test(html)) legendEl.innerHTML = html;
6076
+ else fetch(html).then(r => r.ok ? r.text() : '').then(t => { legendEl.innerHTML = t; }).catch(() => {});
5545
6077
  // inside the map container, never the page's body (see the pinned
5546
6078
  // tooltip mount) — styling body breaks page-positioned elements
5547
6079
  anchorInside(el);
5548
6080
  el.appendChild(legendEl);
5549
- }
6081
+ pageLegendEl = legendEl;
6082
+ };
6083
+ if (this._legendHtml) showPageLegend(this._legendHtml);
5550
6084
 
5551
6085
  // themes are addressed by either the layer()'s own name (e.g. "AREU")
5552
6086
  // or style.name (e.g. "chart") — the real engine's changeThemeStyle
@@ -6432,6 +6966,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6432
6966
  Object.assign(builder._engineOptions, o);
6433
6967
  }
6434
6968
  if (m.scaleParam && m.scaleParam.normalSizeScale != null) builder._engineOptions.normalSizeScale = m.scaleParam.normalSizeScale;
6969
+ // the project's own legend (map.legend: html or a URL to it) replaces the page's, as in flat;
6970
+ // like a page legend it takes the place of the theme-generated legend panels
6971
+ if (typeof m.legend === 'string' && m.legend.trim()) {
6972
+ showPageLegend(m.legend);
6973
+ setLegendOption(builder.mapOptions.legend);
6974
+ }
6435
6975
  if (m.basemap) report.notes.push(`basemap "${m.basemap}" not switched (ixmaps-gl keeps the map's own basemap)`);
6436
6976
  }
6437
6977
 
@@ -6544,7 +7084,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6544
7084
  // charts in their own group above them: whatever the theme order
6545
7085
  // (a choropleth swapped in with add(..., 'replace') comes last), the
6546
7086
  // shapes lie under the charts — in theme order among themselves
6547
- const isShape = l => /^ix-(features|choropleth|shadow)-/.test(l.id);
7087
+ const isShape = l => /^ix-(features|choropleth|shadow|kde)-/.test(l.id);
6548
7088
  layers = layers.filter(isShape).concat(layers.filter(l => !isShape(l)));
6549
7089
  // every deck.gl layer id once: two themes of one layer name and type
6550
7090
  // (the page's comune points and comune polygons, both FEATURES on
@@ -6588,6 +7128,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6588
7128
  // flat's WMS themes render as native raster layers, not deck.gl —
6589
7129
  // reconcile them with the current runtimes on every rebuild
6590
7130
  syncWmsLayers();
7131
+ syncNativeHeatmaps(map, runtimes, mlHeatLayerIds);
6591
7132
  }
6592
7133
 
6593
7134
  // flat's WMS theme type (szFlag /\bWMS|IMAGE\b/, maptheme.js 8207-8247):
@@ -6604,6 +7145,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6604
7145
  // map.getStyle() (a deep copy of the whole style) on every rebuild
6605
7146
  // of a page that has no WMS theme
6606
7147
  const wmsLayerIds = new Set();
7148
+ const mlHeatLayerIds = new Set(); // the MLHEATMAP layers added to the map (syncNativeHeatmaps)
6607
7149
  function syncWmsLayers() {
6608
7150
  if (!map || !map.getLayer) return;
6609
7151
  const idOf = name => 'ixmaps-wms:' + String(name).replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -6649,7 +7191,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6649
7191
  }
6650
7192
  // a style swap (setMapType) drops every native layer — rebuild the
6651
7193
  // WMS ones once the new style stands
6652
- map.on('style.load', () => syncWmsLayers());
7194
+ map.on('style.load', () => { syncWmsLayers(); syncNativeHeatmaps(map, runtimes, mlHeatLayerIds); });
6653
7195
 
6654
7196
  // isZooming flips true/false on every 'zoomstart'/'zoomend' pair —
6655
7197
  // and a scroll-wheel or trackpad zoom fires MANY such pairs per
@@ -6773,8 +7315,61 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6773
7315
  }
6774
7316
  map.on('moveend', loadDeferredThemes);
6775
7317
 
7318
+ // map events (flat's mapApi.on): see MAP_EVENTS_WIRED. "layerdraw" is approximated below.
7319
+ const mapListeners = Object.create(null);
7320
+ const drawHooks = new Map(); // layerdraw handler -> its onRedraw unsubscribe functions
7321
+ const mapName = (this.mapOptions && this.mapOptions.name) || null; // flat's szMap
7322
+ let mapReady = false;
7323
+ const emitMapEvent = (type, detail) => (mapListeners[type] || []).slice().forEach(cb => {
7324
+ try { cb(Object.assign({ type, szMap: mapName }, detail)); } catch (err) { console.error(`[ixmaps-gl] "${type}" callback failed:`, err); }
7325
+ });
7326
+ engineApi.on = (events, cb) => {
7327
+ if (typeof cb !== 'function') return engineApi;
7328
+ for (const ev of splitMapEvents(events)) {
7329
+ warnUnwiredMapEvent(ev);
7330
+ if (ev === 'layerdraw') {
7331
+ // flat fires "layerdraw" once per theme as it finishes drawing; here onRedraw fires once per refresh of
7332
+ // the whole map, so the callback is called once per current theme every time — a page that re-reads
7333
+ // that theme's state (getThemeObj/getFacets) gets fresh numbers after every redraw
7334
+ const off = engineApi.onRedraw(() => runtimes.forEach(rt => {
7335
+ const id = themeIdOf(rt);
7336
+ try { cb({ type: 'layerdraw', szId: id, id, szMap: mapName }); } catch (err) { console.error('[ixmaps-gl] "layerdraw" callback failed:', err); }
7337
+ }));
7338
+ drawHooks.set(cb, (drawHooks.get(cb) || []).concat(off));
7339
+ } else {
7340
+ (mapListeners[ev] = mapListeners[ev] || []).push(cb);
7341
+ if (ev === 'ready' && mapReady) setTimeout(() => { try { cb({ type: 'ready', szMap: mapName }); } catch (err) { console.error('[ixmaps-gl] "ready" callback failed:', err); } }, 0);
7342
+ }
7343
+ }
7344
+ return engineApi;
7345
+ };
7346
+ // flat's mapApi.off(events, cb): removes the handler(s) given to on()
7347
+ engineApi.off = (events, cb) => {
7348
+ if (typeof cb !== 'function') return engineApi;
7349
+ for (const ev of splitMapEvents(events)) {
7350
+ if (ev === 'layerdraw') { (drawHooks.get(cb) || []).forEach(off => off()); drawHooks.delete(cb); continue; }
7351
+ const list = mapListeners[ev];
7352
+ if (list) mapListeners[ev] = list.filter(f => f !== cb);
7353
+ }
7354
+ return engineApi;
7355
+ };
7356
+ let lastViewZoom = map.getZoom(), lastViewCenter = map.getCenter();
7357
+ map.on('moveend', () => {
7358
+ const z = map.getZoom(), c = map.getCenter();
7359
+ const zoomChanged = z !== lastViewZoom, panChanged = c.lng !== lastViewCenter.lng || c.lat !== lastViewCenter.lat;
7360
+ lastViewZoom = z; lastViewCenter = c;
7361
+ const detail = { nZoom: flatViewZoomOf(map), zoomChanged, panChanged };
7362
+ emitMapEvent('viewchange', detail);
7363
+ if (zoomChanged) emitMapEvent('zoomend', detail);
7364
+ if (panChanged && !zoomChanged) emitMapEvent('moveend', detail);
7365
+ });
7366
+ map.on('click', e => emitMapEvent('mapclick', { lngLat: { lng: e.lngLat.lng, lat: e.lngLat.lat }, point: { x: e.point.x, y: e.point.y } }));
7367
+ this.__emitItemClick = detail => emitMapEvent('click', detail);
7368
+
6776
7369
  map.on('load', () => {
6777
7370
  mapHasLoaded = true;
7371
+ mapReady = true;
7372
+ emitMapEvent('ready');
6778
7373
  loadDeferredThemes();
6779
7374
  refresh();
6780
7375
  runtimes.forEach(maybeZoomToTheme);
@@ -6840,18 +7435,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6840
7435
  // gets the same practical effect: fresh numbers after every redraw,
6841
7436
  // for every theme, without needing the page rewritten around a
6842
7437
  // finer-grained event this engine doesn't produce.
6843
- const layerdrawCbs = (this._pendingEvents || [])
6844
- .filter(([event]) => event === 'layerdraw')
6845
- .map(([, cb]) => cb);
6846
- if (layerdrawCbs.length) {
6847
- engineApi.onRedraw(() => {
6848
- runtimes.forEach(rt => {
6849
- layerdrawCbs.forEach(cb => {
6850
- try { cb({ id: rt.name }); } catch (err) { console.error('[ixmaps-gl] "layerdraw" callback failed:', err); }
6851
- });
6852
- });
6853
- });
6854
- }
7438
+ (this._pendingEvents || []).forEach(([event, cb]) => engineApi.on(event, cb));
6855
7439
 
6856
7440
  // the native legend: createLegend (its four hooks are what the rest
6857
7441
  // of the map calls — panels, the legend option, sub-themes, layout)
@@ -7035,7 +7619,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7035
7619
  promise.legend = (...args) => { builder.legend(...args); return promise; };
7036
7620
  promise.local = (...args) => { builder.local(...args); return promise; };
7037
7621
  promise.require = (url) => { if (resolvedApi) resolvedApi.require(url); else builder.require(url); return promise; };
7038
- promise.on = (...args) => { builder.on(...args); return promise; };
7622
+ promise.on = (...args) => { if (resolvedApi) resolvedApi.on(...args); else builder.on(...args); return promise; };
7623
+ promise.off = (...args) => { if (resolvedApi) resolvedApi.off(...args); else builder.off(...args); return promise; };
7624
+ promise.getBounds = () => getBounds();
7625
+ promise.getMapLibre = () => (resolvedApi ? resolvedApi.map : undefined);
7039
7626
  promise.layer = (nameOrBuilder) => {
7040
7627
  if (typeof nameOrBuilder !== 'string') {
7041
7628
  // object form (a pre-built LayerBuilder, e.g. from the global
@@ -7772,7 +8359,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7772
8359
  // "$item$": every record counts 1
7773
8360
  if (field === '$item$') return features.length ? 1 : 0;
7774
8361
  return features.reduce((max, f) => {
7775
- const v = parseFloat(f.properties[field]);
8362
+ const v = scanValue(f.properties[field]);
7776
8363
  return isNaN(v) ? max : Math.max(max, v);
7777
8364
  }, 0);
7778
8365
  }
@@ -8046,6 +8633,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8046
8633
  const scale = scaleDenominatorAt(zoom);
8047
8634
  return !!((upper && scale > upper) || (lower && scale < lower));
8048
8635
  }
8636
+ // GLOW follows the theme's overall `opacity` (a number or numeric string; unset or 0 = 1, flat treats a falsy opacity as unset): the
8637
+ // halo is a fixed soft gradient texture (_getGlowIcon), so its strength is this alpha factor, as a bubble's is fillopacity
8638
+ function glowOpacityOf(style) {
8639
+ const v = Array.isArray(style.opacity) ? style.opacity[0] : style.opacity;
8640
+ const o = v == null || String(v).trim() === '' ? NaN : Number(v);
8641
+ return o > 0 && o < 1 ? o : 1;
8642
+ }
8049
8643
  function themeHiddenByScale(flags, style, zoom) {
8050
8644
  return (flags.has('FEATURE') || flags.has('FEATURES')) ? featuresHiddenByScale(style, zoom) : chartHiddenByScale(style, zoom);
8051
8645
  }
@@ -9376,7 +9970,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9376
9970
  }
9377
9971
  const parts = [], zeroNot = [];
9378
9972
  for (let k = 0; k < o.fields.length; k++) {
9379
- let v = parseFloat(raw[o.fields[k]]);
9973
+ let v = scanValue(raw[o.fields[k]]);
9380
9974
  // flat's weights multiply every record's per-field value as it is
9381
9975
  // accumulated (maptheme.js 10506-10508) — the SIZE (nValueSize)
9382
9976
  // stays raw, exactly as there
@@ -10039,14 +10633,14 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10039
10633
  let maxAlpha = -Infinity, minAlpha = Infinity;
10040
10634
  features.forEach(f => {
10041
10635
  // flat: an alpha that is no number is 0 (maptheme.js 9853-9857)
10042
- let v = parseFloat(f.properties[binding.alpha]);
10636
+ let v = scanValue(f.properties[binding.alpha]);
10043
10637
  if (isNaN(v)) v = 0;
10044
10638
  if (isDensity) {
10045
10639
  const areaKm2 = featureAreaKm2(f);
10046
10640
  if (!areaKm2) return;
10047
10641
  v = v / areaKm2;
10048
10642
  } else if (alpha100) {
10049
- const v100 = parseFloat(f.properties[alpha100]);
10643
+ const v100 = scanValue(f.properties[alpha100]);
10050
10644
  if (!isNaN(v100) && v100) v = 100 / v100 * v;
10051
10645
  }
10052
10646
  out._alphaByFeature.set(f, v);
@@ -10123,10 +10717,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10123
10717
  features.forEach(f => {
10124
10718
  if (!f.properties || !Object.keys(f.properties).length) return; // unjoined polygon
10125
10719
  nItems++;
10126
- const v100 = f100 ? parseFloat(f.properties[f100]) : NaN;
10720
+ const v100 = f100 ? scanValue(f.properties[f100]) : NaN;
10127
10721
  if (f100 && isFinite(v100)) sum100 += v100;
10128
10722
  fields.forEach((field, i) => {
10129
- const v = parseFloat(f.properties[field]);
10723
+ const v = scanValue(f.properties[field]);
10130
10724
  if (!isFinite(v)) return;
10131
10725
  sums[i] += v;
10132
10726
  if (f100 && isFinite(v100)) pooled[i] += v * v100;
@@ -10187,7 +10781,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10187
10781
  const useDeviation = ctx.flags.has('DEVIATION');
10188
10782
  let bestIndex = -1, bestRelevance = 0, bestValue = null;
10189
10783
  for (let i = 0; i < fields.length; i++) {
10190
- const v = parseFloat(props[fields[i]]);
10784
+ const v = scanValue(props[fields[i]]);
10191
10785
  if (!(v > (ctx._dominantMins[i] || 0))) continue;
10192
10786
  const relevance = useDeviation ? (v - ctx._dominantMeans[i]) / ctx._dominantStdDevs[i]
10193
10787
  : usePercentOfMean ? 100 * v / ctx._dominantMeans[i]
@@ -10215,7 +10809,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10215
10809
  const fields = out._multiFields;
10216
10810
  let nMax = 0;
10217
10811
  features.forEach(f => fields.forEach(field => {
10218
- const v = parseFloat(f.properties[field]);
10812
+ const v = scanValue(f.properties[field]);
10219
10813
  if (v > nMax) nMax = v;
10220
10814
  }));
10221
10815
  out._composeColorMax = nMax;
@@ -10255,7 +10849,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10255
10849
  const subtractive = ctx.flags.has('SUBTRACTIVE');
10256
10850
  let rr = 0, gg = 0, bb = 0;
10257
10851
  for (let i = 0; i < fields.length; i++) {
10258
- const v = parseFloat(props[fields[i]]) || 0;
10852
+ const v = scanValue(props[fields[i]]) || 0;
10259
10853
  const weight = v / nMax;
10260
10854
  const [r, g, b] = rgbs[i];
10261
10855
  if (subtractive) { rr += (255 - r) * weight; gg += (255 - g) * weight; bb += (255 - b) * weight; }
@@ -10328,14 +10922,20 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10328
10922
  // flat's reading of a value field (maptheme.js 9776-9800, defaults
10329
10923
  // 7740-7801): a value that is no number counts as 0 — unless
10330
10924
  // UNDEFINEDISNOTVALUE —, and 0 is a value — unless ZEROISNOTVALUE
10925
+ // flat's __scanValue (maptheme.js 9266): a value with a comma is EUROPEAN format (dots are thousands separators, the comma
10926
+ // the decimal: "1.234,5" -> 1234.5, "0,0637" -> 0.0637); a value without one is parsed plain, blanks stripped. Used wherever a
10927
+ // theme reads a value / size / alpha / value100 field from a record — the same strings plain parseFloat would cut at the comma
10928
+ function scanValue(v) {
10929
+ const s = String(v);
10930
+ return s.includes(',') ? parseFloat(s.replace(/\./g, '').replace(/,/g, '.')) : parseFloat(s.replace(/ /g, ''));
10931
+ }
10331
10932
  function flatNumber(v, flags) {
10332
10933
  // flat's own value scan (maptheme.js __scanValue 9265-9272): a
10333
10934
  // value with a comma is EUROPEAN format — dots stripped as
10334
10935
  // thousands separators, the comma is the decimal ("78,8" -> 78.8);
10335
10936
  // a value without one is parsed plain, blanks stripped. Applied
10336
10937
  // to every theme value in flat, so also here
10337
- const s = String(v);
10338
- let n = s.includes(',') ? parseFloat(s.replace(/\./g, '').replace(/,/g, '.')) : parseFloat(s.replace(/ /g, ''));
10938
+ let n = scanValue(v);
10339
10939
  if (isNaN(n)) n = flags && flags.has('UNDEFINEDISNOTVALUE') ? NaN : 0;
10340
10940
  if (n === 0 && flags && flags.has('ZEROISNOTVALUE')) return NaN;
10341
10941
  return n;
@@ -10464,14 +11064,14 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10464
11064
  if (!p) { out.push(f); continue; }
10465
11065
  let clear = [];
10466
11066
  if (size) {
10467
- const v = parseFloat(p[size]);
11067
+ const v = scanValue(p[size]);
10468
11068
  if (!(isFinite(v) && v > 0)) {
10469
11069
  if (!keepShapes) continue;
10470
11070
  clear.push(size);
10471
11071
  }
10472
11072
  }
10473
11073
  if (negativeIsNot) {
10474
- const negative = checked.filter(k => parseFloat(p[k]) < 0);
11074
+ const negative = checked.filter(k => scanValue(p[k]) < 0);
10475
11075
  if (negative.length && aggregate && !keepShapes) continue;
10476
11076
  clear = clear.concat(negative);
10477
11077
  }
@@ -10618,7 +11218,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10618
11218
  // without AGGREGATE applyField100 already differenced every record
10619
11219
  // (flat's nValuesA, popped): the first field IS the value
10620
11220
  ? (!flags.has('AGGREGATE') ? p => flatNumber(p[fields[0]], flags)
10621
- : p => cellAggregatedValues({ sums: fields.map(k => parseFloat(p[k]) || 0), counts: fields.map(() => 1) }, flags, style)[0])
11221
+ : p => cellAggregatedValues({ sums: fields.map(k => scanValue(p[k]) || 0), counts: fields.map(() => 1) }, flags, style)[0])
10622
11222
  // "$item$" is flat's record count: 1 per record
10623
11223
  : binding.value === '$item$' ? () => 1
10624
11224
  : p => (p && Object.keys(p).length ? flatNumber(p[binding.value], flags) : NaN);
@@ -10769,9 +11369,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10769
11369
  // theme's text 0.08 px high).
10770
11370
  function resolveAggregateValue(binding, flags, props) {
10771
11371
  if (binding.size === '$item$') return 1;
10772
- if (binding.size) return parseFloat(props[binding.size]) || 0;
11372
+ if (binding.size) return scanValue(props[binding.size]) || 0;
10773
11373
  if (flags.has('CATEGORICAL') || binding.value == null || binding.value === '$item$') return 1;
10774
- return parseFloat(props[binding.value]) || 0;
11374
+ return scanValue(props[binding.value]) || 0;
10775
11375
  }
10776
11376
 
10777
11377
  // Whether a range-classed AGGREGATE cell's class value (flat: the sum
@@ -11075,7 +11675,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11075
11675
  const add = (cat, v) => { if (cat == null || cat < 0 || cat >= n) return; count[cat]++; sum[cat] += Number.isFinite(v) ? v : 0; };
11076
11676
  if (rt.flags.has('CHOROPLETH')) {
11077
11677
  (rt._activeFeatures || rt.features).forEach(f => {
11078
- const v = parseFloat(f.properties[rt.binding.value]);
11678
+ const v = scanValue(f.properties[rt.binding.value]);
11079
11679
  add(rt._resolvePartsClass(v), v);
11080
11680
  });
11081
11681
  } else if (rt._legendItems) {
@@ -11086,7 +11686,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11086
11686
  if (!c || typeof c[0] !== 'number') return;
11087
11687
  const [lng, lat] = c;
11088
11688
  if (!inViewport(lng, lat, bbox, globeCenter)) return;
11089
- const v = parseFloat(f.properties[rt.binding.value]);
11689
+ const v = scanValue(f.properties[rt.binding.value]);
11090
11690
  add(rt._resolvePartsClass(v), v);
11091
11691
  });
11092
11692
  }
@@ -11128,6 +11728,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11128
11728
  this.name = spec.name;
11129
11729
  this.binding = spec.binding;
11130
11730
  this.style = spec.style;
11731
+ this._donorStyle = spec._donorStyle;
11131
11732
  this.flags = symbolsFlagCompat(spec.flags, spec.style);
11132
11733
  this.meta = spec.meta;
11133
11734
  // see LAYER_DATA_GENERATION above — unique per runtime, see _dataLayerId
@@ -11233,7 +11834,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11233
11834
  // a bar chart's parts are a pie's (its value fields or categories)
11234
11835
  if ((this._isPieChart() || this._isBarChart()) && this.binding.value) {
11235
11836
  this._preparePie();
11236
- } else if (this.flags.has('DOMINANT') && this.binding.value && !isAggregatedCategoricalChoropleth(this)) {
11837
+ } else if (this.flags.has('DOMINANT') && this.binding.value && !isAggregatedCategoricalChoropleth(this) && !this._isDominantGrid()) {
11237
11838
  // .type("CHOROPLETH|DOMINANT") — a MULTI-field bound value
11238
11839
  // (binding.value is a pipe-joined field list, e.g. one CSV column
11239
11840
  // per age band for the same year/sex — same pipe convention
@@ -11270,7 +11871,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11270
11871
  } else {
11271
11872
  const seen = new Map();
11272
11873
  this.features.forEach(f => {
11273
- const v = f.properties[this.binding.value];
11874
+ let v = f.properties[this.binding.value];
11875
+ if (typeof v === 'string') v = v.trim(); // flat: getStringValueIndex strips trailing blanks
11274
11876
  if (v != null && !seen.has(v)) seen.set(v, seen.size);
11275
11877
  });
11276
11878
  this.categoryLabels = Array.from(seen.keys());
@@ -11313,7 +11915,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11313
11915
  const scheme = Array.isArray(this.style.colorscheme) ? this.style.colorscheme : [];
11314
11916
  this._partNoFill = new Set(this._multiFields.map((f, i) => i).filter(i => String(scheme[i]).trim().toLowerCase() === 'none'));
11315
11917
  let max = 0;
11316
- for (const f of this.features) for (const field of this._multiFields) { const v = Math.abs(parseFloat(f.properties[field])); if (v > max) max = v; }
11918
+ for (const f of this.features) for (const field of this._multiFields) { const v = Math.abs(scanValue(f.properties[field])); if (v > max) max = v; }
11317
11919
  this._multiFieldPartMax = max || 1;
11318
11920
  // AUTO100's scale, measured on these features by _buildSequenceChartLayers
11319
11921
  this._seqAuto100Max = null;
@@ -11463,19 +12065,31 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11463
12065
  // flat's nValueSum is the RAW weighted sum, whatever the flags.
11464
12066
  // (RELATIVE/INVERT subtract after the multiply — not reconstructable
11465
12067
  // from the parts; flat's own composed branch never pairs them.)
11466
- _composedDopacityAlpha(props) {
12068
+ // With binding.alpha the opacity follows the alpha field instead of the value sum — flat's
12069
+ // `if (this.szAlphaField)` branch of the same DOPACITY block (maptheme.js 13476-13482):
12070
+ // dopacityscale / nAlphaMax^(1/pow) · nAlpha^(1/pow), nAlpha as _prepareAlphaField made it
12071
+ // (alpha100 "$density$": alpha / the shape's area, the FEATURE base's size field when it binds one);
12072
+ // a shape with no alpha is 0. Not capped at 0.9 and not times fillopacity, as flat
12073
+ _composedDopacityAlpha(props, feature) {
12074
+ if (this.binding && this.binding.alpha) {
12075
+ const nAlpha = this._alphaByFeature && feature ? this._alphaByFeature.get(feature) : undefined;
12076
+ if (nAlpha == null) return 0;
12077
+ const pow = 1 / (styleNum(this.style.dopacitypow) || 1);
12078
+ const o = (styleNum(this.style.dopacityscale) || 1) / Math.pow(this._alphaMax || 1, pow) * Math.pow(nAlpha, pow);
12079
+ return isFinite(o) ? Math.max(0, Math.min(1, o)) : 0;
12080
+ }
11467
12081
  const fields = this._multiFields || [];
11468
12082
  const f100A = this.binding && this.binding.field100 ? String(this.binding.field100).split('|') : null;
11469
12083
  const multiplier = this.flags.has('FRACTION') ? (styleNum(this.style.fractionscale) || 1)
11470
12084
  : this.flags.has('PERMILLE') ? 1000 : 100;
11471
12085
  let sum = 0;
11472
12086
  fields.forEach((field, i) => {
11473
- const part = parseFloat(props[field]);
12087
+ const part = scanValue(props[field]);
11474
12088
  if (isNaN(part)) return;
11475
- const v100 = f100A ? parseFloat(props[f100A[Math.min(i, f100A.length - 1)]]) : NaN;
12089
+ const v100 = f100A ? scanValue(props[f100A[Math.min(i, f100A.length - 1)]]) : NaN;
11476
12090
  sum += isNaN(v100) ? part : (part * v100) / multiplier;
11477
12091
  });
11478
- const v100_0 = f100A ? parseFloat(props[f100A[0]]) : NaN;
12092
+ const v100_0 = f100A ? scanValue(props[f100A[0]]) : NaN;
11479
12093
  const nMyValue = sum / ((v100_0 / 100) || 1);
11480
12094
  return composedDopacityAlpha(this.style, this.flags, nMyValue, this._composeColorMax || 1);
11481
12095
  }
@@ -11494,8 +12108,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11494
12108
  // (_buildPartsA, numeric bucket), or neither (a single implicit
11495
12109
  // bucket, today's behavior for any other theme).
11496
12110
  _resolveClassIndex(rawValue) {
11497
- if (this.categoryIndexByLabel) return this.categoryIndexByLabel.get(rawValue);
11498
- if (this.partsA) return this._resolvePartsClass(parseFloat(rawValue));
12111
+ if (this.categoryIndexByLabel) {
12112
+ const i = this.categoryIndexByLabel.get(rawValue);
12113
+ return i === undefined && typeof rawValue === 'string' ? this.categoryIndexByLabel.get(rawValue.trim()) : i;
12114
+ }
12115
+ if (this.partsA) return this._resolvePartsClass(scanValue(rawValue));
11499
12116
  return 0;
11500
12117
  }
11501
12118
 
@@ -11565,8 +12182,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11565
12182
  // as flat's chart node value (NYC status: value="63" = e-bikes)
11566
12183
  const sizeField = this.binding.size && this.binding.size !== '$item$' ? this.binding.size : null;
11567
12184
  this._featuresByCategory = [sourceFeatures.map(f => {
11568
- const parts = this._multiFields.map(k => parseFloat(f.properties[k]) || 0);
11569
- const size = sizeField ? Math.abs(parseFloat(f.properties[sizeField]) || 0) : undefined;
12185
+ const parts = this._multiFields.map(k => scanValue(f.properties[k]) || 0);
12186
+ const size = sizeField ? Math.abs(scanValue(f.properties[sizeField]) || 0) : undefined;
11570
12187
  return { type: 'Feature', geometry: f.geometry,
11571
12188
  properties: { value: parts[0], parts, series: parts, ...(size !== undefined ? { size } : {}), raw: f.properties } };
11572
12189
  })];
@@ -11592,9 +12209,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11592
12209
  // values, the difference is taken on the cell sums (see post)
11593
12210
  const seriesFields = String(this.binding.value || '').split('|');
11594
12211
  this._seriesDifference = this.flags.has('DIFFERENCE') && seriesFields.length > 1 && !this._aggregateField100;
11595
- const seriesOf = props => seriesFields.map(k => parseFloat(props[k]) || 0);
12212
+ const seriesOf = props => seriesFields.map(k => scanValue(props[k]) || 0);
11596
12213
  // without AGGREGATE the records are already differenced (applyField100)
11597
- const seriesValue = !this.flags.has('AGGREGATE') ? props => parseFloat(props[seriesFields[0]]) || 0 : props => cellAggregatedValues({ sums: seriesOf(props), counts: seriesFields.map(() => 1) }, this.flags, this.style)[0];
12214
+ const seriesValue = !this.flags.has('AGGREGATE') ? props => scanValue(props[seriesFields[0]]) || 0 : props => cellAggregatedValues({ sums: seriesOf(props), counts: seriesFields.map(() => 1) }, this.flags, this.style)[0];
11598
12215
  this._featuresByCategory = [sourceFeatures.map(f => ({
11599
12216
  type: 'Feature',
11600
12217
  geometry: f.geometry,
@@ -11602,8 +12219,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11602
12219
  value: this._seriesDifference ? seriesValue(f.properties) : this._resolveAggregateValue(f.properties),
11603
12220
  ...(this._seriesDifference ? { series: seriesOf(f.properties) } : {}),
11604
12221
  // "$item$": flat's record count, 1 per record
11605
- ...(this._classSeparate ? { classValue: this.binding.value === '$item$' ? 1 : parseFloat(f.properties[this.binding.value]) || 0 } : {}),
11606
- ...(this._aggregateField100 ? { value100: parseFloat(f.properties[f100]) || 0 } : {}),
12222
+ ...(this._classSeparate ? { classValue: this.binding.value === '$item$' ? 1 : scanValue(f.properties[this.binding.value]) || 0 } : {}),
12223
+ ...(this._aggregateField100 ? { value100: scanValue(f.properties[f100]) || 0 } : {}),
11607
12224
  raw: f.properties
11608
12225
  }
11609
12226
  }))];
@@ -11892,7 +12509,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11892
12509
  const countsByField = new Map(fields.map(f => [f, new Map()]));
11893
12510
 
11894
12511
  scoped.forEach(f => {
11895
- const weight = sizeField ? (parseFloat(f.properties[sizeField]) || 0) : 1;
12512
+ const weight = sizeField ? (scanValue(f.properties[sizeField]) || 0) : 1;
11896
12513
  for (const field of fields) {
11897
12514
  const v = f.properties[field];
11898
12515
  if (v == null || v === '') continue;
@@ -12479,7 +13096,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12479
13096
  const labels = this.categoryDisplayLabels || this.categoryLabels || [];
12480
13097
  return tooltipTable(props.parts.map((v, i) => (v > 0 ? { rgb: this.categoryColorsRgb[i], label: labels[i] || '(n/d)', value: this._formatTooltipValue(v) + unitOf } : null)).filter(Boolean));
12481
13098
  }
12482
- if (this.flags.has('CHOROPLETH') && (this.flags.has('DOMINANT') || this.flags.has('COMPOSECOLOR')) && props.raw) {
13099
+ // (a USER chart over several value fields — the pinnacle peaks of a COMPOSECOLOR theme — lists its fields
13100
+ // the same way: its single item row would show the first class with the size total)
13101
+ const multiFieldUser = this.flags.has('USER') && String(this.binding.value || '').includes('|');
13102
+ if ((this.flags.has('CHOROPLETH') || multiFieldUser) && (this.flags.has('DOMINANT') || this.flags.has('COMPOSECOLOR')) && props.raw) {
12483
13103
  return this._renderMultiFieldBarsHtml(props.raw);
12484
13104
  }
12485
13105
  const unit = unitOf;
@@ -12541,13 +13161,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12541
13161
  const fields = String(this.binding.value || '').split('|');
12542
13162
  if (this._multiFieldMax === undefined) {
12543
13163
  let max = 0;
12544
- for (const f of this.features) for (const field of fields) { const v = Math.abs(parseFloat(f.properties[field])); if (v > max) max = v; }
13164
+ for (const f of this.features) for (const field of fields) { const v = Math.abs(scanValue(f.properties[field])); if (v > max) max = v; }
12545
13165
  this._multiFieldMax = max;
12546
13166
  }
12547
13167
  const labels = Array.isArray(this.style.label) && this.style.label.length === fields.length ? this.style.label.map(String)
12548
13168
  : (this.categoryDisplayLabels || this.categoryLabels || fields);
12549
13169
  const unit = this.style.units ? ' ' + this.style.units : '';
12550
- let rows = fields.map((field, i) => ({ i, v: parseFloat(raw[field]) })).filter(r => Number.isFinite(r.v));
13170
+ let rows = fields.map((field, i) => ({ i, v: scanValue(raw[field]) })).filter(r => Number.isFinite(r.v));
12551
13171
  if (!rows.length) return '';
12552
13172
  if (this.flags.has('SORT')) rows.sort((a, b) => (this.flags.has('UP') ? a.v - b.v : b.v - a.v));
12553
13173
  const color = i => { const c = this.classRgb(i); return `rgb(${c.slice(0, 3).join(',')})`; };
@@ -12695,13 +13315,30 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12695
13315
  // early return here must also unmount its groups, or the pane keeps
12696
13316
  // showing the last drawing after the theme went out of scale (or was
12697
13317
  // hidden via setThemeVisible)
12698
- if (this._hidden) { this._clearUserChartPane(); return []; }
13318
+ if (this._hidden) { this._kdeShowGate = true; this._clearUserChartPane(); return []; }
12699
13319
  if (!this.flags.has('FEATURE') && !this.flags.has('FEATURES') && chartHiddenByScale(this.style, liveZoom)) { this._clearUserChartPane(); return []; }
12700
13320
  // DOT is checked FIRST, matching the real engine's own precedence
12701
13321
  // (its DOT branch is a loop-level fast-path that bypasses the whole
12702
13322
  // drawChart/modifier machinery entirely — a layer flagged DOT never
12703
13323
  // reaches CHART|SYMBOL's clustering/GLOW/VALUES pipeline, even if
12704
13324
  // those flags are also present in the type string).
13325
+ // MLHEATMAP: MapLibre's native heatmap layer (syncNativeHeatmaps) — nothing for deck.gl to draw
13326
+ if (this.flags.has('MLHEATMAP')) return [];
13327
+ // KDE: the density bands of the visible records (_buildKdeLayers)
13328
+ if (this.flags.has('KDE')) return this._buildKdeLayers(zoom, bbox, globeCenter);
13329
+ // HEATMAP: one GPU density surface over the visible records (_buildHeatmapLayers)
13330
+ if (this.flags.has('HEATMAP')) {
13331
+ // deck.gl's HeatmapLayer aggregates in screen space on a Web-Mercator viewport: under the
13332
+ // globe it draws nothing (measured, deck.gl 9.4.0 / MapLibre globe) — say so once
13333
+ if (globeCenter) {
13334
+ if (!this._warnedHeatmapGlobe) {
13335
+ this._warnedHeatmapGlobe = true;
13336
+ console.warn(`[ixmaps-gl] layer "${this.name}": HEATMAP is not drawn under the globe projection (deck.gl HeatmapLayer needs a Mercator view) — use mapProjection mercator, or another type`);
13337
+ }
13338
+ return [];
13339
+ }
13340
+ return this._buildHeatmapLayers(liveZoom);
13341
+ }
12705
13342
  if (this.flags.has('DOT')) return this._buildDotLayers();
12706
13343
  // GRIDSIZE (spatial grid binning) is checked before the generic
12707
13344
  // CHART|SYMBOL bubble pipeline, same "more specific base type wins"
@@ -12801,7 +13438,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12801
13438
  // layer keeps its one fill color
12802
13439
  && this.binding.value && this.binding.value !== '$item$' && !String(this.binding.value).includes('|');
12803
13440
  const classColorOf = f => {
12804
- const cls = this._resolvePartsClass(parseFloat(f.properties && f.properties[this.binding.value]));
13441
+ const cls = this._resolvePartsClass(scanValue(f.properties && f.properties[this.binding.value]));
12805
13442
  return this.classRgb(cls);
12806
13443
  };
12807
13444
  const catAlpha = Math.round(255 * (styleNum(this.style.fillopacity) || 1));
@@ -12981,7 +13618,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12981
13618
  // sum over (field100/100), against the same nMax the color
12982
13619
  // blend normalizes by. The raw weighted sum is reconstructed
12983
13620
  // from the fractioned part values (raw = part × its field100)
12984
- dopacityAlpha: dopacityActive ? this._composedDopacityAlpha(f.properties) : null
13621
+ dopacityAlpha: dopacityActive ? this._composedDopacityAlpha(f.properties, f) : null
12985
13622
  }
12986
13623
  };
12987
13624
  }
@@ -13024,7 +13661,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13024
13661
  // linecolor "none": no outline; a class color "none" (an explicit
13025
13662
  // colorscheme list) leaves its polygons unpainted, an rgba() class
13026
13663
  // color keeps its alpha — as flat's SVG fill does
13027
- const lineColorRaw = this.style.linecolor ? styleLineColor(this.style.linecolor) : null;
13664
+ // flat re-styles the SAME shape nodes the FEATURE base drew, so an
13665
+ // outline the choropleth doesn't define itself stays the base's own
13666
+ const donorStyle = this._donorStyle;
13667
+ const lineColorSrc = this.style.linecolor || (donorStyle && donorStyle.linecolor);
13668
+ const lineColorRaw = lineColorSrc ? styleLineColor(lineColorSrc) : null;
13028
13669
  const lineRgb = lineColorRaw ? withAlpha(hexOrNamedToRgb(lineColorRaw), cssColorAlpha(lineColorRaw)) : [255, 255, 255];
13029
13670
  const cs = this.style.colorscheme;
13030
13671
  const classAlpha = Array.isArray(cs) && cs.length && !/^\d+$/.test(String(cs[0])) ? cs.map(cssColorAlpha) : null;
@@ -13056,11 +13697,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13056
13697
  // flat re-styles the SAME shape nodes the FEATURE base drew, so a line
13057
13698
  // choropleth keeps the base's own line width — the donor's linewidth
13058
13699
  // is the fallback when the choropleth has none of its own
13059
- const donorRt = [..._globalThemeRegistry.values()].find(r => r.name === this.name && r._isChoroplethGeometryDonor);
13060
13700
  const anyLine = collection.features.some(isLineGeometry);
13061
13701
  const isolating = !!(this._markedClasses.size && !this._onMarksChanged && evidenceMode === 'isolate');
13062
13702
  const lineW = styleNum(this.style.linewidth)
13063
- || (anyLine && donorRt && styleNum(donorRt.style.linewidth)) || 1;
13703
+ || (donorStyle && styleNum(donorStyle.linewidth)) || 1;
13064
13704
  return [new GeoJsonLayer({
13065
13705
  id: this._dataLayerId(`ix-choropleth-${this.name}`),
13066
13706
  data: collection,
@@ -13142,8 +13782,15 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13142
13782
  // GRIDSIZE without PLOT drawn as one uniform square per cell: only a
13143
13783
  // plain grid — a single color, no VALUES, no GLOW
13144
13784
  _isPlainGridMesh() {
13145
- return !this.flags.has('PLOT') && (this.categoryColorsRgb || []).length <= 1
13146
- && !this.flags.has('VALUES') && !this.flags.has('GLOW');
13785
+ // categorical CENTER|DOMINANT cells are drawn by the same mesh path, per-cell colored
13786
+ return this._isDominantGrid() || (!this.flags.has('PLOT') && (this.categoryColorsRgb || []).length <= 1
13787
+ && !this.flags.has('VALUES') && !this.flags.has('GLOW'));
13788
+ }
13789
+ // flat's categorical HexBin (GRIDSIZE|AGGREGATE|CATEGORICAL|DOMINANT): each grid cell takes
13790
+ // the color of its dominant category, with dynamic opacity from that category's part
13791
+ _isDominantGrid() {
13792
+ return this.flags.has('GRIDSIZE') && !this.flags.has('PLOT') && this.flags.has('CATEGORICAL')
13793
+ && this.flags.has('DOMINANT');
13147
13794
  }
13148
13795
  // GRIDSIZE (no PLOT) symbol chart radius, px: flat's nGridSize / 2
13149
13796
  // (maptheme.js 21843), nGridSize the aggregation cell pitch / nAutoSize
@@ -13160,6 +13807,19 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13160
13807
  const autoSize = rect ? (gap ? 1.15 : 1.0) : (gap ? 0.8 : 0.75);
13161
13808
  return cellPx / autoSize * (styleNum(this.style.scale) || 1) / 2;
13162
13809
  }
13810
+
13811
+ // flat's AUTOSIZE (maptheme.js 21831-21843): the biggest aggregated item is the size of its
13812
+ // grid cell — the radius GRIDSIZE gives every item (hexagon cell/1.5, RECT cell/2; GAP 1.6 /
13813
+ // 2.3) — and the others follow (value/max)^(1/sizepow). This is the biggest item's radius;
13814
+ // null without AUTOSIZE, without a grid, or on a GRIDSIZE/PLOT theme (those size themselves)
13815
+ _autoSizeMaxRadiusPx(zoom, liveZoom) {
13816
+ if (!this.flags.has('AUTOSIZE') || this.flags.has('GRIDSIZE') || this.flags.has('PLOT') || !this._clusterRadiusPx) return null;
13817
+ const fromZoom = this._clusterUsesFixedZoom ? GRIDWIDTH_METERS_REFERENCE_ZOOM : zoom;
13818
+ const cellPx = this._clusterRadiusPx * Math.pow(2, liveZoom - fromZoom);
13819
+ const rect = flatFlag(this.flags, 'RECT');
13820
+ const gap = flatFlag(this.flags, 'GAP');
13821
+ return cellPx / (rect ? (gap ? 2.3 : 2.0) : (gap ? 1.6 : 1.5)) * (styleNum(this.style.scale) || 1);
13822
+ }
13163
13823
  _isItemPlot() {
13164
13824
  return this.flags.has('PLOT') && !this.flags.has('GRIDSIZE') && String(this.binding.value || '').includes('|');
13165
13825
  }
@@ -13310,7 +13970,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13310
13970
  for (const f of this.features) {
13311
13971
  const pos = itemAnchor(f.geometry);
13312
13972
  if (!pos) continue;
13313
- const values = fields.map(k => parseFloat(f.properties[k]));
13973
+ const values = fields.map(k => scanValue(f.properties[k]));
13314
13974
  for (const v of values) if (!isNaN(v)) { dMin = Math.min(dMin, v); dMax = Math.max(dMax, v); }
13315
13975
  if (!active || active.has(f)) items.push({ geometry: { type: 'Point', coordinates: pos }, properties: { values, raw: f.properties } });
13316
13976
  }
@@ -13427,9 +14087,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13427
14087
  : Array.isArray(this.style.xaxis) ? this.style.xaxis : null;
13428
14088
  if (explicit) {
13429
14089
  if (!this._explicitCatIndex) this._explicitCatIndex = new Map(explicit.map((v, i) => [String(v), i]));
13430
- return this._explicitCatIndex.get(String(rawValue));
14090
+ const i = this._explicitCatIndex.get(String(rawValue));
14091
+ return i === undefined ? this._explicitCatIndex.get(String(rawValue).trim()) : i;
13431
14092
  }
13432
- return this._resolveClassIndex(rawValue);
14093
+ // "$item$" is no field: every record counts 1 (resolveAggregateValue), so its class is the 1
14094
+ return this._resolveClassIndex(this.binding.value === '$item$' ? 1 : rawValue);
13433
14095
  }
13434
14096
 
13435
14097
  // pure: cellAggregatedValues → see the pure-function sections above the class
@@ -13770,15 +14432,93 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13770
14432
  // style.colorscheme fill.
13771
14433
  _buildGridMeshLayers(zoom) {
13772
14434
  this._ensureGridIndex(zoom);
13773
- const data = this._gridIndex.map(cell => {
13774
- const center = worldPixelToLngLat(cell.px, cell.py, zoom);
13775
- return { geometry: { type: 'Point', coordinates: [center.lng, center.lat] } };
13776
- });
13777
-
13778
14435
  const fillColor = (resolveColorScheme(this.style.colorscheme, ['']) || ['rgba(255,255,255,0.3)'])[0] || 'rgba(255,255,255,0.3)';
13779
14436
  const borderColor = styleLineColor(this.style.linecolor);
13780
14437
  const borderWidth = styleNum(this.style.linewidth);
13781
14438
  const cellPx = this._gridCellPx;
14439
+
14440
+ // flat's "Hexbin - dynamic opacity" (CHART|SYMBOL|GRIDSIZE|AGGREGATE|SUM|DOPACITY,
14441
+ // symbols:["hexagon"]): hexagon cells, each cell's fill OPACITY ramped from its
14442
+ // aggregated value (resolveDopacityAlpha, the CHOROPLETH|DOPACITY formulas). The plain
14443
+ // one-color mesh (neither flag) stays the uniform square below.
14444
+ const hexCells = Array.isArray(this.style.symbols) && this.style.symbols[0] === 'hexagon';
14445
+ const dopacity = this.flags.has('DOPACITY') || this.flags.has('DOPACITYMIN')
14446
+ || this.flags.has('DOPACITYMAX') || this.flags.has('DOPACITYMINMAX');
14447
+ if (hexCells || dopacity) {
14448
+ const cells = this._gridIndex;
14449
+ const baseOpacity = styleNum(this.style.fillopacity);
14450
+ const base = isNaN(baseOpacity) ? 0.9 : baseOpacity;
14451
+ // CATEGORICAL|DOMINANT: dominant category per cell (the biggest part above 0, the
14452
+ // first of equal ones — aggregatedCategoricalClass), opacity from that part against
14453
+ // the biggest dominant part of the same class over all cells (dominantDopacityAlpha)
14454
+ if (this._isDominantGrid()) {
14455
+ const labels = this._plotCategories();
14456
+ const rgb = resolveClassColors(this.style.colorscheme, labels, undefined) || [];
14457
+ const dom = cells.map(c => {
14458
+ let i = -1;
14459
+ c.sums.forEach((v, k) => { if (v > 0 && (i < 0 || v > c.sums[i])) i = k; });
14460
+ return i < 0 ? null : { i, v: c.sums[i] };
14461
+ });
14462
+ // flat's nMin/nMax: extremes of the cell TOTAL over all categories
14463
+ const totals = cells.map(c => c.sums.reduce((s, x) => s + x, 0));
14464
+ const tMin = Math.min(...totals), tMax = Math.max(...totals);
14465
+ const data = [];
14466
+ // legend click (ixmaps.markThemeClass): flat's evidence "isolate" shows only the cells
14467
+ // whose dominant category is marked; "isolate_gray" keeps the others, dimmed
14468
+ const marking = this._markedClasses.size && !this._onMarksChanged;
14469
+ const gray = String(this.style.evidence || 'isolate') === 'isolate_gray';
14470
+ cells.forEach((cell, n) => {
14471
+ const d = dom[n];
14472
+ if (!d) return;
14473
+ if (marking && !this._markedClasses.has(d.i) && !gray) return;
14474
+ const center = worldPixelToLngLat(cell.px, cell.py, zoom);
14475
+ let a = dopacity ? symbolDopacityAlpha(this.style, d.v, tMin, tMax, baseOpacity) : base;
14476
+ if (marking && !this._markedClasses.has(d.i)) a = DIMMED_ICON_ALPHA / 255;
14477
+ const c = rgb[d.i] || [130, 130, 130];
14478
+ data.push({ geometry: { type: 'Point', coordinates: [center.lng, center.lat] }, rgb: c, alpha: a });
14479
+ });
14480
+ // white hexagon, tinted per cell by getColor (rgb + alpha)
14481
+ const r = cellPx / 1.5;
14482
+ // mask icon: deck tints only mask icons with getColor's rgb (non-mask: alpha only)
14483
+ const icon = Object.assign({}, this._buildGridHexIcon('#ffffff', 'none', 0, r), { mask: true });
14484
+ return [new IconLayer({
14485
+ id: `ix-grid-${this.name}-g${this._iconGeneration}`,
14486
+ data, pickable: false,
14487
+ getPosition: d => d.geometry.coordinates,
14488
+ getIcon: () => icon,
14489
+ getColor: d => [d.rgb[0], d.rgb[1], d.rgb[2], Math.round(255 * d.alpha)],
14490
+ getSize: 2 * r,
14491
+ sizeUnits: 'pixels'
14492
+ })];
14493
+ }
14494
+ // cell-level value stats (the grid IS the dataset for DOPACITY here)
14495
+ const vals = cells.map(c => c.sums[0]).filter(v => v != null && !isNaN(v)).sort((a, b) => a - b);
14496
+ const vMin = vals[0], vMax = vals[vals.length - 1];
14497
+ const data = cells.map(cell => {
14498
+ const center = worldPixelToLngLat(cell.px, cell.py, zoom);
14499
+ const a = dopacity ? symbolDopacityAlpha(this.style, cell.sums[0], vMin, vMax, baseOpacity) : base;
14500
+ return { geometry: { type: 'Point', coordinates: [center.lng, center.lat] }, alpha: a == null ? base : a };
14501
+ });
14502
+ // hexagon tiling (snapToAggregationGrid): column pitch cellPx = 1.5 r, so r = cellPx / 1.5
14503
+ const r = cellPx / 1.5;
14504
+ const icon = hexCells
14505
+ ? this._buildGridHexIcon(fillColor, borderColor, isNaN(borderWidth) ? 0 : borderWidth, r)
14506
+ : this._buildGridSquareIcon(fillColor, borderColor, isNaN(borderWidth) ? 0 : borderWidth, cellPx);
14507
+ return [new IconLayer({
14508
+ id: `ix-grid-${this.name}-g${this._iconGeneration}`,
14509
+ data, pickable: false,
14510
+ getPosition: d => d.geometry.coordinates,
14511
+ getIcon: () => icon,
14512
+ getColor: d => [255, 255, 255, Math.round(255 * d.alpha)],
14513
+ getSize: hexCells ? 2 * r : cellPx,
14514
+ sizeUnits: 'pixels'
14515
+ })];
14516
+ }
14517
+
14518
+ const data = this._gridIndex.map(cell => {
14519
+ const center = worldPixelToLngLat(cell.px, cell.py, zoom);
14520
+ return { geometry: { type: 'Point', coordinates: [center.lng, center.lat] } };
14521
+ });
13782
14522
  const icon = this._buildGridSquareIcon(fillColor, borderColor, isNaN(borderWidth) ? 0 : borderWidth, cellPx);
13783
14523
 
13784
14524
  return [new IconLayer({
@@ -13895,8 +14635,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13895
14635
  // drawn now (fresh), else a cached chart's: the previous drawing's copy
13896
14636
  const def = this._userChartGroup.querySelector('#' + CSS.escape(id)) || this._userChartGroup.ownerDocument.getElementById(id);
13897
14637
  if (def) paneDefs.appendChild(def.cloneNode(true));
14638
+ else if (chart.defsById && chart.defsById[id]) paneDefs.insertAdjacentHTML('beforeend', chart.defsById[id]);
13898
14639
  }
13899
- items.push({ d, coords: d.geometry.coordinates, ox: chart.ox, oy: chart.oy, s: pxPerUnit,
14640
+ items.push({ d, coords: d.geometry.coordinates, ox: chart.ox, oy: chart.oy, s: pxPerUnit, pick: chart.pick,
13900
14641
  r: Math.max(chart.vw, chart.vh) * pxPerUnit / 2,
13901
14642
  // the chart's measured box (flat's getBox of the chart group) in px
13902
14643
  // around the map position: the pane draws a chart point at
@@ -13939,14 +14680,31 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13939
14680
  positionOf: b => b.d.geometry.coordinates, offsetOf: () => [0, 0],
13940
14681
  ids: { circle: `ix-user-box-${this.name}`, icon: `ix-user-box-${this.name}`, polygons: `ix-user-box-polygons-${this.name}`, titles: `ix-user-titles-${this.name}` }
13941
14682
  }) : { box: [], titles: [] };
13942
- this._userChartLastLayers = [...boxLayers.box, new ScatterplotLayer({
14683
+ // each chart answers to its own outline: the polygons sampled from its drawing (else its box), in px
14684
+ // around the map position, here as lng/lat at this zoom (a px is 360/(512·2^z) degrees across,
14685
+ // times cos(lat) up); a few px of transparent stroke keep a thin peak easy to hit
14686
+ const degPerPx = 360 / (512 * Math.pow(2, liveZoom));
14687
+ const pickFeatures = items.map(it => {
14688
+ const [lng, lat] = it.coords;
14689
+ const kx = it.s * degPerPx, ky = it.s * degPerPx * Math.cos(lat * Math.PI / 180);
14690
+ const rings = it.pick && it.pick.length ? it.pick
14691
+ : [[[it.ext[0] / it.s, it.ext[1] / it.s], [it.ext[2] / it.s, it.ext[1] / it.s], [it.ext[2] / it.s, it.ext[3] / it.s], [it.ext[0] / it.s, it.ext[3] / it.s]]];
14692
+ const polys = rings.map(r => {
14693
+ const ring = r.map(([x, y]) => [lng + x * kx, lat - y * ky]);
14694
+ ring.push(ring[0]);
14695
+ return [ring];
14696
+ });
14697
+ // icon + d: what pickedFeature unwraps to the item
14698
+ return { type: 'Feature', geometry: { type: 'MultiPolygon', coordinates: polys }, properties: {}, icon: true, d: it.d };
14699
+ });
14700
+ this._userChartLastLayers = [...boxLayers.box, new GeoJsonLayer({
13943
14701
  id: `ix-bubbles-${this.name}`,
13944
- data: items.map(it => ({ d: it.d, icon: true, r: it.r })),
14702
+ data: { type: 'FeatureCollection', features: pickFeatures },
13945
14703
  pickable: true,
13946
- getPosition: e => e.d.geometry.coordinates,
13947
- getRadius: e => Math.max(2, e.r),
13948
- radiusUnits: 'pixels', radiusMinPixels: 1,
13949
- stroked: false, filled: true, getFillColor: [0, 0, 0, 0],
14704
+ filled: true, stroked: true,
14705
+ // alpha 1/255: invisible, but never a fully transparent fragment a renderer might skip
14706
+ getFillColor: [0, 0, 0, 1], getLineColor: [0, 0, 0, 1],
14707
+ lineWidthUnits: 'pixels', getLineWidth: 4,
13950
14708
  parameters: ICON_LAYER_GLOBE_PARAMETERS
13951
14709
  }), ...boxLayers.titles];
13952
14710
  return this._userChartLastLayers;
@@ -13959,11 +14717,40 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13959
14717
  const raw = d.properties.raw || d.properties;
13960
14718
  const titleField = this.style.titlefield || this.binding.title;
13961
14719
  const title = titleField && raw && raw[titleField] != null ? String(raw[titleField]) : undefined;
13962
- const base = `user|${name}|${cat}|${Math.round(value * 100) / 100}|${Math.round(size * 2) / 2}|${flag}|${title || ''}`;
14720
+ // flat hands the chart the item's value fields: values = every field of binding.value (after the
14721
+ // field100 arithmetic — the record already carries it) and value = the first of them
14722
+ // (maptheme.js 19667, nValuesA[0]); a theme with one value field keeps the item's value
14723
+ let values = [value];
14724
+ const fieldNames = this.binding.value && !d.properties.counts ? String(this.binding.value).split('|') : [];
14725
+ if (fieldNames.length > 1) {
14726
+ const parts = fieldNames.map(f => scanValue(raw && raw[f[0] === '!' ? f.slice(1) : f]));
14727
+ if (parts.some(v => !isNaN(v))) {
14728
+ values = parts.map(v => (isNaN(v) ? 0 : v));
14729
+ // COMPOSECOLOR blends all the fields into one colour, so the chart's value is the one that wins it —
14730
+ // the highest field (flat itself would hand over the first field here: a deliberate departure,
14731
+ // so a "party with the highest result" map prints that party's share)
14732
+ value = this.flags.has('COMPOSECOLOR') ? Math.max(...values) : values[0];
14733
+ }
14734
+ }
14735
+ // DOMINANT: the chart is the dominant field's — its class colour and its value (flat's USER branch,
14736
+ // maptheme.js ~19700-19770: nClass = the dominant index, nValue = nValuesA[nIndex]); the relevance
14737
+ // (plain value, PERCENTOFMEAN, DEVIATION) is the one the DOMINANT choropleth uses
14738
+ if (this.flags.has('DOMINANT') && !this.flags.has('COMPOSECOLOR') && this._multiFields && this._dominantMins && raw && fieldNames.length > 1) {
14739
+ const dominant = this._resolveDominantClass(raw);
14740
+ if (dominant) { value = dominant.value; cat = dominant.index; }
14741
+ }
14742
+ // COMPOSECOLOR: the chart takes the item's blended colour (flat's itemA[a].szColor, the same
14743
+ // blend the choropleth uses) instead of one class colour
14744
+ let composed;
14745
+ if (this.flags.has('COMPOSECOLOR') && this._multiFields && raw) {
14746
+ const c = this._resolveComposedColor(raw);
14747
+ composed = `rgb(${c[0]},${c[1]},${c[2]})`;
14748
+ }
14749
+ const base = `user|${name}|${cat}|${Math.round(value * 100) / 100}|${values.length > 1 ? values.join(',') : ''}|${composed || ''}|${Math.round(size * 2) / 2}|${flag}|${title || ''}`;
13963
14750
  if (!this._userChartSvgs) this._userChartSvgs = new Map();
13964
14751
  let chart = this._userChartSvgs.get(base);
13965
14752
  if (chart === undefined) {
13966
- chart = this._drawUserChartSvg(draw, name, theme, flag, d, value, cat, size, nSize);
14753
+ chart = this._drawUserChartSvg(draw, name, theme, flag, d, value, cat, size, nSize, values, composed);
13967
14754
  if (chart === undefined) return null;
13968
14755
  if (this._userChartSvgs.size >= ICON_CACHE_MAX) this._userChartSvgs.delete(this._userChartSvgs.keys().next().value);
13969
14756
  this._userChartSvgs.set(base, chart);
@@ -13973,10 +14760,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13973
14760
  // the page's chart function draws one chart into the hidden SVG: its
13974
14761
  // markup and box (chart units), null when it draws nothing, undefined
13975
14762
  // when it fails
13976
- _drawUserChartSvg(draw, name, theme, flag, d, value, cat, size, nSize) {
14763
+ _drawUserChartSvg(draw, name, theme, flag, d, value, cat, size, nSize, values, composedColor) {
13977
14764
  const g = document.createElementNS(SVG_NS, 'g');
13978
14765
  this._userChartGroup.appendChild(g);
13979
- const color = this._userChartColor(cat);
14766
+ const color = composedColor || this._userChartColor(cat);
13980
14767
  const raw = d.properties.raw || d.properties;
13981
14768
  const label = this.binding.title && raw && raw[this.binding.title] != null ? String(raw[this.binding.title]) : undefined;
13982
14769
  // flat's chart title (style.titlefield, maptheme's item.szTitle) —
@@ -13986,7 +14773,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13986
14773
  let chart = null;
13987
14774
  try {
13988
14775
  const ret = draw.call(global.ixmaps, document, {
13989
- target: g, theme, value, values: [value], size, maxSize: USER_CHART_MAX_SIZE, color, class: cat, flag,
14776
+ target: g, theme, value, values: values || [value], size, maxSize: USER_CHART_MAX_SIZE, color, class: cat, flag,
13990
14777
  // flat hands the chart the item's color CLASS object (maptheme's
13991
14778
  // ColorScheme entry) — pinnacleChart reads ccolor.lowColor for its
13992
14779
  // VALUES label fill (usercharts/d3/chart.js 199)
@@ -13997,13 +14784,51 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13997
14784
  if (bb && bb.width > 0 && bb.height > 0) {
13998
14785
  const markup = new XMLSerializer().serializeToString(g);
13999
14786
  const ids = new Set([...markup.matchAll(/url\(#([^)"']+)\)/g)].map(m => m[1]));
14000
- const defs = [...ids].map(id => { const el = this._userChartGroup.ownerDocument.getElementById(id); return el ? new XMLSerializer().serializeToString(el) : ''; }).join('');
14787
+ // the gradients the chart uses, kept WITH the chart: a cached chart that was not on the screen in the
14788
+ // last drawing finds no copy of them in the document any more (they are one per colour, and only the
14789
+ // theme's own colours are defined again by _init), and would lose its fill
14790
+ const defsById = {};
14791
+ for (const id of ids) {
14792
+ const el = this._userChartGroup.ownerDocument.getElementById(id);
14793
+ if (el) defsById[id] = new XMLSerializer().serializeToString(el);
14794
+ }
14795
+ const defs = Object.values(defsById).join('');
14001
14796
  // strokes and text halos reach past the geometric box
14002
14797
  const pad = Math.max(bb.width, bb.height) * 0.06 + 10;
14003
14798
  const vx = bb.x - pad, vy = bb.y - pad, vw = bb.width + 2 * pad, vh = bb.height + 2 * pad;
14004
14799
  // the chart's origin (0,0 — or the point flat moves it to, ret) sits at the map position
14005
14800
  const ox = (ret && ret.x) || 0, oy = (ret && ret.y) || 0;
14006
- chart = { vx, vy, vw, vh, ox, oy, markup, ids: [...ids], bb: { x: bb.x, y: bb.y, width: bb.width, height: bb.height },
14801
+ // the chart's own outline for hover and click: its shapes (the peak's triangle, a pie's circle ...)
14802
+ // sampled into polygons, in chart units around the origin — not the box around them (a tall peak
14803
+ // would otherwise answer to a rectangle, or a circle, far wider than the peak itself)
14804
+ const pick = [];
14805
+ try {
14806
+ const inv = g.getCTM().inverse();
14807
+ const at = (el, x, y) => { const p = new DOMPoint(x, y).matrixTransform(inv.multiply(el.getCTM())); return [p.x - ox, p.y - oy]; };
14808
+ g.querySelectorAll('path,rect,circle,ellipse,polygon').forEach(el => {
14809
+ if (el.closest('defs,clipPath,mask')) return;
14810
+ let ring = null;
14811
+ if (el.tagName === 'path') {
14812
+ const len = el.getTotalLength();
14813
+ if (!(len > 0)) return;
14814
+ const n = Math.max(8, Math.min(40, Math.ceil(len / 50)));
14815
+ ring = [];
14816
+ for (let i = 0; i <= n; i++) { const q = el.getPointAtLength(len * i / n); ring.push(at(el, q.x, q.y)); }
14817
+ } else if (el.tagName === 'rect') {
14818
+ const x = el.x.baseVal.value, y = el.y.baseVal.value, w = el.width.baseVal.value, h = el.height.baseVal.value;
14819
+ ring = [at(el, x, y), at(el, x + w, y), at(el, x + w, y + h), at(el, x, y + h)];
14820
+ } else if (el.tagName === 'polygon') {
14821
+ ring = [...el.points].map(q => at(el, q.x, q.y));
14822
+ } else {
14823
+ const cx = el.cx.baseVal.value, cy = el.cy.baseVal.value;
14824
+ const rx = el.tagName === 'circle' ? el.r.baseVal.value : el.rx.baseVal.value, ry = el.tagName === 'circle' ? rx : el.ry.baseVal.value;
14825
+ ring = [];
14826
+ for (let i = 0; i < 12; i++) ring.push(at(el, cx + rx * Math.cos(i * Math.PI / 6), cy + ry * Math.sin(i * Math.PI / 6)));
14827
+ }
14828
+ if (ring && ring.length >= 3 && ring.every(q => isFinite(q[0]) && isFinite(q[1]))) pick.push(ring);
14829
+ });
14830
+ } catch (e) { /* the box below stands in */ }
14831
+ chart = { vx, vy, vw, vh, ox, oy, markup, ids: [...ids], defsById, pick, bb: { x: bb.x, y: bb.y, width: bb.width, height: bb.height },
14007
14832
  svg: `<svg xmlns="${SVG_NS}" width="__W__" height="__H__" viewBox="${vx} ${vy} ${vw} ${vh}"><defs>${defs}</defs>${markup}</svg>` };
14008
14833
  }
14009
14834
  } catch (e) {
@@ -14222,6 +15047,30 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14222
15047
  return this._cacheIcon(key, icon);
14223
15048
  }
14224
15049
 
15050
+ // one flat-top hexagon of circumradius r (px) filling a square raster — the GRIDSIZE
15051
+ // "hexagon" cell (see _buildGridMeshLayers); drawSymbolPath's own hexagon outline
15052
+ _buildGridHexIcon(fillColor, borderColor, borderWidth, r) {
15053
+ const key = `gridhex|${fillColor}|${borderColor}|${borderWidth}|${r}`;
15054
+ if (this._iconCache.has(key)) return this._iconCache.get(key);
15055
+ const size = Math.max(32, Math.min(256, Math.round(2 * r) || 32));
15056
+ const canvas = document.createElement('canvas');
15057
+ canvas.width = size; canvas.height = size;
15058
+ const ctx = canvas.getContext('2d');
15059
+ const rr = size / 2 - 1; // 1px margin so the stroke isn't clipped
15060
+ ctx.beginPath();
15061
+ drawSymbolPath(ctx, 'hexagon', size / 2, size / 2, rr);
15062
+ ctx.closePath();
15063
+ ctx.fillStyle = fillColor;
15064
+ ctx.fill();
15065
+ if (borderColor && borderColor !== 'none' && borderWidth > 0) {
15066
+ ctx.strokeStyle = borderColor;
15067
+ ctx.lineWidth = borderWidth * (size / (2 * r || size));
15068
+ ctx.stroke();
15069
+ }
15070
+ const icon = { url: canvas.toDataURL(), width: size, height: size, anchorX: size / 2, anchorY: size / 2, id: key };
15071
+ return this._cacheIcon(key, icon);
15072
+ }
15073
+
14225
15074
  _buildGridSquareIcon(fillColor, borderColor, borderWidth, cellPx) {
14226
15075
  const key = `grid|${fillColor}|${borderColor}|${borderWidth}|${cellPx}`;
14227
15076
  if (this._iconCache.has(key)) return this._iconCache.get(key);
@@ -14250,6 +15099,135 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14250
15099
  return this._cacheIcon(key, icon);
14251
15100
  }
14252
15101
 
15102
+ // .type("KDE"): the kernel density of the VISIBLE records as stacked bands (the fire pages' KDE).
15103
+ // The records inside the view (plus 3 bandwidths around it, which still add density to it) go
15104
+ // to kdeGrid in a worker, d3-contour cuts the bands at fractions of the maximum (style.kdelevels,
15105
+ // default 0.15 / 0.30 / 0.50 / 0.70), each band the area at or above its threshold, so they
15106
+ // stack, colored from style.colorscheme, drawn with style.fillopacity. The bandwidth is
15107
+ // style.bandwidth (km) or, without it, follows the zoom (kdeBandwidthKm); style.bandwidthscale
15108
+ // (default 1) multiplies either, style.bandwidthzoompow (default 1) flattens the zoom dependence, so a radius control can stay zoom-aware without lagging a redraw.
15109
+ // The result belongs to a view: while the map moves the last bands stay, and once it stands still
15110
+ // (and the view, the records, the binding or the bandwidth changed) the new ones are computed
15111
+ // after a short pause and drawn by a redraw — never more than one computation wanted at a time.
15112
+ // Weights as HEATMAP: the size field, else the numeric value field, else 1 each ($item$).
15113
+ _buildKdeLayers(zoom, bbox, globeCenter) {
15114
+ // the bands are lng/lat polygons of a flat view: under the globe there is no such view — say so once
15115
+ if (globeCenter) {
15116
+ if (!this._warnedKdeGlobe) {
15117
+ this._warnedKdeGlobe = true;
15118
+ console.warn(`[ixmaps-gl] layer "${this.name}": KDE is not drawn under the globe projection (its bands are computed for a flat view) — use mapProjection mercator`);
15119
+ }
15120
+ return [];
15121
+ }
15122
+ const source = this._activeFeatures || this.features;
15123
+ if (this._kdeSource !== source) { this._kdeSource = source; this._kdeSourceId = (this._kdeSourceId || 0) + 1; }
15124
+ const flatZoom = mapLibreToFlatZoom(zoom);
15125
+ const bwScale = styleNum(this.style.bandwidthscale) > 0 ? styleNum(this.style.bandwidthscale) : 1;
15126
+ // style.bandwidthzoompow (0..1) flattens the zoom dependence: the radius is the table's ratio to its
15127
+ // value at the reference flat zoom (style.bandwidthzoomref, default 12), raised to this power
15128
+ // (0 = constant). Without style.bandwidth the radius is the table's, default power 1 (as the fire
15129
+ // pages); WITH style.bandwidth (km) that is the radius AT the reference zoom, default power 0 (fixed).
15130
+ const explicitKm = styleNum(this.style.bandwidth) > 0 ? styleNum(this.style.bandwidth) : 0;
15131
+ const powStyle = styleNum(this.style.bandwidthzoompow);
15132
+ const zoomPow = powStyle >= 0 ? Math.min(1, powStyle) : (explicitKm ? 0 : 1);
15133
+ const refZoom = styleNum(this.style.bandwidthzoomref) > 0 ? styleNum(this.style.bandwidthzoomref) : 12;
15134
+ const tableKm = kdeBandwidthKm(flatZoom), refKm = kdeBandwidthKm(refZoom);
15135
+ const zoomRatio = zoomPow === 1 ? tableKm / refKm : Math.pow(tableKm / refKm, zoomPow);
15136
+ const bwKm = (explicitKm ? explicitKm * zoomRatio : (zoomPow === 1 ? tableKm : refKm * zoomRatio)) * bwScale;
15137
+ const maxCells = kdeMaxCells(flatZoom);
15138
+ const levels = kdeLevels(this.style);
15139
+ const key = JSON.stringify([this._dataGen, this._kdeSourceId, this.binding, bwKm, maxCells, levels, bbox.map(v => Math.round(v * 1e4) / 1e4)]);
15140
+ if (key !== this._kdeKeyDone && key !== this._kdeKeyWanted && !this._viewMoving) {
15141
+ this._kdeKeyWanted = key;
15142
+ clearTimeout(this._kdeTimer);
15143
+ this._kdeTimer = setTimeout(() => {
15144
+ const margin = 3 * bwKm;
15145
+ const mLat = margin / 111.3;
15146
+ const mLng = margin / (111.3 * Math.max(0.05, Math.cos((bbox[1] + bbox[3]) / 2 * Math.PI / 180)));
15147
+ const points = kdePoints(source, this.binding, [bbox[0] - mLng, bbox[1] - mLat, bbox[2] + mLng, bbox[3] + mLat]);
15148
+ Promise.all([ensureD3Contour(), kdeCompute(points, bwKm, maxCells)]).then(([contours, grid]) => {
15149
+ if (this._kdeKeyWanted !== key) return; // the view moved on: a newer computation is wanted
15150
+ this._kdeBands = kdeBands(grid, levels, () => contours());
15151
+ this._kdeBandsGen = (this._kdeBandsGen || 0) + 1; // every set of bands is a new layer for deck.gl
15152
+ this._kdeLevelCount = levels.length;
15153
+ this._kdeBandsBw = bwKm; // the radius these bands were computed for
15154
+ this._kdeKeyDone = key;
15155
+ if (this._triggerRedraw) this._triggerRedraw();
15156
+ }).catch(err => {
15157
+ console.error(`[ixmaps-gl] layer "${this.name}": KDE not computed:`, err);
15158
+ if (this._kdeKeyWanted === key) this._kdeKeyDone = key; // not retried until the view changes
15159
+ });
15160
+ }, 150);
15161
+ }
15162
+ const bands = this._kdeBands;
15163
+ if (!bands || !bands.features.length) return [];
15164
+ // bands kept from before the layer was switched off belong to another view and radius: they are not
15165
+ // shown on switching on until the right ones are computed (a wrong-radius density flashing first
15166
+ // reads as flicker). Once the right ones are there the gate opens, and while the map zooms the
15167
+ // last bands stay as before — a gap on every zoom step would be worse.
15168
+ if (this._kdeShowGate) {
15169
+ if (this._kdeBandsBw !== bwKm) return [];
15170
+ this._kdeShowGate = false;
15171
+ }
15172
+ const colors = kdeBandColors(this.style.colorscheme, this._kdeLevelCount || levels.length);
15173
+ return [new GeoJsonLayer({
15174
+ // a new id for each set of bands: deck.gl keeps a layer's buffers by id, and bands whose polygon
15175
+ // structure changed (holes, counts) under the SAME id drew long triangles between polygons
15176
+ id: this._dataLayerId(`ix-kde-${this.name}-b${this._kdeBandsGen || 0}`),
15177
+ data: bands, pickable: false, stroked: false, filled: true,
15178
+ getFillColor: f => colors[f.properties.level] || colors[colors.length - 1],
15179
+ updateTriggers: { getFillColor: [JSON.stringify(this.style.colorscheme), colors.length] },
15180
+ opacity: styleNum(this.style.fillopacity) || 0.25
15181
+ })];
15182
+ }
15183
+
15184
+ // .type("HEATMAP"): a kernel-density surface (deck.gl HeatmapLayer) of the records' positions,
15185
+ // weighted by heatmapWeight. style.radiuspx (default 30) is the kernel radius in screen pixels
15186
+ // at the reference zoom; it follows the map's object scaling like every symbol
15187
+ // (objectZoomFactor), i.e. it grows and shrinks with the zoom like a real-world distance.
15188
+ // style: scale (radius multiplier), intensity, threshold, colorscheme, fillopacity.
15189
+ //
15190
+ // PERFORMANCE: the layer is rebuilt on every frame of a zoom gesture. A new data array, a new
15191
+ // colorRange or a new radius makes deck redo its GPU work (weight map, color texture) — measured
15192
+ // 90 ms a frame with them rebuilt each time, 15 ms with the same array/colors and a radius that
15193
+ // moves in steps of 2^(1/6) (~12%). So the data and the colors are kept until the records, the
15194
+ // binding or the colorscheme change, and the radius is quantized.
15195
+ _buildHeatmapLayers(liveZoom) {
15196
+ const source = this._activeFeatures || this.features;
15197
+ const bindingKey = JSON.stringify(this.binding);
15198
+ if (!this._heatmapData || this._heatmapSource !== source || this._heatmapBinding !== bindingKey) {
15199
+ const data = [];
15200
+ for (const f of source) {
15201
+ const c = f.geometry && f.geometry.coordinates;
15202
+ if (!c || typeof c[0] !== 'number' || !isFinite(c[0]) || !isFinite(c[1])) continue;
15203
+ const w = heatmapWeight(this.binding, f.properties || {});
15204
+ if (w > 0) data.push({ position: [c[0], c[1]], weight: w });
15205
+ }
15206
+ this._heatmapData = data;
15207
+ this._heatmapSource = source;
15208
+ this._heatmapBinding = bindingKey;
15209
+ }
15210
+ const colorKey = JSON.stringify(this.style.colorscheme);
15211
+ if (!this._heatmapColors || this._heatmapColorKey !== colorKey) {
15212
+ this._heatmapColors = heatmapColorRange(this.style.colorscheme);
15213
+ this._heatmapColorKey = colorKey;
15214
+ }
15215
+ const radiusPx = (styleNum(this.style.radiuspx) || 30) * (styleNum(this.style.scale) || 1)
15216
+ * objectZoomFactor(liveZoom, this.mapOptions);
15217
+ const stepped = Math.pow(2, Math.round(Math.log2(Math.max(1, radiusPx)) * 6) / 6);
15218
+ return [new HeatmapLayer({
15219
+ id: `ix-heatmap-${this.name}`,
15220
+ data: this._heatmapData, pickable: false,
15221
+ getPosition: d => d.position,
15222
+ getWeight: d => d.weight,
15223
+ radiusPixels: Math.max(1, Math.min(400, stepped)),
15224
+ intensity: styleNum(this.style.intensity) || 1,
15225
+ threshold: styleNum(this.style.threshold) || 0.05,
15226
+ colorRange: this._heatmapColors,
15227
+ opacity: styleNum(this.style.fillopacity) || 1
15228
+ })];
15229
+ }
15230
+
14253
15231
  // .type("DOT") — one point per record, always (no clustering, matching
14254
15232
  // the real engine bypassing AGGREGATE for this type). Color comes from
14255
15233
  // a bound value's class — CATEGORICAL (exact match) or numeric
@@ -14265,7 +15243,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14265
15243
  const data = source.map(f => ({
14266
15244
  geometry: f.geometry,
14267
15245
  properties: {
14268
- value: this.binding.size ? parseFloat(f.properties[this.binding.size]) : undefined,
15246
+ value: this.binding.size ? scanValue(f.properties[this.binding.size]) : undefined,
14269
15247
  raw: f.properties,
14270
15248
  cat: hasClasses ? this._resolveClassIndex(f.properties[this.binding.value]) : null
14271
15249
  }
@@ -14522,7 +15500,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14522
15500
  // GRIDSIZE: every chart the size of its grid cell, whatever its value
14523
15501
  // (_gridChartRadiusPx); otherwise valueRadius
14524
15502
  const gridR = this._gridChartRadiusPx(zoom, liveZoom);
14525
- const vRadius = v => (gridR != null ? gridR : valueRadius(v, liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue, this._sizeBound()));
15503
+ // AUTOSIZE: the biggest aggregated item is the size of the cell, the others by value
15504
+ const autoR = this._autoSizeMaxRadiusPx(zoom, liveZoom);
15505
+ const autoMax = autoR != null ? (this._ensureAggregateStats(zoom).sizeMax || 1) : 1;
15506
+ const autoPow = autoR != null ? resolveSizePow(this.style, this.flags) : 1;
15507
+ const vRadius = v => (gridR != null ? gridR
15508
+ : autoR != null ? Math.max(VALUE_RADIUS_MIN, autoR * Math.pow(Math.abs(v || 0) / autoMax, 1 / autoPow))
15509
+ : valueRadius(v, liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue, this._sizeBound()));
14526
15510
  // AGGREGATE sums without a normalsizevalue: flat's default is the
14527
15511
  // largest aggregated value (defaultNormalSizeValue leaves it unset, the
14528
15512
  // sizes depend on the aggregation)
@@ -14738,7 +15722,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14738
15722
  getPosition: d => d.geometry.coordinates,
14739
15723
  getIcon: d => this._getGlowIcon((this.categoryColorsRgb || [])[d.properties.counts ? dominant(this._groupIsolation(d) ? d.properties.counts.map((c, i) => (this._markedClasses.has(i) ? c : 0)) : d.properties.counts) : d.properties.cat]),
14740
15724
  getSize: d => vRadius(sizeValueOf(d)) * (d.properties.counts ? 9 : 11),
14741
- getColor: d => [255, 255, 255, this._iconAlpha(d)],
15725
+ getColor: d => [255, 255, 255, Math.round(this._iconAlpha(d) * glowOpacityOf(this.style))],
14742
15726
  ...(alignOf.active ? { getPixelOffset: alignOf } : {}),
14743
15727
  sizeUnits: 'pixels',
14744
15728
  billboard: true,
@@ -15150,7 +16134,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15150
16134
  if (multi && auto100 && !this._seqAuto100Max) {
15151
16135
  let m = 0;
15152
16136
  for (const f of this.features) {
15153
- const parts = this._multiFields.map(k => parseFloat(f.properties && f.properties[k]) || 0);
16137
+ const parts = this._multiFields.map(k => scanValue(f.properties && f.properties[k]) || 0);
15154
16138
  const sum = parts.reduce((a, v) => a + v, 0);
15155
16139
  if (sum > 0) for (const v of parts) { const nv = v / (sum / 100); if (nv > m) m = nv; }
15156
16140
  }
@@ -15171,12 +16155,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15171
16155
  const sizeField = multi && this.binding.size && this.binding.size !== '$item$' ? this.binding.size : null;
15172
16156
  let sizeMax = 0;
15173
16157
  if (sizeField) {
15174
- for (const f of this.features) { const v = Math.abs(parseFloat(f.properties && f.properties[sizeField]) || 0); if (v > sizeMax) sizeMax = v; }
16158
+ for (const f of this.features) { const v = Math.abs(scanValue(f.properties && f.properties[sizeField]) || 0); if (v > sizeMax) sizeMax = v; }
15175
16159
  }
15176
16160
  const itemSizeFactor = d => {
15177
16161
  if (!sizeField) return 1;
15178
16162
  const sz = typeof d.properties.size === 'number' ? d.properties.size
15179
- : Math.abs(parseFloat((d.properties.raw || {})[sizeField]) || 0);
16163
+ : Math.abs(scanValue((d.properties.raw || {})[sizeField]) || 0);
15180
16164
  return sizeMax > 0 ? Math.pow(sz / sizeMax, 1 / sizePow) : 0;
15181
16165
  };
15182
16166
  const rings = flatFlag(this.flags, 'RINGS');
@@ -15359,7 +16343,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15359
16343
  getIcon: d => this._getGlowIcon(colorOf(d.part.i)),
15360
16344
  getSize: d => d.part.r * 11,
15361
16345
  getPixelOffset: offsetOf,
15362
- getColor: d => [255, 255, 255, alphaOf(d)],
16346
+ getColor: d => [255, 255, 255, Math.round(alphaOf(d) * glowOpacityOf(st))],
15363
16347
  sizeUnits: 'pixels', billboard: true,
15364
16348
  parameters: ICON_LAYER_GLOBE_PARAMETERS
15365
16349
  }));
@@ -15714,7 +16698,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15714
16698
  if (!layout) continue;
15715
16699
  const sum = values.reduce((a, v) => a + v, 0);
15716
16700
  if (star) for (const v of values) if (v > starMaxPart) starMaxPart = v;
15717
- const size = this.binding.size ? parseFloat(raw[this.binding.size]) : undefined;
16701
+ const size = this.binding.size ? scanValue(raw[this.binding.size]) : undefined;
15718
16702
  const r = nSizeOf(sum, size) * unit;
15719
16703
  if (!(r > 0) || !isFinite(r)) continue;
15720
16704
  // flat's draw-order key (maptheme.js 16812-16860): the size value,
@@ -16162,7 +17146,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16162
17146
  return { values: parts, size };
16163
17147
  }
16164
17148
  const raw = f.properties || {};
16165
- return { values: fields.map(k => flatNumber(raw[k], this.flags)), size: sizeField ? parseFloat(raw[this.binding.size]) : undefined };
17149
+ return { values: fields.map(k => flatNumber(raw[k], this.flags)), size: sizeField ? scanValue(raw[this.binding.size]) : undefined };
16166
17150
  };
16167
17151
  const all = agg ? this._pieAggregatedItems(zoom, [-180, -90, 180, 90]) : (this._activeFeatures || this.features);
16168
17152
  // flat's per-part mean, median (getMeanMedianQuantile) and standard
@@ -16887,7 +17871,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16887
17871
  let lo = Infinity, hi = -Infinity;
16888
17872
  const field = this.binding.value;
16889
17873
  for (const f of this.features || []) {
16890
- const v = field === '$item$' ? 1 : parseFloat(f.properties && f.properties[field]);
17874
+ const v = field === '$item$' ? 1 : scanValue(f.properties && f.properties[field]);
16891
17875
  if (isNaN(v)) continue;
16892
17876
  if (v < lo) lo = v;
16893
17877
  if (v > hi) hi = v;
@@ -16997,6 +17981,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16997
17981
  if (!(fontSize > 0)) continue;
16998
17982
  labels.push({ ...d, text: textOf(d.properties), fontSize, halo: itemRgb(d.properties), pixelOffset: alignOf(d) });
16999
17983
  }
17984
+ // a text field read as the value/size scans to 0 (flat's __scanValue), so every label has size 0 and nothing is drawn
17985
+ if (!labels.length && combined.length && !this._warnedNoLabels) {
17986
+ this._warnedNoLabels = true;
17987
+ console.warn(`[ixmaps-gl] theme "${this.name}": LABEL|TEXTONLY drew no labels — the size of all ${combined.length} items is 0. ` +
17988
+ 'A non-numeric (text) field bound as value/size reads as 0; bind a numeric size field.');
17989
+ }
17000
17990
  // TextLayer's outlineColor is one per layer: a layer per halo color
17001
17991
  const byHalo = new Map();
17002
17992
  for (const l of labels) {
@@ -17358,7 +18348,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
17358
18348
  // last built map; called while the map is still building, they run once
17359
18349
  // it is ready.
17360
18350
  const MAP_HANDLE_METHODS = ['replace', 'add', 'newTheme', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
17361
- 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'setProjection', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible', 'switchLayer', 'setThemeTimeFrame', 'attribution'];
18351
+ 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'setProjection', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible', 'switchLayer', 'setThemeTimeFrame', 'attribution', 'on', 'off'];
17362
18352
  const _mapHandles = Object.create(null);
17363
18353
  function makeMapHandle(szMap) {
17364
18354
  const h = {};
@@ -17376,6 +18366,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
17376
18366
  };
17377
18367
  }
17378
18368
  h.getZoom = () => { const a = api(); return a ? a.getZoom() : undefined; };
18369
+ h.getBounds = () => getBounds();
18370
+ // the MapLibre map itself (undefined until the map is built)
18371
+ h.getMapLibre = () => { const a = api(); return a ? a.map : undefined; };
17379
18372
  h.getThemeObj = id => getThemeObj(id);
17380
18373
  // flat's mapApi.message(szText) (htmlgui_flat.js 1381): a short status
17381
18374
  // line over the map — the same accepted no-op as the global
@@ -17487,6 +18480,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
17487
18480
  return [{ lat: Math.max(b[0].lat, -85.05), lng: b[0].lng }, { lat: Math.min(b[1].lat, 85.05), lng: b[1].lng }];
17488
18481
  }
17489
18482
 
18483
+ // flat's mapApi.getBounds() (htmlgui_flat.js): [swLat, swLng, neLat, neLng] — the same box as getBoundingBox
18484
+ function getBounds() {
18485
+ const b = getBoundingBox();
18486
+ return b ? [b[0].lat, b[0].lng, b[1].lat, b[1].lng] : null;
18487
+ }
18488
+
17490
18489
  // flat's ixmaps.setTitle(html) / setTitleBox(text, color) (htmlgui.js
17491
18490
  // 1311-1331): a message line over the last built map, same markup as
17492
18491
  // flat's default (legend not aligned left); '' clears it.
@@ -18032,7 +19031,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
18032
19031
  global.ixmaps = {
18033
19032
  layer, Layer: layer, Map: createMap, setExternalData: setExternalDataBridge, getThemeObj, data: ixmapsData,
18034
19033
  szResourceBase: ixmapsSzResourceBase, getProjectString, setProjectJSON, loadProject,
18035
- markThemeClass, unmarkThemeClass, getBoundingBox, setTitle, setTitleBox, refreshTheme, formatValue: flatFormatValue,
19034
+ markThemeClass, unmarkThemeClass, getBoundingBox, getBounds, setTitle, setTitleBox, refreshTheme, formatValue: flatFormatValue,
18036
19035
  // flat's ixmaps.__formatValue (ui/js/tools/legend.js 451-553) — pages call
18037
19036
  // it for statistics/legend values; legend's own variant differs from
18038
19037
  // format.js's only in the SPACE flag, which flatFormatValue covers too
@@ -18095,7 +19094,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
18095
19094
  // engine runs in a Node vm there); deliberately NOT on the ixmaps object
18096
19095
  global.__ixmapsGlInternals = {
18097
19096
  IXMAPS_GL_VERSION, applyFlatMaxCharts, youtubeClickToPlay, symbolsFlagCompat, tooltipTable, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout, ringsLayout, pieSliceLayout, pieValueLabelLayout, isBarChart, flatBarLayout, flatValueRules, flatPieRecord, flatPieAccumulate, flatPieItemValues, flatVectorItems, orthographicPlane, vectorArcHeight, pathBackIndex, greatCircleMeters, cameraOptions, bezierVectorLayout, cubicBezierPoints, arrowMarkerTriangle, fadeGradientStops, flatToArray, hashUnit, flowDashPhase, multiQuadOffsets, pixelOffsetLngLat, chartTextTransmittance, insideSymbolShape,
18098
- chartBoxLayout, choroplethOutlineColor, normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, MapBuilder, typeStyleNumbers, styleNum,
19097
+ splitMapEvents, getBounds, chartBoxLayout, choroplethOutlineColor, normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, MapBuilder, typeStyleNumbers, styleNum,
18099
19098
  resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
18100
19099
  equalIntervalBreaks, quantileBreaks, naturalBreaks, evenStrideSample, jenksBreakValues, partsFromBreakValues, resolvePartsClass,
18101
19100
  computeAlphaStats, computeMultiFieldClasses, computeDominantStats, resolveDominantClass, computeComposeColorStats,
@@ -18104,7 +19103,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
18104
19103
  flatToMapLibreZoom, mapLibreToFlatZoom, flatViewToMapLibreZoom, mapLibreToFlatViewZoom, flatSvgProjectionOf, flatSvgProjectionByOptions, normalSizeScaleOf, scaleDenominatorAt, resolveZoomReference, resolveAggregationPx, valuesHiddenByScale,
18105
19104
  fetchLayerData, QUERY_HANDOFF, parseCsvText, dataTableRows, geometryRowsToFeatureCollection, filterFlatValues, applyField100, field100Binding, rangeClassLegendTotals, resolveAggregateValue, classValueSeparate, cellAggregatedValues, oneHot, groupCoLocated, aggregateOnGrid, GridAggregateIndex, flatRangeParts, explicitRangeBreaks, explicitRangeParts, resolvePartsClass, isNoneColor, aggregateField100, valueRadius, itemPlotGeometry, itemAnchor, objectZoomFactor, resolveZoomReference, defaultNormalSizeValue,
18106
19105
  applyWhereFilter, joinChartPositions, joinChoroplethFeatures, flatLookupKey, flatShapeCenter, chartHiddenByScale, glowHiddenByScale, isSymbolChart, flatChartAlignOffset, flatChartBranch, flatValueText, categoryValueRecord, flatDerivateRgb, flatBubbleLook, bubbleIconLook, flatChartTextRgb, flatGroupedValue, flatNoBreaks, featuresHiddenByScale, boxHiddenByScale, flatShadowOn, snapToAggregationGrid,
18107
- drawSymbolPath, normalizeSymbolShape, bubbleStrokeRgb,
19106
+ drawSymbolPath, normalizeSymbolShape, bubbleStrokeRgb, heatmapColorRange, heatmapWeight,
19107
+ mlHeatmapPoints, mlHeatmapWeightMax, mlHeatmapRadius, mlHeatmapPaint, syncNativeHeatmaps, mlHeatLayerId,
19108
+ kdeBandwidthKm, kdeMaxCells, kdeLevels, kdePoints, kdeGrid, kdeBands, kdeBandColors, kdeWorkerSource, kdeCompute, kdeReset, setDeckLayerClassesForTests, ensureD3Contour, colorRampRgb,
18108
19109
  flatFormatValue, themeStyleArgs, flatChangedStyleValue, explicitValueDecimals, legendDecimals,
18109
19110
  dataCacheKey, dataCacheDisabled, cachedLayerData, featuresBounds, wantsZoomToExtent, legendIsOn, applySphereBackdrop,
18110
19111
  };