ixmaps-gl 0.2.9 → 0.2.11

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 +5 -4
  2. package/ixmaps-gl.js +1210 -1054
  3. package/package.json +1 -1
package/ixmaps-gl.js CHANGED
@@ -82,7 +82,7 @@
82
82
  // ---------------------------------------------------------------
83
83
  // this engine's release (= package.json "version", checked by
84
84
  // test/unit/version.test.mjs); ixmaps.glVersion, and logged once at start
85
- const IXMAPS_GL_VERSION = '0.2.9';
85
+ const IXMAPS_GL_VERSION = '0.2.11';
86
86
 
87
87
  const LIB_URLS = {
88
88
  // Bumped 3.6.2 -> 5.x (2026-09-21, globe-projection compat fix): native
@@ -308,7 +308,7 @@
308
308
  // crafted link must not be able to load arbitrary code into the page.
309
309
  // ---------------------------------------------------------------
310
310
  // v0.1.6: knows this engine's aliases, lookupfield rule, meta↔style, builder methods, project loading
311
- const VALIDATOR_URL_DEFAULT = 'https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-grammar@v0.1.24/dist/validate.mjs';
311
+ const VALIDATOR_URL_DEFAULT = 'https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-grammar@v0.1.25/dist/validate.mjs';
312
312
  // a page may set ixmaps.validate BEFORE loading this script — the
313
313
  // `global.ixmaps = {...}` export below would otherwise overwrite it
314
314
  const _preloadValidate = global.ixmaps && global.ixmaps.validate;
@@ -1972,91 +1972,6 @@
1972
1972
  }
1973
1973
  }
1974
1974
 
