ixmaps-gl 0.2.30 → 0.2.31

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 +3 -3
  2. package/ixmaps-gl.js +787 -74
  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.31';
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,7 @@ 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);
612
617
  PieSliceLayer = makePieSliceLayer(ScatterplotLayer);
613
618
  BarShapeLayer = makeBarShapeLayer(ScatterplotLayer);
614
619
  FlowPathLayer = makeFlowPathLayer(PathLayer);
@@ -1942,7 +1947,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1942
1947
  function flatShapeAreaKm2(featureRt, f) {
1943
1948
  const sizeField = featureRt.binding && featureRt.binding.size;
1944
1949
  if (sizeField) {
1945
- const v = parseFloat(f.properties && f.properties[sizeField]);
1950
+ const v = scanValue(f.properties && f.properties[sizeField]);
1946
1951
  return isFinite(v) ? v / 1000000 : NaN;
1947
1952
  }
1948
1953
  return geodesicPolygonAreaKm2(f.geometry);
@@ -2001,6 +2006,415 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2001
2006
  return isFinite(o) ? Math.max(0, Math.min(1, o)) : 0;
2002
2007
  }
2003
2008
 
2009
+ // flat's CHART|SYMBOL DOPACITY (maptheme.js 22142-22145): (v-min)^(1/pow) /
2010
+ // (max-min)^(1/pow) · fillopacity(|| 1) · dopacityscale, clamped to 0..1 —
2011
+ // min/max are the theme-wide extremes of the cell values, one pair for all
2012
+ // cells (not the CHOROPLETH|DOPACITY median ramp, not a per-class max)
2013
+ function symbolDopacityAlpha(style, v, min, max, fillopacity) {
2014
+ const pow = 1 / (styleNum(style.dopacitypow) || 1);
2015
+ const scale = styleNum(style.dopacityscale) || 1;
2016
+ const fo = fillopacity || 1;
2017
+ const o = Math.pow(v - min, pow) / Math.pow(max - min, pow) * fo * scale;
2018
+ return isFinite(o) ? Math.max(0, Math.min(1, o)) : fo;
2019
+ }
2020
+
2021
+ // .type("KDE") — kernel density estimate as stacked bands (the fire pages' KDE, done in the
2022
+ // engine). The pure parts: bandwidth by zoom, the points, the density grid, the bands.
2023
+ //
2024
+ // The value of a grid cell is what the pages computed by brute force — the sum over the points
2025
+ // of weight · exp(−d² / 2h²), d the distance in km, h the bandwidth — but here by BINNING each
2026
+ // weight into its four nearest cells (bilinear) and blurring the grid with a separable Gaussian
2027
+ // (σ = h in cells): O(points + cells · kernel) instead of O(cells · points). Thresholds are
2028
+ // fractions of the maximum, so only the shape of the density matters, not its absolute scale.
2029
+
2030
+ // flat zoom → bandwidth (km) and the cell budget of the grid, as the fire pages chose them
2031
+ const KDE_ZOOM_TABLE = [ // [from flat zoom, bandwidth km, max cells]
2032
+ [16, 0.04, 1000000], [15, 0.08, 800000], [14, 0.12, 600000], [13, 0.30, 300000],
2033
+ [12, 0.80, 100000], [11, 2.00, 40000], [10, 5.00, 25000], [9, 10.0, 20000]
2034
+ ];
2035
+ const KDE_FAR_ZOOM = [24.0, 15000];
2036
+ const KDE_DEFAULT_LEVELS = [0.15, 0.30, 0.50, 0.70];
2037
+ const KDE_MIN_POINTS = 5; // fewer points have no density to speak of (the pages' rule)
2038
+ const KDE_MARGIN_SIGMAS = 3; // the grid reaches 3 bandwidths beyond the outermost points
2039
+ const KDE_KM_PER_DEG = 111.3; // the pages' constant
2040
+
2041
+ function kdeZoomRow(flatZoom) {
2042
+ const z = Number(flatZoom);
2043
+ const row = KDE_ZOOM_TABLE.find(r => z >= r[0]);
2044
+ return row ? [row[1], row[2]] : KDE_FAR_ZOOM;
2045
+ }
2046
+ function kdeBandwidthKm(flatZoom) { return kdeZoomRow(flatZoom)[0]; }
2047
+ function kdeMaxCells(flatZoom) { return kdeZoomRow(flatZoom)[1]; }
2048
+
2049
+ // style.kdelevels: the threshold fractions of the maximum, as an array or "0.2,0.5" — numbers in
2050
+ // (0, 1], ascending, each once; anything unusable gives the default
2051
+ function kdeLevels(style) {
2052
+ let v = style && style.kdelevels;
2053
+ if (typeof v === 'string') v = v.split(/[,\s|]+/).filter(Boolean);
2054
+ if (!Array.isArray(v)) return KDE_DEFAULT_LEVELS.slice();
2055
+ const out = [...new Set(v.map(Number).filter(x => isFinite(x) && x > 0 && x <= 1))].sort((a, b) => a - b);
2056
+ return out.length ? out : KDE_DEFAULT_LEVELS.slice();
2057
+ }
2058
+
2059
+ // the records' positions and weights as one Float64Array [lng, lat, weight, …] (cheap to hand to
2060
+ // a worker); `bbox` [west, south, east, north] keeps only what lies inside it
2061
+ function kdePoints(features, binding, bbox) {
2062
+ const out = [];
2063
+ for (const f of features) {
2064
+ const c = f.geometry && f.geometry.coordinates;
2065
+ if (!c || typeof c[0] !== 'number' || !isFinite(c[0]) || !isFinite(c[1])) continue;
2066
+ if (bbox && (c[0] < bbox[0] || c[0] > bbox[2] || c[1] < bbox[1] || c[1] > bbox[3])) continue;
2067
+ const w = heatmapWeight(binding, f.properties || {});
2068
+ if (w > 0) out.push(c[0], c[1], w);
2069
+ }
2070
+ return Float64Array.from(out);
2071
+ }
2072
+
2073
+ // The density grid of `points` ([lng, lat, w, …]) at bandwidth `bwKm`, on about `maxCells` cells
2074
+ // that are square in km. Returns { values (row-major, row j at latitude s + (j + 0.5)·stepLat),
2075
+ // nx, ny, w0, s, stepLng, stepLat, max }, or null for fewer than KDE_MIN_POINTS points. Cells
2076
+ // coarser than the bandwidth still conserve the weights (binning, not sampling).
2077
+ // SELF-CONTAINED on purpose: its source is handed to a Web Worker (Function.prototype.toString),
2078
+ // so it uses nothing from this file.
2079
+ function kdeGrid(points, bwKm, maxCells) {
2080
+ const n = points.length / 3;
2081
+ if (n < 5 || !(bwKm > 0)) return null;
2082
+ const KM = 111.3;
2083
+ let w0 = Infinity, e = -Infinity, s = Infinity, nn = -Infinity;
2084
+ for (let i = 0; i < points.length; i += 3) {
2085
+ const lng = points[i], lat = points[i + 1];
2086
+ if (lng < w0) w0 = lng; if (lng > e) e = lng;
2087
+ if (lat < s) s = lat; if (lat > nn) nn = lat;
2088
+ }
2089
+ const kmLng = Math.max(KM * Math.cos(((s + nn) / 2) * Math.PI / 180), 0.001);
2090
+ const margin = 3 * bwKm;
2091
+ s -= margin / KM; nn += margin / KM; w0 -= margin / kmLng; e += margin / kmLng;
2092
+ const wKm = (e - w0) * kmLng, hKm = (nn - s) * KM;
2093
+ const cell = Math.sqrt(wKm * hKm / Math.max(maxCells, 25));
2094
+ const nx = Math.max(5, Math.ceil(wKm / cell)), ny = Math.max(5, Math.ceil(hKm / cell));
2095
+ const stepLng = (e - w0) / nx, stepLat = (nn - s) / ny;
2096
+
2097
+ // bin: each weight into its four nearest cell centers (cell i is centered at (i + 0.5) · step)
2098
+ let a = new Float32Array(nx * ny);
2099
+ const put = (ix, iy, v) => {
2100
+ if (ix < 0) ix = 0; else if (ix > nx - 1) ix = nx - 1;
2101
+ if (iy < 0) iy = 0; else if (iy > ny - 1) iy = ny - 1;
2102
+ a[iy * nx + ix] += v;
2103
+ };
2104
+ for (let i = 0; i < points.length; i += 3) {
2105
+ const cx = (points[i] - w0) / stepLng - 0.5, cy = (points[i + 1] - s) / stepLat - 0.5;
2106
+ const ix = Math.floor(cx), iy = Math.floor(cy), fx = cx - ix, fy = cy - iy, w = points[i + 2];
2107
+ put(ix, iy, w * (1 - fx) * (1 - fy)); put(ix + 1, iy, w * fx * (1 - fy));
2108
+ put(ix, iy + 1, w * (1 - fx) * fy); put(ix + 1, iy + 1, w * fx * fy);
2109
+ }
2110
+
2111
+ // separable Gaussian, peak 1 (σ in cells per axis, kernel to 3σ; no blur when σ is below a third of a cell)
2112
+ const kernel = sigma => {
2113
+ const r = sigma < 0.33 ? 0 : Math.ceil(3 * sigma);
2114
+ const k = new Float32Array(2 * r + 1);
2115
+ for (let d = -r; d <= r; d++) k[d + r] = Math.exp(-(d * d) / (2 * sigma * sigma));
2116
+ return { k, r };
2117
+ };
2118
+ const kx = kernel(bwKm / (stepLng * kmLng)), ky = kernel(bwKm / (stepLat * KM));
2119
+ let b = new Float32Array(nx * ny);
2120
+ for (let j = 0; j < ny; j++) {
2121
+ const row = j * nx;
2122
+ for (let i = 0; i < nx; i++) {
2123
+ let v = 0;
2124
+ const lo = Math.max(0, i - kx.r), hi = Math.min(nx - 1, i + kx.r);
2125
+ for (let t = lo; t <= hi; t++) v += a[row + t] * kx.k[t - i + kx.r];
2126
+ b[row + i] = v;
2127
+ }
2128
+ }
2129
+ a = new Float32Array(nx * ny);
2130
+ let max = 0;
2131
+ for (let j = 0; j < ny; j++) {
2132
+ const lo = Math.max(0, j - ky.r), hi = Math.min(ny - 1, j + ky.r);
2133
+ for (let i = 0; i < nx; i++) {
2134
+ let v = 0;
2135
+ for (let t = lo; t <= hi; t++) v += b[t * nx + i] * ky.k[t - j + ky.r];
2136
+ a[j * nx + i] = v;
2137
+ if (v > max) max = v;
2138
+ }
2139
+ }
2140
+ return { values: a, nx, ny, w0, s, stepLng, stepLat, max };
2141
+ }
2142
+
2143
+ // The bands of a kdeGrid: one MultiPolygon feature per threshold (fraction · max), each the area
2144
+ // at or above it — so the bands stack, the highest on top — in lng/lat. `makeContours` is
2145
+ // d3.contours (d3-contour), handed in so this stays pure. d3 puts the value of cell (i, j) at
2146
+ // (i + 0.5, j + 0.5), the same convention as the grid, hence the plain scaling by the steps.
2147
+ function kdeBands(grid, fractions, makeContours) {
2148
+ const empty = { type: 'FeatureCollection', features: [] };
2149
+ if (!grid || !(grid.max > 0) || !fractions.length) return empty;
2150
+ const thresholds = fractions.map(f => f * grid.max);
2151
+ const bands = makeContours().size([grid.nx, grid.ny]).thresholds(thresholds)(grid.values);
2152
+ const features = [];
2153
+ bands.forEach((mp, k) => {
2154
+ if (!mp.coordinates || !mp.coordinates.length) return;
2155
+ features.push({
2156
+ type: 'Feature',
2157
+ properties: { level: k, fraction: fractions[k], value: mp.value },
2158
+ geometry: {
2159
+ type: 'MultiPolygon',
2160
+ coordinates: mp.coordinates.map(poly => poly.map(ring => ring.map(p => [grid.w0 + p[0] * grid.stepLng, grid.s + p[1] * grid.stepLat])))
2161
+ }
2162
+ });
2163
+ });
2164
+ return { type: 'FeatureCollection', features };
2165
+ }
2166
+
2167
+ // The worker's whole source: kdeGrid alone (it uses nothing else of this file), answering
2168
+ // {id, points, bwKm, maxCells} with {id, grid}, the grid's values handed back by transfer.
2169
+ function kdeWorkerSource() {
2170
+ return `const kdeGrid = ${kdeGrid.toString()};
2171
+ self.onmessage = e => {
2172
+ const { id, points, bwKm, maxCells } = e.data;
2173
+ const grid = kdeGrid(points, bwKm, maxCells);
2174
+ self.postMessage({ id, grid }, grid ? [grid.values.buffer] : []);
2175
+ };`;
2176
+ }
2177
+
2178
+ // kdeGrid off the main thread: one shared Web Worker, built from kdeWorkerSource. Without a Worker
2179
+ // (a page that forbids blob workers, a test) or when it fails, the same function runs inline.
2180
+ let _kdeWorker = null, _kdeSeq = 0;
2181
+ const _kdePending = new Map();
2182
+ // tests: forget the worker and the loaded d3-contour
2183
+ function kdeReset() { if (_kdeWorker && _kdeWorker.terminate) _kdeWorker.terminate(); _kdeWorker = null; _kdePending.clear(); _d3ContourPromise = null; }
2184
+ // tests: the deck.gl layer classes a runtime builds with, without loading deck.gl
2185
+ function setDeckLayerClassesForTests(classes) { if (classes.GeoJsonLayer) GeoJsonLayer = classes.GeoJsonLayer; }
2186
+ function kdeCompute(points, bwKm, maxCells) {
2187
+ return new Promise(resolve => {
2188
+ try {
2189
+ if (_kdeWorker === null && typeof global.Worker === 'function' && typeof global.Blob === 'function' && global.URL && global.URL.createObjectURL) {
2190
+ const w = new global.Worker(global.URL.createObjectURL(new global.Blob([kdeWorkerSource()], { type: 'text/javascript' })));
2191
+ w.onmessage = e => { const p = _kdePending.get(e.data.id); if (p) { _kdePending.delete(e.data.id); p.resolve(e.data.grid); } };
2192
+ w.onerror = () => {
2193
+ _kdeWorker = false; // no worker from now on: whatever is waiting is computed here
2194
+ const waiting = [..._kdePending.values()]; _kdePending.clear();
2195
+ waiting.forEach(p => p.resolve(kdeGrid(...p.args)));
2196
+ };
2197
+ _kdeWorker = w;
2198
+ }
2199
+ } catch (err) { _kdeWorker = false; }
2200
+ if (!_kdeWorker) { resolve(kdeGrid(points, bwKm, maxCells)); return; }
2201
+ const id = ++_kdeSeq;
2202
+ _kdePending.set(id, { resolve, args: [points, bwKm, maxCells] });
2203
+ _kdeWorker.postMessage({ id, points, bwKm, maxCells });
2204
+ });
2205
+ }
2206
+
2207
+ // d3.contours of d3-contour 4 (+ d3-array 3), fetched once and evaluated PRIVATELY with a
2208
+ // CommonJS `require`: nothing is added to a window.d3 the page may have (d3-array would overwrite
2209
+ // the same-named functions of a d3 v3). Resolves to the `contours` function.
2210
+ let _d3ContourPromise = null;
2211
+ function ensureD3Contour() {
2212
+ if (!_d3ContourPromise) {
2213
+ const get = url => global.fetch(url).then(r => { if (!r.ok) throw new Error(`[ixmaps-gl] failed to load ${url}`); return r.text(); });
2214
+ const run = (src, req) => { const module = { exports: {} }; new Function('module', 'exports', 'require', src)(module, module.exports, req); return module.exports; };
2215
+ _d3ContourPromise = Promise.all([get(LIB_URLS.d3Array), get(LIB_URLS.d3Contour)]).then(([arraySrc, contourSrc]) => {
2216
+ const d3Array = run(arraySrc, () => { throw new Error('[ixmaps-gl] d3-array has no dependencies'); });
2217
+ const d3Contour = run(contourSrc, name => { if (name === 'd3-array') return d3Array; throw new Error(`[ixmaps-gl] d3-contour requires ${name}`); });
2218
+ if (typeof d3Contour.contours !== 'function') throw new Error('[ixmaps-gl] d3-contour: no contours()');
2219
+ return d3Contour.contours;
2220
+ }).catch(err => { _d3ContourPromise = null; throw err; });
2221
+ }
2222
+ return _d3ContourPromise;
2223
+ }
2224
+
2225
+ // A color ramp of `steps` RGB colors from a colorscheme, low → high: a generated scheme
2226
+ // (["5","white","blue"]) is flat's own sweep, a list of colors is taken as given (interpolated),
2227
+ // ONE color is a ramp of itself; `fallback` when nothing in it can be read. Shared by HEATMAP
2228
+ // (colorRange) and KDE (one color per band).
2229
+ function colorRampRgb(colorscheme, steps, fallback) {
2230
+ let rgb;
2231
+ if (isGeneratedColorScheme(colorscheme)) {
2232
+ rgb = flatColorSweep(colorscheme[1], colorscheme[2], Number(colorscheme[0]) || steps, colorscheme[3], colorscheme[4]) || [];
2233
+ } else {
2234
+ const list = Array.isArray(colorscheme) ? colorscheme : colorscheme ? [colorscheme] : [];
2235
+ rgb = list.map(c => parseCssColor(String(c))).filter(Boolean);
2236
+ }
2237
+ if (!rgb.length) rgb = [fallback];
2238
+ return Array.from({ length: steps }, (_, i) => {
2239
+ const t = steps > 1 ? i / (steps - 1) : 1;
2240
+ const x = t * (rgb.length - 1), k = Math.min(rgb.length - 2, Math.floor(x));
2241
+ const c = rgb.length === 1 ? rgb[0] : rgb[k].map((v, j) => Math.round(v + (rgb[k + 1][j] - v) * (x - k)));
2242
+ return [c[0], c[1], c[2]];
2243
+ });
2244
+ }
2245
+
2246
+ // .type("HEATMAP") — deck.gl HeatmapLayer's colorRange (low → high density, RGBA): the ramp of
2247
+ // `steps` colors with an alpha that rises from 0 over the first part of it, so the tails of a
2248
+ // kernel fade out instead of ending in a hard edge.
2249
+ function heatmapColorRange(colorscheme, steps = 6) {
2250
+ return colorRampRgb(colorscheme, steps, [229, 57, 53]).map((c, i) => {
2251
+ const t = steps > 1 ? i / (steps - 1) : 1;
2252
+ return [c[0], c[1], c[2], Math.round(255 * Math.min(1, t * 2.5))];
2253
+ });
2254
+ }
2255
+
2256
+ // .type("KDE") — the color of each band, lowest threshold first (the fire pages' #dd0088 when the
2257
+ // colorscheme says nothing usable)
2258
+ function kdeBandColors(colorscheme, count) { return colorRampRgb(colorscheme, count, [221, 0, 136]); }
2259
+
2260
+ // .type("HEATMAP") — the weight of one record: its size field if bound, else its value field
2261
+ // (a text value field just counts the record), 1 for "$item$"; below 0 or not a number → 0
2262
+ function heatmapWeight(binding, props) {
2263
+ const b = binding || {};
2264
+ const fromSize = !!b.size && b.size !== '$item$';
2265
+ const field = fromSize ? b.size
2266
+ : (b.size === '$item$' || !b.value || b.value === '$item$' || String(b.value).includes('|')) ? null : b.value;
2267
+ if (!field) return 1;
2268
+ const w = scanValue(props[field]);
2269
+ if (isFinite(w)) return w > 0 ? w : 0;
2270
+ return fromSize ? 0 : 1;
2271
+ }
2272
+
2273
+ // ── .type("MLHEATMAP") — MapLibre's NATIVE heatmap layer (type "heatmap"). The pure parts: the
2274
+ // points, the weight that counts 1, the radius by zoom, the paint. Unlike HEATMAP (deck.gl's
2275
+ // HeatmapLayer, which aggregates the whole dataset into one 2048² texture whenever the view
2276
+ // changes and scales the density to the maximum in view), MapLibre keeps the points in tile
2277
+ // buffers, evaluates radius and intensity from the zoom on the GPU at draw time and reads the
2278
+ // density ABSOLUTELY through the color ramp (clamped at 1): zooming costs nothing and a cell means
2279
+ // the same at every zoom. The weight is `w`, the density 1 is reached by a point of weight
2280
+ // weightmax (and more by overlapping points).
2281
+ const MLHEATMAP_MIN_RADIUS_PX = 0.5;
2282
+ const MLHEATMAP_MAX_RADIUS_PX = 400;
2283
+
2284
+ // the records as GeoJSON points {w: weight}, for the native layer's source (weights ≤ 0 and
2285
+ // records without a position are left out)
2286
+ function mlHeatmapPoints(features, binding) {
2287
+ const out = [];
2288
+ for (const f of features) {
2289
+ const c = f.geometry && f.geometry.coordinates;
2290
+ if (!c || typeof c[0] !== 'number' || !isFinite(c[0]) || !isFinite(c[1])) continue;
2291
+ const w = heatmapWeight(binding, f.properties || {});
2292
+ if (w > 0) out.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [c[0], c[1]] }, properties: { w } });
2293
+ }
2294
+ return { type: 'FeatureCollection', features: out };
2295
+ }
2296
+
2297
+ // the weight that counts 1: style.weightmax, else the largest weight of the records (1 for none)
2298
+ function mlHeatmapWeightMax(features, binding, style) {
2299
+ const given = styleNum(style && style.weightmax);
2300
+ if (given > 0) return given;
2301
+ let max = 0;
2302
+ for (const f of features) {
2303
+ const c = f.geometry && f.geometry.coordinates;
2304
+ if (!c || typeof c[0] !== 'number' || !isFinite(c[0]) || !isFinite(c[1])) continue;
2305
+ const w = heatmapWeight(binding, f.properties || {});
2306
+ if (w > max) max = w;
2307
+ }
2308
+ return max > 0 ? max : 1;
2309
+ }
2310
+
2311
+ // heatmap-radius: `radiusPx` at the reference zoom `zoomRef`, growing with the map like every object
2312
+ // (objectZoomFactor: radiusPx · 2^((zoom − zoomRef) / pow)), kept between 0.5 and 400 px. An
2313
+ // exponential interpolation of base 2^(1/pow) between the two zooms where the limits are reached is
2314
+ // exactly that curve, so MapLibre evaluates it from the zoom without any rebuild. A number when the
2315
+ // radius does not follow the zoom (objectscaling not dynamic) or is already at a limit everywhere.
2316
+ function mlHeatmapRadius(radiusPx, zoomRef, pow, dynamic) {
2317
+ const r0 = radiusPx > 0 ? radiusPx : 30;
2318
+ const clamp = r => Math.max(MLHEATMAP_MIN_RADIUS_PX, Math.min(MLHEATMAP_MAX_RADIUS_PX, r));
2319
+ if (!dynamic) return clamp(r0);
2320
+ const p = pow > 0 ? pow : 1;
2321
+ const zoomOf = r => zoomRef + p * Math.log2(r / r0); // the zoom at which the radius is r
2322
+ const radiusAt = z => r0 * Math.pow(2, (z - zoomRef) / p);
2323
+ const zLow = Math.max(0, zoomOf(MLHEATMAP_MIN_RADIUS_PX));
2324
+ const zCap = Math.min(24, zoomOf(MLHEATMAP_MAX_RADIUS_PX));
2325
+ if (!(zCap > zLow + 1e-6)) return clamp(radiusAt(zLow));
2326
+ return ['interpolate', ['exponential', Math.pow(2, 1 / p)], ['zoom'], zLow, clamp(radiusAt(zLow)), zCap, clamp(radiusAt(zCap))];
2327
+ }
2328
+
2329
+ // the paint of the native heatmap layer. Colors: the colorscheme as a ramp over the densities
2330
+ // 0.2 … 1 (transparent at 0, like MapLibre's own example, so the tails fade out); a scheme of one
2331
+ // color fades in by alpha instead. style: intensity (default 1), fillopacity (default 1).
2332
+ function mlHeatmapPaint(style, weightMax, radius) {
2333
+ const st = style || {};
2334
+ const ramp = colorRampRgb(st.colorscheme, 5, [229, 57, 53]);
2335
+ const single = ramp.every(c => c.join() === ramp[0].join());
2336
+ const color = ['interpolate', ['linear'], ['heatmap-density'], 0, `rgba(${ramp[0].join(',')},0)`];
2337
+ ramp.forEach((c, i) => {
2338
+ const d = (i + 1) / 5;
2339
+ color.push(d, single ? `rgba(${c.join(',')},${d})` : `rgb(${c.join(',')})`);
2340
+ });
2341
+ const intensity = styleNum(st.intensity), opacity = styleNum(st.fillopacity);
2342
+ return {
2343
+ 'heatmap-weight': ['interpolate', ['linear'], ['get', 'w'], 0, 0, weightMax > 0 ? weightMax : 1, 1],
2344
+ 'heatmap-intensity': intensity > 0 ? intensity : 1,
2345
+ 'heatmap-color': color,
2346
+ 'heatmap-radius': radius,
2347
+ 'heatmap-opacity': opacity > 0 ? Math.min(opacity, 1) : 1
2348
+ };
2349
+ }
2350
+
2351
+ // The native heatmap layers of the MLHEATMAP themes, kept in step with the runtimes (as the WMS
2352
+ // raster layers are): per theme one GeoJSON source of weighted points and one `heatmap` layer, put
2353
+ // BEFORE the first deck.gl layer (those are MapLibre custom layers) so the symbols stay on top.
2354
+ // Recomputed only when something changed — the records (filter, reload), the binding, the weight
2355
+ // max — and the paint only when its values changed; a new style (setMapType) drops sources and
2356
+ // layers, so a missing one is simply added again from the cached points. `owned` holds the layer ids
2357
+ // this function added, so those of removed themes can be taken away.
2358
+ // (MapLibre's basemap-opacity pass, applyBasemapOpacity, only knows fill/line/raster/circle/symbol
2359
+ // types: a heatmap layer is not faded with the basemap.)
2360
+ const _mlSourceIds = new WeakMap();
2361
+ let _mlSourceSeq = 0;
2362
+ const mlHeatLayerId = name => 'ixmaps-mlheat:' + String(name).replace(/[^a-zA-Z0-9_-]/g, '_');
2363
+ function mlFirstCustomLayerId(map) {
2364
+ const order = typeof map.getLayersOrder === 'function' ? map.getLayersOrder() : (map.getStyle().layers || []).map(l => l.id);
2365
+ return order.find(id => { const l = map.getLayer(id); return l && l.type === 'custom'; });
2366
+ }
2367
+ function syncNativeHeatmaps(map, runtimes, owned) {
2368
+ if (!map || typeof map.getLayer !== 'function') return;
2369
+ const desired = runtimes.filter(rt => rt.flags && rt.flags.has('MLHEATMAP'));
2370
+ if (!desired.length && !owned.size) return;
2371
+ const wanted = new Set(desired.map(rt => mlHeatLayerId(rt.name)));
2372
+ [...owned].forEach(id => {
2373
+ if (wanted.has(id)) return;
2374
+ owned.delete(id);
2375
+ try { if (map.getLayer(id)) map.removeLayer(id); } catch (e) { /* already gone */ }
2376
+ try { if (map.getSource(id)) map.removeSource(id); } catch (e) { /* already gone */ }
2377
+ });
2378
+ desired.forEach(rt => {
2379
+ const id = mlHeatLayerId(rt.name);
2380
+ const st = rt._mlHeat || (rt._mlHeat = {});
2381
+ try {
2382
+ const records = rt._activeFeatures || rt.features || [];
2383
+ if (!_mlSourceIds.has(records)) _mlSourceIds.set(records, ++_mlSourceSeq);
2384
+ const style = rt.style || {};
2385
+ const dataKey = JSON.stringify([rt._dataGen, _mlSourceIds.get(records), rt.binding]);
2386
+ const weightKey = JSON.stringify([dataKey, style.weightmax]);
2387
+ if (st.weightKey !== weightKey) { st.weightMax = mlHeatmapWeightMax(records, rt.binding, style); st.weightKey = weightKey; }
2388
+ if (st.dataKey !== dataKey || !st.fc) {
2389
+ st.fc = mlHeatmapPoints(records, rt.binding);
2390
+ st.dataKey = dataKey;
2391
+ const src = map.getSource(id);
2392
+ if (src && typeof src.setData === 'function') src.setData(st.fc);
2393
+ }
2394
+ if (!map.getSource(id)) map.addSource(id, { type: 'geojson', data: st.fc });
2395
+ owned.add(id);
2396
+ // the radius follows the zoom like every object (objectscaling), from `radiuspx` at the reference zoom
2397
+ const mo = rt.mapOptions || {};
2398
+ const radius = mlHeatmapRadius((styleNum(style.radiuspx) || 30) * (styleNum(style.scale) || 1),
2399
+ resolveZoomReference(mo), resolveDynamicScalePow(mo), String(mo.objectscaling || 'dynamic') === 'dynamic');
2400
+ const paint = mlHeatmapPaint(style, st.weightMax, radius);
2401
+ const paintKey = JSON.stringify(paint);
2402
+ if (!map.getLayer(id)) {
2403
+ map.addLayer({ id, type: 'heatmap', source: id, paint }, mlFirstCustomLayerId(map));
2404
+ st.paintKey = paintKey;
2405
+ } else if (st.paintKey !== paintKey) {
2406
+ Object.keys(paint).forEach(k => map.setPaintProperty(id, k, paint[k]));
2407
+ st.paintKey = paintKey;
2408
+ }
2409
+ const visibility = rt._hidden ? 'none' : 'visible';
2410
+ if ((map.getLayoutProperty(id, 'visibility') || 'visible') !== visibility) map.setLayoutProperty(id, 'visibility', visibility);
2411
+ st.failed = false;
2412
+ } catch (err) {
2413
+ if (!st.failed) { st.failed = true; console.warn(`[ixmaps-gl] layer "${rt.name}": MLHEATMAP not drawn:`, err && err.message ? err.message : err); }
2414
+ }
2415
+ });
2416
+ }
2417
+
2004
2418
  // a choropleth shape's outline color: a LINE shape strokes with its value
