@opendata-ai/openchart-engine 6.28.6 → 7.0.2

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 (46) hide show
  1. package/README.md +1 -0
  2. package/dist/index.d.ts +8 -11
  3. package/dist/index.js +12307 -11338
  4. package/dist/index.js.map +1 -1
  5. package/package.json +2 -2
  6. package/src/__test-fixtures__/specs.ts +3 -0
  7. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +452 -377
  8. package/src/__tests__/axes.test.ts +75 -0
  9. package/src/__tests__/compile-chart.test.ts +304 -0
  10. package/src/__tests__/dimensions.test.ts +224 -0
  11. package/src/__tests__/legend.test.ts +44 -3
  12. package/src/annotations/__tests__/compute.test.ts +111 -0
  13. package/src/annotations/__tests__/resolve-text.test.ts +288 -0
  14. package/src/annotations/constants.ts +20 -0
  15. package/src/annotations/resolve-text.ts +161 -7
  16. package/src/charts/bar/compute.ts +24 -0
  17. package/src/charts/bar/labels.ts +1 -0
  18. package/src/charts/column/compute.ts +33 -1
  19. package/src/charts/column/labels.ts +1 -0
  20. package/src/charts/dot/labels.ts +1 -0
  21. package/src/charts/line/__tests__/compute.test.ts +153 -3
  22. package/src/charts/line/area.ts +111 -23
  23. package/src/charts/line/compute.ts +40 -10
  24. package/src/charts/line/index.ts +34 -7
  25. package/src/charts/line/labels.ts +29 -0
  26. package/src/charts/pie/labels.ts +1 -0
  27. package/src/compile/layer.ts +498 -0
  28. package/src/compile.ts +221 -586
  29. package/src/compiler/__tests__/sparkline-defaults.test.ts +153 -0
  30. package/src/compiler/normalize.ts +12 -1
  31. package/src/compiler/sparkline-defaults.ts +138 -0
  32. package/src/compiler/types.ts +8 -0
  33. package/src/endpoint-labels/__tests__/compute.test.ts +438 -0
  34. package/src/endpoint-labels/compute.ts +417 -0
  35. package/src/endpoint-labels/constants.ts +54 -0
  36. package/src/endpoint-labels/format.ts +30 -0
  37. package/src/endpoint-labels/predict.ts +108 -0
  38. package/src/graphs/compile-graph.ts +1 -0
  39. package/src/layout/axes.ts +27 -2
  40. package/src/layout/dimensions.ts +282 -34
  41. package/src/layout/metrics.ts +118 -0
  42. package/src/layout/scales.ts +41 -4
  43. package/src/legend/__tests__/suppression.test.ts +294 -0
  44. package/src/legend/compute.ts +50 -40
  45. package/src/legend/suppression.ts +204 -0
  46. package/src/sankey/compile-sankey.ts +2 -0
@@ -2,8 +2,13 @@
2
2
  * Area chart mark computation.
3
3
  *
4
4
  * Uses D3 area() generator to produce AreaMark[] with top/bottom
5
- * boundary points and SVG path strings. Supports single areas and
6
- * stacked areas via d3-shape stack layout.
5
+ * boundary points and SVG path strings.
6
+ *
7
+ * Multi-series behavior (v6 redesign):
8
+ * - Default for multi-series with `color` is **overlap** (one translucent
9
+ * gradient band per series, layered with low opacity).
10
+ * - Stacked rendering is opt-in via `encoding.y.stack: 'zero' | true | 'normalize' | 'center'`.
11
+ * - Single series always renders one gradient-filled area.
7
12
  */
8
13
 
9
14
  import type { AreaMark, DataRow, Encoding, MarkAria, Rect } from '@opendata-ai/openchart-core';
@@ -30,10 +35,76 @@ import { resolveCurve } from './curves';
30
35
 
31
36
  const DEFAULT_FILL_OPACITY = 0.15;
32
37
 
