ixmaps-gl 0.2.7 → 0.2.9
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 +653 -61
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,7 +36,8 @@ page. It currently implements:
|
|
|
36
36
|
(equal-interval, QUANTILE, NATURAL/Jenks breaks) or multi-field DOMINANT
|
|
37
37
|
(per-polygon argmax, plain/`PERCENTOFMEAN`/`DEVIATION`; with a `value100` the field means are
|
|
38
38
|
flat's pooled Σ field ÷ Σ value100) or COMPOSECOLOR (additive or
|
|
39
|
-
`SUBTRACTIVE` color blend)
|
|
39
|
+
`SUBTRACTIVE` color blend) or, with `CATEGORICAL|AGGREGATE` on one field, the dominant category of
|
|
40
|
+
each polygon's records (sum of the size field per category); `HEADTAIL` breaks, `DENSITY` (value per km²), flat's
|
|
40
41
|
`ZEROISNOTVALUE`/`UNDEFINEDISNOTVALUE`; plus DOPACITY/DOPACITYMIN/DOPACITYMAX/DOPACITYMINMAX for
|
|
41
42
|
value- or density-driven fill opacity
|
|
42
43
|
- **CHART\|SYMBOL\|GLOW\|CATEGORICAL\|AGGREGATE\|COUNT\|RELOCATE\|VALUES** — the
|
|
@@ -55,6 +56,9 @@ page. It currently implements:
|
|
|
55
56
|
chart — symbol, glow, shadow, box, title and value — as flat does
|
|
56
57
|
- A range theme of at most two classes gets flat's one-row legend (the value range, or for a
|
|
57
58
|
`$item$` count the label or field name)
|
|
59
|
+
- **CHART\|SYMBOL\|SEQUENCE** on AGGREGATE CATEGORICAL data — one chart per cell (or aggregation
|
|
60
|
+
field value) with a symbol per category, laid out as flat does (`STAR`, `HORZ`, centered), with
|
|
61
|
+
flat's part sizes, glow and value labels
|
|
58
62
|
- **CHART\|USER** — a page's own chart function (`style.userdraw`: `ixmaps.<name>(SVGDocument, opt)`
|
|
59
63
|
and its `_init`, as flat calls them), drawn in flat's chart units and shown as an icon at flat's size
|
|
60
64
|
- **DOT** — the simplest base symbol (fixed-radius, unclustered points)
|
|
@@ -72,7 +76,7 @@ page. It currently implements:
|
|
|
72
76
|
`window.__setFacetFilter`) for building filterable sidebars
|
|
73
77
|
- Globe (orthographic) projection alongside flat Mercator
|
|
74
78
|
|
|
75
|
-
Not yet implemented: `CATEGORICAL` choropleths, and the `QUAD`/`BEZIER`/`VECTOR`/
|
|
79
|
+
Not yet implemented: plain (non-AGGREGATE) `CATEGORICAL` choropleths, and the `QUAD`/`BEZIER`/`VECTOR`/
|
|
76
80
|
`PIE`/`DONUT`/`WAFFLE`/`BAR` base types and SYMBOL shape variants beyond
|
|
77
81
|
circle/square/diamond/triangle. See the top-of-file comment in
|
|
78
82
|
[`ixmaps-gl.js`](./ixmaps-gl.js) for the exact, currently-accurate scope note.
|
|
@@ -203,7 +207,7 @@ of your own project files gl can render.
|
|
|
203
207
|
Load the engine from jsDelivr, pinned to a release tag (or `@main` for the latest commit):
|
|
204
208
|
|
|
205
209
|
```html
|
|
206
|
-
<script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.
|
|
210
|
+
<script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.9/ixmaps-gl.js"></script>
|
|
207
211
|
```
|
|
208
212
|
|
|
209
213
|
or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmaps-gl)
|
|
@@ -211,13 +215,13 @@ or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmap
|
|
|
211
215
|
browser script, which defines the global `ixmaps`):
|
|
212
216
|
|
|
213
217
|
```html
|
|
214
|
-
<script src="https://unpkg.com/ixmaps-gl@0.2.
|
|
218
|
+
<script src="https://unpkg.com/ixmaps-gl@0.2.9/ixmaps-gl.js"></script>
|
|
215
219
|
```
|
|
216
220
|
|
|
217
221
|
or use a local copy (`<script src="ixmaps-gl.js"></script>`). A minimal page:
|
|
218
222
|
|
|
219
223
|
```html
|
|
220
|
-
<script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.
|
|
224
|
+
<script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.9/ixmaps-gl.js"></script>
|
|
221
225
|
<div id="map_div" style="position:absolute;inset:0;"></div>
|
|
222
226
|
|
|
223
227
|
<script>
|
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.9';
|
|
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
|
|
@@ -2194,7 +2261,7 @@
|
|
|
2194
2261
|
// edit it by hand; `npm run unit` fails when it drifts.
|
|
2195
2262
|
// ---------------------------------------------------------------
|
|
2196
2263
|
// <grammar:binding-aliases>
|
|
2197
|
-
// generated from ixmaps-grammar 0.1.
|
|
2264
|
+
// generated from ixmaps-grammar 0.1.24 (ixmaps-flat 1.0.42, 2b978d5 2026-10-02) — 52 aliases
|
|
2198
2265
|
const FLAT_BINDING_ALIASES = {
|
|
2199
2266
|
"aggregation": "style.aggregationfield",
|
|
2200
2267
|
"aggregationfield": "style.aggregationfield",
|
|
@@ -2256,7 +2323,7 @@
|
|
|
2256
2323
|
// both — so .style({tooltip}) is a tooltip and .meta({fillopacity}) a style
|
|
2257
2324
|
// property. GENERATED by test/sync-grammar.mjs — do not edit.
|
|
2258
2325
|
// <grammar:meta-keys>
|
|
2259
|
-
// generated from ixmaps-grammar 0.1.
|
|
2326
|
+
// generated from ixmaps-grammar 0.1.24 — 5 meta keys
|
|
2260
2327
|
const FLAT_META_KEYS = ["description","name","snippet","title","tooltip"];
|
|
2261
2328
|
// </grammar:meta-keys>
|
|
2262
2329
|
|
|
@@ -2923,6 +2990,9 @@
|
|
|
2923
2990
|
// theme addressed only by its meta.name, same bug different
|
|
2924
2991
|
// resolution path.
|
|
2925
2992
|
if (rt.meta && rt.meta.name && rt.meta.name !== rt.name) _globalThemeRegistry.set(rt.meta.name, rt);
|
|
2993
|
+
// flat's theme id is style.name (maptheme szId) — several themes
|
|
2994
|
+
// can share one .layer() name (a page's features + charts + texts)
|
|
2995
|
+
if (themeIdOf(rt) !== rt.name) _globalThemeRegistry.set(themeIdOf(rt), rt);
|
|
2926
2996
|
if (named) namedLoads.push(rt);
|
|
2927
2997
|
} catch (err) {
|
|
2928
2998
|
console.error(`[ixmaps-gl] theme "${lb.name}" skipped:`, err);
|
|
@@ -3416,6 +3486,8 @@
|
|
|
3416
3486
|
},
|
|
3417
3487
|
setMapTypeId: (id) => engineApi.setMapType(id),
|
|
3418
3488
|
mapType: (id) => engineApi.setMapType(id),
|
|
3489
|
+
// flat's getMapTypeId(): the basemap type name in use
|
|
3490
|
+
getMapTypeId: () => String(builder.mapOptions.mapType || ''),
|
|
3419
3491
|
setBasemapOpacity: (delta, mode) => {
|
|
3420
3492
|
_basemapOpacity = mode === 'relative'
|
|
3421
3493
|
? Math.max(0, Math.min(1, _basemapOpacity + (parseFloat(delta) || 0)))
|
|
@@ -3857,6 +3929,7 @@
|
|
|
3857
3929
|
runtimes.push(rt);
|
|
3858
3930
|
_globalThemeRegistry.set(rt.name, rt);
|
|
3859
3931
|
if (rt.meta && rt.meta.name && rt.meta.name !== rt.name) _globalThemeRegistry.set(rt.meta.name, rt);
|
|
3932
|
+
if (themeIdOf(rt) !== rt.name) _globalThemeRegistry.set(themeIdOf(rt), rt);
|
|
3860
3933
|
addLegendPanel(rt);
|
|
3861
3934
|
notifyNewTheme(rt);
|
|
3862
3935
|
refresh();
|
|
@@ -4139,8 +4212,25 @@
|
|
|
4139
4212
|
refresh();
|
|
4140
4213
|
});
|
|
4141
4214
|
|
|
4215
|
+
// flat's page hook ixmaps.htmlgui_onDrawTheme(szId), called when a
|
|
4216
|
+
// theme has been drawn: here once per settled redraw, for every
|
|
4217
|
+
// shown theme, with its id. Not re-entered when the hook itself
|
|
4218
|
+
// changes a theme (changeThemeStyle → refresh → here again).
|
|
4219
|
+
let inDrawThemeHook = false;
|
|
4220
|
+
function callDrawThemeHooks() {
|
|
4221
|
+
const hook = pageIxmaps() && pageIxmaps().htmlgui_onDrawTheme;
|
|
4222
|
+
if (typeof hook !== 'function' || inDrawThemeHook) return;
|
|
4223
|
+
inDrawThemeHook = true;
|
|
4224
|
+
try {
|
|
4225
|
+
for (const rt of runtimes.slice()) {
|
|
4226
|
+
if (rt._hidden) continue;
|
|
4227
|
+
try { hook.call(global.ixmaps, themeIdOf(rt)); } catch (e) { console.error('[ixmaps-gl] htmlgui_onDrawTheme:', e); }
|
|
4228
|
+
}
|
|
4229
|
+
} finally { inDrawThemeHook = false; }
|
|
4230
|
+
}
|
|
4142
4231
|
function notifyRedraw() {
|
|
4143
4232
|
redrawListeners.forEach(cb => { try { cb(); } catch (err) { console.error('[ixmaps-gl] onRedraw callback failed:', err); } });
|
|
4233
|
+
callDrawThemeHooks();
|
|
4144
4234
|
}
|
|
4145
4235
|
|
|
4146
4236
|
// For explicit, discrete API calls (setFacetFilter, setSizeField,
|
|
@@ -4514,7 +4604,10 @@
|
|
|
4514
4604
|
// themselves authored with their own leading space (e.g.
|
|
4515
4605
|
// " MW") in real pages.
|
|
4516
4606
|
const legendUnit = rt.style.legendunits || rt.style.units || '';
|
|
4517
|
-
|
|
4607
|
+
// row labels go into innerHTML: the page's own .style({label})
|
|
4608
|
+
// stays HTML, as in flat; categories read from the data are
|
|
4609
|
+
// escaped (a value could carry markup)
|
|
4610
|
+
const labels = legendRowLabels(rt);
|
|
4518
4611
|
// .type("...|TEXTLEGEND") — the FIFTH real legend-related
|
|
4519
4612
|
// type() token: title/snippet/description text only, no
|
|
4520
4613
|
// category rows at all (swatches, bars, chips — none of it),
|
|
@@ -4661,8 +4754,8 @@
|
|
|
4661
4754
|
+ '<span>' + text + '</span></div>';
|
|
4662
4755
|
rowsEl.querySelectorAll('.ix-legend-row').forEach(row => {
|
|
4663
4756
|
row.addEventListener('click', () => {
|
|
4664
|
-
if (rt._markedClasses.has(0))
|
|
4665
|
-
else
|
|
4757
|
+
if (rt._markedClasses.has(0)) unmarkRuntimeClass(rt, 0);
|
|
4758
|
+
else markRuntimeClass(rt, 0);
|
|
4666
4759
|
});
|
|
4667
4760
|
});
|
|
4668
4761
|
};
|
|
@@ -4701,8 +4794,8 @@
|
|
|
4701
4794
|
rowsEl.querySelectorAll('.ix-legend-row').forEach(cell => {
|
|
4702
4795
|
cell.addEventListener('click', () => {
|
|
4703
4796
|
const idx = parseInt(cell.dataset.idx, 10);
|
|
4704
|
-
if (rt._markedClasses.has(idx))
|
|
4705
|
-
else
|
|
4797
|
+
if (rt._markedClasses.has(idx)) unmarkRuntimeClass(rt, idx);
|
|
4798
|
+
else markRuntimeClass(rt, idx);
|
|
4706
4799
|
});
|
|
4707
4800
|
});
|
|
4708
4801
|
};
|
|
@@ -4759,15 +4852,16 @@
|
|
|
4759
4852
|
// — legend.js:817), toggle decided by the caller. Same
|
|
4760
4853
|
// split here: the toggle check lives in this click
|
|
4761
4854
|
// handler, the actual add/remove-and-redraw primitive is
|
|
4762
|
-
// the
|
|
4763
|
-
//
|
|
4764
|
-
//
|
|
4765
|
-
//
|
|
4855
|
+
// markRuntimeClass/unmarkRuntimeClass, which the globals
|
|
4856
|
+
// ixmaps.markThemeClass/unmarkThemeClass also use — so a
|
|
4857
|
+
// page's OWN custom UI drives this exact legend too. The
|
|
4858
|
+
// rows pass their runtime itself: its .layer() name may be
|
|
4859
|
+
// shared with other themes of the page.
|
|
4766
4860
|
rowsEl.querySelectorAll('.ix-legend-row').forEach(rowEl => {
|
|
4767
4861
|
rowEl.addEventListener('click', () => {
|
|
4768
4862
|
const idx = parseInt(rowEl.dataset.idx, 10);
|
|
4769
|
-
if (rt._markedClasses.has(idx))
|
|
4770
|
-
else
|
|
4863
|
+
if (rt._markedClasses.has(idx)) unmarkRuntimeClass(rt, idx);
|
|
4864
|
+
else markRuntimeClass(rt, idx);
|
|
4771
4865
|
});
|
|
4772
4866
|
});
|
|
4773
4867
|
};
|
|
@@ -5627,7 +5721,7 @@
|
|
|
5627
5721
|
}
|
|
5628
5722
|
// maptheme.js formatValue's NOBREAKS switch (26368): theme min > 999 and max < 3000
|
|
5629
5723
|
function flatNoBreaks(vMin, vMax) {
|
|
5630
|
-
return vMin > 999 && vMax < 3000;
|
|
5724
|
+
return Number.isFinite(vMin) && Number.isFinite(vMax) && vMin > 999 && vMax < 3000;
|
|
5631
5725
|
}
|
|
5632
5726
|
|
|
5633
5727
|
// flat's chart position (maptheme.js 24441-24530 and 24603, the non-PLOT,
|
|
@@ -5737,9 +5831,13 @@
|
|
|
5737
5831
|
// scale gate (maptheme.js 2609-2611, 8250-8260): the theme is hidden at
|
|
5738
5832
|
// scales above featureupper and at or below featurelower ("1:200000" or
|
|
5739
5833
|
// a bare denominator, __scanScaleValue 2021)
|
|
5834
|
+
// a scale style value ("1:200000" or a bare denominator, flat's
|
|
5835
|
+
// __scanScaleValue, maptheme.js 2021) as its denominator; 0 when unset
|
|
5836
|
+
function scaleDenom(v) {
|
|
5837
|
+
return (v == null || v === '' ? 0 : Number(String(v).includes(':') ? String(v).split(':')[1] : v)) || 0;
|
|
5838
|
+
}
|
|
5740
5839
|
function featuresHiddenByScale(style, zoom) {
|
|
5741
|
-
const
|
|
5742
|
-
const upper = denom(style.featureupper), lower = denom(style.featurelower);
|
|
5840
|
+
const upper = scaleDenom(style.featureupper), lower = scaleDenom(style.featurelower);
|
|
5743
5841
|
if (!upper && !lower) return false;
|
|
5744
5842
|
const scale = scaleDenominatorAt(zoom);
|
|
5745
5843
|
return (lower && scale <= lower) || (upper && scale > upper);
|
|
@@ -5750,9 +5848,8 @@
|
|
|
5750
5848
|
// chartupper and at or below chartlower; layerupper / layerlower stand in
|
|
5751
5849
|
// for them when unset (1774-1779)
|
|
5752
5850
|
function chartHiddenByScale(style, zoom) {
|
|
5753
|
-
const
|
|
5754
|
-
const
|
|
5755
|
-
const lower = denom(style.chartlower != null ? style.chartlower : style.layerlower);
|
|
5851
|
+
const upper = scaleDenom(style.chartupper != null ? style.chartupper : style.layerupper);
|
|
5852
|
+
const lower = scaleDenom(style.chartlower != null ? style.chartlower : style.layerlower);
|
|
5756
5853
|
if (!upper && !lower) return false;
|
|
5757
5854
|
const scale = scaleDenominatorAt(zoom);
|
|
5758
5855
|
return !!((upper && scale > upper) || (lower && scale <= lower));
|
|
@@ -5761,8 +5858,7 @@
|
|
|
5761
5858
|
// 1726-1734, 16986-16990): the GLOW halo is drawn at scales at or below
|
|
5762
5859
|
// glowupper and at or above glowlower (both inclusive)
|
|
5763
5860
|
function glowHiddenByScale(style, zoom) {
|
|
5764
|
-
const
|
|
5765
|
-
const upper = denom(style.glowupper), lower = denom(style.glowlower);
|
|
5861
|
+
const upper = scaleDenom(style.glowupper), lower = scaleDenom(style.glowlower);
|
|
5766
5862
|
if (!upper && !lower) return false;
|
|
5767
5863
|
const scale = scaleDenominatorAt(zoom);
|
|
5768
5864
|
return !!((upper && scale > upper) || (lower && scale < lower));
|
|
@@ -5784,8 +5880,7 @@
|
|
|
5784
5880
|
const on = style.shadow === true || style.shadow === 'true' || style.shadow === 1 || style.shadow === '1';
|
|
5785
5881
|
if (!on) return false;
|
|
5786
5882
|
if (count > (isNaN(styleNum(style.maxshadow)) ? 1000 : styleNum(style.maxshadow))) return false;
|
|
5787
|
-
const
|
|
5788
|
-
const upper = denom(style.shadowupper), lower = denom(style.shadowlower);
|
|
5883
|
+
const upper = scaleDenom(style.shadowupper), lower = scaleDenom(style.shadowlower);
|
|
5789
5884
|
const scale = scaleDenominatorAt(zoom);
|
|
5790
5885
|
return !((upper && scale > upper) || (lower && scale < lower));
|
|
5791
5886
|
}
|
|
@@ -5799,8 +5894,7 @@
|
|
|
5799
5894
|
// flat's BOX block (maptheme.js 18758-18760): the box — and its title —
|
|
5800
5895
|
// is removed at scales above boxupper or below boxlower
|
|
5801
5896
|
function boxHiddenByScale(style, zoom) {
|
|
5802
|
-
const
|
|
5803
|
-
const upper = denom(style.boxupper), lower = denom(style.boxlower);
|
|
5897
|
+
const upper = scaleDenom(style.boxupper), lower = scaleDenom(style.boxlower);
|
|
5804
5898
|
const scale = scaleDenominatorAt(zoom);
|
|
5805
5899
|
return !!((upper && scale > upper) || (lower && scale < lower));
|
|
5806
5900
|
}
|
|
@@ -6133,8 +6227,9 @@
|
|
|
6133
6227
|
// functionally deleted.
|
|
6134
6228
|
const DIMMED_ICON_ALPHA = 8;
|
|
6135
6229
|
|
|
6136
|
-
// deck.gl
|
|
6137
|
-
//
|
|
6230
|
+
// deck.gl's IconLayer auto-packing atlas (icon-manager.ts, read and
|
|
6231
|
+
// measured on 8.9.35; the guards below are kept for 9.x, not re-measured
|
|
6232
|
+
// there) has NO eviction and NO cap of its own: its texture is 1024px wide (fixed) but
|
|
6138
6233
|
// grows TALLER via power-of-two resize forever, for every distinct icon
|
|
6139
6234
|
// id ever seen, for the lifetime of the layer — confirmed by reading
|
|
6140
6235
|
// that file directly. There is no "atlas full" code path; it just keeps
|
|
@@ -6207,6 +6302,93 @@
|
|
|
6207
6302
|
return { present, radii, offsets, fitScale, maxR };
|
|
6208
6303
|
}
|
|
6209
6304
|
|
|
6305
|
+
// A group while legend classes are marked (flat's MapTheme.markClass,
|
|
6306
|
+
// maptheme.js 14339-14420, evidence "isolate" — the default): the parts
|
|
6307
|
+
// of the other categories are hidden, the marked ones keep their own
|
|
6308
|
+
// size and move to the group's center (the SEQUENCE transform reset to
|
|
6309
|
+
// its scale only). Biggest first, so a smaller marked part stays on top.
|
|
6310
|
+
// null when no marked category is in the group.
|
|
6311
|
+
function isolatedBubblePackLayout(counts, size, marked) {
|
|
6312
|
+
const full = computeBubblePackLayout(counts, size);
|
|
6313
|
+
const keep = full.present.map((p, k) => k).filter(k => marked.has(full.present[k].i))
|
|
6314
|
+
.sort((a, b) => full.radii[b] - full.radii[a]);
|
|
6315
|
+
if (!keep.length) return null;
|
|
6316
|
+
return {
|
|
6317
|
+
present: keep.map(k => full.present[k]),
|
|
6318
|
+
radii: keep.map(k => full.radii[k]),
|
|
6319
|
+
offsets: keep.map(() => ({ x: 0, y: 0 })),
|
|
6320
|
+
fitScale: full.fitScale, maxR: full.maxR
|
|
6321
|
+
};
|
|
6322
|
+
}
|
|
6323
|
+
|
|
6324
|
+
// CHART|SYMBOL|SEQUENCE (flat maptheme.js 21184-21300, 21790-21925,
|
|
6325
|
+
// 22296-22395): one symbol per category part of a chart, in category
|
|
6326
|
+
// order or sorted (SORT: DOWN biggest first, UP smallest first), parts
|
|
6327
|
+
// of value 0 left out. radiusOf(v) is the part's radius (flat: max radius
|
|
6328
|
+
// · (v / max value)^(1/sizepow)). Placement:
|
|
6329
|
+
// - STAR: the first part is the center (star radius R = its radius); each
|
|
6330
|
+
// further part (with UP every part) sits on a circle around it, at
|
|
6331
|
+
// R + r (×1.5 EXPAND, ×2 EXPANDMAX, ×rangescale), stepping the angle
|
|
6332
|
+
// by f = 90° − asin(√((R+r)² − r²) / (R+r)) before and after it, so
|
|
6333
|
+
// neighbours touch; UP: radius R·(1 + parts/5), the angle step of the
|
|
6334
|
+
// 7th part on divided by 1 + 2r/maxRadius; with EXPAND|SORT|UP,
|
|
6335
|
+
// R = (EXPANDMAX ? 2 : 0.5) · maxRadius · √(biggest / max value)
|
|
6336
|
+
// (LINEAR/SIZEP1: linear), COMPRESS halves it (COMPRESSMAX quarter)
|
|
6337
|
+
// - HORZ: side by side from the center to the right
|
|
6338
|
+
// - otherwise (or CENTER): all on the center
|
|
6339
|
+
// Returns the parts in drawing order: { i, v, r, x, y } (pixels, y down).
|
|
6340
|
+
function sequenceLayout(counts, flags, radiusOf, opts = {}) {
|
|
6341
|
+
const has = f => flatFlag(flags, f);
|
|
6342
|
+
const word = w => [...flags].some(f => new RegExp('\\b' + w + '\\b').test(f));
|
|
6343
|
+
let order = counts.map((v, i) => ({ i, v }));
|
|
6344
|
+
if (word('SORT')) {
|
|
6345
|
+
if (word('UP')) order.sort((a, b) => a.v - b.v);
|
|
6346
|
+
else if (word('DOWN')) order.sort((a, b) => b.v - a.v);
|
|
6347
|
+
}
|
|
6348
|
+
const parts = [];
|
|
6349
|
+
const star = has('STAR') && !has('CENTER'), horz = has('HORZ') && !has('CENTER');
|
|
6350
|
+
const up = word('UP');
|
|
6351
|
+
const maxR = opts.maxRadius || 0;
|
|
6352
|
+
const nStarParts = opts.nParts || counts.length;
|
|
6353
|
+
let angle = 0, starR = null, x = 0;
|
|
6354
|
+
order.forEach((p, k) => {
|
|
6355
|
+
if (!p.v) return;
|
|
6356
|
+
const r = radiusOf(p.v);
|
|
6357
|
+
if (!(r > 0)) return;
|
|
6358
|
+
let px = 0, py = 0;
|
|
6359
|
+
if (star) {
|
|
6360
|
+
if (starR === null) {
|
|
6361
|
+
starR = r;
|
|
6362
|
+
if (has('EXPAND') && word('SORT') && up) {
|
|
6363
|
+
const big = order[order.length - 1].v, mv = opts.maxValue || big || 1;
|
|
6364
|
+
starR = (has('EXPANDMAX') ? 2 : 0.5) * maxR * (has('LINEAR') || has('SIZEP1') ? big / mv : Math.sqrt(big / mv));
|
|
6365
|
+
}
|
|
6366
|
+
if (has('COMPRESS')) starR *= has('COMPRESSMAX') ? 0.25 : 0.5;
|
|
6367
|
+
}
|
|
6368
|
+
if (parts.length > 0 || up) {
|
|
6369
|
+
const c = starR + r;
|
|
6370
|
+
const b = Math.sqrt(Math.max(0, c * c - r * r));
|
|
6371
|
+
let f = 90 - Math.asin(Math.min(1, b / c)) / Math.PI * 180;
|
|
6372
|
+
let dist = starR + r;
|
|
6373
|
+
if (up) {
|
|
6374
|
+
if (k > 5) f /= (1 + 2 * (r / (maxR || r)));
|
|
6375
|
+
dist = starR * (1 + nStarParts / 5);
|
|
6376
|
+
}
|
|
6377
|
+
if (opts.rangeScale) dist *= opts.rangeScale;
|
|
6378
|
+
else dist = has('EXPANDMAX') ? dist * 2 : (has('EXPAND') ? dist * 1.5 : dist);
|
|
6379
|
+
angle += f;
|
|
6380
|
+
px = Math.cos(angle / 180 * Math.PI) * dist;
|
|
6381
|
+
py = Math.sin(angle / 180 * Math.PI) * dist;
|
|
6382
|
+
angle += f;
|
|
6383
|
+
}
|
|
6384
|
+
} else if (horz) {
|
|
6385
|
+
x += r; px = x; x += r;
|
|
6386
|
+
}
|
|
6387
|
+
parts.push({ i: p.i, v: p.v, r, x: px, y: py });
|
|
6388
|
+
});
|
|
6389
|
+
return parts;
|
|
6390
|
+
}
|
|
6391
|
+
|
|
6210
6392
|
// ---------------------------------------------------------------
|
|
6211
6393
|
// Classification — pure functions (no LayerRuntime state): class
|
|
6212
6394
|
// breaks for a numeric field, and which class a value falls in.
|
|
@@ -7161,7 +7343,7 @@
|
|
|
7161
7343
|
key = `${snapped.x}:${snapped.y}`;
|
|
7162
7344
|
}
|
|
7163
7345
|
let cell = cells.get(key);
|
|
7164
|
-
if (!cell) { cell = { snapped, sumX: 0, sumY: 0, n: 0, value: 0, classValue: 0, value100: 0, series: null, first: f }; cells.set(key, cell); }
|
|
7346
|
+
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); }
|
|
7165
7347
|
cell.sumX += p.x; cell.sumY += p.y; cell.n++;
|
|
7166
7348
|
cell.value += f.properties.value;
|
|
7167
7349
|
if (f.properties.classValue !== undefined) cell.classValue += f.properties.classValue;
|
|
@@ -7183,6 +7365,9 @@
|
|
|
7183
7365
|
? { cluster: true, point_count: cell.n, value: cell.value, ...(hasClass ? { classValue: cell.classValue } : {}) }
|
|
7184
7366
|
: cell.first.properties;
|
|
7185
7367
|
if (cell.series && cell.n > 1) props = Object.assign({}, props, { series: cell.series });
|
|
7368
|
+
// a field aggregation's cell key (the field value): the categories of
|
|
7369
|
+
// one cell are merged by it (groupCoLocated), not by their positions
|
|
7370
|
+
if (field) props = Object.assign({}, props, { aggKey: cell.key });
|
|
7186
7371
|
if (post) {
|
|
7187
7372
|
props = Object.assign({}, props);
|
|
7188
7373
|
if (hasClass) props.classValue = post(cell.n > 1 ? cell.classValue : props.classValue, cell.value100, props.series);
|
|
@@ -7215,7 +7400,7 @@
|
|
|
7215
7400
|
}
|
|
7216
7401
|
}
|
|
7217
7402
|
|
|
7218
|
-
function groupCoLocated(clusterFeatures, zoom, clusterRadiusPx, nCategories, flags) {
|
|
7403
|
+
function groupCoLocated(clusterFeatures, zoom, clusterRadiusPx, nCategories, flags, atCellCenter = false) {
|
|
7219
7404
|
// Merge tolerance MUST track the same theme-driven, scale-dependent
|
|
7220
7405
|
// aggregation width the clustering itself just used (clusterRadiusPx,
|
|
7221
7406
|
// set by _ensureClusterIndices right before this runs) — not an
|
|
@@ -7237,9 +7422,11 @@
|
|
|
7237
7422
|
// recomputes ptPos as the mean of ptPosA regardless of which
|
|
7238
7423
|
// grid produced the grouping key).
|
|
7239
7424
|
const snapped = cellPx ? snapToAggregationGrid(p.x, p.y, cellPx, flags, zoom) : p;
|
|
7240
|
-
|
|
7425
|
+
// a field aggregation's categories each sit at the mean of their own
|
|
7426
|
+
// records — merged by the field value, not by (float) position
|
|
7427
|
+
const key = f.properties.aggKey != null ? f.properties.aggKey : `${snapped.x}:${snapped.y}`;
|
|
7241
7428
|
let cell = cells.get(key);
|
|
7242
|
-
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); }
|
|
7429
|
+
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); }
|
|
7243
7430
|
cell.sumX += p.x; cell.sumY += p.y; cell.n++;
|
|
7244
7431
|
cell.counts[f.properties.cat] += f.properties.value;
|
|
7245
7432
|
if (f.properties.classValue !== undefined) cell.classTotal = (cell.classTotal || 0) + f.properties.classValue;
|
|
@@ -7250,7 +7437,8 @@
|
|
|
7250
7437
|
cell.recordCounts[f.properties.cat] += (f.properties.point_count || 1);
|
|
7251
7438
|
});
|
|
7252
7439
|
return Array.from(cells.values()).map(cell => {
|
|
7253
|
-
|
|
7440
|
+
// without RELOCATE flat's cell item sits at the cell center
|
|
7441
|
+
const ll = atCellCenter && cellPx ? worldPixelToLngLat(cell.cx, cell.cy, zoom) : worldPixelToLngLat(cell.sumX / cell.n, cell.sumY / cell.n, zoom);
|
|
7254
7442
|
return {
|
|
7255
7443
|
geometry: { type: 'Point', coordinates: [ll.lng, ll.lat] },
|
|
7256
7444
|
properties: { counts: cell.counts, total: cell.counts.reduce((a, c) => a + c, 0), recordCounts: cell.recordCounts, ...(cell.classTotal !== undefined ? { classTotal: cell.classTotal } : {}) }
|
|
@@ -7405,7 +7593,7 @@
|
|
|
7405
7593
|
this._gridRefLat = (minLat <= maxLat) ? (minLat + maxLat) / 2 : 0;
|
|
7406
7594
|
}
|
|
7407
7595
|
|
|
7408
|
-
if (this.flags.has('DOMINANT') && this.binding.value) {
|
|
7596
|
+
if (this.flags.has('DOMINANT') && this.binding.value && !isAggregatedCategoricalChoropleth(this)) {
|
|
7409
7597
|
// .type("CHOROPLETH|DOMINANT") — a MULTI-field bound value
|
|
7410
7598
|
// (binding.value is a pipe-joined field list, e.g. one CSV column
|
|
7411
7599
|
// per age band for the same year/sex — same pipe convention
|
|
@@ -7755,6 +7943,26 @@
|
|
|
7755
7943
|
}
|
|
7756
7944
|
|
|
7757
7945
|
_rebuildActiveFeatures() {
|
|
7946
|
+
// An AGGREGATE CATEGORICAL choropleth filters the RECORDS of each
|
|
7947
|
+
// polygon (as its .filter() does before the join), then sums them —
|
|
7948
|
+
// not the polygons by their first record
|
|
7949
|
+
if (isAggregatedCategoricalChoropleth(this)) {
|
|
7950
|
+
const expr = this._runtimeFilterExpr;
|
|
7951
|
+
const clauses = Array.from(this.facetFilters.entries());
|
|
7952
|
+
if (!expr && !clauses.length) { this._activeFeatures = null; return; }
|
|
7953
|
+
const pred = expr ? flatFilterPredicate(String(expr)) : null;
|
|
7954
|
+
this._activeFeatures = this.features.map(f => {
|
|
7955
|
+
const rows = f.properties && f.properties[AGGREGATED_ROWS];
|
|
7956
|
+
if (!rows) return f;
|
|
7957
|
+
const kept = rows.filter(r => (!pred || pred(r)) && clauses.every(([field, clause]) => matchesFacetClause(r[field], clause)));
|
|
7958
|
+
const properties = kept.length ? Object.assign({}, kept[0]) : {};
|
|
7959
|
+
if (kept.length) Object.defineProperty(properties, AGGREGATED_ROWS, { value: kept });
|
|
7960
|
+
const out = { type: 'Feature', geometry: f.geometry, properties };
|
|
7961
|
+
if (f._flatAreaKm2 !== undefined) out._flatAreaKm2 = f._flatAreaKm2;
|
|
7962
|
+
return out;
|
|
7963
|
+
});
|
|
7964
|
+
return;
|
|
7965
|
+
}
|
|
7758
7966
|
// Base set: this.features narrowed by the runtime WHERE-filter
|
|
7759
7967
|
// (changeThemeStyle's own mechanism, see _runtimeFilterExpr's own
|
|
7760
7968
|
// comment) if one is active — reusing applyWhereFilter, the SAME
|
|
@@ -8011,7 +8219,7 @@
|
|
|
8011
8219
|
return this._glowIconCache.get(key);
|
|
8012
8220
|
}
|
|
8013
8221
|
|
|
8014
|
-
_buildBubbleIcon(counts, colors) {
|
|
8222
|
+
_buildBubbleIcon(counts, colors, marked = null) {
|
|
8015
8223
|
// The cache key quantizes each count's RATIO to the group's total,
|
|
8016
8224
|
// not the raw count — computeBubblePackLayout's radii are purely
|
|
8017
8225
|
// sqrt(c/total), so two clusters with wildly different absolute
|
|
@@ -8036,13 +8244,18 @@
|
|
|
8036
8244
|
// smooth.
|
|
8037
8245
|
const total = counts.reduce((a, b) => a + b, 0) || 1;
|
|
8038
8246
|
const RATIO_BUCKETS = 15;
|
|
8039
|
-
|
|
8247
|
+
// an isolated group shows only its marked parts, alone and at their
|
|
8248
|
+
// own size: their radius (√share, 1/100 steps) keys the icon, not the
|
|
8249
|
+
// coarse share buckets that can put a small marked part at 0
|
|
8250
|
+
const key = counts.map(c => Math.round((c / total) * RATIO_BUCKETS)).join('-') + (marked
|
|
8251
|
+
? '|iso:' + counts.map((c, i) => (c > 0 && marked.has(i) ? i + ':' + Math.round(Math.sqrt(c / total) * 100) : null)).filter(Boolean).join(',')
|
|
8252
|
+
: '');
|
|
8040
8253
|
if (this._iconCache.has(key)) return this._iconCache.get(key);
|
|
8041
8254
|
const size = BUBBLE_ICON_SIZE;
|
|
8042
8255
|
const canvas = document.createElement('canvas');
|
|
8043
8256
|
canvas.width = size; canvas.height = size;
|
|
8044
8257
|
const ctx = canvas.getContext('2d');
|
|
8045
|
-
const { present, radii, offsets, fitScale } = computeBubblePackLayout(counts, size);
|
|
8258
|
+
const { present, radii, offsets, fitScale } = (marked && isolatedBubblePackLayout(counts, size, marked)) || computeBubblePackLayout(counts, size);
|
|
8046
8259
|
const cx = size / 2, cy = size / 2;
|
|
8047
8260
|
present.forEach((p, i) => {
|
|
8048
8261
|
ctx.beginPath();
|
|
@@ -8341,6 +8554,15 @@
|
|
|
8341
8554
|
// counts as marked if ANY of its constituent categories is marked —
|
|
8342
8555
|
// a mixed cluster shouldn't dim just because one of several
|
|
8343
8556
|
// categories inside it happens to be unmarked.
|
|
8557
|
+
// the marked classes a group isolates to (isolatedBubblePackLayout),
|
|
8558
|
+
// or null: none marked, a page's own mark handler, or evidence
|
|
8559
|
+
// "isolate_gray" (keeps every part, dimmed via _iconAlpha)
|
|
8560
|
+
_groupIsolation(d) {
|
|
8561
|
+
if (!d.properties.counts || !this._markedClasses.size || this._onMarksChanged) return null;
|
|
8562
|
+
if (String(this.style.evidence || 'isolate') === 'isolate_gray') return null;
|
|
8563
|
+
return d.properties.counts.some((c, i) => c > 0 && this._markedClasses.has(i)) ? this._markedClasses : null;
|
|
8564
|
+
}
|
|
8565
|
+
|
|
8344
8566
|
_iconAlpha(d) {
|
|
8345
8567
|
if (!this._markedClasses.size) return 255;
|
|
8346
8568
|
if (d.properties.counts) {
|
|
@@ -8437,8 +8659,11 @@
|
|
|
8437
8659
|
// unlike CHOROPLETH, there's no per-feature classification here.
|
|
8438
8660
|
// flat puts fillopacity into the shape's fill-opacity only
|
|
8439
8661
|
// (maptheme.js 13407, 14071-14078): the outline stays opaque
|
|
8440
|
-
|
|
8441
|
-
|
|
8662
|
+
// (a fill color's own alpha — rgba(…, 0.5) — times fillopacity)
|
|
8663
|
+
getFillColor: filled ? [...hexOrNamedToRgb(raw).slice(0, 3), Math.round(255 * cssColorAlpha(raw) * (styleNum(this.style.fillopacity) || 1))] : [0, 0, 0, 0],
|
|
8664
|
+
getLineColor: this.style.linecolor
|
|
8665
|
+
? withAlpha(hexOrNamedToRgb(styleLineColor(this.style.linecolor)), cssColorAlpha(styleLineColor(this.style.linecolor)))
|
|
8666
|
+
: [130, 130, 130],
|
|
8442
8667
|
lineWidthMinPixels: styleNum(this.style.linewidth) || 1,
|
|
8443
8668
|
// Confirmed live (2026-09-21, deck.gl v9.4/MapLibre-globe upgrade):
|
|
8444
8669
|
// this layer's fill rendered with a moire/hatching pattern of
|
|
@@ -8507,9 +8732,10 @@
|
|
|
8507
8732
|
// — plain/PERCENTOFMEAN/DEVIATION); or COMPOSECOLOR's per-polygon
|
|
8508
8733
|
// BLEND across the same piped fields (_resolveComposedColor — no
|
|
8509
8734
|
// single winning class, so no `cat`/tooltip chart line, just the
|
|
8510
|
-
// blended color directly)
|
|
8511
|
-
//
|
|
8512
|
-
//
|
|
8735
|
+
// blended color directly); AGGREGATE CATEGORICAL on one field — the
|
|
8736
|
+
// dominant category of each polygon's records (aggregatedCategoricalClass).
|
|
8737
|
+
// A plain CATEGORICAL choropleth (exact match of one row's value) is not
|
|
8738
|
+
// implemented. Geometry + properties are already the joined
|
|
8513
8739
|
// FeatureCollection from joinChoroplethFeatures. `zoom` is only used
|
|
8514
8740
|
// for style.fillopacity:"auto" (see resolveAutoFillOpacity) — every
|
|
8515
8741
|
// other branch here is zoom-independent.
|
|
@@ -8532,7 +8758,37 @@
|
|
|
8532
8758
|
// DOPACITY/COMPOSECOLOR branches.
|
|
8533
8759
|
const dopacityActive = !isComposeColor &&
|
|
8534
8760
|
(this.flags.has('DOPACITY') || this.flags.has('DOPACITYMIN') || this.flags.has('DOPACITYMAX') || this.flags.has('DOPACITYMINMAX'));
|
|
8761
|
+
// AGGREGATE CATEGORICAL: every polygon's parts and dominant class
|
|
8762
|
+
// (aggregatedCategoricalClass), the biggest part per class over all
|
|
8763
|
+
// polygons for the opacity (flat's nMaxA)
|
|
8764
|
+
const aggCat = isAggregatedCategoricalChoropleth(this) && this.categoryIndexByLabel;
|
|
8765
|
+
let aggOf = null, aggMaxA = null;
|
|
8766
|
+
if (aggCat) {
|
|
8767
|
+
const nCats = this.categoryLabels.length;
|
|
8768
|
+
aggOf = new Map();
|
|
8769
|
+
aggMaxA = new Array(nCats).fill(0);
|
|
8770
|
+
source.forEach(f => {
|
|
8771
|
+
const rows = f.properties && f.properties[AGGREGATED_ROWS];
|
|
8772
|
+
if (!rows) return;
|
|
8773
|
+
const agg = aggregatedCategoricalClass(rows, this.binding, this.flags, this.categoryIndexByLabel, nCats);
|
|
8774
|
+
if (!agg) return;
|
|
8775
|
+
aggOf.set(f, agg);
|
|
8776
|
+
agg.parts.forEach((v, i) => { if (v > aggMaxA[i]) aggMaxA[i] = v; });
|
|
8777
|
+
});
|
|
8778
|
+
}
|
|
8535
8779
|
const data = source.map(f => {
|
|
8780
|
+
if (aggCat) {
|
|
8781
|
+
const agg = aggOf.get(f);
|
|
8782
|
+
return {
|
|
8783
|
+
type: 'Feature',
|
|
8784
|
+
geometry: f.geometry,
|
|
8785
|
+
properties: {
|
|
8786
|
+
// no part above 0: no item in flat, not painted
|
|
8787
|
+
value: agg ? agg.value : undefined, raw: agg ? f.properties : {}, cat: agg ? agg.index : null,
|
|
8788
|
+
dopacityAlpha: agg && dopacityActive ? dominantDopacityAlpha(this.style, this.flags, agg.value, aggMaxA[agg.index]) : null
|
|
8789
|
+
}
|
|
8790
|
+
};
|
|
8791
|
+
}
|
|
8536
8792
|
if (isComposeColor) {
|
|
8537
8793
|
return {
|
|
8538
8794
|
type: 'Feature',
|
|
@@ -8571,7 +8827,14 @@
|
|
|
8571
8827
|
// evidence mode "isolate", the default (7715) — or grayed with
|
|
8572
8828
|
// style.evidence "isolate_gray". ("highlight", flat's highlight list,
|
|
8573
8829
|
// is shown grayed too here.) COMPOSECOLOR has no classes.
|
|
8574
|
-
|
|
8830
|
+
// linecolor "none": no outline; a class color "none" (an explicit
|
|
8831
|
+
// colorscheme list) leaves its polygons unpainted, an rgba() class
|
|
8832
|
+
// color keeps its alpha — as flat's SVG fill does
|
|
8833
|
+
const lineColorRaw = this.style.linecolor ? styleLineColor(this.style.linecolor) : null;
|
|
8834
|
+
const lineRgb = lineColorRaw ? withAlpha(hexOrNamedToRgb(lineColorRaw), cssColorAlpha(lineColorRaw)) : [255, 255, 255];
|
|
8835
|
+
const cs = this.style.colorscheme;
|
|
8836
|
+
const classAlpha = Array.isArray(cs) && cs.length && !/^\d+$/.test(String(cs[0])) ? cs.map(cssColorAlpha) : null;
|
|
8837
|
+
const alphaOfClass = i => (classAlpha && i != null && classAlpha[i] != null ? classAlpha[i] : 1);
|
|
8575
8838
|
const evidenceMode = String(this.style.evidence || 'isolate');
|
|
8576
8839
|
const unmarkedEvidence = d => {
|
|
8577
8840
|
if (!this._markedClasses.size || this._onMarksChanged || this._markedClasses.has(d.properties.cat)) return null;
|
|
@@ -8591,7 +8854,8 @@
|
|
|
8591
8854
|
const evidence = unmarkedEvidence(d);
|
|
8592
8855
|
if (evidence === 'hide') return [0, 0, 0, 0];
|
|
8593
8856
|
if (evidence === 'gray') return [...grayOf(rgb), 90];
|
|
8594
|
-
|
|
8857
|
+
const a = alphaOfClass(d.properties.cat);
|
|
8858
|
+
return d.properties.dopacityAlpha != null ? [...rgb.slice(0, 3), Math.round(d.properties.dopacityAlpha * a * 255)] : withAlpha(rgb, a);
|
|
8595
8859
|
},
|
|
8596
8860
|
// a per-polygon accessor only while a class is isolated
|
|
8597
8861
|
getLineColor: this._markedClasses.size && !this._onMarksChanged && evidenceMode === 'isolate'
|
|
@@ -9535,6 +9799,14 @@
|
|
|
9535
9799
|
});
|
|
9536
9800
|
|
|
9537
9801
|
const doRelocate = this.flags.has('RELOCATE');
|
|
9802
|
+
// SEQUENCE: flat's AGGREGATE cell is ONE chart with a part per
|
|
9803
|
+
// category (RELOCATE only moves it to the records' mean position) —
|
|
9804
|
+
// every category of a cell, single records included, in one group
|
|
9805
|
+
if (flatFlag(this.flags, 'SEQUENCE') && this.flags.has('CATEGORICAL')) {
|
|
9806
|
+
const groups = groupCoLocated(clusterFeatures.concat(individual), effectiveZoom, this._clusterRadiusPx,
|
|
9807
|
+
this.categoryLabels ? this.categoryLabels.length : 1, this.flags, !doRelocate);
|
|
9808
|
+
return { individual: [], groups };
|
|
9809
|
+
}
|
|
9538
9810
|
// without RELOCATE a cell's item already sits at the cell center
|
|
9539
9811
|
// (aggregateOnGrid)
|
|
9540
9812
|
const groups = doRelocate ? this._groupCoLocated(clusterFeatures, effectiveZoom) : clusterFeatures.map(f => {
|
|
@@ -9799,6 +10071,9 @@
|
|
|
9799
10071
|
if (userLayers) return layers.concat(userLayers);
|
|
9800
10072
|
}
|
|
9801
10073
|
if (this.flags.has('LABEL')) return layers.concat(this._buildLabelChartLayers(combined, zoom, liveZoom, sizeValueOf));
|
|
10074
|
+
if (flatFlag(this.flags, 'SEQUENCE') && this.flags.has('CATEGORICAL') && this.flags.has('AGGREGATE')) {
|
|
10075
|
+
return layers.concat(this._buildSequenceChartLayers(combined, zoom, liveZoom));
|
|
10076
|
+
}
|
|
9802
10077
|
const fillOpacity = styleNum(this.style.fillopacity) || 0.85;
|
|
9803
10078
|
// .style({linecolor, linewidth}) — an individual icon's own border,
|
|
9804
10079
|
// see _buildSingleIcon's own comment. Real ixmaps-flat symbol
|
|
@@ -9850,7 +10125,7 @@
|
|
|
9850
10125
|
id: `ix-glow-${this.name}-g${this._iconGeneration}`,
|
|
9851
10126
|
data: combined, pickable: false,
|
|
9852
10127
|
getPosition: d => d.geometry.coordinates,
|
|
9853
|
-
getIcon: d => this._getGlowIcon(this.categoryColorsRgb[d.properties.counts ? dominant(d.properties.counts) : d.properties.cat]),
|
|
10128
|
+
getIcon: d => this._getGlowIcon(this.categoryColorsRgb[d.properties.counts ? dominant(this._groupIsolation(d) ? d.properties.counts.map((c, i) => (this._markedClasses.has(i) ? c : 0)) : d.properties.counts) : d.properties.cat]),
|
|
9854
10129
|
getSize: d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue) * (d.properties.counts ? 9 : 11),
|
|
9855
10130
|
getColor: d => [255, 255, 255, this._iconAlpha(d)],
|
|
9856
10131
|
...(alignOf.active ? { getPixelOffset: alignOf } : {}),
|
|
@@ -10000,7 +10275,7 @@
|
|
|
10000
10275
|
data: combined, pickable: true,
|
|
10001
10276
|
getPosition: d => d.geometry.coordinates,
|
|
10002
10277
|
getIcon: d => d.properties.counts
|
|
10003
|
-
? this._buildBubbleIcon(d.properties.counts, this.categoryColorsRgb)
|
|
10278
|
+
? this._buildBubbleIcon(d.properties.counts, this.categoryColorsRgb, this._groupIsolation(d))
|
|
10004
10279
|
: this._buildSingleIcon(this.categoryColorsRgb[d.properties.cat], fillOpacity, this._resolveSymbolShape(d.properties), singleBorderColorRgb, singleBorderWidthPx),
|
|
10005
10280
|
getSize: d => valueRadius(sizeValueOf(d), liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue) * 2,
|
|
10006
10281
|
getColor: d => [255, 255, 255, this._iconAlpha(d)],
|
|
@@ -10073,7 +10348,8 @@
|
|
|
10073
10348
|
const outerRadiusPx = valueRadius(d.properties.total, liveZoom, this.style, this.mapOptions, this.flags, this._maxSizeValue);
|
|
10074
10349
|
const iconSizePx = outerRadiusPx * 2; // matches the cluster IconLayer's own getSize (*2) below
|
|
10075
10350
|
const pxPerCanvasUnit = iconSizePx / BUBBLE_ICON_SIZE;
|
|
10076
|
-
const
|
|
10351
|
+
const iso = this._groupIsolation(d);
|
|
10352
|
+
const { present, radii, offsets, fitScale } = (iso && isolatedBubblePackLayout(counts, BUBBLE_ICON_SIZE, iso)) || computeBubblePackLayout(counts, BUBBLE_ICON_SIZE);
|
|
10077
10353
|
|
|
10078
10354
|
present.forEach((p, i) => {
|
|
10079
10355
|
// one part for a range-classed cell: its class value, if separate
|
|
@@ -10110,6 +10386,164 @@
|
|
|
10110
10386
|
return layers;
|
|
10111
10387
|
}
|
|
10112
10388
|
|
|
10389
|
+
// flat's nAllMaxValue for a SEQUENCE chart (maptheme.js 13211-13215):
|
|
10390
|
+
// the biggest category part of any chart at this aggregation — over
|
|
10391
|
+
// the whole dataset, not the view; cached per zoom
|
|
10392
|
+
_sequenceMaxValue(zoom) {
|
|
10393
|
+
if (this._seqMaxFor !== this._clusterIndices) { this._seqMaxFor = this._clusterIndices; this._seqMax = new Map(); }
|
|
10394
|
+
if (!this._seqMax.has(zoom)) {
|
|
10395
|
+
const { groups } = this._computeAggregatedItems([-180, -90, 180, 90], zoom);
|
|
10396
|
+
let max = 0;
|
|
10397
|
+
groups.forEach(g => g.properties.counts.forEach(c => { if (c > max) max = c; }));
|
|
10398
|
+
this._seqMax.set(zoom, max || 1);
|
|
10399
|
+
}
|
|
10400
|
+
return this._seqMax.get(zoom);
|
|
10401
|
+
}
|
|
10402
|
+
|
|
10403
|
+
// CHART|SYMBOL|SEQUENCE on an AGGREGATE CATEGORICAL theme: each cell is
|
|
10404
|
+
// one chart of a symbol per category part (sequenceLayout), as flat's
|
|
10405
|
+
// SYMBOL branch draws it (maptheme.js 21122-22400):
|
|
10406
|
+
// - part radius: max radius · (v / max value)^(1/sizepow), max value =
|
|
10407
|
+
// normalsizevalue, else the biggest part of any chart (21793)
|
|
10408
|
+
// - circle in the category color at fillopacity, stroked in linecolor
|
|
10409
|
+
// linewidth · unit · min(2r / max radius, 0.2) wide (21966)
|
|
10410
|
+
// - GLOW (and AURA): with SORT only around the first part, else around
|
|
10411
|
+
// every part (22009)
|
|
10412
|
+
// - marked legend classes: the other parts hidden, the marked ones
|
|
10413
|
+
// back on the center (14339-14420, evidence "isolate"); with
|
|
10414
|
+
// "isolate_gray" the others dimmed
|
|
10415
|
+
// - STAR: the center part drawn last, on top of its satellites (23145)
|
|
10416
|
+
// - VALUES: each part's value (COUNT: its records; valuefield: the class name)
|
|
10417
|
+
// - align / offsets as the SYMBOL branch: half size the normal radius
|
|
10418
|
+
// A part's pick object carries its chart's properties (tooltip).
|
|
10419
|
+
_buildSequenceChartLayers(charts, zoom, liveZoom) {
|
|
10420
|
+
const st = this.style;
|
|
10421
|
+
const seqMax = this._sequenceMaxValue(zoom);
|
|
10422
|
+
const partRadius = v => valueRadius(v, liveZoom, st, this.mapOptions, this.flags, seqMax);
|
|
10423
|
+
const unit = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(st.scale) || 1);
|
|
10424
|
+
const normalR = NORMAL_RADIUS_PX * unit;
|
|
10425
|
+
const nCats = this.categoryLabels ? this.categoryLabels.length : 1;
|
|
10426
|
+
const marking = this._markedClasses.size && !this._onMarksChanged;
|
|
10427
|
+
const gray = marking && String(st.evidence || 'isolate') === 'isolate_gray';
|
|
10428
|
+
const sorted = [...this.flags].some(f => /\bSORT\b/.test(f));
|
|
10429
|
+
const fillOpacity = styleNum(st.fillopacity) || 0.85;
|
|
10430
|
+
const borderRgb = st.linecolor && styleLineColor(st.linecolor) !== 'none' ? hexOrNamedToRgb(styleLineColor(st.linecolor)) : null;
|
|
10431
|
+
const lineWidth = styleNum(st.linewidth) || 1;
|
|
10432
|
+
const symbols = Array.isArray(st.symbols) ? st.symbols : null;
|
|
10433
|
+
const shapeOf = i => { const sh = symbols && (symbols[i] || symbols[0]); return /^(circle|square|diamond|triangle)$/.test(sh) ? sh : 'circle'; };
|
|
10434
|
+
const colorOf = i => this.categoryColorsRgb[i] || [128, 128, 128];
|
|
10435
|
+
const parts = [], glows = [];
|
|
10436
|
+
for (const d of charts) {
|
|
10437
|
+
let layout = sequenceLayout(d.properties.counts, this.flags, partRadius, { maxRadius: normalR, maxValue: seqMax, nParts: nCats, rangeScale: styleNum(st.rangescale) || 0 });
|
|
10438
|
+
if (!layout.length) continue;
|
|
10439
|
+
// GLOW belongs to the first part (sorted) or to every part, and
|
|
10440
|
+
// hides with it when its class is not marked
|
|
10441
|
+
const glowParts = new Set(sorted ? [layout[0].i] : layout.map(p => p.i));
|
|
10442
|
+
if (marking && !gray) {
|
|
10443
|
+
layout = layout.filter(p => this._markedClasses.has(p.i)).map(p => ({ ...p, x: 0, y: 0 }));
|
|
10444
|
+
if (!layout.length) continue;
|
|
10445
|
+
}
|
|
10446
|
+
// the chart's own ptNull.y: the last part's radius + 5 units (23204)
|
|
10447
|
+
d._seqLast = layout[layout.length - 1].r;
|
|
10448
|
+
const items = layout.map(p => {
|
|
10449
|
+
const item = { geometry: d.geometry, properties: d.properties, chart: d, part: p };
|
|
10450
|
+
if (glowParts.has(p.i)) glows.push(item);
|
|
10451
|
+
return item;
|
|
10452
|
+
});
|
|
10453
|
+
// STAR: the first part (its center) is put on top at the end (23145)
|
|
10454
|
+
if (flatFlag(this.flags, 'STAR') && items.length > 1) items.push(items.shift());
|
|
10455
|
+
parts.push(...items);
|
|
10456
|
+
}
|
|
10457
|
+
const alignOf = this._chartAlignFn(liveZoom, d => (d.chart || d)._seqLast || 0);
|
|
10458
|
+
const offsetOf = item => { const o = alignOf(item.chart); return [o[0] + item.part.x, o[1] + item.part.y]; };
|
|
10459
|
+
const alphaOf = item => (gray && !this._markedClasses.has(item.part.i) ? DIMMED_ICON_ALPHA : 255);
|
|
10460
|
+
const layers = [];
|
|
10461
|
+
if (glows.length && this.flags.has('GLOW') && !glowHiddenByScale(st, liveZoom)) {
|
|
10462
|
+
layers.push(new IconLayer({
|
|
10463
|
+
id: `ix-glow-${this.name}-g${this._iconGeneration}`,
|
|
10464
|
+
data: glows, pickable: false,
|
|
10465
|
+
getPosition: d => d.geometry.coordinates,
|
|
10466
|
+
getIcon: d => this._getGlowIcon(colorOf(d.part.i)),
|
|
10467
|
+
getSize: d => d.part.r * 11,
|
|
10468
|
+
getPixelOffset: offsetOf,
|
|
10469
|
+
getColor: d => [255, 255, 255, alphaOf(d)],
|
|
10470
|
+
sizeUnits: 'pixels', billboard: true,
|
|
10471
|
+
parameters: ICON_LAYER_GLOBE_PARAMETERS
|
|
10472
|
+
}));
|
|
10473
|
+
} else if (glows.length && this.flags.has('AURA') && !glowHiddenByScale(st, liveZoom)) {
|
|
10474
|
+
layers.push(new IconLayer({
|
|
10475
|
+
id: `ix-aura-${this.name}-g${this._iconGeneration}`,
|
|
10476
|
+
data: glows, pickable: false,
|
|
10477
|
+
getPosition: d => d.geometry.coordinates,
|
|
10478
|
+
getIcon: d => this._buildSingleIcon(borderRgb || colorOf(d.part.i), 0.3, 'circle', null, 0),
|
|
10479
|
+
getSize: d => d.part.r * 2 * 1.3,
|
|
10480
|
+
getPixelOffset: offsetOf,
|
|
10481
|
+
getColor: d => [255, 255, 255, alphaOf(d)],
|
|
10482
|
+
sizeUnits: 'pixels', billboard: true,
|
|
10483
|
+
parameters: ICON_LAYER_GLOBE_PARAMETERS
|
|
10484
|
+
}));
|
|
10485
|
+
}
|
|
10486
|
+
layers.push(new IconLayer({
|
|
10487
|
+
id: `ix-bubbles-${this.name}-g${this._iconGeneration}`,
|
|
10488
|
+
data: parts, pickable: true,
|
|
10489
|
+
getPosition: d => d.geometry.coordinates,
|
|
10490
|
+
// the stroke, in the icon raster's pixels (the icon is drawn at 2r)
|
|
10491
|
+
getIcon: d => this._buildSingleIcon(colorOf(d.part.i), fillOpacity, shapeOf(d.part.i), borderRgb,
|
|
10492
|
+
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),
|
|
10493
|
+
getSize: d => d.part.r * 2,
|
|
10494
|
+
getPixelOffset: offsetOf,
|
|
10495
|
+
getColor: d => [255, 255, 255, alphaOf(d)],
|
|
10496
|
+
sizeUnits: 'pixels', billboard: true,
|
|
10497
|
+
parameters: ICON_LAYER_GLOBE_PARAMETERS
|
|
10498
|
+
}));
|
|
10499
|
+
// VALUES on each part (maptheme.js 22188-22250): the part's value — with
|
|
10500
|
+
// COUNT its record count, with valuefield on the categorical field the
|
|
10501
|
+
// class name — formatted with valuedecimals (else none) plus the unit;
|
|
10502
|
+
// font min(1, 3.2 / L) · r · valuescale, L the length of the theme's
|
|
10503
|
+
// biggest value text (szMaxText), normal weight, in valuecolor /
|
|
10504
|
+
// textcolor, else ChartColors.textColor of the part color
|
|
10505
|
+
if (this.flags.has('VALUES') && !valuesHiddenByScale(st, zoom)) {
|
|
10506
|
+
const valueScale = styleNum(st.valuescale) || 1;
|
|
10507
|
+
const { noBreaks } = this._valueTextOpts();
|
|
10508
|
+
const units = st.units ? String(st.units) : '';
|
|
10509
|
+
const unit = units ? (units[0] === '.' ? '' : ' ') + units : '';
|
|
10510
|
+
const unitText = unit.length <= 5 ? unit : '';
|
|
10511
|
+
const dec = styleNum(st.valuedecimals) || 0;
|
|
10512
|
+
const fmt = v => flatGroupedValue(v, dec, noBreaks) + unitText;
|
|
10513
|
+
const maxLen = fmt(seqMax).length;
|
|
10514
|
+
const textOverride = st.valuecolor || st.textcolor;
|
|
10515
|
+
const counting = flatFlag(this.flags, 'COUNT');
|
|
10516
|
+
const labels = [];
|
|
10517
|
+
parts.forEach(d => {
|
|
10518
|
+
const rec = this._categoryValueRecord(d.part.i);
|
|
10519
|
+
const name = rec ? String(rec[st.valuefield]) : null;
|
|
10520
|
+
const t = counting && d.properties.recordCounts ? d.properties.recordCounts[d.part.i] : d.part.v;
|
|
10521
|
+
const text = name != null ? name : fmt(t);
|
|
10522
|
+
const L = Math.max((name != null ? name.length : 0) + 1, maxLen);
|
|
10523
|
+
const fontSize = Math.min(1, 3.2 / L) * d.part.r * valueScale;
|
|
10524
|
+
if (fontSize > VALUES_MIN_FONT_PX) {
|
|
10525
|
+
labels.push({ geometry: d.geometry, text, fontSize, pixelOffset: offsetOf(d), alpha: alphaOf(d),
|
|
10526
|
+
color: textOverride ? hexOrNamedToRgb(textOverride) : flatChartTextRgb(colorOf(d.part.i)) });
|
|
10527
|
+
}
|
|
10528
|
+
});
|
|
10529
|
+
if (labels.length) {
|
|
10530
|
+
layers.push(new TextLayer({
|
|
10531
|
+
id: `ix-cluster-values-${this.name}`,
|
|
10532
|
+
data: labels, pickable: false,
|
|
10533
|
+
getPosition: d => d.geometry.coordinates,
|
|
10534
|
+
getText: d => d.text,
|
|
10535
|
+
getSize: d => d.fontSize,
|
|
10536
|
+
getColor: d => [...d.color.slice(0, 3), d.alpha],
|
|
10537
|
+
getPixelOffset: d => d.pixelOffset,
|
|
10538
|
+
sizeUnits: 'pixels', fontFamily: 'arial', fontWeight: 'normal',
|
|
10539
|
+
characterSet: 'auto',
|
|
10540
|
+
getTextAnchor: 'middle', getAlignmentBaseline: 'center'
|
|
10541
|
+
}));
|
|
10542
|
+
}
|
|
10543
|
+
}
|
|
10544
|
+
return layers;
|
|
10545
|
+
}
|
|
10546
|
+
|
|
10113
10547
|
// flat's chart position for this build (flatChartAlignOffset): a
|
|
10114
10548
|
// function item → pixel offset, the same for every part of one chart
|
|
10115
10549
|
// (symbol, glow, shadow, box, title, value text). unit is flat's
|
|
@@ -10353,6 +10787,23 @@
|
|
|
10353
10787
|
}
|
|
10354
10788
|
}
|
|
10355
10789
|
|
|
10790
|
+
// a color's own opacity, 0..1, as SVG paints it in flat: "none" (SVG's
|
|
10791
|
+
// no paint — a polygon with only its outline, or a class not painted)
|
|
10792
|
+
// 0, rgba()/hsla() its alpha, #rrggbbaa / #rgba its alpha digits, else 1
|
|
10793
|
+
function cssColorAlpha(v) {
|
|
10794
|
+
if (typeof v !== 'string') return 1;
|
|
10795
|
+
const c = v.trim().toLowerCase();
|
|
10796
|
+
if (c === 'none' || c === 'transparent') return 0;
|
|
10797
|
+
const m = c.match(/^(?:rgba|hsla)\([^)]*,\s*([\d.]+%?)\s*\)$/) || c.match(/^(?:rgb|hsl)a?\([^)]*\/\s*([\d.]+%?)\s*\)$/);
|
|
10798
|
+
if (m) return Math.max(0, Math.min(1, m[1].endsWith('%') ? parseFloat(m[1]) / 100 : parseFloat(m[1])));
|
|
10799
|
+
const h = c.match(/^#(?:[0-9a-f]{3}([0-9a-f])|[0-9a-f]{6}([0-9a-f]{2}))$/);
|
|
10800
|
+
if (h) return parseInt(h[1] ? h[1] + h[1] : h[2], 16) / 255;
|
|
10801
|
+
return 1;
|
|
10802
|
+
}
|
|
10803
|
+
// [r, g, b] with an alpha 0..1 — the plain [r, g, b] (opaque) when it is 1
|
|
10804
|
+
function withAlpha(rgb, a) {
|
|
10805
|
+
return a >= 1 ? rgb.slice(0, 3) : [...rgb.slice(0, 3), Math.round(255 * Math.max(0, a))];
|
|
10806
|
+
}
|
|
10356
10807
|
function hexOrNamedToRgb(v) {
|
|
10357
10808
|
const NAMED = { gray: [128, 128, 128], grey: [128, 128, 128], black: [0, 0, 0], white: [255, 255, 255] };
|
|
10358
10809
|
return parseCssColor(v) || NAMED[v] || [130, 130, 130];
|
|
@@ -10371,6 +10822,13 @@
|
|
|
10371
10822
|
// nothing here authored that string. Escaped before going into
|
|
10372
10823
|
// innerHTML so a stray `<`/`&`/quote in a source field can't break the
|
|
10373
10824
|
// option markup (or worse).
|
|
10825
|
+
// a legend's row labels as HTML: the page's .style({label}) as given,
|
|
10826
|
+
// values from the data escaped
|
|
10827
|
+
function legendRowLabels(rt) {
|
|
10828
|
+
const display = rt.categoryDisplayLabels;
|
|
10829
|
+
if (display && display !== rt.categoryLabels) return display;
|
|
10830
|
+
return (rt.categoryLabels || []).map(v => escapeHtml(v));
|
|
10831
|
+
}
|
|
10374
10832
|
function escapeHtml(s) {
|
|
10375
10833
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
10376
10834
|
}
|
|
@@ -10381,19 +10839,130 @@
|
|
|
10381
10839
|
// definition wins" caveat). Returns null for an unknown id, same as the
|
|
10382
10840
|
// real engine, rather than throwing.
|
|
10383
10841
|
function getThemeObj(szId) {
|
|
10384
|
-
|
|
10842
|
+
// no id: flat's current theme — here the one a page's facets were
|
|
10843
|
+
// built for (its own facet script sets ixmaps.filterThemeId), else the
|
|
10844
|
+
// last chart / choropleth theme
|
|
10845
|
+
let rt = szId != null ? _globalThemeRegistry.get(szId) : null;
|
|
10846
|
+
if (szId == null) {
|
|
10847
|
+
const pid = pageIxmaps() && pageIxmaps().filterThemeId;
|
|
10848
|
+
rt = (pid != null && _globalThemeRegistry.get(pid))
|
|
10849
|
+
|| [..._globalThemeRegistry.values()].reverse().find(r => r.flags.has('CHART') || r.flags.has('CHOROPLETH')) || null;
|
|
10850
|
+
}
|
|
10385
10851
|
if (!rt) return null;
|
|
10852
|
+
const rgbHex = c => '#' + c.slice(0, 3).map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
|
|
10853
|
+
let items = null;
|
|
10854
|
+
const onMap = () => items || (items = themeItemsOnMap(rt));
|
|
10386
10855
|
return {
|
|
10387
|
-
|
|
10856
|
+
// flat's theme id (style.name) — a .layer() name may be shared by
|
|
10857
|
+
// several themes of a page
|
|
10858
|
+
szId: themeIdOf(rt),
|
|
10388
10859
|
szName: rt.name,
|
|
10389
|
-
|
|
10860
|
+
// the filter in force: a runtime one (changeThemeStyle "filter")
|
|
10861
|
+
// replaces the definition's
|
|
10862
|
+
szFilter: rt._runtimeFilterExpr || rt._filterExpr || '',
|
|
10390
10863
|
szFlag: Array.from(rt.flags).join('|'),
|
|
10391
10864
|
// flat's fVisible is false for a FEATURE theme outside its
|
|
10392
10865
|
// featureupper/featurelower scales too (maptheme.js 2609-2611) — a
|
|
10393
10866
|
// broker reads it to skip refreshing a hidden theme
|
|
10394
|
-
fVisible: !rt._hidden && !(_lastMapApi && _lastMapApi.map && themeHiddenByScale(rt.flags, rt.style, _lastMapApi.map.getZoom()))
|
|
10867
|
+
fVisible: !rt._hidden && !(_lastMapApi && _lastMapApi.map && themeHiddenByScale(rt.flags, rt.style, _lastMapApi.map.getZoom())),
|
|
10868
|
+
nScale: styleNum(rt.style.scale) || 1,
|
|
10869
|
+
szUnits: rt.style.units || '',
|
|
10870
|
+
// flat stores the unit with a leading space unless it starts with "."
|
|
10871
|
+
szUnit: rt.style.units ? (String(rt.style.units)[0] === '.' ? '' : ' ') + rt.style.units : '',
|
|
10872
|
+
szTitleField: rt.binding.title || null,
|
|
10873
|
+
szFieldsA: rt.binding.value ? String(rt.binding.value).split('|') : [],
|
|
10874
|
+
szSizeField: rt.binding.size || null,
|
|
10875
|
+
// the classes, as flat's facet / legend scripts read them:
|
|
10876
|
+
// colorScheme by class, nStringToValueA value → class + 1,
|
|
10877
|
+
// szLabelA / szValuesA the style's label / values lists
|
|
10878
|
+
colorScheme: (rt.categoryColorsRgb || []).map(rgbHex),
|
|
10879
|
+
nStringToValueA: rt.categoryLabels ? Object.fromEntries(rt.categoryLabels.map((v, i) => [v, i + 1])) : {},
|
|
10880
|
+
szLabelA: Array.isArray(rt.style.label) ? rt.style.label.map(String) : (rt.categoryDisplayLabels || null),
|
|
10881
|
+
szValuesA: Array.isArray(rt.style.values) ? rt.style.values.map(String) : null,
|
|
10882
|
+
partsA: rt.partsA || [],
|
|
10883
|
+
// flat's own data surface, as a page's own facet / list scripts
|
|
10884
|
+
// (flat's ui/js/tools facet.js, list.js) read it: the theme's data
|
|
10885
|
+
// table (objTheme.dbTable/dbFields/dbRecords — records as arrays in
|
|
10886
|
+
// field order) and the items on the map (indexA → itemA[i].dbIndexA,
|
|
10887
|
+
// the records of each item). Here one item holds every record of
|
|
10888
|
+
// the theme within the current view (shown, in scale, facet filters
|
|
10889
|
+
// applied) — computed when read
|
|
10890
|
+
get objTheme() { return themeDbTable(rt); },
|
|
10891
|
+
// computed once per theme object (a page's loop reads itemA per record)
|
|
10892
|
+
get indexA() { return onMap().indexA; },
|
|
10893
|
+
get itemA() { return onMap().itemA; }
|
|
10395
10894
|
};
|
|
10396
10895
|
}
|
|
10896
|
+
const _warnedOnce = new Set();
|
|
10897
|
+
function warnOnce(key, msg) { if (!_warnedOnce.has(key)) { _warnedOnce.add(key); console.info(msg); } }
|
|
10898
|
+
// the theme's records as flat's dbFields/dbRecords (cached per data)
|
|
10899
|
+
function themeDbTable(rt) {
|
|
10900
|
+
const features = rt.features || [];
|
|
10901
|
+
if (rt._dbTableFor === features) return rt._dbTable;
|
|
10902
|
+
const rowOf = f => (f.properties && f.properties.raw) || f.properties || {};
|
|
10903
|
+
const names = [];
|
|
10904
|
+
const seen = new Set();
|
|
10905
|
+
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); }
|
|
10906
|
+
const indexOf = new Map();
|
|
10907
|
+
const dbRecords = features.map((f, i) => { indexOf.set(f, i); const row = rowOf(f); return names.map(k => (row[k] == null ? '' : row[k])); });
|
|
10908
|
+
rt._dbTableFor = features;
|
|
10909
|
+
rt._dbIndexOf = indexOf;
|
|
10910
|
+
const dbFields = names.map(id => ({ id, typ: 0, width: 16, decimals: 0 }));
|
|
10911
|
+
// dbTable: data.js Table's own table header ({records, fields} counts)
|
|
10912
|
+
rt._dbTable = { dbTable: { records: dbRecords.length, fields: dbFields.length }, dbFields, dbRecords };
|
|
10913
|
+
return rt._dbTable;
|
|
10914
|
+
}
|
|
10915
|
+
// the records on the map now: none for a hidden / out of scale theme,
|
|
10916
|
+
// else the (facet-filtered) records within the view
|
|
10917
|
+
function themeItemsOnMap(rt) {
|
|
10918
|
+
themeDbTable(rt);
|
|
10919
|
+
const map = _lastMapApi && _lastMapApi.map;
|
|
10920
|
+
const shown = map && !rt._hidden && !themeHiddenByScale(rt.flags, rt.style, map.getZoom());
|
|
10921
|
+
if (!shown) return { indexA: [], itemA: {} };
|
|
10922
|
+
const b = map.getBounds();
|
|
10923
|
+
const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()];
|
|
10924
|
+
const dbIndexA = [];
|
|
10925
|
+
for (const f of (rt._activeFeatures || rt.features || [])) {
|
|
10926
|
+
const g = f.geometry;
|
|
10927
|
+
if (g && g.type === 'Point' && !pointInBbox(g, bbox)) continue;
|
|
10928
|
+
const i = rt._dbIndexOf.get(f);
|
|
10929
|
+
if (i != null) dbIndexA.push(i);
|
|
10930
|
+
}
|
|
10931
|
+
return { indexA: ['view'], itemA: { view: { dbIndexA } } };
|
|
10932
|
+
}
|
|
10933
|
+
// flat's ixmaps.getThemeDefinitionObj(szId): the theme's definition —
|
|
10934
|
+
// type, style (sizefield/valuefield as flat names them), meta
|
|
10935
|
+
function getThemeDefinitionObj(szId) {
|
|
10936
|
+
const rt = _globalThemeRegistry.get(szId);
|
|
10937
|
+
if (!rt) return null;
|
|
10938
|
+
const style = Object.assign({}, rt.style);
|
|
10939
|
+
if (rt.binding.size && !style.sizefield) style.sizefield = rt.binding.size;
|
|
10940
|
+
if (rt.binding.title && !style.titlefield) style.titlefield = rt.binding.title;
|
|
10941
|
+
style.type = rt.flags.typeString != null ? rt.flags.typeString : [...rt.flags].join('|');
|
|
10942
|
+
const data = Object.assign({}, rt._definition && rt._definition.data);
|
|
10943
|
+
if (!data.name) data.name = themeDataName(rt);
|
|
10944
|
+
return { layer: rt.name, type: style.type, field: rt.binding.value, data, style, meta: Object.assign({}, rt.meta) };
|
|
10945
|
+
}
|
|
10946
|
+
// a theme's data table name — flat keeps named data tables in its map
|
|
10947
|
+
// window (ixmaps.embeddedSVG.window[name], read by a page's own list
|
|
10948
|
+
// scripts): the .data({name}) given, else one made from the theme id
|
|
10949
|
+
function themeDataName(rt) {
|
|
10950
|
+
const d = rt._definition && rt._definition.data;
|
|
10951
|
+
return (d && d.name) || ('themeData_' + String(themeIdOf(rt)).replace(/\W/g, '_'));
|
|
10952
|
+
}
|
|
10953
|
+
// flat's ixmaps.embeddedSVG.window: here only its named data tables, as
|
|
10954
|
+
// data.js Table objects {table, fields, records} of the themes' data
|
|
10955
|
+
const embeddedSVG = {
|
|
10956
|
+
window: new Proxy({}, {
|
|
10957
|
+
get(target, key) {
|
|
10958
|
+
if (typeof key !== 'string') return undefined;
|
|
10959
|
+
const rt = [...new Set(_globalThemeRegistry.values())].find(r => themeDataName(r) === key);
|
|
10960
|
+
if (!rt) return undefined;
|
|
10961
|
+
const t = themeDbTable(rt);
|
|
10962
|
+
return { table: t.dbTable, fields: t.dbFields, records: t.dbRecords };
|
|
10963
|
+
}
|
|
10964
|
+
})
|
|
10965
|
+
};
|
|
10397
10966
|
|
|
10398
10967
|
// GL-PORT COMPAT: real ixmaps-flat's global ixmaps.markThemeClass(szId,
|
|
10399
10968
|
// index)/unmarkThemeClass(szId, index) (htmlgui.js:2196/2208 — a bare
|
|
@@ -10410,7 +10979,15 @@
|
|
|
10410
10979
|
// here instead of redrawing.
|
|
10411
10980
|
function markThemeClass(szId, index) {
|
|
10412
10981
|
const rt = _globalThemeRegistry.get(szId);
|
|
10413
|
-
if (
|
|
10982
|
+
if (rt) markRuntimeClass(rt, index);
|
|
10983
|
+
}
|
|
10984
|
+
function unmarkThemeClass(szId, index) {
|
|
10985
|
+
const rt = _globalThemeRegistry.get(szId);
|
|
10986
|
+
if (rt) unmarkRuntimeClass(rt, index);
|
|
10987
|
+
}
|
|
10988
|
+
// the legend's own rows mark their theme directly: its .layer() name
|
|
10989
|
+
// may be shared with other themes of the page
|
|
10990
|
+
function markRuntimeClass(rt, index) {
|
|
10414
10991
|
// flat: a non-CHART theme (choropleth) marks ONE class at a time —
|
|
10415
10992
|
// MapTheme.markClass unmarks the previous class first (maptheme.js
|
|
10416
10993
|
// 14301-14305); chart themes can mark several
|
|
@@ -10420,9 +10997,7 @@
|
|
|
10420
10997
|
if (rt._onMarksChanged) rt._onMarksChanged();
|
|
10421
10998
|
else if (rt._triggerRedraw) rt._triggerRedraw();
|
|
10422
10999
|
}
|
|
10423
|
-
function
|
|
10424
|
-
const rt = _globalThemeRegistry.get(szId);
|
|
10425
|
-
if (!rt) return;
|
|
11000
|
+
function unmarkRuntimeClass(rt, index) {
|
|
10426
11001
|
rt._markedClasses.delete(index);
|
|
10427
11002
|
if (rt._onMarksChanged) rt._onMarksChanged();
|
|
10428
11003
|
else if (rt._triggerRedraw) rt._triggerRedraw();
|
|
@@ -10725,13 +11300,30 @@
|
|
|
10725
11300
|
layer, Map: createMap, setExternalData: setExternalDataBridge, getThemeObj, data: ixmapsData,
|
|
10726
11301
|
szResourceBase: ixmapsSzResourceBase, getProjectString, setProjectJSON, loadProject,
|
|
10727
11302
|
markThemeClass, unmarkThemeClass, getBoundingBox, setTitle, setTitleBox, refreshTheme, formatValue: flatFormatValue,
|
|
10728
|
-
map: mapHandle, getZoom,
|
|
11303
|
+
map: mapHandle, getZoom, getThemeDefinitionObj, embeddedSVG,
|
|
11304
|
+
// flat's theme list: the theme objects of every theme
|
|
11305
|
+
getThemes: () => [...new Set(_globalThemeRegistry.values())].map(rt => getThemeObj(themeIdOf(rt))).filter(Boolean),
|
|
11306
|
+
// flat shows a short status message on the map; not ported
|
|
11307
|
+
message: () => {},
|
|
11308
|
+
// flat's item highlight / item filter (facet histogram hovers): not ported
|
|
11309
|
+
filterThemeItems: () => warnOnce('filterThemeItems', '[ixmaps-gl] ixmaps.filterThemeItems is not supported yet'),
|
|
11310
|
+
highlightThemeItems: () => warnOnce('highlightThemeItems', '[ixmaps-gl] ixmaps.highlightThemeItems is not supported yet'),
|
|
11311
|
+
// flat's global forms with the map name first (htmlgui.js) — the map
|
|
11312
|
+
// handle's methods on the last built map
|
|
11313
|
+
getCenter: () => (_lastMapApi && _lastMapApi.map ? _lastMapApi.map.getCenter() : null),
|
|
11314
|
+
getMapTypeId: () => (_lastMapApi && _lastMapApi.getMapTypeId ? _lastMapApi.getMapTypeId() : ''),
|
|
11315
|
+
changeThemeStyle: (szMap, szId, szStyle, szFlag) => _mapHandle.changeThemeStyle(szId, szStyle, szFlag),
|
|
11316
|
+
removeTheme: (szMap, szId) => _mapHandle.remove(szId),
|
|
11317
|
+
setBasemapOpacity: (szMap, nOpacity, szMode) => _mapHandle.setBasemapOpacity(nOpacity, szMode),
|
|
10729
11318
|
// this engine's version (flat's own ixmaps.version numbers flat)
|
|
10730
11319
|
glVersion: IXMAPS_GL_VERSION,
|
|
10731
11320
|
// flat always has this page hook (its layer legend tool defines it,
|
|
10732
11321
|
// ui/js/tools/layer_legend.js 702-712): pages wrap it and call the one
|
|
10733
11322
|
// they replaced — the chain ends here
|
|
10734
11323
|
htmlgui_onZoomAndPan: function (nZoom) {},
|
|
11324
|
+
// flat calls it when a theme is drawn (htmlgui.js) — pages wrap it
|
|
11325
|
+
// (statistics, facets); called after each settled redraw, notifyRedraw
|
|
11326
|
+
htmlgui_onDrawTheme: function (szId) {},
|
|
10735
11327
|
// carried over from a page's own pre-load `ixmaps.validate = ...`
|
|
10736
11328
|
validate: _preloadValidate
|
|
10737
11329
|
};
|
|
@@ -10746,7 +11338,7 @@
|
|
|
10746
11338
|
// test-only: lets test/unit/*.test.mjs call pure internals directly (the
|
|
10747
11339
|
// engine runs in a Node vm there); deliberately NOT on the ixmaps object
|
|
10748
11340
|
global.__ixmapsGlInternals = {
|
|
10749
|
-
IXMAPS_GL_VERSION,
|
|
11341
|
+
IXMAPS_GL_VERSION, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout,
|
|
10750
11342
|
normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, typeStyleNumbers, styleNum,
|
|
10751
11343
|
resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
|
|
10752
11344
|
equalIntervalBreaks, quantileBreaks, naturalBreaks, evenStrideSample, jenksBreakValues, partsFromBreakValues, resolvePartsClass,
|