1975
- // flat's page hook ixmaps.htmlgui_onNewTheme(szId), called when a theme
1976
- // is created (maptheme.js 927) — with the theme's id (its name)
1977
- function themeIdOf(rt) {
1978
- return (rt.style && rt.style.name) || (rt.meta && rt.meta.name) || rt.name;
1979
- }
1980
- function notifyNewTheme(rt) {
1981
- const hook = pageIxmaps() && pageIxmaps().htmlgui_onNewTheme;
1982
- if (typeof hook !== 'function') return;
1983
- try { hook.call(global.ixmaps, themeIdOf(rt)); } catch (e) { console.error('[ixmaps-gl] htmlgui_onNewTheme:', e); }
1984
- }
1985
-
1986
- // flat's ixmaps.map() — the running map's handle (htmlgui.js 173ff,
1987
- // htmlgui_flat.js). Its methods act on the last built map; called while
1988
- // the map is still building, they run once it is ready.
1989
- const MAP_HANDLE_METHODS = ['replace', 'add', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
1990
- 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible'];
1991
- const _mapHandle = {};
1992
- for (const m of MAP_HANDLE_METHODS) {
1993
- _mapHandle[m] = (...args) => {
1994
- if (_lastMapApi) { const r = _lastMapApi[m](...args); return r === _lastMapApi ? _mapHandle : r; }
1995
- _mapReady.then(api => api[m](...args));
1996
- return _mapHandle;
1997
- };
1998
- }
1999
- _mapHandle.getZoom = () => (_lastMapApi ? _lastMapApi.getZoom() : undefined);
2000
- _mapHandle.getThemeObj = id => getThemeObj(id);
2001
- function mapHandle() { return _mapHandle; }
2002
-
2003
- // flat's ixmaps.formatValue(value, precision, flag) (ui/js/tools/format.js),
2004
- // which user chart scripts call: thousands separated by "." (BLANK: a
2005
- // space), the decimals after "," (BLANK: "."), CEIL / FLOOR rounding
2006
- function flatFormatPart(nPart, szLeading) {
2007
- szLeading = szLeading || '';
2008
- let szPart = '';
2009
- if (nPart < 100) szPart += szLeading;
2010
- if (nPart < 10) szPart += szLeading;
2011
- if (nPart === 0) szPart += szLeading;
2012
- else szPart += String(nPart);
2013
- return szPart;
2014
- }
2015
- function flatFormatValue(nValue, nPrecision, szFlag) {
2016
- nValue = Number(nValue);
2017
- if (!isFinite(nValue) || !isFinite(nPrecision)) return String(nValue);
2018
- if (nValue === 0) return String(nValue);
2019
- if (nValue > 1000000000000 || nValue < -1000000000000) return String(nValue);
2020
- nPrecision = Math.max(0, nPrecision || 0);
2021
- if (nValue > 0.0000001 && nPrecision > 0) {
2022
- while (Number(nValue.toFixed(nPrecision - 1)) === 0) nPrecision++;
2023
- }
2024
- let v = Number(nValue.toFixed(nPrecision + 1));
2025
- const clip = Math.pow(10, nPrecision);
2026
- if (szFlag && /CEIL/.test(szFlag)) v = Math.ceil(v * clip) / clip;
2027
- else if (szFlag && /FLOOR/.test(szFlag)) v = Math.floor(v * clip) / clip;
2028
- else v = Math.round(v * clip) / clip;
2029
- let szDecimals = String(v);
2030
- if (/\./.test(szDecimals)) {
2031
- szDecimals = szDecimals.split('.')[1];
2032
- while (szDecimals.length < nPrecision) szDecimals += '0';
2033
- } else szDecimals = '';
2034
- let szReturn = v < 0 ? '-' : '';
2035
- let szLeading = '';
2036
- let n = Math.floor(Math.abs(v));
2037
- if (!szFlag || !/NOBREAKS/.test(szFlag)) {
2038
- let nClip = 1000;
2039
- while (n > nClip) nClip *= 1000;
2040
- nClip /= 1000;
2041
- let szBreak = ' ';
2042
- while (nClip >= 1000) {
2043
- const nPart = Math.floor(n / nClip);
2044
- szReturn += flatFormatPart(nPart, szLeading);
2045
- n = n % nClip;
2046
- nClip /= 1000;
2047
- if (nPart) {
2048
- szLeading = '0';
2049
- szBreak = szFlag && /SPACE/.test(szFlag) ? '<span style="font-size:0.5em;">&nbsp;</span>' : szFlag && /BLANK/.test(szFlag) ? '&nbsp;' : '.';
2050
- }
2051
- szReturn += szBreak;
2052
- }
2053
- }
2054
- szReturn += flatFormatPart(n, szLeading);
2055
- if (!szReturn.length || szReturn === '-') szReturn += '0';
2056
- if (szDecimals.length && szDecimals !== '00') szReturn += (szFlag && /BLANK/.test(szFlag) ? '.' : ',') + szDecimals;
2057
- return szReturn;
2058
- }
2059
-
2060
1975
  // ---- USER charts: a page's own chart function (style.userdraw) ----
2061
1976
  // flat calls ixmaps.<userdraw>_init(SVGDocument, {target, theme}) once
2062
1977
  // per realize (maptheme.js 17000-17050) and ixmaps.<userdraw>(SVGDocument,
@@ -2101,57 +2016,6 @@
2101
2016
  // packs them into one texture (1024 px wide), which must stay well below
2102
2017
  // the GPU's texture size limit — see _userChartIcon
2103
2018
  const USER_CHART_ATLAS_AREA = 1024 * 8192;
2104
- function getZoom() { return _lastMapApi ? _lastMapApi.getZoom() : undefined; }
2105
-
2106
- // flat's ixmaps.getBoundingBox() (htmlgui_sync_Leaflet_VT.js
2107
- // htmlMap_getBounds): [{lat, lng} south-west, {lat, lng} north-east] of
2108
- // the view, latitude clamped to ±85.05, longitude not capped. Set by the
2109
- // last build(): a broker (data.type "ext") is called while its map is
2110
- // still building, so until the MapLibre map exists the bounds come from
2111
- // the requested view and the container size (MapLibre's 512 px tiles).
2112
- let _boundsSource = null;
2113
- function viewBounds(lat, lng, mlZoom, width, height) {
2114
- const world = 512 * Math.pow(2, mlZoom);
2115
- const x = (lng + 180) / 360 * world;
2116
- const s = Math.sin(Math.max(-85.05, Math.min(85.05, lat)) * Math.PI / 180);
2117
- const y = (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * world;
2118
- const lngAt = px => px / world * 360 - 180;
2119
- const latAt = py => Math.atan(Math.sinh(Math.PI * (1 - 2 * py / world))) * 180 / Math.PI;
2120
- return [{ lat: latAt(y + height / 2), lng: lngAt(x - width / 2) }, { lat: latAt(y - height / 2), lng: lngAt(x + width / 2) }];
2121
- }
2122
- function getBoundingBox() {
2123
- const b = _boundsSource ? _boundsSource() : null;
2124
- if (!b) return null;
2125
- 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 }];
2126
- }
2127
-
2128
- // flat's ixmaps.setTitle(html) / setTitleBox(text, color) (htmlgui.js
2129
- // 1311-1331): a message line over the last built map, same markup as
2130
- // flat's default (legend not aligned left); '' clears it.
2131
- let _titleHost = null;
2132
- function setTitle(szTitle) {
2133
- if (!_titleHost) return;
2134
- let box = _titleHost.querySelector(':scope > .ixmaps-gl-title');
2135
- if (!box) {
2136
- box = document.createElement('div');
2137
- box.className = 'ixmaps-gl-title';
2138
- box.style.cssText = 'position:absolute;top:11px;left:0;z-index:3;pointer-events:none;';
2139
- _titleHost.appendChild(box);
2140
- }
2141
- box.innerHTML = szTitle
2142
- ? "<div style='position:relative;left:100px;top:2px;font-style:arial,helvetica;font-size:22px'>" + szTitle + '</div>'
2143
- : '';
2144
- }
2145
- function setTitleBox(szTitle, szColor) {
2146
- 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>');
2147
- }
2148
- // flat's ixmaps.refreshTheme(szId): reloads the theme's data on the last
2149
- // built map (a broker is called again) — see engineApi.refreshTheme
2150
- function refreshTheme(szId) {
2151
- if (!_lastMapApi || !_lastMapApi.refreshTheme) return;
2152
- _lastMapApi.refreshTheme(szId).catch(err => console.error('[ixmaps-gl] refreshTheme:', err));
2153
- }
2154
-
2155
2019
  class LayerBuilder {
2156
2020
  constructor(name) {
2157
2021
  this.name = name;
@@ -2261,7 +2125,7 @@
2261
2125
  // edit it by hand; `npm run unit` fails when it drifts.
2262
2126
  // ---------------------------------------------------------------
2263
2127
  // <grammar:binding-aliases>
2264
- // generated from ixmaps-grammar 0.1.24 (ixmaps-flat 1.0.42, 2b978d5 2026-10-02) — 52 aliases
2128
+ // generated from ixmaps-grammar 0.1.25 (ixmaps-flat 1.0.42, 2b978d5 2026-10-02) — 52 aliases
2265
2129
  const FLAT_BINDING_ALIASES = {
2266
2130
  "aggregation": "style.aggregationfield",
2267
2131
  "aggregationfield": "style.aggregationfield",
@@ -2323,7 +2187,7 @@
2323
2187
  // both — so .style({tooltip}) is a tooltip and .meta({fillopacity}) a style
2324
2188
  // property. GENERATED by test/sync-grammar.mjs — do not edit.
2325
2189
  // <grammar:meta-keys>
2326
- // generated from ixmaps-grammar 0.1.24 — 5 meta keys
2190
+ // generated from ixmaps-grammar 0.1.25 — 5 meta keys
2327
2191
  const FLAT_META_KEYS = ["description","name","snippet","title","tooltip"];
2328
2192
  // </grammar:meta-keys>
2329
2193
 
@@ -2779,6 +2643,949 @@
2779
2643
  };
2780
2644
  }
2781
2645
 
2646
+ // ---------------------------------------------------------------
2647
+ // flat's map attribution (ui/html/mappage.html #attribution-div): the
2648
+ // page's text bottom left on the map; hidden while empty ("null" counts
2649
+ // as empty, htmlgui_flat.js 522-526). Here a MapLibre control in the
2650
+ // bottom-left corner, styled like MapLibre's own attribution (the
2651
+ // basemap credit, bottom right — same line, same pill: white, 12px
2652
+ // radius, 12px/20px Helvetica Neue, links rgba(0,0,0,0.75)).
2653
+ // ---------------------------------------------------------------
2654
+ let _attributionCss = false;
2655
+ function createAttribution(map) {
2656
+ if (!_attributionCss && typeof document !== 'undefined') {
2657
+ _attributionCss = true;
2658
+ const css = document.createElement('style');
2659
+ css.textContent = '.ixmaps-gl-attribution a{color:rgba(0,0,0,0.75);text-decoration:none}'
2660
+ + '.ixmaps-gl-attribution a:hover{color:inherit;text-decoration:underline}';
2661
+ document.head.appendChild(css);
2662
+ }
2663
+ const box = document.createElement('div');
2664
+ box.className = 'maplibregl-ctrl ixmaps-gl-attribution';
2665
+ box.style.cssText = 'background:#fff;border-radius:12px;padding:2px 8px;min-height:20px;box-sizing:content-box;'
2666
+ + 'font:12px/20px "Helvetica Neue",Arial,Helvetica,sans-serif;color:#000;display:none;';
2667
+ map.addControl({ onAdd: () => box, onRemove: () => box.remove() }, 'bottom-left');
2668
+ let text = '';
2669
+ return {
2670
+ set(t) {
2671
+ text = t == null || t === 'null' ? '' : String(t);
2672
+ box.innerHTML = text;
2673
+ box.style.display = text ? 'block' : 'none';
2674
+ },
2675
+ get() { return text; }
2676
+ };
2677
+ }
2678
+
2679
+ // ---------------------------------------------------------------
2680
+ // Hover tooltips and the click-to-pin tooltip of a map, built once per map
2681
+ // by MapBuilder.build(). ctx: the builder (its map options, for the
2682
+ // tooltip look), the MapLibre map, its container element and
2683
+ // findRuntimeForLayerId (the theme of a drawn deck.gl layer). Returns the
2684
+ // overlay's getTooltip / onClick handlers and mount(), which adds the
2685
+ // pinned tooltip to the page and keeps it on its place while the map moves.
2686
+ // ---------------------------------------------------------------
2687
+ function createTooltips(ctx) {
2688
+ const { builder, map, el, findRuntimeForLayerId } = ctx;
2689
+ // Click-to-pin tooltip: matches the real ixmaps engine's own
2690
+ // click-pins-the-tooltip convention in pan mode (a hover tooltip is
2691
+ // transient; a click keeps one visible until dismissed). This is a
2692
+ // separate DOM element from deck.gl's own hover tooltip (so hovering
2693
+ // a DIFFERENT bubble still shows a normal transient tooltip even
2694
+ // while one is pinned) — its content is a snapshot of buildTooltipHtml
2695
+ // at click time (not live-updating), but its screen POSITION is kept
2696
+ // in sync with the map on every pan/zoom via map.project().
2697
+ let pinned = null; // { runtime, object, lngLat }
2698
+ // Tooltip look, like the legend's (flatLegendLook): flat's light
2699
+ // tooltip (tooltip_mustache.js: white 0.95, #444 text, thin black
2700
+ // border, 5px radius) on light basemaps, this engine's dark tooltip
2701
+ // on dark ones. Read when shown, so options set later (a
2702
+ // myMap.then(...).options({basemapopacity})) count.
2703
+ const tooltipLook = () => {
2704
+ const o = parseFloat(builder._engineOptions.basemapopacity);
2705
+ const look = flatLegendLook(builder.mapOptions.mapType, isNaN(o) ? 1 : o, builder.mapOptions.legendBackground || builder.mapOptions.legendbackground);
2706
+ return look.dark
2707
+ ? { background: 'rgb(41,50,60)', color: 'rgb(200,205,214)', border: 'none', borderRadius: '4px', boxShadow: '0 2px 8px rgba(0,0,0,0.3)' }
2708
+ : { background: 'rgba(255,255,255,0.95)', color: '#444', border: '0.5px solid black', borderRadius: '5px',
2709
+ boxShadow: 'rgba(0,0,0,0.2) 0px 2px 4px 0px, rgba(0,0,0,0.19) 0px 3px 10px 0px' };
2710
+ };
2711
+ const pinnedTooltipEl = document.createElement('div');
2712
+ pinnedTooltipEl.style.cssText = 'position:absolute;top:0;left:0;z-index:6;display:none;' +
2713
+ 'pointer-events:auto;max-width:440px;max-height:360px;overflow:auto;' +
2714
+ 'padding:0.6em 1.6em 0.6em 0.7em;font-size:1em;';
2715
+
2716
+ function updatePinnedTooltipPosition() {
2717
+ if (!pinned) return;
2718
+ const pt = map.project(pinned.lngLat);
2719
+ pinnedTooltipEl.style.transform = `translate(${pt.x + 10}px, ${pt.y - 10}px)`;
2720
+ }
2721
+
2722
+ // A stable per-geometry anchor coordinate for the identity check
2723
+ // below — only a Point's OWN coordinates are an [lng,lat] pair;
2724
+ // Polygon/MultiPolygon nest rings of them, so the first ring's
2725
+ // first vertex stands in instead (stable across rebuilds for the
2726
+ // same underlying feature, same as the Point case: this.features'
2727
+ // geometry references don't change shape between
2728
+ // _buildChoroplethLayers calls, only the wrapping properties do).
2729
+ function anchorCoordOf(geometry) {
2730
+ if (!geometry) return null;
2731
+ if (geometry.type === 'Point') return geometry.coordinates;
2732
+ if (geometry.type === 'Polygon') return geometry.coordinates[0] && geometry.coordinates[0][0];
2733
+ if (geometry.type === 'MultiPolygon') return geometry.coordinates[0] && geometry.coordinates[0][0] && geometry.coordinates[0][0][0];
2734
+ return null;
2735
+ }
2736
+
2737
+ // Identity check for "is this hovered object the same one that's
2738
+ // pinned" — object references are rebuilt from scratch on every
2739
+ // redraw (new individual/group arrays each buildDeckLayers call), so
2740
+ // reference equality never works here. Same layer + same geometry
2741
+ // position is a reasonable proxy: individual points and aggregated
2742
+ // groups alike are keyed by position within a layer, matching how
2743
+ // clustering itself already treats "same position" as "same thing".
2744
+ function isSameAsPinned(layerId, object) {
2745
+ if (pinned.layerId !== layerId) return false;
2746
+ const a = anchorCoordOf(object.geometry);
2747
+ const b = anchorCoordOf(pinned.object.geometry);
2748
+ if (!a || !b) return false;
2749
+ return Math.abs(a[0] - b[0]) < 1e-9 && Math.abs(a[1] - b[1]) < 1e-9;
2750
+ }
2751
+
2752
+ // content lives in its own child so re-rendering (innerHTML) never
2753
+ // wipes out the unpin button below (a sibling, not a descendant of it)
2754
+ const pinnedContentEl = document.createElement('div');
2755
+ pinnedTooltipEl.appendChild(pinnedContentEl);
2756
+
2757
+ function renderPinnedTooltip() {
2758
+ if (!pinned) { pinnedTooltipEl.style.display = 'none'; return; }
2759
+ const html = pinned.runtime.buildTooltipHtml(pinned.object);
2760
+ if (!html) { pinned = null; pinnedTooltipEl.style.display = 'none'; return; }
2761
+ pinnedContentEl.innerHTML = html;
2762
+ Object.assign(pinnedTooltipEl.style, tooltipLook());
2763
+ pinnedTooltipEl.style.display = 'block';
2764
+ updatePinnedTooltipPosition();
2765
+ }
2766
+
2767
+ const unpinBtn = document.createElement('button');
2768
+ unpinBtn.type = 'button';
2769
+ unpinBtn.setAttribute('aria-label', 'chiudi');
2770
+ unpinBtn.title = 'chiudi';
2771
+ unpinBtn.textContent = '×';
2772
+ unpinBtn.style.cssText = 'position:absolute;top:5px;right:5px;width:1.5em;height:1.5em;padding:0;' +
2773
+ 'display:flex;align-items:center;justify-content:center;border:none;border-radius:50%;' +
2774
+ 'background:rgba(255,255,255,0.18);color:inherit;font-size:1em;line-height:1;font-weight:bold;cursor:pointer';
2775
+ unpinBtn.addEventListener('mouseenter', () => { unpinBtn.style.background = 'rgba(255,255,255,0.35)'; });
2776
+ unpinBtn.addEventListener('mouseleave', () => { unpinBtn.style.background = 'rgba(255,255,255,0.18)'; });
2777
+ unpinBtn.addEventListener('click', () => { pinned = null; pinnedTooltipEl.style.display = 'none'; });
2778
+ pinnedTooltipEl.style.position = 'absolute';
2779
+ pinnedTooltipEl.appendChild(unpinBtn);
2780
+
2781
+ // deck.gl's own default hover tooltip is only recomputed on pointer-
2782
+ // move events, not on click — so right after a click, it's still
2783
+ // showing whatever it rendered from the hover that was already
2784
+ // active (the same item we just pinned), and won't hide itself just
2785
+ // because getTooltip would now return null for it. Nothing re-asks
2786
+ // it until the next real mousemove. Force it closed here instead —
2787
+ // deferred one frame so it wins even if deck.gl's own click handling
2788
+ // touches the tooltip DOM in the same tick.
2789
+ function hideDefaultHoverTooltip() {
2790
+ requestAnimationFrame(() => {
2791
+ const defaultTooltipEl = el.parentElement && el.parentElement.querySelector('.deck-tooltip');
2792
+ if (defaultTooltipEl) defaultTooltipEl.style.display = 'none';
2793
+ });
2794
+ }
2795
+
2796
+ let tooltipErrorLogged = false;
2797
+ // a USER chart's picked item is {d, icon} (see _buildUserChartLayers):
2798
+ // the feature is its d
2799
+ function pickedFeature(o) { return o && o.icon && o.d ? o.d : o; }
2800
+ function tooltipFor({ object: picked, layer }) {
2801
+ const object = pickedFeature(picked);
2802
+ if (!object || !layer) return null;
2803
+ // suppress the hover tooltip ONLY for the specific item that's
2804
+ // pinned (showing both would just duplicate the same content) —
2805
+ // every OTHER item still gets its normal hover preview even
2806
+ // while something else stays pinned
2807
+ if (pinned && isSameAsPinned(layer.id, object)) return null;
2808
+ const rt = findRuntimeForLayerId(layer.id);
2809
+ if (!rt) return null;
2810
+ const html = rt.buildTooltipHtml(object);
2811
+ return html ? { html, style: Object.assign({ fontSize: '1em', padding: '0.5em 0.7em', maxWidth: '440px' }, tooltipLook()) } : null;
2812
+ }
2813
+ function clickFor(picked) {
2814
+ const info = picked && Object.assign({}, picked, { object: pickedFeature(picked.object) });
2815
+ if (info && info.object && info.layer) {
2816
+ const rt = findRuntimeForLayerId(info.layer.id);
2817
+ if (rt) {
2818
+ // Only a Point geometry's own coordinates ARE an
2819
+ // [lng,lat] pair (every point/bubble/dot/chart-cluster
2820
+ // layer) — a CHOROPLETH polygon's geometry.coordinates is
2821
+ // a nested array of RINGS, which map.project() below
2822
+ // can't accept (confirmed live: threw MapLibre's own
2823
+ // "LngLatLike argument must be..." error). The `||
2824
+ // info.coordinate` fallback this used to lean on never
2825
+ // actually ran for a polygon — a nested array is truthy,
2826
+ // so the left side always "won" — which is the real bug
2827
+ // this fixes: the pinned tooltip stuck at its default
2828
+ // top:0/left:0 (never got a real position because
2829
+ // updatePinnedTooltipPosition's map.project() threw
2830
+ // before setting one), AND that same uncaught exception,
2831
+ // thrown from inside deck.gl's own click-dispatch
2832
+ // callback, left deck.gl's pointer-interaction state
2833
+ // corrupted enough to block all further pan/zoom —
2834
+ // confirmed live, both symptoms disappear together once
2835
+ // this stops throwing. info.coordinate (the actual
2836
+ // clicked map location, provided regardless of feature
2837
+ // geometry type) anchors every non-Point case correctly.
2838
+ const lngLat = (info.object.geometry && info.object.geometry.type === 'Point')
2839
+ ? info.object.geometry.coordinates
2840
+ : info.coordinate;
2841
+ pinned = { runtime: rt, object: info.object, lngLat, layerId: info.layer.id };
2842
+ renderPinnedTooltip();
2843
+ hideDefaultHoverTooltip();
2844
+ return true;
2845
+ }
2846
+ }
2847
+ // clicked empty map space (or a non-tooltippable layer) -> unpin
2848
+ pinned = null;
2849
+ pinnedTooltipEl.style.display = 'none';
2850
+ return false;
2851
+ }
2852
+ // An exception thrown from these callbacks runs inside deck.gl's
2853
+ // own render frame / event dispatch and leaves its frame and
2854
+ // interaction state broken — the map stopped showing new layers
2855
+ // after zooms and pans (a USER chart's hover threw on every frame
2856
+ // the mouse was over an arrow, see pickedFeature). Both are guarded.
2857
+ function getTooltip(info) {
2858
+ try { return tooltipFor(info); } catch (err) {
2859
+ if (!tooltipErrorLogged) { tooltipErrorLogged = true; console.error('[ixmaps-gl] tooltip failed:', err); }
2860
+ return null;
2861
+ }
2862
+ }
2863
+ function onClick(info) {
2864
+ try { return clickFor(info); } catch (err) {
2865
+ console.error('[ixmaps-gl] click failed:', err);
2866
+ return false;
2867
+ }
2868
+ }
2869
+ function mount() {
2870
+ map.on('move', updatePinnedTooltipPosition);
2871
+ if (el.parentElement) {
2872
+ el.parentElement.style.position = el.parentElement.style.position || 'relative';
2873
+ el.parentElement.appendChild(pinnedTooltipEl);
2874
+ }
2875
+ }
2876
+ return { getTooltip, onClick, mount };
2877
+ }
2878
+
2879
+ // ---------------------------------------------------------------
2880
+ // The map's native interactive legend, built once per map by
2881
+ // MapBuilder.build(). ctx: the builder (its map options), the MapLibre
2882
+ // map, its container element, the deck.gl overlay, the runtimes, the
2883
+ // engine API and its refresh hooks. Returns the four hooks the map
2884
+ // calls: addLegendPanel(rt), setLegendOption(value), updateSubTheme(rt),
2885
+ // layoutLegends().
2886
+ // ---------------------------------------------------------------
2887
+ function createLegend(ctx) {
2888
+ const { builder, map, el, overlay, runtimes, engineApi, built, refresh, scheduleRefresh, notifyRedraw } = ctx;
2889
+ let addLegendPanel = () => {};
2890
+ let setLegendOption = () => {};
2891
+ let updateSubTheme = () => {};
2892
+ let layoutLegends = () => {};
2893
+
2894
+ // NATIVE INTERACTIVE LEGEND — real ixmaps-flat's legend.js
2895
+ // (makeColorLegendHTMLLong) builds, per CATEGORICAL-ish theme: a
2896
+ // title/snippet header, one row per category (color swatch + a bar
2897
+ // proportional to that category's own total + right-aligned
2898
+ // formatted value), a description/source footer, and a per-theme
2899
+ // "Chart size" 25-200% slider — all read directly off source
2900
+ // (ui/js/tools/legend.js:594-1037/3196-3393, ui/js/htmlgui.js:2196/
2901
+ // 2208 for markThemeClass/unmarkThemeClass, ui/css/legend.css for
2902
+ // the selected-row highlight) rather than guessed from the
2903
+ // screenshot alone. Gated on `legend:"open"` — the one real map
2904
+ // option this engine's own createMap already threads through as
2905
+ // builder.mapOptions (see mapOptions.legend below) — since there's no
2906
+ // toolbar toggle button here to open/close it later the way the
2907
+ // real engine's own chrome does.
2908
+ //
2909
+ // Deliberate divergences from the real implementation, both
2910
+ // reasoned rather than accidental:
2911
+ // - Real legend.js does a full innerHTML rebuild on every single
2912
+ // theme redraw (even a bare click-toggle). renderRows() below
2913
+ // only rebuilds the ROWS (swatch/bar/value/highlight) — cheap,
2914
+ // and the only part that can actually change post-load (marks,
2915
+ // or an AGGREGATE theme's dataset-wide totals, which this port
2916
+ // doesn't yet let change post-load anyway) — rather than
2917
+ // tearing down and rebuilding title/snippet/description/slider
2918
+ // too, which never change. Same visible result, less DOM churn.
2919
+ // - Real "isolate_gray" mode dims non-marked SVG paths via a CSS
2920
+ // class; this engine dims via IconLayer's own getColor alpha
2921
+ // (see LayerRuntime#_iconAlpha) since these are deck.gl raster
2922
+ // icons, not DOM/SVG nodes a CSS rule could reach.
2923
+ // Legend on/off and fold state as real ixmaps-flat reads the map
2924
+ // option (htmlgui_flat.js:748-794): on whenever `legend` is given and
2925
+ // isn't false/"false" ("true", "open", "", 1, …), folded for
2926
+ // "closed", off when not given at all.
2927
+ const legendOpt = builder.mapOptions.legend;
2928
+ // let, not const: map.setLegend(value) switches them at runtime (see setLegendOption)
2929
+ let legendOn = legendIsOn(legendOpt);
2930
+ let legendFolded = legendOpt === 'closed';
2931
+ {
2932
+ // Map-level `align` option positions the legend panel — a map-
2933
+ // wide placement choice, not per-theme (unlike legendtheme/
2934
+ // legendfilter above), so read once from builder.mapOptions rather
2935
+ // than per-runtime. Real ixmaps-flat ALSO has an `align` map
2936
+ // option (ui/js/htmlgui_flat.js) controlling legend position, but
2937
+ // its value shape is a free-form regex-matched left/right/center/
2938
+ // top/bottom/pixel-prefixed string tied to a static in-page DOM
2939
+ // panel — a different layout model from this panel's own
2940
+ // position:absolute floating-overlay-over-the-map-canvas
2941
+ // approach. This port reuses the real OPTION NAME but adapts the
2942
+ // VALUE SHAPE to plain four-corner tokens matching how this panel
2943
+ // is actually positioned — a deliberate divergence, same
2944
+ // reasoning already applied to style.legendfilter's own name/
2945
+ // shape choice earlier this session. Default "top-right" per
2946
+ // explicit request (real engine's own fallback is right-anchored
2947
+ // too, just without an opinion on vertical placement).
2948
+ const ALIGN_CSS = {
2949
+ 'top-left': 'left:10px;top:10px;',
2950
+ 'top-right': 'right:10px;top:10px;',
2951
+ 'bottom-left': 'left:10px;bottom:10px;',
2952
+ 'bottom-right': 'right:10px;bottom:10px;'
2953
+ };
2954
+ // gl's four corners, else flat's own values (htmlgui_flat.js
2955
+ // 421-470): "…left" → left (the part before "left", else 55px), 12px
2956
+ // from the top; "…right" → right (else 25px); "center" / "top" → top
2957
+ // center
2958
+ const flatAlignCss = align => {
2959
+ const a = String(align || '');
2960
+ if (/left/.test(a)) return 'left:' + (a.split('left')[0] || '55px') + ';top:12px;';
2961
+ if (/right/.test(a)) return 'right:' + (a.split('right')[0] || '25px') + ';top:10px;';
2962
+ if (a === 'center' || a === 'top') return 'left:50%;transform:translateX(-50%);top:10px;';
2963
+ return null;
2964
+ };
2965
+ const legendAlignCss = ALIGN_CSS[builder.mapOptions.align] || flatAlignCss(builder.mapOptions.align) || ALIGN_CSS['top-right'];
2966
+ // Real ixmaps-flat's SUB-THEME (MapTheme.createSubTheme,
2967
+ // maptheme.js 14284-14292 / 15010-15160; map.Themes.enableSubThemes
2968
+ // defaults to true): marking fields of a multi-field choropleth
2969
+ // (DOMINANT/COMPOSECOLOR, not CHART) paints a new theme over the
2970
+ // original — one field: CHOROPLETH|LOG|DOPACITY on that field, white
2971
+ // → the field's color in 7 steps, dopacityscale 2, dopacitypow 1,
2972
+ // keeping DOPACITY(→DOPACITYMAX)/AGGREGATE/GROUP/SUM/ZEROISVALUE/
2973
+ // VALUES; several fields: the original type on just those fields
2974
+ // and colors. It reuses the original's joined polygons, has no
2975
+ // legend and stays out of the theme registry (its name
2976
+ // "<layer>::subtheme" also keeps its deck.gl layer id apart), so
2977
+ // markThemeClass/removeTheme still address the original.
2978
+ const subThemeCapable = rt => rt.flags.has('CHOROPLETH') && !rt.flags.has('CHART') && (rt.flags.has('DOMINANT') || rt.flags.has('COMPOSECOLOR'));
2979
+ 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;
2980
+ updateSubTheme = (rt) => {
2981
+ if (rt._subTheme) {
2982
+ const i = runtimes.indexOf(rt._subTheme);
2983
+ if (i >= 0) runtimes.splice(i, 1);
2984
+ rt._subTheme = null;
2985
+ }
2986
+ const fields = String(rt.binding.value || '').split('|');
2987
+ const marks = [...rt._markedClasses].filter(i => i >= 0 && i < fields.length).sort((a, b) => a - b);
2988
+ if (marks.length) {
2989
+ const colors = (rt.categoryColorsRgb || []).map(toHex);
2990
+ const labels = rt.categoryDisplayLabels || rt.categoryLabels || fields;
2991
+ const kept = ['DOPACITY', 'AGGREGATE', 'GROUP', 'SUM', 'ZEROISVALUE', 'VALUES']
2992
+ .filter(f => [...rt.flags].some(x => x.startsWith(f) && (f !== 'DOPACITY' || /^DOPACITY/.test(x))))
2993
+ .map(f => (f === 'DOPACITY' ? 'DOPACITYMAX' : f));
2994
+ let def;
2995
+ if (marks.length > 1) {
2996
+ def = {
2997
+ layer: rt.name + '::subtheme',
2998
+ binding: (b => { delete b.field100; delete b.value100; return b; })(Object.assign({}, rt.binding, { value: marks.map(i => fields[i]).join('|') })),
2999
+ style: Object.assign({}, rt.style, { type: [...rt.flags].concat(['SUBTHEME', 'NOLEGEND']).join('|'),
3000
+ colorscheme: marks.map(i => colors[i]), values: marks.map(i => labels[i]) }),
3001
+ meta: { title: 'actual selection' }
3002
+ };
3003
+ } else {
3004
+ const i = marks[0];
3005
+ const binding = Object.assign({}, rt.binding, { value: fields[i] });
3006
+ delete binding.alpha; delete binding.alpha100;
3007
+ // rt.features already carry the field100 result — drop it and
3008
+ // every alias of it, or normalizeTheme would apply it again
3009
+ delete binding.field100; delete binding.value100;
3010
+ def = {
3011
+ layer: rt.name + '::subtheme',
3012
+ binding,
3013
+ style: { type: ['CHOROPLETH', 'LOG', 'DOPACITY', 'NOLEGEND', 'SUBTHEME'].concat(kept).join('|'),
3014
+ colorscheme: ['7', 'white', colors[i]], dopacityscale: 2, dopacitypow: 1,
3015
+ units: rt.style.units, linecolor: rt.style.linecolor, linewidth: rt.style.linewidth },
3016
+ meta: { title: 'actual selection' }
3017
+ };
3018
+ }
3019
+ const sub = new LayerRuntime(normalizeTheme(def), { type: 'FeatureCollection', features: rt.features }, builder._engineOptions);
3020
+ sub._isSubTheme = true;
3021
+ runtimes.splice(runtimes.indexOf(rt) + 1, 0, sub);
3022
+ rt._subTheme = sub;
3023
+ }
3024
+ refresh();
3025
+ notifyRedraw();
3026
+ };
3027
+ // the stack of the theme panels — flat shows one theme's legend
3028
+ // after the other in #map-legend, separated by a line, and leaves
3029
+ // out a theme hidden, out of scale or not yet drawn
3030
+ let legendStack = null;
3031
+ const legendStackEl = shadow => {
3032
+ if (legendStack && legendStack.parentNode) return legendStack;
3033
+ legendStack = document.createElement('div');
3034
+ legendStack.className = 'ix-legend-stack';
3035
+ legendStack.style.cssText = 'position:absolute;' + legendAlignCss + 'z-index:6;width:280px;'
3036
+ + 'display:flex;flex-direction:column;max-height:calc(100% - 24px);overflow-y:auto;'
3037
+ + 'border-radius:6px;box-shadow:' + shadow + ';pointer-events:auto;';
3038
+ if (!document.getElementById('ix-legend-stack-css')) {
3039
+ const css = document.createElement('style');
3040
+ css.id = 'ix-legend-stack-css';
3041
+ 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)}';
3042
+ document.head.appendChild(css);
3043
+ }
3044
+ el.parentElement.appendChild(legendStack);
3045
+ return legendStack;
3046
+ };
3047
+ // map.setLegend(value): the `legend` map option at runtime, same values as
3048
+ // ixmaps.Map(id, {legend}) — on ("true", "open", …), folded ("closed"), off
3049
+ // (false/"false"/0/undefined). Panels are made for themes that lack one,
3050
+ // removed when off, folded/unfolded otherwise.
3051
+ setLegendOption = (opt) => {
3052
+ builder.mapOptions.legend = opt;
3053
+ legendFolded = opt === 'closed';
3054
+ legendOn = legendIsOn(opt);
3055
+ runtimes.forEach(rt => {
3056
+ if (!legendOn) {
3057
+ if (rt._legendPanel && rt._legendPanel.parentNode) rt._legendPanel.parentNode.removeChild(rt._legendPanel);
3058
+ rt._legendPanel = null;
3059
+ } else if (!rt._legendPanel) addLegendPanel(rt);
3060
+ else if (rt._setLegendCollapsed) rt._setLegendCollapsed(legendFolded);
3061
+ });
3062
+ layoutLegends();
3063
+ };
3064
+ layoutLegends = () => {
3065
+ if (!legendStack) return;
3066
+ const z = map.getZoom();
3067
+ let shown = 0;
3068
+ [...legendStack.children].forEach(panel => {
3069
+ const rt = panel._ixRuntime;
3070
+ const off = !rt || rt._hidden || themeHiddenByScale(rt.flags, rt.style, z);
3071
+ if (off) { panel.setAttribute('data-ixoff', ''); panel.style.display = 'none'; }
3072
+ else { panel.removeAttribute('data-ixoff'); panel.style.display = 'flex'; shown++; }
3073
+ });
3074
+ legendStack.style.display = shown ? 'flex' : 'none';
3075
+ };
3076
+ const legendApplies = rt => rt.categoryLabels && rt.categoryLabels.length && !rt.flags.has('FEATURE') && !rt.flags.has('FEATURES') && !rt.flags.has('NOLEGEND');
3077
+ // One theme's panel — at build time for every theme, and again for a
3078
+ // theme defined later (map.layer(...) in a myMap.then(...) chain,
3079
+ // defineLayer, loadProject); removeTheme removes it with the theme.
3080
+ addLegendPanel = (rt) => {
3081
+ // marking a class (legend row, or ixmaps.markThemeClass from a
3082
+ // page) redraws — for every theme, legend panel or not
3083
+ rt._triggerRedraw = () => { refresh(); };
3084
+ if (subThemeCapable(rt)) rt._onMarksChanged = () => updateSubTheme(rt);
3085
+ if (!legendOn || !el.parentElement || !legendApplies(rt)) return;
3086
+ el.parentElement.style.position = el.parentElement.style.position || 'relative';
3087
+ {
3088
+ // .type("...|NOLEGEND") — the fourth real legend-related type()
3089
+ // token: opts a theme OUT of the legend entirely (real engine's
3090
+ // own per-layer "skip this one" flag, distinct from the map-
3091
+ // level legend:"open"/"closed" option gating the whole panel).
3092
+ // Same free-parsing story as SIMPLELEGEND/COMPACTLEGEND above —
3093
+ // just one more flag to exclude on, no new plumbing.
3094
+ const panel = document.createElement('div');
3095
+ rt._legendPanel = panel;
3096
+ panel.className = 'ix-native-legend';
3097
+ // Opt-in via .style({legendtheme:"light"}) — a NEW, ixmaps-gl-
3098
+ // only convention (same naming pattern as the sibling
3099
+ // legendfilter/legendunits keys added earlier this session):
3100
+ // real ixmaps-flat's legend.js has no such switch, this panel
3101
+ // is entirely this port's own construction. Default ("dark")
3102
+ // preserves the original always-dark panel exactly; "light" is
3103
+ // for pages using a light basemap (e.g. CARTO Positron) where
3104
+ // a dark semi-transparent panel reads as a mismatched dark
3105
+ // patch rather than legend chrome. Every other hardcoded panel
3106
+ // color below (rows, bar track, filter <select>) is expressed
3107
+ // in terms of this one palette so the two variants stay in
3108
+ // sync — no separate light/dark branch anywhere else.
3109
+ // flat's look (flatLegendLook) unless the page picks one with
3110
+ // this engine's own legendtheme
3111
+ const opacityOpt = parseFloat(builder._engineOptions.basemapopacity);
3112
+ const flatLook = flatLegendLook(builder.mapOptions.mapType, isNaN(opacityOpt) ? 1 : opacityOpt, builder.mapOptions.legendBackground || builder.mapOptions.legendbackground);
3113
+ const isLightLegend = rt.style.legendtheme === 'light' || (rt.style.legendtheme !== 'dark' && !flatLook.dark);
3114
+ const legendColors = isLightLegend
3115
+ ? { bg: 'rgba(255,255,255,0.92)', fg: '#1a1a1a', shadow: '0 2px 10px rgba(0,0,0,0.18)',
3116
+ rowMarked: 'rgba(0,0,0,0.08)', track: 'rgba(0,0,0,0.08)',
3117
+ selectBg: 'rgba(0,0,0,0.04)', selectBorder: 'rgba(0,0,0,0.2)' }
3118
+ : { bg: 'rgba(28,30,34,0.92)', fg: '#eee', shadow: '0 2px 10px rgba(0,0,0,0.45)',
3119
+ rowMarked: 'rgba(255,255,255,0.14)', track: 'rgba(255,255,255,0.08)',
3120
+ selectBg: 'rgba(255,255,255,0.08)', selectBorder: 'rgba(255,255,255,0.25)' };
3121
+ if (!rt.style.legendtheme) legendColors.bg = flatLook.bg;
3122
+ // max-height:66% resolves against el.parentElement's own
3123
+ // height (the map container, which always has a definite
3124
+ // height for the map itself to render into) since this panel
3125
+ // is absolutely positioned inside it — a plain percentage on
3126
+ // an absolutely-positioned element's height IS legal CSS as
3127
+ // long as its containing block has a definite height, which
3128
+ // this one does. display:flex column + min-height:0 on the
3129
+ // ROWS wrapper below (not this outer panel) is what makes
3130
+ // only the row list scroll while the header/description/
3131
+ // slider stay fixed in place — a flex child won't actually
3132
+ // shrink to fit and scroll internally without min-height:0,
3133
+ // it just overflows its flex parent instead.
3134
+ // one box for every theme, as flat's #map-legend (legend.js
3135
+ // 2200-2460): the panels stack in the legend stack in theme
3136
+ // order, a line between them (see layoutLegends)
3137
+ panel.style.cssText = 'position:relative;flex:0 0 auto;'
3138
+ + 'display:flex;flex-direction:column;'
3139
+ + 'background:' + legendColors.bg + ';color:' + legendColors.fg + ';font:12px/1.4 -apple-system,Arial,sans-serif;'
3140
+ + 'padding:10px 12px 12px;pointer-events:auto;';
3141
+ const stack = legendStackEl(legendColors.shadow);
3142
+ const at = runtimes.indexOf(rt);
3143
+ const next = [...stack.children].find(c => runtimes.indexOf(c._ixRuntime) > at);
3144
+ panel._ixRuntime = rt;
3145
+ stack.insertBefore(panel, next || null);
3146
+
3147
+ // SUM+valuefield mirrors the real engine's own "SUM style
3148
+ // aggregation" reading (style.valuefield, falling back to
3149
+ // the bound size field); anything else (plain CATEGORICAL,
3150
+ // no SUM) falls back to a per-category record COUNT.
3151
+ const useSum = flatFlag(rt.flags, 'SUM') && rt.style.valuefield;
3152
+ const valueField = rt.style.valuefield || rt.binding.size;
3153
+ // legendunits wins over the theme's general-purpose units
3154
+ // (used elsewhere for tooltips, e.g. _renderItemChartHtml) —
3155
+ // a page may want a different/no unit string specifically on
3156
+ // the legend's own value column. Appended as-is (no extra
3157
+ // space injected), matching how style.units/legendunits are
3158
+ // themselves authored with their own leading space (e.g.
3159
+ // " MW") in real pages.
3160
+ const legendUnit = rt.style.legendunits || rt.style.units || '';
3161
+ // row labels go into innerHTML: the page's own .style({label})
3162
+ // stays HTML, as in flat; categories read from the data are
3163
+ // escaped (a value could carry markup)
3164
+ const labels = legendRowLabels(rt);
3165
+ // .type("...|TEXTLEGEND") — the FIFTH real legend-related
3166
+ // type() token: title/snippet/description text only, no
3167
+ // category rows at all (swatches, bars, chips — none of it),
3168
+ // per explicit correction. Everything below that builds the
3169
+ // rows/totals/onRedraw-recompute wiring is skipped outright
3170
+ // rather than built-then-hidden — there is nothing for any of
3171
+ // it to feed once no rows ever render. The country-filter
3172
+ // dropdown and Chart-size slider stay: neither is a
3173
+ // "categorical item" or "colorscheme swatch", both are
3174
+ // independent controls unrelated to the row list.
3175
+ const isTextOnly = rt.flags.has('TEXTLEGEND');
3176
+
3177
+ // Map-view-aware per explicit request: totals reflect only
3178
+ // what's CURRENTLY on screen (the map's own bounds, plus the
3179
+ // same far-hemisphere exclusion _buildChartLayers itself
3180
+ // applies under globe projection — see isOnVisibleHemisphere)
3181
+ // rather than the whole dataset — recomputed on every redraw
3182
+ // (engineApi.onRedraw, below) so panning/zooming/rotating
3183
+ // updates the bars and values live, the same way the actual
3184
+ // rendered bubbles change. Same simple rectangular bbox
3185
+ // membership test _computeAggregatedItems's own non-AGGREGATE
3186
+ // branch uses (no antimeridian wraparound handling — matches
3187
+ // that existing convention, not a new gap this introduces).
3188
+ let totals, maxTotal, order, rowsScroll;
3189
+ // No-op default: TEXTLEGEND never reassigns this (see below),
3190
+ // so the LATER unconditional-looking `renderRows()` call
3191
+ // safely does nothing rather than needing its own isTextOnly
3192
+ // guard at every call site.
3193
+ let renderRows = () => {};
3194
+ if (!isTextOnly) {
3195
+ function computeTotals() {
3196
+ const bounds = map.getBounds();
3197
+ const bbox = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()];
3198
+ const proj = (typeof map.getProjection === 'function' && map.getProjection()) || { type: 'mercator' };
3199
+ const globeCenter = proj.type === 'globe' ? map.getCenter() : null;
3200
+ totals = new Array(rt.categoryLabels.length).fill(0);
3201
+ 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; }
3202
+ // _activeFeatures (set by setFacetFilter/clearFacetFilter,
3203
+ // e.g. the country-select dropdown below) is the facet-
3204
+ // filtered subset when a filter is active, null otherwise —
3205
+ // reading it here keeps the legend's own numbers consistent
3206
+ // with whatever the country filter is currently narrowing
3207
+ // the map down to, not just with the viewport/hemisphere.
3208
+ (rt._activeFeatures || rt.features).forEach(f => {
3209
+ const idx = rt.categoryIndexByLabel ? rt.categoryIndexByLabel.get(f.properties[rt.binding.value]) : null;
3210
+ if (idx == null) return;
3211
+ const [lng, lat] = f.geometry.coordinates;
3212
+ if (lng < bbox[0] || lng > bbox[2] || lat < bbox[1] || lat > bbox[3]) return;
3213
+ if (globeCenter && !isOnVisibleHemisphere(lng, lat, globeCenter)) return;
3214
+ totals[idx] += useSum ? (parseFloat(f.properties[valueField]) || 0) : 1;
3215
+ });
3216
+ maxTotal = Math.max(0, ...totals);
3217
+ order = totals.map((v, i) => i).sort((a, b) => totals[b] - totals[a]);
3218
+ }
3219
+ computeTotals();
3220
+
3221
+ // Only THIS wrapper scrolls (flex:1 1 auto + min-height:0 lets
3222
+ // it shrink to whatever's left of the panel's own 66%-height
3223
+ // cap once the fixed header/description/slider take their
3224
+ // share, then overflow-y:auto scrolls just the row list) —
3225
+ // the header above and description/slider below stay put.
3226
+ rowsScroll = document.createElement('div');
3227
+ rowsScroll.style.cssText = 'flex:1 1 auto;min-height:0;overflow-y:auto;';
3228
+ const rowsEl = document.createElement('div');
3229
+ rowsEl.className = 'ix-legend-rows';
3230
+ // .type("...|SIMPLELEGEND") — real ixmaps-flat's OTHER legend
3231
+ // variant (confirmed against a real-engine screenshot, per
3232
+ // explicit correction): swatch + label chips only, no bar, no
3233
+ // value column. SIMPLELEGEND is just another pipe-delimited
3234
+ // type() token — MapBuilder#type() already puts every token
3235
+ // into this.flags regardless of whether anything reads it, so
3236
+ // no new parsing was needed, only this render-mode branch.
3237
+ // flex-wrap here (vs. the bar mode's block rows) — chips wrap
3238
+ // to the panel's own width the same loose way the real
3239
+ // engine's screenshot shows (an uneven number of chips per
3240
+ // row, driven by each label's own text length, not a fixed
3241
+ // column grid).
3242
+ // .type("...|COMPACTLEGEND") — the THIRD real legend variant
3243
+ // (again confirmed against a real-engine screenshot, per
3244
+ // explicit correction): the bar-mode's own two-line label/bar/
3245
+ // value row, but wrapped into a multi-column flex-wrap grid
3246
+ // instead of one full-width row per category — each item's
3247
+ // own width follows its content (a bigger value's longer bar
3248
+ // makes its own item wider, so fewer fit per line; Coal alone
3249
+ // filled its own row in the reference screenshot while the
3250
+ // smaller categories packed 3-4 per row) rather than a fixed
3251
+ // column count.
3252
+ if (rt.flags.has('SIMPLELEGEND') || rt.flags.has('COMPACTLEGEND')) {
3253
+ rowsEl.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;';
3254
+ }
3255
+ rowsScroll.appendChild(rowsEl);
3256
+
3257
+ // Two lines per row — label on its own full-width line, then
3258
+ // swatch+bar+value below — per explicit correction: a single
3259
+ // shared line left too little room for the bar to read as
3260
+ // proportional (it was squeezed between the label text and a
3261
+ // fixed-width value column). The bar itself lives inside a
3262
+ // flex:1 "track" div sized to whatever's left after the fixed
3263
+ // swatch/value columns, and is a PERCENTAGE of that track's
3264
+ // own width (not a pixel value) — real formula's per-unit-
3265
+ // factor/cap (legend.js:980-984/946-950) collapses to a plain
3266
+ // linear percentage-of-max once the bar's available width
3267
+ // isn't a fixed constant this code has to guess at.
3268
+ const isSimple = rt.flags.has('SIMPLELEGEND');
3269
+ const isCompact = rt.flags.has('COMPACTLEGEND');
3270
+ // Compact mode's bar can't use the full-width mode's
3271
+ // percentage-of-flex-track trick (there's no full-width track
3272
+ // to be a percentage OF once the row itself is content-sized,
3273
+ // not stretched) — a plain pixel width, proportional to value
3274
+ // and capped, same shape as the full-width formula just
3275
+ // expressed in absolute px instead of a % of an elastic track.
3276
+ const COMPACT_MAX_BAR_PX = 70;
3277
+ // flat's compact legend (legend.js makeColorLegendHTML, the
3278
+ // "compact" mode the theme legends use, 1212-1257): a range
3279
+ // theme of 5 classes or more without labels is one line of
3280
+ // color patches with the value range below; DOPACITY with an
3281
+ // alpha field adds two paler lines (opacity 1/2, 1/3) and the
3282
+ // alpha max, "↓" and min to their right (1062-1205). The
3283
+ // legend's own __formatValue (legend.js 451-553) has no SPACE
3284
+ // form: SPACE there is flat's plain "." thousands.
3285
+ const flatCompactLegend = rt._rangeClassed && !flatFlag(rt.flags, 'CATEGORICAL') && !flatFlag(rt.flags, 'PLOT')
3286
+ && (rt.partsA || []).length >= 5 && !(Array.isArray(rt.style.label) && rt.style.label.length);
3287
+ // flat's single-color legend (legend.js 765-769, 1039-1046): a range
3288
+ // theme of at most 2 classes without label/ranges, not
3289
+ // CATEGORICAL, is one row — the swatch and "min ... max unit",
3290
+ // or, when every value is 1 (a "$item$" count), its label or
3291
+ // value field
3292
+ const flatSingleRowLegend = rt._rangeClassed && !flatFlag(rt.flags, 'CATEGORICAL') && !flatFlag(rt.flags, 'PLOT')
3293
+ && (rt.partsA || []).length <= 2 && !(Array.isArray(rt.style.label) && rt.style.label.length)
3294
+ && !(Array.isArray(rt.style.ranges) && rt.style.ranges.length);
3295
+ if (flatSingleRowLegend) {
3296
+ renderRows = function() {
3297
+ const rgb = rt.categoryColorsRgb[0] || [128, 128, 128];
3298
+ const unit = String(rt.style.legendunits || rt.style.units || '').replace(/ /g, '&nbsp;');
3299
+ const vMin = styleNum(rt.style.minvalue) || rt._valueMin, vMax = styleNum(rt.style.maxvalue) || rt._valueMax;
3300
+ const label = rt.style.label != null && !Array.isArray(rt.style.label) ? String(rt.style.label) : String(rt.binding.value || '');
3301
+ const text = vMin !== 1 || vMax !== 1
3302
+ ? flatFormatValue(vMin, 2, 'BLANK') + ' &nbsp;... ' + flatFormatValue(vMax, 2, 'BLANK') + ' ' + unit
3303
+ : label;
3304
+ const marked = rt._markedClasses.has(0);
3305
+ 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;'
3306
+ + 'background:' + (marked ? legendColors.rowMarked : 'transparent') + ';">'
3307
+ + '<span style="flex:0 0 auto;width:1.6em;height:0.8em;background:rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ');"></span>'
3308
+ + '<span>' + text + '</span></div>';
3309
+ rowsEl.querySelectorAll('.ix-legend-row').forEach(row => {
3310
+ row.addEventListener('click', () => {
3311
+ if (rt._markedClasses.has(0)) unmarkRuntimeClass(rt, 0);
3312
+ else markRuntimeClass(rt, 0);
3313
+ });
3314
+ });
3315
+ };
3316
+ } else
3317
+ if (flatCompactLegend) {
3318
+ renderRows = function() {
3319
+ const n = rt.categoryColorsRgb.length;
3320
+ const withAlpha = flatFlag(rt.flags, 'DOPACITY') && rt.binding.alpha && rt._alphaMax;
3321
+ const nLines = withAlpha ? 3 : 1;
3322
+ const dec = rt.style.decimals != null ? parseInt(rt.style.decimals, 10) : 2;
3323
+ const unit = rt.style.legendunits || rt.style.units || '';
3324
+ const ranges = Array.isArray(rt.style.ranges) && rt.style.ranges.length ? rt.style.ranges.map(Number) : null;
3325
+ const vMin = ranges ? ranges[0] : rt._valueMin, vMax = ranges ? ranges[ranges.length - 1] : rt._valueMax;
3326
+ let html = '<div style="display:grid;grid-template-columns:repeat(' + n + ',1fr)' + (withAlpha ? ' auto' : '') + ';gap:1px;align-items:center;">';
3327
+ for (let line = 0; line < nLines; line++) {
3328
+ for (let c = 0; c < n; c++) {
3329
+ const ix = flatFlag(rt.flags, 'INVERT') ? n - c - 1 : c;
3330
+ const rgb = rt.categoryColorsRgb[ix];
3331
+ const part = rt.partsA[ix];
3332
+ const title = part ? flatFormatValue(part.min, dec, '') + unit + ' ... ' + flatFormatValue(part.max, dec, '') + unit : '';
3333
+ const marked = rt._markedClasses.has(ix);
3334
+ html += '<span class="ix-legend-row" data-idx="' + ix + '" title="' + title + '" style="cursor:pointer;height:14px;'
3335
+ + 'background:rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ');opacity:' + (1 / (line + 1)) + ';'
3336
+ + (marked ? 'outline:2px solid ' + legendColors.fg + ';outline-offset:-2px;' : '') + '"></span>';
3337
+ }
3338
+ if (withAlpha) {
3339
+ const txt = line === 0 ? flatFormatValue(rt._alphaMax, 0, 'BLANK')
3340
+ : line === 1 ? '&#8595; ' + (rt.style.alphavalueunits || '') : flatFormatValue(rt._alphaMin || 0, 0, 'BLANK');
3341
+ html += '<span style="padding-left:0.5em;white-space:nowrap;">' + txt + '</span>';
3342
+ }
3343
+ }
3344
+ html += '</div><div style="display:flex;justify-content:space-between;margin-top:3px;' + (withAlpha ? 'margin-right:4em;' : '') + '">'
3345
+ + '<span>' + flatFormatValue(vMin, dec, '') + (unit ? ' ' + unit : '') + '</span>'
3346
+ + '<span>' + flatFormatValue(vMax, dec, '') + (unit && unit.length <= 3 ? ' ' + unit : '') + '</span></div>';
3347
+ rowsEl.innerHTML = html;
3348
+ rowsEl.querySelectorAll('.ix-legend-row').forEach(cell => {
3349
+ cell.addEventListener('click', () => {
3350
+ const idx = parseInt(cell.dataset.idx, 10);
3351
+ if (rt._markedClasses.has(idx)) unmarkRuntimeClass(rt, idx);
3352
+ else markRuntimeClass(rt, idx);
3353
+ });
3354
+ });
3355
+ };
3356
+ } else
3357
+ renderRows = function() {
3358
+ // flat drops the rows without a count once any row has one
3359
+ // (legend.js 754-760, 787-789: fCountBars && !count)
3360
+ rowsEl.innerHTML = order.filter(i => !(maxTotal > 0) || totals[i]).map(i => {
3361
+ const rgb = rt.categoryColorsRgb[i];
3362
+ const color = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
3363
+ const marked = rt._markedClasses.has(i);
3364
+ const dimmed = rt._markedClasses.size > 0 && !marked;
3365
+ const rowStyle = 'padding:4px 3px;border-radius:4px;cursor:pointer;opacity:' + (dimmed ? 0.4 : 1) + ';'
3366
+ + 'background:' + (marked ? legendColors.rowMarked : 'transparent') + ';';
3367
+ if (isSimple) {
3368
+ // Swatch + label only, no bar/value — see rowsEl's own
3369
+ // comment above for why (SIMPLELEGEND).
3370
+ return '<div class="ix-legend-row" data-idx="' + i + '" style="' + rowStyle
3371
+ + 'display:inline-flex;align-items:center;gap:5px;padding:4px 8px;">'
3372
+ + '<span style="flex:0 0 8px;width:8px;height:8px;border-radius:50%;background:' + color + ';"></span>'
3373
+ + '<span>' + labels[i] + '</span>'
3374
+ + '</div>';
3375
+ }
3376
+ const pct = maxTotal ? Math.max(0, Math.min(100, (totals[i] / maxTotal) * 100)) : 0;
3377
+ if (isCompact) {
3378
+ // Same two-line label/bar/value shape as full-width bar
3379
+ // mode below, just content-sized (flex:0 0 auto, no
3380
+ // min-width:0 flex-1 track) so multiple items pack onto
3381
+ // one line inside rowsEl's own flex-wrap — see
3382
+ // COMPACTLEGEND's own comment on rowsEl above.
3383
+ const barPx = Math.max(2, (pct / 100) * COMPACT_MAX_BAR_PX);
3384
+ return '<div class="ix-legend-row" data-idx="' + i + '" style="' + rowStyle + 'flex:0 0 auto;">'
3385
+ + '<div style="margin-bottom:3px;white-space:nowrap;">' + labels[i] + '</div>'
3386
+ + '<div style="display:flex;align-items:center;gap:6px;">'
3387
+ + '<span style="flex:0 0 8px;width:8px;height:8px;border-radius:50%;background:' + color + ';"></span>'
3388
+ + '<span style="flex:0 0 auto;width:' + barPx + 'px;height:6px;background:' + color + ';border-radius:3px;"></span>'
3389
+ + '<span style="flex:0 0 auto;white-space:nowrap;">' + rt._formatTooltipValue(totals[i]) + legendUnit + '</span>'
3390
+ + '</div>'
3391
+ + '</div>';
3392
+ }
3393
+ return '<div class="ix-legend-row" data-idx="' + i + '" style="' + rowStyle + '">'
3394
+ + '<div style="margin-bottom:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + labels[i] + '</div>'
3395
+ + '<div style="display:flex;align-items:center;gap:6px;">'
3396
+ + '<span style="flex:0 0 8px;width:8px;height:8px;border-radius:50%;background:' + color + ';"></span>'
3397
+ + '<span style="flex:1 1 auto;min-width:0;height:6px;background:' + legendColors.track + ';border-radius:3px;overflow:hidden;">'
3398
+ + '<span style="display:block;height:100%;width:' + pct + '%;background:' + color + ';border-radius:3px;"></span>'
3399
+ + '</span>'
3400
+ + '<span style="flex:0 0 auto;text-align:right;min-width:60px;">' + rt._formatTooltipValue(totals[i]) + legendUnit + '</span>'
3401
+ + '</div>'
3402
+ + '</div>';
3403
+ }).join('');
3404
+ // GL-PORT COMPAT: real ixmaps-flat's row markup wires its
3405
+ // click handler inline (onclick="ixmaps.markThemeClass(...)"
3406
+ // — legend.js:817), toggle decided by the caller. Same
3407
+ // split here: the toggle check lives in this click
3408
+ // handler, the actual add/remove-and-redraw primitive is
3409
+ // markRuntimeClass/unmarkRuntimeClass, which the globals
3410
+ // ixmaps.markThemeClass/unmarkThemeClass also use — so a
3411
+ // page's OWN custom UI drives this exact legend too. The
3412
+ // rows pass their runtime itself: its .layer() name may be
3413
+ // shared with other themes of the page.
3414
+ rowsEl.querySelectorAll('.ix-legend-row').forEach(rowEl => {
3415
+ rowEl.addEventListener('click', () => {
3416
+ const idx = parseInt(rowEl.dataset.idx, 10);
3417
+ if (rt._markedClasses.has(idx)) unmarkRuntimeClass(rt, idx);
3418
+ else markRuntimeClass(rt, idx);
3419
+ });
3420
+ });
3421
+ };
3422
+
3423
+ // Recompute+re-render on EVERY redraw, not just mark toggles —
3424
+ // this is what makes the legend map-view-aware: a plain pan/
3425
+ // zoom/rotate fires this too (via the map's own 'move'
3426
+ // listener -> scheduleRefresh -> notifyRedraw, debounced the
3427
+ // same 400ms as the facets sidebar's own onRedraw use), same
3428
+ // signal already used elsewhere in this file for "the visible
3429
+ // data just changed, recompute".
3430
+ engineApi.onRedraw(() => { computeTotals(); renderRows(); });
3431
+ }
3432
+
3433
+ // rt._triggerRedraw: see its own doc comment on LayerRuntime
3434
+ // (set here rather than at construction time since refresh()
3435
+ // doesn't exist yet when the runtime is built). Kept
3436
+ // unconditional (even for TEXTLEGEND, which never registers
3437
+ // the onRedraw recompute above) — a page can still call
3438
+ // ixmaps.markThemeClass/unmarkThemeClass directly with no
3439
+ // visual row list to click, and that should still redraw the
3440
+ // MAP's own dim/isolate effect even without a legend UI for
3441
+ // it, matching real-engine global-API availability regardless
3442
+ // of legend style.
3443
+ rt._triggerRedraw = () => { refresh(); };
3444
+
3445
+ // Header row (title/snippet + collapse toggle) always stays
3446
+ // visible; everything else lives in `bodyEl`, hidden/shown as
3447
+ // a single unit by the toggle below — collapsing shows just
3448
+ // the title bar, matching the real engine's own fold/unfold
3449
+ // behavior (ixmaps.legendState + the "legend-folded" CSS
3450
+ // class, ui/js/tools/legend.js) even though this port toggles
3451
+ // via a plain display swap on one wrapper div rather than a
3452
+ // shared stylesheet class (this panel has no external CSS at
3453
+ // all — everything here is inline-styled, unlike the real
3454
+ // engine's DOM/CSS-class-driven legend).
3455
+ const header = document.createElement('div');
3456
+ header.style.cssText = 'display:flex;align-items:flex-start;justify-content:space-between;gap:6px;';
3457
+ const headerText = document.createElement('div');
3458
+ headerText.style.cssText = 'min-width:0;flex:1 1 auto;';
3459
+ let headerHtml = '';
3460
+ if (rt.meta.title) headerHtml += '<div style="font-weight:600;font-size:13px;margin-bottom:2px;">' + rt.meta.title + '</div>';
3461
+ if (rt.meta.snippet) headerHtml += '<div style="opacity:0.75;">' + rt.meta.snippet + '</div>';
3462
+ headerText.innerHTML = headerHtml;
3463
+ header.appendChild(headerText);
3464
+ const collapseBtn = document.createElement('button');
3465
+ collapseBtn.type = 'button';
3466
+ collapseBtn.style.cssText = 'flex:0 0 auto;background:transparent;border:none;color:inherit;'
3467
+ + 'font-size:14px;line-height:1;cursor:pointer;padding:2px 4px;opacity:0.7;';
3468
+ header.appendChild(collapseBtn);
3469
+ panel.appendChild(header);
3470
+
3471
+ const bodyEl = document.createElement('div');
3472
+ bodyEl.style.cssText = 'display:flex;flex-direction:column;min-height:0;flex:1 1 auto;margin-top:8px;';
3473
+ panel.appendChild(bodyEl);
3474
+
3475
+ // Collapsed by default under the real engine's own narrow-
3476
+ // screen legend threshold (ui/js/tools/legend.js: `if
3477
+ // (window.innerWidth < 500) ixmaps.legend.hide()`) — reusing
3478
+ // that concrete precedent rather than picking an arbitrary
3479
+ // "mobile" breakpoint of this port's own invention. Checked
3480
+ // once at panel-build time, not on resize — matching the real
3481
+ // engine's own one-shot-at-redraw-time check, not a live
3482
+ // matchMedia listener.
3483
+ const MOBILE_LEGEND_BREAKPOINT = 500;
3484
+ let collapsed = legendFolded || window.innerWidth < MOBILE_LEGEND_BREAKPOINT;
3485
+ function applyCollapsed() {
3486
+ bodyEl.style.display = collapsed ? 'none' : 'flex';
3487
+ collapseBtn.textContent = collapsed ? '▸' : '▾';
3488
+ collapseBtn.title = collapsed ? 'Expand legend' : 'Collapse legend';
3489
+ }
3490
+ collapseBtn.addEventListener('click', () => { collapsed = !collapsed; applyCollapsed(); });
3491
+ applyCollapsed();
3492
+ rt._setLegendCollapsed = c => { collapsed = !!c; applyCollapsed(); }; // setLegendOption
3493
+
3494
+ // Selection/filter by field — opt-in via .style({legendfilter:
3495
+ // "<field>"}), e.g. "country_long" on the power-plants sample.
3496
+ // A NEW, ixmaps-gl-only convention: real ixmaps-flat's own
3497
+ // style.filterfield names the DEFAULT field an unqualified
3498
+ // .filter("text") search term matches against (maptheme.js:
3499
+ // 1275-1276/7359-7362) — a different concept entirely, so this
3500
+ // deliberately uses its own name rather than overloading that
3501
+ // one. Options come from the FULL dataset (a world-spanning
3502
+ // bbox, not the current viewport) and stay fixed regardless of
3503
+ // pan/zoom — unlike the map-view-aware category totals below,
3504
+ // a picker whose own choices kept shrinking as you panned
3505
+ // would make it impossible to select a country not currently
3506
+ // in view. Selecting a value reuses the engine's own existing
3507
+ // facet-filter primitive (engineApi.setFacetFilter/
3508
+ // clearFacetFilter — already propagates to sibling runtimes
3509
+ // sharing the same data source and calls refresh()), so this
3510
+ // is UI wiring only, no new filtering logic.
3511
+ const filterField = rt.style.legendfilter;
3512
+ if (filterField) {
3513
+ const facet = engineApi.getFacets(rt.name, [filterField], { bbox: [-180, -85, 180, 85] })[0];
3514
+ const values = (facet && facet.type === 'textual')
3515
+ ? facet.values.slice().sort((a, b) => String(a).localeCompare(String(b)))
3516
+ : [];
3517
+ const filterEl = document.createElement('select');
3518
+ filterEl.style.cssText = 'width:100%;margin-bottom:8px;background:' + legendColors.selectBg + ';'
3519
+ + 'color:' + legendColors.fg + ';border:1px solid ' + legendColors.selectBorder + ';border-radius:4px;padding:4px 6px;font:inherit;';
3520
+ filterEl.innerHTML = '<option value="">All (' + values.length + ')</option>'
3521
+ + values.map(v => '<option value="' + escapeHtml(v) + '">' + escapeHtml(v) + '</option>').join('');
3522
+ filterEl.addEventListener('change', () => {
3523
+ if (filterEl.value) engineApi.setFacetFilter(rt.name, filterField, filterEl.value);
3524
+ else engineApi.clearFacetFilter(rt.name, filterField);
3525
+ });
3526
+ bodyEl.appendChild(filterEl);
3527
+ }
3528
+
3529
+ if (!isTextOnly) { bodyEl.appendChild(rowsScroll); renderRows(); }
3530
+
3531
+ if (rt.meta.description) {
3532
+ const desc = document.createElement('div');
3533
+ desc.style.cssText = 'margin-top:8px;font-size:11px;opacity:0.8;';
3534
+ // Raw HTML, matching the real engine's own description
3535
+ // rendering and this engine's existing .legend(html)/
3536
+ // tooltip conventions (e.g. _buildTooltipContext) — real
3537
+ // pages already embed their own source-citation markup
3538
+ // inside meta.description (see the power-plants sample).
3539
+ desc.innerHTML = rt.meta.description;
3540
+ bodyEl.appendChild(desc);
3541
+ }
3542
+
3543
+ // Chart-size slider — real engine's own range (legend.js:
3544
+ // 3196-3202), 100% == scale:1, wired to changeThemeStyle(
3545
+ // themeId,"scale:"+pct/100,"set")+redrawTheme there; this
3546
+ // engine's equivalent is rt.setStyle({scale}) + refresh(),
3547
+ // the same primitive engineApi.setThemeStyle uses above.
3548
+ // CHOROPLETH themes get an OPACITY slider instead, as in flat
3549
+ // (legend.js ~3190: 0-100%, fillopacity·100, default 90;
3550
+ // changeThemeStyle("fillopacity:"+pct/100,"set") + redraw).
3551
+ // (flat labels a VECTOR theme's size slider "Line width" — gl has
3552
+ // no VECTOR themes.)
3553
+ // Flat tests the words (legend.js 2533-2535): a "CHOROPLETHE"
3554
+ // theme has neither slider.
3555
+ const typeWords = rt.flags.typeString != null ? rt.flags.typeString : [...rt.flags].join('|');
3556
+ const isOpacitySlider = /\bCHOROPLETH\b/.test(typeWords);
3557
+ const hasSlider = isOpacitySlider || /\bCHART\b|\bBUBBLE\b|\bDOT\b/.test(typeWords);
3558
+ if (hasSlider) {
3559
+ const sliderRow = document.createElement('div');
3560
+ sliderRow.style.cssText = 'margin-top:10px;font-size:11px;opacity:0.8;';
3561
+ const fillPct = Math.round(styleNum(rt.style.fillopacity) * 100);
3562
+ const initialPct = isOpacitySlider
3563
+ ? (Number.isFinite(fillPct) ? Math.max(0, Math.min(100, fillPct)) : 90)
3564
+ : Math.round((styleNum(rt.style.scale) || 1) * 100);
3565
+ const sliderLabel = isOpacitySlider ? 'Opacity' : 'Chart size';
3566
+ sliderRow.innerHTML = sliderLabel + ': <span class="ix-legend-scale-val">' + initialPct + '</span>%';
3567
+ bodyEl.appendChild(sliderRow);
3568
+ const slider = document.createElement('input');
3569
+ slider.type = 'range';
3570
+ slider.min = isOpacitySlider ? '0' : '25';
3571
+ slider.max = isOpacitySlider ? '100' : '200';
3572
+ slider.value = String(initialPct);
3573
+ slider.style.cssText = 'width:100%;margin-top:2px;';
3574
+ slider.addEventListener('input', () => {
3575
+ const pct = parseInt(slider.value, 10);
3576
+ sliderRow.querySelector('.ix-legend-scale-val').textContent = pct;
3577
+ rt.setStyle(isOpacitySlider ? { fillopacity: pct / 100 } : { scale: pct / 100 });
3578
+ refresh();
3579
+ });
3580
+ bodyEl.appendChild(slider);
3581
+ }
3582
+ }
3583
+ };
3584
+ runtimes.forEach(rt => addLegendPanel(rt));
3585
+ }
3586
+ return { addLegendPanel, setLegendOption, updateSubTheme, layoutLegends };
3587
+ }
3588
+
2782
3589
  class MapBuilder {
2783
3590
  constructor(containerId, options) {
2784
3591
  this.containerId = containerId;
@@ -2813,7 +3620,13 @@
2813
3620
  // needs (a chart library, a user chart) — page code, like a <script>
2814
3621
  // tag; loaded in order before the layers (see loadRequiredScripts)
2815
3622
  require(url) { this._required = (this._required || []).concat(url); return this; }
2816
- attribution(a) { this._attributionText = a; return this; }
3623
+ // flat's .attribution(text): the page's attribution, bottom left on the
3624
+ // map (createAttribution) — also after the map is built
3625
+ attribution(a) {
3626
+ this._attributionText = a;
3627
+ if (this._setAttribution) this._setAttribution(a);
3628
+ return this;
3629
+ }
2817
3630
  legend(html) { this._legendHtml = html; return this; }
2818
3631
  // GL-PORT COMPAT: real ixmaps-flat's map.layer(name) returns a NEW
2819
3632
  // per-layer builder for a .data()...define() chain on the LAYER
@@ -3007,13 +3820,10 @@
3007
3820
  : resolveBasemapStyleUrl(this.mapOptions.mapType),
3008
3821
  center: [lon, lat],
3009
3822
  zoom: this._viewZoom != null && this._viewZoom !== '' ? flatToMapLibreZoom(Number(this._viewZoom)) : 8,
3010
- // .attribution(a) (MapBuilder, above) was stored but never read —
3011
- // unlike .legend()'s parallel _legendHtml, which the splash/legend
3012
- // block below actually renders. MapLibre's own AttributionControl
3013
- // is added automatically (not disabled anywhere in this file) and
3014
- // accepts extra text via customAttribution, appended alongside the
3015
- // basemap's own required CARTO/OpenStreetMap credit rather than
3016
- // replacing it.
3823
+ // MapLibre's own AttributionControl (bottom right) keeps the
3824
+ // basemap's required CARTO/OpenStreetMap credit; the page's own
3825
+ // .attribution() goes bottom left, as flat shows it
3826
+ // (createAttribution).
3017
3827
  //
3018
3828
  // A color `mapType` (mapTypeColor truthy — see
3019
3829
  // buildBlankBackgroundStyle above) has no real basemap at all, so
@@ -3024,7 +3834,6 @@
3024
3834
  // customAttribution's own required-credit behavior for a REAL
3025
3835
  // basemap is left untouched.
3026
3836
  ...(mapTypeColor ? { attributionControl: false } : {}),
3027
- ...(this._attributionText ? { customAttribution: this._attributionText } : {}),
3028
3837
  // MapLibre's default (true) is kept: with false it also stops the
3029
3838
  // zoom-out where the world gets narrower than the window, so a
3030
3839
  // world map can't open fully zoomed out. The deck.gl overlay takes
@@ -3122,112 +3931,8 @@
3122
3931
  });
