ixmaps-gl 0.2.7 → 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 +653 -61
  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.7/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.7/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.7/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.7';
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
@@ -6207,6 +6302,93 @@
6207
6302
  return { present, radii, offsets, fitScale, maxR };
6208
6303
  }
6209
6304
 
6305
+ // A group while legend classes are marked (flat's MapTheme.markClass,
6306
+ // maptheme.js 14339-14420, evidence "isolate" — the default): the parts
6307
+ // of the other categories are hidden, the marked ones keep their own
6308
+ // size and move to the group's center (the SEQUENCE transform reset to
6309
+ // its scale only). Biggest first, so a smaller marked part stays on top.
6310
+ // null when no marked category is in the group.
6311
+ function isolatedBubblePackLayout(counts, size, marked) {
6312
+ const full = computeBubblePackLayout(counts, size);
6313
+ const keep = full.present.map((p, k) => k).filter(k => marked.has(full.present[k].i))
6314
+ .sort((a, b) => full.radii[b] - full.radii[a]);
6315
+ if (!keep.length) return null;
6316
+ return {
6317
+ present: keep.map(k => full.present[k]),
6318
+ radii: keep.map(k => full.radii[k]),
6319
+ offsets: keep.map(() => ({ x: 0, y: 0 })),
6320
+ fitScale: full.fitScale, maxR: full.maxR
6321
+ };
6322
+ }
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
+
6210
6392
  // ---------------------------------------------------------------
6211
6393
  // Classification — pure functions (no LayerRuntime state): class
6212
6394
  // breaks for a numeric field, and which class a value falls in.
@@ -7161,7 +7343,7 @@
7161
7343
  key = `${snapped.x}:${snapped.y}`;
7162
7344
  }
7163
7345
  let cell = cells.get(key);
7164
- 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); }
7165
7347
  cell.sumX += p.x; cell.sumY += p.y; cell.n++;
7166
7348
  cell.value += f.properties.value;
7167
7349
  if (f.properties.classValue !== undefined) cell.classValue += f.properties.classValue;
@@ -7183,6 +7365,9 @@
7183
7365
  ? { cluster: true, point_count: cell.n, value: cell.value, ...(hasClass ? { classValue: cell.classValue } : {}) }
7184
7366
  : cell.first.properties;
7185
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 });
7186
7371
  if (post) {
7187
7372
  props = Object.assign({}, props);
7188
7373
  if (hasClass) props.classValue = post(cell.n > 1 ? cell.classValue : props.classValue, cell.value100, props.series);
@@ -7215,7 +7400,7 @@
7215
7400
  }
7216
7401
  }
7217
7402
 
