@cfasim-ui/charts 0.7.7 → 0.8.0

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 (63) hide show
  1. package/dist/BarChart/BarChart.d.ts +9 -26
  2. package/dist/ChartMenu/ChartMenu.d.ts +3 -2
  3. package/dist/ChartTooltip/ChartTooltip.d.ts +9 -12
  4. package/dist/ChoroplethMap/ChoroplethMap.d.ts +28 -189
  5. package/dist/ChoroplethMap/ChoroplethTooltip.d.ts +20 -27
  6. package/dist/ChoroplethMap/canvasLayer.d.ts +27 -3
  7. package/dist/DataTable/DataTable.d.ts +3 -2
  8. package/dist/LineChart/LineChart.d.ts +9 -26
  9. package/dist/_shared/ChartAnnotations.d.ts +3 -2
  10. package/dist/_shared/ChartZoomControls.d.ts +3 -2
  11. package/dist/_shared/index.d.ts +2 -1
  12. package/dist/_shared/mapTheme.d.ts +159 -0
  13. package/dist/_shared/mapTheme.test.d.ts +1 -0
  14. package/dist/index.css +1 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.js +1710 -1432
  17. package/docs/BarChart.md +776 -0
  18. package/docs/ChoroplethMap.md +1267 -0
  19. package/docs/DataTable.md +386 -0
  20. package/docs/LineChart.md +1267 -0
  21. package/docs/index.json +56 -0
  22. package/package.json +25 -21
  23. package/src/BarChart/BarChart.md +743 -0
  24. package/src/BarChart/BarChart.vue +1901 -0
  25. package/src/ChartMenu/ChartMenu.vue +220 -0
  26. package/src/ChartMenu/download.ts +120 -0
  27. package/src/ChartTooltip/ChartTooltip.vue +97 -0
  28. package/src/ChoroplethMap/ChoroplethMap.md +1227 -0
  29. package/src/ChoroplethMap/ChoroplethMap.vue +3676 -0
  30. package/src/ChoroplethMap/ChoroplethTooltip.vue +103 -0
  31. package/src/ChoroplethMap/canvasLayer.ts +373 -0
  32. package/src/ChoroplethMap/cityLayout.ts +261 -0
  33. package/src/ChoroplethMap/hsaMapping.ts +4116 -0
  34. package/src/DataTable/DataTable.md +372 -0
  35. package/src/DataTable/DataTable.vue +406 -0
  36. package/src/LineChart/LineChart.md +1225 -0
  37. package/src/LineChart/LineChart.vue +1555 -0
  38. package/src/_shared/ChartAnnotations.vue +420 -0
  39. package/src/_shared/ChartZoomControls.vue +138 -0
  40. package/src/_shared/annotations.ts +106 -0
  41. package/src/_shared/axes.ts +69 -0
  42. package/src/_shared/chartProps.ts +201 -0
  43. package/src/_shared/computeTicks.ts +42 -0
  44. package/src/_shared/contrast.ts +100 -0
  45. package/src/_shared/dateAxis.ts +501 -0
  46. package/src/_shared/index.ts +78 -0
  47. package/src/_shared/mapTheme.ts +551 -0
  48. package/src/_shared/scale.ts +86 -0
  49. package/src/_shared/seriesCsv.ts +68 -0
  50. package/src/_shared/touch.ts +8 -0
  51. package/src/_shared/useChartFoundation.ts +175 -0
  52. package/src/_shared/useChartFullscreen.ts +254 -0
  53. package/src/_shared/useChartMenu.ts +111 -0
  54. package/src/_shared/useChartPadding.ts +235 -0
  55. package/src/_shared/useChartSize.ts +58 -0
  56. package/src/_shared/useChartTooltip.ts +205 -0
  57. package/src/env.d.ts +4 -0
  58. package/src/hsa-mapping.ts +1 -0
  59. package/src/index.ts +41 -0
  60. package/src/tooltip-position.ts +55 -0
  61. package/src/us-cities/data.ts +1371 -0
  62. package/src/us-cities/index.ts +122 -0
  63. package/src/us-cities.ts +7 -0