38
+ /**
39
+ * Resolve `encoding.y.stack` to a boolean: should we stack this area chart?
40
+ *
41
+ * Vega-Lite-aligned semantics:
42
+ * - undefined | null | false -> overlap (NEW DEFAULT for area)
43
+ * - true | 'zero' | 'normalize' | 'center' -> stacked
44
+ *
45
+ * This is a v6 breaking change. Previously, multi-series with `color`
46
+ * implicitly stacked. Now overlap is the default; stacking is opt-in.
47
+ */
48
+ function isStacked(stackProp: unknown): boolean {
49
+ if (stackProp === undefined || stackProp === null || stackProp === false) {
50
+ return false;
51
+ }
52
+ return (
53
+ stackProp === true ||
54
+ stackProp === 'zero' ||
55
+ stackProp === 'normalize' ||
56
+ stackProp === 'center'
57
+ );
58
+ }
59
+
60
+ // Gradient stops calibrated by series count. Solo areas can carry richer fills
61
+ // because there's no overlap to manage; multi-series overlap needs lighter
62
+ // stops so layered bands stay legible.
63
+ const SOLO_GRADIENT_STOPS = [
64
+ { offset: 0, opacity: 0.42 },
65
+ { offset: 0.6, opacity: 0.1 },
66
+ { offset: 1, opacity: 0 },
67
+ ];
68
+
69
+ const OVERLAP_GRADIENT_STOPS = [
70
+ { offset: 0, opacity: 0.22 },
71
+ { offset: 0.7, opacity: 0.04 },
72
+ { offset: 1, opacity: 0 },
73
+ ];
74
+
75
+ // Stacked layers sit on top of each other and need higher opacity so each
76
+ // band reads as a distinct quantity. A gentle top-to-bottom gradient adds
77
+ // depth without losing the categorical color identity.
78
+ const STACKED_GRADIENT_STOPS = [
79
+ { offset: 0, opacity: 0.65 },
80
+ { offset: 1, opacity: 0.35 },
81
+ ];
82
+
83
+ function buildGradientFill(
84
+ colorStr: string,
85
+ stops: ReadonlyArray<{ offset: number; opacity: number }>,
86
+ ): import('@opendata-ai/openchart-core').GradientDef {
87
+ return {
88
+ gradient: 'linear',
89
+ x1: 0,
90
+ y1: 0,
91
+ x2: 0,
92
+ y2: 1,
93
+ stops: stops.map((s) => ({ offset: s.offset, color: colorStr, opacity: s.opacity })),
94
+ };
95
+ }
96
+
33
97
  // ---------------------------------------------------------------------------
34
- // Single area (non-stacked)
98
+ // Single / overlap area (non-stacked)
35
99
  // ---------------------------------------------------------------------------
36
100
 
101
+ /**
102
+ * Compute area marks for non-stacked rendering.
103
+ *
104
+ * - With no `color` field: emits a single area with the solo gradient.
105
+ * - With a `color` field (overlap mode): emits one area per series, each
106
+ * anchored at the y-domain baseline and using the lighter overlap gradient.
107
+ */
37
108
  function computeSingleArea(
38
109
  spec: NormalizedChartSpec,
39
110
  scales: ResolvedScales,
@@ -69,6 +140,9 @@ function computeSingleArea(
69
140
  }
70
141
  }
71
142
 
143
+ const isMultiSeries = colorField !== undefined && groups.size > 1;
144
+ const defaultGradientStops = isMultiSeries ? OVERLAP_GRADIENT_STOPS : SOLO_GRADIENT_STOPS;
145
+
72
146
  const marks: AreaMark[] = [];
73
147
 
