ixmaps-gl 0.2.16 → 0.2.18

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 +994 -131
  3. package/package.json +1 -1
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.18';
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',
@@ -2457,6 +2479,44 @@
2457
2479
  return typeof v === 'number' ? v : NaN;
2458
2480
  }
2459
2481
 
2482
+ // flat's changeThemeStyle arithmetic (maptheme.js doChangeThemeStyle,
2483
+ // 4962-5334): these numeric keys go through __calcNewValue (4486) — flag
2484
+ // "pow" current^v, "factor" current·v, "add"/"delta" current+v, else v.
2485
+ // The value is the theme's current one, or flat's own fallback where its
2486
+ // code has one (`mapTheme.nBrightness || 1`, …; fillopacity: 1 when the
2487
+ // new value is < 1, else 0)
2488
+ const FLAT_STYLE_CALC_KEYS = {
2489
+ opacity: 0, fillopacity: null, blur: 0, dopacityscale: 0, dopacitypow: 0, brightness: 1, rangescale: 1,
2490
+ normalsizevalue: 0, minvalue: 1, maxvalue: 1, valuescale: 0, linewidth: 0, markersize: 0, gapsize: 0,
2491
+ offsetx: 0, offsety: 0, rotation: 0, minvaluesize: 0, minvaluevalue: 0, clipvaluesize: 0, minsize: 0,
2492
+ minchartsize: 0, maxcharts: 0, sizepow: 0, outlierscale: 0, clipsize: 0, clipframerate: 1,
2493
+ gridwidth: 1, gridmatrix: 1, gridwidthpx: 50, gridoffsetx: 0, gridoffsety: 0
2494
+ };
2495
+ // the new value of style[key] after flat's changeThemeStyle(…, "key:value",
2496
+ // flag), or null when flat leaves the theme as it is. scale is a resize
2497
+ // FACTOR there (__calcFactor, 4502: "factor" v, "add" (current+v)/current,
2498
+ // else v/current; no pow/delta), multiplied into nScale by
2499
+ // MapTheme.resize (26544) — a factor of 0 or NaN is falsy, so the
2500
+ // Themes.execute loop (3326) skips the resize: scale:0 changes nothing.
2501
+ // Other keys: the value string as given
2502
+ function flatChangedStyleValue(key, current, value, flag) {
2503
+ const f = String(flag || '');
2504
+ const cur = Number(Array.isArray(current) ? current[0] : current);
2505
+ const v = Number(value);
2506
+ if (key === 'scale') {
2507
+ const now = Number.isFinite(cur) && cur ? cur : 1;
2508
+ const factor = /factor/.test(f) ? v : /add/.test(f) ? (now + v) / now : v / now;
2509
+ return factor ? now * factor : null;
2510
+ }
2511
+ if (!(key in FLAT_STYLE_CALC_KEYS) || !/pow|factor|add|delta/.test(f)) return value;
2512
+ const fallback = FLAT_STYLE_CALC_KEYS[key] == null ? (v < 1 ? 1 : 0) : FLAT_STYLE_CALC_KEYS[key];
2513
+ const base = Number.isFinite(cur) && cur ? cur : fallback;
2514
+ const n = Number.isFinite(v) ? v : 0;
2515
+ if (/pow/.test(f)) return Math.pow(base, n);
2516
+ if (/factor/.test(f)) return base * n;
2517
+ return base + n;
2518
+ }
2519
+
2460
2520
  function normalizeTheme(def) {
2461
2521
  // real ixmaps-flat (htmlgui.js newTheme) merges .meta() INTO style first
2462
2522
  // — meta wins — and only then reads type, bindings and style properties
@@ -2589,6 +2649,16 @@
2589
2649
  return String(text).split(' ').map(w => (dict["'" + w + "'"] !== undefined ? String(dict["'" + w + "'"]) : w)).join(' ');
2590
2650
  };
2591
2651
  }
2652
+ // make the map container the positioning context of what the engine
2653
+ // mounts inside it (splash, pinned tooltip, legends) — only when it is
2654
+ // unpositioned. The RENDERED position decides, never el.style (inline
2655
+ // only): a page's #map_div { position:absolute; top:0; bottom:0 } from a
2656
+ // stylesheet reads as "" there, and overwriting it with relative drops
2657
+ // the top/bottom stretch — the div's height collapses to 0, a blank map
2658
+ // (accidents_app.html from 0.2.15 on)
2659
+ function anchorInside(el) {
2660
+ if (el && getComputedStyle(el).position === 'static') el.style.position = 'relative';
2661
+ }
2592
2662
  function showSplash(el, text) {
2593
2663
  ensureSplashStyle();
2594
2664
  // Checks the actual RENDERED position, not el.style (the inline
@@ -2602,7 +2672,7 @@
2602
2672
  // height silently collapsed to 0 — invisible map, invisible splash,
2603
2673
  // both zero-height. Only truly static (unpositioned) elements need
2604
2674
  // this at all.
2605
- if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
2675
+ anchorInside(el);
2606
2676
  const splash = document.createElement('div');
2607
2677
  splash.className = 'ixmaps-splash';
2608
2678
  splash.innerHTML = '<div class="ixmaps-splash-mark"><span class="ixmaps-splash-ping"></span><span class="ixmaps-splash-dot"></span></div>' +
@@ -3144,7 +3214,7 @@
3144
3214
  // relative pixels, and styling the page's own body (relative) breaks
3145
3215
  // any page element absolutely positioned against it (AirBnB's
3146
3216
  // #onmap-buttons on a zero-height all-float body flew off-screen)
3147
- el.style.position = el.style.position || 'relative';
3217
+ anchorInside(el);
3148
3218
  el.appendChild(pinnedTooltipEl);
3149
3219
  }
3150
3220
  return { getTooltip, onClick, mount };
@@ -3341,7 +3411,7 @@
3341
3411
  // panel's max-height resolves against the MAP's height — the stack's
3342
3412
  // former parent anchor collapsed it to 0 on pages whose body has
3343
3413
  // only floated children (height 0)
3344
- el.style.position = el.style.position || 'relative';
3414
+ anchorInside(el);
3345
3415
  el.appendChild(legendStack);
3346
3416
  return legendStack;
3347
3417
  };
@@ -3387,7 +3457,7 @@
3387
3457
  rt._triggerRedraw = () => { refresh(); };
3388
3458
  if (subThemeCapable(rt)) rt._onMarksChanged = () => updateSubTheme(rt);
3389
3459
  if (!legendOn || !el.parentElement || !legendApplies(rt)) return;
3390
- el.style.position = el.style.position || 'relative';
3460
+ anchorInside(el);
3391
3461
  {
3392
3462
  // .type("...|NOLEGEND") — the fourth real legend-related type()
3393
3463
  // token: opts a theme OUT of the legend entirely (real engine's
@@ -3412,28 +3482,36 @@
3412
3482
  // sync — no separate light/dark branch anywhere else.
3413
3483
  // flat's look (flatLegendLook) unless the page picks one with
3414
3484
  // this engine's own legendtheme
3415
- const opacityOpt = parseFloat(builder._engineOptions.basemapopacity);
3416
- const legendBackground = builder.mapOptions.legendBackground || builder.mapOptions.legendbackground;
3417
- const flatLook = flatLegendLook(builder.mapOptions.mapType, isNaN(opacityOpt) ? 1 : opacityOpt, legendBackground);
3418
- // effective darkness (style background vs page background, see
3419
- // effectiveMapDark): a dark basemap faded to <= 0.5 over a DARK
3420
- // page still reads dark — flatLegendLook's name heuristic would
3421
- // wrongly switch the panel (and the tooltip) to light there
3422
- const looksDark = typeof builder.__effectiveDark === 'function'
3423
- ? builder.__effectiveDark(isNaN(opacityOpt) ? 1 : opacityOpt, builder.mapOptions.mapType, legendBackground)
3424
- : flatLook.dark;
3425
- const isLightLegend = rt.style.legendtheme === 'light' || (rt.style.legendtheme !== 'dark' && !looksDark);
3426
- const legendColors = isLightLegend
3427
- ? { bg: 'rgba(255,255,255,0.92)', fg: '#1a1a1a', shadow: '0 2px 10px rgba(0,0,0,0.18)',
3428
- rowMarked: 'rgba(0,0,0,0.08)', track: 'rgba(0,0,0,0.08)',
3429
- selectBg: 'rgba(0,0,0,0.04)', selectBorder: 'rgba(0,0,0,0.2)' }
3430
- : { bg: 'rgba(28,30,34,0.92)', fg: '#eee', shadow: '0 2px 10px rgba(0,0,0,0.45)',
3431
- rowMarked: 'rgba(255,255,255,0.14)', track: 'rgba(255,255,255,0.08)',
3432
- selectBg: 'rgba(255,255,255,0.08)', selectBorder: 'rgba(255,255,255,0.25)' };
3433
- // the panel bg follows the same effective decision (flatLegendLook
3434
- // would hand the dark look a light bg here: its dark branch never
3435
- // ran for this map, its light bg would clash with dark rows/text)
3436
- if (!rt.style.legendtheme) legendColors.bg = looksDark ? '#111' : flatLook.bg;
3485
+ // The look is decided again once the basemap style exists (see
3486
+ // rt._relookLegend below): a panel built before the style loads
3487
+ // can only get effectiveMapDark's name-heuristic fallback, which
3488
+ // reads a dark basemap faded to <= 0.5 as light.
3489
+ const lookColors = () => {
3490
+ const opacityOpt = parseFloat(builder._engineOptions.basemapopacity);
3491
+ const legendBackground = builder.mapOptions.legendBackground || builder.mapOptions.legendbackground;
3492
+ const flatLook = flatLegendLook(builder.mapOptions.mapType, isNaN(opacityOpt) ? 1 : opacityOpt, legendBackground);
3493
+ // effective darkness (style background vs page background, see
3494
+ // effectiveMapDark): a dark basemap faded to <= 0.5 over a DARK
3495
+ // page still reads dark — flatLegendLook's name heuristic would
3496
+ // wrongly switch the panel (and the tooltip) to light there
3497
+ const looksDark = typeof builder.__effectiveDark === 'function'
3498
+ ? builder.__effectiveDark(isNaN(opacityOpt) ? 1 : opacityOpt, builder.mapOptions.mapType, legendBackground)
3499
+ : flatLook.dark;
3500
+ const isLightLegend = rt.style.legendtheme === 'light' || (rt.style.legendtheme !== 'dark' && !looksDark);
3501
+ const colors = isLightLegend
3502
+ ? { bg: 'rgba(255,255,255,0.92)', fg: '#1a1a1a', shadow: '0 2px 10px rgba(0,0,0,0.18)',
3503
+ rowMarked: 'rgba(0,0,0,0.08)', track: 'rgba(0,0,0,0.08)',
3504
+ selectBg: 'rgba(0,0,0,0.04)', selectBorder: 'rgba(0,0,0,0.2)' }
3505
+ : { bg: 'rgba(28,30,34,0.92)', fg: '#eee', shadow: '0 2px 10px rgba(0,0,0,0.45)',
3506
+ rowMarked: 'rgba(255,255,255,0.14)', track: 'rgba(255,255,255,0.08)',
3507
+ selectBg: 'rgba(255,255,255,0.08)', selectBorder: 'rgba(255,255,255,0.25)' };
3508
+ // the panel bg follows the same effective decision (flatLegendLook
3509
+ // would hand the dark look a light bg here: its dark branch never
3510
+ // ran for this map, its light bg would clash with dark rows/text)
3511
+ if (!rt.style.legendtheme) colors.bg = looksDark ? '#111' : flatLook.bg;
3512
+ return colors;
3513
+ };
3514
+ const legendColors = lookColors();
3437
3515
  // max-height:66.6% resolves against el's own height (the map
3438
3516
  // container, which always has a definite height for the map
3439
3517
  // itself to render into) since this panel sits in the stack,
@@ -3472,6 +3550,17 @@
3472
3550
  // themselves authored with their own leading space (e.g.
3473
3551
  // " MW") in real pages.
3474
3552
  const legendUnit = rt.style.legendunits || rt.style.units || '';
3553
+ // a per-item chart over several value fields: the row values as
3554
+ // flat's legend prints them (legend.js 610, 810: __formatValue
3555
+ // with valuedecimals, else 2, "BLANK" — whole numbers without
3556
+ // decimals), a colorscheme "none" part as an outline in linecolor
3557
+ const multiFieldLegend = !!rt._multiFields && rt._isMultiFieldSequence();
3558
+ const legendTotalText = v => (multiFieldLegend
3559
+ ? flatFormatValue(v, legendDecimals(rt.style), 'BLANK')
3560
+ : rt._formatTooltipValue(v));
3561
+ const outlineRgb = rt.style.linecolor && styleLineColor(rt.style.linecolor) !== 'none' ? hexOrNamedToRgb(styleLineColor(rt.style.linecolor)) : [128, 128, 128];
3562
+ const legendFill = i => (multiFieldLegend && rt._partNoFill && rt._partNoFill.has(i)
3563
+ ? `transparent;box-shadow:inset 0 0 0 1px rgb(${outlineRgb.join(',')})` : null);
3475
3564
  // row labels go into innerHTML: the page's own .style({label})
3476
3565
  // stays HTML, as in flat; categories read from the data are
3477
3566
  // escaped (a value could carry markup)
@@ -3513,6 +3602,22 @@
3513
3602
  const globeCenter = proj.type === 'globe' ? map.getCenter() : null;
3514
3603
  totals = new Array(rt.categoryLabels.length).fill(0);
3515
3604
  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; }
3605
+ // a per-item chart over several value fields: a row per field,
3606
+ // the field's sum over the visible items (flat's NYC status
3607
+ // legend: bikes / empty docks / e-bikes, biggest sum first)
3608
+ if (rt._multiFields && rt._isMultiFieldSequence()) {
3609
+ (rt._activeFeatures || rt.features).forEach(f => {
3610
+ const c = f.geometry && f.geometry.coordinates;
3611
+ if (!c || typeof c[0] !== 'number') return;
3612
+ const [lng, lat] = c;
3613
+ if (lng < bbox[0] || lng > bbox[2] || lat < bbox[1] || lat > bbox[3]) return;
3614
+ if (globeCenter && !isOnVisibleHemisphere(lng, lat, globeCenter)) return;
3615
+ rt._multiFields.forEach((k, i) => { totals[i] += parseFloat(f.properties[k]) || 0; });
3616
+ });
3617
+ maxTotal = Math.max(0, ...totals);
3618
+ order = totals.map((v, i) => i).sort((a, b) => totals[b] - totals[a]);
3619
+ return;
3620
+ }
3516
3621
  // _activeFeatures (set by setFacetFilter/clearFacetFilter,
3517
3622
  // e.g. the country-select dropdown below) is the facet-
3518
3623
  // filtered subset when a filter is active, null otherwise —
@@ -3645,7 +3750,7 @@
3645
3750
  for (let line = 0; line < nLines; line++) {
3646
3751
  for (let c = 0; c < n; c++) {
3647
3752
  const ix = flatFlag(rt.flags, 'INVERT') ? n - c - 1 : c;
3648
- const rgb = rt.categoryColorsRgb[ix];
3753
+ const rgb = rt.categoryColorsRgb[ix] || [128, 128, 128]; // colorscheme "none": no class colors
3649
3754
  const part = rt.partsA[ix];
3650
3755
  const title = part ? flatFormatValue(part.min, dec, '') + unit + ' ... ' + flatFormatValue(part.max, dec, '') + unit : '';
3651
3756
  const marked = rt._markedClasses.has(ix);
@@ -3676,8 +3781,8 @@
3676
3781
  // flat drops the rows without a count once any row has one
3677
3782
  // (legend.js 754-760, 787-789: fCountBars && !count)
3678
3783
  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]})`;
3784
+ const rgb = rt.categoryColorsRgb[i] || [128, 128, 128]; // colorscheme "none": no class colors
3785
+ const color = legendFill(i) || `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
3681
3786
  const marked = rt._markedClasses.has(i);
