ixmaps-gl 0.2.24 → 0.2.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +3 -3
  2. package/ixmaps-gl.js +1343 -665
  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.25';
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
  // ---------------------------------------------------------------
@@ -1402,7 +1547,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1402
1547
  }
1403
1548
  obj = runDataProcess(obj, dataConfig);
1404
1549
  }
1405
- const rows = typeof obj.json === 'function' ? obj.json() : obj;
1550
+ // a raw jsonDB object ({fields, records}) → rows, with or without
1551
+ // process (and without needing data.js)
1552
+ const isJsonDb = !Array.isArray(obj) && Array.isArray(obj.fields) && Array.isArray(obj.records);
1553
+ const rows = typeof obj.json === 'function' ? obj.json() : isJsonDb ? dataTableRows(obj) : obj;
1406
1554
  return rowsResult(rows, binding);
1407
1555
  }
1408
1556
 
@@ -1412,9 +1560,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1412
1560
  '<script src="https://cdn.jsdelivr.net/gh/gjrichter/data.js@master/data.js"> before ixmaps-gl.js');
1413
1561
  }
1414
1562
  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({}, {});
1563
+ // its own options object: the query hands it back with the data
1564
+ const opts = { type: dataConfig.type, name: dataConfig.name };
1565
+ const timeoutMs = QUERY_HANDOFF.timeoutMs || DATAJS_LOAD_TIMEOUT_MS;
1566
+ const dataObj = await new Promise((resolve, reject) => {
1567
+ const timer = setTimeout(() => settleQuery(opts, new Error(`[ixmaps-gl] .data({query}): no data handed over (ixmaps.setExternalData) within ${timeoutMs / 1000}s`)), timeoutMs);
1568
+ _pendingQueries.set(opts, { name: dataConfig.name != null ? String(dataConfig.name) : null, resolve, reject, timer });
1569
+ try { queryFn({}, opts); } catch (err) { settleQuery(opts, err); }
1418
1570
  });
1419
1571
  return rowsResult((dataConfig.process ? runDataProcess(dataObj, dataConfig) : dataObj).json(), binding);
1420
1572
  }
@@ -1436,8 +1588,21 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1436
1588
  // parser (PapaParse), so the Table arrives via the callback
1437
1589
  const tables = await Promise.all(texts.map(dataJsCsvTable));
1438
1590
  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;
1591
+ // records are positional: align each later file's columns to the
1592
+ // first file's by name (a column only a later file has is added),
1593
+ // as the no-data.js path below gets from each file's own header
1594
+ for (const t of tables.slice(1)) {
1595
+ const ids = table.fields.map(f => f.id);
1596
+ const tIds = t.fields.map(f => f.id);
1597
+ if (tIds.length === ids.length && tIds.every((id, c) => id === ids[c])) {
1598
+ table.records = table.records.concat(t.records);
1599
+ continue;
1600
+ }
1601
+ t.fields.forEach(f => { if (!ids.includes(f.id)) { ids.push(f.id); table.fields.push(f); } });
1602
+ const src = ids.map(id => tIds.indexOf(id));
1603
+ table.records = table.records.concat(t.records.map(rec => src.map(c => (c < 0 ? undefined : rec[c]))));
1604
+ }
1605
+ if (table.table) { table.table.records = table.records.length; table.table.fields = table.fields.length; }
1441
1606
  if (scriptProcess) table = scriptProcess(table);
1442
1607
  else if (dataConfig.process) table = runDataProcess(table, dataConfig);
1443
1608
  rows = dataTableRows(table);
@@ -1858,21 +2023,25 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1858
2023
  if (isAggregatedCategoricalChoropleth(spec)) {
1859
2024
  const groups = new Map();
1860
2025
  for (const row of table.rows) {
1861
- const key = String(row[lookupField]);
2026
+ if (row[lookupField] == null) continue;
2027
+ const key = flatLookupKey(row[lookupField], spec.style);
1862
2028
  if (!groups.has(key)) groups.set(key, []);
1863
2029
  groups.get(key).push(row);
1864
2030
  }
1865
2031
  return {
1866
2032
  type: 'FeatureCollection',
1867
2033
  features: geomRt.features.map(f => {
1868
- const rows = groups.get(String(f.properties[idField]));
2034
+ const rows = f.properties[idField] == null ? null : groups.get(String(f.properties[idField]));
1869
2035
  const properties = rows ? Object.assign({}, rows[0]) : {};
1870
2036
  if (rows) Object.defineProperty(properties, AGGREGATED_ROWS, { value: rows });
1871
2037
  return { type: 'Feature', geometry: f.geometry, properties, _flatAreaKm2: flatShapeAreaKm2(geomRt, f) };
1872
2038
  })
1873
2039
  };
1874
2040
  }
1875
- const rowsByKey = new Map(table.rows.map(row => [String(row[lookupField]), row]));
2041
+ // the data side's key as flat makes it (lookupdigits/toupper/tonumber),
2042
+ // the geometry id as is — the same rule as joinChartPositions
2043
+ // a missing key on either side joins nothing (not "undefined" to "undefined")
2044
+ const rowsByKey = new Map(table.rows.filter(row => row[lookupField] != null).map(row => [flatLookupKey(row[lookupField], spec.style), row]));
1876
2045
  // Unmatched polygons (no CSV row for that id) keep their geometry with
1877
2046
  // empty properties — real-world data: not every comune necessarily has
1878
2047
  // a row in every demographic source. Renders as "no data" (see
@@ -1881,7 +2050,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1881
2050
  const features = geomRt.features.map(f => ({
1882
2051
  type: 'Feature',
1883
2052
  geometry: f.geometry,
1884
- properties: rowsByKey.get(String(f.properties[idField])) || {},
2053
+ properties: (f.properties[idField] != null && rowsByKey.get(String(f.properties[idField]))) || {},
1885
2054
  _flatAreaKm2: flatShapeAreaKm2(geomRt, f)
1886
2055
  }));
1887
2056
  return { type: 'FeatureCollection', features };
@@ -2294,13 +2463,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2294
2463
  // this list; the rest is still recognized-but-inert
2295
2464
  const KNOWN_INERT_FLAGS = ['CLIPTOGEOBOUNDS', 'OUTLIER',
2296
2465
  '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/];
2466
+ // flat's PIE / DONUT variants with no rendering here (see _buildPieLayers).
2467
+ // STARBURST / FLOWER / RAYS (the starburst rays, with the XLRAYS & kin
2468
+ // angle/gap variants) and POLAR (its reference circles) ARE drawn — see
2469
+ // pieSliceLayout; the rest is still recognized-but-inert
2470
+ const PIE_INERT_FLAGS = [/3D/, /VOLUME/, /HEIGHT/, /BOW/, /ZOOM/, /DIRECTION/, /DIRECTED/];
2299
2471
  // ... 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$/,
2472
+ // grid cell (drawn by the SIZE rule instead), AUTOSIZE, DOPACITY,
2473
+ // GAUGE, MULTIPLE / DIFFUSE, the post-aggregation
2474
+ // value arithmetic (DIFFERENCE, FRACTION, PERCENT …, field100).
2475
+ // The chart BOX and TITLE ARE drawn (see _buildPieLayers)
2476
+ const PIE_AGGREGATE_INERT_FLAGS = [/^GRIDSIZE$/, /AUTOSIZE/, /DOPACITY/, /^GAUGE$/, /^MULTIPLE$/, /^DIFFUSE$/,
2304
2477
  /^DIFFERENCE$/, /^FRACTION$/, /^PERCENT$/, /^PERMILLE$/, /^PRODUCT$/, /^CALCVAL$/, /^RELATIVE$/];
2305
2478
  // flat's BAR / BARS variants with no rendering here (see flatBarLayout):
2306
2479
  // the bar is drawn plain (SMALLARROW as ARROW) — tested as substrings,
@@ -2310,11 +2483,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2310
2483
  /^DIFFERENCE$/, /^FRACTION$/, /^PERCENT$/, /^PERMILLE$/, /^PRODUCT$/, /^CALCVAL$/, /^RELATIVE$/];
2311
2484
  // flat's VECTOR / BEZIER modifiers with no rendering here (see
2312
2485
  // _buildVectorLayers), the word → what happens instead
2486
+ // (DASH IS drawn — the animated flow, see makeFlowPathLayer)
2313
2487
  const VECTOR_INERT_FLAGS = {
2314
2488
  VALUES: 'the value text along the curve is not drawn yet',
2315
2489
  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'
2490
+ LONG: 'not drawn yet (its bow depends on flat\'s own map units) — the default bow instead'
2318
2491
  };
2319
2492
  const _warnedFlags = new Set();
2320
2493
 
@@ -2359,6 +2532,18 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2359
2532
  // makes while the map is still building wait for it
2360
2533
  let _resolveMapReady;
2361
2534
  const _mapReady = new Promise(r => { _resolveMapReady = r; });
2535
+ // a map declared with an options name (Map(id, {name})) → the deferred of
2536
+ // ITS build: ixmaps.map("name") calls made while it is still building wait
2537
+ // for that map, not whichever map is built first
2538
+ const _namedMapReady = Object.create(null);
2539
+ function namedMapReady(name) {
2540
+ if (!_namedMapReady[name]) {
2541
+ let resolve;
2542
+ const promise = new Promise(r => { resolve = r; });
2543
+ _namedMapReady[name] = { promise, resolve };
2544
+ }
2545
+ return _namedMapReady[name];
2546
+ }
2362
2547
 
2363
2548
  // flat's ixmaps.require(url) fetches the script and evals it
2364
2549
  // (htmlgui.js 3911-3934); here each one runs globally, in order, before
@@ -2478,7 +2663,16 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2478
2663
  // key wins. Always into a new object — the page's own objects are never
2479
2664
  // mutated. Aliases (geo → position, ...) are resolved in normalizeTheme.
2480
2665
  binding(b) {
2481
- this._binding = Object.assign({}, this._binding, b);
2666
+ // flat's toString joins an array with '|' (maptheme.js 1014): a
2667
+ // binding given as an ARRAY — .binding({value: ["out","in"]}, the
2668
+ // pendolari page's saldo pie) is a MULTI-FIELD binding, not one
2669
+ // field named "out,in" (which would find no column and draw
2670
+ // nothing). Normalized once, here, for every consumer that
2671
+ // splits binding.value on '|'
2672
+ const norm = {};
2673
+ // (an empty or missing argument keeps what earlier calls set)
2674
+ for (const [k, v] of Object.entries(b || {})) norm[k] = Array.isArray(v) ? v.join('|') : v;
2675
+ this._binding = Object.assign({}, this._binding, norm);
2482
2676
  return this;
2483
2677
  }
2484
2678
  filter(expr) { this._filterExpr = expr; return this; }
@@ -2768,27 +2962,75 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2768
2962
  // that kind: the data processing function (data.process /
2769
2963
  // style.dbtableProcess), the broker query (data.query /
2770
2964
  // 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).