2005
2419
  // color; any other shape keeps the theme's outline, hidden while a class
2006
2420
  // is isolated and the shape is not in it
@@ -2033,6 +2447,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2033
2447
  // specific FEATURE runtime as a donor, not just sharing its name
2034
2448
  // coincidentally.
2035
2449
  geomRt._isChoroplethGeometryDonor = true;
2450
+ // the donor's own style (outline) — the layer registry is keyed by name,
2451
+ // so the same-named CHOROPLETH replaces the donor there and can't find it
2452
+ Object.defineProperty(spec, '_donorStyle', { value: geomRt.style, configurable: true });
2036
2453
  const idField = geomRt.binding.id;
2037
2454
  const lookupField = spec.binding.lookup;
2038
2455
  if (!idField || !lookupField) {
@@ -2820,7 +3237,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2820
3237
  // edit it by hand; `npm run unit` fails when it drifts.
2821
3238
  // ---------------------------------------------------------------
2822
3239
  // <grammar:binding-aliases>
2823
- // generated from ixmaps-grammar 0.1.28 (ixmaps-flat 1.0.42, 2b978d5 2026-10-02) — 52 aliases
3240
+ // generated from ixmaps-grammar 0.1.29 (ixmaps-flat 1.0.42, 2b978d5 2026-10-02) — 52 aliases
2824
3241
  const FLAT_BINDING_ALIASES = {
2825
3242
  "aggregation": "style.aggregationfield",
2826
3243
  "aggregationfield": "style.aggregationfield",
@@ -2882,7 +3299,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2882
3299
  // both — so .style({tooltip}) is a tooltip and .meta({fillopacity}) a style
2883
3300
  // property. GENERATED by test/sync-grammar.mjs — do not edit.
2884
3301
  // <grammar:meta-keys>
2885
- // generated from ixmaps-grammar 0.1.28 — 5 meta keys
3302
+ // generated from ixmaps-grammar 0.1.29 — 5 meta keys
2886
3303
  const FLAT_META_KEYS = ["description","name","snippet","title","tooltip"];
2887
3304
  // </grammar:meta-keys>
2888
3305
 
@@ -3122,7 +3539,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3122
3539
  'sizepow', 'rangescale', 'minvalue', 'maxvalue', 'markersize', 'boxopacity', 'outlierscale', 'valuescale',
3123
3540
  'brightness', 'fractionscale', 'dopacityscale', 'dopacitypow', 'gridwidthpx', 'textscale', 'rangecentervalue',
3124
3541
  'shadowblur', 'shadowdx', 'shadowdy', 'maxshadow', 'offsetx', 'offsety', 'gridx', 'boxmargin', 'borderwidth',
3125
- 'borderradius', 'maxcharts', 'archeight', 'alignmargin'];
3542
+ 'borderradius', 'maxcharts', 'archeight', 'alignmargin',
3543
+ // HEATMAP / MLHEATMAP / KDE (a project JSON writes them as strings)
3544
+ 'radiuspx', 'intensity', 'threshold', 'weightmax', 'bandwidth'];
3126
3545
  function toNumberIfNumeric(v) {
3127
3546
  if (typeof v !== 'string' || !v.trim()) return v;
3128
3547
  const n = Number(v);
@@ -3197,6 +3616,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3197
3616
  delete style.title;
3198
3617
 
3199
3618
  const flags = new Set(typeStr ? typeStr.split('|') : []);
3619
+ // flat's theme configurator appends "CENTER|DOMINANT" to a categorical type without a
3620
+ // separator ("...|CATEGORICALCENTER|DOMINANT"); flat's substring tests read that as
3621
+ // CATEGORICAL + CENTER, so split a glued CATEGORICAL prefix back into its flags
3622
+ [...flags].forEach(f => {
3623
+ if (f.length > 11 && f.startsWith('CATEGORICAL')) { flags.add('CATEGORICAL'); flags.add(f.slice(11)); }
3624
+ });
3200
3625
  if (flags.has('BUBBLE')) flags.add('SYMBOL');
3201
3626
  // flat tests its base type as a substring (szFlag.match(/CHOROPLETH/)),
3202
3627
  // so "CHOROPLETHE" is a choropleth there too
@@ -4318,7 +4743,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4318
4743
  // category rows: its panel is the text legend (title / snippet / description)
4319
4744
  const countOnlyLegend = rt => !rt.categoryLabels && rt.flags.has('CATEGORICAL') && !rt.binding.value;
4320
4745
  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');
4746
+ && (!(rt.flags.has('FEATURE') || rt.flags.has('FEATURES')) || rt.flags.has('CATEGORICAL')) && !rt.flags.has('NOLEGEND')
4747
+ // KDE and HEATMAP draw a density, not classes of the value: the range legend that a numeric
4748
+ // value would get is meaningless for them (no legend for these, as the fire pages)
4749
+ && !rt.flags.has('KDE') && !rt.flags.has('HEATMAP') && !rt.flags.has('MLHEATMAP');
4322
4750
  // One theme's panel — at build time for every theme, and again for a
4323
4751
  // theme defined later (map.layer(...) in a myMap.then(...) chain,
4324
4752
  // defineLayer, loadProject); removeTheme removes it with the theme.
@@ -4419,7 +4847,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4419
4847
  // no SUM) falls back to a per-category record COUNT.
4420
4848
  // a VECTOR / BEZIER flow legend sums its size field (flat's rows
4421
4849
  // 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));
4850
+ // a categorical symbol/bubble sized by a column: SUM sums that column per category (flat:
4851
+ // partsA[i].nSum accumulates each item's nSize, maptheme.js 16649; legend.js 677); a size
4852
+ // of "$item$" is the count anyway
4853
+ const useSum = flatFlag(rt.flags, 'SUM') && (rt.style.valuefield
4854
+ || ((rt._isVectorChart() || rt._isAggregatedPie()) && rt.binding.size)
4855
+ || (rt.flags.has('CATEGORICAL') && rt.binding.size && rt.binding.size !== '$item$'));
4423
4856
  const valueField = rt.style.valuefield || rt.binding.size;
4424
4857
  // legendunits wins over the theme's general-purpose units
4425
4858
  // (used elsewhere for tooltips, e.g. _renderItemChartHtml) —
@@ -4502,7 +4935,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4502
4935
  if (!c || typeof c[0] !== 'number') return;
4503
4936
  const [lng, lat] = c;
4504
4937
  if (!inViewport(lng, lat, bbox, globeCenter)) return;
4505
- rt._multiFields.forEach((k, i) => { totals[i] += parseFloat(f.properties[k]) || 0; });
4938
+ rt._multiFields.forEach((k, i) => { totals[i] += scanValue(f.properties[k]) || 0; });
4506
4939
  });
