ixmaps-gl 0.2.8 → 0.2.10

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 +1716 -1095
  3. package/package.json +1 -1
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.10';
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
@@ -1905,91 +1972,6 @@
1905
1972
  }
1906
1973
  }
1907
1974
 
1908
- // flat's page hook ixmaps.htmlgui_onNewTheme(szId), called when a theme
1909
- // is created (maptheme.js 927) — with the theme's id (its name)
1910
- function themeIdOf(rt) {
1911
- return (rt.style && rt.style.name) || (rt.meta && rt.meta.name) || rt.name;
1912
- }
1913
- function notifyNewTheme(rt) {
1914
- const hook = pageIxmaps() && pageIxmaps().htmlgui_onNewTheme;
1915
- if (typeof hook !== 'function') return;
1916
- try { hook.call(global.ixmaps, themeIdOf(rt)); } catch (e) { console.error('[ixmaps-gl] htmlgui_onNewTheme:', e); }
1917
- }
1918
-
1919
- // flat's ixmaps.map() — the running map's handle (htmlgui.js 173ff,
1920
- // htmlgui_flat.js). Its methods act on the last built map; called while
1921
- // the map is still building, they run once it is ready.
1922
- const MAP_HANDLE_METHODS = ['replace', 'add', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
1923
- 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible'];
1924
- const _mapHandle = {};
1925
- for (const m of MAP_HANDLE_METHODS) {
1926
- _mapHandle[m] = (...args) => {
1927
- if (_lastMapApi) { const r = _lastMapApi[m](...args); return r === _lastMapApi ? _mapHandle : r; }
1928
- _mapReady.then(api => api[m](...args));
1929
- return _mapHandle;
1930
- };
1931
- }
1932
- _mapHandle.getZoom = () => (_lastMapApi ? _lastMapApi.getZoom() : undefined);
1933
- _mapHandle.getThemeObj = id => getThemeObj(id);
1934
- function mapHandle() { return _mapHandle; }
1935
-
1936
- // flat's ixmaps.formatValue(value, precision, flag) (ui/js/tools/format.js),
1937
- // which user chart scripts call: thousands separated by "." (BLANK: a
1938
- // space), the decimals after "," (BLANK: "."), CEIL / FLOOR rounding
1939
- function flatFormatPart(nPart, szLeading) {
1940
- szLeading = szLeading || '';
1941
- let szPart = '';
1942
- if (nPart < 100) szPart += szLeading;
1943
- if (nPart < 10) szPart += szLeading;
1944
- if (nPart === 0) szPart += szLeading;
1945
- else szPart += String(nPart);
1946
- return szPart;
1947
- }
1948
- function flatFormatValue(nValue, nPrecision, szFlag) {
1949
- nValue = Number(nValue);
1950
- if (!isFinite(nValue) || !isFinite(nPrecision)) return String(nValue);
1951
- if (nValue === 0) return String(nValue);
1952
- if (nValue > 1000000000000 || nValue < -1000000000000) return String(nValue);
1953
- nPrecision = Math.max(0, nPrecision || 0);
1954
- if (nValue > 0.0000001 && nPrecision > 0) {
1955
- while (Number(nValue.toFixed(nPrecision - 1)) === 0) nPrecision++;
1956
- }
1957
- let v = Number(nValue.toFixed(nPrecision + 1));
1958
- const clip = Math.pow(10, nPrecision);
1959
- if (szFlag && /CEIL/.test(szFlag)) v = Math.ceil(v * clip) / clip;
1960
- else if (szFlag && /FLOOR/.test(szFlag)) v = Math.floor(v * clip) / clip;
1961
- else v = Math.round(v * clip) / clip;
1962
- let szDecimals = String(v);
1963
- if (/\./.test(szDecimals)) {
1964
- szDecimals = szDecimals.split('.')[1];
1965
- while (szDecimals.length < nPrecision) szDecimals += '0';
1966
- } else szDecimals = '';
1967
- let szReturn = v < 0 ? '-' : '';
1968
- let szLeading = '';
1969
- let n = Math.floor(Math.abs(v));
1970
- if (!szFlag || !/NOBREAKS/.test(szFlag)) {
1971
- let nClip = 1000;
1972
- while (n > nClip) nClip *= 1000;
1973
- nClip /= 1000;
1974
- let szBreak = ' ';
1975
- while (nClip >= 1000) {
1976
- const nPart = Math.floor(n / nClip);
1977
- szReturn += flatFormatPart(nPart, szLeading);
1978
- n = n % nClip;
1979
- nClip /= 1000;
1980
- if (nPart) {
1981
- szLeading = '0';
1982
- szBreak = szFlag && /SPACE/.test(szFlag) ? '<span style="font-size:0.5em;">&nbsp;</span>' : szFlag && /BLANK/.test(szFlag) ? '&nbsp;' : '.';
1983
- }
1984
- szReturn += szBreak;
1985
- }
1986
- }
1987
- szReturn += flatFormatPart(n, szLeading);
1988
- if (!szReturn.length || szReturn === '-') szReturn += '0';
1989
- if (szDecimals.length && szDecimals !== '00') szReturn += (szFlag && /BLANK/.test(szFlag) ? '.' : ',') + szDecimals;
1990
- return szReturn;
1991
- }
1992
-
1993
1975
  // ---- USER charts: a page's own chart function (style.userdraw) ----
1994
1976
  // flat calls ixmaps.<userdraw>_init(SVGDocument, {target, theme}) once
1995
1977
  // per realize (maptheme.js 17000-17050) and ixmaps.<userdraw>(SVGDocument,
@@ -2034,57 +2016,6 @@
2034
2016
  // packs them into one texture (1024 px wide), which must stay well below
2035
2017
  // the GPU's texture size limit — see _userChartIcon
2036
2018
  const USER_CHART_ATLAS_AREA = 1024 * 8192;
2037
- function getZoom() { return _lastMapApi ? _lastMapApi.getZoom() : undefined; }
2038
-
2039
- // flat's ixmaps.getBoundingBox() (htmlgui_sync_Leaflet_VT.js
2040
- // htmlMap_getBounds): [{lat, lng} south-west, {lat, lng} north-east] of
2041
- // the view, latitude clamped to ±85.05, longitude not capped. Set by the
2042
- // last build(): a broker (data.type "ext") is called while its map is
2043
- // still building, so until the MapLibre map exists the bounds come from
2044
- // the requested view and the container size (MapLibre's 512 px tiles).
2045
- let _boundsSource = null;
2046
- function viewBounds(lat, lng, mlZoom, width, height) {
2047
- const world = 512 * Math.pow(2, mlZoom);
2048
- const x = (lng + 180) / 360 * world;
2049
- const s = Math.sin(Math.max(-85.05, Math.min(85.05, lat)) * Math.PI / 180);
2050
- const y = (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * world;
2051
- const lngAt = px => px / world * 360 - 180;
2052
- const latAt = py => Math.atan(Math.sinh(Math.PI * (1 - 2 * py / world))) * 180 / Math.PI;
2053
- return [{ lat: latAt(y + height / 2), lng: lngAt(x - width / 2) }, { lat: latAt(y - height / 2), lng: lngAt(x + width / 2) }];
2054
- }
2055
- function getBoundingBox() {
2056
- const b = _boundsSource ? _boundsSource() : null;
2057
- if (!b) return null;
2058
- return [{ lat: Math.max(b[0].lat, -85.05), lng: b[0].lng }, { lat: Math.min(b[1].lat, 85.05), lng: b[1].lng }];
2059
- }
2060
-
2061
- // flat's ixmaps.setTitle(html) / setTitleBox(text, color) (htmlgui.js
2062
- // 1311-1331): a message line over the last built map, same markup as
2063
- // flat's default (legend not aligned left); '' clears it.
2064
- let _titleHost = null;
2065
- function setTitle(szTitle) {
2066
- if (!_titleHost) return;
2067
- let box = _titleHost.querySelector(':scope > .ixmaps-gl-title');
2068
- if (!box) {
2069
- box = document.createElement('div');
2070
- box.className = 'ixmaps-gl-title';
2071
- box.style.cssText = 'position:absolute;top:11px;left:0;z-index:3;pointer-events:none;';
2072
- _titleHost.appendChild(box);
2073
- }
2074
- box.innerHTML = szTitle
2075
- ? "<div style='position:relative;left:100px;top:2px;font-style:arial,helvetica;font-size:22px'>" + szTitle + '</div>'
2076
- : '';
2077
- }
2078
- function setTitleBox(szTitle, szColor) {
2079
- setTitle("<span style='display:inline-flex;align-items:center;height:38px;box-sizing:border-box;padding:0 12px;border:1px solid #46494c;border-radius:8px;font-size:14px;font-family:courier new,Raleway,arial,helvetica;background:" + (szColor || 'rgba(255,255,255,0.95)') + ';color:' + (szColor ? '#fff' : '#222') + "'>" + szTitle + '</span>');
2080
- }
2081
- // flat's ixmaps.refreshTheme(szId): reloads the theme's data on the last
2082
- // built map (a broker is called again) — see engineApi.refreshTheme
2083
- function refreshTheme(szId) {
2084
- if (!_lastMapApi || !_lastMapApi.refreshTheme) return;
2085
- _lastMapApi.refreshTheme(szId).catch(err => console.error('[ixmaps-gl] refreshTheme:', err));
2086
- }
2087
-
2088
2019
  class LayerBuilder {
2089
2020
  constructor(name) {
2090
2021
  this.name = name;
@@ -2194,7 +2125,7 @@
2194
2125
  // edit it by hand; `npm run unit` fails when it drifts.
2195
2126
  // ---------------------------------------------------------------
2196
2127
  // <grammar:binding-aliases>
2197
- // generated from ixmaps-grammar 0.1.23 (ixmaps-flat 1.0.42, 2b978d5 2026-10-02) — 52 aliases
2128
+ // generated from ixmaps-grammar 0.1.24 (ixmaps-flat 1.0.42, 2b978d5 2026-10-02) — 52 aliases
2198
2129
  const FLAT_BINDING_ALIASES = {
2199
2130
  "aggregation": "style.aggregationfield",
2200
2131
  "aggregationfield": "style.aggregationfield",
@@ -2256,7 +2187,7 @@
2256
2187
  // both — so .style({tooltip}) is a tooltip and .meta({fillopacity}) a style
2257
2188
  // property. GENERATED by test/sync-grammar.mjs — do not edit.
2258
2189
  // <grammar:meta-keys>
2259
- // generated from ixmaps-grammar 0.1.23 — 5 meta keys
2190
+ // generated from ixmaps-grammar 0.1.24 — 5 meta keys
2260
2191
  const FLAT_META_KEYS = ["description","name","snippet","title","tooltip"];
2261
2192
  // </grammar:meta-keys>
2262
2193
 
@@ -2712,6 +2643,916 @@
2712
2643
  };
2713
2644
  }
2714
2645
 
2646
+ // ---------------------------------------------------------------
2647
+ // Hover tooltips and the click-to-pin tooltip of a map, built once per map
2648
+ // by MapBuilder.build(). ctx: the builder (its map options, for the
2649
+ // tooltip look), the MapLibre map, its container element and
2650
+ // findRuntimeForLayerId (the theme of a drawn deck.gl layer). Returns the
2651
+ // overlay's getTooltip / onClick handlers and mount(), which adds the
2652
+ // pinned tooltip to the page and keeps it on its place while the map moves.
2653
+ // ---------------------------------------------------------------
2654
+ function createTooltips(ctx) {
2655
+ const { builder, map, el, findRuntimeForLayerId } = ctx;
2656
+ // Click-to-pin tooltip: matches the real ixmaps engine's own
2657
+ // click-pins-the-tooltip convention in pan mode (a hover tooltip is
2658
+ // transient; a click keeps one visible until dismissed). This is a
2659
+ // separate DOM element from deck.gl's own hover tooltip (so hovering
2660
+ // a DIFFERENT bubble still shows a normal transient tooltip even
2661
+ // while one is pinned) — its content is a snapshot of buildTooltipHtml
2662
+ // at click time (not live-updating), but its screen POSITION is kept
2663
+ // in sync with the map on every pan/zoom via map.project().
2664
+ let pinned = null; // { runtime, object, lngLat }
2665
+ // Tooltip look, like the legend's (flatLegendLook): flat's light
2666
+ // tooltip (tooltip_mustache.js: white 0.95, #444 text, thin black
2667
+ // border, 5px radius) on light basemaps, this engine's dark tooltip
2668
+ // on dark ones. Read when shown, so options set later (a
2669
+ // myMap.then(...).options({basemapopacity})) count.
2670
+ const tooltipLook = () => {
2671
+ const o = parseFloat(builder._engineOptions.basemapopacity);
2672
+ const look = flatLegendLook(builder.mapOptions.mapType, isNaN(o) ? 1 : o, builder.mapOptions.legendBackground || builder.mapOptions.legendbackground);
2673
+ return look.dark
2674
+ ? { background: 'rgb(41,50,60)', color: 'rgb(200,205,214)', border: 'none', borderRadius: '4px', boxShadow: '0 2px 8px rgba(0,0,0,0.3)' }
2675
+ : { background: 'rgba(255,255,255,0.95)', color: '#444', border: '0.5px solid black', borderRadius: '5px',
2676
+ boxShadow: 'rgba(0,0,0,0.2) 0px 2px 4px 0px, rgba(0,0,0,0.19) 0px 3px 10px 0px' };
2677
+ };
2678
+ const pinnedTooltipEl = document.createElement('div');
2679
+ pinnedTooltipEl.style.cssText = 'position:absolute;top:0;left:0;z-index:6;display:none;' +
2680
+ 'pointer-events:auto;max-width:320px;max-height:320px;overflow:auto;' +
2681
+ 'padding:0.6em 1.6em 0.6em 0.7em;font-size:0.85em;';
2682
+
2683
+ function updatePinnedTooltipPosition() {
2684
+ if (!pinned) return;
2685
+ const pt = map.project(pinned.lngLat);
2686
+ pinnedTooltipEl.style.transform = `translate(${pt.x + 10}px, ${pt.y - 10}px)`;
2687
+ }
2688
+
2689
+ // A stable per-geometry anchor coordinate for the identity check
2690
+ // below — only a Point's OWN coordinates are an [lng,lat] pair;
2691
+ // Polygon/MultiPolygon nest rings of them, so the first ring's
2692
+ // first vertex stands in instead (stable across rebuilds for the
2693
+ // same underlying feature, same as the Point case: this.features'
2694
+ // geometry references don't change shape between
2695
+ // _buildChoroplethLayers calls, only the wrapping properties do).
2696
+ function anchorCoordOf(geometry) {
2697
+ if (!geometry) return null;
2698
+ if (geometry.type === 'Point') return geometry.coordinates;
2699
+ if (geometry.type === 'Polygon') return geometry.coordinates[0] && geometry.coordinates[0][0];
2700
+ if (geometry.type === 'MultiPolygon') return geometry.coordinates[0] && geometry.coordinates[0][0] && geometry.coordinates[0][0][0];
2701
+ return null;
2702
+ }
2703
+
2704
+ // Identity check for "is this hovered object the same one that's
2705
+ // pinned" — object references are rebuilt from scratch on every
2706
+ // redraw (new individual/group arrays each buildDeckLayers call), so
2707
+ // reference equality never works here. Same layer + same geometry
2708
+ // position is a reasonable proxy: individual points and aggregated
2709
+ // groups alike are keyed by position within a layer, matching how
2710
+ // clustering itself already treats "same position" as "same thing".
2711
+ function isSameAsPinned(layerId, object) {
2712
+ if (pinned.layerId !== layerId) return false;
2713
+ const a = anchorCoordOf(object.geometry);
2714
+ const b = anchorCoordOf(pinned.object.geometry);
2715
+ if (!a || !b) return false;
2716
+ return Math.abs(a[0] - b[0]) < 1e-9 && Math.abs(a[1] - b[1]) < 1e-9;
2717
+ }
2718
+
2719
+ // content lives in its own child so re-rendering (innerHTML) never
2720
+ // wipes out the unpin button below (a sibling, not a descendant of it)
2721
+ const pinnedContentEl = document.createElement('div');
2722
+ pinnedTooltipEl.appendChild(pinnedContentEl);
2723
+
2724
+ function renderPinnedTooltip() {
2725
+ if (!pinned) { pinnedTooltipEl.style.display = 'none'; return; }
2726
+ const html = pinned.runtime.buildTooltipHtml(pinned.object);
2727
+ if (!html) { pinned = null; pinnedTooltipEl.style.display = 'none'; return; }
2728
+ pinnedContentEl.innerHTML = html;
2729
+ Object.assign(pinnedTooltipEl.style, tooltipLook());
2730
+ pinnedTooltipEl.style.display = 'block';
2731
+ updatePinnedTooltipPosition();
2732
+ }
2733
+
2734
+ const unpinBtn = document.createElement('button');
2735
+ unpinBtn.type = 'button';
2736
+ unpinBtn.setAttribute('aria-label', 'chiudi');
2737
+ unpinBtn.title = 'chiudi';
2738
+ unpinBtn.textContent = '×';
2739
+ unpinBtn.style.cssText = 'position:absolute;top:5px;right:5px;width:1.5em;height:1.5em;padding:0;' +
2740
+ 'display:flex;align-items:center;justify-content:center;border:none;border-radius:50%;' +
2741
+ 'background:rgba(255,255,255,0.18);color:inherit;font-size:1em;line-height:1;font-weight:bold;cursor:pointer';
2742
+ unpinBtn.addEventListener('mouseenter', () => { unpinBtn.style.background = 'rgba(255,255,255,0.35)'; });
2743
+ unpinBtn.addEventListener('mouseleave', () => { unpinBtn.style.background = 'rgba(255,255,255,0.18)'; });
2744
+ unpinBtn.addEventListener('click', () => { pinned = null; pinnedTooltipEl.style.display = 'none'; });
2745
+ pinnedTooltipEl.style.position = 'absolute';
2746
+ pinnedTooltipEl.appendChild(unpinBtn);
2747
+
2748
+ // deck.gl's own default hover tooltip is only recomputed on pointer-
2749
+ // move events, not on click — so right after a click, it's still
2750
+ // showing whatever it rendered from the hover that was already
2751
+ // active (the same item we just pinned), and won't hide itself just
2752
+ // because getTooltip would now return null for it. Nothing re-asks
2753
+ // it until the next real mousemove. Force it closed here instead —
2754
+ // deferred one frame so it wins even if deck.gl's own click handling
2755
+ // touches the tooltip DOM in the same tick.
2756
+ function hideDefaultHoverTooltip() {
2757
+ requestAnimationFrame(() => {
2758
+ const defaultTooltipEl = el.parentElement && el.parentElement.querySelector('.deck-tooltip');
2759
+ if (defaultTooltipEl) defaultTooltipEl.style.display = 'none';
2760
+ });
2761
+ }
2762
+
2763
+ let tooltipErrorLogged = false;
2764
+ // a USER chart's picked item is {d, icon} (see _buildUserChartLayers):
2765
+ // the feature is its d
2766
+ function pickedFeature(o) { return o && o.icon && o.d ? o.d : o; }
2767
+ function tooltipFor({ object: picked, layer }) {
2768
+ const object = pickedFeature(picked);
2769
+ if (!object || !layer) return null;
2770
+ // suppress the hover tooltip ONLY for the specific item that's
2771
+ // pinned (showing both would just duplicate the same content) —
2772
+ // every OTHER item still gets its normal hover preview even
2773
+ // while something else stays pinned
2774
+ if (pinned && isSameAsPinned(layer.id, object)) return null;
2775
+ const rt = findRuntimeForLayerId(layer.id);
2776
+ if (!rt) return null;
2777
+ const html = rt.buildTooltipHtml(object);
2778
+ return html ? { html, style: Object.assign({ fontSize: '0.85em', padding: '0.5em 0.7em', maxWidth: '320px' }, tooltipLook()) } : null;
2779
+ }
2780
+ function clickFor(picked) {
2781
+ const info = picked && Object.assign({}, picked, { object: pickedFeature(picked.object) });
2782
+ if (info && info.object && info.layer) {
2783
+ const rt = findRuntimeForLayerId(info.layer.id);
2784
+ if (rt) {
2785
+ // Only a Point geometry's own coordinates ARE an
2786
+ // [lng,lat] pair (every point/bubble/dot/chart-cluster
2787
+ // layer) — a CHOROPLETH polygon's geometry.coordinates is
2788
+ // a nested array of RINGS, which map.project() below
2789
+ // can't accept (confirmed live: threw MapLibre's own
2790
+ // "LngLatLike argument must be..." error). The `||
2791
+ // info.coordinate` fallback this used to lean on never
2792
+ // actually ran for a polygon — a nested array is truthy,
2793
+ // so the left side always "won" — which is the real bug
2794
+ // this fixes: the pinned tooltip stuck at its default
2795
+ // top:0/left:0 (never got a real position because
2796
+ // updatePinnedTooltipPosition's map.project() threw
2797
+ // before setting one), AND that same uncaught exception,
2798
+ // thrown from inside deck.gl's own click-dispatch
2799
+ // callback, left deck.gl's pointer-interaction state
2800
+ // corrupted enough to block all further pan/zoom —
2801
+ // confirmed live, both symptoms disappear together once
2802
+ // this stops throwing. info.coordinate (the actual
2803
+ // clicked map location, provided regardless of feature
2804
+ // geometry type) anchors every non-Point case correctly.
2805
+ const lngLat = (info.object.geometry && info.object.geometry.type === 'Point')
2806
+ ? info.object.geometry.coordinates
2807
+ : info.coordinate;
2808
+ pinned = { runtime: rt, object: info.object, lngLat, layerId: info.layer.id };
2809
+ renderPinnedTooltip();
2810
+ hideDefaultHoverTooltip();
2811
+ return true;
2812
+ }
2813
+ }
2814
+ // clicked empty map space (or a non-tooltippable layer) -> unpin
2815
+ pinned = null;
2816
+ pinnedTooltipEl.style.display = 'none';
2817
+ return false;
2818
+ }
2819
+ // An exception thrown from these callbacks runs inside deck.gl's
2820
+ // own render frame / event dispatch and leaves its frame and
2821
+ // interaction state broken — the map stopped showing new layers
2822
+ // after zooms and pans (a USER chart's hover threw on every frame
2823
+ // the mouse was over an arrow, see pickedFeature). Both are guarded.
2824
+ function getTooltip(info) {
2825
+ try { return tooltipFor(info); } catch (err) {
2826
+ if (!tooltipErrorLogged) { tooltipErrorLogged = true; console.error('[ixmaps-gl] tooltip failed:', err); }
2827
+ return null;
2828
+ }
2829
+ }
2830
+ function onClick(info) {
2831
+ try { return clickFor(info); } catch (err) {
2832
+ console.error('[ixmaps-gl] click failed:', err);
2833
+ return false;
2834
+ }
2835
+ }
2836
+ function mount() {
2837
+ map.on('move', updatePinnedTooltipPosition);
2838
+ if (el.parentElement) {
2839
+ el.parentElement.style.position = el.parentElement.style.position || 'relative';
2840
+ el.parentElement.appendChild(pinnedTooltipEl);
2841
+ }
2842
+ }
2843
+ return { getTooltip, onClick, mount };
2844
+ }
2845
+
2846
+ // ---------------------------------------------------------------
2847
+ // The map's native interactive legend, built once per map by
2848
+ // MapBuilder.build(). ctx: the builder (its map options), the MapLibre
2849
+ // map, its container element, the deck.gl overlay, the runtimes, the
2850
+ // engine API and its refresh hooks. Returns the four hooks the map
2851
+ // calls: addLegendPanel(rt), setLegendOption(value), updateSubTheme(rt),
2852
+ // layoutLegends().
2853
+ // ---------------------------------------------------------------
2854
+ function createLegend(ctx) {
2855
+ const { builder, map, el, overlay, runtimes, engineApi, built, refresh, scheduleRefresh, notifyRedraw } = ctx;
2856
+ let addLegendPanel = () => {};
2857
+ let setLegendOption = () => {};
2858
+ let updateSubTheme = () => {};
2859
+ let layoutLegends = () => {};
2860
+
2861
+ // NATIVE INTERACTIVE LEGEND — real ixmaps-flat's legend.js
2862
+ // (makeColorLegendHTMLLong) builds, per CATEGORICAL-ish theme: a
2863
+ // title/snippet header, one row per category (color swatch + a bar
2864
+ // proportional to that category's own total + right-aligned
2865
+ // formatted value), a description/source footer, and a per-theme
2866
+ // "Chart size" 25-200% slider — all read directly off source
2867
+ // (ui/js/tools/legend.js:594-1037/3196-3393, ui/js/htmlgui.js:2196/
2868
+ // 2208 for markThemeClass/unmarkThemeClass, ui/css/legend.css for
2869
+ // the selected-row highlight) rather than guessed from the
2870
+ // screenshot alone. Gated on `legend:"open"` — the one real map
2871
+ // option this engine's own createMap already threads through as
2872
+ // builder.mapOptions (see mapOptions.legend below) — since there's no
2873
+ // toolbar toggle button here to open/close it later the way the
2874
+ // real engine's own chrome does.
2875
+ //
2876
+ // Deliberate divergences from the real implementation, both
2877
+ // reasoned rather than accidental:
2878
+ // - Real legend.js does a full innerHTML rebuild on every single
2879
+ // theme redraw (even a bare click-toggle). renderRows() below
2880
+ // only rebuilds the ROWS (swatch/bar/value/highlight) — cheap,
2881
+ // and the only part that can actually change post-load (marks,
2882
+ // or an AGGREGATE theme's dataset-wide totals, which this port
2883
+ // doesn't yet let change post-load anyway) — rather than
2884
+ // tearing down and rebuilding title/snippet/description/slider
2885
+ // too, which never change. Same visible result, less DOM churn.
2886
+ // - Real "isolate_gray" mode dims non-marked SVG paths via a CSS
2887
+ // class; this engine dims via IconLayer's own getColor alpha
2888
+ // (see LayerRuntime#_iconAlpha) since these are deck.gl raster
2889
+ // icons, not DOM/SVG nodes a CSS rule could reach.
2890
+ // Legend on/off and fold state as real ixmaps-flat reads the map
2891
+ // option (htmlgui_flat.js:748-794): on whenever `legend` is given and
2892
+ // isn't false/"false" ("true", "open", "", 1, …), folded for
2893
+ // "closed", off when not given at all.
2894
+ const legendOpt = builder.mapOptions.legend;
2895
+ // let, not const: map.setLegend(value) switches them at runtime (see setLegendOption)
2896
+ let legendOn = legendIsOn(legendOpt);
2897
+ let legendFolded = legendOpt === 'closed';
2898
+ {
2899
+ // Map-level `align` option positions the legend panel — a map-
2900
+ // wide placement choice, not per-theme (unlike legendtheme/
2901
+ // legendfilter above), so read once from builder.mapOptions rather
2902
+ // than per-runtime. Real ixmaps-flat ALSO has an `align` map
2903
+ // option (ui/js/htmlgui_flat.js) controlling legend position, but
2904
+ // its value shape is a free-form regex-matched left/right/center/
2905
+ // top/bottom/pixel-prefixed string tied to a static in-page DOM
2906
+ // panel — a different layout model from this panel's own
2907
+ // position:absolute floating-overlay-over-the-map-canvas
2908
+ // approach. This port reuses the real OPTION NAME but adapts the
2909
+ // VALUE SHAPE to plain four-corner tokens matching how this panel
2910
+ // is actually positioned — a deliberate divergence, same
2911
+ // reasoning already applied to style.legendfilter's own name/
2912
+ // shape choice earlier this session. Default "top-right" per
2913
+ // explicit request (real engine's own fallback is right-anchored
2914
+ // too, just without an opinion on vertical placement).
2915
+ const ALIGN_CSS = {
2916
+ 'top-left': 'left:10px;top:10px;',
2917
+ 'top-right': 'right:10px;top:10px;',
2918
+ 'bottom-left': 'left:10px;bottom:10px;',
2919
+ 'bottom-right': 'right:10px;bottom:10px;'
2920
+ };
2921
+ // gl's four corners, else flat's own values (htmlgui_flat.js
2922
+ // 421-470): "…left" → left (the part before "left", else 55px), 12px
2923
+ // from the top; "…right" → right (else 25px); "center" / "top" → top
2924
+ // center
2925
+ const flatAlignCss = align => {
2926
+ const a = String(align || '');
2927
+ if (/left/.test(a)) return 'left:' + (a.split('left')[0] || '55px') + ';top:12px;';
2928
+ if (/right/.test(a)) return 'right:' + (a.split('right')[0] || '25px') + ';top:10px;';
2929
+ if (a === 'center' || a === 'top') return 'left:50%;transform:translateX(-50%);top:10px;';
2930
+ return null;
2931
+ };
2932
+ const legendAlignCss = ALIGN_CSS[builder.mapOptions.align] || flatAlignCss(builder.mapOptions.align) || ALIGN_CSS['top-right'];
2933
+ // Real ixmaps-flat's SUB-THEME (MapTheme.createSubTheme,
2934
+ // maptheme.js 14284-14292 / 15010-15160; map.Themes.enableSubThemes
2935
+ // defaults to true): marking fields of a multi-field choropleth
2936
+ // (DOMINANT/COMPOSECOLOR, not CHART) paints a new theme over the
2937
+ // original — one field: CHOROPLETH|LOG|DOPACITY on that field, white
2938
+ // → the field's color in 7 steps, dopacityscale 2, dopacitypow 1,
2939
+ // keeping DOPACITY(→DOPACITYMAX)/AGGREGATE/GROUP/SUM/ZEROISVALUE/
2940
+ // VALUES; several fields: the original type on just those fields
2941
+ // and colors. It reuses the original's joined polygons, has no
2942
+ // legend and stays out of the theme registry (its name
2943
+ // "<layer>::subtheme" also keeps its deck.gl layer id apart), so
2944
+ // markThemeClass/removeTheme still address the original.
2945
+ const subThemeCapable = rt => rt.flags.has('CHOROPLETH') && !rt.flags.has('CHART') && (rt.flags.has('DOMINANT') || rt.flags.has('COMPOSECOLOR'));
2946
+ const toHex = c => Array.isArray(c) ? '#' + c.slice(0, 3).map(v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('') : c;
2947
+ updateSubTheme = (rt) => {
2948
+ if (rt._subTheme) {
2949
+ const i = runtimes.indexOf(rt._subTheme);
2950
+ if (i >= 0) runtimes.splice(i, 1);
2951
+ rt._subTheme = null;
2952
+ }
2953
+ const fields = String(rt.binding.value || '').split('|');
2954
+ const marks = [...rt._markedClasses].filter(i => i >= 0 && i < fields.length).sort((a, b) => a - b);
2955
+ if (marks.length) {
2956
+ const colors = (rt.categoryColorsRgb || []).map(toHex);
2957
+ const labels = rt.categoryDisplayLabels || rt.categoryLabels || fields;
2958
+ const kept = ['DOPACITY', 'AGGREGATE', 'GROUP', 'SUM', 'ZEROISVALUE', 'VALUES']
2959
+ .filter(f => [...rt.flags].some(x => x.startsWith(f) && (f !== 'DOPACITY' || /^DOPACITY/.test(x))))
2960
+ .map(f => (f === 'DOPACITY' ? 'DOPACITYMAX' : f));
2961
+ let def;
2962
+ if (marks.length > 1) {
2963
+ def = {
2964
+ layer: rt.name + '::subtheme',
2965
+ binding: (b => { delete b.field100; delete b.value100; return b; })(Object.assign({}, rt.binding, { value: marks.map(i => fields[i]).join('|') })),
2966
+ style: Object.assign({}, rt.style, { type: [...rt.flags].concat(['SUBTHEME', 'NOLEGEND']).join('|'),
2967
+ colorscheme: marks.map(i => colors[i]), values: marks.map(i => labels[i]) }),
2968
+ meta: { title: 'actual selection' }
2969
+ };
2970
+ } else {
2971
+ const i = marks[0];
2972
+ const binding = Object.assign({}, rt.binding, { value: fields[i] });
2973
+ delete binding.alpha; delete binding.alpha100;
2974
+ // rt.features already carry the field100 result — drop it and
2975
+ // every alias of it, or normalizeTheme would apply it again
2976
+ delete binding.field100; delete binding.value100;
2977
+ def = {
2978
+ layer: rt.name + '::subtheme',
2979
+ binding,
2980
+ style: { type: ['CHOROPLETH', 'LOG', 'DOPACITY', 'NOLEGEND', 'SUBTHEME'].concat(kept).join('|'),
2981
+ colorscheme: ['7', 'white', colors[i]], dopacityscale: 2, dopacitypow: 1,
2982
+ units: rt.style.units, linecolor: rt.style.linecolor, linewidth: rt.style.linewidth },
2983
+ meta: { title: 'actual selection' }
2984
+ };
2985
+ }
2986
+ const sub = new LayerRuntime(normalizeTheme(def), { type: 'FeatureCollection', features: rt.features }, builder._engineOptions);
2987
+ sub._isSubTheme = true;
2988
+ runtimes.splice(runtimes.indexOf(rt) + 1, 0, sub);
2989
+ rt._subTheme = sub;
2990
+ }
2991
+ refresh();
2992
+ notifyRedraw();
2993
+ };
2994
+ // the stack of the theme panels — flat shows one theme's legend
2995
+ // after the other in #map-legend, separated by a line, and leaves
2996
+ // out a theme hidden, out of scale or not yet drawn
2997
+ let legendStack = null;
2998
+ const legendStackEl = shadow => {
2999
+ if (legendStack && legendStack.parentNode) return legendStack;
3000
+ legendStack = document.createElement('div');
3001
+ legendStack.className = 'ix-legend-stack';
3002
+ legendStack.style.cssText = 'position:absolute;' + legendAlignCss + 'z-index:6;width:280px;'
3003
+ + 'display:flex;flex-direction:column;max-height:calc(100% - 24px);overflow-y:auto;'
3004
+ + 'border-radius:6px;box-shadow:' + shadow + ';pointer-events:auto;';
3005
+ if (!document.getElementById('ix-legend-stack-css')) {
3006
+ const css = document.createElement('style');
3007
+ css.id = 'ix-legend-stack-css';
3008
+ css.textContent = '.ix-legend-stack>.ix-native-legend:not([data-ixoff])~.ix-native-legend:not([data-ixoff]){border-top:1px solid rgba(128,128,128,.35)}';
3009
+ document.head.appendChild(css);
3010
+ }
3011
+ el.parentElement.appendChild(legendStack);
3012
+ return legendStack;
3013
+ };
3014
+ // map.setLegend(value): the `legend` map option at runtime, same values as
3015
+ // ixmaps.Map(id, {legend}) — on ("true", "open", …), folded ("closed"), off
3016
+ // (false/"false"/0/undefined). Panels are made for themes that lack one,
3017
+ // removed when off, folded/unfolded otherwise.
3018
+ setLegendOption = (opt) => {
3019
+ builder.mapOptions.legend = opt;
3020
+ legendFolded = opt === 'closed';
3021
+ legendOn = legendIsOn(opt);
3022
+ runtimes.forEach(rt => {
3023
+ if (!legendOn) {
3024
+ if (rt._legendPanel && rt._legendPanel.parentNode) rt._legendPanel.parentNode.removeChild(rt._legendPanel);
3025
+ rt._legendPanel = null;
3026
+ } else if (!rt._legendPanel) addLegendPanel(rt);
3027
+ else if (rt._setLegendCollapsed) rt._setLegendCollapsed(legendFolded);
3028
+ });
3029
+ layoutLegends();
3030
+ };
3031
+ layoutLegends = () => {
3032
+ if (!legendStack) return;
3033
+ const z = map.getZoom();
3034
+ let shown = 0;
3035
+ [...legendStack.children].forEach(panel => {
3036
+ const rt = panel._ixRuntime;
3037
+ const off = !rt || rt._hidden || themeHiddenByScale(rt.flags, rt.style, z);
3038
+ if (off) { panel.setAttribute('data-ixoff', ''); panel.style.display = 'none'; }
3039
+ else { panel.removeAttribute('data-ixoff'); panel.style.display = 'flex'; shown++; }
3040
+ });
3041
+ legendStack.style.display = shown ? 'flex' : 'none';
3042
+ };
3043
+ const legendApplies = rt => rt.categoryLabels && rt.categoryLabels.length && !rt.flags.has('FEATURE') && !rt.flags.has('FEATURES') && !rt.flags.has('NOLEGEND');
3044
+ // One theme's panel — at build time for every theme, and again for a
3045
+ // theme defined later (map.layer(...) in a myMap.then(...) chain,
3046
+ // defineLayer, loadProject); removeTheme removes it with the theme.
3047
+ addLegendPanel = (rt) => {
3048
+ // marking a class (legend row, or ixmaps.markThemeClass from a
3049
+ // page) redraws — for every theme, legend panel or not
3050
+ rt._triggerRedraw = () => { refresh(); };
3051
+ if (subThemeCapable(rt)) rt._onMarksChanged = () => updateSubTheme(rt);
3052
+ if (!legendOn || !el.parentElement || !legendApplies(rt)) return;
3053
+ el.parentElement.style.position = el.parentElement.style.position || 'relative';
3054
+ {
3055
+ // .type("...|NOLEGEND") — the fourth real legend-related type()
3056
+ // token: opts a theme OUT of the legend entirely (real engine's
3057
+ // own per-layer "skip this one" flag, distinct from the map-
3058
+ // level legend:"open"/"closed" option gating the whole panel).
3059
+ // Same free-parsing story as SIMPLELEGEND/COMPACTLEGEND above —
3060
+ // just one more flag to exclude on, no new plumbing.
3061
+ const panel = document.createElement('div');
3062
+ rt._legendPanel = panel;
3063
+ panel.className = 'ix-native-legend';
3064
+ // Opt-in via .style({legendtheme:"light"}) — a NEW, ixmaps-gl-
3065
+ // only convention (same naming pattern as the sibling
3066
+ // legendfilter/legendunits keys added earlier this session):
3067
+ // real ixmaps-flat's legend.js has no such switch, this panel
3068
+ // is entirely this port's own construction. Default ("dark")
3069
+ // preserves the original always-dark panel exactly; "light" is
3070
+ // for pages using a light basemap (e.g. CARTO Positron) where
3071
+ // a dark semi-transparent panel reads as a mismatched dark
3072
+ // patch rather than legend chrome. Every other hardcoded panel
3073
+ // color below (rows, bar track, filter <select>) is expressed
3074
+ // in terms of this one palette so the two variants stay in
3075
+ // sync — no separate light/dark branch anywhere else.
3076
+ // flat's look (flatLegendLook) unless the page picks one with
3077
+ // this engine's own legendtheme
3078
+ const opacityOpt = parseFloat(builder._engineOptions.basemapopacity);
3079
+ const flatLook = flatLegendLook(builder.mapOptions.mapType, isNaN(opacityOpt) ? 1 : opacityOpt, builder.mapOptions.legendBackground || builder.mapOptions.legendbackground);
3080
+ const isLightLegend = rt.style.legendtheme === 'light' || (rt.style.legendtheme !== 'dark' && !flatLook.dark);
3081
+ const legendColors = isLightLegend
3082
+ ? { bg: 'rgba(255,255,255,0.92)', fg: '#1a1a1a', shadow: '0 2px 10px rgba(0,0,0,0.18)',
3083
+ rowMarked: 'rgba(0,0,0,0.08)', track: 'rgba(0,0,0,0.08)',
3084
+ selectBg: 'rgba(0,0,0,0.04)', selectBorder: 'rgba(0,0,0,0.2)' }
3085
+ : { bg: 'rgba(28,30,34,0.92)', fg: '#eee', shadow: '0 2px 10px rgba(0,0,0,0.45)',
3086
+ rowMarked: 'rgba(255,255,255,0.14)', track: 'rgba(255,255,255,0.08)',
3087
+ selectBg: 'rgba(255,255,255,0.08)', selectBorder: 'rgba(255,255,255,0.25)' };
3088
+ if (!rt.style.legendtheme) legendColors.bg = flatLook.bg;
3089
+ // max-height:66% resolves against el.parentElement's own
3090
+ // height (the map container, which always has a definite
3091
+ // height for the map itself to render into) since this panel
3092
+ // is absolutely positioned inside it — a plain percentage on
3093
+ // an absolutely-positioned element's height IS legal CSS as
3094
+ // long as its containing block has a definite height, which
3095
+ // this one does. display:flex column + min-height:0 on the
3096
+ // ROWS wrapper below (not this outer panel) is what makes
3097
+ // only the row list scroll while the header/description/
3098
+ // slider stay fixed in place — a flex child won't actually
3099
+ // shrink to fit and scroll internally without min-height:0,
3100
+ // it just overflows its flex parent instead.
3101
+ // one box for every theme, as flat's #map-legend (legend.js
3102
+ // 2200-2460): the panels stack in the legend stack in theme
3103
+ // order, a line between them (see layoutLegends)
3104
+ panel.style.cssText = 'position:relative;flex:0 0 auto;'
3105
+ + 'display:flex;flex-direction:column;'
3106
+ + 'background:' + legendColors.bg + ';color:' + legendColors.fg + ';font:12px/1.4 -apple-system,Arial,sans-serif;'
3107
+ + 'padding:10px 12px 12px;pointer-events:auto;';
3108
+ const stack = legendStackEl(legendColors.shadow);
3109
+ const at = runtimes.indexOf(rt);
3110
+ const next = [...stack.children].find(c => runtimes.indexOf(c._ixRuntime) > at);
3111
+ panel._ixRuntime = rt;
3112
+ stack.insertBefore(panel, next || null);
3113
+
3114
+ // SUM+valuefield mirrors the real engine's own "SUM style
3115
+ // aggregation" reading (style.valuefield, falling back to
3116
+ // the bound size field); anything else (plain CATEGORICAL,
3117
+ // no SUM) falls back to a per-category record COUNT.
3118
+ const useSum = flatFlag(rt.flags, 'SUM') && rt.style.valuefield;
3119
+ const valueField = rt.style.valuefield || rt.binding.size;
3120
+ // legendunits wins over the theme's general-purpose units
3121
+ // (used elsewhere for tooltips, e.g. _renderItemChartHtml) —
3122
+ // a page may want a different/no unit string specifically on
3123
+ // the legend's own value column. Appended as-is (no extra
3124
+ // space injected), matching how style.units/legendunits are
3125
+ // themselves authored with their own leading space (e.g.
3126
+ // " MW") in real pages.
3127
+ const legendUnit = rt.style.legendunits || rt.style.units || '';
3128
+ // row labels go into innerHTML: the page's own .style({label})
3129
+ // stays HTML, as in flat; categories read from the data are
3130
+ // escaped (a value could carry markup)
3131
+ const labels = legendRowLabels(rt);
3132
+ // .type("...|TEXTLEGEND") — the FIFTH real legend-related
3133
+ // type() token: title/snippet/description text only, no
3134
+ // category rows at all (swatches, bars, chips — none of it),
3135
+ // per explicit correction. Everything below that builds the
3136
+ // rows/totals/onRedraw-recompute wiring is skipped outright
3137
+ // rather than built-then-hidden — there is nothing for any of
3138
+ // it to feed once no rows ever render. The country-filter
3139
+ // dropdown and Chart-size slider stay: neither is a
3140
+ // "categorical item" or "colorscheme swatch", both are
3141
+ // independent controls unrelated to the row list.
3142
+ const isTextOnly = rt.flags.has('TEXTLEGEND');
3143
+
3144
+ // Map-view-aware per explicit request: totals reflect only
3145
+ // what's CURRENTLY on screen (the map's own bounds, plus the
3146
+ // same far-hemisphere exclusion _buildChartLayers itself
3147
+ // applies under globe projection — see isOnVisibleHemisphere)
3148
+ // rather than the whole dataset — recomputed on every redraw
3149
+ // (engineApi.onRedraw, below) so panning/zooming/rotating
3150
+ // updates the bars and values live, the same way the actual
3151
+ // rendered bubbles change. Same simple rectangular bbox
3152
+ // membership test _computeAggregatedItems's own non-AGGREGATE
3153
+ // branch uses (no antimeridian wraparound handling — matches
3154
+ // that existing convention, not a new gap this introduces).
3155
+ let totals, maxTotal, order, rowsScroll;
3156
+ // No-op default: TEXTLEGEND never reassigns this (see below),
3157
+ // so the LATER unconditional-looking `renderRows()` call
3158
+ // safely does nothing rather than needing its own isTextOnly
3159
+ // guard at every call site.
3160
+ let renderRows = () => {};
3161
+ if (!isTextOnly) {
3162
+ function computeTotals() {
3163
+ const bounds = map.getBounds();
3164
+ const bbox = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()];
3165
+ const proj = (typeof map.getProjection === 'function' && map.getProjection()) || { type: 'mercator' };
3166
+ const globeCenter = proj.type === 'globe' ? map.getCenter() : null;
3167
+ totals = new Array(rt.categoryLabels.length).fill(0);
3168
+ if (rt._rangeClassed) { totals = rangeClassLegendTotals(rt, bbox, globeCenter); maxTotal = Math.max(0, ...totals); order = totals.map((v, i) => i).sort((a, b) => totals[b] - totals[a]); return; }
3169
+ // _activeFeatures (set by setFacetFilter/clearFacetFilter,
3170
+ // e.g. the country-select dropdown below) is the facet-
3171
+ // filtered subset when a filter is active, null otherwise —
3172
+ // reading it here keeps the legend's own numbers consistent
3173
+ // with whatever the country filter is currently narrowing
3174
+ // the map down to, not just with the viewport/hemisphere.
3175
+ (rt._activeFeatures || rt.features).forEach(f => {
3176
+ const idx = rt.categoryIndexByLabel ? rt.categoryIndexByLabel.get(f.properties[rt.binding.value]) : null;
3177
+ if (idx == null) return;
3178
+ const [lng, lat] = f.geometry.coordinates;
3179
+ if (lng < bbox[0] || lng > bbox[2] || lat < bbox[1] || lat > bbox[3]) return;
3180
+ if (globeCenter && !isOnVisibleHemisphere(lng, lat, globeCenter)) return;
3181
+ totals[idx] += useSum ? (parseFloat(f.properties[valueField]) || 0) : 1;
3182
+ });
3183
+ maxTotal = Math.max(0, ...totals);
3184
+ order = totals.map((v, i) => i).sort((a, b) => totals[b] - totals[a]);
3185
+ }
3186
+ computeTotals();
3187
+
3188
+ // Only THIS wrapper scrolls (flex:1 1 auto + min-height:0 lets
3189
+ // it shrink to whatever's left of the panel's own 66%-height
3190
+ // cap once the fixed header/description/slider take their
3191
+ // share, then overflow-y:auto scrolls just the row list) —
3192
+ // the header above and description/slider below stay put.
3193
+ rowsScroll = document.createElement('div');
3194
+ rowsScroll.style.cssText = 'flex:1 1 auto;min-height:0;overflow-y:auto;';
3195
+ const rowsEl = document.createElement('div');
3196
+ rowsEl.className = 'ix-legend-rows';
3197
+ // .type("...|SIMPLELEGEND") — real ixmaps-flat's OTHER legend
3198
+ // variant (confirmed against a real-engine screenshot, per
3199
+ // explicit correction): swatch + label chips only, no bar, no
3200
+ // value column. SIMPLELEGEND is just another pipe-delimited
3201
+ // type() token — MapBuilder#type() already puts every token
3202
+ // into this.flags regardless of whether anything reads it, so
3203
+ // no new parsing was needed, only this render-mode branch.
3204
+ // flex-wrap here (vs. the bar mode's block rows) — chips wrap
3205
+ // to the panel's own width the same loose way the real
3206
+ // engine's screenshot shows (an uneven number of chips per
3207
+ // row, driven by each label's own text length, not a fixed
3208
+ // column grid).
3209
+ // .type("...|COMPACTLEGEND") — the THIRD real legend variant
3210
+ // (again confirmed against a real-engine screenshot, per
3211
+ // explicit correction): the bar-mode's own two-line label/bar/
3212
+ // value row, but wrapped into a multi-column flex-wrap grid
3213
+ // instead of one full-width row per category — each item's
3214
+ // own width follows its content (a bigger value's longer bar
3215
+ // makes its own item wider, so fewer fit per line; Coal alone
3216
+ // filled its own row in the reference screenshot while the
3217
+ // smaller categories packed 3-4 per row) rather than a fixed
3218
+ // column count.
3219
+ if (rt.flags.has('SIMPLELEGEND') || rt.flags.has('COMPACTLEGEND')) {
3220
+ rowsEl.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;';
3221
+ }
3222
+ rowsScroll.appendChild(rowsEl);
3223
+
3224
+ // Two lines per row — label on its own full-width line, then
3225
+ // swatch+bar+value below — per explicit correction: a single
3226
+ // shared line left too little room for the bar to read as
3227
+ // proportional (it was squeezed between the label text and a
3228
+ // fixed-width value column). The bar itself lives inside a
3229
+ // flex:1 "track" div sized to whatever's left after the fixed
3230
+ // swatch/value columns, and is a PERCENTAGE of that track's
3231
+ // own width (not a pixel value) — real formula's per-unit-
3232
+ // factor/cap (legend.js:980-984/946-950) collapses to a plain
3233
+ // linear percentage-of-max once the bar's available width
3234
+ // isn't a fixed constant this code has to guess at.
3235
+ const isSimple = rt.flags.has('SIMPLELEGEND');
3236
+ const isCompact = rt.flags.has('COMPACTLEGEND');
3237
+ // Compact mode's bar can't use the full-width mode's
3238
+ // percentage-of-flex-track trick (there's no full-width track
3239
+ // to be a percentage OF once the row itself is content-sized,
3240
+ // not stretched) — a plain pixel width, proportional to value
3241
+ // and capped, same shape as the full-width formula just
3242
+ // expressed in absolute px instead of a % of an elastic track.
3243
+ const COMPACT_MAX_BAR_PX = 70;
3244
+ // flat's compact legend (legend.js makeColorLegendHTML, the
3245
+ // "compact" mode the theme legends use, 1212-1257): a range
3246
+ // theme of 5 classes or more without labels is one line of
3247
+ // color patches with the value range below; DOPACITY with an
3248
+ // alpha field adds two paler lines (opacity 1/2, 1/3) and the
3249
+ // alpha max, "↓" and min to their right (1062-1205). The
3250
+ // legend's own __formatValue (legend.js 451-553) has no SPACE
3251
+ // form: SPACE there is flat's plain "." thousands.
3252
+ const flatCompactLegend = rt._rangeClassed && !flatFlag(rt.flags, 'CATEGORICAL') && !flatFlag(rt.flags, 'PLOT')
3253
+ && (rt.partsA || []).length >= 5 && !(Array.isArray(rt.style.label) && rt.style.label.length);
3254
+ // flat's single-color legend (legend.js 765-769, 1039-1046): a range
3255
+ // theme of at most 2 classes without label/ranges, not
3256
+ // CATEGORICAL, is one row — the swatch and "min ... max unit",
3257
+ // or, when every value is 1 (a "$item$" count), its label or
3258
+ // value field
3259
+ const flatSingleRowLegend = rt._rangeClassed && !flatFlag(rt.flags, 'CATEGORICAL') && !flatFlag(rt.flags, 'PLOT')
3260
+ && (rt.partsA || []).length <= 2 && !(Array.isArray(rt.style.label) && rt.style.label.length)
3261
+ && !(Array.isArray(rt.style.ranges) && rt.style.ranges.length);
3262
+ if (flatSingleRowLegend) {
3263
+ renderRows = function() {
3264
+ const rgb = rt.categoryColorsRgb[0] || [128, 128, 128];
3265
+ const unit = String(rt.style.legendunits || rt.style.units || '').replace(/ /g, '&nbsp;');
3266
+ const vMin = styleNum(rt.style.minvalue) || rt._valueMin, vMax = styleNum(rt.style.maxvalue) || rt._valueMax;
3267
+ const label = rt.style.label != null && !Array.isArray(rt.style.label) ? String(rt.style.label) : String(rt.binding.value || '');
3268
+ const text = vMin !== 1 || vMax !== 1
3269
+ ? flatFormatValue(vMin, 2, 'BLANK') + ' &nbsp;... ' + flatFormatValue(vMax, 2, 'BLANK') + ' ' + unit
3270
+ : label;
3271
+ const marked = rt._markedClasses.has(0);
3272
+ rowsEl.innerHTML = '<div class="ix-legend-row" data-idx="0" style="display:flex;align-items:center;gap:5px;padding:4px 3px;cursor:pointer;border-radius:4px;'
3273
+ + 'background:' + (marked ? legendColors.rowMarked : 'transparent') + ';">'
3274
+ + '<span style="flex:0 0 auto;width:1.6em;height:0.8em;background:rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ');"></span>'
3275
+ + '<span>' + text + '</span></div>';
3276
+ rowsEl.querySelectorAll('.ix-legend-row').forEach(row => {
3277
+ row.addEventListener('click', () => {
3278
+ if (rt._markedClasses.has(0)) unmarkRuntimeClass(rt, 0);
3279
+ else markRuntimeClass(rt, 0);
3280
+ });
3281
+ });
3282
+ };
3283
+ } else
3284
+ if (flatCompactLegend) {
3285
+ renderRows = function() {
3286
+ const n = rt.categoryColorsRgb.length;
3287
+ const withAlpha = flatFlag(rt.flags, 'DOPACITY') && rt.binding.alpha && rt._alphaMax;
3288
+ const nLines = withAlpha ? 3 : 1;
3289
+ const dec = rt.style.decimals != null ? parseInt(rt.style.decimals, 10) : 2;
3290
+ const unit = rt.style.legendunits || rt.style.units || '';
3291
+ const ranges = Array.isArray(rt.style.ranges) && rt.style.ranges.length ? rt.style.ranges.map(Number) : null;
3292
+ const vMin = ranges ? ranges[0] : rt._valueMin, vMax = ranges ? ranges[ranges.length - 1] : rt._valueMax;
3293
+ let html = '<div style="display:grid;grid-template-columns:repeat(' + n + ',1fr)' + (withAlpha ? ' auto' : '') + ';gap:1px;align-items:center;">';
3294
+ for (let line = 0; line < nLines; line++) {
3295
+ for (let c = 0; c < n; c++) {
3296
+ const ix = flatFlag(rt.flags, 'INVERT') ? n - c - 1 : c;
3297
+ const rgb = rt.categoryColorsRgb[ix];
3298
+ const part = rt.partsA[ix];
3299
+ const title = part ? flatFormatValue(part.min, dec, '') + unit + ' ... ' + flatFormatValue(part.max, dec, '') + unit : '';
3300
+ const marked = rt._markedClasses.has(ix);
3301
+ html += '<span class="ix-legend-row" data-idx="' + ix + '" title="' + title + '" style="cursor:pointer;height:14px;'
3302
+ + 'background:rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ');opacity:' + (1 / (line + 1)) + ';'
3303
+ + (marked ? 'outline:2px solid ' + legendColors.fg + ';outline-offset:-2px;' : '') + '"></span>';
3304
+ }
3305
+ if (withAlpha) {
3306
+ const txt = line === 0 ? flatFormatValue(rt._alphaMax, 0, 'BLANK')
3307
+ : line === 1 ? '&#8595; ' + (rt.style.alphavalueunits || '') : flatFormatValue(rt._alphaMin || 0, 0, 'BLANK');
3308
+ html += '<span style="padding-left:0.5em;white-space:nowrap;">' + txt + '</span>';
3309
+ }
3310
+ }
3311
+ html += '</div><div style="display:flex;justify-content:space-between;margin-top:3px;' + (withAlpha ? 'margin-right:4em;' : '') + '">'
3312
+ + '<span>' + flatFormatValue(vMin, dec, '') + (unit ? ' ' + unit : '') + '</span>'
3313
+ + '<span>' + flatFormatValue(vMax, dec, '') + (unit && unit.length <= 3 ? ' ' + unit : '') + '</span></div>';
3314
+ rowsEl.innerHTML = html;
3315
+ rowsEl.querySelectorAll('.ix-legend-row').forEach(cell => {
3316
+ cell.addEventListener('click', () => {
3317
+ const idx = parseInt(cell.dataset.idx, 10);
3318
+ if (rt._markedClasses.has(idx)) unmarkRuntimeClass(rt, idx);
3319
+ else markRuntimeClass(rt, idx);
3320
+ });
3321
+ });
3322
+ };
3323
+ } else
3324
+ renderRows = function() {
3325
+ // flat drops the rows without a count once any row has one
3326
+ // (legend.js 754-760, 787-789: fCountBars && !count)
3327
+ rowsEl.innerHTML = order.filter(i => !(maxTotal > 0) || totals[i]).map(i => {
3328
+ const rgb = rt.categoryColorsRgb[i];
3329
+ const color = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
3330
+ const marked = rt._markedClasses.has(i);
3331
+ const dimmed = rt._markedClasses.size > 0 && !marked;
3332
+ const rowStyle = 'padding:4px 3px;border-radius:4px;cursor:pointer;opacity:' + (dimmed ? 0.4 : 1) + ';'
3333
+ + 'background:' + (marked ? legendColors.rowMarked : 'transparent') + ';';
3334
+ if (isSimple) {
3335
+ // Swatch + label only, no bar/value — see rowsEl's own
3336
+ // comment above for why (SIMPLELEGEND).
3337
+ return '<div class="ix-legend-row" data-idx="' + i + '" style="' + rowStyle
3338
+ + 'display:inline-flex;align-items:center;gap:5px;padding:4px 8px;">'
3339
+ + '<span style="flex:0 0 8px;width:8px;height:8px;border-radius:50%;background:' + color + ';"></span>'
3340
+ + '<span>' + labels[i] + '</span>'
3341
+ + '</div>';
3342
+ }
3343
+ const pct = maxTotal ? Math.max(0, Math.min(100, (totals[i] / maxTotal) * 100)) : 0;
3344
+ if (isCompact) {
3345
+ // Same two-line label/bar/value shape as full-width bar
3346
+ // mode below, just content-sized (flex:0 0 auto, no
3347
+ // min-width:0 flex-1 track) so multiple items pack onto
3348
+ // one line inside rowsEl's own flex-wrap — see
3349
+ // COMPACTLEGEND's own comment on rowsEl above.
3350
+ const barPx = Math.max(2, (pct / 100) * COMPACT_MAX_BAR_PX);
3351
+ return '<div class="ix-legend-row" data-idx="' + i + '" style="' + rowStyle + 'flex:0 0 auto;">'
3352
+ + '<div style="margin-bottom:3px;white-space:nowrap;">' + labels[i] + '</div>'
3353
+ + '<div style="display:flex;align-items:center;gap:6px;">'
3354
+ + '<span style="flex:0 0 8px;width:8px;height:8px;border-radius:50%;background:' + color + ';"></span>'
3355
+ + '<span style="flex:0 0 auto;width:' + barPx + 'px;height:6px;background:' + color + ';border-radius:3px;"></span>'
3356
+ + '<span style="flex:0 0 auto;white-space:nowrap;">' + rt._formatTooltipValue(totals[i]) + legendUnit + '</span>'
3357
+ + '</div>'
3358
+ + '</div>';
3359
+ }
3360
+ return '<div class="ix-legend-row" data-idx="' + i + '" style="' + rowStyle + '">'
3361
+ + '<div style="margin-bottom:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + labels[i] + '</div>'
3362
+ + '<div style="display:flex;align-items:center;gap:6px;">'
3363
+ + '<span style="flex:0 0 8px;width:8px;height:8px;border-radius:50%;background:' + color + ';"></span>'
3364
+ + '<span style="flex:1 1 auto;min-width:0;height:6px;background:' + legendColors.track + ';border-radius:3px;overflow:hidden;">'
3365
+ + '<span style="display:block;height:100%;width:' + pct + '%;background:' + color + ';border-radius:3px;"></span>'
3366
+ + '</span>'
3367
+ + '<span style="flex:0 0 auto;text-align:right;min-width:60px;">' + rt._formatTooltipValue(totals[i]) + legendUnit + '</span>'
3368
+ + '</div>'
3369
+ + '</div>';
3370
+ }).join('');
3371
+ // GL-PORT COMPAT: real ixmaps-flat's row markup wires its
3372
+ // click handler inline (onclick="ixmaps.markThemeClass(...)"
3373
+ // — legend.js:817), toggle decided by the caller. Same
3374
+ // split here: the toggle check lives in this click
3375
+ // handler, the actual add/remove-and-redraw primitive is
3376
+ // markRuntimeClass/unmarkRuntimeClass, which the globals
3377
+ // ixmaps.markThemeClass/unmarkThemeClass also use — so a
3378
+ // page's OWN custom UI drives this exact legend too. The
3379
+ // rows pass their runtime itself: its .layer() name may be
3380
+ // shared with other themes of the page.
3381
+ rowsEl.querySelectorAll('.ix-legend-row').forEach(rowEl => {
3382
+ rowEl.addEventListener('click', () => {
3383
+ const idx = parseInt(rowEl.dataset.idx, 10);
3384
+ if (rt._markedClasses.has(idx)) unmarkRuntimeClass(rt, idx);
3385
+ else markRuntimeClass(rt, idx);
3386
+ });
3387
+ });
3388
+ };
3389
+
3390
+ // Recompute+re-render on EVERY redraw, not just mark toggles —
3391
+ // this is what makes the legend map-view-aware: a plain pan/
3392
+ // zoom/rotate fires this too (via the map's own 'move'
3393
+ // listener -> scheduleRefresh -> notifyRedraw, debounced the
3394
+ // same 400ms as the facets sidebar's own onRedraw use), same
3395
+ // signal already used elsewhere in this file for "the visible
3396
+ // data just changed, recompute".
3397
+ engineApi.onRedraw(() => { computeTotals(); renderRows(); });
3398
+ }
3399
+
3400
+ // rt._triggerRedraw: see its own doc comment on LayerRuntime
3401
+ // (set here rather than at construction time since refresh()
3402
+ // doesn't exist yet when the runtime is built). Kept
3403
+ // unconditional (even for TEXTLEGEND, which never registers
3404
+ // the onRedraw recompute above) — a page can still call
3405
+ // ixmaps.markThemeClass/unmarkThemeClass directly with no
3406
+ // visual row list to click, and that should still redraw the
3407
+ // MAP's own dim/isolate effect even without a legend UI for
3408
+ // it, matching real-engine global-API availability regardless
3409
+ // of legend style.
3410
+ rt._triggerRedraw = () => { refresh(); };
3411
+
3412
+ // Header row (title/snippet + collapse toggle) always stays
3413
+ // visible; everything else lives in `bodyEl`, hidden/shown as
3414
+ // a single unit by the toggle below — collapsing shows just
3415
+ // the title bar, matching the real engine's own fold/unfold
3416
+ // behavior (ixmaps.legendState + the "legend-folded" CSS
3417
+ // class, ui/js/tools/legend.js) even though this port toggles
3418
+ // via a plain display swap on one wrapper div rather than a
3419
+ // shared stylesheet class (this panel has no external CSS at
3420
+ // all — everything here is inline-styled, unlike the real
3421
+ // engine's DOM/CSS-class-driven legend).
3422
+ const header = document.createElement('div');
3423
+ header.style.cssText = 'display:flex;align-items:flex-start;justify-content:space-between;gap:6px;';
3424
+ const headerText = document.createElement('div');
3425
+ headerText.style.cssText = 'min-width:0;flex:1 1 auto;';
3426
+ let headerHtml = '';
3427
+ if (rt.meta.title) headerHtml += '<div style="font-weight:600;font-size:13px;margin-bottom:2px;">' + rt.meta.title + '</div>';
3428
+ if (rt.meta.snippet) headerHtml += '<div style="opacity:0.75;">' + rt.meta.snippet + '</div>';
3429
+ headerText.innerHTML = headerHtml;
3430
+ header.appendChild(headerText);
3431
+ const collapseBtn = document.createElement('button');
3432
+ collapseBtn.type = 'button';
3433
+ collapseBtn.style.cssText = 'flex:0 0 auto;background:transparent;border:none;color:inherit;'
3434
+ + 'font-size:14px;line-height:1;cursor:pointer;padding:2px 4px;opacity:0.7;';
3435
+ header.appendChild(collapseBtn);
3436
+ panel.appendChild(header);
3437
+
3438
+ const bodyEl = document.createElement('div');
3439
+ bodyEl.style.cssText = 'display:flex;flex-direction:column;min-height:0;flex:1 1 auto;margin-top:8px;';
3440
+ panel.appendChild(bodyEl);
3441
+
3442
+ // Collapsed by default under the real engine's own narrow-
3443
+ // screen legend threshold (ui/js/tools/legend.js: `if
3444
+ // (window.innerWidth < 500) ixmaps.legend.hide()`) — reusing
3445
+ // that concrete precedent rather than picking an arbitrary
3446
+ // "mobile" breakpoint of this port's own invention. Checked
3447
+ // once at panel-build time, not on resize — matching the real
3448
+ // engine's own one-shot-at-redraw-time check, not a live
3449
+ // matchMedia listener.
3450
+ const MOBILE_LEGEND_BREAKPOINT = 500;
3451
+ let collapsed = legendFolded || window.innerWidth < MOBILE_LEGEND_BREAKPOINT;
3452
+ function applyCollapsed() {
3453
+ bodyEl.style.display = collapsed ? 'none' : 'flex';
3454
+ collapseBtn.textContent = collapsed ? '▸' : '▾';
3455
+ collapseBtn.title = collapsed ? 'Expand legend' : 'Collapse legend';
3456
+ }
3457
+ collapseBtn.addEventListener('click', () => { collapsed = !collapsed; applyCollapsed(); });
3458
+ applyCollapsed();
3459
+ rt._setLegendCollapsed = c => { collapsed = !!c; applyCollapsed(); }; // setLegendOption
3460
+
3461
+ // Selection/filter by field — opt-in via .style({legendfilter:
3462
+ // "<field>"}), e.g. "country_long" on the power-plants sample.
3463
+ // A NEW, ixmaps-gl-only convention: real ixmaps-flat's own
3464
+ // style.filterfield names the DEFAULT field an unqualified
3465
+ // .filter("text") search term matches against (maptheme.js:
3466
+ // 1275-1276/7359-7362) — a different concept entirely, so this
3467
+ // deliberately uses its own name rather than overloading that
3468
+ // one. Options come from the FULL dataset (a world-spanning
3469
+ // bbox, not the current viewport) and stay fixed regardless of
3470
+ // pan/zoom — unlike the map-view-aware category totals below,
3471
+ // a picker whose own choices kept shrinking as you panned
3472
+ // would make it impossible to select a country not currently
3473
+ // in view. Selecting a value reuses the engine's own existing
3474
+ // facet-filter primitive (engineApi.setFacetFilter/
3475
+ // clearFacetFilter — already propagates to sibling runtimes
3476
+ // sharing the same data source and calls refresh()), so this
3477
+ // is UI wiring only, no new filtering logic.
3478
+ const filterField = rt.style.legendfilter;
3479
+ if (filterField) {
3480
+ const facet = engineApi.getFacets(rt.name, [filterField], { bbox: [-180, -85, 180, 85] })[0];
3481
+ const values = (facet && facet.type === 'textual')
3482
+ ? facet.values.slice().sort((a, b) => String(a).localeCompare(String(b)))
3483
+ : [];
3484
+ const filterEl = document.createElement('select');
3485
+ filterEl.style.cssText = 'width:100%;margin-bottom:8px;background:' + legendColors.selectBg + ';'
3486
+ + 'color:' + legendColors.fg + ';border:1px solid ' + legendColors.selectBorder + ';border-radius:4px;padding:4px 6px;font:inherit;';
3487
+ filterEl.innerHTML = '<option value="">All (' + values.length + ')</option>'
3488
+ + values.map(v => '<option value="' + escapeHtml(v) + '">' + escapeHtml(v) + '</option>').join('');
3489
+ filterEl.addEventListener('change', () => {
3490
+ if (filterEl.value) engineApi.setFacetFilter(rt.name, filterField, filterEl.value);
3491
+ else engineApi.clearFacetFilter(rt.name, filterField);
3492
+ });
3493
+ bodyEl.appendChild(filterEl);
3494
+ }
3495
+
3496
+ if (!isTextOnly) { bodyEl.appendChild(rowsScroll); renderRows(); }
3497
+
3498
+ if (rt.meta.description) {
3499
+ const desc = document.createElement('div');
3500
+ desc.style.cssText = 'margin-top:8px;font-size:11px;opacity:0.8;';
3501
+ // Raw HTML, matching the real engine's own description
3502
+ // rendering and this engine's existing .legend(html)/
3503
+ // tooltip conventions (e.g. _buildTooltipContext) — real
3504
+ // pages already embed their own source-citation markup
3505
+ // inside meta.description (see the power-plants sample).
3506
+ desc.innerHTML = rt.meta.description;
3507
+ bodyEl.appendChild(desc);
3508
+ }
3509
+
3510
+ // Chart-size slider — real engine's own range (legend.js:
3511
+ // 3196-3202), 100% == scale:1, wired to changeThemeStyle(
3512
+ // themeId,"scale:"+pct/100,"set")+redrawTheme there; this
3513
+ // engine's equivalent is rt.setStyle({scale}) + refresh(),
3514
+ // the same primitive engineApi.setThemeStyle uses above.
3515
+ // CHOROPLETH themes get an OPACITY slider instead, as in flat
3516
+ // (legend.js ~3190: 0-100%, fillopacity·100, default 90;
3517
+ // changeThemeStyle("fillopacity:"+pct/100,"set") + redraw).
3518
+ // (flat labels a VECTOR theme's size slider "Line width" — gl has
3519
+ // no VECTOR themes.)
3520
+ // Flat tests the words (legend.js 2533-2535): a "CHOROPLETHE"
3521
+ // theme has neither slider.
3522
+ const typeWords = rt.flags.typeString != null ? rt.flags.typeString : [...rt.flags].join('|');
3523
+ const isOpacitySlider = /\bCHOROPLETH\b/.test(typeWords);
3524
+ const hasSlider = isOpacitySlider || /\bCHART\b|\bBUBBLE\b|\bDOT\b/.test(typeWords);
3525
+ if (hasSlider) {
3526
+ const sliderRow = document.createElement('div');
3527
+ sliderRow.style.cssText = 'margin-top:10px;font-size:11px;opacity:0.8;';
3528
+ const fillPct = Math.round(styleNum(rt.style.fillopacity) * 100);
3529
+ const initialPct = isOpacitySlider
3530
+ ? (Number.isFinite(fillPct) ? Math.max(0, Math.min(100, fillPct)) : 90)
3531
+ : Math.round((styleNum(rt.style.scale) || 1) * 100);
3532
+ const sliderLabel = isOpacitySlider ? 'Opacity' : 'Chart size';
3533
+ sliderRow.innerHTML = sliderLabel + ': <span class="ix-legend-scale-val">' + initialPct + '</span>%';
3534
+ bodyEl.appendChild(sliderRow);
3535
+ const slider = document.createElement('input');
3536
+ slider.type = 'range';
3537
+ slider.min = isOpacitySlider ? '0' : '25';
3538
+ slider.max = isOpacitySlider ? '100' : '200';
3539
+ slider.value = String(initialPct);
3540
+ slider.style.cssText = 'width:100%;margin-top:2px;';
3541
+ slider.addEventListener('input', () => {
3542
+ const pct = parseInt(slider.value, 10);
3543
+ sliderRow.querySelector('.ix-legend-scale-val').textContent = pct;
3544
+ rt.setStyle(isOpacitySlider ? { fillopacity: pct / 100 } : { scale: pct / 100 });
3545
+ refresh();
3546
+ });
3547
+ bodyEl.appendChild(slider);
3548
+ }
3549
+ }
3550
+ };
3551
+ runtimes.forEach(rt => addLegendPanel(rt));
3552
+ }
3553
+ return { addLegendPanel, setLegendOption, updateSubTheme, layoutLegends };
3554
+ }
3555
+
2715
3556
  class MapBuilder {
2716
3557
  constructor(containerId, options) {
2717
3558
  this.containerId = containerId;
@@ -2923,6 +3764,9 @@
2923
3764
  // theme addressed only by its meta.name, same bug different
2924
3765
  // resolution path.
2925
3766
  if (rt.meta && rt.meta.name && rt.meta.name !== rt.name) _globalThemeRegistry.set(rt.meta.name, rt);
3767
+ // flat's theme id is style.name (maptheme szId) — several themes
3768
+ // can share one .layer() name (a page's features + charts + texts)
3769
+ if (themeIdOf(rt) !== rt.name) _globalThemeRegistry.set(themeIdOf(rt), rt);
2926
3770
  if (named) namedLoads.push(rt);
2927
3771
  } catch (err) {
2928
3772
  console.error(`[ixmaps-gl] theme "${lb.name}" skipped:`, err);
@@ -3052,112 +3896,8 @@
3052
3896
  });
3053
3897
  }
