ixmaps-gl 0.2.18 → 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 +809 -90
  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.18/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.18/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.18/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.18';
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');
@@ -7046,68 +7171,79 @@
7046
7171
  }
7047
7172
 
7048
7173
  // Chart texts covered by charts drawn after them. Flat draws each chart
7049
- // as one SVG group (glow, symbol, value text, in that order) in the
7050
- // sorted draw order, so a later chart's symbol and glow paint over the
7051
- // texts of every chart beneath it; here the texts are separate TextLayers
7052
- // above every icon. This composites that occlusion on the CPU: for each
7053
- // text, the transmittance Π(1 − α) of every LATER chart's symbol parts
7054
- // and glow at a few sample points of the text box, averaged — the
7055
- // fraction of the text still showing through, its alpha. Partly covered
7056
- // texts fade instead of being clipped (no per-pixel mask in deck.gl).
7057
- // charts: in draw order, { x, y, parts: [{ dx, dy, r, shape, alpha }],
7058
- // glowR, glowAlpha } in screen pixels; a part's alpha its opacity,
7059
- // glowR 0 for none (glowAlpha scales glowProfileAlpha)
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
7060
7184
  // texts: { order (index into charts), x, y, w, h }
7061
7185
  // Returns each text's transmittance, 0..1.
7062
- const OCCLUSION_CELL_PX = 64;
7186
+ const OCCLUSION_CELL_PX = 32;
7063
7187
  // below this a text is as good as hidden: left out of its TextLayer
7064
7188
  const CHART_TEXT_MIN_TRANSMITTANCE = 0.08;
7065
7189
  function chartTextTransmittance(charts, texts) {
7066
- const grid = new Map();
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);
7067
7213
  charts.forEach((c, k) => {
7068
- let ext = c.glowR || 0;
7069
- for (const p of c.parts) ext = Math.max(ext, Math.hypot(p.dx, p.dy) + p.r * Math.SQRT2);
7070
- const x0 = Math.floor((c.x - ext) / OCCLUSION_CELL_PX), x1 = Math.floor((c.x + ext) / OCCLUSION_CELL_PX);
7071
- const y0 = Math.floor((c.y - ext) / OCCLUSION_CELL_PX), y1 = Math.floor((c.y + ext) / OCCLUSION_CELL_PX);
7072
- for (let gx = x0; gx <= x1; gx++) {
7073
- for (let gy = y0; gy <= y1; gy++) {
7074
- const key = gx + ',' + gy;
7075
- let cell = grid.get(key);
7076
- if (!cell) grid.set(key, cell = []);
7077
- cell.push(k);
7078
- }
7079
- }
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;
7080
7222
  });
7081
- const alphaAt = (c, x, y) => {
7223
+ // what of (x, y) still shows through chart c's parts
7224
+ const keepAt = (c, x, y) => {
7082
7225
  let keep = 1;
7083
- if (c.glowR) {
7084
- const d = Math.hypot(x - c.x, y - c.y) / c.glowR;
7085
- if (d < 1) keep *= 1 - c.glowAlpha * glowProfileAlpha(d);
7086
- }
7087
7226
  for (const p of c.parts) {
7088
7227
  if (insideSymbolShape(p.shape, x - c.x - p.dx, y - c.y - p.dy, p.r)) keep *= 1 - p.alpha;
7089
7228
  }
7090
7229
  return keep;
7091
7230
  };
7092
- // the box center and four points inside its corners
7093
- const SAMPLES = [[0, 0], [-0.35, -0.3], [0.35, -0.3], [-0.35, 0.3], [0.35, 0.3]];
7094
- return texts.map(t => {
7231
+ texts.forEach((t, i) => {
7095
7232
  let sum = 0;
7096
- for (const [sx, sy] of SAMPLES) {
7097
- const x = t.x + sx * t.w, y = t.y + sy * t.h;
7098
- const cell = grid.get(Math.floor(x / OCCLUSION_CELL_PX) + ',' + Math.floor(y / OCCLUSION_CELL_PX));
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);
7099
7236
  let keep = 1;
7100
- // cells list charts in draw order: only the later ones cover
7101
- if (cell) for (let i = cell.length - 1; i >= 0 && cell[i] > t.order && keep > 0; i--) keep *= alphaAt(charts[cell[i]], x, y);
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
+ }
7102
7242
  sum += keep;
7103
7243
  }
7104
- return sum / SAMPLES.length;
7244
+ out[i] = sum / nS;
7105
7245
  });
7106
- }
7107
- // _getGlowIcon's radial gradient: 0.28 at the center, 0.09 at half the
7108
- // radius, 0 at the edge (d = distance / radius)
7109
- function glowProfileAlpha(d) {
7110
- return d < 0.5 ? 0.28 - 0.19 * d / 0.5 : d < 1 ? 0.09 * (1 - (d - 0.5) / 0.5) : 0;
7246
+ return out;
7111
7247
  }
