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