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