@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/scale.ts
CHANGED
|
@@ -1,146 +1,140 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Scales: domain values to pixel positions.
|
|
3
|
-
*
|
|
4
|
-
* PURE. No React, no SVG — enforced by the ESLint override in `.eslintrc.js`
|
|
5
|
-
* and by geometryPurity.test.ts.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
export interface Range {
|
|
9
|
-
readonly min: number;
|
|
10
|
-
readonly max: number;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface LinearScale {
|
|
14
|
-
readonly domain: Range;
|
|
15
|
-
readonly rangePx: Range;
|
|
16
|
-
readonly project: (value: number) => number;
|
|
17
|
-
readonly invert: (px: number) => number;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Nudges a zero-width domain outward so an all-identical dataset still gets a
|
|
22
|
-
* usable axis. Without this the projection divides by zero and every point
|
|
23
|
-
* lands on the same pixel — or on NaN.
|
|
24
|
-
*/
|
|
25
|
-
function widenDegenerateDomain(domain: Range): Range {
|
|
26
|
-
if (domain.max > domain.min) {
|
|
27
|
-
return domain;
|
|
28
|
-
}
|
|
29
|
-
const value = domain.min;
|
|
30
|
-
// Scale the padding to the magnitude so large values do not get a
|
|
31
|
-
// meaninglessly tiny window, and zero still gets a sensible one.
|
|
32
|
-
const pad = value === 0 ? 1 : Math.abs(value) * 0.5;
|
|
33
|
-
return { min: value - pad, max: value + pad };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function linearScale(domain: Range, rangePx: Range): LinearScale {
|
|
37
|
-
const safeDomain = widenDegenerateDomain(domain);
|
|
38
|
-
const domainSpan = safeDomain.max - safeDomain.min;
|
|
39
|
-
const pxSpan = rangePx.max - rangePx.min;
|
|
40
|
-
|
|
41
|
-
return {
|
|
42
|
-
domain: safeDomain,
|
|
43
|
-
rangePx,
|
|
44
|
-
project: (value: number) =>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
readonly
|
|
53
|
-
readonly
|
|
54
|
-
readonly
|
|
55
|
-
readonly
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
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
|
-
max = Math.max(max, 0);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const pad = (max - min) * (options.padFraction ?? 0);
|
|
145
|
-
return { min: min - pad, max: max + pad };
|
|
146
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Scales: domain values to pixel positions.
|
|
3
|
+
*
|
|
4
|
+
* PURE. No React, no SVG — enforced by the ESLint override in `.eslintrc.js`
|
|
5
|
+
* and by geometryPurity.test.ts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface Range {
|
|
9
|
+
readonly min: number;
|
|
10
|
+
readonly max: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface LinearScale {
|
|
14
|
+
readonly domain: Range;
|
|
15
|
+
readonly rangePx: Range;
|
|
16
|
+
readonly project: (value: number) => number;
|
|
17
|
+
readonly invert: (px: number) => number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Nudges a zero-width domain outward so an all-identical dataset still gets a
|
|
22
|
+
* usable axis. Without this the projection divides by zero and every point
|
|
23
|
+
* lands on the same pixel — or on NaN.
|
|
24
|
+
*/
|
|
25
|
+
export function widenDegenerateDomain(domain: Range): Range {
|
|
26
|
+
if (domain.max > domain.min) {
|
|
27
|
+
return domain;
|
|
28
|
+
}
|
|
29
|
+
const value = domain.min;
|
|
30
|
+
// Scale the padding to the magnitude so large values do not get a
|
|
31
|
+
// meaninglessly tiny window, and zero still gets a sensible one.
|
|
32
|
+
const pad = value === 0 ? 1 : Math.abs(value) * 0.5;
|
|
33
|
+
return { min: value - pad, max: value + pad };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function linearScale(domain: Range, rangePx: Range): LinearScale {
|
|
37
|
+
const safeDomain = widenDegenerateDomain(domain);
|
|
38
|
+
const domainSpan = safeDomain.max - safeDomain.min;
|
|
39
|
+
const pxSpan = rangePx.max - rangePx.min;
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
domain: safeDomain,
|
|
43
|
+
rangePx,
|
|
44
|
+
project: (value: number) => rangePx.min + ((value - safeDomain.min) / domainSpan) * pxSpan,
|
|
45
|
+
invert: (px: number) => safeDomain.min + ((px - rangePx.min) / pxSpan) * domainSpan,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface BandScale {
|
|
50
|
+
readonly categories: readonly string[];
|
|
51
|
+
readonly bandWidth: number;
|
|
52
|
+
readonly step: number;
|
|
53
|
+
readonly project: (category: string) => number | undefined;
|
|
54
|
+
readonly centerOf: (category: string) => number | undefined;
|
|
55
|
+
readonly invert: (px: number) => string | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Discrete positions for category axes.
|
|
60
|
+
*
|
|
61
|
+
* `padding` is the fraction of each step left as gap, so 0.2 means bars occupy
|
|
62
|
+
* 80% of their slot.
|
|
63
|
+
*/
|
|
64
|
+
export function bandScale(categories: readonly string[], rangePx: Range, padding = 0.2): BandScale {
|
|
65
|
+
const count = Math.max(categories.length, 1);
|
|
66
|
+
const pxSpan = rangePx.max - rangePx.min;
|
|
67
|
+
const step = pxSpan / count;
|
|
68
|
+
const bandWidth = step * (1 - padding);
|
|
69
|
+
const offset = (step - bandWidth) / 2;
|
|
70
|
+
|
|
71
|
+
const indexOf = new Map<string, number>();
|
|
72
|
+
categories.forEach((c, i) => {
|
|
73
|
+
if (!indexOf.has(c)) {
|
|
74
|
+
indexOf.set(c, i);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const project = (category: string): number | undefined => {
|
|
79
|
+
const i = indexOf.get(category);
|
|
80
|
+
return i === undefined ? undefined : rangePx.min + i * step + offset;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
categories,
|
|
85
|
+
bandWidth,
|
|
86
|
+
step,
|
|
87
|
+
project,
|
|
88
|
+
centerOf: (category: string) => {
|
|
89
|
+
const start = project(category);
|
|
90
|
+
return start === undefined ? undefined : start + bandWidth / 2;
|
|
91
|
+
},
|
|
92
|
+
invert: (px: number) => {
|
|
93
|
+
const i = Math.floor((px - rangePx.min) / step);
|
|
94
|
+
return i >= 0 && i < categories.length ? categories[i] : undefined;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Time is just a linear scale over epoch milliseconds. Kept as a named
|
|
101
|
+
* function so call sites read clearly and so tick generation can branch on it.
|
|
102
|
+
*/
|
|
103
|
+
export function timeScale(domain: Range, rangePx: Range): LinearScale {
|
|
104
|
+
return linearScale(domain, rangePx);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Domain from known values, padded so marks do not touch the plot edges.
|
|
109
|
+
*
|
|
110
|
+
* `includeZero` matters for bar charts: a bar's length is only meaningful when
|
|
111
|
+
* measured from zero. Time-series charts leave it off so a narrow band of
|
|
112
|
+
* values stays legible.
|
|
113
|
+
*/
|
|
114
|
+
export function domainFrom(
|
|
115
|
+
values: readonly number[],
|
|
116
|
+
options: { includeZero?: boolean; padFraction?: number } = {},
|
|
117
|
+
): Range {
|
|
118
|
+
if (values.length === 0) {
|
|
119
|
+
return { min: 0, max: 1 };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let min = values[0];
|
|
123
|
+
let max = values[0];
|
|
124
|
+
for (const v of values) {
|
|
125
|
+
if (v < min) {
|
|
126
|
+
min = v;
|
|
127
|
+
}
|
|
128
|
+
if (v > max) {
|
|
129
|
+
max = v;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (options.includeZero) {
|
|
134
|
+
min = Math.min(min, 0);
|
|
135
|
+
max = Math.max(max, 0);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const pad = (max - min) * (options.padFraction ?? 0);
|
|
139
|
+
return { min: min - pad, max: max + pad };
|
|
140
|
+
}
|
package/src/geometry/ticks.ts
CHANGED
|
@@ -1,153 +1,145 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tick generation and label thinning (FR-070).
|
|
3
|
-
*
|
|
4
|
-
* React Native offers no synchronous text measurement, and an `onLayout`
|
|
5
|
-
* round-trip per label would cost a second layout pass — enough to breach the
|
|
6
|
-
* first-paint budget. So we estimate width from character count and thin ticks
|
|
7
|
-
* to fit.
|
|
8
|
-
*
|
|
9
|
-
* The estimate is deliberately generous: over-estimating drops one tick too
|
|
10
|
-
* many, which is legible. Under-estimating produces overlapping text, which is
|
|
11
|
-
* the failure FR-070 exists to prevent. See research.md R6.
|
|
12
|
-
*
|
|
13
|
-
* PURE. No React, no SVG.
|
|
14
|
-
*/
|
|
15
|
-
import type { Range } from './scale';
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Average glyph advance as a fraction of font size, for the Latin digits and
|
|
19
|
-
* letters that dominate axis labels. Rounded up from the ~0.5-0.55 typical of
|
|
20
|
-
* the platform sans-serif faces.
|
|
21
|
-
*/
|
|
22
|
-
export const AVERAGE_ADVANCE_RATIO = 0.62;
|
|
23
|
-
|
|
24
|
-
/** Minimum breathing room between two adjacent labels, in px. */
|
|
25
|
-
export const LABEL_GUTTER = 8;
|
|
26
|
-
|
|
27
|
-
export function estimateTextWidth(text: string, fontSize: number): number {
|
|
28
|
-
return text.length * fontSize * AVERAGE_ADVANCE_RATIO;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface Tick {
|
|
32
|
-
/** Domain value this tick represents. */
|
|
33
|
-
readonly value: number;
|
|
34
|
-
/** Pixel position along the axis. */
|
|
35
|
-
readonly position: number;
|
|
36
|
-
readonly label: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* "Nice" step sizes: 1, 2, 5 and their powers of ten. Standard axis practice —
|
|
41
|
-
* these are the intervals people read fluently.
|
|
42
|
-
*/
|
|
43
|
-
function niceStep(rawStep: number): number {
|
|
44
|
-
if (rawStep <= 0 || !Number.isFinite(rawStep)) {
|
|
45
|
-
return 1;
|
|
46
|
-
}
|
|
47
|
-
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
|
48
|
-
const normalized = rawStep / magnitude;
|
|
49
|
-
|
|
50
|
-
if (normalized <= 1) {
|
|
51
|
-
return magnitude;
|
|
52
|
-
}
|
|
53
|
-
if (normalized <= 2) {
|
|
54
|
-
return 2 * magnitude;
|
|
55
|
-
}
|
|
56
|
-
if (normalized <= 5) {
|
|
57
|
-
return 5 * magnitude;
|
|
58
|
-
}
|
|
59
|
-
return 10 * magnitude;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function niceTickValues(domain: Range, targetCount: number): number[] {
|
|
63
|
-
const span = domain.max - domain.min;
|
|
64
|
-
if (span <= 0 || targetCount < 1) {
|
|
65
|
-
return [domain.min];
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const step = niceStep(span / targetCount);
|
|
69
|
-
const start = Math.ceil(domain.min / step) * step;
|
|
70
|
-
|
|
71
|
-
const values: number[] = [];
|
|
72
|
-
// Guard against a pathological step producing an unbounded loop.
|
|
73
|
-
for (let v = start, guard = 0; v <= domain.max && guard < 1000; v += step) {
|
|
74
|
-
// Re-round to kill floating-point drift such as 0.30000000000000004.
|
|
75
|
-
values.push(Number((Math.round(v / step) * step).toFixed(10)));
|
|
76
|
-
guard += 1;
|
|
77
|
-
}
|
|
78
|
-
return values.length > 0 ? values : [domain.min];
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Drops ticks until no two labels can overlap.
|
|
83
|
-
*
|
|
84
|
-
* Thinning is preferred over rotation as the default: rotated labels are harder
|
|
85
|
-
* to read on a phone and cost vertical space a mobile chart cannot spare.
|
|
86
|
-
* Rotation stays available as an opt-in for dense category axes.
|
|
87
|
-
*
|
|
88
|
-
* The first tick is always kept, so an axis never renders empty.
|
|
89
|
-
*/
|
|
90
|
-
export function thinTicks(
|
|
91
|
-
ticks
|
|
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
|
-
labels
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
)
|
|
123
|
-
|
|
124
|
-
return
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
): string {
|
|
147
|
-
if (estimateTextWidth(text, fontSize) <= maxWidthPx) {
|
|
148
|
-
return text;
|
|
149
|
-
}
|
|
150
|
-
const perChar = fontSize * AVERAGE_ADVANCE_RATIO;
|
|
151
|
-
const fits = Math.max(1, Math.floor(maxWidthPx / perChar) - 1);
|
|
152
|
-
return `${text.slice(0, fits)}…`;
|
|
153
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Tick generation and label thinning (FR-070).
|
|
3
|
+
*
|
|
4
|
+
* React Native offers no synchronous text measurement, and an `onLayout`
|
|
5
|
+
* round-trip per label would cost a second layout pass — enough to breach the
|
|
6
|
+
* first-paint budget. So we estimate width from character count and thin ticks
|
|
7
|
+
* to fit.
|
|
8
|
+
*
|
|
9
|
+
* The estimate is deliberately generous: over-estimating drops one tick too
|
|
10
|
+
* many, which is legible. Under-estimating produces overlapping text, which is
|
|
11
|
+
* the failure FR-070 exists to prevent. See research.md R6.
|
|
12
|
+
*
|
|
13
|
+
* PURE. No React, no SVG.
|
|
14
|
+
*/
|
|
15
|
+
import type { Range } from './scale';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Average glyph advance as a fraction of font size, for the Latin digits and
|
|
19
|
+
* letters that dominate axis labels. Rounded up from the ~0.5-0.55 typical of
|
|
20
|
+
* the platform sans-serif faces.
|
|
21
|
+
*/
|
|
22
|
+
export const AVERAGE_ADVANCE_RATIO = 0.62;
|
|
23
|
+
|
|
24
|
+
/** Minimum breathing room between two adjacent labels, in px. */
|
|
25
|
+
export const LABEL_GUTTER = 8;
|
|
26
|
+
|
|
27
|
+
export function estimateTextWidth(text: string, fontSize: number): number {
|
|
28
|
+
return text.length * fontSize * AVERAGE_ADVANCE_RATIO;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Tick {
|
|
32
|
+
/** Domain value this tick represents. */
|
|
33
|
+
readonly value: number;
|
|
34
|
+
/** Pixel position along the axis. */
|
|
35
|
+
readonly position: number;
|
|
36
|
+
readonly label: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* "Nice" step sizes: 1, 2, 5 and their powers of ten. Standard axis practice —
|
|
41
|
+
* these are the intervals people read fluently.
|
|
42
|
+
*/
|
|
43
|
+
function niceStep(rawStep: number): number {
|
|
44
|
+
if (rawStep <= 0 || !Number.isFinite(rawStep)) {
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
|
48
|
+
const normalized = rawStep / magnitude;
|
|
49
|
+
|
|
50
|
+
if (normalized <= 1) {
|
|
51
|
+
return magnitude;
|
|
52
|
+
}
|
|
53
|
+
if (normalized <= 2) {
|
|
54
|
+
return 2 * magnitude;
|
|
55
|
+
}
|
|
56
|
+
if (normalized <= 5) {
|
|
57
|
+
return 5 * magnitude;
|
|
58
|
+
}
|
|
59
|
+
return 10 * magnitude;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function niceTickValues(domain: Range, targetCount: number): number[] {
|
|
63
|
+
const span = domain.max - domain.min;
|
|
64
|
+
if (span <= 0 || targetCount < 1) {
|
|
65
|
+
return [domain.min];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const step = niceStep(span / targetCount);
|
|
69
|
+
const start = Math.ceil(domain.min / step) * step;
|
|
70
|
+
|
|
71
|
+
const values: number[] = [];
|
|
72
|
+
// Guard against a pathological step producing an unbounded loop.
|
|
73
|
+
for (let v = start, guard = 0; v <= domain.max && guard < 1000; v += step) {
|
|
74
|
+
// Re-round to kill floating-point drift such as 0.30000000000000004.
|
|
75
|
+
values.push(Number((Math.round(v / step) * step).toFixed(10)));
|
|
76
|
+
guard += 1;
|
|
77
|
+
}
|
|
78
|
+
return values.length > 0 ? values : [domain.min];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Drops ticks until no two labels can overlap.
|
|
83
|
+
*
|
|
84
|
+
* Thinning is preferred over rotation as the default: rotated labels are harder
|
|
85
|
+
* to read on a phone and cost vertical space a mobile chart cannot spare.
|
|
86
|
+
* Rotation stays available as an opt-in for dense category axes.
|
|
87
|
+
*
|
|
88
|
+
* The first tick is always kept, so an axis never renders empty.
|
|
89
|
+
*/
|
|
90
|
+
export function thinTicks(ticks: readonly Tick[], fontSize: number, axisLengthPx: number): Tick[] {
|
|
91
|
+
if (ticks.length <= 1) {
|
|
92
|
+
return [...ticks];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let widest = 0;
|
|
96
|
+
for (const t of ticks) {
|
|
97
|
+
widest = Math.max(widest, estimateTextWidth(t.label, fontSize));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const slot = widest + LABEL_GUTTER;
|
|
101
|
+
const maxLabels = Math.max(1, Math.floor(axisLengthPx / slot));
|
|
102
|
+
if (ticks.length <= maxLabels) {
|
|
103
|
+
return [...ticks];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const stride = Math.ceil(ticks.length / maxLabels);
|
|
107
|
+
return ticks.filter((_, i) => i % stride === 0);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* How much space an axis must reserve for its labels, at the current system
|
|
112
|
+
* font scale (FR-071).
|
|
113
|
+
*/
|
|
114
|
+
export function reserveAxisSpace(
|
|
115
|
+
labels: readonly string[],
|
|
116
|
+
fontSize: number,
|
|
117
|
+
orientation: 'horizontal' | 'vertical',
|
|
118
|
+
): number {
|
|
119
|
+
if (labels.length === 0) {
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
if (orientation === 'horizontal') {
|
|
123
|
+
// One line of text plus a gutter.
|
|
124
|
+
return fontSize * 1.4 + LABEL_GUTTER;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let widest = 0;
|
|
128
|
+
for (const label of labels) {
|
|
129
|
+
widest = Math.max(widest, estimateTextWidth(label, fontSize));
|
|
130
|
+
}
|
|
131
|
+
return widest + LABEL_GUTTER;
|
|
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
|
+
}
|