ixmaps-gl 0.2.8 → 0.2.9

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 +9 -5
  2. package/ixmaps-gl.js +613 -55
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -36,7 +36,8 @@ page. It currently implements:
36
36
  (equal-interval, QUANTILE, NATURAL/Jenks breaks) or multi-field DOMINANT
37
37
  (per-polygon argmax, plain/`PERCENTOFMEAN`/`DEVIATION`; with a `value100` the field means are
38
38
  flat's pooled Σ field ÷ Σ value100) or COMPOSECOLOR (additive or
39
- `SUBTRACTIVE` color blend); `HEADTAIL` breaks, `DENSITY` (value per km²), flat's
39
+ `SUBTRACTIVE` color blend) or, with `CATEGORICAL|AGGREGATE` on one field, the dominant category of
40
+ each polygon's records (sum of the size field per category); `HEADTAIL` breaks, `DENSITY` (value per km²), flat's
40
41
  `ZEROISNOTVALUE`/`UNDEFINEDISNOTVALUE`; plus DOPACITY/DOPACITYMIN/DOPACITYMAX/DOPACITYMINMAX for
41
42
  value- or density-driven fill opacity
42
43
  - **CHART\|SYMBOL\|GLOW\|CATEGORICAL\|AGGREGATE\|COUNT\|RELOCATE\|VALUES** — the
@@ -55,6 +56,9 @@ page. It currently implements:
55
56
  chart — symbol, glow, shadow, box, title and value — as flat does
56
57
  - A range theme of at most two classes gets flat's one-row legend (the value range, or for a
57
58
  `$item$` count the label or field name)
59
+ - **CHART\|SYMBOL\|SEQUENCE** on AGGREGATE CATEGORICAL data — one chart per cell (or aggregation
60
+ field value) with a symbol per category, laid out as flat does (`STAR`, `HORZ`, centered), with
61
+ flat's part sizes, glow and value labels
58
62
  - **CHART\|USER** — a page's own chart function (`style.userdraw`: `ixmaps.<name>(SVGDocument, opt)`
59
63
  and its `_init`, as flat calls them), drawn in flat's chart units and shown as an icon at flat's size
60
64
  - **DOT** — the simplest base symbol (fixed-radius, unclustered points)
@@ -72,7 +76,7 @@ page. It currently implements:
72
76
  `window.__setFacetFilter`) for building filterable sidebars
73
77
  - Globe (orthographic) projection alongside flat Mercator
74
78
 
75
- Not yet implemented: `CATEGORICAL` choropleths, and the `QUAD`/`BEZIER`/`VECTOR`/
79
+ Not yet implemented: plain (non-AGGREGATE) `CATEGORICAL` choropleths, and the `QUAD`/`BEZIER`/`VECTOR`/
76
80
  `PIE`/`DONUT`/`WAFFLE`/`BAR` base types and SYMBOL shape variants beyond
77
81
  circle/square/diamond/triangle. See the top-of-file comment in
78
82
  [`ixmaps-gl.js`](./ixmaps-gl.js) for the exact, currently-accurate scope note.
@@ -203,7 +207,7 @@ of your own project files gl can render.
203
207
  Load the engine from jsDelivr, pinned to a release tag (or `@main` for the latest commit):
204
208
 
205
209
  ```html
206
- <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.8/ixmaps-gl.js"></script>
210
+ <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.9/ixmaps-gl.js"></script>
207
211
  ```
208
212
 
209
213
  or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmaps-gl)
@@ -211,13 +215,13 @@ or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmap
211
215
  browser script, which defines the global `ixmaps`):
212
216
 
213
217
  ```html
214
- <script src="https://unpkg.com/ixmaps-gl@0.2.8/ixmaps-gl.js"></script>
218
+ <script src="https://unpkg.com/ixmaps-gl@0.2.9/ixmaps-gl.js"></script>
215
219
  ```
216
220
 
217
221
  or use a local copy (`<script src="ixmaps-gl.js"></script>`). A minimal page:
218
222
 
219
223
  ```html
220
- <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.8/ixmaps-gl.js"></script>
224
+ <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.9/ixmaps-gl.js"></script>
221
225
  <div id="map_div" style="position:absolute;inset:0;"></div>
222
226
 
223
227
  <script>
package/ixmaps-gl.js CHANGED
@@ -20,8 +20,9 @@
20
20
  // from the same bound value by default, or from .binding({alpha,
21
21
  // alpha100}) when set (alpha100:"$density$" divides by the polygon's
22
22
  // own geodesic area — see _prepareAlphaField/_resolveDopacityAlpha).
23
- // CATEGORICAL choropleths aren't implemented, see
24
- // _buildChoroplethLayers), and the CHART|SYMBOL|
23
+ // AGGREGATE CATEGORICAL on one field paints each polygon in its records'
24
+ // dominant category; a plain CATEGORICAL choropleth is not implemented,
25
+ // see _buildChoroplethLayers), and the CHART|SYMBOL|
25
26
  // GLOW|CATEGORICAL|AGGREGATE|COUNT|RELOCATE|VALUES pipeline (categorical
26
27
  // clustering + sizing + glow + multi-bubble grouping + on-bubble value
27
28
  // labels, generalized to however many distinct category values the DATA
@@ -81,7 +82,7 @@
81
82
  // ---------------------------------------------------------------
82
83
  // this engine's release (= package.json "version", checked by
83
84
  // test/unit/version.test.mjs); ixmaps.glVersion, and logged once at start
84
- const IXMAPS_GL_VERSION = '0.2.8';
85
+ const IXMAPS_GL_VERSION = '0.2.9';
85
86
 
86
87
  const LIB_URLS = {
87
88
  // Bumped 3.6.2 -> 5.x (2026-09-21, globe-projection compat fix): native
@@ -307,7 +308,7 @@
307
308
  // crafted link must not be able to load arbitrary code into the page.
308
309
  // ---------------------------------------------------------------
309
310
  // v0.1.6: knows this engine's aliases, lookupfield rule, meta↔style, builder methods, project loading
310
- const VALIDATOR_URL_DEFAULT = 'https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-grammar@v0.1.23/dist/validate.mjs';
311
+ const VALIDATOR_URL_DEFAULT = 'https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-grammar@v0.1.24/dist/validate.mjs';
311
312
  // a page may set ixmaps.validate BEFORE loading this script — the
312
313
  // `global.ixmaps = {...}` export below would otherwise overwrite it
313
314
  const _preloadValidate = global.ixmaps && global.ixmaps.validate;
@@ -1464,8 +1465,53 @@
1464
1465
  function featureAreaKm2(f) {
1465
1466
  return f._flatAreaKm2 !== undefined ? f._flatAreaKm2 : geodesicPolygonAreaKm2(f.geometry);
1466
1467
  }
1468
+ // .type("CHOROPLETH|CATEGORICAL|AGGREGATE") on ONE value field (e.g.
1469
+ // DOMINANT over the categories of "missione"): flat aggregates every
1470
+ // record of a polygon into a part per category — the sum of the size
1471
+ // field (else the record count, resolveAggregateValue) — and paints the
1472
+ // dominant part's class (maptheme.js 13520-13565)
1473
+ const AGGREGATED_ROWS = Symbol('ixmaps-gl aggregated rows');
1474
+ function isAggregatedCategoricalChoropleth(spec) {
1475
+ const flags = spec.flags;
1476
+ return !!(flags && flags.has('CHOROPLETH') && flags.has('CATEGORICAL') && flags.has('AGGREGATE')
1477
+ && spec.binding && spec.binding.value && !String(spec.binding.value).includes('|'));
1478
+ }
1479
+ // a polygon's parts (sum per category) and its dominant one: the biggest
1480
+ // part above 0, the first of equal ones; null without any
1481
+ function aggregatedCategoricalClass(rows, binding, flags, indexByLabel, nCats) {
1482
+ const parts = new Array(nCats).fill(0);
1483
+ for (const row of rows) {
1484
+ const i = indexByLabel.get(String(row[binding.value]));
1485
+ if (i == null) continue;
1486
+ parts[i] += resolveAggregateValue(binding, flags, row);
1487
+ }
1488
+ let index = -1;
1489
+ parts.forEach((v, i) => { if (v > 0 && (index < 0 || v > parts[index])) index = i; });
1490
+ return index < 0 ? null : { parts, index, value: parts[index] };
1491
+ }
1492
+ // flat's DOMINANT opacity (maptheme.js 13567-13628): from the dominant
1493
+ // part v and the biggest part of that class over all polygons, max —
1494
+ // DOPACITYMAX dopacityscale · (v / max)^(1/dopacitypow), DOPACITYLOGMAX
1495
+ // log v / log max, plain DOPACITY log(dopacityscale · v) / log max;
1496
+ // below 0.0001 → 0, at most 0.9
1497
+ function dominantDopacityAlpha(style, flags, v, max) {
1498
+ const scale = styleNum(style.dopacityscale) || 1;
1499
+ const pow = 1 / (styleNum(style.dopacitypow) || 1);
1500
+ let o;
1501
+ if (flags.has('DOPACITYLOGMAX')) o = Math.log(v) / Math.log(max);
1502
+ else if (flags.has('DOPACITYMAX') || flags.has('DOPACITYPOWMAX')) o = scale * Math.pow(v, pow) / Math.pow(max, pow);
1503
+ else o = Math.log(scale * v) / Math.log(max);
1504
+ if (!(o >= 0.0001)) o = 0;
1505
+ return Math.min(0.9, o);
1506
+ }
1507
+
1467
1508
  function joinChoroplethFeatures(spec, table, runtimes) {
1468
- const geomRt = runtimes.find(r => r.name === spec.name && (r.flags.has('FEATURE') || r.flags.has('FEATURES')));
1509
+ // a page can define several FEATURE layers of one name — e.g. the
1510
+ // comuni as polygons and as center points (for chart positions); flat
1511
+ // paints every shape of the layer with a matching id, so the polygons
1512
+ // are the ones that show: prefer a base with polygon geometry
1513
+ const bases = runtimes.filter(r => r.name === spec.name && (r.flags.has('FEATURE') || r.flags.has('FEATURES')));
1514
+ const geomRt = bases.find(r => (r.features || []).some(f => f.geometry && /Polygon/.test(f.geometry.type))) || bases[0];
1469
1515
  if (!geomRt) {
1470
1516
  throw new Error(`[ixmaps-gl] CHOROPLETH layer "${spec.name}" needs a FEATURE base layer with the same name, .layer()'d earlier on the map`);
