ixmaps-gl 0.2.24 → 0.2.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +21 -8
  2. package/ixmaps-gl.js +1595 -748
  3. package/package.json +1 -1
package/ixmaps-gl.js CHANGED
@@ -100,7 +100,7 @@
100
100
  // ---------------------------------------------------------------
101
101
  // this engine's release (= package.json "version", checked by
102
102
  // test/unit/version.test.mjs); ixmaps.glVersion, and logged once at start
103
- const IXMAPS_GL_VERSION = '0.2.24';
103
+ const IXMAPS_GL_VERSION = '0.2.30';
104
104
 
105
105
  const LIB_URLS = {
106
106
  // Bumped 3.6.2 -> 5.x (2026-09-21, globe-projection compat fix): native
@@ -220,7 +220,7 @@
220
220
  let IconLayer, ScatterplotLayer, GeoJsonLayer, MapLibreOverlay, TextLayer, PathLayer, PathStyleExtension;
221
221
  // PieSliceLayer (see makePieSliceLayer) and BarShapeLayer
222
222
  // (makeBarShapeLayer) are derived from ScatterplotLayer once deck.gl is there
223
- let PieSliceLayer, BarShapeLayer;
223
+ let PieSliceLayer, BarShapeLayer, FlowPathLayer;
224
224
 
225
225
  // CHART|PIE / DONUT slices (_buildPieLayers): one instance per slice — a
226
226
  // ScatterplotLayer circle (billboarded, pixel radius, pixel offset, the
@@ -242,6 +242,111 @@
242
242
  // without anti-aliasing between them (a hard partition: no background
243
243
  // seam shows between two slices); the circle's rim keeps
244
244
  // ScatterplotLayer's own anti-aliasing.
245
+ // flat's VECTOR|DASH (maptheme.js 18240-18256, the bezier branch):
246
+ // the flow lines are dashed — one long dash, a small gap — and the
247
+ // dash pattern MOVES toward the target: flat animates each SVG line's
248
+ // stroke-dashoffset from d1+d2+random down to random (1-2s, infinite),
249
+ // the offset decreasing walking the pattern along the path direction.
250
+ // Here the same in the fragment shader — but deck.gl tessellates a
251
+ // path into per-SEGMENT quads whose vPathPosition.y restarts at 0 at
252
+ // every segment vertex (and is in half-stroke-width units, not
253
+ // pixels), so the pattern is NOT continuous from the segment
254
+ // coordinate alone. deck's own PathStyleExtension with dashMode
255
+ // 'path' (HIGH_PRECISION_DASH) supplies the missing cumulative term:
256
+ // its calculateDashMetrics fills per-segment [distance so far, path
257
+ // length] and its vertex shader turns it into vDashOffset, making
258
+ // alongPath = vPathPosition.y + vDashOffset
259
+ // continuous across segments (deck's own dash shader uses exactly
260
+ // this; the pattern is periodic, so the mod() in vDashOffset is
261
+ // seamless). The extension's own gap stays 0 (getDashArray's second
262
+ // component — it renders the line solid); THIS layer's
263
+ // DECKGL_FILTER_COLOR discards the moving gap: the solid length and
264
+ // flat's random per-arc phase as an instanced attribute, a time
265
+ // uniform advanced by the wall clock (one full dash cycle every 2s),
266
+ // and fragments outside the moving dash window discarded — the layers
267
+ // themselves are NOT rebuilt per frame. The discard skips the picking
268
+ // pass (picking.isActive): flat's dashed SVG line is hover-tested
269
+ // across its gaps too, so the arcs keep their tooltips while the
270
+ // dash runs
271
+ function makeFlowPathLayer(Base) {
272
+ // deck.gl 9: a layer's custom uniforms live in shader MODULES — an
273
+ // std140 block named <module>Uniforms, its layout declared by the
274
+ // module's own GLSL and uniformTypes, values pushed per draw via
275
+ // setShaderModuleProps (the picking module's picking.isActive works
276
+ // exactly the same way). A plain GLSL uniform would have no backing
277
+ // buffer, and deck 8's state.model.setUniforms() no longer exists.
278
+ const FLOW_MODULE = {
279
+ name: 'flow',
280
+ uniformTypes: { uFlowTime: 'f32' },
281
+ defaultUniforms: { uFlowTime: 0 },
282
+ vs: 'layout(std140) uniform flowUniforms { float uFlowTime; } flow;',
283
+ fs: 'layout(std140) uniform flowUniforms { float uFlowTime; } flow;'
284
+ };
285
+ class Layer extends Base {
286
+ getShaders() {
287
+ const shaders = super.getShaders();
288
+ shaders.modules = (shaders.modules || []).concat(FLOW_MODULE);
289
+ const inject = Object.assign({}, shaders.inject || {});
290
+ inject['vs:#decl'] = (inject['vs:#decl'] || '') + `
291
+ in vec2 instanceFlowPhase;
292
+ out vec2 vFlowPhase;
293
+ `;
294
+ inject['vs:#main-end'] = (inject['vs:#main-end'] || '') + `
295
+ vFlowPhase = instanceFlowPhase;
296
+ `;
297
+ inject['fs:#decl'] = (inject['fs:#decl'] || '') + `
298
+ in vec2 vFlowPhase;
299
+ `;
300
+ // before the picking module's own filter; the dash is skipped
301
+ // there (see the class comment). vDashArray/vDashOffset come
302
+ // from the PathStyleExtension (see the layer's extensions),
303
+ // the position along the path is geometry.uv (the path
304
+ // layer's main sets it to vPathPosition before the filter
305
+ // runs; the varying itself is NOT redeclared here — the base
306
+ // shader already declares it, and the injected block lands
307
+ // before the base source, so a redeclaration is a
308
+ // redefinition error). uFlowTime comes from the flow module's
309
+ // block (see FLOW_MODULE)
310
+ inject['fs:DECKGL_FILTER_COLOR'] = (inject['fs:DECKGL_FILTER_COLOR'] || '') + `
311
+ {
312
+ float period = vDashArray.x;
313
+ float pickingActive = 0.0;
314
+ #ifdef MODULE_PICKING
315
+ pickingActive = picking.isActive;
316
+ #endif
317
+ if (period > 0.0 && pickingActive < 0.5) {
318
+ float alongPath = geometry.uv.y + vDashOffset;
319
+ float pos = fract((alongPath - flow.uFlowTime * period + vFlowPhase.y) / period);
320
+ if (pos * period > vFlowPhase.x) discard;
321
+ }
322
+ }
323
+ `;
324
+ shaders.inject = inject;
325
+ return shaders;
326
+ }
327
+ initializeState() {
328
+ super.initializeState();
329
+ this.getAttributeManager().addInstanced({
330
+ instanceFlowPhase: { size: 2, accessor: 'getFlowPhase', defaultValue: [0, 0] }
331
+ });
332
+ }
333
+ draw(opts) {
334
+ // one full dash cycle every 2s — flat's animate dur (the bezier
335
+ // branch); the offset decreases, so the pattern walks toward
336
+ // the target (alongPath grows along the path). setProps on
337
+ // the shader inputs; the model's predraw flushes the uniform
338
+ // buffer when super.draw renders
339
+ this.setShaderModuleProps({ flow: { uFlowTime: (Date.now() % 2000) / 2000 } });
340
+ super.draw(opts);
341
+ }
342
+ }
343
+ Layer.layerName = 'FlowPathLayer';
344
+ Layer.defaultProps = Object.assign({}, Base.defaultProps, {
345
+ getFlowPhase: { type: 'accessor', value: [0, 0] }
346
+ });
347
+ return Layer;
348
+ }
349
+
245
350
  function makePieSliceLayer(Base) {
246
351
  class Layer extends Base {
247
352
  getShaders() {
@@ -293,12 +398,15 @@ in vec4 vPieLineColor;
293
398
  }
294
399
  // in device pixels (fwidth: CSS px per device pixel), so a hairline
295
400
  // stays one at any device pixel ratio. Half the outline lies inside
296
- // each slice (the other half in its neighbour); below a pixel it fades
297
- // in by its width
401
+ // each slice (the other half in its neighbour). No fade-in by the
402
+ // width: the browser rasterizes flat's sub-pixel SVG strokes as
403
+ // hairlines — one device pixel at FULL opacity (Skia's stroke floor,
404
+ // measured on the ISTAT story: 0.17 CSS px strokes render gray 32) —
405
+ // so the line keeps full alpha and only its extent grows past 1px
298
406
  float pieAA = max(fwidth(pieR), 1e-4);
299
407
  float pieH = vPieLineWidth * 0.5 / pieAA;
300
408
  float pieDD = pieD / pieAA;
301
- float pieLine = pieH > 0.0 ? clamp(min(pieH + 0.5 - pieDD, 2.0 * pieH), 0.0, 1.0) : 0.0;
409
+ float pieLine = pieH > 0.0 ? clamp(pieH + 0.5 - pieDD, 0.0, 1.0) : 0.0;
302
410
  float pieRim = clamp((vPieRadius - pieR) / pieAA + 0.5, 0.0, 1.0);
303
411
  color = vec4(mix(color.rgb, vPieLineColor.rgb, pieLine), mix(color.a, vPieLineColor.a * pieRim, pieLine));
304
412
  if (pieInner > 0.0) color.a *= clamp((pieR - pieInner) / pieAA + 0.5, 0.0, 1.0);
@@ -503,6 +611,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
503
611
  ({ IconLayer, ScatterplotLayer, GeoJsonLayer, MapLibreOverlay, TextLayer, PathLayer, PathStyleExtension } = global.deck);
504
612
  PieSliceLayer = makePieSliceLayer(ScatterplotLayer);
505
613
  BarShapeLayer = makeBarShapeLayer(ScatterplotLayer);
614
+ FlowPathLayer = makeFlowPathLayer(PathLayer);
506
615
  // which engine and libraries this page really runs (a page may
507
616
  // bring its own MapLibre/deck.gl — the loaded ones are named)
508
617
  const ml = global.maplibregl && typeof global.maplibregl.getVersion === 'function' ? global.maplibregl.getVersion() : '?';
@@ -662,10 +771,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
662
771
  return ring;
663
772
  }
664
773
 
774
+ // a quantized topology's point positions are absolute (not delta-encoded
775
+ // like arcs) quantized integers: x * scale + translate
776
+ function decodePoint(p, transform) {
777
+ if (!transform) return p;
778
+ return [p[0] * transform.scale[0] + transform.translate[0], p[1] * transform.scale[1] + transform.translate[1]];
779
+ }
780
+
665
781
  function topoGeometryToGeoJSON(geom, arcs, transform) {
666
782
  switch (geom.type) {
667
- case 'Point': return { type: 'Point', coordinates: geom.coordinates };
668
- case 'MultiPoint': return { type: 'MultiPoint', coordinates: geom.coordinates };
783
+ case 'Point': return { type: 'Point', coordinates: decodePoint(geom.coordinates, transform) };
784
+ case 'MultiPoint': return { type: 'MultiPoint', coordinates: geom.coordinates.map(p => decodePoint(p, transform)) };
669
785
  case 'LineString': return { type: 'LineString', coordinates: ringCoords(geom.arcs, arcs, transform) };
670
786
  case 'MultiLineString': return { type: 'MultiLineString', coordinates: geom.arcs.map(a => ringCoords(a, arcs, transform)) };
671
787
  case 'Polygon': return { type: 'Polygon', coordinates: geom.arcs.map(ring => ringCoords(ring, arcs, transform)) };
@@ -941,11 +1057,40 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
941
1057
  // ixmaps-flat dev tree, Themes.prototype.setExternalData). This engine
942
1058
  // has no such pipeline to resume, so the bridge only needs to do the
943
1059
  // ONE thing fetchLayerData actually needs: resolve the promise a
944
- // query()-mode fetch is awaiting. build()'s layer-loading loop is
945
- // strictly sequential (one .data({query}) in flight at a time, see its
946
- // dataCache), so a single pending-resolver slot is enough — not a
947
- // registry keyed by data name like the real engine's.
948
- let _pendingQueryResolve = null;
1060
+ // query()-mode fetch is awaiting. Queries run in parallel (several
1061
+ // layers, a later map.layer()), so every pending one has its own entry,
1062
+ // keyed by the options object its query function received — real pages
1063
+ // hand that same object back: query(themeObj, options) …
1064
+ // ixmaps.setExternalData(data, options). In data order: oldest first.
1065
+ const _pendingQueries = new Map(); // options object → { name, resolve, reject, timer }
1066
+ // a pending query's timeout (0: DATAJS_LOAD_TIMEOUT_MS) — a field so the
1067
+ // unit tests can shorten it
1068
+ const QUERY_HANDOFF = { timeoutMs: 0 };
1069
+ function settleQuery(opts, err, data) {
1070
+ const q = _pendingQueries.get(opts);
1071
+ if (!q) return;
1072
+ _pendingQueries.delete(opts);
1073
+ clearTimeout(q.timer);
1074
+ if (err) q.reject(err); else q.resolve(data);
1075
+ }
1076
+ // the pending query a setExternalData(data, opt) belongs to: the one
1077
+ // given that very options object, else the one of that data name, else —
1078
+ // no name — the oldest (exact while only one is pending)
1079
+ function pendingQueryOf(opt) {
1080
+ if (opt && _pendingQueries.has(opt)) return opt;
1081
+ const name = opt && opt.name;
1082
+ if (name != null && name !== '') {
1083
+ for (const [o, q] of _pendingQueries) if (q.name === String(name)) return o;
1084
+ return null;
1085
+ }
1086
+ const first = _pendingQueries.keys().next();
1087
+ if (first.done) return null;
1088
+ if (_pendingQueries.size > 1) {
1089
+ console.warn('[ixmaps-gl] ixmaps.setExternalData without its query\'s options object or a name, while ' + _pendingQueries.size +
1090
+ ' .data({query}) fetches are pending — handed to the oldest; pass the options the query function received');
1091
+ }
1092
+ return first.value;
1093
+ }
949
1094
  // Data by name, as flat (maptheme.js 2570-2593, 8286-8310):
950
1095
  // setExternalData(data, {type, name}) makes a data.js Table of the data
951
1096
  // and keeps it as window[name]; whoever waits for that name gets it, and
@@ -991,6 +1136,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
991
1136
  // globals a data name must not overwrite
992
1137
  const _engineGlobalsGuard = { ixmaps: 1, Data: 1, deck: 1, maplibregl: 1, window: 1, document: 1, $: 1, jQuery: 1 };
993
1138
  function setExternalDataBridge(dataObj, opt) {
1139
+ const query = pendingQueryOf(opt);
1140
+ if (query) {
1141
+ if (dataObj == null) settleQuery(query, new Error('[ixmaps-gl] .data({query}): the query handed over no data (ixmaps.setExternalData(null, …))'));
1142
+ else settleQuery(query, null, dataObj);
1143
+ return;
1144
+ }
994
1145
  const name = opt && opt.name;
995
1146
  if (name != null && name !== '') {
996
1147
  (dataObj == null ? Promise.resolve(null) : externalDataToTable(dataObj, opt))
@@ -998,13 +1149,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
998
1149
  .catch(err => { console.error(err); publishNamedData(String(name), null); });
999
1150
  return;
1000
1151
  }
1001
- if (!_pendingQueryResolve) {
1002
- console.warn('[ixmaps-gl] ixmaps.setExternalData called with no pending .data({query}) fetch — ignored');
1003
- return;
1004
- }
1005
- const resolve = _pendingQueryResolve;
1006
- _pendingQueryResolve = null;
1007
- resolve(dataObj);
1152
+ console.warn('[ixmaps-gl] ixmaps.setExternalData called with no pending .data({query}) fetch — ignored');
1008
1153
  }
1009
1154
 
1010
1155
  // ---------------------------------------------------------------
@@ -1056,7 +1201,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1056
1201
  if (!table || !Array.isArray(table.records) || !Array.isArray(table.fields)) {
1057
1202
  return table && typeof table.json === 'function' ? table.json() : [];
1058
1203
  }
1059
- const names = table.fields.map(f => f.id);
1204
+ // a header cell that spans lines ("Sezione (*)\nSektion", bilingual
1205
+ // exports) reads as one line, as in flat: "Sezione (*) Sektion"
1206
+ const names = table.fields.map(f => String(f.id).replace(/[\r\n]+/g, ' '));
1060
1207
  const rows = new Array(table.records.length);
1061
1208
  for (let i = 0; i < table.records.length; i++) {
1062
1209
  const rec = table.records[i], o = {};
@@ -1065,6 +1212,26 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1065
1212
  }
1066
1213
  return rows;
1067
1214
  }
1215
+ // .data({process}) on topojson/geojson, as flat: its data.js hands the
1216
+ // process function the features' properties as a Table (column(...).map,
1217
+ // addColumn, ...); the result goes back onto the same features, by row
1218
+ function processFeatureCollection(fc, dataConfig) {
1219
+ if (!dataConfig.process || !fc || !Array.isArray(fc.features) || !fc.features.length) return fc;
1220
+ requireDataJs('process');
1221
+ const headers = [];
1222
+ const seen = new Set();
1223
+ for (const f of fc.features) {
1224
+ for (const k of Object.keys(f.properties || {})) if (!seen.has(k)) { seen.add(k); headers.push(k); }
1225
+ }
1226
+ const table = new global.Data.Table().setArray([headers, ...fc.features.map(f => headers.map(h => (f.properties || {})[h]))]);
1227
+ const rows = dataTableRows(runDataProcess(table, dataConfig));
1228
+ if (rows.length !== fc.features.length) {
1229
+ console.warn(`[ixmaps-gl] .data({process}) changed the row count of ${dataConfig.type} features (${fc.features.length} → ${rows.length}); process result ignored`);
1230
+ return fc;
1231
+ }
1232
+ fc.features.forEach((f, i) => { f.properties = rows[i]; });
1233
+ return fc;
1234
+ }
1068
1235
  // parse CSV text with data.js (as flat) → a Table
1069
1236
  function dataJsCsvTable(text) {
1070
1237
  return new Promise((resolve, reject) => {
@@ -1386,8 +1553,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1386
1553
  // genuinely CSV-shaped .obj with a missing binding.position would
1387
1554
  // throw, even though this .obj was never row data in the first place.
1388
1555
  if (dataConfig.obj) {
1389
- if (dataConfig.type === 'topojson') return topojsonToFeatureCollection(dataConfig.obj, dataConfig.object);
1390
- if (dataConfig.type === 'geojson') return sanitizeGeoJSON(dataConfig.obj);
1556
+ if (dataConfig.type === 'topojson') return processFeatureCollection(topojsonToFeatureCollection(dataConfig.obj, dataConfig.object), dataConfig);
1557
+ if (dataConfig.type === 'geojson') return processFeatureCollection(sanitizeGeoJSON(dataConfig.obj), dataConfig);
1391
1558
  let obj = dataConfig.obj;
1392
1559
  if (dataConfig.process) {
1393
1560
  requireDataJs('process');
@@ -1402,7 +1569,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1402
1569
  }
1403
1570
  obj = runDataProcess(obj, dataConfig);
1404
1571
  }
1405
- const rows = typeof obj.json === 'function' ? obj.json() : obj;
1572
+ // a raw jsonDB object ({fields, records}) → rows, with or without
1573
+ // process (and without needing data.js)
1574
+ const isJsonDb = !Array.isArray(obj) && Array.isArray(obj.fields) && Array.isArray(obj.records);
1575
+ const rows = typeof obj.json === 'function' ? obj.json() : isJsonDb ? dataTableRows(obj) : obj;
1406
1576
  return rowsResult(rows, binding);
1407
1577
  }
1408
1578
 
@@ -1412,9 +1582,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1412
1582
  '<script src="https://cdn.jsdelivr.net/gh/gjrichter/data.js@master/data.js"> before ixmaps-gl.js');
1413
1583
  }
1414
1584
  const queryFn = typeof dataConfig.query === 'function' ? dataConfig.query : new Function(`return (${dataConfig.query});`)();
1415
- const dataObj = await new Promise(resolve => {
1416
- _pendingQueryResolve = resolve;
1417
- queryFn({}, {});
1585
+ // its own options object: the query hands it back with the data
1586
+ const opts = { type: dataConfig.type, name: dataConfig.name };
1587
+ const timeoutMs = QUERY_HANDOFF.timeoutMs || DATAJS_LOAD_TIMEOUT_MS;
1588
+ const dataObj = await new Promise((resolve, reject) => {
1589
+ const timer = setTimeout(() => settleQuery(opts, new Error(`[ixmaps-gl] .data({query}): no data handed over (ixmaps.setExternalData) within ${timeoutMs / 1000}s`)), timeoutMs);
1590
+ _pendingQueries.set(opts, { name: dataConfig.name != null ? String(dataConfig.name) : null, resolve, reject, timer });
1591
+ try { queryFn({}, opts); } catch (err) { settleQuery(opts, err); }
1418
1592
  });
1419
1593
  return rowsResult((dataConfig.process ? runDataProcess(dataObj, dataConfig) : dataObj).json(), binding);
1420
1594
  }
@@ -1436,8 +1610,21 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1436
1610
  // parser (PapaParse), so the Table arrives via the callback
1437
1611
  const tables = await Promise.all(texts.map(dataJsCsvTable));
1438
1612
  let table = tables[0];
1439
- for (const t of tables.slice(1)) table.records = table.records.concat(t.records);
1440
- if (table.table) table.table.records = table.records.length;
1613
+ // records are positional: align each later file's columns to the
1614
+ // first file's by name (a column only a later file has is added),
1615
+ // as the no-data.js path below gets from each file's own header
1616
+ for (const t of tables.slice(1)) {
1617
+ const ids = table.fields.map(f => f.id);
1618
+ const tIds = t.fields.map(f => f.id);
1619
+ if (tIds.length === ids.length && tIds.every((id, c) => id === ids[c])) {
1620
+ table.records = table.records.concat(t.records);
1621
+ continue;
1622
+ }
1623
+ t.fields.forEach(f => { if (!ids.includes(f.id)) { ids.push(f.id); table.fields.push(f); } });
1624
+ const src = ids.map(id => tIds.indexOf(id));
1625
+ table.records = table.records.concat(t.records.map(rec => src.map(c => (c < 0 ? undefined : rec[c]))));
1626
+ }
1627
+ if (table.table) { table.table.records = table.records.length; table.table.fields = table.fields.length; }
1441
1628
  if (scriptProcess) table = scriptProcess(table);
1442
1629
  else if (dataConfig.process) table = runDataProcess(table, dataConfig);
1443
1630
  rows = dataTableRows(table);
@@ -1459,8 +1646,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1459
1646
  // them into a table with a geometry column, and back)
1460
1647
  const resp = await fetch(dataConfig.url);
1461
1648
  if (!resp.ok) throw new Error(`[ixmaps-gl] failed to fetch ${dataConfig.url}: ${resp.status}`);
1462
- if (dataConfig.type === 'topojson') return topojsonToFeatureCollection(await resp.json(), dataConfig.object);
1463
- return sanitizeGeoJSON(await resp.json());
1649
+ if (dataConfig.type === 'topojson') return processFeatureCollection(topojsonToFeatureCollection(await resp.json(), dataConfig.object), dataConfig);
1650
+ return processFeatureCollection(sanitizeGeoJSON(await resp.json()), dataConfig);
1464
1651
  }
1465
1652
  // every other format (json, jsonstat, ndjson, rss, kml, gml, geobuf,
1466
1653
  // flatgeobuf, geopackage, parquet, ...) through data.js, as flat
@@ -1858,21 +2045,25 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1858
2045
  if (isAggregatedCategoricalChoropleth(spec)) {
1859
2046
  const groups = new Map();
1860
2047
  for (const row of table.rows) {
1861
- const key = String(row[lookupField]);
2048
+ if (row[lookupField] == null) continue;
2049
+ const key = flatLookupKey(row[lookupField], spec.style);
1862
2050
  if (!groups.has(key)) groups.set(key, []);
1863
2051
  groups.get(key).push(row);
1864
2052
  }
1865
2053
  return {
1866
2054
  type: 'FeatureCollection',
1867
2055
  features: geomRt.features.map(f => {
1868
- const rows = groups.get(String(f.properties[idField]));
2056
+ const rows = f.properties[idField] == null ? null : groups.get(String(f.properties[idField]));
1869
2057
  const properties = rows ? Object.assign({}, rows[0]) : {};
1870
2058
  if (rows) Object.defineProperty(properties, AGGREGATED_ROWS, { value: rows });
1871
2059
  return { type: 'Feature', geometry: f.geometry, properties, _flatAreaKm2: flatShapeAreaKm2(geomRt, f) };
1872
2060
  })
1873
2061
  };
1874
2062
  }
1875
- const rowsByKey = new Map(table.rows.map(row => [String(row[lookupField]), row]));
2063
+ // the data side's key as flat makes it (lookupdigits/toupper/tonumber),
2064
+ // the geometry id as is — the same rule as joinChartPositions
2065
+ // a missing key on either side joins nothing (not "undefined" to "undefined")
2066
+ const rowsByKey = new Map(table.rows.filter(row => row[lookupField] != null).map(row => [flatLookupKey(row[lookupField], spec.style), row]));
1876
2067
  // Unmatched polygons (no CSV row for that id) keep their geometry with
1877
2068
  // empty properties — real-world data: not every comune necessarily has
1878
2069
  // a row in every demographic source. Renders as "no data" (see
@@ -1881,7 +2072,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1881
2072
  const features = geomRt.features.map(f => ({
1882
2073
  type: 'Feature',
1883
2074
  geometry: f.geometry,
1884
- properties: rowsByKey.get(String(f.properties[idField])) || {},
2075
+ properties: (f.properties[idField] != null && rowsByKey.get(String(f.properties[idField]))) || {},
1885
2076
  _flatAreaKm2: flatShapeAreaKm2(geomRt, f)
1886
2077
  }));
1887
2078
  return { type: 'FeatureCollection', features };
@@ -2294,13 +2485,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2294
2485
  // this list; the rest is still recognized-but-inert
2295
2486
  const KNOWN_INERT_FLAGS = ['CLIPTOGEOBOUNDS', 'OUTLIER',
2296
2487
  'POW2', 'POW3', 'NOSORT'];
2297
- // flat's PIE / DONUT variants with no rendering here (see _buildPieLayers)
2298
- const PIE_INERT_FLAGS = [/3D/, /VOLUME/, /HEIGHT/, /STARBURST/, /FLOWER/, /RAYS/, /BOW/, /ZOOM/, /DIRECTION/, /DIRECTED/, /POLAR/];
2488
+ // flat's PIE / DONUT variants with no rendering here (see _buildPieLayers).
2489
+ // STARBURST / FLOWER / RAYS (the starburst rays, with the XLRAYS & kin
2490
+ // angle/gap variants) and POLAR (its reference circles) ARE drawn — see
2491
+ // pieSliceLayout; the rest is still recognized-but-inert
2492
+ const PIE_INERT_FLAGS = [/3D/, /VOLUME/, /HEIGHT/, /BOW/, /ZOOM/, /DIRECTION/, /DIRECTED/];
2299
2493
  // ... and of AGGREGATE / CATEGORICAL pies: GRIDSIZE's pie the size of its
2300
- // grid cell (drawn by the SIZE rule instead), AUTOSIZE, DOPACITY, the
2301
- // chart BOX and TITLE, GAUGE, MULTIPLE / DIFFUSE, the post-aggregation
2302
- // value arithmetic (DIFFERENCE, FRACTION, PERCENT …, field100)
2303
- const PIE_AGGREGATE_INERT_FLAGS = [/^GRIDSIZE$/, /AUTOSIZE/, /DOPACITY/, /BOX/, /TITLE/, /^GAUGE$/, /^MULTIPLE$/, /^DIFFUSE$/,
2494
+ // grid cell (drawn by the SIZE rule instead), AUTOSIZE, DOPACITY,
2495
+ // GAUGE, MULTIPLE / DIFFUSE, the post-aggregation
2496
+ // value arithmetic (DIFFERENCE, FRACTION, PERCENT …, field100).
2497
+ // The chart BOX and TITLE ARE drawn (see _buildPieLayers)
2498
+ const PIE_AGGREGATE_INERT_FLAGS = [/^GRIDSIZE$/, /AUTOSIZE/, /DOPACITY/, /^GAUGE$/, /^MULTIPLE$/, /^DIFFUSE$/,
2304
2499
  /^DIFFERENCE$/, /^FRACTION$/, /^PERCENT$/, /^PERMILLE$/, /^PRODUCT$/, /^CALCVAL$/, /^RELATIVE$/];
2305
2500
  // flat's BAR / BARS variants with no rendering here (see flatBarLayout):
2306
2501
  // the bar is drawn plain (SMALLARROW as ARROW) — tested as substrings,
@@ -2310,11 +2505,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2310
2505
  /^DIFFERENCE$/, /^FRACTION$/, /^PERCENT$/, /^PERMILLE$/, /^PRODUCT$/, /^CALCVAL$/, /^RELATIVE$/];
2311
2506
  // flat's VECTOR / BEZIER modifiers with no rendering here (see
2312
2507
  // _buildVectorLayers), the word → what happens instead
2508
+ // (DASH IS drawn — the animated flow, see makeFlowPathLayer)
2313
2509
  const VECTOR_INERT_FLAGS = {
2314
2510
  VALUES: 'the value text along the curve is not drawn yet',
2315
2511
  DYNAMICWIDTH: 'not drawn yet (flat widens by the log of the length in its own map units) — the plain width instead',
2316
- LONG: 'not drawn yet (its bow depends on flat\'s own map units) — the default bow instead',
2317
- DASH: 'drawn as static dashes — flat\'s dash animation is not ported'
2512
+ LONG: 'not drawn yet (its bow depends on flat\'s own map units) — the default bow instead'
2318
2513
  };
2319
2514
  const _warnedFlags = new Set();
2320
2515
 
@@ -2359,6 +2554,18 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2359
2554
  // makes while the map is still building wait for it
2360
2555
  let _resolveMapReady;
2361
2556
  const _mapReady = new Promise(r => { _resolveMapReady = r; });
2557
+ // a map declared with an options name (Map(id, {name})) → the deferred of
2558
+ // ITS build: ixmaps.map("name") calls made while it is still building wait
2559
+ // for that map, not whichever map is built first
2560
+ const _namedMapReady = Object.create(null);
2561
+ function namedMapReady(name) {
2562
+ if (!_namedMapReady[name]) {
2563
+ let resolve;
2564
+ const promise = new Promise(r => { resolve = r; });
2565
+ _namedMapReady[name] = { promise, resolve };
2566
+ }
2567
+ return _namedMapReady[name];
2568
+ }
2362
2569
 
2363
2570
  // flat's ixmaps.require(url) fetches the script and evals it
2364
2571
  // (htmlgui.js 3911-3934); here each one runs globally, in order, before
@@ -2478,7 +2685,16 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2478
2685
  // key wins. Always into a new object — the page's own objects are never
2479
2686
  // mutated. Aliases (geo → position, ...) are resolved in normalizeTheme.
2480
2687
  binding(b) {
2481
- this._binding = Object.assign({}, this._binding, b);
2688
+ // flat's toString joins an array with '|' (maptheme.js 1014): a
2689
+ // binding given as an ARRAY — .binding({value: ["out","in"]}, the
2690
+ // pendolari page's saldo pie) is a MULTI-FIELD binding, not one
2691
+ // field named "out,in" (which would find no column and draw
2692
+ // nothing). Normalized once, here, for every consumer that
2693
+ // splits binding.value on '|'
2694
+ const norm = {};
2695
+ // (an empty or missing argument keeps what earlier calls set)
2696
+ for (const [k, v] of Object.entries(b || {})) norm[k] = Array.isArray(v) ? v.join('|') : v;
2697
+ this._binding = Object.assign({}, this._binding, norm);
2482
2698
  return this;
2483
2699
  }
2484
2700
  filter(expr) { this._filterExpr = expr; return this; }
@@ -2768,27 +2984,75 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2768
2984
  // that kind: the data processing function (data.process /
2769
2985
  // style.dbtableProcess), the broker query (data.query /
2770
2986
  // style.dbtableQuery — without it a data.name still reaches a
2771
- // provider the PAGE defined as ixmaps[name]) and a colorscheme given
2772
- // as a string, which resolveColorScheme evaluates as a function
2773
- // (an array colorscheme is data and stays).
2987
+ // provider the PAGE defined as ixmaps[name]) and a colorscheme
2988
+ // resolveColorScheme would evaluate: a string, or ["byvalue", "<fn>"]
2989
+ // (any other array colorscheme is data and stays). normalizeTheme
2990
+ // merges meta INTO style, so every style key is checked under meta too.
2991
+ const isColorSchemeCode = v => typeof v === 'string'
2992
+ || (Array.isArray(v) && v[0] === 'byvalue' && typeof v[1] === 'string');
2774
2993
  const PROJECT_CODE_KEYS = [
2775
2994
  ['data', 'process', 'data.process function'],
2776
2995
  ['style', 'dbtableProcess', 'data.process function'],
2777
2996
  ['data', 'query', 'data.query function'],
2778
2997
  ['style', 'dbtableQuery', 'data.query function'],
2779
- ['style', 'colorscheme', 'colorscheme function', v => typeof v === 'string'],
2780
- ];
2998
+ ['style', 'colorscheme', 'colorscheme function', isColorSchemeCode],
2999
+ ].flatMap(k => (k[0] === 'style' ? [k, ['meta', ...k.slice(1)]] : [k]));
3000
+ // A project's legend texts (title, snippet, description — in style or
3001
+ // meta, normalizeTheme fills meta from either) are shown as HTML, as on
3002
+ // flat: citation links in a description are real. A project's markup is
3003
+ // cleaned to formatting tags and plain attributes so nothing in it runs;
3004
+ // a page's own .meta()/.style() HTML stays as given.
3005
+ const PROJECT_HTML_KEYS = ['title', 'snippet', 'description'];
3006
+ const PROJECT_HTML_DROP = new Set(['SCRIPT', 'STYLE', 'IFRAME', 'FRAME', 'FRAMESET', 'OBJECT', 'EMBED', 'SVG', 'MATH',
3007
+ 'TEMPLATE', 'NOSCRIPT', 'LINK', 'META', 'BASE', 'FORM', 'INPUT', 'BUTTON', 'SELECT', 'TEXTAREA', 'AUDIO', 'VIDEO', 'SOURCE']);
3008
+ const PROJECT_HTML_TAGS = new Set(['A', 'B', 'I', 'EM', 'STRONG', 'U', 'S', 'SMALL', 'SUB', 'SUP', 'BR', 'P', 'DIV', 'SPAN',
3009
+ 'UL', 'OL', 'LI', 'HR', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'IMG', 'TABLE', 'THEAD', 'TBODY', 'TR', 'TD', 'TH', 'CODE', 'BLOCKQUOTE']);
3010
+ const PROJECT_HTML_ATTRS = new Set(['href', 'src', 'alt', 'title', 'target', 'rel', 'class', 'style', 'width', 'height', 'colspan', 'rowspan']);
3011
+ function sanitizeProjectHtml(html) {
3012
+ const s = String(html);
3013
+ if (!/[<&]/.test(s)) return s;
3014
+ // no DOM to parse with (Node): the text, escaped
3015
+ if (typeof global.DOMParser !== 'function') return escapeHtml(s);
3016
+ // an inert document: nothing in it runs or loads while it is cleaned
3017
+ const body = new global.DOMParser().parseFromString('<!doctype html><body>' + s, 'text/html').body;
3018
+ for (const el of Array.from(body.querySelectorAll('*'))) {
3019
+ if (PROJECT_HTML_DROP.has(el.tagName.toUpperCase())) { el.remove(); continue; }
3020
+ if (!PROJECT_HTML_TAGS.has(el.tagName.toUpperCase())) { el.replaceWith(...el.childNodes); continue; }
3021
+ for (const a of Array.from(el.attributes)) {
3022
+ const name = a.name.toLowerCase();
3023
+ if (!PROJECT_HTML_ATTRS.has(name)
3024
+ || ((name === 'href' || name === 'src') && /^(javascript|vbscript|data):/i.test(a.value.replace(/[\s\u0000-\u001f]/g, '')))) {
3025
+ el.removeAttribute(a.name);
3026
+ }
3027
+ }
3028
+ }
3029
+ return body.innerHTML;
3030
+ }
3031
+
2781
3032
  function withoutProjectCode(t, report) {
2782
3033
  const found = PROJECT_CODE_KEYS.filter(([part, key, , isCode]) =>
2783
3034
  t[part] && t[part][key] != null && (!isCode || isCode(t[part][key])));
2784
- if (!found.length) return t;
3035
+ const html = [];
3036
+ for (const part of ['style', 'meta']) {
3037
+ for (const key of PROJECT_HTML_KEYS) {
3038
+ const v = t[part] && t[part][key];
3039
+ if (typeof v !== 'string') continue;
3040
+ const clean = sanitizeProjectHtml(v);
3041
+ if (clean !== v) html.push([part, key, clean]);
3042
+ }
3043
+ }
3044
+ if (!found.length && !html.length) return t;
2785
3045
  const copy = Object.assign({}, t);
2786
- found.forEach(([part, key]) => {
2787
- if (copy[part] === t[part]) copy[part] = Object.assign({}, t[part]);
2788
- delete copy[part][key];
2789
- });
2790
- const what = [...new Set(found.map(f => f[2]))].join(', ');
2791
- report.notes.push(`theme "${t.layer}": the project's ${what} is never run by ixmaps-gl`);
3046
+ const own = part => { if (copy[part] === t[part]) copy[part] = Object.assign({}, t[part]); return copy[part]; };
3047
+ found.forEach(([part, key]) => { delete own(part)[key]; });
3048
+ html.forEach(([part, key, clean]) => { own(part)[key] = clean; });
3049
+ if (found.length) {
3050
+ const what = [...new Set(found.map(f => f[2]))].join(', ');
3051
+ report.notes.push(`theme "${t.layer}": the project's ${what} is never run by ixmaps-gl`);
3052
+ }
3053
+ if (html.length) {
3054
+ report.notes.push(`theme "${t.layer}": the project's ${[...new Set(html.map(h => h[1]))].join(', ')} HTML is cleaned to formatting markup by ixmaps-gl`);
3055
+ }
2792
3056
  return copy;
2793
3057
  }
2794
3058
 
@@ -2858,7 +3122,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2858
3122
  'sizepow', 'rangescale', 'minvalue', 'maxvalue', 'markersize', 'boxopacity', 'outlierscale', 'valuescale',
2859
3123
  'brightness', 'fractionscale', 'dopacityscale', 'dopacitypow', 'gridwidthpx', 'textscale', 'rangecentervalue',
2860
3124
  'shadowblur', 'shadowdx', 'shadowdy', 'maxshadow', 'offsetx', 'offsety', 'gridx', 'boxmargin', 'borderwidth',
2861
- 'borderradius', 'maxcharts', 'archeight'];
3125
+ 'borderradius', 'maxcharts', 'archeight', 'alignmargin'];
2862
3126
  function toNumberIfNumeric(v) {
2863
3127
  if (typeof v !== 'string' || !v.trim()) return v;
2864
3128
  const n = Number(v);
@@ -3350,6 +3614,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3350
3614
  // The ocean of flat's world / orthographic maps is a {type:"Sphere"} feature: the whole map disc,
3351
3615
  // filled with the layer's color. Here it is the surface of the map (the globe, or the flat
3352
3616
  // map): a background layer under everything, in that color and opacity.
3617
+ // the themes are drawn once, as in flat, even where the map repeats its world (the themecopies map option:
3618
+ // true repeats them on every world copy)
3619
+ function applyThemeCopies(map, value) {
3620
+ if (value !== true && value !== 'true') {
3621
+ if (!map.__ixOrigGetRenderWorldCopies) map.__ixOrigGetRenderWorldCopies = map.getRenderWorldCopies;
3622
+ map.getRenderWorldCopies = () => false;
3623
+ } else if (map.__ixOrigGetRenderWorldCopies) {
3624
+ map.getRenderWorldCopies = map.__ixOrigGetRenderWorldCopies;
3625
+ delete map.__ixOrigGetRenderWorldCopies;
3626
+ }
3627
+ }
3353
3628
  const SPHERE_BACKDROP_LAYER_ID = '__ixmaps_gl_sphere_backdrop';
3354
3629
  function applySphereBackdrop(map, style) {
3355
3630
  const cs = style && style.colorscheme;
@@ -3367,7 +3642,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3367
3642
  map.setPaintProperty(id, 'background-opacity', opacity);
3368
3643
  } catch (e) { console.warn('[ixmaps-gl] the {type:"Sphere"} ocean backdrop could not be set:', e); }
3369
3644
  };
