@cfasim-ui/docs 0.3.12 → 0.3.14

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.
@@ -24,13 +24,48 @@ A responsive SVG line chart with support for multiple series, axis labels, and c
24
24
  </template>
25
25
  </ComponentDemo>
26
26
 
27
+ ### x/y
28
+
29
+ Pass paired `x` and `y` arrays to plot points at specific x positions.
30
+ `y` is equivalent to `data` (both names are accepted), and they both
31
+ take typed arrays. For multi-series
32
+ charts, set `x` and `y` (or `data`) on each `Series`.
33
+
34
+ <ComponentDemo>
35
+ <LineChart
36
+ :x="[0, 1, 2, 5, 10, 20, 50]"
37
+ :y="[0, 2, 5, 12, 22, 30, 38]"
38
+ :height="200"
39
+ x-label="Days (log-ish)"
40
+ y-label="Cases"
41
+ tooltip-trigger="hover"
42
+ />
43
+
44
+ <template #code>
45
+
46
+ ```vue
47
+ <LineChart
48
+ :x="[0, 1, 2, 5, 10, 20, 50]"
49
+ :y="[0, 2, 5, 12, 22, 30, 38]"
50
+ :height="200"
51
+ x-label="Days"
52
+ y-label="Cases"
53
+ tooltip-trigger="hover"
54
+ />
55
+ ```
56
+
57
+ </template>
58
+ </ComponentDemo>
59
+
60
+ When `x` is omitted, `y`/`data` values are plotted at indices 0, 1, 2, etc.
61
+
27
62
  ### Multiple series
28
63
 
29
64
  <ComponentDemo>
30
65
  <LineChart
31
66
  :series="[
32
67
  { data: [0, 10, 25, 45, 60, 55, 40, 20, 8], color: '#fb7e38', strokeWidth: 3 },
33
- { data: [0, 5, 12, 20, 28, 25, 18, 10, 4], color: '#0057b7', strokeWidth: 3 },
68
+ { x: [0, 1, 3, 4, 6, 7, 8], y: [0, 5, 20, 28, 18, 10, 4], color: '#0057b7', strokeWidth: 3 },
34
69
  ]"
35
70
  :height="200"
36
71
  x-label="Weeks"
@@ -48,7 +83,8 @@ A responsive SVG line chart with support for multiple series, axis labels, and c
48
83
  strokeWidth: 3,
49
84
  },
50
85
  {
51
- data: [0, 5, 12, 20, 28, 25, 18, 10, 4],
86
+ x: [0, 1, 3, 4, 6, 7, 8],
87
+ y: [0, 5, 20, 28, 18, 10, 4],
52
88
  color: '#0057b7',
53
89
  strokeWidth: 3,
54
90
  },
@@ -408,7 +444,9 @@ until the user clicks Download:
408
444
 
409
445
  | Prop | Type | Required | Default |
410
446
  |------|------|----------|---------|
411
- | `data` | `number[]` | No | — |
447
+ | `y` | `LineChartData` | No | — |
448
+ | `data` | `LineChartData` | No | — |
449
+ | `x` | `LineChartData` | No | — |
412
450
  | `series` | `Series[]` | No | — |
413
451
  | `areas` | `Area[]` | No | — |
414
452
  | `areaSections` | `AreaSection[]` | No | — |
@@ -437,11 +475,38 @@ until the user clicks Download:
437
475
  | `downloadLink` | `boolean \| string` | No | — |
438
476
 
439
477
 
478
+ ### Data
479
+
480
+ `data`, `series[].data`, and `areas[].upper`/`lower` accept a plain
481
+ `number[]` or any standard numeric typed array (`Float64Array`,
482
+ `Int32Array`, etc.). This lets you pass the output of
483
+ `ModelOutput.column()` directly — no `Array.from(...)` copy is needed:
484
+
485
+ ```vue
486
+ <LineChart :data="outputs.series.column('values')" />
487
+ ```
488
+
489
+ ```ts
490
+ type LineChartData =
491
+ | readonly number[]
492
+ | Float64Array
493
+ | Float32Array
494
+ | Int32Array
495
+ | Uint32Array
496
+ | Int16Array
497
+ | Uint16Array
498
+ | Int8Array
499
+ | Uint8Array
500
+ | Uint8ClampedArray;
501
+ ```
502
+
440
503
  ### Series
441
504
 
