ixmaps-gl 0.2.23 → 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 +2285 -541
  3. package/package.json +1 -1
package/ixmaps-gl.js CHANGED
@@ -45,7 +45,11 @@
45
45
  // range adjustments; see flatRangeParts). A CHOROPLETH
46
46
  // layer's geometry is borrowed from a FEATURE base layer sharing the same
47
47
  // .layer() name (the real engine's own convention), joined via
48
- // binding.lookup — see joinChoroplethFeatures. HEADTAIL breaks are flat's
48
+ // binding.lookup — see joinChoroplethFeatures. POSITION (maptheme.js
49
+ // 8328-8353) is a theme that renders nothing itself: it loads a table of
50
+ // id + position pairs and provides the id→position join that other themes
51
+ // on the same .layer() name consume via binding.lookup — see
52
+ // joinChartPositions. HEADTAIL breaks are flat's
49
53
  // (headTailBreaks); LOG/POW2/POW3 are recognized type flags but not
50
54
  // implemented, see KNOWN_INERT_FLAGS. CHART|USER draws a page's own chart
51
55
  // function (style.userdraw, _buildUserChartLayers), CHART|LABEL flat's
@@ -96,7 +100,7 @@
96
100
  // ---------------------------------------------------------------
97
101
  // this engine's release (= package.json "version", checked by
98
102
  // test/unit/version.test.mjs); ixmaps.glVersion, and logged once at start
99
- const IXMAPS_GL_VERSION = '0.2.23';
103
+ const IXMAPS_GL_VERSION = '0.2.25';
100
104
 
101
105
  const LIB_URLS = {
102
106
  // Bumped 3.6.2 -> 5.x (2026-09-21, globe-projection compat fix): native
@@ -216,7 +220,7 @@
216
220
  let IconLayer, ScatterplotLayer, GeoJsonLayer, MapLibreOverlay, TextLayer, PathLayer, PathStyleExtension;
217
221
  // PieSliceLayer (see makePieSliceLayer) and BarShapeLayer
218
222
  // (makeBarShapeLayer) are derived from ScatterplotLayer once deck.gl is there
219
- let PieSliceLayer, BarShapeLayer;
223
+ let PieSliceLayer, BarShapeLayer, FlowPathLayer;
220
224
 
221
225
  // CHART|PIE / DONUT slices (_buildPieLayers): one instance per slice — a
222
226
  // ScatterplotLayer circle (billboarded, pixel radius, pixel offset, the
@@ -238,6 +242,111 @@
238
242
  // without anti-aliasing between them (a hard partition: no background
239
243
  // seam shows between two slices); the circle's rim keeps
240
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
+
241
350
  function makePieSliceLayer(Base) {
242
351
  class Layer extends Base {
243
352
  getShaders() {
@@ -289,12 +398,15 @@ in vec4 vPieLineColor;
289
398
  }
290
399
  // in device pixels (fwidth: CSS px per device pixel), so a hairline
291
400
  // stays one at any device pixel ratio. Half the outline lies inside
292
- // each slice (the other half in its neighbour); below a pixel it fades
293
- // 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
294
406
  float pieAA = max(fwidth(pieR), 1e-4);
295
407
  float pieH = vPieLineWidth * 0.5 / pieAA;
296
408
  float pieDD = pieD / pieAA;
297
- 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;
298
410
  float pieRim = clamp((vPieRadius - pieR) / pieAA + 0.5, 0.0, 1.0);
299
411
  color = vec4(mix(color.rgb, vPieLineColor.rgb, pieLine), mix(color.a, vPieLineColor.a * pieRim, pieLine));
300
412
  if (pieInner > 0.0) color.a *= clamp((pieR - pieInner) / pieAA + 0.5, 0.0, 1.0);
@@ -499,6 +611,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
499
611
  ({ IconLayer, ScatterplotLayer, GeoJsonLayer, MapLibreOverlay, TextLayer, PathLayer, PathStyleExtension } = global.deck);
500
612
  PieSliceLayer = makePieSliceLayer(ScatterplotLayer);
501
613
  BarShapeLayer = makeBarShapeLayer(ScatterplotLayer);
614
+ FlowPathLayer = makeFlowPathLayer(PathLayer);
502
615
  // which engine and libraries this page really runs (a page may
503
616
  // bring its own MapLibre/deck.gl — the loaded ones are named)
504
617
  const ml = global.maplibregl && typeof global.maplibregl.getVersion === 'function' ? global.maplibregl.getVersion() : '?';
@@ -530,7 +643,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
530
643
  // crafted link must not be able to load arbitrary code into the page.
531
644
  // ---------------------------------------------------------------
532
645
  // v0.1.6: knows this engine's aliases, lookupfield rule, meta↔style, builder methods, project loading
533
- const VALIDATOR_URL_DEFAULT = 'https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-grammar@v0.1.27/dist/validate.mjs';
646
+ const VALIDATOR_URL_DEFAULT = 'https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-grammar@v0.1.28/dist/validate.mjs';
534
647
  // a page may set ixmaps.validate BEFORE loading this script — the
535
648
  // `global.ixmaps = {...}` export below would otherwise overwrite it
536
649
  const _preloadValidate = global.ixmaps && global.ixmaps.validate;
@@ -658,10 +771,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
658
771
  return ring;
659
772
  }
660
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
+
661
781
  function topoGeometryToGeoJSON(geom, arcs, transform) {
662
782
  switch (geom.type) {
663
- case 'Point': return { type: 'Point', coordinates: geom.coordinates };
664
- 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)) };
665
785
  case 'LineString': return { type: 'LineString', coordinates: ringCoords(geom.arcs, arcs, transform) };
666
786
  case 'MultiLineString': return { type: 'MultiLineString', coordinates: geom.arcs.map(a => ringCoords(a, arcs, transform)) };
667
787
  case 'Polygon': return { type: 'Polygon', coordinates: geom.arcs.map(ring => ringCoords(ring, arcs, transform)) };
@@ -670,8 +790,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
670
790
  }
671
791
  }
672
792
 
673
- function topojsonToFeatureCollection(topo) {
674
- const objName = Object.keys(topo.objects)[0];
793
+ function topojsonToFeatureCollection(topo, objectName) {
794
+ // .data({object: "NAME"}) selects the topojson object to show; without
795
+ // one (or with an unknown name) it stays the file's FIRST object, the
796
+ // previous behaviour
797
+ const objName = (objectName && topo.objects[objectName]) ? objectName : Object.keys(topo.objects)[0];
675
798
  const obj = topo.objects[objName];
676
799
  const geoms = obj.type === 'GeometryCollection' ? obj.geometries : [obj];
677
800
  const features = geoms.map(g => ({
@@ -787,7 +910,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
787
910
  }
788
911
  return false;
789
912
  });
790
- return features.length === fc.features.length ? fc : Object.assign({}, fc, { features });
913
+ const out = features.length === fc.features.length ? fc : Object.assign({}, fc, { features });
914
+ // a {type:"Sphere"} feature is flat's ocean backdrop: not drawn as a shape, the globe's own
915
+ // surface takes its color (see applySphereBackdrop)
916
+ if (fc.features.some(f => f && f.geometry && f.geometry.type === 'Sphere')) Object.defineProperty(out, '_sphere', { value: true, enumerable: false });
917
+ return out;
791
918
  }
792
919
 
793
920
  // CHOROPLETH's own .data() (e.g. the comuni demographics CSV in
@@ -826,7 +953,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
826
953
  function dataSourceKey(data) {
827
954
  const d = data || {};
828
955
  return JSON.stringify({ url: d.url, urls: d.urls, type: d.type, query: d.query,
829
- process: d.process && String(d.process), ext: d.ext, name: d.ext ? d.name : undefined });
956
+ process: d.process && String(d.process), ext: d.ext, name: d.ext ? d.name : undefined,
957
+ // .data({object}) selects a topojson object — two themes on the same
958
+ // URL with different objects must NOT share one cache entry (the
959
+ // DISS page's 5 layers of one file)
960
+ object: d.object });
830
961
  }
831
962
  function dataCacheKey(data, binding) {
832
963
  const b = binding || {};
@@ -926,11 +1057,40 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
926
1057
  // ixmaps-flat dev tree, Themes.prototype.setExternalData). This engine
927
1058
  // has no such pipeline to resume, so the bridge only needs to do the
928
1059
  // ONE thing fetchLayerData actually needs: resolve the promise a
929
- // query()-mode fetch is awaiting. build()'s layer-loading loop is
930
- // strictly sequential (one .data({query}) in flight at a time, see its
931
- // dataCache), so a single pending-resolver slot is enough — not a
932
- // registry keyed by data name like the real engine's.
933
- 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
+ }
934
1094
  // Data by name, as flat (maptheme.js 2570-2593, 8286-8310):
935
1095
  // setExternalData(data, {type, name}) makes a data.js Table of the data
936
1096
  // and keeps it as window[name]; whoever waits for that name gets it, and
@@ -976,6 +1136,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
976
1136
  // globals a data name must not overwrite
977
1137
  const _engineGlobalsGuard = { ixmaps: 1, Data: 1, deck: 1, maplibregl: 1, window: 1, document: 1, $: 1, jQuery: 1 };
978
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
+ }
979
1145
  const name = opt && opt.name;
980
1146
  if (name != null && name !== '') {
981
1147
  (dataObj == null ? Promise.resolve(null) : externalDataToTable(dataObj, opt))
@@ -983,13 +1149,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
983
1149
  .catch(err => { console.error(err); publishNamedData(String(name), null); });
984
1150
  return;
985
1151
  }
986
- if (!_pendingQueryResolve) {
987
- console.warn('[ixmaps-gl] ixmaps.setExternalData called with no pending .data({query}) fetch — ignored');
988
- return;
989
- }
990
- const resolve = _pendingQueryResolve;
991
- _pendingQueryResolve = null;
992
- resolve(dataObj);
1152
+ console.warn('[ixmaps-gl] ixmaps.setExternalData called with no pending .data({query}) fetch — ignored');
993
1153
  }
994
1154
 
995
1155
  // ---------------------------------------------------------------
@@ -1371,7 +1531,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1371
1531
  // genuinely CSV-shaped .obj with a missing binding.position would
1372
1532
  // throw, even though this .obj was never row data in the first place.
1373
1533
  if (dataConfig.obj) {
1374
- if (dataConfig.type === 'topojson') return topojsonToFeatureCollection(dataConfig.obj);
1534
+ if (dataConfig.type === 'topojson') return topojsonToFeatureCollection(dataConfig.obj, dataConfig.object);
1375
1535
  if (dataConfig.type === 'geojson') return sanitizeGeoJSON(dataConfig.obj);
1376
1536
  let obj = dataConfig.obj;
1377
1537
  if (dataConfig.process) {
@@ -1387,7 +1547,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1387
1547
  }
1388
1548
  obj = runDataProcess(obj, dataConfig);
1389
1549
  }
1390
- 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;
1391
1554
  return rowsResult(rows, binding);
1392
1555
  }
1393
1556
 
@@ -1397,9 +1560,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1397
1560
  '<script src="https://cdn.jsdelivr.net/gh/gjrichter/data.js@master/data.js"> before ixmaps-gl.js');
1398
1561
  }
1399
1562
  const queryFn = typeof dataConfig.query === 'function' ? dataConfig.query : new Function(`return (${dataConfig.query});`)();
1400
- const dataObj = await new Promise(resolve => {
1401
- _pendingQueryResolve = resolve;
1402
- 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); }
1403
1570
  });
1404
1571
  return rowsResult((dataConfig.process ? runDataProcess(dataObj, dataConfig) : dataObj).json(), binding);
1405
1572
  }
@@ -1421,8 +1588,21 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1421
1588
  // parser (PapaParse), so the Table arrives via the callback
1422
1589
  const tables = await Promise.all(texts.map(dataJsCsvTable));
1423
1590
  let table = tables[0];
1424
- for (const t of tables.slice(1)) table.records = table.records.concat(t.records);
1425
- 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; }
1426
1606
  if (scriptProcess) table = scriptProcess(table);
1427
1607
  else if (dataConfig.process) table = runDataProcess(table, dataConfig);
1428
1608
  rows = dataTableRows(table);
@@ -1444,7 +1624,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1444
1624
  // them into a table with a geometry column, and back)
1445
1625
  const resp = await fetch(dataConfig.url);
1446
1626
  if (!resp.ok) throw new Error(`[ixmaps-gl] failed to fetch ${dataConfig.url}: ${resp.status}`);
1447
- if (dataConfig.type === 'topojson') return topojsonToFeatureCollection(await resp.json());
1627
+ if (dataConfig.type === 'topojson') return topojsonToFeatureCollection(await resp.json(), dataConfig.object);
1448
1628
  return sanitizeGeoJSON(await resp.json());
1449
1629
  }
1450
1630
  // every other format (json, jsonstat, ndjson, rss, kml, gml, geobuf,
@@ -1549,7 +1729,29 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1549
1729
  // first filter left.
1550
1730
  function filterAndJoin(raw, spec, runtimes, expr = spec.filter) {
1551
1731
  const f = applyWhereFilter(raw, expr);
1552
- return f.type === 'Table' ? joinTableFeatures(spec, f, runtimes) : f;
1732
+ return f.type === 'Table' ? joinTableFeatures(spec, f, runtimes) : chartAnchoredFeatures(f, spec);
1733
+ }
1734
+ // a CHART theme over geojson SHAPES (the TELRAAM page binds
1735
+ // lookup:"geometry" — each chart at its own feature's shape): lines and
1736
+ // polygons carry no [lng, lat] position, so every chart pipeline test
1737
+ // (typeof c[0] === 'number', inViewport, ...) drops them silently and
1738
+ // nothing renders. flat positions such a chart at the shape's center
1739
+ // (its ptPos, flatShapeCenter) — normalize the features to Point at
1740
+ // that anchor up front. FEATURE(S)/CHOROPLETH keep their real geometry,
1741
+ // VECTOR/BEZIER keep their lines (their own renderers)
1742
+ function chartAnchoredFeatures(fc, spec) {
1743
+ const chart = fc && fc.type === 'FeatureCollection' && spec.flags && spec.flags.has('CHART')
1744
+ && !spec.flags.has('FEATURE') && !spec.flags.has('FEATURES')
1745
+ && !spec.flags.has('CHOROPLETH') && !isVectorChart(spec.flags)
1746
+ && fc.features.some(f => f && f.geometry && f.geometry.type !== 'Point');
1747
+ if (!chart) return fc;
1748
+ const features = fc.features.map(f => {
1749
+ const t = f && f.geometry && f.geometry.type;
1750
+ if (!t || t === 'Point') return f;
1751
+ const anchor = flatShapeCenter(f.geometry);
1752
+ return anchor ? { type: 'Feature', geometry: { type: 'Point', coordinates: anchor }, properties: f.properties } : f;
1753
+ });
1754
+ return { type: 'FeatureCollection', features };
1553
1755
  }
1554
1756
  function applyWhereFilter(fc, filterExpr) {
1555
1757
  if (!filterExpr) return fc;
@@ -1658,6 +1860,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1658
1860
  // each layer where its shape is found (maptheme.js 9324, 10376); records
1659
1861
  // with no shape are left out (10387-10392)
1660
1862
  //
1863
+ // A POSITION theme is a layer's id→position PROVIDER: flat loads it, draws
1864
+ // nothing, and only registers its records as themeNodesPosA entries
1865
+ // (maptheme.js 8328-8353 — "don't create SVG objects but only entries in
1866
+ // map.Themes.themeNodesPosA[]"), the same registry getNodePosition reads
1867
+ // — so here it takes part in the join index exactly like a FEATURE layer
1868
+ // of center points, its binding.id against the consumer's lookup key
1869
+ //
1661
1870
  // flat runs the after-aggregation steps after EACH layer's pass, over all
1662
1871
  // items; DIFFERENCE turns a record's value fields into their difference
1663
1872
  // and drops the last, so a second pass leaves the earlier layers' items
@@ -1672,7 +1881,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1672
1881
  const features = [];
1673
1882
  const indexes = layers.map(layer => {
1674
1883
  const index = new Map();
1675
- runtimes.filter(r => r.name === layer && (r.flags.has('FEATURE') || r.flags.has('FEATURES'))).forEach(r => {
1884
+ runtimes.filter(r => r.name === layer && (r.flags.has('FEATURE') || r.flags.has('FEATURES') || r.flags.has('POSITION'))).forEach(r => {
1676
1885
  // in theme order, as flat's layer holds the themes' shapes
1677
1886
  for (const [k, v] of featurePositionIndex(r)) { if (flatShapeWins(index.get(k), v.area)) index.set(k, v); }
1678
1887
  });
@@ -1759,6 +1968,25 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1759
1968
  return Math.min(0.9, o);
1760
1969
  }
1761
1970
 
1971
+ // flat's DOPACITY formula inside the COMPOSECOLOR branch (maptheme.js
1972
+ // 13489-13505): (dopacityscale || 1) · (v/max)^(1/dopacitypow) — no 0.9
1973
+ // cap and no 0.0001 clip here, unlike the DOMINANT branch: flat writes
1974
+ // the raw SVG stroke-/fill-opacity property and the browser clamps at 1
1975
+ function composedDopacityAlpha(style, flags, v, max) {
1976
+ const scale = styleNum(style.dopacityscale) || 1;
1977
+ const pow = 1 / (styleNum(style.dopacitypow) || 1);
1978
+ const o = scale * Math.pow(v, pow) / Math.pow(max, pow);
1979
+ return isFinite(o) ? Math.max(0, Math.min(1, o)) : 0;
1980
+ }
1981
+
1982
+ // a choropleth shape's outline color: a LINE shape strokes with its value
1983
+ // color; any other shape keeps the theme's outline, hidden while a class
1984
+ // is isolated and the shape is not in it
1985
+ function choroplethOutlineColor(isLine, valueColorOf, d, isolating, unmarkedEvidence, lineRgb) {
1986
+ if (isLine) return valueColorOf(d);
1987
+ return isolating && unmarkedEvidence(d) === 'hide' ? [0, 0, 0, 0] : lineRgb;
1988
+ }
1989
+
1762
1990
  function joinChoroplethFeatures(spec, table, runtimes) {
1763
1991
  // a page can define several FEATURE layers of one name — e.g. the
1764
1992
  // comuni as polygons and as center points (for chart positions); flat
@@ -1795,21 +2023,25 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1795
2023
  if (isAggregatedCategoricalChoropleth(spec)) {
1796
2024
  const groups = new Map();
1797
2025
  for (const row of table.rows) {
1798
- const key = String(row[lookupField]);
2026
+ if (row[lookupField] == null) continue;
2027
+ const key = flatLookupKey(row[lookupField], spec.style);
1799
2028
  if (!groups.has(key)) groups.set(key, []);
1800
2029
  groups.get(key).push(row);
1801
2030
  }
1802
2031
  return {
1803
2032
  type: 'FeatureCollection',
1804
2033
  features: geomRt.features.map(f => {
1805
- const rows = groups.get(String(f.properties[idField]));
2034
+ const rows = f.properties[idField] == null ? null : groups.get(String(f.properties[idField]));
1806
2035
  const properties = rows ? Object.assign({}, rows[0]) : {};
1807
2036
  if (rows) Object.defineProperty(properties, AGGREGATED_ROWS, { value: rows });
1808
2037
  return { type: 'Feature', geometry: f.geometry, properties, _flatAreaKm2: flatShapeAreaKm2(geomRt, f) };
1809
2038
  })
1810
2039
  };
1811
2040
  }
1812
- 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]));
1813
2045
  // Unmatched polygons (no CSV row for that id) keep their geometry with
1814
2046
  // empty properties — real-world data: not every comune necessarily has
1815
2047
  // a row in every demographic source. Renders as "no data" (see
@@ -1818,7 +2050,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1818
2050
  const features = geomRt.features.map(f => ({
1819
2051
  type: 'Feature',
1820
2052
  geometry: f.geometry,
1821
- properties: rowsByKey.get(String(f.properties[idField])) || {},
2053
+ properties: (f.properties[idField] != null && rowsByKey.get(String(f.properties[idField]))) || {},
1822
2054
  _flatAreaKm2: flatShapeAreaKm2(geomRt, f)
1823
2055
  }));
1824
2056
  return { type: 'FeatureCollection', features };
@@ -2091,6 +2323,36 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2091
2323
  // colorscheme (isGeneratedColorScheme above), never as the step count.
2092
2324
  function resolveColorScheme(colorscheme, labels, classes) {
2093
2325
  if (!colorscheme) return labels.map((_, i) => FALLBACK_PALETTE[i % FALLBACK_PALETTE.length]);
2326
+ // flat's custom per-value color function (maptheme.js 12677-12705):
2327
+ // colorscheme: ["byvalue", function(value){ return color; }] — every
2328
+ // known category value gets its color from the function and the
2329
+ // result REPLACES the whole scheme (so neither classes nor the
2330
+ // generated-sweep machinery below ever see it, as in flat). A
2331
+ // function that throws, or one a non-categorical theme hands no
2332
+ // values to iterate, falls back to the default palette — exactly
2333
+ // flat's own two fallbacks. The function may also arrive serialized
2334
+ // (a project-JSON string), like the whole-scheme string function
2335
+ // below evaluates one.
2336
+ if (Array.isArray(colorscheme) && colorscheme.length > 1 && colorscheme[0] === 'byvalue') {
2337
+ let fnByValue = null;
2338
+ if (typeof colorscheme[1] === 'function') fnByValue = colorscheme[1];
2339
+ else if (typeof colorscheme[1] === 'string') {
2340
+ try {
2341
+ const f = new Function('return (' + colorscheme[1] + ')')();
2342
+ if (typeof f === 'function') fnByValue = f;
2343
+ } catch (err) { /* not code */ }
2344
+ }
2345
+ if (fnByValue) {
2346
+ try {
2347
+ const byValueA = labels.map(l => fnByValue(l));
2348
+ if (byValueA.length) return byValueA;
2349
+ console.warn('[ixmaps-gl] colorscheme "byvalue" function needs a categorical theme with known values — using the fallback palette');
2350
+ } catch (err) {
2351
+ console.warn('[ixmaps-gl] colorscheme "byvalue" function threw "' + err + '" — using the fallback palette');
2352
+ }
2353
+ }
2354
+ return labels.map((_, i) => FALLBACK_PALETTE[i % FALLBACK_PALETTE.length]);
2355
+ }
2094
2356
  colorscheme = applyClassesToColorScheme(colorscheme, classes);
2095
2357
  if (isGeneratedColorScheme(colorscheme)) {
2096
2358
  const [, cc1, cc2, nParam1, nParam2] = colorscheme;
@@ -2196,15 +2458,22 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2196
2458
  // mean — see the NOOUTLIER block in _buildChartLayers) has real
2197
2459
  // behavior now; bare OUTLIER (the real source's "keep ONLY the
2198
2460
  // outliers" inverse mode) stays unimplemented — no config here uses it.
2199
- const KNOWN_INERT_FLAGS = ['SEQUENCE', 'STAR', 'SORT', 'DOWN', 'RECT', 'CLIPTOGEOBOUNDS', 'OUTLIER',
2461
+ // SEQUENCE/STAR/SORT/DOWN/RECT are implemented now (the sequence
2462
+ // symbol chart, sequenceLayout + snapToAggregationGrid) — removed from
2463
+ // this list; the rest is still recognized-but-inert
2464
+ const KNOWN_INERT_FLAGS = ['CLIPTOGEOBOUNDS', 'OUTLIER',
2200
2465
  'POW2', 'POW3', 'NOSORT'];
2201
- // flat's PIE / DONUT variants with no rendering here (see _buildPieLayers)
2202
- 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/];
2203
2471
  // ... and of AGGREGATE / CATEGORICAL pies: GRIDSIZE's pie the size of its
2204
- // grid cell (drawn by the SIZE rule instead), AUTOSIZE, DOPACITY, the
2205
- // chart BOX and TITLE, GAUGE, MULTIPLE / DIFFUSE, the post-aggregation
2206
- // value arithmetic (DIFFERENCE, FRACTION, PERCENT …, field100)
2207
- 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$/,
2208
2477
  /^DIFFERENCE$/, /^FRACTION$/, /^PERCENT$/, /^PERMILLE$/, /^PRODUCT$/, /^CALCVAL$/, /^RELATIVE$/];
2209
2478
  // flat's BAR / BARS variants with no rendering here (see flatBarLayout):
2210
2479
  // the bar is drawn plain (SMALLARROW as ARROW) — tested as substrings,
@@ -2214,11 +2483,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2214
2483
  /^DIFFERENCE$/, /^FRACTION$/, /^PERCENT$/, /^PERMILLE$/, /^PRODUCT$/, /^CALCVAL$/, /^RELATIVE$/];
2215
2484
  // flat's VECTOR / BEZIER modifiers with no rendering here (see
2216
2485
  // _buildVectorLayers), the word → what happens instead
2486
+ // (DASH IS drawn — the animated flow, see makeFlowPathLayer)
2217
2487
  const VECTOR_INERT_FLAGS = {
2218
2488
  VALUES: 'the value text along the curve is not drawn yet',
2219
2489
  DYNAMICWIDTH: 'not drawn yet (flat widens by the log of the length in its own map units) — the plain width instead',
2220
- LONG: 'not drawn yet (its bow depends on flat\'s own map units) — the default bow instead',
2221
- 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'
2222
2491
  };
2223
2492
  const _warnedFlags = new Set();
2224
2493
 
@@ -2263,6 +2532,18 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2263
2532
  // makes while the map is still building wait for it
2264
2533
  let _resolveMapReady;
2265
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
+ }
2266
2547
 
2267
2548
  // flat's ixmaps.require(url) fetches the script and evals it
2268
2549
  // (htmlgui.js 3911-3934); here each one runs globally, in order, before
@@ -2382,7 +2663,16 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2382
2663
  // key wins. Always into a new object — the page's own objects are never
2383
2664
  // mutated. Aliases (geo → position, ...) are resolved in normalizeTheme.
2384
2665
  binding(b) {
2385
- 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);
2386
2676
  return this;
2387
2677
  }
