@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.
Files changed (139) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/LICENSE +24 -0
  3. package/README.md +117 -2
  4. package/dist/Chart.d.ts +62 -0
  5. package/dist/Chart.d.ts.map +1 -0
  6. package/dist/Chart.js +401 -0
  7. package/dist/ChartAxis.d.ts +37 -0
  8. package/dist/ChartAxis.d.ts.map +1 -0
  9. package/dist/ChartAxis.js +233 -0
  10. package/dist/ChartContext.d.ts +4 -0
  11. package/dist/ChartContext.d.ts.map +1 -0
  12. package/dist/ChartContext.js +15 -0
  13. package/dist/ChartGrid.d.ts +26 -0
  14. package/dist/ChartGrid.d.ts.map +1 -0
  15. package/dist/ChartGrid.js +118 -0
  16. package/dist/ChartLegend.d.ts +14 -0
  17. package/dist/ChartLegend.d.ts.map +1 -0
  18. package/dist/ChartLegend.js +62 -0
  19. package/dist/ChartSwatch.d.ts +36 -0
  20. package/dist/ChartSwatch.d.ts.map +1 -0
  21. package/dist/ChartSwatch.js +85 -0
  22. package/dist/ChartTooltip.d.ts +46 -0
  23. package/dist/ChartTooltip.d.ts.map +1 -0
  24. package/dist/ChartTooltip.js +330 -0
  25. package/dist/charts.css +33 -0
  26. package/dist/dataTable.d.ts +56 -0
  27. package/dist/dataTable.d.ts.map +1 -0
  28. package/dist/dataTable.js +88 -0
  29. package/dist/formatters.d.ts +37 -0
  30. package/dist/formatters.d.ts.map +1 -0
  31. package/dist/formatters.js +156 -0
  32. package/dist/getChartColors.d.ts +67 -0
  33. package/dist/getChartColors.d.ts.map +1 -0
  34. package/dist/getChartColors.js +191 -0
  35. package/dist/index.d.ts +17 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +19 -0
  38. package/dist/layout.d.ts +40 -0
  39. package/dist/layout.d.ts.map +1 -0
  40. package/dist/layout.js +322 -0
  41. package/dist/legend.d.ts +17 -0
  42. package/dist/legend.d.ts.map +1 -0
  43. package/dist/legend.js +40 -0
  44. package/dist/markColor.d.ts +20 -0
  45. package/dist/markColor.d.ts.map +1 -0
  46. package/dist/markColor.js +56 -0
  47. package/dist/marks/area.d.ts +19 -0
  48. package/dist/marks/area.d.ts.map +1 -0
  49. package/dist/marks/area.js +121 -0
  50. package/dist/marks/band.d.ts +9 -0
  51. package/dist/marks/band.d.ts.map +1 -0
  52. package/dist/marks/band.js +64 -0
  53. package/dist/marks/bar.d.ts +21 -0
  54. package/dist/marks/bar.d.ts.map +1 -0
  55. package/dist/marks/bar.js +161 -0
  56. package/dist/marks/candlestick.d.ts +16 -0
  57. package/dist/marks/candlestick.d.ts.map +1 -0
  58. package/dist/marks/candlestick.js +110 -0
  59. package/dist/marks/dot.d.ts +19 -0
  60. package/dist/marks/dot.d.ts.map +1 -0
  61. package/dist/marks/dot.js +98 -0
  62. package/dist/marks/dotGL.d.ts +16 -0
  63. package/dist/marks/dotGL.d.ts.map +1 -0
  64. package/dist/marks/dotGL.js +231 -0
  65. package/dist/marks/dotGLInteractive.d.ts +35 -0
  66. package/dist/marks/dotGLInteractive.d.ts.map +1 -0
  67. package/dist/marks/dotGLInteractive.js +584 -0
  68. package/dist/marks/errorBar.d.ts +29 -0
  69. package/dist/marks/errorBar.d.ts.map +1 -0
  70. package/dist/marks/errorBar.js +95 -0
  71. package/dist/marks/heatmapGL.d.ts +27 -0
  72. package/dist/marks/heatmapGL.d.ts.map +1 -0
  73. package/dist/marks/heatmapGL.js +335 -0
  74. package/dist/marks/index.d.ts +13 -0
  75. package/dist/marks/index.d.ts.map +1 -0
  76. package/dist/marks/index.js +14 -0
  77. package/dist/marks/line.d.ts +18 -0
  78. package/dist/marks/line.d.ts.map +1 -0
  79. package/dist/marks/line.js +83 -0
  80. package/dist/marks/referenceLine.d.ts +37 -0
  81. package/dist/marks/referenceLine.d.ts.map +1 -0
  82. package/dist/marks/referenceLine.js +204 -0
  83. package/dist/marks/streamGL.d.ts +41 -0
  84. package/dist/marks/streamGL.d.ts.map +1 -0
  85. package/dist/marks/streamGL.js +317 -0
  86. package/dist/tooltip.d.ts +38 -0
  87. package/dist/tooltip.d.ts.map +1 -0
  88. package/dist/tooltip.js +57 -0
  89. package/dist/types.d.ts +193 -0
  90. package/dist/types.d.ts.map +1 -0
  91. package/dist/types.js +70 -0
  92. package/dist/useChartColors.d.ts +16 -0
  93. package/dist/useChartColors.d.ts.map +1 -0
  94. package/dist/useChartColors.js +37 -0
  95. package/dist/utils.d.ts +15 -0
  96. package/dist/utils.d.ts.map +1 -0
  97. package/dist/utils.js +31 -0
  98. package/dist/webgl.d.ts +77 -0
  99. package/dist/webgl.d.ts.map +1 -0
  100. package/dist/webgl.js +235 -0
  101. package/package.json +71 -3
  102. package/src/Chart.doc.mjs +174 -0
  103. package/src/Chart.test.tsx +273 -0
  104. package/src/Chart.tsx +535 -0
  105. package/src/ChartAxis.tsx +289 -0
  106. package/src/ChartContext.ts +18 -0
  107. package/src/ChartGrid.tsx +136 -0
  108. package/src/ChartLegend.tsx +77 -0
  109. package/src/ChartSwatch.doc.mjs +17 -0
  110. package/src/ChartSwatch.tsx +89 -0
  111. package/src/ChartTooltip.tsx +452 -0
  112. package/src/charts.css.d.ts +3 -0
  113. package/src/dataTable.test.ts +86 -0
  114. package/src/dataTable.ts +124 -0
  115. package/src/formatters.ts +167 -0
  116. package/src/getChartColors.test.ts +114 -0
  117. package/src/getChartColors.ts +282 -0
  118. package/src/index.ts +88 -0
  119. package/src/layout.ts +372 -0
  120. package/src/legend.ts +44 -0
  121. package/src/markColor.ts +70 -0
  122. package/src/marks/area.tsx +142 -0
  123. package/src/marks/band.tsx +86 -0
  124. package/src/marks/bar.tsx +210 -0
  125. package/src/marks/candlestick.tsx +128 -0
  126. package/src/marks/dot.tsx +119 -0
  127. package/src/marks/dotGL.tsx +274 -0
  128. package/src/marks/dotGLInteractive.tsx +660 -0
  129. package/src/marks/errorBar.tsx +119 -0
  130. package/src/marks/heatmapGL.tsx +392 -0
  131. package/src/marks/index.ts +17 -0
  132. package/src/marks/line.tsx +118 -0
  133. package/src/marks/referenceLine.tsx +252 -0
  134. package/src/marks/streamGL.tsx +381 -0
  135. package/src/tooltip.ts +73 -0
  136. package/src/types.ts +212 -0
  137. package/src/useChartColors.ts +41 -0
  138. package/src/utils.ts +38 -0
  139. package/src/webgl.ts +262 -0