4507
4940
  maxTotal = Math.max(0, ...totals);
4508
4941
  order = totals.map((v, i) => i).sort((a, b) => totals[b] - totals[a]);
@@ -4515,7 +4948,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4515
4948
  // with whatever the country filter is currently narrowing
4516
4949
  // the map down to, not just with the viewport/hemisphere.
4517
4950
  (rt._activeFeatures || rt.features).forEach(f => {
4518
- const idx = rt.categoryIndexByLabel ? rt.categoryIndexByLabel.get(f.properties[rt.binding.value]) : null;
4951
+ const idx = rt.categoryIndexByLabel ? rt._resolveClassIndex(f.properties[rt.binding.value]) : null;
4519
4952
  if (idx == null) return;
4520
4953
  // a polygon (FEATURE|CATEGORICAL) is counted at its first vertex
4521
4954
  let pt = f.geometry && f.geometry.coordinates;
@@ -4523,7 +4956,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4523
4956
  if (!Array.isArray(pt)) return;
4524
4957
  const [lng, lat] = pt;
4525
4958
  if (!inViewport(lng, lat, bbox, globeCenter)) return;
4526
- totals[idx] += useSum ? (parseFloat(f.properties[valueField]) || 0) : 1;
4959
+ totals[idx] += useSum ? (scanValue(f.properties[valueField]) || 0) : 1;
4527
4960
  });