3682
3787
  const dimmed = rt._markedClasses.size > 0 && !marked;
3683
3788
  const rowStyle = 'padding:4px 3px;border-radius:4px;cursor:pointer;opacity:' + (dimmed ? 0.4 : 1) + ';'
@@ -3704,7 +3809,7 @@
3704
3809
  + '<div style="display:flex;align-items:center;gap:6px;">'
3705
3810
  + '<span style="flex:0 0 8px;width:8px;height:8px;border-radius:50%;background:' + color + ';"></span>'
3706
3811
  + '<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>'
3812
+ + '<span style="flex:0 0 auto;white-space:nowrap;">' + legendTotalText(totals[i]) + legendUnit + '</span>'
3708
3813
  + '</div>'
3709
3814
  + '</div>';
3710
3815
  }
@@ -3715,7 +3820,7 @@
3715
3820
  + '<span style="flex:1 1 auto;min-width:0;height:6px;background:' + legendColors.track + ';border-radius:3px;overflow:hidden;">'
3716
3821
  + '<span style="display:block;height:100%;width:' + pct + '%;background:' + color + ';border-radius:3px;"></span>'
3717
3822
  + '</span>'
3718
- + '<span style="flex:0 0 auto;text-align:right;min-width:60px;">' + rt._formatTooltipValue(totals[i]) + legendUnit + '</span>'
3823
+ + '<span style="flex:0 0 auto;text-align:right;min-width:60px;">' + legendTotalText(totals[i]) + legendUnit + '</span>'
3719
3824
  + '</div>'
3720
3825
  + '</div>';
3721
3826
  }).join('');
@@ -3827,6 +3932,7 @@
3827
3932
  // sharing the same data source and calls refresh()), so this
3828
3933
  // is UI wiring only, no new filtering logic.
3829
3934
  const filterField = rt.style.legendfilter;
3935
+ let filterSelect = null;
3830
3936
  if (filterField) {
3831
3937
  const facet = engineApi.getFacets(rt.name, [filterField], { bbox: [-180, -85, 180, 85] })[0];
3832
3938
  const values = (facet && facet.type === 'textual')
@@ -3842,8 +3948,27 @@
3842
3948
  else engineApi.clearFacetFilter(rt.name, filterField);
3843
3949
  });
3844
3950
  bodyEl.appendChild(filterEl);
3951
+ filterSelect = filterEl;
3845
3952
  }
3846
3953
 
3954
+ // re-apply the look in place once the basemap style is known
3955
+ // (createLegend's 'style.load' listener) — restyled, not rebuilt,
3956
+ // so the filter choice, slider and folding survive
3957
+ rt._relookLegend = () => {
3958
+ const c = lookColors();
3959
+ if (c.bg === legendColors.bg && c.fg === legendColors.fg) return;
3960
+ Object.assign(legendColors, c);
3961
+ panel.style.background = c.bg;
3962
+ panel.style.color = c.fg;
3963
+ if (filterSelect) {
3964
+ filterSelect.style.background = c.selectBg;
3965
+ filterSelect.style.color = c.fg;
3966
+ filterSelect.style.borderColor = c.selectBorder;
3967
+ }
3968
+ if (stack) stack.style.boxShadow = c.shadow;
3969
+ if (!isTextOnly) renderRows();
3970
+ };
3971
+
3847
3972
  if (!isTextOnly) { bodyEl.appendChild(rowsScroll); renderRows(); }
3848
3973
 
3849
3974
  if (rt.meta.description) {
@@ -3900,6 +4025,9 @@
3900
4025
  }
3901
4026
  };
3902
4027
  runtimes.forEach(rt => addLegendPanel(rt));
4028
+ // every style (the initial one, setProjectJSON swaps) can change the
4029
+ // effective darkness the panels were built with
4030
+ map.on('style.load', () => runtimes.forEach(rt => { if (rt._legendPanel && rt._relookLegend) rt._relookLegend(); }));
3903
4031
  }
3904
4032
  return { addLegendPanel, setLegendOption, updateSubTheme, layoutLegends };
3905
4033
  }
@@ -4296,7 +4424,7 @@
4296
4424
  legendEl.innerHTML = this._legendHtml;
4297
4425
  // inside the map container, never the page's body (see the pinned
4298
4426
  // tooltip mount) — styling body breaks page-positioned elements
4299
- el.style.position = el.style.position || 'relative';
4427
+ anchorInside(el);
4300
4428
  el.appendChild(legendEl);
4301
4429
  }
4302
4430
 
@@ -4436,6 +4564,33 @@
4436
4564
  return engineApi;
4437
4565
  },
4438
4566
  replaceTheme: (id, layerBuilder, flag) => engineApi.replace(id, layerBuilder, flag),
4567
+ // flat's newTheme(title, theme, flag) (htmlgui.js 1540-1570, the
4568
+ // handle's newTheme forwards to it): with "replace" the theme of the
4569
+ // same id goes first, "clearcharts" every CHART theme, "clear" /
4570
+ // "clearall" every theme — LOCKED themes stay (flat's
4571
+ // Themes.removeAll, maptheme.js 3047-3050: the fuel-price page's
4572
+ // nuts2 base) — then the theme is added; "force" removes nothing
4573
+ newTheme: (title, layerBuilder, flag) => {
4574
+ layerBuilder = unwrapLayer(layerBuilder);
4575
+ const f = String(flag || '');
4576
+ _chainedLayers = _chainedLayers.then(() => {
4577
+ if (!/force/i.test(f)) {
4578
+ if (/replace/i.test(f)) {
4579
+ const def = layerBuilder.definition();
4580
+ const id = (def.style && def.style.name) || (def.meta && def.meta.name);
4581
+ if (id) removeThemeById(id);
4582
+ } else if (/clearcharts|clear|clearall/i.test(f)) {
4583
+ const charts = /clearcharts/i.test(f);
4584
+ const doomed = runtimes.filter(rt => !flatFlag(rt.flags, 'LOCKED') && (!charts || rt.flags.has('CHART')));
4585
+ const byName = new Map();
4586
+ doomed.forEach(rt => { if (!byName.has(rt.name)) byName.set(rt.name, []); byName.get(rt.name).push(rt); });
4587
+ byName.forEach((list, name) => removeRuntimes(name, list));
4588
+ }
4589
+ }
4590
+ return engineApi.defineLayer(layerBuilder);
4591
+ }).catch(err => console.error('[ixmaps-gl] map.newTheme():', err));
4592
+ return engineApi;
4593
+ },
4439
4594
  remove: (id) => { removeThemeById(id); return engineApi; },
4440
4595
  // flat's map.setMapType(id) / setMapTypeId / mapType (htmlgui_flat.js
4441
4596
  // mapApi): swap the basemap at runtime. MapLibre's setStyle replaces
@@ -4557,23 +4712,40 @@
4557
4712
  // "filter:WHERE ..." value reuses rt.setRuntimeFilter, the SAME
4558
4713
  // applyWhereFilter grammar the layer's own load-time .filter()
4559
4714
  // already parses — just invoked again at runtime.
4715
+ //
4716
+ // themeId null: every theme but the FEATURE ones (flat's
4717
+ // Themes.changeThemeStyle, maptheme.js 4464: `szFlag.match(/FEATURE/)`
4718
+ // skipped), one redraw for all. Numeric keys follow flat's
4719
+ // "factor"/"add"/"pow" flags and scale its resize factor
4720
+ // (flatChangedStyleValue) — the scale slider's
4721
+ // changeThemeStyle(null, null, 'scale:' + v, 'set').
4560
4722
  changeThemeStyle: (themeId, styleKeyValue, action) => {
4561
- const rt = findRuntime(themeId);
4562
- if (!rt) return;
4723
+ const targets = themeId == null
4724
+ ? runtimes.filter(r => ![...r.flags].some(f => /FEATURE/.test(f)))
4725
+ : [findRuntime(themeId)].filter(Boolean);
4726
+ if (!targets.length) return;
4563
4727
  const colonIdx = String(styleKeyValue).indexOf(':');
4564
4728
  const key = colonIdx === -1 ? String(styleKeyValue) : styleKeyValue.slice(0, colonIdx);
4565
4729
  const value = colonIdx === -1 ? '' : styleKeyValue.slice(colonIdx + 1);
4566
- if (validation) validation.style(themeId, { [key]: value });
4567
- if (key === 'filter') {
4568
- rt.setRuntimeFilter(action === 'remove' ? '' : value);
4569
- refresh();
4570
- } else if (key === 'type') {
4730
+ if (key === 'type') {
4571
4731
  console.warn(`[ixmaps-gl] changeThemeStyle: changing the type ("${value}") is not supported`);
4572
- } else {
4573
- // any other style key, as flat: "set" writes it, "remove" drops it
4574
- rt.setStyle({ [key]: /remove/.test(action || '') ? undefined : value });
4575
- refresh();
4732
+ return;
4576
4733
  }
4734
+ let changed = false;
4735
+ targets.forEach(rt => {
4736
+ if (validation) validation.style(themeIdOf(rt), { [key]: value });
4737
+ if (key === 'filter') {
4738
+ rt.setRuntimeFilter(action === 'remove' ? '' : value);
4739
+ changed = true;
4740
+ return;
4741
+ }
4742
+ // any other style key, as flat: "set" writes it, "remove" drops it
4743
+ const next = /remove/.test(action || '') ? undefined : flatChangedStyleValue(key, rt.style[key], value, action);
4744
+ if (next === null) return;
4745
+ rt.setStyle({ [key]: next });
4746
+ changed = true;
4747
+ });
4748
+ if (changed) refresh();
4577
4749
  },
4578
4750
  // flat's ixmaps.setThemeTimeFrame(szId, min, max): show only the
4579
4751
  // records whose timefield lies in [min, max) (ms or Date-
@@ -4663,10 +4835,17 @@
4663
4835
  .catch(err => console.error('[ixmaps-gl] map.layer():', err));
4664
4836
  return engineApi;
4665
4837
  }
4666
- // map.layer(name).data()...define(): replaces a theme of that name
4838
+ // map.layer(name).data()...define(): adds a theme on that layer.
4839
+ // NO same-name auto-remove here: flat lets several themes SHARE one
4840
+ // .layer() name (a page's feature base + its chart overlay, the
4841
+ // NYC status page's two "Station" themes — the overlay joins onto
4842
+ // the base's geometry by lookup, removing the base would kill
4843
+ // both). Replacement is by THEME ID (style.name/meta.name), and
4844
+ // happens inside defineFromDefinition instead — a re-defined id
4845
+ // (the page's 10s live refresh) replaces its own previous theme.
4667
4846
  const lb = new LayerBuilder(nameOrBuilder);
4668
4847
  lb.define = () => {
4669
- _chainedLayers = _chainedLayers.then(() => { engineApi.removeTheme(nameOrBuilder); return engineApi.defineLayer(lb); })
4848
+ _chainedLayers = _chainedLayers.then(() => engineApi.defineLayer(lb))
4670
4849
  .catch(err => console.error('[ixmaps-gl] map.layer().define():', err));
4671
4850
  return _chainedLayers;
4672
4851
  };
@@ -4695,8 +4874,22 @@
4695
4874
  // Removes every runtime whose OWN .layer(name) matches — a theme
4696
4875
  // may legitimately be split across more than one runtime sharing
4697
4876
  // 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)),
4877
+ // removes all of them, not just the first match. GL-PORT COMPAT:
4878
+ // flat addresses a theme by its ID — style.name / meta.name —
4879
+ // as much as by its layer name (htmlgui.js removeTheme), and a
4880
+ // real page's own rebuild loop typically knows only the id it
4881
+ // set via .meta({name}) (the fire page's KDE: removeTheme
4882
+ // ("kde_fires") for the layer "kde_fires_layer"). Layer name
4883
+ // first, then the id — without the fallback every rebuild
4884
+ // removed nothing and stacked another copy of the theme,
4885
+ // compounding opacity (same semantics removeThemeById already
4886
+ // implements for the handle's .remove(id)).
4887
+ removeTheme: (name) => {
4888
+ const byLayer = runtimes.filter(r => r.name === name);
4889
+ if (byLayer.length) return removeRuntimes(name, byLayer);
4890
+ const byId = runtimes.filter(r => (r.style && r.style.name === name) || (r.meta && r.meta.name === name));
4891
+ return byId.length ? removeRuntimes(byId[0].name, byId) : false;
4892
+ },
4700
4893
  // flat's refreshTheme (maptheme.js Themes.refreshTheme): the
4701
4894
  // theme's data is loaded again — a broker is called again — and
4702
4895
  // replaces the old features in place (draw order, legend, style
@@ -4825,10 +5018,15 @@
4825
5018
  else map.fitBounds(bounds, { padding: 30, animate: false, maxZoom: 16 });
4826
5019
  }
4827
5020
 
4828
- async function loadNamedTheme(rt, force) {
5021
+ // quiet: the broker's data fills only this theme — not broadcast to
5022
+ // the other themes of the name (flat's refreshTheme reloads one theme;
5023
+ // setExternalData only releases themes still waiting for the data,
5024
+ // maptheme.js 2570-2591)
5025
+ async function loadNamedTheme(rt, force, quiet = false) {
4829
5026
  const spec = normalizeTheme(rt._definition);
4830
5027
  const name = String(spec.data.name);
4831
5028
  const broker = brokerTheme(spec);
5029
+ if (quiet && broker) _quietNamedData.set(name, (_quietNamedData.get(name) || 0) + 1);
4832
5030
  rt._loading = (rt._loading || 0) + 1;
4833
5031
  if (broker) { pendingLoads++; rt._brokerLoading = (rt._brokerLoading || 0) + 1; setDataLoading(); }
4834
5032
  try {
@@ -4850,6 +5048,7 @@
4850
5048
  else console.error(`[ixmaps-gl] theme "${themeIdOf(rt)}": no data —`, err);
4851
5049
  } finally {
4852
5050
  rt._loading--;
5051
+ if (quiet && broker) { const n = (_quietNamedData.get(name) || 1) - 1; if (n) _quietNamedData.set(name, n); else _quietNamedData.delete(name); }
4853
5052
  if (broker) { pendingLoads--; rt._brokerLoading--; setDataLoading(); }
4854
5053
  }
4855
5054
  }
@@ -4898,7 +5097,11 @@
4898
5097
  }
4899
5098
  // flat, after a FEATURE theme is drawn (maptheme.js 18947-18962): the
4900
5099
  // other themes on its layer are placed again, and a data.query broker