2388
2678
  filter(expr) { this._filterExpr = expr; return this; }
@@ -2477,6 +2767,27 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2477
2767
  return construct;
2478
2768
  }
2479
2769
  const unwrapLayer = x => (typeof x === 'function' && x.__ixLayer) ? x.__ixLayer : x;
2770
+ // flat's layer builder finalizers — definition(), define() AND json()
2771
+ // (htmlgui_flat.js 2056-2083) — all return the plain DEFINITION object,
2772
+ // so a real page's .layer(ixmaps.layer("x").data()...json()) hands
2773
+ // map.layer() that object rather than the builder. Wrap it back into a
2774
+ // LayerBuilder whose definition() reproduces it (the def's own slots;
2775
+ // its type/filter/title live INSIDE style, where definition() put them)
2776
+ function asLayerBuilder(x) {
2777
+ const lb = unwrapLayer(x);
2778
+ if (lb instanceof LayerBuilder) return lb;
2779
+ if (lb && typeof lb === 'object' && (lb.layer || lb.data || lb.style)) {
2780
+ const b = new LayerBuilder(lb.layer || 'layer');
2781
+ b._data = lb.data || null;
2782
+ b._binding = lb.binding || {};
2783
+ b._style = lb.style || {};
2784
+ b._meta = lb.meta || {};
2785
+ if (lb.field != null) b._field = lb.field;
2786
+ if (lb.field100 != null) b._field100 = lb.field100;
2787
+ return b;
2788
+ }
2789
+ return lb;
2790
+ }
2480
2791
 
2481
2792
  // ---------------------------------------------------------------
2482
2793
  // Binding aliases. Real ixmaps-flat accepts ~50 .binding() keys and maps
@@ -2487,7 +2798,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2487
2798
  // edit it by hand; `npm run unit` fails when it drifts.
2488
2799
  // ---------------------------------------------------------------
2489
2800
  // <grammar:binding-aliases>
2490
- // generated from ixmaps-grammar 0.1.27 (ixmaps-flat 1.0.42, 2b978d5 2026-10-02) — 52 aliases
2801
+ // generated from ixmaps-grammar 0.1.28 (ixmaps-flat 1.0.42, 2b978d5 2026-10-02) — 52 aliases
2491
2802
  const FLAT_BINDING_ALIASES = {
2492
2803
  "aggregation": "style.aggregationfield",
2493
2804
  "aggregationfield": "style.aggregationfield",
@@ -2549,7 +2860,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2549
2860
  // both — so .style({tooltip}) is a tooltip and .meta({fillopacity}) a style
2550
2861
  // property. GENERATED by test/sync-grammar.mjs — do not edit.
2551
2862
  // <grammar:meta-keys>
2552
- // generated from ixmaps-grammar 0.1.27 — 5 meta keys
2863
+ // generated from ixmaps-grammar 0.1.28 — 5 meta keys
2553
2864
  const FLAT_META_KEYS = ["description","name","snippet","title","tooltip"];
2554
2865
  // </grammar:meta-keys>
2555
2866
 
@@ -2651,27 +2962,75 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2651
2962
  // that kind: the data processing function (data.process /
2652
2963
  // style.dbtableProcess), the broker query (data.query /
2653
2964
  // style.dbtableQuery — without it a data.name still reaches a
2654
- // provider the PAGE defined as ixmaps[name]) and a colorscheme given
2655
- // as a string, which resolveColorScheme evaluates as a function
2656
- // (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');
2657
2971
  const PROJECT_CODE_KEYS = [
2658
2972
  ['data', 'process', 'data.process function'],
2659
2973
  ['style', 'dbtableProcess', 'data.process function'],
2660
2974
  ['data', 'query', 'data.query function'],
2661
2975
  ['style', 'dbtableQuery', 'data.query function'],
2662
- ['style', 'colorscheme', 'colorscheme function', v => typeof v === 'string'],
2663
- ];
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
+
2664
3010
  function withoutProjectCode(t, report) {
2665
3011
  const found = PROJECT_CODE_KEYS.filter(([part, key, , isCode]) =>
2666
3012
  t[part] && t[part][key] != null && (!isCode || isCode(t[part][key])));
2667
- 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;
2668
3023
  const copy = Object.assign({}, t);
2669
- found.forEach(([part, key]) => {
2670
- if (copy[part] === t[part]) copy[part] = Object.assign({}, t[part]);
2671
- delete copy[part][key];
2672
- });
2673
- const what = [...new Set(found.map(f => f[2]))].join(', ');
2674
- 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
+ }
2675
3034
  return copy;
2676
3035
  }
2677
3036
 
@@ -2905,6 +3264,15 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2905
3264
  }
2906
3265
 
2907
3266
  typeStyleNumbers(style);
3267
+ // flat's own fill-opacity fallback (maptheme.js 13406:
3268
+ // `this.fillOpacity = this.fillOpacity ? this.fillOpacity : this.nOpacity`)
3269
+ // — an "opacity" style is the fill opacity whenever "fillopacity" is
3270
+ // not set, as flat realizes every theme
3271
+ // (Number("") and Number(null) are 0: an empty opacity must not hide the theme)
3272
+ if (!styleNum(style.fillopacity) && style.opacity != null && String(style.opacity).trim() !== ''
3273
+ && Number.isFinite(Number(style.opacity))) {
3274
+ style.fillopacity = Number(style.opacity);
3275
+ }
2908
3276
  return { name: def.layer, data: def.data, binding, targets, geometry, flags, typeStr, style, meta, filter };
2909
3277
  }
2910
3278
 
@@ -3061,7 +3429,58 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3061
3429
  light: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
3062
3430
  dark: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
3063
3431
  };
3432
+ // flat's own raster-tile basemaps (htmlgui_sync_Leaflet.js __addTileLayer
3433
+ // list, ArcGIS block): a mapType NAME flat resolves to real raster tiles
3434
+ // resolves to the SAME tiles here, as a built MapLibre raster style —
3435
+ // "ArcGIS - Topo" was falling through to CARTO Positron before, a
3436
+ // non-topographic look nothing like flat's. Styles are built once per
3437
+ // name, so the same name is the SAME style object and setMapType's
3438
+ // "same style: nothing to reload" identity test keeps working.
3439
+ const ESRI_ATTRIBUTION = 'Tiles &copy; Esri &mdash; Esri, DeLorme, NAVTEQ, TomTom, Intermap, iPC, USGS, FAO, NPS, NRCAN, GeoBase, Kadaster NL, Ordnance Survey, Esri Japan, METI, Esri China (Hong Kong), and the GIS User Community';
3440
+ const FLAT_RASTER_BASEMAPS = [
3441
+ { name: 'ArcGIS - Topo', aliases: ['terrain'], maxzoom: 19,
3442
+ tiles: 'https://services.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}.png' },
3443
+ { name: 'ArcGIS - Light Gray Base', maxzoom: 19,
3444
+ tiles: 'https://services.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}.png' },
3445
+ { name: 'ArcGIS - Ocean Basemap', maxzoom: 13,
3446
+ tiles: 'https://services.arcgisonline.com/ArcGIS/rest/services/Ocean_Basemap/MapServer/tile/{z}/{y}/{x}.png' },
3447
+ { name: 'ArcGIS - Hillshade', maxzoom: 16,
3448
+ tiles: 'https://server.arcgisonline.com/arcgis/rest/services/Elevation/World_Hillshade/MapServer/tile/{z}/{y}/{x}.png' },
3449
+ ];
3450
+ const _rasterBasemapStyles = new Map();
3451
+ function buildRasterBasemapStyle(entry) {
3452
+ if (_rasterBasemapStyles.has(entry.name)) return _rasterBasemapStyles.get(entry.name);
3453
+ const sourceId = 'ixmaps-raster-basemap';
3454
+ const style = {
3455
+ version: 8,
3456
+ sources: {
3457
+ [sourceId]: {
3458
+ type: 'raster',
3459
+ tiles: [entry.tiles],
3460
+ tileSize: 256,
3461
+ maxzoom: entry.maxzoom,
3462
+ attribution: ESRI_ATTRIBUTION
3463
+ }
3464
+ },
3465
+ // raster-opacity (applyBasemapOpacity's raster entry) fades this
3466
+ // layer like any other basemap
3467
+ layers: [{ id: sourceId, type: 'raster', source: sourceId, paint: {} }]
3468
+ };
3469
+ _rasterBasemapStyles.set(entry.name, style);
3470
+ return style;
3471
+ }
3472
+ // flat's mapTypeTranslate ("terrain" → "ArcGIS - Topo", ...) applies to
3473
+ // the raster names too
3474
+ function flatRasterBasemap(mapType) {
3475
+ if (typeof mapType !== 'string') return null;
3476
+ const t = mapType.trim();
3477
+ for (const entry of FLAT_RASTER_BASEMAPS) {
3478
+ if (t === entry.name || (entry.aliases || []).includes(t)) return entry;
3479
+ }
3480
+ }
3064
3481
  function resolveBasemapStyleUrl(mapType) {
3482
+ const raster = flatRasterBasemap(mapType);
3483
+ if (raster) return buildRasterBasemapStyle(raster);
3065
3484
  return typeof mapType === 'string' && /dark|black|night|matter/i.test(mapType) ? BASEMAP_STYLE_URLS.dark : BASEMAP_STYLE_URLS.light;
3066
3485
  }
3067
3486
  // Real ixmaps-flat's legend look (tools/legend.js 3773-3845, via
@@ -3170,6 +3589,32 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3170
3589
  return false;
3171
3590
  }
3172
3591
 
3592
+ // The ocean of flat's world / orthographic maps is a {type:"Sphere"} feature: the whole map disc,
3593
+ // filled with the layer's color. Here it is the surface of the map (the globe, or the flat
3594
+ // map): a background layer under everything, in that color and opacity.
3595
+ const SPHERE_BACKDROP_LAYER_ID = '__ixmaps_gl_sphere_backdrop';
3596
+ function applySphereBackdrop(map, style) {
3597
+ const cs = style && style.colorscheme;
3598
+ const color = Array.isArray(cs) ? cs[0] : cs;
3599
+ if (!color || color === 'none') return;
3600
+ const opacity = Number.isFinite(styleNum(style.fillopacity)) ? styleNum(style.fillopacity) : 1;
3601
+ const paint = () => {
3602
+ try {
3603
+ const id = map.getLayer(BLANK_BACKGROUND_LAYER_ID) ? BLANK_BACKGROUND_LAYER_ID : SPHERE_BACKDROP_LAYER_ID;
3604
+ if (id === SPHERE_BACKDROP_LAYER_ID && !map.getLayer(id)) {
3605
+ const first = (map.getStyle().layers || [])[0];
3606
+ map.addLayer({ id, type: 'background', paint: {} }, first && first.id);
3607
+ }
3608
+ map.setPaintProperty(id, 'background-color', color);
3609
+ map.setPaintProperty(id, 'background-opacity', opacity);
3610
+ } catch (e) { console.warn('[ixmaps-gl] the {type:"Sphere"} ocean backdrop could not be set:', e); }
3611
+ };
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);
3616
+ }
3617
+
3173
3618
  function buildBlankBackgroundStyle(color) {
3174
3619
  return {
3175
3620
  version: 8,
@@ -3316,7 +3761,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3316
3761
  // visible (an element larger than the viewport ends up at its edge)
3317
3762
  function keepTooltipVisible(tipEl, x, y) {
3318
3763
  const w = tipEl.offsetWidth, h = tipEl.offsetHeight;
3319
- 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;
3320
3768
  let tx = x > vw / 2 ? x - w - 30 : x + 30;
3321
3769
  let ty = y > vh / 2 ? y - h - 20 : y + 20;
3322
3770
  tx = Math.min(Math.max(8, tx), Math.max(8, vw - w - 8));
@@ -3659,6 +4107,14 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3659
4107
  // explicit request (real engine's own fallback is right-anchored
3660
4108
  // too, just without an opinion on vertical placement).
3661
4109
  const legendAlignCss = legendAlignCssFor(builder.mapOptions);
4110
+ // flat's align:"center"/"top" legend realizing (htmlgui_flat.js
4111
+ // 445-480, legend.css 516-550): not a floating corner panel but a
4112
+ // full-width bar over the map's top edge — width 100% capped at
4113
+ // legendMaxWidth (1200px), legendBackground (default
4114
+ // rgba(255,255,255,0.9)), rounded BOTTOM corners only, the title
4115
+ // centered above, the class chips inline-block side by side and
4116
+ // the chart-size slider inline in that same row
4117
+ const legendCenter = /^(center|top)$/i.test(String(builder.mapOptions.align || '').trim());
3662
4118
  // Real ixmaps-flat's SUB-THEME (MapTheme.createSubTheme,
3663
4119
  // maptheme.js 14284-14292 / 15010-15160; map.Themes.enableSubThemes
3664
4120
  // defaults to true): marking fields of a multi-field choropleth
@@ -3728,7 +4184,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3728
4184
  if (legendStack && legendStack.parentNode) return legendStack;
3729
4185
  legendStack = document.createElement('div');
3730
4186
  legendStack.className = 'ix-legend-stack';
3731
- legendStack.style.cssText = 'position:absolute;' + legendAlignCss + 'z-index:6;width:280px;'
4187
+ legendStack.style.cssText = legendCenter
4188
+ // flat's center legend: the full-width bar, the panel carries
4189
+ // the background (no stack-level radius or shadow)
4190
+ ? 'position:absolute;left:50%;transform:translateX(-50%);top:0;z-index:6;width:100%;max-width:1200px;'
4191
+ + 'display:flex;flex-direction:column;pointer-events:none;'
4192
+ : 'position:absolute;' + legendAlignCss + 'z-index:6;width:280px;'
3732
4193
  + 'display:flex;flex-direction:column;max-height:66.6%;overflow-y:auto;'
3733
4194
  + 'border-radius:6px;box-shadow:' + shadow + ';pointer-events:auto;';
3734
4195
  if (!document.getElementById('ix-legend-stack-css')) {
@@ -3838,7 +4299,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3838
4299
  const looksDark = typeof builder.__effectiveDark === 'function'
3839
4300
  ? builder.__effectiveDark(isNaN(opacityOpt) ? 1 : opacityOpt, builder.mapOptions.mapType, legendBackground)
3840
4301
  : flatLook.dark;
3841
- const isLightLegend = rt.style.legendtheme === 'light' || (rt.style.legendtheme !== 'dark' && !looksDark);
4302
+ const isLightLegend = legendCenter || rt.style.legendtheme === 'light' || (rt.style.legendtheme !== 'dark' && !looksDark);
3842
4303
  const colors = isLightLegend
3843
4304
  ? { bg: 'rgba(255,255,255,0.92)', fg: '#1a1a1a', shadow: '0 2px 10px rgba(0,0,0,0.18)',
3844
4305
  rowMarked: 'rgba(0,0,0,0.08)', track: 'rgba(0,0,0,0.08)',
@@ -3849,7 +4310,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3849
4310
  // the panel bg follows the same effective decision (flatLegendLook
3850
4311
  // would hand the dark look a light bg here: its dark branch never
3851
4312
  // ran for this map, its light bg would clash with dark rows/text)
3852
- if (!rt.style.legendtheme) colors.bg = looksDark ? '#111' : flatLook.bg;
4313
+ if (!rt.style.legendtheme && !legendCenter) colors.bg = looksDark ? '#111' : flatLook.bg;
3853
4314
  return colors;
3854
4315
  };
3855
4316
  const legendColors = lookColors();
@@ -3867,7 +4328,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3867
4328
  // one box for every theme, as flat's #map-legend (legend.js
3868
4329
  // 2200-2460): the panels stack in the legend stack in theme
3869
4330
  // order, a line between them (see layoutLegends)
3870
- panel.style.cssText = 'position:relative;flex:0 1 auto;min-height:0;overflow:hidden;'
4331
+ panel.style.cssText = legendCenter
4332
+ // flat's center legend bar: full width, centered text, the
4333
+ // light legendBackground, rounded bottom corners only
4334
+ ? 'position:relative;display:flex;flex-direction:column;text-align:center;'
4335
+ + 'background:' + legendColors.bg + ';color:' + legendColors.fg + ';font:12px/1.4 -apple-system,Arial,sans-serif;'
4336
+ + 'padding:6px 12px 10px;pointer-events:auto;border-radius:0 0 6px 6px;'
4337
+ : 'position:relative;flex:0 1 auto;min-height:0;overflow:hidden;'
3871
4338
  + 'display:flex;flex-direction:column;text-align:left;'
3872
4339
  + 'background:' + legendColors.bg + ';color:' + legendColors.fg + ';font:12px/1.4 -apple-system,Arial,sans-serif;'
3873
4340
  + 'padding:10px 12px 12px;pointer-events:auto;';
@@ -3897,13 +4364,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3897
4364
  // twin pages)
3898
4365
  const flatUnitLegend = rt._isVectorChart() || rt._isAggregatedPie();
3899
4366
  const rawLegendUnit = rt.style.legendunits || rt.style.units || '';
3900
- 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;
3901
4372
  // a per-item chart over several value fields: the row values as
3902
4373
  // flat's legend prints them (legend.js 610, 810: __formatValue
3903
4374
  // with valuedecimals, else 2, "BLANK" — whole numbers without
3904
4375
  // decimals), a colorscheme "none" part as an outline in linecolor
3905
4376
  const multiFieldLegend = rt._isMultiFieldChart();
3906
- const legendTotalText = v => ((multiFieldLegend || flatUnitLegend)
4377
+ const legendTotalText = v => ((multiFieldLegend || flatUnitLegend || countLegend)
3907
4378
  ? flatFormatValue(v, legendDecimals(rt.style), 'BLANK')
3908
4379
  : rt._formatTooltipValue(v));
3909
4380
  const outlineRgb = rt.style.linecolor && styleLineColor(rt.style.linecolor) !== 'none' ? hexOrNamedToRgb(styleLineColor(rt.style.linecolor)) : [128, 128, 128];
@@ -3937,6 +4408,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3937
4408
  // branch uses (no antimeridian wraparound handling — matches
3938
4409
  // that existing convention, not a new gap this introduces).
3939
4410
  let totals, maxTotal, order, rowsScroll;
4411
+ // hoisted so the chart-size slider below can join the chips row
4412
+ // in flat's center legend (legend-top-align's inline slider)
4413
+ let rowsEl = null;
3940
4414
  // No-op default: TEXTLEGEND never reassigns this (see below),
3941
4415
  // so the LATER unconditional-looking `renderRows()` call
3942
4416
  // safely does nothing rather than needing its own isTextOnly
@@ -3993,8 +4467,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3993
4467
  // share, then overflow-y:auto scrolls just the row list) —
3994
4468
  // the header above and description/slider below stay put.
3995
4469
  rowsScroll = document.createElement('div');
3996
- rowsScroll.style.cssText = 'flex:1 1 auto;min-height:0;overflow-y:auto;';
3997
- const rowsEl = document.createElement('div');
4470
+ rowsScroll.style.cssText = legendCenter
4471
+ // flat's center bar: one wrapped, centered row holding the
4472
+ // chips AND the inline chart-size slider (legend.css 521-550)
4473
+ ? 'flex:0 1 auto;display:flex;flex-wrap:wrap;justify-content:center;align-items:center;'
4474
+ : 'flex:1 1 auto;min-height:0;overflow-y:auto;';
4475
+ rowsEl = document.createElement('div');
3998
4476
  rowsEl.className = 'ix-legend-rows';
3999
4477
  // .type("...|SIMPLELEGEND") — real ixmaps-flat's OTHER legend
4000
4478
  // variant (confirmed against a real-engine screenshot, per
@@ -4019,7 +4497,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4019
4497
  // smaller categories packed 3-4 per row) rather than a fixed
4020
4498
  // column count.
4021
4499
  if (rt.flags.has('SIMPLELEGEND') || rt.flags.has('COMPACTLEGEND')) {
4022
- rowsEl.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;';
4500
+ // centered: inline-flex so the chips shrink to content inside
4501
+ // the wrapping centered row (the slider joins as a sibling)
4502
+ rowsEl.style.cssText = legendCenter ? 'display:inline-flex;flex-wrap:wrap;gap:4px;'
4503
+ : 'display:flex;flex-wrap:wrap;gap:4px;';
4023
4504
  }
4024
4505
  rowsScroll.appendChild(rowsEl);
4025
4506
 
@@ -4196,7 +4677,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4196
4677
  // same 400ms as the facets sidebar's own onRedraw use), same
4197
4678
  // signal already used elsewhere in this file for "the visible
4198
4679
  // data just changed, recompute".
4199
- 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
+ });
4200
4687
  }
4201
4688
 
4202
4689
  // rt._triggerRedraw: see its own doc comment on LayerRuntime
@@ -4329,6 +4816,101 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4329
4816
  bodyEl.appendChild(desc);
4330
4817
  }
4331
4818
 
4819
+ // flat's TIME SLIDER (legend.js 2488-2530, the szTimeField
4820
+ // branch of makeColorLegendHTMLLong): a theme with a timefield
4821
+ // (style.timefield, resolved into binding.time) gets a slider
4822
+ // over its records' time span — at the span's start every
4823
+ // record shows (setTimeFrame's own start-of-frame rule),
4824
+ // elsewhere a [value, value+range) window. The window size is
4825
+ // flat's own default (a day; 28 days for a span over 120 days,
4826
+ // an hour under 7 — legend.js 2722-2729) and the range buttons
4827
+ // below (hour/day/week/month/year, only the ones the span
4828
+ // supports, and only when there's more than one — legend.js
4829
+ // 2512-2543) replace it, as flat's ixmaps.setSliderRange.
4830
+ if (rt.binding && rt.binding.time) {
4831
+ const timeField = rt.binding.time;
4832
+ const toMs = v => (typeof v === 'number' ? v : new Date(v).getTime());
4833
+ let uMin = Infinity, uMax = -Infinity;
4834
+ rt.features.forEach(f => {
4835
+ const t = toMs((f.properties || {})[timeField]);
4836
+ if (!isNaN(t)) { if (t < uMin) uMin = t; if (t > uMax) uMax = t; }
4837
+ });
4838
+ // one step guard: no span (no/broken time values) — no slider
4839
+ if (Number.isFinite(uMin) && Number.isFinite(uMax) && uMax > uMin) {
4840
+ const uDay = 1000 * 60 * 60 * 24;
4841
+ const days = (uMax - uMin) / uDay;
4842
+ let range = days > 120 ? uDay * 28 : days < 7 ? 1000 * 60 * 60 : uDay;
4843
+ const timeRow = document.createElement('div');
4844
+ timeRow.style.cssText = 'margin-top:10px;font-size:11px;opacity:0.8;';
4845
+ // textContent: the field name comes from the page / project JSON
4846
+ const timeLabel = document.createElement('span');
4847
+ timeLabel.style.cssText = 'font-weight:600;';
4848
+ timeLabel.textContent = String(rt.style.timefield || timeField);
4849
+ const timeSpan = document.createElement('span');
4850
+ timeSpan.className = 'ix-legend-time-span';
4851
+ timeRow.append(timeLabel, ': ', timeSpan);
4852
+ bodyEl.appendChild(timeRow);
4853
+ const timeSlider = document.createElement('input');
4854
+ timeSlider.type = 'range';
4855
+ timeSlider.min = String(uMin);
4856
+ timeSlider.max = String(uMax);
4857
+ timeSlider.value = String(uMin);
4858
+ timeSlider.style.cssText = 'width:100%;margin-top:0.4em;'; // flat's own slider width (legend.js 2503)
4859
+ timeRow.appendChild(timeSlider);
4860
+ const spanEl = () => timeRow.querySelector('.ix-legend-time-span');
4861
+ const applyTimeFrame = () => {
4862
+ const v = Number(timeSlider.value);
4863
+ if (v <= uMin) {
4864
+ // flat: slider at its start position = every record
4865
+ rt.setTimeFrame(null, null);
4866
+ spanEl().textContent = '';
4867
+ } else {
4868
+ rt.setTimeFrame(v, v + range);
4869
+ // flat's span text (legend.js 2737-2745): date - date
4870
+ // for a day-and-up window, date-time under it
4871
+ spanEl().textContent = range < uDay
4872
+ ? new Date(v).toLocaleDateString() + ' ' + new Date(v).toLocaleTimeString()
4873
+ : new Date(v).toLocaleDateString() + ' - ' + new Date(v + range).toLocaleDateString();
4874
+ }
4875
+ // a discrete control, not a pan/zoom stream: full refresh,
4876
+ // so the rows/totals (onRedraw → computeTotals) follow live
4877
+ refresh();
4878
+ };
4879
+ timeSlider.addEventListener('input', applyTimeFrame);
4880
+ const rangeChoices = [
4881
+ ['hour', 1000 * 60 * 60, days < 2],
4882
+ ['day', uDay, days > 1],
4883
+ ['week', uDay * 7, days > 13],
4884
+ ['month', uDay * 28, days > 55],
4885
+ ['year', uDay * 356, days > 365]
4886
+ ].filter(c => c[2]);
4887
+ if (rangeChoices.length > 1) {
4888
+ const btnRow = document.createElement('div');
4889
+ btnRow.style.cssText = 'display:flex;gap:4px;margin-top:4px;';
4890
+ rangeChoices.forEach(([name, ms]) => {
4891
+ const b = document.createElement('button');
4892
+ b.type = 'button';
4893
+ b.textContent = name;
4894
+ const paint = active => {
4895
+ b.style.cssText = 'background:transparent;border:1px solid '
4896
+ + (active ? 'currentColor' : 'rgba(128,128,128,0.5)')
4897
+ + ';color:inherit;font:inherit;font-size:10px;padding:1px 7px;'
4898
+ + 'border-radius:4px;cursor:pointer;opacity:' + (active ? 1 : 0.55) + ';'
4899
+ + (active ? 'font-weight:600;' : '');
4900
+ };
4901
+ paint(ms === range);
4902
+ b.addEventListener('click', () => {
4903
+ range = ms;
4904
+ [...btnRow.children].forEach(x => paint(x === b));
4905
+ applyTimeFrame();
4906
+ });
4907
+ btnRow.appendChild(b);
4908
+ });
4909
+ timeRow.appendChild(btnRow);
4910
+ }
4911
+ }
4912
+ }
4913
+
4332
4914
  // Chart-size slider — real engine's own range (legend.js:
4333
4915
  // 3196-3202), 100% == scale:1, wired to changeThemeStyle(
4334
4916
  // themeId,"scale:"+pct/100,"set")+redrawTheme there; this
@@ -4353,20 +4935,30 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4353
4935
  : Math.round((styleNum(rt.style.scale) || 1) * 100);
4354
4936
  const sliderLabel = isOpacitySlider ? 'Opacity' : 'Chart size';
4355
4937
  sliderRow.innerHTML = sliderLabel + ': <span class="ix-legend-scale-val">' + initialPct + '</span>%';
4356
- bodyEl.appendChild(sliderRow);
4938
+ // flat's center legend: the slider is INLINE in the chips row
4939
+ // (legend.css 541-550: inline-block, one item width, no wrap,
4940
+ // its input 80px) — a sibling of the chips container so the
4941
+ // row re-render can't wipe it
4942
+ const sliderHost = legendCenter && rowsScroll ? rowsScroll : bodyEl;
4943
+ sliderRow.style.cssText = legendCenter && rowsScroll
4944
+ ? 'display:inline-block;margin:0 0 0.5em 1.5em;font-size:11px;opacity:0.8;white-space:nowrap;'
4945
+ : sliderRow.style.cssText;
4946
+ sliderHost.appendChild(sliderRow);
4357
4947
  const slider = document.createElement('input');
4358
4948
  slider.type = 'range';
4359
4949
  slider.min = isOpacitySlider ? '0' : '25';
4360
4950
  slider.max = isOpacitySlider ? '100' : '200';
4361
4951
  slider.value = String(initialPct);
4362
- slider.style.cssText = 'width:50%;margin-top:0.4em;'; // flat's own inline slider style (legend.js 2392)
4952
+ slider.style.cssText = legendCenter && rowsScroll
4953
+ ? 'width:80px;vertical-align:middle;margin:0 0 0 0.5em;' // flat's inline slider width (legend.css 548)
4954
+ : 'width:50%;margin-top:0.4em;'; // flat's own inline slider style (legend.js 2392)
4363
4955
  slider.addEventListener('input', () => {
4364
4956
  const pct = parseInt(slider.value, 10);
4365
4957
  sliderRow.querySelector('.ix-legend-scale-val').textContent = pct;
4366
4958
  rt.setStyle(isOpacitySlider ? { fillopacity: pct / 100 } : { scale: pct / 100 });
4367
4959
  quietRefresh();
4368
4960
  });
4369
- bodyEl.appendChild(slider);
4961
+ sliderHost.appendChild(slider);
4370
4962
  }
4371
4963
  }
4372
4964
  };
@@ -4406,7 +4998,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4406
4998
  }
4407
4999
  return this;
4408
5000
  }
4409
- 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; }
4410
5005
  local(...args) { this._locals.push(args); return this; }
4411
5006
  // flat's .require(url) (htmlgui.js ixmaps.require): a script the page
4412
5007
  // needs (a chart library, a user chart) — page code, like a <script>
@@ -4434,7 +5029,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4434
5029
  this._layerBuilders.push(lb);
4435
5030
  return lb;
4436
5031
  }
4437
- this._layerBuilders.push(unwrapLayer(nameOrBuilder));
5032
+ this._layerBuilders.push(asLayerBuilder(nameOrBuilder));
4438
5033
  return this;
4439
5034
  }
4440
5035
  // .Layer() — alias of .layer()
@@ -4459,8 +5054,38 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4459
5054
  // the Map(id, opts, map => map.options(...)) callback form worked).
4460
5055
  // One microtask lets the caller's whole synchronous chain land first.
4461
5056
  await null;
4462
- const el = document.getElementById(this.containerId);
4463
- if (!el) throw new Error(`[ixmaps-gl] container #${this.containerId} not found`);
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;
5063
+ let el = document.getElementById(this.containerId);
5064
+ if (!el) {
5065
+ // flat's own fallback for a missing target div (htmlgui_flat.js
5066
+ // 1055-1082): auto-create it instead of hard-failing, with a
5067
+ // RANDOMIZED id suffix — two calls that both forgot their div
5068
+ // and both name the same id must not collide on one element —
5069
+ // opt.width/height (else 100%), appended to document.body, and a
5070
+ // loud warning; reassigning this.containerId keeps every
5071
+ // downstream lookup (the MapLibre container, the splash cleanup
5072
+ // in createMap) consistent automatically
5073
+ const szOriginalDiv = this.containerId;
5074
+ do {
5075
+ this.containerId = szOriginalDiv + '-' + Math.random().toString(36).slice(2, 8);
5076
+ } while (document.getElementById(this.containerId));
5077
+ console.warn('[ixmaps-gl] container #' + szOriginalDiv + ' not found in the document — creating #' + this.containerId
5078
+ + ' automatically. Add <div id="' + szOriginalDiv + '"></div> to the page to avoid this warning.');
5079
+ el = document.createElement('div');
5080
+ el.id = this.containerId;
5081
+ el.style.width = (this.mapOptions && this.mapOptions.width) ? String(this.mapOptions.width) : '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';
5087
+ document.body.appendChild(el);
5088
+ }
4464
5089
  // flat's init replaces the container's content with its own map page
4465
5090
  // (mappage.html via innerHTML), so a page's placeholder inside the
4466
5091
  // map div ("connecting to Internet ...", the fuel-price page) goes
@@ -4482,13 +5107,22 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4482
5107
  else if (typeof v === 'string' && /^\s*\d+(\.\d+)?\s*%\s*$/.test(v)
4483
5108
  && !(dim === 'width' ? el.clientWidth : el.clientHeight)) el.style[dim] = v.trim();
4484
5109
  }
4485
- // without width/height flat's map is "fullscreen" (htmlgui_flat.js
5110
+ // without height flat's map is "fullscreen" (htmlgui_flat.js
4486
5111
  // 847-876): it fills the window, whatever the page's CSS gives the
4487
- // container — a container left without a height gets the window's
4488
- if (!(this.mapOptions && (this.mapOptions.width || this.mapOptions.height))) {
4489
- if (!el.clientHeight) el.style.height = '100vh';
4490
- 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
+ });
4491
5124
  }
5125
+ if (!(this.mapOptions && this.mapOptions.width) && !el.clientWidth) el.style.width = '100%';
4492
5126
 
4493
5127
  // shown for the whole build() (library lazy-load + every layer's own
4494
5128
  // data fetch, real-world verified as the two slowest phases) through
@@ -4515,8 +5149,6 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4515
5149
  const svgProjection = flatSvgProjectionOf(this.mapOptions.map)
4516
5150
  || (isFlatOrthographicName(this.mapOptions.mapProjection) ? FLAT_ORTHOGRAPHIC_PROJECTION : null);
4517
5151
  if (svgProjection) flatSvgProjectionByOptions.set(this._engineOptions, svgProjection);
4518
- const planeProjection = flatPlaneProjectionOf(this.mapOptions);
4519
- if (planeProjection) flatPlaneProjectionByOptions.set(this._engineOptions, planeProjection);
4520
5152
  if (isFlatOrthographicName(this.mapOptions.mapProjection)) {
4521
5153
  flatOrthographicByOptions.set(this._engineOptions, true);
4522
5154
  if (this._viewCenter) flatViewCenterByOptions.set(this._engineOptions, { lat: this._viewCenter[0], lng: this._viewCenter[1] });
@@ -4562,6 +5194,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4562
5194
  // build() and every later defineLayer()/refreshTheme() share it
4563
5195
  const dataCache = this._dataCache = this._dataCache || new Map();
4564
5196
  const runtimes = [];
5197
+ const sphereBackdrops = []; // {type:"Sphere"} layers read before the map exists
4565
5198
  // themes whose data comes by name (see namedDataTheme) start empty and
4566
5199
  // load on their own once the map is built, as in flat, where every
4567
5200
  // theme loads independently: a broker that answers late — or only
@@ -4593,9 +5226,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4593
5226
  // instead of colliding with each other under one 'null' entry.
4594
5227
  let raw, cacheKey;
4595
5228
  const deferred = deferredFeatureLoad(spec, initialZoom);
4596
- if (deferred || named) {
5229
+ // flat's WMS theme type (maptheme.js 8207-8247, the szFlag /\bWMS|IMAGE\b/
5230
+ // realize branch) has NO data of its own — .data({server}) is an
5231
+ // Esri MapServer export endpoint rendered as one image per view.
5232
+ // Here the same theme becomes a MapLibre raster tile source (one
5233
+ // 256px image per XYZ tile via the {bbox-epsg-3857} placeholder —
5234
+ // real cached tiles, not a re-fetched viewport image per pan), see
5235
+ // syncWmsLayers: no records to load, ever
5236
+ const wms = spec.flags.has('WMS') || spec.flags.has('IMAGE');
5237
+ if (deferred || named || wms) {
4597
5238
  raw = { type: 'FeatureCollection', features: [] };
4598
- cacheKey = named ? 'name:' + spec.data.name : 'deferred:' + spec.name;
5239
+ cacheKey = named ? 'name:' + spec.data.name : wms ? 'wms:' + spec.name : 'deferred:' + spec.name;
4599
5240
  } else if (spec.data && spec.data.obj) {
4600
5241
  raw = await fetchLayerData(spec.data, spec.binding, this._engineOptions);
4601
5242
  cacheKey = 'obj:' + spec.name;
@@ -4618,6 +5259,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4618
5259
  rt._named = named;
4619
5260
  rt._zoomToPending = wantsZoomToExtent(spec.flags);
4620
5261
  runtimes.push(rt);
5262
+ if (fc._sphere) sphereBackdrops.push(rt.style);
4621
5263
  notifyNewTheme(rt);
4622
5264
  _globalThemeRegistry.set(rt.name, rt);
4623
5265
  // See findRuntime's own comment on the three real theme-id
@@ -4676,6 +5318,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4676
5318
  renderWorldCopies: this._engineOptions.worldcopies !== false
4677
5319
  });
4678
5320
  engineOptionsByMap.set(map, this._engineOptions);
5321
+ sphereBackdrops.forEach(style => applySphereBackdrop(map, style));
4679
5322
  // the live-SVG pane USER charts render into (see chartHostFor) —
4680
5323
  // registered here, keyed by this map's options object, so every
4681
5324
  // runtime (including later loadProject/map.layer ones) finds it
@@ -4810,7 +5453,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4810
5453
  // a page legend (map.legend(html)) is positioned by the map's
4811
5454
  // own align option — the same rule that places the native stack
4812
5455
  legendEl.style.cssText = 'position:absolute;' + legendAlignCssFor(this.mapOptions) + 'z-index:5;pointer-events:none';
4813
- 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(() => {});
4814
5460
  // inside the map container, never the page's body (see the pinned
4815
5461
  // tooltip mount) — styling body breaks page-positioned elements
4816
5462
  anchorInside(el);
@@ -5166,6 +5812,16 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5166
5812
  const rt = findRuntime(themeId);
5167
5813
  if (rt) { rt._hidden = !visible; refresh(); }
5168
5814
  },
5815
+ // flat's Api.switchLayer (htmlgui.js 3383, htmlgui_flat.js 1531):
5816
+ // every theme of the LAYER named — setThemeVisible takes one theme
5817
+ // id, this takes the layer name and switches all of its themes
5818
+ switchLayer: (layerName, visible) => {
5819
+ const targets = runtimes.filter(r => r.name === layerName);
5820
+ if (!targets.length) console.warn(`[ixmaps-gl] switchLayer: no layer named "${layerName}"`);
5821
+ targets.forEach(rt => { rt._hidden = !visible; });
5822
+ if (targets.length) refresh();
5823
+ return engineApi;
5824
+ },
5169
5825
  getCategoryColor: (themeId, value) => {
5170
5826
  const rt = findRuntime(themeId);
5171
5827
  if (!rt || !rt.categoryIndexByLabel) return null;
@@ -5175,7 +5831,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5175
5831
  // called after every redraw (pan/zoom, or any of the setters above)
5176
5832
  // so a page can keep a facets sidebar in sync, mirroring the real
5177
5833
  // engine's htmlgui_onDrawTheme hook
5178
- 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
+ },
5179
5839
  refresh: () => refresh(),
5180
5840
  // Adds ONE new layer/theme to an ALREADY-BUILT map — build()'s own
5181
5841
  // loop above only ever runs once, at construction, so a caller
@@ -5300,9 +5960,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5300
5960
  for (const rt of targets) {
5301
5961
  if (rt._named) { await loadNamedTheme(rt, true); continue; }
5302
5962
  const spec = normalizeTheme(rt._definition);
5303
- // flat's refresh reloads, cache or not (maptheme.js 2366: datacache = false);
5304
- // the fresh result also replaces the cached one for later themes
5305
- const raw = spec.data && spec.data.obj
5963
+ // a WMS theme has no records to reload (see build()'s wms
5964
+ // branch) — its raster layer is re-synced by the refresh below
5965
+ const raw = (spec.flags.has('WMS') || spec.flags.has('IMAGE'))
5966
+ ? { type: 'FeatureCollection', features: [] }
5967
+ : spec.data && spec.data.obj
5306
5968
  ? await fetchLayerData(spec.data, rt._specBinding, builder._engineOptions)
5307
5969
  : await cachedLayerData(builder._dataCache, spec.data, rt._specBinding, builder._engineOptions, { reload: true }).promise;
5308
5970
  rt.replaceFeatures(filterAndJoin(raw, spec, runtimes));
@@ -5323,7 +5985,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5323
5985
  // layers — leaving its groups mounted keeps the charts on the
5324
5986
  // map after map.remove()/replace() (censimenti's sidebar)
5325
5987
  if (rt._clearUserChartPane) rt._clearUserChartPane();
5988
+ if (rt._chartPaneDefs) { rt._chartPaneDefs.remove(); rt._chartPaneDefs = null; }
5326
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; }
5327
5992
  });
5328
5993
  const removed = removedRuntimes.length > 0;
5329
5994
  // stale-registry guard: _globalThemeRegistry is a bare name->
@@ -5531,8 +6196,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5531
6196
  let raw;
5532
6197
  const deferred = deferredFeatureLoad(spec, map.getZoom());
5533
6198
  const named = namedDataTheme(spec);
6199
+ // WMS themes carry no records (see build()'s own wms branch)
6200
+ const wms = spec.flags.has('WMS') || spec.flags.has('IMAGE');
5534
6201
  let sourceKey = null;
5535
- if (deferred || named) {
6202
+ if (deferred || named || wms) {
5536
6203
  raw = { type: 'FeatureCollection', features: [] };
5537
6204
  } else if (spec.data && spec.data.obj) {
5538
6205
  // in memory already — nothing to cache (see build())
@@ -5552,6 +6219,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5552
6219
  rt._dataSourceKey = named ? 'name:' + spec.data.name
5553
6220
  : sourceKey || JSON.stringify({ url: spec.data && spec.data.url, urls: spec.data && spec.data.urls, type: spec.data && spec.data.type, query: spec.data && spec.data.query, obj: !!(spec.data && spec.data.obj) });
5554
6221
  runtimes.push(rt);
6222
+ if (fc._sphere) applySphereBackdrop(map, rt.style);
5555
6223
  _globalThemeRegistry.set(rt.name, rt);
5556
6224
  if (rt.meta && rt.meta.name && rt.meta.name !== rt.name) _globalThemeRegistry.set(rt.meta.name, rt);
5557
6225
  if (themeIdOf(rt) !== rt.name) _globalThemeRegistry.set(themeIdOf(rt), rt);
@@ -5622,8 +6290,6 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5622
6290
  const svgProjection = flatSvgProjectionOf(svg) || (/orthographic\.svg$/i.test(svg) ? FLAT_ORTHOGRAPHIC_PROJECTION : null);
5623
6291
  if (svgProjection) flatSvgProjectionByOptions.set(builder._engineOptions, svgProjection);
5624
6292
  else flatSvgProjectionByOptions.delete(builder._engineOptions);
5625
- if (svgProjection && svgProjection.invert) flatPlaneProjectionByOptions.set(builder._engineOptions, svgProjection);
5626
- else flatPlaneProjectionByOptions.delete(builder._engineOptions);
5627
6293
  if (/orthographic\.svg$/i.test(svg)) flatOrthographicByOptions.set(builder._engineOptions, true);
5628
6294
  else { flatOrthographicByOptions.delete(builder._engineOptions); flatViewCenterByOptions.delete(builder._engineOptions); }
5629
6295
  }
@@ -5794,7 +6460,87 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5794
6460
  if (order !== refreshLayers._lastOrder) { refreshLayers._lastOrder = order; console.info('[ixmaps-gl debug] draw order (bottom → top): ' + order); }
5795
6461
  }
5796
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
+ }
6479
+ // flat's WMS themes render as native raster layers, not deck.gl —
6480
+ // reconcile them with the current runtimes on every rebuild
6481
+ syncWmsLayers();
6482
+ }
6483
+
6484
+ // flat's WMS theme type (szFlag /\bWMS|IMAGE\b/, maptheme.js 8207-8247):
6485
+ // .data({server}) names an Esri MapServer export endpoint, flat draws
6486
+ // ONE f=image request per view (bbox in 4326, the view's pixel size),
6487
+ // opacity from fillOpacity || nOpacity || 0.8. Here the same theme is
6488
+ // a MapLibre raster tile source instead — the SAME export call per
6489
+ // 256px XYZ tile ({bbox-epsg-3857} → bboxSR=3857), which MapLibre
6490
+ // caches, loads progressively and never re-fetches per pan step;
6491
+ // deck.gl's interleaved layers stay on top of it. server is read
6492
+ // from .style() as flat does (maptheme.js 1408: styleObj.server),
6493
+ // or from .data() as this port's own pages write it.
6494
+ // ids of the raster layers this function ever added — lets it skip
6495
+ // map.getStyle() (a deep copy of the whole style) on every rebuild
6496
+ // of a page that has no WMS theme
6497
+ const wmsLayerIds = new Set();
6498
+ function syncWmsLayers() {
6499
+ if (!map || !map.getLayer) return;
6500
+ const idOf = name => 'ixmaps-wms:' + String(name).replace(/[^a-zA-Z0-9_-]/g, '_');
6501
+ const desired = new Map();
6502
+ runtimes.forEach(rt => {
6503
+ if (!(rt.flags.has('WMS') || rt.flags.has('IMAGE'))) return;
6504
+ const server = (rt.style && rt.style.server) || (rt._definition && rt._definition.data && rt._definition.data.server);
6505
+ if (server) desired.set(rt.name, { rt, server });
6506
+ });
6507
+ if (!desired.size && !wmsLayerIds.size) return;
6508
+ // a removed WMS theme takes its raster layer with it — as flat's
6509
+ // clearWMS does (ids are sanitized names, compare id-to-id)
6510
+ const ownedIds = new Set([...desired.keys()].map(idOf));
6511
+ [...wmsLayerIds].forEach(id => {
6512
+ if (ownedIds.has(id)) return;
6513
+ wmsLayerIds.delete(id);
6514
+ try { if (map.getLayer(id)) map.removeLayer(id); } catch (e) { /* already gone */ }
6515
+ try { if (map.getSource(id)) map.removeSource(id); } catch (e) { /* already gone */ }
6516
+ });
6517
+ desired.forEach(({ rt, server }, name) => {
6518
+ const id = idOf(name);
6519
+ wmsLayerIds.add(id);
6520
+ const sep = server.indexOf('?') >= 0 ? '&' : '?';
6521
+ const tiles = [server + sep + 'f=image&transparent=true&bbox={bbox-epsg-3857}&bboxSR=3857&size=256,256'];
6522
+ if (!map.getSource(id)) {
6523
+ map.addSource(id, { type: 'raster', tiles, tileSize: 256 });
6524
+ }
6525
+ if (!map.getLayer(id)) {
6526
+ map.addLayer({ id, type: 'raster', source: id, paint: { 'raster-opacity': 1 } });
6527
+ }
6528
+ // flat's own opacity reading (maptheme.js 8240): fillOpacity ||
6529
+ // nOpacity || 0.8 — style "opacity" already folded into
6530
+ // fillopacity by normalizeTheme
6531
+ const opacity = styleNum(rt.style.fillopacity) || 0.8;
6532
+ if ((map.getPaintProperty(id, 'raster-opacity') || 1) !== opacity) {
6533
+ map.setPaintProperty(id, 'raster-opacity', opacity);
6534
+ }
6535
+ const vis = rt._hidden ? 'none' : 'visible';
6536
+ if ((map.getLayoutProperty(id, 'visibility') || 'visible') !== vis) {
6537
+ map.setLayoutProperty(id, 'visibility', vis);
6538
+ }
6539
+ });
5797
6540
  }
6541
+ // a style swap (setMapType) drops every native layer — rebuild the
6542
+ // WMS ones once the new style stands
6543
+ map.on('style.load', () => syncWmsLayers());
5798
6544
 
5799
6545
  // isZooming flips true/false on every 'zoomstart'/'zoomend' pair —
5800
6546
  // and a scroll-wheel or trackpad zoom fires MANY such pairs per
@@ -5890,7 +6636,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5890
6636
  } finally { inDrawThemeHook = false; }
5891
6637
  }
5892
6638
  function notifyRedraw() {
5893
- 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); } });
5894
6640
  callDrawThemeHooks();
5895
6641
  }
5896
6642
 
@@ -6072,6 +6818,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6072
6818
  };
6073
6819
  }
6074
6820
  _resolveMapReady(engineApi);
6821
+ if (this.mapOptions && this.mapOptions.name) namedMapReady(this.mapOptions.name).resolve(engineApi);
6075
6822
  resolveBuilt();
6076
6823
  namedLoads.forEach(rt => { listenNamedData(rt); if (!rt._deferredLoad) loadNamedTheme(rt, false); });
6077
6824
  return engineApi;
@@ -6090,7 +6837,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6090
6837
  // theirs and are not overridden.
