ixmaps-gl 0.2.22 → 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 (3) hide show
  1. package/README.md +7 -4
  2. package/ixmaps-gl.js +1477 -184
  3. 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.22';
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,
@@ -1542,16 +1557,37 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1542
1557
  _filterCache.set(expr, pred);
1543
1558
  return pred;
1544
1559
  }
1545
- // a load-time .filter() drops records for good, so a later changeThemeStyle
1546
- // "filter" must re-filter the unfiltered data (flat re-processes the theme
1547
- // with the new filter) instead of narrowing what the first filter left
1548
- function refilterFrom(raw, spec, runtimes) {
1549
- return expr => {
1550
- const f = applyWhereFilter(raw, expr);
1551
- return f.type === 'Table' ? joinTableFeatures(spec, f, runtimes) : f;
1552
- };
1560
+ // a theme's data after its .filter() (spec.filter, or expr) and, for a table,
1561
+ // the join to geometry. A load-time .filter() drops records for good, so a
1562
+ // later changeThemeStyle "filter" re-runs this on the unfiltered data (flat
1563
+ // re-processes the theme with the new filter) instead of narrowing what the
1564
+ // first filter left.
1565
+ function filterAndJoin(raw, spec, runtimes, expr = spec.filter) {
1566
+ const f = applyWhereFilter(raw, expr);
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
-
1555
1591
  function applyWhereFilter(fc, filterExpr) {
1556
1592
  if (!filterExpr) return fc;
1557
1593
  const pred = flatFilterPredicate(String(filterExpr));
@@ -1659,6 +1695,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1659
1695
  // each layer where its shape is found (maptheme.js 9324, 10376); records
1660
1696
  // with no shape are left out (10387-10392)
1661
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
+ //
1662
1705
  // flat runs the after-aggregation steps after EACH layer's pass, over all
1663
1706
  // items; DIFFERENCE turns a record's value fields into their difference
1664
1707
  // and drops the last, so a second pass leaves the earlier layers' items
@@ -1673,7 +1716,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1673
1716
  const features = [];
1674
1717
  const indexes = layers.map(layer => {
1675
1718
  const index = new Map();
1676
- 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 => {
1677
1720
  // in theme order, as flat's layer holds the themes' shapes
1678
1721
  for (const [k, v] of featurePositionIndex(r)) { if (flatShapeWins(index.get(k), v.area)) index.set(k, v); }
1679
1722
  });
@@ -1760,6 +1803,25 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
1760
1803
  return Math.min(0.9, o);
1761
1804
  }
1762
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
+
1763
1825
  function joinChoroplethFeatures(spec, table, runtimes) {
1764
1826
  // a page can define several FEATURE layers of one name — e.g. the
1765
1827
  // comuni as polygons and as center points (for chart positions); flat
@@ -2092,6 +2154,36 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2092
2154
  // colorscheme (isGeneratedColorScheme above), never as the step count.
2093
2155
  function resolveColorScheme(colorscheme, labels, classes) {
2094
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
+ }
2095
2187
  colorscheme = applyClassesToColorScheme(colorscheme, classes);
2096
2188
  if (isGeneratedColorScheme(colorscheme)) {
2097
2189
  const [, cc1, cc2, nParam1, nParam2] = colorscheme;
@@ -2197,7 +2289,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2197
2289
  // mean — see the NOOUTLIER block in _buildChartLayers) has real
2198
2290
  // behavior now; bare OUTLIER (the real source's "keep ONLY the
2199
2291
  // outliers" inverse mode) stays unimplemented — no config here uses it.
2200
- 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',
2201
2296
  'POW2', 'POW3', 'NOSORT'];
2202
2297
  // flat's PIE / DONUT variants with no rendering here (see _buildPieLayers)
2203
2298
  const PIE_INERT_FLAGS = [/3D/, /VOLUME/, /HEIGHT/, /STARBURST/, /FLOWER/, /RAYS/, /BOW/, /ZOOM/, /DIRECTION/, /DIRECTED/, /POLAR/];
@@ -2478,6 +2573,27 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2478
2573
  return construct;
2479
2574
  }
2480
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
+ }
2481
2597
 
2482
2598
  // ---------------------------------------------------------------
2483
2599
  // Binding aliases. Real ixmaps-flat accepts ~50 .binding() keys and maps
@@ -2488,7 +2604,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2488
2604
  // edit it by hand; `npm run unit` fails when it drifts.
2489
2605
  // ---------------------------------------------------------------
2490
2606
  // <grammar:binding-aliases>
2491
- // 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
2492
2608
  const FLAT_BINDING_ALIASES = {
2493
2609
  "aggregation": "style.aggregationfield",
2494
2610
  "aggregationfield": "style.aggregationfield",
@@ -2550,7 +2666,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2550
2666
  // both — so .style({tooltip}) is a tooltip and .meta({fillopacity}) a style
2551
2667
  // property. GENERATED by test/sync-grammar.mjs — do not edit.
2552
2668
  // <grammar:meta-keys>
2553
- // generated from ixmaps-grammar 0.1.27 — 5 meta keys
2669
+ // generated from ixmaps-grammar 0.1.28 — 5 meta keys
2554
2670
  const FLAT_META_KEYS = ["description","name","snippet","title","tooltip"];
2555
2671
  // </grammar:meta-keys>
2556
2672
 
@@ -2742,7 +2858,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2742
2858
  'sizepow', 'rangescale', 'minvalue', 'maxvalue', 'markersize', 'boxopacity', 'outlierscale', 'valuescale',
2743
2859
  'brightness', 'fractionscale', 'dopacityscale', 'dopacitypow', 'gridwidthpx', 'textscale', 'rangecentervalue',
2744
2860
  'shadowblur', 'shadowdx', 'shadowdy', 'maxshadow', 'offsetx', 'offsety', 'gridx', 'boxmargin', 'borderwidth',
2745
- 'borderradius', 'maxcharts'];
2861
+ 'borderradius', 'maxcharts', 'archeight'];
2746
2862
  function toNumberIfNumeric(v) {
2747
2863
  if (typeof v !== 'string' || !v.trim()) return v;
2748
2864
  const n = Number(v);
@@ -2906,6 +3022,15 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
2906
3022
  }
2907
3023
 
2908
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
+ }
2909
3034
  return { name: def.layer, data: def.data, binding, targets, geometry, flags, typeStr, style, meta, filter };
2910
3035
  }
2911
3036
 
@@ -3062,7 +3187,58 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3062
3187
  light: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
3063
3188
  dark: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
3064
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
+ }
3065
3239
  function resolveBasemapStyleUrl(mapType) {
3240
+ const raster = flatRasterBasemap(mapType);
3241
+ if (raster) return buildRasterBasemapStyle(raster);
3066
3242
  return typeof mapType === 'string' && /dark|black|night|matter/i.test(mapType) ? BASEMAP_STYLE_URLS.dark : BASEMAP_STYLE_URLS.light;
3067
3243
  }
3068
3244
  // Real ixmaps-flat's legend look (tools/legend.js 3773-3845, via
@@ -3171,6 +3347,29 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3171
3347
  return false;
3172
3348
  }
3173
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
+
3174
3373
  function buildBlankBackgroundStyle(color) {
3175
3374
  return {
3176
3375
  version: 8,
@@ -3660,6 +3859,14 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3660
3859
  // explicit request (real engine's own fallback is right-anchored
3661
3860
  // too, just without an opinion on vertical placement).
3662
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());
3663
3870
  // Real ixmaps-flat's SUB-THEME (MapTheme.createSubTheme,
3664
3871
  // maptheme.js 14284-14292 / 15010-15160; map.Themes.enableSubThemes
3665
3872
  // defaults to true): marking fields of a multi-field choropleth
@@ -3729,7 +3936,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3729
3936
  if (legendStack && legendStack.parentNode) return legendStack;
3730
3937
  legendStack = document.createElement('div');
3731
3938
  legendStack.className = 'ix-legend-stack';
3732
- 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;'
3733
3945
  + 'display:flex;flex-direction:column;max-height:66.6%;overflow-y:auto;'
3734
3946
  + 'border-radius:6px;box-shadow:' + shadow + ';pointer-events:auto;';
3735
3947
  if (!document.getElementById('ix-legend-stack-css')) {
@@ -3839,7 +4051,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3839
4051
  const looksDark = typeof builder.__effectiveDark === 'function'
3840
4052
  ? builder.__effectiveDark(isNaN(opacityOpt) ? 1 : opacityOpt, builder.mapOptions.mapType, legendBackground)
3841
4053
  : flatLook.dark;
3842
- const isLightLegend = rt.style.legendtheme === 'light' || (rt.style.legendtheme !== 'dark' && !looksDark);
4054
+ const isLightLegend = legendCenter || rt.style.legendtheme === 'light' || (rt.style.legendtheme !== 'dark' && !looksDark);
3843
4055
  const colors = isLightLegend
3844
4056
  ? { bg: 'rgba(255,255,255,0.92)', fg: '#1a1a1a', shadow: '0 2px 10px rgba(0,0,0,0.18)',
3845
4057
  rowMarked: 'rgba(0,0,0,0.08)', track: 'rgba(0,0,0,0.08)',
@@ -3850,7 +4062,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3850
4062
  // the panel bg follows the same effective decision (flatLegendLook
3851
4063
  // would hand the dark look a light bg here: its dark branch never
3852
4064
  // ran for this map, its light bg would clash with dark rows/text)
3853
- if (!rt.style.legendtheme) colors.bg = looksDark ? '#111' : flatLook.bg;
4065
+ if (!rt.style.legendtheme && !legendCenter) colors.bg = looksDark ? '#111' : flatLook.bg;
3854
4066
  return colors;
3855
4067
  };
3856
4068
  const legendColors = lookColors();
@@ -3868,7 +4080,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3868
4080
  // one box for every theme, as flat's #map-legend (legend.js
3869
4081
  // 2200-2460): the panels stack in the legend stack in theme
3870
4082
  // order, a line between them (see layoutLegends)
3871
- 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;'
3872
4090
  + 'display:flex;flex-direction:column;text-align:left;'
3873
4091
  + 'background:' + legendColors.bg + ';color:' + legendColors.fg + ';font:12px/1.4 -apple-system,Arial,sans-serif;'
3874
4092
  + 'padding:10px 12px 12px;pointer-events:auto;';
@@ -3938,6 +4156,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3938
4156
  // branch uses (no antimeridian wraparound handling — matches
3939
4157
  // that existing convention, not a new gap this introduces).
3940
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;
3941
4162
  // No-op default: TEXTLEGEND never reassigns this (see below),
3942
4163
  // so the LATER unconditional-looking `renderRows()` call
3943
4164
  // safely does nothing rather than needing its own isTextOnly
@@ -3959,8 +4180,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3959
4180
  const c = f.geometry && f.geometry.coordinates;
3960
4181
  if (!c || typeof c[0] !== 'number') return;
3961
4182
  const [lng, lat] = c;
3962
- if (lng < bbox[0] || lng > bbox[2] || lat < bbox[1] || lat > bbox[3]) return;
3963
- if (globeCenter && !isOnVisibleHemisphere(lng, lat, globeCenter)) return;
4183
+ if (!inViewport(lng, lat, bbox, globeCenter)) return;
3964
4184
  rt._multiFields.forEach((k, i) => { totals[i] += parseFloat(f.properties[k]) || 0; });
3965
4185
  });
3966
4186
  maxTotal = Math.max(0, ...totals);
@@ -3981,8 +4201,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3981
4201
  while (Array.isArray(pt) && Array.isArray(pt[0])) pt = pt[0];
3982
4202
  if (!Array.isArray(pt)) return;
3983
4203
  const [lng, lat] = pt;
3984
- if (lng < bbox[0] || lng > bbox[2] || lat < bbox[1] || lat > bbox[3]) return;
3985
- if (globeCenter && !isOnVisibleHemisphere(lng, lat, globeCenter)) return;
4204
+ if (!inViewport(lng, lat, bbox, globeCenter)) return;
3986
4205
  totals[idx] += useSum ? (parseFloat(f.properties[valueField]) || 0) : 1;
3987
4206
  });
3988
4207
  maxTotal = Math.max(0, ...totals);
@@ -3996,8 +4215,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
3996
4215
  // share, then overflow-y:auto scrolls just the row list) —
3997
4216
  // the header above and description/slider below stay put.
3998
4217
  rowsScroll = document.createElement('div');
3999
- rowsScroll.style.cssText = 'flex:1 1 auto;min-height:0;overflow-y:auto;';
4000
- 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');
4001
4224
  rowsEl.className = 'ix-legend-rows';
4002
4225
  // .type("...|SIMPLELEGEND") — real ixmaps-flat's OTHER legend
4003
4226
  // variant (confirmed against a real-engine screenshot, per
@@ -4022,7 +4245,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4022
4245
  // smaller categories packed 3-4 per row) rather than a fixed
4023
4246
  // column count.
4024
4247
  if (rt.flags.has('SIMPLELEGEND') || rt.flags.has('COMPACTLEGEND')) {
4025
- 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;';
4026
4252
  }
4027
4253
  rowsScroll.appendChild(rowsEl);
4028
4254
 
@@ -4066,7 +4292,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4066
4292
  && !(Array.isArray(rt.style.ranges) && rt.style.ranges.length);
