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