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,305 @@
1
+ // Radiant Charts - CartesianAxis
2
+ // Phase 1.2 + 5.2: Smart formatting, dashed grid lines, data-ink optimization
3
+ // Phase 6: Dynamic axis sizing & configurable label offsets
4
+
5
+ import { Group, Line, Text } from '../scene/Shapes';
6
+ import { Scale } from '../scale/Scale';
7
+ import { Theme } from '../core/ThemeManager';
8
+
9
+ export interface AxisOptions {
10
+ position: 'top' | 'bottom' | 'left' | 'right';
11
+ title?: string;
12
+ /** Show horizontal grid lines (default true for left/right axes) */
13
+ gridLines?: boolean;
14
+ /** Length (px) of the small tick mark on the X-axis. Default: 4 */
15
+ tickLength?: number;
16
+ /** Gap (px) between tick end and label start. Default: 2 */
17
+ labelGap?: number;
18
+ fontSize?: number;
19
+ fontFamily?: string;
20
+ rotation?: number;
21
+ /** 'start' | 'center' | 'end' — only applicable for categorical (Band) scales */
22
+ labelAlignment?: 'start' | 'center' | 'end';
23
+ }
24
+
25
+ /** Phase 5.2 – Smart axis label formatting */
26
+ export function formatAxisValue(val: unknown): string {
27
+ if (val instanceof Date) {
28
+ // Default Date formatting — short M/D/YY. Time scales with sub-day intervals
29
+ // can override this via AxisConfig.label.formatter.
30
+ return `${val.getMonth() + 1}/${val.getDate()}/${String(val.getFullYear()).slice(-2)}`;
31
+ }
32
+ if (typeof val !== 'number') return String(val);
33
+ const abs = Math.abs(val);
34
+ if (abs > 1e11 && abs < 1e14) { // Likely epoch milliseconds (between 1973 and 5138)
35
+ const d = new Date(val);
36
+ if (!isNaN(d.getTime())) {
37
+ return `${d.getMonth() + 1}/${d.getDate()}/${String(d.getFullYear()).slice(-2)}`;
38
+ }
39
+ }
40
+ if (abs >= 1e9) return (val / 1e9).toFixed(1).replace(/\.0$/, '') + 'B';
41
+ if (abs >= 1e6) return (val / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
42
+ if (abs >= 1e3) return (val / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
43
+ if (val % 1 !== 0) {
44
+ // For small decimals (common in KDE / probability densities), preserve
45
+ // significant digits — `toFixed(1)` would collapse 0.005 to "0.0".
46
+ if (abs > 0 && abs < 1) {
47
+ return Number(val.toPrecision(3)).toString();
48
+ }
49
+ return val.toFixed(1);
50
+ }
51
+ return String(val);
52
+ }
53
+
54
+ /**
55
+ * Measures the required space for this axis given the current ticks and theme,
56
+ * using the provided canvas context for accurate text measurement.
57
+ */
58
+ export interface AxisSpaceInfo {
59
+ /** Total pixels the axis needs perpendicular to its direction */
60
+ size: number;
61
+ }
62
+
63
+ export class CartesianAxis {
64
+ private group = new Group();
65
+ public scale: Scale<any, number>;
66
+ private options: AxisOptions;
67
+ private theme: Theme | null = null;
68
+ private _measuredLabelMaxW: number = 0;
69
+ private _layoutMaxWidth: number = 0;
70
+
71
+ constructor(scale: Scale<any, number>, options: AxisOptions) {
72
+ this.scale = scale;
73
+ this.options = options;
74
+ }
75
+
76
+ setLayoutMaxWidth(max: number) {
77
+ this._layoutMaxWidth = max;
78
+ }
79
+
80
+ getGroup() { return this.group; }
81
+
82
+ setTheme(theme: Theme) { this.theme = theme; }
83
+
84
+ updateOptions(options: Partial<AxisOptions>) {
85
+ this.options = { ...this.options, ...options };
86
+ }
87
+
88
+ /**
89
+ * Measures the exact space required for this axis perpendicular to its length.
90
+ */
91
+ getRequiredSpace(ctx: CanvasRenderingContext2D): AxisSpaceInfo {
92
+ const { position, rotation = 0 } = this.options;
93
+ const fontFamily = this.options.fontFamily || this.theme?.fontFamily || 'sans-serif';
94
+ const fontSize = this.options.fontSize ?? 10;
95
+ const tickLength = this.options.tickLength ?? 4;
96
+ const labelGap = this.options.labelGap ?? 2;
97
+
98
+ const tickCount: number | undefined = (this as any)._tickCount;
99
+ const labelFormatter: ((p: { value: any; index: number }) => string) | undefined = (this as any)._labelFormatter;
100
+
101
+ const ticks = this.scale.ticks ? this.scale.ticks(tickCount) : [];
102
+ ctx.font = `${fontSize}px ${fontFamily}`;
103
+
104
+ const rad = (rotation * Math.PI) / 180;
105
+ const absCos = Math.abs(Math.cos(rad));
106
+ const absSin = Math.abs(Math.sin(rad));
107
+
108
+ if (position === 'bottom' || position === 'top') {
109
+ let maxH = fontSize;
110
+ if (rotation !== 0) {
111
+ for (let i = 0; i < ticks.length; i++) {
112
+ const tick = ticks[i];
113
+ const text = labelFormatter
114
+ ? labelFormatter({ value: tick, index: i })
115
+ : formatAxisValue(tick);
116
+ const w = ctx.measureText(text).width;
117
+ const h = w * absSin + fontSize * absCos;
118
+ if (h > maxH) maxH = h;
119
+ }
120
+ }
121
+ let totalH = tickLength + labelGap + maxH + 4; // added 4px buffer
122
+ if (this.options.title) totalH += 6 + 12; // increased gap and space
123
+ return { size: Math.ceil(totalH) };
124
+ }
125
+
126
+ // left / right
127
+ let maxW = 0;
128
+ for (let i = 0; i < ticks.length; i++) {
129
+ const tick = ticks[i];
130
+ const text = labelFormatter
131
+ ? labelFormatter({ value: tick, index: i })
132
+ : formatAxisValue(tick);
133
+ const w = ctx.measureText(text).width;
134
+ const effectiveW = rotation !== 0 ? (w * absCos + fontSize * absSin) : w;
135
+ if (effectiveW > maxW) maxW = effectiveW;
136
+ }
137
+ this._measuredLabelMaxW = Math.ceil(maxW);
138
+ let totalW = labelGap + this._measuredLabelMaxW + 8; // increased from 6 to 8 for better label padding
139
+ if (this.options.title) totalW += 8 + 14; // increased title buffer to ensure no clipping
140
+
141
+ // Force a minimum width of 32px for side axes if they have labels,
142
+ // preventing the chart from collapsing to 0px and causing rendering artifacts.
143
+ if (ticks.length > 0) {
144
+ totalW = Math.max(totalW, 32);
145
+ }
146
+
147
+ return { size: Math.ceil(totalW) };
148
+ }
149
+
150
+ update(length: number, gridLength: number = 0, showLabels: boolean = true, innerPad: number = 0, gridOffset: number = 0) {
151
+ this.group.clear();
152
+ const { position, rotation = 0 } = this.options;
153
+
154
+ const textColor = this.theme?.subtextColor ?? '#6b7280';
155
+ const gridColor = this.theme?.gridColor ?? 'rgba(107,114,128,0.12)';
156
+ const axisColor = this.theme?.axisColor ?? '#d1d5db';
157
+ const fontFamily = this.options.fontFamily || this.theme?.fontFamily || 'sans-serif';
158
+ const fontSize = this.options.fontSize ?? 10;
159
+ const tickLength = this.options.tickLength ?? 4;
160
+ const labelGap = this.options.labelGap ?? 2;
161
+
162
+ if (position === 'bottom' || position === 'top') {
163
+ this.scale.range = [innerPad, length - innerPad];
164
+ } else {
165
+ this.scale.range = [length - innerPad, innerPad];
166
+ }
167
+
168
+ if (!showLabels) return;
169
+
170
+ if (position === 'bottom' || position === 'top') {
171
+ const axisLine = new Line();
172
+ axisLine.x1 = 0; axisLine.y1 = 0; axisLine.x2 = length; axisLine.y2 = 0;
173
+ axisLine.stroke = axisColor; axisLine.strokeWidth = 1;
174
+ this.group.add(axisLine);
175
+ }
176
+
177
+ const tickCount: number | undefined = (this as any)._tickCount;
178
+ const labelFormatter: ((p: { value: any; index: number }) => string) | undefined = (this as any)._labelFormatter;
179
+ const gridStyleArr: ReadonlyArray<{ stroke?: string; lineDash?: readonly number[] }> | undefined = (this as any)._gridStyle;
180
+
181
+ const ticks = this.scale.ticks ? this.scale.ticks(tickCount) : [];
182
+ const showGrid = this.options.gridLines !== false;
183
+ const { labelAlignment = 'center' } = this.options;
184
+
185
+ const getBW = (this.scale as any).getBandwidth?.bind(this.scale);
186
+
187
+ const isXAxis = position === 'bottom' || position === 'top';
188
+ const isYAxis = position === 'left' || position === 'right';
189
+ const labelMinGap = fontSize * 0.5;
190
+ let lastLabelPos = isXAxis ? -Infinity : Infinity;
191
+
192
+ ticks.forEach((tick, tIdx) => {
193
+ let pos = this.scale.convert(tick);
194
+
195
+ if (getBW) {
196
+ const bw = getBW();
197
+ const [br0, br1] = this.scale.range;
198
+ const stepSign = br1 >= br0 ? 1 : -1;
199
+ if (labelAlignment === 'center') pos += (stepSign * bw) / 2;
200
+ else if (labelAlignment === 'end') pos += stepSign * bw;
201
+ }
202
+
203
+ const labelText = labelFormatter
204
+ ? labelFormatter({ value: tick, index: tIdx })
205
+ : formatAxisValue(tick);
206
+
207
+ if (isXAxis && rotation === 0) {
208
+ const estWidth = fontSize * 0.6 * labelText.length;
209
+ const labelLeft = pos - estWidth / 2;
210
+ if (labelLeft < lastLabelPos + labelMinGap) {
211
+ if (position === 'bottom') {
212
+ const tickLine = new Line();
213
+ tickLine.x1 = pos; tickLine.y1 = 0; tickLine.x2 = pos; tickLine.y2 = tickLength;
214
+ tickLine.stroke = axisColor; tickLine.strokeWidth = 1;
215
+ this.group.add(tickLine);
216
+ }
217
+ return;
218
+ }
219
+ lastLabelPos = pos + estWidth / 2;
220
+ }
221
+
222
+ if (isYAxis) {
223
+ if (Math.abs(pos - lastLabelPos) < fontSize + 2) {
224
+ return;
225
+ }
226
+ lastLabelPos = pos;
227
+ }
228
+
229
+ const label = new Text();
230
+ label.text = labelText;
231
+ label.fontSize = fontSize;
232
+ label.fill = textColor;
233
+ label.fontFamily = fontFamily;
234
+
235
+ const rad = (rotation * Math.PI) / 180;
236
+ label.rotation = rad;
237
+
238
+ if (position === 'bottom') {
239
+ const tickLine = new Line();
240
+ tickLine.x1 = pos; tickLine.y1 = 0; tickLine.x2 = pos; tickLine.y2 = tickLength;
241
+ tickLine.stroke = axisColor; tickLine.strokeWidth = 1;
242
+ this.group.add(tickLine);
243
+
244
+ label.translation = { x: pos, y: tickLength + labelGap };
245
+ label.x = 0; label.y = 0;
246
+
247
+ if (rotation === 0) {
248
+ label.textAlign = 'center';
249
+ label.textBaseline = 'top';
250
+ } else {
251
+ label.textAlign = rotation > 0 ? 'left' : 'right';
252
+ label.textBaseline = 'middle';
253
+ }
254
+ this.group.add(label);
255
+
256
+ } else if (position === 'left' || position === 'right') {
257
+ const isRight = position === 'right';
258
+ const sign = isRight ? 1 : -1;
259
+
260
+ label.textAlign = isRight ? 'left' : 'right';
261
+ label.textBaseline = 'middle';
262
+ label.maxWidth = this._layoutMaxWidth > 0 ? (this._layoutMaxWidth - labelGap - 8) : undefined;
263
+ label.translation = { x: sign * labelGap, y: pos };
264
+ label.x = 0; label.y = 0;
265
+ this.group.add(label);
266
+
267
+ if (showGrid && gridLength > 0 && position === 'left') {
268
+ const gridLine = new Line();
269
+ gridLine.x1 = gridOffset; gridLine.y1 = pos;
270
+ gridLine.x2 = gridLength; gridLine.y2 = pos;
271
+ const gs = gridStyleArr && gridStyleArr.length > 0 ? gridStyleArr[tIdx % gridStyleArr.length] : undefined;
272
+ gridLine.stroke = gs?.stroke ?? gridColor;
273
+ gridLine.strokeWidth = 1;
274
+ gridLine.lineDash = gs?.lineDash ? [...gs.lineDash] : [4, 6];
275
+ this.group.add(gridLine);
276
+ }
277
+ }
278
+ });
279
+
280
+ if (this.options.title) {
281
+ const titleLabel = new Text();
282
+ titleLabel.text = this.options.title;
283
+ titleLabel.fontSize = 11;
284
+ titleLabel.fill = textColor;
285
+ titleLabel.fontFamily = fontFamily;
286
+ if (position === 'bottom') {
287
+ titleLabel.x = length / 2;
288
+ titleLabel.y = tickLength + labelGap + this._measuredLabelMaxW + 12;
289
+ titleLabel.textAlign = 'center';
290
+ } else if (position === 'left') {
291
+ titleLabel.x = -(labelGap + (this._layoutMaxWidth || this._measuredLabelMaxW) + 14);
292
+ titleLabel.y = length / 2;
293
+ titleLabel.rotation = -Math.PI / 2;
294
+ titleLabel.textAlign = 'center';
295
+ } else if (position === 'right') {
296
+ titleLabel.x = labelGap + (this._layoutMaxWidth || this._measuredLabelMaxW) + 14;
297
+ titleLabel.y = length / 2;
298
+ titleLabel.rotation = Math.PI / 2;
299
+ titleLabel.textAlign = 'center';
300
+ }
301
+ this.group.add(titleLabel);
302
+ }
303
+ }
304
+ }
305
+
@@ -0,0 +1,119 @@
1
+ // Radiant Charts - Animator
2
+ // Phase 6.1: Supports prefers-reduced-motion for accessibility
3
+
4
+ function prefersReducedMotion(): boolean {
5
+ if (typeof window === 'undefined') return false;
6
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
7
+ }
8
+
9
+ export class Animator {
10
+ private startTime: number = 0;
11
+ private duration: number;
12
+ private easing: (t: number) => number;
13
+ private onUpdate: (progress: number) => void;
14
+ private onComplete?: () => void;
15
+ private requestId: number | null = null;
16
+
17
+ // Easing presets
18
+ static linear = (t: number): number => t;
19
+
20
+ static easeInQuad = (t: number): number => t * t;
21
+ static easeOutQuad = (t: number): number => t * (2 - t);
22
+ static easeInOutQuad = (t: number): number =>
23
+ t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
24
+
25
+ static easeInCubic = (t: number): number => t * t * t;
26
+ static easeOutCubic = (t: number): number => (--t) * t * t + 1;
27
+ static easeInOutCubic = (t: number): number =>
28
+ t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1;
29
+
30
+ static easeInQuart = (t: number): number => t * t * t * t;
31
+ static easeOutQuart = (t: number): number => 1 - (--t) * t * t * t;
32
+ static easeInOutQuart = (t: number): number =>
33
+ t < 0.5 ? 8 * t * t * t * t : 1 - 8 * (--t) * t * t * t;
34
+
35
+ static easeOutBack = (t: number): number => {
36
+ const c1 = 1.70158, c3 = c1 + 1;
37
+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
38
+ };
39
+
40
+ static easeOutBounce = (t: number): number => {
41
+ const n1 = 7.5625;
42
+ const d1 = 2.75;
43
+ if (t < 1 / d1) return n1 * t * t;
44
+ if (t < 2 / d1) return n1 * (t -= 1.5 / d1) * t + 0.75;
45
+ if (t < 2.5 / d1) return n1 * (t -= 2.25 / d1) * t + 0.9375;
46
+ return n1 * (t -= 2.625 / d1) * t + 0.984375;
47
+ };
48
+
49
+ static easeOutElastic = (t: number): number => {
50
+ const c4 = (2 * Math.PI) / 3;
51
+ return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
52
+ };
53
+
54
+ static getEasing(name?: string): (t: number) => number {
55
+ switch (name) {
56
+ case 'linear': return Animator.linear;
57
+ case 'easeIn': return Animator.easeInQuad;
58
+ case 'easeOut': return Animator.easeOutQuad;
59
+ case 'easeInOut': return Animator.easeInOutQuad;
60
+ case 'easeInCubic': return Animator.easeInCubic;
61
+ case 'easeOutCubic': return Animator.easeOutCubic;
62
+ case 'easeInOutCubic': return Animator.easeInOutCubic;
63
+ case 'easeInQuart': return Animator.easeInQuart;
64
+ case 'easeOutQuart': return Animator.easeOutQuart;
65
+ case 'easeInOutQuart': return Animator.easeInOutQuart;
66
+ case 'bounce': return Animator.easeOutBounce;
67
+ case 'elastic': return Animator.easeOutElastic;
68
+ case 'back': return Animator.easeOutBack;
69
+ default: return Animator.easeOutQuart;
70
+ }
71
+ }
72
+
73
+ constructor(options: {
74
+ duration?: number;
75
+ easing?: (t: number) => number;
76
+ onUpdate: (progress: number) => void;
77
+ onComplete?: () => void;
78
+ }) {
79
+ this.duration = options.duration ?? 420;
80
+ this.easing = options.easing ?? Animator.easeOutQuart;
81
+ this.onUpdate = options.onUpdate;
82
+ this.onComplete = options.onComplete;
83
+ }
84
+
85
+ start() {
86
+ this.stop();
87
+
88
+ // Phase 6.1 – Skip animation for users who prefer reduced motion
89
+ if (prefersReducedMotion()) {
90
+ this.onUpdate(1);
91
+ this.onComplete?.();
92
+ return;
93
+ }
94
+
95
+ this.startTime = performance.now();
96
+ this.loop();
97
+ }
98
+
99
+ stop() {
100
+ if (this.requestId !== null) {
101
+ cancelAnimationFrame(this.requestId);
102
+ this.requestId = null;
103
+ }
104
+ }
105
+
106
+ private loop = () => {
107
+ const now = performance.now();
108
+ const progress = Math.min((now - this.startTime) / this.duration, 1);
109
+ this.onUpdate(this.easing(progress));
110
+
111
+ if (progress < 1) {
112
+ this.requestId = requestAnimationFrame(this.loop);
113
+ } else {
114
+ this.requestId = null;
115
+ this.onComplete?.();
116
+ }
117
+ };
118
+ }
119
+