@paul-portfolio/react 0.4.4 → 0.5.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/dist/BarChart.d.ts +22 -0
- package/dist/BarChart.js +21 -0
- package/dist/DonutChart.d.ts +26 -0
- package/dist/DonutChart.js +19 -0
- package/dist/FunnelChart.d.ts +26 -0
- package/dist/FunnelChart.js +24 -0
- package/dist/GaugeChart.d.ts +37 -0
- package/dist/GaugeChart.js +44 -0
- package/dist/GradientBackground.d.ts +21 -0
- package/dist/GradientBackground.js +24 -0
- package/dist/HeatmapChart.d.ts +46 -0
- package/dist/HeatmapChart.js +53 -0
- package/dist/ParetoChart.d.ts +34 -0
- package/dist/ParetoChart.js +44 -0
- package/dist/RadarChart.d.ts +35 -0
- package/dist/RadarChart.js +55 -0
- package/dist/ScatterPlot.d.ts +37 -0
- package/dist/ScatterPlot.js +22 -0
- package/dist/Sparkline.d.ts +30 -0
- package/dist/Sparkline.js +21 -0
- package/dist/Spotlight.d.ts +17 -0
- package/dist/Spotlight.js +39 -0
- package/dist/StackedLineChart.d.ts +35 -0
- package/dist/StackedLineChart.js +38 -0
- package/dist/Ticker.js +13 -24
- package/dist/TiltCard.d.ts +17 -0
- package/dist/TiltCard.js +45 -0
- package/dist/WordCloud.d.ts +38 -0
- package/dist/WordCloud.js +36 -0
- package/dist/chartGeometry.d.ts +229 -0
- package/dist/chartGeometry.js +450 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/usePrefersReducedMotion.d.ts +6 -0
- package/dist/usePrefersReducedMotion.js +22 -0
- package/package.json +1 -1
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, dependency-free chart geometry. Every function turns a plain array of
|
|
3
|
+
* numbers into SVG coordinates or path strings, so the React and Angular chart
|
|
4
|
+
* components can render identical output without pulling in a charting runtime.
|
|
5
|
+
*
|
|
6
|
+
* This module is deliberately framework-agnostic and side-effect-free. It is
|
|
7
|
+
* mirrored verbatim in @paul-portfolio/angular; the unit tests in each package
|
|
8
|
+
* guard the two copies against drifting.
|
|
9
|
+
*/
|
|
10
|
+
function inner(box) {
|
|
11
|
+
const pad = box.padding ?? 0;
|
|
12
|
+
return {
|
|
13
|
+
left: pad,
|
|
14
|
+
top: pad,
|
|
15
|
+
width: Math.max(0, box.width - pad * 2),
|
|
16
|
+
height: Math.max(0, box.height - pad * 2),
|
|
17
|
+
bottom: box.height - pad,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function round(n) {
|
|
21
|
+
return Math.round(n * 1000) / 1000;
|
|
22
|
+
}
|
|
23
|
+
/** Maps each value to a point, spread evenly across the inner width. */
|
|
24
|
+
export function linePoints(values, box) {
|
|
25
|
+
if (values.length === 0)
|
|
26
|
+
return [];
|
|
27
|
+
const b = inner(box);
|
|
28
|
+
const min = Math.min(...values);
|
|
29
|
+
const max = Math.max(...values);
|
|
30
|
+
const span = max - min;
|
|
31
|
+
const yFor = (v) => span === 0 ? b.top + b.height / 2 : b.top + b.height * (1 - (v - min) / span);
|
|
32
|
+
if (values.length === 1) {
|
|
33
|
+
return [{ x: round(b.left + b.width / 2), y: round(yFor(values[0])) }];
|
|
34
|
+
}
|
|
35
|
+
const step = b.width / (values.length - 1);
|
|
36
|
+
return values.map((v, i) => ({ x: round(b.left + step * i), y: round(yFor(v)) }));
|
|
37
|
+
}
|
|
38
|
+
export function linePath(values, box) {
|
|
39
|
+
const pts = linePoints(values, box);
|
|
40
|
+
if (pts.length === 0)
|
|
41
|
+
return '';
|
|
42
|
+
return pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
|
|
43
|
+
}
|
|
44
|
+
export function areaPath(values, box) {
|
|
45
|
+
const pts = linePoints(values, box);
|
|
46
|
+
if (pts.length === 0)
|
|
47
|
+
return '';
|
|
48
|
+
const b = inner(box);
|
|
49
|
+
const baseline = round(b.bottom);
|
|
50
|
+
const line = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
|
|
51
|
+
const last = pts[pts.length - 1];
|
|
52
|
+
const first = pts[0];
|
|
53
|
+
return `${line} L${last.x},${baseline} L${first.x},${baseline} Z`;
|
|
54
|
+
}
|
|
55
|
+
/** Vertical bars: one rect per value, heights proportional to the value. */
|
|
56
|
+
export function barRects(values, box, opts = {}) {
|
|
57
|
+
if (values.length === 0)
|
|
58
|
+
return [];
|
|
59
|
+
const b = inner(box);
|
|
60
|
+
const gap = opts.gap ?? 0.25;
|
|
61
|
+
const max = Math.max(...values, 0);
|
|
62
|
+
const band = b.width / values.length;
|
|
63
|
+
const barWidth = band * (1 - gap);
|
|
64
|
+
return values.map((value, i) => {
|
|
65
|
+
const height = max > 0 ? (value / max) * b.height : 0;
|
|
66
|
+
return {
|
|
67
|
+
x: round(b.left + band * i + (band - barWidth) / 2),
|
|
68
|
+
y: round(b.bottom - height),
|
|
69
|
+
width: round(barWidth),
|
|
70
|
+
height: round(height),
|
|
71
|
+
value,
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** Horizontal bars: one rect per value, widths proportional to the value. */
|
|
76
|
+
export function barRectsHorizontal(values, box, opts = {}) {
|
|
77
|
+
if (values.length === 0)
|
|
78
|
+
return [];
|
|
79
|
+
const b = inner(box);
|
|
80
|
+
const gap = opts.gap ?? 0.25;
|
|
81
|
+
const max = Math.max(...values, 0);
|
|
82
|
+
const band = b.height / values.length;
|
|
83
|
+
const barHeight = band * (1 - gap);
|
|
84
|
+
return values.map((value, i) => {
|
|
85
|
+
const width = max > 0 ? (value / max) * b.width : 0;
|
|
86
|
+
return {
|
|
87
|
+
x: round(b.left),
|
|
88
|
+
y: round(b.top + band * i + (band - barHeight) / 2),
|
|
89
|
+
width: round(width),
|
|
90
|
+
height: round(barHeight),
|
|
91
|
+
value,
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/** 0deg points straight up; angle increases clockwise. */
|
|
96
|
+
export function polarToCartesian(cx, cy, r, angleDeg) {
|
|
97
|
+
const rad = ((angleDeg - 90) * Math.PI) / 180;
|
|
98
|
+
return { x: round(cx + r * Math.cos(rad)), y: round(cy + r * Math.sin(rad)) };
|
|
99
|
+
}
|
|
100
|
+
/** SVG path for a ring wedge between two radii and two angles. */
|
|
101
|
+
export function arcPath(cx, cy, rOuter, rInner, startAngle, endAngle) {
|
|
102
|
+
// A single SVG arc can't span a full turn, so nudge a 360° sweep just short.
|
|
103
|
+
const sweep = endAngle - startAngle;
|
|
104
|
+
const end = sweep >= 360 ? startAngle + 359.999 : endAngle;
|
|
105
|
+
const largeArc = end - startAngle > 180 ? 1 : 0;
|
|
106
|
+
const oStart = polarToCartesian(cx, cy, rOuter, startAngle);
|
|
107
|
+
const oEnd = polarToCartesian(cx, cy, rOuter, end);
|
|
108
|
+
const iEnd = polarToCartesian(cx, cy, rInner, end);
|
|
109
|
+
const iStart = polarToCartesian(cx, cy, rInner, startAngle);
|
|
110
|
+
return [
|
|
111
|
+
`M${oStart.x},${oStart.y}`,
|
|
112
|
+
`A${rOuter},${rOuter} 0 ${largeArc} 1 ${oEnd.x},${oEnd.y}`,
|
|
113
|
+
`L${iEnd.x},${iEnd.y}`,
|
|
114
|
+
`A${rInner},${rInner} 0 ${largeArc} 0 ${iStart.x},${iStart.y}`,
|
|
115
|
+
'Z',
|
|
116
|
+
].join(' ');
|
|
117
|
+
}
|
|
118
|
+
export function donutSegments(values, opts) {
|
|
119
|
+
const total = values.reduce((sum, v) => sum + Math.max(0, v), 0);
|
|
120
|
+
if (values.length === 0 || total <= 0)
|
|
121
|
+
return [];
|
|
122
|
+
const { size, thickness } = opts;
|
|
123
|
+
const cx = size / 2;
|
|
124
|
+
const cy = size / 2;
|
|
125
|
+
const rOuter = size / 2;
|
|
126
|
+
const rInner = Math.max(0, rOuter - thickness);
|
|
127
|
+
let angle = opts.startAngle ?? 0;
|
|
128
|
+
return values.map((raw, index) => {
|
|
129
|
+
const value = Math.max(0, raw);
|
|
130
|
+
const sweep = (value / total) * 360;
|
|
131
|
+
const startAngle = angle;
|
|
132
|
+
const endAngle = angle + sweep;
|
|
133
|
+
angle = endAngle;
|
|
134
|
+
return {
|
|
135
|
+
path: arcPath(cx, cy, rOuter, rInner, startAngle, endAngle),
|
|
136
|
+
percent: (value / total) * 100,
|
|
137
|
+
startAngle,
|
|
138
|
+
endAngle,
|
|
139
|
+
index,
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Stacked trapezoids narrowing stage to stage. Widths are proportional to the
|
|
145
|
+
* value, centred, so the taper reads as the drop-off — which is the number a
|
|
146
|
+
* funnel is actually read for, hence `dropOff` on every stage.
|
|
147
|
+
*/
|
|
148
|
+
export function funnelStages(values, box, gap = 4) {
|
|
149
|
+
if (values.length === 0)
|
|
150
|
+
return [];
|
|
151
|
+
const b = inner(box);
|
|
152
|
+
const first = values[0];
|
|
153
|
+
const max = Math.max(...values, 0);
|
|
154
|
+
const bandHeight = b.height / values.length;
|
|
155
|
+
const height = Math.max(0, bandHeight - gap);
|
|
156
|
+
const widthFor = (v) => (max > 0 ? (Math.max(0, v) / max) * b.width : 0);
|
|
157
|
+
return values.map((value, i) => {
|
|
158
|
+
const top = b.top + bandHeight * i;
|
|
159
|
+
const wTop = widthFor(value);
|
|
160
|
+
// Taper toward the next stage so consecutive bands meet edge to edge.
|
|
161
|
+
const wBottom = i + 1 < values.length ? widthFor(values[i + 1]) : wTop;
|
|
162
|
+
const cx = b.left + b.width / 2;
|
|
163
|
+
const path = [
|
|
164
|
+
`M${round(cx - wTop / 2)},${round(top)}`,
|
|
165
|
+
`L${round(cx + wTop / 2)},${round(top)}`,
|
|
166
|
+
`L${round(cx + wBottom / 2)},${round(top + height)}`,
|
|
167
|
+
`L${round(cx - wBottom / 2)},${round(top + height)}`,
|
|
168
|
+
'Z',
|
|
169
|
+
].join(' ');
|
|
170
|
+
return {
|
|
171
|
+
path,
|
|
172
|
+
percent: first > 0 ? round((Math.max(0, value) / first) * 100) : 0,
|
|
173
|
+
dropOff: i === 0 || values[i - 1] <= 0
|
|
174
|
+
? 0
|
|
175
|
+
: round(((values[i - 1] - value) / values[i - 1]) * 100),
|
|
176
|
+
value,
|
|
177
|
+
index: i,
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Spokes and rings for a radar chart. `radarPolygon` draws on the same centre
|
|
183
|
+
* and radius, so the series and the frame can't disagree.
|
|
184
|
+
*/
|
|
185
|
+
export function radarAxes(count, box, ringCount = 4) {
|
|
186
|
+
const b = inner(box);
|
|
187
|
+
const cx = round(b.left + b.width / 2);
|
|
188
|
+
const cy = round(b.top + b.height / 2);
|
|
189
|
+
const radius = round(Math.min(b.width, b.height) / 2);
|
|
190
|
+
const axes = [];
|
|
191
|
+
for (let i = 0; i < count; i += 1) {
|
|
192
|
+
const angle = (360 / count) * i;
|
|
193
|
+
const point = polarToCartesian(cx, cy, radius, angle);
|
|
194
|
+
axes.push({ x: point.x, y: point.y, angle, index: i });
|
|
195
|
+
}
|
|
196
|
+
const rings = Array.from({ length: ringCount }, (_, i) => round((radius * (i + 1)) / ringCount));
|
|
197
|
+
return { cx, cy, radius, axes, rings };
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* A closed point list for one series. `max` fixes the scale so several series —
|
|
201
|
+
* or several small multiples — are comparable; it defaults to the series max.
|
|
202
|
+
*/
|
|
203
|
+
export function radarPolygon(values, box, max) {
|
|
204
|
+
if (values.length === 0)
|
|
205
|
+
return [];
|
|
206
|
+
const { cx, cy, radius } = radarAxes(values.length, box);
|
|
207
|
+
const ceiling = max ?? Math.max(...values, 0);
|
|
208
|
+
return values.map((value, i) => {
|
|
209
|
+
const r = ceiling > 0 ? (Math.max(0, value) / ceiling) * radius : 0;
|
|
210
|
+
return polarToCartesian(cx, cy, r, (360 / values.length) * i);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Maps points into the box. Pass an explicit `domain` when several plots must
|
|
215
|
+
* share a scale — without it each plot silently rescales to its own extent and
|
|
216
|
+
* they stop being comparable.
|
|
217
|
+
*/
|
|
218
|
+
export function scatterPoints(data, box, domain) {
|
|
219
|
+
if (data.length === 0)
|
|
220
|
+
return [];
|
|
221
|
+
const b = inner(box);
|
|
222
|
+
const xs = data.map((d) => d.x);
|
|
223
|
+
const ys = data.map((d) => d.y);
|
|
224
|
+
const d = domain ?? {
|
|
225
|
+
xMin: Math.min(...xs),
|
|
226
|
+
xMax: Math.max(...xs),
|
|
227
|
+
yMin: Math.min(...ys),
|
|
228
|
+
yMax: Math.max(...ys),
|
|
229
|
+
};
|
|
230
|
+
const spanX = d.xMax - d.xMin;
|
|
231
|
+
const spanY = d.yMax - d.yMin;
|
|
232
|
+
return data.map((datum, index) => ({
|
|
233
|
+
x: round(spanX === 0 ? b.left + b.width / 2 : b.left + ((datum.x - d.xMin) / spanX) * b.width),
|
|
234
|
+
y: round(spanY === 0 ? b.top + b.height / 2 : b.top + b.height * (1 - (datum.y - d.yMin) / spanY)),
|
|
235
|
+
datum,
|
|
236
|
+
index,
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* A grid of cells with a normalised intensity per cell. Intensity is deliberately
|
|
241
|
+
* 0..1 rather than a colour: the component maps it onto the sequential ramp, and
|
|
242
|
+
* a categorical palette here would double-encode the value as hue.
|
|
243
|
+
*/
|
|
244
|
+
export function heatmapCells(matrix, box, gap = 2) {
|
|
245
|
+
if (matrix.length === 0 || matrix.every((row) => row.length === 0))
|
|
246
|
+
return [];
|
|
247
|
+
const b = inner(box);
|
|
248
|
+
const rows = matrix.length;
|
|
249
|
+
const cols = Math.max(...matrix.map((row) => row.length));
|
|
250
|
+
const max = Math.max(...matrix.flat(), 0);
|
|
251
|
+
const cellW = b.width / cols;
|
|
252
|
+
const cellH = b.height / rows;
|
|
253
|
+
const cells = [];
|
|
254
|
+
matrix.forEach((row, r) => {
|
|
255
|
+
row.forEach((value, c) => {
|
|
256
|
+
cells.push({
|
|
257
|
+
x: round(b.left + cellW * c),
|
|
258
|
+
y: round(b.top + cellH * r),
|
|
259
|
+
width: round(Math.max(0, cellW - gap)),
|
|
260
|
+
height: round(Math.max(0, cellH - gap)),
|
|
261
|
+
value,
|
|
262
|
+
intensity: max > 0 ? round(Math.max(0, value) / max) : 0,
|
|
263
|
+
row: r,
|
|
264
|
+
col: c,
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
return cells;
|
|
269
|
+
}
|
|
270
|
+
export function paretoLayout(values, box, opts = {}) {
|
|
271
|
+
const sorted = [...values].filter((v) => v > 0).sort((a, b) => b - a);
|
|
272
|
+
if (sorted.length === 0)
|
|
273
|
+
return { bars: [], cumulative: [], percents: [], cutIndex: -1 };
|
|
274
|
+
const b = inner(box);
|
|
275
|
+
const gap = opts.gap ?? 0.25;
|
|
276
|
+
const threshold = opts.threshold ?? 80;
|
|
277
|
+
const total = sorted.reduce((sum, v) => sum + v, 0);
|
|
278
|
+
const percents = sorted.map((v) => round((v / total) * 100));
|
|
279
|
+
const band = b.width / sorted.length;
|
|
280
|
+
const barWidth = band * (1 - gap);
|
|
281
|
+
const bars = sorted.map((value, i) => {
|
|
282
|
+
const height = (value / total) * 100 * (b.height / 100);
|
|
283
|
+
return {
|
|
284
|
+
x: round(b.left + band * i + (band - barWidth) / 2),
|
|
285
|
+
y: round(b.bottom - height),
|
|
286
|
+
width: round(barWidth),
|
|
287
|
+
height: round(height),
|
|
288
|
+
value,
|
|
289
|
+
};
|
|
290
|
+
});
|
|
291
|
+
let running = 0;
|
|
292
|
+
let cutIndex = -1;
|
|
293
|
+
const cumulative = sorted.map((value, i) => {
|
|
294
|
+
running += (value / total) * 100;
|
|
295
|
+
if (cutIndex === -1 && running >= threshold)
|
|
296
|
+
cutIndex = i;
|
|
297
|
+
return {
|
|
298
|
+
x: round(b.left + band * i + band / 2),
|
|
299
|
+
y: round(b.bottom - running * (b.height / 100)),
|
|
300
|
+
};
|
|
301
|
+
});
|
|
302
|
+
return { bars, cumulative, percents, cutIndex };
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* A single ratio against a limit. Values outside [min, max] clamp rather than
|
|
306
|
+
* overflowing the arc — a gauge reading 140% of its own dial is a bug, not data.
|
|
307
|
+
*/
|
|
308
|
+
export function gaugeArc(value, opts) {
|
|
309
|
+
const { size, thickness } = opts;
|
|
310
|
+
const min = opts.min ?? 0;
|
|
311
|
+
const max = opts.max ?? 100;
|
|
312
|
+
const sweep = opts.sweep ?? 270;
|
|
313
|
+
const cx = size / 2;
|
|
314
|
+
const cy = size / 2;
|
|
315
|
+
const rOuter = size / 2;
|
|
316
|
+
const rInner = Math.max(0, rOuter - thickness);
|
|
317
|
+
const span = max - min;
|
|
318
|
+
const ratio = span === 0 ? 0 : Math.min(1, Math.max(0, (value - min) / span));
|
|
319
|
+
// Centre the dial on straight-up: a 270° sweep starts at -135°.
|
|
320
|
+
const start = -sweep / 2;
|
|
321
|
+
const angle = round(start + sweep * ratio);
|
|
322
|
+
return {
|
|
323
|
+
track: arcPath(cx, cy, rOuter, rInner, start, start + sweep),
|
|
324
|
+
fill: ratio === 0 ? '' : arcPath(cx, cy, rOuter, rInner, start, angle),
|
|
325
|
+
percent: round(ratio * 100),
|
|
326
|
+
angle,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Deterministic spiral packing for a word cloud.
|
|
331
|
+
*
|
|
332
|
+
* Caveat, stated where it can't be missed: glyph AREA is not a comparable
|
|
333
|
+
* encoding, and a long word reads as bigger than a short one at the same
|
|
334
|
+
* weight. A ranked bar chart shows the same data honestly. This exists because
|
|
335
|
+
* a gallery wants one; reach for `BarChart` when the numbers matter.
|
|
336
|
+
*
|
|
337
|
+
* No randomness anywhere — the same input must produce the same picture, or the
|
|
338
|
+
* server and the client disagree and visual regression never settles.
|
|
339
|
+
*/
|
|
340
|
+
export function wordCloudLayout(terms, box, opts = {}) {
|
|
341
|
+
if (terms.length === 0)
|
|
342
|
+
return [];
|
|
343
|
+
const b = inner(box);
|
|
344
|
+
const minFont = opts.minFontSize ?? 12;
|
|
345
|
+
const maxFont = opts.maxFontSize ?? 40;
|
|
346
|
+
const limit = opts.limit ?? 50;
|
|
347
|
+
const ranked = [...terms]
|
|
348
|
+
.filter((t) => t.weight > 0)
|
|
349
|
+
.sort((a, b2) => b2.weight - a.weight)
|
|
350
|
+
.slice(0, limit);
|
|
351
|
+
if (ranked.length === 0)
|
|
352
|
+
return [];
|
|
353
|
+
const top = ranked[0].weight;
|
|
354
|
+
const bottom = ranked[ranked.length - 1].weight;
|
|
355
|
+
const span = top - bottom;
|
|
356
|
+
const cx = b.left + b.width / 2;
|
|
357
|
+
const cy = b.top + b.height / 2;
|
|
358
|
+
const placed = [];
|
|
359
|
+
const overlaps = (r) => placed.some((p) => !(r.x2 < p.x1 || r.x1 > p.x2 || r.y2 < p.y1 || r.y1 > p.y2));
|
|
360
|
+
const out = [];
|
|
361
|
+
ranked.forEach((term, index) => {
|
|
362
|
+
const t = span === 0 ? 1 : (term.weight - bottom) / span;
|
|
363
|
+
const fontSize = round(minFont + t * (maxFont - minFont));
|
|
364
|
+
// Character-width approximation: no DOM here, so no text measurement.
|
|
365
|
+
const width = term.text.length * fontSize * 0.55;
|
|
366
|
+
const height = fontSize * 1.1;
|
|
367
|
+
// Archimedean spiral out from the centre until the box has no room left.
|
|
368
|
+
let step = 0;
|
|
369
|
+
let x = cx;
|
|
370
|
+
let y = cy;
|
|
371
|
+
let rect = { x1: x - width / 2, y1: y - height / 2, x2: x + width / 2, y2: y + height / 2 };
|
|
372
|
+
const maxSteps = 600;
|
|
373
|
+
while (step < maxSteps &&
|
|
374
|
+
(overlaps(rect) ||
|
|
375
|
+
rect.x1 < b.left ||
|
|
376
|
+
rect.x2 > b.left + b.width ||
|
|
377
|
+
rect.y1 < b.top ||
|
|
378
|
+
rect.y2 > b.top + b.height)) {
|
|
379
|
+
step += 1;
|
|
380
|
+
const angle = step * 0.35;
|
|
381
|
+
const radius = 2 + angle * 2.2;
|
|
382
|
+
x = cx + Math.cos(angle) * radius;
|
|
383
|
+
y = cy + Math.sin(angle) * radius * 0.62; // wider than tall, like the box
|
|
384
|
+
rect = { x1: x - width / 2, y1: y - height / 2, x2: x + width / 2, y2: y + height / 2 };
|
|
385
|
+
}
|
|
386
|
+
// Out of room: drop the term rather than stacking it on top of another.
|
|
387
|
+
if (step >= maxSteps)
|
|
388
|
+
return;
|
|
389
|
+
placed.push(rect);
|
|
390
|
+
out.push({ ...term, x: round(x), y: round(y), fontSize, index });
|
|
391
|
+
});
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* One y-domain across every series, computed once. Series scaled independently
|
|
396
|
+
* look comparable and aren't.
|
|
397
|
+
*/
|
|
398
|
+
export function multiLinePoints(series, box) {
|
|
399
|
+
if (series.length === 0)
|
|
400
|
+
return [];
|
|
401
|
+
const all = series.flat();
|
|
402
|
+
if (all.length === 0)
|
|
403
|
+
return series.map(() => []);
|
|
404
|
+
const min = Math.min(...all);
|
|
405
|
+
const max = Math.max(...all);
|
|
406
|
+
const b = inner(box);
|
|
407
|
+
const span = max - min;
|
|
408
|
+
return series.map((values) => {
|
|
409
|
+
if (values.length === 0)
|
|
410
|
+
return [];
|
|
411
|
+
if (values.length === 1) {
|
|
412
|
+
const y = span === 0 ? b.top + b.height / 2 : b.top + b.height * (1 - (values[0] - min) / span);
|
|
413
|
+
return [{ x: round(b.left + b.width / 2), y: round(y) }];
|
|
414
|
+
}
|
|
415
|
+
const step = b.width / (values.length - 1);
|
|
416
|
+
return values.map((v, i) => ({
|
|
417
|
+
x: round(b.left + step * i),
|
|
418
|
+
y: round(span === 0 ? b.top + b.height / 2 : b.top + b.height * (1 - (v - min) / span)),
|
|
419
|
+
}));
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Part-to-whole over time: each series is a band stacked on the ones before it,
|
|
424
|
+
* scaled so the tallest total fills the box.
|
|
425
|
+
*/
|
|
426
|
+
export function stackedSeries(series, box) {
|
|
427
|
+
if (series.length === 0)
|
|
428
|
+
return [];
|
|
429
|
+
const length = Math.max(...series.map((s) => s.length), 0);
|
|
430
|
+
if (length === 0)
|
|
431
|
+
return [];
|
|
432
|
+
const b = inner(box);
|
|
433
|
+
const totals = Array.from({ length }, (_, i) => series.reduce((sum, s) => sum + Math.max(0, s[i] ?? 0), 0));
|
|
434
|
+
const max = Math.max(...totals, 0);
|
|
435
|
+
const step = length === 1 ? 0 : b.width / (length - 1);
|
|
436
|
+
const xAt = (i) => round(length === 1 ? b.left + b.width / 2 : b.left + step * i);
|
|
437
|
+
const yFor = (v) => round(b.bottom - (max > 0 ? (v / max) * b.height : 0));
|
|
438
|
+
const running = new Array(length).fill(0);
|
|
439
|
+
return series.map((values, index) => {
|
|
440
|
+
const lower = [...running];
|
|
441
|
+
for (let i = 0; i < length; i += 1)
|
|
442
|
+
running[i] += Math.max(0, values[i] ?? 0);
|
|
443
|
+
const upperPath = running.map((v, i) => `${i === 0 ? 'M' : 'L'}${xAt(i)},${yFor(v)}`).join(' ');
|
|
444
|
+
const lowerPath = lower
|
|
445
|
+
.map((v, i) => `L${xAt(length - 1 - i)},${yFor(lower[length - 1 - i])}`)
|
|
446
|
+
.slice(0, length)
|
|
447
|
+
.join(' ');
|
|
448
|
+
return { path: `${upperPath} ${lowerPath} Z`, index };
|
|
449
|
+
});
|
|
450
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -16,5 +16,21 @@ export { Skeleton } from './Skeleton';
|
|
|
16
16
|
export { Spinner } from './Spinner';
|
|
17
17
|
export { Divider } from './Divider';
|
|
18
18
|
export { Ticker } from './Ticker';
|
|
19
|
+
export { TiltCard } from './TiltCard';
|
|
20
|
+
export { GradientBackground } from './GradientBackground';
|
|
21
|
+
export { Spotlight } from './Spotlight';
|
|
22
|
+
export { Sparkline } from './Sparkline';
|
|
23
|
+
export { BarChart } from './BarChart';
|
|
24
|
+
export { DonutChart, type DonutDatum } from './DonutChart';
|
|
25
|
+
export { FunnelChart, type FunnelDatum } from './FunnelChart';
|
|
26
|
+
export { ScatterPlot, type ScatterSeries } from './ScatterPlot';
|
|
27
|
+
export { RadarChart, type RadarSeries } from './RadarChart';
|
|
28
|
+
export { ParetoChart, type ParetoDatum } from './ParetoChart';
|
|
29
|
+
export { HeatmapChart, type HeatmapRow } from './HeatmapChart';
|
|
30
|
+
export { GaugeChart, type GaugeTone } from './GaugeChart';
|
|
31
|
+
export { StackedLineChart, type LineSeries } from './StackedLineChart';
|
|
32
|
+
export { WordCloud, type WordCloudDatum } from './WordCloud';
|
|
33
|
+
export * as chartGeometry from './chartGeometry';
|
|
19
34
|
export { VisuallyHidden } from './VisuallyHidden';
|
|
35
|
+
export { usePrefersReducedMotion } from './usePrefersReducedMotion';
|
|
20
36
|
export { cx } from './cx';
|
package/dist/index.js
CHANGED
|
@@ -16,5 +16,21 @@ export { Skeleton } from './Skeleton';
|
|
|
16
16
|
export { Spinner } from './Spinner';
|
|
17
17
|
export { Divider } from './Divider';
|
|
18
18
|
export { Ticker } from './Ticker';
|
|
19
|
+
export { TiltCard } from './TiltCard';
|
|
20
|
+
export { GradientBackground } from './GradientBackground';
|
|
21
|
+
export { Spotlight } from './Spotlight';
|
|
22
|
+
export { Sparkline } from './Sparkline';
|
|
23
|
+
export { BarChart } from './BarChart';
|
|
24
|
+
export { DonutChart } from './DonutChart';
|
|
25
|
+
export { FunnelChart } from './FunnelChart';
|
|
26
|
+
export { ScatterPlot } from './ScatterPlot';
|
|
27
|
+
export { RadarChart } from './RadarChart';
|
|
28
|
+
export { ParetoChart } from './ParetoChart';
|
|
29
|
+
export { HeatmapChart } from './HeatmapChart';
|
|
30
|
+
export { GaugeChart } from './GaugeChart';
|
|
31
|
+
export { StackedLineChart } from './StackedLineChart';
|
|
32
|
+
export { WordCloud } from './WordCloud';
|
|
33
|
+
export * as chartGeometry from './chartGeometry';
|
|
19
34
|
export { VisuallyHidden } from './VisuallyHidden';
|
|
35
|
+
export { usePrefersReducedMotion } from './usePrefersReducedMotion';
|
|
20
36
|
export { cx } from './cx';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks `prefers-reduced-motion`. Defaults to false so the server and the
|
|
3
|
+
* first client render agree (SSR-stable), then updates once mounted. Shared by
|
|
4
|
+
* every motion-driven component so they honour the preference identically.
|
|
5
|
+
*/
|
|
6
|
+
export declare function usePrefersReducedMotion(): boolean;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Tracks `prefers-reduced-motion`. Defaults to false so the server and the
|
|
4
|
+
* first client render agree (SSR-stable), then updates once mounted. Shared by
|
|
5
|
+
* every motion-driven component so they honour the preference identically.
|
|
6
|
+
*/
|
|
7
|
+
export function usePrefersReducedMotion() {
|
|
8
|
+
const [reduced, setReduced] = useState(false);
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (typeof window === 'undefined' ||
|
|
11
|
+
typeof window.matchMedia !== 'function') {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
15
|
+
// Microtask defer keeps the effect from setting state synchronously.
|
|
16
|
+
queueMicrotask(() => setReduced(query.matches));
|
|
17
|
+
const onChange = (e) => setReduced(e.matches);
|
|
18
|
+
query.addEventListener('change', onChange);
|
|
19
|
+
return () => query.removeEventListener('change', onChange);
|
|
20
|
+
}, []);
|
|
21
|
+
return reduced;
|
|
22
|
+
}
|