3054
3898
 
3055
- // Click-to-pin tooltip: matches the real ixmaps engine's own
3056
- // click-pins-the-tooltip convention in pan mode (a hover tooltip is
3057
- // transient; a click keeps one visible until dismissed). This is a
3058
- // separate DOM element from deck.gl's own hover tooltip (so hovering
3059
- // a DIFFERENT bubble still shows a normal transient tooltip even
3060
- // while one is pinned) — its content is a snapshot of buildTooltipHtml
3061
- // at click time (not live-updating), but its screen POSITION is kept
3062
- // in sync with the map on every pan/zoom via map.project().
3063
- let pinned = null; // { runtime, object, lngLat }
3064
- // Tooltip look, like the legend's (flatLegendLook): flat's light
3065
- // tooltip (tooltip_mustache.js: white 0.95, #444 text, thin black
3066
- // border, 5px radius) on light basemaps, this engine's dark tooltip
3067
- // on dark ones. Read when shown, so options set later (a
3068
- // myMap.then(...).options({basemapopacity})) count.
3069
- const tooltipLook = () => {
3070
- const o = parseFloat(this._engineOptions.basemapopacity);
3071
- const look = flatLegendLook(this.mapOptions.mapType, isNaN(o) ? 1 : o, this.mapOptions.legendBackground || this.mapOptions.legendbackground);
3072
- return look.dark
3073
- ? { background: 'rgb(41,50,60)', color: 'rgb(200,205,214)', border: 'none', borderRadius: '4px', boxShadow: '0 2px 8px rgba(0,0,0,0.3)' }
3074
- : { background: 'rgba(255,255,255,0.95)', color: '#444', border: '0.5px solid black', borderRadius: '5px',
3075
- boxShadow: 'rgba(0,0,0,0.2) 0px 2px 4px 0px, rgba(0,0,0,0.19) 0px 3px 10px 0px' };
3076
- };
3077
- const pinnedTooltipEl = document.createElement('div');
3078
- pinnedTooltipEl.style.cssText = 'position:absolute;top:0;left:0;z-index:6;display:none;' +
3079
- 'pointer-events:auto;max-width:320px;max-height:320px;overflow:auto;' +
3080
- 'padding:0.6em 1.6em 0.6em 0.7em;font-size:0.85em;';
3081
-
3082
- function updatePinnedTooltipPosition() {
3083
- if (!pinned) return;
3084
- const pt = map.project(pinned.lngLat);
3085
- pinnedTooltipEl.style.transform = `translate(${pt.x + 10}px, ${pt.y - 10}px)`;
3086
- }
3087
-
3088
- // A stable per-geometry anchor coordinate for the identity check
3089
- // below — only a Point's OWN coordinates are an [lng,lat] pair;
3090
- // Polygon/MultiPolygon nest rings of them, so the first ring's
3091
- // first vertex stands in instead (stable across rebuilds for the
3092
- // same underlying feature, same as the Point case: this.features'
3093
- // geometry references don't change shape between
3094
- // _buildChoroplethLayers calls, only the wrapping properties do).
3095
- function anchorCoordOf(geometry) {
3096
- if (!geometry) return null;
3097
- if (geometry.type === 'Point') return geometry.coordinates;
3098
- if (geometry.type === 'Polygon') return geometry.coordinates[0] && geometry.coordinates[0][0];
3099
- if (geometry.type === 'MultiPolygon') return geometry.coordinates[0] && geometry.coordinates[0][0] && geometry.coordinates[0][0][0];
3100
- return null;
3101
- }
3102
-
3103
- // Identity check for "is this hovered object the same one that's
3104
- // pinned" — object references are rebuilt from scratch on every
3105
- // redraw (new individual/group arrays each buildDeckLayers call), so
3106
- // reference equality never works here. Same layer + same geometry
3107
- // position is a reasonable proxy: individual points and aggregated
3108
- // groups alike are keyed by position within a layer, matching how
3109
- // clustering itself already treats "same position" as "same thing".
3110
- function isSameAsPinned(layerId, object) {
3111
- if (pinned.layerId !== layerId) return false;
3112
- const a = anchorCoordOf(object.geometry);
3113
- const b = anchorCoordOf(pinned.object.geometry);
3114
- if (!a || !b) return false;
3115
- return Math.abs(a[0] - b[0]) < 1e-9 && Math.abs(a[1] - b[1]) < 1e-9;
3116
- }
3117
-
3118
- // content lives in its own child so re-rendering (innerHTML) never
3119
- // wipes out the unpin button below (a sibling, not a descendant of it)
3120
- const pinnedContentEl = document.createElement('div');
3121
- pinnedTooltipEl.appendChild(pinnedContentEl);
3122
-
3123
- function renderPinnedTooltip() {
3124
- if (!pinned) { pinnedTooltipEl.style.display = 'none'; return; }
3125
- const html = pinned.runtime.buildTooltipHtml(pinned.object);
3126
- if (!html) { pinned = null; pinnedTooltipEl.style.display = 'none'; return; }
3127
- pinnedContentEl.innerHTML = html;
3128
- Object.assign(pinnedTooltipEl.style, tooltipLook());
3129
- pinnedTooltipEl.style.display = 'block';
3130
- updatePinnedTooltipPosition();
3131
- }
3132
-
3133
- const unpinBtn = document.createElement('button');
3134
- unpinBtn.type = 'button';
3135
- unpinBtn.setAttribute('aria-label', 'chiudi');
3136
- unpinBtn.title = 'chiudi';
3137
- unpinBtn.textContent = '×';
3138
- unpinBtn.style.cssText = 'position:absolute;top:5px;right:5px;width:1.5em;height:1.5em;padding:0;' +
3139
- 'display:flex;align-items:center;justify-content:center;border:none;border-radius:50%;' +
3140
- 'background:rgba(255,255,255,0.18);color:inherit;font-size:1em;line-height:1;font-weight:bold;cursor:pointer';
3141
- unpinBtn.addEventListener('mouseenter', () => { unpinBtn.style.background = 'rgba(255,255,255,0.35)'; });
3142
- unpinBtn.addEventListener('mouseleave', () => { unpinBtn.style.background = 'rgba(255,255,255,0.18)'; });
3143
- unpinBtn.addEventListener('click', () => { pinned = null; pinnedTooltipEl.style.display = 'none'; });
3144
- pinnedTooltipEl.style.position = 'absolute';
3145
- pinnedTooltipEl.appendChild(unpinBtn);
3146
-
3147
- // deck.gl's own default hover tooltip is only recomputed on pointer-
3148
- // move events, not on click — so right after a click, it's still
3149
- // showing whatever it rendered from the hover that was already
3150
- // active (the same item we just pinned), and won't hide itself just
3151
- // because getTooltip would now return null for it. Nothing re-asks
3152
- // it until the next real mousemove. Force it closed here instead —
3153
- // deferred one frame so it wins even if deck.gl's own click handling
3154
- // touches the tooltip DOM in the same tick.
3155
- function hideDefaultHoverTooltip() {
3156
- requestAnimationFrame(() => {
3157
- const defaultTooltipEl = el.parentElement && el.parentElement.querySelector('.deck-tooltip');
3158
- if (defaultTooltipEl) defaultTooltipEl.style.display = 'none';
3159
- });
3160
- }
3899
+ // hover / click-to-pin tooltips: createTooltips
3900
+ const tooltips = createTooltips({ builder: this, map, el, findRuntimeForLayerId });
3161
3901
 