3370
- if (map.isStyleLoaded()) paint(); else map.once('style.load', paint);
3645
+ // isStyleLoaded() is also false AFTER the style loaded while tiles or
3646
+ // images load (every pan/zoom) — 'style.load' never fires again then, so
3647
+ // a Sphere theme defined at such a moment would never get its color
3648
+ if (map.isStyleLoaded() || (map.style && map.style._loaded)) paint(); else map.once('style.load', paint);
3371
3649
  }
3372
3650
 
3373
3651
  function buildBlankBackgroundStyle(color) {
@@ -3386,6 +3664,23 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3386
3664
  // basemap credit, bottom right — same line, same pill: white, 12px
3387
3665
  // radius, 12px/20px Helvetica Neue, links rgba(0,0,0,0.75)).
3388
3666
  // ---------------------------------------------------------------
3667
+ // On a dark map (the effective darkness, as the legend and the tooltip use it) the MapLibre control buttons
3668
+ // (zoom, compass) and the page's attribution are dark too: the class ixgl-dark on the map element switches them
3669
+ let _controlLookCss = false;
3670
+ function applyControlLook(el, dark) {
3671
+ if (!_controlLookCss && typeof document !== 'undefined') {
3672
+ _controlLookCss = true;
3673
+ const css = document.createElement('style');
3674
+ css.textContent = '.ixgl-dark .maplibregl-ctrl-group{background:#222!important;box-shadow:0 0 0 1px rgba(255,255,255,.15)!important}'
3675
+ + '.ixgl-dark .maplibregl-ctrl-group button+button{border-top:1px solid #444!important}'
3676
+ + '.ixgl-dark .maplibregl-ctrl-group button:not(:disabled):hover{background-color:#3a3a3a!important}'
3677
+ + '.ixgl-dark .maplibregl-ctrl-group .maplibregl-ctrl-icon{filter:invert(1)}'
3678
+ + '.ixgl-dark .ixmaps-gl-attribution{background:#222!important;color:#aaa!important}'
3679
+ + '.ixgl-dark .ixmaps-gl-attribution a{color:#ddd!important}';
3680
+ document.head.appendChild(css);
3681
+ }
3682
+ if (el && el.classList) el.classList.toggle('ixgl-dark', !!dark);
3683
+ }
3389
3684
  let _attributionCss = false;
3390
3685
  function createAttribution(map) {
3391
3686
  if (!_attributionCss && typeof document !== 'undefined') {
@@ -3516,7 +3811,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3516
3811
  // visible (an element larger than the viewport ends up at its edge)
3517
3812
  function keepTooltipVisible(tipEl, x, y) {
3518
3813
  const w = tipEl.offsetWidth, h = tipEl.offsetHeight;
3519
- const vw = global.innerWidth || 1200, vh = global.innerHeight || 800;
3814
+ // x/y and the translate are relative to the map container (it clips,
3815
+ // overflow:hidden): its size is the room, not the window's — they
3816
+ // differ on an embedded map
3817
+ const vw = el.clientWidth || global.innerWidth || 1200, vh = el.clientHeight || global.innerHeight || 800;
3520
3818
  let tx = x > vw / 2 ? x - w - 30 : x + 30;
3521
3819
  let ty = y > vh / 2 ? y - h - 20 : y + 20;
3522
3820
  tx = Math.min(Math.max(8, tx), Math.max(8, vw - w - 8));
@@ -3689,11 +3987,29 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3689
3987
  // pane, the shared look (fonts included) on the container
3690
3988
  return html ? { html: tooltipPaneHtml(html), style: Object.assign({ padding: '0.5em 0.7em', maxWidth: '440px' }, tooltipLook()) } : null;
3691
3989
  }
3990
+ // flat's click-to-action hook (ui/js/tools/tooltip_to_action.js): ixmaps.onItemAction(szId, item, themeObj) on a
3991
+ // click on an item — item.szSelectionId is the value of the theme's selection field — and onItemAction(null, null)
3992
+ // on a click on the map background
3993
+ function callItemAction(rt, object) {
3994
+ const hook = pageIxmaps() && pageIxmaps().onItemAction;
3995
+ if (typeof hook !== 'function') return;
3996
+ try {
3997
+ if (!rt) { hook.call(global.ixmaps, null, null); return; }
3998
+ const field = selectionFieldOf(rt);
3999
+ const props = object.properties || {};
4000
+ // a single record, or an aggregated item's first one
4001
+ const raw = props.raw || props.firstRaw || props.titleRaw || (props.raws && props.raws[0]) || props;
4002
+ const value = field && raw ? raw[field] : null;
4003
+ if (value == null) return;
4004
+ hook.call(global.ixmaps, themeIdOf(rt) + '::' + value, { szSelectionId: String(value) }, getThemeObj(themeIdOf(rt)));
4005
+ } catch (err) { console.error('[ixmaps-gl] onItemAction failed:', err); }
4006
+ }
3692
4007
  function clickFor(picked) {
3693
4008
  const info = picked && Object.assign({}, picked, { object: pickedFeature(picked.object) });
3694
4009
  if (info && info.object && info.layer) {
3695
4010
  const rt = findRuntimeForLayerId(info.layer.id);
3696
4011
  if (rt) {
4012
+ callItemAction(rt, info.object);
3697
4013
  // Only a Point geometry's own coordinates ARE an
3698
4014
  // [lng,lat] pair (every point/bubble/dot/chart-cluster
3699
4015
  // layer) — a CHOROPLETH polygon's geometry.coordinates is
@@ -3724,6 +4040,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3724
4040
  }
3725
4041
  }
3726
4042
  // clicked empty map space (or a non-tooltippable layer) -> unpin
4043
+ callItemAction(null, null);
3727
4044
  pinned = null;
3728
4045
  pinnedTooltipEl.style.display = 'none';
3729
4046
  return false;
@@ -4116,13 +4433,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4116
4433
  // twin pages)
4117
4434
  const flatUnitLegend = rt._isVectorChart() || rt._isAggregatedPie();
4118
4435
  const rawLegendUnit = rt.style.legendunits || rt.style.units || '';
4119
- const legendUnit = flatUnitLegend && rawLegendUnit && rawLegendUnit[0] !== '.' && rawLegendUnit[0] !== ' ' ? ' ' + rawLegendUnit : rawLegendUnit;
4436
+ // COUNT: the rows are record counts — flat prints them as whole numbers
4437
+ // with no unit (legend.js 705, 810: __formatValue(count, nDecimals,
4438
+ // "BLANK") + (COUNT ? "" : " " + unit))
4439
+ const countLegend = flatFlag(rt.flags, 'COUNT') && !useSum;
4440
+ const legendUnit = countLegend ? '' : flatUnitLegend && rawLegendUnit && rawLegendUnit[0] !== '.' && rawLegendUnit[0] !== ' ' ? ' ' + rawLegendUnit : rawLegendUnit;
4120
4441
  // a per-item chart over several value fields: the row values as
4121
4442
  // flat's legend prints them (legend.js 610, 810: __formatValue
4122
4443
  // with valuedecimals, else 2, "BLANK" — whole numbers without
4123
4444
  // decimals), a colorscheme "none" part as an outline in linecolor
4124
4445
  const multiFieldLegend = rt._isMultiFieldChart();
4125
- const legendTotalText = v => ((multiFieldLegend || flatUnitLegend)
4446
+ const legendTotalText = v => ((multiFieldLegend || flatUnitLegend || countLegend)
4126
4447
  ? flatFormatValue(v, legendDecimals(rt.style), 'BLANK')
4127
4448
  : rt._formatTooltipValue(v));
4128
4449
  const outlineRgb = rt.style.linecolor && styleLineColor(rt.style.linecolor) !== 'none' ? hexOrNamedToRgb(styleLineColor(rt.style.linecolor)) : [128, 128, 128];
@@ -4425,7 +4746,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4425
4746
  // same 400ms as the facets sidebar's own onRedraw use), same
4426
4747
  // signal already used elsewhere in this file for "the visible
4427
4748
  // data just changed, recompute".
4428
- engineApi.onRedraw(() => { computeTotals(); renderRows(); });
4749
+ // a panel replaced (rebuild, legend off) or a removed theme: the
4750
+ // listener goes with it — else one per rebuild piles up, each
4751
+ // recomputing a stale runtime on every redraw
4752
+ const offRedraw = engineApi.onRedraw(() => {
4753
+ if (rt._legendPanel !== panel || !runtimes.includes(rt)) { offRedraw(); return; }
4754
+ computeTotals(); renderRows();
4755
+ });
4429
4756
  }
4430
4757
 
4431
4758
  // rt._triggerRedraw: see its own doc comment on LayerRuntime
@@ -4740,7 +5067,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4740
5067
  }
4741
5068
  return this;
4742
5069
  }
4743
- options(o) { this._engineOptions = o; return this; }
5070
+ // merged, as flat's options() sets each key on its own (ixmaps.setOptions)
5071
+ // and the live map's options() below does — .options({a}).options({b})
5072
+ // keeps a
5073
+ options(o) { if (o && typeof o === 'object') Object.assign(this._engineOptions, o); return this; }
4744
5074
  local(...args) { this._locals.push(args); return this; }
4745
5075
  // flat's .require(url) (htmlgui.js ixmaps.require): a script the page
4746
5076
  // needs (a chart library, a user chart) — page code, like a <script>
@@ -4793,6 +5123,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4793
5123
  // the Map(id, opts, map => map.options(...)) callback form worked).
4794
5124
  // One microtask lets the caller's whole synchronous chain land first.
4795
5125
  await null;
5126
+ // the DASH flow animation's repaint loop (see refreshLayers): MapLibre
5127
+ // renders on demand only, so the shader's wall-clock dash (see
5128
+ // makeFlowPathLayer) needs a continuous frame request while any flow
5129
+ // layer is on screen — one handle per map, or another map's refresh
5130
+ // cancels (or blocks) this map's loop
5131
+ let _flowRepaintRAF = null;
4796
5132
  let el = document.getElementById(this.containerId);
4797
5133
  if (!el) {
4798
5134
  // flat's own fallback for a missing target div (htmlgui_flat.js
@@ -4812,7 +5148,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4812
5148
  el = document.createElement('div');
4813
5149
  el.id = this.containerId;
4814
5150
  el.style.width = (this.mapOptions && this.mapOptions.width) ? String(this.mapOptions.width) : '100%';
4815
- el.style.height = (this.mapOptions && this.mapOptions.height) ? String(this.mapOptions.height) : '100%';
5151
+ // a percentage height would collapse against a body without a
5152
+ // height of its own (the blank-screen case): the window's
5153
+ // innerHeight instead, like flat's fullscreen fallback
5154
+ el.style.height = (this.mapOptions && this.mapOptions.height) ? String(this.mapOptions.height)
5155
+ : (global.innerHeight || 800) + 'px';
4816
5156
  document.body.appendChild(el);
4817
5157
  }
4818
5158
  // flat's init replaces the container's content with its own map page
@@ -4836,13 +5176,22 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4836
5176
  else if (typeof v === 'string' && /^\s*\d+(\.\d+)?\s*%\s*$/.test(v)
4837
5177
  && !(dim === 'width' ? el.clientWidth : el.clientHeight)) el.style[dim] = v.trim();
4838
5178
  }
4839
- // without width/height flat's map is "fullscreen" (htmlgui_flat.js
5179
+ // without height flat's map is "fullscreen" (htmlgui_flat.js
4840
5180
  // 847-876): it fills the window, whatever the page's CSS gives the
4841
- // container — a container left without a height gets the window's
4842
- if (!(this.mapOptions && (this.mapOptions.width || this.mapOptions.height))) {
4843
- if (!el.clientHeight) el.style.height = '100vh';
4844
- if (!el.clientWidth) el.style.width = '100%';
5181
+ // container. PER SIDE — also when only a width is given (the World
5182
+ // Dataviz Prize pages: width 100%, no height), where the old
5183
+ // both-sides gate left the container collapsed at 0 (a blank
5184
+ // screen): no height option and no effective CSS height gets the
5185
+ // window's innerHeight, kept current on window resizes (MapLibre
5186
+ // follows its container's own size)
5187
+ if (!(this.mapOptions && this.mapOptions.height) && !el.clientHeight) {
5188
+ this._heightFallbackToWindow = true;
5189
+ el.style.height = (global.innerHeight || 800) + 'px';
5190
+ global.addEventListener('resize', () => {
5191
+ if (this._heightFallbackToWindow) el.style.height = (global.innerHeight || 800) + 'px';
5192
+ });
4845
5193
  }
5194
+ if (!(this.mapOptions && this.mapOptions.width) && !el.clientWidth) el.style.width = '100%';
4846
5195
 
4847
5196
  // shown for the whole build() (library lazy-load + every layer's own
4848
5197
  // data fetch, real-world verified as the two slowest phases) through
@@ -4869,8 +5218,6 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4869
5218
  const svgProjection = flatSvgProjectionOf(this.mapOptions.map)
4870
5219
  || (isFlatOrthographicName(this.mapOptions.mapProjection) ? FLAT_ORTHOGRAPHIC_PROJECTION : null);
4871
5220
  if (svgProjection) flatSvgProjectionByOptions.set(this._engineOptions, svgProjection);
4872
- const planeProjection = flatPlaneProjectionOf(this.mapOptions);
4873
- if (planeProjection) flatPlaneProjectionByOptions.set(this._engineOptions, planeProjection);
4874
5221
  if (isFlatOrthographicName(this.mapOptions.mapProjection)) {
4875
5222
  flatOrthographicByOptions.set(this._engineOptions, true);
4876
5223
  if (this._viewCenter) flatViewCenterByOptions.set(this._engineOptions, { lat: this._viewCenter[0], lng: this._viewCenter[1] });
@@ -5035,11 +5382,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5035
5382
  // world map can't open fully zoomed out. The deck.gl overlay takes
5036
5383
  // its `repeat` from map.getRenderWorldCopies(), so the themes are
5037
5384
  // drawn on every world copy too (flat draws them once; only its
5038
- // basemap tiles wrap). .options({worldcopies: false}) draws the
5385
+ // basemap tiles wrap) — unless themecopies is off, which is the default
5386
+ // (see applyThemeCopies). .options({worldcopies: false}) draws the
5039
5387
  // world once, at the cost of that zoom limit.
5040
5388
  renderWorldCopies: this._engineOptions.worldcopies !== false
5041
5389
  });
5042
5390
  engineOptionsByMap.set(map, this._engineOptions);
5391
+ // themecopies (default: off): the map keeps its world copies (free panning past the world's edge, empty
5392
+ // space beside it) but the deck.gl overlay, which asks map.getRenderWorldCopies() for its `repeat`, draws the
5393
+ // themes once, as flat does. .options({themecopies: true}) repeats them. MapLibre's own transform does not
5394
+ // use this method.
5395
+ applyThemeCopies(map, this._engineOptions.themecopies);
5043
5396
  sphereBackdrops.forEach(style => applySphereBackdrop(map, style));
5044
5397
  // the live-SVG pane USER charts render into (see chartHostFor) —
5045
5398
  // registered here, keyed by this map's options object, so every
@@ -5168,6 +5521,16 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5168
5521
  // the page's attribution (.attribution(), Map option attribution)
5169
5522
  const attributionBox = createAttribution(map);
5170
5523
  attributionBox.set(this._attributionText != null ? this._attributionText : this.mapOptions.attribution);
5524
+ // dark controls on a dark map; re-read when a style (basemap) loads and when basemapopacity is set
5525
+ const refreshControlLook = () => {
5526
+ const o = parseFloat(this._engineOptions.basemapopacity);
5527
+ const legendBackground = this.mapOptions.legendBackground || this.mapOptions.legendbackground;
5528
+ applyControlLook(el, typeof this.__effectiveDark === 'function'
5529
+ ? this.__effectiveDark(isNaN(o) ? 1 : o, this.mapOptions.mapType, legendBackground) : false);
5530
+ };
5531
+ this._refreshControlLook = refreshControlLook;
5532
+ refreshControlLook();
5533
+ map.on('style.load', refreshControlLook);
5171
5534
  this._setAttribution = text => attributionBox.set(text);
5172
5535
 
5173
5536
  if (this._legendHtml) {
@@ -5175,7 +5538,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5175
5538
  // a page legend (map.legend(html)) is positioned by the map's
5176
5539
  // own align option — the same rule that places the native stack
5177
5540
  legendEl.style.cssText = 'position:absolute;' + legendAlignCssFor(this.mapOptions) + 'z-index:5;pointer-events:none';
5178
- legendEl.innerHTML = this._legendHtml;
5541
+ // flat's legend takes the pane's html OR a URL to it (htmlgui):
5542
+ // a value without a tag is fetched, its html shown
5543
+ if (/<[a-z!]/i.test(this._legendHtml)) legendEl.innerHTML = this._legendHtml;
5544
+ else fetch(this._legendHtml).then(r => r.ok ? r.text() : '').then(t => { legendEl.innerHTML = t; }).catch(() => {});
5179
5545
  // inside the map container, never the page's body (see the pinned
5180
5546
  // tooltip mount) — styling body breaks page-positioned elements
5181
5547
  anchorInside(el);
@@ -5305,18 +5671,19 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5305
5671
  add: (layerBuilder, flag) => {
5306
5672
  layerBuilder = unwrapLayer(layerBuilder);
5307
5673
  _chainedLayers = _chainedLayers.then(() => {
5674
+ let slot = null;
5308
5675
  if (/replace/i.test(flag || '')) {
5309
5676
  const def = layerBuilder.definition();
5310
5677
  const id = (def.style && def.style.name) || (def.meta && def.meta.name);
5311
- if (id) removeThemeById(id);
5678
+ if (id) { slot = captureSlot(id); removeThemeById(id); }
5312
5679
  }
5313
- return engineApi.defineLayer(layerBuilder);
5680
+ return engineApi.defineLayer(layerBuilder, slot);
5314
5681
  }).catch(err => console.error('[ixmaps-gl] map.add():', err));
5315
5682
  return engineApi;
5316
5683
  },
5317
5684
  replace: (id, layerBuilder, flag) => {
5318
5685
  layerBuilder = unwrapLayer(layerBuilder);
5319
- _chainedLayers = _chainedLayers.then(() => { removeThemeById(id); return engineApi.defineLayer(layerBuilder); })
5686
+ _chainedLayers = _chainedLayers.then(() => { const slot = captureSlot(id); removeThemeById(id); return engineApi.defineLayer(layerBuilder, slot); })
5320
5687
  .catch(err => console.error('[ixmaps-gl] map.replace():', err));
5321
5688
  return engineApi;
5322
5689
  },
@@ -5331,11 +5698,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5331
5698
  layerBuilder = unwrapLayer(layerBuilder);
5332
5699
  const f = String(flag || '');
5333
5700
  _chainedLayers = _chainedLayers.then(() => {
5701
+ let slot = null;
5334
5702
  if (!/force/i.test(f)) {
5335
5703
  if (/replace/i.test(f)) {
5336
5704
  const def = layerBuilder.definition();
5337
5705
  const id = (def.style && def.style.name) || (def.meta && def.meta.name);
5338
- if (id) removeThemeById(id);
5706
+ if (id) { slot = captureSlot(id); removeThemeById(id); }
5339
5707
  } else if (/clearcharts|clear|clearall/i.test(f)) {
5340
5708
  const charts = /clearcharts/i.test(f);
5341
5709
  const doomed = runtimes.filter(rt => !flatFlag(rt.flags, 'LOCKED') && (!charts || rt.flags.has('CHART')));
@@ -5344,7 +5712,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5344
5712
  byName.forEach((list, name) => removeRuntimes(name, list));
5345
5713
  }
5346
5714
  }
5347
- return engineApi.defineLayer(layerBuilder);
5715
+ return engineApi.defineLayer(layerBuilder, slot);
5348
5716
  }).catch(err => console.error('[ixmaps-gl] map.newTheme():', err));
5349
5717
  return engineApi;
5350
5718
  },
@@ -5550,7 +5918,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5550
5918
  // called after every redraw (pan/zoom, or any of the setters above)
5551
5919
  // so a page can keep a facets sidebar in sync, mirroring the real
5552
5920
  // engine's htmlgui_onDrawTheme hook
5553
- onRedraw: (cb) => { redrawListeners.push(cb); },
5921
+ // returns an unsubscribe (the legend drops its listener with its panel)
5922
+ onRedraw: (cb) => {
5923
+ redrawListeners.push(cb);
5924
+ return () => { const i = redrawListeners.indexOf(cb); if (i >= 0) redrawListeners.splice(i, 1); };
5925
+ },
5554
5926
  refresh: () => refresh(),
5555
5927
  // Adds ONE new layer/theme to an ALREADY-BUILT map — build()'s own
5556
5928
  // loop above only ever runs once, at construction, so a caller
@@ -5572,7 +5944,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5572
5944
  // exactly how this engine's own facet-filter/style setters below
5573
5945
  // already push responsibility for "what should happen" to the
5574
5946
  // caller rather than guessing.
5575
- defineLayer: (layerBuilder) => defineFromDefinition(unwrapLayer(layerBuilder).definition()),
5947
+ defineLayer: (layerBuilder, slot) => defineFromDefinition(unwrapLayer(layerBuilder).definition(), slot),
5576
5948
  // GL-PORT COMPAT: real ixmaps-flat's map handle, as a page gets it
5577
5949
  // from `myMap.then(map => map.view(...).options(...).layer(a)
5578
5950
  // .layer(b))` — the common multi-layer idiom — is chainable on the
@@ -5594,6 +5966,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5594
5966
  if (o && typeof o === 'object') {
5595
5967
  Object.assign(builder._engineOptions, o);
5596
5968
  // read only when the MapLibre map is created — switched here at runtime
5969
+ if ('basemapopacity' in o && builder._refreshControlLook) builder._refreshControlLook();
5970
+ if ('themecopies' in o) { applyThemeCopies(map, o.themecopies); map.triggerRepaint(); }
5597
5971
  if ('worldcopies' in o) map.setRenderWorldCopies(o.worldcopies !== false && o.worldcopies !== 'false');
5598
5972
  refresh();
5599
5973
  }
@@ -5700,7 +6074,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5700
6074
  // layers — leaving its groups mounted keeps the charts on the
5701
6075
  // map after map.remove()/replace() (censimenti's sidebar)
5702
6076
  if (rt._clearUserChartPane) rt._clearUserChartPane();
6077
+ if (rt._chartPaneDefs) { rt._chartPaneDefs.remove(); rt._chartPaneDefs = null; }
5703
6078
  if (rt._chartHost && rt._chartHost.runtimes) rt._chartHost.runtimes.delete(rt);
6079
+ // and its hidden <name>_init group (userChartHost, on the body)
6080
+ if (rt._userChartGroup) { rt._userChartGroup.remove(); rt._userChartGroup = null; }
5704
6081
  });
