ixmaps-gl 0.2.19 → 0.2.21

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 +51 -8
  2. package/ixmaps-gl.js +2986 -74
  3. package/package.json +1 -1
package/ixmaps-gl.js CHANGED
@@ -49,12 +49,26 @@
49
49
  // (headTailBreaks); LOG/POW2/POW3 are recognized type flags but not
50
50
  // implemented, see KNOWN_INERT_FLAGS. CHART|USER draws a page's own chart
51
51
  // function (style.userdraw, _buildUserChartLayers), CHART|LABEL flat's
52
- // value label, boxed or TEXTONLY (_buildLabelChartLayers). These
52
+ // value label, boxed or TEXTONLY (_buildLabelChartLayers), CHART|PIE (and
53
+ // PIE|DONUT) a pie per record, a slice per value field, flat's sizing,
54
+ // CENTER part and VALUES leader labels (_buildPieLayers; 3D/VOLUME/
55
+ // STARBURST/RAYS/BOW variants are noted, drawn flat; AGGREGATE and
56
+ // CATEGORICAL pies a pie per item, a slice per category or field, flat's
57
+ // aggregation), CHART|VECTOR and CHART|BEZIER a flow line
58
+ // per item from its position to its second one (binding position2 /
59
+ // style lookupfield2), per record or AGGREGATE'd per origin–destination
60
+ // pair, with flat's widths, colors, arrow heads and GRADIENT / FADEIN
61
+ // (_buildVectorLayers; VALUES, DYNAMICWIDTH, LONG and the DASH animation
62
+ // are noted, see VECTOR_INERT_FLAGS), CHART|BAR (and BARS) a bar chart
63
+ // per record or aggregated item, a bar per value field or category, flat's
64
+ // sizing, HORZ / STACKED / SORT, POINTER and ARROW pointers, VALUES texts,
65
+ // BOX and TITLE (_buildBarLayers; 3D, VOLUME, DOTTED, TRENDLINE, the axis
66
+ // labels … are noted, drawn plain, see BAR_INERT_FLAGS). These
53
67
  // base types are dispatched by buildDeckLayers in the real engine's own
54
68
  // precedence order — DOT is checked first because the real engine's DOT
55
69
  // bypasses the whole drawChart/modifier pipeline rather than being
56
- // "BUBBLE with the size locked." Other real base types (QUAD, BEZIER,
57
- // VECTOR, PIE/DONUT, WAFFLE, BAR, and SYMBOL's own shape variants) are NOT
70
+ // "BUBBLE with the size locked." Other real base types (QUAD, WAFFLE,
71
+ // and SYMBOL's own shape variants) are NOT
58
72
  // implemented yet — adding one means a new _buildXLayers() method plus a
59
73
  // dispatch line, not a rewrite, but each should be added deliberately
60
74
  // (checking, per real source, which modifiers it actually shares with
@@ -82,7 +96,7 @@
82
96
  // ---------------------------------------------------------------
83
97
  // this engine's release (= package.json "version", checked by
84
98
  // test/unit/version.test.mjs); ixmaps.glVersion, and logged once at start
85
- const IXMAPS_GL_VERSION = '0.2.19';
99
+ const IXMAPS_GL_VERSION = '0.2.21';
86
100
 
87
101
  const LIB_URLS = {
88
102
  // Bumped 3.6.2 -> 5.x (2026-09-21, globe-projection compat fix): native
@@ -199,7 +213,213 @@
199
213
  // byte-grep of the shipped file (deck.gl's own @deck.gl/maplibre
200
214
  // overview page claims ES-modules-only, which the actual UMD bundle
201
215
  // contradicts).
202
- let IconLayer, ScatterplotLayer, GeoJsonLayer, MapLibreOverlay, TextLayer;
216
+ let IconLayer, ScatterplotLayer, GeoJsonLayer, MapLibreOverlay, TextLayer, PathLayer, PathStyleExtension;
217
+ // PieSliceLayer (see makePieSliceLayer) and BarShapeLayer
218
+ // (makeBarShapeLayer) are derived from ScatterplotLayer once deck.gl is there
219
+ let PieSliceLayer, BarShapeLayer;
220
+
221
+ // CHART|PIE / DONUT slices (_buildPieLayers): one instance per slice — a
222
+ // ScatterplotLayer circle (billboarded, pixel radius, pixel offset, the
223
+ // same projection path as every other chart, globe included) whose
224
+ // fragment shader keeps only the slice's sector of the circle and the
225
+ // ring outside its inner radius (DONUT, CENTER), and draws flat's slice
226
+ // outline along the sector's edges.
227
+ // getSlice: [start angle, sweep, inner radius / radius] — radians,
228
+ // clockwise from 12 o'clock (flat's DonutChart angles); a
229
+ // sweep of 2π is a full ring (no radial edges)
230
+ // getSliceLineWidth: the outline width in pixels (0 = none), drawn in
231
+ // getLineColor
232
+ // Why not a canvas icon per pie (as the bubbles): every pie has its own
233
+ // slice angles, ~7 900 distinct icons on the comuni page, far beyond
234
+ // what deck.gl's never-evicting icon atlas holds (ICON_ATLAS_RESET_AFTER);
235
+ // and not polygons in map coordinates: ~31 000 slices re-tessellated at
236
+ // every zoom step. Here the GPU draws each slice from 5 numbers, crisp at
237
+ // any device pixel ratio. Neighbouring slices split the circle by angle
238
+ // without anti-aliasing between them (a hard partition: no background
239
+ // seam shows between two slices); the circle's rim keeps
240
+ // ScatterplotLayer's own anti-aliasing.
241
+ function makePieSliceLayer(Base) {
242
+ class Layer extends Base {
243
+ getShaders() {
244
+ const shaders = super.getShaders();
245
+ const inject = Object.assign({}, shaders.inject || {});
246
+ inject['vs:#decl'] = (inject['vs:#decl'] || '') + `
247
+ in vec3 instancePieSlices;
248
+ in float instancePieLineWidths;
249
+ out vec3 vPieSlice;
250
+ out float vPieRadius;
251
+ out float vPieLineWidth;
252
+ out vec4 vPieLineColor;
253
+ `;
254
+ inject['vs:#main-end'] = (inject['vs:#main-end'] || '') + `
255
+ vPieSlice = instancePieSlices;
256
+ vPieRadius = outerRadiusPixels;
257
+ vPieLineWidth = instancePieLineWidths;
258
+ vPieLineColor = vec4(instanceLineColors.rgb, instanceLineColors.a * layer.opacity);
259
+ `;
260
+ inject['fs:#decl'] = (inject['fs:#decl'] || '') + `
261
+ in vec3 vPieSlice;
262
+ in float vPieRadius;
263
+ in float vPieLineWidth;
264
+ in vec4 vPieLineColor;
265
+ `;
266
+ // before the picking module's own filter (it replaces the color
267
+ // when picking): the discards shape the pick area too
268
+ inject['fs:DECKGL_FILTER_COLOR'] = (inject['fs:DECKGL_FILTER_COLOR'] || '') + `
269
+ {
270
+ const float PIE_TAU = 6.283185307179586;
271
+ vec2 pieP = geometry.uv * vPieRadius;
272
+ float pieR = length(pieP);
273
+ float pieInner = vPieSlice.z * vPieRadius;
274
+ if (pieInner > 0.0 && pieR < pieInner - 1.0) discard;
275
+ bool pieFull = vPieSlice.y >= PIE_TAU - 1e-5;
276
+ if (!pieFull) {
277
+ float pieA = atan(pieP.x, pieP.y);
278
+ float pieRel = mod(pieA - vPieSlice.x + 2.0 * PIE_TAU, PIE_TAU);
279
+ if (pieRel >= vPieSlice.y) discard;
280
+ }
281
+ float pieD = vPieRadius - pieR;
282
+ if (pieInner > 0.0) pieD = min(pieD, pieR - pieInner);
283
+ if (!pieFull) {
284
+ vec2 pieE0 = vec2(sin(vPieSlice.x), cos(vPieSlice.x));
285
+ vec2 pieE1 = vec2(sin(vPieSlice.x + vPieSlice.y), cos(vPieSlice.x + vPieSlice.y));
286
+ float pieD0 = dot(pieP, pieE0) > 0.0 ? abs(pieP.x * pieE0.y - pieP.y * pieE0.x) : pieR;
287
+ float pieD1 = dot(pieP, pieE1) > 0.0 ? abs(pieP.x * pieE1.y - pieP.y * pieE1.x) : pieR;
288
+ pieD = min(pieD, min(pieD0, pieD1));
289
+ }
290
+ // in device pixels (fwidth: CSS px per device pixel), so a hairline
291
+ // stays one at any device pixel ratio. Half the outline lies inside
292
+ // each slice (the other half in its neighbour); below a pixel it fades
293
+ // in by its width
294
+ float pieAA = max(fwidth(pieR), 1e-4);
295
+ float pieH = vPieLineWidth * 0.5 / pieAA;
296
+ float pieDD = pieD / pieAA;
297
+ float pieLine = pieH > 0.0 ? clamp(min(pieH + 0.5 - pieDD, 2.0 * pieH), 0.0, 1.0) : 0.0;
298
+ float pieRim = clamp((vPieRadius - pieR) / pieAA + 0.5, 0.0, 1.0);
299
+ color = vec4(mix(color.rgb, vPieLineColor.rgb, pieLine), mix(color.a, vPieLineColor.a * pieRim, pieLine));
300
+ if (pieInner > 0.0) color.a *= clamp((pieR - pieInner) / pieAA + 0.5, 0.0, 1.0);
301
+ }
302
+ `;
303
+ shaders.inject = inject;
304
+ return shaders;
305
+ }
306
+ initializeState() {
307
+ super.initializeState();
308
+ this.getAttributeManager().addInstanced({
309
+ instancePieSlices: { size: 3, accessor: 'getSlice', defaultValue: [0, 2 * Math.PI, 0] },
310
+ instancePieLineWidths: { size: 1, accessor: 'getSliceLineWidth', defaultValue: 0 }
311
+ });
312
+ }
313
+ }
314
+ Layer.layerName = 'PieSliceLayer';
315
+ // deck.gl merges a subclass's defaultProps over its base class's
316
+ Layer.defaultProps = {
317
+ getSlice: { type: 'accessor', value: [0, 2 * Math.PI, 0] },
318
+ getSliceLineWidth: { type: 'accessor', value: 0 }
319
+ };
320
+ return Layer;
321
+ }
322
+
323
+ // CHART|BAR / BARS bars (_buildBarLayers): one instance per bar — a
324
+ // ScatterplotLayer circle (billboarded, pixel radius, pixel offset, the
325
+ // same projection path as every other chart, globe included) whose
326
+ // fragment shader keeps only the bar's shape inside the circle and draws
327
+ // flat's outline along its edge, centered on it (an SVG stroke).
328
+ // getBarShape: [half width, shaft half width, shaft length, band] px
329
+ // getBarShape2: [head length, direction (0 up, 1 right, 2 down,
330
+ // 3 left; + 4: never picked), outline width px, the base's distance from
331
+ // the circle's center along the direction] — the circle
332
+ // centered on the shape, its radius covering it
333
+ // A plain bar is a rectangle (shaft = half width, no band, no head); a
334
+ // POINTER / ARROW its shaft, a band as wide as the bar and a triangular
335
+ // head (flat's pointer path). Why not a canvas icon per bar: every bar
336
+ // has its own length (~1 000 distinct icons per view on a comuni page,
337
+ // beyond deck.gl's never-evicting icon atlas); nor polygons in map
338
+ // coordinates, re-tessellated at every zoom step and wrong on the globe.
339
+ function makeBarShapeLayer(Base) {
340
+ class Layer extends Base {
341
+ getShaders() {
342
+ const shaders = super.getShaders();
343
+ const inject = Object.assign({}, shaders.inject || {});
344
+ inject['vs:#decl'] = (inject['vs:#decl'] || '') + `
345
+ in vec4 instanceBarShapes;
346
+ in vec4 instanceBarShapes2;
347
+ out vec4 vBarShape;
348
+ out vec4 vBarShape2;
349
+ out float vBarRadius;
350
+ out vec4 vBarLineColor;
351
+ `;
352
+ inject['vs:#main-end'] = (inject['vs:#main-end'] || '') + `
353
+ vBarShape = instanceBarShapes;
354
+ vBarShape2 = instanceBarShapes2;
355
+ vBarRadius = outerRadiusPixels;
356
+ vBarLineColor = vec4(instanceLineColors.rgb, instanceLineColors.a * layer.opacity);
357
+ `;
358
+ inject['fs:#decl'] = (inject['fs:#decl'] || '') + `
359
+ in vec4 vBarShape;
360
+ in vec4 vBarShape2;
361
+ in float vBarRadius;
362
+ in vec4 vBarLineColor;
363
+ float barSegDist(vec2 p, vec2 a, vec2 b) {
364
+ vec2 ab = b - a;
365
+ float l2 = dot(ab, ab);
366
+ float t = l2 > 0.0 ? clamp(dot(p - a, ab) / l2, 0.0, 1.0) : 0.0;
367
+ return length(p - a - t * ab);
368
+ }
369
+ `;
370
+ // before the picking module's own filter (it replaces the color
371
+ // when picking): the discards shape the pick area too
372
+ inject['fs:DECKGL_FILTER_COLOR'] = (inject['fs:DECKGL_FILTER_COLOR'] || '') + `
373
+ {
374
+ vec2 barS = geometry.uv * vBarRadius;
375
+ barS.y = -barS.y;
376
+ // a direction beyond 3: drawn, never picked (a chart's BOX)
377
+ float barDir = vBarShape2.y;
378
+ bool barNoPick = barDir > 3.5;
379
+ if (barNoPick) barDir -= 4.0;
380
+ if (barNoPick && picking.isActive > 0.5) discard;
381
+ vec2 barD = barDir < 0.5 ? vec2(0.0, -1.0) : barDir < 1.5 ? vec2(1.0, 0.0) : barDir < 2.5 ? vec2(0.0, 1.0) : vec2(-1.0, 0.0);
382
+ vec2 barP = vec2(abs(dot(barS, vec2(-barD.y, barD.x))), dot(barS, barD) + vBarShape2.w);
383
+ float barW2 = vBarShape.x, barSW = vBarShape.y, barL = vBarShape.z, barB = vBarShape.w, barH = vBarShape2.x;
384
+ bool barIn = (barP.x <= barSW && barP.y >= 0.0 && barP.y <= barL)
385
+ || (barP.x <= barW2 && barP.y >= barL && barP.y <= barL + barB)
386
+ || (barH > 0.0 && barP.y >= barL + barB && barP.y <= barL + barB + barH && barP.x <= barW2 * (1.0 - (barP.y - barL - barB) / barH));
387
+ float barD0 = barSegDist(barP, vec2(0.0, 0.0), vec2(barSW, 0.0));
388
+ barD0 = min(barD0, barSegDist(barP, vec2(barSW, 0.0), vec2(barSW, barL)));
389
+ barD0 = min(barD0, barSegDist(barP, vec2(barSW, barL), vec2(barW2, barL)));
390
+ barD0 = min(barD0, barSegDist(barP, vec2(barW2, barL), vec2(barW2, barL + barB)));
391
+ barD0 = min(barD0, barSegDist(barP, vec2(barW2, barL + barB), vec2(0.0, barL + barB + barH)));
392
+ // in device pixels (fwidth: CSS px per device pixel): edges anti-aliased
393
+ // over one device pixel, a hairline outline fades in by its width
394
+ float barAA = max(fwidth(barS.x), 1e-4);
395
+ float barFill = barIn ? clamp(barD0 / barAA + 0.5, 0.0, 1.0) : clamp(0.5 - barD0 / barAA, 0.0, 1.0);
396
+ // a bar thinner than a device pixel covers only its share of it (a 0
397
+ // value's bar is no line)
398
+ barFill = min(barFill, clamp(min(2.0 * barW2, barL + barB + barH) / barAA, 0.0, 1.0));
399
+ float barLW = vBarShape2.z;
400
+ float barLine = barLW > 0.0 ? clamp(min(barLW * 0.5 / barAA + 0.5 - barD0 / barAA, barLW / barAA), 0.0, 1.0) : 0.0;
401
+ color = vec4(mix(color.rgb, vBarLineColor.rgb, barLine), mix(color.a * barFill, vBarLineColor.a, barLine));
402
+ if (color.a <= 0.0) discard;
403
+ }
404
+ `;
405
+ shaders.inject = inject;
406
+ return shaders;
407
+ }
408
+ initializeState() {
409
+ super.initializeState();
410
+ this.getAttributeManager().addInstanced({
411
+ instanceBarShapes: { size: 4, accessor: 'getBarShape', defaultValue: [0, 0, 0, 0] },
412
+ instanceBarShapes2: { size: 4, accessor: 'getBarShape2', defaultValue: [0, 0, 0, 0] }
413
+ });
414
+ }
415
+ }
416
+ Layer.layerName = 'BarShapeLayer';
417
+ Layer.defaultProps = {
418
+ getBarShape: { type: 'accessor', value: [0, 0, 0, 0] },
419
+ getBarShape2: { type: 'accessor', value: [0, 0, 0, 0] }
420
+ };
421
+ return Layer;
422
+ }
203
423
 
204
424
  // Cached so multiple ixmaps.Map() calls on one page (or a page that
205
425
  // still has its own static <script> tags for these libraries) only
@@ -276,7 +496,9 @@
276
496
  global.Mustache ? Promise.resolve() : loadScript(LIB_URLS.mustache),
277
497
  [...document.styleSheets].some(s => s.href === LIB_URLS.maplibreCss) ? Promise.resolve() : loadStylesheet(LIB_URLS.maplibreCss)
278
498
  ]).then(() => {
279
- ({ IconLayer, ScatterplotLayer, GeoJsonLayer, MapLibreOverlay, TextLayer } = global.deck);
499
+ ({ IconLayer, ScatterplotLayer, GeoJsonLayer, MapLibreOverlay, TextLayer, PathLayer, PathStyleExtension } = global.deck);
500
+ PieSliceLayer = makePieSliceLayer(ScatterplotLayer);
501
+ BarShapeLayer = makeBarShapeLayer(ScatterplotLayer);
280
502
  // which engine and libraries this page really runs (a page may
281
503
  // bring its own MapLibre/deck.gl — the loaded ones are named)
282
504
  const ml = global.maplibregl && typeof global.maplibregl.getVersion === 'function' ? global.maplibregl.getVersion() : '?';
@@ -1437,13 +1659,17 @@
1437
1659
  const lookupField = spec.binding.lookup;
1438
1660
  let layers = String(spec.name).split('|');
1439
1661
  if (layers.length > 1 && spec.flags && spec.flags.has('DIFFERENCE')) layers = layers.slice(-1);
1662
+ const lookup2 = spec.binding.lookup2;
1440
1663
  const features = [];
1441
- for (const layer of layers) {
1664
+ const indexes = layers.map(layer => {
1442
1665
  const index = new Map();
1443
1666
  runtimes.filter(r => r.name === layer && (r.flags.has('FEATURE') || r.flags.has('FEATURES'))).forEach(r => {
1444
1667
  // in theme order, as flat's layer holds the themes' shapes
1445
1668
  for (const [k, v] of featurePositionIndex(r)) { if (flatShapeWins(index.get(k), v.area)) index.set(k, v); }
1446
1669
  });
1670
+ return index;
1671
+ });
1672
+ for (const index of indexes) {
1447
1673
  if (!index.size) continue;
1448
1674
  for (const row of table.rows) {
1449
1675
  const v = row[lookupField];
@@ -1453,7 +1679,18 @@
1453
1679
  // joined shape, as flat) needs the shape's km² area carried along
1454
1680
  // (featureAreaKm2 prefers this marker), exactly as the choropleth
1455
1681
  // join stamps it
1456
- if (hit) features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: hit.pos }, properties: row, _flatAreaKm2: hit.areaKm2 });
1682
+ if (!hit) continue;
1683
+ const f = { type: 'Feature', geometry: { type: 'Point', coordinates: hit.pos }, properties: row, _flatAreaKm2: hit.areaKm2 };
1684
+ // VECTOR / BEZIER: the second position by its own key (flat looks
1685
+ // it up in every layer of the theme, maptheme.js 10431-10436); a
1686
+ // record without one stays an item that draws no line (null)
1687
+ if (lookup2 && !String(lookup2).includes('|')) {
1688
+ const v2 = row[lookup2];
1689
+ let hit2 = null;
1690
+ if (v2 != null && v2 !== '') for (const idx of indexes) { hit2 = idx.get(flatLookupKey(v2, spec.style)); if (hit2) break; }
1691
+ f._pos2 = hit2 ? hit2.pos : null;
1692
+ }
1693
+ features.push(f);
1457
1694
  }
1458
1695
  }
1459
1696
  return { type: 'FeatureCollection', features };
@@ -1952,6 +2189,28 @@
1952
2189
  // outliers" inverse mode) stays unimplemented — no config here uses it.
1953
2190
  const KNOWN_INERT_FLAGS = ['SEQUENCE', 'STAR', 'SORT', 'DOWN', 'RECT', 'CLIPTOGEOBOUNDS', 'OUTLIER',
1954
2191
  'POW2', 'POW3', 'NOSORT'];
2192
+ // flat's PIE / DONUT variants with no rendering here (see _buildPieLayers)
2193
+ const PIE_INERT_FLAGS = [/3D/, /VOLUME/, /HEIGHT/, /STARBURST/, /FLOWER/, /RAYS/, /BOW/, /ZOOM/, /DIRECTION/, /DIRECTED/, /POLAR/];
2194
+ // ... and of AGGREGATE / CATEGORICAL pies: GRIDSIZE's pie the size of its
2195
+ // grid cell (drawn by the SIZE rule instead), AUTOSIZE, DOPACITY, the
2196
+ // chart BOX and TITLE, GAUGE, MULTIPLE / DIFFUSE, the post-aggregation
2197
+ // value arithmetic (DIFFERENCE, FRACTION, PERCENT …, field100)
2198
+ const PIE_AGGREGATE_INERT_FLAGS = [/^GRIDSIZE$/, /AUTOSIZE/, /DOPACITY/, /BOX/, /TITLE/, /^GAUGE$/, /^MULTIPLE$/, /^DIFFUSE$/,
2199
+ /^DIFFERENCE$/, /^FRACTION$/, /^PERCENT$/, /^PERMILLE$/, /^PRODUCT$/, /^CALCVAL$/, /^RELATIVE$/];
2200
+ // flat's BAR / BARS variants with no rendering here (see flatBarLayout):
2201
+ // the bar is drawn plain (SMALLARROW as ARROW) — tested as substrings,
2202
+ // as flat does
2203
+ const BAR_INERT_FLAGS = [/3D/, /VOLUME/, /COLUMN/, /DOTTED/, /SMALLARROW/, /TRENDLINE/, /AXIS/, /^CLIP$/, /MORPH/,
2204
+ /ZOOM/, /NORMSIZE/, /MENUSIZE/, /INVERT/, /DIAGLEFT/, /DIAGRIGHT/, /^WIDTH$/, /^MULTIPLE$/, /^DIFFUSE$/,
2205
+ /^DIFFERENCE$/, /^FRACTION$/, /^PERCENT$/, /^PERMILLE$/, /^PRODUCT$/, /^CALCVAL$/, /^RELATIVE$/];
2206
+ // flat's VECTOR / BEZIER modifiers with no rendering here (see
2207
+ // _buildVectorLayers), the word → what happens instead
2208
+ const VECTOR_INERT_FLAGS = {
2209
+ VALUES: 'the value text along the curve is not drawn yet',
2210
+ DYNAMICWIDTH: 'not drawn yet (flat widens by the log of the length in its own map units) — the plain width instead',
2211
+ LONG: 'not drawn yet (its bow depends on flat\'s own map units) — the default bow instead',
2212
+ DASH: 'drawn as static dashes — flat\'s dash animation is not ported'
2213
+ };
1955
2214
  const _warnedFlags = new Set();
1956
2215
 
1957
2216
  // GL-PORT COMPAT: real ixmaps-flat's global ixmaps.getThemeObj(szId) /
@@ -2289,6 +2548,9 @@
2289
2548
  'style.valuefield': ['style', 'valuefield'],
2290
2549
  'style.timefield': ['binding', 'time'], // setThemeTimeFrame, see LayerRuntime.setTimeFrame
2291
2550
  'style.titlefield': ['binding', 'title'], // {{theme.item.title}}, see _buildTooltipContext
2551
+ // VECTOR / BEZIER's second position: a join key ("a") or lat/lon
2552
+ // fields ("a|b"), see joinChartPositions and _vectorRecords
2553
+ 'style.lookupfield2': ['binding', 'lookup2'],
2292
2554
  // the lookup key's normalization (flatLookupKey): flat's newTheme moves
2293
2555
  // binding.tonumber / digits / … into these style keys (htmlgui.js 1712)
2294
2556
  'style.lookupdigits': ['style', 'lookupdigits'],
@@ -2521,7 +2783,10 @@
2521
2783
  // real ixmaps-flat (htmlgui.js newTheme) merges .meta() INTO style first
2522
2784
  // — meta wins — and only then reads type, bindings and style properties
2523
2785
  const style = Object.assign({}, def.style, def.meta);
2524
- const typeStr = style.type != null ? String(style.type) : '';
2786
+ let typeStr = style.type != null ? String(style.type) : '';
2787
+ // flat (htmlgui-side theme parse, maptheme.js 1192): the old type
2788
+ // word EXACT is CATEGORICAL
2789
+ if (/\bEXACT\b/.test(typeStr) && !/\bCATEGORICAL\b/.test(typeStr)) typeStr += '|CATEGORICAL';
2525
2790
  const filter = style.filter;
2526
2791
  const title = style.title;
2527
2792
  delete style.type;
@@ -2542,6 +2807,48 @@
2542
2807
  console.info(`[ixmaps-gl] type flag "${flag}" recognized, no distinct rendering behavior implemented yet`);
2543
2808
  }
2544
2809
  });
2810
+ // PIE variants _buildPieLayers doesn't draw (the pie is drawn flat and
2811
+ // plain): flat tests them as substrings, so do these
2812
+ if (flags.has('PIE') && flags.has('CHART')) {
2813
+ flags.forEach(flag => {
2814
+ const inert = PIE_INERT_FLAGS.find(re => re.test(flag));
2815
+ if (inert && !(/HEIGHT/.test(flag) && !flags.has('3D')) && !_warnedFlags.has('PIE:' + flag)) {
2816
+ _warnedFlags.add('PIE:' + flag);
2817
+ console.info(`[ixmaps-gl] type flag "${flag}" on a PIE chart recognized, not drawn yet — the pie is drawn flat and plain`);
2818
+ }
2819
+ });
2820
+ // AGGREGATE / CATEGORICAL pies (_isAggregatedPie): what is not ported
2821
+ if (flags.has('AGGREGATE') || flags.has('CATEGORICAL')) {
2822
+ flags.forEach(flag => {
2823
+ const inert = PIE_AGGREGATE_INERT_FLAGS.find(re => re.test(flag));
2824
+ if (inert && !_warnedFlags.has('PIEAGG:' + flag)) {
2825
+ _warnedFlags.add('PIEAGG:' + flag);
2826
+ console.info(`[ixmaps-gl] type flag "${flag}" on an aggregated PIE chart recognized, not ported yet — ignored`);
2827
+ }
2828
+ });
2829
+ }
2830
+ }
2831
+
2832
+ // BAR variants _buildBarLayers doesn't draw (the bars are drawn plain)
2833
+ if (isBarChart(flags)) {
2834
+ flags.forEach(flag => {
2835
+ if (BAR_INERT_FLAGS.some(re => re.test(flag)) && !_warnedFlags.has('BAR:' + flag)) {
2836
+ _warnedFlags.add('BAR:' + flag);
2837
+ console.info(`[ixmaps-gl] type flag "${flag}" on a BAR chart recognized, not ported yet — the bars are drawn plain`);
2838
+ }
2839
+ });
2840
+ }
2841
+
2842
+ // VECTOR / BEZIER modifiers _buildVectorLayers doesn't draw
2843
+ if (isVectorChart(flags)) {
2844
+ flags.forEach(flag => {
2845
+ const why = VECTOR_INERT_FLAGS[flag];
2846
+ if (why && !_warnedFlags.has('VECTOR:' + flag)) {
2847
+ _warnedFlags.add('VECTOR:' + flag);
2848
+ console.info(`[ixmaps-gl] type flag "${flag}" on a VECTOR/BEZIER chart recognized, ${why}`);
2849
+ }
2850
+ });
2851
+ }
2545
2852
 
2546
2853
  const rawBinding = Object.assign({}, def.binding);
2547
2854
  const targets = resolveBindingTargets(style, rawBinding, def);
@@ -2603,6 +2910,9 @@
2603
2910
  opacity: 1; transition: opacity 0.4s ease;
2604
2911
  }
2605
2912
  .ixmaps-splash.ixmaps-splash-hidden { opacity: 0; pointer-events: none; }