4528
4961
  maxTotal = Math.max(0, ...totals);
4529
4962
  order = totals.map((v, i) => i).sort((a, b) => totals[b] - totals[a]);
@@ -6544,7 +6977,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6544
6977
  // charts in their own group above them: whatever the theme order
6545
6978
  // (a choropleth swapped in with add(..., 'replace') comes last), the
6546
6979
  // shapes lie under the charts — in theme order among themselves
6547
- const isShape = l => /^ix-(features|choropleth|shadow)-/.test(l.id);
6980
+ const isShape = l => /^ix-(features|choropleth|shadow|kde)-/.test(l.id);
6548
6981
  layers = layers.filter(isShape).concat(layers.filter(l => !isShape(l)));
6549
6982
  // every deck.gl layer id once: two themes of one layer name and type
6550
6983
  // (the page's comune points and comune polygons, both FEATURES on
@@ -6588,6 +7021,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6588
7021
  // flat's WMS themes render as native raster layers, not deck.gl —
6589
7022
  // reconcile them with the current runtimes on every rebuild
6590
7023
  syncWmsLayers();
7024
+ syncNativeHeatmaps(map, runtimes, mlHeatLayerIds);
6591
7025
  }
6592
7026
 
6593
7027
  // flat's WMS theme type (szFlag /\bWMS|IMAGE\b/, maptheme.js 8207-8247):
@@ -6604,6 +7038,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6604
7038
  // map.getStyle() (a deep copy of the whole style) on every rebuild
6605
7039
  // of a page that has no WMS theme
6606
7040
  const wmsLayerIds = new Set();
7041
+ const mlHeatLayerIds = new Set(); // the MLHEATMAP layers added to the map (syncNativeHeatmaps)
6607
7042
  function syncWmsLayers() {
6608
7043
  if (!map || !map.getLayer) return;
6609
7044
  const idOf = name => 'ixmaps-wms:' + String(name).replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -6649,7 +7084,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6649
7084
  }
6650
7085
  // a style swap (setMapType) drops every native layer — rebuild the
6651
7086
  // WMS ones once the new style stands
6652
- map.on('style.load', () => syncWmsLayers());
7087
+ map.on('style.load', () => { syncWmsLayers(); syncNativeHeatmaps(map, runtimes, mlHeatLayerIds); });
6653
7088
 
6654
7089
  // isZooming flips true/false on every 'zoomstart'/'zoomend' pair —
6655
7090
  // and a scroll-wheel or trackpad zoom fires MANY such pairs per
@@ -7772,7 +8207,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7772
8207
  // "$item$": every record counts 1
7773
8208
  if (field === '$item$') return features.length ? 1 : 0;
7774
8209
  return features.reduce((max, f) => {
7775
- const v = parseFloat(f.properties[field]);
8210
+ const v = scanValue(f.properties[field]);
7776
8211
  return isNaN(v) ? max : Math.max(max, v);
7777
8212
  }, 0);
7778
8213
  }
@@ -8046,6 +8481,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8046
8481
  const scale = scaleDenominatorAt(zoom);
8047
8482
  return !!((upper && scale > upper) || (lower && scale < lower));
8048
8483
  }
8484
+ // GLOW follows the theme's overall `opacity` (a number or numeric string; unset or 0 = 1, flat treats a falsy opacity as unset): the
8485
+ // halo is a fixed soft gradient texture (_getGlowIcon), so its strength is this alpha factor, as a bubble's is fillopacity
8486
+ function glowOpacityOf(style) {
8487
+ const v = Array.isArray(style.opacity) ? style.opacity[0] : style.opacity;
8488
+ const o = v == null || String(v).trim() === '' ? NaN : Number(v);
8489
+ return o > 0 && o < 1 ? o : 1;
8490
+ }
8049
8491
  function themeHiddenByScale(flags, style, zoom) {
8050
8492
  return (flags.has('FEATURE') || flags.has('FEATURES')) ? featuresHiddenByScale(style, zoom) : chartHiddenByScale(style, zoom);
8051
8493
  }
@@ -9376,7 +9818,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9376
9818
  }
9377
9819
  const parts = [], zeroNot = [];