5705
6082
  const removed = removedRuntimes.length > 0;
5706
6083
  // stale-registry guard: _globalThemeRegistry is a bare name->
@@ -5729,6 +6106,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5729
6106
  if (removed) { refresh(); notifyRedraw(); }
5730
6107
  return removed;
5731
6108
  }
6109
+ // where the theme(s) removeThemeById(id) would remove stand in the stack: the first remaining
6110
+ // theme after them (null = they were on top). Taken BEFORE the removal, handed to defineLayer.
6111
+ function captureSlot(id) {
6112
+ const named = runtimes.filter(r => (r.style && r.style.name === id) || (r.meta && r.meta.name === id));
6113
+ const doomed = named.length ? named : runtimes.filter(r => r.name === id);
6114
+ if (!doomed.length) return null;
6115
+ const last = Math.max(...doomed.map(r => runtimes.indexOf(r)));
6116
+ const anchor = runtimes.slice(last + 1).find(r => !doomed.includes(r) && !doomed.some(d => d._subTheme === r)) || null;
6117
+ return { anchor };
6118
+ }
6119
+
5732
6120
  // flat addresses a theme by its id — style.name / meta.name (several
5733
6121
  // themes may share one layer), else the layer name
5734
6122
  function removeThemeById(id) {
@@ -5899,11 +6287,16 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5899
6287
  // flat itself does ("defining a theme with the same name replaces it
5900
6288
  // in place"; a page's live-refresh loop relies on it, e.g. the NYC
5901
6289
  // status page re-defining its two themes every 10s).
5902
- async function defineFromDefinition(def) {
6290
+ // `slot` (from captureSlot) is where a replaced theme stood in the stack: the replace paths
6291
+ // remove the old theme first and pass it on, so the new one is not appended on top
6292
+ async function defineFromDefinition(def, slot) {
5903
6293
  if (validation) validation.definition(def);
5904
6294
  const pdef = projectThemeToDefinition(def);
5905
6295
  const replacedId = (pdef.meta && pdef.meta.name) || (pdef.style && pdef.style.name);
5906
- if (replacedId) removeThemeById(replacedId);
6296
+ if (replacedId) {
6297
+ if (!slot) slot = captureSlot(replacedId);
6298
+ removeThemeById(replacedId);
6299
+ }
5907
6300
  let spec = normalizeTheme(pdef);
5908
6301
  let raw;
5909
6302
  const deferred = deferredFeatureLoad(spec, map.getZoom());
@@ -5930,7 +6323,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5930
6323
  rt._zoomToPending = wantsZoomToExtent(spec.flags);
5931
6324
  rt._dataSourceKey = named ? 'name:' + spec.data.name
5932
6325
  : sourceKey || JSON.stringify({ url: spec.data && spec.data.url, urls: spec.data && spec.data.urls, type: spec.data && spec.data.type, query: spec.data && spec.data.query, obj: !!(spec.data && spec.data.obj) });
5933
- runtimes.push(rt);
6326
+ // a replacement keeps the stacking position (draw order, legend order) of the theme it replaces:
6327
+ // it goes in front of the theme that followed the old one, still on the map after the await above
6328
+ const slotAt = slot && slot.anchor ? runtimes.indexOf(slot.anchor) : -1;
6329
+ if (slotAt >= 0) runtimes.splice(slotAt, 0, rt); else runtimes.push(rt);
5934
6330
  if (fc._sphere) applySphereBackdrop(map, rt.style);
5935
6331
  _globalThemeRegistry.set(rt.name, rt);
5936
6332
  if (rt.meta && rt.meta.name && rt.meta.name !== rt.name) _globalThemeRegistry.set(rt.meta.name, rt);
@@ -5980,10 +6376,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5980
6376
  for (let i = 0; i < themes.length; i++) {
5981
6377
  const t = themes[i] || {};
5982
6378
  const name = (t.style && t.style.name) || (t.meta && t.meta.name);
5983
- if (/replace/i.test(f) && name) removeThemesNamed(name);
6379
+ let slot = null;
6380
+ if (/replace/i.test(f) && name) { slot = captureSlot(name); removeThemesNamed(name); }
5984
6381
  else if (i === 0 && !/add/i.test(f)) [...new Set(runtimes.map(r => r.name))].forEach(n => engineApi.removeTheme(n));
5985
6382
  try {
5986
- report.themes.push(await defineFromDefinition(withoutProjectCode(t, report)));
6383
+ report.themes.push(await defineFromDefinition(withoutProjectCode(t, report), slot));
5987
6384
  } catch (e) {
5988
6385
  report.skipped.push({ layer: t.layer, reason: e.message });
5989
6386
  }
@@ -6002,8 +6399,6 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6002
6399
  const svgProjection = flatSvgProjectionOf(svg) || (/orthographic\.svg$/i.test(svg) ? FLAT_ORTHOGRAPHIC_PROJECTION : null);
6003
6400
  if (svgProjection) flatSvgProjectionByOptions.set(builder._engineOptions, svgProjection);
6004
6401
  else flatSvgProjectionByOptions.delete(builder._engineOptions);
6005
- if (svgProjection && svgProjection.invert) flatPlaneProjectionByOptions.set(builder._engineOptions, svgProjection);
6006
- else flatPlaneProjectionByOptions.delete(builder._engineOptions);
6007
6402
  if (/orthographic\.svg$/i.test(svg)) flatOrthographicByOptions.set(builder._engineOptions, true);
6008
6403
  else { flatOrthographicByOptions.delete(builder._engineOptions); flatViewCenterByOptions.delete(builder._engineOptions); }
6009
6404
  }
@@ -6174,6 +6569,22 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6174
6569
  if (order !== refreshLayers._lastOrder) { refreshLayers._lastOrder = order; console.info('[ixmaps-gl debug] draw order (bottom → top): ' + order); }
6175
6570
  }
6176
6571
  overlay.setProps({ layers });
6572
+ // start/stop the flow repaint loop: any layer whose paths carry
6573
+ // dash offsets animates, and only then (a static map renders
6574
+ // nothing new; the loop is one rAF, one triggerRepaint each)
6575
+ const flowOn = layers.some(l => l.constructor.layerName === 'FlowPathLayer'
6576
+ && l.props.data && l.props.data.some(d => d.dash && d.dash[0] + d.dash[1] > 0));
6577
+ if (glDebug()) console.info('[ixmaps-gl debug] flow loop on: ' + flowOn + ' (' + layers.filter(l => l.constructor.layerName === 'FlowPathLayer').length + ' flow layers, '
6578
+ + (layers.find(l => l.constructor.layerName === 'FlowPathLayer') || {props: {data: []}}).props.data.length + ' paths)');
6579
+ if (flowOn) {
6580
+ if (_flowRepaintRAF == null) {
6581
+ const loop = () => { if (map.triggerRepaint) map.triggerRepaint(); _flowRepaintRAF = global.requestAnimationFrame(loop); };
6582
+ _flowRepaintRAF = global.requestAnimationFrame(loop);
6583
+ }
6584
+ } else if (_flowRepaintRAF != null) {
6585
+ global.cancelAnimationFrame(_flowRepaintRAF);
6586
+ _flowRepaintRAF = null;
6587
+ }
6177
6588
  // flat's WMS themes render as native raster layers, not deck.gl —
6178
6589
  // reconcile them with the current runtimes on every rebuild
6179
6590
  syncWmsLayers();
@@ -6334,7 +6745,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6334
6745
  } finally { inDrawThemeHook = false; }
6335
6746
  }
6336
6747
  function notifyRedraw() {
6337
- redrawListeners.forEach(cb => { try { cb(); } catch (err) { console.error('[ixmaps-gl] onRedraw callback failed:', err); } });
6748
+ redrawListeners.slice().forEach(cb => { try { cb(); } catch (err) { console.error('[ixmaps-gl] onRedraw callback failed:', err); } });
6338
6749
  callDrawThemeHooks();
6339
6750
  }
6340
6751
 
@@ -6516,6 +6927,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6516
6927
  };
6517
6928
  }
6518
6929
  _resolveMapReady(engineApi);
6930
+ if (this.mapOptions && this.mapOptions.name) namedMapReady(this.mapOptions.name).resolve(engineApi);
6519
6931
  resolveBuilt();
6520
6932
  namedLoads.forEach(rt => { listenNamedData(rt); if (!rt._deferredLoad) loadNamedTheme(rt, false); });
6521
6933
  return engineApi;
@@ -6547,6 +6959,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6547
6959
  // class used throughout this file — a real bug caught during testing
6548
6960
  // (every `new Map()` below silently resolved to *this* function instead)
6549
6961
  function createMap(containerId, options, mapFn) {
6962
+ if (options && options.name) namedMapReady(options.name);
6550
6963
  const builder = new MapBuilder(containerId, options);
6551
6964
  if (mapFn) mapFn(builder);
6552
6965
  // If build() rejects (bad data URL, misconfigured CHOROPLETH join,
@@ -6614,7 +7027,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6614
7027
  // flat's mapApi.setView(center, zoom) (htmlgui_flat.js 1327) — the
6615
7028
  // same live-or-build-time split as .view() above
6616
7029
  promise.setView = (...args) => promise.view(...args);
6617
- promise.options = (...args) => { builder.options(...args); return promise; };
7030
+ // post-build: the live map's options() (merged into the options
7031
+ // object the running themes hold), as .view() above — the builder's
7032
+ // own is read once, by build()
7033
+ promise.options = (...args) => { if (resolvedApi) resolvedApi.options(...args); else builder.options(...args); return promise; };
6618
7034
  promise.attribution = (...args) => { builder.attribution(...args); return promise; };
6619
7035
  promise.legend = (...args) => { builder.legend(...args); return promise; };
6620
7036
  promise.local = (...args) => { builder.local(...args); return promise; };
@@ -6838,11 +7254,6 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6838
7254
  // the flat SVG projection a map's options object belongs to (set by the
6839
7255
  // map's init and loadProject; the runtimes only see the options object)
6840
7256
  const flatSvgProjectionByOptions = new WeakMap();
6841
- // the projection plane a map's VECTOR / BEZIER flows are drawn in: flat's
6842
- // map file, or the Map() option mapProjection ("equalearth") — flat computes
6843
- // the curves in its map's own coordinates, so the same flow bows around the
6844
- // pole there. (Unlike flatSvgProjectionByOptions it leaves the view framing alone.)
6845
- const flatPlaneProjectionByOptions = new WeakMap();
6846
7257
  // a map that is flat's orthographic one (drawn as MapLibre's globe): its object scale is
6847
7258
  // flat's (scale × the globe's pixels per degree at its center, measured on flat 2026-10-10
6848
7259
  // on the mechanical-goods globe page, the same at view zooms 2.5 and 3.5: 197.44e6; the
@@ -6885,12 +7296,6 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6885
7296
  const radius = 512 * Math.pow(2, zoom) / (2 * Math.PI * c);
6886
7297
  return Math.log2(globeDiscRadius(radius, height > 0 ? height : 768) * 2 * Math.PI / 512);
6887
7298
  }
6888
- function flatPlaneProjectionOf(mapOptions) {
6889
- const byUrl = flatSvgProjectionOf(mapOptions.map);
6890
- if (byUrl) return byUrl;
6891
- const named = FLAT_SVG_PROJECTIONS[String(mapOptions.mapProjection || '').trim().toLowerCase()];
6892
- return named || null;
6893
- }
6894
7299
  // flat SVG map scale ÷ the Mercator one at the same on-screen size (1
6895
7300
  // without a flat SVG projection)
6896
7301
  function flatMapScaleRatio(mapOptions) {
@@ -7113,7 +7518,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7113
7518
  : 1;
7114
7519
  }
7115
7520
 