7218
- function groupCoLocated(clusterFeatures, zoom, clusterRadiusPx, nCategories, flags) {
7403
+ function groupCoLocated(clusterFeatures, zoom, clusterRadiusPx, nCategories, flags, atCellCenter = false) {
7219
7404
  // Merge tolerance MUST track the same theme-driven, scale-dependent
7220
7405
  // aggregation width the clustering itself just used (clusterRadiusPx,
7221
7406
  // set by _ensureClusterIndices right before this runs) — not an
@@ -7237,9 +7422,11 @@
7237
7422
  // recomputes ptPos as the mean of ptPosA regardless of which
7238
7423
  // grid produced the grouping key).
7239
7424
  const snapped = cellPx ? snapToAggregationGrid(p.x, p.y, cellPx, flags, zoom) : p;
7240
- 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}`;
7241
7428
  let cell = cells.get(key);
7242
- 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); }
7243
7430
  cell.sumX += p.x; cell.sumY += p.y; cell.n++;
7244
7431
  cell.counts[f.properties.cat] += f.properties.value;
7245
7432
  if (f.properties.classValue !== undefined) cell.classTotal = (cell.classTotal || 0) + f.properties.classValue;
@@ -7250,7 +7437,8 @@
7250
7437
  cell.recordCounts[f.properties.cat] += (f.properties.point_count || 1);
7251
7438
  });
7252
7439
  return Array.from(cells.values()).map(cell => {
7253
- 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);
7254
7442
  return {
7255
7443
  geometry: { type: 'Point', coordinates: [ll.lng, ll.lat] },
7256
7444
  properties: { counts: cell.counts, total: cell.counts.reduce((a, c) => a + c, 0), recordCounts: cell.recordCounts, ...(cell.classTotal !== undefined ? { classTotal: cell.classTotal } : {}) }
@@ -7405,7 +7593,7 @@
7405
7593
  this._gridRefLat = (minLat <= maxLat) ? (minLat + maxLat) / 2 : 0;
7406
7594
  }
7407
7595
 
7408
- if (this.flags.has('DOMINANT') && this.binding.value) {
7596
+ if (this.flags.has('DOMINANT') && this.binding.value && !isAggregatedCategoricalChoropleth(this)) {
7409
7597
  // .type("CHOROPLETH|DOMINANT") — a MULTI-field bound value
7410
7598
  // (binding.value is a pipe-joined field list, e.g. one CSV column
7411
7599
  // per age band for the same year/sex — same pipe convention
@@ -7755,6 +7943,26 @@
7755
7943
  }
7756
7944
 
7757
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
+ }
7758
7966
  // Base set: this.features narrowed by the runtime WHERE-filter
7759
7967
  // (changeThemeStyle's own mechanism, see _runtimeFilterExpr's own
7760
7968
  // comment) if one is active — reusing applyWhereFilter, the SAME
@@ -8011,7 +8219,7 @@
8011
8219
  return this._glowIconCache.get(key);
8012
8220
  }
8013
8221
 
8014
- _buildBubbleIcon(counts, colors) {
8222
+ _buildBubbleIcon(counts, colors, marked = null) {
8015
8223
  // The cache key quantizes each count's RATIO to the group's total,
8016
8224
  // not the raw count — computeBubblePackLayout's radii are purely
8017
8225
  // sqrt(c/total), so two clusters with wildly different absolute
@@ -8036,13 +8244,18 @@
8036
8244
  // smooth.
8037
8245
  const total = counts.reduce((a, b) => a + b, 0) || 1;
8038
8246
  const RATIO_BUCKETS = 15;
8039
- const key = counts.map(c => Math.round((c / total) * RATIO_BUCKETS)).join('-');
8247
+ // an isolated group shows only its marked parts, alone and at their
8248
+ // own size: their radius (√share, 1/100 steps) keys the icon, not the
8249
+ // coarse share buckets that can put a small marked part at 0
8250
+ const key = counts.map(c => Math.round((c / total) * RATIO_BUCKETS)).join('-') + (marked
8251
+ ? '|iso:' + counts.map((c, i) => (c > 0 && marked.has(i) ? i + ':' + Math.round(Math.sqrt(c / total) * 100) : null)).filter(Boolean).join(',')
8252
+ : '');
8040
8253
  if (this._iconCache.has(key)) return this._iconCache.get(key);
8041
8254
  const size = BUBBLE_ICON_SIZE;
8042
8255
  const canvas = document.createElement('canvas');
8043
8256
  canvas.width = size; canvas.height = size;
8044
8257
  const ctx = canvas.getContext('2d');
8045
- const { present, radii, offsets, fitScale } = computeBubblePackLayout(counts, size);
8258
+ const { present, radii, offsets, fitScale } = (marked && isolatedBubblePackLayout(counts, size, marked)) || computeBubblePackLayout(counts, size);
8046
8259
  const cx = size / 2, cy = size / 2;
8047
8260
  present.forEach((p, i) => {
8048
8261
  ctx.beginPath();
@@ -8341,6 +8554,15 @@
8341
8554
  // counts as marked if ANY of its constituent categories is marked —
8342
8555
  // a mixed cluster shouldn't dim just because one of several
8343
8556
  // categories inside it happens to be unmarked.
8557
+ // the marked classes a group isolates to (isolatedBubblePackLayout),
8558
+ // or null: none marked, a page's own mark handler, or evidence
8559
+ // "isolate_gray" (keeps every part, dimmed via _iconAlpha)
8560
+ _groupIsolation(d) {
8561
+ if (!d.properties.counts || !this._markedClasses.size || this._onMarksChanged) return null;
8562
+ if (String(this.style.evidence || 'isolate') === 'isolate_gray') return null;
8563
+ return d.properties.counts.some((c, i) => c > 0 && this._markedClasses.has(i)) ? this._markedClasses : null;
8564
+ }
8565
+
8344
8566
  _iconAlpha(d) {
8345
8567
  if (!this._markedClasses.size) return 255;
8346
8568
  if (d.properties.counts) {
@@ -8437,8 +8659,11 @@
8437
8659
  // unlike CHOROPLETH, there's no per-feature classification here.
8438
8660
  // flat puts fillopacity into the shape's fill-opacity only
8439
8661
  // (maptheme.js 13407, 14071-14078): the outline stays opaque
8440
- getFillColor: filled ? [...hexOrNamedToRgb(raw).slice(0, 3), Math.round(255 * (styleNum(this.style.fillopacity) || 1))] : [0, 0, 0, 0],
8441
- 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],
8442
8667
  lineWidthMinPixels: styleNum(this.style.linewidth) || 1,
8443
8668
  // Confirmed live (2026-09-21, deck.gl v9.4/MapLibre-globe upgrade):
8444
8669
  // this layer's fill rendered with a moire/hatching pattern of
@@ -8507,9 +8732,10 @@
8507
8732
  // — plain/PERCENTOFMEAN/DEVIATION); or COMPOSECOLOR's per-polygon
8508
8733
  // BLEND across the same piped fields (_resolveComposedColor — no
8509
8734
  // single winning class, so no `cat`/tooltip chart line, just the
8510
- // blended color directly). CATEGORICAL choropleths (exact-match,
8511
- // single field) aren't implemented — no ported config uses that
8512
- // 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
8513
8739
  // FeatureCollection from joinChoroplethFeatures. `zoom` is only used
8514
8740
  // for style.fillopacity:"auto" (see resolveAutoFillOpacity) — every
8515
8741
  // other branch here is zoom-independent.
@@ -8532,7 +8758,37 @@
8532
8758
  // DOPACITY/COMPOSECOLOR branches.
8533
8759
  const dopacityActive = !isComposeColor &&
8534
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
+ }
8535
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
+ }
8536
8792
  if (isComposeColor) {
8537
8793
  return {
8538
8794
  type: 'Feature',
@@ -8571,7 +8827,14 @@
8571
8827
  // evidence mode "isolate", the default (7715) — or grayed with
8572
8828
  // style.evidence "isolate_gray". ("highlight", flat's highlight list,
8573
8829
  // is shown grayed too here.) COMPOSECOLOR has no classes.
8574
- 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);
8575
8838
  const evidenceMode = String(this.style.evidence || 'isolate');
8576
8839
  const unmarkedEvidence = d => {
8577
8840
  if (!this._markedClasses.size || this._onMarksChanged || this._markedClasses.has(d.properties.cat)) return null;
@@ -8591,7 +8854,8 @@
8591
8854
  const evidence = unmarkedEvidence(d);
8592
8855
  if (evidence === 'hide') return [0, 0, 0, 0];
8593
8856
  if (evidence === 'gray') return [...grayOf(rgb), 90];
8594
- 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);
8595
8859
  },
8596
8860
  // a per-polygon accessor only while a class is isolated
8597
8861
  getLineColor: this._markedClasses.size && !this._onMarksChanged && evidenceMode === 'isolate'
@@ -9535,6 +9799,14 @@
9535
9799
  });
9536
9800
 
9537
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
+ }
9538
9810
  // without RELOCATE a cell's item already sits at the cell center
9539
9811
  // (aggregateOnGrid)
9540
9812
  const groups = doRelocate ? this._groupCoLocated(clusterFeatures, effectiveZoom) : clusterFeatures.map(f => {
@@ -9799,6 +10071,9 @@
9799
10071
  if (userLayers) return layers.concat(userLayers);
9800
10072
  }
9801
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
+ }
9802
10077
  const fillOpacity = styleNum(this.style.fillopacity) || 0.85;
9803
10078
  // .style({linecolor, linewidth}) — an individual icon's own border,
9804
10079
  // see _buildSingleIcon's own comment. Real ixmaps-flat symbol
@@ -9850,7 +10125,7 @@
9850
10125
  id: `ix-glow-${this.name}-g${this._iconGeneration}`,
9851
10126
  data: combined, pickable: false,
9852
10127
  getPosition: d => d.geometry.coordinates,
9853
- getIcon: d => this._getGlowIcon(this.categoryColorsRgb[d.properties.counts ? dominant(d.properties.counts) : d.properties.cat]),
10128
+ 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]),
9854
10129
  getSize: d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue) * (d.properties.counts ? 9 : 11),
9855
10130
  getColor: d => [255, 255, 255, this._iconAlpha(d)],
9856
10131
  ...(alignOf.active ? { getPixelOffset: alignOf } : {}),
@@ -10000,7 +10275,7 @@
10000
10275
  data: combined, pickable: true,
10001
10276
  getPosition: d => d.geometry.coordinates,
10002
10277
  getIcon: d => d.properties.counts
10003
- ? this._buildBubbleIcon(d.properties.counts, this.categoryColorsRgb)
10278
+ ? this._buildBubbleIcon(d.properties.counts, this.categoryColorsRgb, this._groupIsolation(d))
10004
10279
  : this._buildSingleIcon(this.categoryColorsRgb[d.properties.cat], fillOpacity, this._resolveSymbolShape(d.properties), singleBorderColorRgb, singleBorderWidthPx),
10005
10280
  getSize: d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue) * 2,
10006
10281
  getColor: d => [255, 255, 255, this._iconAlpha(d)],
@@ -10073,7 +10348,8 @@
10073
10348
  const outerRadiusPx = valueRadius(d.properties.total, liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
10074
10349
  const iconSizePx = outerRadiusPx * 2; // matches the cluster IconLayer's own getSize (*2) below
10075
10350
  const pxPerCanvasUnit = iconSizePx / BUBBLE_ICON_SIZE;
10076
- const { present, radii, offsets, fitScale } = computeBubblePackLayout(counts, BUBBLE_ICON_SIZE);
10351
+ const iso = this._groupIsolation(d);
10352
+ const { present, radii, offsets, fitScale } = (iso && isolatedBubblePackLayout(counts, BUBBLE_ICON_SIZE, iso)) || computeBubblePackLayout(counts, BUBBLE_ICON_SIZE);
10077
10353
 
10078
10354
  present.forEach((p, i) => {
10079
10355
  // one part for a range-classed cell: its class value, if separate
@@ -10110,6 +10386,164 @@
10110
10386
  return layers;
10111
10387
  }
10112
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
+
10113
10547
  // flat's chart position for this build (flatChartAlignOffset): a
10114
10548
  // function item → pixel offset, the same for every part of one chart
10115
10549
  // (symbol, glow, shadow, box, title, value text). unit is flat's
@@ -10353,6 +10787,23 @@
10353
10787
  }
10354
10788
  }
10355
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
+ }
10356
10807
  function hexOrNamedToRgb(v) {
10357
10808
  const NAMED = { gray: [128, 128, 128], grey: [128, 128, 128], black: [0, 0, 0], white: [255, 255, 255] };
10358
10809
  return parseCssColor(v) || NAMED[v] || [130, 130, 130];
@@ -10371,6 +10822,13 @@
10371
10822
  // nothing here authored that string. Escaped before going into
10372
10823
  // innerHTML so a stray `<`/`&`/quote in a source field can't break the
10373
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
+ }
10374
10832
  function escapeHtml(s) {
10375
10833
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
10376
10834
  }
@@ -10381,19 +10839,130 @@
10381
10839
  // definition wins" caveat). Returns null for an unknown id, same as the
10382
10840
  // real engine, rather than throwing.
10383
10841
  function getThemeObj(szId) {
10384
- 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
+ }
10385
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));
10386
10855
  return {
10387
- 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),
10388
10859
  szName: rt.name,
10389
- szFilter: rt._filterExpr || '',
10860
+ // the filter in force: a runtime one (changeThemeStyle "filter")
10861
+ // replaces the definition's
10862
+ szFilter: rt._runtimeFilterExpr || rt._filterExpr || '',
10390
10863
  szFlag: Array.from(rt.flags).join('|'),
10391
10864
  // flat's fVisible is false for a FEATURE theme outside its
10392
10865
  // featureupper/featurelower scales too (maptheme.js 2609-2611) — a
10393
10866
  // broker reads it to skip refreshing a hidden theme
10394
- 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; }
10395
10894
  };
10396
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
+ };
10397
10966
 
10398
10967
  // GL-PORT COMPAT: real ixmaps-flat's global ixmaps.markThemeClass(szId,
10399
10968
  // index)/unmarkThemeClass(szId, index) (htmlgui.js:2196/2208 — a bare
@@ -10410,7 +10979,15 @@
10410
10979
  // here instead of redrawing.
10411
10980
  function markThemeClass(szId, index) {
10412
10981
  const rt = _globalThemeRegistry.get(szId);
10413
- 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) {
10414
10991
  // flat: a non-CHART theme (choropleth) marks ONE class at a time —
10415
10992
  // MapTheme.markClass unmarks the previous class first (maptheme.js
10416
10993
  // 14301-14305); chart themes can mark several
@@ -10420,9 +10997,7 @@
10420
10997
  if (rt._onMarksChanged) rt._onMarksChanged();
10421
10998
  else if (rt._triggerRedraw) rt._triggerRedraw();
10422
10999
  }
10423
- function unmarkThemeClass(szId, index) {
10424
- const rt = _globalThemeRegistry.get(szId);
10425
- if (!rt) return;
11000
+ function unmarkRuntimeClass(rt, index) {
10426
11001
  rt._markedClasses.delete(index);
10427
11002
  if (rt._onMarksChanged) rt._onMarksChanged();
10428
11003
  else if (rt._triggerRedraw) rt._triggerRedraw();
@@ -10725,13 +11300,30 @@
10725
11300
  layer, Map: createMap, setExternalData: setExternalDataBridge, getThemeObj, data: ixmapsData,
10726
11301
  szResourceBase: ixmapsSzResourceBase, getProjectString, setProjectJSON, loadProject,
10727
11302
  markThemeClass, unmarkThemeClass, getBoundingBox, setTitle, setTitleBox, refreshTheme, formatValue: flatFormatValue,
10728
- 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),
10729
11318
  // this engine's version (flat's own ixmaps.version numbers flat)
10730
11319
  glVersion: IXMAPS_GL_VERSION,
10731
11320
  // flat always has this page hook (its layer legend tool defines it,
10732
11321
  // ui/js/tools/layer_legend.js 702-712): pages wrap it and call the one
10733
11322
  // they replaced — the chain ends here
10734
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) {},
10735
11327
  // carried over from a page's own pre-load `ixmaps.validate = ...`
10736
11328
  validate: _preloadValidate
10737
11329
  };
@@ -10746,7 +11338,7 @@
10746
11338
  // test-only: lets test/unit/*.test.mjs call pure internals directly (the
10747
11339
  // engine runs in a Node vm there); deliberately NOT on the ixmaps object
10748
11340
  global.__ixmapsGlInternals = {
10749
- IXMAPS_GL_VERSION,
11341
+ IXMAPS_GL_VERSION, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout,
10750
11342
  normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, typeStyleNumbers, styleNum,
10751
11343
  resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
10752
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.7",
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",