3162
3902
  const overlay = new MapLibreOverlay({
3163
3903
  interleaved: true,
@@ -3165,88 +3905,13 @@
3165
3905
  // pointer becomes a hand over anything pickable (bubbles/points),
3166
3906
  // so hovering something clickable actually looks clickable
3167
3907
  getCursor: ({ isDragging, isHovering }) => (isDragging ? 'grabbing' : (isHovering ? 'pointer' : 'grab')),
3168
- // An exception thrown from these callbacks runs inside deck.gl's
3169
- // own render frame / event dispatch and leaves its frame and
3170
- // interaction state broken — the map stopped showing new layers
3171
- // after zooms and pans (a USER chart's hover threw on every frame
3172
- // the mouse was over an arrow, see pickedFeature). Both are guarded.
3173
- getTooltip: (info) => {
3174
- try { return tooltipFor(info); } catch (err) {
3175
- if (!tooltipErrorLogged) { tooltipErrorLogged = true; console.error('[ixmaps-gl] tooltip failed:', err); }
3176
- return null;
3177
- }
3178
- },
3179
- onClick: (info) => {
3180
- try { return clickFor(info); } catch (err) {
3181
- console.error('[ixmaps-gl] click failed:', err);
3182
- return false;
3183
- }
3184
- }
3908
+ // (guarded: see createTooltips)
3909
+ getTooltip: tooltips.getTooltip,
3910
+ onClick: tooltips.onClick
3185
3911
  });