2913
+ .ixmaps-splash.ixmaps-splash-dark { background: #1a1a1a; }
2914
+ .ixmaps-splash-dark .ixmaps-splash-word { color: #e8e8e8; }
2915
+ .ixmaps-splash-dark .ixmaps-splash-text { color: #9a9a9a; }
2606
2916
  .ixmaps-splash-mark { position: relative; width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; }
2607
2917
  .ixmaps-splash-ping {
2608
2918
  position: absolute; width: 16px; height: 16px; border-radius: 50%;
@@ -2638,15 +2948,21 @@
2638
2948
  // flat's Dictionary.getLocalText (mapscript.js 5631-5648) over the page's
2639
2949
  // .local(text, translation) pairs: the whole text, else word by word
2640
2950
  // (a word's entry is written in quotes, 'word')
2951
+ // plus the page-wide entries of ixmaps.setLocalString (flat's
2952
+ // Api.setLocalString → Dictionary.replace, mapapi.js 123), read at call
2953
+ // time so strings set after the map is built count; the later
2954
+ // setLocalString wins over a .local() pair of the same text
2955
+ const _localStrings = Object.create(null);
2641
2956
  function makeLocalText(locals) {
2642
2957
  const dict = {};
2643
2958
  (locals || []).forEach(a => {
2644
2959
  if (a && typeof a[0] === 'object' && a[0]) Object.assign(dict, a[0]);
2645
2960
  else if (a && a.length >= 2) dict[a[0]] = a[1];
2646
2961
  });
2962
+ const entry = k => (_localStrings[k] !== undefined ? _localStrings[k] : dict[k]);
2647
2963
  return text => {
2648
- if (dict[text] !== undefined) return String(dict[text]);
2649
- return String(text).split(' ').map(w => (dict["'" + w + "'"] !== undefined ? String(dict["'" + w + "'"]) : w)).join(' ');
2964
+ if (entry(text) !== undefined) return String(entry(text));
2965
+ return String(text).split(' ').map(w => (entry("'" + w + "'") !== undefined ? String(entry("'" + w + "'")) : w)).join(' ');
2650
2966
  };
2651
2967
  }
2652
2968
  // make the map container the positioning context of what the engine
@@ -2659,7 +2975,7 @@
2659
2975
  function anchorInside(el) {
2660
2976
  if (el && getComputedStyle(el).position === 'static') el.style.position = 'relative';
2661
2977
  }
2662
- function showSplash(el, text) {
2978
+ function showSplash(el, text, dark) {
2663
2979
  ensureSplashStyle();
2664
2980
  // Checks the actual RENDERED position, not el.style (the inline
2665
2981
  // attribute only) — every example page's own #map_div already sets
@@ -2674,7 +2990,7 @@
2674
2990
  // this at all.
2675
2991
  anchorInside(el);
2676
2992
  const splash = document.createElement('div');
2677
- splash.className = 'ixmaps-splash';
2993
+ splash.className = 'ixmaps-splash' + (dark ? ' ixmaps-splash-dark' : '');
2678
2994
  splash.innerHTML = '<div class="ixmaps-splash-mark"><span class="ixmaps-splash-ping"></span><span class="ixmaps-splash-dot"></span></div>' +
2679
2995
  '<div class="ixmaps-splash-word">ixmaps<sup>gl</sup></div>' +
2680
2996
  '<div class="ixmaps-splash-text"></div>';
@@ -3540,7 +3856,9 @@
3540
3856
  // aggregation" reading (style.valuefield, falling back to
3541
3857
  // the bound size field); anything else (plain CATEGORICAL,
3542
3858
  // no SUM) falls back to a per-category record COUNT.
3543
- const useSum = flatFlag(rt.flags, 'SUM') && rt.style.valuefield;
3859
+ // a VECTOR / BEZIER flow legend sums its size field (flat's rows
3860
+ // show the flows' summed sizes, measured on the twin page)
3861
+ const useSum = flatFlag(rt.flags, 'SUM') && (rt.style.valuefield || ((rt._isVectorChart() || rt._isAggregatedPie()) && rt.binding.size));
3544
3862
  const valueField = rt.style.valuefield || rt.binding.size;
3545
3863
  // legendunits wins over the theme's general-purpose units
3546
3864
  // (used elsewhere for tooltips, e.g. _renderItemChartHtml) —
@@ -3549,13 +3867,18 @@
3549
3867
  // space injected), matching how style.units/legendunits are
3550
3868
  // themselves authored with their own leading space (e.g.
3551
3869
  // " MW") in real pages.
3552
- const legendUnit = rt.style.legendunits || rt.style.units || '';
3870
+ // (a VECTOR / BEZIER or aggregated PIE legend: flat's szUnit, the
3871
+ // units after a space unless they start with ".", measured on the
3872
+ // twin pages)
3873
+ const flatUnitLegend = rt._isVectorChart() || rt._isAggregatedPie();
3874
+ const rawLegendUnit = rt.style.legendunits || rt.style.units || '';
3875
+ const legendUnit = flatUnitLegend && rawLegendUnit && rawLegendUnit[0] !== '.' && rawLegendUnit[0] !== ' ' ? ' ' + rawLegendUnit : rawLegendUnit;
3553
3876
  // a per-item chart over several value fields: the row values as
3554
3877
  // flat's legend prints them (legend.js 610, 810: __formatValue
3555
3878
  // with valuedecimals, else 2, "BLANK" — whole numbers without
3556
3879
  // decimals), a colorscheme "none" part as an outline in linecolor
3557
- const multiFieldLegend = !!rt._multiFields && rt._isMultiFieldSequence();
3558
- const legendTotalText = v => (multiFieldLegend
3880
+ const multiFieldLegend = rt._isMultiFieldChart();
3881
+ const legendTotalText = v => ((multiFieldLegend || flatUnitLegend)
3559
3882
  ? flatFormatValue(v, legendDecimals(rt.style), 'BLANK')
3560
3883
  : rt._formatTooltipValue(v));
3561
3884
  const outlineRgb = rt.style.linecolor && styleLineColor(rt.style.linecolor) !== 'none' ? hexOrNamedToRgb(styleLineColor(rt.style.linecolor)) : [128, 128, 128];
@@ -3605,7 +3928,7 @@
3605
3928
  // a per-item chart over several value fields: a row per field,
3606
3929
  // the field's sum over the visible items (flat's NYC status
3607
3930
  // legend: bikes / empty docks / e-bikes, biggest sum first)
3608
- if (rt._multiFields && rt._isMultiFieldSequence()) {
3931
+ if (rt._isMultiFieldChart()) {
3609
3932
  (rt._activeFeatures || rt.features).forEach(f => {
3610
3933
  const c = f.geometry && f.geometry.coordinates;
3611
3934
  if (!c || typeof c[0] !== 'number') return;
@@ -4115,6 +4438,11 @@
4115
4438
  await null;
4116
4439
  const el = document.getElementById(this.containerId);
4117
4440
  if (!el) throw new Error(`[ixmaps-gl] container #${this.containerId} not found`);
4441
+ // flat's init replaces the container's content with its own map page
4442
+ // (mappage.html via innerHTML), so a page's placeholder inside the
4443
+ // map div ("connecting to Internet ...", the fuel-price page) goes
4444
+ // away; here it would stay on top of the map — remove it the same way
4445
+ while (el.firstChild) el.removeChild(el.firstChild);
4118
4446
  // GL-PORT COMPAT: ixmaps.Map(id, {width, height}) — flat sizes its
4119
4447
  // inner map div with these and gives PIXEL values to the page's own
4120
4448
  // container too (htmlgui_flat.js:866-876, parent().css(...)); here
@@ -4125,6 +4453,11 @@
4125
4453
  const v = this.mapOptions && this.mapOptions[dim];
4126
4454
  if (typeof v === 'string' && /^\s*\d+(\.\d+)?\s*px\s*$/i.test(v)) el.style[dim] = v.trim();
4127
4455
  else if (typeof v === 'number' && v > 0) el.style[dim] = v + 'px';
4456
+ // a percentage stays the page's CSS — unless that CSS left the
4457
+ // container collapsed on this side (e.g. an empty flex item, the
4458
+ // UNHCR page): flat's own pixel-sized map div fills it there
4459
+ else if (typeof v === 'string' && /^\s*\d+(\.\d+)?\s*%\s*$/.test(v)
4460
+ && !(dim === 'width' ? el.clientWidth : el.clientHeight)) el.style[dim] = v.trim();
4128
4461
  }
4129
4462
  // without width/height flat's map is "fullscreen" (htmlgui_flat.js
4130
4463
  // 847-876): it fills the window, whatever the page's CSS gives the
@@ -4140,15 +4473,29 @@
4140
4473
  // overrides the default message, .options({splash:false}) skips it
4141
4474
  // entirely (e.g. for a map embedded somewhere a full-cover overlay
4142
4475
  // would be wrong, or one that's expected to load near-instantly).
4476
+ // dark on a dark map (no basemap style yet: flatLegendLook's name/
4477
+ // color rule, or a basemap faded to <= 0.5 over a dark page, as
4478
+ // effectiveMapDark) — a light sheet flashed between a dark page and
4479
+ // a dark map (the UNHCR page)
4480
+ const splashOpacity = parseFloat(this._engineOptions.basemapopacity);
4481
+ const splashLegendBg = this.mapOptions.legendBackground || this.mapOptions.legendbackground;
4482
+ const splashDark = flatLegendLook(this.mapOptions.mapType, isNaN(splashOpacity) ? 1 : splashOpacity, splashLegendBg).dark
4483
+ || (splashOpacity <= 0.5 && flatIsDarkColor('rgb(' + pageBackgroundColorOf(el).map(Math.round).join(',') + ')'));
4143
4484
  const splash = this._engineOptions.splash === false ? null
4144
- : showSplash(el, this._engineOptions.splashText || 'loading…');
4485
+ : showSplash(el, this._engineOptions.splashText || 'loading…', splashDark);
4145
4486
 
4146
4487
  // ixmaps.getBoundingBox()/setTitle() for brokers called below, before
4147
4488
  // the MapLibre map exists (same default view as the map gets)
4148
4489
  _titleHost = el;
4149
- const initialZoom = this._viewZoom != null && this._viewZoom !== '' ? flatToMapLibreZoom(Number(this._viewZoom)) : 8;
4490
+ // a flat SVG map in another projection (equalearth.svg): its view
4491
+ // framing and map scale (see FLAT_SVG_PROJECTIONS)
4492
+ const svgProjection = flatSvgProjectionOf(this.mapOptions.map);
4493
+ if (svgProjection) flatSvgProjectionByOptions.set(this._engineOptions, svgProjection);
4494
+ const [vLat, vLng] = this._viewCenter || [45.5, 9.2];
4495
+ const initialZoom = this._viewZoom != null && this._viewZoom !== ''
4496
+ ? flatViewToMapLibreZoom(Number(this._viewZoom), Number(vLat), Number(vLng), el.clientWidth, el.clientHeight, svgProjection)
4497
+ : 8;
4150
4498
  {
4151
- const [vLat, vLng] = this._viewCenter || [45.5, 9.2];
4152
4499
  _boundsSource = () => viewBounds(Number(vLat), Number(vLng), initialZoom, el.clientWidth, el.clientHeight);
4153
4500
  }
4154
4501
 
@@ -4267,7 +4614,7 @@
4267
4614
  style: mapTypeColor ? buildBlankBackgroundStyle(mapTypeColor)
4268
4615
  : resolveBasemapStyleUrl(this.mapOptions.mapType),
4269
4616
  center: [lon, lat],
4270
- zoom: this._viewZoom != null && this._viewZoom !== '' ? flatToMapLibreZoom(Number(this._viewZoom)) : 8,
4617
+ zoom: initialZoom,
4271
4618
  // MapLibre's own AttributionControl (bottom right) keeps the
4272
4619
  // basemap's required CARTO/OpenStreetMap credit; the page's own
4273
4620
  // .attribution() goes bottom left, as flat shows it
@@ -4291,6 +4638,7 @@
4291
4638
  // world once, at the cost of that zoom limit.
4292
4639
  renderWorldCopies: this._engineOptions.worldcopies !== false
4293
4640
  });
4641
+ engineOptionsByMap.set(map, this._engineOptions);
4294
4642
  // the live-SVG pane USER charts render into (see chartHostFor) —
4295
4643
  // registered here, keyed by this map's options object, so every
4296
4644
  // runtime (including later loadProject/map.layer ones) finds it
@@ -4338,7 +4686,7 @@
4338
4686
  if (typeof hook !== 'function' || !prev) return;
4339
4687
  const p = map.project(c), q = map.project(prev.c);
4340
4688
  if (z === prev.z && Math.abs(p.x - q.x) <= 10 && Math.abs(p.y - q.y) <= 10) return;
4341
- try { hook.call(global.ixmaps, mapLibreToFlatZoom(z)); } catch (e) { console.error('[ixmaps-gl] htmlgui_onZoomAndPan:', e); }
4689
+ try { hook.call(global.ixmaps, flatViewZoomOf(map, z)); } catch (e) { console.error('[ixmaps-gl] htmlgui_onZoomAndPan:', e); }
4342
4690
  });
4343
4691
  notifyZoomAndPanMidGesture = () => {
4344
4692
  const hook = pageIxmaps() && pageIxmaps().htmlgui_onZoomAndPan;
@@ -4346,13 +4694,13 @@
4346
4694
  const z = map.getZoom();
4347
4695
  if (z === last.z) return; // a pan alone waits for 'moveend' (and its 10 px rule)
4348
4696
  last = { z, c: map.getCenter() };
4349
- try { hook.call(global.ixmaps, mapLibreToFlatZoom(z)); } catch (e) { console.error('[ixmaps-gl] htmlgui_onZoomAndPan:', e); }
4697
+ try { hook.call(global.ixmaps, flatViewZoomOf(map, z)); } catch (e) { console.error('[ixmaps-gl] htmlgui_onZoomAndPan:', e); }
4350
4698
  };
4351
4699
  // flat calls it on the first draw too (its old zoom is unset then)
4352
4700
  map.once('load', () => {
4353
4701
  last = { z: map.getZoom(), c: map.getCenter() };
4354
4702
  const hook = pageIxmaps() && pageIxmaps().htmlgui_onZoomAndPan;
4355
- if (typeof hook === 'function') { try { hook.call(global.ixmaps, mapLibreToFlatZoom(last.z)); } catch (e) { console.error('[ixmaps-gl] htmlgui_onZoomAndPan:', e); } }
4703
+ if (typeof hook === 'function') { try { hook.call(global.ixmaps, flatViewZoomOf(map, last.z)); } catch (e) { console.error('[ixmaps-gl] htmlgui_onZoomAndPan:', e); } }
4356
4704
  });
4357
4705
  }
4358
4706
 
@@ -4380,10 +4728,14 @@
4380
4728
  // hover/click tooltip lookup keeps working across a rotation.
4381
4729
  // Non-atlas layers (ix-dot-/ix-features-/ix-choropleth-) never
4382
4730
  // carry the suffix; stripping a pattern that isn't there is a
4383
- // no-op.
4384
- const base = layerId.replace(/-g\d+$/, '');
4731
+ // no-op. A theme's single circles (ix-bubbles-<name>-circles-gN,
4732
+ // a ScatterplotLayer) route like its bubbles.
4733
+ const base = layerId.replace(/-g\d+$/, '').replace(/-circles$/, '').replace(/^(ix-vector-heads-.*)-n\d+$/, '$1');
4385
4734
  return runtimes.find(r => {
4386
4735
  if (base === `ix-bubbles-${r.name}`) return isSymbolChart(r.flags);
4736
+ if (base === `ix-pie-${r.name}`) return r._isPieChart();
4737
+ if (base === `ix-bar-${r.name}`) return r._isBarChart();
4738
+ if (base === `ix-vector-${r.name}` || base === `ix-vector-heads-${r.name}`) return r._isVectorChart();
4387
4739
  if (base === `ix-dot-${r.name}`) return r.flags.has('DOT');
4388
4740
  if (base === `ix-choropleth-${r.name}`) return r.flags.has('CHOROPLETH');
4389
4741
  if (base === `ix-features-${r.name}`) return r.flags.has('FEATURE') || r.flags.has('FEATURES');
@@ -4540,7 +4892,7 @@
4540
4892
  resize: () => { map.resize(); return engineApi; },
4541
4893
  // broker calls in flight (themes whose data comes by name)
4542
4894
  pendingLoads: () => pendingLoads,
4543
- getZoom: () => mapLibreToFlatZoom(map.getZoom()),
4895
+ getZoom: () => flatViewZoomOf(map),
4544
4896
  require: (url) => { loadRequiredScripts([url]); return engineApi; },
4545
4897
  // flat's theme-level calls by theme id (style.name / meta.name, else
4546
4898
  // the layer name): add(theme, flag) — "replace" swaps a theme of the
@@ -4813,7 +5165,7 @@
4813
5165
  const z = isOpts ? latlonOrOpts.zoom : zoom;
4814
5166
  if (c) {
4815
5167
  const lngLat = Array.isArray(c) ? [c[1], c[0]] : [c.lng, c.lat];
4816
- jumpLive(Object.assign({ center: lngLat }, z != null ? { zoom: flatToMapLibreZoom(Number(z)) } : {}));
5168
+ jumpLive(Object.assign({ center: lngLat }, z != null ? { zoom: mapLibreZoomOfFlatView(map, Number(z), lngLat[1], lngLat[0]) } : {}));
4817
5169
  }
4818
5170
  return engineApi;
4819
5171
  },
@@ -5220,6 +5572,12 @@
5220
5572
 
5221
5573
  function applyProjectMap(m, f, report) {
5222
5574
  const svg = String(m.map || '');
5575
+ // its view and map scale follow the project's map file, as on flat
5576
+ if (svg) {
5577
+ const svgProjection = flatSvgProjectionOf(svg);
5578
+ if (svgProjection) flatSvgProjectionByOptions.set(builder._engineOptions, svgProjection);
5579
+ else flatSvgProjectionByOptions.delete(builder._engineOptions);
5580
+ }
5223
5581
  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`);
5224
5582
  if (typeof map.setProjection === 'function') {
5225
5583
  try {
@@ -5240,8 +5598,8 @@
5240
5598
  const c = m.center || {};
5241
5599
  const lat = Number(c.lat), lng = Number(c.lng);
5242
5600
  const z = m.zoom != null && m.zoom !== '' ? Number(m.zoom) : NaN;
5243
- if (Number.isFinite(lat) && Number.isFinite(lng)) jumpLive(Object.assign({ center: [lng, lat] }, Number.isFinite(z) ? { zoom: flatToMapLibreZoom(z) } : {}));
5244
- else if (Number.isFinite(z)) jumpLive({ zoom: flatToMapLibreZoom(z) });
5601
+ if (Number.isFinite(lat) && Number.isFinite(lng)) jumpLive(Object.assign({ center: [lng, lat] }, Number.isFinite(z) ? { zoom: mapLibreZoomOfFlatView(map, z, lat, lng) } : {}));
5602
+ else if (Number.isFinite(z)) jumpLive({ zoom: mapLibreZoomOfFlatView(map, z, map.getCenter().lat, map.getCenter().lng) });
5245
5603
  }
5246
5604
  // a project can't authorize its own scripts — trustedscripts is the page's
5247
5605
  if (m.options && typeof m.options === 'object') {
@@ -5915,6 +6273,110 @@
5915
6273
  const FLAT_ZOOM_OFFSET = 1;
5916
6274
  const flatToMapLibreZoom = z => z - FLAT_ZOOM_OFFSET;
5917
6275
  const mapLibreToFlatZoom = z => z + FLAT_ZOOM_OFFSET;
6276
+
6277
+ // flat's world SVG maps in a projection other than Mercator
6278
+ // (map:".../generic/equalearth.svg"), which this engine draws in Web
6279
+ // Mercator. Flat syncs such a map to its Leaflet view by fitting the
6280
+ // Leaflet bounds' SW/NE corners, projected, into the map
6281
+ // (htmlgui_sync.js setBoundsLatLon → mapscript2.js doSetMapToGeoBounds /
6282
+ // doZoomMapToArea): both axes fit (min) while the bounds span ≥ 180° of
6283
+ // longitude, else the x axis fits the width on its own (mapapi.js
6284
+ // doSetMapToGeoBounds drops fPreserveMapRatio) — so the same view zoom
6285
+ // frames these maps differently from Mercator (Equal Earth at 2 on
6286
+ // 1024×748: 1.55× the Mercator scale; at 4: 1.09×).
6287
+ // `project` is flat's own forward projection (mapscript.js
6288
+ // _LLtoEqualEarth, _LLtoWinkelTripel — Winkel's cos(50.467) in radians
6289
+ // included), in units of the earth radius.
6290
+ // `scaleConstant`: flat's map scale (nTrueMapScale · nZoomScale, the
6291
+ // scale its dynamic object scaling and gates use) × the screen pixels
6292
+ // per degree of longitude on the equator — constant per map file,
6293
+ // measured on flat (2026-10-09, view zooms 2-5): Equal Earth 170.1e6,
6294
+ // Winkel 195.45e6; for flat's Mercator map 442 913 385 · 256 / 360 =
6295
+ // 314.96e6 (FLAT_OBJECT_SCALE_CONSTANT). These SVG maps carry the
6296
+ // Mercator map's mapscale (177 165 354), so at the same on-screen size
6297
+ // flat calls their scale 0.54× (0.62×) the Mercator one, and its
6298
+ // symbols are drawn larger by that ratio^(1/dynamicScalePow). Framing
6299
+ // matches flat's pixels per degree of longitude at the view's center;
6300
+ // the scale gates (scaleDenominatorAt) still use the Mercator scale.
6301
+ const FLAT_MERCATOR_SCALE_CONSTANT = FLAT_OBJECT_SCALE_CONSTANT * 256 / 360;
6302
+ const FLAT_SVG_PROJECTIONS = {
6303
+ equalearth: {
6304
+ svg: 'maps/svg/maps/generic/equalearth.svg',
6305
+ scaleConstant: 170.1e6,
6306
+ project: (lat, lon) => {
6307
+ const M = Math.sqrt(3) / 2, A1 = 1.340264, A2 = -0.081106, A3 = 0.000893, A4 = 0.003796;
6308
+ const l = Math.asin(M * Math.sin(lat * Math.PI / 180)), l2 = l * l, l6 = l2 * l2 * l2;
6309
+ 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))];
6310
+ }
6311
+ },
6312
+ winkel: {
6313
+ svg: 'maps/svg/maps/generic/winkel.svg',
6314
+ scaleConstant: 195.45e6,
6315
+ project: (lat, lon) => {
6316
+ const p = lat * Math.PI / 180, q = Math.max(-180, Math.min(180, lon)) * Math.PI / 360;
6317
+ const D = Math.acos(Math.cos(p) * Math.cos(q));
6318
+ let x1 = 0, y1 = 0;
6319
+ if (D !== 0) { const C = Math.sin(p) / Math.sin(D); x1 = (q < 0 ? -2 : 2) * D * Math.sqrt(1 - C * C); y1 = D * C; }
6320
+ return [0.5 * (x1 + 2 * q * Math.cos(50.467)), 0.5 * (y1 + p)];
6321
+ }
6322
+ }
6323
+ };
6324
+ // the flat SVG map projection of a map:/project map URL (generic file
6325
+ // name), null for Mercator and every map drawn as is (orthographic → globe)
6326
+ function flatSvgProjectionOf(svgUrl) {
6327
+ const m = /(?:^|\/)(equalearth|winkel)\.svgz?(?:[?#].*)?$/i.exec(String(svgUrl || ''));
6328
+ return m ? FLAT_SVG_PROJECTIONS[m[1].toLowerCase()] : null;
6329
+ }
6330
+ // the flat SVG projection a map's options object belongs to (set by the
6331
+ // map's init and loadProject; the runtimes only see the options object)
6332
+ const flatSvgProjectionByOptions = new WeakMap();
6333
+ // flat SVG map scale ÷ the Mercator one at the same on-screen size (1
6334
+ // without a flat SVG projection)
6335
+ function flatMapScaleRatio(mapOptions) {
6336
+ const proj = mapOptions && typeof mapOptions === 'object' ? flatSvgProjectionByOptions.get(mapOptions) : null;
6337
+ return proj ? proj.scaleConstant / FLAT_MERCATOR_SCALE_CONSTANT : 1;
6338
+ }
6339
+ // a flat view zoom → the MapLibre zoom showing what flat shows at it:
6340
+ // under a flat SVG projection the same screen pixels per degree of
6341
+ // longitude at the center latitude as flat's fitted map (see
6342
+ // FLAT_SVG_PROJECTIONS), else flatToMapLibreZoom
6343
+ function flatViewToMapLibreZoom(flatZoom, lat, lng, width, height, proj) {
6344
+ if (!proj || !(width > 0) || !(height > 0) || !Number.isFinite(lat) || !Number.isFinite(lng)) return flatToMapLibreZoom(flatZoom);
6345
+ // flat's Leaflet bounds (256 px tiles) at this zoom
6346
+ const [sw, ne] = viewBounds(lat, lng, flatToMapLibreZoom(flatZoom), width, height);
6347
+ const a = proj.project(sw.lat, sw.lng), b = proj.project(ne.lat, ne.lng);
6348
+ const kx = width / Math.abs(b[0] - a[0]), ky = height / Math.abs(b[1] - a[1]);
6349
+ const k = ne.lng - sw.lng < 180 ? kx : Math.min(kx, ky);
6350
+ const pxPerDegree = k * (proj.project(lat, lng + 0.5)[0] - proj.project(lat, lng - 0.5)[0]);
6351
+ return Math.log2(pxPerDegree * 360 / 512);
6352
+ }
6353
+ // the inverse: the flat view zoom of a MapLibre zoom (bisection — the
6354
+ // fit above is monotonic in the zoom)
6355
+ function mapLibreToFlatViewZoom(mapLibreZoom, lat, lng, width, height, proj) {
6356
+ if (!proj || !(width > 0) || !(height > 0) || !Number.isFinite(lat) || !Number.isFinite(lng)) return mapLibreToFlatZoom(mapLibreZoom);
6357
+ let lo = mapLibreToFlatZoom(mapLibreZoom) - 4, hi = mapLibreToFlatZoom(mapLibreZoom) + 4;
6358
+ for (let i = 0; i < 40; i++) {
6359
+ const mid = (lo + hi) / 2;
6360
+ if (flatViewToMapLibreZoom(mid, lat, lng, width, height, proj) < mapLibreZoom) lo = mid; else hi = mid;
6361
+ }
6362
+ return (lo + hi) / 2;
6363
+ }
6364
+ // a live MapLibre map's options object (its runtimes' mapOptions), for
6365
+ // the conversions below
6366
+ const engineOptionsByMap = new WeakMap();
6367
+ function mapSvgProjection(map) {
6368
+ const options = engineOptionsByMap.get(map);
6369
+ return (options && flatSvgProjectionByOptions.get(options)) || null;
6370
+ }
6371
+ // the flat view zoom a live map shows / the MapLibre zoom of a flat view
6372
+ function flatViewZoomOf(map, mapLibreZoom) {
6373
+ const c = map.getCenter(), el = map.getContainer();
6374
+ return mapLibreToFlatViewZoom(mapLibreZoom == null ? map.getZoom() : mapLibreZoom, c.lat, c.lng, el.clientWidth, el.clientHeight, mapSvgProjection(map));
6375
+ }
6376
+ function mapLibreZoomOfFlatView(map, flatZoom, lat, lng) {
6377
+ const el = map.getContainer();
6378
+ return flatViewToMapLibreZoom(flatZoom, lat, lng, el.clientWidth, el.clientHeight, mapSvgProjection(map));
6379
+ }
5918
6380
  // map scale denominator at a MapLibre zoom (null → the default reference):
5919
6381
  // flat's own map scale, the one its scale bar shows and every scale gate
5920
6382
  // (featureupper, chartupper, boxupper, valueupper, aggregation brackets)
@@ -5939,9 +6401,20 @@
5939
6401
  // (e.g. "fixed") means they don't — same pixel size at every zoom.
5940
6402
  // the zoom at which flat's object scale equals normalSizeScale (symbols
5941
6403
  // at their normal size) — see FLAT_OBJECT_SCALE_CONSTANT
6404
+ // (under a flat SVG projection, flat's scale at that zoom is
6405
+ // flatMapScaleRatio × the Mercator one)
5942
6406
  function resolveZoomReference(mapOptions) {
5943
- const scaleDenominator = parseFloat(mapOptions && mapOptions.normalSizeScale) || FLAT_DEFAULT_NORMAL_SIZE_SCALE;
5944
- return flatToMapLibreZoom(Math.log2(FLAT_OBJECT_SCALE_CONSTANT / scaleDenominator));
6407
+ const scaleDenominator = parseFloat(normalSizeScaleOf(mapOptions)) || FLAT_DEFAULT_NORMAL_SIZE_SCALE;
6408
+ return flatToMapLibreZoom(Math.log2(FLAT_OBJECT_SCALE_CONSTANT * flatMapScaleRatio(mapOptions) / scaleDenominator));
6409
+ }
6410
+ // .options({normalSizeScale}) under any spelling: flat matches the key
6411
+ // case-insensitively (htmlgui.js, /normalSizeScale/i → setScaleParam;
6412
+ // the last matching key wins), e.g. "normalsizescale"
6413
+ function normalSizeScaleOf(mapOptions) {
6414
+ if (!mapOptions || typeof mapOptions !== 'object') return undefined;
6415
+ let value;
6416
+ for (const key of Object.keys(mapOptions)) if (/normalSizeScale/i.test(key)) value = mapOptions[key];
6417
+ return value;
5945
6418
  }
5946
6419
 
5947
6420
  // .options({dynamicScalePow}) — the REAL parameter name (confirmed in
@@ -6014,7 +6487,7 @@
6014
6487
  // scale (resolveZoomReference); flat's real rule (maptheme.js 13408,
6015
6488
  // 0.3 + 0.7 / ln(nZoom / dx)) is not ported yet
6016
6489
  function autoOpacityZoomReference(mapOptions) {
6017
- const scaleDenominator = parseFloat(mapOptions && mapOptions.normalSizeScale);
6490
+ const scaleDenominator = parseFloat(normalSizeScaleOf(mapOptions));
6018
6491
  if (!scaleDenominator) return flatToMapLibreZoom(DEFAULT_ZOOM_REFERENCE);
6019
6492
  return flatToMapLibreZoom(Math.log2(WEBMERCATOR_SCALE_CONSTANT / scaleDenominator));
6020
6493
  }
@@ -6349,6 +6822,12 @@
6349
6822
 
6350
6823
  // the themes drawn by the symbol chart pipeline (_buildChartLayers): flat's
6351
6824
  // BUBBLE/SQUARE/LABEL branch (maptheme.js 20647-20652), symbols, user charts
6825
+ // CHART|VECTOR / CHART|BEZIER — flat's flow lines (maptheme.js chartMap
6826
+ // 18148-18435, ahead of drawChart: they take precedence over PIE,
6827
+ // BUBBLE, SYMBOL ...; DOT's branch comes first)
6828
+ function isVectorChart(flags) {
6829
+ return flags.has('CHART') && (flags.has('VECTOR') || flags.has('BEZIER')) && !flags.has('DOT');
6830
+ }
6352
6831
  function isSymbolChart(flags) {
6353
6832
  return flags.has('CHART') && (flags.has('SYMBOL') || flags.has('USER') || flags.has('LABEL'));
6354
6833
  }
@@ -6384,6 +6863,17 @@
6384
6863
  // the item's radius, SYMBOL: the theme's normal radius)
6385
6864
  // So "left" puts the chart's left edge o to the right of the point (and
6386
6865
  // drops offsetx), "2left" one more h. Returns deck.gl's pixel offset.
6866
+ // flat's maxcharts (maptheme.js 16879-16885): chartMap sorts the charts
6867
+ // ascending (the biggest drawn last, on top) and skips the first
6868
+ // nToDraw − nMaxCharts, so only the biggest maxcharts are drawn — for
6869
+ // every chart type (symbols, pies, bars, VECTOR/BEZIER). In place, on the
6870
+ // already sorted list of charts to draw
6871
+ function applyFlatMaxCharts(list, style) {
6872
+ const n = Math.round(styleNum(style && style.maxcharts)) || 0;
6873
+ if (n > 0 && list.length > n) list.splice(0, list.length - n);
6874
+ return list;
6875
+ }
6876
+
6387
6877
  function flatChartAlignOffset(style, g) {
6388
6878
  const num = v => (isNaN(styleNum(v)) ? 0 : styleNum(v));
6389
6879
  const sym = g.symbolScale || 1;
@@ -6426,6 +6916,53 @@
6426
6916
  }
6427
6917
  return [r, g, b].map(c => Math.min(255, Math.floor(c * f)));
6428
6918
  }
6919
+ // A BUBBLE chart's look in flat (maptheme.js, the BUBBLE branch), for a
6920
+ // circle of screen radius r in the class color rgb, `unit` = flat's
6921
+ // normalX(1) on screen (the object zoom factor × style.scale):
6922
+ // - stroke (20740-20780, 20895): style.linecolor, else ChartColors.lowColor
6923
+ // (the color × 0.7, __maptheme_getChartColors) — none with NOLINES or
6924
+ // linecolor "none"
6925
+ // - width (20898-20903): linewidth (0.1) · unit · √(r / normal radius),
6926
+ // the normal radius 15 · unit; OUTLINE: unit · min(1, r / normal radius)
6927
+ // - fill-opacity fillopacity (1); stroke-opacity (21050-21056) 1 with
6928
+ // OUTLINE or a linecolor, else 1 below fillopacity 0.5, 0.3 above
6929
+ // (_buildLabelChartLayers spells the same rule out for CHART|LABEL's box.)
6930
+ function flatBubbleLook(rgb, r, unit, style, flags) {
6931
+ const fillOpacity = styleNum(style.fillopacity) || 1;
6932
+ const outline = flatFlag(flags, 'OUTLINE');
6933
+ const lineColor = style.linecolor ? styleLineColor(style.linecolor) : null;
6934
+ const stroke = lineColor ? (lineColor === 'none' ? null : hexOrNamedToRgb(lineColor))
6935
+ : flatFlag(flags, 'NOLINES') ? null : flatDerivateRgb(rgb, 0.7);
6936
+ const ratio = r / (NORMAL_RADIUS_PX * unit);
6937
+ const width = outline ? unit * Math.min(1, ratio) : unit * (styleNum(style.linewidth) || 0.1) * Math.sqrt(ratio);
6938
+ const strokeOpacity = outline || lineColor ? 1 : (fillOpacity < 0.5 ? 1 : 0.3);
6939
+ return { fillOpacity, stroke, strokeOpacity, width };
6940
+ }
6941
+
6942
+ // flatBubbleLook for a cached icon raster `size` canvas px wide, drawn 2r
6943
+ // screen px wide: the stroke width in canvas px, in quarter-octave steps
6944
+ // (≤ 19 % apart, at most the icon's radius) — every distinct width is
6945
+ // another icon in deck.gl's shared atlas (see ICON_ATLAS_RESET_AFTER), and
6946
+ // a continuous width would make one per bubble. It hardly depends on the
6947
+ // zoom: r and the width both scale with the object zoom factor. `stroke`
6948
+ // only when it is style.linecolor (a packed icon derives each part's own,
6949
+ // see _buildBubbleIcon), noStroke for NOLINES / linecolor "none";
6950
+ // outlineRatio (OUTLINE): r / normal radius, also stepped, as 1 from 1 up
6951
+ // (where flat's OUTLINE width stops growing).
6952
+ function bubbleIconLook(r, unit, style, flags, size) {
6953
+ const look = flatBubbleLook([0, 0, 0], r, unit, style, flags);
6954
+ const step = v => (v > 0 ? Math.round(Math.pow(2, Math.round(Math.log2(v) * 4) / 4) * 1000) / 1000 : 0);
6955
+ const out = {
6956
+ fillOpacity: look.fillOpacity,
6957
+ strokeOpacity: look.strokeOpacity,
6958
+ stroke: style.linecolor ? look.stroke : null,
6959
+ noStroke: !look.stroke,
6960
+ width: r > 0 ? Math.min(size / 2, step(look.width * size / (2 * r))) : 0,
6961
+ };
6962
+ if (flatFlag(flags, 'OUTLINE')) out.outlineRatio = Math.min(1, step(r / (NORMAL_RADIUS_PX * unit)));
6963
+ return out;
6964
+ }
6965
+
6429
6966
  // ChartColors.textColor (colorscheme.js 104-116): darker on light colors
6430
6967
  function flatChartTextRgb(rgb) {
6431
6968
  return flatDerivateRgb(rgb, rgb[0] + rgb[1] + rgb[2] > 450 ? 0.6 : 3);
@@ -7045,6 +7582,94 @@
7045
7582
  return { offset, index, extent };
7046
7583
  }
7047
7584
 
7585
+ // Chart texts covered by charts drawn after them. Flat draws each chart
7586
+ // as one SVG group (symbol, then its value text) in the sorted draw
7587
+ // order, so a later chart's symbol paints over the texts of every chart
7588
+ // beneath it; here the texts are separate TextLayers above every icon.
7589
+ // This composites that occlusion on the CPU: for each text, the
7590
+ // transmittance Π(1 − α) of every LATER chart's symbol parts at a few
7591
+ // sample points of the text box, averaged — the fraction of the text
7592
+ // still showing through, its alpha. Partly covered texts fade instead
7593
+ // of being clipped (no per-pixel mask in deck.gl).
7594
+ // charts: in draw order, { x, y, parts: [{ dx, dy, r, shape, alpha }] }
7595
+ // in screen pixels, a part's alpha its opacity
7596
+ // texts: { order (index into charts), x, y, w, h }
7597
+ // Returns each text's transmittance, 0..1.
7598
+ const OCCLUSION_CELL_PX = 32;
7599
+ // below this a text is as good as hidden: left out of its TextLayer
7600
+ const CHART_TEXT_MIN_TRANSMITTANCE = 0.08;
7601
+ function chartTextTransmittance(charts, texts) {
7602
+ // the texts' sample points, then a grid over just their extent holding,
7603
+ // per cell, the charts (in draw order) whose reach overlaps it
7604
+ const SAMPLES = [[0, 0], [-0.35, -0.3], [0.35, -0.3], [-0.35, 0.3], [0.35, 0.3]];
7605
+ const nS = SAMPLES.length, px = new Float64Array(texts.length * nS), py = new Float64Array(texts.length * nS);
7606
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
7607
+ texts.forEach((t, i) => SAMPLES.forEach(([sx, sy], j) => {
7608
+ const x = px[i * nS + j] = t.x + sx * t.w, y = py[i * nS + j] = t.y + sy * t.h;
7609
+ if (x < minX) minX = x; if (x > maxX) maxX = x;
7610
+ if (y < minY) minY = y; if (y > maxY) maxY = y;
7611
+ }));
7612
+ const out = new Array(texts.length).fill(1);
7613
+ if (!texts.length || !charts.length) return out;
7614
+ const C = OCCLUSION_CELL_PX;
7615
+ const W = Math.floor((maxX - minX) / C) + 1, H = Math.floor((maxY - minY) / C) + 1;
7616
+ const reach = charts.map(c => {
7617
+ let ext = 0;
7618
+ for (const p of c.parts) ext = Math.max(ext, Math.sqrt(p.dx * p.dx + p.dy * p.dy) + p.r * Math.SQRT2);
7619
+ return ext;
7620
+ });
7621
+ const range = (c, ext) => [Math.max(0, Math.floor((c.x - ext - minX) / C)), Math.min(W - 1, Math.floor((c.x + ext - minX) / C)),
7622
+ Math.max(0, Math.floor((c.y - ext - minY) / C)), Math.min(H - 1, Math.floor((c.y + ext - minY) / C))];
7623
+ // two passes (count, fill) into one flat array: cells keep draw order
7624
+ const start = new Int32Array(W * H + 1);
7625
+ charts.forEach((c, k) => {
7626
+ const [x0, x1, y0, y1] = range(c, reach[k]);
7627
+ for (let gy = y0; gy <= y1; gy++) for (let gx = x0; gx <= x1; gx++) start[gy * W + gx + 1]++;
7628
+ });
7629
+ for (let i = 0; i < W * H; i++) start[i + 1] += start[i];
7630
+ const fill = start.slice(0, W * H), cells = new Int32Array(start[W * H]);
7631
+ charts.forEach((c, k) => {
7632
+ const [x0, x1, y0, y1] = range(c, reach[k]);
7633
+ for (let gy = y0; gy <= y1; gy++) for (let gx = x0; gx <= x1; gx++) cells[fill[gy * W + gx]++] = k;
7634
+ });
7635
+ // what of (x, y) still shows through chart c's parts
7636
+ const keepAt = (c, x, y) => {
7637
+ let keep = 1;
7638
+ for (const p of c.parts) {
7639
+ if (insideSymbolShape(p.shape, x - c.x - p.dx, y - c.y - p.dy, p.r)) keep *= 1 - p.alpha;
7640
+ }
7641
+ return keep;
7642
+ };
7643
+ texts.forEach((t, i) => {
7644
+ let sum = 0;
7645
+ for (let j = 0; j < nS; j++) {
7646
+ const x = px[i * nS + j], y = py[i * nS + j];
7647
+ const cell = Math.floor((y - minY) / C) * W + Math.floor((x - minX) / C);
7648
+ let keep = 1;
7649
+ // only the charts drawn after the text's own cover it
7650
+ for (let n = start[cell + 1] - 1; n >= start[cell] && cells[n] > t.order && keep > 0.005; n--) {
7651
+ const k = cells[n], c = charts[k], dx = x - c.x, dy = y - c.y;
7652
+ if (dx * dx + dy * dy < reach[k] * reach[k]) keep *= keepAt(c, x, y);
7653
+ }
7654
+ sum += keep;
7655
+ }
7656
+ out[i] = sum / nS;
7657
+ });
7658
+ return out;
7659
+ }
7660
+ // inside drawSymbolPath's shape of radius r, (dx, dy) from its center;
7661
+ // the shapes without their own test as the circle
7662
+ function insideSymbolShape(shape, dx, dy, r) {
7663
+ switch (shape) {
7664
+ case 'squarefull': return Math.abs(dx) <= r && Math.abs(dy) <= r;
7665
+ case 'square': return Math.abs(dx) <= r * Math.SQRT1_2 && Math.abs(dy) <= r * Math.SQRT1_2;
7666
+ case 'roundrect': return Math.abs(dx) <= r * 0.8 && Math.abs(dy) <= r * 0.8;
7667
+ case 'diamond': case 'carot': return Math.abs(dx) + Math.abs(dy) <= r;
7668
+ case 'empty': return false;
7669
+ default: return dx * dx + dy * dy <= r * r;
7670
+ }
7671
+ }
7672
+
7048
7673
  // A pixel offset [dx, dy] (y down) from lngLat at MapLibre zoom `zoom`,
7049
7674
  // as lng/lat (Web Mercator, 512 px world tiles) — for shapes too large
7050
7675
  // for an icon, drawn in map coordinates (rebuilt with every refresh).
@@ -7056,6 +7681,915 @@
7056
7681
  return [lngLat[0] + offset[0] * 360 / world, lat];
7057
7682
  }
7058
7683
 
7684
+ // CHART|BAR / BARS: which flat draws as a bar chart — drawChart's BAR
7685
+ // branch (maptheme.js 23283) comes after USER, PIE, WAFFLE, BUBBLE,
7686
+ // SYMBOL and BUFFER; flat tests /BAR/ as a substring (BARS too)
7687
+ function isBarChart(flags) {
7688
+ if (!flags.has('CHART') || isVectorChart(flags) || flags.has('DOT')) return false;
7689
+ const t = flags.typeString != null ? flags.typeString : [...flags].join('|');
7690
+ return /BAR/.test(t) && !/\bUSER\b|PIE|WAFFLE|BUBBLE|SYMBOL|BUFFER/.test(t);
7691
+ }
7692
+
7693
+ // One item's bar chart as real ixmaps-flat draws it — maptheme.js
7694
+ // drawChart's BAR branch (23283-24425) — in flat's normal units (u:
7695
+ // normalX(1), the chart size is 30 u; px = u · the object zoom factor ×
7696
+ // style.scale), y down, relative to the item's position. `values`: the
7697
+ // item's parts (nValuesA, after aggregation); `t`: the type string,
7698
+ // tested as flat does, by substring. `o`:
7699
+ // themeMin / themeMax the theme's nMin / nMax (the items' sizes with a
7700
+ // size field or CATEGORICAL, else their parts)
7701
+ // maxSize, itemSize the theme's nMaxSize, the item's nSize (size field)
7702
+ // sizeField a size field is bound
7703
+ // normalSizeValue, sizePow, rangeScale, valueScale, units,
7704
+ // valueDecimals (explicit, else undefined), noBreaks, gridX, nClasses
7705
+ // (partsA.length), classMax (partsA[k].max), nColors, fillOpacity,
7706
+ // lineWidth, lineColor (linecolor, else undefined), fadeNegative,
7707
+ // fadeValuePow, hideValues (valueupper/lower), means / medians / devs
7708
+ // (per part, OFFSETMEAN / OFFSETMEDIAN / DEVIATION)
7709
+ // measure(text, font) the text's width at that font size (u)
7710
+ // Returns null when flat draws no chart (no size, or a single 0 part),
7711
+ // else { bars, texts, lines, extent: [x0, y0, x1, y1] } —
7712
+ // bars: { part (the value's index), cls (its color index), base
7713
+ // [x, y] (the middle of the shaft's foot), dir (0 up, 1 right,
7714
+ // 2 down, 3 left), w2 (half width), sw (shaft half width), len
7715
+ // (shaft), band, head, value, textValue, fill: { opacity },
7716
+ // line: { rgb: 'color' (the bar's) | null (lineColor or black),
7717
+ // width, opacity }, opacity (FADEIN / FADENEGATIVE) }
7718
+ // texts: { text, x, y (the baseline's start), angle (0 reading right,
7719
+ // 90 up, -90 down), font, opacity, color ('value' — flat's
7720
+ // #798697 values color, 'red', 'contrast' — ChartColors
7721
+ // .textColor of the bar's color —, or a CSS color), bg (null,
7722
+ // 'white' — #fefeff —, or 'bar' — the bar's color at 0.5),
7723
+ // cls, part }
7724
+ // lines: STACKED leader lines { x0, y0, x1, y1 }
7725
+ // Divergences (not drawn here): 3D, VOLUME, COLUMN's cylinders, DOTTED,
7726
+ // SMALLARROW (drawn as ARROW), TRENDLINE, the XAXIS/AXIS labels, the
7727
+ // mean/median marks, CLIP / MORPH frames, showparts / clipparts, ZOOM /
7728
+ // NORMSIZE / MENUSIZE, INVERT, DIAGLEFT / DIAGRIGHT — bars are drawn
7729
+ // plain instead (see BAR_INERT_FLAGS).
7730
+ function flatBarLayout(values, t, o) {
7731
+ t = String(t || '');
7732
+ const has = re => re.test(t);
7733
+ const n = values.length;
7734
+ if (!n) return null;
7735
+ const parts = values.map(v => (isFinite(v) ? v : 0));
7736
+ const sum = parts.reduce((a, v) => a + v, 0);
7737
+ const stacked = has(/STACKED/), pointer = has(/POINTER/), horz = has(/HOR/);
7738
+ const sizeFlag = has(/SIZE/) && !has(/NORMSIZE/);
7739
+ const sizePow = o.sizePow || 2;
7740
+ const CHART = 30;
7741
+ let nMax = Math.max(o.themeMax, Math.abs(o.themeMin));
7742
+ let nRange = o.themeMax - o.themeMin;
7743
+ let maxSize = o.maxSize;
7744
+ if (o.normalSizeValue) nRange = maxSize = o.normalSizeValue;
7745
+ // flat's getChartSize (no chart when 0) and its "a single 0" rule
7746
+ if (sizeFlag && !has(/NOSIZE/)) {
7747
+ const s = o.sizeField ? o.itemSize : Math.abs(sum);
7748
+ const ref = o.sizeField ? maxSize : nRange;
7749
+ const size = has(/SIZELOG/) ? Math.max(1, 15 / Math.log(ref) * Math.log(s)) : 15 / Math.pow(ref, 1 / sizePow) * Math.pow(s, 1 / sizePow);
7750
+ if (isNaN(size) || size === 0) return null;
7751
+ }
7752
+ if (n === 1 && parts[0] === 0 && !has(/ZEROISVALUE|AUTOCOMPLETE|CATEGORICAL/)) return null;
7753
+
7754
+ // the sizer: the chart's size by the size field or by the values' sum
7755
+ let sizer = 1;
7756
+ if (sizeFlag && o.sizeField && o.itemSize != null) {
7757
+ sizer = has(/SIZELOG/) ? 1 / Math.max(1, Math.log(maxSize) * Math.log(o.itemSize))
7758
+ : Math.pow(o.itemSize, 1 / sizePow) / Math.pow(maxSize, 1 / sizePow);
7759
+ } else if (sizeFlag) {
7760
+ const s = pointer ? sum / n : sum;
7761
+ sizer = has(/SIZELOG/) ? 1 / Math.max(1, Math.log(nRange) * Math.log(Math.abs(s)))
7762
+ : Math.pow(Math.abs(s), 1 / sizePow) / Math.pow(nRange, 1 / sizePow);
7763
+ }
7764
+ if (sizer === 0 && sum !== 0) sizer = 1;
7765
+ const chartSize = CHART * sizer;
7766
+
7767
+ // bar width and value text size
7768
+ let W = chartSize / n * 0.66;
7769
+ if (stacked) {
7770
+ W = chartSize / 3;
7771
+ if (o.gridX && !has(/MULTI/)) W /= 2;
7772
+ }
7773
+ let f = W < 5 ? W * 4 / 5 : W;
7774
+ let textOpacity = 1;
7775
+ if (has(/COLUMN/)) W *= has(/THICK/) ? 2 : has(/THIN/) ? 1 : 1.5;
7776
+ if (has(/THIN/)) { W *= 0.6; f *= 0.75; }
7777
+ if (has(/THICK/)) { W *= 1.5; f *= 1.5; }
7778
+ if (stacked) f = 5;
7779
+ if (stacked && !has(/ZOOM/)) f *= sizer;
7780
+ const valueScale = o.valueScale || 1;
7781
+ f *= valueScale;
7782
+
7783
+ // the bar length per value unit (nStep)
7784
+ if (o.normalSizeValue && (!o.field100 || pointer)) nMax = o.normalSizeValue;
7785
+ let step0 = CHART / nMax;
7786
+ if (has(/OFFSETMEAN|OFFSETMEDIAN|DEVIATION/)) step0 = chartSize / 300;
7787
+ if (sizeFlag && has(/SEQUENCE/) && n > 1 && !o.sizeField && !o.field100) step0 = step0 * n / sizer;
7788
+ else if (sizeFlag && n > 1 && !has(/EXPAND/)) {
7789
+ if (o.field100 || pointer) step0 *= sizer;
7790
+ else step0 /= has(/SIZEP4/) ? sizer * sizer : sizer;
7791
+ }
7792
+ if (o.rangeScale) step0 *= o.rangeScale;
7793
+ if (has(/COMPRESSMAX/)) step0 /= 4;
7794
+ else if (has(/COMPRESSMORE/)) step0 /= 3;
7795
+ else if (has(/COMPRESS/)) step0 /= 2;
7796
+ else if (has(/EXPANDMAX/)) step0 *= 4;
7797
+ else if (has(/EXPANDMORE/)) step0 *= 3;
7798
+ else if (has(/EXPAND/)) step0 *= 2;
7799
+ if (pointer && !has(/NORMSIZE/)) step0 *= 1.2;
7800
+
7801
+ // the bar order: SORT descending (STACKED ascending), UP reversed
7802
+ let sorted = null;
7803
+ if (has(/\bSORT\b/)) {
7804
+ sorted = parts.map((v, i) => ({ i, v }));
7805
+ sorted.sort(stacked ? (a, b) => a.v - b.v : (a, b) => b.v - a.v);
7806
+ sorted = sorted.map(s => s.i);
7807
+ }
7808
+ const startI = has(/\bUP\b/) ? n - 1 : 0;
7809
+ const nC = has(/CENTER/) ? 2 : 1;
7810
+ const fmt = (v, d) => flatGroupedValue(v, d, o.noBreaks);
7811
+ const measure = o.measure || ((text, font) => text.length * font * 0.55);
7812
+
7813
+ const bars = [], texts = [], lines = [];
7814
+ let posX = 0, posY = 0, minNext = 0, barsDrawn = 0, step = step0;
7815
+ for (let i = 0; i < n; i++) {
7816
+ step = step0;
7817
+ let idx = Math.abs(startI - i);
7818
+ if (sorted) idx = sorted[idx];
7819
+ let v = parts[idx];
7820
+ let tv = v;
7821
+ if (has(/OFFSETMEAN|OFFSETMEDIAN/)) {
7822
+ const ref = has(/OFFSETMEDIAN/) ? (o.medians || [])[idx] : (o.means || [])[idx];
7823
+ v = 100 / ref * v - 100;
7824
+ v = isFinite(v) ? v : 0;
7825
+ tv = v;
7826
+ nMax = 100;
7827
+ } else if (has(/DEVIATION/)) {
7828
+ v = (v - (o.means || [])[idx]) / (o.devs || [])[idx];
7829
+ v = isFinite(v) ? v : 0;
7830
+ tv = v;
7831
+ v *= 100;
7832
+ }
7833
+ // the color: the part's, or (SEQUENCE, a single part) the class of
7834
+ // the value
7835
+ let cls = o.nColors ? (idx < o.nColors ? idx : idx % (o.gridX || 1000000)) : idx;
7836
+ if ((has(/SEQUENCE|CLIP/) || n === 1) && o.nColors >= 2 && o.classMax && o.classMax.length) {
7837
+ cls = o.classMax.length - 1;
7838
+ for (let k = 0; k < o.classMax.length; k++) if (v < o.classMax[k]) { cls = k; break; }
7839
+ }
7840
+ if (has(/LEFT/) && has(/HORZ/)) v = -v;
7841
+ if (v < 0 && !stacked) { posY = -v * step; v = -v; }
7842
+ if (pointer && sizeFlag) {
7843
+ // flat's sized pointer: its width by the value (the WIDTH variant
7844
+ // by value100 is not ported)
7845
+ W = chartSize * Math.pow(Math.abs(v / nMax), 1 / 3);
7846
+ f = W * 0.6;
7847
+ if (!has(/ZOOM|XAXIS/)) {
7848
+ textOpacity = Math.pow(Math.abs(v), 1 / 2) / Math.pow(o.themeMax, 1 / 2);
7849
+ if (o.fadeValuePow) textOpacity = Math.pow(Math.abs(v), o.fadeValuePow) / Math.pow(nMax, o.fadeValuePow);
7850
+ }
7851
+ }
7852
+ if (has(/DTEXT/) && !has(/ZOOM/)) {
7853
+ f = (chartSize * 2 / 3) * Math.pow(Math.abs(v / nMax), 1 / 2) * 0.75;
7854
+ textOpacity = Math.pow(Math.abs(v), 1 / 3) / Math.pow(nMax, 1 / 3);
7855
+ }
7856
+ v = isFinite(v) ? v : 0;
7857
+ if (v === 0 && has(/NOZERO/)) { posY = 0; continue; }
7858
+ if (tv < 0 && has(/NONEGATIVE/)) { posY = 0; continue; }
7859
+ if (tv > 0 && has(/ONLYNEGATIVE/)) { posY = 0; continue; }
7860
+
7861
+ const oy = posY;
7862
+ const bar = { part: idx, cls, value: v, textValue: tv, dir: 0, w2: W / 2, sw: W / 2, band: 0, head: 0, len: 0,
7863
+ fill: { opacity: o.fillOpacity || 1 }, line: null, opacity: 1 };
7864
+ let drawn = true;
7865
+ if (pointer && v) {
7866
+ if (has(/OFFSETMEAN|OFFSETMEDIAN|DEVIATION/) && Math.abs(v * step) > 50) {
7867
+ const h = Math.abs(v * step);
7868
+ step = (50 + (h - 50) / 10 + h % 50) / Math.abs(v);
7869
+ }
7870
+ const H = v * step;
7871
+ const arrow = has(/ARROW/);
7872
+ const style = { rgb: null, width: o.lineWidth ? o.lineWidth * Math.sqrt(sizer || 1) : 0.05, opacity: 0.5 };
7873
+ if (oy <= 0) {
7874
+ // positive: the shaft from the base up, a band as wide as the bar,
7875
+ // the head on it
7876
+ Object.assign(bar, arrow
7877
+ ? { base: [posX + W / 2, oy - 0.9 * H / nC + 0.9 * H], len: 0.9 * H, sw: W / 6, band: W / 15, head: W / 1.5 }
7878
+ : { base: [posX + W / 2, oy - H / nC + H], len: H, sw: W * 5 / 14, band: W / 15, head: W / 2 });
7879
+ bar.line = style;
7880
+ } else if (!has(/NONEGATIVE/)) {
7881
+ // negative: from the zero line down, the head below
7882
+ Object.assign(bar, arrow
7883
+ ? { base: [posX + W / 2, oy - H / nC], len: H, sw: W / 6, head: W / 2, dir: 2 }
7884
+ : { base: [posX + W / 2, oy - H / nC], len: H, sw: W * 5 / 14, head: W / 4, dir: 2 });
7885
+ bar.line = style;
7886
+ if (!has(/LEFT/)) {
7887
+ const fadeNeg = o.fadeNegative || 0.1;
7888
+ if (has(/ZOOM|NORMSIZE/)) {
7889
+ bar.fill.opacity = 0.5;
7890
+ bar.line = { rgb: 'color', width: 0.25, opacity: 1 };
7891
+ } else if (o.nClasses > 2) {
7892
+ bar.fill.opacity = fadeNeg;
7893
+ bar.line = { rgb: 'low', width: o.lineWidth ? o.lineWidth * 0.25 * sizer : 0.25, opacity: 0.8 };
7894
+ } else {
7895
+ bar.fill.opacity = fadeNeg;
7896
+ bar.line = { rgb: 'high', width: o.lineWidth ? o.lineWidth * Math.sqrt(sizer || 1) : 0.05, opacity: 1 };
7897
+ }
7898
+ }
7899
+ } else drawn = false;
7900
+ } else {
7901
+ // plain 2D bar (DOTTED, 3D, COLUMN are drawn as this too)
7902
+ if (!has(/NOZERO/)) v = v || 0.00001;
7903
+ const H = v * step;
7904
+ bar.value = v;
7905
+ if (H < 0) drawn = false;
7906
+ bar.base = [posX + W / 2, oy - H / nC + H];
7907
+ bar.len = H;
7908
+ if (oy <= 0 || has(/LEFT/)) {
7909
+ bar.line = { rgb: null, width: o.lineWidth ? o.lineWidth * 0.25 * sizer : 0.2, opacity: o.lineWidth ? 1 : 0.1 };
7910
+ } else {
7911
+ bar.fill.opacity = 0.3;
7912
+ bar.line = { rgb: 'color', width: 0.25, opacity: 1 };
7913
+ }
7914
+ }
7915
+ if (has(/FADENEGATIVE/) && tv < 0) bar.opacity = 0.5;
7916
+ if (has(/FADEIN/)) {
7917
+ if (n === 2 && i === 0) bar.opacity = 0.6;
7918
+ else if (!has(/NORMSIZE/)) {
7919
+ const p = has(/FADEINP4/) ? 10 : 3;
7920
+ bar.opacity = 0.1 + 0.9 * Math.pow(i + 1, p) / Math.pow(n, p);
7921
+ }
7922
+ }
7923
+ if (drawn) bars.push(bar);
7924
+
7925
+ // the value text
7926
+ const H = v * step;
7927
+ if (has(/VALUES/) && !(oy > 0 && has(/NONEGATIVE/)) && !o.hideValues
7928
+ && f > 0 && !(o.gridX && o.nClasses / o.gridX > 2)) {
7929
+ let text = fmt(tv, o.valueDecimals != null ? o.valueDecimals : (o.themeMax <= 1 ? 1 : 0));
7930
+ if (pointer && tv > 0 && (o.themeMin < 0 || has(/OFFSETMEAN|OFFSETMEDIAN|\bSIGN\b/))) text = '+' + text;
7931
+ if (!has(/VOLUME/)) text += o.units ? ' ' + o.units + ' ' : '';
7932
+ if (stacked && tv !== 0 && !o.field100 && !o.gridX) text += ' (' + Math.round(tv / sum * 100) + '%)';
7933
+ if (stacked) f = (has(/ZOOM/) ? 18 : 5) * valueScale;
7934
+ // SVG collapses the blanks
7935
+ text = text.replace(/\s+/g, ' ').trim();
7936
+ const red = parseFloat(text) < 0;
7937
+ // createTextLabel's box: not with HORZ; with ZOOM … AXIS only
7938
+ // colored (VALUEBACKGROUND / CTEXT); colored for a positive value
7939
+ const colored = tv > 0 && has(/CTEXT|VALUEBACKGROUND/);
7940
+ let bgOn = true;
7941
+ if (has(/ZOOM|MENUSIZE|NORMSIZE|INFOSIZE|AXIS|SELECTION/)) bgOn = colored;
7942
+ if (has(/VALUEBACKGROUND/)) bgOn = true;
7943
+ if (horz) bgOn = false;
7944
+ const bg = bgOn ? (colored ? 'bar' : 'white') : null;
7945
+ // the text color: negative red (valuecolor), else textcolor; on the
7946
+ // bar's color its text color, else flat's values color
7947
+ const color = red ? (o.valueColor || 'red') : (o.textColor || (bg === 'bar' ? 'contrast' : 'value'));
7948
+ const textW = measure(text, f);
7949
+ const tLen = textW + (bgOn ? 0.7 * f : 0);
7950
+ const ptY = W / 2 + f * 0.15;
7951
+ const outside = (!has(/DOINLINETEXT/) || has(/ZOOM/) || has(/NORMSIZE/)) && has(/VALUE/) && !(has(/VOLUME/) && !has(/ZOOM/))
7952
+ && (has(/THIN|NOINLINETEXT|DTEXT|SIZE|STACKED|NORMSIZE|ZOOM/) || tLen + 1 > H / nC);
7953
+ const base = { text, cls, part: idx, opacity: textOpacity, color, bg };
7954
+ if (outside && stacked) {
7955
+ if (!(v === 0 && !has(/ZEROISVALUE/)) && !(o.gridX && n / o.gridX > 2)) {
7956
+ const topDx = H / 2;
7957
+ minNext = Math.max(0, minNext + (f * 0.75 - topDx));
7958
+ const textPosX = minNext + topDx - f / 3;
7959
+ let indent = 0.5 * sizer;
7960
+ let ptYs = ptY;
7961
+ let x1 = posX + ptY + W / 3, x2 = posX + ptY + W * 7 / 8;
7962
+ const y1 = -topDx, y2 = -textPosX - f / 3;
7963
+ if (o.gridX && barsDrawn < o.gridX) {
7964
+ ptYs -= W * 2 + tLen;
7965
+ const tmp = x2;
7966
+ x2 = x1 - W * 1.4;
7967
+ x1 = tmp - W * 1.4;
7968
+ indent = -indent;
7969
+ }
7970
+ texts.push(Object.assign(base, { x: posX + ptYs + W + indent, y: oy - textPosX, angle: 0, font: f }));
7971
+ [[x1, y1, x1 + indent, y1], [x1 + indent, y1, x2, y2], [x2, y2, x2 + indent, y2]]
7972
+ .forEach(([a, b, c, d]) => lines.push({ x0: a, y0: oy + b, x1: c, y1: oy + d }));
7973
+ minNext = Math.max(0, minNext + (f * 2 / 3 - (H - topDx)));
7974
+ }
7975
+ } else if (outside) {
7976
+ const lift = pointer && tv > 0 ? W * 0.5 : 0;
7977
+ if (n === 1 && !has(/HORZ/) && !has(/VOLUME/)) {
7978
+ texts.push(Object.assign(base, { x: posX - W * 0.8 + f, y: oy - H / nC - f * 0.8 - lift + 0.25 * f, angle: 0, font: f,
7979
+ opacity: tv < 0 ? textOpacity * 0.75 : textOpacity }));
7980
+ } else {
7981
+ // rotate(270) scale(0.9): reading up, 0.9 font
7982
+ texts.push(Object.assign(base, { x: posX + ptY + 0.9 * 0.25 * f, y: oy - 0.5 - W * 0.25 - H / nC - 0.9 * f, angle: 90, font: 0.9 * f }));
7983
+ }
7984
+ } else {
7985
+ // inside the bar, in its text color, shrunk to fit
7986
+ const s = Math.min(1, H / tLen);
7987
+ if (s >= 0.33) {
7988
+ let ptX = -4;
7989
+ if (tv > 0) ptX += Math.max(1, H - (tLen + 3) * s);
7990
+ const dY = 0.5 / s;
7991
+ texts.push(Object.assign(base, { x: posX + ptY - dY + 0.25 * f, y: oy - ptX - f, angle: 90, font: f * s, color: 'contrast', bg: null }));
7992
+ }
7993
+ minNext = Math.max(0, minNext - H);
7994
+ }
7995
+ }
7996
+
7997
+ if (stacked) {
7998
+ if (!has(/CENTER/)) posY -= v * step;
7999
+ } else {
8000
+ posY = 0;
8001
+ posX += W;
8002
+ if (has(/SPACED/)) posX += W / 5;
8003
+ else if (!has(/\bUP\b/)) posX += 0.05;
8004
+ }
8005
+ barsDrawn++;
8006
+ if (stacked && !has(/MULTI/) && o.gridX && barsDrawn % o.gridX === 0) {
8007
+ posY = 0;
8008
+ posX += W + W / 20;
8009
+ }
8010
+ }
8011
+
8012
+ // the chart's position: HOR(Z) turned by 90° (bars to the right, the
8013
+ // first on top, the last centered on the position's height), else
8014
+ // the bars side by side centered on it (STACKED: the first column)
8015
+ const P = posX;
8016
+ let map, dirMap, angleMap;
8017
+ if (horz) {
8018
+ const dy = W / 2 + (has(/3D/) ? W / 3 : 0) - P;
8019
+ map = (x, y) => [-y, x + dy];
8020
+ dirMap = d => (d + 1) % 4;
8021
+ angleMap = a => a - 90;
8022
+ } else {
8023
+ const dx = stacked ? W / 2 : W * barsDrawn / 2;
8024
+ map = (x, y) => [x - dx, y];
8025
+ dirMap = d => d;
8026
+ angleMap = a => a;
8027
+ }
8028
+ const ext = [Infinity, Infinity, -Infinity, -Infinity];
8029
+ const grow = (x, y) => { if (x < ext[0]) ext[0] = x; if (y < ext[1]) ext[1] = y; if (x > ext[2]) ext[2] = x; if (y > ext[3]) ext[3] = y; };
8030
+ for (const b of bars) {
8031
+ b.base = map(b.base[0], b.base[1]);
8032
+ b.dir = dirMap(b.dir);
8033
+ const d = [[0, -1], [1, 0], [0, 1], [-1, 0]][b.dir], a = [-d[1], d[0]];
8034
+ const L = b.len + b.band + b.head;
8035
+ [[-b.w2, 0], [b.w2, 0], [-b.w2, L], [b.w2, L]].forEach(([u, v]) => grow(b.base[0] + a[0] * u + d[0] * v, b.base[1] + a[1] * u + d[1] * v));
8036
+ }
8037
+ for (const tx of texts) {
8038
+ [tx.x, tx.y] = map(tx.x, tx.y);
8039
+ tx.angle = angleMap(tx.angle);
8040
+ // arial: ascent 0.905, descent 0.212 of the font
8041
+ const w = measure(tx.text, tx.font) + (tx.bg ? 0.7 * tx.font : 0);
8042
+ const r = tx.angle * Math.PI / 180, c = Math.cos(r), s = -Math.sin(r);
8043
+ const xo = tx.bg ? -0.3 * tx.font : 0;
8044
+ [[xo, -0.905 * tx.font], [xo + w, -0.905 * tx.font], [xo, 0.212 * tx.font], [xo + w, 0.212 * tx.font]]
8045
+ .forEach(([u, v]) => grow(tx.x + u * c - v * s, tx.y + u * s + v * c));
8046
+ }
8047
+ for (const l of lines) {
8048
+ [l.x0, l.y0] = map(l.x0, l.y0);
8049
+ [l.x1, l.y1] = map(l.x1, l.y1);
8050
+ grow(l.x0, l.y0); grow(l.x1, l.y1);
8051
+ }
8052
+ return { bars, texts, lines, extent: ext[0] <= ext[2] ? ext : [0, 0, 0, 0], width: W, sizer };
8053
+ }
8054
+
8055
+ // flat's BOX and TITLE around a chart (maptheme.js 18758-18905), in the
8056
+ // chart's units: `extent` the chart's [x0, y0, x1, y1]; o: { margin
8057
+ // (boxmargin, 2), titleFont, title (text or ''), bottomTitle, alignRight,
8058
+ // measure }. The margin is min(2 m, m · width / 30); the title's first
8059
+ // baseline 0.7 font above the chart (BOTTOMTITLE: one font below), its
8060
+ // lines wrapped to the chart's width and stacked upward; the box encloses
8061
+ // chart and title, its top trimmed by 0.1 font when titled. Returns
8062
+ // { rect: [x0, y0, x1, y1], title: { lines, x, anchor, baseline, font } | null }
8063
+ function flatChartBox(extent, o) {
8064
+ const e = extent.slice();
8065
+ const w = e[2] - e[0];
8066
+ const margin = Math.min(2 * o.margin, o.margin * w / 30);
8067
+ let title = null;
8068
+ if (o.title) {
8069
+ const f = o.titleFont;
8070
+ const measure = o.measure || ((text, font) => text.length * font * 0.55);
8071
+ const lines = [];
8072
+ for (const word of String(o.title).split(/\s+/).filter(Boolean)) {
8073
+ const last = lines.length ? lines[lines.length - 1] + ' ' + word : null;
8074
+ if (last !== null && measure(last, f) <= w) lines[lines.length - 1] = last;
8075
+ else lines.push(word);
8076
+ }
8077
+ const width = Math.max(...lines.map(l => measure(l, f)));
8078
+ const baseline = o.bottomTitle ? e[3] + f : e[1] - 0.7 * f - (lines.length - 1) * f;
8079
+ const x = o.alignRight ? e[2] : e[0];
8080
+ const x0 = o.alignRight ? x - width : x;
8081
+ title = { lines, x, anchor: o.alignRight ? 'end' : 'start', baseline, font: f };
8082
+ e[0] = Math.min(e[0], x0);
8083
+ e[1] = Math.min(e[1], baseline - 0.905 * f) + 0.1 * f;
8084
+ e[2] = Math.max(e[2], x0 + width);
8085
+ e[3] = Math.max(e[3], baseline + (lines.length - 1) * f + 0.212 * f);
8086
+ }
8087
+ return { rect: [e[0] - margin, e[1] - margin, e[2] + margin, e[3] + margin], title, width: w };
8088
+ }
8089
+
8090
+ // CHART|PIE / DONUT: one pie's slices as real ixmaps-flat lays them out —
8091
+ // maptheme.js drawChart's PIE branch (19939-20330) and piechart.js
8092
+ // DonutChart.realize (181-510). `values`: the item's part values (field
8093
+ // order); `t`: the type string, tested as flat does, by substring (so
8094
+ // CENTERVALUE also is CENTER, XTHIN also THIN). Returns null when flat
8095
+ // draws no pie (no sum, no positive part), else
8096
+ // { slices: [{ i, value, percent, start, sweep, complement }],
8097
+ // inner: inner radius / radius,
8098
+ // center: { i, value, size } | null }
8099
+ // (k on a slice: its index among all of flat's donut parts)
8100
+ // i is the part's field index (-1: AUTOCOMPLETE's no-data complement);
8101
+ // start/sweep in degrees, clockwise from 12 o'clock.
8102
+ // - parts: in field order; SORT biggest first; REVERSE takes flat's
8103
+ // index n − i literally — field n doesn't exist and field 0 is never
8104
+ // reached, so a REVERSE pie loses its first part, as in flat
8105
+ // - a part is a slice when > 0, or = 0 unless ZEROISNOTVALUE (a 0 slice
8106
+ // has no angle and is not drawn); negative parts are left out
8107
+ // - the slices share 100 % (AUTOCOMPLETE below 100: the rest is a
8108
+ // no-data part, the values are taken as percentages)
8109
+ // - CENTER (without DONUT): the center part (style.centerpart first /
8110
+ // last / min / max — default max — or a field number) is no slice but
8111
+ // a disc in the middle, radius √(size / 100) of the pie's, size =
8112
+ // value / sum · 200; the ring around it has that inner radius
8113
+ // - DONUT: inner radius 0.42 (THICK 0.25, THIN 0.66, XTHIN 0.95, 3D 0.5)
8114
+ // - an aggregated 1-record item of fewer than 2 parts has no hole
8115
+ // - angles: from 0° (SYMMETRIC: the first part, BIGTOTOP the biggest,
8116
+ // centered on 12 o'clock), HALF over 180° from 270° (HALFPLUS10 200°
8117
+ // from 260°, HALFMINUS10 160° from 280°, HALFPLUS20 220° from 250°,
8118
+ // HALFMINUS20 140° from 290°)
8119
+ function pieSliceLayout(values, t, opts = {}) {
8120
+ t = String(t || '');
8121
+ const has = re => re.test(t);
8122
+ const parts = values.map(v => (isNaN(v) ? 0 : v));
8123
+ const sum = parts.reduce((a, v) => a + v, 0);
8124
+ const autocomplete = has(/AUTOCOMPLETE/);
8125
+ if (sum === 0 && !autocomplete) return null;
8126
+ const n = parts.length;
8127
+ let sortedIndex = null;
8128
+ if (has(/\bSORT\b/)) sortedIndex = parts.map((value, index) => ({ index, value })).sort((a, b) => b.value - a.value);
8129
+ if (has(/\bREVERSE\b/)) sortedIndex = parts.map((value, i) => ({ index: n - i, value }));
8130
+ const at = p => (sortedIndex ? sortedIndex[p].index : p);
8131
+ const donut = has(/DONUT/), centerFlag = has(/CENTER/);
8132
+ let center = null;
8133
+ if (centerFlag) {
8134
+ let cSum = 0, cMax = -Number.MAX_VALUE, cMin = Number.MAX_VALUE, iMin, iMax;
8135
+ for (let p = 0; p < n; p++) {
8136
+ const i = at(p);
8137
+ cSum += parts[i] || 0;
8138
+ if (parts[i] < cMin) { cMin = parts[i]; iMin = i; }
8139
+ if (parts[i] > cMax) { cMax = parts[i]; iMax = i; }
8140
+ }
8141
+ const cp = opts.centerPart != null && opts.centerPart !== '' ? String(opts.centerPart) : 'max';
8142
+ let ci = 0;
8143
+ if (cp === 'first') ci = 0;
8144
+ else if (cp === 'last') ci = n - 1;
8145
+ else if (cp === 'min') ci = iMin;
8146
+ else if (cp === 'max') ci = iMax;
8147
+ else if (Number(cp)) ci = Number(cp);
8148
+ center = { i: ci, value: parts[ci], size: parts[ci] / cSum * 200 };
8149
+ }
8150
+ const slices = [];
8151
+ for (let p = 0; p < n; p++) {
8152
+ const i = at(p);
8153
+ if (centerFlag && !donut && i === center.i) continue;
8154
+ const v = parts[i];
8155
+ if (v > 0 || (v === 0 && !has(/ZEROISNOTVALUE/))) slices.push({ i, value: v, percent: v });
8156
+ }
8157
+ let inner = 0;
8158
+ if (donut) inner = has(/3D/) ? 0.5 : has(/XTHIN/) ? 0.95 : has(/THIN/) ? 0.66 : has(/THICK/) ? 0.25 : 0.42;
8159
+ else if (centerFlag) inner = Math.sqrt(center.size / 100);
8160
+ // GR 09.11.2019 "donut with count or size == 1 --> bubble": only for an
8161
+ // aggregated item of 1 record (opts.itemCount) — flat's per-record
8162
+ // items have no nCount, so their 1-part donuts keep the hole
8163
+ if (slices.length < 2 && opts.itemCount === 1) inner = 0;
8164
+ // DonutChart.realize
8165
+ let biggest = slices[0], biggestValue = 0, any = false;
8166
+ for (const sl of slices) {
8167
+ if (sl.percent > 0) {
8168
+ if (has(/BIGTOTOP/) && sl.percent > biggestValue) { biggest = sl; biggestValue = sl.percent; }
8169
+ any = true;
8170
+ }
8171
+ }
8172
+ // flat draws its "Pie error: no values !" circle here
8173
+ if (!any && !autocomplete) return null;
8174
+ const pctSum = slices.reduce((a, sl) => a + sl.percent, 0);
8175
+ if (pctSum !== 100) {
8176
+ if (autocomplete && pctSum < 100) slices.push({ i: -1, value: null, percent: Math.floor((100 - pctSum) * 100000) / 100000, complement: true });
8177
+ else slices.forEach(sl => { sl.percent = sl.percent / pctSum * 100; });
8178
+ }
8179
+ let end = 0;
8180
+ if (has(/SYMMETRIC/) && slices.length) {
8181
+ if (has(/BIGTOTOP/)) {
8182
+ end = 360;
8183
+ let x = 0;
8184
+ for (; x < slices.length && slices[x] !== biggest; x++) end -= slices[x].percent < 100 ? Math.floor(360 / 100 * slices[x].percent) : 0;
8185
+ end -= slices[x] && slices[x].percent < 100 ? Math.floor(360 / 100 / 2 * biggest.percent) : 0;
8186
+ } else if (slices[0].percent < 100) {
8187
+ end = 360 - Math.floor(360 / 100 / 2 * slices[0].percent);
8188
+ }
8189
+ }
8190
+ if (has(/HALF/)) end = 270;
8191
+ if (has(/HALFPLUS10/)) end = 260;
8192
+ if (has(/HALFMINUS10/)) end = 280;
8193
+ if (has(/HALFPLUS20/)) end = 250;
8194
+ if (has(/HALFMINUS20/)) end = 290;
8195
+ let maxAngle = has(/HALF/) ? 180 : 360;
8196
+ if (has(/HALFPLUS10/)) maxAngle = 200;
8197
+ if (has(/HALFMINUS10/)) maxAngle = 160;
8198
+ if (has(/HALFPLUS20/)) maxAngle = 220;
8199
+ if (has(/HALFMINUS20/)) maxAngle = 140;
8200
+ const out = [];
8201
+ slices.forEach((sl, k) => {
8202
+ const sweep = sl.percent / 100 * maxAngle;
8203
+ const start = end % 360;
8204
+ end = start + sweep;
8205
+ if (end > 360) end = end % 360;
8206
+ // _crc_drawDonut draws nothing for start == end (a 0 part, or a
8207
+ // full turn that doesn't start at 0°); k: flat's donut part index
8208
+ // (its value texts count them, drawn or not)
8209
+ if (start === end || start < 0 || end < 0) return;
8210
+ out.push(Object.assign(sl, { start, sweep, k }));
8211
+ });
8212
+ return { slices: out, inner, center: centerFlag && !donut ? center : null };
8213
+ }
8214
+
8215
+ // A pie's VALUES as flat draws them for more than one part
8216
+ // (maptheme.js drawDonutText 25072-25155, drawTextforOneQuadrant
8217
+ // 24993-25065, piechart.js getTextPosition 517-555): a label per slice
8218
+ // outside the pie, joined to the slice by a leader line. Per slice: the
8219
+ // point at 1.1 R in the middle of its angle; the labels of each quadrant
8220
+ // stacked by that point's height (line height 1.2 font, kept apart, the
8221
+ // last ones pushed toward the pie's top/bottom), all at x = R + 2u right
8222
+ // or left; the leader line from the slice's rim to its 1.1 R point,
8223
+ // horizontally to R − 3u when short of it, to the label's height at
8224
+ // R + 2u, then 2u straight on; the text 1.5u further. Only flat's first
8225
+ // min(50, maxParts) donut parts get one (maxParts: the theme's class
8226
+ // count). Pixels, y down, relative to the pie center:
8227
+ // [{ slice, segments: [[x0, y0, x1, y1], ...], x, y (text baseline), anchor: 'start' | 'end' }]
8228
+ function pieValueLabelLayout(slices, R, fontPx, unitPx, maxParts) {
8229
+ const limit = Math.min(50, maxParts);
8230
+ const quads = [[], [], [], []];
8231
+ let yMax = 0;
8232
+ for (const sl of slices) {
8233
+ if (sl.k >= limit) continue;
8234
+ let end = sl.start + sl.sweep;
8235
+ if (end > 360) end = end % 360;
8236
+ const mid = sl.start < end ? (sl.start + end) / 2 : Math.min(360, (sl.start + end + 360) / 2) % 360;
8237
+ const sin = Math.sin(Math.PI * mid / 180), cos = Math.cos(Math.PI * mid / 180);
8238
+ const p = { slice: sl, x: sin * R * 1.1, y: -cos * R * 1.1, lx: sin * R, ly: -cos * R };
8239
+ yMax = Math.max(yMax, Math.abs(p.y));
8240
+ // flat's quadrants 1 (upper right) 2 (lower right) 3 (lower left)
8241
+ // 4 (upper left), folded onto positive x/y
8242
+ if (p.x > 0) {
8243
+ if (p.y > 0) quads[1].push(p);
8244
+ else quads[0].push({ slice: sl, x: p.x, y: -p.y, lx: p.lx, ly: -p.ly });
8245
+ } else if (p.y > 0) quads[2].push({ slice: sl, x: -p.x, y: p.y, lx: -p.lx, ly: p.ly });
8246
+ else quads[3].push({ slice: sl, x: -p.x, y: -p.y, lx: -p.lx, ly: -p.ly });
8247
+ }
8248
+ const out = [];
8249
+ const dirs = [[1, -1, 'start'], [1, 1, 'start'], [-1, 1, 'end'], [-1, -1, 'end']];
8250
+ quads.forEach((q, qi) => {
8251
+ q.sort((a, b) => (a.y > b.y ? 1 : -1));
8252
+ const [xDir, yDir, anchor] = dirs[qi];
8253
+ const lh = fontPx * 1.2;
8254
+ let yMin = lh / 2;
8255
+ let ySpace = yMax - lh * (q.length - 2);
8256
+ for (const p of q) {
8257
+ const knee = p.y / p.ly;
8258
+ const yPos = Math.max(yMin, Math.min(p.y, ySpace));
8259
+ yMin = yPos + lh;
8260
+ ySpace += fontPx;
8261
+ const xPos = R + 2 * unitPx;
8262
+ const segments = [];
8263
+ let sx = p.lx * xDir, sy = p.ly * yDir;
8264
+ if (knee) { segments.push([sx, sy, p.x * xDir, p.y * yDir]); sx = p.x * xDir; sy = p.y * yDir; }
8265
+ if (p.x < R - 3 * unitPx) { const x = (R - 3 * unitPx) * xDir; segments.push([sx, sy, x, sy]); sx = x; }
8266
+ let ex = xPos * xDir;
8267
+ const ey = yPos * yDir;
8268
+ segments.push([sx, sy, ex, ey]);
8269
+ segments.push([ex, ey, ex + 2 * unitPx * xDir, ey]);
8270
+ ex += 3.5 * unitPx * xDir;
8271
+ out.push({ slice: p.slice, segments, x: ex, y: ey + fontPx * 0.25, anchor });
8272
+ }
8273
+ });
8274
+ return out;
8275
+ }
8276
+
8277
+ // flat's value rules of a theme (MapTheme constructor, maptheme.js
8278
+ // 7740-7830), by substring of the type string as flat tests them:
8279
+ // nullIsValue (0 is a value: ZEROISNOTVALUE no, ZEROISVALUE yes, a CHART
8280
+ // with CATEGORICAL or several value fields always), negativeOk
8281
+ // (NEGATIVEISNOTVALUE no, NEGATIVEISVALUE yes, CATEGORICAL without
8282
+ // AGGREGATE no), undefinedOk (UNDEFINEDISNOTVALUE no), zeroExcludes
8283
+ // (ZEROISNOTVALUE: a 0 is left out of an aggregation's means)
8284
+ function flatValueRules(t, nFields) {
8285
+ t = String(t || '');
8286
+ let nullIsValue = true, negativeOk = true, undefinedOk = true;
8287
+ if (/ZEROISNOTVALUE/.test(t)) nullIsValue = false;
8288
+ if (/ZEROISVALUE/.test(t)) nullIsValue = true;
8289
+ if (/NEGATIVEISNOTVALUE/.test(t)) negativeOk = false;
8290
+ if (/NEGATIVEISVALUE/.test(t)) negativeOk = true;
8291
+ if (/UNDEFINEDISVALUE/.test(t)) undefinedOk = true;
8292
+ if (/UNDEFINEDISNOTVALUE/.test(t)) undefinedOk = false;
8293
+ if (/CHART/.test(t) && (/CATEGORICAL/.test(t) || nFields > 1)) nullIsValue = true;
8294
+ if (/CATEGORICAL/.test(t) && !/AGGREGATE/.test(t)) negativeOk = false;
8295
+ return { nullIsValue, negativeOk, undefinedOk, zeroExcludes: /ZEROISNOTVALUE/.test(t) };
8296
+ }
8297
+
8298
+ // An AGGREGATE or CATEGORICAL pie: one record's contribution to its item,
8299
+ // as flat's loadAndAggregateValuesOfTheme reads it (maptheme.js
8300
+ // 10465-10571, which every AGGREGATE theme goes through). `o`:
8301
+ // { rules (flatValueRules), sizeField, cat (CATEGORICAL: the record's
8302
+ // category, 0-based, -1 when it is none of them), fields (otherwise:
8303
+ // the value fields) }
8304
+ // Returns null when flat leaves the record out, else
8305
+ // { cat, size } (CATEGORICAL) or { parts, zeroNot, size }
8306
+ // - size: the size field as a number (1 without one); a record whose
8307
+ // size is no number, 0 (unless 0 is a value) or negative (unless
8308
+ // negatives are values) is left out
8309
+ // - CATEGORICAL: a value that is none of the categories (not in
8310
+ // style.values) is part -1 — no slice, but its size still counts in
8311
+ // the item's size (flat: nValuesA[-1]); left out only with
8312
+ // UNDEFINEDISNOTVALUE
8313
+ // - value fields: a value that is no number is 0 (UNDEFINEDISNOTVALUE:
8314
+ // the record is left out); a 0 is left out of the item's MEAN with
8315
+ // ZEROISNOTVALUE (zeroNot), else the record when 0 is no value; a
8316
+ // negative value when negatives are no values
8317
+ function flatPieRecord(raw, o) {
8318
+ const r = o.rules;
8319
+ const size = o.sizeField ? flatScanValue(raw[o.sizeField]) : 1;
8320
+ if (isNaN(size) || (size === 0 && !r.nullIsValue) || (size < 0 && !r.negativeOk)) return null;
8321
+ if (o.cat !== undefined) {
8322
+ if (o.cat < 0 && !r.undefinedOk) return null;
8323
+ return { cat: o.cat, size };
8324
+ }
8325
+ const parts = [], zeroNot = [];
8326
+ for (let k = 0; k < o.fields.length; k++) {
8327
+ let v = parseFloat(raw[o.fields[k]]);
8328
+ zeroNot[k] = false;
8329
+ if (isNaN(v)) {
8330
+ if (!r.undefinedOk) return null;
8331
+ v = 0;
8332
+ }
8333
+ if (v === 0) {
8334
+ if (r.zeroExcludes) { parts[k] = 0; zeroNot[k] = true; continue; }
8335
+ if (!r.nullIsValue) return null;
8336
+ }
8337
+ if (v < 0 && !r.negativeOk) return null;
8338
+ parts[k] = v;
8339
+ }
8340
+ return { parts, zeroNot, size };
8341
+ }
8342
+
8343
+ // Adds a record's flatPieRecord to its item (maptheme.js 10779-10943) —
8344
+ // `item` null for the first record of the item. Returns the item
8345
+ // { parts (sparse for CATEGORICAL: the category sums), counts (the
8346
+ // records per part, for MEAN), size (the item's size, flat's nSize),
8347
+ // n (its record count, flat's nCount) }
8348
+ // SUM (default): the parts and the |sizes| summed; MAX / MIN: their
8349
+ // maximum / minimum (a part missing so far counts 0, as in flat; the
8350
+ // record count stays 1); FIRST: the first record only; LAST: the last.
8351
+ // A CATEGORICAL record of no category (-1) adds its size only.
8352
+ function flatPieAccumulate(item, rec, flags) {
8353
+ const max = flags.has('MAX'), min = flags.has('MIN');
8354
+ if (item && flags.has('FIRST')) return item;
8355
+ if (!item || flags.has('LAST')) {
8356
+ if (rec.cat !== undefined) {
8357
+ const parts = [], counts = [];
8358
+ if (rec.cat >= 0) { parts[rec.cat] = rec.size; counts[rec.cat] = 1; }
8359
+ return { parts, counts, size: Math.abs(rec.size), n: 1 };
8360
+ }
8361
+ return { parts: rec.parts.slice(), counts: rec.zeroNot.map(z => (z ? 0 : 1)), size: Math.abs(rec.size), n: 1 };
8362
+ }
8363
+ const comb = (a, v) => (max ? Math.max(a || 0, v) : min ? Math.min(a || 0, v) : (a || 0) + v);
8364
+ if (rec.cat !== undefined) {
8365
+ if (rec.cat >= 0) {
8366
+ item.parts[rec.cat] = comb(item.parts[rec.cat], rec.size);
8367
+ item.counts[rec.cat] = (item.counts[rec.cat] || 0) + 1;
8368
+ }
8369
+ } else {
8370
+ for (let k = 0; k < rec.parts.length; k++) {
8371
+ item.parts[k] = comb(item.parts[k], rec.parts[k] || 0);
8372
+ if (!rec.zeroNot[k]) item.counts[k] = (item.counts[k] || 0) + 1;
8373
+ }
8374
+ }
8375
+ if (max) item.size = Math.max(item.size, rec.size);
8376
+ else if (min) item.size = Math.min(item.size, rec.size);
8377
+ else { item.size += Math.abs(rec.size); item.n++; }
8378
+ return item;
8379
+ }
8380
+
8381
+ // An aggregated pie item's parts and size after flat's post-aggregation
8382
+ // steps (maptheme.js 11056-11137): MEAN — a part divided by its record
8383
+ // count (CATEGORICAL: the category's records; value fields: the item's
8384
+ // records, or with ZEROISNOTVALUE the records with a value there), the
8385
+ // size by the item's record count unless SIZESUM; AUTO100 — the parts in
8386
+ // % of their sum. `nParts`: the number of parts (CATEGORICAL with
8387
+ // style.values: every category; without: up to the item's last one, as
8388
+ // flat's sparse nValuesA). Returns { parts, size }.
8389
+ function flatPieItemValues(item, t, o = {}) {
8390
+ t = String(t || '');
8391
+ const n = o.nParts != null ? o.nParts : item.parts.length;
8392
+ let parts = [];
8393
+ for (let k = 0; k < n; k++) parts[k] = item.parts[k] || 0;
8394
+ let size = item.size;
8395
+ if (/MEAN/.test(t)) {
8396
+ const perPart = o.categorical || /ZEROISNOTVALUE/.test(t);
8397
+ parts = parts.map((v, k) => (perPart ? (item.counts[k] > 0 ? v / item.counts[k] : 0) : v / item.n));
8398
+ if (!/SIZESUM/.test(t)) size /= item.n;
8399
+ }
8400
+ if (/AUTO100/.test(t)) {
8401
+ const sum = parts.reduce((a, v) => a + v, 0);
8402
+ parts = parts.map(v => v / (sum / 100));
8403
+ }
8404
+ return { parts, size };
8405
+ }
8406
+
8407
+ // CHART|VECTOR / CHART|BEZIER: the theme's items as real ixmaps-flat
8408
+ // builds them (maptheme.js loadAndAggregateValuesOfTheme, which every
8409
+ // VECTOR / BEZIER theme goes through, 9650: 10402-10450 the second
8410
+ // position, 10763 the item id, 10779-10985 the items). `records`:
8411
+ // { p1: [lng, lat], p2: [lng, lat] | null, cat (0-based category, -1
8412
+ // not one of them; null without CATEGORICAL), value (the value
8413
+ // field as a number, without CATEGORICAL), size (the size field as
8414
+ // a number, NaN when not one; 1 without a size field), raw }
8415
+ // `o`: { aggregate, vectorKey (the VECTOR flag: the item id has the
8416
+ // second position too), multiParts (CATEGORICAL: the category is part
8417
+ // of the id), max / min (MAX / MIN aggregation), zeroIsNotValue,
8418
+ // negativeIsNotValue }. Returns items
8419
+ // { p1, p2, cat, nSize, value, count, raw (the first record), raws }
8420
+ // - without AGGREGATE every record is an item, its nSize the size as
8421
+ // it is (negative too, 0 when not a number) — flat checks no value
8422
+ // there
8423
+ // - AGGREGATE: records of one id are one item: the id is the first
8424
+ // position (+ the category with CATEGORICAL, + the second position
8425
+ // with VECTOR — a BEZIER without VECTOR sums every flow of an origin
8426
+ // into its first record's flow, as flat); a record whose size is no
8427
+ // number (or 0 with ZEROISNOTVALUE, negative with
8428
+ // NEGATIVEISNOTVALUE) is left out; nSize the sum of the |sizes|
8429
+ // (MAX / MIN: their maximum / minimum), value the values' sum
8430
+ // - the first record of an item gives its category, second position,
8431
+ // and the record its tooltip reads
8432
+ function flatVectorItems(records, o = {}) {
8433
+ const items = [];
8434
+ if (!o.aggregate) {
8435
+ for (const r of records) {
8436
+ items.push({ p1: r.p1, p2: r.p2, cat: r.cat, nSize: r.size || 0, value: r.value, count: 1, raw: r.raw, raws: [r.raw] });
8437
+ }
8438
+ return items;
8439
+ }
8440
+ const byId = new Map();
8441
+ for (const r of records) {
8442
+ const s = r.size;
8443
+ if (isNaN(s) || (s === 0 && o.zeroIsNotValue) || (s < 0 && o.negativeIsNotValue)) continue;
8444
+ let id = r.p1[0] + ',' + r.p1[1];
8445
+ // flat's nValue for the id: the category number (1-based), 0 for a
8446
+ // value that is none of the categories
8447
+ if (o.multiParts) id += ',' + (r.cat != null && r.cat >= 0 ? r.cat + 1 : 0);
8448
+ if (o.vectorKey && r.p2) id += '&' + r.p2[0] + ',' + r.p2[1];
8449
+ const item = byId.get(id);
8450
+ if (!item) {
8451
+ const it = { p1: r.p1, p2: r.p2, cat: r.cat, nSize: Math.abs(s), value: r.value, count: 1, raw: r.raw, raws: [r.raw] };
8452
+ byId.set(id, it);
8453
+ items.push(it);
8454
+ continue;
8455
+ }
8456
+ if (o.max) {
8457
+ item.nSize = Math.max(item.nSize, s);
8458
+ item.value = Math.max(item.value || 0, r.value || 0);
8459
+ } else if (o.min) {
8460
+ item.nSize = Math.min(item.nSize, s);
8461
+ item.value = Math.min(item.value || 0, r.value || 0);
8462
+ } else {
8463
+ item.nSize += Math.abs(s);
8464
+ item.value = (item.value || 0) + (r.value || 0);
8465
+ }
8466
+ item.count++;
8467
+ item.raws.push(r.raw);
8468
+ }
8469
+ return items;
8470
+ }
8471
+
8472
+ // CHART|BEZIER: one flow's curve as flat draws it (maptheme.js
8473
+ // 18148-18375), in screen pixels relative to its first position (y
8474
+ // down — flat's SVG map coordinates, scaled). dx/dy: the second
8475
+ // position minus the first. o:
8476
+ // w the line width in flat's chart units (linewidth, else
8477
+ // 10 · (|nSize| / max)^(1/sizepow))
8478
+ // ll flat's arrow / gap length in chart units (w + 3, GAP: w +
8479
+ // gapsize — computed by the caller, see _buildVectorLayers)
8480
+ // unit pixels per chart unit (the object zoom × style.scale)
8481
+ // oz pixels per chart unit without style.scale
8482
+ // bow rangescale (flat's nBow; 5 when not given), RANDOM already
8483
+ // applied
8484
+ // t the type string (SHORT, POINTER / ARROW, GAP)
8485
+ // marker markersize (1)
8486
+ // gap gapsize (20)
8487
+ // Returns { start, c1, c2, end, vx, vy } — start/end/c1/c2 [x, y]
8488
+ // pixels; (vx, vy) the end after shortening, which decides the
8489
+ // gradient direction. The control points come from the unshortened
8490
+ // vector: offset by (dy, −dx) / 50 · bow (SHORT: the unit vector · 5
8491
+ // bow chart units), at 1/4 and 1/2 of the (shortened) vector. An arrow (POINTER
8492
+ // or a type word ending in ARROW) ends the curve ll · markersize units
8493
+ // short of the second position, room for the arrow head. GAP: the curve
8494
+ // starts gapsize units off the first position and ends ll / 500 of the
8495
+ // bow further on.
8496
+ function bezierVectorLayout(dx, dy, o) {
8497
+ const t = String(o.t || '');
8498
+ const len = Math.sqrt(dx * dx + dy * dy);
8499
+ const bow = o.bow || 5;
8500
+ let ox, oy;
8501
+ // (LONG's bow depends on flat's own map units — not drawn here, see
8502
+ // VECTOR_INERT_FLAGS: the default bow instead)
8503
+ if (/\bSHORT\b/.test(t)) { ox = dy / len * bow * 5 * o.oz; oy = dx / len * bow * 5 * o.oz; }
8504
+ else { ox = dy / 50 * bow; oy = dx / 50 * bow; }
8505
+ let x = dx, y = dy;
8506
+ // flat's /\bPOINTER|ARROW\b/: POINTER at a word start, or ARROW at a
8507
+ // word end (ARROW, SMALLARROW, LASTARROW ...)
8508
+ if (/\bPOINTER|ARROW\b/.test(t)) {
8509
+ const k = o.ll * o.unit * (o.marker || 1) / len;
8510
+ x -= dx * k; y -= dy * k;
8511
+ }
8512
+ let x0 = 0, y0 = 0;
8513
+ if (/\bGAP\b/.test(t)) {
8514
+ x += ox / 500 * o.ll;
8515
+ y -= oy / 500 * o.ll;
8516
+ x0 = x / len * (o.gap || 20) * o.oz;
8517
+ y0 = y / len * (o.gap || 20) * o.oz;
8518
+ }
8519
+ return { start: [x0, y0], c1: [x / 4 + ox, y / 4 - oy], c2: [x / 2 + ox, y / 2 - oy], end: [x, y], vx: x, vy: y };
8520
+ }
8521
+
8522
+ // flat's Themes.toArray for a style list (maptheme.js 999-1016): a list
8523
+ // as it is; a string split at "|" when it has one or names an RGB(…)
8524
+ // color (flat's /\RGB/: upper case only), else at ","
8525
+ function flatToArray(v) {
8526
+ if (Array.isArray(v)) return v;
8527
+ const str = String(v);
8528
+ return (/\|/.test(str) || /RGB/.test(str)) ? str.split('|') : str.split(',');
8529
+ }
8530
+ // a fixed number in [0, 1) for a string — stands in for flat's
8531
+ // Math.random() where the harness needs the same drawing every time
8532
+ function hashUnit(str) {
8533
+ let h = 2166136261;
8534
+ for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
8535
+ return (h >>> 0) / 4294967296;
8536
+ }
8537
+
8538
+ // points of a cubic Bézier, n segments (n + 1 points)
8539
+ function cubicBezierPoints(p0, c1, c2, p3, n) {
8540
+ const out = [];
8541
+ for (let i = 0; i <= n; i++) {
8542
+ const s = i / n, u = 1 - s;
8543
+ const a = u * u * u, b = 3 * u * u * s, c = 3 * u * s * s, d = s * s * s;
8544
+ out.push([a * p0[0] + b * c1[0] + c * c2[0] + d * p3[0], a * p0[1] + b * c1[1] + c * c2[1] + d * p3[1]]);
8545
+ }
8546
+ return out;
8547
+ }
8548
+
8549
+ // flat's arrow marker (an SVG <marker>, markerUnits strokeWidth, orient
8550
+ // auto) at a line's end: the triangle M0,s L s,s/2 L0,0 in units of the
8551
+ // line width sw, its reference point (refX, refY) on the end point, its
8552
+ // x axis along the end's direction (tx, ty). BEZIER: s = min(5, 2.5 +
8553
+ // 5 / (w · scale)) · markersize, ref (s / 1.7, s / 2) (maptheme.js
8554
+ // 18309-18320); VECTOR: s = 4, ref (4, 2) — the tip on the end point
8555
+ // (18411-18424). A zero direction is orient 0: pointing right.
8556
+ // Returns the three corners [x, y] (pixels, y down).
8557
+ function arrowMarkerTriangle(end, tx, ty, sw, s, refX, refY) {
8558
+ const l = Math.sqrt(tx * tx + ty * ty);
8559
+ const ux = l ? tx / l : 1, uy = l ? ty / l : 0;
8560
+ const at = (mx, my) => {
8561
+ const a = (mx - refX) * sw, b = (my - refY) * sw;
8562
+ return [end[0] + a * ux - b * uy, end[1] + a * uy + b * ux];
8563
+ };
8564
+ return [at(0, s), at(s, s / 2), at(0, 0)];
8565
+ }
8566
+
8567
+ // BEZIER GRADIENT / FADEIN (maptheme.js 18233-18284): a linear gradient
8568
+ // over the curve's bounding box, vertical when the (shortened) vector
8569
+ // is more vertical than horizontal, else horizontal; the stop on the
8570
+ // first position's side has the fade opacity (FADEIN 0.2, GRADIENT 2),
8571
+ // the other one 2 — SVG clamps both to 1 — and the colors run from the
8572
+ // second color (linecolor's first entry) at the start to the line color
8573
+ // at the end. Returns per point { k: 0 … 1 from the start side's stop,
8574
+ // alpha } — the color mix k and opacity of each point.
8575
+ function fadeGradientStops(points, vx, vy, fade) {
8576
+ const vertical = Math.abs(vy) > Math.abs(vx);
8577
+ const axis = vertical ? 1 : 0;
8578
+ let min = Infinity, max = -Infinity;
8579
+ for (const p of points) { if (p[axis] < min) min = p[axis]; if (p[axis] > max) max = p[axis]; }
8580
+ const v = vertical ? vy : vx;
8581
+ const a = Math.min(1, Math.max(0, fade));
8582
+ // offset 0 is the box's left / top side: the start side unless the
8583
+ // vector points left / up
8584
+ const startAtZero = !(v < 0);
8585
+ return points.map(p => {
8586
+ let f = max > min ? (p[axis] - min) / (max - min) : 0;
8587
+ if (!startAtZero) f = 1 - f;
8588
+ // f: 0 on the start side, 1 on the end side
8589
+ return { k: f, alpha: a + (1 - a) * f };
8590
+ });
8591
+ }
8592
+
7059
8593
  // CHART|SYMBOL|SEQUENCE (flat maptheme.js 21184-21300, 21790-21925,
7060
8594
  // 22296-22395): one symbol per category part of a chart, in category
7061
8595
  // order or sorted (SORT: DOWN biggest first, UP smallest first), parts
@@ -8111,6 +9645,9 @@
8111
9645
  cell.value += f.properties.value;
8112
9646
  if (f.properties.classValue !== undefined) cell.classValue += f.properties.classValue;
8113
9647
  if (post) cell.value100 += f.properties.value100 || 0;
9648
+ // an AGGREGATE / CATEGORICAL pie's record (flatPieRecord): its parts
9649
+ // and size added to the cell's item as flat does (flatPieAccumulate)
9650
+ if (f.properties.pie) cell.pie = flatPieAccumulate(cell.pie || null, f.properties.pie, flags);
8114
9651
  const series = f.properties.series;
8115
9652
  if (series) {
8116
9653
  if (!cell.series) cell.series = new Array(series.length).fill(0);
@@ -8132,6 +9669,7 @@
8132
9669
  ...(cell.raws && cell.raws.length ? { raws: cell.raws } : {}) }
8133
9670
  : cell.first.properties;
8134
9671
  if (cell.series && cell.n > 1) props = Object.assign({}, props, { series: cell.series });
9672
+ if (cell.pie) props = Object.assign({}, props, { pie: cell.pie });
8135
9673
  // a field aggregation's cell key (the field value): the categories of
8136
9674
  // one cell are merged by it (groupCoLocated), not by their positions
8137
9675
  if (field) props = Object.assign({}, props, { aggKey: cell.key });
@@ -8309,7 +9847,10 @@
8309
9847
  this._filterExpr = spec.filter || '';
8310
9848
  this.mapOptions = mapOptions || {};
8311
9849
  this._specBinding = spec.binding;
8312
- this.features = applyField100(filterFlatValues(this._withDensity(fc.features), this.binding, this.flags), this.binding, this.flags, this.style);
9850
+ // VECTOR / BEZIER keep every record: flat checks their values only
9851
+ // when it aggregates them (flatVectorItems)
9852
+ const withDensity = this._withDensity(fc.features);
9853
+ this.features = applyField100(isVectorChart(this.flags) ? withDensity : filterFlatValues(withDensity, this.binding, this.flags), this.binding, this.flags, this.style);
8313
9854
  this.binding = field100Binding(this.binding, this.flags);
8314
9855
  this._iconCache = new Map();
8315
9856
  this._glowIconCache = new Map();
@@ -8404,7 +9945,10 @@
8404
9945
  this._gridRefLat = (minLat <= maxLat) ? (minLat + maxLat) / 2 : 0;
8405
9946
  }
8406
9947
 
8407
- if (this.flags.has('DOMINANT') && this.binding.value && !isAggregatedCategoricalChoropleth(this)) {
9948
+ // a bar chart's parts are a pie's (its value fields or categories)
9949
+ if ((this._isPieChart() || this._isBarChart()) && this.binding.value) {
9950
+ this._preparePie();
9951
+ } else if (this.flags.has('DOMINANT') && this.binding.value && !isAggregatedCategoricalChoropleth(this)) {
8408
9952
  // .type("CHOROPLETH|DOMINANT") — a MULTI-field bound value
8409
9953
  // (binding.value is a pipe-joined field list, e.g. one CSV column
8410
9954
  // per age band for the same year/sex — same pipe convention
@@ -8554,6 +10098,7 @@
8554
10098
  if (this._usesAggregationIndex()) {
8555
10099
  this._buildAggregationIndex(this.features);
8556
10100
  }
10101
+ if (this._isVectorChart()) this._prepareVector();
8557
10102
 
8558
10103
  this._prepareAlphaField();
8559
10104
  }
@@ -8647,6 +10192,36 @@
8647
10192
  _resolveAggregateValue(props) { return resolveAggregateValue(this.binding, this.flags, props); }
8648
10193
 
8649
10194
  _buildAggregationIndex(sourceFeatures) {
10195
+ // an AGGREGATE / CATEGORICAL pie: one bucket, each record carrying its
10196
+ // contribution to its item (flatPieRecord), added up per position or
10197
+ // grid cell by aggregateOnGrid (flatPieAccumulate)
10198
+ if (this._isAggregatedPie()) {
10199
+ const t = this.flags.typeString != null ? this.flags.typeString : [...this.flags].join('|');
10200
+ const fields = this._pieCategorical ? null : String(this.binding.value || '').split('|');
10201
+ const rules = flatValueRules(t, fields ? fields.length : 1);
10202
+ const sizeField = this.binding.size && this.binding.size !== '$item$' ? this.binding.size : null;
10203
+ const feats = [];
10204
+ for (const f of sourceFeatures) {
10205
+ const c = f.geometry && f.geometry.coordinates;
10206
+ if (!c || typeof c[0] !== 'number' || !isFinite(c[0]) || !isFinite(c[1])) continue;
10207
+ const p = f.properties || {};
10208
+ let cat;
10209
+ if (this._pieCategorical) {
10210
+ const k = this._pieCategoryKey(p[this.binding.value]);
10211
+ const i = k == null ? undefined : this._pieCategoryIndex.get(k);
10212
+ cat = i == null ? -1 : i;
10213
+ }
10214
+ const pie = flatPieRecord(p, { rules, sizeField, cat, fields });
10215
+ if (!pie) continue;
10216
+ feats.push({ type: 'Feature', geometry: f.geometry, properties: { value: pie.size, pie, raw: p } });
10217
+ }
10218
+ this._featuresByCategory = [feats];
10219
+ this._clusterIndices = null;
10220
+ this._clusterRadiusPx = null;
10221
+ this._aggregateStatsCache = null;
10222
+ this._pieStatsCache = null;
10223
+ return;
10224
+ }
8650
10225
  // Range-classed (non-CATEGORICAL numeric) coloring: DON'T pre-split
8651
10226
  // by class here — the real engine aggregates every record together
8652
10227
  // first and classes color from the resulting CELL TOTAL afterward
@@ -8829,7 +10404,10 @@
8829
10404
  this._timeMin = undefined;
8830
10405
  const facetFilters = this.facetFilters, runtimeFilterExpr = this._runtimeFilterExpr, timeFrame = this._timeFrame;
8831
10406
  this.binding = this._specBinding;
8832
- this.features = applyField100(filterFlatValues(this._withDensity(fc.features), this.binding, this.flags), this.binding, this.flags, this.style);
10407
+ // VECTOR / BEZIER keep every record: flat checks their values only
10408
+ // when it aggregates them (flatVectorItems)
10409
+ const withDensity = this._withDensity(fc.features);
10410
+ this.features = applyField100(isVectorChart(this.flags) ? withDensity : filterFlatValues(withDensity, this.binding, this.flags), this.binding, this.flags, this.style);
8833
10411
  this.binding = field100Binding(this.binding, this.flags);
8834
10412
  this._iconCache.clear();
8835
10413
  this._glowIconCache.clear();
@@ -8872,10 +10450,14 @@
8872
10450
  }
8873
10451
 
8874
10452
  _usesAggregationIndex() {
10453
+ // AGGREGATE / CATEGORICAL pies aggregate their records into items
10454
+ // (see _buildAggregationIndex)
10455
+ if (this._isAggregatedPie()) return true;
8875
10456
  // GRIDSIZE bins its own grid (_ensureGridIndex) — PLOT curves and the
8876
10457
  // plain mesh; a classed GRIDSIZE symbol chart is aggregated like any
8877
10458
  // other (see _isPlainGridMesh)
8878
- return (this.flags.has('AGGREGATE') || isSymbolChart(this.flags))
10459
+ // VECTOR / BEZIER aggregate by their positions (flatVectorItems)
10460
+ return (this.flags.has('AGGREGATE') || isSymbolChart(this.flags)) && !isVectorChart(this.flags)
8879
10461
  && (!this.flags.has('GRIDSIZE') || (!this.flags.has('PLOT') && !this._isPlainGridMesh()));
8880
10462
  }
8881
10463
 
@@ -9160,7 +10742,15 @@
9160
10742
  return this._glowIconCache.get(key);
9161
10743
  }
9162
10744
 
9163
- _buildBubbleIcon(counts, colors, marked = null, shapes = null) {
10745
+ // look (optional, a CATEGORICAL BUBBLE's flat look — see bubbleIconLook):
10746
+ // every part filled at look.fillOpacity and stroked like its own flat
10747
+ // bubble (look.stroke, else its color × 0.7; look.strokeOpacity), the
10748
+ // width look.width canvas px for a part of the icon's full radius,
10749
+ // √(share of it) for a smaller one (flat's width ∝ √r) — OUTLINE:
10750
+ // ∝ min(1, r / normal radius), look.outlineRatio being the full
10751
+ // radius / normal radius. Without it the older look: 0.9 fill, a 1 px
10752
+ // white stroke (the SYMBOL branch's packed icon).
10753
+ _buildBubbleIcon(counts, colors, marked = null, shapes = null, look = null) {
9164
10754
  // The cache key quantizes each count's RATIO to the group's total,
9165
10755
  // not the raw count — computeBubblePackLayout's radii are purely
9166
10756
  // sqrt(c/total), so two clusters with wildly different absolute
@@ -9190,7 +10780,8 @@
9190
10780
  // coarse share buckets that can put a small marked part at 0
9191
10781
  const key = counts.map(c => Math.round((c / total) * RATIO_BUCKETS)).join('-') + (marked
9192
10782
  ? '|iso:' + counts.map((c, i) => (c > 0 && marked.has(i) ? i + ':' + Math.round(Math.sqrt(c / total) * 100) : null)).filter(Boolean).join(',')
9193
- : '') + (shapes ? '|sym:' + shapes.map(v => String(v).toLowerCase()).join(',') : '');
10783
+ : '') + (shapes ? '|sym:' + shapes.map(v => String(v).toLowerCase()).join(',') : '')
10784
+ + (look ? `|fb:${look.fillOpacity}:${look.strokeOpacity}:${look.stroke ? look.stroke.join(',') : look.noStroke ? 'none' : 'low'}:${look.width}${look.outlineRatio != null ? ':' + look.outlineRatio : ''}` : '');
9194
10785
  if (this._iconCache.has(key)) return this._iconCache.get(key);
9195
10786
  const size = BUBBLE_ICON_SIZE;
9196
10787
  const canvas = document.createElement('canvas');
@@ -9205,13 +10796,33 @@
9205
10796
  // _resolveSymbolShape; circles when the name isn't one GL draws
9206
10797
  const shape = shapes ? normalizeSymbolShape(shapes[Math.min(p.i, shapes.length - 1)]) : 'circle';
9207
10798
  const px = cx + offsets[i].x * fitScale, py = cy + offsets[i].y * fitScale;
10799
+ let pr = radii[i] * fitScale, lw = 0;
10800
+ if (look && !look.noStroke) {
10801
+ const k = pr / (size / 2);
10802
+ lw = Math.min(pr, look.outlineRatio != null
10803
+ ? look.width * Math.min(1, k * look.outlineRatio) / Math.min(1, look.outlineRatio)
10804
+ : look.width * Math.sqrt(k));
10805
+ // inside the part, as _buildSingleIcon: the icon's edge is the bubble's
10806
+ pr = Math.max(0.5, pr - lw / 2);
10807
+ }
9208
10808
  ctx.beginPath();
9209
- if (shape === 'circle') ctx.arc(px, py, radii[i] * fitScale, 0, Math.PI * 2);
9210
- else drawSymbolPath(ctx, shape, px, py, radii[i] * fitScale);
10809
+ if (shape === 'circle') ctx.arc(px, py, pr, 0, Math.PI * 2);
10810
+ else drawSymbolPath(ctx, shape, px, py, pr);
9211
10811
  ctx.closePath();
9212
10812
  ctx.fillStyle = `rgb(${colors[p.i].join(',')})`;
9213
- ctx.globalAlpha = 0.9; ctx.fill(); ctx.globalAlpha = 1;
9214
- ctx.lineWidth = 1; ctx.strokeStyle = '#fff'; ctx.stroke();
10813
+ if (!look) {
10814
+ ctx.globalAlpha = 0.9; ctx.fill(); ctx.globalAlpha = 1;
10815
+ ctx.lineWidth = 1; ctx.strokeStyle = '#fff'; ctx.stroke();
10816
+ return;
10817
+ }
10818
+ ctx.globalAlpha = look.fillOpacity; ctx.fill();
10819
+ if (lw > 0) {
10820
+ ctx.globalAlpha = look.strokeOpacity;
10821
+ ctx.lineWidth = lw;
10822
+ ctx.strokeStyle = `rgb(${(look.stroke || flatDerivateRgb(colors[p.i], 0.7)).join(',')})`;
10823
+ ctx.stroke();
10824
+ }
10825
+ ctx.globalAlpha = 1;
9215
10826
  });
9216
10827
  const icon = { url: canvas.toDataURL(), width: size, height: size, anchorX: size / 2, anchorY: size / 2, id: key };
9217
10828
  return this._cacheIcon(key, icon);
@@ -9240,10 +10851,12 @@
9240
10851
  // cached bubble icon here already accepts; the border scales with
9241
10852
  // the bubble exactly as the fill already does, which reads as
9242
10853
  // reasonable rather than wrong.
9243
- _buildSingleIcon(colorRgb, opacity, shape, borderColorRgb, borderWidth) {
10854
+ // borderOpacity (default 1): the border's own alpha (flat's
10855
+ // stroke-opacity, see flatBubbleLook)
10856
+ _buildSingleIcon(colorRgb, opacity, shape, borderColorRgb, borderWidth, borderOpacity = 1) {
9244
10857
  shape = shape || 'circle';
9245
10858
  borderWidth = borderWidth || 0;
9246
- const key = `single-${shape}-${colorRgb.join(',')}-${opacity}-${borderColorRgb ? borderColorRgb.join(',') : 'none'}-${borderWidth}`;
10859
+ const key = `single-${shape}-${colorRgb.join(',')}-${opacity}-${borderColorRgb ? borderColorRgb.join(',') : 'none'}-${borderWidth}${borderOpacity !== 1 ? `-so${borderOpacity}` : ''}`;
9247
10860
  if (this._iconCache.has(key)) return this._iconCache.get(key);
9248
10861
  const size = BUBBLE_ICON_SIZE;
9249
10862
  const canvas = document.createElement('canvas');
@@ -9265,7 +10878,7 @@
9265
10878
  ctx.globalAlpha = shape === 'empty' ? 0 : opacity;
9266
10879
  ctx.fill();
9267
10880
  if (borderWidth > 0 && borderColorRgb) {
9268
- ctx.globalAlpha = 1;
10881
+ ctx.globalAlpha = borderOpacity;
9269
10882
  ctx.strokeStyle = `rgb(${borderColorRgb.join(',')})`;
9270
10883
  ctx.lineWidth = borderWidth;
9271
10884
  ctx.stroke();
@@ -9363,18 +10976,35 @@
9363
10976
  local: {}
9364
10977
  };
9365
10978
 
10979
+ // a VECTOR / BEZIER flow (measured on the twin page): no item chart,
10980
+ // label or class; its value is flat's nValuesA[0] — with CATEGORICAL
10981
+ // the category's number (1 for the first), else the (summed) value
10982
+ // field; its count the records of the item
10983
+ if (this._isVectorChart() && props.vector) {
10984
+ const v = props.vector.itemValue;
10985
+ dataObj.theme.chart = dataObj.theme.item.chart = '';
10986
+ dataObj.theme.item.value = v == null || isNaN(v) ? '' : flatFormatValue(v, explicitValueDecimals(this.style) ?? (Math.abs(v) < 1 ? 2 : 0), 'ROUND');
10987
+ dataObj.theme.item.label = dataObj.theme.item.class = '';
10988
+ dataObj.theme.item.count = props.count;
10989
+ }
10990
+
9366
10991
  // bare {{FIELD}}, {{raw.FIELD}}, {{local.FIELD}} — only meaningful
9367
- // for a single unaggregated record (see class comment above)
9368
- if (!isGroup && props.raw) {
9369
- Object.keys(props.raw).forEach(field => {
10992
+ // for a single unaggregated record (see class comment above); an
10993
+ // aggregated pie's item gives its first record's, as flat (data[0])
10994
+ const fieldsRaw = isGroup ? props.firstRaw : props.raw;
10995
+ if (fieldsRaw) {
10996
+ Object.keys(fieldsRaw).forEach(field => {
9370
10997
  if (field === 'geometry') return;
9371
- const v = props.raw[field];
10998
+ const v = fieldsRaw[field];
9372
10999
  if (this._isNumericValue(v)) {
9373
11000
  dataObj[field] = this._formatTooltipValue(v);
9374
11001
  dataObj.raw[field] = v;
9375
11002
  } else {
9376
11003
  dataObj[field] = v;
9377
- dataObj.local[field] = v;
11004
+ // translated, as flat's tooltip (tooltip_mustache.js 446:
11005
+ // ixmaps.getLocalString(value)) — the migration page's ISO
11006
+ // code → country name in {{local.from}} → {{local.to}}
11007
+ dataObj.local[field] = makeLocalText([])(v);
9378
11008
  }
9379
11009
  });
9380
11010
  }
@@ -9660,8 +11290,21 @@
9660
11290
  // USER charts (a page's own chart function, style.userdraw) go the
9661
11291
  // chart pipeline too, drawn by _buildUserChartLayers (bubbles when the
9662
11292
  // page has no such function)
11293
+ // VECTOR / BEZIER (flat's chartMap draws them before it calls
11294
+ // drawChart, maptheme.js 18148, 18376)
11295
+ // PIE / DONUT (flat's PIE branch comes before BUBBLE and SYMBOL in
11296
+ // drawChart, maptheme.js 19939)
11297
+ if (this._isVectorChart()) return this._buildVectorLayers(zoom, bbox, liveZoom, globeCenter);
11298
+ if (this._isPieChart()) return this._buildPieLayers(zoom, bbox, liveZoom, globeCenter);
11299
+ // BAR / BARS (flat's BAR branch, after BUBBLE and SYMBOL: a type
11300
+ // with those is not a bar chart, see isBarChart)
11301
+ if (this._isBarChart()) return this._buildBarLayers(zoom, bbox, liveZoom, globeCenter);
9663
11302
  if (isSymbolChart(this.flags)) return this._buildChartLayers(zoom, bbox, liveZoom, globeCenter);
9664
- console.warn(`[ixmaps-gl] layer "${this.name}": type "${[...this.flags].join('|')}" has no implemented renderer`);
11303
+ // once per theme, not on every redraw
11304
+ if (!this._warnedNoRenderer) {
11305
+ this._warnedNoRenderer = true;
11306
+ console.warn(`[ixmaps-gl] layer "${this.name}": type "${[...this.flags].join('|')}" has no implemented renderer`);
11307
+ }
9665
11308
  return [];
9666
11309
  }
9667
11310
 
@@ -10022,6 +11665,118 @@
10022
11665
  _itemPlotFields() {
10023
11666
  return String(this.binding.value || '').split('|');
10024
11667
  }
11668
+ // CHART|PIE (and PIE|DONUT): one pie per record, its slices the value
11669
+ // fields; AGGREGATE and CATEGORICAL pies one pie per item — the records
11670
+ // of a position (or grid cell) — its slices the categories or the value
11671
+ // fields (_isAggregatedPie, _buildPieLayers)
11672
+ _isVectorChart() { return isVectorChart(this.flags); }
11673
+ // CHART|BAR / BARS: a bar chart per record or item, its bars a pie's
11674
+ // parts (_buildBarLayers)
11675
+ _isBarChart() { return isBarChart(this.flags); }
11676
+ _isPieChart() {
11677
+ return !isVectorChart(this.flags) && this.flags.has('CHART') && this.flags.has('PIE') && !this.flags.has('DOT');
11678
+ }
11679
+ // a pie whose items flat aggregates (loadAndAggregateValuesOfTheme):
11680
+ // AGGREGATE, or CATEGORICAL (a part per category — flat sums the
11681
+ // records of an item's position by category even without AGGREGATE)
11682
+ // (a bar chart's items too)
11683
+ _isAggregatedPie() {
11684
+ return (this._isPieChart() || this._isBarChart()) && (this.flags.has('AGGREGATE') || this.flags.has('CATEGORICAL'));
11685
+ }
11686
+ // a per-item chart whose parts are its value fields (legend rows, sums)
11687
+ _isMultiFieldChart() {
11688
+ return !!this._multiFields && (this._isMultiFieldSequence() || this._isPieChart() || this._isBarChart());
11689
+ }
11690
+ // flat's theme statistics for a pie (maptheme.js 9119-9130): nMin/nMax
11691
+ // over every part value of every item, the classes (partsA, flat's
11692
+ // distributeValues over that range, as many as the colorscheme has
11693
+ // colors) — flat colors a 1-part pie (or any with CLASSES) by them
11694
+ // (maptheme.js 20330-20341); several parts are colored by field.
11695
+ _preparePie() {
11696
+ if (this.flags.has('CATEGORICAL')) { this._preparePieCategories(); return; }
11697
+ this._pieCategorical = false;
11698
+ const fields = String(this.binding.value).split('|');
11699
+ const pooled = [];
11700
+ for (const f of this.features) {
11701
+ for (const k of fields) { const v = flatNumber(f.properties[k], this.flags); if (isFinite(v)) pooled.push(v); }
11702
+ }
11703
+ let nMin = Infinity, nMax = -Infinity;
11704
+ for (const v of pooled) { if (v < nMin) nMin = v; if (v > nMax) nMax = v; }
11705
+ const nParts = Math.trunc(styleNum(this.style.classes)) || colorSchemeClassCount(this.style.colorscheme) || DEFAULT_RANGE_CLASSES;
11706
+ if (fields.length > 1) {
11707
+ Object.assign(this, computeMultiFieldClasses(this.binding, this.style));
11708
+ this.categoryIndexByLabel = new Map(this.categoryLabels.map((v, i) => [v, i]));
11709
+ const scheme = Array.isArray(this.style.colorscheme) ? this.style.colorscheme : [];
11710
+ this._partNoFill = new Set(fields.map((f, i) => i).filter(i => String(scheme[i]).trim().toLowerCase() === 'none'));
11711
+ } else {
11712
+ const r = computeRangeClasses(this.features, this.binding, this.style, this.flags, v => this._formatTooltipValue(v));
11713
+ if (r) { Object.assign(this, r); this._rangeClassed = false; }
11714
+ this._multiFields = null;
11715
+ this._partNoFill = new Set();
11716
+ }
11717
+ this._pieFields = fields;
11718
+ if (pooled.length) {
11719
+ this._valueMin = nMin;
11720
+ this._valueMax = nMax;
11721
+ this.partsA = flatRangeParts(pooled, nMin, nMax, nParts, this.flags, styleNum(this.style.rangecentervalue));
11722
+ }
11723
+ }
11724
+
11725
+ // a CATEGORICAL pie's categories (flat's getStringValueIndex, maptheme.js
11726
+ // 9204-9243): style.values in their order, else the values in the order
11727
+ // the records bring them; a value is trimmed (IGNORECASE: upper case),
11728
+ // an empty one is none (UNDEFINEDISVALUE: "undefined"). A slice per
11729
+ // category in its colorscheme color, labeled by style.label. flat's
11730
+ // classes are the category numbers (partsA: i + 1, the last one 0.001
11731
+ // wider) — its value range (nMin / nMax) follows the items
11732
+ // (_pieAggregateStats).
11733
+ _preparePieCategories() {
11734
+ const st = this.style;
11735
+ const t = this.flags.typeString != null ? this.flags.typeString : [...this.flags].join('|');
11736
+ const ignoreCase = /IGNORECASE/.test(t), undefinedIsValue = /UNDEFINEDISVALUE/.test(t);
11737
+ const key = v => {
11738
+ let k = v == null ? '' : String(v);
11739
+ if (k.length === 0 || k === ' ') { if (!undefinedIsValue) return null; k = 'undefined'; }
11740
+ if (ignoreCase) k = k.toUpperCase();
11741
+ return k.trim();
11742
+ };
11743
+ const index = new Map();
11744
+ const explicit = Array.isArray(st.values) ? st.values : null;
11745
+ const labels = [];
11746
+ if (explicit) {
11747
+ explicit.forEach(v => { const k = key(v); if (k != null && !index.has(k)) { index.set(k, labels.length); labels.push(String(v).trim()); } });
11748
+ } else {
11749
+ for (const f of this.features) {
11750
+ const k = key(f.properties[this.binding.value]);
11751
+ if (k != null && !index.has(k)) { index.set(k, labels.length); labels.push(k); }
11752
+ }
11753
+ }
11754
+ this._pieCategorical = true;
11755
+ this._pieExplicitValues = !!explicit;
11756
+ this._pieCategoryKey = key;
11757
+ this._pieCategoryIndex = index;
11758
+ this.categoryLabels = labels;
11759
+ const explicitLabel = Array.isArray(st.label) ? st.label.map(String) : null;
11760
+ this.categoryDisplayLabels = explicitLabel && explicitLabel.length === labels.length ? explicitLabel : labels;
11761
+ this.categoryColorsRgb = resolveClassColors(st.colorscheme, this.categoryDisplayLabels, st.classes);
11762
+ // the legend and the facets look a record's raw value up
11763
+ const byLabel = new Map();
11764
+ for (const f of this.features) {
11765
+ const v = f.properties[this.binding.value];
11766
+ if (byLabel.has(v)) continue;
11767
+ const k = key(v);
11768
+ if (k != null && index.has(k)) byLabel.set(v, index.get(k));
11769
+ }
11770
+ labels.forEach((l, i) => { if (!byLabel.has(l)) byLabel.set(l, i); });
11771
+ this.categoryIndexByLabel = byLabel;
11772
+ const n = labels.length;
11773
+ this.partsA = labels.map((l, i) => ({ min: i + 1, max: i + 1 + (i === n - 1 ? 0.001 : 0) + 1e-9 }));
11774
+ this._rangeClassed = false;
11775
+ this._multiFields = null;
11776
+ this._pieFields = null;
11777
+ this._partNoFill = new Set();
11778
+ }
11779
+
10025
11780
  // x-axis labels of a per-item PLOT (style.xaxis / label, else the fields)
10026
11781
  _itemPlotLabels() {
10027
11782
  const fields = this._itemPlotFields();
@@ -11152,8 +12907,7 @@
11152
12907
  const combined = individual.concat(groups).sort((a, b) => sizeValueOf(a) - sizeValueOf(b));
11153
12908
  // maxcharts: like the flat engine (maptheme.js nMaxCharts), draw only the
11154
12909
  // biggest N items — combined is ascending, so drop from the front
11155
- const maxCharts = Math.round(styleNum(this.style.maxcharts)) || 0;
11156
- if (maxCharts > 0 && combined.length > maxCharts) combined.splice(0, combined.length - maxCharts);
12910
+ applyFlatMaxCharts(combined, this.style);
11157
12911
  if (this.flags.has('USER')) {
11158
12912
  const userLayers = this._buildUserChartLayers(combined, liveZoom, sizeValueOf);
11159
12913
  if (userLayers) return layers.concat(userLayers);
@@ -11434,17 +13188,94 @@
11434
13188
  // fillopacity and linecolor, as a single item (flat draws one
11435
13189
  // symbol) — the packed multi-category icon is for CATEGORICAL cells
11436
13190
  const singleSymbol = d => !d.properties.counts || (this._rangeClassed && d.properties.cat != null);
11437
- layers.push(new IconLayer({
13191
+ // a CATEGORICAL BUBBLE (flat's BUBBLE branch): every bubble — a single
13192
+ // one and each part of a packed one — filled and stroked as flat's,
13193
+ // see flatBubbleLook; the icon carries both opacities
13194
+ const flatLook = flatChartBranch(this.flags) === 'bubble' && this.flags.has('CATEGORICAL')
13195
+ ? d => bubbleIconLook(radiusOf(d), objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(this.style.scale) || 1), this.style, this.flags, BUBBLE_ICON_SIZE)
13196
+ : null;
13197
+ const singleIcon = d => {
13198
+ const rgb = (this.categoryColorsRgb || [])[d.properties.cat];
13199
+ if (!flatLook) return this._buildSingleIcon(rgb, fillOpacity, shapeOf(d.properties), singleBorderColorRgb, singleBorderWidthPx);
13200
+ const look = flatLook(d);
13201
+ return this._buildSingleIcon(rgb, look.fillOpacity, shapeOf(d.properties),
13202
+ look.noStroke || !rgb ? null : (look.stroke || flatDerivateRgb(rgb, 0.7)), look.width, look.strokeOpacity);
13203
+ };
13204
+ // flat's symbol outline (maptheme.js 21595-21610 and 21950-21997,
13205
+ // drawn 21625-21634 / 21995-22035): every SYMBOL shape is stroked,
13206
+ // (linewidth || 1) · normalX(min(2r / rMax, 0.2)) with rMax the
13207
+ // normal radius normalX(30 / 2) — on screen min(2r / 15, 0.2 unit)
13208
+ // × linewidth, centered on the outline like any SVG stroke. Its
13209
+ // color: linecolor; NOLINES or linecolor "none" none; a CATEGORICAL
13210
+ // theme without AGGREGATE/GROUP the symbol's own color (white: gray),
13211
+ // every other symbol chart the darker ChartColors.textColor of its
13212
+ // class color. BUBBLE keeps its linecolor/linewidth border, a width
13213
+ // in icon pixels
13214
+ const symbolOutline = flatChartBranch(this.flags) === 'symbol';
13215
+ const outlineRgb = d => {
13216
+ if (!symbolOutline) return singleBorderColorRgb && singleBorderWidthPx > 0 ? singleBorderColorRgb : null;
13217
+ if (singleBorderColorRgb) return singleBorderColorRgb;
13218
+ if (flatFlag(this.flags, 'NOLINES') || (this.style.linecolor && styleLineColor(this.style.linecolor) === 'none')) return null;
13219
+ const fill = (this.categoryColorsRgb || [])[d.properties.cat] || [128, 128, 128];
13220
+ if (perPartBranch) return flatChartTextRgb(fill);
13221
+ return fill.slice(0, 3).every(c => c >= 255) ? [128, 128, 128] : fill;
13222
+ };
13223
+ const outlinePx = (d, r) => (!outlineRgb(d) ? 0 : symbolOutline
13224
+ ? (styleNum(this.style.linewidth) || 1) * Math.min(2 * r / NORMAL_RADIUS_PX, 0.2 * unitPx)
13225
+ : singleBorderWidthPx * r * 2 / BUBBLE_ICON_SIZE);
13226
+ // only single circles (no group icon, no other shape, no pixel
13227
+ // offset — ScatterplotLayer has none): drawn as real circles. A
13228
+ // cached circle icon shrunk to a few device pixels samples a coarse
13229
+ // mip level of the atlas and reads as a square, where flat's SVG
13230
+ // circle of the same radius stays round. Its own id: deck.gl matches
13231
+ // layers by id alone, so a theme switch turning the IconLayer of
13232
+ // this id into a ScatterplotLayer kept the icon layer's state and
13233
+ // drew nothing (the fuel-price page's station points); hover/click
13234
+ // routing goes by the final id (layerRuntimeById). A symbol's outline
13235
+ // is centered on its radius (flat); BUBBLE's border keeps the
13236
+ // icon's look, inside the radius
13237
+ if (combined.length && !alignOf.active && combined.every(d => singleSymbol(d) && shapeOf(d.properties) === 'circle')) {
13238
+ const radiusPx = d => vRadius(sizeValueOf(d));
13239
+ // a CATEGORICAL BUBBLE: flat's look (flatBubbleLook) in screen px,
13240
+ // its stroke centered on the radius as flat's SVG circle
13241
+ const lookUnit = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(this.style.scale) || 1);
13242
+ const circleLook = flatLook ? d => flatBubbleLook((this.categoryColorsRgb || [])[d.properties.cat] || [128, 128, 128], radiusPx(d), lookUnit, this.style, this.flags) : null;
13243
+ const lineRgb = d => (circleLook ? circleLook(d).stroke : outlineRgb(d)) || [0, 0, 0];
13244
+ layers.push(new ScatterplotLayer({
13245
+ id: `ix-bubbles-${this.name}-circles-g${this._iconGeneration}`,
13246
+ data: combined, pickable: true,
13247
+ getPosition: d => d.geometry.coordinates,
13248
+ getRadius: d => radiusPx(d) - (symbolOutline || circleLook ? 0 : outlinePx(d, radiusPx(d)) / 2),
13249
+ radiusUnits: 'pixels',
13250
+ getFillColor: d => [...((this.categoryColorsRgb || [])[d.properties.cat] || [128, 128, 128]).slice(0, 3), Math.round(this._iconAlpha(d) * (circleLook ? circleLook(d).fillOpacity : fillOpacity))],
13251
+ stroked: circleLook ? combined.some(d => circleLook(d).stroke) : combined.some(d => outlineRgb(d)),
13252
+ getLineColor: d => [...lineRgb(d).slice(0, 3), Math.round(this._iconAlpha(d) * (circleLook ? circleLook(d).strokeOpacity : 1))],
13253
+ getLineWidth: d => (circleLook ? (circleLook(d).stroke ? circleLook(d).width : 0) : outlinePx(d, radiusPx(d))),
13254
+ lineWidthUnits: 'pixels',
13255
+ billboard: true,
13256
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
13257
+ }));
13258
+ } else layers.push(new IconLayer({
11438
13259
  id: `ix-bubbles-${this.name}-g${this._iconGeneration}`,
11439
13260
  data: combined, pickable: true,
11440
13261
  getPosition: d => d.geometry.coordinates,
11441
- getIcon: d => !singleSymbol(d)
11442
- ? this._buildBubbleIcon(d.properties.counts, this.categoryColorsRgb, this._groupIsolation(d), Array.isArray(this.style.symbols) ? this.style.symbols : null)
11443
- : this._buildSingleIcon((this.categoryColorsRgb || [])[d.properties.cat], fillOpacity, shapeOf(d.properties), singleBorderColorRgb, singleBorderWidthPx),
13262
+ getIcon: d => {
13263
+ if (!singleSymbol(d)) return this._buildBubbleIcon(d.properties.counts, this.categoryColorsRgb, this._groupIsolation(d), Array.isArray(this.style.symbols) ? this.style.symbols : null, flatLook && flatLook(d));
13264
+ if (flatLook) return singleIcon(d);
13265
+ const shape = shapeOf(d.properties), rgb = outlineRgb(d);
13266
+ if (!symbolOutline || !rgb) return this._buildSingleIcon((this.categoryColorsRgb || [])[d.properties.cat], fillOpacity, shape, rgb, rgb ? singleBorderWidthPx : 0);
13267
+ // the outline in the icon's own pixels, inside its edge; quarter
13268
+ // pixel steps keep the icon cache small. flat's "empty" strokes
13269
+ // #888888 whatever the class color
13270
+ const r = vRadius(sizeValueOf(d));
13271
+ const w = Math.round(outlinePx(d, r) * BUBBLE_ICON_SIZE / Math.max(1e-6, 2 * r) * 4) / 4;
13272
+ return this._buildSingleIcon((this.categoryColorsRgb || [])[d.properties.cat], fillOpacity, shape, shape === 'empty' ? [136, 136, 136] : rgb, w);
13273
+ },
11444
13274
  getSize: d => vRadius(sizeValueOf(d)) * 2,
11445
13275
  // group icons bake a fixed 0.9 alpha; an explicit fillopacity scales it
11446
13276
  // so a near-transparent theme doesn't show opaque 1-px-cell clusters
11447
- getColor: d => [255, 255, 255, Math.round(this._iconAlpha(d) * (!singleSymbol(d) && this.style.fillopacity != null ? fillOpacity : 1))],
13277
+ // (a flat-look icon carries its own fill and stroke opacity)
13278
+ getColor: d => [255, 255, 255, Math.round(this._iconAlpha(d) * (!flatLook && !singleSymbol(d) && this.style.fillopacity != null ? fillOpacity : 1))],
11448
13279
  ...(alignOf.active ? { getPixelOffset: alignOf } : {}),
11449
13280
  sizeUnits: 'pixels',
11450
13281
  billboard: true,
@@ -11478,8 +13309,8 @@
11478
13309
  // VALUES: bold value label centered on each bubble (see
11479
13310
  // flatValueText/valuesFontSizePx above). Labels are their own
11480
13311
  // layers, added last in the array so they draw on top of every icon
11481
- // layer (a small bubble's label can still show over a bigger
11482
- // neighboring bubble's icon — a known, accepted gap). Entries whose
13312
+ // layer; a label under later-drawn charts is faded or dropped by
13313
+ // _occludeChartTexts, as flat's later chart groups cover it. Entries whose
11483
13314
  // computed font size would be sub-pixel are dropped rather than
11484
13315
  // rendered at getSize: 0, matching the real engine's own "too
11485
13316
  // small to bother" gate.
@@ -11488,7 +13319,11 @@
11488
13319
  const textOpts = this._valueTextOpts();
11489
13320
  const pointValueOf = valueTextOf || this._chartValueTextFn();
11490
13321
 
13322
+ // draw order of each chart (see chartTextTransmittance below); an
13323
+ // item maxcharts left out draws no chart, so no text either
13324
+ const orderOf = new Map(combined.map((d, k) => [d, k]));
11491
13325
  const pointLabels = individual.reduce((out, d) => {
13326
+ if (!orderOf.has(d)) return out;
11492
13327
  const radius = vRadius(d.properties.value);
11493
13328
  // flat's VALUES label prints the value (nValuesA), not the size;
11494
13329
  // its font by flat's chart branch (_chartValueTextFn)
@@ -11496,7 +13331,7 @@
11496
13331
  if (fontSize > VALUES_MIN_FONT_PX) {
11497
13332
  const o = alignOf(d);
11498
13333
  out.push({ geometry: d.geometry, text, fontSize, bold, color: resolveTextColor(this.style, contrastTextColor((this.categoryColorsRgb || [])[d.properties.cat])),
11499
- pixelOffset: baseline ? [o[0], o[1] + baseline - 0.35 * fontSize] : o });
13334
+ pixelOffset: baseline ? [o[0], o[1] + baseline - 0.35 * fontSize] : o, order: orderOf.get(d) });
11500
13335
  }
11501
13336
  return out;
11502
13337
  }, []);
@@ -11512,6 +13347,7 @@
11512
13347
  // would misrepresent every category's own count as one summed
11513
13348
  // number nobody's individual bubble actually shows.
11514
13349
  const groupLabels = groups.reduce((out, d) => {
13350
+ if (!orderOf.has(d)) return out;
11515
13351
  const counts = d.properties.counts;
11516
13352
  const outerRadiusPx = vRadius(d.properties.total);
11517
13353
  const iconSizePx = outerRadiusPx * 2; // matches the cluster IconLayer's own getSize (*2) below
@@ -11526,7 +13362,7 @@
11526
13362
  const o = alignOf(d);
11527
13363
  out.push({ geometry: d.geometry, text: v.text, fontSize: v.fontSize, bold: v.bold,
11528
13364
  color: resolveTextColor(this.style, contrastTextColor((this.categoryColorsRgb || [])[d.properties.cat])),
11529
- pixelOffset: v.baseline ? [o[0], o[1] + v.baseline - 0.35 * v.fontSize] : o });
13365
+ pixelOffset: v.baseline ? [o[0], o[1] + v.baseline - 0.35 * v.fontSize] : o, order: orderOf.get(d) });
11530
13366
  }
11531
13367
  return out;
11532
13368
  }
@@ -11541,13 +13377,26 @@
11541
13377
  out.push({
11542
13378
  geometry: d.geometry, text, fontSize,
11543
13379
  color: resolveTextColor(this.style, contrastTextColor((this.categoryColorsRgb || [])[p.i])),
11544
- pixelOffset: [offsets[i].x * fitScale * pxPerCanvasUnit + alignOf(d)[0], offsets[i].y * fitScale * pxPerCanvasUnit + alignOf(d)[1]]
13380
+ pixelOffset: [offsets[i].x * fitScale * pxPerCanvasUnit + alignOf(d)[0], offsets[i].y * fitScale * pxPerCanvasUnit + alignOf(d)[1]],
13381
+ order: orderOf.get(d)
11545
13382
  });
11546
13383
  }
11547
13384
  });
11548
13385
  return out;
11549
13386
  }, []);
11550
13387
 
13388
+ // a text under later charts fades by what still shows through them
13389
+ // (chartTextTransmittance), as flat's per-chart SVG groups paint
13390
+ // each later symbol over it; nearly hidden ones are dropped. (Flat's
13391
+ // later GLOW circles veil it too, but they veil the earlier symbols
13392
+ // as well, and here every glow lies under every symbol — a text
13393
+ // fading under a glow its own symbol isn't under would read wrong.)
13394
+ const visible = this._occludeChartTexts(combined, pointLabels.concat(groupLabels), {
13395
+ liveZoom, globeCenter, alignOf, radiusOf, singleSymbol, shapeOf, fillOpacity, singleBorderWidthPx
13396
+ });
13397
+ const keepVisible = labels => labels.filter(l => visible.has(l));
13398
+ const shownPointLabels = keepVisible(pointLabels), shownGroupLabels = keepVisible(groupLabels);
13399
+
11551
13400
  const textLayerCommonProps = {
11552
13401
  pickable: false,
11553
13402
  getPosition: d => d.geometry.coordinates,
@@ -11565,10 +13414,10 @@
11565
13414
  getAlignmentBaseline: 'center'
11566
13415
  };
11567
13416
  // flat's SYMBOL branch writes its values in normal weight
11568
- if (pointLabels.length) layers.push(new TextLayer({ id: `ix-points-values-${this.name}`, data: pointLabels, getPixelOffset: d => d.pixelOffset, ...textLayerCommonProps,
11569
- fontWeight: pointLabels[0].bold === false ? 'normal' : 'bold' }));
11570
- if (groupLabels.length) layers.push(new TextLayer({ id: `ix-cluster-values-${this.name}`, data: groupLabels, getPixelOffset: d => d.pixelOffset, ...textLayerCommonProps,
11571
- fontWeight: groupLabels[0].bold === false ? 'normal' : 'bold' }));
13417
+ if (shownPointLabels.length) layers.push(new TextLayer({ id: `ix-points-values-${this.name}`, data: shownPointLabels, getPixelOffset: d => d.pixelOffset, ...textLayerCommonProps,
13418
+ fontWeight: shownPointLabels[0].bold === false ? 'normal' : 'bold' }));
13419
+ if (shownGroupLabels.length) layers.push(new TextLayer({ id: `ix-cluster-values-${this.name}`, data: shownGroupLabels, getPixelOffset: d => d.pixelOffset, ...textLayerCommonProps,
13420
+ fontWeight: shownGroupLabels[0].bold === false ? 'normal' : 'bold' }));
11572
13421
  }
11573
13422
 
11574
13423
  // LABEL: flat's chart label (maptheme.js 24533-24590) — a small text
@@ -11792,6 +13641,1007 @@
11792
13641
  return layers;
11793
13642
  }
11794
13643
 
13644
+ // CHART|PIE / DONUT — real ixmaps-flat's PIE branch (maptheme.js
13645
+ // 19939-20533, piechart.js DonutChart): one pie per record at its
13646
+ // position (joined to its FEATURE shape by binding.lookup), a slice per
13647
+ // value field (pieSliceLayout), drawn by PieSliceLayer (see there why
13648
+ // not as icons).
13649
+ // - radius (flat's nSize in chart units, 15 = NORMAL_RADIUS_PX, × the
13650
+ // object zoom and style.scale): with a type word containing SIZE
13651
+ // (not NORMSIZE) and a size field 15 · (size / max size)^(1/sizepow)
13652
+ // — max size: normalsizevalue, else the field's maximum (flat's
13653
+ // nMaxSize) —, SIZE without a size field 15 · (sum / (nMax − nMin))
13654
+ // ^(1/sizepow) — the pie's sum against the range of the part values
13655
+ // (normalsizevalue replaces that range) —, SIZELOG 15 · log(v) /
13656
+ // log(max), at least 1; otherwise 15. A pie of radius 0 (or no
13657
+ // number) is not drawn.
13658
+ // - colors: a field's colorscheme color; a 1-field pie (or CLASSES)
13659
+ // takes its value's class color (flat's partsA, maptheme.js
13660
+ // 20330-20341); a "none" color fills nothing and strokes the pie in
13661
+ // linecolor, else black; AUTOCOMPLETE's rest in nodatacolor (#eeeeee)
13662
+ // - fill opacity fillopacity (flat: the chart group's fill-opacity,
13663
+ // default 1); opacity sets fill and outline
13664
+ // - outline #555566 (NOLINES none, WHITELINES white, linecolor wins),
13665
+ // (linewidth, default 0.1) · R / 15 px wide — flat's
13666
+ // normalX(linewidth) · R / 300 with normalX(1) = 20 chart units
13667
+ // (measured on the twin page): a hairline at the default
13668
+ // - CENTER (not DONUT): the center part's disc, 0.9 × the inner
13669
+ // radius, in its color, with GLOW two halos (6× at 0.05, 2× at 0.1
13670
+ // fill opacity); its value in bold in the middle with CENTERVALUE or
13671
+ // VALUES: min(0.8 r, 3.3 r / length) · valuescale, valuedecimals
13672
+ // (else 0), the unit up to 5 characters, ChartColors.textColor of
13673
+ // the part color
13674
+ // - DONUT|CENTERVALUE: the item's size value in the middle, min(0.5 r,
13675
+ // 2 r / length), valuecolor / textcolor, else #888888, over a 0.5
13676
+ // black halo
13677
+ // - VALUES (not STACKED, within valueupper/lower): several parts (or
13678
+ // NOINLINETEXT) a label per slice outside the pie on a leader line
13679
+ // (pieValueLabelLayout) — font 5u · valuescale, #999999 (textcolor)
13680
+ // on a white box at 0.5 (VALUEBACKGROUND: the slice color), leader
13681
+ // lines #888888 0.5u over a white 1.5u at 0.2; a single part its
13682
+ // value inside the pie, min(0.8 R, 3.4 R / length), 0.1 R above the
13683
+ // center. Texts: valuedecimals, else 2 below 1 and 0 above, plus the
13684
+ // unit.
13685
+ // - draw order: flat's sort before drawing (by the size value, the
13686
+ // sum or the first value; the biggest on top)
13687
+ // - AGGREGATE / CATEGORICAL (_isAggregatedPie): a pie per item — the
13688
+ // records of a position, or of an aggregation grid cell —
13689
+ // (_pieAggregatedItems; flatPieRecord / flatPieAccumulate /
13690
+ // flatPieItemValues), a slice per category (CATEGORICAL, in its
13691
+ // category color) or per value field; its size the item's summed
13692
+ // size field (flat's nSize, every record of the item counted), the
13693
+ // theme's nMin / nMax / nMaxSize those of the items
13694
+ // (_pieAggregateStats); draw order by the item's size with a size
13695
+ // field or AGGREGATE|SUM; COUNT prints a part's record count;
13696
+ // CENTERVALUE the item's record count
13697
+ // Not drawn here (a console note at type parsing, PIE_INERT_FLAGS,
13698
+ // PIE_AGGREGATE_INERT_FLAGS):
13699
+ // 3D, VOLUME, HEIGHT, STARBURST, FLOWER/RAYS, BOW, ZOOM, DIRECTION /
13700
+ // DIRECTED, POLAR — the pie is drawn flat and plain instead. Nor
13701
+ // style.clipparts, showparts, gridx (flat's several pies per item).
13702
+ // Known divergence: flat draws a chart's value labels in the chart's
13703
+ // own group, so a bigger pie drawn later covers a smaller neighbour's
13704
+ // labels; here every label lies above every pie.
13705
+ // Known divergence: the leader lines are drawn in map coordinates
13706
+ // (pixel offsets converted at the current zoom, Web Mercator), so under
13707
+ // the globe projection they are approximate.
13708
+ _buildPieLayers(zoom, bbox, liveZoom = zoom, globeCenter = null) {
13709
+ const st = this.style;
13710
+ const t = this.flags.typeString != null ? this.flags.typeString : [...this.flags].join('|');
13711
+ const has = re => re.test(t);
13712
+ const agg = this._isAggregatedPie();
13713
+ const cat = !!this._pieCategorical;
13714
+ const fields = cat ? null : (this._pieFields || String(this.binding.value || '').split('|'));
13715
+ const multi = !cat && fields.length > 1;
13716
+ // AGGREGATE / CATEGORICAL: the statistics of the items (sets
13717
+ // _valueMin / _valueMax), their biggest size flat's nMaxSize
13718
+ const stats = agg ? this._pieAggregateStats(zoom) : null;
13719
+ const unit = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(st.scale) || 1);
13720
+ const sizePow = resolveSizePow(st, this.flags);
13721
+ // flat tests /SIZE/: also NOSIZE and GRIDSIZE
13722
+ const sizeFlag = has(/SIZE/) && !has(/NORMSIZE/);
13723
+ const sizeLog = has(/SIZELOG/);
13724
+ const nsv = styleNum(st.normalsizevalue);
13725
+ const maxSize = nsv || (stats ? stats.maxSize : this._maxSizeValue);
13726
+ const range = nsv || ((this._valueMax || 0) - (this._valueMin || 0));
13727
+ const nSizeOf = (sum, size) => {
13728
+ const n = NORMAL_RADIUS_PX;
13729
+ if (sizeFlag && this.binding.size) {
13730
+ return sizeLog ? Math.max(1, n / Math.log(maxSize) * Math.log(size)) : n / Math.pow(maxSize, 1 / sizePow) * Math.pow(size, 1 / sizePow);
13731
+ }
13732
+ if (sizeFlag) {
13733
+ return sizeLog ? Math.max(1, n / Math.log(range) * Math.log(sum)) : n / Math.pow(range, 1 / sizePow) * Math.pow(sum, 1 / sizePow);
13734
+ }
13735
+ return n;
13736
+ };
13737
+ const colors = this.categoryColorsRgb || [];
13738
+ const scheme = Array.isArray(st.colorscheme) ? st.colorscheme : [];
13739
+ const noFill = this._partNoFill || new Set();
13740
+ const byClass = has(/CLASSES/) || (!cat && !multi);
13741
+ // flat (maptheme.js 20327-20336): a pie of one part (or CLASSES) takes
13742
+ // the color of the class its value falls in — CATEGORICAL: the class
13743
+ // whose number equals the value (sl.single: the item's only part)
13744
+ const classColor = v => {
13745
+ const parts = this.partsA || [];
13746
+ for (let x = 0; x < parts.length; x++) if (cat ? v === parts[x].min : v < parts[x].max) return colors[x];
13747
+ return null;
13748
+ };
13749
+ const sliceRgb = sl => (sl.complement ? (parseCssColor(String(st.nodatacolor || '')) || [0xee, 0xee, 0xee])
13750
+ : ((byClass || sl.single) && classColor(sl.value)) || colors[sl.i] || [128, 128, 128]);
13751
+ const fo = styleNum(st.fillopacity), op = styleNum(st.opacity);
13752
+ // flat sets the group's fill-opacity only for a truthy value
13753
+ const fillAlpha = op > 0 ? op : fo > 0 ? fo : 1;
13754
+ const lineAlpha = op > 0 ? op : 1;
13755
+ let line = '#555566';
13756
+ if (has(/NOLINES/)) line = 'none';
13757
+ if (has(/WHITELINES/)) line = 'white';
13758
+ const lc = st.linecolor != null && st.linecolor !== '' ? styleLineColor(st.linecolor) : null;
13759
+ if (lc) line = lc;
13760
+ if (!cat && scheme.slice(0, fields.length).some(c => String(c).trim().toLowerCase() === 'none')) line = lc || 'black';
13761
+ const lineRgb = line === 'none' ? null : hexOrNamedToRgb(line);
13762
+ const lineWidthOf = r => (lineRgb ? (styleNum(st.linewidth) || 0.1) * r / 15 : 0);
13763
+ const lineRgba = lineRgb ? [...lineRgb.slice(0, 3), Math.round(255 * lineAlpha)] : [0, 0, 0, 0];
13764
+
13765
+ const charts = [];
13766
+ if (agg) {
13767
+ // one pie per item (_pieAggregatedItems), its parts and size as flat
13768
+ // has them after aggregation (flatPieItemValues)
13769
+ for (const f of this._pieAggregatedItems(zoom, bbox)) {
13770
+ const c = f.geometry.coordinates;
13771
+ if (globeCenter && !isOnVisibleHemisphere(c[0], c[1], globeCenter)) continue;
13772
+ const pie = f.properties.pie;
13773
+ const { parts: values, size } = this._pieItemValues(pie);
13774
+ const layout = pieSliceLayout(values, t, { centerPart: st.centerpart, itemCount: pie.n });
13775
+ if (!layout) continue;
13776
+ if (cat) layout.slices.forEach(sl => { sl.single = values.length === 1; });
13777
+ const sum = values.reduce((a, v) => a + (isNaN(v) ? 0 : v), 0);
13778
+ const r = nSizeOf(sum, size) * unit;
13779
+ if (!(r > 0) || !isFinite(r)) continue;
13780
+ // flat's draw-order key (maptheme.js 16812-16860): with a size
13781
+ // field or AGGREGATE|SUM the item's size, else its first part
13782
+ const key = this.binding.size || (has(/AGGREGATE/) && has(/SUM/)) ? size : values[0];
13783
+ // the tooltip: a row per part (counts), its value the parts' sum
13784
+ // (flat: the sum of nValuesA), its records (theme.item.data, the
13785
+ // page's own {{field}}s from the first, as flat's data[0])
13786
+ const raws = f.properties.raws || (f.properties.raw ? [f.properties.raw] : []);
13787
+ const properties = { counts: values, total: sum, recordCounts: [pie.n], raws, firstRaw: raws[0] || null,
13788
+ titleRaw: f.properties.titleRaw || f.properties.raw || null };
13789
+ charts.push({ c, r, layout, size, count: pie.n, partCounts: pie.counts, nParts: values.length, key, properties });
13790
+ }
13791
+ }
13792
+ for (const f of agg ? [] : this._activeFeatures || this.features) {
13793
+ const c = f.geometry && f.geometry.coordinates;
13794
+ if (!c || typeof c[0] !== 'number') continue;
13795
+ const [lng, lat] = c;
13796
+ if (lng < bbox[0] || lng > bbox[2] || lat < bbox[1] || lat > bbox[3]) continue;
13797
+ if (globeCenter && !isOnVisibleHemisphere(lng, lat, globeCenter)) continue;
13798
+ const raw = f.properties || {};
13799
+ const values = fields.map(k => { const v = flatNumber(raw[k], this.flags); return isNaN(v) ? 0 : v; });
13800
+ const layout = pieSliceLayout(values, t, { centerPart: st.centerpart });
13801
+ if (!layout) continue;
13802
+ const sum = values.reduce((a, v) => a + v, 0);
13803
+ const size = this.binding.size ? parseFloat(raw[this.binding.size]) : undefined;
13804
+ const r = nSizeOf(sum, size) * unit;
13805
+ if (!(r > 0) || !isFinite(r)) continue;
13806
+ // flat's draw-order key (maptheme.js 16812-16860): the size value,
13807
+ // else with SIZE the sum, else the first value
13808
+ const key = this.binding.size ? size : has(/SIZE/) ? sum : values[0];
13809
+ // the tooltip's {{theme.item.chart}}: a row per field (parts), a
13810
+ // 1-field pie its class (cat) like a range-classed bubble
13811
+ const properties = multi ? { value: values[0], parts: values, raw }
13812
+ : { value: values[0], cat: this.partsA ? this._resolvePartsClass(values[0]) : 0, raw };
13813
+ charts.push({ c, r, layout, size, nParts: values.length, key, properties });
13814
+ }
13815
+ // flat sorts the charts before drawing (maptheme.js 16774-16870),
13816
+ // ascending — the last drawn on top —, SORT|UP descending; not with
13817
+ // NOSRT, NOSIZE (unless SORT) or when all values are equal, nor a
13818
+ // CATEGORICAL pie without AGGREGATE, size field or SORT
13819
+ if (((this._valueMin !== this._valueMax) || this.binding.size) && !has(/NOSRT/)
13820
+ && (!has(/NOSIZE/) || has(/\bSORT\b/))
13821
+ && (!has(/CATEGORICAL/) || has(/AGGREGATE/) || this.binding.size || has(/\bSORT\b/))) {
13822
+ const down = has(/\bSORT\b/) && has(/\bUP\b/);
13823
+ charts.sort((a, b) => (down ? b.key - a.key : a.key - b.key) || 0);
13824
+ }
13825
+ applyFlatMaxCharts(charts, st);
13826
+ // align / offsetx / offsety (flatChartAlignOffset) — flat's PIE
13827
+ // ptNull is (0, r + 5 units); the chart's half size taken as r, as
13828
+ // for BUBBLE (ASSUMPTION: not measured on flat for PIE)
13829
+ const aligned = !!(st.align || styleNum(st.offsetx) || styleNum(st.offsety));
13830
+ charts.forEach(ch => {
13831
+ ch.off = aligned ? flatChartAlignOffset(st, { unit, symbolScale: 1, half: ch.r, orig: ch.r + 5 * unit }) : [0, 0];
13832
+ });
13833
+
13834
+ const TAU = 2 * Math.PI;
13835
+ const slices = [], centers = [], glows = [];
13836
+ const glowOn = has(/GLOW/) && !has(/ZOOM/) && !glowHiddenByScale(st, liveZoom);
13837
+ for (const ch of charts) {
13838
+ for (const sl of ch.layout.slices) {
13839
+ const rgb = sliceRgb(sl);
13840
+ slices.push({
13841
+ position: ch.c, r: ch.r, off: ch.off, properties: ch.properties, chart: ch, part: sl,
13842
+ slice: [sl.start * Math.PI / 180, sl.sweep >= 360 ? TAU : sl.sweep * Math.PI / 180, ch.layout.inner],
13843
+ fill: [...rgb.slice(0, 3), !sl.complement && noFill.has(sl.i) ? 0 : Math.round(255 * fillAlpha)]
13844
+ });
13845
+ }
13846
+ const center = ch.layout.center;
13847
+ if (center) {
13848
+ const cr = ch.r * Math.sqrt(center.size / 100);
13849
+ ch.centerR = cr;
13850
+ const rgb = colors[center.i] || [128, 128, 128];
13851
+ 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)] });
13852
+ if (glowOn && cr > 0) {
13853
+ glows.push({ position: ch.c, r: cr * 6, off: ch.off, fill: [...rgb.slice(0, 3), Math.round(255 * 0.05)] });
13854
+ glows.push({ position: ch.c, r: cr * 2, off: ch.off, fill: [...rgb.slice(0, 3), Math.round(255 * 0.1)] });
13855
+ }
13856
+ }
13857
+ }
13858
+ const discProps = {
13859
+ getPosition: d => d.position,
13860
+ getRadius: d => d.r,
13861
+ getPixelOffset: d => d.off,
13862
+ getFillColor: d => d.fill,
13863
+ radiusUnits: 'pixels', billboard: true, stroked: false, filled: true, antialiasing: true,
13864
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
13865
+ };
13866
+ const layers = [new PieSliceLayer(Object.assign({}, discProps, {
13867
+ id: `ix-pie-${this.name}`,
13868
+ data: slices, pickable: true,
13869
+ getLineColor: lineRgba,
13870
+ getSlice: d => d.slice,
13871
+ getSliceLineWidth: d => lineWidthOf(d.r)
13872
+ }))];
13873
+ if (centers.length) {
13874
+ layers.push(new PieSliceLayer(Object.assign({}, discProps, { id: `ix-pie-center-${this.name}`, data: centers, pickable: true })));
13875
+ }
13876
+ if (glows.length) {
13877
+ layers.push(new PieSliceLayer(Object.assign({}, discProps, { id: `ix-pie-glow-${this.name}`, data: glows, pickable: false })));
13878
+ }
13879
+ return layers.concat(this._buildPieTextLayers(charts, zoom, liveZoom, unit, sliceRgb, has));
13880
+ }
13881
+
13882
+ // the items of an AGGREGATE / CATEGORICAL pie within bbox: its records
13883
+ // (_buildAggregationIndex) added up per item by aggregateOnGrid — with
13884
+ // AGGREGATE on the theme's aggregation grid (gridwidth, aggregation …,
13885
+ // see _ensureClusterIndices; none: per position), else per position
13886
+ // (flat sums a CATEGORICAL theme's records of one shape by category
13887
+ // without AGGREGATE too). Each item's properties.pie: flatPieAccumulate's.
13888
+ _pieAggregatedItems(zoom, bbox) {
13889
+ if (!this._featuresByCategory) return [];
13890
+ if (this.flags.has('AGGREGATE')) {
13891
+ this._ensureClusterIndices(zoom);
13892
+ return this._clusterIndices[0].getClusters(bbox, this._clusterUsesFixedZoom ? GRIDWIDTH_METERS_REFERENCE_ZOOM : zoom);
13893
+ }
13894
+ const feats = this._featuresByCategory[0];
13895
+ if (!this._pieExactIndex || this._pieExactIndex.features !== feats) {
13896
+ this._pieExactIndex = new GridAggregateIndex(null, this.flags, undefined, null).load(feats);
13897
+ }
13898
+ return this._pieExactIndex.getClusters(bbox, zoom);
13899
+ }
13900
+
13901
+ // an aggregated pie item's parts (a CATEGORICAL pie with style.values:
13902
+ // one per category) and size (flatPieItemValues)
13903
+ _pieItemValues(pie) {
13904
+ const t = this.flags.typeString != null ? this.flags.typeString : [...this.flags].join('|');
13905
+ const nParts = this._pieCategorical ? (this._pieExplicitValues ? this.categoryLabels.length : null) : (this._pieFields || []).length;
13906
+ return flatPieItemValues(pie, t, { categorical: !!this._pieCategorical, nParts });
13907
+ }
13908
+
13909
+ // flat's statistics of an aggregated pie theme (maptheme.js
13910
+ // 11180-11241), over every item (at this zoom when a grid aggregates):
13911
+ // nMin / nMax over the items' sizes (CATEGORICAL or a size field), else
13912
+ // over their parts; nMaxSize the biggest size; the classes of a
13913
+ // value-field pie (partsA) over the items' parts, as for a per-record
13914
+ // pie (_preparePie). Sets the theme's _valueMin / _valueMax / partsA.
13915
+ _pieAggregateStats(zoom) {
13916
+ const feats = this._featuresByCategory && this._featuresByCategory[0];
13917
+ const zkey = this.flags.has('AGGREGATE') ? zoom : null;
13918
+ const cache = this._pieStatsCache;
13919
+ if (cache && cache.feats === feats && cache.zoom === zkey) return cache;
13920
+ let nMin = Infinity, nMax = -Infinity, maxSize = -Infinity;
13921
+ const pooled = [];
13922
+ const bySize = !!this._pieCategorical || !!(this.binding.size && this.binding.size !== '$item$');
13923
+ for (const f of this._pieAggregatedItems(zoom, [-180, -90, 180, 90])) {
13924
+ const { parts, size } = this._pieItemValues(f.properties.pie);
13925
+ if (isFinite(size) && size > maxSize) maxSize = size;
13926
+ for (const v of parts) if (isFinite(v)) pooled.push(v);
13927
+ if (bySize) {
13928
+ if (isFinite(size)) { if (size < nMin) nMin = size; if (size > nMax) nMax = size; }
13929
+ } else {
13930
+ for (const v of parts) if (isFinite(v)) { if (v < nMin) nMin = v; if (v > nMax) nMax = v; }
13931
+ }
13932
+ }
13933
+ if (nMin <= nMax) {
13934
+ this._valueMin = nMin;
13935
+ this._valueMax = nMax;
13936
+ if (!this._pieCategorical && pooled.length) {
13937
+ const nParts = Math.trunc(styleNum(this.style.classes)) || colorSchemeClassCount(this.style.colorscheme) || DEFAULT_RANGE_CLASSES;
13938
+ this.partsA = flatRangeParts(pooled, nMin, nMax, nParts, this.flags, styleNum(this.style.rangecentervalue));
13939
+ // a 1-field pie's legend rows are these classes (in place, as
13940
+ // _ensureAggregateStats: the legend holds the array)
13941
+ if (!this._multiFields && Array.isArray(this.categoryLabels)) {
13942
+ this.categoryLabels.splice(0, this.categoryLabels.length, ...this.partsA.map(p => `${this._formatTooltipValue(p.min)} - ${this._formatTooltipValue(p.max)}`));
13943
+ }
13944
+ }
13945
+ }
13946
+ this._pieStatsCache = { feats, zoom: zkey, maxSize: maxSize > -Infinity ? maxSize : undefined };
13947
+ return this._pieStatsCache;
13948
+ }
13949
+
13950
+ // the value texts of _buildPieLayers (see there)
13951
+ _buildPieTextLayers(charts, zoom, liveZoom, unit, sliceRgb, has) {
13952
+ const st = this.style;
13953
+ const valueScale = styleNum(st.valuescale) || 1;
13954
+ const units = st.units ? String(st.units) : '';
13955
+ const unitText = units ? (units[0] === '.' ? '' : ' ') + units : '';
13956
+ const partUnit = (has(/AUTOCOMPLETE/) && !/%/.test(unitText) ? ' % ' : '') + unitText;
13957
+ const noBreaks = flatNoBreaks(this._valueMin, this._valueMax);
13958
+ const dec = explicitValueDecimals(st);
13959
+ const fmt = (v, d) => flatFormatValue(v, d, noBreaks ? 'ROUND|NOBREAKS' : 'ROUND');
13960
+ // COUNT on an aggregated pie: the part's record count (flat's nCountA)
13961
+ const countText = has(/COUNT/);
13962
+ const partText = (sl, ch) => {
13963
+ if (sl.complement) return Math.round(sl.percent * 10) / 10 + ' % ';
13964
+ const v = countText && ch && ch.partCounts ? ch.partCounts[sl.i] || 0 : sl.value;
13965
+ return fmt(v, dec != null ? dec : (v < 1 ? 2 : 0)) + partUnit;
13966
+ };
13967
+ const valuesOn = has(/VALUES/) && !has(/STACKED/) && !valuesHiddenByScale(st, zoom);
13968
+ const textOverride = st.valuecolor || st.textcolor;
13969
+ const inline = [], boxed = [], centerTexts = [], lines = [];
13970
+ for (const ch of charts) {
13971
+ const o = ch.off;
13972
+ // CENTER: the center part's value (maptheme.js 20402-20410)
13973
+ if (ch.layout.center && (has(/CENTERVALUE/) || (has(/VALUES/) && !valuesHiddenByScale(st, zoom))) && ch.centerR > 0) {
13974
+ const cv = ch.layout.center.value;
13975
+ const text = fmt(cv, dec != null ? dec : 0) + (unitText.length <= 5 ? unitText : '');
13976
+ const fontSize = Math.min(ch.centerR * 0.8, ch.centerR * (3.3 / Math.max(1, text.length))) * valueScale;
13977
+ const rgb = (this.categoryColorsRgb || [])[ch.layout.center.i] || [128, 128, 128];
13978
+ if (fontSize > VALUES_MIN_FONT_PX) centerTexts.push({ position: ch.c, text, fontSize, off: o, color: flatChartTextRgb(rgb), bold: true });
13979
+ } else if (!ch.layout.center && has(/CENTERVALUE/) && (ch.count || (ch.size !== undefined && !isNaN(ch.size)))) {
13980
+ // flat prints String(nCount || nSize): a record's item has no
13981
+ // nCount, so its size value; an aggregated item its record count
13982
+ const text = String(ch.count || ch.size);
13983
+ const fontSize = Math.min(ch.r * 0.5, ch.r * (2 / Math.max(1, text.length)));
13984
+ if (fontSize > VALUES_MIN_FONT_PX) {
13985
+ centerTexts.push({ position: ch.c, text, fontSize, off: o, bold: false,
13986
+ color: textOverride ? hexOrNamedToRgb(textOverride) : [0x88, 0x88, 0x88] });
13987
+ }
13988
+ }
13989
+ if (!valuesOn) continue;
13990
+ if (ch.nParts > 1 || has(/NOINLINETEXT/)) {
13991
+ const fontSize = 5 * unit * valueScale;
13992
+ if (fontSize <= VALUES_MIN_FONT_PX) continue;
13993
+ for (const lab of pieValueLabelLayout(ch.layout.slices, ch.r, fontSize, unit, (this.partsA || []).length)) {
13994
+ const bg = has(/VALUEBACKGROUND/) ? sliceRgb(lab.slice) : [255, 255, 255];
13995
+ const textRgb = textOverride ? hexOrNamedToRgb(textOverride) : flatDerivateRgb(bg, bg[0] + bg[1] + bg[2] > 450 ? 0.6 : 3);
13996
+ boxed.push({ position: ch.c, text: partText(lab.slice, ch), fontSize, anchor: lab.anchor,
13997
+ // the text's baseline → its center (arial: 0.35 font above)
13998
+ off: [o[0] + lab.x, o[1] + lab.y - 0.35 * fontSize], color: textRgb, bg: [...bg.slice(0, 3), 128] });
13999
+ lab.segments.forEach(sg => lines.push({ c: ch.c, o, sg }));
14000
+ }
14001
+ } else if (ch.layout.slices.length) {
14002
+ const sl = ch.layout.slices[ch.layout.slices.length - 1];
14003
+ const text = partText(sl, ch);
14004
+ const fontSize = Math.min(ch.r * 0.8, ch.r * (3.4 / Math.max(1, text.length)));
14005
+ const color = textOverride ? hexOrNamedToRgb(textOverride) : flatChartTextRgb(sliceRgb(sl));
14006
+ // baseline at −0.6 R + R / 2 + 0.45 font (maptheme.js 20434)
14007
+ if (fontSize > VALUES_MIN_FONT_PX) inline.push({ position: ch.c, text, fontSize, off: [o[0], o[1] - 0.1 * ch.r + 0.45 * fontSize - 0.35 * fontSize], color });
14008
+ }
14009
+ }
14010
+ const layers = [];
14011
+ const textBase = {
14012
+ getPosition: d => d.position, getText: d => d.text, getSize: d => d.fontSize,
14013
+ getPixelOffset: d => d.off, getColor: d => d.color,
14014
+ sizeUnits: 'pixels', fontFamily: 'arial', characterSet: 'auto',
14015
+ getAlignmentBaseline: 'center', pickable: false
14016
+ };
14017
+ if (lines.length) {
14018
+ const feature = d => ({ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: [
14019
+ pixelOffsetLngLat(d.c, [d.o[0] + d.sg[0], d.o[1] + d.sg[1]], liveZoom),
14020
+ pixelOffsetLngLat(d.c, [d.o[0] + d.sg[2], d.o[1] + d.sg[3]], liveZoom)] } });
14021
+ const fc = { type: 'FeatureCollection', features: lines.map(feature) };
14022
+ [['bg', [255, 255, 255, Math.round(255 * 0.2)], 1.5], ['fg', [0x88, 0x88, 0x88, 255], 0.5]].forEach(([k, color, w]) => {
14023
+ layers.push(new GeoJsonLayer({
14024
+ id: `ix-pie-lines-${k}-${this.name}`, data: fc, pickable: false,
14025
+ stroked: true, filled: false, getLineColor: color, getLineWidth: w * unit, lineWidthUnits: 'pixels'
14026
+ }));
14027
+ });
14028
+ }
14029
+ if (boxed.length) {
14030
+ layers.push(new TextLayer(Object.assign({}, textBase, {
14031
+ id: `ix-pie-values-${this.name}`, data: boxed, fontWeight: 'normal',
14032
+ getTextAnchor: d => d.anchor,
14033
+ // flat's box: from 0.3 font left of the text to 0.4 font right of
14034
+ // it, 0.1 font taller (createTextLabel)
14035
+ background: true, getBackgroundColor: d => d.bg, backgroundBorderRadius: 0.1 * 5 * unit * valueScale,
14036
+ backgroundPadding: [0.3, 0, 0.4, 0.1].map(k => k * 5 * unit * valueScale)
14037
+ })));
14038
+ }
14039
+ if (inline.length) {
14040
+ layers.push(new TextLayer(Object.assign({}, textBase, { id: `ix-pie-inline-${this.name}`, data: inline, fontWeight: 'normal', getTextAnchor: 'middle' })));
14041
+ }
14042
+ // CENTER's value in bold; DONUT|CENTERVALUE's in normal weight over
14043
+ // flat's thin black halo (stroke-opacity 0.5)
14044
+ const bold = centerTexts.filter(d => d.bold), plain = centerTexts.filter(d => !d.bold);
14045
+ if (bold.length) {
14046
+ layers.push(new TextLayer(Object.assign({}, textBase, { id: `ix-pie-centervalue-${this.name}`, data: bold, getTextAnchor: 'middle', fontWeight: 'bold' })));
14047
+ }
14048
+ if (plain.length) {
14049
+ layers.push(new TextLayer(Object.assign({}, textBase, {
14050
+ id: `ix-pie-centercount-${this.name}`, data: plain, getTextAnchor: 'middle', fontWeight: 'normal',
14051
+ fontSettings: { sdf: true }, outlineWidth: 1, outlineColor: [0, 0, 0, 128]
14052
+ })));
14053
+ }
14054
+ return layers;
14055
+ }
14056
+
14057
+ // CHART|BAR / BARS (_isBarChart) — flat's bar chart per item
14058
+ // (flatBarLayout, maptheme.js drawChart's BAR branch): its items and
14059
+ // theme statistics as a pie's (one per record, or AGGREGATE /
14060
+ // CATEGORICAL items via _pieAggregatedItems / _pieAggregateStats), a bar
14061
+ // per value field or category:
14062
+ // - size: SIZE by the size field (or the values' sum) with sizepow, the
14063
+ // width chart size / parts · 0.66, the length value · 30 u / nMax
14064
+ // (normalsizevalue, else the theme's max) · rangescale, with SIZE
14065
+ // and several parts / sizer (SIZEP4: sizer²), COMPRESS / EXPAND,
14066
+ // POINTER · 1.2
14067
+ // - HORZ: the bars to the right, the first on top; else upward side
14068
+ // by side; STACKED: one column; SORT, UP, CENTER, SPACED, THIN /
14069
+ // THICK, NOZERO, NONEGATIVE, ONLYNEGATIVE, LEFT
14070
+ // - colors: the part's (categories, fields), a single part or
14071
+ // SEQUENCE the value's class; outline linecolor (black) at
14072
+ // linewidth · 0.25 (0.2 u) and opacity 1 (0.1 without linewidth);
14073
+ // a negative bar fill 0.3, outlined in its color; fillopacity
14074
+ // - POINTER / ARROW (SMALLARROW drawn as ARROW): flat's pointer path,
14075
+ // negative values pointing the other way (fadenegative fill, the
14076
+ // outline lighter or darker), SIZE: its width by the value;
14077
+ // OFFSETMEAN / OFFSETMEDIAN / DEVIATION: the value's deviation from
14078
+ // the part's mean / median (in %) or in standard deviations
14079
+ // - VALUES: the value (valuedecimals, else 1 decimal with a max ≤ 1)
14080
+ // and units, POINTER a "+", STACKED its share; beyond the bar's end
14081
+ // (in a white box unless HORZ; VALUEBACKGROUND / CTEXT: the bar's
14082
+ // color), inside it when it fits (without SIZE, THIN, DTEXT …),
14083
+ // STACKED beside the column on leader lines; flat's values color
14084
+ // #798697 (textcolor), negative red (valuecolor); DTEXT and a sized
14085
+ // POINTER fade small values
14086
+ // - BOX / TITLE (flatChartBox): a box around chart and title, the
14087
+ // item's title above it — boxcolor (white) at boxopacity, bordercolor
14088
+ // (#bbbbbb) at 0.3, borderwidth · 0.2 · w / 30; title 5 u ·
14089
+ // textscale in textcolor (#888888) over a white halo
14090
+ // - draw order: flat's (_buildPieLayers); each chart's box under its
14091
+ // bars, a later chart over an earlier one
14092
+ // Not drawn (BAR_INERT_FLAGS, a console note): see flatBarLayout.
14093
+ // Known divergence: every value text lies above every bar (flat draws
14094
+ // them in the chart's own group); the leader lines are drawn in map
14095
+ // coordinates (approximate on the globe).
14096
+ _buildBarLayers(zoom, bbox, liveZoom = zoom, globeCenter = null) {
14097
+ const st = this.style;
14098
+ const t = this.flags.typeString != null ? this.flags.typeString : [...this.flags].join('|');
14099
+ const has = re => re.test(t);
14100
+ const agg = this._isAggregatedPie();
14101
+ const cat = !!this._pieCategorical;
14102
+ const fields = cat ? null : (this._pieFields || String(this.binding.value || '').split('|'));
14103
+ const stats = agg ? this._pieAggregateStats(zoom) : null;
14104
+ const unit = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(st.scale) || 1);
14105
+ const sizeField = !!(this.binding.size && this.binding.size !== '$item$');
14106
+ const nsv = styleNum(st.normalsizevalue);
14107
+ const colors = this.categoryColorsRgb || [];
14108
+ const num = v => { const x = styleNum(v); return isNaN(x) ? undefined : x; };
14109
+ const measure = (text, font) => titleTextWidth(text, font);
14110
+ // the charts' values (and, for OFFSETMEAN …, every item's)
14111
+ const items = [];
14112
+ const itemValues = f => {
14113
+ if (agg) {
14114
+ const { parts, size } = this._pieItemValues(f.properties.pie);
14115
+ return { values: parts, size };
14116
+ }
14117
+ const raw = f.properties || {};
14118
+ return { values: fields.map(k => flatNumber(raw[k], this.flags)), size: sizeField ? parseFloat(raw[this.binding.size]) : undefined };
14119
+ };
14120
+ const all = agg ? this._pieAggregatedItems(zoom, [-180, -90, 180, 90]) : (this._activeFeatures || this.features);
14121
+ // flat's per-part mean, median (getMeanMedianQuantile) and standard
14122
+ // deviation over every item
14123
+ let means, medians, devs;
14124
+ if (has(/OFFSETMEAN|OFFSETMEDIAN|DEVIATION/)) {
14125
+ const cache = this._barStatsCache;
14126
+ if (cache && cache.all === all && (!agg || cache.zoom === zoom)) ({ means, medians, devs } = cache);
14127
+ else {
14128
+ const cols = [];
14129
+ for (const f of all) itemValues(f).values.forEach((v, k) => { (cols[k] ||= []).push(v); });
14130
+ means = cols.map(c => c.reduce((a, v) => a + v, 0) / c.length);
14131
+ medians = cols.map(c => c.slice().sort((a, b) => a - b)[Math.round(c.length / 2)]);
14132
+ devs = cols.map((c, k) => Math.sqrt(c.reduce((a, v) => a + (v - means[k]) * (v - means[k]), 0) / c.length));
14133
+ this._barStatsCache = { all, zoom, means, medians, devs };
14134
+ }
14135
+ }
14136
+ const lo = {
14137
+ themeMin: this._valueMin, themeMax: this._valueMax,
14138
+ maxSize: stats ? stats.maxSize : this._maxSizeValue, sizeField,
14139
+ normalSizeValue: nsv > 0 ? nsv : undefined, sizePow: resolveSizePow(st, this.flags),
14140
+ rangeScale: num(st.rangescale), valueScale: num(st.valuescale), units: st.units ? String(st.units) : '',
14141
+ valueDecimals: explicitValueDecimals(st), noBreaks: flatNoBreaks(this._valueMin, this._valueMax),
14142
+ gridX: num(st.gridx), nClasses: (this.partsA || []).length, classMax: (this.partsA || []).map(p => p.max),
14143
+ nColors: colors.length, fillOpacity: num(st.fillopacity), lineWidth: num(st.linewidth),
14144
+ fadeNegative: num(st.fadenegative), fadeValuePow: num(st.fadevaluepow),
14145
+ textColor: st.textcolor || undefined, valueColor: st.valuecolor || undefined,
14146
+ hideValues: valuesHiddenByScale(st, zoom), means, medians, devs, measure
14147
+ };
14148
+ const inView = c => c[0] >= bbox[0] && c[0] <= bbox[2] && c[1] >= bbox[1] && c[1] <= bbox[3]
14149
+ && (!globeCenter || isOnVisibleHemisphere(c[0], c[1], globeCenter));
14150
+ const charts = [];
14151
+ for (const f of agg ? this._pieAggregatedItems(zoom, bbox) : all) {
14152
+ const c = f.geometry && f.geometry.coordinates;
14153
+ if (!c || typeof c[0] !== 'number' || !inView(c)) continue;
14154
+ const { values, size } = itemValues(f);
14155
+ const layout = flatBarLayout(values, t, Object.assign({ itemSize: size }, lo));
14156
+ if (!layout || (!layout.bars.length && !layout.texts.length)) continue;
14157
+ const sum = values.reduce((a, v) => a + (isFinite(v) ? v : 0), 0);
14158
+ let properties;
14159
+ if (agg) {
14160
+ const pie = f.properties.pie;
14161
+ const raws = f.properties.raws || (f.properties.raw ? [f.properties.raw] : []);
14162
+ properties = { counts: values, total: sum, recordCounts: [pie.n], raws, firstRaw: raws[0] || null,
14163
+ titleRaw: f.properties.titleRaw || f.properties.raw || null };
14164
+ } else {
14165
+ const raw = f.properties || {};
14166
+ properties = values.length > 1 ? { value: values[0], parts: values, raw }
14167
+ : { value: values[0], cat: this.partsA ? this._resolvePartsClass(values[0]) : 0, raw };
14168
+ }
14169
+ // flat's draw-order key (as for pies, maptheme.js 16812-16860)
14170
+ const key = agg ? (this.binding.size || (has(/AGGREGATE/) && has(/SUM/)) ? size : values[0])
14171
+ : (this.binding.size ? size : has(/SIZE/) ? sum : values[0]);
14172
+ charts.push({ c, layout, key, properties });
14173
+ }
14174
+ if (((this._valueMin !== this._valueMax) || this.binding.size) && !has(/NOSRT/)
14175
+ && (!has(/NOSIZE/) || has(/\bSORT\b/))
14176
+ && (!has(/CATEGORICAL/) || has(/AGGREGATE/) || this.binding.size || has(/\bSORT\b/))) {
14177
+ const down = has(/\bSORT\b/) && has(/\bUP\b/);
14178
+ charts.sort((a, b) => (down ? b.key - a.key : a.key - b.key) || 0);
14179
+ }
14180
+ applyFlatMaxCharts(charts, st);
14181
+
14182
+ const boxShown = flatFlag(this.flags, 'BOX') && !boxHiddenByScale(st, liveZoom);
14183
+ const titleShown = boxShown && flatFlag(this.flags, 'TITLE') && !!this.binding.title;
14184
+ const titleFont = 5 * (num(st.textscale) || num(st.valuescale) || 1);
14185
+ const boxOpacity = isNaN(styleNum(st.boxopacity)) ? 1 : styleNum(st.boxopacity);
14186
+ const borderStyle = String(st.borderstyle || '');
14187
+ const boxFill = [...hexOrNamedToRgb(st.boxcolor || 'white').slice(0, 3), Math.round(255 * boxOpacity)];
14188
+ const boxLine = borderStyle === 'none' ? [0, 0, 0, 0]
14189
+ : [...hexOrNamedToRgb(styleLineColor(st.bordercolor) || '#bbbbbb').slice(0, 3), Math.round(255 * (/^(solid|dotted|dashed)$/.test(borderStyle) ? 1 : 0.3))];
14190
+ const borderWidth = num(st.borderwidth) || 1;
14191
+ const lc = st.linecolor != null && st.linecolor !== '' ? styleLineColor(st.linecolor) : null;
14192
+ const lineRgbOf = (spec, rgb) => (spec.rgb === 'color' ? rgb : spec.rgb === 'low' ? flatDerivateRgb(rgb, 0.7)
14193
+ : spec.rgb === 'high' ? flatDerivateRgb(rgb, 1.3) : (lc && lc !== 'none' ? hexOrNamedToRgb(lc) : [0, 0, 0]));
14194
+ const align = !!(st.align || styleNum(st.offsetx) || styleNum(st.offsety));
14195
+
14196
+ const shapes = [], texts = [], titles = [], lines = [];
14197
+ // a shape in chart units → the instance (px; the circle around it)
14198
+ const shape = (ch, s, fill, line, width, props, noPick) => {
14199
+ const L = s.len + s.band + s.head;
14200
+ const d = [[0, -1], [1, 0], [0, 1], [-1, 0]][s.dir];
14201
+ const cx = (s.base[0] + d[0] * L / 2) * unit + ch.off[0], cy = (s.base[1] + d[1] * L / 2) * unit + ch.off[1];
14202
+ const lw = line[3] > 0 ? width * unit : 0;
14203
+ shapes.push({
14204
+ position: ch.c, off: [cx, -cy], properties: props, fill, line,
14205
+ r: Math.hypot(s.w2 * unit, L * unit / 2) + lw + 2,
14206
+ shape: [s.w2 * unit, s.sw * unit, s.len * unit, s.band * unit],
14207
+ shape2: [s.head * unit, s.dir + (noPick ? 4 : 0), lw, L * unit / 2]
14208
+ });
14209
+ };
14210
+ for (const ch of charts) {
14211
+ const L = ch.layout;
14212
+ let box = null;
14213
+ if (boxShown) {
14214
+ const title = titleShown ? this._itemTitle(ch.properties) : '';
14215
+ box = flatChartBox(L.extent, { margin: num(st.boxmargin) || 2, titleFont, title: title ? title + (st.titleunits || '') : '',
14216
+ bottomTitle: flatFlag(this.flags, 'BOTTOMTITLE'), alignRight: /right/.test(String(st.align || '')), measure });
14217
+ }
14218
+ const ext = box ? box.rect : L.extent;
14219
+ // align / offsetx / offsety (flatChartAlignOffset), the chart's
14220
+ // half height as its size (ASSUMPTION: not measured on flat for BAR)
14221
+ ch.off = align ? flatChartAlignOffset(st, { unit, symbolScale: 1, half: (ext[3] - ext[1]) / 2 * unit }) : [0, 0];
14222
+ if (box) {
14223
+ const r = box.rect;
14224
+ shape(ch, { base: [(r[0] + r[2]) / 2, r[3]], dir: 0, len: r[3] - r[1], w2: (r[2] - r[0]) / 2, sw: (r[2] - r[0]) / 2, band: 0, head: 0 },
14225
+ boxFill, boxLine, borderWidth * 0.2 * box.width / 30, null, true);
14226
+ if (box.title) titles.push({ position: ch.c, text: box.title.lines.join('\n'), font: box.title.font * unit, anchor: box.title.anchor,
14227
+ off: [box.title.x * unit + ch.off[0], (box.title.baseline - 0.8 * box.title.font) * unit + ch.off[1]] });
14228
+ }
14229
+ for (const b of L.bars) {
14230
+ const rgb = colors[b.cls] || [128, 128, 128];
14231
+ const a = Math.round(255 * b.fill.opacity * b.opacity);
14232
+ const line = [...lineRgbOf(b.line, rgb).slice(0, 3), Math.round(255 * b.line.opacity * b.opacity)];
14233
+ shape(ch, b, [...rgb.slice(0, 3), a], line, b.line.width, ch.properties, false);
14234
+ }
14235
+ for (const tx of L.texts) {
14236
+ const font = tx.font * unit;
14237
+ if (font <= VALUES_MIN_FONT_PX) continue;
14238
+ const rgb = colors[tx.cls] || [128, 128, 128];
14239
+ const color = tx.color === 'value' ? [0x79, 0x86, 0x97] : tx.color === 'contrast' ? flatChartTextRgb(rgb) : hexOrNamedToRgb(tx.color);
14240
+ // the baseline's start → the text's middle-left (arial: 0.35 font
14241
+ // above the baseline), perpendicular to its reading direction
14242
+ const r = tx.angle * Math.PI / 180;
14243
+ const mx = tx.x * unit - 0.35 * font * Math.sin(r), my = tx.y * unit - 0.35 * font * Math.cos(r);
14244
+ texts.push({ position: ch.c, text: tx.text, font, angle: tx.angle, off: [mx + ch.off[0], my + ch.off[1]],
14245
+ color: [...color.slice(0, 3), Math.round(255 * Math.max(0, Math.min(1, tx.opacity)))],
14246
+ bg: tx.bg === 'bar' ? [...rgb.slice(0, 3), 128] : tx.bg === 'white' ? [254, 254, 255, 255] : null });
14247
+ }
14248
+ for (const l of L.lines) lines.push({ c: ch.c, o: ch.off, sg: [l.x0 * unit, l.y0 * unit, l.x1 * unit, l.y1 * unit] });
14249
+ }
14250
+
14251
+ const layers = [new BarShapeLayer({
14252
+ id: `ix-bar-${this.name}`,
14253
+ data: shapes, pickable: true,
14254
+ getPosition: d => d.position,
14255
+ getRadius: d => d.r,
14256
+ getPixelOffset: d => d.off,
14257
+ getFillColor: d => d.fill,
14258
+ getLineColor: d => d.line,
14259
+ getBarShape: d => d.shape,
14260
+ getBarShape2: d => d.shape2,
14261
+ radiusUnits: 'pixels', billboard: true, stroked: false, filled: true, antialiasing: true,
14262
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
14263
+ })];
14264
+ if (lines.length) {
14265
+ const feature = d => ({ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: [
14266
+ pixelOffsetLngLat(d.c, [d.o[0] + d.sg[0], d.o[1] + d.sg[1]], liveZoom),
14267
+ pixelOffsetLngLat(d.c, [d.o[0] + d.sg[2], d.o[1] + d.sg[3]], liveZoom)] } });
14268
+ const fc = { type: 'FeatureCollection', features: lines.map(feature) };
14269
+ [['bg', [255, 255, 255, Math.round(255 * 0.2)], 0.15], ['fg', [0x88, 0x88, 0x88, 255], 0.05]].forEach(([k, color, w]) => {
14270
+ layers.push(new GeoJsonLayer({
14271
+ id: `ix-bar-lines-${k}-${this.name}`, data: fc, pickable: false,
14272
+ stroked: true, filled: false, getLineColor: color, getLineWidth: w * unit, lineWidthUnits: 'pixels'
14273
+ }));
14274
+ });
14275
+ }
14276
+ const textBase = {
14277
+ getPosition: d => d.position, getText: d => d.text, getSize: d => d.font,
14278
+ getPixelOffset: d => d.off, getColor: d => d.color, getAngle: d => d.angle,
14279
+ sizeUnits: 'pixels', fontFamily: 'arial', characterSet: 'auto', fontWeight: 'normal',
14280
+ getTextAnchor: 'start', getAlignmentBaseline: 'center', pickable: false,
14281
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
14282
+ };
14283
+ const boxed = texts.filter(d => d.bg), plain = texts.filter(d => !d.bg);
14284
+ if (plain.length) layers.push(new TextLayer(Object.assign({}, textBase, { id: `ix-bar-values-${this.name}`, data: plain })));
14285
+ if (boxed.length) {
14286
+ // flat's box (createTextLabel): 0.3 font left of the text, 0.4 font
14287
+ // right of it, 0.1 font taller
14288
+ layers.push(new TextLayer(Object.assign({}, textBase, {
14289
+ id: `ix-bar-values-boxed-${this.name}`, data: boxed,
14290
+ background: true, getBackgroundColor: d => d.bg,
14291
+ // one padding per layer: of the texts' mean font
14292
+ backgroundPadding: [0.3, 0, 0.4, 0.1].map(k => k * boxed.reduce((a, d) => a + d.font, 0) / boxed.length)
14293
+ })));
14294
+ }
14295
+ if (titles.length) {
14296
+ layers.push(new TextLayer({
14297
+ id: `ix-bar-titles-${this.name}`, data: titles, pickable: false,
14298
+ getPosition: d => d.position, getText: d => d.text, getSize: d => d.font,
14299
+ sizeUnits: 'pixels', fontFamily: 'arial', characterSet: 'auto', lineHeight: 1,
14300
+ getColor: [...hexOrNamedToRgb(st.textcolor || '#888888').slice(0, 3), 255],
14301
+ fontSettings: { sdf: true }, outlineWidth: 1 / 7, outlineColor: [255, 255, 255, 128],
14302
+ getTextAnchor: d => d.anchor, getAlignmentBaseline: 'top',
14303
+ getPixelOffset: d => d.off,
14304
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
14305
+ }));
14306
+ }
14307
+ return layers;
14308
+ }
14309
+
14310
+ // CHART|VECTOR / CHART|BEZIER — the theme's classes as flat has them:
14311
+ // with CATEGORICAL (or EXACT) the categories, flat's string values
14312
+ // trimmed (getStringValueIndex, maptheme.js 9204), its partsA one
14313
+ // [k, k + 1e-9] per category (the last + 0.001) and the colorscheme's
14314
+ // colors per category; the items' statistics follow in _vectorItems
14315
+ _prepareVector() {
14316
+ const st = this.style;
14317
+ this._vectorCategorical = this.flags.has('CATEGORICAL') && !!this.binding.value;
14318
+ if (this._vectorCategorical) {
14319
+ const explicit = Array.isArray(st.values) ? st.values.map(v => String(v).trim()) : null;
14320
+ const index = new Map();
14321
+ if (explicit) explicit.forEach((v, i) => { if (!index.has(v)) index.set(v, i); });
14322
+ else {
14323
+ for (const f of this.features) {
14324
+ const v = f.properties[this.binding.value];
14325
+ if (v == null) continue;
14326
+ const k = String(v).trim();
14327
+ if (k !== '' && !index.has(k)) index.set(k, index.size);
14328
+ }
14329
+ this.categoryLabels = Array.from(index.keys());
14330
+ const explicitLabel = Array.isArray(st.label) ? st.label.map(String) : null;
14331
+ this.categoryDisplayLabels = explicitLabel && explicitLabel.length === this.categoryLabels.length ? explicitLabel : this.categoryLabels;
14332
+ this.categoryColorsRgb = resolveClassColors(st.colorscheme, this.categoryDisplayLabels, st.classes);
14333
+ }
14334
+ this._vectorCategoryIndex = index;
14335
+ // the legend counts records by their raw value
14336
+ const byLabel = new Map(index);
14337
+ for (const f of this.features) {
14338
+ const v = f.properties[this.binding.value];
14339
+ if (v != null && !byLabel.has(v) && index.has(String(v).trim())) byLabel.set(v, index.get(String(v).trim()));
14340
+ }
14341
+ this.categoryIndexByLabel = byLabel;
14342
+ const n = this.categoryLabels.length;
14343
+ this.partsA = this.categoryLabels.map((l, i) => ({ min: i + 1, max: i + 1 + (i === n - 1 ? 0.001 : 0) + 1e-9 }));
14344
+ this._rangeClassed = false;
14345
+ }
14346
+ // colorfield (flat 12529-12597, 12644-12652): an item's color is its
14347
+ // color value's — the colorvalues, else values, else the items'
14348
+ // values in order — n-th color of the colorscheme realized for n
14349
+ const colorField = st.colorfield || (this.binding && this.binding.color);
14350
+ this._vectorColorField = colorField || null;
14351
+ if (colorField) {
14352
+ const list = Array.isArray(st.colorvalues) ? st.colorvalues.map(String)
14353
+ : Array.isArray(st.values) ? st.values.map(String)
14354
+ : [...new Set(this.features.map(f => String(f.properties[colorField])))];
14355
+ const colors = resolveClassColors(st.colorscheme, list, st.classes) || [];
14356
+ this._vectorColorOf = new Map(list.map((v, i) => [v, colors[i]]));
14357
+ }
14358
+ this._vectorItemsOf = null;
14359
+ }
14360
+
14361
+ // the records of flatVectorItems from this theme's (active) features:
14362
+ // the second position joined by key (joinChartPositions: f._pos2) or
14363
+ // read from lat/lon fields ("lat|lon")
14364
+ _vectorRecords(features) {
14365
+ const b = this.binding;
14366
+ const l2 = b.lookup2 != null ? String(b.lookup2) : '';
14367
+ const latlon2 = l2.includes('|') ? l2.split('|') : null;
14368
+ const sizeField = b.size && b.size !== '$item$' ? b.size : null;
14369
+ const records = [];
14370
+ for (const f of features) {
14371
+ const c = f.geometry && f.geometry.coordinates;
14372
+ if (!c || typeof c[0] !== 'number') continue;
14373
+ const p = f.properties || {};
14374
+ let p2 = null;
14375
+ if (latlon2) {
14376
+ const lat = flatScanValue(p[latlon2[0]]), lng = flatScanValue(p[latlon2[1]]);
14377
+ if (isFinite(lat) && isFinite(lng)) p2 = [lng, lat];
14378
+ } else if (f._pos2) p2 = f._pos2;
14379
+ let cat = null, value;
14380
+ if (this._vectorCategorical) {
14381
+ const v = p[b.value];
14382
+ const i = v == null ? undefined : this._vectorCategoryIndex.get(String(v).trim());
14383
+ cat = i == null ? -1 : i;
14384
+ } else if (b.value) {
14385
+ value = flatScanValue(p[b.value]);
14386
+ }
14387
+ records.push({ p1: c, p2, cat, value, size: sizeField ? flatScanValue(p[sizeField]) : 1, raw: p });
14388
+ }
14389
+ return records;
14390
+ }
14391
+
14392
+ // the items (flatVectorItems) of the active features, once per feature
14393
+ // set; also flat's statistics of them (maptheme.js 11180-11240): nMin /
14394
+ // nMax over the items' sizes (CATEGORICAL or a size field, else their
14395
+ // values), nMaxSize the biggest size
14396
+ _vectorItems() {
14397
+ const features = this._activeFeatures || this.features;
14398
+ if (this._vectorItemsOf === features) return this._vectorItemsCache;
14399
+ const t = this.flags.typeString != null ? this.flags.typeString : [...this.flags].join('|');
14400
+ const items = flatVectorItems(this._vectorRecords(features), {
14401
+ aggregate: this.flags.has('AGGREGATE'),
14402
+ vectorKey: /VECTOR/.test(t),
14403
+ multiParts: this.flags.has('CATEGORICAL'),
14404
+ max: /\bMAX\b/.test(t), min: /\bMIN\b/.test(t),
14405
+ zeroIsNotValue: /ZEROISNOTVALUE/.test(t), negativeIsNotValue: /NEGATIVEISNOTVALUE/.test(t)
14406
+ });
14407
+ let min = Infinity, max = -Infinity, maxSize = -Infinity;
14408
+ const bySize = this._vectorCategorical || !!this.binding.size;
14409
+ for (const it of items) {
14410
+ if (isFinite(it.nSize) && it.nSize > maxSize) maxSize = it.nSize;
14411
+ const v = bySize ? it.nSize : it.value;
14412
+ if (isFinite(v)) { if (v < min) min = v; if (v > max) max = v; }
14413
+ }
14414
+ if (min <= max) { this._valueMin = min; this._valueMax = max; }
14415
+ this._vectorMaxSize = maxSize;
14416
+ this._vectorItemsOf = features;
14417
+ this._vectorItemsCache = items;
14418
+ return items;
14419
+ }
14420
+
14421
+ // CHART|VECTOR / CHART|BEZIER — real ixmaps-flat's flow lines
14422
+ // (maptheme.js chartMap 18148-18435): one line per item
14423
+ // (_vectorItems) from its first to its second position — the
14424
+ // positions joined to FEATURE shapes by key (binding position /
14425
+ // position2, style lookupfield / lookupfield2) or read from lat/lon
14426
+ // fields. An item without a second position, or with a size under
14427
+ // style.minvalue, draws nothing; one is drawn when either end lies in
14428
+ // the view (flat 16590-16620).
14429
+ // - BEZIER (also VECTOR|BEZIER — the BEZIER branch comes first): a
14430
+ // cubic curve (bezierVectorLayout), width (linewidth, else 10 ·
14431
+ // (|nSize| / normalsizevalue or the biggest size)^(1/sizepow)) ·
14432
+ // unit px — unit: the object zoom × style.scale; a width ≤ 0 is not
14433
+ // drawn; stroke opacity fillopacity (else 0.3); a negative size or
14434
+ // REVERSE swaps the ends; RANDOM lowers the bow by up to 0.66 ·
14435
+ // rangescale (flat's Math.random, here a fixed hash of the item);
14436
+ // POINTER / ARROW an arrow head (arrowMarkerTriangle) at the
14437
+ // shortened end, its opacity 2 · fillopacity; GRADIENT / FADEIN a
14438
+ // gradient (fadeGradientStops) from the second color (linecolor's
14439
+ // first entry) at the start — FADEIN at 0.2 opacity — to the line
14440
+ // color at the end; DOPACITY (DOPACITYMAX) / DOPACITYMIN the line
14441
+ // and arrow opacity by the size within nMin … nMax (dopacitypow,
14442
+ // dopacityscale); DASH dashes of 50 / 5 + width units, round caps
14443
+ // - VECTOR alone: a straight line, width (linewidth, else 2 · nSize /
14444
+ // normalsizevalue / the biggest size, else 0.1) · the object zoom px
14445
+ // (no style.scale), opacity fillopacity (0.3); POINTER / ARROW a 4 ×
14446
+ // width arrow head with its tip on the second position; DASH dashes
14447
+ // of 50 / 15 units
14448
+ // - color: linecolor (its last entry), else the colorfield's color,
14449
+ // else the category's (CATEGORICAL) or the value's n-th color; a
14450
+ // record whose category is none of them is black (flat: an
14451
+ // "undefined" stroke — szNoDataColor is unset)
14452
+ // - draw order: flat's sort before drawing (16774-16880), the
14453
+ // biggest last (on top) when sized or AGGREGATE|SUM
14454
+ // - lines are densified in Web Mercator pixels (flat draws them on its
14455
+ // Mercator map), so under the globe projection they follow the
14456
+ // surface; their far-side parts are left out
14457
+ // Not drawn here (console note at type parsing, VECTOR_INERT_FLAGS):
14458
+ // VALUES (text along the curve), DYNAMICWIDTH, LONG, the DASH
14459
+ // animation; nor the second position from the category label (flat's
14460
+ // fallback without position2), a grid aggregation (gridwidth), the
14461
+ // VECTOR arrow's thin white outline, the legend's class marking.
14462
+ // Known divergence: all arrow heads lie above all lines (flat draws
14463
+ // each line with its head); a line thinner than a pixel is drawn one
14464
+ // pixel wide at its width as opacity (SVG's coverage).
14465
+ _buildVectorLayers(zoom, bbox, liveZoom = zoom, globeCenter = null) {
14466
+ const st = this.style;
14467
+ const t = this.flags.typeString != null ? this.flags.typeString : [...this.flags].join('|');
14468
+ const has = re => re.test(t);
14469
+ const bezier = has(/\bBEZIER\b/);
14470
+ if (!this.binding.lookup2) {
14471
+ if (!this._warnedNoPos2) {
14472
+ this._warnedNoPos2 = true;
14473
+ console.info(`[ixmaps-gl] layer "${this.name}": a VECTOR/BEZIER chart without position2 (lookupfield2) — flat's second position from the category label is not ported, nothing drawn`);
14474
+ }
14475
+ return [];
14476
+ }
14477
+ const items = this._vectorItems();
14478
+ const oz = objectZoomFactor(liveZoom, this.mapOptions);
14479
+ const scale = styleNum(st.scale) || 1;
14480
+ const unit = oz * scale;
14481
+ const world = 512 * Math.pow(2, liveZoom);
14482
+ const toPx = ([lng, lat]) => {
14483
+ const s = Math.sin(Math.max(-85.0511, Math.min(85.0511, lat)) * Math.PI / 180);
14484
+ return [(lng + 180) / 360 * world, (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * world];
14485
+ };
14486
+ 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];
14487
+ const inBox = c => c && c[0] >= bbox[0] && c[0] <= bbox[2] && c[1] >= bbox[1] && c[1] <= bbox[3];
14488
+
14489
+ const sizePow = resolveSizePow(st, this.flags);
14490
+ const nsv = styleNum(st.normalsizevalue);
14491
+ const maxSize = this._vectorMaxSize;
14492
+ // style.linewidth as flat holds it: the raw style value — flat's
14493
+ // `nLineWidth + 3` (the arrow length) joins a string ("0.6" + 3 =
14494
+ // "0.63") but adds a number
14495
+ const rawLw = this._definition && this._definition.style ? this._definition.style.linewidth : st.linewidth;
14496
+ const lwNum = styleNum(st.linewidth);
14497
+ const markerSize = styleNum(st.markersize) || 1;
14498
+ const gapSize = styleNum(st.gapsize) || 20;
14499
+ const fo = styleNum(st.fillopacity);
14500
+ const groupAlpha = styleNum(st.opacity) > 0 ? Math.min(1, styleNum(st.opacity)) : 1;
14501
+ const minValue = styleNum(st.minvalue);
14502
+ const rs = styleNum(st.rangescale);
14503
+ const dopMin = has(/DOPACITYMIN/), dop = !dopMin && has(/DOPACITY/);
14504
+ const dopPow = styleNum(st.dopacitypow) || 1, dopScale = styleNum(st.dopacityscale) || 1;
14505
+ const nMin = this._valueMin, nMax = this._valueMax;
14506
+ const lineColors = st.linecolor != null && st.linecolor !== '' ? flatToArray(st.linecolor) : null;
14507
+ const lineRgb = lineColors ? hexOrNamedToRgb(String(lineColors[lineColors.length - 1]).trim()) : null;
14508
+ const colors = this.categoryColorsRgb || [];
14509
+ const colorOf = it => {
14510
+ if (lineRgb) return lineRgb;
14511
+ if (this._vectorColorField) {
14512
+ const c = this._vectorColorOf.get(String(it.raw[this._vectorColorField]));
14513
+ if (c) return c;
14514
+ }
14515
+ if (it.cat != null) return it.cat >= 0 && colors[it.cat] ? colors[it.cat] : [0, 0, 0];
14516
+ return Number.isInteger(it.value) && colors[it.value - 1] ? colors[it.value - 1] : [0, 0, 0];
14517
+ };
14518
+ const fade = has(/\bFADEIN\b/) ? 0.2 : 2;
14519
+ const gradient = bezier && has(/\bGRADIENT\b|\bFADEIN\b/);
14520
+ const arrow = has(/\bPOINTER|ARROW\b/);
14521
+ const dash = has(/\bDASH\b/);
14522
+ const dopacity = it => {
14523
+ const range = Math.pow(nMax - nMin, 1 / dopPow);
14524
+ return Math.pow(dopMin ? nMax - it.nSize : it.nSize - nMin, 1 / dopPow) / range * (fo || 1) * dopScale;
14525
+ };
14526
+
14527
+ // flat's sort before drawing (maptheme.js 16774-16880)
14528
+ let order = items;
14529
+ const sized = !!this.binding.size;
14530
+ if (((nMin !== nMax) || sized) && !has(/NOSRT/) && (!has(/NOSIZE/) || has(/\bSORT\b/))
14531
+ && (!this.flags.has('CATEGORICAL') || this.flags.has('AGGREGATE') || sized || has(/\bSORT\b/))) {
14532
+ const key = (sized || (this.flags.has('AGGREGATE') && has(/SUM/))) ? (it => it.nSize)
14533
+ : (this._vectorCategorical ? (it => (it.cat != null && it.cat >= 0 ? it.cat + 1 : 0)) : (it => it.value));
14534
+ const keyed = items.map(it => ({ it, y: key(it) })).filter(d => !isNaN(d.y));
14535
+ const down = has(/\bSORT\b/) && has(/\bUP\b/);
14536
+ keyed.sort((a, b) => (down ? b.y - a.y : a.y - b.y));
14537
+ order = keyed.map(d => d.it);
14538
+ }
14539
+ // the flows that would be drawn (as the loop below draws them), of
14540
+ // which flat keeps the biggest maxcharts
14541
+ if (Math.round(styleNum(st.maxcharts)) > 0) {
14542
+ order = applyFlatMaxCharts(order.filter(it => it.p2 && !(minValue && it.nSize < minValue) && (inBox(it.p1) || inBox(it.p2))), st);
14543
+ }
14544
+
14545
+ const paths = [], heads = [];
14546
+ const N = 32;
14547
+ const visible = c => !globeCenter || isOnVisibleHemisphere(c[0], c[1], globeCenter);
14548
+ for (const it of order) {
14549
+ let p1 = it.p1, p2 = it.p2;
14550
+ if (!p2) continue;
14551
+ if (minValue && it.nSize < minValue) continue;
14552
+ if (!inBox(p1) && !inBox(p2)) continue;
14553
+ const rgb = colorOf(it);
14554
+ let pts, end, tan, sw, headS, refX, refY, headAlpha, lineAlpha, stops = null, dashArr = null;
14555
+ if (bezier) {
14556
+ if (p1[0] === p2[0] && p1[1] === p2[1]) continue;
14557
+ const w = rawLw != null && rawLw !== '' && lwNum ? lwNum
14558
+ : 10 / Math.pow(nsv || maxSize, 1 / sizePow) * Math.pow(Math.abs(it.nSize), 1 / sizePow);
14559
+ sw = w * unit;
14560
+ if (!(sw > 0)) continue;
14561
+ if (has(/\bREVERSE\b/) || it.nSize < 0) [p1, p2] = [p2, p1];
14562
+ const a = toPx(p1), b = toPx(p2);
14563
+ const dx = b[0] - a[0], dy = b[1] - a[1];
14564
+ if (!(dx * dx + dy * dy > 0)) continue;
14565
+ let bow = rs;
14566
+ if (has(/\bRANDOM\b/)) bow -= rs * 0.66 * hashUnit(p1[0] + ',' + p1[1] + '&' + p2[0] + ',' + p2[1]);
14567
+ const gapAdd = has(/\bGAP\b/) ? gapSize : 3;
14568
+ const ll = typeof rawLw === 'string' && lwNum ? Number(rawLw + String(gapAdd)) : w + gapAdd;
14569
+ const lay = bezierVectorLayout(dx, dy, { w, ll, unit, oz, bow, t, marker: markerSize, gap: gapSize });
14570
+ pts = cubicBezierPoints(lay.start, lay.c1, lay.c2, lay.end, N);
14571
+ end = lay.end;
14572
+ tan = [lay.end[0] - lay.c2[0], lay.end[1] - lay.c2[1]];
14573
+ headS = Math.min(5, 2.5 + 5 / (w * scale)) * markerSize;
14574
+ refX = headS / 1.7; refY = headS / 2;
14575
+ lineAlpha = fo || 0.3;
14576
+ headAlpha = Math.min(1, (fo || 0.3) * 2);
14577
+ // (flat sets the computed opacity as is; one that is no number
14578
+ // — every size equal — SVG ignores: the opacity stays)
14579
+ if (dop || dopMin) { const d = dopacity(it); if (isFinite(d)) { lineAlpha = d; headAlpha = d; } }
14580
+ if (gradient) stops = fadeGradientStops(pts, lay.vx, lay.vy, fade);
14581
+ if (dash) dashArr = [50 * oz, 5 * oz + sw];
14582
+ pts = pts.map(p => [p[0] + a[0], p[1] + a[1]]);
14583
+ end = [end[0] + a[0], end[1] + a[1]];
14584
+ } else {
14585
+ const w = (rawLw != null && rawLw !== '' && lwNum) || (2 / (nsv || 1) / maxSize * it.nSize) || 0.1;
14586
+ sw = w * oz;
14587
+ const a = toPx(p1), b = toPx(p2);
14588
+ pts = [];
14589
+ 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]);
14590
+ end = b;
14591
+ tan = [b[0] - a[0], b[1] - a[1]];
14592
+ headS = 4; refX = 4; refY = 2;
14593
+ lineAlpha = fo || 0.3;
14594
+ headAlpha = Math.min(1, (fo || 0.3) * 2);
14595
+ if (dash) dashArr = [50 * oz, 15 * oz];
14596
+ }
14597
+ // a line under a pixel: one pixel at its width as opacity
14598
+ const drawW = Math.max(1, sw);
14599
+ const thin = Math.min(1, sw);
14600
+ const color2 = lineColors && lineColors.length > 1 ? hexOrNamedToRgb(String(lineColors[0]).trim()) : rgb;
14601
+ const vertexColor = i => {
14602
+ const s = stops ? stops[i] : null;
14603
+ const k = s ? s.k : 1;
14604
+ const c = s ? [0, 1, 2].map(j => Math.round(color2[j] + (rgb[j] - color2[j]) * k)) : rgb.slice(0, 3);
14605
+ return [...c, Math.round(255 * Math.min(1, lineAlpha) * (s ? s.alpha : 1) * thin * groupAlpha)];
14606
+ };
14607
+ const properties = { value: it.nSize, cat: it.cat, raw: it.raw, count: it.count,
14608
+ vector: { itemValue: it.cat != null ? (it.cat >= 0 ? it.cat + 1 : 0) : it.value } };
14609
+ // the line in map coordinates; on the globe only its visible runs
14610
+ const ll = pts.map(toLngLat);
14611
+ let run = [], runColors = [];
14612
+ const flush = () => {
14613
+ 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 });
14614
+ run = []; runColors = [];
14615
+ };
14616
+ ll.forEach((c, i) => { if (visible(c)) { run.push(c); runColors.push(vertexColor(i)); } else flush(); });
14617
+ flush();
14618
+ if (arrow && visible(toLngLat(end))) {
14619
+ const tri = arrowMarkerTriangle(end, tan[0], tan[1], sw, headS, refX, refY).map(toLngLat);
14620
+ heads.push({ type: 'Feature', properties, geometry: { type: 'Polygon', coordinates: [[...tri, tri[0]]] },
14621
+ fill: [...rgb.slice(0, 3), Math.round(255 * Math.min(1, headAlpha) * groupAlpha)] });
14622
+ }
14623
+ }
14624
+ const layers = [new PathLayer({
14625
+ id: `ix-vector-${this.name}`, data: paths, pickable: true,
14626
+ getPath: d => d.path, getColor: d => d.colors, getWidth: d => d.width,
14627
+ widthUnits: 'pixels', capRounded: dash && bezier, jointRounded: false, billboard: false,
14628
+ parameters: ICON_LAYER_GLOBE_PARAMETERS,
14629
+ ...(dash ? { getDashArray: d => d.dash, extensions: [new PathStyleExtension({ dash: true })] } : {})
14630
+ })];
14631
+ if (heads.length) {
14632
+ // the head count in the id: deck kept a same-id polygon layer's
14633
+ // larger buffers when a filter cut the heads (the Zeit page's "to
14634
+ // Africa": bow-tie triangles from the unfiltered heads' vertices).
14635
+ // Every head has the same vertex count, so an equal count — a pan —
14636
+ // reuses the layer; a changed one gets fresh buffers
14637
+ layers.push(new GeoJsonLayer({
14638
+ id: `ix-vector-heads-${this.name}-n${heads.length}`, data: { type: 'FeatureCollection', features: heads }, pickable: true,
14639
+ filled: true, stroked: false, getFillColor: d => d.fill, parameters: ICON_LAYER_GLOBE_PARAMETERS
14640
+ }));
14641
+ }
14642
+ return layers;
14643
+ }
14644
+
11795
14645
  // flat's chart position for this build (flatChartAlignOffset): a
