ixmaps-gl 0.2.16 → 0.2.17

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 +571 -72
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -243,7 +243,7 @@ of your own project files gl can render.
243
243
  Load the engine from jsDelivr, pinned to a release tag (or `@main` for the latest commit):
244
244
 
245
245
  ```html
246
- <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.16/ixmaps-gl.js"></script>
246
+ <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.17/ixmaps-gl.js"></script>
247
247
  ```
248
248
 
249
249
  or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmaps-gl)
@@ -251,13 +251,13 @@ or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmap
251
251
  browser script, which defines the global `ixmaps`):
252
252
 
253
253
  ```html
254
- <script src="https://unpkg.com/ixmaps-gl@0.2.16/ixmaps-gl.js"></script>
254
+ <script src="https://unpkg.com/ixmaps-gl@0.2.17/ixmaps-gl.js"></script>
255
255
  ```
256
256
 
257
257
  or use a local copy (`<script src="ixmaps-gl.js"></script>`). A minimal page:
258
258
 
259
259
  ```html
260
- <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.16/ixmaps-gl.js"></script>
260
+ <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.17/ixmaps-gl.js"></script>
261
261
  <div id="map_div" style="position:absolute;inset:0;"></div>
262
262
 
263
263
  <script>
package/ixmaps-gl.js CHANGED
@@ -82,7 +82,7 @@
82
82
  // ---------------------------------------------------------------
83
83
  // this engine's release (= package.json "version", checked by
84
84
  // test/unit/version.test.mjs); ixmaps.glVersion, and logged once at start
85
- const IXMAPS_GL_VERSION = '0.2.16';
85
+ const IXMAPS_GL_VERSION = '0.2.17';
86
86
 
87
87
  const LIB_URLS = {
88
88
  // Bumped 3.6.2 -> 5.x (2026-09-21, globe-projection compat fix): native
@@ -716,6 +716,9 @@
716
716
  // another theme's data: .data({name})). See loadNamedTheme in build().
717
717
  const _namedDataWaiters = new Map(); // name → Set of resolve(table)
718
718
  const _namedDataListeners = new Map(); // name → Set of fn(table)
719
+ // names whose next publications feed only the theme that asked for them
720
+ // (a FEATURE's dependent broker refresh, see featureDependents) → count
721
+ const _quietNamedData = new Map();
719
722
  function namedTable(name) {
720
723
  try { const t = global[name]; return t && typeof t === 'object' && typeof t.json === 'function' ? t : null; } catch (e) { return null; }
721
724
  }
@@ -744,6 +747,7 @@
744
747
  const waiters = _namedDataWaiters.get(name);
745
748
  if (waiters) [...waiters].forEach(resolve => resolve(table));
746
749
  const listeners = _namedDataListeners.get(name);
750
+ if (_quietNamedData.get(name)) return;
747
751
  // after the handing-over code finished (a broker's own theme first)
748
752
  if (listeners && listeners.size && table) setTimeout(() => [...listeners].forEach(fn => fn(table)), 0);
749
753
  }
@@ -1856,12 +1860,22 @@
1856
1860
  if (typeof colorscheme === 'string') {
1857
1861
  try {
1858
1862
  const fn = new Function('return (' + colorscheme + ')')();
1859
- const objTheme = { szLabelA: labels, colorScheme: new Array(labels.length) };
1860
- fn(objTheme);
1861
- return objTheme.colorScheme;
1862
- } catch (err) {
1863
- console.warn('[ixmaps-gl] colorscheme function failed to evaluate, using fallback palette:', err);
1864
- }
1863
+ if (typeof fn === 'function') {
1864
+ const objTheme = { szLabelA: labels, colorScheme: new Array(labels.length) };
1865
+ fn(objTheme);
1866
+ return objTheme.colorScheme;
1867
+ }
1868
+ } catch (err) { /* not code — a flat color-list string, below */ }
1869
+ // GL-PORT COMPAT: flat's Themes.toArray (maptheme.js 999-1017) — a
1870
+ // string colorscheme that isn't a colorscheme FUNCTION is a COLOR
1871
+ // LIST: '|' separated, or ',' separated when there's no RGB() token
1872
+ // (kept whole). A single "#dd0088" is ONE single-color class (the
1873
+ // fire page's KDE isobands), not a broken function silently falling
1874
+ // back to the default palette.
1875
+ const list = (/\|/.test(colorscheme) || /rgb/i.test(colorscheme)) ? colorscheme.split('|') : colorscheme.split(',');
1876
+ if (list.length === 1 && list[0].trim() === 'none') return null; // no fill, the array branch's ['none']
1877
+ if (list.every(c => parseCssColor(c))) return labels.length ? labels.map((_, i) => list[i % list.length]) : list;
1878
+ console.warn('[ixmaps-gl] colorscheme "' + colorscheme + '" is neither a colorscheme function nor a color list, using the fallback palette');
1865
1879
  }
1866
1880
  return labels.map((_, i) => FALLBACK_PALETTE[i % FALLBACK_PALETTE.length]);
1867
1881
  }
@@ -1914,6 +1928,11 @@
1914
1928
  // _prepareDominant) doing its own `.map(hexToRgb)` that would break on
1915
1929
  // the diverging sweep's own output.
1916
1930
  function resolveClassColors(colorscheme, labels, classes) {
1931
+ // flat's colorscheme ["none"] is an explicit NO-FILL (a chart of
1932
+ // unfilled symbols, only its values/boxes/titles drawn — the fire
1933
+ // areas page's SIF chart): NO class colors, never the fallback
1934
+ // palette, which would paint symbol circles flat never draws
1935
+ if (Array.isArray(colorscheme) && colorscheme.length === 1 && colorscheme[0] === 'none') return null;
1917
1936
  const resolved = resolveColorScheme(colorscheme, labels, classes);
1918
1937
  return (resolved || labels.map((_, i) => FALLBACK_PALETTE[i % FALLBACK_PALETTE.length]))
1919
1938
  .map(c => Array.isArray(c) ? c : (parseCssColor(c) || [130, 130, 130]));
@@ -2431,7 +2450,10 @@
2431
2450
  // (flat stores most of these as given and lets arithmetic coerce them;
2432
2451
  // its runtime changeThemeStyle applies Number() — the same result for
2433
2452
  // every clean value.) A linewidth list is typed element by element.
2434
- const STYLE_NUMBER_KEYS = ['linewidth', 'fillopacity', 'scale', 'classes', 'valuedecimals', 'normalsizevalue',
2453
+ // Not valuedecimals: flat's `nValueDecimals || default` depends on the raw
2454
+ // type (a string "0" counts, a number 0 doesn't) — read it through
2455
+ // explicitValueDecimals / legendDecimals.
2456
+ const STYLE_NUMBER_KEYS = ['linewidth', 'fillopacity', 'scale', 'classes', 'normalsizevalue',
2435
2457
  'sizepow', 'rangescale', 'minvalue', 'maxvalue', 'markersize', 'boxopacity', 'outlierscale', 'valuescale',
2436
2458
  'brightness', 'fractionscale', 'dopacityscale', 'dopacitypow', 'gridwidthpx', 'textscale', 'rangecentervalue',
2437
2459
  'shadowblur', 'shadowdx', 'shadowdy', 'maxshadow', 'offsetx', 'offsety', 'gridx', 'boxmargin', 'borderwidth',
@@ -2589,6 +2611,16 @@
2589
2611
  return String(text).split(' ').map(w => (dict["'" + w + "'"] !== undefined ? String(dict["'" + w + "'"]) : w)).join(' ');
2590
2612
  };
2591
2613
  }
2614
+ // make the map container the positioning context of what the engine
2615
+ // mounts inside it (splash, pinned tooltip, legends) — only when it is
2616
+ // unpositioned. The RENDERED position decides, never el.style (inline
2617
+ // only): a page's #map_div { position:absolute; top:0; bottom:0 } from a
2618
+ // stylesheet reads as "" there, and overwriting it with relative drops
2619
+ // the top/bottom stretch — the div's height collapses to 0, a blank map
2620
+ // (accidents_app.html from 0.2.15 on)
2621
+ function anchorInside(el) {
2622
+ if (el && getComputedStyle(el).position === 'static') el.style.position = 'relative';
2623
+ }
2592
2624
  function showSplash(el, text) {
2593
2625
  ensureSplashStyle();
2594
2626
  // Checks the actual RENDERED position, not el.style (the inline
@@ -2602,7 +2634,7 @@
2602
2634
  // height silently collapsed to 0 — invisible map, invisible splash,
2603
2635
  // both zero-height. Only truly static (unpositioned) elements need
2604
2636
  // this at all.
2605
- if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
2637
+ anchorInside(el);
2606
2638
  const splash = document.createElement('div');
2607
2639
  splash.className = 'ixmaps-splash';
2608
2640
  splash.innerHTML = '<div class="ixmaps-splash-mark"><span class="ixmaps-splash-ping"></span><span class="ixmaps-splash-dot"></span></div>' +
@@ -3144,7 +3176,7 @@
3144
3176
  // relative pixels, and styling the page's own body (relative) breaks
3145
3177
  // any page element absolutely positioned against it (AirBnB's
3146
3178
  // #onmap-buttons on a zero-height all-float body flew off-screen)
3147
- el.style.position = el.style.position || 'relative';
3179
+ anchorInside(el);
3148
3180
  el.appendChild(pinnedTooltipEl);
3149
3181
  }
3150
3182
  return { getTooltip, onClick, mount };
@@ -3341,7 +3373,7 @@
3341
3373
  // panel's max-height resolves against the MAP's height — the stack's
3342
3374
  // former parent anchor collapsed it to 0 on pages whose body has
3343
3375
  // only floated children (height 0)
3344
- el.style.position = el.style.position || 'relative';
3376
+ anchorInside(el);
3345
3377
  el.appendChild(legendStack);
3346
3378
  return legendStack;
3347
3379
  };
@@ -3387,7 +3419,7 @@
3387
3419
  rt._triggerRedraw = () => { refresh(); };
3388
3420
  if (subThemeCapable(rt)) rt._onMarksChanged = () => updateSubTheme(rt);
3389
3421
  if (!legendOn || !el.parentElement || !legendApplies(rt)) return;
3390
- el.style.position = el.style.position || 'relative';
3422
+ anchorInside(el);
3391
3423
  {
3392
3424
  // .type("...|NOLEGEND") — the fourth real legend-related type()
3393
3425
  // token: opts a theme OUT of the legend entirely (real engine's
@@ -3472,6 +3504,17 @@
3472
3504
  // themselves authored with their own leading space (e.g.
3473
3505
  // " MW") in real pages.
3474
3506
  const legendUnit = rt.style.legendunits || rt.style.units || '';
3507
+ // a per-item chart over several value fields: the row values as
3508
+ // flat's legend prints them (legend.js 610, 810: __formatValue
3509
+ // with valuedecimals, else 2, "BLANK" — whole numbers without
3510
+ // decimals), a colorscheme "none" part as an outline in linecolor
3511
+ const multiFieldLegend = !!rt._multiFields && rt._isMultiFieldSequence();
3512
+ const legendTotalText = v => (multiFieldLegend
3513
+ ? flatFormatValue(v, legendDecimals(rt.style), 'BLANK')
3514
+ : rt._formatTooltipValue(v));
3515
+ const outlineRgb = rt.style.linecolor && styleLineColor(rt.style.linecolor) !== 'none' ? hexOrNamedToRgb(styleLineColor(rt.style.linecolor)) : [128, 128, 128];
3516
+ const legendFill = i => (multiFieldLegend && rt._partNoFill && rt._partNoFill.has(i)
3517
+ ? `transparent;box-shadow:inset 0 0 0 1px rgb(${outlineRgb.join(',')})` : null);
3475
3518
  // row labels go into innerHTML: the page's own .style({label})
3476
3519
  // stays HTML, as in flat; categories read from the data are
3477
3520
  // escaped (a value could carry markup)
@@ -3513,6 +3556,22 @@
3513
3556
  const globeCenter = proj.type === 'globe' ? map.getCenter() : null;
3514
3557
  totals = new Array(rt.categoryLabels.length).fill(0);
3515
3558
  if (rt._rangeClassed) { totals = rangeClassLegendTotals(rt, bbox, globeCenter); maxTotal = Math.max(0, ...totals); order = totals.map((v, i) => i).sort((a, b) => totals[b] - totals[a]); return; }
3559
+ // a per-item chart over several value fields: a row per field,
3560
+ // the field's sum over the visible items (flat's NYC status
3561
+ // legend: bikes / empty docks / e-bikes, biggest sum first)
3562
+ if (rt._multiFields && rt._isMultiFieldSequence()) {
3563
+ (rt._activeFeatures || rt.features).forEach(f => {
3564
+ const c = f.geometry && f.geometry.coordinates;
3565
+ if (!c || typeof c[0] !== 'number') return;
3566
+ const [lng, lat] = c;
3567
+ if (lng < bbox[0] || lng > bbox[2] || lat < bbox[1] || lat > bbox[3]) return;
3568
+ if (globeCenter && !isOnVisibleHemisphere(lng, lat, globeCenter)) return;
3569
+ rt._multiFields.forEach((k, i) => { totals[i] += parseFloat(f.properties[k]) || 0; });
3570
+ });
3571
+ maxTotal = Math.max(0, ...totals);
3572
+ order = totals.map((v, i) => i).sort((a, b) => totals[b] - totals[a]);
3573
+ return;
3574
+ }
3516
3575
  // _activeFeatures (set by setFacetFilter/clearFacetFilter,
3517
3576
  // e.g. the country-select dropdown below) is the facet-
3518
3577
  // filtered subset when a filter is active, null otherwise —
@@ -3645,7 +3704,7 @@
3645
3704
  for (let line = 0; line < nLines; line++) {
3646
3705
  for (let c = 0; c < n; c++) {
3647
3706
  const ix = flatFlag(rt.flags, 'INVERT') ? n - c - 1 : c;
3648
- const rgb = rt.categoryColorsRgb[ix];
3707
+ const rgb = rt.categoryColorsRgb[ix] || [128, 128, 128]; // colorscheme "none": no class colors
3649
3708
  const part = rt.partsA[ix];
3650
3709
  const title = part ? flatFormatValue(part.min, dec, '') + unit + ' ... ' + flatFormatValue(part.max, dec, '') + unit : '';
3651
3710
  const marked = rt._markedClasses.has(ix);
@@ -3676,8 +3735,8 @@
3676
3735
  // flat drops the rows without a count once any row has one
3677
3736
  // (legend.js 754-760, 787-789: fCountBars && !count)
3678
3737
  rowsEl.innerHTML = order.filter(i => !(maxTotal > 0) || totals[i]).map(i => {
3679
- const rgb = rt.categoryColorsRgb[i];
3680
- const color = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
3738
+ const rgb = rt.categoryColorsRgb[i] || [128, 128, 128]; // colorscheme "none": no class colors
3739
+ const color = legendFill(i) || `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
3681
3740
  const marked = rt._markedClasses.has(i);
