ixmaps-gl 0.2.25 → 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 +21 -8
  2. package/ixmaps-gl.js +1036 -154
  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.25';
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);
@@ -1201,7 +1206,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1201
1206
  if (!table || !Array.isArray(table.records) || !Array.isArray(table.fields)) {
1202
1207
  return table && typeof table.json === 'function' ? table.json() : [];
1203
1208
  }
1204
- const names = table.fields.map(f => f.id);
1209
+ // a header cell that spans lines ("Sezione (*)\nSektion", bilingual
1210
+ // exports) reads as one line, as in flat: "Sezione (*) Sektion"
1211
+ const names = table.fields.map(f => String(f.id).replace(/[\r\n]+/g, ' '));
1205
1212
  const rows = new Array(table.records.length);
1206
1213
  for (let i = 0; i < table.records.length; i++) {
1207
1214
  const rec = table.records[i], o = {};
@@ -1210,6 +1217,26 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1210
1217
  }
1211
1218
  return rows;
1212
1219
  }
1220
+ // .data({process}) on topojson/geojson, as flat: its data.js hands the
1221
+ // process function the features' properties as a Table (column(...).map,
1222
+ // addColumn, ...); the result goes back onto the same features, by row
1223
+ function processFeatureCollection(fc, dataConfig) {
1224
+ if (!dataConfig.process || !fc || !Array.isArray(fc.features) || !fc.features.length) return fc;
1225
+ requireDataJs('process');
1226
+ const headers = [];
1227
+ const seen = new Set();
1228
+ for (const f of fc.features) {
1229
+ for (const k of Object.keys(f.properties || {})) if (!seen.has(k)) { seen.add(k); headers.push(k); }
1230
+ }
1231
+ const table = new global.Data.Table().setArray([headers, ...fc.features.map(f => headers.map(h => (f.properties || {})[h]))]);
1232
+ const rows = dataTableRows(runDataProcess(table, dataConfig));
1233
+ if (rows.length !== fc.features.length) {
1234
+ console.warn(`[ixmaps-gl] .data({process}) changed the row count of ${dataConfig.type} features (${fc.features.length} → ${rows.length}); process result ignored`);
1235
+ return fc;
1236
+ }
1237
+ fc.features.forEach((f, i) => { f.properties = rows[i]; });
1238
+ return fc;
1239
+ }
1213
1240
  // parse CSV text with data.js (as flat) → a Table
1214
1241
  function dataJsCsvTable(text) {
1215
1242
  return new Promise((resolve, reject) => {
@@ -1531,8 +1558,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1531
1558
  // genuinely CSV-shaped .obj with a missing binding.position would
1532
1559
  // throw, even though this .obj was never row data in the first place.
1533
1560
  if (dataConfig.obj) {
1534
- if (dataConfig.type === 'topojson') return topojsonToFeatureCollection(dataConfig.obj, dataConfig.object);
1535
- if (dataConfig.type === 'geojson') return sanitizeGeoJSON(dataConfig.obj);
1561
+ if (dataConfig.type === 'topojson') return processFeatureCollection(topojsonToFeatureCollection(dataConfig.obj, dataConfig.object), dataConfig);
1562
+ if (dataConfig.type === 'geojson') return processFeatureCollection(sanitizeGeoJSON(dataConfig.obj), dataConfig);
1536
1563
  let obj = dataConfig.obj;
1537
1564
  if (dataConfig.process) {
1538
1565
  requireDataJs('process');
@@ -1624,8 +1651,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1624
1651
  // them into a table with a geometry column, and back)
1625
1652
  const resp = await fetch(dataConfig.url);
1626
1653
  if (!resp.ok) throw new Error(`[ixmaps-gl] failed to fetch ${dataConfig.url}: ${resp.status}`);
1627
- if (dataConfig.type === 'topojson') return topojsonToFeatureCollection(await resp.json(), dataConfig.object);
1628
- return sanitizeGeoJSON(await resp.json());
1654
+ if (dataConfig.type === 'topojson') return processFeatureCollection(topojsonToFeatureCollection(await resp.json(), dataConfig.object), dataConfig);
1655
+ return processFeatureCollection(sanitizeGeoJSON(await resp.json()), dataConfig);
1629
1656
  }
1630
1657
  // every other format (json, jsonstat, ndjson, rss, kml, gml, geobuf,
1631
1658
  // flatgeobuf, geopackage, parquet, ...) through data.js, as flat
@@ -1920,7 +1947,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1920
1947
  function flatShapeAreaKm2(featureRt, f) {
1921
1948
  const sizeField = featureRt.binding && featureRt.binding.size;
1922
1949
  if (sizeField) {
1923
- const v = parseFloat(f.properties && f.properties[sizeField]);
1950
+ const v = scanValue(f.properties && f.properties[sizeField]);
1924
1951
  return isFinite(v) ? v / 1000000 : NaN;
1925
1952
  }
1926
1953
  return geodesicPolygonAreaKm2(f.geometry);
@@ -1979,6 +2006,415 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1979
2006
  return isFinite(o) ? Math.max(0, Math.min(1, o)) : 0;
1980
2007
  }
1981
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
+
1982
2418
  // a choropleth shape's outline color: a LINE shape strokes with its value
1983
2419
  // color; any other shape keeps the theme's outline, hidden while a class
1984
2420
  // is isolated and the shape is not in it
@@ -2011,6 +2447,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2011
2447
  // specific FEATURE runtime as a donor, not just sharing its name
2012
2448
  // coincidentally.
2013
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 });
2014
2453
  const idField = geomRt.binding.id;
2015
2454
  const lookupField = spec.binding.lookup;
2016
2455
  if (!idField || !lookupField) {
@@ -2798,7 +3237,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2798
3237
  // edit it by hand; `npm run unit` fails when it drifts.
2799
3238
  // ---------------------------------------------------------------
2800
3239
  // <grammar:binding-aliases>
2801
- // 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
2802
3241
  const FLAT_BINDING_ALIASES = {
2803
3242
  "aggregation": "style.aggregationfield",
2804
3243
  "aggregationfield": "style.aggregationfield",
@@ -2860,7 +3299,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2860
3299
  // both — so .style({tooltip}) is a tooltip and .meta({fillopacity}) a style
2861
3300
  // property. GENERATED by test/sync-grammar.mjs — do not edit.
2862
3301
  // <grammar:meta-keys>
2863
- // generated from ixmaps-grammar 0.1.28 — 5 meta keys
3302
+ // generated from ixmaps-grammar 0.1.29 — 5 meta keys
2864
3303
  const FLAT_META_KEYS = ["description","name","snippet","title","tooltip"];
2865
3304
  // </grammar:meta-keys>
2866
3305
 
@@ -3100,7 +3539,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3100
3539
  'sizepow', 'rangescale', 'minvalue', 'maxvalue', 'markersize', 'boxopacity', 'outlierscale', 'valuescale',
3101
3540
  'brightness', 'fractionscale', 'dopacityscale', 'dopacitypow', 'gridwidthpx', 'textscale', 'rangecentervalue',
3102
3541
  'shadowblur', 'shadowdx', 'shadowdy', 'maxshadow', 'offsetx', 'offsety', 'gridx', 'boxmargin', 'borderwidth',
3103
- 'borderradius', 'maxcharts', 'archeight'];
3542
+ 'borderradius', 'maxcharts', 'archeight', 'alignmargin',
3543
+ // HEATMAP / MLHEATMAP / KDE (a project JSON writes them as strings)
3544
+ 'radiuspx', 'intensity', 'threshold', 'weightmax', 'bandwidth'];
3104
3545
  function toNumberIfNumeric(v) {
3105
3546
  if (typeof v !== 'string' || !v.trim()) return v;
3106
3547
  const n = Number(v);
@@ -3175,6 +3616,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3175
3616
  delete style.title;
3176
3617
 
3177
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
+ });
3178
3625
  if (flags.has('BUBBLE')) flags.add('SYMBOL');
3179
3626
  // flat tests its base type as a substring (szFlag.match(/CHOROPLETH/)),
3180
3627
  // so "CHOROPLETHE" is a choropleth there too
@@ -3592,6 +4039,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3592
4039
  // The ocean of flat's world / orthographic maps is a {type:"Sphere"} feature: the whole map disc,
3593
4040
  // filled with the layer's color. Here it is the surface of the map (the globe, or the flat
3594
4041
  // map): a background layer under everything, in that color and opacity.
4042
+ // the themes are drawn once, as in flat, even where the map repeats its world (the themecopies map option:
4043
+ // true repeats them on every world copy)
4044
+ function applyThemeCopies(map, value) {
4045
+ if (value !== true && value !== 'true') {
4046
+ if (!map.__ixOrigGetRenderWorldCopies) map.__ixOrigGetRenderWorldCopies = map.getRenderWorldCopies;
4047
+ map.getRenderWorldCopies = () => false;
4048
+ } else if (map.__ixOrigGetRenderWorldCopies) {
4049
+ map.getRenderWorldCopies = map.__ixOrigGetRenderWorldCopies;
4050
+ delete map.__ixOrigGetRenderWorldCopies;
4051
+ }
4052
+ }
3595
4053
  const SPHERE_BACKDROP_LAYER_ID = '__ixmaps_gl_sphere_backdrop';
3596
4054
  function applySphereBackdrop(map, style) {
3597
4055
  const cs = style && style.colorscheme;
@@ -3631,6 +4089,23 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3631
4089
  // basemap credit, bottom right — same line, same pill: white, 12px
3632
4090
  // radius, 12px/20px Helvetica Neue, links rgba(0,0,0,0.75)).
3633
4091
  // ---------------------------------------------------------------
4092
+ // On a dark map (the effective darkness, as the legend and the tooltip use it) the MapLibre control buttons
4093
+ // (zoom, compass) and the page's attribution are dark too: the class ixgl-dark on the map element switches them
4094
+ let _controlLookCss = false;
4095
+ function applyControlLook(el, dark) {
4096
+ if (!_controlLookCss && typeof document !== 'undefined') {
4097
+ _controlLookCss = true;
4098
+ const css = document.createElement('style');
4099
+ css.textContent = '.ixgl-dark .maplibregl-ctrl-group{background:#222!important;box-shadow:0 0 0 1px rgba(255,255,255,.15)!important}'
4100
+ + '.ixgl-dark .maplibregl-ctrl-group button+button{border-top:1px solid #444!important}'
4101
+ + '.ixgl-dark .maplibregl-ctrl-group button:not(:disabled):hover{background-color:#3a3a3a!important}'
4102
+ + '.ixgl-dark .maplibregl-ctrl-group .maplibregl-ctrl-icon{filter:invert(1)}'
4103
+ + '.ixgl-dark .ixmaps-gl-attribution{background:#222!important;color:#aaa!important}'
4104
+ + '.ixgl-dark .ixmaps-gl-attribution a{color:#ddd!important}';
4105
+ document.head.appendChild(css);
4106
+ }
4107
+ if (el && el.classList) el.classList.toggle('ixgl-dark', !!dark);
4108
+ }
3634
4109
  let _attributionCss = false;
3635
4110
  function createAttribution(map) {
3636
4111
  if (!_attributionCss && typeof document !== 'undefined') {
@@ -3937,11 +4412,29 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3937
4412
  // pane, the shared look (fonts included) on the container
3938
4413
  return html ? { html: tooltipPaneHtml(html), style: Object.assign({ padding: '0.5em 0.7em', maxWidth: '440px' }, tooltipLook()) } : null;
3939
4414
  }
4415
+ // flat's click-to-action hook (ui/js/tools/tooltip_to_action.js): ixmaps.onItemAction(szId, item, themeObj) on a
4416
+ // click on an item — item.szSelectionId is the value of the theme's selection field — and onItemAction(null, null)
4417
+ // on a click on the map background
4418
+ function callItemAction(rt, object) {
4419
+ const hook = pageIxmaps() && pageIxmaps().onItemAction;
4420
+ if (typeof hook !== 'function') return;
4421
+ try {
4422
+ if (!rt) { hook.call(global.ixmaps, null, null); return; }
4423
+ const field = selectionFieldOf(rt);
4424
+ const props = object.properties || {};
4425
+ // a single record, or an aggregated item's first one
4426
+ const raw = props.raw || props.firstRaw || props.titleRaw || (props.raws && props.raws[0]) || props;
4427
+ const value = field && raw ? raw[field] : null;
4428
+ if (value == null) return;
4429
+ hook.call(global.ixmaps, themeIdOf(rt) + '::' + value, { szSelectionId: String(value) }, getThemeObj(themeIdOf(rt)));
4430
+ } catch (err) { console.error('[ixmaps-gl] onItemAction failed:', err); }
4431
+ }
3940
4432
  function clickFor(picked) {
3941
4433
  const info = picked && Object.assign({}, picked, { object: pickedFeature(picked.object) });
3942
4434
  if (info && info.object && info.layer) {
3943
4435
  const rt = findRuntimeForLayerId(info.layer.id);
3944
4436
  if (rt) {
4437
+ callItemAction(rt, info.object);
3945
4438
  // Only a Point geometry's own coordinates ARE an
3946
4439
  // [lng,lat] pair (every point/bubble/dot/chart-cluster
3947
4440
  // layer) — a CHOROPLETH polygon's geometry.coordinates is
@@ -3972,6 +4465,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3972
4465
  }
3973
4466
  }
3974
4467
  // clicked empty map space (or a non-tooltippable layer) -> unpin
4468
+ callItemAction(null, null);
3975
4469
  pinned = null;
3976
4470
  pinnedTooltipEl.style.display = 'none';
3977
4471
  return false;
@@ -4249,7 +4743,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4249
4743
  // category rows: its panel is the text legend (title / snippet / description)
4250
4744
  const countOnlyLegend = rt => !rt.categoryLabels && rt.flags.has('CATEGORICAL') && !rt.binding.value;
4251
4745
  const legendApplies = rt => (countOnlyLegend(rt) || (rt.categoryLabels && rt.categoryLabels.length))
4252
- && (!(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');
4253
4750
  // One theme's panel — at build time for every theme, and again for a
4254
4751
  // theme defined later (map.layer(...) in a myMap.then(...) chain,
4255
4752
  // defineLayer, loadProject); removeTheme removes it with the theme.
@@ -4350,7 +4847,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4350
4847
  // no SUM) falls back to a per-category record COUNT.
4351
4848
  // a VECTOR / BEZIER flow legend sums its size field (flat's rows
4352
4849
  // show the flows' summed sizes, measured on the twin page)
4353
- 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$'));
4354
4856
  const valueField = rt.style.valuefield || rt.binding.size;
4355
4857
  // legendunits wins over the theme's general-purpose units
4356
4858
  // (used elsewhere for tooltips, e.g. _renderItemChartHtml) —
@@ -4433,7 +4935,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4433
4935
  if (!c || typeof c[0] !== 'number') return;
4434
4936
  const [lng, lat] = c;
4435
4937
  if (!inViewport(lng, lat, bbox, globeCenter)) return;
4436
- 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; });
4437
4939
  });
4438
4940
  maxTotal = Math.max(0, ...totals);
4439
4941
  order = totals.map((v, i) => i).sort((a, b) => totals[b] - totals[a]);
@@ -4446,7 +4948,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4446
4948
  // with whatever the country filter is currently narrowing
4447
4949
  // the map down to, not just with the viewport/hemisphere.
4448
4950
  (rt._activeFeatures || rt.features).forEach(f => {
4449
- 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;
4450
4952
  if (idx == null) return;
4451
4953
  // a polygon (FEATURE|CATEGORICAL) is counted at its first vertex
4452
4954
  let pt = f.geometry && f.geometry.coordinates;
@@ -4454,7 +4956,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4454
4956
  if (!Array.isArray(pt)) return;
4455
4957
  const [lng, lat] = pt;
4456
4958
  if (!inViewport(lng, lat, bbox, globeCenter)) return;
4457
- totals[idx] += useSum ? (parseFloat(f.properties[valueField]) || 0) : 1;
4959
+ totals[idx] += useSum ? (scanValue(f.properties[valueField]) || 0) : 1;
4458
4960
  });
4459
4961
  maxTotal = Math.max(0, ...totals);
4460
4962
  order = totals.map((v, i) => i).sort((a, b) => totals[b] - totals[a]);
@@ -5313,11 +5815,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5313
5815
  // world map can't open fully zoomed out. The deck.gl overlay takes
5314
5816
  // its `repeat` from map.getRenderWorldCopies(), so the themes are
5315
5817
  // drawn on every world copy too (flat draws them once; only its
5316
- // basemap tiles wrap). .options({worldcopies: false}) draws the
5818
+ // basemap tiles wrap) — unless themecopies is off, which is the default
5819
+ // (see applyThemeCopies). .options({worldcopies: false}) draws the
5317
5820
  // world once, at the cost of that zoom limit.
5318
5821
  renderWorldCopies: this._engineOptions.worldcopies !== false
5319
5822
  });