11796
14646
  // function item → pixel offset, the same for every part of one chart
11797
14647
  // (symbol, glow, shadow, box, title, value text). unit is flat's
@@ -11909,6 +14759,60 @@
11909
14759
  return { noBreaks: flatNoBreaks(vMin, vMax), maxValue: styleNum(this.style.normalsizevalue) || (isFinite(vMax) ? vMax : undefined) };
11910
14760
  }
11911
14761
 
14762
+ // The charts of _buildChartLayers' bubbles layer as
14763
+ // chartTextTransmittance's occluders, in draw order and screen
14764
+ // pixels, and each text's resulting alpha: the text's color gets it,
14765
+ // the returned Set holds the texts still visible. Screen pixels:
14766
+ // Web Mercator world pixels at the live zoom; under globe a local
14767
+ // equal-area frame around the view center at the scale MapLibre's
14768
+ // globe matches Mercator's there (the limb's foreshortening ignored).
14769
+ _occludeChartTexts(combined, texts, o) {
14770
+ const visible = new Set();
14771
+ if (!texts.length) return visible;
14772
+ const toRad = Math.PI / 180;
14773
+ let screenOf;
14774
+ if (o.globeCenter) {
14775
+ const s = 512 * Math.pow(2, o.liveZoom) / 360 / Math.cos(o.globeCenter.lat * toRad);
14776
+ screenOf = c => [(((c[0] - o.globeCenter.lng) % 360 + 540) % 360 - 180) * Math.cos(c[1] * toRad) * s, -c[1] * s];
14777
+ } else {
14778
+ screenOf = c => { const p = lngLatToWorldPixel(c[0], c[1], o.liveZoom); return [p.x, p.y]; };
14779
+ }
14780
+ const symbols = Array.isArray(this.style.symbols) ? this.style.symbols : null;
14781
+ const charts = combined.map(d => {
14782
+ const [x, y] = screenOf(d.geometry.coordinates);
14783
+ const a = o.alignOf(d), r = o.radiusOf(d), iconAlpha = this._iconAlpha(d) / 255;
14784
+ let parts;
14785
+ if (o.singleSymbol(d)) {
14786
+ // the icon's shape is inset by half its border (_buildSingleIcon)
14787
+ const shape = o.shapeOf(d.properties);
14788
+ parts = [{ dx: 0, dy: 0, r: r * (1 - o.singleBorderWidthPx / BUBBLE_ICON_SIZE), shape, alpha: o.fillOpacity * iconAlpha }];
14789
+ } else {
14790
+ // _buildBubbleIcon's packed parts, its baked 0.9 alpha scaled by an
14791
+ // explicit fillopacity like the bubbles layer's getColor
14792
+ const counts = d.properties.counts, iso = this._groupIsolation(d);
14793
+ const { present, radii, offsets, fitScale } = (iso && isolatedBubblePackLayout(counts, BUBBLE_ICON_SIZE, iso)) || computeBubblePackLayout(counts, BUBBLE_ICON_SIZE);
14794
+ const px = 2 * r / BUBBLE_ICON_SIZE * fitScale;
14795
+ const alpha = 0.9 * (this.style.fillopacity != null ? o.fillOpacity : 1) * iconAlpha;
14796
+ parts = present.map((p, i) => ({ dx: offsets[i].x * px, dy: offsets[i].y * px, r: radii[i] * px,
14797
+ shape: symbols ? normalizeSymbolShape(symbols[Math.min(p.i, symbols.length - 1)]) : 'circle', alpha }));
14798
+ }
14799
+ return { x: x + a[0], y: y + a[1], parts };
14800
+ });
14801
+ // the text box: Arial digits are ~0.56 em wide, ~0.7 em high
14802
+ const boxes = texts.map(t => {
14803
+ const [x, y] = screenOf(t.geometry.coordinates);
14804
+ return { order: t.order, x: x + t.pixelOffset[0], y: y + t.pixelOffset[1], w: 0.56 * t.fontSize * String(t.text).length, h: 0.7 * t.fontSize };
14805
+ });
14806
+ const shown = chartTextTransmittance(charts, boxes);
14807
+ texts.forEach((t, i) => {
14808
+ if (shown[i] < CHART_TEXT_MIN_TRANSMITTANCE) return;
14809
+ const c = t.color || [0, 0, 0];
14810
+ if (shown[i] < 1) t.color = [c[0], c[1], c[2], Math.round((c[3] != null ? c[3] : 255) * shown[i])];
14811
+ visible.add(t);
14812
+ });
14813
+ return visible;
14814
+ }
14815
+
11912
14816
  // CHART|LABEL (maptheme.js 20647-21037), at the chart radius r of the