3186
- let tooltipErrorLogged = false;
3187
- // a USER chart's picked item is {d, icon} (see _buildUserChartLayers):
3188
- // the feature is its d
3189
- function pickedFeature(o) { return o && o.icon && o.d ? o.d : o; }
3190
- function tooltipFor({ object: picked, layer }) {
3191
- const object = pickedFeature(picked);
3192
- if (!object || !layer) return null;
3193
- // suppress the hover tooltip ONLY for the specific item that's
3194
- // pinned (showing both would just duplicate the same content) —
3195
- // every OTHER item still gets its normal hover preview even
3196
- // while something else stays pinned
3197
- if (pinned && isSameAsPinned(layer.id, object)) return null;
3198
- const rt = findRuntimeForLayerId(layer.id);
3199
- if (!rt) return null;
3200
- const html = rt.buildTooltipHtml(object);
3201
- return html ? { html, style: Object.assign({ fontSize: '0.85em', padding: '0.5em 0.7em', maxWidth: '320px' }, tooltipLook()) } : null;
3202
- }
3203
- function clickFor(picked) {
3204
- const info = picked && Object.assign({}, picked, { object: pickedFeature(picked.object) });
3205
- if (info && info.object && info.layer) {
3206
- const rt = findRuntimeForLayerId(info.layer.id);
3207
- if (rt) {
3208
- // Only a Point geometry's own coordinates ARE an
3209
- // [lng,lat] pair (every point/bubble/dot/chart-cluster
3210
- // layer) — a CHOROPLETH polygon's geometry.coordinates is
3211
- // a nested array of RINGS, which map.project() below
3212
- // can't accept (confirmed live: threw MapLibre's own
3213
- // "LngLatLike argument must be..." error). The `||
3214
- // info.coordinate` fallback this used to lean on never
3215
- // actually ran for a polygon — a nested array is truthy,
3216
- // so the left side always "won" — which is the real bug
3217
- // this fixes: the pinned tooltip stuck at its default
3218
- // top:0/left:0 (never got a real position because
3219
- // updatePinnedTooltipPosition's map.project() threw
3220
- // before setting one), AND that same uncaught exception,
3221
- // thrown from inside deck.gl's own click-dispatch
3222
- // callback, left deck.gl's pointer-interaction state
3223
- // corrupted enough to block all further pan/zoom —
3224
- // confirmed live, both symptoms disappear together once
3225
- // this stops throwing. info.coordinate (the actual
3226
- // clicked map location, provided regardless of feature
3227
- // geometry type) anchors every non-Point case correctly.
3228
- const lngLat = (info.object.geometry && info.object.geometry.type === 'Point')
3229
- ? info.object.geometry.coordinates
3230
- : info.coordinate;
3231
- pinned = { runtime: rt, object: info.object, lngLat, layerId: info.layer.id };
3232
- renderPinnedTooltip();
3233
- hideDefaultHoverTooltip();
3234
- return true;
3235
- }
3236
- }
3237
- // clicked empty map space (or a non-tooltippable layer) -> unpin
3238
- pinned = null;
3239
- pinnedTooltipEl.style.display = 'none';
3240
- return false;
3241
- }
3242
3912
  map.addControl(overlay);
3243
3913
  map.addControl(new maplibregl.NavigationControl(), 'top-left');
3244
- map.on('move', updatePinnedTooltipPosition);
3245
-
3246
- if (el.parentElement) {
3247
- el.parentElement.style.position = el.parentElement.style.position || 'relative';
3248
- el.parentElement.appendChild(pinnedTooltipEl);
3249
- }
3914
+ tooltips.mount();
3250
3915
 
3251
3916
  if (this._legendHtml && el.parentElement) {
3252
3917
  const legendEl = document.createElement('div');
@@ -3416,6 +4081,8 @@
3416
4081
  },
3417
4082
  setMapTypeId: (id) => engineApi.setMapType(id),
3418
4083
  mapType: (id) => engineApi.setMapType(id),
4084
+ // flat's getMapTypeId(): the basemap type name in use
4085
+ getMapTypeId: () => String(builder.mapOptions.mapType || ''),
3419
4086
  setBasemapOpacity: (delta, mode) => {
3420
4087
  _basemapOpacity = mode === 'relative'
3421
4088
  ? Math.max(0, Math.min(1, _basemapOpacity + (parseFloat(delta) || 0)))
@@ -3857,6 +4524,7 @@
3857
4524
  runtimes.push(rt);
3858
4525
  _globalThemeRegistry.set(rt.name, rt);
3859
4526
  if (rt.meta && rt.meta.name && rt.meta.name !== rt.name) _globalThemeRegistry.set(rt.meta.name, rt);
4527
+ if (themeIdOf(rt) !== rt.name) _globalThemeRegistry.set(themeIdOf(rt), rt);
3860
4528
  addLegendPanel(rt);
3861
4529
  notifyNewTheme(rt);
3862
4530
  refresh();
@@ -4139,8 +4807,25 @@
4139
4807
  refresh();
4140
4808
  });
4141
4809
 
4810
+ // flat's page hook ixmaps.htmlgui_onDrawTheme(szId), called when a
4811
+ // theme has been drawn: here once per settled redraw, for every
4812
+ // shown theme, with its id. Not re-entered when the hook itself
4813
+ // changes a theme (changeThemeStyle → refresh → here again).
4814
+ let inDrawThemeHook = false;
4815
+ function callDrawThemeHooks() {
4816
+ const hook = pageIxmaps() && pageIxmaps().htmlgui_onDrawTheme;
4817
+ if (typeof hook !== 'function' || inDrawThemeHook) return;
4818
+ inDrawThemeHook = true;
4819
+ try {
4820
+ for (const rt of runtimes.slice()) {
4821
+ if (rt._hidden) continue;
4822
+ try { hook.call(global.ixmaps, themeIdOf(rt)); } catch (e) { console.error('[ixmaps-gl] htmlgui_onDrawTheme:', e); }
4823
+ }
4824
+ } finally { inDrawThemeHook = false; }
4825
+ }
4142
4826
  function notifyRedraw() {
4143
4827
  redrawListeners.forEach(cb => { try { cb(); } catch (err) { console.error('[ixmaps-gl] onRedraw callback failed:', err); } });
4828
+ callDrawThemeHooks();
4144
4829
  }
4145
4830
 
4146
4831
  // For explicit, discrete API calls (setFacetFilter, setSizeField,
@@ -4201,741 +4886,57 @@
4201
4886
  }
4202
4887
 
4203
4888
  // onRedraw listeners (facets sidebar) are notified on their OWN,