2965
+ // provider the PAGE defined as ixmaps[name]) and a colorscheme
2966
+ // resolveColorScheme would evaluate: a string, or ["byvalue", "<fn>"]
2967
+ // (any other array colorscheme is data and stays). normalizeTheme
2968
+ // merges meta INTO style, so every style key is checked under meta too.
2969
+ const isColorSchemeCode = v => typeof v === 'string'
2970
+ || (Array.isArray(v) && v[0] === 'byvalue' && typeof v[1] === 'string');
2774
2971
  const PROJECT_CODE_KEYS = [
2775
2972
  ['data', 'process', 'data.process function'],
2776
2973
  ['style', 'dbtableProcess', 'data.process function'],
2777
2974
  ['data', 'query', 'data.query function'],
2778
2975
  ['style', 'dbtableQuery', 'data.query function'],
2779
- ['style', 'colorscheme', 'colorscheme function', v => typeof v === 'string'],
2780
- ];
2976
+ ['style', 'colorscheme', 'colorscheme function', isColorSchemeCode],
2977
+ ].flatMap(k => (k[0] === 'style' ? [k, ['meta', ...k.slice(1)]] : [k]));
2978
+ // A project's legend texts (title, snippet, description — in style or
2979
+ // meta, normalizeTheme fills meta from either) are shown as HTML, as on
2980
+ // flat: citation links in a description are real. A project's markup is
2981
+ // cleaned to formatting tags and plain attributes so nothing in it runs;
2982
+ // a page's own .meta()/.style() HTML stays as given.
2983
+ const PROJECT_HTML_KEYS = ['title', 'snippet', 'description'];
2984
+ const PROJECT_HTML_DROP = new Set(['SCRIPT', 'STYLE', 'IFRAME', 'FRAME', 'FRAMESET', 'OBJECT', 'EMBED', 'SVG', 'MATH',
2985
+ 'TEMPLATE', 'NOSCRIPT', 'LINK', 'META', 'BASE', 'FORM', 'INPUT', 'BUTTON', 'SELECT', 'TEXTAREA', 'AUDIO', 'VIDEO', 'SOURCE']);
2986
+ const PROJECT_HTML_TAGS = new Set(['A', 'B', 'I', 'EM', 'STRONG', 'U', 'S', 'SMALL', 'SUB', 'SUP', 'BR', 'P', 'DIV', 'SPAN',
2987
+ 'UL', 'OL', 'LI', 'HR', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'IMG', 'TABLE', 'THEAD', 'TBODY', 'TR', 'TD', 'TH', 'CODE', 'BLOCKQUOTE']);
2988
+ const PROJECT_HTML_ATTRS = new Set(['href', 'src', 'alt', 'title', 'target', 'rel', 'class', 'style', 'width', 'height', 'colspan', 'rowspan']);
2989
+ function sanitizeProjectHtml(html) {
2990
+ const s = String(html);
2991
+ if (!/[<&]/.test(s)) return s;
2992
+ // no DOM to parse with (Node): the text, escaped
2993
+ if (typeof global.DOMParser !== 'function') return escapeHtml(s);
2994
+ // an inert document: nothing in it runs or loads while it is cleaned
2995
+ const body = new global.DOMParser().parseFromString('<!doctype html><body>' + s, 'text/html').body;
2996
+ for (const el of Array.from(body.querySelectorAll('*'))) {
2997
+ if (PROJECT_HTML_DROP.has(el.tagName.toUpperCase())) { el.remove(); continue; }
2998
+ if (!PROJECT_HTML_TAGS.has(el.tagName.toUpperCase())) { el.replaceWith(...el.childNodes); continue; }
2999
+ for (const a of Array.from(el.attributes)) {
3000
+ const name = a.name.toLowerCase();
3001
+ if (!PROJECT_HTML_ATTRS.has(name)
3002
+ || ((name === 'href' || name === 'src') && /^(javascript|vbscript|data):/i.test(a.value.replace(/[\s\u0000-\u001f]/g, '')))) {
3003
+ el.removeAttribute(a.name);
3004
+ }
3005
+ }
3006
+ }
3007
+ return body.innerHTML;
3008
+ }
3009
+
2781
3010
  function withoutProjectCode(t, report) {
2782
3011
  const found = PROJECT_CODE_KEYS.filter(([part, key, , isCode]) =>
2783
3012
  t[part] && t[part][key] != null && (!isCode || isCode(t[part][key])));
2784
- if (!found.length) return t;
3013
+ const html = [];
3014
+ for (const part of ['style', 'meta']) {
3015
+ for (const key of PROJECT_HTML_KEYS) {
3016
+ const v = t[part] && t[part][key];
3017
+ if (typeof v !== 'string') continue;
3018
+ const clean = sanitizeProjectHtml(v);
3019
+ if (clean !== v) html.push([part, key, clean]);
3020
+ }
3021
+ }
3022
+ if (!found.length && !html.length) return t;
2785
3023
  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`);
3024
+ const own = part => { if (copy[part] === t[part]) copy[part] = Object.assign({}, t[part]); return copy[part]; };
3025
+ found.forEach(([part, key]) => { delete own(part)[key]; });
3026
+ html.forEach(([part, key, clean]) => { own(part)[key] = clean; });
3027
+ if (found.length) {
3028
+ const what = [...new Set(found.map(f => f[2]))].join(', ');
3029
+ report.notes.push(`theme "${t.layer}": the project's ${what} is never run by ixmaps-gl`);
3030
+ }
3031
+ if (html.length) {
3032
+ 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`);
3033
+ }
2792
3034
  return copy;
2793
3035
  }
2794
3036
 
@@ -3367,7 +3609,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3367
3609
  map.setPaintProperty(id, 'background-opacity', opacity);
3368
3610
  } catch (e) { console.warn('[ixmaps-gl] the {type:"Sphere"} ocean backdrop could not be set:', e); }
3369
3611
  };
3370
- if (map.isStyleLoaded()) paint(); else map.once('style.load', paint);
3612
+ // isStyleLoaded() is also false AFTER the style loaded while tiles or
3613
+ // images load (every pan/zoom) — 'style.load' never fires again then, so
3614
+ // a Sphere theme defined at such a moment would never get its color
3615
+ if (map.isStyleLoaded() || (map.style && map.style._loaded)) paint(); else map.once('style.load', paint);
3371
3616
  }
3372
3617
 
3373
3618
  function buildBlankBackgroundStyle(color) {
@@ -3516,7 +3761,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3516
3761
  // visible (an element larger than the viewport ends up at its edge)
3517
3762
  function keepTooltipVisible(tipEl, x, y) {
3518
3763
  const w = tipEl.offsetWidth, h = tipEl.offsetHeight;
3519
- const vw = global.innerWidth || 1200, vh = global.innerHeight || 800;
3764
+ // x/y and the translate are relative to the map container (it clips,
3765
+ // overflow:hidden): its size is the room, not the window's — they
3766
+ // differ on an embedded map
3767
+ const vw = el.clientWidth || global.innerWidth || 1200, vh = el.clientHeight || global.innerHeight || 800;
3520
3768
  let tx = x > vw / 2 ? x - w - 30 : x + 30;
3521
3769
  let ty = y > vh / 2 ? y - h - 20 : y + 20;
3522
3770
  tx = Math.min(Math.max(8, tx), Math.max(8, vw - w - 8));
@@ -4116,13 +4364,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4116
4364
  // twin pages)
4117
4365
  const flatUnitLegend = rt._isVectorChart() || rt._isAggregatedPie();
4118
4366
  const rawLegendUnit = rt.style.legendunits || rt.style.units || '';
4119
- const legendUnit = flatUnitLegend && rawLegendUnit && rawLegendUnit[0] !== '.' && rawLegendUnit[0] !== ' ' ? ' ' + rawLegendUnit : rawLegendUnit;
4367
+ // COUNT: the rows are record counts — flat prints them as whole numbers
4368
+ // with no unit (legend.js 705, 810: __formatValue(count, nDecimals,
4369
+ // "BLANK") + (COUNT ? "" : " " + unit))
4370
+ const countLegend = flatFlag(rt.flags, 'COUNT') && !useSum;
4371
+ const legendUnit = countLegend ? '' : flatUnitLegend && rawLegendUnit && rawLegendUnit[0] !== '.' && rawLegendUnit[0] !== ' ' ? ' ' + rawLegendUnit : rawLegendUnit;
4120
4372
  // a per-item chart over several value fields: the row values as
4121
4373
  // flat's legend prints them (legend.js 610, 810: __formatValue
4122
4374
  // with valuedecimals, else 2, "BLANK" — whole numbers without
4123
4375
  // decimals), a colorscheme "none" part as an outline in linecolor
4124
4376
  const multiFieldLegend = rt._isMultiFieldChart();
4125
- const legendTotalText = v => ((multiFieldLegend || flatUnitLegend)
4377
+ const legendTotalText = v => ((multiFieldLegend || flatUnitLegend || countLegend)
4126
4378
  ? flatFormatValue(v, legendDecimals(rt.style), 'BLANK')
4127
4379
  : rt._formatTooltipValue(v));
4128
4380
  const outlineRgb = rt.style.linecolor && styleLineColor(rt.style.linecolor) !== 'none' ? hexOrNamedToRgb(styleLineColor(rt.style.linecolor)) : [128, 128, 128];
@@ -4425,7 +4677,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4425
4677
  // same 400ms as the facets sidebar's own onRedraw use), same
4426
4678
  // signal already used elsewhere in this file for "the visible
4427
4679
  // data just changed, recompute".
4428
- engineApi.onRedraw(() => { computeTotals(); renderRows(); });
4680
+ // a panel replaced (rebuild, legend off) or a removed theme: the
4681
+ // listener goes with it — else one per rebuild piles up, each
4682
+ // recomputing a stale runtime on every redraw
4683
+ const offRedraw = engineApi.onRedraw(() => {
4684
+ if (rt._legendPanel !== panel || !runtimes.includes(rt)) { offRedraw(); return; }
4685
+ computeTotals(); renderRows();
4686
+ });
4429
4687
  }
4430
4688
 
4431
4689
  // rt._triggerRedraw: see its own doc comment on LayerRuntime
@@ -4740,7 +4998,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4740
4998
  }
4741
4999
  return this;
4742
5000
  }
4743
- options(o) { this._engineOptions = o; return this; }
5001
+ // merged, as flat's options() sets each key on its own (ixmaps.setOptions)
5002
+ // and the live map's options() below does — .options({a}).options({b})
5003
+ // keeps a
5004
+ options(o) { if (o && typeof o === 'object') Object.assign(this._engineOptions, o); return this; }
4744
5005
  local(...args) { this._locals.push(args); return this; }
4745
5006
  // flat's .require(url) (htmlgui.js ixmaps.require): a script the page
4746
5007
  // needs (a chart library, a user chart) — page code, like a <script>
@@ -4793,6 +5054,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4793
5054
  // the Map(id, opts, map => map.options(...)) callback form worked).
4794
5055
  // One microtask lets the caller's whole synchronous chain land first.
4795
5056
  await null;
5057
+ // the DASH flow animation's repaint loop (see refreshLayers): MapLibre
5058
+ // renders on demand only, so the shader's wall-clock dash (see
5059
+ // makeFlowPathLayer) needs a continuous frame request while any flow
5060
+ // layer is on screen — one handle per map, or another map's refresh
5061
+ // cancels (or blocks) this map's loop
5062
+ let _flowRepaintRAF = null;
4796
5063
  let el = document.getElementById(this.containerId);
4797
5064
  if (!el) {
4798
5065
  // flat's own fallback for a missing target div (htmlgui_flat.js
@@ -4812,7 +5079,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4812
5079
  el = document.createElement('div');
4813
5080
  el.id = this.containerId;
4814
5081
  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%';
5082
+ // a percentage height would collapse against a body without a
5083
+ // height of its own (the blank-screen case): the window's
5084
+ // innerHeight instead, like flat's fullscreen fallback
5085
+ el.style.height = (this.mapOptions && this.mapOptions.height) ? String(this.mapOptions.height)
5086
+ : (global.innerHeight || 800) + 'px';
4816
5087
  document.body.appendChild(el);
4817
5088
  }
4818
5089
  // flat's init replaces the container's content with its own map page
@@ -4836,13 +5107,22 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4836
5107
  else if (typeof v === 'string' && /^\s*\d+(\.\d+)?\s*%\s*$/.test(v)
4837
5108
  && !(dim === 'width' ? el.clientWidth : el.clientHeight)) el.style[dim] = v.trim();
4838
5109
  }
4839
- // without width/height flat's map is "fullscreen" (htmlgui_flat.js
5110
+ // without height flat's map is "fullscreen" (htmlgui_flat.js
4840
5111
  // 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%';
5112
+ // container. PER SIDE — also when only a width is given (the World
5113
+ // Dataviz Prize pages: width 100%, no height), where the old
5114
+ // both-sides gate left the container collapsed at 0 (a blank
5115
+ // screen): no height option and no effective CSS height gets the
5116
+ // window's innerHeight, kept current on window resizes (MapLibre
5117
+ // follows its container's own size)
5118
+ if (!(this.mapOptions && this.mapOptions.height) && !el.clientHeight) {
5119
+ this._heightFallbackToWindow = true;
5120
+ el.style.height = (global.innerHeight || 800) + 'px';
5121
+ global.addEventListener('resize', () => {
5122
+ if (this._heightFallbackToWindow) el.style.height = (global.innerHeight || 800) + 'px';
5123
+ });
4845
5124
  }
5125
+ if (!(this.mapOptions && this.mapOptions.width) && !el.clientWidth) el.style.width = '100%';
4846
5126
 
4847
5127
  // shown for the whole build() (library lazy-load + every layer's own
4848
5128
  // data fetch, real-world verified as the two slowest phases) through
@@ -4869,8 +5149,6 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4869
5149
  const svgProjection = flatSvgProjectionOf(this.mapOptions.map)
4870
5150
  || (isFlatOrthographicName(this.mapOptions.mapProjection) ? FLAT_ORTHOGRAPHIC_PROJECTION : null);
4871
5151
  if (svgProjection) flatSvgProjectionByOptions.set(this._engineOptions, svgProjection);
4872
- const planeProjection = flatPlaneProjectionOf(this.mapOptions);
4873
- if (planeProjection) flatPlaneProjectionByOptions.set(this._engineOptions, planeProjection);
4874
5152
  if (isFlatOrthographicName(this.mapOptions.mapProjection)) {
4875
5153
  flatOrthographicByOptions.set(this._engineOptions, true);
4876
5154
  if (this._viewCenter) flatViewCenterByOptions.set(this._engineOptions, { lat: this._viewCenter[0], lng: this._viewCenter[1] });
@@ -5175,7 +5453,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5175
5453
  // a page legend (map.legend(html)) is positioned by the map's
5176
5454
  // own align option — the same rule that places the native stack
5177
5455
  legendEl.style.cssText = 'position:absolute;' + legendAlignCssFor(this.mapOptions) + 'z-index:5;pointer-events:none';
5178
- legendEl.innerHTML = this._legendHtml;
5456
+ // flat's legend takes the pane's html OR a URL to it (htmlgui):
5457
+ // a value without a tag is fetched, its html shown
5458
+ if (/<[a-z!]/i.test(this._legendHtml)) legendEl.innerHTML = this._legendHtml;
5459
+ else fetch(this._legendHtml).then(r => r.ok ? r.text() : '').then(t => { legendEl.innerHTML = t; }).catch(() => {});
5179
5460
  // inside the map container, never the page's body (see the pinned
5180
5461
  // tooltip mount) — styling body breaks page-positioned elements
5181
5462
  anchorInside(el);
@@ -5550,7 +5831,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5550
5831
  // called after every redraw (pan/zoom, or any of the setters above)
5551
5832
  // so a page can keep a facets sidebar in sync, mirroring the real
5552
5833
  // engine's htmlgui_onDrawTheme hook
5553
- onRedraw: (cb) => { redrawListeners.push(cb); },
5834
+ // returns an unsubscribe (the legend drops its listener with its panel)
5835
+ onRedraw: (cb) => {
5836
+ redrawListeners.push(cb);
5837
+ return () => { const i = redrawListeners.indexOf(cb); if (i >= 0) redrawListeners.splice(i, 1); };
5838
+ },
5554
5839
  refresh: () => refresh(),
5555
5840
  // Adds ONE new layer/theme to an ALREADY-BUILT map — build()'s own
5556
5841
  // loop above only ever runs once, at construction, so a caller
@@ -5700,7 +5985,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5700
5985
  // layers — leaving its groups mounted keeps the charts on the
5701
5986
  // map after map.remove()/replace() (censimenti's sidebar)
5702
5987
  if (rt._clearUserChartPane) rt._clearUserChartPane();
5988
+ if (rt._chartPaneDefs) { rt._chartPaneDefs.remove(); rt._chartPaneDefs = null; }
5703
5989
  if (rt._chartHost && rt._chartHost.runtimes) rt._chartHost.runtimes.delete(rt);
5990
+ // and its hidden <name>_init group (userChartHost, on the body)
5991
+ if (rt._userChartGroup) { rt._userChartGroup.remove(); rt._userChartGroup = null; }
5704
5992
  });
5705
5993
  const removed = removedRuntimes.length > 0;
5706
5994
  // stale-registry guard: _globalThemeRegistry is a bare name->
@@ -6002,8 +6290,6 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6002
6290
  const svgProjection = flatSvgProjectionOf(svg) || (/orthographic\.svg$/i.test(svg) ? FLAT_ORTHOGRAPHIC_PROJECTION : null);
6003
6291
  if (svgProjection) flatSvgProjectionByOptions.set(builder._engineOptions, svgProjection);
6004
6292
  else flatSvgProjectionByOptions.delete(builder._engineOptions);
6005
- if (svgProjection && svgProjection.invert) flatPlaneProjectionByOptions.set(builder._engineOptions, svgProjection);
6006
- else flatPlaneProjectionByOptions.delete(builder._engineOptions);
6007
6293
  if (/orthographic\.svg$/i.test(svg)) flatOrthographicByOptions.set(builder._engineOptions, true);
6008
6294
  else { flatOrthographicByOptions.delete(builder._engineOptions); flatViewCenterByOptions.delete(builder._engineOptions); }
6009
6295
  }
@@ -6174,6 +6460,22 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6174
6460
  if (order !== refreshLayers._lastOrder) { refreshLayers._lastOrder = order; console.info('[ixmaps-gl debug] draw order (bottom → top): ' + order); }
6175
6461
  }
6176
6462
  overlay.setProps({ layers });
6463
+ // start/stop the flow repaint loop: any layer whose paths carry
6464
+ // dash offsets animates, and only then (a static map renders
6465
+ // nothing new; the loop is one rAF, one triggerRepaint each)
6466
+ const flowOn = layers.some(l => l.constructor.layerName === 'FlowPathLayer'
6467
+ && l.props.data && l.props.data.some(d => d.dash && d.dash[0] + d.dash[1] > 0));
6468
+ if (glDebug()) console.info('[ixmaps-gl debug] flow loop on: ' + flowOn + ' (' + layers.filter(l => l.constructor.layerName === 'FlowPathLayer').length + ' flow layers, '
6469
+ + (layers.find(l => l.constructor.layerName === 'FlowPathLayer') || {props: {data: []}}).props.data.length + ' paths)');
6470
+ if (flowOn) {
6471
+ if (_flowRepaintRAF == null) {
6472
+ const loop = () => { if (map.triggerRepaint) map.triggerRepaint(); _flowRepaintRAF = global.requestAnimationFrame(loop); };
6473
+ _flowRepaintRAF = global.requestAnimationFrame(loop);
6474
+ }
6475
+ } else if (_flowRepaintRAF != null) {
6476
+ global.cancelAnimationFrame(_flowRepaintRAF);
6477
+ _flowRepaintRAF = null;
6478
+ }
6177
6479
  // flat's WMS themes render as native raster layers, not deck.gl —
6178
6480
  // reconcile them with the current runtimes on every rebuild
6179
6481
  syncWmsLayers();
@@ -6334,7 +6636,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6334
6636
  } finally { inDrawThemeHook = false; }
6335
6637
  }
6336
6638
  function notifyRedraw() {
6337
- redrawListeners.forEach(cb => { try { cb(); } catch (err) { console.error('[ixmaps-gl] onRedraw callback failed:', err); } });
6639
+ redrawListeners.slice().forEach(cb => { try { cb(); } catch (err) { console.error('[ixmaps-gl] onRedraw callback failed:', err); } });
6338
6640
  callDrawThemeHooks();
6339
6641
  }
6340
6642
 
@@ -6516,6 +6818,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6516
6818
  };
6517
6819
  }
6518
6820
  _resolveMapReady(engineApi);
6821
+ if (this.mapOptions && this.mapOptions.name) namedMapReady(this.mapOptions.name).resolve(engineApi);
6519
6822
  resolveBuilt();
6520
6823
  namedLoads.forEach(rt => { listenNamedData(rt); if (!rt._deferredLoad) loadNamedTheme(rt, false); });
6521
6824
  return engineApi;
@@ -6547,6 +6850,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6547
6850
  // class used throughout this file — a real bug caught during testing
6548
6851
  // (every `new Map()` below silently resolved to *this* function instead)
6549
6852
  function createMap(containerId, options, mapFn) {
6853
+ if (options && options.name) namedMapReady(options.name);
6550
6854
  const builder = new MapBuilder(containerId, options);
6551
6855
  if (mapFn) mapFn(builder);
6552
6856
  // If build() rejects (bad data URL, misconfigured CHOROPLETH join,
@@ -6614,7 +6918,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6614
6918
  // flat's mapApi.setView(center, zoom) (htmlgui_flat.js 1327) — the
6615
6919
  // same live-or-build-time split as .view() above
6616
6920
  promise.setView = (...args) => promise.view(...args);
6617
- promise.options = (...args) => { builder.options(...args); return promise; };
6921
+ // post-build: the live map's options() (merged into the options
6922
+ // object the running themes hold), as .view() above — the builder's
6923
+ // own is read once, by build()
6924
+ promise.options = (...args) => { if (resolvedApi) resolvedApi.options(...args); else builder.options(...args); return promise; };
6618
6925
  promise.attribution = (...args) => { builder.attribution(...args); return promise; };
6619
6926
  promise.legend = (...args) => { builder.legend(...args); return promise; };
6620
6927
  promise.local = (...args) => { builder.local(...args); return promise; };
@@ -6838,11 +7145,6 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6838
7145
  // the flat SVG projection a map's options object belongs to (set by the
6839
7146
  // map's init and loadProject; the runtimes only see the options object)
6840
7147
  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
7148
  // a map that is flat's orthographic one (drawn as MapLibre's globe): its object scale is
6847
7149
  // flat's (scale × the globe's pixels per degree at its center, measured on flat 2026-10-10
6848
7150
  // on the mechanical-goods globe page, the same at view zooms 2.5 and 3.5: 197.44e6; the
@@ -6885,12 +7187,6 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6885
7187
  const radius = 512 * Math.pow(2, zoom) / (2 * Math.PI * c);
6886
7188
  return Math.log2(globeDiscRadius(radius, height > 0 ? height : 768) * 2 * Math.PI / 512);
6887
7189
  }
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
7190
  // flat SVG map scale ÷ the Mercator one at the same on-screen size (1
6895
7191
  // without a flat SVG projection)
6896
7192
  function flatMapScaleRatio(mapOptions) {
@@ -7678,8 +7974,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7678
7974
  _measureCtx.font = fontPx + 'px arial';
7679
7975
  return _measureCtx.measureText(text).width;
7680
7976
  }
7681
- function wrapTitleLines(text, fontPx, widthPx) {
7682
- const width = w => titleTextWidth(w, fontPx);
7977
+ function wrapTitleLines(text, fontPx, widthPx, measure = titleTextWidth) {
7978
+ const width = w => measure(w, fontPx);
7683
7979
  const lines = [];
7684
7980
  for (const word of String(text).split(/\s+/).filter(Boolean)) {
7685
7981
  const last = lines.length ? lines[lines.length - 1] + ' ' + word : null;
@@ -8629,39 +8925,55 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8629
8925
  return { bars, texts, lines, extent: ext[0] <= ext[2] ? ext : [0, 0, 0, 0], width: W, sizer };
8630
8926
  }
8631
8927
 
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) {
8928
+ // THE box-and-title layout of every chart type (flat's BOX block,
8929
+ // maptheme.js 18757-18925): single symbol, SEQUENCE, PIE and BAR all call
8930
+ // this. `extent`: the chart's drawn [x0, y0, x1, y1] around its anchor (px;
8931
+ // chart units with unit 1). o: { unit (px per flat unit, default 1), margin
8932
+ // (boxmargin, flat's 2), circular (CIRCULARBOX), radius / center (the
8933
+ // circle's base radius and center; default the extent's half longer side
8934
+ // and its center), titleText (with the titleunits suffix, '' / null: no
8935
+ // title), titleFont (px, or w => px for DTEXT), bottomTitle, alignRight,
8936
+ // wrap, measure }. Returns { rect (rectangular box), boxR / cx / cy (the
8937
+ // circular box: radius, center), w (the extent's width), title { text,
8938
+ // lines, x, anchor, top, baseline, f, extent } }.
8939
+ // The margin is min(2 m u, m w / 30); the title's first baseline 0.7 font
8940
+ // above the box (BOTTOMTITLE: one font below), wrapped to the chart's width
8941
+ // (the circle's diameter); a rectangular box grows to enclose the title,
8942
+ // its top trimmed by 0.1 font. arial: ascent 0.905, descent 0.212.
8943
+ function chartBoxLayout(extent, o) {
8641
8944
  const e = extent.slice();
8642
- const w = e[2] - e[0];
8643
- const margin = Math.min(2 * o.margin, o.margin * w / 30);
8945
+ const w = e[2] - e[0], h = e[3] - e[1];
8946
+ const unit = o.unit == null ? 1 : o.unit;
8947
+ const circular = !!o.circular;
8948
+ const margin = Math.min(2 * o.margin * unit, o.margin * w / 30);
8949
+ const center = o.center || [(e[0] + e[2]) / 2, (e[1] + e[3]) / 2];
8950
+ const boxR = circular ? (o.radius != null ? o.radius : Math.max(w, h) / 2) * 1.1 + margin : 0;
8644
8951
  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
- }
8952
+ if (o.titleText) {
8953
+ const f = typeof o.titleFont === 'function' ? o.titleFont(w) : o.titleFont;
8954
+ const measure = o.measure || titleTextWidth;
8955
+ const lines = wrapTitleLines(o.titleText, f, circular ? boxR * 2 : w, measure).split('\n');
8654
8956
  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);
8957
+ // first baseline; lines go down one font each
8958
+ const base = circular
8959
+ ? (o.bottomTitle ? center[1] + boxR + f : center[1] - boxR - 0.7 * f - (lines.length - 1) * f)
8960
+ : (o.bottomTitle ? e[3] + f : e[1] - 0.7 * f - (lines.length - 1) * f);
8961
+ const x = circular ? center[0] + unit / 2 : (o.alignRight ? e[2] : e[0]);
8962
+ const anchor = circular ? 'middle' : (o.alignRight ? 'end' : 'start');
8963
+ const x0 = anchor === 'middle' ? x - width / 2 : anchor === 'end' ? x - width : x;
8964
+ title = { text: lines.join('\n'), lines, x, anchor, top: base - 0.8 * f, baseline: base, f,
8965
+ extent: [x0, base - 0.905 * f, x0 + width, base + (lines.length - 1) * f + 0.212 * f] };
8663
8966
  }
8664
- return { rect: [e[0] - margin, e[1] - margin, e[2] + margin, e[3] + margin], title, width: w };
8967
+ let rect = null;
8968
+ if (!circular) {
8969
+ const b = e.slice();
8970
+ if (title) {
8971
+ b[0] = Math.min(b[0], title.extent[0]); b[1] = Math.min(b[1], title.extent[1]) + 0.1 * title.f;
8972
+ b[2] = Math.max(b[2], title.extent[2]); b[3] = Math.max(b[3], title.extent[3]);
8973
+ }
8974
+ rect = [b[0] - margin, b[1] - margin, b[2] + margin, b[3] + margin];
8975
+ }
8976
+ return { rect, boxR, cx: center[0], cy: center[1], w, margin, title };
8665
8977
  }
8666
8978
 
8667
8979
  // CHART|PIE / DONUT: one pie's slices as real ixmaps-flat lays them out —
@@ -8693,10 +9005,38 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8693
9005
  // centered on 12 o'clock), HALF over 180° from 270° (HALFPLUS10 200°
8694
9006
  // from 260°, HALFMINUS10 160° from 280°, HALFPLUS20 220° from 250°,
8695
9007
  // HALFMINUS20 140° from 290°)
9008
+ // - STARBURST (piechart.js 246, 324-355, 415-420): every part >= 0, the
9009
+ // angles EQUAL (the value is the ray's radius, not its sweep) and the
9010
+ // percents NOT normalized — raw values. The FLOWER/RAYS variants
9011
+ // (XLFLOWER/XLRAYS, LFLOWER/LRAYS, XSFLOWER/XSRAYS, SFLOWER/SRAYS,
9012
+ // FLOWER/RAYS) cap the ray angle (maxAngle / 4 / 12 / 10 / none) and
9013
+ // inset a gap (10 / 20 / 40 / 33 / 35 % of it) on both sides of each
9014
+ // ray. The ray RADIUS is
9015
+ // computed in _buildPieLayers (it needs the theme's maxValue)
8696
9016
  function pieSliceLayout(values, t, opts = {}) {
8697
9017
  t = String(t || '');
8698
9018
  const has = re => re.test(t);
8699
- const parts = values.map(v => (isNaN(v) ? 0 : v));
9019
+ // flat's STARBURST (piechart.js realize 246, 324-355, 415-420): the
9020
+ // parts get EQUAL angles — the value is the ray's radius, not its
9021
+ // sweep — and every part is clamped to >= 0. The FLOWER/RAYS
9022
+ // variants (flat maps XLFLOWER to XLRAYS & kin, maptheme.js
9023
+ // 20169-20186) cap the ray angle and leave a gap between the rays.
9024
+ // flat's `nRadInner = max(5, nRadInner)` for the ray variants is 5
9025
+ // units of the chart's INTERNAL coordinate system — measured on the
9026
+ // ISTAT story: 5 of the disc's 197 internal units, 0.28 screen px,
9027
+ // sub-pixel and invisible (the petals go to the center; an inner
9028
+ // radius shows only with DONUT) — so starInner here is 0 for the
9029
+ // ray variants too, and only DONUT+STARBURST starts its rays at
9030
+ // the donut's inner radius (see _buildPieLayers)
9031
+ const star = has(/STARBURST/);
9032
+ const starRay = !star ? null
9033
+ : /XLFLOWER|XLRAYS/.test(t) ? { cap: 4, gap: 0.10 }
9034
+ : /LFLOWER|LRAYS/.test(t) ? { cap: 4, gap: 0.20 }
9035
+ : /XSFLOWER|XSRAYS/.test(t) ? { cap: 12, gap: 0.40 }
9036
+ : /SFLOWER|SRAYS/.test(t) ? { cap: 10, gap: 0.33 }
9037
+ : /FLOWER|RAYS/.test(t) ? { cap: 1, gap: 0.35 }
9038
+ : { cap: 1, gap: 0 };
9039
+ const parts = values.map(v => (isNaN(v) ? 0 : star ? Math.max(0, v) : v));
8700
9040
  const sum = parts.reduce((a, v) => a + v, 0);
8701
9041
  const autocomplete = has(/AUTOCOMPLETE/);
8702
9042
  if (sum === 0 && !autocomplete) return null;
@@ -8749,13 +9089,18 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8749
9089
  // flat draws its "Pie error: no values !" circle here
8750
9090
  if (!any && !autocomplete) return null;
8751
9091
  const pctSum = slices.reduce((a, sl) => a + sl.percent, 0);
8752
- if (pctSum !== 100) {
9092
+ // flat's STARBURST keeps the parts' raw values (piechart.js 246-258:
9093
+ // no 100% normalization, no AUTOCOMPLETE) — the value is the radius
9094
+ if (!star && pctSum !== 100) {
8753
9095
  if (autocomplete && pctSum < 100) slices.push({ i: -1, value: null, percent: Math.floor((100 - pctSum) * 100000) / 100000, complement: true });
8754
9096
  else slices.forEach(sl => { sl.percent = sl.percent / pctSum * 100; });
8755
9097
  }
8756
9098
  let end = 0;
8757
9099
  if (has(/SYMMETRIC/) && slices.length) {
8758
- if (has(/BIGTOTOP/)) {
9100
+ if (star) {
9101
+ // flat piechart.js 296-299: the first ray centered on 12 o'clock
9102
+ end = 360 - Math.floor(360 / parts.length / 2);
9103
+ } else if (has(/BIGTOTOP/)) {
8759
9104
  end = 360;
8760
9105
  let x = 0;
8761
9106
  for (; x < slices.length && slices[x] !== biggest; x++) end -= slices[x].percent < 100 ? Math.floor(360 / 100 * slices[x].percent) : 0;
@@ -8776,6 +9121,20 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8776
9121
  if (has(/HALFMINUS20/)) maxAngle = 140;
8777
9122
  const out = [];
8778
9123
  slices.forEach((sl, k) => {
9124
+ if (star) {
9125
+ // flat piechart.js 324-355, 395-403: the angles advance by the
9126
+ // FULL equal angle (the ray variants capped), the gap inset on
9127
+ // both sides of a ray; a ray's own sweep is that reduced angle
9128
+ const angle = Math.min(maxAngle / starRay.cap, maxAngle / parts.length);
9129
+ const base = end % 360;
9130
+ end = base + angle;
9131
+ if (end > 360) end = end % 360;
9132
+ const sweep = angle * (1 - 2 * starRay.gap);
9133
+ const start = base + angle * starRay.gap;
9134
+ if (sweep <= 0 || start < 0) return;
9135
+ out.push(Object.assign(sl, { start, sweep, k }));
9136
+ return;
9137
+ }
8779
9138
  const sweep = sl.percent / 100 * maxAngle;
8780
9139
  const start = end % 360;
8781
9140
  end = start + sweep;
@@ -8902,6 +9261,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8902
9261
  const parts = [], zeroNot = [];
8903
9262
  for (let k = 0; k < o.fields.length; k++) {
8904
9263
  let v = parseFloat(raw[o.fields[k]]);
9264
+ // flat's weights multiply every record's per-field value as it is
9265
+ // accumulated (maptheme.js 10506-10508) — the SIZE (nValueSize)
9266
+ // stays raw, exactly as there
9267
+ if (o.weights) v = v * o.weights(k);
8905
9268
  zeroNot[k] = false;
8906
9269
  if (isNaN(v)) {
8907
9270
  if (!r.undefinedOk) return null;
@@ -9111,6 +9474,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9111
9474
  for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
9112
9475
  return (h >>> 0) / 4294967296;
9113
9476
  }
9477
+ // a flow's dash phase in [0, 1): flat's random one, the same for the
9478
+ // same arc on every rebuild
9479
+ function flowDashPhase(p1, p2) {
9480
+ return hashUnit('dash:' + p1[0] + ',' + p1[1] + '&' + p2[0] + ',' + p2[1]);
9481
+ }
9114
9482
 
9115
9483
  // points of a cubic Bézier, n segments (n + 1 points)
9116
9484
  function cubicBezierPoints(p0, c1, c2, p3, n) {
@@ -9273,14 +9641,21 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9273
9641
  const up = fl.up;
9274
9642
  const maxR = opts.maxRadius || 0;
9275
9643
  const nStarParts = opts.nParts || counts.length;
9276
- let angle = 0, starR = null, x = 0;
9644
+ // flat's star radius (nStarRadius, 0 per chart) is set ONLY by the FIRST
9645
+ // part of the sorted order (maptheme.js 22301-22316: `if (i === 0)`), and a
9646
+ // part of value 0 is skipped before it (22222): a chart whose first part
9647
+ // (with SORT|UP the smallest) is 0 keeps star radius 0, so with UP every
9648
+ // part sits on the center — only a chart with ALL parts non-zero spreads
9649
+ // into its star. (A cell of a CATEGORICAL AGGREGATE theme has a zero part
9650
+ // for every category it lacks.)
9651
+ let angle = 0, starR = 0, x = 0;
9277
9652
  order.forEach((p, k) => {
9278
9653
  if (!p.v) return;
9279
9654
  const r = radiusOf(p.v);
9280
9655
  if (!(r > 0)) return;
9281
9656
  let px = 0, py = 0;
9282
9657
  if (star) {
9283
- if (starR === null) {
9658
+ if (k === 0) {
9284
9659
  starR = r;
9285
9660
  if (fl.expand && fl.sort && up) {
9286
9661
  const big = order[order.length - 1].v, mv = opts.maxValue || big || 1;
@@ -9288,7 +9663,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9288
9663
  }
9289
9664
  if (fl.compress) starR *= fl.compressmax ? 0.25 : 0.5;
9290
9665
  }
9291
- if (parts.length > 0 || up) {
9666
+ // flat: `(i > 0 || UP) && nRadius` — i the part's index in the sorted order
9667
+ if (k > 0 || up) {
9292
9668
  const c = starR + r;
9293
9669
  const b = Math.sqrt(Math.max(0, c * c - r * r));
9294
9670
  let f = 90 - Math.asin(Math.min(1, b / c)) / Math.PI * 180;
@@ -9837,7 +10213,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9837
10213
  // 7740-7801): a value that is no number counts as 0 — unless
9838
10214
  // UNDEFINEDISNOTVALUE —, and 0 is a value — unless ZEROISNOTVALUE
9839
10215
  function flatNumber(v, flags) {
9840
- let n = parseFloat(v);
10216
+ // flat's own value scan (maptheme.js __scanValue 9265-9272): a
10217
+ // value with a comma is EUROPEAN format — dots stripped as
10218
+ // thousands separators, the comma is the decimal ("78,8" -> 78.8);
10219
+ // a value without one is parsed plain, blanks stripped. Applied
10220
+ // to every theme value in flat, so also here
10221
+ const s = String(v);
10222
+ let n = s.includes(',') ? parseFloat(s.replace(/\./g, '').replace(/,/g, '.')) : parseFloat(s.replace(/ /g, ''));
9841
10223
  if (isNaN(n)) n = flags && flags.has('UNDEFINEDISNOTVALUE') ? NaN : 0;
9842
10224
  if (n === 0 && flags && flags.has('ZEROISNOTVALUE')) return NaN;
9843
10225
  return n;
@@ -9848,6 +10230,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9848
10230
  const bins = [arr.reduce((a, v) => (v < a ? v : a), Infinity), m];
9849
10231
  while (arr.length > 1) {
9850
10232
  arr = arr.filter(d => d > m);
10233
+ // every value left equals the last mean (a tie at the top): there is
10234
+ // no head above it — mean([]) would add a NaN break (flat's own bug)
10235
+ if (!arr.length) break;
9851
10236
  m = mean(arr);
9852
10237
  bins.push(m);
9853
10238
  }
@@ -9898,6 +10283,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9898
10283
  // values above the last mean, while more than one is left
9899
10284
  if (flags.has('HEADTAIL')) {
9900
10285
  const b = headTailBreaks(values);
10286
+ // fewer breaks than classes (small or low-variance data): the surplus
10287
+ // classes padded at the top with the last break, as naturalBreaks
10288
+ // does — flat reads past the end (undefined bounds, records in no class)
10289
+ while (b.length < nParts + 1) b.push(b[b.length - 1]);
9901
10290
  parts = new Array(nParts).fill(null).map((_, i) => ({ min: b[i], max: b[i + 1] - 0.000001 }));
9902
10291
  } else if (flags.has('QUANTILE')) parts = quantileBreaks(values, nParts);
9903
10292
  else if (flags.has('NATURAL')) parts = naturalBreaks(values, nParts);
@@ -10010,7 +10399,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10010
10399
  function fieldWeightOf(style, nFields, repeatLast) {
10011
10400
  const w = style && style.weights;
10012
10401
  if (w == null || w === '') return null;
10013
- const list = (Array.isArray(w) ? w.join('|') : String(w)).split('|').map(v => parseFloat(v));
10402
+ // flat's toArray (maptheme.js 999-1014): split on '|' when the
10403
+ // string has one, else on ',' — "1,10,10" is a THREE-weight list
10404
+ // (the Happy Planet page), not one weight of 1
10405
+ const s = Array.isArray(w) ? w.join('|') : String(w);
10406
+ const list = (s.includes('|') ? s.split('|') : s.split(',')).map(v => parseFloat(v));
10014
10407
  if (!list.some(v => !isNaN(v))) return null;
10015
10408
  if (repeatLast) { const last = list[list.length - 1]; while (list.length < nFields) list.push(last); }
10016
10409
  return i => list[i] || list[0] || 1;
@@ -10019,9 +10412,16 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10019
10412
  // the value fields' own values weighted — the class value and the field100
10020
10413
  // value stay raw, exactly as flat reads them
10021
10414
  function weightedRecord(f, weightOf) {
10415
+ // a pie record's parts were weighted in flatPieRecord, and its size
10416
+ // (nValueSize) stays raw — nothing to do here
10417
+ if (f.properties && f.properties.pie) return f;
10022
10418
  const p = Object.assign({}, f.properties);
10023
10419
  if (p.value !== undefined && !isNaN(p.value)) p.value = p.value * weightOf(0);
10024
10420
  if (Array.isArray(p.series)) p.series = p.series.map((v, i) => (isNaN(v) ? v : v * weightOf(i)));
10421
+ // the multi-field pie's parts — the SAME per-field values as series,
10422
+ // but the grid cell's pie accumulates THESE (flatPieAccumulate's
10423
+ // rec.parts), so they carry the weights too
10424
+ if (Array.isArray(p.parts)) p.parts = p.parts.map((v, i) => (isNaN(v) ? v : v * weightOf(i)));
10025
10425
  return Object.assign({}, f, { properties: p });
10026
10426
  }
10027
10427
  function applyField100(features, binding, flags, style) {
@@ -10044,7 +10444,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10044
10444
  return features.map(f => {
10045
10445
  if (!f || !f.properties) return f;
10046
10446
  const props = Object.assign({}, f.properties);
10047
- let values = names.map(name => parseFloat(f.properties[name]));
10447
+ let values = names.map(name => flatScanValue(f.properties[name]));
10048
10448
  // the weights run BEFORE the field100 arithmetic, as in flat's
10049
10449
  // addItemValues — the FRACTION of a weighted value is the weighted
10050
10450
  // per-unit value
@@ -10053,7 +10453,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10053
10453
  const sum = auto100 ? values.reduce((a, v) => a + (isNaN(v) ? 0 : v), 0) : 0;
10054
10454
  values = values.map((v, i) => {
10055
10455
  if (isNaN(v)) return v;
10056
- const v100 = auto100 ? sum : parseFloat(f.properties[f100A[Math.min(i, f100A.length - 1)]]);
10456
+ const v100 = auto100 ? sum : flatScanValue(f.properties[f100A[Math.min(i, f100A.length - 1)]]);
10057
10457
  if (v === 0 && !flags.has('ZEROISVALUE')) return 0;
10058
10458
  if (fields[i][0] === '!') return (isNaN(v100) ? 0 : v100) - v;
10059
10459
  if (flags.has('CALCVAL') || flags.has('CALC100')) return v100 ? Math.round(v * v100 / 100) : v;
@@ -10099,7 +10499,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10099
10499
  // DIFFERENCE over several value fields: the difference is the value
10100
10500
  const fields = String(binding.value).split('|');
10101
10501
  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]
10502
+ // without AGGREGATE applyField100 already differenced every record
10503
+ // (flat's nValuesA, popped): the first field IS the value
10504
+ ? (!flags.has('AGGREGATE') ? p => flatNumber(p[fields[0]], flags)
10505
+ : p => cellAggregatedValues({ sums: fields.map(k => parseFloat(p[k]) || 0), counts: fields.map(() => 1) }, flags, style)[0])
10103
10506
  // "$item$" is flat's record count: 1 per record
10104
10507
  : binding.value === '$item$' ? () => 1
10105
10508
  : p => (p && Object.keys(p).length ? flatNumber(p[binding.value], flags) : NaN);
@@ -10395,13 +10798,18 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10395
10798
  if (cell.titleMax === undefined || (maxAgg ? m > cell.titleMax : m < cell.titleMax)) {
10396
10799
  cell.titleMax = m;
10397
10800
  cell.titleFrom = f;
10801
+ cell.titleX = p.x; cell.titleY = p.y;
10398
10802
  }
10399
10803
  }
10400
10804
  }
10401
10805
  const out = [];
10402
10806
  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;
10807
+ // RELOCATE: at the records' mean position — with MAX / MIN at the
10808
+ // record holding the max (min), the one the cell IS (and its title's);
10809
+ // only the position follows it, never a value
10810
+ const atTitle = relocate && cell.titleFrom;
10811
+ const x = atTitle ? cell.titleX : relocate ? cell.sumX / cell.n : cell.snapped.x;
10812
+ const y = atTitle ? cell.titleY : relocate ? cell.sumY / cell.n : cell.snapped.y;
10405
10813
  const ll = worldPixelToLngLat(x, y, zoom);
10406
10814
  const geometry = { type: 'Point', coordinates: [ll.lng, ll.lat] };
10407
10815
  const hasClass = cell.first.properties.classValue !== undefined;
@@ -10702,17 +11110,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10702
11110
  // could ever need it (a plain "px" gridwidth, or an aggregation
10703
11111
  // array with only "px" entries, never reads this field).
10704
11112
  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
- }
11113
+ // aggregationscale: flat's alias of aggregation, read the same way in
11114
+ // _ensureClusterIndices
11115
+ if (gridwidthIsMeters || aggregationHasMetersEntry(this.style.aggregation || this.style.aggregationscale)) this._scanGridRefLat();
10716
11116
 
10717
11117
  // a bar chart's parts are a pie's (its value fields or categories)
10718
11118
  if ((this._isPieChart() || this._isBarChart()) && this.binding.value) {
@@ -10799,6 +11199,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10799
11199
  let max = 0;
10800
11200
  for (const f of this.features) for (const field of this._multiFields) { const v = Math.abs(parseFloat(f.properties[field])); if (v > max) max = v; }
10801
11201
  this._multiFieldPartMax = max || 1;
11202
+ // AUTO100's scale, measured on these features by _buildSequenceChartLayers
11203
+ this._seqAuto100Max = null;
10802
11204
  } else if (this.binding.value) {
10803
11205
  // real engine's OTHER coloring mode (maptheme.js distributeValues,
10804
11206
  // partsA): a NUMERIC bound value, not CATEGORICAL, is classed into
@@ -10988,6 +11390,14 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10988
11390
  _resolveAggregateValue(props) { return resolveAggregateValue(this.binding, this.flags, props); }
10989
11391
 
10990
11392
  _buildAggregationIndex(sourceFeatures) {
11393
+ // only records with a point position are drawn: a GeoJSON feature may
11394
+ // have geometry null (RFC 7946 — open-data exports of unlocated
11395
+ // records), or a shape chartAnchoredFeatures could not anchor; they
11396
+ // stay in the data (counts, facets), as on flat, but have no chart
11397
+ sourceFeatures = sourceFeatures.filter(f => {
11398
+ const c = f.geometry && f.geometry.coordinates;
11399
+ return !!c && typeof c[0] === 'number' && isFinite(c[0]) && isFinite(c[1]);
11400
+ });
10991
11401
  // an AGGREGATE / CATEGORICAL pie: one bucket, each record carrying its
10992
11402
  // contribution to its item (flatPieRecord), added up per position or
10993
11403
  // grid cell by aggregateOnGrid (flatPieAccumulate)
@@ -10997,9 +11407,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10997
11407
  const rules = flatValueRules(t, fields ? fields.length : 1);
10998
11408
  const sizeField = this.binding.size && this.binding.size !== '$item$' ? this.binding.size : null;
10999
11409
  const feats = [];
11410
+ // flat's per-field weights (maptheme.js 10506-10508), applied to
11411
+ // every record's parts inside flatPieRecord
11412
+ const pieWeightOf = fieldWeightOf(this.style, fields ? fields.length : 1, true);
11000
11413
  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
11414
  const p = f.properties || {};
11004
11415
  let cat;
11005
11416
  if (this._pieCategorical) {
@@ -11007,7 +11418,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11007
11418
  const i = k == null ? undefined : this._pieCategoryIndex.get(k);
11008
11419
  cat = i == null ? -1 : i;
11009
11420
  }
11010
- const pie = flatPieRecord(p, { rules, sizeField, cat, fields });
11421
+ const pie = flatPieRecord(p, { rules, sizeField, cat, fields, weights: pieWeightOf });
11011
11422
  if (!pie) continue;
11012
11423
  feats.push({ type: 'Feature', geometry: f.geometry, properties: { value: pie.size, pie, raw: p } });
11013
11424
  }
@@ -11066,7 +11477,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11066
11477
  const seriesFields = String(this.binding.value || '').split('|');
11067
11478
  this._seriesDifference = this.flags.has('DIFFERENCE') && seriesFields.length > 1 && !this._aggregateField100;
11068
11479
  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];
11480
+ // without AGGREGATE the records are already differenced (applyField100)
11481
+ const seriesValue = !this.flags.has('AGGREGATE') ? props => parseFloat(props[seriesFields[0]]) || 0 : props => cellAggregatedValues({ sums: seriesOf(props), counts: seriesFields.map(() => 1) }, this.flags, this.style)[0];
11070
11482
  this._featuresByCategory = [sourceFeatures.map(f => ({
11071
11483
  type: 'Feature',
11072
11484
  geometry: f.geometry,
@@ -11424,6 +11836,20 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11424
11836
  });
11425
11837
  }
11426
11838
 
11839
+ // the meters grid's reference latitude: the middle of the data's own
11840
+ // latitude extent (see _prepare)
11841
+ _scanGridRefLat() {
11842
+ let minLat = Infinity, maxLat = -Infinity;
11843
+ this.features.forEach(f => {
11844
+ const lat = f.geometry && f.geometry.coordinates && f.geometry.coordinates[1];
11845
+ if (typeof lat === 'number' && !isNaN(lat)) {
11846
+ if (lat < minLat) minLat = lat;
11847
+ if (lat > maxLat) maxLat = lat;
11848
+ }
11849
+ });
11850
+ this._gridRefLat = (minLat <= maxLat) ? (minLat + maxLat) / 2 : 0;
11851
+ }
11852
+
11427
11853
  // (re)builds the per-category GridAggregateIndex for the grid width
11428
11854
  // resolved at the given zoom, only when that width changed. Grid width,
11429
11855
  // as flat reads it (maptheme.js:1892-1908, 7990-8008): gridwidthpx and
@@ -11434,6 +11860,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11434
11860
  // style.aggregation bracket; none → no grid (exact positions).
11435
11861
  _ensureClusterIndices(zoom) {
11436
11862
  let radiusPx;
11863
+ // a meters grid set later by setStyle (gridwidth / aggregation) never
11864
+ // went through _prepare's scan — not the equator, the data's latitude
11865
+ if (this._gridRefLat === undefined) this._scanGridRefLat();
11437
11866
  // GL-PORT COMPAT: a real-ixmaps-flat page's own gridwidth is
11438
11867
  // sometimes a fixed SCREEN-PIXEL size (e.g. style.gridwidth:"3px",
11439
11868
  // confirmed against a real ported page) rather than this engine's
@@ -11480,6 +11909,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11480
11909
  this._clusterUsesFixedZoom = false;
11481
11910
  }
11482
11911
  if (this._clusterIndices && this._clusterRadiusPx === radiusPx && this._clusterField === aggField) return;
11912
+ // new cells (e.g. a runtime gridwidth): the zoom-keyed stats of the
11913
+ // old ones (breaks, NORMALIZE, NOOUTLIER, sizeMax) no longer apply
11914
+ this._aggregateStatsCache = null;
11483
11915
  this._clusterRadiusPx = radiusPx;
11484
11916
  this._clusterField = aggField;
11485
11917
  // after aggregation, on the cell sums: field100 of a value-based
@@ -12080,7 +12512,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12080
12512
  // companion layer) takes the uniform mesh shortcut.
12081
12513
  if (this.flags.has('GRIDSIZE') && this.flags.has('PLOT')) return this._buildPlotLayers(zoom, bbox);
12082
12514
  // PLOT without GRIDSIZE: one line chart per item over its value fields
12083
- if (this.flags.has('PLOT') && this._isItemPlot()) return this._buildItemPlotLayers(zoom);
12515
+ if (this.flags.has('PLOT') && this._isItemPlot()) return this._buildItemPlotLayers(zoom, liveZoom);
12084
12516
  // a plain grid (one color, no values or glow — the Germany page's
12085
12517
  // companion "grid" layer) as one square per cell; a classed GRIDSIZE
12086
12518
  // chart (the fuel-price page) is a symbol chart sized to the grid,
@@ -12666,16 +13098,20 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12666
13098
  return explicit ? fields.map((f, i) => String(explicit[i] != null ? explicit[i] : f)) : fields;
12667
13099
  }
12668
13100
 
12669
- _buildItemPlotLayers(zoom) {
13101
+ _buildItemPlotLayers(zoom, liveZoom = zoom) {
12670
13102
  const fields = this._itemPlotFields();
12671
13103
  const items = [];
12672
13104
  let dMin = Infinity, dMax = -Infinity;
13105
+ // facet / runtime WHERE / time frame narrow the drawn charts, as in
13106
+ // every other renderer; the value range stays the full data's (as
13107
+ // the bubbles' _maxSizeValue), so a filter does not rescale them
13108
+ const active = this._activeFeatures ? new Set(this._activeFeatures) : null;
12673
13109
  for (const f of this.features) {
12674
13110
  const pos = itemAnchor(f.geometry);
12675
13111
  if (!pos) continue;
12676
13112
  const values = fields.map(k => parseFloat(f.properties[k]));
12677
13113
  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 } });
13114
+ if (!active || active.has(f)) items.push({ geometry: { type: 'Point', coordinates: pos }, properties: { values, raw: f.properties } });
12679
13115
  }
12680
13116
  if (!items.length) return [];
12681
13117
  const sMin = styleNum(this.style.minvalue), sMaxRaw = this.style.maxvalue;
@@ -12694,7 +13130,18 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12694
13130
  const S = 2 * NORMAL_RADIUS_PX * (styleNum(this.style.scale) || 1) * objectZoomFactor(zoom, this.mapOptions);
12695
13131
  const color = parseCssColor(Array.isArray(this.style.colorscheme) ? this.style.colorscheme[0] : this.style.colorscheme) || [0, 102, 204];
12696
13132
  const fillOpacity = styleNum(this.style.fillopacity);
12697
- return [new IconLayer({
13133
+ // BOX / TITLE: flat's chart-group box around every chart; the chart's
13134
+ // drawn extent is its plot geometry's bounding box (chart units × S px,
13135
+ // y up in the geometry, down on screen)
13136
+ const boxRows = this._boxRows(items, { unit: S / (2 * NORMAL_RADIUS_PX), liveZoom, extentOf: d => {
13137
+ const b = itemPlotGeometry(d.properties.values, rangeFor(d.properties.values), this.style, this.flags, sizeMax).bbox;
13138
+ return [b.x0 * S, -b.y1 * S, b.x1 * S, -b.y0 * S];
13139
+ } });
13140
+ const boxLayers = boxRows ? this._boxTitleLayers(boxRows, {
13141
+ unit: S / (2 * NORMAL_RADIUS_PX), liveZoom, positionOf: b => b.d.geometry.coordinates, offsetOf: () => [0, 0],
13142
+ 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}` }
13143
+ }) : { box: [], titles: [] };
13144
+ return [...boxLayers.box, new IconLayer({
12698
13145
  id: `ix-itemplot-${this.name}-g${this._iconGeneration}`,
12699
13146
  data: items, pickable: true,
12700
13147
  getPosition: d => d.geometry.coordinates,
@@ -12704,7 +13151,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12704
13151
  getSize: d => this._buildItemPlotIcon(d.properties.values, rangeFor(d.properties.values), sizeMax, color, fillOpacity).heightUnits * S,
12705
13152
  sizeUnits: 'pixels',
12706
13153
  updateTriggers: { getSize: [S] }
12707
- })];
13154
+ }), ...boxLayers.titles];
12708
13155
  }