5320
5823
  engineOptionsByMap.set(map, this._engineOptions);
5824
+ // themecopies (default: off): the map keeps its world copies (free panning past the world's edge, empty
5825
+ // space beside it) but the deck.gl overlay, which asks map.getRenderWorldCopies() for its `repeat`, draws the
5826
+ // themes once, as flat does. .options({themecopies: true}) repeats them. MapLibre's own transform does not
5827
+ // use this method.
5828
+ applyThemeCopies(map, this._engineOptions.themecopies);
5321
5829
  sphereBackdrops.forEach(style => applySphereBackdrop(map, style));
5322
5830
  // the live-SVG pane USER charts render into (see chartHostFor) —
5323
5831
  // registered here, keyed by this map's options object, so every
@@ -5446,6 +5954,16 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5446
5954
  // the page's attribution (.attribution(), Map option attribution)
5447
5955
  const attributionBox = createAttribution(map);
5448
5956
  attributionBox.set(this._attributionText != null ? this._attributionText : this.mapOptions.attribution);
5957
+ // dark controls on a dark map; re-read when a style (basemap) loads and when basemapopacity is set
5958
+ const refreshControlLook = () => {
5959
+ const o = parseFloat(this._engineOptions.basemapopacity);
5960
+ const legendBackground = this.mapOptions.legendBackground || this.mapOptions.legendbackground;
5961
+ applyControlLook(el, typeof this.__effectiveDark === 'function'
5962
+ ? this.__effectiveDark(isNaN(o) ? 1 : o, this.mapOptions.mapType, legendBackground) : false);
5963
+ };
5964
+ this._refreshControlLook = refreshControlLook;
5965
+ refreshControlLook();
5966
+ map.on('style.load', refreshControlLook);
5449
5967
  this._setAttribution = text => attributionBox.set(text);
5450
5968
 
5451
5969
  if (this._legendHtml) {
@@ -5586,18 +6104,19 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5586
6104
  add: (layerBuilder, flag) => {
5587
6105
  layerBuilder = unwrapLayer(layerBuilder);
5588
6106
  _chainedLayers = _chainedLayers.then(() => {
6107
+ let slot = null;
5589
6108
  if (/replace/i.test(flag || '')) {
5590
6109
  const def = layerBuilder.definition();
5591
6110
  const id = (def.style && def.style.name) || (def.meta && def.meta.name);
5592
- if (id) removeThemeById(id);
6111
+ if (id) { slot = captureSlot(id); removeThemeById(id); }
5593
6112
  }
5594
- return engineApi.defineLayer(layerBuilder);
6113
+ return engineApi.defineLayer(layerBuilder, slot);
5595
6114
  }).catch(err => console.error('[ixmaps-gl] map.add():', err));
5596
6115
  return engineApi;
5597
6116
  },
5598
6117
  replace: (id, layerBuilder, flag) => {
5599
6118
  layerBuilder = unwrapLayer(layerBuilder);
5600
- _chainedLayers = _chainedLayers.then(() => { removeThemeById(id); return engineApi.defineLayer(layerBuilder); })
6119
+ _chainedLayers = _chainedLayers.then(() => { const slot = captureSlot(id); removeThemeById(id); return engineApi.defineLayer(layerBuilder, slot); })
5601
6120
  .catch(err => console.error('[ixmaps-gl] map.replace():', err));
5602
6121
  return engineApi;
5603
6122
  },
@@ -5612,11 +6131,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5612
6131
  layerBuilder = unwrapLayer(layerBuilder);
5613
6132
  const f = String(flag || '');
5614
6133
  _chainedLayers = _chainedLayers.then(() => {
6134
+ let slot = null;
5615
6135
  if (!/force/i.test(f)) {
5616
6136
  if (/replace/i.test(f)) {
5617
6137
  const def = layerBuilder.definition();
5618
6138
  const id = (def.style && def.style.name) || (def.meta && def.meta.name);
5619
- if (id) removeThemeById(id);
6139
+ if (id) { slot = captureSlot(id); removeThemeById(id); }
5620
6140
  } else if (/clearcharts|clear|clearall/i.test(f)) {
5621
6141
  const charts = /clearcharts/i.test(f);
5622
6142
  const doomed = runtimes.filter(rt => !flatFlag(rt.flags, 'LOCKED') && (!charts || rt.flags.has('CHART')));
@@ -5625,7 +6145,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5625
6145
  byName.forEach((list, name) => removeRuntimes(name, list));
5626
6146
  }
5627
6147
  }
5628
- return engineApi.defineLayer(layerBuilder);
6148
+ return engineApi.defineLayer(layerBuilder, slot);
5629
6149
  }).catch(err => console.error('[ixmaps-gl] map.newTheme():', err));
5630
6150
  return engineApi;
5631
6151
  },