3123
3932
  }
3124
3933
 
3125
- // Click-to-pin tooltip: matches the real ixmaps engine's own
3126
- // click-pins-the-tooltip convention in pan mode (a hover tooltip is
3127
- // transient; a click keeps one visible until dismissed). This is a
3128
- // separate DOM element from deck.gl's own hover tooltip (so hovering
3129
- // a DIFFERENT bubble still shows a normal transient tooltip even
3130
- // while one is pinned) — its content is a snapshot of buildTooltipHtml
3131
- // at click time (not live-updating), but its screen POSITION is kept
3132
- // in sync with the map on every pan/zoom via map.project().
3133
- let pinned = null; // { runtime, object, lngLat }
3134
- // Tooltip look, like the legend's (flatLegendLook): flat's light
3135
- // tooltip (tooltip_mustache.js: white 0.95, #444 text, thin black
3136
- // border, 5px radius) on light basemaps, this engine's dark tooltip
3137
- // on dark ones. Read when shown, so options set later (a
3138
- // myMap.then(...).options({basemapopacity})) count.
3139
- const tooltipLook = () => {
3140
- const o = parseFloat(this._engineOptions.basemapopacity);
3141
- const look = flatLegendLook(this.mapOptions.mapType, isNaN(o) ? 1 : o, this.mapOptions.legendBackground || this.mapOptions.legendbackground);
3142
- return look.dark
3143
- ? { background: 'rgb(41,50,60)', color: 'rgb(200,205,214)', border: 'none', borderRadius: '4px', boxShadow: '0 2px 8px rgba(0,0,0,0.3)' }
3144
- : { background: 'rgba(255,255,255,0.95)', color: '#444', border: '0.5px solid black', borderRadius: '5px',
3145
- boxShadow: 'rgba(0,0,0,0.2) 0px 2px 4px 0px, rgba(0,0,0,0.19) 0px 3px 10px 0px' };
3146
- };
3147
- const pinnedTooltipEl = document.createElement('div');
3148
- pinnedTooltipEl.style.cssText = 'position:absolute;top:0;left:0;z-index:6;display:none;' +
3149
- 'pointer-events:auto;max-width:320px;max-height:320px;overflow:auto;' +
3150
- 'padding:0.6em 1.6em 0.6em 0.7em;font-size:0.85em;';
3151
-
3152
- function updatePinnedTooltipPosition() {
3153
- if (!pinned) return;
3154
- const pt = map.project(pinned.lngLat);
3155
- pinnedTooltipEl.style.transform = `translate(${pt.x + 10}px, ${pt.y - 10}px)`;
3156
- }
3157
-
3158
- // A stable per-geometry anchor coordinate for the identity check
3159
- // below — only a Point's OWN coordinates are an [lng,lat] pair;
3160
- // Polygon/MultiPolygon nest rings of them, so the first ring's
3161
- // first vertex stands in instead (stable across rebuilds for the
3162
- // same underlying feature, same as the Point case: this.features'
3163
- // geometry references don't change shape between
3164
- // _buildChoroplethLayers calls, only the wrapping properties do).
3165
- function anchorCoordOf(geometry) {
3166
- if (!geometry) return null;
3167
- if (geometry.type === 'Point') return geometry.coordinates;
3168
- if (geometry.type === 'Polygon') return geometry.coordinates[0] && geometry.coordinates[0][0];
3169
- if (geometry.type === 'MultiPolygon') return geometry.coordinates[0] && geometry.coordinates[0][0] && geometry.coordinates[0][0][0];
3170
- return null;
3171
- }
3172
-
3173
- // Identity check for "is this hovered object the same one that's
3174
- // pinned" — object references are rebuilt from scratch on every
3175
- // redraw (new individual/group arrays each buildDeckLayers call), so
3176
- // reference equality never works here. Same layer + same geometry
3177
- // position is a reasonable proxy: individual points and aggregated
3178
- // groups alike are keyed by position within a layer, matching how
3179
- // clustering itself already treats "same position" as "same thing".
3180
- function isSameAsPinned(layerId, object) {
3181
- if (pinned.layerId !== layerId) return false;
3182
- const a = anchorCoordOf(object.geometry);
3183
- const b = anchorCoordOf(pinned.object.geometry);
3184
- if (!a || !b) return false;
3185
- return Math.abs(a[0] - b[0]) < 1e-9 && Math.abs(a[1] - b[1]) < 1e-9;
3186
- }
3187
-
3188
- // content lives in its own child so re-rendering (innerHTML) never
3189
- // wipes out the unpin button below (a sibling, not a descendant of it)
3190
- const pinnedContentEl = document.createElement('div');
3191
- pinnedTooltipEl.appendChild(pinnedContentEl);
3192
-
3193
- function renderPinnedTooltip() {
3194
- if (!pinned) { pinnedTooltipEl.style.display = 'none'; return; }
3195
- const html = pinned.runtime.buildTooltipHtml(pinned.object);
3196
- if (!html) { pinned = null; pinnedTooltipEl.style.display = 'none'; return; }
3197
- pinnedContentEl.innerHTML = html;
3198
- Object.assign(pinnedTooltipEl.style, tooltipLook());
3199
- pinnedTooltipEl.style.display = 'block';
3200
- updatePinnedTooltipPosition();
3201
- }
3202
-
3203
- const unpinBtn = document.createElement('button');
3204
- unpinBtn.type = 'button';
3205
- unpinBtn.setAttribute('aria-label', 'chiudi');
3206
- unpinBtn.title = 'chiudi';
3207
- unpinBtn.textContent = '×';
3208
- unpinBtn.style.cssText = 'position:absolute;top:5px;right:5px;width:1.5em;height:1.5em;padding:0;' +
3209
- 'display:flex;align-items:center;justify-content:center;border:none;border-radius:50%;' +
3210
- 'background:rgba(255,255,255,0.18);color:inherit;font-size:1em;line-height:1;font-weight:bold;cursor:pointer';
3211
- unpinBtn.addEventListener('mouseenter', () => { unpinBtn.style.background = 'rgba(255,255,255,0.35)'; });
3212
- unpinBtn.addEventListener('mouseleave', () => { unpinBtn.style.background = 'rgba(255,255,255,0.18)'; });
3213
- unpinBtn.addEventListener('click', () => { pinned = null; pinnedTooltipEl.style.display = 'none'; });
3214
- pinnedTooltipEl.style.position = 'absolute';
3215
- pinnedTooltipEl.appendChild(unpinBtn);
3216
-
3217
- // deck.gl's own default hover tooltip is only recomputed on pointer-
3218
- // move events, not on click — so right after a click, it's still
3219
- // showing whatever it rendered from the hover that was already
3220
- // active (the same item we just pinned), and won't hide itself just
3221
- // because getTooltip would now return null for it. Nothing re-asks
3222
- // it until the next real mousemove. Force it closed here instead —
3223
- // deferred one frame so it wins even if deck.gl's own click handling
3224
- // touches the tooltip DOM in the same tick.
3225
- function hideDefaultHoverTooltip() {
3226
- requestAnimationFrame(() => {
3227
- const defaultTooltipEl = el.parentElement && el.parentElement.querySelector('.deck-tooltip');
3228
- if (defaultTooltipEl) defaultTooltipEl.style.display = 'none';
3229
- });
3230
- }
3934
+ // hover / click-to-pin tooltips: createTooltips
3935
+ const tooltips = createTooltips({ builder: this, map, el, findRuntimeForLayerId });
3231
3936
 
