ixmaps-gl 0.2.9 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +3 -3
  2. package/ixmaps-gl.js +1079 -1016
  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.10';
86
86
 
87
87
  const LIB_URLS = {
88
88
  // Bumped 3.6.2 -> 5.x (2026-09-21, globe-projection compat fix): native
@@ -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;
@@ -2779,6 +2643,916 @@
2779
2643
  };
2780
2644
  }
2781
2645
 
2646
+ // ---------------------------------------------------------------
2647
+ // Hover tooltips and the click-to-pin tooltip of a map, built once per map
2648
+ // by MapBuilder.build(). ctx: the builder (its map options, for the
2649
+ // tooltip look), the MapLibre map, its container element and
2650
+ // findRuntimeForLayerId (the theme of a drawn deck.gl layer). Returns the
2651
+ // overlay's getTooltip / onClick handlers and mount(), which adds the
2652
+ // pinned tooltip to the page and keeps it on its place while the map moves.
2653
+ // ---------------------------------------------------------------
2654
+ function createTooltips(ctx) {
2655
+ const { builder, map, el, findRuntimeForLayerId } = ctx;
2656
+ // Click-to-pin tooltip: matches the real ixmaps engine's own
2657
+ // click-pins-the-tooltip convention in pan mode (a hover tooltip is
2658
+ // transient; a click keeps one visible until dismissed). This is a
2659
+ // separate DOM element from deck.gl's own hover tooltip (so hovering
2660
+ // a DIFFERENT bubble still shows a normal transient tooltip even
2661
+ // while one is pinned) — its content is a snapshot of buildTooltipHtml
2662
+ // at click time (not live-updating), but its screen POSITION is kept
2663
+ // in sync with the map on every pan/zoom via map.project().
2664
+ let pinned = null; // { runtime, object, lngLat }
2665
+ // Tooltip look, like the legend's (flatLegendLook): flat's light
2666
+ // tooltip (tooltip_mustache.js: white 0.95, #444 text, thin black
2667
+ // border, 5px radius) on light basemaps, this engine's dark tooltip
2668
+ // on dark ones. Read when shown, so options set later (a
2669
+ // myMap.then(...).options({basemapopacity})) count.
2670
+ const tooltipLook = () => {
2671
+ const o = parseFloat(builder._engineOptions.basemapopacity);
2672
+ const look = flatLegendLook(builder.mapOptions.mapType, isNaN(o) ? 1 : o, builder.mapOptions.legendBackground || builder.mapOptions.legendbackground);
2673
+ return look.dark
2674
+ ? { background: 'rgb(41,50,60)', color: 'rgb(200,205,214)', border: 'none', borderRadius: '4px', boxShadow: '0 2px 8px rgba(0,0,0,0.3)' }
2675
+ : { background: 'rgba(255,255,255,0.95)', color: '#444', border: '0.5px solid black', borderRadius: '5px',
2676
+ boxShadow: 'rgba(0,0,0,0.2) 0px 2px 4px 0px, rgba(0,0,0,0.19) 0px 3px 10px 0px' };
2677
+ };
2678
+ const pinnedTooltipEl = document.createElement('div');
2679
+ pinnedTooltipEl.style.cssText = 'position:absolute;top:0;left:0;z-index:6;display:none;' +
2680
+ 'pointer-events:auto;max-width:320px;max-height:320px;overflow:auto;' +
2681
+ 'padding:0.6em 1.6em 0.6em 0.7em;font-size:0.85em;';
2682
+
2683
+ function updatePinnedTooltipPosition() {
2684
+ if (!pinned) return;
2685
+ const pt = map.project(pinned.lngLat);
2686
+ pinnedTooltipEl.style.transform = `translate(${pt.x + 10}px, ${pt.y - 10}px)`;
2687
+ }
2688
+
2689
+ // A stable per-geometry anchor coordinate for the identity check
2690
+ // below — only a Point's OWN coordinates are an [lng,lat] pair;
2691
+ // Polygon/MultiPolygon nest rings of them, so the first ring's
2692
+ // first vertex stands in instead (stable across rebuilds for the
2693
+ // same underlying feature, same as the Point case: this.features'
2694
+ // geometry references don't change shape between
2695
+ // _buildChoroplethLayers calls, only the wrapping properties do).
2696
+ function anchorCoordOf(geometry) {
2697
+ if (!geometry) return null;
2698
+ if (geometry.type === 'Point') return geometry.coordinates;
2699
+ if (geometry.type === 'Polygon') return geometry.coordinates[0] && geometry.coordinates[0][0];
2700
+ if (geometry.type === 'MultiPolygon') return geometry.coordinates[0] && geometry.coordinates[0][0] && geometry.coordinates[0][0][0];
2701
+ return null;
2702
+ }
2703
+
2704
+ // Identity check for "is this hovered object the same one that's
2705
+ // pinned" — object references are rebuilt from scratch on every
2706
+ // redraw (new individual/group arrays each buildDeckLayers call), so
2707
+ // reference equality never works here. Same layer + same geometry
2708
+ // position is a reasonable proxy: individual points and aggregated
2709
+ // groups alike are keyed by position within a layer, matching how
2710
+ // clustering itself already treats "same position" as "same thing".
2711
+ function isSameAsPinned(layerId, object) {
2712
+ if (pinned.layerId !== layerId) return false;
2713
+ const a = anchorCoordOf(object.geometry);
2714
+ const b = anchorCoordOf(pinned.object.geometry);
2715
+ if (!a || !b) return false;
2716
+ return Math.abs(a[0] - b[0]) < 1e-9 && Math.abs(a[1] - b[1]) < 1e-9;
2717
+ }
2718
+
2719
+ // content lives in its own child so re-rendering (innerHTML) never
2720
+ // wipes out the unpin button below (a sibling, not a descendant of it)
2721
+ const pinnedContentEl = document.createElement('div');
2722
+ pinnedTooltipEl.appendChild(pinnedContentEl);
2723
+
2724
+ function renderPinnedTooltip() {
2725
+ if (!pinned) { pinnedTooltipEl.style.display = 'none'; return; }
2726
+ const html = pinned.runtime.buildTooltipHtml(pinned.object);
2727
+ if (!html) { pinned = null; pinnedTooltipEl.style.display = 'none'; return; }
2728
+ pinnedContentEl.innerHTML = html;
2729
+ Object.assign(pinnedTooltipEl.style, tooltipLook());
2730
+ pinnedTooltipEl.style.display = 'block';
2731
+ updatePinnedTooltipPosition();
2732
+ }
2733
+
2734
+ const unpinBtn = document.createElement('button');
2735
+ unpinBtn.type = 'button';
2736
+ unpinBtn.setAttribute('aria-label', 'chiudi');
2737
+ unpinBtn.title = 'chiudi';
2738
+ unpinBtn.textContent = '×';
2739
+ unpinBtn.style.cssText = 'position:absolute;top:5px;right:5px;width:1.5em;height:1.5em;padding:0;' +
2740
+ 'display:flex;align-items:center;justify-content:center;border:none;border-radius:50%;' +
2741
+ 'background:rgba(255,255,255,0.18);color:inherit;font-size:1em;line-height:1;font-weight:bold;cursor:pointer';
2742
+ unpinBtn.addEventListener('mouseenter', () => { unpinBtn.style.background = 'rgba(255,255,255,0.35)'; });
2743
+ unpinBtn.addEventListener('mouseleave', () => { unpinBtn.style.background = 'rgba(255,255,255,0.18)'; });
2744
+ unpinBtn.addEventListener('click', () => { pinned = null; pinnedTooltipEl.style.display = 'none'; });
2745
+ pinnedTooltipEl.style.position = 'absolute';
2746
+ pinnedTooltipEl.appendChild(unpinBtn);
2747
+
2748
+ // deck.gl's own default hover tooltip is only recomputed on pointer-
2749
+ // move events, not on click — so right after a click, it's still
2750
+ // showing whatever it rendered from the hover that was already
2751
+ // active (the same item we just pinned), and won't hide itself just
2752
+ // because getTooltip would now return null for it. Nothing re-asks
2753
+ // it until the next real mousemove. Force it closed here instead —
2754
+ // deferred one frame so it wins even if deck.gl's own click handling
2755
+ // touches the tooltip DOM in the same tick.
2756
+ function hideDefaultHoverTooltip() {
2757
+ requestAnimationFrame(() => {
2758
+ const defaultTooltipEl = el.parentElement && el.parentElement.querySelector('.deck-tooltip');
2759
+ if (defaultTooltipEl) defaultTooltipEl.style.display = 'none';
2760
+ });
2761
+ }
2762
+
2763
+ let tooltipErrorLogged = false;
2764
+ // a USER chart's picked item is {d, icon} (see _buildUserChartLayers):
2765
+ // the feature is its d
2766
+ function pickedFeature(o) { return o && o.icon && o.d ? o.d : o; }
2767
+ function tooltipFor({ object: picked, layer }) {
2768
+ const object = pickedFeature(picked);
2769
+ if (!object || !layer) return null;
2770
+ // suppress the hover tooltip ONLY for the specific item that's
2771
+ // pinned (showing both would just duplicate the same content) —
2772
+ // every OTHER item still gets its normal hover preview even
2773
+ // while something else stays pinned
2774
+ if (pinned && isSameAsPinned(layer.id, object)) return null;
2775
+ const rt = findRuntimeForLayerId(layer.id);
2776
+ if (!rt) return null;
2777
+ const html = rt.buildTooltipHtml(object);
2778
+ return html ? { html, style: Object.assign({ fontSize: '0.85em', padding: '0.5em 0.7em', maxWidth: '320px' }, tooltipLook()) } : null;
2779
+ }
2780
+ function clickFor(picked) {
2781
+ const info = picked && Object.assign({}, picked, { object: pickedFeature(picked.object) });
2782
+ if (info && info.object && info.layer) {
2783
+ const rt = findRuntimeForLayerId(info.layer.id);
2784
+ if (rt) {
2785
+ // Only a Point geometry's own coordinates ARE an
2786
+ // [lng,lat] pair (every point/bubble/dot/chart-cluster
2787
+ // layer) — a CHOROPLETH polygon's geometry.coordinates is
2788
+ // a nested array of RINGS, which map.project() below
2789
+ // can't accept (confirmed live: threw MapLibre's own
2790
+ // "LngLatLike argument must be..." error). The `||
2791
+ // info.coordinate` fallback this used to lean on never
2792
+ // actually ran for a polygon — a nested array is truthy,
2793
+ // so the left side always "won" — which is the real bug
2794
+ // this fixes: the pinned tooltip stuck at its default
2795
+ // top:0/left:0 (never got a real position because
2796
+ // updatePinnedTooltipPosition's map.project() threw
2797
+ // before setting one), AND that same uncaught exception,
2798
+ // thrown from inside deck.gl's own click-dispatch
2799
+ // callback, left deck.gl's pointer-interaction state
2800
+ // corrupted enough to block all further pan/zoom —
2801
+ // confirmed live, both symptoms disappear together once
2802
+ // this stops throwing. info.coordinate (the actual
2803
+ // clicked map location, provided regardless of feature
2804
+ // geometry type) anchors every non-Point case correctly.
2805
+ const lngLat = (info.object.geometry && info.object.geometry.type === 'Point')
2806
+ ? info.object.geometry.coordinates
2807
+ : info.coordinate;
2808
+ pinned = { runtime: rt, object: info.object, lngLat, layerId: info.layer.id };
2809
+ renderPinnedTooltip();
2810
+ hideDefaultHoverTooltip();
2811
+ return true;
2812
+ }
2813
+ }
2814
+ // clicked empty map space (or a non-tooltippable layer) -> unpin
2815
+ pinned = null;
2816
+ pinnedTooltipEl.style.display = 'none';
2817
+ return false;
2818
+ }
2819
+ // An exception thrown from these callbacks runs inside deck.gl's
2820
+ // own render frame / event dispatch and leaves its frame and
2821
+ // interaction state broken — the map stopped showing new layers
2822
+ // after zooms and pans (a USER chart's hover threw on every frame
2823
+ // the mouse was over an arrow, see pickedFeature). Both are guarded.
2824
+ function getTooltip(info) {
2825
+ try { return tooltipFor(info); } catch (err) {
2826
+ if (!tooltipErrorLogged) { tooltipErrorLogged = true; console.error('[ixmaps-gl] tooltip failed:', err); }
2827
+ return null;
2828
+ }
2829
+ }
2830
+ function onClick(info) {
2831
+ try { return clickFor(info); } catch (err) {
2832
+ console.error('[ixmaps-gl] click failed:', err);
2833
+ return false;
2834
+ }
2835
+ }
2836
+ function mount() {
2837
+ map.on('move', updatePinnedTooltipPosition);
2838
+ if (el.parentElement) {
2839
+ el.parentElement.style.position = el.parentElement.style.position || 'relative';
2840
+ el.parentElement.appendChild(pinnedTooltipEl);
2841
+ }
2842
+ }
2843
+ return { getTooltip, onClick, mount };
2844
+ }
2845
+
2846
+ // ---------------------------------------------------------------
2847
+ // The map's native interactive legend, built once per map by
2848
+ // MapBuilder.build(). ctx: the builder (its map options), the MapLibre
2849
+ // map, its container element, the deck.gl overlay, the runtimes, the
2850
+ // engine API and its refresh hooks. Returns the four hooks the map
2851
+ // calls: addLegendPanel(rt), setLegendOption(value), updateSubTheme(rt),
2852
+ // layoutLegends().
2853
+ // ---------------------------------------------------------------
2854
+ function createLegend(ctx) {
2855
+ const { builder, map, el, overlay, runtimes, engineApi, built, refresh, scheduleRefresh, notifyRedraw } = ctx;
2856
+ let addLegendPanel = () => {};
2857
+ let setLegendOption = () => {};
2858
+ let updateSubTheme = () => {};
2859
+ let layoutLegends = () => {};
2860
+
2861
+ // NATIVE INTERACTIVE LEGEND — real ixmaps-flat's legend.js
2862
+ // (makeColorLegendHTMLLong) builds, per CATEGORICAL-ish theme: a
2863
+ // title/snippet header, one row per category (color swatch + a bar
2864
+ // proportional to that category's own total + right-aligned
2865
+ // formatted value), a description/source footer, and a per-theme
2866
+ // "Chart size" 25-200% slider — all read directly off source
2867
+ // (ui/js/tools/legend.js:594-1037/3196-3393, ui/js/htmlgui.js:2196/
2868
+ // 2208 for markThemeClass/unmarkThemeClass, ui/css/legend.css for
2869
+ // the selected-row highlight) rather than guessed from the
2870
+ // screenshot alone. Gated on `legend:"open"` — the one real map
2871
+ // option this engine's own createMap already threads through as
2872
+ // builder.mapOptions (see mapOptions.legend below) — since there's no
2873
+ // toolbar toggle button here to open/close it later the way the
2874
+ // real engine's own chrome does.
2875
+ //
2876
+ // Deliberate divergences from the real implementation, both
2877
+ // reasoned rather than accidental:
2878
+ // - Real legend.js does a full innerHTML rebuild on every single
2879
+ // theme redraw (even a bare click-toggle). renderRows() below
2880
+ // only rebuilds the ROWS (swatch/bar/value/highlight) — cheap,
2881
+ // and the only part that can actually change post-load (marks,
2882
+ // or an AGGREGATE theme's dataset-wide totals, which this port
2883
+ // doesn't yet let change post-load anyway) — rather than
2884
+ // tearing down and rebuilding title/snippet/description/slider
2885
+ // too, which never change. Same visible result, less DOM churn.
2886
+ // - Real "isolate_gray" mode dims non-marked SVG paths via a CSS
2887
+ // class; this engine dims via IconLayer's own getColor alpha
2888
+ // (see LayerRuntime#_iconAlpha) since these are deck.gl raster
2889
+ // icons, not DOM/SVG nodes a CSS rule could reach.
2890
+ // Legend on/off and fold state as real ixmaps-flat reads the map
2891
+ // option (htmlgui_flat.js:748-794): on whenever `legend` is given and
2892
+ // isn't false/"false" ("true", "open", "", 1, …), folded for
2893
+ // "closed", off when not given at all.
2894
+ const legendOpt = builder.mapOptions.legend;
2895
+ // let, not const: map.setLegend(value) switches them at runtime (see setLegendOption)
2896
+ let legendOn = legendIsOn(legendOpt);
2897
+ let legendFolded = legendOpt === 'closed';
2898
+ {
2899
+ // Map-level `align` option positions the legend panel — a map-
2900
+ // wide placement choice, not per-theme (unlike legendtheme/
2901
+ // legendfilter above), so read once from builder.mapOptions rather
2902
+ // than per-runtime. Real ixmaps-flat ALSO has an `align` map
2903
+ // option (ui/js/htmlgui_flat.js) controlling legend position, but
2904
+ // its value shape is a free-form regex-matched left/right/center/
2905
+ // top/bottom/pixel-prefixed string tied to a static in-page DOM
2906
+ // panel — a different layout model from this panel's own
2907
+ // position:absolute floating-overlay-over-the-map-canvas
2908
+ // approach. This port reuses the real OPTION NAME but adapts the
2909
+ // VALUE SHAPE to plain four-corner tokens matching how this panel
2910
+ // is actually positioned — a deliberate divergence, same
2911
+ // reasoning already applied to style.legendfilter's own name/
2912
+ // shape choice earlier this session. Default "top-right" per
2913
+ // explicit request (real engine's own fallback is right-anchored
2914
+ // too, just without an opinion on vertical placement).
2915
+ const ALIGN_CSS = {
2916
+ 'top-left': 'left:10px;top:10px;',
2917
+ 'top-right': 'right:10px;top:10px;',
2918
+ 'bottom-left': 'left:10px;bottom:10px;',
2919
+ 'bottom-right': 'right:10px;bottom:10px;'
2920
+ };
2921
+ // gl's four corners, else flat's own values (htmlgui_flat.js
2922
+ // 421-470): "…left" → left (the part before "left", else 55px), 12px
2923
+ // from the top; "…right" → right (else 25px); "center" / "top" → top
2924
+ // center
2925
+ const flatAlignCss = align => {
2926
+ const a = String(align || '');
2927
+ if (/left/.test(a)) return 'left:' + (a.split('left')[0] || '55px') + ';top:12px;';
2928
+ if (/right/.test(a)) return 'right:' + (a.split('right')[0] || '25px') + ';top:10px;';
2929
+ if (a === 'center' || a === 'top') return 'left:50%;transform:translateX(-50%);top:10px;';
2930
+ return null;
2931
+ };
2932
+ const legendAlignCss = ALIGN_CSS[builder.mapOptions.align] || flatAlignCss(builder.mapOptions.align) || ALIGN_CSS['top-right'];
2933
+ // Real ixmaps-flat's SUB-THEME (MapTheme.createSubTheme,
2934
+ // maptheme.js 14284-14292 / 15010-15160; map.Themes.enableSubThemes
2935
+ // defaults to true): marking fields of a multi-field choropleth
2936
+ // (DOMINANT/COMPOSECOLOR, not CHART) paints a new theme over the
2937
+ // original — one field: CHOROPLETH|LOG|DOPACITY on that field, white
2938
+ // → the field's color in 7 steps, dopacityscale 2, dopacitypow 1,
2939
+ // keeping DOPACITY(→DOPACITYMAX)/AGGREGATE/GROUP/SUM/ZEROISVALUE/
2940
+ // VALUES; several fields: the original type on just those fields
2941
+ // and colors. It reuses the original's joined polygons, has no
2942
+ // legend and stays out of the theme registry (its name
2943
+ // "<layer>::subtheme" also keeps its deck.gl layer id apart), so
2944
+ // markThemeClass/removeTheme still address the original.
2945
+ const subThemeCapable = rt => rt.flags.has('CHOROPLETH') && !rt.flags.has('CHART') && (rt.flags.has('DOMINANT') || rt.flags.has('COMPOSECOLOR'));
2946
+ const toHex = c => Array.isArray(c) ? '#' + c.slice(0, 3).map(v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('') : c;
2947
+ updateSubTheme = (rt) => {
2948
+ if (rt._subTheme) {
2949
+ const i = runtimes.indexOf(rt._subTheme);
2950
+ if (i >= 0) runtimes.splice(i, 1);
2951
+ rt._subTheme = null;
2952
+ }
2953
+ const fields = String(rt.binding.value || '').split('|');
2954
+ const marks = [...rt._markedClasses].filter(i => i >= 0 && i < fields.length).sort((a, b) => a - b);
2955
+ if (marks.length) {
2956
+ const colors = (rt.categoryColorsRgb || []).map(toHex);
2957
+ const labels = rt.categoryDisplayLabels || rt.categoryLabels || fields;
2958
+ const kept = ['DOPACITY', 'AGGREGATE', 'GROUP', 'SUM', 'ZEROISVALUE', 'VALUES']
2959
+ .filter(f => [...rt.flags].some(x => x.startsWith(f) && (f !== 'DOPACITY' || /^DOPACITY/.test(x))))
2960
+ .map(f => (f === 'DOPACITY' ? 'DOPACITYMAX' : f));
2961
+ let def;
2962
+ if (marks.length > 1) {
2963
+ def = {
2964
+ layer: rt.name + '::subtheme',
2965
+ binding: (b => { delete b.field100; delete b.value100; return b; })(Object.assign({}, rt.binding, { value: marks.map(i => fields[i]).join('|') })),
2966
+ style: Object.assign({}, rt.style, { type: [...rt.flags].concat(['SUBTHEME', 'NOLEGEND']).join('|'),
2967
+ colorscheme: marks.map(i => colors[i]), values: marks.map(i => labels[i]) }),
2968
+ meta: { title: 'actual selection' }
2969
+ };
2970
+ } else {
2971
+ const i = marks[0];
2972
+ const binding = Object.assign({}, rt.binding, { value: fields[i] });
2973
+ delete binding.alpha; delete binding.alpha100;
2974
+ // rt.features already carry the field100 result — drop it and
2975
+ // every alias of it, or normalizeTheme would apply it again
2976
+ delete binding.field100; delete binding.value100;
2977
+ def = {
2978
+ layer: rt.name + '::subtheme',
2979
+ binding,
2980
+ style: { type: ['CHOROPLETH', 'LOG', 'DOPACITY', 'NOLEGEND', 'SUBTHEME'].concat(kept).join('|'),
2981
+ colorscheme: ['7', 'white', colors[i]], dopacityscale: 2, dopacitypow: 1,
2982
+ units: rt.style.units, linecolor: rt.style.linecolor, linewidth: rt.style.linewidth },
2983
+ meta: { title: 'actual selection' }
2984
+ };
2985
+ }
2986
+ const sub = new LayerRuntime(normalizeTheme(def), { type: 'FeatureCollection', features: rt.features }, builder._engineOptions);
2987
+ sub._isSubTheme = true;
2988
+ runtimes.splice(runtimes.indexOf(rt) + 1, 0, sub);
2989
+ rt._subTheme = sub;
2990
+ }
2991
+ refresh();
2992
+ notifyRedraw();
2993
+ };
2994
+ // the stack of the theme panels — flat shows one theme's legend
2995
+ // after the other in #map-legend, separated by a line, and leaves
2996
+ // out a theme hidden, out of scale or not yet drawn
2997
+ let legendStack = null;
2998
+ const legendStackEl = shadow => {
2999
+ if (legendStack && legendStack.parentNode) return legendStack;
3000
+ legendStack = document.createElement('div');
3001
+ legendStack.className = 'ix-legend-stack';
3002
+ legendStack.style.cssText = 'position:absolute;' + legendAlignCss + 'z-index:6;width:280px;'
3003
+ + 'display:flex;flex-direction:column;max-height:calc(100% - 24px);overflow-y:auto;'
3004
+ + 'border-radius:6px;box-shadow:' + shadow + ';pointer-events:auto;';
3005
+ if (!document.getElementById('ix-legend-stack-css')) {
3006
+ const css = document.createElement('style');
3007
+ css.id = 'ix-legend-stack-css';
3008
+ css.textContent = '.ix-legend-stack>.ix-native-legend:not([data-ixoff])~.ix-native-legend:not([data-ixoff]){border-top:1px solid rgba(128,128,128,.35)}';
3009
+ document.head.appendChild(css);
3010
+ }
3011
+ el.parentElement.appendChild(legendStack);
3012
+ return legendStack;
3013
+ };
3014
+ // map.setLegend(value): the `legend` map option at runtime, same values as
3015
+ // ixmaps.Map(id, {legend}) — on ("true", "open", …), folded ("closed"), off
3016
+ // (false/"false"/0/undefined). Panels are made for themes that lack one,
3017
+ // removed when off, folded/unfolded otherwise.
3018
+ setLegendOption = (opt) => {
3019
+ builder.mapOptions.legend = opt;
3020
+ legendFolded = opt === 'closed';
3021
+ legendOn = legendIsOn(opt);
3022
+ runtimes.forEach(rt => {
3023
+ if (!legendOn) {
3024
+ if (rt._legendPanel && rt._legendPanel.parentNode) rt._legendPanel.parentNode.removeChild(rt._legendPanel);
3025
+ rt._legendPanel = null;
3026
+ } else if (!rt._legendPanel) addLegendPanel(rt);
3027
+ else if (rt._setLegendCollapsed) rt._setLegendCollapsed(legendFolded);
3028
+ });
3029
+ layoutLegends();
3030
+ };
3031
+ layoutLegends = () => {
3032
+ if (!legendStack) return;
3033
+ const z = map.getZoom();
3034
+ let shown = 0;
3035
+ [...legendStack.children].forEach(panel => {
3036
+ const rt = panel._ixRuntime;
3037
+ const off = !rt || rt._hidden || themeHiddenByScale(rt.flags, rt.style, z);
3038
+ if (off) { panel.setAttribute('data-ixoff', ''); panel.style.display = 'none'; }
3039
+ else { panel.removeAttribute('data-ixoff'); panel.style.display = 'flex'; shown++; }
3040
+ });
3041
+ legendStack.style.display = shown ? 'flex' : 'none';
3042
+ };
3043
+ const legendApplies = rt => rt.categoryLabels && rt.categoryLabels.length && !rt.flags.has('FEATURE') && !rt.flags.has('FEATURES') && !rt.flags.has('NOLEGEND');
3044
+ // One theme's panel — at build time for every theme, and again for a
3045
+ // theme defined later (map.layer(...) in a myMap.then(...) chain,
3046
+ // defineLayer, loadProject); removeTheme removes it with the theme.
3047
+ addLegendPanel = (rt) => {
3048
+ // marking a class (legend row, or ixmaps.markThemeClass from a
3049
+ // page) redraws — for every theme, legend panel or not
3050
+ rt._triggerRedraw = () => { refresh(); };
3051
+ if (subThemeCapable(rt)) rt._onMarksChanged = () => updateSubTheme(rt);
3052
+ if (!legendOn || !el.parentElement || !legendApplies(rt)) return;
3053
+ el.parentElement.style.position = el.parentElement.style.position || 'relative';
3054
+ {
3055
+ // .type("...|NOLEGEND") — the fourth real legend-related type()
3056
+ // token: opts a theme OUT of the legend entirely (real engine's
3057
+ // own per-layer "skip this one" flag, distinct from the map-
3058
+ // level legend:"open"/"closed" option gating the whole panel).
3059
+ // Same free-parsing story as SIMPLELEGEND/COMPACTLEGEND above —
3060
+ // just one more flag to exclude on, no new plumbing.
3061
+ const panel = document.createElement('div');
3062
+ rt._legendPanel = panel;
3063
+ panel.className = 'ix-native-legend';
3064
+ // Opt-in via .style({legendtheme:"light"}) — a NEW, ixmaps-gl-
3065
+ // only convention (same naming pattern as the sibling
3066
+ // legendfilter/legendunits keys added earlier this session):
3067
+ // real ixmaps-flat's legend.js has no such switch, this panel
3068
+ // is entirely this port's own construction. Default ("dark")
3069
+ // preserves the original always-dark panel exactly; "light" is
3070
+ // for pages using a light basemap (e.g. CARTO Positron) where
3071
+ // a dark semi-transparent panel reads as a mismatched dark
3072
+ // patch rather than legend chrome. Every other hardcoded panel
3073
+ // color below (rows, bar track, filter <select>) is expressed
3074
+ // in terms of this one palette so the two variants stay in
3075
+ // sync — no separate light/dark branch anywhere else.
3076
+ // flat's look (flatLegendLook) unless the page picks one with
3077
+ // this engine's own legendtheme
3078
+ const opacityOpt = parseFloat(builder._engineOptions.basemapopacity);
3079
+ const flatLook = flatLegendLook(builder.mapOptions.mapType, isNaN(opacityOpt) ? 1 : opacityOpt, builder.mapOptions.legendBackground || builder.mapOptions.legendbackground);
3080
+ const isLightLegend = rt.style.legendtheme === 'light' || (rt.style.legendtheme !== 'dark' && !flatLook.dark);
3081
+ const legendColors = isLightLegend
3082
+ ? { bg: 'rgba(255,255,255,0.92)', fg: '#1a1a1a', shadow: '0 2px 10px rgba(0,0,0,0.18)',
3083
+ rowMarked: 'rgba(0,0,0,0.08)', track: 'rgba(0,0,0,0.08)',
3084
+ selectBg: 'rgba(0,0,0,0.04)', selectBorder: 'rgba(0,0,0,0.2)' }
3085
+ : { bg: 'rgba(28,30,34,0.92)', fg: '#eee', shadow: '0 2px 10px rgba(0,0,0,0.45)',
3086
+ rowMarked: 'rgba(255,255,255,0.14)', track: 'rgba(255,255,255,0.08)',
3087
+ selectBg: 'rgba(255,255,255,0.08)', selectBorder: 'rgba(255,255,255,0.25)' };
3088
+ if (!rt.style.legendtheme) legendColors.bg = flatLook.bg;
3089
+ // max-height:66% resolves against el.parentElement's own
3090
+ // height (the map container, which always has a definite
3091
+ // height for the map itself to render into) since this panel
3092
+ // is absolutely positioned inside it — a plain percentage on
3093
+ // an absolutely-positioned element's height IS legal CSS as
3094
+ // long as its containing block has a definite height, which
3095
+ // this one does. display:flex column + min-height:0 on the
3096
+ // ROWS wrapper below (not this outer panel) is what makes
3097
+ // only the row list scroll while the header/description/
3098
+ // slider stay fixed in place — a flex child won't actually
3099
+ // shrink to fit and scroll internally without min-height:0,
3100
+ // it just overflows its flex parent instead.
3101
+ // one box for every theme, as flat's #map-legend (legend.js
3102
+ // 2200-2460): the panels stack in the legend stack in theme
3103
+ // order, a line between them (see layoutLegends)
3104
+ panel.style.cssText = 'position:relative;flex:0 0 auto;'
3105
+ + 'display:flex;flex-direction:column;'
3106
+ + 'background:' + legendColors.bg + ';color:' + legendColors.fg + ';font:12px/1.4 -apple-system,Arial,sans-serif;'
3107
+ + 'padding:10px 12px 12px;pointer-events:auto;';
3108
+ const stack = legendStackEl(legendColors.shadow);
3109
+ const at = runtimes.indexOf(rt);
3110
+ const next = [...stack.children].find(c => runtimes.indexOf(c._ixRuntime) > at);
3111
+ panel._ixRuntime = rt;
3112
+ stack.insertBefore(panel, next || null);
3113
+
3114
+ // SUM+valuefield mirrors the real engine's own "SUM style
3115
+ // aggregation" reading (style.valuefield, falling back to
3116
+ // the bound size field); anything else (plain CATEGORICAL,
3117
+ // no SUM) falls back to a per-category record COUNT.
3118
+ const useSum = flatFlag(rt.flags, 'SUM') && rt.style.valuefield;
3119
+ const valueField = rt.style.valuefield || rt.binding.size;
3120
+ // legendunits wins over the theme's general-purpose units
3121
+ // (used elsewhere for tooltips, e.g. _renderItemChartHtml) —
3122
+ // a page may want a different/no unit string specifically on
3123
+ // the legend's own value column. Appended as-is (no extra
3124
+ // space injected), matching how style.units/legendunits are
3125
+ // themselves authored with their own leading space (e.g.
3126
+ // " MW") in real pages.
3127
+ const legendUnit = rt.style.legendunits || rt.style.units || '';
3128
+ // row labels go into innerHTML: the page's own .style({label})
3129
+ // stays HTML, as in flat; categories read from the data are
3130
+ // escaped (a value could carry markup)
3131
+ const labels = legendRowLabels(rt);
3132
+ // .type("...|TEXTLEGEND") — the FIFTH real legend-related
3133
+ // type() token: title/snippet/description text only, no
3134
+ // category rows at all (swatches, bars, chips — none of it),
3135
+ // per explicit correction. Everything below that builds the
3136
+ // rows/totals/onRedraw-recompute wiring is skipped outright
3137
+ // rather than built-then-hidden — there is nothing for any of
3138
+ // it to feed once no rows ever render. The country-filter
3139
+ // dropdown and Chart-size slider stay: neither is a
3140
+ // "categorical item" or "colorscheme swatch", both are
3141
+ // independent controls unrelated to the row list.
3142
+ const isTextOnly = rt.flags.has('TEXTLEGEND');
3143
+
3144
+ // Map-view-aware per explicit request: totals reflect only
3145
+ // what's CURRENTLY on screen (the map's own bounds, plus the
3146
+ // same far-hemisphere exclusion _buildChartLayers itself
3147
+ // applies under globe projection — see isOnVisibleHemisphere)
3148
+ // rather than the whole dataset — recomputed on every redraw
3149
+ // (engineApi.onRedraw, below) so panning/zooming/rotating
3150
+ // updates the bars and values live, the same way the actual
3151
+ // rendered bubbles change. Same simple rectangular bbox
3152
+ // membership test _computeAggregatedItems's own non-AGGREGATE
3153
+ // branch uses (no antimeridian wraparound handling — matches
3154
+ // that existing convention, not a new gap this introduces).
3155
+ let totals, maxTotal, order, rowsScroll;
3156
+ // No-op default: TEXTLEGEND never reassigns this (see below),
3157
+ // so the LATER unconditional-looking `renderRows()` call
3158
+ // safely does nothing rather than needing its own isTextOnly
3159
+ // guard at every call site.
3160
+ let renderRows = () => {};
3161
+ if (!isTextOnly) {
3162
+ function computeTotals() {
3163
+ const bounds = map.getBounds();
3164
+ const bbox = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()];
3165
+ const proj = (typeof map.getProjection === 'function' && map.getProjection()) || { type: 'mercator' };
3166
+ const globeCenter = proj.type === 'globe' ? map.getCenter() : null;
3167
+ totals = new Array(rt.categoryLabels.length).fill(0);
3168
+ if (rt._rangeClassed) { totals = rangeClassLegendTotals(rt, bbox, globeCenter); maxTotal = Math.max(0, ...totals); order = totals.map((v, i) => i).sort((a, b) => totals[b] - totals[a]); return; }
3169
+ // _activeFeatures (set by setFacetFilter/clearFacetFilter,
3170
+ // e.g. the country-select dropdown below) is the facet-
3171
+ // filtered subset when a filter is active, null otherwise —
3172
+ // reading it here keeps the legend's own numbers consistent
3173
+ // with whatever the country filter is currently narrowing
3174
+ // the map down to, not just with the viewport/hemisphere.
3175
+ (rt._activeFeatures || rt.features).forEach(f => {
3176
+ const idx = rt.categoryIndexByLabel ? rt.categoryIndexByLabel.get(f.properties[rt.binding.value]) : null;
3177
+ if (idx == null) return;
3178
+ const [lng, lat] = f.geometry.coordinates;
3179
+ if (lng < bbox[0] || lng > bbox[2] || lat < bbox[1] || lat > bbox[3]) return;
3180
+ if (globeCenter && !isOnVisibleHemisphere(lng, lat, globeCenter)) return;
3181
+ totals[idx] += useSum ? (parseFloat(f.properties[valueField]) || 0) : 1;
3182
+ });
3183
+ maxTotal = Math.max(0, ...totals);
3184
+ order = totals.map((v, i) => i).sort((a, b) => totals[b] - totals[a]);
3185
+ }
3186
+ computeTotals();
3187
+
3188
+ // Only THIS wrapper scrolls (flex:1 1 auto + min-height:0 lets
3189
+ // it shrink to whatever's left of the panel's own 66%-height
3190
+ // cap once the fixed header/description/slider take their
3191
+ // share, then overflow-y:auto scrolls just the row list) —
3192
+ // the header above and description/slider below stay put.
3193
+ rowsScroll = document.createElement('div');
3194
+ rowsScroll.style.cssText = 'flex:1 1 auto;min-height:0;overflow-y:auto;';
3195
+ const rowsEl = document.createElement('div');
3196
+ rowsEl.className = 'ix-legend-rows';
3197
+ // .type("...|SIMPLELEGEND") — real ixmaps-flat's OTHER legend
3198
+ // variant (confirmed against a real-engine screenshot, per
3199
+ // explicit correction): swatch + label chips only, no bar, no
3200
+ // value column. SIMPLELEGEND is just another pipe-delimited
3201
+ // type() token — MapBuilder#type() already puts every token
3202
+ // into this.flags regardless of whether anything reads it, so
3203
+ // no new parsing was needed, only this render-mode branch.
3204
+ // flex-wrap here (vs. the bar mode's block rows) — chips wrap
3205
+ // to the panel's own width the same loose way the real
3206
+ // engine's screenshot shows (an uneven number of chips per
3207
+ // row, driven by each label's own text length, not a fixed
3208
+ // column grid).
3209
+ // .type("...|COMPACTLEGEND") — the THIRD real legend variant
3210
+ // (again confirmed against a real-engine screenshot, per
3211
+ // explicit correction): the bar-mode's own two-line label/bar/
3212
+ // value row, but wrapped into a multi-column flex-wrap grid
3213
+ // instead of one full-width row per category — each item's
3214
+ // own width follows its content (a bigger value's longer bar
3215
+ // makes its own item wider, so fewer fit per line; Coal alone
3216
+ // filled its own row in the reference screenshot while the
3217
+ // smaller categories packed 3-4 per row) rather than a fixed
3218
+ // column count.
3219
+ if (rt.flags.has('SIMPLELEGEND') || rt.flags.has('COMPACTLEGEND')) {
3220
+ rowsEl.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;';
3221
+ }
3222
+ rowsScroll.appendChild(rowsEl);
3223
+
3224
+ // Two lines per row — label on its own full-width line, then
3225
+ // swatch+bar+value below — per explicit correction: a single
3226
+ // shared line left too little room for the bar to read as
3227
+ // proportional (it was squeezed between the label text and a
3228
+ // fixed-width value column). The bar itself lives inside a
3229
+ // flex:1 "track" div sized to whatever's left after the fixed
3230
+ // swatch/value columns, and is a PERCENTAGE of that track's
3231
+ // own width (not a pixel value) — real formula's per-unit-
3232
+ // factor/cap (legend.js:980-984/946-950) collapses to a plain
3233
+ // linear percentage-of-max once the bar's available width
3234
+ // isn't a fixed constant this code has to guess at.
3235
+ const isSimple = rt.flags.has('SIMPLELEGEND');
3236
+ const isCompact = rt.flags.has('COMPACTLEGEND');
3237
+ // Compact mode's bar can't use the full-width mode's
3238
+ // percentage-of-flex-track trick (there's no full-width track
3239
+ // to be a percentage OF once the row itself is content-sized,
3240
+ // not stretched) — a plain pixel width, proportional to value
3241
+ // and capped, same shape as the full-width formula just
3242
+ // expressed in absolute px instead of a % of an elastic track.
3243
+ const COMPACT_MAX_BAR_PX = 70;
3244
+ // flat's compact legend (legend.js makeColorLegendHTML, the
3245
+ // "compact" mode the theme legends use, 1212-1257): a range
3246
+ // theme of 5 classes or more without labels is one line of
3247
+ // color patches with the value range below; DOPACITY with an
3248
+ // alpha field adds two paler lines (opacity 1/2, 1/3) and the
3249
+ // alpha max, "↓" and min to their right (1062-1205). The
3250
+ // legend's own __formatValue (legend.js 451-553) has no SPACE
3251
+ // form: SPACE there is flat's plain "." thousands.
3252
+ const flatCompactLegend = rt._rangeClassed && !flatFlag(rt.flags, 'CATEGORICAL') && !flatFlag(rt.flags, 'PLOT')
3253
+ && (rt.partsA || []).length >= 5 && !(Array.isArray(rt.style.label) && rt.style.label.length);
3254
+ // flat's single-color legend (legend.js 765-769, 1039-1046): a range
3255
+ // theme of at most 2 classes without label/ranges, not
3256
+ // CATEGORICAL, is one row — the swatch and "min ... max unit",
3257
+ // or, when every value is 1 (a "$item$" count), its label or
3258
+ // value field
3259
+ const flatSingleRowLegend = rt._rangeClassed && !flatFlag(rt.flags, 'CATEGORICAL') && !flatFlag(rt.flags, 'PLOT')
3260
+ && (rt.partsA || []).length <= 2 && !(Array.isArray(rt.style.label) && rt.style.label.length)
3261
+ && !(Array.isArray(rt.style.ranges) && rt.style.ranges.length);
3262
+ if (flatSingleRowLegend) {
3263
+ renderRows = function() {
3264
+ const rgb = rt.categoryColorsRgb[0] || [128, 128, 128];
3265
+ const unit = String(rt.style.legendunits || rt.style.units || '').replace(/ /g, '&nbsp;');
3266
+ const vMin = styleNum(rt.style.minvalue) || rt._valueMin, vMax = styleNum(rt.style.maxvalue) || rt._valueMax;
3267
+ const label = rt.style.label != null && !Array.isArray(rt.style.label) ? String(rt.style.label) : String(rt.binding.value || '');
3268
+ const text = vMin !== 1 || vMax !== 1
3269
+ ? flatFormatValue(vMin, 2, 'BLANK') + ' &nbsp;... ' + flatFormatValue(vMax, 2, 'BLANK') + ' ' + unit
3270
+ : label;
3271
+ const marked = rt._markedClasses.has(0);
3272
+ rowsEl.innerHTML = '<div class="ix-legend-row" data-idx="0" style="display:flex;align-items:center;gap:5px;padding:4px 3px;cursor:pointer;border-radius:4px;'
3273
+ + 'background:' + (marked ? legendColors.rowMarked : 'transparent') + ';">'
3274
+ + '<span style="flex:0 0 auto;width:1.6em;height:0.8em;background:rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ');"></span>'
3275
+ + '<span>' + text + '</span></div>';
3276
+ rowsEl.querySelectorAll('.ix-legend-row').forEach(row => {
3277
+ row.addEventListener('click', () => {
3278
+ if (rt._markedClasses.has(0)) unmarkRuntimeClass(rt, 0);
3279
+ else markRuntimeClass(rt, 0);
3280
+ });
3281
+ });
3282
+ };
3283
+ } else
3284
+ if (flatCompactLegend) {
3285
+ renderRows = function() {
3286
+ const n = rt.categoryColorsRgb.length;
3287
+ const withAlpha = flatFlag(rt.flags, 'DOPACITY') && rt.binding.alpha && rt._alphaMax;
3288
+ const nLines = withAlpha ? 3 : 1;
3289
+ const dec = rt.style.decimals != null ? parseInt(rt.style.decimals, 10) : 2;
3290
+ const unit = rt.style.legendunits || rt.style.units || '';
3291
+ const ranges = Array.isArray(rt.style.ranges) && rt.style.ranges.length ? rt.style.ranges.map(Number) : null;
3292
+ const vMin = ranges ? ranges[0] : rt._valueMin, vMax = ranges ? ranges[ranges.length - 1] : rt._valueMax;
3293
+ let html = '<div style="display:grid;grid-template-columns:repeat(' + n + ',1fr)' + (withAlpha ? ' auto' : '') + ';gap:1px;align-items:center;">';
3294
+ for (let line = 0; line < nLines; line++) {
3295
+ for (let c = 0; c < n; c++) {
3296
+ const ix = flatFlag(rt.flags, 'INVERT') ? n - c - 1 : c;
3297
+ const rgb = rt.categoryColorsRgb[ix];
3298
+ const part = rt.partsA[ix];
3299
+ const title = part ? flatFormatValue(part.min, dec, '') + unit + ' ... ' + flatFormatValue(part.max, dec, '') + unit : '';
3300
+ const marked = rt._markedClasses.has(ix);
3301
+ html += '<span class="ix-legend-row" data-idx="' + ix + '" title="' + title + '" style="cursor:pointer;height:14px;'
3302
+ + 'background:rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ');opacity:' + (1 / (line + 1)) + ';'
3303
+ + (marked ? 'outline:2px solid ' + legendColors.fg + ';outline-offset:-2px;' : '') + '"></span>';
3304
+ }
3305
+ if (withAlpha) {
3306
+ const txt = line === 0 ? flatFormatValue(rt._alphaMax, 0, 'BLANK')
3307
+ : line === 1 ? '&#8595; ' + (rt.style.alphavalueunits || '') : flatFormatValue(rt._alphaMin || 0, 0, 'BLANK');
3308
+ html += '<span style="padding-left:0.5em;white-space:nowrap;">' + txt + '</span>';
3309
+ }
3310
+ }
3311
+ html += '</div><div style="display:flex;justify-content:space-between;margin-top:3px;' + (withAlpha ? 'margin-right:4em;' : '') + '">'
3312
+ + '<span>' + flatFormatValue(vMin, dec, '') + (unit ? ' ' + unit : '') + '</span>'
3313
+ + '<span>' + flatFormatValue(vMax, dec, '') + (unit && unit.length <= 3 ? ' ' + unit : '') + '</span></div>';
3314
+ rowsEl.innerHTML = html;
3315
+ rowsEl.querySelectorAll('.ix-legend-row').forEach(cell => {
3316
+ cell.addEventListener('click', () => {
3317
+ const idx = parseInt(cell.dataset.idx, 10);
3318
+ if (rt._markedClasses.has(idx)) unmarkRuntimeClass(rt, idx);
3319
+ else markRuntimeClass(rt, idx);
3320
+ });
3321
+ });
3322
+ };
3323
+ } else
3324
+ renderRows = function() {
3325
+ // flat drops the rows without a count once any row has one
3326
+ // (legend.js 754-760, 787-789: fCountBars && !count)
3327
+ rowsEl.innerHTML = order.filter(i => !(maxTotal > 0) || totals[i]).map(i => {
3328
+ const rgb = rt.categoryColorsRgb[i];
3329
+ const color = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
3330
+ const marked = rt._markedClasses.has(i);
3331
+ const dimmed = rt._markedClasses.size > 0 && !marked;
3332
+ const rowStyle = 'padding:4px 3px;border-radius:4px;cursor:pointer;opacity:' + (dimmed ? 0.4 : 1) + ';'
3333
+ + 'background:' + (marked ? legendColors.rowMarked : 'transparent') + ';';
3334
+ if (isSimple) {
3335
+ // Swatch + label only, no bar/value — see rowsEl's own
3336
+ // comment above for why (SIMPLELEGEND).
3337
+ return '<div class="ix-legend-row" data-idx="' + i + '" style="' + rowStyle
3338
+ + 'display:inline-flex;align-items:center;gap:5px;padding:4px 8px;">'
3339
+ + '<span style="flex:0 0 8px;width:8px;height:8px;border-radius:50%;background:' + color + ';"></span>'
3340
+ + '<span>' + labels[i] + '</span>'
3341
+ + '</div>';
3342
+ }
3343
+ const pct = maxTotal ? Math.max(0, Math.min(100, (totals[i] / maxTotal) * 100)) : 0;
3344
+ if (isCompact) {
3345
+ // Same two-line label/bar/value shape as full-width bar
3346
+ // mode below, just content-sized (flex:0 0 auto, no
3347
+ // min-width:0 flex-1 track) so multiple items pack onto
3348
+ // one line inside rowsEl's own flex-wrap — see
3349
+ // COMPACTLEGEND's own comment on rowsEl above.
3350
+ const barPx = Math.max(2, (pct / 100) * COMPACT_MAX_BAR_PX);
3351
+ return '<div class="ix-legend-row" data-idx="' + i + '" style="' + rowStyle + 'flex:0 0 auto;">'
3352
+ + '<div style="margin-bottom:3px;white-space:nowrap;">' + labels[i] + '</div>'
3353
+ + '<div style="display:flex;align-items:center;gap:6px;">'
3354
+ + '<span style="flex:0 0 8px;width:8px;height:8px;border-radius:50%;background:' + color + ';"></span>'
3355
+ + '<span style="flex:0 0 auto;width:' + barPx + 'px;height:6px;background:' + color + ';border-radius:3px;"></span>'
3356
+ + '<span style="flex:0 0 auto;white-space:nowrap;">' + rt._formatTooltipValue(totals[i]) + legendUnit + '</span>'
3357
+ + '</div>'
3358
+ + '</div>';
3359
+ }
3360
+ return '<div class="ix-legend-row" data-idx="' + i + '" style="' + rowStyle + '">'
3361
+ + '<div style="margin-bottom:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + labels[i] + '</div>'
3362
+ + '<div style="display:flex;align-items:center;gap:6px;">'
3363
+ + '<span style="flex:0 0 8px;width:8px;height:8px;border-radius:50%;background:' + color + ';"></span>'
3364
+ + '<span style="flex:1 1 auto;min-width:0;height:6px;background:' + legendColors.track + ';border-radius:3px;overflow:hidden;">'
3365
+ + '<span style="display:block;height:100%;width:' + pct + '%;background:' + color + ';border-radius:3px;"></span>'
3366
+ + '</span>'
3367
+ + '<span style="flex:0 0 auto;text-align:right;min-width:60px;">' + rt._formatTooltipValue(totals[i]) + legendUnit + '</span>'
3368
+ + '</div>'
3369
+ + '</div>';
3370
+ }).join('');
3371
+ // GL-PORT COMPAT: real ixmaps-flat's row markup wires its
3372
+ // click handler inline (onclick="ixmaps.markThemeClass(...)"
3373
+ // — legend.js:817), toggle decided by the caller. Same
3374
+ // split here: the toggle check lives in this click
3375
+ // handler, the actual add/remove-and-redraw primitive is
3376
+ // markRuntimeClass/unmarkRuntimeClass, which the globals
3377
+ // ixmaps.markThemeClass/unmarkThemeClass also use — so a
3378
+ // page's OWN custom UI drives this exact legend too. The
3379
+ // rows pass their runtime itself: its .layer() name may be
3380
+ // shared with other themes of the page.
3381
+ rowsEl.querySelectorAll('.ix-legend-row').forEach(rowEl => {
3382
+ rowEl.addEventListener('click', () => {
3383
+ const idx = parseInt(rowEl.dataset.idx, 10);
3384
+ if (rt._markedClasses.has(idx)) unmarkRuntimeClass(rt, idx);
3385
+ else markRuntimeClass(rt, idx);
3386
+ });
3387
+ });
3388
+ };
3389
+
3390
+ // Recompute+re-render on EVERY redraw, not just mark toggles —
3391
+ // this is what makes the legend map-view-aware: a plain pan/
3392
+ // zoom/rotate fires this too (via the map's own 'move'
3393
+ // listener -> scheduleRefresh -> notifyRedraw, debounced the
3394
+ // same 400ms as the facets sidebar's own onRedraw use), same
3395
+ // signal already used elsewhere in this file for "the visible
3396
+ // data just changed, recompute".
3397
+ engineApi.onRedraw(() => { computeTotals(); renderRows(); });
3398
+ }
3399
+
3400
+ // rt._triggerRedraw: see its own doc comment on LayerRuntime
3401
+ // (set here rather than at construction time since refresh()
3402
+ // doesn't exist yet when the runtime is built). Kept
3403
+ // unconditional (even for TEXTLEGEND, which never registers
3404
+ // the onRedraw recompute above) — a page can still call
3405
+ // ixmaps.markThemeClass/unmarkThemeClass directly with no
3406
+ // visual row list to click, and that should still redraw the
3407
+ // MAP's own dim/isolate effect even without a legend UI for
3408
+ // it, matching real-engine global-API availability regardless
3409
+ // of legend style.
3410
+ rt._triggerRedraw = () => { refresh(); };
3411
+
3412
+ // Header row (title/snippet + collapse toggle) always stays
3413
+ // visible; everything else lives in `bodyEl`, hidden/shown as
3414
+ // a single unit by the toggle below — collapsing shows just
3415
+ // the title bar, matching the real engine's own fold/unfold
3416
+ // behavior (ixmaps.legendState + the "legend-folded" CSS
3417
+ // class, ui/js/tools/legend.js) even though this port toggles
3418
+ // via a plain display swap on one wrapper div rather than a
3419
+ // shared stylesheet class (this panel has no external CSS at
3420
+ // all — everything here is inline-styled, unlike the real
3421
+ // engine's DOM/CSS-class-driven legend).
3422
+ const header = document.createElement('div');
3423
+ header.style.cssText = 'display:flex;align-items:flex-start;justify-content:space-between;gap:6px;';
3424
+ const headerText = document.createElement('div');
3425
+ headerText.style.cssText = 'min-width:0;flex:1 1 auto;';
3426
+ let headerHtml = '';
3427
+ if (rt.meta.title) headerHtml += '<div style="font-weight:600;font-size:13px;margin-bottom:2px;">' + rt.meta.title + '</div>';
3428
+ if (rt.meta.snippet) headerHtml += '<div style="opacity:0.75;">' + rt.meta.snippet + '</div>';
3429
+ headerText.innerHTML = headerHtml;
3430
+ header.appendChild(headerText);
3431
+ const collapseBtn = document.createElement('button');
3432
+ collapseBtn.type = 'button';
3433
+ collapseBtn.style.cssText = 'flex:0 0 auto;background:transparent;border:none;color:inherit;'
3434
+ + 'font-size:14px;line-height:1;cursor:pointer;padding:2px 4px;opacity:0.7;';
3435
+ header.appendChild(collapseBtn);
3436
+ panel.appendChild(header);
3437
+
3438
+ const bodyEl = document.createElement('div');
3439
+ bodyEl.style.cssText = 'display:flex;flex-direction:column;min-height:0;flex:1 1 auto;margin-top:8px;';
3440
+ panel.appendChild(bodyEl);
3441
+
3442
+ // Collapsed by default under the real engine's own narrow-
3443
+ // screen legend threshold (ui/js/tools/legend.js: `if
3444
+ // (window.innerWidth < 500) ixmaps.legend.hide()`) — reusing
3445
+ // that concrete precedent rather than picking an arbitrary
3446
+ // "mobile" breakpoint of this port's own invention. Checked
3447
+ // once at panel-build time, not on resize — matching the real
3448
+ // engine's own one-shot-at-redraw-time check, not a live
3449
+ // matchMedia listener.
3450
+ const MOBILE_LEGEND_BREAKPOINT = 500;
3451
+ let collapsed = legendFolded || window.innerWidth < MOBILE_LEGEND_BREAKPOINT;
3452
+ function applyCollapsed() {
3453
+ bodyEl.style.display = collapsed ? 'none' : 'flex';
3454
+ collapseBtn.textContent = collapsed ? '▸' : '▾';
3455
+ collapseBtn.title = collapsed ? 'Expand legend' : 'Collapse legend';
3456
+ }
3457
+ collapseBtn.addEventListener('click', () => { collapsed = !collapsed; applyCollapsed(); });
3458
+ applyCollapsed();
3459
+ rt._setLegendCollapsed = c => { collapsed = !!c; applyCollapsed(); }; // setLegendOption
3460
+
3461
+ // Selection/filter by field — opt-in via .style({legendfilter:
3462
+ // "<field>"}), e.g. "country_long" on the power-plants sample.
3463
+ // A NEW, ixmaps-gl-only convention: real ixmaps-flat's own
3464
+ // style.filterfield names the DEFAULT field an unqualified
3465
+ // .filter("text") search term matches against (maptheme.js:
3466
+ // 1275-1276/7359-7362) — a different concept entirely, so this
3467
+ // deliberately uses its own name rather than overloading that
3468
+ // one. Options come from the FULL dataset (a world-spanning
3469
+ // bbox, not the current viewport) and stay fixed regardless of
3470
+ // pan/zoom — unlike the map-view-aware category totals below,
3471
+ // a picker whose own choices kept shrinking as you panned
3472
+ // would make it impossible to select a country not currently
3473
+ // in view. Selecting a value reuses the engine's own existing
3474
+ // facet-filter primitive (engineApi.setFacetFilter/
3475
+ // clearFacetFilter — already propagates to sibling runtimes
3476
+ // sharing the same data source and calls refresh()), so this
3477
+ // is UI wiring only, no new filtering logic.
3478
+ const filterField = rt.style.legendfilter;
3479
+ if (filterField) {
3480
+ const facet = engineApi.getFacets(rt.name, [filterField], { bbox: [-180, -85, 180, 85] })[0];
3481
+ const values = (facet && facet.type === 'textual')
3482
+ ? facet.values.slice().sort((a, b) => String(a).localeCompare(String(b)))
3483
+ : [];
3484
+ const filterEl = document.createElement('select');
3485
+ filterEl.style.cssText = 'width:100%;margin-bottom:8px;background:' + legendColors.selectBg + ';'
3486
+ + 'color:' + legendColors.fg + ';border:1px solid ' + legendColors.selectBorder + ';border-radius:4px;padding:4px 6px;font:inherit;';
3487
+ filterEl.innerHTML = '<option value="">All (' + values.length + ')</option>'
3488
+ + values.map(v => '<option value="' + escapeHtml(v) + '">' + escapeHtml(v) + '</option>').join('');
3489
+ filterEl.addEventListener('change', () => {
3490
+ if (filterEl.value) engineApi.setFacetFilter(rt.name, filterField, filterEl.value);
3491
+ else engineApi.clearFacetFilter(rt.name, filterField);
3492
+ });
3493
+ bodyEl.appendChild(filterEl);
3494
+ }
3495
+
3496
+ if (!isTextOnly) { bodyEl.appendChild(rowsScroll); renderRows(); }
3497
+
3498
+ if (rt.meta.description) {
3499
+ const desc = document.createElement('div');
3500
+ desc.style.cssText = 'margin-top:8px;font-size:11px;opacity:0.8;';
3501
+ // Raw HTML, matching the real engine's own description
3502
+ // rendering and this engine's existing .legend(html)/
3503
+ // tooltip conventions (e.g. _buildTooltipContext) — real
3504
+ // pages already embed their own source-citation markup
3505
+ // inside meta.description (see the power-plants sample).
3506
+ desc.innerHTML = rt.meta.description;
3507
+ bodyEl.appendChild(desc);
3508
+ }
3509
+
3510
+ // Chart-size slider — real engine's own range (legend.js:
3511
+ // 3196-3202), 100% == scale:1, wired to changeThemeStyle(
3512
+ // themeId,"scale:"+pct/100,"set")+redrawTheme there; this
3513
+ // engine's equivalent is rt.setStyle({scale}) + refresh(),
3514
+ // the same primitive engineApi.setThemeStyle uses above.
3515
+ // CHOROPLETH themes get an OPACITY slider instead, as in flat
3516
+ // (legend.js ~3190: 0-100%, fillopacity·100, default 90;
3517
+ // changeThemeStyle("fillopacity:"+pct/100,"set") + redraw).
3518
+ // (flat labels a VECTOR theme's size slider "Line width" — gl has
3519
+ // no VECTOR themes.)
3520
+ // Flat tests the words (legend.js 2533-2535): a "CHOROPLETHE"
3521
+ // theme has neither slider.
3522
+ const typeWords = rt.flags.typeString != null ? rt.flags.typeString : [...rt.flags].join('|');
3523
+ const isOpacitySlider = /\bCHOROPLETH\b/.test(typeWords);
3524
+ const hasSlider = isOpacitySlider || /\bCHART\b|\bBUBBLE\b|\bDOT\b/.test(typeWords);
3525
+ if (hasSlider) {
3526
+ const sliderRow = document.createElement('div');
3527
+ sliderRow.style.cssText = 'margin-top:10px;font-size:11px;opacity:0.8;';
3528
+ const fillPct = Math.round(styleNum(rt.style.fillopacity) * 100);
3529
+ const initialPct = isOpacitySlider
3530
+ ? (Number.isFinite(fillPct) ? Math.max(0, Math.min(100, fillPct)) : 90)
3531
+ : Math.round((styleNum(rt.style.scale) || 1) * 100);
3532
+ const sliderLabel = isOpacitySlider ? 'Opacity' : 'Chart size';
3533
+ sliderRow.innerHTML = sliderLabel + ': <span class="ix-legend-scale-val">' + initialPct + '</span>%';
3534
+ bodyEl.appendChild(sliderRow);
3535
+ const slider = document.createElement('input');
3536
+ slider.type = 'range';
3537
+ slider.min = isOpacitySlider ? '0' : '25';
3538
+ slider.max = isOpacitySlider ? '100' : '200';
3539
+ slider.value = String(initialPct);
3540
+ slider.style.cssText = 'width:100%;margin-top:2px;';
3541
+ slider.addEventListener('input', () => {
3542
+ const pct = parseInt(slider.value, 10);
3543
+ sliderRow.querySelector('.ix-legend-scale-val').textContent = pct;
3544
+ rt.setStyle(isOpacitySlider ? { fillopacity: pct / 100 } : { scale: pct / 100 });
3545
+ refresh();
3546
+ });
3547
+ bodyEl.appendChild(slider);
3548
+ }
3549
+ }
3550
+ };
3551
+ runtimes.forEach(rt => addLegendPanel(rt));
3552
+ }
3553
+ return { addLegendPanel, setLegendOption, updateSubTheme, layoutLegends };
3554
+ }
3555
+
2782
3556
  class MapBuilder {
2783
3557
  constructor(containerId, options) {
2784
3558
  this.containerId = containerId;
@@ -3122,112 +3896,8 @@
3122
3896
  });
