@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.
- package/dist/BarChart/BarChart.d.ts +9 -26
- package/dist/ChartMenu/ChartMenu.d.ts +3 -2
- package/dist/ChartTooltip/ChartTooltip.d.ts +9 -12
- package/dist/ChoroplethMap/ChoroplethMap.d.ts +28 -189
- package/dist/ChoroplethMap/ChoroplethTooltip.d.ts +20 -27
- package/dist/ChoroplethMap/canvasLayer.d.ts +27 -3
- package/dist/DataTable/DataTable.d.ts +3 -2
- package/dist/LineChart/LineChart.d.ts +9 -26
- package/dist/_shared/ChartAnnotations.d.ts +3 -2
- package/dist/_shared/ChartZoomControls.d.ts +3 -2
- package/dist/_shared/index.d.ts +2 -1
- package/dist/_shared/mapTheme.d.ts +159 -0
- package/dist/_shared/mapTheme.test.d.ts +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1710 -1432
- package/docs/BarChart.md +776 -0
- package/docs/ChoroplethMap.md +1267 -0
- package/docs/DataTable.md +386 -0
- package/docs/LineChart.md +1267 -0
- package/docs/index.json +56 -0
- package/package.json +25 -21
- package/src/BarChart/BarChart.md +743 -0
- package/src/BarChart/BarChart.vue +1901 -0
- package/src/ChartMenu/ChartMenu.vue +220 -0
- package/src/ChartMenu/download.ts +120 -0
- package/src/ChartTooltip/ChartTooltip.vue +97 -0
- package/src/ChoroplethMap/ChoroplethMap.md +1227 -0
- package/src/ChoroplethMap/ChoroplethMap.vue +3676 -0
- package/src/ChoroplethMap/ChoroplethTooltip.vue +103 -0
- package/src/ChoroplethMap/canvasLayer.ts +373 -0
- package/src/ChoroplethMap/cityLayout.ts +261 -0
- package/src/ChoroplethMap/hsaMapping.ts +4116 -0
- package/src/DataTable/DataTable.md +372 -0
- package/src/DataTable/DataTable.vue +406 -0
- package/src/LineChart/LineChart.md +1225 -0
- package/src/LineChart/LineChart.vue +1555 -0
- package/src/_shared/ChartAnnotations.vue +420 -0
- package/src/_shared/ChartZoomControls.vue +138 -0
- package/src/_shared/annotations.ts +106 -0
- package/src/_shared/axes.ts +69 -0
- package/src/_shared/chartProps.ts +201 -0
- package/src/_shared/computeTicks.ts +42 -0
- package/src/_shared/contrast.ts +100 -0
- package/src/_shared/dateAxis.ts +501 -0
- package/src/_shared/index.ts +78 -0
- package/src/_shared/mapTheme.ts +551 -0
- package/src/_shared/scale.ts +86 -0
- package/src/_shared/seriesCsv.ts +68 -0
- package/src/_shared/touch.ts +8 -0
- package/src/_shared/useChartFoundation.ts +175 -0
- package/src/_shared/useChartFullscreen.ts +254 -0
- package/src/_shared/useChartMenu.ts +111 -0
- package/src/_shared/useChartPadding.ts +235 -0
- package/src/_shared/useChartSize.ts +58 -0
- package/src/_shared/useChartTooltip.ts +205 -0
- package/src/env.d.ts +4 -0
- package/src/hsa-mapping.ts +1 -0
- package/src/index.ts +41 -0
- package/src/tooltip-position.ts +55 -0
- package/src/us-cities/data.ts +1371 -0
- package/src/us-cities/index.ts +122 -0
- 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>
|