ixmaps-gl 0.2.17 → 0.2.19

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 +269 -59
  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.19/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.19/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.19/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.19';
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']) {
@@ -8743,7 +8872,11 @@
8743
8872
  }
8744
8873
 
8745
8874
  _usesAggregationIndex() {
8746
- return (this.flags.has('AGGREGATE') || isSymbolChart(this.flags)) && !this.flags.has('GRIDSIZE');
8875
+ // GRIDSIZE bins its own grid (_ensureGridIndex) — PLOT curves and the
8876
+ // plain mesh; a classed GRIDSIZE symbol chart is aggregated like any
8877
+ // other (see _isPlainGridMesh)
8878
+ return (this.flags.has('AGGREGATE') || isSymbolChart(this.flags))
8879
+ && (!this.flags.has('GRIDSIZE') || (!this.flags.has('PLOT') && !this._isPlainGridMesh()));
8747
8880
  }
8748
8881
 
8749
8882
  _rebuildActiveFeatures() {
@@ -9487,16 +9620,19 @@
9487
9620
  // GRIDSIZE (spatial grid binning) is checked before the generic
9488
9621
  // CHART|SYMBOL bubble pipeline, same "more specific base type wins"
9489
9622
  // 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).
9623
+ // (_buildPlotLayers); GRIDSIZE without PLOT is flat's symbol chart
9624
+ // with `nRadius = nGridSize/2` (maptheme.js 21843) — every chart the
9625
+ // size of its cell, colored by its class (QUANTILE of the MEAN, the
9626
+ // fuel-price page). Only a plain one-color grid (the "grid"
9627
+ // companion layer) takes the uniform mesh shortcut.
9496
9628
  if (this.flags.has('GRIDSIZE') && this.flags.has('PLOT')) return this._buildPlotLayers(zoom, bbox);
9497
9629
  // PLOT without GRIDSIZE: one line chart per item over its value fields
9498
9630
  if (this.flags.has('PLOT') && this._isItemPlot()) return this._buildItemPlotLayers(zoom);
9499
- if (this.flags.has('GRIDSIZE')) return this._buildGridMeshLayers(zoom, bbox);
9631
+ // a plain grid (one color, no values or glow — the Germany page's
9632
+ // companion "grid" layer) as one square per cell; a classed GRIDSIZE
9633
+ // chart (the fuel-price page) is a symbol chart sized to the grid,
9634
+ // below in _buildChartLayers (_gridChartRadiusPx)
9635
+ if (this.flags.has('GRIDSIZE') && this._isPlainGridMesh()) return this._buildGridMeshLayers(zoom, bbox);
9500
9636
  // CHOROPLETH is checked before the generic FEATURE dispatch — a
9501
9637
  // CHOROPLETH layer's own type string doesn't carry FEATURE/FEATURES
9502
9638
  // (only its geometry-donor base layer does, see
@@ -9852,6 +9988,27 @@
9852
9988
  // style.values/xaxis (explicit, see _prepare) if set, else whatever
9853
9989
  // CATEGORICAL auto-discovered.
9854
9990
  // a per-item PLOT: no GRIDSIZE grid, several value fields per item
9991
+ // GRIDSIZE without PLOT drawn as one uniform square per cell: only a
9992
+ // plain grid — a single color, no VALUES, no GLOW
9993
+ _isPlainGridMesh() {
9994
+ return !this.flags.has('PLOT') && (this.categoryColorsRgb || []).length <= 1
9995
+ && !this.flags.has('VALUES') && !this.flags.has('GLOW');
9996
+ }
9997
+ // GRIDSIZE (no PLOT) symbol chart radius, px: flat's nGridSize / 2
9998
+ // (maptheme.js 21843), nGridSize the aggregation cell pitch / nAutoSize
9999
+ // (16944-16952: RECT or no AGGREGATE 1.0, with GAP 1.15; else 0.75,
10000
+ // with GAP 0.8) · style.scale. Measured on the fuel-price page: 5 px
10001
+ // cells, RECT|GAP → nGridSize 4.35 px, circle r 2.17 px. null when not
10002
+ // a GRIDSIZE chart or no grid at this scale.
10003
+ _gridChartRadiusPx(zoom, liveZoom) {
10004
+ if (!this.flags.has('GRIDSIZE') || this.flags.has('PLOT') || !this._clusterRadiusPx) return null;
10005
+ const fromZoom = this._clusterUsesFixedZoom ? GRIDWIDTH_METERS_REFERENCE_ZOOM : zoom;
10006
+ const cellPx = this._clusterRadiusPx * Math.pow(2, liveZoom - fromZoom);
10007
+ const rect = flatFlag(this.flags, 'RECT') || !this.flags.has('AGGREGATE');
10008
+ const gap = flatFlag(this.flags, 'GAP');
10009
+ const autoSize = rect ? (gap ? 1.15 : 1.0) : (gap ? 0.8 : 0.75);
10010
+ return cellPx / autoSize * (styleNum(this.style.scale) || 1) / 2;
10011
+ }
9855
10012
  _isItemPlot() {
9856
10013
  return this.flags.has('PLOT') && !this.flags.has('GRIDSIZE') && String(this.binding.value || '').includes('|');
9857
10014
  }
@@ -10715,6 +10872,20 @@
10715
10872
  ...(f.properties.titleRaw ? { titleRaw: f.properties.titleRaw } : {}) }