4204
- // longer debounce, separate from the map's own 150ms layer throttle
4205
- // above — measured cost: getFacets() over every configured field
4206
- // plus the sidebar DOM rebuild can run ~55-70ms at a zoomed-out
4207
- // viewport (tens of thousands of in-view points), vs ~5ms for
4208
- // refreshLayers() itself. Running that on every throttled pan/zoom
4209
- // tick visibly competed with the map's own frame budget; recomputing
4210
- // it only once movement settles (this fires once, NOTIFY_REDRAW_MS
4211
- // after the last 'move' event) keeps panning smooth and matches the
4212
- // real ixmaps engine's own shape — htmlgui_onDrawTheme (facets) fires
4213
- // once per completed theme redraw, not per intermediate pan frame.
4214
- const NOTIFY_REDRAW_MS = 400;
4215
- let notifyRedrawTimer = null;
4216
- function scheduleNotifyRedraw() {
4217
- clearTimeout(notifyRedrawTimer);
4218
- notifyRedrawTimer = setTimeout(notifyRedraw, NOTIFY_REDRAW_MS);
4219
- }
4220
-
4221
- map.on('move', scheduleRefresh);
4222
- window.addEventListener('resize', () => map.resize());
4223
-
4224
- // GL-PORT COMPAT: real ixmaps-flat's map.on("layerdraw", cb) fires
4225
- // once per THEME as it finishes drawing, handing the callback an
4226
- // event object carrying that theme's own id (e.constructor.id).
4227
- // This engine has no equivalent per-theme draw event — onRedraw
4228
- // fires once per REFRESH of the whole map (see its own comment,
4229
- // above) — so "layerdraw" is approximated by firing the registered
4230
- // callback once per CURRENT runtime, synthesizing {id: rt.name},
4231
- // every time onRedraw fires. A page reacting to "layerdraw" by
4232
- // reading THAT theme's own state (e.g. recomputing a stats sidebar
4233
- // via getThemeObj/getFacets — see the global compat shims, below)
4234
- // gets the same practical effect: fresh numbers after every redraw,
4235
- // for every theme, without needing the page rewritten around a
4236
- // finer-grained event this engine doesn't produce.
4237
- const layerdrawCbs = (this._pendingEvents || [])
4238
- .filter(([event]) => event === 'layerdraw')
4239
- .map(([, cb]) => cb);
4240
- if (layerdrawCbs.length) {
4241
- engineApi.onRedraw(() => {
4242
- runtimes.forEach(rt => {
4243
- layerdrawCbs.forEach(cb => {
4244
- try { cb({ id: rt.name }); } catch (err) { console.error('[ixmaps-gl] "layerdraw" callback failed:', err); }
4245
- });
4246
- });
4247
- });
4248
- }
4249
-
4250
- // NATIVE INTERACTIVE LEGEND — real ixmaps-flat's legend.js
4251
- // (makeColorLegendHTMLLong) builds, per CATEGORICAL-ish theme: a
4252
- // title/snippet header, one row per category (color swatch + a bar
4253
- // proportional to that category's own total + right-aligned
4254
- // formatted value), a description/source footer, and a per-theme
4255
- // "Chart size" 25-200% slider — all read directly off source
4256
- // (ui/js/tools/legend.js:594-1037/3196-3393, ui/js/htmlgui.js:2196/
4257
- // 2208 for markThemeClass/unmarkThemeClass, ui/css/legend.css for
4258
- // the selected-row highlight) rather than guessed from the
4259
- // screenshot alone. Gated on `legend:"open"` — the one real map
4260
- // option this engine's own createMap already threads through as
4261
- // this.mapOptions (see mapOptions.legend below) — since there's no
4262
- // toolbar toggle button here to open/close it later the way the
4263
- // real engine's own chrome does.
4264
- //
4265
- // Deliberate divergences from the real implementation, both
4266
- // reasoned rather than accidental:
4267
- // - Real legend.js does a full innerHTML rebuild on every single
4268
- // theme redraw (even a bare click-toggle). renderRows() below
4269
- // only rebuilds the ROWS (swatch/bar/value/highlight) — cheap,
4270
- // and the only part that can actually change post-load (marks,
4271
- // or an AGGREGATE theme's dataset-wide totals, which this port
4272
- // doesn't yet let change post-load anyway) — rather than
4273
- // tearing down and rebuilding title/snippet/description/slider
4274
- // too, which never change. Same visible result, less DOM churn.
4275
- // - Real "isolate_gray" mode dims non-marked SVG paths via a CSS
4276
- // class; this engine dims via IconLayer's own getColor alpha
4277
- // (see LayerRuntime#_iconAlpha) since these are deck.gl raster
4278
- // icons, not DOM/SVG nodes a CSS rule could reach.
4279
- // Legend on/off and fold state as real ixmaps-flat reads the map
4280
- // option (htmlgui_flat.js:748-794): on whenever `legend` is given and
4281
- // isn't false/"false" ("true", "open", "", 1, …), folded for
4282
- // "closed", off when not given at all.
4283
- const legendOpt = this.mapOptions.legend;
4284
- // let, not const: map.setLegend(value) switches them at runtime (see setLegendOption)
4285
- let legendOn = legendIsOn(legendOpt);
4286
- let legendFolded = legendOpt === 'closed';
4287
- {
4288
- // Map-level `align` option positions the legend panel — a map-
4289
- // wide placement choice, not per-theme (unlike legendtheme/
4290
- // legendfilter above), so read once from this.mapOptions rather
4291
- // than per-runtime. Real ixmaps-flat ALSO has an `align` map
4292
- // option (ui/js/htmlgui_flat.js) controlling legend position, but
4293
- // its value shape is a free-form regex-matched left/right/center/
4294
- // top/bottom/pixel-prefixed string tied to a static in-page DOM
4295
- // panel — a different layout model from this panel's own
4296
- // position:absolute floating-overlay-over-the-map-canvas
4297
- // approach. This port reuses the real OPTION NAME but adapts the
4298
- // VALUE SHAPE to plain four-corner tokens matching how this panel
4299
- // is actually positioned — a deliberate divergence, same
4300
- // reasoning already applied to style.legendfilter's own name/
4301
- // shape choice earlier this session. Default "top-right" per
4302
- // explicit request (real engine's own fallback is right-anchored
4303
- // too, just without an opinion on vertical placement).
4304
- const ALIGN_CSS = {
4305
- 'top-left': 'left:10px;top:10px;',
4306
- 'top-right': 'right:10px;top:10px;',
4307
- 'bottom-left': 'left:10px;bottom:10px;',
4308
- 'bottom-right': 'right:10px;bottom:10px;'
4309
- };
4310
- // gl's four corners, else flat's own values (htmlgui_flat.js
4311
- // 421-470): "…left" → left (the part before "left", else 55px), 12px
4312
- // from the top; "…right" → right (else 25px); "center" / "top" → top
4313
- // center
4314
- const flatAlignCss = align => {
4315
- const a = String(align || '');
4316
- if (/left/.test(a)) return 'left:' + (a.split('left')[0] || '55px') + ';top:12px;';
4317
- if (/right/.test(a)) return 'right:' + (a.split('right')[0] || '25px') + ';top:10px;';
4318
- if (a === 'center' || a === 'top') return 'left:50%;transform:translateX(-50%);top:10px;';
4319
- return null;
4320
- };
4321
- const legendAlignCss = ALIGN_CSS[this.mapOptions.align] || flatAlignCss(this.mapOptions.align) || ALIGN_CSS['top-right'];
4322
- // Real ixmaps-flat's SUB-THEME (MapTheme.createSubTheme,
4323
- // maptheme.js 14284-14292 / 15010-15160; map.Themes.enableSubThemes
4324
- // defaults to true): marking fields of a multi-field choropleth
4325
- // (DOMINANT/COMPOSECOLOR, not CHART) paints a new theme over the
4326
- // original — one field: CHOROPLETH|LOG|DOPACITY on that field, white
4327
- // → the field's color in 7 steps, dopacityscale 2, dopacitypow 1,
4328
- // keeping DOPACITY(→DOPACITYMAX)/AGGREGATE/GROUP/SUM/ZEROISVALUE/
4329
- // VALUES; several fields: the original type on just those fields
4330
- // and colors. It reuses the original's joined polygons, has no
4331
- // legend and stays out of the theme registry (its name
4332
- // "<layer>::subtheme" also keeps its deck.gl layer id apart), so
4333
- // markThemeClass/removeTheme still address the original.
4334
- const subThemeCapable = rt => rt.flags.has('CHOROPLETH') && !rt.flags.has('CHART') && (rt.flags.has('DOMINANT') || rt.flags.has('COMPOSECOLOR'));
4335
- const toHex = c => Array.isArray(c) ? '#' + c.slice(0, 3).map(v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('') : c;
4336
- updateSubTheme = (rt) => {
4337
- if (rt._subTheme) {
4338
- const i = runtimes.indexOf(rt._subTheme);
4339
- if (i >= 0) runtimes.splice(i, 1);
4340
- rt._subTheme = null;
4341
- }
4342
- const fields = String(rt.binding.value || '').split('|');
4343
- const marks = [...rt._markedClasses].filter(i => i >= 0 && i < fields.length).sort((a, b) => a - b);
4344
- if (marks.length) {
4345
- const colors = (rt.categoryColorsRgb || []).map(toHex);
4346
- const labels = rt.categoryDisplayLabels || rt.categoryLabels || fields;
4347
- const kept = ['DOPACITY', 'AGGREGATE', 'GROUP', 'SUM', 'ZEROISVALUE', 'VALUES']
4348
- .filter(f => [...rt.flags].some(x => x.startsWith(f) && (f !== 'DOPACITY' || /^DOPACITY/.test(x))))
4349
- .map(f => (f === 'DOPACITY' ? 'DOPACITYMAX' : f));
4350
- let def;
4351
- if (marks.length > 1) {
4352
- def = {
4353
- layer: rt.name + '::subtheme',
4354
- binding: (b => { delete b.field100; delete b.value100; return b; })(Object.assign({}, rt.binding, { value: marks.map(i => fields[i]).join('|') })),
4355
- style: Object.assign({}, rt.style, { type: [...rt.flags].concat(['SUBTHEME', 'NOLEGEND']).join('|'),
4356
- colorscheme: marks.map(i => colors[i]), values: marks.map(i => labels[i]) }),
4357
- meta: { title: 'actual selection' }
4358
- };
4359
- } else {
4360
- const i = marks[0];
4361
- const binding = Object.assign({}, rt.binding, { value: fields[i] });
4362
- delete binding.alpha; delete binding.alpha100;
4363
- // rt.features already carry the field100 result — drop it and
4364
- // every alias of it, or normalizeTheme would apply it again
4365
- delete binding.field100; delete binding.value100;
4366
- def = {
4367
- layer: rt.name + '::subtheme',
4368
- binding,
4369
- style: { type: ['CHOROPLETH', 'LOG', 'DOPACITY', 'NOLEGEND', 'SUBTHEME'].concat(kept).join('|'),
4370
- colorscheme: ['7', 'white', colors[i]], dopacityscale: 2, dopacitypow: 1,
4371
- units: rt.style.units, linecolor: rt.style.linecolor, linewidth: rt.style.linewidth },
4372
- meta: { title: 'actual selection' }
4373
- };
4374
- }
4375
- const sub = new LayerRuntime(normalizeTheme(def), { type: 'FeatureCollection', features: rt.features }, builder._engineOptions);
4376
- sub._isSubTheme = true;
4377
- runtimes.splice(runtimes.indexOf(rt) + 1, 0, sub);
4378
- rt._subTheme = sub;
4379
- }
4380
- refresh();
4381
- notifyRedraw();
4382
- };
4383
- // the stack of the theme panels — flat shows one theme's legend
4384
- // after the other in #map-legend, separated by a line, and leaves
4385
- // out a theme hidden, out of scale or not yet drawn
4386
- let legendStack = null;
4387
- const legendStackEl = shadow => {
4388
- if (legendStack && legendStack.parentNode) return legendStack;
4389
- legendStack = document.createElement('div');
4390
- legendStack.className = 'ix-legend-stack';
4391
- legendStack.style.cssText = 'position:absolute;' + legendAlignCss + 'z-index:6;width:280px;'
4392
- + 'display:flex;flex-direction:column;max-height:calc(100% - 24px);overflow-y:auto;'
4393
- + 'border-radius:6px;box-shadow:' + shadow + ';pointer-events:auto;';
4394
- if (!document.getElementById('ix-legend-stack-css')) {
4395
- const css = document.createElement('style');
4396
- css.id = 'ix-legend-stack-css';
4397
- css.textContent = '.ix-legend-stack>.ix-native-legend:not([data-ixoff])~.ix-native-legend:not([data-ixoff]){border-top:1px solid rgba(128,128,128,.35)}';
4398
- document.head.appendChild(css);
4399
- }
4400
- el.parentElement.appendChild(legendStack);
4401
- return legendStack;
4402
- };
4403
- // map.setLegend(value): the `legend` map option at runtime, same values as
4404
- // ixmaps.Map(id, {legend}) — on ("true", "open", …), folded ("closed"), off
4405
- // (false/"false"/0/undefined). Panels are made for themes that lack one,
4406
- // removed when off, folded/unfolded otherwise.
4407
- setLegendOption = (opt) => {
4408
- builder.mapOptions.legend = opt;
4409
- legendFolded = opt === 'closed';
4410
- legendOn = legendIsOn(opt);
4411
- runtimes.forEach(rt => {
4412
- if (!legendOn) {
4413
- if (rt._legendPanel && rt._legendPanel.parentNode) rt._legendPanel.parentNode.removeChild(rt._legendPanel);
4414
- rt._legendPanel = null;
4415
- } else if (!rt._legendPanel) addLegendPanel(rt);
4416
- else if (rt._setLegendCollapsed) rt._setLegendCollapsed(legendFolded);
4417
- });
4418
- layoutLegends();
4419
- };
4420
- layoutLegends = () => {
4421
- if (!legendStack) return;
4422
- const z = map.getZoom();
4423
- let shown = 0;
4424
- [...legendStack.children].forEach(panel => {
4425
- const rt = panel._ixRuntime;
4426
- const off = !rt || rt._hidden || themeHiddenByScale(rt.flags, rt.style, z);
4427
- if (off) { panel.setAttribute('data-ixoff', ''); panel.style.display = 'none'; }
4428
- else { panel.removeAttribute('data-ixoff'); panel.style.display = 'flex'; shown++; }
4429
- });
4430
- legendStack.style.display = shown ? 'flex' : 'none';
4431
- };
4432
- const legendApplies = rt => rt.categoryLabels && rt.categoryLabels.length && !rt.flags.has('FEATURE') && !rt.flags.has('FEATURES') && !rt.flags.has('NOLEGEND');
4433
- // One theme's panel — at build time for every theme, and again for a
4434
- // theme defined later (map.layer(...) in a myMap.then(...) chain,
4435
- // defineLayer, loadProject); removeTheme removes it with the theme.
4436
- addLegendPanel = (rt) => {
4437
- // marking a class (legend row, or ixmaps.markThemeClass from a
4438
- // page) redraws — for every theme, legend panel or not
4439
- rt._triggerRedraw = () => { refresh(); };
4440
- if (subThemeCapable(rt)) rt._onMarksChanged = () => updateSubTheme(rt);
4441
- if (!legendOn || !el.parentElement || !legendApplies(rt)) return;
4442
- el.parentElement.style.position = el.parentElement.style.position || 'relative';
4443
- {
4444
- // .type("...|NOLEGEND") — the fourth real legend-related type()
4445
- // token: opts a theme OUT of the legend entirely (real engine's
4446
- // own per-layer "skip this one" flag, distinct from the map-
4447
- // level legend:"open"/"closed" option gating the whole panel).
4448
- // Same free-parsing story as SIMPLELEGEND/COMPACTLEGEND above —
4449
- // just one more flag to exclude on, no new plumbing.
4450
- const panel = document.createElement('div');
4451
- rt._legendPanel = panel;
4452
- panel.className = 'ix-native-legend';
4453
- // Opt-in via .style({legendtheme:"light"}) — a NEW, ixmaps-gl-
4454
- // only convention (same naming pattern as the sibling
4455
- // legendfilter/legendunits keys added earlier this session):
4456
- // real ixmaps-flat's legend.js has no such switch, this panel
4457
- // is entirely this port's own construction. Default ("dark")
4458
- // preserves the original always-dark panel exactly; "light" is
4459
- // for pages using a light basemap (e.g. CARTO Positron) where
4460
- // a dark semi-transparent panel reads as a mismatched dark
4461
- // patch rather than legend chrome. Every other hardcoded panel
4462
- // color below (rows, bar track, filter <select>) is expressed
4463
- // in terms of this one palette so the two variants stay in
4464
- // sync — no separate light/dark branch anywhere else.
4465
- // flat's look (flatLegendLook) unless the page picks one with
4466
- // this engine's own legendtheme
4467
- const opacityOpt = parseFloat(this._engineOptions.basemapopacity);
4468
- const flatLook = flatLegendLook(this.mapOptions.mapType, isNaN(opacityOpt) ? 1 : opacityOpt, this.mapOptions.legendBackground || this.mapOptions.legendbackground);
4469
- const isLightLegend = rt.style.legendtheme === 'light' || (rt.style.legendtheme !== 'dark' && !flatLook.dark);
4470
- const legendColors = isLightLegend
4471
- ? { bg: 'rgba(255,255,255,0.92)', fg: '#1a1a1a', shadow: '0 2px 10px rgba(0,0,0,0.18)',
4472
- rowMarked: 'rgba(0,0,0,0.08)', track: 'rgba(0,0,0,0.08)',
4473
- selectBg: 'rgba(0,0,0,0.04)', selectBorder: 'rgba(0,0,0,0.2)' }
4474
- : { bg: 'rgba(28,30,34,0.92)', fg: '#eee', shadow: '0 2px 10px rgba(0,0,0,0.45)',
4475
- rowMarked: 'rgba(255,255,255,0.14)', track: 'rgba(255,255,255,0.08)',
4476
- selectBg: 'rgba(255,255,255,0.08)', selectBorder: 'rgba(255,255,255,0.25)' };
4477
- if (!rt.style.legendtheme) legendColors.bg = flatLook.bg;
4478
- // max-height:66% resolves against el.parentElement's own
4479
- // height (the map container, which always has a definite
4480
- // height for the map itself to render into) since this panel
4481
- // is absolutely positioned inside it — a plain percentage on
4482
- // an absolutely-positioned element's height IS legal CSS as
4483
- // long as its containing block has a definite height, which
4484
- // this one does. display:flex column + min-height:0 on the
4485
- // ROWS wrapper below (not this outer panel) is what makes
4486
- // only the row list scroll while the header/description/
4487
- // slider stay fixed in place — a flex child won't actually
4488
- // shrink to fit and scroll internally without min-height:0,
4489
- // it just overflows its flex parent instead.
4490
- // one box for every theme, as flat's #map-legend (legend.js
4491
- // 2200-2460): the panels stack in the legend stack in theme
4492
- // order, a line between them (see layoutLegends)
4493
- panel.style.cssText = 'position:relative;flex:0 0 auto;'
4494
- + 'display:flex;flex-direction:column;'
4495
- + 'background:' + legendColors.bg + ';color:' + legendColors.fg + ';font:12px/1.4 -apple-system,Arial,sans-serif;'
4496
- + 'padding:10px 12px 12px;pointer-events:auto;';
4497
- const stack = legendStackEl(legendColors.shadow);
4498
- const at = runtimes.indexOf(rt);
4499
- const next = [...stack.children].find(c => runtimes.indexOf(c._ixRuntime) > at);
4500
- panel._ixRuntime = rt;
4501
- stack.insertBefore(panel, next || null);
4502
-
4503
- // SUM+valuefield mirrors the real engine's own "SUM style
4504
- // aggregation" reading (style.valuefield, falling back to
4505
- // the bound size field); anything else (plain CATEGORICAL,
4506
- // no SUM) falls back to a per-category record COUNT.
4507
- const useSum = flatFlag(rt.flags, 'SUM') && rt.style.valuefield;
4508
- const valueField = rt.style.valuefield || rt.binding.size;
4509
- // legendunits wins over the theme's general-purpose units
4510
- // (used elsewhere for tooltips, e.g. _renderItemChartHtml) —
4511
- // a page may want a different/no unit string specifically on
4512
- // the legend's own value column. Appended as-is (no extra
4513
- // space injected), matching how style.units/legendunits are
4514
- // themselves authored with their own leading space (e.g.
4515
- // " MW") in real pages.
4516
- const legendUnit = rt.style.legendunits || rt.style.units || '';
4517
- const labels = rt.categoryDisplayLabels || rt.categoryLabels;
4518
- // .type("...|TEXTLEGEND") — the FIFTH real legend-related
4519
- // type() token: title/snippet/description text only, no
4520
- // category rows at all (swatches, bars, chips — none of it),
4521
- // per explicit correction. Everything below that builds the
4522
- // rows/totals/onRedraw-recompute wiring is skipped outright
4523
- // rather than built-then-hidden — there is nothing for any of
4524
- // it to feed once no rows ever render. The country-filter
4525
- // dropdown and Chart-size slider stay: neither is a
4526
- // "categorical item" or "colorscheme swatch", both are
4527
- // independent controls unrelated to the row list.
4528
- const isTextOnly = rt.flags.has('TEXTLEGEND');
4529
-
4530
- // Map-view-aware per explicit request: totals reflect only
4531
- // what's CURRENTLY on screen (the map's own bounds, plus the
4532
- // same far-hemisphere exclusion _buildChartLayers itself
4533
- // applies under globe projection — see isOnVisibleHemisphere)
4534
- // rather than the whole dataset — recomputed on every redraw
4535
- // (engineApi.onRedraw, below) so panning/zooming/rotating
4536
- // updates the bars and values live, the same way the actual
4537
- // rendered bubbles change. Same simple rectangular bbox
4538
- // membership test _computeAggregatedItems's own non-AGGREGATE
4539
- // branch uses (no antimeridian wraparound handling — matches
4540
- // that existing convention, not a new gap this introduces).
4541
- let totals, maxTotal, order, rowsScroll;
4542
- // No-op default: TEXTLEGEND never reassigns this (see below),
4543
- // so the LATER unconditional-looking `renderRows()` call
4544
- // safely does nothing rather than needing its own isTextOnly
4545
- // guard at every call site.
4546
- let renderRows = () => {};
4547
- if (!isTextOnly) {
4548
- function computeTotals() {
4549
- const bounds = map.getBounds();
4550
- const bbox = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()];
4551
- const proj = (typeof map.getProjection === 'function' && map.getProjection()) || { type: 'mercator' };
4552
- const globeCenter = proj.type === 'globe' ? map.getCenter() : null;
4553
- totals = new Array(rt.categoryLabels.length).fill(0);
4554
- if (rt._rangeClassed) { totals = rangeClassLegendTotals(rt, bbox, globeCenter); maxTotal = Math.max(0, ...totals); order = totals.map((v, i) => i).sort((a, b) => totals[b] - totals[a]); return; }
4555
- // _activeFeatures (set by setFacetFilter/clearFacetFilter,
4556
- // e.g. the country-select dropdown below) is the facet-
4557
- // filtered subset when a filter is active, null otherwise —
4558
- // reading it here keeps the legend's own numbers consistent
4559
- // with whatever the country filter is currently narrowing
4560
- // the map down to, not just with the viewport/hemisphere.
4561
- (rt._activeFeatures || rt.features).forEach(f => {
4562
- const idx = rt.categoryIndexByLabel ? rt.categoryIndexByLabel.get(f.properties[rt.binding.value]) : null;
4563
- if (idx == null) return;
4564
- const [lng, lat] = f.geometry.coordinates;
4565
- if (lng < bbox[0] || lng > bbox[2] || lat < bbox[1] || lat > bbox[3]) return;
4566
- if (globeCenter && !isOnVisibleHemisphere(lng, lat, globeCenter)) return;
4567
- totals[idx] += useSum ? (parseFloat(f.properties[valueField]) || 0) : 1;
4568
- });
4569
- maxTotal = Math.max(0, ...totals);
4570
- order = totals.map((v, i) => i).sort((a, b) => totals[b] - totals[a]);
4571
- }
4572
- computeTotals();
4573
-
4574
- // Only THIS wrapper scrolls (flex:1 1 auto + min-height:0 lets
4575
- // it shrink to whatever's left of the panel's own 66%-height
4576
- // cap once the fixed header/description/slider take their
4577
- // share, then overflow-y:auto scrolls just the row list) —
4578
- // the header above and description/slider below stay put.
4579
- rowsScroll = document.createElement('div');
4580
- rowsScroll.style.cssText = 'flex:1 1 auto;min-height:0;overflow-y:auto;';
4581
- const rowsEl = document.createElement('div');
4582
- rowsEl.className = 'ix-legend-rows';
4583
- // .type("...|SIMPLELEGEND") — real ixmaps-flat's OTHER legend
4584
- // variant (confirmed against a real-engine screenshot, per
4585
- // explicit correction): swatch + label chips only, no bar, no
4586
- // value column. SIMPLELEGEND is just another pipe-delimited
4587
- // type() token — MapBuilder#type() already puts every token
4588
- // into this.flags regardless of whether anything reads it, so
4589
- // no new parsing was needed, only this render-mode branch.
4590
- // flex-wrap here (vs. the bar mode's block rows) — chips wrap
4591
- // to the panel's own width the same loose way the real
4592
- // engine's screenshot shows (an uneven number of chips per
4593
- // row, driven by each label's own text length, not a fixed
4594
- // column grid).
4595
- // .type("...|COMPACTLEGEND") — the THIRD real legend variant
4596
- // (again confirmed against a real-engine screenshot, per
4597
- // explicit correction): the bar-mode's own two-line label/bar/
4598
- // value row, but wrapped into a multi-column flex-wrap grid
4599
- // instead of one full-width row per category — each item's
4600
- // own width follows its content (a bigger value's longer bar
4601
- // makes its own item wider, so fewer fit per line; Coal alone
4602
- // filled its own row in the reference screenshot while the
4603
- // smaller categories packed 3-4 per row) rather than a fixed
4604
- // column count.
4605
- if (rt.flags.has('SIMPLELEGEND') || rt.flags.has('COMPACTLEGEND')) {
4606
- rowsEl.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;';
4607
- }
4608
- rowsScroll.appendChild(rowsEl);
4609
-
4610
- // Two lines per row — label on its own full-width line, then
4611
- // swatch+bar+value below — per explicit correction: a single
4612
- // shared line left too little room for the bar to read as
4613
- // proportional (it was squeezed between the label text and a
4614
- // fixed-width value column). The bar itself lives inside a
4615
- // flex:1 "track" div sized to whatever's left after the fixed
4616
- // swatch/value columns, and is a PERCENTAGE of that track's
4617
- // own width (not a pixel value) — real formula's per-unit-
4618
- // factor/cap (legend.js:980-984/946-950) collapses to a plain
4619
- // linear percentage-of-max once the bar's available width
4620
- // isn't a fixed constant this code has to guess at.
4621
- const isSimple = rt.flags.has('SIMPLELEGEND');
4622
- const isCompact = rt.flags.has('COMPACTLEGEND');
4623
- // Compact mode's bar can't use the full-width mode's
4624
- // percentage-of-flex-track trick (there's no full-width track
4625
- // to be a percentage OF once the row itself is content-sized,
4626
- // not stretched) — a plain pixel width, proportional to value
4627
- // and capped, same shape as the full-width formula just
4628
- // expressed in absolute px instead of a % of an elastic track.
4629
- const COMPACT_MAX_BAR_PX = 70;
4630
- // flat's compact legend (legend.js makeColorLegendHTML, the
4631
- // "compact" mode the theme legends use, 1212-1257): a range
4632
- // theme of 5 classes or more without labels is one line of
4633
- // color patches with the value range below; DOPACITY with an
4634
- // alpha field adds two paler lines (opacity 1/2, 1/3) and the
4635
- // alpha max, "↓" and min to their right (1062-1205). The
4636
- // legend's own __formatValue (legend.js 451-553) has no SPACE
4637
- // form: SPACE there is flat's plain "." thousands.
4638
- const flatCompactLegend = rt._rangeClassed && !flatFlag(rt.flags, 'CATEGORICAL') && !flatFlag(rt.flags, 'PLOT')
4639
- && (rt.partsA || []).length >= 5 && !(Array.isArray(rt.style.label) && rt.style.label.length);
4640
- // flat's single-color legend (legend.js 765-769, 1039-1046): a range
4641
- // theme of at most 2 classes without label/ranges, not
4642
- // CATEGORICAL, is one row — the swatch and "min ... max unit",
4643
- // or, when every value is 1 (a "$item$" count), its label or
4644
- // value field
4645
- const flatSingleRowLegend = rt._rangeClassed && !flatFlag(rt.flags, 'CATEGORICAL') && !flatFlag(rt.flags, 'PLOT')
4646
- && (rt.partsA || []).length <= 2 && !(Array.isArray(rt.style.label) && rt.style.label.length)
4647
- && !(Array.isArray(rt.style.ranges) && rt.style.ranges.length);
4648
- if (flatSingleRowLegend) {
4649
- renderRows = function() {
4650
- const rgb = rt.categoryColorsRgb[0] || [128, 128, 128];
4651
- const unit = String(rt.style.legendunits || rt.style.units || '').replace(/ /g, '&nbsp;');
4652
- const vMin = styleNum(rt.style.minvalue) || rt._valueMin, vMax = styleNum(rt.style.maxvalue) || rt._valueMax;
4653
- const label = rt.style.label != null && !Array.isArray(rt.style.label) ? String(rt.style.label) : String(rt.binding.value || '');
4654
- const text = vMin !== 1 || vMax !== 1
4655
- ? flatFormatValue(vMin, 2, 'BLANK') + ' &nbsp;... ' + flatFormatValue(vMax, 2, 'BLANK') + ' ' + unit
4656
- : label;
4657
- const marked = rt._markedClasses.has(0);
4658
- rowsEl.innerHTML = '<div class="ix-legend-row" data-idx="0" style="display:flex;align-items:center;gap:5px;padding:4px 3px;cursor:pointer;border-radius:4px;'
4659
- + 'background:' + (marked ? legendColors.rowMarked : 'transparent') + ';">'
4660
- + '<span style="flex:0 0 auto;width:1.6em;height:0.8em;background:rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ');"></span>'
4661
- + '<span>' + text + '</span></div>';
4662
- rowsEl.querySelectorAll('.ix-legend-row').forEach(row => {
4663
- row.addEventListener('click', () => {
4664
- if (rt._markedClasses.has(0)) window.ixmaps.unmarkThemeClass(rt.name, 0);
4665
- else window.ixmaps.markThemeClass(rt.name, 0);
4666
- });
4667
- });
4668
- };
4669
- } else
4670
- if (flatCompactLegend) {
4671
- renderRows = function() {
4672
- const n = rt.categoryColorsRgb.length;
4673
- const withAlpha = flatFlag(rt.flags, 'DOPACITY') && rt.binding.alpha && rt._alphaMax;
4674
- const nLines = withAlpha ? 3 : 1;
4675
- const dec = rt.style.decimals != null ? parseInt(rt.style.decimals, 10) : 2;
4676
- const unit = rt.style.legendunits || rt.style.units || '';
4677
- const ranges = Array.isArray(rt.style.ranges) && rt.style.ranges.length ? rt.style.ranges.map(Number) : null;
4678
- const vMin = ranges ? ranges[0] : rt._valueMin, vMax = ranges ? ranges[ranges.length - 1] : rt._valueMax;
4679
- let html = '<div style="display:grid;grid-template-columns:repeat(' + n + ',1fr)' + (withAlpha ? ' auto' : '') + ';gap:1px;align-items:center;">';
4680
- for (let line = 0; line < nLines; line++) {
4681
- for (let c = 0; c < n; c++) {
4682
- const ix = flatFlag(rt.flags, 'INVERT') ? n - c - 1 : c;
4683
- const rgb = rt.categoryColorsRgb[ix];
4684
- const part = rt.partsA[ix];
4685
- const title = part ? flatFormatValue(part.min, dec, '') + unit + ' ... ' + flatFormatValue(part.max, dec, '') + unit : '';
4686
- const marked = rt._markedClasses.has(ix);
4687
- html += '<span class="ix-legend-row" data-idx="' + ix + '" title="' + title + '" style="cursor:pointer;height:14px;'
4688
- + 'background:rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ');opacity:' + (1 / (line + 1)) + ';'
4689
- + (marked ? 'outline:2px solid ' + legendColors.fg + ';outline-offset:-2px;' : '') + '"></span>';
4690
- }
4691
- if (withAlpha) {
4692
- const txt = line === 0 ? flatFormatValue(rt._alphaMax, 0, 'BLANK')
4693
- : line === 1 ? '&#8595; ' + (rt.style.alphavalueunits || '') : flatFormatValue(rt._alphaMin || 0, 0, 'BLANK');
4694
- html += '<span style="padding-left:0.5em;white-space:nowrap;">' + txt + '</span>';
4695
- }
4696
- }
4697
- html += '</div><div style="display:flex;justify-content:space-between;margin-top:3px;' + (withAlpha ? 'margin-right:4em;' : '') + '">'
4698
- + '<span>' + flatFormatValue(vMin, dec, '') + (unit ? ' ' + unit : '') + '</span>'
4699
- + '<span>' + flatFormatValue(vMax, dec, '') + (unit && unit.length <= 3 ? ' ' + unit : '') + '</span></div>';
4700
- rowsEl.innerHTML = html;
4701
- rowsEl.querySelectorAll('.ix-legend-row').forEach(cell => {
4702
- cell.addEventListener('click', () => {
4703
- 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);
4706
- });
4707
- });
4708
- };
4709
- } else
4710
- renderRows = function() {
4711
- // flat drops the rows without a count once any row has one
4712
- // (legend.js 754-760, 787-789: fCountBars && !count)
4713
- rowsEl.innerHTML = order.filter(i => !(maxTotal > 0) || totals[i]).map(i => {
4714
- const rgb = rt.categoryColorsRgb[i];
4715
- const color = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
4716
- const marked = rt._markedClasses.has(i);
4717
- const dimmed = rt._markedClasses.size > 0 && !marked;
4718
- const rowStyle = 'padding:4px 3px;border-radius:4px;cursor:pointer;opacity:' + (dimmed ? 0.4 : 1) + ';'
4719
- + 'background:' + (marked ? legendColors.rowMarked : 'transparent') + ';';
4720
- if (isSimple) {
4721
- // Swatch + label only, no bar/value — see rowsEl's own
4722
- // comment above for why (SIMPLELEGEND).
4723
- return '<div class="ix-legend-row" data-idx="' + i + '" style="' + rowStyle
4724
- + 'display:inline-flex;align-items:center;gap:5px;padding:4px 8px;">'
4725
- + '<span style="flex:0 0 8px;width:8px;height:8px;border-radius:50%;background:' + color + ';"></span>'
4726
- + '<span>' + labels[i] + '</span>'
4727
- + '</div>';
4728
- }
4729
- const pct = maxTotal ? Math.max(0, Math.min(100, (totals[i] / maxTotal) * 100)) : 0;
4730
- if (isCompact) {
4731
- // Same two-line label/bar/value shape as full-width bar
4732
- // mode below, just content-sized (flex:0 0 auto, no
4733
- // min-width:0 flex-1 track) so multiple items pack onto
4734
- // one line inside rowsEl's own flex-wrap — see
4735
- // COMPACTLEGEND's own comment on rowsEl above.
4736
- const barPx = Math.max(2, (pct / 100) * COMPACT_MAX_BAR_PX);
4737
- return '<div class="ix-legend-row" data-idx="' + i + '" style="' + rowStyle + 'flex:0 0 auto;">'
4738
- + '<div style="margin-bottom:3px;white-space:nowrap;">' + labels[i] + '</div>'
4739
- + '<div style="display:flex;align-items:center;gap:6px;">'
4740
- + '<span style="flex:0 0 8px;width:8px;height:8px;border-radius:50%;background:' + color + ';"></span>'
4741
- + '<span style="flex:0 0 auto;width:' + barPx + 'px;height:6px;background:' + color + ';border-radius:3px;"></span>'
4742
- + '<span style="flex:0 0 auto;white-space:nowrap;">' + rt._formatTooltipValue(totals[i]) + legendUnit + '</span>'
4743
- + '</div>'
4744
- + '</div>';
4745
- }
4746
- return '<div class="ix-legend-row" data-idx="' + i + '" style="' + rowStyle + '">'
4747
- + '<div style="margin-bottom:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + labels[i] + '</div>'
4748
- + '<div style="display:flex;align-items:center;gap:6px;">'
4749
- + '<span style="flex:0 0 8px;width:8px;height:8px;border-radius:50%;background:' + color + ';"></span>'
4750
- + '<span style="flex:1 1 auto;min-width:0;height:6px;background:' + legendColors.track + ';border-radius:3px;overflow:hidden;">'
4751
- + '<span style="display:block;height:100%;width:' + pct + '%;background:' + color + ';border-radius:3px;"></span>'
4752
- + '</span>'
4753
- + '<span style="flex:0 0 auto;text-align:right;min-width:60px;">' + rt._formatTooltipValue(totals[i]) + legendUnit + '</span>'
4754
- + '</div>'
4755
- + '</div>';
4756
- }).join('');
4757
- // GL-PORT COMPAT: real ixmaps-flat's row markup wires its
4758
- // click handler inline (onclick="ixmaps.markThemeClass(...)"
4759
- // — legend.js:817), toggle decided by the caller. Same
4760
- // split here: the toggle check lives in this click
4761
- // 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.
4766
- rowsEl.querySelectorAll('.ix-legend-row').forEach(rowEl => {
4767
- rowEl.addEventListener('click', () => {
4768
- 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);
4771
- });
4772
- });
4773
- };
4774
-
4775
- // Recompute+re-render on EVERY redraw, not just mark toggles —
4776
- // this is what makes the legend map-view-aware: a plain pan/
4777
- // zoom/rotate fires this too (via the map's own 'move'
4778
- // listener -> scheduleRefresh -> notifyRedraw, debounced the
4779
- // same 400ms as the facets sidebar's own onRedraw use), same
4780
- // signal already used elsewhere in this file for "the visible
4781
- // data just changed, recompute".
4782
- engineApi.onRedraw(() => { computeTotals(); renderRows(); });
4783
- }
4784
-
4785
- // rt._triggerRedraw: see its own doc comment on LayerRuntime
4786
- // (set here rather than at construction time since refresh()
4787
- // doesn't exist yet when the runtime is built). Kept
4788
- // unconditional (even for TEXTLEGEND, which never registers
4789
- // the onRedraw recompute above) — a page can still call
4790
- // ixmaps.markThemeClass/unmarkThemeClass directly with no
4791
- // visual row list to click, and that should still redraw the
4792
- // MAP's own dim/isolate effect even without a legend UI for
4793
- // it, matching real-engine global-API availability regardless
4794
- // of legend style.
4795
- rt._triggerRedraw = () => { refresh(); };
4796
-
4797
- // Header row (title/snippet + collapse toggle) always stays
4798
- // visible; everything else lives in `bodyEl`, hidden/shown as
4799
- // a single unit by the toggle below — collapsing shows just
4800
- // the title bar, matching the real engine's own fold/unfold
4801
- // behavior (ixmaps.legendState + the "legend-folded" CSS
4802
- // class, ui/js/tools/legend.js) even though this port toggles
4803
- // via a plain display swap on one wrapper div rather than a
4804
- // shared stylesheet class (this panel has no external CSS at
4805
- // all — everything here is inline-styled, unlike the real
4806
- // engine's DOM/CSS-class-driven legend).
4807
- const header = document.createElement('div');
4808
- header.style.cssText = 'display:flex;align-items:flex-start;justify-content:space-between;gap:6px;';
4809
- const headerText = document.createElement('div');
4810
- headerText.style.cssText = 'min-width:0;flex:1 1 auto;';
4811
- let headerHtml = '';
4812
- if (rt.meta.title) headerHtml += '<div style="font-weight:600;font-size:13px;margin-bottom:2px;">' + rt.meta.title + '</div>';
4813
- if (rt.meta.snippet) headerHtml += '<div style="opacity:0.75;">' + rt.meta.snippet + '</div>';
4814
- headerText.innerHTML = headerHtml;
4815
- header.appendChild(headerText);
4816
- const collapseBtn = document.createElement('button');
4817
- collapseBtn.type = 'button';
4818
- collapseBtn.style.cssText = 'flex:0 0 auto;background:transparent;border:none;color:inherit;'
4819
- + 'font-size:14px;line-height:1;cursor:pointer;padding:2px 4px;opacity:0.7;';
4820
- header.appendChild(collapseBtn);
4821
- panel.appendChild(header);
4822
-
4823
- const bodyEl = document.createElement('div');
4824
- bodyEl.style.cssText = 'display:flex;flex-direction:column;min-height:0;flex:1 1 auto;margin-top:8px;';
4825
- panel.appendChild(bodyEl);
4826
-
4827
- // Collapsed by default under the real engine's own narrow-
4828
- // screen legend threshold (ui/js/tools/legend.js: `if
4829
- // (window.innerWidth < 500) ixmaps.legend.hide()`) — reusing
4830
- // that concrete precedent rather than picking an arbitrary
4831
- // "mobile" breakpoint of this port's own invention. Checked
4832
- // once at panel-build time, not on resize — matching the real
4833
- // engine's own one-shot-at-redraw-time check, not a live
4834
- // matchMedia listener.
4835
- const MOBILE_LEGEND_BREAKPOINT = 500;
4836
- let collapsed = legendFolded || window.innerWidth < MOBILE_LEGEND_BREAKPOINT;
4837
- function applyCollapsed() {
4838
- bodyEl.style.display = collapsed ? 'none' : 'flex';
4839
- collapseBtn.textContent = collapsed ? '▸' : '▾';
4840
- collapseBtn.title = collapsed ? 'Expand legend' : 'Collapse legend';
4841
- }
4842
- collapseBtn.addEventListener('click', () => { collapsed = !collapsed; applyCollapsed(); });
4843
- applyCollapsed();
4844
- rt._setLegendCollapsed = c => { collapsed = !!c; applyCollapsed(); }; // setLegendOption
4845
-
4846
- // Selection/filter by field — opt-in via .style({legendfilter:
4847
- // "<field>"}), e.g. "country_long" on the power-plants sample.
4848
- // A NEW, ixmaps-gl-only convention: real ixmaps-flat's own
4849
- // style.filterfield names the DEFAULT field an unqualified
4850
- // .filter("text") search term matches against (maptheme.js:
4851
- // 1275-1276/7359-7362) — a different concept entirely, so this
4852
- // deliberately uses its own name rather than overloading that
4853
- // one. Options come from the FULL dataset (a world-spanning
4854
- // bbox, not the current viewport) and stay fixed regardless of
4855
- // pan/zoom — unlike the map-view-aware category totals below,
4856
- // a picker whose own choices kept shrinking as you panned
4857
- // would make it impossible to select a country not currently
4858
- // in view. Selecting a value reuses the engine's own existing
4859
- // facet-filter primitive (engineApi.setFacetFilter/
4860
- // clearFacetFilter — already propagates to sibling runtimes
4861
- // sharing the same data source and calls refresh()), so this
4862
- // is UI wiring only, no new filtering logic.
4863
- const filterField = rt.style.legendfilter;
4864
- if (filterField) {
4865
- const facet = engineApi.getFacets(rt.name, [filterField], { bbox: [-180, -85, 180, 85] })[0];
4866
- const values = (facet && facet.type === 'textual')
4867
- ? facet.values.slice().sort((a, b) => String(a).localeCompare(String(b)))
4868
- : [];
4869
- const filterEl = document.createElement('select');
4870
- filterEl.style.cssText = 'width:100%;margin-bottom:8px;background:' + legendColors.selectBg + ';'
4871
- + 'color:' + legendColors.fg + ';border:1px solid ' + legendColors.selectBorder + ';border-radius:4px;padding:4px 6px;font:inherit;';
4872
- filterEl.innerHTML = '<option value="">All (' + values.length + ')</option>'
4873
- + values.map(v => '<option value="' + escapeHtml(v) + '">' + escapeHtml(v) + '</option>').join('');
4874
- filterEl.addEventListener('change', () => {
4875
- if (filterEl.value) engineApi.setFacetFilter(rt.name, filterField, filterEl.value);
4876
- else engineApi.clearFacetFilter(rt.name, filterField);
4877
- });
4878
- bodyEl.appendChild(filterEl);
4879
- }
4889
+ // longer debounce, separate from the map's own 150ms layer throttle
4890
+ // above — measured cost: getFacets() over every configured field
4891
+ // plus the sidebar DOM rebuild can run ~55-70ms at a zoomed-out
4892
+ // viewport (tens of thousands of in-view points), vs ~5ms for
4893
+ // refreshLayers() itself. Running that on every throttled pan/zoom
4894
+ // tick visibly competed with the map's own frame budget; recomputing
4895
+ // it only once movement settles (this fires once, NOTIFY_REDRAW_MS
4896
+ // after the last 'move' event) keeps panning smooth and matches the
4897
+ // real ixmaps engine's own shape — htmlgui_onDrawTheme (facets) fires
4898
+ // once per completed theme redraw, not per intermediate pan frame.
4899
+ const NOTIFY_REDRAW_MS = 400;
4900
+ let notifyRedrawTimer = null;
4901
+ function scheduleNotifyRedraw() {
4902
+ clearTimeout(notifyRedrawTimer);
4903
+ notifyRedrawTimer = setTimeout(notifyRedraw, NOTIFY_REDRAW_MS);
4904
+ }
4880
4905
 