4901
- // among them is refreshed 500 ms later
5100
+ // among them is refreshed 500 ms later — quietly: its new data fills
5101
+ // that theme only. Broadcast to every theme of the name, a FEATURE
5102
+ // theme sharing the broker's data name (the fire areas page: SIF
5103
+ // polygons and chart both "data_2") was refilled, drawn, refreshed
5104
+ // its dependent again — an endless reload of ~15k records every 3 s
4902
5105
  function featureDependents(featureRt) {
4903
5106
  runtimes.forEach(dep => {
4904
5107
  if (dep === featureRt || dep.flags.has('FEATURE') || dep.flags.has('FEATURES')) return;
@@ -4906,7 +5109,7 @@
4906
5109
  const spec = normalizeTheme(dep._definition || {});
4907
5110
  if (dep._named && spec.data && spec.data.query) {
4908
5111
  clearTimeout(dep._dependentTimer);
4909
- dep._dependentTimer = setTimeout(() => { if (runtimes.includes(dep)) loadNamedTheme(dep, true); }, 500);
5112
+ dep._dependentTimer = setTimeout(() => { if (runtimes.includes(dep)) loadNamedTheme(dep, true, true); }, 500);
4910
5113
  } else if (dep._named && dep._lastTable && !dep._loading) {
4911
5114
  fillNamedTheme(dep, spec, dep._lastTable, null);
4912
5115
  }
@@ -4915,10 +5118,18 @@
4915
5118
 
4916
5119
  // One theme definition (real ixmaps-flat's shape — from a builder's
4917
5120
  // definition() or a project file) → a new runtime on this map. Shared
4918
- // by defineLayer and loadProject. Purely additive, see defineLayer.
5121
+ // by defineLayer and loadProject. Purely additive across LAYER names —
5122
+ // several themes legitimately share one (a FEATURE base + its CHART
5123
+ // overlay, the CHOROPLETH/FEATURE-donor pair) — but a re-defined
5124
+ // THEME ID (style.name/meta.name) replaces its own previous theme, as
5125
+ // flat itself does ("defining a theme with the same name replaces it
5126
+ // in place"; a page's live-refresh loop relies on it, e.g. the NYC
5127
+ // status page re-defining its two themes every 10s).
4919
5128
  async function defineFromDefinition(def) {
4920
5129
  if (validation) validation.definition(def);
4921
5130
  const pdef = projectThemeToDefinition(def);
5131
+ const replacedId = (pdef.meta && pdef.meta.name) || (pdef.style && pdef.style.name);
5132
+ if (replacedId) removeThemeById(replacedId);
4922
5133
  let spec = normalizeTheme(pdef);
4923
5134
  let raw;
4924
5135
  const deferred = deferredFeatureLoad(spec, map.getZoom());
@@ -5093,10 +5304,26 @@
5093
5304
  // never by size, so resizing the SAME cached icon every frame
5094
5305
  // creates no new atlas entries — none of the reclustering risk
5095
5306
  // above applies to a pure size change.
5307
+ //
5308
+ // globe: MapLibre shifts getZoom() by log2(cos(center lat)) as the
5309
+ // center moves north/south (the globe keeps its size on screen); the
5310
+ // grid zoom takes that out, so a gridwidthpx grid changes on zoom
5311
+ // only, not on pan (flat's orthographic keeps its scale on rotation)
5312
+ // — rounded so float noise doesn't miss GridAggregateIndex's
5313
+ // per-zoom cache. Measured on the GHS orthographic page: center lat
5314
+ // 17.2° → 47.2° moved getZoom() 2.467 → 1.975, re-binning the
5315
+ // 3 px MEAN cloud (cells changing color) on every pan.
5316
+ function gridZoomOf() {
5317
+ const z = map.getZoom();
5318
+ const proj = typeof map.getProjection === 'function' && map.getProjection();
5319
+ if (!proj || proj.type !== 'globe') return z;
5320
+ const lat = map.getCenter().lat * Math.PI / 180;
5321
+ return Math.round((z - Math.log2(Math.cos(lat))) * 1e6) / 1e6;
5322
+ }
5096
5323
  function refreshLayers() {
5097
5324
  const liveZoom = map.getZoom();
5098
5325
  // frozen only while MapLibre really is zooming (see 'moveend')
5099
- const zoom = isZooming && map.isZooming() ? stableGridZoom : liveZoom;
5326
+ const zoom = isZooming && map.isZooming() ? stableGridZoom : gridZoomOf();
5100
5327
  const viewMoving = map.isMoving();
5101
5328
  const bounds = map.getBounds();
5102
5329
  const bbox = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()];
@@ -5179,11 +5406,11 @@
5179
5406
  // zoom too) picks up the hide/show within one 150ms tick — still
5180
5407
  // effectively instant, without the unthrottled rebuild storm.
5181
5408
  let isZooming = false;
5182
- let stableGridZoom = map.getZoom();
5409
+ let stableGridZoom = gridZoomOf();
5183
5410
  map.on('zoomstart', () => { isZooming = true; });
5184
5411
  map.on('zoomend', () => {
5185
5412
  isZooming = false;
5186
- stableGridZoom = map.getZoom();
5413
+ stableGridZoom = gridZoomOf();
5187
5414
  // Was: clearTimeout(refreshTimer); refreshLayers(); — a DIRECT,
5188
5415
  // un-throttled rebuild on every single zoomend. That was fine
5189
5416
  // while every runtime's per-tick cost was cheap (viewport-scoped
@@ -5233,7 +5460,7 @@
5233
5460
  // the same arrows at every zoom, while the scale gates (liveZoom)
5234
5461
  // still switched.
5235
5462
  isZooming = false;
5236
- stableGridZoom = map.getZoom();
5463
+ stableGridZoom = gridZoomOf();
5237
5464
  refresh();
5238
5465
  });
5239
5466
 
@@ -5452,7 +5679,7 @@
5452
5679
  // as ixmaps.map() does). The builder's own methods with different
5453
5680
  // semantics — layer, view, options, require, attribution, on — keep
5454
5681
  // theirs and are not overridden.
5455
- for (const m of ['replace', 'add', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
5682
+ for (const m of ['replace', 'add', 'newTheme', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
5456
5683
  'refreshTheme', 'redrawTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'setProjection', 'resize',
5457
5684
  'loadProject', 'project', 'setThemeVisible', 'setThemeTimeFrame', 'show', 'hide', 'getZoom', 'getThemeObj',
5458
5685
  'getThemes', 'theme', 'setView']) {
@@ -5572,8 +5799,11 @@
5572
5799
  // itself, which stays purely additive for every other caller —
5573
5800
  // see its own comment for why (the CHOROPLETH/FEATURE-donor
5574
5801
  // pairing needs two runtimes sharing one name to coexist).
5802
+ // NO same-LAYER-name auto-remove (see engineApi.layer's own
5803
+ // comment — several themes legitimately share one layer name);
5804
+ // a re-defined THEME ID replaces its own previous theme inside
5805
+ // defineFromDefinition instead
5575
5806
  lb.define = () => {
5576
- resolvedApi.removeTheme(nameOrBuilder);
5577
5807
  return resolvedApi.defineLayer(lb);
5578
5808
  };
5579
5809
  }
@@ -5856,6 +6086,41 @@
5856
6086
  * (flatChartBranch(flags) === 'bubble' ? 0.5 : 1);
5857
6087
  return Math.max(VALUE_RADIUS_MIN, Math.min(VALUE_RADIUS_MAX, fixed * zoomFactor));
5858
6088
  }
6089
+ // NOSIZE: every chart ONE fixed radius, whatever the item's value;
6090
+ // normalsizevalue doesn't scale it. Which one depends on flat's SYMBOL
6091
+ // sub-branch:
6092
+ // - a CATEGORICAL theme without AGGREGATE/GROUP ("symbol equals
6093
+ // value", maptheme.js 21566-21625, "GR 01.07.2023 redesign size
6094
+ // handling"): the normal radius itself
6095
+ // - every other symbol chart (the per-part ladder, 21829-21896):
6096
+ // SEQUENCE normalX(chart size / 3), 2/3 of it; otherwise
6097
+ // nMaxRadius / 5 — measured on the fire areas page's SIF chart
6098
+ // (CHART|SYMBOL|NOSIZE: r 60 units at normalX(1) 20, i.e. 3 of the
6099
+ // 15-unit normal radius)
6100
+ // The BUBBLE draw keeps its own older NOSIZE value, half of it
6101
+ // (maptheme.js 19839)
6102
+ if (flags && flags.has('NOSIZE') && !flags.has('PLOT') && !flags.has('GRIDSIZE')) {
6103
+ const symbolEqualsValue = flags.has('CATEGORICAL') && !flags.has('AGGREGATE') && !flatFlag(flags, 'GROUP');
6104
+ const fixed = NORMAL_RADIUS_PX * (styleNum(style.scale) || 1)
6105
+ * (flatChartBranch(flags) === 'bubble' ? 0.5
6106
+ : symbolEqualsValue ? 1
6107
+ : flatFlag(flags, 'SEQUENCE') ? 2 / 3 : 1 / 5);
6108
+ return Math.max(VALUE_RADIUS_MIN, Math.min(VALUE_RADIUS_MAX, fixed * zoomFactor));
6109
+ }
6110
+ // Same redesign, the rule around it (maptheme.js 21612-21625) — only in
6111
+ // flat's "symbol equals value" sub-branch, a CATEGORICAL theme without
6112
+ // AGGREGATE/GROUP: the symbol resizes by itemA[a].nSize, which flat only
6113
+ // sets for a theme with a bound szSizeField (maptheme.js 9144-9153), so
6114
+ // WITHOUT a size field every item keeps the DEFAULT normal radius,
6115
+ // whatever its value. Every other symbol chart (the per-part ladder,
6116
+ // 21850-21851) and BUBBLE (19851) scale by the value — below.
6117
+ if (flags && flags.has('SYMBOL') && !flags.has('BUBBLE') && !flags.has('USER')
6118
+ && !flags.has('FIXSIZE') && !flags.has('PLOT') && !flags.has('GRIDSIZE')
6119
+ && flags.has('CATEGORICAL') && !flags.has('AGGREGATE') && !flatFlag(flags, 'GROUP')
6120
+ && !style.sizefield) {
6121
+ const fixed = NORMAL_RADIUS_PX * (styleNum(style.scale) || 1);
6122
+ return Math.max(VALUE_RADIUS_MIN, Math.min(VALUE_RADIUS_MAX, fixed * zoomFactor));
6123
+ }
5859
6124
  // Real default (maptheme.js ~line 5059) when the theme sets no
5860
6125
  // normalsizevalue: the dataset's own max value on the bound size
5861
6126
  // field (maxSizeValue, from LayerRuntime._prepare), not a fixed
@@ -6168,9 +6433,9 @@
6168
6433
 
6169
6434
  // a chart's VALUES text (maptheme.js 20979-21017): an explicit valuefield
6170
6435
  // 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
6436
+ // is), else the value; numbers formatted with valuedecimals when set
6437
+ // (explicitValueDecimals: a given "0" counts), else 1 decimal below 1 —
6438
+ // or when the max value is at most 1 — else none, plus the unit, which flat stores with a leading space unless
6174
6439
  // it starts with "." (9675) and appends only up to 5 characters.
6175
6440
  // A CATEGORICAL bubble prints its size value when size is bound to a
6176
6441
  // field (its value, see resolveAggregateValue), as flat does; with
@@ -6184,11 +6449,30 @@
6184
6449
  if (!field || field !== binding.value || !flags.has('CATEGORICAL') || !labels || labels[i] == null) return null;
6185
6450
  return { [field]: labels[i] };
6186
6451
  }
6452
+ // style.valuedecimals as flat reads it: nValueDecimals keeps the raw
6453
+ // style string, so an explicit "0" is truthy in flat's `nValueDecimals ||
6454
+ // default` and counts (fire areas page: valuedecimals "0", 0.48 → "1 ha");
6455
+ // null when not set
6456
+ // (valuedecimals is therefore not typed: STYLE_NUMBER_KEYS). Flat's
6457
+ // truthiness: unset, "" and a numeric 0 fall back to the default
6458
+ function explicitValueDecimals(style) {
6459
+ const v = style && style.valuedecimals;
6460
+ if (!v) return null;
6461
+ const n = Number(v);
6462
+ return Number.isFinite(n) ? n : null;
6463
+ }
6464
+ // the legend's decimals (legend.js 610): valuedecimals when defined, else 2
6465
+ function legendDecimals(style) {
6466
+ const v = style && style.valuedecimals;
6467
+ const n = v == null || v === '' ? NaN : Number(v);
6468
+ return Number.isFinite(n) ? n : 2;
6469
+ }
6187
6470
  function flatValueText(raw, title, value, style, flags, opts = {}) {
6188
6471
  const units = style.units ? String(style.units) : '';
6189
6472
  const unit = units ? (units[0] === '.' ? '' : ' ') + units : '';
6190
6473
  const unitText = unit.length <= 5 ? unit : '';
6191
- const dec = v => styleNum(style.valuedecimals) || ((v < 1 || (opts.maxValue != null && opts.maxValue <= 1)) ? 1 : 0);
6474
+ const explicitDec = explicitValueDecimals(style);
6475
+ const dec = v => (explicitDec != null ? explicitDec : ((v < 1 || (opts.maxValue != null && opts.maxValue <= 1)) ? 1 : 0));
6192
6476
  const signed = (v, text) => {
6193
6477
  if (flags && (flags.has('DIFFERENCE') || flags.has('RELATIVE') || flags.has('SIGN'))) {
6194
6478
  if (v === 0) return '+/-' + text;
@@ -6201,7 +6485,7 @@
6201
6485
  if (field && raw && raw[field] != null && raw[field] !== '') {
6202
6486
  if (isNaN(raw[field])) return String(raw[field]);
6203
6487
  const v = Number(raw[field]);
6204
- return signed(v, flatGroupedValue(v, styleNum(style.valuedecimals) || (v < 1 ? 1 : 0), opts.noBreaks) + unitText);
6488
+ return signed(v, flatGroupedValue(v, explicitDec != null ? explicitDec : (v < 1 ? 1 : 0), opts.noBreaks) + unitText);
6205
6489
  }
6206
6490
  return signed(value, flatGroupedValue(value, dec(value), opts.noBreaks) + unitText);
6207
6491
  }
@@ -6303,19 +6587,29 @@
6303
6587
  }
6304
6588
 
6305
6589
  function valuesHiddenByScale(style, zoom) {
6306
- const m = /^1:(\d+(?:\.\d+)?)$/.exec(style.valueupper || '');
6590
+ // flat accepts both 'valueupper' and 'valuesupper' (maptheme.js 1710,
6591
+ // "GR 15.03.2017 accept also 'valuesupper'"), same for the lower pair
6592
+ const m = /^1:(\d+(?:\.\d+)?)$/.exec(style.valueupper || style.valuesupper || '');
6307
6593
  if (!m) return false;
6308
6594
  const upperRatio = parseFloat(m[1]);
6309
6595
  const scaleDenominator = scaleDenominatorAt(zoom);
6310
6596
  return scaleDenominator > upperRatio;
6311
6597
  }
6598
+ // flat's chart LABEL scale gate (maptheme.js 24536-24537): the label is
6599
+ // visible while the scale passes <= against BOTH bounds — flat's own
6600
+ // lower test reads <= too, kept as-is for parity
6601
+ function labelHiddenByScale(style, zoom) {
6602
+ const upper = scaleDenom(style.labelupper), lower = scaleDenom(style.labellower);
6603
+ const scale = scaleDenominatorAt(zoom);
6604
+ return !!((upper && scale > upper) || (lower && scale > lower));
6605
+ }
6312
6606
 
6313
6607
  // contrasting text color against a bubble's own fill — standard relative
6314
6608
  // luminance; approximates the real engine's __maptheme_getChartColors(...)
6315
6609
  // .textColor (white text on ixmaps' typically dark/saturated palette,
6316
6610
  // dark text on pale fills) without needing its exact color-space table.
6317
6611
  function contrastTextColor(fillRgb) {
6318
- const [r, g, b] = fillRgb;
6612
+ const [r, g, b] = fillRgb || [128, 128, 128];
6319
6613
  const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
6320
6614
  return luminance > 0.6 ? [30, 30, 30] : [255, 255, 255];
6321
6615
  }
@@ -6751,6 +7045,83 @@
6751
7045
  return { offset, index, extent };
6752
7046
  }
6753
7047
 
7048
+ // Chart texts covered by charts drawn after them. Flat draws each chart
7049
+ // as one SVG group (glow, symbol, value text, in that order) in the
7050
+ // sorted draw order, so a later chart's symbol and glow paint over the
7051
+ // texts of every chart beneath it; here the texts are separate TextLayers
7052
+ // above every icon. This composites that occlusion on the CPU: for each
7053
+ // text, the transmittance Π(1 − α) of every LATER chart's symbol parts
7054
+ // and glow at a few sample points of the text box, averaged — the
7055
+ // fraction of the text still showing through, its alpha. Partly covered
7056
+ // texts fade instead of being clipped (no per-pixel mask in deck.gl).
7057
+ // charts: in draw order, { x, y, parts: [{ dx, dy, r, shape, alpha }],
7058
+ // glowR, glowAlpha } in screen pixels; a part's alpha its opacity,
7059
+ // glowR 0 for none (glowAlpha scales glowProfileAlpha)
7060
+ // texts: { order (index into charts), x, y, w, h }
7061
+ // Returns each text's transmittance, 0..1.
7062
+ const OCCLUSION_CELL_PX = 64;
7063
+ // below this a text is as good as hidden: left out of its TextLayer
7064
+ const CHART_TEXT_MIN_TRANSMITTANCE = 0.08;
7065
+ function chartTextTransmittance(charts, texts) {
7066
+ const grid = new Map();
7067
+ charts.forEach((c, k) => {
7068
+ let ext = c.glowR || 0;
7069
+ for (const p of c.parts) ext = Math.max(ext, Math.hypot(p.dx, p.dy) + p.r * Math.SQRT2);
7070
+ const x0 = Math.floor((c.x - ext) / OCCLUSION_CELL_PX), x1 = Math.floor((c.x + ext) / OCCLUSION_CELL_PX);
7071
+ const y0 = Math.floor((c.y - ext) / OCCLUSION_CELL_PX), y1 = Math.floor((c.y + ext) / OCCLUSION_CELL_PX);
7072
+ for (let gx = x0; gx <= x1; gx++) {
7073
+ for (let gy = y0; gy <= y1; gy++) {
7074
+ const key = gx + ',' + gy;
7075
+ let cell = grid.get(key);
7076
+ if (!cell) grid.set(key, cell = []);
7077
+ cell.push(k);
7078
+ }
7079
+ }
7080
+ });
7081
+ const alphaAt = (c, x, y) => {
7082
+ let keep = 1;
7083
+ if (c.glowR) {
7084
+ const d = Math.hypot(x - c.x, y - c.y) / c.glowR;
7085
+ if (d < 1) keep *= 1 - c.glowAlpha * glowProfileAlpha(d);
7086
+ }
7087
+ for (const p of c.parts) {
7088
+ if (insideSymbolShape(p.shape, x - c.x - p.dx, y - c.y - p.dy, p.r)) keep *= 1 - p.alpha;
7089
+ }
7090
+ return keep;
7091
+ };
7092
+ // the box center and four points inside its corners
7093
+ const SAMPLES = [[0, 0], [-0.35, -0.3], [0.35, -0.3], [-0.35, 0.3], [0.35, 0.3]];
7094
+ return texts.map(t => {
7095
+ let sum = 0;
7096
+ for (const [sx, sy] of SAMPLES) {
7097
+ const x = t.x + sx * t.w, y = t.y + sy * t.h;
7098
+ const cell = grid.get(Math.floor(x / OCCLUSION_CELL_PX) + ',' + Math.floor(y / OCCLUSION_CELL_PX));
7099
+ let keep = 1;
7100
+ // cells list charts in draw order: only the later ones cover
7101
+ if (cell) for (let i = cell.length - 1; i >= 0 && cell[i] > t.order && keep > 0; i--) keep *= alphaAt(charts[cell[i]], x, y);
7102
+ sum += keep;
7103
+ }
7104
+ return sum / SAMPLES.length;
7105
+ });
7106
+ }
7107
+ // _getGlowIcon's radial gradient: 0.28 at the center, 0.09 at half the
7108
+ // radius, 0 at the edge (d = distance / radius)
7109
+ function glowProfileAlpha(d) {
7110
+ return d < 0.5 ? 0.28 - 0.19 * d / 0.5 : d < 1 ? 0.09 * (1 - (d - 0.5) / 0.5) : 0;
7111
+ }
7112
+ // inside drawSymbolPath's shape of radius r, (dx, dy) from its center;
7113
+ // the shapes without their own test as the circle
7114
+ function insideSymbolShape(shape, dx, dy, r) {
7115
+ switch (shape) {
7116
+ case 'squarefull': return Math.abs(dx) <= r && Math.abs(dy) <= r;
7117
+ case 'square': return Math.abs(dx) <= r * Math.SQRT1_2 && Math.abs(dy) <= r * Math.SQRT1_2;
7118
+ case 'roundrect': return Math.abs(dx) <= r * 0.8 && Math.abs(dy) <= r * 0.8;
7119
+ case 'diamond': case 'carot': return Math.abs(dx) + Math.abs(dy) <= r;
7120
+ case 'empty': return false;
7121
+ default: return dx * dx + dy * dy <= r * r;
7122
+ }
7123
+ }
7124
+
6754
7125
  // A pixel offset [dx, dy] (y down) from lngLat at MapLibre zoom `zoom`,
6755
7126
  // as lng/lat (Web Mercator, 512 px world tiles) — for shapes too large
6756
7127
  // for an icon, drawn in map coordinates (rebuilt with every refresh).
@@ -6830,6 +7201,25 @@
6830
7201
  return parts;
6831
7202
  }
6832
7203
 
7204
+ // CHART|SYMBOL|SEQUENCE|RINGS (flat maptheme.js 21293-21318): the parts
7205
+ // nest on the center — each non-zero part sized by the running total of
7206
+ // the non-zero parts up to it (its own value included), biggest first
7207
+ // so the smaller ones stay on top; parts of value 0 are left out.
7208
+ // radiusOf(total) is the ring's radius. Measured on flat (NYC status:
7209
+ // 63 | 75 | 3 → r 161.5 | 353.8 | 361.5 at max radius 300, max value
7210
+ // 117). Returns { i, v (the part's own value), c (running total), r, x, y }.
7211
+ function ringsLayout(counts, radiusOf) {
7212
+ const parts = [];
7213
+ let total = 0;
7214
+ counts.forEach((v, i) => {
7215
+ if (!v) return;
7216
+ total += v;
7217
+ const r = radiusOf(total);
7218
+ if (r > 0) parts.push({ i, v, c: total, r, x: 0, y: 0 });
7219
+ });
7220
+ return parts.sort((a, b) => b.c - a.c);
7221
+ }
7222
+
6833
7223
  // ---------------------------------------------------------------
6834
7224
  // Classification — pure functions (no LayerRuntime state): class
6835
7225
  // breaks for a numeric field, and which class a value falls in.
@@ -7966,6 +8356,18 @@
7966
8356
  return out;
7967
8357
  }
7968
8358
 
8359
+ // Every runtime ever built gets a fresh number: a theme rebuilt through
8360
+ // removeTheme() + defineLayer() (a page's own refresh loop, e.g. the fire
8361
+ // page's KDE isobands) is a NEW runtime whose data may differ from the
8362
+ // runtime it replaces — if its deck.gl layer kept the SAME id, deck would
8363
+ // update the old layer's state in place, and under the interleaved overlay
8364
+ // those in-place updates can leave the PREVIOUS generation's geometry on
8365
+ // screen (the exact failure _dataLayerId's own comment documents for
8366
+ // replaceFeatures: "updates in place, arriving over each other, left
8367
+ // triangles across sections"). A unique -dN suffix per runtime forces deck
8368
+ // to build a genuinely new layer and dispose the old one.
8369
+ let LAYER_DATA_GENERATION = 0;
8370
+
7969
8371
  class LayerRuntime {
7970
8372
  // spec: a normalizeTheme() result — never a raw LayerBuilder
7971
8373
  constructor(spec, fc, mapOptions) {
@@ -7974,6 +8376,8 @@
7974
8376
  this.style = spec.style;
7975
8377
  this.flags = symbolsFlagCompat(spec.flags, spec.style);
7976
8378
  this.meta = spec.meta;
8379
+ // see LAYER_DATA_GENERATION above — unique per runtime, see _dataLayerId
8380
+ this._dataGen = ++LAYER_DATA_GENERATION;
7977
8381
  // GL-PORT COMPAT: retained only for ixmaps.getThemeObj()'s szFilter
7978
8382
  // (real ixmaps-flat global compat shim, see _globalThemeRegistry) —
7979
8383
  // this engine's own filtering is fully static/load-time (see
@@ -8144,6 +8548,21 @@
8144
8548
  // ("investimento"/"tamponamento"/...) needs the display text, not
8145
8549
  // a raw numeric code it could never match against.
8146
8550
  this.categoryColorsRgb = resolveClassColors(this.style.colorscheme, this.categoryDisplayLabels, this.style.classes);
8551
+ } else if (this._isMultiFieldSequence()) {
8552
+ // CHART|SYMBOL|SEQUENCE over several value fields (no CATEGORICAL,
8553
+ // no AGGREGATE): one part per field, colored by the colorscheme in
8554
+ // field order, labeled by .style({label}) (flat's szFieldsA /
8555
+ // szLabelA). A colorscheme entry "none" is a part without fill —
8556
+ // only its outline in linecolor (flat: fill:none). The parts' max
8557
+ // value is flat's nMax: the biggest single field value of any
8558
+ // record (maptheme.js nMaxA → nMax), measured on the NYC status page
8559
+ Object.assign(this, computeMultiFieldClasses(this.binding, this.style));
8560
+ this.categoryIndexByLabel = new Map(this.categoryLabels.map((v, i) => [v, i]));
8561
+ const scheme = Array.isArray(this.style.colorscheme) ? this.style.colorscheme : [];
8562
+ this._partNoFill = new Set(this._multiFields.map((f, i) => i).filter(i => String(scheme[i]).trim().toLowerCase() === 'none'));
8563
+ let max = 0;
8564
+ 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; }
8565
+ this._multiFieldPartMax = max || 1;
8147
8566
  } else if (this.binding.value) {
8148
8567
  // real engine's OTHER coloring mode (maptheme.js distributeValues,
8149
8568
  // partsA): a NUMERIC bound value, not CATEGORICAL, is classed into
@@ -8156,6 +8575,24 @@
8156
8575
  // "which bucket does this feature belong to", and once buckets +
8157
8576
  // colors exist, everything downstream is identical.
8158
8577
  this._buildPartsA();
8578
+ } else if (this.style.colorscheme != null) {
8579
+ // GL-PORT COMPAT: a theme with NO classification at all (no
8580
+ // CATEGORICAL categories, no numeric binding.value to range-class)
8581
+ // still has classes in the real engine — exactly ONE, its implicit
8582
+ // bucket: _resolveClassIndex returns 0 for every feature, and flat
8583
+ // colors them all with colorScheme[0] (every MapTheme is built
8584
+ // from its colorscheme array — one entry, one class, e.g. the
8585
+ // fire page's KDE isobands: FEATURE|CHOROPLETH, colorscheme
8586
+ // "#dd0088", no value field — every iso band one flat magenta).
8587
+ // Without this branch categoryColorsRgb stayed undefined while
8588
+ // cat was still 0, and the choropleth's getFillColor crashed on
8589
+ // categoryColorsRgb[0] — deck.gl killed the layer at init and
8590
+ // the theme never drew. A colorscheme that resolves to NO colors
8591
+ // (the explicit no-fill ["none"] — flat draws unfilled symbols,
8592
+ // only their values/boxes/titles) keeps that: resolving it to the
8593
+ // fallback palette would paint symbol circles flat never draws.
8594
+ const resolved = resolveColorScheme(this.style.colorscheme, [''], this.style.classes);
8595
+ this.categoryColorsRgb = resolved ? resolved.map(c => Array.isArray(c) ? c : (parseCssColor(c) || [130, 130, 130])) : null;
8159
8596
  }
8160
8597
 
8161
8598
  // facet-driven dynamic filter state (see setFacetFilter/getFacets) —
@@ -8225,6 +8662,17 @@
8225
8662
  // this with AGGREGATE clustering yet, so a clustered GROUP bubble
8226
8663
  // always stays circular (_buildBubbleIcon, untouched).
8227
8664
  _resolveSymbolShape(props) {
8665
+ // flat's class→symbol mapping (maptheme.js 1417, 2074-2077, 12937):
8666
+ // `symbols` WITHOUT a symbolfield names the shape per COLOR class —
8667
+ // szSymbolsA[class], the last entry repeating when the array is
8668
+ // shorter than the classes (a single entry, like the fire page's
8669
+ // symbols: ["square"], shapes every class). Only GL's implemented
8670
+ // shapes draw (drawSymbolPath); flat-only names fall back to circle.
8671
+ if (!this.style.symbolfield && Array.isArray(this.style.symbols)) {
8672
+ const s = this.style.symbols;
8673
+ const cat = props && props.cat != null ? props.cat : 0;
8674
+ return normalizeSymbolShape(s[Math.min(cat, s.length - 1)]);
8675
+ }
8228
8676
  if (!this.style.symbolfield || !Array.isArray(this.style.symbolvalues) || !Array.isArray(this.style.symbols)) return 'circle';
8229
8677
  // CORRECTED: found live via pickObject, not assumed — the properties
8230
8678
  // this actually receives at render time (from _computeAggregatedItems'
@@ -8286,6 +8734,19 @@
8286
8734
  // they'd never even share a cell in the first place). Every
8287
8735
  // feature goes into ONE bucket; _buildChartLayers resolves the real
8288
8736
  // 7-class color once it knows each cell's actual aggregated total.
8737
+ if (this._multiFields && this._isMultiFieldSequence()) {
8738
+ // one bucket; each record carries its field values as the chart's
8739
+ // parts. Its value — the draw order — is the first field's, as
8740
+ // flat's chart node value (NYC status: value="63" = e-bikes)
8741
+ this._featuresByCategory = [sourceFeatures.map(f => {
8742
+ const parts = this._multiFields.map(k => parseFloat(f.properties[k]) || 0);
8743
+ return { type: 'Feature', geometry: f.geometry, properties: { value: parts[0], parts, raw: f.properties } };
8744
+ })];
8745
+ this._clusterIndices = null;
8746
+ this._clusterRadiusPx = null;
8747
+ this._aggregateStatsCache = null;
8748
+ return;
8749
+ }
8289
8750
  if (this._rangeClassed) {
8290
8751
  // flat colors/classes an aggregated cell by the SUM of the value
8291
8752
  // field (nValuesA, maptheme.js:10870ff; classified by nValuesA[0],
@@ -8488,7 +8949,11 @@
8488
8949
  }
8489
8950
 
8490
8951
  _usesAggregationIndex() {
8491
- return (this.flags.has('AGGREGATE') || isSymbolChart(this.flags)) && !this.flags.has('GRIDSIZE');
8952
+ // GRIDSIZE bins its own grid (_ensureGridIndex) — PLOT curves and the
8953
+ // plain mesh; a classed GRIDSIZE symbol chart is aggregated like any
8954
+ // other (see _isPlainGridMesh)
8955
+ return (this.flags.has('AGGREGATE') || isSymbolChart(this.flags))
8956
+ && (!this.flags.has('GRIDSIZE') || (!this.flags.has('PLOT') && !this._isPlainGridMesh()));
8492
8957
  }
8493
8958
 
8494
8959
  _rebuildActiveFeatures() {
@@ -8772,7 +9237,7 @@
8772
9237
  return this._glowIconCache.get(key);
8773
9238
  }
8774
9239
 
8775
- _buildBubbleIcon(counts, colors, marked = null) {
9240
+ _buildBubbleIcon(counts, colors, marked = null, shapes = null) {
8776
9241
  // The cache key quantizes each count's RATIO to the group's total,
8777
9242
  // not the raw count — computeBubblePackLayout's radii are purely
8778
9243
  // sqrt(c/total), so two clusters with wildly different absolute
@@ -8802,7 +9267,7 @@
8802
9267
  // coarse share buckets that can put a small marked part at 0
8803
9268
  const key = counts.map(c => Math.round((c / total) * RATIO_BUCKETS)).join('-') + (marked
8804
9269
  ? '|iso:' + counts.map((c, i) => (c > 0 && marked.has(i) ? i + ':' + Math.round(Math.sqrt(c / total) * 100) : null)).filter(Boolean).join(',')
8805
- : '');
9270
+ : '') + (shapes ? '|sym:' + shapes.map(v => String(v).toLowerCase()).join(',') : '');
8806
9271
  if (this._iconCache.has(key)) return this._iconCache.get(key);
8807
9272
  const size = BUBBLE_ICON_SIZE;
8808
9273
  const canvas = document.createElement('canvas');
@@ -8811,8 +9276,16 @@
8811
9276
  const { present, radii, offsets, fitScale } = (marked && isolatedBubblePackLayout(counts, size, marked)) || computeBubblePackLayout(counts, size);
8812
9277
  const cx = size / 2, cy = size / 2;
8813
9278
  present.forEach((p, i) => {
9279
+ // flat draws each part of an aggregated SYMBOL theme as ITS class's
9280
+ // symbol shape (the szSymbol switch runs per part, maptheme.js
9281
+ // 21995-22060) — `symbols` names them per class like
9282
+ // _resolveSymbolShape; circles when the name isn't one GL draws
9283
+ const shape = shapes ? normalizeSymbolShape(shapes[Math.min(p.i, shapes.length - 1)]) : 'circle';
9284
+ const px = cx + offsets[i].x * fitScale, py = cy + offsets[i].y * fitScale;
8814
9285
  ctx.beginPath();
8815
- ctx.arc(cx + offsets[i].x * fitScale, cy + offsets[i].y * fitScale, radii[i] * fitScale, 0, Math.PI * 2);
9286
+ if (shape === 'circle') ctx.arc(px, py, radii[i] * fitScale, 0, Math.PI * 2);
9287
+ else drawSymbolPath(ctx, shape, px, py, radii[i] * fitScale);
9288
+ ctx.closePath();
8816
9289
  ctx.fillStyle = `rgb(${colors[p.i].join(',')})`;
8817
9290
  ctx.globalAlpha = 0.9; ctx.fill(); ctx.globalAlpha = 1;
8818
9291
  ctx.lineWidth = 1; ctx.strokeStyle = '#fff'; ctx.stroke();
@@ -8862,14 +9335,22 @@
8862
9335
  ctx.beginPath();
8863
9336
  drawSymbolPath(ctx, shape, c, c, r);
8864
9337
  ctx.closePath();
9338
+ // flat's "empty" (maptheme.js 21629-21631): the circle geometry, but
9339
+ // a fully transparent fill and a gray outline (fill-opacity:0,
9340
+ // stroke #888888) — a shapeless spot marker
8865
9341
  ctx.fillStyle = `rgb(${colorRgb.join(',')})`;
8866
- ctx.globalAlpha = opacity;
9342
+ ctx.globalAlpha = shape === 'empty' ? 0 : opacity;
8867
9343
  ctx.fill();
8868
9344
  if (borderWidth > 0 && borderColorRgb) {
8869
9345
  ctx.globalAlpha = 1;
8870
9346
  ctx.strokeStyle = `rgb(${borderColorRgb.join(',')})`;
8871
9347
  ctx.lineWidth = borderWidth;
8872
9348
  ctx.stroke();
9349
+ } else if (shape === 'empty') {
9350
+ ctx.globalAlpha = 1;
9351
+ ctx.strokeStyle = '#888888';
9352
+ ctx.lineWidth = 1;
9353
+ ctx.stroke();
8873
9354
  }
8874
9355
  const icon = { url: canvas.toDataURL(), width: size, height: size, anchorX: size / 2, anchorY: size / 2, id: key };
8875
9356
  return this._cacheIcon(key, icon);
@@ -9146,7 +9627,8 @@
9146
9627
  _formatTooltipValue(value) {
9147
9628
  const num = Number(value);
9148
9629
  if (isNaN(num)) return String(value);
9149
- const rawDecimals = styleNum(this.style.valuedecimals);
9630
+ const vd = this.style.valuedecimals;
9631
+ const rawDecimals = vd == null || vd === '' ? NaN : Number(vd);
9150
9632
  const decimals = isNaN(rawDecimals) ? 2 : rawDecimals;
9151
9633
  // flat's __formatValue (mapscript.js 6268ff) rounds twice: first to
9152
9634
  // one decimal more (toFixed), then to the precision (Math.round) —
@@ -9215,16 +9697,19 @@
9215
9697
  // GRIDSIZE (spatial grid binning) is checked before the generic
9216
9698
  // CHART|SYMBOL bubble pipeline, same "more specific base type wins"
9217
9699
  // precedence as DOT: PLOT+GRIDSIZE is the per-year curves mini-chart
9218
- // (_buildPlotLayers); GRIDSIZE alone (no PLOT — the real engine's
9219
- // "grid" companion layer) is a plain uniform grid mesh, sized to the
9220
- // full cell pitch regardless of SIZE/QUANTILE/MEAN, matching
9221
- // maptheme.js's own `GRIDSIZE && !PLOT -> nRadius = nGridSize/2` rule
9222
- // (SIZE/QUANTILE/MEAN have no visible effect there in the real
9223
- // engine either — confirmed, not a gap in this port).
9700
+ // (_buildPlotLayers); GRIDSIZE without PLOT is flat's symbol chart
9701
+ // with `nRadius = nGridSize/2` (maptheme.js 21843) — every chart the
9702
+ // size of its cell, colored by its class (QUANTILE of the MEAN, the
9703
+ // fuel-price page). Only a plain one-color grid (the "grid"
9704
+ // companion layer) takes the uniform mesh shortcut.
9224
9705
  if (this.flags.has('GRIDSIZE') && this.flags.has('PLOT')) return this._buildPlotLayers(zoom, bbox);
9225
9706
  // PLOT without GRIDSIZE: one line chart per item over its value fields
9226
9707
  if (this.flags.has('PLOT') && this._isItemPlot()) return this._buildItemPlotLayers(zoom);
9227
- if (this.flags.has('GRIDSIZE')) return this._buildGridMeshLayers(zoom, bbox);
9708
+ // a plain grid (one color, no values or glow — the Germany page's
9709
+ // companion "grid" layer) as one square per cell; a classed GRIDSIZE
9710
+ // chart (the fuel-price page) is a symbol chart sized to the grid,
9711
+ // below in _buildChartLayers (_gridChartRadiusPx)
9712
+ if (this.flags.has('GRIDSIZE') && this._isPlainGridMesh()) return this._buildGridMeshLayers(zoom, bbox);
9228
9713
  // CHOROPLETH is checked before the generic FEATURE dispatch — a
9229
9714
  // CHOROPLETH layer's own type string doesn't carry FEATURE/FEATURES
9230
9715
  // (only its geometry-donor base layer does, see
@@ -9259,7 +9744,16 @@
9259
9744
 
9260
9745
  _buildFeaturesLayers(zoom, bbox) {
9261
9746
  const cs = this.style.colorscheme;
9262
- const raw = Array.isArray(cs) ? cs[0] : cs;
9747
+ // a generated scheme's first entry is the class COUNT, not a color —
9748
+ // flat colors an unclassed theme with the sweep's FIRST generated
9749
+ // color (maptheme colorScheme[0] after realize generates the sweep),
9750
+ // never the bare count string (a near-white dot for "5","white","blue"
9751
+ // — the NYC page's station base)
9752
+ let raw = Array.isArray(cs) ? cs[0] : cs;
9753
+ if (Array.isArray(cs) && isGeneratedColorScheme(cs)) {
9754
+ const sweep = flatColorSweep(cs[1], cs[2], Number(cs[0]) || 5, cs[3], cs[4]);
9755
+ if (sweep && sweep.length) raw = sweep[0];
9756
+ }
9263
9757
  const filled = raw !== 'none';
9264
9758
  // FEATURE|CATEGORICAL: polygons coloured by their category (flat colours
9265
9759
  // each shape by its class), not one flat fill
@@ -9288,7 +9782,7 @@
9288
9782
  getFillColor: byCategory
9289
9783
  ? f => {
9290
9784
  const cat = this._resolveClassIndex(f.properties[this.binding.value]);
9291
- return [...(cat != null ? this.categoryColorsRgb[cat] : nodataRgb).slice(0, 3), catAlpha];
9785
+ return [...(cat != null ? (this.categoryColorsRgb || [])[cat] || nodataRgb : nodataRgb).slice(0, 3), catAlpha];
9292
9786
  }
9293
9787
  : filled ? [...hexOrNamedToRgb(raw).slice(0, 3), Math.round(255 * cssColorAlpha(raw) * (styleNum(this.style.fillopacity) || 1))] : [0, 0, 0, 0],
9294
9788
  getLineColor: this.style.linecolor
@@ -9495,7 +9989,7 @@
9495
9989
  getFillColor: d => {
9496
9990
  if (!hasData(d)) return [0, 0, 0, 0];
9497
9991
  const rgb = d.properties.composedColor
9498
- || (d.properties.cat != null ? this.categoryColorsRgb[d.properties.cat] : null) || fallbackRgb;
9992
+ || (d.properties.cat != null ? (this.categoryColorsRgb || [])[d.properties.cat] : null) || fallbackRgb;
9499
9993
  const evidence = unmarkedEvidence(d);
9500
9994
  if (evidence === 'hide') return [0, 0, 0, 0];
9501
9995
  if (evidence === 'gray') return [...grayOf(rgb), 90];
@@ -9510,7 +10004,15 @@
9510
10004
  // per-feature alpha (baked above) already carries the resolved
9511
10005
  // base opacity — a layer-level opacity on TOP of that would
9512
10006
  // multiply it a second time.
9513
- opacity: dopacityActive ? 1 : baseOpacity
10007
+ opacity: dopacityActive ? 1 : baseOpacity,
10008
+ // Same coplanar z-fighting fix as the FEATURE/FEATURES GeoJsonLayer
10009
+ // (see _buildFeaturesLayers' own comment): under globe projection
10010
+ // the interleaved polygon surface and MapLibre's own sphere mesh
10011
+ // compete for the same depth-buffer real estate and the fill comes
10012
+ // out moiré/hatched ("holes"), never under flat Mercator. Draw
10013
+ // order against other polygon layers is unchanged — depthCompare
10014
+ // only settles the fight against the map's own surface.
10015
+ parameters: { depthCompare: 'always' }
9514
10016
  })];
9515
10017
  }
9516
10018
 
@@ -9563,9 +10065,37 @@
9563
10065
  // style.values/xaxis (explicit, see _prepare) if set, else whatever
9564
10066
  // CATEGORICAL auto-discovered.
9565
10067
  // a per-item PLOT: no GRIDSIZE grid, several value fields per item
10068
+ // GRIDSIZE without PLOT drawn as one uniform square per cell: only a
10069
+ // plain grid — a single color, no VALUES, no GLOW
10070
+ _isPlainGridMesh() {
10071
+ return !this.flags.has('PLOT') && (this.categoryColorsRgb || []).length <= 1
10072
+ && !this.flags.has('VALUES') && !this.flags.has('GLOW');
10073
+ }
10074
+ // GRIDSIZE (no PLOT) symbol chart radius, px: flat's nGridSize / 2
10075
+ // (maptheme.js 21843), nGridSize the aggregation cell pitch / nAutoSize
10076
+ // (16944-16952: RECT or no AGGREGATE 1.0, with GAP 1.15; else 0.75,
10077
+ // with GAP 0.8) · style.scale. Measured on the fuel-price page: 5 px
10078
+ // cells, RECT|GAP → nGridSize 4.35 px, circle r 2.17 px. null when not
10079
+ // a GRIDSIZE chart or no grid at this scale.
10080
+ _gridChartRadiusPx(zoom, liveZoom) {
10081
+ if (!this.flags.has('GRIDSIZE') || this.flags.has('PLOT') || !this._clusterRadiusPx) return null;
10082
+ const fromZoom = this._clusterUsesFixedZoom ? GRIDWIDTH_METERS_REFERENCE_ZOOM : zoom;
10083
+ const cellPx = this._clusterRadiusPx * Math.pow(2, liveZoom - fromZoom);
10084
+ const rect = flatFlag(this.flags, 'RECT') || !this.flags.has('AGGREGATE');
10085
+ const gap = flatFlag(this.flags, 'GAP');
10086
+ const autoSize = rect ? (gap ? 1.15 : 1.0) : (gap ? 0.8 : 0.75);
10087
+ return cellPx / autoSize * (styleNum(this.style.scale) || 1) / 2;
10088
+ }
9566
10089
  _isItemPlot() {
9567
10090
  return this.flags.has('PLOT') && !this.flags.has('GRIDSIZE') && String(this.binding.value || '').includes('|');
9568
10091
  }
10092
+ // a per-item SEQUENCE chart: CHART|SYMBOL|SEQUENCE, several value
10093
+ // fields per item, one part per field (no CATEGORICAL, no AGGREGATE)
10094
+ _isMultiFieldSequence() {
10095
+ return this.flags.has('CHART') && this.flags.has('SYMBOL') && flatFlag(this.flags, 'SEQUENCE')
10096
+ && !this.flags.has('CATEGORICAL') && !this.flags.has('AGGREGATE') && !this.flags.has('PLOT')
10097
+ && String(this.binding.value || '').includes('|');
10098
+ }
9569
10099
  _itemPlotFields() {
9570
10100
  return String(this.binding.value || '').split('|');
9571
10101
  }
@@ -10419,6 +10949,20 @@
10419
10949
  ...(f.properties.titleRaw ? { titleRaw: f.properties.titleRaw } : {}) }
10420
10950
  };
10421
10951
  });
10952
+ // MEAN (no CATEGORICAL): a cell's value is the mean of its records,
10953
+ // not their sum (flat's MEAN aggregation) — what its label prints,
10954
+ // its size and its class follow. The fuel-price page: 119 stations,
10955
+ // sum 270.0 → 2.27 €
10956
+ if (this.flags.has('MEAN') && !this.flags.has('CATEGORICAL')) {
10957
+ groups.forEach(g => {
10958
+ const p = g.properties;
10959
+ const n = groupRecordCount(p);
10960
+ if (!(n > 0)) return;
10961
+ if (p.recordCounts) p.counts = p.counts.map((c, i) => (p.recordCounts[i] ? c / p.recordCounts[i] : c));
10962
+ p.total = p.total / n;
10963
+ if (p.classTotal !== undefined) p.classTotal = p.classTotal / n;
10964
+ });
10965
+ }
10422
10966
  return { individual, groups };
10423
10967
  }