9378
9820
  for (let k = 0; k < o.fields.length; k++) {
9379
- let v = parseFloat(raw[o.fields[k]]);
9821
+ let v = scanValue(raw[o.fields[k]]);
9380
9822
  // flat's weights multiply every record's per-field value as it is
9381
9823
  // accumulated (maptheme.js 10506-10508) — the SIZE (nValueSize)
9382
9824
  // stays raw, exactly as there
@@ -10039,14 +10481,14 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10039
10481
  let maxAlpha = -Infinity, minAlpha = Infinity;
10040
10482
  features.forEach(f => {
10041
10483
  // flat: an alpha that is no number is 0 (maptheme.js 9853-9857)
10042
- let v = parseFloat(f.properties[binding.alpha]);
10484
+ let v = scanValue(f.properties[binding.alpha]);
10043
10485
  if (isNaN(v)) v = 0;
10044
10486
  if (isDensity) {
10045
10487
  const areaKm2 = featureAreaKm2(f);
10046
10488
  if (!areaKm2) return;
10047
10489
  v = v / areaKm2;
10048
10490
  } else if (alpha100) {
10049
- const v100 = parseFloat(f.properties[alpha100]);
10491
+ const v100 = scanValue(f.properties[alpha100]);
10050
10492
  if (!isNaN(v100) && v100) v = 100 / v100 * v;
10051
10493
  }
10052
10494
  out._alphaByFeature.set(f, v);
@@ -10123,10 +10565,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10123
10565
  features.forEach(f => {
10124
10566
  if (!f.properties || !Object.keys(f.properties).length) return; // unjoined polygon
10125
10567
  nItems++;
10126
- const v100 = f100 ? parseFloat(f.properties[f100]) : NaN;
10568
+ const v100 = f100 ? scanValue(f.properties[f100]) : NaN;
10127
10569
  if (f100 && isFinite(v100)) sum100 += v100;
10128
10570
  fields.forEach((field, i) => {
10129
- const v = parseFloat(f.properties[field]);
10571
+ const v = scanValue(f.properties[field]);
10130
10572
  if (!isFinite(v)) return;
10131
10573
  sums[i] += v;
10132
10574
  if (f100 && isFinite(v100)) pooled[i] += v * v100;
@@ -10187,7 +10629,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10187
10629
  const useDeviation = ctx.flags.has('DEVIATION');
10188
10630
  let bestIndex = -1, bestRelevance = 0, bestValue = null;
10189
10631
  for (let i = 0; i < fields.length; i++) {
10190
- const v = parseFloat(props[fields[i]]);
10632
+ const v = scanValue(props[fields[i]]);
10191
10633
  if (!(v > (ctx._dominantMins[i] || 0))) continue;
10192
10634
  const relevance = useDeviation ? (v - ctx._dominantMeans[i]) / ctx._dominantStdDevs[i]
10193
10635
  : usePercentOfMean ? 100 * v / ctx._dominantMeans[i]
@@ -10215,7 +10657,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10215
10657
  const fields = out._multiFields;
10216
10658
  let nMax = 0;
10217
10659
  features.forEach(f => fields.forEach(field => {
10218
- const v = parseFloat(f.properties[field]);
10660
+ const v = scanValue(f.properties[field]);
10219
10661
  if (v > nMax) nMax = v;
10220
10662
  }));
10221
10663
  out._composeColorMax = nMax;
@@ -10255,7 +10697,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10255
10697
  const subtractive = ctx.flags.has('SUBTRACTIVE');
10256
10698
  let rr = 0, gg = 0, bb = 0;
10257
10699
  for (let i = 0; i < fields.length; i++) {
10258
- const v = parseFloat(props[fields[i]]) || 0;
10700
+ const v = scanValue(props[fields[i]]) || 0;
10259
10701
  const weight = v / nMax;
10260
10702
  const [r, g, b] = rgbs[i];
10261
10703
  if (subtractive) { rr += (255 - r) * weight; gg += (255 - g) * weight; bb += (255 - b) * weight; }
@@ -10328,14 +10770,20 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10328
10770
  // flat's reading of a value field (maptheme.js 9776-9800, defaults
10329
10771
  // 7740-7801): a value that is no number counts as 0 — unless
10330
10772
  // UNDEFINEDISNOTVALUE —, and 0 is a value — unless ZEROISNOTVALUE
10773
+ // flat's __scanValue (maptheme.js 9266): a value with a comma is EUROPEAN format (dots are thousands separators, the comma
10774
+ // the decimal: "1.234,5" -> 1234.5, "0,0637" -> 0.0637); a value without one is parsed plain, blanks stripped. Used wherever a
10775
+ // theme reads a value / size / alpha / value100 field from a record — the same strings plain parseFloat would cut at the comma
10776
+ function scanValue(v) {
10777
+ const s = String(v);
10778
+ return s.includes(',') ? parseFloat(s.replace(/\./g, '').replace(/,/g, '.')) : parseFloat(s.replace(/ /g, ''));
10779
+ }
10331
10780
  function flatNumber(v, flags) {
10332
10781
  // flat's own value scan (maptheme.js __scanValue 9265-9272): a
10333
10782
  // value with a comma is EUROPEAN format — dots stripped as
10334
10783
  // thousands separators, the comma is the decimal ("78,8" -> 78.8);
10335
10784
  // a value without one is parsed plain, blanks stripped. Applied
10336
10785
  // 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, ''));
10786
+ let n = scanValue(v);
10339
10787
  if (isNaN(n)) n = flags && flags.has('UNDEFINEDISNOTVALUE') ? NaN : 0;
10340
10788
  if (n === 0 && flags && flags.has('ZEROISNOTVALUE')) return NaN;
10341
10789
  return n;
@@ -10464,14 +10912,14 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10464
10912
  if (!p) { out.push(f); continue; }
10465
10913
  let clear = [];
10466
10914
  if (size) {
10467
- const v = parseFloat(p[size]);
10915
+ const v = scanValue(p[size]);
10468
10916
  if (!(isFinite(v) && v > 0)) {
10469
10917
  if (!keepShapes) continue;
10470
10918
  clear.push(size);
10471
10919
  }
10472
10920
  }
10473
10921
  if (negativeIsNot) {
10474
- const negative = checked.filter(k => parseFloat(p[k]) < 0);
10922
+ const negative = checked.filter(k => scanValue(p[k]) < 0);
10475
10923
  if (negative.length && aggregate && !keepShapes) continue;
10476
10924
  clear = clear.concat(negative);
10477
10925
  }
@@ -10618,7 +11066,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10618
11066
  // without AGGREGATE applyField100 already differenced every record
10619
11067
  // (flat's nValuesA, popped): the first field IS the value
10620
11068
  ? (!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])
11069
+ : p => cellAggregatedValues({ sums: fields.map(k => scanValue(p[k]) || 0), counts: fields.map(() => 1) }, flags, style)[0])
10622
11070
  // "$item$" is flat's record count: 1 per record
10623
11071
  : binding.value === '$item$' ? () => 1
10624
11072
  : p => (p && Object.keys(p).length ? flatNumber(p[binding.value], flags) : NaN);
@@ -10769,9 +11217,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10769
11217
  // theme's text 0.08 px high).
10770
11218
  function resolveAggregateValue(binding, flags, props) {
10771
11219
  if (binding.size === '$item$') return 1;
10772
- if (binding.size) return parseFloat(props[binding.size]) || 0;
11220
+ if (binding.size) return scanValue(props[binding.size]) || 0;
10773
11221
  if (flags.has('CATEGORICAL') || binding.value == null || binding.value === '$item$') return 1;
10774
- return parseFloat(props[binding.value]) || 0;
11222
+ return scanValue(props[binding.value]) || 0;
10775
11223
  }
10776
11224
 
10777
11225
  // Whether a range-classed AGGREGATE cell's class value (flat: the sum
@@ -11075,7 +11523,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11075
11523
  const add = (cat, v) => { if (cat == null || cat < 0 || cat >= n) return; count[cat]++; sum[cat] += Number.isFinite(v) ? v : 0; };
11076
11524
  if (rt.flags.has('CHOROPLETH')) {
11077
11525
  (rt._activeFeatures || rt.features).forEach(f => {
11078
- const v = parseFloat(f.properties[rt.binding.value]);
11526
+ const v = scanValue(f.properties[rt.binding.value]);
11079
11527
  add(rt._resolvePartsClass(v), v);
11080
11528
  });
11081
11529
  } else if (rt._legendItems) {
@@ -11086,7 +11534,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11086
11534
  if (!c || typeof c[0] !== 'number') return;
11087
11535
  const [lng, lat] = c;
11088
11536
  if (!inViewport(lng, lat, bbox, globeCenter)) return;
11089
- const v = parseFloat(f.properties[rt.binding.value]);
11537
+ const v = scanValue(f.properties[rt.binding.value]);
11090
11538
  add(rt._resolvePartsClass(v), v);
11091
11539
  });
11092
11540
  }
@@ -11128,6 +11576,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11128
11576
  this.name = spec.name;
11129
11577
  this.binding = spec.binding;
11130
11578
  this.style = spec.style;
11579
+ this._donorStyle = spec._donorStyle;
11131
11580
  this.flags = symbolsFlagCompat(spec.flags, spec.style);
11132
11581
  this.meta = spec.meta;
11133
11582
  // see LAYER_DATA_GENERATION above — unique per runtime, see _dataLayerId
@@ -11233,7 +11682,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11233
11682
  // a bar chart's parts are a pie's (its value fields or categories)
11234
11683
  if ((this._isPieChart() || this._isBarChart()) && this.binding.value) {
11235
11684
  this._preparePie();
11236
- } else if (this.flags.has('DOMINANT') && this.binding.value && !isAggregatedCategoricalChoropleth(this)) {
11685
+ } else if (this.flags.has('DOMINANT') && this.binding.value && !isAggregatedCategoricalChoropleth(this) && !this._isDominantGrid()) {
11237
11686
  // .type("CHOROPLETH|DOMINANT") — a MULTI-field bound value
11238
11687
  // (binding.value is a pipe-joined field list, e.g. one CSV column
11239
11688
  // per age band for the same year/sex — same pipe convention
@@ -11270,7 +11719,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11270
11719
  } else {
11271
11720
  const seen = new Map();
11272
11721
  this.features.forEach(f => {
11273
- const v = f.properties[this.binding.value];
11722
+ let v = f.properties[this.binding.value];
11723
+ if (typeof v === 'string') v = v.trim(); // flat: getStringValueIndex strips trailing blanks
11274
11724
  if (v != null && !seen.has(v)) seen.set(v, seen.size);
11275
11725
  });
11276
11726
  this.categoryLabels = Array.from(seen.keys());
@@ -11313,7 +11763,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11313
11763
  const scheme = Array.isArray(this.style.colorscheme) ? this.style.colorscheme : [];
11314
11764
  this._partNoFill = new Set(this._multiFields.map((f, i) => i).filter(i => String(scheme[i]).trim().toLowerCase() === 'none'));
11315
11765
  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; }
11766
+ 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
11767
  this._multiFieldPartMax = max || 1;
11318
11768
  // AUTO100's scale, measured on these features by _buildSequenceChartLayers
11319
11769
  this._seqAuto100Max = null;
@@ -11470,12 +11920,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11470
11920
  : this.flags.has('PERMILLE') ? 1000 : 100;
11471
11921
  let sum = 0;
11472
11922
  fields.forEach((field, i) => {
11473
- const part = parseFloat(props[field]);
11923
+ const part = scanValue(props[field]);
11474
11924
  if (isNaN(part)) return;
11475
- const v100 = f100A ? parseFloat(props[f100A[Math.min(i, f100A.length - 1)]]) : NaN;
11925
+ const v100 = f100A ? scanValue(props[f100A[Math.min(i, f100A.length - 1)]]) : NaN;
11476
11926
  sum += isNaN(v100) ? part : (part * v100) / multiplier;
11477
11927
  });
11478
- const v100_0 = f100A ? parseFloat(props[f100A[0]]) : NaN;
11928
+ const v100_0 = f100A ? scanValue(props[f100A[0]]) : NaN;
11479
11929
  const nMyValue = sum / ((v100_0 / 100) || 1);
11480
11930
  return composedDopacityAlpha(this.style, this.flags, nMyValue, this._composeColorMax || 1);
11481
11931
  }
@@ -11494,8 +11944,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11494
11944
  // (_buildPartsA, numeric bucket), or neither (a single implicit
11495
11945
  // bucket, today's behavior for any other theme).
11496
11946
  _resolveClassIndex(rawValue) {
11497
- if (this.categoryIndexByLabel) return this.categoryIndexByLabel.get(rawValue);
11498
- if (this.partsA) return this._resolvePartsClass(parseFloat(rawValue));
11947
+ if (this.categoryIndexByLabel) {
11948
+ const i = this.categoryIndexByLabel.get(rawValue);
11949
+ return i === undefined && typeof rawValue === 'string' ? this.categoryIndexByLabel.get(rawValue.trim()) : i;
11950
+ }
11951
+ if (this.partsA) return this._resolvePartsClass(scanValue(rawValue));
11499
11952
  return 0;
11500
11953
  }
11501
11954
 
@@ -11565,8 +12018,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11565
12018
  // as flat's chart node value (NYC status: value="63" = e-bikes)
11566
12019
  const sizeField = this.binding.size && this.binding.size !== '$item$' ? this.binding.size : null;
11567
12020
  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;
12021
+ const parts = this._multiFields.map(k => scanValue(f.properties[k]) || 0);
12022
+ const size = sizeField ? Math.abs(scanValue(f.properties[sizeField]) || 0) : undefined;
11570
12023
  return { type: 'Feature', geometry: f.geometry,
11571
12024
  properties: { value: parts[0], parts, series: parts, ...(size !== undefined ? { size } : {}), raw: f.properties } };
11572
12025
  })];
@@ -11592,9 +12045,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11592
12045
  // values, the difference is taken on the cell sums (see post)
11593
12046
  const seriesFields = String(this.binding.value || '').split('|');
11594
12047
  this._seriesDifference = this.flags.has('DIFFERENCE') && seriesFields.length > 1 && !this._aggregateField100;
11595
- const seriesOf = props => seriesFields.map(k => parseFloat(props[k]) || 0);
12048
+ const seriesOf = props => seriesFields.map(k => scanValue(props[k]) || 0);
11596
12049
  // 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];
12050
+ 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
12051
  this._featuresByCategory = [sourceFeatures.map(f => ({
11599
12052
  type: 'Feature',
11600
12053
  geometry: f.geometry,
@@ -11602,8 +12055,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11602
12055
  value: this._seriesDifference ? seriesValue(f.properties) : this._resolveAggregateValue(f.properties),
11603
12056
  ...(this._seriesDifference ? { series: seriesOf(f.properties) } : {}),
11604
12057
  // "$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 } : {}),
12058
+ ...(this._classSeparate ? { classValue: this.binding.value === '$item$' ? 1 : scanValue(f.properties[this.binding.value]) || 0 } : {}),
12059
+ ...(this._aggregateField100 ? { value100: scanValue(f.properties[f100]) || 0 } : {}),
11607
12060
  raw: f.properties
11608
12061
  }
11609
12062
  }))];
@@ -11892,7 +12345,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11892
12345
  const countsByField = new Map(fields.map(f => [f, new Map()]));
11893
12346
 
11894
12347
  scoped.forEach(f => {
11895
- const weight = sizeField ? (parseFloat(f.properties[sizeField]) || 0) : 1;
12348
+ const weight = sizeField ? (scanValue(f.properties[sizeField]) || 0) : 1;
11896
12349
  for (const field of fields) {
11897
12350
  const v = f.properties[field];
11898
12351
  if (v == null || v === '') continue;
@@ -12541,13 +12994,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12541
12994
  const fields = String(this.binding.value || '').split('|');
12542
12995
  if (this._multiFieldMax === undefined) {
12543
12996
  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; }
12997
+ 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
12998
  this._multiFieldMax = max;
12546
12999
  }
12547
13000
  const labels = Array.isArray(this.style.label) && this.style.label.length === fields.length ? this.style.label.map(String)
12548
13001
  : (this.categoryDisplayLabels || this.categoryLabels || fields);
12549
13002
  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));
13003
+ let rows = fields.map((field, i) => ({ i, v: scanValue(raw[field]) })).filter(r => Number.isFinite(r.v));
12551
13004
  if (!rows.length) return '';
12552
13005
  if (this.flags.has('SORT')) rows.sort((a, b) => (this.flags.has('UP') ? a.v - b.v : b.v - a.v));
12553
13006
  const color = i => { const c = this.classRgb(i); return `rgb(${c.slice(0, 3).join(',')})`; };
@@ -12702,6 +13155,23 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12702
13155
  // drawChart/modifier machinery entirely — a layer flagged DOT never
12703
13156
  // reaches CHART|SYMBOL's clustering/GLOW/VALUES pipeline, even if
12704
13157
  // those flags are also present in the type string).
13158
+ // MLHEATMAP: MapLibre's native heatmap layer (syncNativeHeatmaps) — nothing for deck.gl to draw
13159
+ if (this.flags.has('MLHEATMAP')) return [];
13160
+ // KDE: the density bands of the visible records (_buildKdeLayers)
13161
+ if (this.flags.has('KDE')) return this._buildKdeLayers(zoom, bbox, globeCenter);
13162
+ // HEATMAP: one GPU density surface over the visible records (_buildHeatmapLayers)
13163
+ if (this.flags.has('HEATMAP')) {
13164
+ // deck.gl's HeatmapLayer aggregates in screen space on a Web-Mercator viewport: under the
13165
+ // globe it draws nothing (measured, deck.gl 9.4.0 / MapLibre globe) — say so once
13166
+ if (globeCenter) {
13167
+ if (!this._warnedHeatmapGlobe) {
13168
+ this._warnedHeatmapGlobe = true;
13169
+ 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`);
13170
+ }
13171
+ return [];
13172
+ }
13173
+ return this._buildHeatmapLayers(liveZoom);
13174
+ }
12705
13175
  if (this.flags.has('DOT')) return this._buildDotLayers();
12706
13176
  // GRIDSIZE (spatial grid binning) is checked before the generic
12707
13177
  // CHART|SYMBOL bubble pipeline, same "more specific base type wins"
@@ -12801,7 +13271,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12801
13271
  // layer keeps its one fill color
12802
13272
  && this.binding.value && this.binding.value !== '$item$' && !String(this.binding.value).includes('|');
12803
13273
  const classColorOf = f => {
12804
- const cls = this._resolvePartsClass(parseFloat(f.properties && f.properties[this.binding.value]));
13274
+ const cls = this._resolvePartsClass(scanValue(f.properties && f.properties[this.binding.value]));
12805
13275
  return this.classRgb(cls);
12806
13276
  };
12807
13277
  const catAlpha = Math.round(255 * (styleNum(this.style.fillopacity) || 1));
@@ -13024,7 +13494,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13024
13494
  // linecolor "none": no outline; a class color "none" (an explicit
13025
13495
  // colorscheme list) leaves its polygons unpainted, an rgba() class
13026
13496
  // color keeps its alpha — as flat's SVG fill does
13027
- const lineColorRaw = this.style.linecolor ? styleLineColor(this.style.linecolor) : null;
13497
+ // flat re-styles the SAME shape nodes the FEATURE base drew, so an
13498
+ // outline the choropleth doesn't define itself stays the base's own
13499
+ const donorStyle = this._donorStyle;
13500
+ const lineColorSrc = this.style.linecolor || (donorStyle && donorStyle.linecolor);
13501
+ const lineColorRaw = lineColorSrc ? styleLineColor(lineColorSrc) : null;
13028
13502
  const lineRgb = lineColorRaw ? withAlpha(hexOrNamedToRgb(lineColorRaw), cssColorAlpha(lineColorRaw)) : [255, 255, 255];
13029
13503
  const cs = this.style.colorscheme;
13030
13504
  const classAlpha = Array.isArray(cs) && cs.length && !/^\d+$/.test(String(cs[0])) ? cs.map(cssColorAlpha) : null;
@@ -13056,11 +13530,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13056
13530
  // flat re-styles the SAME shape nodes the FEATURE base drew, so a line
13057
13531
  // choropleth keeps the base's own line width — the donor's linewidth
13058
13532
  // 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
13533
  const anyLine = collection.features.some(isLineGeometry);
13061
13534
  const isolating = !!(this._markedClasses.size && !this._onMarksChanged && evidenceMode === 'isolate');
13062
13535
  const lineW = styleNum(this.style.linewidth)
13063
- || (anyLine && donorRt && styleNum(donorRt.style.linewidth)) || 1;
13536
+ || (donorStyle && styleNum(donorStyle.linewidth)) || 1;
13064
13537
  return [new GeoJsonLayer({
13065
13538
  id: this._dataLayerId(`ix-choropleth-${this.name}`),
13066
13539
  data: collection,
@@ -13142,8 +13615,15 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13142
13615
  // GRIDSIZE without PLOT drawn as one uniform square per cell: only a
13143
13616
  // plain grid — a single color, no VALUES, no GLOW
13144
13617
  _isPlainGridMesh() {
13145
- return !this.flags.has('PLOT') && (this.categoryColorsRgb || []).length <= 1
13146
- && !this.flags.has('VALUES') && !this.flags.has('GLOW');
13618
+ // categorical CENTER|DOMINANT cells are drawn by the same mesh path, per-cell colored
13619
+ return this._isDominantGrid() || (!this.flags.has('PLOT') && (this.categoryColorsRgb || []).length <= 1
13620
+ && !this.flags.has('VALUES') && !this.flags.has('GLOW'));
13621
+ }
13622
+ // flat's categorical HexBin (GRIDSIZE|AGGREGATE|CATEGORICAL|DOMINANT): each grid cell takes
13623
+ // the color of its dominant category, with dynamic opacity from that category's part
13624
+ _isDominantGrid() {
13625
+ return this.flags.has('GRIDSIZE') && !this.flags.has('PLOT') && this.flags.has('CATEGORICAL')
13626
+ && this.flags.has('DOMINANT');
13147
13627
  }
13148
13628
  // GRIDSIZE (no PLOT) symbol chart radius, px: flat's nGridSize / 2
13149
13629
  // (maptheme.js 21843), nGridSize the aggregation cell pitch / nAutoSize
@@ -13160,6 +13640,19 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13160
13640
  const autoSize = rect ? (gap ? 1.15 : 1.0) : (gap ? 0.8 : 0.75);
13161
13641
  return cellPx / autoSize * (styleNum(this.style.scale) || 1) / 2;
13162
13642
  }
13643
+
13644
+ // flat's AUTOSIZE (maptheme.js 21831-21843): the biggest aggregated item is the size of its
13645
+ // grid cell — the radius GRIDSIZE gives every item (hexagon cell/1.5, RECT cell/2; GAP 1.6 /
13646
+ // 2.3) — and the others follow (value/max)^(1/sizepow). This is the biggest item's radius;
13647
+ // null without AUTOSIZE, without a grid, or on a GRIDSIZE/PLOT theme (those size themselves)
13648
+ _autoSizeMaxRadiusPx(zoom, liveZoom) {
13649
+ if (!this.flags.has('AUTOSIZE') || this.flags.has('GRIDSIZE') || this.flags.has('PLOT') || !this._clusterRadiusPx) return null;
13650
+ const fromZoom = this._clusterUsesFixedZoom ? GRIDWIDTH_METERS_REFERENCE_ZOOM : zoom;
13651
+ const cellPx = this._clusterRadiusPx * Math.pow(2, liveZoom - fromZoom);
13652
+ const rect = flatFlag(this.flags, 'RECT');
13653
+ const gap = flatFlag(this.flags, 'GAP');
13654
+ return cellPx / (rect ? (gap ? 2.3 : 2.0) : (gap ? 1.6 : 1.5)) * (styleNum(this.style.scale) || 1);
13655
+ }
13163
13656
  _isItemPlot() {
13164
13657
  return this.flags.has('PLOT') && !this.flags.has('GRIDSIZE') && String(this.binding.value || '').includes('|');
13165
13658
  }
@@ -13310,7 +13803,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13310
13803
  for (const f of this.features) {
13311
13804
  const pos = itemAnchor(f.geometry);
13312
13805
  if (!pos) continue;
13313
- const values = fields.map(k => parseFloat(f.properties[k]));
13806
+ const values = fields.map(k => scanValue(f.properties[k]));
13314
13807
  for (const v of values) if (!isNaN(v)) { dMin = Math.min(dMin, v); dMax = Math.max(dMax, v); }
13315
13808
  if (!active || active.has(f)) items.push({ geometry: { type: 'Point', coordinates: pos }, properties: { values, raw: f.properties } });
13316
13809
  }
@@ -13427,9 +13920,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13427
13920
  : Array.isArray(this.style.xaxis) ? this.style.xaxis : null;
13428
13921
  if (explicit) {
13429
13922
  if (!this._explicitCatIndex) this._explicitCatIndex = new Map(explicit.map((v, i) => [String(v), i]));
13430
- return this._explicitCatIndex.get(String(rawValue));
13923
+ const i = this._explicitCatIndex.get(String(rawValue));
13924
+ return i === undefined ? this._explicitCatIndex.get(String(rawValue).trim()) : i;
13431
13925
  }
13432
- return this._resolveClassIndex(rawValue);
13926
+ // "$item$" is no field: every record counts 1 (resolveAggregateValue), so its class is the 1
13927
+ return this._resolveClassIndex(this.binding.value === '$item$' ? 1 : rawValue);
13433
13928
  }
13434
13929
 
13435
13930
  // pure: cellAggregatedValues → see the pure-function sections above the class
@@ -13770,15 +14265,93 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13770
14265
  // style.colorscheme fill.
13771
14266
  _buildGridMeshLayers(zoom) {
13772
14267
  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
14268
  const fillColor = (resolveColorScheme(this.style.colorscheme, ['']) || ['rgba(255,255,255,0.3)'])[0] || 'rgba(255,255,255,0.3)';
13779
14269
  const borderColor = styleLineColor(this.style.linecolor);
13780
14270
  const borderWidth = styleNum(this.style.linewidth);
13781
14271
  const cellPx = this._gridCellPx;
14272
+
14273
+ // flat's "Hexbin - dynamic opacity" (CHART|SYMBOL|GRIDSIZE|AGGREGATE|SUM|DOPACITY,
14274
+ // symbols:["hexagon"]): hexagon cells, each cell's fill OPACITY ramped from its
14275
+ // aggregated value (resolveDopacityAlpha, the CHOROPLETH|DOPACITY formulas). The plain
14276
+ // one-color mesh (neither flag) stays the uniform square below.
14277
+ const hexCells = Array.isArray(this.style.symbols) && this.style.symbols[0] === 'hexagon';
14278
+ const dopacity = this.flags.has('DOPACITY') || this.flags.has('DOPACITYMIN')
14279
+ || this.flags.has('DOPACITYMAX') || this.flags.has('DOPACITYMINMAX');
14280
+ if (hexCells || dopacity) {
14281
+ const cells = this._gridIndex;
14282
+ const baseOpacity = styleNum(this.style.fillopacity);
14283
+ const base = isNaN(baseOpacity) ? 0.9 : baseOpacity;
14284
+ // CATEGORICAL|DOMINANT: dominant category per cell (the biggest part above 0, the
14285
+ // first of equal ones — aggregatedCategoricalClass), opacity from that part against
14286
+ // the biggest dominant part of the same class over all cells (dominantDopacityAlpha)
14287
+ if (this._isDominantGrid()) {
14288
+ const labels = this._plotCategories();
14289
+ const rgb = resolveClassColors(this.style.colorscheme, labels, undefined) || [];
14290
+ const dom = cells.map(c => {
14291
+ let i = -1;
14292
+ c.sums.forEach((v, k) => { if (v > 0 && (i < 0 || v > c.sums[i])) i = k; });
14293
+ return i < 0 ? null : { i, v: c.sums[i] };
14294
+ });
14295
+ // flat's nMin/nMax: extremes of the cell TOTAL over all categories
14296
+ const totals = cells.map(c => c.sums.reduce((s, x) => s + x, 0));
14297
+ const tMin = Math.min(...totals), tMax = Math.max(...totals);
14298
+ const data = [];
14299
+ // legend click (ixmaps.markThemeClass): flat's evidence "isolate" shows only the cells
14300
+ // whose dominant category is marked; "isolate_gray" keeps the others, dimmed
14301
+ const marking = this._markedClasses.size && !this._onMarksChanged;
14302
+ const gray = String(this.style.evidence || 'isolate') === 'isolate_gray';
14303
+ cells.forEach((cell, n) => {
14304
+ const d = dom[n];
14305
+ if (!d) return;
14306
+ if (marking && !this._markedClasses.has(d.i) && !gray) return;
14307
+ const center = worldPixelToLngLat(cell.px, cell.py, zoom);
14308
+ let a = dopacity ? symbolDopacityAlpha(this.style, d.v, tMin, tMax, baseOpacity) : base;
14309
+ if (marking && !this._markedClasses.has(d.i)) a = DIMMED_ICON_ALPHA / 255;
14310
+ const c = rgb[d.i] || [130, 130, 130];
14311
+ data.push({ geometry: { type: 'Point', coordinates: [center.lng, center.lat] }, rgb: c, alpha: a });
14312
+ });
14313
+ // white hexagon, tinted per cell by getColor (rgb + alpha)
14314
+ const r = cellPx / 1.5;
14315
+ // mask icon: deck tints only mask icons with getColor's rgb (non-mask: alpha only)
14316
+ const icon = Object.assign({}, this._buildGridHexIcon('#ffffff', 'none', 0, r), { mask: true });
14317
+ return [new IconLayer({
14318
+ id: `ix-grid-${this.name}-g${this._iconGeneration}`,
14319
+ data, pickable: false,
14320
+ getPosition: d => d.geometry.coordinates,
14321
+ getIcon: () => icon,
14322
+ getColor: d => [d.rgb[0], d.rgb[1], d.rgb[2], Math.round(255 * d.alpha)],
14323
+ getSize: 2 * r,
14324
+ sizeUnits: 'pixels'
14325
+ })];
14326
+ }
14327
+ // cell-level value stats (the grid IS the dataset for DOPACITY here)
14328
+ const vals = cells.map(c => c.sums[0]).filter(v => v != null && !isNaN(v)).sort((a, b) => a - b);
14329
+ const vMin = vals[0], vMax = vals[vals.length - 1];
14330
+ const data = cells.map(cell => {
14331
+ const center = worldPixelToLngLat(cell.px, cell.py, zoom);
14332
+ const a = dopacity ? symbolDopacityAlpha(this.style, cell.sums[0], vMin, vMax, baseOpacity) : base;
14333
+ return { geometry: { type: 'Point', coordinates: [center.lng, center.lat] }, alpha: a == null ? base : a };
14334
+ });
14335
+ // hexagon tiling (snapToAggregationGrid): column pitch cellPx = 1.5 r, so r = cellPx / 1.5
14336
+ const r = cellPx / 1.5;
14337
+ const icon = hexCells
14338
+ ? this._buildGridHexIcon(fillColor, borderColor, isNaN(borderWidth) ? 0 : borderWidth, r)
14339
+ : this._buildGridSquareIcon(fillColor, borderColor, isNaN(borderWidth) ? 0 : borderWidth, cellPx);
14340
+ return [new IconLayer({
14341
+ id: `ix-grid-${this.name}-g${this._iconGeneration}`,
14342
+ data, pickable: false,
14343
+ getPosition: d => d.geometry.coordinates,
14344
+ getIcon: () => icon,
14345
+ getColor: d => [255, 255, 255, Math.round(255 * d.alpha)],
14346
+ getSize: hexCells ? 2 * r : cellPx,
14347
+ sizeUnits: 'pixels'
14348
+ })];
14349
+ }
14350
+
14351
+ const data = this._gridIndex.map(cell => {
14352
+ const center = worldPixelToLngLat(cell.px, cell.py, zoom);
14353
+ return { geometry: { type: 'Point', coordinates: [center.lng, center.lat] } };
14354
+ });
13782
14355
  const icon = this._buildGridSquareIcon(fillColor, borderColor, isNaN(borderWidth) ? 0 : borderWidth, cellPx);
13783
14356
 
13784
14357
  return [new IconLayer({
@@ -14222,6 +14795,30 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14222
14795
  return this._cacheIcon(key, icon);
14223
14796
  }
14224
14797
 
14798
+ // one flat-top hexagon of circumradius r (px) filling a square raster — the GRIDSIZE
14799
+ // "hexagon" cell (see _buildGridMeshLayers); drawSymbolPath's own hexagon outline
14800
+ _buildGridHexIcon(fillColor, borderColor, borderWidth, r) {
14801
+ const key = `gridhex|${fillColor}|${borderColor}|${borderWidth}|${r}`;
14802
+ if (this._iconCache.has(key)) return this._iconCache.get(key);
14803
+ const size = Math.max(32, Math.min(256, Math.round(2 * r) || 32));
14804
+ const canvas = document.createElement('canvas');
14805
+ canvas.width = size; canvas.height = size;
14806
+ const ctx = canvas.getContext('2d');
14807
+ const rr = size / 2 - 1; // 1px margin so the stroke isn't clipped
14808
+ ctx.beginPath();
14809
+ drawSymbolPath(ctx, 'hexagon', size / 2, size / 2, rr);
14810
+ ctx.closePath();
14811
+ ctx.fillStyle = fillColor;
14812
+ ctx.fill();
14813
+ if (borderColor && borderColor !== 'none' && borderWidth > 0) {
14814
+ ctx.strokeStyle = borderColor;
14815
+ ctx.lineWidth = borderWidth * (size / (2 * r || size));
14816
+ ctx.stroke();
14817
+ }
14818
+ const icon = { url: canvas.toDataURL(), width: size, height: size, anchorX: size / 2, anchorY: size / 2, id: key };
14819
+ return this._cacheIcon(key, icon);
14820
+ }
14821
+
14225
14822
  _buildGridSquareIcon(fillColor, borderColor, borderWidth, cellPx) {
14226
14823
  const key = `grid|${fillColor}|${borderColor}|${borderWidth}|${cellPx}`;
14227
14824
  if (this._iconCache.has(key)) return this._iconCache.get(key);
@@ -14250,6 +14847,114 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14250
14847
  return this._cacheIcon(key, icon);
14251
14848
  }
14252
14849
 
14850
+ // .type("KDE"): the kernel density of the VISIBLE records as stacked bands (the fire pages' KDE).
14851
+ // The records inside the view (plus 3 bandwidths around it, which still add density to it) go
14852
+ // to kdeGrid in a worker, d3-contour cuts the bands at fractions of the maximum (style.kdelevels,
14853
+ // default 0.15 / 0.30 / 0.50 / 0.70), each band the area at or above its threshold, so they
14854
+ // stack, colored from style.colorscheme, drawn with style.fillopacity. The bandwidth is
14855
+ // style.bandwidth (km) or, without it, follows the zoom (kdeBandwidthKm).
14856
+ // The result belongs to a view: while the map moves the last bands stay, and once it stands still
14857
+ // (and the view, the records, the binding or the bandwidth changed) the new ones are computed
14858
+ // after a short pause and drawn by a redraw — never more than one computation wanted at a time.
14859
+ // Weights as HEATMAP: the size field, else the numeric value field, else 1 each ($item$).
14860
+ _buildKdeLayers(zoom, bbox, globeCenter) {
14861
+ // the bands are lng/lat polygons of a flat view: under the globe there is no such view — say so once
14862
+ if (globeCenter) {
14863
+ if (!this._warnedKdeGlobe) {
14864
+ this._warnedKdeGlobe = true;
14865
+ 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`);
14866
+ }
14867
+ return [];
14868
+ }
14869
+ const source = this._activeFeatures || this.features;
14870
+ if (this._kdeSource !== source) { this._kdeSource = source; this._kdeSourceId = (this._kdeSourceId || 0) + 1; }
14871
+ const flatZoom = mapLibreToFlatZoom(zoom);
14872
+ const bwKm = styleNum(this.style.bandwidth) > 0 ? styleNum(this.style.bandwidth) : kdeBandwidthKm(flatZoom);
14873
+ const maxCells = kdeMaxCells(flatZoom);
14874
+ const levels = kdeLevels(this.style);
14875
+ const key = JSON.stringify([this._dataGen, this._kdeSourceId, this.binding, bwKm, maxCells, levels, bbox.map(v => Math.round(v * 1e4) / 1e4)]);
14876
+ if (key !== this._kdeKeyDone && key !== this._kdeKeyWanted && !this._viewMoving) {
14877
+ this._kdeKeyWanted = key;
14878
+ clearTimeout(this._kdeTimer);
14879
+ this._kdeTimer = setTimeout(() => {
14880
+ const margin = 3 * bwKm;
14881
+ const mLat = margin / 111.3;
14882
+ const mLng = margin / (111.3 * Math.max(0.05, Math.cos((bbox[1] + bbox[3]) / 2 * Math.PI / 180)));
14883
+ const points = kdePoints(source, this.binding, [bbox[0] - mLng, bbox[1] - mLat, bbox[2] + mLng, bbox[3] + mLat]);
14884
+ Promise.all([ensureD3Contour(), kdeCompute(points, bwKm, maxCells)]).then(([contours, grid]) => {
14885
+ if (this._kdeKeyWanted !== key) return; // the view moved on: a newer computation is wanted
14886
+ this._kdeBands = kdeBands(grid, levels, () => contours());
14887
+ this._kdeBandsGen = (this._kdeBandsGen || 0) + 1; // every set of bands is a new layer for deck.gl
14888
+ this._kdeLevelCount = levels.length;
14889
+ this._kdeKeyDone = key;
14890
+ if (this._triggerRedraw) this._triggerRedraw();
14891
+ }).catch(err => {
14892
+ console.error(`[ixmaps-gl] layer "${this.name}": KDE not computed:`, err);
14893
+ if (this._kdeKeyWanted === key) this._kdeKeyDone = key; // not retried until the view changes
14894
+ });
14895
+ }, 150);
14896
+ }
14897
+ const bands = this._kdeBands;
14898
+ if (!bands || !bands.features.length) return [];
14899
+ const colors = kdeBandColors(this.style.colorscheme, this._kdeLevelCount || levels.length);
14900
+ return [new GeoJsonLayer({
14901
+ // a new id for each set of bands: deck.gl keeps a layer's buffers by id, and bands whose polygon
14902
+ // structure changed (holes, counts) under the SAME id drew long triangles between polygons
14903
+ id: this._dataLayerId(`ix-kde-${this.name}-b${this._kdeBandsGen || 0}`),
14904
+ data: bands, pickable: false, stroked: false, filled: true,
14905
+ getFillColor: f => colors[f.properties.level] || colors[colors.length - 1],
14906
+ updateTriggers: { getFillColor: [JSON.stringify(this.style.colorscheme), colors.length] },
14907
+ opacity: styleNum(this.style.fillopacity) || 0.25
14908
+ })];
14909
+ }
14910
+
14911
+ // .type("HEATMAP"): a kernel-density surface (deck.gl HeatmapLayer) of the records' positions,
14912
+ // weighted by heatmapWeight. style.radiuspx (default 30) is the kernel radius in screen pixels
14913
+ // at the reference zoom; it follows the map's object scaling like every symbol
14914
+ // (objectZoomFactor), i.e. it grows and shrinks with the zoom like a real-world distance.
14915
+ // style: scale (radius multiplier), intensity, threshold, colorscheme, fillopacity.
14916
+ //
14917
+ // PERFORMANCE: the layer is rebuilt on every frame of a zoom gesture. A new data array, a new
14918
+ // colorRange or a new radius makes deck redo its GPU work (weight map, color texture) — measured
14919
+ // 90 ms a frame with them rebuilt each time, 15 ms with the same array/colors and a radius that
14920
+ // moves in steps of 2^(1/6) (~12%). So the data and the colors are kept until the records, the
14921
+ // binding or the colorscheme change, and the radius is quantized.
14922
+ _buildHeatmapLayers(liveZoom) {
14923
+ const source = this._activeFeatures || this.features;
14924
+ const bindingKey = JSON.stringify(this.binding);
14925
+ if (!this._heatmapData || this._heatmapSource !== source || this._heatmapBinding !== bindingKey) {
14926
+ const data = [];
14927
+ for (const f of source) {
14928
+ const c = f.geometry && f.geometry.coordinates;
14929
+ if (!c || typeof c[0] !== 'number' || !isFinite(c[0]) || !isFinite(c[1])) continue;
14930
+ const w = heatmapWeight(this.binding, f.properties || {});
14931
+ if (w > 0) data.push({ position: [c[0], c[1]], weight: w });
14932
+ }
14933
+ this._heatmapData = data;
14934
+ this._heatmapSource = source;
14935
+ this._heatmapBinding = bindingKey;
14936
+ }
14937
+ const colorKey = JSON.stringify(this.style.colorscheme);
14938
+ if (!this._heatmapColors || this._heatmapColorKey !== colorKey) {
14939
+ this._heatmapColors = heatmapColorRange(this.style.colorscheme);
14940
+ this._heatmapColorKey = colorKey;
14941
+ }
14942
+ const radiusPx = (styleNum(this.style.radiuspx) || 30) * (styleNum(this.style.scale) || 1)
14943
+ * objectZoomFactor(liveZoom, this.mapOptions);
14944
+ const stepped = Math.pow(2, Math.round(Math.log2(Math.max(1, radiusPx)) * 6) / 6);
14945
+ return [new HeatmapLayer({
14946
+ id: `ix-heatmap-${this.name}`,
14947
+ data: this._heatmapData, pickable: false,
14948
+ getPosition: d => d.position,
14949
+ getWeight: d => d.weight,
14950
+ radiusPixels: Math.max(1, Math.min(400, stepped)),
14951
+ intensity: styleNum(this.style.intensity) || 1,
14952
+ threshold: styleNum(this.style.threshold) || 0.05,
14953
+ colorRange: this._heatmapColors,
14954
+ opacity: styleNum(this.style.fillopacity) || 1
14955
+ })];
14956
+ }
14957
+
14253
14958
  // .type("DOT") — one point per record, always (no clustering, matching