@@ -5857,7 +6377,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5857
6377
  // exactly how this engine's own facet-filter/style setters below
5858
6378
  // already push responsibility for "what should happen" to the
5859
6379
  // caller rather than guessing.
5860
- defineLayer: (layerBuilder) => defineFromDefinition(unwrapLayer(layerBuilder).definition()),
6380
+ defineLayer: (layerBuilder, slot) => defineFromDefinition(unwrapLayer(layerBuilder).definition(), slot),
5861
6381
  // GL-PORT COMPAT: real ixmaps-flat's map handle, as a page gets it
5862
6382
  // from `myMap.then(map => map.view(...).options(...).layer(a)
5863
6383
  // .layer(b))` — the common multi-layer idiom — is chainable on the
@@ -5879,6 +6399,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5879
6399
  if (o && typeof o === 'object') {
5880
6400
  Object.assign(builder._engineOptions, o);
5881
6401
  // read only when the MapLibre map is created — switched here at runtime
6402
+ if ('basemapopacity' in o && builder._refreshControlLook) builder._refreshControlLook();
6403
+ if ('themecopies' in o) { applyThemeCopies(map, o.themecopies); map.triggerRepaint(); }
5882
6404
  if ('worldcopies' in o) map.setRenderWorldCopies(o.worldcopies !== false && o.worldcopies !== 'false');
5883
6405
  refresh();
5884
6406
  }
@@ -6017,6 +6539,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6017
6539
  if (removed) { refresh(); notifyRedraw(); }
6018
6540
  return removed;
6019
6541
  }
6542
+ // where the theme(s) removeThemeById(id) would remove stand in the stack: the first remaining
6543
+ // theme after them (null = they were on top). Taken BEFORE the removal, handed to defineLayer.
6544
+ function captureSlot(id) {
6545
+ const named = runtimes.filter(r => (r.style && r.style.name === id) || (r.meta && r.meta.name === id));
6546
+ const doomed = named.length ? named : runtimes.filter(r => r.name === id);
6547
+ if (!doomed.length) return null;
6548
+ const last = Math.max(...doomed.map(r => runtimes.indexOf(r)));
6549
+ const anchor = runtimes.slice(last + 1).find(r => !doomed.includes(r) && !doomed.some(d => d._subTheme === r)) || null;
6550
+ return { anchor };
6551
+ }
6552
+
6020
6553
  // flat addresses a theme by its id — style.name / meta.name (several
6021
6554
  // themes may share one layer), else the layer name