4881
- if (!isTextOnly) { bodyEl.appendChild(rowsScroll); renderRows(); }
4882
-
4883
- if (rt.meta.description) {
4884
- const desc = document.createElement('div');
4885
- desc.style.cssText = 'margin-top:8px;font-size:11px;opacity:0.8;';
4886
- // Raw HTML, matching the real engine's own description
4887
- // rendering and this engine's existing .legend(html)/
4888
- // tooltip conventions (e.g. _buildTooltipContext) — real
4889
- // pages already embed their own source-citation markup
4890
- // inside meta.description (see the power-plants sample).
4891
- desc.innerHTML = rt.meta.description;
4892
- bodyEl.appendChild(desc);
4893
- }
4906
+ map.on('move', scheduleRefresh);
4907
+ window.addEventListener('resize', () => map.resize());
4894
4908
 
4895
- // Chart-size slider — real engine's own range (legend.js:
4896
- // 3196-3202), 100% == scale:1, wired to changeThemeStyle(
4897
- // themeId,"scale:"+pct/100,"set")+redrawTheme there; this
4898
- // engine's equivalent is rt.setStyle({scale}) + refresh(),
4899
- // the same primitive engineApi.setThemeStyle uses above.
4900
- // CHOROPLETH themes get an OPACITY slider instead, as in flat
4901
- // (legend.js ~3190: 0-100%, fillopacity·100, default 90;
4902
- // changeThemeStyle("fillopacity:"+pct/100,"set") + redraw).
4903
- // (flat labels a VECTOR theme's size slider "Line width" — gl has
4904
- // no VECTOR themes.)
4905
- // Flat tests the words (legend.js 2533-2535): a "CHOROPLETHE"
4906
- // theme has neither slider.
4907
- const typeWords = rt.flags.typeString != null ? rt.flags.typeString : [...rt.flags].join('|');
4908
- const isOpacitySlider = /\bCHOROPLETH\b/.test(typeWords);
4909
- const hasSlider = isOpacitySlider || /\bCHART\b|\bBUBBLE\b|\bDOT\b/.test(typeWords);
4910
- if (hasSlider) {
4911
- const sliderRow = document.createElement('div');
4912
- sliderRow.style.cssText = 'margin-top:10px;font-size:11px;opacity:0.8;';
4913
- const fillPct = Math.round(styleNum(rt.style.fillopacity) * 100);
4914
- const initialPct = isOpacitySlider
4915
- ? (Number.isFinite(fillPct) ? Math.max(0, Math.min(100, fillPct)) : 90)
4916
- : Math.round((styleNum(rt.style.scale) || 1) * 100);
4917
- const sliderLabel = isOpacitySlider ? 'Opacity' : 'Chart size';
4918
- sliderRow.innerHTML = sliderLabel + ': <span class="ix-legend-scale-val">' + initialPct + '</span>%';
4919
- bodyEl.appendChild(sliderRow);
4920
- const slider = document.createElement('input');
4921
- slider.type = 'range';
4922
- slider.min = isOpacitySlider ? '0' : '25';
4923
- slider.max = isOpacitySlider ? '100' : '200';
4924
- slider.value = String(initialPct);
4925
- slider.style.cssText = 'width:100%;margin-top:2px;';
4926
- slider.addEventListener('input', () => {
4927
- const pct = parseInt(slider.value, 10);
4928
- sliderRow.querySelector('.ix-legend-scale-val').textContent = pct;
4929
- rt.setStyle(isOpacitySlider ? { fillopacity: pct / 100 } : { scale: pct / 100 });
4930
- refresh();
4909
+ // GL-PORT COMPAT: real ixmaps-flat's map.on("layerdraw", cb) fires
4910
+ // once per THEME as it finishes drawing, handing the callback an
4911
+ // event object carrying that theme's own id (e.constructor.id).
4912
+ // This engine has no equivalent per-theme draw event — onRedraw
4913
+ // fires once per REFRESH of the whole map (see its own comment,
4914
+ // above) — so "layerdraw" is approximated by firing the registered
4915
+ // callback once per CURRENT runtime, synthesizing {id: rt.name},
4916
+ // every time onRedraw fires. A page reacting to "layerdraw" by
4917
+ // reading THAT theme's own state (e.g. recomputing a stats sidebar
4918
+ // via getThemeObj/getFacets — see the global compat shims, below)
4919
+ // gets the same practical effect: fresh numbers after every redraw,
4920
+ // for every theme, without needing the page rewritten around a
4921
+ // finer-grained event this engine doesn't produce.
4922
+ const layerdrawCbs = (this._pendingEvents || [])
4923
+ .filter(([event]) => event === 'layerdraw')
4924
+ .map(([, cb]) => cb);
4925
+ if (layerdrawCbs.length) {
4926
+ engineApi.onRedraw(() => {
4927
+ runtimes.forEach(rt => {
4928
+ layerdrawCbs.forEach(cb => {
4929
+ try { cb({ id: rt.name }); } catch (err) { console.error('[ixmaps-gl] "layerdraw" callback failed:', err); }
4931
4930
  });
4932
- bodyEl.appendChild(slider);
4933
- }
4934
- }
4935
- };
4936
- runtimes.forEach(rt => addLegendPanel(rt));
4931
+ });
4932
+ });
4937
4933
  }
4938
4934
 
4935
+ // the native legend: createLegend (its four hooks are what the rest
4936
+ // of the map calls — panels, the legend option, sub-themes, layout)
4937
+ ({ addLegendPanel, setLegendOption, updateSubTheme, layoutLegends } =
4938
+ createLegend({ builder, map, el, overlay, runtimes, engineApi, built, refresh, scheduleRefresh, notifyRedraw }));
4939
+
4939
4940
  _lastMapApi = engineApi;
4940
4941
  // ?ixgl-debug: ixmaps.__glDebug.check() reports, for the moment it is
4941
4942
  // called, every theme's geometry problems (invalid or mixed-dimension
@@ -5627,7 +5628,7 @@
5627
5628
  }
5628
5629
  // maptheme.js formatValue's NOBREAKS switch (26368): theme min > 999 and max < 3000
5629
5630
  function flatNoBreaks(vMin, vMax) {
5630
- return vMin > 999 && vMax < 3000;
5631
+ return Number.isFinite(vMin) && Number.isFinite(vMax) && vMin > 999 && vMax < 3000;
5631
5632
  }
5632
5633
 
5633
5634
  // flat's chart position (maptheme.js 24441-24530 and 24603, the non-PLOT,
@@ -5737,9 +5738,13 @@
5737
5738
  // scale gate (maptheme.js 2609-2611, 8250-8260): the theme is hidden at
5738
5739
  // scales above featureupper and at or below featurelower ("1:200000" or
5739
5740
  // a bare denominator, __scanScaleValue 2021)
5741
+ // a scale style value ("1:200000" or a bare denominator, flat's
5742
+ // __scanScaleValue, maptheme.js 2021) as its denominator; 0 when unset
5743
+ function scaleDenom(v) {
5744
+ return (v == null || v === '' ? 0 : Number(String(v).includes(':') ? String(v).split(':')[1] : v)) || 0;
5745
+ }
5740
5746
  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);
5747
+ const upper = scaleDenom(style.featureupper), lower = scaleDenom(style.featurelower);
5743
5748
  if (!upper && !lower) return false;
5744
5749
  const scale = scaleDenominatorAt(zoom);
5745
5750
  return (lower && scale <= lower) || (upper && scale > upper);
@@ -5750,9 +5755,8 @@
5750
5755
  // chartupper and at or below chartlower; layerupper / layerlower stand in
5751
5756
  // for them when unset (1774-1779)
5752
5757
  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);
5758
+ const upper = scaleDenom(style.chartupper != null ? style.chartupper : style.layerupper);
5759
+ const lower = scaleDenom(style.chartlower != null ? style.chartlower : style.layerlower);
5756
5760
  if (!upper && !lower) return false;
5757
5761
  const scale = scaleDenominatorAt(zoom);
5758
5762
  return !!((upper && scale > upper) || (lower && scale <= lower));
@@ -5761,8 +5765,7 @@
5761
5765
  // 1726-1734, 16986-16990): the GLOW halo is drawn at scales at or below
5762
5766
  // glowupper and at or above glowlower (both inclusive)
5763
5767
  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);
5768
+ const upper = scaleDenom(style.glowupper), lower = scaleDenom(style.glowlower);
5766
5769
  if (!upper && !lower) return false;
5767
5770
  const scale = scaleDenominatorAt(zoom);
5768
5771
  return !!((upper && scale > upper) || (lower && scale < lower));
@@ -5784,8 +5787,7 @@
5784
5787
  const on = style.shadow === true || style.shadow === 'true' || style.shadow === 1 || style.shadow === '1';
5785
5788
  if (!on) return false;
5786
5789
  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);
5790
+ const upper = scaleDenom(style.shadowupper), lower = scaleDenom(style.shadowlower);
5789
5791
  const scale = scaleDenominatorAt(zoom);
5790
5792
  return !((upper && scale > upper) || (lower && scale < lower));
5791
5793
  }
@@ -5799,8 +5801,7 @@
5799
5801
  // flat's BOX block (maptheme.js 18758-18760): the box — and its title —
5800
5802
  // is removed at scales above boxupper or below boxlower
5801
5803
  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);
5804
+ const upper = scaleDenom(style.boxupper), lower = scaleDenom(style.boxlower);
5804
5805
  const scale = scaleDenominatorAt(zoom);
5805
5806
  return !!((upper && scale > upper) || (lower && scale < lower));
5806
5807
  }
@@ -6133,8 +6134,9 @@
6133
6134
  // functionally deleted.
6134
6135
  const DIMMED_ICON_ALPHA = 8;
6135
6136
 
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
6137
+ // deck.gl's IconLayer auto-packing atlas (icon-manager.ts, read and
6138
+ // measured on 8.9.35; the guards below are kept for 9.x, not re-measured
6139
+ // there) has NO eviction and NO cap of its own: its texture is 1024px wide (fixed) but
6138
6140
  // grows TALLER via power-of-two resize forever, for every distinct icon
6139
6141
  // id ever seen, for the lifetime of the layer — confirmed by reading
6140
6142
  // that file directly. There is no "atlas full" code path; it just keeps
@@ -6226,6 +6228,74 @@
6226
6228
  };
6227
6229
  }
6228
6230
 
6231
+ // CHART|SYMBOL|SEQUENCE (flat maptheme.js 21184-21300, 21790-21925,
6232
+ // 22296-22395): one symbol per category part of a chart, in category
6233
+ // order or sorted (SORT: DOWN biggest first, UP smallest first), parts
6234
+ // of value 0 left out. radiusOf(v) is the part's radius (flat: max radius
6235
+ // · (v / max value)^(1/sizepow)). Placement:
6236
+ // - STAR: the first part is the center (star radius R = its radius); each
6237
+ // further part (with UP every part) sits on a circle around it, at
6238
+ // R + r (×1.5 EXPAND, ×2 EXPANDMAX, ×rangescale), stepping the angle
6239
+ // by f = 90° − asin(√((R+r)² − r²) / (R+r)) before and after it, so
6240
+ // neighbours touch; UP: radius R·(1 + parts/5), the angle step of the
6241
+ // 7th part on divided by 1 + 2r/maxRadius; with EXPAND|SORT|UP,
6242
+ // R = (EXPANDMAX ? 2 : 0.5) · maxRadius · √(biggest / max value)
6243
+ // (LINEAR/SIZEP1: linear), COMPRESS halves it (COMPRESSMAX quarter)
6244
+ // - HORZ: side by side from the center to the right
6245
+ // - otherwise (or CENTER): all on the center
6246
+ // Returns the parts in drawing order: { i, v, r, x, y } (pixels, y down).
6247
+ function sequenceLayout(counts, flags, radiusOf, opts = {}) {
6248
+ const has = f => flatFlag(flags, f);
6249
+ const word = w => [...flags].some(f => new RegExp('\\b' + w + '\\b').test(f));
6250
+ let order = counts.map((v, i) => ({ i, v }));
6251
+ if (word('SORT')) {
6252
+ if (word('UP')) order.sort((a, b) => a.v - b.v);
6253
+ else if (word('DOWN')) order.sort((a, b) => b.v - a.v);
6254
+ }
6255
+ const parts = [];
6256
+ const star = has('STAR') && !has('CENTER'), horz = has('HORZ') && !has('CENTER');
6257
+ const up = word('UP');
6258
+ const maxR = opts.maxRadius || 0;
6259
+ const nStarParts = opts.nParts || counts.length;
6260
+ let angle = 0, starR = null, x = 0;
6261
+ order.forEach((p, k) => {
6262
+ if (!p.v) return;
6263
+ const r = radiusOf(p.v);
6264
+ if (!(r > 0)) return;
6265
+ let px = 0, py = 0;
6266
+ if (star) {
6267
+ if (starR === null) {
6268
+ starR = r;
6269
+ if (has('EXPAND') && word('SORT') && up) {
6270
+ const big = order[order.length - 1].v, mv = opts.maxValue || big || 1;
6271
+ starR = (has('EXPANDMAX') ? 2 : 0.5) * maxR * (has('LINEAR') || has('SIZEP1') ? big / mv : Math.sqrt(big / mv));
6272
+ }
6273
+ if (has('COMPRESS')) starR *= has('COMPRESSMAX') ? 0.25 : 0.5;
6274
+ }
6275
+ if (parts.length > 0 || up) {
6276
+ const c = starR + r;
6277
+ const b = Math.sqrt(Math.max(0, c * c - r * r));
6278
+ let f = 90 - Math.asin(Math.min(1, b / c)) / Math.PI * 180;
6279
+ let dist = starR + r;
6280
+ if (up) {
6281
+ if (k > 5) f /= (1 + 2 * (r / (maxR || r)));
6282
+ dist = starR * (1 + nStarParts / 5);
6283
+ }
6284
+ if (opts.rangeScale) dist *= opts.rangeScale;
6285
+ else dist = has('EXPANDMAX') ? dist * 2 : (has('EXPAND') ? dist * 1.5 : dist);
6286
+ angle += f;
6287
+ px = Math.cos(angle / 180 * Math.PI) * dist;
6288
+ py = Math.sin(angle / 180 * Math.PI) * dist;
6289
+ angle += f;
6290
+ }
6291
+ } else if (horz) {
6292
+ x += r; px = x; x += r;
6293
+ }
6294
+ parts.push({ i: p.i, v: p.v, r, x: px, y: py });
6295
+ });
6296
+ return parts;
6297
+ }
6298
+
6229
6299
  // ---------------------------------------------------------------
6230
6300
  // Classification — pure functions (no LayerRuntime state): class
6231
6301
  // breaks for a numeric field, and which class a value falls in.
@@ -7180,7 +7250,7 @@
7180
7250
  key = `${snapped.x}:${snapped.y}`;
7181
7251
  }
7182
7252
  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); }
7253
+ 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
7254
  cell.sumX += p.x; cell.sumY += p.y; cell.n++;
7185
7255
  cell.value += f.properties.value;
7186
7256
  if (f.properties.classValue !== undefined) cell.classValue += f.properties.classValue;
@@ -7202,6 +7272,9 @@
7202
7272
  ? { cluster: true, point_count: cell.n, value: cell.value, ...(hasClass ? { classValue: cell.classValue } : {}) }
7203
7273
  : cell.first.properties;
7204
7274
  if (cell.series && cell.n > 1) props = Object.assign({}, props, { series: cell.series });
7275
+ // a field aggregation's cell key (the field value): the categories of
7276
+ // one cell are merged by it (groupCoLocated), not by their positions
7277
+ if (field) props = Object.assign({}, props, { aggKey: cell.key });
7205
7278
  if (post) {
7206
7279
  props = Object.assign({}, props);
7207
7280
  if (hasClass) props.classValue = post(cell.n > 1 ? cell.classValue : props.classValue, cell.value100, props.series);
@@ -7234,7 +7307,7 @@
7234
7307
  }
7235
7308
  }
7236
7309
 
