@chipmobilesdk/rn-chart 0.1.1 → 0.2.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/CHANGELOG.md +126 -74
- package/README.md +337 -314
- package/package.json +46 -49
- package/skills/sdk-rn-chart/SKILL.md +1 -2
- package/src/a11y/summary.ts +170 -177
- package/src/contract/datasource.ts +126 -134
- package/src/contract/errors.ts +129 -133
- package/src/contract/format.ts +162 -168
- package/src/contract/interaction.ts +70 -71
- package/src/contract/marker.ts +75 -75
- package/src/contract/props.ts +57 -60
- package/src/contract/state.ts +88 -94
- package/src/contract/theme.ts +169 -139
- package/src/contract/value.ts +121 -125
- package/src/geometry/bar.ts +271 -256
- package/src/geometry/distribution.ts +197 -200
- package/src/geometry/heatmap.ts +189 -195
- package/src/geometry/hitTest.ts +117 -120
- package/src/geometry/layout.ts +162 -121
- package/src/geometry/markers.ts +55 -0
- package/src/geometry/path.ts +193 -199
- package/src/geometry/progress.ts +223 -204
- package/src/geometry/scale.ts +140 -146
- package/src/geometry/ticks.ts +145 -153
- package/src/geometry/timeSeries.ts +168 -158
- package/src/geometry/types.ts +94 -88
- package/src/index.ts +158 -176
- package/src/react/ChartBoundary.tsx +96 -96
- package/src/react/ChartFrame.tsx +583 -620
- package/src/react/ChartLegend.tsx +211 -177
- package/src/react/ChartThemeProvider.tsx +44 -48
- package/src/react/charts.tsx +154 -161
- package/src/react/useChartLayout.ts +124 -124
- package/src/react/useChartSelection.ts +152 -170
- package/src/render/Axis.tsx +120 -122
- package/src/render/Marks.tsx +173 -205
- package/src/validation/budgets.ts +103 -110
- package/src/validation/validateDatasource.ts +174 -174
package/src/geometry/progress.ts
CHANGED
|
@@ -1,204 +1,223 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Progress geometry: current value against an optional target.
|
|
3
|
-
*
|
|
4
|
-
* The rule that matters (FR-021, FR-022): with no usable target there is **no
|
|
5
|
-
* proportion**. The chart shows the current value and nothing else. And a value
|
|
6
|
-
* beyond its target renders as overshoot rather than a clamped full bar that
|
|
7
|
-
* hides the excess.
|
|
8
|
-
*
|
|
9
|
-
* PURE. No React, no SVG.
|
|
10
|
-
*/
|
|
11
|
-
import type { ChartDatasource } from '../contract/datasource';
|
|
12
|
-
import { findTarget, readProgress } from '../contract/marker';
|
|
13
|
-
import { isKnown, isWithheld } from '../contract/value';
|
|
14
|
-
import type { HitRegion } from './hitTest';
|
|
15
|
-
import { plotRight } from './layout';
|
|
16
|
-
import { rectSubpath } from './path';
|
|
17
|
-
import type {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
datasource
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Progress geometry: current value against an optional target.
|
|
3
|
+
*
|
|
4
|
+
* The rule that matters (FR-021, FR-022): with no usable target there is **no
|
|
5
|
+
* proportion**. The chart shows the current value and nothing else. And a value
|
|
6
|
+
* beyond its target renders as overshoot rather than a clamped full bar that
|
|
7
|
+
* hides the excess.
|
|
8
|
+
*
|
|
9
|
+
* PURE. No React, no SVG.
|
|
10
|
+
*/
|
|
11
|
+
import type { ChartDatasource } from '../contract/datasource';
|
|
12
|
+
import { findTarget, readProgress } from '../contract/marker';
|
|
13
|
+
import { isKnown, isWithheld } from '../contract/value';
|
|
14
|
+
import type { HitRegion } from './hitTest';
|
|
15
|
+
import { plotBottom, plotRight } from './layout';
|
|
16
|
+
import { rectSubpath } from './path';
|
|
17
|
+
import type { GeometryInput, GeometryLabel, MarkerLine, MarkPath, ResolvedGeometry } from './types';
|
|
18
|
+
|
|
19
|
+
export interface ProgressOptions {
|
|
20
|
+
readonly orientation?: 'horizontal' | 'vertical';
|
|
21
|
+
readonly trackRadius?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** How far past the target an overshoot bar is allowed to extend visually. */
|
|
25
|
+
const OVERSHOOT_DISPLAY_CAP = 1.25;
|
|
26
|
+
|
|
27
|
+
export function buildProgressGeometry<M>(
|
|
28
|
+
datasource: ChartDatasource<M>,
|
|
29
|
+
input: GeometryInput,
|
|
30
|
+
options: ProgressOptions = {},
|
|
31
|
+
): ResolvedGeometry {
|
|
32
|
+
const { plot } = input;
|
|
33
|
+
const metrics = datasource.series.flatMap(series =>
|
|
34
|
+
series.points.map(point => ({ series, point })),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
if (metrics.length === 0 || plot.width <= 0) {
|
|
38
|
+
return {
|
|
39
|
+
plot,
|
|
40
|
+
paths: [],
|
|
41
|
+
markers: [],
|
|
42
|
+
labels: [],
|
|
43
|
+
xTicks: [],
|
|
44
|
+
yTicks: [],
|
|
45
|
+
hitRegions: [],
|
|
46
|
+
incomplete: false,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const readings = metrics.map(({ point }) =>
|
|
51
|
+
readProgress(
|
|
52
|
+
point.value,
|
|
53
|
+
findTarget(datasource.markers, point.id) ?? findTarget(datasource.markers),
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// The track spans 0..displayMax of the target, shared by every row so their
|
|
58
|
+
// targets line up. With an overshoot the target sits inside the plot and the
|
|
59
|
+
// excess is drawn to its right — previously the target was the plot's right
|
|
60
|
+
// edge, so overshoot started there and was clipped by the SVG (CHT-03).
|
|
61
|
+
const displayMax = readings.reduce(
|
|
62
|
+
(max, r) =>
|
|
63
|
+
r.proportion === undefined
|
|
64
|
+
? max
|
|
65
|
+
: Math.max(max, Math.min(r.proportion, OVERSHOOT_DISPLAY_CAP)),
|
|
66
|
+
1,
|
|
67
|
+
);
|
|
68
|
+
const unit = plot.width / displayMax;
|
|
69
|
+
const targetX = plot.x + unit;
|
|
70
|
+
|
|
71
|
+
const rowHeight = plot.height / metrics.length;
|
|
72
|
+
const barHeight = Math.min(rowHeight * 0.45, 24);
|
|
73
|
+
const radius = options.trackRadius ?? barHeight / 2;
|
|
74
|
+
|
|
75
|
+
const paths: MarkPath[] = [];
|
|
76
|
+
const labels: GeometryLabel[] = [];
|
|
77
|
+
const hitRegions: HitRegion[] = [];
|
|
78
|
+
const markers: MarkerLine[] = [];
|
|
79
|
+
let incomplete = false;
|
|
80
|
+
|
|
81
|
+
metrics.forEach(({ series, point }, index) => {
|
|
82
|
+
const rowY = plot.y + index * rowHeight + (rowHeight - barHeight) / 2;
|
|
83
|
+
const reading = readings[index];
|
|
84
|
+
|
|
85
|
+
// Track: the full width, always drawn, so an empty metric still reads as
|
|
86
|
+
// a metric rather than as blank space.
|
|
87
|
+
paths.push({
|
|
88
|
+
key: `track-${series.id}-${point.id}`,
|
|
89
|
+
seriesId: series.id,
|
|
90
|
+
d: rectSubpath({
|
|
91
|
+
x: plot.x,
|
|
92
|
+
y: rowY,
|
|
93
|
+
width: plot.width,
|
|
94
|
+
height: barHeight,
|
|
95
|
+
radius,
|
|
96
|
+
}),
|
|
97
|
+
color: input.seriesColors[series.id],
|
|
98
|
+
pattern: 'solid',
|
|
99
|
+
filled: true,
|
|
100
|
+
opacity: 0.15,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (!isKnown(point.value)) {
|
|
104
|
+
incomplete = true;
|
|
105
|
+
// No fill at all, and a texture rather than a zero-length bar — a gap
|
|
106
|
+
// must not look like "none of the target achieved".
|
|
107
|
+
paths.push({
|
|
108
|
+
key: `nonvalue-${series.id}-${point.id}`,
|
|
109
|
+
seriesId: series.id,
|
|
110
|
+
d: rectSubpath({
|
|
111
|
+
x: plot.x,
|
|
112
|
+
y: rowY,
|
|
113
|
+
width: plot.width,
|
|
114
|
+
height: barHeight,
|
|
115
|
+
radius,
|
|
116
|
+
}),
|
|
117
|
+
color: input.seriesColors[series.id],
|
|
118
|
+
pattern: 'solid',
|
|
119
|
+
filled: true,
|
|
120
|
+
texture: input.nonValueTextures[point.value.kind],
|
|
121
|
+
opacity: 0.5,
|
|
122
|
+
});
|
|
123
|
+
} else {
|
|
124
|
+
const proportion = reading.proportion;
|
|
125
|
+
// With no usable target there is no proportion to draw, so the fill
|
|
126
|
+
// shows the value's presence rather than a fabricated percentage.
|
|
127
|
+
const fillFraction =
|
|
128
|
+
proportion === undefined ? 1 : Math.min(proportion, OVERSHOOT_DISPLAY_CAP);
|
|
129
|
+
|
|
130
|
+
paths.push({
|
|
131
|
+
key: `fill-${series.id}-${point.id}`,
|
|
132
|
+
seriesId: series.id,
|
|
133
|
+
d: rectSubpath({
|
|
134
|
+
x: plot.x,
|
|
135
|
+
y: rowY,
|
|
136
|
+
width: Math.max(0, unit * Math.min(fillFraction, 1)),
|
|
137
|
+
height: barHeight,
|
|
138
|
+
radius,
|
|
139
|
+
}),
|
|
140
|
+
color: input.seriesColors[series.id],
|
|
141
|
+
pattern: 'solid',
|
|
142
|
+
filled: true,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Overshoot is drawn beyond the target line rather than clamped away,
|
|
146
|
+
// so exceeding a goal is visible instead of indistinguishable from
|
|
147
|
+
// exactly meeting it.
|
|
148
|
+
if (reading.overshoot && proportion !== undefined) {
|
|
149
|
+
const overshootWidth = unit * (Math.min(proportion, OVERSHOOT_DISPLAY_CAP) - 1);
|
|
150
|
+
paths.push({
|
|
151
|
+
key: `overshoot-${series.id}-${point.id}`,
|
|
152
|
+
seriesId: series.id,
|
|
153
|
+
d: rectSubpath({
|
|
154
|
+
x: targetX,
|
|
155
|
+
y: rowY + barHeight * 0.2,
|
|
156
|
+
width: Math.max(0, overshootWidth),
|
|
157
|
+
height: barHeight * 0.6,
|
|
158
|
+
radius: radius / 2,
|
|
159
|
+
}),
|
|
160
|
+
color: input.seriesColors[series.id],
|
|
161
|
+
pattern: 'dashed',
|
|
162
|
+
filled: true,
|
|
163
|
+
opacity: 0.7,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// The target itself, across this row's bar (FR-022, FR-072).
|
|
169
|
+
if (reading.proportion !== undefined && reading.target) {
|
|
170
|
+
markers.push({
|
|
171
|
+
id: `target-${series.id}-${point.id}`,
|
|
172
|
+
d: `M${targetX},${Math.max(plot.y, rowY - 3)}V${Math.min(plotBottom(plot), rowY + barHeight + 3)}`,
|
|
173
|
+
label: reading.target.label,
|
|
174
|
+
kind: 'target',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const valueText = isKnown(point.value) ? input.formatValue(point.value.value) : '';
|
|
179
|
+
// Only append a percentage when one is real.
|
|
180
|
+
const proportionText =
|
|
181
|
+
reading.proportion === undefined ? '' : ` (${Math.round(reading.proportion * 100)}%)`;
|
|
182
|
+
|
|
183
|
+
labels.push({
|
|
184
|
+
key: `label-${series.id}-${point.id}`,
|
|
185
|
+
text: input.formatCategory(
|
|
186
|
+
point.position.kind === 'category' ? point.position.category : series.label,
|
|
187
|
+
),
|
|
188
|
+
x: plot.x,
|
|
189
|
+
y: rowY - 4,
|
|
190
|
+
anchor: 'start',
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (valueText) {
|
|
194
|
+
labels.push({
|
|
195
|
+
key: `value-${series.id}-${point.id}`,
|
|
196
|
+
text: `${valueText}${proportionText}`,
|
|
197
|
+
x: plotRight(plot),
|
|
198
|
+
y: rowY - 4,
|
|
199
|
+
anchor: 'end',
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
hitRegions.push({
|
|
204
|
+
seriesId: series.id,
|
|
205
|
+
pointId: point.id,
|
|
206
|
+
x: plot.x + plot.width / 2,
|
|
207
|
+
y: rowY + barHeight / 2,
|
|
208
|
+
bounds: { x: plot.x, y: rowY, width: plot.width, height: barHeight },
|
|
209
|
+
selectable: !isWithheld(point.value),
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
plot,
|
|
215
|
+
paths,
|
|
216
|
+
markers,
|
|
217
|
+
labels,
|
|
218
|
+
xTicks: [],
|
|
219
|
+
yTicks: [],
|
|
220
|
+
hitRegions,
|
|
221
|
+
incomplete,
|
|
222
|
+
};
|
|
223
|
+
}
|