ixmaps-gl 0.2.23 → 0.2.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/ixmaps-gl.js +1180 -114
  2. package/package.json +1 -1
package/ixmaps-gl.js CHANGED
@@ -45,7 +45,11 @@
45
45
  // range adjustments; see flatRangeParts). A CHOROPLETH
46
46
  // layer's geometry is borrowed from a FEATURE base layer sharing the same
47
47
  // .layer() name (the real engine's own convention), joined via
48
- // binding.lookup — see joinChoroplethFeatures. HEADTAIL breaks are flat's
48
+ // binding.lookup — see joinChoroplethFeatures. POSITION (maptheme.js
49
+ // 8328-8353) is a theme that renders nothing itself: it loads a table of
50
+ // id + position pairs and provides the id→position join that other themes
51
+ // on the same .layer() name consume via binding.lookup — see
52
+ // joinChartPositions. HEADTAIL breaks are flat's
49
53
  // (headTailBreaks); LOG/POW2/POW3 are recognized type flags but not
50
54
  // implemented, see KNOWN_INERT_FLAGS. CHART|USER draws a page's own chart
51
55
  // function (style.userdraw, _buildUserChartLayers), CHART|LABEL flat's
@@ -96,7 +100,7 @@
96
100
  // ---------------------------------------------------------------
97
101
  // this engine's release (= package.json "version", checked by
98
102
  // test/unit/version.test.mjs); ixmaps.glVersion, and logged once at start
99
- const IXMAPS_GL_VERSION = '0.2.23';
103
+ const IXMAPS_GL_VERSION = '0.2.24';
100
104
 
101
105
  const LIB_URLS = {
102
106
  // Bumped 3.6.2 -> 5.x (2026-09-21, globe-projection compat fix): native
@@ -530,7 +534,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
530
534
  // crafted link must not be able to load arbitrary code into the page.
531
535
  // ---------------------------------------------------------------
532
536
  // v0.1.6: knows this engine's aliases, lookupfield rule, meta↔style, builder methods, project loading
533
- const VALIDATOR_URL_DEFAULT = 'https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-grammar@v0.1.27/dist/validate.mjs';
537
+ const VALIDATOR_URL_DEFAULT = 'https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-grammar@v0.1.28/dist/validate.mjs';
534
538
  // a page may set ixmaps.validate BEFORE loading this script — the
535
539
  // `global.ixmaps = {...}` export below would otherwise overwrite it
536
540
  const _preloadValidate = global.ixmaps && global.ixmaps.validate;
@@ -670,8 +674,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
670
674
  }
671
675
  }
672
676
 
673
- function topojsonToFeatureCollection(topo) {
674
- const objName = Object.keys(topo.objects)[0];
677
+ function topojsonToFeatureCollection(topo, objectName) {
678
+ // .data({object: "NAME"}) selects the topojson object to show; without
679
+ // one (or with an unknown name) it stays the file's FIRST object, the
680
+ // previous behaviour
681
+ const objName = (objectName && topo.objects[objectName]) ? objectName : Object.keys(topo.objects)[0];
675
682
  const obj = topo.objects[objName];
676
683
  const geoms = obj.type === 'GeometryCollection' ? obj.geometries : [obj];
677
684
  const features = geoms.map(g => ({
@@ -787,7 +794,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
787
794
  }
788
795
  return false;
789
796
  });
790
- return features.length === fc.features.length ? fc : Object.assign({}, fc, { features });
797
+ const out = features.length === fc.features.length ? fc : Object.assign({}, fc, { features });
798
+ // a {type:"Sphere"} feature is flat's ocean backdrop: not drawn as a shape, the globe's own
799
+ // surface takes its color (see applySphereBackdrop)
800
+ if (fc.features.some(f => f && f.geometry && f.geometry.type === 'Sphere')) Object.defineProperty(out, '_sphere', { value: true, enumerable: false });
801
+ return out;
791
802
  }
792
803
 
793
804
  // CHOROPLETH's own .data() (e.g. the comuni demographics CSV in
@@ -826,7 +837,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
826
837
  function dataSourceKey(data) {
827
838
  const d = data || {};
828
839
  return JSON.stringify({ url: d.url, urls: d.urls, type: d.type, query: d.query,
829
- process: d.process && String(d.process), ext: d.ext, name: d.ext ? d.name : undefined });
840
+ process: d.process && String(d.process), ext: d.ext, name: d.ext ? d.name : undefined,
841
+ // .data({object}) selects a topojson object — two themes on the same
842
+ // URL with different objects must NOT share one cache entry (the
843
+ // DISS page's 5 layers of one file)
844
+ object: d.object });
830
845
  }
