@jarenjs/charts 0.34.0
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.
- package/README.md +293 -0
- package/dist/types/component/index.d.ts +95 -0
- package/dist/types/core/axis.d.ts +77 -0
- package/dist/types/core/cartesian.d.ts +127 -0
- package/dist/types/core/chart.d.ts +58 -0
- package/dist/types/core/domain.d.ts +96 -0
- package/dist/types/core/marks.d.ts +86 -0
- package/dist/types/core/palette.d.ts +72 -0
- package/dist/types/core/scale.d.ts +59 -0
- package/dist/types/core/session.d.ts +85 -0
- package/dist/types/core/stream-adapter.d.ts +187 -0
- package/dist/types/index.d.ts +35 -0
- package/dist/types/transforms/benchmark-adapter.d.ts +169 -0
- package/dist/types/transforms/mermaid-adapter.d.ts +30 -0
- package/dist/types/types/bar.d.ts +213 -0
- package/dist/types/types/boxplot.d.ts +116 -0
- package/dist/types/types/candlestick.d.ts +218 -0
- package/dist/types/types/gauge.d.ts +68 -0
- package/dist/types/types/heatmap.d.ts +104 -0
- package/dist/types/types/line.d.ts +272 -0
- package/dist/types/types/map.d.ts +137 -0
- package/dist/types/types/pie.d.ts +146 -0
- package/dist/types/types/radar.d.ts +89 -0
- package/dist/types/types/sankey.d.ts +100 -0
- package/dist/types/types/scatter.d.ts +80 -0
- package/dist/types/types/streamgraph.d.ts +75 -0
- package/dist/types/types/treemap.d.ts +118 -0
- package/package.json +76 -0
- package/schemas/chart-definition.schema.json +448 -0
- package/src/component/index.js +125 -0
- package/src/core/axis.js +221 -0
- package/src/core/cartesian.js +192 -0
- package/src/core/chart.js +101 -0
- package/src/core/domain.js +123 -0
- package/src/core/marks.js +110 -0
- package/src/core/palette.js +126 -0
- package/src/core/scale.js +106 -0
- package/src/core/session.js +0 -0
- package/src/core/stream-adapter.js +613 -0
- package/src/index.js +40 -0
- package/src/transforms/benchmark-adapter.js +298 -0
- package/src/transforms/mermaid-adapter.js +19 -0
- package/src/types/bar.js +276 -0
- package/src/types/boxplot.js +216 -0
- package/src/types/candlestick.js +274 -0
- package/src/types/gauge.js +140 -0
- package/src/types/heatmap.js +176 -0
- package/src/types/line.js +349 -0
- package/src/types/map.js +378 -0
- package/src/types/pie.js +163 -0
- package/src/types/radar.js +224 -0
- package/src/types/sankey.js +391 -0
- package/src/types/scatter.js +148 -0
- package/src/types/streamgraph.js +158 -0
- package/src/types/treemap.js +322 -0
- package/styles/charts.css +83 -0
package/src/types/map.js
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The map chart type: GeoJSON drawn in Web Mercator, optionally
|
|
4
|
+
* shaded by a feature property (a choropleth). Data shape:
|
|
5
|
+
*
|
|
6
|
+
* data = { features: <FeatureCollection | Feature[] | Feature>,
|
|
7
|
+
* points?: [{ at: [lon, lat], label?, value? }] }
|
|
8
|
+
* config = { type:'map', title?, value?, label?, log?, simplify?, aspect? }
|
|
9
|
+
*
|
|
10
|
+
* `value` names the feature property to shade by and `label` the one to
|
|
11
|
+
* name features by (default `'name'`); `points` is the convenience path
|
|
12
|
+
* for a caller holding a list of places rather than GeoJSON, and folds
|
|
13
|
+
* into the same shape list at build time so the AST has one.
|
|
14
|
+
*
|
|
15
|
+
* The AST is unit-space rings, lines and dots plus a normalized
|
|
16
|
+
* magnitude per shape — the projection and the simplification happen in
|
|
17
|
+
* the build (they are geometry *of the data*, not of the drawing), the
|
|
18
|
+
* colors, the stroke widths and the ramp legend in the render.
|
|
19
|
+
*
|
|
20
|
+
* Two things are worth knowing about the projection. It is Web Mercator
|
|
21
|
+
* because that is what every reader's mental model of a map already is,
|
|
22
|
+
* and it is applied **only here**: nothing measures on the result, and
|
|
23
|
+
* `@jarenjs/core/geo` keeps its area and distance functions on the
|
|
24
|
+
* sphere for exactly that reason. And it is fitted uniformly — the map
|
|
25
|
+
* is centred in whichever axis has room left over rather than stretched
|
|
26
|
+
* to fill the frame, which is the single most common way to make a map
|
|
27
|
+
* look wrong.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { svgRoot, coord } from '@jarenjs/view/helpers';
|
|
31
|
+
import { clamp01 } from '@jarenjs/core/math';
|
|
32
|
+
import { bboxOf, bboxUnion, fitMercator, simplifyLine, simplifyRing } from '@jarenjs/core/geo';
|
|
33
|
+
import { formatTickValue } from '../core/axis.js';
|
|
34
|
+
import { FS_TITLE, annotateChart, chartTitle, legendRow } from '../core/cartesian.js';
|
|
35
|
+
import { SEQUENTIAL, sequentialColor } from '../core/palette.js';
|
|
36
|
+
import { normalizeTooltip, valueMark } from '../core/marks.js';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {object} MapShapeAST
|
|
40
|
+
* @property {string} label
|
|
41
|
+
* @property {number|null} value the shading property, when the feature had one
|
|
42
|
+
* @property {number|null} t normalized magnitude (0..1), null when unshaded
|
|
43
|
+
* @property {Array<Array<number[]>>} rings polygon rings in unit space,
|
|
44
|
+
* exterior first then holes; `[u, v]` with v growing downward
|
|
45
|
+
* @property {Array<Array<number[]>>} lines line strings in unit space
|
|
46
|
+
* @property {Array<number[]>} dots point positions in unit space
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {object} MapAST
|
|
50
|
+
* @property {'map'} type
|
|
51
|
+
* @property {string|null} title
|
|
52
|
+
* @property {number} aspect layout width:height ratio
|
|
53
|
+
* @property {number[]|null} bbox the geographic extent drawn, `[w,s,e,n]`
|
|
54
|
+
* @property {MapShapeAST[]} shapes
|
|
55
|
+
* @property {{min: number, max: number}|null} domain shading extent (null = unshaded)
|
|
56
|
+
* @property {{source: number, drawn: number}} vertices positions read vs kept
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Default simplification tolerance, as a fraction of the frame's width.
|
|
61
|
+
* At the default 560px-wide chart this is about a third of a pixel, so
|
|
62
|
+
* the simplification is invisible by construction: it can only remove
|
|
63
|
+
* vertices that would have landed on a neighbour's pixel anyway. A
|
|
64
|
+
* caller drawing much larger passes a smaller number, and `false`
|
|
65
|
+
* switches it off. Exported because the streaming map accumulator
|
|
66
|
+
* simplifies on arrival with the same default.
|
|
67
|
+
*/
|
|
68
|
+
export const MAP_SIMPLIFY = 0.0006;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build the geometry-free map AST.
|
|
72
|
+
* @param {any} data
|
|
73
|
+
* @param {any} [config]
|
|
74
|
+
* @returns {MapAST}
|
|
75
|
+
*/
|
|
76
|
+
export function buildMapAST(data, config = {}) {
|
|
77
|
+
const aspect = typeof config.aspect === 'number' && Number.isFinite(config.aspect) && config.aspect > 0
|
|
78
|
+
? config.aspect : 1.6;
|
|
79
|
+
const valueKey = typeof config.value === 'string' ? config.value : null;
|
|
80
|
+
const labelKey = typeof config.label === 'string' && config.label !== '' ? config.label : 'name';
|
|
81
|
+
const tolerance = config.simplify === false ? 0
|
|
82
|
+
: (typeof config.simplify === 'number' && config.simplify >= 0 ? config.simplify : MAP_SIMPLIFY);
|
|
83
|
+
const log = config.log === true;
|
|
84
|
+
|
|
85
|
+
const entries = collectEntries(data, valueKey, labelKey);
|
|
86
|
+
|
|
87
|
+
let bbox = null;
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
const box = bboxOf(entry.geometry);
|
|
90
|
+
if (box !== null)
|
|
91
|
+
bbox = bbox === null ? box : bboxUnion(bbox, box);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// the shading extent, over the features that actually carry a value
|
|
95
|
+
let min = Infinity;
|
|
96
|
+
let max = -Infinity;
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
const v = entry.value;
|
|
99
|
+
if (v === null || (log && v <= 0)) continue;
|
|
100
|
+
if (v < min) min = v;
|
|
101
|
+
if (v > max) max = v;
|
|
102
|
+
}
|
|
103
|
+
const domain = Number.isFinite(min) ? { min, max } : null;
|
|
104
|
+
const span = domain === null ? 0
|
|
105
|
+
: log ? Math.log10(max) - Math.log10(min)
|
|
106
|
+
: max - min;
|
|
107
|
+
|
|
108
|
+
const shapes = [];
|
|
109
|
+
const counts = { source: 0, drawn: 0 };
|
|
110
|
+
if (bbox !== null) {
|
|
111
|
+
const fit = fitMercator(bbox, aspect);
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
const shape = {
|
|
114
|
+
label: entry.label,
|
|
115
|
+
value: entry.value,
|
|
116
|
+
t: entry.value === null || domain === null || (log && entry.value <= 0) ? null
|
|
117
|
+
: span === 0 ? 0.5
|
|
118
|
+
: clamp01(log
|
|
119
|
+
? (Math.log10(entry.value) - Math.log10(min)) / span
|
|
120
|
+
: (entry.value - min) / span),
|
|
121
|
+
rings: [],
|
|
122
|
+
lines: [],
|
|
123
|
+
dots: [],
|
|
124
|
+
};
|
|
125
|
+
collectParts(entry.geometry, fit, tolerance, shape, counts);
|
|
126
|
+
if (shape.rings.length !== 0 || shape.lines.length !== 0 || shape.dots.length !== 0)
|
|
127
|
+
shapes.push(shape);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
type: 'map',
|
|
133
|
+
title: config.title ?? null,
|
|
134
|
+
aspect,
|
|
135
|
+
bbox,
|
|
136
|
+
shapes,
|
|
137
|
+
domain,
|
|
138
|
+
vertices: counts,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Flatten the input into `{label, value, geometry}` entries. Accepts a
|
|
144
|
+
* FeatureCollection, an array of Features or geometries, or a single
|
|
145
|
+
* one; the `points` convenience list becomes Point geometries so
|
|
146
|
+
* everything downstream sees the same thing.
|
|
147
|
+
*/
|
|
148
|
+
function collectEntries(data, valueKey, labelKey) {
|
|
149
|
+
const entries = [];
|
|
150
|
+
const source = data?.features ?? data;
|
|
151
|
+
const list = Array.isArray(source) ? source
|
|
152
|
+
: source?.type === 'FeatureCollection' ? (source.features ?? [])
|
|
153
|
+
: source === null || source === undefined ? [] : [source];
|
|
154
|
+
|
|
155
|
+
for (const item of list) {
|
|
156
|
+
if (item === null || typeof item !== 'object') continue;
|
|
157
|
+
const properties = item.type === 'Feature' ? (item.properties ?? {}) : item;
|
|
158
|
+
const geometry = item.type === 'Feature' ? item.geometry : item;
|
|
159
|
+
if (geometry === null || typeof geometry !== 'object') continue;
|
|
160
|
+
entries.push({
|
|
161
|
+
label: labelOf(properties, labelKey),
|
|
162
|
+
value: valueKey === null ? null : finiteOrNull(properties?.[valueKey]),
|
|
163
|
+
geometry,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const point of data?.points ?? []) {
|
|
168
|
+
if (point === null || typeof point !== 'object') continue;
|
|
169
|
+
const at = Array.isArray(point.at) ? point.at : null;
|
|
170
|
+
if (at === null || typeof at[0] !== 'number' || typeof at[1] !== 'number') continue;
|
|
171
|
+
entries.push({
|
|
172
|
+
label: labelOf(point, labelKey),
|
|
173
|
+
value: finiteOrNull(point.value),
|
|
174
|
+
geometry: { type: 'Point', coordinates: at },
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return entries;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function labelOf(properties, labelKey) {
|
|
181
|
+
const named = properties?.[labelKey] ?? properties?.label;
|
|
182
|
+
return named === null || named === undefined ? '' : String(named);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function finiteOrNull(v) {
|
|
186
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Walk a geometry, projecting and simplifying each part into the shape's
|
|
191
|
+
* unit-space lists. Unknown `type` values contribute nothing rather than
|
|
192
|
+
* throwing — a map of half-broken data should draw the half that works.
|
|
193
|
+
*/
|
|
194
|
+
function collectParts(geometry, fit, tolerance, shape, counts) {
|
|
195
|
+
const { type, coordinates } = geometry;
|
|
196
|
+
if (type === 'GeometryCollection') {
|
|
197
|
+
for (const inner of geometry.geometries ?? []) {
|
|
198
|
+
if (inner !== null && typeof inner === 'object')
|
|
199
|
+
collectParts(inner, fit, tolerance, shape, counts);
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (type === 'Point') {
|
|
204
|
+
const dot = project(coordinates, fit);
|
|
205
|
+
if (dot !== null) {
|
|
206
|
+
counts.source++;
|
|
207
|
+
counts.drawn++;
|
|
208
|
+
shape.dots.push(dot);
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (type === 'MultiPoint') {
|
|
213
|
+
for (const position of coordinates ?? []) {
|
|
214
|
+
const dot = project(position, fit);
|
|
215
|
+
if (dot !== null) {
|
|
216
|
+
counts.source++;
|
|
217
|
+
counts.drawn++;
|
|
218
|
+
shape.dots.push(dot);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (type === 'LineString') {
|
|
224
|
+
pushLine(coordinates, fit, tolerance, shape, counts);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (type === 'MultiLineString') {
|
|
228
|
+
for (const part of coordinates ?? [])
|
|
229
|
+
pushLine(part, fit, tolerance, shape, counts);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (type === 'Polygon') {
|
|
233
|
+
for (const ring of coordinates ?? [])
|
|
234
|
+
pushRing(ring, fit, tolerance, shape, counts);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (type === 'MultiPolygon') {
|
|
238
|
+
for (const rings of coordinates ?? []) {
|
|
239
|
+
for (const ring of rings ?? [])
|
|
240
|
+
pushRing(ring, fit, tolerance, shape, counts);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function project(position, fit) {
|
|
246
|
+
if (!Array.isArray(position)) return null;
|
|
247
|
+
const lon = position[0];
|
|
248
|
+
const lat = position[1];
|
|
249
|
+
if (typeof lon !== 'number' || typeof lat !== 'number'
|
|
250
|
+
|| !Number.isFinite(lon) || !Number.isFinite(lat))
|
|
251
|
+
return null;
|
|
252
|
+
return fit(lon, lat);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Project a run of positions, dropping the ones that are not positions. */
|
|
256
|
+
function projectRun(positions, fit, counts) {
|
|
257
|
+
if (!Array.isArray(positions)) return [];
|
|
258
|
+
const out = [];
|
|
259
|
+
for (const position of positions) {
|
|
260
|
+
const p = project(position, fit);
|
|
261
|
+
if (p !== null) {
|
|
262
|
+
counts.source++;
|
|
263
|
+
out.push(p);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function pushLine(positions, fit, tolerance, shape, counts) {
|
|
270
|
+
const run = projectRun(positions, fit, counts);
|
|
271
|
+
if (run.length < 2) return;
|
|
272
|
+
const drawn = tolerance > 0 ? simplifyLine(run, tolerance) : run;
|
|
273
|
+
counts.drawn += drawn.length;
|
|
274
|
+
shape.lines.push(drawn);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function pushRing(positions, fit, tolerance, shape, counts) {
|
|
278
|
+
const run = projectRun(positions, fit, counts);
|
|
279
|
+
if (run.length < 3) return;
|
|
280
|
+
const drawn = tolerance > 0 ? simplifyRing(run, tolerance) : run;
|
|
281
|
+
counts.drawn += drawn.length;
|
|
282
|
+
shape.rings.push(drawn);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Render a map AST to a pure-vnode SVG: one filled path per shape (all
|
|
287
|
+
* its rings as subpaths, `evenodd` so holes punch through whichever way
|
|
288
|
+
* the producer wound them), stroked paths for lines, dots for points,
|
|
289
|
+
* and a ramp key in the legend slot when the map is shaded.
|
|
290
|
+
* @param {MapAST} ast
|
|
291
|
+
* @param {{tokens: Record<string,string>, cssVars: Record<string,string>}} theme
|
|
292
|
+
* @param {string} hash
|
|
293
|
+
* @param {{rootClass?: string, keyPrefix?: string, ramp?: readonly string[], width?: number,
|
|
294
|
+
* tooltip?: import('../core/marks.js').ChartTooltipSpec}} [options]
|
|
295
|
+
* @returns {any}
|
|
296
|
+
*/
|
|
297
|
+
export function renderMapAST(ast, theme, hash, options = {}) {
|
|
298
|
+
const t = theme.tokens;
|
|
299
|
+
const ramp = options.ramp ?? SEQUENTIAL;
|
|
300
|
+
const tooltip = normalizeTooltip(options.tooltip);
|
|
301
|
+
const width = options.width ?? 560;
|
|
302
|
+
|
|
303
|
+
// The ramp key rides the frame's legend mechanism, exactly as the
|
|
304
|
+
// heatmap's does: five stops as swatches, the extent as end labels.
|
|
305
|
+
const rampStops = [0, 0.25, 0.5, 0.75, 1].map((v) => sequentialColor(v, ramp));
|
|
306
|
+
const legend = ast.domain === null ? null : rampStops.map((_, i) => ({
|
|
307
|
+
name: i === 0 ? formatTickValue(ast.domain.min)
|
|
308
|
+
: i === rampStops.length - 1 ? formatTickValue(ast.domain.max) : '',
|
|
309
|
+
swatch: i,
|
|
310
|
+
}));
|
|
311
|
+
|
|
312
|
+
// A map has no axes, no ticks and no gridlines, so it does not use the
|
|
313
|
+
// cartesian frame — only its title and legend chrome. That also lets
|
|
314
|
+
// the plot sit on an even margin, which a map needs and an axis-bearing
|
|
315
|
+
// plot (whose left margin holds the tick labels) cannot have.
|
|
316
|
+
const pad = 12;
|
|
317
|
+
const plotW = width - 2 * pad;
|
|
318
|
+
const plotH = plotW / ast.aspect;
|
|
319
|
+
const top = (ast.title ? 34 : 14) + (legend === null ? 0 : 22);
|
|
320
|
+
const height = top + plotH + 10;
|
|
321
|
+
const children = [];
|
|
322
|
+
|
|
323
|
+
if (ast.title)
|
|
324
|
+
children.push(chartTitle(width / 2, 22, ast.title, FS_TITLE, t));
|
|
325
|
+
if (legend !== null)
|
|
326
|
+
children.push(...legendRow(legend, width - 16, top - 10, theme, rampStops));
|
|
327
|
+
|
|
328
|
+
const toX = (u) => coord(pad + u * plotW);
|
|
329
|
+
const toY = (v) => coord(top + v * plotH);
|
|
330
|
+
const unshaded = ast.domain === null ? sequentialColor(0, ramp) : t.grid;
|
|
331
|
+
|
|
332
|
+
for (const shape of ast.shapes) {
|
|
333
|
+
const fill = shape.t === null ? unshaded : sequentialColor(shape.t, ramp);
|
|
334
|
+
const hover = shape.value === null
|
|
335
|
+
? shape.label
|
|
336
|
+
: `${shape.label}: ${formatTickValue(shape.value)}`;
|
|
337
|
+
const descriptor = { type: 'map', label: shape.label, value: shape.value };
|
|
338
|
+
|
|
339
|
+
if (shape.rings.length !== 0) {
|
|
340
|
+
let d = '';
|
|
341
|
+
for (const ring of shape.rings) {
|
|
342
|
+
for (let i = 0; i < ring.length; i++)
|
|
343
|
+
d += (i === 0 ? 'M' : 'L') + toX(ring[i][0]) + ' ' + toY(ring[i][1]) + ' ';
|
|
344
|
+
d += 'Z ';
|
|
345
|
+
}
|
|
346
|
+
children.push(valueMark('path', {
|
|
347
|
+
d: d.trim(), fill, 'fill-rule': 'evenodd',
|
|
348
|
+
stroke: t.sliceStroke, 'stroke-width': 0.5,
|
|
349
|
+
'stroke-linejoin': 'round', class: 'chart-map-area',
|
|
350
|
+
}, tooltip, hover, descriptor));
|
|
351
|
+
}
|
|
352
|
+
for (const line of shape.lines) {
|
|
353
|
+
let d = '';
|
|
354
|
+
for (let i = 0; i < line.length; i++)
|
|
355
|
+
d += (i === 0 ? 'M' : 'L') + toX(line[i][0]) + ' ' + toY(line[i][1]) + ' ';
|
|
356
|
+
children.push(valueMark('path', {
|
|
357
|
+
d: d.trim(), fill: 'none',
|
|
358
|
+
stroke: shape.t === null ? t.axis : fill, 'stroke-width': 1.5,
|
|
359
|
+
'stroke-linejoin': 'round', 'stroke-linecap': 'round', class: 'chart-map-line',
|
|
360
|
+
}, tooltip, hover, descriptor));
|
|
361
|
+
}
|
|
362
|
+
for (const dot of shape.dots) {
|
|
363
|
+
// A dot with no value is a *marker* — a city on a population map —
|
|
364
|
+
// which is a different thing from an area with no data, so it takes
|
|
365
|
+
// ink rather than the ramp's "unshaded" grey and stays visible
|
|
366
|
+
// against whatever it sits on.
|
|
367
|
+
children.push(valueMark('circle', {
|
|
368
|
+
cx: toX(dot[0]), cy: toY(dot[1]), r: 3.5,
|
|
369
|
+
fill: shape.t === null ? t.text : fill,
|
|
370
|
+
stroke: t.sliceStroke, 'stroke-width': 1, class: 'chart-map-dot',
|
|
371
|
+
}, tooltip, hover, descriptor));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const svg = svgRoot(options.rootClass ?? 'chart chart-svg chart-map-chart',
|
|
376
|
+
width, height, theme, children, (options.keyPrefix ?? 'map-') + hash);
|
|
377
|
+
return annotateChart(svg, ast.title);
|
|
378
|
+
}
|
package/src/types/pie.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The pie chart type, split into the geometry-free AST build and
|
|
4
|
+
* the SVG render (the two-stage contract every chart type follows).
|
|
5
|
+
* This is the extraction of the mermaid pie: `@jarenjs/mermaid` now
|
|
6
|
+
* delegates its `pie` diagrams here, passing its own class names,
|
|
7
|
+
* palette and theme through `render` options so its output is unchanged
|
|
8
|
+
* — charts never imports mermaid (the dependency arrow is one-way).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { svgRoot, rect, group, textAt, num, polarPoint, textWidth } from '@jarenjs/view/helpers';
|
|
12
|
+
import { CATEGORICAL } from '../core/palette.js';
|
|
13
|
+
import { normalizeTooltip, markProps } from '../core/marks.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} PieSliceAST
|
|
17
|
+
* @property {string} label
|
|
18
|
+
* @property {number} value
|
|
19
|
+
* @property {number} frac fraction of the total (0..1)
|
|
20
|
+
* @property {number} start start angle (radians, 12 o'clock = -π/2)
|
|
21
|
+
* @property {number} end end angle (radians)
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {object} PieAST
|
|
25
|
+
* @property {'pie'} type
|
|
26
|
+
* @property {string|null} title
|
|
27
|
+
* @property {number} total
|
|
28
|
+
* @property {number|null} inner donut hole radius as a fraction of the outer radius (null = solid pie)
|
|
29
|
+
* @property {PieSliceAST[]} slices
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build the geometry-free pie AST: labels/values → fractions and
|
|
34
|
+
* accumulated start/end angles, starting at 12 o'clock. `config.donut`
|
|
35
|
+
* turns the pie into a donut: `true` uses the default hole fraction,
|
|
36
|
+
* a number in (0, 1) sets it directly.
|
|
37
|
+
* @param {{slices?: {label: string, value: number}[]}} data
|
|
38
|
+
* @param {{title?: string|null, donut?: boolean|number}} [config]
|
|
39
|
+
* @returns {PieAST}
|
|
40
|
+
*/
|
|
41
|
+
export function buildPieAST(data, config = {}) {
|
|
42
|
+
const input = data?.slices ?? [];
|
|
43
|
+
const total = input.reduce((s, x) => s + x.value, 0) || 1;
|
|
44
|
+
const slices = [];
|
|
45
|
+
let angle = -Math.PI / 2;
|
|
46
|
+
for (let i = 0; i < input.length; i++) {
|
|
47
|
+
const frac = input[i].value / total;
|
|
48
|
+
const next = angle + frac * Math.PI * 2;
|
|
49
|
+
slices.push({
|
|
50
|
+
label: input[i].label,
|
|
51
|
+
value: input[i].value,
|
|
52
|
+
frac,
|
|
53
|
+
start: angle,
|
|
54
|
+
end: next,
|
|
55
|
+
});
|
|
56
|
+
angle = next;
|
|
57
|
+
}
|
|
58
|
+
const donut = config.donut;
|
|
59
|
+
const inner = donut === true ? 0.55
|
|
60
|
+
: typeof donut === 'number' && donut > 0 && donut < 1 ? donut
|
|
61
|
+
: null;
|
|
62
|
+
return {
|
|
63
|
+
type: 'pie',
|
|
64
|
+
title: config.title ?? null,
|
|
65
|
+
total,
|
|
66
|
+
inner,
|
|
67
|
+
slices,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @typedef {object} PieRenderOptions
|
|
73
|
+
* @property {string} [rootClass] the root `<svg>` class
|
|
74
|
+
* @property {string} [keyPrefix] vnode key prefix (`<prefix><hash>`)
|
|
75
|
+
* @property {string} [sliceClass] class per slice `<path>`
|
|
76
|
+
* @property {string} [legendClass] class per legend `<g>`
|
|
77
|
+
* @property {readonly string[]} [palette] slice colors
|
|
78
|
+
* @property {string} [textColor] title/legend text fill
|
|
79
|
+
* @property {string} [sliceStroke] slice separator stroke
|
|
80
|
+
* @property {boolean} [titles] per-slice hover `<title>` (default true).
|
|
81
|
+
* `@jarenjs/mermaid` turns it off: a mermaid pie is a *diagram*, and
|
|
82
|
+
* its emitted SVG is a byte-stable contract that hover text would
|
|
83
|
+
* break — the delegation exists to share geometry, not to change what
|
|
84
|
+
* mermaid renders.
|
|
85
|
+
* @property {import('../core/marks.js').ChartTooltipSpec} [tooltip]
|
|
86
|
+
* pointer bindings for a floating-tooltip host
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Render a pie AST to a pure-vnode SVG: angles → arc paths, plus a
|
|
91
|
+
* swatch legend with percentages and an optional title. With
|
|
92
|
+
* `ast.inner === null` (no `donut` in the config) the slice path is
|
|
93
|
+
* emitted by the same wedge template as before the donut variant
|
|
94
|
+
* existed — solid-pie output is byte-stable, which is what keeps the
|
|
95
|
+
* mermaid delegation byte-identical; the annular geometry lives
|
|
96
|
+
* entirely in the other branch. Every slice carries a label/value
|
|
97
|
+
* `<title>` unless `options.titles` is false (mermaid's opt-out).
|
|
98
|
+
* @param {PieAST} ast
|
|
99
|
+
* @param {{tokens: Record<string,string>, cssVars: Record<string,string>}} theme
|
|
100
|
+
* @param {string} hash content hash for the vnode key
|
|
101
|
+
* @param {PieRenderOptions} [options]
|
|
102
|
+
* @returns {any} an SVG vnode
|
|
103
|
+
*/
|
|
104
|
+
export function renderPieAST(ast, theme, hash, options = {}) {
|
|
105
|
+
const rootClass = options.rootClass ?? 'chart chart-svg chart-pie';
|
|
106
|
+
const keyPrefix = options.keyPrefix ?? 'pie-';
|
|
107
|
+
const sliceClass = options.sliceClass ?? 'chart-pie-slice';
|
|
108
|
+
const legendClass = options.legendClass ?? 'chart-pie-legend';
|
|
109
|
+
const palette = options.palette ?? CATEGORICAL;
|
|
110
|
+
const textColor = options.textColor ?? theme.tokens.text;
|
|
111
|
+
const sliceStroke = options.sliceStroke ?? theme.tokens.sliceStroke;
|
|
112
|
+
const titles = options.titles !== false;
|
|
113
|
+
const tooltip = normalizeTooltip(options.tooltip);
|
|
114
|
+
const fs = 14;
|
|
115
|
+
const R = 130;
|
|
116
|
+
const cx = R + 20;
|
|
117
|
+
const cy = R + 40;
|
|
118
|
+
const inner = ast.inner ?? null;
|
|
119
|
+
const r = inner === null ? 0 : R * inner;
|
|
120
|
+
const slices = [];
|
|
121
|
+
for (let i = 0; i < ast.slices.length; i++) {
|
|
122
|
+
const s = ast.slices[i];
|
|
123
|
+
const { x: x1, y: y1 } = polarPoint(cx, cy, R, s.start);
|
|
124
|
+
const { x: x2, y: y2 } = polarPoint(cx, cy, R, s.end);
|
|
125
|
+
const large = s.frac > 0.5 ? 1 : 0;
|
|
126
|
+
const color = palette[i % palette.length];
|
|
127
|
+
const i1 = polarPoint(cx, cy, r, s.start);
|
|
128
|
+
const i2 = polarPoint(cx, cy, r, s.end);
|
|
129
|
+
const d = inner === null
|
|
130
|
+
? `M${num(cx)},${num(cy)} L${num(x1)},${num(y1)} A${R},${R} 0 ${large} 1 ${num(x2)},${num(y2)} Z`
|
|
131
|
+
: `M${num(i1.x)},${num(i1.y)} `
|
|
132
|
+
+ `L${num(x1)},${num(y1)} A${R},${R} 0 ${large} 1 ${num(x2)},${num(y2)} `
|
|
133
|
+
+ `L${num(i2.x)},${num(i2.y)} `
|
|
134
|
+
+ `A${num(r)},${num(r)} 0 ${large} 0 ${num(i1.x)},${num(i1.y)} Z`;
|
|
135
|
+
const text = `${s.label}: ${s.value} (${(s.frac * 100).toFixed(1)}%)`;
|
|
136
|
+
const props = markProps(
|
|
137
|
+
{ d, fill: color, stroke: sliceStroke, 'stroke-width': 1, class: sliceClass },
|
|
138
|
+
tooltip, text, { type: 'pie', label: s.label, value: s.value });
|
|
139
|
+
slices.push(titles ? ['path', props, ['title', {}, text]] : ['path', props]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Legend.
|
|
143
|
+
const legendX = 2 * R + 50;
|
|
144
|
+
let legendW = 120;
|
|
145
|
+
const legend = ast.slices.map((s, i) => {
|
|
146
|
+
const y = 40 + i * 24;
|
|
147
|
+
const label = `${s.label} (${(s.frac * 100).toFixed(1)}%)`;
|
|
148
|
+
legendW = Math.max(legendW, textWidth(label, fs) + 30);
|
|
149
|
+
return group({ class: legendClass }, [
|
|
150
|
+
rect(legendX, y, 14, 14, { fill: palette[i % palette.length] }),
|
|
151
|
+
textAt(legendX + 20, y + 12, label, fs, { fill: textColor }),
|
|
152
|
+
]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const children = [];
|
|
156
|
+
if (ast.title) {
|
|
157
|
+
children.push(textAt(cx, 24, ast.title, fs + 2, { 'font-weight': 'bold', 'text-anchor': 'middle', fill: textColor }));
|
|
158
|
+
}
|
|
159
|
+
children.push(...slices, ...legend);
|
|
160
|
+
const width = legendX + legendW + 20;
|
|
161
|
+
const height = 2 * R + 70;
|
|
162
|
+
return svgRoot(rootClass, width, height, theme, children, keyPrefix + hash);
|
|
163
|
+
}
|