10716
10873
  };
10717
10874
  });
10875
+ // MEAN (no CATEGORICAL): a cell's value is the mean of its records,
10876
+ // not their sum (flat's MEAN aggregation) — what its label prints,
10877
+ // its size and its class follow. The fuel-price page: 119 stations,
10878
+ // sum 270.0 → 2.27 €
10879
+ if (this.flags.has('MEAN') && !this.flags.has('CATEGORICAL')) {
10880
+ groups.forEach(g => {
10881
+ const p = g.properties;
10882
+ const n = groupRecordCount(p);
10883
+ if (!(n > 0)) return;
10884
+ if (p.recordCounts) p.counts = p.counts.map((c, i) => (p.recordCounts[i] ? c / p.recordCounts[i] : c));
10885
+ p.total = p.total / n;
10886
+ if (p.classTotal !== undefined) p.classTotal = p.classTotal / n;
10887
+ });
10888
+ }
10718
10889
  return { individual, groups };
10719
10890
  }
10720
10891
 
@@ -10842,6 +11013,10 @@
10842
11013
  // _computeAggregatedItems below — a plain (non-AGGREGATE)
10843
11014
  // BUBBLE/CHART theme never reads them.
10844
11015
  if (this.flags.has('AGGREGATE')) this._ensureClusterIndices(zoom);
11016
+ // GRIDSIZE: every chart the size of its grid cell, whatever its value
11017
+ // (_gridChartRadiusPx); otherwise valueRadius
11018
+ const gridR = this._gridChartRadiusPx(zoom, liveZoom);
11019
+ const vRadius = v => (gridR != null ? gridR : valueRadius(v, liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue));
10845
11020
  // AGGREGATE sums without a normalsizevalue: flat's default is the
10846
11021
  // largest aggregated value (defaultNormalSizeValue leaves it unset, the
10847
11022
  // sizes depend on the aggregation)
@@ -10999,7 +11174,7 @@
10999
11174
  const singleBorderWidthPx = styleNum(this.style.linewidth) || 0;
11000
11175
 
11001
11176
  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);
11177
+ const radiusOf = d => vRadius(sizeValueOf(d));
11003
11178
  // align / offsetx / offsety: the whole chart moves (flatChartAlignOffset)
11004
11179
  // MULTIQUAD / MULTISQUARE: the items at one position side by side
11005
11180
  // (multiQuadOffsets), added to the chart position
@@ -11048,7 +11223,7 @@
11048
11223
  data: combined, pickable: false,
11049
11224
  getPosition: d => d.geometry.coordinates,
11050
11225
  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),
11226
+ getSize: d => vRadius(sizeValueOf(d)) * (d.properties.counts ? 9 : 11),
11052
11227
  getColor: d => [255, 255, 255, this._iconAlpha(d)],
11053
11228
  ...(alignOf.active ? { getPixelOffset: alignOf } : {}),
11054
11229
  sizeUnits: 'pixels',
@@ -11124,7 +11299,7 @@
11124
11299
  const alignRight = /right/.test(String(this.style.align || ''));
11125
11300
  // with MULTIQUAD only the first item of a position gets its box/title
11126
11301
  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);
11302
+ const r = vRadius(sizeValueOf(d));
11128
11303
  const ext = multi ? multi.extent.get(d) : [0, 0, 0, 0];
11129
11304
  const chart = [ext[0] - r, ext[1] - r, ext[2] + r, ext[3] + r];
11130
11305
  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 +11424,27 @@
11249
11424
  }));
11250
11425
  }
11251
11426
 