442
505
  ```ts
443
506
  interface Series {
444
- data: number[];
507
+ y?: LineChartData; // y-values (preferred)
508
+ data?: LineChartData; // y-values (alternative name; one of y/data must be set)
509
+ x?: LineChartData; // optional parallel x-values
445
510
  color?: string;
446
511
  dashed?: boolean;
447
512
  strokeWidth?: number;
@@ -5,8 +5,38 @@ import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
5
5
  import { saveSvg, savePng, downloadCsv } from "../ChartMenu/download.js";
6
6
  import { placeTooltip } from "../tooltip-position.js";
7
7
 
8
+ /**
9
+ * Numeric input accepted by the chart. `number[]` and any standard numeric
10
+ * typed array are supported, so the output of
11
+ * `ModelOutput.column('x')` (e.g. a `Float64Array`) can be passed directly
12
+ * without copying into a plain array.
13
+ */
14
+ export type LineChartData =
15
+ | readonly number[]
16
+ | Float64Array
17
+ | Float32Array
18
+ | Int32Array
19
+ | Uint32Array
20
+ | Int16Array
21
+ | Uint16Array
22
+ | Int8Array
23
+ | Uint8Array
24
+ | Uint8ClampedArray;
25
+
8
26
  export interface Series {
9
- data: number[];
27
+ /**
28
+ * Y-values. One of `y` or `data` must be supplied; `y` wins if both
29
+ * are set.
30
+ */
31
+ y?: LineChartData;
32
+ /** Y-values (alternative name for `y`). */
33
+ data?: LineChartData;
34
+ /**
35
+ * Optional x-values, parallel to `y`/`data`. When set, the chart
36
+ * plots points at the given x positions (irregular spacing supported).
37
+ * When omitted, points are plotted at indices 0, 1, 2, ...
38
+ */
39
+ x?: LineChartData;
10
40
  color?: string;
11
41
  dashed?: boolean;
12
42
  strokeWidth?: number;
@@ -23,8 +53,10 @@ export interface Series {
23
53
  }
24
54
 
25
55
  export interface Area {
26
- upper: number[];
27
- lower: number[];
56
+ upper: LineChartData;
57
+ lower: LineChartData;
58
+ /** Optional x-values parallel to `upper`/`lower`. See `Series.x`. */
59
+ x?: LineChartData;
28
60
  color?: string;
29
61
  opacity?: number;
30
62
  }
@@ -54,7 +86,16 @@ export interface AreaSection {
54
86
 
55
87
  const props = withDefaults(
56
88
  defineProps<{
57
- data?: number[];
89
+ /** Y-values. Equivalent to `data`. If both are set, `y` wins. */
90
+ y?: LineChartData;
91
+ /** Y-values (alternative name for `y`). */
92
+ data?: LineChartData;
93
+ /**
94
+ * Optional x-values paired with `y`/`data`. When provided, points
95
+ * are plotted at the given x positions instead of at their indices.
96
+ * Ignored when `series` is used — set `x` on each `Series` instead.
97
+ */
98
+ x?: LineChartData;
58
99
  series?: Series[];
59
100
  areas?: Area[];
60
101
  areaSections?: AreaSection[];
@@ -65,6 +106,11 @@ const props = withDefaults(
65
106
  xLabel?: string;
66
107
  yLabel?: string;
67
108
  yMin?: number;
109
+ /**
110
+ * Offset applied to index-based x values (e.g. `xMin: 10` starts the
111
+ * x axis at 10 instead of 0). Ignored when any series or area has
112
+ * explicit `x` values.
113
+ */
68
114
  xMin?: number;
69
115
  /**
70
116
  * Tick placement on the x-axis. Number = interval in data units
@@ -193,9 +239,23 @@ const innerH = computed(
193
239
  () => height.value - padding.value.top - padding.value.bottom,
194
240
  );
195
241
 
196
- const allSeries = computed<Series[]>(() => {
197
- if (props.series && props.series.length > 0) return props.series;
198
- if (props.data) return [{ data: props.data }];
242
+ /**
243
+ * Internal series shape where `data` (y-values) is always resolved.
244
+ * `Series.y` takes precedence over `Series.data` when both are set.
245
+ */
246
+ type ResolvedSeries = Omit<Series, "data" | "y"> & { data: LineChartData };
247
+
248
+ const EMPTY_DATA: readonly number[] = [];
249
+
250
+ function resolveSeries(s: Series): ResolvedSeries {
251
+ return { ...s, data: s.y ?? s.data ?? EMPTY_DATA };
252
+ }
253
+
254
+ const allSeries = computed<ResolvedSeries[]>(() => {
255
+ if (props.series && props.series.length > 0)
256
+ return props.series.map(resolveSeries);
257
+ const topY = props.y ?? props.data;
258
+ if (topY) return [{ data: topY, x: props.x }];
199
259
  return [];
200
260
  });
201
261
 
@@ -213,6 +273,62 @@ const maxLen = computed(() => {
213
273
  return m;
214
274
  });
215
275
 
276
+ /** True when any series/area supplies explicit x-values (irregular x mode). */
277
+ const hasExplicitX = computed(
278
+ () =>
279
+ allSeries.value.some((s) => s.x != null) ||
280
+ allAreas.value.some((a) => a.x != null),
281
+ );
282
+
283
+ /** Data-space x value for the i-th point of a series. */
284
+ function seriesXAt(s: { x?: LineChartData }, i: number): number {
285
+ return s.x ? Number(s.x[i]) : i;
286
+ }
287
+
288
+ /** Data-space x value for the i-th point of an area. */
289
+ function areaXAt(a: Area, i: number): number {
290
+ return a.x ? Number(a.x[i]) : i;
291
+ }
292
+
293
+ /**
294
+ * Display-only offset: added to tick values and tooltip x-labels so
295
+ * `xMin: 10` with index-based data shows "10, 11, …" without changing
296
+ * where points are drawn. Ignored when explicit `x` is provided.
297
+ */
298
+ const xDisplayOffset = computed(() =>
299
+ hasExplicitX.value ? 0 : (props.xMin ?? 0),
300
+ );
301
+
302
+ const xExtent = computed(() => {
303
+ let min = Infinity;
304
+ let max = -Infinity;
305
+ for (const s of allSeries.value) {
306
+ for (let i = 0; i < s.data.length; i++) {
307
+ const v = seriesXAt(s, i);
308
+ if (!isFinite(v)) continue;
309
+ if (v < min) min = v;
310
+ if (v > max) max = v;
311
+ }
312
+ }
313
+ for (const a of allAreas.value) {
314
+ const n = Math.max(a.upper.length, a.lower.length);
315
+ for (let i = 0; i < n; i++) {
316
+ const v = areaXAt(a, i);
317
+ if (!isFinite(v)) continue;
318
+ if (v < min) min = v;
319
+ if (v > max) max = v;
320
+ }
321
+ }
322
+ if (!isFinite(min)) return { min: 0, max: 0 };
323
+ return { min, max };
324
+ });
325
+
326
+ function xPixel(v: number): number {
327
+ const { min, max } = xExtent.value;
328
+ const range = max - min || 1;
329
+ return padding.value.left + ((v - min) / range) * innerW.value;
330
+ }
331
+
216
332
  const extent = computed(() => {
217
333
  let min = Infinity;
218
334
  let max = -Infinity;
@@ -240,21 +356,21 @@ const extent = computed(() => {
240
356
  return { min, max, range: max - min || 1 };
241
357
  });
242
358
 
243
- function toPath(data: number[]): string {
359
+ function toPath(s: ResolvedSeries): string {
360
+ const data = s.data;
244
361
  if (data.length === 0) return "";
245
362
  const { min, range } = extent.value;
246
- const len = maxLen.value;
247
- const xScale = innerW.value / (len - 1 || 1);
248
363
  const yScale = innerH.value / range;
249
364
  const py = padding.value.top + innerH.value;
250
365
  let d = "";
251
366
  let inSegment = false;
252
367
  for (let i = 0; i < data.length; i++) {
253
- if (!isFinite(data[i])) {
368
+ const xv = seriesXAt(s, i);
369
+ if (!isFinite(data[i]) || !isFinite(xv)) {
254
370
  inSegment = false;
255
371
  continue;
256
372
  }
257
- const x = padding.value.left + i * xScale;
373
+ const x = xPixel(xv);
258
374
  const y = py - (data[i] - min) * yScale;
259
375
  d += inSegment ? `L${x},${y}` : `M${x},${y}`;
260
376
  inSegment = true;
@@ -262,38 +378,36 @@ function toPath(data: number[]): string {
262
378
  return d;
263
379
  }
264
380
 
265
- function toPoints(data: number[]): { x: number; y: number }[] {
381
+ function toPoints(s: ResolvedSeries): { x: number; y: number }[] {
382
+ const data = s.data;
266
383
  const { min, range } = extent.value;
267
- const len = maxLen.value;
268
- const xScale = innerW.value / (len - 1 || 1);
269
384
  const yScale = innerH.value / range;
270
385
  const py = padding.value.top + innerH.value;
271
386
  const pts: { x: number; y: number }[] = [];
272
387
  for (let i = 0; i < data.length; i++) {
273
- if (!isFinite(data[i])) continue;
274
- pts.push({
275
- x: padding.value.left + i * xScale,
276
- y: py - (data[i] - min) * yScale,
277
- });
388
+ const xv = seriesXAt(s, i);
389
+ if (!isFinite(data[i]) || !isFinite(xv)) continue;
390
+ pts.push({ x: xPixel(xv), y: py - (data[i] - min) * yScale });
278
391
  }
279
392
  return pts;
280
393
  }
281
394
 
282
- function toAreaPath(upper: number[], lower: number[]): string {
283
- const len = Math.min(upper.length, lower.length);
395
+ function toAreaPath(a: Area): string {
396
+ const len = Math.min(a.upper.length, a.lower.length);
284
397
  if (len === 0) return "";
285
398
  const { min, range } = extent.value;
286
- const ml = maxLen.value;
287
- const xScale = innerW.value / (ml - 1 || 1);
288
399
  const yScale = innerH.value / range;
289
400
  const py = padding.value.top + innerH.value;
290
- const x = (i: number) => padding.value.left + i * xScale;
291
401
  const y = (v: number) => py - (v - min) * yScale;
292
- // Collect contiguous segments where both upper and lower are finite
402
+ // Collect contiguous segments where both upper/lower and x are finite
293
403
  const segments: number[][] = [];
294
404
  let seg: number[] = [];
295
405
  for (let i = 0; i < len; i++) {
296
- if (isFinite(upper[i]) && isFinite(lower[i])) {
406
+ if (
407
+ isFinite(a.upper[i]) &&
408
+ isFinite(a.lower[i]) &&
409
+ isFinite(areaXAt(a, i))
410
+ ) {
297
411
  seg.push(i);
298
412
  } else if (seg.length) {
299
413
  segments.push(seg);
@@ -303,27 +417,39 @@ function toAreaPath(upper: number[], lower: number[]): string {
303
417
  if (seg.length) segments.push(seg);
304
418
  let d = "";
305
419
  for (const s of segments) {
306
- d += `M${x(s[0])},${y(upper[s[0]])}`;
307
- for (let j = 1; j < s.length; j++) d += `L${x(s[j])},${y(upper[s[j]])}`;
420
+ d += `M${xPixel(areaXAt(a, s[0]))},${y(a.upper[s[0]])}`;
421
+ for (let j = 1; j < s.length; j++)
422
+ d += `L${xPixel(areaXAt(a, s[j]))},${y(a.upper[s[j]])}`;
308
423
  for (let j = s.length - 1; j >= 0; j--)
309
- d += `L${x(s[j])},${y(lower[s[j]])}`;
424
+ d += `L${xPixel(areaXAt(a, s[j]))},${y(a.lower[s[j]])}`;
310
425
  d += "Z";
311
426
  }
312
427
  return d;
313
428
  }
314
429
 
430
+ /**
431
+ * Pixel x of a section boundary. Maps through the referenced series'
432
+ * `x` array when available, then falls back to series 0, then to the
433
+ * raw index (index-mode).
434
+ */
435
+ function sectionXPixel(section: AreaSection, which: "start" | "end"): number {
436
+ const idx = which === "start" ? section.startIndex : section.endIndex;
437
+ const s =
438
+ (section.seriesIndex != null && allSeries.value[section.seriesIndex]) ||
439
+ allSeries.value[0];
440
+ if (s) return xPixel(seriesXAt(s, idx));
441
+ return xPixel(idx);
442
+ }
443
+
315
444
  function toSectionPath(section: AreaSection, closed = true): string {
316
- const len = maxLen.value;
317
- const xScale = innerW.value / (len - 1 || 1);
318
445
  const py = padding.value.top + innerH.value;
319
- const x = (i: number) => padding.value.left + i * xScale;
320
446
 
321
447
  // No seriesIndex: full-height rectangle spanning the range
322
448
  if (section.seriesIndex == null) {
323
- const start = Math.max(0, section.startIndex);
324
- const end = Math.min(len - 1, section.endIndex);
325
- if (start > end) return "";
326
- return `M${x(start)},${padding.value.top}L${x(end)},${padding.value.top}L${x(end)},${py}L${x(start)},${py}Z`;
449
+ const sx = sectionXPixel(section, "start");
450
+ const ex = sectionXPixel(section, "end");
451
+ if (sx > ex) return "";
452
+ return `M${sx},${padding.value.top}L${ex},${padding.value.top}L${ex},${py}L${sx},${py}Z`;
327
453
  }
328
454
 
329
455
  const s = allSeries.value[section.seriesIndex];
@@ -336,14 +462,14 @@ function toSectionPath(section: AreaSection, closed = true): string {
336
462
  const end = Math.min(s.data.length - 1, section.endIndex);
337
463
  if (start > end) return "";
338
464
 
339
- let d = `M${x(start)},${y(s.data[start])}`;
465
+ let d = `M${xPixel(seriesXAt(s, start))},${y(s.data[start])}`;
340
466
  for (let i = start + 1; i <= end; i++) {
341
467
  if (!isFinite(s.data[i])) continue;
342
- d += `L${x(i)},${y(s.data[i])}`;
468
+ d += `L${xPixel(seriesXAt(s, i))},${y(s.data[i])}`;
343
469
  }
344
470
  if (closed) {
345
- d += `L${x(end)},${py}`;
346
- d += `L${x(start)},${py}`;
471
+ d += `L${xPixel(seriesXAt(s, end))},${py}`;
472
+ d += `L${xPixel(seriesXAt(s, start))},${py}`;
347
473
  d += "Z";
348
474
  }
349
475
  return d;
@@ -371,19 +497,24 @@ const sectionLabels = computed<{
371
497
  const sections = props.areaSections;
372
498
  if (!sections?.length) return { labels: [], extraHeight: 0 };
373
499
 
374
- const len = maxLen.value;
375
- const xScale = innerW.value / (len - 1 || 1);
376
-
377
500
  const items: PositionedSectionLabel[] = [];
501
+ const chartRight = padding.value.left + innerW.value;
378
502
  for (const sec of sections) {
379
503
  if (!sec.label && !sec.description) continue;
380
504
  if (sec.legend === "inline" || sec.legend === false) continue;
381
- const cx =
382
- padding.value.left + ((sec.startIndex + sec.endIndex) / 2) * xScale;
383
505
  const labelText = sec.label ?? "";
384
506
  const descText = sec.description ?? "";
385
507
  const maxChars = Math.max(labelText.length, descText.length);
386
508
  const textWidth = maxChars * SECTION_LABEL_CHAR_WIDTH;
509
+ // Anchor the indicator circle to the start of the area. The circle is
510
+ // drawn at (cx - textWidth/2 - 2), so solve for cx to place it at the
511
+ // section's start pixel. Clamp so the label's right edge stays within
512
+ // the chart if it would otherwise overflow.
513
+ const startPx = sectionXPixel(sec, "start");
514
+ const labelRightPad = 8;
515
+ const preferred = startPx + textWidth / 2 + 2;
516
+ const maxCx = chartRight - textWidth / 2 - labelRightPad;
517
+ const cx = Math.min(preferred, maxCx);
387
518
  const color =
388
519
  sec.color ??
389
520
  (sec.seriesIndex != null
@@ -545,47 +676,61 @@ const yTickItems = computed(() => {
545
676
  });
546
677
 
547
678
  const xTickItems = computed(() => {
679
+ const { min: xMin, max: xMax } = xExtent.value;
680
+ if (xMin === xMax) return [];
681
+ const offset = xDisplayOffset.value;
548
682
  const len = maxLen.value;
549
- if (len <= 1) return [];
550
- const offset = props.xMin ?? 0;
551
- const xMin = offset;
552
- const xMax = offset + (len - 1);
553
683
 
554
- const toX = (v: number) =>
555
- snap(padding.value.left + ((v - offset) / (len - 1)) * innerW.value);
684
+ // Tick values are in data-space; display labels add `xDisplayOffset`.
556
685
  const fmt = (v: number, i: number) => {
557
- if (props.xTickFormat) return props.xTickFormat(v, i);
558
- const labels = props.xLabels;
559
- const idx = v - offset;
560
- if (labels && Number.isInteger(idx) && idx >= 0 && idx < labels.length) {
561
- return labels[idx];
686
+ const display = v + offset;
687
+ if (props.xTickFormat) return props.xTickFormat(display, i);
688
+ if (
689
+ !hasExplicitX.value &&
690
+ props.xLabels &&
691
+ Number.isInteger(v) &&
692
+ v >= 0 &&
693
+ v < props.xLabels.length
694
+ ) {
695
+ return props.xLabels[v];
562
696
  }
563
- return formatTick(v);
697
+ return formatTick(display);
564
698
  };
565
699
 
566
700
  let values: number[];
567
701
  if (Array.isArray(props.xTicks)) {
568
- values = props.xTicks.filter((v) => v >= xMin && v <= xMax);
702
+ // User supplies display-space values; shift to data-space.
703
+ values = props.xTicks
704
+ .map((v) => v - offset)
705
+ .filter((v) => v >= xMin && v <= xMax);
569
706
  } else if (typeof props.xTicks === "number") {
570
- values = intervalValues(xMin, xMax, props.xTicks);
571
- } else if (props.xLabels && props.xLabels.length === len) {
707
+ // Align to multiples of the step in display space (preserves
708
+ // e.g. `xMin: 3, xTicks: 5` display ticks 5, 10, 15 behavior).
709
+ values = intervalValues(xMin + offset, xMax + offset, props.xTicks).map(
710
+ (v) => v - offset,
711
+ );
712
+ } else if (
713
+ !hasExplicitX.value &&
714
+ props.xLabels &&
715
+ props.xLabels.length === len
716
+ ) {
572
717
  const targetTicks = Math.max(3, Math.floor(innerW.value / 80));
573
718
  const step = Math.max(1, Math.round((len - 1) / targetTicks));
574
719
  values = [];
575
- for (let i = 0; i < len; i += step) values.push(offset + i);
720
+ for (let i = 0; i < len; i += step) values.push(i);
576
721
  } else {
577
722
  const targetTicks = Math.max(3, Math.floor(innerW.value / 80));
578
- const step = niceStep(len - 1, targetTicks);
579
- values = [];
580
- for (let i = 0; i <= len - 1; i += step) {
581
- values.push(Math.round(i) + offset);
582
- }
723
+ const step = niceStep(xMax - xMin, targetTicks);
724
+ values = intervalValues(xMin + offset, xMax + offset, step).map(
725
+ (v) => v - offset,
726
+ );
583
727
  }
728
+
584
729
  const leftEdge = padding.value.left;
585
730
  const rightEdge = padding.value.left + innerW.value;
586
731
  const edgeSnapPx = 1;
587
732
  return values.map((v, i) => {
588
- const x = toX(v);
733
+ const x = snap(xPixel(v));
589
734
  let anchor: "start" | "middle" | "end" = "middle";
590
735
  if (x - leftEdge <= edgeSnapPx) anchor = "start";
591
736
  else if (rightEdge - x <= edgeSnapPx) anchor = "end";
@@ -608,13 +753,20 @@ function toCsv(): string {
608
753
  const series = allSeries.value;
609
754
  if (series.length === 0) return "";
610
755
  const len = maxLen.value;
756
+ // Use an `x` column when every series shares the same x source;
757
+ // otherwise fall back to `index`.
758
+ const sharedX = series.every((s) => s.x === series[0].x)
759
+ ? series[0].x
760
+ : undefined;
761
+ const xHeader = sharedX ? "x" : "index";
611
762
  const headers =
612
763
  series.length === 1
613
- ? ["index", "value"]
614
- : ["index", ...series.map((_, i) => `series_${i}`)];
764
+ ? [xHeader, "value"]
765
+ : [xHeader, ...series.map((_, i) => `series_${i}`)];
615
766
  const rows = [headers.join(",")];
616
767
  for (let r = 0; r < len; r++) {
617
- const cells = [r.toString()];
768
+ const xCell = sharedX ? String(sharedX[r]) : r.toString();
769
+ const cells = [xCell];
618
770
  for (const s of series) {
619
771
  cells.push(r < s.data.length ? String(s.data[r]) : "");
620
772
  }
@@ -634,43 +786,82 @@ const hasTooltipSlot = computed(
634
786
  () => !!props.tooltipData || !!props.tooltipTrigger,
635
787
  );
636
788
 
637
- const hoverX = computed(() => {
638
- if (hoverIndex.value === null) return 0;
639
- const len = maxLen.value;
640
- const xScale = innerW.value / (len - 1 || 1);
641
- return padding.value.left + hoverIndex.value * xScale;
789
+ /** Data-space x of the hovered point (via the first series). */
790
+ const hoverDataX = computed(() => {
791
+ const idx = hoverIndex.value;
792
+ const s0 = allSeries.value[0];
793
+ if (idx === null || !s0) return null;
794
+ return seriesXAt(s0, idx);
642
795
  });
643
796
 
797
+ const hoverX = computed(() =>
798
+ hoverDataX.value === null ? 0 : xPixel(hoverDataX.value),
799
+ );
800
+
801
+ /** Index of the series point closest to the given data-space x. */
802
+ function nearestIndex(s: ResolvedSeries, targetX: number): number | null {
803
+ const len = s.data.length;
804
+ if (len === 0) return null;
805
+ let bestIdx = 0;
806
+ let bestDist = Infinity;
807
+ for (let i = 0; i < len; i++) {
808
+ const svx = seriesXAt(s, i);
809
+ if (!isFinite(svx)) continue;
810
+ const d = Math.abs(svx - targetX);
811
+ if (d < bestDist) {
812
+ bestDist = d;
813
+ bestIdx = i;
814
+ }
815
+ }
816
+ return bestDist === Infinity ? null : bestIdx;
817
+ }
818
+
644
819
  const hoverDots = computed(() => {
645
- const idx = hoverIndex.value;
646
- if (idx === null) return [];
820
+ const targetX = hoverDataX.value;
821
+ if (targetX === null) return [];
647
822
  const { min, range } = extent.value;
648
823
  const yScale = innerH.value / range;
649
824
  const py = padding.value.top + innerH.value;
650
- return allSeries.value
651
- .filter((s) => idx < s.data.length && isFinite(s.data[idx]))
652
- .map((s) => ({
653
- x: hoverX.value,
654
- y: py - (s.data[idx] - min) * yScale,
825
+ const dots: { x: number; y: number; color: string }[] = [];
826
+ for (const s of allSeries.value) {
827
+ const nIdx = nearestIndex(s, targetX);
828
+ if (nIdx === null) continue;
829
+ const yv = s.data[nIdx];
830
+ if (!isFinite(yv)) continue;
831
+ dots.push({
832
+ x: xPixel(seriesXAt(s, nIdx)),
833
+ y: py - (yv - min) * yScale,
655
834
  color: s.color ?? "currentColor",
656
- }));
835
+ });
836
+ }
837
+ return dots;
657
838
  });
658
839
 
659
840
  const hoverSlotProps = computed(() => {
660
841
  const idx = hoverIndex.value;
661
- if (idx === null) return null;
662
- const offset = props.xMin ?? 0;
663
- const xLabel = props.xTickFormat
664
- ? props.xTickFormat(idx + offset, idx)
665
- : props.xLabels?.[idx];
842
+ const targetX = hoverDataX.value;
843
+ if (idx === null || targetX === null) return null;
844
+ const offset = xDisplayOffset.value;
845
+ const displayX = targetX + offset;
846
+ let xLabel: string | undefined;
847
+ if (props.xTickFormat) {
848
+ xLabel = props.xTickFormat(displayX, idx);
849
+ } else if (!hasExplicitX.value) {
850
+ xLabel = props.xLabels?.[idx];
851
+ } else {
852
+ xLabel = formatTick(displayX);
853
+ }
666
854
  return {
667
855
  index: idx,
668
856
  xLabel,
669
- values: allSeries.value.map((s, i) => ({
670
- value: s.data[idx],
671
- color: s.color ?? "currentColor",
672
- seriesIndex: i,
673
- })),
857
+ values: allSeries.value.map((s, i) => {
858
+ const nIdx = nearestIndex(s, targetX);
859
+ return {
860
+ value: nIdx !== null ? Number(s.data[nIdx]) : NaN,
861
+ color: s.color ?? "currentColor",
862
+ seriesIndex: i,
863
+ };
864
+ }),
674
865
  data: props.tooltipData?.[idx] ?? null,
675
866
  };
676
867
  });
@@ -687,12 +878,13 @@ function pointerFromEvent(
687
878
  function indexFromPointer(clientX: number): number | null {
688
879
  const rect = containerRef.value?.getBoundingClientRect();
689
880
  if (!rect) return null;
690
- const len = maxLen.value;
691
- if (len <= 1) return null;
881
+ const s0 = allSeries.value[0];
882
+ if (!s0 || s0.data.length === 0) return null;
883
+ const { min: xMin, max: xMax } = xExtent.value;
884
+ const range = xMax - xMin || 1;
692
885
  const mouseX = clientX - rect.left;
693
- const xScale = innerW.value / (len - 1 || 1);
694
- const dataX = (mouseX - padding.value.left) / xScale;
695
- return Math.round(Math.max(0, Math.min(len - 1, dataX)));
886
+ const targetX = xMin + ((mouseX - padding.value.left) / innerW.value) * range;
887
+ return nearestIndex(s0, targetX);
696
888
  }
697
889
 
698
890
  function updateHover(event: MouseEvent | TouchEvent) {
@@ -956,7 +1148,7 @@ const menuItems = computed<ChartMenuItem[]>(() => {
956
1148
  <path
957
1149
  v-for="(a, i) in allAreas"
958
1150
  :key="'area' + i"
959
- :d="toAreaPath(a.upper, a.lower)"
1151
+ :d="toAreaPath(a)"
960
1152
  :fill="a.color ?? 'currentColor'"
961
1153
  :fill-opacity="a.opacity ?? 0.2"
962
1154
  stroke="none"
@@ -965,7 +1157,7 @@ const menuItems = computed<ChartMenuItem[]>(() => {
965
1157
  <template v-for="(s, i) in allSeries" :key="i">
966
1158
  <path
967
1159
  v-if="s.line !== false"
968
- :d="toPath(s.data)"
1160
+ :d="toPath(s)"
969
1161
  fill="none"
970
1162
  :stroke="s.color ?? 'currentColor'"
971
1163
  :stroke-width="s.strokeWidth ?? 1.5"
@@ -974,7 +1166,7 @@ const menuItems = computed<ChartMenuItem[]>(() => {
974
1166
  />
975
1167
  <template v-if="s.dots">
976
1168
  <circle
977
- v-for="(pt, j) in toPoints(s.data)"
1169
+ v-for="(pt, j) in toPoints(s)"
978
1170
  :key="j"
979
1171
  :cx="pt.x"
980
1172
  :cy="pt.y"
@@ -1011,26 +1203,18 @@ const menuItems = computed<ChartMenuItem[]>(() => {
1011
1203
  <!-- vertical edge lines for full-height sections -->
1012
1204
  <template v-if="sec.seriesIndex == null">
1013
1205
  <line
1014
- :x1="
1015
- snap(padding.left + sec.startIndex * (innerW / (maxLen - 1 || 1)))
1016
- "
1206
+ :x1="snap(sectionXPixel(sec, 'start'))"
1017
1207
  :y1="padding.top"
1018
- :x2="
1019
- snap(padding.left + sec.startIndex * (innerW / (maxLen - 1 || 1)))
1020
- "
1208
+ :x2="snap(sectionXPixel(sec, 'start'))"
1021
1209
  :y2="padding.top + innerH"
1022
1210
  :stroke="sec.color ?? '#999'"
1023
1211
  :stroke-width="sec.strokeWidth ?? 2"
1024
1212
  :stroke-dasharray="sec.dashed ? '6 3' : undefined"
1025
1213
  />
1026
1214
  <line
1027
- :x1="
1028
- snap(padding.left + sec.endIndex * (innerW / (maxLen - 1 || 1)))
1029
- "
1215
+ :x1="snap(sectionXPixel(sec, 'end'))"
1030
1216
  :y1="padding.top"
1031
- :x2="
1032
- snap(padding.left + sec.endIndex * (innerW / (maxLen - 1 || 1)))
1033
- "
1217
+ :x2="snap(sectionXPixel(sec, 'end'))"
1034
1218
  :y2="padding.top + innerH"
1035
1219
  :stroke="sec.color ?? '#999'"
1036
1220
  :stroke-width="sec.strokeWidth ?? 2"
@@ -1039,21 +1223,17 @@ const menuItems = computed<ChartMenuItem[]>(() => {
1039
1223
  </template>
1040
1224
  <!-- tick marks at section boundaries -->
1041
1225
  <line
1042
- :x1="
1043
- snap(padding.left + sec.startIndex * (innerW / (maxLen - 1 || 1)))
1044
- "
1226
+ :x1="snap(sectionXPixel(sec, 'start'))"
1045
1227
  :y1="padding.top + innerH - 4"
1046
- :x2="
1047
- snap(padding.left + sec.startIndex * (innerW / (maxLen - 1 || 1)))
1048
- "
1228
+ :x2="snap(sectionXPixel(sec, 'start'))"
1049
1229
  :y2="padding.top + innerH + 4"
1050
1230
  stroke="currentColor"
1051
1231
  stroke-opacity="0.4"
1052
1232
  />
1053
1233
  <line
1054
- :x1="snap(padding.left + sec.endIndex * (innerW / (maxLen - 1 || 1)))"
1234
+ :x1="snap(sectionXPixel(sec, 'end'))"
1055
1235
  :y1="padding.top + innerH - 4"
1056
- :x2="snap(padding.left + sec.endIndex * (innerW / (maxLen - 1 || 1)))"
1236
+ :x2="snap(sectionXPixel(sec, 'end'))"
1057
1237
  :y2="padding.top + innerH + 4"
1058
1238
  stroke="currentColor"
1059
1239
  stroke-opacity="0.4"
package/charts/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export {
2
2
  default as LineChart,
3
+ type LineChartData,
3
4
  type Series,
4
5
  type Area,
5
6
  type AreaSection,
package/index.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.12",
2
+ "version": "0.3.14",
3
3
  "package": "@cfasim-ui/docs",
4
4
  "content": {
5
5
  "components": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/docs",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "description": "LLM-friendly component and chart documentation for cfasim-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {