@pharos-ds/charts 0.0.0-stage → 1.0.10
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 +73 -0
- package/LICENSE +24 -0
- package/README.md +117 -2
- package/dist/Chart.d.ts +62 -0
- package/dist/Chart.d.ts.map +1 -0
- package/dist/Chart.js +401 -0
- package/dist/ChartAxis.d.ts +37 -0
- package/dist/ChartAxis.d.ts.map +1 -0
- package/dist/ChartAxis.js +233 -0
- package/dist/ChartContext.d.ts +4 -0
- package/dist/ChartContext.d.ts.map +1 -0
- package/dist/ChartContext.js +15 -0
- package/dist/ChartGrid.d.ts +26 -0
- package/dist/ChartGrid.d.ts.map +1 -0
- package/dist/ChartGrid.js +118 -0
- package/dist/ChartLegend.d.ts +14 -0
- package/dist/ChartLegend.d.ts.map +1 -0
- package/dist/ChartLegend.js +62 -0
- package/dist/ChartSwatch.d.ts +36 -0
- package/dist/ChartSwatch.d.ts.map +1 -0
- package/dist/ChartSwatch.js +85 -0
- package/dist/ChartTooltip.d.ts +46 -0
- package/dist/ChartTooltip.d.ts.map +1 -0
- package/dist/ChartTooltip.js +330 -0
- package/dist/charts.css +33 -0
- package/dist/dataTable.d.ts +56 -0
- package/dist/dataTable.d.ts.map +1 -0
- package/dist/dataTable.js +88 -0
- package/dist/formatters.d.ts +37 -0
- package/dist/formatters.d.ts.map +1 -0
- package/dist/formatters.js +156 -0
- package/dist/getChartColors.d.ts +67 -0
- package/dist/getChartColors.d.ts.map +1 -0
- package/dist/getChartColors.js +191 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/layout.d.ts +40 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +322 -0
- package/dist/legend.d.ts +17 -0
- package/dist/legend.d.ts.map +1 -0
- package/dist/legend.js +40 -0
- package/dist/markColor.d.ts +20 -0
- package/dist/markColor.d.ts.map +1 -0
- package/dist/markColor.js +56 -0
- package/dist/marks/area.d.ts +19 -0
- package/dist/marks/area.d.ts.map +1 -0
- package/dist/marks/area.js +121 -0
- package/dist/marks/band.d.ts +9 -0
- package/dist/marks/band.d.ts.map +1 -0
- package/dist/marks/band.js +64 -0
- package/dist/marks/bar.d.ts +21 -0
- package/dist/marks/bar.d.ts.map +1 -0
- package/dist/marks/bar.js +161 -0
- package/dist/marks/candlestick.d.ts +16 -0
- package/dist/marks/candlestick.d.ts.map +1 -0
- package/dist/marks/candlestick.js +110 -0
- package/dist/marks/dot.d.ts +19 -0
- package/dist/marks/dot.d.ts.map +1 -0
- package/dist/marks/dot.js +98 -0
- package/dist/marks/dotGL.d.ts +16 -0
- package/dist/marks/dotGL.d.ts.map +1 -0
- package/dist/marks/dotGL.js +231 -0
- package/dist/marks/dotGLInteractive.d.ts +35 -0
- package/dist/marks/dotGLInteractive.d.ts.map +1 -0
- package/dist/marks/dotGLInteractive.js +584 -0
- package/dist/marks/errorBar.d.ts +29 -0
- package/dist/marks/errorBar.d.ts.map +1 -0
- package/dist/marks/errorBar.js +95 -0
- package/dist/marks/heatmapGL.d.ts +27 -0
- package/dist/marks/heatmapGL.d.ts.map +1 -0
- package/dist/marks/heatmapGL.js +335 -0
- package/dist/marks/index.d.ts +13 -0
- package/dist/marks/index.d.ts.map +1 -0
- package/dist/marks/index.js +14 -0
- package/dist/marks/line.d.ts +18 -0
- package/dist/marks/line.d.ts.map +1 -0
- package/dist/marks/line.js +83 -0
- package/dist/marks/referenceLine.d.ts +37 -0
- package/dist/marks/referenceLine.d.ts.map +1 -0
- package/dist/marks/referenceLine.js +204 -0
- package/dist/marks/streamGL.d.ts +41 -0
- package/dist/marks/streamGL.d.ts.map +1 -0
- package/dist/marks/streamGL.js +317 -0
- package/dist/tooltip.d.ts +38 -0
- package/dist/tooltip.d.ts.map +1 -0
- package/dist/tooltip.js +57 -0
- package/dist/types.d.ts +193 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +70 -0
- package/dist/useChartColors.d.ts +16 -0
- package/dist/useChartColors.d.ts.map +1 -0
- package/dist/useChartColors.js +37 -0
- package/dist/utils.d.ts +15 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +31 -0
- package/dist/webgl.d.ts +77 -0
- package/dist/webgl.d.ts.map +1 -0
- package/dist/webgl.js +235 -0
- package/package.json +71 -3
- package/src/Chart.doc.mjs +174 -0
- package/src/Chart.test.tsx +273 -0
- package/src/Chart.tsx +535 -0
- package/src/ChartAxis.tsx +289 -0
- package/src/ChartContext.ts +18 -0
- package/src/ChartGrid.tsx +136 -0
- package/src/ChartLegend.tsx +77 -0
- package/src/ChartSwatch.doc.mjs +17 -0
- package/src/ChartSwatch.tsx +89 -0
- package/src/ChartTooltip.tsx +452 -0
- package/src/charts.css.d.ts +3 -0
- package/src/dataTable.test.ts +86 -0
- package/src/dataTable.ts +124 -0
- package/src/formatters.ts +167 -0
- package/src/getChartColors.test.ts +114 -0
- package/src/getChartColors.ts +282 -0
- package/src/index.ts +88 -0
- package/src/layout.ts +372 -0
- package/src/legend.ts +44 -0
- package/src/markColor.ts +70 -0
- package/src/marks/area.tsx +142 -0
- package/src/marks/band.tsx +86 -0
- package/src/marks/bar.tsx +210 -0
- package/src/marks/candlestick.tsx +128 -0
- package/src/marks/dot.tsx +119 -0
- package/src/marks/dotGL.tsx +274 -0
- package/src/marks/dotGLInteractive.tsx +660 -0
- package/src/marks/errorBar.tsx +119 -0
- package/src/marks/heatmapGL.tsx +392 -0
- package/src/marks/index.ts +17 -0
- package/src/marks/line.tsx +118 -0
- package/src/marks/referenceLine.tsx +252 -0
- package/src/marks/streamGL.tsx +381 -0
- package/src/tooltip.ts +73 -0
- package/src/types.ts +212 -0
- package/src/useChartColors.ts +41 -0
- package/src/utils.ts +38 -0
- package/src/webgl.ts +262 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file types.ts
|
|
5
|
+
* @output Core types for the v2 chart architecture
|
|
6
|
+
* @position Foundation — consumed by layout, renderers, and interactions
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {ReactNode} from 'react';
|
|
10
|
+
import type {ScaleLinear, ScaleBand} from 'd3-scale';
|
|
11
|
+
|
|
12
|
+
export type ChartScale = ScaleLinear<number, number> | ScaleBand<string>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A color for a mark: either a constant CSS color/token, or an accessor that
|
|
16
|
+
* returns a color per datum (for per-element coloring, e.g. positive/negative).
|
|
17
|
+
*/
|
|
18
|
+
export type ColorAccessor =
|
|
19
|
+
string | ((datum: Record<string, unknown>, index: number) => string);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Fallback series color used when a mark has neither an explicit color nor a
|
|
23
|
+
* palette color assigned by the chart root. Uses an EMITTED core token
|
|
24
|
+
* (`--color-accent`) — the data-viz `--color-data-*` tokens are JS-only values
|
|
25
|
+
* (resolved via useChartColors), not CSS custom properties, so `var(--color-data-*)`
|
|
26
|
+
* would render nothing. The chart root normally assigns a resolved palette color,
|
|
27
|
+
* so this is only a last-resort fallback.
|
|
28
|
+
*/
|
|
29
|
+
export const DEFAULT_SERIES_COLOR = 'var(--color-accent)';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Controls how the y-axis domain is derived when no explicit `yDomain` is given:
|
|
33
|
+
* - `'auto'` (default) — bar/area marks pin an honest zero baseline; continuous-
|
|
34
|
+
* only charts (line/dot/…) get a little headroom so points don't touch edges.
|
|
35
|
+
* - `'zero'` — symmetric around zero (good for +/- data like profit/loss).
|
|
36
|
+
* - `'data'` — tight fit to the data extent (no zero forcing, no headroom).
|
|
37
|
+
*/
|
|
38
|
+
export type YBaseline = 'auto' | 'zero' | 'data';
|
|
39
|
+
|
|
40
|
+
export interface ChartMargin {
|
|
41
|
+
top: number;
|
|
42
|
+
right: number;
|
|
43
|
+
bottom: number;
|
|
44
|
+
left: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Resolved pixel position for a single data point in a series */
|
|
48
|
+
export interface ResolvedPoint {
|
|
49
|
+
/** Pixel x in plot area coordinates */
|
|
50
|
+
px: number;
|
|
51
|
+
/** Pixel y in plot area coordinates (top of mark) */
|
|
52
|
+
py: number;
|
|
53
|
+
/** Pixel y baseline (bottom of mark) — differs from py when stacked */
|
|
54
|
+
py0: number;
|
|
55
|
+
/** Index into the data array */
|
|
56
|
+
dataIndex: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Context passed to resolve() and render() on each series */
|
|
60
|
+
export interface SeriesContext {
|
|
61
|
+
data: Record<string, unknown>[];
|
|
62
|
+
xKey: string;
|
|
63
|
+
xScale: ChartScale;
|
|
64
|
+
yScale: ScaleLinear<number, number>;
|
|
65
|
+
/**
|
|
66
|
+
* Categorical y band scale for row-based marks (e.g. heatmap). Present only
|
|
67
|
+
* when a series declares `layout.yBandKey`; in that case the y-axis is
|
|
68
|
+
* categorical and the linear `yScale` is unused/degenerate.
|
|
69
|
+
*/
|
|
70
|
+
yBandScale?: ScaleBand<string>;
|
|
71
|
+
/** Width of the plot area */
|
|
72
|
+
width: number;
|
|
73
|
+
/** Height of the plot area */
|
|
74
|
+
height: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The standardized shape every mark helper returns.
|
|
79
|
+
* Each mark type is self-contained: it knows how to resolve positions
|
|
80
|
+
* and how to render itself. The chart root just iterates and calls.
|
|
81
|
+
*
|
|
82
|
+
* A SeriesDef instance is single-use: the layout/render pass annotates it in
|
|
83
|
+
* place (`_uid`, `_isTopOfStack`, `_resolvedColor`), so a given def must belong
|
|
84
|
+
* to exactly one <Chart>. Build a fresh series array per chart rather than
|
|
85
|
+
* sharing the same mark objects across charts.
|
|
86
|
+
*/
|
|
87
|
+
export interface SeriesDef {
|
|
88
|
+
/** Mark type identifier */
|
|
89
|
+
readonly type: string;
|
|
90
|
+
/**
|
|
91
|
+
* Semantic key for this series — typically the dataKey. Used as a
|
|
92
|
+
* human-readable label fallback and for stack/group coordination. It is NOT
|
|
93
|
+
* guaranteed unique (two series can share a dataKey), so it must never be
|
|
94
|
+
* used as a Map key or React key — use `_uid` for identity instead.
|
|
95
|
+
*/
|
|
96
|
+
readonly key: string;
|
|
97
|
+
/** Data keys this series reads from — used to compute y domain */
|
|
98
|
+
readonly dataKeys: string[];
|
|
99
|
+
/**
|
|
100
|
+
* Collision-free per-instance identity, assigned by the layout pass from the
|
|
101
|
+
* series' position in the `series` array. This is the key used for the
|
|
102
|
+
* resolved map, React keys, and tooltip/hover lookups. Never set it yourself.
|
|
103
|
+
*/
|
|
104
|
+
_uid?: string;
|
|
105
|
+
/** Whether this series is the topmost in its stack (set during layout) */
|
|
106
|
+
_isTopOfStack?: boolean;
|
|
107
|
+
/** Static color for legend display. Undefined if color is dynamic (accessor function). */
|
|
108
|
+
readonly color?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Palette color assigned by the chart root for series that don't supply a
|
|
111
|
+
* static color (auto-colored or accessor-colored). Used as the render fallback
|
|
112
|
+
* and the legend/tooltip representative color. Never set it yourself.
|
|
113
|
+
*/
|
|
114
|
+
_resolvedColor?: string;
|
|
115
|
+
/** Human-readable label for legend. Falls back to key if not provided. */
|
|
116
|
+
readonly label?: string;
|
|
117
|
+
/** Layout hints the chart root uses for cross-series coordination */
|
|
118
|
+
readonly layout: {
|
|
119
|
+
/** Stack group — series with same stack accumulate */
|
|
120
|
+
stack?: string;
|
|
121
|
+
/** Bar group — series with same group subdivide bandwidth */
|
|
122
|
+
group?: string;
|
|
123
|
+
/** Whether this series contributes to y-domain zero inclusion */
|
|
124
|
+
includeZero?: boolean;
|
|
125
|
+
/**
|
|
126
|
+
* When set, the chart's y-axis is a band scale built from this data key's
|
|
127
|
+
* unique values (categorical rows, e.g. a heatmap's days) rather than a
|
|
128
|
+
* linear value scale. The layout pass exposes it to marks and the axis as
|
|
129
|
+
* `yBandScale`, so a left `<ChartAxis>` renders the categories aligned to
|
|
130
|
+
* each row. A heatmap should also declare `dataKeys: []` so it doesn't feed
|
|
131
|
+
* the (now unused) linear y-domain.
|
|
132
|
+
*/
|
|
133
|
+
yBandKey?: string;
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* Resolve pixel positions for each data point.
|
|
137
|
+
* Called by the chart root during the layout pass.
|
|
138
|
+
* Receives pre-computed stack offsets if stacked.
|
|
139
|
+
*/
|
|
140
|
+
resolve(
|
|
141
|
+
ctx: SeriesContext,
|
|
142
|
+
stackOffsets?: {y0: number; y1: number}[],
|
|
143
|
+
groupInfo?: {index: number; count: number},
|
|
144
|
+
): ResolvedPoint[];
|
|
145
|
+
/**
|
|
146
|
+
* Render the mark given resolved positions.
|
|
147
|
+
* Called by the chart root during the render pass.
|
|
148
|
+
* Returns SVG elements (or manages its own canvas for WebGL).
|
|
149
|
+
*/
|
|
150
|
+
render(resolved: ResolvedPoint[], ctx: SeriesContext): ReactNode;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Resolved positions for all series */
|
|
154
|
+
export type ResolvedPositions = Map<string, ResolvedPoint[]>;
|
|
155
|
+
|
|
156
|
+
/** Pointer event normalized to plot-area coordinates */
|
|
157
|
+
export interface ChartPointerEvent {
|
|
158
|
+
x: number;
|
|
159
|
+
y: number;
|
|
160
|
+
nearest: (ResolvedPoint & {seriesKey: string}) | null;
|
|
161
|
+
active: boolean;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Which way the chart reads. `'vertical'` (default): categories along x, values
|
|
166
|
+
* grow up. `'horizontal'`: categories along y, values grow right (horizontal
|
|
167
|
+
* bars). Marks always resolve in the vertical "plot space"; the chart root
|
|
168
|
+
* transposes them on screen.
|
|
169
|
+
*/
|
|
170
|
+
export type ChartOrientation = 'vertical' | 'horizontal';
|
|
171
|
+
|
|
172
|
+
/** Context provided by Chart to interaction children */
|
|
173
|
+
export interface ChartContext {
|
|
174
|
+
/**
|
|
175
|
+
* Chart orientation. Chrome (axes, grid) receives display-space scales: for
|
|
176
|
+
* `'horizontal'`, `xScale` is the value scale and `yBandScale` the category
|
|
177
|
+
* scale. `resolved` points stay in plot space (see `ChartOrientation`).
|
|
178
|
+
*/
|
|
179
|
+
orientation?: ChartOrientation;
|
|
180
|
+
width: number;
|
|
181
|
+
height: number;
|
|
182
|
+
margin: ChartMargin;
|
|
183
|
+
data: Record<string, unknown>[];
|
|
184
|
+
xKey: string;
|
|
185
|
+
xScale: ChartScale;
|
|
186
|
+
yScale: ScaleLinear<number, number>;
|
|
187
|
+
/**
|
|
188
|
+
* Categorical y band scale (e.g. heatmap rows), present when a series declares
|
|
189
|
+
* `layout.yBandKey`. Chrome that renders the y-axis (`ChartAxis`) should
|
|
190
|
+
* prefer this over the linear `yScale` when it exists.
|
|
191
|
+
*/
|
|
192
|
+
yBandScale?: ScaleBand<string>;
|
|
193
|
+
resolved: Map<string, ResolvedPoint[]>;
|
|
194
|
+
onPointer: (handler: (e: ChartPointerEvent) => void) => () => void;
|
|
195
|
+
svgRef: React.RefObject<SVGSVGElement | null>;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Series mark types that are utility / annotation marks rather than primary
|
|
200
|
+
* data. They're skipped from chrome surfaces (legend, tooltip rows) since
|
|
201
|
+
* they don't represent values the user is comparing.
|
|
202
|
+
*/
|
|
203
|
+
export const UTILITY_MARK_TYPES: ReadonlySet<string> = new Set([
|
|
204
|
+
'referenceLine',
|
|
205
|
+
'errorBar',
|
|
206
|
+
'band',
|
|
207
|
+
]);
|
|
208
|
+
|
|
209
|
+
/** True if the series type is a utility / annotation mark. */
|
|
210
|
+
export function isUtilityMarkType(type: string): boolean {
|
|
211
|
+
return UTILITY_MARK_TYPES.has(type);
|
|
212
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file useChartColors.ts
|
|
5
|
+
* @output Theme-aware chart color palette hook
|
|
6
|
+
* @position React hook wrapping getChartColors — resolves from live theme context
|
|
7
|
+
*
|
|
8
|
+
* Returns a stable reference across renders — the palette only rebuilds when the
|
|
9
|
+
* resolved theme tokens change (theme or color mode), so consumers can safely
|
|
10
|
+
* use the result in effect/memo dependency arrays.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {useMemo} from 'react';
|
|
14
|
+
import {useTheme} from '@pharos-ds/core/theme';
|
|
15
|
+
import {getChartColorsFromResolver} from './getChartColors';
|
|
16
|
+
import type {ChartColorsAPI} from './getChartColors';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Theme-aware chart colors. Resolves data-viz tokens from the current
|
|
20
|
+
* theme context — respects light/dark mode and custom themes.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```
|
|
24
|
+
* const colors = useChartColors();
|
|
25
|
+
* colors.categorical(5)
|
|
26
|
+
* colors.sequential.blue(3)
|
|
27
|
+
* colors.diverging.positiveNegative(5)
|
|
28
|
+
* colors.alpha('#0171E3', 0.5)
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function useChartColors(): ChartColorsAPI {
|
|
32
|
+
// Depend on the memoized token map (stable per theme + mode), not useTheme's
|
|
33
|
+
// `token` function, which is a fresh closure on every render and would defeat
|
|
34
|
+
// memoization — returning a new palette object each render.
|
|
35
|
+
const {tokens} = useTheme();
|
|
36
|
+
|
|
37
|
+
return useMemo(
|
|
38
|
+
() => getChartColorsFromResolver(name => tokens[name] ?? ''),
|
|
39
|
+
[tokens],
|
|
40
|
+
);
|
|
41
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file utils.ts
|
|
5
|
+
* @output Shared utilities for chart components
|
|
6
|
+
* @position Internal helpers; consumed by mark, axis, and interaction components
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {ScaleBand} from 'd3-scale';
|
|
10
|
+
import type {ChartScale} from './types';
|
|
11
|
+
|
|
12
|
+
/** Type guard for d3 band scales */
|
|
13
|
+
export function isBandScale(scale: ChartScale): scale is ScaleBand<string> {
|
|
14
|
+
return typeof scale === 'function' && 'bandwidth' in scale;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get the x pixel position for a datum using the chart's xKey and xScale.
|
|
19
|
+
* For band scales, returns the center of the band.
|
|
20
|
+
*/
|
|
21
|
+
export function xPixel(
|
|
22
|
+
d: Record<string, unknown>,
|
|
23
|
+
xKey: string,
|
|
24
|
+
xScale: ChartScale,
|
|
25
|
+
): number {
|
|
26
|
+
const raw = d[xKey];
|
|
27
|
+
if (isBandScale(xScale)) {
|
|
28
|
+
return (xScale(String(raw)) ?? 0) + xScale.bandwidth() / 2;
|
|
29
|
+
}
|
|
30
|
+
// Not a band scale → the guard narrows `xScale` to the linear scale, so no
|
|
31
|
+
// cast is needed. Coerce explicitly: finite numbers pass through, Date/
|
|
32
|
+
// numeric-string map as d3 would. Anything that coerces to a non-finite value
|
|
33
|
+
// — a non-numeric (→ NaN) or a raw ±Infinity — collapses to NaN, a single
|
|
34
|
+
// off-canvas sentinel that marks already skip, rather than a silently wrong
|
|
35
|
+
// position (d3 maps ±Infinity to an out-of-range pixel, not off-canvas).
|
|
36
|
+
const value = typeof raw === 'number' ? raw : Number(raw);
|
|
37
|
+
return Number.isFinite(value) ? xScale(value) : NaN;
|
|
38
|
+
}
|
package/src/webgl.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file webgl.ts
|
|
5
|
+
* @output Shared WebGL utilities for chart components
|
|
6
|
+
* @position Internal helpers; consumed by all GL mark components
|
|
7
|
+
*
|
|
8
|
+
* Provides:
|
|
9
|
+
* - Canvas mounting outside SVG (avoids foreignObject DPR blur)
|
|
10
|
+
* - Premultiplied alpha setup (correct compositing over page)
|
|
11
|
+
* - Shader compilation helpers
|
|
12
|
+
* - Smoothstep circle fragment for crisp point sprites
|
|
13
|
+
* - Hex-to-GL color conversion via the shared @pharos-ds/core/utils/color
|
|
14
|
+
* parsers (fallback instead of NaN)
|
|
15
|
+
* - Context-loss registration + eager context teardown helpers
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {parseHex, toGLFloats} from '@pharos-ds/core/utils';
|
|
19
|
+
|
|
20
|
+
/** Compile a WebGL shader, returns null on failure */
|
|
21
|
+
export function compileShader(
|
|
22
|
+
gl: WebGLRenderingContext,
|
|
23
|
+
type: number,
|
|
24
|
+
src: string,
|
|
25
|
+
): WebGLShader | null {
|
|
26
|
+
const s = gl.createShader(type);
|
|
27
|
+
if (!s) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
gl.shaderSource(s, src);
|
|
31
|
+
gl.compileShader(s);
|
|
32
|
+
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
|
|
33
|
+
gl.deleteShader(s);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return s;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Link a vertex + fragment shader into a program */
|
|
40
|
+
export function createProgram(
|
|
41
|
+
gl: WebGLRenderingContext,
|
|
42
|
+
vertSrc: string,
|
|
43
|
+
fragSrc: string,
|
|
44
|
+
): WebGLProgram | null {
|
|
45
|
+
const vs = compileShader(gl, gl.VERTEX_SHADER, vertSrc);
|
|
46
|
+
const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragSrc);
|
|
47
|
+
if (!vs || !fs) {
|
|
48
|
+
// One shader may have compiled while the other failed — free the survivor
|
|
49
|
+
// so the failure path never leaks a shader object.
|
|
50
|
+
if (vs) {
|
|
51
|
+
gl.deleteShader(vs);
|
|
52
|
+
}
|
|
53
|
+
if (fs) {
|
|
54
|
+
gl.deleteShader(fs);
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const p = gl.createProgram();
|
|
59
|
+
if (!p) {
|
|
60
|
+
gl.deleteShader(vs);
|
|
61
|
+
gl.deleteShader(fs);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
gl.attachShader(p, vs);
|
|
65
|
+
gl.attachShader(p, fs);
|
|
66
|
+
gl.linkProgram(p);
|
|
67
|
+
// The shaders are now linked into the program; flag them for deletion so the
|
|
68
|
+
// GPU frees them once the program is deleted instead of leaving them attached
|
|
69
|
+
// (and allocated) for the program's whole lifetime.
|
|
70
|
+
gl.deleteShader(vs);
|
|
71
|
+
gl.deleteShader(fs);
|
|
72
|
+
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
|
|
73
|
+
gl.deleteProgram(p);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return p;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Parse a hex color to [r, g, b] floats in 0-1.
|
|
81
|
+
*
|
|
82
|
+
* Accepts `#rgb`, `#rgba`, `#rrggbb`, and `#rrggbbaa`, with or without the
|
|
83
|
+
* leading `#` (any alpha byte is ignored). Non-hex input — CSS custom
|
|
84
|
+
* properties, `rgb()`, named colors, or malformed strings — returns a neutral
|
|
85
|
+
* fallback rather than producing NaN, so the GPU never receives a bad uniform.
|
|
86
|
+
*/
|
|
87
|
+
export function hexToGL(hex: string): [number, number, number] {
|
|
88
|
+
return toGLFloats(parseHex(hex));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** DPR with 2x supersampling for crisp circles. SSR-safe (falls back to 2). */
|
|
92
|
+
export function getCanvasDPR(): number {
|
|
93
|
+
const base = typeof window !== 'undefined' ? window.devicePixelRatio : 0;
|
|
94
|
+
return (base || 2) * 2;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Create or get a WebGL context with correct alpha settings.
|
|
99
|
+
* Uses premultiplied alpha for correct compositing over the page.
|
|
100
|
+
*/
|
|
101
|
+
export function getWebGLContext(
|
|
102
|
+
canvas: HTMLCanvasElement,
|
|
103
|
+
): WebGLRenderingContext | null {
|
|
104
|
+
return canvas.getContext('webgl', {
|
|
105
|
+
alpha: true,
|
|
106
|
+
premultipliedAlpha: true,
|
|
107
|
+
antialias: true,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Set up standard GL state for chart rendering:
|
|
113
|
+
* - Clear with transparent
|
|
114
|
+
* - Enable premultiplied alpha blending
|
|
115
|
+
*/
|
|
116
|
+
export function setupGLState(gl: WebGLRenderingContext): void {
|
|
117
|
+
gl.clearColor(0, 0, 0, 0);
|
|
118
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
119
|
+
gl.enable(gl.BLEND);
|
|
120
|
+
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Size a canvas for sharp rendering, accounting for DPR.
|
|
125
|
+
* Returns the DPR used.
|
|
126
|
+
*/
|
|
127
|
+
export function sizeCanvas(
|
|
128
|
+
canvas: HTMLCanvasElement,
|
|
129
|
+
width: number,
|
|
130
|
+
height: number,
|
|
131
|
+
): number {
|
|
132
|
+
const dpr = getCanvasDPR();
|
|
133
|
+
// The drawing buffer must be a positive integer — a 0-sized (or fractional)
|
|
134
|
+
// buffer makes GL viewport/draw calls silently fail.
|
|
135
|
+
canvas.width = Math.max(1, Math.round(width * dpr));
|
|
136
|
+
canvas.height = Math.max(1, Math.round(height * dpr));
|
|
137
|
+
canvas.style.width = `${Math.max(0, width)}px`;
|
|
138
|
+
canvas.style.height = `${Math.max(0, height)}px`;
|
|
139
|
+
return dpr;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Mount a canvas as an absolute-positioned sibling to an SVG element.
|
|
144
|
+
* Automatically aligns with the SVG marker's position — no manual margin
|
|
145
|
+
* offset needed. This is a Tier 1 guarantee: the canvas lands exactly
|
|
146
|
+
* where the SVG content area is, regardless of chart margins or transforms.
|
|
147
|
+
*
|
|
148
|
+
* Call from a useEffect. Returns a cleanup function.
|
|
149
|
+
*/
|
|
150
|
+
export function mountCanvasOverSVG(
|
|
151
|
+
svgMarker: SVGGraphicsElement,
|
|
152
|
+
canvas: HTMLCanvasElement,
|
|
153
|
+
width: number,
|
|
154
|
+
height: number,
|
|
155
|
+
): (() => void) | undefined {
|
|
156
|
+
const svg = svgMarker.ownerSVGElement;
|
|
157
|
+
if (!svg) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const parent = svg.parentElement;
|
|
161
|
+
if (!parent) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (getComputedStyle(parent).position === 'static') {
|
|
166
|
+
parent.style.position = 'relative';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
canvas.style.position = 'absolute';
|
|
170
|
+
canvas.style.pointerEvents = 'none';
|
|
171
|
+
|
|
172
|
+
// Compute offset from the SVG marker's actual screen position.
|
|
173
|
+
// The marker sits inside the chart's <g transform="translate(margin)">
|
|
174
|
+
// so its bounding rect includes the margin offset automatically.
|
|
175
|
+
// This is a Tier 1 guarantee — we derive position from the SVG, not from props.
|
|
176
|
+
const parentRect = parent.getBoundingClientRect();
|
|
177
|
+
const markerCTM = svgMarker.getScreenCTM();
|
|
178
|
+
if (markerCTM) {
|
|
179
|
+
canvas.style.left = `${markerCTM.e - parentRect.left}px`;
|
|
180
|
+
canvas.style.top = `${markerCTM.f - parentRect.top}px`;
|
|
181
|
+
} else {
|
|
182
|
+
canvas.style.left = '0';
|
|
183
|
+
canvas.style.top = '0';
|
|
184
|
+
}
|
|
185
|
+
canvas.style.width = `${width}px`;
|
|
186
|
+
canvas.style.height = `${height}px`;
|
|
187
|
+
|
|
188
|
+
parent.appendChild(canvas);
|
|
189
|
+
|
|
190
|
+
return () => {
|
|
191
|
+
if (canvas.parentElement === parent) {
|
|
192
|
+
parent.removeChild(canvas);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Register WebGL context-loss/restore handlers on a canvas.
|
|
199
|
+
*
|
|
200
|
+
* Calling `preventDefault()` on the lost event is REQUIRED — without it the
|
|
201
|
+
* browser will never fire `webglcontextrestored`. `onLost` should drop any
|
|
202
|
+
* cached GL handles (they are all invalid after loss); `onRestored` should
|
|
203
|
+
* trigger a rebuild + redraw.
|
|
204
|
+
*
|
|
205
|
+
* Returns a cleanup that removes both listeners.
|
|
206
|
+
*/
|
|
207
|
+
export function registerContextLossHandlers(
|
|
208
|
+
canvas: HTMLCanvasElement,
|
|
209
|
+
onLost: () => void,
|
|
210
|
+
onRestored: () => void,
|
|
211
|
+
): () => void {
|
|
212
|
+
const handleLost = (e: Event) => {
|
|
213
|
+
e.preventDefault();
|
|
214
|
+
onLost();
|
|
215
|
+
};
|
|
216
|
+
canvas.addEventListener('webglcontextlost', handleLost, false);
|
|
217
|
+
canvas.addEventListener('webglcontextrestored', onRestored, false);
|
|
218
|
+
return () => {
|
|
219
|
+
canvas.removeEventListener('webglcontextlost', handleLost, false);
|
|
220
|
+
canvas.removeEventListener('webglcontextrestored', onRestored, false);
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Eagerly release a WebGL context and its GPU resources on teardown.
|
|
226
|
+
*
|
|
227
|
+
* Browsers cap the number of live contexts (~16), so freeing on unmount rather
|
|
228
|
+
* than waiting for GC prevents "too many active WebGL contexts" failures when
|
|
229
|
+
* many charts mount/unmount over a session.
|
|
230
|
+
*/
|
|
231
|
+
export function loseContext(gl: WebGLRenderingContext): void {
|
|
232
|
+
const ext = gl.getExtension('WEBGL_lose_context');
|
|
233
|
+
if (ext) {
|
|
234
|
+
ext.loseContext();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* GLSL fragment for premultiplied-alpha circle with smoothstep AA.
|
|
240
|
+
* Use in fragment shaders that render point sprites.
|
|
241
|
+
*
|
|
242
|
+
* Expects:
|
|
243
|
+
* - varying float v_alpha (the final alpha before edge AA)
|
|
244
|
+
* - uniform vec3 u_color
|
|
245
|
+
*
|
|
246
|
+
* Returns: gl_FragColor with premultiplied alpha
|
|
247
|
+
*/
|
|
248
|
+
export const CIRCLE_FRAG_BODY = `
|
|
249
|
+
vec2 coord = gl_PointCoord - vec2(0.5);
|
|
250
|
+
float dist = length(coord);
|
|
251
|
+
if (dist > 0.5) discard;
|
|
252
|
+
float edge = 1.0 - smoothstep(0.48, 0.5, dist);
|
|
253
|
+
float a = v_alpha * edge;
|
|
254
|
+
gl_FragColor = vec4(u_color * a, a);
|
|
255
|
+
`;
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Smoothstep compensation factor for gl_PointSize.
|
|
259
|
+
* The visible circle radius is 0.48 of the point sprite (smoothstep starts there),
|
|
260
|
+
* so we scale up by 1/0.96 to match SVG circle radius exactly.
|
|
261
|
+
*/
|
|
262
|
+
export const POINT_SIZE_COMPENSATION = 1.0 / 0.96;
|