6091
6838
  for (const m of ['replace', 'add', 'newTheme', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
6092
6839
  'refreshTheme', 'redrawTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'setProjection', 'resize',
6093
- 'loadProject', 'project', 'setThemeVisible', 'setThemeTimeFrame', 'show', 'hide', 'getZoom', 'getThemeObj',
6840
+ 'loadProject', 'project', 'setThemeVisible', 'switchLayer', 'setThemeTimeFrame', 'show', 'hide', 'getZoom', 'getThemeObj',
6094
6841
  'getThemes', 'theme', 'setView']) {
6095
6842
  if (!MapBuilder.prototype[m]) {
6096
6843
  MapBuilder.prototype[m] = function (...args) {
@@ -6103,6 +6850,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6103
6850
  // class used throughout this file — a real bug caught during testing
6104
6851
  // (every `new Map()` below silently resolved to *this* function instead)
6105
6852
  function createMap(containerId, options, mapFn) {
6853
+ if (options && options.name) namedMapReady(options.name);
6106
6854
  const builder = new MapBuilder(containerId, options);
6107
6855
  if (mapFn) mapFn(builder);
6108
6856
  // If build() rejects (bad data URL, misconfigured CHOROPLETH join,
@@ -6113,7 +6861,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6113
6861
  // .catch()/await still sees the real failure — this only guarantees
6114
6862
  // the splash doesn't outlive it.
6115
6863
  const promise = builder.build().catch(err => {
6116
- const el = document.getElementById(containerId);
6864
+ // builder.containerId, not the closure's: build() reassigns it when
6865
+ // it had to auto-create a missing container (flat's fallback above)
6866
+ const el = document.getElementById(builder.containerId);
6117
6867
  const splash = el && el.querySelector('.ixmaps-splash');
6118
6868
  if (splash) hideSplash(splash);
6119
6869
  throw err;
@@ -6168,7 +6918,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6168
6918
  // flat's mapApi.setView(center, zoom) (htmlgui_flat.js 1327) — the
6169
6919
  // same live-or-build-time split as .view() above
6170
6920
  promise.setView = (...args) => promise.view(...args);
6171
- 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; };
6172
6925
  promise.attribution = (...args) => { builder.attribution(...args); return promise; };
6173
6926
  promise.legend = (...args) => { builder.legend(...args); return promise; };
6174
6927
  promise.local = (...args) => { builder.local(...args); return promise; };
@@ -6392,11 +7145,6 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6392
7145
  // the flat SVG projection a map's options object belongs to (set by the
6393
7146
  // map's init and loadProject; the runtimes only see the options object)
6394
7147
  const flatSvgProjectionByOptions = new WeakMap();
6395
- // the projection plane a map's VECTOR / BEZIER flows are drawn in: flat's
6396
- // map file, or the Map() option mapProjection ("equalearth") — flat computes
6397
- // the curves in its map's own coordinates, so the same flow bows around the
6398
- // pole there. (Unlike flatSvgProjectionByOptions it leaves the view framing alone.)
6399
- const flatPlaneProjectionByOptions = new WeakMap();
6400
7148
  // a map that is flat's orthographic one (drawn as MapLibre's globe): its object scale is
6401
7149
  // flat's (scale × the globe's pixels per degree at its center, measured on flat 2026-10-10
6402
7150
  // on the mechanical-goods globe page, the same at view zooms 2.5 and 3.5: 197.44e6; the
@@ -6439,12 +7187,6 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6439
7187
  const radius = 512 * Math.pow(2, zoom) / (2 * Math.PI * c);
6440
7188
  return Math.log2(globeDiscRadius(radius, height > 0 ? height : 768) * 2 * Math.PI / 512);
6441
7189
  }
6442
- function flatPlaneProjectionOf(mapOptions) {
6443
- const byUrl = flatSvgProjectionOf(mapOptions.map);
6444
- if (byUrl) return byUrl;
6445
- const named = FLAT_SVG_PROJECTIONS[String(mapOptions.mapProjection || '').trim().toLowerCase()];
6446
- return named || null;
6447
- }
6448
7190
  // flat SVG map scale ÷ the Mercator one at the same on-screen size (1
6449
7191
  // without a flat SVG projection)
6450
7192
  function flatMapScaleRatio(mapOptions) {
@@ -7232,8 +7974,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7232
7974
  _measureCtx.font = fontPx + 'px arial';
7233
7975
  return _measureCtx.measureText(text).width;
7234
7976
  }
7235
- function wrapTitleLines(text, fontPx, widthPx) {
7236
- const width = w => titleTextWidth(w, fontPx);
7977
+ function wrapTitleLines(text, fontPx, widthPx, measure = titleTextWidth) {
7978
+ const width = w => measure(w, fontPx);
7237
7979
  const lines = [];
7238
7980
  for (const word of String(text).split(/\s+/).filter(Boolean)) {
7239
7981
  const last = lines.length ? lines[lines.length - 1] + ' ' + word : null;
@@ -7519,6 +8261,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7519
8261
  // independently-rendered systems (deck.gl's icon quad, MapLibre's own
7520
8262
  // globe-sphere mesh) competing for the same depth-buffer real estate.
7521
8263
  const ICON_LAYER_GLOBE_PARAMETERS = { cullMode: 'none', depthCompare: 'always' };
8264
+ // a transparent 1x1 PNG — the placeholder icon of an external symbol
8265
+ // file still being rasterized (see _buildSequenceChartLayers' urlIcon)
8266
+ const TRANSPARENT_1PX_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
7522
8267
 
7523
8268
  // GLOBE_HORIZON_DEG / isOnVisibleHemisphere: `depthCompare: 'always'`
7524
8269
  // above (needed to stop the near-surface z-fighting/half-moon bug —
@@ -7790,6 +8535,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7790
8535
  switch (shape) {
7791
8536
  case 'squarefull': return Math.abs(dx) <= r && Math.abs(dy) <= r;
7792
8537
  case 'square': return Math.abs(dx) <= r * Math.SQRT1_2 && Math.abs(dy) <= r * Math.SQRT1_2;
8538
+ case 'roundrectfull': return Math.abs(dx) <= r && Math.abs(dy) <= r;
7793
8539
  case 'roundrect': return Math.abs(dx) <= r * 0.8 && Math.abs(dy) <= r * 0.8;
7794
8540
  case 'diamond': case 'carot': return Math.abs(dx) + Math.abs(dy) <= r;
7795
8541
  case 'empty': return false;
@@ -8179,39 +8925,55 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8179
8925
  return { bars, texts, lines, extent: ext[0] <= ext[2] ? ext : [0, 0, 0, 0], width: W, sizer };
8180
8926
  }
8181
8927
 
8182
- // flat's BOX and TITLE around a chart (maptheme.js 18758-18905), in the
8183
- // chart's units: `extent` the chart's [x0, y0, x1, y1]; o: { margin
8184
- // (boxmargin, 2), titleFont, title (text or ''), bottomTitle, alignRight,
8185
- // measure }. The margin is min(2 m, m · width / 30); the title's first
8186
- // baseline 0.7 font above the chart (BOTTOMTITLE: one font below), its
8187
- // lines wrapped to the chart's width and stacked upward; the box encloses
8188
- // chart and title, its top trimmed by 0.1 font when titled. Returns
8189
- // { rect: [x0, y0, x1, y1], title: { lines, x, anchor, baseline, font } | null }
8190
- 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) {
8191
8944
  const e = extent.slice();
8192
- const w = e[2] - e[0];
8193
- 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;
8194
8951
  let title = null;
8195
- if (o.title) {
8196
- const f = o.titleFont;
8197
- const measure = o.measure || ((text, font) => text.length * font * 0.55);
8198
- const lines = [];
8199
- for (const word of String(o.title).split(/\s+/).filter(Boolean)) {
8200
- const last = lines.length ? lines[lines.length - 1] + ' ' + word : null;
8201
- if (last !== null && measure(last, f) <= w) lines[lines.length - 1] = last;
8202
- else lines.push(word);
8203
- }
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');
8204
8956
  const width = Math.max(...lines.map(l => measure(l, f)));
8205
- const baseline = o.bottomTitle ? e[3] + f : e[1] - 0.7 * f - (lines.length - 1) * f;
8206
- const x = o.alignRight ? e[2] : e[0];
8207
- const x0 = o.alignRight ? x - width : x;
8208
- title = { lines, x, anchor: o.alignRight ? 'end' : 'start', baseline, font: f };
8209
- e[0] = Math.min(e[0], x0);
8210
- e[1] = Math.min(e[1], baseline - 0.905 * f) + 0.1 * f;
8211
- e[2] = Math.max(e[2], x0 + width);
8212
- 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] };
8213
8966
  }
8214
- 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 };
8215
8977
  }
8216
8978
 
8217
8979
  // CHART|PIE / DONUT: one pie's slices as real ixmaps-flat lays them out —
@@ -8243,10 +9005,38 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8243
9005
  // centered on 12 o'clock), HALF over 180° from 270° (HALFPLUS10 200°
8244
9006
  // from 260°, HALFMINUS10 160° from 280°, HALFPLUS20 220° from 250°,
8245
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)
8246
9016
  function pieSliceLayout(values, t, opts = {}) {
8247
9017
  t = String(t || '');
8248
9018
  const has = re => re.test(t);
8249
- 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));
8250
9040
  const sum = parts.reduce((a, v) => a + v, 0);
8251
9041
  const autocomplete = has(/AUTOCOMPLETE/);
8252
9042
  if (sum === 0 && !autocomplete) return null;
@@ -8299,13 +9089,18 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8299
9089
  // flat draws its "Pie error: no values !" circle here
8300
9090
  if (!any && !autocomplete) return null;
8301
9091
  const pctSum = slices.reduce((a, sl) => a + sl.percent, 0);
8302
- 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) {
8303
9095
  if (autocomplete && pctSum < 100) slices.push({ i: -1, value: null, percent: Math.floor((100 - pctSum) * 100000) / 100000, complement: true });
8304
9096
  else slices.forEach(sl => { sl.percent = sl.percent / pctSum * 100; });
8305
9097
  }
8306
9098
  let end = 0;
8307
9099
  if (has(/SYMMETRIC/) && slices.length) {
8308
- 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/)) {
8309
9104
  end = 360;
8310
9105
  let x = 0;
8311
9106
  for (; x < slices.length && slices[x] !== biggest; x++) end -= slices[x].percent < 100 ? Math.floor(360 / 100 * slices[x].percent) : 0;
@@ -8326,6 +9121,20 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8326
9121
  if (has(/HALFMINUS20/)) maxAngle = 140;
8327
9122
  const out = [];
8328
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
+ }
8329
9138
  const sweep = sl.percent / 100 * maxAngle;
8330
9139
  const start = end % 360;
8331
9140
  end = start + sweep;
@@ -8452,6 +9261,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8452
9261
  const parts = [], zeroNot = [];
8453
9262
  for (let k = 0; k < o.fields.length; k++) {
8454
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);
8455
9268
  zeroNot[k] = false;
8456
9269
  if (isNaN(v)) {
8457
9270
  if (!r.undefinedOk) return null;
@@ -8661,6 +9474,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8661
9474
  for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
8662
9475
  return (h >>> 0) / 4294967296;
8663
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
+ }
8664
9482
 
8665
9483
  // points of a cubic Bézier, n segments (n + 1 points)
8666
9484
  function cubicBezierPoints(p0, c1, c2, p3, n) {
@@ -8804,7 +9622,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8804
9622
  const word = w => { const re = new RegExp('\\b' + w + '\\b'); return [...flags].some(f => re.test(f)); };
8805
9623
  const info = {
8806
9624
  sort: word('SORT'), up: word('UP'), down: word('DOWN'),
8807
- star: has('STAR') && !has('CENTER'), horz: has('HORZ') && !has('CENTER'),
9625
+ star: has('STAR') && !flatFlag(flags, 'CENTER'), horz: has('HORZ') && !flatFlag(flags, 'CENTER'),
8808
9626
  expand: has('EXPAND'), expandmax: has('EXPANDMAX'), linear: has('LINEAR'), sizep1: has('SIZEP1'),
8809
9627
  compress: has('COMPRESS'), compressmax: has('COMPRESSMAX')
8810
9628
  };
@@ -8823,14 +9641,21 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8823
9641
  const up = fl.up;
8824
9642
  const maxR = opts.maxRadius || 0;
8825
9643
  const nStarParts = opts.nParts || counts.length;
8826
- 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;
8827
9652
  order.forEach((p, k) => {
8828
9653
  if (!p.v) return;
8829
9654
  const r = radiusOf(p.v);
8830
9655
  if (!(r > 0)) return;
8831
9656
  let px = 0, py = 0;
8832
9657
  if (star) {
8833
- if (starR === null) {
9658
+ if (k === 0) {
8834
9659
  starR = r;
8835
9660
  if (fl.expand && fl.sort && up) {
8836
9661
  const big = order[order.length - 1].v, mv = opts.maxValue || big || 1;
@@ -8838,7 +9663,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8838
9663
  }
8839
9664
  if (fl.compress) starR *= fl.compressmax ? 0.25 : 0.5;
8840
9665
  }
8841
- 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) {
8842
9668
  const c = starR + r;
8843
9669
  const b = Math.sqrt(Math.max(0, c * c - r * r));
8844
9670
  let f = 90 - Math.asin(Math.min(1, b / c)) / Math.PI * 180;
@@ -9052,6 +9878,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9052
9878
  // which every other caller here still relies on unchanged.
9053
9879
  function resolvePartsClass(value, partsA) {
9054
9880
  if (!partsA || isNaN(value)) return null;
9881
+ // explicit ranges: a value below the first break is class 0, as flat's
9882
+ // class loop (value < parts[0].max) puts it
9883
+ if (partsA.length && partsA[0].clampLow && value < partsA[0].min) return 0;
9055
9884
  for (let i = 0; i < partsA.length; i++) {
9056
9885
  const isLast = i === partsA.length - 1;
9057
9886
  const inLower = value >= partsA[i].min;
@@ -9384,7 +10213,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9384
10213
  // 7740-7801): a value that is no number counts as 0 — unless
9385
10214
  // UNDEFINEDISNOTVALUE —, and 0 is a value — unless ZEROISNOTVALUE
9386
10215
  function flatNumber(v, flags) {
9387
- 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, ''));
9388
10223
  if (isNaN(n)) n = flags && flags.has('UNDEFINEDISNOTVALUE') ? NaN : 0;
9389
10224
  if (n === 0 && flags && flags.has('ZEROISNOTVALUE')) return NaN;
9390
10225
  return n;
@@ -9395,11 +10230,35 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9395
10230
  const bins = [arr.reduce((a, v) => (v < a ? v : a), Infinity), m];
9396
10231
  while (arr.length > 1) {
9397
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;
9398
10236
  m = mean(arr);
9399
10237
  bins.push(m);
9400
10238
  }
9401
10239
  return bins;
9402
10240
  }
10241
+ // Explicit class breaks: .style({ranges}) with the RANGES flag (flat,
10242
+ // maptheme.js 2324 / 12905-12960). n+1 break values (a comma or "|"
10243
+ // separated string, or an array) are n classes [r_i, r_i+1] — a class
10244
+ // max is nudged by 1e-9 as flat does, so a break value belongs to the class
10245
+ // below it. Not for CATEGORICAL (there the values are exact categories).
10246
+ // null when the theme has no usable explicit ranges.
10247
+ function explicitRangeBreaks(style, flags) {
10248
+ if (!flags || !flags.has('RANGES') || flags.has('CATEGORICAL')) return null;
10249
+ let r = style && style.ranges;
10250
+ if (typeof r === 'string') r = r.split(r.includes('|') ? '|' : ',');
10251
+ if (!Array.isArray(r)) return null;
10252
+ r = r.map(Number);
10253
+ return r.length > 1 && r.every(isFinite) ? r : null;
10254
+ }
10255
+ function explicitRangeParts(breaks) {
10256
+ const parts = [];
10257
+ for (let i = 0; i < breaks.length - 1; i++) parts.push({ min: breaks[i], max: breaks[i + 1] + 0.000000001 });
10258
+ parts[0].clampLow = true;
10259
+ return parts;
10260
+ }
10261
+
9403
10262
  function flatRangeParts(values, nMin, nMax, nParts, flags, centerValue) {
9404
10263
  const rawMin = nMin, rawMax = nMax;
9405
10264
  if (centerValue != null && !isNaN(centerValue)) {
@@ -9424,6 +10283,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9424
10283
  // values above the last mean, while more than one is left
9425
10284
  if (flags.has('HEADTAIL')) {
9426
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]);
9427
10290
  parts = new Array(nParts).fill(null).map((_, i) => ({ min: b[i], max: b[i + 1] - 0.000001 }));
9428
10291
  } else if (flags.has('QUANTILE')) parts = quantileBreaks(values, nParts);
9429
10292
  else if (flags.has('NATURAL')) parts = naturalBreaks(values, nParts);
@@ -9523,6 +10386,44 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9523
10386
  // On copies of the features' properties — the page's data is not changed.
9524
10387
  // AGGREGATE themes: not here — flat computes field100 on the aggregated
9525
10388
  // sums (aggregateField100).
10389
+ // .style({weights}) — flat's weighted values: every record's per-field
10390
+ // value × its field's weight, BEFORE the field100 arithmetic (the
10391
+ // per-record multiply in addItemValues, maptheme.js 9007-9009; the
10392
+ // aggregate loader reads the weight per value field at 10506-10508).
10393
+ // One number, or a pipe list with one weight per value field; a field
10394
+ // beyond the list takes the first weight (flat: `szWeightsA[i] ||
10395
+ // szWeightsA[0]`), the aggregate loader instead repeats the LAST one
10396
+ // (10262-10266) — `repeatLast`; no weights at all leaves the values raw.
10397
+ // The Milano page: weights 0.1 over km-long street lengths + FRACTION →
10398
+ // cars per 100 m
10399
+ function fieldWeightOf(style, nFields, repeatLast) {
10400
+ const w = style && style.weights;
10401
+ if (w == null || w === '') return null;
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));
10407
+ if (!list.some(v => !isNaN(v))) return null;
10408
+ if (repeatLast) { const last = list[list.length - 1]; while (list.length < nFields) list.push(last); }
10409
+ return i => list[i] || list[0] || 1;
10410
+ }
10411
+ // flat's weighted record for the AGGREGATE path (maptheme.js 10506-10508):
10412
+ // the value fields' own values weighted — the class value and the field100
10413
+ // value stay raw, exactly as flat reads them
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;
10418
+ const p = Object.assign({}, f.properties);
10419
+ if (p.value !== undefined && !isNaN(p.value)) p.value = p.value * weightOf(0);
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)));
10425
+ return Object.assign({}, f, { properties: p });
10426
+ }
9526
10427
  function applyField100(features, binding, flags, style) {
9527
10428
  if (!binding || !binding.value || !Array.isArray(features)) return features;
9528
10429
  // AGGREGATE: flat applies field100 to the aggregated sums
@@ -9532,7 +10433,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9532
10433
  const fields = String(binding.value).split('|');
9533
10434
  const auto100 = flags.has('AUTO100') && !flags.has('AGGREGATE');
9534
10435
  const seriesDifference = flags.has('DIFFERENCE') && fields.length > 1;
9535
- if (!f100 && !auto100 && !seriesDifference) return features;
10436
+ // flat's weights run even without a field100 (addItemValues multiplies
10437
+ // every record's values, maptheme.js 9007-9009)
10438
+ const weightOf = fieldWeightOf(style, fields.length, false);
10439
+ if (!f100 && !auto100 && !seriesDifference && !weightOf) return features;
9536
10440
  const f100A = f100 ? String(f100).split('|') : null;
9537
10441
  const fractionScale = styleNum(style && style.fractionscale) || 1;
9538
10442
  const names = fields.map(field => (field[0] === '!' ? field.slice(1) : field));
@@ -9540,12 +10444,16 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9540
10444
  return features.map(f => {
9541
10445
  if (!f || !f.properties) return f;
9542
10446
  const props = Object.assign({}, f.properties);
9543
- let values = names.map(name => parseFloat(f.properties[name]));
10447
+ let values = names.map(name => flatScanValue(f.properties[name]));
10448
+ // the weights run BEFORE the field100 arithmetic, as in flat's
10449
+ // addItemValues — the FRACTION of a weighted value is the weighted
10450
+ // per-unit value
10451
+ if (weightOf) values = values.map((v, i) => (isNaN(v) ? v : v * weightOf(i)));
9544
10452
  if (f100A || auto100) {
9545
10453
  const sum = auto100 ? values.reduce((a, v) => a + (isNaN(v) ? 0 : v), 0) : 0;
9546
10454
  values = values.map((v, i) => {
9547
10455
  if (isNaN(v)) return v;
9548
- 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)]]);
9549
10457
  if (v === 0 && !flags.has('ZEROISVALUE')) return 0;
9550
10458
  if (fields[i][0] === '!') return (isNaN(v100) ? 0 : v100) - v;
9551
10459
  if (flags.has('CALCVAL') || flags.has('CALC100')) return v100 ? Math.round(v * v100 / 100) : v;
@@ -9591,7 +10499,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9591
10499
  // DIFFERENCE over several value fields: the difference is the value
9592
10500
  const fields = String(binding.value).split('|');
9593
10501
  const valueOf = flags && flags.has('DIFFERENCE') && fields.length > 1
9594
- ? 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])
9595
10506
  // "$item$" is flat's record count: 1 per record
9596
10507
  : binding.value === '$item$' ? () => 1
9597
10508
  : p => (p && Object.keys(p).length ? flatNumber(p[binding.value], flags) : NaN);
@@ -9618,11 +10529,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9618
10529
  out._valueMax = nMax;
9619
10530
  const sorted = values.slice().sort((a, b) => a - b);
9620
10531
  out._valueMedian = sorted[Math.floor((sorted.length - 1) / 2)];
9621
- const nParts = Math.trunc(styleNum(style.classes)) || colorSchemeClassCount(style.colorscheme) || DEFAULT_RANGE_CLASSES;
10532
+ const breaks = explicitRangeBreaks(style, flags);
10533
+ const nParts = breaks ? breaks.length - 1
10534
+ : Math.trunc(styleNum(style.classes)) || colorSchemeClassCount(style.colorscheme) || DEFAULT_RANGE_CLASSES;
9622
10535
  const placeholders = new Array(nParts).fill('');
9623
- const colorsRgb = resolveClassColors(style.colorscheme, placeholders, style.classes);
10536
+ const colorsRgb = resolveClassColors(style.colorscheme, placeholders, breaks ? nParts : style.classes);
9624
10537
 
9625
- out.partsA = flatRangeParts(values, nMin, nMax, nParts, flags, styleNum(style.rangecentervalue));
10538
+ out.partsA = breaks ? explicitRangeParts(breaks) : flatRangeParts(values, nMin, nMax, nParts, flags, styleNum(style.rangecentervalue));
9626
10539
  out._nRangeParts = nParts; // the configured count; partsA may have fewer (all values equal)
9627
10540
 
9628
10541
  out.categoryLabels = out.partsA.map(p => `${formatValue(p.min)} - ${formatValue(p.max)}`);
@@ -9760,6 +10673,15 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9760
10673
  // (maptheme.js 11086-11101): each value becomes the next minus it
9761
10674
  // (RELATIVE: in % of it when it is above field100min, else 0), the last
9762
10675
  // one is dropped
