@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
package/src/layout.ts ADDED
@@ -0,0 +1,372 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file layout.ts
5
+ * @output Computes scales, stacking, grouping, then delegates to each series.resolve()
6
+ * @position Called by Chart root — the single layout pass
7
+ *
8
+ * The layout engine does NOT know about specific mark types.
9
+ * It handles cross-series coordination (scales, stacking, grouping)
10
+ * then calls each series' own resolve() method.
11
+ */
12
+
13
+ import {scaleLinear, scaleBand} from 'd3-scale';
14
+ import {stack as d3Stack, stackOrderNone, stackOffsetNone} from 'd3-shape';
15
+ import type {
16
+ SeriesDef,
17
+ ChartScale,
18
+ ResolvedPoint,
19
+ SeriesContext,
20
+ YBaseline,
21
+ } from './types';
22
+
23
+ /**
24
+ * Fraction of the data range added as padding above/below for continuous-only
25
+ * charts (no bar/area zero baseline), so endpoints and curve overshoot don't
26
+ * glue to the plot edges.
27
+ */
28
+ const CONTINUOUS_HEADROOM = 0.08;
29
+
30
+ export interface LayoutInput {
31
+ data: Record<string, unknown>[];
32
+ xKey: string;
33
+ series: SeriesDef[];
34
+ width: number;
35
+ height: number;
36
+ /** How the y-domain is derived when `yDomain` is not provided. Default 'auto'. */
37
+ yBaseline?: YBaseline;
38
+ /** Explicit y-domain [min, max]. Authoritative — no baseline/headroom/nice(). */
39
+ yDomain?: [number, number];
40
+ /**
41
+ * Explicit x-domain [min, max] for numeric/linear x. Authoritative and honored
42
+ * even when `data` is empty (stable streaming window). Ignored for band scales.
43
+ */
44
+ xDomain?: [number, number];
45
+ }
46
+
47
+ export interface LayoutResult {
48
+ xScale: ChartScale;
49
+ yScale: ReturnType<typeof scaleLinear<number, number>>;
50
+ /**
51
+ * Categorical y band scale, built when a series declares `layout.yBandKey`
52
+ * (e.g. a heatmap's rows). When present the y-axis is categorical and the
53
+ * linear `yScale` is unused.
54
+ */
55
+ yBandScale?: ReturnType<typeof scaleBand<string>>;
56
+ resolved: Map<string, ResolvedPoint[]>;
57
+ }
58
+
59
+ /** True only when both ends of an explicit domain are usable finite numbers. */
60
+ function isFiniteDomain(domain: [number, number]): boolean {
61
+ return Number.isFinite(domain[0]) && Number.isFinite(domain[1]);
62
+ }
63
+
64
+ /**
65
+ * Finite [min, max] of a numeric column, robust to the two failure modes of
66
+ * `Math.min(...arr)` / `Math.max(...arr)`: a NaN/Infinity poisoning the result,
67
+ * and a call-stack overflow when spreading a very large array (GL marks push
68
+ * hundreds of thousands of points). Empty or degenerate (all-equal) extents are
69
+ * expanded so the resulting scale never collapses onto a single pixel.
70
+ */
71
+ function finiteExtent(nums: number[]): [number, number] {
72
+ let lo = Infinity;
73
+ let hi = -Infinity;
74
+ for (const v of nums) {
75
+ if (Number.isFinite(v)) {
76
+ if (v < lo) {
77
+ lo = v;
78
+ }
79
+ if (v > hi) {
80
+ hi = v;
81
+ }
82
+ }
83
+ }
84
+ if (!Number.isFinite(lo) || !Number.isFinite(hi)) {
85
+ return [0, 1];
86
+ }
87
+ if (lo === hi) {
88
+ const pad = Math.abs(lo) * 0.5 || 1;
89
+ return [lo - pad, hi + pad];
90
+ }
91
+ return [lo, hi];
92
+ }
93
+
94
+ export function computeLayout({
95
+ data,
96
+ xKey,
97
+ series,
98
+ width,
99
+ height,
100
+ yBaseline,
101
+ yDomain,
102
+ xDomain,
103
+ }: LayoutInput): LayoutResult {
104
+ // A categorical y-axis (heatmap) means BOTH axes are categorical: columns
105
+ // should tile the plot edge-to-edge just like the rows. Drop the bar-style
106
+ // outer padding on the x band scale for these charts so the grid meets the
107
+ // x/y axis lines flush on every side (no asymmetric gap).
108
+ const yBandKey = series.find(s => s.layout.yBandKey != null)?.layout.yBandKey;
109
+ const isCategoricalGrid = yBandKey != null;
110
+
111
+ // ─── 1. X scale ──────────────────────────────────────────────────────
112
+ const xValues = data.map(d => d[xKey]);
113
+ const isNumericX =
114
+ xValues.length > 0 && xValues.every(v => typeof v === 'number');
115
+
116
+ let xScale: ChartScale;
117
+ if (xDomain && isFiniteDomain(xDomain)) {
118
+ // Explicit domain is authoritative (no .nice()) — enables a stable
119
+ // streaming window and a valid scale even when `data` is empty. Trusted
120
+ // only when finite; a NaN/Infinity domain would collapse the scale.
121
+ xScale = scaleLinear().domain(xDomain).range([0, width]);
122
+ } else if (isNumericX) {
123
+ // NaN passes `typeof === 'number'`, so derive the extent defensively.
124
+ const [lo, hi] = finiteExtent(xValues as number[]);
125
+ xScale = scaleLinear().domain([lo, hi]).range([0, width]).nice();
126
+ } else {
127
+ xScale = scaleBand<string>()
128
+ .domain(xValues.map(String))
129
+ .range([0, width])
130
+ .padding(isCategoricalGrid ? 0 : 0.2);
131
+ }
132
+
133
+ // ─── 2. Y domain from all series dataKeys ────────────────────────────
134
+ // One pass collects the union of data keys, whether any mark wants a zero
135
+ // baseline, and the stack groupings. `stackGroups` is reused verbatim by the
136
+ // stacking pass below (§3) — previously these groups were built twice.
137
+ const allKeys = new Set<string>();
138
+ let includeZero = false;
139
+ const stackGroups = new Map<string, string[]>();
140
+ for (const s of series) {
141
+ for (const k of s.dataKeys) {
142
+ allKeys.add(k);
143
+ }
144
+ if (s.layout.includeZero) {
145
+ includeZero = true;
146
+ }
147
+ const primaryKey = s.dataKeys[0];
148
+ if (s.layout.stack && primaryKey != null) {
149
+ const group = stackGroups.get(s.layout.stack) ?? [];
150
+ group.push(primaryKey);
151
+ stackGroups.set(s.layout.stack, group);
152
+ }
153
+ }
154
+
155
+ let yMin = Infinity,
156
+ yMax = -Infinity;
157
+
158
+ // Stacked series contribute the full extent of their running cumulative — every
159
+ // layer boundary, not just the net total — mirroring the d3 stackOffsetNone pass
160
+ // in §3 that actually draws them. The 0 baseline is included because stacks are
161
+ // drawn from it. Tracking only the total would let the drawn bars/areas escape
162
+ // the scale: a mixed-sign stack whose interior peaks exceed the net total, or a
163
+ // 'data'-baseline stack whose bars start at 0 (below the smallest positive
164
+ // total), would render outside the y-domain and be clipped. `Number.isFinite`
165
+ // (not `typeof === 'number'`) keeps a stray NaN/Infinity from corrupting the
166
+ // domain into a NaN/Infinity scale; a non-finite layer carries the baseline
167
+ // forward (contributes nothing new) exactly as stackOffsetNone does.
168
+ const stackedKeys = new Set<string>();
169
+ for (const [, keys] of stackGroups) {
170
+ for (const k of keys) {
171
+ stackedKeys.add(k);
172
+ }
173
+ for (const d of data) {
174
+ let acc = 0;
175
+ if (acc < yMin) {
176
+ yMin = acc;
177
+ }
178
+ if (acc > yMax) {
179
+ yMax = acc;
180
+ }
181
+ for (const k of keys) {
182
+ const v = d[k];
183
+ if (Number.isFinite(v)) {
184
+ acc += v as number;
185
+ if (acc < yMin) {
186
+ yMin = acc;
187
+ }
188
+ if (acc > yMax) {
189
+ yMax = acc;
190
+ }
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ // Non-stacked series contribute their individual finite values.
197
+ for (const d of data) {
198
+ for (const k of allKeys) {
199
+ if (stackedKeys.has(k)) {
200
+ continue;
201
+ } // already handled above
202
+ const v = d[k];
203
+ if (Number.isFinite(v)) {
204
+ const n = v as number;
205
+ if (n < yMin) {
206
+ yMin = n;
207
+ }
208
+ if (n > yMax) {
209
+ yMax = n;
210
+ }
211
+ }
212
+ }
213
+ }
214
+
215
+ // ─── Resolve the final y-domain ──────────────────────────────────────
216
+ let yDomainFinal: [number, number];
217
+ let applyNice = true;
218
+
219
+ if (yDomain && isFiniteDomain(yDomain)) {
220
+ // Caller owns the exact domain (e.g. zoom/streaming). No baseline,
221
+ // headroom, or .nice() — rounding here would cause visual ratcheting.
222
+ // Trusted only when finite; a NaN/Infinity domain falls through to auto.
223
+ yDomainFinal = yDomain;
224
+ applyNice = false;
225
+ } else {
226
+ // Degenerate: no finite values (empty data / all non-numeric).
227
+ if (!Number.isFinite(yMin) || !Number.isFinite(yMax)) {
228
+ yMin = 0;
229
+ yMax = 1;
230
+ }
231
+ // Degenerate: zero range (single point / all-equal) — expand so the mark
232
+ // is visible and ticks are sane.
233
+ if (yMin === yMax) {
234
+ const pad = Math.abs(yMin) * 0.5 || 1;
235
+ yMin -= pad;
236
+ yMax += pad;
237
+ }
238
+
239
+ const mode: YBaseline = yBaseline ?? 'auto';
240
+ if (mode === 'zero') {
241
+ const abs = Math.max(Math.abs(yMin), Math.abs(yMax));
242
+ yMin = -abs;
243
+ yMax = abs;
244
+ } else if (mode === 'data') {
245
+ // Tight fit — no zero forcing, no headroom.
246
+ } else {
247
+ // 'auto': bar/area marks pin an honest zero baseline; continuous-only
248
+ // charts get headroom so endpoints/overshoot don't glue to the edges.
249
+ if (includeZero) {
250
+ if (yMin > 0) {
251
+ yMin = 0;
252
+ }
253
+ if (yMax < 0) {
254
+ yMax = 0;
255
+ }
256
+ } else {
257
+ const pad = (yMax - yMin || 1) * CONTINUOUS_HEADROOM;
258
+ yMin -= pad;
259
+ yMax += pad;
260
+ }
261
+ }
262
+ yDomainFinal = [yMin, yMax];
263
+ }
264
+
265
+ const yScaleBase = scaleLinear().domain(yDomainFinal).range([height, 0]);
266
+ const yScale = applyNice ? yScaleBase.nice() : yScaleBase;
267
+
268
+ // ─── 2b. Categorical y band scale ────────────────────────────────────
269
+ // If any series declares a `yBandKey` (e.g. a heatmap's rows), the y-axis is
270
+ // categorical: build one band scale over that key's unique values so the
271
+ // marks and the left axis share the same row layout. The linear yScale above
272
+ // is left in place but goes unused for these charts.
273
+ // No band padding: heatmap rows should tile the full plot height edge-to-edge
274
+ // (the mark's own `cellGap` draws the thin grid lines between cells). Padding
275
+ // here would inset the grid and leave a sliver at the top/bottom.
276
+ let yBandScale: ReturnType<typeof scaleBand<string>> | undefined;
277
+ if (yBandKey != null) {
278
+ const cats = [...new Set(data.map(d => String(d[yBandKey])))];
279
+ yBandScale = scaleBand<string>().domain(cats).range([0, height]).padding(0);
280
+ }
281
+
282
+ // ─── 3. Stacking ─────────────────────────────────────────────────────
283
+ // Reuses `stackGroups` collected during the domain pass (§2).
284
+ const stackedData = new Map<string, {y0: number; y1: number}[]>();
285
+ for (const [, keys] of stackGroups) {
286
+ const stackGen = d3Stack<Record<string, unknown>>()
287
+ .keys(keys)
288
+ .order(stackOrderNone)
289
+ .offset(stackOffsetNone);
290
+ const stacked = stackGen(data);
291
+ for (const layer of stacked) {
292
+ stackedData.set(
293
+ layer.key,
294
+ layer.map(d => ({y0: d[0], y1: d[1]})),
295
+ );
296
+ }
297
+ }
298
+
299
+ // ─── 4. Bar grouping ─────────────────────────────────────────────────
300
+ // Bars can be grouped (side-by-side) whether or not they are stacked.
301
+ // For grouped stacks, each unique stack group name within the same bar
302
+ // group gets its own sub-band. For ungrouped non-stacked bars with a
303
+ // group key, each series gets its own sub-band.
304
+ const barGroups = new Map<string, string[]>();
305
+ for (const s of series) {
306
+ if (s.type === 'bar' && s.layout.group) {
307
+ const group = barGroups.get(s.layout.group) ?? [];
308
+ // For grouped stacks: use the stack name as the group slot identifier
309
+ // so all series in the same stack share one slot.
310
+ const slotKey = s.layout.stack ?? s.key;
311
+ if (!group.includes(slotKey)) {
312
+ group.push(slotKey);
313
+ }
314
+ barGroups.set(s.layout.group, group);
315
+ }
316
+ }
317
+
318
+ // ─── 5. Resolve each series ──────────────────────────────────────────
319
+ const ctx: SeriesContext = {
320
+ data,
321
+ xKey,
322
+ xScale,
323
+ yScale,
324
+ yBandScale,
325
+ width,
326
+ height,
327
+ };
328
+ const resolved = new Map<string, ResolvedPoint[]>();
329
+
330
+ // Determine which series is the topmost in each stack group
331
+ const stackTopKeys = new Set<string>();
332
+ for (const [, keys] of stackGroups) {
333
+ // Last key in the stack group is rendered on top
334
+ if (keys.length > 0) {
335
+ stackTopKeys.add(keys[keys.length - 1]);
336
+ }
337
+ }
338
+
339
+ for (let seriesIndex = 0; seriesIndex < series.length; seriesIndex++) {
340
+ const s = series[seriesIndex];
341
+ // Assign a collision-free identity from array position. Two series sharing
342
+ // a dataKey (and thus `key`) still get distinct uids, so neither the
343
+ // resolved map nor React keys silently drop one of them.
344
+ s._uid = `${seriesIndex}:${s.key}`;
345
+ const stackOffsets = stackedData.get(s.dataKeys[0]);
346
+
347
+ // Mark whether this series is the topmost in its stack
348
+ if (s.layout.stack) {
349
+ s._isTopOfStack = stackTopKeys.has(s.dataKeys[0]);
350
+ } else {
351
+ s._isTopOfStack = true;
352
+ }
353
+
354
+ let groupInfo: {index: number; count: number} | undefined;
355
+ if (s.layout.group) {
356
+ const groupKeys = barGroups.get(s.layout.group);
357
+ if (groupKeys) {
358
+ // For grouped stacks, the slot key is the stack name
359
+ const slotKey = s.layout.stack ?? s.key;
360
+ groupInfo = {
361
+ index: groupKeys.indexOf(slotKey),
362
+ count: groupKeys.length,
363
+ };
364
+ }
365
+ }
366
+
367
+ const points = s.resolve(ctx, stackOffsets, groupInfo);
368
+ resolved.set(s._uid, points);
369
+ }
370
+
371
+ return {xScale, yScale, yBandScale, resolved};
372
+ }
package/src/legend.ts ADDED
@@ -0,0 +1,44 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file legend.ts
5
+ * @output Legend utility — derives legend items from series definitions
6
+ * @position Used by Chart to bridge series → legend items
7
+ */
8
+
9
+ import {DEFAULT_SERIES_COLOR, isUtilityMarkType, type SeriesDef} from './types';
10
+
11
+ export interface LegendItem {
12
+ label: string;
13
+ color: string;
14
+ /** Mark type — determines swatch shape (square for bar, line for everything else) */
15
+ type?: string;
16
+ }
17
+
18
+ /**
19
+ * A series appears in the legend if it's a primary (non-utility) mark. Its
20
+ * representative color is the static color, else the root-assigned palette color
21
+ * (so auto-colored AND accessor-colored series still show up — previously an
22
+ * accessor color silently dropped the series from the legend).
23
+ */
24
+ function legendColor(series: SeriesDef): string | undefined {
25
+ if (isUtilityMarkType(series.type)) {
26
+ return undefined;
27
+ }
28
+ return series.color ?? series._resolvedColor ?? DEFAULT_SERIES_COLOR;
29
+ }
30
+
31
+ /**
32
+ * Derive legend items from series definitions. Skips utility marks.
33
+ */
34
+ export function deriveLegendItems(series: readonly SeriesDef[]): LegendItem[] {
35
+ const items: LegendItem[] = [];
36
+ for (const s of series) {
37
+ const color = legendColor(s);
38
+ if (color == null) {
39
+ continue;
40
+ }
41
+ items.push({label: s.label ?? s.key, color, type: s.type});
42
+ }
43
+ return items;
44
+ }
@@ -0,0 +1,70 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file markColor.ts
5
+ * @output Color resolution shared by marks
6
+ * @position Used by mark renderers to turn an optional color into a concrete one
7
+ *
8
+ * Precedence: an explicit color (string or accessor) always wins; otherwise the
9
+ * chart root's palette color for the series (`_resolvedColor`); otherwise a real
10
+ * data-viz token fallback. This is what makes an un-colored `bar('revenue')`
11
+ * render a real color instead of the historical broken `--color-chart-1`.
12
+ *
13
+ * Empty strings are treated as "unset" at every level (an empty `fill`/`stroke`
14
+ * paints nothing), and a per-point accessor that returns a non-string or empty
15
+ * value falls through to the palette rather than emitting an invalid paint.
16
+ */
17
+
18
+ import {
19
+ DEFAULT_SERIES_COLOR,
20
+ type SeriesDef,
21
+ type ColorAccessor,
22
+ } from './types';
23
+
24
+ /** A usable CSS paint value — a non-blank string. */
25
+ function isPaint(value: unknown): value is string {
26
+ // A whitespace-only string is an invalid paint (it renders as the initial
27
+ // color, not the intended fallback), so treat it as "unset" like an empty one.
28
+ return typeof value === 'string' && value.trim().length > 0;
29
+ }
30
+
31
+ /** The palette color assigned by the chart root, or the last-resort token. */
32
+ function fallbackFill(def: SeriesDef): string {
33
+ return isPaint(def._resolvedColor)
34
+ ? def._resolvedColor
35
+ : DEFAULT_SERIES_COLOR;
36
+ }
37
+
38
+ /** The single resolved color for a series (line/area/band and similar). */
39
+ export function seriesFill(
40
+ def: SeriesDef,
41
+ color: ColorAccessor | undefined,
42
+ ): string {
43
+ if (isPaint(color)) {
44
+ return color;
45
+ }
46
+ return fallbackFill(def);
47
+ }
48
+
49
+ /** Per-point fill: an accessor wins, then an explicit string, then the palette. */
50
+ export function pointFill(
51
+ def: SeriesDef,
52
+ color: ColorAccessor | undefined,
53
+ datum: Record<string, unknown>,
54
+ index: number,
55
+ ): string {
56
+ if (typeof color === 'function') {
57
+ try {
58
+ const resolved = color(datum, index);
59
+ if (isPaint(resolved)) {
60
+ return resolved;
61
+ }
62
+ } catch {
63
+ // A consumer accessor can throw on unexpected data; fall through to the
64
+ // palette/token fallback rather than crashing the whole chart render.
65
+ }
66
+ } else if (isPaint(color)) {
67
+ return color;
68
+ }
69
+ return fallbackFill(def);
70
+ }
@@ -0,0 +1,142 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file marks/area.tsx
5
+ * @output Area series — fill under line with stacking + gradient support
6
+ *
7
+ * Missing / non-finite values (including gaps in a stacked baseline) become
8
+ * holes via d3's `.defined()` rather than collapsing to zero. The gradient id is
9
+ * salted with the series' collision-free `_uid` so overlapping areas over the
10
+ * same dataKey never share (and steal) each other's fill.
11
+ */
12
+
13
+ import {
14
+ area as d3Area,
15
+ curveLinear,
16
+ curveMonotoneX,
17
+ curveNatural,
18
+ curveStep,
19
+ } from 'd3-shape';
20
+ import {line as d3Line} from 'd3-shape';
21
+ import type {SeriesDef, ResolvedPoint} from '../types';
22
+ import {seriesFill} from '../markColor';
23
+ import {xPixel} from '../utils';
24
+
25
+ const CURVES = {
26
+ linear: curveLinear,
27
+ monotone: curveMonotoneX,
28
+ natural: curveNatural,
29
+ step: curveStep,
30
+ } as const;
31
+
32
+ export interface AreaOptions {
33
+ color?: string;
34
+ opacity?: number;
35
+ curve?: keyof typeof CURVES;
36
+ stack?: string;
37
+ gradient?: boolean;
38
+ stroke?: boolean;
39
+ label?: string;
40
+ }
41
+
42
+ /** Both edges of an area point must be finite for the span to be drawable. */
43
+ function isFinitePoint(p: ResolvedPoint): boolean {
44
+ return (
45
+ Number.isFinite(p.px) && Number.isFinite(p.py) && Number.isFinite(p.py0)
46
+ );
47
+ }
48
+
49
+ export function area(dataKey: string, options: AreaOptions = {}): SeriesDef {
50
+ const color = options.color;
51
+ const opacity =
52
+ typeof options.opacity === 'number' && Number.isFinite(options.opacity)
53
+ ? Math.min(1, Math.max(0, options.opacity))
54
+ : 0.3;
55
+ const curve = options.curve ?? 'monotone';
56
+ const gradient = options.gradient ?? false;
57
+ const stroke = options.stroke ?? true;
58
+
59
+ const seriesDef: SeriesDef = {
60
+ type: 'area',
61
+ key: dataKey,
62
+ dataKeys: [dataKey],
63
+ color,
64
+ label: options.label ?? dataKey,
65
+ layout: {stack: options.stack, includeZero: true},
66
+
67
+ resolve(ctx, stackOffsets) {
68
+ const {data, xKey, xScale, yScale} = ctx;
69
+ const points: ResolvedPoint[] = [];
70
+ for (let i = 0; i < data.length; i++) {
71
+ const d = data[i];
72
+ let py: number, py0: number;
73
+ if (stackOffsets) {
74
+ const offset = stackOffsets[i];
75
+ py = offset ? yScale(offset.y1) : NaN;
76
+ py0 = offset ? yScale(offset.y0) : NaN;
77
+ } else {
78
+ const raw = d[dataKey];
79
+ const v = typeof raw === 'number' && Number.isFinite(raw) ? raw : NaN;
80
+ py = yScale(v);
81
+ py0 = yScale(0);
82
+ }
83
+ points.push({px: xPixel(d, xKey, xScale), py, py0, dataIndex: i});
84
+ }
85
+ return points;
86
+ },
87
+
88
+ render(resolved) {
89
+ if (resolved.length === 0) {
90
+ return null;
91
+ }
92
+ const curveFactory = CURVES[curve] ?? curveMonotoneX;
93
+
94
+ const areaGen = d3Area<ResolvedPoint>()
95
+ .defined(isFinitePoint)
96
+ .x(d => d.px)
97
+ .y0(d => d.py0)
98
+ .y1(d => d.py)
99
+ .curve(curveFactory);
100
+ const pathD = areaGen(resolved) ?? '';
101
+
102
+ const lineGen = d3Line<ResolvedPoint>()
103
+ .defined(isFinitePoint)
104
+ .x(d => d.px)
105
+ .y(d => d.py)
106
+ .curve(curveFactory);
107
+ const strokeD = lineGen(resolved) ?? '';
108
+
109
+ // Unique per series instance so two areas over the same dataKey don't
110
+ // share a gradient id (which would make one steal the other's fill).
111
+ const gradientId = `area-grad-${(seriesDef._uid ?? dataKey).replace(
112
+ /[^a-zA-Z0-9_-]/g,
113
+ '-',
114
+ )}`;
115
+ const fillColor = seriesFill(seriesDef, color);
116
+
117
+ return (
118
+ <g>
119
+ {gradient && (
120
+ <defs>
121
+ <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
122
+ <stop offset="0%" stopColor={fillColor} stopOpacity={opacity} />
123
+ <stop offset="100%" stopColor={fillColor} stopOpacity={0} />
124
+ </linearGradient>
125
+ </defs>
126
+ )}
127
+ <path
128
+ d={pathD}
129
+ fill={gradient ? `url(#${gradientId})` : fillColor}
130
+ fillOpacity={gradient ? 1 : opacity}
131
+ stroke="none"
132
+ />
133
+ {stroke && (
134
+ <path d={strokeD} fill="none" stroke={fillColor} strokeWidth={2} />
135
+ )}
136
+ </g>
137
+ );
138
+ },
139
+ };
140
+
141
+ return seriesDef;
142
+ }