@cfasim-ui/docs 0.3.11 → 0.3.13
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.
- package/charts/LineChart/LineChart.md +69 -4
- package/charts/LineChart/LineChart.vue +302 -122
- package/charts/index.ts +1 -0
- package/index.json +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
{
|
|
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
|
-
|
|
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
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
27
|
-
lower:
|
|
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
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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(
|
|
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
|
|
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 (
|
|
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${
|
|
307
|
-
for (let j = 1; j < s.length; 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${
|
|
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
|
|
324
|
-
const
|
|
325
|
-
if (
|
|
326
|
-
return `M${
|
|
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${
|
|
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${
|
|
468
|
+
d += `L${xPixel(seriesXAt(s, i))},${y(s.data[i])}`;
|
|
343
469
|
}
|
|
344
470
|
if (closed) {
|
|
345
|
-
d += `L${
|
|
346
|
-
d += `L${
|
|
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
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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(
|
|
697
|
+
return formatTick(display);
|
|
564
698
|
};
|
|
565
699
|
|
|
566
700
|
let values: number[];
|
|
567
701
|
if (Array.isArray(props.xTicks)) {
|
|
568
|
-
|
|
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
|
-
|
|
571
|
-
|
|
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(
|
|
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(
|
|
579
|
-
values =
|
|
580
|
-
|
|
581
|
-
|
|
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 =
|
|
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
|
-
? [
|
|
614
|
-
: [
|
|
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
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
const
|
|
640
|
-
const
|
|
641
|
-
|
|
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
|
|
646
|
-
if (
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
|
691
|
-
if (
|
|
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
|
|
694
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
1234
|
+
:x1="snap(sectionXPixel(sec, 'end'))"
|
|
1055
1235
|
:y1="padding.top + innerH - 4"
|
|
1056
|
-
:x2="snap(
|
|
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
package/index.json
CHANGED