10676
+ // How a record joins its cell's running value (flat's MAX / MIN / SUM
10677
+ // aggregation, maptheme.js 10880-10901): MAX / MIN keep the extreme
10678
+ // (a missing part counts 0, as in flat), SUM adds. `first`: the cell's
10679
+ // first record, which just starts the value.
10680
+ function aggCombiner(flags) {
10681
+ const max = flags.has('MAX'), min = flags.has('MIN');
10682
+ return (acc, v, first) => (first ? v : max ? Math.max(acc || 0, v) : min ? Math.min(acc || 0, v) : acc + v);
10683
+ }
10684
+
9763
10685
  function cellAggregatedValues(cell, flags, style) {
9764
10686
  const useMean = flags.has('MEAN');
9765
10687
  const values = cell.sums.map((s, i) => (useMean && cell.counts[i] > 0 ? s / cell.counts[i] : s));
@@ -9825,6 +10747,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9825
10747
  // relocates a field aggregation, maptheme.js 11141) — instead of a grid
9826
10748
  function aggregateOnGrid(features, zoom, cellPx, flags, post, field) {
9827
10749
  const relocate = flags.has('RELOCATE') || !!field;
10750
+ const combine = aggCombiner(flags);
10751
+ // MAX / MIN aggregation: the cell IS one defined record — the one
10752
+ // holding the biggest (smallest) size — so its title follows THAT
10753
+ // record, not the first one's (flat re-sets the merging record's
10754
+ // szTitle under __fMax, maptheme.js 10870-10874; here it is the
10755
+ // record that actually holds it)
10756
+ const maxAgg = flags.has('MAX'), minAgg = flags.has('MIN');
9828
10757
  const cells = new Map();
9829
10758
  for (const f of features) {
9830
10759
  const [lng, lat] = f.geometry.coordinates;
@@ -9846,34 +10775,54 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9846
10775
  (cell.raws = cell.raws || []).push(f.properties.raw);
9847
10776
  }
9848
10777
  cell.sumX += p.x; cell.sumY += p.y; cell.n++;
9849
- cell.value += f.properties.value;
9850
- if (f.properties.classValue !== undefined) cell.classValue += f.properties.classValue;
9851
- if (post) cell.value100 += f.properties.value100 || 0;
10778
+ const first = cell.n === 1;
10779
+ cell.value = combine(cell.value, f.properties.value, first);
10780
+ if (f.properties.classValue !== undefined) cell.classValue = combine(cell.classValue, f.properties.classValue, first);
10781
+ if (post) cell.value100 = combine(cell.value100, f.properties.value100 || 0, first);
9852
10782
  // an AGGREGATE / CATEGORICAL pie's record (flatPieRecord): its parts
9853
10783
  // and size added to the cell's item as flat does (flatPieAccumulate)
9854
10784
  if (f.properties.pie) cell.pie = flatPieAccumulate(cell.pie || null, f.properties.pie, flags);
9855
10785
  const series = f.properties.series;
9856
10786
  if (series) {
9857
10787
  if (!cell.series) cell.series = new Array(series.length).fill(0);
9858
- for (let i = 0; i < series.length; i++) cell.series[i] += series[i];
10788
+ for (let i = 0; i < series.length; i++) cell.series[i] = combine(cell.series[i], series[i], first);
10789
+ }
10790
+ // a multi-field SEQUENCE record's size-field value (flat's
10791
+ // nValueSize): MAX/MIN/summed into the item's nSize, as flat does
10792
+ if (f.properties.size !== undefined) cell.size = combine(typeof cell.size === 'number' ? cell.size : 0, f.properties.size, first);
10793
+ // MAX / MIN: which record the cell's title belongs to (see the
10794
+ // comment at the top — the size field carries the magnitude, else
10795
+ // the combined value)
10796
+ if (maxAgg || minAgg) {
10797
+ const m = typeof f.properties.size === 'number' ? Math.abs(f.properties.size) : Math.abs(f.properties.value || 0);
10798
+ if (cell.titleMax === undefined || (maxAgg ? m > cell.titleMax : m < cell.titleMax)) {
10799
+ cell.titleMax = m;
10800
+ cell.titleFrom = f;
10801
+ cell.titleX = p.x; cell.titleY = p.y;
10802
+ }
9859
10803
  }
9860
10804
  }
9861
10805
  const out = [];
9862
10806
  for (const cell of cells.values()) {
9863
- const x = relocate ? cell.sumX / cell.n : cell.snapped.x;
9864
- 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;
9865
10813
  const ll = worldPixelToLngLat(x, y, zoom);
9866
10814
  const geometry = { type: 'Point', coordinates: [ll.lng, ll.lat] };
9867
10815
  const hasClass = cell.first.properties.classValue !== undefined;
9868
10816
  let props = cell.n > 1
9869
- // titleRaw: the cell's first record — flat titles an aggregated
9870
- // item by it (itemTitle)
10817
+ // titleRaw: the record the cell's title belongs to — with MAX/MIN
10818
+ // the one holding the max (min), else the first (flat's itemTitle)
9871
10819
  ? { cluster: true, point_count: cell.n, value: cell.value, ...(hasClass ? { classValue: cell.classValue } : {}),
9872
- titleRaw: cell.first.properties.raw || cell.first.properties,
10820
+ titleRaw: (cell.titleFrom || cell.first).properties.raw || (cell.titleFrom || cell.first).properties,
9873
10821
  ...(cell.raws && cell.raws.length ? { raws: cell.raws } : {}) }
9874
10822
  : cell.first.properties;
9875
10823
  if (cell.series && cell.n > 1) props = Object.assign({}, props, { series: cell.series });
9876
10824
  if (cell.pie) props = Object.assign({}, props, { pie: cell.pie });
10825
+ if (typeof cell.size === 'number' && cell.n > 1) props = Object.assign({}, props, { size: cell.size });
9877
10826
  // a field aggregation's cell key (the field value): the categories of
9878
10827
  // one cell are merged by it (groupCoLocated), not by their positions
9879
10828
  if (field) props = Object.assign({}, props, { aggKey: cell.key });
@@ -9937,7 +10886,18 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9937
10886
  let cell = cells.get(key);
9938
10887
  if (!cell) { cell = { sumX: 0, sumY: 0, n: 0, cx: snapped.x, cy: snapped.y, counts: new Array(n).fill(0), recordCounts: new Array(n).fill(0), classTotal: undefined }; cells.set(key, cell); }
9939
10888
  cell.sumX += p.x; cell.sumY += p.y; cell.n++;
9940
- if (!cell.titleRaw) cell.titleRaw = f.properties.titleRaw || f.properties.raw || null;
10889
+ // the cell's title: with MAX/MIN aggregation the member holding the
10890
+ // biggest (smallest) size (the cell IS that one item), else the
10891
+ // first — flat's itemTitle
10892
+ const maxAgg = flags.has('MAX'), minAgg = flags.has('MIN');
10893
+ const titleOf = f => f.properties.titleRaw || f.properties.raw || null;
10894
+ if (maxAgg || minAgg) {
10895
+ const m = typeof f.properties.size === 'number' ? Math.abs(f.properties.size) : Math.abs(f.properties.total || f.properties.value || 0);
10896
+ if (cell.titleMax === undefined || (maxAgg ? m > cell.titleMax : m < cell.titleMax)) {
10897
+ cell.titleMax = m;
10898
+ cell.titleRaw = titleOf(f);
10899
+ }
10900
+ } else if (!cell.titleRaw) cell.titleRaw = titleOf(f);
9941
10901
  // the members' own records, for the tooltip's {{theme.item.data}} —
9942
10902
  // flat's __add_data tables them (numbered, up to 50) for aggregated
9943
10903
  // items too; references only, so no copy is made. A member can itself
@@ -9952,6 +10912,18 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9952
10912
  }
9953
10913
  cell.counts[f.properties.cat] += f.properties.value;
9954
10914
  if (f.properties.classValue !== undefined) cell.classTotal = (cell.classTotal || 0) + f.properties.classValue;
10915
+ // a multi-field SEQUENCE cell's per-field parts and its size-field
10916
+ // value (see _buildAggregationIndex) — combined with the theme's
10917
+ // own MAX/MIN/sum combiner, so a re-grouped cell keeps them
10918
+ const combineSeries = cell.combineSeries || (cell.combineSeries = aggCombiner(flags));
10919
+ const memberSeries = f.properties.series;
10920
+ if (memberSeries) {
10921
+ if (!cell.series) cell.series = new Array(memberSeries.length).fill(0);
10922
+ for (let i = 0; i < memberSeries.length; i++) cell.series[i] = combineSeries(cell.series[i], memberSeries[i], cell.n === 1);
10923
+ }
10924
+ if (typeof f.properties.size === 'number') {
10925
+ cell.size = combineSeries(typeof cell.size === 'number' ? cell.size : 0, f.properties.size, cell.n === 1);
10926
+ }
9955
10927
  // point_count is the aggregated-record count of a multi-record
9956
10928
  // cell (aggregateOnGrid; absent on a single-record cell)
9957
10929
  // — tracked separately from counts (the summed bound VALUE) purely
@@ -9965,7 +10937,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9965
10937
  geometry: { type: 'Point', coordinates: [ll.lng, ll.lat] },
9966
10938
  properties: { counts: cell.counts, total: cell.counts.reduce((a, c) => a + c, 0), recordCounts: cell.recordCounts, ...(cell.classTotal !== undefined ? { classTotal: cell.classTotal } : {}),
9967
10939
  ...(cell.raws && cell.raws.length ? { raws: cell.raws } : {}),
9968
- ...(cell.titleRaw ? { titleRaw: cell.titleRaw } : {}) }
10940
+ ...(cell.titleRaw ? { titleRaw: cell.titleRaw } : {}),
10941
+ ...(cell.series ? { series: cell.series } : {}),
10942
+ ...(typeof cell.size === 'number' ? { size: cell.size } : {}) }
9969
10943
  };
9970
10944
  });
9971
10945
  }
@@ -10136,17 +11110,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10136
11110
  // could ever need it (a plain "px" gridwidth, or an aggregation
10137
11111
  // array with only "px" entries, never reads this field).
10138
11112
  const gridwidthIsMeters = typeof this.style.gridwidth === 'string' ? !/px\s*$/i.test(this.style.gridwidth) : this.style.gridwidth != null;
10139
- if (gridwidthIsMeters || aggregationHasMetersEntry(this.style.aggregation)) {
10140
- let minLat = Infinity, maxLat = -Infinity;
10141
- this.features.forEach(f => {
10142
- const lat = f.geometry && f.geometry.coordinates && f.geometry.coordinates[1];
10143
- if (typeof lat === 'number' && !isNaN(lat)) {
10144
- if (lat < minLat) minLat = lat;
10145
- if (lat > maxLat) maxLat = lat;
10146
- }
10147
- });
10148
- this._gridRefLat = (minLat <= maxLat) ? (minLat + maxLat) / 2 : 0;
10149
- }
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();
10150
11116
 
10151
11117
  // a bar chart's parts are a pie's (its value fields or categories)
10152
11118
  if ((this._isPieChart() || this._isBarChart()) && this.binding.value) {
@@ -10233,6 +11199,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10233
11199
  let max = 0;
10234
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; }
10235
11201
  this._multiFieldPartMax = max || 1;
11202
+ // AUTO100's scale, measured on these features by _buildSequenceChartLayers
11203
+ this._seqAuto100Max = null;
10236
11204
  } else if (this.binding.value) {
10237
11205
  // real engine's OTHER coloring mode (maptheme.js distributeValues,
10238
11206
  // partsA): a NUMERIC bound value, not CATEGORICAL, is classed into
@@ -10369,6 +11337,33 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10369
11337
  // pure: resolveComposedColor → see "Classification — pure functions"
10370
11338
  _resolveComposedColor(props) { return resolveComposedColor(this, props); }
10371
11339
 
11340
+ // flat's DOPACITY value inside the COMPOSECOLOR branch (maptheme.js
11341
+ // 13489-13505): nMyValue = the item's raw weighted value sum /
11342
+ // ((field100/100) || 1), the max the color blend's own (nMax). The
11343
+ // raw weighted sum is reconstructed from the part values the field100
11344
+ // arithmetic left in the properties — those carry the arithmetic's own
11345
+ // multiplier (FRACTION ×fractionscale, PERMILLE ×1000, the default
11346
+ // percent ×100, see applyField100), which must be divided back out:
11347
+ // flat's nValueSum is the RAW weighted sum, whatever the flags.
11348
+ // (RELATIVE/INVERT subtract after the multiply — not reconstructable
11349
+ // from the parts; flat's own composed branch never pairs them.)
11350
+ _composedDopacityAlpha(props) {
11351
+ const fields = this._multiFields || [];
11352
+ const f100A = this.binding && this.binding.field100 ? String(this.binding.field100).split('|') : null;
11353
+ const multiplier = this.flags.has('FRACTION') ? (styleNum(this.style.fractionscale) || 1)
11354
+ : this.flags.has('PERMILLE') ? 1000 : 100;
11355
+ let sum = 0;
11356
+ fields.forEach((field, i) => {
11357
+ const part = parseFloat(props[field]);
11358
+ if (isNaN(part)) return;
11359
+ const v100 = f100A ? parseFloat(props[f100A[Math.min(i, f100A.length - 1)]]) : NaN;
11360
+ sum += isNaN(v100) ? part : (part * v100) / multiplier;
11361
+ });
11362
+ const v100_0 = f100A ? parseFloat(props[f100A[0]]) : NaN;
11363
+ const nMyValue = sum / ((v100_0 / 100) || 1);
11364
+ return composedDopacityAlpha(this.style, this.flags, nMyValue, this._composeColorMax || 1);
11365
+ }
11366
+
10372
11367
  // pure: buildPartsA → see "Classification — pure functions"
10373
11368
  _buildPartsA() { const r = computeRangeClasses(this.features, this.binding, this.style, this.flags, v => this._formatTooltipValue(v)); if (r) Object.assign(this, r); }
10374
11369
 
@@ -10395,6 +11390,14 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10395
11390
  _resolveAggregateValue(props) { return resolveAggregateValue(this.binding, this.flags, props); }
10396
11391
 
10397
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
+ });
10398
11401
  // an AGGREGATE / CATEGORICAL pie: one bucket, each record carrying its
10399
11402
  // contribution to its item (flatPieRecord), added up per position or
10400
11403
  // grid cell by aggregateOnGrid (flatPieAccumulate)
@@ -10404,9 +11407,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10404
11407
  const rules = flatValueRules(t, fields ? fields.length : 1);
10405
11408
  const sizeField = this.binding.size && this.binding.size !== '$item$' ? this.binding.size : null;
10406
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);
10407
11413
  for (const f of sourceFeatures) {
10408
- const c = f.geometry && f.geometry.coordinates;
10409
- if (!c || typeof c[0] !== 'number' || !isFinite(c[0]) || !isFinite(c[1])) continue;
10410
11414
  const p = f.properties || {};
10411
11415
  let cat;
10412
11416
  if (this._pieCategorical) {
@@ -10414,7 +11418,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10414
11418
  const i = k == null ? undefined : this._pieCategoryIndex.get(k);
10415
11419
  cat = i == null ? -1 : i;
10416
11420
  }
10417
- const pie = flatPieRecord(p, { rules, sizeField, cat, fields });
11421
+ const pie = flatPieRecord(p, { rules, sizeField, cat, fields, weights: pieWeightOf });
10418
11422
  if (!pie) continue;
10419
11423
  feats.push({ type: 'Feature', geometry: f.geometry, properties: { value: pie.size, pie, raw: p } });
10420
11424
  }
@@ -10437,11 +11441,18 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10437
11441
  // 7-class color once it knows each cell's actual aggregated total.
10438
11442
  if (this._multiFields && this._isMultiFieldSequence()) {
10439
11443
  // one bucket; each record carries its field values as the chart's
10440
- // parts. Its value — the draw order — is the first field's, as
10441
- // flat's chart node value (NYC status: value="63" = e-bikes)
11444
+ // parts — and, the same array, as `series` so that an AGGREGATE
11445
+ // theme's grid cells combine them PER FIELD (aggregateOnGrid), as
11446
+ // flat accumulates its per-field nValuesA (maptheme.js 10876-10889)
11447
+ // — plus its size-field value (flat's nValueSize, the item nSize
11448
+ // MAX/MIN/sum). Its value — the draw order — is the first field's,
11449
+ // as flat's chart node value (NYC status: value="63" = e-bikes)
11450
+ const sizeField = this.binding.size && this.binding.size !== '$item$' ? this.binding.size : null;
10442
11451
  this._featuresByCategory = [sourceFeatures.map(f => {
10443
11452
  const parts = this._multiFields.map(k => parseFloat(f.properties[k]) || 0);
10444
- return { type: 'Feature', geometry: f.geometry, properties: { value: parts[0], parts, raw: f.properties } };
11453
+ const size = sizeField ? Math.abs(parseFloat(f.properties[sizeField]) || 0) : undefined;
11454
+ return { type: 'Feature', geometry: f.geometry,
11455
+ properties: { value: parts[0], parts, series: parts, ...(size !== undefined ? { size } : {}), raw: f.properties } };
10445
11456
  })];
10446
11457
  this._clusterIndices = null;
10447
11458
  this._clusterRadiusPx = null;
@@ -10466,7 +11477,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10466
11477
  const seriesFields = String(this.binding.value || '').split('|');
10467
11478
  this._seriesDifference = this.flags.has('DIFFERENCE') && seriesFields.length > 1 && !this._aggregateField100;
10468
11479
  const seriesOf = props => seriesFields.map(k => parseFloat(props[k]) || 0);
10469
- 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];
10470
11482
  this._featuresByCategory = [sourceFeatures.map(f => ({
10471
11483
  type: 'Feature',
10472
11484
  geometry: f.geometry,
@@ -10824,6 +11836,20 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10824
11836
  });
10825
11837
  }
10826
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
+
10827
11853
  // (re)builds the per-category GridAggregateIndex for the grid width
10828
11854
  // resolved at the given zoom, only when that width changed. Grid width,
10829
11855
  // as flat reads it (maptheme.js:1892-1908, 7990-8008): gridwidthpx and
@@ -10834,6 +11860,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10834
11860
  // style.aggregation bracket; none → no grid (exact positions).
10835
11861
  _ensureClusterIndices(zoom) {
10836
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();
10837
11866
  // GL-PORT COMPAT: a real-ixmaps-flat page's own gridwidth is
10838
11867
  // sometimes a fixed SCREEN-PIXEL size (e.g. style.gridwidth:"3px",
10839
11868
  // confirmed against a real ported page) rather than this engine's
@@ -10880,6 +11909,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10880
11909
  this._clusterUsesFixedZoom = false;
10881
11910
  }
10882
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;
10883
11915
  this._clusterRadiusPx = radiusPx;
10884
11916
  this._clusterField = aggField;
10885
11917
  // after aggregation, on the cell sums: field100 of a value-based
@@ -10888,7 +11920,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10888
11920
  const post = this._seriesDifference
10889
11921
  ? (v, v100, series) => (series ? cellAggregatedValues({ sums: series, counts: series.map(() => 1) }, this.flags, this.style)[0] : v)
10890
11922
  : this._aggregateField100 ? (v, v100) => aggregateField100(v, v100, this.flags, this.style) : undefined;
10891
- this._clusterIndices = this._featuresByCategory.map(feats => new GridAggregateIndex(radiusPx, this.flags, post, aggField).load(feats));
11923
+ // flat's weights multiply every record's value fields as they are
11924
+ // accumulated (maptheme.js 10506-10508), before the sums' field100
11925
+ // arithmetic above — a weighted SUM, not a sum weighted afterward
11926
+ const weightOf = fieldWeightOf(this.style, (this._multiFields || [this.binding && this.binding.value]).length, true);
11927
+ this._clusterIndices = this._featuresByCategory.map(feats =>
11928
+ new GridAggregateIndex(radiusPx, this.flags, post, aggField).load(weightOf ? feats.map(f => weightedRecord(f, weightOf)) : feats));
10892
11929
  }
10893
11930
 
10894
11931
  // Reverted from the source-accurate two-flat-circle GLOW (radius*6
@@ -11139,6 +12176,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11139
12176
  const raw = props.raw || props.titleRaw;
11140
12177
  const title = this.binding.title && raw && raw[this.binding.title] != null ? String(raw[this.binding.title]) : '';
11141
12178
  const aggregated = !!(props.counts || props.cluster);
12179
+ // MAX / MIN aggregation: the item IS one defined record — the one
12180
+ // holding the max (min) — so it carries that record's real title;
12181
+ // "(n)" is the label of a summed/mean aggregate
12182
+ if (this.flags.has('MAX') || this.flags.has('MIN')) return title;
11142
12183
  if (aggregated && this._clusterRadiusPx && !this._clusterField) {
11143
12184
  const n = groupRecordCount(props);
11144
12185
  if (!title || n > 3) return '(' + n + ')';
@@ -11471,7 +12512,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11471
12512
  // companion layer) takes the uniform mesh shortcut.
11472
12513
  if (this.flags.has('GRIDSIZE') && this.flags.has('PLOT')) return this._buildPlotLayers(zoom, bbox);
11473
12514
  // PLOT without GRIDSIZE: one line chart per item over its value fields
11474
- if (this.flags.has('PLOT') && this._isItemPlot()) return this._buildItemPlotLayers(zoom);
12515
+ if (this.flags.has('PLOT') && this._isItemPlot()) return this._buildItemPlotLayers(zoom, liveZoom);
11475
12516
  // a plain grid (one color, no values or glow — the Germany page's
11476
12517
  // companion "grid" layer) as one square per cell; a classed GRIDSIZE
11477
12518
  // chart (the fuel-price page) is a symbol chart sized to the grid,
@@ -11498,6 +12539,15 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11498
12539
  // unmatched polygon, so the donor's fill serves no purpose once
11499
12540
  // superseded.
11500
12541
  if (this._isChoroplethGeometryDonor) return [];
12542
+ // flat's WMS theme (one server image over the view) renders here as
12543
+ // a native MapLibre raster source+layer managed by syncWmsLayers —
12544
+ // not as deck.gl layers — so its own build returns nothing
12545
+ if (this.flags.has('WMS') || this.flags.has('IMAGE')) return [];
12546
+ // flat's POSITION theme (maptheme.js 8328-8353): loads its data and
12547
+ // draws nothing — it only exists as this layer's id→position provider
12548
+ // for the join (joinChartPositions reads its features, its binding.id
12549
+ // against the consumers' lookup key), so no renderer runs for it
12550
+ if (this.flags.has('POSITION')) return [];
11501
12551
  if (this.flags.has('FEATURE') || this.flags.has('FEATURES')) {
11502
12552
  return featuresHiddenByScale(this.style, liveZoom) ? [] : this._buildFeaturesLayers(liveZoom, bbox);
11503
12553
  }
@@ -11539,6 +12589,20 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11539
12589
  // each shape by its class), not one flat fill
11540
12590
  const byCategory = filled && this.flags.has('CATEGORICAL') && this.categoryIndexByLabel && this.binding.value
11541
12591
  && this.categoryColorsRgb && this.categoryColorsRgb.length;
12592
+ // a NUMERIC bound value classes every shape too (flat's
12593
+ // distributeValues → partsA; paintShape, maptheme.js 13991-14018,
12594
+ // paints each shape in `colorScheme[nIndex]` — a line as
12595
+ // fill:none;stroke:class-color, a polygon filled with it): the
12596
+ // TELRAAM speed segments, green→red by v85
12597
+ const byClass = !byCategory && this._rangeClassed && this.partsA
12598
+ && this.categoryColorsRgb && this.categoryColorsRgb.length
12599
+ // "$item$" (or a field list) is no numeric field: a plain FEATURE
12600
+ // layer keeps its one fill color
12601
+ && this.binding.value && this.binding.value !== '$item$' && !String(this.binding.value).includes('|');
12602
+ const classColorOf = f => {
12603
+ const cls = this._resolvePartsClass(parseFloat(f.properties && f.properties[this.binding.value]));
12604
+ return this.classRgb(cls);
12605
+ };
11542
12606
  const catAlpha = Math.round(255 * (styleNum(this.style.fillopacity) || 1));
11543
12607
  const nodataRgb = parseCssColor(this.style.nodatacolor) || [255, 255, 255];
11544
12608
  const shadow = filled && flatShadowOn(this.style, this.features.length, zoom)
@@ -11558,14 +12622,19 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11558
12622
  // flat puts fillopacity into the shape's fill-opacity only
11559
12623
  // (maptheme.js 13407, 14071-14078): the outline stays opaque
11560
12624
  // (a fill color's own alpha — rgba(…, 0.5) — times fillopacity)
11561
- ...(byCategory ? { updateTriggers: { getFillColor: JSON.stringify(this.categoryColorsRgb) } } : {}),
12625
+ ...(byCategory || byClass ? { updateTriggers: { getFillColor: JSON.stringify(this.categoryColorsRgb), getLineColor: JSON.stringify(this.categoryColorsRgb) } } : {}),
11562
12626
  getFillColor: byCategory
11563
12627
  ? f => {
11564
12628
  const cat = this._resolveClassIndex(f.properties[this.binding.value]);
11565
12629
  return [...(cat != null ? (this.categoryColorsRgb || [])[cat] || nodataRgb : nodataRgb).slice(0, 3), catAlpha];
11566
12630
  }
12631
+ : byClass ? f => [...classColorOf(f).slice(0, 3), catAlpha]
11567
12632
  : filled ? [...hexOrNamedToRgb(raw).slice(0, 3), Math.round(255 * cssColorAlpha(raw) * (styleNum(this.style.fillopacity) || 1))] : [0, 0, 0, 0],
11568
- getLineColor: this.style.linecolor
12633
+ getLineColor: byClass
12634
+ // flat's paintShape strokes a line shape with its class color
12635
+ // (fill:none;stroke:class-color), at stroke-opacity fillopacity
12636
+ ? f => [...classColorOf(f).slice(0, 3), catAlpha]
12637
+ : this.style.linecolor
11569
12638
  ? withAlpha(hexOrNamedToRgb(styleLineColor(this.style.linecolor)), cssColorAlpha(styleLineColor(this.style.linecolor)))
11570
12639
  : [130, 130, 130],
11571
12640
  lineWidthMinPixels: styleNum(this.style.linewidth) || 1,
@@ -11655,12 +12724,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11655
12724
  const isComposeColor = this.flags.has('COMPOSECOLOR');
11656
12725
  const baseOpacity = resolveFillOpacity(this.style, this.mapOptions, zoom);
11657
12726
  // DOPACITY needs a PER-FEATURE opacity, which deck.gl only supports
11658
- // via getFillColor's own alpha channel (its layer-level `opacity`
11659
- // prop is one number for every feature) — see _resolveDopacityAlpha.
11660
- // Not meaningful combined with COMPOSECOLOR (no single driving value
11661
- // per polygon there), matching the real engine's own separate
11662
- // DOPACITY/COMPOSECOLOR branches.
11663
- const dopacityActive = !isComposeColor &&
12727
+ // via the color accessors' own alpha channel (its layer-level
12728
+ // `opacity` prop is one number for every feature) — see
12729
+ // _resolveDopacityAlpha. COMPOSECOLOR runs DOPACITY too, with its own
12730
+ // formula on the item's value sum (flat maptheme.js 13485-13506,
12731
+ // composedDopacityAlpha) — it is only the DOMINANT/range
12732
+ // _resolveDopacityAlpha that has no single driving value there
12733
+ const dopacityActive =
11664
12734
  (this.flags.has('DOPACITY') || this.flags.has('DOPACITYMIN') || this.flags.has('DOPACITYMAX') || this.flags.has('DOPACITYMINMAX'));
11665
12735
  // AGGREGATE CATEGORICAL: every polygon's parts and dominant class
11666
12736
  // (aggregatedCategoricalClass), the biggest part per class over all
@@ -11703,7 +12773,15 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11703
12773
  return {
11704
12774
  type: 'Feature',
11705
12775
  geometry: f.geometry,
11706
- properties: { raw: f.properties, composedColor: this._resolveComposedColor(f.properties) }
12776
+ properties: {
12777
+ raw: f.properties, composedColor: this._resolveComposedColor(f.properties),
12778
+ // flat's DOPACITY inside the COMPOSECOLOR branch (maptheme.js
12779
+ // 13485-13506): opacity from the item's raw weighted value
12780
+ // sum over (field100/100), against the same nMax the color
12781
+ // blend normalizes by. The raw weighted sum is reconstructed
12782
+ // from the fractioned part values (raw = part × its field100)
12783
+ dopacityAlpha: dopacityActive ? this._composedDopacityAlpha(f.properties) : null
12784
+ }
11707
12785
  };
11708
12786
  }