14254
14959
  // the real engine bypassing AGGREGATE for this type). Color comes from
14255
14960
  // a bound value's class — CATEGORICAL (exact match) or numeric
@@ -14265,7 +14970,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14265
14970
  const data = source.map(f => ({
14266
14971
  geometry: f.geometry,
14267
14972
  properties: {
14268
- value: this.binding.size ? parseFloat(f.properties[this.binding.size]) : undefined,
14973
+ value: this.binding.size ? scanValue(f.properties[this.binding.size]) : undefined,
14269
14974
  raw: f.properties,
14270
14975
  cat: hasClasses ? this._resolveClassIndex(f.properties[this.binding.value]) : null
14271
14976
  }
@@ -14522,7 +15227,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14522
15227
  // GRIDSIZE: every chart the size of its grid cell, whatever its value
14523
15228
  // (_gridChartRadiusPx); otherwise valueRadius
14524
15229
  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()));
15230
+ // AUTOSIZE: the biggest aggregated item is the size of the cell, the others by value
15231
+ const autoR = this._autoSizeMaxRadiusPx(zoom, liveZoom);
15232
+ const autoMax = autoR != null ? (this._ensureAggregateStats(zoom).sizeMax || 1) : 1;
15233
+ const autoPow = autoR != null ? resolveSizePow(this.style, this.flags) : 1;
15234
+ const vRadius = v => (gridR != null ? gridR
15235
+ : autoR != null ? Math.max(VALUE_RADIUS_MIN, autoR * Math.pow(Math.abs(v || 0) / autoMax, 1 / autoPow))
15236
+ : valueRadius(v, liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue, this._sizeBound()));
14526
15237
  // AGGREGATE sums without a normalsizevalue: flat's default is the
14527
15238
  // largest aggregated value (defaultNormalSizeValue leaves it unset, the
14528
15239
  // sizes depend on the aggregation)
@@ -14738,7 +15449,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14738
15449
  getPosition: d => d.geometry.coordinates,
14739
15450
  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
15451
  getSize: d => vRadius(sizeValueOf(d)) * (d.properties.counts ? 9 : 11),
14741
- getColor: d => [255, 255, 255, this._iconAlpha(d)],
15452
+ getColor: d => [255, 255, 255, Math.round(this._iconAlpha(d) * glowOpacityOf(this.style))],
14742
15453
  ...(alignOf.active ? { getPixelOffset: alignOf } : {}),
14743
15454
  sizeUnits: 'pixels',
14744
15455
  billboard: true,
@@ -15150,7 +15861,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15150
15861
  if (multi && auto100 && !this._seqAuto100Max) {
15151
15862
  let m = 0;
15152
15863
  for (const f of this.features) {
15153
- const parts = this._multiFields.map(k => parseFloat(f.properties && f.properties[k]) || 0);
15864
+ const parts = this._multiFields.map(k => scanValue(f.properties && f.properties[k]) || 0);
15154
15865
  const sum = parts.reduce((a, v) => a + v, 0);
15155
15866
  if (sum > 0) for (const v of parts) { const nv = v / (sum / 100); if (nv > m) m = nv; }
15156
15867
  }
@@ -15171,12 +15882,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15171
15882
  const sizeField = multi && this.binding.size && this.binding.size !== '$item$' ? this.binding.size : null;
15172
15883
  let sizeMax = 0;
15173
15884
  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; }
15885
+ for (const f of this.features) { const v = Math.abs(scanValue(f.properties && f.properties[sizeField]) || 0); if (v > sizeMax) sizeMax = v; }
15175
15886
  }