11913
14817
  // size value:
11914
14818
  // - LABEL: a rounded rectangle 2.05r wide and 1.6·f high (f = min(0.8r,
@@ -12882,11 +15786,12 @@
12882
15786
  const map = _lastMapApi.map;
12883
15787
  const center = map.getCenter();
12884
15788
  const proj = (typeof map.getProjection === 'function' && map.getProjection()) || { type: 'mercator' };
15789
+ const svgProjection = mapSvgProjection(map);
12885
15790
  return JSON.stringify({
12886
15791
  map: {
12887
- map: proj.type === 'globe' ? 'maps/svg/maps/generic/orthographic.svg' : 'maps/svg/maps/generic/mercator.svg',
15792
+ map: proj.type === 'globe' ? 'maps/svg/maps/generic/orthographic.svg' : svgProjection ? svgProjection.svg : 'maps/svg/maps/generic/mercator.svg',
12888
15793
  center: { lat: center.lat, lng: center.lng },
12889
- zoom: mapLibreToFlatZoom(map.getZoom())
15794
+ zoom: flatViewZoomOf(map)
12890
15795
  }
12891
15796
  });
12892
15797
  }
@@ -12957,6 +15862,13 @@
12957
15862
  htmlgui_getAttributionString: () => (_lastMapApi && _lastMapApi.getAttribution ? _lastMapApi.getAttribution() : ''),