12709
13156
 
12710
13157
  // canvas for one per-item PLOT chart (itemPlotGeometry), RASTER px per
@@ -12842,14 +13289,22 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12842
13289
  datasetMax = datasetMax || 1;
12843
13290
  if (isNaN(normalSizeValue)) normalSizeValue = datasetMax;
12844
13291
 
12845
- return [new IconLayer({
13292
+ // BOX / TITLE: flat's chart-group box around every chart; the chart is
13293
+ // the iconSize square on the cell's center (flat's chart size is 30 units)
13294
+ const boxRows = this._boxRows(data, { unit: iconSize / 30, liveZoom: zoom,
13295
+ extentOf: () => [-iconSize / 2, -iconSize / 2, iconSize / 2, iconSize / 2] });
13296
+ const boxLayers = boxRows ? this._boxTitleLayers(boxRows, {
13297
+ unit: iconSize / 30, liveZoom: zoom, positionOf: b => b.d.geometry.coordinates, offsetOf: () => [0, 0],
13298
+ 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}` }
13299
+ }) : { box: [], titles: [] };
13300
+ return [...boxLayers.box, new IconLayer({
12846
13301
  id: `ix-plot-${this.name}-g${this._iconGeneration}`,
12847
13302
  data, pickable: true,
12848
13303
  getPosition: d => d.geometry.coordinates,
12849
13304
  getIcon: d => this._buildPlotIcon(d.properties.values, categories, labels, normalSizeValue, datasetMax, datasetMin),
12850
13305
  getSize: iconSize,
12851
13306
  sizeUnits: 'pixels'
12852
- })];
13307
+ }), ...boxLayers.titles];
12853
13308
  }
12854
13309
 
12855
13310
  // Canvas rendering for one cell's mini-chart — BOX background, AREA
@@ -12967,9 +13422,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12967
13422
  canvas.width = W; canvas.height = H;
12968
13423
  const ctx = canvas.getContext('2d');
12969
13424
 
12970
- // BOX
13425
+ // BOX (a plot without the BOX flag keeps its own canvas box; with it the
13426
+ // shared box layer draws it — see _buildPlotLayers)
12971
13427
  const boxOpacity = styleNum(this.style.boxopacity);
12972
- if (!isNaN(boxOpacity) && boxOpacity > 0) {
13428
+ if (!flatFlag(this.flags, 'BOX') && !isNaN(boxOpacity) && boxOpacity > 0) {
12973
13429
  ctx.fillStyle = `rgba(255,255,255,${boxOpacity})`;
12974
13430
  ctx.fillRect(0, 0, W, H);
12975
13431
  }
@@ -13215,6 +13671,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13215
13671
  // async image decode, the whole drawing is synchronous
13216
13672
  const items = [];
13217
13673
  const defsSeen = new Set();
13674
+ // this drawing's defs, swapped in for the previous drawing's below —
13675
+ // appended to the shared pane <defs> on every draw they grew without
13676
+ // bound (and getElementById then found a stale earlier copy)
13677
+ const paneDefs = document.createElementNS(SVG_NS, 'g');
13218
13678
  let html = '';
13219
13679
  for (const d of combined) {
13220
13680
  const value = valueOf(d);
@@ -13231,11 +13691,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13231
13691
  for (const id of chart.ids) {
13232
13692
  if (defsSeen.has(id)) continue;
13233
13693
  defsSeen.add(id);
13234
- const def = this._userChartGroup.ownerDocument.getElementById(id);
13235
- if (def) chartHost.defs.appendChild(def.cloneNode(true));
13694
+ // drawn now (fresh), else a cached chart's: the previous drawing's copy
13695
+ const def = this._userChartGroup.querySelector('#' + CSS.escape(id)) || this._userChartGroup.ownerDocument.getElementById(id);
13696
+ if (def) paneDefs.appendChild(def.cloneNode(true));
13236
13697
  }
13237
13698
  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 });
13699
+ r: Math.max(chart.vw, chart.vh) * pxPerUnit / 2,
13700
+ // the chart's measured box (flat's getBox of the chart group) in px
13701
+ // around the map position: the pane draws a chart point at
13702
+ // p + s · (point − origin)
13703
+ ext: [(chart.bb.x - chart.ox) * pxPerUnit, (chart.bb.y - chart.oy) * pxPerUnit,
13704
+ (chart.bb.x + chart.bb.width - chart.ox) * pxPerUnit, (chart.bb.y + chart.bb.height - chart.oy) * pxPerUnit] });
13239
13705
  }
13240
13706
  if (glDebug()) console.info(`[ixmaps-gl debug] ${themeIdOf(this)}: zoom ${mapLibreToFlatZoom(liveZoom).toFixed(2)}, ${combined.length} charts, ${items.length} drawn${this._viewMoving ? ' (moving)' : ''}`);
13241
13707
  if (!items.length) {
@@ -13244,6 +13710,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13244
13710
  this._chartItems = null;
13245
13711
  return (this._userChartLastLayers = []);
13246
13712
  }
13713
+ chartHost.defs.appendChild(paneDefs);
13714
+ if (this._chartPaneDefs) this._chartPaneDefs.remove();
13715
+ this._chartPaneDefs = paneDefs;
13247
13716
  if (this._chartPaneGroup) this._chartPaneGroup.remove();
13248
13717
  this._chartPaneGroup = document.createElementNS(SVG_NS, 'g');
13249
13718
  chartHost.pane.appendChild(this._chartPaneGroup);
@@ -13252,11 +13721,24 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13252
13721
  for (let i = 0; i < items.length; i++) items[i].el = els[i];
13253
13722
  this._chartItems = items;
13254
13723
  chartHost.runtimes.add(this);
13724
+ this._chartHost = chartHost; // removeRuntimes takes it out again
13255
13725
  chartHost.update();
13256
13726
  // picking: a transparent circle per chart — the VISIBLE chart is the
13257
13727
  // live SVG in the pane; this deck layer only serves hover, click and
13258
13728
  // the tooltips (pickedFeature unwraps the {d, icon} entry shape)
13259
- this._userChartLastLayers = [new ScatterplotLayer({
13729
+ // BOX / TITLE: flat's chart-group box around the chart as the page's
13730
+ // function drew it (its measured box). The chart itself is a live SVG in
13731
+ // the pane above the deck canvas, so the box lies under it and the title
13732
+ // (outside the box) beside it
13733
+ const extOf = new Map(items.map(it => [it.d, it.ext]));
13734
+ const boxRows = this._boxRows(items.map(it => it.d), {
13735
+ unit: objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(this.style.scale) || 1), liveZoom, extentOf: d => extOf.get(d) });
13736
+ const boxLayers = boxRows ? this._boxTitleLayers(boxRows, {
13737
+ unit: objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(this.style.scale) || 1), liveZoom,
13738
+ positionOf: b => b.d.geometry.coordinates, offsetOf: () => [0, 0],
13739
+ 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}` }
13740
+ }) : { box: [], titles: [] };
13741
+ this._userChartLastLayers = [...boxLayers.box, new ScatterplotLayer({
13260
13742
  id: `ix-bubbles-${this.name}`,
13261
13743
  data: items.map(it => ({ d: it.d, icon: true, r: it.r })),
13262
13744
  pickable: true,
@@ -13265,7 +13747,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13265
13747
  radiusUnits: 'pixels', radiusMinPixels: 1,
13266
13748
  stroked: false, filled: true, getFillColor: [0, 0, 0, 0],
13267
13749
  parameters: ICON_LAYER_GLOBE_PARAMETERS
13268
- })];
13750
+ }), ...boxLayers.titles];
13269
13751
  return this._userChartLastLayers;
13270
13752
  }
13271
13753
  // the chart's SVG, cached by its draw parameters — a failing draw
@@ -13320,7 +13802,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13320
13802
  const vx = bb.x - pad, vy = bb.y - pad, vw = bb.width + 2 * pad, vh = bb.height + 2 * pad;
13321
13803
  // the chart's origin (0,0 — or the point flat moves it to, ret) sits at the map position
13322
13804
  const ox = (ret && ret.x) || 0, oy = (ret && ret.y) || 0;
13323
- chart = { vx, vy, vw, vh, ox, oy, markup, ids: [...ids],
13805
+ chart = { vx, vy, vw, vh, ox, oy, markup, ids: [...ids], bb: { x: bb.x, y: bb.y, width: bb.width, height: bb.height },
13324
13806
  svg: `<svg xmlns="${SVG_NS}" width="__W__" height="__H__" viewBox="${vx} ${vy} ${vw} ${vh}"><defs>${defs}</defs>${markup}</svg>` };
13325
13807
  }
13326
13808
  } catch (e) {
@@ -13333,6 +13815,187 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13333
13815
  return chart;
13334
13816
  }
13335
13817
 
13818
+ // flat's BOX block belongs to the box: the box owns its TITLE — no BOX, no
13819
+ // title — so everything about both is decided here and in _boxRows, for
13820
+ // every chart type (single symbol, SEQUENCE, PIE, BAR): the gates (BOX /
13821
+ // TITLE flags, boxupper / boxlower / titleupper scale), the margin, the
13822
+ // title font (DTEXT follows the chart), BOTTOMTITLE, align, CIRCULARBOX.
13823
+ // null when there is no box at this zoom.
13824
+ _boxSpec(liveZoom) {
13825
+ const st = this.style;
13826
+ if (!(flatFlag(this.flags, 'BOX') && !boxHiddenByScale(st, liveZoom))) return null;
13827
+ const titleUpper = scaleDenom(st.titleupper);
13828
+ const titleScale = styleNum(st.textscale) || styleNum(st.valuescale) || 1;
13829
+ return {
13830
+ circular: flatFlag(this.flags, 'CIRCULAR'),
13831
+ margin: styleNum(st.boxmargin) || 2,
13832
+ bottomTitle: flatFlag(this.flags, 'BOTTOMTITLE'),
13833
+ alignRight: /right/.test(String(st.align || '')),
13834
+ titleShown: flatFlag(this.flags, 'TITLE') && !!this.binding.title
13835
+ && !(titleUpper && scaleDenominatorAt(liveZoom) > titleUpper),
13836
+ // DTEXT: the title font follows the chart, max(0.9u, min(W/5, 14u /
13837
+ // scale)) (18815) — measured on the fire areas page's SIF chart
13838
+ // (W 362 units → font 72.35)
13839
+ titleFontOf: (unit, w) => (flatFlag(this.flags, 'DTEXT')
13840
+ ? Math.max(0.9 * unit, Math.min(w / 5, 14 * unit / (styleNum(st.scale) || 1)))
13841
+ : 5 * unit) * titleScale
13842
+ };
13843
+ }
13844
+
13845
+ // the layout row of every item's box and title (chartBoxLayout + the spec
13846
+ // above); null without a box (items: the array, or a function building it
13847
+ // only when there is a box). o: { unit (px per flat unit), liveZoom,
13848
+ // extentOf (item -> its drawn [x0, y0, x1, y1], px around the anchor),
13849
+ // radiusOf / centerOf (the circular box's base, default the extent's) }.
13850
+ // A row: { d: item, geometry, properties, w (the chart's width — flat's
13851
+ // size measure W, the stroke and corner basis), boxR / cx / cy (circle),
13852
+ // rect (rectangle), title }
13853
+ _boxRows(items, o) {
13854
+ const spec = this._boxSpec(o.liveZoom);
13855
+ if (!spec) return null;
13856
+ // flat appends style.titleunits (its parse adds the leading space,
13857
+ // maptheme.js 1470; szTitle += at 18827)
13858
+ const units = this.style.titleunits ? ' ' + this.style.titleunits : '';
13859
+ return (typeof items === 'function' ? items() : items).map(d => {
13860
+ const itemTitle = spec.titleShown ? this._itemTitle(d.properties) : null;
13861
+ const L = chartBoxLayout(o.extentOf(d), { unit: o.unit, margin: spec.margin, circular: spec.circular,
13862
+ radius: o.radiusOf ? o.radiusOf(d) : undefined, center: o.centerOf ? o.centerOf(d) : undefined,
13863
+ titleText: itemTitle ? itemTitle + units : null, titleFont: w => spec.titleFontOf(o.unit, w),
13864
+ bottomTitle: spec.bottomTitle, alignRight: spec.alignRight });
13865
+ // geometry/properties of the chart's feature on the row: the circular
13866
+ // box is pickable, and the tooltip (buildTooltipHtml, the click-pin)
13867
+ // works on the feature's own properties
13868
+ return { d, geometry: d.geometry, properties: d.properties, w: L.w, boxR: L.boxR, cx: L.cx, cy: L.cy,
13869
+ rect: L.rect, title: L.title };
13870
+ });
13871
+ }
13872
+
13873
+ // THE box-and-title layers of every chart type: rows are _boxRows results.
13874
+ // o: { unit, liveZoom, positionOf (row -> [lng, lat]), offsetOf (row ->
13875
+ // the chart's [dx, dy] px move: align / offsetx / offsety), box (false:
13876
+ // the title layer only — a BAR draws its box as a shader shape), ids:
13877
+ // { circle, icon, polygons, titles } } — the layer ids stay each chart
13878
+ // type's own. Returns { box: [layers], titles: [layers] }: the caller
13879
+ // pushes them where its draw order wants them.
13880
+ _boxTitleLayers(rows, o) {
13881
+ const st = this.style;
13882
+ const { unit, liveZoom, positionOf, offsetOf, ids } = o;
13883
+ const circular = flatFlag(this.flags, 'CIRCULAR');
13884
+ const box = [], titles = [];
13885
+ // flat: fill-opacity (boxopacity || 1) on the raw style value — the usual
13886
+ // string "0" is transparent; typed here, a given 0 is too
13887
+ const boxOpacity = isNaN(styleNum(st.boxopacity)) ? 1 : styleNum(st.boxopacity);
13888
+ if (o.box === false) {
13889
+ // the title layer only
13890
+ } else if (circular) {
13891
+ const boxNone = isNoneColor(st.boxcolor), borderNone = isNoneColor(st.bordercolor);
13892
+ const boxRgb = hexOrNamedToRgb(boxNone ? '#eeeeee' : st.boxcolor || '#eeeeee');
13893
+ const boxAlpha = boxNone ? 0 : Math.round(255 * boxOpacity);
13894
+ const borderRgb = borderNone ? null : hexOrNamedToRgb(st.bordercolor || '#dddddd');
13895
+ // Always a ScatterplotLayer: its shader circle is smooth at any
13896
+ // size, where an icon — a 48px canvas texture deck.gl scales up —
13897
+ // pixelated with large boxes. ScatterplotLayer has no pixel offset,
13898
+ // so the chart's align/offsetx/offsety move and flat's circular box
13899
+ // position (the chart's bounding-box center, 18793) are baked into
13900
+ // map coordinates (pixelOffsetLngLat). Pickable: flat's box is part
13901
+ // of the chart group, so hovering it fires the chart's tooltip — the
13902
+ // symbols sit above it, so they keep their own
13903
+ box.push(new ScatterplotLayer({
13904
+ id: ids.circle,
13905
+ data: rows, pickable: true,
13906
+ getPosition: b => { const f = offsetOf(b); return pixelOffsetLngLat(positionOf(b), [f[0] + b.cx, f[1] + b.cy], liveZoom); },
13907
+ getRadius: b => b.boxR,
13908
+ radiusUnits: 'pixels',
13909
+ stroked: !borderNone,
13910
+ getFillColor: [...boxRgb.slice(0, 3), boxAlpha],
13911
+ getLineColor: [...(borderRgb || boxRgb).slice(0, 3), 255],
13912
+ getLineWidth: unit / 20,
13913
+ lineWidthUnits: 'pixels',
13914
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
13915
+ }));
13916
+ } else {
13917
+ // flat's rectangular box (the chart and its title plus the margin)
13918
+ const borderStyle = String(st.borderstyle || '');
13919
+ const boxLook = {
13920
+ fill: st.boxcolor || 'white',
13921
+ fillOpacity: boxOpacity,
13922
+ stroke: borderStyle === 'none' || isNoneColor(styleLineColor(st.bordercolor)) ? null : styleLineColor(st.bordercolor) || '#bbbbbb',
13923
+ strokeOpacity: /^(solid|dotted|dashed)$/.test(borderStyle) ? 1 : 0.3,
13924
+ };
13925
+ const borderWidth = styleNum(st.borderwidth) || 1;
13926
+ const borderRadius = styleNum(st.borderradius);
13927
+ const strokeOf = b => borderWidth * 0.2 * b.w / 30;
13928
+ // a box larger than BOX_ICON_MAX_PX (a MULTIQUAD grid of thousands
13929
+ // of items) would need a huge canvas: it is drawn as a polygon in
13930
+ // map coordinates instead (square corners)
13931
+ const bigBox = b => Math.max(b.rect[2] - b.rect[0], b.rect[3] - b.rect[1]) > BOX_ICON_MAX_PX;
13932
+ const iconBoxes = rows.filter(b => !bigBox(b)), polygonBoxes = rows.filter(bigBox);
13933
+ iconBoxes.forEach(b => {
13934
+ b.icon = this._buildBoxIcon(b.rect[2] - b.rect[0], b.rect[3] - b.rect[1],
13935
+ isNaN(borderRadius) ? 0 : borderRadius * Math.min(unit, b.w / 30),
13936
+ strokeOf(b), boxLook);
13937
+ });
13938
+ if (polygonBoxes.length) {
13939
+ const fillRgb = hexOrNamedToRgb(boxLook.fill);
13940
+ const strokeRgb = boxLook.stroke ? hexOrNamedToRgb(boxLook.stroke) : null;
13941
+ box.push(new GeoJsonLayer({
13942
+ id: ids.polygons,
13943
+ data: polygonBoxes.map(b => {
13944
+ const f = offsetOf(b), c = positionOf(b), r = b.rect;
13945
+ const at = (x, y) => pixelOffsetLngLat(c, [x + f[0], y + f[1]], liveZoom);
13946
+ return { type: 'Feature', properties: { stroke: strokeOf(b) },
13947
+ 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])]] } };
13948
+ }),
13949
+ pickable: false,
13950
+ filled: boxLook.fillOpacity > 0,
13951
+ stroked: !!strokeRgb,
13952
+ getFillColor: [...fillRgb.slice(0, 3), Math.round(255 * boxLook.fillOpacity)],
13953
+ getLineColor: strokeRgb ? [...strokeRgb.slice(0, 3), Math.round(255 * boxLook.strokeOpacity)] : [0, 0, 0, 0],
13954
+ getLineWidth: f => f.properties.stroke,
13955
+ lineWidthUnits: 'pixels',
13956
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
13957
+ }));
13958
+ }
13959
+ if (iconBoxes.length) box.push(new IconLayer({
13960
+ // the generation suffix (like every icon-atlas layer id, see
13961
+ // ICON_ATLAS_RESET_AFTER): every zoom creates a new set of box
13962
+ // rasters, and deck.gl's atlas never evicts
13963
+ id: `${ids.icon}-g${this._iconGeneration}`,
13964
+ data: iconBoxes, pickable: false,
13965
+ getPosition: positionOf,
13966
+ getIcon: b => b.icon,
13967
+ // the icon is drawn at 2× with a stroke padding around the box
13968
+ getSize: b => b.icon.height / 2,
13969
+ 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]]; },
13970
+ sizeUnits: 'pixels',
13971
+ billboard: true,
13972
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
13973
+ }));
13974
+ }
13975
+ const titleData = rows.filter(b => b.title);
13976
+ if (titleData.length) titles.push(new TextLayer({
13977
+ id: ids.titles,
13978
+ data: titleData, pickable: false,
13979
+ getPosition: positionOf,
13980
+ getText: b => b.title.text,
13981
+ getSize: b => b.title.f,
13982
+ sizeUnits: 'pixels',
13983
+ fontFamily: 'arial',
13984
+ characterSet: 'auto',
13985
+ lineHeight: 1,
13986
+ getColor: [...hexOrNamedToRgb(st.textcolor || '#888888').slice(0, 3), 255],
13987
+ fontSettings: { sdf: true },
13988
+ outlineWidth: 1 / 7,
13989
+ outlineColor: [255, 255, 255, 128],
13990
+ getTextAnchor: b => b.title.anchor,
13991
+ // top of the text ≈ first baseline − 0.8 font
13992
+ getAlignmentBaseline: 'top',
13993
+ getPixelOffset: b => { const f = offsetOf(b); return [b.title.x + f[0], b.title.top + f[1]]; },
13994
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
13995
+ }));
13996
+ return { box, titles };
13997
+ }
13998
+
13336
13999
  // BOX (see the BOX block in _buildChartLayers): a rectangle icon