3682
3741
  const dimmed = rt._markedClasses.size > 0 && !marked;
3683
3742
  const rowStyle = 'padding:4px 3px;border-radius:4px;cursor:pointer;opacity:' + (dimmed ? 0.4 : 1) + ';'
@@ -3704,7 +3763,7 @@
3704
3763
  + '<div style="display:flex;align-items:center;gap:6px;">'
3705
3764
  + '<span style="flex:0 0 8px;width:8px;height:8px;border-radius:50%;background:' + color + ';"></span>'
3706
3765
  + '<span style="flex:0 0 auto;width:' + barPx + 'px;height:6px;background:' + color + ';border-radius:3px;"></span>'
3707
- + '<span style="flex:0 0 auto;white-space:nowrap;">' + rt._formatTooltipValue(totals[i]) + legendUnit + '</span>'
3766
+ + '<span style="flex:0 0 auto;white-space:nowrap;">' + legendTotalText(totals[i]) + legendUnit + '</span>'
3708
3767
  + '</div>'
3709
3768
  + '</div>';
3710
3769
  }
@@ -3715,7 +3774,7 @@
3715
3774
  + '<span style="flex:1 1 auto;min-width:0;height:6px;background:' + legendColors.track + ';border-radius:3px;overflow:hidden;">'
3716
3775
  + '<span style="display:block;height:100%;width:' + pct + '%;background:' + color + ';border-radius:3px;"></span>'
3717
3776
  + '</span>'
3718
- + '<span style="flex:0 0 auto;text-align:right;min-width:60px;">' + rt._formatTooltipValue(totals[i]) + legendUnit + '</span>'
3777
+ + '<span style="flex:0 0 auto;text-align:right;min-width:60px;">' + legendTotalText(totals[i]) + legendUnit + '</span>'
3719
3778
  + '</div>'
3720
3779
  + '</div>';
3721
3780
  }).join('');
@@ -4296,7 +4355,7 @@
4296
4355
  legendEl.innerHTML = this._legendHtml;
4297
4356
  // inside the map container, never the page's body (see the pinned
4298
4357
  // tooltip mount) — styling body breaks page-positioned elements
4299
- el.style.position = el.style.position || 'relative';
4358
+ anchorInside(el);
4300
4359
  el.appendChild(legendEl);
4301
4360
  }
4302
4361
 
@@ -4663,10 +4722,17 @@
4663
4722
  .catch(err => console.error('[ixmaps-gl] map.layer():', err));
4664
4723
  return engineApi;
4665
4724
  }
4666
- // map.layer(name).data()...define(): replaces a theme of that name
4725
+ // map.layer(name).data()...define(): adds a theme on that layer.
4726
+ // NO same-name auto-remove here: flat lets several themes SHARE one
4727
+ // .layer() name (a page's feature base + its chart overlay, the
4728
+ // NYC status page's two "Station" themes — the overlay joins onto
4729
+ // the base's geometry by lookup, removing the base would kill
4730
+ // both). Replacement is by THEME ID (style.name/meta.name), and
4731
+ // happens inside defineFromDefinition instead — a re-defined id
4732
+ // (the page's 10s live refresh) replaces its own previous theme.
4667
4733
  const lb = new LayerBuilder(nameOrBuilder);
4668
4734
  lb.define = () => {
4669
- _chainedLayers = _chainedLayers.then(() => { engineApi.removeTheme(nameOrBuilder); return engineApi.defineLayer(lb); })
4735
+ _chainedLayers = _chainedLayers.then(() => engineApi.defineLayer(lb))
4670
4736
  .catch(err => console.error('[ixmaps-gl] map.layer().define():', err));
4671
4737
  return _chainedLayers;
4672
4738
  };
@@ -4695,8 +4761,22 @@
4695
4761
  // Removes every runtime whose OWN .layer(name) matches — a theme
4696
4762
  // may legitimately be split across more than one runtime sharing
4697
4763
  // one name (the CHOROPLETH/FEATURE-donor pair above), so this
4698
- // removes all of them, not just the first match.
4699
- removeTheme: (name) => removeRuntimes(name, runtimes.filter(r => r.name === name)),
4764
+ // removes all of them, not just the first match. GL-PORT COMPAT:
4765
+ // flat addresses a theme by its ID — style.name / meta.name —
4766
+ // as much as by its layer name (htmlgui.js removeTheme), and a
4767
+ // real page's own rebuild loop typically knows only the id it
4768
+ // set via .meta({name}) (the fire page's KDE: removeTheme
4769
+ // ("kde_fires") for the layer "kde_fires_layer"). Layer name
4770
+ // first, then the id — without the fallback every rebuild
4771
+ // removed nothing and stacked another copy of the theme,
4772
+ // compounding opacity (same semantics removeThemeById already
4773
+ // implements for the handle's .remove(id)).
4774
+ removeTheme: (name) => {
4775
+ const byLayer = runtimes.filter(r => r.name === name);
4776
+ if (byLayer.length) return removeRuntimes(name, byLayer);
4777
+ const byId = runtimes.filter(r => (r.style && r.style.name === name) || (r.meta && r.meta.name === name));
4778
+ return byId.length ? removeRuntimes(byId[0].name, byId) : false;
4779
+ },
4700
4780
  // flat's refreshTheme (maptheme.js Themes.refreshTheme): the
4701
4781
  // theme's data is loaded again — a broker is called again — and
4702
4782
  // replaces the old features in place (draw order, legend, style
@@ -4825,10 +4905,15 @@
4825
4905
  else map.fitBounds(bounds, { padding: 30, animate: false, maxZoom: 16 });
4826
4906
  }
4827
4907
 
4828
- async function loadNamedTheme(rt, force) {
4908
+ // quiet: the broker's data fills only this theme — not broadcast to
4909
+ // the other themes of the name (flat's refreshTheme reloads one theme;
4910
+ // setExternalData only releases themes still waiting for the data,
4911
+ // maptheme.js 2570-2591)
4912
+ async function loadNamedTheme(rt, force, quiet = false) {
4829
4913
  const spec = normalizeTheme(rt._definition);
4830
4914
  const name = String(spec.data.name);
4831
4915
  const broker = brokerTheme(spec);
4916
+ if (quiet && broker) _quietNamedData.set(name, (_quietNamedData.get(name) || 0) + 1);
4832
4917
  rt._loading = (rt._loading || 0) + 1;
4833
4918
  if (broker) { pendingLoads++; rt._brokerLoading = (rt._brokerLoading || 0) + 1; setDataLoading(); }
4834
4919
  try {
@@ -4850,6 +4935,7 @@
4850
4935
  else console.error(`[ixmaps-gl] theme "${themeIdOf(rt)}": no data —`, err);
4851
4936
  } finally {
4852
4937
  rt._loading--;
4938
+ if (quiet && broker) { const n = (_quietNamedData.get(name) || 1) - 1; if (n) _quietNamedData.set(name, n); else _quietNamedData.delete(name); }
4853
4939
  if (broker) { pendingLoads--; rt._brokerLoading--; setDataLoading(); }
4854
4940
  }
4855
4941
  }
@@ -4898,7 +4984,11 @@
4898
4984
  }
4899
4985
  // flat, after a FEATURE theme is drawn (maptheme.js 18947-18962): the