10424
10968
 
@@ -10546,6 +11090,10 @@
10546
11090
  // _computeAggregatedItems below — a plain (non-AGGREGATE)
10547
11091
  // BUBBLE/CHART theme never reads them.
10548
11092
  if (this.flags.has('AGGREGATE')) this._ensureClusterIndices(zoom);
11093
+ // GRIDSIZE: every chart the size of its grid cell, whatever its value
11094
+ // (_gridChartRadiusPx); otherwise valueRadius
11095
+ const gridR = this._gridChartRadiusPx(zoom, liveZoom);
11096
+ const vRadius = v => (gridR != null ? gridR : valueRadius(v, liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue));
10549
11097
  // AGGREGATE sums without a normalsizevalue: flat's default is the
10550
11098
  // largest aggregated value (defaultNormalSizeValue leaves it unset, the
10551
11099
  // sizes depend on the aggregation)
@@ -10688,7 +11236,7 @@
10688
11236
  if (userLayers) return layers.concat(userLayers);
10689
11237
  }
10690
11238
  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')) {
11239
+ if ((flatFlag(this.flags, 'SEQUENCE') && this.flags.has('CATEGORICAL') && this.flags.has('AGGREGATE')) || (this._multiFields && this._isMultiFieldSequence())) {
10692
11240
  return layers.concat(this._buildSequenceChartLayers(combined, zoom, liveZoom));
10693
11241
  }