7112
7248
  // inside drawSymbolPath's shape of radius r, (dx, dy) from its center;
7113
7249
  // the shapes without their own test as the circle
@@ -7133,6 +7269,193 @@
7133
7269
  return [lngLat[0] + offset[0] * 360 / world, lat];
7134
7270
  }
7135
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
+
7136
7459
  // CHART|SYMBOL|SEQUENCE (flat maptheme.js 21184-21300, 21790-21925,
7137
7460
  // 22296-22395): one symbol per category part of a chart, in category
7138
7461
  // order or sorted (SORT: DOWN biggest first, UP smallest first), parts
@@ -8481,7 +8804,9 @@
8481
8804
  this._gridRefLat = (minLat <= maxLat) ? (minLat + maxLat) / 2 : 0;
8482
8805
  }
8483
8806
 
8484
- 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)) {
8485
8810
  // .type("CHOROPLETH|DOMINANT") — a MULTI-field bound value
8486
8811
  // (binding.value is a pipe-joined field list, e.g. one CSV column
8487
8812
  // per age band for the same year/sex — same pipe convention
@@ -9737,6 +10062,9 @@
9737
10062
  // USER charts (a page's own chart function, style.userdraw) go the
9738
10063
  // chart pipeline too, drawn by _buildUserChartLayers (bubbles when the
9739
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);
9740
10068
  if (isSymbolChart(this.flags)) return this._buildChartLayers(zoom, bbox, liveZoom, globeCenter);
9741
10069
  console.warn(`[ixmaps-gl] layer "${this.name}": type "${[...this.flags].join('|')}" has no implemented renderer`);
9742
10070
  return [];
@@ -10099,6 +10427,51 @@
10099
10427
  _itemPlotFields() {
10100
10428
  return String(this.binding.value || '').split('|');
10101
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
+
10102
10475
  // x-axis labels of a per-item PLOT (style.xaxis / label, else the fields)
10103
10476
  _itemPlotLabels() {
10104
10477
  const fields = this._itemPlotFields();
@@ -11511,13 +11884,71 @@
11511
11884
  // fillopacity and linecolor, as a single item (flat draws one
11512
11885
  // symbol) — the packed multi-category icon is for CATEGORICAL cells
11513
11886
  const singleSymbol = d => !d.properties.counts || (this._rangeClassed && d.properties.cat != null);
11514
- 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({
11515
11938
  id: `ix-bubbles-${this.name}-g${this._iconGeneration}`,
11516
11939
  data: combined, pickable: true,
11517
11940
  getPosition: d => d.geometry.coordinates,
11518
- getIcon: d => !singleSymbol(d)
11519
- ? this._buildBubbleIcon(d.properties.counts, this.categoryColorsRgb, this._groupIsolation(d), Array.isArray(this.style.symbols) ? this.style.symbols : null)
11520
- : 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
+ },
11521
11952
  getSize: d => vRadius(sizeValueOf(d)) * 2,
11522
11953
  // group icons bake a fixed 0.9 alpha; an explicit fillopacity scales it
11523
11954
  // so a near-transparent theme doesn't show opaque 1-px-cell clusters
@@ -11633,10 +12064,12 @@
11633
12064
 
11634
12065
  // a text under later charts fades by what still shows through them
11635
12066
  // (chartTextTransmittance), as flat's per-chart SVG groups paint
11636
- // each later symbol and glow over it; nearly hidden ones are dropped
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.)
11637
12071
  const visible = this._occludeChartTexts(combined, pointLabels.concat(groupLabels), {
11638
- liveZoom, globeCenter, alignOf, radiusOf, sizeValueOf, singleSymbol, shapeOf, fillOpacity, vRadius,
11639
- singleBorderWidthPx
12072
+ liveZoom, globeCenter, alignOf, radiusOf, singleSymbol, shapeOf, fillOpacity, singleBorderWidthPx
11640
12073
  });
11641
12074
  const keepVisible = labels => labels.filter(l => visible.has(l));
11642
12075
  const shownPointLabels = keepVisible(pointLabels), shownGroupLabels = keepVisible(groupLabels);
@@ -11885,6 +12318,299 @@
11885
12318
  return layers;
11886
12319
  }
11887
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
+
11888
12614
  // flat's chart position for this build (flatChartAlignOffset): a
11889
12615
  // function item → pixel offset, the same for every part of one chart
11890
12616
  // (symbol, glow, shadow, box, title, value text). unit is flat's
@@ -12002,28 +12728,8 @@
12002
12728
  return { noBreaks: flatNoBreaks(vMin, vMax), maxValue: styleNum(this.style.normalsizevalue) || (isFinite(vMax) ? vMax : undefined) };
12003
12729
  }