831
846
  function dataCacheKey(data, binding) {
832
847
  const b = binding || {};
@@ -1371,7 +1386,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1371
1386
  // genuinely CSV-shaped .obj with a missing binding.position would
1372
1387
  // throw, even though this .obj was never row data in the first place.
1373
1388
  if (dataConfig.obj) {
1374
- if (dataConfig.type === 'topojson') return topojsonToFeatureCollection(dataConfig.obj);
1389
+ if (dataConfig.type === 'topojson') return topojsonToFeatureCollection(dataConfig.obj, dataConfig.object);
1375
1390
  if (dataConfig.type === 'geojson') return sanitizeGeoJSON(dataConfig.obj);
1376
1391
  let obj = dataConfig.obj;
1377
1392
  if (dataConfig.process) {
@@ -1444,7 +1459,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1444
1459
  // them into a table with a geometry column, and back)
1445
1460
  const resp = await fetch(dataConfig.url);
1446
1461
  if (!resp.ok) throw new Error(`[ixmaps-gl] failed to fetch ${dataConfig.url}: ${resp.status}`);
1447
- if (dataConfig.type === 'topojson') return topojsonToFeatureCollection(await resp.json());
1462
+ if (dataConfig.type === 'topojson') return topojsonToFeatureCollection(await resp.json(), dataConfig.object);
1448
1463
  return sanitizeGeoJSON(await resp.json());
1449
1464
  }
1450
1465
  // every other format (json, jsonstat, ndjson, rss, kml, gml, geobuf,
@@ -1549,7 +1564,29 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1549
1564
  // first filter left.
1550
1565
  function filterAndJoin(raw, spec, runtimes, expr = spec.filter) {
1551
1566
  const f = applyWhereFilter(raw, expr);
1552
- return f.type === 'Table' ? joinTableFeatures(spec, f, runtimes) : f;
1567
+ return f.type === 'Table' ? joinTableFeatures(spec, f, runtimes) : chartAnchoredFeatures(f, spec);
1568
+ }
1569
+ // a CHART theme over geojson SHAPES (the TELRAAM page binds
1570
+ // lookup:"geometry" — each chart at its own feature's shape): lines and
1571
+ // polygons carry no [lng, lat] position, so every chart pipeline test
1572
+ // (typeof c[0] === 'number', inViewport, ...) drops them silently and
1573
+ // nothing renders. flat positions such a chart at the shape's center
1574
+ // (its ptPos, flatShapeCenter) — normalize the features to Point at
1575
+ // that anchor up front. FEATURE(S)/CHOROPLETH keep their real geometry,
1576
+ // VECTOR/BEZIER keep their lines (their own renderers)
1577
+ function chartAnchoredFeatures(fc, spec) {
1578
+ const chart = fc && fc.type === 'FeatureCollection' && spec.flags && spec.flags.has('CHART')
1579
+ && !spec.flags.has('FEATURE') && !spec.flags.has('FEATURES')
1580
+ && !spec.flags.has('CHOROPLETH') && !isVectorChart(spec.flags)
1581
+ && fc.features.some(f => f && f.geometry && f.geometry.type !== 'Point');
1582
+ if (!chart) return fc;
1583
+ const features = fc.features.map(f => {
1584
+ const t = f && f.geometry && f.geometry.type;
1585
+ if (!t || t === 'Point') return f;
1586
+ const anchor = flatShapeCenter(f.geometry);
1587
+ return anchor ? { type: 'Feature', geometry: { type: 'Point', coordinates: anchor }, properties: f.properties } : f;
1588
+ });
1589
+ return { type: 'FeatureCollection', features };
1553
1590
  }
1554
1591
  function applyWhereFilter(fc, filterExpr) {
1555
1592
  if (!filterExpr) return fc;
@@ -1658,6 +1695,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1658
1695
  // each layer where its shape is found (maptheme.js 9324, 10376); records
1659
1696
  // with no shape are left out (10387-10392)
1660
1697
  //
1698
+ // A POSITION theme is a layer's id→position PROVIDER: flat loads it, draws
1699
+ // nothing, and only registers its records as themeNodesPosA entries
1700
+ // (maptheme.js 8328-8353 — "don't create SVG objects but only entries in
1701
+ // map.Themes.themeNodesPosA[]"), the same registry getNodePosition reads
1702
+ // — so here it takes part in the join index exactly like a FEATURE layer
1703
+ // of center points, its binding.id against the consumer's lookup key
1704
+ //
1661
1705
  // flat runs the after-aggregation steps after EACH layer's pass, over all
1662
1706
  // items; DIFFERENCE turns a record's value fields into their difference
1663
1707
  // and drops the last, so a second pass leaves the earlier layers' items
@@ -1672,7 +1716,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1672
1716
  const features = [];
1673
1717
  const indexes = layers.map(layer => {
1674
1718
  const index = new Map();
1675
- runtimes.filter(r => r.name === layer && (r.flags.has('FEATURE') || r.flags.has('FEATURES'))).forEach(r => {
1719
+ runtimes.filter(r => r.name === layer && (r.flags.has('FEATURE') || r.flags.has('FEATURES') || r.flags.has('POSITION'))).forEach(r => {
1676
1720
  // in theme order, as flat's layer holds the themes' shapes
1677
1721
  for (const [k, v] of featurePositionIndex(r)) { if (flatShapeWins(index.get(k), v.area)) index.set(k, v); }
1678
1722
  });
@@ -1759,6 +1803,25 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1759
1803
  return Math.min(0.9, o);
1760
1804
  }
1761
1805
 
1806
+ // flat's DOPACITY formula inside the COMPOSECOLOR branch (maptheme.js
1807
+ // 13489-13505): (dopacityscale || 1) · (v/max)^(1/dopacitypow) — no 0.9
1808
+ // cap and no 0.0001 clip here, unlike the DOMINANT branch: flat writes
1809
+ // the raw SVG stroke-/fill-opacity property and the browser clamps at 1
1810
+ function composedDopacityAlpha(style, flags, v, max) {
1811
+ const scale = styleNum(style.dopacityscale) || 1;
1812
+ const pow = 1 / (styleNum(style.dopacitypow) || 1);
1813
+ const o = scale * Math.pow(v, pow) / Math.pow(max, pow);
1814
+ return isFinite(o) ? Math.max(0, Math.min(1, o)) : 0;
1815
+ }
1816
+
1817
+ // a choropleth shape's outline color: a LINE shape strokes with its value
1818
+ // color; any other shape keeps the theme's outline, hidden while a class
1819
+ // is isolated and the shape is not in it
1820
+ function choroplethOutlineColor(isLine, valueColorOf, d, isolating, unmarkedEvidence, lineRgb) {
1821
+ if (isLine) return valueColorOf(d);
1822
+ return isolating && unmarkedEvidence(d) === 'hide' ? [0, 0, 0, 0] : lineRgb;
1823
+ }
1824
+
1762
1825
  function joinChoroplethFeatures(spec, table, runtimes) {
1763
1826
  // a page can define several FEATURE layers of one name — e.g. the
1764
1827
  // comuni as polygons and as center points (for chart positions); flat
@@ -2091,6 +2154,36 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2091
2154
  // colorscheme (isGeneratedColorScheme above), never as the step count.
2092
2155
  function resolveColorScheme(colorscheme, labels, classes) {
2093
2156
  if (!colorscheme) return labels.map((_, i) => FALLBACK_PALETTE[i % FALLBACK_PALETTE.length]);
2157
+ // flat's custom per-value color function (maptheme.js 12677-12705):
2158
+ // colorscheme: ["byvalue", function(value){ return color; }] — every
2159
+ // known category value gets its color from the function and the
2160
+ // result REPLACES the whole scheme (so neither classes nor the
2161
+ // generated-sweep machinery below ever see it, as in flat). A
2162
+ // function that throws, or one a non-categorical theme hands no
2163
+ // values to iterate, falls back to the default palette — exactly
2164
+ // flat's own two fallbacks. The function may also arrive serialized
2165
+ // (a project-JSON string), like the whole-scheme string function
2166
+ // below evaluates one.
2167
+ if (Array.isArray(colorscheme) && colorscheme.length > 1 && colorscheme[0] === 'byvalue') {
2168
+ let fnByValue = null;
2169
+ if (typeof colorscheme[1] === 'function') fnByValue = colorscheme[1];
2170
+ else if (typeof colorscheme[1] === 'string') {
2171
+ try {
2172
+ const f = new Function('return (' + colorscheme[1] + ')')();
2173
+ if (typeof f === 'function') fnByValue = f;
2174
+ } catch (err) { /* not code */ }
2175
+ }
2176
+ if (fnByValue) {
2177
+ try {
2178
+ const byValueA = labels.map(l => fnByValue(l));
2179
+ if (byValueA.length) return byValueA;
2180
+ console.warn('[ixmaps-gl] colorscheme "byvalue" function needs a categorical theme with known values — using the fallback palette');
2181
+ } catch (err) {
2182
+ console.warn('[ixmaps-gl] colorscheme "byvalue" function threw "' + err + '" — using the fallback palette');
2183
+ }
2184
+ }
2185
+ return labels.map((_, i) => FALLBACK_PALETTE[i % FALLBACK_PALETTE.length]);
2186
+ }
2094
2187
  colorscheme = applyClassesToColorScheme(colorscheme, classes);
2095
2188
  if (isGeneratedColorScheme(colorscheme)) {
2096
2189
  const [, cc1, cc2, nParam1, nParam2] = colorscheme;
@@ -2196,7 +2289,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2196
2289
  // mean — see the NOOUTLIER block in _buildChartLayers) has real
2197
2290
  // behavior now; bare OUTLIER (the real source's "keep ONLY the
2198
2291
  // outliers" inverse mode) stays unimplemented — no config here uses it.
2199
- const KNOWN_INERT_FLAGS = ['SEQUENCE', 'STAR', 'SORT', 'DOWN', 'RECT', 'CLIPTOGEOBOUNDS', 'OUTLIER',
2292
+ // SEQUENCE/STAR/SORT/DOWN/RECT are implemented now (the sequence
2293
+ // symbol chart, sequenceLayout + snapToAggregationGrid) — removed from
2294
+ // this list; the rest is still recognized-but-inert
2295
+ const KNOWN_INERT_FLAGS = ['CLIPTOGEOBOUNDS', 'OUTLIER',
2200
2296
  'POW2', 'POW3', 'NOSORT'];
2201
2297
  // flat's PIE / DONUT variants with no rendering here (see _buildPieLayers)
2202
2298
  const PIE_INERT_FLAGS = [/3D/, /VOLUME/, /HEIGHT/, /STARBURST/, /FLOWER/, /RAYS/, /BOW/, /ZOOM/, /DIRECTION/, /DIRECTED/, /POLAR/];
@@ -2477,6 +2573,27 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2477
2573
  return construct;
2478
2574
  }
2479
2575
  const unwrapLayer = x => (typeof x === 'function' && x.__ixLayer) ? x.__ixLayer : x;
2576
+ // flat's layer builder finalizers — definition(), define() AND json()
2577
+ // (htmlgui_flat.js 2056-2083) — all return the plain DEFINITION object,
2578
+ // so a real page's .layer(ixmaps.layer("x").data()...json()) hands
2579
+ // map.layer() that object rather than the builder. Wrap it back into a
2580
+ // LayerBuilder whose definition() reproduces it (the def's own slots;
2581
+ // its type/filter/title live INSIDE style, where definition() put them)
2582
+ function asLayerBuilder(x) {
2583
+ const lb = unwrapLayer(x);
2584
+ if (lb instanceof LayerBuilder) return lb;
2585
+ if (lb && typeof lb === 'object' && (lb.layer || lb.data || lb.style)) {
2586
+ const b = new LayerBuilder(lb.layer || 'layer');
2587
+ b._data = lb.data || null;
2588
+ b._binding = lb.binding || {};
2589
+ b._style = lb.style || {};
2590
+ b._meta = lb.meta || {};
2591
+ if (lb.field != null) b._field = lb.field;
2592
+ if (lb.field100 != null) b._field100 = lb.field100;
2593
+ return b;
2594
+ }
2595
+ return lb;
2596
+ }
2480
2597
 
2481
2598
  // ---------------------------------------------------------------
2482
2599
  // Binding aliases. Real ixmaps-flat accepts ~50 .binding() keys and maps
@@ -2487,7 +2604,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2487
2604
  // edit it by hand; `npm run unit` fails when it drifts.
2488
2605
  // ---------------------------------------------------------------
2489
2606
  // <grammar:binding-aliases>
2490
- // generated from ixmaps-grammar 0.1.27 (ixmaps-flat 1.0.42, 2b978d5 2026-10-02) — 52 aliases
2607
+ // generated from ixmaps-grammar 0.1.28 (ixmaps-flat 1.0.42, 2b978d5 2026-10-02) — 52 aliases
2491
2608
  const FLAT_BINDING_ALIASES = {
2492
2609
  "aggregation": "style.aggregationfield",
2493
2610
  "aggregationfield": "style.aggregationfield",
@@ -2549,7 +2666,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2549
2666
  // both — so .style({tooltip}) is a tooltip and .meta({fillopacity}) a style
2550
2667
  // property. GENERATED by test/sync-grammar.mjs — do not edit.
2551
2668
  // <grammar:meta-keys>
2552
- // generated from ixmaps-grammar 0.1.27 — 5 meta keys
2669
+ // generated from ixmaps-grammar 0.1.28 — 5 meta keys
2553
2670
  const FLAT_META_KEYS = ["description","name","snippet","title","tooltip"];
2554
2671
  // </grammar:meta-keys>
2555
2672
 
@@ -2905,6 +3022,15 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2905
3022
  }
2906
3023
 
2907
3024
  typeStyleNumbers(style);
3025
+ // flat's own fill-opacity fallback (maptheme.js 13406:
3026
+ // `this.fillOpacity = this.fillOpacity ? this.fillOpacity : this.nOpacity`)
3027
+ // — an "opacity" style is the fill opacity whenever "fillopacity" is
3028
+ // not set, as flat realizes every theme
3029
+ // (Number("") and Number(null) are 0: an empty opacity must not hide the theme)
3030
+ if (!styleNum(style.fillopacity) && style.opacity != null && String(style.opacity).trim() !== ''
3031
+ && Number.isFinite(Number(style.opacity))) {
3032
+ style.fillopacity = Number(style.opacity);
3033
+ }
2908
3034
  return { name: def.layer, data: def.data, binding, targets, geometry, flags, typeStr, style, meta, filter };
2909
3035
  }
2910
3036
 
@@ -3061,7 +3187,58 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3061
3187
  light: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
3062
3188
  dark: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
3063
3189
  };
3190
+ // flat's own raster-tile basemaps (htmlgui_sync_Leaflet.js __addTileLayer
3191
+ // list, ArcGIS block): a mapType NAME flat resolves to real raster tiles
3192
+ // resolves to the SAME tiles here, as a built MapLibre raster style —
3193
+ // "ArcGIS - Topo" was falling through to CARTO Positron before, a
3194
+ // non-topographic look nothing like flat's. Styles are built once per
3195
+ // name, so the same name is the SAME style object and setMapType's
3196
+ // "same style: nothing to reload" identity test keeps working.
3197
+ const ESRI_ATTRIBUTION = 'Tiles &copy; Esri &mdash; Esri, DeLorme, NAVTEQ, TomTom, Intermap, iPC, USGS, FAO, NPS, NRCAN, GeoBase, Kadaster NL, Ordnance Survey, Esri Japan, METI, Esri China (Hong Kong), and the GIS User Community';
3198
+ const FLAT_RASTER_BASEMAPS = [
3199
+ { name: 'ArcGIS - Topo', aliases: ['terrain'], maxzoom: 19,
3200
+ tiles: 'https://services.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}.png' },
3201
+ { name: 'ArcGIS - Light Gray Base', maxzoom: 19,
3202
+ tiles: 'https://services.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}.png' },
3203
+ { name: 'ArcGIS - Ocean Basemap', maxzoom: 13,
3204
+ tiles: 'https://services.arcgisonline.com/ArcGIS/rest/services/Ocean_Basemap/MapServer/tile/{z}/{y}/{x}.png' },
3205
+ { name: 'ArcGIS - Hillshade', maxzoom: 16,
3206
+ tiles: 'https://server.arcgisonline.com/arcgis/rest/services/Elevation/World_Hillshade/MapServer/tile/{z}/{y}/{x}.png' },
3207
+ ];
3208
+ const _rasterBasemapStyles = new Map();
3209
+ function buildRasterBasemapStyle(entry) {
3210
+ if (_rasterBasemapStyles.has(entry.name)) return _rasterBasemapStyles.get(entry.name);
3211
+ const sourceId = 'ixmaps-raster-basemap';
3212
+ const style = {
3213
+ version: 8,
3214
+ sources: {
3215
+ [sourceId]: {
3216
+ type: 'raster',
3217
+ tiles: [entry.tiles],
3218
+ tileSize: 256,
3219
+ maxzoom: entry.maxzoom,
3220
+ attribution: ESRI_ATTRIBUTION
3221
+ }
3222
+ },
3223
+ // raster-opacity (applyBasemapOpacity's raster entry) fades this
3224
+ // layer like any other basemap
3225
+ layers: [{ id: sourceId, type: 'raster', source: sourceId, paint: {} }]
3226
+ };
3227
+ _rasterBasemapStyles.set(entry.name, style);
3228
+ return style;
3229
+ }
3230
+ // flat's mapTypeTranslate ("terrain" → "ArcGIS - Topo", ...) applies to
3231
+ // the raster names too
3232
+ function flatRasterBasemap(mapType) {
3233
+ if (typeof mapType !== 'string') return null;
3234
+ const t = mapType.trim();
3235
+ for (const entry of FLAT_RASTER_BASEMAPS) {
3236
+ if (t === entry.name || (entry.aliases || []).includes(t)) return entry;
3237
+ }
3238
+ }
3064
3239
  function resolveBasemapStyleUrl(mapType) {
3240
+ const raster = flatRasterBasemap(mapType);
3241
+ if (raster) return buildRasterBasemapStyle(raster);
3065
3242
  return typeof mapType === 'string' && /dark|black|night|matter/i.test(mapType) ? BASEMAP_STYLE_URLS.dark : BASEMAP_STYLE_URLS.light;
3066
3243
  }
3067
3244
  // Real ixmaps-flat's legend look (tools/legend.js 3773-3845, via
@@ -3170,6 +3347,29 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3170
3347
  return false;
3171
3348
  }
3172
3349
 
3350
+ // The ocean of flat's world / orthographic maps is a {type:"Sphere"} feature: the whole map disc,
3351
+ // filled with the layer's color. Here it is the surface of the map (the globe, or the flat
3352
+ // map): a background layer under everything, in that color and opacity.
3353
+ const SPHERE_BACKDROP_LAYER_ID = '__ixmaps_gl_sphere_backdrop';
3354
+ function applySphereBackdrop(map, style) {
3355
+ const cs = style && style.colorscheme;
3356
+ const color = Array.isArray(cs) ? cs[0] : cs;
3357
+ if (!color || color === 'none') return;
3358
+ const opacity = Number.isFinite(styleNum(style.fillopacity)) ? styleNum(style.fillopacity) : 1;
3359
+ const paint = () => {
3360
+ try {
3361
+ const id = map.getLayer(BLANK_BACKGROUND_LAYER_ID) ? BLANK_BACKGROUND_LAYER_ID : SPHERE_BACKDROP_LAYER_ID;
3362
+ if (id === SPHERE_BACKDROP_LAYER_ID && !map.getLayer(id)) {
3363
+ const first = (map.getStyle().layers || [])[0];
3364
+ map.addLayer({ id, type: 'background', paint: {} }, first && first.id);
3365
+ }
3366
+ map.setPaintProperty(id, 'background-color', color);
3367
+ map.setPaintProperty(id, 'background-opacity', opacity);
3368
+ } catch (e) { console.warn('[ixmaps-gl] the {type:"Sphere"} ocean backdrop could not be set:', e); }
3369
+ };
3370
+ if (map.isStyleLoaded()) paint(); else map.once('style.load', paint);
3371
+ }
3372
+
3173
3373
  function buildBlankBackgroundStyle(color) {
3174
3374
  return {
3175
3375
  version: 8,
@@ -3659,6 +3859,14 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3659
3859
  // explicit request (real engine's own fallback is right-anchored
3660
3860
  // too, just without an opinion on vertical placement).
3661
3861
  const legendAlignCss = legendAlignCssFor(builder.mapOptions);
3862
+ // flat's align:"center"/"top" legend realizing (htmlgui_flat.js
3863
+ // 445-480, legend.css 516-550): not a floating corner panel but a
3864
+ // full-width bar over the map's top edge — width 100% capped at
3865
+ // legendMaxWidth (1200px), legendBackground (default
3866
+ // rgba(255,255,255,0.9)), rounded BOTTOM corners only, the title
3867
+ // centered above, the class chips inline-block side by side and
3868
+ // the chart-size slider inline in that same row
3869
+ const legendCenter = /^(center|top)$/i.test(String(builder.mapOptions.align || '').trim());
3662
3870
  // Real ixmaps-flat's SUB-THEME (MapTheme.createSubTheme,
3663
3871
  // maptheme.js 14284-14292 / 15010-15160; map.Themes.enableSubThemes
3664
3872
  // defaults to true): marking fields of a multi-field choropleth
@@ -3728,7 +3936,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3728
3936
  if (legendStack && legendStack.parentNode) return legendStack;
3729
3937
  legendStack = document.createElement('div');
3730
3938
  legendStack.className = 'ix-legend-stack';
3731
- legendStack.style.cssText = 'position:absolute;' + legendAlignCss + 'z-index:6;width:280px;'
3939
+ legendStack.style.cssText = legendCenter
3940
+ // flat's center legend: the full-width bar, the panel carries
3941
+ // the background (no stack-level radius or shadow)
3942
+ ? 'position:absolute;left:50%;transform:translateX(-50%);top:0;z-index:6;width:100%;max-width:1200px;'
3943
+ + 'display:flex;flex-direction:column;pointer-events:none;'
3944
+ : 'position:absolute;' + legendAlignCss + 'z-index:6;width:280px;'
3732
3945
  + 'display:flex;flex-direction:column;max-height:66.6%;overflow-y:auto;'
3733
3946
  + 'border-radius:6px;box-shadow:' + shadow + ';pointer-events:auto;';
3734
3947
  if (!document.getElementById('ix-legend-stack-css')) {
@@ -3838,7 +4051,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3838
4051
  const looksDark = typeof builder.__effectiveDark === 'function'
3839
4052
  ? builder.__effectiveDark(isNaN(opacityOpt) ? 1 : opacityOpt, builder.mapOptions.mapType, legendBackground)
3840
4053
  : flatLook.dark;
3841
- const isLightLegend = rt.style.legendtheme === 'light' || (rt.style.legendtheme !== 'dark' && !looksDark);
4054
+ const isLightLegend = legendCenter || rt.style.legendtheme === 'light' || (rt.style.legendtheme !== 'dark' && !looksDark);
3842
4055
  const colors = isLightLegend
3843
4056
  ? { bg: 'rgba(255,255,255,0.92)', fg: '#1a1a1a', shadow: '0 2px 10px rgba(0,0,0,0.18)',
3844
4057
  rowMarked: 'rgba(0,0,0,0.08)', track: 'rgba(0,0,0,0.08)',
@@ -3849,7 +4062,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3849
4062
  // the panel bg follows the same effective decision (flatLegendLook
3850
4063
  // would hand the dark look a light bg here: its dark branch never
3851
4064
  // ran for this map, its light bg would clash with dark rows/text)
3852
- if (!rt.style.legendtheme) colors.bg = looksDark ? '#111' : flatLook.bg;
4065
+ if (!rt.style.legendtheme && !legendCenter) colors.bg = looksDark ? '#111' : flatLook.bg;
3853
4066
  return colors;
3854
4067
  };
3855
4068
  const legendColors = lookColors();
@@ -3867,7 +4080,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3867
4080
  // one box for every theme, as flat's #map-legend (legend.js
3868
4081
  // 2200-2460): the panels stack in the legend stack in theme
3869
4082
  // order, a line between them (see layoutLegends)
3870
- panel.style.cssText = 'position:relative;flex:0 1 auto;min-height:0;overflow:hidden;'
4083
+ panel.style.cssText = legendCenter
4084
+ // flat's center legend bar: full width, centered text, the
4085
+ // light legendBackground, rounded bottom corners only
4086
+ ? 'position:relative;display:flex;flex-direction:column;text-align:center;'
4087
+ + 'background:' + legendColors.bg + ';color:' + legendColors.fg + ';font:12px/1.4 -apple-system,Arial,sans-serif;'
4088
+ + 'padding:6px 12px 10px;pointer-events:auto;border-radius:0 0 6px 6px;'
4089
+ : 'position:relative;flex:0 1 auto;min-height:0;overflow:hidden;'
3871
4090
  + 'display:flex;flex-direction:column;text-align:left;'
3872
4091
  + 'background:' + legendColors.bg + ';color:' + legendColors.fg + ';font:12px/1.4 -apple-system,Arial,sans-serif;'
3873
4092
  + 'padding:10px 12px 12px;pointer-events:auto;';
@@ -3937,6 +4156,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3937
4156
  // branch uses (no antimeridian wraparound handling — matches
3938
4157
  // that existing convention, not a new gap this introduces).
3939
4158
  let totals, maxTotal, order, rowsScroll;
4159
+ // hoisted so the chart-size slider below can join the chips row
4160
+ // in flat's center legend (legend-top-align's inline slider)
4161
+ let rowsEl = null;
3940
4162
  // No-op default: TEXTLEGEND never reassigns this (see below),
3941
4163
  // so the LATER unconditional-looking `renderRows()` call
3942
4164
  // safely does nothing rather than needing its own isTextOnly
@@ -3993,8 +4215,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3993
4215
  // share, then overflow-y:auto scrolls just the row list) —
3994
4216
  // the header above and description/slider below stay put.
3995
4217
  rowsScroll = document.createElement('div');
3996
- rowsScroll.style.cssText = 'flex:1 1 auto;min-height:0;overflow-y:auto;';
3997
- const rowsEl = document.createElement('div');
4218
+ rowsScroll.style.cssText = legendCenter
4219
+ // flat's center bar: one wrapped, centered row holding the
4220
+ // chips AND the inline chart-size slider (legend.css 521-550)
4221
+ ? 'flex:0 1 auto;display:flex;flex-wrap:wrap;justify-content:center;align-items:center;'
4222
+ : 'flex:1 1 auto;min-height:0;overflow-y:auto;';
4223
+ rowsEl = document.createElement('div');
3998
4224
  rowsEl.className = 'ix-legend-rows';
3999
4225
  // .type("...|SIMPLELEGEND") — real ixmaps-flat's OTHER legend
4000
4226
  // variant (confirmed against a real-engine screenshot, per
@@ -4019,7 +4245,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4019
4245
  // smaller categories packed 3-4 per row) rather than a fixed
4020
4246
  // column count.
4021
4247
  if (rt.flags.has('SIMPLELEGEND') || rt.flags.has('COMPACTLEGEND')) {
4022
- rowsEl.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;';
4248
+ // centered: inline-flex so the chips shrink to content inside
4249
+ // the wrapping centered row (the slider joins as a sibling)
4250
+ rowsEl.style.cssText = legendCenter ? 'display:inline-flex;flex-wrap:wrap;gap:4px;'
4251
+ : 'display:flex;flex-wrap:wrap;gap:4px;';
4023
4252
  }
4024
4253
  rowsScroll.appendChild(rowsEl);
4025
4254
 
@@ -4329,6 +4558,101 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4329
4558
  bodyEl.appendChild(desc);
4330
4559
  }
4331
4560
 
4561
+ // flat's TIME SLIDER (legend.js 2488-2530, the szTimeField
4562
+ // branch of makeColorLegendHTMLLong): a theme with a timefield
4563
+ // (style.timefield, resolved into binding.time) gets a slider
4564
+ // over its records' time span — at the span's start every
4565
+ // record shows (setTimeFrame's own start-of-frame rule),
4566
+ // elsewhere a [value, value+range) window. The window size is
4567
+ // flat's own default (a day; 28 days for a span over 120 days,
4568
+ // an hour under 7 — legend.js 2722-2729) and the range buttons
4569
+ // below (hour/day/week/month/year, only the ones the span
4570
+ // supports, and only when there's more than one — legend.js
4571
+ // 2512-2543) replace it, as flat's ixmaps.setSliderRange.
4572
+ if (rt.binding && rt.binding.time) {
4573
+ const timeField = rt.binding.time;
4574
+ const toMs = v => (typeof v === 'number' ? v : new Date(v).getTime());
4575
+ let uMin = Infinity, uMax = -Infinity;
4576
+ rt.features.forEach(f => {
4577
+ const t = toMs((f.properties || {})[timeField]);
4578
+ if (!isNaN(t)) { if (t < uMin) uMin = t; if (t > uMax) uMax = t; }
4579
+ });
4580
+ // one step guard: no span (no/broken time values) — no slider
4581
+ if (Number.isFinite(uMin) && Number.isFinite(uMax) && uMax > uMin) {
4582
+ const uDay = 1000 * 60 * 60 * 24;
4583
+ const days = (uMax - uMin) / uDay;
4584
+ let range = days > 120 ? uDay * 28 : days < 7 ? 1000 * 60 * 60 : uDay;
4585
+ const timeRow = document.createElement('div');
4586
+ timeRow.style.cssText = 'margin-top:10px;font-size:11px;opacity:0.8;';
4587
+ // textContent: the field name comes from the page / project JSON
4588
+ const timeLabel = document.createElement('span');
4589
+ timeLabel.style.cssText = 'font-weight:600;';
4590
+ timeLabel.textContent = String(rt.style.timefield || timeField);
4591
+ const timeSpan = document.createElement('span');
4592
+ timeSpan.className = 'ix-legend-time-span';
4593
+ timeRow.append(timeLabel, ': ', timeSpan);
4594
+ bodyEl.appendChild(timeRow);
4595
+ const timeSlider = document.createElement('input');
4596
+ timeSlider.type = 'range';
4597
+ timeSlider.min = String(uMin);
4598
+ timeSlider.max = String(uMax);
4599
+ timeSlider.value = String(uMin);
4600
+ timeSlider.style.cssText = 'width:100%;margin-top:0.4em;'; // flat's own slider width (legend.js 2503)
4601
+ timeRow.appendChild(timeSlider);
4602
+ const spanEl = () => timeRow.querySelector('.ix-legend-time-span');
4603
+ const applyTimeFrame = () => {
4604
+ const v = Number(timeSlider.value);
4605
+ if (v <= uMin) {
4606
+ // flat: slider at its start position = every record
4607
+ rt.setTimeFrame(null, null);
4608
+ spanEl().textContent = '';
4609
+ } else {
4610
+ rt.setTimeFrame(v, v + range);
4611
+ // flat's span text (legend.js 2737-2745): date - date
4612
+ // for a day-and-up window, date-time under it
4613
+ spanEl().textContent = range < uDay
4614
+ ? new Date(v).toLocaleDateString() + ' ' + new Date(v).toLocaleTimeString()
4615
+ : new Date(v).toLocaleDateString() + ' - ' + new Date(v + range).toLocaleDateString();
4616
+ }
4617
+ // a discrete control, not a pan/zoom stream: full refresh,
4618
+ // so the rows/totals (onRedraw → computeTotals) follow live
4619
+ refresh();
4620
+ };
4621
+ timeSlider.addEventListener('input', applyTimeFrame);
4622
+ const rangeChoices = [
4623
+ ['hour', 1000 * 60 * 60, days < 2],
4624
+ ['day', uDay, days > 1],
4625
+ ['week', uDay * 7, days > 13],
4626
+ ['month', uDay * 28, days > 55],
4627
+ ['year', uDay * 356, days > 365]
4628
+ ].filter(c => c[2]);
4629
+ if (rangeChoices.length > 1) {
4630
+ const btnRow = document.createElement('div');
4631
+ btnRow.style.cssText = 'display:flex;gap:4px;margin-top:4px;';
4632
+ rangeChoices.forEach(([name, ms]) => {
4633
+ const b = document.createElement('button');
4634
+ b.type = 'button';
4635
+ b.textContent = name;
4636
+ const paint = active => {
4637
+ b.style.cssText = 'background:transparent;border:1px solid '
4638
+ + (active ? 'currentColor' : 'rgba(128,128,128,0.5)')
4639
+ + ';color:inherit;font:inherit;font-size:10px;padding:1px 7px;'
4640
+ + 'border-radius:4px;cursor:pointer;opacity:' + (active ? 1 : 0.55) + ';'
4641
+ + (active ? 'font-weight:600;' : '');
4642
+ };
4643
+ paint(ms === range);
4644
+ b.addEventListener('click', () => {
4645
+ range = ms;
4646
+ [...btnRow.children].forEach(x => paint(x === b));
4647
+ applyTimeFrame();
4648
+ });
4649
+ btnRow.appendChild(b);
4650
+ });
4651
+ timeRow.appendChild(btnRow);
4652
+ }
4653
+ }
4654
+ }
4655
+
4332
4656
  // Chart-size slider — real engine's own range (legend.js:
4333
4657
  // 3196-3202), 100% == scale:1, wired to changeThemeStyle(
4334
4658
  // themeId,"scale:"+pct/100,"set")+redrawTheme there; this
@@ -4353,20 +4677,30 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4353
4677
  : Math.round((styleNum(rt.style.scale) || 1) * 100);
4354
4678
  const sliderLabel = isOpacitySlider ? 'Opacity' : 'Chart size';
4355
4679
  sliderRow.innerHTML = sliderLabel + ': <span class="ix-legend-scale-val">' + initialPct + '</span>%';
4356
- bodyEl.appendChild(sliderRow);
4680
+ // flat's center legend: the slider is INLINE in the chips row
4681
+ // (legend.css 541-550: inline-block, one item width, no wrap,
4682
+ // its input 80px) — a sibling of the chips container so the
4683
+ // row re-render can't wipe it
4684
+ const sliderHost = legendCenter && rowsScroll ? rowsScroll : bodyEl;
4685
+ sliderRow.style.cssText = legendCenter && rowsScroll
4686
+ ? 'display:inline-block;margin:0 0 0.5em 1.5em;font-size:11px;opacity:0.8;white-space:nowrap;'
4687
+ : sliderRow.style.cssText;
4688
+ sliderHost.appendChild(sliderRow);
4357
4689
  const slider = document.createElement('input');
4358
4690
  slider.type = 'range';
4359
4691
  slider.min = isOpacitySlider ? '0' : '25';
4360
4692
  slider.max = isOpacitySlider ? '100' : '200';
4361
4693
  slider.value = String(initialPct);
4362
- slider.style.cssText = 'width:50%;margin-top:0.4em;'; // flat's own inline slider style (legend.js 2392)
4694
+ slider.style.cssText = legendCenter && rowsScroll
4695
+ ? 'width:80px;vertical-align:middle;margin:0 0 0 0.5em;' // flat's inline slider width (legend.css 548)
4696
+ : 'width:50%;margin-top:0.4em;'; // flat's own inline slider style (legend.js 2392)
4363
4697
  slider.addEventListener('input', () => {
4364
4698
  const pct = parseInt(slider.value, 10);
4365
4699
  sliderRow.querySelector('.ix-legend-scale-val').textContent = pct;
4366
4700
  rt.setStyle(isOpacitySlider ? { fillopacity: pct / 100 } : { scale: pct / 100 });
4367
4701
  quietRefresh();
4368
4702
  });
4369
- bodyEl.appendChild(slider);
4703
+ sliderHost.appendChild(slider);
4370
4704
  }
4371
4705
  }
4372
4706
  };
@@ -4434,7 +4768,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4434
4768
  this._layerBuilders.push(lb);
4435
4769
  return lb;
4436
4770
  }
4437
- this._layerBuilders.push(unwrapLayer(nameOrBuilder));
4771
+ this._layerBuilders.push(asLayerBuilder(nameOrBuilder));
4438
4772
  return this;
4439
4773
  }
4440
4774
  // .Layer() — alias of .layer()
@@ -4459,8 +4793,28 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4459
4793
  // the Map(id, opts, map => map.options(...)) callback form worked).
4460
4794
  // One microtask lets the caller's whole synchronous chain land first.
4461
4795
  await null;
4462
- const el = document.getElementById(this.containerId);
4463
- if (!el) throw new Error(`[ixmaps-gl] container #${this.containerId} not found`);
4796
+ let el = document.getElementById(this.containerId);
4797
+ if (!el) {
4798
+ // flat's own fallback for a missing target div (htmlgui_flat.js
4799
+ // 1055-1082): auto-create it instead of hard-failing, with a
4800
+ // RANDOMIZED id suffix — two calls that both forgot their div
4801
+ // and both name the same id must not collide on one element —
4802
+ // opt.width/height (else 100%), appended to document.body, and a
4803
+ // loud warning; reassigning this.containerId keeps every
4804
+ // downstream lookup (the MapLibre container, the splash cleanup
4805
+ // in createMap) consistent automatically
4806
+ const szOriginalDiv = this.containerId;
4807
+ do {
4808
+ this.containerId = szOriginalDiv + '-' + Math.random().toString(36).slice(2, 8);
4809
+ } while (document.getElementById(this.containerId));
4810
+ console.warn('[ixmaps-gl] container #' + szOriginalDiv + ' not found in the document — creating #' + this.containerId
4811
+ + ' automatically. Add <div id="' + szOriginalDiv + '"></div> to the page to avoid this warning.');
4812
+ el = document.createElement('div');
4813
+ el.id = this.containerId;
4814
+ el.style.width = (this.mapOptions && this.mapOptions.width) ? String(this.mapOptions.width) : '100%';
4815
+ el.style.height = (this.mapOptions && this.mapOptions.height) ? String(this.mapOptions.height) : '100%';
4816
+ document.body.appendChild(el);
4817
+ }
4464
4818
  // flat's init replaces the container's content with its own map page
4465
4819
  // (mappage.html via innerHTML), so a page's placeholder inside the
4466
4820
  // map div ("connecting to Internet ...", the fuel-price page) goes
@@ -4562,6 +4916,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4562
4916
  // build() and every later defineLayer()/refreshTheme() share it
4563
4917
  const dataCache = this._dataCache = this._dataCache || new Map();
4564
4918
  const runtimes = [];
4919
+ const sphereBackdrops = []; // {type:"Sphere"} layers read before the map exists
4565
4920
  // themes whose data comes by name (see namedDataTheme) start empty and
4566
4921
  // load on their own once the map is built, as in flat, where every
4567
4922
  // theme loads independently: a broker that answers late — or only
@@ -4593,9 +4948,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4593
4948
  // instead of colliding with each other under one 'null' entry.
4594
4949
  let raw, cacheKey;
4595
4950
  const deferred = deferredFeatureLoad(spec, initialZoom);
4596
- if (deferred || named) {
4951
+ // flat's WMS theme type (maptheme.js 8207-8247, the szFlag /\bWMS|IMAGE\b/
4952
+ // realize branch) has NO data of its own — .data({server}) is an
4953
+ // Esri MapServer export endpoint rendered as one image per view.
4954
+ // Here the same theme becomes a MapLibre raster tile source (one
4955
+ // 256px image per XYZ tile via the {bbox-epsg-3857} placeholder —
4956
+ // real cached tiles, not a re-fetched viewport image per pan), see
4957
+ // syncWmsLayers: no records to load, ever
4958
+ const wms = spec.flags.has('WMS') || spec.flags.has('IMAGE');
4959
+ if (deferred || named || wms) {
4597
4960
  raw = { type: 'FeatureCollection', features: [] };
4598
- cacheKey = named ? 'name:' + spec.data.name : 'deferred:' + spec.name;
4961
+ cacheKey = named ? 'name:' + spec.data.name : wms ? 'wms:' + spec.name : 'deferred:' + spec.name;
4599
4962
  } else if (spec.data && spec.data.obj) {
4600
4963
  raw = await fetchLayerData(spec.data, spec.binding, this._engineOptions);
4601
4964
  cacheKey = 'obj:' + spec.name;
@@ -4618,6 +4981,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4618
4981
  rt._named = named;
4619
4982
  rt._zoomToPending = wantsZoomToExtent(spec.flags);
4620
4983
  runtimes.push(rt);
4984
+ if (fc._sphere) sphereBackdrops.push(rt.style);
4621
4985
  notifyNewTheme(rt);
4622
4986
  _globalThemeRegistry.set(rt.name, rt);
4623
4987
  // See findRuntime's own comment on the three real theme-id
@@ -4676,6 +5040,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4676
5040
  renderWorldCopies: this._engineOptions.worldcopies !== false
4677
5041
  });
4678
5042
  engineOptionsByMap.set(map, this._engineOptions);
5043
+ sphereBackdrops.forEach(style => applySphereBackdrop(map, style));
4679
5044
  // the live-SVG pane USER charts render into (see chartHostFor) —
4680
5045
  // registered here, keyed by this map's options object, so every
4681
5046
  // runtime (including later loadProject/map.layer ones) finds it
@@ -5166,6 +5531,16 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5166
5531
  const rt = findRuntime(themeId);
5167
5532
  if (rt) { rt._hidden = !visible; refresh(); }
5168
5533
  },
5534
+ // flat's Api.switchLayer (htmlgui.js 3383, htmlgui_flat.js 1531):
5535
+ // every theme of the LAYER named — setThemeVisible takes one theme
5536
+ // id, this takes the layer name and switches all of its themes
5537
+ switchLayer: (layerName, visible) => {
5538
+ const targets = runtimes.filter(r => r.name === layerName);
5539
+ if (!targets.length) console.warn(`[ixmaps-gl] switchLayer: no layer named "${layerName}"`);
5540
+ targets.forEach(rt => { rt._hidden = !visible; });
5541
+ if (targets.length) refresh();
5542
+ return engineApi;
5543
+ },
5169
5544
  getCategoryColor: (themeId, value) => {
5170
5545
  const rt = findRuntime(themeId);
5171
5546
  if (!rt || !rt.categoryIndexByLabel) return null;
@@ -5300,9 +5675,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5300
5675
  for (const rt of targets) {
5301
5676
  if (rt._named) { await loadNamedTheme(rt, true); continue; }
5302
5677
  const spec = normalizeTheme(rt._definition);
5303
- // flat's refresh reloads, cache or not (maptheme.js 2366: datacache = false);
5304
- // the fresh result also replaces the cached one for later themes
5305
- const raw = spec.data && spec.data.obj
5678
+ // a WMS theme has no records to reload (see build()'s wms
5679
+ // branch) — its raster layer is re-synced by the refresh below
5680
+ const raw = (spec.flags.has('WMS') || spec.flags.has('IMAGE'))
5681
+ ? { type: 'FeatureCollection', features: [] }
5682
+ : spec.data && spec.data.obj
5306
5683
  ? await fetchLayerData(spec.data, rt._specBinding, builder._engineOptions)
5307
5684
  : await cachedLayerData(builder._dataCache, spec.data, rt._specBinding, builder._engineOptions, { reload: true }).promise;
5308
5685
  rt.replaceFeatures(filterAndJoin(raw, spec, runtimes));
@@ -5531,8 +5908,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5531
5908
  let raw;
5532
5909
  const deferred = deferredFeatureLoad(spec, map.getZoom());
5533
5910
  const named = namedDataTheme(spec);
5911
+ // WMS themes carry no records (see build()'s own wms branch)
5912
+ const wms = spec.flags.has('WMS') || spec.flags.has('IMAGE');
5534
5913
  let sourceKey = null;
5535
- if (deferred || named) {
5914
+ if (deferred || named || wms) {
5536
5915
  raw = { type: 'FeatureCollection', features: [] };
5537
5916
  } else if (spec.data && spec.data.obj) {
5538
5917
  // in memory already — nothing to cache (see build())
@@ -5552,6 +5931,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5552
5931
  rt._dataSourceKey = named ? 'name:' + spec.data.name
5553
5932
  : sourceKey || JSON.stringify({ url: spec.data && spec.data.url, urls: spec.data && spec.data.urls, type: spec.data && spec.data.type, query: spec.data && spec.data.query, obj: !!(spec.data && spec.data.obj) });
5554
5933
  runtimes.push(rt);
5934
+ if (fc._sphere) applySphereBackdrop(map, rt.style);
5555
5935
  _globalThemeRegistry.set(rt.name, rt);
5556
5936
  if (rt.meta && rt.meta.name && rt.meta.name !== rt.name) _globalThemeRegistry.set(rt.meta.name, rt);
5557
5937
  if (themeIdOf(rt) !== rt.name) _globalThemeRegistry.set(themeIdOf(rt), rt);
@@ -5794,7 +6174,71 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5794
6174
  if (order !== refreshLayers._lastOrder) { refreshLayers._lastOrder = order; console.info('[ixmaps-gl debug] draw order (bottom → top): ' + order); }
5795
6175
  }
5796
6176
  overlay.setProps({ layers });
6177
+ // flat's WMS themes render as native raster layers, not deck.gl —
6178
+ // reconcile them with the current runtimes on every rebuild
6179
+ syncWmsLayers();
6180
+ }
6181
+
6182
+ // flat's WMS theme type (szFlag /\bWMS|IMAGE\b/, maptheme.js 8207-8247):
6183
+ // .data({server}) names an Esri MapServer export endpoint, flat draws
6184
+ // ONE f=image request per view (bbox in 4326, the view's pixel size),
6185
+ // opacity from fillOpacity || nOpacity || 0.8. Here the same theme is
6186
+ // a MapLibre raster tile source instead — the SAME export call per
6187
+ // 256px XYZ tile ({bbox-epsg-3857} → bboxSR=3857), which MapLibre
6188
+ // caches, loads progressively and never re-fetches per pan step;
6189
+ // deck.gl's interleaved layers stay on top of it. server is read
6190
+ // from .style() as flat does (maptheme.js 1408: styleObj.server),
6191
+ // or from .data() as this port's own pages write it.
6192
+ // ids of the raster layers this function ever added — lets it skip
6193
+ // map.getStyle() (a deep copy of the whole style) on every rebuild
6194
+ // of a page that has no WMS theme
6195
+ const wmsLayerIds = new Set();
6196
+ function syncWmsLayers() {
6197
+ if (!map || !map.getLayer) return;
6198
+ const idOf = name => 'ixmaps-wms:' + String(name).replace(/[^a-zA-Z0-9_-]/g, '_');
6199
+ const desired = new Map();
6200
+ runtimes.forEach(rt => {
6201
+ if (!(rt.flags.has('WMS') || rt.flags.has('IMAGE'))) return;
6202
+ const server = (rt.style && rt.style.server) || (rt._definition && rt._definition.data && rt._definition.data.server);
6203
+ if (server) desired.set(rt.name, { rt, server });
6204
+ });
6205
+ if (!desired.size && !wmsLayerIds.size) return;
6206
+ // a removed WMS theme takes its raster layer with it — as flat's
6207
+ // clearWMS does (ids are sanitized names, compare id-to-id)
6208
+ const ownedIds = new Set([...desired.keys()].map(idOf));
6209
+ [...wmsLayerIds].forEach(id => {
6210
+ if (ownedIds.has(id)) return;
6211
+ wmsLayerIds.delete(id);
6212
+ try { if (map.getLayer(id)) map.removeLayer(id); } catch (e) { /* already gone */ }
6213
+ try { if (map.getSource(id)) map.removeSource(id); } catch (e) { /* already gone */ }
6214
+ });
6215
+ desired.forEach(({ rt, server }, name) => {
6216
+ const id = idOf(name);
6217
+ wmsLayerIds.add(id);
6218
+ const sep = server.indexOf('?') >= 0 ? '&' : '?';
6219
+ const tiles = [server + sep + 'f=image&transparent=true&bbox={bbox-epsg-3857}&bboxSR=3857&size=256,256'];
6220
+ if (!map.getSource(id)) {
6221
+ map.addSource(id, { type: 'raster', tiles, tileSize: 256 });
6222
+ }
6223
+ if (!map.getLayer(id)) {
6224
+ map.addLayer({ id, type: 'raster', source: id, paint: { 'raster-opacity': 1 } });
6225
+ }
6226
+ // flat's own opacity reading (maptheme.js 8240): fillOpacity ||
6227
+ // nOpacity || 0.8 — style "opacity" already folded into
6228
+ // fillopacity by normalizeTheme
6229
+ const opacity = styleNum(rt.style.fillopacity) || 0.8;
6230
+ if ((map.getPaintProperty(id, 'raster-opacity') || 1) !== opacity) {
6231
+ map.setPaintProperty(id, 'raster-opacity', opacity);
6232
+ }
6233
+ const vis = rt._hidden ? 'none' : 'visible';
6234
+ if ((map.getLayoutProperty(id, 'visibility') || 'visible') !== vis) {
6235
+ map.setLayoutProperty(id, 'visibility', vis);
6236
+ }
6237
+ });
5797
6238
  }
6239
+ // a style swap (setMapType) drops every native layer — rebuild the
6240
+ // WMS ones once the new style stands
6241
+ map.on('style.load', () => syncWmsLayers());
5798
6242
 
5799
6243
  // isZooming flips true/false on every 'zoomstart'/'zoomend' pair —
5800
6244
  // and a scroll-wheel or trackpad zoom fires MANY such pairs per
@@ -6090,7 +6534,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6090
6534
  // theirs and are not overridden.
6091
6535
  for (const m of ['replace', 'add', 'newTheme', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
6092
6536
  'refreshTheme', 'redrawTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'setProjection', 'resize',
6093
- 'loadProject', 'project', 'setThemeVisible', 'setThemeTimeFrame', 'show', 'hide', 'getZoom', 'getThemeObj',
6537
+ 'loadProject', 'project', 'setThemeVisible', 'switchLayer', 'setThemeTimeFrame', 'show', 'hide', 'getZoom', 'getThemeObj',
6094
6538
  'getThemes', 'theme', 'setView']) {
6095
6539
  if (!MapBuilder.prototype[m]) {
6096
6540
  MapBuilder.prototype[m] = function (...args) {
@@ -6113,7 +6557,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6113
6557
  // .catch()/await still sees the real failure — this only guarantees
6114
6558
  // the splash doesn't outlive it.
6115
6559
  const promise = builder.build().catch(err => {
6116
- const el = document.getElementById(containerId);
6560
+ // builder.containerId, not the closure's: build() reassigns it when
6561
+ // it had to auto-create a missing container (flat's fallback above)
6562
+ const el = document.getElementById(builder.containerId);
6117
6563
  const splash = el && el.querySelector('.ixmaps-splash');
6118
6564
  if (splash) hideSplash(splash);
6119
6565
  throw err;
@@ -7519,6 +7965,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7519
7965
  // independently-rendered systems (deck.gl's icon quad, MapLibre's own
7520
7966
  // globe-sphere mesh) competing for the same depth-buffer real estate.
7521
7967
  const ICON_LAYER_GLOBE_PARAMETERS = { cullMode: 'none', depthCompare: 'always' };
7968
+ // a transparent 1x1 PNG — the placeholder icon of an external symbol
7969
+ // file still being rasterized (see _buildSequenceChartLayers' urlIcon)
7970
+ const TRANSPARENT_1PX_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
7522
7971
 
7523
7972
  // GLOBE_HORIZON_DEG / isOnVisibleHemisphere: `depthCompare: 'always'`
7524
7973
  // above (needed to stop the near-surface z-fighting/half-moon bug —
@@ -7790,6 +8239,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7790
8239
  switch (shape) {
7791
8240
  case 'squarefull': return Math.abs(dx) <= r && Math.abs(dy) <= r;
7792
8241
  case 'square': return Math.abs(dx) <= r * Math.SQRT1_2 && Math.abs(dy) <= r * Math.SQRT1_2;
8242
+ case 'roundrectfull': return Math.abs(dx) <= r && Math.abs(dy) <= r;
7793
8243
  case 'roundrect': return Math.abs(dx) <= r * 0.8 && Math.abs(dy) <= r * 0.8;
7794
8244
  case 'diamond': case 'carot': return Math.abs(dx) + Math.abs(dy) <= r;
7795
8245
  case 'empty': return false;
@@ -8804,7 +9254,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8804
9254
  const word = w => { const re = new RegExp('\\b' + w + '\\b'); return [...flags].some(f => re.test(f)); };
8805
9255
  const info = {
8806
9256
  sort: word('SORT'), up: word('UP'), down: word('DOWN'),
8807
- star: has('STAR') && !has('CENTER'), horz: has('HORZ') && !has('CENTER'),
9257
+ star: has('STAR') && !flatFlag(flags, 'CENTER'), horz: has('HORZ') && !flatFlag(flags, 'CENTER'),
8808
9258
  expand: has('EXPAND'), expandmax: has('EXPANDMAX'), linear: has('LINEAR'), sizep1: has('SIZEP1'),
8809
9259
  compress: has('COMPRESS'), compressmax: has('COMPRESSMAX')
8810
9260
  };
@@ -9052,6 +9502,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9052
9502
  // which every other caller here still relies on unchanged.
9053
9503
  function resolvePartsClass(value, partsA) {
9054
9504
  if (!partsA || isNaN(value)) return null;
9505
+ // explicit ranges: a value below the first break is class 0, as flat's
9506
+ // class loop (value < parts[0].max) puts it
9507
+ if (partsA.length && partsA[0].clampLow && value < partsA[0].min) return 0;
9055
9508
  for (let i = 0; i < partsA.length; i++) {
9056
9509
  const isLast = i === partsA.length - 1;
9057
9510
  const inLower = value >= partsA[i].min;
@@ -9400,6 +9853,27 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9400
9853
  }
9401
9854
  return bins;
9402
9855
  }
9856
+ // Explicit class breaks: .style({ranges}) with the RANGES flag (flat,
9857
+ // maptheme.js 2324 / 12905-12960). n+1 break values (a comma or "|"
9858
+ // separated string, or an array) are n classes [r_i, r_i+1] — a class
9859
+ // max is nudged by 1e-9 as flat does, so a break value belongs to the class
9860
+ // below it. Not for CATEGORICAL (there the values are exact categories).
9861
+ // null when the theme has no usable explicit ranges.
9862
+ function explicitRangeBreaks(style, flags) {
9863
+ if (!flags || !flags.has('RANGES') || flags.has('CATEGORICAL')) return null;
9864
+ let r = style && style.ranges;
9865
+ if (typeof r === 'string') r = r.split(r.includes('|') ? '|' : ',');
9866
+ if (!Array.isArray(r)) return null;
9867
+ r = r.map(Number);
9868
+ return r.length > 1 && r.every(isFinite) ? r : null;
9869
+ }
9870
+ function explicitRangeParts(breaks) {
9871
+ const parts = [];
9872
+ for (let i = 0; i < breaks.length - 1; i++) parts.push({ min: breaks[i], max: breaks[i + 1] + 0.000000001 });
9873
+ parts[0].clampLow = true;
9874
+ return parts;
9875
+ }
9876
+
9403
9877
  function flatRangeParts(values, nMin, nMax, nParts, flags, centerValue) {
9404
9878
  const rawMin = nMin, rawMax = nMax;
9405
9879
  if (centerValue != null && !isNaN(centerValue)) {
@@ -9523,6 +9997,33 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9523
9997
  // On copies of the features' properties — the page's data is not changed.
9524
9998
  // AGGREGATE themes: not here — flat computes field100 on the aggregated
9525
9999
  // sums (aggregateField100).
10000
+ // .style({weights}) — flat's weighted values: every record's per-field
10001
+ // value × its field's weight, BEFORE the field100 arithmetic (the
10002
+ // per-record multiply in addItemValues, maptheme.js 9007-9009; the
10003
+ // aggregate loader reads the weight per value field at 10506-10508).
10004
+ // One number, or a pipe list with one weight per value field; a field
10005
+ // beyond the list takes the first weight (flat: `szWeightsA[i] ||
10006
+ // szWeightsA[0]`), the aggregate loader instead repeats the LAST one
10007
+ // (10262-10266) — `repeatLast`; no weights at all leaves the values raw.
10008
+ // The Milano page: weights 0.1 over km-long street lengths + FRACTION →
10009
+ // cars per 100 m
10010
+ function fieldWeightOf(style, nFields, repeatLast) {
10011
+ const w = style && style.weights;
10012
+ if (w == null || w === '') return null;
10013
+ const list = (Array.isArray(w) ? w.join('|') : String(w)).split('|').map(v => parseFloat(v));
10014
+ if (!list.some(v => !isNaN(v))) return null;
10015
+ if (repeatLast) { const last = list[list.length - 1]; while (list.length < nFields) list.push(last); }
10016
+ return i => list[i] || list[0] || 1;
10017
+ }
10018
+ // flat's weighted record for the AGGREGATE path (maptheme.js 10506-10508):
10019
+ // the value fields' own values weighted — the class value and the field100
10020
+ // value stay raw, exactly as flat reads them
10021
+ function weightedRecord(f, weightOf) {
10022
+ const p = Object.assign({}, f.properties);
10023
+ if (p.value !== undefined && !isNaN(p.value)) p.value = p.value * weightOf(0);
10024
+ if (Array.isArray(p.series)) p.series = p.series.map((v, i) => (isNaN(v) ? v : v * weightOf(i)));
10025
+ return Object.assign({}, f, { properties: p });
10026
+ }
9526
10027
  function applyField100(features, binding, flags, style) {
9527
10028
  if (!binding || !binding.value || !Array.isArray(features)) return features;
9528
10029
  // AGGREGATE: flat applies field100 to the aggregated sums
@@ -9532,7 +10033,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9532
10033
  const fields = String(binding.value).split('|');
9533
10034
  const auto100 = flags.has('AUTO100') && !flags.has('AGGREGATE');
9534
10035
  const seriesDifference = flags.has('DIFFERENCE') && fields.length > 1;
9535
- if (!f100 && !auto100 && !seriesDifference) return features;
10036
+ // flat's weights run even without a field100 (addItemValues multiplies
10037
+ // every record's values, maptheme.js 9007-9009)
10038
+ const weightOf = fieldWeightOf(style, fields.length, false);
10039
+ if (!f100 && !auto100 && !seriesDifference && !weightOf) return features;
9536
10040
  const f100A = f100 ? String(f100).split('|') : null;
9537
10041
  const fractionScale = styleNum(style && style.fractionscale) || 1;
9538
10042
  const names = fields.map(field => (field[0] === '!' ? field.slice(1) : field));
@@ -9541,6 +10045,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9541
10045
  if (!f || !f.properties) return f;
9542
10046
  const props = Object.assign({}, f.properties);
9543
10047
  let values = names.map(name => parseFloat(f.properties[name]));
10048
+ // the weights run BEFORE the field100 arithmetic, as in flat's
10049
+ // addItemValues — the FRACTION of a weighted value is the weighted
10050
+ // per-unit value
10051
+ if (weightOf) values = values.map((v, i) => (isNaN(v) ? v : v * weightOf(i)));
9544
10052
  if (f100A || auto100) {
9545
10053
  const sum = auto100 ? values.reduce((a, v) => a + (isNaN(v) ? 0 : v), 0) : 0;
9546
10054
  values = values.map((v, i) => {
@@ -9618,11 +10126,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9618
10126
  out._valueMax = nMax;
9619
10127
  const sorted = values.slice().sort((a, b) => a - b);
9620
10128
  out._valueMedian = sorted[Math.floor((sorted.length - 1) / 2)];
9621
- const nParts = Math.trunc(styleNum(style.classes)) || colorSchemeClassCount(style.colorscheme) || DEFAULT_RANGE_CLASSES;
10129
+ const breaks = explicitRangeBreaks(style, flags);
10130
+ const nParts = breaks ? breaks.length - 1
10131
+ : Math.trunc(styleNum(style.classes)) || colorSchemeClassCount(style.colorscheme) || DEFAULT_RANGE_CLASSES;
9622
10132
  const placeholders = new Array(nParts).fill('');
9623
- const colorsRgb = resolveClassColors(style.colorscheme, placeholders, style.classes);
10133
+ const colorsRgb = resolveClassColors(style.colorscheme, placeholders, breaks ? nParts : style.classes);
9624
10134
 
9625
- out.partsA = flatRangeParts(values, nMin, nMax, nParts, flags, styleNum(style.rangecentervalue));
10135
+ out.partsA = breaks ? explicitRangeParts(breaks) : flatRangeParts(values, nMin, nMax, nParts, flags, styleNum(style.rangecentervalue));
9626
10136
  out._nRangeParts = nParts; // the configured count; partsA may have fewer (all values equal)
9627
10137
 
9628
10138
  out.categoryLabels = out.partsA.map(p => `${formatValue(p.min)} - ${formatValue(p.max)}`);
@@ -9760,6 +10270,15 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9760
10270
  // (maptheme.js 11086-11101): each value becomes the next minus it
9761
10271
  // (RELATIVE: in % of it when it is above field100min, else 0), the last
9762
10272
  // one is dropped
10273
+ // How a record joins its cell's running value (flat's MAX / MIN / SUM
10274
+ // aggregation, maptheme.js 10880-10901): MAX / MIN keep the extreme
10275
+ // (a missing part counts 0, as in flat), SUM adds. `first`: the cell's
10276
+ // first record, which just starts the value.
10277
+ function aggCombiner(flags) {
10278
+ const max = flags.has('MAX'), min = flags.has('MIN');
10279
+ return (acc, v, first) => (first ? v : max ? Math.max(acc || 0, v) : min ? Math.min(acc || 0, v) : acc + v);
10280
+ }
10281
+
9763
10282
  function cellAggregatedValues(cell, flags, style) {
9764
10283
  const useMean = flags.has('MEAN');
9765
10284
  const values = cell.sums.map((s, i) => (useMean && cell.counts[i] > 0 ? s / cell.counts[i] : s));
@@ -9825,6 +10344,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9825
10344
  // relocates a field aggregation, maptheme.js 11141) — instead of a grid
9826
10345
  function aggregateOnGrid(features, zoom, cellPx, flags, post, field) {
9827
10346
  const relocate = flags.has('RELOCATE') || !!field;
10347
+ const combine = aggCombiner(flags);
10348
+ // MAX / MIN aggregation: the cell IS one defined record — the one
10349
+ // holding the biggest (smallest) size — so its title follows THAT
10350
+ // record, not the first one's (flat re-sets the merging record's
10351
+ // szTitle under __fMax, maptheme.js 10870-10874; here it is the
10352
+ // record that actually holds it)
10353
+ const maxAgg = flags.has('MAX'), minAgg = flags.has('MIN');
9828
10354
  const cells = new Map();
9829
10355
  for (const f of features) {
9830
10356
  const [lng, lat] = f.geometry.coordinates;
@@ -9846,16 +10372,30 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9846
10372
  (cell.raws = cell.raws || []).push(f.properties.raw);
9847
10373
  }
9848
10374
  cell.sumX += p.x; cell.sumY += p.y; cell.n++;
9849
- cell.value += f.properties.value;
9850
- if (f.properties.classValue !== undefined) cell.classValue += f.properties.classValue;
9851
- if (post) cell.value100 += f.properties.value100 || 0;
10375
+ const first = cell.n === 1;
10376
+ cell.value = combine(cell.value, f.properties.value, first);
10377
+ if (f.properties.classValue !== undefined) cell.classValue = combine(cell.classValue, f.properties.classValue, first);
10378
+ if (post) cell.value100 = combine(cell.value100, f.properties.value100 || 0, first);
9852
10379
  // an AGGREGATE / CATEGORICAL pie's record (flatPieRecord): its parts
9853
10380
  // and size added to the cell's item as flat does (flatPieAccumulate)
9854
10381
  if (f.properties.pie) cell.pie = flatPieAccumulate(cell.pie || null, f.properties.pie, flags);
9855
10382
  const series = f.properties.series;
9856
10383
  if (series) {
9857
10384
  if (!cell.series) cell.series = new Array(series.length).fill(0);
9858
- for (let i = 0; i < series.length; i++) cell.series[i] += series[i];
10385
+ for (let i = 0; i < series.length; i++) cell.series[i] = combine(cell.series[i], series[i], first);
10386
+ }
10387
+ // a multi-field SEQUENCE record's size-field value (flat's
10388
+ // nValueSize): MAX/MIN/summed into the item's nSize, as flat does
10389
+ if (f.properties.size !== undefined) cell.size = combine(typeof cell.size === 'number' ? cell.size : 0, f.properties.size, first);
10390
+ // MAX / MIN: which record the cell's title belongs to (see the
10391
+ // comment at the top — the size field carries the magnitude, else
10392
+ // the combined value)
10393
+ if (maxAgg || minAgg) {
10394
+ const m = typeof f.properties.size === 'number' ? Math.abs(f.properties.size) : Math.abs(f.properties.value || 0);
10395
+ if (cell.titleMax === undefined || (maxAgg ? m > cell.titleMax : m < cell.titleMax)) {
10396
+ cell.titleMax = m;
10397
+ cell.titleFrom = f;
10398
+ }
9859
10399
  }
9860
10400
  }
9861
10401
  const out = [];
@@ -9866,14 +10406,15 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9866
10406
  const geometry = { type: 'Point', coordinates: [ll.lng, ll.lat] };
9867
10407
  const hasClass = cell.first.properties.classValue !== undefined;
9868
10408
  let props = cell.n > 1
9869
- // titleRaw: the cell's first record — flat titles an aggregated
9870
- // item by it (itemTitle)
10409
+ // titleRaw: the record the cell's title belongs to — with MAX/MIN
10410
+ // the one holding the max (min), else the first (flat's itemTitle)
9871
10411
  ? { cluster: true, point_count: cell.n, value: cell.value, ...(hasClass ? { classValue: cell.classValue } : {}),
9872
- titleRaw: cell.first.properties.raw || cell.first.properties,
10412
+ titleRaw: (cell.titleFrom || cell.first).properties.raw || (cell.titleFrom || cell.first).properties,
9873
10413
  ...(cell.raws && cell.raws.length ? { raws: cell.raws } : {}) }
9874
10414
  : cell.first.properties;
9875
10415
  if (cell.series && cell.n > 1) props = Object.assign({}, props, { series: cell.series });
9876
10416
  if (cell.pie) props = Object.assign({}, props, { pie: cell.pie });
10417
+ if (typeof cell.size === 'number' && cell.n > 1) props = Object.assign({}, props, { size: cell.size });
9877
10418
  // a field aggregation's cell key (the field value): the categories of
9878
10419
  // one cell are merged by it (groupCoLocated), not by their positions
9879
10420
  if (field) props = Object.assign({}, props, { aggKey: cell.key });
@@ -9937,7 +10478,18 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9937
10478
  let cell = cells.get(key);
9938
10479
  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); }
9939
10480
  cell.sumX += p.x; cell.sumY += p.y; cell.n++;
9940
- if (!cell.titleRaw) cell.titleRaw = f.properties.titleRaw || f.properties.raw || null;
10481
+ // the cell's title: with MAX/MIN aggregation the member holding the
10482
+ // biggest (smallest) size (the cell IS that one item), else the
10483
+ // first — flat's itemTitle
10484
+ const maxAgg = flags.has('MAX'), minAgg = flags.has('MIN');
10485
+ const titleOf = f => f.properties.titleRaw || f.properties.raw || null;
10486
+ if (maxAgg || minAgg) {
10487
+ const m = typeof f.properties.size === 'number' ? Math.abs(f.properties.size) : Math.abs(f.properties.total || f.properties.value || 0);
10488
+ if (cell.titleMax === undefined || (maxAgg ? m > cell.titleMax : m < cell.titleMax)) {
10489
+ cell.titleMax = m;
10490
+ cell.titleRaw = titleOf(f);
10491
+ }
10492
+ } else if (!cell.titleRaw) cell.titleRaw = titleOf(f);
9941
10493
  // the members' own records, for the tooltip's {{theme.item.data}} —
9942
10494
  // flat's __add_data tables them (numbered, up to 50) for aggregated
9943
10495
  // items too; references only, so no copy is made. A member can itself
@@ -9952,6 +10504,18 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9952
10504
  }
9953
10505
  cell.counts[f.properties.cat] += f.properties.value;
9954
10506
  if (f.properties.classValue !== undefined) cell.classTotal = (cell.classTotal || 0) + f.properties.classValue;
10507
+ // a multi-field SEQUENCE cell's per-field parts and its size-field
10508
+ // value (see _buildAggregationIndex) — combined with the theme's
10509
+ // own MAX/MIN/sum combiner, so a re-grouped cell keeps them
10510
+ const combineSeries = cell.combineSeries || (cell.combineSeries = aggCombiner(flags));
10511
+ const memberSeries = f.properties.series;
10512
+ if (memberSeries) {
10513
+ if (!cell.series) cell.series = new Array(memberSeries.length).fill(0);
10514
+ for (let i = 0; i < memberSeries.length; i++) cell.series[i] = combineSeries(cell.series[i], memberSeries[i], cell.n === 1);
10515
+ }
10516
+ if (typeof f.properties.size === 'number') {
10517
+ cell.size = combineSeries(typeof cell.size === 'number' ? cell.size : 0, f.properties.size, cell.n === 1);
10518
+ }
9955
10519
  // point_count is the aggregated-record count of a multi-record
9956
10520
  // cell (aggregateOnGrid; absent on a single-record cell)
9957
10521
  // — tracked separately from counts (the summed bound VALUE) purely
@@ -9965,7 +10529,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9965
10529
  geometry: { type: 'Point', coordinates: [ll.lng, ll.lat] },
9966
10530
  properties: { counts: cell.counts, total: cell.counts.reduce((a, c) => a + c, 0), recordCounts: cell.recordCounts, ...(cell.classTotal !== undefined ? { classTotal: cell.classTotal } : {}),
9967
10531
  ...(cell.raws && cell.raws.length ? { raws: cell.raws } : {}),
9968
- ...(cell.titleRaw ? { titleRaw: cell.titleRaw } : {}) }
10532
+ ...(cell.titleRaw ? { titleRaw: cell.titleRaw } : {}),
10533
+ ...(cell.series ? { series: cell.series } : {}),
10534
+ ...(typeof cell.size === 'number' ? { size: cell.size } : {}) }
9969
10535
  };
9970
10536
  });
9971
10537
  }
@@ -10369,6 +10935,33 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10369
10935
  // pure: resolveComposedColor → see "Classification — pure functions"
10370
10936
  _resolveComposedColor(props) { return resolveComposedColor(this, props); }
10371
10937
 
10938
+ // flat's DOPACITY value inside the COMPOSECOLOR branch (maptheme.js
10939
+ // 13489-13505): nMyValue = the item's raw weighted value sum /
10940
+ // ((field100/100) || 1), the max the color blend's own (nMax). The
10941
+ // raw weighted sum is reconstructed from the part values the field100
10942
+ // arithmetic left in the properties — those carry the arithmetic's own
10943
+ // multiplier (FRACTION ×fractionscale, PERMILLE ×1000, the default
10944
+ // percent ×100, see applyField100), which must be divided back out:
10945
+ // flat's nValueSum is the RAW weighted sum, whatever the flags.
10946
+ // (RELATIVE/INVERT subtract after the multiply — not reconstructable
10947
+ // from the parts; flat's own composed branch never pairs them.)
10948
+ _composedDopacityAlpha(props) {
10949
+ const fields = this._multiFields || [];
10950
+ const f100A = this.binding && this.binding.field100 ? String(this.binding.field100).split('|') : null;
10951
+ const multiplier = this.flags.has('FRACTION') ? (styleNum(this.style.fractionscale) || 1)
10952
+ : this.flags.has('PERMILLE') ? 1000 : 100;
10953
+ let sum = 0;
10954
+ fields.forEach((field, i) => {
10955
+ const part = parseFloat(props[field]);
10956
+ if (isNaN(part)) return;
10957
+ const v100 = f100A ? parseFloat(props[f100A[Math.min(i, f100A.length - 1)]]) : NaN;
10958
+ sum += isNaN(v100) ? part : (part * v100) / multiplier;
10959
+ });
10960
+ const v100_0 = f100A ? parseFloat(props[f100A[0]]) : NaN;
10961
+ const nMyValue = sum / ((v100_0 / 100) || 1);
10962
+ return composedDopacityAlpha(this.style, this.flags, nMyValue, this._composeColorMax || 1);
10963
+ }
10964
+
10372
10965
  // pure: buildPartsA → see "Classification — pure functions"
10373
10966
  _buildPartsA() { const r = computeRangeClasses(this.features, this.binding, this.style, this.flags, v => this._formatTooltipValue(v)); if (r) Object.assign(this, r); }
10374
10967
 
@@ -10437,11 +11030,18 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10437
11030
  // 7-class color once it knows each cell's actual aggregated total.
10438
11031
  if (this._multiFields && this._isMultiFieldSequence()) {
10439
11032
  // one bucket; each record carries its field values as the chart's
10440
- // parts. Its value — the draw order — is the first field's, as
10441
- // flat's chart node value (NYC status: value="63" = e-bikes)
11033
+ // parts — and, the same array, as `series` so that an AGGREGATE
11034
+ // theme's grid cells combine them PER FIELD (aggregateOnGrid), as
11035
+ // flat accumulates its per-field nValuesA (maptheme.js 10876-10889)
11036
+ // — plus its size-field value (flat's nValueSize, the item nSize
11037
+ // MAX/MIN/sum). Its value — the draw order — is the first field's,
11038
+ // as flat's chart node value (NYC status: value="63" = e-bikes)
11039
+ const sizeField = this.binding.size && this.binding.size !== '$item$' ? this.binding.size : null;
10442
11040
  this._featuresByCategory = [sourceFeatures.map(f => {
10443
11041
  const parts = this._multiFields.map(k => parseFloat(f.properties[k]) || 0);
10444
- return { type: 'Feature', geometry: f.geometry, properties: { value: parts[0], parts, raw: f.properties } };
11042
+ const size = sizeField ? Math.abs(parseFloat(f.properties[sizeField]) || 0) : undefined;
11043
+ return { type: 'Feature', geometry: f.geometry,
11044
+ properties: { value: parts[0], parts, series: parts, ...(size !== undefined ? { size } : {}), raw: f.properties } };
10445
11045
  })];
10446
11046
  this._clusterIndices = null;
10447
11047
  this._clusterRadiusPx = null;
@@ -10888,7 +11488,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10888
11488
  const post = this._seriesDifference
10889
11489
  ? (v, v100, series) => (series ? cellAggregatedValues({ sums: series, counts: series.map(() => 1) }, this.flags, this.style)[0] : v)
10890
11490
  : this._aggregateField100 ? (v, v100) => aggregateField100(v, v100, this.flags, this.style) : undefined;
10891
- this._clusterIndices = this._featuresByCategory.map(feats => new GridAggregateIndex(radiusPx, this.flags, post, aggField).load(feats));
11491
+ // flat's weights multiply every record's value fields as they are
11492
+ // accumulated (maptheme.js 10506-10508), before the sums' field100
11493
+ // arithmetic above — a weighted SUM, not a sum weighted afterward
11494
+ const weightOf = fieldWeightOf(this.style, (this._multiFields || [this.binding && this.binding.value]).length, true);
11495
+ this._clusterIndices = this._featuresByCategory.map(feats =>
11496
+ new GridAggregateIndex(radiusPx, this.flags, post, aggField).load(weightOf ? feats.map(f => weightedRecord(f, weightOf)) : feats));
10892
11497
  }
10893
11498
 
10894
11499
  // Reverted from the source-accurate two-flat-circle GLOW (radius*6
@@ -11139,6 +11744,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11139
11744
  const raw = props.raw || props.titleRaw;
11140
11745
  const title = this.binding.title && raw && raw[this.binding.title] != null ? String(raw[this.binding.title]) : '';
11141
11746
  const aggregated = !!(props.counts || props.cluster);
11747
+ // MAX / MIN aggregation: the item IS one defined record — the one
11748
+ // holding the max (min) — so it carries that record's real title;
11749
+ // "(n)" is the label of a summed/mean aggregate
11750
+ if (this.flags.has('MAX') || this.flags.has('MIN')) return title;
11142
11751
  if (aggregated && this._clusterRadiusPx && !this._clusterField) {
11143
11752
  const n = groupRecordCount(props);
11144
11753
  if (!title || n > 3) return '(' + n + ')';
@@ -11498,6 +12107,15 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11498
12107
  // unmatched polygon, so the donor's fill serves no purpose once
11499
12108
  // superseded.
11500
12109
  if (this._isChoroplethGeometryDonor) return [];
12110
+ // flat's WMS theme (one server image over the view) renders here as
12111
+ // a native MapLibre raster source+layer managed by syncWmsLayers —
12112
+ // not as deck.gl layers — so its own build returns nothing
12113
+ if (this.flags.has('WMS') || this.flags.has('IMAGE')) return [];
12114
+ // flat's POSITION theme (maptheme.js 8328-8353): loads its data and
12115
+ // draws nothing — it only exists as this layer's id→position provider
12116
+ // for the join (joinChartPositions reads its features, its binding.id
12117
+ // against the consumers' lookup key), so no renderer runs for it
12118
+ if (this.flags.has('POSITION')) return [];
11501
12119
  if (this.flags.has('FEATURE') || this.flags.has('FEATURES')) {
11502
12120
  return featuresHiddenByScale(this.style, liveZoom) ? [] : this._buildFeaturesLayers(liveZoom, bbox);
11503
12121
  }
@@ -11539,6 +12157,20 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11539
12157
  // each shape by its class), not one flat fill
11540
12158
  const byCategory = filled && this.flags.has('CATEGORICAL') && this.categoryIndexByLabel && this.binding.value
11541
12159
  && this.categoryColorsRgb && this.categoryColorsRgb.length;
12160
+ // a NUMERIC bound value classes every shape too (flat's
12161
+ // distributeValues → partsA; paintShape, maptheme.js 13991-14018,
12162
+ // paints each shape in `colorScheme[nIndex]` — a line as
12163
+ // fill:none;stroke:class-color, a polygon filled with it): the
12164
+ // TELRAAM speed segments, green→red by v85
12165
+ const byClass = !byCategory && this._rangeClassed && this.partsA
12166
+ && this.categoryColorsRgb && this.categoryColorsRgb.length
12167
+ // "$item$" (or a field list) is no numeric field: a plain FEATURE
12168
+ // layer keeps its one fill color
12169
+ && this.binding.value && this.binding.value !== '$item$' && !String(this.binding.value).includes('|');
12170
+ const classColorOf = f => {
12171
+ const cls = this._resolvePartsClass(parseFloat(f.properties && f.properties[this.binding.value]));
12172
+ return this.classRgb(cls);
12173
+ };
11542
12174
  const catAlpha = Math.round(255 * (styleNum(this.style.fillopacity) || 1));
11543
12175
  const nodataRgb = parseCssColor(this.style.nodatacolor) || [255, 255, 255];
11544
12176
  const shadow = filled && flatShadowOn(this.style, this.features.length, zoom)
@@ -11558,14 +12190,19 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11558
12190
  // flat puts fillopacity into the shape's fill-opacity only
11559
12191
  // (maptheme.js 13407, 14071-14078): the outline stays opaque
11560
12192
  // (a fill color's own alpha — rgba(…, 0.5) — times fillopacity)
11561
- ...(byCategory ? { updateTriggers: { getFillColor: JSON.stringify(this.categoryColorsRgb) } } : {}),
12193
+ ...(byCategory || byClass ? { updateTriggers: { getFillColor: JSON.stringify(this.categoryColorsRgb), getLineColor: JSON.stringify(this.categoryColorsRgb) } } : {}),
11562
12194
  getFillColor: byCategory
11563
12195
  ? f => {
11564
12196
  const cat = this._resolveClassIndex(f.properties[this.binding.value]);
11565
12197
  return [...(cat != null ? (this.categoryColorsRgb || [])[cat] || nodataRgb : nodataRgb).slice(0, 3), catAlpha];
11566
12198
  }
12199
+ : byClass ? f => [...classColorOf(f).slice(0, 3), catAlpha]
11567
12200
  : filled ? [...hexOrNamedToRgb(raw).slice(0, 3), Math.round(255 * cssColorAlpha(raw) * (styleNum(this.style.fillopacity) || 1))] : [0, 0, 0, 0],
11568
- getLineColor: this.style.linecolor
12201
+ getLineColor: byClass
12202
+ // flat's paintShape strokes a line shape with its class color
12203
+ // (fill:none;stroke:class-color), at stroke-opacity fillopacity
12204
+ ? f => [...classColorOf(f).slice(0, 3), catAlpha]
12205
+ : this.style.linecolor
11569
12206
  ? withAlpha(hexOrNamedToRgb(styleLineColor(this.style.linecolor)), cssColorAlpha(styleLineColor(this.style.linecolor)))
11570
12207
  : [130, 130, 130],
11571
12208
  lineWidthMinPixels: styleNum(this.style.linewidth) || 1,
@@ -11655,12 +12292,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11655
12292
  const isComposeColor = this.flags.has('COMPOSECOLOR');
11656
12293
  const baseOpacity = resolveFillOpacity(this.style, this.mapOptions, zoom);
11657
12294
  // DOPACITY needs a PER-FEATURE opacity, which deck.gl only supports
11658
- // via getFillColor's own alpha channel (its layer-level `opacity`
11659
- // prop is one number for every feature) — see _resolveDopacityAlpha.
11660
- // Not meaningful combined with COMPOSECOLOR (no single driving value
11661
- // per polygon there), matching the real engine's own separate
11662
- // DOPACITY/COMPOSECOLOR branches.
11663
- const dopacityActive = !isComposeColor &&
12295
+ // via the color accessors' own alpha channel (its layer-level
12296
+ // `opacity` prop is one number for every feature) — see
12297
+ // _resolveDopacityAlpha. COMPOSECOLOR runs DOPACITY too, with its own
12298
+ // formula on the item's value sum (flat maptheme.js 13485-13506,
12299
+ // composedDopacityAlpha) — it is only the DOMINANT/range
12300
+ // _resolveDopacityAlpha that has no single driving value there
12301
+ const dopacityActive =
11664
12302
  (this.flags.has('DOPACITY') || this.flags.has('DOPACITYMIN') || this.flags.has('DOPACITYMAX') || this.flags.has('DOPACITYMINMAX'));
11665
12303
  // AGGREGATE CATEGORICAL: every polygon's parts and dominant class
11666
12304
  // (aggregatedCategoricalClass), the biggest part per class over all
@@ -11703,7 +12341,15 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11703
12341
  return {
11704
12342
  type: 'Feature',
11705
12343
  geometry: f.geometry,
11706
- properties: { raw: f.properties, composedColor: this._resolveComposedColor(f.properties) }
12344
+ properties: {
12345
+ raw: f.properties, composedColor: this._resolveComposedColor(f.properties),
12346
+ // flat's DOPACITY inside the COMPOSECOLOR branch (maptheme.js
12347
+ // 13485-13506): opacity from the item's raw weighted value
12348
+ // sum over (field100/100), against the same nMax the color
12349
+ // blend normalizes by. The raw weighted sum is reconstructed
12350
+ // from the fractioned part values (raw = part × its field100)
12351
+ dopacityAlpha: dopacityActive ? this._composedDopacityAlpha(f.properties) : null
12352
+ }
11707
12353
  };
11708
12354
  }
11709
12355
  if (isDominant) {
@@ -11759,6 +12405,29 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11759
12405
  // deck.gl re-runs the color accessors only for new data or a changed trigger
11760
12406
  const colorKey = JSON.stringify([[...this._markedClasses], !!this._onMarksChanged, evidenceMode,
11761
12407
  this.categoryColorsRgb, fallbackRgb, classAlpha, lineRgb]);
12408
+ // flat paints a LINE shape's value color as its STROKE (paintShape on
12409
+ // a line shape, szShapeType line — its DOPACITY writes stroke-opacity
12410
+ // there too): a choropleth over line geometry, the Milano street lines,
12411
+ // shows the value color ON the line; a polygon keeps it as its fill
12412
+ const isLineGeometry = d => { const t = d.geometry && d.geometry.type; return t === 'LineString' || t === 'MultiLineString'; };
12413
+ const valueColorOf = d => {
12414
+ if (!hasData(d)) return [0, 0, 0, 0];
12415
+ const rgb = d.properties.composedColor
12416
+ || (d.properties.cat != null ? (this.categoryColorsRgb || [])[d.properties.cat] : null) || fallbackRgb;
12417
+ const evidence = unmarkedEvidence(d);
12418
+ if (evidence === 'hide') return [0, 0, 0, 0];
12419
+ if (evidence === 'gray') return [...grayOf(rgb), 90];
12420
+ const a = alphaOfClass(d.properties.cat);
12421
+ return d.properties.dopacityAlpha != null ? [...rgb.slice(0, 3), Math.round(d.properties.dopacityAlpha * a * 255)] : withAlpha(rgb, a);
12422
+ };
12423
+ // flat re-styles the SAME shape nodes the FEATURE base drew, so a line
12424
+ // choropleth keeps the base's own line width — the donor's linewidth
12425
+ // is the fallback when the choropleth has none of its own
12426
+ const donorRt = [..._globalThemeRegistry.values()].find(r => r.name === this.name && r._isChoroplethGeometryDonor);
12427
+ const anyLine = collection.features.some(isLineGeometry);
12428
+ const isolating = !!(this._markedClasses.size && !this._onMarksChanged && evidenceMode === 'isolate');
12429
+ const lineW = styleNum(this.style.linewidth)
12430
+ || (anyLine && donorRt && styleNum(donorRt.style.linewidth)) || 1;
11762
12431
  return [new GeoJsonLayer({
11763
12432
  id: this._dataLayerId(`ix-choropleth-${this.name}`),
11764
12433
  data: collection,
@@ -11766,21 +12435,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11766
12435
  pickable: true,
11767
12436
  stroked: true,
11768
12437
  filled: true,
11769
- getFillColor: d => {
11770
- if (!hasData(d)) return [0, 0, 0, 0];
11771
- const rgb = d.properties.composedColor
11772
- || (d.properties.cat != null ? (this.categoryColorsRgb || [])[d.properties.cat] : null) || fallbackRgb;
11773
- const evidence = unmarkedEvidence(d);
11774
- if (evidence === 'hide') return [0, 0, 0, 0];
11775
- if (evidence === 'gray') return [...grayOf(rgb), 90];
11776
- const a = alphaOfClass(d.properties.cat);
11777
- return d.properties.dopacityAlpha != null ? [...rgb.slice(0, 3), Math.round(d.properties.dopacityAlpha * a * 255)] : withAlpha(rgb, a);
11778
- },
11779
- // a per-polygon accessor only while a class is isolated
11780
- getLineColor: this._markedClasses.size && !this._onMarksChanged && evidenceMode === 'isolate'
11781
- ? d => (unmarkedEvidence(d) === 'hide' ? [0, 0, 0, 0] : lineRgb)
11782
- : lineRgb,
11783
- lineWidthMinPixels: styleNum(this.style.linewidth) || 1,
12438
+ getFillColor: d => valueColorOf(d),
12439
+ // a constant unless a shape needs its own color (line geometry, or a
12440
+ // class isolated) — deck.gl then skips a per-item accessor call
12441
+ getLineColor: !anyLine && !isolating ? lineRgb
12442
+ : d => choroplethOutlineColor(isLineGeometry(d), valueColorOf, d, isolating, unmarkedEvidence, lineRgb),
12443
+ lineWidthMinPixels: lineW,
11784
12444
  // per-feature alpha (baked above) already carries the resolved
11785
12445
  // base opacity — a layer-level opacity on TOP of that would
11786
12446
  // multiply it a second time.
@@ -11817,6 +12477,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11817
12477
  const categories = this._plotCategories();
11818
12478
 
11819
12479
  const cells = new Map();
12480
+ const combine = aggCombiner(this.flags);
11820
12481
  source.forEach(f => {
11821
12482
  const ci = this._gridCategoryIndex(f.properties[this.binding.value]);
11822
12483
  if (ci == null) return;
@@ -11834,7 +12495,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11834
12495
  cells.set(key, cell);
11835
12496
  }
11836
12497
  const v = this._resolveAggregateValue(f.properties);
11837
- cell.sums[ci] += v;
12498
+ cell.sums[ci] = combine(cell.sums[ci], v, cell.counts[ci] === 0);
11838
12499
  cell.counts[ci]++;
11839
12500
  });
11840
12501
 
@@ -11872,8 +12533,14 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11872
12533
  // a per-item SEQUENCE chart: CHART|SYMBOL|SEQUENCE, several value
11873
12534
  // fields per item, one part per field (no CATEGORICAL, no AGGREGATE)
11874
12535
  _isMultiFieldSequence() {
12536
+ // AGGREGATE is allowed: flat's loadAndAggregateValuesOfTheme
12537
+ // aggregates a multi-field SEQUENCE theme per FIELD (its per-field
12538
+ // nValuesA, MAX/MIN per field, else summed — maptheme.js 10876-10889)
12539
+ // and AUTO100 then normalizes every item's parts to % of their own
12540
+ // sum (12166-12176) — the parts ride the grid cells as `series` and
12541
+ // the per-item % is applied in _buildSequenceChartLayers
11875
12542
  return this.flags.has('CHART') && this.flags.has('SYMBOL') && flatFlag(this.flags, 'SEQUENCE')
11876
- && !this.flags.has('CATEGORICAL') && !this.flags.has('AGGREGATE') && !this.flags.has('PLOT')
12543
+ && !this.flags.has('CATEGORICAL') && !this.flags.has('PLOT')
11877
12544
  && String(this.binding.value || '').includes('|');
11878
12545
  }
11879
12546
  _itemPlotFields() {
@@ -12684,8 +13351,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12684
13351
  ctx.arcTo(x, y + hh, x, y, rr); ctx.arcTo(x, y, x + ww, y, rr); ctx.closePath();
12685
13352
  };
12686
13353
  path();
12687
- ctx.globalAlpha = look.fillOpacity; ctx.fillStyle = look.fill; ctx.fill();
12688
- if (look.stroke && S > 0) { ctx.globalAlpha = look.strokeOpacity; ctx.strokeStyle = look.stroke; ctx.lineWidth = S * k; ctx.stroke(); }
13354
+ if (!isNoneColor(look.fill)) { ctx.globalAlpha = look.fillOpacity; ctx.fillStyle = look.fill; ctx.fill(); }
13355
+ if (look.stroke && !isNoneColor(look.stroke) && S > 0) { ctx.globalAlpha = look.strokeOpacity; ctx.strokeStyle = look.stroke; ctx.lineWidth = S * k; ctx.stroke(); }
12689
13356
  // getSize is the icon's height: the padding is part of it
12690
13357
  const icon = { url: canvas.toDataURL(), width: canvas.width, height: canvas.height, anchorX: canvas.width / 2, anchorY: canvas.height / 2, id: key };
12691
13358
  return this._cacheIcon(key, icon);
@@ -12936,7 +13603,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12936
13603
  const rangeSource = this._classSeparate && this.binding.size && !normalize ? rawSizeTotals : totals;
12937
13604
  let nMin = Infinity, nMax = -Infinity;
12938
13605
  for (const v of rangeSource) { if (v < nMin) nMin = v; if (v > nMax) nMax = v; }
12939
- breaks = flatRangeParts(totals, nMin, nMax, nParts, this.flags, styleNum(this.style.rangecentervalue));
13606
+ const explicit = explicitRangeBreaks(this.style, this.flags);
13607
+ breaks = explicit ? explicitRangeParts(explicit)
13608
+ : flatRangeParts(totals, nMin, nMax, nParts, this.flags, styleNum(this.style.rangecentervalue));
12940
13609
  }
12941
13610
 
12942
13611
  // The theme's own classes follow the aggregated items, as flat's do
@@ -13288,7 +13957,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13288
13957
  const titleFontPx = titleFontOf(w);
13289
13958
  let title = null;
13290
13959
  if (titleShown) {
13291
- const text = this._itemTitle(d.properties);
13960
+ // flat appends style.titleunits (its parse adds the leading
13961
+ // space, maptheme.js 1470; szTitle += at 18827)
13962
+ const units = this.style.titleunits ? ' ' + this.style.titleunits : '';
13963
+ const text = this._itemTitle(d.properties) ? this._itemTitle(d.properties) + units : null;
13292
13964
  if (text) {
13293
13965
  const f = titleFontPx;
13294
13966
  const lines = wrapTitleLines(text, f, circular ? boxR * 2 : w).split('\n');
@@ -13315,9 +13987,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13315
13987
  return { d, r, w, boxR, rect, title };
13316
13988
  }) : null;
13317
13989
  if (boxShown && circular) {
13318
- const boxRgb = hexOrNamedToRgb(this.style.boxcolor || '#eeeeee');
13319
- const boxAlpha = Math.round(255 * boxOpacity);
13320
- const borderRgb = hexOrNamedToRgb(this.style.bordercolor || '#dddddd');
13990
+ const boxNone = isNoneColor(this.style.boxcolor), borderNone = isNoneColor(this.style.bordercolor);
13991
+ const boxRgb = hexOrNamedToRgb(boxNone ? '#eeeeee' : this.style.boxcolor || '#eeeeee');
13992
+ const boxAlpha = boxNone ? 0 : Math.round(255 * boxOpacity);
13993
+ const borderRgb = borderNone ? null : hexOrNamedToRgb(this.style.bordercolor || '#dddddd');
13321
13994
  // ScatterplotLayer has no pixel offset: a moved chart (align,
13322
13995
  // offsetx/offsety) gets its circle as an icon
13323
13996
  if (alignOf.active) layers.push(new IconLayer({
@@ -13325,7 +13998,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13325
13998
  data: boxLayout, pickable: false,
13326
13999
  getPosition: b => b.d.geometry.coordinates,
13327
14000
  getIcon: b => this._buildSingleIcon(boxRgb, boxAlpha / 255, 'circle', borderRgb,
13328
- Math.round(unitPx / 20 * BUBBLE_ICON_SIZE / Math.max(1, 2 * b.boxR) * 4) / 4),
14001
+ borderNone ? 0 : Math.round(unitPx / 20 * BUBBLE_ICON_SIZE / Math.max(1, 2 * b.boxR) * 4) / 4),
13329
14002
  getSize: b => b.boxR * 2,
13330
14003
  getPixelOffset: b => alignOf(b.d),
13331
14004
  sizeUnits: 'pixels',
@@ -13338,9 +14011,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13338
14011
  getPosition: b => b.d.geometry.coordinates,
13339
14012
  getRadius: b => b.boxR,
13340
14013
  radiusUnits: 'pixels',
13341
- stroked: true,
14014
+ stroked: !borderNone,
13342
14015
  getFillColor: [...boxRgb.slice(0, 3), boxAlpha],
13343
- getLineColor: [...borderRgb.slice(0, 3), 255],
14016
+ getLineColor: [...(borderRgb || boxRgb).slice(0, 3), 255],
13344
14017
  getLineWidth: unitPx / 20,
13345
14018
  lineWidthUnits: 'pixels',
13346
14019
  parameters: ICON_LAYER_GLOBE_PARAMETERS
@@ -13350,7 +14023,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13350
14023
  const boxLook = {
13351
14024
  fill: this.style.boxcolor || 'white',
13352
14025
  fillOpacity: boxOpacity,
13353
- stroke: borderStyle === 'none' ? null : styleLineColor(this.style.bordercolor) || '#bbbbbb',
14026
+ stroke: borderStyle === 'none' || isNoneColor(styleLineColor(this.style.bordercolor)) ? null : styleLineColor(this.style.bordercolor) || '#bbbbbb',
13354
14027
  strokeOpacity: /^(solid|dotted|dashed)$/.test(borderStyle) ? 1 : 0.3,
13355
14028
  };
13356
14029
  const borderWidth = styleNum(this.style.borderwidth) || 1;
@@ -13402,11 +14075,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13402
14075
  }
13403
14076
 
13404
14077
  // flat's per-part SYMBOL branch (everything but CATEGORICAL without
13405
- // AGGREGATE/GROUP) draws a square of side 2r, the other one 1.6r
13406
- // (drawSymbolPath's own square)
14078
+ // AGGREGATE/GROUP) draws a square or roundrect of side 2r, the other
14079
+ // one 1.6r (drawSymbolPath's own square / roundrect)
13407
14080
  const perPartBranch = flatChartBranch(this.flags) === 'symbol'
13408
14081
  && !(this.flags.has('CATEGORICAL') && !this.flags.has('AGGREGATE') && !flatFlag(this.flags, 'GROUP'));
13409
- const shapeOf = props => { const sh = this._resolveSymbolShape(props); return sh === 'square' && perPartBranch ? 'squarefull' : sh; };
14082
+ const shapeOf = props => { const sh = this._resolveSymbolShape(props); return perPartBranch && (sh === 'square' || sh === 'roundrect') ? sh + 'full' : sh; };
13410
14083
  // a range-classed cell is ONE class: one symbol in its class color,
13411
14084
  // fillopacity and linecolor, as a single item (flat draws one
13412
14085
  // symbol) — the packed multi-category icon is for CATEGORICAL cells
@@ -13730,13 +14403,55 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13730
14403
  _buildSequenceChartLayers(charts, zoom, liveZoom) {
13731
14404
  const st = this.style;
13732
14405
  const multi = !!this._multiFields && this._isMultiFieldSequence();
13733
- const seqMax = multi ? (styleNum(st.normalsizevalue) || this._multiFieldPartMax) : this._sequenceMaxValue(zoom);
14406
+ // AGGREGATE + AUTO100 (flat normalizes the aggregated item's
14407
+ // nValuesA to % of their own sum, maptheme.js 12166-12176): every
14408
+ // item's parts in % of that item. Without AGGLEARATE, applyField100
14409
+ // already normalized the records' field values (auto100 there) —
14410
+ // this must not run twice
14411
+ const auto100 = multi && this.flags.has('AUTO100') && this.flags.has('AGGREGATE');
14412
+ // The value scale must be measured on the SAME scale the parts are
14413
+ // drawn in. With AGGREGATE, AUTO100 normalizes every item's parts to
14414
+ // % of their own sum at layout time (the records themselves stay
14415
+ // raw — applyField100 skips AGGREGATE themes), so the raw-features
14416
+ // _multiFieldPartMax (~1.0 here) is 100x off the drawn values and
14417
+ // would blow every part's radius up by sqrt(100). flat's own
14418
+ // nMaxValue fallback (nNormalSizeValue || nAllMaxValue,
14419
+ // maptheme.js 21796) measures nAllMaxValue on the NORMALIZED item
14420
+ // values (12166-12176), so the scale matches the parts. Without
14421
+ // AUTO100 the records were already normalized by applyField100 and
14422
+ // _multiFieldPartMax IS that scale (NYC status page)
14423
+ if (multi && auto100 && !this._seqAuto100Max) {
14424
+ let m = 0;
14425
+ for (const f of this.features) {
14426
+ const parts = this._multiFields.map(k => parseFloat(f.properties && f.properties[k]) || 0);
14427
+ const sum = parts.reduce((a, v) => a + v, 0);
14428
+ if (sum > 0) for (const v of parts) { const nv = v / (sum / 100); if (nv > m) m = nv; }
14429
+ }
14430
+ this._seqAuto100Max = m || 1;
14431
+ }
14432
+ const seqMax = multi
14433
+ ? (auto100 ? this._seqAuto100Max : (styleNum(st.normalsizevalue) || this._multiFieldPartMax))
14434
+ : this._sequenceMaxValue(zoom);
13734
14435
  const unit = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(st.scale) || 1);
13735
14436
  const normalR = NORMAL_RADIUS_PX * unit;
13736
14437
  const sizePow = resolveSizePow(st, this.flags);
13737
- const partRadius = multi
13738
- ? v => Math.max(VALUE_RADIUS_MIN, normalR * Math.pow(Math.abs(v) / seqMax, 1 / sizePow))
13739
- : v => valueRadius(v, liveZoom, st, this.mapOptions, this.flags, seqMax);
14438
+ // flat's size field on a SEQUENCE chart part (maptheme.js 21851-21874):
14439
+ // the radius carries BOTH the part's value AND the item's size —
14440
+ // nMaxRadius · (nValue/nMaxValue)^(1/p) · (nSize/nMaxSize)^(1/p),
14441
+ // nMaxSize the biggest item size (measured on the records; MAX, the
14442
+ // one combiner flat's star-part charts pair with a size field, keeps
14443
+ // the cells' max equal to the records' max)
14444
+ const sizeField = multi && this.binding.size && this.binding.size !== '$item$' ? this.binding.size : null;
14445
+ let sizeMax = 0;
14446
+ if (sizeField) {
14447
+ for (const f of this.features) { const v = Math.abs(parseFloat(f.properties && f.properties[sizeField]) || 0); if (v > sizeMax) sizeMax = v; }
14448
+ }
14449
+ const itemSizeFactor = d => {
14450
+ if (!sizeField) return 1;
14451
+ const sz = typeof d.properties.size === 'number' ? d.properties.size
14452
+ : Math.abs(parseFloat((d.properties.raw || {})[sizeField]) || 0);
14453
+ return sizeMax > 0 ? Math.pow(sz / sizeMax, 1 / sizePow) : 0;
14454
+ };
13740
14455
  const rings = flatFlag(this.flags, 'RINGS');
13741
14456
  const centerValues = flatFlag(this.flags, 'CENTERVALUES');
13742
14457
  const noFill = this._partNoFill || new Set();
@@ -13749,17 +14464,98 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13749
14464
  const borderRgb = st.linecolor && styleLineColor(st.linecolor) !== 'none' ? hexOrNamedToRgb(styleLineColor(st.linecolor)) : null;
13750
14465
  const lineWidth = styleNum(st.linewidth) || 1;
13751
14466
  const symbols = Array.isArray(st.symbols) ? st.symbols : null;
14467
+ // flat draws an external .svg/.png/.jpeg symbol file as an <image>
14468
+ // node (maptheme.js 22190-22199), the chart's own shape otherwise.
14469
+ // Here the same URL is the part's IconLayer icon — deck.gl loads
14470
+ // it itself (its ImageBitmap loader falls back to <img>, which
14471
+ // rasterizes SVG) — NOT tinted: these files carry their own colors,
14472
+ // and flat doesn't recolor an <image> either
14473
+ const urlSymbolOf = i => {
14474
+ const s = symbols && (symbols[i] || symbols[0]);
14475
+ return typeof s === 'string' && /\.(svg|png|jpe?g)([?#]|$)/i.test(s.trim()) ? s.trim() : null;
14476
+ };
14477
+ if (!this._urlIconCache) this._urlIconCache = new Map();
14478
+ const urlIcon = u => {
14479
+ if (!this._urlIconCache.has(u)) {
14480
+ // the placeholder is a transparent 1x1 PNG, NOT the raw symbol
14481
+ // URL: deck.gl would immediately try to load that itself, and an
14482
+ // SVG without natural dimensions (a viewBox only, the bus
14483
+ // pictogram of the City-moving page) makes Chrome's
14484
+ // createImageBitmap throw — a console error and a part that
14485
+ // never renders. The real raster replaces it below
14486
+ const entry = { url: TRANSPARENT_1PX_PNG, width: 256, height: 256, anchorX: 128, anchorY: 128, mask: false, id: 'seq-sym-pending:' + u };
14487
+ this._urlIconCache.set(u, entry);
14488
+ // fetch the symbol ONCE and rasterize it into the descriptor's
14489
+ // own 256px cell here: deck then always sees exactly the declared
14490
+ // dimensions, and dimension-less SVGs work like any other. The
14491
+ // redraw lets the next getIcon see the patched descriptor
14492
+ fetch(u).then(r => r.ok ? r.text() : null).then(text => new Promise((resolve, reject) => {
14493
+ if (typeof text !== 'string' || !/<svg[\s>]/i.test(text)) { reject(new Error('not svg')); return; }
14494
+ if (!/\swidth\s*=/i.test(text) || !/\sheight\s*=/i.test(text)) {
14495
+ const vb = text.match(/viewBox\s*=\s*["']([^"']*)["']/i);
14496
+ const p = vb && vb[1].trim().split(/[\s,]+/).map(Number);
14497
+ if (p && p.length === 4 && isFinite(p[2]) && p[2] > 0 && isFinite(p[3]) && p[3] > 0) {
14498
+ text = text.replace(/<svg/i, '<svg width="' + p[2] + '" height="' + p[3] + '"');
14499
+ }
14500
+ }
14501
+ const img = new Image();
14502
+ img.onload = () => resolve(img);
14503
+ img.onerror = () => reject(new Error('svg decode failed'));
14504
+ img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(text);
14505
+ })).then(img => {
14506
+ const size = entry.width;
14507
+ const canvas = document.createElement('canvas');
14508
+ canvas.width = size; canvas.height = size;
14509
+ const ctx = canvas.getContext('2d');
14510
+ const s = Math.min(size / (img.naturalWidth || size), size / (img.naturalHeight || size));
14511
+ const dw = (img.naturalWidth || size) * s, dh = (img.naturalHeight || size) * s;
14512
+ ctx.drawImage(img, (size - dw) / 2, (size - dh) / 2, dw, dh);
14513
+ entry.url = canvas.toDataURL('image/png');
14514
+ entry.id = 'seq-sym-raster:' + u;
14515
+ if (this._triggerRedraw) this._triggerRedraw();
14516
+ }).catch(() => {
14517
+ // not an SVG (a PNG/JPEG symbol): deck's own URL loading takes
14518
+ // it from here — and a pre-sized SVG that failed to rasterize
14519
+ // gets that same second chance
14520
+ entry.url = u;
14521
+ entry.id = 'seq-sym-natural:' + u;
14522
+ if (this._triggerRedraw) this._triggerRedraw();
14523
+ });
14524
+ }
14525
+ return this._urlIconCache.get(u);
14526
+ };
13752
14527
  const shapeOf = i => { const sh = symbols && (symbols[i] || symbols[0]); return normalizeSymbolShape(sh); };
13753
14528
  const colorOf = i => this.classRgb(i);
13754
14529
  const parts = [], glows = [];
13755
14530
  for (const d of charts) {
13756
- const counts = d.properties.parts || d.properties.counts;
14531
+ // parts: the record's own field values (a per-item chart, or the
14532
+ // single-record cell of an aggregated one); series: a multi-record
14533
+ // AGGREGATE cell's per-field combination (aggregateOnGrid); counts:
14534
+ // the CATEGORICAL variant
14535
+ let counts = d.properties.parts || d.properties.series || d.properties.counts;
14536
+ if (!Array.isArray(counts)) continue;
14537
+ if (auto100) {
14538
+ const sum = counts.reduce((a, v) => a + (v || 0), 0);
14539
+ counts = counts.map(v => sum > 0 ? v / (sum / 100) : 0);
14540
+ }
14541
+ const sizeFactor = itemSizeFactor(d);
14542
+ const partRadius = multi
14543
+ ? v => Math.max(VALUE_RADIUS_MIN, normalR * Math.pow(Math.abs(v) / seqMax, 1 / sizePow) * sizeFactor)
14544
+ : v => valueRadius(v, liveZoom, st, this.mapOptions, this.flags, seqMax);
13757
14545
  let layout = rings ? ringsLayout(counts, partRadius)
13758
14546
  : sequenceLayout(counts, this.flags, partRadius, { maxRadius: normalR, maxValue: seqMax, nParts: nCats, rangeScale: styleNum(st.rangescale) || 0 });
13759
14547
  if (!layout.length) continue;
13760
14548
  // GLOW belongs to the first part (sorted) or to every part, and
13761
14549
  // hides with it when its class is not marked
13762
14550
  const glowParts = new Set(sorted ? [layout[0].i] : layout.map(p => p.i));
14551
+ // the chart's drawn extent (flat: map.Dom.getBox of its shape
14552
+ // group) — the parts' star cluster — for BOX / CIRCULARBOX sizing
14553
+ let ex0 = Infinity, ey0 = Infinity, ex1 = -Infinity, ey1 = -Infinity;
14554
+ layout.forEach(p => {
14555
+ ex0 = Math.min(ex0, p.x - p.r); ey0 = Math.min(ey0, p.y - p.r);
14556
+ ex1 = Math.max(ex1, p.x + p.r); ey1 = Math.max(ey1, p.y + p.r);
14557
+ });
14558
+ d._seqExtent = [ex0, ey0, ex1, ey1];
13763
14559
  if (marking && !gray) {
13764
14560
  layout = layout.filter(p => this._markedClasses.has(p.i)).map(p => ({ ...p, x: 0, y: 0 }));
13765
14561
  if (!layout.length) continue;
@@ -13779,6 +14575,162 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13779
14575
  const offsetOf = item => { const o = alignOf(item.chart); return [o[0] + item.part.x, o[1] + item.part.y]; };
13780
14576
  const alphaOf = item => (gray && !this._markedClasses.has(item.part.i) ? DIMMED_ICON_ALPHA : 255);
13781
14577
  const layers = [];
14578
+ // BOX / CIRCULARBOX with TITLE / BOTTOMTITLE — flat's own box block
14579
+ // (maptheme.js 18757-18925) on a SEQUENCE chart's extent: the star
14580
+ // cluster of parts (d._seqExtent), not one bubble's radius.
14581
+ // - CIRCULARBOX: a circle of max(w,h)/2 · 1.1 + margin, margin
14582
+ // min(2m, m·w/30u), m = boxmargin (2) · u; boxcolor (#eeeeee) at
14583
+ // boxopacity, stroke bordercolor (#dddddd) one SVG unit (u/20)
14584
+ // - the title (TITLE / BOTTOMTITLE) in textcolor (#888888) over
14585
+ // the half-opaque white halo, centered under the circle
14586
+ // (BOTTOMTITLE: one font below its bottom edge), DTEXT font
14587
+ // max(0.9u, min(w/5, 14u/scale)) · textscale, wrapped to the
14588
+ // circle's width — gated by titleupper (shown while the map
14589
+ // scale is that bound or coarser, 18810) and boxupper/boxlower
14590
+ const boxShown = flatFlag(this.flags, 'BOX') && !boxHiddenByScale(st, liveZoom);
14591
+ const circular = flatFlag(this.flags, 'CIRCULAR');
14592
+ const bottomTitle = flatFlag(this.flags, 'BOTTOMTITLE');
14593
+ const titleUpper = scaleDenom(st.titleupper);
14594
+ const titleShown = boxShown && flatFlag(this.flags, 'TITLE') && !!this.binding.title
14595
+ && !(titleUpper && scaleDenominatorAt(liveZoom) > titleUpper);
14596
+ let boxLayout = null;
14597
+ if (boxShown) {
14598
+ const boxMarginU = styleNum(st.boxmargin) || 2;
14599
+ const boxOpacity = isNaN(styleNum(st.boxopacity)) ? 1 : styleNum(st.boxopacity);
14600
+ const titleScale = styleNum(st.textscale) || styleNum(st.valuescale) || 1;
14601
+ const titleFontOf = w => (flatFlag(this.flags, 'DTEXT')
14602
+ ? Math.max(0.9 * unit, Math.min(w / 5, 14 * unit / (styleNum(st.scale) || 1)))
14603
+ : 5 * unit) * titleScale;
14604
+ const alignRight = /right/.test(String(st.align || ''));
14605
+ boxLayout = charts.filter(d => d._seqExtent).map(d => {
14606
+ const [ex0, ey0, ex1, ey1] = d._seqExtent;
14607
+ const w = ex1 - ex0, h = ey1 - ey0;
14608
+ // flat centers the circular box (and its title) on the chart's
14609
+ // bounding-box CENTER (maptheme.js 18793-18798: center =
14610
+ // bBox.x + bBox.width/2, bBox.y + bBox.height/2), not on the
14611
+ // chart's anchor — a star cluster's satellites spread
14612
+ // asymmetrically around it
14613
+ const cx = (ex0 + ex1) / 2, cy = (ey0 + ey1) / 2;
14614
+ const margin = Math.min(2 * boxMarginU * unit, boxMarginU * w / 30);
14615
+ const boxR = circular ? Math.max(w, h) / 2 * 1.1 + margin : 0;
14616
+ let title = null;
14617
+ if (titleShown) {
14618
+ // flat appends style.titleunits to the box title (its parse
14619
+ // gives it the leading space, maptheme.js 1470; szTitle += at
14620
+ // 18827): "27.5" + "km/h" -> "27.5 km/h"
14621
+ const units = st.titleunits ? ' ' + st.titleunits : '';
14622
+ const text = this._itemTitle(d.properties) ? this._itemTitle(d.properties) + units : null;
14623
+ if (text) {
14624
+ const f = titleFontOf(w);
14625
+ const lines = wrapTitleLines(text, f, circular ? boxR * 2 : w).split('\n');
14626
+ const width = Math.max(...lines.map(l => titleTextWidth(l, f)));
14627
+ // flat's title baselines are measured on the resized box
14628
+ // (the circle spans center ± radius, 18798-18830)
14629
+ const base = circular
14630
+ ? (bottomTitle ? cy + boxR + f : cy - boxR - 0.7 * f - (lines.length - 1) * f)
14631
+ : (bottomTitle ? ey1 + f : ey0 - 0.7 * f - (lines.length - 1) * f);
14632
+ const x = circular ? cx + unit / 2 : (alignRight ? ex1 : ex0);
14633
+ const anchor = circular ? 'middle' : (alignRight ? 'end' : 'start');
14634
+ const x0 = anchor === 'middle' ? x - width / 2 : anchor === 'end' ? x - width : x;
14635
+ title = { text: lines.join('\n'), x, anchor, top: base - 0.8 * f, f,
14636
+ extent: [x0, base - 0.905 * f, x0 + width, base + (lines.length - 1) * f + 0.212 * f] };
14637
+ }
14638
+ }
14639
+ let rect = null;
14640
+ if (!circular) {
14641
+ const e = [ex0, ey0, ex1, ey1];
14642
+ if (title) { e[0] = Math.min(e[0], title.extent[0]); e[1] = Math.min(e[1], title.extent[1]) + 0.1 * title.f; e[2] = Math.max(e[2], title.extent[2]); e[3] = Math.max(e[3], title.extent[3]); }
14643
+ rect = [e[0] - margin, e[1] - margin, e[2] + margin, e[3] + margin];
14644
+ }
14645
+ return { d, w, boxR, rect, title, boxOpacity, cx, cy };
14646
+ });
14647
+ if (circular) {
14648
+ const boxNone = isNoneColor(st.boxcolor), borderNone = isNoneColor(st.bordercolor);
14649
+ const boxRgb = hexOrNamedToRgb(boxNone ? '#eeeeee' : st.boxcolor || '#eeeeee');
14650
+ const borderRgb = borderNone ? null : hexOrNamedToRgb(st.bordercolor || '#dddddd');
14651
+ // ScatterplotLayer has no pixel offset: a moved chart (align,
14652
+ // offsetx/offsety) gets its circle as an icon — at the chart's
14653
+ // bounding-box center, flat's circular box position (18793)
14654
+ if (alignOf.active) layers.push(new IconLayer({
14655
+ id: `ix-seqbox-${this.name}-g${this._iconGeneration}`,
14656
+ data: boxLayout, pickable: false,
14657
+ getPosition: b => b.d.geometry.coordinates,
14658
+ getIcon: b => this._buildSingleIcon(boxRgb, b.boxOpacity, 'circle', borderRgb,
14659
+ borderNone ? 0 : Math.round(unit / 20 * BUBBLE_ICON_SIZE / Math.max(1, 2 * b.boxR) * 4) / 4),
14660
+ getSize: b => b.boxR * 2,
14661
+ getPixelOffset: b => { const o = alignOf(b.d); return [o[0] + b.cx, o[1] + b.cy]; },
14662
+ sizeUnits: 'pixels', billboard: true,
14663
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
14664
+ }));
14665
+ else layers.push(new ScatterplotLayer({
14666
+ id: `ix-seqbox-${this.name}`,
14667
+ data: boxLayout, pickable: false,
14668
+ getPosition: b => {
14669
+ // the circle at the chart's bbox center: with no align
14670
+ // offset it moves in map coordinates
14671
+ const o = pixelOffsetLngLat(b.d.geometry.coordinates, [b.cx, b.cy], liveZoom);
14672
+ return o;
14673
+ },
14674
+ getRadius: b => b.boxR,
14675
+ radiusUnits: 'pixels',
14676
+ stroked: !borderNone,
14677
+ getFillColor: b => [...boxRgb.slice(0, 3), Math.round(255 * b.boxOpacity)],
14678
+ getLineColor: [...(borderRgb || boxRgb).slice(0, 3), 255],
14679
+ getLineWidth: unit / 20,
14680
+ lineWidthUnits: 'pixels',
14681
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
14682
+ }));
14683
+ } else {
14684
+ // flat's rectangular box (the chart and its title plus the
14685
+ // margin), the single-symbol path's own _buildBoxIcon drawing
14686
+ const borderStyle = String(st.borderstyle || '');
14687
+ const boxLook = {
14688
+ fill: st.boxcolor || 'white',
14689
+ fillOpacity: isNaN(styleNum(st.boxopacity)) ? 1 : styleNum(st.boxopacity),
14690
+ stroke: borderStyle === 'none' || isNoneColor(styleLineColor(st.bordercolor)) ? null : styleLineColor(st.bordercolor) || '#bbbbbb',
14691
+ strokeOpacity: /^(solid|dotted|dashed)$/.test(borderStyle) ? 1 : 0.3
14692
+ };
14693
+ const borderWidth = styleNum(st.borderwidth) || 1;
14694
+ const borderRadius = styleNum(st.borderradius);
14695
+ const strokeOf = b => borderWidth * 0.2 * b.w / 30;
14696
+ boxLayout.forEach(b => {
14697
+ b.icon = this._buildBoxIcon(b.rect[2] - b.rect[0], b.rect[3] - b.rect[1],
14698
+ isNaN(borderRadius) ? 0 : borderRadius * Math.min(unit, b.w / 30),
14699
+ strokeOf(b), boxLook);
14700
+ });
14701
+ if (boxLayout.length) layers.push(new IconLayer({
14702
+ id: `ix-seqbox-${this.name}-g${this._iconGeneration}`,
14703
+ data: boxLayout, pickable: false,
14704
+ getPosition: b => b.d.geometry.coordinates,
14705
+ getIcon: b => b.icon,
14706
+ getSize: b => b.icon.height / 2,
14707
+ getPixelOffset: b => { const o = alignOf(b.d); return [(b.rect[0] + b.rect[2]) / 2 + o[0], (b.rect[1] + b.rect[3]) / 2 + o[1]]; },
14708
+ sizeUnits: 'pixels', billboard: true,
14709
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
14710
+ }));
14711
+ }
14712
+ if (titleShown) {
14713
+ const titleData = boxLayout.filter(b => b.title);
14714
+ if (titleData.length) layers.push(new TextLayer({
14715
+ id: `ix-seqtitles-${this.name}`,
14716
+ data: titleData, pickable: false,
14717
+ getPosition: b => b.d.geometry.coordinates,
14718
+ getText: b => b.title.text,
14719
+ getSize: b => b.title.f,
14720
+ sizeUnits: 'pixels', fontFamily: 'arial',
14721
+ characterSet: 'auto',
14722
+ lineHeight: 1,
14723
+ getColor: [...hexOrNamedToRgb(st.textcolor || '#888888').slice(0, 3), 255],
14724
+ fontSettings: { sdf: true },
14725
+ outlineWidth: 1 / 7,
14726
+ outlineColor: [255, 255, 255, 128],
14727
+ getTextAnchor: b => b.title.anchor,
14728
+ getAlignmentBaseline: 'top',
14729
+ getPixelOffset: b => { const o = alignOf(b.d); return [b.title.x + o[0], b.title.top + o[1]]; },
14730
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
14731
+ }));
14732
+ }
14733
+ }
13782
14734
  if (glows.length && this.flags.has('GLOW') && !glowHiddenByScale(st, liveZoom)) {
13783
14735
  layers.push(new IconLayer({
13784
14736
  id: `ix-glow-${this.name}-g${this._iconGeneration}`,
@@ -13809,20 +14761,35 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13809
14761
  data: parts, pickable: true,
13810
14762
  getPosition: d => d.geometry.coordinates,
13811
14763
  // the stroke, in the icon raster's pixels (the icon is drawn at 2r)
13812
- getIcon: d => this._buildSingleIcon(colorOf(d.part.i), noFill.has(d.part.i) ? 0 : fillOpacity, shapeOf(d.part.i), borderRgb,
13813
- 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),
14764
+ getIcon: d => {
14765
+ // an external symbol file: the URL icon as flat's <image> (see
14766
+ // urlSymbolOf above), scaled to the part's diameter like flat's
14767
+ // fu.scale(nRadius / nMaxRadius) on a nChartSize image
14768
+ const u = urlSymbolOf(d.part.i);
14769
+ if (u) return urlIcon(u);
14770
+ return this._buildSingleIcon(colorOf(d.part.i), noFill.has(d.part.i) ? 0 : fillOpacity, shapeOf(d.part.i), borderRgb,
14771
+ 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);
14772
+ },
13814
14773
  getSize: d => d.part.r * 2,
13815
14774
  getPixelOffset: offsetOf,
13816
14775
  getColor: d => [255, 255, 255, alphaOf(d)],
13817
14776
  sizeUnits: 'pixels', billboard: true,
13818
14777
  parameters: ICON_LAYER_GLOBE_PARAMETERS
13819
14778
  }));
13820
- // VALUES on each part (maptheme.js 22188-22250): the part's value — with
14779
+ // VALUES on each part (maptheme.js 22188-22260): the part's value — with
13821
14780
  // COUNT its record count, with valuefield on the categorical field the
13822
- // class name — formatted with valuedecimals (else none) plus the unit;
13823
- // font min(1, 3.2 / L) · r · valuescale, L the length of the theme's
13824
- // biggest value text (szMaxText), normal weight, in valuecolor /
13825
- // textcolor, else ChartColors.textColor of the part color.
14781
+ // class name — formatted with valuedecimals (else none) plus the unit.
14782
+ // - a SHAPE part (circle & kin): font min(1, 3.2 / L) · r · valuescale,
14783
+ // L the length of the theme's biggest value text (szMaxText), normal
14784
+ // weight, in valuecolor / textcolor, else ChartColors.textColor of
14785
+ // the part color
14786
+ // - an IMAGE-symbol part (an external .svg, the TELRAAM pictograms):
14787
+ // flat's own other branch (22253-22256) — font max(r · 0.8, 1) ·
14788
+ // valuescale, the text over createTextLabel's background box (white,
14789
+ // its rgba(…,0.3) fill × the 0.5 fill-opacity) and in
14790
+ // ChartColors.lowColor — the 0.7 derivate of the part color —
14791
+ // regardless of valuecolor/textcolor (flat passes cColor.lowColor
14792
+ // straight into createTextLabel)
13826
14793
  // CENTERVALUES: only on the first part (class 0), its own value
13827
14794
  if ((this.flags.has('VALUES') || centerValues) && !valuesHiddenByScale(st, zoom)) {
13828
14795
  const valueScale = styleNum(st.valuescale) || 1;
@@ -13842,14 +14809,88 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13842
14809
  const name = rec ? String(rec[st.valuefield]) : null;
13843
14810
  const t = counting && d.properties.recordCounts ? d.properties.recordCounts[d.part.i] : d.part.v;
13844
14811
  const text = name != null ? name : fmt(t);
14812
+ const image = !!urlSymbolOf(d.part.i);
13845
14813
  const L = Math.max((name != null ? name.length : 0) + 1, maxLen);
13846
- const fontSize = Math.min(1, 3.2 / L) * d.part.r * valueScale;
14814
+ const fontSize = image
14815
+ ? Math.max(d.part.r * 0.8, 1) * valueScale
14816
+ : Math.min(1, 3.2 / L) * d.part.r * valueScale;
13847
14817
  if (fontSize > VALUES_MIN_FONT_PX) {
13848
14818
  labels.push({ geometry: d.geometry, text, fontSize, pixelOffset: offsetOf(d), alpha: alphaOf(d),
13849
- color: textOverride ? hexOrNamedToRgb(textOverride) : flatChartTextRgb(colorOf(d.part.i)) });
14819
+ image,
14820
+ color: image ? flatDerivateRgb(colorOf(d.part.i), 0.7)
14821
+ : textOverride ? hexOrNamedToRgb(textOverride) : flatChartTextRgb(colorOf(d.part.i)) });
13850
14822
  }
13851
14823
  });
13852
14824
  if (labels.length) {
14825
+ // VALUEBACKGROUND on URL-symbol parts: a fading white border
14826
+ // around the value's own CHARACTER SHAPES — the glyphs
14827
+ // re-drawn in white on rings of pixel offsets (a stroke of
14828
+ // copies, no box): the width is a fraction of the font size,
14829
+ // so the halo scales with every label; the shape-part values
14830
+ // keep flat's plain text (no outline)
14831
+ if (flatFlag(this.flags, 'VALUEBACKGROUND')) {
14832
+ const haloLabels = labels.filter(l => l.image);
14833
+ const plainLabels = labels.filter(l => !l.image);
14834
+ if (haloLabels.length) {
14835
+ // a real outline around the characters: the label's own
14836
+ // glyphs re-drawn in white on rings of pixel offsets — a
14837
+ // stroke of copies that hugs the character shapes (deck's
14838
+ // SDF outline is only a faint, buffer-clipped rim). The
14839
+ // base radius is a small fraction of the font size (the
14840
+ // 1px floor keeps it tiny until labels get big), and the
14841
+ // ring alphas fall off, so the border fades to nothing
14842
+ const DIRS = 16, RINGS = [[1, 210], [2, 120], [3.2, 65]];
14843
+ const haloCopies = [];
14844
+ for (const l of haloLabels) {
14845
+ const rb = Math.max(0.75, l.fontSize * 0.015);
14846
+ for (let ring = 0; ring < RINGS.length; ring++) {
14847
+ for (let dir = 0; dir < DIRS; dir++) {
14848
+ const a = (dir / DIRS) * 2 * Math.PI;
14849
+ haloCopies.push({ ...l,
14850
+ dx: Math.cos(a), dy: Math.sin(a),
14851
+ radius: rb * RINGS[ring][0],
14852
+ haloAlpha: Math.round(RINGS[ring][1] * l.alpha / 255) });
14853
+ }
14854
+ }
14855
+ }
14856
+ layers.push(new TextLayer({
14857
+ id: `ix-cluster-values-halo-${this.name}`,
14858
+ data: haloCopies, pickable: false,
14859
+ getPosition: d => d.geometry.coordinates,
14860
+ getText: d => d.text,
14861
+ getSize: d => d.fontSize,
14862
+ getColor: d => [255, 255, 255, d.haloAlpha],
14863
+ getPixelOffset: d => [d.pixelOffset[0] + d.dx * d.radius, d.pixelOffset[1] + d.dy * d.radius],
14864
+ sizeUnits: 'pixels', fontFamily: 'arial', fontWeight: 'normal',
14865
+ characterSet: 'auto',
14866
+ getTextAnchor: 'middle', getAlignmentBaseline: 'center'
14867
+ }));
14868
+ layers.push(new TextLayer({
14869
+ id: `ix-cluster-values-${this.name}`,
14870
+ data: haloLabels, pickable: false,
14871
+ getPosition: d => d.geometry.coordinates,
14872
+ getText: d => d.text,
14873
+ getSize: d => d.fontSize,
14874
+ getColor: d => [...d.color.slice(0, 3), d.alpha],
14875
+ getPixelOffset: d => d.pixelOffset,
14876
+ sizeUnits: 'pixels', fontFamily: 'arial', fontWeight: 'normal',
14877
+ characterSet: 'auto',
14878
+ getTextAnchor: 'middle', getAlignmentBaseline: 'center'
14879
+ }));
14880
+ }
14881
+ if (plainLabels.length) layers.push(new TextLayer({
14882
+ id: `ix-cluster-values-plain-${this.name}`,
14883
+ data: plainLabels, pickable: false,
14884
+ getPosition: d => d.geometry.coordinates,
14885
+ getText: d => d.text,
14886
+ getSize: d => d.fontSize,
14887
+ getColor: d => [...d.color.slice(0, 3), d.alpha],
14888
+ getPixelOffset: d => d.pixelOffset,
14889
+ sizeUnits: 'pixels', fontFamily: 'arial', fontWeight: 'normal',
14890
+ characterSet: 'auto',
14891
+ getTextAnchor: 'middle', getAlignmentBaseline: 'center'
14892
+ }));
14893
+ } else {
13853
14894
  layers.push(new TextLayer({
13854
14895
  id: `ix-cluster-values-${this.name}`,
13855
14896
  data: labels, pickable: false,
@@ -13862,6 +14903,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13862
14903
  characterSet: 'auto',
13863
14904
  getTextAnchor: 'middle', getAlignmentBaseline: 'center'
13864
14905
  }));
14906
+ }
13865
14907
  }
13866
14908
  }
13867
14909
  return layers;
@@ -14409,8 +15451,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14409
15451
  const titleFont = 5 * (num(st.textscale) || num(st.valuescale) || 1);
14410
15452
  const boxOpacity = isNaN(styleNum(st.boxopacity)) ? 1 : styleNum(st.boxopacity);
14411
15453
  const borderStyle = String(st.borderstyle || '');
14412
- const boxFill = [...hexOrNamedToRgb(st.boxcolor || 'white').slice(0, 3), Math.round(255 * boxOpacity)];
14413
- const boxLine = borderStyle === 'none' ? [0, 0, 0, 0]
15454
+ const boxFill = [...hexOrNamedToRgb(isNoneColor(st.boxcolor) ? 'white' : st.boxcolor || 'white').slice(0, 3), isNoneColor(st.boxcolor) ? 0 : Math.round(255 * boxOpacity)];
15455
+ const boxLine = borderStyle === 'none' || isNoneColor(styleLineColor(st.bordercolor)) ? [0, 0, 0, 0]
14414
15456
  : [...hexOrNamedToRgb(styleLineColor(st.bordercolor) || '#bbbbbb').slice(0, 3), Math.round(255 * (/^(solid|dotted|dashed)$/.test(borderStyle) ? 1 : 0.3))];
14415
15457
  const borderWidth = num(st.borderwidth) || 1;
14416
15458
  const lc = st.linecolor != null && st.linecolor !== '' ? styleLineColor(st.linecolor) : null;
@@ -15295,8 +16337,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15295
16337
  ctx.lineTo(cx + r, cy - r * 0.6);
15296
16338
  ctx.lineTo(cx, cy + r);
15297
16339
  break;
16340
+ // flat's per-part SYMBOL roundrect (maptheme.js 22039): side 2r, like
16341
+ // 'squarefull'; the other branch (21639) is 1.6r
16342
+ case 'roundrectfull':
15298
16343
  case 'roundrect': {
15299
- const s = r * 0.8;
16344
+ const s = shape === 'roundrectfull' ? r : r * 0.8;
15300
16345
  if (ctx.roundRect) ctx.roundRect(cx - s, cy - s, s * 2, s * 2, r * 0.3);
15301
16346
  else ctx.rect(cx - s, cy - s, s * 2, s * 2);
15302
16347
  break;
@@ -15365,6 +16410,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15365
16410
  function withAlpha(rgb, a) {
15366
16411
  return a >= 1 ? rgb.slice(0, 3) : [...rgb.slice(0, 3), Math.round(255 * Math.max(0, a))];
15367
16412
  }
16413
+ // a style color of "none": no paint (flat writes it into the SVG style:
16414
+ // fill:none / stroke:none) — neither a color nor a canvas fillStyle
16415
+ function isNoneColor(v) {
16416
+ return typeof v === 'string' && v.trim().toLowerCase() === 'none';
16417
+ }
15368
16418
  function hexOrNamedToRgb(v) {
15369
16419
  const NAMED = { gray: [128, 128, 128], grey: [128, 128, 128], black: [0, 0, 0], white: [255, 255, 255] };
15370
16420
  return parseCssColor(v) || NAMED[v] || [130, 130, 130];
@@ -15481,7 +16531,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15481
16531
  // last built map; called while the map is still building, they run once
15482
16532
  // it is ready.
15483
16533
  const MAP_HANDLE_METHODS = ['replace', 'add', 'newTheme', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
15484
- 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'setProjection', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible', 'setThemeTimeFrame', 'attribution'];
16534
+ 'refreshTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'setProjection', 'resize', 'view', 'options', 'layer', 'loadProject', 'require', 'setThemeVisible', 'switchLayer', 'setThemeTimeFrame', 'attribution'];
15485
16535
  const _mapHandles = Object.create(null);
15486
16536
  function makeMapHandle(szMap) {
15487
16537
  const h = {};
@@ -15496,6 +16546,15 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15496
16546
  }
15497
16547
  h.getZoom = () => { const a = api(); return a ? a.getZoom() : undefined; };
15498
16548
  h.getThemeObj = id => getThemeObj(id);
16549
+ // flat's mapApi.message(szText) (htmlgui_flat.js 1381): a short status
16550
+ // line over the map — the same accepted no-op as the global
16551
+ // ixmaps.message ("flat shows a short status message on the map;
16552
+ // not ported"), here for a page's own map().message('...') calls
16553
+ h.message = () => h;
16554
+ // flat's mapApi.getProjectString() (htmlgui_flat.js 1375): the
16555
+ // project JSON of the map — the global's, of the last built map (a
16556
+ // page's code-view overlay calls it through the handle, WBGT port)
16557
+ h.getProjectString = () => getProjectString();
15499
16558
  // flat's mapApi.theme(szTheme) (htmlgui_flat.js 1571): the theme handle
15500
16559
  h.theme = szTheme => new themeApi(szMap, szTheme);
15501
16560
  // flat's mapApi aliases (htmlgui_flat.js 1286+): show/hide a theme,
@@ -16064,7 +17123,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16064
17123
  // known real page's globe toggle actually uses (see
16065
17124
  // example-world-bubble-projection-toggle.md) — so `ixmaps.szResourceBase`
16066
17125
  // only needs to be SOME string, not a real, working resource base URL.
16067
- const ixmapsSzResourceBase = '';
17126
+ // It is the flat release CDN anyway: flat's own tool scripts required by a
17127
+ // page (tooltip_mustache.js) resolve their dependencies through it —
17128
+ // $.getScript(ixmaps.szResourceBase + "ui/js/tools/format.js") — which
17129
+ // with the empty string 404s against the page's own directory
17130
+ const ixmapsSzResourceBase = 'https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-flat@1/';
16068
17131
  function getProjectString() {
16069
17132
  if (!_lastMapApi || !_lastMapApi.map) return JSON.stringify({ map: {} });
16070
17133
  const map = _lastMapApi.map;
@@ -16157,6 +17220,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16157
17220
  newTheme: (title, theme, flag) => _mapHandle.newTheme(title, theme, flag),
16158
17221
  // flat's time slider: show the records of a theme (null = all themes) whose timefield lies in [min, max)
16159
17222
  setThemeTimeFrame: (szId, nMin, nMax) => _mapHandle.setThemeTimeFrame(szId, nMin, nMax),
17223
+ // flat's ixmaps.switchLayer(szLayerName, fState) (htmlgui.js 3383): every
17224
+ // theme of the named layer on the last built map shown or hidden
17225
+ switchLayer: (szLayerName, fState) => _mapHandle.switchLayer(szLayerName, fState),
16160
17226
  setBasemapOpacity: compatSetBasemapOpacity,
16161
17227
  // this engine's version (flat's own ixmaps.version numbers flat)
16162
17228
  glVersion: IXMAPS_GL_VERSION,
@@ -16182,14 +17248,14 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
16182
17248
  // engine runs in a Node vm there); deliberately NOT on the ixmaps object
16183
17249
  global.__ixmapsGlInternals = {
16184
17250
  IXMAPS_GL_VERSION, applyFlatMaxCharts, youtubeClickToPlay, symbolsFlagCompat, tooltipTable, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout, ringsLayout, pieSliceLayout, pieValueLabelLayout, isBarChart, flatBarLayout, flatChartBox, flatValueRules, flatPieRecord, flatPieAccumulate, flatPieItemValues, flatVectorItems, orthographicPlane, vectorArcHeight, pathBackIndex, greatCircleMeters, cameraOptions, bezierVectorLayout, cubicBezierPoints, arrowMarkerTriangle, fadeGradientStops, flatToArray, hashUnit, multiQuadOffsets, pixelOffsetLngLat, chartTextTransmittance, insideSymbolShape,
16185
- normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, MapBuilder, typeStyleNumbers, styleNum,
17251
+ choroplethOutlineColor, normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, MapBuilder, typeStyleNumbers, styleNum,
16186
17252
  resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
16187
17253
  equalIntervalBreaks, quantileBreaks, naturalBreaks, evenStrideSample, jenksBreakValues, partsFromBreakValues, resolvePartsClass,
16188
17254
  computeAlphaStats, computeMultiFieldClasses, computeDominantStats, resolveDominantClass, computeComposeColorStats,
16189
17255
  resolveComposedColor, computeRangeClasses, colorSchemeClassCount, resolveDopacityAlpha,
16190
17256
  flatColorSweep, applyClassesToColorScheme, resolveClassColors, flatOutlierStats, parseCssColor, flatLegendLook,
16191
17257
  flatToMapLibreZoom, mapLibreToFlatZoom, flatViewToMapLibreZoom, mapLibreToFlatViewZoom, flatSvgProjectionOf, flatSvgProjectionByOptions, normalSizeScaleOf, scaleDenominatorAt, resolveZoomReference, resolveAggregationPx, valuesHiddenByScale,
16192
- fetchLayerData, parseCsvText, dataTableRows, geometryRowsToFeatureCollection, filterFlatValues, applyField100, field100Binding, rangeClassLegendTotals, resolveAggregateValue, classValueSeparate, cellAggregatedValues, oneHot, groupCoLocated, aggregateOnGrid, GridAggregateIndex, flatRangeParts, aggregateField100, valueRadius, itemPlotGeometry, itemAnchor, objectZoomFactor, resolveZoomReference, defaultNormalSizeValue,
17258
+ fetchLayerData, parseCsvText, dataTableRows, geometryRowsToFeatureCollection, filterFlatValues, applyField100, field100Binding, rangeClassLegendTotals, resolveAggregateValue, classValueSeparate, cellAggregatedValues, oneHot, groupCoLocated, aggregateOnGrid, GridAggregateIndex, flatRangeParts, explicitRangeBreaks, explicitRangeParts, resolvePartsClass, isNoneColor, aggregateField100, valueRadius, itemPlotGeometry, itemAnchor, objectZoomFactor, resolveZoomReference, defaultNormalSizeValue,
16193
17259
  applyWhereFilter, joinChartPositions, flatLookupKey, flatShapeCenter, chartHiddenByScale, glowHiddenByScale, isSymbolChart, flatChartAlignOffset, flatChartBranch, flatValueText, categoryValueRecord, flatDerivateRgb, flatBubbleLook, bubbleIconLook, flatChartTextRgb, flatGroupedValue, flatNoBreaks, featuresHiddenByScale, boxHiddenByScale, flatShadowOn, snapToAggregationGrid,
16194
17260
  drawSymbolPath, normalizeSymbolShape,
16195
17261
  flatFormatValue, themeStyleArgs, flatChangedStyleValue, explicitValueDecimals, legendDecimals,