10694
11242
  const fillOpacity = styleNum(this.style.fillopacity) || 0.85;
@@ -10703,7 +11251,7 @@
10703
11251
  const singleBorderWidthPx = styleNum(this.style.linewidth) || 0;
10704
11252
 
10705
11253
  const itemRgb = d => this.categoryColorsRgb[d.properties.counts ? dominant(d.properties.counts) : d.properties.cat] || [128, 128, 128];
10706
- const radiusOf = d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
11254
+ const radiusOf = d => vRadius(sizeValueOf(d));
10707
11255
  // align / offsetx / offsety: the whole chart moves (flatChartAlignOffset)
10708
11256
  // MULTIQUAD / MULTISQUARE: the items at one position side by side
10709
11257
  // (multiQuadOffsets), added to the chart position
@@ -10726,7 +11274,7 @@
10726
11274
  id: `ix-symbolshadow-${this.name}-g${this._iconGeneration}`,
10727
11275
  data: combined, pickable: false,
10728
11276
  getPosition: d => d.geometry.coordinates,
10729
- getIcon: d => this._getShadowIcon(radiusOf(d), blurPx, d.properties.counts ? 'circle' : this._resolveSymbolShape(d.properties)),
11277
+ getIcon: d => this._getShadowIcon(radiusOf(d), blurPx, d.properties.counts && !Array.isArray(this.style.symbols) ? 'circle' : this._resolveSymbolShape(d.properties)),
10730
11278
  getSize: d => radiusOf(d) * 3, // disc = 2/3 of the icon → its diameter is 2r