15176
15887
  const itemSizeFactor = d => {
15177
15888
  if (!sizeField) return 1;
15178
15889
  const sz = typeof d.properties.size === 'number' ? d.properties.size
15179
- : Math.abs(parseFloat((d.properties.raw || {})[sizeField]) || 0);
15890
+ : Math.abs(scanValue((d.properties.raw || {})[sizeField]) || 0);
15180
15891
  return sizeMax > 0 ? Math.pow(sz / sizeMax, 1 / sizePow) : 0;
15181
15892
  };
15182
15893
  const rings = flatFlag(this.flags, 'RINGS');
@@ -15359,7 +16070,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15359
16070
  getIcon: d => this._getGlowIcon(colorOf(d.part.i)),
15360
16071
  getSize: d => d.part.r * 11,
15361
16072
  getPixelOffset: offsetOf,
15362
- getColor: d => [255, 255, 255, alphaOf(d)],
16073
+ getColor: d => [255, 255, 255, Math.round(alphaOf(d) * glowOpacityOf(st))],
15363
16074
  sizeUnits: 'pixels', billboard: true,
15364
16075
  parameters: ICON_LAYER_GLOBE_PARAMETERS
15365
16076
  }));
@@ -15714,7 +16425,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15714
16425
  if (!layout) continue;