4900
4986
  // other themes on its layer are placed again, and a data.query broker
4901
- // among them is refreshed 500 ms later
4987
+ // among them is refreshed 500 ms later — quietly: its new data fills
4988
+ // that theme only. Broadcast to every theme of the name, a FEATURE
4989
+ // theme sharing the broker's data name (the fire areas page: SIF
4990
+ // polygons and chart both "data_2") was refilled, drawn, refreshed
4991
+ // its dependent again — an endless reload of ~15k records every 3 s
4902
4992
  function featureDependents(featureRt) {
4903
4993
  runtimes.forEach(dep => {
4904
4994
  if (dep === featureRt || dep.flags.has('FEATURE') || dep.flags.has('FEATURES')) return;
@@ -4906,7 +4996,7 @@
4906
4996
  const spec = normalizeTheme(dep._definition || {});
4907
4997
  if (dep._named && spec.data && spec.data.query) {
4908
4998
  clearTimeout(dep._dependentTimer);
4909
- dep._dependentTimer = setTimeout(() => { if (runtimes.includes(dep)) loadNamedTheme(dep, true); }, 500);
4999
+ dep._dependentTimer = setTimeout(() => { if (runtimes.includes(dep)) loadNamedTheme(dep, true, true); }, 500);
4910
5000
  } else if (dep._named && dep._lastTable && !dep._loading) {
4911
5001
  fillNamedTheme(dep, spec, dep._lastTable, null);
4912
5002
  }
@@ -4915,10 +5005,18 @@
4915
5005
 
4916
5006
  // One theme definition (real ixmaps-flat's shape — from a builder's
4917
5007
  // definition() or a project file) → a new runtime on this map. Shared
4918
- // by defineLayer and loadProject. Purely additive, see defineLayer.
5008
+ // by defineLayer and loadProject. Purely additive across LAYER names —
5009
+ // several themes legitimately share one (a FEATURE base + its CHART
5010
+ // overlay, the CHOROPLETH/FEATURE-donor pair) — but a re-defined
5011
+ // THEME ID (style.name/meta.name) replaces its own previous theme, as
5012
+ // flat itself does ("defining a theme with the same name replaces it
5013
+ // in place"; a page's live-refresh loop relies on it, e.g. the NYC
5014
+ // status page re-defining its two themes every 10s).
4919
5015
  async function defineFromDefinition(def) {
4920
5016
  if (validation) validation.definition(def);
4921
5017
  const pdef = projectThemeToDefinition(def);
5018
+ const replacedId = (pdef.meta && pdef.meta.name) || (pdef.style && pdef.style.name);
5019
+ if (replacedId) removeThemeById(replacedId);
4922
5020
  let spec = normalizeTheme(pdef);
4923
5021
  let raw;
4924
5022
  const deferred = deferredFeatureLoad(spec, map.getZoom());
@@ -5572,8 +5670,11 @@
5572
5670
  // itself, which stays purely additive for every other caller —
5573
5671
  // see its own comment for why (the CHOROPLETH/FEATURE-donor
5574
5672
  // pairing needs two runtimes sharing one name to coexist).
5673
+ // NO same-LAYER-name auto-remove (see engineApi.layer's own
5674
+ // comment — several themes legitimately share one layer name);
5675
+ // a re-defined THEME ID replaces its own previous theme inside
5676
+ // defineFromDefinition instead
5575
5677
  lb.define = () => {
5576
- resolvedApi.removeTheme(nameOrBuilder);
5577
5678
  return resolvedApi.defineLayer(lb);
5578
5679
  };
5579
5680
  }
@@ -5856,6 +5957,41 @@
5856
5957
  * (flatChartBranch(flags) === 'bubble' ? 0.5 : 1);
5857
5958
  return Math.max(VALUE_RADIUS_MIN, Math.min(VALUE_RADIUS_MAX, fixed * zoomFactor));
5858
5959
  }
5960
+ // NOSIZE: every chart ONE fixed radius, whatever the item's value;
5961
+ // normalsizevalue doesn't scale it. Which one depends on flat's SYMBOL
5962
+ // sub-branch:
5963
+ // - a CATEGORICAL theme without AGGREGATE/GROUP ("symbol equals
5964
+ // value", maptheme.js 21566-21625, "GR 01.07.2023 redesign size
5965
+ // handling"): the normal radius itself
5966
+ // - every other symbol chart (the per-part ladder, 21829-21896):
5967
+ // SEQUENCE normalX(chart size / 3), 2/3 of it; otherwise
5968
+ // nMaxRadius / 5 — measured on the fire areas page's SIF chart
5969
+ // (CHART|SYMBOL|NOSIZE: r 60 units at normalX(1) 20, i.e. 3 of the
5970
+ // 15-unit normal radius)
5971
+ // The BUBBLE draw keeps its own older NOSIZE value, half of it
5972
+ // (maptheme.js 19839)
5973
+ if (flags && flags.has('NOSIZE') && !flags.has('PLOT') && !flags.has('GRIDSIZE')) {
5974
+ const symbolEqualsValue = flags.has('CATEGORICAL') && !flags.has('AGGREGATE') && !flatFlag(flags, 'GROUP');
5975
+ const fixed = NORMAL_RADIUS_PX * (styleNum(style.scale) || 1)
5976
+ * (flatChartBranch(flags) === 'bubble' ? 0.5
5977
+ : symbolEqualsValue ? 1
5978
+ : flatFlag(flags, 'SEQUENCE') ? 2 / 3 : 1 / 5);
5979
+ return Math.max(VALUE_RADIUS_MIN, Math.min(VALUE_RADIUS_MAX, fixed * zoomFactor));
5980
+ }
5981
+ // Same redesign, the rule around it (maptheme.js 21612-21625) — only in
5982
+ // flat's "symbol equals value" sub-branch, a CATEGORICAL theme without
5983
+ // AGGREGATE/GROUP: the symbol resizes by itemA[a].nSize, which flat only
5984
+ // sets for a theme with a bound szSizeField (maptheme.js 9144-9153), so
5985
+ // WITHOUT a size field every item keeps the DEFAULT normal radius,
5986
+ // whatever its value. Every other symbol chart (the per-part ladder,
5987
+ // 21850-21851) and BUBBLE (19851) scale by the value — below.
5988
+ if (flags && flags.has('SYMBOL') && !flags.has('BUBBLE') && !flags.has('USER')
5989
+ && !flags.has('FIXSIZE') && !flags.has('PLOT') && !flags.has('GRIDSIZE')
5990
+ && flags.has('CATEGORICAL') && !flags.has('AGGREGATE') && !flatFlag(flags, 'GROUP')
5991
+ && !style.sizefield) {
5992
+ const fixed = NORMAL_RADIUS_PX * (styleNum(style.scale) || 1);
5993
+ return Math.max(VALUE_RADIUS_MIN, Math.min(VALUE_RADIUS_MAX, fixed * zoomFactor));
5994
+ }
5859
5995
  // Real default (maptheme.js ~line 5059) when the theme sets no
5860
5996
  // normalsizevalue: the dataset's own max value on the bound size
5861
5997
  // field (maxSizeValue, from LayerRuntime._prepare), not a fixed
@@ -6168,9 +6304,9 @@
6168
6304
 
6169
6305
  // a chart's VALUES text (maptheme.js 20979-21017): an explicit valuefield
6170
6306
  // prints that field of the record ($title$: the title; a non-number as
6171
- // is), else the value; numbers formatted with valuedecimals (flat's `||`:
6172
- // 0 or unset → 1 decimal below 1 — or when the max value is at most 1 —
6173
- // else none) plus the unit, which flat stores with a leading space unless
6307
+ // is), else the value; numbers formatted with valuedecimals when set
6308
+ // (explicitValueDecimals: a given "0" counts), else 1 decimal below 1 —
6309
+ // or when the max value is at most 1 — else none, plus the unit, which flat stores with a leading space unless
6174
6310
  // it starts with "." (9675) and appends only up to 5 characters.
6175
6311
  // A CATEGORICAL bubble prints its size value when size is bound to a
6176
6312
  // field (its value, see resolveAggregateValue), as flat does; with
@@ -6184,11 +6320,30 @@
6184
6320
  if (!field || field !== binding.value || !flags.has('CATEGORICAL') || !labels || labels[i] == null) return null;
6185
6321
  return { [field]: labels[i] };
6186
6322
  }
6323
+ // style.valuedecimals as flat reads it: nValueDecimals keeps the raw
6324
+ // style string, so an explicit "0" is truthy in flat's `nValueDecimals ||
6325
+ // default` and counts (fire areas page: valuedecimals "0", 0.48 → "1 ha");
6326
+ // null when not set
6327
+ // (valuedecimals is therefore not typed: STYLE_NUMBER_KEYS). Flat's
6328
+ // truthiness: unset, "" and a numeric 0 fall back to the default
6329
+ function explicitValueDecimals(style) {
6330
+ const v = style && style.valuedecimals;
6331
+ if (!v) return null;
6332
+ const n = Number(v);
6333
+ return Number.isFinite(n) ? n : null;
6334
+ }
6335
+ // the legend's decimals (legend.js 610): valuedecimals when defined, else 2
6336
+ function legendDecimals(style) {
6337
+ const v = style && style.valuedecimals;
6338
+ const n = v == null || v === '' ? NaN : Number(v);
6339
+ return Number.isFinite(n) ? n : 2;
6340
+ }
6187
6341
  function flatValueText(raw, title, value, style, flags, opts = {}) {
6188
6342
  const units = style.units ? String(style.units) : '';
6189
6343
  const unit = units ? (units[0] === '.' ? '' : ' ') + units : '';
6190
6344
  const unitText = unit.length <= 5 ? unit : '';
6191
- const dec = v => styleNum(style.valuedecimals) || ((v < 1 || (opts.maxValue != null && opts.maxValue <= 1)) ? 1 : 0);
6345
+ const explicitDec = explicitValueDecimals(style);
6346
+ const dec = v => (explicitDec != null ? explicitDec : ((v < 1 || (opts.maxValue != null && opts.maxValue <= 1)) ? 1 : 0));
6192
6347
  const signed = (v, text) => {
6193
6348
  if (flags && (flags.has('DIFFERENCE') || flags.has('RELATIVE') || flags.has('SIGN'))) {
6194
6349
  if (v === 0) return '+/-' + text;
@@ -6201,7 +6356,7 @@
6201
6356
  if (field && raw && raw[field] != null && raw[field] !== '') {
6202
6357
  if (isNaN(raw[field])) return String(raw[field]);
6203
6358
  const v = Number(raw[field]);
6204
- return signed(v, flatGroupedValue(v, styleNum(style.valuedecimals) || (v < 1 ? 1 : 0), opts.noBreaks) + unitText);
6359
+ return signed(v, flatGroupedValue(v, explicitDec != null ? explicitDec : (v < 1 ? 1 : 0), opts.noBreaks) + unitText);
6205
6360
  }
6206
6361
  return signed(value, flatGroupedValue(value, dec(value), opts.noBreaks) + unitText);
6207
6362
  }
@@ -6303,19 +6458,29 @@
6303
6458
  }
6304
6459
 
6305
6460
  function valuesHiddenByScale(style, zoom) {
6306
- const m = /^1:(\d+(?:\.\d+)?)$/.exec(style.valueupper || '');
6461
+ // flat accepts both 'valueupper' and 'valuesupper' (maptheme.js 1710,
6462
+ // "GR 15.03.2017 accept also 'valuesupper'"), same for the lower pair
6463
+ const m = /^1:(\d+(?:\.\d+)?)$/.exec(style.valueupper || style.valuesupper || '');
6307
6464
  if (!m) return false;
6308
6465
  const upperRatio = parseFloat(m[1]);
6309
6466
  const scaleDenominator = scaleDenominatorAt(zoom);
6310
6467
  return scaleDenominator > upperRatio;
6311
6468
  }
6469
+ // flat's chart LABEL scale gate (maptheme.js 24536-24537): the label is
6470
+ // visible while the scale passes <= against BOTH bounds — flat's own
6471
+ // lower test reads <= too, kept as-is for parity
6472
+ function labelHiddenByScale(style, zoom) {
6473
+ const upper = scaleDenom(style.labelupper), lower = scaleDenom(style.labellower);
6474
+ const scale = scaleDenominatorAt(zoom);
6475
+ return !!((upper && scale > upper) || (lower && scale > lower));
6476
+ }
6312
6477
 
6313
6478
  // contrasting text color against a bubble's own fill — standard relative
6314
6479
  // luminance; approximates the real engine's __maptheme_getChartColors(...)
6315
6480
  // .textColor (white text on ixmaps' typically dark/saturated palette,
6316
6481
  // dark text on pale fills) without needing its exact color-space table.
6317
6482
  function contrastTextColor(fillRgb) {
6318
- const [r, g, b] = fillRgb;
6483
+ const [r, g, b] = fillRgb || [128, 128, 128];
6319
6484
  const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
6320
6485
  return luminance > 0.6 ? [30, 30, 30] : [255, 255, 255];
6321
6486
  }
@@ -6830,6 +6995,25 @@
6830
6995
  return parts;
6831
6996
  }
6832
6997
 
6998
+ // CHART|SYMBOL|SEQUENCE|RINGS (flat maptheme.js 21293-21318): the parts
6999
+ // nest on the center — each non-zero part sized by the running total of
7000
+ // the non-zero parts up to it (its own value included), biggest first
7001
+ // so the smaller ones stay on top; parts of value 0 are left out.
7002
+ // radiusOf(total) is the ring's radius. Measured on flat (NYC status:
7003
+ // 63 | 75 | 3 → r 161.5 | 353.8 | 361.5 at max radius 300, max value
7004
+ // 117). Returns { i, v (the part's own value), c (running total), r, x, y }.
7005
+ function ringsLayout(counts, radiusOf) {
7006
+ const parts = [];
7007
+ let total = 0;
7008
+ counts.forEach((v, i) => {
7009
+ if (!v) return;
7010
+ total += v;
7011
+ const r = radiusOf(total);
7012
+ if (r > 0) parts.push({ i, v, c: total, r, x: 0, y: 0 });
7013
+ });
7014
+ return parts.sort((a, b) => b.c - a.c);
7015
+ }
7016
+
6833
7017
  // ---------------------------------------------------------------
6834
7018
  // Classification — pure functions (no LayerRuntime state): class
6835
7019
  // breaks for a numeric field, and which class a value falls in.
@@ -7966,6 +8150,18 @@
7966
8150
  return out;
7967
8151
  }
