ixmaps-gl 0.2.15 → 0.2.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +13 -3
  2. package/ixmaps-gl.js +807 -227
  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.15';
85
+ const IXMAPS_GL_VERSION = '0.2.17';
86
86
 
87
87
  const LIB_URLS = {
88
88
  // Bumped 3.6.2 -> 5.x (2026-09-21, globe-projection compat fix): native
@@ -716,6 +716,9 @@
716
716
  // another theme's data: .data({name})). See loadNamedTheme in build().
717
717
  const _namedDataWaiters = new Map(); // name → Set of resolve(table)
718
718
  const _namedDataListeners = new Map(); // name → Set of fn(table)
719
+ // names whose next publications feed only the theme that asked for them
720
+ // (a FEATURE's dependent broker refresh, see featureDependents) → count
721
+ const _quietNamedData = new Map();
719
722
  function namedTable(name) {
720
723
  try { const t = global[name]; return t && typeof t === 'object' && typeof t.json === 'function' ? t : null; } catch (e) { return null; }
721
724
  }
@@ -744,6 +747,7 @@
744
747
  const waiters = _namedDataWaiters.get(name);
745
748
  if (waiters) [...waiters].forEach(resolve => resolve(table));
746
749
  const listeners = _namedDataListeners.get(name);
750
+ if (_quietNamedData.get(name)) return;
747
751
  // after the handing-over code finished (a broker's own theme first)
748
752
  if (listeners && listeners.size && table) setTimeout(() => [...listeners].forEach(fn => fn(table)), 0);
749
753
  }
@@ -1411,7 +1415,7 @@
1411
1415
  const key = String(id), area = geometryArea(f.geometry);
1412
1416
  if (!flatShapeWins(best.get(key), area)) continue;
1413
1417
  const pos = flatShapeCenter(f.geometry);
1414
- if (pos) best.set(key, { area, pos });
1418
+ if (pos) best.set(key, { area, pos, areaKm2: flatShapeAreaKm2(rt, f) });
1415
1419
  }
1416
1420
  }
1417
1421
  rt._positionIndex = best;
@@ -1445,7 +1449,11 @@
1445
1449
  const v = row[lookupField];
1446
1450
  if (v == null || v === '') continue;
1447
1451
  const hit = index.get(flatLookupKey(v, spec.style));
1448
- if (hit) features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: hit.pos }, properties: row });
1452
+ // the joined feature is a POINT — DENSITY (value per km² of the
1453
+ // joined shape, as flat) needs the shape's km² area carried along
1454
+ // (featureAreaKm2 prefers this marker), exactly as the choropleth
1455
+ // join stamps it
1456
+ if (hit) features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: hit.pos }, properties: row, _flatAreaKm2: hit.areaKm2 });
1449
1457
  }
1450
1458
  }
1451
1459
  return { type: 'FeatureCollection', features };
@@ -1852,12 +1860,22 @@
1852
1860
  if (typeof colorscheme === 'string') {
1853
1861
  try {
1854
1862
  const fn = new Function('return (' + colorscheme + ')')();
1855
- const objTheme = { szLabelA: labels, colorScheme: new Array(labels.length) };
1856
- fn(objTheme);
1857
- return objTheme.colorScheme;
1858
- } catch (err) {
1859
- console.warn('[ixmaps-gl] colorscheme function failed to evaluate, using fallback palette:', err);
1860
- }
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');
1861
1879
  }
1862
1880
  return labels.map((_, i) => FALLBACK_PALETTE[i % FALLBACK_PALETTE.length]);
1863
1881
  }
@@ -1910,6 +1928,11 @@
1910
1928
  // _prepareDominant) doing its own `.map(hexToRgb)` that would break on
1911
1929
  // the diverging sweep's own output.
1912
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;
1913
1936
  const resolved = resolveColorScheme(colorscheme, labels, classes);
1914
1937
  return (resolved || labels.map((_, i) => FALLBACK_PALETTE[i % FALLBACK_PALETTE.length]))
1915
1938
  .map(c => Array.isArray(c) ? c : (parseCssColor(c) || [130, 130, 130]));
@@ -2010,16 +2033,57 @@
2010
2033
  // the user chart's size unit: flat's normalX(15) = 300 is the chart's
2011
2034
  // normal radius (maxSize), shown at gl's normal bubble radius
2012
2035
  const USER_CHART_MAX_SIZE = 300;
2013
- // raster sizes (px) of a user chart's image — see _userChartIcon
2014
- const USER_CHART_RASTER = [16, 24, 32, 48, 64, 96, 128, 192, 256];
2036
+ // ---- the pane USER charts render into: one per map, live over the canvas ----
2037
+ // flat draws its user charts as LIVE SVG over the map — vector-crisp at
2038
+ // every zoom, labels as real text. The first port here rasterized every
2039
+ // chart into one texture atlas instead, and a page with thousands of
2040
+ // charts got ~50 px per chart (the atlas area is fixed) — icons scaled
2041
+ // up several times over: blur, mushy gradients, unreadable labels. In
2042
+ // this pane each chart is a <g> mounted once; a map 'move' only updates
2043
+ // its transform (translate + scale), which the browser composites —
2044
+ // the same thing flat's own engine does over Leaflet, at the same item
2045
+ // counts. Keyed by the map's options object, so runtimes defined later
2046
+ // (loadProject, post-build map.layer) find it too.
2047
+ const _chartHosts = new WeakMap();
2048
+ function chartHostFor(mapOptions, map, el) {
2049
+ let host = _chartHosts.get(mapOptions);
2050
+ if (host) return host;
2051
+ const pane = document.createElementNS(SVG_NS, 'svg');
2052
+ pane.setAttribute('class', 'ix-user-charts');
2053
+ pane.style.cssText = 'position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none;overflow:visible;z-index:3;';
2054
+ const defs = document.createElementNS(SVG_NS, 'defs');
2055
+ pane.appendChild(defs);
2056
+ el.appendChild(pane);
2057
+ host = { pane, defs, map, el, runtimes: new Set(), raf: 0 };
2058
+ host.requestUpdate = () => {
2059
+ if (host.raf) return;
2060
+ host.raf = global.requestAnimationFrame(() => { host.raf = 0; host.update(); });
2061
+ };
2062
+ // transform-only per move: positions follow the map live; the charts'
2063
+ // own markup is redrawn on the settled-refresh cadence (freezeOnPan)
2064
+ host.update = () => {
2065
+ const w = el.clientWidth, h = el.clientHeight, margin = 256;
2066
+ for (const rt of host.runtimes) {
2067
+ const items = rt._chartItems;
2068
+ if (!items) continue;
2069
+ for (const it of items) {
2070
+ const p = map.project(it.coords);
2071
+ const off = p.x < -margin || p.y < -margin || p.x > w + margin || p.y > h + margin;
2072
+ if (off) { if (it.el.style.display !== 'none') it.el.style.display = 'none'; continue; }
2073
+ if (it.el.style.display === 'none') it.el.style.display = '';
2074
+ it.el.setAttribute('transform', `translate(${p.x} ${p.y}) scale(${it.s}) translate(${-it.ox} ${-it.oy})`);
2075
+ }
2076
+ }
2077
+ };
2078
+ map.on('move', host.requestUpdate);
2079
+ map.on('resize', host.requestUpdate);
2080
+ _chartHosts.set(mapOptions, host);
2081
+ return host;
2082
+ }
2015
2083
  // ?ixgl-debug in the page URL: ixmaps-gl logs what it draws (console.info)
2016
2084
  function glDebug() {
2017
2085
  try { return new URLSearchParams(global.location.search).has('ixgl-debug'); } catch (e) { return false; }
2018
2086
  }
2019
- // the pixel area all of a layer's chart images may take together: deck.gl
2020
- // packs them into one texture (1024 px wide), which must stay well below
2021
- // the GPU's texture size limit — see _userChartIcon
2022
- const USER_CHART_ATLAS_AREA = 1024 * 8192;
2023
2087
  class LayerBuilder {
2024
2088
  constructor(name) {
2025
2089
  this.name = name;
@@ -2114,11 +2178,24 @@
2114
2178
  define() { return this; }
2115
2179
  }
2116
2180
 
2181
+ // flat-era pages keep the layer construct and CALL it like a function
2182
+ // when handing it back to the map later (censimenti's sidebar:
2183
+ // map().add(__theme(),'clear')) — the call returns the builder; pages also
2184
+ // chain builder methods right on the factory result, so the construct
2185
+ // delegates every one of them (chaining stays on the construct). Every
2186
+ // consumption point unwraps __ixLayer, so the object form keeps working.
2117
2187
  function layer(name, configFn) {
2118
2188
  const builder = new LayerBuilder(name);
2119
2189
  if (configFn) configFn(builder);
2120
- return builder;
2190
+ const construct = function () { return builder; };
2191
+ construct.__ixLayer = builder;
2192
+ for (const k of Object.getOwnPropertyNames(LayerBuilder.prototype)) {
2193
+ if (k === 'constructor') continue;
2194
+ construct[k] = function (...args) { const r = builder[k](...args); return r === builder ? construct : r; };
2195
+ }
2196
+ return construct;
2121
2197
  }
2198
+ const unwrapLayer = x => (typeof x === 'function' && x.__ixLayer) ? x.__ixLayer : x;
2122
2199
 
2123
2200
  // ---------------------------------------------------------------
2124
2201
  // Binding aliases. Real ixmaps-flat accepts ~50 .binding() keys and maps
@@ -2373,7 +2450,10 @@
2373
2450
  // (flat stores most of these as given and lets arithmetic coerce them;
2374
2451
  // its runtime changeThemeStyle applies Number() — the same result for
2375
2452
  // every clean value.) A linewidth list is typed element by element.
2376
- 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',
2377
2457
  'sizepow', 'rangescale', 'minvalue', 'maxvalue', 'markersize', 'boxopacity', 'outlierscale', 'valuescale',
2378
2458
  'brightness', 'fractionscale', 'dopacityscale', 'dopacitypow', 'gridwidthpx', 'textscale', 'rangecentervalue',
2379
2459
  'shadowblur', 'shadowdx', 'shadowdy', 'maxshadow', 'offsetx', 'offsety', 'gridx', 'boxmargin', 'borderwidth',
@@ -2531,6 +2611,16 @@
2531
2611
  return String(text).split(' ').map(w => (dict["'" + w + "'"] !== undefined ? String(dict["'" + w + "'"]) : w)).join(' ');
2532
2612
  };
2533
2613
  }
