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/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "radiant-charts-core",
3
+ "version": "0.2.0",
4
+ "description": "Open-source, MIT-licensed core charting library for React — canvas-rendered, high performance",
5
+ "license": "MIT",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.mjs",
8
+ "types": "./dist/index.d.ts",
9
+ "files": [
10
+ "dist",
11
+ "src",
12
+ "LICENSE.md"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsup",
16
+ "dev": "tsup --watch",
17
+ "test": "vitest run",
18
+ "test:watch": "vitest"
19
+ },
20
+ "peerDependencies": {
21
+ "react": "^18.0.0 || ^19.0.0",
22
+ "react-dom": "^18.0.0 || ^19.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/react": "^19.0.0",
26
+ "@types/react-dom": "^19.0.0",
27
+ "tsup": "^8.0.0",
28
+ "typescript": "^5.0.0",
29
+ "vitest": "^4.1.4"
30
+ }
31
+ }
@@ -0,0 +1,503 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Radiant Charts — Declarative JSX API
5
+ *
6
+ * Provides a composable, JSX-first interface that React developers expect.
7
+ * Children register their configuration with the parent <Chart> via context;
8
+ * the parent assembles a standard RadiantChartOptions object and renders a
9
+ * single imperative RadiantChart under the hood.
10
+ *
11
+ * @example
12
+ * ```tsx
13
+ * import { Chart, Bar, Line, XAxis, YAxis, Title, Legend } from 'radiant-charts/Declarative';
14
+ *
15
+ * const data = [
16
+ * { month: 'Jan', sales: 120, target: 100 },
17
+ * { month: 'Feb', sales: 150, target: 130 },
18
+ * { month: 'Mar', sales: 135, target: 140 },
19
+ * ];
20
+ *
21
+ * export default function Revenue() {
22
+ * return (
23
+ * <Chart data={data} theme="dark" height={400}>
24
+ * <Title text="Monthly Revenue" />
25
+ * <Legend position="bottom" />
26
+ * <XAxis fontSize={11} rotation={-45} />
27
+ * <YAxis show={false} />
28
+ * <Bar xKey="month" yKey="sales" fill="#4e79a7" cornerRadius={4} />
29
+ * <Line xKey="month" yKey="target" stroke="#e15759" />
30
+ * </Chart>
31
+ * );
32
+ * }
33
+ * ```
34
+ */
35
+
36
+ import React, {
37
+ createContext,
38
+ useContext,
39
+ useCallback,
40
+ useRef,
41
+ useEffect,
42
+ useMemo,
43
+ useState,
44
+ } from 'react';
45
+ import RadiantChart, {
46
+ RadiantChartOptions,
47
+ AnimationOptions,
48
+ DataLabelOptions,
49
+ RadiantChartHandle,
50
+ } from './RadiantChart';
51
+ import type { TooltipOptions } from './tooltip/types';
52
+
53
+ // ─── Internal Registration Types ──────────────────────────────────────────────
54
+
55
+ /** Every series child resolves to one of these registration entries. */
56
+ interface SeriesRegistration {
57
+ id: string;
58
+ /** The props as-is from the JSX component, minus React children. */
59
+ props: Record<string, any>;
60
+ }
61
+
62
+ interface AxisRegistration {
63
+ axis: 'x' | 'y' | 'y-right';
64
+ props: Record<string, any>;
65
+ }
66
+
67
+ interface TitleRegistration {
68
+ kind: 'title' | 'subtitle';
69
+ props: { text: string; fontSize?: number; fill?: string };
70
+ }
71
+
72
+ interface LegendRegistration {
73
+ enabled?: boolean;
74
+ position?: 'top' | 'bottom' | 'left' | 'right';
75
+ }
76
+
77
+ interface ChartContextValue {
78
+ registerSeries: (id: string, props: Record<string, any>) => void;
79
+ unregisterSeries: (id: string) => void;
80
+ registerAxis: (id: string, axis: AxisRegistration) => void;
81
+ unregisterAxis: (id: string) => void;
82
+ registerTitle: (id: string, reg: TitleRegistration) => void;
83
+ unregisterTitle: (id: string) => void;
84
+ registerLegend: (id: string, reg: LegendRegistration) => void;
85
+ unregisterLegend: (id: string) => void;
86
+ }
87
+
88
+ const ChartContext = createContext<ChartContextValue | null>(null);
89
+
90
+ function useChartContext(): ChartContextValue {
91
+ const ctx = useContext(ChartContext);
92
+ if (!ctx) throw new Error('Radiant declarative components must be children of <Chart>.');
93
+ return ctx;
94
+ }
95
+
96
+ // ─── ID Generator ─────────────────────────────────────────────────────────────
97
+
98
+ let _nextId = 0;
99
+ function useStableId(prefix: string): string {
100
+ const ref = useRef<string>('');
101
+ if (ref.current === '') ref.current = `${prefix}-${_nextId++}`;
102
+ return ref.current;
103
+ }
104
+
105
+ // ─── <Chart> ──────────────────────────────────────────────────────────────────
106
+
107
+ export interface ChartProps {
108
+ /** The data array shared by all series children. */
109
+ data: readonly any[];
110
+ /** Color theme. Accepts 'light', 'dark', or 'system'. */
111
+ theme?: 'light' | 'dark' | 'system';
112
+ /** Chart width. Number = px, string = CSS value. Default '100%'. */
113
+ width?: number | string;
114
+ /** Chart height. Number = px, string = CSS value. Default '100%'. */
115
+ height?: number | string;
116
+ /** Global animation options applied to all series. */
117
+ animation?: AnimationOptions;
118
+ /** Padding around the plot area. */
119
+ padding?: {
120
+ top?: number;
121
+ right?: number;
122
+ bottom?: number;
123
+ left?: number;
124
+ inner?: number;
125
+ };
126
+ /** Crosshair configuration. */
127
+ crosshair?: { x?: boolean; y?: boolean };
128
+ /** Tooltip configuration. */
129
+ tooltip?: TooltipOptions;
130
+ /** Show the export toolbar in the top-right corner. */
131
+ showExport?: boolean;
132
+ /** Additional class name applied to the container. */
133
+ className?: string;
134
+ /** Inline style applied to the container. */
135
+ style?: React.CSSProperties;
136
+ /** Access the imperative chart handle (e.g. exportToPng). */
137
+ chartRef?: React.Ref<RadiantChartHandle>;
138
+ /** Declarative series, axis, title, legend, and annotation children. */
139
+ children?: React.ReactNode;
140
+ }
141
+
142
+ export const Chart: React.FC<ChartProps> = ({
143
+ data,
144
+ theme,
145
+ width,
146
+ height,
147
+ animation,
148
+ padding,
149
+ crosshair,
150
+ tooltip,
151
+ showExport,
152
+ className,
153
+ style,
154
+ chartRef,
155
+ children,
156
+ }) => {
157
+ // ── Mutable registries (writes don't trigger re-render) ───────────────
158
+ const seriesMap = useRef(new Map<string, Record<string, any>>());
159
+ const axisMap = useRef(new Map<string, AxisRegistration>());
160
+ const titleMap = useRef(new Map<string, TitleRegistration>());
161
+ const legendMap = useRef(new Map<string, LegendRegistration>());
162
+ // Incremented to signal a rebuild of the options object.
163
+ const [rev, setRev] = useState(0);
164
+ const bump = useCallback(() => setRev(r => r + 1), []);
165
+
166
+ // ── Context value (stable across renders) ─────────────────────────────
167
+ const ctx = useMemo<ChartContextValue>(() => ({
168
+ registerSeries: (id, props) => { seriesMap.current.set(id, props); bump(); },
169
+ unregisterSeries: (id) => { seriesMap.current.delete(id); bump(); },
170
+ registerAxis: (id, a) => { axisMap.current.set(id, a); bump(); },
171
+ unregisterAxis: (id) => { axisMap.current.delete(id); bump(); },
172
+ registerTitle: (id, t) => { titleMap.current.set(id, t); bump(); },
173
+ unregisterTitle: (id) => { titleMap.current.delete(id); bump(); },
174
+ registerLegend: (id, l) => { legendMap.current.set(id, l); bump(); },
175
+ unregisterLegend: (id) => { legendMap.current.delete(id); bump(); },
176
+ }), [bump]);
177
+
178
+ // ── Build RadiantChartOptions from all registrations ──────────────────
179
+ const options = useMemo<RadiantChartOptions>(() => {
180
+ // --- Series ---
181
+ const series: any[] = [];
182
+ seriesMap.current.forEach(props => {
183
+ series.push(props);
184
+ });
185
+ // Guarantee at least one series entry so the engine doesn't error.
186
+ if (series.length === 0) {
187
+ series.push({ type: 'bar', xKey: 'x', yKey: 'y' });
188
+ }
189
+
190
+ // --- Axes ---
191
+ let showXAxis: boolean | undefined;
192
+ let showYAxis: boolean | undefined;
193
+ let showYAxisRight: boolean | undefined;
194
+ let xAxisFontSize: number | undefined;
195
+ let xAxisFontFamily: string | undefined;
196
+ let xAxisRotation: number | undefined;
197
+ let xAxisLabelAlignment: 'start' | 'center' | 'end' | undefined;
198
+ let yAxisFontSize: number | undefined;
199
+ let yAxisFontFamily: string | undefined;
200
+ let yAxisRotation: number | undefined;
201
+ let yAxisLabelAlignment: 'start' | 'center' | 'end' | undefined;
202
+ let yAxisRightFontSize: number | undefined;
203
+ let yAxisRightFontFamily: string | undefined;
204
+ let yAxisRightRotation: number | undefined;
205
+ let yAxisRightLabelAlignment: 'start' | 'center' | 'end' | undefined;
206
+
207
+ axisMap.current.forEach(({ axis, props }) => {
208
+ if (axis === 'x') {
209
+ showXAxis = props.show ?? true;
210
+ xAxisFontSize = props.fontSize;
211
+ xAxisFontFamily = props.fontFamily;
212
+ xAxisRotation = props.rotation;
213
+ xAxisLabelAlignment = props.labelAlignment;
214
+ } else if (axis === 'y') {
215
+ showYAxis = props.show ?? true;
216
+ yAxisFontSize = props.fontSize;
217
+ yAxisFontFamily = props.fontFamily;
218
+ yAxisRotation = props.rotation;
219
+ yAxisLabelAlignment = props.labelAlignment;
220
+ } else if (axis === 'y-right') {
221
+ showYAxisRight = props.show ?? true;
222
+ yAxisRightFontSize = props.fontSize;
223
+ yAxisRightFontFamily = props.fontFamily;
224
+ yAxisRightRotation = props.rotation;
225
+ yAxisRightLabelAlignment = props.labelAlignment;
226
+ }
227
+ });
228
+
229
+ // --- Title / Subtitle ---
230
+ let title: RadiantChartOptions['title'];
231
+ let subtitle: RadiantChartOptions['subtitle'];
232
+ titleMap.current.forEach(t => {
233
+ if (t.kind === 'title') title = t.props;
234
+ else subtitle = t.props;
235
+ });
236
+
237
+ // --- Legend ---
238
+ let legend: RadiantChartOptions['legend'];
239
+ legendMap.current.forEach(l => {
240
+ legend = { enabled: l.enabled ?? true, position: l.position };
241
+ });
242
+
243
+ // --- Tooltip ---
244
+ // Scanned directly from the JSX children tree rather than the registry,
245
+ // because these are lightweight config markers — no mount side-effects needed.
246
+ let tooltipFromChild: TooltipOptions | undefined;
247
+ React.Children.forEach(children, (child) => {
248
+ if (React.isValidElement(child) && child.type === Tooltip) {
249
+ tooltipFromChild = child.props as TooltipOptions;
250
+ }
251
+ });
252
+
253
+ return {
254
+ data,
255
+ theme,
256
+ animation,
257
+ padding,
258
+ crosshair,
259
+ tooltip: tooltipFromChild ? { ...tooltip, ...tooltipFromChild } : tooltip,
260
+ toolbar: showExport ? { showExport: true } : undefined,
261
+ title,
262
+ subtitle,
263
+ legend,
264
+ showXAxis,
265
+ showYAxis,
266
+ showYAxisRight,
267
+ xAxisFontSize,
268
+ xAxisFontFamily,
269
+ xAxisRotation,
270
+ xAxisLabelAlignment,
271
+ yAxisFontSize,
272
+ yAxisFontFamily,
273
+ yAxisRotation,
274
+ yAxisLabelAlignment,
275
+ yAxisRightFontSize,
276
+ yAxisRightFontFamily,
277
+ yAxisRightRotation,
278
+ yAxisRightLabelAlignment,
279
+ series,
280
+ };
281
+ // rev is the rebuild signal — it isn't used in the body but must be in
282
+ // the dependency array so useMemo re-runs when children register/unregister.
283
+ // children is included so Tooltip prop changes are picked up immediately.
284
+ // eslint-disable-next-line react-hooks/exhaustive-deps
285
+ }, [data, theme, animation, padding, crosshair, tooltip, showExport, children, rev]);
286
+
287
+ return (
288
+ <ChartContext.Provider value={ctx}>
289
+ {/* Render children so their effects fire and register with ctx */}
290
+ {children}
291
+ <RadiantChart
292
+ ref={chartRef}
293
+ options={options}
294
+ width={width}
295
+ height={height}
296
+ className={className}
297
+ style={style}
298
+ />
299
+ </ChartContext.Provider>
300
+ );
301
+ };
302
+
303
+ // ─── Series Components ────────────────────────────────────────────────────────
304
+ //
305
+ // Each component is a "config-only" node: it renders nothing visible.
306
+ // On mount it registers its props with the parent <Chart> context;
307
+ // on unmount it unregisters. Prop changes trigger a re-registration.
308
+
309
+ /** Shared props accepted by every series component. */
310
+ interface CommonSeriesProps {
311
+ xKey?: string;
312
+ yKey?: string;
313
+ fill?: string;
314
+ stroke?: string;
315
+ strokeWidth?: number;
316
+ fillOpacity?: number;
317
+ cornerRadius?: number;
318
+ title?: string;
319
+ grouped?: boolean;
320
+ stacked?: boolean;
321
+ normalized?: boolean;
322
+ direction?: 'vertical' | 'horizontal';
323
+ interpolation?: 'linear' | 'step' | 'smooth';
324
+ marker?: { enabled?: boolean; size?: number; fill?: string };
325
+ labels?: DataLabelOptions;
326
+ animation?: AnimationOptions;
327
+ yAxisId?: 'left' | 'right';
328
+ /** Any additional props are forwarded verbatim to the engine. */
329
+ [key: string]: any;
330
+ }
331
+
332
+ function createSeriesComponent(seriesType: string, displayName: string) {
333
+ const Component: React.FC<CommonSeriesProps> = (props) => {
334
+ const id = useStableId(seriesType);
335
+ const ctx = useChartContext();
336
+
337
+ // Build the full props blob including `type`.
338
+ const blob = useMemo(
339
+ () => ({ type: seriesType, ...props }),
340
+ // Fast-path: serialise props to catch deep changes without a manual dep list.
341
+ // eslint-disable-next-line react-hooks/exhaustive-deps
342
+ [JSON.stringify(props)],
343
+ );
344
+
345
+ useEffect(() => {
346
+ ctx.registerSeries(id, blob);
347
+ return () => ctx.unregisterSeries(id);
348
+ }, [ctx, id, blob]);
349
+
350
+ return null; // Config-only — no DOM output
351
+ };
352
+ Component.displayName = displayName;
353
+ return Component;
354
+ }
355
+
356
+ // ── Primary series ────────────────────────────────────────────────────────────
357
+
358
+ export const Bar = createSeriesComponent('bar', 'Bar');
359
+ export const Line = createSeriesComponent('line', 'Line');
360
+ export const Area = createSeriesComponent('area', 'Area');
361
+ export const Scatter = createSeriesComponent('scatter', 'Scatter');
362
+ export const Pie = createSeriesComponent('pie', 'Pie');
363
+ export const Donut = createSeriesComponent('donut', 'Donut');
364
+
365
+
366
+ // ─── Axis Components ──────────────────────────────────────────────────────────
367
+
368
+ export interface AxisProps {
369
+ /** Show or hide this axis. Default: true. */
370
+ show?: boolean;
371
+ /** Font size for axis labels in px. */
372
+ fontSize?: number;
373
+ /** Font family for axis labels. */
374
+ fontFamily?: string;
375
+ /** Label rotation in degrees. */
376
+ rotation?: number;
377
+ /** Label alignment. */
378
+ labelAlignment?: 'start' | 'center' | 'end';
379
+ }
380
+
381
+ export const XAxis: React.FC<AxisProps> = (props) => {
382
+ const id = useStableId('x-axis');
383
+ const ctx = useChartContext();
384
+ const blob = useMemo(() => ({ axis: 'x' as const, props }), [JSON.stringify(props)]); // eslint-disable-line react-hooks/exhaustive-deps
385
+ useEffect(() => {
386
+ ctx.registerAxis(id, blob);
387
+ return () => ctx.unregisterAxis(id);
388
+ }, [ctx, id, blob]);
389
+ return null;
390
+ };
391
+ XAxis.displayName = 'XAxis';
392
+
393
+ export const YAxis: React.FC<AxisProps> = (props) => {
394
+ const id = useStableId('y-axis');
395
+ const ctx = useChartContext();
396
+ const blob = useMemo(() => ({ axis: 'y' as const, props }), [JSON.stringify(props)]); // eslint-disable-line react-hooks/exhaustive-deps
397
+ useEffect(() => {
398
+ ctx.registerAxis(id, blob);
399
+ return () => ctx.unregisterAxis(id);
400
+ }, [ctx, id, blob]);
401
+ return null;
402
+ };
403
+ YAxis.displayName = 'YAxis';
404
+
405
+ export const YAxisRight: React.FC<AxisProps> = (props) => {
406
+ const id = useStableId('y-axis-right');
407
+ const ctx = useChartContext();
408
+ const blob = useMemo(() => ({ axis: 'y-right' as const, props }), [JSON.stringify(props)]); // eslint-disable-line react-hooks/exhaustive-deps
409
+ useEffect(() => {
410
+ ctx.registerAxis(id, blob);
411
+ return () => ctx.unregisterAxis(id);
412
+ }, [ctx, id, blob]);
413
+ return null;
414
+ };
415
+ YAxisRight.displayName = 'YAxisRight';
416
+
417
+ // ─── Title & Subtitle ─────────────────────────────────────────────────────────
418
+
419
+ export interface TitleProps {
420
+ text: string;
421
+ fontSize?: number;
422
+ fill?: string;
423
+ }
424
+
425
+ export const Title: React.FC<TitleProps> = (props) => {
426
+ const id = useStableId('title');
427
+ const ctx = useChartContext();
428
+ const blob = useMemo(
429
+ () => ({ kind: 'title' as const, props }),
430
+ [JSON.stringify(props)], // eslint-disable-line react-hooks/exhaustive-deps
431
+ );
432
+ useEffect(() => {
433
+ ctx.registerTitle(id, blob);
434
+ return () => ctx.unregisterTitle(id);
435
+ }, [ctx, id, blob]);
436
+ return null;
437
+ };
438
+ Title.displayName = 'Title';
439
+
440
+ export const Subtitle: React.FC<TitleProps> = (props) => {
441
+ const id = useStableId('subtitle');
442
+ const ctx = useChartContext();
443
+ const blob = useMemo(
444
+ () => ({ kind: 'subtitle' as const, props }),
445
+ [JSON.stringify(props)], // eslint-disable-line react-hooks/exhaustive-deps
446
+ );
447
+ useEffect(() => {
448
+ ctx.registerTitle(id, blob);
449
+ return () => ctx.unregisterTitle(id);
450
+ }, [ctx, id, blob]);
451
+ return null;
452
+ };
453
+ Subtitle.displayName = 'Subtitle';
454
+
455
+ // ─── Legend ───────────────────────────────────────────────────────────────────
456
+
457
+ export interface LegendProps {
458
+ /** Show or hide the legend. Default: true. */
459
+ enabled?: boolean;
460
+ /** Legend placement. */
461
+ position?: 'top' | 'bottom' | 'left' | 'right';
462
+ }
463
+
464
+ export const Legend: React.FC<LegendProps> = (props) => {
465
+ const id = useStableId('legend');
466
+ const ctx = useChartContext();
467
+ const blob = useMemo(
468
+ () => ({ enabled: props.enabled, position: props.position }),
469
+ [props.enabled, props.position],
470
+ );
471
+ useEffect(() => {
472
+ ctx.registerLegend(id, blob);
473
+ return () => ctx.unregisterLegend(id);
474
+ }, [ctx, id, blob]);
475
+ return null;
476
+ };
477
+ Legend.displayName = 'Legend';
478
+
479
+ // ─── Tooltip (declarative config-only component) ─────────────────────────────
480
+
481
+ export interface TooltipProps extends TooltipOptions {}
482
+
483
+ /**
484
+ * Configure the tooltip system declaratively as a child of `<Chart>`.
485
+ * This is a config-only component — it renders nothing.
486
+ * Props are passed through to the chart's tooltip options.
487
+ *
488
+ * @example
489
+ * <Chart data={data}>
490
+ * <Tooltip
491
+ * mode="shared"
492
+ * snap
493
+ * sticky
494
+ * bulletShape="circle"
495
+ * bodyTemplate="Revenue: ${revenue}"
496
+ * footerTemplate="Source: ${source}"
497
+ * style={{ background: '#1a1a2e', borderRadius: 12 }}
498
+ * />
499
+ * <Bar xKey="month" yKey="sales" />
500
+ * </Chart>
501
+ */
502
+ export const Tooltip: React.FC<TooltipProps> = () => null;
503
+ Tooltip.displayName = 'Tooltip';