7968
8152
 
8153
+ // Every runtime ever built gets a fresh number: a theme rebuilt through
8154
+ // removeTheme() + defineLayer() (a page's own refresh loop, e.g. the fire
8155
+ // page's KDE isobands) is a NEW runtime whose data may differ from the
8156
+ // runtime it replaces — if its deck.gl layer kept the SAME id, deck would
8157
+ // update the old layer's state in place, and under the interleaved overlay
8158
+ // those in-place updates can leave the PREVIOUS generation's geometry on
8159
+ // screen (the exact failure _dataLayerId's own comment documents for
8160
+ // replaceFeatures: "updates in place, arriving over each other, left
8161
+ // triangles across sections"). A unique -dN suffix per runtime forces deck
8162
+ // to build a genuinely new layer and dispose the old one.
8163
+ let LAYER_DATA_GENERATION = 0;
8164
+
7969
8165
  class LayerRuntime {
7970
8166
  // spec: a normalizeTheme() result — never a raw LayerBuilder
7971
8167
  constructor(spec, fc, mapOptions) {
@@ -7974,6 +8170,8 @@
7974
8170
  this.style = spec.style;
7975
8171
  this.flags = symbolsFlagCompat(spec.flags, spec.style);
7976
8172
  this.meta = spec.meta;
8173
+ // see LAYER_DATA_GENERATION above — unique per runtime, see _dataLayerId
8174
+ this._dataGen = ++LAYER_DATA_GENERATION;
7977
8175
  // GL-PORT COMPAT: retained only for ixmaps.getThemeObj()'s szFilter
7978
8176
  // (real ixmaps-flat global compat shim, see _globalThemeRegistry) —
7979
8177
  // this engine's own filtering is fully static/load-time (see
@@ -8144,6 +8342,21 @@
8144
8342
  // ("investimento"/"tamponamento"/...) needs the display text, not
8145
8343
  // a raw numeric code it could never match against.
8146
8344
  this.categoryColorsRgb = resolveClassColors(this.style.colorscheme, this.categoryDisplayLabels, this.style.classes);
8345
+ } else if (this._isMultiFieldSequence()) {
8346
+ // CHART|SYMBOL|SEQUENCE over several value fields (no CATEGORICAL,
8347
+ // no AGGREGATE): one part per field, colored by the colorscheme in
8348
+ // field order, labeled by .style({label}) (flat's szFieldsA /
8349
+ // szLabelA). A colorscheme entry "none" is a part without fill —
8350
+ // only its outline in linecolor (flat: fill:none). The parts' max
8351
+ // value is flat's nMax: the biggest single field value of any
8352
+ // record (maptheme.js nMaxA → nMax), measured on the NYC status page
8353
+ Object.assign(this, computeMultiFieldClasses(this.binding, this.style));
8354
+ this.categoryIndexByLabel = new Map(this.categoryLabels.map((v, i) => [v, i]));
8355
+ const scheme = Array.isArray(this.style.colorscheme) ? this.style.colorscheme : [];
8356
+ this._partNoFill = new Set(this._multiFields.map((f, i) => i).filter(i => String(scheme[i]).trim().toLowerCase() === 'none'));
8357
+ let max = 0;
8358
+ 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; }
8359
+ this._multiFieldPartMax = max || 1;
8147
8360
  } else if (this.binding.value) {
8148
8361
  // real engine's OTHER coloring mode (maptheme.js distributeValues,
8149
8362
  // partsA): a NUMERIC bound value, not CATEGORICAL, is classed into
@@ -8156,6 +8369,24 @@
8156
8369
  // "which bucket does this feature belong to", and once buckets +
8157
8370
  // colors exist, everything downstream is identical.
8158
8371
  this._buildPartsA();
8372
+ } else if (this.style.colorscheme != null) {
8373
+ // GL-PORT COMPAT: a theme with NO classification at all (no
8374
+ // CATEGORICAL categories, no numeric binding.value to range-class)
8375
+ // still has classes in the real engine — exactly ONE, its implicit
8376
+ // bucket: _resolveClassIndex returns 0 for every feature, and flat
8377
+ // colors them all with colorScheme[0] (every MapTheme is built
8378
+ // from its colorscheme array — one entry, one class, e.g. the
8379
+ // fire page's KDE isobands: FEATURE|CHOROPLETH, colorscheme
8380
+ // "#dd0088", no value field — every iso band one flat magenta).
8381
+ // Without this branch categoryColorsRgb stayed undefined while
8382
+ // cat was still 0, and the choropleth's getFillColor crashed on
8383
+ // categoryColorsRgb[0] — deck.gl killed the layer at init and
8384
+ // the theme never drew. A colorscheme that resolves to NO colors
8385
+ // (the explicit no-fill ["none"] — flat draws unfilled symbols,
8386
+ // only their values/boxes/titles) keeps that: resolving it to the
8387
+ // fallback palette would paint symbol circles flat never draws.
8388
+ const resolved = resolveColorScheme(this.style.colorscheme, [''], this.style.classes);
8389
+ this.categoryColorsRgb = resolved ? resolved.map(c => Array.isArray(c) ? c : (parseCssColor(c) || [130, 130, 130])) : null;
8159
8390
  }
8160
8391
 
8161
8392
  // facet-driven dynamic filter state (see setFacetFilter/getFacets) —
@@ -8225,6 +8456,17 @@
8225
8456
  // this with AGGREGATE clustering yet, so a clustered GROUP bubble
8226
8457
  // always stays circular (_buildBubbleIcon, untouched).