3232
3937
  const overlay = new MapLibreOverlay({
3233
3938
  interleaved: true,
@@ -3235,88 +3940,17 @@
3235
3940
  // pointer becomes a hand over anything pickable (bubbles/points),
3236
3941
  // so hovering something clickable actually looks clickable
3237
3942
  getCursor: ({ isDragging, isHovering }) => (isDragging ? 'grabbing' : (isHovering ? 'pointer' : 'grab')),
3238
- // An exception thrown from these callbacks runs inside deck.gl's
3239
- // own render frame / event dispatch and leaves its frame and
3240
- // interaction state broken — the map stopped showing new layers
3241
- // after zooms and pans (a USER chart's hover threw on every frame
3242
- // the mouse was over an arrow, see pickedFeature). Both are guarded.
3243
- getTooltip: (info) => {
3244
- try { return tooltipFor(info); } catch (err) {
3245
- if (!tooltipErrorLogged) { tooltipErrorLogged = true; console.error('[ixmaps-gl] tooltip failed:', err); }
3246
- return null;
3247
- }
3248
- },
3249
- onClick: (info) => {
3250
- try { return clickFor(info); } catch (err) {
3251
- console.error('[ixmaps-gl] click failed:', err);
3252
- return false;
3253
- }
3254
- }
3943
+ // (guarded: see createTooltips)
3944
+ getTooltip: tooltips.getTooltip,
3945
+ onClick: tooltips.onClick
3255
3946
  });
