@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.
- package/LICENSE +40 -0
- package/README.md +103 -0
- package/dist/charts/AreaChart.svelte +150 -0
- package/dist/charts/AreaChart.svelte.d.ts +60 -0
- package/dist/charts/BarChart.svelte +142 -0
- package/dist/charts/BarChart.svelte.d.ts +58 -0
- package/dist/charts/BoxPlotChart.svelte +138 -0
- package/dist/charts/BoxPlotChart.svelte.d.ts +56 -0
- package/dist/charts/DonutChart.svelte +129 -0
- package/dist/charts/DonutChart.svelte.d.ts +73 -0
- package/dist/charts/LineChart.svelte +149 -0
- package/dist/charts/LineChart.svelte.d.ts +63 -0
- package/dist/charts/Sparkline.svelte +87 -0
- package/dist/charts/Sparkline.svelte.d.ts +40 -0
- package/dist/charts/StackChart.svelte +157 -0
- package/dist/charts/StackChart.svelte.d.ts +69 -0
- package/dist/components/Arc.svelte +202 -0
- package/dist/components/Arc.svelte.d.ts +50 -0
- package/dist/components/Area.svelte +264 -0
- package/dist/components/Area.svelte.d.ts +54 -0
- package/dist/components/Axis.svelte +139 -0
- package/dist/components/Axis.svelte.d.ts +26 -0
- package/dist/components/Bars.svelte +192 -0
- package/dist/components/Bars.svelte.d.ts +55 -0
- package/dist/components/Box.svelte +287 -0
- package/dist/components/Box.svelte.d.ts +48 -0
- package/dist/components/Chart.svelte +207 -0
- package/dist/components/Chart.svelte.d.ts +23 -0
- package/dist/components/Crosshair.svelte +67 -0
- package/dist/components/Crosshair.svelte.d.ts +14 -0
- package/dist/components/Grid.svelte +38 -0
- package/dist/components/Grid.svelte.d.ts +14 -0
- package/dist/components/Labels.svelte +61 -0
- package/dist/components/Labels.svelte.d.ts +35 -0
- package/dist/components/Legend.svelte +81 -0
- package/dist/components/Legend.svelte.d.ts +12 -0
- package/dist/components/Line.svelte +192 -0
- package/dist/components/Line.svelte.d.ts +47 -0
- package/dist/components/PlotBand.svelte +68 -0
- package/dist/components/PlotBand.svelte.d.ts +14 -0
- package/dist/components/PlotLine.svelte +54 -0
- package/dist/components/PlotLine.svelte.d.ts +16 -0
- package/dist/components/Points.svelte +179 -0
- package/dist/components/Points.svelte.d.ts +53 -0
- package/dist/components/Svg.svelte +36 -0
- package/dist/components/Svg.svelte.d.ts +8 -0
- package/dist/components/Tooltip.svelte +211 -0
- package/dist/components/Tooltip.svelte.d.ts +44 -0
- package/dist/core/bisect.d.ts +5 -0
- package/dist/core/bisect.js +23 -0
- package/dist/core/context.svelte.d.ts +140 -0
- package/dist/core/context.svelte.js +294 -0
- package/dist/core/curves.d.ts +4 -0
- package/dist/core/curves.js +13 -0
- package/dist/core/hit.d.ts +34 -0
- package/dist/core/hit.js +43 -0
- package/dist/core/keynav.d.ts +20 -0
- package/dist/core/keynav.js +41 -0
- package/dist/core/labels.d.ts +39 -0
- package/dist/core/labels.js +27 -0
- package/dist/core/merge.d.ts +17 -0
- package/dist/core/merge.js +46 -0
- package/dist/core/motion.svelte.d.ts +31 -0
- package/dist/core/motion.svelte.js +129 -0
- package/dist/core/normalize.d.ts +35 -0
- package/dist/core/normalize.js +97 -0
- package/dist/core/options.d.ts +113 -0
- package/dist/core/options.js +36 -0
- package/dist/core/palette.d.ts +8 -0
- package/dist/core/palette.js +24 -0
- package/dist/core/responsive.d.ts +6 -0
- package/dist/core/responsive.js +19 -0
- package/dist/core/scales.d.ts +31 -0
- package/dist/core/scales.js +89 -0
- package/dist/core/stack.d.ts +19 -0
- package/dist/core/stack.js +133 -0
- package/dist/core/stats.d.ts +45 -0
- package/dist/core/stats.js +114 -0
- package/dist/core/symbols.d.ts +8 -0
- package/dist/core/symbols.js +31 -0
- package/dist/core/types.d.ts +28 -0
- package/dist/core/types.js +1 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.js +42 -0
- package/package.json +81 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
2
|
+
import type { NormalizedPoint, XValue } from './normalize.js';
|
|
3
|
+
import type { AnyScale, ScaleDomain, ScaleKind } from './scales.js';
|
|
4
|
+
import type { StackExtents, StackOffset, StackOrder } from './stack.js';
|
|
5
|
+
import type { KeyNavPosition } from './keynav.js';
|
|
6
|
+
import type { ChartOptions } from './types.js';
|
|
7
|
+
/** Stacking participation (bars AND areas): series sharing an id accumulate. */
|
|
8
|
+
export interface StackedSeriesOptions {
|
|
9
|
+
id: string;
|
|
10
|
+
/** How the stack sits (normal/percent anchor at zero; stream/silhouette float). */
|
|
11
|
+
offset: StackOffset;
|
|
12
|
+
/** Layer ordering within the stack. */
|
|
13
|
+
order?: StackOrder;
|
|
14
|
+
}
|
|
15
|
+
/** What a mark component registers: its normalized points plus display metadata. */
|
|
16
|
+
export interface RegisteredSeries<T = unknown> {
|
|
17
|
+
points: NormalizedPoint<T>[];
|
|
18
|
+
name?: string;
|
|
19
|
+
/** Resolved CSS color string (var() included). Falls back to the palette slot. */
|
|
20
|
+
color?: string;
|
|
21
|
+
/** Bar series claim a side-by-side slot in the band and force zero into the y domain. */
|
|
22
|
+
isBar?: boolean;
|
|
23
|
+
/** Present when the series stacks (bars or areas). */
|
|
24
|
+
stacked?: StackedSeriesOptions;
|
|
25
|
+
/** Box/range series: claims a categorical slot like bars, but does NOT anchor at zero. */
|
|
26
|
+
isBox?: boolean;
|
|
27
|
+
/** Per-point [lo, hi] value extents; the y domain spans these instead of p.y.
|
|
28
|
+
* Used by range marks (box plots now; candlestick later). */
|
|
29
|
+
yExtents?: [number, number][];
|
|
30
|
+
/** Accessible series description (announced when keyboard focus enters the series). */
|
|
31
|
+
description?: string;
|
|
32
|
+
/** Per-point announcement override for keyboard navigation. */
|
|
33
|
+
describePoint?: (point: NormalizedPoint<unknown>) => string | undefined;
|
|
34
|
+
}
|
|
35
|
+
/** A registered series as exposed on the context (legend/tooltip read these). */
|
|
36
|
+
export interface SeriesEntry {
|
|
37
|
+
index: number;
|
|
38
|
+
points: NormalizedPoint<unknown>[];
|
|
39
|
+
name?: string;
|
|
40
|
+
color: string;
|
|
41
|
+
visible: boolean;
|
|
42
|
+
isBar?: boolean;
|
|
43
|
+
stacked?: StackedSeriesOptions;
|
|
44
|
+
isBox?: boolean;
|
|
45
|
+
yExtents?: [number, number][];
|
|
46
|
+
description?: string;
|
|
47
|
+
describePoint?: (point: NormalizedPoint<unknown>) => string | undefined;
|
|
48
|
+
}
|
|
49
|
+
export interface SeriesRegistration {
|
|
50
|
+
/** Zero-based registration order — drives the default palette slot and identifies the series. */
|
|
51
|
+
index: number;
|
|
52
|
+
unregister: () => void;
|
|
53
|
+
}
|
|
54
|
+
export declare class ChartContext {
|
|
55
|
+
#private;
|
|
56
|
+
/** Container size, bound by <Chart> via bind:clientWidth/Height (SSR-safe: stays 0 on the server). */
|
|
57
|
+
width: number;
|
|
58
|
+
height: number;
|
|
59
|
+
/** Pointer position in PLOT-AREA coordinates, or null when outside. Written by <Chart>. */
|
|
60
|
+
pointer: {
|
|
61
|
+
x: number;
|
|
62
|
+
y: number;
|
|
63
|
+
} | null;
|
|
64
|
+
/** Keyboard-focused point (arrow-key navigation), or null. */
|
|
65
|
+
activePoint: KeyNavPosition | null;
|
|
66
|
+
/** Series indices currently hidden (legend toggle). */
|
|
67
|
+
readonly hiddenSeries: SvelteSet<number>;
|
|
68
|
+
constructor(options: () => ChartOptions);
|
|
69
|
+
get options(): ChartOptions;
|
|
70
|
+
padding: {
|
|
71
|
+
top: number;
|
|
72
|
+
right: number;
|
|
73
|
+
bottom: number;
|
|
74
|
+
left: number;
|
|
75
|
+
};
|
|
76
|
+
innerWidth: number;
|
|
77
|
+
innerHeight: number;
|
|
78
|
+
/** All registered series with metadata, in registration order (hidden ones included). */
|
|
79
|
+
seriesEntries: SeriesEntry[];
|
|
80
|
+
xType: ScaleKind;
|
|
81
|
+
yType: ScaleKind;
|
|
82
|
+
xDomain: ScaleDomain;
|
|
83
|
+
/** Stack extents for a bar series (by registration index), keyed by stackKey(x). */
|
|
84
|
+
stackFor: (seriesIndex: number) => StackExtents | undefined;
|
|
85
|
+
/**
|
|
86
|
+
* Side-by-side slot layout for bar series: each unstacked bar series gets
|
|
87
|
+
* its own slot, each stack group shares one. Hidden series free their slot.
|
|
88
|
+
*/
|
|
89
|
+
barSlots: {
|
|
90
|
+
count: number;
|
|
91
|
+
bySeries: Map<number, number>;
|
|
92
|
+
};
|
|
93
|
+
yDomain: ScaleDomain;
|
|
94
|
+
xScale: AnyScale;
|
|
95
|
+
yScale: AnyScale;
|
|
96
|
+
/** Pixel position for an x value (band scales: band center). NaN if unmappable. */
|
|
97
|
+
xPos: (value: XValue) => number;
|
|
98
|
+
/** Pixel position for a y value. NaN if unmappable. */
|
|
99
|
+
yPos: (value: XValue) => number;
|
|
100
|
+
xTicks: (count?: number) => XValue[];
|
|
101
|
+
yTicks: (count?: number) => XValue[];
|
|
102
|
+
xTickFormat: (count?: number) => ((value: XValue) => string);
|
|
103
|
+
yTickFormat: (count?: number) => ((value: XValue) => string);
|
|
104
|
+
/**
|
|
105
|
+
* Mark components call this once at init with a reactive getter for their
|
|
106
|
+
* series. Domains re-derive whenever any registered getter's value changes.
|
|
107
|
+
* Call `unregister` on destroy.
|
|
108
|
+
*/
|
|
109
|
+
registerSeries(get: () => RegisteredSeries): SeriesRegistration;
|
|
110
|
+
/** The keyboard focus expressed as a plot-area position (for tooltip/crosshair). */
|
|
111
|
+
keyboardPointer: {
|
|
112
|
+
x: number;
|
|
113
|
+
y: number;
|
|
114
|
+
} | null;
|
|
115
|
+
/** What interaction layers should react to: the mouse, else the keyboard focus. */
|
|
116
|
+
hoverPointer: {
|
|
117
|
+
x: number;
|
|
118
|
+
y: number;
|
|
119
|
+
} | null;
|
|
120
|
+
/**
|
|
121
|
+
* Move keyboard focus by points (dPoint, ±Infinity = Home/End) and/or
|
|
122
|
+
* series (dSeries). Returns the focused entry+point for announcements.
|
|
123
|
+
*/
|
|
124
|
+
moveFocusBy(dPoint: number, dSeries: number): {
|
|
125
|
+
entry: SeriesEntry;
|
|
126
|
+
point: NormalizedPoint<unknown>;
|
|
127
|
+
} | null;
|
|
128
|
+
clearFocus(): void;
|
|
129
|
+
/** Legend toggle: hide/show a series by its registration index. */
|
|
130
|
+
toggleSeries(index: number): void;
|
|
131
|
+
isSeriesVisible: (index: number) => boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Resolve a series color. An override (any CSS color string, including
|
|
134
|
+
* `var(--whatever)`) passes through untouched — never parsed, never
|
|
135
|
+
* converted. Otherwise the palette slot for the series index.
|
|
136
|
+
*/
|
|
137
|
+
seriesColor(index: number, override?: string): string;
|
|
138
|
+
}
|
|
139
|
+
export declare function setChartContext(ctx: ChartContext): ChartContext;
|
|
140
|
+
export declare function getChartContext(): ChartContext;
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactive chart context — the foundation pattern (documented in ARCHITECTURE.md).
|
|
3
|
+
*
|
|
4
|
+
* <Chart> constructs ONE ChartContext instance and shares it via setContext.
|
|
5
|
+
* Context itself is set once and never replaced; reactivity lives INSIDE the
|
|
6
|
+
* instance: dimensions and pointer are $state (bound/written by <Chart>),
|
|
7
|
+
* everything else is $derived from those + the options closure + the series
|
|
8
|
+
* registry. Children read `ctx.xScale` etc. and re-render exactly when the
|
|
9
|
+
* underlying state moves.
|
|
10
|
+
*
|
|
11
|
+
* Mark components register a getter for their series (points + name + color);
|
|
12
|
+
* scale domains derive from the union of VISIBLE registered series unless
|
|
13
|
+
* pinned via axis options — which is why a legend toggle recomputes domains
|
|
14
|
+
* for free, and why pinned domains don't move.
|
|
15
|
+
*/
|
|
16
|
+
import { getContext, setContext } from 'svelte';
|
|
17
|
+
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
|
18
|
+
import { max, min } from 'd3-array';
|
|
19
|
+
import { createScale, scalePos, scaleTicks, scaleTickFormat } from './scales.js';
|
|
20
|
+
import { seriesColorVar } from './palette.js';
|
|
21
|
+
import { stackSeries } from './stack.js';
|
|
22
|
+
import { moveFocus } from './keynav.js';
|
|
23
|
+
const DEFAULT_PADDING = { top: 12, right: 16, bottom: 32, left: 44 };
|
|
24
|
+
export class ChartContext {
|
|
25
|
+
/** Container size, bound by <Chart> via bind:clientWidth/Height (SSR-safe: stays 0 on the server). */
|
|
26
|
+
width = $state(0);
|
|
27
|
+
height = $state(0);
|
|
28
|
+
/** Pointer position in PLOT-AREA coordinates, or null when outside. Written by <Chart>. */
|
|
29
|
+
pointer = $state(null);
|
|
30
|
+
/** Keyboard-focused point (arrow-key navigation), or null. */
|
|
31
|
+
activePoint = $state(null);
|
|
32
|
+
/** Series indices currently hidden (legend toggle). */
|
|
33
|
+
hiddenSeries = new SvelteSet();
|
|
34
|
+
#options;
|
|
35
|
+
#registry = new SvelteMap();
|
|
36
|
+
#seriesCount = 0;
|
|
37
|
+
constructor(options) {
|
|
38
|
+
this.#options = options;
|
|
39
|
+
}
|
|
40
|
+
get options() {
|
|
41
|
+
return this.#options();
|
|
42
|
+
}
|
|
43
|
+
padding = $derived.by(() => ({ ...DEFAULT_PADDING, ...this.#options().padding }));
|
|
44
|
+
innerWidth = $derived(Math.max(0, this.width - this.padding.left - this.padding.right));
|
|
45
|
+
innerHeight = $derived(Math.max(0, this.height - this.padding.top - this.padding.bottom));
|
|
46
|
+
/** All registered series with metadata, in registration order (hidden ones included). */
|
|
47
|
+
seriesEntries = $derived.by(() => {
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const { index, get } of this.#registry.values()) {
|
|
50
|
+
const { points, name, color, isBar, stacked, isBox, yExtents, description, describePoint } = get();
|
|
51
|
+
out.push({
|
|
52
|
+
index,
|
|
53
|
+
points,
|
|
54
|
+
name,
|
|
55
|
+
color: color ?? seriesColorVar(index),
|
|
56
|
+
visible: !this.hiddenSeries.has(index),
|
|
57
|
+
isBar,
|
|
58
|
+
stacked,
|
|
59
|
+
isBox,
|
|
60
|
+
yExtents,
|
|
61
|
+
description,
|
|
62
|
+
describePoint
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return out.sort((a, b) => a.index - b.index);
|
|
66
|
+
});
|
|
67
|
+
/** Points of VISIBLE series only — what domains derive from. */
|
|
68
|
+
#allPoints = $derived(this.seriesEntries.filter((e) => e.visible).flatMap((e) => e.points));
|
|
69
|
+
xType = $derived.by(() => {
|
|
70
|
+
const explicit = this.#options().x?.type;
|
|
71
|
+
if (explicit)
|
|
72
|
+
return explicit;
|
|
73
|
+
const probe = this.#allPoints.find((p) => p.x != null);
|
|
74
|
+
if (!probe)
|
|
75
|
+
return 'linear';
|
|
76
|
+
if (probe.x instanceof Date)
|
|
77
|
+
return 'time';
|
|
78
|
+
if (typeof probe.x === 'string')
|
|
79
|
+
return 'band';
|
|
80
|
+
return 'linear';
|
|
81
|
+
});
|
|
82
|
+
yType = $derived.by(() => this.#options().y?.type ?? 'linear');
|
|
83
|
+
xDomain = $derived.by(() => {
|
|
84
|
+
const o = this.#options().x;
|
|
85
|
+
if (this.xType === 'band') {
|
|
86
|
+
if (o?.categories)
|
|
87
|
+
return o.categories;
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
for (const p of this.#allPoints) {
|
|
90
|
+
if (p.x != null)
|
|
91
|
+
seen.add(String(p.x));
|
|
92
|
+
}
|
|
93
|
+
return [...seen];
|
|
94
|
+
}
|
|
95
|
+
const values = [];
|
|
96
|
+
for (const p of this.#allPoints) {
|
|
97
|
+
if (typeof p.x === 'number' || p.x instanceof Date)
|
|
98
|
+
values.push(p.x);
|
|
99
|
+
}
|
|
100
|
+
const lo = o?.min ?? min(values) ?? 0;
|
|
101
|
+
const hi = o?.max ?? max(values) ?? 1;
|
|
102
|
+
return [lo, hi];
|
|
103
|
+
});
|
|
104
|
+
/**
|
|
105
|
+
* Stacked [y0, y1] extents (value space) per bar series, computed across
|
|
106
|
+
* VISIBLE members of each stack group — toggling a series re-stacks.
|
|
107
|
+
*/
|
|
108
|
+
#stacks = $derived.by(() => {
|
|
109
|
+
const groups = new Map();
|
|
110
|
+
for (const e of this.seriesEntries) {
|
|
111
|
+
if (!e.visible || !e.stacked)
|
|
112
|
+
continue;
|
|
113
|
+
let group = groups.get(e.stacked.id);
|
|
114
|
+
if (!group) {
|
|
115
|
+
group = { offset: e.stacked.offset, order: e.stacked.order, series: [] };
|
|
116
|
+
groups.set(e.stacked.id, group);
|
|
117
|
+
}
|
|
118
|
+
// Members share an id; the last seen offset/order wins for the group.
|
|
119
|
+
group.offset = e.stacked.offset;
|
|
120
|
+
if (e.stacked.order)
|
|
121
|
+
group.order = e.stacked.order;
|
|
122
|
+
group.series.push({ key: e.index, points: e.points });
|
|
123
|
+
}
|
|
124
|
+
const out = new Map();
|
|
125
|
+
for (const group of groups.values()) {
|
|
126
|
+
for (const [key, extents] of stackSeries(group.series, {
|
|
127
|
+
offset: group.offset,
|
|
128
|
+
order: group.order
|
|
129
|
+
})) {
|
|
130
|
+
out.set(key, extents);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
});
|
|
135
|
+
/** Stack extents for a bar series (by registration index), keyed by stackKey(x). */
|
|
136
|
+
stackFor = (seriesIndex) => this.#stacks.get(seriesIndex);
|
|
137
|
+
/**
|
|
138
|
+
* Side-by-side slot layout for bar series: each unstacked bar series gets
|
|
139
|
+
* its own slot, each stack group shares one. Hidden series free their slot.
|
|
140
|
+
*/
|
|
141
|
+
barSlots = $derived.by(() => {
|
|
142
|
+
const keys = [];
|
|
143
|
+
const bySeries = new Map();
|
|
144
|
+
for (const e of this.seriesEntries) {
|
|
145
|
+
if (!e.visible || !(e.isBar || e.isBox))
|
|
146
|
+
continue;
|
|
147
|
+
const key = e.stacked ? `stack:${e.stacked.id}` : `series:${e.index}`;
|
|
148
|
+
let slot = keys.indexOf(key);
|
|
149
|
+
if (slot === -1) {
|
|
150
|
+
keys.push(key);
|
|
151
|
+
slot = keys.length - 1;
|
|
152
|
+
}
|
|
153
|
+
bySeries.set(e.index, slot);
|
|
154
|
+
}
|
|
155
|
+
return { count: Math.max(1, keys.length), bySeries };
|
|
156
|
+
});
|
|
157
|
+
yDomain = $derived.by(() => {
|
|
158
|
+
const o = this.#options().y;
|
|
159
|
+
if (this.yType === 'band')
|
|
160
|
+
return o?.categories ?? [];
|
|
161
|
+
const values = [];
|
|
162
|
+
let hasBars = false;
|
|
163
|
+
for (const e of this.seriesEntries) {
|
|
164
|
+
if (!e.visible)
|
|
165
|
+
continue;
|
|
166
|
+
if (e.isBar)
|
|
167
|
+
hasBars = true;
|
|
168
|
+
const stacked = e.stacked ? this.#stacks.get(e.index) : undefined;
|
|
169
|
+
if (stacked) {
|
|
170
|
+
for (const [y0, y1] of stacked.values())
|
|
171
|
+
values.push(y0, y1);
|
|
172
|
+
}
|
|
173
|
+
else if (e.yExtents) {
|
|
174
|
+
for (const [lo, hi] of e.yExtents)
|
|
175
|
+
values.push(lo, hi);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
for (const p of e.points) {
|
|
179
|
+
if (p.y != null)
|
|
180
|
+
values.push(p.y);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
let lo = o?.min ?? min(values) ?? 0;
|
|
185
|
+
let hi = o?.max ?? max(values) ?? 1;
|
|
186
|
+
// Bars are anchored at zero, so zero always belongs in the domain.
|
|
187
|
+
if (o?.includeZero || hasBars) {
|
|
188
|
+
lo = Math.min(0, lo);
|
|
189
|
+
hi = Math.max(0, hi);
|
|
190
|
+
}
|
|
191
|
+
if (lo === hi)
|
|
192
|
+
hi = lo + 1; // degenerate domain → give the scale some room
|
|
193
|
+
return [lo, hi];
|
|
194
|
+
});
|
|
195
|
+
xScale = $derived.by(() => createScale({
|
|
196
|
+
kind: this.xType,
|
|
197
|
+
domain: this.xDomain,
|
|
198
|
+
range: [0, this.innerWidth],
|
|
199
|
+
nice: this.#options().x?.nice,
|
|
200
|
+
bandPadding: this.#options().x?.bandPadding
|
|
201
|
+
}));
|
|
202
|
+
yScale = $derived.by(() => createScale({
|
|
203
|
+
kind: this.yType,
|
|
204
|
+
domain: this.yDomain,
|
|
205
|
+
range: [this.innerHeight, 0],
|
|
206
|
+
nice: this.#options().y?.nice,
|
|
207
|
+
bandPadding: this.#options().y?.bandPadding
|
|
208
|
+
}));
|
|
209
|
+
#autoXTickCount = $derived(Math.max(2, Math.min(10, Math.round(this.innerWidth / 80))));
|
|
210
|
+
#autoYTickCount = $derived(Math.max(2, Math.min(10, Math.round(this.innerHeight / 50))));
|
|
211
|
+
/** Pixel position for an x value (band scales: band center). NaN if unmappable. */
|
|
212
|
+
xPos = (value) => scalePos(this.xScale, value);
|
|
213
|
+
/** Pixel position for a y value. NaN if unmappable. */
|
|
214
|
+
yPos = (value) => scalePos(this.yScale, value);
|
|
215
|
+
xTicks = (count) => scaleTicks(this.xScale, count ?? this.#autoXTickCount);
|
|
216
|
+
yTicks = (count) => scaleTicks(this.yScale, count ?? this.#autoYTickCount);
|
|
217
|
+
xTickFormat = (count) => scaleTickFormat(this.xScale, count ?? this.#autoXTickCount);
|
|
218
|
+
yTickFormat = (count) => scaleTickFormat(this.yScale, count ?? this.#autoYTickCount);
|
|
219
|
+
/**
|
|
220
|
+
* Mark components call this once at init with a reactive getter for their
|
|
221
|
+
* series. Domains re-derive whenever any registered getter's value changes.
|
|
222
|
+
* Call `unregister` on destroy.
|
|
223
|
+
*/
|
|
224
|
+
registerSeries(get) {
|
|
225
|
+
const id = Symbol('fc-series');
|
|
226
|
+
const index = this.#seriesCount++;
|
|
227
|
+
this.#registry.set(id, { index, get });
|
|
228
|
+
return {
|
|
229
|
+
index,
|
|
230
|
+
unregister: () => {
|
|
231
|
+
this.#registry.delete(id);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/** The keyboard focus expressed as a plot-area position (for tooltip/crosshair). */
|
|
236
|
+
keyboardPointer = $derived.by(() => {
|
|
237
|
+
const active = this.activePoint;
|
|
238
|
+
if (!active)
|
|
239
|
+
return null;
|
|
240
|
+
const entry = this.seriesEntries.find((e) => e.index === active.series);
|
|
241
|
+
const point = entry?.points[active.point];
|
|
242
|
+
if (!entry || !entry.visible || !point || point.y == null)
|
|
243
|
+
return null;
|
|
244
|
+
const x = this.xPos(point.x);
|
|
245
|
+
const y = this.yPos(point.y);
|
|
246
|
+
return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null;
|
|
247
|
+
});
|
|
248
|
+
/** What interaction layers should react to: the mouse, else the keyboard focus. */
|
|
249
|
+
hoverPointer = $derived(this.pointer ?? this.keyboardPointer);
|
|
250
|
+
/**
|
|
251
|
+
* Move keyboard focus by points (dPoint, ±Infinity = Home/End) and/or
|
|
252
|
+
* series (dSeries). Returns the focused entry+point for announcements.
|
|
253
|
+
*/
|
|
254
|
+
moveFocusBy(dPoint, dSeries) {
|
|
255
|
+
const visible = this.seriesEntries.filter((e) => e.visible);
|
|
256
|
+
const next = moveFocus(visible, this.activePoint, dPoint, dSeries);
|
|
257
|
+
if (!next)
|
|
258
|
+
return null;
|
|
259
|
+
this.activePoint = next;
|
|
260
|
+
const entry = this.seriesEntries.find((e) => e.index === next.series);
|
|
261
|
+
return { entry, point: entry.points[next.point] };
|
|
262
|
+
}
|
|
263
|
+
clearFocus() {
|
|
264
|
+
this.activePoint = null;
|
|
265
|
+
}
|
|
266
|
+
/** Legend toggle: hide/show a series by its registration index. */
|
|
267
|
+
toggleSeries(index) {
|
|
268
|
+
if (this.hiddenSeries.has(index))
|
|
269
|
+
this.hiddenSeries.delete(index);
|
|
270
|
+
else
|
|
271
|
+
this.hiddenSeries.add(index);
|
|
272
|
+
}
|
|
273
|
+
isSeriesVisible = (index) => !this.hiddenSeries.has(index);
|
|
274
|
+
/**
|
|
275
|
+
* Resolve a series color. An override (any CSS color string, including
|
|
276
|
+
* `var(--whatever)`) passes through untouched — never parsed, never
|
|
277
|
+
* converted. Otherwise the palette slot for the series index.
|
|
278
|
+
*/
|
|
279
|
+
seriesColor(index, override) {
|
|
280
|
+
return override ?? seriesColorVar(index);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const CONTEXT_KEY = Symbol('flarechart');
|
|
284
|
+
export function setChartContext(ctx) {
|
|
285
|
+
setContext(CONTEXT_KEY, ctx);
|
|
286
|
+
return ctx;
|
|
287
|
+
}
|
|
288
|
+
export function getChartContext() {
|
|
289
|
+
const ctx = getContext(CONTEXT_KEY);
|
|
290
|
+
if (!ctx) {
|
|
291
|
+
throw new Error('flarechart: this component must be rendered inside a <Chart>.');
|
|
292
|
+
}
|
|
293
|
+
return ctx;
|
|
294
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { CurveFactory } from 'd3-shape';
|
|
2
|
+
export type CurveName = 'linear' | 'monotone' | 'natural' | 'step' | 'basis' | 'bump';
|
|
3
|
+
/** Resolve a curve by name, or pass a d3 CurveFactory straight through. */
|
|
4
|
+
export declare function curveFor(curve?: CurveName | CurveFactory): CurveFactory;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { curveBasis, curveBumpX, curveLinear, curveMonotoneX, curveNatural, curveStepAfter } from 'd3-shape';
|
|
2
|
+
const CURVES = {
|
|
3
|
+
linear: curveLinear,
|
|
4
|
+
monotone: curveMonotoneX,
|
|
5
|
+
natural: curveNatural,
|
|
6
|
+
step: curveStepAfter,
|
|
7
|
+
basis: curveBasis,
|
|
8
|
+
bump: curveBumpX
|
|
9
|
+
};
|
|
10
|
+
/** Resolve a curve by name, or pass a d3 CurveFactory straight through. */
|
|
11
|
+
export function curveFor(curve = 'linear') {
|
|
12
|
+
return typeof curve === 'function' ? curve : CURVES[curve];
|
|
13
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { NormalizedPoint, XValue } from './normalize.js';
|
|
2
|
+
export type TooltipMode = 'bisect-x' | 'nearest' | 'band';
|
|
3
|
+
export interface HitResult<T = unknown> {
|
|
4
|
+
point: NormalizedPoint<T>;
|
|
5
|
+
px: number;
|
|
6
|
+
py: number;
|
|
7
|
+
/** Horizontal distance (bisect-x) or euclidean distance (nearest) to the probe. */
|
|
8
|
+
distance: number;
|
|
9
|
+
}
|
|
10
|
+
/** One series' contribution to a tooltip. `datum` is the consumer's original item. */
|
|
11
|
+
export interface TooltipPoint<T = unknown> {
|
|
12
|
+
x: XValue;
|
|
13
|
+
y: number | null;
|
|
14
|
+
datum: T;
|
|
15
|
+
pointIndex: number;
|
|
16
|
+
seriesIndex: number;
|
|
17
|
+
seriesName?: string;
|
|
18
|
+
color: string;
|
|
19
|
+
px: number;
|
|
20
|
+
py: number;
|
|
21
|
+
}
|
|
22
|
+
export interface TooltipData<T = unknown> {
|
|
23
|
+
/** The anchor x value (shared tooltips group on this). */
|
|
24
|
+
x: XValue;
|
|
25
|
+
px: number;
|
|
26
|
+
py: number;
|
|
27
|
+
points: TooltipPoint<T>[];
|
|
28
|
+
}
|
|
29
|
+
/** Nearest point by x pixel position (binary search; data assumed in x order). */
|
|
30
|
+
export declare function hitBisectX<T>(points: readonly NormalizedPoint<T>[], xPos: (value: XValue) => number, yPos: (value: XValue) => number, probePx: number): HitResult<T> | null;
|
|
31
|
+
/** Nearest point by euclidean distance (scatter-style hovering). */
|
|
32
|
+
export declare function hitNearest<T>(points: readonly NormalizedPoint<T>[], xPos: (value: XValue) => number, yPos: (value: XValue) => number, probePx: number, probePy: number): HitResult<T> | null;
|
|
33
|
+
/** All non-gap points of a series sitting on a band category. */
|
|
34
|
+
export declare function hitsAtBand<T>(points: readonly NormalizedPoint<T>[], category: string): NormalizedPoint<T>[];
|
package/dist/core/hit.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure hit-testing math for tooltips and crosshairs. No DOM, no Svelte —
|
|
3
|
+
* components feed in plot-area pixel coordinates and position functions.
|
|
4
|
+
* Gap points (y == null) and unmappable x values never match.
|
|
5
|
+
*/
|
|
6
|
+
import { bisectNearest } from './bisect.js';
|
|
7
|
+
/** Nearest point by x pixel position (binary search; data assumed in x order). */
|
|
8
|
+
export function hitBisectX(points, xPos, yPos, probePx) {
|
|
9
|
+
const usable = [];
|
|
10
|
+
for (const point of points) {
|
|
11
|
+
if (point.y == null)
|
|
12
|
+
continue;
|
|
13
|
+
const px = xPos(point.x);
|
|
14
|
+
if (Number.isFinite(px))
|
|
15
|
+
usable.push({ point, px });
|
|
16
|
+
}
|
|
17
|
+
if (usable.length === 0)
|
|
18
|
+
return null;
|
|
19
|
+
const index = bisectNearest(usable.map((u) => u.px), probePx);
|
|
20
|
+
const { point, px } = usable[index];
|
|
21
|
+
return { point, px, py: yPos(point.y), distance: Math.abs(px - probePx) };
|
|
22
|
+
}
|
|
23
|
+
/** Nearest point by euclidean distance (scatter-style hovering). */
|
|
24
|
+
export function hitNearest(points, xPos, yPos, probePx, probePy) {
|
|
25
|
+
let best = null;
|
|
26
|
+
for (const point of points) {
|
|
27
|
+
if (point.y == null)
|
|
28
|
+
continue;
|
|
29
|
+
const px = xPos(point.x);
|
|
30
|
+
const py = yPos(point.y);
|
|
31
|
+
if (!Number.isFinite(px) || !Number.isFinite(py))
|
|
32
|
+
continue;
|
|
33
|
+
const distance = Math.hypot(px - probePx, py - probePy);
|
|
34
|
+
if (!best || distance < best.distance) {
|
|
35
|
+
best = { point, px, py, distance };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return best;
|
|
39
|
+
}
|
|
40
|
+
/** All non-gap points of a series sitting on a band category. */
|
|
41
|
+
export function hitsAtBand(points, category) {
|
|
42
|
+
return points.filter((p) => p.y != null && String(p.x) === category);
|
|
43
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard point-navigation math — pure, tested.
|
|
3
|
+
*
|
|
4
|
+
* Arrow keys move between points (skipping gaps, clamping at the ends) and
|
|
5
|
+
* between series (preserving the point position, snapped to the nearest
|
|
6
|
+
* non-gap point). ±Infinity jumps to the first/last point (Home/End).
|
|
7
|
+
*/
|
|
8
|
+
import type { NormalizedPoint } from './normalize.js';
|
|
9
|
+
export interface KeyNavSeries {
|
|
10
|
+
/** Registration index (stable identity). */
|
|
11
|
+
index: number;
|
|
12
|
+
points: readonly NormalizedPoint<unknown>[];
|
|
13
|
+
}
|
|
14
|
+
export interface KeyNavPosition {
|
|
15
|
+
/** Series registration index. */
|
|
16
|
+
series: number;
|
|
17
|
+
/** Point index within that series' points array. */
|
|
18
|
+
point: number;
|
|
19
|
+
}
|
|
20
|
+
export declare function moveFocus(seriesList: readonly KeyNavSeries[], current: KeyNavPosition | null, dPoint: number, dSeries: number): KeyNavPosition | null;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
function nonGapIndices(points) {
|
|
2
|
+
const out = [];
|
|
3
|
+
for (let i = 0; i < points.length; i++) {
|
|
4
|
+
if (points[i].y != null)
|
|
5
|
+
out.push(i);
|
|
6
|
+
}
|
|
7
|
+
return out;
|
|
8
|
+
}
|
|
9
|
+
export function moveFocus(seriesList, current, dPoint, dSeries) {
|
|
10
|
+
const usable = seriesList.filter((s) => s.points.some((p) => p.y != null));
|
|
11
|
+
if (usable.length === 0)
|
|
12
|
+
return null;
|
|
13
|
+
let seriesPos = current ? usable.findIndex((s) => s.index === current.series) : -1;
|
|
14
|
+
if (seriesPos === -1) {
|
|
15
|
+
// No (valid) focus yet → land on the first point of the first series.
|
|
16
|
+
const first = usable[0];
|
|
17
|
+
return { series: first.index, point: nonGapIndices(first.points)[0] };
|
|
18
|
+
}
|
|
19
|
+
seriesPos = Math.max(0, Math.min(usable.length - 1, seriesPos + dSeries));
|
|
20
|
+
const target = usable[seriesPos];
|
|
21
|
+
const indices = nonGapIndices(target.points);
|
|
22
|
+
if (dSeries !== 0) {
|
|
23
|
+
// Switching series: keep the point position, snapped to the nearest non-gap.
|
|
24
|
+
const cur = current.point;
|
|
25
|
+
let best = indices[0];
|
|
26
|
+
for (const i of indices) {
|
|
27
|
+
if (Math.abs(i - cur) < Math.abs(best - cur))
|
|
28
|
+
best = i;
|
|
29
|
+
}
|
|
30
|
+
return { series: target.index, point: best };
|
|
31
|
+
}
|
|
32
|
+
const currentPos = indices.indexOf(current.point);
|
|
33
|
+
let nextPos;
|
|
34
|
+
if (dPoint === Number.POSITIVE_INFINITY)
|
|
35
|
+
nextPos = indices.length - 1;
|
|
36
|
+
else if (dPoint === Number.NEGATIVE_INFINITY)
|
|
37
|
+
nextPos = 0;
|
|
38
|
+
else
|
|
39
|
+
nextPos = Math.max(0, Math.min(indices.length - 1, (currentPos === -1 ? 0 : currentPos) + dPoint));
|
|
40
|
+
return { series: target.index, point: indices[nextPos] };
|
|
41
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Point/bar label placement with basic collision avoidance — pure math.
|
|
3
|
+
*
|
|
4
|
+
* Greedy keep-first strategy: candidates are processed left-to-right; a label
|
|
5
|
+
* whose estimated box would overlap the previously kept one is skipped.
|
|
6
|
+
* Labels near the top edge flip below their anchor. Width is estimated from
|
|
7
|
+
* character count (no DOM measurement — SSR-safe and cheap).
|
|
8
|
+
*/
|
|
9
|
+
export interface LabelCandidate {
|
|
10
|
+
/** Anchor position in plot-area pixels (e.g. the data point). */
|
|
11
|
+
px: number;
|
|
12
|
+
py: number;
|
|
13
|
+
text: string;
|
|
14
|
+
}
|
|
15
|
+
export interface PlacedLabel {
|
|
16
|
+
/** Final text position (text-anchor: middle, y is the baseline). */
|
|
17
|
+
x: number;
|
|
18
|
+
y: number;
|
|
19
|
+
text: string;
|
|
20
|
+
/** True when the label flipped below its anchor (top-edge avoidance). */
|
|
21
|
+
below: boolean;
|
|
22
|
+
px: number;
|
|
23
|
+
py: number;
|
|
24
|
+
}
|
|
25
|
+
export interface LabelPlacementOptions {
|
|
26
|
+
bounds: {
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
};
|
|
30
|
+
/** Distance between anchor and label, default 8. */
|
|
31
|
+
offset?: number;
|
|
32
|
+
/** Used for box estimation and below-flip placement, default 10. */
|
|
33
|
+
fontSize?: number;
|
|
34
|
+
/** Estimated px per character, default fontSize * 0.62. */
|
|
35
|
+
charWidth?: number;
|
|
36
|
+
/** Minimum horizontal gap between neighbouring labels, default 4. */
|
|
37
|
+
gap?: number;
|
|
38
|
+
}
|
|
39
|
+
export declare function placeLabels(candidates: readonly LabelCandidate[], options: LabelPlacementOptions): PlacedLabel[];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function placeLabels(candidates, options) {
|
|
2
|
+
const offset = options.offset ?? 8;
|
|
3
|
+
const fontSize = options.fontSize ?? 10;
|
|
4
|
+
const charWidth = options.charWidth ?? fontSize * 0.62;
|
|
5
|
+
const gap = options.gap ?? 4;
|
|
6
|
+
const { width, height } = options.bounds;
|
|
7
|
+
const sorted = [...candidates].sort((a, b) => a.px - b.px);
|
|
8
|
+
const placed = [];
|
|
9
|
+
let lastRight = -Infinity;
|
|
10
|
+
for (const candidate of sorted) {
|
|
11
|
+
if (!Number.isFinite(candidate.px) || !Number.isFinite(candidate.py))
|
|
12
|
+
continue;
|
|
13
|
+
const boxWidth = Math.max(charWidth, candidate.text.length * charWidth);
|
|
14
|
+
const half = boxWidth / 2;
|
|
15
|
+
// Clamp into the plot horizontally (labels at the edges shift inward).
|
|
16
|
+
const x = Math.max(half, Math.min(width - half, candidate.px));
|
|
17
|
+
if (x - half < lastRight + gap)
|
|
18
|
+
continue; // would overlap the previous label
|
|
19
|
+
const below = candidate.py - offset - fontSize < 0;
|
|
20
|
+
let y = below ? candidate.py + offset + fontSize : candidate.py - offset;
|
|
21
|
+
if (y > height)
|
|
22
|
+
y = candidate.py - offset;
|
|
23
|
+
placed.push({ x, y, text: candidate.text, below, px: candidate.px, py: candidate.py });
|
|
24
|
+
lastRight = x + half;
|
|
25
|
+
}
|
|
26
|
+
return placed;
|
|
27
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options precedence merge — Data chain, as one tiny utility:
|
|
3
|
+
*
|
|
4
|
+
* chart defaults < chart-level plotOptions < per-series options < per-point options
|
|
5
|
+
*
|
|
6
|
+
* Call it with layers in ascending precedence; later layers win:
|
|
7
|
+
*
|
|
8
|
+
* mergeOptions(defaults, plotOptions, series.options, point.options)
|
|
9
|
+
*
|
|
10
|
+
* Rules:
|
|
11
|
+
* - plain objects merge recursively
|
|
12
|
+
* - everything else (arrays, Dates, functions, class instances, scalars) replaces
|
|
13
|
+
* - `undefined` values are skipped (do not overwrite)
|
|
14
|
+
* - `null` values DO overwrite (explicit "clear this option")
|
|
15
|
+
* - inputs are never mutated; nested plain objects are cloned
|
|
16
|
+
*/
|
|
17
|
+
export declare function mergeOptions<T>(...layers: ReadonlyArray<Partial<T> | null | undefined>): T;
|