8227
8458
  _resolveSymbolShape(props) {
8459
+ // flat's class→symbol mapping (maptheme.js 1417, 2074-2077, 12937):
8460
+ // `symbols` WITHOUT a symbolfield names the shape per COLOR class —
8461
+ // szSymbolsA[class], the last entry repeating when the array is
8462
+ // shorter than the classes (a single entry, like the fire page's
8463
+ // symbols: ["square"], shapes every class). Only GL's implemented
8464
+ // shapes draw (drawSymbolPath); flat-only names fall back to circle.
8465
+ if (!this.style.symbolfield && Array.isArray(this.style.symbols)) {
8466
+ const s = this.style.symbols;
8467
+ const cat = props && props.cat != null ? props.cat : 0;
8468
+ return normalizeSymbolShape(s[Math.min(cat, s.length - 1)]);
8469
+ }
8228
8470
  if (!this.style.symbolfield || !Array.isArray(this.style.symbolvalues) || !Array.isArray(this.style.symbols)) return 'circle';
8229
8471
  // CORRECTED: found live via pickObject, not assumed — the properties
8230
8472
  // this actually receives at render time (from _computeAggregatedItems'
@@ -8286,6 +8528,19 @@
8286
8528
  // they'd never even share a cell in the first place). Every
8287
8529
  // feature goes into ONE bucket; _buildChartLayers resolves the real
8288
8530
  // 7-class color once it knows each cell's actual aggregated total.
8531
+ if (this._multiFields && this._isMultiFieldSequence()) {
8532
+ // one bucket; each record carries its field values as the chart's
8533
+ // parts. Its value — the draw order — is the first field's, as
8534
+ // flat's chart node value (NYC status: value="63" = e-bikes)
8535
+ this._featuresByCategory = [sourceFeatures.map(f => {
8536
+ const parts = this._multiFields.map(k => parseFloat(f.properties[k]) || 0);
8537
+ return { type: 'Feature', geometry: f.geometry, properties: { value: parts[0], parts, raw: f.properties } };
8538
+ })];
8539
+ this._clusterIndices = null;
8540
+ this._clusterRadiusPx = null;
8541
+ this._aggregateStatsCache = null;
8542
+ return;
8543
+ }
8289
8544
  if (this._rangeClassed) {
8290
8545
  // flat colors/classes an aggregated cell by the SUM of the value
8291
8546
  // field (nValuesA, maptheme.js:10870ff; classified by nValuesA[0],
@@ -8772,7 +9027,7 @@
8772
9027
  return this._glowIconCache.get(key);
8773
9028
  }
8774
9029
 
8775
- _buildBubbleIcon(counts, colors, marked = null) {
9030
+ _buildBubbleIcon(counts, colors, marked = null, shapes = null) {
8776
9031
  // The cache key quantizes each count's RATIO to the group's total,
8777
9032
  // not the raw count — computeBubblePackLayout's radii are purely
8778
9033
  // sqrt(c/total), so two clusters with wildly different absolute
@@ -8802,7 +9057,7 @@
8802
9057
  // coarse share buckets that can put a small marked part at 0
8803
9058
  const key = counts.map(c => Math.round((c / total) * RATIO_BUCKETS)).join('-') + (marked
8804
9059
  ? '|iso:' + counts.map((c, i) => (c > 0 && marked.has(i) ? i + ':' + Math.round(Math.sqrt(c / total) * 100) : null)).filter(Boolean).join(',')
8805
- : '');
9060
+ : '') + (shapes ? '|sym:' + shapes.map(v => String(v).toLowerCase()).join(',') : '');
8806
9061
  if (this._iconCache.has(key)) return this._iconCache.get(key);
8807
9062
  const size = BUBBLE_ICON_SIZE;
8808
9063
  const canvas = document.createElement('canvas');
@@ -8811,8 +9066,16 @@
8811
9066
  const { present, radii, offsets, fitScale } = (marked && isolatedBubblePackLayout(counts, size, marked)) || computeBubblePackLayout(counts, size);
8812
9067
  const cx = size / 2, cy = size / 2;
8813
9068
  present.forEach((p, i) => {
9069
+ // flat draws each part of an aggregated SYMBOL theme as ITS class's
9070
+ // symbol shape (the szSymbol switch runs per part, maptheme.js
9071
+ // 21995-22060) — `symbols` names them per class like
9072
+ // _resolveSymbolShape; circles when the name isn't one GL draws
9073
+ const shape = shapes ? normalizeSymbolShape(shapes[Math.min(p.i, shapes.length - 1)]) : 'circle';
9074
+ const px = cx + offsets[i].x * fitScale, py = cy + offsets[i].y * fitScale;
8814
9075
  ctx.beginPath();
8815
- ctx.arc(cx + offsets[i].x * fitScale, cy + offsets[i].y * fitScale, radii[i] * fitScale, 0, Math.PI * 2);
9076
+ if (shape === 'circle') ctx.arc(px, py, radii[i] * fitScale, 0, Math.PI * 2);
9077
+ else drawSymbolPath(ctx, shape, px, py, radii[i] * fitScale);
9078
+ ctx.closePath();
8816
9079
  ctx.fillStyle = `rgb(${colors[p.i].join(',')})`;
8817
9080
  ctx.globalAlpha = 0.9; ctx.fill(); ctx.globalAlpha = 1;
8818
9081
  ctx.lineWidth = 1; ctx.strokeStyle = '#fff'; ctx.stroke();
@@ -8862,14 +9125,22 @@
8862
9125
  ctx.beginPath();
8863
9126
  drawSymbolPath(ctx, shape, c, c, r);
8864
9127
  ctx.closePath();
9128
+ // flat's "empty" (maptheme.js 21629-21631): the circle geometry, but
9129
+ // a fully transparent fill and a gray outline (fill-opacity:0,
9130
+ // stroke #888888) — a shapeless spot marker
8865
9131
  ctx.fillStyle = `rgb(${colorRgb.join(',')})`;
8866
- ctx.globalAlpha = opacity;
9132
+ ctx.globalAlpha = shape === 'empty' ? 0 : opacity;
8867
9133
  ctx.fill();
8868
9134
  if (borderWidth > 0 && borderColorRgb) {
8869
9135
  ctx.globalAlpha = 1;
8870
9136
  ctx.strokeStyle = `rgb(${borderColorRgb.join(',')})`;
8871
9137
  ctx.lineWidth = borderWidth;
8872
9138
  ctx.stroke();
9139
+ } else if (shape === 'empty') {
9140
+ ctx.globalAlpha = 1;
9141
+ ctx.strokeStyle = '#888888';
9142
+ ctx.lineWidth = 1;
9143
+ ctx.stroke();
8873
9144
  }
8874
9145
  const icon = { url: canvas.toDataURL(), width: size, height: size, anchorX: size / 2, anchorY: size / 2, id: key };
8875
9146
  return this._cacheIcon(key, icon);
@@ -9146,7 +9417,8 @@
9146
9417
  _formatTooltipValue(value) {
9147
9418
  const num = Number(value);
9148
9419
  if (isNaN(num)) return String(value);
9149
- const rawDecimals = styleNum(this.style.valuedecimals);
9420
+ const vd = this.style.valuedecimals;
9421
+ const rawDecimals = vd == null || vd === '' ? NaN : Number(vd);
9150
9422
  const decimals = isNaN(rawDecimals) ? 2 : rawDecimals;
9151
9423
  // flat's __formatValue (mapscript.js 6268ff) rounds twice: first to
9152
9424
  // one decimal more (toFixed), then to the precision (Math.round) —
@@ -9259,7 +9531,16 @@
9259
9531
 
9260
9532
  _buildFeaturesLayers(zoom, bbox) {
9261
9533
  const cs = this.style.colorscheme;
9262
- const raw = Array.isArray(cs) ? cs[0] : cs;
9534
+ // a generated scheme's first entry is the class COUNT, not a color —
9535
+ // flat colors an unclassed theme with the sweep's FIRST generated
9536
+ // color (maptheme colorScheme[0] after realize generates the sweep),
9537
+ // never the bare count string (a near-white dot for "5","white","blue"
9538
+ // — the NYC page's station base)
9539
+ let raw = Array.isArray(cs) ? cs[0] : cs;
9540
+ if (Array.isArray(cs) && isGeneratedColorScheme(cs)) {
9541
+ const sweep = flatColorSweep(cs[1], cs[2], Number(cs[0]) || 5, cs[3], cs[4]);
9542
+ if (sweep && sweep.length) raw = sweep[0];
9543
+ }
9263
9544
  const filled = raw !== 'none';
9264
9545
  // FEATURE|CATEGORICAL: polygons coloured by their category (flat colours
9265
9546
  // each shape by its class), not one flat fill
@@ -9288,7 +9569,7 @@
9288
9569
  getFillColor: byCategory
9289
9570
  ? f => {
9290
9571
  const cat = this._resolveClassIndex(f.properties[this.binding.value]);
9291
- return [...(cat != null ? this.categoryColorsRgb[cat] : nodataRgb).slice(0, 3), catAlpha];
9572
+ return [...(cat != null ? (this.categoryColorsRgb || [])[cat] || nodataRgb : nodataRgb).slice(0, 3), catAlpha];
9292
9573
  }
9293
9574
  : filled ? [...hexOrNamedToRgb(raw).slice(0, 3), Math.round(255 * cssColorAlpha(raw) * (styleNum(this.style.fillopacity) || 1))] : [0, 0, 0, 0],
9294
9575
  getLineColor: this.style.linecolor
@@ -9495,7 +9776,7 @@
9495
9776
  getFillColor: d => {
9496
9777
  if (!hasData(d)) return [0, 0, 0, 0];
9497
9778
  const rgb = d.properties.composedColor
9498
- || (d.properties.cat != null ? this.categoryColorsRgb[d.properties.cat] : null) || fallbackRgb;
9779
+ || (d.properties.cat != null ? (this.categoryColorsRgb || [])[d.properties.cat] : null) || fallbackRgb;
9499
9780
  const evidence = unmarkedEvidence(d);
9500
9781
  if (evidence === 'hide') return [0, 0, 0, 0];
9501
9782
  if (evidence === 'gray') return [...grayOf(rgb), 90];
@@ -9510,7 +9791,15 @@
9510
9791
  // per-feature alpha (baked above) already carries the resolved
9511
9792
  // base opacity — a layer-level opacity on TOP of that would
9512
9793
  // multiply it a second time.
9513
- opacity: dopacityActive ? 1 : baseOpacity
9794
+ opacity: dopacityActive ? 1 : baseOpacity,
9795
+ // Same coplanar z-fighting fix as the FEATURE/FEATURES GeoJsonLayer
9796
+ // (see _buildFeaturesLayers' own comment): under globe projection
9797
+ // the interleaved polygon surface and MapLibre's own sphere mesh
9798
+ // compete for the same depth-buffer real estate and the fill comes
9799
+ // out moiré/hatched ("holes"), never under flat Mercator. Draw
9800
+ // order against other polygon layers is unchanged — depthCompare
9801
+ // only settles the fight against the map's own surface.
9802
+ parameters: { depthCompare: 'always' }
9514
9803
  })];
9515
9804
  }
9516
9805
 
@@ -9566,6 +9855,13 @@
9566
9855
  _isItemPlot() {
9567
9856
  return this.flags.has('PLOT') && !this.flags.has('GRIDSIZE') && String(this.binding.value || '').includes('|');
9568
9857
  }
9858
+ // a per-item SEQUENCE chart: CHART|SYMBOL|SEQUENCE, several value
9859
+ // fields per item, one part per field (no CATEGORICAL, no AGGREGATE)
9860
+ _isMultiFieldSequence() {
9861
+ return this.flags.has('CHART') && this.flags.has('SYMBOL') && flatFlag(this.flags, 'SEQUENCE')
9862
+ && !this.flags.has('CATEGORICAL') && !this.flags.has('AGGREGATE') && !this.flags.has('PLOT')
9863
+ && String(this.binding.value || '').includes('|');
9864
+ }
9569
9865
  _itemPlotFields() {
9570
9866
  return String(this.binding.value || '').split('|');
9571
9867
  }
@@ -10688,7 +10984,7 @@
10688
10984
  if (userLayers) return layers.concat(userLayers);
10689
10985
  }
10690
10986
  if (this.flags.has('LABEL')) return layers.concat(this._buildLabelChartLayers(combined, zoom, liveZoom, sizeValueOf));
10691
- if (flatFlag(this.flags, 'SEQUENCE') && this.flags.has('CATEGORICAL') && this.flags.has('AGGREGATE')) {
10987
+ if ((flatFlag(this.flags, 'SEQUENCE') && this.flags.has('CATEGORICAL') && this.flags.has('AGGREGATE')) || (this._multiFields && this._isMultiFieldSequence())) {
10692
10988
  return layers.concat(this._buildSequenceChartLayers(combined, zoom, liveZoom));
10693
10989
  }
10694
10990
  const fillOpacity = styleNum(this.style.fillopacity) || 0.85;
@@ -10726,7 +11022,7 @@
10726
11022
  id: `ix-symbolshadow-${this.name}-g${this._iconGeneration}`,
10727
11023
  data: combined, pickable: false,
10728
11024
  getPosition: d => d.geometry.coordinates,
10729
- getIcon: d => this._getShadowIcon(radiusOf(d), blurPx, d.properties.counts ? 'circle' : this._resolveSymbolShape(d.properties)),
11025
+ getIcon: d => this._getShadowIcon(radiusOf(d), blurPx, d.properties.counts && !Array.isArray(this.style.symbols) ? 'circle' : this._resolveSymbolShape(d.properties)),
10730
11026
  getSize: d => radiusOf(d) * 3, // disc = 2/3 of the icon → its diameter is 2r
10731
11027
  getPixelOffset: alignOf.active ? d => { const o = alignOf(d); return [o[0] + dx, o[1] + dy]; } : [dx, dy],
10732
11028
  getColor: d => {
@@ -10751,7 +11047,7 @@
10751
11047
  id: `ix-glow-${this.name}-g${this._iconGeneration}`,
10752
11048
  data: combined, pickable: false,
10753
11049
  getPosition: d => d.geometry.coordinates,
10754
- getIcon: d => this._getGlowIcon(this.categoryColorsRgb[d.properties.counts ? dominant(this._groupIsolation(d) ? d.properties.counts.map((c, i) => (this._markedClasses.has(i) ? c : 0)) : d.properties.counts) : d.properties.cat]),
11050
+ getIcon: d => this._getGlowIcon((this.categoryColorsRgb || [])[d.properties.counts ? dominant(this._groupIsolation(d) ? d.properties.counts.map((c, i) => (this._markedClasses.has(i) ? c : 0)) : d.properties.counts) : d.properties.cat]),
10755
11051
  getSize: d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue) * (d.properties.counts ? 9 : 11),
10756
11052
  getColor: d => [255, 255, 255, this._iconAlpha(d)],
10757
11053
  ...(alignOf.active ? { getPixelOffset: alignOf } : {}),
@@ -10798,15 +11094,29 @@
10798
11094
  // Box and title are removed outside boxupper/boxlower.
10799
11095
  // Flat draws box and title in the chart group, which is scaled by
10800
11096
  // style.scale: the units below are u · scale. Its size measure W is
10801
- // the chart's width (2r; with MULTIQUAD the width of the whole grid
10802
- // at the position, the box enclosing all of it); flat's chart size
10803
- // is 30 units.
11097
+ // the width of the drawn chart (flat's getBox of the chart group,
11098
+ // 18785): the symbol (with MULTIQUAD the whole grid at the position,
11099
+ // the box enclosing all of it), its value text and its label
11100
+ // (chartItemExtent); flat's chart size is 30 units.
11101
+ // DTEXT: the title font follows the chart, max(0.9u, min(W/5,
11102
+ // 14u / scale)) (18815) — measured on the fire areas page's SIF chart
11103
+ // (W 362 units → font 72.35).
10804
11104
  const boxShown = flatFlag(this.flags, 'BOX') && !boxHiddenByScale(this.style, liveZoom);
10805
11105
  const circular = flatFlag(this.flags, 'CIRCULAR');
10806
11106
  const unitPx = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(this.style.scale) || 1);
10807
11107
  const boxMarginU = styleNum(this.style.boxmargin) || 2;
10808
11108
  const titleShown = boxShown && flatFlag(this.flags, 'TITLE') && !!this.binding.title;
10809
- const titleFontPx = 5 * unitPx * (styleNum(this.style.textscale) || styleNum(this.style.valuescale) || 1);
11109
+ const titleScale = styleNum(this.style.textscale) || styleNum(this.style.valuescale) || 1;
11110
+ const titleFontOf = W => (flatFlag(this.flags, 'DTEXT')
11111
+ ? Math.max(0.9 * unitPx, Math.min(W / 5, 14 * unitPx / (styleNum(this.style.scale) || 1)))
11112
+ : 5 * unitPx) * titleScale;
11113
+ // the chart's value text and label (see VALUES and LABEL below), shared
11114
+ // with the box: flat's box encloses whatever the chart group draws
11115
+ const valuesShown = this.flags.has('VALUES') && !valuesHiddenByScale(this.style, zoom);
11116
+ const valueTextOf = valuesShown ? this._chartValueTextFn() : null;
11117
+ const labelShown = !!this.style.labelfield && !flatFlag(this.flags, 'NORMSIZE') && !flatFlag(this.flags, 'ZOOM')
11118
+ && !labelHiddenByScale(this.style, zoom);
11119
+ const labelOf = labelShown ? this._chartLabelFn(unitPx) : null;
10810
11120
  // flat: fill-opacity (boxopacity || 1) on the raw style value — the
10811
11121
  // usual string "0" is transparent; typed here, a given 0 is too
10812
11122
  const boxOpacity = isNaN(styleNum(this.style.boxopacity)) ? 1 : styleNum(this.style.boxopacity);
@@ -10817,9 +11127,13 @@
10817
11127
  const r = valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
10818
11128
  const ext = multi ? multi.extent.get(d) : [0, 0, 0, 0];
10819
11129
  const chart = [ext[0] - r, ext[1] - r, ext[2] + r, ext[3] + r];
11130
+ 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]); };
11131
+ if (valueTextOf && !d.properties.counts) { const v = valueTextOf(d, r); if (v) grow(v.extent); }
11132
+ if (labelOf) { const l = labelOf(d, r); if (l) grow(l.extent); }
10820
11133
  const w = chart[2] - chart[0];