11427
+ // flat's per-part SYMBOL branch (everything but CATEGORICAL without
11428
+ // AGGREGATE/GROUP) draws a square of side 2r, the other one 1.6r
11429
+ // (drawSymbolPath's own square)
11430
+ const perPartBranch = flatChartBranch(this.flags) === 'symbol'
11431
+ && !(this.flags.has('CATEGORICAL') && !this.flags.has('AGGREGATE') && !flatFlag(this.flags, 'GROUP'));
11432
+ const shapeOf = props => { const sh = this._resolveSymbolShape(props); return sh === 'square' && perPartBranch ? 'squarefull' : sh; };
11433
+ // a range-classed cell is ONE class: one symbol in its class color,
11434
+ // fillopacity and linecolor, as a single item (flat draws one
11435
+ // symbol) — the packed multi-category icon is for CATEGORICAL cells
11436
+ const singleSymbol = d => !d.properties.counts || (this._rangeClassed && d.properties.cat != null);
11252
11437
  layers.push(new IconLayer({
11253
11438
  id: `ix-bubbles-${this.name}-g${this._iconGeneration}`,
11254
11439
  data: combined, pickable: true,
11255
11440
  getPosition: d => d.geometry.coordinates,
11256
- getIcon: d => d.properties.counts
11441
+ getIcon: d => !singleSymbol(d)
11257
11442
  ? 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,
11443
+ : this._buildSingleIcon((this.categoryColorsRgb || [])[d.properties.cat], fillOpacity, shapeOf(d.properties), singleBorderColorRgb, singleBorderWidthPx),
11444
+ getSize: d => vRadius(sizeValueOf(d)) * 2,
11260
11445
  // group icons bake a fixed 0.9 alpha; an explicit fillopacity scales it
11261
11446
  // 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))],
11447
+ getColor: d => [255, 255, 255, Math.round(this._iconAlpha(d) * (!singleSymbol(d) && this.style.fillopacity != null ? fillOpacity : 1))],
11263
11448
  ...(alignOf.active ? { getPixelOffset: alignOf } : {}),
11264
11449
  sizeUnits: 'pixels',
11265
11450
  billboard: true,
@@ -11304,7 +11489,7 @@
11304
11489
  const pointValueOf = valueTextOf || this._chartValueTextFn();
11305
11490
 
11306
11491
  const pointLabels = individual.reduce((out, d) => {
11307
- const radius = valueRadius(d.properties.value, liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
11492
+ const radius = vRadius(d.properties.value);
11308
11493
  // flat's VALUES label prints the value (nValuesA), not the size;
11309
11494
  // its font by flat's chart branch (_chartValueTextFn)
11310
11495
  const { text, fontSize, bold, baseline } = pointValueOf(d, radius);
@@ -11328,10 +11513,23 @@
11328
11513
  // number nobody's individual bubble actually shows.
11329
11514
  const groupLabels = groups.reduce((out, d) => {
11330
11515
  const counts = d.properties.counts;
11331
- const outerRadiusPx = valueRadius(d.properties.total, liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
11516
+ const outerRadiusPx = vRadius(d.properties.total);
11332
11517
  const iconSizePx = outerRadiusPx * 2; // matches the cluster IconLayer's own getSize (*2) below
11333
11518
  const pxPerCanvasUnit = iconSizePx / BUBBLE_ICON_SIZE;
11334
11519
  const iso = this._groupIsolation(d);
11520
+ // a range-classed cell is one symbol (see the bubbles layer): its
11521
+ // value text as a single chart's (_chartValueTextFn)
11522
+ if (this._rangeClassed && d.properties.cat != null) {
11523
+ const value = d.properties.classTotal !== undefined ? d.properties.classTotal : d.properties.total;
11524
+ const v = pointValueOf({ properties: { value, cat: d.properties.cat } }, outerRadiusPx);
11525
+ if (v.fontSize > VALUES_MIN_FONT_PX) {
11526
+ const o = alignOf(d);
11527
+ out.push({ geometry: d.geometry, text: v.text, fontSize: v.fontSize, bold: v.bold,
11528
+ color: resolveTextColor(this.style, contrastTextColor((this.categoryColorsRgb || [])[d.properties.cat])),
11529
+ pixelOffset: v.baseline ? [o[0], o[1] + v.baseline - 0.35 * v.fontSize] : o });
11530
+ }
11531
+ return out;
11532
+ }
11335
11533
  const { present, radii, offsets, fitScale } = (iso && isolatedBubblePackLayout(counts, BUBBLE_ICON_SIZE, iso)) || computeBubblePackLayout(counts, BUBBLE_ICON_SIZE);
11336
11534
 
11337
11535
  present.forEach((p, i) => {
@@ -11359,13 +11557,18 @@
11359
11557
  sizeUnits: 'pixels',
11360
11558
  fontFamily: 'arial',
11361
11559
  fontWeight: 'bold',
11560
+ // the glyphs of the actual texts, not deck's ASCII default — a
11561
+ // unit like " €" (the fuel-price page) is outside it ("Missing
11562
+ // character: €" and no glyph)
11563
+ characterSet: 'auto',
11362
11564
  getTextAnchor: 'middle',
11363
11565
  getAlignmentBaseline: 'center'
11364
11566
  };
11365
11567
  // flat's SYMBOL branch writes its values in normal weight
11366
11568
  if (pointLabels.length) layers.push(new TextLayer({ id: `ix-points-values-${this.name}`, data: pointLabels, getPixelOffset: d => d.pixelOffset, ...textLayerCommonProps,
11367
11569
  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 }));
11570
+ if (groupLabels.length) layers.push(new TextLayer({ id: `ix-cluster-values-${this.name}`, data: groupLabels, getPixelOffset: d => d.pixelOffset, ...textLayerCommonProps,
11571
+ fontWeight: groupLabels[0].bold === false ? 'normal' : 'bold' }));
11369
11572
  }
11370
11573
 
11371
11574
  // LABEL: flat's chart label (maptheme.js 24533-24590) — a small text
@@ -11882,6 +12085,11 @@
11882
12085
  // gray stroke (fill-opacity:0, stroke #888888)
11883
12086
  function drawSymbolPath(ctx, shape, cx, cy, r) {
11884
12087
  switch (shape) {
12088
+ // flat's per-part SYMBOL square (maptheme.js 22037): side 2r — with
12089
+ // GRIDSIZE the whole cell (the fuel-price grid, contiguous squares)
12090
+ case 'squarefull':
12091
+ ctx.rect(cx - r, cy - r, 2 * r, 2 * r);
12092
+ break;
11885
12093
  case 'square': {
11886
12094
  const s = r * Math.SQRT2;
11887
12095
  ctx.rect(cx - s / 2, cy - s / 2, s, s);
@@ -12084,7 +12292,7 @@
12084
12292
  // map named by szMap (its Map() options name) or, without a name, the
12085
12293
  // last built map; called while the map is still building, they run once
12086
12294
  // it is ready.
12087
- const MAP_HANDLE_METHODS = ['replace', 'add', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
12295
+ const MAP_HANDLE_METHODS = ['replace', 'add', 'newTheme', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
12088
12296
  'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'setProjection', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible', 'setThemeTimeFrame', 'attribution'];
12089
12297
  const _mapHandles = Object.create(null);
12090
12298
  function makeMapHandle(szMap) {
@@ -12749,6 +12957,8 @@
12749
12957
  htmlgui_getAttributionString: () => (_lastMapApi && _lastMapApi.getAttribution ? _lastMapApi.getAttribution() : ''),
12750
12958
  changeThemeStyle: compatChangeThemeStyle,
12751
12959
  removeTheme: compatRemoveTheme,
12960
+ // flat's ixmaps.newTheme(title, theme, flag) — on the last built map
12961
+ newTheme: (title, theme, flag) => _mapHandle.newTheme(title, theme, flag),
12752
12962
  // flat's time slider: show the records of a theme (null = all themes) whose timefield lies in [min, max)
12753
12963
  setThemeTimeFrame: (szId, nMin, nMax) => _mapHandle.setThemeTimeFrame(szId, nMin, nMax),
12754
12964
  setBasemapOpacity: compatSetBasemapOpacity,
@@ -12786,7 +12996,7 @@
12786
12996
  fetchLayerData, parseCsvText, dataTableRows, geometryRowsToFeatureCollection, filterFlatValues, applyField100, field100Binding, rangeClassLegendTotals, resolveAggregateValue, classValueSeparate, cellAggregatedValues, oneHot, groupCoLocated, aggregateOnGrid, GridAggregateIndex, flatRangeParts, aggregateField100, valueRadius, itemPlotGeometry, itemAnchor, objectZoomFactor, resolveZoomReference, defaultNormalSizeValue,
12787
12997
  applyWhereFilter, joinChartPositions, flatLookupKey, flatShapeCenter, chartHiddenByScale, glowHiddenByScale, isSymbolChart, flatChartAlignOffset, flatChartBranch, flatValueText, categoryValueRecord, flatDerivateRgb, flatChartTextRgb, flatGroupedValue, flatNoBreaks, featuresHiddenByScale, boxHiddenByScale, flatShadowOn, snapToAggregationGrid,
12788
12998
  drawSymbolPath, normalizeSymbolShape,
12789
- flatFormatValue, themeStyleArgs, explicitValueDecimals, legendDecimals,
12999
+ flatFormatValue, themeStyleArgs, flatChangedStyleValue, explicitValueDecimals, legendDecimals,
12790
13000
  dataCacheKey, dataCacheDisabled, cachedLayerData, featuresBounds, wantsZoomToExtent, legendIsOn,
12791
13001
  };
12792
13002
  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.19",
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",