1471
1517
  }
@@ -1488,6 +1534,27 @@
1488
1534
  if (!idField || !lookupField) {
1489
1535
  throw new Error(`[ixmaps-gl] CHOROPLETH layer "${spec.name}" needs .binding({lookup}), and its FEATURE base needs .binding({id})`);
1490
1536
  }
1537
+ // an AGGREGATE CATEGORICAL choropleth sums ALL the rows of a polygon
1538
+ // per category (aggregatedCategoricalClass) — every row is kept, under
1539
+ // AGGREGATED_ROWS (not enumerable: the first row stays the polygon's
1540
+ // properties for the tooltip and filters)
1541
+ if (isAggregatedCategoricalChoropleth(spec)) {
1542
+ const groups = new Map();
1543
+ for (const row of table.rows) {
1544
+ const key = String(row[lookupField]);
1545
+ if (!groups.has(key)) groups.set(key, []);
1546
+ groups.get(key).push(row);
1547
+ }
1548
+ return {
1549
+ type: 'FeatureCollection',
1550
+ features: geomRt.features.map(f => {
1551
+ const rows = groups.get(String(f.properties[idField]));
1552
+ const properties = rows ? Object.assign({}, rows[0]) : {};
1553
+ if (rows) Object.defineProperty(properties, AGGREGATED_ROWS, { value: rows });
1554
+ return { type: 'Feature', geometry: f.geometry, properties, _flatAreaKm2: flatShapeAreaKm2(geomRt, f) };
1555
+ })
1556
+ };
1557
+ }
1491
1558
  const rowsByKey = new Map(table.rows.map(row => [String(row[lookupField]), row]));
1492
1559
  // Unmatched polygons (no CSV row for that id) keep their geometry with
1493
1560
  // empty properties — real-world data: not every comune necessarily has
@@ -2194,7 +2261,7 @@
2194
2261
  // edit it by hand; `npm run unit` fails when it drifts.
2195
2262
  // ---------------------------------------------------------------
2196
2263
  // <grammar:binding-aliases>
2197
- // generated from ixmaps-grammar 0.1.23 (ixmaps-flat 1.0.42, 2b978d5 2026-10-02) — 52 aliases
2264
+ // generated from ixmaps-grammar 0.1.24 (ixmaps-flat 1.0.42, 2b978d5 2026-10-02) — 52 aliases
2198
2265
  const FLAT_BINDING_ALIASES = {
2199
2266
  "aggregation": "style.aggregationfield",
2200
2267
  "aggregationfield": "style.aggregationfield",
@@ -2256,7 +2323,7 @@
2256
2323
  // both — so .style({tooltip}) is a tooltip and .meta({fillopacity}) a style
2257
2324
  // property. GENERATED by test/sync-grammar.mjs — do not edit.
2258
2325
  // <grammar:meta-keys>
2259
- // generated from ixmaps-grammar 0.1.23 — 5 meta keys
2326
+ // generated from ixmaps-grammar 0.1.24 — 5 meta keys
2260
2327
  const FLAT_META_KEYS = ["description","name","snippet","title","tooltip"];
2261
2328
  // </grammar:meta-keys>
2262
2329
 
@@ -2923,6 +2990,9 @@
2923
2990
  // theme addressed only by its meta.name, same bug different
2924
2991
  // resolution path.
2925
2992
  if (rt.meta && rt.meta.name && rt.meta.name !== rt.name) _globalThemeRegistry.set(rt.meta.name, rt);
2993
+ // flat's theme id is style.name (maptheme szId) — several themes
2994
+ // can share one .layer() name (a page's features + charts + texts)
2995
+ if (themeIdOf(rt) !== rt.name) _globalThemeRegistry.set(themeIdOf(rt), rt);
2926
2996
  if (named) namedLoads.push(rt);
2927
2997
  } catch (err) {
2928
2998
  console.error(`[ixmaps-gl] theme "${lb.name}" skipped:`, err);
@@ -3416,6 +3486,8 @@
3416
3486
  },
3417
3487
  setMapTypeId: (id) => engineApi.setMapType(id),
3418
3488
  mapType: (id) => engineApi.setMapType(id),
3489
+ // flat's getMapTypeId(): the basemap type name in use
3490
+ getMapTypeId: () => String(builder.mapOptions.mapType || ''),
3419
3491
  setBasemapOpacity: (delta, mode) => {
3420
3492
  _basemapOpacity = mode === 'relative'
3421
3493
  ? Math.max(0, Math.min(1, _basemapOpacity + (parseFloat(delta) || 0)))
@@ -3857,6 +3929,7 @@
3857
3929
  runtimes.push(rt);
3858
3930
  _globalThemeRegistry.set(rt.name, rt);
3859
3931
  if (rt.meta && rt.meta.name && rt.meta.name !== rt.name) _globalThemeRegistry.set(rt.meta.name, rt);
3932
+ if (themeIdOf(rt) !== rt.name) _globalThemeRegistry.set(themeIdOf(rt), rt);
3860
3933
  addLegendPanel(rt);
3861
3934
  notifyNewTheme(rt);
3862
3935
  refresh();
@@ -4139,8 +4212,25 @@
4139
4212
  refresh();
4140
4213
  });
4141
4214
 
4215
+ // flat's page hook ixmaps.htmlgui_onDrawTheme(szId), called when a
4216
+ // theme has been drawn: here once per settled redraw, for every
4217
+ // shown theme, with its id. Not re-entered when the hook itself
4218
+ // changes a theme (changeThemeStyle → refresh → here again).
4219
+ let inDrawThemeHook = false;
4220
+ function callDrawThemeHooks() {
4221
+ const hook = pageIxmaps() && pageIxmaps().htmlgui_onDrawTheme;
4222
+ if (typeof hook !== 'function' || inDrawThemeHook) return;
4223
+ inDrawThemeHook = true;
4224
+ try {
4225
+ for (const rt of runtimes.slice()) {
4226
+ if (rt._hidden) continue;
4227
+ try { hook.call(global.ixmaps, themeIdOf(rt)); } catch (e) { console.error('[ixmaps-gl] htmlgui_onDrawTheme:', e); }
4228
+ }
4229
+ } finally { inDrawThemeHook = false; }
4230
+ }
4142
4231
  function notifyRedraw() {
4143
4232
  redrawListeners.forEach(cb => { try { cb(); } catch (err) { console.error('[ixmaps-gl] onRedraw callback failed:', err); } });
4233
+ callDrawThemeHooks();
4144
4234
  }
4145
4235
 
4146
4236
  // For explicit, discrete API calls (setFacetFilter, setSizeField,
@@ -4514,7 +4604,10 @@
4514
4604
  // themselves authored with their own leading space (e.g.
4515
4605
  // " MW") in real pages.
4516
4606
  const legendUnit = rt.style.legendunits || rt.style.units || '';
4517
- const labels = rt.categoryDisplayLabels || rt.categoryLabels;
4607
+ // row labels go into innerHTML: the page's own .style({label})
4608
+ // stays HTML, as in flat; categories read from the data are
4609
+ // escaped (a value could carry markup)
4610
+ const labels = legendRowLabels(rt);
4518
4611
  // .type("...|TEXTLEGEND") — the FIFTH real legend-related
4519
4612
  // type() token: title/snippet/description text only, no
4520
4613
  // category rows at all (swatches, bars, chips — none of it),
@@ -4661,8 +4754,8 @@
4661
4754
  + '<span>' + text + '</span></div>';
4662
4755
  rowsEl.querySelectorAll('.ix-legend-row').forEach(row => {
4663
4756
  row.addEventListener('click', () => {
4664
- if (rt._markedClasses.has(0)) window.ixmaps.unmarkThemeClass(rt.name, 0);
4665
- else window.ixmaps.markThemeClass(rt.name, 0);
4757
+ if (rt._markedClasses.has(0)) unmarkRuntimeClass(rt, 0);
4758
+ else markRuntimeClass(rt, 0);
4666
4759
  });
4667
4760
  });
4668
4761
  };
@@ -4701,8 +4794,8 @@
4701
4794
  rowsEl.querySelectorAll('.ix-legend-row').forEach(cell => {
4702
4795
  cell.addEventListener('click', () => {
4703
4796
  const idx = parseInt(cell.dataset.idx, 10);
4704
- if (rt._markedClasses.has(idx)) window.ixmaps.unmarkThemeClass(rt.name, idx);
4705
- else window.ixmaps.markThemeClass(rt.name, idx);
4797
+ if (rt._markedClasses.has(idx)) unmarkRuntimeClass(rt, idx);
4798
+ else markRuntimeClass(rt, idx);
4706
4799
  });
4707
4800
  });
