@faintshadow/flarecharts 26.3.1

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.
Files changed (85) hide show
  1. package/LICENSE +40 -0
  2. package/README.md +103 -0
  3. package/dist/charts/AreaChart.svelte +150 -0
  4. package/dist/charts/AreaChart.svelte.d.ts +60 -0
  5. package/dist/charts/BarChart.svelte +142 -0
  6. package/dist/charts/BarChart.svelte.d.ts +58 -0
  7. package/dist/charts/BoxPlotChart.svelte +138 -0
  8. package/dist/charts/BoxPlotChart.svelte.d.ts +56 -0
  9. package/dist/charts/DonutChart.svelte +129 -0
  10. package/dist/charts/DonutChart.svelte.d.ts +73 -0
  11. package/dist/charts/LineChart.svelte +149 -0
  12. package/dist/charts/LineChart.svelte.d.ts +63 -0
  13. package/dist/charts/Sparkline.svelte +87 -0
  14. package/dist/charts/Sparkline.svelte.d.ts +40 -0
  15. package/dist/charts/StackChart.svelte +157 -0
  16. package/dist/charts/StackChart.svelte.d.ts +69 -0
  17. package/dist/components/Arc.svelte +202 -0
  18. package/dist/components/Arc.svelte.d.ts +50 -0
  19. package/dist/components/Area.svelte +264 -0
  20. package/dist/components/Area.svelte.d.ts +54 -0
  21. package/dist/components/Axis.svelte +139 -0
  22. package/dist/components/Axis.svelte.d.ts +26 -0
  23. package/dist/components/Bars.svelte +192 -0
  24. package/dist/components/Bars.svelte.d.ts +55 -0
  25. package/dist/components/Box.svelte +287 -0
  26. package/dist/components/Box.svelte.d.ts +48 -0
  27. package/dist/components/Chart.svelte +207 -0
  28. package/dist/components/Chart.svelte.d.ts +23 -0
  29. package/dist/components/Crosshair.svelte +67 -0
  30. package/dist/components/Crosshair.svelte.d.ts +14 -0
  31. package/dist/components/Grid.svelte +38 -0
  32. package/dist/components/Grid.svelte.d.ts +14 -0
  33. package/dist/components/Labels.svelte +61 -0
  34. package/dist/components/Labels.svelte.d.ts +35 -0
  35. package/dist/components/Legend.svelte +81 -0
  36. package/dist/components/Legend.svelte.d.ts +12 -0
  37. package/dist/components/Line.svelte +192 -0
  38. package/dist/components/Line.svelte.d.ts +47 -0
  39. package/dist/components/PlotBand.svelte +68 -0
  40. package/dist/components/PlotBand.svelte.d.ts +14 -0
  41. package/dist/components/PlotLine.svelte +54 -0
  42. package/dist/components/PlotLine.svelte.d.ts +16 -0
  43. package/dist/components/Points.svelte +179 -0
  44. package/dist/components/Points.svelte.d.ts +53 -0
  45. package/dist/components/Svg.svelte +36 -0
  46. package/dist/components/Svg.svelte.d.ts +8 -0
  47. package/dist/components/Tooltip.svelte +211 -0
  48. package/dist/components/Tooltip.svelte.d.ts +44 -0
  49. package/dist/core/bisect.d.ts +5 -0
  50. package/dist/core/bisect.js +23 -0
  51. package/dist/core/context.svelte.d.ts +140 -0
  52. package/dist/core/context.svelte.js +294 -0
  53. package/dist/core/curves.d.ts +4 -0
  54. package/dist/core/curves.js +13 -0
  55. package/dist/core/hit.d.ts +34 -0
  56. package/dist/core/hit.js +43 -0
  57. package/dist/core/keynav.d.ts +20 -0
  58. package/dist/core/keynav.js +41 -0
  59. package/dist/core/labels.d.ts +39 -0
  60. package/dist/core/labels.js +27 -0
  61. package/dist/core/merge.d.ts +17 -0
  62. package/dist/core/merge.js +46 -0
  63. package/dist/core/motion.svelte.d.ts +31 -0
  64. package/dist/core/motion.svelte.js +129 -0
  65. package/dist/core/normalize.d.ts +35 -0
  66. package/dist/core/normalize.js +97 -0
  67. package/dist/core/options.d.ts +113 -0
  68. package/dist/core/options.js +36 -0
  69. package/dist/core/palette.d.ts +8 -0
  70. package/dist/core/palette.js +24 -0
  71. package/dist/core/responsive.d.ts +6 -0
  72. package/dist/core/responsive.js +19 -0
  73. package/dist/core/scales.d.ts +31 -0
  74. package/dist/core/scales.js +89 -0
  75. package/dist/core/stack.d.ts +19 -0
  76. package/dist/core/stack.js +133 -0
  77. package/dist/core/stats.d.ts +45 -0
  78. package/dist/core/stats.js +114 -0
  79. package/dist/core/symbols.d.ts +8 -0
  80. package/dist/core/symbols.js +31 -0
  81. package/dist/core/types.d.ts +28 -0
  82. package/dist/core/types.js +1 -0
  83. package/dist/index.d.ts +52 -0
  84. package/dist/index.js +42 -0
  85. package/package.json +81 -0