13337
14000
  // w×h px, rounded corners rx, stroke sw px; drawn at twice the size
13338
14001
  _buildBoxIcon(w, h, rx, sw, look) {
@@ -13503,7 +14166,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13503
14166
  const groups = doRelocate ? this._groupCoLocated(clusterFeatures, effectiveZoom) : clusterFeatures.map(f => {
13504
14167
  return {
13505
14168
  geometry: f.geometry,
13506
- properties: { counts: this._oneHot(f.properties.cat, f.properties.point_count), total: f.properties.value,
14169
+ // counts is the summed bound VALUE and recordCounts the record
14170
+ // count, as groupCoLocated builds them (RELOCATE) — RELOCATE
14171
+ // moves the item, it never changes a value
14172
+ properties: { counts: this._oneHot(f.properties.cat, f.properties.value),
14173
+ recordCounts: this._oneHot(f.properties.cat, f.properties.point_count), total: f.properties.value,
13507
14174
  ...(f.properties.classValue !== undefined ? { classTotal: f.properties.classValue } : {}),
13508
14175
  ...(f.properties.titleRaw ? { titleRaw: f.properties.titleRaw } : {}) }
13509
14176
  };
@@ -13922,15 +14589,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13922
14589
  // DTEXT: the title font follows the chart, max(0.9u, min(W/5,
13923
14590
  // 14u / scale)) (18815) — measured on the fire areas page's SIF chart
13924
14591
  // (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
14592
  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
14593
  // the chart's value text and label (see VALUES and LABEL below), shared
13935
14594
  // with the box: flat's box encloses whatever the chart group draws
13936
14595
  const valuesShown = this.flags.has('VALUES') && !valuesHiddenByScale(this.style, zoom);
@@ -13938,141 +14597,28 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13938
14597
  const labelShown = !!this.style.labelfield && !flatFlag(this.flags, 'NORMSIZE') && !flatFlag(this.flags, 'ZOOM')
13939
14598
  && !labelHiddenByScale(this.style, zoom);
13940
14599
  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];
14600
+ // with MULTIQUAD only the first item of a position gets its box/title;
14601
+ // the circle sits on the anchor (0, 0), its base radius the symbol's
14602
+ const boxLayout = this._boxRows(() => combined.filter(d => !multi || multi.index.get(d) === 0), {
14603
+ unit: unitPx, liveZoom,
14604
+ radiusOf: d => vRadius(sizeValueOf(d)), centerOf: () => [0, 0],
14605
+ extentOf: d => {
14606
+ const r = vRadius(sizeValueOf(d));
14607
+ const ext = multi ? multi.extent.get(d) : [0, 0, 0, 0];
14608
+ const chart = [ext[0] - r, ext[1] - r, ext[2] + r, ext[3] + r];
14609
+ 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]); };
14610
+ if (valueTextOf && !d.properties.counts) { const v = valueTextOf(d, r); if (v) grow(v.extent); }
14611
+ if (labelOf) { const l = labelOf(d, r); if (l) grow(l.extent); }
14612
+ return chart;
13986
14613
  }
13987
- return { d, r, w, boxR, rect, title };
14614
+ });
14615
+ // (the title layers are pushed after the symbols, below)
14616
+ const boxLayers = boxLayout ? this._boxTitleLayers(boxLayout, {
14617
+ unit: unitPx, liveZoom,
14618
+ positionOf: b => b.d.geometry.coordinates, offsetOf: b => alignOf(b.d),
14619
+ ids: { circle: `ix-box-${this.name}`, icon: `ix-box-${this.name}`, polygons: `ix-box-polygons-${this.name}`, titles: `ix-titles-${this.name}` }
13988
14620
  }) : 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
- }
14621
+ if (boxLayers) layers.push(...boxLayers.box);
14076
14622
 
