ixmaps-gl 0.2.19 → 0.2.20

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 +11 -5
  2. package/ixmaps-gl.js +895 -22
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -61,6 +61,12 @@ page. It currently implements:
61
61
  flat's part sizes, glow and value labels
62
62
  - **CHART\|USER** — a page's own chart function (`style.userdraw`: `ixmaps.<name>(SVGDocument, opt)`
63
63
  and its `_init`, as flat calls them), drawn in flat's chart units and shown as an icon at flat's size
64
+ - **CHART\|PIE** (and `PIE|DONUT`) — a pie per record (placed on its FEATURE shape by
65
+ `binding.lookup`), a slice per value field, as flat lays them out: `SIZE` by the size field (or
66
+ the parts' sum), `SORT`, `SYMMETRIC`/`BIGTOTOP`, `HALF*`, `AUTOCOMPLETE`, `CENTER` (the center
67
+ part as a disc, `GLOW`), `CENTERVALUE`, `VALUES` (flat's leader-line labels; one part: inside),
68
+ `THIN`/`XTHIN`/`THICK` donuts. `3D`, `VOLUME`, `STARBURST`, `RAYS`/`FLOWER`, `BOW`, `ZOOM` are
69
+ noted in the console and drawn as a flat pie; `AGGREGATE` and `CATEGORICAL` pies are not drawn yet
64
70
  - **DOT** — the simplest base symbol (fixed-radius, unclustered points)
65
71
  - **PLOT** — a small line/area chart per item over its value fields (flat's per-item PLOT
66
72
  geometry: first point at the item, `scale`/`rangescale`, FIXSIZE markers), or one per grid
@@ -77,7 +83,7 @@ page. It currently implements:
77
83
  - Globe (orthographic) projection alongside flat Mercator
78
84
 
79
85
  Not yet implemented: plain (non-AGGREGATE) `CATEGORICAL` choropleths, and the `QUAD`/`BEZIER`/`VECTOR`/
80
- `PIE`/`DONUT`/`WAFFLE`/`BAR` base types and SYMBOL shape variants beyond
86
+ `WAFFLE`/`BAR` base types, `AGGREGATE`/`CATEGORICAL` pies, and SYMBOL shape variants beyond
81
87
  circle/square/diamond/triangle. See the top-of-file comment in
82
88
  [`ixmaps-gl.js`](./ixmaps-gl.js) for the exact, currently-accurate scope note.
83
89
 
@@ -243,7 +249,7 @@ of your own project files gl can render.
243
249
  Load the engine from jsDelivr, pinned to a release tag (or `@main` for the latest commit):
244
250
 
245
251
  ```html
246
- <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.19/ixmaps-gl.js"></script>
252
+ <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.20/ixmaps-gl.js"></script>
247
253
  ```
248
254
 
249
255
  or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmaps-gl)
@@ -251,13 +257,13 @@ or from unpkg, the npm package [`ixmaps-gl`](https://www.npmjs.com/package/ixmap
251
257
  browser script, which defines the global `ixmaps`):
252
258
 
253
259
  ```html
254
- <script src="https://unpkg.com/ixmaps-gl@0.2.19/ixmaps-gl.js"></script>
260
+ <script src="https://unpkg.com/ixmaps-gl@0.2.20/ixmaps-gl.js"></script>
255
261
  ```
256
262
 
257
263
  or use a local copy (`<script src="ixmaps-gl.js"></script>`). A minimal page:
258
264
 
259
265
  ```html
260
- <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.19/ixmaps-gl.js"></script>
266
+ <script src="https://cdn.jsdelivr.net/gh/gjrichter/ixmaps-gl@v0.2.20/ixmaps-gl.js"></script>
261
267
  <div id="map_div" style="position:absolute;inset:0;"></div>
262
268
 
263
269
  <script>
@@ -288,7 +294,7 @@ features this engine doesn't implement:
288
294
 
289
295
  ```text
290
296
  [ixmaps-gl validate] error unknown-style-key — layer "points": unknown style key "fillOpacity" — did you mean "fillopacity"?