10731
11279
  getPixelOffset: alignOf.active ? d => { const o = alignOf(d); return [o[0] + dx, o[1] + dy]; } : [dx, dy],
10732
11280
  getColor: d => {
@@ -10751,8 +11299,8 @@
10751
11299
  id: `ix-glow-${this.name}-g${this._iconGeneration}`,
10752
11300
  data: combined, pickable: false,
10753
11301
  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]),
10755
- getSize: d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue) * (d.properties.counts ? 9 : 11),
11302
+ 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]),
11303
+ getSize: d => vRadius(sizeValueOf(d)) * (d.properties.counts ? 9 : 11),
10756
11304
  getColor: d => [255, 255, 255, this._iconAlpha(d)],
10757
11305
  ...(alignOf.active ? { getPixelOffset: alignOf } : {}),
10758
11306
  sizeUnits: 'pixels',
@@ -10798,15 +11346,29 @@
10798
11346
  // Box and title are removed outside boxupper/boxlower.
10799
11347
  // Flat draws box and title in the chart group, which is scaled by
10800
11348
  // 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.
11349
+ // the width of the drawn chart (flat's getBox of the chart group,
11350
+ // 18785): the symbol (with MULTIQUAD the whole grid at the position,
11351
+ // the box enclosing all of it), its value text and its label
11352
+ // (chartItemExtent); flat's chart size is 30 units.
11353
+ // DTEXT: the title font follows the chart, max(0.9u, min(W/5,
11354
+ // 14u / scale)) (18815) — measured on the fire areas page's SIF chart
11355
+ // (W 362 units → font 72.35).
10804
11356
  const boxShown = flatFlag(this.flags, 'BOX') && !boxHiddenByScale(this.style, liveZoom);
10805
11357
  const circular = flatFlag(this.flags, 'CIRCULAR');
10806
11358
  const unitPx = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(this.style.scale) || 1);
10807
11359
  const boxMarginU = styleNum(this.style.boxmargin) || 2;
10808
11360
  const titleShown = boxShown && flatFlag(this.flags, 'TITLE') && !!this.binding.title;
10809
- const titleFontPx = 5 * unitPx * (styleNum(this.style.textscale) || styleNum(this.style.valuescale) || 1);
11361
+ const titleScale = styleNum(this.style.textscale) || styleNum(this.style.valuescale) || 1;
11362
+ const titleFontOf = W => (flatFlag(this.flags, 'DTEXT')
11363
+ ? Math.max(0.9 * unitPx, Math.min(W / 5, 14 * unitPx / (styleNum(this.style.scale) || 1)))
11364
+ : 5 * unitPx) * titleScale;
11365
+ // the chart's value text and label (see VALUES and LABEL below), shared
11366
+ // with the box: flat's box encloses whatever the chart group draws
11367
+ const valuesShown = this.flags.has('VALUES') && !valuesHiddenByScale(this.style, zoom);
11368
+ const valueTextOf = valuesShown ? this._chartValueTextFn() : null;
11369
+ const labelShown = !!this.style.labelfield && !flatFlag(this.flags, 'NORMSIZE') && !flatFlag(this.flags, 'ZOOM')
11370
+ && !labelHiddenByScale(this.style, zoom);
11371
+ const labelOf = labelShown ? this._chartLabelFn(unitPx) : null;
10810
11372
  // flat: fill-opacity (boxopacity || 1) on the raw style value — the
10811
11373
  // usual string "0" is transparent; typed here, a given 0 is too
10812
11374
  const boxOpacity = isNaN(styleNum(this.style.boxopacity)) ? 1 : styleNum(this.style.boxopacity);
@@ -10814,12 +11376,16 @@
10814
11376
  const alignRight = /right/.test(String(this.style.align || ''));
10815
11377
  // with MULTIQUAD only the first item of a position gets its box/title
10816
11378
  const boxLayout = boxShown ? combined.filter(d => !multi || multi.index.get(d) === 0).map(d => {
10817
- const r = valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
11379
+ const r = vRadius(sizeValueOf(d));
10818
11380
  const ext = multi ? multi.extent.get(d) : [0, 0, 0, 0];
10819
11381
  const chart = [ext[0] - r, ext[1] - r, ext[2] + r, ext[3] + r];
11382
+ 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]); };
11383
+ if (valueTextOf && !d.properties.counts) { const v = valueTextOf(d, r); if (v) grow(v.extent); }
11384
+ if (labelOf) { const l = labelOf(d, r); if (l) grow(l.extent); }
10820
11385
  const w = chart[2] - chart[0];
10821
11386
  const margin = Math.min(2 * boxMarginU * unitPx, boxMarginU * w / 30);
10822
11387
  const boxR = circular ? r * 1.1 + margin : r;
11388
+ const titleFontPx = titleFontOf(w);
10823
11389
  let title = null;
10824
11390
  if (titleShown) {
10825
11391
  const text = this._itemTitle(d.properties);
@@ -10835,7 +11401,7 @@
10835
11401
  const anchor = circular ? 'middle' : (alignRight ? 'end' : 'start');
10836
11402
  const x0 = anchor === 'middle' ? x - width / 2 : anchor === 'end' ? x - width : x;
10837
11403
  // arial: ascent 0.905, descent 0.212 of the font size
10838
- title = { text: lines.join('\n'), x, anchor, top: base - 0.8 * f,
11404
+ title = { text: lines.join('\n'), x, anchor, top: base - 0.8 * f, f,
10839
11405
  extent: [x0, base - 0.905 * f, x0 + width, base + (lines.length - 1) * f + 0.212 * f] };
10840
11406
  }
10841
11407
  }
@@ -10935,17 +11501,27 @@
10935
11501
  }));
10936
11502
  }
10937
11503
 
11504
+ // flat's per-part SYMBOL branch (everything but CATEGORICAL without
11505
+ // AGGREGATE/GROUP) draws a square of side 2r, the other one 1.6r
11506
+ // (drawSymbolPath's own square)
11507
+ const perPartBranch = flatChartBranch(this.flags) === 'symbol'
11508
+ && !(this.flags.has('CATEGORICAL') && !this.flags.has('AGGREGATE') && !flatFlag(this.flags, 'GROUP'));
11509
+ const shapeOf = props => { const sh = this._resolveSymbolShape(props); return sh === 'square' && perPartBranch ? 'squarefull' : sh; };
11510
+ // a range-classed cell is ONE class: one symbol in its class color,
11511
+ // fillopacity and linecolor, as a single item (flat draws one
11512
+ // symbol) — the packed multi-category icon is for CATEGORICAL cells
11513
+ const singleSymbol = d => !d.properties.counts || (this._rangeClassed && d.properties.cat != null);
10938
11514
  layers.push(new IconLayer({
10939
11515
  id: `ix-bubbles-${this.name}-g${this._iconGeneration}`,
10940
11516
  data: combined, pickable: true,
10941
11517
  getPosition: d => d.geometry.coordinates,
10942
- 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),
10945
- getSize: d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue) * 2,
11518
+ getIcon: d => !singleSymbol(d)
11519
+ ? this._buildBubbleIcon(d.properties.counts, this.categoryColorsRgb, this._groupIsolation(d), Array.isArray(this.style.symbols) ? this.style.symbols : null)
11520
+ : this._buildSingleIcon((this.categoryColorsRgb || [])[d.properties.cat], fillOpacity, shapeOf(d.properties), singleBorderColorRgb, singleBorderWidthPx),
11521
+ getSize: d => vRadius(sizeValueOf(d)) * 2,
10946
11522
  // group icons bake a fixed 0.9 alpha; an explicit fillopacity scales it
10947
11523
  // so a near-transparent theme doesn't show opaque 1-px-cell clusters
10948
- getColor: d => [255, 255, 255, Math.round(this._iconAlpha(d) * (d.properties.counts && this.style.fillopacity != null ? fillOpacity : 1))],
11524
+ getColor: d => [255, 255, 255, Math.round(this._iconAlpha(d) * (!singleSymbol(d) && this.style.fillopacity != null ? fillOpacity : 1))],
10949
11525
  ...(alignOf.active ? { getPixelOffset: alignOf } : {}),
10950
11526
  sizeUnits: 'pixels',
10951
11527
  billboard: true,
@@ -10959,7 +11535,7 @@
10959
11535
  data: titleData, pickable: false,
10960
11536
  getPosition: b => b.d.geometry.coordinates,
10961
11537
  getText: b => b.title.text,
10962
- getSize: titleFontPx,
11538
+ getSize: b => b.title.f,
10963
11539
  sizeUnits: 'pixels',
10964
11540
  fontFamily: 'arial',
10965
11541
  characterSet: 'auto',
@@ -10979,23 +11555,29 @@
10979
11555
  // VALUES: bold value label centered on each bubble (see
10980
11556
  // flatValueText/valuesFontSizePx above). Labels are their own
10981
11557
  // layers, added last in the array so they draw on top of every icon
10982
- // layer (a small bubble's label can still show over a bigger
10983
- // neighboring bubble's icon — a known, accepted gap). Entries whose
11558
+ // layer; a label under later-drawn charts is faded or dropped by
11559
+ // _occludeChartTexts, as flat's later chart groups cover it. Entries whose
10984
11560
  // computed font size would be sub-pixel are dropped rather than
10985
11561
  // rendered at getSize: 0, matching the real engine's own "too
10986
11562
  // small to bother" gate.
10987
- if (this.flags.has('VALUES') && !valuesHiddenByScale(this.style, zoom)) {
11563
+ if (valuesShown) {
10988
11564
  const valueScale = styleNum(this.style.valuescale) || 1;
10989
11565
  const textOpts = this._valueTextOpts();
11566
+ const pointValueOf = valueTextOf || this._chartValueTextFn();
10990
11567
 
11568
+ // draw order of each chart (see chartTextTransmittance below); an
11569
+ // item maxcharts left out draws no chart, so no text either
11570
+ const orderOf = new Map(combined.map((d, k) => [d, k]));
10991
11571
  const pointLabels = individual.reduce((out, d) => {
10992
- 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);
11572
+ if (!orderOf.has(d)) return out;
11573
+ const radius = vRadius(d.properties.value);
11574
+ // flat's VALUES label prints the value (nValuesA), not the size;
11575
+ // its font by flat's chart branch (_chartValueTextFn)
11576
+ const { text, fontSize, bold, baseline } = pointValueOf(d, radius);
10997
11577
  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) });
11578
+ const o = alignOf(d);
11579
+ out.push({ geometry: d.geometry, text, fontSize, bold, color: resolveTextColor(this.style, contrastTextColor((this.categoryColorsRgb || [])[d.properties.cat])),
11580
+ pixelOffset: baseline ? [o[0], o[1] + baseline - 0.35 * fontSize] : o, order: orderOf.get(d) });
10999
11581
  }
11000
11582
  return out;
11001
11583
  }, []);
@@ -11011,11 +11593,25 @@
11011
11593
  // would misrepresent every category's own count as one summed
11012
11594
  // number nobody's individual bubble actually shows.