6022
6555
  function removeThemeById(id) {
@@ -6187,11 +6720,16 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6187
6720
  // flat itself does ("defining a theme with the same name replaces it
6188
6721
  // in place"; a page's live-refresh loop relies on it, e.g. the NYC
6189
6722
  // status page re-defining its two themes every 10s).
6190
- async function defineFromDefinition(def) {
6723
+ // `slot` (from captureSlot) is where a replaced theme stood in the stack: the replace paths
6724
+ // remove the old theme first and pass it on, so the new one is not appended on top
6725
+ async function defineFromDefinition(def, slot) {
6191
6726
  if (validation) validation.definition(def);
6192
6727
  const pdef = projectThemeToDefinition(def);
6193
6728
  const replacedId = (pdef.meta && pdef.meta.name) || (pdef.style && pdef.style.name);
6194
- if (replacedId) removeThemeById(replacedId);
6729
+ if (replacedId) {
6730
+ if (!slot) slot = captureSlot(replacedId);
6731
+ removeThemeById(replacedId);
6732
+ }
6195
6733
  let spec = normalizeTheme(pdef);
6196
6734
  let raw;
6197
6735
  const deferred = deferredFeatureLoad(spec, map.getZoom());
@@ -6218,7 +6756,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6218
6756
  rt._zoomToPending = wantsZoomToExtent(spec.flags);
6219
6757
  rt._dataSourceKey = named ? 'name:' + spec.data.name
6220
6758
  : sourceKey || JSON.stringify({ url: spec.data && spec.data.url, urls: spec.data && spec.data.urls, type: spec.data && spec.data.type, query: spec.data && spec.data.query, obj: !!(spec.data && spec.data.obj) });
6221
- runtimes.push(rt);
6759
+ // a replacement keeps the stacking position (draw order, legend order) of the theme it replaces:
6760
+ // it goes in front of the theme that followed the old one, still on the map after the await above
6761
+ const slotAt = slot && slot.anchor ? runtimes.indexOf(slot.anchor) : -1;
6762
+ if (slotAt >= 0) runtimes.splice(slotAt, 0, rt); else runtimes.push(rt);
6222
6763
  if (fc._sphere) applySphereBackdrop(map, rt.style);
6223
6764
  _globalThemeRegistry.set(rt.name, rt);
6224
6765
  if (rt.meta && rt.meta.name && rt.meta.name !== rt.name) _globalThemeRegistry.set(rt.meta.name, rt);
@@ -6268,10 +6809,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6268
6809
  for (let i = 0; i < themes.length; i++) {
6269
6810
  const t = themes[i] || {};
6270
6811
  const name = (t.style && t.style.name) || (t.meta && t.meta.name);
6271
- if (/replace/i.test(f) && name) removeThemesNamed(name);
6812
+ let slot = null;
6813
+ if (/replace/i.test(f) && name) { slot = captureSlot(name); removeThemesNamed(name); }
6272
6814
  else if (i === 0 && !/add/i.test(f)) [...new Set(runtimes.map(r => r.name))].forEach(n => engineApi.removeTheme(n));
6273
6815
  try {
6274
- report.themes.push(await defineFromDefinition(withoutProjectCode(t, report)));
6816
+ report.themes.push(await defineFromDefinition(withoutProjectCode(t, report), slot));
6275
6817
  } catch (e) {
6276
6818
  report.skipped.push({ layer: t.layer, reason: e.message });
6277
6819
  }
@@ -6435,7 +6977,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6435
6977
  // charts in their own group above them: whatever the theme order
6436
6978
  // (a choropleth swapped in with add(..., 'replace') comes last), the
6437
6979
  // shapes lie under the charts — in theme order among themselves
6438
- const isShape = l => /^ix-(features|choropleth|shadow)-/.test(l.id);
6980
+ const isShape = l => /^ix-(features|choropleth|shadow|kde)-/.test(l.id);
6439
6981
  layers = layers.filter(isShape).concat(layers.filter(l => !isShape(l)));
6440
6982
  // every deck.gl layer id once: two themes of one layer name and type
6441
6983
  // (the page's comune points and comune polygons, both FEATURES on
@@ -6479,6 +7021,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6479
7021
  // flat's WMS themes render as native raster layers, not deck.gl —
6480
7022
  // reconcile them with the current runtimes on every rebuild
6481
7023
  syncWmsLayers();
7024
+ syncNativeHeatmaps(map, runtimes, mlHeatLayerIds);
6482
7025
  }
6483
7026
 
6484
7027
  // flat's WMS theme type (szFlag /\bWMS|IMAGE\b/, maptheme.js 8207-8247):
@@ -6495,6 +7038,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6495
7038
  // map.getStyle() (a deep copy of the whole style) on every rebuild
6496
7039
  // of a page that has no WMS theme
6497
7040
  const wmsLayerIds = new Set();
7041
+ const mlHeatLayerIds = new Set(); // the MLHEATMAP layers added to the map (syncNativeHeatmaps)
6498
7042
  function syncWmsLayers() {
6499
7043
  if (!map || !map.getLayer) return;
6500
7044
  const idOf = name => 'ixmaps-wms:' + String(name).replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -6540,7 +7084,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6540
7084
  }
6541
7085
  // a style swap (setMapType) drops every native layer — rebuild the
6542
7086
  // WMS ones once the new style stands
6543
- map.on('style.load', () => syncWmsLayers());
7087
+ map.on('style.load', () => { syncWmsLayers(); syncNativeHeatmaps(map, runtimes, mlHeatLayerIds); });
6544
7088
 
6545
7089
  // isZooming flips true/false on every 'zoomstart'/'zoomend' pair —
6546
7090
  // and a scroll-wheel or trackpad zoom fires MANY such pairs per
@@ -7409,7 +7953,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7409
7953
  : 1;
7410
7954
  }
7411
7955
 
7412
- function valueRadius(value, zoom, style, mapOptions, flags, maxSizeValue) {
7956
+ function valueRadius(value, zoom, style, mapOptions, flags, maxSizeValue, sizeBound) {
7413
7957
  const zoomFactor = objectZoomFactor(zoom, mapOptions);
7414
7958
  // FIXSIZE (a symbol chart; PLOT / GRIDSIZE size their markers
7415
7959
  // themselves): one radius for every item, whatever its value — the
@@ -7452,7 +7996,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7452
7996
  if (flags && flags.has('SYMBOL') && !flags.has('BUBBLE') && !flags.has('USER')
7453
7997
  && !flags.has('FIXSIZE') && !flags.has('PLOT') && !flags.has('GRIDSIZE')
7454
7998
  && flags.has('CATEGORICAL') && !flags.has('AGGREGATE') && !flatFlag(flags, 'GROUP')
7455
- && !style.sizefield) {
7999
+ && !style.sizefield && !sizeBound) {
7456
8000
  const fixed = NORMAL_RADIUS_PX * (styleNum(style.scale) || 1);
7457
8001
  return Math.max(VALUE_RADIUS_MIN, Math.min(VALUE_RADIUS_MAX, fixed * zoomFactor));
7458
8002
  }
@@ -7663,7 +8207,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7663
8207
  // "$item$": every record counts 1
7664
8208
  if (field === '$item$') return features.length ? 1 : 0;
7665
8209
  return features.reduce((max, f) => {
7666
- const v = parseFloat(f.properties[field]);
8210
+ const v = scanValue(f.properties[field]);
7667
8211
  return isNaN(v) ? max : Math.max(max, v);
7668
8212
  }, 0);
7669
8213
  }
@@ -7825,6 +8369,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7825
8369
  return out;
7826
8370
  }
7827
8371
 
8372
+ // the outline colour of a flat bubble icon: none for NOLINES / linecolor "none", else linecolor, else the
8373
+ // class colour's darker derivate — and none when there is neither (an unfilled "none" class has no colour
8374
+ // to derive from; with a linecolor its outline must still draw)
8375
+ function bubbleStrokeRgb(look, rgb) {
8376
+ return look.noStroke ? null : (look.stroke || (rgb ? flatDerivateRgb(rgb, 0.7) : null));
8377
+ }
8378
+
7828
8379
  // ChartColors.textColor (colorscheme.js 104-116): darker on light colors
7829
8380
  function flatChartTextRgb(rgb) {
7830
8381
  return flatDerivateRgb(rgb, rgb[0] + rgb[1] + rgb[2] > 450 ? 0.6 : 3);
@@ -7930,6 +8481,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7930
8481
  const scale = scaleDenominatorAt(zoom);
7931
8482
  return !!((upper && scale > upper) || (lower && scale < lower));
7932
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
+ }
7933
8491
  function themeHiddenByScale(flags, style, zoom) {
7934
8492
  return (flags.has('FEATURE') || flags.has('FEATURES')) ? featuresHiddenByScale(style, zoom) : chartHiddenByScale(style, zoom);
7935
8493
  }
@@ -9260,7 +9818,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9260
9818
  }
9261
9819
  const parts = [], zeroNot = [];
9262
9820
  for (let k = 0; k < o.fields.length; k++) {
9263
- let v = parseFloat(raw[o.fields[k]]);
9821
+ let v = scanValue(raw[o.fields[k]]);
9264
9822
  // flat's weights multiply every record's per-field value as it is
9265
9823
  // accumulated (maptheme.js 10506-10508) — the SIZE (nValueSize)
9266
9824
  // stays raw, exactly as there
@@ -9923,14 +10481,14 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9923
10481
  let maxAlpha = -Infinity, minAlpha = Infinity;
9924
10482
  features.forEach(f => {
9925
10483
  // flat: an alpha that is no number is 0 (maptheme.js 9853-9857)
9926
- let v = parseFloat(f.properties[binding.alpha]);
10484
+ let v = scanValue(f.properties[binding.alpha]);
9927
10485
  if (isNaN(v)) v = 0;
9928
10486
  if (isDensity) {
9929
10487
  const areaKm2 = featureAreaKm2(f);
9930
10488
  if (!areaKm2) return;
9931
10489
  v = v / areaKm2;
9932
10490
  } else if (alpha100) {
9933
- const v100 = parseFloat(f.properties[alpha100]);
10491
+ const v100 = scanValue(f.properties[alpha100]);
9934
10492
  if (!isNaN(v100) && v100) v = 100 / v100 * v;
9935
10493
  }
9936
10494
  out._alphaByFeature.set(f, v);
@@ -10007,10 +10565,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10007
10565
  features.forEach(f => {
10008
10566
  if (!f.properties || !Object.keys(f.properties).length) return; // unjoined polygon
10009
10567
  nItems++;
10010
- const v100 = f100 ? parseFloat(f.properties[f100]) : NaN;
10568
+ const v100 = f100 ? scanValue(f.properties[f100]) : NaN;
10011
10569
  if (f100 && isFinite(v100)) sum100 += v100;
10012
10570
  fields.forEach((field, i) => {
10013
- const v = parseFloat(f.properties[field]);
10571
+ const v = scanValue(f.properties[field]);
10014
10572
  if (!isFinite(v)) return;
10015
10573
  sums[i] += v;
10016
10574
  if (f100 && isFinite(v100)) pooled[i] += v * v100;
@@ -10071,7 +10629,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10071
10629
  const useDeviation = ctx.flags.has('DEVIATION');
10072
10630
  let bestIndex = -1, bestRelevance = 0, bestValue = null;
10073
10631
  for (let i = 0; i < fields.length; i++) {
10074
- const v = parseFloat(props[fields[i]]);
10632
+ const v = scanValue(props[fields[i]]);
10075
10633
  if (!(v > (ctx._dominantMins[i] || 0))) continue;
10076
10634
  const relevance = useDeviation ? (v - ctx._dominantMeans[i]) / ctx._dominantStdDevs[i]
10077
10635
  : usePercentOfMean ? 100 * v / ctx._dominantMeans[i]
@@ -10099,7 +10657,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10099
10657
  const fields = out._multiFields;
10100
10658
  let nMax = 0;
10101
10659
  features.forEach(f => fields.forEach(field => {
10102
- const v = parseFloat(f.properties[field]);
10660
+ const v = scanValue(f.properties[field]);
10103
10661
  if (v > nMax) nMax = v;
10104
10662
  }));
10105
10663
  out._composeColorMax = nMax;
@@ -10139,7 +10697,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10139
10697
  const subtractive = ctx.flags.has('SUBTRACTIVE');
10140
10698
  let rr = 0, gg = 0, bb = 0;
10141
10699
  for (let i = 0; i < fields.length; i++) {
10142
- const v = parseFloat(props[fields[i]]) || 0;
10700
+ const v = scanValue(props[fields[i]]) || 0;
10143
10701
  const weight = v / nMax;
10144
10702
  const [r, g, b] = rgbs[i];
10145
10703
  if (subtractive) { rr += (255 - r) * weight; gg += (255 - g) * weight; bb += (255 - b) * weight; }
@@ -10212,14 +10770,20 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10212
10770
  // flat's reading of a value field (maptheme.js 9776-9800, defaults
10213
10771
  // 7740-7801): a value that is no number counts as 0 — unless
10214
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
+ }
10215
10780
  function flatNumber(v, flags) {
10216
10781
  // flat's own value scan (maptheme.js __scanValue 9265-9272): a
10217
10782
  // value with a comma is EUROPEAN format — dots stripped as
10218
10783
  // thousands separators, the comma is the decimal ("78,8" -> 78.8);
10219
10784
  // a value without one is parsed plain, blanks stripped. Applied
10220
10785
  // to every theme value in flat, so also here
10221
- const s = String(v);
10222
- let n = s.includes(',') ? parseFloat(s.replace(/\./g, '').replace(/,/g, '.')) : parseFloat(s.replace(/ /g, ''));
10786
+ let n = scanValue(v);
10223
10787
  if (isNaN(n)) n = flags && flags.has('UNDEFINEDISNOTVALUE') ? NaN : 0;
10224
10788
  if (n === 0 && flags && flags.has('ZEROISNOTVALUE')) return NaN;
10225
10789
  return n;
@@ -10348,14 +10912,14 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10348
10912
  if (!p) { out.push(f); continue; }
10349
10913
  let clear = [];
10350
10914
  if (size) {
10351
- const v = parseFloat(p[size]);
10915
+ const v = scanValue(p[size]);
10352
10916
  if (!(isFinite(v) && v > 0)) {
10353
10917
  if (!keepShapes) continue;
10354
10918
  clear.push(size);
10355
10919
  }
10356
10920
  }
10357
10921
  if (negativeIsNot) {
10358
- const negative = checked.filter(k => parseFloat(p[k]) < 0);
10922
+ const negative = checked.filter(k => scanValue(p[k]) < 0);
10359
10923
  if (negative.length && aggregate && !keepShapes) continue;
10360
10924
  clear = clear.concat(negative);
10361
10925
  }
@@ -10502,7 +11066,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10502
11066
  // without AGGREGATE applyField100 already differenced every record
10503
11067
  // (flat's nValuesA, popped): the first field IS the value
10504
11068
  ? (!flags.has('AGGREGATE') ? p => flatNumber(p[fields[0]], flags)
10505
- : 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])
10506
11070
  // "$item$" is flat's record count: 1 per record
10507
11071
  : binding.value === '$item$' ? () => 1
10508
11072
  : p => (p && Object.keys(p).length ? flatNumber(p[binding.value], flags) : NaN);
@@ -10653,9 +11217,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10653
11217
  // theme's text 0.08 px high).
10654
11218
  function resolveAggregateValue(binding, flags, props) {
10655
11219
  if (binding.size === '$item$') return 1;
10656
- if (binding.size) return parseFloat(props[binding.size]) || 0;
11220
+ if (binding.size) return scanValue(props[binding.size]) || 0;
10657
11221
  if (flags.has('CATEGORICAL') || binding.value == null || binding.value === '$item$') return 1;
10658
- return parseFloat(props[binding.value]) || 0;
11222
+ return scanValue(props[binding.value]) || 0;
10659
11223
  }
10660
11224
 
10661
11225
  // Whether a range-classed AGGREGATE cell's class value (flat: the sum
@@ -10959,7 +11523,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10959
11523
  const add = (cat, v) => { if (cat == null || cat < 0 || cat >= n) return; count[cat]++; sum[cat] += Number.isFinite(v) ? v : 0; };
10960
11524
  if (rt.flags.has('CHOROPLETH')) {
10961
11525
  (rt._activeFeatures || rt.features).forEach(f => {
10962
- const v = parseFloat(f.properties[rt.binding.value]);
11526
+ const v = scanValue(f.properties[rt.binding.value]);
10963
11527
  add(rt._resolvePartsClass(v), v);
10964
11528
  });
10965
11529
  } else if (rt._legendItems) {
@@ -10970,7 +11534,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10970
11534
  if (!c || typeof c[0] !== 'number') return;
10971
11535
  const [lng, lat] = c;
10972
11536
  if (!inViewport(lng, lat, bbox, globeCenter)) return;
10973
- const v = parseFloat(f.properties[rt.binding.value]);
11537
+ const v = scanValue(f.properties[rt.binding.value]);
10974
11538
  add(rt._resolvePartsClass(v), v);
10975
11539
  });
10976
11540
  }
@@ -11012,6 +11576,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11012
11576
  this.name = spec.name;
11013
11577
  this.binding = spec.binding;
11014
11578
  this.style = spec.style;
11579
+ this._donorStyle = spec._donorStyle;
11015
11580
  this.flags = symbolsFlagCompat(spec.flags, spec.style);
11016
11581
  this.meta = spec.meta;
11017
11582
  // see LAYER_DATA_GENERATION above — unique per runtime, see _dataLayerId
@@ -11117,7 +11682,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11117
11682
  // a bar chart's parts are a pie's (its value fields or categories)
11118
11683
  if ((this._isPieChart() || this._isBarChart()) && this.binding.value) {
11119
11684
  this._preparePie();
11120
- } 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()) {
11121
11686
  // .type("CHOROPLETH|DOMINANT") — a MULTI-field bound value
11122
11687
  // (binding.value is a pipe-joined field list, e.g. one CSV column
11123
11688
  // per age band for the same year/sex — same pipe convention
@@ -11154,7 +11719,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11154
11719
  } else {
11155
11720
  const seen = new Map();
11156
11721
  this.features.forEach(f => {
11157
- 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
11158
11724
  if (v != null && !seen.has(v)) seen.set(v, seen.size);
11159
11725
  });
11160
11726
  this.categoryLabels = Array.from(seen.keys());
@@ -11197,7 +11763,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11197
11763
  const scheme = Array.isArray(this.style.colorscheme) ? this.style.colorscheme : [];
11198
11764
  this._partNoFill = new Set(this._multiFields.map((f, i) => i).filter(i => String(scheme[i]).trim().toLowerCase() === 'none'));
11199
11765
  let max = 0;
11200
- 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; }
11201
11767
  this._multiFieldPartMax = max || 1;
11202
11768
  // AUTO100's scale, measured on these features by _buildSequenceChartLayers
11203
11769
  this._seqAuto100Max = null;
@@ -11354,12 +11920,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11354
11920
  : this.flags.has('PERMILLE') ? 1000 : 100;
11355
11921
  let sum = 0;
11356
11922
  fields.forEach((field, i) => {
11357
- const part = parseFloat(props[field]);
11923
+ const part = scanValue(props[field]);
11358
11924
  if (isNaN(part)) return;
11359
- 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;
11360
11926
  sum += isNaN(v100) ? part : (part * v100) / multiplier;
11361
11927
  });
11362
- const v100_0 = f100A ? parseFloat(props[f100A[0]]) : NaN;
11928
+ const v100_0 = f100A ? scanValue(props[f100A[0]]) : NaN;
11363
11929
  const nMyValue = sum / ((v100_0 / 100) || 1);
11364
11930
  return composedDopacityAlpha(this.style, this.flags, nMyValue, this._composeColorMax || 1);
11365
11931
  }
@@ -11378,8 +11944,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11378
11944
  // (_buildPartsA, numeric bucket), or neither (a single implicit
11379
11945
  // bucket, today's behavior for any other theme).
11380
11946
  _resolveClassIndex(rawValue) {
11381
- if (this.categoryIndexByLabel) return this.categoryIndexByLabel.get(rawValue);
11382
- 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));
11383
11952
  return 0;
11384
11953
  }
11385
11954
 
@@ -11449,8 +12018,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11449
12018
  // as flat's chart node value (NYC status: value="63" = e-bikes)
11450
12019
  const sizeField = this.binding.size && this.binding.size !== '$item$' ? this.binding.size : null;
11451
12020
  this._featuresByCategory = [sourceFeatures.map(f => {
11452
- const parts = this._multiFields.map(k => parseFloat(f.properties[k]) || 0);
11453
- 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;
11454
12023
  return { type: 'Feature', geometry: f.geometry,
11455
12024
  properties: { value: parts[0], parts, series: parts, ...(size !== undefined ? { size } : {}), raw: f.properties } };
11456
12025
  })];
@@ -11476,9 +12045,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11476
12045
  // values, the difference is taken on the cell sums (see post)
11477
12046
  const seriesFields = String(this.binding.value || '').split('|');
11478
12047
  this._seriesDifference = this.flags.has('DIFFERENCE') && seriesFields.length > 1 && !this._aggregateField100;
11479
- const seriesOf = props => seriesFields.map(k => parseFloat(props[k]) || 0);
12048
+ const seriesOf = props => seriesFields.map(k => scanValue(props[k]) || 0);
11480
12049
  // without AGGREGATE the records are already differenced (applyField100)
11481
- 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];
11482
12051
  this._featuresByCategory = [sourceFeatures.map(f => ({
11483
12052
  type: 'Feature',
11484
12053
  geometry: f.geometry,
@@ -11486,8 +12055,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11486
12055
  value: this._seriesDifference ? seriesValue(f.properties) : this._resolveAggregateValue(f.properties),
11487
12056
  ...(this._seriesDifference ? { series: seriesOf(f.properties) } : {}),
11488
12057
  // "$item$": flat's record count, 1 per record
11489
- ...(this._classSeparate ? { classValue: this.binding.value === '$item$' ? 1 : parseFloat(f.properties[this.binding.value]) || 0 } : {}),
11490
- ...(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 } : {}),
11491
12060
  raw: f.properties
11492
12061
  }
11493
12062
  }))];