14077
14623
  // flat's per-part SYMBOL branch (everything but CATEGORICAL without
14078
14624
  // AGGREGATE/GROUP) draws a square or roundrect of side 2r, the other
@@ -14180,29 +14726,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14180
14726
  parameters: ICON_LAYER_GLOBE_PARAMETERS
14181
14727
  }));
14182
14728
 
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
- }
14729
+ if (boxLayers) layers.push(...boxLayers.titles);
14206
14730
 
14207
14731
  // VALUES: bold value label centered on each bubble (see
14208
14732
  // flatValueText/valuesFontSizePx above). Labels are their own
@@ -14526,7 +15050,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14526
15050
  };
14527
15051
  const shapeOf = i => { const sh = symbols && (symbols[i] || symbols[0]); return normalizeSymbolShape(sh); };
14528
15052
  const colorOf = i => this.classRgb(i);
14529
- const parts = [], glows = [];
15053
+ const allParts = [], allGlows = [];
14530
15054
  for (const d of charts) {
14531
15055
  // parts: the record's own field values (a per-item chart, or the
14532
15056
  // single-record cell of an aggregated one); series: a multi-record
@@ -14538,6 +15062,18 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14538
15062
  const sum = counts.reduce((a, v) => a + (v || 0), 0);
14539
15063
  counts = counts.map(v => sum > 0 ? v / (sum / 100) : 0);
14540
15064
  }
15065
+ // flat's aggregated CATEGORICAL item holds a value per category only up
15066
+ // to the highest category it has (measured on flat: the chart's star and
15067
+ // glow follow the categories present as 0..k, 985 of 988 charts of the
15068
+ // concessioni page): trailing zeros are no parts, so with SORT|UP the
15069
+ // smallest part — order index 0, the one that sets the star radius and
15070
+ // carries the glow — is a zero only when a category BELOW the highest
15071
+ // one is missing
15072
+ if (!d.properties.parts && !d.properties.series) {
15073
+ let n = counts.length;
15074
+ while (n > 0 && !counts[n - 1]) n--;
15075
+ if (n < counts.length) counts = counts.slice(0, n);
15076
+ }
14541
15077
  const sizeFactor = itemSizeFactor(d);
14542
15078
  const partRadius = multi
14543
15079
  ? v => Math.max(VALUE_RADIUS_MIN, normalR * Math.pow(Math.abs(v) / seqMax, 1 / sizePow) * sizeFactor)
@@ -14547,7 +15083,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14547
15083
  if (!layout.length) continue;
14548
15084
  // GLOW belongs to the first part (sorted) or to every part, and
14549
15085
  // hides with it when its class is not marked
14550
- const glowParts = new Set(sorted ? [layout[0].i] : layout.map(p => p.i));
15086
+ // every chart glows (deliberately NOT flat's rule, which gives the glow to
15087
+ // the part at sorted order index 0 — a zero, skipped, whenever a category
15088
+ // below the highest is missing — so only about a quarter of flat's charts
15089
+ // glow): a sorted chart's glow belongs to its biggest part, an unsorted
15090
+ // one glows with every part as in flat
15091
+ const biggest = layout.reduce((m, p) => (p.v > m.v ? p : m), layout[0]);
15092
+ const glowParts = new Set(sorted ? [biggest.i] : layout.map(p => p.i));
14551
15093
  // the chart's drawn extent (flat: map.Dom.getBox of its shape
14552
15094
  // group) — the parts' star cluster — for BOX / CIRCULARBOX sizing
14553
15095
  let ex0 = Infinity, ey0 = Infinity, ex1 = -Infinity, ey1 = -Infinity;
@@ -14564,12 +15106,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14564
15106
  d._seqLast = layout[layout.length - 1].r;
14565
15107
  const items = layout.map(p => {
14566
15108
  const item = { geometry: d.geometry, properties: d.properties, chart: d, part: p };
14567
- if (glowParts.has(p.i)) glows.push(item);
15109
+ if (glowParts.has(p.i)) allGlows.push(item);
14568
15110
  return item;
14569
15111
  });
14570
15112
  // STAR: the first part (its center) is put on top at the end (23145)
14571
15113
  if (star && items.length > 1) items.push(items.shift());
14572
- parts.push(...items);
15114
+ allParts.push(...items);
14573
15115
  }
14574
15116
  const alignOf = this._chartAlignFn(liveZoom, d => (d.chart || d)._seqLast || 0);
14575
15117
  const offsetOf = item => { const o = alignOf(item.chart); return [o[0] + item.part.x, o[1] + item.part.y]; };
@@ -14587,287 +15129,227 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14587
15129
  // max(0.9u, min(w/5, 14u/scale)) · textscale, wrapped to the
14588
15130
  // circle's width — gated by titleupper (shown while the map
14589
15131
  // scale is that bound or coarser, 18810) and boxupper/boxlower
14590
- const boxShown = flatFlag(this.flags, 'BOX') && !boxHiddenByScale(st, liveZoom);
15132
+ // (flat centers the circular box and its title on the chart's
15133
+ // bounding-box CENTER — maptheme.js 18793-18798 — not on its anchor: a
15134
+ // star cluster's satellites spread asymmetrically around it; the
15135
+ // circle's title baselines are measured on the resized box, 18798-18830)
14591
15136
  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);
15137
+ const boxLayoutAll = this._boxRows(() => charts.filter(d => d._seqExtent), { unit, liveZoom, extentOf: d => d._seqExtent });
15138
+ // per-chart overlap bands (see the band computation in the
15139
+ // box block): a Map chart → band index, filled only when
15140
+ // boxes are drawn; empty (single band) otherwise
15141
+ const bandOfChart = new Map();
15142
+ let nBands = 1;
15143
+ if (boxLayoutAll) {
15144
+ // flat draws every chart as ONE group — its box, its symbols,
15145
+ // its title, its values together — and the groups are sorted by
15146
+ // size, so a bigger chart covers a smaller one ENTIRELY
15147
+ // (chartMap's ascending sort, the biggest drawn last). deck.gl
15148
+ // stacks whole layers, so with every box in one layer below
15149
+ // every symbol, a bigger chart's box covered a smaller chart's
15150
+ // box while that chart's symbols floated on top of it (the
15151
+ // City-moving page's overlapping circles). Charts are grouped
15152
+ // into overlap "bands" instead — an interval coloring assigned
15153
+ // biggest-chart-first: within a band no two charts overlap
15154
+ // (their box + title extents, in map pixel space, disjoint),
15155
+ // and a chart is always in a band above every smaller chart it
15156
+ // overlaps. Each band then draws its charts' complete group
15157
+ // (box, title, glow, symbols, values), the smallest band first
15158
+ // — deck.gl draws the first layer at the bottom — so between
15159
+ // two overlapping charts the bigger one is wholly on top, as
15160
+ // flat draws it. Overlap-free themes keep one band and today's
15161
+ // exact single layers
15162
+ const world = 512 * Math.pow(2, liveZoom);
15163
+ const anchorPx = d => {
15164
+ const o = alignOf(d), c = d.geometry.coordinates;
15165
+ const lat = c[1] * Math.PI / 180;
15166
+ return [(c[0] + 180) / 360 * world + o[0],
15167
+ (0.5 - Math.log(Math.tan(Math.PI / 4 + lat / 2)) / (2 * Math.PI)) * world + o[1]];
15168
+ };
15169
+ const sizeOfBand = b => circular ? b.boxR
15170
+ : b.rect ? Math.max(b.rect[2] - b.rect[0], b.rect[3] - b.rect[1]) / 2 : 0;
15171
+ const bandRects = [];
15172
+ for (const b of boxLayoutAll.slice().sort((a, c) => sizeOfBand(c) - sizeOfBand(a))) {
15173
+ // the chart's whole drawn extent: parts cluster, box and
15174
+ // title, as px around the (align-moved) anchor
15175
+ const e = b.d._seqExtent || [0, 0, 0, 0];
15176
+ let x0 = e[0], y0 = e[1], x1 = e[2], y1 = e[3];
15177
+ 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); }
15178
+ 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]); }
15179
+ 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]); }
15180
+ const p = anchorPx(b.d);
15181
+ const r = [p[0] + x0, p[1] + y0, p[0] + x1, p[1] + y1];
15182
+ let k = 0;
15183
+ while (bandRects[k] && bandRects[k].some(q => r[0] < q[2] && q[0] < r[2] && r[1] < q[3] && q[1] < r[3])) k++;
15184
+ (bandRects[k] = bandRects[k] || []).push(r);
15185
+ bandOfChart.set(b.d, k);
15186
+ }
15187
+ nBands = bandRects.length;
15188
+ }
15189
+ // one band's whole chart group — box, title, glow, symbols and
15190
+ // values of ITS charts only (the closure's boxLayout, glows and
15191
+ // parts are the band's slices) — pushed as one deck.gl layer
15192
+ // sequence, in today's order. bs: the band's id suffix
15193
+ const drawBand = (boxLayout, glows, parts, bs) => {
15194
+ if (boxLayout) {
15195
+ const L = this._boxTitleLayers(boxLayout, {
15196
+ unit, liveZoom,
15197
+ positionOf: b => b.d.geometry.coordinates, offsetOf: b => alignOf(b.d),
15198
+ ids: { circle: `ix-seqbox-${this.name}${bs}`, icon: `ix-seqbox-${this.name}${bs}`,
15199
+ polygons: `ix-seqbox-polygons-${this.name}${bs}`, titles: `ix-seqtitles-${this.name}${bs}` }
14700
15200
  });
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]]; },
15201
+ layers.push(...L.box, ...L.titles);
15202
+ }
15203
+ if (glows.length && this.flags.has('GLOW') && !glowHiddenByScale(st, liveZoom)) {
15204
+ layers.push(new IconLayer({
15205
+ id: `ix-glow-${this.name}${bs}-g${this._iconGeneration}`,
15206
+ data: glows, pickable: false,
15207
+ getPosition: d => d.geometry.coordinates,
15208
+ getIcon: d => this._getGlowIcon(colorOf(d.part.i)),
15209
+ getSize: d => d.part.r * 11,
15210
+ getPixelOffset: offsetOf,
15211
+ getColor: d => [255, 255, 255, alphaOf(d)],
14708
15212
  sizeUnits: 'pixels', billboard: true,
14709
15213
  parameters: ICON_LAYER_GLOBE_PARAMETERS
14710
15214
  }));
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]]; },
15215
+ } else if (glows.length && this.flags.has('AURA') && !glowHiddenByScale(st, liveZoom)) {
15216
+ layers.push(new IconLayer({
15217
+ id: `ix-aura-${this.name}${bs}-g${this._iconGeneration}`,
15218
+ data: glows, pickable: false,
15219
+ getPosition: d => d.geometry.coordinates,
15220
+ getIcon: d => this._buildSingleIcon(borderRgb || colorOf(d.part.i), 0.3, 'circle', null, 0),
15221
+ getSize: d => d.part.r * 2 * 1.3,
15222
+ getPixelOffset: offsetOf,
15223
+ getColor: d => [255, 255, 255, alphaOf(d)],
15224
+ sizeUnits: 'pixels', billboard: true,
14730
15225
  parameters: ICON_LAYER_GLOBE_PARAMETERS
14731
15226
  }));
14732
15227
  }
14733
- }
14734
- if (glows.length && this.flags.has('GLOW') && !glowHiddenByScale(st, liveZoom)) {
14735
- layers.push(new IconLayer({
14736
- id: `ix-glow-${this.name}-g${this._iconGeneration}`,
14737
- data: glows, pickable: false,
14738
- 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
15228
  layers.push(new IconLayer({
14748
- id: `ix-aura-${this.name}-g${this._iconGeneration}`,
14749
- data: glows, pickable: false,
15229
+ id: `ix-bubbles-${this.name}${bs}-g${this._iconGeneration}`,
15230
+ data: parts, pickable: true,
14750
15231
  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,
15232
+ // the stroke, in the icon raster's pixels (the icon is drawn at 2r)
15233
+ getIcon: d => {
15234
+ // an external symbol file: the URL icon as flat's <image> (see
15235
+ // urlSymbolOf above), scaled to the part's diameter like flat's
15236
+ // fu.scale(nRadius / nMaxRadius) on a nChartSize image
15237
+ const u = urlSymbolOf(d.part.i);
15238
+ if (u) return urlIcon(u);
15239
+ return this._buildSingleIcon(colorOf(d.part.i), noFill.has(d.part.i) ? 0 : fillOpacity, shapeOf(d.part.i), borderRgb,
15240
+ 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);
15241
+ },
15242
+ getSize: d => d.part.r * 2,
14753
15243
  getPixelOffset: offsetOf,
14754
15244
  getColor: d => [255, 255, 255, alphaOf(d)],
14755
15245
  sizeUnits: 'pixels', billboard: true,
14756
15246
  parameters: ICON_LAYER_GLOBE_PARAMETERS
14757
15247
  }));
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) });
15248
+ // VALUES on each part (maptheme.js 22188-22260): the part's value — with
15249
+ // COUNT its record count, with valuefield on the categorical field the
15250
+ // class name — formatted with valuedecimals (else none) plus the unit.
15251
+ // - a SHAPE part (circle & kin): font min(1, 3.2 / L) · r · valuescale,
15252
+ // L the length of the theme's biggest value text (szMaxText), normal
15253
+ // weight, in valuecolor / textcolor, else ChartColors.textColor of
15254
+ // the part color
15255
+ // - an IMAGE-symbol part (an external .svg, the TELRAAM pictograms):
15256
+ // flat's own other branch (22253-22256) — font max(r · 0.8, 1) ·
15257
+ // valuescale, the text over createTextLabel's background box (white,
15258
+ // its rgba(…,0.3) fill × the 0.5 fill-opacity) and in
15259
+ // ChartColors.lowColor — the 0.7 derivate of the part color —
15260
+ // regardless of valuecolor/textcolor (flat passes cColor.lowColor
15261
+ // straight into createTextLabel)
15262
+ // CENTERVALUES: only on the first part (class 0), its own value
15263
+ if ((this.flags.has('VALUES') || centerValues) && !valuesHiddenByScale(st, zoom)) {
15264
+ const valueScale = styleNum(st.valuescale) || 1;
15265
+ const { noBreaks } = this._valueTextOpts();
15266
+ const units = st.units ? String(st.units) : '';
15267
+ const unit = units ? (units[0] === '.' ? '' : ' ') + units : '';
15268
+ const unitText = unit.length <= 5 ? unit : '';
15269
+ const dec = explicitValueDecimals(st) || 0;
15270
+ const fmt = v => flatGroupedValue(v, dec, noBreaks) + unitText;
15271
+ const maxLen = fmt(seqMax).length;
15272
+ const textOverride = st.valuecolor || st.textcolor;
15273
+ const counting = flatFlag(this.flags, 'COUNT');
15274
+ const labels = [];
15275
+ parts.forEach(d => {
15276
+ if (centerValues && d.part.i !== 0) return;
15277
+ const rec = this._categoryValueRecord(d.part.i);
15278
+ const name = rec ? String(rec[st.valuefield]) : null;
15279
+ const t = counting && d.properties.recordCounts ? d.properties.recordCounts[d.part.i] : d.part.v;
15280
+ const text = name != null ? name : fmt(t);
15281
+ const image = !!urlSymbolOf(d.part.i);
15282
+ const L = Math.max((name != null ? name.length : 0) + 1, maxLen);
15283
+ const fontSize = image
15284
+ ? Math.max(d.part.r * 0.8, 1) * valueScale
15285
+ : Math.min(1, 3.2 / L) * d.part.r * valueScale;
15286
+ if (fontSize > VALUES_MIN_FONT_PX) {
15287
+ labels.push({ geometry: d.geometry, text, fontSize, pixelOffset: offsetOf(d), alpha: alphaOf(d),
15288
+ image,
15289
+ color: image ? flatDerivateRgb(colorOf(d.part.i), 0.7)
15290
+ : textOverride ? hexOrNamedToRgb(textOverride) : flatChartTextRgb(colorOf(d.part.i)) });
15291
+ }
15292
+ });
15293
+ if (labels.length) {
15294
+ // VALUEBACKGROUND on URL-symbol parts: a fading white border
15295
+ // around the value's own CHARACTER SHAPES — the glyphs
15296
+ // re-drawn in white on rings of pixel offsets (a stroke of
15297
+ // copies, no box): the width is a fraction of the font size,
15298
+ // so the halo scales with every label; the shape-part values
15299
+ // keep flat's plain text (no outline)
15300
+ if (flatFlag(this.flags, 'VALUEBACKGROUND')) {
15301
+ const haloLabels = labels.filter(l => l.image);
15302
+ const plainLabels = labels.filter(l => !l.image);
15303
+ if (haloLabels.length) {
15304
+ // a real outline around the characters: the label's own
15305
+ // glyphs re-drawn in white on rings of pixel offsets — a
15306
+ // stroke of copies that hugs the character shapes (deck's
15307
+ // SDF outline is only a faint, buffer-clipped rim). The
15308
+ // base radius is a small fraction of the font size (the
15309
+ // 1px floor keeps it tiny until labels get big), and the
15310
+ // ring alphas fall off, so the border fades to nothing
15311
+ const DIRS = 16, RINGS = [[1, 210], [2, 120], [3.2, 65]];
15312
+ const haloCopies = [];
15313
+ for (const l of haloLabels) {
15314
+ const rb = Math.max(0.75, l.fontSize * 0.015);
15315
+ for (let ring = 0; ring < RINGS.length; ring++) {
15316
+ for (let dir = 0; dir < DIRS; dir++) {
15317
+ const a = (dir / DIRS) * 2 * Math.PI;
15318
+ haloCopies.push({ ...l,
15319
+ dx: Math.cos(a), dy: Math.sin(a),
15320
+ radius: rb * RINGS[ring][0],
15321
+ haloAlpha: Math.round(RINGS[ring][1] * l.alpha / 255) });
15322
+ }
14853
15323
  }
14854
15324
  }
15325
+ layers.push(new TextLayer({
15326
+ id: `ix-cluster-values-halo-${this.name}${bs}`,
15327
+ data: haloCopies, pickable: false,
15328
+ getPosition: d => d.geometry.coordinates,
15329
+ getText: d => d.text,
15330
+ getSize: d => d.fontSize,
15331
+ getColor: d => [255, 255, 255, d.haloAlpha],
15332
+ getPixelOffset: d => [d.pixelOffset[0] + d.dx * d.radius, d.pixelOffset[1] + d.dy * d.radius],
15333
+ sizeUnits: 'pixels', fontFamily: 'arial', fontWeight: 'normal',
15334
+ characterSet: 'auto',
15335
+ getTextAnchor: 'middle', getAlignmentBaseline: 'center'
15336
+ }));
15337
+ layers.push(new TextLayer({
15338
+ id: `ix-cluster-values-${this.name}${bs}`,
15339
+ data: haloLabels, pickable: false,
15340
+ getPosition: d => d.geometry.coordinates,
15341
+ getText: d => d.text,
15342
+ getSize: d => d.fontSize,
15343
+ getColor: d => [...d.color.slice(0, 3), d.alpha],
15344
+ getPixelOffset: d => d.pixelOffset,
15345
+ sizeUnits: 'pixels', fontFamily: 'arial', fontWeight: 'normal',
15346
+ characterSet: 'auto',
15347
+ getTextAnchor: 'middle', getAlignmentBaseline: 'center'
15348
+ }));
14855
15349
  }
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,
15350
+ if (plainLabels.length) layers.push(new TextLayer({
15351
+ id: `ix-cluster-values-plain-${this.name}${bs}`,
15352
+ data: plainLabels, pickable: false,
14871
15353
  getPosition: d => d.geometry.coordinates,
14872
15354
  getText: d => d.text,
14873
15355
  getSize: d => d.fontSize,
@@ -14877,10 +15359,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14877
15359
  characterSet: 'auto',
14878
15360
  getTextAnchor: 'middle', getAlignmentBaseline: 'center'
14879
15361
  }));
14880
- }
14881
- if (plainLabels.length) layers.push(new TextLayer({
14882
- id: `ix-cluster-values-plain-${this.name}`,
14883
- data: plainLabels, pickable: false,
15362
+ } else {
15363
+ layers.push(new TextLayer({
15364
+ id: `ix-cluster-values-${this.name}${bs}`,
15365
+ data: labels, pickable: false,
14884
15366
  getPosition: d => d.geometry.coordinates,
14885
15367
  getText: d => d.text,
14886
15368
  getSize: d => d.fontSize,
@@ -14890,21 +15372,20 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14890
15372
  characterSet: 'auto',
14891
15373
  getTextAnchor: 'middle', getAlignmentBaseline: 'center'
14892
15374
  }));
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
- }));
15375
+ }
14906
15376
  }
14907
15377
  }
