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.
@@ -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
+