@@ -11776,7 +12345,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11776
12345
  const countsByField = new Map(fields.map(f => [f, new Map()]));
11777
12346
 
11778
12347
  scoped.forEach(f => {
11779
- const weight = sizeField ? (parseFloat(f.properties[sizeField]) || 0) : 1;
12348
+ const weight = sizeField ? (scanValue(f.properties[sizeField]) || 0) : 1;
11780
12349
  for (const field of fields) {
11781
12350
  const v = f.properties[field];
11782
12351
  if (v == null || v === '') continue;
@@ -12103,6 +12672,91 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12103
12672
  return Array.isArray(cs) && cs.length === 1 && String(cs[0]).trim().toLowerCase() === 'none';
12104
12673
  }
12105
12674
 
12675
+ // an external symbol file (.svg/.png/.jpeg) named by `symbols` — flat draws it as an
12676
+ // <image> (maptheme.js 21743, 22168), not as the chart's own shape. null for a shape name
12677
+ _urlSymbolOf(sym) {
12678
+ if (typeof sym !== 'string') return null;
12679
+ const s = sym.trim();
12680
+ // a data: URI is a symbol file too, and the only kind fetch() can read on a file:// page
12681
+ return /\.(svg|png|jpe?g)([?#]|$)/i.test(s) || /^data:image\/(svg\+xml|png|jpe?g)[;,]/i.test(s) ? s : null;
12682
+ }
12683
+
12684
+ // the symbol file as an IconLayer icon descriptor, NOT tinted (flat doesn't recolor an
12685
+ // <image> either); fetched and rasterized once per URL, shared by SEQUENCE and plain symbols
12686
+ _urlSymbolIcon(u) {
12687
+ if (!this._urlIconCache) this._urlIconCache = new Map();
12688
+ if (!this._urlIconCache.has(u)) {
12689
+ // the placeholder is a transparent 1x1 PNG, NOT the raw symbol
12690
+ // URL: deck.gl would immediately try to load that itself, and an
12691
+ // SVG without natural dimensions (a viewBox only, the bus
12692
+ // pictogram of the City-moving page) makes Chrome's
12693
+ // createImageBitmap throw — a console error and a part that
12694
+ // never renders. The real raster replaces it below
12695
+ const entry = { url: TRANSPARENT_1PX_PNG, width: 256, height: 256, anchorX: 128, anchorY: 128, mask: false, id: 'seq-sym-pending:' + u };
12696
+ this._urlIconCache.set(u, entry);
12697
+ // fetch the symbol ONCE and rasterize it into the descriptor's
12698
+ // own 256px cell here: deck then always sees exactly the declared
12699
+ // dimensions, and dimension-less SVGs work like any other. The
12700
+ // redraw lets the next getIcon see the patched descriptor
12701
+ fetch(u).then(r => r.ok ? r.text() : null).then(text => new Promise((resolve, reject) => {
12702
+ if (typeof text !== 'string' || !/<svg[\s>]/i.test(text)) { reject(new Error('not svg')); return; }
12703
+ if (!/\swidth\s*=/i.test(text) || !/\sheight\s*=/i.test(text)) {
12704
+ const vb = text.match(/viewBox\s*=\s*["']([^"']*)["']/i);
12705
+ const p = vb && vb[1].trim().split(/[\s,]+/).map(Number);
12706
+ if (p && p.length === 4 && isFinite(p[2]) && p[2] > 0 && isFinite(p[3]) && p[3] > 0) {
12707
+ text = text.replace(/<svg/i, '<svg width="' + p[2] + '" height="' + p[3] + '"');
12708
+ }
12709
+ }
12710
+ const img = new Image();
12711
+ img.onload = () => resolve(img);
12712
+ img.onerror = () => reject(new Error('svg decode failed'));
12713
+ img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(text);
12714
+ })).then(img => {
12715
+ const size = entry.width;
12716
+ const canvas = document.createElement('canvas');
12717
+ canvas.width = size; canvas.height = size;
12718
+ const ctx = canvas.getContext('2d');
12719
+ const s = Math.min(size / (img.naturalWidth || size), size / (img.naturalHeight || size));
12720
+ const dw = (img.naturalWidth || size) * s, dh = (img.naturalHeight || size) * s;
12721
+ ctx.drawImage(img, (size - dw) / 2, (size - dh) / 2, dw, dh);
12722
+ entry.url = canvas.toDataURL('image/png');
12723
+ entry.id = 'seq-sym-raster:' + u;
12724
+ if (this._triggerRedraw) this._triggerRedraw();
12725
+ }).catch(() => {
12726
+ // a symbol FILE of a file:// page can't be fetched (nor loaded by deck): say so once
12727
+ let onDisk = false;
12728
+ try { onDisk = new URL(u, location.href).protocol === 'file:'; } catch (e) { /* not a URL */ }
12729
+ if (onDisk && !this._warnedFileSymbol) {
12730
+ this._warnedFileSymbol = true;
12731
+ console.warn('[ixmaps-gl] symbol file "' + u + '" cannot be loaded from a file:// page — serve the page over http(s) or give the symbol as a data: URI');
12732
+ }
12733
+ // not an SVG (a PNG/JPEG symbol): deck's own URL loading takes
12734
+ // it from here — and a pre-sized SVG that failed to rasterize
12735
+ // gets that same second chance
12736
+ entry.url = u;
12737
+ entry.id = 'seq-sym-natural:' + u;
12738
+ if (this._triggerRedraw) this._triggerRedraw();
12739
+ });
12740
+ }
12741
+ return this._urlIconCache.get(u);
12742
+ }
12743
+
12744
+ // a size field is bound (flat's szSizeField): .binding({size}) or style.sizefield, live —
12745
+ // setSizeField() rebinds at runtime. '$item$' is not a field
12746
+ _sizeBound() { return !!(this.binding.size && this.binding.size !== '$item$'); }
12747
+
12748
+ // the symbol file of a plain (non-SEQUENCE) item, or null: the same class→symbol /
12749
+ // symbolfield mapping as _resolveSymbolShape, before it folds unknown names to 'circle'
12750
+ _resolveSymbolUrl(props) {
12751
+ const s = this.style.symbols;
12752
+ if (!Array.isArray(s)) return null;
12753
+ if (!this.style.symbolfield) {
12754
+ const cat = props && props.cat != null ? props.cat : 0;
12755
+ return this._urlSymbolOf(s[Math.min(cat, s.length - 1)]);
12756
+ }
12757
+ return this._urlSymbolOf(this._resolveSymbolShape(props));
12758
+ }
12759
+
12106
12760
  _buildSingleIcon(colorRgb, opacity, shape, borderColorRgb, borderWidth, borderOpacity = 1) {
12107
12761
  if (!colorRgb) { colorRgb = [128, 128, 128]; opacity = 0; } // no class colour (["none"]): unfilled
12108
12762
  shape = shape || 'circle';
@@ -12340,13 +12994,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12340
12994
  const fields = String(this.binding.value || '').split('|');
12341
12995
  if (this._multiFieldMax === undefined) {
12342
12996
  let max = 0;
12343
- 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; }
12344
12998
  this._multiFieldMax = max;
12345
12999
  }
12346
13000
  const labels = Array.isArray(this.style.label) && this.style.label.length === fields.length ? this.style.label.map(String)
12347
13001
  : (this.categoryDisplayLabels || this.categoryLabels || fields);
12348
13002
  const unit = this.style.units ? ' ' + this.style.units : '';
12349
- 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));
12350
13004
  if (!rows.length) return '';
12351
13005
  if (this.flags.has('SORT')) rows.sort((a, b) => (this.flags.has('UP') ? a.v - b.v : b.v - a.v));
12352
13006
  const color = i => { const c = this.classRgb(i); return `rgb(${c.slice(0, 3).join(',')})`; };
@@ -12501,6 +13155,23 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12501
13155
  // drawChart/modifier machinery entirely — a layer flagged DOT never
12502
13156
  // reaches CHART|SYMBOL's clustering/GLOW/VALUES pipeline, even if
12503
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
+ }
12504
13175
  if (this.flags.has('DOT')) return this._buildDotLayers();
12505
13176
  // GRIDSIZE (spatial grid binning) is checked before the generic
12506
13177
  // CHART|SYMBOL bubble pipeline, same "more specific base type wins"
@@ -12600,7 +13271,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12600
13271
  // layer keeps its one fill color
12601
13272
  && this.binding.value && this.binding.value !== '$item$' && !String(this.binding.value).includes('|');
12602
13273
  const classColorOf = f => {
12603
- 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]));
12604
13275
  return this.classRgb(cls);