291
- [ixmaps-gl validate] warning gl-unsupported — layer "pie": type flag "PIE" is not implemented by ixmaps-gl
297
+ [ixmaps-gl validate] warning gl-unsupported — layer "waffle": type flag "WAFFLE" is not implemented by ixmaps-gl
292
298
  ```
293
299
 
294
300
  It is **off by default** — nothing is fetched and nothing changes. Turn it on with any of:
package/ixmaps-gl.js CHANGED
@@ -49,12 +49,16 @@
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 not yet). These
53
57
  // base types are dispatched by buildDeckLayers in the real engine's own
54
58
  // precedence order — DOT is checked first because the real engine's DOT
55
59
  // bypasses the whole drawChart/modifier pipeline rather than being
56
60
  // "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
61
+ // VECTOR, WAFFLE, BAR, and SYMBOL's own shape variants) are NOT
58
62
  // implemented yet — adding one means a new _buildXLayers() method plus a
59
63
  // dispatch line, not a rewrite, but each should be added deliberately
60
64
  // (checking, per real source, which modifiers it actually shares with
@@ -82,7 +86,7 @@
82
86
  // ---------------------------------------------------------------
83
87
  // this engine's release (= package.json "version", checked by
84
88
  // test/unit/version.test.mjs); ixmaps.glVersion, and logged once at start
85
- const IXMAPS_GL_VERSION = '0.2.19';
89
+ const IXMAPS_GL_VERSION = '0.2.20';
86
90
 
87
91
  const LIB_URLS = {
88
92
  // Bumped 3.6.2 -> 5.x (2026-09-21, globe-projection compat fix): native
@@ -200,6 +204,111 @@
200
204
  // overview page claims ES-modules-only, which the actual UMD bundle
201
205
  // contradicts).
202
206
  let IconLayer, ScatterplotLayer, GeoJsonLayer, MapLibreOverlay, TextLayer;
207
+ // PieSliceLayer (see makePieSliceLayer) is derived from ScatterplotLayer
208
+ // once deck.gl is there
209
+ let PieSliceLayer;
210
+
211
+ // CHART|PIE / DONUT slices (_buildPieLayers): one instance per slice — a
212
+ // ScatterplotLayer circle (billboarded, pixel radius, pixel offset, the
213
+ // same projection path as every other chart, globe included) whose
214
+ // fragment shader keeps only the slice's sector of the circle and the
215
+ // ring outside its inner radius (DONUT, CENTER), and draws flat's slice
216
+ // outline along the sector's edges.
217
+ // getSlice: [start angle, sweep, inner radius / radius] — radians,
218
+ // clockwise from 12 o'clock (flat's DonutChart angles); a
219
+ // sweep of 2π is a full ring (no radial edges)
220
+ // getSliceLineWidth: the outline width in pixels (0 = none), drawn in
221
+ // getLineColor
222
+ // Why not a canvas icon per pie (as the bubbles): every pie has its own
223
+ // slice angles, ~7 900 distinct icons on the comuni page, far beyond
224
+ // what deck.gl's never-evicting icon atlas holds (ICON_ATLAS_RESET_AFTER);
225
+ // and not polygons in map coordinates: ~31 000 slices re-tessellated at
226
+ // every zoom step. Here the GPU draws each slice from 5 numbers, crisp at
227
+ // any device pixel ratio. Neighbouring slices split the circle by angle
228
+ // without anti-aliasing between them (a hard partition: no background
229
+ // seam shows between two slices); the circle's rim keeps
230
+ // ScatterplotLayer's own anti-aliasing.
231
+ function makePieSliceLayer(Base) {
232
+ class Layer extends Base {
233
+ getShaders() {
234
+ const shaders = super.getShaders();
235
+ const inject = Object.assign({}, shaders.inject || {});
236
+ inject['vs:#decl'] = (inject['vs:#decl'] || '') + `
237
+ in vec3 instancePieSlices;
238
+ in float instancePieLineWidths;
239
+ out vec3 vPieSlice;
240
+ out float vPieRadius;
241
+ out float vPieLineWidth;
242
+ out vec4 vPieLineColor;
243
+ `;
244
+ inject['vs:#main-end'] = (inject['vs:#main-end'] || '') + `
245
+ vPieSlice = instancePieSlices;
246
+ vPieRadius = outerRadiusPixels;
247
+ vPieLineWidth = instancePieLineWidths;
248
+ vPieLineColor = vec4(instanceLineColors.rgb, instanceLineColors.a * layer.opacity);
249
+ `;
250
+ inject['fs:#decl'] = (inject['fs:#decl'] || '') + `
251
+ in vec3 vPieSlice;
252
+ in float vPieRadius;
253
+ in float vPieLineWidth;
254
+ in vec4 vPieLineColor;
255
+ `;
256
+ // before the picking module's own filter (it replaces the color
257
+ // when picking): the discards shape the pick area too
258
+ inject['fs:DECKGL_FILTER_COLOR'] = (inject['fs:DECKGL_FILTER_COLOR'] || '') + `
259
+ {
260
+ const float PIE_TAU = 6.283185307179586;
261
+ vec2 pieP = geometry.uv * vPieRadius;
262
+ float pieR = length(pieP);
263
+ float pieInner = vPieSlice.z * vPieRadius;
264
+ if (pieInner > 0.0 && pieR < pieInner - 1.0) discard;
265
+ bool pieFull = vPieSlice.y >= PIE_TAU - 1e-5;
266
+ if (!pieFull) {
267
+ float pieA = atan(pieP.x, pieP.y);
268
+ float pieRel = mod(pieA - vPieSlice.x + 2.0 * PIE_TAU, PIE_TAU);
269
+ if (pieRel >= vPieSlice.y) discard;
270
+ }
271
+ float pieD = vPieRadius - pieR;
272
+ if (pieInner > 0.0) pieD = min(pieD, pieR - pieInner);
273
+ if (!pieFull) {
274
+ vec2 pieE0 = vec2(sin(vPieSlice.x), cos(vPieSlice.x));
275
+ vec2 pieE1 = vec2(sin(vPieSlice.x + vPieSlice.y), cos(vPieSlice.x + vPieSlice.y));
276
+ float pieD0 = dot(pieP, pieE0) > 0.0 ? abs(pieP.x * pieE0.y - pieP.y * pieE0.x) : pieR;
277
+ float pieD1 = dot(pieP, pieE1) > 0.0 ? abs(pieP.x * pieE1.y - pieP.y * pieE1.x) : pieR;
278
+ pieD = min(pieD, min(pieD0, pieD1));
279
+ }
280
+ // in device pixels (fwidth: CSS px per device pixel), so a hairline
281
+ // stays one at any device pixel ratio. Half the outline lies inside
282
+ // each slice (the other half in its neighbour); below a pixel it fades
283
+ // in by its width
284
+ float pieAA = max(fwidth(pieR), 1e-4);
285
+ float pieH = vPieLineWidth * 0.5 / pieAA;
286
+ float pieDD = pieD / pieAA;
287
+ float pieLine = pieH > 0.0 ? clamp(min(pieH + 0.5 - pieDD, 2.0 * pieH), 0.0, 1.0) : 0.0;
288
+ float pieRim = clamp((vPieRadius - pieR) / pieAA + 0.5, 0.0, 1.0);
289
+ color = vec4(mix(color.rgb, vPieLineColor.rgb, pieLine), mix(color.a, vPieLineColor.a * pieRim, pieLine));
290
+ if (pieInner > 0.0) color.a *= clamp((pieR - pieInner) / pieAA + 0.5, 0.0, 1.0);
291
+ }
292
+ `;
293
+ shaders.inject = inject;
294
+ return shaders;
295
+ }
296
+ initializeState() {
297
+ super.initializeState();
298
+ this.getAttributeManager().addInstanced({
299
+ instancePieSlices: { size: 3, accessor: 'getSlice', defaultValue: [0, 2 * Math.PI, 0] },
300
+ instancePieLineWidths: { size: 1, accessor: 'getSliceLineWidth', defaultValue: 0 }
301
+ });
302
+ }
303
+ }
304
+ Layer.layerName = 'PieSliceLayer';
305
+ // deck.gl merges a subclass's defaultProps over its base class's
306
+ Layer.defaultProps = {
307
+ getSlice: { type: 'accessor', value: [0, 2 * Math.PI, 0] },
308
+ getSliceLineWidth: { type: 'accessor', value: 0 }
309
+ };
310
+ return Layer;
311
+ }
203
312
 
204
313
  // Cached so multiple ixmaps.Map() calls on one page (or a page that
205
314
  // still has its own static <script> tags for these libraries) only
@@ -277,6 +386,7 @@
277
386
  [...document.styleSheets].some(s => s.href === LIB_URLS.maplibreCss) ? Promise.resolve() : loadStylesheet(LIB_URLS.maplibreCss)
278
387
  ]).then(() => {
279
388
  ({ IconLayer, ScatterplotLayer, GeoJsonLayer, MapLibreOverlay, TextLayer } = global.deck);
389
+ PieSliceLayer = makePieSliceLayer(ScatterplotLayer);
280
390
  // which engine and libraries this page really runs (a page may
281
391
  // bring its own MapLibre/deck.gl — the loaded ones are named)
282
392
  const ml = global.maplibregl && typeof global.maplibregl.getVersion === 'function' ? global.maplibregl.getVersion() : '?';
@@ -1952,6 +2062,8 @@
1952
2062
  // outliers" inverse mode) stays unimplemented — no config here uses it.
1953
2063
  const KNOWN_INERT_FLAGS = ['SEQUENCE', 'STAR', 'SORT', 'DOWN', 'RECT', 'CLIPTOGEOBOUNDS', 'OUTLIER',
1954
2064
  'POW2', 'POW3', 'NOSORT'];
2065
+ // flat's PIE / DONUT variants with no rendering here (see _buildPieLayers)
2066
+ const PIE_INERT_FLAGS = [/3D/, /VOLUME/, /HEIGHT/, /STARBURST/, /FLOWER/, /RAYS/, /BOW/, /ZOOM/, /DIRECTION/, /DIRECTED/, /POLAR/];
1955
2067
  const _warnedFlags = new Set();
1956
2068
 
1957
2069
  // GL-PORT COMPAT: real ixmaps-flat's global ixmaps.getThemeObj(szId) /
@@ -2542,6 +2654,17 @@
2542
2654
  console.info(`[ixmaps-gl] type flag "${flag}" recognized, no distinct rendering behavior implemented yet`);
2543
2655
  }
2544
2656
  });
2657
+ // PIE variants _buildPieLayers doesn't draw (the pie is drawn flat and
2658
+ // plain): flat tests them as substrings, so do these
2659
+ if (flags.has('PIE') && flags.has('CHART')) {
2660
+ flags.forEach(flag => {
2661
+ const inert = PIE_INERT_FLAGS.find(re => re.test(flag));
2662
+ if (inert && !(/HEIGHT/.test(flag) && !flags.has('3D')) && !_warnedFlags.has('PIE:' + flag)) {
2663
+ _warnedFlags.add('PIE:' + flag);
2664
+ console.info(`[ixmaps-gl] type flag "${flag}" on a PIE chart recognized, not drawn yet — the pie is drawn flat and plain`);
2665
+ }
2666
+ });
2667
+ }
2545
2668
 
2546
2669
  const rawBinding = Object.assign({}, def.binding);
2547
2670
  const targets = resolveBindingTargets(style, rawBinding, def);
@@ -3554,7 +3677,7 @@
3554
3677
  // flat's legend prints them (legend.js 610, 810: __formatValue
3555
3678
  // with valuedecimals, else 2, "BLANK" — whole numbers without
3556
3679
  // decimals), a colorscheme "none" part as an outline in linecolor
3557
- const multiFieldLegend = !!rt._multiFields && rt._isMultiFieldSequence();
3680
+ const multiFieldLegend = rt._isMultiFieldChart();
3558
3681
  const legendTotalText = v => (multiFieldLegend
3559
3682
  ? flatFormatValue(v, legendDecimals(rt.style), 'BLANK')
3560
3683
  : rt._formatTooltipValue(v));
@@ -3605,7 +3728,7 @@
3605
3728
  // a per-item chart over several value fields: a row per field,
3606
3729
  // the field's sum over the visible items (flat's NYC status
3607
3730
  // legend: bikes / empty docks / e-bikes, biggest sum first)
3608
- if (rt._multiFields && rt._isMultiFieldSequence()) {
3731
+ if (rt._isMultiFieldChart()) {
3609
3732
  (rt._activeFeatures || rt.features).forEach(f => {
3610
3733
  const c = f.geometry && f.geometry.coordinates;
3611
3734
  if (!c || typeof c[0] !== 'number') return;
@@ -4380,10 +4503,12 @@
4380
4503
  // hover/click tooltip lookup keeps working across a rotation.
4381
4504
  // Non-atlas layers (ix-dot-/ix-features-/ix-choropleth-) never
4382
4505
  // carry the suffix; stripping a pattern that isn't there is a
4383
- // no-op.
4384
- const base = layerId.replace(/-g\d+$/, '');
4506
+ // no-op. A theme's single circles (ix-bubbles-<name>-circles-gN,
4507
+ // a ScatterplotLayer) route like its bubbles.
4508
+ const base = layerId.replace(/-g\d+$/, '').replace(/-circles$/, '');
4385
4509
  return runtimes.find(r => {
4386
4510
  if (base === `ix-bubbles-${r.name}`) return isSymbolChart(r.flags);
4511
+ if (base === `ix-pie-${r.name}`) return r._isPieChart();
4387
4512
  if (base === `ix-dot-${r.name}`) return r.flags.has('DOT');
4388
4513
  if (base === `ix-choropleth-${r.name}`) return r.flags.has('CHOROPLETH');
4389
4514
  if (base === `ix-features-${r.name}`) return r.flags.has('FEATURE') || r.flags.has('FEATURES');
@@ -7045,6 +7170,94 @@
7045
7170
  return { offset, index, extent };
7046
7171
  }
7047
7172
 
7173
+ // Chart texts covered by charts drawn after them. Flat draws each chart
7174
+ // as one SVG group (symbol, then its value text) in the sorted draw
7175
+ // order, so a later chart's symbol paints over the texts of every chart
7176
+ // beneath it; here the texts are separate TextLayers above every icon.
7177
+ // This composites that occlusion on the CPU: for each text, the
7178
+ // transmittance Π(1 − α) of every LATER chart's symbol parts at a few
7179
+ // sample points of the text box, averaged — the fraction of the text
7180
+ // still showing through, its alpha. Partly covered texts fade instead
7181
+ // of being clipped (no per-pixel mask in deck.gl).
7182
+ // charts: in draw order, { x, y, parts: [{ dx, dy, r, shape, alpha }] }
7183
+ // in screen pixels, a part's alpha its opacity
7184
+ // texts: { order (index into charts), x, y, w, h }
7185
+ // Returns each text's transmittance, 0..1.
7186
+ const OCCLUSION_CELL_PX = 32;
7187
+ // below this a text is as good as hidden: left out of its TextLayer
7188
+ const CHART_TEXT_MIN_TRANSMITTANCE = 0.08;
7189
+ function chartTextTransmittance(charts, texts) {
7190
+ // the texts' sample points, then a grid over just their extent holding,
7191
+ // per cell, the charts (in draw order) whose reach overlaps it
7192
+ const SAMPLES = [[0, 0], [-0.35, -0.3], [0.35, -0.3], [-0.35, 0.3], [0.35, 0.3]];
7193
+ const nS = SAMPLES.length, px = new Float64Array(texts.length * nS), py = new Float64Array(texts.length * nS);
7194
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
7195
+ texts.forEach((t, i) => SAMPLES.forEach(([sx, sy], j) => {
7196
+ const x = px[i * nS + j] = t.x + sx * t.w, y = py[i * nS + j] = t.y + sy * t.h;
7197
+ if (x < minX) minX = x; if (x > maxX) maxX = x;
7198
+ if (y < minY) minY = y; if (y > maxY) maxY = y;
7199
+ }));
7200
+ const out = new Array(texts.length).fill(1);
7201
+ if (!texts.length || !charts.length) return out;
7202
+ const C = OCCLUSION_CELL_PX;
7203
+ const W = Math.floor((maxX - minX) / C) + 1, H = Math.floor((maxY - minY) / C) + 1;
7204
+ const reach = charts.map(c => {
7205
+ let ext = 0;
7206
+ for (const p of c.parts) ext = Math.max(ext, Math.sqrt(p.dx * p.dx + p.dy * p.dy) + p.r * Math.SQRT2);
7207
+ return ext;
7208
+ });
7209
+ 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)),
7210
+ Math.max(0, Math.floor((c.y - ext - minY) / C)), Math.min(H - 1, Math.floor((c.y + ext - minY) / C))];
7211
+ // two passes (count, fill) into one flat array: cells keep draw order
7212
+ const start = new Int32Array(W * H + 1);
7213
+ charts.forEach((c, k) => {
7214
+ const [x0, x1, y0, y1] = range(c, reach[k]);
7215
+ for (let gy = y0; gy <= y1; gy++) for (let gx = x0; gx <= x1; gx++) start[gy * W + gx + 1]++;
7216
+ });
7217
+ for (let i = 0; i < W * H; i++) start[i + 1] += start[i];
7218
+ const fill = start.slice(0, W * H), cells = new Int32Array(start[W * H]);
7219
+ charts.forEach((c, k) => {
7220
+ const [x0, x1, y0, y1] = range(c, reach[k]);
7221
+ for (let gy = y0; gy <= y1; gy++) for (let gx = x0; gx <= x1; gx++) cells[fill[gy * W + gx]++] = k;
7222
+ });
7223
+ // what of (x, y) still shows through chart c's parts
7224
+ const keepAt = (c, x, y) => {
7225
+ let keep = 1;
7226
+ for (const p of c.parts) {
7227
+ if (insideSymbolShape(p.shape, x - c.x - p.dx, y - c.y - p.dy, p.r)) keep *= 1 - p.alpha;
7228
+ }
7229
+ return keep;
7230
+ };
7231
+ texts.forEach((t, i) => {
7232
+ let sum = 0;
7233
+ for (let j = 0; j < nS; j++) {
7234
+ const x = px[i * nS + j], y = py[i * nS + j];
7235
+ const cell = Math.floor((y - minY) / C) * W + Math.floor((x - minX) / C);
7236
+ let keep = 1;
7237
+ // only the charts drawn after the text's own cover it
7238
+ for (let n = start[cell + 1] - 1; n >= start[cell] && cells[n] > t.order && keep > 0.005; n--) {
7239
+ const k = cells[n], c = charts[k], dx = x - c.x, dy = y - c.y;
7240
+ if (dx * dx + dy * dy < reach[k] * reach[k]) keep *= keepAt(c, x, y);
7241
+ }
7242
+ sum += keep;
7243
+ }
7244
+ out[i] = sum / nS;
7245
+ });
7246
+ return out;
7247
+ }
7248
+ // inside drawSymbolPath's shape of radius r, (dx, dy) from its center;
7249
+ // the shapes without their own test as the circle
7250
+ function insideSymbolShape(shape, dx, dy, r) {
7251
+ switch (shape) {
7252
+ case 'squarefull': return Math.abs(dx) <= r && Math.abs(dy) <= r;
7253
+ case 'square': return Math.abs(dx) <= r * Math.SQRT1_2 && Math.abs(dy) <= r * Math.SQRT1_2;
7254
+ case 'roundrect': return Math.abs(dx) <= r * 0.8 && Math.abs(dy) <= r * 0.8;
7255
+ case 'diamond': case 'carot': return Math.abs(dx) + Math.abs(dy) <= r;
7256
+ case 'empty': return false;
7257
+ default: return dx * dx + dy * dy <= r * r;
7258
+ }
7259
+ }
7260
+
7048
7261
  // A pixel offset [dx, dy] (y down) from lngLat at MapLibre zoom `zoom`,
7049
7262
  // as lng/lat (Web Mercator, 512 px world tiles) — for shapes too large
7050
7263
  // for an icon, drawn in map coordinates (rebuilt with every refresh).
@@ -7056,6 +7269,193 @@
7056
7269
  return [lngLat[0] + offset[0] * 360 / world, lat];
7057
7270
  }
7058
7271
 
7272
+ // CHART|PIE / DONUT: one pie's slices as real ixmaps-flat lays them out —
7273
+ // maptheme.js drawChart's PIE branch (19939-20330) and piechart.js
7274
+ // DonutChart.realize (181-510). `values`: the item's part values (field
7275
+ // order); `t`: the type string, tested as flat does, by substring (so
7276
+ // CENTERVALUE also is CENTER, XTHIN also THIN). Returns null when flat
7277
+ // draws no pie (no sum, no positive part), else
7278
+ // { slices: [{ i, value, percent, start, sweep, complement }],
7279
+ // inner: inner radius / radius,
7280
+ // center: { i, value, size } | null }
7281
+ // (k on a slice: its index among all of flat's donut parts)
7282
+ // i is the part's field index (-1: AUTOCOMPLETE's no-data complement);
7283
+ // start/sweep in degrees, clockwise from 12 o'clock.
7284
+ // - parts: in field order; SORT biggest first; REVERSE takes flat's
7285
+ // index n − i literally — field n doesn't exist and field 0 is never
7286
+ // reached, so a REVERSE pie loses its first part, as in flat
7287
+ // - a part is a slice when > 0, or = 0 unless ZEROISNOTVALUE (a 0 slice
7288
+ // has no angle and is not drawn); negative parts are left out
7289
+ // - the slices share 100 % (AUTOCOMPLETE below 100: the rest is a
7290
+ // no-data part, the values are taken as percentages)
7291
+ // - CENTER (without DONUT): the center part (style.centerpart first /
7292
+ // last / min / max — default max — or a field number) is no slice but
7293
+ // a disc in the middle, radius √(size / 100) of the pie's, size =
7294
+ // value / sum · 200; the ring around it has that inner radius
7295
+ // - DONUT: inner radius 0.42 (THICK 0.25, THIN 0.66, XTHIN 0.95, 3D 0.5)
7296
+ // - an aggregated 1-record item of fewer than 2 parts has no hole
7297
+ // - angles: from 0° (SYMMETRIC: the first part, BIGTOTOP the biggest,
7298
+ // centered on 12 o'clock), HALF over 180° from 270° (HALFPLUS10 200°
7299
+ // from 260°, HALFMINUS10 160° from 280°, HALFPLUS20 220° from 250°,
7300
+ // HALFMINUS20 140° from 290°)
7301
+ function pieSliceLayout(values, t, opts = {}) {
7302
+ t = String(t || '');
7303
+ const has = re => re.test(t);
7304
+ const parts = values.map(v => (isNaN(v) ? 0 : v));
7305
+ const sum = parts.reduce((a, v) => a + v, 0);
7306
+ const autocomplete = has(/AUTOCOMPLETE/);
7307
+ if (sum === 0 && !autocomplete) return null;
7308
+ const n = parts.length;
7309
+ let sortedIndex = null;
7310
+ if (has(/\bSORT\b/)) sortedIndex = parts.map((value, index) => ({ index, value })).sort((a, b) => b.value - a.value);
7311
+ if (has(/\bREVERSE\b/)) sortedIndex = parts.map((value, i) => ({ index: n - i, value }));
7312
+ const at = p => (sortedIndex ? sortedIndex[p].index : p);
7313
+ const donut = has(/DONUT/), centerFlag = has(/CENTER/);
7314
+ let center = null;
7315
+ if (centerFlag) {
7316
+ let cSum = 0, cMax = -Number.MAX_VALUE, cMin = Number.MAX_VALUE, iMin, iMax;
7317
+ for (let p = 0; p < n; p++) {
7318
+ const i = at(p);
7319
+ cSum += parts[i] || 0;
7320
+ if (parts[i] < cMin) { cMin = parts[i]; iMin = i; }
7321
+ if (parts[i] > cMax) { cMax = parts[i]; iMax = i; }
7322
+ }
7323
+ const cp = opts.centerPart != null && opts.centerPart !== '' ? String(opts.centerPart) : 'max';
7324
+ let ci = 0;
7325
+ if (cp === 'first') ci = 0;
7326
+ else if (cp === 'last') ci = n - 1;
7327
+ else if (cp === 'min') ci = iMin;
7328
+ else if (cp === 'max') ci = iMax;
7329
+ else if (Number(cp)) ci = Number(cp);
7330
+ center = { i: ci, value: parts[ci], size: parts[ci] / cSum * 200 };
7331
+ }
7332
+ const slices = [];
7333
+ for (let p = 0; p < n; p++) {
7334
+ const i = at(p);
7335
+ if (centerFlag && !donut && i === center.i) continue;
7336
+ const v = parts[i];
7337
+ if (v > 0 || (v === 0 && !has(/ZEROISNOTVALUE/))) slices.push({ i, value: v, percent: v });
7338
+ }
7339
+ let inner = 0;
7340
+ if (donut) inner = has(/3D/) ? 0.5 : has(/XTHIN/) ? 0.95 : has(/THIN/) ? 0.66 : has(/THICK/) ? 0.25 : 0.42;
7341
+ else if (centerFlag) inner = Math.sqrt(center.size / 100);
7342
+ // GR 09.11.2019 "donut with count or size == 1 --> bubble": only for an
7343
+ // aggregated item of 1 record (opts.itemCount) — flat's per-record
7344
+ // items have no nCount, so their 1-part donuts keep the hole
7345
+ if (slices.length < 2 && opts.itemCount === 1) inner = 0;
7346
+ // DonutChart.realize
7347
+ let biggest = slices[0], biggestValue = 0, any = false;
7348
+ for (const sl of slices) {
7349
+ if (sl.percent > 0) {
7350
+ if (has(/BIGTOTOP/) && sl.percent > biggestValue) { biggest = sl; biggestValue = sl.percent; }
7351
+ any = true;
7352
+ }
7353
+ }
7354
+ // flat draws its "Pie error: no values !" circle here
7355
+ if (!any && !autocomplete) return null;
7356
+ const pctSum = slices.reduce((a, sl) => a + sl.percent, 0);
7357
+ if (pctSum !== 100) {
7358
+ if (autocomplete && pctSum < 100) slices.push({ i: -1, value: null, percent: Math.floor((100 - pctSum) * 100000) / 100000, complement: true });
7359
+ else slices.forEach(sl => { sl.percent = sl.percent / pctSum * 100; });
7360
+ }
7361
+ let end = 0;
7362
+ if (has(/SYMMETRIC/) && slices.length) {
7363
+ if (has(/BIGTOTOP/)) {
7364
+ end = 360;
7365
+ let x = 0;
7366
+ for (; x < slices.length && slices[x] !== biggest; x++) end -= slices[x].percent < 100 ? Math.floor(360 / 100 * slices[x].percent) : 0;
7367
+ end -= slices[x] && slices[x].percent < 100 ? Math.floor(360 / 100 / 2 * biggest.percent) : 0;
7368
+ } else if (slices[0].percent < 100) {
7369
+ end = 360 - Math.floor(360 / 100 / 2 * slices[0].percent);
7370
+ }
7371
+ }
7372
+ if (has(/HALF/)) end = 270;
7373
+ if (has(/HALFPLUS10/)) end = 260;
7374
+ if (has(/HALFMINUS10/)) end = 280;
7375
+ if (has(/HALFPLUS20/)) end = 250;
7376
+ if (has(/HALFMINUS20/)) end = 290;
7377
+ let maxAngle = has(/HALF/) ? 180 : 360;
7378
+ if (has(/HALFPLUS10/)) maxAngle = 200;
7379
+ if (has(/HALFMINUS10/)) maxAngle = 160;
7380
+ if (has(/HALFPLUS20/)) maxAngle = 220;
7381
+ if (has(/HALFMINUS20/)) maxAngle = 140;
7382
+ const out = [];
7383
+ slices.forEach((sl, k) => {
7384
+ const sweep = sl.percent / 100 * maxAngle;
7385
+ const start = end % 360;
7386
+ end = start + sweep;
7387
+ if (end > 360) end = end % 360;
7388
+ // _crc_drawDonut draws nothing for start == end (a 0 part, or a
7389
+ // full turn that doesn't start at 0°); k: flat's donut part index
7390
+ // (its value texts count them, drawn or not)
7391
+ if (start === end || start < 0 || end < 0) return;
7392
+ out.push(Object.assign(sl, { start, sweep, k }));
7393
+ });
7394
+ return { slices: out, inner, center: centerFlag && !donut ? center : null };
7395
+ }
7396
+
7397
+ // A pie's VALUES as flat draws them for more than one part
7398
+ // (maptheme.js drawDonutText 25072-25155, drawTextforOneQuadrant
7399
+ // 24993-25065, piechart.js getTextPosition 517-555): a label per slice
7400
+ // outside the pie, joined to the slice by a leader line. Per slice: the
7401
+ // point at 1.1 R in the middle of its angle; the labels of each quadrant
7402
+ // stacked by that point's height (line height 1.2 font, kept apart, the
7403
+ // last ones pushed toward the pie's top/bottom), all at x = R + 2u right
7404
+ // or left; the leader line from the slice's rim to its 1.1 R point,
7405
+ // horizontally to R − 3u when short of it, to the label's height at
7406
+ // R + 2u, then 2u straight on; the text 1.5u further. Only flat's first
7407
+ // min(50, maxParts) donut parts get one (maxParts: the theme's class
7408
+ // count). Pixels, y down, relative to the pie center:
7409
+ // [{ slice, segments: [[x0, y0, x1, y1], ...], x, y (text baseline), anchor: 'start' | 'end' }]
7410
+ function pieValueLabelLayout(slices, R, fontPx, unitPx, maxParts) {
7411
+ const limit = Math.min(50, maxParts);
7412
+ const quads = [[], [], [], []];
7413
+ let yMax = 0;
7414
+ for (const sl of slices) {
7415
+ if (sl.k >= limit) continue;
7416
+ let end = sl.start + sl.sweep;
7417
+ if (end > 360) end = end % 360;
7418
+ const mid = sl.start < end ? (sl.start + end) / 2 : Math.min(360, (sl.start + end + 360) / 2) % 360;
7419
+ const sin = Math.sin(Math.PI * mid / 180), cos = Math.cos(Math.PI * mid / 180);
7420
+ const p = { slice: sl, x: sin * R * 1.1, y: -cos * R * 1.1, lx: sin * R, ly: -cos * R };
7421
+ yMax = Math.max(yMax, Math.abs(p.y));
7422
+ // flat's quadrants 1 (upper right) 2 (lower right) 3 (lower left)
7423
+ // 4 (upper left), folded onto positive x/y
7424
+ if (p.x > 0) {
7425
+ if (p.y > 0) quads[1].push(p);
7426
+ else quads[0].push({ slice: sl, x: p.x, y: -p.y, lx: p.lx, ly: -p.ly });
7427
+ } else if (p.y > 0) quads[2].push({ slice: sl, x: -p.x, y: p.y, lx: -p.lx, ly: p.ly });
7428
+ else quads[3].push({ slice: sl, x: -p.x, y: -p.y, lx: -p.lx, ly: -p.ly });
7429
+ }
7430
+ const out = [];
7431
+ const dirs = [[1, -1, 'start'], [1, 1, 'start'], [-1, 1, 'end'], [-1, -1, 'end']];
7432
+ quads.forEach((q, qi) => {
7433
+ q.sort((a, b) => (a.y > b.y ? 1 : -1));
7434
+ const [xDir, yDir, anchor] = dirs[qi];
7435
+ const lh = fontPx * 1.2;
7436
+ let yMin = lh / 2;
7437
+ let ySpace = yMax - lh * (q.length - 2);
7438
+ for (const p of q) {
7439
+ const knee = p.y / p.ly;
7440
+ const yPos = Math.max(yMin, Math.min(p.y, ySpace));
7441
+ yMin = yPos + lh;
7442
+ ySpace += fontPx;
7443
+ const xPos = R + 2 * unitPx;
7444
+ const segments = [];
7445
+ let sx = p.lx * xDir, sy = p.ly * yDir;
7446
+ if (knee) { segments.push([sx, sy, p.x * xDir, p.y * yDir]); sx = p.x * xDir; sy = p.y * yDir; }
7447
+ if (p.x < R - 3 * unitPx) { const x = (R - 3 * unitPx) * xDir; segments.push([sx, sy, x, sy]); sx = x; }
7448
+ let ex = xPos * xDir;
7449
+ const ey = yPos * yDir;
7450
+ segments.push([sx, sy, ex, ey]);
7451
+ segments.push([ex, ey, ex + 2 * unitPx * xDir, ey]);
7452
+ ex += 3.5 * unitPx * xDir;
7453
+ out.push({ slice: p.slice, segments, x: ex, y: ey + fontPx * 0.25, anchor });
7454
+ }
7455
+ });
7456
+ return out;
7457
+ }
7458
+
7059
7459
  // CHART|SYMBOL|SEQUENCE (flat maptheme.js 21184-21300, 21790-21925,
7060
7460
  // 22296-22395): one symbol per category part of a chart, in category
7061
7461
  // order or sorted (SORT: DOWN biggest first, UP smallest first), parts
@@ -8404,7 +8804,9 @@
8404
8804
  this._gridRefLat = (minLat <= maxLat) ? (minLat + maxLat) / 2 : 0;
8405
8805
  }
8406
8806
 
8407
- if (this.flags.has('DOMINANT') && this.binding.value && !isAggregatedCategoricalChoropleth(this)) {
8807
+ if (this._isPieChart() && this.binding.value) {
8808
+ this._preparePie();
8809
+ } else if (this.flags.has('DOMINANT') && this.binding.value && !isAggregatedCategoricalChoropleth(this)) {
8408
8810
  // .type("CHOROPLETH|DOMINANT") — a MULTI-field bound value
8409
8811
  // (binding.value is a pipe-joined field list, e.g. one CSV column
8410
8812
  // per age band for the same year/sex — same pipe convention
@@ -9660,6 +10062,9 @@
9660
10062
  // USER charts (a page's own chart function, style.userdraw) go the
9661
10063
  // chart pipeline too, drawn by _buildUserChartLayers (bubbles when the
9662
10064
  // page has no such function)
10065
+ // PIE / DONUT (flat's PIE branch comes before BUBBLE and SYMBOL in
10066
+ // drawChart, maptheme.js 19939)
10067
+ if (this._isPieChart()) return this._buildPieLayers(zoom, bbox, liveZoom, globeCenter);
9663
10068
  if (isSymbolChart(this.flags)) return this._buildChartLayers(zoom, bbox, liveZoom, globeCenter);
9664
10069
  console.warn(`[ixmaps-gl] layer "${this.name}": type "${[...this.flags].join('|')}" has no implemented renderer`);
9665
10070
  return [];
@@ -10022,6 +10427,51 @@
10022
10427
  _itemPlotFields() {
10023
10428
  return String(this.binding.value || '').split('|');
10024
10429
  }
10430
+ // CHART|PIE (and PIE|DONUT): one pie per record, its slices the value
10431
+ // fields (_buildPieLayers). An AGGREGATE pie (flat sums the records of
10432
+ // a grid cell or category into one pie) and a CATEGORICAL one (a part
10433
+ // per category) have no renderer here yet.
10434
+ _isPieChart() {
10435
+ return this.flags.has('CHART') && this.flags.has('PIE') && !this.flags.has('AGGREGATE')
10436
+ && !this.flags.has('CATEGORICAL') && !this.flags.has('DOT');
10437
+ }
10438
+ // a per-item chart whose parts are its value fields (legend rows, sums)
10439
+ _isMultiFieldChart() {
10440
+ return !!this._multiFields && (this._isMultiFieldSequence() || this._isPieChart());
10441
+ }
10442
+ // flat's theme statistics for a pie (maptheme.js 9119-9130): nMin/nMax
10443
+ // over every part value of every item, the classes (partsA, flat's
10444
+ // distributeValues over that range, as many as the colorscheme has
10445
+ // colors) — flat colors a 1-part pie (or any with CLASSES) by them
10446
+ // (maptheme.js 20330-20341); several parts are colored by field.
10447
+ _preparePie() {
10448
+ const fields = String(this.binding.value).split('|');
10449
+ const pooled = [];
10450
+ for (const f of this.features) {
10451
+ for (const k of fields) { const v = flatNumber(f.properties[k], this.flags); if (isFinite(v)) pooled.push(v); }
10452
+ }
10453
+ let nMin = Infinity, nMax = -Infinity;
10454
+ for (const v of pooled) { if (v < nMin) nMin = v; if (v > nMax) nMax = v; }
10455
+ const nParts = Math.trunc(styleNum(this.style.classes)) || colorSchemeClassCount(this.style.colorscheme) || DEFAULT_RANGE_CLASSES;
10456
+ if (fields.length > 1) {
10457
+ Object.assign(this, computeMultiFieldClasses(this.binding, this.style));
10458
+ this.categoryIndexByLabel = new Map(this.categoryLabels.map((v, i) => [v, i]));
10459
+ const scheme = Array.isArray(this.style.colorscheme) ? this.style.colorscheme : [];
10460
+ this._partNoFill = new Set(fields.map((f, i) => i).filter(i => String(scheme[i]).trim().toLowerCase() === 'none'));
10461
+ } else {
10462
+ const r = computeRangeClasses(this.features, this.binding, this.style, this.flags, v => this._formatTooltipValue(v));
10463
+ if (r) { Object.assign(this, r); this._rangeClassed = false; }
10464
+ this._multiFields = null;
10465
+ this._partNoFill = new Set();
10466
+ }
10467
+ this._pieFields = fields;
10468
+ if (pooled.length) {
10469
+ this._valueMin = nMin;
10470
+ this._valueMax = nMax;
10471
+ this.partsA = flatRangeParts(pooled, nMin, nMax, nParts, this.flags, styleNum(this.style.rangecentervalue));
10472
+ }
10473
+ }
10474
+
10025
10475
  // x-axis labels of a per-item PLOT (style.xaxis / label, else the fields)
10026
10476
  _itemPlotLabels() {
10027
10477
  const fields = this._itemPlotFields();
@@ -11434,13 +11884,71 @@
11434
11884
  // fillopacity and linecolor, as a single item (flat draws one
11435
11885
  // symbol) — the packed multi-category icon is for CATEGORICAL cells
11436
11886
  const singleSymbol = d => !d.properties.counts || (this._rangeClassed && d.properties.cat != null);
11437
- layers.push(new IconLayer({
11887
+ // flat's symbol outline (maptheme.js 21595-21610 and 21950-21997,
11888
+ // drawn 21625-21634 / 21995-22035): every SYMBOL shape is stroked,
11889
+ // (linewidth || 1) · normalX(min(2r / rMax, 0.2)) with rMax the
11890
+ // normal radius normalX(30 / 2) — on screen min(2r / 15, 0.2 unit)
11891
+ // × linewidth, centered on the outline like any SVG stroke. Its
11892
+ // color: linecolor; NOLINES or linecolor "none" none; a CATEGORICAL
11893
+ // theme without AGGREGATE/GROUP the symbol's own color (white: gray),
11894
+ // every other symbol chart the darker ChartColors.textColor of its
11895
+ // class color. BUBBLE keeps its linecolor/linewidth border, a width
11896
+ // in icon pixels
11897
+ const symbolOutline = flatChartBranch(this.flags) === 'symbol';
11898
+ const outlineRgb = d => {
11899
+ if (!symbolOutline) return singleBorderColorRgb && singleBorderWidthPx > 0 ? singleBorderColorRgb : null;
11900
+ if (singleBorderColorRgb) return singleBorderColorRgb;
11901
+ if (flatFlag(this.flags, 'NOLINES') || (this.style.linecolor && styleLineColor(this.style.linecolor) === 'none')) return null;
11902
+ const fill = (this.categoryColorsRgb || [])[d.properties.cat] || [128, 128, 128];
11903
+ if (perPartBranch) return flatChartTextRgb(fill);
11904
+ return fill.slice(0, 3).every(c => c >= 255) ? [128, 128, 128] : fill;
11905
+ };
11906
+ const outlinePx = (d, r) => (!outlineRgb(d) ? 0 : symbolOutline
11907
+ ? (styleNum(this.style.linewidth) || 1) * Math.min(2 * r / NORMAL_RADIUS_PX, 0.2 * unitPx)
11908
+ : singleBorderWidthPx * r * 2 / BUBBLE_ICON_SIZE);
11909
+ // only single circles (no group icon, no other shape, no pixel
11910
+ // offset — ScatterplotLayer has none): drawn as real circles. A
11911
+ // cached circle icon shrunk to a few device pixels samples a coarse
11912
+ // mip level of the atlas and reads as a square, where flat's SVG
11913
+ // circle of the same radius stays round. Its own id: deck.gl matches
11914
+ // layers by id alone, so a theme switch turning the IconLayer of
11915
+ // this id into a ScatterplotLayer kept the icon layer's state and
11916
+ // drew nothing (the fuel-price page's station points); hover/click
11917
+ // routing goes by the final id (layerRuntimeById). A symbol's outline
11918
+ // is centered on its radius (flat); BUBBLE's border keeps the
11919
+ // icon's look, inside the radius
11920
+ if (combined.length && !alignOf.active && combined.every(d => singleSymbol(d) && shapeOf(d.properties) === 'circle')) {
11921
+ const radiusPx = d => vRadius(sizeValueOf(d));
11922
+ const lineRgb = d => outlineRgb(d) || [0, 0, 0];
11923
+ layers.push(new ScatterplotLayer({
11924
+ id: `ix-bubbles-${this.name}-circles-g${this._iconGeneration}`,
11925
+ data: combined, pickable: true,
11926
+ getPosition: d => d.geometry.coordinates,
11927
+ getRadius: d => radiusPx(d) - (symbolOutline ? 0 : outlinePx(d, radiusPx(d)) / 2),
11928
+ radiusUnits: 'pixels',
11929
+ getFillColor: d => [...((this.categoryColorsRgb || [])[d.properties.cat] || [128, 128, 128]).slice(0, 3), Math.round(this._iconAlpha(d) * fillOpacity)],
11930
+ stroked: combined.some(d => outlineRgb(d)),
11931
+ getLineColor: d => [...lineRgb(d).slice(0, 3), this._iconAlpha(d)],
11932
+ getLineWidth: d => outlinePx(d, radiusPx(d)),
11933
+ lineWidthUnits: 'pixels',
11934
+ billboard: true,
11935
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
11936
+ }));
11937
+ } else layers.push(new IconLayer({
11438
11938
  id: `ix-bubbles-${this.name}-g${this._iconGeneration}`,
11439
11939
  data: combined, pickable: true,
11440
11940
  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),
11941
+ getIcon: d => {
11942
+ if (!singleSymbol(d)) return this._buildBubbleIcon(d.properties.counts, this.categoryColorsRgb, this._groupIsolation(d), Array.isArray(this.style.symbols) ? this.style.symbols : null);
11943
+ const shape = shapeOf(d.properties), rgb = outlineRgb(d);
11944
+ if (!symbolOutline || !rgb) return this._buildSingleIcon((this.categoryColorsRgb || [])[d.properties.cat], fillOpacity, shape, rgb, rgb ? singleBorderWidthPx : 0);
11945
+ // the outline in the icon's own pixels, inside its edge; quarter
11946
+ // pixel steps keep the icon cache small. flat's "empty" strokes
11947
+ // #888888 whatever the class color
11948
+ const r = vRadius(sizeValueOf(d));
11949
+ const w = Math.round(outlinePx(d, r) * BUBBLE_ICON_SIZE / Math.max(1e-6, 2 * r) * 4) / 4;
11950
+ return this._buildSingleIcon((this.categoryColorsRgb || [])[d.properties.cat], fillOpacity, shape, shape === 'empty' ? [136, 136, 136] : rgb, w);
11951
+ },
11444
11952
  getSize: d => vRadius(sizeValueOf(d)) * 2,
11445
11953
  // group icons bake a fixed 0.9 alpha; an explicit fillopacity scales it
11446
11954
  // so a near-transparent theme doesn't show opaque 1-px-cell clusters
@@ -11478,8 +11986,8 @@
11478
11986
  // VALUES: bold value label centered on each bubble (see
11479
11987
  // flatValueText/valuesFontSizePx above). Labels are their own
11480
11988
  // 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
11989
+ // layer; a label under later-drawn charts is faded or dropped by
11990
+ // _occludeChartTexts, as flat's later chart groups cover it. Entries whose
11483
11991
  // computed font size would be sub-pixel are dropped rather than
11484
11992
  // rendered at getSize: 0, matching the real engine's own "too
11485
11993
  // small to bother" gate.
@@ -11488,7 +11996,11 @@
11488
11996
  const textOpts = this._valueTextOpts();
11489
11997
  const pointValueOf = valueTextOf || this._chartValueTextFn();
11490
11998
 
11999
+ // draw order of each chart (see chartTextTransmittance below); an
12000
+ // item maxcharts left out draws no chart, so no text either
12001
+ const orderOf = new Map(combined.map((d, k) => [d, k]));
11491
12002
  const pointLabels = individual.reduce((out, d) => {
12003
+ if (!orderOf.has(d)) return out;
11492
12004
  const radius = vRadius(d.properties.value);
11493
12005
  // flat's VALUES label prints the value (nValuesA), not the size;
11494
12006
  // its font by flat's chart branch (_chartValueTextFn)
@@ -11496,7 +12008,7 @@
11496
12008
  if (fontSize > VALUES_MIN_FONT_PX) {
11497
12009
  const o = alignOf(d);
11498
12010
  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 });
12011
+ pixelOffset: baseline ? [o[0], o[1] + baseline - 0.35 * fontSize] : o, order: orderOf.get(d) });
11500
12012
  }
11501
12013
  return out;
11502
12014
  }, []);
@@ -11512,6 +12024,7 @@
11512
12024
  // would misrepresent every category's own count as one summed
11513
12025
  // number nobody's individual bubble actually shows.
11514
12026
  const groupLabels = groups.reduce((out, d) => {
12027
+ if (!orderOf.has(d)) return out;
11515
12028
  const counts = d.properties.counts;
11516
12029
  const outerRadiusPx = vRadius(d.properties.total);
11517
12030
  const iconSizePx = outerRadiusPx * 2; // matches the cluster IconLayer's own getSize (*2) below
@@ -11526,7 +12039,7 @@
11526
12039
  const o = alignOf(d);
11527
12040
  out.push({ geometry: d.geometry, text: v.text, fontSize: v.fontSize, bold: v.bold,
11528
12041
  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 });
12042
+ pixelOffset: v.baseline ? [o[0], o[1] + v.baseline - 0.35 * v.fontSize] : o, order: orderOf.get(d) });
11530
12043
  }
11531
12044
  return out;
11532
12045
  }
@@ -11541,13 +12054,26 @@
11541
12054
  out.push({
11542
12055
  geometry: d.geometry, text, fontSize,
11543
12056
  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]]
12057
+ pixelOffset: [offsets[i].x * fitScale * pxPerCanvasUnit + alignOf(d)[0], offsets[i].y * fitScale * pxPerCanvasUnit + alignOf(d)[1]],
12058
+ order: orderOf.get(d)
11545
12059
  });
11546
12060
  }
11547
12061
  });
11548
12062
  return out;
11549
12063
  }, []);
11550
12064
 
12065
+ // a text under later charts fades by what still shows through them
12066
+ // (chartTextTransmittance), as flat's per-chart SVG groups paint
12067
+ // each later symbol over it; nearly hidden ones are dropped. (Flat's
12068
+ // later GLOW circles veil it too, but they veil the earlier symbols
12069
+ // as well, and here every glow lies under every symbol — a text
12070
+ // fading under a glow its own symbol isn't under would read wrong.)
12071
+ const visible = this._occludeChartTexts(combined, pointLabels.concat(groupLabels), {
12072
+ liveZoom, globeCenter, alignOf, radiusOf, singleSymbol, shapeOf, fillOpacity, singleBorderWidthPx
12073
+ });
12074
+ const keepVisible = labels => labels.filter(l => visible.has(l));
12075
+ const shownPointLabels = keepVisible(pointLabels), shownGroupLabels = keepVisible(groupLabels);
12076
+
11551
12077
  const textLayerCommonProps = {
11552
12078
  pickable: false,
11553
12079
  getPosition: d => d.geometry.coordinates,
@@ -11565,10 +12091,10 @@
11565
12091
  getAlignmentBaseline: 'center'
11566
12092
  };
11567
12093
  // 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' }));
12094
+ if (shownPointLabels.length) layers.push(new TextLayer({ id: `ix-points-values-${this.name}`, data: shownPointLabels, getPixelOffset: d => d.pixelOffset, ...textLayerCommonProps,
12095
+ fontWeight: shownPointLabels[0].bold === false ? 'normal' : 'bold' }));
12096
+ if (shownGroupLabels.length) layers.push(new TextLayer({ id: `ix-cluster-values-${this.name}`, data: shownGroupLabels, getPixelOffset: d => d.pixelOffset, ...textLayerCommonProps,
12097
+ fontWeight: shownGroupLabels[0].bold === false ? 'normal' : 'bold' }));
11572
12098
  }
11573
12099
 
11574
12100
  // LABEL: flat's chart label (maptheme.js 24533-24590) — a small text
@@ -11792,6 +12318,299 @@
11792
12318
  return layers;
11793
12319
  }
11794
12320
 
12321
+ // CHART|PIE / DONUT — real ixmaps-flat's PIE branch (maptheme.js
12322
+ // 19939-20533, piechart.js DonutChart): one pie per record at its
12323
+ // position (joined to its FEATURE shape by binding.lookup), a slice per
12324
+ // value field (pieSliceLayout), drawn by PieSliceLayer (see there why
12325
+ // not as icons).
12326
+ // - radius (flat's nSize in chart units, 15 = NORMAL_RADIUS_PX, × the
12327
+ // object zoom and style.scale): with a type word containing SIZE
12328
+ // (not NORMSIZE) and a size field 15 · (size / max size)^(1/sizepow)
12329
+ // — max size: normalsizevalue, else the field's maximum (flat's
12330
+ // nMaxSize) —, SIZE without a size field 15 · (sum / (nMax − nMin))
12331
+ // ^(1/sizepow) — the pie's sum against the range of the part values
12332
+ // (normalsizevalue replaces that range) —, SIZELOG 15 · log(v) /
12333
+ // log(max), at least 1; otherwise 15. A pie of radius 0 (or no
12334
+ // number) is not drawn.
12335
+ // - colors: a field's colorscheme color; a 1-field pie (or CLASSES)
12336
+ // takes its value's class color (flat's partsA, maptheme.js
12337
+ // 20330-20341); a "none" color fills nothing and strokes the pie in
12338
+ // linecolor, else black; AUTOCOMPLETE's rest in nodatacolor (#eeeeee)
12339
+ // - fill opacity fillopacity (flat: the chart group's fill-opacity,
12340
+ // default 1); opacity sets fill and outline
12341
+ // - outline #555566 (NOLINES none, WHITELINES white, linecolor wins),
12342
+ // (linewidth, default 0.1) · R / 15 px wide — flat's
12343
+ // normalX(linewidth) · R / 300 with normalX(1) = 20 chart units
12344
+ // (measured on the twin page): a hairline at the default
12345
+ // - CENTER (not DONUT): the center part's disc, 0.9 × the inner
12346
+ // radius, in its color, with GLOW two halos (6× at 0.05, 2× at 0.1
12347
+ // fill opacity); its value in bold in the middle with CENTERVALUE or
12348
+ // VALUES: min(0.8 r, 3.3 r / length) · valuescale, valuedecimals
12349
+ // (else 0), the unit up to 5 characters, ChartColors.textColor of
12350
+ // the part color
12351
+ // - DONUT|CENTERVALUE: the item's size value in the middle, min(0.5 r,
12352
+ // 2 r / length), valuecolor / textcolor, else #888888, over a 0.5
12353
+ // black halo
12354
+ // - VALUES (not STACKED, within valueupper/lower): several parts (or
12355
+ // NOINLINETEXT) a label per slice outside the pie on a leader line
12356
+ // (pieValueLabelLayout) — font 5u · valuescale, #999999 (textcolor)
12357
+ // on a white box at 0.5 (VALUEBACKGROUND: the slice color), leader
12358
+ // lines #888888 0.5u over a white 1.5u at 0.2; a single part its
12359
+ // value inside the pie, min(0.8 R, 3.4 R / length), 0.1 R above the
12360
+ // center. Texts: valuedecimals, else 2 below 1 and 0 above, plus the
12361
+ // unit.
12362
+ // - draw order: flat's sort before drawing (by the size value, the
12363
+ // sum or the first value; the biggest on top)
12364
+ // Not drawn here (a console note at type parsing, PIE_INERT_FLAGS):
12365
+ // 3D, VOLUME, HEIGHT, STARBURST, FLOWER/RAYS, BOW, ZOOM, DIRECTION /
12366
+ // DIRECTED, POLAR — the pie is drawn flat and plain instead. Nor
12367
+ // style.clipparts, showparts, gridx (flat's several pies per item).
12368
+ // Known divergence: flat draws a chart's value labels in the chart's
12369
+ // own group, so a bigger pie drawn later covers a smaller neighbour's
12370
+ // labels; here every label lies above every pie.
12371
+ // Known divergence: the leader lines are drawn in map coordinates
12372
+ // (pixel offsets converted at the current zoom, Web Mercator), so under
12373
+ // the globe projection they are approximate.
12374
+ _buildPieLayers(zoom, bbox, liveZoom = zoom, globeCenter = null) {
12375
+ const st = this.style;
12376
+ const t = this.flags.typeString != null ? this.flags.typeString : [...this.flags].join('|');
12377
+ const has = re => re.test(t);
12378
+ const fields = this._pieFields || String(this.binding.value || '').split('|');
12379
+ const multi = fields.length > 1;
12380
+ const unit = objectZoomFactor(liveZoom, this.mapOptions) * (styleNum(st.scale) || 1);
12381
+ const sizePow = resolveSizePow(st, this.flags);
12382
+ // flat tests /SIZE/: also NOSIZE and GRIDSIZE
12383
+ const sizeFlag = has(/SIZE/) && !has(/NORMSIZE/);
12384
+ const sizeLog = has(/SIZELOG/);
12385
+ const nsv = styleNum(st.normalsizevalue);
12386
+ const maxSize = nsv || this._maxSizeValue;
12387
+ const range = nsv || ((this._valueMax || 0) - (this._valueMin || 0));
12388
+ const nSizeOf = (sum, size) => {
12389
+ const n = NORMAL_RADIUS_PX;
12390
+ if (sizeFlag && this.binding.size) {
12391
+ return sizeLog ? Math.max(1, n / Math.log(maxSize) * Math.log(size)) : n / Math.pow(maxSize, 1 / sizePow) * Math.pow(size, 1 / sizePow);
12392
+ }
12393
+ if (sizeFlag) {
12394
+ return sizeLog ? Math.max(1, n / Math.log(range) * Math.log(sum)) : n / Math.pow(range, 1 / sizePow) * Math.pow(sum, 1 / sizePow);
12395
+ }
12396
+ return n;
12397
+ };
12398
+ const colors = this.categoryColorsRgb || [];
12399
+ const scheme = Array.isArray(st.colorscheme) ? st.colorscheme : [];
12400
+ const noFill = this._partNoFill || new Set();
12401
+ const byClass = has(/CLASSES/) || !multi;
12402
+ const classColor = v => {
12403
+ const parts = this.partsA || [];
12404
+ for (let x = 0; x < parts.length; x++) if (v < parts[x].max) return colors[x];
12405
+ return null;
12406
+ };
12407
+ const sliceRgb = sl => (sl.complement ? (parseCssColor(String(st.nodatacolor || '')) || [0xee, 0xee, 0xee])
12408
+ : (byClass && classColor(sl.value)) || colors[sl.i] || [128, 128, 128]);
12409
+ const fo = styleNum(st.fillopacity), op = styleNum(st.opacity);
12410
+ // flat sets the group's fill-opacity only for a truthy value
12411
+ const fillAlpha = op > 0 ? op : fo > 0 ? fo : 1;
12412
+ const lineAlpha = op > 0 ? op : 1;
12413
+ let line = '#555566';
12414
+ if (has(/NOLINES/)) line = 'none';
12415
+ if (has(/WHITELINES/)) line = 'white';
12416
+ const lc = st.linecolor != null && st.linecolor !== '' ? styleLineColor(st.linecolor) : null;
12417
+ if (lc) line = lc;
12418
+ if (scheme.slice(0, fields.length).some(c => String(c).trim().toLowerCase() === 'none')) line = lc || 'black';
12419
+ const lineRgb = line === 'none' ? null : hexOrNamedToRgb(line);
12420
+ const lineWidthOf = r => (lineRgb ? (styleNum(st.linewidth) || 0.1) * r / 15 : 0);
12421
+ const lineRgba = lineRgb ? [...lineRgb.slice(0, 3), Math.round(255 * lineAlpha)] : [0, 0, 0, 0];
12422
+
12423
+ const charts = [];
12424
+ for (const f of this._activeFeatures || this.features) {
12425
+ const c = f.geometry && f.geometry.coordinates;
12426
+ if (!c || typeof c[0] !== 'number') continue;
12427
+ const [lng, lat] = c;
12428
+ if (lng < bbox[0] || lng > bbox[2] || lat < bbox[1] || lat > bbox[3]) continue;
12429
+ if (globeCenter && !isOnVisibleHemisphere(lng, lat, globeCenter)) continue;
12430
+ const raw = f.properties || {};
12431
+ const values = fields.map(k => { const v = flatNumber(raw[k], this.flags); return isNaN(v) ? 0 : v; });
12432
+ const layout = pieSliceLayout(values, t, { centerPart: st.centerpart });
12433
+ if (!layout) continue;
12434
+ const sum = values.reduce((a, v) => a + v, 0);
12435
+ const size = this.binding.size ? parseFloat(raw[this.binding.size]) : undefined;
12436
+ const r = nSizeOf(sum, size) * unit;
12437
+ if (!(r > 0) || !isFinite(r)) continue;
12438
+ // flat's draw-order key (maptheme.js 16812-16860): the size value,
12439
+ // else with SIZE the sum, else the first value
12440
+ const key = this.binding.size ? size : has(/SIZE/) ? sum : values[0];
12441
+ // the tooltip's {{theme.item.chart}}: a row per field (parts), a
12442
+ // 1-field pie its class (cat) like a range-classed bubble
12443
+ const properties = multi ? { value: values[0], parts: values, raw }
12444
+ : { value: values[0], cat: this.partsA ? this._resolvePartsClass(values[0]) : 0, raw };
12445
+ charts.push({ c, r, layout, size, key, properties });
12446
+ }
12447
+ // flat sorts the charts before drawing (maptheme.js 16774-16870),
12448
+ // ascending — the last drawn on top —, SORT|UP descending; not with
12449
+ // NOSRT, NOSIZE (unless SORT) or when all values are equal
12450
+ if (((this._valueMin !== this._valueMax) || this.binding.size) && !has(/NOSRT/)
12451
+ && (!has(/NOSIZE/) || has(/\bSORT\b/))) {
12452
+ const down = has(/\bSORT\b/) && has(/\bUP\b/);
12453
+ charts.sort((a, b) => (down ? b.key - a.key : a.key - b.key) || 0);
12454
+ }
12455
+ // align / offsetx / offsety (flatChartAlignOffset) — flat's PIE
12456
+ // ptNull is (0, r + 5 units); the chart's half size taken as r, as
12457
+ // for BUBBLE (ASSUMPTION: not measured on flat for PIE)
12458
+ const aligned = !!(st.align || styleNum(st.offsetx) || styleNum(st.offsety));
12459
+ charts.forEach(ch => {
12460
+ ch.off = aligned ? flatChartAlignOffset(st, { unit, symbolScale: 1, half: ch.r, orig: ch.r + 5 * unit }) : [0, 0];
12461
+ });
12462
+
12463
+ const TAU = 2 * Math.PI;
12464
+ const slices = [], centers = [], glows = [];
12465
+ const glowOn = has(/GLOW/) && !has(/ZOOM/) && !glowHiddenByScale(st, liveZoom);
12466
+ for (const ch of charts) {
12467
+ for (const sl of ch.layout.slices) {
12468
+ const rgb = sliceRgb(sl);
12469
+ slices.push({
12470
+ position: ch.c, r: ch.r, off: ch.off, properties: ch.properties, chart: ch, part: sl,
12471
+ slice: [sl.start * Math.PI / 180, sl.sweep >= 360 ? TAU : sl.sweep * Math.PI / 180, ch.layout.inner],
12472
+ fill: [...rgb.slice(0, 3), !sl.complement && noFill.has(sl.i) ? 0 : Math.round(255 * fillAlpha)]
12473
+ });
12474
+ }
12475
+ const center = ch.layout.center;
12476
+ if (center) {
12477
+ const cr = ch.r * Math.sqrt(center.size / 100);
12478
+ ch.centerR = cr;
12479
+ const rgb = colors[center.i] || [128, 128, 128];
12480
+ 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)] });
12481
+ if (glowOn && cr > 0) {
12482
+ glows.push({ position: ch.c, r: cr * 6, off: ch.off, fill: [...rgb.slice(0, 3), Math.round(255 * 0.05)] });
12483
+ glows.push({ position: ch.c, r: cr * 2, off: ch.off, fill: [...rgb.slice(0, 3), Math.round(255 * 0.1)] });
12484
+ }
12485
+ }
12486
+ }
12487
+ const discProps = {
12488
+ getPosition: d => d.position,
12489
+ getRadius: d => d.r,
12490
+ getPixelOffset: d => d.off,
12491
+ getFillColor: d => d.fill,
12492
+ radiusUnits: 'pixels', billboard: true, stroked: false, filled: true, antialiasing: true,
12493
+ parameters: ICON_LAYER_GLOBE_PARAMETERS
12494
+ };
12495
+ const layers = [new PieSliceLayer(Object.assign({}, discProps, {
12496
+ id: `ix-pie-${this.name}`,
12497
+ data: slices, pickable: true,
12498
+ getLineColor: lineRgba,
12499
+ getSlice: d => d.slice,
12500
+ getSliceLineWidth: d => lineWidthOf(d.r)
12501
+ }))];
12502
+ if (centers.length) {
12503
+ layers.push(new PieSliceLayer(Object.assign({}, discProps, { id: `ix-pie-center-${this.name}`, data: centers, pickable: true })));
12504
+ }
12505
+ if (glows.length) {
12506
+ layers.push(new PieSliceLayer(Object.assign({}, discProps, { id: `ix-pie-glow-${this.name}`, data: glows, pickable: false })));
12507
+ }
12508
+ return layers.concat(this._buildPieTextLayers(charts, zoom, liveZoom, unit, sliceRgb, has));
12509
+ }
12510
+
12511
+ // the value texts of _buildPieLayers (see there)
12512
+ _buildPieTextLayers(charts, zoom, liveZoom, unit, sliceRgb, has) {
12513
+ const st = this.style;
12514
+ const valueScale = styleNum(st.valuescale) || 1;
12515
+ const units = st.units ? String(st.units) : '';
12516
+ const unitText = units ? (units[0] === '.' ? '' : ' ') + units : '';
12517
+ const partUnit = (has(/AUTOCOMPLETE/) && !/%/.test(unitText) ? ' % ' : '') + unitText;
12518
+ const noBreaks = flatNoBreaks(this._valueMin, this._valueMax);
12519
+ const dec = explicitValueDecimals(st);
12520
+ const fmt = (v, d) => flatFormatValue(v, d, noBreaks ? 'ROUND|NOBREAKS' : 'ROUND');
12521
+ const partText = sl => (sl.complement ? Math.round(sl.percent * 10) / 10 + ' % '
12522
+ : fmt(sl.value, dec != null ? dec : (sl.value < 1 ? 2 : 0)) + partUnit);
12523
+ const valuesOn = has(/VALUES/) && !has(/STACKED/) && !valuesHiddenByScale(st, zoom);
12524
+ const textOverride = st.valuecolor || st.textcolor;
12525
+ const inline = [], boxed = [], centerTexts = [], lines = [];
12526
+ const nFields = (this._pieFields || []).length;
12527
+ for (const ch of charts) {
12528
+ const o = ch.off;
12529
+ // CENTER: the center part's value (maptheme.js 20402-20410)
12530
+ if (ch.layout.center && (has(/CENTERVALUE/) || (has(/VALUES/) && !valuesHiddenByScale(st, zoom))) && ch.centerR > 0) {
12531
+ const cv = ch.layout.center.value;
12532
+ const text = fmt(cv, dec != null ? dec : 0) + (unitText.length <= 5 ? unitText : '');
12533
+ const fontSize = Math.min(ch.centerR * 0.8, ch.centerR * (3.3 / Math.max(1, text.length))) * valueScale;
12534
+ const rgb = (this.categoryColorsRgb || [])[ch.layout.center.i] || [128, 128, 128];
12535
+ if (fontSize > VALUES_MIN_FONT_PX) centerTexts.push({ position: ch.c, text, fontSize, off: o, color: flatChartTextRgb(rgb), bold: true });
12536
+ } else if (!ch.layout.center && has(/CENTERVALUE/) && ch.size !== undefined && !isNaN(ch.size)) {
12537
+ // flat prints String(nCount || nSize): a record's item has no
12538
+ // nCount, so its size value
12539
+ const text = String(ch.size);
12540
+ const fontSize = Math.min(ch.r * 0.5, ch.r * (2 / Math.max(1, text.length)));
12541
+ if (fontSize > VALUES_MIN_FONT_PX) {
12542
+ centerTexts.push({ position: ch.c, text, fontSize, off: o, bold: false,
12543
+ color: textOverride ? hexOrNamedToRgb(textOverride) : [0x88, 0x88, 0x88] });
12544
+ }
12545
+ }
12546
+ if (!valuesOn) continue;
12547
+ if (nFields > 1 || has(/NOINLINETEXT/)) {
12548
+ const fontSize = 5 * unit * valueScale;
12549
+ if (fontSize <= VALUES_MIN_FONT_PX) continue;
12550
+ for (const lab of pieValueLabelLayout(ch.layout.slices, ch.r, fontSize, unit, (this.partsA || []).length)) {
12551
+ const bg = has(/VALUEBACKGROUND/) ? sliceRgb(lab.slice) : [255, 255, 255];
12552
+ const textRgb = textOverride ? hexOrNamedToRgb(textOverride) : flatDerivateRgb(bg, bg[0] + bg[1] + bg[2] > 450 ? 0.6 : 3);
12553
+ boxed.push({ position: ch.c, text: partText(lab.slice), fontSize, anchor: lab.anchor,
12554
+ // the text's baseline → its center (arial: 0.35 font above)
12555
+ off: [o[0] + lab.x, o[1] + lab.y - 0.35 * fontSize], color: textRgb, bg: [...bg.slice(0, 3), 128] });
12556
+ lab.segments.forEach(sg => lines.push({ c: ch.c, o, sg }));
12557
+ }
12558
+ } else if (ch.layout.slices.length) {
12559
+ const sl = ch.layout.slices[ch.layout.slices.length - 1];
12560
+ const text = partText(sl);
12561
+ const fontSize = Math.min(ch.r * 0.8, ch.r * (3.4 / Math.max(1, text.length)));
12562
+ const color = textOverride ? hexOrNamedToRgb(textOverride) : flatChartTextRgb(sliceRgb(sl));
12563
+ // baseline at −0.6 R + R / 2 + 0.45 font (maptheme.js 20434)
12564
+ 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 });
12565
+ }
12566
+ }
12567
+ const layers = [];
12568
+ const textBase = {
12569
+ getPosition: d => d.position, getText: d => d.text, getSize: d => d.fontSize,
12570
+ getPixelOffset: d => d.off, getColor: d => d.color,
12571
+ sizeUnits: 'pixels', fontFamily: 'arial', characterSet: 'auto',
12572
+ getAlignmentBaseline: 'center', pickable: false
12573
+ };
12574
+ if (lines.length) {
12575
+ const feature = d => ({ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: [
12576
+ pixelOffsetLngLat(d.c, [d.o[0] + d.sg[0], d.o[1] + d.sg[1]], liveZoom),
12577
+ pixelOffsetLngLat(d.c, [d.o[0] + d.sg[2], d.o[1] + d.sg[3]], liveZoom)] } });
12578
+ const fc = { type: 'FeatureCollection', features: lines.map(feature) };
12579
+ [['bg', [255, 255, 255, Math.round(255 * 0.2)], 1.5], ['fg', [0x88, 0x88, 0x88, 255], 0.5]].forEach(([k, color, w]) => {
12580
+ layers.push(new GeoJsonLayer({
12581
+ id: `ix-pie-lines-${k}-${this.name}`, data: fc, pickable: false,
12582
+ stroked: true, filled: false, getLineColor: color, getLineWidth: w * unit, lineWidthUnits: 'pixels'
12583
+ }));
12584
+ });
12585
+ }
12586
+ if (boxed.length) {
12587
+ layers.push(new TextLayer(Object.assign({}, textBase, {
12588
+ id: `ix-pie-values-${this.name}`, data: boxed, fontWeight: 'normal',
12589
+ getTextAnchor: d => d.anchor,
12590
+ // flat's box: from 0.3 font left of the text to 0.4 font right of
12591
+ // it, 0.1 font taller (createTextLabel)
12592
+ background: true, getBackgroundColor: d => d.bg, backgroundBorderRadius: 0.1 * 5 * unit * valueScale,
12593
+ backgroundPadding: [0.3, 0, 0.4, 0.1].map(k => k * 5 * unit * valueScale)
12594
+ })));
12595
+ }
12596
+ if (inline.length) {
12597
+ layers.push(new TextLayer(Object.assign({}, textBase, { id: `ix-pie-inline-${this.name}`, data: inline, fontWeight: 'normal', getTextAnchor: 'middle' })));
12598
+ }
12599
+ // CENTER's value in bold; DONUT|CENTERVALUE's in normal weight over
12600
+ // flat's thin black halo (stroke-opacity 0.5)
12601
+ const bold = centerTexts.filter(d => d.bold), plain = centerTexts.filter(d => !d.bold);
12602
+ if (bold.length) {
12603
+ layers.push(new TextLayer(Object.assign({}, textBase, { id: `ix-pie-centervalue-${this.name}`, data: bold, getTextAnchor: 'middle', fontWeight: 'bold' })));
12604
+ }
12605
+ if (plain.length) {
12606
+ layers.push(new TextLayer(Object.assign({}, textBase, {
12607
+ id: `ix-pie-centercount-${this.name}`, data: plain, getTextAnchor: 'middle', fontWeight: 'normal',
12608
+ fontSettings: { sdf: true }, outlineWidth: 1, outlineColor: [0, 0, 0, 128]
12609
+ })));
12610
+ }
12611
+ return layers;
12612
+ }
12613
+
11795
12614
  // flat's chart position for this build (flatChartAlignOffset): a
11796
12615
  // function item → pixel offset, the same for every part of one chart
11797
12616
  // (symbol, glow, shadow, box, title, value text). unit is flat's
@@ -11909,6 +12728,60 @@
11909
12728
  return { noBreaks: flatNoBreaks(vMin, vMax), maxValue: styleNum(this.style.normalsizevalue) || (isFinite(vMax) ? vMax : undefined) };
11910
12729
  }
11911
12730
 
12731
+ // The charts of _buildChartLayers' bubbles layer as
12732
+ // chartTextTransmittance's occluders, in draw order and screen
12733
+ // pixels, and each text's resulting alpha: the text's color gets it,
12734
+ // the returned Set holds the texts still visible. Screen pixels:
12735
+ // Web Mercator world pixels at the live zoom; under globe a local
12736
+ // equal-area frame around the view center at the scale MapLibre's
12737
+ // globe matches Mercator's there (the limb's foreshortening ignored).
12738
+ _occludeChartTexts(combined, texts, o) {
12739
+ const visible = new Set();
12740
+ if (!texts.length) return visible;
12741
+ const toRad = Math.PI / 180;
12742
+ let screenOf;
12743
+ if (o.globeCenter) {
12744
+ const s = 512 * Math.pow(2, o.liveZoom) / 360 / Math.cos(o.globeCenter.lat * toRad);
12745
+ screenOf = c => [(((c[0] - o.globeCenter.lng) % 360 + 540) % 360 - 180) * Math.cos(c[1] * toRad) * s, -c[1] * s];
12746
+ } else {
12747
+ screenOf = c => { const p = lngLatToWorldPixel(c[0], c[1], o.liveZoom); return [p.x, p.y]; };
12748
+ }
12749
+ const symbols = Array.isArray(this.style.symbols) ? this.style.symbols : null;
12750
+ const charts = combined.map(d => {
12751
+ const [x, y] = screenOf(d.geometry.coordinates);
12752
+ const a = o.alignOf(d), r = o.radiusOf(d), iconAlpha = this._iconAlpha(d) / 255;
12753
+ let parts;
12754
+ if (o.singleSymbol(d)) {
12755
+ // the icon's shape is inset by half its border (_buildSingleIcon)
12756
+ const shape = o.shapeOf(d.properties);
12757
+ parts = [{ dx: 0, dy: 0, r: r * (1 - o.singleBorderWidthPx / BUBBLE_ICON_SIZE), shape, alpha: o.fillOpacity * iconAlpha }];
12758
+ } else {
12759
+ // _buildBubbleIcon's packed parts, its baked 0.9 alpha scaled by an
12760
+ // explicit fillopacity like the bubbles layer's getColor
12761
+ const counts = d.properties.counts, iso = this._groupIsolation(d);
12762
+ const { present, radii, offsets, fitScale } = (iso && isolatedBubblePackLayout(counts, BUBBLE_ICON_SIZE, iso)) || computeBubblePackLayout(counts, BUBBLE_ICON_SIZE);
12763
+ const px = 2 * r / BUBBLE_ICON_SIZE * fitScale;
12764
+ const alpha = 0.9 * (this.style.fillopacity != null ? o.fillOpacity : 1) * iconAlpha;
12765
+ parts = present.map((p, i) => ({ dx: offsets[i].x * px, dy: offsets[i].y * px, r: radii[i] * px,
12766
+ shape: symbols ? normalizeSymbolShape(symbols[Math.min(p.i, symbols.length - 1)]) : 'circle', alpha }));
12767
+ }
12768
+ return { x: x + a[0], y: y + a[1], parts };
12769
+ });
12770
+ // the text box: Arial digits are ~0.56 em wide, ~0.7 em high
12771
+ const boxes = texts.map(t => {
12772
+ const [x, y] = screenOf(t.geometry.coordinates);
12773
+ 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 };
12774
+ });
12775
+ const shown = chartTextTransmittance(charts, boxes);
12776
+ texts.forEach((t, i) => {
12777
+ if (shown[i] < CHART_TEXT_MIN_TRANSMITTANCE) return;
12778
+ const c = t.color || [0, 0, 0];
12779
+ if (shown[i] < 1) t.color = [c[0], c[1], c[2], Math.round((c[3] != null ? c[3] : 255) * shown[i])];
12780
+ visible.add(t);
12781
+ });
12782
+ return visible;
12783
+ }
12784
+
11912
12785
  // CHART|LABEL (maptheme.js 20647-21037), at the chart radius r of the
11913
12786
  // size value:
11914
12787
  // - LABEL: a rounded rectangle 2.05r wide and 1.6·f high (f = min(0.8r,
@@ -12985,7 +13858,7 @@
12985
13858
  // test-only: lets test/unit/*.test.mjs call pure internals directly (the
12986
13859
  // engine runs in a Node vm there); deliberately NOT on the ixmaps object
12987
13860
  global.__ixmapsGlInternals = {
12988
- IXMAPS_GL_VERSION, youtubeClickToPlay, symbolsFlagCompat, tooltipTable, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, sequenceLayout, ringsLayout, multiQuadOffsets, pixelOffsetLngLat,
13861
+ IXMAPS_GL_VERSION, youtubeClickToPlay, symbolsFlagCompat, tooltipTable, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, chartTextTransmittance, insideSymbolShape, sequenceLayout, ringsLayout, pieSliceLayout, pieValueLabelLayout, multiQuadOffsets, pixelOffsetLngLat,
12989
13862
  normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, MapBuilder, typeStyleNumbers, styleNum,
12990
13863
  resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
12991
13864
  equalIntervalBreaks, quantileBreaks, naturalBreaks, evenStrideSample, jenksBreakValues, partsFromBreakValues, resolvePartsClass,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ixmaps-gl",
3
- "version": "0.2.19",
3
+ "version": "0.2.20",
4
4
  "description": "MapLibre GL + deck.gl reimplementation of the ixmaps-flat declarative mapping engine",
5
5
  "main": "ixmaps-gl.js",
6
6
  "browser": "ixmaps-gl.js",