4708
4801
  };
@@ -4759,15 +4852,16 @@
4759
4852
  // — legend.js:817), toggle decided by the caller. Same
4760
4853
  // split here: the toggle check lives in this click
4761
4854
  // handler, the actual add/remove-and-redraw primitive is
4762
- // the module-level ixmaps.markThemeClass/unmarkThemeClass
4763
- // (see their own comment) — so a page's OWN custom UI
4764
- // calling those same two globals drives this exact legend
4765
- // too, not just these rows.
4855
+ // markRuntimeClass/unmarkRuntimeClass, which the globals
4856
+ // ixmaps.markThemeClass/unmarkThemeClass also use — so a
4857
+ // page's OWN custom UI drives this exact legend too. The
4858
+ // rows pass their runtime itself: its .layer() name may be
4859
+ // shared with other themes of the page.
4766
4860
  rowsEl.querySelectorAll('.ix-legend-row').forEach(rowEl => {
4767
4861
  rowEl.addEventListener('click', () => {
4768
4862
  const idx = parseInt(rowEl.dataset.idx, 10);
4769
- if (rt._markedClasses.has(idx)) window.ixmaps.unmarkThemeClass(rt.name, idx);
4770
- else window.ixmaps.markThemeClass(rt.name, idx);
4863
+ if (rt._markedClasses.has(idx)) unmarkRuntimeClass(rt, idx);
4864
+ else markRuntimeClass(rt, idx);
4771
4865
  });
4772
4866
  });
4773
4867
  };
@@ -5627,7 +5721,7 @@
5627
5721
  }
5628
5722
  // maptheme.js formatValue's NOBREAKS switch (26368): theme min > 999 and max < 3000
5629
5723
  function flatNoBreaks(vMin, vMax) {
5630
- return vMin > 999 && vMax < 3000;
5724
+ return Number.isFinite(vMin) && Number.isFinite(vMax) && vMin > 999 && vMax < 3000;
5631
5725
  }
5632
5726
 
5633
5727
  // flat's chart position (maptheme.js 24441-24530 and 24603, the non-PLOT,
@@ -5737,9 +5831,13 @@
5737
5831
  // scale gate (maptheme.js 2609-2611, 8250-8260): the theme is hidden at
5738
5832
  // scales above featureupper and at or below featurelower ("1:200000" or
5739
5833
  // a bare denominator, __scanScaleValue 2021)
5834
+ // a scale style value ("1:200000" or a bare denominator, flat's
5835
+ // __scanScaleValue, maptheme.js 2021) as its denominator; 0 when unset
5836
+ function scaleDenom(v) {
5837
+ return (v == null || v === '' ? 0 : Number(String(v).includes(':') ? String(v).split(':')[1] : v)) || 0;
5838
+ }
5740
5839
  function featuresHiddenByScale(style, zoom) {
5741
- const denom = v => (v == null || v === '' ? 0 : Number(String(v).includes(':') ? String(v).split(':')[1] : v)) || 0;
5742
- const upper = denom(style.featureupper), lower = denom(style.featurelower);
5840
+ const upper = scaleDenom(style.featureupper), lower = scaleDenom(style.featurelower);
5743
5841
  if (!upper && !lower) return false;
5744
5842
  const scale = scaleDenominatorAt(zoom);
5745
5843
  return (lower && scale <= lower) || (upper && scale > upper);
@@ -5750,9 +5848,8 @@
5750
5848
  // chartupper and at or below chartlower; layerupper / layerlower stand in
5751
5849
  // for them when unset (1774-1779)
5752
5850
  function chartHiddenByScale(style, zoom) {
5753
- const denom = v => (v == null || v === '' ? 0 : Number(String(v).includes(':') ? String(v).split(':')[1] : v)) || 0;
5754
- const upper = denom(style.chartupper != null ? style.chartupper : style.layerupper);
5755
- const lower = denom(style.chartlower != null ? style.chartlower : style.layerlower);
5851
+ const upper = scaleDenom(style.chartupper != null ? style.chartupper : style.layerupper);
5852
+ const lower = scaleDenom(style.chartlower != null ? style.chartlower : style.layerlower);
5756
5853
  if (!upper && !lower) return false;
5757
5854
  const scale = scaleDenominatorAt(zoom);
5758
5855
  return !!((upper && scale > upper) || (lower && scale <= lower));
@@ -5761,8 +5858,7 @@
5761
5858
  // 1726-1734, 16986-16990): the GLOW halo is drawn at scales at or below
5762
5859
  // glowupper and at or above glowlower (both inclusive)
5763
5860
  function glowHiddenByScale(style, zoom) {
5764
- const denom = v => (v == null || v === '' ? 0 : Number(String(v).includes(':') ? String(v).split(':')[1] : v)) || 0;
5765
- const upper = denom(style.glowupper), lower = denom(style.glowlower);
5861
+ const upper = scaleDenom(style.glowupper), lower = scaleDenom(style.glowlower);
5766
5862
  if (!upper && !lower) return false;
5767
5863
  const scale = scaleDenominatorAt(zoom);
5768
5864
  return !!((upper && scale > upper) || (lower && scale < lower));
@@ -5784,8 +5880,7 @@
5784
5880
  const on = style.shadow === true || style.shadow === 'true' || style.shadow === 1 || style.shadow === '1';
5785
5881
  if (!on) return false;
5786
5882
  if (count > (isNaN(styleNum(style.maxshadow)) ? 1000 : styleNum(style.maxshadow))) return false;
5787
- const denom = v => (v == null || v === '' ? 0 : Number(String(v).includes(':') ? String(v).split(':')[1] : v)) || 0;
5788
- const upper = denom(style.shadowupper), lower = denom(style.shadowlower);
5883
+ const upper = scaleDenom(style.shadowupper), lower = scaleDenom(style.shadowlower);
5789
5884
  const scale = scaleDenominatorAt(zoom);
5790
5885
  return !((upper && scale > upper) || (lower && scale < lower));
5791
5886
  }
@@ -5799,8 +5894,7 @@
5799
5894
  // flat's BOX block (maptheme.js 18758-18760): the box — and its title —
5800
5895
  // is removed at scales above boxupper or below boxlower
5801
5896
  function boxHiddenByScale(style, zoom) {
5802
- const denom = v => (v == null || v === '' ? 0 : Number(String(v).includes(':') ? String(v).split(':')[1] : v)) || 0;
5803
- const upper = denom(style.boxupper), lower = denom(style.boxlower);
5897
+ const upper = scaleDenom(style.boxupper), lower = scaleDenom(style.boxlower);
5804
5898
  const scale = scaleDenominatorAt(zoom);
5805
5899
  return !!((upper && scale > upper) || (lower && scale < lower));
5806
5900
  }
@@ -6133,8 +6227,9 @@
6133
6227
  // functionally deleted.
6134
6228
  const DIMMED_ICON_ALPHA = 8;
6135
6229
 
6136
- // deck.gl 8.9.35's IconLayer auto-packing atlas (icon-manager.ts) has NO
6137
- // eviction and NO cap of its own: its texture is 1024px wide (fixed) but
6230
+ // deck.gl's IconLayer auto-packing atlas (icon-manager.ts, read and
6231
+ // measured on 8.9.35; the guards below are kept for 9.x, not re-measured
6232
+ // there) has NO eviction and NO cap of its own: its texture is 1024px wide (fixed) but
6138
6233
  // grows TALLER via power-of-two resize forever, for every distinct icon
6139
6234
  // id ever seen, for the lifetime of the layer — confirmed by reading
6140
6235
  // that file directly. There is no "atlas full" code path; it just keeps
@@ -6226,6 +6321,74 @@
6226
6321
  };
6227
6322
  }
6228
6323
 
