@chipmobilesdk/rn-chart 0.1.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/CHANGELOG.md +74 -0
- package/README.md +358 -0
- package/package.json +48 -0
- package/src/a11y/summary.ts +177 -0
- package/src/contract/datasource.ts +134 -0
- package/src/contract/errors.ts +133 -0
- package/src/contract/format.ts +168 -0
- package/src/contract/interaction.ts +71 -0
- package/src/contract/marker.ts +75 -0
- package/src/contract/props.ts +60 -0
- package/src/contract/state.ts +94 -0
- package/src/contract/theme.ts +139 -0
- package/src/contract/value.ts +125 -0
- package/src/geometry/bar.ts +256 -0
- package/src/geometry/distribution.ts +200 -0
- package/src/geometry/heatmap.ts +195 -0
- package/src/geometry/hitTest.ts +120 -0
- package/src/geometry/layout.ts +121 -0
- package/src/geometry/path.ts +199 -0
- package/src/geometry/progress.ts +204 -0
- package/src/geometry/scale.ts +146 -0
- package/src/geometry/ticks.ts +153 -0
- package/src/geometry/timeSeries.ts +158 -0
- package/src/geometry/types.ts +88 -0
- package/src/index.ts +176 -0
- package/src/react/ChartBoundary.tsx +96 -0
- package/src/react/ChartFrame.tsx +620 -0
- package/src/react/ChartLegend.tsx +177 -0
- package/src/react/ChartThemeProvider.tsx +48 -0
- package/src/react/charts.tsx +161 -0
- package/src/react/useChartLayout.ts +124 -0
- package/src/react/useChartSelection.ts +170 -0
- package/src/render/Axis.tsx +122 -0
- package/src/render/Marks.tsx +205 -0
- package/src/validation/budgets.ts +110 -0
- package/src/validation/validateDatasource.ts +174 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legend (FR-066).
|
|
3
|
+
*
|
|
4
|
+
* On by default for multi-series and multi-segment charts, disableable, and its
|
|
5
|
+
* model is exported so an app can render its own layout instead. Every entry
|
|
6
|
+
* carries a dash pattern as well as a colour, so series stay distinguishable
|
|
7
|
+
* without colour perception.
|
|
8
|
+
*/
|
|
9
|
+
import React from 'react';
|
|
10
|
+
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
11
|
+
|
|
12
|
+
import type { ChartDatasource } from '../contract/datasource';
|
|
13
|
+
import { resolveSeriesColor, type ChartTheme, type LegendEntry } from '../contract/theme';
|
|
14
|
+
|
|
15
|
+
/** Minimum tap target, per platform accessibility guidance (UX-003). */
|
|
16
|
+
const MIN_TAP_TARGET = 44;
|
|
17
|
+
|
|
18
|
+
export function buildLegendEntries<M>(
|
|
19
|
+
datasource: ChartDatasource<M>,
|
|
20
|
+
theme: ChartTheme,
|
|
21
|
+
hidden: ReadonlySet<string>,
|
|
22
|
+
toggleable: boolean,
|
|
23
|
+
): LegendEntry[] {
|
|
24
|
+
return datasource.series.map((series, index) => ({
|
|
25
|
+
seriesId: series.id,
|
|
26
|
+
label: series.label,
|
|
27
|
+
color: resolveSeriesColor(theme, index, series.color),
|
|
28
|
+
pattern: series.pattern ?? 'solid',
|
|
29
|
+
visible: !hidden.has(series.id),
|
|
30
|
+
toggleable,
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function segmentCountFor(pattern: LegendEntry['pattern']): number {
|
|
35
|
+
switch (pattern) {
|
|
36
|
+
case 'dotted':
|
|
37
|
+
return 4;
|
|
38
|
+
case 'dashed':
|
|
39
|
+
case 'dashDot':
|
|
40
|
+
return 2;
|
|
41
|
+
default:
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function PatternSwatch({
|
|
47
|
+
entry,
|
|
48
|
+
theme,
|
|
49
|
+
}: {
|
|
50
|
+
readonly entry: LegendEntry;
|
|
51
|
+
readonly theme: ChartTheme;
|
|
52
|
+
}) {
|
|
53
|
+
// Dashes are drawn as gapped segments so the pattern reads at swatch size —
|
|
54
|
+
// this is what gives a series an identity beyond its colour (FR-064).
|
|
55
|
+
const segments = segmentCountFor(entry.pattern);
|
|
56
|
+
const gap = segments > 1 ? 1 : 0;
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<View style={styles.swatchRow}>
|
|
60
|
+
{Array.from({ length: segments }, (_, i) => (
|
|
61
|
+
<View
|
|
62
|
+
key={i}
|
|
63
|
+
style={[
|
|
64
|
+
styles.swatchSegment,
|
|
65
|
+
entry.visible ? styles.swatchVisible : styles.swatchHidden,
|
|
66
|
+
{
|
|
67
|
+
width: 12 / segments - gap,
|
|
68
|
+
marginRight: gap,
|
|
69
|
+
backgroundColor: entry.visible ? entry.color : theme.muted,
|
|
70
|
+
},
|
|
71
|
+
]}
|
|
72
|
+
/>
|
|
73
|
+
))}
|
|
74
|
+
</View>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function ChartLegend({
|
|
79
|
+
entries,
|
|
80
|
+
theme,
|
|
81
|
+
onToggle,
|
|
82
|
+
}: {
|
|
83
|
+
readonly entries: readonly LegendEntry[];
|
|
84
|
+
readonly theme: ChartTheme;
|
|
85
|
+
readonly onToggle?: (seriesId: string) => void;
|
|
86
|
+
}) {
|
|
87
|
+
if (entries.length === 0) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<View style={styles.container} accessibilityRole="list">
|
|
93
|
+
{entries.map(entry => {
|
|
94
|
+
const content = (
|
|
95
|
+
<>
|
|
96
|
+
<PatternSwatch entry={entry} theme={theme} />
|
|
97
|
+
<Text
|
|
98
|
+
style={[
|
|
99
|
+
styles.label,
|
|
100
|
+
{
|
|
101
|
+
color: entry.visible
|
|
102
|
+
? theme.typography.legendLabel.color
|
|
103
|
+
: theme.muted,
|
|
104
|
+
fontSize: theme.typography.legendLabel.fontSize,
|
|
105
|
+
},
|
|
106
|
+
]}
|
|
107
|
+
numberOfLines={1}
|
|
108
|
+
>
|
|
109
|
+
{entry.label}
|
|
110
|
+
</Text>
|
|
111
|
+
</>
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
if (!entry.toggleable || !onToggle) {
|
|
115
|
+
return (
|
|
116
|
+
<View key={entry.seriesId} style={styles.entry} accessibilityRole="text">
|
|
117
|
+
{content}
|
|
118
|
+
</View>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<Pressable
|
|
124
|
+
key={entry.seriesId}
|
|
125
|
+
onPress={() => onToggle(entry.seriesId)}
|
|
126
|
+
accessibilityRole="switch"
|
|
127
|
+
accessibilityState={{ checked: entry.visible }}
|
|
128
|
+
accessibilityLabel={entry.label}
|
|
129
|
+
accessibilityHint={
|
|
130
|
+
entry.visible ? 'Hides this series' : 'Shows this series'
|
|
131
|
+
}
|
|
132
|
+
style={[styles.entry, styles.tappable]}
|
|
133
|
+
>
|
|
134
|
+
{content}
|
|
135
|
+
</Pressable>
|
|
136
|
+
);
|
|
137
|
+
})}
|
|
138
|
+
</View>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const styles = StyleSheet.create({
|
|
143
|
+
container: {
|
|
144
|
+
flexDirection: 'row',
|
|
145
|
+
flexWrap: 'wrap',
|
|
146
|
+
alignItems: 'center',
|
|
147
|
+
},
|
|
148
|
+
entry: {
|
|
149
|
+
flexDirection: 'row',
|
|
150
|
+
alignItems: 'center',
|
|
151
|
+
marginRight: 12,
|
|
152
|
+
marginTop: 4,
|
|
153
|
+
},
|
|
154
|
+
tappable: {
|
|
155
|
+
minHeight: MIN_TAP_TARGET,
|
|
156
|
+
paddingVertical: 4,
|
|
157
|
+
},
|
|
158
|
+
swatchRow: {
|
|
159
|
+
flexDirection: 'row',
|
|
160
|
+
alignItems: 'center',
|
|
161
|
+
width: 12,
|
|
162
|
+
marginRight: 6,
|
|
163
|
+
},
|
|
164
|
+
swatchSegment: {
|
|
165
|
+
height: 3,
|
|
166
|
+
borderRadius: 2,
|
|
167
|
+
},
|
|
168
|
+
swatchVisible: {
|
|
169
|
+
opacity: 1,
|
|
170
|
+
},
|
|
171
|
+
swatchHidden: {
|
|
172
|
+
opacity: 0.4,
|
|
173
|
+
},
|
|
174
|
+
label: {
|
|
175
|
+
flexShrink: 1,
|
|
176
|
+
},
|
|
177
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme resolution: prop -> context -> built-in default keyed on the OS scheme.
|
|
3
|
+
*
|
|
4
|
+
* The dependency is inverted on purpose. The package declares a `ChartTheme`
|
|
5
|
+
* value type and any design system can satisfy it, which is what keeps
|
|
6
|
+
* `@chipmobilesdk/rn-theme` and `react-native-unistyles` out of this package
|
|
7
|
+
* entirely (FR-065, LIB-004).
|
|
8
|
+
*/
|
|
9
|
+
import React, { createContext, useContext, useMemo } from 'react';
|
|
10
|
+
import { useColorScheme } from 'react-native';
|
|
11
|
+
|
|
12
|
+
import { defaultChartTheme, type ChartTheme } from '../contract/theme';
|
|
13
|
+
|
|
14
|
+
const ChartThemeContext = createContext<ChartTheme | undefined>(undefined);
|
|
15
|
+
|
|
16
|
+
export function ChartThemeProvider({
|
|
17
|
+
theme,
|
|
18
|
+
children,
|
|
19
|
+
}: {
|
|
20
|
+
readonly theme: ChartTheme;
|
|
21
|
+
readonly children: React.ReactNode;
|
|
22
|
+
}) {
|
|
23
|
+
return (
|
|
24
|
+
<ChartThemeContext.Provider value={theme}>
|
|
25
|
+
{children}
|
|
26
|
+
</ChartThemeContext.Provider>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* An explicit prop wins, then context, then the built-in default for the
|
|
32
|
+
* current OS colour scheme — so a chart renders correctly with no theme
|
|
33
|
+
* configuration at all.
|
|
34
|
+
*/
|
|
35
|
+
export function useChartTheme(explicit?: ChartTheme): ChartTheme {
|
|
36
|
+
const fromContext = useContext(ChartThemeContext);
|
|
37
|
+
const scheme = useColorScheme();
|
|
38
|
+
|
|
39
|
+
return useMemo(() => {
|
|
40
|
+
if (explicit) {
|
|
41
|
+
return explicit;
|
|
42
|
+
}
|
|
43
|
+
if (fromContext) {
|
|
44
|
+
return fromContext;
|
|
45
|
+
}
|
|
46
|
+
return defaultChartTheme(scheme === 'dark' ? 'dark' : 'light');
|
|
47
|
+
}, [explicit, fromContext, scheme]);
|
|
48
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The five chart family components.
|
|
3
|
+
*
|
|
4
|
+
* Each is a thin wrapper over `ChartFrame` plus its own geometry builder, so
|
|
5
|
+
* validation, state handling, theming, selection, accessibility, and failure
|
|
6
|
+
* containment behave identically across every family.
|
|
7
|
+
*
|
|
8
|
+
* All five are wrapped in `React.memo`: value-identical props stop at the memo
|
|
9
|
+
* boundary and never reach geometry (FR-073, SC-017).
|
|
10
|
+
*/
|
|
11
|
+
import React, { useCallback } from 'react';
|
|
12
|
+
|
|
13
|
+
import type { ChartDatasource } from '../contract/datasource';
|
|
14
|
+
import type { BaseChartProps } from '../contract/props';
|
|
15
|
+
import { buildBarGeometry, type BarGrouping } from '../geometry/bar';
|
|
16
|
+
import {
|
|
17
|
+
buildDistributionGeometry,
|
|
18
|
+
type DistributionShape,
|
|
19
|
+
} from '../geometry/distribution';
|
|
20
|
+
import { buildHeatmapGeometry } from '../geometry/heatmap';
|
|
21
|
+
import { buildProgressGeometry } from '../geometry/progress';
|
|
22
|
+
import { buildTimeSeriesGeometry } from '../geometry/timeSeries';
|
|
23
|
+
import type { GeometryInput } from '../geometry/types';
|
|
24
|
+
import { ChartFrame } from './ChartFrame';
|
|
25
|
+
|
|
26
|
+
export interface ProgressChartProps<M = unknown> extends BaseChartProps<M> {
|
|
27
|
+
readonly orientation?: 'horizontal' | 'vertical';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ProgressChartImpl<M>(props: ProgressChartProps<M>) {
|
|
31
|
+
const build = useCallback(
|
|
32
|
+
(ds: ChartDatasource<M>, input: GeometryInput) =>
|
|
33
|
+
buildProgressGeometry(ds, input, { orientation: props.orientation }),
|
|
34
|
+
[props.orientation],
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<ChartFrame
|
|
39
|
+
{...props}
|
|
40
|
+
family="progress"
|
|
41
|
+
buildGeometry={build}
|
|
42
|
+
showAxes={false}
|
|
43
|
+
defaultLegend={false}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface DistributionChartProps<M = unknown> extends BaseChartProps<M> {
|
|
49
|
+
readonly shape?: DistributionShape;
|
|
50
|
+
readonly innerRadiusRatio?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function DistributionChartImpl<M>(props: DistributionChartProps<M>) {
|
|
54
|
+
const build = useCallback(
|
|
55
|
+
(ds: ChartDatasource<M>, input: GeometryInput) =>
|
|
56
|
+
buildDistributionGeometry(ds, input, {
|
|
57
|
+
shape: props.shape,
|
|
58
|
+
innerRadiusRatio: props.innerRadiusRatio,
|
|
59
|
+
}),
|
|
60
|
+
[props.shape, props.innerRadiusRatio],
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<ChartFrame
|
|
65
|
+
{...props}
|
|
66
|
+
family="distribution"
|
|
67
|
+
buildGeometry={build}
|
|
68
|
+
showAxes={false}
|
|
69
|
+
defaultLegend
|
|
70
|
+
/>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface TimeSeriesChartProps<M = unknown> extends BaseChartProps<M> {
|
|
75
|
+
/** A window the app supplies. Users cannot pan or zoom (out of scope). */
|
|
76
|
+
readonly range?: { readonly from: number; readonly to: number };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function TimeSeriesChartImpl<M>(props: TimeSeriesChartProps<M>) {
|
|
80
|
+
const build = useCallback(
|
|
81
|
+
(ds: ChartDatasource<M>, input: GeometryInput) =>
|
|
82
|
+
buildTimeSeriesGeometry(ds, input, { range: props.range }),
|
|
83
|
+
[props.range],
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<ChartFrame
|
|
88
|
+
{...props}
|
|
89
|
+
family="timeSeries"
|
|
90
|
+
buildGeometry={build}
|
|
91
|
+
// A user aims at a moment in time, not at a pixel on the line.
|
|
92
|
+
hitTestMode="byX"
|
|
93
|
+
defaultLegend
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface BarChartProps<M = unknown> extends BaseChartProps<M> {
|
|
99
|
+
readonly grouping?: BarGrouping;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function BarChartImpl<M>(props: BarChartProps<M>) {
|
|
103
|
+
const build = useCallback(
|
|
104
|
+
(ds: ChartDatasource<M>, input: GeometryInput) =>
|
|
105
|
+
buildBarGeometry(ds, input, { grouping: props.grouping }),
|
|
106
|
+
[props.grouping],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<ChartFrame {...props} family="bar" buildGeometry={build} defaultLegend />
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface HeatmapChartProps<M = unknown> extends BaseChartProps<M> {
|
|
115
|
+
readonly buckets?: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function HeatmapChartImpl<M>(props: HeatmapChartProps<M>) {
|
|
119
|
+
const build = useCallback(
|
|
120
|
+
(ds: ChartDatasource<M>, input: GeometryInput) =>
|
|
121
|
+
buildHeatmapGeometry(ds, input, { buckets: props.buckets }),
|
|
122
|
+
[props.buckets],
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<ChartFrame
|
|
127
|
+
{...props}
|
|
128
|
+
family="heatmap"
|
|
129
|
+
buildGeometry={build}
|
|
130
|
+
showAxes={false}
|
|
131
|
+
defaultLegend={false}
|
|
132
|
+
/>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* `React.memo` erases the generic parameter, so each component is re-cast to a
|
|
138
|
+
* generic function type. This keeps `<BarChart<MyMeta> ... />` working for
|
|
139
|
+
* consumers while still getting the memo boundary.
|
|
140
|
+
*/
|
|
141
|
+
export const ProgressChart = React.memo(ProgressChartImpl) as <M = unknown>(
|
|
142
|
+
props: ProgressChartProps<M>,
|
|
143
|
+
) => React.ReactElement;
|
|
144
|
+
|
|
145
|
+
export const DistributionChart = React.memo(DistributionChartImpl) as <
|
|
146
|
+
M = unknown,
|
|
147
|
+
>(
|
|
148
|
+
props: DistributionChartProps<M>,
|
|
149
|
+
) => React.ReactElement;
|
|
150
|
+
|
|
151
|
+
export const TimeSeriesChart = React.memo(TimeSeriesChartImpl) as <M = unknown>(
|
|
152
|
+
props: TimeSeriesChartProps<M>,
|
|
153
|
+
) => React.ReactElement;
|
|
154
|
+
|
|
155
|
+
export const BarChart = React.memo(BarChartImpl) as <M = unknown>(
|
|
156
|
+
props: BarChartProps<M>,
|
|
157
|
+
) => React.ReactElement;
|
|
158
|
+
|
|
159
|
+
export const HeatmapChart = React.memo(HeatmapChartImpl) as <M = unknown>(
|
|
160
|
+
props: HeatmapChartProps<M>,
|
|
161
|
+
) => React.ReactElement;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Container measurement and the geometry memo.
|
|
3
|
+
*
|
|
4
|
+
* `ResolvedGeometry` is memoized on datasource identity, plot rectangle, theme
|
|
5
|
+
* identity, and formatter identity — and deliberately **not** on selection, so
|
|
6
|
+
* highlighting a point repaints the highlight without recomputing scales or
|
|
7
|
+
* path strings (FR-073, SC-017).
|
|
8
|
+
*
|
|
9
|
+
* A consequence worth documenting for consumers: rebuilding the datasource
|
|
10
|
+
* object inline on every render defeats this. The README says so, and the demo
|
|
11
|
+
* app models the correct pattern.
|
|
12
|
+
*/
|
|
13
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
14
|
+
import { PixelRatio, type LayoutChangeEvent } from 'react-native';
|
|
15
|
+
|
|
16
|
+
import type { ChartDatasource } from '../contract/datasource';
|
|
17
|
+
import { resolveSeriesColor, type ChartTheme } from '../contract/theme';
|
|
18
|
+
import {
|
|
19
|
+
EMPTY_PLOT,
|
|
20
|
+
isMeasurable,
|
|
21
|
+
resolvePlotRect,
|
|
22
|
+
type ContainerSize,
|
|
23
|
+
type PlotRect,
|
|
24
|
+
} from '../geometry/layout';
|
|
25
|
+
import { effectiveFontSize } from '../geometry/layout';
|
|
26
|
+
import type { GeometryInput } from '../geometry/types';
|
|
27
|
+
|
|
28
|
+
export interface ChartLayout {
|
|
29
|
+
readonly onLayout: (event: LayoutChangeEvent) => void;
|
|
30
|
+
readonly size: ContainerSize;
|
|
31
|
+
readonly measured: boolean;
|
|
32
|
+
readonly plot: PlotRect;
|
|
33
|
+
readonly fontSize: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function useChartLayout(
|
|
37
|
+
theme: ChartTheme,
|
|
38
|
+
options: {
|
|
39
|
+
readonly height?: number;
|
|
40
|
+
readonly xLabels?: readonly string[];
|
|
41
|
+
readonly yLabels?: readonly string[];
|
|
42
|
+
} = {},
|
|
43
|
+
): ChartLayout {
|
|
44
|
+
const [size, setSize] = useState<ContainerSize>({ width: 0, height: 0 });
|
|
45
|
+
|
|
46
|
+
const onLayout = useCallback((event: LayoutChangeEvent) => {
|
|
47
|
+
const { width, height } = event.nativeEvent.layout;
|
|
48
|
+
setSize(prev =>
|
|
49
|
+
// Avoid a state churn loop on sub-pixel layout jitter.
|
|
50
|
+
Math.abs(prev.width - width) < 0.5 && Math.abs(prev.height - height) < 0.5
|
|
51
|
+
? prev
|
|
52
|
+
: { width, height },
|
|
53
|
+
);
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
const fontScale = PixelRatio.getFontScale();
|
|
57
|
+
const fontSize = effectiveFontSize(theme.typography.axisLabel.fontSize, fontScale);
|
|
58
|
+
|
|
59
|
+
const measured = isMeasurable(size);
|
|
60
|
+
|
|
61
|
+
const plot = useMemo(() => {
|
|
62
|
+
if (!measured) {
|
|
63
|
+
// Wait for a real measurement rather than computing a broken layout
|
|
64
|
+
// or throwing (FR-078).
|
|
65
|
+
return EMPTY_PLOT;
|
|
66
|
+
}
|
|
67
|
+
return resolvePlotRect({
|
|
68
|
+
container: size,
|
|
69
|
+
xLabels: options.xLabels,
|
|
70
|
+
yLabels: options.yLabels,
|
|
71
|
+
baseFontSize: theme.typography.axisLabel.fontSize,
|
|
72
|
+
fontScale,
|
|
73
|
+
padding: theme.spacing.xs,
|
|
74
|
+
});
|
|
75
|
+
}, [
|
|
76
|
+
measured,
|
|
77
|
+
size,
|
|
78
|
+
options.xLabels,
|
|
79
|
+
options.yLabels,
|
|
80
|
+
theme.typography.axisLabel.fontSize,
|
|
81
|
+
theme.spacing.xs,
|
|
82
|
+
fontScale,
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
return { onLayout, size, measured, plot, fontSize };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Builds the geometry input, resolving per-series colours once so geometry
|
|
90
|
+
* modules never reach into the theme themselves.
|
|
91
|
+
*/
|
|
92
|
+
export function useGeometryInput<M>(
|
|
93
|
+
datasource: ChartDatasource<M>,
|
|
94
|
+
theme: ChartTheme,
|
|
95
|
+
layout: ChartLayout,
|
|
96
|
+
formatters: {
|
|
97
|
+
readonly formatValue: (value: number) => string;
|
|
98
|
+
readonly formatCategory: (raw: string) => string;
|
|
99
|
+
readonly formatInstant: (at: number) => string;
|
|
100
|
+
},
|
|
101
|
+
): GeometryInput {
|
|
102
|
+
return useMemo(() => {
|
|
103
|
+
const seriesColors: Record<string, string> = {};
|
|
104
|
+
datasource.series.forEach((series, index) => {
|
|
105
|
+
seriesColors[series.id] = resolveSeriesColor(theme, index, series.color);
|
|
106
|
+
// Distribution segments are coloured per point rather than per series,
|
|
107
|
+
// so give each point a palette entry too.
|
|
108
|
+
series.points.forEach((point, pointIndex) => {
|
|
109
|
+
seriesColors[point.id] = resolveSeriesColor(theme, pointIndex);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
plot: layout.plot,
|
|
115
|
+
fontSize: layout.fontSize,
|
|
116
|
+
seriesColors,
|
|
117
|
+
nonValueTextures: theme.nonValue,
|
|
118
|
+
heatmapRamp: theme.heatmapRamp,
|
|
119
|
+
formatValue: formatters.formatValue,
|
|
120
|
+
formatCategory: formatters.formatCategory,
|
|
121
|
+
formatInstant: formatters.formatInstant,
|
|
122
|
+
};
|
|
123
|
+
}, [datasource, theme, layout.plot, layout.fontSize, formatters]);
|
|
124
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selection ownership: uncontrolled by default, controlled when the app
|
|
3
|
+
* supplies a value (FR-054a).
|
|
4
|
+
*
|
|
5
|
+
* Two properties matter and both are load-bearing:
|
|
6
|
+
*
|
|
7
|
+
* - The selection is **resolved against the current datasource on every
|
|
8
|
+
* render** rather than stored resolved. A reference to a point that no
|
|
9
|
+
* longer exists therefore draws nothing, in either mode (FR-054).
|
|
10
|
+
* - Selection state lives outside the geometry memo, so highlighting repaints
|
|
11
|
+
* the highlight without recomputing scales or path strings (FR-073).
|
|
12
|
+
*/
|
|
13
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
PointInteractionEvent,
|
|
17
|
+
SelectionRef,
|
|
18
|
+
SeriesInteractionEvent,
|
|
19
|
+
} from '../contract/interaction';
|
|
20
|
+
import { sameSelection } from '../contract/interaction';
|
|
21
|
+
import { findRegion, type HitRegion } from '../geometry/hitTest';
|
|
22
|
+
|
|
23
|
+
export interface UseChartSelectionArgs<M> {
|
|
24
|
+
/** Present (including `null`) means the app owns the selection. */
|
|
25
|
+
readonly controlled?: SelectionRef | null;
|
|
26
|
+
readonly onSelectionChange?: (
|
|
27
|
+
next: SelectionRef | null,
|
|
28
|
+
event: PointInteractionEvent<M> | null,
|
|
29
|
+
) => void;
|
|
30
|
+
readonly onSelectionUnresolvable?: (ref: SelectionRef) => void;
|
|
31
|
+
readonly hitRegions: readonly HitRegion[];
|
|
32
|
+
/** Non-ready statuses suppress selection entirely (FR-053). */
|
|
33
|
+
readonly interactionSuppressed: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* False until the container has been measured and geometry exists.
|
|
36
|
+
*
|
|
37
|
+
* Without this, a chart that has not laid out yet has no hit regions, and a
|
|
38
|
+
* perfectly valid app-controlled selection would be reported as
|
|
39
|
+
* unresolvable on first paint — telling the app its state is wrong when it
|
|
40
|
+
* is not.
|
|
41
|
+
*/
|
|
42
|
+
readonly geometryReady: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ChartSelectionApi<M> {
|
|
46
|
+
/** What to draw. Never a reference the datasource cannot resolve. */
|
|
47
|
+
readonly resolved: HitRegion | undefined;
|
|
48
|
+
readonly selection: SelectionRef | null;
|
|
49
|
+
readonly select: (
|
|
50
|
+
ref: SelectionRef | null,
|
|
51
|
+
event: PointInteractionEvent<M> | null,
|
|
52
|
+
) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function useChartSelection<M>(
|
|
56
|
+
args: UseChartSelectionArgs<M>,
|
|
57
|
+
): ChartSelectionApi<M> {
|
|
58
|
+
const isControlled = args.controlled !== undefined;
|
|
59
|
+
const [internal, setInternal] = useState<SelectionRef | null>(null);
|
|
60
|
+
|
|
61
|
+
const selection = isControlled ? args.controlled ?? null : internal;
|
|
62
|
+
|
|
63
|
+
// Resolve fresh every render. A stale reference resolves to `undefined` and
|
|
64
|
+
// simply is not drawn.
|
|
65
|
+
const resolved =
|
|
66
|
+
selection && !args.interactionSuppressed
|
|
67
|
+
? findRegion(args.hitRegions, selection.seriesId, selection.pointId)
|
|
68
|
+
: undefined;
|
|
69
|
+
|
|
70
|
+
// Tell a controlling app when its selection stopped resolving, rather than
|
|
71
|
+
// silently ignoring it. Uncontrolled selections are cleared instead.
|
|
72
|
+
const reportedRef = useRef<string | null>(null);
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (
|
|
75
|
+
!selection ||
|
|
76
|
+
resolved ||
|
|
77
|
+
args.interactionSuppressed ||
|
|
78
|
+
!args.geometryReady
|
|
79
|
+
) {
|
|
80
|
+
reportedRef.current = null;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const key = `${selection.seriesId}:${selection.pointId ?? ''}`;
|
|
85
|
+
if (reportedRef.current === key) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
reportedRef.current = key;
|
|
89
|
+
|
|
90
|
+
if (isControlled) {
|
|
91
|
+
args.onSelectionUnresolvable?.(selection);
|
|
92
|
+
} else {
|
|
93
|
+
setInternal(null);
|
|
94
|
+
}
|
|
95
|
+
}, [selection, resolved, isControlled, args, args.geometryReady]);
|
|
96
|
+
|
|
97
|
+
const select = useCallback(
|
|
98
|
+
(ref: SelectionRef | null, event: PointInteractionEvent<M> | null) => {
|
|
99
|
+
if (args.interactionSuppressed) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (!isControlled) {
|
|
103
|
+
setInternal(prev => (sameSelection(prev, ref) ? null : ref));
|
|
104
|
+
}
|
|
105
|
+
args.onSelectionChange?.(ref, event);
|
|
106
|
+
},
|
|
107
|
+
[args, isControlled],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return { resolved, selection, select };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface UseSeriesVisibilityArgs<M> {
|
|
114
|
+
/** Present means the app owns visibility. */
|
|
115
|
+
readonly controlled?: readonly string[];
|
|
116
|
+
readonly onVisibilityChange?: (event: SeriesInteractionEvent<M>) => void;
|
|
117
|
+
/** Toggling stays off unless the app opts in (FR-049). */
|
|
118
|
+
readonly enabled: boolean;
|
|
119
|
+
/** Series marked hidden in the datasource itself. */
|
|
120
|
+
readonly initiallyHidden: readonly string[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface SeriesVisibilityApi {
|
|
124
|
+
readonly hidden: ReadonlySet<string>;
|
|
125
|
+
readonly toggle: (seriesId: string, metadata?: unknown) => void;
|
|
126
|
+
readonly enabled: boolean;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function useSeriesVisibility<M>(
|
|
130
|
+
args: UseSeriesVisibilityArgs<M>,
|
|
131
|
+
): SeriesVisibilityApi {
|
|
132
|
+
const isControlled = args.controlled !== undefined;
|
|
133
|
+
const [internal, setInternal] = useState<readonly string[]>(
|
|
134
|
+
args.initiallyHidden,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const hiddenList = isControlled ? args.controlled ?? [] : internal;
|
|
138
|
+
const hidden = new Set(hiddenList);
|
|
139
|
+
|
|
140
|
+
const toggle = useCallback(
|
|
141
|
+
(seriesId: string, metadata?: unknown) => {
|
|
142
|
+
if (!args.enabled) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const currentlyHidden = new Set(
|
|
147
|
+
isControlled ? args.controlled ?? [] : internal,
|
|
148
|
+
);
|
|
149
|
+
const willBeVisible = currentlyHidden.has(seriesId);
|
|
150
|
+
|
|
151
|
+
if (!isControlled) {
|
|
152
|
+
setInternal(prev =>
|
|
153
|
+
prev.includes(seriesId)
|
|
154
|
+
? prev.filter(id => id !== seriesId)
|
|
155
|
+
: [...prev, seriesId],
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
args.onVisibilityChange?.({
|
|
160
|
+
type: 'visibilityChange',
|
|
161
|
+
seriesId,
|
|
162
|
+
visible: willBeVisible,
|
|
163
|
+
metadata: metadata as M,
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
[args, internal, isControlled],
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
return { hidden, toggle, enabled: args.enabled };
|
|
170
|
+
}
|