12605
13276
  };
12606
13277
  const catAlpha = Math.round(255 * (styleNum(this.style.fillopacity) || 1));
@@ -12823,7 +13494,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12823
13494
  // linecolor "none": no outline; a class color "none" (an explicit
12824
13495
  // colorscheme list) leaves its polygons unpainted, an rgba() class
12825
13496
  // color keeps its alpha — as flat's SVG fill does
12826
- 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;
12827
13502
  const lineRgb = lineColorRaw ? withAlpha(hexOrNamedToRgb(lineColorRaw), cssColorAlpha(lineColorRaw)) : [255, 255, 255];
12828
13503
  const cs = this.style.colorscheme;
12829
13504
  const classAlpha = Array.isArray(cs) && cs.length && !/^\d+$/.test(String(cs[0])) ? cs.map(cssColorAlpha) : null;
@@ -12855,11 +13530,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12855
13530
  // flat re-styles the SAME shape nodes the FEATURE base drew, so a line
12856
13531
  // choropleth keeps the base's own line width — the donor's linewidth
12857
13532
  // is the fallback when the choropleth has none of its own
12858
- const donorRt = [..._globalThemeRegistry.values()].find(r => r.name === this.name && r._isChoroplethGeometryDonor);
12859
13533
  const anyLine = collection.features.some(isLineGeometry);
12860
13534
  const isolating = !!(this._markedClasses.size && !this._onMarksChanged && evidenceMode === 'isolate');
12861
13535
  const lineW = styleNum(this.style.linewidth)
12862
- || (anyLine && donorRt && styleNum(donorRt.style.linewidth)) || 1;
13536
+ || (donorStyle && styleNum(donorStyle.linewidth)) || 1;
12863
13537
  return [new GeoJsonLayer({
12864
13538
  id: this._dataLayerId(`ix-choropleth-${this.name}`),
12865
13539
  data: collection,
@@ -12941,8 +13615,15 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12941
13615
  // GRIDSIZE without PLOT drawn as one uniform square per cell: only a
12942
13616
  // plain grid — a single color, no VALUES, no GLOW
12943
13617
  _isPlainGridMesh() {
12944
- return !this.flags.has('PLOT') && (this.categoryColorsRgb || []).length <= 1
12945
- && !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');
12946
13627
  }
12947
13628
  // GRIDSIZE (no PLOT) symbol chart radius, px: flat's nGridSize / 2
12948
13629
  // (maptheme.js 21843), nGridSize the aggregation cell pitch / nAutoSize
@@ -12959,6 +13640,19 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12959
13640
  const autoSize = rect ? (gap ? 1.15 : 1.0) : (gap ? 0.8 : 0.75);
12960
13641
  return cellPx / autoSize * (styleNum(this.style.scale) || 1) / 2;
12961
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
+ }
12962
13656
  _isItemPlot() {
12963
13657
  return this.flags.has('PLOT') && !this.flags.has('GRIDSIZE') && String(this.binding.value || '').includes('|');
12964
13658
  }
@@ -13109,7 +13803,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13109
13803
  for (const f of this.features) {
13110
13804
  const pos = itemAnchor(f.geometry);
13111
13805
  if (!pos) continue;
13112
- const values = fields.map(k => parseFloat(f.properties[k]));
13806
+ const values = fields.map(k => scanValue(f.properties[k]));
13113
13807
  for (const v of values) if (!isNaN(v)) { dMin = Math.min(dMin, v); dMax = Math.max(dMax, v); }
13114
13808
  if (!active || active.has(f)) items.push({ geometry: { type: 'Point', coordinates: pos }, properties: { values, raw: f.properties } });
13115
13809
  }
@@ -13226,9 +13920,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13226
13920
  : Array.isArray(this.style.xaxis) ? this.style.xaxis : null;
13227
13921
  if (explicit) {
13228
13922
  if (!this._explicitCatIndex) this._explicitCatIndex = new Map(explicit.map((v, i) => [String(v), i]));
13229
- 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;
13230
13925
  }
13231
- 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);
13232
13928
  }
13233
13929
 
13234
13930
  // pure: cellAggregatedValues → see the pure-function sections above the class
@@ -13569,15 +14265,93 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13569
14265
  // style.colorscheme fill.
13570
14266
  _buildGridMeshLayers(zoom) {
13571
14267
  this._ensureGridIndex(zoom);
13572
- const data = this._gridIndex.map(cell => {
13573
- const center = worldPixelToLngLat(cell.px, cell.py, zoom);
13574
- return { geometry: { type: 'Point', coordinates: [center.lng, center.lat] } };
13575
- });
13576
-
13577
14268
  const fillColor = (resolveColorScheme(this.style.colorscheme, ['']) || ['rgba(255,255,255,0.3)'])[0] || 'rgba(255,255,255,0.3)';
13578
14269
  const borderColor = styleLineColor(this.style.linecolor);
13579
14270
  const borderWidth = styleNum(this.style.linewidth);
13580
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
+ });
13581
14355
  const icon = this._buildGridSquareIcon(fillColor, borderColor, isNaN(borderWidth) ? 0 : borderWidth, cellPx);
13582
14356
 