3256
- let tooltipErrorLogged = false;
3257
- // a USER chart's picked item is {d, icon} (see _buildUserChartLayers):
3258
- // the feature is its d
3259
- function pickedFeature(o) { return o && o.icon && o.d ? o.d : o; }
3260
- function tooltipFor({ object: picked, layer }) {
3261
- const object = pickedFeature(picked);
3262
- if (!object || !layer) return null;
3263
- // suppress the hover tooltip ONLY for the specific item that's
3264
- // pinned (showing both would just duplicate the same content) —
3265
- // every OTHER item still gets its normal hover preview even
3266
- // while something else stays pinned
3267
- if (pinned && isSameAsPinned(layer.id, object)) return null;
3268
- const rt = findRuntimeForLayerId(layer.id);
3269
- if (!rt) return null;
3270
- const html = rt.buildTooltipHtml(object);
3271
- return html ? { html, style: Object.assign({ fontSize: '0.85em', padding: '0.5em 0.7em', maxWidth: '320px' }, tooltipLook()) } : null;
3272
- }
3273
- function clickFor(picked) {
3274
- const info = picked && Object.assign({}, picked, { object: pickedFeature(picked.object) });
3275
- if (info && info.object && info.layer) {
3276
- const rt = findRuntimeForLayerId(info.layer.id);
3277
- if (rt) {
3278
- // Only a Point geometry's own coordinates ARE an
3279
- // [lng,lat] pair (every point/bubble/dot/chart-cluster
3280
- // layer) — a CHOROPLETH polygon's geometry.coordinates is
3281
- // a nested array of RINGS, which map.project() below
3282
- // can't accept (confirmed live: threw MapLibre's own
3283
- // "LngLatLike argument must be..." error). The `||
3284
- // info.coordinate` fallback this used to lean on never
3285
- // actually ran for a polygon — a nested array is truthy,
3286
- // so the left side always "won" — which is the real bug
3287
- // this fixes: the pinned tooltip stuck at its default
3288
- // top:0/left:0 (never got a real position because
3289
- // updatePinnedTooltipPosition's map.project() threw
3290
- // before setting one), AND that same uncaught exception,
3291
- // thrown from inside deck.gl's own click-dispatch
3292
- // callback, left deck.gl's pointer-interaction state
3293
- // corrupted enough to block all further pan/zoom —
3294
- // confirmed live, both symptoms disappear together once
3295
- // this stops throwing. info.coordinate (the actual
3296
- // clicked map location, provided regardless of feature
3297
- // geometry type) anchors every non-Point case correctly.
3298
- const lngLat = (info.object.geometry && info.object.geometry.type === 'Point')
3299
- ? info.object.geometry.coordinates
3300
- : info.coordinate;
3301
- pinned = { runtime: rt, object: info.object, lngLat, layerId: info.layer.id };
3302
- renderPinnedTooltip();
3303
- hideDefaultHoverTooltip();
3304
- return true;
3305
- }
3306
- }
3307
- // clicked empty map space (or a non-tooltippable layer) -> unpin
3308
- pinned = null;
3309
- pinnedTooltipEl.style.display = 'none';
3310
- return false;
3311
- }
3312
3947
  map.addControl(overlay);
3313
3948
  map.addControl(new maplibregl.NavigationControl(), 'top-left');
3314
- map.on('move', updatePinnedTooltipPosition);
3315
-
3316
- if (el.parentElement) {
3317
- el.parentElement.style.position = el.parentElement.style.position || 'relative';
3318
- el.parentElement.appendChild(pinnedTooltipEl);
3319
- }
3949
+ tooltips.mount();
3950
+ // the page's attribution (.attribution(), Map option attribution)
3951
+ const attributionBox = createAttribution(map);
3952
+ attributionBox.set(this._attributionText != null ? this._attributionText : this.mapOptions.attribution);
3953
+ this._setAttribution = text => attributionBox.set(text);
3320
3954
 
3321
3955
  if (this._legendHtml && el.parentElement) {
3322
3956
  const legendEl = document.createElement('div');
@@ -3488,6 +4122,11 @@
3488
4122
  mapType: (id) => engineApi.setMapType(id),
3489
4123
  // flat's getMapTypeId(): the basemap type name in use
3490
4124
  getMapTypeId: () => String(builder.mapOptions.mapType || ''),
4125
+ // flat's attribution (htmlgui.js htmlgui_set/getAttributionString)
4126
+ setAttribution: (text) => { builder.attribution(text); return engineApi; },
4127
+ // flat's map handle ixmaps.map().attribution(text) (htmlgui_flat.js 1381)
4128
+ attribution: (text) => engineApi.setAttribution(text),
4129
+ getAttribution: () => attributionBox.get(),
3491
4130
  setBasemapOpacity: (delta, mode) => {
3492
4131
  _basemapOpacity = mode === 'relative'
3493
4132
  ? Math.max(0, Math.min(1, _basemapOpacity + (parseFloat(delta) || 0)))
@@ -4337,698 +4976,10 @@
4337
4976
  });
4338
4977
  }
4339
4978
 