@@ -0,0 +1,55 @@
1
+ import type { NormalizedPoint, XValue } from '../core/normalize.js';
2
+ import type { StackOffset, StackOrder } from '../core/stack.js';
3
+ declare function $$render<T>(): {
4
+ props: {
5
+ data: readonly T[];
6
+ x?: (datum: T, index: number) => XValue;
7
+ y?: (datum: T, index: number) => number | null | undefined;
8
+ color?: string;
9
+ /** Per-point color override (wins over color/palette) — end of the precedence chain. */
10
+ colorFor?: (datum: T, index: number) => string | undefined;
11
+ name?: string;
12
+ /** Accessible series description (announced when keyboard focus enters the series). */
13
+ description?: string;
14
+ /** Per-point announcement override for keyboard navigation. */
15
+ describePoint?: (point: NormalizedPoint<T>) => string | undefined;
16
+ /**
17
+ * Off (default) → side-by-side grouping with other bar series.
18
+ * 'normal' → stack values; 'percent' → stack normalized to ±100.
19
+ * (stream/silhouette also accepted but are intended for areas.)
20
+ */
21
+ stacking?: StackOffset;
22
+ /** Layer ordering within the stack group. */
23
+ order?: StackOrder;
24
+ /** Stack group id — series sharing it stack together (default one shared group). */
25
+ stack?: string;
26
+ /** Fraction of the slot width trimmed from EACH side of a bar (0–0.5). */
27
+ barPadding?: number;
28
+ /** Corner radius. */
29
+ rx?: number;
30
+ /** Override the palette slot. */
31
+ index?: number;
32
+ class?: string;
33
+ };
34
+ exports: {};
35
+ bindings: "";
36
+ slots: {};
37
+ events: {};
38
+ };
39
+ declare class __sveltets_Render<T> {
40
+ props(): ReturnType<typeof $$render<T>>['props'];
41
+ events(): ReturnType<typeof $$render<T>>['events'];
42
+ slots(): ReturnType<typeof $$render<T>>['slots'];
43
+ bindings(): "";
44
+ exports(): {};
45
+ }
46
+ interface $$IsomorphicComponent {
47
+ new <T>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
48
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
49
+ } & ReturnType<__sveltets_Render<T>['exports']>;
50
+ <T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
51
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
52
+ }
53
+ declare const Bars: $$IsomorphicComponent;
54
+ type Bars<T> = InstanceType<typeof Bars<T>>;
55
+ export default Bars;
@@ -0,0 +1,287 @@
1
+ <script lang="ts" generics="T">
2
+ import { onDestroy, onMount } from 'svelte';
3
+ import { Tween } from 'svelte/motion';
4
+ import { cubicOut } from 'svelte/easing';
5
+ import { getChartContext } from '../core/context.svelte.js';
6
+ import type { NormalizedPoint, XValue } from '../core/normalize.js';
7
+ import { isBandScale } from '../core/scales.js';
8
+ import { resolveBoxStats } from '../core/stats.js';
9
+ import type { BoxStats, WhiskerMode } from '../core/stats.js';
10
+ import { boxEnterPhases, motionDuration } from '../core/motion.svelte.js';
11
+
12
+ interface Props {
13
+ data: readonly T[];
14
+ x?: (datum: T, index: number) => XValue;
15
+ low?: (datum: T, index: number) => number | null | undefined;
16
+ q1?: (datum: T, index: number) => number | null | undefined;
17
+ median?: (datum: T, index: number) => number | null | undefined;
18
+ q3?: (datum: T, index: number) => number | null | undefined;
19
+ high?: (datum: T, index: number) => number | null | undefined;
20
+ outliers?: (datum: T, index: number) => readonly number[] | undefined;
21
+ /** Raw samples — the lib computes the five-number summary (wins over the precomputed accessors). */
22
+ samples?: (datum: T, index: number) => readonly number[] | undefined;
23
+ whisker?: WhiskerMode;
24
+ whiskerK?: number;
25
+ color?: string;
26
+ colorFor?: (datum: T, index: number) => string | undefined;
27
+ name?: string;
28
+ description?: string;
29
+ describePoint?: (point: NormalizedPoint<T>) => string | undefined;
30
+ /** Fraction of the slot width trimmed from EACH side (0–0.5). */
31
+ boxPadding?: number;
32
+ index?: number;
33
+ class?: string;
34
+ }
35
+
36
+ let {
37
+ data,
38
+ x,
39
+ low,
40
+ q1,
41
+ median,
42
+ q3,
43
+ high,
44
+ outliers,
45
+ samples,
46
+ whisker = 'tukey',
47
+ whiskerK = 1.5,
48
+ color,
49
+ colorFor,
50
+ name,
51
+ description,
52
+ describePoint,
53
+ boxPadding = 0.15,
54
+ index: indexProp,
55
+ class: klass = ''
56
+ }: Props = $props();
57
+
58
+ const ctx = getChartContext();
59
+
60
+ function asX(v: unknown, fallback: number): XValue {
61
+ if (typeof v === 'number' || typeof v === 'string' || v instanceof Date) return v;
62
+ return fallback;
63
+ }
64
+
65
+ function boxX(datum: T, i: number): XValue {
66
+ if (x) return asX(x(datum, i), i);
67
+ if (Array.isArray(datum) && datum.length >= 6) return asX(datum[0], i);
68
+ if (datum && typeof datum === 'object' && !Array.isArray(datum) && 'x' in datum) {
69
+ return asX((datum as { x: unknown }).x, i);
70
+ }
71
+ return i;
72
+ }
73
+
74
+ interface ResolvedBox {
75
+ x: XValue;
76
+ stats: BoxStats;
77
+ point: NormalizedPoint<T>;
78
+ }
79
+
80
+ const boxes = $derived.by((): ResolvedBox[] => {
81
+ const accessors = { low, q1, median, q3, high, outliers, samples };
82
+ const out: ResolvedBox[] = [];
83
+ for (let i = 0; i < data.length; i++) {
84
+ const datum = data[i];
85
+ const stats = resolveBoxStats(datum, i, accessors, { whisker, k: whiskerK });
86
+ if (!stats) continue;
87
+ const xv = boxX(datum, i);
88
+ out.push({ x: xv, stats, point: { x: xv, y: stats.median, datum, index: i } });
89
+ }
90
+ return out;
91
+ });
92
+
93
+ const points = $derived(boxes.map((b) => b.point));
94
+ const yExtents = $derived(
95
+ boxes.map((b): [number, number] => {
96
+ const all = [b.stats.low, b.stats.high, ...b.stats.outliers];
97
+ return [Math.min(...all), Math.max(...all)];
98
+ })
99
+ );
100
+ const statsByIndex = $derived(new Map(boxes.map((b) => [b.point.index, b.stats])));
101
+
102
+ const round = (n: number) => Math.round(n * 100) / 100;
103
+ const defaultDescribePoint = (p: NormalizedPoint<unknown>): string | undefined => {
104
+ const s = statsByIndex.get(p.index);
105
+ if (!s) return undefined;
106
+ const n = s.outliers.length;
107
+ return `${String(p.x)}: median ${round(s.median)}, IQR ${round(s.q1)}–${round(s.q3)}, range ${round(s.low)}–${round(s.high)}${n ? `, ${n} outlier${n > 1 ? 's' : ''}` : ''}`;
108
+ };
109
+
110
+ const registration = ctx.registerSeries(() => ({
111
+ points,
112
+ name,
113
+ color: resolvedColor,
114
+ isBox: true,
115
+ yExtents,
116
+ description,
117
+ describePoint: (describePoint ?? defaultDescribePoint) as
118
+ | ((p: NormalizedPoint<unknown>) => string | undefined)
119
+ | undefined
120
+ }));
121
+ onDestroy(registration.unregister);
122
+
123
+ const seriesIndex = $derived(indexProp ?? registration.index);
124
+ const resolvedColor = $derived(ctx.seriesColor(seriesIndex, color));
125
+ const visible = $derived(ctx.isSeriesVisible(registration.index));
126
+
127
+ function continuousStep(centers: number[]): number {
128
+ if (centers.length < 2) return 24;
129
+ const sorted = [...centers].sort((a, b) => a - b);
130
+ let minDiff = Infinity;
131
+ for (let i = 1; i < sorted.length; i++) {
132
+ const diff = sorted[i] - sorted[i - 1];
133
+ if (diff > 0 && diff < minDiff) minDiff = diff;
134
+ }
135
+ return Number.isFinite(minDiff) ? minDiff * 0.8 : 24;
136
+ }
137
+
138
+ interface BoxGeom {
139
+ rectX: number;
140
+ rectW: number;
141
+ cx: number;
142
+ capX1: number;
143
+ capX2: number;
144
+ yMedian: number;
145
+ yLow: number;
146
+ yHigh: number;
147
+ yQ1: number;
148
+ yQ3: number;
149
+ outliers: number[];
150
+ point: NormalizedPoint<T>;
151
+ color: string;
152
+ }
153
+
154
+ const geoms = $derived.by((): BoxGeom[] => {
155
+ if (!visible) return [];
156
+ const slot = ctx.barSlots.bySeries.get(registration.index);
157
+ if (slot === undefined) return [];
158
+ const slotCount = ctx.barSlots.count;
159
+ const xScale = ctx.xScale;
160
+ const band = isBandScale(xScale);
161
+ const step = band ? 0 : continuousStep(boxes.map((b) => ctx.xPos(b.x)).filter(Number.isFinite));
162
+
163
+ const out: BoxGeom[] = [];
164
+ for (const b of boxes) {
165
+ let groupStart: number;
166
+ let groupWidth: number;
167
+ if (band) {
168
+ const start = xScale(String(b.x));
169
+ if (start === undefined) continue;
170
+ groupStart = start;
171
+ groupWidth = xScale.bandwidth();
172
+ } else {
173
+ const center = ctx.xPos(b.x);
174
+ if (!Number.isFinite(center)) continue;
175
+ groupWidth = step;
176
+ groupStart = center - groupWidth / 2;
177
+ }
178
+ const slotWidth = groupWidth / slotCount;
179
+ const inset = slotWidth * Math.max(0, Math.min(0.5, boxPadding));
180
+ const rectX = groupStart + slot * slotWidth + inset;
181
+ const rectW = Math.max(0, slotWidth - 2 * inset);
182
+ const cx = rectX + rectW / 2;
183
+
184
+ out.push({
185
+ rectX,
186
+ rectW,
187
+ cx,
188
+ capX1: cx - rectW / 4,
189
+ capX2: cx + rectW / 4,
190
+ yMedian: ctx.yPos(b.stats.median),
191
+ yLow: ctx.yPos(b.stats.low),
192
+ yHigh: ctx.yPos(b.stats.high),
193
+ yQ1: ctx.yPos(b.stats.q1),
194
+ yQ3: ctx.yPos(b.stats.q3),
195
+ outliers: b.stats.outliers,
196
+ point: b.point,
197
+ color: colorFor?.(b.point.datum, b.point.index) ?? resolvedColor
198
+ });
199
+ }
200
+ return out;
201
+ });
202
+
203
+ // Staged entrance: progress 0→1 on mount drives the phase factors.
204
+ let rootEl: SVGGElement | undefined = $state();
205
+ let dur = $state(0);
206
+ const progress = new Tween(1, { duration: () => dur, easing: cubicOut });
207
+ onMount(() => {
208
+ dur = motionDuration(rootEl);
209
+ if (dur > 0) {
210
+ progress.set(0, { duration: 0 });
211
+ progress.set(1);
212
+ }
213
+ });
214
+ const phase = $derived(boxEnterPhases(progress.current));
215
+ </script>
216
+
217
+ {#if visible}
218
+ <g class="fc-series fc-box {klass}" data-name={name} bind:this={rootEl}>
219
+ {#each geoms as g (g.point.index)}
220
+ {@const m = g.yMedian}
221
+ {@const q1 = m + (g.yQ1 - m) * phase.grow}
222
+ {@const q3 = m + (g.yQ3 - m) * phase.grow}
223
+ <g class="fc-box-group" style="--fc-box-series-color: {g.color}">
224
+ <line class="fc-box-whisker" x1={g.cx} x2={g.cx} y1={q3} y2={g.yHigh} opacity={phase.frame} />
225
+ <line class="fc-box-whisker" x1={g.cx} x2={g.cx} y1={q1} y2={g.yLow} opacity={phase.frame} />
226
+ <line class="fc-box-cap" x1={g.capX1} x2={g.capX2} y1={g.yHigh} y2={g.yHigh} opacity={phase.frame} />
227
+ <line class="fc-box-cap" x1={g.capX1} x2={g.capX2} y1={g.yLow} y2={g.yLow} opacity={phase.frame} />
228
+ <rect
229
+ class="fc-box-rect"
230
+ x={g.rectX}
231
+ y={Math.min(q1, q3)}
232
+ width={g.rectW}
233
+ height={Math.abs(q1 - q3)}
234
+ opacity={phase.frame}
235
+ />
236
+ <line
237
+ class="fc-box-median"
238
+ x1={g.rectX}
239
+ x2={g.rectX + g.rectW}
240
+ y1={m}
241
+ y2={m}
242
+ opacity={phase.median}
243
+ />
244
+ {#each g.outliers as o, oi (oi)}
245
+ <circle class="fc-box-outlier" cx={g.cx} cy={ctx.yPos(o)} r={2} opacity={phase.median} />
246
+ {/each}
247
+ </g>
248
+ {/each}
249
+ </g>
250
+ {/if}
251
+
252
+ <style>
253
+ .fc-box-rect {
254
+ fill: var(--fc-box-fill, var(--fc-box-series-color));
255
+ fill-opacity: var(--fc-box-fill-opacity, 0.4);
256
+ stroke: var(--fc-box-stroke, var(--fc-box-series-color));
257
+ stroke-width: 1.5;
258
+ }
259
+ .fc-box-median {
260
+ stroke: var(--fc-box-median, var(--fc-box-series-color));
261
+ stroke-width: 2;
262
+ }
263
+ .fc-box-whisker {
264
+ stroke: var(--fc-box-whisker, var(--fc-box-stroke, var(--fc-box-series-color)));
265
+ stroke-width: 1.5;
266
+ }
267
+ .fc-box-cap {
268
+ stroke: var(--fc-box-cap, var(--fc-box-stroke, var(--fc-box-series-color)));
269
+ stroke-width: 1.5;
270
+ }
271
+ .fc-box-outlier {
272
+ fill: none;
273
+ stroke: var(--fc-box-outlier, var(--fc-box-stroke, var(--fc-box-series-color)));
274
+ stroke-width: 1;
275
+ }
276
+ .fc-box-group {
277
+ transition: opacity var(--fc-duration-hover, 120ms) var(--fc-ease, ease);
278
+ }
279
+ :global(.fc-svg:has(.fc-series:hover)) .fc-series:not(:hover) .fc-box-group {
280
+ opacity: 0.4;
281
+ }
282
+ @media (prefers-reduced-motion: reduce) {
283
+ .fc-box-group {
284
+ transition: none;
285
+ }
286
+ }
287
+ </style>
@@ -0,0 +1,48 @@
1
+ import type { NormalizedPoint, XValue } from '../core/normalize.js';
2
+ import type { WhiskerMode } from '../core/stats.js';
3
+ declare function $$render<T>(): {
4
+ props: {
5
+ data: readonly T[];
6
+ x?: (datum: T, index: number) => XValue;
7
+ low?: (datum: T, index: number) => number | null | undefined;
8
+ q1?: (datum: T, index: number) => number | null | undefined;
9
+ median?: (datum: T, index: number) => number | null | undefined;
10
+ q3?: (datum: T, index: number) => number | null | undefined;
11
+ high?: (datum: T, index: number) => number | null | undefined;
12
+ outliers?: (datum: T, index: number) => readonly number[] | undefined;
13
+ /** Raw samples — the lib computes the five-number summary (wins over the precomputed accessors). */
14
+ samples?: (datum: T, index: number) => readonly number[] | undefined;
15
+ whisker?: WhiskerMode;
16
+ whiskerK?: number;
17
+ color?: string;
18
+ colorFor?: (datum: T, index: number) => string | undefined;
19
+ name?: string;
20
+ description?: string;
21
+ describePoint?: (point: NormalizedPoint<T>) => string | undefined;
22
+ /** Fraction of the slot width trimmed from EACH side (0–0.5). */
23
+ boxPadding?: number;
24
+ index?: number;
25
+ class?: string;
26
+ };
27
+ exports: {};
28
+ bindings: "";
29
+ slots: {};
30
+ events: {};
31
+ };
32
+ declare class __sveltets_Render<T> {
33
+ props(): ReturnType<typeof $$render<T>>['props'];
34
+ events(): ReturnType<typeof $$render<T>>['events'];
35
+ slots(): ReturnType<typeof $$render<T>>['slots'];
36
+ bindings(): "";
37
+ exports(): {};
38
+ }
39
+ interface $$IsomorphicComponent {
40
+ new <T>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
41
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
42
+ } & ReturnType<__sveltets_Render<T>['exports']>;
43
+ <T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
44
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
45
+ }
46
+ declare const Box: $$IsomorphicComponent;
47
+ type Box<T> = InstanceType<typeof Box<T>>;
48
+ export default Box;
@@ -0,0 +1,207 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { ChartContext, setChartContext } from '../core/context.svelte.js';
4
+ import type { AxisOptions, Padding } from '../core/types.js';
5
+ import type { XValue } from '../core/normalize.js';
6
+ import { stackKey } from '../core/stack.js';
7
+
8
+ interface Props {
9
+ /** X axis options (type inferred from data when omitted). */
10
+ x?: AxisOptions;
11
+ /** Y axis options. */
12
+ y?: AxisOptions;
13
+ /** Space reserved around the plot area for axes/labels. */
14
+ padding?: Padding;
15
+ /** Accessible name for the chart. */
16
+ label?: string;
17
+ /** Longer accessible description (rendered for screen readers). */
18
+ description?: string;
19
+ /** Screen-reader data table fallback (opt-out). */
20
+ dataTable?: boolean;
21
+ /** Arrow-key point navigation (opt-out). */
22
+ keyboard?: boolean;
23
+ class?: string;
24
+ children?: Snippet;
25
+ }
26
+
27
+ let {
28
+ x,
29
+ y,
30
+ padding,
31
+ label = 'Chart',
32
+ description,
33
+ dataTable = true,
34
+ keyboard = true,
35
+ class: klass = '',
36
+ children
37
+ }: Props = $props();
38
+
39
+ // One context instance for the chart's lifetime; reactivity lives inside it.
40
+ // The closure keeps options reactive without ever replacing the context.
41
+ const ctx = setChartContext(new ChartContext(() => ({ x, y, padding })));
42
+
43
+ // Pointer position in plot-area coordinates; null outside the plot.
44
+ // Tooltip/Crosshair derive everything from ctx.hoverPointer.
45
+ function handlePointerMove(event: PointerEvent) {
46
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
47
+ const px = event.clientX - rect.left - ctx.padding.left;
48
+ const py = event.clientY - rect.top - ctx.padding.top;
49
+ ctx.pointer =
50
+ px >= 0 && px <= ctx.innerWidth && py >= 0 && py <= ctx.innerHeight
51
+ ? { x: px, y: py }
52
+ : null;
53
+ }
54
+
55
+ function clearPointer() {
56
+ ctx.pointer = null;
57
+ }
58
+
59
+ // --- Keyboard navigation + announcements -------------------------------
60
+
61
+ let announcement = $state('');
62
+ let lastAnnouncedSeries = -1;
63
+
64
+ function formatXText(value: XValue): string {
65
+ return value instanceof Date
66
+ ? value.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
67
+ : String(value);
68
+ }
69
+
70
+ const KEY_MOVES: Record<string, [number, number]> = {
71
+ ArrowRight: [1, 0],
72
+ ArrowLeft: [-1, 0],
73
+ ArrowDown: [0, 1],
74
+ ArrowUp: [0, -1],
75
+ Home: [Number.NEGATIVE_INFINITY, 0],
76
+ End: [Number.POSITIVE_INFINITY, 0]
77
+ };
78
+
79
+ function handleKeydown(event: KeyboardEvent) {
80
+ if (event.key === 'Escape') {
81
+ ctx.clearFocus();
82
+ announcement = '';
83
+ return;
84
+ }
85
+ const move = KEY_MOVES[event.key];
86
+ if (!move) return;
87
+ event.preventDefault();
88
+ const focused = ctx.moveFocusBy(move[0], move[1]);
89
+ if (!focused) return;
90
+ const { entry, point } = focused;
91
+ const custom = entry.describePoint?.(point);
92
+ const seriesName = entry.name ?? `Series ${entry.index + 1}`;
93
+ let text = custom ?? `${seriesName}, ${formatXText(point.x)}: ${point.y}`;
94
+ if (entry.index !== lastAnnouncedSeries && entry.description) {
95
+ text = `${entry.description}. ${text}`;
96
+ }
97
+ lastAnnouncedSeries = entry.index;
98
+ announcement = text;
99
+ }
100
+
101
+ function handleNavBlur() {
102
+ ctx.clearFocus();
103
+ lastAnnouncedSeries = -1;
104
+ announcement = '';
105
+ }
106
+
107
+ // --- Screen-reader data table -------------------------------------------
108
+
109
+ const tableData = $derived.by(() => {
110
+ if (!dataTable) return null;
111
+ const visible = ctx.seriesEntries.filter((e) => e.visible && e.points.length > 0);
112
+ if (visible.length === 0) return null;
113
+ const xKeys: { key: string; label: string }[] = [];
114
+ const seen = new Set<string>();
115
+ for (const entry of visible) {
116
+ for (const p of entry.points) {
117
+ const key = stackKey(p.x);
118
+ if (!seen.has(key)) {
119
+ seen.add(key);
120
+ xKeys.push({ key, label: formatXText(p.x) });
121
+ }
122
+ }
123
+ }
124
+ const lookup = visible.map((entry) => {
125
+ const m = new Map<string, number | null>();
126
+ for (const p of entry.points) m.set(stackKey(p.x), p.y);
127
+ return m;
128
+ });
129
+ return { visible, xKeys, lookup };
130
+ });
131
+
132
+ const hasSeries = $derived(ctx.seriesEntries.some((e) => e.visible && e.points.length > 0));
133
+ </script>
134
+
135
+ <div
136
+ class="fc-chart {klass}"
137
+ role="group"
138
+ aria-label={label}
139
+ bind:clientWidth={ctx.width}
140
+ bind:clientHeight={ctx.height}
141
+ onpointermove={handlePointerMove}
142
+ onpointerleave={clearPointer}
143
+ onpointercancel={clearPointer}
144
+ >
145
+ {#if description}
146
+ <p class="fc-sr-only">{description}</p>
147
+ {/if}
148
+
149
+ {#if keyboard && hasSeries}
150
+ <button
151
+ type="button"
152
+ class="fc-sr-only fc-kbd-nav"
153
+ aria-label="{label}. Interactive chart: use arrow keys to move between points and series, Home/End for first and last point, Escape to leave."
154
+ onkeydown={handleKeydown}
155
+ onblur={handleNavBlur}
156
+ ></button>
157
+ <div class="fc-sr-only" aria-live="polite" role="status">{announcement}</div>
158
+ {/if}
159
+
160
+ {#if ctx.width > 0 && ctx.height > 0}
161
+ {@render children?.()}
162
+ {/if}
163
+
164
+ {#if tableData}
165
+ <table class="fc-sr-only fc-data-table">
166
+ <caption>{label}{description ? `. ${description}` : ''}</caption>
167
+ <thead>
168
+ <tr>
169
+ <th scope="col">x</th>
170
+ {#each tableData.visible as entry (entry.index)}
171
+ <th scope="col">{entry.name ?? `Series ${entry.index + 1}`}</th>
172
+ {/each}
173
+ </tr>
174
+ </thead>
175
+ <tbody>
176
+ {#each tableData.xKeys as xk (xk.key)}
177
+ <tr>
178
+ <th scope="row">{xk.label}</th>
179
+ {#each tableData.lookup as seriesMap, si (si)}
180
+ <td>{seriesMap.get(xk.key) ?? '—'}</td>
181
+ {/each}
182
+ </tr>
183
+ {/each}
184
+ </tbody>
185
+ </table>
186
+ {/if}
187
+ </div>
188
+
189
+ <style>
190
+ .fc-chart {
191
+ position: relative;
192
+ width: 100%;
193
+ height: 100%;
194
+ font-family: var(--fc-font, ui-sans-serif, system-ui, sans-serif);
195
+ }
196
+ .fc-sr-only {
197
+ position: absolute;
198
+ width: 1px;
199
+ height: 1px;
200
+ margin: -1px;
201
+ padding: 0;
202
+ border: 0;
203
+ clip-path: inset(50%);
204
+ overflow: hidden;
205
+ white-space: nowrap;
206
+ }
207
+ </style>
@@ -0,0 +1,23 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { AxisOptions, Padding } from '../core/types.js';
3
+ interface Props {
4
+ /** X axis options (type inferred from data when omitted). */
5
+ x?: AxisOptions;
6
+ /** Y axis options. */
7
+ y?: AxisOptions;
8
+ /** Space reserved around the plot area for axes/labels. */
9
+ padding?: Padding;
10
+ /** Accessible name for the chart. */
11
+ label?: string;
12
+ /** Longer accessible description (rendered for screen readers). */
13
+ description?: string;
14
+ /** Screen-reader data table fallback (opt-out). */
15
+ dataTable?: boolean;
16
+ /** Arrow-key point navigation (opt-out). */
17
+ keyboard?: boolean;
18
+ class?: string;
19
+ children?: Snippet;
20
+ }
21
+ declare const Chart: import("svelte").Component<Props, {}, "">;
22
+ type Chart = ReturnType<typeof Chart>;
23
+ export default Chart;
@@ -0,0 +1,67 @@
1
+ <script lang="ts">
2
+ import { getChartContext } from '../core/context.svelte.js';
3
+ import { hitBisectX } from '../core/hit.js';
4
+ import type { HitResult } from '../core/hit.js';
5
+
6
+ interface Props {
7
+ /** Vertical line at the (snapped) x position. */
8
+ x?: boolean;
9
+ /** Horizontal line at the pointer y position. */
10
+ y?: boolean;
11
+ /** Snap the x line to the nearest data point across visible series. */
12
+ snap?: boolean;
13
+ dash?: string;
14
+ width?: number;
15
+ class?: string;
16
+ }
17
+
18
+ let { x = true, y = false, snap = true, dash = '3 3', width = 1, class: klass = '' }: Props =
19
+ $props();
20
+
21
+ const ctx = getChartContext();
22
+
23
+ const anchor = $derived.by(() => {
24
+ const ptr = ctx.hoverPointer;
25
+ if (!ptr) return null;
26
+ if (!snap) return { px: ptr.x, py: ptr.y };
27
+ let best: HitResult | null = null;
28
+ for (const entry of ctx.seriesEntries) {
29
+ if (!entry.visible) continue;
30
+ const hit = hitBisectX(entry.points, ctx.xPos, ctx.yPos, ptr.x);
31
+ if (hit && (!best || hit.distance < best.distance)) best = hit;
32
+ }
33
+ return { px: best?.px ?? ptr.x, py: ptr.y };
34
+ });
35
+ </script>
36
+
37
+ {#if anchor}
38
+ <g class="fc-crosshair {klass}">
39
+ {#if x}
40
+ <line
41
+ x1={anchor.px}
42
+ x2={anchor.px}
43
+ y1="0"
44
+ y2={ctx.innerHeight}
45
+ stroke-dasharray={dash}
46
+ stroke-width={width}
47
+ />
48
+ {/if}
49
+ {#if y}
50
+ <line
51
+ x1="0"
52
+ x2={ctx.innerWidth}
53
+ y1={anchor.py}
54
+ y2={anchor.py}
55
+ stroke-dasharray={dash}
56
+ stroke-width={width}
57
+ />
58
+ {/if}
59
+ </g>
60
+ {/if}
61
+
62
+ <style>
63
+ .fc-crosshair line {
64
+ stroke: var(--fc-crosshair, #94a3b8);
65
+ fill: none;
66
+ }
67
+ </style>