13583
14357
  return [new IconLayer({
@@ -13681,7 +14455,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13681
14455
  // the item's class (range classes set it for groups too — a negative
13682
14456
  // group total would make dominant() pick the wrong class)
13683
14457
  const cat = d.properties.cat != null ? d.properties.cat : (d.properties.counts ? dominant(d.properties.counts) : 0);
13684
- const radiusPx = valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
14458
+ const radiusPx = valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue, this._sizeBound());
13685
14459
  const size = radiusPx / pxPerUnit;
13686
14460
  const chart = this._userChartSvg(draw, name, theme, flag, d, value, cat, size, sizeValueOf(d));
13687
14461
  if (!chart) continue;
@@ -14021,6 +14795,30 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14021
14795
  return this._cacheIcon(key, icon);
14022
14796
  }
14023
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
+
14024
14822
  _buildGridSquareIcon(fillColor, borderColor, borderWidth, cellPx) {
14025
14823
  const key = `grid|${fillColor}|${borderColor}|${borderWidth}|${cellPx}`;
14026
14824
  if (this._iconCache.has(key)) return this._iconCache.get(key);
@@ -14049,6 +14847,114 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14049
14847
  return this._cacheIcon(key, icon);
14050
14848
  }
14051
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
+
14052
14958
  // .type("DOT") — one point per record, always (no clustering, matching
14053
14959
  // the real engine bypassing AGGREGATE for this type). Color comes from
14054
14960
  // a bound value's class — CATEGORICAL (exact match) or numeric
@@ -14064,7 +14970,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14064
14970
  const data = source.map(f => ({
14065
14971
  geometry: f.geometry,
14066
14972
  properties: {
14067
- value: this.binding.size ? parseFloat(f.properties[this.binding.size]) : undefined,
14973
+ value: this.binding.size ? scanValue(f.properties[this.binding.size]) : undefined,
14068
14974
  raw: f.properties,
14069
14975
  cat: hasClasses ? this._resolveClassIndex(f.properties[this.binding.value]) : null
14070
14976
  }
@@ -14321,7 +15227,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14321
15227
  // GRIDSIZE: every chart the size of its grid cell, whatever its value
14322
15228
  // (_gridChartRadiusPx); otherwise valueRadius
14323
15229
  const gridR = this._gridChartRadiusPx(zoom, liveZoom);
14324
- const vRadius = v => (gridR != null ? gridR : valueRadius(v, liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue));
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()));
14325
15237
  // AGGREGATE sums without a normalsizevalue: flat's default is the
14326
15238
  // largest aggregated value (defaultNormalSizeValue leaves it unset, the
14327
15239
  // sizes depend on the aggregation)
@@ -14537,7 +15449,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14537
15449
  getPosition: d => d.geometry.coordinates,
14538
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]),
14539
15451
  getSize: d => vRadius(sizeValueOf(d)) * (d.properties.counts ? 9 : 11),
14540
- getColor: d => [255, 255, 255, this._iconAlpha(d)],
15452
+ getColor: d => [255, 255, 255, Math.round(this._iconAlpha(d) * glowOpacityOf(this.style))],
14541
15453
  ...(alignOf.active ? { getPixelOffset: alignOf } : {}),
14542
15454
  sizeUnits: 'pixels',
14543
15455
  billboard: true,
@@ -14641,7 +15553,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14641
15553
  if (!flatLook) return this._buildSingleIcon(rgb, fillOpacity, shapeOf(d.properties), singleBorderColorRgb, singleBorderWidthPx);
14642
15554
  const look = flatLook(d);
14643
15555
  return this._buildSingleIcon(rgb, look.fillOpacity, shapeOf(d.properties),
14644
- look.noStroke || !rgb ? null : (look.stroke || flatDerivateRgb(rgb, 0.7)), look.width, look.strokeOpacity);
15556
+ bubbleStrokeRgb(look, rgb), look.width, look.strokeOpacity);
14645
15557
  };
14646
15558
  // flat's symbol outline (maptheme.js 21595-21610 and 21950-21997,
14647
15559
  // drawn 21625-21634 / 21995-22035): every SYMBOL shape is stroked,
@@ -14676,7 +15588,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14676
15588
  // routing goes by the final id (layerRuntimeById). A symbol's outline
14677
15589
  // is centered on its radius (flat); BUBBLE's border keeps the
14678
15590
  // icon's look, inside the radius
14679
- if (combined.length && !alignOf.active && combined.every(d => singleSymbol(d) && shapeOf(d.properties) === 'circle')) {
15591
+ if (combined.length && !alignOf.active && combined.every(d => singleSymbol(d) && shapeOf(d.properties) === 'circle' && !this._resolveSymbolUrl(d.properties))) {
14680
15592
  const radiusPx = d => vRadius(sizeValueOf(d));
14681
15593
  // a CATEGORICAL BUBBLE: flat's look (flatBubbleLook) in screen px,
14682
15594
  // its stroke centered on the radius as flat's SVG circle
@@ -14705,6 +15617,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14705
15617
  getPosition: d => d.geometry.coordinates,
14706
15618
  getIcon: d => {
14707
15619
  if (!singleSymbol(d)) return this._buildBubbleIcon(d.properties.counts, this.categoryColorsRgb, this._groupIsolation(d), Array.isArray(this.style.symbols) ? this.style.symbols : null, flatLook && flatLook(d));
15620
+ const symbolUrl = this._resolveSymbolUrl(d.properties);
15621
+ if (symbolUrl) return this._urlSymbolIcon(symbolUrl);
14708
15622
  if (flatLook) return singleIcon(d);
14709
15623
  const shape = shapeOf(d.properties), rgb = outlineRgb(d);
14710
15624
  if (!symbolOutline || !rgb) return this._buildSingleIcon((this.categoryColorsRgb || [])[d.properties.cat], fillOpacity, shape, rgb, rgb ? singleBorderWidthPx : 0);
@@ -14947,7 +15861,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14947
15861
  if (multi && auto100 && !this._seqAuto100Max) {
14948
15862
  let m = 0;
14949
15863
  for (const f of this.features) {
14950
- 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);
14951
15865
  const sum = parts.reduce((a, v) => a + v, 0);
14952
15866
  if (sum > 0) for (const v of parts) { const nv = v / (sum / 100); if (nv > m) m = nv; }
14953
15867
  }
@@ -14968,12 +15882,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14968
15882
  const sizeField = multi && this.binding.size && this.binding.size !== '$item$' ? this.binding.size : null;
14969
15883
  let sizeMax = 0;
14970
15884
  if (sizeField) {
14971
- 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; }
14972
15886
  }
14973
15887
  const itemSizeFactor = d => {
14974
15888
  if (!sizeField) return 1;
14975
15889
  const sz = typeof d.properties.size === 'number' ? d.properties.size
14976
- : Math.abs(parseFloat((d.properties.raw || {})[sizeField]) || 0);
15890
+ : Math.abs(scanValue((d.properties.raw || {})[sizeField]) || 0);
14977
15891
  return sizeMax > 0 ? Math.pow(sz / sizeMax, 1 / sizePow) : 0;
14978
15892
  };
14979
15893
  const rings = flatFlag(this.flags, 'RINGS');
@@ -14994,60 +15908,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14994
15908
  // it itself (its ImageBitmap loader falls back to <img>, which
14995
15909
  // rasterizes SVG) — NOT tinted: these files carry their own colors,
14996
15910
  // and flat doesn't recolor an <image> either
14997
- const urlSymbolOf = i => {
14998
- const s = symbols && (symbols[i] || symbols[0]);
14999
- return typeof s === 'string' && /\.(svg|png|jpe?g)([?#]|$)/i.test(s.trim()) ? s.trim() : null;
15000
- };
15001
- if (!this._urlIconCache) this._urlIconCache = new Map();
15002
- const urlIcon = u => {
15003
- if (!this._urlIconCache.has(u)) {
15004
- // the placeholder is a transparent 1x1 PNG, NOT the raw symbol
15005
- // URL: deck.gl would immediately try to load that itself, and an
15006
- // SVG without natural dimensions (a viewBox only, the bus
15007
- // pictogram of the City-moving page) makes Chrome's
15008
- // createImageBitmap throw — a console error and a part that
15009
- // never renders. The real raster replaces it below
15010
- const entry = { url: TRANSPARENT_1PX_PNG, width: 256, height: 256, anchorX: 128, anchorY: 128, mask: false, id: 'seq-sym-pending:' + u };
15011
- this._urlIconCache.set(u, entry);
15012
- // fetch the symbol ONCE and rasterize it into the descriptor's
15013
- // own 256px cell here: deck then always sees exactly the declared
15014
- // dimensions, and dimension-less SVGs work like any other. The
15015
- // redraw lets the next getIcon see the patched descriptor
15016
- fetch(u).then(r => r.ok ? r.text() : null).then(text => new Promise((resolve, reject) => {
15017
- if (typeof text !== 'string' || !/<svg[\s>]/i.test(text)) { reject(new Error('not svg')); return; }
15018
- if (!/\swidth\s*=/i.test(text) || !/\sheight\s*=/i.test(text)) {
15019
- const vb = text.match(/viewBox\s*=\s*["']([^"']*)["']/i);
15020
- const p = vb && vb[1].trim().split(/[\s,]+/).map(Number);
15021
- if (p && p.length === 4 && isFinite(p[2]) && p[2] > 0 && isFinite(p[3]) && p[3] > 0) {
15022
- text = text.replace(/<svg/i, '<svg width="' + p[2] + '" height="' + p[3] + '"');
15023
- }
15024
- }
15025
- const img = new Image();
15026
- img.onload = () => resolve(img);
15027
- img.onerror = () => reject(new Error('svg decode failed'));
15028
- img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(text);
15029
- })).then(img => {
15030
- const size = entry.width;
15031
- const canvas = document.createElement('canvas');
15032
- canvas.width = size; canvas.height = size;
15033
- const ctx = canvas.getContext('2d');
15034
- const s = Math.min(size / (img.naturalWidth || size), size / (img.naturalHeight || size));
15035
- const dw = (img.naturalWidth || size) * s, dh = (img.naturalHeight || size) * s;
15036
- ctx.drawImage(img, (size - dw) / 2, (size - dh) / 2, dw, dh);
15037
- entry.url = canvas.toDataURL('image/png');
15038
- entry.id = 'seq-sym-raster:' + u;
15039
- if (this._triggerRedraw) this._triggerRedraw();
15040
- }).catch(() => {
15041
- // not an SVG (a PNG/JPEG symbol): deck's own URL loading takes
15042
- // it from here — and a pre-sized SVG that failed to rasterize
15043
- // gets that same second chance
15044
- entry.url = u;
15045
- entry.id = 'seq-sym-natural:' + u;
15046
- if (this._triggerRedraw) this._triggerRedraw();
15047
- });
15048
- }
15049
- return this._urlIconCache.get(u);
15050
- };
15911
+ const urlSymbolOf = i => this._urlSymbolOf(symbols && (symbols[i] || symbols[0]));
15912
+ const urlIcon = u => this._urlSymbolIcon(u);
15051
15913
  const shapeOf = i => { const sh = symbols && (symbols[i] || symbols[0]); return normalizeSymbolShape(sh); };
15052
15914
  const colorOf = i => this.classRgb(i);
15053
15915
  const allParts = [], allGlows = [];
@@ -15077,7 +15939,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15077
15939
  const sizeFactor = itemSizeFactor(d);
15078
15940
  const partRadius = multi
15079
15941
  ? v => Math.max(VALUE_RADIUS_MIN, normalR * Math.pow(Math.abs(v) / seqMax, 1 / sizePow) * sizeFactor)
15080
- : v => valueRadius(v, liveZoom, st, this.mapOptions, this.flags, seqMax);
15942
+ : v => valueRadius(v, liveZoom, st, this.mapOptions, this.flags, seqMax, this._sizeBound());
15081
15943
  let layout = rings ? ringsLayout(counts, partRadius)
15082
15944
  : sequenceLayout(counts, this.flags, partRadius, { maxRadius: normalR, maxValue: seqMax, nParts: nCats, rangeScale: styleNum(st.rangescale) || 0 });
15083
15945
  if (!layout.length) continue;
@@ -15208,7 +16070,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15208
16070
  getIcon: d => this._getGlowIcon(colorOf(d.part.i)),
15209
16071
  getSize: d => d.part.r * 11,
15210
16072
  getPixelOffset: offsetOf,
15211
- getColor: d => [255, 255, 255, alphaOf(d)],
16073
+ getColor: d => [255, 255, 255, Math.round(alphaOf(d) * glowOpacityOf(st))],
15212
16074
  sizeUnits: 'pixels', billboard: true,
15213
16075
  parameters: ICON_LAYER_GLOBE_PARAMETERS
15214
16076
  }));
@@ -15563,7 +16425,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15563
16425
  if (!layout) continue;
15564
16426
  const sum = values.reduce((a, v) => a + v, 0);
15565
16427
  if (star) for (const v of values) if (v > starMaxPart) starMaxPart = v;
15566
- const size = this.binding.size ? parseFloat(raw[this.binding.size]) : undefined;
16428
+ const size = this.binding.size ? scanValue(raw[this.binding.size]) : undefined;
15567
16429
  const r = nSizeOf(sum, size) * unit;
15568
16430
  if (!(r > 0) || !isFinite(r)) continue;
15569
16431
  // flat's draw-order key (maptheme.js 16812-16860): the size value,
@@ -16011,7 +16873,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16011
16873
  return { values: parts, size };
16012
16874
  }