11709
12787
  if (isDominant) {
@@ -11759,6 +12837,29 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11759
12837
  // deck.gl re-runs the color accessors only for new data or a changed trigger
11760
12838
  const colorKey = JSON.stringify([[...this._markedClasses], !!this._onMarksChanged, evidenceMode,
11761
12839
  this.categoryColorsRgb, fallbackRgb, classAlpha, lineRgb]);
12840
+ // flat paints a LINE shape's value color as its STROKE (paintShape on
12841
+ // a line shape, szShapeType line — its DOPACITY writes stroke-opacity
12842
+ // there too): a choropleth over line geometry, the Milano street lines,
12843
+ // shows the value color ON the line; a polygon keeps it as its fill
12844
+ const isLineGeometry = d => { const t = d.geometry && d.geometry.type; return t === 'LineString' || t === 'MultiLineString'; };
12845
+ const valueColorOf = d => {
12846
+ if (!hasData(d)) return [0, 0, 0, 0];
12847
+ const rgb = d.properties.composedColor
12848
+ || (d.properties.cat != null ? (this.categoryColorsRgb || [])[d.properties.cat] : null) || fallbackRgb;
12849
+ const evidence = unmarkedEvidence(d);
12850
+ if (evidence === 'hide') return [0, 0, 0, 0];
12851
+ if (evidence === 'gray') return [...grayOf(rgb), 90];
12852
+ const a = alphaOfClass(d.properties.cat);
12853
+ return d.properties.dopacityAlpha != null ? [...rgb.slice(0, 3), Math.round(d.properties.dopacityAlpha * a * 255)] : withAlpha(rgb, a);
12854
+ };
12855
+ // flat re-styles the SAME shape nodes the FEATURE base drew, so a line
12856
+ // choropleth keeps the base's own line width — the donor's linewidth
12857
+ // is the fallback when the choropleth has none of its own
12858
+ const donorRt = [..._globalThemeRegistry.values()].find(r => r.name === this.name && r._isChoroplethGeometryDonor);
12859
+ const anyLine = collection.features.some(isLineGeometry);
12860
+ const isolating = !!(this._markedClasses.size && !this._onMarksChanged && evidenceMode === 'isolate');
12861
+ const lineW = styleNum(this.style.linewidth)
12862
+ || (anyLine && donorRt && styleNum(donorRt.style.linewidth)) || 1;
11762
12863
  return [new GeoJsonLayer({
11763
12864
  id: this._dataLayerId(`ix-choropleth-${this.name}`),
11764
12865
  data: collection,
@@ -11766,21 +12867,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11766
12867
  pickable: true,
11767
12868
  stroked: true,
11768
12869
  filled: true,
11769
- getFillColor: d => {
11770
- if (!hasData(d)) return [0, 0, 0, 0];
11771
- const rgb = d.properties.composedColor
11772
- || (d.properties.cat != null ? (this.categoryColorsRgb || [])[d.properties.cat] : null) || fallbackRgb;
11773
- const evidence = unmarkedEvidence(d);
11774
- if (evidence === 'hide') return [0, 0, 0, 0];
11775
- if (evidence === 'gray') return [...grayOf(rgb), 90];
11776
- const a = alphaOfClass(d.properties.cat);
11777
- return d.properties.dopacityAlpha != null ? [...rgb.slice(0, 3), Math.round(d.properties.dopacityAlpha * a * 255)] : withAlpha(rgb, a);
11778
- },
11779
- // a per-polygon accessor only while a class is isolated
11780
- getLineColor: this._markedClasses.size && !this._onMarksChanged && evidenceMode === 'isolate'
11781
- ? d => (unmarkedEvidence(d) === 'hide' ? [0, 0, 0, 0] : lineRgb)
11782
- : lineRgb,
11783
- lineWidthMinPixels: styleNum(this.style.linewidth) || 1,
12870
+ getFillColor: d => valueColorOf(d),
12871
+ // a constant unless a shape needs its own color (line geometry, or a
12872
+ // class isolated) — deck.gl then skips a per-item accessor call
12873
+ getLineColor: !anyLine && !isolating ? lineRgb
12874
+ : d => choroplethOutlineColor(isLineGeometry(d), valueColorOf, d, isolating, unmarkedEvidence, lineRgb),
12875
+ lineWidthMinPixels: lineW,
11784
12876
  // per-feature alpha (baked above) already carries the resolved
11785
12877
  // base opacity — a layer-level opacity on TOP of that would
11786
12878
  // multiply it a second time.
@@ -11817,6 +12909,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11817
12909
  const categories = this._plotCategories();
11818
12910
 
11819
12911
  const cells = new Map();
12912
+ const combine = aggCombiner(this.flags);
11820
12913
  source.forEach(f => {
11821
12914
  const ci = this._gridCategoryIndex(f.properties[this.binding.value]);
11822
12915
  if (ci == null) return;
@@ -11834,7 +12927,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11834
12927
  cells.set(key, cell);
11835
12928
  }
11836
12929
  const v = this._resolveAggregateValue(f.properties);
11837
- cell.sums[ci] += v;
12930
+ cell.sums[ci] = combine(cell.sums[ci], v, cell.counts[ci] === 0);
11838
12931
  cell.counts[ci]++;
11839
12932
  });
11840
12933
 
@@ -11872,8 +12965,14 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11872
12965
  // a per-item SEQUENCE chart: CHART|SYMBOL|SEQUENCE, several value
11873
12966
  // fields per item, one part per field (no CATEGORICAL, no AGGREGATE)
11874
12967
  _isMultiFieldSequence() {
12968
+ // AGGREGATE is allowed: flat's loadAndAggregateValuesOfTheme
12969
+ // aggregates a multi-field SEQUENCE theme per FIELD (its per-field
12970
+ // nValuesA, MAX/MIN per field, else summed — maptheme.js 10876-10889)
12971
+ // and AUTO100 then normalizes every item's parts to % of their own
12972
+ // sum (12166-12176) — the parts ride the grid cells as `series` and
12973
+ // the per-item % is applied in _buildSequenceChartLayers
11875
12974
  return this.flags.has('CHART') && this.flags.has('SYMBOL') && flatFlag(this.flags, 'SEQUENCE')
11876
- && !this.flags.has('CATEGORICAL') && !this.flags.has('AGGREGATE') && !this.flags.has('PLOT')
12975
+ && !this.flags.has('CATEGORICAL') && !this.flags.has('PLOT')
11877
12976
  && String(this.binding.value || '').includes('|');
11878
12977
  }
11879
12978
  _itemPlotFields() {
@@ -11999,16 +13098,20 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11999
13098
  return explicit ? fields.map((f, i) => String(explicit[i] != null ? explicit[i] : f)) : fields;
12000
13099
  }
12001
13100
 
12002
- _buildItemPlotLayers(zoom) {
13101
+ _buildItemPlotLayers(zoom, liveZoom = zoom) {
12003
13102
  const fields = this._itemPlotFields();
12004
13103
  const items = [];
12005
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;
12006
13109
  for (const f of this.features) {
12007
13110
  const pos = itemAnchor(f.geometry);
12008
13111
  if (!pos) continue;
12009
13112
  const values = fields.map(k => parseFloat(f.properties[k]));
12010
13113
  for (const v of values) if (!isNaN(v)) { dMin = Math.min(dMin, v); dMax = Math.max(dMax, v); }
12011
- 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 } });
12012
13115
  }
12013
13116
  if (!items.length) return [];
12014
13117
  const sMin = styleNum(this.style.minvalue), sMaxRaw = this.style.maxvalue;
@@ -12027,7 +13130,18 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12027
13130
  const S = 2 * NORMAL_RADIUS_PX * (styleNum(this.style.scale) || 1) * objectZoomFactor(zoom, this.mapOptions);
12028
13131
  const color = parseCssColor(Array.isArray(this.style.colorscheme) ? this.style.colorscheme[0] : this.style.colorscheme) || [0, 102, 204];
12029
13132
  const fillOpacity = styleNum(this.style.fillopacity);
12030
- 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({
12031
13145
  id: `ix-itemplot-${this.name}-g${this._iconGeneration}`,
12032
13146
  data: items, pickable: true,
12033
13147
  getPosition: d => d.geometry.coordinates,
@@ -12037,7 +13151,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12037
13151
  getSize: d => this._buildItemPlotIcon(d.properties.values, rangeFor(d.properties.values), sizeMax, color, fillOpacity).heightUnits * S,
12038
13152
  sizeUnits: 'pixels',
12039
13153
  updateTriggers: { getSize: [S] }
12040
- })];
13154
+ }), ...boxLayers.titles];
12041
13155
  }
12042
13156
 
12043
13157
  // canvas for one per-item PLOT chart (itemPlotGeometry), RASTER px per
@@ -12175,14 +13289,22 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12175
13289
  datasetMax = datasetMax || 1;
12176
13290
  if (isNaN(normalSizeValue)) normalSizeValue = datasetMax;
12177
13291
 
12178
- 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({
12179
13301
  id: `ix-plot-${this.name}-g${this._iconGeneration}`,
12180
13302
  data, pickable: true,
12181
13303
  getPosition: d => d.geometry.coordinates,
12182
13304
  getIcon: d => this._buildPlotIcon(d.properties.values, categories, labels, normalSizeValue, datasetMax, datasetMin),
12183
13305
  getSize: iconSize,
12184
13306
  sizeUnits: 'pixels'
12185
- })];
13307
+ }), ...boxLayers.titles];
12186
13308
  }
12187
13309
 
12188
13310
  // Canvas rendering for one cell's mini-chart — BOX background, AREA
@@ -12300,9 +13422,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12300
13422
  canvas.width = W; canvas.height = H;
12301
13423
  const ctx = canvas.getContext('2d');
12302
13424
 
12303
- // 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)
12304
13427
  const boxOpacity = styleNum(this.style.boxopacity);
12305
- if (!isNaN(boxOpacity) && boxOpacity > 0) {
13428
+ if (!flatFlag(this.flags, 'BOX') && !isNaN(boxOpacity) && boxOpacity > 0) {
12306
13429
  ctx.fillStyle = `rgba(255,255,255,${boxOpacity})`;
12307
13430
  ctx.fillRect(0, 0, W, H);
12308
13431
  }
@@ -12548,6 +13671,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12548
13671
  // async image decode, the whole drawing is synchronous
12549
13672
  const items = [];
12550
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');
12551
13678
  let html = '';
12552
13679
  for (const d of combined) {
12553
13680
  const value = valueOf(d);
@@ -12564,11 +13691,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12564
13691
  for (const id of chart.ids) {
12565
13692
  if (defsSeen.has(id)) continue;
12566
13693
  defsSeen.add(id);
12567
- const def = this._userChartGroup.ownerDocument.getElementById(id);
12568
- 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));
12569
13697
  }
12570
13698
  items.push({ d, coords: d.geometry.coordinates, ox: chart.ox, oy: chart.oy, s: pxPerUnit,
12571
- 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] });
12572
13705
  }
12573
13706
  if (glDebug()) console.info(`[ixmaps-gl debug] ${themeIdOf(this)}: zoom ${mapLibreToFlatZoom(liveZoom).toFixed(2)}, ${combined.length} charts, ${items.length} drawn${this._viewMoving ? ' (moving)' : ''}`);
12574
13707
  if (!items.length) {
@@ -12577,6 +13710,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12577
13710
  this._chartItems = null;
12578
13711
  return (this._userChartLastLayers = []);
12579
13712
  }
13713
+ chartHost.defs.appendChild(paneDefs);
13714
+ if (this._chartPaneDefs) this._chartPaneDefs.remove();
13715
+ this._chartPaneDefs = paneDefs;
12580
13716
  if (this._chartPaneGroup) this._chartPaneGroup.remove();
12581
13717
  this._chartPaneGroup = document.createElementNS(SVG_NS, 'g');
12582
13718
  chartHost.pane.appendChild(this._chartPaneGroup);
@@ -12585,11 +13721,24 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12585
13721
  for (let i = 0; i < items.length; i++) items[i].el = els[i];
12586
13722
  this._chartItems = items;
12587
13723
  chartHost.runtimes.add(this);
13724
+ this._chartHost = chartHost; // removeRuntimes takes it out again
12588
13725
  chartHost.update();
12589
13726
  // picking: a transparent circle per chart — the VISIBLE chart is the
12590
13727
  // live SVG in the pane; this deck layer only serves hover, click and
12591
13728
  // the tooltips (pickedFeature unwraps the {d, icon} entry shape)
12592
- 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({
12593
13742
  id: `ix-bubbles-${this.name}`,
12594
13743
  data: items.map(it => ({ d: it.d, icon: true, r: it.r })),
12595
13744
  pickable: true,
@@ -12598,7 +13747,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12598
13747
  radiusUnits: 'pixels', radiusMinPixels: 1,
12599
13748
  stroked: false, filled: true, getFillColor: [0, 0, 0, 0],
12600
13749
  parameters: ICON_LAYER_GLOBE_PARAMETERS
12601
- })];
13750
+ }), ...boxLayers.titles];
12602
13751
  return this._userChartLastLayers;
12603
13752
  }
12604
13753
  // the chart's SVG, cached by its draw parameters — a failing draw
@@ -12653,7 +13802,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12653
13802
  const vx = bb.x - pad, vy = bb.y - pad, vw = bb.width + 2 * pad, vh = bb.height + 2 * pad;
12654
13803
  // the chart's origin (0,0 — or the point flat moves it to, ret) sits at the map position
12655
13804
  const ox = (ret && ret.x) || 0, oy = (ret && ret.y) || 0;
12656
- 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 },
12657
13806
  svg: `<svg xmlns="${SVG_NS}" width="__W__" height="__H__" viewBox="${vx} ${vy} ${vw} ${vh}"><defs>${defs}</defs>${markup}</svg>` };
12658
13807
  }
12659
13808
  } catch (e) {
@@ -12666,6 +13815,187 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12666
13815
  return chart;
12667
13816
  }
12668
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
+
12669
13999
  // BOX (see the BOX block in _buildChartLayers): a rectangle icon
12670
14000
  // w×h px, rounded corners rx, stroke sw px; drawn at twice the size
12671
14001
  _buildBoxIcon(w, h, rx, sw, look) {
@@ -12684,8 +14014,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12684
14014
  ctx.arcTo(x, y + hh, x, y, rr); ctx.arcTo(x, y, x + ww, y, rr); ctx.closePath();
12685
14015
  };
12686
14016
  path();
12687
- ctx.globalAlpha = look.fillOpacity; ctx.fillStyle = look.fill; ctx.fill();
12688
- if (look.stroke && S > 0) { ctx.globalAlpha = look.strokeOpacity; ctx.strokeStyle = look.stroke; ctx.lineWidth = S * k; ctx.stroke(); }
14017
+ if (!isNoneColor(look.fill)) { ctx.globalAlpha = look.fillOpacity; ctx.fillStyle = look.fill; ctx.fill(); }
14018
+ if (look.stroke && !isNoneColor(look.stroke) && S > 0) { ctx.globalAlpha = look.strokeOpacity; ctx.strokeStyle = look.stroke; ctx.lineWidth = S * k; ctx.stroke(); }
12689
14019
  // getSize is the icon's height: the padding is part of it
12690
14020
  const icon = { url: canvas.toDataURL(), width: canvas.width, height: canvas.height, anchorX: canvas.width / 2, anchorY: canvas.height / 2, id: key };
12691
14021
  return this._cacheIcon(key, icon);
@@ -12836,7 +14166,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12836
14166
  const groups = doRelocate ? this._groupCoLocated(clusterFeatures, effectiveZoom) : clusterFeatures.map(f => {
12837
14167
  return {
12838
14168
  geometry: f.geometry,
12839
- 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,
12840
14174
  ...(f.properties.classValue !== undefined ? { classTotal: f.properties.classValue } : {}),
12841
14175
  ...(f.properties.titleRaw ? { titleRaw: f.properties.titleRaw } : {}) }
12842
14176
  };
@@ -12936,7 +14270,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12936
14270
  const rangeSource = this._classSeparate && this.binding.size && !normalize ? rawSizeTotals : totals;
12937
14271
  let nMin = Infinity, nMax = -Infinity;
12938
14272
  for (const v of rangeSource) { if (v < nMin) nMin = v; if (v > nMax) nMax = v; }
12939
- breaks = flatRangeParts(totals, nMin, nMax, nParts, this.flags, styleNum(this.style.rangecentervalue));
14273
+ const explicit = explicitRangeBreaks(this.style, this.flags);
14274
+ breaks = explicit ? explicitRangeParts(explicit)
14275
+ : flatRangeParts(totals, nMin, nMax, nParts, this.flags, styleNum(this.style.rangecentervalue));
12940
14276
  }
12941
14277
 
12942
14278
  // The theme's own classes follow the aggregated items, as flat's do
@@ -13253,15 +14589,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13253
14589
  // DTEXT: the title font follows the chart, max(0.9u, min(W/5,
13254
14590
  // 14u / scale)) (18815) — measured on the fire areas page's SIF chart
13255
14591
  // (W 362 units → font 72.35).
13256
- const boxShown = flatFlag(this.flags, 'BOX') && !boxHiddenByScale(this.style, liveZoom);
13257
- const circular = flatFlag(this.flags, 'CIRCULAR');
13258
14592
  const unitPx = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(this.style.scale) || 1);
13259
- const boxMarginU = styleNum(this.style.boxmargin) || 2;
13260
- const titleShown = boxShown && flatFlag(this.flags, 'TITLE') && !!this.binding.title;
13261
- const titleScale = styleNum(this.style.textscale) || styleNum(this.style.valuescale) || 1;
13262
- const titleFontOf = W => (flatFlag(this.flags, 'DTEXT')
13263
- ? Math.max(0.9 * unitPx, Math.min(W / 5, 14 * unitPx / (styleNum(this.style.scale) || 1)))
13264
- : 5 * unitPx) * titleScale;
13265
14593
  // the chart's value text and label (see VALUES and LABEL below), shared
13266
14594
  // with the box: flat's box encloses whatever the chart group draws
13267
14595
  const valuesShown = this.flags.has('VALUES') && !valuesHiddenByScale(this.style, zoom);
@@ -13269,144 +14597,35 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13269
14597
  const labelShown = !!this.style.labelfield && !flatFlag(this.flags, 'NORMSIZE') && !flatFlag(this.flags, 'ZOOM')
13270
14598
  && !labelHiddenByScale(this.style, zoom);
13271
14599
  const labelOf = labelShown ? this._chartLabelFn(unitPx) : null;
13272
- // flat: fill-opacity (boxopacity || 1) on the raw style value — the
13273
- // usual string "0" is transparent; typed here, a given 0 is too
13274
- const boxOpacity = isNaN(styleNum(this.style.boxopacity)) ? 1 : styleNum(this.style.boxopacity);
13275
- const bottomTitle = flatFlag(this.flags, 'BOTTOMTITLE');
13276
- const alignRight = /right/.test(String(this.style.align || ''));
13277
- // with MULTIQUAD only the first item of a position gets its box/title
13278
- const boxLayout = boxShown ? combined.filter(d => !multi || multi.index.get(d) === 0).map(d => {
13279
- const r = vRadius(sizeValueOf(d));
13280
- const ext = multi ? multi.extent.get(d) : [0, 0, 0, 0];
13281
- const chart = [ext[0] - r, ext[1] - r, ext[2] + r, ext[3] + r];
13282
- 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]); };
13283
- if (valueTextOf && !d.properties.counts) { const v = valueTextOf(d, r); if (v) grow(v.extent); }
13284
- if (labelOf) { const l = labelOf(d, r); if (l) grow(l.extent); }
13285
- const w = chart[2] - chart[0];
13286
- const margin = Math.min(2 * boxMarginU * unitPx, boxMarginU * w / 30);
13287
- const boxR = circular ? r * 1.1 + margin : r;
13288
- const titleFontPx = titleFontOf(w);
13289
- let title = null;
13290
- if (titleShown) {
13291
- const text = this._itemTitle(d.properties);
13292
- if (text) {
13293
- const f = titleFontPx;
13294
- const lines = wrapTitleLines(text, f, circular ? boxR * 2 : w).split('\n');
13295
- const width = Math.max(...lines.map(l => titleTextWidth(l, f)));
13296
- // first baseline; lines go down one font each
13297
- const base = circular
13298
- ? (bottomTitle ? boxR + f : -boxR - 0.7 * f - (lines.length - 1) * f)
13299
- : (bottomTitle ? chart[3] + f : chart[1] - 0.7 * f - (lines.length - 1) * f);
13300
- const x = circular ? unitPx / 2 : (alignRight ? chart[2] : chart[0]);
13301
- const anchor = circular ? 'middle' : (alignRight ? 'end' : 'start');
13302
- const x0 = anchor === 'middle' ? x - width / 2 : anchor === 'end' ? x - width : x;
13303
- // arial: ascent 0.905, descent 0.212 of the font size
13304
- title = { text: lines.join('\n'), x, anchor, top: base - 0.8 * f, f,
13305
- extent: [x0, base - 0.905 * f, x0 + width, base + (lines.length - 1) * f + 0.212 * f] };
13306
- }
13307
- }
13308
- let rect = null;
13309
- if (!circular) {
13310
- const e = chart.slice();
13311
- // with a title flat trims 0.1 font off the top of chart + title
13312
- 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]); }
13313
- 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;
13314
14613
  }