3123
3897
  }
3124
3898
 
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
- }
3899
+ // hover / click-to-pin tooltips: createTooltips
3900
+ const tooltips = createTooltips({ builder: this, map, el, findRuntimeForLayerId });
3231
3901
 
3232
3902
  const overlay = new MapLibreOverlay({
3233
3903
  interleaved: true,
@@ -3235,88 +3905,13 @@
3235
3905
  // pointer becomes a hand over anything pickable (bubbles/points),
3236
3906
  // so hovering something clickable actually looks clickable
3237
3907
  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
- }
3908
+ // (guarded: see createTooltips)
3909
+ getTooltip: tooltips.getTooltip,
3910
+ onClick: tooltips.onClick
3255
3911
  });
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
3912
  map.addControl(overlay);
3313
3913
  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
- }
3914
+ tooltips.mount();
3320
3915
 
3321
3916
  if (this._legendHtml && el.parentElement) {
3322
3917
  const legendEl = document.createElement('div');
@@ -4337,698 +4932,10 @@
4337
4932
  });
4338
4933
  }
4339
4934
 
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
- }
4935
+ // the native legend: createLegend (its four hooks are what the rest
4936
+ // of the map calls — panels, the legend option, sub-themes, layout)
4937
+ ({ addLegendPanel, setLegendOption, updateSubTheme, layoutLegends } =
4938
+ createLegend({ builder, map, el, overlay, runtimes, engineApi, built, refresh, scheduleRefresh, notifyRedraw }));
5032
4939
 