4067
4293
  if (flatSingleRowLegend) {
4068
4294
  renderRows = function() {
4069
- const rgb = rt.categoryColorsRgb[0] || [128, 128, 128];
4295
+ const rgb = rt.classRgb(0);
4070
4296
  const unit = String(rt.style.legendunits || rt.style.units || '').replace(/ /g, '&nbsp;');
4071
4297
  const vMin = styleNum(rt.style.minvalue) || rt._valueMin, vMax = styleNum(rt.style.maxvalue) || rt._valueMax;
4072
4298
  const label = rt.style.label != null && !Array.isArray(rt.style.label) ? String(rt.style.label) : String(rt.binding.value || '');
@@ -4099,7 +4325,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4099
4325
  for (let line = 0; line < nLines; line++) {
4100
4326
  for (let c = 0; c < n; c++) {
4101
4327
  const ix = flatFlag(rt.flags, 'INVERT') ? n - c - 1 : c;
4102
- const rgb = rt.categoryColorsRgb[ix] || [128, 128, 128]; // colorscheme "none": no class colors
4328
+ const rgb = rt.classRgb(ix); // colorscheme "none": no class colors
4103
4329
  const part = rt.partsA[ix];
4104
4330
  const title = part ? flatFormatValue(part.min, dec, '') + unit + ' ... ' + flatFormatValue(part.max, dec, '') + unit : '';
4105
4331
  const marked = rt._markedClasses.has(ix);
@@ -4130,7 +4356,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4130
4356
  // flat drops the rows without a count once any row has one
4131
4357
  // (legend.js 754-760, 787-789: fCountBars && !count)
4132
4358
  rowsEl.innerHTML = order.filter(i => !(maxTotal > 0) || totals[i]).map(i => {
4133
- const rgb = rt.categoryColorsRgb[i] || [128, 128, 128]; // colorscheme "none": no class colors
4359
+ const rgb = rt.classRgb(i); // colorscheme "none": no class colors
4134
4360
  const color = legendFill(i) || `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
4135
4361
  const marked = rt._markedClasses.has(i);
4136
4362
  const dimmed = rt._markedClasses.size > 0 && !marked;
@@ -4332,6 +4558,101 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4332
4558
  bodyEl.appendChild(desc);
4333
4559
  }
4334
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
+
4335
4656
  // Chart-size slider — real engine's own range (legend.js:
4336
4657
  // 3196-3202), 100% == scale:1, wired to changeThemeStyle(
4337
4658
  // themeId,"scale:"+pct/100,"set")+redrawTheme there; this
@@ -4356,20 +4677,30 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4356
4677
  : Math.round((styleNum(rt.style.scale) || 1) * 100);
4357
4678
  const sliderLabel = isOpacitySlider ? 'Opacity' : 'Chart size';
4358
4679
  sliderRow.innerHTML = sliderLabel + ': <span class="ix-legend-scale-val">' + initialPct + '</span>%';
4359
- 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);
4360
4689
  const slider = document.createElement('input');
4361
4690
  slider.type = 'range';
4362
4691
  slider.min = isOpacitySlider ? '0' : '25';
4363
4692
  slider.max = isOpacitySlider ? '100' : '200';
4364
4693
  slider.value = String(initialPct);
4365
- 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)
4366
4697
  slider.addEventListener('input', () => {
4367
4698
  const pct = parseInt(slider.value, 10);
4368
4699
  sliderRow.querySelector('.ix-legend-scale-val').textContent = pct;
4369
4700
  rt.setStyle(isOpacitySlider ? { fillopacity: pct / 100 } : { scale: pct / 100 });
4370
4701
  quietRefresh();
4371
4702
  });
4372
- bodyEl.appendChild(slider);
4703
+ sliderHost.appendChild(slider);
4373
4704
  }
4374
4705
  }
4375
4706
  };
@@ -4437,7 +4768,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4437
4768
  this._layerBuilders.push(lb);
4438
4769
  return lb;
4439
4770
  }
4440
- this._layerBuilders.push(unwrapLayer(nameOrBuilder));
4771
+ this._layerBuilders.push(asLayerBuilder(nameOrBuilder));
4441
4772
  return this;
4442
4773
  }
4443
4774
  // .Layer() — alias of .layer()
@@ -4462,8 +4793,28 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4462
4793
  // the Map(id, opts, map => map.options(...)) callback form worked).
4463
4794
  // One microtask lets the caller's whole synchronous chain land first.
4464
4795
  await null;
4465
- const el = document.getElementById(this.containerId);
4466
- 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
+ }
4467
4818
  // flat's init replaces the container's content with its own map page
4468
4819
  // (mappage.html via innerHTML), so a page's placeholder inside the
4469
4820
  // map div ("connecting to Internet ...", the fuel-price page) goes
@@ -4515,8 +4866,15 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4515
4866
  _titleHost = el;
4516
4867
  // a flat SVG map in another projection (equalearth.svg): its view
4517
4868
  // framing and map scale (see FLAT_SVG_PROJECTIONS)
4518
- const svgProjection = flatSvgProjectionOf(this.mapOptions.map);
4869
+ const svgProjection = flatSvgProjectionOf(this.mapOptions.map)
4870
+ || (isFlatOrthographicName(this.mapOptions.mapProjection) ? FLAT_ORTHOGRAPHIC_PROJECTION : null);
4519
4871
  if (svgProjection) flatSvgProjectionByOptions.set(this._engineOptions, svgProjection);
4872
+ const planeProjection = flatPlaneProjectionOf(this.mapOptions);
4873
+ if (planeProjection) flatPlaneProjectionByOptions.set(this._engineOptions, planeProjection);
4874
+ if (isFlatOrthographicName(this.mapOptions.mapProjection)) {
4875
+ flatOrthographicByOptions.set(this._engineOptions, true);
4876
+ if (this._viewCenter) flatViewCenterByOptions.set(this._engineOptions, { lat: this._viewCenter[0], lng: this._viewCenter[1] });
4877
+ }
4520
4878
  const [vLat, vLng] = this._viewCenter || [45.5, 9.2];
4521
4879
  const initialZoom = this._viewZoom != null && this._viewZoom !== ''
4522
4880
  ? flatViewToMapLibreZoom(Number(this._viewZoom), Number(vLat), Number(vLng), el.clientWidth, el.clientHeight, svgProjection)
@@ -4558,6 +4916,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4558
4916
  // build() and every later defineLayer()/refreshTheme() share it
4559
4917
  const dataCache = this._dataCache = this._dataCache || new Map();
4560
4918
  const runtimes = [];
4919
+ const sphereBackdrops = []; // {type:"Sphere"} layers read before the map exists
4561
4920
  // themes whose data comes by name (see namedDataTheme) start empty and
4562
4921
  // load on their own once the map is built, as in flat, where every
4563
4922
  // theme loads independently: a broker that answers late — or only
@@ -4589,9 +4948,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4589
4948
  // instead of colliding with each other under one 'null' entry.
4590
4949
  let raw, cacheKey;
4591
4950
  const deferred = deferredFeatureLoad(spec, initialZoom);
4592
- 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) {
4593
4960
  raw = { type: 'FeatureCollection', features: [] };
4594
- cacheKey = named ? 'name:' + spec.data.name : 'deferred:' + spec.name;
4961
+ cacheKey = named ? 'name:' + spec.data.name : wms ? 'wms:' + spec.name : 'deferred:' + spec.name;
4595
4962
  } else if (spec.data && spec.data.obj) {
4596
4963
  raw = await fetchLayerData(spec.data, spec.binding, this._engineOptions);
4597
4964
  cacheKey = 'obj:' + spec.name;
@@ -4600,10 +4967,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4600
4967
  cacheKey = cached.sourceKey;
4601
4968
  raw = await cached.promise;
4602
4969
  }
4603
- const filtered = applyWhereFilter(raw, spec.filter);
4604
- const fc = filtered.type === 'Table' ? joinTableFeatures(spec, filtered, runtimes) : filtered;
4970
+ const fc = filterAndJoin(raw, spec, runtimes);
4605
4971
  const rt = new LayerRuntime(spec, fc, this._engineOptions);
4606
- if (spec.filter) rt._refilter = refilterFrom(raw, spec, runtimes);
4972
+ if (spec.filter) rt._refilter = expr => filterAndJoin(raw, spec, runtimes, expr);
4607
4973
  // tags which underlying data source this runtime came from — see
4608
4974
  // setFacetFilter/clearFacetFilter/clearAllFacetFilters below,
4609
4975
  // which use this to propagate a facet filter to every theme
@@ -4615,6 +4981,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4615
4981
  rt._named = named;
4616
4982
  rt._zoomToPending = wantsZoomToExtent(spec.flags);
4617
4983
  runtimes.push(rt);
4984
+ if (fc._sphere) sphereBackdrops.push(rt.style);
4618
4985
  notifyNewTheme(rt);
4619
4986
  _globalThemeRegistry.set(rt.name, rt);
4620
4987
  // See findRuntime's own comment on the three real theme-id
@@ -4642,6 +5009,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4642
5009
  : resolveBasemapStyleUrl(this.mapOptions.mapType),
4643
5010
  center: [lon, lat],
4644
5011
  zoom: initialZoom,
5012
+ ...cameraOptions(this._engineOptions, this.mapOptions),
5013
+ // flat's orthographic globe can be centered anywhere (the mechanical-goods page: 70°N);
5014
+ // MapLibre's default keeps the world's Mercator edge out of view, which drags the
5015
+ // center south (64° at 1024×768) and forces a larger zoom (MapLibre >= 6)
5016
+ ...(isFlatOrthographicName(this.mapOptions.mapProjection)
5017
+ ? { transformConstrain: (lngLat, zoom) => ({ center: new maplibregl.LngLat(lngLat.lng, globeCenterLat(lngLat.lat)), zoom }) }
5018
+ : {}),
4645
5019
  // MapLibre's own AttributionControl (bottom right) keeps the
4646
5020
  // basemap's required CARTO/OpenStreetMap credit; the page's own
4647
5021
  // .attribution() goes bottom left, as flat shows it
@@ -4666,6 +5040,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
4666
5040
  renderWorldCopies: this._engineOptions.worldcopies !== false
4667
5041
  });
4668
5042
  engineOptionsByMap.set(map, this._engineOptions);
5043
+ sphereBackdrops.forEach(style => applySphereBackdrop(map, style));
4669
5044
  // the live-SVG pane USER charts render into (see chartHostFor) —
4670
5045
  // registered here, keyed by this map's options object, so every
4671
5046
  // runtime (including later loadProject/map.layer ones) finds it
@@ -5156,6 +5531,16 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5156
5531
  const rt = findRuntime(themeId);
5157
5532
  if (rt) { rt._hidden = !visible; refresh(); }
5158
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
+ },
5159
5544
  getCategoryColor: (themeId, value) => {
5160
5545
  const rt = findRuntime(themeId);
5161
5546
  if (!rt || !rt.categoryIndexByLabel) return null;
@@ -5290,13 +5675,14 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5290
5675
  for (const rt of targets) {
5291
5676
  if (rt._named) { await loadNamedTheme(rt, true); continue; }
5292
5677
  const spec = normalizeTheme(rt._definition);
5293
- // flat's refresh reloads, cache or not (maptheme.js 2366: datacache = false);
5294
- // the fresh result also replaces the cached one for later themes
5295
- 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
5296
5683
  ? await fetchLayerData(spec.data, rt._specBinding, builder._engineOptions)
5297
5684
  : await cachedLayerData(builder._dataCache, spec.data, rt._specBinding, builder._engineOptions, { reload: true }).promise;
5298
- const filtered = applyWhereFilter(raw, spec.filter);
5299
- rt.replaceFeatures(filtered.type === 'Table' ? joinTableFeatures(spec, filtered, runtimes) : filtered);
5685
+ rt.replaceFeatures(filterAndJoin(raw, spec, runtimes));
5300
5686
  maybeZoomToTheme(rt); // a scale-deferred theme gets its first data here
5301
5687
  }
5302
5688
  if (targets.length) { refresh(); notifyRedraw(); }
@@ -5440,9 +5826,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5440
5826
  if (broker) { pendingLoads--; rt._brokerLoading--; setDataLoading(); }
5441
5827
  }
5442
5828
  }
5443
- function namedThemeFeatures(spec, rows, binding) {
5444
- const filtered = applyWhereFilter(rowsResult(rows, binding || spec.binding), spec.filter);
5445
- return filtered.type === 'Table' ? joinTableFeatures(spec, filtered, runtimes) : filtered;
5829
+ function namedThemeFeatures(spec, rows, binding, expr) {
5830
+ return filterAndJoin(rowsResult(rows, binding || spec.binding), spec, runtimes, expr);
5446
5831
  }
5447
5832
  function fillNamedTheme(rt, spec, table, patch) {
5448
5833
  rt._lastTable = table;
@@ -5461,7 +5846,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5461
5846
  spec = pspec;
5462
5847
  }
5463
5848
  rt.replaceFeatures(namedThemeFeatures(spec, rows, rt._specBinding));
5464
- rt._refilter = spec.filter ? expr => namedThemeFeatures(Object.assign({}, spec, { filter: expr }), rows, rt._specBinding) : null;
5849
+ rt._refilter = spec.filter ? expr => namedThemeFeatures(spec, rows, rt._specBinding, expr) : null;
5465
5850
  if (glDebug()) console.info(`[ixmaps-gl debug] data for ${themeIdOf(rt)}: ${rows.length} rows → ${rt.features.length} features`);
5466
5851
  // the legend is built from the theme's classes: build it again
5467
5852
  if (rt._legendPanel && rt._legendPanel.parentNode) rt._legendPanel.parentNode.removeChild(rt._legendPanel);
@@ -5523,8 +5908,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5523
5908
  let raw;
5524
5909
  const deferred = deferredFeatureLoad(spec, map.getZoom());
5525
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');
5526
5913
  let sourceKey = null;
5527
- if (deferred || named) {
5914
+ if (deferred || named || wms) {
5528
5915
  raw = { type: 'FeatureCollection', features: [] };
5529
5916
  } else if (spec.data && spec.data.obj) {
5530
5917
  // in memory already — nothing to cache (see build())
@@ -5534,10 +5921,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5534
5921
  sourceKey = cached.sourceKey;
5535
5922
  raw = await cached.promise;
5536
5923
  }
5537
- const filtered = applyWhereFilter(raw, spec.filter);
5538
- const fc = filtered.type === 'Table' ? joinTableFeatures(spec, filtered, runtimes) : filtered;
5924
+ const fc = filterAndJoin(raw, spec, runtimes);
5539
5925
  const rt = new LayerRuntime(spec, fc, builder._engineOptions);
5540
- if (spec.filter) rt._refilter = refilterFrom(raw, spec, runtimes);
5926
+ if (spec.filter) rt._refilter = expr => filterAndJoin(raw, spec, runtimes, expr);
5541
5927
  rt._definition = pdef; // for refreshTheme
5542
5928
  rt._deferredLoad = deferred;
5543
5929
  rt._named = named;
@@ -5545,6 +5931,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5545
5931
  rt._dataSourceKey = named ? 'name:' + spec.data.name
5546
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) });
5547
5933
  runtimes.push(rt);
5934
+ if (fc._sphere) applySphereBackdrop(map, rt.style);
5548
5935
  _globalThemeRegistry.set(rt.name, rt);
5549
5936
  if (rt.meta && rt.meta.name && rt.meta.name !== rt.name) _globalThemeRegistry.set(rt.meta.name, rt);
5550
5937
  if (themeIdOf(rt) !== rt.name) _globalThemeRegistry.set(themeIdOf(rt), rt);
@@ -5612,9 +5999,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5612
5999
  const svg = String(m.map || '');
5613
6000
  // its view and map scale follow the project's map file, as on flat
5614
6001
  if (svg) {
5615
- const svgProjection = flatSvgProjectionOf(svg);
6002
+ const svgProjection = flatSvgProjectionOf(svg) || (/orthographic\.svg$/i.test(svg) ? FLAT_ORTHOGRAPHIC_PROJECTION : null);
5616
6003
  if (svgProjection) flatSvgProjectionByOptions.set(builder._engineOptions, svgProjection);
5617
6004
  else flatSvgProjectionByOptions.delete(builder._engineOptions);
6005
+ if (svgProjection && svgProjection.invert) flatPlaneProjectionByOptions.set(builder._engineOptions, svgProjection);
6006
+ else flatPlaneProjectionByOptions.delete(builder._engineOptions);
6007
+ if (/orthographic\.svg$/i.test(svg)) flatOrthographicByOptions.set(builder._engineOptions, true);
6008
+ else { flatOrthographicByOptions.delete(builder._engineOptions); flatViewCenterByOptions.delete(builder._engineOptions); }
5618
6009
  }
5619
6010
  if (svg && !/generic\/(mercator|orthographic)\.svg$/i.test(svg)) report.notes.push(`flat SVG map "${svg.split('/').pop()}" is not available in ixmaps-gl — mercator used`);
5620
6011
  if (typeof map.setProjection === 'function') {
@@ -5717,7 +6108,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5717
6108
  return Math.round((z - Math.log2(Math.cos(lat))) * 1e6) / 1e6;
5718
6109
  }
5719
6110
  function refreshLayers() {
5720
- const liveZoom = map.getZoom();
6111
+ const projNow = typeof map.getProjection === 'function' && map.getProjection();
6112
+ const liveZoom = projNow && projNow.type === 'globe' && flatOrthographicByOptions.has(builder._engineOptions)
6113
+ ? globeSizingZoom(map.getZoom(), map.getCenter().lat, map.getContainer().clientHeight) : map.getZoom();
5721
6114
  // frozen only while MapLibre really is zooming (see 'moveend')
5722
6115
  const zoom = isZooming && map.isZooming() ? stableGridZoom : gridZoomOf();
5723
6116
  const viewMoving = map.isMoving();
@@ -5781,7 +6174,71 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
5781
6174
  if (order !== refreshLayers._lastOrder) { refreshLayers._lastOrder = order; console.info('[ixmaps-gl debug] draw order (bottom → top): ' + order); }
5782
6175
  }
5783
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
+ });
5784
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());
5785
6242
 
5786
6243
  // isZooming flips true/false on every 'zoomstart'/'zoomend' pair —
5787
6244
  // and a scroll-wheel or trackpad zoom fires MANY such pairs per
@@ -6077,7 +6534,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6077
6534
  // theirs and are not overridden.
6078
6535
  for (const m of ['replace', 'add', 'newTheme', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
6079
6536
  'refreshTheme', 'redrawTheme', 'setBasemapOpacity', 'setMapType', 'setMapTypeId', 'mapType', 'setProjection', 'resize',
6080
- 'loadProject', 'project', 'setThemeVisible', 'setThemeTimeFrame', 'show', 'hide', 'getZoom', 'getThemeObj',
6537
+ 'loadProject', 'project', 'setThemeVisible', 'switchLayer', 'setThemeTimeFrame', 'show', 'hide', 'getZoom', 'getThemeObj',
6081
6538
  'getThemes', 'theme', 'setView']) {
6082
6539
  if (!MapBuilder.prototype[m]) {
6083
6540
  MapBuilder.prototype[m] = function (...args) {
@@ -6100,7 +6557,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6100
6557
  // .catch()/await still sees the real failure — this only guarantees
6101
6558
  // the splash doesn't outlive it.
6102
6559
  const promise = builder.build().catch(err => {
6103
- 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);
6104
6563
  const splash = el && el.querySelector('.ixmaps-splash');
6105
6564
  if (splash) hideSplash(splash);
6106
6565
  throw err;
@@ -6345,6 +6804,17 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6345
6804
  const M = Math.sqrt(3) / 2, A1 = 1.340264, A2 = -0.081106, A3 = 0.000893, A4 = 0.003796;
6346
6805
  const l = Math.asin(M * Math.sin(lat * Math.PI / 180)), l2 = l * l, l6 = l2 * l2 * l2;
6347
6806
  return [lon * Math.PI / 180 * Math.cos(l) / (M * (A1 + 3 * A2 * l2 + l6 * (7 * A3 + 9 * A4 * l2))), l * (A1 + A2 * l2 + l6 * (A3 + A4 * l2))];
6807
+ },
6808
+ // [lat, lon] in degrees of a projected point (Newton on the parametric latitude)
6809
+ invert: (x, y) => {
6810
+ const M = Math.sqrt(3) / 2, A1 = 1.340264, A2 = -0.081106, A3 = 0.000893, A4 = 0.003796;
6811
+ let l = y, l2 = l * l, l6 = l2 * l2 * l2;
6812
+ for (let i = 0; i < 12; i++) {
6813
+ const delta = (l * (A1 + A2 * l2 + l6 * (A3 + A4 * l2)) - y) / (A1 + 3 * A2 * l2 + l6 * (7 * A3 + 9 * A4 * l2));
6814
+ l -= delta; l2 = l * l; l6 = l2 * l2 * l2;
6815
+ if (Math.abs(delta) < 1e-12) break;
6816
+ }
6817
+ return [Math.asin(Math.sin(l) / M) * 180 / Math.PI, M * x * (A1 + 3 * A2 * l2 + l6 * (7 * A3 + 9 * A4 * l2)) / Math.cos(l) * 180 / Math.PI];
6348
6818
  }
6349
6819
  },
6350
6820
  winkel: {
@@ -6368,6 +6838,59 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6368
6838
  // the flat SVG projection a map's options object belongs to (set by the
6369
6839
  // map's init and loadProject; the runtimes only see the options object)
6370
6840
  const flatSvgProjectionByOptions = new WeakMap();
6841
+ // the projection plane a map's VECTOR / BEZIER flows are drawn in: flat's
6842
+ // map file, or the Map() option mapProjection ("equalearth") — flat computes
6843
+ // the curves in its map's own coordinates, so the same flow bows around the
6844
+ // pole there. (Unlike flatSvgProjectionByOptions it leaves the view framing alone.)
6845
+ const flatPlaneProjectionByOptions = new WeakMap();
6846
+ // a map that is flat's orthographic one (drawn as MapLibre's globe): its object scale is
6847
+ // flat's (scale × the globe's pixels per degree at its center, measured on flat 2026-10-10
6848
+ // on the mechanical-goods globe page, the same at view zooms 2.5 and 3.5: 197.44e6; the
6849
+ // Mercator map's is FLAT_MERCATOR_SCALE_CONSTANT). MapLibre's globe zoom moves with the
6850
+ // center latitude (the globe keeps its size), so symbols are sized at the zoom of the
6851
+ // globe's real radius (globeSizingZoom). The view center the page asked for stays the
6852
+ // plane its flows are curved in.
6853
+ const FLAT_ORTHOGRAPHIC_SCALE_CONSTANT = 197.44e6;
6854
+ // flat fits its orthographic globe to the map's height: the disc is 0.88 of it at the
6855
+ // view zoom 2.5 and doubles per zoom level (338.6 px of 768 on the mechanical-goods
6856
+ // globe page, as 450 px of 1013 in a real window)
6857
+ const FLAT_ORTHOGRAPHIC_RADIUS_PER_HEIGHT = 338.6 / 768;
6858
+ const FLAT_ORTHOGRAPHIC_PROJECTION = { orthographic: true, scaleConstant: FLAT_ORTHOGRAPHIC_SCALE_CONSTANT };
6859
+ const FLAT_ORTHOGRAPHIC_FLOW_WIDTH = 8.96 / 5.585;
6860
+ const flatOrthographicByOptions = new WeakMap();
6861
+ const flatViewCenterByOptions = new WeakMap();
6862
+ function isFlatOrthographicName(v) { return /^orth?ographic$/i.test(String(v || '').trim()); }
6863
+ // MapLibre's zoom at which a Mercator map of the same pixel scale shows the globe: its
6864
+ // radius is 512 · 2^zoom / 2π / cos(center latitude)
6865
+ // MapLibre's globe is seen in perspective (vertical field of view 36.87°, so a focal length
6866
+ // of 1.5 · the map height, in the px the surface is drawn at in the center): a globe of
6867
+ // radius R (px per radian at its center) fills a disc of radius R / sqrt(1 + 2R / f) on
6868
+ // the screen. Flat's orthographic globe has no perspective: its radius is its disc.
6869
+ function globeDiscRadius(radiusAtCenter, height) {
6870
+ return radiusAtCenter / Math.sqrt(1 + 2 * radiusAtCenter / (1.5 * height));
6871
+ }
6872
+ function globeRadiusOfDisc(discRadius, height) {
6873
+ const f = 1.5 * height, a = discRadius * discRadius / f;
6874
+ return a + Math.sqrt(a * a + discRadius * discRadius);
6875
+ }
6876
+ // the zoom a Mercator map would have for the globe's disc radius — what symbols are sized at,
6877
+ // so they match flat's, whose scale follows its disc
6878
+ // The globe's center stays within 85° of the equator: MapLibre's globe zoom is compensated by
6879
+ // 1 / cos(latitude), so a pole-centered globe would need a zoom of about -7, where the
6880
+ // overlay's layers collapse to one point (flat can center the pole).
6881
+ const GLOBE_MAX_CENTER_LAT = 85;
6882
+ function globeCenterLat(lat) { return Math.max(-GLOBE_MAX_CENTER_LAT, Math.min(GLOBE_MAX_CENTER_LAT, lat)); }
6883
+ function globeSizingZoom(zoom, centerLat, height) {
6884
+ const c = Math.cos(globeCenterLat(centerLat) * Math.PI / 180);
6885
+ const radius = 512 * Math.pow(2, zoom) / (2 * Math.PI * c);
6886
+ return Math.log2(globeDiscRadius(radius, height > 0 ? height : 768) * 2 * Math.PI / 512);
6887
+ }
6888
+ function flatPlaneProjectionOf(mapOptions) {
6889
+ const byUrl = flatSvgProjectionOf(mapOptions.map);
6890
+ if (byUrl) return byUrl;
6891
+ const named = FLAT_SVG_PROJECTIONS[String(mapOptions.mapProjection || '').trim().toLowerCase()];
6892
+ return named || null;
6893
+ }
6371
6894
  // flat SVG map scale ÷ the Mercator one at the same on-screen size (1
6372
6895
  // without a flat SVG projection)
6373
6896
  function flatMapScaleRatio(mapOptions) {
@@ -6380,6 +6903,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
6380
6903
  // FLAT_SVG_PROJECTIONS), else flatToMapLibreZoom
6381
6904
  function flatViewToMapLibreZoom(flatZoom, lat, lng, width, height, proj) {
6382
6905
  if (!proj || !(width > 0) || !(height > 0) || !Number.isFinite(lat) || !Number.isFinite(lng)) return flatToMapLibreZoom(flatZoom);
6906
+ if (proj.orthographic) {
6907
+ // the globe's radius as flat draws it; MapLibre's is 512 · 2^zoom / 2π / cos(center lat)
6908
+ const disc = FLAT_ORTHOGRAPHIC_RADIUS_PER_HEIGHT * height * Math.pow(2, flatZoom - 2.5);
6909
+ return Math.log2(globeRadiusOfDisc(disc, height) * 2 * Math.PI * Math.cos(globeCenterLat(lat) * Math.PI / 180) / 512);
6910
+ }
6383
6911
  // flat's Leaflet bounds (256 px tiles) at this zoom
6384
6912
  const [sw, ne] = viewBounds(lat, lng, flatToMapLibreZoom(flatZoom), width, height);
6385
6913
  const a = proj.project(sw.lat, sw.lng), b = proj.project(ne.lat, ne.lng);
@@ -7437,6 +7965,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7437
7965
  // independently-rendered systems (deck.gl's icon quad, MapLibre's own
7438
7966
  // globe-sphere mesh) competing for the same depth-buffer real estate.
7439
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==';
7440
7971
 
7441
7972
  // GLOBE_HORIZON_DEG / isOnVisibleHemisphere: `depthCompare: 'always'`
7442
7973
  // above (needed to stop the near-surface z-fighting/half-moon bug —
@@ -7461,6 +7992,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7461
7992
  // only to stop the far side bleeding through, not to hem in near-limb
7462
7993
  // points that are still legitimately visible (if foreshortened).
7463
7994
  const GLOBE_HORIZON_DEG = 90;
7995
+ // a point inside the lng/lat bbox [w, s, e, n] and, under globe projection
7996
+ // (globeCenter set), on the visible hemisphere
7997
+ function inViewport(lng, lat, bbox, globeCenter) {
7998
+ if (lng < bbox[0] || lng > bbox[2] || lat < bbox[1] || lat > bbox[3]) return false;
7999
+ return !globeCenter || isOnVisibleHemisphere(lng, lat, globeCenter);
8000
+ }
8001
+
7464
8002
  function isOnVisibleHemisphere(lng, lat, center) {
7465
8003
  const toRad = d => d * Math.PI / 180;
7466
8004
  const phi0 = toRad(center.lat), phi1 = toRad(lat);
@@ -7701,6 +8239,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
7701
8239
  switch (shape) {
7702
8240
  case 'squarefull': return Math.abs(dx) <= r && Math.abs(dy) <= r;
7703
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;
7704
8243
  case 'roundrect': return Math.abs(dx) <= r * 0.8 && Math.abs(dy) <= r * 0.8;
7705
8244
  case 'diamond': case 'carot': return Math.abs(dx) + Math.abs(dy) <= r;
7706
8245
  case 'empty': return false;
@@ -8584,6 +9123,66 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8584
9123
  return out;
8585
9124
  }
8586
9125
 
9126
+ // The orthographic plane around a view center (flat's orthographic map): project
9127
+ // (lat, lon) → [x, y] in earth radii, y up; invert is the way back ([NaN, NaN]
9128
+ // outside the disc); inFront([lng, lat]) tells the face the viewer sees
9129
+ function orthographicPlane(lat0, lng0) {
9130
+ const r = Math.PI / 180, p0 = lat0 * r, s0 = Math.sin(p0), c0 = Math.cos(p0);
9131
+ return {
9132
+ project: (lat, lon) => {
9133
+ const p = lat * r, dl = (lon - lng0) * r;
9134
+ return [Math.cos(p) * Math.sin(dl), c0 * Math.sin(p) - s0 * Math.cos(p) * Math.cos(dl)];
9135
+ },
9136
+ invert: (x, y) => {
9137
+ const rho = Math.hypot(x, y);
9138
+ if (!(rho <= 1)) return [NaN, NaN];
9139
+ if (!rho) return [lat0, lng0];
9140
+ const c = Math.asin(rho), sc = Math.sin(c), cc = Math.cos(c);
9141
+ const lat = Math.asin(cc * s0 + y * sc * c0 / rho);
9142
+ const lng = lng0 * r + Math.atan2(x * sc, rho * c0 * cc - y * s0 * sc);
9143
+ return [lat / r, ((lng / r + 540) % 360) - 180];
9144
+ },
9145
+ inFront: ([lng, lat]) => s0 * Math.sin(lat * r) + c0 * Math.cos(lat * r) * Math.cos((lng - lng0) * r) > 0
9146
+ };
9147
+ }
9148
+
9149
+ // CHART|VECTOR|BEZIER with 3D (ixmaps-gl only; flat draws its flows flat): the
9150
+ // flow arcs above the map. Height in meters of the vertex i of last+1, a half
9151
+ // sine over the flow: both ends stay on the ground (arrow heads sit there),
9152
+ // the top is ratio · the flow's length (style.archeight, default 0.3)
9153
+ const VECTOR_ARC_HEIGHT_RATIO = 0.3;
9154
+ function vectorArcHeight(i, last, chordMeters, ratio) {
9155
+ return last > 0 ? chordMeters * ratio * Math.sin(Math.PI * i / last) : 0;
9156
+ }
9157
+ // the fractional vertex index of a path (px points) `back` px before its last
9158
+ // point (0 when the path is shorter): where an arrow head's base sits on the
9159
+ // arc, so the head is lifted with the line it ends
9160
+ function pathBackIndex(pts, back) {
9161
+ let left = back;
9162
+ for (let i = pts.length - 1; i > 0; i--) {
9163
+ const len = Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
9164
+ if (left <= len) return len ? i - left / len : i;
9165
+ left -= len;
9166
+ }
9167
+ return 0;
9168
+ }
9169
+ // great-circle distance between two [lng, lat], meters
9170
+ function greatCircleMeters(p1, p2) {
9171
+ const r = Math.PI / 180, dLat = (p2[1] - p1[1]) * r, dLng = (p2[0] - p1[0]) * r;
9172
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(p1[1] * r) * Math.cos(p2[1] * r) * Math.sin(dLng / 2) ** 2;
9173
+ return 2 * 6371008.8 * Math.asin(Math.sqrt(Math.min(1, h)));
9174
+ }
9175
+ // the map's initial camera tilt: .options({pitch, bearing}) or the Map() option
9176
+ // of that name (degrees); maxPitch is raised to the pitch asked for
9177
+ function cameraOptions(engineOptions, mapOptions) {
9178
+ const num = k => { const v = engineOptions && engineOptions[k] != null ? engineOptions[k] : mapOptions && mapOptions[k]; const n = Number(v); return v == null || v === '' || !isFinite(n) ? undefined : n; };
9179
+ const out = {};
9180
+ const pitch = num('pitch'), bearing = num('bearing');
9181
+ if (pitch !== undefined) { out.pitch = Math.max(0, Math.min(85, pitch)); out.maxPitch = Math.max(60, out.pitch); }
9182
+ if (bearing !== undefined) out.bearing = bearing;
9183
+ return out;
9184
+ }
9185
+
8587
9186
  // flat's arrow marker (an SVG <marker>, markerUnits strokeWidth, orient
8588
9187
  // auto) at a line's end: the triangle M0,s L s,s/2 L0,0 in units of the
8589
9188
  // line width sw, its reference point (refX, refY) on the end point, its
@@ -8644,17 +9243,34 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8644
9243
  // - HORZ: side by side from the center to the right
8645
9244
  // - otherwise (or CENTER): all on the center
8646
9245
  // Returns the parts in drawing order: { i, v, r, x, y } (pixels, y down).
8647
- function sequenceLayout(counts, flags, radiusOf, opts = {}) {
9246
+ // the flag tests sequenceLayout makes, resolved once per flag set: it runs for
9247
+ // every chart on every redraw, and scanning the flags (and compiling a RegExp
9248
+ // per word) each time cost ~135 ms per redraw on the accidents demo
9249
+ const _sequenceFlagCache = new WeakMap();
9250
+ function sequenceFlags(flags) {
9251
+ const hit = _sequenceFlagCache.get(flags);
9252
+ if (hit && hit.size === flags.size) return hit.info;
8648
9253
  const has = f => flatFlag(flags, f);
8649
- const word = w => [...flags].some(f => new RegExp('\\b' + w + '\\b').test(f));
9254
+ const word = w => { const re = new RegExp('\\b' + w + '\\b'); return [...flags].some(f => re.test(f)); };
9255
+ const info = {
9256
+ sort: word('SORT'), up: word('UP'), down: word('DOWN'),
9257
+ star: has('STAR') && !flatFlag(flags, 'CENTER'), horz: has('HORZ') && !flatFlag(flags, 'CENTER'),
9258
+ expand: has('EXPAND'), expandmax: has('EXPANDMAX'), linear: has('LINEAR'), sizep1: has('SIZEP1'),
9259
+ compress: has('COMPRESS'), compressmax: has('COMPRESSMAX')
9260
+ };
9261
+ _sequenceFlagCache.set(flags, { size: flags.size, info });
9262
+ return info;
9263
+ }
9264
+ function sequenceLayout(counts, flags, radiusOf, opts = {}) {
9265
+ const fl = sequenceFlags(flags);
8650
9266
  let order = counts.map((v, i) => ({ i, v }));
8651
- if (word('SORT')) {
8652
- if (word('UP')) order.sort((a, b) => a.v - b.v);
8653
- else if (word('DOWN')) order.sort((a, b) => b.v - a.v);
9267
+ if (fl.sort) {
9268
+ if (fl.up) order.sort((a, b) => a.v - b.v);
9269
+ else if (fl.down) order.sort((a, b) => b.v - a.v);
8654
9270
  }
8655
9271
  const parts = [];
8656
- const star = has('STAR') && !has('CENTER'), horz = has('HORZ') && !has('CENTER');
8657
- const up = word('UP');
9272
+ const star = fl.star, horz = fl.horz;
9273
+ const up = fl.up;
8658
9274
  const maxR = opts.maxRadius || 0;
8659
9275
  const nStarParts = opts.nParts || counts.length;
8660
9276
  let angle = 0, starR = null, x = 0;
@@ -8666,11 +9282,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8666
9282
  if (star) {
8667
9283
  if (starR === null) {
8668
9284
  starR = r;
8669
- if (has('EXPAND') && word('SORT') && up) {
9285
+ if (fl.expand && fl.sort && up) {
8670
9286
  const big = order[order.length - 1].v, mv = opts.maxValue || big || 1;
8671
- starR = (has('EXPANDMAX') ? 2 : 0.5) * maxR * (has('LINEAR') || has('SIZEP1') ? big / mv : Math.sqrt(big / mv));
9287
+ starR = (fl.expandmax ? 2 : 0.5) * maxR * (fl.linear || fl.sizep1 ? big / mv : Math.sqrt(big / mv));
8672
9288
  }
8673
- if (has('COMPRESS')) starR *= has('COMPRESSMAX') ? 0.25 : 0.5;
9289
+ if (fl.compress) starR *= fl.compressmax ? 0.25 : 0.5;
8674
9290
  }
8675
9291
  if (parts.length > 0 || up) {
8676
9292
  const c = starR + r;
@@ -8682,7 +9298,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8682
9298
  dist = starR * (1 + nStarParts / 5);
8683
9299
  }
8684
9300
  if (opts.rangeScale) dist *= opts.rangeScale;
8685
- else dist = has('EXPANDMAX') ? dist * 2 : (has('EXPAND') ? dist * 1.5 : dist);
9301
+ else dist = fl.expandmax ? dist * 2 : (fl.expand ? dist * 1.5 : dist);
8686
9302
  angle += f;
8687
9303
  px = Math.cos(angle / 180 * Math.PI) * dist;
8688
9304
  py = Math.sin(angle / 180 * Math.PI) * dist;
@@ -8886,6 +9502,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
8886
9502
  // which every other caller here still relies on unchanged.
8887
9503
  function resolvePartsClass(value, partsA) {
8888
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;
8889
9508
  for (let i = 0; i < partsA.length; i++) {
8890
9509
  const isLast = i === partsA.length - 1;
8891
9510
  const inLower = value >= partsA[i].min;
@@ -9234,6 +9853,27 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9234
9853
  }
9235
9854
  return bins;
9236
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
+
9237
9877
  function flatRangeParts(values, nMin, nMax, nParts, flags, centerValue) {
9238
9878
  const rawMin = nMin, rawMax = nMax;
9239
9879
  if (centerValue != null && !isNaN(centerValue)) {
@@ -9357,6 +9997,33 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9357
9997
  // On copies of the features' properties — the page's data is not changed.
9358
9998
  // AGGREGATE themes: not here — flat computes field100 on the aggregated
9359
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
+ }
9360
10027
  function applyField100(features, binding, flags, style) {
9361
10028
  if (!binding || !binding.value || !Array.isArray(features)) return features;
9362
10029
  // AGGREGATE: flat applies field100 to the aggregated sums
@@ -9366,7 +10033,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9366
10033
  const fields = String(binding.value).split('|');
9367
10034
  const auto100 = flags.has('AUTO100') && !flags.has('AGGREGATE');
9368
10035
  const seriesDifference = flags.has('DIFFERENCE') && fields.length > 1;
9369
- 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;
9370
10040
  const f100A = f100 ? String(f100).split('|') : null;
9371
10041
  const fractionScale = styleNum(style && style.fractionscale) || 1;
9372
10042
  const names = fields.map(field => (field[0] === '!' ? field.slice(1) : field));
@@ -9375,6 +10045,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9375
10045
  if (!f || !f.properties) return f;
9376
10046
  const props = Object.assign({}, f.properties);
9377
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)));
9378
10052
  if (f100A || auto100) {
9379
10053
  const sum = auto100 ? values.reduce((a, v) => a + (isNaN(v) ? 0 : v), 0) : 0;
9380
10054
  values = values.map((v, i) => {
@@ -9452,11 +10126,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9452
10126
  out._valueMax = nMax;
9453
10127
  const sorted = values.slice().sort((a, b) => a - b);
9454
10128
  out._valueMedian = sorted[Math.floor((sorted.length - 1) / 2)];
9455
- 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;
9456
10132
  const placeholders = new Array(nParts).fill('');
9457
- const colorsRgb = resolveClassColors(style.colorscheme, placeholders, style.classes);
10133
+ const colorsRgb = resolveClassColors(style.colorscheme, placeholders, breaks ? nParts : style.classes);
9458
10134
 
9459
- 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));
9460
10136
  out._nRangeParts = nParts; // the configured count; partsA may have fewer (all values equal)
9461
10137
 
9462
10138
  out.categoryLabels = out.partsA.map(p => `${formatValue(p.min)} - ${formatValue(p.max)}`);
@@ -9594,6 +10270,15 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9594
10270
  // (maptheme.js 11086-11101): each value becomes the next minus it
9595
10271
  // (RELATIVE: in % of it when it is above field100min, else 0), the last
9596
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
+
9597
10282
  function cellAggregatedValues(cell, flags, style) {
9598
10283
  const useMean = flags.has('MEAN');
9599
10284
  const values = cell.sums.map((s, i) => (useMean && cell.counts[i] > 0 ? s / cell.counts[i] : s));
@@ -9659,6 +10344,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9659
10344
  // relocates a field aggregation, maptheme.js 11141) — instead of a grid
9660
10345
  function aggregateOnGrid(features, zoom, cellPx, flags, post, field) {
9661
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');
9662
10354
  const cells = new Map();
9663
10355
  for (const f of features) {
9664
10356
  const [lng, lat] = f.geometry.coordinates;
@@ -9680,16 +10372,30 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9680
10372
  (cell.raws = cell.raws || []).push(f.properties.raw);
9681
10373
  }
9682
10374
  cell.sumX += p.x; cell.sumY += p.y; cell.n++;
9683
- cell.value += f.properties.value;
9684
- if (f.properties.classValue !== undefined) cell.classValue += f.properties.classValue;
9685
- 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);
9686
10379
  // an AGGREGATE / CATEGORICAL pie's record (flatPieRecord): its parts
9687
10380
  // and size added to the cell's item as flat does (flatPieAccumulate)
9688
10381
  if (f.properties.pie) cell.pie = flatPieAccumulate(cell.pie || null, f.properties.pie, flags);
9689
10382
  const series = f.properties.series;
9690
10383
  if (series) {
9691
10384
  if (!cell.series) cell.series = new Array(series.length).fill(0);
9692
- 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
+ }
9693
10399
  }
9694
10400
  }
9695
10401
  const out = [];
@@ -9700,14 +10406,15 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9700
10406
  const geometry = { type: 'Point', coordinates: [ll.lng, ll.lat] };
9701
10407
  const hasClass = cell.first.properties.classValue !== undefined;
9702
10408
  let props = cell.n > 1
9703
- // titleRaw: the cell's first record — flat titles an aggregated
9704
- // 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)
9705
10411
  ? { cluster: true, point_count: cell.n, value: cell.value, ...(hasClass ? { classValue: cell.classValue } : {}),
9706
- titleRaw: cell.first.properties.raw || cell.first.properties,
10412
+ titleRaw: (cell.titleFrom || cell.first).properties.raw || (cell.titleFrom || cell.first).properties,
9707
10413
  ...(cell.raws && cell.raws.length ? { raws: cell.raws } : {}) }
9708
10414
  : cell.first.properties;
9709
10415
  if (cell.series && cell.n > 1) props = Object.assign({}, props, { series: cell.series });
9710
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 });
9711
10418
  // a field aggregation's cell key (the field value): the categories of
9712
10419
  // one cell are merged by it (groupCoLocated), not by their positions
9713
10420
  if (field) props = Object.assign({}, props, { aggKey: cell.key });
@@ -9771,7 +10478,18 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9771
10478
  let cell = cells.get(key);
9772
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); }
9773
10480
  cell.sumX += p.x; cell.sumY += p.y; cell.n++;
9774
- 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);
9775
10493
  // the members' own records, for the tooltip's {{theme.item.data}} —
9776
10494
  // flat's __add_data tables them (numbered, up to 50) for aggregated
9777
10495
  // items too; references only, so no copy is made. A member can itself
@@ -9786,6 +10504,18 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9786
10504
  }
9787
10505
  cell.counts[f.properties.cat] += f.properties.value;
9788
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
+ }
9789
10519
  // point_count is the aggregated-record count of a multi-record
9790
10520
  // cell (aggregateOnGrid; absent on a single-record cell)
9791
10521
  // — tracked separately from counts (the summed bound VALUE) purely
@@ -9799,7 +10529,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9799
10529
  geometry: { type: 'Point', coordinates: [ll.lng, ll.lat] },
9800
10530
  properties: { counts: cell.counts, total: cell.counts.reduce((a, c) => a + c, 0), recordCounts: cell.recordCounts, ...(cell.classTotal !== undefined ? { classTotal: cell.classTotal } : {}),
9801
10531
  ...(cell.raws && cell.raws.length ? { raws: cell.raws } : {}),
9802
- ...(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 } : {}) }
9803
10535
  };
9804
10536
  });
9805
10537
  }
@@ -9829,8 +10561,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
9829
10561
  const c = f.geometry && f.geometry.coordinates;
9830
10562
  if (!c || typeof c[0] !== 'number') return;
9831
10563
  const [lng, lat] = c;
9832
- if (lng < bbox[0] || lng > bbox[2] || lat < bbox[1] || lat > bbox[3]) return;
9833
- if (globeCenter && !isOnVisibleHemisphere(lng, lat, globeCenter)) return;
10564
+ if (!inViewport(lng, lat, bbox, globeCenter)) return;
9834
10565
  const v = parseFloat(f.properties[rt.binding.value]);
9835
10566
  add(rt._resolvePartsClass(v), v);
9836
10567
  });
@@ -10204,6 +10935,33 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10204
10935
  // pure: resolveComposedColor → see "Classification — pure functions"
10205
10936
  _resolveComposedColor(props) { return resolveComposedColor(this, props); }
10206
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
+
10207
10965
  // pure: buildPartsA → see "Classification — pure functions"
10208
10966
  _buildPartsA() { const r = computeRangeClasses(this.features, this.binding, this.style, this.flags, v => this._formatTooltipValue(v)); if (r) Object.assign(this, r); }
10209
10967
 
@@ -10272,11 +11030,18 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10272
11030
  // 7-class color once it knows each cell's actual aggregated total.
10273
11031
  if (this._multiFields && this._isMultiFieldSequence()) {
10274
11032
  // one bucket; each record carries its field values as the chart's
10275
- // parts. Its value — the draw order — is the first field's, as
10276
- // 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;
10277
11040
  this._featuresByCategory = [sourceFeatures.map(f => {
10278
11041
  const parts = this._multiFields.map(k => parseFloat(f.properties[k]) || 0);
10279
- 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 } };
10280
11045
  })];
10281
11046
  this._clusterIndices = null;
10282
11047
  this._clusterRadiusPx = null;
@@ -10723,7 +11488,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10723
11488
  const post = this._seriesDifference
10724
11489
  ? (v, v100, series) => (series ? cellAggregatedValues({ sums: series, counts: series.map(() => 1) }, this.flags, this.style)[0] : v)
10725
11490
  : this._aggregateField100 ? (v, v100) => aggregateField100(v, v100, this.flags, this.style) : undefined;
10726
- 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));
10727
11497
  }
10728
11498
 
10729
11499
  // Reverted from the source-accurate two-flat-circle GLOW (radius*6
@@ -10891,6 +11661,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10891
11661
  // reasonable rather than wrong.
10892
11662
  // borderOpacity (default 1): the border's own alpha (flat's
10893
11663
  // stroke-opacity, see flatBubbleLook)
11664
+ // the color of class i; neutral grey when the theme has no class colors
11665
+ // (colorscheme ["none"], or a class beyond the scheme)
11666
+ classRgb(i) { return (this.categoryColorsRgb || [])[i] || [128, 128, 128]; }
11667
+
10894
11668
  // colorscheme ["none"] — flat's explicit no-fill symbol
10895
11669
  _noFillScheme() {
10896
11670
  const cs = this.style.colorscheme;
@@ -10970,6 +11744,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
10970
11744
  const raw = props.raw || props.titleRaw;
10971
11745
  const title = this.binding.title && raw && raw[this.binding.title] != null ? String(raw[this.binding.title]) : '';
10972
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;
10973
11751
  if (aggregated && this._clusterRadiusPx && !this._clusterField) {
10974
11752
  const n = groupRecordCount(props);
10975
11753
  if (!title || n > 3) return '(' + n + ')';
@@ -11139,7 +11917,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11139
11917
  let rows = fields.map((field, i) => ({ i, v: parseFloat(raw[field]) })).filter(r => Number.isFinite(r.v));
11140
11918
  if (!rows.length) return '';
11141
11919
  if (this.flags.has('SORT')) rows.sort((a, b) => (this.flags.has('UP') ? a.v - b.v : b.v - a.v));
11142
- const color = i => { const c = (this.categoryColorsRgb || [])[i] || [128, 128, 128]; return `rgb(${c.slice(0, 3).join(',')})`; };
11920
+ const color = i => { const c = this.classRgb(i); return `rgb(${c.slice(0, 3).join(',')})`; };
11143
11921
  const frac = v => (this._multiFieldMax ? Math.max(0, Math.abs(v) / this._multiFieldMax) : 0);
11144
11922
  const esc = t => String(t).replace(/&/g, '&amp;').replace(/</g, '&lt;');
11145
11923
  if (this.flags.has('HORZ')) {
@@ -11329,6 +12107,15 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11329
12107
  // unmatched polygon, so the donor's fill serves no purpose once
11330
12108
  // superseded.
11331
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 [];
11332
12119
  if (this.flags.has('FEATURE') || this.flags.has('FEATURES')) {
11333
12120
  return featuresHiddenByScale(this.style, liveZoom) ? [] : this._buildFeaturesLayers(liveZoom, bbox);
11334
12121
  }
@@ -11370,6 +12157,20 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11370
12157
  // each shape by its class), not one flat fill
11371
12158
  const byCategory = filled && this.flags.has('CATEGORICAL') && this.categoryIndexByLabel && this.binding.value
11372
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
+ };
11373
12174
  const catAlpha = Math.round(255 * (styleNum(this.style.fillopacity) || 1));
11374
12175
  const nodataRgb = parseCssColor(this.style.nodatacolor) || [255, 255, 255];
11375
12176
  const shadow = filled && flatShadowOn(this.style, this.features.length, zoom)
@@ -11389,14 +12190,19 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11389
12190
  // flat puts fillopacity into the shape's fill-opacity only
11390
12191
  // (maptheme.js 13407, 14071-14078): the outline stays opaque
11391
12192
  // (a fill color's own alpha — rgba(…, 0.5) — times fillopacity)
11392
- ...(byCategory ? { updateTriggers: { getFillColor: JSON.stringify(this.categoryColorsRgb) } } : {}),
12193
+ ...(byCategory || byClass ? { updateTriggers: { getFillColor: JSON.stringify(this.categoryColorsRgb), getLineColor: JSON.stringify(this.categoryColorsRgb) } } : {}),
11393
12194
  getFillColor: byCategory
11394
12195
  ? f => {
11395
12196
  const cat = this._resolveClassIndex(f.properties[this.binding.value]);
11396
12197
  return [...(cat != null ? (this.categoryColorsRgb || [])[cat] || nodataRgb : nodataRgb).slice(0, 3), catAlpha];
11397
12198
  }
12199
+ : byClass ? f => [...classColorOf(f).slice(0, 3), catAlpha]
11398
12200
  : filled ? [...hexOrNamedToRgb(raw).slice(0, 3), Math.round(255 * cssColorAlpha(raw) * (styleNum(this.style.fillopacity) || 1))] : [0, 0, 0, 0],
11399
- 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
11400
12206
  ? withAlpha(hexOrNamedToRgb(styleLineColor(this.style.linecolor)), cssColorAlpha(styleLineColor(this.style.linecolor)))
11401
12207
  : [130, 130, 130],
11402
12208
  lineWidthMinPixels: styleNum(this.style.linewidth) || 1,
@@ -11486,12 +12292,13 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11486
12292
  const isComposeColor = this.flags.has('COMPOSECOLOR');
11487
12293
  const baseOpacity = resolveFillOpacity(this.style, this.mapOptions, zoom);
11488
12294
  // DOPACITY needs a PER-FEATURE opacity, which deck.gl only supports
11489
- // via getFillColor's own alpha channel (its layer-level `opacity`
11490
- // prop is one number for every feature) — see _resolveDopacityAlpha.
11491
- // Not meaningful combined with COMPOSECOLOR (no single driving value
11492
- // per polygon there), matching the real engine's own separate
11493
- // DOPACITY/COMPOSECOLOR branches.
11494
- 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 =
11495
12302
  (this.flags.has('DOPACITY') || this.flags.has('DOPACITYMIN') || this.flags.has('DOPACITYMAX') || this.flags.has('DOPACITYMINMAX'));
11496
12303
  // AGGREGATE CATEGORICAL: every polygon's parts and dominant class
11497
12304
  // (aggregatedCategoricalClass), the biggest part per class over all
@@ -11534,7 +12341,15 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11534
12341
  return {
11535
12342
  type: 'Feature',
11536
12343
  geometry: f.geometry,
11537
- 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
+ }
11538
12353
  };
11539
12354
  }
11540
12355
  if (isDominant) {
@@ -11590,6 +12405,29 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11590
12405
  // deck.gl re-runs the color accessors only for new data or a changed trigger
11591
12406
  const colorKey = JSON.stringify([[...this._markedClasses], !!this._onMarksChanged, evidenceMode,
11592
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;
11593
12431
  return [new GeoJsonLayer({
11594
12432
  id: this._dataLayerId(`ix-choropleth-${this.name}`),
11595
12433
  data: collection,
@@ -11597,21 +12435,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11597
12435
  pickable: true,
11598
12436
  stroked: true,
11599
12437
  filled: true,
11600
- getFillColor: d => {
11601
- if (!hasData(d)) return [0, 0, 0, 0];
11602
- const rgb = d.properties.composedColor
11603
- || (d.properties.cat != null ? (this.categoryColorsRgb || [])[d.properties.cat] : null) || fallbackRgb;
11604
- const evidence = unmarkedEvidence(d);
11605
- if (evidence === 'hide') return [0, 0, 0, 0];
11606
- if (evidence === 'gray') return [...grayOf(rgb), 90];
11607
- const a = alphaOfClass(d.properties.cat);
11608
- return d.properties.dopacityAlpha != null ? [...rgb.slice(0, 3), Math.round(d.properties.dopacityAlpha * a * 255)] : withAlpha(rgb, a);
11609
- },
11610
- // a per-polygon accessor only while a class is isolated
11611
- getLineColor: this._markedClasses.size && !this._onMarksChanged && evidenceMode === 'isolate'
11612
- ? d => (unmarkedEvidence(d) === 'hide' ? [0, 0, 0, 0] : lineRgb)
11613
- : lineRgb,
11614
- 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,
11615
12444
  // per-feature alpha (baked above) already carries the resolved
11616
12445
  // base opacity — a layer-level opacity on TOP of that would
11617
12446
  // multiply it a second time.
@@ -11648,6 +12477,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11648
12477
  const categories = this._plotCategories();
11649
12478
 
11650
12479
  const cells = new Map();
12480
+ const combine = aggCombiner(this.flags);
11651
12481
  source.forEach(f => {
11652
12482
  const ci = this._gridCategoryIndex(f.properties[this.binding.value]);
11653
12483
  if (ci == null) return;
@@ -11665,7 +12495,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11665
12495
  cells.set(key, cell);
11666
12496
  }
11667
12497
  const v = this._resolveAggregateValue(f.properties);
11668
- cell.sums[ci] += v;
12498
+ cell.sums[ci] = combine(cell.sums[ci], v, cell.counts[ci] === 0);
11669
12499
  cell.counts[ci]++;
11670
12500
  });
11671
12501
 
@@ -11703,8 +12533,14 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
11703
12533
  // a per-item SEQUENCE chart: CHART|SYMBOL|SEQUENCE, several value
11704
12534
  // fields per item, one part per field (no CATEGORICAL, no AGGREGATE)
11705
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
11706
12542
  return this.flags.has('CHART') && this.flags.has('SYMBOL') && flatFlag(this.flags, 'SEQUENCE')
11707
- && !this.flags.has('CATEGORICAL') && !this.flags.has('AGGREGATE') && !this.flags.has('PLOT')
12543
+ && !this.flags.has('CATEGORICAL') && !this.flags.has('PLOT')
11708
12544
  && String(this.binding.value || '').includes('|');
11709
12545
  }
11710
12546
  _itemPlotFields() {
@@ -12324,7 +13160,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12324
13160
  _userChartColor(cat) {
12325
13161
  const cs = this.style.colorscheme;
12326
13162
  if (Array.isArray(cs) && cs.length === (this.categoryColorsRgb || []).length && !isGeneratedColorScheme(cs)) return String(cs[cat]);
12327
- const c = (this.categoryColorsRgb || [])[cat] || [128, 128, 128];
13163
+ const c = this.classRgb(cat);
12328
13164
  return `rgb(${c[0]},${c[1]},${c[2]})`;
12329
13165
  }
12330
13166
  // USER charts (see resolveUserChartFunction): one icon per chart, drawn
@@ -12515,8 +13351,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12515
13351
  ctx.arcTo(x, y + hh, x, y, rr); ctx.arcTo(x, y, x + ww, y, rr); ctx.closePath();
12516
13352
  };
12517
13353
  path();
12518
- ctx.globalAlpha = look.fillOpacity; ctx.fillStyle = look.fill; ctx.fill();
12519
- 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(); }
12520
13356
  // getSize is the icon's height: the padding is part of it
12521
13357
  const icon = { url: canvas.toDataURL(), width: canvas.width, height: canvas.height, anchorX: canvas.width / 2, anchorY: canvas.height / 2, id: key };
12522
13358
  return this._cacheIcon(key, icon);
@@ -12628,7 +13464,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12628
13464
  this._featuresByCategory.forEach((feats, cat) => {
12629
13465
  feats.forEach(f => {
12630
13466
  const [lng, lat] = f.geometry.coordinates;
12631
- if (lng < bbox[0] || lng > bbox[2] || lat < bbox[1] || lat > bbox[3]) return;
13467
+ if (!inViewport(lng, lat, bbox)) return;
12632
13468
  individual.push({ geometry: f.geometry, properties: { ...f.properties, cat } });
12633
13469
  });
12634
13470
  });
@@ -12767,7 +13603,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12767
13603
  const rangeSource = this._classSeparate && this.binding.size && !normalize ? rawSizeTotals : totals;
12768
13604
  let nMin = Infinity, nMax = -Infinity;
12769
13605
  for (const v of rangeSource) { if (v < nMin) nMin = v; if (v > nMax) nMax = v; }
12770
- 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));
12771
13609
  }
12772
13610
 
12773
13611
  // The theme's own classes follow the aggregated items, as flat's do
@@ -12981,7 +13819,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
12981
13819
  const singleBorderColorRgb = this.style.linecolor && styleLineColor(this.style.linecolor) !== 'none' ? hexOrNamedToRgb(styleLineColor(this.style.linecolor)) : null;
12982
13820
  const singleBorderWidthPx = styleNum(this.style.linewidth) || 0;
12983
13821
 
12984
- const itemRgb = d => this.categoryColorsRgb[d.properties.counts ? dominant(d.properties.counts) : d.properties.cat] || [128, 128, 128];
13822
+ const itemRgb = d => this.classRgb(d.properties.counts ? dominant(d.properties.counts) : d.properties.cat);
12985
13823
  const radiusOf = d => vRadius(sizeValueOf(d));
12986
13824
  // align / offsetx / offsety: the whole chart moves (flatChartAlignOffset)
12987
13825
  // MULTIQUAD / MULTISQUARE: the items at one position side by side
@@ -13119,7 +13957,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13119
13957
  const titleFontPx = titleFontOf(w);
13120
13958
  let title = null;
13121
13959
  if (titleShown) {
13122
- 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;
13123
13964
  if (text) {
13124
13965
  const f = titleFontPx;
13125
13966
  const lines = wrapTitleLines(text, f, circular ? boxR * 2 : w).split('\n');
@@ -13146,9 +13987,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13146
13987
  return { d, r, w, boxR, rect, title };
13147
13988
  }) : null;
13148
13989
  if (boxShown && circular) {
13149
- const boxRgb = hexOrNamedToRgb(this.style.boxcolor || '#eeeeee');
13150
- const boxAlpha = Math.round(255 * boxOpacity);
13151
- 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');
13152
13994
  // ScatterplotLayer has no pixel offset: a moved chart (align,
13153
13995
  // offsetx/offsety) gets its circle as an icon
13154
13996
  if (alignOf.active) layers.push(new IconLayer({
@@ -13156,7 +13998,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13156
13998
  data: boxLayout, pickable: false,
13157
13999
  getPosition: b => b.d.geometry.coordinates,
13158
14000
  getIcon: b => this._buildSingleIcon(boxRgb, boxAlpha / 255, 'circle', borderRgb,
13159
- 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),
13160
14002
  getSize: b => b.boxR * 2,
13161
14003
  getPixelOffset: b => alignOf(b.d),
13162
14004
  sizeUnits: 'pixels',
@@ -13169,9 +14011,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13169
14011
  getPosition: b => b.d.geometry.coordinates,
13170
14012
  getRadius: b => b.boxR,
13171
14013
  radiusUnits: 'pixels',
13172
- stroked: true,
14014
+ stroked: !borderNone,
13173
14015
  getFillColor: [...boxRgb.slice(0, 3), boxAlpha],
13174
- getLineColor: [...borderRgb.slice(0, 3), 255],
14016
+ getLineColor: [...(borderRgb || boxRgb).slice(0, 3), 255],
13175
14017
  getLineWidth: unitPx / 20,
13176
14018
  lineWidthUnits: 'pixels',
13177
14019
  parameters: ICON_LAYER_GLOBE_PARAMETERS
@@ -13181,7 +14023,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13181
14023
  const boxLook = {
13182
14024
  fill: this.style.boxcolor || 'white',
13183
14025
  fillOpacity: boxOpacity,
13184
- stroke: borderStyle === 'none' ? null : styleLineColor(this.style.bordercolor) || '#bbbbbb',
14026
+ stroke: borderStyle === 'none' || isNoneColor(styleLineColor(this.style.bordercolor)) ? null : styleLineColor(this.style.bordercolor) || '#bbbbbb',
13185
14027
  strokeOpacity: /^(solid|dotted|dashed)$/.test(borderStyle) ? 1 : 0.3,
13186
14028
  };
13187
14029
  const borderWidth = styleNum(this.style.borderwidth) || 1;
@@ -13233,11 +14075,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13233
14075
  }
13234
14076
 
13235
14077
  // flat's per-part SYMBOL branch (everything but CATEGORICAL without
13236
- // AGGREGATE/GROUP) draws a square of side 2r, the other one 1.6r
13237
- // (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)
13238
14080
  const perPartBranch = flatChartBranch(this.flags) === 'symbol'
13239
14081
  && !(this.flags.has('CATEGORICAL') && !this.flags.has('AGGREGATE') && !flatFlag(this.flags, 'GROUP'));
13240
- 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; };
13241
14083
  // a range-classed cell is ONE class: one symbol in its class color,
13242
14084
  // fillopacity and linecolor, as a single item (flat draws one
13243
14085
  // symbol) — the packed multi-category icon is for CATEGORICAL cells
@@ -13270,7 +14112,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13270
14112
  if (!symbolOutline) return singleBorderColorRgb && singleBorderWidthPx > 0 ? singleBorderColorRgb : null;
13271
14113
  if (singleBorderColorRgb) return singleBorderColorRgb;
13272
14114
  if (flatFlag(this.flags, 'NOLINES') || (this.style.linecolor && styleLineColor(this.style.linecolor) === 'none')) return null;
13273
- const fill = (this.categoryColorsRgb || [])[d.properties.cat] || [128, 128, 128];
14115
+ const fill = this.classRgb(d.properties.cat);
13274
14116
  if (perPartBranch) return flatChartTextRgb(fill);
13275
14117
  return fill.slice(0, 3).every(c => c >= 255) ? [128, 128, 128] : fill;
13276
14118
  };
@@ -13295,7 +14137,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13295
14137
  const lookUnit = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(this.style.scale) || 1);
13296
14138
  // colorscheme ["none"]: flat draws the symbol unfilled (only its values / box / title)
13297
14139
  const noFill = this._noFillScheme();
13298
- const circleLook = flatLook ? d => flatBubbleLook((this.categoryColorsRgb || [])[d.properties.cat] || [128, 128, 128], radiusPx(d), lookUnit, this.style, this.flags) : null;
14140
+ const circleLook = flatLook ? d => flatBubbleLook(this.classRgb(d.properties.cat), radiusPx(d), lookUnit, this.style, this.flags) : null;
13299
14141
  const lineRgb = d => (circleLook ? circleLook(d).stroke : outlineRgb(d)) || [0, 0, 0];
13300
14142
  layers.push(new ScatterplotLayer({
13301
14143
  id: `ix-bubbles-${this.name}-circles-g${this._iconGeneration}`,
@@ -13303,7 +14145,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13303
14145
  getPosition: d => d.geometry.coordinates,
13304
14146
  getRadius: d => radiusPx(d) - (symbolOutline || circleLook ? 0 : outlinePx(d, radiusPx(d)) / 2),
13305
14147
  radiusUnits: 'pixels',
13306
- getFillColor: d => [...((this.categoryColorsRgb || [])[d.properties.cat] || [128, 128, 128]).slice(0, 3), noFill ? 0 : Math.round(this._iconAlpha(d) * (circleLook ? circleLook(d).fillOpacity : fillOpacity))],
14148
+ getFillColor: d => [...(this.classRgb(d.properties.cat)).slice(0, 3), noFill ? 0 : Math.round(this._iconAlpha(d) * (circleLook ? circleLook(d).fillOpacity : fillOpacity))],
13307
14149
  stroked: circleLook ? combined.some(d => circleLook(d).stroke) : combined.some(d => outlineRgb(d)),
13308
14150
  getLineColor: d => [...lineRgb(d).slice(0, 3), Math.round(this._iconAlpha(d) * (circleLook ? circleLook(d).strokeOpacity : 1))],
13309
14151
  getLineWidth: d => (circleLook ? (circleLook(d).stroke ? circleLook(d).width : 0) : outlinePx(d, radiusPx(d))),
@@ -13561,13 +14403,55 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13561
14403
  _buildSequenceChartLayers(charts, zoom, liveZoom) {
13562
14404
  const st = this.style;
13563
14405
  const multi = !!this._multiFields && this._isMultiFieldSequence();
13564
- 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);
13565
14435
  const unit = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(st.scale) || 1);
13566
14436
  const normalR = NORMAL_RADIUS_PX * unit;
13567
14437
  const sizePow = resolveSizePow(st, this.flags);
13568
- const partRadius = multi
13569
- ? v => Math.max(VALUE_RADIUS_MIN, normalR * Math.pow(Math.abs(v) / seqMax, 1 / sizePow))
13570
- : 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
+ };
13571
14455
  const rings = flatFlag(this.flags, 'RINGS');
13572
14456
  const centerValues = flatFlag(this.flags, 'CENTERVALUES');
13573
14457
  const noFill = this._partNoFill || new Set();
@@ -13575,21 +14459,103 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13575
14459
  const marking = this._markedClasses.size && !this._onMarksChanged;
13576
14460
  const gray = marking && String(st.evidence || 'isolate') === 'isolate_gray';
13577
14461
  const sorted = [...this.flags].some(f => /\bSORT\b/.test(f));
14462
+ const star = flatFlag(this.flags, 'STAR');
13578
14463
  const fillOpacity = styleNum(st.fillopacity) || 0.85;
13579
14464
  const borderRgb = st.linecolor && styleLineColor(st.linecolor) !== 'none' ? hexOrNamedToRgb(styleLineColor(st.linecolor)) : null;
13580
14465
  const lineWidth = styleNum(st.linewidth) || 1;
13581
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
+ };
13582
14527
  const shapeOf = i => { const sh = symbols && (symbols[i] || symbols[0]); return normalizeSymbolShape(sh); };
13583
- const colorOf = i => this.categoryColorsRgb[i] || [128, 128, 128];
14528
+ const colorOf = i => this.classRgb(i);
13584
14529
  const parts = [], glows = [];
13585
14530
  for (const d of charts) {
13586
- 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);
13587
14545
  let layout = rings ? ringsLayout(counts, partRadius)
13588
14546
  : sequenceLayout(counts, this.flags, partRadius, { maxRadius: normalR, maxValue: seqMax, nParts: nCats, rangeScale: styleNum(st.rangescale) || 0 });
13589
14547
  if (!layout.length) continue;
13590
14548
  // GLOW belongs to the first part (sorted) or to every part, and
13591
14549
  // hides with it when its class is not marked
13592
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];
13593
14559
  if (marking && !gray) {
13594
14560
  layout = layout.filter(p => this._markedClasses.has(p.i)).map(p => ({ ...p, x: 0, y: 0 }));
13595
14561
  if (!layout.length) continue;
@@ -13602,13 +14568,169 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13602
14568
  return item;
13603
14569
  });
13604
14570
  // STAR: the first part (its center) is put on top at the end (23145)
13605
- if (flatFlag(this.flags, 'STAR') && items.length > 1) items.push(items.shift());
14571
+ if (star && items.length > 1) items.push(items.shift());
13606
14572
  parts.push(...items);
13607
14573
  }
13608
14574
  const alignOf = this._chartAlignFn(liveZoom, d => (d.chart || d)._seqLast || 0);
13609
14575
  const offsetOf = item => { const o = alignOf(item.chart); return [o[0] + item.part.x, o[1] + item.part.y]; };
13610
14576
  const alphaOf = item => (gray && !this._markedClasses.has(item.part.i) ? DIMMED_ICON_ALPHA : 255);
13611
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
+ }
13612
14734
  if (glows.length && this.flags.has('GLOW') && !glowHiddenByScale(st, liveZoom)) {
13613
14735
  layers.push(new IconLayer({
13614
14736
  id: `ix-glow-${this.name}-g${this._iconGeneration}`,
@@ -13639,20 +14761,35 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13639
14761
  data: parts, pickable: true,
13640
14762
  getPosition: d => d.geometry.coordinates,
13641
14763
  // the stroke, in the icon raster's pixels (the icon is drawn at 2r)
13642
- getIcon: d => this._buildSingleIcon(colorOf(d.part.i), noFill.has(d.part.i) ? 0 : fillOpacity, shapeOf(d.part.i), borderRgb,
13643
- 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
+ },
13644
14773
  getSize: d => d.part.r * 2,
13645
14774
  getPixelOffset: offsetOf,
13646
14775
  getColor: d => [255, 255, 255, alphaOf(d)],
13647
14776
  sizeUnits: 'pixels', billboard: true,
13648
14777
  parameters: ICON_LAYER_GLOBE_PARAMETERS
13649
14778
  }));
13650
- // 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
13651
14780
  // COUNT its record count, with valuefield on the categorical field the
13652
- // class name — formatted with valuedecimals (else none) plus the unit;
13653
- // font min(1, 3.2 / L) · r · valuescale, L the length of the theme's
13654
- // biggest value text (szMaxText), normal weight, in valuecolor /
13655
- // 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)
13656
14793
  // CENTERVALUES: only on the first part (class 0), its own value
13657
14794
  if ((this.flags.has('VALUES') || centerValues) && !valuesHiddenByScale(st, zoom)) {
13658
14795
  const valueScale = styleNum(st.valuescale) || 1;
@@ -13672,14 +14809,88 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13672
14809
  const name = rec ? String(rec[st.valuefield]) : null;
13673
14810
  const t = counting && d.properties.recordCounts ? d.properties.recordCounts[d.part.i] : d.part.v;
13674
14811
  const text = name != null ? name : fmt(t);
14812
+ const image = !!urlSymbolOf(d.part.i);
13675
14813
  const L = Math.max((name != null ? name.length : 0) + 1, maxLen);
13676
- 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;
13677
14817
  if (fontSize > VALUES_MIN_FONT_PX) {
13678
14818
  labels.push({ geometry: d.geometry, text, fontSize, pixelOffset: offsetOf(d), alpha: alphaOf(d),
13679
- 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)) });
13680
14822
  }
13681
14823
  });
13682
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 {
13683
14894
  layers.push(new TextLayer({
13684
14895
  id: `ix-cluster-values-${this.name}`,
13685
14896
  data: labels, pickable: false,
@@ -13692,6 +14903,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13692
14903
  characterSet: 'auto',
13693
14904
  getTextAnchor: 'middle', getAlignmentBaseline: 'center'
13694
14905
  }));
14906
+ }
13695
14907
  }
13696
14908
  }
13697
14909
  return layers;
@@ -13803,7 +15015,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13803
15015
  return null;
13804
15016
  };
13805
15017
  const sliceRgb = sl => (sl.complement ? (parseCssColor(String(st.nodatacolor || '')) || [0xee, 0xee, 0xee])
13806
- : ((byClass || sl.single) && classColor(sl.value)) || colors[sl.i] || [128, 128, 128]);
15018
+ : ((byClass || sl.single) && classColor(sl.value)) || this.classRgb(sl.i));
13807
15019
  const fo = styleNum(st.fillopacity), op = styleNum(st.opacity);
13808
15020
  // flat sets the group's fill-opacity only for a truthy value
13809
15021
  const fillAlpha = op > 0 ? op : fo > 0 ? fo : 1;
@@ -13849,8 +15061,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13849
15061
  const c = f.geometry && f.geometry.coordinates;
13850
15062
  if (!c || typeof c[0] !== 'number') continue;
13851
15063
  const [lng, lat] = c;
13852
- if (lng < bbox[0] || lng > bbox[2] || lat < bbox[1] || lat > bbox[3]) continue;
13853
- if (globeCenter && !isOnVisibleHemisphere(lng, lat, globeCenter)) continue;
15064
+ if (!inViewport(lng, lat, bbox, globeCenter)) continue;
13854
15065
  const raw = f.properties || {};
13855
15066
  const values = fields.map(k => { const v = flatNumber(raw[k], this.flags); return isNaN(v) ? 0 : v; });
13856
15067
  const layout = pieSliceLayout(values, t, { centerPart: st.centerpart });
@@ -13903,7 +15114,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
13903
15114
  if (center) {
13904
15115
  const cr = ch.r * Math.sqrt(center.size / 100);
13905
15116
  ch.centerR = cr;
13906
- const rgb = colors[center.i] || [128, 128, 128];
15117
+ const rgb = this.classRgb(center.i);
13907
15118
  if (cr > 0) centers.push({ position: ch.c, r: cr * 0.9, off: ch.off, properties: ch.properties, chart: ch, fill: [...rgb.slice(0, 3), Math.round(255 * fillAlpha)] });
13908
15119
  if (glowOn && cr > 0) {
13909
15120
  glows.push({ position: ch.c, r: cr * 6, off: ch.off, fill: [...rgb.slice(0, 3), Math.round(255 * 0.05)] });
@@ -14030,7 +15241,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14030
15241
  const cv = ch.layout.center.value;
14031
15242
  const text = fmt(cv, dec != null ? dec : 0) + (unitText.length <= 5 ? unitText : '');
14032
15243
  const fontSize = Math.min(ch.centerR * 0.8, ch.centerR * (3.3 / Math.max(1, text.length))) * valueScale;
14033
- const rgb = (this.categoryColorsRgb || [])[ch.layout.center.i] || [128, 128, 128];
15244
+ const rgb = this.classRgb(ch.layout.center.i);
14034
15245
  if (fontSize > VALUES_MIN_FONT_PX) centerTexts.push({ position: ch.c, text, fontSize, off: o, color: flatChartTextRgb(rgb), bold: true });
14035
15246
  } else if (!ch.layout.center && has(/CENTERVALUE/) && (ch.count || (ch.size !== undefined && !isNaN(ch.size)))) {
14036
15247
  // flat prints String(nCount || nSize): a record's item has no
@@ -14240,8 +15451,8 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14240
15451
  const titleFont = 5 * (num(st.textscale) || num(st.valuescale) || 1);
14241
15452
  const boxOpacity = isNaN(styleNum(st.boxopacity)) ? 1 : styleNum(st.boxopacity);
14242
15453
  const borderStyle = String(st.borderstyle || '');
14243
- const boxFill = [...hexOrNamedToRgb(st.boxcolor || 'white').slice(0, 3), Math.round(255 * boxOpacity)];
14244
- 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]
14245
15456
  : [...hexOrNamedToRgb(styleLineColor(st.bordercolor) || '#bbbbbb').slice(0, 3), Math.round(255 * (/^(solid|dotted|dashed)$/.test(borderStyle) ? 1 : 0.3))];
14246
15457
  const borderWidth = num(st.borderwidth) || 1;
14247
15458
  const lc = st.linecolor != null && st.linecolor !== '' ? styleLineColor(st.linecolor) : null;
@@ -14283,7 +15494,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14283
15494
  off: [box.title.x * unit + ch.off[0], (box.title.baseline - 0.8 * box.title.font) * unit + ch.off[1]] });
14284
15495
  }
14285
15496
  for (const b of L.bars) {
14286
- const rgb = colors[b.cls] || [128, 128, 128];
15497
+ const rgb = this.classRgb(b.cls);
14287
15498
  const a = Math.round(255 * b.fill.opacity * b.opacity);
14288
15499
  const line = [...lineRgbOf(b.line, rgb).slice(0, 3), Math.round(255 * b.line.opacity * b.opacity)];
14289
15500
  shape(ch, b, [...rgb.slice(0, 3), a], line, b.line.width, ch.properties, false);
@@ -14291,7 +15502,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14291
15502
  for (const tx of L.texts) {
14292
15503
  const font = tx.font * unit;
14293
15504
  if (font <= VALUES_MIN_FONT_PX) continue;
14294
- const rgb = colors[tx.cls] || [128, 128, 128];
15505
+ const rgb = this.classRgb(tx.cls);
14295
15506
  const color = tx.color === 'value' ? [0x79, 0x86, 0x97] : tx.color === 'contrast' ? flatChartTextRgb(rgb) : hexOrNamedToRgb(tx.color);
14296
15507
  // the baseline's start → the text's middle-left (arial: 0.35 font
14297
15508
  // above the baseline), perpendicular to its reading direction
@@ -14523,6 +15734,12 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14523
15734
  const t = this.flags.typeString != null ? this.flags.typeString : [...this.flags].join('|');
14524
15735
  const has = re => re.test(t);
14525
15736
  const bezier = has(/\bBEZIER\b/);
15737
+ const lift = has(/\b3D\b/);
15738
+ // flat's orthographic map draws a flow 1.604 times as wide as its Mercator formula gives at the
15739
+ // same object scale (measured on the mechanical-goods globe page: the widest ribbon 8.96 px
15740
+ // where scale 0.31 · 1.8 · 10 units gives 5.6; its pies, at the same scale, match)
15741
+ const flowWidthK = flatOrthographicByOptions.has(this.mapOptions) ? FLAT_ORTHOGRAPHIC_FLOW_WIDTH : 1;
15742
+ const archRatio = isNaN(styleNum(st.archeight)) ? VECTOR_ARC_HEIGHT_RATIO : styleNum(st.archeight);
14526
15743
  if (!this.binding.lookup2) {
14527
15744
  if (!this._warnedNoPos2) {
14528
15745
  this._warnedNoPos2 = true;
@@ -14535,11 +15752,27 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14535
15752
  const scale = styleNum(st.scale) || 1;
14536
15753
  const unit = oz * scale;
14537
15754
  const world = 512 * Math.pow(2, liveZoom);
14538
- const toPx = ([lng, lat]) => {
15755
+ const mercToPx = ([lng, lat]) => {
14539
15756
  const s = Math.sin(Math.max(-85.0511, Math.min(85.0511, lat)) * Math.PI / 180);
14540
15757
  return [(lng + 180) / 360 * world, (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * world];
14541
15758
  };
14542
- const toLngLat = ([x, y]) => [x / world * 360 - 180, (2 * Math.atan(Math.exp((0.5 - y / world) * 2 * Math.PI)) - Math.PI / 2) * 180 / Math.PI];
15759
+ const mercToLngLat = ([x, y]) => [x / world * 360 - 180, (2 * Math.atan(Math.exp((0.5 - y / world) * 2 * Math.PI)) - Math.PI / 2) * 180 / Math.PI];
15760
+ // the curves are computed in the plane of flat's map (Equal Earth), at the screen
15761
+ // size of this map's degree of longitude on the equator, then mapped back: the
15762
+ // flow bows around the pole as on flat. Mercator's plane stays the default.
15763
+ // On the globe (flat's orthographic map) the plane is the one of the view center,
15764
+ // for the flows whose two ends face the viewer; the others keep Mercator's.
15765
+ const planeFns = (proj, k) => ({
15766
+ toPx: ([lng, lat]) => { const q = proj.project(lat, lng); return [q[0] * k, -q[1] * k]; },
15767
+ toLngLat: ([x, y]) => { const q = proj.invert(x / k, -y / k); return [q[1], q[0]]; }
15768
+ });
15769
+ const mercFns = { toPx: mercToPx, toLngLat: mercToLngLat };
15770
+ const fixedPlane = flatPlaneProjectionByOptions.get(this.mapOptions);
15771
+ const fixedFns = fixedPlane && fixedPlane.invert ? planeFns(fixedPlane, (world / 360) / fixedPlane.project(0, 1)[0]) : null;
15772
+ const planeCenter = flatViewCenterByOptions.get(this.mapOptions) || globeCenter;
15773
+ const ortho = !fixedFns && globeCenter ? orthographicPlane(planeCenter.lat, planeCenter.lng) : null;
15774
+ const orthoFns = ortho ? planeFns(ortho, world / (2 * Math.PI)) : null;
15775
+ const fnsOf = it => (fixedFns || (orthoFns && ortho.inFront(it.p1) && ortho.inFront(it.p2) ? orthoFns : mercFns));
14543
15776
  const inBox = c => c && c[0] >= bbox[0] && c[0] <= bbox[2] && c[1] >= bbox[1] && c[1] <= bbox[3];
14544
15777
 
14545
15778
  const sizePow = resolveSizePow(st, this.flags);
@@ -14569,7 +15802,10 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14569
15802
  if (c) return c;
14570
15803
  }
14571
15804
  if (it.cat != null) return it.cat >= 0 && colors[it.cat] ? colors[it.cat] : [0, 0, 0];
14572
- return Number.isInteger(it.value) && colors[it.value - 1] ? colors[it.value - 1] : [0, 0, 0];
15805
+ // no value field: the item's value is 1, its color the scheme's first (flat's
15806
+ // colorScheme[nValuesA[0] - 1]; mechanical_goods_flows.html draws one color per series)
15807
+ const v = it.value === undefined ? 1 : it.value;
15808
+ return Number.isInteger(v) && colors[v - 1] ? colors[v - 1] : [0, 0, 0];
14573
15809
  };
14574
15810
  const fade = has(/\bFADEIN\b/) ? 0.2 : 2;
14575
15811
  const gradient = bezier && has(/\bGRADIENT\b|\bFADEIN\b/);
@@ -14600,8 +15836,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14600
15836
 
14601
15837
  const paths = [], heads = [];
14602
15838
  const N = 32;
14603
- const visible = c => !globeCenter || isOnVisibleHemisphere(c[0], c[1], globeCenter);
15839
+ const visible = c => Number.isFinite(c[0]) && Number.isFinite(c[1]) && (!globeCenter || isOnVisibleHemisphere(c[0], c[1], globeCenter));
14604
15840
  for (const it of order) {
15841
+ const { toPx, toLngLat } = fnsOf(it);
14605
15842
  let p1 = it.p1, p2 = it.p2;
14606
15843
  if (!p2) continue;
14607
15844
  if (minValue && it.nSize < minValue) continue;
@@ -14612,7 +15849,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14612
15849
  if (p1[0] === p2[0] && p1[1] === p2[1]) continue;
14613
15850
  const w = rawLw != null && rawLw !== '' && lwNum ? lwNum
14614
15851
  : 10 / Math.pow(nsv || maxSize, 1 / sizePow) * Math.pow(Math.abs(it.nSize), 1 / sizePow);
14615
- sw = w * unit;
15852
+ sw = w * unit * flowWidthK;
14616
15853
  if (!(sw > 0)) continue;
14617
15854
  if (has(/\bREVERSE\b/) || it.nSize < 0) [p1, p2] = [p2, p1];
14618
15855
  const a = toPx(p1), b = toPx(p2);
@@ -14639,7 +15876,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14639
15876
  end = [end[0] + a[0], end[1] + a[1]];
14640
15877
  } else {
14641
15878
  const w = (rawLw != null && rawLw !== '' && lwNum) || (2 / (nsv || 1) / maxSize * it.nSize) || 0.1;
14642
- sw = w * oz;
15879
+ sw = w * oz * flowWidthK;
14643
15880
  const a = toPx(p1), b = toPx(p2);
14644
15881
  pts = [];
14645
15882
  for (let i = 0; i <= N; i++) pts.push([a[0] + (b[0] - a[0]) * i / N, a[1] + (b[1] - a[1]) * i / N]);
@@ -14664,15 +15901,47 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14664
15901
  vector: { itemValue: it.cat != null ? (it.cat >= 0 ? it.cat + 1 : 0) : it.value } };
14665
15902
  // the line in map coordinates; on the globe only its visible runs
14666
15903
  const ll = pts.map(toLngLat);
15904
+ // a flow that crosses the antimeridian (over the pole, or the Pacific) continues past ±180°
15905
+ // instead of jumping back, which would draw it the long way round the globe
15906
+ for (let i = 1; i < ll.length; i++) {
15907
+ if (!Number.isFinite(ll[i][0]) || !Number.isFinite(ll[i - 1][0])) continue;
15908
+ while (ll[i][0] - ll[i - 1][0] > 180) ll[i][0] -= 360;
15909
+ while (ll[i][0] - ll[i - 1][0] < -180) ll[i][0] += 360;
15910
+ }
14667
15911
  let run = [], runColors = [];
14668
15912
  const flush = () => {
14669
- if (run.length > 1) paths.push({ path: run, colors: runColors, width: drawW, dash: dashArr ? [dashArr[0] / drawW, dashArr[1] / drawW] : [0, 0], properties, item: it });
15913
+ if (run.length > 1) {
15914
+ const dash = dashArr ? [dashArr[0] / drawW, dashArr[1] / drawW] : [0, 0];
15915
+ if (globeCenter && !dashArr) {
15916
+ // on the globe deck.gl draws only the first three quarters of a path that has a
15917
+ // color per vertex (also an equal one): one color per path, a gradient as one
15918
+ // path per segment, its two colors averaged
15919
+ const same = runColors.every(c => c[0] === runColors[0][0] && c[1] === runColors[0][1] && c[2] === runColors[0][2] && c[3] === runColors[0][3]);
15920
+ if (same) paths.push({ path: run, colors: runColors[0], width: drawW, dash, properties, item: it });
15921
+ else for (let k = 0; k < run.length - 1; k++) {
15922
+ const c0 = runColors[k], c1 = runColors[k + 1];
15923
+ paths.push({ path: [run[k], run[k + 1]], colors: [0, 1, 2, 3].map(j => Math.round((c0[j] + c1[j]) / 2)), width: drawW, dash, properties, item: it });
15924
+ }
15925
+ } else paths.push({ path: run, colors: runColors, width: drawW, dash, properties, item: it });
15926
+ }
14670
15927
  run = []; runColors = [];
14671
15928
  };
14672
- ll.forEach((c, i) => { if (visible(c)) { run.push(c); runColors.push(vertexColor(i)); } else flush(); });
15929
+ const chordM = lift ? greatCircleMeters(p1, p2) : 0;
15930
+ ll.forEach((c, i) => { if (visible(c)) { run.push(lift ? [c[0], c[1], vectorArcHeight(i, ll.length - 1, chordM, archRatio)] : c); runColors.push(vertexColor(i)); } else flush(); });
14673
15931
  flush();
14674
15932
  if (arrow && visible(toLngLat(end))) {
14675
- const tri = arrowMarkerTriangle(end, tan[0], tan[1], sw, headS, refX, refY).map(toLngLat);
15933
+ const triPx = arrowMarkerTriangle(end, tan[0], tan[1], sw, headS, refX, refY);
15934
+ let tri = triPx.map(toLngLat);
15935
+ if (lift) {
15936
+ // the head lies in the arc's plane: a vertex behind the line's end is as
15937
+ // high as the arc there, the tip (past the end) on the ground
15938
+ const tl = Math.hypot(tan[0], tan[1]) || 1;
15939
+ tri = tri.map((c, k) => {
15940
+ const back = -((triPx[k][0] - end[0]) * tan[0] + (triPx[k][1] - end[1]) * tan[1]) / tl;
15941
+ return [c[0], c[1], back > 0 ? vectorArcHeight(pathBackIndex(pts, back), pts.length - 1, chordM, archRatio) : 0];
15942
+ });
15943
+ }
15944
+ if (tri.some(c => !Number.isFinite(c[0]) || !Number.isFinite(c[1]))) continue; // off the projection's outline
14676
15945
  heads.push({ type: 'Feature', properties, geometry: { type: 'Polygon', coordinates: [[...tri, tri[0]]] },
14677
15946
  fill: [...rgb.slice(0, 3), Math.round(255 * Math.min(1, headAlpha) * groupAlpha)] });
14678
15947
  }
@@ -14898,7 +16167,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
14898
16167
  const valueScale = styleNum(this.style.valuescale) || 1;
14899
16168
  const textOverride = this.style.valuecolor || this.style.textcolor;
14900
16169
  const opts = this._valueTextOpts();
14901
- const itemRgb = props => this.categoryColorsRgb[props.counts ? dominant(props.counts) : props.cat] || [128, 128, 128];
16170
+ const itemRgb = props => this.classRgb(props.counts ? dominant(props.counts) : props.cat);
14902
16171
  const valueOf = props => (props.counts ? props.total : (props.classValue !== undefined ? props.classValue : props.value));
14903
16172
  const textOf = props => flatValueText(props.counts ? this._categoryValueRecord(dominant(props.counts)) : props.raw,
14904
16173
  this.binding.title && props.raw ? props.raw[this.binding.title] : undefined, valueOf(props), this.style, this.flags, opts);
@@ -15068,8 +16337,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15068
16337
  ctx.lineTo(cx + r, cy - r * 0.6);
15069
16338
  ctx.lineTo(cx, cy + r);
15070
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':
15071
16343
  case 'roundrect': {
15072
- const s = r * 0.8;
16344
+ const s = shape === 'roundrectfull' ? r : r * 0.8;
15073
16345
  if (ctx.roundRect) ctx.roundRect(cx - s, cy - s, s * 2, s * 2, r * 0.3);
15074
16346
  else ctx.rect(cx - s, cy - s, s * 2, s * 2);
15075
16347
  break;
@@ -15138,6 +16410,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15138
16410
  function withAlpha(rgb, a) {
15139
16411
  return a >= 1 ? rgb.slice(0, 3) : [...rgb.slice(0, 3), Math.round(255 * Math.max(0, a))];
15140
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
+ }
15141
16418
  function hexOrNamedToRgb(v) {
15142
16419
  const NAMED = { gray: [128, 128, 128], grey: [128, 128, 128], black: [0, 0, 0], white: [255, 255, 255] };
15143
16420
  return parseCssColor(v) || NAMED[v] || [130, 130, 130];
@@ -15254,7 +16531,7 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15254
16531
  // last built map; called while the map is still building, they run once
15255
16532
  // it is ready.
15256
16533
  const MAP_HANDLE_METHODS = ['replace', 'add', 'newTheme', 'remove', 'removeTheme', 'replaceTheme', 'changeThemeStyle', 'setThemeStyle',
15257
- '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'];
15258
16535
  const _mapHandles = Object.create(null);
15259
16536
  function makeMapHandle(szMap) {
15260
16537
  const h = {};
@@ -15269,6 +16546,15 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15269
16546
  }
15270
16547
  h.getZoom = () => { const a = api(); return a ? a.getZoom() : undefined; };
15271
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();
15272
16558
  // flat's mapApi.theme(szTheme) (htmlgui_flat.js 1571): the theme handle
15273
16559
  h.theme = szTheme => new themeApi(szMap, szTheme);
15274
16560
  // flat's mapApi aliases (htmlgui_flat.js 1286+): show/hide a theme,
@@ -15837,7 +17123,11 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15837
17123
  // known real page's globe toggle actually uses (see
15838
17124
  // example-world-bubble-projection-toggle.md) — so `ixmaps.szResourceBase`
15839
17125
  // only needs to be SOME string, not a real, working resource base URL.
15840
- 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/';
15841
17131
  function getProjectString() {
15842
17132
  if (!_lastMapApi || !_lastMapApi.map) return JSON.stringify({ map: {} });
15843
17133
  const map = _lastMapApi.map;
@@ -15930,6 +17220,9 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15930
17220
  newTheme: (title, theme, flag) => _mapHandle.newTheme(title, theme, flag),
15931
17221
  // flat's time slider: show the records of a theme (null = all themes) whose timefield lies in [min, max)
15932
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),
15933
17226
  setBasemapOpacity: compatSetBasemapOpacity,
15934
17227
  // this engine's version (flat's own ixmaps.version numbers flat)
15935
17228
  glVersion: IXMAPS_GL_VERSION,
@@ -15954,15 +17247,15 @@ float barSegDist(vec2 p, vec2 a, vec2 b) {
15954
17247
  // test-only: lets test/unit/*.test.mjs call pure internals directly (the
15955
17248
  // engine runs in a Node vm there); deliberately NOT on the ixmaps object
15956
17249
  global.__ixmapsGlInternals = {
15957
- 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, bezierVectorLayout, cubicBezierPoints, arrowMarkerTriangle, fadeGradientStops, flatToArray, hashUnit, multiQuadOffsets, pixelOffsetLngLat, chartTextTransmittance, insideSymbolShape,
15958
- normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, MapBuilder, typeStyleNumbers, styleNum,
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,
17251
+ choroplethOutlineColor, normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, MapBuilder, typeStyleNumbers, styleNum,
15959
17252
  resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
15960
17253
  equalIntervalBreaks, quantileBreaks, naturalBreaks, evenStrideSample, jenksBreakValues, partsFromBreakValues, resolvePartsClass,
15961
17254
  computeAlphaStats, computeMultiFieldClasses, computeDominantStats, resolveDominantClass, computeComposeColorStats,
15962
17255
  resolveComposedColor, computeRangeClasses, colorSchemeClassCount, resolveDopacityAlpha,
15963
17256
  flatColorSweep, applyClassesToColorScheme, resolveClassColors, flatOutlierStats, parseCssColor, flatLegendLook,
15964
17257
  flatToMapLibreZoom, mapLibreToFlatZoom, flatViewToMapLibreZoom, mapLibreToFlatViewZoom, flatSvgProjectionOf, flatSvgProjectionByOptions, normalSizeScaleOf, scaleDenominatorAt, resolveZoomReference, resolveAggregationPx, valuesHiddenByScale,
15965
- 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,
15966
17259
  applyWhereFilter, joinChartPositions, flatLookupKey, flatShapeCenter, chartHiddenByScale, glowHiddenByScale, isSymbolChart, flatChartAlignOffset, flatChartBranch, flatValueText, categoryValueRecord, flatDerivateRgb, flatBubbleLook, bubbleIconLook, flatChartTextRgb, flatGroupedValue, flatNoBreaks, featuresHiddenByScale, boxHiddenByScale, flatShadowOn, snapToAggregationGrid,
15967
17260
  drawSymbolPath, normalizeSymbolShape,
15968
17261
  flatFormatValue, themeStyleArgs, flatChangedStyleValue, explicitValueDecimals, legendDecimals,