74
148
  for (const [seriesKey, rows] of groups) {
@@ -137,7 +211,8 @@ function computeSingleArea(
137
211
 
138
212
  // Allow markDef.fill to override color with a gradient.
139
213
  // When a gradient is provided, set fillOpacity=1 so gradient stop-opacity controls the fade.
140
- // When no fill is provided, auto-generate a top-to-bottom fade gradient.
214
+ // When no fill is provided, auto-generate a top-to-bottom fade gradient
215
+ // tuned to series count (lighter for overlap).
141
216
  const markFill = spec.markDef.fill;
142
217
  let fillValue: string | import('@opendata-ai/openchart-core').GradientDef;
143
218
  let fillOpacity: number;
@@ -149,17 +224,7 @@ function computeSingleArea(
149
224
  : (spec.markDef.opacity ?? (y2Channel ? 0.25 : DEFAULT_FILL_OPACITY));
150
225
  } else {
151
226
  const colorStr = getRepresentativeColor(color);
152
- fillValue = {
153
- gradient: 'linear',
154
- x1: 0,
155
- y1: 0,
156
- x2: 0,
157
- y2: 1,
158
- stops: [
159
- { offset: 0, color: colorStr, opacity: 0.12 },
160
- { offset: 1, color: colorStr, opacity: 0 },
161
- ],
162
- };
227
+ fillValue = buildGradientFill(colorStr, defaultGradientStops);
163
228
  fillOpacity = 1;
164
229
  }
165
230
 
@@ -171,8 +236,9 @@ function computeSingleArea(
171
236
  topPath: topPathStr,
172
237
  fill: fillValue,
173
238
  fillOpacity: fillOpacity,
174
- stroke: getRepresentativeColor(isGradientDef(fillValue) ? color : fillValue),
175
- strokeWidth: spec.markDef.strokeWidth ?? (spec.display === 'sparkline' ? 1.25 : 1.5),
239
+ stroke:
240
+ spec.markDef.stroke ?? getRepresentativeColor(isGradientDef(fillValue) ? color : fillValue),
241
+ strokeWidth: spec.markDef.strokeWidth ?? 1.5,
176
242
  seriesKey: seriesKey === '__default__' ? undefined : seriesKey,
177
243
  data: validPoints.map((p) => p.row),
178
244
  dataPoints: validPoints.map((p) => ({ x: p.x, y: p.yTop, datum: p.row })),
@@ -312,14 +378,29 @@ function computeStackedArea(
312
378
  label: `${seriesKey}: stacked area with ${validPoints.length} data points`,
313
379
  };
314
380
 
381
+ // Per-layer top-to-bottom gradient. User-supplied markDef.fill (string or
382
+ // gradient) overrides the auto gradient just like in single-area mode.
383
+ const markFill = spec.markDef.fill;
384
+ let fillValue: string | import('@opendata-ai/openchart-core').GradientDef;
385
+ let fillOpacity: number;
386
+
387
+ if (markFill != null) {
388
+ fillValue = markFill;
389
+ fillOpacity = isGradientDef(markFill) ? 1 : (spec.markDef.opacity ?? 0.7);
390
+ } else {
391
+ const colorStr = getRepresentativeColor(color);
392
+ fillValue = buildGradientFill(colorStr, STACKED_GRADIENT_STOPS);
393
+ fillOpacity = 1;
394
+ }
395
+
315
396
  marks.push({
316
397
  type: 'area',
317
398
  topPoints,
318
399
  bottomPoints,
319
400
  path: pathStr,
320
401
  topPath: topPathStr,
321
- fill: color,
322
- fillOpacity: 0.7, // Higher opacity for stacked so layers are visible
402
+ fill: fillValue,
403
+ fillOpacity,
323
404
  stroke: getRepresentativeColor(color),
324
405
  strokeWidth: 1,
325
406
  seriesKey,
@@ -348,8 +429,15 @@ function computeStackedArea(
348
429
  /**
349
430
  * Compute area marks from a normalized chart spec.
350
431
  *
351
- * For multi-series with color encoding, produces stacked areas.
352
- * For single series, produces a simple area fill from the line to baseline (y=0).
432
+ * Behavior depends on `encoding.y.stack` (Vega-Lite aligned):
433
+ * - `undefined | null | false` -> overlap (default for multi-series)
434
+ * - `true | 'zero' | 'normalize' | 'center'` -> stacked
435
+ *
436
+ * Single-series specs always render one gradient-filled area; the `stack`
437
+ * branch only matters when a `color` encoding is present.
438
+ *
439
+ * BREAKING CHANGE (v6): multi-series no longer auto-stacks. Pass
440
+ * `encoding.y.stack: 'zero'` (or `true`) to opt back into the old behavior.
353
441
  */
354
442
  export function computeAreaMarks(
355
443
  spec: NormalizedChartSpec,
@@ -357,9 +445,9 @@ export function computeAreaMarks(
357
445
  chartArea: Rect,
358
446
  ): AreaMark[] {
359
447
  const encoding = spec.encoding as Encoding;
360
- const hasColor = !!encoding.color;
448
+ const yChannel = encoding.y;
361
449
 
362
- if (hasColor) {
450
+ if (yChannel && isStacked(yChannel.stack)) {
363
451
  return computeStackedArea(spec, scales, chartArea);
364
452
  }
365
453
 
@@ -29,13 +29,10 @@ import { resolveCurve } from './curves';
29
29
  // Constants
30
30
  // ---------------------------------------------------------------------------
31
31
 
32
- /** Default stroke width for line marks. */
32
+ /** Default stroke width for line marks. Sparklines use the same width so the
33
+ * visual weight matches the rest of the chart family. */
33
34
  const DEFAULT_STROKE_WIDTH = 1.5;
34
35
 
35
- /** Sparkline mode uses a thinner stroke since the chart area is tiny and a
36
- * 2.5px line reads as clunky. 1.25px keeps the trend legible without dominating. */
37
- const SPARKLINE_STROKE_WIDTH = 1.25;
38
-
39
36
  /** Default radius for point marks (hover targets). */
40
37
  const DEFAULT_POINT_RADIUS = 3;
41
38
 
@@ -77,7 +74,10 @@ export function computeLineMarks(
77
74
  const color: string | GradientDef = isSequentialColor
78
75
  ? getSequentialColor(scales, _getMidValue(rows, sequentialColorField!))
79
76
  : getColor(scales, seriesKey);
80
- const strokeColor = getRepresentativeColor(color);
77
+ // markDef.stroke wins over the scale-derived color when set explicitly.
78
+ // Sparkline mode injects this via applySparklineDefaults to carry the
79
+ // trend color; users can also set it directly to override the palette.
80
+ const strokeColor = spec.markDef.stroke ?? getRepresentativeColor(color);
81
81
 
82
82
  // Sort rows by x-axis field so lines draw left-to-right.
83
83
  // For nominal/ordinal axes, preserve data order since there's no
@@ -178,10 +178,7 @@ export function computeLineMarks(
178
178
  points: allPoints,
179
179
  path: combinedPath,
180
180
  stroke: strokeColor,
181
- strokeWidth:
182
- styleOverride?.strokeWidth ??
183
- spec.markDef.strokeWidth ??
184
- (spec.display === 'sparkline' ? SPARKLINE_STROKE_WIDTH : DEFAULT_STROKE_WIDTH),
181
+ strokeWidth: styleOverride?.strokeWidth ?? spec.markDef.strokeWidth ?? DEFAULT_STROKE_WIDTH,
185
182
  strokeDasharray,
186
183
  opacity: styleOverride?.opacity,
187
184
  seriesKey: seriesStyleKey,
@@ -195,10 +192,12 @@ export function computeLineMarks(
195
192
  // Emit PointMark objects when markDef.point is truthy, or when sequential
196
193
  // color is active (points carry the gradient since SVG paths are single-color).
197
194
  const markPoint = spec.markDef.point;
195
+ const isSingleEndpoint = markPoint === 'last' || markPoint === 'first';
198
196
  const showPoints =
199
197
  markPoint === true ||
200
198
  markPoint === 'transparent' ||
201
199
  markPoint === 'endpoints' ||
200
+ isSingleEndpoint ||
202
201
  isSequentialColor;
203
202
 
204
203
  if (showPoints) {
@@ -211,6 +210,14 @@ export function computeLineMarks(
211
210
  for (let i = 0; i < pointsWithData.length; i++) {
212
211
  const p = pointsWithData[i];
213
212
  const isEndpoint = i === 0 || i === lastIdx;
213
+ const isLast = i === lastIdx;
214
+ const isFirst = i === 0;
215
+ // Single-endpoint mode ('last' / 'first'): emit only the chosen point.
216
+ // Skip the others entirely instead of emitting invisible placeholders.
217
+ if (isSingleEndpoint) {
218
+ if (markPoint === 'last' && !isLast) continue;
219
+ if (markPoint === 'first' && !isFirst) continue;
220
+ }
214
221
  const visible = seriesShowPoints && !isTransparent && (!isEndpoints || isEndpoint);
215
222
  // Sequential color: each point gets colored by its data value
216
223
  let pointColor = color;
@@ -220,6 +227,29 @@ export function computeLineMarks(
220
227
  }
221
228
  const hollow = isEndpoints && visible;
222
229
  const pointColorStr = getRepresentativeColor(pointColor);
230
+ // 'last' / 'first' render as a tight filled terminator dot in the line
231
+ // color — no white halo, no hollow ring. Marked decorative because the
232
+ // data point already exists on the line and gets described by the a11y
233
+ // data table; the dot is purely visual.
234
+ if (isSingleEndpoint) {
235
+ marks.push({
236
+ type: 'point',
237
+ cx: p.x,
238
+ cy: p.y,
239
+ // 3.5 reads as a deliberate terminator against the 2px sparkline
240
+ // line — smaller dots blur into the stroke at typical card sizes.
241
+ r: 3.5,
242
+ fill: strokeColor,
243
+ stroke: 'transparent',
244
+ strokeWidth: 0,
245
+ fillOpacity: 1,
246
+ data: p.row,
247
+ // No label: decorative marks render with aria-hidden="true" and
248
+ // don't participate in the accessible data table.
249
+ aria: { decorative: true },
250
+ });
251
+ continue;
252
+ }
223
253
  const pointMark: PointMark = {
224
254
  type: 'point',
225
255
  cx: p.x,
@@ -2,6 +2,12 @@
2
2
  * Line & area chart module.
3
3
  *
4
4
  * Exports line and area chart renderers and computation functions.
5
+ *
6
+ * Area chart multi-series defaults (v6):
7
+ * - Default with `color` encoding is **overlap** -- one translucent gradient
8
+ * band per series, all anchored at the y-domain baseline.
9
+ * - Stacked rendering is opt-in: set `encoding.y.stack` to `true`, `'zero'`,
10
+ * `'normalize'`, or `'center'`.
5
11
  */
6
12
 
7
13
  import type { AreaMark, LineMark, Mark } from '@opendata-ai/openchart-core';
@@ -27,8 +33,16 @@ export const lineRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _
27
33
  // Extract just the line marks for label computation
28
34
  const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
29
35
 
30
- // Compute and attach labels to line marks by seriesKey lookup
31
- const labelMap = computeLineLabels(lineMarks, strategy, spec.labels.density, spec.labels.offsets);
36
+ // Compute and attach labels to line marks by seriesKey lookup. Passing the
37
+ // spec engages the shared suppression truth table so end-of-line labels
38
+ // hide when the legend or endpoint column is showing.
39
+ const labelMap = computeLineLabels(
40
+ lineMarks,
41
+ strategy,
42
+ spec.labels.density,
43
+ spec.labels.offsets,
44
+ spec,
45
+ );
32
46
  for (const mark of marks) {
33
47
  if (mark.type === 'line' && mark.seriesKey) {
34
48
  const label = labelMap.get(mark.seriesKey);
@@ -48,9 +62,14 @@ export const lineRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _
48
62
  /**
49
63
  * Area chart renderer.
50
64
  *
51
- * Computes area fill marks (stacked if multi-series).
65
+ * Computes area fill marks (stacked or overlapping per `encoding.y.stack`).
52
66
  * Also computes line marks for the top boundary and point marks
53
67
  * for hover targets, layered on top of the areas.
68
+ *
69
+ * Lines are derived from area top boundaries whenever there's a color
70
+ * encoding -- this keeps each line glued to its band's top edge regardless
71
+ * of whether the layout is stacked (cumulative tops) or overlap (per-series
72
+ * raw values).
54
73
  */
55
74
  export const areaRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _theme) => {
56
75
  const areas = computeAreaMarks(spec, scales, chartArea);
@@ -58,8 +77,9 @@ export const areaRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _
58
77
  const encoding = spec.encoding;
59
78
  const hasColor = !!(encoding.color && 'field' in encoding.color);
60
79
 
61
- // For stacked areas, derive line marks from the area top paths so lines
62
- // align with stacked positions. For non-stacked, compute lines normally.
80
+ // With a color encoding (stacked or overlap), derive line marks from the
81
+ // area tops so each line traces the upper edge of its band. For single
82
+ // series, compute lines normally so we get the regular line + point marks.
63
83
  const lines = hasColor
64
84
  ? linesFromAreas(areas)
65
85
  : computeLineMarks(spec, scales, chartArea, strategy);
@@ -73,8 +93,15 @@ export const areaRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _
73
93
  // ---------------------------------------------------------------------------
74
94
 
75
95
  /**
76
- * Derive LineMark[] from stacked AreaMark[] using each area's top boundary.
77
- * This ensures lines sit on top of their corresponding stacked area bands.
96
+ * Derive LineMark[] from AreaMark[] using each area's top boundary.
97
+ *
98
+ * Works for both stacked and overlap layouts:
99
+ * - Stacked: `topPoints` is the cumulative top edge of the layer, so the line
100
+ * sits on top of its band rather than the raw series value.
101
+ * - Overlap: `topPoints` is the series' own y value (areas all share the same
102
+ * baseline), so the line traces the actual data.
103
+ *
104
+ * No z-order assumption -- each area independently provides its own top.
78
105
  */
79
106
  function linesFromAreas(areas: AreaMark[]): LineMark[] {
80
107
  return areas.map((a) => ({
@@ -25,6 +25,9 @@ import {
25
25
  resolveCollisions,
26
26
  } from '@opendata-ai/openchart-core';
27
27
 
28
+ import type { NormalizedChartSpec } from '../../compiler/types';
29
+ import { countColorSeries, resolveSuppression } from '../../legend/suppression';
30
+
28
31
  // ---------------------------------------------------------------------------
29
32
  // Constants
30
33
  // ---------------------------------------------------------------------------
@@ -56,6 +59,11 @@ const LABEL_OFFSET_X = 6;
56
59
  * @param marks - Line marks (only processes marks with type === 'line').
57
60
  * @param strategy - Layout strategy from the responsive breakpoint.
58
61
  * @param density - Label density mode from spec.labels.density.
62
+ * @param labelOffsets - Per-series user offsets applied after collision resolution.
63
+ * @param spec - Optional normalized spec. When provided, the shared suppression
64
+ * truth table gates rendering: end-of-line labels are the last-resort series
65
+ * identifier and only render when both the traditional legend and endpoint
66
+ * labels column are off.
59
67
  * @returns Map of seriesKey -> ResolvedLabel after collision detection.
60
68
  */
61
69
  export function computeLineLabels(
@@ -63,6 +71,7 @@ export function computeLineLabels(
63
71
  strategy: LayoutStrategy,
64
72
  density: LabelDensity = 'auto',
65
73
  labelOffsets?: Record<string, { dx?: number; dy?: number }>,
74
+ spec?: NormalizedChartSpec,
66
75
  ): Map<string, ResolvedLabel> {
67
76
  const result = new Map<string, ResolvedLabel>();
68
77
 
@@ -74,6 +83,26 @@ export function computeLineLabels(
74
83
  return result;
75
84
  }
76
85
 
86
+ // Suppression truth table: when the spec is available, defer to the shared
87
+ // helper. End-of-line labels render only when both the legend and endpoint
88
+ // column are off — they're the last-resort series identifier.
89
+ if (spec) {
90
+ // The 'none' branches above already returned, so by here labelMode and
91
+ // density are not 'none'. Pass false explicitly to keep the helper pure.
92
+ const suppression = resolveSuppression(spec, {
93
+ seriesCount: countColorSeries(spec),
94
+ labelsHiddenByStrategy: false,
95
+ labelsDensityNone: false,
96
+ });
97
+ if (!suppression.showEndOfLineLabels) {
98
+ // Single-series charts have no series-name end-of-line label to hide
99
+ // (computeLineLabels returns nothing when seriesKey is empty). For
100
+ // multi-series, the suppression result tells us to skip explicitly.
101
+ const isMultiSeries = !!spec.encoding.color && countColorSeries(spec) >= 2;
102
+ if (isMultiSeries) return result;
103
+ }
104
+ }
105
+
77
106
  const candidates: LabelCandidate[] = [];
78
107
  const seriesOrder: string[] = [];
79
108
 
@@ -66,6 +66,7 @@ export function computePieLabels(
66
66
  // Format is "Category: value (percent%)". Split on the first colon
67
67
  // to handle category names that might contain colons.
68
68
  const ariaLabel = mark.aria.label;
69
+ if (!ariaLabel) continue;
69
70
  const firstColon = ariaLabel.indexOf(':');
70
71
  const labelText = firstColon >= 0 ? ariaLabel.slice(0, firstColon).trim() : '';
71
72
  if (!labelText) continue;