5033
4940
  _lastMapApi = engineApi;
5034
4941
  // ?ixgl-debug: ixmaps.__glDebug.check() reports, for the moment it is
@@ -10822,6 +10729,9 @@
10822
10729
  // nothing here authored that string. Escaped before going into
10823
10730
  // innerHTML so a stray `<`/`&`/quote in a source field can't break the
10824
10731
  // option markup (or worse).
10732
+ function escapeHtml(s) {
10733
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
10734
+ }
10825
10735
  // a legend's row labels as HTML: the page's .style({label}) as given,
10826
10736
  // values from the data escaped
10827
10737
  function legendRowLabels(rt) {
@@ -10829,9 +10739,162 @@
10829
10739
  if (display && display !== rt.categoryLabels) return display;
10830
10740
  return (rt.categoryLabels || []).map(v => escapeHtml(v));
10831
10741
  }
10832
- function escapeHtml(s) {
10833
- return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
10742
+
10743
+ // ===============================================================
10744
+ // FLAT-COMPATIBILITY API — what a page written for ixmaps-flat calls on
10745
+ // the global `ixmaps` object; it acts on the last built map:
10746
+ // - page hooks gl calls: htmlgui_onNewTheme, htmlgui_onDrawTheme,
10747
+ // htmlgui_onZoomAndPan
10748
+ // - the map handle ixmaps.map() and its methods (MAP_HANDLE_METHODS)
10749
+ // - view: getZoom, getCenter, getMapTypeId, getBoundingBox
10750
+ // - themes: getThemeObj (flat's theme object, with its data table and
10751
+ // the items on the map), getThemeDefinitionObj, getThemes,
10752
+ // changeThemeStyle / removeTheme / setBasemapOpacity (map name first),
10753
+ // refreshTheme, markThemeClass / unmarkThemeClass
10754
+ // - data: ixmaps.data (facets: getFacets, showFacets, …), the
10755
+ // embeddedSVG.window tables (setExternalData is with the data loading)
10756
+ // - projects: getProjectString, setProjectJSON, loadProject
10757
+ // - page UI: setTitle, setTitleBox, formatValue; message,
10758
+ // filterThemeItems and highlightThemeItems are accepted but not ported
10759
+ // The object itself is assembled at the end of this section (global.ixmaps).
10760
+ // ===============================================================
10761
+
10762
+ // flat's page hook ixmaps.htmlgui_onNewTheme(szId), called when a theme
10763
+ // is created (maptheme.js 927) — with the theme's id (its name)
10764
+ function themeIdOf(rt) {
10765
+ return (rt.style && rt.style.name) || (rt.meta && rt.meta.name) || rt.name;
10766
+ }
10767
+ function notifyNewTheme(rt) {
10768
+ const hook = pageIxmaps() && pageIxmaps().htmlgui_onNewTheme;
10769
+ if (typeof hook !== 'function') return;
10770
+ try { hook.call(global.ixmaps, themeIdOf(rt)); } catch (e) { console.error('[ixmaps-gl] htmlgui_onNewTheme:', e); }
10771
+ }
10772
+
10773
+ // flat's ixmaps.map() — the running map's handle (htmlgui.js 173ff,
10774
+ // htmlgui_flat.js). Its methods act on the last built map; called while
10775
+ // the map is still building, they run once it is ready.
10776
+ const MAP_HANDLE_METHODS = ['replace', 'add', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
10777
+ 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible'];
10778
+ const _mapHandle = {};
10779
+ for (const m of MAP_HANDLE_METHODS) {
10780
+ _mapHandle[m] = (...args) => {
10781
+ if (_lastMapApi) { const r = _lastMapApi[m](...args); return r === _lastMapApi ? _mapHandle : r; }
10782
+ _mapReady.then(api => api[m](...args));
10783
+ return _mapHandle;
10784
+ };
10785
+ }
10786
+ _mapHandle.getZoom = () => (_lastMapApi ? _lastMapApi.getZoom() : undefined);
10787
+ _mapHandle.getThemeObj = id => getThemeObj(id);
10788
+ function mapHandle() { return _mapHandle; }
10789
+
10790
+ // flat's ixmaps.formatValue(value, precision, flag) (ui/js/tools/format.js),
10791
+ // which user chart scripts call: thousands separated by "." (BLANK: a
10792
+ // space), the decimals after "," (BLANK: "."), CEIL / FLOOR rounding
10793
+ function flatFormatPart(nPart, szLeading) {
10794
+ szLeading = szLeading || '';
10795
+ let szPart = '';
10796
+ if (nPart < 100) szPart += szLeading;
10797
+ if (nPart < 10) szPart += szLeading;
10798
+ if (nPart === 0) szPart += szLeading;
10799
+ else szPart += String(nPart);
10800
+ return szPart;
10834
10801
  }
10802
+ function flatFormatValue(nValue, nPrecision, szFlag) {
10803
+ nValue = Number(nValue);
10804
+ if (!isFinite(nValue) || !isFinite(nPrecision)) return String(nValue);
10805
+ if (nValue === 0) return String(nValue);
10806
+ if (nValue > 1000000000000 || nValue < -1000000000000) return String(nValue);
10807
+ nPrecision = Math.max(0, nPrecision || 0);
10808
+ if (nValue > 0.0000001 && nPrecision > 0) {
10809
+ while (Number(nValue.toFixed(nPrecision - 1)) === 0) nPrecision++;
10810
+ }
10811
+ let v = Number(nValue.toFixed(nPrecision + 1));
10812
+ const clip = Math.pow(10, nPrecision);
10813
+ if (szFlag && /CEIL/.test(szFlag)) v = Math.ceil(v * clip) / clip;
10814
+ else if (szFlag && /FLOOR/.test(szFlag)) v = Math.floor(v * clip) / clip;
10815
+ else v = Math.round(v * clip) / clip;
10816
+ let szDecimals = String(v);
10817
+ if (/\./.test(szDecimals)) {
10818
+ szDecimals = szDecimals.split('.')[1];
10819
+ while (szDecimals.length < nPrecision) szDecimals += '0';
10820
+ } else szDecimals = '';
10821
+ let szReturn = v < 0 ? '-' : '';
10822
+ let szLeading = '';
10823
+ let n = Math.floor(Math.abs(v));
10824
+ if (!szFlag || !/NOBREAKS/.test(szFlag)) {
10825
+ let nClip = 1000;
10826
+ while (n > nClip) nClip *= 1000;
10827
+ nClip /= 1000;
10828
+ let szBreak = ' ';
10829
+ while (nClip >= 1000) {
10830
+ const nPart = Math.floor(n / nClip);
10831
+ szReturn += flatFormatPart(nPart, szLeading);
10832
+ n = n % nClip;
10833
+ nClip /= 1000;
10834
+ if (nPart) {
10835
+ szLeading = '0';
10836
+ szBreak = szFlag && /SPACE/.test(szFlag) ? '<span style="font-size:0.5em;">&nbsp;</span>' : szFlag && /BLANK/.test(szFlag) ? '&nbsp;' : '.';
10837
+ }
10838
+ szReturn += szBreak;
10839
+ }
10840
+ }
10841
+ szReturn += flatFormatPart(n, szLeading);
10842
+ if (!szReturn.length || szReturn === '-') szReturn += '0';
10843
+ if (szDecimals.length && szDecimals !== '00') szReturn += (szFlag && /BLANK/.test(szFlag) ? '.' : ',') + szDecimals;
10844
+ return szReturn;
10845
+ }
10846
+
10847
+ function getZoom() { return _lastMapApi ? _lastMapApi.getZoom() : undefined; }
10848
+
10849
+ // flat's ixmaps.getBoundingBox() (htmlgui_sync_Leaflet_VT.js
10850
+ // htmlMap_getBounds): [{lat, lng} south-west, {lat, lng} north-east] of
10851
+ // the view, latitude clamped to ±85.05, longitude not capped. Set by the
10852
+ // last build(): a broker (data.type "ext") is called while its map is
10853
+ // still building, so until the MapLibre map exists the bounds come from
10854
+ // the requested view and the container size (MapLibre's 512 px tiles).
10855
+ let _boundsSource = null;
10856
+ function viewBounds(lat, lng, mlZoom, width, height) {
10857
+ const world = 512 * Math.pow(2, mlZoom);
10858
+ const x = (lng + 180) / 360 * world;
10859
+ const s = Math.sin(Math.max(-85.05, Math.min(85.05, lat)) * Math.PI / 180);
10860
+ const y = (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * world;
10861
+ const lngAt = px => px / world * 360 - 180;
10862
+ const latAt = py => Math.atan(Math.sinh(Math.PI * (1 - 2 * py / world))) * 180 / Math.PI;
10863
+ return [{ lat: latAt(y + height / 2), lng: lngAt(x - width / 2) }, { lat: latAt(y - height / 2), lng: lngAt(x + width / 2) }];
10864
+ }
10865
+ function getBoundingBox() {
10866
+ const b = _boundsSource ? _boundsSource() : null;
10867
+ if (!b) return null;
10868
+ return [{ lat: Math.max(b[0].lat, -85.05), lng: b[0].lng }, { lat: Math.min(b[1].lat, 85.05), lng: b[1].lng }];
10869
+ }
10870
+
10871
+ // flat's ixmaps.setTitle(html) / setTitleBox(text, color) (htmlgui.js
10872
+ // 1311-1331): a message line over the last built map, same markup as
10873
+ // flat's default (legend not aligned left); '' clears it.
10874
+ let _titleHost = null;
10875
+ function setTitle(szTitle) {
10876
+ if (!_titleHost) return;
10877
+ let box = _titleHost.querySelector(':scope > .ixmaps-gl-title');
10878
+ if (!box) {
10879
+ box = document.createElement('div');
10880
+ box.className = 'ixmaps-gl-title';
10881
+ box.style.cssText = 'position:absolute;top:11px;left:0;z-index:3;pointer-events:none;';
10882
+ _titleHost.appendChild(box);
10883
+ }
10884
+ box.innerHTML = szTitle
10885
+ ? "<div style='position:relative;left:100px;top:2px;font-style:arial,helvetica;font-size:22px'>" + szTitle + '</div>'
10886
+ : '';
10887
+ }
10888
+ function setTitleBox(szTitle, szColor) {
10889
+ setTitle("<span style='display:inline-flex;align-items:center;height:38px;box-sizing:border-box;padding:0 12px;border:1px solid #46494c;border-radius:8px;font-size:14px;font-family:courier new,Raleway,arial,helvetica;background:" + (szColor || 'rgba(255,255,255,0.95)') + ';color:' + (szColor ? '#fff' : '#222') + "'>" + szTitle + '</span>');
10890
+ }
10891
+ // flat's ixmaps.refreshTheme(szId): reloads the theme's data on the last
10892
+ // built map (a broker is called again) — see engineApi.refreshTheme
10893
+ function refreshTheme(szId) {
10894
+ if (!_lastMapApi || !_lastMapApi.refreshTheme) return;
10895
+ _lastMapApi.refreshTheme(szId).catch(err => console.error('[ixmaps-gl] refreshTheme:', err));
10896
+ }
10897
+
10835
10898
 
10836
10899
  // GL-PORT COMPAT: real ixmaps-flat's ixmaps.getThemeObj(szId) — a
10837
10900
  // global lookup by theme id, independent of which map built it — looked