@cfasim-ui/docs 0.4.10 → 0.4.12

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.
@@ -13,10 +13,28 @@ import {
13
13
  useChartFoundation,
14
14
  makeTooltipValueFormatter,
15
15
  ChartAnnotations,
16
+ INLINE_LEGEND_ROW_HEIGHT,
17
+ TITLE_LINE_HEIGHT,
18
+ TITLE_FONT_SIZE,
19
+ TITLE_FONT_WEIGHT,
20
+ AXIS_LABEL_FONT_SIZE,
21
+ TICK_LABEL_FONT_SIZE,
22
+ TICK_LABEL_OPACITY,
23
+ LEGEND_FONT_SIZE,
24
+ resolveLabelStyle,
25
+ parseDate,
26
+ isAllDates,
27
+ pickDateTicks,
28
+ formatDate,
16
29
  type ChartData,
30
+ type LabelStyle,
31
+ type DateFormat,
32
+ type DateTickUnit,
33
+ type DateTimezone,
17
34
  type ChartCommonProps,
18
35
  type ChartHoverPayload,
19
36
  type ChartTooltipBaseProps,
37
+ type ChartTooltipValue,
20
38
  } from "../_shared/index.js";
21
39
 
22
40
  /**
@@ -27,6 +45,13 @@ import {
27
45
  */
28
46
  export type LineChartData = ChartData;
29
47
 