15378
+ };
15379
+ const chartBand = c => bandOfChart.get(c) || 0;
15380
+ if (nBands > 1) {
15381
+ // deck.gl draws the FIRST layer at the bottom: the smallest
15382
+ // charts' band first, the biggest (band 0) last, on top
15383
+ for (let k = nBands - 1; k >= 0; k--) drawBand(
15384
+ boxLayoutAll ? boxLayoutAll.filter(b => chartBand(b.d) === k) : null,
15385
+ allGlows.filter(g => chartBand(g.chart) === k),
15386
+ allParts.filter(p => chartBand(p.chart) === k), '-b' + k);
15387
+ } else {
15388
+ drawBand(boxLayoutAll, allGlows, allParts, '');
14908
15389
  }
14909
15390
  return layers;
14910
15391
  }
@@ -14964,8 +15445,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14964
15445
  // CENTERVALUE the item's record count
14965
15446
  // Not drawn here (a console note at type parsing, PIE_INERT_FLAGS,
14966
15447
  // 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
15448
+ // 3D, VOLUME, HEIGHT, BOW, ZOOM, DIRECTION / DIRECTED — the pie is
15449
+ // drawn flat and plain instead; POLAR without STARBURST has no
15450
+ // effect in flat either. STARBURST (with the FLOWER/RAYS angle/gap
15451
+ // variants) and its POLAR circles ARE drawn (pieSliceLayout). Nor
14969
15452
  // style.clipparts, showparts, gridx (flat's several pies per item).
14970
15453
  // Known divergence: flat draws a chart's value labels in the chart's
14971
15454
  // own group, so a bigger pie drawn later covers a smaller neighbour's
@@ -15029,6 +15512,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15029
15512
  const lineRgb = line === 'none' ? null : hexOrNamedToRgb(line);
15030
15513
  const lineWidthOf = r => (lineRgb ? (styleNum(st.linewidth) || 0.1) * r / 15 : 0);
15031
15514
  const lineRgba = lineRgb ? [...lineRgb.slice(0, 3), Math.round(255 * lineAlpha)] : [0, 0, 0, 0];
15515
+ // flat's STARBURST (maptheme.js 20262-20275, piechart.js 281-287,
15516
+ // 415-420): the parts get equal angles and a ray's radius carries
15517
+ // its value — inner + span · sqrt(v / maxValue) · scale, scale =
15518
+ // maxValue / (normalsizevalue || maxValue) · 2.14 (with SIZE flat
15519
+ // sets only maxValue, so a part at the reference value reaches
15520
+ // 2.14 R). maxValue: style.maxvalue (the reference — the ISTAT
15521
+ // story's national mean), else the biggest part value over the
15522
+ // items (flat's max of the per-part maxima nMaxA). The star's
15523
+ // inner px (the ray variants' 5px) comes off the layout
15524
+ const star = has(/STARBURST/);
15525
+ let starMaxPart = 0;
15032
15526
 
15033
15527
  const charts = [];
15034
15528
  if (agg) {
@@ -15043,6 +15537,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15043
15537
  if (!layout) continue;
15044
15538
  if (cat) layout.slices.forEach(sl => { sl.single = values.length === 1; });
15045
15539
  const sum = values.reduce((a, v) => a + (isNaN(v) ? 0 : v), 0);
15540
+ if (star) for (const v of values) if (v > starMaxPart) starMaxPart = v;
15046
15541
  const r = nSizeOf(sum, size) * unit;
15047
15542
  if (!(r > 0) || !isFinite(r)) continue;
15048
15543
  // flat's draw-order key (maptheme.js 16812-16860): with a size
@@ -15067,6 +15562,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15067
15562
  const layout = pieSliceLayout(values, t, { centerPart: st.centerpart });
15068
15563
  if (!layout) continue;
15069
15564
  const sum = values.reduce((a, v) => a + v, 0);
15565
+ if (star) for (const v of values) if (v > starMaxPart) starMaxPart = v;
15070
15566
  const size = this.binding.size ? parseFloat(raw[this.binding.size]) : undefined;
15071
15567
  const r = nSizeOf(sum, size) * unit;
15072
15568
  if (!(r > 0) || !isFinite(r)) continue;
@@ -15099,14 +15595,80 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15099
15595
  });
15100
15596
 
15101
15597
  const TAU = 2 * Math.PI;
15102
- const slices = [], centers = [], glows = [];
15598
+ const slices = [], centers = [], glows = [], polar = [], polarDashes = [];
15103
15599
  const glowOn = has(/GLOW/) && !has(/ZOOM/) && !glowHiddenByScale(st, liveZoom);
15600
+ // the STARBURST ray scale (see the star comment above): style
15601
+ // maxvalue, else the biggest part value over the items — flat's
15602
+ // `this.nMaxValue || max(nMaxA)`. Without SIZE flat also sets the
15603
+ // donut's normalSizeValue (normalsizevalue, else maxValue), which
15604
+ // dampens the scale
15605
+ const starMax = styleNum(st.maxvalue);
15606
+ const starRef = !isNaN(starMax) && starMax > 0 ? starMax : (starMaxPart > 0 ? starMaxPart : 1);
15607
+ const starNsv = has(/\bSIZE\b/) ? starRef : (nsv || starRef);
15608
+ const starScale = starRef / starNsv * 2.14;
15609
+ // a chart's rays start at the CENTER — flat's `max(5, nRadInner)`
15610
+ // for the ray variants is 5 units of the chart's internal
15611
+ // coordinate system, sub-pixel and invisible on screen (see
15612
+ // pieSliceLayout) — and only with DONUT at the donut's inner
15613
+ // radius (flat piechart.js 326-327: nRadOuter += nRadInner, the
15614
+ // span stays R)
15615
+ const starInnerOf = ch => (ch.layout.inner || 0) * ch.r;
15616
+ const starRadius = (v, R, inner) => Math.max(1, inner + (R - inner) * Math.sqrt(Math.max(0, v) / starRef) * starScale);
15104
15617
  for (const ch of charts) {
15618
+ const starInner = star ? starInnerOf(ch) : 0;
15619
+ if (star && has(/POLAR/)) {
15620
+ // flat piechart.js 435-452: the STARBURST's reference circles —
15621
+ // a white disc at the reference value (fill-opacity 0.25,
15622
+ // #444444 border, solid) and range rings at 75 / 50 / 25 % of
15623
+ // it, the 75% and 25% rings DASHED (stroke-dasharray 50 50 in
15624
+ // the chart group's units, on for half of each cycle). All of
15625
+ // them under every ray. The stroke widths are flat's 1px /
15626
+ // 3px — in the CHART GROUP's coordinate system, whose scale
15627
+ // is ~0.017 of the screen, so on screen they are sub-pixel
15628
+ // hairlines (measured on the ISTAT story: the disc's 197
15629
+ // group-units radius is 11.2px on screen); 1px ≈ 1.1% and
15630
+ // 3px ≈ 3.2% of the base radius R, taken here as fractions
15631
+ // of ch.r so they stay hairlines at every zoom, as in flat
15632
+ const lw1 = ch.r * 0.0108, lw3 = ch.r * 0.0325;
15633
+ const w = ch.r - starInner;
15634
+ polar.push({ position: ch.c, r: starInner + w * starScale, off: ch.off,
15635
+ fill: [255, 255, 255, 64], line: [68, 68, 68, 255], lw: lw1 });
15636
+ polar.push({ position: ch.c, r: starInner + w * Math.sqrt(0.5) * starScale, off: ch.off,
15637
+ fill: [0, 0, 0, 0], line: [68, 68, 68, 255], lw: lw1 });
15638
+ // the dashed rings: one chord per dash — flat's own arcs are
15639
+ // octant polylines, so straight chords are as faithful. The
15640
+ // dash count is flat's geometry: a 50-unit dash on the ring's
15641
+ // group radius — the ring in group units is ~17.7 × its
15642
+ // screen radius (the chart group scale measured on the
15643
+ // story), so a cycle is 100 group units = 100/(17.7 · 2π ·
15644
+ // ringR_screen) of the circle: ~11 dashes around the 75%,
15645
+ // ~6 around the 25% ring, at every zoom
15646
+ for (const q of [0.75, 0.25]) {
15647
+ const rr = starInner + w * Math.sqrt(q) * starScale;
15648
+ const cycles = Math.max(q > 0.5 ? 4 : 3, Math.round((q > 0.5 ? 2.06 : 1.19) * ch.r));
15649
+ const seg = 2 * Math.PI / cycles;
15650
+ for (let i = 0; i < cycles; i++) {
15651
+ const a0 = i * seg, a1 = a0 + seg / 2;
15652
+ // 0 at 12 o'clock, clockwise (pieSliceLayout's convention);
15653
+ // pixelOffsetLngLat's y grows down
15654
+ polarDashes.push({
15655
+ path: [
15656
+ pixelOffsetLngLat(ch.c, [Math.sin(a0) * rr, -Math.cos(a0) * rr], liveZoom),
15657
+ pixelOffsetLngLat(ch.c, [Math.sin(a1) * rr, -Math.cos(a1) * rr], liveZoom)
15658
+ ], lw: lw3
15659
+ });
15660
+ }
15661
+ }
15662
+ }
15105
15663
  for (const sl of ch.layout.slices) {
15106
15664
  const rgb = sliceRgb(sl);
15665
+ // STARBURST: this ray's own radius (the value), its inner the
15666
+ // px starInner as a fraction of that radius — the shader takes
15667
+ // a fraction of the instance radius, which varies per ray here
15668
+ const rayR = star ? starRadius(sl.value, ch.r, starInner) : ch.r;
15107
15669
  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],
15670
+ position: ch.c, r: rayR, off: ch.off, properties: ch.properties, chart: ch, part: sl,
15671
+ slice: [sl.start * Math.PI / 180, sl.sweep >= 360 ? TAU : sl.sweep * Math.PI / 180, star ? starInner / rayR : ch.layout.inner],
15110
15672
  fill: [...rgb.slice(0, 3), !sl.complement && noFill.has(sl.i) ? 0 : Math.round(255 * fillAlpha)]
15111
15673
  });
15112
15674
  }
@@ -15130,13 +15692,80 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15130
15692
  radiusUnits: 'pixels', billboard: true, stroked: false, filled: true, antialiasing: true,
15131
15693
  parameters: ICON_LAYER_GLOBE_PARAMETERS
15132
15694
  };