2614
+ // make the map container the positioning context of what the engine
2615
+ // mounts inside it (splash, pinned tooltip, legends) — only when it is
2616
+ // unpositioned. The RENDERED position decides, never el.style (inline
2617
+ // only): a page's #map_div { position:absolute; top:0; bottom:0 } from a
2618
+ // stylesheet reads as "" there, and overwriting it with relative drops
2619
+ // the top/bottom stretch — the div's height collapses to 0, a blank map
2620
+ // (accidents_app.html from 0.2.15 on)
2621
+ function anchorInside(el) {
2622
+ if (el && getComputedStyle(el).position === 'static') el.style.position = 'relative';
2623
+ }
2534
2624
  function showSplash(el, text) {
2535
2625
  ensureSplashStyle();
2536
2626
  // Checks the actual RENDERED position, not el.style (the inline
@@ -2544,7 +2634,7 @@
2544
2634
  // height silently collapsed to 0 — invisible map, invisible splash,
2545
2635
  // both zero-height. Only truly static (unpositioned) elements need
2546
2636
  // this at all.
2547
- if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
2637
+ anchorInside(el);
2548
2638
  const splash = document.createElement('div');
2549
2639
  splash.className = 'ixmaps-splash';
2550
2640
  splash.innerHTML = '<div class="ixmaps-splash-mark"><span class="ixmaps-splash-ping"></span><span class="ixmaps-splash-dot"></span></div>' +
@@ -2875,8 +2965,20 @@
2875
2965
  if (__hoverTooltipWatch || !el.parentElement) return;
2876
2966
  const host = el.parentElement;
2877
2967
  const attach = (tipEl) => {
2968
+ // deck nests its tooltip inside its widget root (.fill, z-index 2),
2969
+ // a stacking context under the USER-chart pane (z 3) — the tooltip's
2970
+ // own z-index can never cross that. Moved out to the map container,
2971
+ // a sibling of the pane, where z-index 7 puts it above everything;
2972
+ // same coordinate origin, deck keeps updating the same node.
2973
+ // Re-asserted on every style write (deck sets z-index 1 itself).
2974
+ const liftTooltip = () => {
2975
+ if (tipEl.parentNode !== el) el.appendChild(tipEl);
2976
+ if (tipEl.style.zIndex !== '7') tipEl.style.zIndex = 7;
2977
+ };
2978
+ liftTooltip();
2878
2979
  let lastSetTransform = null;
2879
2980
  const mo = new MutationObserver(() => {
2981
+ liftTooltip();
2880
2982
  if (tipEl.style.display === 'none' || !tipEl.textContent.trim()) return;
2881
2983
  const t = tipEl.style.transform;
2882
2984
  if (!t || t === lastSetTransform) return;
@@ -2976,7 +3078,10 @@
2976
3078
  // touches the tooltip DOM in the same tick.
2977
3079
  function hideDefaultHoverTooltip() {
2978
3080
  requestAnimationFrame(() => {
2979
- const defaultTooltipEl = el.parentElement && el.parentElement.querySelector('.deck-tooltip');
3081
+ // the hover tooltip is reparented into el (see watchHoverTooltip) —
3082
+ // look there first, then where deck originally nested it
3083
+ const defaultTooltipEl = (el && el.querySelector('.deck-tooltip')) ||
3084
+ (el.parentElement && el.parentElement.querySelector('.deck-tooltip'));
2980
3085
  if (defaultTooltipEl) defaultTooltipEl.style.display = 'none';
2981
3086
  });
2982
3087
  }
@@ -3071,7 +3176,7 @@
3071
3176
  // relative pixels, and styling the page's own body (relative) breaks
3072
3177
  // any page element absolutely positioned against it (AirBnB's
3073
3178
  // #onmap-buttons on a zero-height all-float body flew off-screen)
3074
- el.style.position = el.style.position || 'relative';
3179
+ anchorInside(el);
3075
3180
  el.appendChild(pinnedTooltipEl);
3076
3181
  }
3077
3182
  return { getTooltip, onClick, mount };
@@ -3085,6 +3190,33 @@
3085
3190
  // calls: addLegendPanel(rt), setLegendOption(value), updateSubTheme(rt),
3086
3191
  // layoutLegends().
3087
3192
  // ---------------------------------------------------------------
3193
+ // The map-level `align` option positions the legend — BOTH the native
3194
+ // panel stack (createLegend) and a page legend (map.legend(html), the
3195
+ // legendEl built in build()): one helper, so they can never disagree.
3196
+ // gl's four corners, else flat's own values (htmlgui_flat.js 421-470):
3197
+ // "…left" → left (the part before "left", else 55px), 12px from the top;
3198
+ // "…right" → right (else 25px); "center" / "top" → top center.
3199
+ // Default "top-right" per explicit request (real engine's own fallback is
3200
+ // right-anchored too, just without an opinion on vertical placement).
3201
+ function legendAlignCssFor(mapOptions) {
3202
+ const ALIGN_CSS = {
3203
+ 'top-left': 'left:10px;top:10px;',
3204
+ 'top-right': 'right:10px;top:10px;',
3205
+ 'bottom-left': 'left:10px;bottom:10px;',
3206
+ 'bottom-right': 'right:10px;bottom:10px;'
3207
+ };
3208
+ const flatAlignCss = align => {
3209
+ const a = String(align || '');
3210
+ // flat hands these to jQuery .css(), which adds px to a unitless
3211
+ // number — a raw cssText needs it explicit
3212
+ const px = v => /^\d+(\.\d+)?$/.test(v) ? v + 'px' : v;
3213
+ if (/left/.test(a)) return 'left:' + px(a.split('left')[0] || '55px') + ';top:12px;';
3214
+ if (/right/.test(a)) return 'right:' + px(a.split('right')[0] || '25px') + ';top:10px;';
3215
+ if (a === 'center' || a === 'top') return 'left:50%;transform:translateX(-50%);top:10px;';
3216
+ return null;
3217
+ };
3218
+ return ALIGN_CSS[mapOptions.align] || flatAlignCss(mapOptions.align) || ALIGN_CSS['top-right'];
3219
+ }
3088
3220
  function createLegend(ctx) {
3089
3221
  const { builder, map, el, overlay, runtimes, engineApi, built, refresh, scheduleRefresh, notifyRedraw, quietRefresh } = ctx;
3090
3222
  let addLegendPanel = () => {};
@@ -3128,6 +3260,10 @@
3128
3260
  const legendOpt = builder.mapOptions.legend;
3129
3261
  // let, not const: map.setLegend(value) switches them at runtime (see setLegendOption)
3130
3262
  let legendOn = legendIsOn(legendOpt);
3263
+ // flat: a page legend (map.legend(html) → ixmaps.legend.url) REPLACES
3264
+ // the theme-generated legend — legend.js 2076 renders the layerlist
3265
+ // legend instead (useLayerListLegend) — so the native panels stay off
3266
+ if (builder._legendHtml) legendOn = false;
3131
3267
  let legendFolded = legendOpt === 'closed';
3132
3268
  {
3133
3269
  // Map-level `align` option positions the legend panel — a map-
@@ -3146,24 +3282,7 @@
3146
3282
  // shape choice earlier this session. Default "top-right" per
3147
3283
  // explicit request (real engine's own fallback is right-anchored
3148
3284
  // too, just without an opinion on vertical placement).
3149
- const ALIGN_CSS = {
3150
- 'top-left': 'left:10px;top:10px;',
3151
- 'top-right': 'right:10px;top:10px;',
3152
- 'bottom-left': 'left:10px;bottom:10px;',
3153
- 'bottom-right': 'right:10px;bottom:10px;'
3154
- };
3155
- // gl's four corners, else flat's own values (htmlgui_flat.js
3156
- // 421-470): "…left" → left (the part before "left", else 55px), 12px
3157
- // from the top; "…right" → right (else 25px); "center" / "top" → top
3158
- // center
3159
- const flatAlignCss = align => {
3160
- const a = String(align || '');
3161
- if (/left/.test(a)) return 'left:' + (a.split('left')[0] || '55px') + ';top:12px;';
3162
- if (/right/.test(a)) return 'right:' + (a.split('right')[0] || '25px') + ';top:10px;';
3163
- if (a === 'center' || a === 'top') return 'left:50%;transform:translateX(-50%);top:10px;';
3164
- return null;
3165
- };
3166
- const legendAlignCss = ALIGN_CSS[builder.mapOptions.align] || flatAlignCss(builder.mapOptions.align) || ALIGN_CSS['top-right'];
3285
+ const legendAlignCss = legendAlignCssFor(builder.mapOptions);
3167
3286
  // Real ixmaps-flat's SUB-THEME (MapTheme.createSubTheme,
3168
3287
  // maptheme.js 14284-14292 / 15010-15160; map.Themes.enableSubThemes
3169
3288
  // defaults to true): marking fields of a multi-field choropleth
@@ -3254,7 +3373,7 @@
3254
3373
  // panel's max-height resolves against the MAP's height — the stack's
3255
3374
  // former parent anchor collapsed it to 0 on pages whose body has
3256
3375
  // only floated children (height 0)
3257
- el.style.position = el.style.position || 'relative';
3376
+ anchorInside(el);
3258
3377
  el.appendChild(legendStack);
3259
3378
  return legendStack;
3260
3379
  };
@@ -3265,7 +3384,7 @@
3265
3384
  setLegendOption = (opt) => {
3266
3385
  builder.mapOptions.legend = opt;
3267
3386
  legendFolded = opt === 'closed';
3268
- legendOn = legendIsOn(opt);
3387
+ legendOn = builder._legendHtml ? false : legendIsOn(opt);
3269
3388
  runtimes.forEach(rt => {
3270
3389
  if (!legendOn) {
3271
3390
  if (rt._legendPanel && rt._legendPanel.parentNode) rt._legendPanel.parentNode.removeChild(rt._legendPanel);
@@ -3300,7 +3419,7 @@
3300
3419
  rt._triggerRedraw = () => { refresh(); };
3301
3420
  if (subThemeCapable(rt)) rt._onMarksChanged = () => updateSubTheme(rt);
3302
3421
  if (!legendOn || !el.parentElement || !legendApplies(rt)) return;
3303
- el.style.position = el.style.position || 'relative';
3422
+ anchorInside(el);
3304
3423
  {
3305
3424
  // .type("...|NOLEGEND") — the fourth real legend-related type()
3306
3425
  // token: opts a theme OUT of the legend entirely (real engine's
@@ -3385,6 +3504,17 @@
3385
3504
  // themselves authored with their own leading space (e.g.
3386
3505
  // " MW") in real pages.
3387
3506
  const legendUnit = rt.style.legendunits || rt.style.units || '';
3507
+ // a per-item chart over several value fields: the row values as
3508
+ // flat's legend prints them (legend.js 610, 810: __formatValue
3509
+ // with valuedecimals, else 2, "BLANK" — whole numbers without
3510
+ // decimals), a colorscheme "none" part as an outline in linecolor
3511
+ const multiFieldLegend = !!rt._multiFields && rt._isMultiFieldSequence();
3512
+ const legendTotalText = v => (multiFieldLegend
3513
+ ? flatFormatValue(v, legendDecimals(rt.style), 'BLANK')
3514
+ : rt._formatTooltipValue(v));
3515
+ const outlineRgb = rt.style.linecolor && styleLineColor(rt.style.linecolor) !== 'none' ? hexOrNamedToRgb(styleLineColor(rt.style.linecolor)) : [128, 128, 128];
3516
+ const legendFill = i => (multiFieldLegend && rt._partNoFill && rt._partNoFill.has(i)
3517
+ ? `transparent;box-shadow:inset 0 0 0 1px rgb(${outlineRgb.join(',')})` : null);
3388
3518
  // row labels go into innerHTML: the page's own .style({label})
3389
3519
  // stays HTML, as in flat; categories read from the data are
3390
3520
  // escaped (a value could carry markup)
@@ -3426,6 +3556,22 @@
3426
3556
  const globeCenter = proj.type === 'globe' ? map.getCenter() : null;
3427
3557
  totals = new Array(rt.categoryLabels.length).fill(0);
3428
3558
  if (rt._rangeClassed) { totals = rangeClassLegendTotals(rt, bbox, globeCenter); maxTotal = Math.max(0, ...totals); order = totals.map((v, i) => i).sort((a, b) => totals[b] - totals[a]); return; }
3559
+ // a per-item chart over several value fields: a row per field,
3560
+ // the field's sum over the visible items (flat's NYC status
3561
+ // legend: bikes / empty docks / e-bikes, biggest sum first)
3562
+ if (rt._multiFields && rt._isMultiFieldSequence()) {
3563
+ (rt._activeFeatures || rt.features).forEach(f => {
3564
+ const c = f.geometry && f.geometry.coordinates;
3565
+ if (!c || typeof c[0] !== 'number') return;
3566
+ const [lng, lat] = c;
3567
+ if (lng < bbox[0] || lng > bbox[2] || lat < bbox[1] || lat > bbox[3]) return;
3568
+ if (globeCenter && !isOnVisibleHemisphere(lng, lat, globeCenter)) return;
3569
+ rt._multiFields.forEach((k, i) => { totals[i] += parseFloat(f.properties[k]) || 0; });
3570
+ });
3571
+ maxTotal = Math.max(0, ...totals);
3572
+ order = totals.map((v, i) => i).sort((a, b) => totals[b] - totals[a]);
3573
+ return;
3574
+ }
3429
3575
  // _activeFeatures (set by setFacetFilter/clearFacetFilter,
3430
3576
  // e.g. the country-select dropdown below) is the facet-
3431
3577
  // filtered subset when a filter is active, null otherwise —
@@ -3558,7 +3704,7 @@
3558
3704
  for (let line = 0; line < nLines; line++) {
3559
3705
  for (let c = 0; c < n; c++) {
3560
3706
  const ix = flatFlag(rt.flags, 'INVERT') ? n - c - 1 : c;
3561
- const rgb = rt.categoryColorsRgb[ix];
3707
+ const rgb = rt.categoryColorsRgb[ix] || [128, 128, 128]; // colorscheme "none": no class colors
3562
3708
  const part = rt.partsA[ix];
3563
3709
  const title = part ? flatFormatValue(part.min, dec, '') + unit + ' ... ' + flatFormatValue(part.max, dec, '') + unit : '';
3564
3710
  const marked = rt._markedClasses.has(ix);
@@ -3589,8 +3735,8 @@
3589
3735
  // flat drops the rows without a count once any row has one
3590
3736
  // (legend.js 754-760, 787-789: fCountBars && !count)
3591
3737
  rowsEl.innerHTML = order.filter(i => !(maxTotal > 0) || totals[i]).map(i => {
3592
- const rgb = rt.categoryColorsRgb[i];
3593
- const color = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
3738
+ const rgb = rt.categoryColorsRgb[i] || [128, 128, 128]; // colorscheme "none": no class colors
3739
+ const color = legendFill(i) || `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
3594
3740
  const marked = rt._markedClasses.has(i);
3595
3741
  const dimmed = rt._markedClasses.size > 0 && !marked;
3596
3742
  const rowStyle = 'padding:4px 3px;border-radius:4px;cursor:pointer;opacity:' + (dimmed ? 0.4 : 1) + ';'
@@ -3617,7 +3763,7 @@
3617
3763
  + '<div style="display:flex;align-items:center;gap:6px;">'
3618
3764
  + '<span style="flex:0 0 8px;width:8px;height:8px;border-radius:50%;background:' + color + ';"></span>'
3619
3765
  + '<span style="flex:0 0 auto;width:' + barPx + 'px;height:6px;background:' + color + ';border-radius:3px;"></span>'
3620
- + '<span style="flex:0 0 auto;white-space:nowrap;">' + rt._formatTooltipValue(totals[i]) + legendUnit + '</span>'
3766
+ + '<span style="flex:0 0 auto;white-space:nowrap;">' + legendTotalText(totals[i]) + legendUnit + '</span>'
3621
3767
  + '</div>'
3622
3768
  + '</div>';
3623
3769
  }
@@ -3628,7 +3774,7 @@
3628
3774
  + '<span style="flex:1 1 auto;min-width:0;height:6px;background:' + legendColors.track + ';border-radius:3px;overflow:hidden;">'
3629
3775
  + '<span style="display:block;height:100%;width:' + pct + '%;background:' + color + ';border-radius:3px;"></span>'
3630
3776
  + '</span>'
3631
- + '<span style="flex:0 0 auto;text-align:right;min-width:60px;">' + rt._formatTooltipValue(totals[i]) + legendUnit + '</span>'
3777
+ + '<span style="flex:0 0 auto;text-align:right;min-width:60px;">' + legendTotalText(totals[i]) + legendUnit + '</span>'
3632
3778
  + '</div>'
3633
3779
  + '</div>';
3634
3780
  }).join('');
@@ -3873,7 +4019,7 @@
3873
4019
  this._layerBuilders.push(lb);
3874
4020
  return lb;
3875
4021
  }
3876
- this._layerBuilders.push(nameOrBuilder);
4022
+ this._layerBuilders.push(unwrapLayer(nameOrBuilder));
3877
4023
  return this;
3878
4024
  }
3879
4025
  // .Layer() — alias of .layer()
@@ -4076,6 +4222,10 @@
4076
4222
  // world once, at the cost of that zoom limit.
4077
4223
  renderWorldCopies: this._engineOptions.worldcopies !== false
4078
4224
  });
4225
+ // the live-SVG pane USER charts render into (see chartHostFor) —
4226
+ // registered here, keyed by this map's options object, so every
4227
+ // runtime (including later loadProject/map.layer ones) finds it
4228
+ chartHostFor(this._engineOptions, map, el);
4079
4229
  // ixmaps.Map(id, {mapProjection}): flat's orthographic map is MapLibre's
4080
4230
  // native globe here (same mapping as loadProject, applyProjectMap);
4081
4231
  // "ortographic" is the spelling flat pages commonly carry. Any other
@@ -4199,11 +4349,13 @@
4199
4349
 
4200
4350
  if (this._legendHtml) {
4201
4351
  const legendEl = document.createElement('div');
4202
- legendEl.style.cssText = 'position:absolute;top:0;left:0;z-index:5;pointer-events:none';
4352
+ // a page legend (map.legend(html)) is positioned by the map's
4353
+ // own align option — the same rule that places the native stack
4354
+ legendEl.style.cssText = 'position:absolute;' + legendAlignCssFor(this.mapOptions) + 'z-index:5;pointer-events:none';
4203
4355
  legendEl.innerHTML = this._legendHtml;
4204
4356
  // inside the map container, never the page's body (see the pinned
4205
4357
  // tooltip mount) — styling body breaks page-positioned elements
4206
- el.style.position = el.style.position || 'relative';
4358
+ anchorInside(el);
4207
4359
  el.appendChild(legendEl);
4208
4360
  }
4209
4361
 
@@ -4325,6 +4477,7 @@
4325
4477
  // the layer name): add(theme, flag) — "replace" swaps a theme of the
4326
4478
  // same id —, replace(id, theme, flag), remove(id)
4327
4479
  add: (layerBuilder, flag) => {
4480
+ layerBuilder = unwrapLayer(layerBuilder);
4328
4481
  _chainedLayers = _chainedLayers.then(() => {
4329
4482
  if (/replace/i.test(flag || '')) {
4330
4483
  const def = layerBuilder.definition();
@@ -4336,6 +4489,7 @@
4336
4489
  return engineApi;
4337
4490
  },
4338
4491
  replace: (id, layerBuilder, flag) => {
4492
+ layerBuilder = unwrapLayer(layerBuilder);
4339
4493
  _chainedLayers = _chainedLayers.then(() => { removeThemeById(id); return engineApi.defineLayer(layerBuilder); })
4340
4494
  .catch(err => console.error('[ixmaps-gl] map.replace():', err));
4341
4495
  return engineApi;
@@ -4367,6 +4521,23 @@
4367
4521
  },
4368
4522
  setMapTypeId: (id) => engineApi.setMapType(id),
4369
4523
  mapType: (id) => engineApi.setMapType(id),
4524
+ // projection swap WITHOUT the project machinery: setProjectJSON
4525
+ // tears down and re-defines every theme to change the projection
4526
+ // (a projection toggle on a theme-heavy page paid a full data
4527
+ // re-aggregation — the power plants stage page). The projection
4528
+ // itself is one MapLibre call; the layers are projection-
4529
+ // dependent (globe far-hemisphere cull, chart placement) so they
4530
+ // are rebuilt — refreshLayers, once now and again when the new
4531
+ // projection has rendered — but THEMES and VIEW are never touched
4532
+ setProjection: (type) => {
4533
+ if (typeof map.setProjection !== 'function') { console.warn('[ixmaps-gl] map.setProjection not available on the loaded maplibre-gl build (needs >=5.0.1) — projection unchanged'); return engineApi; }
4534
+ const t = /globe/i.test(String(type || '')) ? 'globe' : 'mercator';
4535
+ try { map.setProjection({ type: t }); } catch (e) { console.warn('[ixmaps-gl] map.setProjection failed:', e); return engineApi; }
4536
+ refreshLayers();
4537
+ map.triggerRepaint();
4538
+ map.once('idle', refreshLayers);
4539
+ return engineApi;
4540
+ },
4370
4541
  // flat's getMapTypeId(): the basemap type name in use
4371
4542
  getMapTypeId: () => String(builder.mapOptions.mapType || ''),
4372
4543
  // flat's attribution (htmlgui.js htmlgui_set/getAttributionString)
@@ -4515,7 +4686,7 @@
4515
4686
  // exactly how this engine's own facet-filter/style setters below
4516
4687
  // already push responsibility for "what should happen" to the
4517
4688
  // caller rather than guessing.
4518
- defineLayer: (layerBuilder) => defineFromDefinition(layerBuilder.definition()),
4689
+ defineLayer: (layerBuilder) => defineFromDefinition(unwrapLayer(layerBuilder).definition()),
4519
4690
  // GL-PORT COMPAT: real ixmaps-flat's map handle, as a page gets it
4520
4691
  // from `myMap.then(map => map.view(...).options(...).layer(a)
4521
4692
  // .layer(b))` — the common multi-layer idiom — is chainable on the
@@ -4551,10 +4722,17 @@
4551
4722
  .catch(err => console.error('[ixmaps-gl] map.layer():', err));
4552
4723
  return engineApi;
4553
4724
  }
4554
- // map.layer(name).data()...define(): replaces a theme of that name
4725
+ // map.layer(name).data()...define(): adds a theme on that layer.
4726
+ // NO same-name auto-remove here: flat lets several themes SHARE one
4727
+ // .layer() name (a page's feature base + its chart overlay, the
4728
+ // NYC status page's two "Station" themes — the overlay joins onto
4729
+ // the base's geometry by lookup, removing the base would kill
4730
+ // both). Replacement is by THEME ID (style.name/meta.name), and
4731
+ // happens inside defineFromDefinition instead — a re-defined id
4732
+ // (the page's 10s live refresh) replaces its own previous theme.
4555
4733
  const lb = new LayerBuilder(nameOrBuilder);
4556
4734
  lb.define = () => {
4557
- _chainedLayers = _chainedLayers.then(() => { engineApi.removeTheme(nameOrBuilder); return engineApi.defineLayer(lb); })
4735
+ _chainedLayers = _chainedLayers.then(() => engineApi.defineLayer(lb))
4558
4736
  .catch(err => console.error('[ixmaps-gl] map.layer().define():', err));
4559
4737
  return _chainedLayers;
4560
4738
  };
@@ -4583,8 +4761,22 @@
4583
4761
  // Removes every runtime whose OWN .layer(name) matches — a theme
4584
4762
  // may legitimately be split across more than one runtime sharing
4585
4763
  // one name (the CHOROPLETH/FEATURE-donor pair above), so this
4586
- // removes all of them, not just the first match.
4587
- removeTheme: (name) => removeRuntimes(name, runtimes.filter(r => r.name === name)),
4764
+ // removes all of them, not just the first match. GL-PORT COMPAT:
4765
+ // flat addresses a theme by its ID — style.name / meta.name —
4766
+ // as much as by its layer name (htmlgui.js removeTheme), and a
4767
+ // real page's own rebuild loop typically knows only the id it
4768
+ // set via .meta({name}) (the fire page's KDE: removeTheme
4769
+ // ("kde_fires") for the layer "kde_fires_layer"). Layer name
4770
+ // first, then the id — without the fallback every rebuild
4771
+ // removed nothing and stacked another copy of the theme,
4772
+ // compounding opacity (same semantics removeThemeById already
4773
+ // implements for the handle's .remove(id)).
4774
+ removeTheme: (name) => {
4775
+ const byLayer = runtimes.filter(r => r.name === name);
4776
+ if (byLayer.length) return removeRuntimes(name, byLayer);
4777
+ const byId = runtimes.filter(r => (r.style && r.style.name === name) || (r.meta && r.meta.name === name));
4778
+ return byId.length ? removeRuntimes(byId[0].name, byId) : false;
4779
+ },
4588
4780
  // flat's refreshTheme (maptheme.js Themes.refreshTheme): the
4589
4781
  // theme's data is loaded again — a broker is called again — and
4590
4782
  // replaces the old features in place (draw order, legend, style
@@ -4617,6 +4809,11 @@
4617
4809
  if (rt._unlisten) rt._unlisten();
4618
4810
  if (rt._cancelWait) rt._cancelWait();
4619
4811
  clearTimeout(rt._dependentTimer);
4812
+ // a removed theme's live USER-chart SVG is not part of the deck
4813
+ // layers — leaving its groups mounted keeps the charts on the
4814
+ // map after map.remove()/replace() (censimenti's sidebar)
4815
+ if (rt._clearUserChartPane) rt._clearUserChartPane();
4816
+ if (rt._chartHost && rt._chartHost.runtimes) rt._chartHost.runtimes.delete(rt);
4620
4817
  });
4621
4818
  const removed = removedRuntimes.length > 0;
4622
4819
  // stale-registry guard: _globalThemeRegistry is a bare name->
@@ -4708,10 +4905,15 @@
4708
4905
  else map.fitBounds(bounds, { padding: 30, animate: false, maxZoom: 16 });
4709
4906
  }
4710
4907
 
4711
- async function loadNamedTheme(rt, force) {
4908
+ // quiet: the broker's data fills only this theme — not broadcast to
4909
+ // the other themes of the name (flat's refreshTheme reloads one theme;
4910
+ // setExternalData only releases themes still waiting for the data,
4911
+ // maptheme.js 2570-2591)
4912
+ async function loadNamedTheme(rt, force, quiet = false) {
4712
4913
  const spec = normalizeTheme(rt._definition);
4713
4914
  const name = String(spec.data.name);
4714
4915
  const broker = brokerTheme(spec);
4916
+ if (quiet && broker) _quietNamedData.set(name, (_quietNamedData.get(name) || 0) + 1);
4715
4917
  rt._loading = (rt._loading || 0) + 1;
4716
4918
  if (broker) { pendingLoads++; rt._brokerLoading = (rt._brokerLoading || 0) + 1; setDataLoading(); }
4717
4919
  try {
@@ -4733,6 +4935,7 @@
4733
4935
  else console.error(`[ixmaps-gl] theme "${themeIdOf(rt)}": no data —`, err);
4734
4936
  } finally {
4735
4937
  rt._loading--;
4938
+ if (quiet && broker) { const n = (_quietNamedData.get(name) || 1) - 1; if (n) _quietNamedData.set(name, n); else _quietNamedData.delete(name); }
4736
4939
  if (broker) { pendingLoads--; rt._brokerLoading--; setDataLoading(); }
4737
4940
  }
4738
4941
  }
@@ -4781,7 +4984,11 @@
4781
4984
  }
4782
4985
  // flat, after a FEATURE theme is drawn (maptheme.js 18947-18962): the
4783
4986
  // other themes on its layer are placed again, and a data.query broker
4784
- // among them is refreshed 500 ms later
4987
+ // among them is refreshed 500 ms later — quietly: its new data fills
4988
+ // that theme only. Broadcast to every theme of the name, a FEATURE
4989
+ // theme sharing the broker's data name (the fire areas page: SIF
4990
+ // polygons and chart both "data_2") was refilled, drawn, refreshed
4991
+ // its dependent again — an endless reload of ~15k records every 3 s
4785
4992
  function featureDependents(featureRt) {
4786
4993
  runtimes.forEach(dep => {
4787
4994
  if (dep === featureRt || dep.flags.has('FEATURE') || dep.flags.has('FEATURES')) return;
@@ -4789,7 +4996,7 @@
4789
4996
  const spec = normalizeTheme(dep._definition || {});
4790
4997
  if (dep._named && spec.data && spec.data.query) {
4791
4998
  clearTimeout(dep._dependentTimer);
4792
- dep._dependentTimer = setTimeout(() => { if (runtimes.includes(dep)) loadNamedTheme(dep, true); }, 500);
4999
+ dep._dependentTimer = setTimeout(() => { if (runtimes.includes(dep)) loadNamedTheme(dep, true, true); }, 500);
4793
5000
  } else if (dep._named && dep._lastTable && !dep._loading) {
4794
5001
  fillNamedTheme(dep, spec, dep._lastTable, null);
4795
5002
  }
@@ -4798,10 +5005,18 @@
4798
5005
 
4799
5006
  // One theme definition (real ixmaps-flat's shape — from a builder's
4800
5007
  // definition() or a project file) → a new runtime on this map. Shared
4801
- // by defineLayer and loadProject. Purely additive, see defineLayer.
5008
+ // by defineLayer and loadProject. Purely additive across LAYER names —
5009
+ // several themes legitimately share one (a FEATURE base + its CHART
5010
+ // overlay, the CHOROPLETH/FEATURE-donor pair) — but a re-defined
5011
+ // THEME ID (style.name/meta.name) replaces its own previous theme, as
5012
+ // flat itself does ("defining a theme with the same name replaces it
5013
+ // in place"; a page's live-refresh loop relies on it, e.g. the NYC
5014
+ // status page re-defining its two themes every 10s).
4802
5015
  async function defineFromDefinition(def) {
4803
5016
  if (validation) validation.definition(def);
4804
5017
  const pdef = projectThemeToDefinition(def);
5018
+ const replacedId = (pdef.meta && pdef.meta.name) || (pdef.style && pdef.style.name);
5019
+ if (replacedId) removeThemeById(replacedId);
4805
5020
  let spec = normalizeTheme(pdef);
4806
5021
  let raw;
4807
5022
  const deferred = deferredFeatureLoad(spec, map.getZoom());
@@ -5336,7 +5551,7 @@
5336
5551
  // semantics — layer, view, options, require, attribution, on — keep
5337
5552
  // theirs and are not overridden.
5338
5553
  for (const m of ['replace', 'add', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
5339
- 'refreshTheme', 'redrawTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'resize',
5554
+ 'refreshTheme', 'redrawTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'setProjection', 'resize',
5340
5555
  'loadProject', 'project', 'setThemeVisible', 'setThemeTimeFrame', 'show', 'hide', 'getZoom', 'getThemeObj',
5341
5556
  'getThemes', 'theme', 'setView']) {
5342
5557
  if (!MapBuilder.prototype[m]) {
@@ -5412,6 +5627,9 @@
5412
5627
  }
5413
5628
  return promise;
5414
5629
  };
5630
+ // flat's mapApi.setView(center, zoom) (htmlgui_flat.js 1327) — the
5631
+ // same live-or-build-time split as .view() above
5632
+ promise.setView = (...args) => promise.view(...args);
5415
5633
  promise.options = (...args) => { builder.options(...args); return promise; };
5416
5634
  promise.attribution = (...args) => { builder.attribution(...args); return promise; };
5417
5635
  promise.legend = (...args) => { builder.legend(...args); return promise; };
@@ -5452,8 +5670,11 @@
5452
5670
  // itself, which stays purely additive for every other caller —
5453
5671
  // see its own comment for why (the CHOROPLETH/FEATURE-donor
5454
5672
  // pairing needs two runtimes sharing one name to coexist).
5673
+ // NO same-LAYER-name auto-remove (see engineApi.layer's own
5674
+ // comment — several themes legitimately share one layer name);
5675
+ // a re-defined THEME ID replaces its own previous theme inside
5676
+ // defineFromDefinition instead
5455
5677
  lb.define = () => {
5456
- resolvedApi.removeTheme(nameOrBuilder);
5457
5678
  return resolvedApi.defineLayer(lb);
5458
5679
  };
5459
5680
  }
@@ -5736,6 +5957,41 @@
5736
5957
  * (flatChartBranch(flags) === 'bubble' ? 0.5 : 1);
5737
5958
  return Math.max(VALUE_RADIUS_MIN, Math.min(VALUE_RADIUS_MAX, fixed * zoomFactor));
5738
5959
  }
5960
+ // NOSIZE: every chart ONE fixed radius, whatever the item's value;
5961
+ // normalsizevalue doesn't scale it. Which one depends on flat's SYMBOL
5962
+ // sub-branch:
5963
+ // - a CATEGORICAL theme without AGGREGATE/GROUP ("symbol equals
5964
+ // value", maptheme.js 21566-21625, "GR 01.07.2023 redesign size
5965
+ // handling"): the normal radius itself
5966
+ // - every other symbol chart (the per-part ladder, 21829-21896):
5967
+ // SEQUENCE normalX(chart size / 3), 2/3 of it; otherwise
5968
+ // nMaxRadius / 5 — measured on the fire areas page's SIF chart
5969
+ // (CHART|SYMBOL|NOSIZE: r 60 units at normalX(1) 20, i.e. 3 of the
5970
+ // 15-unit normal radius)
5971
+ // The BUBBLE draw keeps its own older NOSIZE value, half of it
5972
+ // (maptheme.js 19839)
5973
+ if (flags && flags.has('NOSIZE') && !flags.has('PLOT') && !flags.has('GRIDSIZE')) {
5974
+ const symbolEqualsValue = flags.has('CATEGORICAL') && !flags.has('AGGREGATE') && !flatFlag(flags, 'GROUP');
5975
+ const fixed = NORMAL_RADIUS_PX * (styleNum(style.scale) || 1)
5976
+ * (flatChartBranch(flags) === 'bubble' ? 0.5
5977
+ : symbolEqualsValue ? 1
5978
+ : flatFlag(flags, 'SEQUENCE') ? 2 / 3 : 1 / 5);
5979
+ return Math.max(VALUE_RADIUS_MIN, Math.min(VALUE_RADIUS_MAX, fixed * zoomFactor));
5980
+ }
5981
+ // Same redesign, the rule around it (maptheme.js 21612-21625) — only in
5982
+ // flat's "symbol equals value" sub-branch, a CATEGORICAL theme without
5983
+ // AGGREGATE/GROUP: the symbol resizes by itemA[a].nSize, which flat only
5984
+ // sets for a theme with a bound szSizeField (maptheme.js 9144-9153), so
5985
+ // WITHOUT a size field every item keeps the DEFAULT normal radius,
5986
+ // whatever its value. Every other symbol chart (the per-part ladder,
5987
+ // 21850-21851) and BUBBLE (19851) scale by the value — below.
5988
+ if (flags && flags.has('SYMBOL') && !flags.has('BUBBLE') && !flags.has('USER')
5989
+ && !flags.has('FIXSIZE') && !flags.has('PLOT') && !flags.has('GRIDSIZE')
5990
+ && flags.has('CATEGORICAL') && !flags.has('AGGREGATE') && !flatFlag(flags, 'GROUP')
5991
+ && !style.sizefield) {
5992
+ const fixed = NORMAL_RADIUS_PX * (styleNum(style.scale) || 1);
5993
+ return Math.max(VALUE_RADIUS_MIN, Math.min(VALUE_RADIUS_MAX, fixed * zoomFactor));
5994
+ }
5739
5995
  // Real default (maptheme.js ~line 5059) when the theme sets no
5740
5996
  // normalsizevalue: the dataset's own max value on the bound size
5741
5997
  // field (maxSizeValue, from LayerRuntime._prepare), not a fixed
@@ -6048,9 +6304,9 @@
6048
6304
 
6049
6305
  // a chart's VALUES text (maptheme.js 20979-21017): an explicit valuefield
6050
6306
  // prints that field of the record ($title$: the title; a non-number as
6051
- // is), else the value; numbers formatted with valuedecimals (flat's `||`:
6052
- // 0 or unset → 1 decimal below 1 — or when the max value is at most 1 —
6053
- // else none) plus the unit, which flat stores with a leading space unless
6307
+ // is), else the value; numbers formatted with valuedecimals when set
6308
+ // (explicitValueDecimals: a given "0" counts), else 1 decimal below 1 —
6309
+ // or when the max value is at most 1 — else none, plus the unit, which flat stores with a leading space unless
6054
6310
  // it starts with "." (9675) and appends only up to 5 characters.
6055
6311
  // A CATEGORICAL bubble prints its size value when size is bound to a
6056
6312
  // field (its value, see resolveAggregateValue), as flat does; with
@@ -6064,11 +6320,30 @@
6064
6320
  if (!field || field !== binding.value || !flags.has('CATEGORICAL') || !labels || labels[i] == null) return null;
6065
6321
  return { [field]: labels[i] };
6066
6322
  }
6323
+ // style.valuedecimals as flat reads it: nValueDecimals keeps the raw
6324
+ // style string, so an explicit "0" is truthy in flat's `nValueDecimals ||
6325
+ // default` and counts (fire areas page: valuedecimals "0", 0.48 → "1 ha");
6326
+ // null when not set
6327
+ // (valuedecimals is therefore not typed: STYLE_NUMBER_KEYS). Flat's
6328
+ // truthiness: unset, "" and a numeric 0 fall back to the default
6329
+ function explicitValueDecimals(style) {
6330
+ const v = style && style.valuedecimals;
6331
+ if (!v) return null;
6332
+ const n = Number(v);
6333
+ return Number.isFinite(n) ? n : null;
6334
+ }
6335
+ // the legend's decimals (legend.js 610): valuedecimals when defined, else 2
6336
+ function legendDecimals(style) {
6337
+ const v = style && style.valuedecimals;
6338
+ const n = v == null || v === '' ? NaN : Number(v);
6339
+ return Number.isFinite(n) ? n : 2;
6340
+ }
6067
6341
  function flatValueText(raw, title, value, style, flags, opts = {}) {
6068
6342
  const units = style.units ? String(style.units) : '';
6069
6343
  const unit = units ? (units[0] === '.' ? '' : ' ') + units : '';
6070
6344
  const unitText = unit.length <= 5 ? unit : '';
6071
- const dec = v => styleNum(style.valuedecimals) || ((v < 1 || (opts.maxValue != null && opts.maxValue <= 1)) ? 1 : 0);
6345
+ const explicitDec = explicitValueDecimals(style);
6346
+ const dec = v => (explicitDec != null ? explicitDec : ((v < 1 || (opts.maxValue != null && opts.maxValue <= 1)) ? 1 : 0));
6072
6347
  const signed = (v, text) => {
6073
6348
  if (flags && (flags.has('DIFFERENCE') || flags.has('RELATIVE') || flags.has('SIGN'))) {
6074
6349
  if (v === 0) return '+/-' + text;
@@ -6081,7 +6356,7 @@
6081
6356
  if (field && raw && raw[field] != null && raw[field] !== '') {
6082
6357
  if (isNaN(raw[field])) return String(raw[field]);
6083
6358
  const v = Number(raw[field]);
6084
- return signed(v, flatGroupedValue(v, styleNum(style.valuedecimals) || (v < 1 ? 1 : 0), opts.noBreaks) + unitText);
6359
+ return signed(v, flatGroupedValue(v, explicitDec != null ? explicitDec : (v < 1 ? 1 : 0), opts.noBreaks) + unitText);
6085
6360
  }
6086
6361
  return signed(value, flatGroupedValue(value, dec(value), opts.noBreaks) + unitText);
6087
6362
  }
@@ -6183,19 +6458,29 @@
6183
6458
  }
6184
6459
 
6185
6460
  function valuesHiddenByScale(style, zoom) {
6186
- const m = /^1:(\d+(?:\.\d+)?)$/.exec(style.valueupper || '');
6461
+ // flat accepts both 'valueupper' and 'valuesupper' (maptheme.js 1710,
6462
+ // "GR 15.03.2017 accept also 'valuesupper'"), same for the lower pair
6463
+ const m = /^1:(\d+(?:\.\d+)?)$/.exec(style.valueupper || style.valuesupper || '');
6187
6464
  if (!m) return false;
6188
6465
  const upperRatio = parseFloat(m[1]);
6189
6466
  const scaleDenominator = scaleDenominatorAt(zoom);
6190
6467
  return scaleDenominator > upperRatio;
6191
6468
  }
6469
+ // flat's chart LABEL scale gate (maptheme.js 24536-24537): the label is
6470
+ // visible while the scale passes <= against BOTH bounds — flat's own
6471
+ // lower test reads <= too, kept as-is for parity
6472
+ function labelHiddenByScale(style, zoom) {
6473
+ const upper = scaleDenom(style.labelupper), lower = scaleDenom(style.labellower);
6474
+ const scale = scaleDenominatorAt(zoom);
6475
+ return !!((upper && scale > upper) || (lower && scale > lower));
6476
+ }
6192
6477
 
6193
6478
  // contrasting text color against a bubble's own fill — standard relative
6194
6479
  // luminance; approximates the real engine's __maptheme_getChartColors(...)
6195
6480
  // .textColor (white text on ixmaps' typically dark/saturated palette,
6196
6481
  // dark text on pale fills) without needing its exact color-space table.
6197
6482
  function contrastTextColor(fillRgb) {
6198
- const [r, g, b] = fillRgb;
6483
+ const [r, g, b] = fillRgb || [128, 128, 128];
6199
6484
  const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
6200
6485
  return luminance > 0.6 ? [30, 30, 30] : [255, 255, 255];
6201
6486
  }
@@ -6710,6 +6995,25 @@
6710
6995
  return parts;
6711
6996
  }
6712
6997
 
6998
+ // CHART|SYMBOL|SEQUENCE|RINGS (flat maptheme.js 21293-21318): the parts
6999
+ // nest on the center — each non-zero part sized by the running total of
7000
+ // the non-zero parts up to it (its own value included), biggest first
7001
+ // so the smaller ones stay on top; parts of value 0 are left out.
7002
+ // radiusOf(total) is the ring's radius. Measured on flat (NYC status:
7003
+ // 63 | 75 | 3 → r 161.5 | 353.8 | 361.5 at max radius 300, max value
7004
+ // 117). Returns { i, v (the part's own value), c (running total), r, x, y }.
7005
+ function ringsLayout(counts, radiusOf) {
7006
+ const parts = [];
7007
+ let total = 0;
7008
+ counts.forEach((v, i) => {
7009
+ if (!v) return;
7010
+ total += v;
7011
+ const r = radiusOf(total);
7012
+ if (r > 0) parts.push({ i, v, c: total, r, x: 0, y: 0 });
7013
+ });
7014
+ return parts.sort((a, b) => b.c - a.c);
7015
+ }
7016
+
6713
7017
  // ---------------------------------------------------------------
6714
7018
  // Classification — pure functions (no LayerRuntime state): class
6715
7019
  // breaks for a numeric field, and which class a value falls in.
@@ -7846,6 +8150,18 @@
7846
8150
  return out;
7847
8151
  }
7848
8152
 
8153
+ // Every runtime ever built gets a fresh number: a theme rebuilt through
8154
+ // removeTheme() + defineLayer() (a page's own refresh loop, e.g. the fire
8155
+ // page's KDE isobands) is a NEW runtime whose data may differ from the
8156
+ // runtime it replaces — if its deck.gl layer kept the SAME id, deck would
8157
+ // update the old layer's state in place, and under the interleaved overlay
8158
+ // those in-place updates can leave the PREVIOUS generation's geometry on
8159
+ // screen (the exact failure _dataLayerId's own comment documents for
8160
+ // replaceFeatures: "updates in place, arriving over each other, left
8161
+ // triangles across sections"). A unique -dN suffix per runtime forces deck
8162
+ // to build a genuinely new layer and dispose the old one.
8163
+ let LAYER_DATA_GENERATION = 0;
8164
+
7849
8165
  class LayerRuntime {
7850
8166
  // spec: a normalizeTheme() result — never a raw LayerBuilder
7851
8167
  constructor(spec, fc, mapOptions) {
@@ -7854,6 +8170,8 @@
7854
8170
  this.style = spec.style;
7855
8171
  this.flags = symbolsFlagCompat(spec.flags, spec.style);
7856
8172
  this.meta = spec.meta;
8173
+ // see LAYER_DATA_GENERATION above — unique per runtime, see _dataLayerId
8174
+ this._dataGen = ++LAYER_DATA_GENERATION;
7857
8175
  // GL-PORT COMPAT: retained only for ixmaps.getThemeObj()'s szFilter
7858
8176
  // (real ixmaps-flat global compat shim, see _globalThemeRegistry) —
7859
8177
  // this engine's own filtering is fully static/load-time (see
@@ -8024,6 +8342,21 @@
8024
8342
  // ("investimento"/"tamponamento"/...) needs the display text, not
8025
8343
  // a raw numeric code it could never match against.
8026
8344
  this.categoryColorsRgb = resolveClassColors(this.style.colorscheme, this.categoryDisplayLabels, this.style.classes);
8345
+ } else if (this._isMultiFieldSequence()) {
8346
+ // CHART|SYMBOL|SEQUENCE over several value fields (no CATEGORICAL,
8347
+ // no AGGREGATE): one part per field, colored by the colorscheme in
8348
+ // field order, labeled by .style({label}) (flat's szFieldsA /
8349
+ // szLabelA). A colorscheme entry "none" is a part without fill —
8350
+ // only its outline in linecolor (flat: fill:none). The parts' max
8351
+ // value is flat's nMax: the biggest single field value of any
8352
+ // record (maptheme.js nMaxA → nMax), measured on the NYC status page
8353
+ Object.assign(this, computeMultiFieldClasses(this.binding, this.style));
8354
+ this.categoryIndexByLabel = new Map(this.categoryLabels.map((v, i) => [v, i]));
8355
+ const scheme = Array.isArray(this.style.colorscheme) ? this.style.colorscheme : [];
8356
+ this._partNoFill = new Set(this._multiFields.map((f, i) => i).filter(i => String(scheme[i]).trim().toLowerCase() === 'none'));
8357
+ let max = 0;
8358
+ for (const f of this.features) for (const field of this._multiFields) { const v = Math.abs(parseFloat(f.properties[field])); if (v > max) max = v; }
8359
+ this._multiFieldPartMax = max || 1;
8027
8360
  } else if (this.binding.value) {
8028
8361
  // real engine's OTHER coloring mode (maptheme.js distributeValues,
8029
8362
  // partsA): a NUMERIC bound value, not CATEGORICAL, is classed into
@@ -8036,6 +8369,24 @@
8036
8369
  // "which bucket does this feature belong to", and once buckets +
8037
8370
  // colors exist, everything downstream is identical.
8038
8371
  this._buildPartsA();
8372
+ } else if (this.style.colorscheme != null) {
8373
+ // GL-PORT COMPAT: a theme with NO classification at all (no
8374
+ // CATEGORICAL categories, no numeric binding.value to range-class)
8375
+ // still has classes in the real engine — exactly ONE, its implicit
8376
+ // bucket: _resolveClassIndex returns 0 for every feature, and flat
8377
+ // colors them all with colorScheme[0] (every MapTheme is built
8378
+ // from its colorscheme array — one entry, one class, e.g. the
8379
+ // fire page's KDE isobands: FEATURE|CHOROPLETH, colorscheme
8380
+ // "#dd0088", no value field — every iso band one flat magenta).
8381
+ // Without this branch categoryColorsRgb stayed undefined while
8382
+ // cat was still 0, and the choropleth's getFillColor crashed on
8383
+ // categoryColorsRgb[0] — deck.gl killed the layer at init and
8384
+ // the theme never drew. A colorscheme that resolves to NO colors
8385
+ // (the explicit no-fill ["none"] — flat draws unfilled symbols,
8386
+ // only their values/boxes/titles) keeps that: resolving it to the
8387
+ // fallback palette would paint symbol circles flat never draws.
8388
+ const resolved = resolveColorScheme(this.style.colorscheme, [''], this.style.classes);
8389
+ this.categoryColorsRgb = resolved ? resolved.map(c => Array.isArray(c) ? c : (parseCssColor(c) || [130, 130, 130])) : null;
8039
8390
  }
8040
8391
 
8041
8392
  // facet-driven dynamic filter state (see setFacetFilter/getFacets) —
@@ -8105,6 +8456,17 @@
8105
8456
  // this with AGGREGATE clustering yet, so a clustered GROUP bubble
8106
8457
  // always stays circular (_buildBubbleIcon, untouched).
8107
8458
  _resolveSymbolShape(props) {
8459
+ // flat's class→symbol mapping (maptheme.js 1417, 2074-2077, 12937):
8460
+ // `symbols` WITHOUT a symbolfield names the shape per COLOR class —
8461
+ // szSymbolsA[class], the last entry repeating when the array is
8462
+ // shorter than the classes (a single entry, like the fire page's
8463
+ // symbols: ["square"], shapes every class). Only GL's implemented
8464
+ // shapes draw (drawSymbolPath); flat-only names fall back to circle.
8465
+ if (!this.style.symbolfield && Array.isArray(this.style.symbols)) {
8466
+ const s = this.style.symbols;
8467
+ const cat = props && props.cat != null ? props.cat : 0;
8468
+ return normalizeSymbolShape(s[Math.min(cat, s.length - 1)]);
8469
+ }
8108
8470
  if (!this.style.symbolfield || !Array.isArray(this.style.symbolvalues) || !Array.isArray(this.style.symbols)) return 'circle';
8109
8471
  // CORRECTED: found live via pickObject, not assumed — the properties
8110
8472
  // this actually receives at render time (from _computeAggregatedItems'
@@ -8166,6 +8528,19 @@
8166
8528
  // they'd never even share a cell in the first place). Every
8167
8529
  // feature goes into ONE bucket; _buildChartLayers resolves the real
8168
8530
  // 7-class color once it knows each cell's actual aggregated total.
8531
+ if (this._multiFields && this._isMultiFieldSequence()) {
8532
+ // one bucket; each record carries its field values as the chart's
8533
+ // parts. Its value — the draw order — is the first field's, as
8534
+ // flat's chart node value (NYC status: value="63" = e-bikes)
8535
+ this._featuresByCategory = [sourceFeatures.map(f => {
8536
+ const parts = this._multiFields.map(k => parseFloat(f.properties[k]) || 0);
8537
+ return { type: 'Feature', geometry: f.geometry, properties: { value: parts[0], parts, raw: f.properties } };
8538
+ })];
8539
+ this._clusterIndices = null;
8540
+ this._clusterRadiusPx = null;
8541
+ this._aggregateStatsCache = null;
8542
+ return;
8543
+ }
8169
8544
  if (this._rangeClassed) {
8170
8545
  // flat colors/classes an aggregated cell by the SUM of the value
8171
8546
  // field (nValuesA, maptheme.js:10870ff; classified by nValuesA[0],
@@ -8652,7 +9027,7 @@
8652
9027
  return this._glowIconCache.get(key);
8653
9028
  }
8654
9029
 
8655
- _buildBubbleIcon(counts, colors, marked = null) {
9030
+ _buildBubbleIcon(counts, colors, marked = null, shapes = null) {
8656
9031
  // The cache key quantizes each count's RATIO to the group's total,
8657
9032
  // not the raw count — computeBubblePackLayout's radii are purely
8658
9033
  // sqrt(c/total), so two clusters with wildly different absolute
@@ -8682,7 +9057,7 @@
8682
9057
  // coarse share buckets that can put a small marked part at 0
8683
9058
  const key = counts.map(c => Math.round((c / total) * RATIO_BUCKETS)).join('-') + (marked
8684
9059
  ? '|iso:' + counts.map((c, i) => (c > 0 && marked.has(i) ? i + ':' + Math.round(Math.sqrt(c / total) * 100) : null)).filter(Boolean).join(',')
8685
- : '');
9060
+ : '') + (shapes ? '|sym:' + shapes.map(v => String(v).toLowerCase()).join(',') : '');
8686
9061
  if (this._iconCache.has(key)) return this._iconCache.get(key);
8687
9062
  const size = BUBBLE_ICON_SIZE;
8688
9063
  const canvas = document.createElement('canvas');
@@ -8691,8 +9066,16 @@
8691
9066
  const { present, radii, offsets, fitScale } = (marked && isolatedBubblePackLayout(counts, size, marked)) || computeBubblePackLayout(counts, size);
8692
9067
  const cx = size / 2, cy = size / 2;
8693
9068
  present.forEach((p, i) => {
9069
+ // flat draws each part of an aggregated SYMBOL theme as ITS class's
9070
+ // symbol shape (the szSymbol switch runs per part, maptheme.js
9071
+ // 21995-22060) — `symbols` names them per class like
9072
+ // _resolveSymbolShape; circles when the name isn't one GL draws
9073
+ const shape = shapes ? normalizeSymbolShape(shapes[Math.min(p.i, shapes.length - 1)]) : 'circle';
9074
+ const px = cx + offsets[i].x * fitScale, py = cy + offsets[i].y * fitScale;
8694
9075
  ctx.beginPath();
8695
- ctx.arc(cx + offsets[i].x * fitScale, cy + offsets[i].y * fitScale, radii[i] * fitScale, 0, Math.PI * 2);
9076
+ if (shape === 'circle') ctx.arc(px, py, radii[i] * fitScale, 0, Math.PI * 2);
9077
+ else drawSymbolPath(ctx, shape, px, py, radii[i] * fitScale);
9078
+ ctx.closePath();
8696
9079
  ctx.fillStyle = `rgb(${colors[p.i].join(',')})`;
8697
9080
  ctx.globalAlpha = 0.9; ctx.fill(); ctx.globalAlpha = 1;
8698
9081
  ctx.lineWidth = 1; ctx.strokeStyle = '#fff'; ctx.stroke();
@@ -8742,14 +9125,22 @@
8742
9125
  ctx.beginPath();
8743
9126
  drawSymbolPath(ctx, shape, c, c, r);
8744
9127
  ctx.closePath();
9128
+ // flat's "empty" (maptheme.js 21629-21631): the circle geometry, but
9129
+ // a fully transparent fill and a gray outline (fill-opacity:0,
9130
+ // stroke #888888) — a shapeless spot marker
8745
9131
  ctx.fillStyle = `rgb(${colorRgb.join(',')})`;
8746
- ctx.globalAlpha = opacity;
9132
+ ctx.globalAlpha = shape === 'empty' ? 0 : opacity;
8747
9133
  ctx.fill();
8748
9134
  if (borderWidth > 0 && borderColorRgb) {
8749
9135
  ctx.globalAlpha = 1;
8750
9136
  ctx.strokeStyle = `rgb(${borderColorRgb.join(',')})`;
8751
9137
  ctx.lineWidth = borderWidth;
8752
9138
  ctx.stroke();
9139
+ } else if (shape === 'empty') {
9140
+ ctx.globalAlpha = 1;
9141
+ ctx.strokeStyle = '#888888';
9142
+ ctx.lineWidth = 1;
9143
+ ctx.stroke();
8753
9144
  }
8754
9145
  const icon = { url: canvas.toDataURL(), width: size, height: size, anchorX: size / 2, anchorY: size / 2, id: key };
8755
9146
  return this._cacheIcon(key, icon);
@@ -9026,7 +9417,8 @@
9026
9417
  _formatTooltipValue(value) {
9027
9418
  const num = Number(value);
9028
9419
  if (isNaN(num)) return String(value);
9029
- const rawDecimals = styleNum(this.style.valuedecimals);
9420
+ const vd = this.style.valuedecimals;
9421
+ const rawDecimals = vd == null || vd === '' ? NaN : Number(vd);
9030
9422
  const decimals = isNaN(rawDecimals) ? 2 : rawDecimals;
9031
9423
  // flat's __formatValue (mapscript.js 6268ff) rounds twice: first to
9032
9424
  // one decimal more (toFixed), then to the precision (Math.round) —
@@ -9080,8 +9472,12 @@
9080
9472
  buildDeckLayers(zoom, bbox, liveZoom = zoom, globeCenter = null) {
9081
9473
  this._lastZoom = zoom;
9082
9474
  this._lastBbox = bbox;
9083
- if (this._hidden) return [];
9084
- if (!this.flags.has('FEATURE') && !this.flags.has('FEATURES') && chartHiddenByScale(this.style, liveZoom)) return [];
9475
+ // the live USER-chart SVG pane is not part of the deck layers — an
9476
+ // early return here must also unmount its groups, or the pane keeps
9477
+ // showing the last drawing after the theme went out of scale (or was
9478
+ // hidden via setThemeVisible)
9479
+ if (this._hidden) { this._clearUserChartPane(); return []; }
9480
+ if (!this.flags.has('FEATURE') && !this.flags.has('FEATURES') && chartHiddenByScale(this.style, liveZoom)) { this._clearUserChartPane(); return []; }
9085
9481
  // DOT is checked FIRST, matching the real engine's own precedence
9086
9482
  // (its DOT branch is a loop-level fast-path that bypasses the whole
9087
9483
  // drawChart/modifier machinery entirely — a layer flagged DOT never
@@ -9135,7 +9531,16 @@
9135
9531
 
9136
9532
  _buildFeaturesLayers(zoom, bbox) {
9137
9533
  const cs = this.style.colorscheme;
9138
- const raw = Array.isArray(cs) ? cs[0] : cs;
9534
+ // a generated scheme's first entry is the class COUNT, not a color —
9535
+ // flat colors an unclassed theme with the sweep's FIRST generated
9536
+ // color (maptheme colorScheme[0] after realize generates the sweep),
9537
+ // never the bare count string (a near-white dot for "5","white","blue"
9538
+ // — the NYC page's station base)
9539
+ let raw = Array.isArray(cs) ? cs[0] : cs;
9540
+ if (Array.isArray(cs) && isGeneratedColorScheme(cs)) {
9541
+ const sweep = flatColorSweep(cs[1], cs[2], Number(cs[0]) || 5, cs[3], cs[4]);
9542
+ if (sweep && sweep.length) raw = sweep[0];
9543
+ }
9139
9544
  const filled = raw !== 'none';
9140
9545
  // FEATURE|CATEGORICAL: polygons coloured by their category (flat colours
9141
9546
  // each shape by its class), not one flat fill
@@ -9164,7 +9569,7 @@
9164
9569
  getFillColor: byCategory
9165
9570
  ? f => {
9166
9571
  const cat = this._resolveClassIndex(f.properties[this.binding.value]);
9167
- return [...(cat != null ? this.categoryColorsRgb[cat] : nodataRgb).slice(0, 3), catAlpha];
9572
+ return [...(cat != null ? (this.categoryColorsRgb || [])[cat] || nodataRgb : nodataRgb).slice(0, 3), catAlpha];
9168
9573
  }
9169
9574
  : filled ? [...hexOrNamedToRgb(raw).slice(0, 3), Math.round(255 * cssColorAlpha(raw) * (styleNum(this.style.fillopacity) || 1))] : [0, 0, 0, 0],
9170
9575
  getLineColor: this.style.linecolor
@@ -9371,7 +9776,7 @@
9371
9776
  getFillColor: d => {
9372
9777
  if (!hasData(d)) return [0, 0, 0, 0];
9373
9778
  const rgb = d.properties.composedColor
9374
- || (d.properties.cat != null ? this.categoryColorsRgb[d.properties.cat] : null) || fallbackRgb;
9779
+ || (d.properties.cat != null ? (this.categoryColorsRgb || [])[d.properties.cat] : null) || fallbackRgb;
9375
9780
  const evidence = unmarkedEvidence(d);
9376
9781
  if (evidence === 'hide') return [0, 0, 0, 0];
9377
9782
  if (evidence === 'gray') return [...grayOf(rgb), 90];
@@ -9386,7 +9791,15 @@
9386
9791
  // per-feature alpha (baked above) already carries the resolved
9387
9792
  // base opacity — a layer-level opacity on TOP of that would
9388
9793
  // multiply it a second time.
9389
- opacity: dopacityActive ? 1 : baseOpacity
9794
+ opacity: dopacityActive ? 1 : baseOpacity,
9795
+ // Same coplanar z-fighting fix as the FEATURE/FEATURES GeoJsonLayer
9796
+ // (see _buildFeaturesLayers' own comment): under globe projection
9797
+ // the interleaved polygon surface and MapLibre's own sphere mesh
9798
+ // compete for the same depth-buffer real estate and the fill comes
9799
+ // out moiré/hatched ("holes"), never under flat Mercator. Draw
9800
+ // order against other polygon layers is unchanged — depthCompare
9801
+ // only settles the fight against the map's own surface.
9802
+ parameters: { depthCompare: 'always' }
9390
9803
  })];
9391
9804
  }
9392
9805
 
@@ -9442,6 +9855,13 @@
9442
9855
  _isItemPlot() {
9443
9856
  return this.flags.has('PLOT') && !this.flags.has('GRIDSIZE') && String(this.binding.value || '').includes('|');
9444
9857
  }
9858
+ // a per-item SEQUENCE chart: CHART|SYMBOL|SEQUENCE, several value
9859
+ // fields per item, one part per field (no CATEGORICAL, no AGGREGATE)
9860
+ _isMultiFieldSequence() {
9861
+ return this.flags.has('CHART') && this.flags.has('SYMBOL') && flatFlag(this.flags, 'SEQUENCE')
9862
+ && !this.flags.has('CATEGORICAL') && !this.flags.has('AGGREGATE') && !this.flags.has('PLOT')
9863
+ && String(this.binding.value || '').includes('|');
9864
+ }
9445
9865
  _itemPlotFields() {
9446
9866
  return String(this.binding.value || '').split('|');
9447
9867
  }
@@ -9952,6 +10372,11 @@
9952
10372
  }
9953
10373
  // USER charts (see resolveUserChartFunction): one icon per chart, drawn
9954
10374
  // by the page's function; null → no such function (drawn as bubbles)
10375
+ _clearUserChartPane() {
10376
+ if (this._chartPaneGroup) this._chartPaneGroup.remove();
10377
+ this._chartPaneGroup = null;
10378
+ this._chartItems = null;
10379
+ }
9955
10380
  _buildUserChartLayers(combined, liveZoom, sizeValueOf) {
9956
10381
  const name = String(this.style.userdraw || '');
9957
10382
  const draw = resolveUserChartFunction(name);
@@ -9987,9 +10412,17 @@
9987
10412
  // radius of it (see USER_CHART_MAX_SIZE)
9988
10413
  const pxPerUnit = NORMAL_RADIUS_PX * objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(this.style.scale) || 1) / USER_CHART_MAX_SIZE;
9989
10414
  const flag = theme.szFlag;
9990
- const maxRasterPx = Math.sqrt(USER_CHART_ATLAS_AREA / Math.max(1, combined.length));
9991
- const data = [];
9992
- let pending = 0;
10415
+ const chartHost = _chartHosts.get(this.mapOptions);
10416
+ if (!chartHost) {
10417
+ if (!this._userChartWarned) { this._userChartWarned = true; console.warn(`[ixmaps-gl] USER chart "${name}": no chart pane — drawn as bubbles`); }
10418
+ return null;
10419
+ }
10420
+ // this view's charts, mounted as live <g> elements in the pane (see
10421
+ // chartHostFor) — flat's own SVG rendering: no atlas, no raster, no
10422
+ // async image decode, the whole drawing is synchronous
10423
+ const items = [];
10424
+ const defsSeen = new Set();
10425
+ let html = '';
9993
10426
  for (const d of combined) {
9994
10427
  const value = valueOf(d);
9995
10428
  // the item's class (range classes set it for groups too — a negative
@@ -9997,133 +10430,69 @@
9997
10430
  const cat = d.properties.cat != null ? d.properties.cat : (d.properties.counts ? dominant(d.properties.counts) : 0);
9998
10431
  const radiusPx = valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
9999
10432
  const size = radiusPx / pxPerUnit;
10000
- const icon = this._userChartIcon(draw, name, theme, flag, d, value, cat, size, sizeValueOf(d), pxPerUnit, maxRasterPx);
10001
- if (!icon || icon.failed) continue;
10002
- if (!icon.canvas) { pending++; continue; }
10003
- data.push({ d, icon });
10004
- }
10005
- // The charts' images come asynchronously (an SVG decodes as an image):
10006
- // until every chart of this view has its image the previous complete
10007
- // drawing stays, then the new one replaces it in one piece — as flat
10008
- // shows a theme once it is drawn. (Drawing the ready part meanwhile,
10009
- // and redrawing batch by batch as images came, left the map on an
10010
- // incomplete or old state after many zooms and pans.)
10011
- if (glDebug()) console.info(`[ixmaps-gl debug] ${themeIdOf(this)}: zoom ${mapLibreToFlatZoom(liveZoom).toFixed(2)}, ${combined.length} charts, ${data.length} ready, ${pending} pending${this._viewMoving ? ' (moving)' : ''}`);
10012
- if (pending) {
10013
- this._userChartWaiting = true;
10014
- return this._userChartLastLayers || [];
10015
- }
10016
- this._userChartWaiting = false;
10017
- if (!data.length) return (this._userChartLastLayers = []);
10018
- // the icon texture is built here, from the chart canvases, and given
10019
- // to deck.gl whole (iconAtlas + iconMapping): deck.gl has nothing to
10020
- // load, and the texture is exactly this view's charts
10021
- const atlas = this._userChartAtlas(data.map(e => e.icon));
10022
- this._userChartLastLayers = [new IconLayer({
10433
+ const chart = this._userChartSvg(draw, name, theme, flag, d, value, cat, size, sizeValueOf(d));
10434
+ if (!chart) continue;
10435
+ html += chart.markup;
10436
+ // the chart's own defs (gradients) go to the pane once per id —
10437
+ // several charts share one (pinnacle's per-color gradient)
10438
+ for (const id of chart.ids) {
10439
+ if (defsSeen.has(id)) continue;
10440
+ defsSeen.add(id);
10441
+ const def = this._userChartGroup.ownerDocument.getElementById(id);
10442
+ if (def) chartHost.defs.appendChild(def.cloneNode(true));
10443
+ }
10444
+ items.push({ d, coords: d.geometry.coordinates, ox: chart.ox, oy: chart.oy, s: pxPerUnit,
10445
+ r: Math.max(chart.vw, chart.vh) * pxPerUnit / 2 });
10446
+ }
10447
+ if (glDebug()) console.info(`[ixmaps-gl debug] ${themeIdOf(this)}: zoom ${mapLibreToFlatZoom(liveZoom).toFixed(2)}, ${combined.length} charts, ${items.length} drawn${this._viewMoving ? ' (moving)' : ''}`);
10448
+ if (!items.length) {
10449
+ if (this._chartPaneGroup) this._chartPaneGroup.remove();
10450
+ this._chartPaneGroup = null;
10451
+ this._chartItems = null;
10452
+ return (this._userChartLastLayers = []);
10453
+ }
10454
+ if (this._chartPaneGroup) this._chartPaneGroup.remove();
10455
+ this._chartPaneGroup = document.createElementNS(SVG_NS, 'g');
10456
+ chartHost.pane.appendChild(this._chartPaneGroup);
10457
+ this._chartPaneGroup.innerHTML = html;
10458
+ const els = this._chartPaneGroup.children;
10459
+ for (let i = 0; i < items.length; i++) items[i].el = els[i];
10460
+ this._chartItems = items;
10461
+ chartHost.runtimes.add(this);
10462
+ chartHost.update();
10463
+ // picking: a transparent circle per chart — the VISIBLE chart is the
10464
+ // live SVG in the pane; this deck layer only serves hover, click and
10465
+ // the tooltips (pickedFeature unwraps the {d, icon} entry shape)
10466
+ this._userChartLastLayers = [new ScatterplotLayer({
10023
10467
  id: `ix-bubbles-${this.name}`,
10024
- data, pickable: true,
10025
- iconAtlas: atlas.canvas, iconMapping: atlas.mapping,
10468
+ data: items.map(it => ({ d: it.d, icon: true, r: it.r })),
10469
+ pickable: true,
10026
10470
  getPosition: e => e.d.geometry.coordinates,
10027
- getIcon: e => e.icon.id,
10028
- getSize: e => e.icon.unitHeight * pxPerUnit,
10029
- sizeUnits: 'pixels',
10030
- billboard: true,
10031
- // no mipmaps: the charts are drawn at their size on screen
10032
- textureParameters: { minFilter: 'linear', magFilter: 'linear' },
10471
+ getRadius: e => Math.max(2, e.r),
10472
+ radiusUnits: 'pixels', radiusMinPixels: 1,
10473
+ stroked: false, filled: true, getFillColor: [0, 0, 0, 0],
10033
10474
  parameters: ICON_LAYER_GLOBE_PARAMETERS
10034
10475
  })];
10035
10476
  return this._userChartLastLayers;
10036
10477
  }
10037
- // one texture for a view's charts: rows of images, 2048 px wide; the
10038
- // same set of charts keeps its texture
10039
- _userChartAtlas(icons) {
10040
- const ids = icons.map(i => i.id);
10041
- const sig = [...new Set(ids)].sort().join('\n');
10042
- if (this._userChartAtlasCache && this._userChartAtlasCache.sig === sig) return this._userChartAtlasCache;
10043
- const W = 2048, PAD = 2, mapping = {}, seen = new Set(), place = [];
10044
- let x = 0, y = 0, rowH = 0;
10045
- for (const icon of icons) {
10046
- if (seen.has(icon.id)) continue;
10047
- seen.add(icon.id);
10048
- if (x + icon.width + PAD > W) { x = 0; y += rowH + PAD; rowH = 0; }
10049
- place.push([icon, x, y]);
10050
- mapping[icon.id] = { x, y, width: icon.width, height: icon.height, anchorX: icon.anchorX, anchorY: icon.anchorY, mask: false };
10051
- x += icon.width + PAD; rowH = Math.max(rowH, icon.height);
10052
- }
10053
- const canvas = document.createElement('canvas');
10054
- canvas.width = W; canvas.height = Math.max(1, y + rowH);
10055
- const ctx = canvas.getContext('2d');
10056
- for (const [icon, px, py] of place) ctx.drawImage(icon.canvas, px, py);
10057
- this._userChartAtlasCache = { sig, canvas, mapping };
10058
- return this._userChartAtlasCache;
10059
- }
10060
- // user chart images become ready one by one: one redraw once they are
10061
- _scheduleUserChartRedraw() {
10062
- if (this._userChartRedrawTimer) return;
10063
- this._userChartRedrawTimer = setTimeout(() => {
10064
- this._userChartRedrawTimer = null;
10065
- if (this._userChartWaiting && this._triggerRedraw) this._triggerRedraw();
10066
- }, 30);
10067
- }
10068
- // The chart is drawn once (its SVG kept in _userChartSvgs) and rasterised
10069
- // at its size on screen (× devicePixelRatio, in steps of USER_CHART_RASTER
10070
- // px) onto a canvas (icon.canvas, once the SVG image has decoded): one
10071
- // image per chart at a fixed 256 px made the texture of a few hundred
10072
- // charts outgrow the GPU's texture size — WebGL errors, and deck.gl
10073
- // frames that failed, leaving the map un-updated after a zoom.
10074
- _userChartIcon(draw, name, theme, flag, d, value, cat, size, nSize, pxPerUnit, maxRasterPx = Infinity) {
10075
- const base = `user|${name}|${cat}|${Math.round(value * 100) / 100}|${Math.round(size * 2) / 2}|${flag}`;
10478
+ // the chart's SVG, cached by its draw parameters — a failing draw
10479
+ // (undefined) is tried again on the next redraw (null: draws nothing)
10480
+ _userChartSvg(draw, name, theme, flag, d, value, cat, size, nSize) {
10481
+ // the title is part of the markup now (item.szTitle) — items sharing
10482
+ // value/size/class but not a title must not share a cache entry
10483
+ const raw = d.properties.raw || d.properties;
10484
+ const titleField = this.style.titlefield || this.binding.title;
10485
+ const title = titleField && raw && raw[titleField] != null ? String(raw[titleField]) : undefined;
10486
+ const base = `user|${name}|${cat}|${Math.round(value * 100) / 100}|${Math.round(size * 2) / 2}|${flag}|${title || ''}`;
10076
10487
  if (!this._userChartSvgs) this._userChartSvgs = new Map();
10077
- if (!this._userChartRasters) this._userChartRasters = new Map();
10078
10488
  let chart = this._userChartSvgs.get(base);
10079
10489
  if (chart === undefined) {
10080
10490
  chart = this._drawUserChartSvg(draw, name, theme, flag, d, value, cat, size, nSize);
10081
- if (chart === undefined) return null; // a failing draw is tried again
10491
+ if (chart === undefined) return null;
10082
10492
  if (this._userChartSvgs.size >= ICON_CACHE_MAX) this._userChartSvgs.delete(this._userChartSvgs.keys().next().value);
10083
10493
  this._userChartSvgs.set(base, chart);
10084
10494
  }
10085
- if (!chart) return null;
10086
- const dpr = (global.devicePixelRatio || 1);
10087
- const want = Math.max(chart.vw, chart.vh) * pxPerUnit * dpr;
10088
- const fits = USER_CHART_RASTER.filter(r => r <= maxRasterPx);
10089
- const steps = fits.length ? fits : USER_CHART_RASTER.slice(0, 1);
10090
- const px = steps.find(r => r >= want) || steps[steps.length - 1];
10091
- const key = base + '|' + px;
10092
- const rasters = this._userChartRasters;
10093
- if (rasters.has(key)) {
10094
- // least recently used goes first
10095
- const hit = rasters.get(key);
10096
- rasters.delete(key); rasters.set(key, hit);
10097
- return hit;
10098
- }
10099
- const s = px / Math.max(chart.vw, chart.vh);
10100
- const w = Math.max(1, Math.round(chart.vw * s)), h = Math.max(1, Math.round(chart.vh * s));
10101
- const icon = { canvas: null, width: w, height: h, anchorX: (chart.ox - chart.vx) * s, anchorY: (chart.oy - chart.vy) * s, id: key, unitHeight: chart.vh };
10102
- // a chart whose image fails is left out (not waited for)
10103
- const failed = e => {
10104
- icon.failed = true;
10105
- if (!this._userChartImageError) console.warn(`[ixmaps-gl] USER chart ${name}: chart image failed`, e);
10106
- this._userChartImageError = e || true;
10107
- this._scheduleUserChartRedraw();
10108
- };
10109
- // the SVG goes through a canvas: deck.gl blends an SVG image as decoded
10110
- // (premultiplied), which darkened half-transparent fills (a
10111
- // fadenegative arrow turned grey)
10112
- const img = new Image();
10113
- img.onerror = failed;
10114
- img.onload = () => {
10115
- try {
10116
- const canvas = document.createElement('canvas');
10117
- canvas.width = w; canvas.height = h;
10118
- canvas.getContext('2d').drawImage(img, 0, 0, w, h);
10119
- icon.canvas = canvas;
10120
- } catch (e) { failed(e); return; }
10121
- this._scheduleUserChartRedraw();
10122
- };
10123
- img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(chart.svg.replace('__W__', w).replace('__H__', h));
10124
- if (rasters.size >= ICON_CACHE_MAX) rasters.delete(rasters.keys().next().value);
10125
- rasters.set(key, icon);
10126
- return icon;
10495
+ return chart || null;
10127
10496
  }
10128
10497
  // the page's chart function draws one chart into the hidden SVG: its
10129
10498
  // markup and box (chart units), null when it draws nothing, undefined
@@ -10134,11 +10503,19 @@
10134
10503
  const color = this._userChartColor(cat);
10135
10504
  const raw = d.properties.raw || d.properties;
10136
10505
  const label = this.binding.title && raw && raw[this.binding.title] != null ? String(raw[this.binding.title]) : undefined;
10506
+ // flat's chart title (style.titlefield, maptheme's item.szTitle) —
10507
+ // pinnacleChart draws it below the value text (chart.js 204-216)
10508
+ const titleField = this.style.titlefield || this.binding.title;
10509
+ const title = titleField && raw && raw[titleField] != null ? String(raw[titleField]) : undefined;
10137
10510
  let chart = null;
10138
10511
  try {
10139
10512
  const ret = draw.call(global.ixmaps, document, {
10140
10513
  target: g, theme, value, values: [value], size, maxSize: USER_CHART_MAX_SIZE, color, class: cat, flag,
10141
- item: { szColor: color, szLabel: label, nCount: d.properties.point_count || 1, nSize }, dbRecord: raw
10514
+ // flat hands the chart the item's color CLASS object (maptheme's
10515
+ // ColorScheme entry) — pinnacleChart reads ccolor.lowColor for its
10516
+ // VALUES label fill (usercharts/d3/chart.js 199)
10517
+ ccolor: { szColor: color, lowColor: color, highColor: color },
10518
+ item: { szColor: color, szLabel: label, szTitle: title, nCount: d.properties.point_count || 1, nSize }, dbRecord: raw
10142
10519
  });
10143
10520
  const bb = ret ? g.getBBox() : null;
10144
10521
  if (bb && bb.width > 0 && bb.height > 0) {
@@ -10150,7 +10527,7 @@
10150
10527
  const vx = bb.x - pad, vy = bb.y - pad, vw = bb.width + 2 * pad, vh = bb.height + 2 * pad;
10151
10528
  // the chart's origin (0,0 — or the point flat moves it to, ret) sits at the map position
10152
10529
  const ox = (ret && ret.x) || 0, oy = (ret && ret.y) || 0;
10153
- chart = { vx, vy, vw, vh, ox, oy,
10530
+ chart = { vx, vy, vw, vh, ox, oy, markup, ids: [...ids],
10154
10531
  svg: `<svg xmlns="${SVG_NS}" width="__W__" height="__H__" viewBox="${vx} ${vy} ${vw} ${vh}"><defs>${defs}</defs>${markup}</svg>` };
10155
10532
  }
10156
10533
  } catch (e) {
@@ -10607,7 +10984,7 @@
10607
10984
  if (userLayers) return layers.concat(userLayers);
10608
10985
  }
10609
10986
  if (this.flags.has('LABEL')) return layers.concat(this._buildLabelChartLayers(combined, zoom, liveZoom, sizeValueOf));
10610
- if (flatFlag(this.flags, 'SEQUENCE') && this.flags.has('CATEGORICAL') && this.flags.has('AGGREGATE')) {
10987
+ if ((flatFlag(this.flags, 'SEQUENCE') && this.flags.has('CATEGORICAL') && this.flags.has('AGGREGATE')) || (this._multiFields && this._isMultiFieldSequence())) {
10611
10988
  return layers.concat(this._buildSequenceChartLayers(combined, zoom, liveZoom));
10612
10989
  }
10613
10990
  const fillOpacity = styleNum(this.style.fillopacity) || 0.85;
@@ -10645,7 +11022,7 @@
10645
11022
  id: `ix-symbolshadow-${this.name}-g${this._iconGeneration}`,
10646
11023
  data: combined, pickable: false,
10647
11024
  getPosition: d => d.geometry.coordinates,
10648
- getIcon: d => this._getShadowIcon(radiusOf(d), blurPx, d.properties.counts ? 'circle' : this._resolveSymbolShape(d.properties)),
11025
+ getIcon: d => this._getShadowIcon(radiusOf(d), blurPx, d.properties.counts && !Array.isArray(this.style.symbols) ? 'circle' : this._resolveSymbolShape(d.properties)),
10649
11026
  getSize: d => radiusOf(d) * 3, // disc = 2/3 of the icon → its diameter is 2r
10650
11027
  getPixelOffset: alignOf.active ? d => { const o = alignOf(d); return [o[0] + dx, o[1] + dy]; } : [dx, dy],
10651
11028
  getColor: d => {
@@ -10670,7 +11047,7 @@
10670
11047
  id: `ix-glow-${this.name}-g${this._iconGeneration}`,
10671
11048
  data: combined, pickable: false,
10672
11049
  getPosition: d => d.geometry.coordinates,
10673
- getIcon: d => this._getGlowIcon(this.categoryColorsRgb[d.properties.counts ? dominant(this._groupIsolation(d) ? d.properties.counts.map((c, i) => (this._markedClasses.has(i) ? c : 0)) : d.properties.counts) : d.properties.cat]),
11050
+ getIcon: d => this._getGlowIcon((this.categoryColorsRgb || [])[d.properties.counts ? dominant(this._groupIsolation(d) ? d.properties.counts.map((c, i) => (this._markedClasses.has(i) ? c : 0)) : d.properties.counts) : d.properties.cat]),
10674
11051
  getSize: d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue) * (d.properties.counts ? 9 : 11),
10675
11052
  getColor: d => [255, 255, 255, this._iconAlpha(d)],
10676
11053
  ...(alignOf.active ? { getPixelOffset: alignOf } : {}),
@@ -10717,15 +11094,29 @@
10717
11094
  // Box and title are removed outside boxupper/boxlower.
10718
11095
  // Flat draws box and title in the chart group, which is scaled by
10719
11096
  // style.scale: the units below are u · scale. Its size measure W is
10720
- // the chart's width (2r; with MULTIQUAD the width of the whole grid
10721
- // at the position, the box enclosing all of it); flat's chart size
10722
- // is 30 units.
11097
+ // the width of the drawn chart (flat's getBox of the chart group,
11098
+ // 18785): the symbol (with MULTIQUAD the whole grid at the position,
11099
+ // the box enclosing all of it), its value text and its label
11100
+ // (chartItemExtent); flat's chart size is 30 units.
11101
+ // DTEXT: the title font follows the chart, max(0.9u, min(W/5,
11102
+ // 14u / scale)) (18815) — measured on the fire areas page's SIF chart
11103
+ // (W 362 units → font 72.35).
10723
11104
  const boxShown = flatFlag(this.flags, 'BOX') && !boxHiddenByScale(this.style, liveZoom);
10724
11105
  const circular = flatFlag(this.flags, 'CIRCULAR');
10725
11106
  const unitPx = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(this.style.scale) || 1);
10726
11107
  const boxMarginU = styleNum(this.style.boxmargin) || 2;
10727
11108
  const titleShown = boxShown && flatFlag(this.flags, 'TITLE') && !!this.binding.title;
10728
- const titleFontPx = 5 * unitPx * (styleNum(this.style.textscale) || styleNum(this.style.valuescale) || 1);
11109
+ const titleScale = styleNum(this.style.textscale) || styleNum(this.style.valuescale) || 1;
11110
+ const titleFontOf = W => (flatFlag(this.flags, 'DTEXT')
11111
+ ? Math.max(0.9 * unitPx, Math.min(W / 5, 14 * unitPx / (styleNum(this.style.scale) || 1)))
11112
+ : 5 * unitPx) * titleScale;
11113
+ // the chart's value text and label (see VALUES and LABEL below), shared
11114
+ // with the box: flat's box encloses whatever the chart group draws
11115
+ const valuesShown = this.flags.has('VALUES') && !valuesHiddenByScale(this.style, zoom);
11116
+ const valueTextOf = valuesShown ? this._chartValueTextFn() : null;
11117
+ const labelShown = !!this.style.labelfield && !flatFlag(this.flags, 'NORMSIZE') && !flatFlag(this.flags, 'ZOOM')
11118
+ && !labelHiddenByScale(this.style, zoom);
11119
+ const labelOf = labelShown ? this._chartLabelFn(unitPx) : null;
10729
11120
  // flat: fill-opacity (boxopacity || 1) on the raw style value — the
10730
11121
  // usual string "0" is transparent; typed here, a given 0 is too
10731
11122
  const boxOpacity = isNaN(styleNum(this.style.boxopacity)) ? 1 : styleNum(this.style.boxopacity);
@@ -10736,9 +11127,13 @@
10736
11127
  const r = valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
10737
11128
  const ext = multi ? multi.extent.get(d) : [0, 0, 0, 0];
10738
11129
  const chart = [ext[0] - r, ext[1] - r, ext[2] + r, ext[3] + r];
11130
+ const grow = e => { if (!e) return; chart[0] = Math.min(chart[0], e[0]); chart[1] = Math.min(chart[1], e[1]); chart[2] = Math.max(chart[2], e[2]); chart[3] = Math.max(chart[3], e[3]); };
11131
+ if (valueTextOf && !d.properties.counts) { const v = valueTextOf(d, r); if (v) grow(v.extent); }
11132
+ if (labelOf) { const l = labelOf(d, r); if (l) grow(l.extent); }
10739
11133
  const w = chart[2] - chart[0];
10740
11134
  const margin = Math.min(2 * boxMarginU * unitPx, boxMarginU * w / 30);
10741
11135
  const boxR = circular ? r * 1.1 + margin : r;
11136
+ const titleFontPx = titleFontOf(w);
10742
11137
  let title = null;
10743
11138
  if (titleShown) {
10744
11139
  const text = this._itemTitle(d.properties);
@@ -10754,7 +11149,7 @@
10754
11149
  const anchor = circular ? 'middle' : (alignRight ? 'end' : 'start');
10755
11150
  const x0 = anchor === 'middle' ? x - width / 2 : anchor === 'end' ? x - width : x;
10756
11151
  // arial: ascent 0.905, descent 0.212 of the font size
10757
- title = { text: lines.join('\n'), x, anchor, top: base - 0.8 * f,
11152
+ title = { text: lines.join('\n'), x, anchor, top: base - 0.8 * f, f,
10758
11153
  extent: [x0, base - 0.905 * f, x0 + width, base + (lines.length - 1) * f + 0.212 * f] };
10759
11154
  }
10760
11155
  }
@@ -10859,8 +11254,8 @@
10859
11254
  data: combined, pickable: true,
10860
11255
  getPosition: d => d.geometry.coordinates,
10861
11256
  getIcon: d => d.properties.counts
10862
- ? this._buildBubbleIcon(d.properties.counts, this.categoryColorsRgb, this._groupIsolation(d))
10863
- : this._buildSingleIcon(this.categoryColorsRgb[d.properties.cat], fillOpacity, this._resolveSymbolShape(d.properties), singleBorderColorRgb, singleBorderWidthPx),
11257
+ ? this._buildBubbleIcon(d.properties.counts, this.categoryColorsRgb, this._groupIsolation(d), Array.isArray(this.style.symbols) ? this.style.symbols : null)
11258
+ : this._buildSingleIcon((this.categoryColorsRgb || [])[d.properties.cat], fillOpacity, this._resolveSymbolShape(d.properties), singleBorderColorRgb, singleBorderWidthPx),
10864
11259
  getSize: d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue) * 2,
10865
11260
  // group icons bake a fixed 0.9 alpha; an explicit fillopacity scales it
10866
11261
  // so a near-transparent theme doesn't show opaque 1-px-cell clusters
@@ -10878,7 +11273,7 @@
10878
11273
  data: titleData, pickable: false,
10879
11274
  getPosition: b => b.d.geometry.coordinates,
10880
11275
  getText: b => b.title.text,
10881
- getSize: titleFontPx,
11276
+ getSize: b => b.title.f,
10882
11277
  sizeUnits: 'pixels',
10883
11278
  fontFamily: 'arial',
10884
11279
  characterSet: 'auto',
@@ -10903,18 +11298,20 @@
10903
11298
  // computed font size would be sub-pixel are dropped rather than
10904
11299
  // rendered at getSize: 0, matching the real engine's own "too
10905
11300
  // small to bother" gate.
10906
- if (this.flags.has('VALUES') && !valuesHiddenByScale(this.style, zoom)) {
11301
+ if (valuesShown) {
10907
11302
  const valueScale = styleNum(this.style.valuescale) || 1;
10908
11303
  const textOpts = this._valueTextOpts();
11304
+ const pointValueOf = valueTextOf || this._chartValueTextFn();
10909
11305
 
10910
11306
  const pointLabels = individual.reduce((out, d) => {
10911
11307
  const radius = valueRadius(d.properties.value, liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
10912
- // flat's VALUES label prints the value (nValuesA), not the size
10913
- const text = flatValueText(d.properties.raw, this.binding.title && d.properties.raw ? d.properties.raw[this.binding.title] : undefined,
10914
- d.properties.classValue !== undefined ? d.properties.classValue : d.properties.value, this.style, this.flags, textOpts);
10915
- const fontSize = valuesFontSizePx(radius, text, valueScale);
11308
+ // flat's VALUES label prints the value (nValuesA), not the size;
11309
+ // its font by flat's chart branch (_chartValueTextFn)
11310
+ const { text, fontSize, bold, baseline } = pointValueOf(d, radius);
10916
11311
  if (fontSize > VALUES_MIN_FONT_PX) {
10917
- out.push({ geometry: d.geometry, text, fontSize, color: resolveTextColor(this.style, contrastTextColor(this.categoryColorsRgb[d.properties.cat])), pixelOffset: alignOf(d) });
11312
+ const o = alignOf(d);
11313
+ out.push({ geometry: d.geometry, text, fontSize, bold, color: resolveTextColor(this.style, contrastTextColor((this.categoryColorsRgb || [])[d.properties.cat])),
11314
+ pixelOffset: baseline ? [o[0], o[1] + baseline - 0.35 * fontSize] : o });
10918
11315
  }
10919
11316
  return out;
10920
11317
  }, []);
@@ -10945,7 +11342,7 @@
10945
11342
  if (fontSize > VALUES_MIN_FONT_PX) {
10946
11343
  out.push({
10947
11344
  geometry: d.geometry, text, fontSize,
10948
- color: resolveTextColor(this.style, contrastTextColor(this.categoryColorsRgb[p.i])),
11345
+ color: resolveTextColor(this.style, contrastTextColor((this.categoryColorsRgb || [])[p.i])),
10949
11346
  pixelOffset: [offsets[i].x * fitScale * pxPerCanvasUnit + alignOf(d)[0], offsets[i].y * fitScale * pxPerCanvasUnit + alignOf(d)[1]]
10950
11347
  });
10951
11348
  }
@@ -10965,10 +11362,55 @@
10965
11362
  getTextAnchor: 'middle',
10966
11363
  getAlignmentBaseline: 'center'
10967
11364
  };
10968
- if (pointLabels.length) layers.push(new TextLayer({ id: `ix-points-values-${this.name}`, data: pointLabels, ...(alignOf.active ? { getPixelOffset: d => d.pixelOffset } : {}), ...textLayerCommonProps }));
11365
+ // flat's SYMBOL branch writes its values in normal weight
11366
+ if (pointLabels.length) layers.push(new TextLayer({ id: `ix-points-values-${this.name}`, data: pointLabels, getPixelOffset: d => d.pixelOffset, ...textLayerCommonProps,
11367
+ fontWeight: pointLabels[0].bold === false ? 'normal' : 'bold' }));
10969
11368
  if (groupLabels.length) layers.push(new TextLayer({ id: `ix-cluster-values-${this.name}`, data: groupLabels, getPixelOffset: d => d.pixelOffset, ...textLayerCommonProps }));
10970
11369
  }
10971
11370
 
11371
+ // LABEL: flat's chart label (maptheme.js 24533-24590) — a small text
11372
+ // below-right of each chart, the .style({labelfield}) record value
11373
+ // (the fire page's labelfield ANNO: the fire year "2 014"), black at
11374
+ // 0.7 opacity on a white 0.4-opacity rounded background box, font
11375
+ // 5·unit·textscale. Gated by labelupper/labellower (flat's own test
11376
+ // compares <= against BOTH), suppressed by NORMSIZE/ZOOM, a numeric
11377
+ // label formatted like a value (valuedecimals) plus labelunits.
11378
+ if (labelShown) {
11379
+ const labelFontPx = 5 * unitPx * (styleNum(this.style.textscale) || 1);
11380
+ if (labelFontPx > VALUES_MIN_FONT_PX) {
11381
+ const labelData = [];
11382
+ for (const d of individual) {
11383
+ if (!d.properties || !d.properties.raw) continue;
11384
+ // _chartLabelFn: text, and its place below-right of the chart
11385
+ const l = labelOf(d, radiusOf(d));
11386
+ if (!l) continue;
11387
+ const o = alignOf(d);
11388
+ // drawn centered: flat's baseline sits 0.35 font below the
11389
+ // middle of its background box (-0.95 .. +0.25 font)
11390
+ labelData.push({ geometry: d.geometry, text: l.text, fontSize: l.fontSize,
11391
+ pixelOffset: [l.offset[0] + o[0], l.offset[1] - 0.35 * l.fontSize + o[1]] });
11392
+ }
11393
+ if (labelData.length) layers.push(new TextLayer({
11394
+ id: `ix-labels-${this.name}`,
11395
+ data: labelData, pickable: false,
11396
+ getPosition: d => d.geometry.coordinates,
11397
+ getText: d => d.text,
11398
+ getSize: d => d.fontSize,
11399
+ getColor: [0, 0, 0, Math.round(0.7 * 255)],
11400
+ sizeUnits: 'pixels',
11401
+ fontFamily: 'arial',
11402
+ characterSet: 'auto',
11403
+ getTextAnchor: 'middle',
11404
+ getAlignmentBaseline: 'center',
11405
+ getPixelOffset: d => d.pixelOffset,
11406
+ background: true,
11407
+ getBackgroundColor: [255, 255, 255, Math.round(0.4 * 255)],
11408
+ backgroundPadding: [labelFontPx * 0.25, labelFontPx * 0.1],
11409
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
11410
+ }));
11411
+ }
11412
+ }
11413
+
10972
11414
  return layers;
10973
11415
  }
10974
11416
 
@@ -11002,12 +11444,25 @@
11002
11444
  // - VALUES: each part's value (COUNT: its records; valuefield: the class name)
11003
11445
  // - align / offsets as the SYMBOL branch: half size the normal radius
11004
11446
  // A part's pick object carries its chart's properties (tooltip).
11447
+ // The same for a per-item chart over several value fields
11448
+ // (_isMultiFieldSequence): a part per field, max value the biggest
11449
+ // field value of any record (normalsizevalue overrides), the part
11450
+ // radius value-scaled as above; a colorscheme "none" part is only its
11451
+ // outline. RINGS nests the parts (ringsLayout); CENTERVALUES prints
11452
+ // only the first part's value (flat: nClass 0, maptheme.js 22216).
11005
11453
  _buildSequenceChartLayers(charts, zoom, liveZoom) {
11006
11454
  const st = this.style;
11007
- const seqMax = this._sequenceMaxValue(zoom);
11008
- const partRadius = v => valueRadius(v, liveZoom, st, this.mapOptions, this.flags, seqMax);
11455
+ const multi = !!this._multiFields && this._isMultiFieldSequence();
11456
+ const seqMax = multi ? (styleNum(st.normalsizevalue) || this._multiFieldPartMax) : this._sequenceMaxValue(zoom);
11009
11457
  const unit = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(st.scale) || 1);
11010
11458
  const normalR = NORMAL_RADIUS_PX * unit;
11459
+ const sizePow = resolveSizePow(st, this.flags);
11460
+ const partRadius = multi
11461
+ ? v => Math.max(VALUE_RADIUS_MIN, normalR * Math.pow(Math.abs(v) / seqMax, 1 / sizePow))
11462
+ : v => valueRadius(v, liveZoom, st, this.mapOptions, this.flags, seqMax);
11463
+ const rings = flatFlag(this.flags, 'RINGS');
11464
+ const centerValues = flatFlag(this.flags, 'CENTERVALUES');
11465
+ const noFill = this._partNoFill || new Set();
11011
11466
  const nCats = this.categoryLabels ? this.categoryLabels.length : 1;
11012
11467
  const marking = this._markedClasses.size && !this._onMarksChanged;
11013
11468
  const gray = marking && String(st.evidence || 'isolate') === 'isolate_gray';
@@ -11016,11 +11471,13 @@
11016
11471
  const borderRgb = st.linecolor && styleLineColor(st.linecolor) !== 'none' ? hexOrNamedToRgb(styleLineColor(st.linecolor)) : null;
11017
11472
  const lineWidth = styleNum(st.linewidth) || 1;
11018
11473
  const symbols = Array.isArray(st.symbols) ? st.symbols : null;
11019
- const shapeOf = i => { const sh = symbols && (symbols[i] || symbols[0]); return /^(circle|square|diamond|triangle)$/.test(sh) ? sh : 'circle'; };
11474
+ const shapeOf = i => { const sh = symbols && (symbols[i] || symbols[0]); return normalizeSymbolShape(sh); };
11020
11475
  const colorOf = i => this.categoryColorsRgb[i] || [128, 128, 128];
11021
11476
  const parts = [], glows = [];
11022
11477
  for (const d of charts) {
11023
- let layout = sequenceLayout(d.properties.counts, this.flags, partRadius, { maxRadius: normalR, maxValue: seqMax, nParts: nCats, rangeScale: styleNum(st.rangescale) || 0 });
11478
+ const counts = d.properties.parts || d.properties.counts;
11479
+ let layout = rings ? ringsLayout(counts, partRadius)
11480
+ : sequenceLayout(counts, this.flags, partRadius, { maxRadius: normalR, maxValue: seqMax, nParts: nCats, rangeScale: styleNum(st.rangescale) || 0 });
11024
11481
  if (!layout.length) continue;
11025
11482
  // GLOW belongs to the first part (sorted) or to every part, and
11026
11483
  // hides with it when its class is not marked
@@ -11074,7 +11531,7 @@
11074
11531
  data: parts, pickable: true,
11075
11532
  getPosition: d => d.geometry.coordinates,
11076
11533
  // the stroke, in the icon raster's pixels (the icon is drawn at 2r)
11077
- getIcon: d => this._buildSingleIcon(colorOf(d.part.i), fillOpacity, shapeOf(d.part.i), borderRgb,
11534
+ getIcon: d => this._buildSingleIcon(colorOf(d.part.i), noFill.has(d.part.i) ? 0 : fillOpacity, shapeOf(d.part.i), borderRgb,
11078
11535
  borderRgb ? Math.round(lineWidth * unit * Math.min(2 * d.part.r / normalR, 0.2) * BUBBLE_ICON_SIZE / Math.max(1, 2 * d.part.r) * 4) / 4 : 0),
11079
11536
  getSize: d => d.part.r * 2,
11080
11537
  getPixelOffset: offsetOf,
@@ -11087,20 +11544,22 @@
11087
11544
  // class name — formatted with valuedecimals (else none) plus the unit;
11088
11545
  // font min(1, 3.2 / L) · r · valuescale, L the length of the theme's
11089
11546
  // biggest value text (szMaxText), normal weight, in valuecolor /
11090
- // textcolor, else ChartColors.textColor of the part color
11091
- if (this.flags.has('VALUES') && !valuesHiddenByScale(st, zoom)) {
11547
+ // textcolor, else ChartColors.textColor of the part color.
11548
+ // CENTERVALUES: only on the first part (class 0), its own value
11549
+ if ((this.flags.has('VALUES') || centerValues) && !valuesHiddenByScale(st, zoom)) {
11092
11550
  const valueScale = styleNum(st.valuescale) || 1;
11093
11551
  const { noBreaks } = this._valueTextOpts();
11094
11552
  const units = st.units ? String(st.units) : '';
11095
11553
  const unit = units ? (units[0] === '.' ? '' : ' ') + units : '';
11096
11554
  const unitText = unit.length <= 5 ? unit : '';
11097
- const dec = styleNum(st.valuedecimals) || 0;
11555
+ const dec = explicitValueDecimals(st) || 0;
11098
11556
  const fmt = v => flatGroupedValue(v, dec, noBreaks) + unitText;
11099
11557
  const maxLen = fmt(seqMax).length;
11100
11558
  const textOverride = st.valuecolor || st.textcolor;
11101
11559
  const counting = flatFlag(this.flags, 'COUNT');
11102
11560
  const labels = [];
11103
11561
  parts.forEach(d => {
11562
+ if (centerValues && d.part.i !== 0) return;
11104
11563
  const rec = this._categoryValueRecord(d.part.i);
11105
11564
  const name = rec ? String(rec[st.valuefield]) : null;
11106
11565
  const t = counting && d.properties.recordCounts ? d.properties.recordCounts[d.part.i] : d.part.v;
@@ -11160,6 +11619,69 @@
11160
11619
  // the class name (the records' own valuefield value) — otherwise none
11161
11620
  _categoryValueRecord(i) { return categoryValueRecord(this.style, this.binding, this.flags, this.categoryLabels, i); }
11162
11621
 
11622
+ // A single chart's VALUES text: (item, its radius px) → { text,
11623
+ // fontSize, extent } — extent [x0, y0, x1, y1] in px around the chart
11624
+ // center (y down), as the box encloses it. The font by flat's branch:
11625
+ // - SYMBOL (maptheme.js 22200-22202, 21705-21708): min(1, 3.2 / L)
11626
+ // (hexagon 2.7) · r · valuescale, L the length of the theme's biggest
11627
+ // value text (szMaxText: the max value formatted, plus the unit up to
11628
+ // 5 characters, 21479) — normal weight, baseline 0.33 font below the
11629
+ // center (fire areas SIF chart: r 60, valuescale 3, "7 242 ha" → 72)
11630
+ // - BUBBLE: valuesFontSizePx, bold, centered
11631
+ _chartValueTextFn() {
11632
+ const valueScale = styleNum(this.style.valuescale) || 1;
11633
+ const textOpts = this._valueTextOpts();
11634
+ const symbol = flatChartBranch(this.flags) === 'symbol';
11635
+ let maxLen = 1;
11636
+ if (symbol) {
11637
+ const vMax = this._valueMax !== undefined ? this._valueMax : (this._valueTextRange || [])[1];
11638
+ if (isFinite(vMax)) maxLen = Math.max(1, flatValueText(null, null, vMax, this.style, this.flags, textOpts).length);
11639
+ }
11640
+ return (d, r) => {
11641
+ const p = d.properties;
11642
+ const text = flatValueText(p.raw, this.binding.title && p.raw ? p.raw[this.binding.title] : undefined,
11643
+ p.classValue !== undefined ? p.classValue : p.value, this.style, this.flags, textOpts);
11644
+ if (!symbol) {
11645
+ const fontSize = valuesFontSizePx(r, text, valueScale);
11646
+ const tw = titleTextWidth(text, fontSize);
11647
+ return { text, fontSize, bold: true, extent: [-tw / 2, -fontSize / 2, tw / 2, fontSize / 2] };
11648
+ }
11649
+ const k = this._resolveSymbolShape(p) === 'hexagon' ? 2.7 : 3.2;
11650
+ const fontSize = Math.min(1, k / maxLen) * r * valueScale;
11651
+ const tw = titleTextWidth(text, fontSize);
11652
+ return { text, fontSize, bold: false, baseline: 0.33 * fontSize,
11653
+ extent: [-tw / 2, (0.33 - 0.905) * fontSize, tw / 2, (0.33 + 0.212) * fontSize] };
11654
+ };
11655
+ }
11656
+
11657
+ // A single chart's LABEL (.style({labelfield}), maptheme.js 24533-24590):
11658
+ // (item, its radius px) → { text, fontSize, offset, extent } or null.
11659
+ // Font 5u · textscale; a number formatted like a value — valuedecimals
11660
+ // when set (flat's nValueDecimals is the raw style string, so "0"
11661
+ // counts), else 2 — plus labelunits. The background box is the wider of
11662
+ // the text and (length + 2) · font / 2, 1.2 font high from 0.95 font
11663
+ // above the baseline; the label sits at (width / 3, font / 3 + the
11664
+ // chart's anchor y: r + 5u). Fire areas page: "2 010" at (116.7, 193.3)
11665
+ // units, box 350 × 120.
11666
+ _chartLabelFn(unitPx) {
11667
+ const f = 5 * unitPx * (styleNum(this.style.textscale) || 1);
11668
+ const dec = explicitValueDecimals(this.style);
11669
+ const { noBreaks } = this._valueTextOpts();
11670
+ const units = String(this.style.labelunits || '');
11671
+ return (d, r) => {
11672
+ const raw = d.properties && d.properties.raw;
11673
+ const v = raw && raw[this.style.labelfield];
11674
+ let text = v == null || v === '' ? '' : String(v);
11675
+ if (!text) return null;
11676
+ const num = parseFloat(text.replace(',', '.'));
11677
+ text = (!isNaN(num) ? flatGroupedValue(num, dec != null ? dec : 2, noBreaks) : text) + units;
11678
+ const width = Math.max(titleTextWidth(text, f), (text.length + 2) * f * 0.5);
11679
+ const x = width / 3, y = f / 3 + r + 5 * unitPx;
11680
+ return { text, fontSize: f, offset: [x, y], width,
11681
+ extent: [x - width / 2, y - 0.95 * f, x + width / 2, y + 0.25 * f] };
11682
+ };
11683
+ }
11684
+
11163
11685
  // value-text options (flatValueText): flat's formatValue NOBREAKS for a
11164
11686
  // theme of values within 1000-3000, the max value for the decimals
11165
11687
  _valueTextOpts() {
@@ -11350,6 +11872,14 @@
11350
11872
  // DOWN, confirmed against a real ported page's own comment ("il motore
11351
11873
  // disegna il triangolo con la punta in basso" — "the engine draws the
11352
11874
  // triangle with the tip at the bottom").
11875
+ // flat's symbol shapes (maptheme.js 21625-21680). Geometry follows flat
11876
+ // where GL didn't have an established shape already (square, diamond,
11877
+ // triangle keep GL's own proportions — pages already shipped with them):
11878
+ // roundrect — rect 1.6r, rounded 0.3r; label — wide flat rect 1.6r×0.8r;
11879
+ // carot — flat's alias of diamond; hexagon — vertices (±r,0),(±r/2,±r·sin60°);
11880
+ // cross — flat's 8-arm path (A=0.4r, B=0.8r); empty — the circle
11881
+ // geometry, but _buildSingleIcon gives it flat's transparent fill +
11882
+ // gray stroke (fill-opacity:0, stroke #888888)
11353
11883
  function drawSymbolPath(ctx, shape, cx, cy, r) {
11354
11884
  switch (shape) {
11355
11885
  case 'square': {
@@ -11358,6 +11888,7 @@
11358
11888
  break;
11359
11889
  }
11360
11890
  case 'diamond':
11891
+ case 'carot': // flat: carot and diamond are the same rotated rect
11361
11892
  ctx.moveTo(cx, cy - r);
11362
11893
  ctx.lineTo(cx + r, cy);
11363
11894
  ctx.lineTo(cx, cy + r);
@@ -11368,10 +11899,58 @@
11368
11899
  ctx.lineTo(cx + r, cy - r * 0.6);
11369
11900
  ctx.lineTo(cx, cy + r);
11370
11901
  break;
11902
+ case 'roundrect': {
11903
+ const s = r * 0.8;
11904
+ if (ctx.roundRect) ctx.roundRect(cx - s, cy - s, s * 2, s * 2, r * 0.3);
11905
+ else ctx.rect(cx - s, cy - s, s * 2, s * 2);
11906
+ break;
11907
+ }
11908
+ case 'label': {
11909
+ const w = r * 0.8, h = r * 0.4;
11910
+ ctx.rect(cx - w, cy - h, w * 2, h * 2);
11911
+ break;
11912
+ }
11913
+ case 'hexagon': {
11914
+ const A = r / 2, B = r * Math.sin(Math.PI / 3);
11915
+ ctx.moveTo(cx - r, cy);
11916
+ ctx.lineTo(cx - A, cy - B);
11917
+ ctx.lineTo(cx + A, cy - B);
11918
+ ctx.lineTo(cx + r, cy);
11919
+ ctx.lineTo(cx + A, cy + B);
11920
+ ctx.lineTo(cx - A, cy + B);
11921
+ break;
11922
+ }
11923
+ case 'cross': {
11924
+ // flat's own path, vertex for vertex: a plus of arm thickness A
11925
+ // reaching to ±(B + A/2) (maptheme.js 21652-21655)
11926
+ const A = r * 0.4, B = r * 0.8, E = B + A / 2;
11927
+ ctx.moveTo(cx, cy - E);
11928
+ ctx.lineTo(cx + A / 2, cy - E);
11929
+ ctx.lineTo(cx + A / 2, cy - A / 2);
11930
+ ctx.lineTo(cx + E, cy - A / 2);
11931
+ ctx.lineTo(cx + E, cy + A / 2);
11932
+ ctx.lineTo(cx + A / 2, cy + A / 2);
11933
+ ctx.lineTo(cx + A / 2, cy + E);
11934
+ ctx.lineTo(cx - A / 2, cy + E);
11935
+ ctx.lineTo(cx - A / 2, cy + A / 2);
11936
+ ctx.lineTo(cx - E, cy + A / 2);
11937
+ ctx.lineTo(cx - E, cy - A / 2);
11938
+ ctx.lineTo(cx - A / 2, cy - A / 2);
11939
+ ctx.lineTo(cx - A / 2, cy - E);
11940
+ break;
11941
+ }
11942
+ case 'empty':
11371
11943
  default: // circle
11372
11944
  ctx.arc(cx, cy, r, 0, Math.PI * 2);
11373
11945
  }
11374
11946
  }
11947
+ // the shape names flat's szSymbol switch knows (maptheme.js 21625) —
11948
+ // anything else falls back to circle, never silently dropped
11949
+ const SYMBOL_SHAPE_RE = /^(circle|square|diamond|triangle|roundrect|carot|hexagon|cross|empty|label)$/;
11950
+ function normalizeSymbolShape(name) {
11951
+ const n = String(name || '').toLowerCase();
11952
+ return SYMBOL_SHAPE_RE.test(n) ? n : 'circle';
11953
+ }
11375
11954
 
11376
11955
  // a color's own opacity, 0..1, as SVG paints it in flat: "none" (SVG's
11377
11956
  // no paint — a polygon with only its outline, or a class not painted)
@@ -11506,7 +12085,7 @@
11506
12085
  // last built map; called while the map is still building, they run once
11507
12086
  // it is ready.
11508
12087
  const MAP_HANDLE_METHODS = ['replace', 'add', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
11509
- 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible', 'setThemeTimeFrame', 'attribution'];
12088
+ 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'setProjection', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible', 'setThemeTimeFrame', 'attribution'];
11510
12089
  const _mapHandles = Object.create(null);
11511
12090
  function makeMapHandle(szMap) {
11512
12091
  const h = {};
@@ -12196,7 +12775,7 @@
12196
12775
  // test-only: lets test/unit/*.test.mjs call pure internals directly (the
12197
12776
  // engine runs in a Node vm there); deliberately NOT on the ixmaps object
12198
12777
  global.__ixmapsGlInternals = {
12199
- IXMAPS_GL_VERSION, youtubeClickToPlay, symbolsFlagCompat, tooltipTable, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout, multiQuadOffsets, pixelOffsetLngLat,
12778
+ IXMAPS_GL_VERSION, youtubeClickToPlay, symbolsFlagCompat, tooltipTable, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout, ringsLayout, multiQuadOffsets, pixelOffsetLngLat,
12200
12779
  normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, MapBuilder, typeStyleNumbers, styleNum,
12201
12780
  resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
12202
12781
  equalIntervalBreaks, quantileBreaks, naturalBreaks, evenStrideSample, jenksBreakValues, partsFromBreakValues, resolvePartsClass,
@@ -12206,7 +12785,8 @@
12206
12785
  flatToMapLibreZoom, mapLibreToFlatZoom, scaleDenominatorAt, resolveZoomReference, resolveAggregationPx, valuesHiddenByScale,
12207
12786
  fetchLayerData, parseCsvText, dataTableRows, geometryRowsToFeatureCollection, filterFlatValues, applyField100, field100Binding, rangeClassLegendTotals, resolveAggregateValue, classValueSeparate, cellAggregatedValues, oneHot, groupCoLocated, aggregateOnGrid, GridAggregateIndex, flatRangeParts, aggregateField100, valueRadius, itemPlotGeometry, itemAnchor, objectZoomFactor, resolveZoomReference, defaultNormalSizeValue,
12208
12787
  applyWhereFilter, joinChartPositions, flatLookupKey, flatShapeCenter, chartHiddenByScale, glowHiddenByScale, isSymbolChart, flatChartAlignOffset, flatChartBranch, flatValueText, categoryValueRecord, flatDerivateRgb, flatChartTextRgb, flatGroupedValue, flatNoBreaks, featuresHiddenByScale, boxHiddenByScale, flatShadowOn, snapToAggregationGrid,
12209
- flatFormatValue, themeStyleArgs,
12788
+ drawSymbolPath, normalizeSymbolShape,
12789
+ flatFormatValue, themeStyleArgs, explicitValueDecimals, legendDecimals,
12210
12790
  dataCacheKey, dataCacheDisabled, cachedLayerData, featuresBounds, wantsZoomToExtent, legendIsOn,
12211
12791
  };
12212
12792
  global.__setFilter = __setFilter;