6324
+ // CHART|SYMBOL|SEQUENCE (flat maptheme.js 21184-21300, 21790-21925,
6325
+ // 22296-22395): one symbol per category part of a chart, in category
6326
+ // order or sorted (SORT: DOWN biggest first, UP smallest first), parts
6327
+ // of value 0 left out. radiusOf(v) is the part's radius (flat: max radius
6328
+ // · (v / max value)^(1/sizepow)). Placement:
6329
+ // - STAR: the first part is the center (star radius R = its radius); each
6330
+ // further part (with UP every part) sits on a circle around it, at
6331
+ // R + r (×1.5 EXPAND, ×2 EXPANDMAX, ×rangescale), stepping the angle
6332
+ // by f = 90° − asin(√((R+r)² − r²) / (R+r)) before and after it, so
6333
+ // neighbours touch; UP: radius R·(1 + parts/5), the angle step of the
6334
+ // 7th part on divided by 1 + 2r/maxRadius; with EXPAND|SORT|UP,
6335
+ // R = (EXPANDMAX ? 2 : 0.5) · maxRadius · √(biggest / max value)
6336
+ // (LINEAR/SIZEP1: linear), COMPRESS halves it (COMPRESSMAX quarter)
6337
+ // - HORZ: side by side from the center to the right
6338
+ // - otherwise (or CENTER): all on the center
6339
+ // Returns the parts in drawing order: { i, v, r, x, y } (pixels, y down).
6340
+ function sequenceLayout(counts, flags, radiusOf, opts = {}) {
6341
+ const has = f => flatFlag(flags, f);
6342
+ const word = w => [...flags].some(f => new RegExp('\\b' + w + '\\b').test(f));
6343
+ let order = counts.map((v, i) => ({ i, v }));
6344
+ if (word('SORT')) {
6345
+ if (word('UP')) order.sort((a, b) => a.v - b.v);
6346
+ else if (word('DOWN')) order.sort((a, b) => b.v - a.v);
6347
+ }
6348
+ const parts = [];
6349
+ const star = has('STAR') && !has('CENTER'), horz = has('HORZ') && !has('CENTER');
6350
+ const up = word('UP');
6351
+ const maxR = opts.maxRadius || 0;
6352
+ const nStarParts = opts.nParts || counts.length;
6353
+ let angle = 0, starR = null, x = 0;
6354
+ order.forEach((p, k) => {
6355
+ if (!p.v) return;
6356
+ const r = radiusOf(p.v);
6357
+ if (!(r > 0)) return;
6358
+ let px = 0, py = 0;
6359
+ if (star) {
6360
+ if (starR === null) {
6361
+ starR = r;
6362
+ if (has('EXPAND') && word('SORT') && up) {
6363
+ const big = order[order.length - 1].v, mv = opts.maxValue || big || 1;
6364
+ starR = (has('EXPANDMAX') ? 2 : 0.5) * maxR * (has('LINEAR') || has('SIZEP1') ? big / mv : Math.sqrt(big / mv));
6365
+ }
6366
+ if (has('COMPRESS')) starR *= has('COMPRESSMAX') ? 0.25 : 0.5;
6367
+ }
6368
+ if (parts.length > 0 || up) {
6369
+ const c = starR + r;
6370
+ const b = Math.sqrt(Math.max(0, c * c - r * r));
6371
+ let f = 90 - Math.asin(Math.min(1, b / c)) / Math.PI * 180;
6372
+ let dist = starR + r;
6373
+ if (up) {
6374
+ if (k > 5) f /= (1 + 2 * (r / (maxR || r)));
6375
+ dist = starR * (1 + nStarParts / 5);
6376
+ }
6377
+ if (opts.rangeScale) dist *= opts.rangeScale;
6378
+ else dist = has('EXPANDMAX') ? dist * 2 : (has('EXPAND') ? dist * 1.5 : dist);
6379
+ angle += f;
6380
+ px = Math.cos(angle / 180 * Math.PI) * dist;
6381
+ py = Math.sin(angle / 180 * Math.PI) * dist;
6382
+ angle += f;
6383
+ }
6384
+ } else if (horz) {
6385
+ x += r; px = x; x += r;
6386
+ }
6387
+ parts.push({ i: p.i, v: p.v, r, x: px, y: py });
6388
+ });
6389
+ return parts;
6390
+ }
6391
+
6229
6392
  // ---------------------------------------------------------------
6230
6393
  // Classification — pure functions (no LayerRuntime state): class
6231
6394
  // breaks for a numeric field, and which class a value falls in.
@@ -7180,7 +7343,7 @@
7180
7343
  key = `${snapped.x}:${snapped.y}`;
7181
7344
  }
7182
7345
  let cell = cells.get(key);
7183
- if (!cell) { cell = { snapped, sumX: 0, sumY: 0, n: 0, value: 0, classValue: 0, value100: 0, series: null, first: f }; cells.set(key, cell); }
7346
+ if (!cell) { cell = { key, snapped, sumX: 0, sumY: 0, n: 0, value: 0, classValue: 0, value100: 0, series: null, first: f }; cells.set(key, cell); }
7184
7347
  cell.sumX += p.x; cell.sumY += p.y; cell.n++;
7185
7348
  cell.value += f.properties.value;
7186
7349
  if (f.properties.classValue !== undefined) cell.classValue += f.properties.classValue;
@@ -7202,6 +7365,9 @@
7202
7365
  ? { cluster: true, point_count: cell.n, value: cell.value, ...(hasClass ? { classValue: cell.classValue } : {}) }
7203
7366
  : cell.first.properties;
7204
7367
  if (cell.series && cell.n > 1) props = Object.assign({}, props, { series: cell.series });
7368
+ // a field aggregation's cell key (the field value): the categories of
7369
+ // one cell are merged by it (groupCoLocated), not by their positions
7370
+ if (field) props = Object.assign({}, props, { aggKey: cell.key });
7205
7371
  if (post) {
7206
7372
  props = Object.assign({}, props);
7207
7373
  if (hasClass) props.classValue = post(cell.n > 1 ? cell.classValue : props.classValue, cell.value100, props.series);
@@ -7234,7 +7400,7 @@
7234
7400
  }
7235
7401
  }
7236
7402
 
7237
- function groupCoLocated(clusterFeatures, zoom, clusterRadiusPx, nCategories, flags) {
7403
+ function groupCoLocated(clusterFeatures, zoom, clusterRadiusPx, nCategories, flags, atCellCenter = false) {
7238
7404
  // Merge tolerance MUST track the same theme-driven, scale-dependent
7239
7405
  // aggregation width the clustering itself just used (clusterRadiusPx,
7240
7406
  // set by _ensureClusterIndices right before this runs) — not an
@@ -7256,9 +7422,11 @@
7256
7422
  // recomputes ptPos as the mean of ptPosA regardless of which
7257
7423
  // grid produced the grouping key).
7258
7424
  const snapped = cellPx ? snapToAggregationGrid(p.x, p.y, cellPx, flags, zoom) : p;
7259
- const key = `${snapped.x}:${snapped.y}`;
7425
+ // a field aggregation's categories each sit at the mean of their own
7426
+ // records — merged by the field value, not by (float) position
7427
+ const key = f.properties.aggKey != null ? f.properties.aggKey : `${snapped.x}:${snapped.y}`;
7260
7428
  let cell = cells.get(key);
7261
- if (!cell) { cell = { sumX: 0, sumY: 0, n: 0, counts: new Array(n).fill(0), recordCounts: new Array(n).fill(0), classTotal: undefined }; cells.set(key, cell); }
7429
+ if (!cell) { cell = { sumX: 0, sumY: 0, n: 0, cx: snapped.x, cy: snapped.y, counts: new Array(n).fill(0), recordCounts: new Array(n).fill(0), classTotal: undefined }; cells.set(key, cell); }
7262
7430
  cell.sumX += p.x; cell.sumY += p.y; cell.n++;
7263
7431
  cell.counts[f.properties.cat] += f.properties.value;
7264
7432
  if (f.properties.classValue !== undefined) cell.classTotal = (cell.classTotal || 0) + f.properties.classValue;
@@ -7269,7 +7437,8 @@
7269
7437
  cell.recordCounts[f.properties.cat] += (f.properties.point_count || 1);
7270
7438
  });
