radiant-charts-core 0.2.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/LICENSE +21 -0
- package/LICENSE.md +21 -0
- package/dist/index.d.mts +431 -0
- package/dist/index.d.ts +431 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +9 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +31 -0
- package/src/Declarative.tsx +503 -0
- package/src/RadiantChart.tsx +446 -0
- package/src/ResponsiveContainer.tsx +128 -0
- package/src/axes/CartesianAxis.ts +305 -0
- package/src/core/Animator.ts +119 -0
- package/src/core/ChartManager.ts +1062 -0
- package/src/core/CrosshairManager.ts +334 -0
- package/src/core/Legend.ts +269 -0
- package/src/core/ThemeManager.ts +98 -0
- package/src/index.ts +31 -0
- package/src/scale/Scale.ts +99 -0
- package/src/scene/Node.ts +183 -0
- package/src/scene/Scene.ts +197 -0
- package/src/scene/Shapes.ts +446 -0
- package/src/series/AreaSeries.ts +315 -0
- package/src/series/BarSeries.ts +502 -0
- package/src/series/LineSeries.ts +284 -0
- package/src/series/PieSeries.ts +203 -0
- package/src/series/ScatterSeries.ts +305 -0
- package/src/tooltip/TooltipContext.ts +22 -0
- package/src/tooltip/TooltipStore.ts +169 -0
- package/src/tooltip/__tests__/TooltipStore.test.ts +176 -0
- package/src/tooltip/coordUtils.ts +41 -0
- package/src/tooltip/index.ts +18 -0
- package/src/tooltip/types.ts +57 -0
- package/src/tooltip/useChartTooltip.ts +43 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// Radiant Charts - CrosshairRenderer (formerly CrosshairManager)
|
|
2
|
+
// Enhanced: snap dots per series, band highlighting, spike lines, lerp animation
|
|
3
|
+
|
|
4
|
+
import { Group, Line, Circle, Rect, Text } from '../scene/Shapes';
|
|
5
|
+
import { Theme } from './ThemeManager';
|
|
6
|
+
import { BandScale, safeBandwidth } from '../scale/Scale';
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
/** @deprecated Use `CrosshairRenderer` */
|
|
10
|
+
export type CrosshairManager = CrosshairRenderer;
|
|
11
|
+
|
|
12
|
+
export class CrosshairRenderer {
|
|
13
|
+
private group = new Group();
|
|
14
|
+
private xLine = new Line();
|
|
15
|
+
private yLine = new Line();
|
|
16
|
+
private snapDot = new Circle();
|
|
17
|
+
private bandHighlight = new Rect();
|
|
18
|
+
private enabledX = false;
|
|
19
|
+
private enabledY = false;
|
|
20
|
+
|
|
21
|
+
/** Pooled per-series snap dots for shared mode */
|
|
22
|
+
private seriesSnapDots: Circle[] = [];
|
|
23
|
+
/** Target radii for snap dot scale animation (0 = hidden, 4 = full) */
|
|
24
|
+
private seriesSnapTargets: number[] = [];
|
|
25
|
+
/** Spike line axis labels */
|
|
26
|
+
private spikeXLabel = new Text();
|
|
27
|
+
private spikeYLabel = new Text();
|
|
28
|
+
private spikeXBg = new Rect();
|
|
29
|
+
private spikeYBg = new Rect();
|
|
30
|
+
|
|
31
|
+
/** Lerp animation state — target vs current positions */
|
|
32
|
+
private _targetX = 0;
|
|
33
|
+
private _targetY = 0;
|
|
34
|
+
private _currentX = 0;
|
|
35
|
+
private _currentY = 0;
|
|
36
|
+
private _lerpActive = false;
|
|
37
|
+
private static readonly LERP_SPEED = 0.25; // 0–1, higher = faster convergence
|
|
38
|
+
/** Skip lerp animation when user prefers reduced motion */
|
|
39
|
+
private _reducedMotion = typeof window !== 'undefined'
|
|
40
|
+
&& window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
41
|
+
|
|
42
|
+
constructor() {
|
|
43
|
+
this.group.visible = false;
|
|
44
|
+
|
|
45
|
+
this.xLine.strokeWidth = 1;
|
|
46
|
+
this.yLine.strokeWidth = 1;
|
|
47
|
+
|
|
48
|
+
this.snapDot.radius = 5;
|
|
49
|
+
this.snapDot.fill = 'transparent';
|
|
50
|
+
this.snapDot.strokeWidth = 2;
|
|
51
|
+
|
|
52
|
+
this.bandHighlight.fill = 'rgba(128,128,128,0.06)';
|
|
53
|
+
this.bandHighlight.visible = false;
|
|
54
|
+
|
|
55
|
+
// Spike label defaults
|
|
56
|
+
this.spikeXLabel.fontSize = 10;
|
|
57
|
+
this.spikeXLabel.textAlign = 'center';
|
|
58
|
+
this.spikeXLabel.textBaseline = 'top';
|
|
59
|
+
this.spikeXLabel.visible = false;
|
|
60
|
+
this.spikeXBg.cornerRadius = 3;
|
|
61
|
+
this.spikeXBg.visible = false;
|
|
62
|
+
|
|
63
|
+
this.spikeYLabel.fontSize = 10;
|
|
64
|
+
this.spikeYLabel.textAlign = 'right';
|
|
65
|
+
this.spikeYLabel.textBaseline = 'middle';
|
|
66
|
+
this.spikeYLabel.visible = false;
|
|
67
|
+
this.spikeYBg.cornerRadius = 3;
|
|
68
|
+
this.spikeYBg.visible = false;
|
|
69
|
+
|
|
70
|
+
this.group.add(this.bandHighlight);
|
|
71
|
+
this.group.add(this.xLine);
|
|
72
|
+
this.group.add(this.yLine);
|
|
73
|
+
this.group.add(this.snapDot);
|
|
74
|
+
this.group.add(this.spikeXBg);
|
|
75
|
+
this.group.add(this.spikeXLabel);
|
|
76
|
+
this.group.add(this.spikeYBg);
|
|
77
|
+
this.group.add(this.spikeYLabel);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getGroup() { return this.group; }
|
|
81
|
+
|
|
82
|
+
updateOptions(options: { x?: boolean; y?: boolean }) {
|
|
83
|
+
this.enabledX = options.x ?? false;
|
|
84
|
+
this.enabledY = options.y ?? false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Show crosshair lines and snap to nearest data point.
|
|
89
|
+
* If xScale (BandScale) and data are provided, the vertical line snaps to
|
|
90
|
+
* the nearest category center instead of following the raw mouse X.
|
|
91
|
+
*/
|
|
92
|
+
show(
|
|
93
|
+
x: number,
|
|
94
|
+
y: number,
|
|
95
|
+
bounds: { x: number; y: number; width: number; height: number },
|
|
96
|
+
theme: Theme,
|
|
97
|
+
xScale?: BandScale,
|
|
98
|
+
data?: readonly any[],
|
|
99
|
+
xKey?: string
|
|
100
|
+
) {
|
|
101
|
+
if (!this.enabledX && !this.enabledY) {
|
|
102
|
+
this.group.visible = false;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
this.group.visible = true;
|
|
107
|
+
const color = theme.axisColor;
|
|
108
|
+
|
|
109
|
+
// Snap x to nearest category center
|
|
110
|
+
let snappedX = x;
|
|
111
|
+
let snappedY = y;
|
|
112
|
+
this.snapDot.visible = false;
|
|
113
|
+
this.bandHighlight.visible = false;
|
|
114
|
+
|
|
115
|
+
if (xScale && data && xKey) {
|
|
116
|
+
let minDist = Infinity;
|
|
117
|
+
let closestX = x;
|
|
118
|
+
let closestBandX = 0;
|
|
119
|
+
for (const d of data) {
|
|
120
|
+
const catX = xScale.convert(d[xKey]) + safeBandwidth(xScale) / 2;
|
|
121
|
+
const absX = catX + bounds.x;
|
|
122
|
+
const dist = Math.abs(absX - x);
|
|
123
|
+
if (dist < minDist) {
|
|
124
|
+
minDist = dist;
|
|
125
|
+
closestX = absX;
|
|
126
|
+
closestBandX = xScale.convert(d[xKey]);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
snappedX = closestX;
|
|
130
|
+
|
|
131
|
+
// Band highlight for BandScale charts
|
|
132
|
+
if (this.enabledX) {
|
|
133
|
+
this.bandHighlight.visible = true;
|
|
134
|
+
this.bandHighlight.x = closestBandX + bounds.x;
|
|
135
|
+
this.bandHighlight.y = bounds.y;
|
|
136
|
+
this.bandHighlight.width = safeBandwidth(xScale);
|
|
137
|
+
this.bandHighlight.height = bounds.height;
|
|
138
|
+
const isDark = theme.backgroundColor.includes('2') || theme.backgroundColor.includes('0');
|
|
139
|
+
this.bandHighlight.fill = isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Show snap dot on the crosshair intersection
|
|
143
|
+
if (this.enabledX) {
|
|
144
|
+
this.snapDot.visible = true;
|
|
145
|
+
this.snapDot.centerX = snappedX;
|
|
146
|
+
this.snapDot.centerY = y;
|
|
147
|
+
this.snapDot.fill = theme.backgroundColor;
|
|
148
|
+
this.snapDot.stroke = color;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Set targets for lerp animation
|
|
153
|
+
this._targetX = snappedX;
|
|
154
|
+
this._targetY = snappedY;
|
|
155
|
+
|
|
156
|
+
// On first show, snap immediately (no lerp from 0,0)
|
|
157
|
+
if (!this._lerpActive && this._currentX === 0 && this._currentY === 0) {
|
|
158
|
+
this._currentX = snappedX;
|
|
159
|
+
this._currentY = snappedY;
|
|
160
|
+
} else {
|
|
161
|
+
this._lerpActive = true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (this.enabledX) {
|
|
165
|
+
this.xLine.visible = true;
|
|
166
|
+
this.xLine.x1 = this._currentX; this.xLine.y1 = bounds.y;
|
|
167
|
+
this.xLine.x2 = this._currentX; this.xLine.y2 = bounds.y + bounds.height;
|
|
168
|
+
this.xLine.stroke = color;
|
|
169
|
+
this.xLine.lineDash = [4, 4];
|
|
170
|
+
} else {
|
|
171
|
+
this.xLine.visible = false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (this.enabledY) {
|
|
175
|
+
this.yLine.visible = true;
|
|
176
|
+
this.yLine.x1 = bounds.x; this.yLine.y1 = this._currentY;
|
|
177
|
+
this.yLine.x2 = bounds.x + bounds.width; this.yLine.y2 = this._currentY;
|
|
178
|
+
this.yLine.stroke = color;
|
|
179
|
+
this.yLine.lineDash = [4, 4];
|
|
180
|
+
} else {
|
|
181
|
+
this.yLine.visible = false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Hide spike labels by default (they're shown via showSeriesSnaps)
|
|
185
|
+
this.spikeXLabel.visible = false;
|
|
186
|
+
this.spikeXBg.visible = false;
|
|
187
|
+
this.spikeYLabel.visible = false;
|
|
188
|
+
this.spikeYBg.visible = false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Show spike line axis labels at the crosshair intersection with each axis.
|
|
193
|
+
*/
|
|
194
|
+
showSpikeLabels(
|
|
195
|
+
xLabel: string,
|
|
196
|
+
yLabel: string,
|
|
197
|
+
snappedX: number,
|
|
198
|
+
snappedY: number,
|
|
199
|
+
bounds: { x: number; y: number; width: number; height: number },
|
|
200
|
+
theme: Theme,
|
|
201
|
+
) {
|
|
202
|
+
const isDark = theme.backgroundColor.includes('2') || theme.backgroundColor.includes('0');
|
|
203
|
+
|
|
204
|
+
// X-axis spike label
|
|
205
|
+
if (xLabel && this.enabledX) {
|
|
206
|
+
this.spikeXLabel.visible = true;
|
|
207
|
+
this.spikeXLabel.text = xLabel;
|
|
208
|
+
this.spikeXLabel.x = snappedX;
|
|
209
|
+
this.spikeXLabel.y = bounds.y + bounds.height + 4;
|
|
210
|
+
this.spikeXLabel.fill = isDark ? '#e2e8f0' : '#1e293b';
|
|
211
|
+
this.spikeXLabel.fontFamily = theme.fontFamily;
|
|
212
|
+
|
|
213
|
+
// Background badge
|
|
214
|
+
const tw = xLabel.length * 6 + 12;
|
|
215
|
+
this.spikeXBg.visible = true;
|
|
216
|
+
this.spikeXBg.x = snappedX - tw / 2;
|
|
217
|
+
this.spikeXBg.y = bounds.y + bounds.height + 2;
|
|
218
|
+
this.spikeXBg.width = tw;
|
|
219
|
+
this.spikeXBg.height = 18;
|
|
220
|
+
this.spikeXBg.fill = isDark ? '#1e293b' : '#f1f5f9';
|
|
221
|
+
this.spikeXBg.stroke = theme.axisColor;
|
|
222
|
+
this.spikeXBg.strokeWidth = 1;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Y-axis spike label
|
|
226
|
+
if (yLabel && this.enabledY) {
|
|
227
|
+
this.spikeYLabel.visible = true;
|
|
228
|
+
this.spikeYLabel.text = yLabel;
|
|
229
|
+
this.spikeYLabel.x = bounds.x - 4;
|
|
230
|
+
this.spikeYLabel.y = snappedY;
|
|
231
|
+
this.spikeYLabel.fill = isDark ? '#e2e8f0' : '#1e293b';
|
|
232
|
+
this.spikeYLabel.fontFamily = theme.fontFamily;
|
|
233
|
+
|
|
234
|
+
const tw = yLabel.length * 6 + 12;
|
|
235
|
+
this.spikeYBg.visible = true;
|
|
236
|
+
this.spikeYBg.x = bounds.x - tw - 4;
|
|
237
|
+
this.spikeYBg.y = snappedY - 9;
|
|
238
|
+
this.spikeYBg.width = tw;
|
|
239
|
+
this.spikeYBg.height = 18;
|
|
240
|
+
this.spikeYBg.fill = isDark ? '#1e293b' : '#f1f5f9';
|
|
241
|
+
this.spikeYBg.stroke = theme.axisColor;
|
|
242
|
+
this.spikeYBg.strokeWidth = 1;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Tick the lerp interpolation one step. Called each RAF frame by ChartManager.
|
|
248
|
+
* Returns true if the crosshair position changed (scene needs re-render).
|
|
249
|
+
*/
|
|
250
|
+
tick(): boolean {
|
|
251
|
+
if (!this.group.visible) return false;
|
|
252
|
+
// Check if there are any animating snap dots
|
|
253
|
+
const hasSnapAnim = this.seriesSnapDots.some((d, i) =>
|
|
254
|
+
Math.abs((this.seriesSnapTargets[i] ?? 0) - d.radius) > 0.2
|
|
255
|
+
);
|
|
256
|
+
if (!this._lerpActive && !hasSnapAnim) return false;
|
|
257
|
+
|
|
258
|
+
// Skip animation when user prefers reduced motion — snap instantly
|
|
259
|
+
if (this._reducedMotion) {
|
|
260
|
+
this._currentX = this._targetX;
|
|
261
|
+
this._currentY = this._targetY;
|
|
262
|
+
this._lerpActive = false;
|
|
263
|
+
if (this.enabledX) { this.xLine.x1 = this._currentX; this.xLine.x2 = this._currentX; }
|
|
264
|
+
if (this.enabledY) { this.yLine.y1 = this._currentY; this.yLine.y2 = this._currentY; }
|
|
265
|
+
for (let i = 0; i < this.seriesSnapDots.length; i++) {
|
|
266
|
+
const dot = this.seriesSnapDots[i];
|
|
267
|
+
const target = this.seriesSnapTargets[i] ?? 0;
|
|
268
|
+
dot.radius = target;
|
|
269
|
+
if (target === 0) dot.visible = false;
|
|
270
|
+
}
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const speed = CrosshairRenderer.LERP_SPEED;
|
|
275
|
+
const dx = this._targetX - this._currentX;
|
|
276
|
+
const dy = this._targetY - this._currentY;
|
|
277
|
+
|
|
278
|
+
// Snap when close enough (sub-pixel)
|
|
279
|
+
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) {
|
|
280
|
+
this._currentX = this._targetX;
|
|
281
|
+
this._currentY = this._targetY;
|
|
282
|
+
this._lerpActive = false;
|
|
283
|
+
} else {
|
|
284
|
+
this._currentX += dx * speed;
|
|
285
|
+
this._currentY += dy * speed;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Apply interpolated position to the crosshair lines
|
|
289
|
+
if (this.enabledX) {
|
|
290
|
+
this.xLine.x1 = this._currentX;
|
|
291
|
+
this.xLine.x2 = this._currentX;
|
|
292
|
+
}
|
|
293
|
+
if (this.enabledY) {
|
|
294
|
+
this.yLine.y1 = this._currentY;
|
|
295
|
+
this.yLine.y2 = this._currentY;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Animate snap dot radii (scale in/out)
|
|
299
|
+
for (let i = 0; i < this.seriesSnapDots.length; i++) {
|
|
300
|
+
const dot = this.seriesSnapDots[i];
|
|
301
|
+
const target = this.seriesSnapTargets[i] ?? 0;
|
|
302
|
+
const diff = target - dot.radius;
|
|
303
|
+
if (Math.abs(diff) < 0.2) {
|
|
304
|
+
dot.radius = target;
|
|
305
|
+
if (target === 0) dot.visible = false;
|
|
306
|
+
} else {
|
|
307
|
+
dot.radius += diff * 0.35;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Hide all series snap dots (animated scale-out via tick()) */
|
|
315
|
+
hideSeriesSnaps() {
|
|
316
|
+
for (let i = 0; i < this.seriesSnapDots.length; i++) {
|
|
317
|
+
this.seriesSnapTargets[i] = 0;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
hide() {
|
|
322
|
+
this.group.visible = false;
|
|
323
|
+
this.bandHighlight.visible = false;
|
|
324
|
+
this.hideSeriesSnaps();
|
|
325
|
+
this.spikeXLabel.visible = false;
|
|
326
|
+
this.spikeXBg.visible = false;
|
|
327
|
+
this.spikeYLabel.visible = false;
|
|
328
|
+
this.spikeYBg.visible = false;
|
|
329
|
+
// Reset lerp so next show starts fresh
|
|
330
|
+
this._lerpActive = false;
|
|
331
|
+
this._currentX = 0;
|
|
332
|
+
this._currentY = 0;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { Group, Rect, Text, Circle, Line } from '../scene/Shapes';
|
|
2
|
+
import { Theme } from './ThemeManager';
|
|
3
|
+
|
|
4
|
+
export interface LegendItem {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
fill: string;
|
|
8
|
+
markerType?: 'square' | 'circle' | 'line';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Legend: Renders the chart legend with interactive toggle and pagination support.
|
|
13
|
+
*
|
|
14
|
+
* Items are wrapped into rows then split into pages so the legend never grows
|
|
15
|
+
* taller than ~20 % of the chart height. When multiple pages exist, ◀ / ▶
|
|
16
|
+
* buttons are rendered in a nav row at the bottom of the legend group.
|
|
17
|
+
*
|
|
18
|
+
* ## Click flow
|
|
19
|
+
* - Series item group → `seriesId` = the series ID → ChartManager toggles visibility
|
|
20
|
+
* - Arrow button group → `seriesId` = '__legend_prev__' | '__legend_next__'
|
|
21
|
+
* → ChartManager calls `legend.paginate(±1)`
|
|
22
|
+
*
|
|
23
|
+
* ## Hit-testing note
|
|
24
|
+
* `Text.isPointInNode` always returns false, so arrow buttons wrap the text in
|
|
25
|
+
* a Group that also contains a transparent Rect that absorbs pointer events.
|
|
26
|
+
*/
|
|
27
|
+
export class Legend {
|
|
28
|
+
private group = new Group();
|
|
29
|
+
private items: LegendItem[] = [];
|
|
30
|
+
private theme?: Theme;
|
|
31
|
+
|
|
32
|
+
/** Series IDs that the user has toggled off via legend click. */
|
|
33
|
+
hiddenSeries: string[] = [];
|
|
34
|
+
|
|
35
|
+
// ── Pagination state ────────────────────────────────────────────────────────
|
|
36
|
+
private pages: LegendItem[][] = [];
|
|
37
|
+
private currentPage = 0;
|
|
38
|
+
|
|
39
|
+
// ── Layout constants ────────────────────────────────────────────────────────
|
|
40
|
+
private readonly ROW_H = 24; // px per item row
|
|
41
|
+
private readonly NAV_H = 22; // px for the ◀/▶ row
|
|
42
|
+
private readonly MARKER = 12; // marker square/circle size
|
|
43
|
+
private readonly GAP = 6; // gap between marker and label
|
|
44
|
+
private readonly SPACING = 20; // gap between items on the same row
|
|
45
|
+
private readonly CHAR_W = 7.5; // estimated px per label character
|
|
46
|
+
|
|
47
|
+
// Captured in update() and reused by _renderCurrentPage()
|
|
48
|
+
private lastMaxWidth = 0;
|
|
49
|
+
private lastMaxRows = 2;
|
|
50
|
+
|
|
51
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
getGroup() { return this.group; }
|
|
54
|
+
|
|
55
|
+
setHiddenSeries(ids: string[]) { this.hiddenSeries = ids; }
|
|
56
|
+
getHiddenSeries(): string[] { return this.hiddenSeries; }
|
|
57
|
+
|
|
58
|
+
update(items: LegendItem[], theme: Theme, maxWidth: number, maxHeight: number, position: string = 'bottom') {
|
|
59
|
+
this.items = items;
|
|
60
|
+
this.theme = theme;
|
|
61
|
+
this.lastMaxWidth = maxWidth;
|
|
62
|
+
|
|
63
|
+
const isVertical = position === 'left' || position === 'right';
|
|
64
|
+
const verticalLimitFrac = 0.85; // Reserve up to 85% for side legends
|
|
65
|
+
const horizontalLimitFrac = 0.25; // Reserve up to 25% for top/bottom legends
|
|
66
|
+
|
|
67
|
+
// Reserve space based on orientation; side legends can grow much taller.
|
|
68
|
+
this.lastMaxRows = Math.max(1, Math.floor((maxHeight * (isVertical ? verticalLimitFrac : horizontalLimitFrac)) / this.ROW_H));
|
|
69
|
+
|
|
70
|
+
this.pages = this._buildPages(items, maxWidth, this.lastMaxRows);
|
|
71
|
+
// Clamp in case items were removed and pages count dropped
|
|
72
|
+
this.currentPage = Math.min(this.currentPage, Math.max(0, this.pages.length - 1));
|
|
73
|
+
|
|
74
|
+
this._renderCurrentPage();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Advance (+1) or retreat (−1) one page and re-render.
|
|
79
|
+
* Called by ChartManager when the ◀ / ▶ arrow is clicked.
|
|
80
|
+
*/
|
|
81
|
+
paginate(delta: 1 | -1) {
|
|
82
|
+
this.currentPage = Math.max(0, Math.min(this.pages.length - 1, this.currentPage + delta));
|
|
83
|
+
this._renderCurrentPage();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
getBBox() { return this.group.getBBox(); }
|
|
87
|
+
|
|
88
|
+
// ── Private helpers ─────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Wrap items into rows then chunk rows into pages.
|
|
92
|
+
*
|
|
93
|
+
* Two-stage layout:
|
|
94
|
+
* 1. Pack items left-to-right; start a new row when the current row overflows.
|
|
95
|
+
* 2. Split the resulting rows into pages of `maxRows` rows each.
|
|
96
|
+
*/
|
|
97
|
+
private _buildPages(items: LegendItem[], maxWidth: number, maxRows: number): LegendItem[][] {
|
|
98
|
+
// Stage 1: row-wrap
|
|
99
|
+
const rows: LegendItem[][] = [];
|
|
100
|
+
let currentRow: LegendItem[] = [];
|
|
101
|
+
let currentX = 0;
|
|
102
|
+
|
|
103
|
+
for (const item of items) {
|
|
104
|
+
const itemW = this.MARKER + this.GAP + item.label.length * this.CHAR_W + this.SPACING;
|
|
105
|
+
if (currentX + itemW > maxWidth && currentRow.length > 0) {
|
|
106
|
+
rows.push(currentRow);
|
|
107
|
+
currentRow = [];
|
|
108
|
+
currentX = 0;
|
|
109
|
+
}
|
|
110
|
+
currentRow.push(item);
|
|
111
|
+
currentX += itemW;
|
|
112
|
+
}
|
|
113
|
+
if (currentRow.length > 0) rows.push(currentRow);
|
|
114
|
+
|
|
115
|
+
// Stage 2: page-chunk
|
|
116
|
+
const pages: LegendItem[][] = [];
|
|
117
|
+
for (let i = 0; i < rows.length; i += maxRows) {
|
|
118
|
+
pages.push(rows.slice(i, i + maxRows).flat());
|
|
119
|
+
}
|
|
120
|
+
return pages.length > 0 ? pages : [[]];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Rebuild the group's children for the active page. */
|
|
124
|
+
private _renderCurrentPage() {
|
|
125
|
+
this.group.clear();
|
|
126
|
+
if (!this.theme) return;
|
|
127
|
+
|
|
128
|
+
const theme = this.theme;
|
|
129
|
+
const items = this.pages[this.currentPage] ?? [];
|
|
130
|
+
const multiPage = this.pages.length > 1;
|
|
131
|
+
|
|
132
|
+
// ── Series item rows ────────────────────────────────────────────────────
|
|
133
|
+
let currentX = 0;
|
|
134
|
+
let currentY = 0;
|
|
135
|
+
|
|
136
|
+
for (const item of items) {
|
|
137
|
+
const labelW = item.label.length * this.CHAR_W;
|
|
138
|
+
const totalW = this.MARKER + this.GAP + labelW;
|
|
139
|
+
|
|
140
|
+
if (currentX + totalW > this.lastMaxWidth && currentX > 0) {
|
|
141
|
+
currentX = 0;
|
|
142
|
+
currentY += this.ROW_H;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const itemGroup = new Group();
|
|
146
|
+
itemGroup.translation = { x: currentX, y: currentY };
|
|
147
|
+
itemGroup.seriesId = item.id;
|
|
148
|
+
itemGroup.opacity = this.hiddenSeries.includes(item.id) ? 0.4 : 1;
|
|
149
|
+
itemGroup.pointerEvents = true;
|
|
150
|
+
|
|
151
|
+
if (item.markerType === 'circle') {
|
|
152
|
+
const marker = new Circle();
|
|
153
|
+
marker.centerX = this.MARKER / 2;
|
|
154
|
+
marker.centerY = this.MARKER / 2;
|
|
155
|
+
marker.radius = this.MARKER / 2;
|
|
156
|
+
marker.fill = item.fill;
|
|
157
|
+
itemGroup.add(marker);
|
|
158
|
+
} else if (item.markerType === 'line') {
|
|
159
|
+
const marker = new Line();
|
|
160
|
+
marker.x1 = 0;
|
|
161
|
+
marker.y1 = this.MARKER / 2;
|
|
162
|
+
marker.x2 = this.MARKER;
|
|
163
|
+
marker.y2 = this.MARKER / 2;
|
|
164
|
+
marker.stroke = item.fill;
|
|
165
|
+
marker.strokeWidth = 3;
|
|
166
|
+
itemGroup.add(marker);
|
|
167
|
+
} else {
|
|
168
|
+
const marker = new Rect();
|
|
169
|
+
marker.width = this.MARKER;
|
|
170
|
+
marker.height = this.MARKER;
|
|
171
|
+
marker.fill = item.fill;
|
|
172
|
+
marker.cornerRadius = 2;
|
|
173
|
+
itemGroup.add(marker);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const label = new Text();
|
|
177
|
+
label.text = item.label;
|
|
178
|
+
label.x = this.MARKER + this.GAP;
|
|
179
|
+
label.y = this.MARKER / 2;
|
|
180
|
+
label.fontSize = 11;
|
|
181
|
+
label.fontFamily = theme.fontFamily;
|
|
182
|
+
label.fill = theme.textColor;
|
|
183
|
+
label.textBaseline = 'middle';
|
|
184
|
+
itemGroup.add(label);
|
|
185
|
+
|
|
186
|
+
this.group.add(itemGroup);
|
|
187
|
+
currentX += totalW + this.SPACING;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Navigation row ──────────────────────────────────────────────────────
|
|
191
|
+
if (!multiPage) return;
|
|
192
|
+
|
|
193
|
+
const navY = currentY + (items.length > 0 ? this.ROW_H : 0);
|
|
194
|
+
const muted = theme.subtextColor;
|
|
195
|
+
const btnH = this.NAV_H;
|
|
196
|
+
const btnW = 28; // clickable width for each arrow button
|
|
197
|
+
|
|
198
|
+
// ◀ prev
|
|
199
|
+
if (this.currentPage > 0) {
|
|
200
|
+
this.group.add(this._makeNavButton('◀', '__legend_prev__', 0, navY, btnW, btnH, muted, theme, 'start'));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// "2 / 5" page indicator (non-interactive)
|
|
204
|
+
const pageIndicator = new Text();
|
|
205
|
+
pageIndicator.text = `${this.currentPage + 1} / ${this.pages.length}`;
|
|
206
|
+
pageIndicator.x = this.lastMaxWidth / 2;
|
|
207
|
+
pageIndicator.y = navY + btnH / 2;
|
|
208
|
+
pageIndicator.fontSize = 10;
|
|
209
|
+
pageIndicator.fontFamily = theme.fontFamily;
|
|
210
|
+
pageIndicator.fill = muted;
|
|
211
|
+
pageIndicator.textAlign = 'center';
|
|
212
|
+
pageIndicator.textBaseline = 'middle';
|
|
213
|
+
pageIndicator.pointerEvents = false;
|
|
214
|
+
this.group.add(pageIndicator);
|
|
215
|
+
|
|
216
|
+
// ▶ next
|
|
217
|
+
if (this.currentPage < this.pages.length - 1) {
|
|
218
|
+
this.group.add(
|
|
219
|
+
this._makeNavButton('▶', '__legend_next__', this.lastMaxWidth - btnW, navY, btnW, btnH, muted, theme, 'end'),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Creates a clickable arrow button: an invisible hit-area Rect behind a Text
|
|
226
|
+
* arrow glyph, wrapped in a Group tagged with the special seriesId.
|
|
227
|
+
*
|
|
228
|
+
* The `Rect` provides a reliably hittable area because `Text.isPointInNode`
|
|
229
|
+
* always returns false in this scene-graph implementation.
|
|
230
|
+
*/
|
|
231
|
+
private _makeNavButton(
|
|
232
|
+
glyph: string,
|
|
233
|
+
actionId: '__legend_prev__' | '__legend_next__',
|
|
234
|
+
x: number,
|
|
235
|
+
y: number,
|
|
236
|
+
w: number,
|
|
237
|
+
h: number,
|
|
238
|
+
color: string,
|
|
239
|
+
theme: Theme,
|
|
240
|
+
align: CanvasTextAlign,
|
|
241
|
+
): Group {
|
|
242
|
+
const btn = new Group();
|
|
243
|
+
btn.translation = { x, y };
|
|
244
|
+
btn.seriesId = actionId;
|
|
245
|
+
btn.pointerEvents = true;
|
|
246
|
+
|
|
247
|
+
// Transparent hit-area so the full button region is clickable
|
|
248
|
+
const hitRect = new Rect();
|
|
249
|
+
hitRect.x = 0;
|
|
250
|
+
hitRect.y = 0;
|
|
251
|
+
hitRect.width = w;
|
|
252
|
+
hitRect.height = h;
|
|
253
|
+
hitRect.fill = 'transparent';
|
|
254
|
+
btn.add(hitRect);
|
|
255
|
+
|
|
256
|
+
const label = new Text();
|
|
257
|
+
label.text = glyph;
|
|
258
|
+
label.x = align === 'end' ? w : 0;
|
|
259
|
+
label.y = h / 2;
|
|
260
|
+
label.fontSize = 11;
|
|
261
|
+
label.fontFamily = theme.fontFamily;
|
|
262
|
+
label.fill = color;
|
|
263
|
+
label.textAlign = align;
|
|
264
|
+
label.textBaseline = 'middle';
|
|
265
|
+
btn.add(label);
|
|
266
|
+
|
|
267
|
+
return btn;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Radiant Charts - ThemeManager
|
|
2
|
+
// Phase 1.2: Global Theming System with semantic palettes
|
|
3
|
+
|
|
4
|
+
export interface ThemePalettes {
|
|
5
|
+
categorical: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface Theme {
|
|
9
|
+
backgroundColor: string;
|
|
10
|
+
textColor: string;
|
|
11
|
+
subtextColor: string;
|
|
12
|
+
axisColor: string;
|
|
13
|
+
gridColor: string;
|
|
14
|
+
/** Default palette (categorical) */
|
|
15
|
+
palette: string[];
|
|
16
|
+
palettes: ThemePalettes;
|
|
17
|
+
fontFamily: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ─── Palettes ────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const CATEGORICAL_LIGHT = [
|
|
23
|
+
'#4e79a7', '#f28e2c', '#e15759', '#76b7b2',
|
|
24
|
+
'#59a14f', '#edc948', '#b07aa1', '#ff9da7',
|
|
25
|
+
'#9c755f', '#bab0ac',
|
|
26
|
+
];
|
|
27
|
+
const CATEGORICAL_DARK = [
|
|
28
|
+
'#80b1d3', '#fdb462', '#fb8072', '#bebada',
|
|
29
|
+
'#b3de69', '#fccde5', '#8dd3c7', '#ffffb3',
|
|
30
|
+
'#bc80bd', '#ccebc5',
|
|
31
|
+
];
|
|
32
|
+
// ─── Built-in Themes ─────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export const lightTheme: Theme = {
|
|
35
|
+
backgroundColor: '#ffffff',
|
|
36
|
+
textColor: '#1a1a2e',
|
|
37
|
+
subtextColor: '#6b7280',
|
|
38
|
+
axisColor: '#d1d5db',
|
|
39
|
+
gridColor: 'rgba(107,114,128,0.12)',
|
|
40
|
+
palette: CATEGORICAL_LIGHT,
|
|
41
|
+
palettes: {
|
|
42
|
+
categorical: CATEGORICAL_LIGHT,
|
|
43
|
+
},
|
|
44
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const darkTheme: Theme = {
|
|
48
|
+
backgroundColor: '#020617',
|
|
49
|
+
textColor: '#e2e8f0',
|
|
50
|
+
subtextColor: '#94a3b8',
|
|
51
|
+
axisColor: '#334155',
|
|
52
|
+
gridColor: 'rgba(148,163,184,0.08)',
|
|
53
|
+
palette: CATEGORICAL_DARK,
|
|
54
|
+
palettes: {
|
|
55
|
+
categorical: CATEGORICAL_DARK,
|
|
56
|
+
},
|
|
57
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// ─── ThemeManager ─────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
export type ThemeOption = 'light' | 'dark' | 'system' | Theme;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Detects whether the OS/browser is currently in dark mode.
|
|
66
|
+
* Returns false in SSR or when the API is unavailable.
|
|
67
|
+
*/
|
|
68
|
+
function systemPrefersDark(): boolean {
|
|
69
|
+
if (typeof window === 'undefined') return false;
|
|
70
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class ThemeManager {
|
|
74
|
+
private currentTheme: Theme = lightTheme;
|
|
75
|
+
|
|
76
|
+
setTheme(name: ThemeOption) {
|
|
77
|
+
if (name === 'light') this.currentTheme = lightTheme;
|
|
78
|
+
else if (name === 'dark') this.currentTheme = darkTheme;
|
|
79
|
+
else if (name === 'system') this.currentTheme = systemPrefersDark() ? darkTheme : lightTheme;
|
|
80
|
+
else this.currentTheme = name as Theme;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get theme(): Theme {
|
|
84
|
+
return this.currentTheme;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolves whether the given theme option evaluates to dark mode right now.
|
|
89
|
+
* Useful for UI layers that need an `isDark` boolean without holding a
|
|
90
|
+
* ThemeManager instance.
|
|
91
|
+
*/
|
|
92
|
+
static resolveIsDark(option: ThemeOption | undefined): boolean {
|
|
93
|
+
if (option === 'dark') return true;
|
|
94
|
+
if (option === 'system') return systemPrefersDark();
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|