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