7271
7439
  return Array.from(cells.values()).map(cell => {
7272
- const ll = worldPixelToLngLat(cell.sumX / cell.n, cell.sumY / cell.n, zoom);
7440
+ // without RELOCATE flat's cell item sits at the cell center
7441
+ const ll = atCellCenter && cellPx ? worldPixelToLngLat(cell.cx, cell.cy, zoom) : worldPixelToLngLat(cell.sumX / cell.n, cell.sumY / cell.n, zoom);
7273
7442
  return {
7274
7443
  geometry: { type: 'Point', coordinates: [ll.lng, ll.lat] },
7275
7444
  properties: { counts: cell.counts, total: cell.counts.reduce((a, c) => a + c, 0), recordCounts: cell.recordCounts, ...(cell.classTotal !== undefined ? { classTotal: cell.classTotal } : {}) }
@@ -7424,7 +7593,7 @@
7424
7593
  this._gridRefLat = (minLat <= maxLat) ? (minLat + maxLat) / 2 : 0;
7425
7594
  }
7426
7595
 
7427
- if (this.flags.has('DOMINANT') && this.binding.value) {
7596
+ if (this.flags.has('DOMINANT') && this.binding.value && !isAggregatedCategoricalChoropleth(this)) {
7428
7597
  // .type("CHOROPLETH|DOMINANT") — a MULTI-field bound value
7429
7598
  // (binding.value is a pipe-joined field list, e.g. one CSV column
7430
7599
  // per age band for the same year/sex — same pipe convention
@@ -7774,6 +7943,26 @@
7774
7943
  }
7775
7944
 
7776
7945
  _rebuildActiveFeatures() {
7946
+ // An AGGREGATE CATEGORICAL choropleth filters the RECORDS of each
7947
+ // polygon (as its .filter() does before the join), then sums them —
7948
+ // not the polygons by their first record
7949
+ if (isAggregatedCategoricalChoropleth(this)) {
7950
+ const expr = this._runtimeFilterExpr;
7951
+ const clauses = Array.from(this.facetFilters.entries());
7952
+ if (!expr && !clauses.length) { this._activeFeatures = null; return; }
7953
+ const pred = expr ? flatFilterPredicate(String(expr)) : null;
7954
+ this._activeFeatures = this.features.map(f => {
7955
+ const rows = f.properties && f.properties[AGGREGATED_ROWS];
7956
+ if (!rows) return f;
7957
+ const kept = rows.filter(r => (!pred || pred(r)) && clauses.every(([field, clause]) => matchesFacetClause(r[field], clause)));
7958
+ const properties = kept.length ? Object.assign({}, kept[0]) : {};
7959
+ if (kept.length) Object.defineProperty(properties, AGGREGATED_ROWS, { value: kept });
7960
+ const out = { type: 'Feature', geometry: f.geometry, properties };
7961
+ if (f._flatAreaKm2 !== undefined) out._flatAreaKm2 = f._flatAreaKm2;
7962
+ return out;
7963
+ });
7964
+ return;
7965
+ }
7777
7966
  // Base set: this.features narrowed by the runtime WHERE-filter
7778
7967
  // (changeThemeStyle's own mechanism, see _runtimeFilterExpr's own
7779
7968
  // comment) if one is active — reusing applyWhereFilter, the SAME
@@ -8470,8 +8659,11 @@
8470
8659
  // unlike CHOROPLETH, there's no per-feature classification here.
8471
8660
  // flat puts fillopacity into the shape's fill-opacity only
8472
8661
  // (maptheme.js 13407, 14071-14078): the outline stays opaque
8473
- getFillColor: filled ? [...hexOrNamedToRgb(raw).slice(0, 3), Math.round(255 * (styleNum(this.style.fillopacity) || 1))] : [0, 0, 0, 0],
8474
- getLineColor: this.style.linecolor ? hexOrNamedToRgb(styleLineColor(this.style.linecolor)) : [130, 130, 130],
8662
+ // (a fill color's own alpha — rgba(…, 0.5) — times fillopacity)
8663
+ getFillColor: filled ? [...hexOrNamedToRgb(raw).slice(0, 3), Math.round(255 * cssColorAlpha(raw) * (styleNum(this.style.fillopacity) || 1))] : [0, 0, 0, 0],
8664
+ getLineColor: this.style.linecolor
8665
+ ? withAlpha(hexOrNamedToRgb(styleLineColor(this.style.linecolor)), cssColorAlpha(styleLineColor(this.style.linecolor)))
8666
+ : [130, 130, 130],
8475
8667
  lineWidthMinPixels: styleNum(this.style.linewidth) || 1,
8476
8668
  // Confirmed live (2026-09-21, deck.gl v9.4/MapLibre-globe upgrade):
8477
8669
  // this layer's fill rendered with a moire/hatching pattern of
@@ -8540,9 +8732,10 @@
8540
8732
  // — plain/PERCENTOFMEAN/DEVIATION); or COMPOSECOLOR's per-polygon
8541
8733
  // BLEND across the same piped fields (_resolveComposedColor — no
8542
8734
  // single winning class, so no `cat`/tooltip chart line, just the
8543
- // blended color directly). CATEGORICAL choropleths (exact-match,
8544
- // single field) aren't implemented — no ported config uses that
8545
- // combination yet. Geometry + properties are already the joined
8735
+ // blended color directly); AGGREGATE CATEGORICAL on one field — the
8736
+ // dominant category of each polygon's records (aggregatedCategoricalClass).
8737
+ // A plain CATEGORICAL choropleth (exact match of one row's value) is not
8738
+ // implemented. Geometry + properties are already the joined
8546
8739
  // FeatureCollection from joinChoroplethFeatures. `zoom` is only used
8547
8740
  // for style.fillopacity:"auto" (see resolveAutoFillOpacity) — every
8548
8741
  // other branch here is zoom-independent.
@@ -8565,7 +8758,37 @@
8565
8758
  // DOPACITY/COMPOSECOLOR branches.
8566
8759
  const dopacityActive = !isComposeColor &&
8567
8760
  (this.flags.has('DOPACITY') || this.flags.has('DOPACITYMIN') || this.flags.has('DOPACITYMAX') || this.flags.has('DOPACITYMINMAX'));
8761
+ // AGGREGATE CATEGORICAL: every polygon's parts and dominant class
8762
+ // (aggregatedCategoricalClass), the biggest part per class over all
8763
+ // polygons for the opacity (flat's nMaxA)
8764
+ const aggCat = isAggregatedCategoricalChoropleth(this) && this.categoryIndexByLabel;
8765
+ let aggOf = null, aggMaxA = null;
8766
+ if (aggCat) {
8767
+ const nCats = this.categoryLabels.length;
8768
+ aggOf = new Map();
8769
+ aggMaxA = new Array(nCats).fill(0);
8770
+ source.forEach(f => {
8771
+ const rows = f.properties && f.properties[AGGREGATED_ROWS];
8772
+ if (!rows) return;
8773
+ const agg = aggregatedCategoricalClass(rows, this.binding, this.flags, this.categoryIndexByLabel, nCats);
8774
+ if (!agg) return;
8775
+ aggOf.set(f, agg);
8776
+ agg.parts.forEach((v, i) => { if (v > aggMaxA[i]) aggMaxA[i] = v; });
8777
+ });
8778
+ }
8568
8779
  const data = source.map(f => {
8780
+ if (aggCat) {
8781
+ const agg = aggOf.get(f);
8782
+ return {
8783
+ type: 'Feature',
8784
+ geometry: f.geometry,
8785
+ properties: {
8786
+ // no part above 0: no item in flat, not painted
8787
+ value: agg ? agg.value : undefined, raw: agg ? f.properties : {}, cat: agg ? agg.index : null,
8788
+ dopacityAlpha: agg && dopacityActive ? dominantDopacityAlpha(this.style, this.flags, agg.value, aggMaxA[agg.index]) : null
8789
+ }
8790
+ };
8791
+ }
8569
8792
  if (isComposeColor) {
8570
8793
  return {
8571
8794
  type: 'Feature',
@@ -8604,7 +8827,14 @@
8604
8827
  // evidence mode "isolate", the default (7715) — or grayed with
8605
8828
  // style.evidence "isolate_gray". ("highlight", flat's highlight list,
8606
8829
  // is shown grayed too here.) COMPOSECOLOR has no classes.
8607
- const lineRgb = this.style.linecolor ? hexOrNamedToRgb(styleLineColor(this.style.linecolor)) : [255, 255, 255];
8830
+ // linecolor "none": no outline; a class color "none" (an explicit
8831
+ // colorscheme list) leaves its polygons unpainted, an rgba() class
8832
+ // color keeps its alpha — as flat's SVG fill does
8833
+ const lineColorRaw = this.style.linecolor ? styleLineColor(this.style.linecolor) : null;
8834
+ const lineRgb = lineColorRaw ? withAlpha(hexOrNamedToRgb(lineColorRaw), cssColorAlpha(lineColorRaw)) : [255, 255, 255];
8835
+ const cs = this.style.colorscheme;
8836
+ const classAlpha = Array.isArray(cs) && cs.length && !/^\d+$/.test(String(cs[0])) ? cs.map(cssColorAlpha) : null;
8837
+ const alphaOfClass = i => (classAlpha && i != null && classAlpha[i] != null ? classAlpha[i] : 1);
8608
8838
  const evidenceMode = String(this.style.evidence || 'isolate');
8609
8839
  const unmarkedEvidence = d => {
8610
8840
  if (!this._markedClasses.size || this._onMarksChanged || this._markedClasses.has(d.properties.cat)) return null;
@@ -8624,7 +8854,8 @@
8624
8854
  const evidence = unmarkedEvidence(d);
8625
8855
  if (evidence === 'hide') return [0, 0, 0, 0];
8626
8856
  if (evidence === 'gray') return [...grayOf(rgb), 90];
8627
- return d.properties.dopacityAlpha != null ? [...rgb, Math.round(d.properties.dopacityAlpha * 255)] : rgb;
8857
+ const a = alphaOfClass(d.properties.cat);
8858
+ return d.properties.dopacityAlpha != null ? [...rgb.slice(0, 3), Math.round(d.properties.dopacityAlpha * a * 255)] : withAlpha(rgb, a);
8628
8859
  },
8629
8860
  // a per-polygon accessor only while a class is isolated
8630
8861
  getLineColor: this._markedClasses.size && !this._onMarksChanged && evidenceMode === 'isolate'
@@ -9568,6 +9799,14 @@
9568
9799
  });
9569
9800
 
9570
9801
  const doRelocate = this.flags.has('RELOCATE');
9802
+ // SEQUENCE: flat's AGGREGATE cell is ONE chart with a part per
9803
+ // category (RELOCATE only moves it to the records' mean position) —
9804
+ // every category of a cell, single records included, in one group
9805
+ if (flatFlag(this.flags, 'SEQUENCE') && this.flags.has('CATEGORICAL')) {
9806
+ const groups = groupCoLocated(clusterFeatures.concat(individual), effectiveZoom, this._clusterRadiusPx,
9807
+ this.categoryLabels ? this.categoryLabels.length : 1, this.flags, !doRelocate);
9808
+ return { individual: [], groups };
9809
+ }
9571
9810
  // without RELOCATE a cell's item already sits at the cell center
9572
9811
  // (aggregateOnGrid)
9573
9812
  const groups = doRelocate ? this._groupCoLocated(clusterFeatures, effectiveZoom) : clusterFeatures.map(f => {
@@ -9832,6 +10071,9 @@
9832
10071
  if (userLayers) return layers.concat(userLayers);
9833
10072
  }
9834
10073
  if (this.flags.has('LABEL')) return layers.concat(this._buildLabelChartLayers(combined, zoom, liveZoom, sizeValueOf));
10074
+ if (flatFlag(this.flags, 'SEQUENCE') && this.flags.has('CATEGORICAL') && this.flags.has('AGGREGATE')) {
10075
+ return layers.concat(this._buildSequenceChartLayers(combined, zoom, liveZoom));
10076
+ }
9835
10077
  const fillOpacity = styleNum(this.style.fillopacity) || 0.85;
9836
10078
  // .style({linecolor, linewidth}) — an individual icon's own border,
9837
10079
  // see _buildSingleIcon's own comment. Real ixmaps-flat symbol
@@ -10144,6 +10386,164 @@
10144
10386
  return layers;
10145
10387
  }
10146
10388
 
10389
+ // flat's nAllMaxValue for a SEQUENCE chart (maptheme.js 13211-13215):
10390
+ // the biggest category part of any chart at this aggregation — over
10391
+ // the whole dataset, not the view; cached per zoom
10392
+ _sequenceMaxValue(zoom) {
10393
+ if (this._seqMaxFor !== this._clusterIndices) { this._seqMaxFor = this._clusterIndices; this._seqMax = new Map(); }
10394
+ if (!this._seqMax.has(zoom)) {
10395
+ const { groups } = this._computeAggregatedItems([-180, -90, 180, 90], zoom);
10396
+ let max = 0;
10397
+ groups.forEach(g => g.properties.counts.forEach(c => { if (c > max) max = c; }));
10398
+ this._seqMax.set(zoom, max || 1);
10399
+ }
10400
+ return this._seqMax.get(zoom);
10401
+ }
10402
+
10403
+ // CHART|SYMBOL|SEQUENCE on an AGGREGATE CATEGORICAL theme: each cell is
10404
+ // one chart of a symbol per category part (sequenceLayout), as flat's
10405
+ // SYMBOL branch draws it (maptheme.js 21122-22400):
10406
+ // - part radius: max radius · (v / max value)^(1/sizepow), max value =
10407
+ // normalsizevalue, else the biggest part of any chart (21793)
10408
+ // - circle in the category color at fillopacity, stroked in linecolor
10409
+ // linewidth · unit · min(2r / max radius, 0.2) wide (21966)
10410
+ // - GLOW (and AURA): with SORT only around the first part, else around
10411
+ // every part (22009)
10412
+ // - marked legend classes: the other parts hidden, the marked ones
10413
+ // back on the center (14339-14420, evidence "isolate"); with
10414
+ // "isolate_gray" the others dimmed
10415
+ // - STAR: the center part drawn last, on top of its satellites (23145)
10416
+ // - VALUES: each part's value (COUNT: its records; valuefield: the class name)
10417
+ // - align / offsets as the SYMBOL branch: half size the normal radius
10418
+ // A part's pick object carries its chart's properties (tooltip).
10419
+ _buildSequenceChartLayers(charts, zoom, liveZoom) {
10420
+ const st = this.style;
10421
+ const seqMax = this._sequenceMaxValue(zoom);
10422
+ const partRadius = v => valueRadius(v, liveZoom, st, this.mapOptions, this.flags, seqMax);
10423
+ const unit = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(st.scale) || 1);
10424
+ const normalR = NORMAL_RADIUS_PX * unit;
10425
+ const nCats = this.categoryLabels ? this.categoryLabels.length : 1;
10426
+ const marking = this._markedClasses.size && !this._onMarksChanged;
10427
+ const gray = marking && String(st.evidence || 'isolate') === 'isolate_gray';
10428
+ const sorted = [...this.flags].some(f => /\bSORT\b/.test(f));
10429
+ const fillOpacity = styleNum(st.fillopacity) || 0.85;
10430
+ const borderRgb = st.linecolor && styleLineColor(st.linecolor) !== 'none' ? hexOrNamedToRgb(styleLineColor(st.linecolor)) : null;
10431
+ const lineWidth = styleNum(st.linewidth) || 1;
10432
+ const symbols = Array.isArray(st.symbols) ? st.symbols : null;
10433
+ const shapeOf = i => { const sh = symbols && (symbols[i] || symbols[0]); return /^(circle|square|diamond|triangle)$/.test(sh) ? sh : 'circle'; };
10434
+ const colorOf = i => this.categoryColorsRgb[i] || [128, 128, 128];
10435
+ const parts = [], glows = [];
10436
+ for (const d of charts) {
10437
+ let layout = sequenceLayout(d.properties.counts, this.flags, partRadius, { maxRadius: normalR, maxValue: seqMax, nParts: nCats, rangeScale: styleNum(st.rangescale) || 0 });
10438
+ if (!layout.length) continue;
10439
+ // GLOW belongs to the first part (sorted) or to every part, and
10440
+ // hides with it when its class is not marked
10441
+ const glowParts = new Set(sorted ? [layout[0].i] : layout.map(p => p.i));
10442
+ if (marking && !gray) {
10443
+ layout = layout.filter(p => this._markedClasses.has(p.i)).map(p => ({ ...p, x: 0, y: 0 }));
10444
+ if (!layout.length) continue;
10445
+ }
10446
+ // the chart's own ptNull.y: the last part's radius + 5 units (23204)
10447
+ d._seqLast = layout[layout.length - 1].r;
10448
+ const items = layout.map(p => {
10449
+ const item = { geometry: d.geometry, properties: d.properties, chart: d, part: p };
10450
+ if (glowParts.has(p.i)) glows.push(item);
10451
+ return item;
10452
+ });
10453
+ // STAR: the first part (its center) is put on top at the end (23145)
10454
+ if (flatFlag(this.flags, 'STAR') && items.length > 1) items.push(items.shift());
10455
+ parts.push(...items);
10456
+ }
10457
+ const alignOf = this._chartAlignFn(liveZoom, d => (d.chart || d)._seqLast || 0);
10458
+ const offsetOf = item => { const o = alignOf(item.chart); return [o[0] + item.part.x, o[1] + item.part.y]; };
10459
+ const alphaOf = item => (gray && !this._markedClasses.has(item.part.i) ? DIMMED_ICON_ALPHA : 255);
10460
+ const layers = [];
10461
+ if (glows.length && this.flags.has('GLOW') && !glowHiddenByScale(st, liveZoom)) {
10462
+ layers.push(new IconLayer({
10463
+ id: `ix-glow-${this.name}-g${this._iconGeneration}`,
10464
+ data: glows, pickable: false,
10465
+ getPosition: d => d.geometry.coordinates,
10466
+ getIcon: d => this._getGlowIcon(colorOf(d.part.i)),
10467
+ getSize: d => d.part.r * 11,
10468
+ getPixelOffset: offsetOf,
10469
+ getColor: d => [255, 255, 255, alphaOf(d)],
10470
+ sizeUnits: 'pixels', billboard: true,
10471
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
10472
+ }));
10473
+ } else if (glows.length && this.flags.has('AURA') && !glowHiddenByScale(st, liveZoom)) {
10474
+ layers.push(new IconLayer({
10475
+ id: `ix-aura-${this.name}-g${this._iconGeneration}`,
10476
+ data: glows, pickable: false,
10477
+ getPosition: d => d.geometry.coordinates,
10478
+ getIcon: d => this._buildSingleIcon(borderRgb || colorOf(d.part.i), 0.3, 'circle', null, 0),
10479
+ getSize: d => d.part.r * 2 * 1.3,
10480
+ getPixelOffset: offsetOf,
10481
+ getColor: d => [255, 255, 255, alphaOf(d)],
10482
+ sizeUnits: 'pixels', billboard: true,
10483
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
10484
+ }));
10485
+ }
10486
+ layers.push(new IconLayer({
10487
+ id: `ix-bubbles-${this.name}-g${this._iconGeneration}`,
10488
+ data: parts, pickable: true,
10489
+ getPosition: d => d.geometry.coordinates,
10490
+ // the stroke, in the icon raster's pixels (the icon is drawn at 2r)
10491
+ getIcon: d => this._buildSingleIcon(colorOf(d.part.i), fillOpacity, shapeOf(d.part.i), borderRgb,
10492
+ 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),
10493
+ getSize: d => d.part.r * 2,
10494
+ getPixelOffset: offsetOf,
10495
+ getColor: d => [255, 255, 255, alphaOf(d)],
10496
+ sizeUnits: 'pixels', billboard: true,
10497
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
10498
+ }));
10499
+ // VALUES on each part (maptheme.js 22188-22250): the part's value — with
10500
+ // COUNT its record count, with valuefield on the categorical field the
10501
+ // class name — formatted with valuedecimals (else none) plus the unit;
10502
+ // font min(1, 3.2 / L) · r · valuescale, L the length of the theme's
10503
+ // biggest value text (szMaxText), normal weight, in valuecolor /
10504
+ // textcolor, else ChartColors.textColor of the part color
10505
+ if (this.flags.has('VALUES') && !valuesHiddenByScale(st, zoom)) {
10506
+ const valueScale = styleNum(st.valuescale) || 1;
10507
+ const { noBreaks } = this._valueTextOpts();
10508
+ const units = st.units ? String(st.units) : '';
10509
+ const unit = units ? (units[0] === '.' ? '' : ' ') + units : '';
10510
+ const unitText = unit.length <= 5 ? unit : '';
10511
+ const dec = styleNum(st.valuedecimals) || 0;
10512
+ const fmt = v => flatGroupedValue(v, dec, noBreaks) + unitText;
10513
+ const maxLen = fmt(seqMax).length;
10514
+ const textOverride = st.valuecolor || st.textcolor;
10515
+ const counting = flatFlag(this.flags, 'COUNT');
10516
+ const labels = [];
10517
+ parts.forEach(d => {
10518
+ const rec = this._categoryValueRecord(d.part.i);
10519
+ const name = rec ? String(rec[st.valuefield]) : null;
10520
+ const t = counting && d.properties.recordCounts ? d.properties.recordCounts[d.part.i] : d.part.v;
10521
+ const text = name != null ? name : fmt(t);
10522
+ const L = Math.max((name != null ? name.length : 0) + 1, maxLen);
10523
+ const fontSize = Math.min(1, 3.2 / L) * d.part.r * valueScale;
10524
+ if (fontSize > VALUES_MIN_FONT_PX) {
10525
+ labels.push({ geometry: d.geometry, text, fontSize, pixelOffset: offsetOf(d), alpha: alphaOf(d),
10526
+ color: textOverride ? hexOrNamedToRgb(textOverride) : flatChartTextRgb(colorOf(d.part.i)) });
10527
+ }
10528
+ });
10529
+ if (labels.length) {
10530
+ layers.push(new TextLayer({
10531
+ id: `ix-cluster-values-${this.name}`,
10532
+ data: labels, pickable: false,
10533
+ getPosition: d => d.geometry.coordinates,
10534
+ getText: d => d.text,
10535
+ getSize: d => d.fontSize,
10536
+ getColor: d => [...d.color.slice(0, 3), d.alpha],
10537
+ getPixelOffset: d => d.pixelOffset,
10538
+ sizeUnits: 'pixels', fontFamily: 'arial', fontWeight: 'normal',
10539
+ characterSet: 'auto',
10540
+ getTextAnchor: 'middle', getAlignmentBaseline: 'center'
10541
+ }));
10542
+ }
10543
+ }
10544
+ return layers;
10545
+ }
10546
+
10147
10547
  // flat's chart position for this build (flatChartAlignOffset): a
10148
10548
  // function item → pixel offset, the same for every part of one chart
10149
10549
  // (symbol, glow, shadow, box, title, value text). unit is flat's
@@ -10387,6 +10787,23 @@
10387
10787
  }
10388
10788
  }
10389
10789
 
10790
+ // a color's own opacity, 0..1, as SVG paints it in flat: "none" (SVG's
10791
+ // no paint — a polygon with only its outline, or a class not painted)
10792
+ // 0, rgba()/hsla() its alpha, #rrggbbaa / #rgba its alpha digits, else 1
10793
+ function cssColorAlpha(v) {
10794
+ if (typeof v !== 'string') return 1;
10795
+ const c = v.trim().toLowerCase();
10796
+ if (c === 'none' || c === 'transparent') return 0;
10797
+ const m = c.match(/^(?:rgba|hsla)\([^)]*,\s*([\d.]+%?)\s*\)$/) || c.match(/^(?:rgb|hsl)a?\([^)]*\/\s*([\d.]+%?)\s*\)$/);
10798
+ if (m) return Math.max(0, Math.min(1, m[1].endsWith('%') ? parseFloat(m[1]) / 100 : parseFloat(m[1])));
10799
+ const h = c.match(/^#(?:[0-9a-f]{3}([0-9a-f])|[0-9a-f]{6}([0-9a-f]{2}))$/);
10800
+ if (h) return parseInt(h[1] ? h[1] + h[1] : h[2], 16) / 255;
10801
+ return 1;
10802
+ }
10803
+ // [r, g, b] with an alpha 0..1 — the plain [r, g, b] (opaque) when it is 1
10804
+ function withAlpha(rgb, a) {
10805
+ return a >= 1 ? rgb.slice(0, 3) : [...rgb.slice(0, 3), Math.round(255 * Math.max(0, a))];
10806
+ }
10390
10807
  function hexOrNamedToRgb(v) {
10391
10808
  const NAMED = { gray: [128, 128, 128], grey: [128, 128, 128], black: [0, 0, 0], white: [255, 255, 255] };
10392
10809
  return parseCssColor(v) || NAMED[v] || [130, 130, 130];
@@ -10405,6 +10822,13 @@
10405
10822
  // nothing here authored that string. Escaped before going into
10406
10823
  // innerHTML so a stray `<`/`&`/quote in a source field can't break the
10407
10824
  // option markup (or worse).
10825
+ // a legend's row labels as HTML: the page's .style({label}) as given,
10826
+ // values from the data escaped
10827
+ function legendRowLabels(rt) {
10828
+ const display = rt.categoryDisplayLabels;
10829
+ if (display && display !== rt.categoryLabels) return display;
10830
+ return (rt.categoryLabels || []).map(v => escapeHtml(v));
10831
+ }
10408
10832
  function escapeHtml(s) {
10409
10833
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
10410
10834
  }
@@ -10415,19 +10839,130 @@
10415
10839
  // definition wins" caveat). Returns null for an unknown id, same as the
10416
10840
  // real engine, rather than throwing.
10417
10841
  function getThemeObj(szId) {
10418
- const rt = _globalThemeRegistry.get(szId);
10842
+ // no id: flat's current theme — here the one a page's facets were
10843
+ // built for (its own facet script sets ixmaps.filterThemeId), else the
10844
+ // last chart / choropleth theme
10845
+ let rt = szId != null ? _globalThemeRegistry.get(szId) : null;
10846
+ if (szId == null) {
10847
+ const pid = pageIxmaps() && pageIxmaps().filterThemeId;
10848
+ rt = (pid != null && _globalThemeRegistry.get(pid))
10849
+ || [..._globalThemeRegistry.values()].reverse().find(r => r.flags.has('CHART') || r.flags.has('CHOROPLETH')) || null;
10850
+ }
10419
10851
  if (!rt) return null;
10852
+ const rgbHex = c => '#' + c.slice(0, 3).map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
10853
+ let items = null;
10854
+ const onMap = () => items || (items = themeItemsOnMap(rt));
10420
10855
  return {
10421
- szId: rt.name,
10856
+ // flat's theme id (style.name) — a .layer() name may be shared by
10857
+ // several themes of a page
10858
+ szId: themeIdOf(rt),
10422
10859
  szName: rt.name,
10423
- szFilter: rt._filterExpr || '',
10860
+ // the filter in force: a runtime one (changeThemeStyle "filter")
10861
+ // replaces the definition's
10862
+ szFilter: rt._runtimeFilterExpr || rt._filterExpr || '',
10424
10863
  szFlag: Array.from(rt.flags).join('|'),
10425
10864
  // flat's fVisible is false for a FEATURE theme outside its
10426
10865
  // featureupper/featurelower scales too (maptheme.js 2609-2611) — a
10427
10866
  // broker reads it to skip refreshing a hidden theme
10428
- fVisible: !rt._hidden && !(_lastMapApi && _lastMapApi.map && themeHiddenByScale(rt.flags, rt.style, _lastMapApi.map.getZoom()))
10867
+ fVisible: !rt._hidden && !(_lastMapApi && _lastMapApi.map && themeHiddenByScale(rt.flags, rt.style, _lastMapApi.map.getZoom())),
10868
+ nScale: styleNum(rt.style.scale) || 1,
10869
+ szUnits: rt.style.units || '',
10870
+ // flat stores the unit with a leading space unless it starts with "."
10871
+ szUnit: rt.style.units ? (String(rt.style.units)[0] === '.' ? '' : ' ') + rt.style.units : '',
10872
+ szTitleField: rt.binding.title || null,
10873
+ szFieldsA: rt.binding.value ? String(rt.binding.value).split('|') : [],
10874
+ szSizeField: rt.binding.size || null,
10875
+ // the classes, as flat's facet / legend scripts read them:
10876
+ // colorScheme by class, nStringToValueA value → class + 1,
10877
+ // szLabelA / szValuesA the style's label / values lists
10878
+ colorScheme: (rt.categoryColorsRgb || []).map(rgbHex),
10879
+ nStringToValueA: rt.categoryLabels ? Object.fromEntries(rt.categoryLabels.map((v, i) => [v, i + 1])) : {},
10880
+ szLabelA: Array.isArray(rt.style.label) ? rt.style.label.map(String) : (rt.categoryDisplayLabels || null),
10881
+ szValuesA: Array.isArray(rt.style.values) ? rt.style.values.map(String) : null,
10882
+ partsA: rt.partsA || [],
10883
+ // flat's own data surface, as a page's own facet / list scripts
10884
+ // (flat's ui/js/tools facet.js, list.js) read it: the theme's data
10885
+ // table (objTheme.dbTable/dbFields/dbRecords — records as arrays in
10886
+ // field order) and the items on the map (indexA → itemA[i].dbIndexA,
10887
+ // the records of each item). Here one item holds every record of
10888
+ // the theme within the current view (shown, in scale, facet filters
10889
+ // applied) — computed when read
10890
+ get objTheme() { return themeDbTable(rt); },
10891
+ // computed once per theme object (a page's loop reads itemA per record)
10892
+ get indexA() { return onMap().indexA; },
10893
+ get itemA() { return onMap().itemA; }
10429
10894
  };
10430
10895
  }
10896
+ const _warnedOnce = new Set();
10897
+ function warnOnce(key, msg) { if (!_warnedOnce.has(key)) { _warnedOnce.add(key); console.info(msg); } }
10898
+ // the theme's records as flat's dbFields/dbRecords (cached per data)
10899
+ function themeDbTable(rt) {
10900
+ const features = rt.features || [];
10901
+ if (rt._dbTableFor === features) return rt._dbTable;
10902
+ const rowOf = f => (f.properties && f.properties.raw) || f.properties || {};
10903
+ const names = [];
10904
+ const seen = new Set();
10905
+ for (const f of features.slice(0, 1000)) for (const k of Object.keys(rowOf(f))) if (!seen.has(k)) { seen.add(k); names.push(k); }
10906
+ const indexOf = new Map();
10907
+ const dbRecords = features.map((f, i) => { indexOf.set(f, i); const row = rowOf(f); return names.map(k => (row[k] == null ? '' : row[k])); });
10908
+ rt._dbTableFor = features;
10909
+ rt._dbIndexOf = indexOf;
10910
+ const dbFields = names.map(id => ({ id, typ: 0, width: 16, decimals: 0 }));
10911
+ // dbTable: data.js Table's own table header ({records, fields} counts)
10912
+ rt._dbTable = { dbTable: { records: dbRecords.length, fields: dbFields.length }, dbFields, dbRecords };
10913
+ return rt._dbTable;
10914
+ }
10915
+ // the records on the map now: none for a hidden / out of scale theme,
10916
+ // else the (facet-filtered) records within the view
10917
+ function themeItemsOnMap(rt) {
10918
+ themeDbTable(rt);
10919
+ const map = _lastMapApi && _lastMapApi.map;
10920
+ const shown = map && !rt._hidden && !themeHiddenByScale(rt.flags, rt.style, map.getZoom());
10921
+ if (!shown) return { indexA: [], itemA: {} };
10922
+ const b = map.getBounds();
10923
+ const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()];
10924
+ const dbIndexA = [];
10925
+ for (const f of (rt._activeFeatures || rt.features || [])) {
10926
+ const g = f.geometry;
10927
+ if (g && g.type === 'Point' && !pointInBbox(g, bbox)) continue;
10928
+ const i = rt._dbIndexOf.get(f);
10929
+ if (i != null) dbIndexA.push(i);
10930
+ }
10931
+ return { indexA: ['view'], itemA: { view: { dbIndexA } } };
10932
+ }
10933
+ // flat's ixmaps.getThemeDefinitionObj(szId): the theme's definition —
10934
+ // type, style (sizefield/valuefield as flat names them), meta
10935
+ function getThemeDefinitionObj(szId) {
10936
+ const rt = _globalThemeRegistry.get(szId);
10937
+ if (!rt) return null;
10938
+ const style = Object.assign({}, rt.style);
10939
+ if (rt.binding.size && !style.sizefield) style.sizefield = rt.binding.size;
10940
+ if (rt.binding.title && !style.titlefield) style.titlefield = rt.binding.title;
10941
+ style.type = rt.flags.typeString != null ? rt.flags.typeString : [...rt.flags].join('|');
10942
+ const data = Object.assign({}, rt._definition && rt._definition.data);
10943
+ if (!data.name) data.name = themeDataName(rt);
10944
+ return { layer: rt.name, type: style.type, field: rt.binding.value, data, style, meta: Object.assign({}, rt.meta) };
10945
+ }
10946
+ // a theme's data table name — flat keeps named data tables in its map
10947
+ // window (ixmaps.embeddedSVG.window[name], read by a page's own list
10948
+ // scripts): the .data({name}) given, else one made from the theme id
10949
+ function themeDataName(rt) {
10950
+ const d = rt._definition && rt._definition.data;
10951
+ return (d && d.name) || ('themeData_' + String(themeIdOf(rt)).replace(/\W/g, '_'));
10952
+ }
10953
+ // flat's ixmaps.embeddedSVG.window: here only its named data tables, as
10954
+ // data.js Table objects {table, fields, records} of the themes' data
10955
+ const embeddedSVG = {
10956
+ window: new Proxy({}, {
10957
+ get(target, key) {
10958
+ if (typeof key !== 'string') return undefined;
10959
+ const rt = [...new Set(_globalThemeRegistry.values())].find(r => themeDataName(r) === key);
10960
+ if (!rt) return undefined;
10961
+ const t = themeDbTable(rt);
10962
+ return { table: t.dbTable, fields: t.dbFields, records: t.dbRecords };
10963
+ }
10964
+ })
10965
+ };
10431
10966
 
10432
10967
  // GL-PORT COMPAT: real ixmaps-flat's global ixmaps.markThemeClass(szId,
10433
10968
  // index)/unmarkThemeClass(szId, index) (htmlgui.js:2196/2208 — a bare
@@ -10444,7 +10979,15 @@
10444
10979
  // here instead of redrawing.
10445
10980
  function markThemeClass(szId, index) {
10446
10981
  const rt = _globalThemeRegistry.get(szId);
10447
- if (!rt) return;
10982
+ if (rt) markRuntimeClass(rt, index);
10983
+ }
10984
+ function unmarkThemeClass(szId, index) {
10985
+ const rt = _globalThemeRegistry.get(szId);
10986
+ if (rt) unmarkRuntimeClass(rt, index);
10987
+ }
10988
+ // the legend's own rows mark their theme directly: its .layer() name
10989
+ // may be shared with other themes of the page
10990
+ function markRuntimeClass(rt, index) {
10448
10991
  // flat: a non-CHART theme (choropleth) marks ONE class at a time —
10449
10992
  // MapTheme.markClass unmarks the previous class first (maptheme.js
10450
10993
  // 14301-14305); chart themes can mark several
@@ -10454,9 +10997,7 @@
10454
10997
  if (rt._onMarksChanged) rt._onMarksChanged();
10455
10998
  else if (rt._triggerRedraw) rt._triggerRedraw();
10456
10999
  }
10457
- function unmarkThemeClass(szId, index) {
10458
- const rt = _globalThemeRegistry.get(szId);
10459
- if (!rt) return;
11000
+ function unmarkRuntimeClass(rt, index) {
10460
11001
  rt._markedClasses.delete(index);
10461
11002
  if (rt._onMarksChanged) rt._onMarksChanged();
10462
11003
  else if (rt._triggerRedraw) rt._triggerRedraw();
@@ -10759,13 +11300,30 @@
10759
11300
  layer, Map: createMap, setExternalData: setExternalDataBridge, getThemeObj, data: ixmapsData,
10760
11301
  szResourceBase: ixmapsSzResourceBase, getProjectString, setProjectJSON, loadProject,
10761
11302
  markThemeClass, unmarkThemeClass, getBoundingBox, setTitle, setTitleBox, refreshTheme, formatValue: flatFormatValue,
10762
- map: mapHandle, getZoom,
11303
+ map: mapHandle, getZoom, getThemeDefinitionObj, embeddedSVG,
11304
+ // flat's theme list: the theme objects of every theme
11305
+ getThemes: () => [...new Set(_globalThemeRegistry.values())].map(rt => getThemeObj(themeIdOf(rt))).filter(Boolean),
11306
+ // flat shows a short status message on the map; not ported
11307
+ message: () => {},
11308
+ // flat's item highlight / item filter (facet histogram hovers): not ported
11309
+ filterThemeItems: () => warnOnce('filterThemeItems', '[ixmaps-gl] ixmaps.filterThemeItems is not supported yet'),
11310
+ highlightThemeItems: () => warnOnce('highlightThemeItems', '[ixmaps-gl] ixmaps.highlightThemeItems is not supported yet'),
11311
+ // flat's global forms with the map name first (htmlgui.js) — the map
11312
+ // handle's methods on the last built map
11313
+ getCenter: () => (_lastMapApi && _lastMapApi.map ? _lastMapApi.map.getCenter() : null),
11314
+ getMapTypeId: () => (_lastMapApi && _lastMapApi.getMapTypeId ? _lastMapApi.getMapTypeId() : ''),
11315
+ changeThemeStyle: (szMap, szId, szStyle, szFlag) => _mapHandle.changeThemeStyle(szId, szStyle, szFlag),
11316
+ removeTheme: (szMap, szId) => _mapHandle.remove(szId),
11317
+ setBasemapOpacity: (szMap, nOpacity, szMode) => _mapHandle.setBasemapOpacity(nOpacity, szMode),
10763
11318
  // this engine's version (flat's own ixmaps.version numbers flat)
10764
11319
  glVersion: IXMAPS_GL_VERSION,
10765
11320
  // flat always has this page hook (its layer legend tool defines it,
10766
11321
  // ui/js/tools/layer_legend.js 702-712): pages wrap it and call the one
10767
11322
  // they replaced — the chain ends here
10768
11323
  htmlgui_onZoomAndPan: function (nZoom) {},
11324
+ // flat calls it when a theme is drawn (htmlgui.js) — pages wrap it
11325
+ // (statistics, facets); called after each settled redraw, notifyRedraw
11326
+ htmlgui_onDrawTheme: function (szId) {},
10769
11327
  // carried over from a page's own pre-load `ixmaps.validate = ...`
10770
11328
  validate: _preloadValidate
10771
11329
  };
@@ -10780,7 +11338,7 @@
10780
11338
  // test-only: lets test/unit/*.test.mjs call pure internals directly (the
10781
11339
  // engine runs in a Node vm there); deliberately NOT on the ixmaps object
10782
11340
  global.__ixmapsGlInternals = {
10783
- IXMAPS_GL_VERSION, computeBubblePackLayout, isolatedBubblePackLayout,
11341
+ IXMAPS_GL_VERSION, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout,
10784
11342
  normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, typeStyleNumbers, styleNum,
10785
11343
  resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
10786
11344
  equalIntervalBreaks, quantileBreaks, naturalBreaks, evenStrideSample, jenksBreakValues, partsFromBreakValues, resolvePartsClass,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ixmaps-gl",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
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",