48
+ /**
49
+ * Accepted shapes for an `x` array. Numeric arrays (typed or plain) plot
50
+ * as numbers; string-or-`Date` arrays trigger date-axis mode when every
51
+ * element parses. See the `timezone` prop and `dateAxis` module.
52
+ */
53
+ export type LineChartXInput = LineChartData | readonly (string | Date)[];
54
+
30
55
  export interface Series {
31
56
  /**
32
57
  * Y-values. One of `y` or `data` must be supplied; `y` wins if both
@@ -38,9 +63,10 @@ export interface Series {
38
63
  /**
39
64
  * Optional x-values, parallel to `y`/`data`. When set, the chart
40
65
  * plots points at the given x positions (irregular spacing supported).
41
- * When omitted, points are plotted at indices 0, 1, 2, ...
66
+ * When omitted, points are plotted at indices 0, 1, 2, ... Accepts
67
+ * date strings or `Date` objects to enable date-axis mode.
42
68
  */
43
- x?: LineChartData;
69
+ x?: LineChartXInput;
44
70
  color?: string;
45
71
  dashed?: boolean;
46
72
  strokeWidth?: number;
@@ -48,19 +74,35 @@ export interface Series {
48
74
  lineOpacity?: number;
49
75
  dotOpacity?: number;
50
76
  line?: boolean;
77
+ /**
78
+ * Draw a page-colored stroke behind the line so it stays visually
79
+ * separated from overlapping series and busy backgrounds. Has no
80
+ * effect when `line` is `false`.
81
+ */
82
+ outline?: boolean;
51
83
  dots?: boolean;
52
84
  dotRadius?: number;
53
85
  dotFill?: string;
54
86
  dotStroke?: string;
55
87
  /** Label shown in the inline legend */
56
88
  legend?: string;
89
+ /**
90
+ * Whether this series appears in the inline legend. Defaults to true.
91
+ * Has no effect when `legend` is unset (no legend entry to begin with).
92
+ */
93
+ showInLegend?: boolean;
94
+ /**
95
+ * Whether this series contributes a value to the tooltip and shows a
96
+ * hover dot. Defaults to true. The series line/dots are still drawn.
97
+ */
98
+ showInTooltip?: boolean;
57
99
  }
58
100
 
59
101
  export interface Area {
60
102
  upper: LineChartData;
61
103
  lower: LineChartData;
62
104
  /** Optional x-values parallel to `upper`/`lower`. See `Series.x`. */
63
- x?: LineChartData;
105
+ x?: LineChartXInput;
64
106
  color?: string;
65
107
  opacity?: number;
66
108
  }
@@ -86,6 +128,17 @@ export interface AreaSection {
86
128
  dashed?: boolean;
87
129
  /** Label placement: "below" (default) renders below chart, "inline" renders in legend row, false hides label */
88
130
  legend?: "inline" | "below" | false;
131
+ /**
132
+ * Style for the area section's primary label text. Defaults: font-size
133
+ * 11, font-weight 600, color taken from the section's `color`.
134
+ */
135
+ inlineLabelStyle?: LabelStyle;
136
+ /**
137
+ * Style for the area section's secondary description text. Defaults:
138
+ * font-size 11, currentColor at 0.6 opacity. Providing `color` drops
139
+ * the default opacity.
140
+ */
141
+ inlineDescriptionStyle?: LabelStyle;
89
142
  }
90
143
 
91
144
  interface LineChartProps extends ChartCommonProps {
@@ -97,8 +150,9 @@ interface LineChartProps extends ChartCommonProps {
97
150
  * Optional x-values paired with `y`/`data`. When provided, points
98
151
  * are plotted at the given x positions instead of at their indices.
99
152
  * Ignored when `series` is used — set `x` on each `Series` instead.
153
+ * Accepts date strings or `Date` objects to enable date-axis mode.
100
154
  */
101
- x?: LineChartData;
155
+ x?: LineChartXInput;
102
156
  series?: Series[];
103
157
  areas?: Area[];
104
158
  areaSections?: AreaSection[];
@@ -131,12 +185,24 @@ interface LineChartProps extends ChartCommonProps {
131
185
  */
132
186
  yTicks?: number | number[];
133
187
  /**
134
- * Formatter for x-axis tick labels. Accepts a preset name, a printf-style
135
- * format string, or a function. The two-arg function form `(value, index)`
136
- * is also supported for index-based labels. See `formatNumber` in
137
- * `@cfasim-ui/shared`.
188
+ * Formatter for x-axis tick labels. On a numeric axis, accepts a
189
+ * preset name, a printf-style format string, or a function (the
190
+ * two-arg `(value, index)` form is also supported for index-based
191
+ * labels — see `formatNumber` in `@cfasim-ui/shared`). On a date
192
+ * axis (auto-detected when every x value parses as a date), accepts
193
+ * a `DateFormat` instead — see the `dateAxis` module.
194
+ */
195
+ xTickFormat?:
196
+ | NumberFormat
197
+ | ((value: number, index: number) => string)
198
+ | DateFormat;
199
+ /**
200
+ * Timezone used when parsing offset-less ISO strings and rendering
201
+ * date-axis tick labels. `"utc"` (default) keeps visuals consistent
202
+ * across viewers; `"local"` uses the browser timezone. Ignored on a
203
+ * numeric axis.
138
204
  */
139
- xTickFormat?: NumberFormat | ((value: number, index: number) => string);
205
+ timezone?: DateTimezone;
140
206
  /**
141
207
  * Formatter for y-axis tick labels. Accepts a preset name, a printf-style
142
208
  * format string, or a function. See `formatNumber` in `@cfasim-ui/shared`.
@@ -167,25 +233,50 @@ defineSlots<{
167
233
  tooltip?(props: ChartTooltipBaseProps & { xLabel?: string }): unknown;
168
234
  }>();
169
235
 
170
- const hasInlineLegend = computed(
171
- () =>
172
- allSeries.value.some((s) => s.legend) ||
173
- props.areaSections?.some(
174
- (s) => s.legend === "inline" && (s.label || s.description),
175
- ) ||
176
- false,
177
- );
178
-
179
236
  /**
180
- * Internal series shape where `data` (y-values) is always resolved.
181
- * `Series.y` takes precedence over `Series.data` when both are set.
237
+ * Internal series shape where `data` (y-values) is always resolved and
238
+ * `x` is narrowed to numeric (date inputs have already been parsed to
239
+ * epoch-ms). `Series.y` takes precedence over `Series.data`.
182
240
  */
183
- type ResolvedSeries = Omit<Series, "data" | "y"> & { data: LineChartData };
241
+ type ResolvedSeries = Omit<Series, "data" | "y" | "x"> & {
242
+ data: LineChartData;
243
+ x?: LineChartData;
244
+ };
245
+
246
+ /** Same shape as `Area` but with `x` narrowed to numeric post-parse. */
247
+ type ResolvedArea = Omit<Area, "x"> & { x?: LineChartData };
184
248
 
185
249
  const EMPTY_DATA: readonly number[] = [];
186
250
 
187
- function resolveSeries(s: Series): ResolvedSeries {
188
- return { ...s, data: s.y ?? s.data ?? EMPTY_DATA };
251
+ /**
252
+ * Yields every user-supplied `x` array on the component (top-level,
253
+ * per-series, per-area). Used both for date auto-detection and for the
254
+ * single-pass resolution that converts strings/Dates to numeric ms.
255
+ */
256
+ function* xInputs(p: LineChartProps): Iterable<LineChartXInput> {
257
+ if (p.x && p.x.length > 0) yield p.x;
258
+ if (p.series) {
259
+ for (const s of p.series) if (s.x && s.x.length > 0) yield s.x;
260
+ }
261
+ if (p.areas) {
262
+ for (const a of p.areas) if (a.x && a.x.length > 0) yield a.x;
263
+ }
264
+ }
265
+
266
+ /** Convert an x input to numeric. Pre-parsed when `isDate` is true. */
267
+ function toNumericX(
268
+ x: LineChartXInput | undefined,
269
+ isDate: boolean,
270
+ tz: DateTimezone,
271
+ ): LineChartData | undefined {
272
+ if (!x) return undefined;
273
+ if (!isDate) return x as LineChartData;
274
+ const out = new Float64Array(x.length);
275
+ for (let i = 0; i < x.length; i++) {
276
+ const v = parseDate(x[i], tz);
277
+ out[i] = v ?? NaN;
278
+ }
279
+ return out;
189
280
  }
190
281
 
191
282
  const formatTooltipValue = makeTooltipValueFormatter(
@@ -193,15 +284,58 @@ const formatTooltipValue = makeTooltipValueFormatter(
193
284
  () => props.yTickFormat,
194
285
  );
195
286
 
196
- const allSeries = computed<ResolvedSeries[]>(() => {
197
- if (props.series && props.series.length > 0)
198
- return props.series.map(resolveSeries);
199
- const topY = props.y ?? props.data;
200
- if (topY) return [{ data: topY, x: props.x }];
201
- return [];
287
+ const tz = computed<DateTimezone>(() => props.timezone ?? "utc");
288
+
289
+ /**
290
+ * Single-pass resolution of x-axis mode + resolved series and areas.
291
+ * Date auto-detection (`isAllDates` per input array) and date→ms
292
+ * conversion (`toNumericX`) both call `parseDate`; this computed
293
+ * makes sure each user-supplied x value is parsed exactly once per
294
+ * render even though we have multiple consumers.
295
+ */
296
+ const resolvedXAxis = computed<{
297
+ isDate: boolean;
298
+ series: ResolvedSeries[];
299
+ areas: ResolvedArea[];
300
+ }>(() => {
301
+ const z = tz.value;
302
+ let isDate = false;
303
+ let any = false;
304
+ for (const x of xInputs(props)) {
305
+ any = true;
306
+ if (!isAllDates(x, z)) {
307
+ isDate = false;
308
+ break;
309
+ }
310
+ isDate = true;
311
+ }
312
+ if (!any) isDate = false;
313
+
314
+ const series: ResolvedSeries[] =
315
+ props.series && props.series.length > 0
316
+ ? props.series.map((s) => ({
317
+ ...s,
318
+ data: s.y ?? s.data ?? EMPTY_DATA,
319
+ x: toNumericX(s.x, isDate, z),
320
+ }))
321
+ : (() => {
322
+ const topY = props.y ?? props.data;
323
+ return topY
324
+ ? [{ data: topY, x: toNumericX(props.x, isDate, z) }]
325
+ : [];
326
+ })();
327
+
328
+ const areas: ResolvedArea[] = (props.areas ?? []).map((a) => ({
329
+ ...a,
330
+ x: toNumericX(a.x, isDate, z),
331
+ }));
332
+
333
+ return { isDate, series, areas };
202
334
  });
203
335
 
204
- const allAreas = computed<Area[]>(() => props.areas ?? []);
336
+ const xIsDate = computed(() => resolvedXAxis.value.isDate);
337
+ const allSeries = computed<ResolvedSeries[]>(() => resolvedXAxis.value.series);
338
+ const allAreas = computed<ResolvedArea[]>(() => resolvedXAxis.value.areas);
205
339
 
206
340
  const maxLen = computed(() => {
207
341
  let m = 0;
@@ -228,7 +362,7 @@ function seriesXAt(s: { x?: LineChartData }, i: number): number {
228
362
  }
229
363
 
230
364
  /** Data-space x value for the i-th point of an area. */
231
- function areaXAt(a: Area, i: number): number {
365
+ function areaXAt(a: ResolvedArea, i: number): number {
232
366
  return a.x ? Number(a.x[i]) : i;
233
367
  }
234
368
 
@@ -337,7 +471,7 @@ function toPoints(s: ResolvedSeries): { x: number; y: number }[] {
337
471
  return pts;
338
472
  }
339
473
 
340
- function toAreaPath(a: Area): string {
474
+ function toAreaPath(a: ResolvedArea): string {
341
475
  const len = Math.min(a.upper.length, a.lower.length);
342
476
  if (len === 0) return "";
343
477
  // Collect contiguous segments where both upper/lower and x are finite
@@ -426,6 +560,8 @@ interface PositionedSectionLabel {
426
560
  row: number;
427
561
  color: string;
428
562
  fillOpacity: number;
563
+ labelStyle: ReturnType<typeof resolveLabelStyle>;
564
+ descStyle: ReturnType<typeof resolveLabelStyle>;
429
565
  }
430
566
 
431
567
  const sectionLabels = computed<{
@@ -458,6 +594,16 @@ const sectionLabels = computed<{
458
594
  (sec.seriesIndex != null
459
595
  ? (allSeries.value[sec.seriesIndex]?.color ?? "currentColor")
460
596
  : "#999");
597
+ // Section labels default to the section's data-driven color/weight;
598
+ // user-supplied styles override piece-by-piece.
599
+ const labelStyle = resolveLabelStyle(
600
+ { color, fontWeight: 600, ...sec.inlineLabelStyle },
601
+ { fontSize: 11 },
602
+ );
603
+ const descStyle = resolveLabelStyle(sec.inlineDescriptionStyle, {
604
+ fontSize: 11,
605
+ fillOpacity: 0.6,
606
+ });
461
607
  items.push({
462
608
  cx,
463
609
  labelText,
@@ -466,6 +612,8 @@ const sectionLabels = computed<{
466
612
  row: 0,
467
613
  color,
468
614
  fillOpacity: sec.opacity ?? 0.15,
615
+ labelStyle,
616
+ descStyle,
469
617
  });
470
618
  }
471
619
 
@@ -503,7 +651,7 @@ interface InlineLegendItem {
503
651
  const inlineLegendItems = computed<InlineLegendItem[]>(() => {
504
652
  const items: InlineLegendItem[] = [];
505
653
  for (const s of allSeries.value) {
506
- if (!s.legend) continue;
654
+ if (!s.legend || s.showInLegend === false) continue;
507
655
  items.push({
508
656
  label: s.legend,
509
657
  color: s.color ?? "currentColor",
@@ -533,6 +681,10 @@ const inlineLegendItems = computed<InlineLegendItem[]>(() => {
533
681
  return items;
534
682
  });
535
683
 
684
+ const inlineLegendLabels = computed(() =>
685
+ inlineLegendItems.value.map((item) => item.label),
686
+ );
687
+
536
688
  const totalHeight = computed(
537
689
  () => height.value + sectionLabels.value.extraHeight,
538
690
  );
@@ -568,43 +720,68 @@ const yTickItems = computed(() => {
568
720
  return values.map((v) => ({ value: fmt(v), y: snap(yPixel(v)) }));
569
721
  });
570
722
 
723
+ /**
724
+ * Format a single x-axis value into its display label. Used by both
725
+ * tick rendering and the tooltip x-label so the two stay in sync. On
726
+ * a date axis, `unit` (when supplied) drives the default formatter's
727
+ * preset choice (year-tick → "year", month-tick → "month-year", etc.);
728
+ * the tooltip path leaves it undefined and gets the ISO default.
729
+ */
730
+ function formatXValue(v: number, i: number, unit?: DateTickUnit): string {
731
+ const xf = props.xTickFormat;
732
+ if (xIsDate.value) {
733
+ if (typeof xf === "function") {
734
+ return (xf as (v: number, i: number) => string)(v, i);
735
+ }
736
+ return formatDate(v, xf as DateFormat | undefined, tz.value, unit);
737
+ }
738
+ const display = v + xDisplayOffset.value;
739
+ if (xf !== undefined) {
740
+ return typeof xf === "function"
741
+ ? (xf as (v: number, i: number) => string)(display, i)
742
+ : formatNumber(display, xf as NumberFormat);
743
+ }
744
+ if (
745
+ !hasExplicitX.value &&
746
+ props.xLabels &&
747
+ Number.isInteger(v) &&
748
+ v >= 0 &&
749
+ v < props.xLabels.length
750
+ ) {
751
+ return props.xLabels[v];
752
+ }
753
+ return formatTick(display);
754
+ }
755
+
571
756
  const xTickItems = computed(() => {
572
757
  const { min: xMin, max: xMax } = xExtent.value;
573
758
  if (xMin === xMax) return [];
574
- const offset = xDisplayOffset.value;
759
+ const isDate = xIsDate.value;
760
+ // `xMin` (display offset) is meaningless on a date axis — every x
761
+ // value already lives in absolute ms.
762
+ const offset = isDate ? 0 : xDisplayOffset.value;
575
763
  const len = maxLen.value;
576
-
577
- // Tick values are in data-space; display labels add `xDisplayOffset`.
578
- const fmt = (v: number, i: number) => {
579
- const display = v + offset;
580
- const xf = props.xTickFormat;
581
- if (xf !== undefined) {
582
- return typeof xf === "function"
583
- ? xf(display, i)
584
- : formatNumber(display, xf);
585
- }
586
- if (
587
- !hasExplicitX.value &&
588
- props.xLabels &&
589
- Number.isInteger(v) &&
590
- v >= 0 &&
591
- v < props.xLabels.length
592
- ) {
593
- return props.xLabels[v];
594
- }
595
- return formatTick(display);
596
- };
764
+ const targetTickCount = innerW.value / 80;
597
765
 
598
766
  let values: number[];
599
- if (
767
+ let unit: DateTickUnit | undefined;
768
+ if (isDate) {
769
+ const picked = pickDateTicks(xMin, xMax, targetTickCount, tz.value);
770
+ unit = picked.unit;
771
+ // Run through computeTickValues so the clip-to-range semantics
772
+ // match the numeric path. xMin is already 0, so `ticks` is used
773
+ // directly.
774
+ values = computeTickValues({ min: xMin, max: xMax, ticks: picked.values });
775
+ } else if (
600
776
  props.xTicks == null &&
601
777
  !hasExplicitX.value &&
602
778
  props.xLabels &&
603
779
  props.xLabels.length === len
604
780
  ) {
605
781
  // xLabels fallback: pick evenly-spaced index ticks so every label
606
- // bucket gets at most one tick.
607
- const targetTicks = Math.max(3, Math.floor(innerW.value / 80));
782
+ // bucket gets at most one tick. Date mode supersedes xLabels — if
783
+ // both are supplied, only the date axis is used.
784
+ const targetTicks = Math.max(3, Math.floor(targetTickCount));
608
785
  const step = Math.max(1, Math.round((len - 1) / targetTicks));
609
786
  values = [];
610
787
  for (let i = 0; i < len; i += step) values.push(i);
@@ -613,7 +790,7 @@ const xTickItems = computed(() => {
613
790
  min: xMin,
614
791
  max: xMax,
615
792
  ticks: props.xTicks,
616
- targetTickCount: innerW.value / 80,
793
+ targetTickCount,
617
794
  displayOffset: offset,
618
795
  });
619
796
  }
@@ -626,7 +803,7 @@ const xTickItems = computed(() => {
626
803
  let anchor: "start" | "middle" | "end" = "middle";
627
804
  if (x - leftEdge <= edgeSnapPx) anchor = "start";
628
805
  else if (rightEdge - x <= edgeSnapPx) anchor = "end";
629
- return { value: fmt(v, i), x, anchor };
806
+ return { value: formatXValue(v, i, unit), x, anchor };
630
807
  });
631
808
  });
632
809
 
@@ -675,6 +852,7 @@ const hoverDots = computed(() => {
675
852
  if (targetX === null) return [];
676
853
  const dots: { x: number; y: number; color: string }[] = [];
677
854
  for (const s of allSeries.value) {
855
+ if (s.showInTooltip === false) continue;
678
856
  const nIdx = nearestIndex(s, targetX);
679
857
  if (nIdx === null) continue;
680
858
  const yv = s.data[nIdx];
@@ -692,29 +870,24 @@ const hoverSlotProps = computed(() => {
692
870
  const idx = hoverIndex.value;
693
871
  const targetX = hoverDataX.value;
694
872
  if (idx === null || targetX === null) return null;
695
- const offset = xDisplayOffset.value;
696
- const displayX = targetX + offset;
697
- let xLabel: string | undefined;
698
- const xf = props.xTickFormat;
699
- if (xf !== undefined) {
700
- xLabel =
701
- typeof xf === "function" ? xf(displayX, idx) : formatNumber(displayX, xf);
702
- } else if (!hasExplicitX.value) {
703
- xLabel = props.xLabels?.[idx];
704
- } else {
705
- xLabel = formatTick(displayX);
873
+ // Single source of truth for label dispatch — same as the x-axis ticks.
874
+ const xLabel = formatXValue(targetX, idx);
875
+ const series = allSeries.value;
876
+ const values: ChartTooltipValue[] = [];
877
+ for (let i = 0; i < series.length; i++) {
878
+ const s = series[i];
879
+ if (s.showInTooltip === false) continue;
880
+ const nIdx = nearestIndex(s, targetX);
881
+ values.push({
882
+ value: nIdx !== null ? Number(s.data[nIdx]) : NaN,
883
+ color: s.color ?? "currentColor",
884
+ seriesIndex: i,
885
+ });
706
886
  }
707
887
  return {
708
888
  index: idx,
709
889
  xLabel,
710
- values: allSeries.value.map((s, i) => {
711
- const nIdx = nearestIndex(s, targetX);
712
- return {
713
- value: nIdx !== null ? Number(s.data[nIdx]) : NaN,
714
- color: s.color ?? "currentColor",
715
- seriesIndex: i,
716
- };
717
- }),
890
+ values,
718
891
  data: props.tooltipData?.[idx] ?? null,
719
892
  };
720
893
  });
@@ -747,6 +920,7 @@ const {
747
920
  height,
748
921
  padding,
749
922
  legendY,
923
+ inlineLegendLayout,
750
924
  innerW,
751
925
  innerH,
752
926
  bounds,
@@ -758,10 +932,12 @@ const {
758
932
  downloadLinkText,
759
933
  csvHref,
760
934
  menuFilename,
935
+ isFullscreen,
761
936
  } = useChartFoundation({
762
937
  width: () => props.width,
763
938
  height: () => props.height,
764
939
  title: () => props.title,
940
+ titleStyle: () => props.titleStyle,
765
941
  xLabel: () => props.xLabel,
766
942
  yLabel: () => props.yLabel,
767
943
  debounce: () => props.debounce,
@@ -771,40 +947,113 @@ const {
771
947
  filename: () => props.filename,
772
948
  downloadLink: () => props.downloadLink,
773
949
  chartPadding: () => props.chartPadding,
774
- hasInlineLegend: () => hasInlineLegend.value,
950
+ inlineLegendLabels: () => inlineLegendLabels.value,
775
951
  hasTooltipSlot: () => hasTooltipSlot.value,
776
952
  getCsv: toCsv,
777
953
  pointerToIndex: indexFromPointer,
778
954
  onHover: (payload) => emit("hover", payload),
955
+ extraBelowHeight: () => sectionLabels.value.extraHeight,
956
+ });
957
+
958
+ /** Resolved style for the x/y axis labels. */
959
+ const axisLabelResolved = computed(() =>
960
+ resolveLabelStyle(props.axisLabelStyle, { fontSize: AXIS_LABEL_FONT_SIZE }),
961
+ );
962
+ /** Resolved style for the axis tick labels. */
963
+ const tickLabelResolved = computed(() =>
964
+ resolveLabelStyle(props.tickLabelStyle, {
965
+ fontSize: TICK_LABEL_FONT_SIZE,
966
+ fillOpacity: TICK_LABEL_OPACITY,
967
+ }),
968
+ );
969
+ /** Resolved style for inline legend item labels. */
970
+ const legendResolved = computed(() =>
971
+ resolveLabelStyle(props.legendStyle, { fontSize: LEGEND_FONT_SIZE }),
972
+ );
973
+
974
+ /** Resolved title style with defaults applied. */
975
+ const titleResolved = computed(() => {
976
+ const s = props.titleStyle;
977
+ const align = s?.align ?? "left";
978
+ const b = bounds.value;
979
+ const x =
980
+ align === "left"
981
+ ? b.left
982
+ : align === "right"
983
+ ? b.right
984
+ : b.left + b.width / 2;
985
+ const anchor =
986
+ align === "left" ? "start" : align === "right" ? "end" : "middle";
987
+ return {
988
+ lines: (props.title ?? "").split("\n"),
989
+ fontSize: s?.fontSize ?? TITLE_FONT_SIZE,
990
+ lineHeight: s?.lineHeight ?? TITLE_LINE_HEIGHT,
991
+ fontWeight: s?.fontWeight ?? TITLE_FONT_WEIGHT,
992
+ color: s?.color ?? "currentColor",
993
+ x,
994
+ anchor,
995
+ };
996
+ });
997
+
998
+ /**
999
+ * Legend items joined with their wrapped pixel positions. `x` is the
1000
+ * left edge of the indicator; `y` is the center of the row.
1001
+ */
1002
+ const positionedLegendItems = computed(() => {
1003
+ const positions = inlineLegendLayout.value.positions;
1004
+ const pad = padding.value.left;
1005
+ const baseY = legendY.value;
1006
+ return inlineLegendItems.value.map((item, i) => {
1007
+ const pos = positions[i];
1008
+ return {
1009
+ ...item,
1010
+ x: pad + pos.x,
1011
+ y: baseY + pos.row * INLINE_LEGEND_ROW_HEIGHT,
1012
+ };
1013
+ });
779
1014
  });
780
1015
  </script>
781
1016
 
782
1017
  <template>
783
- <div ref="containerRef" class="line-chart-wrapper">
1018
+ <div
1019
+ ref="containerRef"
1020
+ class="line-chart-wrapper"
1021
+ :class="{ 'is-fullscreen': isFullscreen }"
1022
+ >
784
1023
  <ChartMenu v-if="menu" :items="menuItems" />
1024
+ <div class="chart-sr-only" aria-live="polite">
1025
+ {{ isFullscreen ? "Chart expanded to fill window" : "" }}
1026
+ </div>
785
1027
  <svg ref="svgRef" :width="width" :height="totalHeight">
786
1028
  <!-- title -->
787
1029
  <text
788
1030
  v-if="title"
789
- :x="width / 2"
790
- :y="18"
791
- text-anchor="middle"
792
- font-size="14"
793
- font-weight="600"
794
- fill="currentColor"
1031
+ :x="titleResolved.x"
1032
+ :y="titleResolved.lineHeight"
1033
+ :text-anchor="titleResolved.anchor"
1034
+ :font-size="titleResolved.fontSize"
1035
+ :font-weight="titleResolved.fontWeight"
1036
+ :fill="titleResolved.color"
795
1037
  >
796
- {{ title }}
1038
+ <tspan
1039
+ v-for="(line, i) in titleResolved.lines"
1040
+ :key="i"
1041
+ :x="titleResolved.x"
1042
+ :dy="i === 0 ? 0 : titleResolved.lineHeight"
1043
+ >
1044
+ {{ line }}
1045
+ </tspan>
797
1046
  </text>
798
1047
  <!-- inline legend -->
799
- <g v-if="inlineLegendItems.length > 0">
800
- <template v-for="(item, i) in inlineLegendItems" :key="'ileg' + i">
1048
+ <g v-if="positionedLegendItems.length > 0">
1049
+ <template v-for="(item, i) in positionedLegendItems" :key="'ileg' + i">
801
1050
  <!-- series indicator: line -->
802
1051
  <line
803
1052
  v-if="item.type === 'series'"
804
- :x1="padding.left + i * 120"
805
- :y1="legendY"
806
- :x2="padding.left + i * 120 + 12"
807
- :y2="legendY"
1053
+ :x1="item.x"
1054
+ :y1="item.y"
1055
+ :x2="item.x + 12"
1056
+ :y2="item.y"
808
1057
  :stroke="item.color"
809
1058
  stroke-width="2"
810
1059
  :stroke-dasharray="item.dashed ? '4 2' : undefined"
@@ -812,8 +1061,8 @@ const {
812
1061
  <!-- section indicator: filled circle -->
813
1062
  <circle
814
1063
  v-else
815
- :cx="padding.left + i * 120 + 4"
816
- :cy="legendY"
1064
+ :cx="item.x + 4"
1065
+ :cy="item.y"
817
1066
  r="4"
818
1067
  :fill="item.color"
819
1068
  :fill-opacity="item.fillOpacity"
@@ -821,10 +1070,11 @@ const {
821
1070
  stroke-width="1.5"
822
1071
  />
823
1072
  <text
824
- :x="padding.left + i * 120 + 18"
825
- :y="legendY + 4"
826
- font-size="11"
827
- fill="currentColor"
1073
+ :x="item.x + 18"
1074
+ :y="item.y + 4"
1075
+ :font-size="legendResolved.fontSize"
1076
+ :fill="legendResolved.fill"
1077
+ :font-weight="legendResolved.fontWeight"
828
1078
  >
829
1079
  {{ item.label }}
830
1080
  </text>
@@ -882,9 +1132,10 @@ const {
882
1132
  :y="tick.y"
883
1133
  text-anchor="end"
884
1134
  dominant-baseline="middle"
885
- font-size="10"
886
- fill="currentColor"
887
- fill-opacity="0.6"
1135
+ :font-size="tickLabelResolved.fontSize"
1136
+ :fill="tickLabelResolved.fill"
1137
+ :font-weight="tickLabelResolved.fontWeight"
1138
+ :fill-opacity="tickLabelResolved.fillOpacity"
888
1139
  >
889
1140
  {{ tick.value }}
890
1141
  </text>
@@ -895,8 +1146,9 @@ const {
895
1146
  :y="0"
896
1147
  :transform="`translate(14, ${padding.top + innerH / 2}) rotate(-90)`"
897
1148
  text-anchor="middle"
898
- font-size="13"
899
- fill="currentColor"
1149
+ :font-size="axisLabelResolved.fontSize"
1150
+ :fill="axisLabelResolved.fill"
1151
+ :font-weight="axisLabelResolved.fontWeight"
900
1152
  >
901
1153
  {{ yLabel }}
902
1154
  </text>
@@ -908,9 +1160,10 @@ const {
908
1160
  :x="tick.x"
909
1161
  :y="padding.top + innerH + 16"
910
1162
  :text-anchor="tick.anchor"
911
- font-size="10"
912
- fill="currentColor"
913
- fill-opacity="0.6"
1163
+ :font-size="tickLabelResolved.fontSize"
1164
+ :fill="tickLabelResolved.fill"
1165
+ :font-weight="tickLabelResolved.fontWeight"
1166
+ :fill-opacity="tickLabelResolved.fillOpacity"
914
1167
  >
915
1168
  {{ tick.value }}
916
1169
  </text>
@@ -920,8 +1173,9 @@ const {
920
1173
  :x="padding.left + innerW / 2"
921
1174
  :y="height - 4"
922
1175
  text-anchor="middle"
923
- font-size="13"
924
- fill="currentColor"
1176
+ :font-size="axisLabelResolved.fontSize"
1177
+ :fill="axisLabelResolved.fill"
1178
+ :font-weight="axisLabelResolved.fontWeight"
925
1179
  >
926
1180
  {{ xLabel }}
927
1181
  </text>
@@ -936,6 +1190,16 @@ const {
936
1190
  />
937
1191
  <!-- data lines and dots -->
938
1192
  <template v-for="(s, i) in allSeries" :key="i">
1193
+ <path
1194
+ v-if="s.line !== false && s.outline"
1195
+ :d="toPath(s)"
1196
+ fill="none"
1197
+ stroke="var(--color-bg-0, #fff)"
1198
+ :stroke-width="(s.strokeWidth ?? 1.5) + 4"
1199
+ stroke-linecap="round"
1200
+ stroke-linejoin="round"
1201
+ data-testid="line-outline"
1202
+ />
939
1203
  <path
940
1204
  v-if="s.line !== false"
941
1205
  :d="toPath(s)"
@@ -1077,9 +1341,9 @@ const {
1077
1341
  v-if="item.labelText"
1078
1342
  :x="item.cx - item.textWidth / 2 + 8"
1079
1343
  :y="sectionLabelBaseY + item.row * SECTION_LABEL_ROW_HEIGHT + 8"
1080
- font-size="11"
1081
- font-weight="600"
1082
- :fill="item.color"
1344
+ :font-size="item.labelStyle.fontSize"
1345
+ :font-weight="item.labelStyle.fontWeight"
1346
+ :fill="item.labelStyle.fill"
1083
1347
  >
1084
1348
  {{ item.labelText }}
1085
1349
  </text>
@@ -1087,9 +1351,10 @@ const {
1087
1351
  v-if="item.descText"
1088
1352
  :x="item.cx - item.textWidth / 2 + 8"
1089
1353
  :y="sectionLabelBaseY + item.row * SECTION_LABEL_ROW_HEIGHT + 22"
1090
- font-size="11"
1091
- fill="currentColor"
1092
- fill-opacity="0.6"
1354
+ :font-size="item.descStyle.fontSize"
1355
+ :font-weight="item.descStyle.fontWeight"
1356
+ :fill="item.descStyle.fill"
1357
+ :fill-opacity="item.descStyle.fillOpacity"
1093
1358
  >
1094
1359
  {{ item.descText }}
1095
1360
  </text>
@@ -1147,10 +1412,6 @@ const {
1147
1412
  width: 100%;
1148
1413
  }
1149
1414
 
1150
- .line-chart-wrapper:hover :deep(.chart-menu-button) {
1151
- opacity: 1;
1152
- }
1153
-
1154
1415
  .line-chart-tooltip-label {
1155
1416
  font-weight: 600;
1156
1417
  margin-bottom: 0.25em;