12004
12730
 
12005
- // CHART|LABEL (maptheme.js 20647-21037), at the chart radius r of the
12006
- // size value:
12007
- // - LABEL: a rounded rectangle 2.05r wide and 1.6·f high (f = min(0.8r,
12008
- // 3.4r / the value's text length)) from −r and −0.83f, corners 2r/15
12009
- // (normalX(2r / normal radius)), in the item color at fillopacity
12010
- // (default 1), stroked in linecolor or the color × 0.7 (NOLINES: none)
12011
- // linewidth (0.1) · unit · √(r / normal radius) wide (OUTLINE:
12012
- // unit · min(1, r / normal radius)), opaque with OUTLINE or a
12013
- // linecolor, else 1 below fillopacity 0.5, 0.3 above; with VALUES the
12014
- // text in bold, min(0.8r, 3.3r / its length) · valuescale, in
12015
- // valuecolor / textcolor, else ChartColors.textColor of a class
12016
- // color (white for a single-color theme). Flat's drop shadow rects
12017
- // (20950) never draw: they need fOrigShadow without fShadow, which
12018
- // the shadow gate (16740-16745) leaves equal.
12019
- // - LABEL|TEXTONLY: no box, only an invisible hit rect (the
12020
- // hover/tooltip target) and, with VALUES, the text at r·0.8·valuescale
12021
- // in normal weight, valuecolor/textcolor (else black), over a halo in
12022
- // the item color (stroke font/7, opacity 0.5).
12023
- // Both moved by the chart position (flatChartAlignOffset). The text is
12024
- // the hover target of TEXTONLY, the box of LABEL.
12025
- // The charts of _buildChartLayers' bubbles layer (and its GLOW / AURA)
12026
- // as chartTextTransmittance's occluders, in draw order and screen
12731
+ // The charts of _buildChartLayers' bubbles layer as
12732
+ // chartTextTransmittance's occluders, in draw order and screen
12027
12733
  // pixels, and each text's resulting alpha: the text's color gets it,
12028
12734
  // the returned Set holds the texts still visible. Screen pixels:
12029
12735
  // Web Mercator world pixels at the live zoom; under globe a local
@@ -12040,8 +12746,6 @@
12040
12746
  } else {
12041
12747
  screenOf = c => { const p = lngLatToWorldPixel(c[0], c[1], o.liveZoom); return [p.x, p.y]; };
12042
12748
  }
12043
- const glowOn = this.flags.has('GLOW') && !glowHiddenByScale(this.style, o.liveZoom);
12044
- const auraOn = !glowOn && this.flags.has('AURA') && !this.flags.has('ZOOM');
12045
12749
  const symbols = Array.isArray(this.style.symbols) ? this.style.symbols : null;
