@chipmobilesdk/rn-chart 0.1.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 +74 -0
- package/README.md +358 -0
- package/package.json +48 -0
- package/src/a11y/summary.ts +177 -0
- package/src/contract/datasource.ts +134 -0
- package/src/contract/errors.ts +133 -0
- package/src/contract/format.ts +168 -0
- package/src/contract/interaction.ts +71 -0
- package/src/contract/marker.ts +75 -0
- package/src/contract/props.ts +60 -0
- package/src/contract/state.ts +94 -0
- package/src/contract/theme.ts +139 -0
- package/src/contract/value.ts +125 -0
- package/src/geometry/bar.ts +256 -0
- package/src/geometry/distribution.ts +200 -0
- package/src/geometry/heatmap.ts +195 -0
- package/src/geometry/hitTest.ts +120 -0
- package/src/geometry/layout.ts +121 -0
- package/src/geometry/path.ts +199 -0
- package/src/geometry/progress.ts +204 -0
- package/src/geometry/scale.ts +146 -0
- package/src/geometry/ticks.ts +153 -0
- package/src/geometry/timeSeries.ts +158 -0
- package/src/geometry/types.ts +88 -0
- package/src/index.ts +176 -0
- package/src/react/ChartBoundary.tsx +96 -0
- package/src/react/ChartFrame.tsx +620 -0
- package/src/react/ChartLegend.tsx +177 -0
- package/src/react/ChartThemeProvider.tsx +48 -0
- package/src/react/charts.tsx +161 -0
- package/src/react/useChartLayout.ts +124 -0
- package/src/react/useChartSelection.ts +170 -0
- package/src/render/Axis.tsx +122 -0
- package/src/render/Marks.tsx +205 -0
- package/src/validation/budgets.ts +110 -0
- package/src/validation/validateDatasource.ts +174 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chart status contract (FR-029 to FR-038).
|
|
3
|
+
*
|
|
4
|
+
* Ownership is split on a simple principle: the package derives what only the
|
|
5
|
+
* data can determine, and the app declares what only the app can know.
|
|
6
|
+
*
|
|
7
|
+
* empty, partial -> derived from the datasource
|
|
8
|
+
* loading, stale, -> declared by the app: it alone knows about its own
|
|
9
|
+
* noPermission, fetches, cache age, access rules, and failures
|
|
10
|
+
* error
|
|
11
|
+
*/
|
|
12
|
+
import type { ReactNode } from 'react';
|
|
13
|
+
|
|
14
|
+
import type { ChartError } from './errors';
|
|
15
|
+
|
|
16
|
+
export type ChartStatus =
|
|
17
|
+
| 'loading'
|
|
18
|
+
| 'empty'
|
|
19
|
+
| 'ready'
|
|
20
|
+
| 'error'
|
|
21
|
+
| 'stale'
|
|
22
|
+
| 'noPermission'
|
|
23
|
+
| 'partial';
|
|
24
|
+
|
|
25
|
+
/** The statuses an app may declare. `empty` and `partial` are derived. */
|
|
26
|
+
export type DeclarableChartStatus = Exclude<ChartStatus, 'empty' | 'partial'>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolution precedence, highest first.
|
|
30
|
+
*
|
|
31
|
+
* `noPermission` outranks everything so restricted values can never slip
|
|
32
|
+
* through some other state. `stale` and `partial` sit below the blocking
|
|
33
|
+
* states because they are modifiers on a chart that still draws its data.
|
|
34
|
+
*/
|
|
35
|
+
export const STATUS_PRECEDENCE: readonly ChartStatus[] = [
|
|
36
|
+
'noPermission',
|
|
37
|
+
'error',
|
|
38
|
+
'loading',
|
|
39
|
+
'empty',
|
|
40
|
+
'stale',
|
|
41
|
+
'partial',
|
|
42
|
+
'ready',
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export interface StatusInputs {
|
|
46
|
+
/** What the app declared, if anything. */
|
|
47
|
+
readonly declared?: DeclarableChartStatus;
|
|
48
|
+
/** True when the datasource carries no points at all. */
|
|
49
|
+
readonly isEmpty: boolean;
|
|
50
|
+
/** True when some points are valid and others are unknown/n-a/withheld. */
|
|
51
|
+
readonly isPartial: boolean;
|
|
52
|
+
/** A validation failure or a contained render exception. */
|
|
53
|
+
readonly error?: ChartError;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function resolveStatus(inputs: StatusInputs): ChartStatus {
|
|
57
|
+
const candidates = new Set<ChartStatus>(['ready']);
|
|
58
|
+
|
|
59
|
+
if (inputs.declared) {
|
|
60
|
+
candidates.add(inputs.declared);
|
|
61
|
+
}
|
|
62
|
+
if (inputs.error) {
|
|
63
|
+
candidates.add('error');
|
|
64
|
+
}
|
|
65
|
+
if (inputs.isEmpty) {
|
|
66
|
+
candidates.add('empty');
|
|
67
|
+
}
|
|
68
|
+
if (inputs.isPartial) {
|
|
69
|
+
candidates.add('partial');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const status of STATUS_PRECEDENCE) {
|
|
73
|
+
if (candidates.has(status)) {
|
|
74
|
+
return status;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return 'ready';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Statuses in which the chart draws its data (possibly with an indicator). */
|
|
81
|
+
export function drawsData(status: ChartStatus): boolean {
|
|
82
|
+
return status === 'ready' || status === 'stale' || status === 'partial';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Statuses in which no data-selection callback may fire, because the chart is
|
|
87
|
+
* not displaying the values a callback would carry (FR-053).
|
|
88
|
+
*/
|
|
89
|
+
export function suppressesInteraction(status: ChartStatus): boolean {
|
|
90
|
+
return !drawsData(status);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Per-status content the app supplies, replacing any package wording (FR-035). */
|
|
94
|
+
export type ChartStateContent = Partial<Record<ChartStatus, ReactNode>>;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme contract (FR-060 to FR-065).
|
|
3
|
+
*
|
|
4
|
+
* A plain value type rather than a dependency. The package declares what it
|
|
5
|
+
* needs and any design system can satisfy it — which is what keeps
|
|
6
|
+
* `@chipmobilesdk/rn-theme` and `react-native-unistyles` out of this package's
|
|
7
|
+
* dependency list entirely. The demo app owns the mapping adapter.
|
|
8
|
+
*
|
|
9
|
+
* Non-colour encodings are first-class here, because meaning must never ride on
|
|
10
|
+
* hue alone (FR-064).
|
|
11
|
+
*/
|
|
12
|
+
import type { ValueKind } from './value';
|
|
13
|
+
|
|
14
|
+
export interface TextStyleTokens {
|
|
15
|
+
readonly fontSize: number;
|
|
16
|
+
readonly fontWeight?: '400' | '500' | '600' | '700';
|
|
17
|
+
readonly color: string;
|
|
18
|
+
readonly letterSpacing?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** How the three non-value classes are drawn: texture, never a hue difference. */
|
|
22
|
+
export type NonValueTexture = 'gap' | 'hatch' | 'lockedHatch';
|
|
23
|
+
|
|
24
|
+
export interface ChartTheme {
|
|
25
|
+
readonly colorScheme: 'light' | 'dark';
|
|
26
|
+
/** Categorical fallback, at least 8 entries, distinguishable in greyscale. */
|
|
27
|
+
readonly palette: readonly string[];
|
|
28
|
+
readonly surface: string;
|
|
29
|
+
readonly onSurface: string;
|
|
30
|
+
readonly axis: string;
|
|
31
|
+
readonly grid: string;
|
|
32
|
+
readonly muted: string;
|
|
33
|
+
readonly typography: {
|
|
34
|
+
readonly axisLabel: TextStyleTokens;
|
|
35
|
+
readonly legendLabel: TextStyleTokens;
|
|
36
|
+
readonly valueLabel: TextStyleTokens;
|
|
37
|
+
};
|
|
38
|
+
readonly spacing: {
|
|
39
|
+
readonly xs: number;
|
|
40
|
+
readonly sm: number;
|
|
41
|
+
readonly md: number;
|
|
42
|
+
};
|
|
43
|
+
readonly radius: {
|
|
44
|
+
readonly bar: number;
|
|
45
|
+
readonly cell: number;
|
|
46
|
+
};
|
|
47
|
+
readonly nonValue: Readonly<
|
|
48
|
+
Record<Exclude<ValueKind, 'known'>, NonValueTexture>
|
|
49
|
+
>;
|
|
50
|
+
/** Ramp for heatmap intensity, low to high. */
|
|
51
|
+
readonly heatmapRamp: readonly string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface LegendEntry {
|
|
55
|
+
readonly seriesId: string;
|
|
56
|
+
readonly label: string;
|
|
57
|
+
readonly color: string;
|
|
58
|
+
readonly pattern: 'solid' | 'dashed' | 'dotted' | 'dashDot';
|
|
59
|
+
readonly visible: boolean;
|
|
60
|
+
readonly toggleable: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Eight categorical colours per scheme.
|
|
65
|
+
*
|
|
66
|
+
* Chosen for separation in hue *and* lightness, so they stay distinguishable
|
|
67
|
+
* under both common colour-vision deficiencies and a greyscale review. Colour
|
|
68
|
+
* is never the only carrier of meaning regardless — series also differ by
|
|
69
|
+
* legend label and, for lines, by dash pattern.
|
|
70
|
+
*/
|
|
71
|
+
const LIGHT_PALETTE: readonly string[] = [
|
|
72
|
+
'#2563EB',
|
|
73
|
+
'#D97706',
|
|
74
|
+
'#059669',
|
|
75
|
+
'#DB2777',
|
|
76
|
+
'#7C3AED',
|
|
77
|
+
'#0891B2',
|
|
78
|
+
'#B45309',
|
|
79
|
+
'#4B5563',
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const DARK_PALETTE: readonly string[] = [
|
|
83
|
+
'#60A5FA',
|
|
84
|
+
'#FBBF24',
|
|
85
|
+
'#34D399',
|
|
86
|
+
'#F472B6',
|
|
87
|
+
'#A78BFA',
|
|
88
|
+
'#22D3EE',
|
|
89
|
+
'#FCD34D',
|
|
90
|
+
'#9CA3AF',
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const NON_VALUE_TEXTURES = {
|
|
94
|
+
unknown: 'gap',
|
|
95
|
+
notApplicable: 'hatch',
|
|
96
|
+
withheld: 'lockedHatch',
|
|
97
|
+
} as const;
|
|
98
|
+
|
|
99
|
+
export function defaultChartTheme(
|
|
100
|
+
colorScheme: 'light' | 'dark' = 'light',
|
|
101
|
+
): ChartTheme {
|
|
102
|
+
const dark = colorScheme === 'dark';
|
|
103
|
+
|
|
104
|
+
const onSurface = dark ? '#F3F4F6' : '#111827';
|
|
105
|
+
const muted = dark ? '#9CA3AF' : '#6B7280';
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
colorScheme,
|
|
109
|
+
palette: dark ? DARK_PALETTE : LIGHT_PALETTE,
|
|
110
|
+
surface: dark ? '#111827' : '#FFFFFF',
|
|
111
|
+
onSurface,
|
|
112
|
+
axis: dark ? '#4B5563' : '#D1D5DB',
|
|
113
|
+
grid: dark ? '#374151' : '#E5E7EB',
|
|
114
|
+
muted,
|
|
115
|
+
typography: {
|
|
116
|
+
axisLabel: { fontSize: 11, fontWeight: '400', color: muted },
|
|
117
|
+
legendLabel: { fontSize: 12, fontWeight: '500', color: onSurface },
|
|
118
|
+
valueLabel: { fontSize: 13, fontWeight: '600', color: onSurface },
|
|
119
|
+
},
|
|
120
|
+
spacing: { xs: 4, sm: 8, md: 16 },
|
|
121
|
+
radius: { bar: 3, cell: 2 },
|
|
122
|
+
nonValue: NON_VALUE_TEXTURES,
|
|
123
|
+
heatmapRamp: dark
|
|
124
|
+
? ['#1F2937', '#1E3A8A', '#1D4ED8', '#3B82F6', '#93C5FD']
|
|
125
|
+
: ['#EFF6FF', '#BFDBFE', '#60A5FA', '#2563EB', '#1E3A8A'],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Assigns a colour to a series: explicit override wins, otherwise the palette
|
|
131
|
+
* cycles by index so the remaining series stay distinguishable (FR-062, FR-063).
|
|
132
|
+
*/
|
|
133
|
+
export function resolveSeriesColor(
|
|
134
|
+
theme: ChartTheme,
|
|
135
|
+
index: number,
|
|
136
|
+
override?: string,
|
|
137
|
+
): string {
|
|
138
|
+
return override ?? theme.palette[index % theme.palette.length];
|
|
139
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Value semantics for @chipmobilesdk/rn-chart.
|
|
3
|
+
*
|
|
4
|
+
* The foundation of the whole contract. Four value classes, carried as a
|
|
5
|
+
* discriminated union with **no numeric field on the three non-value cases**.
|
|
6
|
+
*
|
|
7
|
+
* That shape is deliberate: with a nullable `number`, `value ?? 0` would
|
|
8
|
+
* compile, and turning a gap into a zero is the single most damaging thing a
|
|
9
|
+
* charting library can do to someone's data. Here there is simply no number to
|
|
10
|
+
* read unless the value is known.
|
|
11
|
+
*
|
|
12
|
+
* See specs/007-rn-chart-package/data-model.md §1 (FR-011, FR-012).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** A real observation, including a genuine `0`. */
|
|
16
|
+
export interface KnownValue {
|
|
17
|
+
readonly kind: 'known';
|
|
18
|
+
readonly value: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** We do not know this value. Never rendered as zero, never interpolated. */
|
|
22
|
+
export interface UnknownValue {
|
|
23
|
+
readonly kind: 'unknown';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** This value does not apply here. Excluded from totals and proportions. */
|
|
27
|
+
export interface NotApplicableValue {
|
|
28
|
+
readonly kind: 'notApplicable';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Hidden from this viewer: permission, or not enough data to disclose. */
|
|
32
|
+
export interface WithheldValue {
|
|
33
|
+
readonly kind: 'withheld';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type ValueState =
|
|
37
|
+
| KnownValue
|
|
38
|
+
| UnknownValue
|
|
39
|
+
| NotApplicableValue
|
|
40
|
+
| WithheldValue;
|
|
41
|
+
|
|
42
|
+
export type ValueKind = ValueState['kind'];
|
|
43
|
+
|
|
44
|
+
const UNKNOWN: UnknownValue = { kind: 'unknown' };
|
|
45
|
+
const NOT_APPLICABLE: NotApplicableValue = { kind: 'notApplicable' };
|
|
46
|
+
const WITHHELD: WithheldValue = { kind: 'withheld' };
|
|
47
|
+
|
|
48
|
+
export function known(value: number): KnownValue {
|
|
49
|
+
return { kind: 'known', value };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Note: shadows the `unknown` *type* keyword in value position only. TypeScript
|
|
54
|
+
* keeps type and value namespaces separate, so `x: unknown` still means the
|
|
55
|
+
* type even in modules that import this helper.
|
|
56
|
+
*/
|
|
57
|
+
export function unknown(): UnknownValue {
|
|
58
|
+
return UNKNOWN;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function notApplicable(): NotApplicableValue {
|
|
62
|
+
return NOT_APPLICABLE;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function withheld(): WithheldValue {
|
|
66
|
+
return WITHHELD;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isKnown(v: ValueState): v is KnownValue {
|
|
70
|
+
return v.kind === 'known';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* True when the value must never reach a rendering, a label, a summary, or an
|
|
75
|
+
* interaction callback (FR-033, FR-053). Withheld values carry no number at
|
|
76
|
+
* all, so this is about intent rather than data hiding.
|
|
77
|
+
*/
|
|
78
|
+
export function isWithheld(v: ValueState): v is WithheldValue {
|
|
79
|
+
return v.kind === 'withheld';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* True when the point takes part in totals, proportions, and axis domains.
|
|
84
|
+
* `notApplicable` and `withheld` are excluded; excluding either makes a total
|
|
85
|
+
* incomplete (FR-013).
|
|
86
|
+
*/
|
|
87
|
+
export function participatesInTotals(v: ValueState): boolean {
|
|
88
|
+
return v.kind === 'known';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface TotalResult {
|
|
92
|
+
readonly total: number;
|
|
93
|
+
/** True when any point was excluded, so the total is not the whole picture. */
|
|
94
|
+
readonly incomplete: boolean;
|
|
95
|
+
readonly countedPoints: number;
|
|
96
|
+
readonly excludedPoints: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Sums known values and reports whether anything was left out (FR-013).
|
|
101
|
+
*
|
|
102
|
+
* `unknown` also excludes and marks the total incomplete: we cannot claim a sum
|
|
103
|
+
* is complete when part of the data is missing.
|
|
104
|
+
*/
|
|
105
|
+
export function sumKnown(values: readonly ValueState[]): TotalResult {
|
|
106
|
+
let total = 0;
|
|
107
|
+
let counted = 0;
|
|
108
|
+
let excluded = 0;
|
|
109
|
+
|
|
110
|
+
for (const v of values) {
|
|
111
|
+
if (v.kind === 'known') {
|
|
112
|
+
total += v.value;
|
|
113
|
+
counted += 1;
|
|
114
|
+
} else {
|
|
115
|
+
excluded += 1;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
total,
|
|
121
|
+
incomplete: excluded > 0,
|
|
122
|
+
countedPoints: counted,
|
|
123
|
+
excludedPoints: excluded,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bar geometry: one batched path per series, grouped or stacked.
|
|
3
|
+
*
|
|
4
|
+
* Bars measure length from zero, so the domain always includes it — otherwise
|
|
5
|
+
* a bar's length means nothing. Negative values place the baseline correctly
|
|
6
|
+
* rather than clamping to the plot floor.
|
|
7
|
+
*
|
|
8
|
+
* PURE. No React, no SVG.
|
|
9
|
+
*/
|
|
10
|
+
import type { ChartDatasource, Position, Series } from '../contract/datasource';
|
|
11
|
+
import { isKnown, isWithheld } from '../contract/value';
|
|
12
|
+
import type { HitRegion } from './hitTest';
|
|
13
|
+
import { plotBottom, plotRight } from './layout';
|
|
14
|
+
import { rectBatchPath, type RectSpec } from './path';
|
|
15
|
+
import { bandScale, domainFrom, linearScale } from './scale';
|
|
16
|
+
import { thinTicks, type Tick } from './ticks';
|
|
17
|
+
import type { GeometryInput, MarkPath, ResolvedGeometry } from './types';
|
|
18
|
+
|
|
19
|
+
export type BarGrouping = 'grouped' | 'stacked';
|
|
20
|
+
|
|
21
|
+
export interface BarOptions {
|
|
22
|
+
readonly grouping?: BarGrouping;
|
|
23
|
+
readonly radius?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Bars accept both category and instant positions — that dual acceptance is
|
|
28
|
+
* what makes swapping a time-series chart for a bar chart free. Both collapse
|
|
29
|
+
* to a string key for the band scale.
|
|
30
|
+
*/
|
|
31
|
+
function categoryKey(position: Position): string {
|
|
32
|
+
switch (position.kind) {
|
|
33
|
+
case 'category':
|
|
34
|
+
return position.category;
|
|
35
|
+
case 'instant':
|
|
36
|
+
return String(position.at);
|
|
37
|
+
default:
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function countExcluded<M>(datasource: ChartDatasource<M>): number {
|
|
43
|
+
let excluded = 0;
|
|
44
|
+
for (const series of datasource.series) {
|
|
45
|
+
for (const point of series.points) {
|
|
46
|
+
if (!isKnown(point.value)) {
|
|
47
|
+
excluded += 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return excluded;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function groupedDomainValues<M>(datasource: ChartDatasource<M>): number[] {
|
|
55
|
+
const values: number[] = [];
|
|
56
|
+
for (const series of datasource.series) {
|
|
57
|
+
for (const point of series.points) {
|
|
58
|
+
if (isKnown(point.value)) {
|
|
59
|
+
values.push(point.value.value);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return values;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A stacked axis must fit the stack totals, not the individual values.
|
|
68
|
+
* Positive and negative parts accumulate separately so a stack straddling zero
|
|
69
|
+
* still fits.
|
|
70
|
+
*/
|
|
71
|
+
function stackedDomainValues<M>(
|
|
72
|
+
categories: readonly string[],
|
|
73
|
+
visible: readonly Series<M>[],
|
|
74
|
+
): number[] {
|
|
75
|
+
const values: number[] = [];
|
|
76
|
+
|
|
77
|
+
for (const category of categories) {
|
|
78
|
+
let positive = 0;
|
|
79
|
+
let negative = 0;
|
|
80
|
+
|
|
81
|
+
for (const series of visible) {
|
|
82
|
+
const point = series.points.find(
|
|
83
|
+
p => categoryKey(p.position) === category,
|
|
84
|
+
);
|
|
85
|
+
if (point && isKnown(point.value)) {
|
|
86
|
+
if (point.value.value >= 0) {
|
|
87
|
+
positive += point.value.value;
|
|
88
|
+
} else {
|
|
89
|
+
negative += point.value.value;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
values.push(positive, negative);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return values;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function groupedBarX(
|
|
100
|
+
bandStart: number,
|
|
101
|
+
bandWidth: number,
|
|
102
|
+
seriesIndex: number,
|
|
103
|
+
seriesCount: number,
|
|
104
|
+
): { barX: number; barWidth: number } {
|
|
105
|
+
const barWidth = bandWidth / Math.max(seriesCount, 1);
|
|
106
|
+
return { barX: bandStart + seriesIndex * barWidth, barWidth };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Grows from the zero baseline, upward or downward as the sign requires. */
|
|
110
|
+
function groupedBarY(
|
|
111
|
+
value: number,
|
|
112
|
+
zeroY: number,
|
|
113
|
+
project: (v: number) => number,
|
|
114
|
+
): { top: number; height: number } {
|
|
115
|
+
const valueY = project(value);
|
|
116
|
+
return { top: Math.min(zeroY, valueY), height: Math.abs(valueY - zeroY) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Grows from wherever the running stack for this sign currently ends. */
|
|
120
|
+
function stackedBarY(
|
|
121
|
+
value: number,
|
|
122
|
+
category: string,
|
|
123
|
+
stack: Map<string, number>,
|
|
124
|
+
project: (v: number) => number,
|
|
125
|
+
): { top: number; height: number } {
|
|
126
|
+
const base = stack.get(category) ?? 0;
|
|
127
|
+
const from = project(base);
|
|
128
|
+
const to = project(base + value);
|
|
129
|
+
stack.set(category, base + value);
|
|
130
|
+
return { top: Math.min(from, to), height: Math.abs(to - from) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function buildBarGeometry<M>(
|
|
134
|
+
datasource: ChartDatasource<M>,
|
|
135
|
+
input: GeometryInput,
|
|
136
|
+
options: BarOptions = {},
|
|
137
|
+
): ResolvedGeometry {
|
|
138
|
+
const { plot } = input;
|
|
139
|
+
const grouping: BarGrouping = options.grouping ?? 'grouped';
|
|
140
|
+
|
|
141
|
+
// Preserve first-seen order rather than sorting: the app chose this order.
|
|
142
|
+
const categories: string[] = [];
|
|
143
|
+
const seen = new Set<string>();
|
|
144
|
+
for (const series of datasource.series) {
|
|
145
|
+
for (const point of series.points) {
|
|
146
|
+
const key = categoryKey(point.position);
|
|
147
|
+
if (key && !seen.has(key)) {
|
|
148
|
+
seen.add(key);
|
|
149
|
+
categories.push(key);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const visible = datasource.series.filter(s => !s.hidden);
|
|
155
|
+
const excluded = countExcluded(datasource);
|
|
156
|
+
const values =
|
|
157
|
+
grouping === 'stacked'
|
|
158
|
+
? stackedDomainValues(categories, visible)
|
|
159
|
+
: groupedDomainValues(datasource);
|
|
160
|
+
|
|
161
|
+
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
|
+
);
|
|
166
|
+
const zeroY = y.project(0);
|
|
167
|
+
|
|
168
|
+
const paths: MarkPath[] = [];
|
|
169
|
+
const hitRegions: HitRegion[] = [];
|
|
170
|
+
|
|
171
|
+
// Running stack offsets, tracked separately per sign so negative bars grow
|
|
172
|
+
// downward from zero while positive bars grow upward.
|
|
173
|
+
const stackUp = new Map<string, number>();
|
|
174
|
+
const stackDown = new Map<string, number>();
|
|
175
|
+
|
|
176
|
+
visible.forEach((series, seriesIndex) => {
|
|
177
|
+
const rects: RectSpec[] = [];
|
|
178
|
+
|
|
179
|
+
for (const point of series.points) {
|
|
180
|
+
const category = categoryKey(point.position);
|
|
181
|
+
const bandStart = xBand.project(category);
|
|
182
|
+
if (bandStart === undefined || !isKnown(point.value)) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const value = point.value.value;
|
|
187
|
+
|
|
188
|
+
const { barX, barWidth } =
|
|
189
|
+
grouping === 'grouped'
|
|
190
|
+
? groupedBarX(bandStart, xBand.bandWidth, seriesIndex, visible.length)
|
|
191
|
+
: { barX: bandStart, barWidth: xBand.bandWidth };
|
|
192
|
+
|
|
193
|
+
const stack = value >= 0 ? stackUp : stackDown;
|
|
194
|
+
const { top, height } =
|
|
195
|
+
grouping === 'stacked'
|
|
196
|
+
? stackedBarY(value, category, stack, y.project)
|
|
197
|
+
: groupedBarY(value, zeroY, y.project);
|
|
198
|
+
|
|
199
|
+
rects.push({
|
|
200
|
+
x: barX,
|
|
201
|
+
y: top,
|
|
202
|
+
width: barWidth,
|
|
203
|
+
height,
|
|
204
|
+
radius: options.radius,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
hitRegions.push({
|
|
208
|
+
seriesId: series.id,
|
|
209
|
+
pointId: point.id,
|
|
210
|
+
x: barX + barWidth / 2,
|
|
211
|
+
y: top + height / 2,
|
|
212
|
+
bounds: { x: barX, y: top, width: barWidth, height },
|
|
213
|
+
selectable: !isWithheld(point.value),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (rects.length > 0) {
|
|
218
|
+
paths.push({
|
|
219
|
+
key: `bars-${series.id}`,
|
|
220
|
+
seriesId: series.id,
|
|
221
|
+
d: rectBatchPath(rects),
|
|
222
|
+
color: input.seriesColors[series.id],
|
|
223
|
+
pattern: series.pattern ?? 'solid',
|
|
224
|
+
filled: true,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const xTicks: Tick[] = categories.map(c => ({
|
|
230
|
+
value: 0,
|
|
231
|
+
position: xBand.centerOf(c) ?? plot.x,
|
|
232
|
+
label:
|
|
233
|
+
datasource.positionKind === 'instant'
|
|
234
|
+
? input.formatInstant(Number(c))
|
|
235
|
+
: input.formatCategory(c),
|
|
236
|
+
}));
|
|
237
|
+
|
|
238
|
+
const yTicks: Tick[] = [y.domain.min, 0, y.domain.max]
|
|
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
|
+
}));
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
plot,
|
|
248
|
+
paths,
|
|
249
|
+
markers: [],
|
|
250
|
+
labels: [],
|
|
251
|
+
xTicks: thinTicks(xTicks, input.fontSize, plot.width),
|
|
252
|
+
yTicks,
|
|
253
|
+
hitRegions,
|
|
254
|
+
incomplete: excluded > 0,
|
|
255
|
+
};
|
|
256
|
+
}
|