@@ -0,0 +1,1555 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+ import { formatNumber, type NumberFormat } from "@cfasim-ui/shared";
4
+ import ChartMenu from "../ChartMenu/ChartMenu.vue";
5
+ import {
6
+ snap,
7
+ formatTick,
8
+ computeTickValues,
9
+ computeLogTickValues,
10
+ scaleFraction,
11
+ clampExtentForScale,
12
+ seriesToCsv,
13
+ useChartFoundation,
14
+ makeTooltipValueFormatter,
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,
29
+ type ChartData,
30
+ type LabelStyle,
31
+ type DateFormat,
32
+ type DateTickUnit,
33
+ type DateTimezone,
34
+ type ChartCommonProps,
35
+ type ChartHoverPayload,
36
+ type ChartTooltipBaseProps,
37
+ type ChartTooltipValue,
38
+ type BlendMode,
39
+ type LineMarkStyle,
40
+ } from "../_shared/index.js";
41
+
42
+ /**
43
+ * Numeric input accepted by the chart. `number[]` and any standard numeric
44
+ * typed array are supported, so the output of
45
+ * `ModelOutput.column('x')` (e.g. a `Float64Array`) can be passed directly
46
+ * without copying into a plain array.
47
+ */
48
+ export type LineChartData = ChartData;
49
+
50
+ /**
51
+ * Accepted shapes for an `x` array. Numeric arrays (typed or plain) plot
52
+ * as numbers; string-or-`Date` arrays trigger date-axis mode when every
53
+ * element parses. See the `timezone` prop and `dateAxis` module.
54
+ */
55
+ export type LineChartXInput = LineChartData | readonly (string | Date)[];
56
+
57
+ /**
58
+ * A single line/dot series. Inherits visual styling
59
+ * (`color`, `strokeWidth`, `dashed`, `opacity`, `blendMode`, `dots`,
60
+ * `dotRadius`, `legend`, `showInLegend`) from `LineMarkStyle`, shared
61
+ * with `BarChart`'s `BarSummaryLine`.
62
+ */
63
+ export interface Series extends LineMarkStyle {
64
+ /**
65
+ * Y-values. One of `y` or `data` must be supplied; `y` wins if both
66
+ * are set.
67
+ */
68
+ y?: LineChartData;
69
+ /** Y-values (alternative name for `y`). */
70
+ data?: LineChartData;
71
+ /**
72
+ * Optional x-values, parallel to `y`/`data`. When set, the chart
73
+ * plots points at the given x positions (irregular spacing supported).
74
+ * When omitted, points are plotted at indices 0, 1, 2, ... Accepts
75
+ * date strings or `Date` objects to enable date-axis mode.
76
+ */
77
+ x?: LineChartXInput;
78
+ /** Overrides `opacity` for the line stroke only. */
79
+ lineOpacity?: number;
80
+ /** Overrides `opacity` for the dots only. */
81
+ dotOpacity?: number;
82
+ /** Draw the line. Default true. */
83
+ line?: boolean;
84
+ /**
85
+ * Draw a page-colored stroke behind the line so it stays visually
86
+ * separated from overlapping series and busy backgrounds. Has no
87
+ * effect when `line` is `false`.
88
+ */
89
+ outline?: boolean;
90
+ /**
91
+ * Outline stroke color. Defaults to the page background
92
+ * (`var(--color-bg-0, #fff)`). Only applies when `outline` is true.
93
+ */
94
+ outlineColor?: string;
95
+ /**
96
+ * Total extra width added to the line's `strokeWidth` for the outline
97
+ * (split evenly on each side). Defaults to `4`. Only applies when
98
+ * `outline` is true.
99
+ */
100
+ outlineWidth?: number;
101
+ dotFill?: string;
102
+ dotStroke?: string;
103
+ /**
104
+ * Whether this series contributes a value to the tooltip and shows a
105
+ * hover dot. Defaults to true. The series line/dots are still drawn.
106
+ */
107
+ showInTooltip?: boolean;
108
+ }
109
+
110
+ export interface Area {
111
+ upper: LineChartData;
112
+ lower: LineChartData;
113
+ /** Optional x-values parallel to `upper`/`lower`. See `Series.x`. */
114
+ x?: LineChartXInput;
115
+ color?: string;
116
+ opacity?: number;
117
+ /** Label shown in the inline legend. */
118
+ legend?: string;
119
+ /**
120
+ * Whether this area appears in the inline legend. Defaults to true.
121
+ * Has no effect when `legend` is unset.
122
+ */
123
+ showInLegend?: boolean;
124
+ /**
125
+ * CSS `mix-blend-mode` applied to the area fill. Lets overlapping
126
+ * areas (e.g. confidence bands) combine their colors instead of one
127
+ * obscuring the other.
128
+ */
129
+ blendMode?: BlendMode;
130
+ }
131
+
132
+ export interface AreaSection {
133
+ /** Index into the series array. When omitted, fills the full chart height with no line. */
134
+ seriesIndex?: number;
135
+ /** Start x-index (inclusive) */
136
+ startIndex: number;
137
+ /** End x-index (inclusive) */
138
+ endIndex: number;
139
+ /** Fill color; defaults to referenced series color */
140
+ color?: string;
141
+ /** Fill opacity; defaults to 0.15 */
142
+ opacity?: number;
143
+ /** Primary label text (e.g. "Day 36–63") */
144
+ label?: string;
145
+ /** Secondary description text (e.g. "40.0M vaccines administered") */
146
+ description?: string;
147
+ /** Stroke width for the highlighted line segment (default: 2) */
148
+ strokeWidth?: number;
149
+ /** Dashed stroke pattern */
150
+ dashed?: boolean;
151
+ /** Label placement: "below" (default) renders below chart, "inline" renders in legend row, false hides label */
152
+ legend?: "inline" | "below" | false;
153
+ /**
154
+ * Style for the area section's primary label text. Defaults: font-size
155
+ * 11, font-weight 600, color taken from the section's `color`.
156
+ */
157
+ inlineLabelStyle?: LabelStyle;
158
+ /**
159
+ * Style for the area section's secondary description text. Defaults:
160
+ * font-size 11, currentColor at 0.6 opacity. Providing `color` drops
161
+ * the default opacity.
162
+ */
163
+ inlineDescriptionStyle?: LabelStyle;
164
+ }
165
+
166
+ interface LineChartProps extends ChartCommonProps {
167
+ /** Y-values. Equivalent to `data`. If both are set, `y` wins. */
168
+ y?: LineChartData;
169
+ /** Y-values (alternative name for `y`). */
170
+ data?: LineChartData;
171
+ /**
172
+ * Optional x-values paired with `y`/`data`. When provided, points
173
+ * are plotted at the given x positions instead of at their indices.
174
+ * Ignored when `series` is used — set `x` on each `Series` instead.
175
+ * Accepts date strings or `Date` objects to enable date-axis mode.
176
+ */
177
+ x?: LineChartXInput;
178
+ series?: Series[];
179
+ areas?: Area[];
180
+ areaSections?: AreaSection[];
181
+ lineOpacity?: number;
182
+ yMin?: number;
183
+ /**
184
+ * Scale type for the y axis. `"linear"` (default) maps values directly
185
+ * to pixels; `"log"` uses a base-10 log mapping. On a log axis,
186
+ * non-positive values collapse to the visible minimum, and `yMin <= 0`
187
+ * is ignored.
188
+ */
189
+ yScaleType?: "linear" | "log";
190
+ /**
191
+ * Offset applied to index-based x values (e.g. `xMin: 10` starts the
192
+ * x axis at 10 instead of 0). Ignored when any series or area has
193
+ * explicit `x` values.
194
+ */
195
+ xMin?: number;
196
+ /**
197
+ * Tick placement on the x-axis. Number = interval in data units
198
+ * (respecting `xMin`, e.g. `7` ticks every 7 days). Array = explicit tick
199
+ * values in data space; values outside the data range are dropped.
200
+ * When omitted, ticks are chosen automatically.
201
+ */
202
+ xTicks?: number | number[];
203
+ /**
204
+ * Tick placement on the y-axis. Number = interval in data units. Array =
205
+ * explicit tick values; values outside the data range are dropped. When
206
+ * omitted, ticks are chosen automatically.
207
+ */
208
+ yTicks?: number | number[];
209
+ /**
210
+ * Formatter for x-axis tick labels. On a numeric axis, accepts a
211
+ * preset name, a printf-style format string, or a function (the
212
+ * two-arg `(value, index)` form is also supported for index-based
213
+ * labels — see `formatNumber` in `@cfasim-ui/shared`). On a date
214
+ * axis (auto-detected when every x value parses as a date), accepts
215
+ * a `DateFormat` instead — see the `dateAxis` module.
216
+ */
217
+ xTickFormat?:
218
+ NumberFormat | ((value: number, index: number) => string) | DateFormat;
219
+ /**
220
+ * Timezone used when parsing offset-less ISO strings and rendering
221
+ * date-axis tick labels. `"utc"` (default) keeps visuals consistent
222
+ * across viewers; `"local"` uses the browser timezone. Ignored on a
223
+ * numeric axis.
224
+ */
225
+ timezone?: DateTimezone;
226
+ /**
227
+ * Formatter for y-axis tick labels. Accepts a preset name, a printf-style
228
+ * format string, or a function. See `formatNumber` in `@cfasim-ui/shared`.
229
+ */
230
+ yTickFormat?: NumberFormat;
231
+ /**
232
+ * @deprecated Use `xTickFormat` (e.g. `(_, i) => labels[i]`) together
233
+ * with `xTicks` for explicit control. Still honored for tooltip x-labels
234
+ * and as a default x-tick formatter when `xTickFormat` is not provided.
235
+ */
236
+ xLabels?: string[];
237
+ xGrid?: boolean;
238
+ yGrid?: boolean;
239
+ }
240
+
241
+ const props = withDefaults(defineProps<LineChartProps>(), {
242
+ lineOpacity: 1,
243
+ menu: true,
244
+ tooltipClamp: "window",
245
+ yScaleType: "linear",
246
+ });
247
+
248
+ // Accessible name for the chart; falls back to the visible title.
249
+ const chartAriaLabel = computed(() => props.ariaLabel ?? props.title);
250
+ // Expose the <svg> as a single labeled image so screen readers announce the
251
+ // name instead of wandering through the individual marks. The menu/download
252
+ // controls live outside the <svg>, so they stay reachable. An explicit `role`
253
+ // prop always wins.
254
+ const chartRole = computed(
255
+ () => props.role ?? (chartAriaLabel.value ? "img" : undefined),
256
+ );
257
+
258
+ // The template root is a <Teleport>, so fallthrough attrs (class, style,
259
+ // data-*, id…) can't auto-inherit — forward them onto the wrapper manually.
260
+ defineOptions({ inheritAttrs: false });
261
+
262
+ const emit = defineEmits<{
263
+ (e: "hover", payload: ChartHoverPayload): void;
264
+ }>();
265
+
266
+ defineSlots<{
267
+ tooltip?(props: ChartTooltipBaseProps & { xLabel?: string }): unknown;
268
+ }>();
269
+
270
+ /**
271
+ * Internal series shape where `data` (y-values) is always resolved and
272
+ * `x` is narrowed to numeric (date inputs have already been parsed to
273
+ * epoch-ms). `Series.y` takes precedence over `Series.data`.
274
+ */
275
+ type ResolvedSeries = Omit<Series, "data" | "y" | "x"> & {
276
+ data: LineChartData;
277
+ x?: LineChartData;
278
+ };
279
+
280
+ /** Same shape as `Area` but with `x` narrowed to numeric post-parse. */
281
+ type ResolvedArea = Omit<Area, "x"> & { x?: LineChartData };
282
+
283
+ const EMPTY_DATA: readonly number[] = [];
284
+
285
+ /**
286
+ * Yields every user-supplied `x` array on the component (top-level,
287
+ * per-series, per-area). Used both for date auto-detection and for the
288
+ * single-pass resolution that converts strings/Dates to numeric ms.
289
+ */
290
+ function* xInputs(p: LineChartProps): Iterable<LineChartXInput> {
291
+ if (p.x && p.x.length > 0) yield p.x;
292
+ if (p.series) {
293
+ for (const s of p.series) if (s.x && s.x.length > 0) yield s.x;
294
+ }
295
+ if (p.areas) {
296
+ for (const a of p.areas) if (a.x && a.x.length > 0) yield a.x;
297
+ }
298
+ }
299
+
300
+ /** Convert an x input to numeric. Pre-parsed when `isDate` is true. */
301
+ function toNumericX(
302
+ x: LineChartXInput | undefined,
303
+ isDate: boolean,
304
+ tz: DateTimezone,
305
+ ): LineChartData | undefined {
306
+ if (!x) return undefined;
307
+ if (!isDate) return x as LineChartData;
308
+ const out = new Float64Array(x.length);
309
+ for (let i = 0; i < x.length; i++) {
310
+ const v = parseDate(x[i], tz);
311
+ out[i] = v ?? NaN;
312
+ }
313
+ return out;
314
+ }
315
+
316
+ const formatTooltipValue = makeTooltipValueFormatter(
317
+ () => props.tooltipValueFormat,
318
+ () => props.yTickFormat,
319
+ );
320
+
321
+ const tz = computed<DateTimezone>(() => props.timezone ?? "utc");
322
+
323
+ /**
324
+ * Single-pass resolution of x-axis mode + resolved series and areas.
325
+ * Date auto-detection (`isAllDates` per input array) and date→ms
326
+ * conversion (`toNumericX`) both call `parseDate`; this computed
327
+ * makes sure each user-supplied x value is parsed exactly once per
328
+ * render even though we have multiple consumers.
329
+ */
330
+ const resolvedXAxis = computed<{
331
+ isDate: boolean;
332
+ series: ResolvedSeries[];
333
+ areas: ResolvedArea[];
334
+ }>(() => {
335
+ const z = tz.value;
336
+ let isDate = false;
337
+ let any = false;
338
+ for (const x of xInputs(props)) {
339
+ any = true;
340
+ if (!isAllDates(x, z)) {
341
+ isDate = false;
342
+ break;
343
+ }
344
+ isDate = true;
345
+ }
346
+ if (!any) isDate = false;
347
+
348
+ const series: ResolvedSeries[] =
349
+ props.series && props.series.length > 0
350
+ ? props.series.map((s) => ({
351
+ ...s,
352
+ data: s.y ?? s.data ?? EMPTY_DATA,
353
+ x: toNumericX(s.x, isDate, z),
354
+ }))
355
+ : (() => {
356
+ const topY = props.y ?? props.data;
357
+ return topY
358
+ ? [{ data: topY, x: toNumericX(props.x, isDate, z) }]
359
+ : [];
360
+ })();
361
+
362
+ const areas: ResolvedArea[] = (props.areas ?? []).map((a) => ({
363
+ ...a,
364
+ x: toNumericX(a.x, isDate, z),
365
+ }));
366
+
367
+ return { isDate, series, areas };
368
+ });
369
+
370
+ const xIsDate = computed(() => resolvedXAxis.value.isDate);
371
+ const allSeries = computed<ResolvedSeries[]>(() => resolvedXAxis.value.series);
372
+ const allAreas = computed<ResolvedArea[]>(() => resolvedXAxis.value.areas);
373
+
374
+ const maxLen = computed(() => {
375
+ let m = 0;
376
+ for (const s of allSeries.value) {
377
+ if (s.data.length > m) m = s.data.length;
378
+ }
379
+ for (const a of allAreas.value) {
380
+ if (a.upper.length > m) m = a.upper.length;
381
+ if (a.lower.length > m) m = a.lower.length;
382
+ }
383
+ return m;
384
+ });
385
+
386
+ /** True when any series/area supplies explicit x-values (irregular x mode). */
387
+ const hasExplicitX = computed(
388
+ () =>
389
+ allSeries.value.some((s) => s.x != null) ||
390
+ allAreas.value.some((a) => a.x != null),
391
+ );
392
+
393
+ /** Data-space x value for the i-th point of a series. */
394
+ function seriesXAt(s: { x?: LineChartData }, i: number): number {
395
+ return s.x ? Number(s.x[i]) : i;
396
+ }
397
+
398
+ /** Data-space x value for the i-th point of an area. */
399
+ function areaXAt(a: ResolvedArea, i: number): number {
400
+ return a.x ? Number(a.x[i]) : i;
401
+ }
402
+
403
+ /**
404
+ * Display-only offset: added to tick values and tooltip x-labels so
405
+ * `xMin: 10` with index-based data shows "10, 11, …" without changing
406
+ * where points are drawn. Ignored when explicit `x` is provided.
407
+ */
408
+ const xDisplayOffset = computed(() =>
409
+ hasExplicitX.value ? 0 : (props.xMin ?? 0),
410
+ );
411
+
412
+ const xExtent = computed(() => {
413
+ let min = Infinity;
414
+ let max = -Infinity;
415
+ for (const s of allSeries.value) {
416
+ for (let i = 0; i < s.data.length; i++) {
417
+ const v = seriesXAt(s, i);
418
+ if (!isFinite(v)) continue;
419
+ if (v < min) min = v;
420
+ if (v > max) max = v;
421
+ }
422
+ }
423
+ for (const a of allAreas.value) {
424
+ const n = Math.max(a.upper.length, a.lower.length);
425
+ for (let i = 0; i < n; i++) {
426
+ const v = areaXAt(a, i);
427
+ if (!isFinite(v)) continue;
428
+ if (v < min) min = v;
429
+ if (v > max) max = v;
430
+ }
431
+ }
432
+ if (!isFinite(min)) return { min: 0, max: 0 };
433
+ return { min, max };
434
+ });
435
+
436
+ function xPixel(v: number): number {
437
+ const { min, max } = xExtent.value;
438
+ const range = max - min || 1;
439
+ return padding.value.left + ((v - min) / range) * innerW.value;
440
+ }
441
+
442
+ const extent = computed(() => {
443
+ let min = Infinity;
444
+ let max = -Infinity;
445
+ let smallestPositive = Infinity;
446
+ const visit = (v: number) => {
447
+ if (!isFinite(v)) return;
448
+ if (v < min) min = v;
449
+ if (v > max) max = v;
450
+ if (v > 0 && v < smallestPositive) smallestPositive = v;
451
+ };
452
+ for (const s of allSeries.value) for (const v of s.data) visit(v);
453
+ for (const a of allAreas.value) {
454
+ for (const v of a.upper) visit(v);
455
+ for (const v of a.lower) visit(v);
456
+ }
457
+ if (!isFinite(min)) return { min: 0, max: 0, range: 1 };
458
+ if (props.yMin != null && props.yMin < min) min = props.yMin;
459
+ const clamped = clampExtentForScale(
460
+ min,
461
+ max,
462
+ props.yScaleType,
463
+ smallestPositive,
464
+ );
465
+ return {
466
+ min: clamped.min,
467
+ max: clamped.max,
468
+ range: clamped.max - clamped.min || 1,
469
+ };
470
+ });
471
+
472
+ function yPixel(v: number): number {
473
+ const { min, max } = extent.value;
474
+ const py = padding.value.top + innerH.value;
475
+ return py - scaleFraction(v, min, max, props.yScaleType) * innerH.value;
476
+ }
477
+
478
+ function toPath(s: ResolvedSeries): string {
479
+ const data = s.data;
480
+ if (data.length === 0) return "";
481
+ let d = "";
482
+ let inSegment = false;
483
+ for (let i = 0; i < data.length; i++) {
484
+ const xv = seriesXAt(s, i);
485
+ if (!isFinite(data[i]) || !isFinite(xv)) {
486
+ inSegment = false;
487
+ continue;
488
+ }
489
+ const x = xPixel(xv);
490
+ const y = yPixel(data[i]);
491
+ d += inSegment ? `L${x},${y}` : `M${x},${y}`;
492
+ inSegment = true;
493
+ }
494
+ return d;
495
+ }
496
+
497
+ function toPoints(s: ResolvedSeries): { x: number; y: number }[] {
498
+ const data = s.data;
499
+ const pts: { x: number; y: number }[] = [];
500
+ for (let i = 0; i < data.length; i++) {
501
+ const xv = seriesXAt(s, i);
502
+ if (!isFinite(data[i]) || !isFinite(xv)) continue;
503
+ pts.push({ x: xPixel(xv), y: yPixel(data[i]) });
504
+ }
505
+ return pts;
506
+ }
507
+
508
+ function toAreaPath(a: ResolvedArea): string {
509
+ const len = Math.min(a.upper.length, a.lower.length);
510
+ if (len === 0) return "";
511
+ // Collect contiguous segments where both upper/lower and x are finite
512
+ const segments: number[][] = [];
513
+ let seg: number[] = [];
514
+ for (let i = 0; i < len; i++) {
515
+ if (
516
+ isFinite(a.upper[i]) &&
517
+ isFinite(a.lower[i]) &&
518
+ isFinite(areaXAt(a, i))
519
+ ) {
520
+ seg.push(i);
521
+ } else if (seg.length) {
522
+ segments.push(seg);
523
+ seg = [];
524
+ }
525
+ }
526
+ if (seg.length) segments.push(seg);
527
+ let d = "";
528
+ for (const s of segments) {
529
+ d += `M${xPixel(areaXAt(a, s[0]))},${yPixel(a.upper[s[0]])}`;
530
+ for (let j = 1; j < s.length; j++)
531
+ d += `L${xPixel(areaXAt(a, s[j]))},${yPixel(a.upper[s[j]])}`;
532
+ for (let j = s.length - 1; j >= 0; j--)
533
+ d += `L${xPixel(areaXAt(a, s[j]))},${yPixel(a.lower[s[j]])}`;
534
+ d += "Z";
535
+ }
536
+ return d;
537
+ }
538
+
539
+ /**
540
+ * Pixel x of a section boundary. Maps through the referenced series'
541
+ * `x` array when available, then falls back to series 0, then to the
542
+ * raw index (index-mode).
543
+ */
544
+ function sectionXPixel(section: AreaSection, which: "start" | "end"): number {
545
+ const idx = which === "start" ? section.startIndex : section.endIndex;
546
+ const s =
547
+ (section.seriesIndex != null && allSeries.value[section.seriesIndex]) ||
548
+ allSeries.value[0];
549
+ if (s) return xPixel(seriesXAt(s, idx));
550
+ return xPixel(idx);
551
+ }
552
+
553
+ function toSectionPath(section: AreaSection, closed = true): string {
554
+ const py = padding.value.top + innerH.value;
555
+
556
+ // No seriesIndex: full-height rectangle spanning the range
557
+ if (section.seriesIndex == null) {
558
+ const sx = sectionXPixel(section, "start");
559
+ const ex = sectionXPixel(section, "end");
560
+ if (sx > ex) return "";
561
+ return `M${sx},${padding.value.top}L${ex},${padding.value.top}L${ex},${py}L${sx},${py}Z`;
562
+ }
563
+
564
+ const s = allSeries.value[section.seriesIndex];
565
+ if (!s) return "";
566
+
567
+ const start = Math.max(0, section.startIndex);
568
+ const end = Math.min(s.data.length - 1, section.endIndex);
569
+ if (start > end) return "";
570
+
571
+ let d = `M${xPixel(seriesXAt(s, start))},${yPixel(s.data[start])}`;
572
+ for (let i = start + 1; i <= end; i++) {
573
+ if (!isFinite(s.data[i])) continue;
574
+ d += `L${xPixel(seriesXAt(s, i))},${yPixel(s.data[i])}`;
575
+ }
576
+ if (closed) {
577
+ d += `L${xPixel(seriesXAt(s, end))},${py}`;
578
+ d += `L${xPixel(seriesXAt(s, start))},${py}`;
579
+ d += "Z";
580
+ }
581
+ return d;
582
+ }
583
+
584
+ const SECTION_LABEL_ROW_HEIGHT = 36;
585
+ const SECTION_LABEL_TOP_MARGIN = 12;
586
+ const SECTION_LABEL_CHAR_WIDTH = 7;
587
+ const SECTION_LABEL_H_GAP = 16;
588
+
589
+ interface PositionedSectionLabel {
590
+ cx: number;
591
+ labelText: string;
592
+ descText: string;
593
+ textWidth: number;
594
+ row: number;
595
+ color: string;
596
+ fillOpacity: number;
597
+ labelStyle: ReturnType<typeof resolveLabelStyle>;
598
+ descStyle: ReturnType<typeof resolveLabelStyle>;
599
+ }
600
+
601
+ const sectionLabels = computed<{
602
+ labels: PositionedSectionLabel[];
603
+ extraHeight: number;
604
+ }>(() => {
605
+ const sections = props.areaSections;
606
+ if (!sections?.length) return { labels: [], extraHeight: 0 };
607
+
608
+ const items: PositionedSectionLabel[] = [];
609
+ const chartRight = padding.value.left + innerW.value;
610
+ for (const sec of sections) {
611
+ if (!sec.label && !sec.description) continue;
612
+ if (sec.legend === "inline" || sec.legend === false) continue;
613
+ const labelText = sec.label ?? "";
614
+ const descText = sec.description ?? "";
615
+ const maxChars = Math.max(labelText.length, descText.length);
616
+ const textWidth = maxChars * SECTION_LABEL_CHAR_WIDTH;
617
+ // Anchor the indicator circle to the start of the area. The circle is
618
+ // drawn at (cx - textWidth/2 - 2), so solve for cx to place it at the
619
+ // section's start pixel. Clamp so the label's right edge stays within
620
+ // the chart if it would otherwise overflow.
621
+ const startPx = sectionXPixel(sec, "start");
622
+ const labelRightPad = 8;
623
+ const preferred = startPx + textWidth / 2 + 2;
624
+ const maxCx = chartRight - textWidth / 2 - labelRightPad;
625
+ const cx = Math.min(preferred, maxCx);
626
+ const color =
627
+ sec.color ??
628
+ (sec.seriesIndex != null
629
+ ? (allSeries.value[sec.seriesIndex]?.color ?? "currentColor")
630
+ : "#999");
631
+ // Section labels default to the section's data-driven color/weight;
632
+ // user-supplied styles override piece-by-piece.
633
+ const labelStyle = resolveLabelStyle(
634
+ { color, fontWeight: 600, ...sec.inlineLabelStyle },
635
+ { fontSize: 11 },
636
+ );
637
+ const descStyle = resolveLabelStyle(sec.inlineDescriptionStyle, {
638
+ fontSize: 11,
639
+ fillOpacity: 0.6,
640
+ });
641
+ items.push({
642
+ cx,
643
+ labelText,
644
+ descText,
645
+ textWidth,
646
+ row: 0,
647
+ color,
648
+ fillOpacity: sec.opacity ?? 0.15,
649
+ labelStyle,
650
+ descStyle,
651
+ });
652
+ }
653
+
654
+ items.sort((a, b) => a.cx - b.cx);
655
+
656
+ // Greedy collision detection
657
+ const rowRightEdges: number[] = [];
658
+ for (const item of items) {
659
+ const left = item.cx - item.textWidth / 2;
660
+ let row = 0;
661
+ while (row < rowRightEdges.length) {
662
+ if (left >= rowRightEdges[row] + SECTION_LABEL_H_GAP) break;
663
+ row++;
664
+ }
665
+ item.row = row;
666
+ const right = item.cx + item.textWidth / 2;
667
+ rowRightEdges[row] = Math.max(rowRightEdges[row] ?? -Infinity, right);
668
+ }
669
+
670
+ if (items.length === 0) return { labels: [], extraHeight: 0 };
671
+ const maxRow = Math.max(...items.map((it) => it.row));
672
+ const extraHeight =
673
+ (maxRow + 1) * SECTION_LABEL_ROW_HEIGHT + SECTION_LABEL_TOP_MARGIN;
674
+ return { labels: items, extraHeight };
675
+ });
676
+
677
+ interface InlineLegendItem {
678
+ label: string;
679
+ color: string;
680
+ type: "series" | "area" | "section";
681
+ dashed?: boolean;
682
+ fillOpacity?: number;
683
+ }
684
+
685
+ const inlineLegendItems = computed<InlineLegendItem[]>(() => {
686
+ const items: InlineLegendItem[] = [];
687
+ for (const s of allSeries.value) {
688
+ if (!s.legend || s.showInLegend === false) continue;
689
+ items.push({
690
+ label: s.legend,
691
+ color: s.color ?? "currentColor",
692
+ type: "series",
693
+ dashed: s.dashed,
694
+ });
695
+ }
696
+ for (const a of allAreas.value) {
697
+ if (!a.legend || a.showInLegend === false) continue;
698
+ items.push({
699
+ label: a.legend,
700
+ color: a.color ?? "currentColor",
701
+ type: "area",
702
+ fillOpacity: a.opacity ?? 0.2,
703
+ });
704
+ }
705
+ const sections = props.areaSections;
706
+ if (sections) {
707
+ for (const sec of sections) {
708
+ if (sec.legend !== "inline") continue;
709
+ if (!sec.label && !sec.description) continue;
710
+ const label = [sec.label, sec.description].filter(Boolean).join(" ");
711
+ const color =
712
+ sec.color ??
713
+ (sec.seriesIndex != null
714
+ ? (allSeries.value[sec.seriesIndex]?.color ?? "currentColor")
715
+ : "#999");
716
+ items.push({
717
+ label,
718
+ color,
719
+ type: "section",
720
+ fillOpacity: sec.opacity ?? 0.15,
721
+ });
722
+ }
723
+ }
724
+ return items;
725
+ });
726
+
727
+ const inlineLegendLabels = computed(() =>
728
+ inlineLegendItems.value.map((item) => item.label),
729
+ );
730
+
731
+ const totalHeight = computed(
732
+ () => height.value + sectionLabels.value.extraHeight,
733
+ );
734
+
735
+ const sectionLabelBaseY = computed(
736
+ () =>
737
+ padding.value.top +
738
+ innerH.value +
739
+ padding.value.bottom +
740
+ SECTION_LABEL_TOP_MARGIN,
741
+ );
742
+
743
+ const yTickItems = computed(() => {
744
+ const { min, max } = extent.value;
745
+ const fmt = (v: number) =>
746
+ props.yTickFormat !== undefined
747
+ ? formatNumber(v, props.yTickFormat)
748
+ : formatTick(v);
749
+
750
+ if (min === max) {
751
+ return [{ value: fmt(min), y: snap(padding.value.top + innerH.value / 2) }];
752
+ }
753
+
754
+ const values =
755
+ props.yScaleType === "log"
756
+ ? computeLogTickValues({ min, max, ticks: props.yTicks })
757
+ : computeTickValues({
758
+ min,
759
+ max,
760
+ ticks: props.yTicks,
761
+ targetTickCount: innerH.value / 50,
762
+ });
763
+ return values.map((v) => ({ value: fmt(v), y: snap(yPixel(v)) }));
764
+ });
765
+
766
+ /**
767
+ * Format a single x-axis value into its display label. Used by both
768
+ * tick rendering and the tooltip x-label so the two stay in sync. On
769
+ * a date axis, `unit` (when supplied) drives the default formatter's
770
+ * preset choice (year-tick → "year", month-tick → "month-year", etc.);
771
+ * the tooltip path leaves it undefined and gets the ISO default.
772
+ */
773
+ function formatXValue(v: number, i: number, unit?: DateTickUnit): string {
774
+ const xf = props.xTickFormat;
775
+ if (xIsDate.value) {
776
+ if (typeof xf === "function") {
777
+ return (xf as (v: number, i: number) => string)(v, i);
778
+ }
779
+ return formatDate(v, xf as DateFormat | undefined, tz.value, unit);
780
+ }
781
+ const display = v + xDisplayOffset.value;
782
+ if (xf !== undefined) {
783
+ return typeof xf === "function"
784
+ ? (xf as (v: number, i: number) => string)(display, i)
785
+ : formatNumber(display, xf as NumberFormat);
786
+ }
787
+ if (
788
+ !hasExplicitX.value &&
789
+ props.xLabels &&
790
+ Number.isInteger(v) &&
791
+ v >= 0 &&
792
+ v < props.xLabels.length
793
+ ) {
794
+ return props.xLabels[v];
795
+ }
796
+ return formatTick(display);
797
+ }
798
+
799
+ const xTickItems = computed(() => {
800
+ const { min: xMin, max: xMax } = xExtent.value;
801
+ if (xMin === xMax) return [];
802
+ const isDate = xIsDate.value;
803
+ // `xMin` (display offset) is meaningless on a date axis — every x
804
+ // value already lives in absolute ms.
805
+ const offset = isDate ? 0 : xDisplayOffset.value;
806
+ const len = maxLen.value;
807
+ const targetTickCount = innerW.value / 80;
808
+
809
+ let values: number[];
810
+ let unit: DateTickUnit | undefined;
811
+ if (isDate) {
812
+ const picked = pickDateTicks(xMin, xMax, targetTickCount, tz.value);
813
+ unit = picked.unit;
814
+ // Run through computeTickValues so the clip-to-range semantics
815
+ // match the numeric path. xMin is already 0, so `ticks` is used
816
+ // directly.
817
+ values = computeTickValues({ min: xMin, max: xMax, ticks: picked.values });
818
+ } else if (
819
+ props.xTicks == null &&
820
+ !hasExplicitX.value &&
821
+ props.xLabels &&
822
+ props.xLabels.length === len
823
+ ) {
824
+ // xLabels fallback: pick evenly-spaced index ticks so every label
825
+ // bucket gets at most one tick. Date mode supersedes xLabels — if
826
+ // both are supplied, only the date axis is used.
827
+ const targetTicks = Math.max(3, Math.floor(targetTickCount));
828
+ const step = Math.max(1, Math.round((len - 1) / targetTicks));
829
+ values = [];
830
+ for (let i = 0; i < len; i += step) values.push(i);
831
+ } else {
832
+ values = computeTickValues({
833
+ min: xMin,
834
+ max: xMax,
835
+ ticks: props.xTicks,
836
+ targetTickCount,
837
+ displayOffset: offset,
838
+ });
839
+ }
840
+
841
+ const leftEdge = padding.value.left;
842
+ const rightEdge = padding.value.left + innerW.value;
843
+ const edgeSnapPx = 1;
844
+ return values.map((v, i) => {
845
+ const x = snap(xPixel(v));
846
+ let anchor: "start" | "middle" | "end" = "middle";
847
+ if (x - leftEdge <= edgeSnapPx) anchor = "start";
848
+ else if (rightEdge - x <= edgeSnapPx) anchor = "end";
849
+ return { value: formatXValue(v, i, unit), x, anchor };
850
+ });
851
+ });
852
+
853
+ function toCsv(): string {
854
+ if (typeof props.csv === "function") return props.csv();
855
+ if (typeof props.csv === "string") return props.csv;
856
+ return seriesToCsv(allSeries.value);
857
+ }
858
+
859
+ const hasTooltipSlot = computed(
860
+ () => !!props.tooltipData || !!props.tooltipTrigger,
861
+ );
862
+
863
+ /** Data-space x of the hovered point (via the first series). */
864
+ const hoverDataX = computed(() => {
865
+ const idx = hoverIndex.value;
866
+ const s0 = allSeries.value[0];
867
+ if (idx === null || !s0) return null;
868
+ return seriesXAt(s0, idx);
869
+ });
870
+
871
+ const hoverX = computed(() =>
872
+ hoverDataX.value === null ? 0 : xPixel(hoverDataX.value),
873
+ );
874
+
875
+ /** Index of the series point closest to the given data-space x. */
876
+ function nearestIndex(s: ResolvedSeries, targetX: number): number | null {
877
+ const len = s.data.length;
878
+ if (len === 0) return null;
879
+ let bestIdx = 0;
880
+ let bestDist = Infinity;
881
+ for (let i = 0; i < len; i++) {
882
+ const svx = seriesXAt(s, i);
883
+ if (!isFinite(svx)) continue;
884
+ const d = Math.abs(svx - targetX);
885
+ if (d < bestDist) {
886
+ bestDist = d;
887
+ bestIdx = i;
888
+ }
889
+ }
890
+ return bestDist === Infinity ? null : bestIdx;
891
+ }
892
+
893
+ const hoverDots = computed(() => {
894
+ const targetX = hoverDataX.value;
895
+ if (targetX === null) return [];
896
+ const dots: { x: number; y: number; color: string }[] = [];
897
+ for (const s of allSeries.value) {
898
+ if (s.showInTooltip === false) continue;
899
+ const nIdx = nearestIndex(s, targetX);
900
+ if (nIdx === null) continue;
901
+ const yv = s.data[nIdx];
902
+ if (!isFinite(yv)) continue;
903
+ dots.push({
904
+ x: xPixel(seriesXAt(s, nIdx)),
905
+ y: yPixel(yv),
906
+ color: s.color ?? "currentColor",
907
+ });
908
+ }
909
+ return dots;
910
+ });
911
+
912
+ const hoverSlotProps = computed(() => {
913
+ const idx = hoverIndex.value;
914
+ const targetX = hoverDataX.value;
915
+ if (idx === null || targetX === null) return null;
916
+ // Single source of truth for label dispatch — same as the x-axis ticks.
917
+ const xLabel = formatXValue(targetX, idx);
918
+ const series = allSeries.value;
919
+ const values: ChartTooltipValue[] = [];
920
+ for (let i = 0; i < series.length; i++) {
921
+ const s = series[i];
922
+ if (s.showInTooltip === false) continue;
923
+ const nIdx = nearestIndex(s, targetX);
924
+ values.push({
925
+ value: nIdx !== null ? Number(s.data[nIdx]) : NaN,
926
+ color: s.color ?? "currentColor",
927
+ seriesIndex: i,
928
+ });
929
+ }
930
+ return {
931
+ index: idx,
932
+ xLabel,
933
+ values,
934
+ data: props.tooltipData?.[idx] ?? null,
935
+ };
936
+ });
937
+
938
+ function projectAnnotation(
939
+ x: number,
940
+ y: number,
941
+ ): { x: number; y: number } | null {
942
+ if (!isFinite(x) || !isFinite(y)) return null;
943
+ const internalX = x - xDisplayOffset.value;
944
+ return { x: xPixel(internalX), y: yPixel(y) };
945
+ }
946
+
947
+ function indexFromPointer(clientX: number): number | null {
948
+ const rect = containerRef.value?.getBoundingClientRect();
949
+ if (!rect) return null;
950
+ const s0 = allSeries.value[0];
951
+ if (!s0 || s0.data.length === 0) return null;
952
+ const { min: xMin, max: xMax } = xExtent.value;
953
+ const range = xMax - xMin || 1;
954
+ const mouseX = clientX - rect.left;
955
+ const targetX = xMin + ((mouseX - padding.value.left) / innerW.value) * range;
956
+ return nearestIndex(s0, targetX);
957
+ }
958
+
959
+ const {
960
+ containerRef,
961
+ svgRef,
962
+ width,
963
+ height,
964
+ padding,
965
+ legendY,
966
+ inlineLegendLayout,
967
+ innerW,
968
+ innerH,
969
+ bounds,
970
+ hoverIndex,
971
+ tooltipRef,
972
+ tooltipPos,
973
+ tooltipHandlers,
974
+ menuItems,
975
+ downloadLinkText,
976
+ csvHref,
977
+ downloadButtonText,
978
+ triggerCsvDownload,
979
+ menuFilename,
980
+ isFullscreen,
981
+ fullscreenStyle,
982
+ teleportTarget,
983
+ exitFullscreen,
984
+ } = useChartFoundation({
985
+ width: () => props.width,
986
+ height: () => props.height,
987
+ title: () => props.title,
988
+ titleStyle: () => props.titleStyle,
989
+ xLabel: () => props.xLabel,
990
+ yLabel: () => props.yLabel,
991
+ debounce: () => props.debounce,
992
+ menu: () => props.menu,
993
+ tooltipTrigger: () => props.tooltipTrigger,
994
+ tooltipClamp: () => props.tooltipClamp,
995
+ filename: () => props.filename,
996
+ downloadLink: () => props.downloadLink,
997
+ downloadButton: () => props.downloadButton,
998
+ fullscreenTarget: () => props.fullscreenTarget,
999
+ chartPadding: () => props.chartPadding,
1000
+ inlineLegendLabels: () => inlineLegendLabels.value,
1001
+ hasTooltipSlot: () => hasTooltipSlot.value,
1002
+ getCsv: toCsv,
1003
+ pointerToIndex: indexFromPointer,
1004
+ onHover: (payload) => emit("hover", payload),
1005
+ extraBelowHeight: () => sectionLabels.value.extraHeight,
1006
+ });
1007
+
1008
+ /** Resolved style for the x/y axis labels. */
1009
+ const axisLabelResolved = computed(() =>
1010
+ resolveLabelStyle(props.axisLabelStyle, { fontSize: AXIS_LABEL_FONT_SIZE }),
1011
+ );
1012
+ /** Resolved style for the axis tick labels. */
1013
+ const tickLabelResolved = computed(() =>
1014
+ resolveLabelStyle(props.tickLabelStyle, {
1015
+ fontSize: TICK_LABEL_FONT_SIZE,
1016
+ fillOpacity: TICK_LABEL_OPACITY,
1017
+ }),
1018
+ );
1019
+ /** Resolved style for inline legend item labels. */
1020
+ const legendResolved = computed(() =>
1021
+ resolveLabelStyle(props.legendStyle, { fontSize: LEGEND_FONT_SIZE }),
1022
+ );
1023
+
1024
+ /** Resolved title style with defaults applied. */
1025
+ const titleResolved = computed(() => {
1026
+ const s = props.titleStyle;
1027
+ const align = s?.align ?? "left";
1028
+ const b = bounds.value;
1029
+ const x =
1030
+ align === "left"
1031
+ ? b.left
1032
+ : align === "right"
1033
+ ? b.right
1034
+ : b.left + b.width / 2;
1035
+ const anchor =
1036
+ align === "left" ? "start" : align === "right" ? "end" : "middle";
1037
+ return {
1038
+ lines: (props.title ?? "").split("\n"),
1039
+ fontSize: s?.fontSize ?? TITLE_FONT_SIZE,
1040
+ lineHeight: s?.lineHeight ?? TITLE_LINE_HEIGHT,
1041
+ fontWeight: s?.fontWeight ?? TITLE_FONT_WEIGHT,
1042
+ color: s?.color ?? "currentColor",
1043
+ x,
1044
+ anchor,
1045
+ };
1046
+ });
1047
+
1048
+ /**
1049
+ * Legend items joined with their wrapped pixel positions. `x` is the
1050
+ * left edge of the indicator; `y` is the center of the row.
1051
+ */
1052
+ const positionedLegendItems = computed(() => {
1053
+ const positions = inlineLegendLayout.value.positions;
1054
+ const pad = padding.value.left;
1055
+ const baseY = legendY.value;
1056
+ return inlineLegendItems.value.map((item, i) => {
1057
+ const pos = positions[i];
1058
+ return {
1059
+ ...item,
1060
+ x: pad + pos.x,
1061
+ y: baseY + pos.row * INLINE_LEGEND_ROW_HEIGHT,
1062
+ };
1063
+ });
1064
+ });
1065
+ </script>
1066
+
1067
+ <template>
1068
+ <Teleport :to="teleportTarget" :disabled="!isFullscreen">
1069
+ <div
1070
+ ref="containerRef"
1071
+ v-bind="$attrs"
1072
+ class="line-chart-wrapper"
1073
+ :class="{ 'is-fullscreen': isFullscreen }"
1074
+ :style="fullscreenStyle"
1075
+ >
1076
+ <ChartMenu
1077
+ v-if="menu"
1078
+ :items="menuItems"
1079
+ :is-fullscreen="isFullscreen"
1080
+ @close="exitFullscreen"
1081
+ />
1082
+ <div class="chart-sr-only" aria-live="polite">
1083
+ {{ isFullscreen ? "Chart expanded to fill window" : "" }}
1084
+ </div>
1085
+ <svg
1086
+ ref="svgRef"
1087
+ :width="width"
1088
+ :height="totalHeight"
1089
+ :role="chartRole || undefined"
1090
+ :aria-label="chartAriaLabel || undefined"
1091
+ >
1092
+ <!-- title -->
1093
+ <text
1094
+ v-if="title"
1095
+ :x="titleResolved.x"
1096
+ :y="titleResolved.lineHeight"
1097
+ :text-anchor="titleResolved.anchor"
1098
+ :font-size="titleResolved.fontSize"
1099
+ :font-weight="titleResolved.fontWeight"
1100
+ :fill="titleResolved.color"
1101
+ >
1102
+ <tspan
1103
+ v-for="(line, i) in titleResolved.lines"
1104
+ :key="i"
1105
+ :x="titleResolved.x"
1106
+ :dy="i === 0 ? 0 : titleResolved.lineHeight"
1107
+ >
1108
+ {{ line }}
1109
+ </tspan>
1110
+ </text>
1111
+ <!-- inline legend -->
1112
+ <g v-if="positionedLegendItems.length > 0">
1113
+ <template
1114
+ v-for="(item, i) in positionedLegendItems"
1115
+ :key="'ileg' + i"
1116
+ >
1117
+ <!-- series indicator: line -->
1118
+ <line
1119
+ v-if="item.type === 'series'"
1120
+ :x1="item.x"
1121
+ :y1="item.y"
1122
+ :x2="item.x + 12"
1123
+ :y2="item.y"
1124
+ :stroke="item.color"
1125
+ stroke-width="2"
1126
+ :stroke-dasharray="item.dashed ? '4 2' : undefined"
1127
+ />
1128
+ <!-- area indicator: filled swatch -->
1129
+ <rect
1130
+ v-else-if="item.type === 'area'"
1131
+ :x="item.x"
1132
+ :y="item.y - 4"
1133
+ width="12"
1134
+ height="8"
1135
+ :fill="item.color"
1136
+ :fill-opacity="item.fillOpacity"
1137
+ :stroke="item.color"
1138
+ stroke-width="1.5"
1139
+ />
1140
+ <!-- section indicator: filled circle -->
1141
+ <circle
1142
+ v-else
1143
+ :cx="item.x + 4"
1144
+ :cy="item.y"
1145
+ r="4"
1146
+ :fill="item.color"
1147
+ :fill-opacity="item.fillOpacity"
1148
+ :stroke="item.color"
1149
+ stroke-width="1.5"
1150
+ />
1151
+ <text
1152
+ :x="item.x + 18"
1153
+ :y="item.y + 4"
1154
+ :font-size="legendResolved.fontSize"
1155
+ :fill="legendResolved.fill"
1156
+ :font-weight="legendResolved.fontWeight"
1157
+ >
1158
+ {{ item.label }}
1159
+ </text>
1160
+ </template>
1161
+ </g>
1162
+ <!-- axes -->
1163
+ <line
1164
+ :x1="snap(padding.left)"
1165
+ :y1="snap(padding.top)"
1166
+ :x2="snap(padding.left)"
1167
+ :y2="snap(padding.top + innerH)"
1168
+ stroke="currentColor"
1169
+ stroke-opacity="0.3"
1170
+ />
1171
+ <line
1172
+ :x1="snap(padding.left)"
1173
+ :y1="snap(padding.top + innerH)"
1174
+ :x2="snap(padding.left + innerW)"
1175
+ :y2="snap(padding.top + innerH)"
1176
+ stroke="currentColor"
1177
+ stroke-opacity="0.3"
1178
+ />
1179
+ <!-- y grid lines -->
1180
+ <template v-if="yGrid">
1181
+ <line
1182
+ v-for="(tick, i) in yTickItems"
1183
+ :key="'yg' + i"
1184
+ :x1="padding.left"
1185
+ :y1="tick.y"
1186
+ :x2="padding.left + innerW"
1187
+ :y2="tick.y"
1188
+ stroke="currentColor"
1189
+ stroke-opacity="0.1"
1190
+ />
1191
+ </template>
1192
+ <!-- x grid lines -->
1193
+ <template v-if="xGrid">
1194
+ <line
1195
+ v-for="(tick, i) in xTickItems"
1196
+ :key="'xg' + i"
1197
+ :x1="tick.x"
1198
+ :y1="padding.top"
1199
+ :x2="tick.x"
1200
+ :y2="padding.top + innerH"
1201
+ stroke="currentColor"
1202
+ stroke-opacity="0.1"
1203
+ />
1204
+ </template>
1205
+ <!-- y tick labels -->
1206
+ <text
1207
+ v-for="(tick, i) in yTickItems"
1208
+ :key="'y' + i"
1209
+ data-testid="y-tick"
1210
+ :x="padding.left - 6"
1211
+ :y="tick.y"
1212
+ text-anchor="end"
1213
+ dominant-baseline="middle"
1214
+ :font-size="tickLabelResolved.fontSize"
1215
+ :fill="tickLabelResolved.fill"
1216
+ :font-weight="tickLabelResolved.fontWeight"
1217
+ :fill-opacity="tickLabelResolved.fillOpacity"
1218
+ >
1219
+ {{ tick.value }}
1220
+ </text>
1221
+ <!-- y axis label -->
1222
+ <text
1223
+ v-if="yLabel"
1224
+ :x="0"
1225
+ :y="0"
1226
+ :transform="`translate(14, ${padding.top + innerH / 2}) rotate(-90)`"
1227
+ text-anchor="middle"
1228
+ :font-size="axisLabelResolved.fontSize"
1229
+ :fill="axisLabelResolved.fill"
1230
+ :font-weight="axisLabelResolved.fontWeight"
1231
+ >
1232
+ {{ yLabel }}
1233
+ </text>
1234
+ <!-- x tick labels -->
1235
+ <text
1236
+ v-for="(tick, i) in xTickItems"
1237
+ :key="'x' + i"
1238
+ data-testid="x-tick"
1239
+ :x="tick.x"
1240
+ :y="padding.top + innerH + 16"
1241
+ :text-anchor="tick.anchor"
1242
+ :font-size="tickLabelResolved.fontSize"
1243
+ :fill="tickLabelResolved.fill"
1244
+ :font-weight="tickLabelResolved.fontWeight"
1245
+ :fill-opacity="tickLabelResolved.fillOpacity"
1246
+ >
1247
+ {{ tick.value }}
1248
+ </text>
1249
+ <!-- x axis label -->
1250
+ <text
1251
+ v-if="xLabel"
1252
+ :x="padding.left + innerW / 2"
1253
+ :y="height - 4"
1254
+ text-anchor="middle"
1255
+ :font-size="axisLabelResolved.fontSize"
1256
+ :fill="axisLabelResolved.fill"
1257
+ :font-weight="axisLabelResolved.fontWeight"
1258
+ >
1259
+ {{ xLabel }}
1260
+ </text>
1261
+ <!-- areas -->
1262
+ <path
1263
+ v-for="(a, i) in allAreas"
1264
+ :key="'area' + i"
1265
+ :d="toAreaPath(a)"
1266
+ :fill="a.color ?? 'currentColor'"
1267
+ :fill-opacity="a.opacity ?? 0.2"
1268
+ stroke="none"
1269
+ :style="a.blendMode ? { mixBlendMode: a.blendMode } : undefined"
1270
+ />
1271
+ <!-- data lines and dots -->
1272
+ <template v-for="(s, i) in allSeries" :key="i">
1273
+ <path
1274
+ v-if="s.line !== false && s.outline"
1275
+ :d="toPath(s)"
1276
+ fill="none"
1277
+ :stroke="s.outlineColor ?? 'var(--color-bg-0, #fff)'"
1278
+ :stroke-width="(s.strokeWidth ?? 1.5) + (s.outlineWidth ?? 4)"
1279
+ stroke-linecap="round"
1280
+ stroke-linejoin="round"
1281
+ data-testid="line-outline"
1282
+ />
1283
+ <path
1284
+ v-if="s.line !== false"
1285
+ :d="toPath(s)"
1286
+ fill="none"
1287
+ :stroke="s.color ?? 'currentColor'"
1288
+ :stroke-width="s.strokeWidth ?? 1.5"
1289
+ :stroke-opacity="s.lineOpacity ?? s.opacity ?? lineOpacity"
1290
+ :stroke-dasharray="s.dashed ? '6 3' : undefined"
1291
+ :style="s.blendMode ? { mixBlendMode: s.blendMode } : undefined"
1292
+ />
1293
+ <template v-if="s.dots">
1294
+ <circle
1295
+ v-for="(pt, j) in toPoints(s)"
1296
+ :key="j"
1297
+ :cx="pt.x"
1298
+ :cy="pt.y"
1299
+ :r="s.dotRadius ?? (s.strokeWidth ?? 1.5) + 1"
1300
+ :fill="s.dotFill ?? s.color ?? 'currentColor'"
1301
+ :fill-opacity="s.dotOpacity ?? s.opacity ?? lineOpacity"
1302
+ :stroke="s.dotStroke ?? 'none'"
1303
+ :style="s.blendMode ? { mixBlendMode: s.blendMode } : undefined"
1304
+ />
1305
+ </template>
1306
+ </template>
1307
+ <!-- area sections (rendered above series) -->
1308
+ <template v-for="(sec, i) in areaSections ?? []" :key="'areasec' + i">
1309
+ <path
1310
+ :d="toSectionPath(sec)"
1311
+ :fill="
1312
+ sec.color ??
1313
+ (sec.seriesIndex != null
1314
+ ? (allSeries[sec.seriesIndex]?.color ?? 'currentColor')
1315
+ : '#999')
1316
+ "
1317
+ :fill-opacity="sec.opacity ?? 0.15"
1318
+ stroke="none"
1319
+ />
1320
+ <path
1321
+ v-if="sec.seriesIndex != null"
1322
+ :d="toSectionPath(sec, false)"
1323
+ fill="none"
1324
+ :stroke="
1325
+ sec.color ?? allSeries[sec.seriesIndex]?.color ?? 'currentColor'
1326
+ "
1327
+ :stroke-width="sec.strokeWidth ?? 2"
1328
+ :stroke-dasharray="sec.dashed ? '6 3' : undefined"
1329
+ />
1330
+ <!-- vertical edge lines for full-height sections -->
1331
+ <template v-if="sec.seriesIndex == null">
1332
+ <line
1333
+ :x1="snap(sectionXPixel(sec, 'start'))"
1334
+ :y1="padding.top"
1335
+ :x2="snap(sectionXPixel(sec, 'start'))"
1336
+ :y2="padding.top + innerH"
1337
+ :stroke="sec.color ?? '#999'"
1338
+ :stroke-width="sec.strokeWidth ?? 2"
1339
+ :stroke-dasharray="sec.dashed ? '6 3' : undefined"
1340
+ />
1341
+ <line
1342
+ :x1="snap(sectionXPixel(sec, 'end'))"
1343
+ :y1="padding.top"
1344
+ :x2="snap(sectionXPixel(sec, 'end'))"
1345
+ :y2="padding.top + innerH"
1346
+ :stroke="sec.color ?? '#999'"
1347
+ :stroke-width="sec.strokeWidth ?? 2"
1348
+ :stroke-dasharray="sec.dashed ? '6 3' : undefined"
1349
+ />
1350
+ </template>
1351
+ <!-- tick marks at section boundaries -->
1352
+ <line
1353
+ :x1="snap(sectionXPixel(sec, 'start'))"
1354
+ :y1="padding.top + innerH - 4"
1355
+ :x2="snap(sectionXPixel(sec, 'start'))"
1356
+ :y2="padding.top + innerH + 4"
1357
+ stroke="currentColor"
1358
+ stroke-opacity="0.4"
1359
+ />
1360
+ <line
1361
+ :x1="snap(sectionXPixel(sec, 'end'))"
1362
+ :y1="padding.top + innerH - 4"
1363
+ :x2="snap(sectionXPixel(sec, 'end'))"
1364
+ :y2="padding.top + innerH + 4"
1365
+ stroke="currentColor"
1366
+ stroke-opacity="0.4"
1367
+ />
1368
+ </template>
1369
+ <!-- Tooltip: crosshair line -->
1370
+ <line
1371
+ v-if="hasTooltipSlot && hoverIndex !== null"
1372
+ :x1="snap(hoverX)"
1373
+ :y1="padding.top"
1374
+ :x2="snap(hoverX)"
1375
+ :y2="padding.top + innerH"
1376
+ stroke="currentColor"
1377
+ stroke-opacity="0.3"
1378
+ stroke-dasharray="4 2"
1379
+ pointer-events="none"
1380
+ />
1381
+ <!-- Tooltip: hover dots -->
1382
+ <circle
1383
+ v-for="(dot, i) in hoverDots"
1384
+ :key="'hd' + i"
1385
+ :cx="dot.x"
1386
+ :cy="dot.y"
1387
+ r="4"
1388
+ :fill="dot.color"
1389
+ stroke="var(--color-bg-0, #fff)"
1390
+ stroke-width="2"
1391
+ pointer-events="none"
1392
+ />
1393
+ <!-- Tooltip: interaction overlay -->
1394
+ <rect
1395
+ v-if="hasTooltipSlot"
1396
+ :x="padding.left"
1397
+ :y="padding.top"
1398
+ :width="innerW"
1399
+ :height="innerH"
1400
+ fill="transparent"
1401
+ style="cursor: crosshair; touch-action: pan-y"
1402
+ v-on="tooltipHandlers"
1403
+ />
1404
+ <!-- annotations (top layer) -->
1405
+ <ChartAnnotations
1406
+ v-if="annotations && annotations.length > 0"
1407
+ :annotations="annotations"
1408
+ :project="projectAnnotation"
1409
+ :bounds="bounds"
1410
+ />
1411
+ <!-- area section labels -->
1412
+ <g v-for="(item, i) in sectionLabels.labels" :key="'seclab' + i">
1413
+ <circle
1414
+ :cx="item.cx - item.textWidth / 2 - 2"
1415
+ :cy="sectionLabelBaseY + item.row * SECTION_LABEL_ROW_HEIGHT + 4"
1416
+ r="4"
1417
+ :fill="item.color"
1418
+ :fill-opacity="item.fillOpacity"
1419
+ :stroke="item.color"
1420
+ stroke-width="1.5"
1421
+ />
1422
+ <text
1423
+ v-if="item.labelText"
1424
+ :x="item.cx - item.textWidth / 2 + 8"
1425
+ :y="sectionLabelBaseY + item.row * SECTION_LABEL_ROW_HEIGHT + 8"
1426
+ :font-size="item.labelStyle.fontSize"
1427
+ :font-weight="item.labelStyle.fontWeight"
1428
+ :fill="item.labelStyle.fill"
1429
+ >
1430
+ {{ item.labelText }}
1431
+ </text>
1432
+ <text
1433
+ v-if="item.descText"
1434
+ :x="item.cx - item.textWidth / 2 + 8"
1435
+ :y="sectionLabelBaseY + item.row * SECTION_LABEL_ROW_HEIGHT + 22"
1436
+ :font-size="item.descStyle.fontSize"
1437
+ :font-weight="item.descStyle.fontWeight"
1438
+ :fill="item.descStyle.fill"
1439
+ :fill-opacity="item.descStyle.fillOpacity"
1440
+ >
1441
+ {{ item.descText }}
1442
+ </text>
1443
+ </g>
1444
+ </svg>
1445
+ <!-- Tooltip floating content -->
1446
+ <div
1447
+ v-if="hasTooltipSlot && hoverIndex !== null && hoverSlotProps"
1448
+ ref="tooltipRef"
1449
+ class="chart-tooltip-content"
1450
+ :style="{
1451
+ position: 'absolute',
1452
+ top: '0',
1453
+ left: '0',
1454
+ willChange: 'transform',
1455
+ transform: tooltipPos
1456
+ ? `translate3d(${tooltipPos.left}px, ${tooltipPos.top}px, 0) translateY(-50%)`
1457
+ : 'translateY(-50%)',
1458
+ visibility: tooltipPos ? 'visible' : 'hidden',
1459
+ }"
1460
+ >
1461
+ <slot name="tooltip" v-bind="hoverSlotProps">
1462
+ <div class="line-chart-tooltip">
1463
+ <div v-if="hoverSlotProps.xLabel" class="line-chart-tooltip-label">
1464
+ {{ hoverSlotProps.xLabel }}
1465
+ </div>
1466
+ <div
1467
+ v-for="v in hoverSlotProps.values"
1468
+ :key="v.seriesIndex"
1469
+ class="line-chart-tooltip-row"
1470
+ >
1471
+ <span
1472
+ class="line-chart-tooltip-swatch"
1473
+ :style="{ background: v.color }"
1474
+ />
1475
+ {{ isFinite(v.value) ? formatTooltipValue(v.value) : "—" }}
1476
+ </div>
1477
+ </div>
1478
+ </slot>
1479
+ </div>
1480
+ <a
1481
+ v-if="downloadLinkText"
1482
+ class="line-chart-download-link"
1483
+ :href="csvHref!"
1484
+ :download="`${menuFilename()}.csv`"
1485
+ >
1486
+ {{ downloadLinkText }}
1487
+ </a>
1488
+ <button
1489
+ v-if="downloadButtonText"
1490
+ type="button"
1491
+ class="line-chart-download-button"
1492
+ @click="triggerCsvDownload"
1493
+ >
1494
+ {{ downloadButtonText }}
1495
+ </button>
1496
+ </div>
1497
+ </Teleport>
1498
+ </template>
1499
+
1500
+ <style scoped>
1501
+ .line-chart-wrapper {
1502
+ position: relative;
1503
+ width: 100%;
1504
+ }
1505
+
1506
+ .line-chart-tooltip-label {
1507
+ font-weight: 600;
1508
+ margin-bottom: 0.25em;
1509
+ }
1510
+
1511
+ .line-chart-tooltip-row {
1512
+ display: flex;
1513
+ align-items: center;
1514
+ gap: 0.375em;
1515
+ }
1516
+
1517
+ .line-chart-download-link {
1518
+ display: block;
1519
+ text-align: right;
1520
+ font-size: var(--font-size-sm);
1521
+ margin-top: 0.25em;
1522
+ }
1523
+
1524
+ .line-chart-tooltip-swatch {
1525
+ display: inline-block;
1526
+ width: 0.625em;
1527
+ height: 0.625em;
1528
+ border-radius: 50%;
1529
+ flex-shrink: 0;
1530
+ }
1531
+ </style>
1532
+
1533
+ <style>
1534
+ .line-chart-download-button {
1535
+ display: inline-flex;
1536
+ align-items: center;
1537
+ margin-top: 0.5em;
1538
+ padding: 0.5em 1em;
1539
+ border: 1px solid var(--color-border);
1540
+ border-radius: 0.25em;
1541
+ background: var(--color-bg-0, #fff);
1542
+ color: var(--color-text);
1543
+ font-size: var(--font-size-sm);
1544
+ cursor: pointer;
1545
+ }
1546
+
1547
+ .line-chart-download-button:hover {
1548
+ background: var(--color-bg-1, rgba(0, 0, 0, 0.05));
1549
+ }
1550
+
1551
+ .line-chart-download-button:focus-visible {
1552
+ outline: 2px solid var(--color-primary);
1553
+ outline-offset: 2px;
1554
+ }
1555
+ </style>