@paul-portfolio/react 0.4.3 → 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.d.ts +27 -0
- package/dist/Ticker.js +96 -0
- 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 +17 -0
- package/dist/index.js +17 -0
- package/dist/usePrefersReducedMotion.d.ts +6 -0
- package/dist/usePrefersReducedMotion.js +22 -0
- package/package.json +1 -1
|
@@ -0,0 +1,229 @@
|
|
|
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
|
+
export interface ChartBox {
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
/** Uniform inset, in px, between the drawing and the edges of the box. */
|
|
14
|
+
padding?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface Point {
|
|
17
|
+
x: number;
|
|
18
|
+
y: number;
|
|
19
|
+
}
|
|
20
|
+
export interface Rect {
|
|
21
|
+
x: number;
|
|
22
|
+
y: number;
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
value: number;
|
|
26
|
+
}
|
|
27
|
+
export interface BarOptions {
|
|
28
|
+
/** Fraction of each band left empty as a gap, 0..1. Defaults to 0.25. */
|
|
29
|
+
gap?: number;
|
|
30
|
+
}
|
|
31
|
+
export interface DonutOptions {
|
|
32
|
+
/** Outer diameter of the ring, in px. */
|
|
33
|
+
size: number;
|
|
34
|
+
/** Ring thickness (outer radius − inner radius), in px. */
|
|
35
|
+
thickness: number;
|
|
36
|
+
/** Angle, in degrees, where the first slice starts. 0 is straight up. */
|
|
37
|
+
startAngle?: number;
|
|
38
|
+
}
|
|
39
|
+
export interface DonutSegment {
|
|
40
|
+
path: string;
|
|
41
|
+
percent: number;
|
|
42
|
+
startAngle: number;
|
|
43
|
+
endAngle: number;
|
|
44
|
+
index: number;
|
|
45
|
+
}
|
|
46
|
+
/** Maps each value to a point, spread evenly across the inner width. */
|
|
47
|
+
export declare function linePoints(values: number[], box: ChartBox): Point[];
|
|
48
|
+
export declare function linePath(values: number[], box: ChartBox): string;
|
|
49
|
+
export declare function areaPath(values: number[], box: ChartBox): string;
|
|
50
|
+
/** Vertical bars: one rect per value, heights proportional to the value. */
|
|
51
|
+
export declare function barRects(values: number[], box: ChartBox, opts?: BarOptions): Rect[];
|
|
52
|
+
/** Horizontal bars: one rect per value, widths proportional to the value. */
|
|
53
|
+
export declare function barRectsHorizontal(values: number[], box: ChartBox, opts?: BarOptions): Rect[];
|
|
54
|
+
/** 0deg points straight up; angle increases clockwise. */
|
|
55
|
+
export declare function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number): Point;
|
|
56
|
+
/** SVG path for a ring wedge between two radii and two angles. */
|
|
57
|
+
export declare function arcPath(cx: number, cy: number, rOuter: number, rInner: number, startAngle: number, endAngle: number): string;
|
|
58
|
+
export declare function donutSegments(values: number[], opts: DonutOptions): DonutSegment[];
|
|
59
|
+
export interface FunnelStage {
|
|
60
|
+
/** Trapezoid path for the stage band. */
|
|
61
|
+
path: string;
|
|
62
|
+
/** Share of the FIRST stage, 0..100 — what "conversion" means in a funnel. */
|
|
63
|
+
percent: number;
|
|
64
|
+
/** Loss from the previous stage, 0..100. 0 for the first stage. */
|
|
65
|
+
dropOff: number;
|
|
66
|
+
value: number;
|
|
67
|
+
index: number;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Stacked trapezoids narrowing stage to stage. Widths are proportional to the
|
|
71
|
+
* value, centred, so the taper reads as the drop-off — which is the number a
|
|
72
|
+
* funnel is actually read for, hence `dropOff` on every stage.
|
|
73
|
+
*/
|
|
74
|
+
export declare function funnelStages(values: number[], box: ChartBox, gap?: number): FunnelStage[];
|
|
75
|
+
export interface RadarAxis {
|
|
76
|
+
/** Outer end of the spoke. */
|
|
77
|
+
x: number;
|
|
78
|
+
y: number;
|
|
79
|
+
angle: number;
|
|
80
|
+
index: number;
|
|
81
|
+
}
|
|
82
|
+
export interface RadarGeometry {
|
|
83
|
+
cx: number;
|
|
84
|
+
cy: number;
|
|
85
|
+
radius: number;
|
|
86
|
+
axes: RadarAxis[];
|
|
87
|
+
/** Radii of the background rings, outermost last. */
|
|
88
|
+
rings: number[];
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Spokes and rings for a radar chart. `radarPolygon` draws on the same centre
|
|
92
|
+
* and radius, so the series and the frame can't disagree.
|
|
93
|
+
*/
|
|
94
|
+
export declare function radarAxes(count: number, box: ChartBox, ringCount?: number): RadarGeometry;
|
|
95
|
+
/**
|
|
96
|
+
* A closed point list for one series. `max` fixes the scale so several series —
|
|
97
|
+
* or several small multiples — are comparable; it defaults to the series max.
|
|
98
|
+
*/
|
|
99
|
+
export declare function radarPolygon(values: number[], box: ChartBox, max?: number): Point[];
|
|
100
|
+
export interface ScatterDatum {
|
|
101
|
+
x: number;
|
|
102
|
+
y: number;
|
|
103
|
+
}
|
|
104
|
+
export interface ScatterPoint extends Point {
|
|
105
|
+
datum: ScatterDatum;
|
|
106
|
+
index: number;
|
|
107
|
+
}
|
|
108
|
+
export interface ScatterDomain {
|
|
109
|
+
xMin: number;
|
|
110
|
+
xMax: number;
|
|
111
|
+
yMin: number;
|
|
112
|
+
yMax: number;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Maps points into the box. Pass an explicit `domain` when several plots must
|
|
116
|
+
* share a scale — without it each plot silently rescales to its own extent and
|
|
117
|
+
* they stop being comparable.
|
|
118
|
+
*/
|
|
119
|
+
export declare function scatterPoints(data: ScatterDatum[], box: ChartBox, domain?: ScatterDomain): ScatterPoint[];
|
|
120
|
+
export interface HeatmapCell extends Rect {
|
|
121
|
+
/** 0..1 against the matrix max — the input to the SEQUENTIAL ramp. */
|
|
122
|
+
intensity: number;
|
|
123
|
+
row: number;
|
|
124
|
+
col: number;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* A grid of cells with a normalised intensity per cell. Intensity is deliberately
|
|
128
|
+
* 0..1 rather than a colour: the component maps it onto the sequential ramp, and
|
|
129
|
+
* a categorical palette here would double-encode the value as hue.
|
|
130
|
+
*/
|
|
131
|
+
export declare function heatmapCells(matrix: number[][], box: ChartBox, gap?: number): HeatmapCell[];
|
|
132
|
+
export interface ParetoLayout {
|
|
133
|
+
/** Bars as PERCENT of total, so they share the cumulative line's scale. */
|
|
134
|
+
bars: Rect[];
|
|
135
|
+
/** Cumulative percentage points, in the same coordinate space as the bars. */
|
|
136
|
+
cumulative: Point[];
|
|
137
|
+
/** Percent value per index, sorted descending like the bars. */
|
|
138
|
+
percents: number[];
|
|
139
|
+
/** Index where the cumulative line first crosses 80% — the Pareto cut. */
|
|
140
|
+
cutIndex: number;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Bars plus a cumulative line on ONE axis.
|
|
144
|
+
*
|
|
145
|
+
* The textbook Pareto puts counts on the left and cumulative percent on the
|
|
146
|
+
* right. Two y-scales on one plot invent a relationship the data doesn't have —
|
|
147
|
+
* the alignment between them is arbitrary. Here the bars are percent-of-total
|
|
148
|
+
* and the line is cumulative percent, so both live on the same 0–100 scale and
|
|
149
|
+
* the crossing point means something.
|
|
150
|
+
*
|
|
151
|
+
* Values are sorted descending, as a Pareto requires. `cutIndex` is the first
|
|
152
|
+
* item at or past `opts.threshold`, so the reported cut and the rule a component
|
|
153
|
+
* draws can't disagree.
|
|
154
|
+
*/
|
|
155
|
+
export interface ParetoOptions {
|
|
156
|
+
/** Fraction of each band left empty as a gap, 0..1. Defaults to 0.25. */
|
|
157
|
+
gap?: number;
|
|
158
|
+
/**
|
|
159
|
+
* The cumulative percentage the cut is measured against. Defaults to 80 —
|
|
160
|
+
* the "80/20" in Pareto — but a caller drawing a different rule needs
|
|
161
|
+
* `cutIndex` to agree with the line they drew, so it lives here rather than
|
|
162
|
+
* being re-derived in each component.
|
|
163
|
+
*/
|
|
164
|
+
threshold?: number;
|
|
165
|
+
}
|
|
166
|
+
export declare function paretoLayout(values: number[], box: ChartBox, opts?: ParetoOptions): ParetoLayout;
|
|
167
|
+
export interface GaugeOptions {
|
|
168
|
+
size: number;
|
|
169
|
+
thickness: number;
|
|
170
|
+
min?: number;
|
|
171
|
+
max?: number;
|
|
172
|
+
/** Total sweep in degrees. Defaults to 270 — a dial, not a full ring. */
|
|
173
|
+
sweep?: number;
|
|
174
|
+
}
|
|
175
|
+
export interface GaugeGeometry {
|
|
176
|
+
track: string;
|
|
177
|
+
fill: string;
|
|
178
|
+
/** 0..100, clamped. */
|
|
179
|
+
percent: number;
|
|
180
|
+
/** Where the fill ends, in degrees. */
|
|
181
|
+
angle: number;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* A single ratio against a limit. Values outside [min, max] clamp rather than
|
|
185
|
+
* overflowing the arc — a gauge reading 140% of its own dial is a bug, not data.
|
|
186
|
+
*/
|
|
187
|
+
export declare function gaugeArc(value: number, opts: GaugeOptions): GaugeGeometry;
|
|
188
|
+
export interface WordCloudTerm {
|
|
189
|
+
text: string;
|
|
190
|
+
weight: number;
|
|
191
|
+
}
|
|
192
|
+
export interface PlacedWord extends WordCloudTerm {
|
|
193
|
+
x: number;
|
|
194
|
+
y: number;
|
|
195
|
+
fontSize: number;
|
|
196
|
+
index: number;
|
|
197
|
+
}
|
|
198
|
+
export interface WordCloudOptions {
|
|
199
|
+
minFontSize?: number;
|
|
200
|
+
maxFontSize?: number;
|
|
201
|
+
/** Terms considered, highest weight first. Beyond this the tail is dropped. */
|
|
202
|
+
limit?: number;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Deterministic spiral packing for a word cloud.
|
|
206
|
+
*
|
|
207
|
+
* Caveat, stated where it can't be missed: glyph AREA is not a comparable
|
|
208
|
+
* encoding, and a long word reads as bigger than a short one at the same
|
|
209
|
+
* weight. A ranked bar chart shows the same data honestly. This exists because
|
|
210
|
+
* a gallery wants one; reach for `BarChart` when the numbers matter.
|
|
211
|
+
*
|
|
212
|
+
* No randomness anywhere — the same input must produce the same picture, or the
|
|
213
|
+
* server and the client disagree and visual regression never settles.
|
|
214
|
+
*/
|
|
215
|
+
export declare function wordCloudLayout(terms: WordCloudTerm[], box: ChartBox, opts?: WordCloudOptions): PlacedWord[];
|
|
216
|
+
export interface StackedBand {
|
|
217
|
+
path: string;
|
|
218
|
+
index: number;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* One y-domain across every series, computed once. Series scaled independently
|
|
222
|
+
* look comparable and aren't.
|
|
223
|
+
*/
|
|
224
|
+
export declare function multiLinePoints(series: number[][], box: ChartBox): Point[][];
|
|
225
|
+
/**
|
|
226
|
+
* Part-to-whole over time: each series is a band stacked on the ones before it,
|
|
227
|
+
* scaled so the tallest total fills the box.
|
|
228
|
+
*/
|
|
229
|
+
export declare function stackedSeries(series: number[][], box: ChartBox): StackedBand[];
|
|
@@ -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
|
@@ -15,5 +15,22 @@ export { Badge } from './Badge';
|
|
|
15
15
|
export { Skeleton } from './Skeleton';
|
|
16
16
|
export { Spinner } from './Spinner';
|
|
17
17
|
export { Divider } from './Divider';
|
|
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';
|
|
18
34
|
export { VisuallyHidden } from './VisuallyHidden';
|
|
35
|
+
export { usePrefersReducedMotion } from './usePrefersReducedMotion';
|
|
19
36
|
export { cx } from './cx';
|