10821
11134
  const margin = Math.min(2 * boxMarginU * unitPx, boxMarginU * w / 30);
10822
11135
  const boxR = circular ? r * 1.1 + margin : r;
11136
+ const titleFontPx = titleFontOf(w);
10823
11137
  let title = null;
10824
11138
  if (titleShown) {
10825
11139
  const text = this._itemTitle(d.properties);
@@ -10835,7 +11149,7 @@
10835
11149
  const anchor = circular ? 'middle' : (alignRight ? 'end' : 'start');
10836
11150
  const x0 = anchor === 'middle' ? x - width / 2 : anchor === 'end' ? x - width : x;
10837
11151
  // arial: ascent 0.905, descent 0.212 of the font size
10838
- title = { text: lines.join('\n'), x, anchor, top: base - 0.8 * f,
11152
+ title = { text: lines.join('\n'), x, anchor, top: base - 0.8 * f, f,
10839
11153
  extent: [x0, base - 0.905 * f, x0 + width, base + (lines.length - 1) * f + 0.212 * f] };
10840
11154
  }
10841
11155
  }
@@ -10940,8 +11254,8 @@
10940
11254
  data: combined, pickable: true,
10941
11255
  getPosition: d => d.geometry.coordinates,
10942
11256
  getIcon: d => d.properties.counts
10943
- ? this._buildBubbleIcon(d.properties.counts, this.categoryColorsRgb, this._groupIsolation(d))
10944
- : this._buildSingleIcon(this.categoryColorsRgb[d.properties.cat], fillOpacity, this._resolveSymbolShape(d.properties), singleBorderColorRgb, singleBorderWidthPx),
11257
+ ? this._buildBubbleIcon(d.properties.counts, this.categoryColorsRgb, this._groupIsolation(d), Array.isArray(this.style.symbols) ? this.style.symbols : null)
11258
+ : this._buildSingleIcon((this.categoryColorsRgb || [])[d.properties.cat], fillOpacity, this._resolveSymbolShape(d.properties), singleBorderColorRgb, singleBorderWidthPx),
10945
11259
  getSize: d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue) * 2,
10946
11260
  // group icons bake a fixed 0.9 alpha; an explicit fillopacity scales it
10947
11261
  // so a near-transparent theme doesn't show opaque 1-px-cell clusters
@@ -10959,7 +11273,7 @@
10959
11273
  data: titleData, pickable: false,
10960
11274
  getPosition: b => b.d.geometry.coordinates,
10961
11275
  getText: b => b.title.text,
10962
- getSize: titleFontPx,
11276
+ getSize: b => b.title.f,
10963
11277
  sizeUnits: 'pixels',
10964
11278
  fontFamily: 'arial',
10965
11279
  characterSet: 'auto',
@@ -10984,18 +11298,20 @@
10984
11298
  // computed font size would be sub-pixel are dropped rather than
10985
11299
  // rendered at getSize: 0, matching the real engine's own "too
10986
11300
  // small to bother" gate.
10987
- if (this.flags.has('VALUES') && !valuesHiddenByScale(this.style, zoom)) {
11301
+ if (valuesShown) {
10988
11302
  const valueScale = styleNum(this.style.valuescale) || 1;
10989
11303
  const textOpts = this._valueTextOpts();
11304
+ const pointValueOf = valueTextOf || this._chartValueTextFn();
10990
11305
 
10991
11306
  const pointLabels = individual.reduce((out, d) => {
10992
11307
  const radius = valueRadius(d.properties.value, liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
10993
- // flat's VALUES label prints the value (nValuesA), not the size
10994
- const text = flatValueText(d.properties.raw, this.binding.title && d.properties.raw ? d.properties.raw[this.binding.title] : undefined,
10995
- d.properties.classValue !== undefined ? d.properties.classValue : d.properties.value, this.style, this.flags, textOpts);
10996
- const fontSize = valuesFontSizePx(radius, text, valueScale);
11308
+ // flat's VALUES label prints the value (nValuesA), not the size;
11309
+ // its font by flat's chart branch (_chartValueTextFn)
11310
+ const { text, fontSize, bold, baseline } = pointValueOf(d, radius);
10997
11311
  if (fontSize > VALUES_MIN_FONT_PX) {
10998
- out.push({ geometry: d.geometry, text, fontSize, color: resolveTextColor(this.style, contrastTextColor(this.categoryColorsRgb[d.properties.cat])), pixelOffset: alignOf(d) });
11312
+ const o = alignOf(d);
11313
+ out.push({ geometry: d.geometry, text, fontSize, bold, color: resolveTextColor(this.style, contrastTextColor((this.categoryColorsRgb || [])[d.properties.cat])),
11314
+ pixelOffset: baseline ? [o[0], o[1] + baseline - 0.35 * fontSize] : o });
10999
11315
  }
11000
11316
  return out;
11001
11317
  }, []);
@@ -11026,7 +11342,7 @@
11026
11342
  if (fontSize > VALUES_MIN_FONT_PX) {
11027
11343
  out.push({
11028
11344
  geometry: d.geometry, text, fontSize,
11029
- color: resolveTextColor(this.style, contrastTextColor(this.categoryColorsRgb[p.i])),
11345
+ color: resolveTextColor(this.style, contrastTextColor((this.categoryColorsRgb || [])[p.i])),
11030
11346
  pixelOffset: [offsets[i].x * fitScale * pxPerCanvasUnit + alignOf(d)[0], offsets[i].y * fitScale * pxPerCanvasUnit + alignOf(d)[1]]
11031
11347
  });
11032
11348
  }
@@ -11046,10 +11362,55 @@
11046
11362
  getTextAnchor: 'middle',
11047
11363
  getAlignmentBaseline: 'center'
11048
11364
  };
11049
- if (pointLabels.length) layers.push(new TextLayer({ id: `ix-points-values-${this.name}`, data: pointLabels, ...(alignOf.active ? { getPixelOffset: d => d.pixelOffset } : {}), ...textLayerCommonProps }));
11365
+ // flat's SYMBOL branch writes its values in normal weight
11366
+ if (pointLabels.length) layers.push(new TextLayer({ id: `ix-points-values-${this.name}`, data: pointLabels, getPixelOffset: d => d.pixelOffset, ...textLayerCommonProps,
11367
+ fontWeight: pointLabels[0].bold === false ? 'normal' : 'bold' }));
11050
11368
  if (groupLabels.length) layers.push(new TextLayer({ id: `ix-cluster-values-${this.name}`, data: groupLabels, getPixelOffset: d => d.pixelOffset, ...textLayerCommonProps }));
11051
11369
  }
11052
11370
 
11371
+ // LABEL: flat's chart label (maptheme.js 24533-24590) — a small text
11372
+ // below-right of each chart, the .style({labelfield}) record value
11373
+ // (the fire page's labelfield ANNO: the fire year "2 014"), black at
11374
+ // 0.7 opacity on a white 0.4-opacity rounded background box, font
11375
+ // 5·unit·textscale. Gated by labelupper/labellower (flat's own test
11376
+ // compares <= against BOTH), suppressed by NORMSIZE/ZOOM, a numeric
11377
+ // label formatted like a value (valuedecimals) plus labelunits.
11378
+ if (labelShown) {
11379
+ const labelFontPx = 5 * unitPx * (styleNum(this.style.textscale) || 1);
11380
+ if (labelFontPx > VALUES_MIN_FONT_PX) {
11381
+ const labelData = [];
11382
+ for (const d of individual) {
11383
+ if (!d.properties || !d.properties.raw) continue;
11384
+ // _chartLabelFn: text, and its place below-right of the chart
11385
+ const l = labelOf(d, radiusOf(d));
11386
+ if (!l) continue;
11387
+ const o = alignOf(d);
11388
+ // drawn centered: flat's baseline sits 0.35 font below the
11389
+ // middle of its background box (-0.95 .. +0.25 font)
11390
+ labelData.push({ geometry: d.geometry, text: l.text, fontSize: l.fontSize,
11391
+ pixelOffset: [l.offset[0] + o[0], l.offset[1] - 0.35 * l.fontSize + o[1]] });
11392
+ }
11393
+ if (labelData.length) layers.push(new TextLayer({
11394
+ id: `ix-labels-${this.name}`,
11395
+ data: labelData, pickable: false,
11396
+ getPosition: d => d.geometry.coordinates,
11397
+ getText: d => d.text,
11398
+ getSize: d => d.fontSize,
11399
+ getColor: [0, 0, 0, Math.round(0.7 * 255)],
11400
+ sizeUnits: 'pixels',
11401
+ fontFamily: 'arial',
11402
+ characterSet: 'auto',
11403
+ getTextAnchor: 'middle',
11404
+ getAlignmentBaseline: 'center',
11405
+ getPixelOffset: d => d.pixelOffset,
11406
+ background: true,
11407
+ getBackgroundColor: [255, 255, 255, Math.round(0.4 * 255)],
11408
+ backgroundPadding: [labelFontPx * 0.25, labelFontPx * 0.1],
11409
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
11410
+ }));
11411
+ }
11412
+ }
11413
+
11053
11414
  return layers;
11054
11415
  }