4340
- // NATIVE INTERACTIVE LEGEND — real ixmaps-flat's legend.js
4341
- // (makeColorLegendHTMLLong) builds, per CATEGORICAL-ish theme: a
4342
- // title/snippet header, one row per category (color swatch + a bar
4343
- // proportional to that category's own total + right-aligned
4344
- // formatted value), a description/source footer, and a per-theme
4345
- // "Chart size" 25-200% slider — all read directly off source
4346
- // (ui/js/tools/legend.js:594-1037/3196-3393, ui/js/htmlgui.js:2196/
4347
- // 2208 for markThemeClass/unmarkThemeClass, ui/css/legend.css for
4348
- // the selected-row highlight) rather than guessed from the
4349
- // screenshot alone. Gated on `legend:"open"` — the one real map
4350
- // option this engine's own createMap already threads through as
4351
- // this.mapOptions (see mapOptions.legend below) — since there's no
4352
- // toolbar toggle button here to open/close it later the way the
4353
- // real engine's own chrome does.
4354
- //
4355
- // Deliberate divergences from the real implementation, both
4356
- // reasoned rather than accidental:
4357
- // - Real legend.js does a full innerHTML rebuild on every single
4358
- // theme redraw (even a bare click-toggle). renderRows() below
4359
- // only rebuilds the ROWS (swatch/bar/value/highlight) — cheap,
4360
- // and the only part that can actually change post-load (marks,
4361
- // or an AGGREGATE theme's dataset-wide totals, which this port
4362
- // doesn't yet let change post-load anyway) — rather than
4363
- // tearing down and rebuilding title/snippet/description/slider
4364
- // too, which never change. Same visible result, less DOM churn.
4365
- // - Real "isolate_gray" mode dims non-marked SVG paths via a CSS
4366
- // class; this engine dims via IconLayer's own getColor alpha
4367
- // (see LayerRuntime#_iconAlpha) since these are deck.gl raster
4368
- // icons, not DOM/SVG nodes a CSS rule could reach.
4369
- // Legend on/off and fold state as real ixmaps-flat reads the map
4370
- // option (htmlgui_flat.js:748-794): on whenever `legend` is given and
4371
- // isn't false/"false" ("true", "open", "", 1, …), folded for
4372
- // "closed", off when not given at all.
4373
- const legendOpt = this.mapOptions.legend;
4374
- // let, not const: map.setLegend(value) switches them at runtime (see setLegendOption)
4375
- let legendOn = legendIsOn(legendOpt);
4376
- let legendFolded = legendOpt === 'closed';
4377
- {
4378
- // Map-level `align` option positions the legend panel — a map-
4379
- // wide placement choice, not per-theme (unlike legendtheme/
4380
- // legendfilter above), so read once from this.mapOptions rather
4381
- // than per-runtime. Real ixmaps-flat ALSO has an `align` map
4382
- // option (ui/js/htmlgui_flat.js) controlling legend position, but
4383
- // its value shape is a free-form regex-matched left/right/center/
4384
- // top/bottom/pixel-prefixed string tied to a static in-page DOM
4385
- // panel — a different layout model from this panel's own
4386
- // position:absolute floating-overlay-over-the-map-canvas
4387
- // approach. This port reuses the real OPTION NAME but adapts the
4388
- // VALUE SHAPE to plain four-corner tokens matching how this panel
4389
- // is actually positioned — a deliberate divergence, same
4390
- // reasoning already applied to style.legendfilter's own name/
4391
- // shape choice earlier this session. Default "top-right" per
4392
- // explicit request (real engine's own fallback is right-anchored
4393
- // too, just without an opinion on vertical placement).
4394
- const ALIGN_CSS = {
4395
- 'top-left': 'left:10px;top:10px;',
4396
- 'top-right': 'right:10px;top:10px;',
4397
- 'bottom-left': 'left:10px;bottom:10px;',
4398
- 'bottom-right': 'right:10px;bottom:10px;'
4399
- };
4400
- // gl's four corners, else flat's own values (htmlgui_flat.js
4401
- // 421-470): "…left" → left (the part before "left", else 55px), 12px
4402
- // from the top; "…right" → right (else 25px); "center" / "top" → top
4403
- // center
4404
- const flatAlignCss = align => {
4405
- const a = String(align || '');
4406
- if (/left/.test(a)) return 'left:' + (a.split('left')[0] || '55px') + ';top:12px;';
4407
- if (/right/.test(a)) return 'right:' + (a.split('right')[0] || '25px') + ';top:10px;';
4408
- if (a === 'center' || a === 'top') return 'left:50%;transform:translateX(-50%);top:10px;';
4409
- return null;
4410
- };
4411
- const legendAlignCss = ALIGN_CSS[this.mapOptions.align] || flatAlignCss(this.mapOptions.align) || ALIGN_CSS['top-right'];
4412
- // Real ixmaps-flat's SUB-THEME (MapTheme.createSubTheme,
4413
- // maptheme.js 14284-14292 / 15010-15160; map.Themes.enableSubThemes
4414
- // defaults to true): marking fields of a multi-field choropleth
4415
- // (DOMINANT/COMPOSECOLOR, not CHART) paints a new theme over the
4416
- // original — one field: CHOROPLETH|LOG|DOPACITY on that field, white
4417
- // → the field's color in 7 steps, dopacityscale 2, dopacitypow 1,
4418
- // keeping DOPACITY(→DOPACITYMAX)/AGGREGATE/GROUP/SUM/ZEROISVALUE/
4419
- // VALUES; several fields: the original type on just those fields
4420
- // and colors. It reuses the original's joined polygons, has no
4421
- // legend and stays out of the theme registry (its name
4422
- // "<layer>::subtheme" also keeps its deck.gl layer id apart), so
4423
- // markThemeClass/removeTheme still address the original.
4424
- const subThemeCapable = rt => rt.flags.has('CHOROPLETH') && !rt.flags.has('CHART') && (rt.flags.has('DOMINANT') || rt.flags.has('COMPOSECOLOR'));
4425
- 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;
4426
- updateSubTheme = (rt) => {
4427
- if (rt._subTheme) {
4428
- const i = runtimes.indexOf(rt._subTheme);
4429
- if (i >= 0) runtimes.splice(i, 1);
4430
- rt._subTheme = null;
4431
- }
4432
- const fields = String(rt.binding.value || '').split('|');
4433
- const marks = [...rt._markedClasses].filter(i => i >= 0 && i < fields.length).sort((a, b) => a - b);
4434
- if (marks.length) {
4435
- const colors = (rt.categoryColorsRgb || []).map(toHex);
4436
- const labels = rt.categoryDisplayLabels || rt.categoryLabels || fields;
4437
- const kept = ['DOPACITY', 'AGGREGATE', 'GROUP', 'SUM', 'ZEROISVALUE', 'VALUES']
4438
- .filter(f => [...rt.flags].some(x => x.startsWith(f) && (f !== 'DOPACITY' || /^DOPACITY/.test(x))))
4439
- .map(f => (f === 'DOPACITY' ? 'DOPACITYMAX' : f));
4440
- let def;
4441
- if (marks.length > 1) {
4442
- def = {
4443
- layer: rt.name + '::subtheme',
4444
- binding: (b => { delete b.field100; delete b.value100; return b; })(Object.assign({}, rt.binding, { value: marks.map(i => fields[i]).join('|') })),
4445
- style: Object.assign({}, rt.style, { type: [...rt.flags].concat(['SUBTHEME', 'NOLEGEND']).join('|'),
4446
- colorscheme: marks.map(i => colors[i]), values: marks.map(i => labels[i]) }),
4447
- meta: { title: 'actual selection' }
4448
- };
4449
- } else {
4450
- const i = marks[0];
4451
- const binding = Object.assign({}, rt.binding, { value: fields[i] });
4452
- delete binding.alpha; delete binding.alpha100;
4453
- // rt.features already carry the field100 result — drop it and
4454
- // every alias of it, or normalizeTheme would apply it again
4455
- delete binding.field100; delete binding.value100;
4456
- def = {
4457
- layer: rt.name + '::subtheme',
4458
- binding,
4459
- style: { type: ['CHOROPLETH', 'LOG', 'DOPACITY', 'NOLEGEND', 'SUBTHEME'].concat(kept).join('|'),
4460
- colorscheme: ['7', 'white', colors[i]], dopacityscale: 2, dopacitypow: 1,
4461
- units: rt.style.units, linecolor: rt.style.linecolor, linewidth: rt.style.linewidth },
4462
- meta: { title: 'actual selection' }
4463
- };
4464
- }
4465
- const sub = new LayerRuntime(normalizeTheme(def), { type: 'FeatureCollection', features: rt.features }, builder._engineOptions);
4466
- sub._isSubTheme = true;
4467
- runtimes.splice(runtimes.indexOf(rt) + 1, 0, sub);
4468
- rt._subTheme = sub;
4469
- }
4470
- refresh();
4471
- notifyRedraw();
4472
- };
4473
- // the stack of the theme panels — flat shows one theme's legend
4474
- // after the other in #map-legend, separated by a line, and leaves
4475
- // out a theme hidden, out of scale or not yet drawn
4476
- let legendStack = null;
4477
- const legendStackEl = shadow => {
4478
- if (legendStack && legendStack.parentNode) return legendStack;
4479
- legendStack = document.createElement('div');
4480
- legendStack.className = 'ix-legend-stack';
4481
- legendStack.style.cssText = 'position:absolute;' + legendAlignCss + 'z-index:6;width:280px;'
4482
- + 'display:flex;flex-direction:column;max-height:calc(100% - 24px);overflow-y:auto;'
4483
- + 'border-radius:6px;box-shadow:' + shadow + ';pointer-events:auto;';
4484
- if (!document.getElementById('ix-legend-stack-css')) {
4485
- const css = document.createElement('style');
4486
- css.id = 'ix-legend-stack-css';
4487
- 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)}';
4488
- document.head.appendChild(css);
4489
- }
4490
- el.parentElement.appendChild(legendStack);
4491
- return legendStack;
4492
- };
4493
- // map.setLegend(value): the `legend` map option at runtime, same values as
4494
- // ixmaps.Map(id, {legend}) — on ("true", "open", …), folded ("closed"), off
4495
- // (false/"false"/0/undefined). Panels are made for themes that lack one,
4496
- // removed when off, folded/unfolded otherwise.
4497
- setLegendOption = (opt) => {
4498
- builder.mapOptions.legend = opt;
4499
- legendFolded = opt === 'closed';
4500
- legendOn = legendIsOn(opt);
4501
- runtimes.forEach(rt => {
4502
- if (!legendOn) {
4503
- if (rt._legendPanel && rt._legendPanel.parentNode) rt._legendPanel.parentNode.removeChild(rt._legendPanel);
4504
- rt._legendPanel = null;
4505
- } else if (!rt._legendPanel) addLegendPanel(rt);
4506
- else if (rt._setLegendCollapsed) rt._setLegendCollapsed(legendFolded);
4507
- });
4508
- layoutLegends();
4509
- };
4510
- layoutLegends = () => {
4511
- if (!legendStack) return;
4512
- const z = map.getZoom();
4513
- let shown = 0;
4514
- [...legendStack.children].forEach(panel => {
4515
- const rt = panel._ixRuntime;
4516
- const off = !rt || rt._hidden || themeHiddenByScale(rt.flags, rt.style, z);
4517
- if (off) { panel.setAttribute('data-ixoff', ''); panel.style.display = 'none'; }
4518
- else { panel.removeAttribute('data-ixoff'); panel.style.display = 'flex'; shown++; }
4519
- });
4520
- legendStack.style.display = shown ? 'flex' : 'none';
4521
- };
4522
- const legendApplies = rt => rt.categoryLabels && rt.categoryLabels.length && !rt.flags.has('FEATURE') && !rt.flags.has('FEATURES') && !rt.flags.has('NOLEGEND');
4523
- // One theme's panel — at build time for every theme, and again for a
4524
- // theme defined later (map.layer(...) in a myMap.then(...) chain,
4525
- // defineLayer, loadProject); removeTheme removes it with the theme.
4526
- addLegendPanel = (rt) => {
4527
- // marking a class (legend row, or ixmaps.markThemeClass from a
4528
- // page) redraws — for every theme, legend panel or not
4529
- rt._triggerRedraw = () => { refresh(); };
4530
- if (subThemeCapable(rt)) rt._onMarksChanged = () => updateSubTheme(rt);
4531
- if (!legendOn || !el.parentElement || !legendApplies(rt)) return;
4532
- el.parentElement.style.position = el.parentElement.style.position || 'relative';
4533
- {
4534
- // .type("...|NOLEGEND") — the fourth real legend-related type()
4535
- // token: opts a theme OUT of the legend entirely (real engine's
4536
- // own per-layer "skip this one" flag, distinct from the map-
4537
- // level legend:"open"/"closed" option gating the whole panel).
4538
- // Same free-parsing story as SIMPLELEGEND/COMPACTLEGEND above —
4539
- // just one more flag to exclude on, no new plumbing.
4540
- const panel = document.createElement('div');
4541
- rt._legendPanel = panel;
4542
- panel.className = 'ix-native-legend';
4543
- // Opt-in via .style({legendtheme:"light"}) — a NEW, ixmaps-gl-
4544
- // only convention (same naming pattern as the sibling
4545
- // legendfilter/legendunits keys added earlier this session):
4546
- // real ixmaps-flat's legend.js has no such switch, this panel
4547
- // is entirely this port's own construction. Default ("dark")
4548
- // preserves the original always-dark panel exactly; "light" is
4549
- // for pages using a light basemap (e.g. CARTO Positron) where
4550
- // a dark semi-transparent panel reads as a mismatched dark
4551
- // patch rather than legend chrome. Every other hardcoded panel
4552
- // color below (rows, bar track, filter <select>) is expressed
4553
- // in terms of this one palette so the two variants stay in
4554
- // sync — no separate light/dark branch anywhere else.
4555
- // flat's look (flatLegendLook) unless the page picks one with
4556
- // this engine's own legendtheme
4557
- const opacityOpt = parseFloat(this._engineOptions.basemapopacity);
4558
- const flatLook = flatLegendLook(this.mapOptions.mapType, isNaN(opacityOpt) ? 1 : opacityOpt, this.mapOptions.legendBackground || this.mapOptions.legendbackground);
4559
- const isLightLegend = rt.style.legendtheme === 'light' || (rt.style.legendtheme !== 'dark' && !flatLook.dark);
4560
- const legendColors = isLightLegend
4561
- ? { bg: 'rgba(255,255,255,0.92)', fg: '#1a1a1a', shadow: '0 2px 10px rgba(0,0,0,0.18)',
4562
- rowMarked: 'rgba(0,0,0,0.08)', track: 'rgba(0,0,0,0.08)',
4563
- selectBg: 'rgba(0,0,0,0.04)', selectBorder: 'rgba(0,0,0,0.2)' }
4564
- : { bg: 'rgba(28,30,34,0.92)', fg: '#eee', shadow: '0 2px 10px rgba(0,0,0,0.45)',
4565
- rowMarked: 'rgba(255,255,255,0.14)', track: 'rgba(255,255,255,0.08)',
4566
- selectBg: 'rgba(255,255,255,0.08)', selectBorder: 'rgba(255,255,255,0.25)' };
4567
- if (!rt.style.legendtheme) legendColors.bg = flatLook.bg;
4568
- // max-height:66% resolves against el.parentElement's own
4569
- // height (the map container, which always has a definite
4570
- // height for the map itself to render into) since this panel
4571
- // is absolutely positioned inside it — a plain percentage on
4572
- // an absolutely-positioned element's height IS legal CSS as
4573
- // long as its containing block has a definite height, which
4574
- // this one does. display:flex column + min-height:0 on the
4575
- // ROWS wrapper below (not this outer panel) is what makes
4576
- // only the row list scroll while the header/description/
4577
- // slider stay fixed in place — a flex child won't actually
4578
- // shrink to fit and scroll internally without min-height:0,
4579
- // it just overflows its flex parent instead.
4580
- // one box for every theme, as flat's #map-legend (legend.js
4581
- // 2200-2460): the panels stack in the legend stack in theme
4582
- // order, a line between them (see layoutLegends)
4583
- panel.style.cssText = 'position:relative;flex:0 0 auto;'
4584
- + 'display:flex;flex-direction:column;'
4585
- + 'background:' + legendColors.bg + ';color:' + legendColors.fg + ';font:12px/1.4 -apple-system,Arial,sans-serif;'
4586
- + 'padding:10px 12px 12px;pointer-events:auto;';
4587
- const stack = legendStackEl(legendColors.shadow);
4588
- const at = runtimes.indexOf(rt);
4589
- const next = [...stack.children].find(c => runtimes.indexOf(c._ixRuntime) > at);
4590
- panel._ixRuntime = rt;
4591
- stack.insertBefore(panel, next || null);
4592
-
4593
- // SUM+valuefield mirrors the real engine's own "SUM style
4594
- // aggregation" reading (style.valuefield, falling back to
4595
- // the bound size field); anything else (plain CATEGORICAL,
4596
- // no SUM) falls back to a per-category record COUNT.
4597
- const useSum = flatFlag(rt.flags, 'SUM') && rt.style.valuefield;
4598
- const valueField = rt.style.valuefield || rt.binding.size;
4599
- // legendunits wins over the theme's general-purpose units
4600
- // (used elsewhere for tooltips, e.g. _renderItemChartHtml) —
4601
- // a page may want a different/no unit string specifically on
4602
- // the legend's own value column. Appended as-is (no extra
4603
- // space injected), matching how style.units/legendunits are
4604
- // themselves authored with their own leading space (e.g.
4605
- // " MW") in real pages.
4606
- const legendUnit = rt.style.legendunits || rt.style.units || '';
4607
- // row labels go into innerHTML: the page's own .style({label})
4608
- // stays HTML, as in flat; categories read from the data are
4609
- // escaped (a value could carry markup)
4610
- const labels = legendRowLabels(rt);
4611
- // .type("...|TEXTLEGEND") — the FIFTH real legend-related
4612
- // type() token: title/snippet/description text only, no
4613
- // category rows at all (swatches, bars, chips — none of it),
4614
- // per explicit correction. Everything below that builds the
4615
- // rows/totals/onRedraw-recompute wiring is skipped outright
4616
- // rather than built-then-hidden — there is nothing for any of
4617
- // it to feed once no rows ever render. The country-filter
4618
- // dropdown and Chart-size slider stay: neither is a
4619
- // "categorical item" or "colorscheme swatch", both are
4620
- // independent controls unrelated to the row list.
4621
- const isTextOnly = rt.flags.has('TEXTLEGEND');
4622
-
4623
- // Map-view-aware per explicit request: totals reflect only
4624
- // what's CURRENTLY on screen (the map's own bounds, plus the
4625
- // same far-hemisphere exclusion _buildChartLayers itself
4626
- // applies under globe projection — see isOnVisibleHemisphere)
4627
- // rather than the whole dataset — recomputed on every redraw
4628
- // (engineApi.onRedraw, below) so panning/zooming/rotating
4629
- // updates the bars and values live, the same way the actual
4630
- // rendered bubbles change. Same simple rectangular bbox
4631
- // membership test _computeAggregatedItems's own non-AGGREGATE
4632
- // branch uses (no antimeridian wraparound handling — matches
4633
- // that existing convention, not a new gap this introduces).
4634
- let totals, maxTotal, order, rowsScroll;
4635
- // No-op default: TEXTLEGEND never reassigns this (see below),
4636
- // so the LATER unconditional-looking `renderRows()` call
4637
- // safely does nothing rather than needing its own isTextOnly
4638
- // guard at every call site.
4639
- let renderRows = () => {};
4640
- if (!isTextOnly) {
4641
- function computeTotals() {
4642
- const bounds = map.getBounds();
4643
- const bbox = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()];
4644
- const proj = (typeof map.getProjection === 'function' && map.getProjection()) || { type: 'mercator' };
4645
- const globeCenter = proj.type === 'globe' ? map.getCenter() : null;
4646
- totals = new Array(rt.categoryLabels.length).fill(0);
4647
- 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; }
4648
- // _activeFeatures (set by setFacetFilter/clearFacetFilter,
4649
- // e.g. the country-select dropdown below) is the facet-
4650
- // filtered subset when a filter is active, null otherwise —
4651
- // reading it here keeps the legend's own numbers consistent
4652
- // with whatever the country filter is currently narrowing
4653
- // the map down to, not just with the viewport/hemisphere.
4654
- (rt._activeFeatures || rt.features).forEach(f => {
4655
- const idx = rt.categoryIndexByLabel ? rt.categoryIndexByLabel.get(f.properties[rt.binding.value]) : null;
4656
- if (idx == null) return;
4657
- const [lng, lat] = f.geometry.coordinates;
4658
- if (lng < bbox[0] || lng > bbox[2] || lat < bbox[1] || lat > bbox[3]) return;
4659
- if (globeCenter && !isOnVisibleHemisphere(lng, lat, globeCenter)) return;
4660
- totals[idx] += useSum ? (parseFloat(f.properties[valueField]) || 0) : 1;
4661
- });
4662
- maxTotal = Math.max(0, ...totals);
4663
- order = totals.map((v, i) => i).sort((a, b) => totals[b] - totals[a]);
4664
- }
4665
- computeTotals();
4666
-
4667
- // Only THIS wrapper scrolls (flex:1 1 auto + min-height:0 lets
4668
- // it shrink to whatever's left of the panel's own 66%-height
4669
- // cap once the fixed header/description/slider take their
4670
- // share, then overflow-y:auto scrolls just the row list) —
4671
- // the header above and description/slider below stay put.
4672
- rowsScroll = document.createElement('div');
4673
- rowsScroll.style.cssText = 'flex:1 1 auto;min-height:0;overflow-y:auto;';
4674
- const rowsEl = document.createElement('div');
4675
- rowsEl.className = 'ix-legend-rows';
4676
- // .type("...|SIMPLELEGEND") — real ixmaps-flat's OTHER legend
4677
- // variant (confirmed against a real-engine screenshot, per
4678
- // explicit correction): swatch + label chips only, no bar, no
4679
- // value column. SIMPLELEGEND is just another pipe-delimited
4680
- // type() token — MapBuilder#type() already puts every token
4681
- // into this.flags regardless of whether anything reads it, so
4682
- // no new parsing was needed, only this render-mode branch.
4683
- // flex-wrap here (vs. the bar mode's block rows) — chips wrap
4684
- // to the panel's own width the same loose way the real
4685
- // engine's screenshot shows (an uneven number of chips per
4686
- // row, driven by each label's own text length, not a fixed
4687
- // column grid).
4688
- // .type("...|COMPACTLEGEND") — the THIRD real legend variant
4689
- // (again confirmed against a real-engine screenshot, per
4690
- // explicit correction): the bar-mode's own two-line label/bar/
4691
- // value row, but wrapped into a multi-column flex-wrap grid
4692
- // instead of one full-width row per category — each item's
4693
- // own width follows its content (a bigger value's longer bar
4694
- // makes its own item wider, so fewer fit per line; Coal alone
4695
- // filled its own row in the reference screenshot while the
4696
- // smaller categories packed 3-4 per row) rather than a fixed
4697
- // column count.
4698
- if (rt.flags.has('SIMPLELEGEND') || rt.flags.has('COMPACTLEGEND')) {
4699
- rowsEl.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;';
4700
- }
4701
- rowsScroll.appendChild(rowsEl);
4702
-
4703
- // Two lines per row — label on its own full-width line, then
4704
- // swatch+bar+value below — per explicit correction: a single
4705
- // shared line left too little room for the bar to read as
4706
- // proportional (it was squeezed between the label text and a
4707
- // fixed-width value column). The bar itself lives inside a
4708
- // flex:1 "track" div sized to whatever's left after the fixed
4709
- // swatch/value columns, and is a PERCENTAGE of that track's
4710
- // own width (not a pixel value) — real formula's per-unit-
4711
- // factor/cap (legend.js:980-984/946-950) collapses to a plain
4712
- // linear percentage-of-max once the bar's available width
4713
- // isn't a fixed constant this code has to guess at.
4714
- const isSimple = rt.flags.has('SIMPLELEGEND');
4715
- const isCompact = rt.flags.has('COMPACTLEGEND');
4716
- // Compact mode's bar can't use the full-width mode's
4717
- // percentage-of-flex-track trick (there's no full-width track
4718
- // to be a percentage OF once the row itself is content-sized,
4719
- // not stretched) — a plain pixel width, proportional to value
4720
- // and capped, same shape as the full-width formula just
4721
- // expressed in absolute px instead of a % of an elastic track.
4722
- const COMPACT_MAX_BAR_PX = 70;
4723
- // flat's compact legend (legend.js makeColorLegendHTML, the
4724
- // "compact" mode the theme legends use, 1212-1257): a range
4725
- // theme of 5 classes or more without labels is one line of
4726
- // color patches with the value range below; DOPACITY with an
4727
- // alpha field adds two paler lines (opacity 1/2, 1/3) and the
4728
- // alpha max, "↓" and min to their right (1062-1205). The
4729
- // legend's own __formatValue (legend.js 451-553) has no SPACE
4730
- // form: SPACE there is flat's plain "." thousands.
4731
- const flatCompactLegend = rt._rangeClassed && !flatFlag(rt.flags, 'CATEGORICAL') && !flatFlag(rt.flags, 'PLOT')
4732
- && (rt.partsA || []).length >= 5 && !(Array.isArray(rt.style.label) && rt.style.label.length);
4733
- // flat's single-color legend (legend.js 765-769, 1039-1046): a range
4734
- // theme of at most 2 classes without label/ranges, not
4735
- // CATEGORICAL, is one row — the swatch and "min ... max unit",
4736
- // or, when every value is 1 (a "$item$" count), its label or
4737
- // value field
4738
- const flatSingleRowLegend = rt._rangeClassed && !flatFlag(rt.flags, 'CATEGORICAL') && !flatFlag(rt.flags, 'PLOT')
4739
- && (rt.partsA || []).length <= 2 && !(Array.isArray(rt.style.label) && rt.style.label.length)
4740
- && !(Array.isArray(rt.style.ranges) && rt.style.ranges.length);
4741
- if (flatSingleRowLegend) {
4742
- renderRows = function() {
4743
- const rgb = rt.categoryColorsRgb[0] || [128, 128, 128];
4744
- const unit = String(rt.style.legendunits || rt.style.units || '').replace(/ /g, '&nbsp;');
4745
- const vMin = styleNum(rt.style.minvalue) || rt._valueMin, vMax = styleNum(rt.style.maxvalue) || rt._valueMax;
4746
- const label = rt.style.label != null && !Array.isArray(rt.style.label) ? String(rt.style.label) : String(rt.binding.value || '');
4747
- const text = vMin !== 1 || vMax !== 1
4748
- ? flatFormatValue(vMin, 2, 'BLANK') + ' &nbsp;... ' + flatFormatValue(vMax, 2, 'BLANK') + ' ' + unit
4749
- : label;
4750
- const marked = rt._markedClasses.has(0);
4751
- 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;'
4752
- + 'background:' + (marked ? legendColors.rowMarked : 'transparent') + ';">'
4753
- + '<span style="flex:0 0 auto;width:1.6em;height:0.8em;background:rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ');"></span>'
4754
- + '<span>' + text + '</span></div>';
4755
- rowsEl.querySelectorAll('.ix-legend-row').forEach(row => {
4756
- row.addEventListener('click', () => {
4757
- if (rt._markedClasses.has(0)) unmarkRuntimeClass(rt, 0);
4758
- else markRuntimeClass(rt, 0);
4759
- });
4760
- });
4761
- };
4762
- } else
4763
- if (flatCompactLegend) {
4764
- renderRows = function() {
4765
- const n = rt.categoryColorsRgb.length;
4766
- const withAlpha = flatFlag(rt.flags, 'DOPACITY') && rt.binding.alpha && rt._alphaMax;
4767
- const nLines = withAlpha ? 3 : 1;
4768
- const dec = rt.style.decimals != null ? parseInt(rt.style.decimals, 10) : 2;
4769
- const unit = rt.style.legendunits || rt.style.units || '';
4770
- const ranges = Array.isArray(rt.style.ranges) && rt.style.ranges.length ? rt.style.ranges.map(Number) : null;
4771
- const vMin = ranges ? ranges[0] : rt._valueMin, vMax = ranges ? ranges[ranges.length - 1] : rt._valueMax;
4772
- let html = '<div style="display:grid;grid-template-columns:repeat(' + n + ',1fr)' + (withAlpha ? ' auto' : '') + ';gap:1px;align-items:center;">';
4773
- for (let line = 0; line < nLines; line++) {
4774
- for (let c = 0; c < n; c++) {
4775
- const ix = flatFlag(rt.flags, 'INVERT') ? n - c - 1 : c;
4776
- const rgb = rt.categoryColorsRgb[ix];
4777
- const part = rt.partsA[ix];
4778
- const title = part ? flatFormatValue(part.min, dec, '') + unit + ' ... ' + flatFormatValue(part.max, dec, '') + unit : '';
4779
- const marked = rt._markedClasses.has(ix);
4780
- html += '<span class="ix-legend-row" data-idx="' + ix + '" title="' + title + '" style="cursor:pointer;height:14px;'
4781
- + 'background:rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ');opacity:' + (1 / (line + 1)) + ';'
4782
- + (marked ? 'outline:2px solid ' + legendColors.fg + ';outline-offset:-2px;' : '') + '"></span>';
4783
- }
4784
- if (withAlpha) {
4785
- const txt = line === 0 ? flatFormatValue(rt._alphaMax, 0, 'BLANK')
4786
- : line === 1 ? '&#8595; ' + (rt.style.alphavalueunits || '') : flatFormatValue(rt._alphaMin || 0, 0, 'BLANK');
4787
- html += '<span style="padding-left:0.5em;white-space:nowrap;">' + txt + '</span>';
4788
- }
4789
- }
4790
- html += '</div><div style="display:flex;justify-content:space-between;margin-top:3px;' + (withAlpha ? 'margin-right:4em;' : '') + '">'
4791
- + '<span>' + flatFormatValue(vMin, dec, '') + (unit ? ' ' + unit : '') + '</span>'
4792
- + '<span>' + flatFormatValue(vMax, dec, '') + (unit && unit.length <= 3 ? ' ' + unit : '') + '</span></div>';
4793
- rowsEl.innerHTML = html;
4794
- rowsEl.querySelectorAll('.ix-legend-row').forEach(cell => {
4795
- cell.addEventListener('click', () => {
4796
- const idx = parseInt(cell.dataset.idx, 10);
4797
- if (rt._markedClasses.has(idx)) unmarkRuntimeClass(rt, idx);
4798
- else markRuntimeClass(rt, idx);
4799
- });
4800
- });
4801
- };
4802
- } else
4803
- renderRows = function() {
4804
- // flat drops the rows without a count once any row has one
4805
- // (legend.js 754-760, 787-789: fCountBars && !count)
4806
- rowsEl.innerHTML = order.filter(i => !(maxTotal > 0) || totals[i]).map(i => {
4807
- const rgb = rt.categoryColorsRgb[i];
4808
- const color = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
4809
- const marked = rt._markedClasses.has(i);
4810
- const dimmed = rt._markedClasses.size > 0 && !marked;
4811
- const rowStyle = 'padding:4px 3px;border-radius:4px;cursor:pointer;opacity:' + (dimmed ? 0.4 : 1) + ';'
4812
- + 'background:' + (marked ? legendColors.rowMarked : 'transparent') + ';';
4813
- if (isSimple) {
4814
- // Swatch + label only, no bar/value — see rowsEl's own
4815
- // comment above for why (SIMPLELEGEND).
4816
- return '<div class="ix-legend-row" data-idx="' + i + '" style="' + rowStyle
4817
- + 'display:inline-flex;align-items:center;gap:5px;padding:4px 8px;">'
4818
- + '<span style="flex:0 0 8px;width:8px;height:8px;border-radius:50%;background:' + color + ';"></span>'
4819
- + '<span>' + labels[i] + '</span>'
4820
- + '</div>';
4821
- }
4822
- const pct = maxTotal ? Math.max(0, Math.min(100, (totals[i] / maxTotal) * 100)) : 0;
4823
- if (isCompact) {
4824
- // Same two-line label/bar/value shape as full-width bar
4825
- // mode below, just content-sized (flex:0 0 auto, no
4826
- // min-width:0 flex-1 track) so multiple items pack onto
4827
- // one line inside rowsEl's own flex-wrap — see
4828
- // COMPACTLEGEND's own comment on rowsEl above.
4829
- const barPx = Math.max(2, (pct / 100) * COMPACT_MAX_BAR_PX);
4830
- return '<div class="ix-legend-row" data-idx="' + i + '" style="' + rowStyle + 'flex:0 0 auto;">'
4831
- + '<div style="margin-bottom:3px;white-space:nowrap;">' + labels[i] + '</div>'
4832
- + '<div style="display:flex;align-items:center;gap:6px;">'
4833
- + '<span style="flex:0 0 8px;width:8px;height:8px;border-radius:50%;background:' + color + ';"></span>'
4834
- + '<span style="flex:0 0 auto;width:' + barPx + 'px;height:6px;background:' + color + ';border-radius:3px;"></span>'
4835
- + '<span style="flex:0 0 auto;white-space:nowrap;">' + rt._formatTooltipValue(totals[i]) + legendUnit + '</span>'
4836
- + '</div>'
4837
- + '</div>';
4838
- }
4839
- return '<div class="ix-legend-row" data-idx="' + i + '" style="' + rowStyle + '">'
4840
- + '<div style="margin-bottom:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + labels[i] + '</div>'
4841
- + '<div style="display:flex;align-items:center;gap:6px;">'
4842
- + '<span style="flex:0 0 8px;width:8px;height:8px;border-radius:50%;background:' + color + ';"></span>'
4843
- + '<span style="flex:1 1 auto;min-width:0;height:6px;background:' + legendColors.track + ';border-radius:3px;overflow:hidden;">'
4844
- + '<span style="display:block;height:100%;width:' + pct + '%;background:' + color + ';border-radius:3px;"></span>'
4845
- + '</span>'
4846
- + '<span style="flex:0 0 auto;text-align:right;min-width:60px;">' + rt._formatTooltipValue(totals[i]) + legendUnit + '</span>'
4847
- + '</div>'
4848
- + '</div>';
4849
- }).join('');
4850
- // GL-PORT COMPAT: real ixmaps-flat's row markup wires its
4851
- // click handler inline (onclick="ixmaps.markThemeClass(...)"
4852
- // — legend.js:817), toggle decided by the caller. Same
4853
- // split here: the toggle check lives in this click
4854
- // handler, the actual add/remove-and-redraw primitive is
4855
- // markRuntimeClass/unmarkRuntimeClass, which the globals
4856
- // ixmaps.markThemeClass/unmarkThemeClass also use — so a
4857
- // page's OWN custom UI drives this exact legend too. The
4858
- // rows pass their runtime itself: its .layer() name may be
4859
- // shared with other themes of the page.
4860
- rowsEl.querySelectorAll('.ix-legend-row').forEach(rowEl => {
4861
- rowEl.addEventListener('click', () => {
4862
- const idx = parseInt(rowEl.dataset.idx, 10);
4863
- if (rt._markedClasses.has(idx)) unmarkRuntimeClass(rt, idx);
4864
- else markRuntimeClass(rt, idx);
4865
- });
4866
- });
4867
- };
4868
-
4869
- // Recompute+re-render on EVERY redraw, not just mark toggles —
4870
- // this is what makes the legend map-view-aware: a plain pan/
4871
- // zoom/rotate fires this too (via the map's own 'move'
4872
- // listener -> scheduleRefresh -> notifyRedraw, debounced the
4873
- // same 400ms as the facets sidebar's own onRedraw use), same
4874
- // signal already used elsewhere in this file for "the visible
4875
- // data just changed, recompute".
4876
- engineApi.onRedraw(() => { computeTotals(); renderRows(); });
4877
- }
4878
-
4879
- // rt._triggerRedraw: see its own doc comment on LayerRuntime
4880
- // (set here rather than at construction time since refresh()
4881
- // doesn't exist yet when the runtime is built). Kept
4882
- // unconditional (even for TEXTLEGEND, which never registers
4883
- // the onRedraw recompute above) — a page can still call
4884
- // ixmaps.markThemeClass/unmarkThemeClass directly with no
4885
- // visual row list to click, and that should still redraw the
4886
- // MAP's own dim/isolate effect even without a legend UI for
4887
- // it, matching real-engine global-API availability regardless
4888
- // of legend style.
4889
- rt._triggerRedraw = () => { refresh(); };
4890
-
4891
- // Header row (title/snippet + collapse toggle) always stays
4892
- // visible; everything else lives in `bodyEl`, hidden/shown as
4893
- // a single unit by the toggle below — collapsing shows just
4894
- // the title bar, matching the real engine's own fold/unfold
4895
- // behavior (ixmaps.legendState + the "legend-folded" CSS
4896
- // class, ui/js/tools/legend.js) even though this port toggles
4897
- // via a plain display swap on one wrapper div rather than a
4898
- // shared stylesheet class (this panel has no external CSS at
4899
- // all — everything here is inline-styled, unlike the real
4900
- // engine's DOM/CSS-class-driven legend).
4901
- const header = document.createElement('div');
4902
- header.style.cssText = 'display:flex;align-items:flex-start;justify-content:space-between;gap:6px;';
4903
- const headerText = document.createElement('div');
4904
- headerText.style.cssText = 'min-width:0;flex:1 1 auto;';
4905
- let headerHtml = '';
4906
- if (rt.meta.title) headerHtml += '<div style="font-weight:600;font-size:13px;margin-bottom:2px;">' + rt.meta.title + '</div>';
4907
- if (rt.meta.snippet) headerHtml += '<div style="opacity:0.75;">' + rt.meta.snippet + '</div>';
4908
- headerText.innerHTML = headerHtml;
4909
- header.appendChild(headerText);
4910
- const collapseBtn = document.createElement('button');
4911
- collapseBtn.type = 'button';
4912
- collapseBtn.style.cssText = 'flex:0 0 auto;background:transparent;border:none;color:inherit;'
4913
- + 'font-size:14px;line-height:1;cursor:pointer;padding:2px 4px;opacity:0.7;';
4914
- header.appendChild(collapseBtn);
4915
- panel.appendChild(header);
4916
-
4917
- const bodyEl = document.createElement('div');
4918
- bodyEl.style.cssText = 'display:flex;flex-direction:column;min-height:0;flex:1 1 auto;margin-top:8px;';
4919
- panel.appendChild(bodyEl);
4920
-
4921
- // Collapsed by default under the real engine's own narrow-
4922
- // screen legend threshold (ui/js/tools/legend.js: `if
4923
- // (window.innerWidth < 500) ixmaps.legend.hide()`) — reusing
4924
- // that concrete precedent rather than picking an arbitrary
4925
- // "mobile" breakpoint of this port's own invention. Checked
4926
- // once at panel-build time, not on resize — matching the real
4927
- // engine's own one-shot-at-redraw-time check, not a live
4928
- // matchMedia listener.
4929
- const MOBILE_LEGEND_BREAKPOINT = 500;
4930
- let collapsed = legendFolded || window.innerWidth < MOBILE_LEGEND_BREAKPOINT;
4931
- function applyCollapsed() {
4932
- bodyEl.style.display = collapsed ? 'none' : 'flex';
4933
- collapseBtn.textContent = collapsed ? '▸' : '▾';
4934
- collapseBtn.title = collapsed ? 'Expand legend' : 'Collapse legend';
4935
- }
4936
- collapseBtn.addEventListener('click', () => { collapsed = !collapsed; applyCollapsed(); });
4937
- applyCollapsed();
4938
- rt._setLegendCollapsed = c => { collapsed = !!c; applyCollapsed(); }; // setLegendOption
4939
-
4940
- // Selection/filter by field — opt-in via .style({legendfilter:
4941
- // "<field>"}), e.g. "country_long" on the power-plants sample.
4942
- // A NEW, ixmaps-gl-only convention: real ixmaps-flat's own
4943
- // style.filterfield names the DEFAULT field an unqualified
4944
- // .filter("text") search term matches against (maptheme.js:
4945
- // 1275-1276/7359-7362) — a different concept entirely, so this
4946
- // deliberately uses its own name rather than overloading that
4947
- // one. Options come from the FULL dataset (a world-spanning
4948
- // bbox, not the current viewport) and stay fixed regardless of
4949
- // pan/zoom — unlike the map-view-aware category totals below,
4950
- // a picker whose own choices kept shrinking as you panned
4951
- // would make it impossible to select a country not currently
4952
- // in view. Selecting a value reuses the engine's own existing
4953
- // facet-filter primitive (engineApi.setFacetFilter/
4954
- // clearFacetFilter — already propagates to sibling runtimes
4955
- // sharing the same data source and calls refresh()), so this
4956
- // is UI wiring only, no new filtering logic.
4957
- const filterField = rt.style.legendfilter;
4958
- if (filterField) {
4959
- const facet = engineApi.getFacets(rt.name, [filterField], { bbox: [-180, -85, 180, 85] })[0];
4960
- const values = (facet && facet.type === 'textual')
4961
- ? facet.values.slice().sort((a, b) => String(a).localeCompare(String(b)))
4962
- : [];
4963
- const filterEl = document.createElement('select');
4964
- filterEl.style.cssText = 'width:100%;margin-bottom:8px;background:' + legendColors.selectBg + ';'
4965
- + 'color:' + legendColors.fg + ';border:1px solid ' + legendColors.selectBorder + ';border-radius:4px;padding:4px 6px;font:inherit;';
4966
- filterEl.innerHTML = '<option value="">All (' + values.length + ')</option>'
4967
- + values.map(v => '<option value="' + escapeHtml(v) + '">' + escapeHtml(v) + '</option>').join('');
4968
- filterEl.addEventListener('change', () => {
4969
- if (filterEl.value) engineApi.setFacetFilter(rt.name, filterField, filterEl.value);
4970
- else engineApi.clearFacetFilter(rt.name, filterField);
4971
- });
4972
- bodyEl.appendChild(filterEl);
4973
- }
4974
-
4975
- if (!isTextOnly) { bodyEl.appendChild(rowsScroll); renderRows(); }
4976
-
4977
- if (rt.meta.description) {
4978
- const desc = document.createElement('div');
4979
- desc.style.cssText = 'margin-top:8px;font-size:11px;opacity:0.8;';
4980
- // Raw HTML, matching the real engine's own description
4981
- // rendering and this engine's existing .legend(html)/
4982
- // tooltip conventions (e.g. _buildTooltipContext) — real
4983
- // pages already embed their own source-citation markup
4984
- // inside meta.description (see the power-plants sample).
4985
- desc.innerHTML = rt.meta.description;
4986
- bodyEl.appendChild(desc);
4987
- }
4988
-
4989
- // Chart-size slider — real engine's own range (legend.js:
4990
- // 3196-3202), 100% == scale:1, wired to changeThemeStyle(
4991
- // themeId,"scale:"+pct/100,"set")+redrawTheme there; this
4992
- // engine's equivalent is rt.setStyle({scale}) + refresh(),
4993
- // the same primitive engineApi.setThemeStyle uses above.
4994
- // CHOROPLETH themes get an OPACITY slider instead, as in flat
4995
- // (legend.js ~3190: 0-100%, fillopacity·100, default 90;
4996
- // changeThemeStyle("fillopacity:"+pct/100,"set") + redraw).
4997
- // (flat labels a VECTOR theme's size slider "Line width" — gl has
4998
- // no VECTOR themes.)
4999
- // Flat tests the words (legend.js 2533-2535): a "CHOROPLETHE"
5000
- // theme has neither slider.
5001
- const typeWords = rt.flags.typeString != null ? rt.flags.typeString : [...rt.flags].join('|');
5002
- const isOpacitySlider = /\bCHOROPLETH\b/.test(typeWords);
5003
- const hasSlider = isOpacitySlider || /\bCHART\b|\bBUBBLE\b|\bDOT\b/.test(typeWords);
5004
- if (hasSlider) {
5005
- const sliderRow = document.createElement('div');
5006
- sliderRow.style.cssText = 'margin-top:10px;font-size:11px;opacity:0.8;';
5007
- const fillPct = Math.round(styleNum(rt.style.fillopacity) * 100);
5008
- const initialPct = isOpacitySlider
5009
- ? (Number.isFinite(fillPct) ? Math.max(0, Math.min(100, fillPct)) : 90)
5010
- : Math.round((styleNum(rt.style.scale) || 1) * 100);
5011
- const sliderLabel = isOpacitySlider ? 'Opacity' : 'Chart size';
5012
- sliderRow.innerHTML = sliderLabel + ': <span class="ix-legend-scale-val">' + initialPct + '</span>%';
5013
- bodyEl.appendChild(sliderRow);
5014
- const slider = document.createElement('input');
5015
- slider.type = 'range';
5016
- slider.min = isOpacitySlider ? '0' : '25';
5017
- slider.max = isOpacitySlider ? '100' : '200';
5018
- slider.value = String(initialPct);
5019
- slider.style.cssText = 'width:100%;margin-top:2px;';
5020
- slider.addEventListener('input', () => {
5021
- const pct = parseInt(slider.value, 10);
5022
- sliderRow.querySelector('.ix-legend-scale-val').textContent = pct;
5023
- rt.setStyle(isOpacitySlider ? { fillopacity: pct / 100 } : { scale: pct / 100 });
5024
- refresh();
5025
- });
5026
- bodyEl.appendChild(slider);
5027
- }
5028
- }
5029
- };
5030
- runtimes.forEach(rt => addLegendPanel(rt));
5031
- }
4979
+ // the native legend: createLegend (its four hooks are what the rest
4980
+ // of the map calls — panels, the legend option, sub-themes, layout)
4981
+ ({ addLegendPanel, setLegendOption, updateSubTheme, layoutLegends } =
4982
+ createLegend({ builder, map, el, overlay, runtimes, engineApi, built, refresh, scheduleRefresh, notifyRedraw }));
5032
4983
 