15715
16426
  const sum = values.reduce((a, v) => a + v, 0);
15716
16427
  if (star) for (const v of values) if (v > starMaxPart) starMaxPart = v;
15717
- const size = this.binding.size ? parseFloat(raw[this.binding.size]) : undefined;
16428
+ const size = this.binding.size ? scanValue(raw[this.binding.size]) : undefined;
15718
16429
  const r = nSizeOf(sum, size) * unit;
15719
16430
  if (!(r > 0) || !isFinite(r)) continue;
15720
16431
  // flat's draw-order key (maptheme.js 16812-16860): the size value,
@@ -16162,7 +16873,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16162
16873
  return { values: parts, size };
16163
16874
  }
16164
16875
  const raw = f.properties || {};
16165
- return { values: fields.map(k => flatNumber(raw[k], this.flags)), size: sizeField ? parseFloat(raw[this.binding.size]) : undefined };
16876
+ return { values: fields.map(k => flatNumber(raw[k], this.flags)), size: sizeField ? scanValue(raw[this.binding.size]) : undefined };
16166
16877
  };
16167
16878
  const all = agg ? this._pieAggregatedItems(zoom, [-180, -90, 180, 90]) : (this._activeFeatures || this.features);
16168
16879
  // flat's per-part mean, median (getMeanMedianQuantile) and standard
@@ -16887,7 +17598,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16887
17598
  let lo = Infinity, hi = -Infinity;
16888
17599
  const field = this.binding.value;
16889
17600
  for (const f of this.features || []) {
16890
- const v = field === '$item$' ? 1 : parseFloat(f.properties && f.properties[field]);
17601
+ const v = field === '$item$' ? 1 : scanValue(f.properties && f.properties[field]);
16891
17602
  if (isNaN(v)) continue;
16892
17603
  if (v < lo) lo = v;
16893
17604
  if (v > hi) hi = v;
@@ -18104,7 +18815,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
18104
18815
  flatToMapLibreZoom, mapLibreToFlatZoom, flatViewToMapLibreZoom, mapLibreToFlatViewZoom, flatSvgProjectionOf, flatSvgProjectionByOptions, normalSizeScaleOf, scaleDenominatorAt, resolveZoomReference, resolveAggregationPx, valuesHiddenByScale,
18105
18816
  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
18817
  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,
18818
+ drawSymbolPath, normalizeSymbolShape, bubbleStrokeRgb, heatmapColorRange, heatmapWeight,
18819
+ mlHeatmapPoints, mlHeatmapWeightMax, mlHeatmapRadius, mlHeatmapPaint, syncNativeHeatmaps, mlHeatLayerId,
18820
+ kdeBandwidthKm, kdeMaxCells, kdeLevels, kdePoints, kdeGrid, kdeBands, kdeBandColors, kdeWorkerSource, kdeCompute, kdeReset, setDeckLayerClassesForTests, ensureD3Contour, colorRampRgb,
18108
18821
  flatFormatValue, themeStyleArgs, flatChangedStyleValue, explicitValueDecimals, legendDecimals,
18109
18822
  dataCacheKey, dataCacheDisabled, cachedLayerData, featuresBounds, wantsZoomToExtent, legendIsOn, applySphereBackdrop,
18110
18823
  };