15133
- const layers = [new PieSliceLayer(Object.assign({}, discProps, {
15695
+ // flat's BOX / TITLE for a pie (maptheme.js 18446-18925, the same
15696
+ // block the sequence builder draws): a rectangle around the
15697
+ // chart's drawn extent — on a STARBURST the longest ray, the
15698
+ // disc otherwise — plus, with TITLE, the item's title (an
15699
+ // aggregated grid cell of more than 3 records without its own:
15700
+ // "(n)", see _itemTitle) at the chart's left edge, wrapped to
15701
+ // the chart's width, in textcolor (#888888) over a half-opaque
15702
+ // white halo, its first baseline 0.7 font above the box — the
15703
+ // box grows to enclose it, its top trimmed by 0.1 font. The
15704
+ // box: white at boxopacity, #bbbbbb border at 0.3 opacity
15705
+ // (borderstyle solid/dotted/dashed: 1, none: no border),
15706
+ // borderwidth · 0.2 · width/chartSize px wide. Both are removed
15707
+ // outside boxupper / titleupper (maptheme.js 18758-18760, the
15708
+ // TITLE gate 18809-18811)
15709
+ // the chart's drawn extent, px around the anchor (flat's getBox of the
15710
+ // shape group — the longest ray on a STARBURST)
15711
+ const boxRows = this._boxRows(charts, { unit, liveZoom, extentOf: ch => {
15712
+ let e = ch.r;
15713
+ if (star) { const in0 = starInnerOf(ch); for (const sl of ch.layout.slices) e = Math.max(e, starRadius(sl.value, ch.r, in0)); }
15714
+ return [-e, -e, e, e];
15715
+ } });
15716
+ const layers = [];
15717
+ if (boxRows && boxRows.length) {
15718
+ const L = this._boxTitleLayers(boxRows, {
15719
+ unit, liveZoom,
15720
+ positionOf: b => b.d.c, offsetOf: b => b.d.off,
15721
+ 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}` }
15722
+ });
15723
+ layers.push(...L.box, ...L.titles);
15724
+ }
15725
+ if (polar.length) {
15726
+ layers.push(new ScatterplotLayer({
15727
+ id: `ix-pie-polar-${this.name}`,
15728
+ data: polar, pickable: false,
15729
+ getPosition: d => d.position,
15730
+ getRadius: d => d.r,
15731
+ getPixelOffset: d => d.off,
15732
+ radiusUnits: 'pixels', billboard: true,
15733
+ filled: true, stroked: true,
15734
+ getFillColor: d => d.fill,
15735
+ getLineColor: d => d.line,
15736
+ getLineWidth: d => d.lw,
15737
+ lineWidthUnits: 'pixels',
15738
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
15739
+ }));
15740
+ // flat's dashed range rings (see the polar block): one chord per
15741
+ // dash, drawn in map coordinates at the current zoom — the
15742
+ // chart anchors move on zoom, and the layers rebuild with them
15743
+ if (polarDashes.length) {
15744
+ layers.push(new PathLayer({
15745
+ id: `ix-pie-polar-dashes-${this.name}`,
15746
+ data: polarDashes, pickable: false,
15747
+ getPath: d => d.path,
15748
+ getColor: [68, 68, 68, 255],
15749
+ getWidth: d => d.lw,
15750
+ widthUnits: 'pixels',
15751
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
15752
+ }));
15753
+ }
15754
+ }
15755
+ layers.push(new PieSliceLayer(Object.assign({}, discProps, {
15134
15756
  id: `ix-pie-${this.name}`,
15135
15757
  data: slices, pickable: true,
15136
15758
  getLineColor: lineRgba,
15137
15759
  getSlice: d => d.slice,
15138
- getSliceLineWidth: d => lineWidthOf(d.r)
15139
- }))];
15760
+ // flat's pie outline (piechart.js: normalX(linewidth) · R / 300)
15761
+ // is 2% of the radius — sub-pixel for every small chart, and the
15762
+ // browser rasterizes it as a HAIRLINE: one device pixel at full
15763
+ // opacity (Skia's stroke floor). The 1 device-px floor here
15764
+ // matches that: the width is in the shader's device-pixel
15765
+ // space (pieAA = fwidth), so the CSS width is scaled by the
15766
+ // device pixel ratio first
15767
+ getSliceLineWidth: d => (lineWidthOf(d.r) > 0 ? Math.max(lineWidthOf(d.r) * (global.devicePixelRatio || 1), 1) : 0)
15768
+ })));
15140
15769
  if (centers.length) {
15141
15770
  layers.push(new PieSliceLayer(Object.assign({}, discProps, { id: `ix-pie-center-${this.name}`, data: centers, pickable: true })));
15142
15771
  }
@@ -15350,7 +15979,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15350
15979
  // STACKED beside the column on leader lines; flat's values color
15351
15980
  // #798697 (textcolor), negative red (valuecolor); DTEXT and a sized
15352
15981
  // POINTER fade small values
15353
- // - BOX / TITLE (flatChartBox): a box around chart and title, the
15982
+ // - BOX / TITLE (chartBoxLayout): a box around chart and title, the
15354
15983
  // item's title above it — boxcolor (white) at boxopacity, bordercolor
15355
15984
  // (#bbbbbb) at 0.3, borderwidth · 0.2 · w / 30; title 5 u ·
15356
15985
  // textscale in textcolor (#888888) over a white halo
@@ -15446,9 +16075,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15446
16075
  }
15447
16076
  applyFlatMaxCharts(charts, st);
15448
16077
 
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);
16078
+ // a BAR draws its box as a shader shape (rectangle only, no CIRCULARBOX):
16079
+ // the gates, margin, title font and placement are the box's, shared with
16080
+ // every chart type (_boxSpec / chartBoxLayout, here in chart units)
16081
+ const boxSpec = this._boxSpec(liveZoom);
16082
+ const boxUnits = st.titleunits ? ' ' + st.titleunits : '';
15452
16083
  const boxOpacity = isNaN(styleNum(st.boxopacity)) ? 1 : styleNum(st.boxopacity);
15453
16084
  const borderStyle = String(st.borderstyle || '');
15454
16085
  const boxFill = [...hexOrNamedToRgb(isNoneColor(st.boxcolor) ? 'white' : st.boxcolor || 'white').slice(0, 3), isNoneColor(st.boxcolor) ? 0 : Math.round(255 * boxOpacity)];
@@ -15477,10 +16108,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15477
16108
  for (const ch of charts) {
15478
16109
  const L = ch.layout;
15479
16110
  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 });
16111
+ if (boxSpec) {
16112
+ const title = boxSpec.titleShown ? this._itemTitle(ch.properties) : '';
16113
+ box = chartBoxLayout(L.extent, { unit: 1, margin: boxSpec.margin, titleText: title ? title + boxUnits : '',
16114
+ titleFont: w => boxSpec.titleFontOf(1, w), bottomTitle: boxSpec.bottomTitle, alignRight: boxSpec.alignRight, measure });
15484
16115
  }
15485
16116
  const ext = box ? box.rect : L.extent;
15486
16117
  // align / offsetx / offsety (flatChartAlignOffset), the chart's
@@ -15489,9 +16120,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15489
16120
  if (box) {
15490
16121
  const r = box.rect;
15491
16122
  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]] });
16123
+ boxFill, boxLine, borderWidth * 0.2 * box.w / 30, null, true);
16124
+ // the title in px, rendered by the shared title layer
16125
+ if (box.title) titles.push({ d: ch, title: { text: box.title.text, x: box.title.x * unit, anchor: box.title.anchor,
16126
+ top: box.title.top * unit, f: box.title.f * unit } });
15495
16127
  }
15496
16128
  for (const b of L.bars) {
15497
16129
  const rgb = this.classRgb(b.cls);
@@ -15559,18 +16191,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15559
16191
  backgroundPadding: [0.3, 0, 0.4, 0.1].map(k => k * boxed.reduce((a, d) => a + d.font, 0) / boxed.length)
15560
16192
  })));
15561
16193
  }
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
- }
16194
+ if (titles.length) layers.push(...this._boxTitleLayers(titles, {
16195
+ box: false, unit, liveZoom, positionOf: b => b.d.c, offsetOf: b => b.d.off,
16196
+ ids: { titles: `ix-bar-titles-${this.name}` }
16197
+ }).titles);
15574
16198
  return layers;
15575
16199
  }
15576
16200
 
@@ -15757,22 +16381,28 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15757
16381
  return [(lng + 180) / 360 * world, (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * world];
15758
16382
  };
15759
16383
  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.
16384
+ // the curves are computed in the plane the map is DISPLAYED in: Web
16385
+ // Mercator under MapLibre's mercator projection; on the globe (flat's
16386
+ // orthographic map) the orthographic plane of the view center, for
16387
+ // the flows whose two ends face the viewer (the others keep
16388
+ // Mercator's). flat's own Equal Earth / Winkel plane (e1cb264) is NOT
16389
+ // used: this engine displays those maps in Web Mercator too (a flat
16390
+ // SVG map in another projection is reported "not available — mercator
16391
+ // used"), and a curve bowed in the Equal Earth plane swings far
16392
+ // toward the pole once Mercator renders it — measured on
16393
+ // mechanical_goods_flows.html: Japan→USA at 82°N, its bow almost half
16394
+ // the chord, where the Mercator curve stays at 63°N — the "deviated
16395
+ // near the north pole" vectors. Flat bows its curves in the plane of
16396
+ // ITS map; here the plane of the display.
15765
16397
  const planeFns = (proj, k) => ({
15766
16398
  toPx: ([lng, lat]) => { const q = proj.project(lat, lng); return [q[0] * k, -q[1] * k]; },
15767
16399
  toLngLat: ([x, y]) => { const q = proj.invert(x / k, -y / k); return [q[1], q[0]]; }
15768
16400
  });
15769
16401
  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
16402
  const planeCenter = flatViewCenterByOptions.get(this.mapOptions) || globeCenter;
15773
- const ortho = !fixedFns && globeCenter ? orthographicPlane(planeCenter.lat, planeCenter.lng) : null;
16403
+ const ortho = globeCenter ? orthographicPlane(planeCenter.lat, planeCenter.lng) : null;
15774
16404
  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));
16405
+ const fnsOf = it => (orthoFns && ortho.inFront(it.p1) && ortho.inFront(it.p2) ? orthoFns : mercFns);
15776
16406
  const inBox = c => c && c[0] >= bbox[0] && c[0] <= bbox[2] && c[1] >= bbox[1] && c[1] <= bbox[3];
15777
16407
 
15778
16408
  const sizePow = resolveSizePow(st, this.flags);
@@ -15871,7 +16501,6 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15871
16501
  // — every size equal — SVG ignores: the opacity stays)
15872
16502
  if (dop || dopMin) { const d = dopacity(it); if (isFinite(d)) { lineAlpha = d; headAlpha = d; } }
15873
16503
  if (gradient) stops = fadeGradientStops(pts, lay.vx, lay.vy, fade);
15874
- if (dash) dashArr = [50 * oz, 5 * oz + sw];
15875
16504
  pts = pts.map(p => [p[0] + a[0], p[1] + a[1]]);
15876
16505
  end = [end[0] + a[0], end[1] + a[1]];
15877
16506
  } else {
@@ -15885,7 +16514,25 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15885
16514
  headS = 4; refX = 4; refY = 2;
15886
16515
  lineAlpha = fo || 0.3;
15887
16516
  headAlpha = Math.min(1, (fo || 0.3) * 2);
15888
- if (dash) dashArr = [50 * oz, 15 * oz];
16517
+ }
16518
+ if (dash) {
16519
+ // flat's dash pattern: one long dash (50 px at the object scale)
16520
+ // and a small gap, its phase walking toward the target — a flow
16521
+ // of any length gets its own count of dashes that way: long ones
16522
+ // many, short ones none at all (a solid line). style.dashcount
16523
+ // instead divides the flow into that many dash+gap periods:
16524
+ // every flow, long or short, shows the same pattern and moves at
16525
+ // the same relative speed (one full dash cycle every 2s), the
16526
+ // arrows as on the region-migration page
16527
+ const nDashes = Math.round(styleNum(st.dashcount) || 0);
16528
+ if (nDashes >= 1) {
16529
+ let flowPx = 0;
16530
+ 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]);
16531
+ const period = flowPx / nDashes;
16532
+ dashArr = [period * 0.85, period * 0.15];
16533
+ } else {
16534
+ dashArr = bezier ? [50 * oz, 5 * oz + sw] : [50 * oz, 15 * oz];
16535
+ }
15889
16536
  }
15890
16537
  // a line under a pixel: one pixel at its width as opacity
15891
16538
  const drawW = Math.max(1, sw);
@@ -15911,7 +16558,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15911
16558
  let run = [], runColors = [];
15912
16559
  const flush = () => {
15913
16560
  if (run.length > 1) {
15914
- const dash = dashArr ? [dashArr[0] / drawW, dashArr[1] / drawW] : [0, 0];
16561
+ // the flow triple: [dash, gap] in px (the shader's path
16562
+ // distance unit) + flat's random per-arc dashoffset phase
16563
+ // (maptheme.js 18249), fixed per arc: the layers are rebuilt on
16564
+ // every pan/zoom, a new random phase each time made dashes jump
16565
+ const dash = dashArr ? [dashArr[0], dashArr[1], flowDashPhase(p1, p2)] : [0, 0, 0];
15915
16566
  if (globeCenter && !dashArr) {
15916
16567
  // on the globe deck.gl draws only the first three quarters of a path that has a
15917
16568
  // color per vertex (also an equal one): one color per path, a gradient as one
@@ -15946,12 +16597,22 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15946
16597
  fill: [...rgb.slice(0, 3), Math.round(255 * Math.min(1, headAlpha) * groupAlpha)] });
15947
16598
  }
15948
16599
  }
15949
- const layers = [new PathLayer({
16600
+ // always FlowPathLayer: without dash offsets its flow hook no-ops
16601
+ // (solid lines), with them the pattern walks toward the target
16602
+ // (see makeFlowPathLayer). The dash triple is px; deck's path
16603
+ // shader measures along-path positions in HALF-STROKE-WIDTH
16604
+ // units (see makeFlowPathLayer), so the accessors convert:
16605
+ // getDashArray carries the pattern period (its gap stays 0 — the
16606
+ // extension renders solid, the moving gap is the flow hook),
16607
+ // getFlowPhase the solid length and flat's random per-arc phase
16608
+ const layers = [new FlowPathLayer({
15950
16609
  id: `ix-vector-${this.name}`, data: paths, pickable: true,
15951
16610
  getPath: d => d.path, getColor: d => d.colors, getWidth: d => d.width,
16611
+ getDashArray: d => [(d.dash[0] + d.dash[1]) * 2 / Math.max(d.width, 1), 0],
16612
+ getFlowPhase: d => [d.dash[0] * 2 / Math.max(d.width, 1), d.dash[2]],
15952
16613
  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 })] } : {})
16614
+ extensions: [new PathStyleExtension({ dash: true, dashMode: 'path' })],
16615
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
15955
16616
  })];
15956
16617
  if (heads.length) {
15957
16618
  // the head count in the id: deck kept a same-id polygon layer's
@@ -16051,7 +16712,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16051
16712
  const v = raw && raw[this.style.labelfield];
16052
16713
  let text = v == null || v === '' ? '' : String(v);
16053
16714
  if (!text) return null;
16054
- const num = parseFloat(text.replace(',', '.'));
16715
+ // flat reads it with __scanValue: "1.234,5", "12 500"
16716
+ const num = flatScanValue(text);
16055
16717
  text = (!isNaN(num) ? flatGroupedValue(num, dec != null ? dec : 2, noBreaks) : text) + units;
16056
16718
  const width = Math.max(titleTextWidth(text, f), (text.length + 2) * f * 0.5);
16057
16719
  const x = width / 3, y = f / 3 + r + 5 * unitPx;
@@ -16223,6 +16885,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16223
16885
  // the box is sized by the value's own text, not the valuefield's
16224
16886
  const boxStyle = Object.assign({}, this.style, { valuefield: undefined });
16225
16887
  const boxes = [], texts = [];
16888
+ // the label's drawn extent for the chart-group BOX (below): x from -r
16889
+ // to 1.05r, y from -0.83f to 0.77f around the chart origin
16890
+ const labelExtent = new Map();
16226
16891
  for (const d of combined) {
16227
16892
  const r = radiusOf(d);
16228
16893
  if (!(r > 0)) continue;
@@ -16237,6 +16902,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16237
16902
  const o = alignOf(d);
16238
16903
  // the box's center relative to the chart's: x from −r to 1.05r, y from −0.83f to 0.77f
16239
16904
  boxes.push({ ...d, icon, pixelOffset: [o[0] + 0.025 * r, o[1] - 0.03 * f] });
16905
+ labelExtent.set(d, [-r, -0.83 * f, 1.05 * r, 0.77 * f]);
16240
16906
  if (showValues) {
16241
16907
  const text = textOf(props);
16242
16908
  const fontSize = Math.min(r * 0.8, r * 3.3 / Math.max(1, text.length)) * valueScale;
@@ -16244,7 +16910,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16244
16910
  if (fontSize > 0) texts.push({ ...d, text, fontSize, color, pixelOffset: o });
16245
16911
  }
16246
16912
  }
16247
- const layers = [];
16913
+ // BOX / TITLE: flat's chart-group box around the label chart
16914
+ const boxRows = this._boxRows(() => combined.filter(d => labelExtent.has(d)), { unit, liveZoom, extentOf: d => labelExtent.get(d) });
16915
+ const boxLayers = boxRows ? this._boxTitleLayers(boxRows, {
16916
+ unit, liveZoom, positionOf: b => b.d.geometry.coordinates, offsetOf: b => alignOf(b.d),
16917
+ 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}` }
16918
+ }) : null;
16919
+ const layers = boxLayers ? [...boxLayers.box] : [];
16248
16920
  if (boxes.length) {
16249
16921
  layers.push(new IconLayer({
16250
16922
  id: `ix-bubbles-${this.name}-g${this._iconGeneration}`,
@@ -16278,6 +16950,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16278
16950
  parameters: ICON_LAYER_GLOBE_PARAMETERS
16279
16951
  }));
16280
16952
  }
16953
+ if (boxLayers) layers.push(...boxLayers.titles);
16281
16954
  return layers;
16282
16955
  }
16283
16956
 
@@ -16535,12 +17208,16 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16535
17208
  const _mapHandles = Object.create(null);
16536
17209
  function makeMapHandle(szMap) {
16537
17210
  const h = {};
16538
- const api = () => (szMap && _mapApis[szMap]) || _lastMapApi;
17211
+ // a name a Map() declared waits for that map; any other name (or none)
17212
+ // is the last built map, as before
17213
+ const declared = () => szMap && _namedMapReady[szMap];
17214
+ const api = () => (szMap && _mapApis[szMap]) || (declared() ? null : _lastMapApi);
16539
17215
  for (const m of MAP_HANDLE_METHODS) {
16540
17216
  h[m] = (...args) => {
16541
17217
  const a = api();
16542
17218
  if (a) { const r = a[m](...args); return r === a ? h : r; }
16543
- _mapReady.then(ready => ready[m](...args));
17219
+ (declared() ? declared().promise : _mapReady).then(ready => ready[m](...args))
17220
+ .catch(err => console.error(`[ixmaps-gl] map(${szMap ? '"' + szMap + '"' : ''}).${m}:`, err));
16544
17221
  return h;
16545
17222
  };
16546
17223
  }
@@ -17005,9 +17682,14 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
17005
17682
  // their own comments) are still real, directly-callable globals a
17006
17683
  // page's OWN custom UI can use too — only THIS generated markup
17007
17684
  // happens to reach them via delegation instead of inline HTML.
17685
+ // the handlers below outlive this call: they read the theme id of
17686
+ // the LATEST call into this container (a theme switch re-renders
17687
+ // the same div for another theme)
17688
+ container._ixFacetsId = szId;
17008
17689
  if (!container.dataset.ixFacetsWired) {
17009
17690
  container.dataset.ixFacetsWired = '1';
17010
17691
  container.addEventListener('click', e => {
17692
+ const szId = container._ixFacetsId;
17011
17693
  const moreBtn = e.target.closest('[data-role="more"]');
17012
17694
  if (moreBtn) { moreBtn.hidden = true; moreBtn.nextElementSibling.hidden = false; return; }
17013
17695
 
@@ -17033,7 +17715,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
17033
17715
  container.addEventListener('keyup', e => {
17034
17716
  if (e.key !== 'Enter') return;
17035
17717
  const input = e.target.closest('[data-role="search"]');
17036
- if (input) global.__setFilter(szId, input.closest('[data-field]').dataset.field, input.value);
17718
+ if (input) global.__setFilter(container._ixFacetsId, input.closest('[data-field]').dataset.field, input.value);
17037
17719
  });
17038
17720
  }
17039
17721
  }
@@ -17123,11 +17805,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
17123
17805
  // known real page's globe toggle actually uses (see
17124
17806
  // example-world-bubble-projection-toggle.md) — so `ixmaps.szResourceBase`
17125
17807
  // 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/';
17808
+ const ixmapsSzResourceBase = '';
17131
17809
  function getProjectString() {
17132
17810
  if (!_lastMapApi || !_lastMapApi.map) return JSON.stringify({ map: {} });
17133
17811
  const map = _lastMapApi.map;
@@ -17247,19 +17925,19 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
17247
17925
  // test-only: lets test/unit/*.test.mjs call pure internals directly (the
17248
17926
  // engine runs in a Node vm there); deliberately NOT on the ixmaps object
17249
17927
  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,
17928
+ 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,
17929
+ chartBoxLayout, choroplethOutlineColor, normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, MapBuilder, typeStyleNumbers, styleNum,
17252
17930
  resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
17253
17931
  equalIntervalBreaks, quantileBreaks, naturalBreaks, evenStrideSample, jenksBreakValues, partsFromBreakValues, resolvePartsClass,
17254
17932
  computeAlphaStats, computeMultiFieldClasses, computeDominantStats, resolveDominantClass, computeComposeColorStats,
17255
17933
  resolveComposedColor, computeRangeClasses, colorSchemeClassCount, resolveDopacityAlpha,
17256
17934
  flatColorSweep, applyClassesToColorScheme, resolveClassColors, flatOutlierStats, parseCssColor, flatLegendLook,
17257
17935
  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,
17936
+ fetchLayerData, QUERY_HANDOFF, parseCsvText, dataTableRows, geometryRowsToFeatureCollection, filterFlatValues, applyField100, field100Binding, rangeClassLegendTotals, resolveAggregateValue, classValueSeparate, cellAggregatedValues, oneHot, groupCoLocated, aggregateOnGrid, GridAggregateIndex, flatRangeParts, explicitRangeBreaks, explicitRangeParts, resolvePartsClass, isNoneColor, aggregateField100, valueRadius, itemPlotGeometry, itemAnchor, objectZoomFactor, resolveZoomReference, defaultNormalSizeValue,
17937
+ applyWhereFilter, joinChartPositions, joinChoroplethFeatures, flatLookupKey, flatShapeCenter, chartHiddenByScale, glowHiddenByScale, isSymbolChart, flatChartAlignOffset, flatChartBranch, flatValueText, categoryValueRecord, flatDerivateRgb, flatBubbleLook, bubbleIconLook, flatChartTextRgb, flatGroupedValue, flatNoBreaks, featuresHiddenByScale, boxHiddenByScale, flatShadowOn, snapToAggregationGrid,
17260
17938
  drawSymbolPath, normalizeSymbolShape,
17261
17939
  flatFormatValue, themeStyleArgs, flatChangedStyleValue, explicitValueDecimals, legendDecimals,
17262
- dataCacheKey, dataCacheDisabled, cachedLayerData, featuresBounds, wantsZoomToExtent, legendIsOn,
17940
+ dataCacheKey, dataCacheDisabled, cachedLayerData, featuresBounds, wantsZoomToExtent, legendIsOn, applySphereBackdrop,
17263
17941
  };
17264
17942
  global.__setFilter = __setFilter;
17265
17943
  global.__removeFacets = __removeFacets;