11055
11416
 
@@ -11083,12 +11444,25 @@
11083
11444
  // - VALUES: each part's value (COUNT: its records; valuefield: the class name)
11084
11445
  // - align / offsets as the SYMBOL branch: half size the normal radius
11085
11446
  // A part's pick object carries its chart's properties (tooltip).
11447
+ // The same for a per-item chart over several value fields
11448
+ // (_isMultiFieldSequence): a part per field, max value the biggest
11449
+ // field value of any record (normalsizevalue overrides), the part
11450
+ // radius value-scaled as above; a colorscheme "none" part is only its
11451
+ // outline. RINGS nests the parts (ringsLayout); CENTERVALUES prints
11452
+ // only the first part's value (flat: nClass 0, maptheme.js 22216).
11086
11453
  _buildSequenceChartLayers(charts, zoom, liveZoom) {
11087
11454
  const st = this.style;
11088
- const seqMax = this._sequenceMaxValue(zoom);
11089
- const partRadius = v => valueRadius(v, liveZoom, st, this.mapOptions, this.flags, seqMax);
11455
+ const multi = !!this._multiFields && this._isMultiFieldSequence();
11456
+ const seqMax = multi ? (styleNum(st.normalsizevalue) || this._multiFieldPartMax) : this._sequenceMaxValue(zoom);
11090
11457
  const unit = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(st.scale) || 1);
11091
11458
  const normalR = NORMAL_RADIUS_PX * unit;
11459
+ const sizePow = resolveSizePow(st, this.flags);
11460
+ const partRadius = multi
11461
+ ? v => Math.max(VALUE_RADIUS_MIN, normalR * Math.pow(Math.abs(v) / seqMax, 1 / sizePow))
11462
+ : v => valueRadius(v, liveZoom, st, this.mapOptions, this.flags, seqMax);
11463
+ const rings = flatFlag(this.flags, 'RINGS');
11464
+ const centerValues = flatFlag(this.flags, 'CENTERVALUES');
11465
+ const noFill = this._partNoFill || new Set();
11092
11466
  const nCats = this.categoryLabels ? this.categoryLabels.length : 1;
11093
11467
  const marking = this._markedClasses.size && !this._onMarksChanged;
11094
11468
  const gray = marking && String(st.evidence || 'isolate') === 'isolate_gray';
@@ -11097,11 +11471,13 @@
11097
11471
  const borderRgb = st.linecolor && styleLineColor(st.linecolor) !== 'none' ? hexOrNamedToRgb(styleLineColor(st.linecolor)) : null;
11098
11472
  const lineWidth = styleNum(st.linewidth) || 1;
11099
11473
  const symbols = Array.isArray(st.symbols) ? st.symbols : null;
11100
- const shapeOf = i => { const sh = symbols && (symbols[i] || symbols[0]); return /^(circle|square|diamond|triangle)$/.test(sh) ? sh : 'circle'; };
11474
+ const shapeOf = i => { const sh = symbols && (symbols[i] || symbols[0]); return normalizeSymbolShape(sh); };
11101
11475
  const colorOf = i => this.categoryColorsRgb[i] || [128, 128, 128];
11102
11476
  const parts = [], glows = [];