5033
4984
  _lastMapApi = engineApi;
5034
4985
  // ?ixgl-debug: ixmaps.__glDebug.check() reports, for the moment it is
@@ -7362,7 +7313,10 @@
7362
7313
  const geometry = { type: 'Point', coordinates: [ll.lng, ll.lat] };
7363
7314
  const hasClass = cell.first.properties.classValue !== undefined;
7364
7315
  let props = cell.n > 1
7365
- ? { cluster: true, point_count: cell.n, value: cell.value, ...(hasClass ? { classValue: cell.classValue } : {}) }
7316
+ // titleRaw: the cell's first record — flat titles an aggregated
7317
+ // item by it (itemTitle)
7318
+ ? { cluster: true, point_count: cell.n, value: cell.value, ...(hasClass ? { classValue: cell.classValue } : {}),
7319
+ titleRaw: cell.first.properties.raw || cell.first.properties }
7366
7320
  : cell.first.properties;
7367
7321
  if (cell.series && cell.n > 1) props = Object.assign({}, props, { series: cell.series });
7368
7322
  // a field aggregation's cell key (the field value): the categories of
@@ -7428,6 +7382,7 @@
7428
7382
  let cell = cells.get(key);
7429
7383
  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); }
7430
7384
  cell.sumX += p.x; cell.sumY += p.y; cell.n++;
7385
+ if (!cell.titleRaw) cell.titleRaw = f.properties.titleRaw || f.properties.raw || null;
7431
7386
  cell.counts[f.properties.cat] += f.properties.value;
7432
7387
  if (f.properties.classValue !== undefined) cell.classTotal = (cell.classTotal || 0) + f.properties.classValue;
7433
7388
  // point_count is the aggregated-record count of a multi-record
@@ -7441,7 +7396,8 @@
7441
7396
  const ll = atCellCenter && cellPx ? worldPixelToLngLat(cell.cx, cell.cy, zoom) : worldPixelToLngLat(cell.sumX / cell.n, cell.sumY / cell.n, zoom);
7442
7397
  return {
7443
7398
  geometry: { type: 'Point', coordinates: [ll.lng, ll.lat] },
7444
- properties: { counts: cell.counts, total: cell.counts.reduce((a, c) => a + c, 0), recordCounts: cell.recordCounts, ...(cell.classTotal !== undefined ? { classTotal: cell.classTotal } : {}) }
7399
+ properties: { counts: cell.counts, total: cell.counts.reduce((a, c) => a + c, 0), recordCounts: cell.recordCounts, ...(cell.classTotal !== undefined ? { classTotal: cell.classTotal } : {}),
7400
+ ...(cell.titleRaw ? { titleRaw: cell.titleRaw } : {}) }
7445
7401
  };
7446
7402
  });
7447
7403
  }
@@ -8346,14 +8302,31 @@
8346
8302
  // only available for an individual, unaggregated point — an
8347
8303
  // aggregated/grouped bubble has no single underlying record to
8348
8304
  // tabulate, so it resolves to an empty string there.
8305
+ // an item's title as flat gives it (maptheme.js 11838-11886): the title
8306
+ // field of its record — of an aggregated item its first record; an
8307
+ // aggregated grid cell (no aggregation field) of more than 3 records,
8308
+ // or without a title, is titled "(n)"
8309
+ _itemTitle(props) {
8310
+ const raw = props.raw || props.titleRaw;
8311
+ const title = this.binding.title && raw && raw[this.binding.title] != null ? String(raw[this.binding.title]) : '';
8312
+ const aggregated = !!(props.counts || props.cluster);
8313
+ if (aggregated && this._clusterRadiusPx && !this._clusterField) {
8314
+ const n = groupRecordCount(props);
8315
+ if (!title || n > 3) return '(' + n + ')';
8316
+ }
8317
+ return title;
8318
+ }
8319
+
8349
8320
  buildTooltipHtml(object) {
8350
- if (!this.meta.tooltip) return null;
8321
+ // no .meta({tooltip}): flat's default template (tooltip_mustache.js 394)
8322
+ // — the theme title, the item's title and its chart
8323
+ const template = this.meta.tooltip || FLAT_DEFAULT_TOOLTIP;
8351
8324
  if (!global.Mustache) {
8352
- console.warn('[ixmaps-gl] .meta({tooltip}) is set but Mustache.js is not loaded — ' +
8325
+ console.warn('[ixmaps-gl] tooltips need Mustache.js, which is not loaded — ' +
8353
8326
  'include https://unpkg.com/mustache@4.2.0/mustache.min.js before ixmaps-gl.js.');
8354
8327
  return null;
8355
8328
  }
8356
- return global.Mustache.render(this.meta.tooltip, this._buildTooltipContext(object));
8329
+ return global.Mustache.render(template, this._buildTooltipContext(object));
8357
8330
  }
8358
8331
 
8359
8332
  _buildTooltipContext(object) {
@@ -8374,9 +8347,7 @@
8374
8347
  label: isGroup ? '' : (this.categoryDisplayLabels ? (this.categoryDisplayLabels[props.cat] || '') : ''),
8375
8348
  // flat: the item's title is its titlefield value (binding title/
8376
8349
  // titlefield, e.g. the comune name); else the class label
8377
- title: isGroup ? '' : (this.binding.title && props.raw && props.raw[this.binding.title] != null
8378
- ? String(props.raw[this.binding.title])
8379
- : (this.categoryDisplayLabels ? (this.categoryDisplayLabels[props.cat] || '') : '')),
8350
+ title: this._itemTitle(props) || (isGroup ? '' : (this.categoryDisplayLabels ? (this.categoryDisplayLabels[props.cat] || '') : '')),
8380
8351
  count: isGroup ? (props.recordCounts ? props.recordCounts.reduce((a, c) => a + c, 0) : '') : 1,
8381
8352
  class: isGroup ? '' : props.cat
8382
8353
  }
@@ -8409,11 +8380,16 @@
8409
8380
  // breakdown, one line per present category, each with its own legend
8410
8381
  // color swatch and its OWN value (never summed across categories).
8411
8382
  _renderItemChartHtml(props) {
8383
+ const unitOf = this.style.units ? ' ' + this.style.units : '';
8384
+ // an AGGREGATE CATEGORICAL choropleth polygon: its sum per category
8385
+ if (props.parts) {
8386
+ const labels = this.categoryDisplayLabels || this.categoryLabels || [];
8387
+ return tooltipTable(props.parts.map((v, i) => (v > 0 ? { rgb: this.categoryColorsRgb[i], label: labels[i] || '(n/d)', value: this._formatTooltipValue(v) + unitOf } : null)).filter(Boolean));
8388
+ }
8412
8389
  if (this.flags.has('CHOROPLETH') && (this.flags.has('DOMINANT') || this.flags.has('COMPOSECOLOR')) && props.raw) {
8413
8390
  return this._renderMultiFieldBarsHtml(props.raw);
8414
8391
  }
8415
- const swatch = rgb => `<span style="display:inline-block;width:0.7em;height:0.7em;border-radius:50%;background:rgb(${rgb.join(',')});margin-right:0.4em;vertical-align:middle"></span>`;
8416
- const unit = this.style.units ? ' ' + this.style.units : '';
8392
+ const unit = unitOf;
8417
8393
 
8418
8394
  // PLOT|GRIDSIZE curve cells: properties.values is an array parallel
8419
8395
  // to _plotCategories() (e.g. one FERITI sum per year), not a single
@@ -8423,10 +8399,10 @@
8423
8399
  if (Array.isArray(props.values)) {
8424
8400
  const categories = this._isItemPlot() ? this._itemPlotLabels() : this._plotCategories();
8425
8401
  const labels = this._isItemPlot() ? categories : Array.isArray(this.style.label) ? this.style.label.map(String) : categories;
8426
- return props.values.map((v, i) => {
8402
+ return tooltipTable(props.values.map((v, i) => {
8427
8403
  if (v == null || isNaN(v)) return null;
8428
- return `<div>${labels[i] || categories[i] || i}: ${this._formatTooltipValue(v)}${unit}</div>`;
8429
- }).filter(Boolean).join('');
8404
+ return { rgb: null, label: labels[i] || categories[i] || i, value: this._formatTooltipValue(v) + unit };
8405
+ }).filter(Boolean));
8430
8406
  }
8431
8407
 
8432
8408
  if (props.counts) {
@@ -8445,19 +8421,17 @@
8445
8421
  // zoom until something resets it. Root cause of the "trackpad
8446
8422
  // zoom sometimes just stops updating" reports.
8447
8423
  const labels = this.categoryDisplayLabels || this.categoryLabels || [];
8448
- return labels.map((label, i) => {
8424
+ return tooltipTable(labels.map((label, i) => {
8449
8425
  if (!(props.counts[i] > 0)) return null;
8450
- const rgb = this.categoryColorsRgb[i];
8451
- return `<div>${swatch(rgb)}${label || '(n/d)'}: ${this._formatTooltipValue(props.counts[i])}${unit}</div>`;
8452
- }).filter(Boolean).join('');
8426
+ return { rgb: this.categoryColorsRgb[i], label: label || '(n/d)', value: this._formatTooltipValue(props.counts[i]) + unit };
8427
+ }).filter(Boolean));
8453
8428
  }
8454
8429
  if (props.cat != null) {
8455
8430
  const label = this.categoryDisplayLabels ? (this.categoryDisplayLabels[props.cat] || '') : '';
8456
8431
  const rgb = this.categoryColorsRgb ? this.categoryColorsRgb[props.cat] : null;
8457
8432
  // no bound size field (e.g. a plain DOT|CATEGORICAL layer) -> just
8458
8433
  // the category label, no ": undefined" value suffix
8459
- const valueSuffix = this._hasValue(props.value) ? `: ${this._formatTooltipValue(props.value)}${unit}` : '';
8460
- return `<div>${rgb ? swatch(rgb) : ''}${label}${valueSuffix}</div>`;
8434
+ return tooltipTable([{ rgb, label, value: this._hasValue(props.value) ? this._formatTooltipValue(props.value) + unit : '' }]);
8461
8435
  }
8462
8436
  return '';
8463
8437
  }
@@ -8518,7 +8492,7 @@
8518
8492
  .filter(k => k !== 'geometry')
8519
8493
  .map(k => `<tr><td style="padding:0 0.6em 0 0;color:#888">${k}</td><td>${this._isNumericValue(raw[k]) ? this._formatTooltipValue(raw[k]) : raw[k]}</td></tr>`)
8520
8494
  .join('');
8521
- return `<table style="font-size:0.85em;border-collapse:collapse">${rows}</table>`;
8495
+ return `<table style="font-size:0.95em;border-collapse:collapse">${rows}</table>`;
8522
8496
  }