12046
12750
  const charts = combined.map(d => {
12047
12751
  const [x, y] = screenOf(d.geometry.coordinates);
@@ -12061,12 +12765,7 @@
12061
12765
  parts = present.map((p, i) => ({ dx: offsets[i].x * px, dy: offsets[i].y * px, r: radii[i] * px,
12062
12766
  shape: symbols ? normalizeSymbolShape(symbols[Math.min(p.i, symbols.length - 1)]) : 'circle', alpha }));
12063
12767
  }
12064
- if (auraOn) parts.push({ dx: 0, dy: 0, r: r * 1.3, shape: 'circle', alpha: 0.3 * iconAlpha });
12065
- return {
12066
- x: x + a[0], y: y + a[1], parts,
12067
- glowR: glowOn ? o.vRadius(o.sizeValueOf(d)) * (d.properties.counts ? 9 : 11) / 2 : 0,
12068
- glowAlpha: iconAlpha
12069
- };
12768
+ return { x: x + a[0], y: y + a[1], parts };
12070
12769
  });
12071
12770
  // the text box: Arial digits are ~0.56 em wide, ~0.7 em high
12072
12771
  const boxes = texts.map(t => {
@@ -12083,6 +12782,26 @@
12083
12782
  return visible;
12084
12783
  }
12085
12784
 
12785
+ // CHART|LABEL (maptheme.js 20647-21037), at the chart radius r of the
12786
+ // size value:
12787
+ // - LABEL: a rounded rectangle 2.05r wide and 1.6·f high (f = min(0.8r,
12788
+ // 3.4r / the value's text length)) from −r and −0.83f, corners 2r/15
12789
+ // (normalX(2r / normal radius)), in the item color at fillopacity
12790
+ // (default 1), stroked in linecolor or the color × 0.7 (NOLINES: none)
12791
+ // linewidth (0.1) · unit · √(r / normal radius) wide (OUTLINE:
12792
+ // unit · min(1, r / normal radius)), opaque with OUTLINE or a
12793
+ // linecolor, else 1 below fillopacity 0.5, 0.3 above; with VALUES the
12794
+ // text in bold, min(0.8r, 3.3r / its length) · valuescale, in
12795
+ // valuecolor / textcolor, else ChartColors.textColor of a class
12796
+ // color (white for a single-color theme). Flat's drop shadow rects
12797
+ // (20950) never draw: they need fOrigShadow without fShadow, which
12798
+ // the shadow gate (16740-16745) leaves equal.
12799
+ // - LABEL|TEXTONLY: no box, only an invisible hit rect (the
12800
+ // hover/tooltip target) and, with VALUES, the text at r·0.8·valuescale
12801
+ // in normal weight, valuecolor/textcolor (else black), over a halo in
12802
+ // the item color (stroke font/7, opacity 0.5).
12803
+ // Both moved by the chart position (flatChartAlignOffset). The text is
12804
+ // the hover target of TEXTONLY, the box of LABEL.
12086
12805
  _buildLabelChartLayers(combined, zoom, liveZoom, sizeValueOf) {
12087
12806
  const textOnly = this.flags.has('TEXTONLY');
12088
12807
  const showValues = this.flags.has('VALUES') && !valuesHiddenByScale(this.style, zoom);
@@ -13139,7 +13858,7 @@
13139
13858
  // test-only: lets test/unit/*.test.mjs call pure internals directly (the
13140
13859
  // engine runs in a Node vm there); deliberately NOT on the ixmaps object
13141
13860
  global.__ixmapsGlInternals = {
13142
- IXMAPS_GL_VERSION, youtubeClickToPlay, symbolsFlagCompat, tooltipTable, legendRowLabels, scaleDenom, cssColorAlpha, aggregatedCategoricalClass, dominantDopacityAlpha, isAggregatedCategoricalChoropleth, computeBubblePackLayout, isolatedBubblePackLayout, chartTextTransmittance, glowProfileAlpha, insideSymbolShape, 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,
13143
13862
  normalizeTheme, projectThemeToDefinition, withoutProjectCode, groupRecordCount, resolveBasemapStyleUrl, resolveMapTypeColor, LayerBuilder, LayerRuntime, MapBuilder, typeStyleNumbers, styleNum,
13144
13863
  resolveScriptUrl, isTrustedScriptUrl, loadProcessingScript, loadBrokerData, applyBrokerThemePatch, makeBrokerTheme,
13145
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.18",
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",