12958
15863
  changeThemeStyle: compatChangeThemeStyle,
12959
15864
  removeTheme: compatRemoveTheme,
15865
+ // flat's ixmaps.setLocalString(orig, local) (htmlgui.js 3814 →
15866
+ // Api.setLocalString → Dictionary.replace): a page-wide translation,
15867
+ // e.g. the migration page's ISO code → country name; usable before
15868
+ // the map exists. getLocalString: its translation (flat's never
15869
+ // returns it — a missing return there)
15870
+ setLocalString: (szOrig, szLocal) => { _localStrings[szOrig] = szLocal; },
15871
+ getLocalString: (szOrig) => makeLocalText([])(szOrig),
12960
15872
  // flat's ixmaps.newTheme(title, theme, flag) — on the last built map
12961
15873
  newTheme: (title, theme, flag) => _mapHandle.newTheme(title, theme, flag),
12962
15874
  // flat's time slider: show the records of a theme (null = all themes) whose timefield lies in [min, max)
@@ -12985,16 +15897,16 @@
12985
15897
  // test-only: lets test/unit/*.test.mjs call pure internals directly (the
12986
15898
  // engine runs in a Node vm there); deliberately NOT on the ixmaps object
12987
15899
  global.__ixmapsGlInternals = {
12988
- IXMAPS_GL_VERSION, youtubeClickToPlay, symbolsFlagCompat, tooltipTable, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout, ringsLayout, multiQuadOffsets, pixelOffsetLngLat,
15900
+ 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,
12989
15901
  normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, MapBuilder, typeStyleNumbers, styleNum,
12990
15902
  resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
12991
15903
  equalIntervalBreaks, quantileBreaks, naturalBreaks, evenStrideSample, jenksBreakValues, partsFromBreakValues, resolvePartsClass,
12992
15904
  computeAlphaStats, computeMultiFieldClasses, computeDominantStats, resolveDominantClass, computeComposeColorStats,
12993
15905
  resolveComposedColor, computeRangeClasses, colorSchemeClassCount, resolveDopacityAlpha,
12994
15906
  flatColorSweep, applyClassesToColorScheme, resolveClassColors, flatOutlierStats, parseCssColor, flatLegendLook,
12995
- flatToMapLibreZoom, mapLibreToFlatZoom, scaleDenominatorAt, resolveZoomReference, resolveAggregationPx, valuesHiddenByScale,
15907
+ flatToMapLibreZoom, mapLibreToFlatZoom, flatViewToMapLibreZoom, mapLibreToFlatViewZoom, flatSvgProjectionOf, flatSvgProjectionByOptions, normalSizeScaleOf, scaleDenominatorAt, resolveZoomReference, resolveAggregationPx, valuesHiddenByScale,
12996
15908
  fetchLayerData, parseCsvText, dataTableRows, geometryRowsToFeatureCollection, filterFlatValues, applyField100, field100Binding, rangeClassLegendTotals, resolveAggregateValue, classValueSeparate, cellAggregatedValues, oneHot, groupCoLocated, aggregateOnGrid, GridAggregateIndex, flatRangeParts, aggregateField100, valueRadius, itemPlotGeometry, itemAnchor, objectZoomFactor, resolveZoomReference, defaultNormalSizeValue,
12997
- applyWhereFilter, joinChartPositions, flatLookupKey, flatShapeCenter, chartHiddenByScale, glowHiddenByScale, isSymbolChart, flatChartAlignOffset, flatChartBranch, flatValueText, categoryValueRecord, flatDerivateRgb, flatChartTextRgb, flatGroupedValue, flatNoBreaks, featuresHiddenByScale, boxHiddenByScale, flatShadowOn, snapToAggregationGrid,
15909
+ applyWhereFilter, joinChartPositions, flatLookupKey, flatShapeCenter, chartHiddenByScale, glowHiddenByScale, isSymbolChart, flatChartAlignOffset, flatChartBranch, flatValueText, categoryValueRecord, flatDerivateRgb, flatBubbleLook, bubbleIconLook, flatChartTextRgb, flatGroupedValue, flatNoBreaks, featuresHiddenByScale, boxHiddenByScale, flatShadowOn, snapToAggregationGrid,
12998
15910
  drawSymbolPath, normalizeSymbolShape,
12999
15911
  flatFormatValue, themeStyleArgs, flatChangedStyleValue, explicitValueDecimals, legendDecimals,
13000
15912
  dataCacheKey, dataCacheDisabled, cachedLayerData, featuresBounds, wantsZoomToExtent, legendIsOn,