7116
- function valueRadius(value, zoom, style, mapOptions, flags, maxSizeValue) {
7521
+ function valueRadius(value, zoom, style, mapOptions, flags, maxSizeValue, sizeBound) {
7117
7522
  const zoomFactor = objectZoomFactor(zoom, mapOptions);
7118
7523
  // FIXSIZE (a symbol chart; PLOT / GRIDSIZE size their markers
7119
7524
  // themselves): one radius for every item, whatever its value — the
@@ -7156,7 +7561,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7156
7561
  if (flags && flags.has('SYMBOL') && !flags.has('BUBBLE') && !flags.has('USER')
7157
7562
  && !flags.has('FIXSIZE') && !flags.has('PLOT') && !flags.has('GRIDSIZE')
7158
7563
  && flags.has('CATEGORICAL') && !flags.has('AGGREGATE') && !flatFlag(flags, 'GROUP')
7159
- && !style.sizefield) {
7564
+ && !style.sizefield && !sizeBound) {
7160
7565
  const fixed = NORMAL_RADIUS_PX * (styleNum(style.scale) || 1);
7161
7566
  return Math.max(VALUE_RADIUS_MIN, Math.min(VALUE_RADIUS_MAX, fixed * zoomFactor));
7162
7567
  }
@@ -7529,6 +7934,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7529
7934
  return out;
7530
7935
  }
7531
7936
 
7937
+ // the outline colour of a flat bubble icon: none for NOLINES / linecolor "none", else linecolor, else the
7938
+ // class colour's darker derivate — and none when there is neither (an unfilled "none" class has no colour
7939
+ // to derive from; with a linecolor its outline must still draw)
7940
+ function bubbleStrokeRgb(look, rgb) {
7941
+ return look.noStroke ? null : (look.stroke || (rgb ? flatDerivateRgb(rgb, 0.7) : null));
7942
+ }
7943
+
7532
7944
  // ChartColors.textColor (colorscheme.js 104-116): darker on light colors
7533
7945
  function flatChartTextRgb(rgb) {
7534
7946
  return flatDerivateRgb(rgb, rgb[0] + rgb[1] + rgb[2] > 450 ? 0.6 : 3);
@@ -7678,8 +8090,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7678
8090
  _measureCtx.font = fontPx + 'px arial';
7679
8091
  return _measureCtx.measureText(text).width;
7680
8092
  }
7681
- function wrapTitleLines(text, fontPx, widthPx) {
7682
- const width = w => titleTextWidth(w, fontPx);
8093
+ function wrapTitleLines(text, fontPx, widthPx, measure = titleTextWidth) {
8094
+ const width = w => measure(w, fontPx);
7683
8095
  const lines = [];
7684
8096
  for (const word of String(text).split(/\s+/).filter(Boolean)) {
7685
8097
  const last = lines.length ? lines[lines.length - 1] + ' ' + word : null;
@@ -8629,39 +9041,55 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8629
9041
  return { bars, texts, lines, extent: ext[0] <= ext[2] ? ext : [0, 0, 0, 0], width: W, sizer };
8630
9042
  }
8631
9043
 
8632
- // flat's BOX and TITLE around a chart (maptheme.js 18758-18905), in the
8633
- // chart's units: `extent` the chart's [x0, y0, x1, y1]; o: { margin
8634
- // (boxmargin, 2), titleFont, title (text or ''), bottomTitle, alignRight,
8635
- // measure }. The margin is min(2 m, m · width / 30); the title's first
8636
- // baseline 0.7 font above the chart (BOTTOMTITLE: one font below), its
8637
- // lines wrapped to the chart's width and stacked upward; the box encloses
8638
- // chart and title, its top trimmed by 0.1 font when titled. Returns
8639
- // { rect: [x0, y0, x1, y1], title: { lines, x, anchor, baseline, font } | null }
8640
- function flatChartBox(extent, o) {
9044
+ // THE box-and-title layout of every chart type (flat's BOX block,
9045
+ // maptheme.js 18757-18925): single symbol, SEQUENCE, PIE and BAR all call
9046
+ // this. `extent`: the chart's drawn [x0, y0, x1, y1] around its anchor (px;
9047
+ // chart units with unit 1). o: { unit (px per flat unit, default 1), margin
9048
+ // (boxmargin, flat's 2), circular (CIRCULARBOX), radius / center (the
9049
+ // circle's base radius and center; default the extent's half longer side
9050
+ // and its center), titleText (with the titleunits suffix, '' / null: no
9051
+ // title), titleFont (px, or w => px for DTEXT), bottomTitle, alignRight,
9052
+ // wrap, measure }. Returns { rect (rectangular box), boxR / cx / cy (the
9053
+ // circular box: radius, center), w (the extent's width), title { text,
9054
+ // lines, x, anchor, top, baseline, f, extent } }.
9055
+ // The margin is min(2 m u, m w / 30); the title's first baseline 0.7 font
9056
+ // above the box (BOTTOMTITLE: one font below), wrapped to the chart's width
9057
+ // (the circle's diameter); a rectangular box grows to enclose the title,
9058
+ // its top trimmed by 0.1 font. arial: ascent 0.905, descent 0.212.
9059
+ function chartBoxLayout(extent, o) {
8641
9060
  const e = extent.slice();
8642
- const w = e[2] - e[0];
8643
- const margin = Math.min(2 * o.margin, o.margin * w / 30);
9061
+ const w = e[2] - e[0], h = e[3] - e[1];
9062
+ const unit = o.unit == null ? 1 : o.unit;
9063
+ const circular = !!o.circular;
9064
+ const margin = Math.min(2 * o.margin * unit, o.margin * w / 30);
9065
+ const center = o.center || [(e[0] + e[2]) / 2, (e[1] + e[3]) / 2];
9066
+ const boxR = circular ? (o.radius != null ? o.radius : Math.max(w, h) / 2) * 1.1 + margin : 0;
8644
9067
  let title = null;
8645
- if (o.title) {
8646
- const f = o.titleFont;
8647
- const measure = o.measure || ((text, font) => text.length * font * 0.55);
8648
- const lines = [];
8649
- for (const word of String(o.title).split(/\s+/).filter(Boolean)) {
8650
- const last = lines.length ? lines[lines.length - 1] + ' ' + word : null;
8651
- if (last !== null && measure(last, f) <= w) lines[lines.length - 1] = last;
8652
- else lines.push(word);
8653
- }
9068
+ if (o.titleText) {
9069
+ const f = typeof o.titleFont === 'function' ? o.titleFont(w) : o.titleFont;
9070
+ const measure = o.measure || titleTextWidth;
9071
+ const lines = wrapTitleLines(o.titleText, f, circular ? boxR * 2 : w, measure).split('\n');
8654
9072
  const width = Math.max(...lines.map(l => measure(l, f)));
8655
- const baseline = o.bottomTitle ? e[3] + f : e[1] - 0.7 * f - (lines.length - 1) * f;
8656
- const x = o.alignRight ? e[2] : e[0];
8657
- const x0 = o.alignRight ? x - width : x;
8658
- title = { lines, x, anchor: o.alignRight ? 'end' : 'start', baseline, font: f };
8659
- e[0] = Math.min(e[0], x0);
8660
- e[1] = Math.min(e[1], baseline - 0.905 * f) + 0.1 * f;
8661
- e[2] = Math.max(e[2], x0 + width);
8662
- e[3] = Math.max(e[3], baseline + (lines.length - 1) * f + 0.212 * f);
9073
+ // first baseline; lines go down one font each
9074
+ const base = circular
9075
+ ? (o.bottomTitle ? center[1] + boxR + f : center[1] - boxR - 0.7 * f - (lines.length - 1) * f)
9076
+ : (o.bottomTitle ? e[3] + f : e[1] - 0.7 * f - (lines.length - 1) * f);
9077
+ const x = circular ? center[0] + unit / 2 : (o.alignRight ? e[2] : e[0]);
9078
+ const anchor = circular ? 'middle' : (o.alignRight ? 'end' : 'start');
9079
+ const x0 = anchor === 'middle' ? x - width / 2 : anchor === 'end' ? x - width : x;
9080
+ title = { text: lines.join('\n'), lines, x, anchor, top: base - 0.8 * f, baseline: base, f,
9081
+ extent: [x0, base - 0.905 * f, x0 + width, base + (lines.length - 1) * f + 0.212 * f] };
8663
9082
  }
8664
- return { rect: [e[0] - margin, e[1] - margin, e[2] + margin, e[3] + margin], title, width: w };
9083
+ let rect = null;
9084
+ if (!circular) {
9085
+ const b = e.slice();
9086
+ if (title) {
9087
+ b[0] = Math.min(b[0], title.extent[0]); b[1] = Math.min(b[1], title.extent[1]) + 0.1 * title.f;
9088
+ b[2] = Math.max(b[2], title.extent[2]); b[3] = Math.max(b[3], title.extent[3]);
9089
+ }
9090
+ rect = [b[0] - margin, b[1] - margin, b[2] + margin, b[3] + margin];
9091
+ }
9092
+ return { rect, boxR, cx: center[0], cy: center[1], w, margin, title };
8665
9093
  }
8666
9094
 
8667
9095
  // CHART|PIE / DONUT: one pie's slices as real ixmaps-flat lays them out —
@@ -8693,10 +9121,38 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8693
9121
  // centered on 12 o'clock), HALF over 180° from 270° (HALFPLUS10 200°
8694
9122
  // from 260°, HALFMINUS10 160° from 280°, HALFPLUS20 220° from 250°,
8695
9123
  // HALFMINUS20 140° from 290°)
9124
+ // - STARBURST (piechart.js 246, 324-355, 415-420): every part >= 0, the
9125
+ // angles EQUAL (the value is the ray's radius, not its sweep) and the
9126
+ // percents NOT normalized — raw values. The FLOWER/RAYS variants
9127
+ // (XLFLOWER/XLRAYS, LFLOWER/LRAYS, XSFLOWER/XSRAYS, SFLOWER/SRAYS,
9128
+ // FLOWER/RAYS) cap the ray angle (maxAngle / 4 / 12 / 10 / none) and
9129
+ // inset a gap (10 / 20 / 40 / 33 / 35 % of it) on both sides of each
9130
+ // ray. The ray RADIUS is
9131
+ // computed in _buildPieLayers (it needs the theme's maxValue)
8696
9132
  function pieSliceLayout(values, t, opts = {}) {
8697
9133
  t = String(t || '');
8698
9134
  const has = re => re.test(t);
8699
- const parts = values.map(v => (isNaN(v) ? 0 : v));
9135
+ // flat's STARBURST (piechart.js realize 246, 324-355, 415-420): the
9136
+ // parts get EQUAL angles — the value is the ray's radius, not its
9137
+ // sweep — and every part is clamped to >= 0. The FLOWER/RAYS
9138
+ // variants (flat maps XLFLOWER to XLRAYS & kin, maptheme.js
9139
+ // 20169-20186) cap the ray angle and leave a gap between the rays.
9140
+ // flat's `nRadInner = max(5, nRadInner)` for the ray variants is 5
9141
+ // units of the chart's INTERNAL coordinate system — measured on the
9142
+ // ISTAT story: 5 of the disc's 197 internal units, 0.28 screen px,
9143
+ // sub-pixel and invisible (the petals go to the center; an inner
9144
+ // radius shows only with DONUT) — so starInner here is 0 for the
9145
+ // ray variants too, and only DONUT+STARBURST starts its rays at
9146
+ // the donut's inner radius (see _buildPieLayers)
9147
+ const star = has(/STARBURST/);
9148
+ const starRay = !star ? null
9149
+ : /XLFLOWER|XLRAYS/.test(t) ? { cap: 4, gap: 0.10 }
9150
+ : /LFLOWER|LRAYS/.test(t) ? { cap: 4, gap: 0.20 }
9151
+ : /XSFLOWER|XSRAYS/.test(t) ? { cap: 12, gap: 0.40 }
9152
+ : /SFLOWER|SRAYS/.test(t) ? { cap: 10, gap: 0.33 }
9153
+ : /FLOWER|RAYS/.test(t) ? { cap: 1, gap: 0.35 }
9154
+ : { cap: 1, gap: 0 };
9155
+ const parts = values.map(v => (isNaN(v) ? 0 : star ? Math.max(0, v) : v));
8700
9156
  const sum = parts.reduce((a, v) => a + v, 0);
8701
9157
  const autocomplete = has(/AUTOCOMPLETE/);
8702
9158
  if (sum === 0 && !autocomplete) return null;
@@ -8749,13 +9205,18 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8749
9205
  // flat draws its "Pie error: no values !" circle here
8750
9206
  if (!any && !autocomplete) return null;
8751
9207
  const pctSum = slices.reduce((a, sl) => a + sl.percent, 0);
8752
- if (pctSum !== 100) {
9208
+ // flat's STARBURST keeps the parts' raw values (piechart.js 246-258:
9209
+ // no 100% normalization, no AUTOCOMPLETE) — the value is the radius
9210
+ if (!star && pctSum !== 100) {
8753
9211
  if (autocomplete && pctSum < 100) slices.push({ i: -1, value: null, percent: Math.floor((100 - pctSum) * 100000) / 100000, complement: true });
8754
9212
  else slices.forEach(sl => { sl.percent = sl.percent / pctSum * 100; });
8755
9213
  }
8756
9214
  let end = 0;
8757
9215
  if (has(/SYMMETRIC/) && slices.length) {
8758
- if (has(/BIGTOTOP/)) {
9216
+ if (star) {
9217
+ // flat piechart.js 296-299: the first ray centered on 12 o'clock
9218
+ end = 360 - Math.floor(360 / parts.length / 2);
9219
+ } else if (has(/BIGTOTOP/)) {
8759
9220
  end = 360;
8760
9221
  let x = 0;
8761
9222
  for (; x < slices.length && slices[x] !== biggest; x++) end -= slices[x].percent < 100 ? Math.floor(360 / 100 * slices[x].percent) : 0;
@@ -8776,6 +9237,20 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8776
9237
  if (has(/HALFMINUS20/)) maxAngle = 140;
8777
9238
  const out = [];
8778
9239
  slices.forEach((sl, k) => {
9240
+ if (star) {
9241
+ // flat piechart.js 324-355, 395-403: the angles advance by the
9242
+ // FULL equal angle (the ray variants capped), the gap inset on
9243
+ // both sides of a ray; a ray's own sweep is that reduced angle
9244
+ const angle = Math.min(maxAngle / starRay.cap, maxAngle / parts.length);
9245
+ const base = end % 360;
9246
+ end = base + angle;
9247
+ if (end > 360) end = end % 360;
9248
+ const sweep = angle * (1 - 2 * starRay.gap);
9249
+ const start = base + angle * starRay.gap;
9250
+ if (sweep <= 0 || start < 0) return;
9251
+ out.push(Object.assign(sl, { start, sweep, k }));
9252
+ return;
9253
+ }
8779
9254
  const sweep = sl.percent / 100 * maxAngle;
8780
9255
  const start = end % 360;
8781
9256
  end = start + sweep;
@@ -8902,6 +9377,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8902
9377
  const parts = [], zeroNot = [];
8903
9378
  for (let k = 0; k < o.fields.length; k++) {
8904
9379
  let v = parseFloat(raw[o.fields[k]]);
9380
+ // flat's weights multiply every record's per-field value as it is
9381
+ // accumulated (maptheme.js 10506-10508) — the SIZE (nValueSize)
9382
+ // stays raw, exactly as there
9383
+ if (o.weights) v = v * o.weights(k);
8905
9384
  zeroNot[k] = false;
8906
9385
  if (isNaN(v)) {
8907
9386
  if (!r.undefinedOk) return null;
@@ -9111,6 +9590,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9111
9590
  for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
9112
9591
  return (h >>> 0) / 4294967296;
9113
9592
  }
9593
+ // a flow's dash phase in [0, 1): flat's random one, the same for the
9594
+ // same arc on every rebuild
9595
+ function flowDashPhase(p1, p2) {
9596
+ return hashUnit('dash:' + p1[0] + ',' + p1[1] + '&' + p2[0] + ',' + p2[1]);
9597
+ }
9114
9598
 
9115
9599
  // points of a cubic Bézier, n segments (n + 1 points)
9116
9600
  function cubicBezierPoints(p0, c1, c2, p3, n) {
@@ -9273,14 +9757,21 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9273
9757
  const up = fl.up;
9274
9758
  const maxR = opts.maxRadius || 0;
9275
9759
  const nStarParts = opts.nParts || counts.length;
9276
- let angle = 0, starR = null, x = 0;
9760
+ // flat's star radius (nStarRadius, 0 per chart) is set ONLY by the FIRST
9761
+ // part of the sorted order (maptheme.js 22301-22316: `if (i === 0)`), and a
9762
+ // part of value 0 is skipped before it (22222): a chart whose first part
9763
+ // (with SORT|UP the smallest) is 0 keeps star radius 0, so with UP every
9764
+ // part sits on the center — only a chart with ALL parts non-zero spreads
9765
+ // into its star. (A cell of a CATEGORICAL AGGREGATE theme has a zero part
9766
+ // for every category it lacks.)
9767
+ let angle = 0, starR = 0, x = 0;
9277
9768
  order.forEach((p, k) => {
9278
9769
  if (!p.v) return;
9279
9770
  const r = radiusOf(p.v);
9280
9771
  if (!(r > 0)) return;
9281
9772
  let px = 0, py = 0;
9282
9773
  if (star) {
9283
- if (starR === null) {
9774
+ if (k === 0) {
9284
9775
  starR = r;
9285
9776
  if (fl.expand && fl.sort && up) {
9286
9777
  const big = order[order.length - 1].v, mv = opts.maxValue || big || 1;
@@ -9288,7 +9779,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9288
9779
  }
9289
9780
  if (fl.compress) starR *= fl.compressmax ? 0.25 : 0.5;
9290
9781
  }
9291
- if (parts.length > 0 || up) {
9782
+ // flat: `(i > 0 || UP) && nRadius` — i the part's index in the sorted order
9783
+ if (k > 0 || up) {
9292
9784
  const c = starR + r;
9293
9785
  const b = Math.sqrt(Math.max(0, c * c - r * r));
9294
9786
  let f = 90 - Math.asin(Math.min(1, b / c)) / Math.PI * 180;
@@ -9837,7 +10329,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9837
10329
  // 7740-7801): a value that is no number counts as 0 — unless
9838
10330
  // UNDEFINEDISNOTVALUE —, and 0 is a value — unless ZEROISNOTVALUE
9839
10331
  function flatNumber(v, flags) {
9840
- let n = parseFloat(v);
10332
+ // flat's own value scan (maptheme.js __scanValue 9265-9272): a
10333
+ // value with a comma is EUROPEAN format — dots stripped as
10334
+ // thousands separators, the comma is the decimal ("78,8" -> 78.8);
10335
+ // a value without one is parsed plain, blanks stripped. Applied
10336
+ // to every theme value in flat, so also here
10337
+ const s = String(v);
10338
+ let n = s.includes(',') ? parseFloat(s.replace(/\./g, '').replace(/,/g, '.')) : parseFloat(s.replace(/ /g, ''));
9841
10339
  if (isNaN(n)) n = flags && flags.has('UNDEFINEDISNOTVALUE') ? NaN : 0;
9842
10340
  if (n === 0 && flags && flags.has('ZEROISNOTVALUE')) return NaN;
9843
10341
  return n;
@@ -9848,6 +10346,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9848
10346
  const bins = [arr.reduce((a, v) => (v < a ? v : a), Infinity), m];
9849
10347
  while (arr.length > 1) {
9850
10348
  arr = arr.filter(d => d > m);
10349
+ // every value left equals the last mean (a tie at the top): there is
10350
+ // no head above it — mean([]) would add a NaN break (flat's own bug)
10351
+ if (!arr.length) break;
9851
10352
  m = mean(arr);
9852
10353
  bins.push(m);
9853
10354
  }
@@ -9898,6 +10399,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9898
10399
  // values above the last mean, while more than one is left
9899
10400
  if (flags.has('HEADTAIL')) {
9900
10401
  const b = headTailBreaks(values);
10402
+ // fewer breaks than classes (small or low-variance data): the surplus
10403
+ // classes padded at the top with the last break, as naturalBreaks
10404
+ // does — flat reads past the end (undefined bounds, records in no class)
10405
+ while (b.length < nParts + 1) b.push(b[b.length - 1]);
9901
10406
  parts = new Array(nParts).fill(null).map((_, i) => ({ min: b[i], max: b[i + 1] - 0.000001 }));
9902
10407
  } else if (flags.has('QUANTILE')) parts = quantileBreaks(values, nParts);
9903
10408
  else if (flags.has('NATURAL')) parts = naturalBreaks(values, nParts);
@@ -10010,7 +10515,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10010
10515
  function fieldWeightOf(style, nFields, repeatLast) {
10011
10516
  const w = style && style.weights;
10012
10517
  if (w == null || w === '') return null;
10013
- const list = (Array.isArray(w) ? w.join('|') : String(w)).split('|').map(v => parseFloat(v));
10518
+ // flat's toArray (maptheme.js 999-1014): split on '|' when the
10519
+ // string has one, else on ',' — "1,10,10" is a THREE-weight list
10520
+ // (the Happy Planet page), not one weight of 1
10521
+ const s = Array.isArray(w) ? w.join('|') : String(w);
10522
+ const list = (s.includes('|') ? s.split('|') : s.split(',')).map(v => parseFloat(v));
10014
10523
  if (!list.some(v => !isNaN(v))) return null;
10015
10524
  if (repeatLast) { const last = list[list.length - 1]; while (list.length < nFields) list.push(last); }
10016
10525
  return i => list[i] || list[0] || 1;
@@ -10019,9 +10528,16 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10019
10528
  // the value fields' own values weighted — the class value and the field100
10020
10529
  // value stay raw, exactly as flat reads them
10021
10530
  function weightedRecord(f, weightOf) {
10531
+ // a pie record's parts were weighted in flatPieRecord, and its size
10532
+ // (nValueSize) stays raw — nothing to do here
10533
+ if (f.properties && f.properties.pie) return f;
10022
10534
  const p = Object.assign({}, f.properties);
10023
10535
  if (p.value !== undefined && !isNaN(p.value)) p.value = p.value * weightOf(0);
10024
10536
  if (Array.isArray(p.series)) p.series = p.series.map((v, i) => (isNaN(v) ? v : v * weightOf(i)));
10537
+ // the multi-field pie's parts — the SAME per-field values as series,
10538
+ // but the grid cell's pie accumulates THESE (flatPieAccumulate's
10539
+ // rec.parts), so they carry the weights too
10540
+ if (Array.isArray(p.parts)) p.parts = p.parts.map((v, i) => (isNaN(v) ? v : v * weightOf(i)));
10025
10541
  return Object.assign({}, f, { properties: p });
10026
10542
  }
10027
10543
  function applyField100(features, binding, flags, style) {
@@ -10044,7 +10560,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10044
10560
  return features.map(f => {
10045
10561
  if (!f || !f.properties) return f;
10046
10562
  const props = Object.assign({}, f.properties);
10047
- let values = names.map(name => parseFloat(f.properties[name]));
10563
+ let values = names.map(name => flatScanValue(f.properties[name]));
10048
10564
  // the weights run BEFORE the field100 arithmetic, as in flat's
10049
10565
  // addItemValues — the FRACTION of a weighted value is the weighted
10050
10566
  // per-unit value
@@ -10053,7 +10569,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10053
10569
  const sum = auto100 ? values.reduce((a, v) => a + (isNaN(v) ? 0 : v), 0) : 0;
10054
10570
  values = values.map((v, i) => {
10055
10571
  if (isNaN(v)) return v;
10056
- const v100 = auto100 ? sum : parseFloat(f.properties[f100A[Math.min(i, f100A.length - 1)]]);
10572
+ const v100 = auto100 ? sum : flatScanValue(f.properties[f100A[Math.min(i, f100A.length - 1)]]);
10057
10573
  if (v === 0 && !flags.has('ZEROISVALUE')) return 0;
10058
10574
  if (fields[i][0] === '!') return (isNaN(v100) ? 0 : v100) - v;
10059
10575
  if (flags.has('CALCVAL') || flags.has('CALC100')) return v100 ? Math.round(v * v100 / 100) : v;
@@ -10099,7 +10615,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10099
10615
  // DIFFERENCE over several value fields: the difference is the value
10100
10616
  const fields = String(binding.value).split('|');
10101
10617
  const valueOf = flags && flags.has('DIFFERENCE') && fields.length > 1
10102
- ? p => cellAggregatedValues({ sums: fields.map(k => parseFloat(p[k]) || 0), counts: fields.map(() => 1) }, flags, style)[0]
10618
+ // without AGGREGATE applyField100 already differenced every record
10619
+ // (flat's nValuesA, popped): the first field IS the value
10620
+ ? (!flags.has('AGGREGATE') ? p => flatNumber(p[fields[0]], flags)
10621
+ : p => cellAggregatedValues({ sums: fields.map(k => parseFloat(p[k]) || 0), counts: fields.map(() => 1) }, flags, style)[0])
10103
10622
  // "$item$" is flat's record count: 1 per record
10104
10623
  : binding.value === '$item$' ? () => 1
10105
10624
  : p => (p && Object.keys(p).length ? flatNumber(p[binding.value], flags) : NaN);
@@ -10395,13 +10914,18 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10395
10914
  if (cell.titleMax === undefined || (maxAgg ? m > cell.titleMax : m < cell.titleMax)) {
10396
10915
  cell.titleMax = m;
10397
10916
  cell.titleFrom = f;
10917
+ cell.titleX = p.x; cell.titleY = p.y;
10398
10918
  }
10399
10919
  }
10400
10920
  }
10401
10921
  const out = [];
10402
10922
  for (const cell of cells.values()) {
10403
- const x = relocate ? cell.sumX / cell.n : cell.snapped.x;
10404
- const y = relocate ? cell.sumY / cell.n : cell.snapped.y;
10923
+ // RELOCATE: at the records' mean position — with MAX / MIN at the
10924
+ // record holding the max (min), the one the cell IS (and its title's);
10925
+ // only the position follows it, never a value
10926
+ const atTitle = relocate && cell.titleFrom;
10927
+ const x = atTitle ? cell.titleX : relocate ? cell.sumX / cell.n : cell.snapped.x;
10928
+ const y = atTitle ? cell.titleY : relocate ? cell.sumY / cell.n : cell.snapped.y;
10405
10929
  const ll = worldPixelToLngLat(x, y, zoom);
10406
10930
  const geometry = { type: 'Point', coordinates: [ll.lng, ll.lat] };
10407
10931
  const hasClass = cell.first.properties.classValue !== undefined;
@@ -10702,17 +11226,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10702
11226
  // could ever need it (a plain "px" gridwidth, or an aggregation
10703
11227
  // array with only "px" entries, never reads this field).
10704
11228
  const gridwidthIsMeters = typeof this.style.gridwidth === 'string' ? !/px\s*$/i.test(this.style.gridwidth) : this.style.gridwidth != null;
10705
- if (gridwidthIsMeters || aggregationHasMetersEntry(this.style.aggregation)) {
10706
- let minLat = Infinity, maxLat = -Infinity;
10707
- this.features.forEach(f => {
10708
- const lat = f.geometry && f.geometry.coordinates && f.geometry.coordinates[1];
10709
- if (typeof lat === 'number' && !isNaN(lat)) {
10710
- if (lat < minLat) minLat = lat;
10711
- if (lat > maxLat) maxLat = lat;
10712
- }
10713
- });
10714
- this._gridRefLat = (minLat <= maxLat) ? (minLat + maxLat) / 2 : 0;
10715
- }
11229
+ // aggregationscale: flat's alias of aggregation, read the same way in
11230
+ // _ensureClusterIndices
11231
+ if (gridwidthIsMeters || aggregationHasMetersEntry(this.style.aggregation || this.style.aggregationscale)) this._scanGridRefLat();
10716
11232
 
10717
11233
  // a bar chart's parts are a pie's (its value fields or categories)
10718
11234
  if ((this._isPieChart() || this._isBarChart()) && this.binding.value) {
@@ -10799,6 +11315,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10799
11315
  let max = 0;
10800
11316
  for (const f of this.features) for (const field of this._multiFields) { const v = Math.abs(parseFloat(f.properties[field])); if (v > max) max = v; }
10801
11317
  this._multiFieldPartMax = max || 1;
11318
+ // AUTO100's scale, measured on these features by _buildSequenceChartLayers
11319
+ this._seqAuto100Max = null;
10802
11320
  } else if (this.binding.value) {
10803
11321
  // real engine's OTHER coloring mode (maptheme.js distributeValues,
10804
11322
  // partsA): a NUMERIC bound value, not CATEGORICAL, is classed into
@@ -10988,6 +11506,14 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10988
11506
  _resolveAggregateValue(props) { return resolveAggregateValue(this.binding, this.flags, props); }
10989
11507
 
10990
11508
  _buildAggregationIndex(sourceFeatures) {
11509
+ // only records with a point position are drawn: a GeoJSON feature may
11510
+ // have geometry null (RFC 7946 — open-data exports of unlocated
11511
+ // records), or a shape chartAnchoredFeatures could not anchor; they
11512
+ // stay in the data (counts, facets), as on flat, but have no chart
11513
+ sourceFeatures = sourceFeatures.filter(f => {
11514
+ const c = f.geometry && f.geometry.coordinates;
11515
+ return !!c && typeof c[0] === 'number' && isFinite(c[0]) && isFinite(c[1]);
11516
+ });
10991
11517
  // an AGGREGATE / CATEGORICAL pie: one bucket, each record carrying its
10992
11518
  // contribution to its item (flatPieRecord), added up per position or
10993
11519
  // grid cell by aggregateOnGrid (flatPieAccumulate)
@@ -10997,9 +11523,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10997
11523
  const rules = flatValueRules(t, fields ? fields.length : 1);
10998
11524
  const sizeField = this.binding.size && this.binding.size !== '$item$' ? this.binding.size : null;
10999
11525
  const feats = [];
11526
+ // flat's per-field weights (maptheme.js 10506-10508), applied to
11527
+ // every record's parts inside flatPieRecord
11528
+ const pieWeightOf = fieldWeightOf(this.style, fields ? fields.length : 1, true);
11000
11529
  for (const f of sourceFeatures) {
11001
- const c = f.geometry && f.geometry.coordinates;
11002
- if (!c || typeof c[0] !== 'number' || !isFinite(c[0]) || !isFinite(c[1])) continue;
11003
11530
  const p = f.properties || {};
11004
11531
  let cat;
11005
11532
  if (this._pieCategorical) {
@@ -11007,7 +11534,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11007
11534
  const i = k == null ? undefined : this._pieCategoryIndex.get(k);
11008
11535
  cat = i == null ? -1 : i;
11009
11536
  }
11010
- const pie = flatPieRecord(p, { rules, sizeField, cat, fields });
11537
+ const pie = flatPieRecord(p, { rules, sizeField, cat, fields, weights: pieWeightOf });
11011
11538
  if (!pie) continue;
11012
11539
  feats.push({ type: 'Feature', geometry: f.geometry, properties: { value: pie.size, pie, raw: p } });
11013
11540
  }
@@ -11066,7 +11593,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11066
11593
  const seriesFields = String(this.binding.value || '').split('|');
11067
11594
  this._seriesDifference = this.flags.has('DIFFERENCE') && seriesFields.length > 1 && !this._aggregateField100;
11068
11595
  const seriesOf = props => seriesFields.map(k => parseFloat(props[k]) || 0);
11069
- const seriesValue = props => cellAggregatedValues({ sums: seriesOf(props), counts: seriesFields.map(() => 1) }, this.flags, this.style)[0];
11596
+ // without AGGREGATE the records are already differenced (applyField100)
11597
+ const seriesValue = !this.flags.has('AGGREGATE') ? props => parseFloat(props[seriesFields[0]]) || 0 : props => cellAggregatedValues({ sums: seriesOf(props), counts: seriesFields.map(() => 1) }, this.flags, this.style)[0];
11070
11598
  this._featuresByCategory = [sourceFeatures.map(f => ({
11071
11599
  type: 'Feature',
11072
11600
  geometry: f.geometry,
@@ -11424,6 +11952,20 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11424
11952
  });
11425
11953
  }
11426
11954
 
11955
+ // the meters grid's reference latitude: the middle of the data's own
11956
+ // latitude extent (see _prepare)
11957
+ _scanGridRefLat() {
11958
+ let minLat = Infinity, maxLat = -Infinity;
11959
+ this.features.forEach(f => {
11960
+ const lat = f.geometry && f.geometry.coordinates && f.geometry.coordinates[1];
11961
+ if (typeof lat === 'number' && !isNaN(lat)) {
11962
+ if (lat < minLat) minLat = lat;
11963
+ if (lat > maxLat) maxLat = lat;
11964
+ }
11965
+ });
11966
+ this._gridRefLat = (minLat <= maxLat) ? (minLat + maxLat) / 2 : 0;
11967
+ }
11968
+
11427
11969
  // (re)builds the per-category GridAggregateIndex for the grid width
11428
11970
  // resolved at the given zoom, only when that width changed. Grid width,
11429
11971
  // as flat reads it (maptheme.js:1892-1908, 7990-8008): gridwidthpx and
@@ -11434,6 +11976,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11434
11976
  // style.aggregation bracket; none → no grid (exact positions).
11435
11977
  _ensureClusterIndices(zoom) {
11436
11978
  let radiusPx;
11979
+ // a meters grid set later by setStyle (gridwidth / aggregation) never
11980
+ // went through _prepare's scan — not the equator, the data's latitude
11981
+ if (this._gridRefLat === undefined) this._scanGridRefLat();
11437
11982
  // GL-PORT COMPAT: a real-ixmaps-flat page's own gridwidth is
11438
11983
  // sometimes a fixed SCREEN-PIXEL size (e.g. style.gridwidth:"3px",
11439
11984
  // confirmed against a real ported page) rather than this engine's
@@ -11480,6 +12025,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11480
12025
  this._clusterUsesFixedZoom = false;
11481
12026
  }
11482
12027
  if (this._clusterIndices && this._clusterRadiusPx === radiusPx && this._clusterField === aggField) return;
12028
+ // new cells (e.g. a runtime gridwidth): the zoom-keyed stats of the
12029
+ // old ones (breaks, NORMALIZE, NOOUTLIER, sizeMax) no longer apply
12030
+ this._aggregateStatsCache = null;
11483
12031
  this._clusterRadiusPx = radiusPx;
11484
12032
  this._clusterField = aggField;
11485
12033
  // after aggregation, on the cell sums: field100 of a value-based
@@ -11671,6 +12219,91 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11671
12219
  return Array.isArray(cs) && cs.length === 1 && String(cs[0]).trim().toLowerCase() === 'none';
11672
12220
  }
11673
12221
 
12222
+ // an external symbol file (.svg/.png/.jpeg) named by `symbols` — flat draws it as an
12223
+ // <image> (maptheme.js 21743, 22168), not as the chart's own shape. null for a shape name
12224
+ _urlSymbolOf(sym) {
12225
+ if (typeof sym !== 'string') return null;
12226
+ const s = sym.trim();
12227
+ // a data: URI is a symbol file too, and the only kind fetch() can read on a file:// page
12228
+ return /\.(svg|png|jpe?g)([?#]|$)/i.test(s) || /^data:image\/(svg\+xml|png|jpe?g)[;,]/i.test(s) ? s : null;
12229
+ }
12230
+
12231
+ // the symbol file as an IconLayer icon descriptor, NOT tinted (flat doesn't recolor an
12232
+ // <image> either); fetched and rasterized once per URL, shared by SEQUENCE and plain symbols
12233
+ _urlSymbolIcon(u) {
12234
+ if (!this._urlIconCache) this._urlIconCache = new Map();
12235
+ if (!this._urlIconCache.has(u)) {
12236
+ // the placeholder is a transparent 1x1 PNG, NOT the raw symbol
12237
+ // URL: deck.gl would immediately try to load that itself, and an
12238
+ // SVG without natural dimensions (a viewBox only, the bus
12239
+ // pictogram of the City-moving page) makes Chrome's
12240
+ // createImageBitmap throw — a console error and a part that
12241
+ // never renders. The real raster replaces it below
12242
+ const entry = { url: TRANSPARENT_1PX_PNG, width: 256, height: 256, anchorX: 128, anchorY: 128, mask: false, id: 'seq-sym-pending:' + u };
12243
+ this._urlIconCache.set(u, entry);
12244
+ // fetch the symbol ONCE and rasterize it into the descriptor's
12245
+ // own 256px cell here: deck then always sees exactly the declared
12246
+ // dimensions, and dimension-less SVGs work like any other. The
12247
+ // redraw lets the next getIcon see the patched descriptor
12248
+ fetch(u).then(r => r.ok ? r.text() : null).then(text => new Promise((resolve, reject) => {
12249
+ if (typeof text !== 'string' || !/<svg[\s>]/i.test(text)) { reject(new Error('not svg')); return; }
12250
+ if (!/\swidth\s*=/i.test(text) || !/\sheight\s*=/i.test(text)) {
12251
+ const vb = text.match(/viewBox\s*=\s*["']([^"']*)["']/i);
12252
+ const p = vb && vb[1].trim().split(/[\s,]+/).map(Number);
12253
+ if (p && p.length === 4 && isFinite(p[2]) && p[2] > 0 && isFinite(p[3]) && p[3] > 0) {
12254
+ text = text.replace(/<svg/i, '<svg width="' + p[2] + '" height="' + p[3] + '"');
12255
+ }
12256
+ }
12257
+ const img = new Image();
12258
+ img.onload = () => resolve(img);
12259
+ img.onerror = () => reject(new Error('svg decode failed'));
12260
+ img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(text);
12261
+ })).then(img => {
12262
+ const size = entry.width;
12263
+ const canvas = document.createElement('canvas');
12264
+ canvas.width = size; canvas.height = size;
12265
+ const ctx = canvas.getContext('2d');
12266
+ const s = Math.min(size / (img.naturalWidth || size), size / (img.naturalHeight || size));
12267
+ const dw = (img.naturalWidth || size) * s, dh = (img.naturalHeight || size) * s;
12268
+ ctx.drawImage(img, (size - dw) / 2, (size - dh) / 2, dw, dh);
12269
+ entry.url = canvas.toDataURL('image/png');
12270
+ entry.id = 'seq-sym-raster:' + u;
12271
+ if (this._triggerRedraw) this._triggerRedraw();
12272
+ }).catch(() => {
12273
+ // a symbol FILE of a file:// page can't be fetched (nor loaded by deck): say so once
12274
+ let onDisk = false;
12275
+ try { onDisk = new URL(u, location.href).protocol === 'file:'; } catch (e) { /* not a URL */ }
12276
+ if (onDisk && !this._warnedFileSymbol) {
12277
+ this._warnedFileSymbol = true;
12278
+ console.warn('[ixmaps-gl] symbol file "' + u + '" cannot be loaded from a file:// page — serve the page over http(s) or give the symbol as a data: URI');
12279
+ }
12280
+ // not an SVG (a PNG/JPEG symbol): deck's own URL loading takes
12281
+ // it from here — and a pre-sized SVG that failed to rasterize
12282
+ // gets that same second chance
12283
+ entry.url = u;
12284
+ entry.id = 'seq-sym-natural:' + u;
12285
+ if (this._triggerRedraw) this._triggerRedraw();
12286
+ });
12287
+ }
12288
+ return this._urlIconCache.get(u);
12289
+ }
12290
+
12291
+ // a size field is bound (flat's szSizeField): .binding({size}) or style.sizefield, live —
12292
+ // setSizeField() rebinds at runtime. '$item$' is not a field
12293
+ _sizeBound() { return !!(this.binding.size && this.binding.size !== '$item$'); }
12294
+
12295
+ // the symbol file of a plain (non-SEQUENCE) item, or null: the same class→symbol /
12296
+ // symbolfield mapping as _resolveSymbolShape, before it folds unknown names to 'circle'
12297
+ _resolveSymbolUrl(props) {
12298
+ const s = this.style.symbols;
12299
+ if (!Array.isArray(s)) return null;
12300
+ if (!this.style.symbolfield) {
12301
+ const cat = props && props.cat != null ? props.cat : 0;
12302
+ return this._urlSymbolOf(s[Math.min(cat, s.length - 1)]);
12303
+ }
12304
+ return this._urlSymbolOf(this._resolveSymbolShape(props));
12305
+ }
12306
+
11674
12307
  _buildSingleIcon(colorRgb, opacity, shape, borderColorRgb, borderWidth, borderOpacity = 1) {
11675
12308
  if (!colorRgb) { colorRgb = [128, 128, 128]; opacity = 0; } // no class colour (["none"]): unfilled
11676
12309
  shape = shape || 'circle';
@@ -12080,7 +12713,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12080
12713
  // companion layer) takes the uniform mesh shortcut.
12081
12714
  if (this.flags.has('GRIDSIZE') && this.flags.has('PLOT')) return this._buildPlotLayers(zoom, bbox);
12082
12715
  // PLOT without GRIDSIZE: one line chart per item over its value fields
12083
- if (this.flags.has('PLOT') && this._isItemPlot()) return this._buildItemPlotLayers(zoom);
12716
+ if (this.flags.has('PLOT') && this._isItemPlot()) return this._buildItemPlotLayers(zoom, liveZoom);
12084
12717
  // a plain grid (one color, no values or glow — the Germany page's
12085
12718
  // companion "grid" layer) as one square per cell; a classed GRIDSIZE
12086
12719
  // chart (the fuel-price page) is a symbol chart sized to the grid,
@@ -12666,16 +13299,20 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12666
13299
  return explicit ? fields.map((f, i) => String(explicit[i] != null ? explicit[i] : f)) : fields;
12667
13300
  }
12668
13301
 
12669
- _buildItemPlotLayers(zoom) {
13302
+ _buildItemPlotLayers(zoom, liveZoom = zoom) {
12670
13303
  const fields = this._itemPlotFields();
12671
13304
  const items = [];
12672
13305
  let dMin = Infinity, dMax = -Infinity;
13306
+ // facet / runtime WHERE / time frame narrow the drawn charts, as in
13307
+ // every other renderer; the value range stays the full data's (as
13308
+ // the bubbles' _maxSizeValue), so a filter does not rescale them
13309
+ const active = this._activeFeatures ? new Set(this._activeFeatures) : null;
12673
13310
  for (const f of this.features) {
12674
13311
  const pos = itemAnchor(f.geometry);
12675
13312
  if (!pos) continue;
12676
13313
  const values = fields.map(k => parseFloat(f.properties[k]));
12677
13314
  for (const v of values) if (!isNaN(v)) { dMin = Math.min(dMin, v); dMax = Math.max(dMax, v); }
12678
- items.push({ geometry: { type: 'Point', coordinates: pos }, properties: { values, raw: f.properties } });
13315
+ if (!active || active.has(f)) items.push({ geometry: { type: 'Point', coordinates: pos }, properties: { values, raw: f.properties } });
12679
13316
  }
12680
13317
  if (!items.length) return [];
12681
13318
  const sMin = styleNum(this.style.minvalue), sMaxRaw = this.style.maxvalue;
@@ -12694,7 +13331,18 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12694
13331
  const S = 2 * NORMAL_RADIUS_PX * (styleNum(this.style.scale) || 1) * objectZoomFactor(zoom, this.mapOptions);
12695
13332
  const color = parseCssColor(Array.isArray(this.style.colorscheme) ? this.style.colorscheme[0] : this.style.colorscheme) || [0, 102, 204];
12696
13333
  const fillOpacity = styleNum(this.style.fillopacity);
12697
- return [new IconLayer({
13334
+ // BOX / TITLE: flat's chart-group box around every chart; the chart's
13335
+ // drawn extent is its plot geometry's bounding box (chart units × S px,
13336
+ // y up in the geometry, down on screen)
13337
+ const boxRows = this._boxRows(items, { unit: S / (2 * NORMAL_RADIUS_PX), liveZoom, extentOf: d => {
13338
+ const b = itemPlotGeometry(d.properties.values, rangeFor(d.properties.values), this.style, this.flags, sizeMax).bbox;
13339
+ return [b.x0 * S, -b.y1 * S, b.x1 * S, -b.y0 * S];
13340
+ } });
13341
+ const boxLayers = boxRows ? this._boxTitleLayers(boxRows, {
13342
+ unit: S / (2 * NORMAL_RADIUS_PX), liveZoom, positionOf: b => b.d.geometry.coordinates, offsetOf: () => [0, 0],
13343
+ ids: { circle: `ix-itemplot-box-${this.name}`, icon: `ix-itemplot-box-${this.name}`, polygons: `ix-itemplot-box-polygons-${this.name}`, titles: `ix-itemplot-titles-${this.name}` }
13344
+ }) : { box: [], titles: [] };
13345
+ return [...boxLayers.box, new IconLayer({
12698
13346
  id: `ix-itemplot-${this.name}-g${this._iconGeneration}`,
12699
13347
  data: items, pickable: true,
12700
13348
  getPosition: d => d.geometry.coordinates,
@@ -12704,7 +13352,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12704
13352
  getSize: d => this._buildItemPlotIcon(d.properties.values, rangeFor(d.properties.values), sizeMax, color, fillOpacity).heightUnits * S,
12705
13353
  sizeUnits: 'pixels',
12706
13354
  updateTriggers: { getSize: [S] }
12707
- })];
13355
+ }), ...boxLayers.titles];
12708
13356
  }
12709
13357
 
12710
13358
  // canvas for one per-item PLOT chart (itemPlotGeometry), RASTER px per
@@ -12842,14 +13490,22 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12842
13490
  datasetMax = datasetMax || 1;
12843
13491
  if (isNaN(normalSizeValue)) normalSizeValue = datasetMax;
12844
13492
 
12845
- return [new IconLayer({
13493
+ // BOX / TITLE: flat's chart-group box around every chart; the chart is
13494
+ // the iconSize square on the cell's center (flat's chart size is 30 units)
13495
+ const boxRows = this._boxRows(data, { unit: iconSize / 30, liveZoom: zoom,
13496
+ extentOf: () => [-iconSize / 2, -iconSize / 2, iconSize / 2, iconSize / 2] });
13497
+ const boxLayers = boxRows ? this._boxTitleLayers(boxRows, {
13498
+ unit: iconSize / 30, liveZoom: zoom, positionOf: b => b.d.geometry.coordinates, offsetOf: () => [0, 0],
13499
+ ids: { circle: `ix-plot-box-${this.name}`, icon: `ix-plot-box-${this.name}`, polygons: `ix-plot-box-polygons-${this.name}`, titles: `ix-plot-titles-${this.name}` }
13500
+ }) : { box: [], titles: [] };
13501
+ return [...boxLayers.box, new IconLayer({
12846
13502
  id: `ix-plot-${this.name}-g${this._iconGeneration}`,
12847
13503
  data, pickable: true,
12848
13504
  getPosition: d => d.geometry.coordinates,
12849
13505
  getIcon: d => this._buildPlotIcon(d.properties.values, categories, labels, normalSizeValue, datasetMax, datasetMin),
12850
13506
  getSize: iconSize,
12851
13507
  sizeUnits: 'pixels'
12852
- })];
13508
+ }), ...boxLayers.titles];
12853
13509
  }
12854
13510
 
12855
13511
  // Canvas rendering for one cell's mini-chart — BOX background, AREA
@@ -12967,9 +13623,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12967
13623
  canvas.width = W; canvas.height = H;
12968
13624
  const ctx = canvas.getContext('2d');
12969
13625
 
12970
- // BOX
13626
+ // BOX (a plot without the BOX flag keeps its own canvas box; with it the
13627
+ // shared box layer draws it — see _buildPlotLayers)
12971
13628
  const boxOpacity = styleNum(this.style.boxopacity);
12972
- if (!isNaN(boxOpacity) && boxOpacity > 0) {
13629
+ if (!flatFlag(this.flags, 'BOX') && !isNaN(boxOpacity) && boxOpacity > 0) {
12973
13630
  ctx.fillStyle = `rgba(255,255,255,${boxOpacity})`;
12974
13631
  ctx.fillRect(0, 0, W, H);
12975
13632
  }
@@ -13215,13 +13872,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13215
13872
  // async image decode, the whole drawing is synchronous
13216
13873
  const items = [];
13217
13874
  const defsSeen = new Set();
13875
+ // this drawing's defs, swapped in for the previous drawing's below —
13876
+ // appended to the shared pane <defs> on every draw they grew without
13877
+ // bound (and getElementById then found a stale earlier copy)
13878
+ const paneDefs = document.createElementNS(SVG_NS, 'g');
13218
13879
  let html = '';
13219
13880
  for (const d of combined) {
13220
13881
  const value = valueOf(d);
13221
13882
  // the item's class (range classes set it for groups too — a negative
13222
13883
  // group total would make dominant() pick the wrong class)
13223
13884
  const cat = d.properties.cat != null ? d.properties.cat : (d.properties.counts ? dominant(d.properties.counts) : 0);
13224
- const radiusPx = valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
13885
+ const radiusPx = valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue, this._sizeBound());
13225
13886
  const size = radiusPx / pxPerUnit;
13226
13887
  const chart = this._userChartSvg(draw, name, theme, flag, d, value, cat, size, sizeValueOf(d));
13227
13888
  if (!chart) continue;
@@ -13231,11 +13892,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13231
13892
  for (const id of chart.ids) {
13232
13893
  if (defsSeen.has(id)) continue;
13233
13894
  defsSeen.add(id);
13234
- const def = this._userChartGroup.ownerDocument.getElementById(id);
13235
- if (def) chartHost.defs.appendChild(def.cloneNode(true));
13895
+ // drawn now (fresh), else a cached chart's: the previous drawing's copy
13896
+ const def = this._userChartGroup.querySelector('#' + CSS.escape(id)) || this._userChartGroup.ownerDocument.getElementById(id);
13897
+ if (def) paneDefs.appendChild(def.cloneNode(true));
13236
13898
  }
13237
13899
  items.push({ d, coords: d.geometry.coordinates, ox: chart.ox, oy: chart.oy, s: pxPerUnit,
13238
- r: Math.max(chart.vw, chart.vh) * pxPerUnit / 2 });
13900
+ r: Math.max(chart.vw, chart.vh) * pxPerUnit / 2,
13901
+ // the chart's measured box (flat's getBox of the chart group) in px
13902
+ // around the map position: the pane draws a chart point at
13903
+ // p + s · (point − origin)
13904
+ ext: [(chart.bb.x - chart.ox) * pxPerUnit, (chart.bb.y - chart.oy) * pxPerUnit,
13905
+ (chart.bb.x + chart.bb.width - chart.ox) * pxPerUnit, (chart.bb.y + chart.bb.height - chart.oy) * pxPerUnit] });
13239
13906
  }
13240
13907
  if (glDebug()) console.info(`[ixmaps-gl debug] ${themeIdOf(this)}: zoom ${mapLibreToFlatZoom(liveZoom).toFixed(2)}, ${combined.length} charts, ${items.length} drawn${this._viewMoving ? ' (moving)' : ''}`);
13241
13908
  if (!items.length) {
@@ -13244,6 +13911,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13244
13911
  this._chartItems = null;
13245
13912
  return (this._userChartLastLayers = []);
13246
13913
  }
13914
+ chartHost.defs.appendChild(paneDefs);
13915
+ if (this._chartPaneDefs) this._chartPaneDefs.remove();
13916
+ this._chartPaneDefs = paneDefs;
13247
13917
  if (this._chartPaneGroup) this._chartPaneGroup.remove();
13248
13918
  this._chartPaneGroup = document.createElementNS(SVG_NS, 'g');
13249
13919
  chartHost.pane.appendChild(this._chartPaneGroup);
@@ -13252,11 +13922,24 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13252
13922
  for (let i = 0; i < items.length; i++) items[i].el = els[i];
13253
13923
  this._chartItems = items;
13254
13924
  chartHost.runtimes.add(this);
13925
+ this._chartHost = chartHost; // removeRuntimes takes it out again
13255
13926
  chartHost.update();
13256
13927
  // picking: a transparent circle per chart — the VISIBLE chart is the
13257
13928
  // live SVG in the pane; this deck layer only serves hover, click and
13258
13929
  // the tooltips (pickedFeature unwraps the {d, icon} entry shape)
13259
- this._userChartLastLayers = [new ScatterplotLayer({
13930
+ // BOX / TITLE: flat's chart-group box around the chart as the page's
13931
+ // function drew it (its measured box). The chart itself is a live SVG in
13932
+ // the pane above the deck canvas, so the box lies under it and the title
13933
+ // (outside the box) beside it
13934
+ const extOf = new Map(items.map(it => [it.d, it.ext]));
13935
+ const boxRows = this._boxRows(items.map(it => it.d), {
13936
+ unit: objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(this.style.scale) || 1), liveZoom, extentOf: d => extOf.get(d) });
13937
+ const boxLayers = boxRows ? this._boxTitleLayers(boxRows, {
13938
+ unit: objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(this.style.scale) || 1), liveZoom,
13939
+ positionOf: b => b.d.geometry.coordinates, offsetOf: () => [0, 0],
13940
+ ids: { circle: `ix-user-box-${this.name}`, icon: `ix-user-box-${this.name}`, polygons: `ix-user-box-polygons-${this.name}`, titles: `ix-user-titles-${this.name}` }
13941
+ }) : { box: [], titles: [] };
13942
+ this._userChartLastLayers = [...boxLayers.box, new ScatterplotLayer({
13260
13943
  id: `ix-bubbles-${this.name}`,
13261
13944
  data: items.map(it => ({ d: it.d, icon: true, r: it.r })),
13262
13945
  pickable: true,
@@ -13265,7 +13948,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13265
13948
  radiusUnits: 'pixels', radiusMinPixels: 1,
13266
13949
  stroked: false, filled: true, getFillColor: [0, 0, 0, 0],
13267
13950
  parameters: ICON_LAYER_GLOBE_PARAMETERS
13268
- })];
13951
+ }), ...boxLayers.titles];
13269
13952
  return this._userChartLastLayers;
13270
13953
  }
13271
13954
  // the chart's SVG, cached by its draw parameters — a failing draw
@@ -13320,7 +14003,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13320
14003
  const vx = bb.x - pad, vy = bb.y - pad, vw = bb.width + 2 * pad, vh = bb.height + 2 * pad;
13321
14004
  // the chart's origin (0,0 — or the point flat moves it to, ret) sits at the map position
13322
14005
  const ox = (ret && ret.x) || 0, oy = (ret && ret.y) || 0;
13323
- chart = { vx, vy, vw, vh, ox, oy, markup, ids: [...ids],
14006
+ chart = { vx, vy, vw, vh, ox, oy, markup, ids: [...ids], bb: { x: bb.x, y: bb.y, width: bb.width, height: bb.height },
13324
14007
  svg: `<svg xmlns="${SVG_NS}" width="__W__" height="__H__" viewBox="${vx} ${vy} ${vw} ${vh}"><defs>${defs}</defs>${markup}</svg>` };
13325
14008
  }
13326
14009
  } catch (e) {
@@ -13333,6 +14016,187 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13333
14016
  return chart;
13334
14017
  }
13335
14018
 
14019
+ // flat's BOX block belongs to the box: the box owns its TITLE — no BOX, no
14020
+ // title — so everything about both is decided here and in _boxRows, for
14021
+ // every chart type (single symbol, SEQUENCE, PIE, BAR): the gates (BOX /
14022
+ // TITLE flags, boxupper / boxlower / titleupper scale), the margin, the
14023
+ // title font (DTEXT follows the chart), BOTTOMTITLE, align, CIRCULARBOX.
14024
+ // null when there is no box at this zoom.
14025
+ _boxSpec(liveZoom) {
14026
+ const st = this.style;
14027
+ if (!(flatFlag(this.flags, 'BOX') && !boxHiddenByScale(st, liveZoom))) return null;
14028
+ const titleUpper = scaleDenom(st.titleupper);
14029
+ const titleScale = styleNum(st.textscale) || styleNum(st.valuescale) || 1;
14030
+ return {
14031
+ circular: flatFlag(this.flags, 'CIRCULAR'),
14032
+ margin: styleNum(st.boxmargin) || 2,
14033
+ bottomTitle: flatFlag(this.flags, 'BOTTOMTITLE'),
14034
+ alignRight: /right/.test(String(st.align || '')),
14035
+ titleShown: flatFlag(this.flags, 'TITLE') && !!this.binding.title
14036
+ && !(titleUpper && scaleDenominatorAt(liveZoom) > titleUpper),
14037
+ // DTEXT: the title font follows the chart, max(0.9u, min(W/5, 14u /
14038
+ // scale)) (18815) — measured on the fire areas page's SIF chart
14039
+ // (W 362 units → font 72.35)
14040
+ titleFontOf: (unit, w) => (flatFlag(this.flags, 'DTEXT')
14041
+ ? Math.max(0.9 * unit, Math.min(w / 5, 14 * unit / (styleNum(st.scale) || 1)))
14042
+ : 5 * unit) * titleScale
14043
+ };
14044
+ }
14045
+
14046
+ // the layout row of every item's box and title (chartBoxLayout + the spec
14047
+ // above); null without a box (items: the array, or a function building it
14048
+ // only when there is a box). o: { unit (px per flat unit), liveZoom,
14049
+ // extentOf (item -> its drawn [x0, y0, x1, y1], px around the anchor),
14050
+ // radiusOf / centerOf (the circular box's base, default the extent's) }.
14051
+ // A row: { d: item, geometry, properties, w (the chart's width — flat's
14052
+ // size measure W, the stroke and corner basis), boxR / cx / cy (circle),
14053
+ // rect (rectangle), title }
14054
+ _boxRows(items, o) {
14055
+ const spec = this._boxSpec(o.liveZoom);
14056
+ if (!spec) return null;
14057
+ // flat appends style.titleunits (its parse adds the leading space,
14058
+ // maptheme.js 1470; szTitle += at 18827)
14059
+ const units = this.style.titleunits ? ' ' + this.style.titleunits : '';
14060
+ return (typeof items === 'function' ? items() : items).map(d => {
14061
+ const itemTitle = spec.titleShown ? this._itemTitle(d.properties) : null;
14062
+ const L = chartBoxLayout(o.extentOf(d), { unit: o.unit, margin: spec.margin, circular: spec.circular,
14063
+ radius: o.radiusOf ? o.radiusOf(d) : undefined, center: o.centerOf ? o.centerOf(d) : undefined,
14064
+ titleText: itemTitle ? itemTitle + units : null, titleFont: w => spec.titleFontOf(o.unit, w),
14065
+ bottomTitle: spec.bottomTitle, alignRight: spec.alignRight });
14066
+ // geometry/properties of the chart's feature on the row: the circular
14067
+ // box is pickable, and the tooltip (buildTooltipHtml, the click-pin)
14068
+ // works on the feature's own properties
14069
+ return { d, geometry: d.geometry, properties: d.properties, w: L.w, boxR: L.boxR, cx: L.cx, cy: L.cy,
14070
+ rect: L.rect, title: L.title };
14071
+ });
14072
+ }
14073
+
14074
+ // THE box-and-title layers of every chart type: rows are _boxRows results.
14075
+ // o: { unit, liveZoom, positionOf (row -> [lng, lat]), offsetOf (row ->
14076
+ // the chart's [dx, dy] px move: align / offsetx / offsety), box (false:
14077
+ // the title layer only — a BAR draws its box as a shader shape), ids:
14078
+ // { circle, icon, polygons, titles } } — the layer ids stay each chart
14079
+ // type's own. Returns { box: [layers], titles: [layers] }: the caller
14080
+ // pushes them where its draw order wants them.
14081
+ _boxTitleLayers(rows, o) {
14082
+ const st = this.style;
14083
+ const { unit, liveZoom, positionOf, offsetOf, ids } = o;
14084
+ const circular = flatFlag(this.flags, 'CIRCULAR');
14085
+ const box = [], titles = [];
14086
+ // flat: fill-opacity (boxopacity || 1) on the raw style value — the usual
14087
+ // string "0" is transparent; typed here, a given 0 is too
14088
+ const boxOpacity = isNaN(styleNum(st.boxopacity)) ? 1 : styleNum(st.boxopacity);
14089
+ if (o.box === false) {
14090
+ // the title layer only
14091
+ } else if (circular) {
14092
+ const boxNone = isNoneColor(st.boxcolor), borderNone = isNoneColor(st.bordercolor);
14093
+ const boxRgb = hexOrNamedToRgb(boxNone ? '#eeeeee' : st.boxcolor || '#eeeeee');
14094
+ const boxAlpha = boxNone ? 0 : Math.round(255 * boxOpacity);
14095
+ const borderRgb = borderNone ? null : hexOrNamedToRgb(st.bordercolor || '#dddddd');
14096
+ // Always a ScatterplotLayer: its shader circle is smooth at any
14097
+ // size, where an icon — a 48px canvas texture deck.gl scales up —
14098
+ // pixelated with large boxes. ScatterplotLayer has no pixel offset,
14099
+ // so the chart's align/offsetx/offsety move and flat's circular box
14100
+ // position (the chart's bounding-box center, 18793) are baked into
14101
+ // map coordinates (pixelOffsetLngLat). Pickable: flat's box is part
14102
+ // of the chart group, so hovering it fires the chart's tooltip — the
14103
+ // symbols sit above it, so they keep their own
14104
+ box.push(new ScatterplotLayer({
14105
+ id: ids.circle,
14106
+ data: rows, pickable: true,
14107
+ getPosition: b => { const f = offsetOf(b); return pixelOffsetLngLat(positionOf(b), [f[0] + b.cx, f[1] + b.cy], liveZoom); },
14108
+ getRadius: b => b.boxR,
14109
+ radiusUnits: 'pixels',
14110
+ stroked: !borderNone,
14111
+ getFillColor: [...boxRgb.slice(0, 3), boxAlpha],
14112
+ getLineColor: [...(borderRgb || boxRgb).slice(0, 3), 255],
14113
+ getLineWidth: unit / 20,
14114
+ lineWidthUnits: 'pixels',
14115
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
14116
+ }));
14117
+ } else {
14118
+ // flat's rectangular box (the chart and its title plus the margin)
14119
+ const borderStyle = String(st.borderstyle || '');
14120
+ const boxLook = {
14121
+ fill: st.boxcolor || 'white',
14122
+ fillOpacity: boxOpacity,
14123
+ stroke: borderStyle === 'none' || isNoneColor(styleLineColor(st.bordercolor)) ? null : styleLineColor(st.bordercolor) || '#bbbbbb',
14124
+ strokeOpacity: /^(solid|dotted|dashed)$/.test(borderStyle) ? 1 : 0.3,
14125
+ };
14126
+ const borderWidth = styleNum(st.borderwidth) || 1;
14127
+ const borderRadius = styleNum(st.borderradius);
14128
+ const strokeOf = b => borderWidth * 0.2 * b.w / 30;
14129
+ // a box larger than BOX_ICON_MAX_PX (a MULTIQUAD grid of thousands
14130
+ // of items) would need a huge canvas: it is drawn as a polygon in
14131
+ // map coordinates instead (square corners)
14132
+ const bigBox = b => Math.max(b.rect[2] - b.rect[0], b.rect[3] - b.rect[1]) > BOX_ICON_MAX_PX;
14133
+ const iconBoxes = rows.filter(b => !bigBox(b)), polygonBoxes = rows.filter(bigBox);
14134
+ iconBoxes.forEach(b => {
14135
+ b.icon = this._buildBoxIcon(b.rect[2] - b.rect[0], b.rect[3] - b.rect[1],
14136
+ isNaN(borderRadius) ? 0 : borderRadius * Math.min(unit, b.w / 30),
14137
+ strokeOf(b), boxLook);
14138
+ });
14139
+ if (polygonBoxes.length) {
14140
+ const fillRgb = hexOrNamedToRgb(boxLook.fill);
14141
+ const strokeRgb = boxLook.stroke ? hexOrNamedToRgb(boxLook.stroke) : null;
14142
+ box.push(new GeoJsonLayer({
14143
+ id: ids.polygons,
14144
+ data: polygonBoxes.map(b => {
14145
+ const f = offsetOf(b), c = positionOf(b), r = b.rect;
14146
+ const at = (x, y) => pixelOffsetLngLat(c, [x + f[0], y + f[1]], liveZoom);
14147
+ return { type: 'Feature', properties: { stroke: strokeOf(b) },
14148
+ geometry: { type: 'Polygon', coordinates: [[at(r[0], r[1]), at(r[2], r[1]), at(r[2], r[3]), at(r[0], r[3]), at(r[0], r[1])]] } };
14149
+ }),
14150
+ pickable: false,
14151
+ filled: boxLook.fillOpacity > 0,
14152
+ stroked: !!strokeRgb,
14153
+ getFillColor: [...fillRgb.slice(0, 3), Math.round(255 * boxLook.fillOpacity)],
14154
+ getLineColor: strokeRgb ? [...strokeRgb.slice(0, 3), Math.round(255 * boxLook.strokeOpacity)] : [0, 0, 0, 0],
14155
+ getLineWidth: f => f.properties.stroke,
14156
+ lineWidthUnits: 'pixels',
14157
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
14158
+ }));
14159
+ }
14160
+ if (iconBoxes.length) box.push(new IconLayer({
14161
+ // the generation suffix (like every icon-atlas layer id, see
14162
+ // ICON_ATLAS_RESET_AFTER): every zoom creates a new set of box
14163
+ // rasters, and deck.gl's atlas never evicts
14164
+ id: `${ids.icon}-g${this._iconGeneration}`,
14165
+ data: iconBoxes, pickable: false,
14166
+ getPosition: positionOf,
14167
+ getIcon: b => b.icon,
14168
+ // the icon is drawn at 2× with a stroke padding around the box
14169
+ getSize: b => b.icon.height / 2,
14170
+ getPixelOffset: b => { const f = offsetOf(b); return [(b.rect[0] + b.rect[2]) / 2 + f[0], (b.rect[1] + b.rect[3]) / 2 + f[1]]; },
14171
+ sizeUnits: 'pixels',
14172
+ billboard: true,
14173
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
14174
+ }));
14175
+ }
14176
+ const titleData = rows.filter(b => b.title);
14177
+ if (titleData.length) titles.push(new TextLayer({
14178
+ id: ids.titles,
14179
+ data: titleData, pickable: false,
14180
+ getPosition: positionOf,
14181
+ getText: b => b.title.text,
14182
+ getSize: b => b.title.f,
14183
+ sizeUnits: 'pixels',
14184
+ fontFamily: 'arial',
14185
+ characterSet: 'auto',
14186
+ lineHeight: 1,
14187
+ getColor: [...hexOrNamedToRgb(st.textcolor || '#888888').slice(0, 3), 255],
14188
+ fontSettings: { sdf: true },
14189
+ outlineWidth: 1 / 7,
14190
+ outlineColor: [255, 255, 255, 128],
14191
+ getTextAnchor: b => b.title.anchor,
14192
+ // top of the text ≈ first baseline − 0.8 font
14193
+ getAlignmentBaseline: 'top',
14194
+ getPixelOffset: b => { const f = offsetOf(b); return [b.title.x + f[0], b.title.top + f[1]]; },
14195
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
14196
+ }));
14197
+ return { box, titles };
14198
+ }
14199
+
13336
14200
  // BOX (see the BOX block in _buildChartLayers): a rectangle icon
13337
14201
  // w×h px, rounded corners rx, stroke sw px; drawn at twice the size
13338
14202
  _buildBoxIcon(w, h, rx, sw, look) {
@@ -13503,7 +14367,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13503
14367
  const groups = doRelocate ? this._groupCoLocated(clusterFeatures, effectiveZoom) : clusterFeatures.map(f => {
13504
14368
  return {
13505
14369
  geometry: f.geometry,
13506
- properties: { counts: this._oneHot(f.properties.cat, f.properties.point_count), total: f.properties.value,
14370
+ // counts is the summed bound VALUE and recordCounts the record
14371
+ // count, as groupCoLocated builds them (RELOCATE) — RELOCATE
14372
+ // moves the item, it never changes a value
14373
+ properties: { counts: this._oneHot(f.properties.cat, f.properties.value),
14374
+ recordCounts: this._oneHot(f.properties.cat, f.properties.point_count), total: f.properties.value,
13507
14375
  ...(f.properties.classValue !== undefined ? { classTotal: f.properties.classValue } : {}),
13508
14376
  ...(f.properties.titleRaw ? { titleRaw: f.properties.titleRaw } : {}) }
13509
14377
  };
@@ -13654,7 +14522,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13654
14522
  // GRIDSIZE: every chart the size of its grid cell, whatever its value
13655
14523
  // (_gridChartRadiusPx); otherwise valueRadius
13656
14524
  const gridR = this._gridChartRadiusPx(zoom, liveZoom);
13657
- const vRadius = v => (gridR != null ? gridR : valueRadius(v, liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue));
14525
+ const vRadius = v => (gridR != null ? gridR : valueRadius(v, liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue, this._sizeBound()));
13658
14526
  // AGGREGATE sums without a normalsizevalue: flat's default is the
13659
14527
  // largest aggregated value (defaultNormalSizeValue leaves it unset, the
13660
14528
  // sizes depend on the aggregation)
@@ -13922,15 +14790,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13922
14790
  // DTEXT: the title font follows the chart, max(0.9u, min(W/5,
13923
14791
  // 14u / scale)) (18815) — measured on the fire areas page's SIF chart
13924
14792
  // (W 362 units → font 72.35).
13925
- const boxShown = flatFlag(this.flags, 'BOX') && !boxHiddenByScale(this.style, liveZoom);
13926
- const circular = flatFlag(this.flags, 'CIRCULAR');
13927
14793
  const unitPx = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(this.style.scale) || 1);
13928
- const boxMarginU = styleNum(this.style.boxmargin) || 2;
13929
- const titleShown = boxShown && flatFlag(this.flags, 'TITLE') && !!this.binding.title;
13930
- const titleScale = styleNum(this.style.textscale) || styleNum(this.style.valuescale) || 1;
13931
- const titleFontOf = W => (flatFlag(this.flags, 'DTEXT')
13932
- ? Math.max(0.9 * unitPx, Math.min(W / 5, 14 * unitPx / (styleNum(this.style.scale) || 1)))
13933
- : 5 * unitPx) * titleScale;
13934
14794
  // the chart's value text and label (see VALUES and LABEL below), shared
13935
14795
  // with the box: flat's box encloses whatever the chart group draws
13936
14796
  const valuesShown = this.flags.has('VALUES') && !valuesHiddenByScale(this.style, zoom);
@@ -13938,141 +14798,28 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13938
14798
  const labelShown = !!this.style.labelfield && !flatFlag(this.flags, 'NORMSIZE') && !flatFlag(this.flags, 'ZOOM')
13939
14799
  && !labelHiddenByScale(this.style, zoom);
13940
14800
  const labelOf = labelShown ? this._chartLabelFn(unitPx) : null;
13941
- // flat: fill-opacity (boxopacity || 1) on the raw style value — the
13942
- // usual string "0" is transparent; typed here, a given 0 is too
13943
- const boxOpacity = isNaN(styleNum(this.style.boxopacity)) ? 1 : styleNum(this.style.boxopacity);
13944
- const bottomTitle = flatFlag(this.flags, 'BOTTOMTITLE');
13945
- const alignRight = /right/.test(String(this.style.align || ''));
13946
- // with MULTIQUAD only the first item of a position gets its box/title
13947
- const boxLayout = boxShown ? combined.filter(d => !multi || multi.index.get(d) === 0).map(d => {
13948
- const r = vRadius(sizeValueOf(d));
13949
- const ext = multi ? multi.extent.get(d) : [0, 0, 0, 0];
13950
- const chart = [ext[0] - r, ext[1] - r, ext[2] + r, ext[3] + r];
13951
- const grow = e => { if (!e) return; chart[0] = Math.min(chart[0], e[0]); chart[1] = Math.min(chart[1], e[1]); chart[2] = Math.max(chart[2], e[2]); chart[3] = Math.max(chart[3], e[3]); };
13952
- if (valueTextOf && !d.properties.counts) { const v = valueTextOf(d, r); if (v) grow(v.extent); }
13953
- if (labelOf) { const l = labelOf(d, r); if (l) grow(l.extent); }
13954
- const w = chart[2] - chart[0];
13955
- const margin = Math.min(2 * boxMarginU * unitPx, boxMarginU * w / 30);
13956
- const boxR = circular ? r * 1.1 + margin : r;
13957
- const titleFontPx = titleFontOf(w);
13958
- let title = null;
13959
- if (titleShown) {
13960
- // flat appends style.titleunits (its parse adds the leading
13961
- // space, maptheme.js 1470; szTitle += at 18827)
13962
- const units = this.style.titleunits ? ' ' + this.style.titleunits : '';
13963
- const text = this._itemTitle(d.properties) ? this._itemTitle(d.properties) + units : null;
13964
- if (text) {
13965
- const f = titleFontPx;
13966
- const lines = wrapTitleLines(text, f, circular ? boxR * 2 : w).split('\n');
13967
- const width = Math.max(...lines.map(l => titleTextWidth(l, f)));
13968
- // first baseline; lines go down one font each
13969
- const base = circular
13970
- ? (bottomTitle ? boxR + f : -boxR - 0.7 * f - (lines.length - 1) * f)
13971
- : (bottomTitle ? chart[3] + f : chart[1] - 0.7 * f - (lines.length - 1) * f);
13972
- const x = circular ? unitPx / 2 : (alignRight ? chart[2] : chart[0]);
13973
- const anchor = circular ? 'middle' : (alignRight ? 'end' : 'start');
13974
- const x0 = anchor === 'middle' ? x - width / 2 : anchor === 'end' ? x - width : x;
13975
- // arial: ascent 0.905, descent 0.212 of the font size
13976
- title = { text: lines.join('\n'), x, anchor, top: base - 0.8 * f, f,
13977
- extent: [x0, base - 0.905 * f, x0 + width, base + (lines.length - 1) * f + 0.212 * f] };
13978
- }
13979
- }
13980
- let rect = null;
13981
- if (!circular) {
13982
- const e = chart.slice();
13983
- // with a title flat trims 0.1 font off the top of chart + title
13984
- if (title) { e[0] = Math.min(e[0], title.extent[0]); e[1] = Math.min(e[1], title.extent[1]) + 0.1 * titleFontPx; e[2] = Math.max(e[2], title.extent[2]); e[3] = Math.max(e[3], title.extent[3]); }
13985
- rect = [e[0] - margin, e[1] - margin, e[2] + margin, e[3] + margin];
14801
+ // with MULTIQUAD only the first item of a position gets its box/title;
14802
+ // the circle sits on the anchor (0, 0), its base radius the symbol's
14803
+ const boxLayout = this._boxRows(() => combined.filter(d => !multi || multi.index.get(d) === 0), {
14804
+ unit: unitPx, liveZoom,
14805
+ radiusOf: d => vRadius(sizeValueOf(d)), centerOf: () => [0, 0],
14806
+ extentOf: d => {
14807
+ const r = vRadius(sizeValueOf(d));
14808
+ const ext = multi ? multi.extent.get(d) : [0, 0, 0, 0];
14809
+ const chart = [ext[0] - r, ext[1] - r, ext[2] + r, ext[3] + r];
14810
+ const grow = e => { if (!e) return; chart[0] = Math.min(chart[0], e[0]); chart[1] = Math.min(chart[1], e[1]); chart[2] = Math.max(chart[2], e[2]); chart[3] = Math.max(chart[3], e[3]); };
14811
+ if (valueTextOf && !d.properties.counts) { const v = valueTextOf(d, r); if (v) grow(v.extent); }
14812
+ if (labelOf) { const l = labelOf(d, r); if (l) grow(l.extent); }
14813
+ return chart;
13986
14814
  }
13987
- return { d, r, w, boxR, rect, title };
14815
+ });
14816
+ // (the title layers are pushed after the symbols, below)
14817
+ const boxLayers = boxLayout ? this._boxTitleLayers(boxLayout, {
14818
+ unit: unitPx, liveZoom,
14819
+ positionOf: b => b.d.geometry.coordinates, offsetOf: b => alignOf(b.d),
14820
+ ids: { circle: `ix-box-${this.name}`, icon: `ix-box-${this.name}`, polygons: `ix-box-polygons-${this.name}`, titles: `ix-titles-${this.name}` }
13988
14821
  }) : null;
13989
- if (boxShown && circular) {
13990
- const boxNone = isNoneColor(this.style.boxcolor), borderNone = isNoneColor(this.style.bordercolor);
13991
- const boxRgb = hexOrNamedToRgb(boxNone ? '#eeeeee' : this.style.boxcolor || '#eeeeee');
13992
- const boxAlpha = boxNone ? 0 : Math.round(255 * boxOpacity);
13993
- const borderRgb = borderNone ? null : hexOrNamedToRgb(this.style.bordercolor || '#dddddd');
13994
- // ScatterplotLayer has no pixel offset: a moved chart (align,
13995
- // offsetx/offsety) gets its circle as an icon
13996
- if (alignOf.active) layers.push(new IconLayer({
13997
- id: `ix-box-${this.name}-g${this._iconGeneration}`,
13998
- data: boxLayout, pickable: false,
13999
- getPosition: b => b.d.geometry.coordinates,
14000
- getIcon: b => this._buildSingleIcon(boxRgb, boxAlpha / 255, 'circle', borderRgb,
14001
- borderNone ? 0 : Math.round(unitPx / 20 * BUBBLE_ICON_SIZE / Math.max(1, 2 * b.boxR) * 4) / 4),
14002
- getSize: b => b.boxR * 2,
14003
- getPixelOffset: b => alignOf(b.d),
14004
- sizeUnits: 'pixels',
14005
- billboard: true,
14006
- parameters: ICON_LAYER_GLOBE_PARAMETERS
14007
- }));
14008
- else layers.push(new ScatterplotLayer({
14009
- id: `ix-box-${this.name}`,
14010
- data: boxLayout, pickable: false,
14011
- getPosition: b => b.d.geometry.coordinates,
14012
- getRadius: b => b.boxR,
14013
- radiusUnits: 'pixels',
14014
- stroked: !borderNone,
14015
- getFillColor: [...boxRgb.slice(0, 3), boxAlpha],
14016
- getLineColor: [...(borderRgb || boxRgb).slice(0, 3), 255],
14017
- getLineWidth: unitPx / 20,
14018
- lineWidthUnits: 'pixels',
14019
- parameters: ICON_LAYER_GLOBE_PARAMETERS
14020
- }));
14021
- } else if (boxShown) {
14022
- const borderStyle = String(this.style.borderstyle || '');
14023
- const boxLook = {
14024
- fill: this.style.boxcolor || 'white',
14025
- fillOpacity: boxOpacity,
14026
- stroke: borderStyle === 'none' || isNoneColor(styleLineColor(this.style.bordercolor)) ? null : styleLineColor(this.style.bordercolor) || '#bbbbbb',
14027
- strokeOpacity: /^(solid|dotted|dashed)$/.test(borderStyle) ? 1 : 0.3,
14028
- };
14029
- const borderWidth = styleNum(this.style.borderwidth) || 1;
14030
- const borderRadius = styleNum(this.style.borderradius);
14031
- const strokeOf = b => borderWidth * 0.2 * b.w / 30;
14032
- // a box larger than BOX_ICON_MAX_PX (a MULTIQUAD grid of thousands
14033
- // of items) would need a huge canvas: it is drawn as a polygon in
14034
- // map coordinates instead (square corners)
14035
- const bigBox = b => Math.max(b.rect[2] - b.rect[0], b.rect[3] - b.rect[1]) > BOX_ICON_MAX_PX;
14036
- const iconBoxes = boxLayout.filter(b => !bigBox(b)), polygonBoxes = boxLayout.filter(bigBox);
14037
- iconBoxes.forEach(b => {
14038
- b.icon = this._buildBoxIcon(b.rect[2] - b.rect[0], b.rect[3] - b.rect[1],
14039
- isNaN(borderRadius) ? 0 : borderRadius * Math.min(unitPx, b.w / 30),
14040
- strokeOf(b), boxLook);
14041
- });
14042
- if (polygonBoxes.length) {
14043
- const fillRgb = hexOrNamedToRgb(boxLook.fill);
14044
- const strokeRgb = boxLook.stroke ? hexOrNamedToRgb(boxLook.stroke) : null;
14045
- layers.push(new GeoJsonLayer({
14046
- id: `ix-box-polygons-${this.name}`,
14047
- data: polygonBoxes.map(b => {
14048
- const o = alignOf(b.d), c = b.d.geometry.coordinates, r = b.rect;
14049
- const at = (x, y) => pixelOffsetLngLat(c, [x + o[0], y + o[1]], liveZoom);
14050
- return { type: 'Feature', properties: { stroke: strokeOf(b) },
14051
- geometry: { type: 'Polygon', coordinates: [[at(r[0], r[1]), at(r[2], r[1]), at(r[2], r[3]), at(r[0], r[3]), at(r[0], r[1])]] } };
14052
- }),
14053
- pickable: false,
14054
- filled: boxLook.fillOpacity > 0,
14055
- stroked: !!strokeRgb,
14056
- getFillColor: [...fillRgb.slice(0, 3), Math.round(255 * boxLook.fillOpacity)],
14057
- getLineColor: strokeRgb ? [...strokeRgb.slice(0, 3), Math.round(255 * boxLook.strokeOpacity)] : [0, 0, 0, 0],
14058
- getLineWidth: f => f.properties.stroke,
14059
- lineWidthUnits: 'pixels',
14060
- parameters: ICON_LAYER_GLOBE_PARAMETERS
14061
- }));
14062
- }
14063
- if (iconBoxes.length) layers.push(new IconLayer({
14064
- id: `ix-box-${this.name}-g${this._iconGeneration}`,
14065
- data: iconBoxes, pickable: false,
14066
- getPosition: b => b.d.geometry.coordinates,
14067
- getIcon: b => b.icon,
14068
- // the icon is drawn at 2× with a stroke padding around the box
14069
- getSize: b => b.icon.height / 2,
14070
- getPixelOffset: b => { const o = alignOf(b.d); return [(b.rect[0] + b.rect[2]) / 2 + o[0], (b.rect[1] + b.rect[3]) / 2 + o[1]]; },
14071
- sizeUnits: 'pixels',
14072
- billboard: true,
14073
- parameters: ICON_LAYER_GLOBE_PARAMETERS
14074
- }));
14075
- }
14822
+ if (boxLayers) layers.push(...boxLayers.box);
14076
14823
 
14077
14824
  // flat's per-part SYMBOL branch (everything but CATEGORICAL without
14078
14825
  // AGGREGATE/GROUP) draws a square or roundrect of side 2r, the other
@@ -14095,7 +14842,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14095
14842
  if (!flatLook) return this._buildSingleIcon(rgb, fillOpacity, shapeOf(d.properties), singleBorderColorRgb, singleBorderWidthPx);
14096
14843
  const look = flatLook(d);
14097
14844
  return this._buildSingleIcon(rgb, look.fillOpacity, shapeOf(d.properties),
14098
- look.noStroke || !rgb ? null : (look.stroke || flatDerivateRgb(rgb, 0.7)), look.width, look.strokeOpacity);
14845
+ bubbleStrokeRgb(look, rgb), look.width, look.strokeOpacity);
14099
14846
  };
14100
14847
  // flat's symbol outline (maptheme.js 21595-21610 and 21950-21997,
14101
14848
  // drawn 21625-21634 / 21995-22035): every SYMBOL shape is stroked,
@@ -14130,7 +14877,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14130
14877
  // routing goes by the final id (layerRuntimeById). A symbol's outline
14131
14878
  // is centered on its radius (flat); BUBBLE's border keeps the
14132
14879
  // icon's look, inside the radius
14133
- if (combined.length && !alignOf.active && combined.every(d => singleSymbol(d) && shapeOf(d.properties) === 'circle')) {
14880
+ if (combined.length && !alignOf.active && combined.every(d => singleSymbol(d) && shapeOf(d.properties) === 'circle' && !this._resolveSymbolUrl(d.properties))) {
14134
14881
  const radiusPx = d => vRadius(sizeValueOf(d));
14135
14882
  // a CATEGORICAL BUBBLE: flat's look (flatBubbleLook) in screen px,
14136
14883
  // its stroke centered on the radius as flat's SVG circle
@@ -14159,6 +14906,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14159
14906
  getPosition: d => d.geometry.coordinates,
14160
14907
  getIcon: d => {
14161
14908
  if (!singleSymbol(d)) return this._buildBubbleIcon(d.properties.counts, this.categoryColorsRgb, this._groupIsolation(d), Array.isArray(this.style.symbols) ? this.style.symbols : null, flatLook && flatLook(d));
14909
+ const symbolUrl = this._resolveSymbolUrl(d.properties);
14910
+ if (symbolUrl) return this._urlSymbolIcon(symbolUrl);
14162
14911
  if (flatLook) return singleIcon(d);
14163
14912
  const shape = shapeOf(d.properties), rgb = outlineRgb(d);
14164
14913
  if (!symbolOutline || !rgb) return this._buildSingleIcon((this.categoryColorsRgb || [])[d.properties.cat], fillOpacity, shape, rgb, rgb ? singleBorderWidthPx : 0);
@@ -14180,29 +14929,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14180
14929
  parameters: ICON_LAYER_GLOBE_PARAMETERS
14181
14930
  }));
14182
14931
 
14183
- const titleData = boxLayout ? boxLayout.filter(b => b.title) : [];
14184
- if (titleData.length) {
14185
- layers.push(new TextLayer({
14186
- id: `ix-titles-${this.name}`,
14187
- data: titleData, pickable: false,
14188
- getPosition: b => b.d.geometry.coordinates,
14189
- getText: b => b.title.text,
14190
- getSize: b => b.title.f,
14191
- sizeUnits: 'pixels',
14192
- fontFamily: 'arial',
14193
- characterSet: 'auto',
14194
- lineHeight: 1,
14195
- getColor: [...hexOrNamedToRgb(this.style.textcolor || '#888888').slice(0, 3), 255],
14196
- fontSettings: { sdf: true },
14197
- outlineWidth: 1 / 7,
14198
- outlineColor: [255, 255, 255, 128],
14199
- getTextAnchor: b => b.title.anchor,
14200
- // top of the text ≈ first baseline − 0.8 font
14201
- getAlignmentBaseline: 'top',
14202
- getPixelOffset: b => { const o = alignOf(b.d); return [b.title.x + o[0], b.title.top + o[1]]; },
14203
- parameters: ICON_LAYER_GLOBE_PARAMETERS
14204
- }));
14205
- }
14932
+ if (boxLayers) layers.push(...boxLayers.titles);
14206
14933
 
14207
14934
  // VALUES: bold value label centered on each bubble (see
14208
14935
  // flatValueText/valuesFontSizePx above). Labels are their own
@@ -14470,63 +15197,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14470
15197
  // it itself (its ImageBitmap loader falls back to <img>, which
14471
15198
  // rasterizes SVG) — NOT tinted: these files carry their own colors,
14472
15199
  // and flat doesn't recolor an <image> either
14473
- const urlSymbolOf = i => {
14474
- const s = symbols && (symbols[i] || symbols[0]);
14475
- return typeof s === 'string' && /\.(svg|png|jpe?g)([?#]|$)/i.test(s.trim()) ? s.trim() : null;
14476
- };
14477
- if (!this._urlIconCache) this._urlIconCache = new Map();
14478
- const urlIcon = u => {
14479
- if (!this._urlIconCache.has(u)) {
14480
- // the placeholder is a transparent 1x1 PNG, NOT the raw symbol
14481
- // URL: deck.gl would immediately try to load that itself, and an
14482
- // SVG without natural dimensions (a viewBox only, the bus
14483
- // pictogram of the City-moving page) makes Chrome's
14484
- // createImageBitmap throw — a console error and a part that
14485
- // never renders. The real raster replaces it below
14486
- const entry = { url: TRANSPARENT_1PX_PNG, width: 256, height: 256, anchorX: 128, anchorY: 128, mask: false, id: 'seq-sym-pending:' + u };
14487
- this._urlIconCache.set(u, entry);
14488
- // fetch the symbol ONCE and rasterize it into the descriptor's
14489
- // own 256px cell here: deck then always sees exactly the declared
14490
- // dimensions, and dimension-less SVGs work like any other. The
14491
- // redraw lets the next getIcon see the patched descriptor
14492
- fetch(u).then(r => r.ok ? r.text() : null).then(text => new Promise((resolve, reject) => {
14493
- if (typeof text !== 'string' || !/<svg[\s>]/i.test(text)) { reject(new Error('not svg')); return; }
14494
- if (!/\swidth\s*=/i.test(text) || !/\sheight\s*=/i.test(text)) {
14495
- const vb = text.match(/viewBox\s*=\s*["']([^"']*)["']/i);
14496
- const p = vb && vb[1].trim().split(/[\s,]+/).map(Number);
14497
- if (p && p.length === 4 && isFinite(p[2]) && p[2] > 0 && isFinite(p[3]) && p[3] > 0) {
14498
- text = text.replace(/<svg/i, '<svg width="' + p[2] + '" height="' + p[3] + '"');
14499
- }
14500
- }
14501
- const img = new Image();
14502
- img.onload = () => resolve(img);
14503
- img.onerror = () => reject(new Error('svg decode failed'));
14504
- img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(text);
14505
- })).then(img => {
14506
- const size = entry.width;
14507
- const canvas = document.createElement('canvas');
14508
- canvas.width = size; canvas.height = size;
14509
- const ctx = canvas.getContext('2d');
14510
- const s = Math.min(size / (img.naturalWidth || size), size / (img.naturalHeight || size));
14511
- const dw = (img.naturalWidth || size) * s, dh = (img.naturalHeight || size) * s;
14512
- ctx.drawImage(img, (size - dw) / 2, (size - dh) / 2, dw, dh);
14513
- entry.url = canvas.toDataURL('image/png');
14514
- entry.id = 'seq-sym-raster:' + u;
14515
- if (this._triggerRedraw) this._triggerRedraw();
14516
- }).catch(() => {
14517
- // not an SVG (a PNG/JPEG symbol): deck's own URL loading takes
14518
- // it from here — and a pre-sized SVG that failed to rasterize
14519
- // gets that same second chance
14520
- entry.url = u;
14521
- entry.id = 'seq-sym-natural:' + u;
14522
- if (this._triggerRedraw) this._triggerRedraw();
14523
- });
14524
- }
14525
- return this._urlIconCache.get(u);
14526
- };
15200
+ const urlSymbolOf = i => this._urlSymbolOf(symbols && (symbols[i] || symbols[0]));
15201
+ const urlIcon = u => this._urlSymbolIcon(u);
14527
15202
  const shapeOf = i => { const sh = symbols && (symbols[i] || symbols[0]); return normalizeSymbolShape(sh); };
14528
15203
  const colorOf = i => this.classRgb(i);
14529
- const parts = [], glows = [];
15204
+ const allParts = [], allGlows = [];
14530
15205
  for (const d of charts) {
14531
15206
  // parts: the record's own field values (a per-item chart, or the
14532
15207
  // single-record cell of an aggregated one); series: a multi-record
@@ -14538,16 +15213,34 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14538
15213
  const sum = counts.reduce((a, v) => a + (v || 0), 0);
14539
15214
  counts = counts.map(v => sum > 0 ? v / (sum / 100) : 0);
14540
15215
  }
15216
+ // flat's aggregated CATEGORICAL item holds a value per category only up
15217
+ // to the highest category it has (measured on flat: the chart's star and
15218
+ // glow follow the categories present as 0..k, 985 of 988 charts of the
15219
+ // concessioni page): trailing zeros are no parts, so with SORT|UP the
15220
+ // smallest part — order index 0, the one that sets the star radius and
15221
+ // carries the glow — is a zero only when a category BELOW the highest
15222
+ // one is missing
15223
+ if (!d.properties.parts && !d.properties.series) {
15224
+ let n = counts.length;
15225
+ while (n > 0 && !counts[n - 1]) n--;
15226
+ if (n < counts.length) counts = counts.slice(0, n);
15227
+ }
14541
15228
  const sizeFactor = itemSizeFactor(d);
14542
15229
  const partRadius = multi
14543
15230
  ? v => Math.max(VALUE_RADIUS_MIN, normalR * Math.pow(Math.abs(v) / seqMax, 1 / sizePow) * sizeFactor)
14544
- : v => valueRadius(v, liveZoom, st, this.mapOptions, this.flags, seqMax);
15231
+ : v => valueRadius(v, liveZoom, st, this.mapOptions, this.flags, seqMax, this._sizeBound());
14545
15232
  let layout = rings ? ringsLayout(counts, partRadius)
14546
15233
  : sequenceLayout(counts, this.flags, partRadius, { maxRadius: normalR, maxValue: seqMax, nParts: nCats, rangeScale: styleNum(st.rangescale) || 0 });
14547
15234
  if (!layout.length) continue;
14548
15235
  // GLOW belongs to the first part (sorted) or to every part, and
14549
15236
  // hides with it when its class is not marked
14550
- const glowParts = new Set(sorted ? [layout[0].i] : layout.map(p => p.i));
15237
+ // every chart glows (deliberately NOT flat's rule, which gives the glow to
15238
+ // the part at sorted order index 0 — a zero, skipped, whenever a category
15239
+ // below the highest is missing — so only about a quarter of flat's charts
15240
+ // glow): a sorted chart's glow belongs to its biggest part, an unsorted
15241
+ // one glows with every part as in flat
15242
+ const biggest = layout.reduce((m, p) => (p.v > m.v ? p : m), layout[0]);
15243
+ const glowParts = new Set(sorted ? [biggest.i] : layout.map(p => p.i));
14551
15244
  // the chart's drawn extent (flat: map.Dom.getBox of its shape
14552
15245
  // group) — the parts' star cluster — for BOX / CIRCULARBOX sizing
14553
15246
  let ex0 = Infinity, ey0 = Infinity, ex1 = -Infinity, ey1 = -Infinity;
@@ -14564,12 +15257,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14564
15257
  d._seqLast = layout[layout.length - 1].r;
14565
15258
  const items = layout.map(p => {
14566
15259
  const item = { geometry: d.geometry, properties: d.properties, chart: d, part: p };
14567
- if (glowParts.has(p.i)) glows.push(item);
15260
+ if (glowParts.has(p.i)) allGlows.push(item);
14568
15261
  return item;
14569
15262
  });
14570
15263
  // STAR: the first part (its center) is put on top at the end (23145)
14571
15264
  if (star && items.length > 1) items.push(items.shift());
14572
- parts.push(...items);
15265
+ allParts.push(...items);
14573
15266
  }
14574
15267
  const alignOf = this._chartAlignFn(liveZoom, d => (d.chart || d)._seqLast || 0);
14575
15268
  const offsetOf = item => { const o = alignOf(item.chart); return [o[0] + item.part.x, o[1] + item.part.y]; };
@@ -14587,287 +15280,227 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14587
15280
  // max(0.9u, min(w/5, 14u/scale)) · textscale, wrapped to the
14588
15281
  // circle's width — gated by titleupper (shown while the map
14589
15282
  // scale is that bound or coarser, 18810) and boxupper/boxlower
14590
- const boxShown = flatFlag(this.flags, 'BOX') && !boxHiddenByScale(st, liveZoom);
15283
+ // (flat centers the circular box and its title on the chart's
15284
+ // bounding-box CENTER — maptheme.js 18793-18798 — not on its anchor: a
15285
+ // star cluster's satellites spread asymmetrically around it; the
15286
+ // circle's title baselines are measured on the resized box, 18798-18830)
14591
15287
  const circular = flatFlag(this.flags, 'CIRCULAR');
14592
- const bottomTitle = flatFlag(this.flags, 'BOTTOMTITLE');
14593
- const titleUpper = scaleDenom(st.titleupper);
14594
- const titleShown = boxShown && flatFlag(this.flags, 'TITLE') && !!this.binding.title
14595
- && !(titleUpper && scaleDenominatorAt(liveZoom) > titleUpper);
14596
- let boxLayout = null;
14597
- if (boxShown) {
14598
- const boxMarginU = styleNum(st.boxmargin) || 2;
14599
- const boxOpacity = isNaN(styleNum(st.boxopacity)) ? 1 : styleNum(st.boxopacity);
14600
- const titleScale = styleNum(st.textscale) || styleNum(st.valuescale) || 1;
14601
- const titleFontOf = w => (flatFlag(this.flags, 'DTEXT')
14602
- ? Math.max(0.9 * unit, Math.min(w / 5, 14 * unit / (styleNum(st.scale) || 1)))
14603
- : 5 * unit) * titleScale;
14604
- const alignRight = /right/.test(String(st.align || ''));
14605
- boxLayout = charts.filter(d => d._seqExtent).map(d => {
14606
- const [ex0, ey0, ex1, ey1] = d._seqExtent;
14607
- const w = ex1 - ex0, h = ey1 - ey0;
14608
- // flat centers the circular box (and its title) on the chart's
14609
- // bounding-box CENTER (maptheme.js 18793-18798: center =
14610
- // bBox.x + bBox.width/2, bBox.y + bBox.height/2), not on the
14611
- // chart's anchor — a star cluster's satellites spread
14612
- // asymmetrically around it
14613
- const cx = (ex0 + ex1) / 2, cy = (ey0 + ey1) / 2;
14614
- const margin = Math.min(2 * boxMarginU * unit, boxMarginU * w / 30);
14615
- const boxR = circular ? Math.max(w, h) / 2 * 1.1 + margin : 0;
14616
- let title = null;
14617
- if (titleShown) {
14618
- // flat appends style.titleunits to the box title (its parse
14619
- // gives it the leading space, maptheme.js 1470; szTitle += at
14620
- // 18827): "27.5" + "km/h" -> "27.5 km/h"
14621
- const units = st.titleunits ? ' ' + st.titleunits : '';
14622
- const text = this._itemTitle(d.properties) ? this._itemTitle(d.properties) + units : null;
14623
- if (text) {
14624
- const f = titleFontOf(w);
14625
- const lines = wrapTitleLines(text, f, circular ? boxR * 2 : w).split('\n');
14626
- const width = Math.max(...lines.map(l => titleTextWidth(l, f)));
14627
- // flat's title baselines are measured on the resized box
14628
- // (the circle spans center ± radius, 18798-18830)
14629
- const base = circular
14630
- ? (bottomTitle ? cy + boxR + f : cy - boxR - 0.7 * f - (lines.length - 1) * f)
14631
- : (bottomTitle ? ey1 + f : ey0 - 0.7 * f - (lines.length - 1) * f);
14632
- const x = circular ? cx + unit / 2 : (alignRight ? ex1 : ex0);
14633
- const anchor = circular ? 'middle' : (alignRight ? 'end' : 'start');
14634
- const x0 = anchor === 'middle' ? x - width / 2 : anchor === 'end' ? x - width : x;
14635
- title = { text: lines.join('\n'), x, anchor, top: base - 0.8 * f, f,
14636
- extent: [x0, base - 0.905 * f, x0 + width, base + (lines.length - 1) * f + 0.212 * f] };
14637
- }
14638
- }
14639
- let rect = null;
14640
- if (!circular) {
14641
- const e = [ex0, ey0, ex1, ey1];
14642
- if (title) { e[0] = Math.min(e[0], title.extent[0]); e[1] = Math.min(e[1], title.extent[1]) + 0.1 * title.f; e[2] = Math.max(e[2], title.extent[2]); e[3] = Math.max(e[3], title.extent[3]); }
14643
- rect = [e[0] - margin, e[1] - margin, e[2] + margin, e[3] + margin];
14644
- }
14645
- return { d, w, boxR, rect, title, boxOpacity, cx, cy };
14646
- });
14647
- if (circular) {
14648
- const boxNone = isNoneColor(st.boxcolor), borderNone = isNoneColor(st.bordercolor);
14649
- const boxRgb = hexOrNamedToRgb(boxNone ? '#eeeeee' : st.boxcolor || '#eeeeee');
14650
- const borderRgb = borderNone ? null : hexOrNamedToRgb(st.bordercolor || '#dddddd');
14651
- // ScatterplotLayer has no pixel offset: a moved chart (align,
14652
- // offsetx/offsety) gets its circle as an icon — at the chart's
14653
- // bounding-box center, flat's circular box position (18793)
14654
- if (alignOf.active) layers.push(new IconLayer({
14655
- id: `ix-seqbox-${this.name}-g${this._iconGeneration}`,
14656
- data: boxLayout, pickable: false,
14657
- getPosition: b => b.d.geometry.coordinates,
14658
- getIcon: b => this._buildSingleIcon(boxRgb, b.boxOpacity, 'circle', borderRgb,
14659
- borderNone ? 0 : Math.round(unit / 20 * BUBBLE_ICON_SIZE / Math.max(1, 2 * b.boxR) * 4) / 4),
14660
- getSize: b => b.boxR * 2,
14661
- getPixelOffset: b => { const o = alignOf(b.d); return [o[0] + b.cx, o[1] + b.cy]; },
14662
- sizeUnits: 'pixels', billboard: true,
14663
- parameters: ICON_LAYER_GLOBE_PARAMETERS
14664
- }));
14665
- else layers.push(new ScatterplotLayer({
14666
- id: `ix-seqbox-${this.name}`,
14667
- data: boxLayout, pickable: false,
14668
- getPosition: b => {
14669
- // the circle at the chart's bbox center: with no align
14670
- // offset it moves in map coordinates
14671
- const o = pixelOffsetLngLat(b.d.geometry.coordinates, [b.cx, b.cy], liveZoom);
14672
- return o;
14673
- },
14674
- getRadius: b => b.boxR,
14675
- radiusUnits: 'pixels',
14676
- stroked: !borderNone,
14677
- getFillColor: b => [...boxRgb.slice(0, 3), Math.round(255 * b.boxOpacity)],
14678
- getLineColor: [...(borderRgb || boxRgb).slice(0, 3), 255],
14679
- getLineWidth: unit / 20,
14680
- lineWidthUnits: 'pixels',
14681
- parameters: ICON_LAYER_GLOBE_PARAMETERS
14682
- }));
14683
- } else {
14684
- // flat's rectangular box (the chart and its title plus the
14685
- // margin), the single-symbol path's own _buildBoxIcon drawing
14686
- const borderStyle = String(st.borderstyle || '');
14687
- const boxLook = {
14688
- fill: st.boxcolor || 'white',
14689
- fillOpacity: isNaN(styleNum(st.boxopacity)) ? 1 : styleNum(st.boxopacity),
14690
- stroke: borderStyle === 'none' || isNoneColor(styleLineColor(st.bordercolor)) ? null : styleLineColor(st.bordercolor) || '#bbbbbb',
14691
- strokeOpacity: /^(solid|dotted|dashed)$/.test(borderStyle) ? 1 : 0.3
14692
- };
14693
- const borderWidth = styleNum(st.borderwidth) || 1;
14694
- const borderRadius = styleNum(st.borderradius);
14695
- const strokeOf = b => borderWidth * 0.2 * b.w / 30;
14696
- boxLayout.forEach(b => {
14697
- b.icon = this._buildBoxIcon(b.rect[2] - b.rect[0], b.rect[3] - b.rect[1],
14698
- isNaN(borderRadius) ? 0 : borderRadius * Math.min(unit, b.w / 30),
14699
- strokeOf(b), boxLook);
15288
+ const boxLayoutAll = this._boxRows(() => charts.filter(d => d._seqExtent), { unit, liveZoom, extentOf: d => d._seqExtent });
15289
+ // per-chart overlap bands (see the band computation in the
15290
+ // box block): a Map chart → band index, filled only when
15291
+ // boxes are drawn; empty (single band) otherwise
15292
+ const bandOfChart = new Map();
15293
+ let nBands = 1;
15294
+ if (boxLayoutAll) {
15295
+ // flat draws every chart as ONE group — its box, its symbols,
15296
+ // its title, its values together — and the groups are sorted by
15297
+ // size, so a bigger chart covers a smaller one ENTIRELY
15298
+ // (chartMap's ascending sort, the biggest drawn last). deck.gl
15299
+ // stacks whole layers, so with every box in one layer below
15300
+ // every symbol, a bigger chart's box covered a smaller chart's
15301
+ // box while that chart's symbols floated on top of it (the
15302
+ // City-moving page's overlapping circles). Charts are grouped
15303
+ // into overlap "bands" instead — an interval coloring assigned
15304
+ // biggest-chart-first: within a band no two charts overlap
15305
+ // (their box + title extents, in map pixel space, disjoint),
15306
+ // and a chart is always in a band above every smaller chart it
15307
+ // overlaps. Each band then draws its charts' complete group
15308
+ // (box, title, glow, symbols, values), the smallest band first
15309
+ // — deck.gl draws the first layer at the bottom — so between
15310
+ // two overlapping charts the bigger one is wholly on top, as
15311
+ // flat draws it. Overlap-free themes keep one band and today's
15312
+ // exact single layers
15313
+ const world = 512 * Math.pow(2, liveZoom);
15314
+ const anchorPx = d => {
15315
+ const o = alignOf(d), c = d.geometry.coordinates;
15316
+ const lat = c[1] * Math.PI / 180;
15317
+ return [(c[0] + 180) / 360 * world + o[0],
15318
+ (0.5 - Math.log(Math.tan(Math.PI / 4 + lat / 2)) / (2 * Math.PI)) * world + o[1]];
15319
+ };
15320
+ const sizeOfBand = b => circular ? b.boxR
15321
+ : b.rect ? Math.max(b.rect[2] - b.rect[0], b.rect[3] - b.rect[1]) / 2 : 0;
15322
+ const bandRects = [];
15323
+ for (const b of boxLayoutAll.slice().sort((a, c) => sizeOfBand(c) - sizeOfBand(a))) {
15324
+ // the chart's whole drawn extent: parts cluster, box and
15325
+ // title, as px around the (align-moved) anchor
15326
+ const e = b.d._seqExtent || [0, 0, 0, 0];
15327
+ let x0 = e[0], y0 = e[1], x1 = e[2], y1 = e[3];
15328
+ if (circular) { x0 = Math.min(x0, b.cx - b.boxR); y0 = Math.min(y0, b.cy - b.boxR); x1 = Math.max(x1, b.cx + b.boxR); y1 = Math.max(y1, b.cy + b.boxR); }
15329
+ else if (b.rect) { x0 = Math.min(x0, b.rect[0]); y0 = Math.min(y0, b.rect[1]); x1 = Math.max(x1, b.rect[2]); y1 = Math.max(y1, b.rect[3]); }
15330
+ if (b.title) { x0 = Math.min(x0, b.title.extent[0]); y0 = Math.min(y0, b.title.extent[1]); x1 = Math.max(x1, b.title.extent[2]); y1 = Math.max(y1, b.title.extent[3]); }
15331
+ const p = anchorPx(b.d);
15332
+ const r = [p[0] + x0, p[1] + y0, p[0] + x1, p[1] + y1];
15333
+ let k = 0;
15334
+ while (bandRects[k] && bandRects[k].some(q => r[0] < q[2] && q[0] < r[2] && r[1] < q[3] && q[1] < r[3])) k++;
15335
+ (bandRects[k] = bandRects[k] || []).push(r);
15336
+ bandOfChart.set(b.d, k);
15337
+ }
15338
+ nBands = bandRects.length;
15339
+ }
15340
+ // one band's whole chart group — box, title, glow, symbols and
15341
+ // values of ITS charts only (the closure's boxLayout, glows and
15342
+ // parts are the band's slices) — pushed as one deck.gl layer
15343
+ // sequence, in today's order. bs: the band's id suffix
15344
+ const drawBand = (boxLayout, glows, parts, bs) => {
15345
+ if (boxLayout) {
15346
+ const L = this._boxTitleLayers(boxLayout, {
15347
+ unit, liveZoom,
15348
+ positionOf: b => b.d.geometry.coordinates, offsetOf: b => alignOf(b.d),
15349
+ ids: { circle: `ix-seqbox-${this.name}${bs}`, icon: `ix-seqbox-${this.name}${bs}`,
15350
+ polygons: `ix-seqbox-polygons-${this.name}${bs}`, titles: `ix-seqtitles-${this.name}${bs}` }
14700
15351
  });
14701
- if (boxLayout.length) layers.push(new IconLayer({
14702
- id: `ix-seqbox-${this.name}-g${this._iconGeneration}`,
14703
- data: boxLayout, pickable: false,
14704
- getPosition: b => b.d.geometry.coordinates,
14705
- getIcon: b => b.icon,
14706
- getSize: b => b.icon.height / 2,
14707
- getPixelOffset: b => { const o = alignOf(b.d); return [(b.rect[0] + b.rect[2]) / 2 + o[0], (b.rect[1] + b.rect[3]) / 2 + o[1]]; },
15352
+ layers.push(...L.box, ...L.titles);
15353
+ }
15354
+ if (glows.length && this.flags.has('GLOW') && !glowHiddenByScale(st, liveZoom)) {
15355
+ layers.push(new IconLayer({
15356
+ id: `ix-glow-${this.name}${bs}-g${this._iconGeneration}`,
15357
+ data: glows, pickable: false,
15358
+ getPosition: d => d.geometry.coordinates,
15359
+ getIcon: d => this._getGlowIcon(colorOf(d.part.i)),
15360
+ getSize: d => d.part.r * 11,
15361
+ getPixelOffset: offsetOf,
15362
+ getColor: d => [255, 255, 255, alphaOf(d)],
14708
15363
  sizeUnits: 'pixels', billboard: true,
14709
15364
  parameters: ICON_LAYER_GLOBE_PARAMETERS
14710
15365
  }));
14711
- }
14712
- if (titleShown) {
14713
- const titleData = boxLayout.filter(b => b.title);
14714
- if (titleData.length) layers.push(new TextLayer({
14715
- id: `ix-seqtitles-${this.name}`,
14716
- data: titleData, pickable: false,
14717
- getPosition: b => b.d.geometry.coordinates,
14718
- getText: b => b.title.text,
14719
- getSize: b => b.title.f,
14720
- sizeUnits: 'pixels', fontFamily: 'arial',
14721
- characterSet: 'auto',
14722
- lineHeight: 1,
14723
- getColor: [...hexOrNamedToRgb(st.textcolor || '#888888').slice(0, 3), 255],
14724
- fontSettings: { sdf: true },
14725
- outlineWidth: 1 / 7,
14726
- outlineColor: [255, 255, 255, 128],
14727
- getTextAnchor: b => b.title.anchor,
14728
- getAlignmentBaseline: 'top',
14729
- getPixelOffset: b => { const o = alignOf(b.d); return [b.title.x + o[0], b.title.top + o[1]]; },
15366
+ } else if (glows.length && this.flags.has('AURA') && !glowHiddenByScale(st, liveZoom)) {
15367
+ layers.push(new IconLayer({
15368
+ id: `ix-aura-${this.name}${bs}-g${this._iconGeneration}`,
15369
+ data: glows, pickable: false,
15370
+ getPosition: d => d.geometry.coordinates,
15371
+ getIcon: d => this._buildSingleIcon(borderRgb || colorOf(d.part.i), 0.3, 'circle', null, 0),
15372
+ getSize: d => d.part.r * 2 * 1.3,
15373
+ getPixelOffset: offsetOf,
15374
+ getColor: d => [255, 255, 255, alphaOf(d)],
15375
+ sizeUnits: 'pixels', billboard: true,
14730
15376
  parameters: ICON_LAYER_GLOBE_PARAMETERS
14731
15377
  }));
14732
15378
  }
14733
- }
14734
- if (glows.length && this.flags.has('GLOW') && !glowHiddenByScale(st, liveZoom)) {
14735
15379
  layers.push(new IconLayer({
14736
- id: `ix-glow-${this.name}-g${this._iconGeneration}`,
14737
- data: glows, pickable: false,
15380
+ id: `ix-bubbles-${this.name}${bs}-g${this._iconGeneration}`,
15381
+ data: parts, pickable: true,
14738
15382
  getPosition: d => d.geometry.coordinates,
14739
- getIcon: d => this._getGlowIcon(colorOf(d.part.i)),
14740
- getSize: d => d.part.r * 11,
14741
- getPixelOffset: offsetOf,
14742
- getColor: d => [255, 255, 255, alphaOf(d)],
14743
- sizeUnits: 'pixels', billboard: true,
14744
- parameters: ICON_LAYER_GLOBE_PARAMETERS
14745
- }));
14746
- } else if (glows.length && this.flags.has('AURA') && !glowHiddenByScale(st, liveZoom)) {
14747
- layers.push(new IconLayer({
14748
- id: `ix-aura-${this.name}-g${this._iconGeneration}`,
14749
- data: glows, pickable: false,
14750
- getPosition: d => d.geometry.coordinates,
14751
- getIcon: d => this._buildSingleIcon(borderRgb || colorOf(d.part.i), 0.3, 'circle', null, 0),
14752
- getSize: d => d.part.r * 2 * 1.3,
15383
+ // the stroke, in the icon raster's pixels (the icon is drawn at 2r)
15384
+ getIcon: d => {
15385
+ // an external symbol file: the URL icon as flat's <image> (see
15386
+ // urlSymbolOf above), scaled to the part's diameter like flat's
15387
+ // fu.scale(nRadius / nMaxRadius) on a nChartSize image
15388
+ const u = urlSymbolOf(d.part.i);
15389
+ if (u) return urlIcon(u);
15390
+ return this._buildSingleIcon(colorOf(d.part.i), noFill.has(d.part.i) ? 0 : fillOpacity, shapeOf(d.part.i), borderRgb,
15391
+ borderRgb ? Math.round(lineWidth * unit * Math.min(2 * d.part.r / normalR, 0.2) * BUBBLE_ICON_SIZE / Math.max(1, 2 * d.part.r) * 4) / 4 : 0);
15392
+ },
15393
+ getSize: d => d.part.r * 2,
14753
15394
  getPixelOffset: offsetOf,
14754
15395
  getColor: d => [255, 255, 255, alphaOf(d)],
14755
15396
  sizeUnits: 'pixels', billboard: true,
14756
15397
  parameters: ICON_LAYER_GLOBE_PARAMETERS
14757
15398
  }));
14758
- }
14759
- layers.push(new IconLayer({
14760
- id: `ix-bubbles-${this.name}-g${this._iconGeneration}`,
14761
- data: parts, pickable: true,
14762
- getPosition: d => d.geometry.coordinates,
14763
- // the stroke, in the icon raster's pixels (the icon is drawn at 2r)
14764
- getIcon: d => {
14765
- // an external symbol file: the URL icon as flat's <image> (see
14766
- // urlSymbolOf above), scaled to the part's diameter like flat's
14767
- // fu.scale(nRadius / nMaxRadius) on a nChartSize image
14768
- const u = urlSymbolOf(d.part.i);
14769
- if (u) return urlIcon(u);
14770
- return this._buildSingleIcon(colorOf(d.part.i), noFill.has(d.part.i) ? 0 : fillOpacity, shapeOf(d.part.i), borderRgb,
14771
- borderRgb ? Math.round(lineWidth * unit * Math.min(2 * d.part.r / normalR, 0.2) * BUBBLE_ICON_SIZE / Math.max(1, 2 * d.part.r) * 4) / 4 : 0);
14772
- },
14773
- getSize: d => d.part.r * 2,
14774
- getPixelOffset: offsetOf,
14775
- getColor: d => [255, 255, 255, alphaOf(d)],
14776
- sizeUnits: 'pixels', billboard: true,
14777
- parameters: ICON_LAYER_GLOBE_PARAMETERS
14778
- }));
14779
- // VALUES on each part (maptheme.js 22188-22260): the part's value — with
14780
- // COUNT its record count, with valuefield on the categorical field the
14781
- // class name — formatted with valuedecimals (else none) plus the unit.
14782
- // - a SHAPE part (circle & kin): font min(1, 3.2 / L) · r · valuescale,
14783
- // L the length of the theme's biggest value text (szMaxText), normal
14784
- // weight, in valuecolor / textcolor, else ChartColors.textColor of
14785
- // the part color
14786
- // - an IMAGE-symbol part (an external .svg, the TELRAAM pictograms):
14787
- // flat's own other branch (22253-22256) — font max(r · 0.8, 1) ·
14788
- // valuescale, the text over createTextLabel's background box (white,
14789
- // its rgba(…,0.3) fill × the 0.5 fill-opacity) and in
14790
- // ChartColors.lowColor — the 0.7 derivate of the part color —
14791
- // regardless of valuecolor/textcolor (flat passes cColor.lowColor
14792
- // straight into createTextLabel)
14793
- // CENTERVALUES: only on the first part (class 0), its own value
14794
- if ((this.flags.has('VALUES') || centerValues) && !valuesHiddenByScale(st, zoom)) {
14795
- const valueScale = styleNum(st.valuescale) || 1;
14796
- const { noBreaks } = this._valueTextOpts();
14797
- const units = st.units ? String(st.units) : '';
14798
- const unit = units ? (units[0] === '.' ? '' : ' ') + units : '';
14799
- const unitText = unit.length <= 5 ? unit : '';
14800
- const dec = explicitValueDecimals(st) || 0;
14801
- const fmt = v => flatGroupedValue(v, dec, noBreaks) + unitText;
14802
- const maxLen = fmt(seqMax).length;
14803
- const textOverride = st.valuecolor || st.textcolor;
14804
- const counting = flatFlag(this.flags, 'COUNT');
14805
- const labels = [];
14806
- parts.forEach(d => {
14807
- if (centerValues && d.part.i !== 0) return;
14808
- const rec = this._categoryValueRecord(d.part.i);
14809
- const name = rec ? String(rec[st.valuefield]) : null;
14810
- const t = counting && d.properties.recordCounts ? d.properties.recordCounts[d.part.i] : d.part.v;
14811
- const text = name != null ? name : fmt(t);
14812
- const image = !!urlSymbolOf(d.part.i);
14813
- const L = Math.max((name != null ? name.length : 0) + 1, maxLen);
14814
- const fontSize = image
14815
- ? Math.max(d.part.r * 0.8, 1) * valueScale
14816
- : Math.min(1, 3.2 / L) * d.part.r * valueScale;
14817
- if (fontSize > VALUES_MIN_FONT_PX) {
14818
- labels.push({ geometry: d.geometry, text, fontSize, pixelOffset: offsetOf(d), alpha: alphaOf(d),
14819
- image,
14820
- color: image ? flatDerivateRgb(colorOf(d.part.i), 0.7)
14821
- : textOverride ? hexOrNamedToRgb(textOverride) : flatChartTextRgb(colorOf(d.part.i)) });
14822
- }
14823
- });
14824
- if (labels.length) {
14825
- // VALUEBACKGROUND on URL-symbol parts: a fading white border
14826
- // around the value's own CHARACTER SHAPES — the glyphs
14827
- // re-drawn in white on rings of pixel offsets (a stroke of
14828
- // copies, no box): the width is a fraction of the font size,
14829
- // so the halo scales with every label; the shape-part values
14830
- // keep flat's plain text (no outline)
14831
- if (flatFlag(this.flags, 'VALUEBACKGROUND')) {
14832
- const haloLabels = labels.filter(l => l.image);
14833
- const plainLabels = labels.filter(l => !l.image);
14834
- if (haloLabels.length) {
14835
- // a real outline around the characters: the label's own
14836
- // glyphs re-drawn in white on rings of pixel offsets — a
14837
- // stroke of copies that hugs the character shapes (deck's
14838
- // SDF outline is only a faint, buffer-clipped rim). The
14839
- // base radius is a small fraction of the font size (the
14840
- // 1px floor keeps it tiny until labels get big), and the
14841
- // ring alphas fall off, so the border fades to nothing
14842
- const DIRS = 16, RINGS = [[1, 210], [2, 120], [3.2, 65]];
14843
- const haloCopies = [];
14844
- for (const l of haloLabels) {
14845
- const rb = Math.max(0.75, l.fontSize * 0.015);
14846
- for (let ring = 0; ring < RINGS.length; ring++) {
14847
- for (let dir = 0; dir < DIRS; dir++) {
14848
- const a = (dir / DIRS) * 2 * Math.PI;
14849
- haloCopies.push({ ...l,
14850
- dx: Math.cos(a), dy: Math.sin(a),
14851
- radius: rb * RINGS[ring][0],
14852
- haloAlpha: Math.round(RINGS[ring][1] * l.alpha / 255) });
15399
+ // VALUES on each part (maptheme.js 22188-22260): the part's value — with
15400
+ // COUNT its record count, with valuefield on the categorical field the
15401
+ // class name — formatted with valuedecimals (else none) plus the unit.
15402
+ // - a SHAPE part (circle & kin): font min(1, 3.2 / L) · r · valuescale,
15403
+ // L the length of the theme's biggest value text (szMaxText), normal
15404
+ // weight, in valuecolor / textcolor, else ChartColors.textColor of
15405
+ // the part color
15406
+ // - an IMAGE-symbol part (an external .svg, the TELRAAM pictograms):
15407
+ // flat's own other branch (22253-22256) — font max(r · 0.8, 1) ·
15408
+ // valuescale, the text over createTextLabel's background box (white,
15409
+ // its rgba(…,0.3) fill × the 0.5 fill-opacity) and in
15410
+ // ChartColors.lowColor — the 0.7 derivate of the part color —
15411
+ // regardless of valuecolor/textcolor (flat passes cColor.lowColor
15412
+ // straight into createTextLabel)
15413
+ // CENTERVALUES: only on the first part (class 0), its own value
15414
+ if ((this.flags.has('VALUES') || centerValues) && !valuesHiddenByScale(st, zoom)) {
15415
+ const valueScale = styleNum(st.valuescale) || 1;
15416
+ const { noBreaks } = this._valueTextOpts();
15417
+ const units = st.units ? String(st.units) : '';
15418
+ const unit = units ? (units[0] === '.' ? '' : ' ') + units : '';
15419
+ const unitText = unit.length <= 5 ? unit : '';
15420
+ const dec = explicitValueDecimals(st) || 0;
15421
+ const fmt = v => flatGroupedValue(v, dec, noBreaks) + unitText;
15422
+ const maxLen = fmt(seqMax).length;
15423
+ const textOverride = st.valuecolor || st.textcolor;
15424
+ const counting = flatFlag(this.flags, 'COUNT');
15425
+ const labels = [];
15426
+ parts.forEach(d => {
15427
+ if (centerValues && d.part.i !== 0) return;
15428
+ const rec = this._categoryValueRecord(d.part.i);
15429
+ const name = rec ? String(rec[st.valuefield]) : null;
15430
+ const t = counting && d.properties.recordCounts ? d.properties.recordCounts[d.part.i] : d.part.v;
15431
+ const text = name != null ? name : fmt(t);
15432
+ const image = !!urlSymbolOf(d.part.i);
15433
+ const L = Math.max((name != null ? name.length : 0) + 1, maxLen);
15434
+ const fontSize = image
15435
+ ? Math.max(d.part.r * 0.8, 1) * valueScale
15436
+ : Math.min(1, 3.2 / L) * d.part.r * valueScale;
15437
+ if (fontSize > VALUES_MIN_FONT_PX) {
15438
+ labels.push({ geometry: d.geometry, text, fontSize, pixelOffset: offsetOf(d), alpha: alphaOf(d),
15439
+ image,
15440
+ color: image ? flatDerivateRgb(colorOf(d.part.i), 0.7)
15441
+ : textOverride ? hexOrNamedToRgb(textOverride) : flatChartTextRgb(colorOf(d.part.i)) });
15442
+ }
15443
+ });
15444
+ if (labels.length) {
15445
+ // VALUEBACKGROUND on URL-symbol parts: a fading white border
15446
+ // around the value's own CHARACTER SHAPES — the glyphs
15447
+ // re-drawn in white on rings of pixel offsets (a stroke of
15448
+ // copies, no box): the width is a fraction of the font size,
15449
+ // so the halo scales with every label; the shape-part values
15450
+ // keep flat's plain text (no outline)
15451
+ if (flatFlag(this.flags, 'VALUEBACKGROUND')) {
15452
+ const haloLabels = labels.filter(l => l.image);
15453
+ const plainLabels = labels.filter(l => !l.image);
15454
+ if (haloLabels.length) {
15455
+ // a real outline around the characters: the label's own
15456
+ // glyphs re-drawn in white on rings of pixel offsets — a
15457
+ // stroke of copies that hugs the character shapes (deck's
15458
+ // SDF outline is only a faint, buffer-clipped rim). The
15459
+ // base radius is a small fraction of the font size (the
15460
+ // 1px floor keeps it tiny until labels get big), and the
15461
+ // ring alphas fall off, so the border fades to nothing
15462
+ const DIRS = 16, RINGS = [[1, 210], [2, 120], [3.2, 65]];
15463
+ const haloCopies = [];
15464
+ for (const l of haloLabels) {
15465
+ const rb = Math.max(0.75, l.fontSize * 0.015);
15466
+ for (let ring = 0; ring < RINGS.length; ring++) {
15467
+ for (let dir = 0; dir < DIRS; dir++) {
15468
+ const a = (dir / DIRS) * 2 * Math.PI;
15469
+ haloCopies.push({ ...l,
15470
+ dx: Math.cos(a), dy: Math.sin(a),
15471
+ radius: rb * RINGS[ring][0],
15472
+ haloAlpha: Math.round(RINGS[ring][1] * l.alpha / 255) });
15473
+ }
14853
15474
  }
14854
15475
  }
15476
+ layers.push(new TextLayer({
15477
+ id: `ix-cluster-values-halo-${this.name}${bs}`,
15478
+ data: haloCopies, pickable: false,
15479
+ getPosition: d => d.geometry.coordinates,
15480
+ getText: d => d.text,
15481
+ getSize: d => d.fontSize,
15482
+ getColor: d => [255, 255, 255, d.haloAlpha],
15483
+ getPixelOffset: d => [d.pixelOffset[0] + d.dx * d.radius, d.pixelOffset[1] + d.dy * d.radius],
15484
+ sizeUnits: 'pixels', fontFamily: 'arial', fontWeight: 'normal',
15485
+ characterSet: 'auto',
15486
+ getTextAnchor: 'middle', getAlignmentBaseline: 'center'
15487
+ }));
15488
+ layers.push(new TextLayer({
15489
+ id: `ix-cluster-values-${this.name}${bs}`,
15490
+ data: haloLabels, pickable: false,
15491
+ getPosition: d => d.geometry.coordinates,
15492
+ getText: d => d.text,
15493
+ getSize: d => d.fontSize,
15494
+ getColor: d => [...d.color.slice(0, 3), d.alpha],
15495
+ getPixelOffset: d => d.pixelOffset,
15496
+ sizeUnits: 'pixels', fontFamily: 'arial', fontWeight: 'normal',
15497
+ characterSet: 'auto',
15498
+ getTextAnchor: 'middle', getAlignmentBaseline: 'center'
15499
+ }));
14855
15500
  }
14856
- layers.push(new TextLayer({
14857
- id: `ix-cluster-values-halo-${this.name}`,
14858
- data: haloCopies, pickable: false,
14859
- getPosition: d => d.geometry.coordinates,
14860
- getText: d => d.text,
14861
- getSize: d => d.fontSize,
14862
- getColor: d => [255, 255, 255, d.haloAlpha],
14863
- getPixelOffset: d => [d.pixelOffset[0] + d.dx * d.radius, d.pixelOffset[1] + d.dy * d.radius],
14864
- sizeUnits: 'pixels', fontFamily: 'arial', fontWeight: 'normal',
14865
- characterSet: 'auto',
14866
- getTextAnchor: 'middle', getAlignmentBaseline: 'center'
14867
- }));
14868
- layers.push(new TextLayer({
14869
- id: `ix-cluster-values-${this.name}`,
14870
- data: haloLabels, pickable: false,
15501
+ if (plainLabels.length) layers.push(new TextLayer({
15502
+ id: `ix-cluster-values-plain-${this.name}${bs}`,
15503
+ data: plainLabels, pickable: false,
14871
15504
  getPosition: d => d.geometry.coordinates,
14872
15505
  getText: d => d.text,
14873
15506
  getSize: d => d.fontSize,
@@ -14877,10 +15510,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14877
15510
  characterSet: 'auto',
14878
15511
  getTextAnchor: 'middle', getAlignmentBaseline: 'center'
14879
15512
  }));
14880
- }
14881
- if (plainLabels.length) layers.push(new TextLayer({
14882
- id: `ix-cluster-values-plain-${this.name}`,
14883
- data: plainLabels, pickable: false,
15513
+ } else {
15514
+ layers.push(new TextLayer({
15515
+ id: `ix-cluster-values-${this.name}${bs}`,
15516
+ data: labels, pickable: false,
14884
15517
  getPosition: d => d.geometry.coordinates,
14885
15518
  getText: d => d.text,
14886
15519
  getSize: d => d.fontSize,
@@ -14890,21 +15523,20 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14890
15523
  characterSet: 'auto',
14891
15524
  getTextAnchor: 'middle', getAlignmentBaseline: 'center'
14892
15525
  }));
14893
- } else {
14894
- layers.push(new TextLayer({
14895
- id: `ix-cluster-values-${this.name}`,
14896
- data: labels, pickable: false,
14897
- getPosition: d => d.geometry.coordinates,
14898
- getText: d => d.text,
14899
- getSize: d => d.fontSize,
14900
- getColor: d => [...d.color.slice(0, 3), d.alpha],
14901
- getPixelOffset: d => d.pixelOffset,
14902
- sizeUnits: 'pixels', fontFamily: 'arial', fontWeight: 'normal',
14903
- characterSet: 'auto',
14904
- getTextAnchor: 'middle', getAlignmentBaseline: 'center'
14905
- }));
15526
+ }
14906
15527
  }
14907
15528
  }
15529
+ };
15530
+ const chartBand = c => bandOfChart.get(c) || 0;
15531
+ if (nBands > 1) {
15532
+ // deck.gl draws the FIRST layer at the bottom: the smallest
15533
+ // charts' band first, the biggest (band 0) last, on top
15534
+ for (let k = nBands - 1; k >= 0; k--) drawBand(
15535
+ boxLayoutAll ? boxLayoutAll.filter(b => chartBand(b.d) === k) : null,
15536
+ allGlows.filter(g => chartBand(g.chart) === k),
15537
+ allParts.filter(p => chartBand(p.chart) === k), '-b' + k);
15538
+ } else {
15539
+ drawBand(boxLayoutAll, allGlows, allParts, '');
14908
15540
  }
14909
15541
  return layers;
14910
15542
  }
@@ -14964,8 +15596,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14964
15596
  // CENTERVALUE the item's record count
14965
15597
  // Not drawn here (a console note at type parsing, PIE_INERT_FLAGS,
14966
15598
  // PIE_AGGREGATE_INERT_FLAGS):
14967
- // 3D, VOLUME, HEIGHT, STARBURST, FLOWER/RAYS, BOW, ZOOM, DIRECTION /
14968
- // DIRECTED, POLAR — the pie is drawn flat and plain instead. Nor
15599
+ // 3D, VOLUME, HEIGHT, BOW, ZOOM, DIRECTION / DIRECTED — the pie is
15600
+ // drawn flat and plain instead; POLAR without STARBURST has no
15601
+ // effect in flat either. STARBURST (with the FLOWER/RAYS angle/gap
15602
+ // variants) and its POLAR circles ARE drawn (pieSliceLayout). Nor
14969
15603
  // style.clipparts, showparts, gridx (flat's several pies per item).
14970
15604
  // Known divergence: flat draws a chart's value labels in the chart's
14971
15605
  // own group, so a bigger pie drawn later covers a smaller neighbour's
@@ -15029,6 +15663,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15029
15663
  const lineRgb = line === 'none' ? null : hexOrNamedToRgb(line);
15030
15664
  const lineWidthOf = r => (lineRgb ? (styleNum(st.linewidth) || 0.1) * r / 15 : 0);
15031
15665
  const lineRgba = lineRgb ? [...lineRgb.slice(0, 3), Math.round(255 * lineAlpha)] : [0, 0, 0, 0];
15666
+ // flat's STARBURST (maptheme.js 20262-20275, piechart.js 281-287,
15667
+ // 415-420): the parts get equal angles and a ray's radius carries
15668
+ // its value — inner + span · sqrt(v / maxValue) · scale, scale =
15669
+ // maxValue / (normalsizevalue || maxValue) · 2.14 (with SIZE flat
15670
+ // sets only maxValue, so a part at the reference value reaches
15671
+ // 2.14 R). maxValue: style.maxvalue (the reference — the ISTAT
15672
+ // story's national mean), else the biggest part value over the
15673
+ // items (flat's max of the per-part maxima nMaxA). The star's
15674
+ // inner px (the ray variants' 5px) comes off the layout
15675
+ const star = has(/STARBURST/);
15676
+ let starMaxPart = 0;
15032
15677
 
15033
15678
  const charts = [];
15034
15679
  if (agg) {
@@ -15043,6 +15688,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15043
15688
  if (!layout) continue;
15044
15689
  if (cat) layout.slices.forEach(sl => { sl.single = values.length === 1; });
15045
15690
  const sum = values.reduce((a, v) => a + (isNaN(v) ? 0 : v), 0);
15691
+ if (star) for (const v of values) if (v > starMaxPart) starMaxPart = v;
15046
15692
  const r = nSizeOf(sum, size) * unit;
15047
15693
  if (!(r > 0) || !isFinite(r)) continue;
15048
15694
  // flat's draw-order key (maptheme.js 16812-16860): with a size
@@ -15067,6 +15713,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15067
15713
  const layout = pieSliceLayout(values, t, { centerPart: st.centerpart });
15068
15714
  if (!layout) continue;
15069
15715
  const sum = values.reduce((a, v) => a + v, 0);
15716
+ if (star) for (const v of values) if (v > starMaxPart) starMaxPart = v;
15070
15717
  const size = this.binding.size ? parseFloat(raw[this.binding.size]) : undefined;
15071
15718
  const r = nSizeOf(sum, size) * unit;
15072
15719
  if (!(r > 0) || !isFinite(r)) continue;
@@ -15099,14 +15746,80 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15099
15746
  });
15100
15747
 
15101
15748
  const TAU = 2 * Math.PI;
15102
- const slices = [], centers = [], glows = [];
15749
+ const slices = [], centers = [], glows = [], polar = [], polarDashes = [];
15103
15750
  const glowOn = has(/GLOW/) && !has(/ZOOM/) && !glowHiddenByScale(st, liveZoom);
15751
+ // the STARBURST ray scale (see the star comment above): style
15752
+ // maxvalue, else the biggest part value over the items — flat's
15753
+ // `this.nMaxValue || max(nMaxA)`. Without SIZE flat also sets the
15754
+ // donut's normalSizeValue (normalsizevalue, else maxValue), which
15755
+ // dampens the scale
15756
+ const starMax = styleNum(st.maxvalue);
15757
+ const starRef = !isNaN(starMax) && starMax > 0 ? starMax : (starMaxPart > 0 ? starMaxPart : 1);
15758
+ const starNsv = has(/\bSIZE\b/) ? starRef : (nsv || starRef);
15759
+ const starScale = starRef / starNsv * 2.14;
15760
+ // a chart's rays start at the CENTER — flat's `max(5, nRadInner)`
15761
+ // for the ray variants is 5 units of the chart's internal
15762
+ // coordinate system, sub-pixel and invisible on screen (see
15763
+ // pieSliceLayout) — and only with DONUT at the donut's inner
15764
+ // radius (flat piechart.js 326-327: nRadOuter += nRadInner, the
15765
+ // span stays R)
15766
+ const starInnerOf = ch => (ch.layout.inner || 0) * ch.r;
15767
+ const starRadius = (v, R, inner) => Math.max(1, inner + (R - inner) * Math.sqrt(Math.max(0, v) / starRef) * starScale);
15104
15768
  for (const ch of charts) {
15769
+ const starInner = star ? starInnerOf(ch) : 0;
15770
+ if (star && has(/POLAR/)) {
15771
+ // flat piechart.js 435-452: the STARBURST's reference circles —
15772
+ // a white disc at the reference value (fill-opacity 0.25,
15773
+ // #444444 border, solid) and range rings at 75 / 50 / 25 % of
15774
+ // it, the 75% and 25% rings DASHED (stroke-dasharray 50 50 in
15775
+ // the chart group's units, on for half of each cycle). All of
15776
+ // them under every ray. The stroke widths are flat's 1px /
15777
+ // 3px — in the CHART GROUP's coordinate system, whose scale
15778
+ // is ~0.017 of the screen, so on screen they are sub-pixel
15779
+ // hairlines (measured on the ISTAT story: the disc's 197
15780
+ // group-units radius is 11.2px on screen); 1px ≈ 1.1% and
15781
+ // 3px ≈ 3.2% of the base radius R, taken here as fractions
15782
+ // of ch.r so they stay hairlines at every zoom, as in flat
15783
+ const lw1 = ch.r * 0.0108, lw3 = ch.r * 0.0325;
15784
+ const w = ch.r - starInner;
15785
+ polar.push({ position: ch.c, r: starInner + w * starScale, off: ch.off,
15786
+ fill: [255, 255, 255, 64], line: [68, 68, 68, 255], lw: lw1 });
15787
+ polar.push({ position: ch.c, r: starInner + w * Math.sqrt(0.5) * starScale, off: ch.off,
15788
+ fill: [0, 0, 0, 0], line: [68, 68, 68, 255], lw: lw1 });
15789
+ // the dashed rings: one chord per dash — flat's own arcs are
15790
+ // octant polylines, so straight chords are as faithful. The
15791
+ // dash count is flat's geometry: a 50-unit dash on the ring's
15792
+ // group radius — the ring in group units is ~17.7 × its
15793
+ // screen radius (the chart group scale measured on the
15794
+ // story), so a cycle is 100 group units = 100/(17.7 · 2π ·
15795
+ // ringR_screen) of the circle: ~11 dashes around the 75%,
15796
+ // ~6 around the 25% ring, at every zoom
15797
+ for (const q of [0.75, 0.25]) {
15798
+ const rr = starInner + w * Math.sqrt(q) * starScale;
15799
+ const cycles = Math.max(q > 0.5 ? 4 : 3, Math.round((q > 0.5 ? 2.06 : 1.19) * ch.r));
15800
+ const seg = 2 * Math.PI / cycles;
15801
+ for (let i = 0; i < cycles; i++) {
15802
+ const a0 = i * seg, a1 = a0 + seg / 2;
15803
+ // 0 at 12 o'clock, clockwise (pieSliceLayout's convention);
15804
+ // pixelOffsetLngLat's y grows down
15805
+ polarDashes.push({
15806
+ path: [
15807
+ pixelOffsetLngLat(ch.c, [Math.sin(a0) * rr, -Math.cos(a0) * rr], liveZoom),
15808
+ pixelOffsetLngLat(ch.c, [Math.sin(a1) * rr, -Math.cos(a1) * rr], liveZoom)
15809
+ ], lw: lw3
15810
+ });
15811
+ }
15812
+ }
15813
+ }
15105
15814
  for (const sl of ch.layout.slices) {
15106
15815
  const rgb = sliceRgb(sl);
15816
+ // STARBURST: this ray's own radius (the value), its inner the
15817
+ // px starInner as a fraction of that radius — the shader takes
15818
+ // a fraction of the instance radius, which varies per ray here
15819
+ const rayR = star ? starRadius(sl.value, ch.r, starInner) : ch.r;
15107
15820
  slices.push({
15108
- position: ch.c, r: ch.r, off: ch.off, properties: ch.properties, chart: ch, part: sl,
15109
- slice: [sl.start * Math.PI / 180, sl.sweep >= 360 ? TAU : sl.sweep * Math.PI / 180, ch.layout.inner],
15821
+ position: ch.c, r: rayR, off: ch.off, properties: ch.properties, chart: ch, part: sl,
15822
+ slice: [sl.start * Math.PI / 180, sl.sweep >= 360 ? TAU : sl.sweep * Math.PI / 180, star ? starInner / rayR : ch.layout.inner],
15110
15823
  fill: [...rgb.slice(0, 3), !sl.complement && noFill.has(sl.i) ? 0 : Math.round(255 * fillAlpha)]
15111
15824
  });
15112
15825
  }
@@ -15130,13 +15843,80 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15130
15843
  radiusUnits: 'pixels', billboard: true, stroked: false, filled: true, antialiasing: true,
15131
15844
  parameters: ICON_LAYER_GLOBE_PARAMETERS
15132
15845
  };
15133
- const layers = [new PieSliceLayer(Object.assign({}, discProps, {
15846
+ // flat's BOX / TITLE for a pie (maptheme.js 18446-18925, the same
15847
+ // block the sequence builder draws): a rectangle around the
15848
+ // chart's drawn extent — on a STARBURST the longest ray, the
15849
+ // disc otherwise — plus, with TITLE, the item's title (an
15850
+ // aggregated grid cell of more than 3 records without its own:
15851
+ // "(n)", see _itemTitle) at the chart's left edge, wrapped to
15852
+ // the chart's width, in textcolor (#888888) over a half-opaque
15853
+ // white halo, its first baseline 0.7 font above the box — the
15854
+ // box grows to enclose it, its top trimmed by 0.1 font. The
15855
+ // box: white at boxopacity, #bbbbbb border at 0.3 opacity
15856
+ // (borderstyle solid/dotted/dashed: 1, none: no border),
15857
+ // borderwidth · 0.2 · width/chartSize px wide. Both are removed
15858
+ // outside boxupper / titleupper (maptheme.js 18758-18760, the
15859
+ // TITLE gate 18809-18811)
15860
+ // the chart's drawn extent, px around the anchor (flat's getBox of the
15861
+ // shape group — the longest ray on a STARBURST)
15862
+ const boxRows = this._boxRows(charts, { unit, liveZoom, extentOf: ch => {
15863
+ let e = ch.r;
15864
+ if (star) { const in0 = starInnerOf(ch); for (const sl of ch.layout.slices) e = Math.max(e, starRadius(sl.value, ch.r, in0)); }
15865
+ return [-e, -e, e, e];
15866
+ } });
15867
+ const layers = [];
15868
+ if (boxRows && boxRows.length) {
15869
+ const L = this._boxTitleLayers(boxRows, {
15870
+ unit, liveZoom,
15871
+ positionOf: b => b.d.c, offsetOf: b => b.d.off,
15872
+ ids: { circle: `ix-pie-box-${this.name}`, icon: `ix-pie-box-${this.name}`, polygons: `ix-pie-box-polygons-${this.name}`, titles: `ix-pie-titles-${this.name}` }
15873
+ });
15874
+ layers.push(...L.box, ...L.titles);
15875
+ }
15876
+ if (polar.length) {
15877
+ layers.push(new ScatterplotLayer({
15878
+ id: `ix-pie-polar-${this.name}`,
15879
+ data: polar, pickable: false,
15880
+ getPosition: d => d.position,
15881
+ getRadius: d => d.r,
15882
+ getPixelOffset: d => d.off,
15883
+ radiusUnits: 'pixels', billboard: true,
15884
+ filled: true, stroked: true,
15885
+ getFillColor: d => d.fill,
15886
+ getLineColor: d => d.line,
15887
+ getLineWidth: d => d.lw,
15888
+ lineWidthUnits: 'pixels',
15889
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
15890
+ }));
15891
+ // flat's dashed range rings (see the polar block): one chord per
15892
+ // dash, drawn in map coordinates at the current zoom — the
15893
+ // chart anchors move on zoom, and the layers rebuild with them
15894
+ if (polarDashes.length) {
15895
+ layers.push(new PathLayer({
15896
+ id: `ix-pie-polar-dashes-${this.name}`,
15897
+ data: polarDashes, pickable: false,
15898
+ getPath: d => d.path,
15899
+ getColor: [68, 68, 68, 255],
15900
+ getWidth: d => d.lw,
15901
+ widthUnits: 'pixels',
15902
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
15903
+ }));
15904
+ }
15905
+ }
15906
+ layers.push(new PieSliceLayer(Object.assign({}, discProps, {
15134
15907
  id: `ix-pie-${this.name}`,
15135
15908
  data: slices, pickable: true,
15136
15909
  getLineColor: lineRgba,
15137
15910
  getSlice: d => d.slice,
15138
- getSliceLineWidth: d => lineWidthOf(d.r)
15139
- }))];
15911
+ // flat's pie outline (piechart.js: normalX(linewidth) · R / 300)
15912
+ // is 2% of the radius — sub-pixel for every small chart, and the
15913
+ // browser rasterizes it as a HAIRLINE: one device pixel at full
15914
+ // opacity (Skia's stroke floor). The 1 device-px floor here
15915
+ // matches that: the width is in the shader's device-pixel
15916
+ // space (pieAA = fwidth), so the CSS width is scaled by the
15917
+ // device pixel ratio first
15918
+ getSliceLineWidth: d => (lineWidthOf(d.r) > 0 ? Math.max(lineWidthOf(d.r) * (global.devicePixelRatio || 1), 1) : 0)
15919
+ })));
15140
15920
  if (centers.length) {
15141
15921
  layers.push(new PieSliceLayer(Object.assign({}, discProps, { id: `ix-pie-center-${this.name}`, data: centers, pickable: true })));
15142
15922
  }
@@ -15350,7 +16130,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15350
16130
  // STACKED beside the column on leader lines; flat's values color
15351
16131
  // #798697 (textcolor), negative red (valuecolor); DTEXT and a sized
15352
16132
  // POINTER fade small values
15353
- // - BOX / TITLE (flatChartBox): a box around chart and title, the
16133
+ // - BOX / TITLE (chartBoxLayout): a box around chart and title, the
15354
16134
  // item's title above it — boxcolor (white) at boxopacity, bordercolor
15355
16135
  // (#bbbbbb) at 0.3, borderwidth · 0.2 · w / 30; title 5 u ·
15356
16136
  // textscale in textcolor (#888888) over a white halo
@@ -15446,9 +16226,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15446
16226
  }
15447
16227
  applyFlatMaxCharts(charts, st);
15448
16228
 
15449
- const boxShown = flatFlag(this.flags, 'BOX') && !boxHiddenByScale(st, liveZoom);
15450
- const titleShown = boxShown && flatFlag(this.flags, 'TITLE') && !!this.binding.title;
15451
- const titleFont = 5 * (num(st.textscale) || num(st.valuescale) || 1);
16229
+ // a BAR draws its box as a shader shape (rectangle only, no CIRCULARBOX):
16230
+ // the gates, margin, title font and placement are the box's, shared with
16231
+ // every chart type (_boxSpec / chartBoxLayout, here in chart units)
16232
+ const boxSpec = this._boxSpec(liveZoom);
16233
+ const boxUnits = st.titleunits ? ' ' + st.titleunits : '';
15452
16234
  const boxOpacity = isNaN(styleNum(st.boxopacity)) ? 1 : styleNum(st.boxopacity);
15453
16235
  const borderStyle = String(st.borderstyle || '');
15454
16236
  const boxFill = [...hexOrNamedToRgb(isNoneColor(st.boxcolor) ? 'white' : st.boxcolor || 'white').slice(0, 3), isNoneColor(st.boxcolor) ? 0 : Math.round(255 * boxOpacity)];
@@ -15477,10 +16259,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15477
16259
  for (const ch of charts) {
15478
16260
  const L = ch.layout;
15479
16261
  let box = null;
15480
- if (boxShown) {
15481
- const title = titleShown ? this._itemTitle(ch.properties) : '';
15482
- box = flatChartBox(L.extent, { margin: num(st.boxmargin) || 2, titleFont, title: title ? title + (st.titleunits || '') : '',
15483
- bottomTitle: flatFlag(this.flags, 'BOTTOMTITLE'), alignRight: /right/.test(String(st.align || '')), measure });
16262
+ if (boxSpec) {
16263
+ const title = boxSpec.titleShown ? this._itemTitle(ch.properties) : '';
16264
+ box = chartBoxLayout(L.extent, { unit: 1, margin: boxSpec.margin, titleText: title ? title + boxUnits : '',
16265
+ titleFont: w => boxSpec.titleFontOf(1, w), bottomTitle: boxSpec.bottomTitle, alignRight: boxSpec.alignRight, measure });
15484
16266
  }
15485
16267
  const ext = box ? box.rect : L.extent;
15486
16268
  // align / offsetx / offsety (flatChartAlignOffset), the chart's
@@ -15489,9 +16271,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15489
16271
  if (box) {
15490
16272
  const r = box.rect;
15491
16273
  shape(ch, { base: [(r[0] + r[2]) / 2, r[3]], dir: 0, len: r[3] - r[1], w2: (r[2] - r[0]) / 2, sw: (r[2] - r[0]) / 2, band: 0, head: 0 },
15492
- boxFill, boxLine, borderWidth * 0.2 * box.width / 30, null, true);
15493
- if (box.title) titles.push({ position: ch.c, text: box.title.lines.join('\n'), font: box.title.font * unit, anchor: box.title.anchor,
15494
- off: [box.title.x * unit + ch.off[0], (box.title.baseline - 0.8 * box.title.font) * unit + ch.off[1]] });
16274
+ boxFill, boxLine, borderWidth * 0.2 * box.w / 30, null, true);
16275
+ // the title in px, rendered by the shared title layer
16276
+ if (box.title) titles.push({ d: ch, title: { text: box.title.text, x: box.title.x * unit, anchor: box.title.anchor,
16277
+ top: box.title.top * unit, f: box.title.f * unit } });
15495
16278
  }
15496
16279
  for (const b of L.bars) {
15497
16280
  const rgb = this.classRgb(b.cls);
@@ -15559,18 +16342,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15559
16342
  backgroundPadding: [0.3, 0, 0.4, 0.1].map(k => k * boxed.reduce((a, d) => a + d.font, 0) / boxed.length)
15560
16343
  })));
15561
16344
  }
15562
- if (titles.length) {
15563
- layers.push(new TextLayer({
15564
- id: `ix-bar-titles-${this.name}`, data: titles, pickable: false,
15565
- getPosition: d => d.position, getText: d => d.text, getSize: d => d.font,
15566
- sizeUnits: 'pixels', fontFamily: 'arial', characterSet: 'auto', lineHeight: 1,
15567
- getColor: [...hexOrNamedToRgb(st.textcolor || '#888888').slice(0, 3), 255],
15568
- fontSettings: { sdf: true }, outlineWidth: 1 / 7, outlineColor: [255, 255, 255, 128],
15569
- getTextAnchor: d => d.anchor, getAlignmentBaseline: 'top',
15570
- getPixelOffset: d => d.off,
15571
- parameters: ICON_LAYER_GLOBE_PARAMETERS
15572
- }));
15573
- }
16345
+ if (titles.length) layers.push(...this._boxTitleLayers(titles, {
16346
+ box: false, unit, liveZoom, positionOf: b => b.d.c, offsetOf: b => b.d.off,
16347
+ ids: { titles: `ix-bar-titles-${this.name}` }
16348
+ }).titles);
15574
16349
  return layers;
15575
16350
  }
15576
16351
 
@@ -15757,22 +16532,28 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15757
16532
  return [(lng + 180) / 360 * world, (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * world];
15758
16533
  };
15759
16534
  const mercToLngLat = ([x, y]) => [x / world * 360 - 180, (2 * Math.atan(Math.exp((0.5 - y / world) * 2 * Math.PI)) - Math.PI / 2) * 180 / Math.PI];
15760
- // the curves are computed in the plane of flat's map (Equal Earth), at the screen
15761
- // size of this map's degree of longitude on the equator, then mapped back: the
15762
- // flow bows around the pole as on flat. Mercator's plane stays the default.
15763
- // On the globe (flat's orthographic map) the plane is the one of the view center,
15764
- // for the flows whose two ends face the viewer; the others keep Mercator's.
16535
+ // the curves are computed in the plane the map is DISPLAYED in: Web
16536
+ // Mercator under MapLibre's mercator projection; on the globe (flat's
16537
+ // orthographic map) the orthographic plane of the view center, for
16538
+ // the flows whose two ends face the viewer (the others keep
16539
+ // Mercator's). flat's own Equal Earth / Winkel plane (e1cb264) is NOT
16540
+ // used: this engine displays those maps in Web Mercator too (a flat
16541
+ // SVG map in another projection is reported "not available — mercator
16542
+ // used"), and a curve bowed in the Equal Earth plane swings far
16543
+ // toward the pole once Mercator renders it — measured on
16544
+ // mechanical_goods_flows.html: Japan→USA at 82°N, its bow almost half
16545
+ // the chord, where the Mercator curve stays at 63°N — the "deviated
16546
+ // near the north pole" vectors. Flat bows its curves in the plane of
16547
+ // ITS map; here the plane of the display.
15765
16548
  const planeFns = (proj, k) => ({
15766
16549
  toPx: ([lng, lat]) => { const q = proj.project(lat, lng); return [q[0] * k, -q[1] * k]; },
15767
16550
  toLngLat: ([x, y]) => { const q = proj.invert(x / k, -y / k); return [q[1], q[0]]; }
15768
16551
  });
15769
16552
  const mercFns = { toPx: mercToPx, toLngLat: mercToLngLat };
15770
- const fixedPlane = flatPlaneProjectionByOptions.get(this.mapOptions);
15771
- const fixedFns = fixedPlane && fixedPlane.invert ? planeFns(fixedPlane, (world / 360) / fixedPlane.project(0, 1)[0]) : null;
15772
16553
  const planeCenter = flatViewCenterByOptions.get(this.mapOptions) || globeCenter;
15773
- const ortho = !fixedFns && globeCenter ? orthographicPlane(planeCenter.lat, planeCenter.lng) : null;
16554
+ const ortho = globeCenter ? orthographicPlane(planeCenter.lat, planeCenter.lng) : null;
15774
16555
  const orthoFns = ortho ? planeFns(ortho, world / (2 * Math.PI)) : null;
15775
- const fnsOf = it => (fixedFns || (orthoFns && ortho.inFront(it.p1) && ortho.inFront(it.p2) ? orthoFns : mercFns));
16556
+ const fnsOf = it => (orthoFns && ortho.inFront(it.p1) && ortho.inFront(it.p2) ? orthoFns : mercFns);
15776
16557
  const inBox = c => c && c[0] >= bbox[0] && c[0] <= bbox[2] && c[1] >= bbox[1] && c[1] <= bbox[3];
15777
16558
 
15778
16559
  const sizePow = resolveSizePow(st, this.flags);
@@ -15871,7 +16652,6 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15871
16652
  // — every size equal — SVG ignores: the opacity stays)
15872
16653
  if (dop || dopMin) { const d = dopacity(it); if (isFinite(d)) { lineAlpha = d; headAlpha = d; } }
15873
16654
  if (gradient) stops = fadeGradientStops(pts, lay.vx, lay.vy, fade);
15874
- if (dash) dashArr = [50 * oz, 5 * oz + sw];
15875
16655
  pts = pts.map(p => [p[0] + a[0], p[1] + a[1]]);
15876
16656
  end = [end[0] + a[0], end[1] + a[1]];
15877
16657
  } else {
@@ -15885,7 +16665,25 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15885
16665
  headS = 4; refX = 4; refY = 2;
15886
16666
  lineAlpha = fo || 0.3;
15887
16667
  headAlpha = Math.min(1, (fo || 0.3) * 2);
15888
- if (dash) dashArr = [50 * oz, 15 * oz];
16668
+ }
16669
+ if (dash) {
16670
+ // flat's dash pattern: one long dash (50 px at the object scale)
16671
+ // and a small gap, its phase walking toward the target — a flow
16672
+ // of any length gets its own count of dashes that way: long ones
16673
+ // many, short ones none at all (a solid line). style.dashcount
16674
+ // instead divides the flow into that many dash+gap periods:
16675
+ // every flow, long or short, shows the same pattern and moves at
16676
+ // the same relative speed (one full dash cycle every 2s), the
16677
+ // arrows as on the region-migration page
16678
+ const nDashes = Math.round(styleNum(st.dashcount) || 0);
16679
+ if (nDashes >= 1) {
16680
+ let flowPx = 0;
16681
+ for (let i = 1; i < pts.length; i++) flowPx += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
16682
+ const period = flowPx / nDashes;
16683
+ dashArr = [period * 0.85, period * 0.15];
16684
+ } else {
16685
+ dashArr = bezier ? [50 * oz, 5 * oz + sw] : [50 * oz, 15 * oz];
16686
+ }
15889
16687
  }
15890
16688
  // a line under a pixel: one pixel at its width as opacity
15891
16689
  const drawW = Math.max(1, sw);
@@ -15911,7 +16709,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15911
16709
  let run = [], runColors = [];
15912
16710
  const flush = () => {
15913
16711
  if (run.length > 1) {
15914
- const dash = dashArr ? [dashArr[0] / drawW, dashArr[1] / drawW] : [0, 0];
16712
+ // the flow triple: [dash, gap] in px (the shader's path
16713
+ // distance unit) + flat's random per-arc dashoffset phase
16714
+ // (maptheme.js 18249), fixed per arc: the layers are rebuilt on
16715
+ // every pan/zoom, a new random phase each time made dashes jump
16716
+ const dash = dashArr ? [dashArr[0], dashArr[1], flowDashPhase(p1, p2)] : [0, 0, 0];
15915
16717
  if (globeCenter && !dashArr) {
15916
16718
  // on the globe deck.gl draws only the first three quarters of a path that has a
15917
16719
  // color per vertex (also an equal one): one color per path, a gradient as one
@@ -15946,12 +16748,22 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15946
16748
  fill: [...rgb.slice(0, 3), Math.round(255 * Math.min(1, headAlpha) * groupAlpha)] });
15947
16749
  }
15948
16750
  }
15949
- const layers = [new PathLayer({
16751
+ // always FlowPathLayer: without dash offsets its flow hook no-ops
16752
+ // (solid lines), with them the pattern walks toward the target
16753
+ // (see makeFlowPathLayer). The dash triple is px; deck's path
16754
+ // shader measures along-path positions in HALF-STROKE-WIDTH
16755
+ // units (see makeFlowPathLayer), so the accessors convert:
16756
+ // getDashArray carries the pattern period (its gap stays 0 — the
16757
+ // extension renders solid, the moving gap is the flow hook),
16758
+ // getFlowPhase the solid length and flat's random per-arc phase
16759
+ const layers = [new FlowPathLayer({
15950
16760
  id: `ix-vector-${this.name}`, data: paths, pickable: true,
15951
16761
  getPath: d => d.path, getColor: d => d.colors, getWidth: d => d.width,
16762
+ getDashArray: d => [(d.dash[0] + d.dash[1]) * 2 / Math.max(d.width, 1), 0],
16763
+ getFlowPhase: d => [d.dash[0] * 2 / Math.max(d.width, 1), d.dash[2]],
15952
16764
  widthUnits: 'pixels', capRounded: dash && bezier, jointRounded: false, billboard: false,
15953
- parameters: ICON_LAYER_GLOBE_PARAMETERS,
15954
- ...(dash ? { getDashArray: d => d.dash, extensions: [new PathStyleExtension({ dash: true })] } : {})
16765
+ extensions: [new PathStyleExtension({ dash: true, dashMode: 'path' })],
16766
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
15955
16767
  })];
15956
16768
  if (heads.length) {
15957
16769
  // the head count in the id: deck kept a same-id polygon layer's
@@ -15970,7 +16782,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15970
16782
  // flat's chart position for this build (flatChartAlignOffset): a
15971
16783
  // function item → pixel offset, the same for every part of one chart
15972
16784
  // (symbol, glow, shadow, box, title, value text). unit is flat's
15973
- // normalX(1) on screen, the object zoom × style.scale.
16785
+ // normalX(1) on screen, the object zoom × style.scale. flat keeps a margin of 5 units between the
16786
+ // position and the chart's facing edge for left/right/top/bottom; the gl-only style.alignmargin
16787
+ // changes it (0: the edge touches the position, so a left and a right chart touch each other)
15974
16788
  _chartAlignFn(liveZoom, radiusOf, textOnly = false) {
15975
16789
  const st = this.style;
15976
16790
  const none = () => [0, 0];
@@ -15979,12 +16793,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15979
16793
  const unit = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(st.scale) || 1);
15980
16794
  const symbol = flatChartBranch(this.flags) === 'symbol';
15981
16795
  const normalR = NORMAL_RADIUS_PX * unit; // flat's normalX(chart size / 2)
16796
+ const margin = Number.isFinite(styleNum(st.alignmargin)) ? styleNum(st.alignmargin) : 5;
15982
16797
  const cache = new Map();
15983
16798
  const fn = d => {
15984
16799
  let v = cache.get(d);
15985
16800
  if (v) return v;
15986
16801
  const r = radiusOf(d);
15987
- v = flatChartAlignOffset(st, { unit, symbolScale: symbol ? r / (2 * normalR) : 1, half: symbol ? normalR : r, orig: r + (textOnly ? 0 : 5 * unit) });
16802
+ v = flatChartAlignOffset(st, { unit, symbolScale: symbol ? r / (2 * normalR) : 1, half: symbol ? normalR : r, orig: r + (textOnly ? 0 : margin * unit) });
15988
16803
  cache.set(d, v);
15989
16804
  return v;
15990
16805
  };
@@ -16051,7 +16866,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16051
16866
  const v = raw && raw[this.style.labelfield];
16052
16867
  let text = v == null || v === '' ? '' : String(v);
16053
16868
  if (!text) return null;
16054
- const num = parseFloat(text.replace(',', '.'));
16869
+ // flat reads it with __scanValue: "1.234,5", "12 500"
16870
+ const num = flatScanValue(text);
16055
16871
  text = (!isNaN(num) ? flatGroupedValue(num, dec != null ? dec : 2, noBreaks) : text) + units;
16056
16872
  const width = Math.max(titleTextWidth(text, f), (text.length + 2) * f * 0.5);
16057
16873
  const x = width / 3, y = f / 3 + r + 5 * unitPx;
@@ -16162,7 +16978,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16162
16978
  const textOnly = this.flags.has('TEXTONLY');
16163
16979
  const showValues = this.flags.has('VALUES') && !valuesHiddenByScale(this.style, zoom);
16164
16980
  if (textOnly && !showValues) return [];
16165
- const radiusOf = d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
16981
+ const radiusOf = d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue, this._sizeBound());
16166
16982
  const alignOf = this._chartAlignFn(liveZoom, radiusOf, textOnly);
16167
16983
  const valueScale = styleNum(this.style.valuescale) || 1;
16168
16984
  const textOverride = this.style.valuecolor || this.style.textcolor;
@@ -16223,6 +17039,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16223
17039
  // the box is sized by the value's own text, not the valuefield's
16224
17040
  const boxStyle = Object.assign({}, this.style, { valuefield: undefined });
16225
17041
  const boxes = [], texts = [];
17042
+ // the label's drawn extent for the chart-group BOX (below): x from -r
17043
+ // to 1.05r, y from -0.83f to 0.77f around the chart origin
17044
+ const labelExtent = new Map();
16226
17045
  for (const d of combined) {
16227
17046
  const r = radiusOf(d);
16228
17047
  if (!(r > 0)) continue;
@@ -16237,6 +17056,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16237
17056
  const o = alignOf(d);
16238
17057
  // the box's center relative to the chart's: x from −r to 1.05r, y from −0.83f to 0.77f
16239
17058
  boxes.push({ ...d, icon, pixelOffset: [o[0] + 0.025 * r, o[1] - 0.03 * f] });
17059
+ labelExtent.set(d, [-r, -0.83 * f, 1.05 * r, 0.77 * f]);
16240
17060
  if (showValues) {
16241
17061
  const text = textOf(props);
16242
17062
  const fontSize = Math.min(r * 0.8, r * 3.3 / Math.max(1, text.length)) * valueScale;
@@ -16244,7 +17064,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16244
17064
  if (fontSize > 0) texts.push({ ...d, text, fontSize, color, pixelOffset: o });
16245
17065
  }
16246
17066
  }
16247
- const layers = [];
17067
+ // BOX / TITLE: flat's chart-group box around the label chart
17068
+ const boxRows = this._boxRows(() => combined.filter(d => labelExtent.has(d)), { unit, liveZoom, extentOf: d => labelExtent.get(d) });
17069
+ const boxLayers = boxRows ? this._boxTitleLayers(boxRows, {
17070
+ unit, liveZoom, positionOf: b => b.d.geometry.coordinates, offsetOf: b => alignOf(b.d),
17071
+ ids: { circle: `ix-label-box-${this.name}`, icon: `ix-label-box-${this.name}`, polygons: `ix-label-box-polygons-${this.name}`, titles: `ix-label-titles-${this.name}` }
17072
+ }) : null;
17073
+ const layers = boxLayers ? [...boxLayers.box] : [];
16248
17074
  if (boxes.length) {
16249
17075
  layers.push(new IconLayer({
16250
17076
  id: `ix-bubbles-${this.name}-g${this._iconGeneration}`,
@@ -16278,6 +17104,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16278
17104
  parameters: ICON_LAYER_GLOBE_PARAMETERS
16279
17105
  }));
16280
17106
  }
17107
+ if (boxLayers) layers.push(...boxLayers.titles);
16281
17108
  return layers;
16282
17109
  }
16283
17110
 
@@ -16535,12 +17362,16 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16535
17362
  const _mapHandles = Object.create(null);
16536
17363
  function makeMapHandle(szMap) {
16537
17364
  const h = {};
16538
- const api = () => (szMap && _mapApis[szMap]) || _lastMapApi;
17365
+ // a name a Map() declared waits for that map; any other name (or none)
17366
+ // is the last built map, as before
17367
+ const declared = () => szMap && _namedMapReady[szMap];
17368
+ const api = () => (szMap && _mapApis[szMap]) || (declared() ? null : _lastMapApi);
16539
17369
  for (const m of MAP_HANDLE_METHODS) {
16540
17370
  h[m] = (...args) => {
16541
17371
  const a = api();
16542
17372
  if (a) { const r = a[m](...args); return r === a ? h : r; }
16543
- _mapReady.then(ready => ready[m](...args));
17373
+ (declared() ? declared().promise : _mapReady).then(ready => ready[m](...args))
17374
+ .catch(err => console.error(`[ixmaps-gl] map(${szMap ? '"' + szMap + '"' : ''}).${m}:`, err));
16544
17375
  return h;
16545
17376
  };
16546
17377
  }
@@ -16708,6 +17539,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16708
17539
  // several themes of a page
16709
17540
  szId: themeIdOf(rt),
16710
17541
  szName: rt.name,
17542
+ // flat's szSelectionField: the field a click on an item selects by (the id binding, else the field the
17543
+ // item is positioned by)
17544
+ szSelectionField: selectionFieldOf(rt),
16711
17545
  // the filter in force: a runtime one (changeThemeStyle "filter")
16712
17546
  // replaces the definition's
16713
17547
  szFilter: rt._runtimeFilterExpr || rt._filterExpr || '',
@@ -16744,6 +17578,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16744
17578
  get itemA() { return onMap().itemA; }
16745
17579
  };
16746
17580
  }
17581
+ // the field whose value identifies an item on a click (flat's szSelectionField): binding.id, else the position field
17582
+ // (which a join to a geometry layer turns into binding.lookup)
17583
+ function selectionFieldOf(rt) {
17584
+ const b = rt.binding || {};
17585
+ const f = b.id || b.position || b.lookup;
17586
+ return typeof f === 'string' && f && f !== 'geometry' && !/^geometry$/i.test(f) ? f : null;
17587
+ }
16747
17588
  const _warnedOnce = new Set();
16748
17589
  function warnOnce(key, msg) { if (!_warnedOnce.has(key)) { _warnedOnce.add(key); console.info(msg); } }
16749
17590
  // the theme's records as flat's dbFields/dbRecords (cached per data)
@@ -17005,9 +17846,14 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
17005
17846
  // their own comments) are still real, directly-callable globals a
17006
17847
  // page's OWN custom UI can use too — only THIS generated markup
17007
17848
  // happens to reach them via delegation instead of inline HTML.
17849
+ // the handlers below outlive this call: they read the theme id of
17850
+ // the LATEST call into this container (a theme switch re-renders
17851
+ // the same div for another theme)
17852
+ container._ixFacetsId = szId;
17008
17853
  if (!container.dataset.ixFacetsWired) {
17009
17854
  container.dataset.ixFacetsWired = '1';
17010
17855
  container.addEventListener('click', e => {
17856
+ const szId = container._ixFacetsId;
17011
17857
  const moreBtn = e.target.closest('[data-role="more"]');
17012
17858
  if (moreBtn) { moreBtn.hidden = true; moreBtn.nextElementSibling.hidden = false; return; }
17013
17859
 
@@ -17033,7 +17879,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
17033
17879
  container.addEventListener('keyup', e => {
17034
17880
  if (e.key !== 'Enter') return;
17035
17881
  const input = e.target.closest('[data-role="search"]');
17036
- if (input) global.__setFilter(szId, input.closest('[data-field]').dataset.field, input.value);
17882
+ if (input) global.__setFilter(container._ixFacetsId, input.closest('[data-field]').dataset.field, input.value);
17037
17883
  });
17038
17884
  }
17039
17885
  }
@@ -17123,11 +17969,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
17123
17969
  // known real page's globe toggle actually uses (see
17124
17970
  // example-world-bubble-projection-toggle.md) — so `ixmaps.szResourceBase`
17125
17971
  // only needs to be SOME string, not a real, working resource base URL.
17126
- // It is the flat release CDN anyway: flat's own tool scripts required by a
17127
- // page (tooltip_mustache.js) resolve their dependencies through it —
17128
- // $.getScript(ixmaps.szResourceBase + "ui/js/tools/format.js") — which
17129
- // with the empty string 404s against the page's own directory
17130
- const ixmapsSzResourceBase = 'https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-flat@1/';
17972
+ const ixmapsSzResourceBase = '';
17131
17973
  function getProjectString() {
17132
17974
  if (!_lastMapApi || !_lastMapApi.map) return JSON.stringify({ map: {} });
17133
17975
  const map = _lastMapApi.map;
@@ -17174,6 +18016,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
17174
18016
  function compatRemoveTheme(...a) {
17175
18017
  return _mapHandle.remove(a.length >= 2 ? a[1] : a[0]);
17176
18018
  }
18019
+ // flat's ixmaps.toggleTheme(szMap, szTheme) (htmlgui.js): show / hide a theme by its id, e.g. a page's on / off buttons
18020
+ function compatToggleTheme(...a) {
18021
+ new themeApi(null, a.length >= 2 ? a[1] : a[0]).toggle();
18022
+ }
17177
18023
  function compatSetBasemapOpacity(...a) {
17178
18024
  if (a.length >= 3) {
17179
18025
  const api = (a[0] && _mapApis[a[0]]) || _lastMapApi;
@@ -17209,6 +18055,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
17209
18055
  htmlgui_getAttributionString: () => (_lastMapApi && _lastMapApi.getAttribution ? _lastMapApi.getAttribution() : ''),
17210
18056
  changeThemeStyle: compatChangeThemeStyle,
17211
18057
  removeTheme: compatRemoveTheme,
18058
+ toggleTheme: compatToggleTheme,
17212
18059
  // flat's ixmaps.setLocalString(orig, local) (htmlgui.js 3814 →
17213
18060
  // Api.setLocalString → Dictionary.replace): a page-wide translation,
17214
18061
  // e.g. the migration page's ISO code → country name; usable before
@@ -17247,19 +18094,19 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
17247
18094
  // test-only: lets test/unit/*.test.mjs call pure internals directly (the
17248
18095
  // engine runs in a Node vm there); deliberately NOT on the ixmaps object
17249
18096
  global.__ixmapsGlInternals = {
17250
- IXMAPS_GL_VERSION, applyFlatMaxCharts, youtubeClickToPlay, symbolsFlagCompat, tooltipTable, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout, ringsLayout, pieSliceLayout, pieValueLabelLayout, isBarChart, flatBarLayout, flatChartBox, flatValueRules, flatPieRecord, flatPieAccumulate, flatPieItemValues, flatVectorItems, orthographicPlane, vectorArcHeight, pathBackIndex, greatCircleMeters, cameraOptions, bezierVectorLayout, cubicBezierPoints, arrowMarkerTriangle, fadeGradientStops, flatToArray, hashUnit, multiQuadOffsets, pixelOffsetLngLat, chartTextTransmittance, insideSymbolShape,
17251
- choroplethOutlineColor, normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, MapBuilder, typeStyleNumbers, styleNum,
18097
+ IXMAPS_GL_VERSION, applyFlatMaxCharts, youtubeClickToPlay, symbolsFlagCompat, tooltipTable, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout, ringsLayout, pieSliceLayout, pieValueLabelLayout, isBarChart, flatBarLayout, flatValueRules, flatPieRecord, flatPieAccumulate, flatPieItemValues, flatVectorItems, orthographicPlane, vectorArcHeight, pathBackIndex, greatCircleMeters, cameraOptions, bezierVectorLayout, cubicBezierPoints, arrowMarkerTriangle, fadeGradientStops, flatToArray, hashUnit, flowDashPhase, multiQuadOffsets, pixelOffsetLngLat, chartTextTransmittance, insideSymbolShape,
18098
+ chartBoxLayout, choroplethOutlineColor, normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, MapBuilder, typeStyleNumbers, styleNum,
17252
18099
  resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
17253
18100
  equalIntervalBreaks, quantileBreaks, naturalBreaks, evenStrideSample, jenksBreakValues, partsFromBreakValues, resolvePartsClass,
17254
18101
  computeAlphaStats, computeMultiFieldClasses, computeDominantStats, resolveDominantClass, computeComposeColorStats,
17255
18102
  resolveComposedColor, computeRangeClasses, colorSchemeClassCount, resolveDopacityAlpha,
17256
18103
  flatColorSweep, applyClassesToColorScheme, resolveClassColors, flatOutlierStats, parseCssColor, flatLegendLook,
17257
18104
  flatToMapLibreZoom, mapLibreToFlatZoom, flatViewToMapLibreZoom, mapLibreToFlatViewZoom, flatSvgProjectionOf, flatSvgProjectionByOptions, normalSizeScaleOf, scaleDenominatorAt, resolveZoomReference, resolveAggregationPx, valuesHiddenByScale,
17258
- fetchLayerData, 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,
17259
- applyWhereFilter, joinChartPositions, flatLookupKey, flatShapeCenter, chartHiddenByScale, glowHiddenByScale, isSymbolChart, flatChartAlignOffset, flatChartBranch, flatValueText, categoryValueRecord, flatDerivateRgb, flatBubbleLook, bubbleIconLook, flatChartTextRgb, flatGroupedValue, flatNoBreaks, featuresHiddenByScale, boxHiddenByScale, flatShadowOn, snapToAggregationGrid,
17260
- drawSymbolPath, normalizeSymbolShape,
18105
+ 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
+ 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,
17261
18108
  flatFormatValue, themeStyleArgs, flatChangedStyleValue, explicitValueDecimals, legendDecimals,
17262
- dataCacheKey, dataCacheDisabled, cachedLayerData, featuresBounds, wantsZoomToExtent, legendIsOn,
18109
+ dataCacheKey, dataCacheDisabled, cachedLayerData, featuresBounds, wantsZoomToExtent, legendIsOn, applySphereBackdrop,
17263
18110
  };
17264
18111
  global.__setFilter = __setFilter;
17265
18112
  global.__removeFacets = __removeFacets;