16013
16875
  const raw = f.properties || {};
16014
- 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 };
16015
16877
  };
16016
16878
  const all = agg ? this._pieAggregatedItems(zoom, [-180, -90, 180, 90]) : (this._activeFeatures || this.features);
16017
16879
  // flat's per-part mean, median (getMeanMedianQuantile) and standard
@@ -16631,7 +17493,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16631
17493
  // flat's chart position for this build (flatChartAlignOffset): a
16632
17494
  // function item → pixel offset, the same for every part of one chart
16633
17495
  // (symbol, glow, shadow, box, title, value text). unit is flat's
16634
- // normalX(1) on screen, the object zoom × style.scale.
17496
+ // normalX(1) on screen, the object zoom × style.scale. flat keeps a margin of 5 units between the
17497
+ // position and the chart's facing edge for left/right/top/bottom; the gl-only style.alignmargin
17498
+ // changes it (0: the edge touches the position, so a left and a right chart touch each other)
16635
17499
  _chartAlignFn(liveZoom, radiusOf, textOnly = false) {
16636
17500
  const st = this.style;
16637
17501
  const none = () => [0, 0];
@@ -16640,12 +17504,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16640
17504
  const unit = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(st.scale) || 1);
16641
17505
  const symbol = flatChartBranch(this.flags) === 'symbol';
16642
17506
  const normalR = NORMAL_RADIUS_PX * unit; // flat's normalX(chart size / 2)
17507
+ const margin = Number.isFinite(styleNum(st.alignmargin)) ? styleNum(st.alignmargin) : 5;
16643
17508
  const cache = new Map();
16644
17509
  const fn = d => {
16645
17510
  let v = cache.get(d);
16646
17511
  if (v) return v;
16647
17512
  const r = radiusOf(d);
16648
- v = flatChartAlignOffset(st, { unit, symbolScale: symbol ? r / (2 * normalR) : 1, half: symbol ? normalR : r, orig: r + (textOnly ? 0 : 5 * unit) });
17513
+ v = flatChartAlignOffset(st, { unit, symbolScale: symbol ? r / (2 * normalR) : 1, half: symbol ? normalR : r, orig: r + (textOnly ? 0 : margin * unit) });
16649
17514
  cache.set(d, v);
16650
17515
  return v;
16651
17516
  };
@@ -16733,7 +17598,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16733
17598
  let lo = Infinity, hi = -Infinity;
16734
17599
  const field = this.binding.value;
16735
17600
  for (const f of this.features || []) {
16736
- const v = field === '$item$' ? 1 : parseFloat(f.properties && f.properties[field]);
17601
+ const v = field === '$item$' ? 1 : scanValue(f.properties && f.properties[field]);
16737
17602
  if (isNaN(v)) continue;
16738
17603
  if (v < lo) lo = v;
16739
17604
  if (v > hi) hi = v;
@@ -16824,7 +17689,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16824
17689
  const textOnly = this.flags.has('TEXTONLY');
16825
17690
  const showValues = this.flags.has('VALUES') && !valuesHiddenByScale(this.style, zoom);
16826
17691
  if (textOnly && !showValues) return [];
16827
- const radiusOf = d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
17692
+ const radiusOf = d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue, this._sizeBound());
16828
17693
  const alignOf = this._chartAlignFn(liveZoom, radiusOf, textOnly);
16829
17694
  const valueScale = styleNum(this.style.valuescale) || 1;
16830
17695
  const textOverride = this.style.valuecolor || this.style.textcolor;
@@ -17385,6 +18250,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
17385
18250
  // several themes of a page
17386
18251
  szId: themeIdOf(rt),
17387
18252
  szName: rt.name,
18253
+ // flat's szSelectionField: the field a click on an item selects by (the id binding, else the field the
18254
+ // item is positioned by)
18255
+ szSelectionField: selectionFieldOf(rt),
17388
18256
  // the filter in force: a runtime one (changeThemeStyle "filter")
17389
18257
  // replaces the definition's
17390
18258
  szFilter: rt._runtimeFilterExpr || rt._filterExpr || '',
@@ -17421,6 +18289,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
17421
18289
  get itemA() { return onMap().itemA; }
17422
18290
  };
17423
18291
  }
18292
+ // the field whose value identifies an item on a click (flat's szSelectionField): binding.id, else the position field
18293
+ // (which a join to a geometry layer turns into binding.lookup)
18294
+ function selectionFieldOf(rt) {
18295
+ const b = rt.binding || {};
18296
+ const f = b.id || b.position || b.lookup;
18297
+ return typeof f === 'string' && f && f !== 'geometry' && !/^geometry$/i.test(f) ? f : null;
18298
+ }
17424
18299
  const _warnedOnce = new Set();
17425
18300
  function warnOnce(key, msg) { if (!_warnedOnce.has(key)) { _warnedOnce.add(key); console.info(msg); } }
17426
18301
  // the theme's records as flat's dbFields/dbRecords (cached per data)
@@ -17852,6 +18727,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
17852
18727
  function compatRemoveTheme(...a) {
17853
18728
  return _mapHandle.remove(a.length >= 2 ? a[1] : a[0]);
17854
18729
  }
18730
+ // flat's ixmaps.toggleTheme(szMap, szTheme) (htmlgui.js): show / hide a theme by its id, e.g. a page's on / off buttons
18731
+ function compatToggleTheme(...a) {
18732
+ new themeApi(null, a.length >= 2 ? a[1] : a[0]).toggle();
18733
+ }
17855
18734
  function compatSetBasemapOpacity(...a) {
17856
18735
  if (a.length >= 3) {
17857
18736
  const api = (a[0] && _mapApis[a[0]]) || _lastMapApi;
@@ -17887,6 +18766,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
17887
18766
  htmlgui_getAttributionString: () => (_lastMapApi && _lastMapApi.getAttribution ? _lastMapApi.getAttribution() : ''),
17888
18767
  changeThemeStyle: compatChangeThemeStyle,
17889
18768
  removeTheme: compatRemoveTheme,
18769
+ toggleTheme: compatToggleTheme,
17890
18770
  // flat's ixmaps.setLocalString(orig, local) (htmlgui.js 3814 →
17891
18771
  // Api.setLocalString → Dictionary.replace): a page-wide translation,
17892
18772
  // e.g. the migration page's ISO code → country name; usable before
@@ -17935,7 +18815,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
17935
18815
  flatToMapLibreZoom, mapLibreToFlatZoom, flatViewToMapLibreZoom, mapLibreToFlatViewZoom, flatSvgProjectionOf, flatSvgProjectionByOptions, normalSizeScaleOf, scaleDenominatorAt, resolveZoomReference, resolveAggregationPx, valuesHiddenByScale,
17936
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,
17937
18817
  applyWhereFilter, joinChartPositions, joinChoroplethFeatures, flatLookupKey, flatShapeCenter, chartHiddenByScale, glowHiddenByScale, isSymbolChart, flatChartAlignOffset, flatChartBranch, flatValueText, categoryValueRecord, flatDerivateRgb, flatBubbleLook, bubbleIconLook, flatChartTextRgb, flatGroupedValue, flatNoBreaks, featuresHiddenByScale, boxHiddenByScale, flatShadowOn, snapToAggregationGrid,
17938
- drawSymbolPath, normalizeSymbolShape,
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,
17939
18821
  flatFormatValue, themeStyleArgs, flatChangedStyleValue, explicitValueDecimals, legendDecimals,
17940
18822
  dataCacheKey, dataCacheDisabled, cachedLayerData, featuresBounds, wantsZoomToExtent, legendIsOn, applySphereBackdrop,
17941
18823
  };