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.
- package/README.md +3 -3
- package/ixmaps-gl.js +1079 -1016
- 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.
|
|
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;"> </span>' : szFlag && /BLANK/.test(szFlag) ? ' ' : '.';
|
|
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, ' ');
|
|
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') + ' ... ' + 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 ? '↓ ' + (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
|
-
//
|
|
3126
|
-
|
|
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
|
-
//
|
|
3239
|
-
|
|
3240
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
4341
|
-
//
|
|
4342
|
-
|
|
4343
|
-
|
|
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, ' ');
|
|
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') + ' ... ' + 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 ? '↓ ' + (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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
-
|
|
10833
|
-
|
|
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;"> </span>' : szFlag && /BLANK/.test(szFlag) ? ' ' : '.';
|
|
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
|