13315
- 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}` }
13316
14620
  }) : null;
13317
- if (boxShown && circular) {
13318
- const boxRgb = hexOrNamedToRgb(this.style.boxcolor || '#eeeeee');
13319
- const boxAlpha = Math.round(255 * boxOpacity);
13320
- const borderRgb = hexOrNamedToRgb(this.style.bordercolor || '#dddddd');
13321
- // ScatterplotLayer has no pixel offset: a moved chart (align,
13322
- // offsetx/offsety) gets its circle as an icon
13323
- if (alignOf.active) layers.push(new IconLayer({
13324
- id: `ix-box-${this.name}-g${this._iconGeneration}`,
13325
- data: boxLayout, pickable: false,
13326
- getPosition: b => b.d.geometry.coordinates,
13327
- getIcon: b => this._buildSingleIcon(boxRgb, boxAlpha / 255, 'circle', borderRgb,
13328
- Math.round(unitPx / 20 * BUBBLE_ICON_SIZE / Math.max(1, 2 * b.boxR) * 4) / 4),
13329
- getSize: b => b.boxR * 2,
13330
- getPixelOffset: b => alignOf(b.d),
13331
- sizeUnits: 'pixels',
13332
- billboard: true,
13333
- parameters: ICON_LAYER_GLOBE_PARAMETERS
13334
- }));
13335
- else layers.push(new ScatterplotLayer({
13336
- id: `ix-box-${this.name}`,
13337
- data: boxLayout, pickable: false,
13338
- getPosition: b => b.d.geometry.coordinates,
13339
- getRadius: b => b.boxR,
13340
- radiusUnits: 'pixels',
13341
- stroked: true,
13342
- getFillColor: [...boxRgb.slice(0, 3), boxAlpha],
13343
- getLineColor: [...borderRgb.slice(0, 3), 255],
13344
- getLineWidth: unitPx / 20,
13345
- lineWidthUnits: 'pixels',
13346
- parameters: ICON_LAYER_GLOBE_PARAMETERS
13347
- }));
13348
- } else if (boxShown) {
13349
- const borderStyle = String(this.style.borderstyle || '');
13350
- const boxLook = {
13351
- fill: this.style.boxcolor || 'white',
13352
- fillOpacity: boxOpacity,
13353
- stroke: borderStyle === 'none' ? null : styleLineColor(this.style.bordercolor) || '#bbbbbb',
13354
- strokeOpacity: /^(solid|dotted|dashed)$/.test(borderStyle) ? 1 : 0.3,
13355
- };
13356
- const borderWidth = styleNum(this.style.borderwidth) || 1;
13357
- const borderRadius = styleNum(this.style.borderradius);
13358
- const strokeOf = b => borderWidth * 0.2 * b.w / 30;
13359
- // a box larger than BOX_ICON_MAX_PX (a MULTIQUAD grid of thousands
13360
- // of items) would need a huge canvas: it is drawn as a polygon in
13361
- // map coordinates instead (square corners)
13362
- const bigBox = b => Math.max(b.rect[2] - b.rect[0], b.rect[3] - b.rect[1]) > BOX_ICON_MAX_PX;
13363
- const iconBoxes = boxLayout.filter(b => !bigBox(b)), polygonBoxes = boxLayout.filter(bigBox);
13364
- iconBoxes.forEach(b => {
13365
- b.icon = this._buildBoxIcon(b.rect[2] - b.rect[0], b.rect[3] - b.rect[1],
13366
- isNaN(borderRadius) ? 0 : borderRadius * Math.min(unitPx, b.w / 30),
13367
- strokeOf(b), boxLook);
13368
- });
13369
- if (polygonBoxes.length) {
13370
- const fillRgb = hexOrNamedToRgb(boxLook.fill);
13371
- const strokeRgb = boxLook.stroke ? hexOrNamedToRgb(boxLook.stroke) : null;
13372
- layers.push(new GeoJsonLayer({
13373
- id: `ix-box-polygons-${this.name}`,
13374
- data: polygonBoxes.map(b => {
13375
- const o = alignOf(b.d), c = b.d.geometry.coordinates, r = b.rect;
13376
- const at = (x, y) => pixelOffsetLngLat(c, [x + o[0], y + o[1]], liveZoom);
13377
- return { type: 'Feature', properties: { stroke: strokeOf(b) },
13378
- 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])]] } };
13379
- }),
13380
- pickable: false,
13381
- filled: boxLook.fillOpacity > 0,
13382
- stroked: !!strokeRgb,
13383
- getFillColor: [...fillRgb.slice(0, 3), Math.round(255 * boxLook.fillOpacity)],
13384
- getLineColor: strokeRgb ? [...strokeRgb.slice(0, 3), Math.round(255 * boxLook.strokeOpacity)] : [0, 0, 0, 0],
13385
- getLineWidth: f => f.properties.stroke,
13386
- lineWidthUnits: 'pixels',
13387
- parameters: ICON_LAYER_GLOBE_PARAMETERS
13388
- }));
13389
- }
13390
- if (iconBoxes.length) layers.push(new IconLayer({
13391
- id: `ix-box-${this.name}-g${this._iconGeneration}`,
13392
- data: iconBoxes, pickable: false,
13393
- getPosition: b => b.d.geometry.coordinates,
13394
- getIcon: b => b.icon,
13395
- // the icon is drawn at 2× with a stroke padding around the box
13396
- getSize: b => b.icon.height / 2,
13397
- 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]]; },
13398
- sizeUnits: 'pixels',
13399
- billboard: true,
13400
- parameters: ICON_LAYER_GLOBE_PARAMETERS
13401
- }));
13402
- }
14621
+ if (boxLayers) layers.push(...boxLayers.box);
13403
14622
 
13404
14623
  // flat's per-part SYMBOL branch (everything but CATEGORICAL without
13405
- // AGGREGATE/GROUP) draws a square of side 2r, the other one 1.6r
13406
- // (drawSymbolPath's own square)
14624
+ // AGGREGATE/GROUP) draws a square or roundrect of side 2r, the other
14625
+ // one 1.6r (drawSymbolPath's own square / roundrect)
13407
14626
  const perPartBranch = flatChartBranch(this.flags) === 'symbol'
13408
14627
  && !(this.flags.has('CATEGORICAL') && !this.flags.has('AGGREGATE') && !flatFlag(this.flags, 'GROUP'));
13409
- const shapeOf = props => { const sh = this._resolveSymbolShape(props); return sh === 'square' && perPartBranch ? 'squarefull' : sh; };
14628
+ const shapeOf = props => { const sh = this._resolveSymbolShape(props); return perPartBranch && (sh === 'square' || sh === 'roundrect') ? sh + 'full' : sh; };
13410
14629
  // a range-classed cell is ONE class: one symbol in its class color,
13411
14630
  // fillopacity and linecolor, as a single item (flat draws one
13412
14631
  // symbol) — the packed multi-category icon is for CATEGORICAL cells
@@ -13507,29 +14726,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13507
14726
  parameters: ICON_LAYER_GLOBE_PARAMETERS
13508
14727
  }));
13509
14728
 
13510
- const titleData = boxLayout ? boxLayout.filter(b => b.title) : [];
13511
- if (titleData.length) {
13512
- layers.push(new TextLayer({
13513
- id: `ix-titles-${this.name}`,
13514
- data: titleData, pickable: false,
13515
- getPosition: b => b.d.geometry.coordinates,
13516
- getText: b => b.title.text,
13517
- getSize: b => b.title.f,
13518
- sizeUnits: 'pixels',
13519
- fontFamily: 'arial',
13520
- characterSet: 'auto',
13521
- lineHeight: 1,
13522
- getColor: [...hexOrNamedToRgb(this.style.textcolor || '#888888').slice(0, 3), 255],
13523
- fontSettings: { sdf: true },
13524
- outlineWidth: 1 / 7,
13525
- outlineColor: [255, 255, 255, 128],
13526
- getTextAnchor: b => b.title.anchor,
13527
- // top of the text ≈ first baseline − 0.8 font
13528
- getAlignmentBaseline: 'top',
13529
- getPixelOffset: b => { const o = alignOf(b.d); return [b.title.x + o[0], b.title.top + o[1]]; },
13530
- parameters: ICON_LAYER_GLOBE_PARAMETERS
13531
- }));
13532
- }
14729
+ if (boxLayers) layers.push(...boxLayers.titles);
13533
14730
 
13534
14731
  // VALUES: bold value label centered on each bubble (see
13535
14732
  // flatValueText/valuesFontSizePx above). Labels are their own
@@ -13730,13 +14927,55 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13730
14927
  _buildSequenceChartLayers(charts, zoom, liveZoom) {
13731
14928
  const st = this.style;
13732
14929
  const multi = !!this._multiFields && this._isMultiFieldSequence();
13733
- const seqMax = multi ? (styleNum(st.normalsizevalue) || this._multiFieldPartMax) : this._sequenceMaxValue(zoom);
14930
+ // AGGREGATE + AUTO100 (flat normalizes the aggregated item's
14931
+ // nValuesA to % of their own sum, maptheme.js 12166-12176): every
14932
+ // item's parts in % of that item. Without AGGLEARATE, applyField100
14933
+ // already normalized the records' field values (auto100 there) —
14934
+ // this must not run twice
14935
+ const auto100 = multi && this.flags.has('AUTO100') && this.flags.has('AGGREGATE');
14936
+ // The value scale must be measured on the SAME scale the parts are
14937
+ // drawn in. With AGGREGATE, AUTO100 normalizes every item's parts to
14938
+ // % of their own sum at layout time (the records themselves stay
14939
+ // raw — applyField100 skips AGGREGATE themes), so the raw-features
14940
+ // _multiFieldPartMax (~1.0 here) is 100x off the drawn values and
14941
+ // would blow every part's radius up by sqrt(100). flat's own
14942
+ // nMaxValue fallback (nNormalSizeValue || nAllMaxValue,
14943
+ // maptheme.js 21796) measures nAllMaxValue on the NORMALIZED item
14944
+ // values (12166-12176), so the scale matches the parts. Without
14945
+ // AUTO100 the records were already normalized by applyField100 and
14946
+ // _multiFieldPartMax IS that scale (NYC status page)
14947
+ if (multi && auto100 && !this._seqAuto100Max) {
14948
+ let m = 0;
14949
+ for (const f of this.features) {
14950
+ const parts = this._multiFields.map(k => parseFloat(f.properties && f.properties[k]) || 0);
14951
+ const sum = parts.reduce((a, v) => a + v, 0);
14952
+ if (sum > 0) for (const v of parts) { const nv = v / (sum / 100); if (nv > m) m = nv; }
14953
+ }
14954
+ this._seqAuto100Max = m || 1;
14955
+ }
14956
+ const seqMax = multi
14957
+ ? (auto100 ? this._seqAuto100Max : (styleNum(st.normalsizevalue) || this._multiFieldPartMax))
14958
+ : this._sequenceMaxValue(zoom);
13734
14959
  const unit = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(st.scale) || 1);
13735
14960
  const normalR = NORMAL_RADIUS_PX * unit;
13736
14961
  const sizePow = resolveSizePow(st, this.flags);
13737
- const partRadius = multi
13738
- ? v => Math.max(VALUE_RADIUS_MIN, normalR * Math.pow(Math.abs(v) / seqMax, 1 / sizePow))
13739
- : v => valueRadius(v, liveZoom, st, this.mapOptions, this.flags, seqMax);
14962
+ // flat's size field on a SEQUENCE chart part (maptheme.js 21851-21874):
14963
+ // the radius carries BOTH the part's value AND the item's size —
14964
+ // nMaxRadius · (nValue/nMaxValue)^(1/p) · (nSize/nMaxSize)^(1/p),
14965
+ // nMaxSize the biggest item size (measured on the records; MAX, the
14966
+ // one combiner flat's star-part charts pair with a size field, keeps
14967
+ // the cells' max equal to the records' max)
14968
+ const sizeField = multi && this.binding.size && this.binding.size !== '$item$' ? this.binding.size : null;
14969
+ let sizeMax = 0;
14970
+ if (sizeField) {
14971
+ for (const f of this.features) { const v = Math.abs(parseFloat(f.properties && f.properties[sizeField]) || 0); if (v > sizeMax) sizeMax = v; }
14972
+ }
14973
+ const itemSizeFactor = d => {
14974
+ if (!sizeField) return 1;
14975
+ const sz = typeof d.properties.size === 'number' ? d.properties.size
14976
+ : Math.abs(parseFloat((d.properties.raw || {})[sizeField]) || 0);
14977
+ return sizeMax > 0 ? Math.pow(sz / sizeMax, 1 / sizePow) : 0;
14978
+ };
13740
14979
  const rings = flatFlag(this.flags, 'RINGS');
13741
14980
  const centerValues = flatFlag(this.flags, 'CENTERVALUES');
13742
14981
  const noFill = this._partNoFill || new Set();
@@ -13749,17 +14988,116 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13749
14988
  const borderRgb = st.linecolor && styleLineColor(st.linecolor) !== 'none' ? hexOrNamedToRgb(styleLineColor(st.linecolor)) : null;
13750
14989
  const lineWidth = styleNum(st.linewidth) || 1;
13751
14990
  const symbols = Array.isArray(st.symbols) ? st.symbols : null;
14991
+ // flat draws an external .svg/.png/.jpeg symbol file as an <image>
14992
+ // node (maptheme.js 22190-22199), the chart's own shape otherwise.
14993
+ // Here the same URL is the part's IconLayer icon — deck.gl loads
14994
+ // it itself (its ImageBitmap loader falls back to <img>, which
14995
+ // rasterizes SVG) — NOT tinted: these files carry their own colors,
14996
+ // and flat doesn't recolor an <image> either
14997
+ const urlSymbolOf = i => {
14998
+ const s = symbols && (symbols[i] || symbols[0]);
14999
+ return typeof s === 'string' && /\.(svg|png|jpe?g)([?#]|$)/i.test(s.trim()) ? s.trim() : null;
15000
+ };
15001
+ if (!this._urlIconCache) this._urlIconCache = new Map();
15002
+ const urlIcon = u => {
15003
+ if (!this._urlIconCache.has(u)) {
15004
+ // the placeholder is a transparent 1x1 PNG, NOT the raw symbol
15005
+ // URL: deck.gl would immediately try to load that itself, and an
15006
+ // SVG without natural dimensions (a viewBox only, the bus
15007
+ // pictogram of the City-moving page) makes Chrome's
15008
+ // createImageBitmap throw — a console error and a part that
15009
+ // never renders. The real raster replaces it below
15010
+ const entry = { url: TRANSPARENT_1PX_PNG, width: 256, height: 256, anchorX: 128, anchorY: 128, mask: false, id: 'seq-sym-pending:' + u };
15011
+ this._urlIconCache.set(u, entry);
15012
+ // fetch the symbol ONCE and rasterize it into the descriptor's
15013
+ // own 256px cell here: deck then always sees exactly the declared
15014
+ // dimensions, and dimension-less SVGs work like any other. The
15015
+ // redraw lets the next getIcon see the patched descriptor
15016
+ fetch(u).then(r => r.ok ? r.text() : null).then(text => new Promise((resolve, reject) => {
15017
+ if (typeof text !== 'string' || !/<svg[\s>]/i.test(text)) { reject(new Error('not svg')); return; }
15018
+ if (!/\swidth\s*=/i.test(text) || !/\sheight\s*=/i.test(text)) {
15019
+ const vb = text.match(/viewBox\s*=\s*["']([^"']*)["']/i);
15020
+ const p = vb && vb[1].trim().split(/[\s,]+/).map(Number);
15021
+ if (p && p.length === 4 && isFinite(p[2]) && p[2] > 0 && isFinite(p[3]) && p[3] > 0) {
15022
+ text = text.replace(/<svg/i, '<svg width="' + p[2] + '" height="' + p[3] + '"');
15023
+ }
15024
+ }
15025
+ const img = new Image();
15026
+ img.onload = () => resolve(img);
15027
+ img.onerror = () => reject(new Error('svg decode failed'));
15028
+ img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(text);
15029
+ })).then(img => {
15030
+ const size = entry.width;
15031
+ const canvas = document.createElement('canvas');
15032
+ canvas.width = size; canvas.height = size;
15033
+ const ctx = canvas.getContext('2d');
15034
+ const s = Math.min(size / (img.naturalWidth || size), size / (img.naturalHeight || size));
15035
+ const dw = (img.naturalWidth || size) * s, dh = (img.naturalHeight || size) * s;
15036
+ ctx.drawImage(img, (size - dw) / 2, (size - dh) / 2, dw, dh);
15037
+ entry.url = canvas.toDataURL('image/png');
15038
+ entry.id = 'seq-sym-raster:' + u;
15039
+ if (this._triggerRedraw) this._triggerRedraw();
15040
+ }).catch(() => {
15041
+ // not an SVG (a PNG/JPEG symbol): deck's own URL loading takes
15042
+ // it from here — and a pre-sized SVG that failed to rasterize
15043
+ // gets that same second chance
15044
+ entry.url = u;
15045
+ entry.id = 'seq-sym-natural:' + u;
15046
+ if (this._triggerRedraw) this._triggerRedraw();
15047
+ });
15048
+ }
15049
+ return this._urlIconCache.get(u);
15050
+ };
13752
15051
  const shapeOf = i => { const sh = symbols && (symbols[i] || symbols[0]); return normalizeSymbolShape(sh); };
13753
15052
  const colorOf = i => this.classRgb(i);
13754
- const parts = [], glows = [];
15053
+ const allParts = [], allGlows = [];
13755
15054
  for (const d of charts) {
13756
- const counts = d.properties.parts || d.properties.counts;
15055
+ // parts: the record's own field values (a per-item chart, or the
15056
+ // single-record cell of an aggregated one); series: a multi-record
15057
+ // AGGREGATE cell's per-field combination (aggregateOnGrid); counts:
15058
+ // the CATEGORICAL variant
15059
+ let counts = d.properties.parts || d.properties.series || d.properties.counts;
15060
+ if (!Array.isArray(counts)) continue;
15061
+ if (auto100) {
15062
+ const sum = counts.reduce((a, v) => a + (v || 0), 0);
15063
+ counts = counts.map(v => sum > 0 ? v / (sum / 100) : 0);
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
+ }
15077
+ const sizeFactor = itemSizeFactor(d);
15078
+ const partRadius = multi
15079
+ ? v => Math.max(VALUE_RADIUS_MIN, normalR * Math.pow(Math.abs(v) / seqMax, 1 / sizePow) * sizeFactor)
15080
+ : v => valueRadius(v, liveZoom, st, this.mapOptions, this.flags, seqMax);
13757
15081
  let layout = rings ? ringsLayout(counts, partRadius)
13758
15082
  : sequenceLayout(counts, this.flags, partRadius, { maxRadius: normalR, maxValue: seqMax, nParts: nCats, rangeScale: styleNum(st.rangescale) || 0 });
13759
15083
  if (!layout.length) continue;
13760
15084
  // GLOW belongs to the first part (sorted) or to every part, and
13761
15085
  // hides with it when its class is not marked
13762
- 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));
15093
+ // the chart's drawn extent (flat: map.Dom.getBox of its shape
15094
+ // group) — the parts' star cluster — for BOX / CIRCULARBOX sizing
15095
+ let ex0 = Infinity, ey0 = Infinity, ex1 = -Infinity, ey1 = -Infinity;
15096
+ layout.forEach(p => {
15097
+ ex0 = Math.min(ex0, p.x - p.r); ey0 = Math.min(ey0, p.y - p.r);
15098
+ ex1 = Math.max(ex1, p.x + p.r); ey1 = Math.max(ey1, p.y + p.r);
15099
+ });
15100
+ d._seqExtent = [ex0, ey0, ex1, ey1];
13763
15101
  if (marking && !gray) {
13764
15102
  layout = layout.filter(p => this._markedClasses.has(p.i)).map(p => ({ ...p, x: 0, y: 0 }));
13765
15103
  if (!layout.length) continue;
@@ -13768,101 +15106,286 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13768
15106
  d._seqLast = layout[layout.length - 1].r;
13769
15107
  const items = layout.map(p => {
13770
15108
  const item = { geometry: d.geometry, properties: d.properties, chart: d, part: p };
13771
- if (glowParts.has(p.i)) glows.push(item);
15109
+ if (glowParts.has(p.i)) allGlows.push(item);
13772
15110
  return item;
13773
15111
  });
13774
15112
  // STAR: the first part (its center) is put on top at the end (23145)
13775
15113
  if (star && items.length > 1) items.push(items.shift());
13776
- parts.push(...items);
15114
+ allParts.push(...items);
13777
15115
  }
13778
15116
  const alignOf = this._chartAlignFn(liveZoom, d => (d.chart || d)._seqLast || 0);
13779
15117
  const offsetOf = item => { const o = alignOf(item.chart); return [o[0] + item.part.x, o[1] + item.part.y]; };
13780
15118
  const alphaOf = item => (gray && !this._markedClasses.has(item.part.i) ? DIMMED_ICON_ALPHA : 255);
13781
15119
  const layers = [];
13782
- if (glows.length && this.flags.has('GLOW') && !glowHiddenByScale(st, liveZoom)) {
13783
- layers.push(new IconLayer({
13784
- id: `ix-glow-${this.name}-g${this._iconGeneration}`,
13785
- data: glows, pickable: false,
13786
- getPosition: d => d.geometry.coordinates,
13787
- getIcon: d => this._getGlowIcon(colorOf(d.part.i)),
13788
- getSize: d => d.part.r * 11,
13789
- getPixelOffset: offsetOf,
13790
- getColor: d => [255, 255, 255, alphaOf(d)],
13791
- sizeUnits: 'pixels', billboard: true,
13792
- parameters: ICON_LAYER_GLOBE_PARAMETERS
13793
- }));
13794
- } else if (glows.length && this.flags.has('AURA') && !glowHiddenByScale(st, liveZoom)) {
15120
+ // BOX / CIRCULARBOX with TITLE / BOTTOMTITLE — flat's own box block
15121
+ // (maptheme.js 18757-18925) on a SEQUENCE chart's extent: the star
15122
+ // cluster of parts (d._seqExtent), not one bubble's radius.
15123
+ // - CIRCULARBOX: a circle of max(w,h)/2 · 1.1 + margin, margin
15124
+ // min(2m, m·w/30u), m = boxmargin (2) · u; boxcolor (#eeeeee) at
15125
+ // boxopacity, stroke bordercolor (#dddddd) one SVG unit (u/20)
15126
+ // - the title (TITLE / BOTTOMTITLE) in textcolor (#888888) over
15127
+ // the half-opaque white halo, centered under the circle
15128
+ // (BOTTOMTITLE: one font below its bottom edge), DTEXT font
15129
+ // max(0.9u, min(w/5, 14u/scale)) · textscale, wrapped to the
15130
+ // circle's width — gated by titleupper (shown while the map
15131
+ // scale is that bound or coarser, 18810) and boxupper/boxlower
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)
15136
+ const circular = flatFlag(this.flags, 'CIRCULAR');
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}` }
15200
+ });
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)],
15212
+ sizeUnits: 'pixels', billboard: true,
15213
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
15214
+ }));
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,
15225
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
15226
+ }));
15227
+ }
13795
15228
  layers.push(new IconLayer({
13796
- id: `ix-aura-${this.name}-g${this._iconGeneration}`,
13797
- data: glows, pickable: false,
15229
+ id: `ix-bubbles-${this.name}${bs}-g${this._iconGeneration}`,
15230
+ data: parts, pickable: true,
13798
15231
  getPosition: d => d.geometry.coordinates,
13799
- getIcon: d => this._buildSingleIcon(borderRgb || colorOf(d.part.i), 0.3, 'circle', null, 0),
13800
- 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,
13801
15243
  getPixelOffset: offsetOf,
13802
15244
  getColor: d => [255, 255, 255, alphaOf(d)],
13803
15245
  sizeUnits: 'pixels', billboard: true,
13804
15246
  parameters: ICON_LAYER_GLOBE_PARAMETERS
13805
15247
  }));
13806
- }
13807
- layers.push(new IconLayer({
13808
- id: `ix-bubbles-${this.name}-g${this._iconGeneration}`,
13809
- data: parts, pickable: true,
13810
- getPosition: d => d.geometry.coordinates,
13811
- // the stroke, in the icon raster's pixels (the icon is drawn at 2r)
13812
- getIcon: d => this._buildSingleIcon(colorOf(d.part.i), noFill.has(d.part.i) ? 0 : fillOpacity, shapeOf(d.part.i), borderRgb,
13813
- 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),
13814
- getSize: d => d.part.r * 2,
13815
- getPixelOffset: offsetOf,
13816
- getColor: d => [255, 255, 255, alphaOf(d)],
13817
- sizeUnits: 'pixels', billboard: true,
13818
- parameters: ICON_LAYER_GLOBE_PARAMETERS
13819
- }));
13820
- // VALUES on each part (maptheme.js 22188-22250): the part's value — with
13821
- // COUNT its record count, with valuefield on the categorical field the
13822
- // class name — formatted with valuedecimals (else none) plus the unit;
13823
- // font min(1, 3.2 / L) · r · valuescale, L the length of the theme's
13824
- // biggest value text (szMaxText), normal weight, in valuecolor /
13825
- // textcolor, else ChartColors.textColor of the part color.
13826
- // CENTERVALUES: only on the first part (class 0), its own value
13827
- if ((this.flags.has('VALUES') || centerValues) && !valuesHiddenByScale(st, zoom)) {
13828
- const valueScale = styleNum(st.valuescale) || 1;
13829
- const { noBreaks } = this._valueTextOpts();
13830
- const units = st.units ? String(st.units) : '';
13831
- const unit = units ? (units[0] === '.' ? '' : ' ') + units : '';
13832
- const unitText = unit.length <= 5 ? unit : '';
13833
- const dec = explicitValueDecimals(st) || 0;
13834
- const fmt = v => flatGroupedValue(v, dec, noBreaks) + unitText;
13835
- const maxLen = fmt(seqMax).length;
13836
- const textOverride = st.valuecolor || st.textcolor;
13837
- const counting = flatFlag(this.flags, 'COUNT');
13838
- const labels = [];
13839
- parts.forEach(d => {
13840
- if (centerValues && d.part.i !== 0) return;
13841
- const rec = this._categoryValueRecord(d.part.i);
13842
- const name = rec ? String(rec[st.valuefield]) : null;
13843
- const t = counting && d.properties.recordCounts ? d.properties.recordCounts[d.part.i] : d.part.v;
13844
- const text = name != null ? name : fmt(t);
13845
- const L = Math.max((name != null ? name.length : 0) + 1, maxLen);
13846
- const fontSize = Math.min(1, 3.2 / L) * d.part.r * valueScale;
13847
- if (fontSize > VALUES_MIN_FONT_PX) {
13848
- labels.push({ geometry: d.geometry, text, fontSize, pixelOffset: offsetOf(d), alpha: alphaOf(d),
13849
- color: textOverride ? hexOrNamedToRgb(textOverride) : flatChartTextRgb(colorOf(d.part.i)) });
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
+ }
15323
+ }
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
+ }));
15349
+ }
15350
+ if (plainLabels.length) layers.push(new TextLayer({
15351
+ id: `ix-cluster-values-plain-${this.name}${bs}`,
15352
+ data: plainLabels, pickable: false,
15353
+ getPosition: d => d.geometry.coordinates,
15354
+ getText: d => d.text,
15355
+ getSize: d => d.fontSize,
15356
+ getColor: d => [...d.color.slice(0, 3), d.alpha],
15357
+ getPixelOffset: d => d.pixelOffset,
15358
+ sizeUnits: 'pixels', fontFamily: 'arial', fontWeight: 'normal',
15359
+ characterSet: 'auto',
15360
+ getTextAnchor: 'middle', getAlignmentBaseline: 'center'
15361
+ }));
15362
+ } else {
15363
+ layers.push(new TextLayer({
15364
+ id: `ix-cluster-values-${this.name}${bs}`,
15365
+ data: labels, pickable: false,
15366
+ getPosition: d => d.geometry.coordinates,
15367
+ getText: d => d.text,
15368
+ getSize: d => d.fontSize,
15369
+ getColor: d => [...d.color.slice(0, 3), d.alpha],
15370
+ getPixelOffset: d => d.pixelOffset,
15371
+ sizeUnits: 'pixels', fontFamily: 'arial', fontWeight: 'normal',
15372
+ characterSet: 'auto',
15373
+ getTextAnchor: 'middle', getAlignmentBaseline: 'center'
15374
+ }));
15375
+ }
13850
15376
  }