7237
- function groupCoLocated(clusterFeatures, zoom, clusterRadiusPx, nCategories, flags) {
7310
+ function groupCoLocated(clusterFeatures, zoom, clusterRadiusPx, nCategories, flags, atCellCenter = false) {
7238
7311
  // Merge tolerance MUST track the same theme-driven, scale-dependent
7239
7312
  // aggregation width the clustering itself just used (clusterRadiusPx,
7240
7313
  // set by _ensureClusterIndices right before this runs) — not an
@@ -7256,9 +7329,11 @@
7256
7329
  // recomputes ptPos as the mean of ptPosA regardless of which
7257
7330
  // grid produced the grouping key).
7258
7331
  const snapped = cellPx ? snapToAggregationGrid(p.x, p.y, cellPx, flags, zoom) : p;
7259
- const key = `${snapped.x}:${snapped.y}`;
7332
+ // a field aggregation's categories each sit at the mean of their own
7333
+ // records — merged by the field value, not by (float) position
7334
+ const key = f.properties.aggKey != null ? f.properties.aggKey : `${snapped.x}:${snapped.y}`;
7260
7335
  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); }
7336
+ 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
7337
  cell.sumX += p.x; cell.sumY += p.y; cell.n++;
7263
7338
  cell.counts[f.properties.cat] += f.properties.value;
7264
7339
  if (f.properties.classValue !== undefined) cell.classTotal = (cell.classTotal || 0) + f.properties.classValue;
@@ -7269,7 +7344,8 @@
7269
7344
  cell.recordCounts[f.properties.cat] += (f.properties.point_count || 1);
7270
7345
  });