8523
8497
 
8524
8498
  _isNumericValue(v) {
@@ -8784,7 +8758,7 @@
8784
8758
  geometry: f.geometry,
8785
8759
  properties: {
8786
8760
  // no part above 0: no item in flat, not painted
8787
- value: agg ? agg.value : undefined, raw: agg ? f.properties : {}, cat: agg ? agg.index : null,
8761
+ value: agg ? agg.value : undefined, raw: agg ? f.properties : {}, cat: agg ? agg.index : null, parts: agg ? agg.parts : undefined,
8788
8762
  dopacityAlpha: agg && dopacityActive ? dominantDopacityAlpha(this.style, this.flags, agg.value, aggMaxA[agg.index]) : null
8789
8763
  }
8790
8764
  };
@@ -9813,7 +9787,8 @@
9813
9787
  return {
9814
9788
  geometry: f.geometry,
9815
9789
  properties: { counts: this._oneHot(f.properties.cat, f.properties.point_count), total: f.properties.value,
9816
- ...(f.properties.classValue !== undefined ? { classTotal: f.properties.classValue } : {}) }
9790
+ ...(f.properties.classValue !== undefined ? { classTotal: f.properties.classValue } : {}),
9791
+ ...(f.properties.titleRaw ? { titleRaw: f.properties.titleRaw } : {}) }
9817
9792
  };
9818
9793
  });
9819
9794
  return { individual, groups };
@@ -10184,9 +10159,7 @@
10184
10159
  const boxR = circular ? r * 1.1 + margin : r;
10185
10160
  let title = null;
10186
10161
  if (titleShown) {
10187
- const raw = d.properties.raw;
10188
- const n = groupRecordCount(d.properties);
10189
- const text = n > 3 ? '(' + n + ')' : (raw && raw[this.binding.title] != null ? String(raw[this.binding.title]) : '');
10162
+ const text = this._itemTitle(d.properties);
10190
10163
  if (text) {
10191
10164
  const f = titleFontPx;
10192
10165
  const lines = wrapTitleLines(text, f, boxR * 2).split('\n');
@@ -10822,6 +10795,9 @@
10822
10795
  // nothing here authored that string. Escaped before going into
10823
10796
  // innerHTML so a stray `<`/`&`/quote in a source field can't break the
10824
10797
  // option markup (or worse).
10798
+ function escapeHtml(s) {
10799
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
10800
+ }
10825
10801
  // a legend's row labels as HTML: the page's .style({label}) as given,
10826
10802
  // values from the data escaped
10827
10803
  function legendRowLabels(rt) {
@@ -10829,10 +10805,186 @@
10829
10805
  if (display && display !== rt.categoryLabels) return display;
10830
10806
  return (rt.categoryLabels || []).map(v => escapeHtml(v));
10831
10807
  }
10832
- function escapeHtml(s) {
10833
- return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
10808
+
10809
+ // ===============================================================
10810
+ // FLAT-COMPATIBILITY API — what a page written for ixmaps-flat calls on
10811
+ // the global `ixmaps` object; it acts on the last built map:
10812
+ // - page hooks gl calls: htmlgui_onNewTheme, htmlgui_onDrawTheme,
10813
+ // htmlgui_onZoomAndPan
10814
+ // - the map handle ixmaps.map() and its methods (MAP_HANDLE_METHODS)
10815
+ // - view: getZoom, getCenter, getMapTypeId, getBoundingBox
10816
+ // - themes: getThemeObj (flat's theme object, with its data table and
10817
+ // the items on the map), getThemeDefinitionObj, getThemes,
10818
+ // changeThemeStyle / removeTheme / setBasemapOpacity (map name first),
10819
+ // refreshTheme, markThemeClass / unmarkThemeClass
10820
+ // - data: ixmaps.data (facets: getFacets, showFacets, …), the
10821
+ // embeddedSVG.window tables (setExternalData is with the data loading)
10822
+ // - projects: getProjectString, setProjectJSON, loadProject
10823
+ // - page UI: setTitle, setTitleBox, formatValue; message,
10824
+ // filterThemeItems and highlightThemeItems are accepted but not ported
10825
+ // The object itself is assembled at the end of this section (global.ixmaps).
10826
+ // ===============================================================
10827
+
10828
+ // flat's page hook ixmaps.htmlgui_onNewTheme(szId), called when a theme
10829
+ // is created (maptheme.js 927) — with the theme's id (its name)
10830
+ // flat's tooltip for a theme without its own template (ui/js/tools/
10831
+ // tooltip_mustache.js 394)
10832
+ // tooltip_mustache.js 394) — the same parts, structured: the theme title
10833
+ // as h2, the item title as h3, the chart (tooltipTable) below
10834
+ // (inline styles throughout: a page's own h2 / table / td rules must not
10835
+ // restyle the tooltip)
10836
+ const FLAT_DEFAULT_TOOLTIP = "<h2 style='margin:0 0 0.15em;padding:0;font-size:1.25em;font-weight:600;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis'>{{theme.title}}</h2>"
10837
+ + "{{#theme.item.title}}<h3 style='margin:0 0 0.4em;padding:0;font-size:1.1em;font-weight:600;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis'>{{theme.item.title}}</h3>{{/theme.item.title}}"
10838
+ + "{{theme.item.chart}}";
10839
+ // {{theme.item.chart}} as a table: per row a color swatch (or none), the
10840
+ // label (left, one line) and the value (right-aligned)
10841
+ function tooltipTable(rows) {
10842
+ if (!rows.length) return '';
10843
+ const swatch = rgb => (rgb ? `<span style="display:inline-block;width:0.75em;height:0.75em;border-radius:50%;background:rgb(${rgb.slice(0, 3).join(',')})"></span>` : '');
10844
+ const td = 'border:none;background:transparent;font:inherit;color:inherit;line-height:1.15;';
10845
+ return '<table style="border-collapse:collapse;border:none;background:transparent;margin:0.2em 0;font-size:1em;line-height:1.15">'
10846
+ + rows.map(r => '<tr>'
10847
+ + `<td style="${td}padding:0.05em 0.4em 0.05em 0;vertical-align:middle">${swatch(r.rgb)}</td>`
10848
+ // one line: a long label ends in an ellipsis (full text as its title)
10849
+ + `<td style="${td}padding:0.05em 0.8em 0.05em 0;text-align:left"><span title="${escapeHtml(String(r.label).replace(/<[^>]*>/g, ''))}" style="display:inline-block;max-width:13em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;vertical-align:bottom">${r.label}</span></td>`
10850
+ + `<td style="${td}padding:0.05em 0;text-align:right;white-space:nowrap">${r.value}</td></tr>`).join('')
10851
+ + '</table>';
10852
+ }
10853
+ function themeIdOf(rt) {
10854
+ return (rt.style && rt.style.name) || (rt.meta && rt.meta.name) || rt.name;
10855
+ }
10856
+ function notifyNewTheme(rt) {
10857
+ const hook = pageIxmaps() && pageIxmaps().htmlgui_onNewTheme;
10858
+ if (typeof hook !== 'function') return;
10859
+ try { hook.call(global.ixmaps, themeIdOf(rt)); } catch (e) { console.error('[ixmaps-gl] htmlgui_onNewTheme:', e); }
10834
10860
  }
10835
10861
 
10862
+ // flat's ixmaps.map() — the running map's handle (htmlgui.js 173ff,
10863
+ // htmlgui_flat.js). Its methods act on the last built map; called while
10864
+ // the map is still building, they run once it is ready.
10865
+ const MAP_HANDLE_METHODS = ['replace', 'add', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
10866
+ 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible', 'attribution'];
10867
+ const _mapHandle = {};
10868
+ for (const m of MAP_HANDLE_METHODS) {
10869
+ _mapHandle[m] = (...args) => {
10870
+ if (_lastMapApi) { const r = _lastMapApi[m](...args); return r === _lastMapApi ? _mapHandle : r; }
10871
+ _mapReady.then(api => api[m](...args));
10872
+ return _mapHandle;
10873
+ };
10874
+ }
10875
+ _mapHandle.getZoom = () => (_lastMapApi ? _lastMapApi.getZoom() : undefined);
10876
+ _mapHandle.getThemeObj = id => getThemeObj(id);
10877
+ function mapHandle() { return _mapHandle; }
10878
+
10879
+ // flat's ixmaps.formatValue(value, precision, flag) (ui/js/tools/format.js),
10880
+ // which user chart scripts call: thousands separated by "." (BLANK: a
10881
+ // space), the decimals after "," (BLANK: "."), CEIL / FLOOR rounding
10882
+ function flatFormatPart(nPart, szLeading) {
10883
+ szLeading = szLeading || '';
10884
+ let szPart = '';
10885
+ if (nPart < 100) szPart += szLeading;
10886
+ if (nPart < 10) szPart += szLeading;
10887
+ if (nPart === 0) szPart += szLeading;
10888
+ else szPart += String(nPart);
10889
+ return szPart;
10890
+ }
10891
+ function flatFormatValue(nValue, nPrecision, szFlag) {
10892
+ nValue = Number(nValue);
10893
+ if (!isFinite(nValue) || !isFinite(nPrecision)) return String(nValue);
10894
+ if (nValue === 0) return String(nValue);
10895
+ if (nValue > 1000000000000 || nValue < -1000000000000) return String(nValue);
10896
+ nPrecision = Math.max(0, nPrecision || 0);
10897
+ if (nValue > 0.0000001 && nPrecision > 0) {
10898
+ while (Number(nValue.toFixed(nPrecision - 1)) === 0) nPrecision++;
10899
+ }
10900
+ let v = Number(nValue.toFixed(nPrecision + 1));
10901
+ const clip = Math.pow(10, nPrecision);
10902
+ if (szFlag && /CEIL/.test(szFlag)) v = Math.ceil(v * clip) / clip;
10903
+ else if (szFlag && /FLOOR/.test(szFlag)) v = Math.floor(v * clip) / clip;
10904
+ else v = Math.round(v * clip) / clip;
10905
+ let szDecimals = String(v);
10906
+ if (/\./.test(szDecimals)) {
10907
+ szDecimals = szDecimals.split('.')[1];
10908
+ while (szDecimals.length < nPrecision) szDecimals += '0';
10909
+ } else szDecimals = '';
10910
+ let szReturn = v < 0 ? '-' : '';
10911
+ let szLeading = '';
10912
+ let n = Math.floor(Math.abs(v));
10913
+ if (!szFlag || !/NOBREAKS/.test(szFlag)) {
10914
+ let nClip = 1000;
10915
+ while (n > nClip) nClip *= 1000;
10916
+ nClip /= 1000;
10917
+ let szBreak = ' ';
10918
+ while (nClip >= 1000) {
10919
+ const nPart = Math.floor(n / nClip);
10920
+ szReturn += flatFormatPart(nPart, szLeading);
10921
+ n = n % nClip;
10922
+ nClip /= 1000;
10923
+ if (nPart) {
10924
+ szLeading = '0';
10925
+ szBreak = szFlag && /SPACE/.test(szFlag) ? '<span style="font-size:0.5em;">&nbsp;</span>' : szFlag && /BLANK/.test(szFlag) ? '&nbsp;' : '.';
10926
+ }
10927
+ szReturn += szBreak;
10928
+ }
10929
+ }
10930
+ szReturn += flatFormatPart(n, szLeading);
10931
+ if (!szReturn.length || szReturn === '-') szReturn += '0';
10932
+ if (szDecimals.length && szDecimals !== '00') szReturn += (szFlag && /BLANK/.test(szFlag) ? '.' : ',') + szDecimals;
10933
+ return szReturn;
10934
+ }
10935
+
10936
+ function getZoom() { return _lastMapApi ? _lastMapApi.getZoom() : undefined; }
10937
+
10938
+ // flat's ixmaps.getBoundingBox() (htmlgui_sync_Leaflet_VT.js
10939
+ // htmlMap_getBounds): [{lat, lng} south-west, {lat, lng} north-east] of
10940
+ // the view, latitude clamped to ±85.05, longitude not capped. Set by the
10941
+ // last build(): a broker (data.type "ext") is called while its map is
10942
+ // still building, so until the MapLibre map exists the bounds come from
10943
+ // the requested view and the container size (MapLibre's 512 px tiles).
10944
+ let _boundsSource = null;
10945
+ function viewBounds(lat, lng, mlZoom, width, height) {
10946
+ const world = 512 * Math.pow(2, mlZoom);
10947
+ const x = (lng + 180) / 360 * world;
10948
+ const s = Math.sin(Math.max(-85.05, Math.min(85.05, lat)) * Math.PI / 180);
10949
+ const y = (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * world;
10950
+ const lngAt = px => px / world * 360 - 180;
10951
+ const latAt = py => Math.atan(Math.sinh(Math.PI * (1 - 2 * py / world))) * 180 / Math.PI;
10952
+ return [{ lat: latAt(y + height / 2), lng: lngAt(x - width / 2) }, { lat: latAt(y - height / 2), lng: lngAt(x + width / 2) }];
10953
+ }
10954
+ function getBoundingBox() {
10955
+ const b = _boundsSource ? _boundsSource() : null;
10956
+ if (!b) return null;
10957
+ 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 }];
10958
+ }
10959
+
10960
+ // flat's ixmaps.setTitle(html) / setTitleBox(text, color) (htmlgui.js
10961
+ // 1311-1331): a message line over the last built map, same markup as
10962
+ // flat's default (legend not aligned left); '' clears it.
10963
+ let _titleHost = null;
10964
+ function setTitle(szTitle) {
10965
+ if (!_titleHost) return;
10966
+ let box = _titleHost.querySelector(':scope > .ixmaps-gl-title');
10967
+ if (!box) {
10968
+ box = document.createElement('div');
10969
+ box.className = 'ixmaps-gl-title';
10970
+ box.style.cssText = 'position:absolute;top:11px;left:0;z-index:3;pointer-events:none;';
10971
+ _titleHost.appendChild(box);
10972
+ }
10973
+ box.innerHTML = szTitle
10974
+ ? "<div style='position:relative;left:100px;top:2px;font-style:arial,helvetica;font-size:22px'>" + szTitle + '</div>'
10975
+ : '';
10976
+ }
10977
+ function setTitleBox(szTitle, szColor) {
10978
+ 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>');
10979
+ }
10980
+ // flat's ixmaps.refreshTheme(szId): reloads the theme's data on the last
10981
+ // built map (a broker is called again) — see engineApi.refreshTheme
10982
+ function refreshTheme(szId) {
10983
+ if (!_lastMapApi || !_lastMapApi.refreshTheme) return;
10984
+ _lastMapApi.refreshTheme(szId).catch(err => console.error('[ixmaps-gl] refreshTheme:', err));
10985
+ }
10986
+
10987
+
10836
10988
  // GL-PORT COMPAT: real ixmaps-flat's ixmaps.getThemeObj(szId) — a
10837
10989
  // global lookup by theme id, independent of which map built it — looked
10838
10990
  // up from _globalThemeRegistry (see its own comment for the "last
@@ -11312,6 +11464,10 @@
11312
11464
  // handle's methods on the last built map
11313
11465
  getCenter: () => (_lastMapApi && _lastMapApi.map ? _lastMapApi.map.getCenter() : null),
11314
11466
  getMapTypeId: () => (_lastMapApi && _lastMapApi.getMapTypeId ? _lastMapApi.getMapTypeId() : ''),
11467
+ // flat's attribution: ixmaps.setAttribution(text) and its htmlgui_ pair
11468
+ setAttribution: (text) => { if (_lastMapApi && _lastMapApi.setAttribution) _lastMapApi.setAttribution(text); },
11469
+ htmlgui_setAttributionString: (text) => { if (_lastMapApi && _lastMapApi.setAttribution) _lastMapApi.setAttribution(text); },
11470
+ htmlgui_getAttributionString: () => (_lastMapApi && _lastMapApi.getAttribution ? _lastMapApi.getAttribution() : ''),
11315
11471
  changeThemeStyle: (szMap, szId, szStyle, szFlag) => _mapHandle.changeThemeStyle(szId, szStyle, szFlag),
11316
11472
  removeTheme: (szMap, szId) => _mapHandle.remove(szId),
11317
11473
  setBasemapOpacity: (szMap, nOpacity, szMode) => _mapHandle.setBasemapOpacity(nOpacity, szMode),
@@ -11338,7 +11494,7 @@
11338
11494
  // test-only: lets test/unit/*.test.mjs call pure internals directly (the
11339
11495
  // engine runs in a Node vm there); deliberately NOT on the ixmaps object
11340
11496
  global.__ixmapsGlInternals = {
11341
- IXMAPS_GL_VERSION, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout,
11497
+ IXMAPS_GL_VERSION, tooltipTable, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout,
11342
11498
  normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, typeStyleNumbers, styleNum,
11343
11499
  resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
11344
11500
  equalIntervalBreaks, quantileBreaks, naturalBreaks, evenStrideSample, jenksBreakValues, partsFromBreakValues, resolvePartsClass,