11013
11595
  const groupLabels = groups.reduce((out, d) => {
11596
+ if (!orderOf.has(d)) return out;
11014
11597
  const counts = d.properties.counts;
11015
- const outerRadiusPx = valueRadius(d.properties.total, liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
11598
+ const outerRadiusPx = vRadius(d.properties.total);
11016
11599
  const iconSizePx = outerRadiusPx * 2; // matches the cluster IconLayer's own getSize (*2) below
11017
11600
  const pxPerCanvasUnit = iconSizePx / BUBBLE_ICON_SIZE;
11018
11601
  const iso = this._groupIsolation(d);
11602
+ // a range-classed cell is one symbol (see the bubbles layer): its
11603
+ // value text as a single chart's (_chartValueTextFn)
11604
+ if (this._rangeClassed && d.properties.cat != null) {
11605
+ const value = d.properties.classTotal !== undefined ? d.properties.classTotal : d.properties.total;
11606
+ const v = pointValueOf({ properties: { value, cat: d.properties.cat } }, outerRadiusPx);
11607
+ if (v.fontSize > VALUES_MIN_FONT_PX) {
11608
+ const o = alignOf(d);
11609
+ out.push({ geometry: d.geometry, text: v.text, fontSize: v.fontSize, bold: v.bold,
11610
+ color: resolveTextColor(this.style, contrastTextColor((this.categoryColorsRgb || [])[d.properties.cat])),
11611
+ pixelOffset: v.baseline ? [o[0], o[1] + v.baseline - 0.35 * v.fontSize] : o, order: orderOf.get(d) });
11612
+ }
11613
+ return out;
11614
+ }
11019
11615
  const { present, radii, offsets, fitScale } = (iso && isolatedBubblePackLayout(counts, BUBBLE_ICON_SIZE, iso)) || computeBubblePackLayout(counts, BUBBLE_ICON_SIZE);
11020
11616
 
11021
11617
  present.forEach((p, i) => {
@@ -11026,14 +11622,25 @@
11026
11622
  if (fontSize > VALUES_MIN_FONT_PX) {
11027
11623
  out.push({
11028
11624
  geometry: d.geometry, text, fontSize,
11029
- color: resolveTextColor(this.style, contrastTextColor(this.categoryColorsRgb[p.i])),
11030
- pixelOffset: [offsets[i].x * fitScale * pxPerCanvasUnit + alignOf(d)[0], offsets[i].y * fitScale * pxPerCanvasUnit + alignOf(d)[1]]
11625
+ color: resolveTextColor(this.style, contrastTextColor((this.categoryColorsRgb || [])[p.i])),
11626
+ pixelOffset: [offsets[i].x * fitScale * pxPerCanvasUnit + alignOf(d)[0], offsets[i].y * fitScale * pxPerCanvasUnit + alignOf(d)[1]],
11627
+ order: orderOf.get(d)
11031
11628
  });
11032
11629
  }
11033
11630
  });
11034
11631
  return out;
11035
11632
  }, []);
11036
11633
 
11634
+ // a text under later charts fades by what still shows through them
11635
+ // (chartTextTransmittance), as flat's per-chart SVG groups paint
11636
+ // each later symbol and glow over it; nearly hidden ones are dropped
11637
+ const visible = this._occludeChartTexts(combined, pointLabels.concat(groupLabels), {
11638
+ liveZoom, globeCenter, alignOf, radiusOf, sizeValueOf, singleSymbol, shapeOf, fillOpacity, vRadius,
11639
+ singleBorderWidthPx
11640
+ });
11641
+ const keepVisible = labels => labels.filter(l => visible.has(l));
11642
+ const shownPointLabels = keepVisible(pointLabels), shownGroupLabels = keepVisible(groupLabels);
11643
+
11037
11644
  const textLayerCommonProps = {
11038
11645
  pickable: false,
11039
11646
  getPosition: d => d.geometry.coordinates,
@@ -11043,11 +11650,61 @@
11043
11650
  sizeUnits: 'pixels',
11044
11651
  fontFamily: 'arial',
11045
11652
  fontWeight: 'bold',
11653
+ // the glyphs of the actual texts, not deck's ASCII default — a
11654
+ // unit like " €" (the fuel-price page) is outside it ("Missing
11655
+ // character: €" and no glyph)
11656
+ characterSet: 'auto',
11046
11657
  getTextAnchor: 'middle',
11047
11658
  getAlignmentBaseline: 'center'
11048
11659
  };
11049
- if (pointLabels.length) layers.push(new TextLayer({ id: `ix-points-values-${this.name}`, data: pointLabels, ...(alignOf.active ? { getPixelOffset: d => d.pixelOffset } : {}), ...textLayerCommonProps }));
11050
- if (groupLabels.length) layers.push(new TextLayer({ id: `ix-cluster-values-${this.name}`, data: groupLabels, getPixelOffset: d => d.pixelOffset, ...textLayerCommonProps }));
11660
+ // flat's SYMBOL branch writes its values in normal weight
11661
+ if (shownPointLabels.length) layers.push(new TextLayer({ id: `ix-points-values-${this.name}`, data: shownPointLabels, getPixelOffset: d => d.pixelOffset, ...textLayerCommonProps,
11662
+ fontWeight: shownPointLabels[0].bold === false ? 'normal' : 'bold' }));
11663
+ if (shownGroupLabels.length) layers.push(new TextLayer({ id: `ix-cluster-values-${this.name}`, data: shownGroupLabels, getPixelOffset: d => d.pixelOffset, ...textLayerCommonProps,
11664
+ fontWeight: shownGroupLabels[0].bold === false ? 'normal' : 'bold' }));
11665
+ }
11666
+
11667
+ // LABEL: flat's chart label (maptheme.js 24533-24590) — a small text
11668
+ // below-right of each chart, the .style({labelfield}) record value
11669
+ // (the fire page's labelfield ANNO: the fire year "2 014"), black at
11670
+ // 0.7 opacity on a white 0.4-opacity rounded background box, font
11671
+ // 5·unit·textscale. Gated by labelupper/labellower (flat's own test
11672
+ // compares <= against BOTH), suppressed by NORMSIZE/ZOOM, a numeric
11673
+ // label formatted like a value (valuedecimals) plus labelunits.
11674
+ if (labelShown) {
11675
+ const labelFontPx = 5 * unitPx * (styleNum(this.style.textscale) || 1);
11676
+ if (labelFontPx > VALUES_MIN_FONT_PX) {
11677
+ const labelData = [];
11678
+ for (const d of individual) {
11679
+ if (!d.properties || !d.properties.raw) continue;
11680
+ // _chartLabelFn: text, and its place below-right of the chart
11681
+ const l = labelOf(d, radiusOf(d));
11682
+ if (!l) continue;
11683
+ const o = alignOf(d);
11684
+ // drawn centered: flat's baseline sits 0.35 font below the
11685
+ // middle of its background box (-0.95 .. +0.25 font)
11686
+ labelData.push({ geometry: d.geometry, text: l.text, fontSize: l.fontSize,
11687
+ pixelOffset: [l.offset[0] + o[0], l.offset[1] - 0.35 * l.fontSize + o[1]] });
11688
+ }
11689
+ if (labelData.length) layers.push(new TextLayer({
11690
+ id: `ix-labels-${this.name}`,
11691
+ data: labelData, pickable: false,
11692
+ getPosition: d => d.geometry.coordinates,
11693
+ getText: d => d.text,
11694
+ getSize: d => d.fontSize,
11695
+ getColor: [0, 0, 0, Math.round(0.7 * 255)],
11696
+ sizeUnits: 'pixels',
11697
+ fontFamily: 'arial',
11698
+ characterSet: 'auto',
11699
+ getTextAnchor: 'middle',
11700
+ getAlignmentBaseline: 'center',
11701
+ getPixelOffset: d => d.pixelOffset,
11702
+ background: true,
11703
+ getBackgroundColor: [255, 255, 255, Math.round(0.4 * 255)],
11704
+ backgroundPadding: [labelFontPx * 0.25, labelFontPx * 0.1],
11705
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
11706
+ }));
11707
+ }
11051
11708
  }
11052
11709
 
11053
11710
  return layers;
@@ -11083,12 +11740,25 @@
11083
11740
  // - VALUES: each part's value (COUNT: its records; valuefield: the class name)
11084
11741
  // - align / offsets as the SYMBOL branch: half size the normal radius
11085
11742
  // A part's pick object carries its chart's properties (tooltip).
11743
+ // The same for a per-item chart over several value fields
11744
+ // (_isMultiFieldSequence): a part per field, max value the biggest
11745
+ // field value of any record (normalsizevalue overrides), the part
11746
+ // radius value-scaled as above; a colorscheme "none" part is only its
11747
+ // outline. RINGS nests the parts (ringsLayout); CENTERVALUES prints
11748
+ // only the first part's value (flat: nClass 0, maptheme.js 22216).
11086
11749
  _buildSequenceChartLayers(charts, zoom, liveZoom) {
11087
11750
  const st = this.style;
11088
- const seqMax = this._sequenceMaxValue(zoom);
11089
- const partRadius = v => valueRadius(v, liveZoom, st, this.mapOptions, this.flags, seqMax);
11751
+ const multi = !!this._multiFields && this._isMultiFieldSequence();
11752
+ const seqMax = multi ? (styleNum(st.normalsizevalue) || this._multiFieldPartMax) : this._sequenceMaxValue(zoom);
11090
11753
  const unit = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(st.scale) || 1);
11091
11754
  const normalR = NORMAL_RADIUS_PX * unit;
11755
+ const sizePow = resolveSizePow(st, this.flags);
11756
+ const partRadius = multi
11757
+ ? v => Math.max(VALUE_RADIUS_MIN, normalR * Math.pow(Math.abs(v) / seqMax, 1 / sizePow))
11758
+ : v => valueRadius(v, liveZoom, st, this.mapOptions, this.flags, seqMax);
11759
+ const rings = flatFlag(this.flags, 'RINGS');
11760
+ const centerValues = flatFlag(this.flags, 'CENTERVALUES');
11761
+ const noFill = this._partNoFill || new Set();
11092
11762
  const nCats = this.categoryLabels ? this.categoryLabels.length : 1;
11093
11763
  const marking = this._markedClasses.size && !this._onMarksChanged;
11094
11764
  const gray = marking && String(st.evidence || 'isolate') === 'isolate_gray';
@@ -11097,11 +11767,13 @@
11097
11767
  const borderRgb = st.linecolor && styleLineColor(st.linecolor) !== 'none' ? hexOrNamedToRgb(styleLineColor(st.linecolor)) : null;
11098
11768
  const lineWidth = styleNum(st.linewidth) || 1;
11099
11769
  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'; };
11770
+ const shapeOf = i => { const sh = symbols && (symbols[i] || symbols[0]); return normalizeSymbolShape(sh); };
11101
11771
  const colorOf = i => this.categoryColorsRgb[i] || [128, 128, 128];
11102
11772
  const parts = [], glows = [];
11103
11773
  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 });
11774
+ const counts = d.properties.parts || d.properties.counts;
11775
+ let layout = rings ? ringsLayout(counts, partRadius)
11776
+ : sequenceLayout(counts, this.flags, partRadius, { maxRadius: normalR, maxValue: seqMax, nParts: nCats, rangeScale: styleNum(st.rangescale) || 0 });
11105
11777
  if (!layout.length) continue;
11106
11778
  // GLOW belongs to the first part (sorted) or to every part, and
11107
11779
  // hides with it when its class is not marked
@@ -11155,7 +11827,7 @@
11155
11827
  data: parts, pickable: true,
11156
11828
  getPosition: d => d.geometry.coordinates,
11157
11829
  // 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,
