ixmaps-gl 0.2.17 → 0.2.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +3 -3
  2. package/ixmaps-gl.js +430 -66
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -243,7 +243,7 @@ of your own project files gl can render.
243
243
  Load the engine from jsDelivr, pinned to a release tag (or `@main` for the latest commit):
244
244
 
245
245
  ```html
246
- <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.17/ixmaps-gl.js"></script>
246
+ <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.18/ixmaps-gl.js"></script>
247
247
  ```
248
248
 
249
249
  or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmaps-gl)
@@ -251,13 +251,13 @@ or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmap
251
251
  browser script, which defines the global `ixmaps`):
252
252
 
253
253
  ```html
254
- <script src="https://unpkg.com/ixmaps-gl@0.2.17/ixmaps-gl.js"></script>
254
+ <script src="https://unpkg.com/ixmaps-gl@0.2.18/ixmaps-gl.js"></script>
255
255
  ```
256
256
 
257
257
  or use a local copy (`<script src="ixmaps-gl.js"></script>`). A minimal page:
258
258
 
259
259
  ```html
260
- <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.17/ixmaps-gl.js"></script>
260
+ <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.18/ixmaps-gl.js"></script>
261
261
  <div id="map_div" style="position:absolute;inset:0;"></div>
262
262
 
263
263
  <script>
package/ixmaps-gl.js CHANGED
@@ -82,7 +82,7 @@
82
82
  // ---------------------------------------------------------------
83
83
  // this engine's release (= package.json "version", checked by
84
84
  // test/unit/version.test.mjs); ixmaps.glVersion, and logged once at start
85
- const IXMAPS_GL_VERSION = '0.2.17';
85
+ const IXMAPS_GL_VERSION = '0.2.18';
86
86
 
87
87
  const LIB_URLS = {
88
88
  // Bumped 3.6.2 -> 5.x (2026-09-21, globe-projection compat fix): native
@@ -2479,6 +2479,44 @@
2479
2479
  return typeof v === 'number' ? v : NaN;
2480
2480
  }
2481
2481
 
2482
+ // flat's changeThemeStyle arithmetic (maptheme.js doChangeThemeStyle,
2483
+ // 4962-5334): these numeric keys go through __calcNewValue (4486) — flag
2484
+ // "pow" current^v, "factor" current·v, "add"/"delta" current+v, else v.
2485
+ // The value is the theme's current one, or flat's own fallback where its
2486
+ // code has one (`mapTheme.nBrightness || 1`, …; fillopacity: 1 when the
2487
+ // new value is < 1, else 0)
2488
+ const FLAT_STYLE_CALC_KEYS = {
2489
+ opacity: 0, fillopacity: null, blur: 0, dopacityscale: 0, dopacitypow: 0, brightness: 1, rangescale: 1,
2490
+ normalsizevalue: 0, minvalue: 1, maxvalue: 1, valuescale: 0, linewidth: 0, markersize: 0, gapsize: 0,
2491
+ offsetx: 0, offsety: 0, rotation: 0, minvaluesize: 0, minvaluevalue: 0, clipvaluesize: 0, minsize: 0,
2492
+ minchartsize: 0, maxcharts: 0, sizepow: 0, outlierscale: 0, clipsize: 0, clipframerate: 1,
2493
+ gridwidth: 1, gridmatrix: 1, gridwidthpx: 50, gridoffsetx: 0, gridoffsety: 0
2494
+ };
2495
+ // the new value of style[key] after flat's changeThemeStyle(…, "key:value",
2496
+ // flag), or null when flat leaves the theme as it is. scale is a resize
2497
+ // FACTOR there (__calcFactor, 4502: "factor" v, "add" (current+v)/current,
2498
+ // else v/current; no pow/delta), multiplied into nScale by
2499
+ // MapTheme.resize (26544) — a factor of 0 or NaN is falsy, so the
2500
+ // Themes.execute loop (3326) skips the resize: scale:0 changes nothing.
2501
+ // Other keys: the value string as given
2502
+ function flatChangedStyleValue(key, current, value, flag) {
2503
+ const f = String(flag || '');
2504
+ const cur = Number(Array.isArray(current) ? current[0] : current);
2505
+ const v = Number(value);
2506
+ if (key === 'scale') {
2507
+ const now = Number.isFinite(cur) && cur ? cur : 1;
2508
+ const factor = /factor/.test(f) ? v : /add/.test(f) ? (now + v) / now : v / now;
2509
+ return factor ? now * factor : null;
2510
+ }
2511
+ if (!(key in FLAT_STYLE_CALC_KEYS) || !/pow|factor|add|delta/.test(f)) return value;
2512
+ const fallback = FLAT_STYLE_CALC_KEYS[key] == null ? (v < 1 ? 1 : 0) : FLAT_STYLE_CALC_KEYS[key];
2513
+ const base = Number.isFinite(cur) && cur ? cur : fallback;
2514
+ const n = Number.isFinite(v) ? v : 0;
2515
+ if (/pow/.test(f)) return Math.pow(base, n);
2516
+ if (/factor/.test(f)) return base * n;
2517
+ return base + n;
2518
+ }
2519
+
2482
2520
  function normalizeTheme(def) {
2483
2521
  // real ixmaps-flat (htmlgui.js newTheme) merges .meta() INTO style first
2484
2522
  // — meta wins — and only then reads type, bindings and style properties
@@ -3444,28 +3482,36 @@
3444
3482
  // sync — no separate light/dark branch anywhere else.
3445
3483
  // flat's look (flatLegendLook) unless the page picks one with
3446
3484
  // this engine's own legendtheme
3447
- const opacityOpt = parseFloat(builder._engineOptions.basemapopacity);
3448
- const legendBackground = builder.mapOptions.legendBackground || builder.mapOptions.legendbackground;
3449
- const flatLook = flatLegendLook(builder.mapOptions.mapType, isNaN(opacityOpt) ? 1 : opacityOpt, legendBackground);
3450
- // effective darkness (style background vs page background, see
3451
- // effectiveMapDark): a dark basemap faded to <= 0.5 over a DARK
3452
- // page still reads dark — flatLegendLook's name heuristic would
3453
- // wrongly switch the panel (and the tooltip) to light there
3454
- const looksDark = typeof builder.__effectiveDark === 'function'
3455
- ? builder.__effectiveDark(isNaN(opacityOpt) ? 1 : opacityOpt, builder.mapOptions.mapType, legendBackground)
3456
- : flatLook.dark;
3457
- const isLightLegend = rt.style.legendtheme === 'light' || (rt.style.legendtheme !== 'dark' && !looksDark);
3458
- const legendColors = isLightLegend
3459
- ? { bg: 'rgba(255,255,255,0.92)', fg: '#1a1a1a', shadow: '0 2px 10px rgba(0,0,0,0.18)',
3460
- rowMarked: 'rgba(0,0,0,0.08)', track: 'rgba(0,0,0,0.08)',
3461
- selectBg: 'rgba(0,0,0,0.04)', selectBorder: 'rgba(0,0,0,0.2)' }
3462
- : { bg: 'rgba(28,30,34,0.92)', fg: '#eee', shadow: '0 2px 10px rgba(0,0,0,0.45)',
3463
- rowMarked: 'rgba(255,255,255,0.14)', track: 'rgba(255,255,255,0.08)',
3464
- selectBg: 'rgba(255,255,255,0.08)', selectBorder: 'rgba(255,255,255,0.25)' };
3465
- // the panel bg follows the same effective decision (flatLegendLook
3466
- // would hand the dark look a light bg here: its dark branch never
3467
- // ran for this map, its light bg would clash with dark rows/text)
3468
- if (!rt.style.legendtheme) legendColors.bg = looksDark ? '#111' : flatLook.bg;
3485
+ // The look is decided again once the basemap style exists (see
3486
+ // rt._relookLegend below): a panel built before the style loads
3487
+ // can only get effectiveMapDark's name-heuristic fallback, which
3488
+ // reads a dark basemap faded to <= 0.5 as light.
3489
+ const lookColors = () => {
3490
+ const opacityOpt = parseFloat(builder._engineOptions.basemapopacity);
3491
+ const legendBackground = builder.mapOptions.legendBackground || builder.mapOptions.legendbackground;
3492
+ const flatLook = flatLegendLook(builder.mapOptions.mapType, isNaN(opacityOpt) ? 1 : opacityOpt, legendBackground);
3493
+ // effective darkness (style background vs page background, see
3494
+ // effectiveMapDark): a dark basemap faded to <= 0.5 over a DARK
3495
+ // page still reads dark — flatLegendLook's name heuristic would
3496
+ // wrongly switch the panel (and the tooltip) to light there
3497
+ const looksDark = typeof builder.__effectiveDark === 'function'
3498
+ ? builder.__effectiveDark(isNaN(opacityOpt) ? 1 : opacityOpt, builder.mapOptions.mapType, legendBackground)
3499
+ : flatLook.dark;
3500
+ const isLightLegend = rt.style.legendtheme === 'light' || (rt.style.legendtheme !== 'dark' && !looksDark);
3501
+ const colors = isLightLegend
3502
+ ? { bg: 'rgba(255,255,255,0.92)', fg: '#1a1a1a', shadow: '0 2px 10px rgba(0,0,0,0.18)',
3503
+ rowMarked: 'rgba(0,0,0,0.08)', track: 'rgba(0,0,0,0.08)',
3504
+ selectBg: 'rgba(0,0,0,0.04)', selectBorder: 'rgba(0,0,0,0.2)' }
3505
+ : { bg: 'rgba(28,30,34,0.92)', fg: '#eee', shadow: '0 2px 10px rgba(0,0,0,0.45)',
3506
+ rowMarked: 'rgba(255,255,255,0.14)', track: 'rgba(255,255,255,0.08)',
3507
+ selectBg: 'rgba(255,255,255,0.08)', selectBorder: 'rgba(255,255,255,0.25)' };
3508
+ // the panel bg follows the same effective decision (flatLegendLook
3509
+ // would hand the dark look a light bg here: its dark branch never
3510
+ // ran for this map, its light bg would clash with dark rows/text)
3511
+ if (!rt.style.legendtheme) colors.bg = looksDark ? '#111' : flatLook.bg;
3512
+ return colors;
3513
+ };
3514
+ const legendColors = lookColors();
3469
3515
  // max-height:66.6% resolves against el's own height (the map
3470
3516
  // container, which always has a definite height for the map
3471
3517
  // itself to render into) since this panel sits in the stack,
@@ -3886,6 +3932,7 @@
3886
3932
  // sharing the same data source and calls refresh()), so this
3887
3933
  // is UI wiring only, no new filtering logic.
3888
3934
  const filterField = rt.style.legendfilter;
3935
+ let filterSelect = null;
3889
3936
  if (filterField) {
3890
3937
  const facet = engineApi.getFacets(rt.name, [filterField], { bbox: [-180, -85, 180, 85] })[0];
3891
3938
  const values = (facet && facet.type === 'textual')
@@ -3901,8 +3948,27 @@
3901
3948
  else engineApi.clearFacetFilter(rt.name, filterField);
3902
3949
  });
3903
3950
  bodyEl.appendChild(filterEl);
3951
+ filterSelect = filterEl;
3904
3952
  }
3905
3953
 
3954
+ // re-apply the look in place once the basemap style is known
3955
+ // (createLegend's 'style.load' listener) — restyled, not rebuilt,
3956
+ // so the filter choice, slider and folding survive
3957
+ rt._relookLegend = () => {
3958
+ const c = lookColors();
3959
+ if (c.bg === legendColors.bg && c.fg === legendColors.fg) return;
3960
+ Object.assign(legendColors, c);
3961
+ panel.style.background = c.bg;
3962
+ panel.style.color = c.fg;
3963
+ if (filterSelect) {
3964
+ filterSelect.style.background = c.selectBg;
3965
+ filterSelect.style.color = c.fg;
3966
+ filterSelect.style.borderColor = c.selectBorder;
3967
+ }
3968
+ if (stack) stack.style.boxShadow = c.shadow;
3969
+ if (!isTextOnly) renderRows();
3970
+ };
3971
+
3906
3972
  if (!isTextOnly) { bodyEl.appendChild(rowsScroll); renderRows(); }
3907
3973
 
3908
3974
  if (rt.meta.description) {
@@ -3959,6 +4025,9 @@
3959
4025
  }
3960
4026
  };
3961
4027
  runtimes.forEach(rt => addLegendPanel(rt));
4028
+ // every style (the initial one, setProjectJSON swaps) can change the
4029
+ // effective darkness the panels were built with
4030
+ map.on('style.load', () => runtimes.forEach(rt => { if (rt._legendPanel && rt._relookLegend) rt._relookLegend(); }));
3962
4031
  }
3963
4032
  return { addLegendPanel, setLegendOption, updateSubTheme, layoutLegends };
3964
4033
  }
@@ -4495,6 +4564,33 @@
4495
4564
  return engineApi;
4496
4565
  },
4497
4566
  replaceTheme: (id, layerBuilder, flag) => engineApi.replace(id, layerBuilder, flag),
4567
+ // flat's newTheme(title, theme, flag) (htmlgui.js 1540-1570, the
4568
+ // handle's newTheme forwards to it): with "replace" the theme of the
4569
+ // same id goes first, "clearcharts" every CHART theme, "clear" /
4570
+ // "clearall" every theme — LOCKED themes stay (flat's
4571
+ // Themes.removeAll, maptheme.js 3047-3050: the fuel-price page's
4572
+ // nuts2 base) — then the theme is added; "force" removes nothing
4573
+ newTheme: (title, layerBuilder, flag) => {
4574
+ layerBuilder = unwrapLayer(layerBuilder);
4575
+ const f = String(flag || '');
4576
+ _chainedLayers = _chainedLayers.then(() => {
4577
+ if (!/force/i.test(f)) {
4578
+ if (/replace/i.test(f)) {
4579
+ const def = layerBuilder.definition();
4580
+ const id = (def.style && def.style.name) || (def.meta && def.meta.name);
4581
+ if (id) removeThemeById(id);
4582
+ } else if (/clearcharts|clear|clearall/i.test(f)) {
4583
+ const charts = /clearcharts/i.test(f);
4584
+ const doomed = runtimes.filter(rt => !flatFlag(rt.flags, 'LOCKED') && (!charts || rt.flags.has('CHART')));
4585
+ const byName = new Map();
4586
+ doomed.forEach(rt => { if (!byName.has(rt.name)) byName.set(rt.name, []); byName.get(rt.name).push(rt); });
4587
+ byName.forEach((list, name) => removeRuntimes(name, list));
4588
+ }
4589
+ }
4590
+ return engineApi.defineLayer(layerBuilder);
4591
+ }).catch(err => console.error('[ixmaps-gl] map.newTheme():', err));
4592
+ return engineApi;
4593
+ },
4498
4594
  remove: (id) => { removeThemeById(id); return engineApi; },
4499
4595
  // flat's map.setMapType(id) / setMapTypeId / mapType (htmlgui_flat.js
4500
4596
  // mapApi): swap the basemap at runtime. MapLibre's setStyle replaces
@@ -4616,23 +4712,40 @@
4616
4712
  // "filter:WHERE ..." value reuses rt.setRuntimeFilter, the SAME
4617
4713
  // applyWhereFilter grammar the layer's own load-time .filter()
4618
4714
  // already parses — just invoked again at runtime.
4715
+ //
4716
+ // themeId null: every theme but the FEATURE ones (flat's
4717
+ // Themes.changeThemeStyle, maptheme.js 4464: `szFlag.match(/FEATURE/)`
4718
+ // skipped), one redraw for all. Numeric keys follow flat's
4719
+ // "factor"/"add"/"pow" flags and scale its resize factor
4720
+ // (flatChangedStyleValue) — the scale slider's
4721
+ // changeThemeStyle(null, null, 'scale:' + v, 'set').
4619
4722
  changeThemeStyle: (themeId, styleKeyValue, action) => {
4620
- const rt = findRuntime(themeId);
4621
- if (!rt) return;
4723
+ const targets = themeId == null
4724
+ ? runtimes.filter(r => ![...r.flags].some(f => /FEATURE/.test(f)))
4725
+ : [findRuntime(themeId)].filter(Boolean);
4726
+ if (!targets.length) return;
4622
4727
  const colonIdx = String(styleKeyValue).indexOf(':');
4623
4728
  const key = colonIdx === -1 ? String(styleKeyValue) : styleKeyValue.slice(0, colonIdx);
4624
4729
  const value = colonIdx === -1 ? '' : styleKeyValue.slice(colonIdx + 1);
4625
- if (validation) validation.style(themeId, { [key]: value });
4626
- if (key === 'filter') {
4627
- rt.setRuntimeFilter(action === 'remove' ? '' : value);
4628
- refresh();
4629
- } else if (key === 'type') {
4730
+ if (key === 'type') {
4630
4731
  console.warn(`[ixmaps-gl] changeThemeStyle: changing the type ("${value}") is not supported`);
4631
- } else {
4632
- // any other style key, as flat: "set" writes it, "remove" drops it
4633
- rt.setStyle({ [key]: /remove/.test(action || '') ? undefined : value });
4634
- refresh();
4732
+ return;
4635
4733
  }
4734
+ let changed = false;
4735
+ targets.forEach(rt => {
4736
+ if (validation) validation.style(themeIdOf(rt), { [key]: value });
4737
+ if (key === 'filter') {
4738
+ rt.setRuntimeFilter(action === 'remove' ? '' : value);
4739
+ changed = true;
4740
+ return;
4741
+ }
4742
+ // any other style key, as flat: "set" writes it, "remove" drops it
4743
+ const next = /remove/.test(action || '') ? undefined : flatChangedStyleValue(key, rt.style[key], value, action);
4744
+ if (next === null) return;
4745
+ rt.setStyle({ [key]: next });
4746
+ changed = true;
4747
+ });
4748
+ if (changed) refresh();
4636
4749
  },
4637
4750
  // flat's ixmaps.setThemeTimeFrame(szId, min, max): show only the
4638
4751
  // records whose timefield lies in [min, max) (ms or Date-
@@ -5191,10 +5304,26 @@
5191
5304
  // never by size, so resizing the SAME cached icon every frame
5192
5305
  // creates no new atlas entries — none of the reclustering risk
5193
5306
  // above applies to a pure size change.
5307
+ //
5308
+ // globe: MapLibre shifts getZoom() by log2(cos(center lat)) as the
5309
+ // center moves north/south (the globe keeps its size on screen); the
5310
+ // grid zoom takes that out, so a gridwidthpx grid changes on zoom
5311
+ // only, not on pan (flat's orthographic keeps its scale on rotation)
5312
+ // — rounded so float noise doesn't miss GridAggregateIndex's
5313
+ // per-zoom cache. Measured on the GHS orthographic page: center lat
5314
+ // 17.2° → 47.2° moved getZoom() 2.467 → 1.975, re-binning the
5315
+ // 3 px MEAN cloud (cells changing color) on every pan.
5316
+ function gridZoomOf() {
5317
+ const z = map.getZoom();
5318
+ const proj = typeof map.getProjection === 'function' && map.getProjection();
5319
+ if (!proj || proj.type !== 'globe') return z;
5320
+ const lat = map.getCenter().lat * Math.PI / 180;
5321
+ return Math.round((z - Math.log2(Math.cos(lat))) * 1e6) / 1e6;
5322
+ }
5194
5323
  function refreshLayers() {
5195
5324
  const liveZoom = map.getZoom();
5196
5325
  // frozen only while MapLibre really is zooming (see 'moveend')
5197
- const zoom = isZooming && map.isZooming() ? stableGridZoom : liveZoom;
5326
+ const zoom = isZooming && map.isZooming() ? stableGridZoom : gridZoomOf();
5198
5327
  const viewMoving = map.isMoving();
5199
5328
  const bounds = map.getBounds();
5200
5329
  const bbox = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()];
@@ -5277,11 +5406,11 @@
5277
5406
  // zoom too) picks up the hide/show within one 150ms tick — still
5278
5407
  // effectively instant, without the unthrottled rebuild storm.
5279
5408
  let isZooming = false;
5280
- let stableGridZoom = map.getZoom();
5409
+ let stableGridZoom = gridZoomOf();
5281
5410
  map.on('zoomstart', () => { isZooming = true; });
5282
5411
  map.on('zoomend', () => {
5283
5412
  isZooming = false;
5284
- stableGridZoom = map.getZoom();
5413
+ stableGridZoom = gridZoomOf();
5285
5414
  // Was: clearTimeout(refreshTimer); refreshLayers(); — a DIRECT,
5286
5415
  // un-throttled rebuild on every single zoomend. That was fine
5287
5416
  // while every runtime's per-tick cost was cheap (viewport-scoped
@@ -5331,7 +5460,7 @@
5331
5460
  // the same arrows at every zoom, while the scale gates (liveZoom)
5332
5461
  // still switched.
5333
5462
  isZooming = false;
5334
- stableGridZoom = map.getZoom();
5463
+ stableGridZoom = gridZoomOf();
5335
5464
  refresh();
5336
5465
  });
5337
5466
 
@@ -5550,7 +5679,7 @@
5550
5679
  // as ixmaps.map() does). The builder's own methods with different
5551
5680
  // semantics — layer, view, options, require, attribution, on — keep
5552
5681
  // theirs and are not overridden.
5553
- for (const m of ['replace', 'add', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
5682
+ for (const m of ['replace', 'add', 'newTheme', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
5554
5683
  'refreshTheme', 'redrawTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'setProjection', 'resize',
5555
5684
  'loadProject', 'project', 'setThemeVisible', 'setThemeTimeFrame', 'show', 'hide', 'getZoom', 'getThemeObj',
5556
5685
  'getThemes', 'theme', 'setView']) {
@@ -6916,6 +7045,83 @@
6916
7045
  return { offset, index, extent };
6917
7046
  }
6918
7047
 
7048
+ // Chart texts covered by charts drawn after them. Flat draws each chart
7049
+ // as one SVG group (glow, symbol, value text, in that order) in the
7050
+ // sorted draw order, so a later chart's symbol and glow paint over the
7051
+ // texts of every chart beneath it; here the texts are separate TextLayers
7052
+ // above every icon. This composites that occlusion on the CPU: for each
7053
+ // text, the transmittance Π(1 − α) of every LATER chart's symbol parts
7054
+ // and glow at a few sample points of the text box, averaged — the
7055
+ // fraction of the text still showing through, its alpha. Partly covered
7056
+ // texts fade instead of being clipped (no per-pixel mask in deck.gl).
7057
+ // charts: in draw order, { x, y, parts: [{ dx, dy, r, shape, alpha }],
7058
+ // glowR, glowAlpha } in screen pixels; a part's alpha its opacity,
7059
+ // glowR 0 for none (glowAlpha scales glowProfileAlpha)
7060
+ // texts: { order (index into charts), x, y, w, h }
7061
+ // Returns each text's transmittance, 0..1.
7062
+ const OCCLUSION_CELL_PX = 64;
7063
+ // below this a text is as good as hidden: left out of its TextLayer
7064
+ const CHART_TEXT_MIN_TRANSMITTANCE = 0.08;
7065
+ function chartTextTransmittance(charts, texts) {
7066
+ const grid = new Map();
7067
+ charts.forEach((c, k) => {
7068
+ let ext = c.glowR || 0;
7069
+ for (const p of c.parts) ext = Math.max(ext, Math.hypot(p.dx, p.dy) + p.r * Math.SQRT2);
7070
+ const x0 = Math.floor((c.x - ext) / OCCLUSION_CELL_PX), x1 = Math.floor((c.x + ext) / OCCLUSION_CELL_PX);
7071
+ const y0 = Math.floor((c.y - ext) / OCCLUSION_CELL_PX), y1 = Math.floor((c.y + ext) / OCCLUSION_CELL_PX);
7072
+ for (let gx = x0; gx <= x1; gx++) {
7073
+ for (let gy = y0; gy <= y1; gy++) {
7074
+ const key = gx + ',' + gy;
7075
+ let cell = grid.get(key);
7076
+ if (!cell) grid.set(key, cell = []);
7077
+ cell.push(k);
7078
+ }
7079
+ }
7080
+ });
7081
+ const alphaAt = (c, x, y) => {
7082
+ let keep = 1;
7083
+ if (c.glowR) {
7084
+ const d = Math.hypot(x - c.x, y - c.y) / c.glowR;
7085
+ if (d < 1) keep *= 1 - c.glowAlpha * glowProfileAlpha(d);
7086
+ }
7087
+ for (const p of c.parts) {
7088
+ if (insideSymbolShape(p.shape, x - c.x - p.dx, y - c.y - p.dy, p.r)) keep *= 1 - p.alpha;
7089
+ }
7090
+ return keep;
7091
+ };
7092
+ // the box center and four points inside its corners
7093
+ const SAMPLES = [[0, 0], [-0.35, -0.3], [0.35, -0.3], [-0.35, 0.3], [0.35, 0.3]];
7094
+ return texts.map(t => {
7095
+ let sum = 0;
7096
+ for (const [sx, sy] of SAMPLES) {
7097
+ const x = t.x + sx * t.w, y = t.y + sy * t.h;
7098
+ const cell = grid.get(Math.floor(x / OCCLUSION_CELL_PX) + ',' + Math.floor(y / OCCLUSION_CELL_PX));
7099
+ let keep = 1;
7100
+ // cells list charts in draw order: only the later ones cover
7101
+ if (cell) for (let i = cell.length - 1; i >= 0 && cell[i] > t.order && keep > 0; i--) keep *= alphaAt(charts[cell[i]], x, y);
7102
+ sum += keep;
7103
+ }
7104
+ return sum / SAMPLES.length;
7105
+ });
7106
+ }
7107
+ // _getGlowIcon's radial gradient: 0.28 at the center, 0.09 at half the
7108
+ // radius, 0 at the edge (d = distance / radius)
7109
+ function glowProfileAlpha(d) {
7110
+ return d < 0.5 ? 0.28 - 0.19 * d / 0.5 : d < 1 ? 0.09 * (1 - (d - 0.5) / 0.5) : 0;
7111
+ }
7112
+ // inside drawSymbolPath's shape of radius r, (dx, dy) from its center;
7113
+ // the shapes without their own test as the circle
7114
+ function insideSymbolShape(shape, dx, dy, r) {
7115
+ switch (shape) {
7116
+ case 'squarefull': return Math.abs(dx) <= r && Math.abs(dy) <= r;
7117
+ case 'square': return Math.abs(dx) <= r * Math.SQRT1_2 && Math.abs(dy) <= r * Math.SQRT1_2;
7118
+ case 'roundrect': return Math.abs(dx) <= r * 0.8 && Math.abs(dy) <= r * 0.8;
7119
+ case 'diamond': case 'carot': return Math.abs(dx) + Math.abs(dy) <= r;
7120
+ case 'empty': return false;
7121
+ default: return dx * dx + dy * dy <= r * r;
7122
+ }
7123
+ }
7124
+
6919
7125
  // A pixel offset [dx, dy] (y down) from lngLat at MapLibre zoom `zoom`,
6920
7126
  // as lng/lat (Web Mercator, 512 px world tiles) — for shapes too large
6921
7127
  // for an icon, drawn in map coordinates (rebuilt with every refresh).
@@ -8743,7 +8949,11 @@
8743
8949
  }
8744
8950
 
8745
8951
  _usesAggregationIndex() {
8746
- return (this.flags.has('AGGREGATE') || isSymbolChart(this.flags)) && !this.flags.has('GRIDSIZE');
8952
+ // GRIDSIZE bins its own grid (_ensureGridIndex) — PLOT curves and the
8953
+ // plain mesh; a classed GRIDSIZE symbol chart is aggregated like any
8954
+ // other (see _isPlainGridMesh)
8955
+ return (this.flags.has('AGGREGATE') || isSymbolChart(this.flags))
8956
+ && (!this.flags.has('GRIDSIZE') || (!this.flags.has('PLOT') && !this._isPlainGridMesh()));
8747
8957
  }
8748
8958
 
8749
8959
  _rebuildActiveFeatures() {
@@ -9487,16 +9697,19 @@
9487
9697
  // GRIDSIZE (spatial grid binning) is checked before the generic
9488
9698
  // CHART|SYMBOL bubble pipeline, same "more specific base type wins"
9489
9699
  // precedence as DOT: PLOT+GRIDSIZE is the per-year curves mini-chart
9490
- // (_buildPlotLayers); GRIDSIZE alone (no PLOT — the real engine's
9491
- // "grid" companion layer) is a plain uniform grid mesh, sized to the
9492
- // full cell pitch regardless of SIZE/QUANTILE/MEAN, matching
9493
- // maptheme.js's own `GRIDSIZE && !PLOT -> nRadius = nGridSize/2` rule
9494
- // (SIZE/QUANTILE/MEAN have no visible effect there in the real
9495
- // engine either — confirmed, not a gap in this port).
9700
+ // (_buildPlotLayers); GRIDSIZE without PLOT is flat's symbol chart
9701
+ // with `nRadius = nGridSize/2` (maptheme.js 21843) — every chart the
9702
+ // size of its cell, colored by its class (QUANTILE of the MEAN, the
9703
+ // fuel-price page). Only a plain one-color grid (the "grid"
9704
+ // companion layer) takes the uniform mesh shortcut.
9496
9705
  if (this.flags.has('GRIDSIZE') && this.flags.has('PLOT')) return this._buildPlotLayers(zoom, bbox);
9497
9706
  // PLOT without GRIDSIZE: one line chart per item over its value fields
9498
9707
  if (this.flags.has('PLOT') && this._isItemPlot()) return this._buildItemPlotLayers(zoom);
9499
- if (this.flags.has('GRIDSIZE')) return this._buildGridMeshLayers(zoom, bbox);
9708
+ // a plain grid (one color, no values or glow — the Germany page's
9709
+ // companion "grid" layer) as one square per cell; a classed GRIDSIZE
9710
+ // chart (the fuel-price page) is a symbol chart sized to the grid,
9711
+ // below in _buildChartLayers (_gridChartRadiusPx)
9712
+ if (this.flags.has('GRIDSIZE') && this._isPlainGridMesh()) return this._buildGridMeshLayers(zoom, bbox);
9500
9713
  // CHOROPLETH is checked before the generic FEATURE dispatch — a
9501
9714
  // CHOROPLETH layer's own type string doesn't carry FEATURE/FEATURES
9502
9715
  // (only its geometry-donor base layer does, see
@@ -9852,6 +10065,27 @@
9852
10065
  // style.values/xaxis (explicit, see _prepare) if set, else whatever
9853
10066
  // CATEGORICAL auto-discovered.
9854
10067
  // a per-item PLOT: no GRIDSIZE grid, several value fields per item
10068
+ // GRIDSIZE without PLOT drawn as one uniform square per cell: only a
10069
+ // plain grid — a single color, no VALUES, no GLOW
10070
+ _isPlainGridMesh() {
10071
+ return !this.flags.has('PLOT') && (this.categoryColorsRgb || []).length <= 1
10072
+ && !this.flags.has('VALUES') && !this.flags.has('GLOW');
10073
+ }
10074
+ // GRIDSIZE (no PLOT) symbol chart radius, px: flat's nGridSize / 2
10075
+ // (maptheme.js 21843), nGridSize the aggregation cell pitch / nAutoSize
10076
+ // (16944-16952: RECT or no AGGREGATE 1.0, with GAP 1.15; else 0.75,
10077
+ // with GAP 0.8) · style.scale. Measured on the fuel-price page: 5 px
10078
+ // cells, RECT|GAP → nGridSize 4.35 px, circle r 2.17 px. null when not
10079
+ // a GRIDSIZE chart or no grid at this scale.
10080
+ _gridChartRadiusPx(zoom, liveZoom) {
10081
+ if (!this.flags.has('GRIDSIZE') || this.flags.has('PLOT') || !this._clusterRadiusPx) return null;
10082
+ const fromZoom = this._clusterUsesFixedZoom ? GRIDWIDTH_METERS_REFERENCE_ZOOM : zoom;
10083
+ const cellPx = this._clusterRadiusPx * Math.pow(2, liveZoom - fromZoom);
10084
+ const rect = flatFlag(this.flags, 'RECT') || !this.flags.has('AGGREGATE');
10085
+ const gap = flatFlag(this.flags, 'GAP');
10086
+ const autoSize = rect ? (gap ? 1.15 : 1.0) : (gap ? 0.8 : 0.75);
10087
+ return cellPx / autoSize * (styleNum(this.style.scale) || 1) / 2;
10088
+ }
9855
10089
  _isItemPlot() {
9856
10090
  return this.flags.has('PLOT') && !this.flags.has('GRIDSIZE') && String(this.binding.value || '').includes('|');
9857
10091
  }
@@ -10715,6 +10949,20 @@
10715
10949
  ...(f.properties.titleRaw ? { titleRaw: f.properties.titleRaw } : {}) }
10716
10950
  };
10717
10951
  });
10952
+ // MEAN (no CATEGORICAL): a cell's value is the mean of its records,
10953
+ // not their sum (flat's MEAN aggregation) — what its label prints,
10954
+ // its size and its class follow. The fuel-price page: 119 stations,
10955
+ // sum 270.0 → 2.27 €
10956
+ if (this.flags.has('MEAN') && !this.flags.has('CATEGORICAL')) {
10957
+ groups.forEach(g => {
10958
+ const p = g.properties;
10959
+ const n = groupRecordCount(p);
10960
+ if (!(n > 0)) return;
10961
+ if (p.recordCounts) p.counts = p.counts.map((c, i) => (p.recordCounts[i] ? c / p.recordCounts[i] : c));
10962
+ p.total = p.total / n;
10963
+ if (p.classTotal !== undefined) p.classTotal = p.classTotal / n;
10964
+ });
10965
+ }
10718
10966
  return { individual, groups };
10719
10967
  }
10720
10968
 
@@ -10842,6 +11090,10 @@
10842
11090
  // _computeAggregatedItems below — a plain (non-AGGREGATE)
10843
11091
  // BUBBLE/CHART theme never reads them.
10844
11092
  if (this.flags.has('AGGREGATE')) this._ensureClusterIndices(zoom);
11093
+ // GRIDSIZE: every chart the size of its grid cell, whatever its value
11094
+ // (_gridChartRadiusPx); otherwise valueRadius
11095
+ const gridR = this._gridChartRadiusPx(zoom, liveZoom);
11096
+ const vRadius = v => (gridR != null ? gridR : valueRadius(v, liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue));
10845
11097
  // AGGREGATE sums without a normalsizevalue: flat's default is the
10846
11098
  // largest aggregated value (defaultNormalSizeValue leaves it unset, the
10847
11099
  // sizes depend on the aggregation)
@@ -10999,7 +11251,7 @@
10999
11251
  const singleBorderWidthPx = styleNum(this.style.linewidth) || 0;
11000
11252
 
11001
11253
  const itemRgb = d => this.categoryColorsRgb[d.properties.counts ? dominant(d.properties.counts) : d.properties.cat] || [128, 128, 128];
11002
- const radiusOf = d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
11254
+ const radiusOf = d => vRadius(sizeValueOf(d));
11003
11255
  // align / offsetx / offsety: the whole chart moves (flatChartAlignOffset)
11004
11256
  // MULTIQUAD / MULTISQUARE: the items at one position side by side
11005
11257
  // (multiQuadOffsets), added to the chart position
@@ -11048,7 +11300,7 @@
11048
11300
  data: combined, pickable: false,
11049
11301
  getPosition: d => d.geometry.coordinates,
11050
11302
  getIcon: d => this._getGlowIcon((this.categoryColorsRgb || [])[d.properties.counts ? dominant(this._groupIsolation(d) ? d.properties.counts.map((c, i) => (this._markedClasses.has(i) ? c : 0)) : d.properties.counts) : d.properties.cat]),
11051
- getSize: d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue) * (d.properties.counts ? 9 : 11),
11303
+ getSize: d => vRadius(sizeValueOf(d)) * (d.properties.counts ? 9 : 11),
11052
11304
  getColor: d => [255, 255, 255, this._iconAlpha(d)],
11053
11305
  ...(alignOf.active ? { getPixelOffset: alignOf } : {}),
11054
11306
  sizeUnits: 'pixels',
@@ -11124,7 +11376,7 @@
11124
11376
  const alignRight = /right/.test(String(this.style.align || ''));
11125
11377
  // with MULTIQUAD only the first item of a position gets its box/title
11126
11378
  const boxLayout = boxShown ? combined.filter(d => !multi || multi.index.get(d) === 0).map(d => {
11127
- const r = valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
11379
+ const r = vRadius(sizeValueOf(d));
11128
11380
  const ext = multi ? multi.extent.get(d) : [0, 0, 0, 0];
11129
11381
  const chart = [ext[0] - r, ext[1] - r, ext[2] + r, ext[3] + r];
11130
11382
  const grow = e => { if (!e) return; chart[0] = Math.min(chart[0], e[0]); chart[1] = Math.min(chart[1], e[1]); chart[2] = Math.max(chart[2], e[2]); chart[3] = Math.max(chart[3], e[3]); };
@@ -11249,17 +11501,27 @@
11249
11501
  }));
11250
11502
  }
11251
11503
 
11504
+ // flat's per-part SYMBOL branch (everything but CATEGORICAL without
11505
+ // AGGREGATE/GROUP) draws a square of side 2r, the other one 1.6r
11506
+ // (drawSymbolPath's own square)
11507
+ const perPartBranch = flatChartBranch(this.flags) === 'symbol'
11508
+ && !(this.flags.has('CATEGORICAL') && !this.flags.has('AGGREGATE') && !flatFlag(this.flags, 'GROUP'));
11509
+ const shapeOf = props => { const sh = this._resolveSymbolShape(props); return sh === 'square' && perPartBranch ? 'squarefull' : sh; };
11510
+ // a range-classed cell is ONE class: one symbol in its class color,
11511
+ // fillopacity and linecolor, as a single item (flat draws one
11512
+ // symbol) — the packed multi-category icon is for CATEGORICAL cells
11513
+ const singleSymbol = d => !d.properties.counts || (this._rangeClassed && d.properties.cat != null);
11252
11514
  layers.push(new IconLayer({
11253
11515
  id: `ix-bubbles-${this.name}-g${this._iconGeneration}`,
11254
11516
  data: combined, pickable: true,
11255
11517
  getPosition: d => d.geometry.coordinates,
11256
- getIcon: d => d.properties.counts
11518
+ getIcon: d => !singleSymbol(d)
11257
11519
  ? 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),
11259
- getSize: d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue) * 2,
11520
+ : this._buildSingleIcon((this.categoryColorsRgb || [])[d.properties.cat], fillOpacity, shapeOf(d.properties), singleBorderColorRgb, singleBorderWidthPx),
11521
+ getSize: d => vRadius(sizeValueOf(d)) * 2,
11260
11522
  // group icons bake a fixed 0.9 alpha; an explicit fillopacity scales it
11261
11523
  // so a near-transparent theme doesn't show opaque 1-px-cell clusters
11262
- getColor: d => [255, 255, 255, Math.round(this._iconAlpha(d) * (d.properties.counts && this.style.fillopacity != null ? fillOpacity : 1))],
11524
+ getColor: d => [255, 255, 255, Math.round(this._iconAlpha(d) * (!singleSymbol(d) && this.style.fillopacity != null ? fillOpacity : 1))],
11263
11525
  ...(alignOf.active ? { getPixelOffset: alignOf } : {}),
11264
11526
  sizeUnits: 'pixels',
11265
11527
  billboard: true,
@@ -11293,8 +11555,8 @@
11293
11555
  // VALUES: bold value label centered on each bubble (see
11294
11556
  // flatValueText/valuesFontSizePx above). Labels are their own
11295
11557
  // layers, added last in the array so they draw on top of every icon
11296
- // layer (a small bubble's label can still show over a bigger
11297
- // neighboring bubble's icon — a known, accepted gap). Entries whose
11558
+ // layer; a label under later-drawn charts is faded or dropped by
11559
+ // _occludeChartTexts, as flat's later chart groups cover it. Entries whose
11298
11560
  // computed font size would be sub-pixel are dropped rather than
11299
11561
  // rendered at getSize: 0, matching the real engine's own "too
11300
11562
  // small to bother" gate.
@@ -11303,15 +11565,19 @@
11303
11565
  const textOpts = this._valueTextOpts();
11304
11566
  const pointValueOf = valueTextOf || this._chartValueTextFn();
11305
11567
 
11568
+ // draw order of each chart (see chartTextTransmittance below); an
11569
+ // item maxcharts left out draws no chart, so no text either
11570
+ const orderOf = new Map(combined.map((d, k) => [d, k]));
11306
11571
  const pointLabels = individual.reduce((out, d) => {
11307
- const radius = valueRadius(d.properties.value, liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
11572
+ if (!orderOf.has(d)) return out;
11573
+ const radius = vRadius(d.properties.value);
11308
11574
  // flat's VALUES label prints the value (nValuesA), not the size;
11309
11575
  // its font by flat's chart branch (_chartValueTextFn)
11310
11576
  const { text, fontSize, bold, baseline } = pointValueOf(d, radius);
11311
11577
  if (fontSize > VALUES_MIN_FONT_PX) {
11312
11578
  const o = alignOf(d);
11313
11579
  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 });
11580
+ pixelOffset: baseline ? [o[0], o[1] + baseline - 0.35 * fontSize] : o, order: orderOf.get(d) });
11315
11581
  }
11316
11582
  return out;
11317
11583
  }, []);
@@ -11327,11 +11593,25 @@
11327
11593
  // would misrepresent every category's own count as one summed
11328
11594
  // number nobody's individual bubble actually shows.
11329
11595
  const groupLabels = groups.reduce((out, d) => {
11596
+ if (!orderOf.has(d)) return out;
11330
11597
  const counts = d.properties.counts;
11331
- const outerRadiusPx = valueRadius(d.properties.total, liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
11598
+ const outerRadiusPx = vRadius(d.properties.total);
11332
11599
  const iconSizePx = outerRadiusPx * 2; // matches the cluster IconLayer's own getSize (*2) below
11333
11600
  const pxPerCanvasUnit = iconSizePx / BUBBLE_ICON_SIZE;
11334
11601
  const iso = this._groupIsolation(d);
11602
+ // a range-classed cell is one symbol (see the bubbles layer): its
11603
+ // value text as a single chart's (_chartValueTextFn)
11604
+ if (this._rangeClassed && d.properties.cat != null) {
11605
+ const value = d.properties.classTotal !== undefined ? d.properties.classTotal : d.properties.total;
11606
+ const v = pointValueOf({ properties: { value, cat: d.properties.cat } }, outerRadiusPx);
11607
+ if (v.fontSize > VALUES_MIN_FONT_PX) {
11608
+ const o = alignOf(d);
11609
+ out.push({ geometry: d.geometry, text: v.text, fontSize: v.fontSize, bold: v.bold,
11610
+ color: resolveTextColor(this.style, contrastTextColor((this.categoryColorsRgb || [])[d.properties.cat])),
11611
+ pixelOffset: v.baseline ? [o[0], o[1] + v.baseline - 0.35 * v.fontSize] : o, order: orderOf.get(d) });
11612
+ }
11613
+ return out;
11614
+ }
11335
11615
  const { present, radii, offsets, fitScale } = (iso && isolatedBubblePackLayout(counts, BUBBLE_ICON_SIZE, iso)) || computeBubblePackLayout(counts, BUBBLE_ICON_SIZE);
11336
11616
 
11337
11617
  present.forEach((p, i) => {
@@ -11343,13 +11623,24 @@
11343
11623
  out.push({
11344
11624
  geometry: d.geometry, text, fontSize,
11345
11625
  color: resolveTextColor(this.style, contrastTextColor((this.categoryColorsRgb || [])[p.i])),
11346
- pixelOffset: [offsets[i].x * fitScale * pxPerCanvasUnit + alignOf(d)[0], offsets[i].y * fitScale * pxPerCanvasUnit + alignOf(d)[1]]
11626
+ pixelOffset: [offsets[i].x * fitScale * pxPerCanvasUnit + alignOf(d)[0], offsets[i].y * fitScale * pxPerCanvasUnit + alignOf(d)[1]],
11627
+ order: orderOf.get(d)
11347
11628
  });
11348
11629
  }
11349
11630
  });
11350
11631
  return out;
11351
11632
  }, []);
11352
11633
 
11634
+ // a text under later charts fades by what still shows through them
11635
+ // (chartTextTransmittance), as flat's per-chart SVG groups paint
11636
+ // each later symbol and glow over it; nearly hidden ones are dropped
11637
+ const visible = this._occludeChartTexts(combined, pointLabels.concat(groupLabels), {
11638
+ liveZoom, globeCenter, alignOf, radiusOf, sizeValueOf, singleSymbol, shapeOf, fillOpacity, vRadius,
11639
+ singleBorderWidthPx
11640
+ });
11641
+ const keepVisible = labels => labels.filter(l => visible.has(l));
11642
+ const shownPointLabels = keepVisible(pointLabels), shownGroupLabels = keepVisible(groupLabels);
11643
+
11353
11644
  const textLayerCommonProps = {
11354
11645
  pickable: false,
11355
11646
  getPosition: d => d.geometry.coordinates,
@@ -11359,13 +11650,18 @@
11359
11650
  sizeUnits: 'pixels',
11360
11651
  fontFamily: 'arial',
11361
11652
  fontWeight: 'bold',
11653
+ // the glyphs of the actual texts, not deck's ASCII default — a
11654
+ // unit like " €" (the fuel-price page) is outside it ("Missing
11655
+ // character: €" and no glyph)
11656
+ characterSet: 'auto',
11362
11657
  getTextAnchor: 'middle',
11363
11658
  getAlignmentBaseline: 'center'
11364
11659
  };
11365
11660
  // 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' }));
11368
- if (groupLabels.length) layers.push(new TextLayer({ id: `ix-cluster-values-${this.name}`, data: groupLabels, getPixelOffset: d => d.pixelOffset, ...textLayerCommonProps }));
11661
+ if (shownPointLabels.length) layers.push(new TextLayer({ id: `ix-points-values-${this.name}`, data: shownPointLabels, getPixelOffset: d => d.pixelOffset, ...textLayerCommonProps,
11662
+ fontWeight: shownPointLabels[0].bold === false ? 'normal' : 'bold' }));
11663
+ if (shownGroupLabels.length) layers.push(new TextLayer({ id: `ix-cluster-values-${this.name}`, data: shownGroupLabels, getPixelOffset: d => d.pixelOffset, ...textLayerCommonProps,
11664
+ fontWeight: shownGroupLabels[0].bold === false ? 'normal' : 'bold' }));
11369
11665
  }
11370
11666
 
11371
11667
  // LABEL: flat's chart label (maptheme.js 24533-24590) — a small text
@@ -11726,6 +12022,67 @@
11726
12022
  // the item color (stroke font/7, opacity 0.5).
11727
12023
  // Both moved by the chart position (flatChartAlignOffset). The text is
11728
12024
  // the hover target of TEXTONLY, the box of LABEL.
12025
+ // The charts of _buildChartLayers' bubbles layer (and its GLOW / AURA)
12026
+ // as chartTextTransmittance's occluders, in draw order and screen
12027
+ // pixels, and each text's resulting alpha: the text's color gets it,
12028
+ // the returned Set holds the texts still visible. Screen pixels:
12029
+ // Web Mercator world pixels at the live zoom; under globe a local
12030
+ // equal-area frame around the view center at the scale MapLibre's
12031
+ // globe matches Mercator's there (the limb's foreshortening ignored).
12032
+ _occludeChartTexts(combined, texts, o) {
12033
+ const visible = new Set();
12034
+ if (!texts.length) return visible;
12035
+ const toRad = Math.PI / 180;
12036
+ let screenOf;
12037
+ if (o.globeCenter) {
12038
+ const s = 512 * Math.pow(2, o.liveZoom) / 360 / Math.cos(o.globeCenter.lat * toRad);
12039
+ screenOf = c => [(((c[0] - o.globeCenter.lng) % 360 + 540) % 360 - 180) * Math.cos(c[1] * toRad) * s, -c[1] * s];
12040
+ } else {
12041
+ screenOf = c => { const p = lngLatToWorldPixel(c[0], c[1], o.liveZoom); return [p.x, p.y]; };
12042
+ }
12043
+ const glowOn = this.flags.has('GLOW') && !glowHiddenByScale(this.style, o.liveZoom);
12044
+ const auraOn = !glowOn && this.flags.has('AURA') && !this.flags.has('ZOOM');
12045
+ const symbols = Array.isArray(this.style.symbols) ? this.style.symbols : null;
12046
+ const charts = combined.map(d => {
12047
+ const [x, y] = screenOf(d.geometry.coordinates);
12048
+ const a = o.alignOf(d), r = o.radiusOf(d), iconAlpha = this._iconAlpha(d) / 255;
12049
+ let parts;
12050
+ if (o.singleSymbol(d)) {
12051
+ // the icon's shape is inset by half its border (_buildSingleIcon)
12052
+ const shape = o.shapeOf(d.properties);
12053
+ parts = [{ dx: 0, dy: 0, r: r * (1 - o.singleBorderWidthPx / BUBBLE_ICON_SIZE), shape, alpha: o.fillOpacity * iconAlpha }];
12054
+ } else {
12055
+ // _buildBubbleIcon's packed parts, its baked 0.9 alpha scaled by an
12056
+ // explicit fillopacity like the bubbles layer's getColor
12057
+ const counts = d.properties.counts, iso = this._groupIsolation(d);
12058
+ const { present, radii, offsets, fitScale } = (iso && isolatedBubblePackLayout(counts, BUBBLE_ICON_SIZE, iso)) || computeBubblePackLayout(counts, BUBBLE_ICON_SIZE);
12059
+ const px = 2 * r / BUBBLE_ICON_SIZE * fitScale;
12060
+ const alpha = 0.9 * (this.style.fillopacity != null ? o.fillOpacity : 1) * iconAlpha;
12061
+ parts = present.map((p, i) => ({ dx: offsets[i].x * px, dy: offsets[i].y * px, r: radii[i] * px,
12062
+ shape: symbols ? normalizeSymbolShape(symbols[Math.min(p.i, symbols.length - 1)]) : 'circle', alpha }));
12063
+ }
12064
+ if (auraOn) parts.push({ dx: 0, dy: 0, r: r * 1.3, shape: 'circle', alpha: 0.3 * iconAlpha });
12065
+ return {
12066
+ x: x + a[0], y: y + a[1], parts,
12067
+ glowR: glowOn ? o.vRadius(o.sizeValueOf(d)) * (d.properties.counts ? 9 : 11) / 2 : 0,
12068
+ glowAlpha: iconAlpha
12069
+ };
12070
+ });
12071
+ // the text box: Arial digits are ~0.56 em wide, ~0.7 em high
12072
+ const boxes = texts.map(t => {
12073
+ const [x, y] = screenOf(t.geometry.coordinates);
12074
+ return { order: t.order, x: x + t.pixelOffset[0], y: y + t.pixelOffset[1], w: 0.56 * t.fontSize * String(t.text).length, h: 0.7 * t.fontSize };
12075
+ });
12076
+ const shown = chartTextTransmittance(charts, boxes);
12077
+ texts.forEach((t, i) => {
12078
+ if (shown[i] < CHART_TEXT_MIN_TRANSMITTANCE) return;
12079
+ const c = t.color || [0, 0, 0];
12080
+ if (shown[i] < 1) t.color = [c[0], c[1], c[2], Math.round((c[3] != null ? c[3] : 255) * shown[i])];
12081
+ visible.add(t);
12082
+ });
12083
+ return visible;
12084
+ }
12085
+
11729
12086
  _buildLabelChartLayers(combined, zoom, liveZoom, sizeValueOf) {
11730
12087
  const textOnly = this.flags.has('TEXTONLY');
11731
12088
  const showValues = this.flags.has('VALUES') && !valuesHiddenByScale(this.style, zoom);
@@ -11882,6 +12239,11 @@
11882
12239
  // gray stroke (fill-opacity:0, stroke #888888)
11883
12240
  function drawSymbolPath(ctx, shape, cx, cy, r) {
11884
12241
  switch (shape) {
12242
+ // flat's per-part SYMBOL square (maptheme.js 22037): side 2r — with
12243
+ // GRIDSIZE the whole cell (the fuel-price grid, contiguous squares)
12244
+ case 'squarefull':
12245
+ ctx.rect(cx - r, cy - r, 2 * r, 2 * r);
12246
+ break;
11885
12247
  case 'square': {
11886
12248
  const s = r * Math.SQRT2;
11887
12249
  ctx.rect(cx - s / 2, cy - s / 2, s, s);
@@ -12084,7 +12446,7 @@
12084
12446
  // map named by szMap (its Map() options name) or, without a name, the
12085
12447
  // last built map; called while the map is still building, they run once
12086
12448
  // it is ready.
12087
- const MAP_HANDLE_METHODS = ['replace', 'add', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
12449
+ const MAP_HANDLE_METHODS = ['replace', 'add', 'newTheme', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
12088
12450
  'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'setProjection', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible', 'setThemeTimeFrame', 'attribution'];
12089
12451
  const _mapHandles = Object.create(null);
12090
12452
  function makeMapHandle(szMap) {
@@ -12749,6 +13111,8 @@
12749
13111
  htmlgui_getAttributionString: () => (_lastMapApi && _lastMapApi.getAttribution ? _lastMapApi.getAttribution() : ''),
12750
13112
  changeThemeStyle: compatChangeThemeStyle,
12751
13113
  removeTheme: compatRemoveTheme,
13114
+ // flat's ixmaps.newTheme(title, theme, flag) — on the last built map
13115
+ newTheme: (title, theme, flag) => _mapHandle.newTheme(title, theme, flag),
12752
13116
  // flat's time slider: show the records of a theme (null = all themes) whose timefield lies in [min, max)
12753
13117
  setThemeTimeFrame: (szId, nMin, nMax) => _mapHandle.setThemeTimeFrame(szId, nMin, nMax),
12754
13118
  setBasemapOpacity: compatSetBasemapOpacity,
@@ -12775,7 +13139,7 @@
12775
13139
  // test-only: lets test/unit/*.test.mjs call pure internals directly (the
12776
13140
  // engine runs in a Node vm there); deliberately NOT on the ixmaps object
12777
13141
  global.__ixmapsGlInternals = {
12778
- IXMAPS_GL_VERSION, youtubeClickToPlay, symbolsFlagCompat, tooltipTable, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout, ringsLayout, multiQuadOffsets, pixelOffsetLngLat,
13142
+ IXMAPS_GL_VERSION, youtubeClickToPlay, symbolsFlagCompat, tooltipTable, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, chartTextTransmittance, glowProfileAlpha, insideSymbolShape, sequenceLayout, ringsLayout, multiQuadOffsets, pixelOffsetLngLat,
12779
13143
  normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, MapBuilder, typeStyleNumbers, styleNum,
12780
13144
  resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
12781
13145
  equalIntervalBreaks, quantileBreaks, naturalBreaks, evenStrideSample, jenksBreakValues, partsFromBreakValues, resolvePartsClass,
@@ -12786,7 +13150,7 @@
12786
13150
  fetchLayerData, parseCsvText, dataTableRows, geometryRowsToFeatureCollection, filterFlatValues, applyField100, field100Binding, rangeClassLegendTotals, resolveAggregateValue, classValueSeparate, cellAggregatedValues, oneHot, groupCoLocated, aggregateOnGrid, GridAggregateIndex, flatRangeParts, aggregateField100, valueRadius, itemPlotGeometry, itemAnchor, objectZoomFactor, resolveZoomReference, defaultNormalSizeValue,
12787
13151
  applyWhereFilter, joinChartPositions, flatLookupKey, flatShapeCenter, chartHiddenByScale, glowHiddenByScale, isSymbolChart, flatChartAlignOffset, flatChartBranch, flatValueText, categoryValueRecord, flatDerivateRgb, flatChartTextRgb, flatGroupedValue, flatNoBreaks, featuresHiddenByScale, boxHiddenByScale, flatShadowOn, snapToAggregationGrid,
12788
13152
  drawSymbolPath, normalizeSymbolShape,
12789
- flatFormatValue, themeStyleArgs, explicitValueDecimals, legendDecimals,
13153
+ flatFormatValue, themeStyleArgs, flatChangedStyleValue, explicitValueDecimals, legendDecimals,
12790
13154
  dataCacheKey, dataCacheDisabled, cachedLayerData, featuresBounds, wantsZoomToExtent, legendIsOn,
12791
13155
  };
12792
13156
  global.__setFilter = __setFilter;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ixmaps-gl",
3
- "version": "0.2.17",
3
+ "version": "0.2.18",
4
4
  "description": "MapLibre GL + deck.gl reimplementation of the ixmaps-flat declarative mapping engine",
5
5
  "main": "ixmaps-gl.js",
6
6
  "browser": "ixmaps-gl.js",