11103
11477
  for (const d of charts) {
11104
- let layout = sequenceLayout(d.properties.counts, this.flags, partRadius, { maxRadius: normalR, maxValue: seqMax, nParts: nCats, rangeScale: styleNum(st.rangescale) || 0 });
11478
+ const counts = d.properties.parts || d.properties.counts;
11479
+ let layout = rings ? ringsLayout(counts, partRadius)
11480
+ : sequenceLayout(counts, this.flags, partRadius, { maxRadius: normalR, maxValue: seqMax, nParts: nCats, rangeScale: styleNum(st.rangescale) || 0 });
11105
11481
  if (!layout.length) continue;
11106
11482
  // GLOW belongs to the first part (sorted) or to every part, and
11107
11483
  // hides with it when its class is not marked
@@ -11155,7 +11531,7 @@
11155
11531
  data: parts, pickable: true,
11156
11532
  getPosition: d => d.geometry.coordinates,
11157
11533
  // the stroke, in the icon raster's pixels (the icon is drawn at 2r)
11158
- getIcon: d => this._buildSingleIcon(colorOf(d.part.i), fillOpacity, shapeOf(d.part.i), borderRgb,
11534
+ getIcon: d => this._buildSingleIcon(colorOf(d.part.i), noFill.has(d.part.i) ? 0 : fillOpacity, shapeOf(d.part.i), borderRgb,
11159
11535
  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),
11160
11536
  getSize: d => d.part.r * 2,
11161
11537
  getPixelOffset: offsetOf,
@@ -11168,20 +11544,22 @@
11168
11544
  // class name — formatted with valuedecimals (else none) plus the unit;
11169
11545
  // font min(1, 3.2 / L) · r · valuescale, L the length of the theme's
11170
11546
  // biggest value text (szMaxText), normal weight, in valuecolor /
11171
- // textcolor, else ChartColors.textColor of the part color
11172
- if (this.flags.has('VALUES') && !valuesHiddenByScale(st, zoom)) {
11547
+ // textcolor, else ChartColors.textColor of the part color.
11548
+ // CENTERVALUES: only on the first part (class 0), its own value
11549
+ if ((this.flags.has('VALUES') || centerValues) && !valuesHiddenByScale(st, zoom)) {
11173
11550
  const valueScale = styleNum(st.valuescale) || 1;
11174
11551
  const { noBreaks } = this._valueTextOpts();
11175
11552
  const units = st.units ? String(st.units) : '';
11176
11553
  const unit = units ? (units[0] === '.' ? '' : ' ') + units : '';
11177
11554
  const unitText = unit.length <= 5 ? unit : '';
11178
- const dec = styleNum(st.valuedecimals) || 0;
11555
+ const dec = explicitValueDecimals(st) || 0;
11179
11556
  const fmt = v => flatGroupedValue(v, dec, noBreaks) + unitText;
11180
11557
  const maxLen = fmt(seqMax).length;
11181
11558
  const textOverride = st.valuecolor || st.textcolor;
11182
11559
  const counting = flatFlag(this.flags, 'COUNT');
11183
11560
  const labels = [];
11184
11561
  parts.forEach(d => {
11562
+ if (centerValues && d.part.i !== 0) return;
11185
11563
  const rec = this._categoryValueRecord(d.part.i);
11186
11564
  const name = rec ? String(rec[st.valuefield]) : null;
11187
11565
  const t = counting && d.properties.recordCounts ? d.properties.recordCounts[d.part.i] : d.part.v;
@@ -11241,6 +11619,69 @@
11241
11619
  // the class name (the records' own valuefield value) — otherwise none
11242
11620
  _categoryValueRecord(i) { return categoryValueRecord(this.style, this.binding, this.flags, this.categoryLabels, i); }
11243
11621
 
11622
+ // A single chart's VALUES text: (item, its radius px) → { text,
11623
+ // fontSize, extent } — extent [x0, y0, x1, y1] in px around the chart
11624
+ // center (y down), as the box encloses it. The font by flat's branch:
11625
+ // - SYMBOL (maptheme.js 22200-22202, 21705-21708): min(1, 3.2 / L)
11626
+ // (hexagon 2.7) · r · valuescale, L the length of the theme's biggest
11627
+ // value text (szMaxText: the max value formatted, plus the unit up to
11628
+ // 5 characters, 21479) — normal weight, baseline 0.33 font below the
11629
+ // center (fire areas SIF chart: r 60, valuescale 3, "7 242 ha" → 72)
11630
+ // - BUBBLE: valuesFontSizePx, bold, centered
11631
+ _chartValueTextFn() {
11632
+ const valueScale = styleNum(this.style.valuescale) || 1;
11633
+ const textOpts = this._valueTextOpts();
11634
+ const symbol = flatChartBranch(this.flags) === 'symbol';
11635
+ let maxLen = 1;
11636
+ if (symbol) {
11637
+ const vMax = this._valueMax !== undefined ? this._valueMax : (this._valueTextRange || [])[1];
11638
+ if (isFinite(vMax)) maxLen = Math.max(1, flatValueText(null, null, vMax, this.style, this.flags, textOpts).length);
11639
+ }
11640
+ return (d, r) => {
11641
+ const p = d.properties;
11642
+ const text = flatValueText(p.raw, this.binding.title && p.raw ? p.raw[this.binding.title] : undefined,
11643
+ p.classValue !== undefined ? p.classValue : p.value, this.style, this.flags, textOpts);
11644
+ if (!symbol) {
11645
+ const fontSize = valuesFontSizePx(r, text, valueScale);
11646
+ const tw = titleTextWidth(text, fontSize);
11647
+ return { text, fontSize, bold: true, extent: [-tw / 2, -fontSize / 2, tw / 2, fontSize / 2] };
11648
+ }
11649
+ const k = this._resolveSymbolShape(p) === 'hexagon' ? 2.7 : 3.2;
11650
+ const fontSize = Math.min(1, k / maxLen) * r * valueScale;
11651
+ const tw = titleTextWidth(text, fontSize);
11652
+ return { text, fontSize, bold: false, baseline: 0.33 * fontSize,
11653
+ extent: [-tw / 2, (0.33 - 0.905) * fontSize, tw / 2, (0.33 + 0.212) * fontSize] };
11654
+ };
11655
+ }
11656
+
11657
+ // A single chart's LABEL (.style({labelfield}), maptheme.js 24533-24590):
11658
+ // (item, its radius px) → { text, fontSize, offset, extent } or null.
11659
+ // Font 5u · textscale; a number formatted like a value — valuedecimals
11660
+ // when set (flat's nValueDecimals is the raw style string, so "0"
11661
+ // counts), else 2 — plus labelunits. The background box is the wider of
11662
+ // the text and (length + 2) · font / 2, 1.2 font high from 0.95 font
11663
+ // above the baseline; the label sits at (width / 3, font / 3 + the
11664
+ // chart's anchor y: r + 5u). Fire areas page: "2 010" at (116.7, 193.3)
11665
+ // units, box 350 × 120.
11666
+ _chartLabelFn(unitPx) {
11667
+ const f = 5 * unitPx * (styleNum(this.style.textscale) || 1);
11668
+ const dec = explicitValueDecimals(this.style);
11669
+ const { noBreaks } = this._valueTextOpts();
11670
+ const units = String(this.style.labelunits || '');
11671
+ return (d, r) => {
11672
+ const raw = d.properties && d.properties.raw;
11673
+ const v = raw && raw[this.style.labelfield];
11674
+ let text = v == null || v === '' ? '' : String(v);
11675
+ if (!text) return null;
11676
+ const num = parseFloat(text.replace(',', '.'));
11677
+ text = (!isNaN(num) ? flatGroupedValue(num, dec != null ? dec : 2, noBreaks) : text) + units;
11678
+ const width = Math.max(titleTextWidth(text, f), (text.length + 2) * f * 0.5);
11679
+ const x = width / 3, y = f / 3 + r + 5 * unitPx;
11680
+ return { text, fontSize: f, offset: [x, y], width,
11681
+ extent: [x - width / 2, y - 0.95 * f, x + width / 2, y + 0.25 * f] };
11682
+ };
11683
+ }
11684
+
11244
11685
  // value-text options (flatValueText): flat's formatValue NOBREAKS for a
11245
11686
  // theme of values within 1000-3000, the max value for the decimals
11246
11687
  _valueTextOpts() {
@@ -11431,6 +11872,14 @@
11431
11872
  // DOWN, confirmed against a real ported page's own comment ("il motore
11432
11873
  // disegna il triangolo con la punta in basso" — "the engine draws the
11433
11874
  // triangle with the tip at the bottom").
11875
+ // flat's symbol shapes (maptheme.js 21625-21680). Geometry follows flat
11876
+ // where GL didn't have an established shape already (square, diamond,
11877
+ // triangle keep GL's own proportions — pages already shipped with them):
11878
+ // roundrect — rect 1.6r, rounded 0.3r; label — wide flat rect 1.6r×0.8r;
11879
+ // carot — flat's alias of diamond; hexagon — vertices (±r,0),(±r/2,±r·sin60°);
11880
+ // cross — flat's 8-arm path (A=0.4r, B=0.8r); empty — the circle
11881
+ // geometry, but _buildSingleIcon gives it flat's transparent fill +
11882
+ // gray stroke (fill-opacity:0, stroke #888888)
11434
11883
  function drawSymbolPath(ctx, shape, cx, cy, r) {
11435
11884
  switch (shape) {
11436
11885
  case 'square': {
@@ -11439,6 +11888,7 @@
11439
11888
  break;
11440
11889
  }
11441
11890
  case 'diamond':
11891
+ case 'carot': // flat: carot and diamond are the same rotated rect
11442
11892
  ctx.moveTo(cx, cy - r);
11443
11893
  ctx.lineTo(cx + r, cy);
11444
11894
  ctx.lineTo(cx, cy + r);
@@ -11449,10 +11899,58 @@
11449
11899
  ctx.lineTo(cx + r, cy - r * 0.6);
11450
11900
  ctx.lineTo(cx, cy + r);
11451
11901
  break;
11902
+ case 'roundrect': {
11903
+ const s = r * 0.8;
11904
+ if (ctx.roundRect) ctx.roundRect(cx - s, cy - s, s * 2, s * 2, r * 0.3);
11905
+ else ctx.rect(cx - s, cy - s, s * 2, s * 2);
11906
+ break;
11907
+ }
11908
+ case 'label': {
11909
+ const w = r * 0.8, h = r * 0.4;
11910
+ ctx.rect(cx - w, cy - h, w * 2, h * 2);
11911
+ break;
11912
+ }
11913
+ case 'hexagon': {
11914
+ const A = r / 2, B = r * Math.sin(Math.PI / 3);
11915
+ ctx.moveTo(cx - r, cy);
11916
+ ctx.lineTo(cx - A, cy - B);
11917
+ ctx.lineTo(cx + A, cy - B);
11918
+ ctx.lineTo(cx + r, cy);
11919
+ ctx.lineTo(cx + A, cy + B);
11920
+ ctx.lineTo(cx - A, cy + B);
11921
+ break;
11922
+ }
11923
+ case 'cross': {
11924
+ // flat's own path, vertex for vertex: a plus of arm thickness A
11925
+ // reaching to ±(B + A/2) (maptheme.js 21652-21655)
11926
+ const A = r * 0.4, B = r * 0.8, E = B + A / 2;
11927
+ ctx.moveTo(cx, cy - E);
11928
+ ctx.lineTo(cx + A / 2, cy - E);
11929
+ ctx.lineTo(cx + A / 2, cy - A / 2);
11930
+ ctx.lineTo(cx + E, cy - A / 2);
11931
+ ctx.lineTo(cx + E, cy + A / 2);
11932
+ ctx.lineTo(cx + A / 2, cy + A / 2);
11933
+ ctx.lineTo(cx + A / 2, cy + E);
11934
+ ctx.lineTo(cx - A / 2, cy + E);
11935
+ ctx.lineTo(cx - A / 2, cy + A / 2);
11936
+ ctx.lineTo(cx - E, cy + A / 2);
11937
+ ctx.lineTo(cx - E, cy - A / 2);
11938
+ ctx.lineTo(cx - A / 2, cy - A / 2);
11939
+ ctx.lineTo(cx - A / 2, cy - E);
11940
+ break;
11941
+ }
11942
+ case 'empty':
11452
11943
  default: // circle
11453
11944
  ctx.arc(cx, cy, r, 0, Math.PI * 2);
11454
11945
  }
11455
11946
  }
11947
+ // the shape names flat's szSymbol switch knows (maptheme.js 21625) —
11948
+ // anything else falls back to circle, never silently dropped
11949
+ const SYMBOL_SHAPE_RE = /^(circle|square|diamond|triangle|roundrect|carot|hexagon|cross|empty|label)$/;
11950
+ function normalizeSymbolShape(name) {
11951
+ const n = String(name || '').toLowerCase();
11952
+ return SYMBOL_SHAPE_RE.test(n) ? n : 'circle';
11953
+ }
11456
11954
 
11457
11955
  // a color's own opacity, 0..1, as SVG paints it in flat: "none" (SVG's
11458
11956
  // no paint — a polygon with only its outline, or a class not painted)
@@ -12277,7 +12775,7 @@
12277
12775
  // test-only: lets test/unit/*.test.mjs call pure internals directly (the
12278
12776
  // engine runs in a Node vm there); deliberately NOT on the ixmaps object
12279
12777
  global.__ixmapsGlInternals = {
12280
- IXMAPS_GL_VERSION, youtubeClickToPlay, symbolsFlagCompat, tooltipTable, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout, multiQuadOffsets, pixelOffsetLngLat,
12778
+ IXMAPS_GL_VERSION, youtubeClickToPlay, symbolsFlagCompat, tooltipTable, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout, ringsLayout, multiQuadOffsets, pixelOffsetLngLat,
12281
12779
  normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, MapBuilder, typeStyleNumbers, styleNum,
12282
12780
  resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
12283
12781
  equalIntervalBreaks, quantileBreaks, naturalBreaks, evenStrideSample, jenksBreakValues, partsFromBreakValues, resolvePartsClass,
@@ -12287,7 +12785,8 @@
12287
12785
  flatToMapLibreZoom, mapLibreToFlatZoom, scaleDenominatorAt, resolveZoomReference, resolveAggregationPx, valuesHiddenByScale,
12288
12786
  fetchLayerData, parseCsvText, dataTableRows, geometryRowsToFeatureCollection, filterFlatValues, applyField100, field100Binding, rangeClassLegendTotals, resolveAggregateValue, classValueSeparate, cellAggregatedValues, oneHot, groupCoLocated, aggregateOnGrid, GridAggregateIndex, flatRangeParts, aggregateField100, valueRadius, itemPlotGeometry, itemAnchor, objectZoomFactor, resolveZoomReference, defaultNormalSizeValue,
12289
12787
  applyWhereFilter, joinChartPositions, flatLookupKey, flatShapeCenter, chartHiddenByScale, glowHiddenByScale, isSymbolChart, flatChartAlignOffset, flatChartBranch, flatValueText, categoryValueRecord, flatDerivateRgb, flatChartTextRgb, flatGroupedValue, flatNoBreaks, featuresHiddenByScale, boxHiddenByScale, flatShadowOn, snapToAggregationGrid,
12290
- flatFormatValue, themeStyleArgs,
12788
+ drawSymbolPath, normalizeSymbolShape,
12789
+ flatFormatValue, themeStyleArgs, explicitValueDecimals, legendDecimals,
12291
12790
  dataCacheKey, dataCacheDisabled, cachedLayerData, featuresBounds, wantsZoomToExtent, legendIsOn,
12292
12791
  };
12293
12792
  global.__setFilter = __setFilter;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ixmaps-gl",
3
- "version": "0.2.16",
3
+ "version": "0.2.17",
4
4
  "description": "MapLibre GL + deck.gl reimplementation of the ixmaps-flat declarative mapping engine",
5
5
  "main": "ixmaps-gl.js",
6
6
  "browser": "ixmaps-gl.js",