@chipmobilesdk/rn-chart 0.2.1 → 0.4.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.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Every text the package itself speaks or shows (CHT-05): screen-reader
3
+ * summaries and announcements, adjustable-action labels, built-in state text and
4
+ * legend hints. English defaults; an app translates them by passing `strings`
5
+ * (or by registering `chartStrings.user` with rn-i18n and passing the translated
6
+ * texts). `{name}` marks a variable.
7
+ */
8
+ import { defineMessages } from '@chipmobilesdk/rn-core';
9
+
10
+ export const chartStrings = defineMessages({
11
+ user: {
12
+ 'chart.family.progress': 'progress chart',
13
+ 'chart.family.distribution': 'distribution chart',
14
+ 'chart.family.timeSeries': 'time series chart',
15
+ 'chart.family.bar': 'bar chart',
16
+ 'chart.family.heatmap': 'heatmap',
17
+ 'chart.status.loading': 'Loading data.',
18
+ 'chart.status.empty': 'No data yet.',
19
+ 'chart.status.error': 'Data could not be shown.',
20
+ 'chart.status.stale': 'Showing data that may be out of date.',
21
+ 'chart.status.noPermission': 'This data is not available to you.',
22
+ 'chart.status.partial': 'Some data is missing.',
23
+ 'chart.summary.heading': '{title}, {noun}.',
24
+ 'chart.summary.headingUntitled': '{noun}.',
25
+ 'chart.summary.scale': '{series} series, {points} points.',
26
+ 'chart.summary.scaleSingle': '{points} points.',
27
+ 'chart.summary.single': 'Value {value}.',
28
+ 'chart.summary.extremes': 'Highest {maxLabel} at {max}. Lowest {minLabel} at {min}.',
29
+ 'chart.summary.marker': '{name} {value}',
30
+ 'chart.summary.incomplete': 'Some values are missing, so totals are incomplete.',
31
+ 'chart.point.withheld': '{position}, {series}, not available.',
32
+ 'chart.point.noData': '{position}, {series}, no data.',
33
+ 'chart.point.value': '{position}, {series}, {value}.',
34
+ 'chart.total': 'Total {value}.',
35
+ 'chart.total.incomplete': 'Total {value}, excluding {count} values that are not available.',
36
+ 'chart.action.next': 'Next data point',
37
+ 'chart.action.previous': 'Previous data point',
38
+ 'chart.state.loading': 'Loading…',
39
+ 'chart.state.empty': 'No data yet',
40
+ 'chart.state.error': 'Unable to show this chart',
41
+ 'chart.state.noPermission': 'Not available',
42
+ 'chart.legend.hide': 'Hides this series',
43
+ 'chart.legend.show': 'Shows this series',
44
+ 'chart.legend.scale': 'Colour scale from {min} to {max}',
45
+ 'chart.note.incomplete': 'Some values are unavailable, so totals are incomplete.',
46
+ },
47
+ });
48
+
49
+ export type ChartStrings = typeof chartStrings.defaults;
50
+ export type ChartStringKey = keyof ChartStrings;
@@ -14,7 +14,7 @@ import { insetForAxes, plotBottom, plotRight } from './layout';
14
14
  import { horizontalMarkers, markerDomainValues } from './markers';
15
15
  import { rectBatchPath, type RectSpec } from './path';
16
16
  import { bandScale, domainFrom, linearScale, widenDegenerateDomain } from './scale';
17
- import { thinTicks, type Tick } from './ticks';
17
+ import { niceTickValues, thinTicks, type Tick } from './ticks';
18
18
  import type { GeometryInput, MarkPath, ResolvedGeometry } from './types';
19
19
 
20
20
  export type BarGrouping = 'grouped' | 'stacked';
@@ -75,21 +75,24 @@ function stackedDomainValues<M>(
75
75
  ): number[] {
76
76
  const values: number[] = [];
77
77
 
78
- for (const category of categories) {
79
- let positive = 0;
80
- let negative = 0;
81
-
82
- for (const series of visible) {
83
- const point = series.points.find(p => categoryKey(p.position) === category);
84
- if (point && isKnown(point.value)) {
85
- if (point.value.value >= 0) {
86
- positive += point.value.value;
87
- } else {
88
- negative += point.value.value;
89
- }
78
+ // One pass over the points instead of a search per category and series.
79
+ const positive = new Map<string, number>();
80
+ const negative = new Map<string, number>();
81
+ for (const series of visible) {
82
+ const counted = new Set<string>();
83
+ for (const point of series.points) {
84
+ const category = categoryKey(point.position);
85
+ // The first point of a series in a category is the one drawn there.
86
+ if (counted.has(category) || !isKnown(point.value)) {
87
+ continue;
90
88
  }
89
+ counted.add(category);
90
+ const sums = point.value.value >= 0 ? positive : negative;
91
+ sums.set(category, (sums.get(category) ?? 0) + point.value.value);
91
92
  }
92
- values.push(positive, negative);
93
+ }
94
+ for (const category of categories) {
95
+ values.push(positive.get(category) ?? 0, negative.get(category) ?? 0);
93
96
  }
94
97
 
95
98
  return values;
@@ -149,6 +152,13 @@ export function buildBarGeometry<M>(
149
152
  }
150
153
  }
151
154
 
155
+ // Instants have an order of their own. First-seen order put Tuesday before
156
+ // Monday when one series started a day late, and disagreed with the
157
+ // time-series chart drawn from the same datasource (CHT-20).
158
+ if (datasource.positionKind === 'instant') {
159
+ categories.sort((a, b) => Number(a) - Number(b));
160
+ }
161
+
152
162
  const visible = datasource.series.filter(s => !s.hidden);
153
163
  const excluded = countExcluded(datasource);
154
164
  const values = [
@@ -168,7 +178,8 @@ export function buildBarGeometry<M>(
168
178
  min: values.some(v => v < 0) ? padded.min : Math.max(padded.min, 0),
169
179
  max: values.some(v => v > 0) ? padded.max : Math.min(padded.max, 0),
170
180
  });
171
- const yTickValues = [domain.min, 0, domain.max].filter((v, i, arr) => arr.indexOf(v) === i);
181
+ // Round steps a reader can count in, not the padded extremes (105.3).
182
+ const yTickValues = niceTickValues(domain, 4);
172
183
  const xLabel = (c: string) =>
173
184
  datasource.positionKind === 'instant'
174
185
  ? input.formatInstant(Number(c))
@@ -219,7 +230,7 @@ export function buildBarGeometry<M>(
219
230
  y: top,
220
231
  width: barWidth,
221
232
  height,
222
- radius: options.radius,
233
+ radius: options.radius ?? input.radius?.bar,
223
234
  });
224
235
 
225
236
  hitRegions.push({
@@ -9,6 +9,7 @@
9
9
  * PURE. No React, no SVG.
10
10
  */
11
11
  import type { ChartDatasource } from '../contract/datasource';
12
+ import { resolveDistributionValues, type DistributionValues } from '../contract/distribution';
12
13
  import { segmentKey } from '../contract/theme';
13
14
  import { isKnown, isWithheld, sumKnown } from '../contract/value';
14
15
  import type { HitRegion } from './hitTest';
@@ -21,6 +22,8 @@ export interface DistributionOptions {
21
22
  readonly shape?: DistributionShape;
22
23
  /** Donut hole as a fraction of the outer radius. */
23
24
  readonly innerRadiusRatio?: number;
25
+ /** What the values mean; inferred from the data when omitted. */
26
+ readonly valuesAre?: DistributionValues;
24
27
  }
25
28
 
26
29
  const TAU = Math.PI * 2;
@@ -69,10 +72,11 @@ export function buildDistributionGeometry<M>(
69
72
  // Proportions supplied directly (each in 0..1, summing to ~1) are honoured
70
73
  // as given; absolute values are normalised by their own total. Either way we
71
74
  // never stretch a short sum to fill the circle.
72
- const suppliedAsProportions = segments.every(
73
- s => !isKnown(s.point.value) || (s.point.value.value >= 0 && s.point.value.value <= 1),
74
- );
75
- const denominator = suppliedAsProportions ? 1 : total.total;
75
+ const suppliedAsProportions =
76
+ resolveDistributionValues(datasource, options.valuesAre) === 'proportions';
77
+ // Shares declared as proportions that add up past the whole are normalised
78
+ // rather than drawn over one another.
79
+ const denominator = suppliedAsProportions ? Math.max(1, total.total) : total.total;
76
80
  const coverage = total.total / denominator;
77
81
  const incomplete = total.incomplete || coverage < 0.999;
78
82
 
@@ -88,7 +92,7 @@ export function buildDistributionGeometry<M>(
88
92
  let cursor = plot.x;
89
93
 
90
94
  for (const { series, point } of segments) {
91
- if (!isKnown(point.value)) {
95
+ if (!isKnown(point.value) || point.value.value < 0) {
92
96
  continue;
93
97
  }
94
98
  const fraction = point.value.value / denominator;
@@ -135,7 +139,7 @@ export function buildDistributionGeometry<M>(
135
139
  let angle = 0;
136
140
 
137
141
  for (const { series, point } of segments) {
138
- if (!isKnown(point.value)) {
142
+ if (!isKnown(point.value) || point.value.value < 0) {
139
143
  continue;
140
144
  }
141
145
 
@@ -180,6 +184,7 @@ export function buildDistributionGeometry<M>(
180
184
  pointId: point.id,
181
185
  x: cx + labelRadius * Math.cos(mid),
182
186
  y: cy + labelRadius * Math.sin(mid),
187
+ arc: { cx, cy, innerRadius, outerRadius, startAngle: start, endAngle: end },
183
188
  selectable: !isWithheld(point.value),
184
189
  });
185
190
  }
@@ -15,7 +15,9 @@
15
15
  import type { ChartDatasource } from '../contract/datasource';
16
16
  import { isKnown, isWithheld } from '../contract/value';
17
17
  import type { HitRegion } from './hitTest';
18
+ import { insetForAxes } from './layout';
18
19
  import { rectBatchPath, type RectSpec } from './path';
20
+ import { thinTicks } from './ticks';
19
21
  import type { GeometryInput, MarkPath, ResolvedGeometry } from './types';
20
22
 
21
23
  export interface HeatmapOptions {
@@ -30,8 +32,6 @@ export function buildHeatmapGeometry<M>(
30
32
  input: GeometryInput,
31
33
  options: HeatmapOptions = {},
32
34
  ): ResolvedGeometry {
33
- const { plot } = input;
34
-
35
35
  const rows: string[] = [];
36
36
  const columns: string[] = [];
37
37
  const rowSeen = new Set<string>();
@@ -53,9 +53,9 @@ export function buildHeatmapGeometry<M>(
53
53
  }
54
54
  }
55
55
 
56
- if (rows.length === 0 || columns.length === 0 || plot.width <= 0) {
56
+ if (rows.length === 0 || columns.length === 0 || input.plot.width <= 0) {
57
57
  return {
58
- plot,
58
+ plot: input.plot,
59
59
  paths: [],
60
60
  markers: [],
61
61
  labels: [],
@@ -66,6 +66,18 @@ export function buildHeatmapGeometry<M>(
66
66
  };
67
67
  }
68
68
 
69
+ // Row and column names are the heatmap's axes. The plot makes room for
70
+ // them; they were computed before and never drawn (CHT-15).
71
+ const columnLabels = columns.map(input.formatCategory);
72
+ const rowLabels = rows.map(input.formatCategory);
73
+ const plot = insetForAxes(input.plot, {
74
+ xLabels: columnLabels,
75
+ yLabels: rowLabels,
76
+ fontSize: input.fontSize,
77
+ });
78
+ const columnIndex = new Map(columns.map((column, index) => [column, index]));
79
+ const rowIndex = new Map(rows.map((row, index) => [row, index]));
80
+
69
81
  const gap = options.cellGap ?? 2;
70
82
  const cellWidth = plot.width / columns.length;
71
83
  const cellHeight = plot.height / rows.length;
@@ -102,18 +114,18 @@ export function buildHeatmapGeometry<M>(
102
114
  continue;
103
115
  }
104
116
 
105
- const colIndex = columns.indexOf(point.position.column);
106
- const rowIndex = rows.indexOf(point.position.row);
107
- if (colIndex < 0 || rowIndex < 0) {
117
+ const col = columnIndex.get(point.position.column);
118
+ const row = rowIndex.get(point.position.row);
119
+ if (col === undefined || row === undefined) {
108
120
  continue;
109
121
  }
110
122
 
111
123
  const rect: RectSpec = {
112
- x: plot.x + colIndex * cellWidth + gap / 2,
113
- y: plot.y + rowIndex * cellHeight + gap / 2,
124
+ x: plot.x + col * cellWidth + gap / 2,
125
+ y: plot.y + row * cellHeight + gap / 2,
114
126
  width: Math.max(0, cellWidth - gap),
115
127
  height: Math.max(0, cellHeight - gap),
116
- radius: options.cellRadius,
128
+ radius: options.cellRadius ?? input.radius?.cell,
117
129
  };
118
130
 
119
131
  if (isKnown(point.value)) {
@@ -173,17 +185,22 @@ export function buildHeatmapGeometry<M>(
173
185
  paths,
174
186
  markers: [],
175
187
  labels: [],
176
- xTicks: columns.map((c, i) => ({
177
- value: i,
178
- position: plot.x + i * cellWidth + cellWidth / 2,
179
- label: input.formatCategory(c),
180
- })),
181
- yTicks: rows.map((r, i) => ({
188
+ xTicks: thinTicks(
189
+ columnLabels.map((label, i) => ({
190
+ value: i,
191
+ position: plot.x + i * cellWidth + cellWidth / 2,
192
+ label,
193
+ })),
194
+ input.fontSize,
195
+ plot.width,
196
+ ),
197
+ yTicks: rowLabels.map((label, i) => ({
182
198
  value: i,
183
199
  position: plot.y + i * cellHeight + cellHeight / 2,
184
- label: input.formatCategory(r),
200
+ label,
185
201
  })),
186
202
  hitRegions,
203
+ ...(hasKnown ? { colorScale: { min, max, colors: ramp.slice(0, bucketCount) } } : {}),
187
204
  incomplete: excluded > 0,
188
205
  };
189
206
  }
@@ -23,6 +23,19 @@ export interface HitRegion {
23
23
  readonly width: number;
24
24
  readonly height: number;
25
25
  };
26
+ /**
27
+ * An arc segment: containment by radius and angle. Angles are radians,
28
+ * clockwise from twelve o'clock, as the distribution geometry draws them.
29
+ * A region with an arc is hit only inside it, never by proximity.
30
+ */
31
+ readonly arc?: {
32
+ readonly cx: number;
33
+ readonly cy: number;
34
+ readonly innerRadius: number;
35
+ readonly outerRadius: number;
36
+ readonly startAngle: number;
37
+ readonly endAngle: number;
38
+ };
26
39
  /**
27
40
  * False for points that must never be selectable: withheld values, and
28
41
  * anything the chart is not currently drawing (FR-053).
@@ -47,6 +60,20 @@ function inBounds(region: HitRegion, x: number, y: number): boolean {
47
60
  return x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height;
48
61
  }
49
62
 
63
+ const TAU = Math.PI * 2;
64
+
65
+ function inArc(arc: NonNullable<HitRegion['arc']>, x: number, y: number): boolean {
66
+ const dx = x - arc.cx;
67
+ const dy = y - arc.cy;
68
+ const radius = Math.sqrt(dx * dx + dy * dy);
69
+ if (radius < arc.innerRadius || radius > arc.outerRadius) {
70
+ return false;
71
+ }
72
+ // Clockwise from the top, the convention the arcs are drawn in.
73
+ const angle = (Math.atan2(dy, dx) + Math.PI / 2 + TAU) % TAU;
74
+ return angle >= arc.startAngle && angle < arc.endAngle;
75
+ }
76
+
50
77
  /**
51
78
  * Resolves a touch to a mark.
52
79
  *
@@ -70,6 +97,14 @@ export function hitTest(
70
97
  if (inBounds(region, x, y)) {
71
98
  return { seriesId: region.seriesId, pointId: region.pointId, distance: 0 };
72
99
  }
100
+ if (region.arc) {
101
+ // A large segment's edge is far from its midpoint, and a neighbour's
102
+ // midpoint can be nearer: only the segment under the finger counts.
103
+ if (inArc(region.arc, x, y)) {
104
+ return { seriesId: region.seriesId, pointId: region.pointId, distance: 0 };
105
+ }
106
+ continue;
107
+ }
73
108
 
74
109
  const dx = region.x - x;
75
110
  const dy = region.y - y;
@@ -17,7 +17,6 @@ import { rectSubpath } from './path';
17
17
  import type { GeometryInput, GeometryLabel, MarkerLine, MarkPath, ResolvedGeometry } from './types';
18
18
 
19
19
  export interface ProgressOptions {
20
- readonly orientation?: 'horizontal' | 'vertical';
21
20
  readonly trackRadius?: number;
22
21
  }
23
22
 
@@ -130,16 +130,3 @@ export function reserveAxisSpace(
130
130
  }
131
131
  return widest + LABEL_GUTTER;
132
132
  }
133
-
134
- /**
135
- * Shortens a label that cannot fit, signalling the truncation with an ellipsis.
136
- * Callers must expose the full value through an accessible label (FR-070).
137
- */
138
- export function truncateToWidth(text: string, fontSize: number, maxWidthPx: number): string {
139
- if (estimateTextWidth(text, fontSize) <= maxWidthPx) {
140
- return text;
141
- }
142
- const perChar = fontSize * AVERAGE_ADVANCE_RATIO;
143
- const fits = Math.max(1, Math.floor(maxWidthPx / perChar) - 1);
144
- return `${text.slice(0, fits)}…`;
145
- }
@@ -50,8 +50,13 @@ export function buildTimeSeriesGeometry<M>(
50
50
  const values: number[] = [];
51
51
  let excluded = 0;
52
52
 
53
+ // Sorted and windowed once per series; both passes below read the result.
54
+ const ordered = new Map(
55
+ datasource.series.map(series => [series, visiblePoints(series, options.range)] as const),
56
+ );
57
+
53
58
  for (const series of datasource.series) {
54
- for (const point of visiblePoints(series, options.range)) {
59
+ for (const point of ordered.get(series) ?? []) {
55
60
  if (point.position.kind === 'instant') {
56
61
  instants.push(point.position.at);
57
62
  }
@@ -104,7 +109,7 @@ export function buildTimeSeriesGeometry<M>(
104
109
 
105
110
  const pts: MaybePt[] = [];
106
111
 
107
- for (const point of visiblePoints(series, options.range)) {
112
+ for (const point of ordered.get(series) ?? []) {
108
113
  const at = point.position.kind === 'instant' ? point.position.at : 0;
109
114
  const px = x.project(at);
110
115
 
@@ -46,6 +46,13 @@ export interface GeometryLabel {
46
46
  readonly muted?: boolean;
47
47
  }
48
48
 
49
+ /** What a colour ramp runs from and to, for the legend that explains it. */
50
+ export interface ColorScale {
51
+ readonly min: number;
52
+ readonly max: number;
53
+ readonly colors: readonly string[];
54
+ }
55
+
49
56
  export interface ResolvedGeometry {
50
57
  readonly plot: PlotRect;
51
58
  readonly paths: readonly MarkPath[];
@@ -54,6 +61,8 @@ export interface ResolvedGeometry {
54
61
  readonly xTicks: readonly Tick[];
55
62
  readonly yTicks: readonly Tick[];
56
63
  readonly hitRegions: readonly HitRegion[];
64
+ /** Present when colour encodes a value, as in a heatmap. */
65
+ readonly colorScale?: ColorScale;
57
66
  /**
58
67
  * True when something was excluded from a total or proportion, so the chart
59
68
  * can say so rather than presenting a partial figure as whole (FR-013).
@@ -88,6 +97,8 @@ export interface GeometryInput {
88
97
  Record<'unknown' | 'notApplicable' | 'withheld', NonValueTexture>
89
98
  >;
90
99
  readonly heatmapRamp: readonly string[];
100
+ /** Corner radii from the theme, for bars and heatmap cells. */
101
+ readonly radius?: { readonly bar: number; readonly cell: number };
91
102
  readonly formatValue: (value: number) => string;
92
103
  readonly formatCategory: (raw: string) => string;
93
104
  readonly formatInstant: (at: number) => string;
package/src/index.ts CHANGED
@@ -62,6 +62,8 @@ export {
62
62
  resolveUnit,
63
63
  } from './contract/datasource';
64
64
 
65
+ export type { DistributionValues } from './contract/distribution';
66
+
65
67
  export type { MarkerKind, ProgressReading, ReferenceMarker } from './contract/marker';
66
68
  export { findTarget, isUsableTarget, readProgress } from './contract/marker';
67
69
 
@@ -149,10 +151,15 @@ export {
149
151
  TimeSeriesChart,
150
152
  } from './react/charts';
151
153
 
152
- export { ChartLegend, buildLegendEntries } from './react/ChartLegend';
154
+ export { ChartLegend, ColorScaleLegend, buildLegendEntries } from './react/ChartLegend';
155
+ export type { ColorScale } from './geometry/types';
153
156
  export { ChartThemeProvider, useChartTheme } from './react/ChartThemeProvider';
154
157
 
155
158
  // ---------------------------------------------------------------------------
156
159
  // Accessibility helpers
157
160
  // ---------------------------------------------------------------------------
158
161
  export { buildChartSummary, buildPointAnnouncement, buildTotalAnnouncement } from './a11y/summary';
162
+
163
+ // The package's own texts, for translation (CHT-05).
164
+ export { chartStrings } from './contract/strings';
165
+ export type { ChartStringKey, ChartStrings } from './contract/strings';
@@ -69,7 +69,7 @@ export class ChartBoundary extends React.Component<Props, State> {
69
69
  return { error: chartError('render', 'renderException') };
70
70
  }
71
71
 
72
- componentDidCatch(error: unknown, info: { componentStack?: string }): void {
72
+ override componentDidCatch(error: unknown, info: { componentStack?: string }): void {
73
73
  const report = toFailureReport(
74
74
  this.props.family,
75
75
  error,
@@ -87,7 +87,7 @@ export class ChartBoundary extends React.Component<Props, State> {
87
87
  this.props.onFailure?.(report);
88
88
  }
89
89
 
90
- render(): React.ReactNode {
90
+ override render(): React.ReactNode {
91
91
  if (this.state.error) {
92
92
  return this.props.renderFallback(this.state.error);
93
93
  }