@@ -0,0 +1,289 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file ChartAxis.tsx (v2)
5
+ * @output Renders an axis (top, right, bottom, left) using the chart's scales
6
+ * @position Child of Chart v2; reads scales from chart context
7
+ *
8
+ * The axis edge line and tick marks are independently toggleable so callers
9
+ * can compose: line-only, line + ticks, or line + ticks + grid (paired with
10
+ * `<ChartGrid>`). To stay consistent with most charts, the bottom axis
11
+ * draws its edge line by default and other positions don't.
12
+ *
13
+ * Labels use d3's native tick formatter for continuous scales by default (clean
14
+ * numbers/dates) unless a `tickFormat` is supplied, and are auto-thinned to the
15
+ * available width (horizontal) or height (vertical) to avoid overlap.
16
+ *
17
+ * Ticks use CSS transitions for smooth sliding during streaming updates.
18
+ *
19
+ * Orientation-aware: in a horizontal chart the context hands over display-space
20
+ * scales (values on x, categories on y), so `position="bottom"` shows values and
21
+ * `position="left"` shows categories. The default edge line follows the value
22
+ * baseline: bottom for vertical charts, left for horizontal ones.
23
+ */
24
+
25
+ import {useCallback, useMemo} from 'react';
26
+ import * as stylex from '@stylexjs/stylex';
27
+ import {colorVars} from '@pharos-ds/core/theme/tokens.stylex';
28
+ import {useChart} from './ChartContext';
29
+ import {isBandScale} from './utils';
30
+ import type {ScaleLinear, ScaleTime} from 'd3-scale';
31
+
32
+ export interface ChartAxisProps {
33
+ /** Which edge to render the axis on */
34
+ position: 'top' | 'right' | 'bottom' | 'left';
35
+ /** Number of ticks (approximate — d3 decides final count) */
36
+ tickCount?: number;
37
+ /** Maximum number of tick labels to show. Labels are evenly skipped when exceeded. */
38
+ maxTicks?: number;
39
+ /** Custom tick formatter */
40
+ tickFormat?: (value: unknown) => string;
41
+ /** Truncate labels to this many characters (appends "\u2026"). */
42
+ truncate?: number;
43
+ /** Enable smooth transitions for streaming (default: true) */
44
+ animated?: boolean;
45
+ /**
46
+ * Show the axis edge line. Defaults to `true` for the category axis
47
+ * (`position="bottom"`, or `position="left"` in a horizontal chart), `false`
48
+ * otherwise. Forced to `true` when `showTicks` is enabled.
49
+ * Pair with `<ChartGrid vertical />` and pass `showAxisLine` on the
50
+ * corresponding side axis to keep the visual edge grounded.
51
+ */
52
+ showAxisLine?: boolean;
53
+ /** Show perpendicular tick marks at each tick label. Default: `false`. */
54
+ showTicks?: boolean;
55
+ }
56
+
57
+ const styles = stylex.create({
58
+ axisLine: {
59
+ stroke: colorVars['--color-text-primary'],
60
+ strokeWidth: 1,
61
+ },
62
+ tickMark: {
63
+ stroke: colorVars['--color-text-primary'],
64
+ strokeWidth: 1,
65
+ },
66
+ });
67
+
68
+ const TICK_SIZE = 6;
69
+
70
+ /**
71
+ * Chart axis. Always draws tick labels; the axis edge line and tick marks
72
+ * are independently toggleable. Pair with `<ChartGrid>` for grid lines.
73
+ *
74
+ * @example
75
+ * ```
76
+ * <ChartAxis position="bottom" /> // edge line + labels
77
+ * <ChartAxis position="left" /> // labels only (no edge line)
78
+ * <ChartAxis position="left" showAxisLine showTicks />
79
+ * ```
80
+ */
81
+ export function ChartAxis({
82
+ position,
83
+ tickCount = 5,
84
+ maxTicks,
85
+ tickFormat,
86
+ truncate,
87
+ animated = true,
88
+ showAxisLine,
89
+ showTicks = false,
90
+ }: ChartAxisProps) {
91
+ const {width, height, xScale, yScale, yBandScale, orientation} = useChart();
92
+ const isHorizontalChart = orientation === 'horizontal';
93
+ // Tick marks need an axis line to anchor against; force it on when ticks
94
+ // are enabled so the two visuals stay coherent.
95
+ const renderAxisLine =
96
+ (showAxisLine ?? position === (isHorizontalChart ? 'left' : 'bottom')) ||
97
+ showTicks;
98
+
99
+ const isHorizontal = position === 'top' || position === 'bottom';
100
+ // A categorical y-axis (e.g. heatmap rows) is exposed as `yBandScale`; prefer
101
+ // it over the linear `yScale` so a left/right axis renders the row categories
102
+ // (days) aligned to each band instead of meaningless value ticks.
103
+ const scale = isHorizontal ? xScale : (yBandScale ?? yScale);
104
+
105
+ // Sanitize the tick count before it reaches d3. `.ticks()` targets ~N ticks
106
+ // and allocates an array that large, so a huge N throws `RangeError: Invalid
107
+ // array length` and crashes the render; a non-finite or negative N would
108
+ // otherwise blank the axis. Clamp to a sane integer (0 still means "no
109
+ // ticks") and fall back to the default for invalid input. Shared by tick
110
+ // generation and the auto formatter so the two stay in sync.
111
+ const safeTickCount =
112
+ Number.isFinite(tickCount) && tickCount >= 0
113
+ ? Math.min(Math.floor(tickCount), 1000)
114
+ : 5;
115
+
116
+ // Default label formatter. For continuous (linear/time) scales, use d3's own
117
+ // tick formatter so numbers get sensible precision (no floating-point dust
118
+ // like "0.30000000000000004") and dates render as calendar labels instead of
119
+ // `String(Date)`. Band scales keep their category strings.
120
+ const autoFormat = useMemo<((v: unknown) => string) | null>(() => {
121
+ if (tickFormat || isBandScale(scale)) {
122
+ return null;
123
+ }
124
+ const continuous = scale as
125
+ ScaleLinear<number, number> | ScaleTime<number, number>;
126
+ const fmt = continuous.tickFormat(safeTickCount);
127
+ return (v: unknown) => fmt(v as number & Date);
128
+ }, [tickFormat, scale, safeTickCount]);
129
+
130
+ const format = useCallback(
131
+ (value: unknown): string => {
132
+ const str = (tickFormat ?? autoFormat ?? String)(value);
133
+ return truncate && str.length > truncate
134
+ ? str.slice(0, truncate) + '\u2026'
135
+ : str;
136
+ },
137
+ [tickFormat, autoFormat, truncate],
138
+ );
139
+
140
+ const ticks = useMemo(() => {
141
+ let allTicks: {value: unknown; offset: number}[];
142
+ if (isBandScale(scale)) {
143
+ allTicks = scale.domain().map(d => ({
144
+ value: d,
145
+ offset: (scale(d) ?? 0) + scale.bandwidth() / 2,
146
+ }));
147
+ } else {
148
+ const linearScale = scale as
149
+ ScaleLinear<number, number> | ScaleTime<number, number>;
150
+ allTicks = linearScale.ticks(safeTickCount).map(d => ({
151
+ value: d,
152
+ offset: linearScale(d as number & Date),
153
+ }));
154
+ }
155
+
156
+ // Cap label count to avoid overlap. An explicit `maxTicks` always wins.
157
+ // Otherwise derive a cap from the available space and label size so dense
158
+ // axes don't smear into an unreadable blur: width ÷ widest-label for
159
+ // horizontal (e.g. 30 daily categories), height ÷ line-height for vertical
160
+ // (e.g. a categorical y-axis on a horizontal bar chart). Labels are then
161
+ // evenly skipped down to the cap.
162
+ let cap = maxTicks;
163
+ if (cap == null && allTicks.length > 1) {
164
+ if (isHorizontal && width > 0) {
165
+ const widestChars = allTicks.reduce(
166
+ (m, t) => Math.max(m, format(t.value).length),
167
+ 1,
168
+ );
169
+ const approxLabelPx = widestChars * 7 + 16;
170
+ cap = Math.max(1, Math.floor(width / approxLabelPx));
171
+ } else if (!isHorizontal && height > 0) {
172
+ // ~12px glyph + breathing room keeps stacked labels from touching.
173
+ cap = Math.max(1, Math.floor(height / 18));
174
+ }
175
+ }
176
+ if (cap && allTicks.length > cap) {
177
+ const step = Math.ceil(allTicks.length / cap);
178
+ allTicks = allTicks.filter((_, i) => i % step === 0);
179
+ }
180
+
181
+ return allTicks;
182
+ }, [scale, safeTickCount, maxTicks, isHorizontal, width, height, format]);
183
+
184
+ const transform =
185
+ position === 'bottom'
186
+ ? `translate(0,${height})`
187
+ : position === 'right'
188
+ ? `translate(${width},0)`
189
+ : undefined;
190
+
191
+ // For the bottom axis, draw the axis line at y=0 when the domain spans
192
+ // negative values (so the axis line still represents zero, not the chart edge).
193
+ const axisLineY = (() => {
194
+ // Categorical y (e.g. heatmap rows): there is no meaningful zero, and the
195
+ // linear yScale is degenerate — anchor the bottom edge line to the plot edge
196
+ // so it frames the grid instead of cutting across the cells.
197
+ if (position !== 'bottom' || yBandScale || isHorizontalChart) {
198
+ return 0;
199
+ }
200
+ const domain = yScale.domain();
201
+ if (domain[0] < 0 && domain[1] > 0) {
202
+ return yScale(0) - height;
203
+ }
204
+ return 0;
205
+ })();
206
+
207
+ // Horizontal charts: the left edge line marks value zero when the value
208
+ // domain spans negative values (mirror of `axisLineY` above).
209
+ const axisLineX = (() => {
210
+ if (position !== 'left' || !isHorizontalChart || isBandScale(xScale)) {
211
+ return 0;
212
+ }
213
+ const domain = (xScale as ScaleLinear<number, number>).domain();
214
+ if (domain[0] < 0 && domain[1] > 0) {
215
+ return (xScale as ScaleLinear<number, number>)(0);
216
+ }
217
+ return 0;
218
+ })();
219
+
220
+ const tickTransition = animated
221
+ ? 'transform 150ms linear, opacity 150ms ease'
222
+ : undefined;
223
+
224
+ return (
225
+ <g transform={transform} role="group" aria-label={`${position} axis`}>
226
+ {renderAxisLine && (
227
+ <line
228
+ x1={isHorizontal ? 0 : axisLineX}
229
+ x2={isHorizontal ? width : axisLineX}
230
+ y1={isHorizontal ? axisLineY : 0}
231
+ y2={isHorizontal ? axisLineY : height}
232
+ {...stylex.props(styles.axisLine)}
233
+ />
234
+ )}
235
+ {ticks.map(({value, offset}) => {
236
+ const label = format(value);
237
+ // Key off the raw tick value, not the formatted label: distinct ticks
238
+ // can format (or truncate) to the same string and would collide as keys.
239
+ const key = String(value);
240
+ const isVisible = isHorizontal
241
+ ? offset >= -10 && offset <= width + 10
242
+ : offset >= -10 && offset <= height + 10;
243
+
244
+ if (isHorizontal) {
245
+ const y = position === 'bottom' ? TICK_SIZE : -TICK_SIZE;
246
+ return (
247
+ <g
248
+ key={key}
249
+ style={{
250
+ transform: `translateX(${offset}px)`,
251
+ transition: tickTransition,
252
+ opacity: isVisible ? 1 : 0,
253
+ }}>
254
+ {showTicks && <line y2={y} {...stylex.props(styles.tickMark)} />}
255
+ <text
256
+ y={position === 'bottom' ? TICK_SIZE + 12 : -(TICK_SIZE + 4)}
257
+ textAnchor="middle"
258
+ fill="var(--color-text-secondary)"
259
+ fontSize={12}>
260
+ {label}
261
+ </text>
262
+ </g>
263
+ );
264
+ }
265
+ // Vertical axis
266
+ const x = position === 'left' ? -TICK_SIZE : TICK_SIZE;
267
+ return (
268
+ <g
269
+ key={key}
270
+ style={{
271
+ transform: `translateY(${offset}px)`,
272
+ transition: tickTransition,
273
+ opacity: isVisible ? 1 : 0,
274
+ }}>
275
+ {showTicks && <line x2={x} {...stylex.props(styles.tickMark)} />}
276
+ <text
277
+ x={position === 'left' ? -(TICK_SIZE + 4) : TICK_SIZE + 4}
278
+ dy="0.32em"
279
+ textAnchor={position === 'left' ? 'end' : 'start'}
280
+ fill="var(--color-text-secondary)"
281
+ fontSize={12}>
282
+ {label}
283
+ </text>
284
+ </g>
285
+ );
286
+ })}
287
+ </g>
288
+ );
289
+ }
@@ -0,0 +1,18 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ import {createContext, useContext} from 'react';
6
+ import type {ChartContext} from './types';
7
+
8
+ const Ctx = createContext<ChartContext | null>(null);
9
+ Ctx.displayName = 'ChartContext';
10
+ export const ChartProvider = Ctx.Provider;
11
+
12
+ export function useChart(): ChartContext {
13
+ const ctx = useContext(Ctx);
14
+ if (!ctx) {
15
+ throw new Error('Must be used inside <Chart>');
16
+ }
17
+ return ctx;
18
+ }
@@ -0,0 +1,136 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file ChartGrid.tsx (v2)
5
+ * @output Grid lines behind chart marks
6
+ * @position Child of Chart v2; reads scales from chart context
7
+ *
8
+ * Defaults follow the value axis: horizontal lines for vertical charts,
9
+ * vertical lines for `orientation="horizontal"` charts. In a horizontal chart
10
+ * with categorical rows, explicit `horizontal` lines sit on the row centers.
11
+ */
12
+
13
+ import {useMemo} from 'react';
14
+ import * as stylex from '@stylexjs/stylex';
15
+ import {colorVars} from '@pharos-ds/core/theme/tokens.stylex';
16
+ import {useChart} from './ChartContext';
17
+ import {isBandScale} from './utils';
18
+ import type {ScaleLinear, ScaleTime} from 'd3-scale';
19
+
20
+ export interface ChartGridProps {
21
+ /** Show horizontal grid lines. Default: `true` (`false` in horizontal charts). */
22
+ horizontal?: boolean;
23
+ /** Show vertical grid lines. Default: `false` (`true` in horizontal charts). */
24
+ vertical?: boolean;
25
+ /**
26
+ * Approximate number of grid lines per axis. Match your `<ChartAxis>`
27
+ * `tickCount` so grid lines and tick labels land on the same values.
28
+ * Default: `5`.
29
+ */
30
+ tickCount?: number;
31
+ }
32
+
33
+ const styles = stylex.create({
34
+ gridLine: {
35
+ stroke: colorVars['--color-border-emphasized'],
36
+ strokeWidth: 1,
37
+ },
38
+ });
39
+
40
+ /**
41
+ * Grid lines behind chart marks.
42
+ *
43
+ * Pair with `<ChartAxis>` so users always see an axis line beside the
44
+ * grid — the components are designed to be used together.
45
+ *
46
+ * @example
47
+ * ```
48
+ * <ChartGrid /> // horizontal grid lines (default)
49
+ * <ChartGrid horizontal vertical />
50
+ * ```
51
+ */
52
+ export function ChartGrid({
53
+ horizontal: horizontalProp,
54
+ vertical: verticalProp,
55
+ tickCount = 5,
56
+ }: ChartGridProps) {
57
+ const {width, height, xScale, yScale, yBandScale, orientation} = useChart();
58
+ const isHorizontalChart = orientation === 'horizontal';
59
+ const horizontal = horizontalProp ?? !isHorizontalChart;
60
+ const vertical = verticalProp ?? isHorizontalChart;
61
+
62
+ // Sanitize the tick count before it reaches d3's `.ticks()`: it targets ~N
63
+ // ticks and allocates an array that large, so a huge N throws `RangeError:
64
+ // Invalid array length` and crashes the render, while a non-finite or
65
+ // negative N would misbehave. Clamp to a sane integer (0 still means "no
66
+ // lines") and fall back to the default for invalid input.
67
+ const safeTickCount =
68
+ Number.isFinite(tickCount) && tickCount >= 0
69
+ ? Math.min(Math.floor(tickCount), 1000)
70
+ : 5;
71
+
72
+ // Skip the y=0 line when emphasizing it via the axis. Without this we'd
73
+ // double-draw on top of the axis line. Each line carries its tick `value`,
74
+ // used as a stable React key — the pixel position can be NaN/duplicated for
75
+ // degenerate (zero-range / unmeasured) scales and would collide as a key.
76
+ const hLines = useMemo(() => {
77
+ if (!horizontal) {
78
+ return [];
79
+ }
80
+ if (isHorizontalChart && yBandScale) {
81
+ return yBandScale.domain().map(d => ({
82
+ value: d,
83
+ pos: (yBandScale(d) ?? 0) + yBandScale.bandwidth() / 2,
84
+ }));
85
+ }
86
+ return yScale
87
+ .ticks(safeTickCount)
88
+ .filter(tick => tick !== 0)
89
+ .map(tick => ({value: tick, pos: yScale(tick)}));
90
+ }, [horizontal, yScale, yBandScale, isHorizontalChart, safeTickCount]);
91
+
92
+ const vLines = useMemo(() => {
93
+ if (!vertical) {
94
+ return [];
95
+ }
96
+ if (isBandScale(xScale)) {
97
+ return xScale
98
+ .domain()
99
+ .map(d => ({value: d, pos: (xScale(d) ?? 0) + xScale.bandwidth() / 2}));
100
+ }
101
+ const linear = xScale as
102
+ ScaleLinear<number, number> | ScaleTime<number, number>;
103
+ return (
104
+ linear
105
+ .ticks(safeTickCount)
106
+ // Horizontal charts draw value zero with the left axis line.
107
+ .filter(d => !(isHorizontalChart && Number(d) === 0))
108
+ .map(d => ({value: String(d), pos: linear(d as number & Date)}))
109
+ );
110
+ }, [vertical, xScale, safeTickCount, isHorizontalChart]);
111
+
112
+ return (
113
+ <g>
114
+ {hLines.map(({value, pos}) => (
115
+ <line
116
+ key={`h-${value}`}
117
+ x1={0}
118
+ x2={width}
119
+ y1={pos}
120
+ y2={pos}
121
+ {...stylex.props(styles.gridLine)}
122
+ />
123
+ ))}
124
+ {vLines.map(({value, pos}) => (
125
+ <line
126
+ key={`v-${value}`}
127
+ x1={pos}
128
+ x2={pos}
129
+ y1={0}
130
+ y2={height}
131
+ {...stylex.props(styles.gridLine)}
132
+ />
133
+ ))}
134
+ </g>
135
+ );
136
+ }
@@ -0,0 +1,77 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file ChartLegend.tsx
5
+ * @output Standalone chart legend component
6
+ * @position Can be used inside Chart via the `legend` prop, or independently
7
+ *
8
+ * @example
9
+ * // Via Chart
10
+ * <Chart legend={{position: 'top', alignment: 'start'}} ... />
11
+ *
12
+ * // Standalone
13
+ * <ChartLegend items={[{label: 'Revenue', color: '#3b82f6'}]} />
14
+ */
15
+
16
+ import {Text} from '@pharos-ds/core';
17
+ import {VStack, HStack} from '@pharos-ds/core';
18
+ import {ChartSwatch, swatchVariantForType} from './ChartSwatch';
19
+ import type {LegendItem} from './legend';
20
+
21
+ export type {LegendItem};
22
+
23
+ export type LegendPosition = 'top' | 'bottom' | 'start' | 'end';
24
+ export type LegendAlignment = 'start' | 'center' | 'end';
25
+
26
+ export interface ChartLegendProps {
27
+ /** Legend items to display */
28
+ items?: LegendItem[];
29
+ /** Position of the legend relative to the chart. Default: 'bottom' */
30
+ position?: LegendPosition;
31
+ /** Alignment of the legend within its position. Default: 'start' */
32
+ alignment?: LegendAlignment;
33
+ }
34
+
35
+ export function ChartLegend({
36
+ items = [],
37
+ position = 'bottom',
38
+ alignment = 'start',
39
+ }: ChartLegendProps) {
40
+ if (items.length === 0) {
41
+ return null;
42
+ }
43
+
44
+ const isVertical = position === 'start' || position === 'end';
45
+
46
+ // Rows are stateless, but labels aren't guaranteed unique (two series can
47
+ // share a label), so disambiguate with the index to keep keys collision-free.
48
+ const legendItems = items.map((item, i) => (
49
+ <HStack key={`${item.label}-${i}`} gap={2} vAlign="center" role="listitem">
50
+ <ChartSwatch
51
+ color={item.color}
52
+ variant={swatchVariantForType(item.type)}
53
+ />
54
+ <Text type="supporting">{item.label}</Text>
55
+ </HStack>
56
+ ));
57
+
58
+ if (isVertical) {
59
+ return (
60
+ <VStack gap={2} hAlign={alignment} role="list" aria-label="Chart legend">
61
+ {legendItems}
62
+ </VStack>
63
+ );
64
+ }
65
+
66
+ return (
67
+ <HStack
68
+ gap={4}
69
+ justify={alignment}
70
+ vAlign="center"
71
+ wrap="wrap"
72
+ role="list"
73
+ aria-label="Chart legend">
74
+ {legendItems}
75
+ </HStack>
76
+ );
77
+ }
@@ -0,0 +1,17 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('../../core/src/docs-types').ComponentDoc} */
4
+
5
+ export const docs = {
6
+ name: 'ChartSwatch',
7
+ displayName: 'Chart Swatch',
8
+ group: 'Charts',
9
+ category: 'Data Visualization',
10
+ isHiddenFromOverview: true,
11
+
12
+ usage: {
13
+ description: '',
14
+ },
15
+
16
+ props: [],
17
+ };
@@ -0,0 +1,89 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file ChartSwatch.tsx
5
+ * @output Color swatch primitive for legends, tooltips, and standalone use
6
+ * @position Composable primitive — used by ChartLegend (and future
7
+ * ChartTooltip), or independently anywhere a series indicator
8
+ * is needed.
9
+ *
10
+ * @example
11
+ * ```
12
+ * <ChartSwatch color="#3b82f6" variant="square" />
13
+ * <ChartSwatch color="#f59e0b" variant="line" />
14
+ * ```
15
+ */
16
+
17
+ import {memo} from 'react';
18
+ import * as stylex from '@stylexjs/stylex';
19
+
20
+ /**
21
+ * Visual variant — drives shape.
22
+ * - `'square'` — square chip (bar series)
23
+ * - `'line'` — short stroke (line, dot, area, and other non-bar series)
24
+ */
25
+ export type ChartSwatchVariant = 'square' | 'line';
26
+
27
+ export interface ChartSwatchProps {
28
+ /** Fill color — typically a series color token */
29
+ color: string;
30
+ /** Visual variant. Default: `'square'`. */
31
+ variant?: ChartSwatchVariant;
32
+ }
33
+
34
+ const styles = stylex.create({
35
+ base: {
36
+ flexShrink: 0,
37
+ },
38
+ square: {
39
+ width: 8,
40
+ height: 8,
41
+ borderRadius: 2,
42
+ marginInline: 1,
43
+ },
44
+ line: {
45
+ width: 10,
46
+ height: 3,
47
+ borderRadius: 1.5,
48
+ },
49
+ // Runtime color → dynamic StyleX (not an inline style), per repo convention.
50
+ fill: (color: string) => ({
51
+ backgroundColor: color,
52
+ }),
53
+ });
54
+
55
+ /**
56
+ * Color swatch primitive.
57
+ *
58
+ * Used by ChartLegend (and ChartTooltip) to render the colored
59
+ * indicator next to a series label. Exported as a primitive so consumers
60
+ * can build their own custom legend/tooltip layouts while keeping visuals
61
+ * consistent.
62
+ */
63
+ function ChartSwatchImpl({color, variant = 'square'}: ChartSwatchProps) {
64
+ return (
65
+ <div
66
+ aria-hidden
67
+ {...stylex.props(styles.base, styles[variant], styles.fill(color))}
68
+ />
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Memoized — swatches are static for a given (color, variant) tuple
74
+ * and can render many times in a tooltip/legend with several series.
75
+ */
76
+ export const ChartSwatch = memo(ChartSwatchImpl);
77
+
78
+ /**
79
+ * Map a SeriesDef.type string to its swatch variant.
80
+ *
81
+ * Bar series get a square chip; everything else (line, dot, area, and any
82
+ * other non-bar mark) gets a short stroke. Centralized so legend, tooltip,
83
+ * and any custom chrome stay consistent.
84
+ */
85
+ export function swatchVariantForType(
86
+ type: string | undefined,
87
+ ): ChartSwatchVariant {
88
+ return type === 'bar' ? 'square' : 'line';
89
+ }