@chipmobilesdk/rn-chart 0.1.0 → 0.2.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 +41 -0
- package/README.md +69 -46
- package/package.json +10 -9
- package/skills/sdk-rn-chart/SKILL.md +69 -0
- package/src/contract/datasource.ts +2 -0
- package/src/contract/theme.ts +38 -0
- package/src/geometry/bar.ts +40 -23
- package/src/geometry/distribution.ts +5 -2
- package/src/geometry/layout.ts +53 -1
- package/src/geometry/markers.ts +57 -0
- package/src/geometry/progress.ts +34 -8
- package/src/geometry/scale.ts +1 -1
- package/src/geometry/timeSeries.ts +26 -16
- package/src/geometry/types.ts +6 -0
- package/src/react/ChartFrame.tsx +3 -1
- package/src/react/ChartLegend.tsx +42 -4
- package/src/react/useChartLayout.ts +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,47 @@ All notable changes to `@chipmobilesdk/rn-chart` are documented here.
|
|
|
5
5
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
6
6
|
this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.0] - 2026-11-01
|
|
9
|
+
|
|
10
|
+
Three drawing defects on the production path. **No code change is required.**
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Axis labels are no longer clipped.** The frame handed every builder the
|
|
15
|
+
full container, so Y labels (right-aligned left of the plot) were drawn at
|
|
16
|
+
negative x and X labels fell below the bottom of the SVG: on a device a bar
|
|
17
|
+
or time-series chart showed no value scale and cut-off dates. Builders now
|
|
18
|
+
reserve room for their own labels before projecting anything, in one pass.
|
|
19
|
+
- **Distribution legends match their segments.** Segments were coloured by
|
|
20
|
+
point index while the legend listed series, so a one-series donut of four
|
|
21
|
+
segments had four colours and one legend entry; four one-point series all
|
|
22
|
+
drew in the first palette colour. Segment colours now come from one resolver
|
|
23
|
+
shared by the arcs and the legend, which has one entry per segment. Point
|
|
24
|
+
colours also had their own map: they shared the series map, so a point id
|
|
25
|
+
equal to a series id overwrote that series' colour, in every family.
|
|
26
|
+
- **Reference markers are drawn.** Every builder returned no markers, so
|
|
27
|
+
targets, baselines and thresholds reached the accessibility summary only.
|
|
28
|
+
Bar and time-series charts now draw them across the plot (with the domain
|
|
29
|
+
stretched to include them); progress draws its target on each bar.
|
|
30
|
+
- **Progress overshoot stays inside the plot.** It started at the plot's
|
|
31
|
+
right edge and was clipped, so 118% looked like 100%. The track is now scaled
|
|
32
|
+
so the target sits inside the plot and the excess is visible beyond it.
|
|
33
|
+
|
|
34
|
+
- **No phantom axis below zero on all-positive bars.** The domain was padded
|
|
35
|
+
past zero on both sides, so a bar chart with no negative value printed a
|
|
36
|
+
negative tick (`-5.5`) on top of the `0` label once labels became visible.
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- `DataPoint.color`, a per-segment colour for distribution charts.
|
|
41
|
+
- `LegendEntry.pointId`, set on distribution entries.
|
|
42
|
+
- `buildLegendEntries(..., family)`: pass `'distribution'` for segment entries.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- The plot area of bar and time-series charts is smaller by the space its axis
|
|
47
|
+
labels need, and `ResolvedGeometry.plot` reports that inner rectangle.
|
|
48
|
+
|
|
8
49
|
## [0.1.0] - Unreleased
|
|
9
50
|
|
|
10
51
|
### Added
|
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# @chipmobilesdk/rn-chart
|
|
2
2
|
|
|
3
|
-
Domain-agnostic charts for ChipMobileSdk-family React Native apps.
|
|
4
|
-
|
|
5
|
-
> **License:** `UNLICENSED`. Published publicly for use by the owner's
|
|
6
|
-
> applications; no open-source license is granted.
|
|
7
|
-
|
|
8
|
-
- **Version**: `0.
|
|
9
|
-
- **Entrypoint**: `@chipmobilesdk/rn-chart`
|
|
3
|
+
Domain-agnostic charts for ChipMobileSdk-family React Native apps.
|
|
4
|
+
|
|
5
|
+
> **License:** `UNLICENSED`. Published publicly for use by the owner's
|
|
6
|
+
> applications; no open-source license is granted.
|
|
7
|
+
|
|
8
|
+
- **Version**: `0.2.x` (see [CHANGELOG](CHANGELOG.md))
|
|
9
|
+
- **Entrypoint**: `@chipmobilesdk/rn-chart`
|
|
10
10
|
|
|
11
11
|
One datasource contract spans five chart families. The package knows nothing
|
|
12
12
|
about nutrition, finance, health, or productivity — you hand it aggregated,
|
|
@@ -133,6 +133,23 @@ error**, not a rendering guess.
|
|
|
133
133
|
Time is epoch milliseconds and carries no timezone. All date wording comes from
|
|
134
134
|
your formatters.
|
|
135
135
|
|
|
136
|
+
### Reference markers
|
|
137
|
+
|
|
138
|
+
`datasource.markers` holds targets, baselines and thresholds. Those with a known
|
|
139
|
+
value are drawn as dashed lines:
|
|
140
|
+
|
|
141
|
+
- `BarChart` and `TimeSeriesChart` draw each one across the plot, with its
|
|
142
|
+
`label` at the right end. The value axis is stretched to include it, so a
|
|
143
|
+
threshold above every data point is still on the chart.
|
|
144
|
+
- `ProgressChart` draws the target on each row's bar. A value past its target
|
|
145
|
+
is drawn as overshoot beyond that line, inside the plot: the track is scaled
|
|
146
|
+
so the target sits at `1 / min(largest proportion, 1.25)` of its width.
|
|
147
|
+
- `DistributionChart` and `HeatmapChart` ignore markers.
|
|
148
|
+
|
|
149
|
+
Axis labels have room reserved for them: the plot shrinks to fit the widest
|
|
150
|
+
value label on the left and one line of time or category labels below, so no
|
|
151
|
+
label is clipped by the chart's edge.
|
|
152
|
+
|
|
136
153
|
## States
|
|
137
154
|
|
|
138
155
|
Seven, and the package derives the two that only the data can determine:
|
|
@@ -192,20 +209,20 @@ ownership; the same callbacks fire either way.
|
|
|
192
209
|
/>
|
|
193
210
|
```
|
|
194
211
|
|
|
195
|
-
Every payload carries the point id, series id, value, position, and whatever
|
|
196
|
-
metadata you attached. The package never navigates, fetches, or mutates.
|
|
197
|
-
|
|
198
|
-
Legends can also toggle series visibility. The state is uncontrolled unless
|
|
199
|
-
you supply `hiddenSeriesIds`:
|
|
200
|
-
|
|
201
|
-
```tsx
|
|
202
|
-
<BarChart
|
|
203
|
-
datasource={ds}
|
|
204
|
-
seriesToggleEnabled
|
|
205
|
-
hiddenSeriesIds={hiddenSeriesIds}
|
|
206
|
-
onVisibilityChange={event => updateHiddenSeries(event.seriesId)}
|
|
207
|
-
/>
|
|
208
|
-
```
|
|
212
|
+
Every payload carries the point id, series id, value, position, and whatever
|
|
213
|
+
metadata you attached. The package never navigates, fetches, or mutates.
|
|
214
|
+
|
|
215
|
+
Legends can also toggle series visibility. The state is uncontrolled unless
|
|
216
|
+
you supply `hiddenSeriesIds`:
|
|
217
|
+
|
|
218
|
+
```tsx
|
|
219
|
+
<BarChart
|
|
220
|
+
datasource={ds}
|
|
221
|
+
seriesToggleEnabled
|
|
222
|
+
hiddenSeriesIds={hiddenSeriesIds}
|
|
223
|
+
onVisibilityChange={event => updateHiddenSeries(event.seriesId)}
|
|
224
|
+
/>
|
|
225
|
+
```
|
|
209
226
|
|
|
210
227
|
Pan, pinch-zoom, and brush-to-select are out of scope. `range` on
|
|
211
228
|
`TimeSeriesChart` is a window **you** supply, not one the user draws.
|
|
@@ -235,6 +252,12 @@ correctly in light and dark.
|
|
|
235
252
|
Meaning never rides on colour alone: series carry dash patterns as well as
|
|
236
253
|
colours, and non-value classes use textures.
|
|
237
254
|
|
|
255
|
+
`DistributionChart` colours **segments**, not series. Palette entries are taken
|
|
256
|
+
in order across every point, so one series of four points and four series of
|
|
257
|
+
one point both give four colours. A point's own `color` overrides its segment;
|
|
258
|
+
a series with a single point is that segment, so its series `color` applies.
|
|
259
|
+
The legend has one entry per segment, in the same colour as its arc.
|
|
260
|
+
|
|
238
261
|
## Formatting
|
|
239
262
|
|
|
240
263
|
```tsx
|
|
@@ -304,32 +327,32 @@ If you render your own narrative text near a chart — particularly text from an
|
|
|
304
327
|
AI service or from users — moderation and content obligations are entirely
|
|
305
328
|
yours. The package ships no free-text surface at all.
|
|
306
329
|
|
|
307
|
-
## Public API
|
|
330
|
+
## Public API
|
|
308
331
|
|
|
309
332
|
`src/index.ts` is the only entrypoint. Geometry, scales, layout maths, path
|
|
310
333
|
builders, the chart shell, and the SVG adapter are private so they can change
|
|
311
334
|
without a breaking release.
|
|
312
335
|
|
|
313
|
-
**Stable**: value semantics, `Position`, the datasource types, the five chart
|
|
314
|
-
components and their base props, `ChartStatus`, the error types, interaction
|
|
315
|
-
events.
|
|
316
|
-
|
|
317
|
-
The single entrypoint exports these public groups:
|
|
318
|
-
|
|
319
|
-
- Components: `BarChart`, `DistributionChart`, `HeatmapChart`,
|
|
320
|
-
`ProgressChart`, `TimeSeriesChart`, `ChartLegend`, `ChartThemeProvider`,
|
|
321
|
-
`useChartTheme`, `buildLegendEntries`, and their props
|
|
322
|
-
- Values and datasource: `known`, `unknown`, `notApplicable`, `withheld`,
|
|
323
|
-
`isKnown`, `isWithheld`, `sumKnown`, position/series/datasource types,
|
|
324
|
-
marker helpers, and format resolution helpers
|
|
325
|
-
- State and validation: `resolveStatus`, `drawsData`, `suppressesInteraction`,
|
|
326
|
-
`STATUS_PRECEDENCE`, `DATASET_BUDGETS`, `checkBudget`, error/failure types,
|
|
327
|
-
and `isChartError`
|
|
328
|
-
- Interaction: selection and series interaction types plus `sameSelection`
|
|
329
|
-
- Theme and formatting: `defaultChartTheme`, `resolveSeriesColor`, default
|
|
330
|
-
formatter helpers, `NON_VALUE_TEXT`, and public theme/formatter contracts
|
|
331
|
-
- Accessibility: `buildChartSummary`, `buildPointAnnouncement`, and
|
|
332
|
-
`buildTotalAnnouncement`
|
|
336
|
+
**Stable**: value semantics, `Position`, the datasource types, the five chart
|
|
337
|
+
components and their base props, `ChartStatus`, the error types, interaction
|
|
338
|
+
events.
|
|
339
|
+
|
|
340
|
+
The single entrypoint exports these public groups:
|
|
341
|
+
|
|
342
|
+
- Components: `BarChart`, `DistributionChart`, `HeatmapChart`,
|
|
343
|
+
`ProgressChart`, `TimeSeriesChart`, `ChartLegend`, `ChartThemeProvider`,
|
|
344
|
+
`useChartTheme`, `buildLegendEntries`, and their props
|
|
345
|
+
- Values and datasource: `known`, `unknown`, `notApplicable`, `withheld`,
|
|
346
|
+
`isKnown`, `isWithheld`, `sumKnown`, position/series/datasource types,
|
|
347
|
+
marker helpers, and format resolution helpers
|
|
348
|
+
- State and validation: `resolveStatus`, `drawsData`, `suppressesInteraction`,
|
|
349
|
+
`STATUS_PRECEDENCE`, `DATASET_BUDGETS`, `checkBudget`, error/failure types,
|
|
350
|
+
and `isChartError`
|
|
351
|
+
- Interaction: selection and series interaction types plus `sameSelection`
|
|
352
|
+
- Theme and formatting: `defaultChartTheme`, `resolveSeriesColor`, default
|
|
353
|
+
formatter helpers, `NON_VALUE_TEXT`, and public theme/formatter contracts
|
|
354
|
+
- Accessibility: `buildChartSummary`, `buildPointAnnouncement`, and
|
|
355
|
+
`buildTotalAnnouncement`
|
|
333
356
|
|
|
334
357
|
**Experimental** — may change in a minor: `ChartTheme` token set, `LegendEntry`,
|
|
335
358
|
family-specific props (`shape`, `grouping`, `buckets`), and the
|
|
@@ -352,7 +375,7 @@ npm run lint
|
|
|
352
375
|
npm test
|
|
353
376
|
npm run typecheck:chart
|
|
354
377
|
npm run pack:chart
|
|
355
|
-
npm run validate:chart # all of the above
|
|
356
|
-
```
|
|
357
|
-
|
|
358
|
-
The demo integration lives in `src/screens/ChartDemoScreen.tsx`.
|
|
378
|
+
npm run validate:chart # all of the above
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
The demo integration lives in `src/screens/ChartDemoScreen.tsx`.
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chipmobilesdk/rn-chart",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"private": false,
|
|
5
|
-
"license": "UNLICENSED",
|
|
6
|
-
"main": "src/index.ts",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"src/react",
|
|
21
21
|
"src/render",
|
|
22
22
|
"src/validation",
|
|
23
|
+
"skills",
|
|
23
24
|
"README.md",
|
|
24
25
|
"CHANGELOG.md"
|
|
25
26
|
],
|
|
@@ -41,8 +42,8 @@
|
|
|
41
42
|
"engines": {
|
|
42
43
|
"node": ">=22.11.0"
|
|
43
44
|
},
|
|
44
|
-
"publishConfig": {
|
|
45
|
-
"access": "public",
|
|
46
|
-
"registry": "https://registry.npmjs.org/"
|
|
47
|
-
}
|
|
48
|
-
}
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public",
|
|
47
|
+
"registry": "https://registry.npmjs.org/"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sdk-rn-chart
|
|
3
|
+
description: "Charts via @chipmobilesdk/rn-chart: datasource contract, series theming, legends, selection, dataset budgets, chart accessibility. Use when rendering a chart, wiring chart data, theming series, or hitting a dataset size limit."
|
|
4
|
+
metadata:
|
|
5
|
+
package: "@chipmobilesdk/rn-chart"
|
|
6
|
+
packageVersion: "0.1.0"
|
|
7
|
+
user-invocable: true
|
|
8
|
+
disable-model-invocation: false
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# @chipmobilesdk/rn-chart
|
|
12
|
+
|
|
13
|
+
Chart rendering with an explicit datasource contract, its own theme provider,
|
|
14
|
+
accessibility support and enforced dataset budgets.
|
|
15
|
+
|
|
16
|
+
## Use this when
|
|
17
|
+
|
|
18
|
+
- Rendering any chart or adding a series
|
|
19
|
+
- Shaping data to the datasource contract
|
|
20
|
+
- Theming series colors or building a legend
|
|
21
|
+
- Handling selection / interaction on a chart
|
|
22
|
+
- A chart refuses to render a large dataset
|
|
23
|
+
|
|
24
|
+
## Check what it deliberately does not do
|
|
25
|
+
|
|
26
|
+
The README opens with `## What it does not do`. Read that first — it prevents building
|
|
27
|
+
a feature this package intentionally excludes.
|
|
28
|
+
|
|
29
|
+
## Datasets are budgeted
|
|
30
|
+
|
|
31
|
+
`DATASET_BUDGETS` and `checkBudget` enforce size limits so charts stay at 60 FPS on
|
|
32
|
+
low-end devices. When a budget is exceeded, aggregate or window the data — do not raise
|
|
33
|
+
the budget. See `## Dataset budgets`.
|
|
34
|
+
|
|
35
|
+
## Chart theming is separate from app theming
|
|
36
|
+
|
|
37
|
+
Charts use `ChartThemeProvider` / `useChartTheme`, not the app-wide theme provider from
|
|
38
|
+
`sdk-rn-theme`. Series colors come from `resolveSeriesColor`, never from hardcoded values.
|
|
39
|
+
|
|
40
|
+
## Public API (v0.2.0)
|
|
41
|
+
|
|
42
|
+
Runtime:
|
|
43
|
+
|
|
44
|
+
- `ChartThemeProvider`, `useChartTheme`, `defaultChartTheme`, `resolveSeriesColor`
|
|
45
|
+
- `ChartLegend`, `buildLegendEntries`
|
|
46
|
+
- `findTarget`, `isUsableTarget`, `readProgress`, `sameSelection`
|
|
47
|
+
- `DATASET_BUDGETS`, `checkBudget`
|
|
48
|
+
- `isChartError`
|
|
49
|
+
|
|
50
|
+
Types: `MarkerKind`, `ProgressReading`, `ReferenceMarker`, `BudgetCheck`, `DatasetBudget`
|
|
51
|
+
|
|
52
|
+
Use `sameSelection` for selection equality — comparing selection objects by reference
|
|
53
|
+
causes redundant re-renders.
|
|
54
|
+
|
|
55
|
+
## Where the detail lives
|
|
56
|
+
|
|
57
|
+
Read one section of `node_modules/@chipmobilesdk/rn-chart/README.md` (~360 lines):
|
|
58
|
+
|
|
59
|
+
| Need | Section |
|
|
60
|
+
|---|---|
|
|
61
|
+
| Scope check | `## What it does not do` |
|
|
62
|
+
| First integration | `## Install`, `## Quick start` |
|
|
63
|
+
| Shaping data | `## The datasource contract`, `## Value semantics` |
|
|
64
|
+
| Loading/empty/error rendering | `## States` |
|
|
65
|
+
| Touch and selection | `## Interaction` |
|
|
66
|
+
| Colors and legends | `## Theming` |
|
|
67
|
+
| Axis/label formatting | `## Formatting` |
|
|
68
|
+
| Screen reader support | `## Accessibility` |
|
|
69
|
+
| Size limits | `## Dataset budgets` |
|
|
@@ -61,6 +61,8 @@ export interface DataPoint<M = unknown> {
|
|
|
61
61
|
readonly unit?: string;
|
|
62
62
|
/** Overrides the series format hint. */
|
|
63
63
|
readonly format?: FormatHint;
|
|
64
|
+
/** Colour of this point's segment in a distribution chart. Ignored elsewhere. */
|
|
65
|
+
readonly color?: string;
|
|
64
66
|
/** Opaque to the package: never read, never logged, returned unchanged. */
|
|
65
67
|
readonly metadata?: M;
|
|
66
68
|
}
|
package/src/contract/theme.ts
CHANGED
|
@@ -53,6 +53,8 @@ export interface ChartTheme {
|
|
|
53
53
|
|
|
54
54
|
export interface LegendEntry {
|
|
55
55
|
readonly seriesId: string;
|
|
56
|
+
/** Set on distribution entries, which describe one segment rather than a series. */
|
|
57
|
+
readonly pointId?: string;
|
|
56
58
|
readonly label: string;
|
|
57
59
|
readonly color: string;
|
|
58
60
|
readonly pattern: 'solid' | 'dashed' | 'dotted' | 'dashDot';
|
|
@@ -137,3 +139,39 @@ export function resolveSeriesColor(
|
|
|
137
139
|
): string {
|
|
138
140
|
return override ?? theme.palette[index % theme.palette.length];
|
|
139
141
|
}
|
|
142
|
+
|
|
143
|
+
/** Key of one distribution segment: point ids are only unique within a series. */
|
|
144
|
+
export function segmentKey(seriesId: string, pointId: string): string {
|
|
145
|
+
return `${seriesId}/${pointId}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Colours for distribution segments, keyed by `segmentKey`. The single source
|
|
150
|
+
* for both the arcs and the legend, so the two cannot disagree (CHT-02).
|
|
151
|
+
*
|
|
152
|
+
* Segments take palette entries in order across the whole datasource, so one
|
|
153
|
+
* series of four points and four series of one point both give four colours.
|
|
154
|
+
* A point's own `color` wins; a series with a single point is that segment, so
|
|
155
|
+
* the series `color` applies to it.
|
|
156
|
+
*/
|
|
157
|
+
export function resolveSegmentColors(
|
|
158
|
+
datasource: {
|
|
159
|
+
readonly series: readonly {
|
|
160
|
+
readonly id: string;
|
|
161
|
+
readonly color?: string;
|
|
162
|
+
readonly points: readonly { readonly id: string; readonly color?: string }[];
|
|
163
|
+
}[];
|
|
164
|
+
},
|
|
165
|
+
theme: ChartTheme,
|
|
166
|
+
): Record<string, string> {
|
|
167
|
+
const colors: Record<string, string> = {};
|
|
168
|
+
let index = 0;
|
|
169
|
+
for (const series of datasource.series) {
|
|
170
|
+
for (const point of series.points) {
|
|
171
|
+
const override = point.color ?? (series.points.length === 1 ? series.color : undefined);
|
|
172
|
+
colors[segmentKey(series.id, point.id)] = resolveSeriesColor(theme, index, override);
|
|
173
|
+
index += 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return colors;
|
|
177
|
+
}
|
package/src/geometry/bar.ts
CHANGED
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
import type { ChartDatasource, Position, Series } from '../contract/datasource';
|
|
11
11
|
import { isKnown, isWithheld } from '../contract/value';
|
|
12
12
|
import type { HitRegion } from './hitTest';
|
|
13
|
-
import { plotBottom, plotRight } from './layout';
|
|
13
|
+
import { insetForAxes, plotBottom, plotRight } from './layout';
|
|
14
|
+
import { horizontalMarkers, markerDomainValues } from './markers';
|
|
14
15
|
import { rectBatchPath, type RectSpec } from './path';
|
|
15
|
-
import { bandScale, domainFrom, linearScale } from './scale';
|
|
16
|
+
import { bandScale, domainFrom, linearScale, widenDegenerateDomain } from './scale';
|
|
16
17
|
import { thinTicks, type Tick } from './ticks';
|
|
17
18
|
import type { GeometryInput, MarkPath, ResolvedGeometry } from './types';
|
|
18
19
|
|
|
@@ -135,7 +136,6 @@ export function buildBarGeometry<M>(
|
|
|
135
136
|
input: GeometryInput,
|
|
136
137
|
options: BarOptions = {},
|
|
137
138
|
): ResolvedGeometry {
|
|
138
|
-
const { plot } = input;
|
|
139
139
|
const grouping: BarGrouping = options.grouping ?? 'grouped';
|
|
140
140
|
|
|
141
141
|
// Preserve first-seen order rather than sorting: the app chose this order.
|
|
@@ -153,16 +153,36 @@ export function buildBarGeometry<M>(
|
|
|
153
153
|
|
|
154
154
|
const visible = datasource.series.filter(s => !s.hidden);
|
|
155
155
|
const excluded = countExcluded(datasource);
|
|
156
|
-
const values =
|
|
157
|
-
grouping === 'stacked'
|
|
156
|
+
const values = [
|
|
157
|
+
...(grouping === 'stacked'
|
|
158
158
|
? stackedDomainValues(categories, visible)
|
|
159
|
-
: groupedDomainValues(datasource)
|
|
159
|
+
: groupedDomainValues(datasource)),
|
|
160
|
+
// A threshold above every bar must still be on the chart.
|
|
161
|
+
...markerDomainValues(datasource.markers),
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
// Labels depend only on the domain, so they are known before any pixel is,
|
|
165
|
+
// and the plot can make room for them first.
|
|
166
|
+
// Bars grow from zero, so padding past zero on a side no bar reaches only
|
|
167
|
+
// adds a meaningless tick (-5.5 under all-positive data) crowding the 0.
|
|
168
|
+
const padded = domainFrom(values, { includeZero: true, padFraction: 0.05 });
|
|
169
|
+
const domain = widenDegenerateDomain({
|
|
170
|
+
min: values.some(v => v < 0) ? padded.min : Math.max(padded.min, 0),
|
|
171
|
+
max: values.some(v => v > 0) ? padded.max : Math.min(padded.max, 0),
|
|
172
|
+
});
|
|
173
|
+
const yTickValues = [domain.min, 0, domain.max].filter((v, i, arr) => arr.indexOf(v) === i);
|
|
174
|
+
const xLabel = (c: string) =>
|
|
175
|
+
datasource.positionKind === 'instant'
|
|
176
|
+
? input.formatInstant(Number(c))
|
|
177
|
+
: input.formatCategory(c);
|
|
178
|
+
const plot = insetForAxes(input.plot, {
|
|
179
|
+
xLabels: categories.map(xLabel),
|
|
180
|
+
yLabels: yTickValues.map(input.formatValue),
|
|
181
|
+
fontSize: input.fontSize,
|
|
182
|
+
});
|
|
160
183
|
|
|
161
184
|
const xBand = bandScale(categories, { min: plot.x, max: plotRight(plot) }, 0.25);
|
|
162
|
-
const y = linearScale(
|
|
163
|
-
domainFrom(values, { includeZero: true, padFraction: 0.05 }),
|
|
164
|
-
{ min: plotBottom(plot), max: plot.y },
|
|
165
|
-
);
|
|
185
|
+
const y = linearScale(domain, { min: plotBottom(plot), max: plot.y });
|
|
166
186
|
const zeroY = y.project(0);
|
|
167
187
|
|
|
168
188
|
const paths: MarkPath[] = [];
|
|
@@ -229,25 +249,22 @@ export function buildBarGeometry<M>(
|
|
|
229
249
|
const xTicks: Tick[] = categories.map(c => ({
|
|
230
250
|
value: 0,
|
|
231
251
|
position: xBand.centerOf(c) ?? plot.x,
|
|
232
|
-
label:
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
252
|
+
label: xLabel(c),
|
|
253
|
+
}));
|
|
254
|
+
|
|
255
|
+
const yTicks: Tick[] = yTickValues.map(v => ({
|
|
256
|
+
value: v,
|
|
257
|
+
position: y.project(v),
|
|
258
|
+
label: input.formatValue(v),
|
|
236
259
|
}));
|
|
237
260
|
|
|
238
|
-
const
|
|
239
|
-
.filter((v, i, arr) => arr.indexOf(v) === i)
|
|
240
|
-
.map(v => ({
|
|
241
|
-
value: v,
|
|
242
|
-
position: y.project(v),
|
|
243
|
-
label: input.formatValue(v),
|
|
244
|
-
}));
|
|
261
|
+
const markers = horizontalMarkers(datasource.markers, plot, y.project);
|
|
245
262
|
|
|
246
263
|
return {
|
|
247
264
|
plot,
|
|
248
265
|
paths,
|
|
249
|
-
markers:
|
|
250
|
-
labels:
|
|
266
|
+
markers: markers.lines,
|
|
267
|
+
labels: markers.labels,
|
|
251
268
|
xTicks: thinTicks(xTicks, input.fontSize, plot.width),
|
|
252
269
|
yTicks,
|
|
253
270
|
hitRegions,
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* PURE. No React, no SVG.
|
|
10
10
|
*/
|
|
11
11
|
import type { ChartDatasource } from '../contract/datasource';
|
|
12
|
+
import { segmentKey } from '../contract/theme';
|
|
12
13
|
import { isKnown, isWithheld, sumKnown } from '../contract/value';
|
|
13
14
|
import type { HitRegion } from './hitTest';
|
|
14
15
|
import { arcPath, rectSubpath } from './path';
|
|
@@ -83,6 +84,8 @@ export function buildDistributionGeometry<M>(
|
|
|
83
84
|
const paths: MarkPath[] = [];
|
|
84
85
|
const labels: GeometryLabel[] = [];
|
|
85
86
|
const hitRegions: HitRegion[] = [];
|
|
87
|
+
const colorOf = (seriesId: string, pointId: string) =>
|
|
88
|
+
input.segmentColors?.[segmentKey(seriesId, pointId)] ?? input.seriesColors[seriesId];
|
|
86
89
|
|
|
87
90
|
if (shape === 'segmentedBar') {
|
|
88
91
|
const barHeight = Math.min(plot.height, 28);
|
|
@@ -100,7 +103,7 @@ export function buildDistributionGeometry<M>(
|
|
|
100
103
|
key: `seg-${series.id}-${point.id}`,
|
|
101
104
|
seriesId: series.id,
|
|
102
105
|
d: rectSubpath({ x: cursor, y: barY, width, height: barHeight }),
|
|
103
|
-
color:
|
|
106
|
+
color: colorOf(series.id, point.id),
|
|
104
107
|
pattern: 'solid',
|
|
105
108
|
filled: true,
|
|
106
109
|
});
|
|
@@ -159,7 +162,7 @@ export function buildDistributionGeometry<M>(
|
|
|
159
162
|
startAngle: start,
|
|
160
163
|
endAngle: end,
|
|
161
164
|
}),
|
|
162
|
-
color:
|
|
165
|
+
color: colorOf(series.id, point.id),
|
|
163
166
|
pattern: 'solid',
|
|
164
167
|
filled: true,
|
|
165
168
|
});
|
package/src/geometry/layout.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* PURE. No React, no SVG.
|
|
9
9
|
*/
|
|
10
|
-
import { reserveAxisSpace } from './ticks';
|
|
10
|
+
import { estimateTextWidth, reserveAxisSpace } from './ticks';
|
|
11
11
|
|
|
12
12
|
export interface PlotRect {
|
|
13
13
|
readonly x: number;
|
|
@@ -103,6 +103,58 @@ export function resolvePlotRect(options: LayoutOptions): PlotRect {
|
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
export interface AxisLabels {
|
|
107
|
+
readonly xLabels: readonly string[];
|
|
108
|
+
readonly yLabels: readonly string[];
|
|
109
|
+
readonly fontSize: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Shrinks a plot so its own axis labels fit inside the container.
|
|
114
|
+
*
|
|
115
|
+
* Builders call this once they know their tick labels (which depend only on
|
|
116
|
+
* the data domain) and before projecting anything, so the reservation costs no
|
|
117
|
+
* second pass. Without it the frame handed every builder the full container:
|
|
118
|
+
* Y labels, drawn right-aligned left of the plot, landed at negative x, and X
|
|
119
|
+
* labels below the baseline fell past the bottom of the SVG (CHT-01).
|
|
120
|
+
*
|
|
121
|
+
* - left: the widest Y label plus a gutter, and at least half the widest X
|
|
122
|
+
* label, because the first X label is centred on the plot's left edge;
|
|
123
|
+
* - right: half the widest X label, for the last one;
|
|
124
|
+
* - top: the part of the topmost Y label that rises above its tick;
|
|
125
|
+
* - bottom: one line of X labels plus a gutter.
|
|
126
|
+
*
|
|
127
|
+
* Each side is capped so labels can never squeeze the plot out of existence.
|
|
128
|
+
*/
|
|
129
|
+
export function insetForAxes(outer: PlotRect, labels: AxisLabels): PlotRect {
|
|
130
|
+
const { fontSize } = labels;
|
|
131
|
+
let widestX = 0;
|
|
132
|
+
for (const label of labels.xLabels) {
|
|
133
|
+
widestX = Math.max(widestX, estimateTextWidth(label, fontSize));
|
|
134
|
+
}
|
|
135
|
+
const hasY = labels.yLabels.length > 0;
|
|
136
|
+
const hasX = labels.xLabels.length > 0;
|
|
137
|
+
|
|
138
|
+
const left = Math.min(
|
|
139
|
+
Math.max(hasY ? reserveAxisSpace(labels.yLabels, fontSize, 'vertical') : 0, widestX / 2),
|
|
140
|
+
outer.width * 0.4,
|
|
141
|
+
);
|
|
142
|
+
const right = Math.min(widestX / 2, outer.width * 0.25);
|
|
143
|
+
// Y labels sit on their tick at `position + fontSize / 3`, so two thirds of
|
|
144
|
+
// the glyph box rise above it.
|
|
145
|
+
const top = hasY ? Math.min(Math.ceil((fontSize * 2) / 3), outer.height * 0.2) : 0;
|
|
146
|
+
const bottom = hasX
|
|
147
|
+
? Math.min(reserveAxisSpace(labels.xLabels, fontSize, 'horizontal'), outer.height * 0.4)
|
|
148
|
+
: 0;
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
x: outer.x + left,
|
|
152
|
+
y: outer.y + top,
|
|
153
|
+
width: Math.max(0, outer.width - left - right),
|
|
154
|
+
height: Math.max(0, outer.height - top - bottom),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
106
158
|
export function plotBottom(plot: PlotRect): number {
|
|
107
159
|
return plot.y + plot.height;
|
|
108
160
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reference markers on a value axis: thresholds, baselines and targets drawn
|
|
3
|
+
* as horizontal lines across the plot (FR-008, FR-072).
|
|
4
|
+
*
|
|
5
|
+
* Every builder used to return `markers: []`, so a threshold the app supplied
|
|
6
|
+
* reached the accessibility summary and nowhere else (CHT-03).
|
|
7
|
+
*
|
|
8
|
+
* PURE. No React, no SVG.
|
|
9
|
+
*/
|
|
10
|
+
import type { ReferenceMarker } from '../contract/marker';
|
|
11
|
+
import { isKnown } from '../contract/value';
|
|
12
|
+
import { plotRight, type PlotRect } from './layout';
|
|
13
|
+
import type { GeometryLabel, MarkerLine } from './types';
|
|
14
|
+
|
|
15
|
+
type PlacedMarker = ReferenceMarker & { readonly value: { kind: 'known'; value: number } };
|
|
16
|
+
|
|
17
|
+
/** Markers that can be placed on a value axis: those with a known value. */
|
|
18
|
+
export function placeableMarkers(
|
|
19
|
+
markers: readonly ReferenceMarker[] | undefined,
|
|
20
|
+
): PlacedMarker[] {
|
|
21
|
+
return (markers ?? []).filter((m): m is PlacedMarker => isKnown(m.value));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Values the domain must include so every marker lands inside the plot. */
|
|
25
|
+
export function markerDomainValues(markers: readonly ReferenceMarker[] | undefined): number[] {
|
|
26
|
+
return placeableMarkers(markers).map(m => m.value.value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One line per marker across the plot, plus its label when it has one. */
|
|
30
|
+
export function horizontalMarkers(
|
|
31
|
+
markers: readonly ReferenceMarker[] | undefined,
|
|
32
|
+
plot: PlotRect,
|
|
33
|
+
project: (value: number) => number,
|
|
34
|
+
): { lines: MarkerLine[]; labels: GeometryLabel[] } {
|
|
35
|
+
const lines: MarkerLine[] = [];
|
|
36
|
+
const labels: GeometryLabel[] = [];
|
|
37
|
+
for (const marker of placeableMarkers(markers)) {
|
|
38
|
+
const y = project(marker.value.value);
|
|
39
|
+
lines.push({
|
|
40
|
+
id: `marker-${marker.id}`,
|
|
41
|
+
d: `M${plot.x},${y}H${plotRight(plot)}`,
|
|
42
|
+
label: marker.label,
|
|
43
|
+
kind: marker.kind,
|
|
44
|
+
});
|
|
45
|
+
if (marker.label) {
|
|
46
|
+
labels.push({
|
|
47
|
+
key: `marker-label-${marker.id}`,
|
|
48
|
+
text: marker.label,
|
|
49
|
+
x: plotRight(plot),
|
|
50
|
+
// Above the line, unless that would leave the plot.
|
|
51
|
+
y: y - 4 >= plot.y + 8 ? y - 4 : y + 12,
|
|
52
|
+
anchor: 'end',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { lines, labels };
|
|
57
|
+
}
|
package/src/geometry/progress.ts
CHANGED
|
@@ -12,11 +12,12 @@ import type { ChartDatasource } from '../contract/datasource';
|
|
|
12
12
|
import { findTarget, readProgress } from '../contract/marker';
|
|
13
13
|
import { isKnown, isWithheld } from '../contract/value';
|
|
14
14
|
import type { HitRegion } from './hitTest';
|
|
15
|
-
import { plotRight } from './layout';
|
|
15
|
+
import { plotBottom, plotRight } from './layout';
|
|
16
16
|
import { rectSubpath } from './path';
|
|
17
17
|
import type {
|
|
18
18
|
GeometryInput,
|
|
19
19
|
GeometryLabel,
|
|
20
|
+
MarkerLine,
|
|
20
21
|
MarkPath,
|
|
21
22
|
ResolvedGeometry,
|
|
22
23
|
} from './types';
|
|
@@ -52,6 +53,22 @@ export function buildProgressGeometry<M>(
|
|
|
52
53
|
};
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
const readings = metrics.map(({ point }) =>
|
|
57
|
+
readProgress(point.value, findTarget(datasource.markers, point.id) ?? findTarget(datasource.markers)),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// The track spans 0..displayMax of the target, shared by every row so their
|
|
61
|
+
// targets line up. With an overshoot the target sits inside the plot and the
|
|
62
|
+
// excess is drawn to its right — previously the target was the plot's right
|
|
63
|
+
// edge, so overshoot started there and was clipped by the SVG (CHT-03).
|
|
64
|
+
const displayMax = readings.reduce(
|
|
65
|
+
(max, r) =>
|
|
66
|
+
r.proportion === undefined ? max : Math.max(max, Math.min(r.proportion, OVERSHOOT_DISPLAY_CAP)),
|
|
67
|
+
1,
|
|
68
|
+
);
|
|
69
|
+
const unit = plot.width / displayMax;
|
|
70
|
+
const targetX = plot.x + unit;
|
|
71
|
+
|
|
55
72
|
const rowHeight = plot.height / metrics.length;
|
|
56
73
|
const barHeight = Math.min(rowHeight * 0.45, 24);
|
|
57
74
|
const radius = options.trackRadius ?? barHeight / 2;
|
|
@@ -59,12 +76,12 @@ export function buildProgressGeometry<M>(
|
|
|
59
76
|
const paths: MarkPath[] = [];
|
|
60
77
|
const labels: GeometryLabel[] = [];
|
|
61
78
|
const hitRegions: HitRegion[] = [];
|
|
79
|
+
const markers: MarkerLine[] = [];
|
|
62
80
|
let incomplete = false;
|
|
63
81
|
|
|
64
82
|
metrics.forEach(({ series, point }, index) => {
|
|
65
83
|
const rowY = plot.y + index * rowHeight + (rowHeight - barHeight) / 2;
|
|
66
|
-
const
|
|
67
|
-
const reading = readProgress(point.value, target);
|
|
84
|
+
const reading = readings[index];
|
|
68
85
|
|
|
69
86
|
// Track: the full width, always drawn, so an empty metric still reads as
|
|
70
87
|
// a metric rather than as blank space.
|
|
@@ -119,7 +136,7 @@ export function buildProgressGeometry<M>(
|
|
|
119
136
|
d: rectSubpath({
|
|
120
137
|
x: plot.x,
|
|
121
138
|
y: rowY,
|
|
122
|
-
width: Math.max(0,
|
|
139
|
+
width: Math.max(0, unit * Math.min(fillFraction, 1)),
|
|
123
140
|
height: barHeight,
|
|
124
141
|
radius,
|
|
125
142
|
}),
|
|
@@ -132,13 +149,12 @@ export function buildProgressGeometry<M>(
|
|
|
132
149
|
// so exceeding a goal is visible instead of indistinguishable from
|
|
133
150
|
// exactly meeting it.
|
|
134
151
|
if (reading.overshoot && proportion !== undefined) {
|
|
135
|
-
const overshootWidth =
|
|
136
|
-
plot.width * (Math.min(proportion, OVERSHOOT_DISPLAY_CAP) - 1);
|
|
152
|
+
const overshootWidth = unit * (Math.min(proportion, OVERSHOOT_DISPLAY_CAP) - 1);
|
|
137
153
|
paths.push({
|
|
138
154
|
key: `overshoot-${series.id}-${point.id}`,
|
|
139
155
|
seriesId: series.id,
|
|
140
156
|
d: rectSubpath({
|
|
141
|
-
x:
|
|
157
|
+
x: targetX,
|
|
142
158
|
y: rowY + barHeight * 0.2,
|
|
143
159
|
width: Math.max(0, overshootWidth),
|
|
144
160
|
height: barHeight * 0.6,
|
|
@@ -152,6 +168,16 @@ export function buildProgressGeometry<M>(
|
|
|
152
168
|
}
|
|
153
169
|
}
|
|
154
170
|
|
|
171
|
+
// The target itself, across this row's bar (FR-022, FR-072).
|
|
172
|
+
if (reading.proportion !== undefined && reading.target) {
|
|
173
|
+
markers.push({
|
|
174
|
+
id: `target-${series.id}-${point.id}`,
|
|
175
|
+
d: `M${targetX},${Math.max(plot.y, rowY - 3)}V${Math.min(plotBottom(plot), rowY + barHeight + 3)}`,
|
|
176
|
+
label: reading.target.label,
|
|
177
|
+
kind: 'target',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
155
181
|
const valueText = isKnown(point.value)
|
|
156
182
|
? input.formatValue(point.value.value)
|
|
157
183
|
: '';
|
|
@@ -194,7 +220,7 @@ export function buildProgressGeometry<M>(
|
|
|
194
220
|
return {
|
|
195
221
|
plot,
|
|
196
222
|
paths,
|
|
197
|
-
markers
|
|
223
|
+
markers,
|
|
198
224
|
labels,
|
|
199
225
|
xTicks: [],
|
|
200
226
|
yTicks: [],
|
package/src/geometry/scale.ts
CHANGED
|
@@ -22,7 +22,7 @@ export interface LinearScale {
|
|
|
22
22
|
* usable axis. Without this the projection divides by zero and every point
|
|
23
23
|
* lands on the same pixel — or on NaN.
|
|
24
24
|
*/
|
|
25
|
-
function widenDegenerateDomain(domain: Range): Range {
|
|
25
|
+
export function widenDegenerateDomain(domain: Range): Range {
|
|
26
26
|
if (domain.max > domain.min) {
|
|
27
27
|
return domain;
|
|
28
28
|
}
|
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
import type { ChartDatasource, Series } from '../contract/datasource';
|
|
11
11
|
import { isKnown, isWithheld } from '../contract/value';
|
|
12
12
|
import type { HitRegion } from './hitTest';
|
|
13
|
-
import { plotBottom, plotRight } from './layout';
|
|
13
|
+
import { insetForAxes, plotBottom, plotRight } from './layout';
|
|
14
|
+
import { horizontalMarkers, markerDomainValues } from './markers';
|
|
14
15
|
import { linePath, type MaybePt } from './path';
|
|
15
|
-
import { domainFrom, linearScale, timeScale } from './scale';
|
|
16
|
+
import { domainFrom, linearScale, timeScale, widenDegenerateDomain } from './scale';
|
|
16
17
|
import { niceTickValues, thinTicks, type Tick } from './ticks';
|
|
17
18
|
import type { GeometryInput, MarkPath, ResolvedGeometry } from './types';
|
|
18
19
|
|
|
@@ -45,8 +46,6 @@ export function buildTimeSeriesGeometry<M>(
|
|
|
45
46
|
input: GeometryInput,
|
|
46
47
|
options: TimeSeriesOptions = {},
|
|
47
48
|
): ResolvedGeometry {
|
|
48
|
-
const { plot } = input;
|
|
49
|
-
|
|
50
49
|
const instants: number[] = [];
|
|
51
50
|
const values: number[] = [];
|
|
52
51
|
let excluded = 0;
|
|
@@ -66,7 +65,7 @@ export function buildTimeSeriesGeometry<M>(
|
|
|
66
65
|
|
|
67
66
|
if (instants.length === 0) {
|
|
68
67
|
return {
|
|
69
|
-
plot,
|
|
68
|
+
plot: input.plot,
|
|
70
69
|
paths: [],
|
|
71
70
|
markers: [],
|
|
72
71
|
labels: [],
|
|
@@ -77,15 +76,24 @@ export function buildTimeSeriesGeometry<M>(
|
|
|
77
76
|
};
|
|
78
77
|
}
|
|
79
78
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
79
|
+
// Tick labels depend only on the domains, so the plot can make room for
|
|
80
|
+
// them before anything is projected.
|
|
81
|
+
// Widened here, as the scales would, so the ticks label the axes actually drawn.
|
|
82
|
+
const xDomain = widenDegenerateDomain(domainFrom(instants));
|
|
83
|
+
const yDomain = widenDegenerateDomain(
|
|
84
|
+
domainFrom([...values, ...markerDomainValues(datasource.markers)], { padFraction: 0.1 }),
|
|
85
|
+
);
|
|
86
|
+
const xTickValues = niceTickValues(xDomain, 4);
|
|
87
|
+
const yTickValues = niceTickValues(yDomain, 4);
|
|
88
|
+
const plot = insetForAxes(input.plot, {
|
|
89
|
+
xLabels: xTickValues.map(input.formatInstant),
|
|
90
|
+
yLabels: yTickValues.map(input.formatValue),
|
|
91
|
+
fontSize: input.fontSize,
|
|
87
92
|
});
|
|
88
93
|
|
|
94
|
+
const x = timeScale(xDomain, { min: plot.x, max: plotRight(plot) });
|
|
95
|
+
const y = linearScale(yDomain, { min: plotBottom(plot), max: plot.y });
|
|
96
|
+
|
|
89
97
|
const paths: MarkPath[] = [];
|
|
90
98
|
const hitRegions: HitRegion[] = [];
|
|
91
99
|
|
|
@@ -133,23 +141,25 @@ export function buildTimeSeriesGeometry<M>(
|
|
|
133
141
|
}
|
|
134
142
|
}
|
|
135
143
|
|
|
136
|
-
const xTicks: Tick[] =
|
|
144
|
+
const xTicks: Tick[] = xTickValues.map(v => ({
|
|
137
145
|
value: v,
|
|
138
146
|
position: x.project(v),
|
|
139
147
|
label: input.formatInstant(v),
|
|
140
148
|
}));
|
|
141
149
|
|
|
142
|
-
const yTicks: Tick[] =
|
|
150
|
+
const yTicks: Tick[] = yTickValues.map(v => ({
|
|
143
151
|
value: v,
|
|
144
152
|
position: y.project(v),
|
|
145
153
|
label: input.formatValue(v),
|
|
146
154
|
}));
|
|
147
155
|
|
|
156
|
+
const markers = horizontalMarkers(datasource.markers, plot, y.project);
|
|
157
|
+
|
|
148
158
|
return {
|
|
149
159
|
plot,
|
|
150
160
|
paths,
|
|
151
|
-
markers:
|
|
152
|
-
labels:
|
|
161
|
+
markers: markers.lines,
|
|
162
|
+
labels: markers.labels,
|
|
153
163
|
xTicks: thinTicks(xTicks, input.fontSize, plot.width),
|
|
154
164
|
yTicks,
|
|
155
165
|
hitRegions,
|
package/src/geometry/types.ts
CHANGED
|
@@ -78,6 +78,12 @@ export interface GeometryInput {
|
|
|
78
78
|
readonly fontSize: number;
|
|
79
79
|
/** Resolved per series, so geometry never reaches into the theme itself. */
|
|
80
80
|
readonly seriesColors: Readonly<Record<string, string>>;
|
|
81
|
+
/**
|
|
82
|
+
* Distribution segment colours keyed by `segmentKey(seriesId, pointId)`.
|
|
83
|
+
* Only resolved for the distribution family. Point colours used to share
|
|
84
|
+
* `seriesColors`, so a point id equal to a series id overwrote that series.
|
|
85
|
+
*/
|
|
86
|
+
readonly segmentColors?: Readonly<Record<string, string>>;
|
|
81
87
|
readonly nonValueTextures: Readonly<
|
|
82
88
|
Record<'unknown' | 'notApplicable' | 'withheld', NonValueTexture>
|
|
83
89
|
>;
|
package/src/react/ChartFrame.tsx
CHANGED
|
@@ -167,6 +167,7 @@ export function ChartFrame<M>(props: ChartFrameProps<M>) {
|
|
|
167
167
|
theme,
|
|
168
168
|
layout,
|
|
169
169
|
geometryFormatters,
|
|
170
|
+
family,
|
|
170
171
|
);
|
|
171
172
|
|
|
172
173
|
const showData = drawsData(status);
|
|
@@ -362,9 +363,10 @@ export function ChartFrame<M>(props: ChartFrameProps<M>) {
|
|
|
362
363
|
theme,
|
|
363
364
|
visibility.hidden,
|
|
364
365
|
visibility.enabled,
|
|
366
|
+
family,
|
|
365
367
|
)
|
|
366
368
|
: [],
|
|
367
|
-
[legendEnabled, props.datasource, theme, visibility.hidden, visibility.enabled],
|
|
369
|
+
[legendEnabled, props.datasource, theme, visibility.hidden, visibility.enabled, family],
|
|
368
370
|
);
|
|
369
371
|
|
|
370
372
|
const legendPosition =
|
|
@@ -9,18 +9,52 @@
|
|
|
9
9
|
import React from 'react';
|
|
10
10
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
11
11
|
|
|
12
|
-
import type { ChartDatasource } from '../contract/datasource';
|
|
13
|
-
import {
|
|
12
|
+
import type { ChartDatasource, ChartFamily } from '../contract/datasource';
|
|
13
|
+
import {
|
|
14
|
+
resolveSegmentColors,
|
|
15
|
+
resolveSeriesColor,
|
|
16
|
+
segmentKey,
|
|
17
|
+
type ChartTheme,
|
|
18
|
+
type LegendEntry,
|
|
19
|
+
} from '../contract/theme';
|
|
14
20
|
|
|
15
21
|
/** Minimum tap target, per platform accessibility guidance (UX-003). */
|
|
16
22
|
const MIN_TAP_TARGET = 44;
|
|
17
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Legend model. A distribution chart gets one entry per segment, in the colour
|
|
26
|
+
* its arc is drawn in; it used to get one per series, so a one-series donut of
|
|
27
|
+
* four segments showed a single entry (CHT-02).
|
|
28
|
+
*/
|
|
18
29
|
export function buildLegendEntries<M>(
|
|
19
30
|
datasource: ChartDatasource<M>,
|
|
20
31
|
theme: ChartTheme,
|
|
21
32
|
hidden: ReadonlySet<string>,
|
|
22
33
|
toggleable: boolean,
|
|
34
|
+
family?: ChartFamily,
|
|
23
35
|
): LegendEntry[] {
|
|
36
|
+
if (family === 'distribution') {
|
|
37
|
+
const colors = resolveSegmentColors(datasource, theme);
|
|
38
|
+
return datasource.series.flatMap(series =>
|
|
39
|
+
series.points.map(point => {
|
|
40
|
+
const single = series.points.length === 1;
|
|
41
|
+
let label = series.label;
|
|
42
|
+
if (!single) {
|
|
43
|
+
label = point.position.kind === 'category' ? point.position.category : point.id;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
seriesId: series.id,
|
|
47
|
+
pointId: point.id,
|
|
48
|
+
label,
|
|
49
|
+
color: colors[segmentKey(series.id, point.id)],
|
|
50
|
+
pattern: 'solid' as const,
|
|
51
|
+
visible: !hidden.has(series.id),
|
|
52
|
+
// Visibility is per series, so only a one-segment series toggles.
|
|
53
|
+
toggleable: toggleable && single,
|
|
54
|
+
};
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
24
58
|
return datasource.series.map((series, index) => ({
|
|
25
59
|
seriesId: series.id,
|
|
26
60
|
label: series.label,
|
|
@@ -31,6 +65,10 @@ export function buildLegendEntries<M>(
|
|
|
31
65
|
}));
|
|
32
66
|
}
|
|
33
67
|
|
|
68
|
+
function keyOf(entry: LegendEntry): string {
|
|
69
|
+
return entry.pointId === undefined ? entry.seriesId : segmentKey(entry.seriesId, entry.pointId);
|
|
70
|
+
}
|
|
71
|
+
|
|
34
72
|
function segmentCountFor(pattern: LegendEntry['pattern']): number {
|
|
35
73
|
switch (pattern) {
|
|
36
74
|
case 'dotted':
|
|
@@ -113,7 +151,7 @@ export function ChartLegend({
|
|
|
113
151
|
|
|
114
152
|
if (!entry.toggleable || !onToggle) {
|
|
115
153
|
return (
|
|
116
|
-
<View key={entry
|
|
154
|
+
<View key={keyOf(entry)} style={styles.entry} accessibilityRole="text">
|
|
117
155
|
{content}
|
|
118
156
|
</View>
|
|
119
157
|
);
|
|
@@ -121,7 +159,7 @@ export function ChartLegend({
|
|
|
121
159
|
|
|
122
160
|
return (
|
|
123
161
|
<Pressable
|
|
124
|
-
key={entry
|
|
162
|
+
key={keyOf(entry)}
|
|
125
163
|
onPress={() => onToggle(entry.seriesId)}
|
|
126
164
|
accessibilityRole="switch"
|
|
127
165
|
accessibilityState={{ checked: entry.visible }}
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
import { useCallback, useMemo, useState } from 'react';
|
|
14
14
|
import { PixelRatio, type LayoutChangeEvent } from 'react-native';
|
|
15
15
|
|
|
16
|
-
import type { ChartDatasource } from '../contract/datasource';
|
|
17
|
-
import { resolveSeriesColor, type ChartTheme } from '../contract/theme';
|
|
16
|
+
import type { ChartDatasource, ChartFamily } from '../contract/datasource';
|
|
17
|
+
import { resolveSegmentColors, resolveSeriesColor, type ChartTheme } from '../contract/theme';
|
|
18
18
|
import {
|
|
19
19
|
EMPTY_PLOT,
|
|
20
20
|
isMeasurable,
|
|
@@ -98,27 +98,27 @@ export function useGeometryInput<M>(
|
|
|
98
98
|
readonly formatCategory: (raw: string) => string;
|
|
99
99
|
readonly formatInstant: (at: number) => string;
|
|
100
100
|
},
|
|
101
|
+
family?: ChartFamily,
|
|
101
102
|
): GeometryInput {
|
|
102
103
|
return useMemo(() => {
|
|
103
104
|
const seriesColors: Record<string, string> = {};
|
|
104
105
|
datasource.series.forEach((series, index) => {
|
|
105
106
|
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
107
|
});
|
|
112
108
|
|
|
113
109
|
return {
|
|
114
110
|
plot: layout.plot,
|
|
115
111
|
fontSize: layout.fontSize,
|
|
116
112
|
seriesColors,
|
|
113
|
+
// Per point only where segments exist; resolving one per point for
|
|
114
|
+
// every family cost a colour per data point at the time-series budget.
|
|
115
|
+
segmentColors:
|
|
116
|
+
family === 'distribution' ? resolveSegmentColors(datasource, theme) : undefined,
|
|
117
117
|
nonValueTextures: theme.nonValue,
|
|
118
118
|
heatmapRamp: theme.heatmapRamp,
|
|
119
119
|
formatValue: formatters.formatValue,
|
|
120
120
|
formatCategory: formatters.formatCategory,
|
|
121
121
|
formatInstant: formatters.formatInstant,
|
|
122
122
|
};
|
|
123
|
-
}, [datasource, theme, layout.plot, layout.fontSize, formatters]);
|
|
123
|
+
}, [datasource, theme, layout.plot, layout.fontSize, formatters, family]);
|
|
124
124
|
}
|