11830
+ getIcon: d => this._buildSingleIcon(colorOf(d.part.i), noFill.has(d.part.i) ? 0 : fillOpacity, shapeOf(d.part.i), borderRgb,
11159
11831
  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
11832
  getSize: d => d.part.r * 2,
11161
11833
  getPixelOffset: offsetOf,
@@ -11168,20 +11840,22 @@
11168
11840
  // class name — formatted with valuedecimals (else none) plus the unit;
11169
11841
  // font min(1, 3.2 / L) · r · valuescale, L the length of the theme's
11170
11842
  // 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)) {
11843
+ // textcolor, else ChartColors.textColor of the part color.
11844
+ // CENTERVALUES: only on the first part (class 0), its own value
11845
+ if ((this.flags.has('VALUES') || centerValues) && !valuesHiddenByScale(st, zoom)) {
11173
11846
  const valueScale = styleNum(st.valuescale) || 1;
11174
11847
  const { noBreaks } = this._valueTextOpts();
11175
11848
  const units = st.units ? String(st.units) : '';
11176
11849
  const unit = units ? (units[0] === '.' ? '' : ' ') + units : '';
11177
11850
  const unitText = unit.length <= 5 ? unit : '';
11178
- const dec = styleNum(st.valuedecimals) || 0;
11851
+ const dec = explicitValueDecimals(st) || 0;
11179
11852
  const fmt = v => flatGroupedValue(v, dec, noBreaks) + unitText;
11180
11853
  const maxLen = fmt(seqMax).length;
11181
11854
  const textOverride = st.valuecolor || st.textcolor;
11182
11855
  const counting = flatFlag(this.flags, 'COUNT');
11183
11856
  const labels = [];
11184
11857
  parts.forEach(d => {
11858
+ if (centerValues && d.part.i !== 0) return;
11185
11859
  const rec = this._categoryValueRecord(d.part.i);
11186
11860
  const name = rec ? String(rec[st.valuefield]) : null;
11187
11861
  const t = counting && d.properties.recordCounts ? d.properties.recordCounts[d.part.i] : d.part.v;
@@ -11241,6 +11915,69 @@
11241
11915
  // the class name (the records' own valuefield value) — otherwise none
11242
11916
  _categoryValueRecord(i) { return categoryValueRecord(this.style, this.binding, this.flags, this.categoryLabels, i); }
11243
11917
 
11918
+ // A single chart's VALUES text: (item, its radius px) → { text,
11919
+ // fontSize, extent } — extent [x0, y0, x1, y1] in px around the chart
11920
+ // center (y down), as the box encloses it. The font by flat's branch:
11921
+ // - SYMBOL (maptheme.js 22200-22202, 21705-21708): min(1, 3.2 / L)
11922
+ // (hexagon 2.7) · r · valuescale, L the length of the theme's biggest
11923
+ // value text (szMaxText: the max value formatted, plus the unit up to
11924
+ // 5 characters, 21479) — normal weight, baseline 0.33 font below the
11925
+ // center (fire areas SIF chart: r 60, valuescale 3, "7 242 ha" → 72)
11926
+ // - BUBBLE: valuesFontSizePx, bold, centered
11927
+ _chartValueTextFn() {
11928
+ const valueScale = styleNum(this.style.valuescale) || 1;
11929
+ const textOpts = this._valueTextOpts();
11930
+ const symbol = flatChartBranch(this.flags) === 'symbol';
11931
+ let maxLen = 1;
11932
+ if (symbol) {
11933
+ const vMax = this._valueMax !== undefined ? this._valueMax : (this._valueTextRange || [])[1];
11934
+ if (isFinite(vMax)) maxLen = Math.max(1, flatValueText(null, null, vMax, this.style, this.flags, textOpts).length);
11935
+ }
11936
+ return (d, r) => {
11937
+ const p = d.properties;
11938
+ const text = flatValueText(p.raw, this.binding.title && p.raw ? p.raw[this.binding.title] : undefined,
11939
+ p.classValue !== undefined ? p.classValue : p.value, this.style, this.flags, textOpts);
11940
+ if (!symbol) {
11941
+ const fontSize = valuesFontSizePx(r, text, valueScale);
11942
+ const tw = titleTextWidth(text, fontSize);
11943
+ return { text, fontSize, bold: true, extent: [-tw / 2, -fontSize / 2, tw / 2, fontSize / 2] };
11944
+ }
11945
+ const k = this._resolveSymbolShape(p) === 'hexagon' ? 2.7 : 3.2;
11946
+ const fontSize = Math.min(1, k / maxLen) * r * valueScale;
11947
+ const tw = titleTextWidth(text, fontSize);
11948
+ return { text, fontSize, bold: false, baseline: 0.33 * fontSize,
11949
+ extent: [-tw / 2, (0.33 - 0.905) * fontSize, tw / 2, (0.33 + 0.212) * fontSize] };
11950
+ };
11951
+ }
11952
+
11953
+ // A single chart's LABEL (.style({labelfield}), maptheme.js 24533-24590):
11954
+ // (item, its radius px) → { text, fontSize, offset, extent } or null.
11955
+ // Font 5u · textscale; a number formatted like a value — valuedecimals
11956
+ // when set (flat's nValueDecimals is the raw style string, so "0"
11957
+ // counts), else 2 — plus labelunits. The background box is the wider of
11958
+ // the text and (length + 2) · font / 2, 1.2 font high from 0.95 font
11959
+ // above the baseline; the label sits at (width / 3, font / 3 + the
11960
+ // chart's anchor y: r + 5u). Fire areas page: "2 010" at (116.7, 193.3)
11961
+ // units, box 350 × 120.
11962
+ _chartLabelFn(unitPx) {
11963
+ const f = 5 * unitPx * (styleNum(this.style.textscale) || 1);
11964
+ const dec = explicitValueDecimals(this.style);
11965
+ const { noBreaks } = this._valueTextOpts();
11966
+ const units = String(this.style.labelunits || '');
11967
+ return (d, r) => {
11968
+ const raw = d.properties && d.properties.raw;
11969
+ const v = raw && raw[this.style.labelfield];
11970
+ let text = v == null || v === '' ? '' : String(v);
11971
+ if (!text) return null;
11972
+ const num = parseFloat(text.replace(',', '.'));
11973
+ text = (!isNaN(num) ? flatGroupedValue(num, dec != null ? dec : 2, noBreaks) : text) + units;
11974
+ const width = Math.max(titleTextWidth(text, f), (text.length + 2) * f * 0.5);
11975
+ const x = width / 3, y = f / 3 + r + 5 * unitPx;
11976
+ return { text, fontSize: f, offset: [x, y], width,
11977
+ extent: [x - width / 2, y - 0.95 * f, x + width / 2, y + 0.25 * f] };
11978
+ };
11979
+ }
11980
+
11244
11981
  // value-text options (flatValueText): flat's formatValue NOBREAKS for a
11245
11982
  // theme of values within 1000-3000, the max value for the decimals
11246
11983
  _valueTextOpts() {
@@ -11285,6 +12022,67 @@
11285
12022
  // the item color (stroke font/7, opacity 0.5).
11286
12023
  // Both moved by the chart position (flatChartAlignOffset). The text is
11287
12024
  // the hover target of TEXTONLY, the box of LABEL.
12025
+ // The charts of _buildChartLayers' bubbles layer (and its GLOW / AURA)
12026
+ // as chartTextTransmittance's occluders, in draw order and screen
12027
+ // pixels, and each text's resulting alpha: the text's color gets it,
12028
+ // the returned Set holds the texts still visible. Screen pixels:
12029
+ // Web Mercator world pixels at the live zoom; under globe a local
12030
+ // equal-area frame around the view center at the scale MapLibre's
12031
+ // globe matches Mercator's there (the limb's foreshortening ignored).
12032
+ _occludeChartTexts(combined, texts, o) {
12033
+ const visible = new Set();
12034
+ if (!texts.length) return visible;
12035
+ const toRad = Math.PI / 180;
12036
+ let screenOf;
12037
+ if (o.globeCenter) {
12038
+ const s = 512 * Math.pow(2, o.liveZoom) / 360 / Math.cos(o.globeCenter.lat * toRad);
12039
+ screenOf = c => [(((c[0] - o.globeCenter.lng) % 360 + 540) % 360 - 180) * Math.cos(c[1] * toRad) * s, -c[1] * s];
12040
+ } else {
12041
+ screenOf = c => { const p = lngLatToWorldPixel(c[0], c[1], o.liveZoom); return [p.x, p.y]; };
12042
+ }
12043
+ const glowOn = this.flags.has('GLOW') && !glowHiddenByScale(this.style, o.liveZoom);
12044
+ const auraOn = !glowOn && this.flags.has('AURA') && !this.flags.has('ZOOM');
12045
+ const symbols = Array.isArray(this.style.symbols) ? this.style.symbols : null;
12046
+ const charts = combined.map(d => {
12047
+ const [x, y] = screenOf(d.geometry.coordinates);
12048
+ const a = o.alignOf(d), r = o.radiusOf(d), iconAlpha = this._iconAlpha(d) / 255;
12049
+ let parts;
12050
+ if (o.singleSymbol(d)) {
12051
+ // the icon's shape is inset by half its border (_buildSingleIcon)
12052
+ const shape = o.shapeOf(d.properties);
12053
+ parts = [{ dx: 0, dy: 0, r: r * (1 - o.singleBorderWidthPx / BUBBLE_ICON_SIZE), shape, alpha: o.fillOpacity * iconAlpha }];
12054
+ } else {
12055
+ // _buildBubbleIcon's packed parts, its baked 0.9 alpha scaled by an
12056
+ // explicit fillopacity like the bubbles layer's getColor
12057
+ const counts = d.properties.counts, iso = this._groupIsolation(d);
12058
+ const { present, radii, offsets, fitScale } = (iso && isolatedBubblePackLayout(counts, BUBBLE_ICON_SIZE, iso)) || computeBubblePackLayout(counts, BUBBLE_ICON_SIZE);
12059
+ const px = 2 * r / BUBBLE_ICON_SIZE * fitScale;
12060
+ const alpha = 0.9 * (this.style.fillopacity != null ? o.fillOpacity : 1) * iconAlpha;
12061
+ parts = present.map((p, i) => ({ dx: offsets[i].x * px, dy: offsets[i].y * px, r: radii[i] * px,
12062
+ shape: symbols ? normalizeSymbolShape(symbols[Math.min(p.i, symbols.length - 1)]) : 'circle', alpha }));
12063
+ }
12064
+ if (auraOn) parts.push({ dx: 0, dy: 0, r: r * 1.3, shape: 'circle', alpha: 0.3 * iconAlpha });
12065
+ return {
12066
+ x: x + a[0], y: y + a[1], parts,
12067
+ glowR: glowOn ? o.vRadius(o.sizeValueOf(d)) * (d.properties.counts ? 9 : 11) / 2 : 0,
12068
+ glowAlpha: iconAlpha
12069
+ };
12070
+ });
12071
+ // the text box: Arial digits are ~0.56 em wide, ~0.7 em high
12072
+ const boxes = texts.map(t => {
12073
+ const [x, y] = screenOf(t.geometry.coordinates);
12074
+ return { order: t.order, x: x + t.pixelOffset[0], y: y + t.pixelOffset[1], w: 0.56 * t.fontSize * String(t.text).length, h: 0.7 * t.fontSize };
12075
+ });
12076
+ const shown = chartTextTransmittance(charts, boxes);
12077
+ texts.forEach((t, i) => {
12078
+ if (shown[i] < CHART_TEXT_MIN_TRANSMITTANCE) return;
12079
+ const c = t.color || [0, 0, 0];
12080
+ if (shown[i] < 1) t.color = [c[0], c[1], c[2], Math.round((c[3] != null ? c[3] : 255) * shown[i])];
12081
+ visible.add(t);
12082
+ });
12083
+ return visible;
12084
+ }
12085
+
11288
12086
  _buildLabelChartLayers(combined, zoom, liveZoom, sizeValueOf) {
11289
12087
  const textOnly = this.flags.has('TEXTONLY');
11290
12088
  const showValues = this.flags.has('VALUES') && !valuesHiddenByScale(this.style, zoom);
@@ -11431,14 +12229,28 @@
11431
12229
  // DOWN, confirmed against a real ported page's own comment ("il motore
11432
12230
  // disegna il triangolo con la punta in basso" — "the engine draws the
11433
12231
  // triangle with the tip at the bottom").
12232
+ // flat's symbol shapes (maptheme.js 21625-21680). Geometry follows flat
12233
+ // where GL didn't have an established shape already (square, diamond,
12234
+ // triangle keep GL's own proportions — pages already shipped with them):
12235
+ // roundrect — rect 1.6r, rounded 0.3r; label — wide flat rect 1.6r×0.8r;
12236
+ // carot — flat's alias of diamond; hexagon — vertices (±r,0),(±r/2,±r·sin60°);
12237
+ // cross — flat's 8-arm path (A=0.4r, B=0.8r); empty — the circle
12238
+ // geometry, but _buildSingleIcon gives it flat's transparent fill +
12239
+ // gray stroke (fill-opacity:0, stroke #888888)
11434
12240
  function drawSymbolPath(ctx, shape, cx, cy, r) {
11435
12241
  switch (shape) {
12242
+ // flat's per-part SYMBOL square (maptheme.js 22037): side 2r — with
12243
+ // GRIDSIZE the whole cell (the fuel-price grid, contiguous squares)
12244
+ case 'squarefull':
12245
+ ctx.rect(cx - r, cy - r, 2 * r, 2 * r);
12246
+ break;
11436
12247
  case 'square': {
11437
12248
  const s = r * Math.SQRT2;
11438
12249
  ctx.rect(cx - s / 2, cy - s / 2, s, s);
11439
12250
  break;
11440
12251
  }
11441
12252
  case 'diamond':
12253
+ case 'carot': // flat: carot and diamond are the same rotated rect
11442
12254
  ctx.moveTo(cx, cy - r);
11443
12255
  ctx.lineTo(cx + r, cy);
11444
12256
  ctx.lineTo(cx, cy + r);
@@ -11449,10 +12261,58 @@
11449
12261
  ctx.lineTo(cx + r, cy - r * 0.6);
11450
12262
  ctx.lineTo(cx, cy + r);
11451
12263
  break;
12264
+ case 'roundrect': {
12265
+ const s = r * 0.8;
12266
+ if (ctx.roundRect) ctx.roundRect(cx - s, cy - s, s * 2, s * 2, r * 0.3);
12267
+ else ctx.rect(cx - s, cy - s, s * 2, s * 2);
12268
+ break;
12269
+ }
12270
+ case 'label': {
12271
+ const w = r * 0.8, h = r * 0.4;
12272
+ ctx.rect(cx - w, cy - h, w * 2, h * 2);
12273
+ break;
12274
+ }
12275
+ case 'hexagon': {
12276
+ const A = r / 2, B = r * Math.sin(Math.PI / 3);
12277
+ ctx.moveTo(cx - r, cy);
12278
+ ctx.lineTo(cx - A, cy - B);
12279
+ ctx.lineTo(cx + A, cy - B);
12280
+ ctx.lineTo(cx + r, cy);
12281
+ ctx.lineTo(cx + A, cy + B);
12282
+ ctx.lineTo(cx - A, cy + B);
12283
+ break;
12284
+ }
12285
+ case 'cross': {
12286
+ // flat's own path, vertex for vertex: a plus of arm thickness A
12287
+ // reaching to ±(B + A/2) (maptheme.js 21652-21655)
12288
+ const A = r * 0.4, B = r * 0.8, E = B + A / 2;
12289
+ ctx.moveTo(cx, cy - E);
12290
+ ctx.lineTo(cx + A / 2, cy - E);
12291
+ ctx.lineTo(cx + A / 2, cy - A / 2);
12292
+ ctx.lineTo(cx + E, cy - A / 2);
12293
+ ctx.lineTo(cx + E, cy + A / 2);
12294
+ ctx.lineTo(cx + A / 2, cy + A / 2);
12295
+ ctx.lineTo(cx + A / 2, cy + E);
12296
+ ctx.lineTo(cx - A / 2, cy + E);
12297
+ ctx.lineTo(cx - A / 2, cy + A / 2);
12298
+ ctx.lineTo(cx - E, cy + A / 2);
12299
+ ctx.lineTo(cx - E, cy - A / 2);
12300
+ ctx.lineTo(cx - A / 2, cy - A / 2);
12301
+ ctx.lineTo(cx - A / 2, cy - E);
12302
+ break;
12303
+ }
12304
+ case 'empty':
11452
12305
  default: // circle
11453
12306
  ctx.arc(cx, cy, r, 0, Math.PI * 2);
11454
12307
  }
11455
12308
  }
12309
+ // the shape names flat's szSymbol switch knows (maptheme.js 21625) —
12310
+ // anything else falls back to circle, never silently dropped
12311
+ const SYMBOL_SHAPE_RE = /^(circle|square|diamond|triangle|roundrect|carot|hexagon|cross|empty|label)$/;
12312
+ function normalizeSymbolShape(name) {
12313
+ const n = String(name || '').toLowerCase();
12314
+ return SYMBOL_SHAPE_RE.test(n) ? n : 'circle';
12315
+ }
11456
12316
 
11457
12317
  // a color's own opacity, 0..1, as SVG paints it in flat: "none" (SVG's
11458
12318
  // no paint — a polygon with only its outline, or a class not painted)
@@ -11586,7 +12446,7 @@
11586
12446
  // map named by szMap (its Map() options name) or, without a name, the
11587
12447
  // last built map; called while the map is still building, they run once
11588
12448
  // it is ready.
11589
- const MAP_HANDLE_METHODS = ['replace', 'add', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
12449
+ const MAP_HANDLE_METHODS = ['replace', 'add', 'newTheme', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
11590
12450
  'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'setProjection', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible', 'setThemeTimeFrame', 'attribution'];
11591
12451
  const _mapHandles = Object.create(null);
11592
12452
  function makeMapHandle(szMap) {
@@ -12251,6 +13111,8 @@
12251
13111
  htmlgui_getAttributionString: () => (_lastMapApi && _lastMapApi.getAttribution ? _lastMapApi.getAttribution() : ''),
12252
13112
  changeThemeStyle: compatChangeThemeStyle,
12253
13113
  removeTheme: compatRemoveTheme,
13114
+ // flat's ixmaps.newTheme(title, theme, flag) — on the last built map
13115
+ newTheme: (title, theme, flag) => _mapHandle.newTheme(title, theme, flag),
12254
13116
  // flat's time slider: show the records of a theme (null = all themes) whose timefield lies in [min, max)
12255
13117
  setThemeTimeFrame: (szId, nMin, nMax) => _mapHandle.setThemeTimeFrame(szId, nMin, nMax),
12256
13118
  setBasemapOpacity: compatSetBasemapOpacity,
@@ -12277,7 +13139,7 @@
12277
13139
  // test-only: lets test/unit/*.test.mjs call pure internals directly (the
12278
13140
  // engine runs in a Node vm there); deliberately NOT on the ixmaps object
12279
13141
  global.__ixmapsGlInternals = {
12280
- IXMAPS_GL_VERSION, youtubeClickToPlay, symbolsFlagCompat, tooltipTable, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout, multiQuadOffsets, pixelOffsetLngLat,
13142
+ IXMAPS_GL_VERSION, youtubeClickToPlay, symbolsFlagCompat, tooltipTable, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, chartTextTransmittance, glowProfileAlpha, insideSymbolShape, sequenceLayout, ringsLayout, multiQuadOffsets, pixelOffsetLngLat,
12281
13143
  normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, MapBuilder, typeStyleNumbers, styleNum,
12282
13144
  resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
12283
13145
  equalIntervalBreaks, quantileBreaks, naturalBreaks, evenStrideSample, jenksBreakValues, partsFromBreakValues, resolvePartsClass,
@@ -12287,7 +13149,8 @@
12287
13149
  flatToMapLibreZoom, mapLibreToFlatZoom, scaleDenominatorAt, resolveZoomReference, resolveAggregationPx, valuesHiddenByScale,
12288
13150
  fetchLayerData, parseCsvText, dataTableRows, geometryRowsToFeatureCollection, filterFlatValues, applyField100, field100Binding, rangeClassLegendTotals, resolveAggregateValue, classValueSeparate, cellAggregatedValues, oneHot, groupCoLocated, aggregateOnGrid, GridAggregateIndex, flatRangeParts, aggregateField100, valueRadius, itemPlotGeometry, itemAnchor, objectZoomFactor, resolveZoomReference, defaultNormalSizeValue,
12289
13151
  applyWhereFilter, joinChartPositions, flatLookupKey, flatShapeCenter, chartHiddenByScale, glowHiddenByScale, isSymbolChart, flatChartAlignOffset, flatChartBranch, flatValueText, categoryValueRecord, flatDerivateRgb, flatChartTextRgb, flatGroupedValue, flatNoBreaks, featuresHiddenByScale, boxHiddenByScale, flatShadowOn, snapToAggregationGrid,
12290
- flatFormatValue, themeStyleArgs,
13152
+ drawSymbolPath, normalizeSymbolShape,
13153
+ flatFormatValue, themeStyleArgs, flatChangedStyleValue, explicitValueDecimals, legendDecimals,
12291
13154
  dataCacheKey, dataCacheDisabled, cachedLayerData, featuresBounds, wantsZoomToExtent, legendIsOn,
12292
13155
  };
12293
13156
  global.__setFilter = __setFilter;