7271
7346
  return Array.from(cells.values()).map(cell => {
7272
- const ll = worldPixelToLngLat(cell.sumX / cell.n, cell.sumY / cell.n, zoom);
7347
+ // without RELOCATE flat's cell item sits at the cell center
7348
+ const ll = atCellCenter && cellPx ? worldPixelToLngLat(cell.cx, cell.cy, zoom) : worldPixelToLngLat(cell.sumX / cell.n, cell.sumY / cell.n, zoom);
7273
7349
  return {
7274
7350
  geometry: { type: 'Point', coordinates: [ll.lng, ll.lat] },
7275
7351
  properties: { counts: cell.counts, total: cell.counts.reduce((a, c) => a + c, 0), recordCounts: cell.recordCounts, ...(cell.classTotal !== undefined ? { classTotal: cell.classTotal } : {}) }
@@ -7424,7 +7500,7 @@
7424
7500
  this._gridRefLat = (minLat <= maxLat) ? (minLat + maxLat) / 2 : 0;
7425
7501
  }
7426
7502
 
7427
- if (this.flags.has('DOMINANT') && this.binding.value) {
7503
+ if (this.flags.has('DOMINANT') && this.binding.value && !isAggregatedCategoricalChoropleth(this)) {
7428
7504
  // .type("CHOROPLETH|DOMINANT") — a MULTI-field bound value
7429
7505
  // (binding.value is a pipe-joined field list, e.g. one CSV column
7430
7506
  // per age band for the same year/sex — same pipe convention
@@ -7774,6 +7850,26 @@
7774
7850
  }
7775
7851
 
7776
7852
  _rebuildActiveFeatures() {
7853
+ // An AGGREGATE CATEGORICAL choropleth filters the RECORDS of each
7854
+ // polygon (as its .filter() does before the join), then sums them —
7855
+ // not the polygons by their first record
7856
+ if (isAggregatedCategoricalChoropleth(this)) {
7857
+ const expr = this._runtimeFilterExpr;
7858
+ const clauses = Array.from(this.facetFilters.entries());
7859
+ if (!expr && !clauses.length) { this._activeFeatures = null; return; }
7860
+ const pred = expr ? flatFilterPredicate(String(expr)) : null;
7861
+ this._activeFeatures = this.features.map(f => {
7862
+ const rows = f.properties && f.properties[AGGREGATED_ROWS];
7863
+ if (!rows) return f;
7864
+ const kept = rows.filter(r => (!pred || pred(r)) && clauses.every(([field, clause]) => matchesFacetClause(r[field], clause)));
7865
+ const properties = kept.length ? Object.assign({}, kept[0]) : {};
7866
+ if (kept.length) Object.defineProperty(properties, AGGREGATED_ROWS, { value: kept });
7867
+ const out = { type: 'Feature', geometry: f.geometry, properties };
7868
+ if (f._flatAreaKm2 !== undefined) out._flatAreaKm2 = f._flatAreaKm2;
7869
+ return out;
7870
+ });
7871
+ return;
7872
+ }
7777
7873
  // Base set: this.features narrowed by the runtime WHERE-filter
7778
7874
  // (changeThemeStyle's own mechanism, see _runtimeFilterExpr's own
7779
7875
  // comment) if one is active — reusing applyWhereFilter, the SAME
@@ -8470,8 +8566,11 @@
8470
8566
  // unlike CHOROPLETH, there's no per-feature classification here.
8471
8567
  // flat puts fillopacity into the shape's fill-opacity only
8472
8568
  // (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],
8569
+ // (a fill color's own alpha — rgba(…, 0.5) — times fillopacity)
8570
+ getFillColor: filled ? [...hexOrNamedToRgb(raw).slice(0, 3), Math.round(255 * cssColorAlpha(raw) * (styleNum(this.style.fillopacity) || 1))] : [0, 0, 0, 0],
8571
+ getLineColor: this.style.linecolor
8572
+ ? withAlpha(hexOrNamedToRgb(styleLineColor(this.style.linecolor)), cssColorAlpha(styleLineColor(this.style.linecolor)))
8573
+ : [130, 130, 130],
8475
8574
  lineWidthMinPixels: styleNum(this.style.linewidth) || 1,
8476
8575
  // Confirmed live (2026-09-21, deck.gl v9.4/MapLibre-globe upgrade):
8477
8576
  // this layer's fill rendered with a moire/hatching pattern of
@@ -8540,9 +8639,10 @@
8540
8639
  // — plain/PERCENTOFMEAN/DEVIATION); or COMPOSECOLOR's per-polygon
8541
8640
  // BLEND across the same piped fields (_resolveComposedColor — no
8542
8641
  // 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
8642
+ // blended color directly); AGGREGATE CATEGORICAL on one field — the
8643
+ // dominant category of each polygon's records (aggregatedCategoricalClass).
8644
+ // A plain CATEGORICAL choropleth (exact match of one row's value) is not
8645
+ // implemented. Geometry + properties are already the joined
8546
8646
  // FeatureCollection from joinChoroplethFeatures. `zoom` is only used
8547
8647
  // for style.fillopacity:"auto" (see resolveAutoFillOpacity) — every
8548
8648
  // other branch here is zoom-independent.
@@ -8565,7 +8665,37 @@
8565
8665
  // DOPACITY/COMPOSECOLOR branches.
8566
8666
  const dopacityActive = !isComposeColor &&
8567
8667
  (this.flags.has('DOPACITY') || this.flags.has('DOPACITYMIN') || this.flags.has('DOPACITYMAX') || this.flags.has('DOPACITYMINMAX'));
8668
+ // AGGREGATE CATEGORICAL: every polygon's parts and dominant class
8669
+ // (aggregatedCategoricalClass), the biggest part per class over all
8670
+ // polygons for the opacity (flat's nMaxA)
8671
+ const aggCat = isAggregatedCategoricalChoropleth(this) && this.categoryIndexByLabel;
8672
+ let aggOf = null, aggMaxA = null;
8673
+ if (aggCat) {
8674
+ const nCats = this.categoryLabels.length;
8675
+ aggOf = new Map();
8676
+ aggMaxA = new Array(nCats).fill(0);
8677
+ source.forEach(f => {
8678
+ const rows = f.properties && f.properties[AGGREGATED_ROWS];
8679
+ if (!rows) return;
8680
+ const agg = aggregatedCategoricalClass(rows, this.binding, this.flags, this.categoryIndexByLabel, nCats);
8681
+ if (!agg) return;
8682
+ aggOf.set(f, agg);
8683
+ agg.parts.forEach((v, i) => { if (v > aggMaxA[i]) aggMaxA[i] = v; });
8684
+ });
8685
+ }
8568
8686
  const data = source.map(f => {
8687
+ if (aggCat) {
8688
+ const agg = aggOf.get(f);
8689
+ return {
8690
+ type: 'Feature',
8691
+ geometry: f.geometry,
8692
+ properties: {
8693
+ // no part above 0: no item in flat, not painted
8694
+ value: agg ? agg.value : undefined, raw: agg ? f.properties : {}, cat: agg ? agg.index : null,
8695
+ dopacityAlpha: agg && dopacityActive ? dominantDopacityAlpha(this.style, this.flags, agg.value, aggMaxA[agg.index]) : null
8696
+ }
8697
+ };
8698
+ }
8569
8699
  if (isComposeColor) {
8570
8700
  return {
8571
8701
  type: 'Feature',
@@ -8604,7 +8734,14 @@
8604
8734
  // evidence mode "isolate", the default (7715) — or grayed with
8605
8735
  // style.evidence "isolate_gray". ("highlight", flat's highlight list,
8606
8736
  // is shown grayed too here.) COMPOSECOLOR has no classes.
8607
- const lineRgb = this.style.linecolor ? hexOrNamedToRgb(styleLineColor(this.style.linecolor)) : [255, 255, 255];
8737
+ // linecolor "none": no outline; a class color "none" (an explicit
8738
+ // colorscheme list) leaves its polygons unpainted, an rgba() class
8739
+ // color keeps its alpha — as flat's SVG fill does
8740
+ const lineColorRaw = this.style.linecolor ? styleLineColor(this.style.linecolor) : null;
8741
+ const lineRgb = lineColorRaw ? withAlpha(hexOrNamedToRgb(lineColorRaw), cssColorAlpha(lineColorRaw)) : [255, 255, 255];
8742
+ const cs = this.style.colorscheme;
8743
+ const classAlpha = Array.isArray(cs) && cs.length && !/^\d+$/.test(String(cs[0])) ? cs.map(cssColorAlpha) : null;
8744
+ const alphaOfClass = i => (classAlpha && i != null && classAlpha[i] != null ? classAlpha[i] : 1);
8608
8745
  const evidenceMode = String(this.style.evidence || 'isolate');
8609
8746
  const unmarkedEvidence = d => {
8610
8747
  if (!this._markedClasses.size || this._onMarksChanged || this._markedClasses.has(d.properties.cat)) return null;
@@ -8624,7 +8761,8 @@
8624
8761
  const evidence = unmarkedEvidence(d);
8625
8762
  if (evidence === 'hide') return [0, 0, 0, 0];
8626
8763
  if (evidence === 'gray') return [...grayOf(rgb), 90];
8627
- return d.properties.dopacityAlpha != null ? [...rgb, Math.round(d.properties.dopacityAlpha * 255)] : rgb;
8764
+ const a = alphaOfClass(d.properties.cat);
8765
+ return d.properties.dopacityAlpha != null ? [...rgb.slice(0, 3), Math.round(d.properties.dopacityAlpha * a * 255)] : withAlpha(rgb, a);
8628
8766
  },
8629
8767
  // a per-polygon accessor only while a class is isolated
8630
8768
  getLineColor: this._markedClasses.size && !this._onMarksChanged && evidenceMode === 'isolate'
@@ -9568,6 +9706,14 @@
9568
9706
  });
9569
9707
 
9570
9708
  const doRelocate = this.flags.has('RELOCATE');
9709
+ // SEQUENCE: flat's AGGREGATE cell is ONE chart with a part per
9710
+ // category (RELOCATE only moves it to the records' mean position) —
9711
+ // every category of a cell, single records included, in one group
9712
+ if (flatFlag(this.flags, 'SEQUENCE') && this.flags.has('CATEGORICAL')) {
9713
+ const groups = groupCoLocated(clusterFeatures.concat(individual), effectiveZoom, this._clusterRadiusPx,
9714
+ this.categoryLabels ? this.categoryLabels.length : 1, this.flags, !doRelocate);
9715
+ return { individual: [], groups };
9716
+ }
9571
9717
  // without RELOCATE a cell's item already sits at the cell center
9572
9718
  // (aggregateOnGrid)
9573
9719
  const groups = doRelocate ? this._groupCoLocated(clusterFeatures, effectiveZoom) : clusterFeatures.map(f => {
@@ -9832,6 +9978,9 @@
9832
9978
  if (userLayers) return layers.concat(userLayers);
9833
9979
  }
9834
9980
  if (this.flags.has('LABEL')) return layers.concat(this._buildLabelChartLayers(combined, zoom, liveZoom, sizeValueOf));
9981
+ if (flatFlag(this.flags, 'SEQUENCE') && this.flags.has('CATEGORICAL') && this.flags.has('AGGREGATE')) {
9982
+ return layers.concat(this._buildSequenceChartLayers(combined, zoom, liveZoom));
9983
+ }
9835
9984
  const fillOpacity = styleNum(this.style.fillopacity) || 0.85;
9836
9985
  // .style({linecolor, linewidth}) — an individual icon's own border,
9837
9986
  // see _buildSingleIcon's own comment. Real ixmaps-flat symbol
@@ -10144,6 +10293,164 @@
10144
10293
  return layers;
10145
10294
  }
10146
10295
 
10296
+ // flat's nAllMaxValue for a SEQUENCE chart (maptheme.js 13211-13215):
10297
+ // the biggest category part of any chart at this aggregation — over
10298
+ // the whole dataset, not the view; cached per zoom
10299
+ _sequenceMaxValue(zoom) {
10300
+ if (this._seqMaxFor !== this._clusterIndices) { this._seqMaxFor = this._clusterIndices; this._seqMax = new Map(); }
10301
+ if (!this._seqMax.has(zoom)) {
10302
+ const { groups } = this._computeAggregatedItems([-180, -90, 180, 90], zoom);
10303
+ let max = 0;
10304
+ groups.forEach(g => g.properties.counts.forEach(c => { if (c > max) max = c; }));
10305
+ this._seqMax.set(zoom, max || 1);
10306
+ }
10307
+ return this._seqMax.get(zoom);
10308
+ }
10309
+
10310
+ // CHART|SYMBOL|SEQUENCE on an AGGREGATE CATEGORICAL theme: each cell is
10311
+ // one chart of a symbol per category part (sequenceLayout), as flat's
10312
+ // SYMBOL branch draws it (maptheme.js 21122-22400):
10313
+ // - part radius: max radius · (v / max value)^(1/sizepow), max value =
10314
+ // normalsizevalue, else the biggest part of any chart (21793)
10315
+ // - circle in the category color at fillopacity, stroked in linecolor
10316
+ // linewidth · unit · min(2r / max radius, 0.2) wide (21966)
10317
+ // - GLOW (and AURA): with SORT only around the first part, else around
10318
+ // every part (22009)
10319
+ // - marked legend classes: the other parts hidden, the marked ones
10320
+ // back on the center (14339-14420, evidence "isolate"); with
10321
+ // "isolate_gray" the others dimmed
10322
+ // - STAR: the center part drawn last, on top of its satellites (23145)
10323
+ // - VALUES: each part's value (COUNT: its records; valuefield: the class name)
10324
+ // - align / offsets as the SYMBOL branch: half size the normal radius
10325
+ // A part's pick object carries its chart's properties (tooltip).
10326
+ _buildSequenceChartLayers(charts, zoom, liveZoom) {
10327
+ const st = this.style;
10328
+ const seqMax = this._sequenceMaxValue(zoom);
10329
+ const partRadius = v => valueRadius(v, liveZoom, st, this.mapOptions, this.flags, seqMax);
10330
+ const unit = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(st.scale) || 1);
10331
+ const normalR = NORMAL_RADIUS_PX * unit;
10332
+ const nCats = this.categoryLabels ? this.categoryLabels.length : 1;
10333
+ const marking = this._markedClasses.size && !this._onMarksChanged;
10334
+ const gray = marking && String(st.evidence || 'isolate') === 'isolate_gray';
10335
+ const sorted = [...this.flags].some(f => /\bSORT\b/.test(f));
10336
+ const fillOpacity = styleNum(st.fillopacity) || 0.85;
10337
+ const borderRgb = st.linecolor && styleLineColor(st.linecolor) !== 'none' ? hexOrNamedToRgb(styleLineColor(st.linecolor)) : null;
10338
+ const lineWidth = styleNum(st.linewidth) || 1;
10339
+ const symbols = Array.isArray(st.symbols) ? st.symbols : null;
10340
+ const shapeOf = i => { const sh = symbols && (symbols[i] || symbols[0]); return /^(circle|square|diamond|triangle)$/.test(sh) ? sh : 'circle'; };
10341
+ const colorOf = i => this.categoryColorsRgb[i] || [128, 128, 128];
10342
+ const parts = [], glows = [];
10343
+ for (const d of charts) {
10344
+ let layout = sequenceLayout(d.properties.counts, this.flags, partRadius, { maxRadius: normalR, maxValue: seqMax, nParts: nCats, rangeScale: styleNum(st.rangescale) || 0 });
10345
+ if (!layout.length) continue;
10346
+ // GLOW belongs to the first part (sorted) or to every part, and
10347
+ // hides with it when its class is not marked
10348
+ const glowParts = new Set(sorted ? [layout[0].i] : layout.map(p => p.i));
10349
+ if (marking && !gray) {
10350
+ layout = layout.filter(p => this._markedClasses.has(p.i)).map(p => ({ ...p, x: 0, y: 0 }));
10351
+ if (!layout.length) continue;
10352
+ }
10353
+ // the chart's own ptNull.y: the last part's radius + 5 units (23204)
10354
+ d._seqLast = layout[layout.length - 1].r;
10355
+ const items = layout.map(p => {
10356
+ const item = { geometry: d.geometry, properties: d.properties, chart: d, part: p };
10357
+ if (glowParts.has(p.i)) glows.push(item);
10358
+ return item;
10359
+ });
10360
+ // STAR: the first part (its center) is put on top at the end (23145)
10361
+ if (flatFlag(this.flags, 'STAR') && items.length > 1) items.push(items.shift());
10362
+ parts.push(...items);
10363
+ }
10364
+ const alignOf = this._chartAlignFn(liveZoom, d => (d.chart || d)._seqLast || 0);
10365
+ const offsetOf = item => { const o = alignOf(item.chart); return [o[0] + item.part.x, o[1] + item.part.y]; };
10366
+ const alphaOf = item => (gray && !this._markedClasses.has(item.part.i) ? DIMMED_ICON_ALPHA : 255);
10367
+ const layers = [];
10368
+ if (glows.length && this.flags.has('GLOW') && !glowHiddenByScale(st, liveZoom)) {
10369
+ layers.push(new IconLayer({
10370
+ id: `ix-glow-${this.name}-g${this._iconGeneration}`,
10371
+ data: glows, pickable: false,
10372
+ getPosition: d => d.geometry.coordinates,
10373
+ getIcon: d => this._getGlowIcon(colorOf(d.part.i)),
10374
+ getSize: d => d.part.r * 11,
10375
+ getPixelOffset: offsetOf,
10376
+ getColor: d => [255, 255, 255, alphaOf(d)],
10377
+ sizeUnits: 'pixels', billboard: true,
10378
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
10379
+ }));
10380
+ } else if (glows.length && this.flags.has('AURA') && !glowHiddenByScale(st, liveZoom)) {
10381
+ layers.push(new IconLayer({
10382
+ id: `ix-aura-${this.name}-g${this._iconGeneration}`,
10383
+ data: glows, pickable: false,
10384
+ getPosition: d => d.geometry.coordinates,
10385
+ getIcon: d => this._buildSingleIcon(borderRgb || colorOf(d.part.i), 0.3, 'circle', null, 0),
10386
+ getSize: d => d.part.r * 2 * 1.3,
10387
+ getPixelOffset: offsetOf,
10388
+ getColor: d => [255, 255, 255, alphaOf(d)],
10389
+ sizeUnits: 'pixels', billboard: true,
10390
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
10391
+ }));
10392
+ }
10393
+ layers.push(new IconLayer({
10394
+ id: `ix-bubbles-${this.name}-g${this._iconGeneration}`,
10395
+ data: parts, pickable: true,
10396
+ getPosition: d => d.geometry.coordinates,
10397
+ // the stroke, in the icon raster's pixels (the icon is drawn at 2r)
10398
+ getIcon: d => this._buildSingleIcon(colorOf(d.part.i), fillOpacity, shapeOf(d.part.i), borderRgb,
10399
+ 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),
10400
+ getSize: d => d.part.r * 2,
10401
+ getPixelOffset: offsetOf,
10402
+ getColor: d => [255, 255, 255, alphaOf(d)],
10403
+ sizeUnits: 'pixels', billboard: true,
10404
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
10405
+ }));
10406
+ // VALUES on each part (maptheme.js 22188-22250): the part's value — with
10407
+ // COUNT its record count, with valuefield on the categorical field the
10408
+ // class name — formatted with valuedecimals (else none) plus the unit;
10409
+ // font min(1, 3.2 / L) · r · valuescale, L the length of the theme's
10410
+ // biggest value text (szMaxText), normal weight, in valuecolor /
10411
+ // textcolor, else ChartColors.textColor of the part color
10412
+ if (this.flags.has('VALUES') && !valuesHiddenByScale(st, zoom)) {
10413
+ const valueScale = styleNum(st.valuescale) || 1;
10414
+ const { noBreaks } = this._valueTextOpts();
10415
+ const units = st.units ? String(st.units) : '';
10416
+ const unit = units ? (units[0] === '.' ? '' : ' ') + units : '';
10417
+ const unitText = unit.length <= 5 ? unit : '';
10418
+ const dec = styleNum(st.valuedecimals) || 0;
10419
+ const fmt = v => flatGroupedValue(v, dec, noBreaks) + unitText;
10420
+ const maxLen = fmt(seqMax).length;
10421
+ const textOverride = st.valuecolor || st.textcolor;
10422
+ const counting = flatFlag(this.flags, 'COUNT');
10423
+ const labels = [];
10424
+ parts.forEach(d => {
10425
+ const rec = this._categoryValueRecord(d.part.i);
10426
+ const name = rec ? String(rec[st.valuefield]) : null;
10427
+ const t = counting && d.properties.recordCounts ? d.properties.recordCounts[d.part.i] : d.part.v;
10428
+ const text = name != null ? name : fmt(t);
10429
+ const L = Math.max((name != null ? name.length : 0) + 1, maxLen);
10430
+ const fontSize = Math.min(1, 3.2 / L) * d.part.r * valueScale;
10431
+ if (fontSize > VALUES_MIN_FONT_PX) {
10432
+ labels.push({ geometry: d.geometry, text, fontSize, pixelOffset: offsetOf(d), alpha: alphaOf(d),
10433
+ color: textOverride ? hexOrNamedToRgb(textOverride) : flatChartTextRgb(colorOf(d.part.i)) });
10434
+ }
10435
+ });
10436
+ if (labels.length) {
10437
+ layers.push(new TextLayer({
10438
+ id: `ix-cluster-values-${this.name}`,
10439
+ data: labels, pickable: false,
10440
+ getPosition: d => d.geometry.coordinates,
10441
+ getText: d => d.text,
10442
+ getSize: d => d.fontSize,
10443
+ getColor: d => [...d.color.slice(0, 3), d.alpha],
10444
+ getPixelOffset: d => d.pixelOffset,
10445
+ sizeUnits: 'pixels', fontFamily: 'arial', fontWeight: 'normal',
10446
+ characterSet: 'auto',
10447
+ getTextAnchor: 'middle', getAlignmentBaseline: 'center'
10448
+ }));
10449
+ }
10450
+ }
10451
+ return layers;
10452
+ }
10453
+
10147
10454
  // flat's chart position for this build (flatChartAlignOffset): a
10148
10455
  // function item → pixel offset, the same for every part of one chart
10149
10456
  // (symbol, glow, shadow, box, title, value text). unit is flat's
@@ -10387,6 +10694,23 @@
10387
10694
  }
10388
10695
  }
10389
10696
 
10697
+ // a color's own opacity, 0..1, as SVG paints it in flat: "none" (SVG's
10698
+ // no paint — a polygon with only its outline, or a class not painted)
10699
+ // 0, rgba()/hsla() its alpha, #rrggbbaa / #rgba its alpha digits, else 1
10700
+ function cssColorAlpha(v) {
10701
+ if (typeof v !== 'string') return 1;
10702
+ const c = v.trim().toLowerCase();
10703
+ if (c === 'none' || c === 'transparent') return 0;
10704
+ const m = c.match(/^(?:rgba|hsla)\([^)]*,\s*([\d.]+%?)\s*\)$/) || c.match(/^(?:rgb|hsl)a?\([^)]*\/\s*([\d.]+%?)\s*\)$/);
10705
+ if (m) return Math.max(0, Math.min(1, m[1].endsWith('%') ? parseFloat(m[1]) / 100 : parseFloat(m[1])));
10706
+ const h = c.match(/^#(?:[0-9a-f]{3}([0-9a-f])|[0-9a-f]{6}([0-9a-f]{2}))$/);
10707
+ if (h) return parseInt(h[1] ? h[1] + h[1] : h[2], 16) / 255;
10708
+ return 1;
10709
+ }
10710
+ // [r, g, b] with an alpha 0..1 — the plain [r, g, b] (opaque) when it is 1
10711
+ function withAlpha(rgb, a) {
10712
+ return a >= 1 ? rgb.slice(0, 3) : [...rgb.slice(0, 3), Math.round(255 * Math.max(0, a))];
10713
+ }
10390
10714
  function hexOrNamedToRgb(v) {
10391
10715
  const NAMED = { gray: [128, 128, 128], grey: [128, 128, 128], black: [0, 0, 0], white: [255, 255, 255] };
10392
10716
  return parseCssColor(v) || NAMED[v] || [130, 130, 130];
@@ -10408,6 +10732,169 @@
10408
10732
  function escapeHtml(s) {
10409
10733
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
10410
10734
  }
10735
+ // a legend's row labels as HTML: the page's .style({label}) as given,
10736
+ // values from the data escaped
10737
+ function legendRowLabels(rt) {
10738
+ const display = rt.categoryDisplayLabels;
10739
+ if (display && display !== rt.categoryLabels) return display;
10740
+ return (rt.categoryLabels || []).map(v => escapeHtml(v));
10741
+ }
10742
+
10743
+ // ===============================================================
10744
+ // FLAT-COMPATIBILITY API — what a page written for ixmaps-flat calls on
10745
+ // the global `ixmaps` object; it acts on the last built map:
10746
+ // - page hooks gl calls: htmlgui_onNewTheme, htmlgui_onDrawTheme,
10747
+ // htmlgui_onZoomAndPan
10748
+ // - the map handle ixmaps.map() and its methods (MAP_HANDLE_METHODS)
10749
+ // - view: getZoom, getCenter, getMapTypeId, getBoundingBox
10750
+ // - themes: getThemeObj (flat's theme object, with its data table and
10751
+ // the items on the map), getThemeDefinitionObj, getThemes,
10752
+ // changeThemeStyle / removeTheme / setBasemapOpacity (map name first),
10753
+ // refreshTheme, markThemeClass / unmarkThemeClass
10754
+ // - data: ixmaps.data (facets: getFacets, showFacets, …), the
10755
+ // embeddedSVG.window tables (setExternalData is with the data loading)
10756
+ // - projects: getProjectString, setProjectJSON, loadProject
10757
+ // - page UI: setTitle, setTitleBox, formatValue; message,
10758
+ // filterThemeItems and highlightThemeItems are accepted but not ported
10759
+ // The object itself is assembled at the end of this section (global.ixmaps).
10760
+ // ===============================================================
10761
+
10762
+ // flat's page hook ixmaps.htmlgui_onNewTheme(szId), called when a theme
10763
+ // is created (maptheme.js 927) — with the theme's id (its name)
10764
+ function themeIdOf(rt) {
10765
+ return (rt.style && rt.style.name) || (rt.meta && rt.meta.name) || rt.name;
10766
+ }
10767
+ function notifyNewTheme(rt) {
10768
+ const hook = pageIxmaps() && pageIxmaps().htmlgui_onNewTheme;
10769
+ if (typeof hook !== 'function') return;
10770
+ try { hook.call(global.ixmaps, themeIdOf(rt)); } catch (e) { console.error('[ixmaps-gl] htmlgui_onNewTheme:', e); }
10771
+ }
10772
+
10773
+ // flat's ixmaps.map() — the running map's handle (htmlgui.js 173ff,
10774
+ // htmlgui_flat.js). Its methods act on the last built map; called while
10775
+ // the map is still building, they run once it is ready.
10776
+ const MAP_HANDLE_METHODS = ['replace', 'add', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
10777
+ 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible'];
10778
+ const _mapHandle = {};
10779
+ for (const m of MAP_HANDLE_METHODS) {
10780
+ _mapHandle[m] = (...args) => {
10781
+ if (_lastMapApi) { const r = _lastMapApi[m](...args); return r === _lastMapApi ? _mapHandle : r; }
10782
+ _mapReady.then(api => api[m](...args));
10783
+ return _mapHandle;
10784
+ };
10785
+ }
10786
+ _mapHandle.getZoom = () => (_lastMapApi ? _lastMapApi.getZoom() : undefined);
10787
+ _mapHandle.getThemeObj = id => getThemeObj(id);
10788
+ function mapHandle() { return _mapHandle; }
10789
+
10790
+ // flat's ixmaps.formatValue(value, precision, flag) (ui/js/tools/format.js),
10791
+ // which user chart scripts call: thousands separated by "." (BLANK: a
10792
+ // space), the decimals after "," (BLANK: "."), CEIL / FLOOR rounding
10793
+ function flatFormatPart(nPart, szLeading) {
10794
+ szLeading = szLeading || '';
10795
+ let szPart = '';
10796
+ if (nPart < 100) szPart += szLeading;
10797
+ if (nPart < 10) szPart += szLeading;
10798
+ if (nPart === 0) szPart += szLeading;
10799
+ else szPart += String(nPart);
10800
+ return szPart;
10801
+ }
10802
+ function flatFormatValue(nValue, nPrecision, szFlag) {
10803
+ nValue = Number(nValue);
10804
+ if (!isFinite(nValue) || !isFinite(nPrecision)) return String(nValue);
10805
+ if (nValue === 0) return String(nValue);
10806
+ if (nValue > 1000000000000 || nValue < -1000000000000) return String(nValue);
10807
+ nPrecision = Math.max(0, nPrecision || 0);
10808
+ if (nValue > 0.0000001 && nPrecision > 0) {
10809
+ while (Number(nValue.toFixed(nPrecision - 1)) === 0) nPrecision++;
10810
+ }
10811
+ let v = Number(nValue.toFixed(nPrecision + 1));
10812
+ const clip = Math.pow(10, nPrecision);
10813
+ if (szFlag && /CEIL/.test(szFlag)) v = Math.ceil(v * clip) / clip;
10814
+ else if (szFlag && /FLOOR/.test(szFlag)) v = Math.floor(v * clip) / clip;
10815
+ else v = Math.round(v * clip) / clip;
10816
+ let szDecimals = String(v);
10817
+ if (/\./.test(szDecimals)) {
10818
+ szDecimals = szDecimals.split('.')[1];
10819
+ while (szDecimals.length < nPrecision) szDecimals += '0';
10820
+ } else szDecimals = '';
10821
+ let szReturn = v < 0 ? '-' : '';
10822
+ let szLeading = '';
10823
+ let n = Math.floor(Math.abs(v));
10824
+ if (!szFlag || !/NOBREAKS/.test(szFlag)) {
10825
+ let nClip = 1000;
10826
+ while (n > nClip) nClip *= 1000;
10827
+ nClip /= 1000;
10828
+ let szBreak = ' ';
10829
+ while (nClip >= 1000) {
10830
+ const nPart = Math.floor(n / nClip);
10831
+ szReturn += flatFormatPart(nPart, szLeading);
10832
+ n = n % nClip;
10833
+ nClip /= 1000;
10834
+ if (nPart) {
10835
+ szLeading = '0';
10836
+ szBreak = szFlag && /SPACE/.test(szFlag) ? '<span style="font-size:0.5em;">&nbsp;</span>' : szFlag && /BLANK/.test(szFlag) ? '&nbsp;' : '.';
10837
+ }
10838
+ szReturn += szBreak;
10839
+ }
10840
+ }
10841
+ szReturn += flatFormatPart(n, szLeading);
10842
+ if (!szReturn.length || szReturn === '-') szReturn += '0';
10843
+ if (szDecimals.length && szDecimals !== '00') szReturn += (szFlag && /BLANK/.test(szFlag) ? '.' : ',') + szDecimals;
10844
+ return szReturn;
10845
+ }
10846
+
10847
+ function getZoom() { return _lastMapApi ? _lastMapApi.getZoom() : undefined; }
10848
+
10849
+ // flat's ixmaps.getBoundingBox() (htmlgui_sync_Leaflet_VT.js
10850
+ // htmlMap_getBounds): [{lat, lng} south-west, {lat, lng} north-east] of
10851
+ // the view, latitude clamped to ±85.05, longitude not capped. Set by the
10852
+ // last build(): a broker (data.type "ext") is called while its map is
10853
+ // still building, so until the MapLibre map exists the bounds come from
10854
+ // the requested view and the container size (MapLibre's 512 px tiles).
10855
+ let _boundsSource = null;
10856
+ function viewBounds(lat, lng, mlZoom, width, height) {
10857
+ const world = 512 * Math.pow(2, mlZoom);
10858
+ const x = (lng + 180) / 360 * world;
10859
+ const s = Math.sin(Math.max(-85.05, Math.min(85.05, lat)) * Math.PI / 180);
10860
+ const y = (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * world;
10861
+ const lngAt = px => px / world * 360 - 180;
10862
+ const latAt = py => Math.atan(Math.sinh(Math.PI * (1 - 2 * py / world))) * 180 / Math.PI;
10863
+ return [{ lat: latAt(y + height / 2), lng: lngAt(x - width / 2) }, { lat: latAt(y - height / 2), lng: lngAt(x + width / 2) }];
10864
+ }
10865
+ function getBoundingBox() {
10866
+ const b = _boundsSource ? _boundsSource() : null;
10867
+ if (!b) return null;
10868
+ return [{ lat: Math.max(b[0].lat, -85.05), lng: b[0].lng }, { lat: Math.min(b[1].lat, 85.05), lng: b[1].lng }];
10869
+ }
10870
+
10871
+ // flat's ixmaps.setTitle(html) / setTitleBox(text, color) (htmlgui.js
10872
+ // 1311-1331): a message line over the last built map, same markup as
10873
+ // flat's default (legend not aligned left); '' clears it.
10874
+ let _titleHost = null;
10875
+ function setTitle(szTitle) {
10876
+ if (!_titleHost) return;
10877
+ let box = _titleHost.querySelector(':scope > .ixmaps-gl-title');
10878
+ if (!box) {
10879
+ box = document.createElement('div');
10880
+ box.className = 'ixmaps-gl-title';
10881
+ box.style.cssText = 'position:absolute;top:11px;left:0;z-index:3;pointer-events:none;';
10882
+ _titleHost.appendChild(box);
10883
+ }
10884
+ box.innerHTML = szTitle
10885
+ ? "<div style='position:relative;left:100px;top:2px;font-style:arial,helvetica;font-size:22px'>" + szTitle + '</div>'
10886
+ : '';
10887
+ }
10888
+ function setTitleBox(szTitle, szColor) {
10889
+ setTitle("<span style='display:inline-flex;align-items:center;height:38px;box-sizing:border-box;padding:0 12px;border:1px solid #46494c;border-radius:8px;font-size:14px;font-family:courier new,Raleway,arial,helvetica;background:" + (szColor || 'rgba(255,255,255,0.95)') + ';color:' + (szColor ? '#fff' : '#222') + "'>" + szTitle + '</span>');
10890
+ }
10891
+ // flat's ixmaps.refreshTheme(szId): reloads the theme's data on the last
10892
+ // built map (a broker is called again) — see engineApi.refreshTheme
10893
+ function refreshTheme(szId) {
10894
+ if (!_lastMapApi || !_lastMapApi.refreshTheme) return;
10895
+ _lastMapApi.refreshTheme(szId).catch(err => console.error('[ixmaps-gl] refreshTheme:', err));
10896
+ }
10897
+
10411
10898
 
10412
10899
  // GL-PORT COMPAT: real ixmaps-flat's ixmaps.getThemeObj(szId) — a
10413
10900
  // global lookup by theme id, independent of which map built it — looked
@@ -10415,19 +10902,130 @@
10415
10902
  // definition wins" caveat). Returns null for an unknown id, same as the
10416
10903
  // real engine, rather than throwing.
10417
10904
  function getThemeObj(szId) {
10418
- const rt = _globalThemeRegistry.get(szId);
10905
+ // no id: flat's current theme — here the one a page's facets were
10906
+ // built for (its own facet script sets ixmaps.filterThemeId), else the
10907
+ // last chart / choropleth theme
10908
+ let rt = szId != null ? _globalThemeRegistry.get(szId) : null;
10909
+ if (szId == null) {
10910
+ const pid = pageIxmaps() && pageIxmaps().filterThemeId;
10911
+ rt = (pid != null && _globalThemeRegistry.get(pid))
10912
+ || [..._globalThemeRegistry.values()].reverse().find(r => r.flags.has('CHART') || r.flags.has('CHOROPLETH')) || null;
10913
+ }
10419
10914
  if (!rt) return null;
10915
+ const rgbHex = c => '#' + c.slice(0, 3).map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
10916
+ let items = null;
10917
+ const onMap = () => items || (items = themeItemsOnMap(rt));
10420
10918
  return {
10421
- szId: rt.name,
10919
+ // flat's theme id (style.name) — a .layer() name may be shared by
10920
+ // several themes of a page
10921
+ szId: themeIdOf(rt),
10422
10922
  szName: rt.name,
10423
- szFilter: rt._filterExpr || '',
10923
+ // the filter in force: a runtime one (changeThemeStyle "filter")
10924
+ // replaces the definition's
10925
+ szFilter: rt._runtimeFilterExpr || rt._filterExpr || '',
10424
10926
  szFlag: Array.from(rt.flags).join('|'),
10425
10927
  // flat's fVisible is false for a FEATURE theme outside its
10426
10928
  // featureupper/featurelower scales too (maptheme.js 2609-2611) — a
10427
10929
  // broker reads it to skip refreshing a hidden theme
10428
- fVisible: !rt._hidden && !(_lastMapApi && _lastMapApi.map && themeHiddenByScale(rt.flags, rt.style, _lastMapApi.map.getZoom()))
10930
+ fVisible: !rt._hidden && !(_lastMapApi && _lastMapApi.map && themeHiddenByScale(rt.flags, rt.style, _lastMapApi.map.getZoom())),
10931
+ nScale: styleNum(rt.style.scale) || 1,
10932
+ szUnits: rt.style.units || '',
10933
+ // flat stores the unit with a leading space unless it starts with "."
10934
+ szUnit: rt.style.units ? (String(rt.style.units)[0] === '.' ? '' : ' ') + rt.style.units : '',
10935
+ szTitleField: rt.binding.title || null,
10936
+ szFieldsA: rt.binding.value ? String(rt.binding.value).split('|') : [],
10937
+ szSizeField: rt.binding.size || null,
10938
+ // the classes, as flat's facet / legend scripts read them:
10939
+ // colorScheme by class, nStringToValueA value → class + 1,
10940
+ // szLabelA / szValuesA the style's label / values lists
10941
+ colorScheme: (rt.categoryColorsRgb || []).map(rgbHex),
10942
+ nStringToValueA: rt.categoryLabels ? Object.fromEntries(rt.categoryLabels.map((v, i) => [v, i + 1])) : {},
10943
+ szLabelA: Array.isArray(rt.style.label) ? rt.style.label.map(String) : (rt.categoryDisplayLabels || null),
10944
+ szValuesA: Array.isArray(rt.style.values) ? rt.style.values.map(String) : null,
10945
+ partsA: rt.partsA || [],
10946
+ // flat's own data surface, as a page's own facet / list scripts
10947
+ // (flat's ui/js/tools facet.js, list.js) read it: the theme's data
10948
+ // table (objTheme.dbTable/dbFields/dbRecords — records as arrays in
10949
+ // field order) and the items on the map (indexA → itemA[i].dbIndexA,
10950
+ // the records of each item). Here one item holds every record of
10951
+ // the theme within the current view (shown, in scale, facet filters
10952
+ // applied) — computed when read
10953
+ get objTheme() { return themeDbTable(rt); },
10954
+ // computed once per theme object (a page's loop reads itemA per record)
10955
+ get indexA() { return onMap().indexA; },
10956
+ get itemA() { return onMap().itemA; }
10429
10957
  };
10430
10958
  }
10959
+ const _warnedOnce = new Set();
10960
+ function warnOnce(key, msg) { if (!_warnedOnce.has(key)) { _warnedOnce.add(key); console.info(msg); } }
10961
+ // the theme's records as flat's dbFields/dbRecords (cached per data)
10962
+ function themeDbTable(rt) {
10963
+ const features = rt.features || [];
10964
+ if (rt._dbTableFor === features) return rt._dbTable;
10965
+ const rowOf = f => (f.properties && f.properties.raw) || f.properties || {};
10966
+ const names = [];
10967
+ const seen = new Set();
10968
+ 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); }
10969
+ const indexOf = new Map();
10970
+ const dbRecords = features.map((f, i) => { indexOf.set(f, i); const row = rowOf(f); return names.map(k => (row[k] == null ? '' : row[k])); });
10971
+ rt._dbTableFor = features;
10972
+ rt._dbIndexOf = indexOf;
10973
+ const dbFields = names.map(id => ({ id, typ: 0, width: 16, decimals: 0 }));
10974
+ // dbTable: data.js Table's own table header ({records, fields} counts)
10975
+ rt._dbTable = { dbTable: { records: dbRecords.length, fields: dbFields.length }, dbFields, dbRecords };
10976
+ return rt._dbTable;
10977
+ }
10978
+ // the records on the map now: none for a hidden / out of scale theme,
10979
+ // else the (facet-filtered) records within the view
10980
+ function themeItemsOnMap(rt) {
10981
+ themeDbTable(rt);
10982
+ const map = _lastMapApi && _lastMapApi.map;
10983
+ const shown = map && !rt._hidden && !themeHiddenByScale(rt.flags, rt.style, map.getZoom());
10984
+ if (!shown) return { indexA: [], itemA: {} };
10985
+ const b = map.getBounds();
10986
+ const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()];
10987
+ const dbIndexA = [];
10988
+ for (const f of (rt._activeFeatures || rt.features || [])) {
10989
+ const g = f.geometry;
10990
+ if (g && g.type === 'Point' && !pointInBbox(g, bbox)) continue;
10991
+ const i = rt._dbIndexOf.get(f);
10992
+ if (i != null) dbIndexA.push(i);
10993
+ }
10994
+ return { indexA: ['view'], itemA: { view: { dbIndexA } } };
10995
+ }
10996
+ // flat's ixmaps.getThemeDefinitionObj(szId): the theme's definition —
10997
+ // type, style (sizefield/valuefield as flat names them), meta
10998
+ function getThemeDefinitionObj(szId) {
10999
+ const rt = _globalThemeRegistry.get(szId);
11000
+ if (!rt) return null;
11001
+ const style = Object.assign({}, rt.style);
11002
+ if (rt.binding.size && !style.sizefield) style.sizefield = rt.binding.size;
11003
+ if (rt.binding.title && !style.titlefield) style.titlefield = rt.binding.title;
11004
+ style.type = rt.flags.typeString != null ? rt.flags.typeString : [...rt.flags].join('|');
11005
+ const data = Object.assign({}, rt._definition && rt._definition.data);
11006
+ if (!data.name) data.name = themeDataName(rt);
11007
+ return { layer: rt.name, type: style.type, field: rt.binding.value, data, style, meta: Object.assign({}, rt.meta) };
11008
+ }
11009
+ // a theme's data table name — flat keeps named data tables in its map
11010
+ // window (ixmaps.embeddedSVG.window[name], read by a page's own list
11011
+ // scripts): the .data({name}) given, else one made from the theme id
11012
+ function themeDataName(rt) {
11013
+ const d = rt._definition && rt._definition.data;
11014
+ return (d && d.name) || ('themeData_' + String(themeIdOf(rt)).replace(/\W/g, '_'));
11015
+ }
11016
+ // flat's ixmaps.embeddedSVG.window: here only its named data tables, as
11017
+ // data.js Table objects {table, fields, records} of the themes' data
11018
+ const embeddedSVG = {
11019
+ window: new Proxy({}, {
11020
+ get(target, key) {
11021
+ if (typeof key !== 'string') return undefined;
11022
+ const rt = [...new Set(_globalThemeRegistry.values())].find(r => themeDataName(r) === key);
11023
+ if (!rt) return undefined;
11024
+ const t = themeDbTable(rt);
11025
+ return { table: t.dbTable, fields: t.dbFields, records: t.dbRecords };
11026
+ }
11027
+ })
11028
+ };
10431
11029
 
10432
11030
  // GL-PORT COMPAT: real ixmaps-flat's global ixmaps.markThemeClass(szId,
10433
11031
  // index)/unmarkThemeClass(szId, index) (htmlgui.js:2196/2208 — a bare
@@ -10444,7 +11042,15 @@
10444
11042
  // here instead of redrawing.
10445
11043
  function markThemeClass(szId, index) {
10446
11044
  const rt = _globalThemeRegistry.get(szId);
10447
- if (!rt) return;
11045
+ if (rt) markRuntimeClass(rt, index);
11046
+ }
11047
+ function unmarkThemeClass(szId, index) {
11048
+ const rt = _globalThemeRegistry.get(szId);
11049
+ if (rt) unmarkRuntimeClass(rt, index);
11050
+ }
11051
+ // the legend's own rows mark their theme directly: its .layer() name
11052
+ // may be shared with other themes of the page
11053
+ function markRuntimeClass(rt, index) {
10448
11054
  // flat: a non-CHART theme (choropleth) marks ONE class at a time —
10449
11055
  // MapTheme.markClass unmarks the previous class first (maptheme.js
10450
11056
  // 14301-14305); chart themes can mark several
@@ -10454,9 +11060,7 @@
10454
11060
  if (rt._onMarksChanged) rt._onMarksChanged();
10455
11061
  else if (rt._triggerRedraw) rt._triggerRedraw();
10456
11062
  }
10457
- function unmarkThemeClass(szId, index) {
10458
- const rt = _globalThemeRegistry.get(szId);
10459
- if (!rt) return;
11063
+ function unmarkRuntimeClass(rt, index) {
10460
11064
  rt._markedClasses.delete(index);
10461
11065
  if (rt._onMarksChanged) rt._onMarksChanged();
10462
11066
  else if (rt._triggerRedraw) rt._triggerRedraw();
@@ -10759,13 +11363,30 @@
10759
11363
  layer, Map: createMap, setExternalData: setExternalDataBridge, getThemeObj, data: ixmapsData,
10760
11364
  szResourceBase: ixmapsSzResourceBase, getProjectString, setProjectJSON, loadProject,
10761
11365
  markThemeClass, unmarkThemeClass, getBoundingBox, setTitle, setTitleBox, refreshTheme, formatValue: flatFormatValue,
10762
- map: mapHandle, getZoom,
11366
+ map: mapHandle, getZoom, getThemeDefinitionObj, embeddedSVG,
11367
+ // flat's theme list: the theme objects of every theme
11368
+ getThemes: () => [...new Set(_globalThemeRegistry.values())].map(rt => getThemeObj(themeIdOf(rt))).filter(Boolean),
11369
+ // flat shows a short status message on the map; not ported
11370
+ message: () => {},
11371
+ // flat's item highlight / item filter (facet histogram hovers): not ported
11372
+ filterThemeItems: () => warnOnce('filterThemeItems', '[ixmaps-gl] ixmaps.filterThemeItems is not supported yet'),
11373
+ highlightThemeItems: () => warnOnce('highlightThemeItems', '[ixmaps-gl] ixmaps.highlightThemeItems is not supported yet'),
11374
+ // flat's global forms with the map name first (htmlgui.js) — the map
11375
+ // handle's methods on the last built map
11376
+ getCenter: () => (_lastMapApi && _lastMapApi.map ? _lastMapApi.map.getCenter() : null),
11377
+ getMapTypeId: () => (_lastMapApi && _lastMapApi.getMapTypeId ? _lastMapApi.getMapTypeId() : ''),
11378
+ changeThemeStyle: (szMap, szId, szStyle, szFlag) => _mapHandle.changeThemeStyle(szId, szStyle, szFlag),
11379
+ removeTheme: (szMap, szId) => _mapHandle.remove(szId),
11380
+ setBasemapOpacity: (szMap, nOpacity, szMode) => _mapHandle.setBasemapOpacity(nOpacity, szMode),
10763
11381
  // this engine's version (flat's own ixmaps.version numbers flat)
10764
11382
  glVersion: IXMAPS_GL_VERSION,
10765
11383
  // flat always has this page hook (its layer legend tool defines it,
10766
11384
  // ui/js/tools/layer_legend.js 702-712): pages wrap it and call the one
10767
11385
  // they replaced — the chain ends here
10768
11386
  htmlgui_onZoomAndPan: function (nZoom) {},
11387
+ // flat calls it when a theme is drawn (htmlgui.js) — pages wrap it
11388
+ // (statistics, facets); called after each settled redraw, notifyRedraw
11389
+ htmlgui_onDrawTheme: function (szId) {},
10769
11390
  // carried over from a page's own pre-load `ixmaps.validate = ...`
10770
11391
  validate: _preloadValidate
10771
11392
  };
@@ -10780,7 +11401,7 @@
10780
11401
  // test-only: lets test/unit/*.test.mjs call pure internals directly (the
10781
11402
  // engine runs in a Node vm there); deliberately NOT on the ixmaps object
10782
11403
  global.__ixmapsGlInternals = {
10783
- IXMAPS_GL_VERSION, computeBubblePackLayout, isolatedBubblePackLayout,
11404
+ IXMAPS_GL_VERSION, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout,
10784
11405
  normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, typeStyleNumbers, styleNum,
10785
11406
  resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
10786
11407
  equalIntervalBreaks, quantileBreaks, naturalBreaks, evenStrideSample, jenksBreakValues, partsFromBreakValues, resolvePartsClass,