13851
- });
13852
- if (labels.length) {
13853
- layers.push(new TextLayer({
13854
- id: `ix-cluster-values-${this.name}`,
13855
- data: labels, pickable: false,
13856
- getPosition: d => d.geometry.coordinates,
13857
- getText: d => d.text,
13858
- getSize: d => d.fontSize,
13859
- getColor: d => [...d.color.slice(0, 3), d.alpha],
13860
- getPixelOffset: d => d.pixelOffset,
13861
- sizeUnits: 'pixels', fontFamily: 'arial', fontWeight: 'normal',
13862
- characterSet: 'auto',
13863
- getTextAnchor: 'middle', getAlignmentBaseline: 'center'
13864
- }));
13865
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, '');
13866
15389
  }
13867
15390
  return layers;
13868
15391
  }
@@ -13922,8 +15445,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13922
15445
  // CENTERVALUE the item's record count
13923
15446
  // Not drawn here (a console note at type parsing, PIE_INERT_FLAGS,
13924
15447
  // PIE_AGGREGATE_INERT_FLAGS):
13925
- // 3D, VOLUME, HEIGHT, STARBURST, FLOWER/RAYS, BOW, ZOOM, DIRECTION /
13926
- // 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
13927
15452
  // style.clipparts, showparts, gridx (flat's several pies per item).
13928
15453
  // Known divergence: flat draws a chart's value labels in the chart's
13929
15454
  // own group, so a bigger pie drawn later covers a smaller neighbour's
@@ -13987,6 +15512,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13987
15512
  const lineRgb = line === 'none' ? null : hexOrNamedToRgb(line);
13988
15513
  const lineWidthOf = r => (lineRgb ? (styleNum(st.linewidth) || 0.1) * r / 15 : 0);
13989
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;
13990
15526
 
13991
15527
  const charts = [];
13992
15528
  if (agg) {
@@ -14001,6 +15537,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14001
15537
  if (!layout) continue;
14002
15538
  if (cat) layout.slices.forEach(sl => { sl.single = values.length === 1; });
14003
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;
14004
15541
  const r = nSizeOf(sum, size) * unit;
14005
15542
  if (!(r > 0) || !isFinite(r)) continue;
14006
15543
  // flat's draw-order key (maptheme.js 16812-16860): with a size
@@ -14025,6 +15562,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14025
15562
  const layout = pieSliceLayout(values, t, { centerPart: st.centerpart });
14026
15563
  if (!layout) continue;
14027
15564
  const sum = values.reduce((a, v) => a + v, 0);
15565
+ if (star) for (const v of values) if (v > starMaxPart) starMaxPart = v;
14028
15566
  const size = this.binding.size ? parseFloat(raw[this.binding.size]) : undefined;
14029
15567
  const r = nSizeOf(sum, size) * unit;
14030
15568
  if (!(r > 0) || !isFinite(r)) continue;
@@ -14057,14 +15595,80 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14057
15595
  });
14058
15596
 
14059
15597
  const TAU = 2 * Math.PI;
14060
- const slices = [], centers = [], glows = [];
15598
+ const slices = [], centers = [], glows = [], polar = [], polarDashes = [];
14061
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);
14062
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
+ }
14063
15663
  for (const sl of ch.layout.slices) {
14064
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;
14065
15669
  slices.push({
14066
- position: ch.c, r: ch.r, off: ch.off, properties: ch.properties, chart: ch, part: sl,
14067
- 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],
14068
15672
  fill: [...rgb.slice(0, 3), !sl.complement && noFill.has(sl.i) ? 0 : Math.round(255 * fillAlpha)]
14069
15673
  });
14070
15674
  }
@@ -14088,13 +15692,80 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14088
15692
  radiusUnits: 'pixels', billboard: true, stroked: false, filled: true, antialiasing: true,
14089
15693
  parameters: ICON_LAYER_GLOBE_PARAMETERS
14090
15694
  };
14091
- 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, {
14092
15756
  id: `ix-pie-${this.name}`,
14093
15757
  data: slices, pickable: true,
14094
15758
  getLineColor: lineRgba,
14095
15759
  getSlice: d => d.slice,
14096
- getSliceLineWidth: d => lineWidthOf(d.r)
14097
- }))];
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
+ })));
14098
15769
  if (centers.length) {
14099
15770
  layers.push(new PieSliceLayer(Object.assign({}, discProps, { id: `ix-pie-center-${this.name}`, data: centers, pickable: true })));
14100
15771
  }
@@ -14308,7 +15979,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14308
15979
  // STACKED beside the column on leader lines; flat's values color
14309
15980
  // #798697 (textcolor), negative red (valuecolor); DTEXT and a sized
14310
15981
  // POINTER fade small values
14311
- // - BOX / TITLE (flatChartBox): a box around chart and title, the
15982
+ // - BOX / TITLE (chartBoxLayout): a box around chart and title, the
14312
15983
  // item's title above it — boxcolor (white) at boxopacity, bordercolor
14313
15984
  // (#bbbbbb) at 0.3, borderwidth · 0.2 · w / 30; title 5 u ·
14314
15985
  // textscale in textcolor (#888888) over a white halo
@@ -14404,13 +16075,15 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14404
16075
  }
14405
16076
  applyFlatMaxCharts(charts, st);
14406
16077
 
14407
- const boxShown = flatFlag(this.flags, 'BOX') && !boxHiddenByScale(st, liveZoom);
14408
- const titleShown = boxShown && flatFlag(this.flags, 'TITLE') && !!this.binding.title;
14409
- 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 : '';
14410
16083
  const boxOpacity = isNaN(styleNum(st.boxopacity)) ? 1 : styleNum(st.boxopacity);
14411
16084
  const borderStyle = String(st.borderstyle || '');
14412
- const boxFill = [...hexOrNamedToRgb(st.boxcolor || 'white').slice(0, 3), Math.round(255 * boxOpacity)];
14413
- const boxLine = borderStyle === 'none' ? [0, 0, 0, 0]
16085
+ const boxFill = [...hexOrNamedToRgb(isNoneColor(st.boxcolor) ? 'white' : st.boxcolor || 'white').slice(0, 3), isNoneColor(st.boxcolor) ? 0 : Math.round(255 * boxOpacity)];
16086
+ const boxLine = borderStyle === 'none' || isNoneColor(styleLineColor(st.bordercolor)) ? [0, 0, 0, 0]
14414
16087
  : [...hexOrNamedToRgb(styleLineColor(st.bordercolor) || '#bbbbbb').slice(0, 3), Math.round(255 * (/^(solid|dotted|dashed)$/.test(borderStyle) ? 1 : 0.3))];
14415
16088
  const borderWidth = num(st.borderwidth) || 1;
14416
16089
  const lc = st.linecolor != null && st.linecolor !== '' ? styleLineColor(st.linecolor) : null;
@@ -14435,10 +16108,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14435
16108
  for (const ch of charts) {
14436
16109
  const L = ch.layout;
14437
16110
  let box = null;
14438
- if (boxShown) {
14439
- const title = titleShown ? this._itemTitle(ch.properties) : '';
14440
- box = flatChartBox(L.extent, { margin: num(st.boxmargin) || 2, titleFont, title: title ? title + (st.titleunits || '') : '',
14441
- 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 });
14442
16115
  }
14443
16116
  const ext = box ? box.rect : L.extent;
14444
16117
  // align / offsetx / offsety (flatChartAlignOffset), the chart's
@@ -14447,9 +16120,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14447
16120
  if (box) {
14448
16121
  const r = box.rect;
14449
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 },
14450
- boxFill, boxLine, borderWidth * 0.2 * box.width / 30, null, true);
14451
- if (box.title) titles.push({ position: ch.c, text: box.title.lines.join('\n'), font: box.title.font * unit, anchor: box.title.anchor,
14452
- 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 } });
14453
16127
  }
14454
16128
  for (const b of L.bars) {
14455
16129
  const rgb = this.classRgb(b.cls);
@@ -14517,18 +16191,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14517
16191
  backgroundPadding: [0.3, 0, 0.4, 0.1].map(k => k * boxed.reduce((a, d) => a + d.font, 0) / boxed.length)
14518
16192
  })));
14519
16193
  }
14520
- if (titles.length) {
14521
- layers.push(new TextLayer({
14522
- id: `ix-bar-titles-${this.name}`, data: titles, pickable: false,
14523
- getPosition: d => d.position, getText: d => d.text, getSize: d => d.font,
14524
- sizeUnits: 'pixels', fontFamily: 'arial', characterSet: 'auto', lineHeight: 1,
14525
- getColor: [...hexOrNamedToRgb(st.textcolor || '#888888').slice(0, 3), 255],
14526
- fontSettings: { sdf: true }, outlineWidth: 1 / 7, outlineColor: [255, 255, 255, 128],
14527
- getTextAnchor: d => d.anchor, getAlignmentBaseline: 'top',
14528
- getPixelOffset: d => d.off,
14529
- parameters: ICON_LAYER_GLOBE_PARAMETERS
14530
- }));
14531
- }
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);
14532
16198
  return layers;
14533
16199
  }
14534
16200
 
@@ -14715,22 +16381,28 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14715
16381
  return [(lng + 180) / 360 * world, (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * world];
14716
16382
  };
14717
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];
14718
- // the curves are computed in the plane of flat's map (Equal Earth), at the screen
14719
- // size of this map's degree of longitude on the equator, then mapped back: the
14720
- // flow bows around the pole as on flat. Mercator's plane stays the default.
14721
- // On the globe (flat's orthographic map) the plane is the one of the view center,
14722
- // 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.
14723
16397
  const planeFns = (proj, k) => ({
14724
16398
  toPx: ([lng, lat]) => { const q = proj.project(lat, lng); return [q[0] * k, -q[1] * k]; },
14725
16399
  toLngLat: ([x, y]) => { const q = proj.invert(x / k, -y / k); return [q[1], q[0]]; }
14726
16400
  });
14727
16401
  const mercFns = { toPx: mercToPx, toLngLat: mercToLngLat };
14728
- const fixedPlane = flatPlaneProjectionByOptions.get(this.mapOptions);
14729
- const fixedFns = fixedPlane && fixedPlane.invert ? planeFns(fixedPlane, (world / 360) / fixedPlane.project(0, 1)[0]) : null;
14730
16402
  const planeCenter = flatViewCenterByOptions.get(this.mapOptions) || globeCenter;
14731
- const ortho = !fixedFns && globeCenter ? orthographicPlane(planeCenter.lat, planeCenter.lng) : null;
16403
+ const ortho = globeCenter ? orthographicPlane(planeCenter.lat, planeCenter.lng) : null;
14732
16404
  const orthoFns = ortho ? planeFns(ortho, world / (2 * Math.PI)) : null;
14733
- 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);
14734
16406
  const inBox = c => c && c[0] >= bbox[0] && c[0] <= bbox[2] && c[1] >= bbox[1] && c[1] <= bbox[3];
14735
16407
 
14736
16408
  const sizePow = resolveSizePow(st, this.flags);
@@ -14829,7 +16501,6 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14829
16501
  // — every size equal — SVG ignores: the opacity stays)
14830
16502
  if (dop || dopMin) { const d = dopacity(it); if (isFinite(d)) { lineAlpha = d; headAlpha = d; } }
14831
16503
  if (gradient) stops = fadeGradientStops(pts, lay.vx, lay.vy, fade);
14832
- if (dash) dashArr = [50 * oz, 5 * oz + sw];
14833
16504
  pts = pts.map(p => [p[0] + a[0], p[1] + a[1]]);
14834
16505
  end = [end[0] + a[0], end[1] + a[1]];
14835
16506
  } else {
@@ -14843,7 +16514,25 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14843
16514
  headS = 4; refX = 4; refY = 2;
14844
16515
  lineAlpha = fo || 0.3;
14845
16516
  headAlpha = Math.min(1, (fo || 0.3) * 2);
14846
- 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
+ }
14847
16536
  }
14848
16537
  // a line under a pixel: one pixel at its width as opacity
14849
16538
  const drawW = Math.max(1, sw);
@@ -14869,7 +16558,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14869
16558
  let run = [], runColors = [];
14870
16559
  const flush = () => {
14871
16560
  if (run.length > 1) {
14872
- 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];
14873
16566
  if (globeCenter && !dashArr) {
14874
16567
  // on the globe deck.gl draws only the first three quarters of a path that has a
14875
16568
  // color per vertex (also an equal one): one color per path, a gradient as one
@@ -14904,12 +16597,22 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14904
16597
  fill: [...rgb.slice(0, 3), Math.round(255 * Math.min(1, headAlpha) * groupAlpha)] });
14905
16598
  }
14906
16599
  }
14907
- 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({
14908
16609
  id: `ix-vector-${this.name}`, data: paths, pickable: true,
14909
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]],
14910
16613
  widthUnits: 'pixels', capRounded: dash && bezier, jointRounded: false, billboard: false,
14911
- parameters: ICON_LAYER_GLOBE_PARAMETERS,
14912
- ...(dash ? { getDashArray: d => d.dash, extensions: [new PathStyleExtension({ dash: true })] } : {})
16614
+ extensions: [new PathStyleExtension({ dash: true, dashMode: 'path' })],
16615
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
14913
16616
  })];
14914
16617
  if (heads.length) {
14915
16618
  // the head count in the id: deck kept a same-id polygon layer's
@@ -15009,7 +16712,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15009
16712
  const v = raw && raw[this.style.labelfield];
15010
16713
  let text = v == null || v === '' ? '' : String(v);
15011
16714
  if (!text) return null;
15012
- const num = parseFloat(text.replace(',', '.'));
16715
+ // flat reads it with __scanValue: "1.234,5", "12 500"
16716
+ const num = flatScanValue(text);
15013
16717
  text = (!isNaN(num) ? flatGroupedValue(num, dec != null ? dec : 2, noBreaks) : text) + units;
15014
16718
  const width = Math.max(titleTextWidth(text, f), (text.length + 2) * f * 0.5);
15015
16719
  const x = width / 3, y = f / 3 + r + 5 * unitPx;
@@ -15181,6 +16885,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15181
16885
  // the box is sized by the value's own text, not the valuefield's
15182
16886
  const boxStyle = Object.assign({}, this.style, { valuefield: undefined });
15183
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();
15184
16891
  for (const d of combined) {
15185
16892
  const r = radiusOf(d);
15186
16893
  if (!(r > 0)) continue;
@@ -15195,6 +16902,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15195
16902
  const o = alignOf(d);
15196
16903
  // the box's center relative to the chart's: x from −r to 1.05r, y from −0.83f to 0.77f
15197
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]);
15198
16906
  if (showValues) {
15199
16907
  const text = textOf(props);
15200
16908
  const fontSize = Math.min(r * 0.8, r * 3.3 / Math.max(1, text.length)) * valueScale;
@@ -15202,7 +16910,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15202
16910
  if (fontSize > 0) texts.push({ ...d, text, fontSize, color, pixelOffset: o });
15203
16911
  }
15204
16912
  }
15205
- 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] : [];
15206
16920
  if (boxes.length) {
15207
16921
  layers.push(new IconLayer({
15208
16922
  id: `ix-bubbles-${this.name}-g${this._iconGeneration}`,
@@ -15236,6 +16950,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15236
16950
  parameters: ICON_LAYER_GLOBE_PARAMETERS
15237
16951
  }));
15238
16952
  }
16953
+ if (boxLayers) layers.push(...boxLayers.titles);
15239
16954
  return layers;
15240
16955
  }
15241
16956
 
@@ -15295,8 +17010,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15295
17010
  ctx.lineTo(cx + r, cy - r * 0.6);
15296
17011
  ctx.lineTo(cx, cy + r);
15297
17012
  break;
17013
+ // flat's per-part SYMBOL roundrect (maptheme.js 22039): side 2r, like
17014
+ // 'squarefull'; the other branch (21639) is 1.6r
17015
+ case 'roundrectfull':
15298
17016
  case 'roundrect': {
15299
- const s = r * 0.8;
17017
+ const s = shape === 'roundrectfull' ? r : r * 0.8;
15300
17018
  if (ctx.roundRect) ctx.roundRect(cx - s, cy - s, s * 2, s * 2, r * 0.3);
15301
17019
  else ctx.rect(cx - s, cy - s, s * 2, s * 2);
15302
17020
  break;
@@ -15365,6 +17083,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15365
17083
  function withAlpha(rgb, a) {
15366
17084
  return a >= 1 ? rgb.slice(0, 3) : [...rgb.slice(0, 3), Math.round(255 * Math.max(0, a))];
15367
17085
  }
17086
+ // a style color of "none": no paint (flat writes it into the SVG style:
17087
+ // fill:none / stroke:none) — neither a color nor a canvas fillStyle
17088
+ function isNoneColor(v) {
17089
+ return typeof v === 'string' && v.trim().toLowerCase() === 'none';
17090
+ }
15368
17091
  function hexOrNamedToRgb(v) {
15369
17092
  const NAMED = { gray: [128, 128, 128], grey: [128, 128, 128], black: [0, 0, 0], white: [255, 255, 255] };
15370
17093
  return parseCssColor(v) || NAMED[v] || [130, 130, 130];
@@ -15481,21 +17204,34 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15481
17204
  // last built map; called while the map is still building, they run once
15482
17205
  // it is ready.
15483
17206
  const MAP_HANDLE_METHODS = ['replace', 'add', 'newTheme', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
15484
- 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'setProjection', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible', 'setThemeTimeFrame', 'attribution'];
17207
+ 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'setProjection', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible', 'switchLayer', 'setThemeTimeFrame', 'attribution'];
15485
17208
  const _mapHandles = Object.create(null);
15486
17209
  function makeMapHandle(szMap) {
15487
17210
  const h = {};
15488
- 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);
15489
17215
  for (const m of MAP_HANDLE_METHODS) {
15490
17216
  h[m] = (...args) => {
15491
17217
  const a = api();
15492
17218
  if (a) { const r = a[m](...args); return r === a ? h : r; }
15493
- _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));
15494
17221
  return h;
15495
17222
  };
15496
17223
  }
15497
17224
  h.getZoom = () => { const a = api(); return a ? a.getZoom() : undefined; };
15498
17225
  h.getThemeObj = id => getThemeObj(id);
17226
+ // flat's mapApi.message(szText) (htmlgui_flat.js 1381): a short status
17227
+ // line over the map — the same accepted no-op as the global
17228
+ // ixmaps.message ("flat shows a short status message on the map;
17229
+ // not ported"), here for a page's own map().message('...') calls
17230
+ h.message = () => h;
17231
+ // flat's mapApi.getProjectString() (htmlgui_flat.js 1375): the
17232
+ // project JSON of the map — the global's, of the last built map (a
17233
+ // page's code-view overlay calls it through the handle, WBGT port)
17234
+ h.getProjectString = () => getProjectString();
15499
17235
  // flat's mapApi.theme(szTheme) (htmlgui_flat.js 1571): the theme handle
15500
17236
  h.theme = szTheme => new themeApi(szMap, szTheme);
15501
17237
  // flat's mapApi aliases (htmlgui_flat.js 1286+): show/hide a theme,
@@ -15946,9 +17682,14 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15946
17682
  // their own comments) are still real, directly-callable globals a
15947
17683
  // page's OWN custom UI can use too — only THIS generated markup
15948
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;
15949
17689
  if (!container.dataset.ixFacetsWired) {
15950
17690
  container.dataset.ixFacetsWired = '1';
15951
17691
  container.addEventListener('click', e => {
17692
+ const szId = container._ixFacetsId;
15952
17693
  const moreBtn = e.target.closest('[data-role="more"]');
15953
17694
  if (moreBtn) { moreBtn.hidden = true; moreBtn.nextElementSibling.hidden = false; return; }
15954
17695
 
@@ -15974,7 +17715,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15974
17715
  container.addEventListener('keyup', e => {
15975
17716
  if (e.key !== 'Enter') return;
15976
17717
  const input = e.target.closest('[data-role="search"]');
15977
- 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);
15978
17719
  });
15979
17720
  }
15980
17721
  }
@@ -16157,6 +17898,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16157
17898
  newTheme: (title, theme, flag) => _mapHandle.newTheme(title, theme, flag),
16158
17899
  // flat's time slider: show the records of a theme (null = all themes) whose timefield lies in [min, max)
16159
17900
  setThemeTimeFrame: (szId, nMin, nMax) => _mapHandle.setThemeTimeFrame(szId, nMin, nMax),
17901
+ // flat's ixmaps.switchLayer(szLayerName, fState) (htmlgui.js 3383): every
17902
+ // theme of the named layer on the last built map shown or hidden
17903
+ switchLayer: (szLayerName, fState) => _mapHandle.switchLayer(szLayerName, fState),
16160
17904
  setBasemapOpacity: compatSetBasemapOpacity,
16161
17905
  // this engine's version (flat's own ixmaps.version numbers flat)
16162
17906
  glVersion: IXMAPS_GL_VERSION,
@@ -16181,19 +17925,19 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16181
17925
  // test-only: lets test/unit/*.test.mjs call pure internals directly (the
16182
17926
  // engine runs in a Node vm there); deliberately NOT on the ixmaps object
16183
17927
  global.__ixmapsGlInternals = {
16184
- 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,
16185
- 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,
16186
17930
  resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
16187
17931
  equalIntervalBreaks, quantileBreaks, naturalBreaks, evenStrideSample, jenksBreakValues, partsFromBreakValues, resolvePartsClass,
16188
17932
  computeAlphaStats, computeMultiFieldClasses, computeDominantStats, resolveDominantClass, computeComposeColorStats,
16189
17933
  resolveComposedColor, computeRangeClasses, colorSchemeClassCount, resolveDopacityAlpha,
16190
17934
  flatColorSweep, applyClassesToColorScheme, resolveClassColors, flatOutlierStats, parseCssColor, flatLegendLook,
16191
17935
  flatToMapLibreZoom, mapLibreToFlatZoom, flatViewToMapLibreZoom, mapLibreToFlatViewZoom, flatSvgProjectionOf, flatSvgProjectionByOptions, normalSizeScaleOf, scaleDenominatorAt, resolveZoomReference, resolveAggregationPx, valuesHiddenByScale,
16192
- fetchLayerData, parseCsvText, dataTableRows, geometryRowsToFeatureCollection, filterFlatValues, applyField100, field100Binding, rangeClassLegendTotals, resolveAggregateValue, classValueSeparate, cellAggregatedValues, oneHot, groupCoLocated, aggregateOnGrid, GridAggregateIndex, flatRangeParts, aggregateField100, valueRadius, itemPlotGeometry, itemAnchor, objectZoomFactor, resolveZoomReference, defaultNormalSizeValue,
16193
- 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,
16194
17938
  drawSymbolPath, normalizeSymbolShape,
16195
17939
  flatFormatValue, themeStyleArgs, flatChangedStyleValue, explicitValueDecimals, legendDecimals,
16196
- dataCacheKey, dataCacheDisabled, cachedLayerData, featuresBounds, wantsZoomToExtent, legendIsOn,
17940
+ dataCacheKey, dataCacheDisabled, cachedLayerData, featuresBounds, wantsZoomToExtent, legendIsOn, applySphereBackdrop,
16197
17941
  };
16198
17942
  global.__setFilter = __setFilter;
16199
17943
  global.__removeFacets = __removeFacets;