@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,1901 @@
|
|
|
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
|
+
categoricalToCsv,
|
|
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
|
+
pickContrastColor,
|
|
25
|
+
resolveLabelStyle,
|
|
26
|
+
parseDate,
|
|
27
|
+
isAllDates,
|
|
28
|
+
pickDateTicks,
|
|
29
|
+
formatDate,
|
|
30
|
+
type ChartData,
|
|
31
|
+
type DateFormat,
|
|
32
|
+
type ChartCommonProps,
|
|
33
|
+
type ChartHoverPayload,
|
|
34
|
+
type ChartTooltipBaseProps,
|
|
35
|
+
type ChartTooltipValue,
|
|
36
|
+
type BlendMode,
|
|
37
|
+
type LineMarkStyle,
|
|
38
|
+
type LabelStyle,
|
|
39
|
+
type ChartPadding,
|
|
40
|
+
} from "../_shared/index.js";
|
|
41
|
+
|
|
42
|
+
export type BarChartData = ChartData;
|
|
43
|
+
|
|
44
|
+
export interface BarSeries {
|
|
45
|
+
/** Bar values; one entry per category. `y` is accepted as an alias. */
|
|
46
|
+
y?: BarChartData;
|
|
47
|
+
data?: BarChartData;
|
|
48
|
+
color?: string;
|
|
49
|
+
opacity?: number;
|
|
50
|
+
/**
|
|
51
|
+
* CSS `mix-blend-mode` applied to each bar in this series. Lets
|
|
52
|
+
* overlapping bars (e.g. `layout="overlay"`) combine their colors
|
|
53
|
+
* instead of one obscuring the other.
|
|
54
|
+
*/
|
|
55
|
+
blendMode?: BlendMode;
|
|
56
|
+
/** Label shown in the inline legend. */
|
|
57
|
+
legend?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Whether this series appears in the inline legend. Defaults to true.
|
|
60
|
+
* Has no effect when `legend` is unset (no legend entry to begin with).
|
|
61
|
+
*/
|
|
62
|
+
showInLegend?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Whether this series contributes a value to the tooltip. Defaults to
|
|
65
|
+
* true. The bars are still drawn.
|
|
66
|
+
*/
|
|
67
|
+
showInTooltip?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A line overlay drawn on top of the bars — e.g. a KDE over a histogram,
|
|
72
|
+
* a rolling mean, or any other summary curve. Each line scales to its
|
|
73
|
+
* own value extent (independent of the bars), so a probability-density
|
|
74
|
+
* curve in `[0, 0.02]` and a count histogram in `[0, 500]` can coexist.
|
|
75
|
+
*
|
|
76
|
+
* Sample the curve densely on the input side — `summaryLines` connects
|
|
77
|
+
* points with straight segments (matching matplotlib / seaborn `kde=True`).
|
|
78
|
+
*
|
|
79
|
+
* Shares visual styling (`color`, `strokeWidth`, `dashed`, `opacity`,
|
|
80
|
+
* `blendMode`, `dots`, `dotRadius`, `legend`, `showInLegend`) with
|
|
81
|
+
* `LineChart`'s `Series` via `LineMarkStyle`.
|
|
82
|
+
*/
|
|
83
|
+
export interface BarSummaryLine extends LineMarkStyle {
|
|
84
|
+
/** Y values along the line. */
|
|
85
|
+
data: BarChartData;
|
|
86
|
+
/**
|
|
87
|
+
* X positions in category-index space (0 = first category center,
|
|
88
|
+
* `categories.length - 1` = last). Fractional values land between
|
|
89
|
+
* category centers. When omitted, points plot at successive category
|
|
90
|
+
* centers — one point per `data` entry.
|
|
91
|
+
*/
|
|
92
|
+
x?: BarChartData;
|
|
93
|
+
/**
|
|
94
|
+
* Override the line's value-axis floor. When unset, defaults to the
|
|
95
|
+
* line's own min (clamped to 0 when all data is non-negative).
|
|
96
|
+
*/
|
|
97
|
+
valueMin?: number;
|
|
98
|
+
/** Override the line's value-axis ceiling. Defaults to the line's own max. */
|
|
99
|
+
valueMax?: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Context passed to a `barLabels.format` function. */
|
|
103
|
+
export interface BarLabelContext {
|
|
104
|
+
value: number;
|
|
105
|
+
categoryIndex: number;
|
|
106
|
+
seriesIndex: number;
|
|
107
|
+
/** The resolved category label string. */
|
|
108
|
+
category: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Styling and behavior for value labels drawn directly on the bars (an
|
|
113
|
+
* alternative to reading values off the value axis — see `valueAxis`).
|
|
114
|
+
* Most useful with `layout="stacked"`, where each segment carries its own
|
|
115
|
+
* value. Extends `LabelStyle` (`fontSize` / `fontWeight`).
|
|
116
|
+
*/
|
|
117
|
+
export interface BarLabelStyle extends LabelStyle {
|
|
118
|
+
/**
|
|
119
|
+
* How to turn a bar's numeric value into label text. A `NumberFormat`
|
|
120
|
+
* (preset/printf/function), or a function receiving `(value, context)`
|
|
121
|
+
* for fully custom strings (e.g. `">99%"`). Return `""` to omit a
|
|
122
|
+
* label. When unset, falls back to `valueTickFormat`, then a default.
|
|
123
|
+
*/
|
|
124
|
+
format?: NumberFormat | ((value: number, ctx: BarLabelContext) => string);
|
|
125
|
+
/**
|
|
126
|
+
* Label text color. `"auto"` (default) picks white or near-black per
|
|
127
|
+
* segment by the fill's luminance for readable contrast; outside-placed
|
|
128
|
+
* labels use the chart's text color. Any other CSS color is used as-is
|
|
129
|
+
* for every label.
|
|
130
|
+
*/
|
|
131
|
+
color?: string;
|
|
132
|
+
/**
|
|
133
|
+
* Where to draw each label. `"auto"` (default) centers it inside the
|
|
134
|
+
* segment when the text fits, otherwise just past the segment's
|
|
135
|
+
* high-value edge. `"inside"` / `"outside"` force one placement.
|
|
136
|
+
*/
|
|
137
|
+
placement?: "auto" | "inside" | "outside";
|
|
138
|
+
/**
|
|
139
|
+
* Alignment of an *inside* label along the value axis. `"center"`
|
|
140
|
+
* (default) centers it in its segment; `"start"` pins it to the
|
|
141
|
+
* segment's low-value edge (left edge for horizontal bars); `"end"`
|
|
142
|
+
* pins it to the high-value edge. Outside/overflow labels ignore this
|
|
143
|
+
* and always sit just past the high-value edge.
|
|
144
|
+
*/
|
|
145
|
+
align?: "start" | "center" | "end";
|
|
146
|
+
/**
|
|
147
|
+
* How to handle labels that would collide along the value axis (common
|
|
148
|
+
* with many small adjacent segments). `"shift"` (default) nudges them
|
|
149
|
+
* apart toward higher values; `"hide"` drops the colliding label. Either
|
|
150
|
+
* way, a label pushed past the chart edge is hidden.
|
|
151
|
+
*/
|
|
152
|
+
overlap?: "shift" | "hide";
|
|
153
|
+
/**
|
|
154
|
+
* Segments smaller than this many pixels (along the value axis) never
|
|
155
|
+
* get an *inside* label — they fall back to outside placement (or hide,
|
|
156
|
+
* per `overlap`). Use to suppress labels crammed into thin slivers.
|
|
157
|
+
* Default 0 (size alone never forces a label out; text fit still does).
|
|
158
|
+
*/
|
|
159
|
+
minSize?: number;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface BarChartProps extends ChartCommonProps {
|
|
163
|
+
/** Single-series values. Equivalent to `y`. */
|
|
164
|
+
data?: BarChartData;
|
|
165
|
+
/** Single-series values (alias for `data`). */
|
|
166
|
+
y?: BarChartData;
|
|
167
|
+
/** Multi-series mode. Each series has its own values. */
|
|
168
|
+
series?: BarSeries[];
|
|
169
|
+
/**
|
|
170
|
+
* Line overlays drawn on top of the bars (e.g. a KDE, a rolling mean,
|
|
171
|
+
* or any summary curve). Each line scales to its own value extent —
|
|
172
|
+
* unrelated to the bar value axis. See `BarSummaryLine`.
|
|
173
|
+
*/
|
|
174
|
+
summaryLines?: BarSummaryLine[];
|
|
175
|
+
/**
|
|
176
|
+
* Category labels for the categorical axis. Length should match the
|
|
177
|
+
* longest series. When omitted, indices (0, 1, 2, ...) are used.
|
|
178
|
+
* Accepts date strings or `Date` objects: when every category parses
|
|
179
|
+
* as a date, label formatting switches to date mode (bar positions
|
|
180
|
+
* stay ordinal — they are not time-proportional).
|
|
181
|
+
*/
|
|
182
|
+
categories?: readonly (string | Date)[];
|
|
183
|
+
/** "vertical" (default, aka column) draws upright bars; "horizontal" draws sideways. */
|
|
184
|
+
orientation?: "vertical" | "horizontal";
|
|
185
|
+
/**
|
|
186
|
+
* "grouped" (default) places series side-by-side; "stacked" stacks
|
|
187
|
+
* them; "overlay" draws each series at full group width from the
|
|
188
|
+
* shared baseline, painting later series on top of earlier ones.
|
|
189
|
+
* In overlay mode set `opacity` per series, or order series so the
|
|
190
|
+
* tallest renders first, so bars behind remain visible.
|
|
191
|
+
*/
|
|
192
|
+
layout?: "grouped" | "stacked" | "overlay";
|
|
193
|
+
/** Force the value axis to start at this value or lower (default 0). */
|
|
194
|
+
valueMin?: number;
|
|
195
|
+
/**
|
|
196
|
+
* Scale type for the value axis. `"linear"` (default) maps values
|
|
197
|
+
* directly to pixels; `"log"` uses a base-10 log mapping. On a log
|
|
198
|
+
* axis, non-positive values collapse to the visible minimum, and
|
|
199
|
+
* `valueMin <= 0` is ignored. Stacked layout + log produces a
|
|
200
|
+
* cumulative axis but individual segment sizes are no longer
|
|
201
|
+
* proportional to their values.
|
|
202
|
+
*/
|
|
203
|
+
valueScaleType?: "linear" | "log";
|
|
204
|
+
/**
|
|
205
|
+
* Tick placement on the value axis (numeric). Number = interval,
|
|
206
|
+
* array = explicit values. When omitted, ticks are chosen automatically.
|
|
207
|
+
*/
|
|
208
|
+
valueTicks?: number | number[];
|
|
209
|
+
/**
|
|
210
|
+
* Formatter for value-axis tick labels. Accepts a preset name, a
|
|
211
|
+
* printf-style format string, or a function. See `formatNumber` in
|
|
212
|
+
* `@cfasim-ui/shared`.
|
|
213
|
+
*/
|
|
214
|
+
valueTickFormat?: NumberFormat;
|
|
215
|
+
/** Formatter for category-axis labels. Receives the resolved category string. */
|
|
216
|
+
categoryFormat?: (label: string, index: number) => string;
|
|
217
|
+
/**
|
|
218
|
+
* Alignment of the category labels in horizontal orientation. `"end"`
|
|
219
|
+
* (default) right-aligns them against the plot's left edge; `"start"`
|
|
220
|
+
* left-aligns them at the chart's left margin; `"center"` centers them
|
|
221
|
+
* in the margin. The `categoryHeader`, if set, follows the same
|
|
222
|
+
* alignment. Ignored in vertical orientation (labels stay centered
|
|
223
|
+
* under each column).
|
|
224
|
+
*/
|
|
225
|
+
categoryAlign?: "start" | "center" | "end";
|
|
226
|
+
/**
|
|
227
|
+
* Date-tick formatter for date-mode category labels (auto-detected
|
|
228
|
+
* when every entry of `categories` parses as a date). Ignored unless
|
|
229
|
+
* the axis is in date mode. Accepts a `DateFormat` — preset name,
|
|
230
|
+
* `Intl.DateTimeFormatOptions`, or `(ms, unit?) => string`. When
|
|
231
|
+
* omitted, the formatter is picked from the chosen tick unit (e.g.
|
|
232
|
+
* monthly ticks → `"month-year"`). Has no effect when
|
|
233
|
+
* `categoryFormat` is also set.
|
|
234
|
+
*/
|
|
235
|
+
dateFormat?: DateFormat;
|
|
236
|
+
/**
|
|
237
|
+
* Fraction of each category slot reserved as gap between groups (0..1).
|
|
238
|
+
* Default 0.2 — i.e. bars/groups fill 80% of their slot.
|
|
239
|
+
*/
|
|
240
|
+
barPadding?: number;
|
|
241
|
+
/**
|
|
242
|
+
* Pixel gap between bars within a single category group in `grouped` layout.
|
|
243
|
+
* Default 1.
|
|
244
|
+
*/
|
|
245
|
+
groupGap?: number;
|
|
246
|
+
valueGrid?: boolean;
|
|
247
|
+
/**
|
|
248
|
+
* Draw the value axis (its line and tick labels). Default true. Set
|
|
249
|
+
* false for a clean "value-on-bar" look where each segment is labeled
|
|
250
|
+
* via `barLabels` and there's no axis to read against.
|
|
251
|
+
*/
|
|
252
|
+
valueAxis?: boolean;
|
|
253
|
+
/**
|
|
254
|
+
* Draw the numeric value directly on each bar instead of (or alongside)
|
|
255
|
+
* reading it off the value axis. `true` uses defaults; pass a
|
|
256
|
+
* `BarLabelStyle` to customize formatting, color, placement, and
|
|
257
|
+
* overlap handling. Most useful with `layout="stacked"`.
|
|
258
|
+
*/
|
|
259
|
+
barLabels?: boolean | BarLabelStyle;
|
|
260
|
+
/**
|
|
261
|
+
* Header drawn above the category labels (e.g. "Scenario"). Aligned to
|
|
262
|
+
* match the category labels — right-edge-aligned in horizontal
|
|
263
|
+
* orientation, centered under each column in vertical. Reserves a row
|
|
264
|
+
* of space above the plot.
|
|
265
|
+
*/
|
|
266
|
+
categoryHeader?: string;
|
|
267
|
+
/**
|
|
268
|
+
* Header drawn above the bars / value area (e.g. "Proportion of
|
|
269
|
+
* simulations"), left-aligned to where the bars start. Pairs with
|
|
270
|
+
* `categoryHeader` to label both columns of a value-on-bar chart.
|
|
271
|
+
* Reserves a row of space above the plot.
|
|
272
|
+
*/
|
|
273
|
+
valueHeader?: string;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const props = withDefaults(defineProps<BarChartProps>(), {
|
|
277
|
+
orientation: "vertical",
|
|
278
|
+
layout: "grouped",
|
|
279
|
+
valueMin: 0,
|
|
280
|
+
barPadding: 0.2,
|
|
281
|
+
groupGap: 1,
|
|
282
|
+
menu: true,
|
|
283
|
+
tooltipClamp: "window",
|
|
284
|
+
valueGrid: true,
|
|
285
|
+
valueScaleType: "linear",
|
|
286
|
+
valueAxis: true,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Accessible name for the chart; falls back to the visible title.
|
|
290
|
+
const chartAriaLabel = computed(() => props.ariaLabel ?? props.title);
|
|
291
|
+
// Expose the <svg> as a single labeled image so screen readers announce the
|
|
292
|
+
// name instead of wandering through the individual bars. The menu/download
|
|
293
|
+
// controls live outside the <svg>, so they stay reachable. An explicit `role`
|
|
294
|
+
// prop always wins.
|
|
295
|
+
const chartRole = computed(
|
|
296
|
+
() => props.role ?? (chartAriaLabel.value ? "img" : undefined),
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
// The template root is a <Teleport>, so fallthrough attrs (class, style,
|
|
300
|
+
// data-*, id…) can't auto-inherit — forward them onto the wrapper manually.
|
|
301
|
+
defineOptions({ inheritAttrs: false });
|
|
302
|
+
|
|
303
|
+
const emit = defineEmits<{
|
|
304
|
+
(e: "hover", payload: ChartHoverPayload): void;
|
|
305
|
+
}>();
|
|
306
|
+
|
|
307
|
+
defineSlots<{
|
|
308
|
+
tooltip?(props: ChartTooltipBaseProps & { category: string }): unknown;
|
|
309
|
+
}>();
|
|
310
|
+
|
|
311
|
+
const EMPTY_DATA: readonly number[] = [];
|
|
312
|
+
|
|
313
|
+
type ResolvedSeries = {
|
|
314
|
+
data: BarChartData;
|
|
315
|
+
color?: string;
|
|
316
|
+
opacity?: number;
|
|
317
|
+
blendMode?: BlendMode;
|
|
318
|
+
legend?: string;
|
|
319
|
+
showInLegend?: boolean;
|
|
320
|
+
showInTooltip?: boolean;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
function resolveSeries(s: BarSeries): ResolvedSeries {
|
|
324
|
+
return {
|
|
325
|
+
data: s.y ?? s.data ?? EMPTY_DATA,
|
|
326
|
+
color: s.color,
|
|
327
|
+
opacity: s.opacity,
|
|
328
|
+
blendMode: s.blendMode,
|
|
329
|
+
legend: s.legend,
|
|
330
|
+
showInLegend: s.showInLegend,
|
|
331
|
+
showInTooltip: s.showInTooltip,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const allSeries = computed<ResolvedSeries[]>(() => {
|
|
336
|
+
if (props.series && props.series.length > 0)
|
|
337
|
+
return props.series.map(resolveSeries);
|
|
338
|
+
const topY = props.y ?? props.data;
|
|
339
|
+
if (topY) return [{ data: topY }];
|
|
340
|
+
return [];
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const categoryCount = computed(() => {
|
|
344
|
+
let n = props.categories?.length ?? 0;
|
|
345
|
+
for (const s of allSeries.value) {
|
|
346
|
+
if (s.data.length > n) n = s.data.length;
|
|
347
|
+
}
|
|
348
|
+
return n;
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const categoryLabels = computed<string[]>(() => {
|
|
352
|
+
const n = categoryCount.value;
|
|
353
|
+
const labels = new Array<string>(n);
|
|
354
|
+
for (let i = 0; i < n; i++) {
|
|
355
|
+
const c = props.categories?.[i];
|
|
356
|
+
if (c instanceof Date) {
|
|
357
|
+
// Stable string form for CSV / tooltip. Date axis formatting
|
|
358
|
+
// (visible labels) goes through `categoryTickItems` separately.
|
|
359
|
+
labels[i] = c.toISOString().slice(0, 10);
|
|
360
|
+
} else {
|
|
361
|
+
labels[i] = c ?? String(i);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return labels;
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Parallel array of epoch-ms timestamps when every category parses as
|
|
369
|
+
* a date, otherwise `null`. Drives both date-aware tick thinning and
|
|
370
|
+
* the default tick label formatter.
|
|
371
|
+
*/
|
|
372
|
+
const categoryDatesMs = computed<number[] | null>(() => {
|
|
373
|
+
const cats = props.categories;
|
|
374
|
+
if (!cats || cats.length === 0) return null;
|
|
375
|
+
if (!isAllDates(cats, "utc")) return null;
|
|
376
|
+
const out = new Array<number>(cats.length);
|
|
377
|
+
for (let i = 0; i < cats.length; i++) {
|
|
378
|
+
out[i] = parseDate(cats[i], "utc") ?? NaN;
|
|
379
|
+
}
|
|
380
|
+
return out;
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const isVertical = computed(() => props.orientation === "vertical");
|
|
384
|
+
|
|
385
|
+
/** Extent of the value axis (across all series, accounting for stacking). */
|
|
386
|
+
const valueExtent = computed(() => {
|
|
387
|
+
let min = Infinity;
|
|
388
|
+
let max = -Infinity;
|
|
389
|
+
let smallestPositive = Infinity;
|
|
390
|
+
const visitPositive = (v: number) => {
|
|
391
|
+
if (v > 0 && v < smallestPositive) smallestPositive = v;
|
|
392
|
+
};
|
|
393
|
+
if (props.layout === "stacked") {
|
|
394
|
+
const n = categoryCount.value;
|
|
395
|
+
for (let i = 0; i < n; i++) {
|
|
396
|
+
let pos = 0;
|
|
397
|
+
let neg = 0;
|
|
398
|
+
for (const s of allSeries.value) {
|
|
399
|
+
if (i >= s.data.length) continue;
|
|
400
|
+
const v = Number(s.data[i]);
|
|
401
|
+
if (!isFinite(v)) continue;
|
|
402
|
+
visitPositive(v);
|
|
403
|
+
if (v >= 0) pos += v;
|
|
404
|
+
else neg += v;
|
|
405
|
+
}
|
|
406
|
+
if (pos > max) max = pos;
|
|
407
|
+
if (neg < min) min = neg;
|
|
408
|
+
}
|
|
409
|
+
} else {
|
|
410
|
+
for (const s of allSeries.value) {
|
|
411
|
+
for (const v of s.data) {
|
|
412
|
+
const n = Number(v);
|
|
413
|
+
if (!isFinite(n)) continue;
|
|
414
|
+
visitPositive(n);
|
|
415
|
+
if (n < min) min = n;
|
|
416
|
+
if (n > max) max = n;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (!isFinite(min)) min = 0;
|
|
421
|
+
if (!isFinite(max)) max = 0;
|
|
422
|
+
// Extend the value axis down to valueMin (default 0) when data sits
|
|
423
|
+
// above it, so bars share a common baseline. On log scales, only
|
|
424
|
+
// positive valueMin values are honored.
|
|
425
|
+
const floor = props.valueMin ?? 0;
|
|
426
|
+
if (props.valueScaleType === "log") {
|
|
427
|
+
if (floor > 0 && floor < min) min = floor;
|
|
428
|
+
} else if (floor < min) {
|
|
429
|
+
min = floor;
|
|
430
|
+
}
|
|
431
|
+
const clamped = clampExtentForScale(
|
|
432
|
+
min,
|
|
433
|
+
max,
|
|
434
|
+
props.valueScaleType,
|
|
435
|
+
smallestPositive,
|
|
436
|
+
);
|
|
437
|
+
return {
|
|
438
|
+
min: clamped.min,
|
|
439
|
+
max: clamped.max,
|
|
440
|
+
range: clamped.max - clamped.min || 1,
|
|
441
|
+
};
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
/** Size (in pixels) of the categorical axis. */
|
|
445
|
+
const categoricalSize = computed(() =>
|
|
446
|
+
isVertical.value ? innerW.value : innerH.value,
|
|
447
|
+
);
|
|
448
|
+
/** Size (in pixels) of the value axis. */
|
|
449
|
+
const valueSize = computed(() =>
|
|
450
|
+
isVertical.value ? innerH.value : innerW.value,
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
const slotSize = computed(() => {
|
|
454
|
+
const n = categoryCount.value;
|
|
455
|
+
return n > 0 ? categoricalSize.value / n : 0;
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
/** Total width allocated to bars within one category slot. */
|
|
459
|
+
const groupWidth = computed(() => slotSize.value * (1 - props.barPadding));
|
|
460
|
+
|
|
461
|
+
/** Width of an individual bar (always; for stacked it's the full group). */
|
|
462
|
+
const barWidth = computed(() => {
|
|
463
|
+
const k = allSeries.value.length;
|
|
464
|
+
if (k === 0) return 0;
|
|
465
|
+
if (props.layout === "stacked" || k === 1) return groupWidth.value;
|
|
466
|
+
const totalGap = props.groupGap * (k - 1);
|
|
467
|
+
return Math.max(1, (groupWidth.value - totalGap) / k);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
/** Pixel position of the start of category slot `i` along the categorical axis. */
|
|
471
|
+
function slotStart(i: number): number {
|
|
472
|
+
const base = isVertical.value ? padding.value.left : padding.value.top;
|
|
473
|
+
return base + i * slotSize.value;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Pixel position of the resting baseline for grouped bars — `valueMin`
|
|
478
|
+
* (default 0) clamped to the visible value extent. This pins positive
|
|
479
|
+
* and negative bars to a common zero line when data is mixed-sign, and
|
|
480
|
+
* to the requested floor when `valueMin` is set inside the data range.
|
|
481
|
+
*/
|
|
482
|
+
const groupedBaselinePixel = computed(() => {
|
|
483
|
+
const { min, max } = valueExtent.value;
|
|
484
|
+
const target = props.valueMin ?? 0;
|
|
485
|
+
return valuePixel(Math.max(min, Math.min(max, target)));
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
/** Convert a data value to its pixel position along the value axis. */
|
|
489
|
+
function valuePixel(v: number): number {
|
|
490
|
+
const { min, max } = valueExtent.value;
|
|
491
|
+
const frac = scaleFraction(v, min, max, props.valueScaleType);
|
|
492
|
+
if (isVertical.value) {
|
|
493
|
+
return padding.value.top + innerH.value - frac * innerH.value;
|
|
494
|
+
}
|
|
495
|
+
return padding.value.left + frac * innerW.value;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
interface BarRect {
|
|
499
|
+
x: number;
|
|
500
|
+
y: number;
|
|
501
|
+
w: number;
|
|
502
|
+
h: number;
|
|
503
|
+
color: string;
|
|
504
|
+
opacity: number;
|
|
505
|
+
blendMode?: BlendMode;
|
|
506
|
+
value: number;
|
|
507
|
+
categoryIndex: number;
|
|
508
|
+
seriesIndex: number;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Build one bar rectangle spanning [aPx..bPx] along the value axis and
|
|
513
|
+
* [start..start+span] along the categorical axis. Orientation flips
|
|
514
|
+
* which pair maps to (x, w) vs (y, h).
|
|
515
|
+
*/
|
|
516
|
+
function makeBar(
|
|
517
|
+
aPx: number,
|
|
518
|
+
bPx: number,
|
|
519
|
+
start: number,
|
|
520
|
+
span: number,
|
|
521
|
+
color: string,
|
|
522
|
+
opacity: number,
|
|
523
|
+
blendMode: BlendMode | undefined,
|
|
524
|
+
value: number,
|
|
525
|
+
categoryIndex: number,
|
|
526
|
+
seriesIndex: number,
|
|
527
|
+
): BarRect {
|
|
528
|
+
const lo = Math.min(aPx, bPx);
|
|
529
|
+
const size = Math.abs(aPx - bPx);
|
|
530
|
+
if (isVertical.value) {
|
|
531
|
+
return {
|
|
532
|
+
x: start,
|
|
533
|
+
y: lo,
|
|
534
|
+
w: span,
|
|
535
|
+
h: size,
|
|
536
|
+
color,
|
|
537
|
+
opacity,
|
|
538
|
+
blendMode,
|
|
539
|
+
value,
|
|
540
|
+
categoryIndex,
|
|
541
|
+
seriesIndex,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
return {
|
|
545
|
+
x: lo,
|
|
546
|
+
y: start,
|
|
547
|
+
w: size,
|
|
548
|
+
h: span,
|
|
549
|
+
color,
|
|
550
|
+
opacity,
|
|
551
|
+
blendMode,
|
|
552
|
+
value,
|
|
553
|
+
categoryIndex,
|
|
554
|
+
seriesIndex,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const bars = computed<BarRect[]>(() => {
|
|
559
|
+
const out: BarRect[] = [];
|
|
560
|
+
const seriesList = allSeries.value;
|
|
561
|
+
const k = seriesList.length;
|
|
562
|
+
if (k === 0) return out;
|
|
563
|
+
const n = categoryCount.value;
|
|
564
|
+
const slot = slotSize.value;
|
|
565
|
+
const group = groupWidth.value;
|
|
566
|
+
const bw = barWidth.value;
|
|
567
|
+
const innerOffset = (slot - group) / 2;
|
|
568
|
+
const baseline = groupedBaselinePixel.value;
|
|
569
|
+
|
|
570
|
+
for (let i = 0; i < n; i++) {
|
|
571
|
+
const groupStart = slotStart(i) + innerOffset;
|
|
572
|
+
if (props.layout === "stacked") {
|
|
573
|
+
let posCursor = 0;
|
|
574
|
+
let negCursor = 0;
|
|
575
|
+
for (let s = 0; s < k; s++) {
|
|
576
|
+
const series = seriesList[s];
|
|
577
|
+
const raw = Number(series.data[i] ?? NaN);
|
|
578
|
+
if (!isFinite(raw)) continue;
|
|
579
|
+
const bottom = raw >= 0 ? posCursor : negCursor;
|
|
580
|
+
const top = bottom + raw;
|
|
581
|
+
out.push(
|
|
582
|
+
makeBar(
|
|
583
|
+
valuePixel(bottom),
|
|
584
|
+
valuePixel(top),
|
|
585
|
+
groupStart,
|
|
586
|
+
group,
|
|
587
|
+
series.color ?? defaultColor(s),
|
|
588
|
+
series.opacity ?? 1,
|
|
589
|
+
series.blendMode,
|
|
590
|
+
raw,
|
|
591
|
+
i,
|
|
592
|
+
s,
|
|
593
|
+
),
|
|
594
|
+
);
|
|
595
|
+
if (raw >= 0) posCursor = top;
|
|
596
|
+
else negCursor = top;
|
|
597
|
+
}
|
|
598
|
+
} else if (props.layout === "overlay") {
|
|
599
|
+
for (let s = 0; s < k; s++) {
|
|
600
|
+
const series = seriesList[s];
|
|
601
|
+
const raw = Number(series.data[i] ?? NaN);
|
|
602
|
+
if (!isFinite(raw)) continue;
|
|
603
|
+
out.push(
|
|
604
|
+
makeBar(
|
|
605
|
+
baseline,
|
|
606
|
+
valuePixel(raw),
|
|
607
|
+
groupStart,
|
|
608
|
+
group,
|
|
609
|
+
series.color ?? defaultColor(s),
|
|
610
|
+
series.opacity ?? 1,
|
|
611
|
+
series.blendMode,
|
|
612
|
+
raw,
|
|
613
|
+
i,
|
|
614
|
+
s,
|
|
615
|
+
),
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
} else {
|
|
619
|
+
for (let s = 0; s < k; s++) {
|
|
620
|
+
const series = seriesList[s];
|
|
621
|
+
const raw = Number(series.data[i] ?? NaN);
|
|
622
|
+
if (!isFinite(raw)) continue;
|
|
623
|
+
const barStart = groupStart + (k === 1 ? 0 : s * (bw + props.groupGap));
|
|
624
|
+
out.push(
|
|
625
|
+
makeBar(
|
|
626
|
+
baseline,
|
|
627
|
+
valuePixel(raw),
|
|
628
|
+
barStart,
|
|
629
|
+
bw,
|
|
630
|
+
series.color ?? defaultColor(s),
|
|
631
|
+
series.opacity ?? 1,
|
|
632
|
+
series.blendMode,
|
|
633
|
+
raw,
|
|
634
|
+
i,
|
|
635
|
+
s,
|
|
636
|
+
),
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return out;
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Per-line styling, resolved with defaults but without pixel work. Kept
|
|
646
|
+
* separate from `ResolvedSummaryLine` so the inline legend can depend on
|
|
647
|
+
* it without pulling in the chart's pixel layout (which depends on the
|
|
648
|
+
* legend row count — a circular reference otherwise).
|
|
649
|
+
*/
|
|
650
|
+
type StyledSummaryLine = {
|
|
651
|
+
data: BarChartData;
|
|
652
|
+
x?: BarChartData;
|
|
653
|
+
color: string;
|
|
654
|
+
strokeWidth: number;
|
|
655
|
+
dashed: boolean;
|
|
656
|
+
opacity: number;
|
|
657
|
+
blendMode?: BlendMode;
|
|
658
|
+
dots: boolean;
|
|
659
|
+
dotRadius: number;
|
|
660
|
+
extent: { min: number; max: number };
|
|
661
|
+
legend?: string;
|
|
662
|
+
showInLegend: boolean;
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
type ResolvedSummaryLine = StyledSummaryLine & {
|
|
666
|
+
pathD: string;
|
|
667
|
+
points: { x: number; y: number }[];
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
/** Compute the line's own value extent. */
|
|
671
|
+
function computeLineExtent(line: BarSummaryLine): { min: number; max: number } {
|
|
672
|
+
let min = Infinity;
|
|
673
|
+
let max = -Infinity;
|
|
674
|
+
for (const v of line.data) {
|
|
675
|
+
const n = Number(v);
|
|
676
|
+
if (!isFinite(n)) continue;
|
|
677
|
+
if (n < min) min = n;
|
|
678
|
+
if (n > max) max = n;
|
|
679
|
+
}
|
|
680
|
+
if (!isFinite(min)) {
|
|
681
|
+
min = 0;
|
|
682
|
+
max = 1;
|
|
683
|
+
}
|
|
684
|
+
if (line.valueMin !== undefined) {
|
|
685
|
+
min = line.valueMin;
|
|
686
|
+
} else if (min > 0) {
|
|
687
|
+
// Default to a 0 floor for all-positive data so the line shape
|
|
688
|
+
// reads as "rises from zero" rather than filling the full plot.
|
|
689
|
+
min = 0;
|
|
690
|
+
}
|
|
691
|
+
if (line.valueMax !== undefined) max = line.valueMax;
|
|
692
|
+
if (min === max) max = min + 1;
|
|
693
|
+
return { min, max };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Project a single line point (x in category-index space, y in the
|
|
698
|
+
* line's own value space) to pixels. Orientation flips which axis is
|
|
699
|
+
* categorical vs value.
|
|
700
|
+
*/
|
|
701
|
+
function projectLinePoint(
|
|
702
|
+
x: number,
|
|
703
|
+
y: number,
|
|
704
|
+
extent: { min: number; max: number },
|
|
705
|
+
): { x: number; y: number } {
|
|
706
|
+
const base = isVertical.value ? padding.value.left : padding.value.top;
|
|
707
|
+
const categoricalPx = base + (x + 0.5) * slotSize.value;
|
|
708
|
+
const span = extent.max - extent.min || 1;
|
|
709
|
+
const frac = (y - extent.min) / span;
|
|
710
|
+
if (isVertical.value) {
|
|
711
|
+
return {
|
|
712
|
+
x: categoricalPx,
|
|
713
|
+
y: padding.value.top + innerH.value - frac * innerH.value,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
return {
|
|
717
|
+
x: padding.value.left + frac * innerW.value,
|
|
718
|
+
y: categoricalPx,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const summaryLinesStyled = computed<StyledSummaryLine[]>(() => {
|
|
723
|
+
const lines = props.summaryLines;
|
|
724
|
+
if (!lines || lines.length === 0) return [];
|
|
725
|
+
return lines.map((line, i): StyledSummaryLine => {
|
|
726
|
+
return {
|
|
727
|
+
data: line.data ?? EMPTY_DATA,
|
|
728
|
+
x: line.x,
|
|
729
|
+
color: line.color ?? defaultLineColor(i),
|
|
730
|
+
strokeWidth: line.strokeWidth ?? 2,
|
|
731
|
+
dashed: line.dashed ?? false,
|
|
732
|
+
opacity: line.opacity ?? 1,
|
|
733
|
+
blendMode: line.blendMode,
|
|
734
|
+
dots: line.dots ?? false,
|
|
735
|
+
dotRadius: line.dotRadius ?? (line.strokeWidth ?? 2) + 1,
|
|
736
|
+
extent: computeLineExtent(line),
|
|
737
|
+
legend: line.legend,
|
|
738
|
+
showInLegend: line.showInLegend !== false,
|
|
739
|
+
};
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
const summaryLinesResolved = computed<ResolvedSummaryLine[]>(() => {
|
|
744
|
+
if (slotSize.value === 0) return [];
|
|
745
|
+
return summaryLinesStyled.value.map((line): ResolvedSummaryLine => {
|
|
746
|
+
const points: { x: number; y: number }[] = [];
|
|
747
|
+
let d = "";
|
|
748
|
+
let inSeg = false;
|
|
749
|
+
for (let j = 0; j < line.data.length; j++) {
|
|
750
|
+
const yv = Number(line.data[j]);
|
|
751
|
+
const xv = line.x ? Number(line.x[j]) : j;
|
|
752
|
+
if (!isFinite(yv) || !isFinite(xv)) {
|
|
753
|
+
inSeg = false;
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
const p = projectLinePoint(xv, yv, line.extent);
|
|
757
|
+
points.push(p);
|
|
758
|
+
d += inSeg ? `L${p.x},${p.y}` : `M${p.x},${p.y}`;
|
|
759
|
+
inSeg = true;
|
|
760
|
+
}
|
|
761
|
+
return { ...line, pathD: d, points };
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
const DEFAULT_LINE_COLORS = [
|
|
766
|
+
"var(--color-fg-1, #111)",
|
|
767
|
+
"var(--color-danger, #ef4444)",
|
|
768
|
+
"var(--color-success, #10b981)",
|
|
769
|
+
"var(--color-info, #6366f1)",
|
|
770
|
+
];
|
|
771
|
+
|
|
772
|
+
function defaultLineColor(i: number): string {
|
|
773
|
+
return DEFAULT_LINE_COLORS[i % DEFAULT_LINE_COLORS.length];
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const DEFAULT_COLORS = [
|
|
777
|
+
"var(--color-primary, #3b82f6)",
|
|
778
|
+
"var(--color-accent, #f59e0b)",
|
|
779
|
+
"var(--color-success, #10b981)",
|
|
780
|
+
"var(--color-danger, #ef4444)",
|
|
781
|
+
"var(--color-info, #6366f1)",
|
|
782
|
+
"var(--color-warning, #d97706)",
|
|
783
|
+
];
|
|
784
|
+
|
|
785
|
+
function defaultColor(i: number): string {
|
|
786
|
+
return DEFAULT_COLORS[i % DEFAULT_COLORS.length];
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const formatTooltipValue = makeTooltipValueFormatter(
|
|
790
|
+
() => props.tooltipValueFormat,
|
|
791
|
+
() => props.valueTickFormat,
|
|
792
|
+
);
|
|
793
|
+
|
|
794
|
+
const valueTickItems = computed(() => {
|
|
795
|
+
const { min, max } = valueExtent.value;
|
|
796
|
+
const fmt = (v: number) =>
|
|
797
|
+
props.valueTickFormat !== undefined
|
|
798
|
+
? formatNumber(v, props.valueTickFormat)
|
|
799
|
+
: formatTick(v);
|
|
800
|
+
if (min === max) {
|
|
801
|
+
return [
|
|
802
|
+
{
|
|
803
|
+
value: fmt(min),
|
|
804
|
+
pos: snap(valuePixel(min)),
|
|
805
|
+
},
|
|
806
|
+
];
|
|
807
|
+
}
|
|
808
|
+
const targetTickPixels = isVertical.value ? 50 : 80;
|
|
809
|
+
const values =
|
|
810
|
+
props.valueScaleType === "log"
|
|
811
|
+
? computeLogTickValues({ min, max, ticks: props.valueTicks })
|
|
812
|
+
: computeTickValues({
|
|
813
|
+
min,
|
|
814
|
+
max,
|
|
815
|
+
ticks: props.valueTicks,
|
|
816
|
+
targetTickCount: valueSize.value / targetTickPixels,
|
|
817
|
+
});
|
|
818
|
+
return values.map((v) => ({
|
|
819
|
+
value: fmt(v),
|
|
820
|
+
pos: snap(valuePixel(v)),
|
|
821
|
+
}));
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
interface CategoryTickItem {
|
|
825
|
+
label: string;
|
|
826
|
+
pos: number;
|
|
827
|
+
anchor: "start" | "middle" | "end";
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const categoryTickItems = computed<CategoryTickItem[]>(() => {
|
|
831
|
+
const n = categoryCount.value;
|
|
832
|
+
const dateMs = categoryDatesMs.value;
|
|
833
|
+
|
|
834
|
+
// Date mode: thin labels to the closest category index for each
|
|
835
|
+
// tick boundary picked by `pickDateTicks`. Bar positions stay
|
|
836
|
+
// ordinal — only the labels become date-aware.
|
|
837
|
+
if (dateMs && dateMs.length > 0) {
|
|
838
|
+
let min = Infinity;
|
|
839
|
+
let max = -Infinity;
|
|
840
|
+
for (const m of dateMs) {
|
|
841
|
+
if (!Number.isFinite(m)) continue;
|
|
842
|
+
if (m < min) min = m;
|
|
843
|
+
if (m > max) max = m;
|
|
844
|
+
}
|
|
845
|
+
if (!Number.isFinite(min) || min === max) return [];
|
|
846
|
+
const span = isVertical.value ? innerW.value : innerH.value;
|
|
847
|
+
const target = Math.max(2, Math.floor(span / 80));
|
|
848
|
+
const picked = pickDateTicks(min, max, target, "utc");
|
|
849
|
+
const unit = picked.unit;
|
|
850
|
+
const items: CategoryTickItem[] = [];
|
|
851
|
+
const seen = new Set<number>();
|
|
852
|
+
for (const tickMs of picked.values) {
|
|
853
|
+
let nearest = -1;
|
|
854
|
+
let best = Infinity;
|
|
855
|
+
for (let i = 0; i < dateMs.length; i++) {
|
|
856
|
+
const d = Math.abs(dateMs[i] - tickMs);
|
|
857
|
+
if (d < best) {
|
|
858
|
+
best = d;
|
|
859
|
+
nearest = i;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if (nearest < 0 || seen.has(nearest)) continue;
|
|
863
|
+
seen.add(nearest);
|
|
864
|
+
const center = slotStart(nearest) + slotSize.value / 2;
|
|
865
|
+
const label = props.categoryFormat
|
|
866
|
+
? props.categoryFormat(categoryLabels.value[nearest], nearest)
|
|
867
|
+
: formatDate(dateMs[nearest], props.dateFormat, "utc", unit);
|
|
868
|
+
items.push({ label, pos: center, anchor: "middle" });
|
|
869
|
+
}
|
|
870
|
+
return items;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const out: CategoryTickItem[] = [];
|
|
874
|
+
const fmt = (label: string, i: number) =>
|
|
875
|
+
props.categoryFormat ? props.categoryFormat(label, i) : label;
|
|
876
|
+
const formatted = new Array<string>(n);
|
|
877
|
+
let maxLen = 0;
|
|
878
|
+
for (let i = 0; i < n; i++) {
|
|
879
|
+
formatted[i] = fmt(categoryLabels.value[i], i);
|
|
880
|
+
if (formatted[i].length > maxLen) maxLen = formatted[i].length;
|
|
881
|
+
}
|
|
882
|
+
// Skip labels when slots are too narrow (or short) to fit them
|
|
883
|
+
// without overlap. Vertical orientation measures horizontal label
|
|
884
|
+
// width (≈ chars × fontSize × 0.6); horizontal orientation just
|
|
885
|
+
// measures stacked line height (≈ fontSize × 1.2).
|
|
886
|
+
const fontSize = props.tickLabelStyle?.fontSize ?? TICK_LABEL_FONT_SIZE;
|
|
887
|
+
const minGap = 8;
|
|
888
|
+
const needPerLabel = isVertical.value
|
|
889
|
+
? maxLen * fontSize * 0.6 + minGap
|
|
890
|
+
: fontSize * 1.2 + minGap;
|
|
891
|
+
const stride =
|
|
892
|
+
slotSize.value > 0
|
|
893
|
+
? Math.max(1, Math.ceil(needPerLabel / slotSize.value))
|
|
894
|
+
: 1;
|
|
895
|
+
for (let i = 0; i < n; i += stride) {
|
|
896
|
+
const center = slotStart(i) + slotSize.value / 2;
|
|
897
|
+
out.push({
|
|
898
|
+
label: formatted[i],
|
|
899
|
+
pos: center,
|
|
900
|
+
anchor: "middle",
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
return out;
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
interface InlineLegendItem {
|
|
907
|
+
label: string;
|
|
908
|
+
color: string;
|
|
909
|
+
kind: "bar" | "line";
|
|
910
|
+
dashed?: boolean;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const inlineLegendItems = computed<InlineLegendItem[]>(() => {
|
|
914
|
+
const items: InlineLegendItem[] = [];
|
|
915
|
+
allSeries.value.forEach((s, i) => {
|
|
916
|
+
if (!s.legend || s.showInLegend === false) return;
|
|
917
|
+
items.push({
|
|
918
|
+
label: s.legend,
|
|
919
|
+
color: s.color ?? defaultColor(i),
|
|
920
|
+
kind: "bar",
|
|
921
|
+
});
|
|
922
|
+
});
|
|
923
|
+
summaryLinesStyled.value.forEach((line) => {
|
|
924
|
+
if (!line.legend || !line.showInLegend) return;
|
|
925
|
+
items.push({
|
|
926
|
+
label: line.legend,
|
|
927
|
+
color: line.color,
|
|
928
|
+
kind: "line",
|
|
929
|
+
dashed: line.dashed,
|
|
930
|
+
});
|
|
931
|
+
});
|
|
932
|
+
return items;
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
const inlineLegendLabels = computed(() =>
|
|
936
|
+
inlineLegendItems.value.map((item) => item.label),
|
|
937
|
+
);
|
|
938
|
+
|
|
939
|
+
function toCsv(): string {
|
|
940
|
+
if (typeof props.csv === "function") return props.csv();
|
|
941
|
+
if (typeof props.csv === "string") return props.csv;
|
|
942
|
+
const namedSeries = allSeries.value.map((s) => ({
|
|
943
|
+
label: s.legend,
|
|
944
|
+
data: s.data,
|
|
945
|
+
}));
|
|
946
|
+
return categoricalToCsv(categoryLabels.value, namedSeries);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const hasTooltipSlot = computed(
|
|
950
|
+
() => !!props.tooltipData || !!props.tooltipTrigger,
|
|
951
|
+
);
|
|
952
|
+
|
|
953
|
+
function projectAnnotation(
|
|
954
|
+
x: number,
|
|
955
|
+
y: number,
|
|
956
|
+
): { x: number; y: number } | null {
|
|
957
|
+
if (!isFinite(x) || !isFinite(y)) return null;
|
|
958
|
+
if (slotSize.value === 0) return null;
|
|
959
|
+
const base = isVertical.value ? padding.value.left : padding.value.top;
|
|
960
|
+
const categoricalPx = base + (x + 0.5) * slotSize.value;
|
|
961
|
+
const valuePx = valuePixel(y);
|
|
962
|
+
return isVertical.value
|
|
963
|
+
? { x: categoricalPx, y: valuePx }
|
|
964
|
+
: { x: valuePx, y: categoricalPx };
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function pointerToIndex(clientX: number, clientY: number): number | null {
|
|
968
|
+
const rect = containerRef.value?.getBoundingClientRect();
|
|
969
|
+
if (!rect) return null;
|
|
970
|
+
const n = categoryCount.value;
|
|
971
|
+
if (n === 0 || slotSize.value === 0) return null;
|
|
972
|
+
const local = isVertical.value
|
|
973
|
+
? clientX - rect.left - padding.value.left
|
|
974
|
+
: clientY - rect.top - padding.value.top;
|
|
975
|
+
return Math.max(0, Math.min(n - 1, Math.floor(local / slotSize.value)));
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** Height reserved above the plot for the category/value column headers. */
|
|
979
|
+
const HEADER_ROW_HEIGHT = 22;
|
|
980
|
+
|
|
981
|
+
const hasHeaders = computed(
|
|
982
|
+
() => !!(props.categoryHeader || props.valueHeader),
|
|
983
|
+
);
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* `chartPadding` with extra top room reserved for the column headers when
|
|
987
|
+
* either is set, so they don't overlap the legend or the plot.
|
|
988
|
+
*/
|
|
989
|
+
const effectiveChartPadding = computed<ChartPadding | undefined>(() => {
|
|
990
|
+
const extraTop = hasHeaders.value ? HEADER_ROW_HEIGHT : 0;
|
|
991
|
+
const base = props.chartPadding;
|
|
992
|
+
if (!extraTop) return base;
|
|
993
|
+
if (base == null) return { top: extraTop };
|
|
994
|
+
if (typeof base === "number")
|
|
995
|
+
return { top: base + extraTop, right: base, bottom: base, left: base };
|
|
996
|
+
return { ...base, top: (base.top ?? 0) + extraTop };
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
const {
|
|
1000
|
+
containerRef,
|
|
1001
|
+
svgRef,
|
|
1002
|
+
width,
|
|
1003
|
+
height,
|
|
1004
|
+
padding,
|
|
1005
|
+
legendY,
|
|
1006
|
+
inlineLegendLayout,
|
|
1007
|
+
innerW,
|
|
1008
|
+
innerH,
|
|
1009
|
+
bounds,
|
|
1010
|
+
hoverIndex,
|
|
1011
|
+
tooltipRef,
|
|
1012
|
+
tooltipPos,
|
|
1013
|
+
tooltipHandlers,
|
|
1014
|
+
menuItems,
|
|
1015
|
+
downloadLinkText,
|
|
1016
|
+
csvHref,
|
|
1017
|
+
downloadButtonText,
|
|
1018
|
+
triggerCsvDownload,
|
|
1019
|
+
menuFilename,
|
|
1020
|
+
isFullscreen,
|
|
1021
|
+
fullscreenStyle,
|
|
1022
|
+
teleportTarget,
|
|
1023
|
+
exitFullscreen,
|
|
1024
|
+
} = useChartFoundation({
|
|
1025
|
+
width: () => props.width,
|
|
1026
|
+
height: () => props.height,
|
|
1027
|
+
title: () => props.title,
|
|
1028
|
+
titleStyle: () => props.titleStyle,
|
|
1029
|
+
xLabel: () => props.xLabel,
|
|
1030
|
+
yLabel: () => props.yLabel,
|
|
1031
|
+
debounce: () => props.debounce,
|
|
1032
|
+
menu: () => props.menu,
|
|
1033
|
+
tooltipTrigger: () => props.tooltipTrigger,
|
|
1034
|
+
tooltipClamp: () => props.tooltipClamp,
|
|
1035
|
+
filename: () => props.filename,
|
|
1036
|
+
downloadLink: () => props.downloadLink,
|
|
1037
|
+
downloadButton: () => props.downloadButton,
|
|
1038
|
+
fullscreenTarget: () => props.fullscreenTarget,
|
|
1039
|
+
chartPadding: () => effectiveChartPadding.value,
|
|
1040
|
+
inlineLegendLabels: () => inlineLegendLabels.value,
|
|
1041
|
+
hasTooltipSlot: () => hasTooltipSlot.value,
|
|
1042
|
+
getCsv: toCsv,
|
|
1043
|
+
pointerToIndex,
|
|
1044
|
+
// Bars run along the category axis: vertical orientation scrubs across X
|
|
1045
|
+
// (keep vertical page scroll), horizontal orientation scrubs down Y.
|
|
1046
|
+
scrubAxis: () => (isVertical.value ? "x" : "y"),
|
|
1047
|
+
onHover: (payload) => emit("hover", payload),
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
/** Resolved style for the x/y axis labels. */
|
|
1051
|
+
const axisLabelResolved = computed(() =>
|
|
1052
|
+
resolveLabelStyle(props.axisLabelStyle, { fontSize: AXIS_LABEL_FONT_SIZE }),
|
|
1053
|
+
);
|
|
1054
|
+
/** Resolved style for the axis tick labels. */
|
|
1055
|
+
const tickLabelResolved = computed(() =>
|
|
1056
|
+
resolveLabelStyle(props.tickLabelStyle, {
|
|
1057
|
+
fontSize: TICK_LABEL_FONT_SIZE,
|
|
1058
|
+
fillOpacity: TICK_LABEL_OPACITY,
|
|
1059
|
+
}),
|
|
1060
|
+
);
|
|
1061
|
+
/** Resolved style for inline legend item labels. */
|
|
1062
|
+
const legendResolved = computed(() =>
|
|
1063
|
+
resolveLabelStyle(props.legendStyle, { fontSize: LEGEND_FONT_SIZE }),
|
|
1064
|
+
);
|
|
1065
|
+
|
|
1066
|
+
/** Small inset from the chart's left edge for start-aligned category labels. */
|
|
1067
|
+
const CATEGORY_LABEL_INSET = 2;
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* Left x-anchor for the title and inline legend. When the category labels
|
|
1071
|
+
* are start-aligned (horizontal), the title/legend align to that same left
|
|
1072
|
+
* edge so the whole header column lines up; otherwise they sit at the
|
|
1073
|
+
* plot's left edge.
|
|
1074
|
+
*/
|
|
1075
|
+
const headerLeftX = computed(() =>
|
|
1076
|
+
props.categoryAlign === "start" && !isVertical.value
|
|
1077
|
+
? CATEGORY_LABEL_INSET
|
|
1078
|
+
: padding.value.left,
|
|
1079
|
+
);
|
|
1080
|
+
|
|
1081
|
+
/** Resolved title style with defaults applied. */
|
|
1082
|
+
const titleResolved = computed(() => {
|
|
1083
|
+
const s = props.titleStyle;
|
|
1084
|
+
const align = s?.align ?? "left";
|
|
1085
|
+
const b = bounds.value;
|
|
1086
|
+
const x =
|
|
1087
|
+
align === "left"
|
|
1088
|
+
? headerLeftX.value
|
|
1089
|
+
: align === "right"
|
|
1090
|
+
? b.right
|
|
1091
|
+
: b.left + b.width / 2;
|
|
1092
|
+
const anchor =
|
|
1093
|
+
align === "left" ? "start" : align === "right" ? "end" : "middle";
|
|
1094
|
+
return {
|
|
1095
|
+
lines: (props.title ?? "").split("\n"),
|
|
1096
|
+
fontSize: s?.fontSize ?? TITLE_FONT_SIZE,
|
|
1097
|
+
lineHeight: s?.lineHeight ?? TITLE_LINE_HEIGHT,
|
|
1098
|
+
fontWeight: s?.fontWeight ?? TITLE_FONT_WEIGHT,
|
|
1099
|
+
color: s?.color ?? "currentColor",
|
|
1100
|
+
x,
|
|
1101
|
+
anchor,
|
|
1102
|
+
};
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
const hoveredCategoryLabel = computed(() => {
|
|
1106
|
+
const i = hoverIndex.value;
|
|
1107
|
+
if (i === null) return undefined;
|
|
1108
|
+
// In date mode, format the tooltip label so it honors `dateFormat`
|
|
1109
|
+
// (otherwise the user would see the raw ISO string while the ticks
|
|
1110
|
+
// render with a nicer preset).
|
|
1111
|
+
const dateMs = categoryDatesMs.value;
|
|
1112
|
+
if (dateMs && Number.isFinite(dateMs[i])) {
|
|
1113
|
+
return formatDate(dateMs[i], props.dateFormat, "utc");
|
|
1114
|
+
}
|
|
1115
|
+
return categoryLabels.value[i];
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
const hoverSlotProps = computed(() => {
|
|
1119
|
+
const idx = hoverIndex.value;
|
|
1120
|
+
if (idx === null) return null;
|
|
1121
|
+
const series = allSeries.value;
|
|
1122
|
+
const values: ChartTooltipValue[] = [];
|
|
1123
|
+
for (let i = 0; i < series.length; i++) {
|
|
1124
|
+
const s = series[i];
|
|
1125
|
+
if (s.showInTooltip === false) continue;
|
|
1126
|
+
values.push({
|
|
1127
|
+
value: Number(s.data[idx] ?? NaN),
|
|
1128
|
+
color: s.color ?? defaultColor(i),
|
|
1129
|
+
seriesIndex: i,
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
return {
|
|
1133
|
+
index: idx,
|
|
1134
|
+
category: categoryLabels.value[idx] ?? String(idx),
|
|
1135
|
+
values,
|
|
1136
|
+
data: props.tooltipData?.[idx] ?? null,
|
|
1137
|
+
};
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
/** Pixel rectangle of the hovered category slot (for the highlight band). */
|
|
1141
|
+
const hoverBand = computed(() => {
|
|
1142
|
+
const i = hoverIndex.value;
|
|
1143
|
+
if (i === null) return null;
|
|
1144
|
+
const start = slotStart(i);
|
|
1145
|
+
if (isVertical.value) {
|
|
1146
|
+
return {
|
|
1147
|
+
x: start,
|
|
1148
|
+
y: padding.value.top,
|
|
1149
|
+
w: slotSize.value,
|
|
1150
|
+
h: innerH.value,
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
return {
|
|
1154
|
+
x: padding.value.left,
|
|
1155
|
+
y: start,
|
|
1156
|
+
w: innerW.value,
|
|
1157
|
+
h: slotSize.value,
|
|
1158
|
+
};
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* Legend items joined with their wrapped pixel positions. `x` is the
|
|
1163
|
+
* left edge of the indicator; `y` is the center of the row.
|
|
1164
|
+
*/
|
|
1165
|
+
const positionedLegendItems = computed(() => {
|
|
1166
|
+
const positions = inlineLegendLayout.value.positions;
|
|
1167
|
+
const pad = headerLeftX.value;
|
|
1168
|
+
const baseY = legendY.value;
|
|
1169
|
+
return inlineLegendItems.value.map((item, i) => {
|
|
1170
|
+
const pos = positions[i];
|
|
1171
|
+
return {
|
|
1172
|
+
...item,
|
|
1173
|
+
x: pad + pos.x,
|
|
1174
|
+
y: baseY + pos.row * INLINE_LEGEND_ROW_HEIGHT,
|
|
1175
|
+
};
|
|
1176
|
+
});
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
const BAR_LABEL_FONT_SIZE = 11;
|
|
1180
|
+
/** Estimated glyph width as a fraction of font size (matches axis logic). */
|
|
1181
|
+
const BAR_LABEL_CHAR_WIDTH = 0.6;
|
|
1182
|
+
/** Inset of an inside label / offset of an outside label from the edge. */
|
|
1183
|
+
const BAR_LABEL_PAD = 4;
|
|
1184
|
+
/** Minimum gap between two labels when decluttering. */
|
|
1185
|
+
const BAR_LABEL_GAP = 4;
|
|
1186
|
+
|
|
1187
|
+
interface ResolvedBarLabels {
|
|
1188
|
+
format?: NumberFormat | ((value: number, ctx: BarLabelContext) => string);
|
|
1189
|
+
color: string;
|
|
1190
|
+
placement: "auto" | "inside" | "outside";
|
|
1191
|
+
align: "start" | "center" | "end";
|
|
1192
|
+
overlap: "shift" | "hide";
|
|
1193
|
+
minSize: number;
|
|
1194
|
+
fontSize: number;
|
|
1195
|
+
fontWeight: number | string | undefined;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const barLabelOptions = computed<ResolvedBarLabels | null>(() => {
|
|
1199
|
+
const b = props.barLabels;
|
|
1200
|
+
if (!b) return null;
|
|
1201
|
+
const o = b === true ? {} : b;
|
|
1202
|
+
return {
|
|
1203
|
+
format: o.format,
|
|
1204
|
+
color: o.color ?? "auto",
|
|
1205
|
+
placement: o.placement ?? "auto",
|
|
1206
|
+
align: o.align ?? "center",
|
|
1207
|
+
overlap: o.overlap ?? "shift",
|
|
1208
|
+
minSize: o.minSize ?? 0,
|
|
1209
|
+
fontSize: o.fontSize ?? BAR_LABEL_FONT_SIZE,
|
|
1210
|
+
fontWeight: o.fontWeight,
|
|
1211
|
+
};
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
function formatBarLabel(value: number, ctx: BarLabelContext): string {
|
|
1215
|
+
const f = barLabelOptions.value?.format;
|
|
1216
|
+
if (typeof f === "function") return f(value, ctx);
|
|
1217
|
+
if (f !== undefined) return formatNumber(value, f);
|
|
1218
|
+
if (props.valueTickFormat !== undefined)
|
|
1219
|
+
return formatNumber(value, props.valueTickFormat);
|
|
1220
|
+
return formatTick(value);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Fill color of the bar covering pixel (px, py) within category `cat`, or
|
|
1225
|
+
* null when no bar sits there (the chart background). Used to choose a
|
|
1226
|
+
* readable color for a label that overflows onto a neighboring segment.
|
|
1227
|
+
*/
|
|
1228
|
+
function barColorAt(cat: number, px: number, py: number): string | null {
|
|
1229
|
+
// Iterate in reverse so the topmost (last-drawn) bar wins under overlap.
|
|
1230
|
+
for (let i = bars.value.length - 1; i >= 0; i--) {
|
|
1231
|
+
const b = bars.value[i];
|
|
1232
|
+
if (b.categoryIndex !== cat) continue;
|
|
1233
|
+
if (px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + b.h)
|
|
1234
|
+
return b.color;
|
|
1235
|
+
}
|
|
1236
|
+
return null;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
interface BarLabelItem {
|
|
1240
|
+
key: string;
|
|
1241
|
+
x: number;
|
|
1242
|
+
y: number;
|
|
1243
|
+
text: string;
|
|
1244
|
+
anchor: "start" | "middle" | "end";
|
|
1245
|
+
fill: string;
|
|
1246
|
+
fontSize: number;
|
|
1247
|
+
fontWeight: number | string | undefined;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
const barLabelItems = computed<BarLabelItem[]>(() => {
|
|
1251
|
+
const opts = barLabelOptions.value;
|
|
1252
|
+
if (!opts) return [];
|
|
1253
|
+
const vertical = isVertical.value;
|
|
1254
|
+
const fontSize = opts.fontSize;
|
|
1255
|
+
|
|
1256
|
+
// Build a draft label per bar, computing its placement (inside vs
|
|
1257
|
+
// outside) and a 1D extent [lo, hi] along the value axis in a
|
|
1258
|
+
// "value-increasing" coordinate `u` (rightward when horizontal, upward
|
|
1259
|
+
// when vertical). Collision resolution then runs per category in `u`.
|
|
1260
|
+
interface Draft {
|
|
1261
|
+
bar: (typeof bars.value)[number];
|
|
1262
|
+
text: string;
|
|
1263
|
+
textLen: number;
|
|
1264
|
+
inside: boolean;
|
|
1265
|
+
cross: number; // fixed coordinate on the categorical axis
|
|
1266
|
+
lo: number; // low-value edge of the text in u
|
|
1267
|
+
hi: number; // high-value edge of the text in u
|
|
1268
|
+
// Horizontal text-anchor for this label (vertical bars always center).
|
|
1269
|
+
svgAnchor: "start" | "middle" | "end";
|
|
1270
|
+
hidden: boolean;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
const drafts: Draft[] = [];
|
|
1274
|
+
for (const bar of bars.value) {
|
|
1275
|
+
const text = formatBarLabel(bar.value, {
|
|
1276
|
+
value: bar.value,
|
|
1277
|
+
categoryIndex: bar.categoryIndex,
|
|
1278
|
+
seriesIndex: bar.seriesIndex,
|
|
1279
|
+
category:
|
|
1280
|
+
categoryLabels.value[bar.categoryIndex] ?? String(bar.categoryIndex),
|
|
1281
|
+
});
|
|
1282
|
+
if (!text) continue;
|
|
1283
|
+
|
|
1284
|
+
const segSize = vertical ? bar.h : bar.w;
|
|
1285
|
+
const textLen = text.length * fontSize * BAR_LABEL_CHAR_WIDTH;
|
|
1286
|
+
const fits =
|
|
1287
|
+
segSize >= textLen + 2 * BAR_LABEL_PAD && segSize >= opts.minSize;
|
|
1288
|
+
const inside =
|
|
1289
|
+
opts.placement === "inside"
|
|
1290
|
+
? true
|
|
1291
|
+
: opts.placement === "outside"
|
|
1292
|
+
? false
|
|
1293
|
+
: fits;
|
|
1294
|
+
|
|
1295
|
+
// `u` increases with value: rightward (x) for horizontal bars, upward
|
|
1296
|
+
// (-y) for vertical bars. The label occupies a span of `len` in u —
|
|
1297
|
+
// its text width when horizontal, its line height when vertical.
|
|
1298
|
+
let lo: number;
|
|
1299
|
+
let hi: number;
|
|
1300
|
+
let cross: number;
|
|
1301
|
+
let svgAnchor: "start" | "middle" | "end" = "middle";
|
|
1302
|
+
const segHiU = vertical ? -bar.y : bar.x + bar.w; // high-value edge
|
|
1303
|
+
const segLoU = vertical ? -(bar.y + bar.h) : bar.x; // low-value edge
|
|
1304
|
+
const len = vertical ? fontSize : textLen;
|
|
1305
|
+
cross = vertical ? bar.x + bar.w / 2 : bar.y + bar.h / 2;
|
|
1306
|
+
if (!inside) {
|
|
1307
|
+
// Just past the high-value edge, growing outward.
|
|
1308
|
+
lo = segHiU + BAR_LABEL_PAD;
|
|
1309
|
+
hi = lo + len;
|
|
1310
|
+
svgAnchor = "start";
|
|
1311
|
+
} else if (opts.align === "start") {
|
|
1312
|
+
lo = segLoU + BAR_LABEL_PAD;
|
|
1313
|
+
hi = lo + len;
|
|
1314
|
+
svgAnchor = "start";
|
|
1315
|
+
} else if (opts.align === "end") {
|
|
1316
|
+
hi = segHiU - BAR_LABEL_PAD;
|
|
1317
|
+
lo = hi - len;
|
|
1318
|
+
svgAnchor = "end";
|
|
1319
|
+
} else {
|
|
1320
|
+
const c = (segHiU + segLoU) / 2;
|
|
1321
|
+
lo = c - len / 2;
|
|
1322
|
+
hi = c + len / 2;
|
|
1323
|
+
svgAnchor = "middle";
|
|
1324
|
+
}
|
|
1325
|
+
drafts.push({
|
|
1326
|
+
bar,
|
|
1327
|
+
text,
|
|
1328
|
+
textLen,
|
|
1329
|
+
inside,
|
|
1330
|
+
cross,
|
|
1331
|
+
lo,
|
|
1332
|
+
hi,
|
|
1333
|
+
svgAnchor,
|
|
1334
|
+
hidden: false,
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// Declutter per category along `u`: sweep low→high, pushing or hiding
|
|
1339
|
+
// labels that would overlap the previous one. Bounds keep a pushed label
|
|
1340
|
+
// from sliding off the chart.
|
|
1341
|
+
const uMax = vertical ? -padding.value.top : width.value - 2;
|
|
1342
|
+
const byCategory = new Map<number, Draft[]>();
|
|
1343
|
+
for (const d of drafts) {
|
|
1344
|
+
const arr = byCategory.get(d.bar.categoryIndex);
|
|
1345
|
+
if (arr) arr.push(d);
|
|
1346
|
+
else byCategory.set(d.bar.categoryIndex, [d]);
|
|
1347
|
+
}
|
|
1348
|
+
for (const group of byCategory.values()) {
|
|
1349
|
+
group.sort((a, b) => a.lo - b.lo);
|
|
1350
|
+
let cursor = -Infinity;
|
|
1351
|
+
for (const d of group) {
|
|
1352
|
+
if (d.lo < cursor + BAR_LABEL_GAP) {
|
|
1353
|
+
if (opts.overlap === "hide") {
|
|
1354
|
+
d.hidden = true;
|
|
1355
|
+
continue;
|
|
1356
|
+
}
|
|
1357
|
+
const shift = cursor + BAR_LABEL_GAP - d.lo;
|
|
1358
|
+
d.lo += shift;
|
|
1359
|
+
d.hi += shift;
|
|
1360
|
+
d.inside = false; // a shifted label no longer sits in its segment
|
|
1361
|
+
d.svgAnchor = "start";
|
|
1362
|
+
}
|
|
1363
|
+
if (d.hi > uMax) {
|
|
1364
|
+
d.hidden = true;
|
|
1365
|
+
continue;
|
|
1366
|
+
}
|
|
1367
|
+
cursor = d.hi;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
const auto = opts.color === "auto";
|
|
1372
|
+
const items: BarLabelItem[] = [];
|
|
1373
|
+
for (const d of drafts) {
|
|
1374
|
+
if (d.hidden) continue;
|
|
1375
|
+
let x: number;
|
|
1376
|
+
let y: number;
|
|
1377
|
+
let anchor: "start" | "middle" | "end";
|
|
1378
|
+
if (vertical) {
|
|
1379
|
+
// Vertical bars center the text horizontally; align shifted the
|
|
1380
|
+
// [lo, hi] band along the value (y) axis, so render at its center.
|
|
1381
|
+
x = d.cross;
|
|
1382
|
+
y = -((d.lo + d.hi) / 2);
|
|
1383
|
+
anchor = "middle";
|
|
1384
|
+
} else {
|
|
1385
|
+
anchor = d.svgAnchor;
|
|
1386
|
+
x =
|
|
1387
|
+
anchor === "start" ? d.lo : anchor === "end" ? d.hi : (d.lo + d.hi) / 2;
|
|
1388
|
+
y = d.cross;
|
|
1389
|
+
}
|
|
1390
|
+
// Pick contrast against whatever the label actually sits on. Inside
|
|
1391
|
+
// labels are on their own segment; an outside/shifted label commonly
|
|
1392
|
+
// lands on a neighboring segment of the same stack — use that
|
|
1393
|
+
// segment's fill, falling back to the chart background (currentColor)
|
|
1394
|
+
// only when the label clears all bars.
|
|
1395
|
+
let fill: string;
|
|
1396
|
+
if (!auto) {
|
|
1397
|
+
fill = opts.color;
|
|
1398
|
+
} else if (d.inside) {
|
|
1399
|
+
fill = pickContrastColor(d.bar.color);
|
|
1400
|
+
} else {
|
|
1401
|
+
const cx = vertical ? d.cross : (d.lo + d.hi) / 2;
|
|
1402
|
+
const cy = vertical ? -((d.lo + d.hi) / 2) : d.cross;
|
|
1403
|
+
const under = barColorAt(d.bar.categoryIndex, cx, cy);
|
|
1404
|
+
fill = under ? pickContrastColor(under) : "currentColor";
|
|
1405
|
+
}
|
|
1406
|
+
items.push({
|
|
1407
|
+
key: `${d.bar.categoryIndex}-${d.bar.seriesIndex}`,
|
|
1408
|
+
x,
|
|
1409
|
+
y,
|
|
1410
|
+
text: d.text,
|
|
1411
|
+
anchor,
|
|
1412
|
+
fill,
|
|
1413
|
+
fontSize,
|
|
1414
|
+
fontWeight: opts.fontWeight,
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
return items;
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* Anchor + x for the category labels (and matching `categoryHeader`) in
|
|
1422
|
+
* horizontal orientation, driven by `categoryAlign`. Vertical orientation
|
|
1423
|
+
* doesn't use this (labels stay centered under each column).
|
|
1424
|
+
*/
|
|
1425
|
+
const categoryLabelLayout = computed<{
|
|
1426
|
+
anchor: "start" | "middle" | "end";
|
|
1427
|
+
x: number;
|
|
1428
|
+
}>(() => {
|
|
1429
|
+
const right = padding.value.left - 6;
|
|
1430
|
+
switch (props.categoryAlign ?? "end") {
|
|
1431
|
+
case "start":
|
|
1432
|
+
return { anchor: "start", x: CATEGORY_LABEL_INSET };
|
|
1433
|
+
case "center":
|
|
1434
|
+
return { anchor: "middle", x: (CATEGORY_LABEL_INSET + right) / 2 };
|
|
1435
|
+
default:
|
|
1436
|
+
return { anchor: "end", x: right };
|
|
1437
|
+
}
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1440
|
+
interface ColumnHeader {
|
|
1441
|
+
key: string;
|
|
1442
|
+
text: string;
|
|
1443
|
+
x: number;
|
|
1444
|
+
y: number;
|
|
1445
|
+
anchor: "start" | "middle" | "end";
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* Positioned column headers (`categoryHeader` / `valueHeader`), drawn in
|
|
1450
|
+
* the reserved row just above the plot. The category header is aligned to
|
|
1451
|
+
* match the category labels; the value header starts where the bars do.
|
|
1452
|
+
*/
|
|
1453
|
+
const columnHeaders = computed<ColumnHeader[]>(() => {
|
|
1454
|
+
const out: ColumnHeader[] = [];
|
|
1455
|
+
const y = padding.value.top - 7;
|
|
1456
|
+
if (props.valueHeader) {
|
|
1457
|
+
out.push({
|
|
1458
|
+
key: "vh",
|
|
1459
|
+
text: props.valueHeader,
|
|
1460
|
+
x: padding.value.left,
|
|
1461
|
+
y,
|
|
1462
|
+
anchor: "start",
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
if (props.categoryHeader) {
|
|
1466
|
+
const horizontal = !isVertical.value;
|
|
1467
|
+
const cat = categoryLabelLayout.value;
|
|
1468
|
+
out.push({
|
|
1469
|
+
key: "ch",
|
|
1470
|
+
text: props.categoryHeader,
|
|
1471
|
+
x: horizontal ? cat.x : padding.value.left,
|
|
1472
|
+
y,
|
|
1473
|
+
anchor: horizontal ? cat.anchor : "start",
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
return out;
|
|
1477
|
+
});
|
|
1478
|
+
</script>
|
|
1479
|
+
|
|
1480
|
+
<template>
|
|
1481
|
+
<Teleport :to="teleportTarget" :disabled="!isFullscreen">
|
|
1482
|
+
<div
|
|
1483
|
+
ref="containerRef"
|
|
1484
|
+
v-bind="$attrs"
|
|
1485
|
+
class="bar-chart-wrapper"
|
|
1486
|
+
:class="{ 'is-fullscreen': isFullscreen }"
|
|
1487
|
+
:style="fullscreenStyle"
|
|
1488
|
+
>
|
|
1489
|
+
<ChartMenu
|
|
1490
|
+
v-if="menu"
|
|
1491
|
+
:items="menuItems"
|
|
1492
|
+
:is-fullscreen="isFullscreen"
|
|
1493
|
+
@close="exitFullscreen"
|
|
1494
|
+
/>
|
|
1495
|
+
<div class="chart-sr-only" aria-live="polite">
|
|
1496
|
+
{{ isFullscreen ? "Chart expanded to fill window" : "" }}
|
|
1497
|
+
</div>
|
|
1498
|
+
<svg
|
|
1499
|
+
ref="svgRef"
|
|
1500
|
+
:width="width"
|
|
1501
|
+
:height="height"
|
|
1502
|
+
:role="chartRole || undefined"
|
|
1503
|
+
:aria-label="chartAriaLabel || undefined"
|
|
1504
|
+
>
|
|
1505
|
+
<!-- title -->
|
|
1506
|
+
<text
|
|
1507
|
+
v-if="title"
|
|
1508
|
+
:x="titleResolved.x"
|
|
1509
|
+
:y="titleResolved.lineHeight"
|
|
1510
|
+
:text-anchor="titleResolved.anchor"
|
|
1511
|
+
:font-size="titleResolved.fontSize"
|
|
1512
|
+
:font-weight="titleResolved.fontWeight"
|
|
1513
|
+
:fill="titleResolved.color"
|
|
1514
|
+
>
|
|
1515
|
+
<tspan
|
|
1516
|
+
v-for="(line, i) in titleResolved.lines"
|
|
1517
|
+
:key="i"
|
|
1518
|
+
:x="titleResolved.x"
|
|
1519
|
+
:dy="i === 0 ? 0 : titleResolved.lineHeight"
|
|
1520
|
+
>
|
|
1521
|
+
{{ line }}
|
|
1522
|
+
</tspan>
|
|
1523
|
+
</text>
|
|
1524
|
+
<!-- column headers (category / value), in the reserved row above the plot -->
|
|
1525
|
+
<text
|
|
1526
|
+
v-for="h in columnHeaders"
|
|
1527
|
+
:key="h.key"
|
|
1528
|
+
:data-testid="h.key === 'ch' ? 'category-header' : 'value-header'"
|
|
1529
|
+
:x="h.x"
|
|
1530
|
+
:y="h.y"
|
|
1531
|
+
:text-anchor="h.anchor"
|
|
1532
|
+
:font-size="axisLabelResolved.fontSize"
|
|
1533
|
+
:fill="axisLabelResolved.fill"
|
|
1534
|
+
font-weight="600"
|
|
1535
|
+
>
|
|
1536
|
+
{{ h.text }}
|
|
1537
|
+
</text>
|
|
1538
|
+
<!-- inline legend -->
|
|
1539
|
+
<g v-if="positionedLegendItems.length > 0">
|
|
1540
|
+
<template
|
|
1541
|
+
v-for="(item, i) in positionedLegendItems"
|
|
1542
|
+
:key="'ileg' + i"
|
|
1543
|
+
>
|
|
1544
|
+
<rect
|
|
1545
|
+
v-if="item.kind === 'bar'"
|
|
1546
|
+
:x="item.x"
|
|
1547
|
+
:y="item.y - 5"
|
|
1548
|
+
width="12"
|
|
1549
|
+
height="10"
|
|
1550
|
+
:fill="item.color"
|
|
1551
|
+
/>
|
|
1552
|
+
<line
|
|
1553
|
+
v-else
|
|
1554
|
+
:x1="item.x"
|
|
1555
|
+
:y1="item.y"
|
|
1556
|
+
:x2="item.x + 12"
|
|
1557
|
+
:y2="item.y"
|
|
1558
|
+
:stroke="item.color"
|
|
1559
|
+
stroke-width="2"
|
|
1560
|
+
:stroke-dasharray="item.dashed ? '4 2' : undefined"
|
|
1561
|
+
/>
|
|
1562
|
+
<text
|
|
1563
|
+
:x="item.x + 18"
|
|
1564
|
+
:y="item.y + 4"
|
|
1565
|
+
:font-size="legendResolved.fontSize"
|
|
1566
|
+
:fill="legendResolved.fill"
|
|
1567
|
+
:font-weight="legendResolved.fontWeight"
|
|
1568
|
+
>
|
|
1569
|
+
{{ item.label }}
|
|
1570
|
+
</text>
|
|
1571
|
+
</template>
|
|
1572
|
+
</g>
|
|
1573
|
+
<!-- axes (the value axis line is suppressed when valueAxis is false) -->
|
|
1574
|
+
<line
|
|
1575
|
+
v-if="!isVertical || valueAxis"
|
|
1576
|
+
:x1="snap(padding.left)"
|
|
1577
|
+
:y1="snap(padding.top)"
|
|
1578
|
+
:x2="snap(padding.left)"
|
|
1579
|
+
:y2="snap(padding.top + innerH)"
|
|
1580
|
+
stroke="currentColor"
|
|
1581
|
+
stroke-opacity="0.3"
|
|
1582
|
+
/>
|
|
1583
|
+
<line
|
|
1584
|
+
v-if="isVertical || valueAxis"
|
|
1585
|
+
:x1="snap(padding.left)"
|
|
1586
|
+
:y1="snap(padding.top + innerH)"
|
|
1587
|
+
:x2="snap(padding.left + innerW)"
|
|
1588
|
+
:y2="snap(padding.top + innerH)"
|
|
1589
|
+
stroke="currentColor"
|
|
1590
|
+
stroke-opacity="0.3"
|
|
1591
|
+
/>
|
|
1592
|
+
<!-- value grid lines -->
|
|
1593
|
+
<template v-if="valueGrid && valueAxis">
|
|
1594
|
+
<line
|
|
1595
|
+
v-for="(tick, i) in valueTickItems"
|
|
1596
|
+
:key="'vg' + i"
|
|
1597
|
+
:x1="isVertical ? padding.left : tick.pos"
|
|
1598
|
+
:y1="isVertical ? tick.pos : padding.top"
|
|
1599
|
+
:x2="isVertical ? padding.left + innerW : tick.pos"
|
|
1600
|
+
:y2="isVertical ? tick.pos : padding.top + innerH"
|
|
1601
|
+
stroke="currentColor"
|
|
1602
|
+
stroke-opacity="0.1"
|
|
1603
|
+
/>
|
|
1604
|
+
</template>
|
|
1605
|
+
<!-- hover highlight band (rendered behind bars) -->
|
|
1606
|
+
<rect
|
|
1607
|
+
v-if="hoverBand && hasTooltipSlot"
|
|
1608
|
+
:x="hoverBand.x"
|
|
1609
|
+
:y="hoverBand.y"
|
|
1610
|
+
:width="hoverBand.w"
|
|
1611
|
+
:height="hoverBand.h"
|
|
1612
|
+
fill="currentColor"
|
|
1613
|
+
fill-opacity="0.06"
|
|
1614
|
+
pointer-events="none"
|
|
1615
|
+
/>
|
|
1616
|
+
<!-- value tick labels -->
|
|
1617
|
+
<template v-if="valueAxis && isVertical">
|
|
1618
|
+
<text
|
|
1619
|
+
v-for="(tick, i) in valueTickItems"
|
|
1620
|
+
:key="'vt' + i"
|
|
1621
|
+
data-testid="value-tick"
|
|
1622
|
+
:x="padding.left - 6"
|
|
1623
|
+
:y="tick.pos"
|
|
1624
|
+
text-anchor="end"
|
|
1625
|
+
dominant-baseline="middle"
|
|
1626
|
+
:font-size="tickLabelResolved.fontSize"
|
|
1627
|
+
:fill="tickLabelResolved.fill"
|
|
1628
|
+
:font-weight="tickLabelResolved.fontWeight"
|
|
1629
|
+
:fill-opacity="tickLabelResolved.fillOpacity"
|
|
1630
|
+
>
|
|
1631
|
+
{{ tick.value }}
|
|
1632
|
+
</text>
|
|
1633
|
+
</template>
|
|
1634
|
+
<template v-else-if="valueAxis">
|
|
1635
|
+
<text
|
|
1636
|
+
v-for="(tick, i) in valueTickItems"
|
|
1637
|
+
:key="'vt' + i"
|
|
1638
|
+
data-testid="value-tick"
|
|
1639
|
+
:x="tick.pos"
|
|
1640
|
+
:y="padding.top + innerH + 16"
|
|
1641
|
+
text-anchor="middle"
|
|
1642
|
+
:font-size="tickLabelResolved.fontSize"
|
|
1643
|
+
:fill="tickLabelResolved.fill"
|
|
1644
|
+
:font-weight="tickLabelResolved.fontWeight"
|
|
1645
|
+
:fill-opacity="tickLabelResolved.fillOpacity"
|
|
1646
|
+
>
|
|
1647
|
+
{{ tick.value }}
|
|
1648
|
+
</text>
|
|
1649
|
+
</template>
|
|
1650
|
+
<!-- y axis label -->
|
|
1651
|
+
<text
|
|
1652
|
+
v-if="yLabel"
|
|
1653
|
+
:x="0"
|
|
1654
|
+
:y="0"
|
|
1655
|
+
:transform="`translate(14, ${padding.top + innerH / 2}) rotate(-90)`"
|
|
1656
|
+
text-anchor="middle"
|
|
1657
|
+
:font-size="axisLabelResolved.fontSize"
|
|
1658
|
+
:fill="axisLabelResolved.fill"
|
|
1659
|
+
:font-weight="axisLabelResolved.fontWeight"
|
|
1660
|
+
>
|
|
1661
|
+
{{ yLabel }}
|
|
1662
|
+
</text>
|
|
1663
|
+
<!-- category tick labels -->
|
|
1664
|
+
<template v-if="isVertical">
|
|
1665
|
+
<text
|
|
1666
|
+
v-for="(tick, i) in categoryTickItems"
|
|
1667
|
+
:key="'ct' + i"
|
|
1668
|
+
data-testid="category-tick"
|
|
1669
|
+
:x="tick.pos"
|
|
1670
|
+
:y="padding.top + innerH + 16"
|
|
1671
|
+
:text-anchor="tick.anchor"
|
|
1672
|
+
:font-size="tickLabelResolved.fontSize"
|
|
1673
|
+
:fill="tickLabelResolved.fill"
|
|
1674
|
+
:font-weight="tickLabelResolved.fontWeight"
|
|
1675
|
+
:fill-opacity="tickLabelResolved.fillOpacity"
|
|
1676
|
+
>
|
|
1677
|
+
{{ tick.label }}
|
|
1678
|
+
</text>
|
|
1679
|
+
</template>
|
|
1680
|
+
<template v-else>
|
|
1681
|
+
<text
|
|
1682
|
+
v-for="(tick, i) in categoryTickItems"
|
|
1683
|
+
:key="'ct' + i"
|
|
1684
|
+
data-testid="category-tick"
|
|
1685
|
+
:x="categoryLabelLayout.x"
|
|
1686
|
+
:y="tick.pos"
|
|
1687
|
+
:text-anchor="categoryLabelLayout.anchor"
|
|
1688
|
+
dominant-baseline="middle"
|
|
1689
|
+
:font-size="tickLabelResolved.fontSize"
|
|
1690
|
+
:fill="tickLabelResolved.fill"
|
|
1691
|
+
:font-weight="tickLabelResolved.fontWeight"
|
|
1692
|
+
:fill-opacity="tickLabelResolved.fillOpacity"
|
|
1693
|
+
>
|
|
1694
|
+
{{ tick.label }}
|
|
1695
|
+
</text>
|
|
1696
|
+
</template>
|
|
1697
|
+
<!-- x axis label -->
|
|
1698
|
+
<text
|
|
1699
|
+
v-if="xLabel"
|
|
1700
|
+
:x="padding.left + innerW / 2"
|
|
1701
|
+
:y="height - 4"
|
|
1702
|
+
text-anchor="middle"
|
|
1703
|
+
:font-size="axisLabelResolved.fontSize"
|
|
1704
|
+
:fill="axisLabelResolved.fill"
|
|
1705
|
+
:font-weight="axisLabelResolved.fontWeight"
|
|
1706
|
+
>
|
|
1707
|
+
{{ xLabel }}
|
|
1708
|
+
</text>
|
|
1709
|
+
<!-- bars -->
|
|
1710
|
+
<rect
|
|
1711
|
+
v-for="(bar, i) in bars"
|
|
1712
|
+
:key="'bar' + i"
|
|
1713
|
+
data-testid="bar"
|
|
1714
|
+
:data-category="bar.categoryIndex"
|
|
1715
|
+
:data-series="bar.seriesIndex"
|
|
1716
|
+
:x="bar.x"
|
|
1717
|
+
:y="bar.y"
|
|
1718
|
+
:width="bar.w"
|
|
1719
|
+
:height="bar.h"
|
|
1720
|
+
:fill="bar.color"
|
|
1721
|
+
:fill-opacity="bar.opacity"
|
|
1722
|
+
:style="bar.blendMode ? { mixBlendMode: bar.blendMode } : undefined"
|
|
1723
|
+
/>
|
|
1724
|
+
<!-- value-on-bar labels -->
|
|
1725
|
+
<text
|
|
1726
|
+
v-for="item in barLabelItems"
|
|
1727
|
+
:key="'blbl' + item.key"
|
|
1728
|
+
data-testid="bar-label"
|
|
1729
|
+
:x="item.x"
|
|
1730
|
+
:y="item.y"
|
|
1731
|
+
:text-anchor="item.anchor"
|
|
1732
|
+
dominant-baseline="middle"
|
|
1733
|
+
:font-size="item.fontSize"
|
|
1734
|
+
:font-weight="item.fontWeight"
|
|
1735
|
+
:fill="item.fill"
|
|
1736
|
+
pointer-events="none"
|
|
1737
|
+
>
|
|
1738
|
+
{{ item.text }}
|
|
1739
|
+
</text>
|
|
1740
|
+
<!-- summary lines (drawn above bars, below annotations) -->
|
|
1741
|
+
<template v-for="(line, i) in summaryLinesResolved" :key="'sl' + i">
|
|
1742
|
+
<path
|
|
1743
|
+
v-if="line.pathD"
|
|
1744
|
+
data-testid="summary-line"
|
|
1745
|
+
:d="line.pathD"
|
|
1746
|
+
fill="none"
|
|
1747
|
+
:stroke="line.color"
|
|
1748
|
+
:stroke-width="line.strokeWidth"
|
|
1749
|
+
:stroke-opacity="line.opacity"
|
|
1750
|
+
:stroke-dasharray="line.dashed ? '6 3' : undefined"
|
|
1751
|
+
stroke-linecap="round"
|
|
1752
|
+
stroke-linejoin="round"
|
|
1753
|
+
:style="
|
|
1754
|
+
line.blendMode ? { mixBlendMode: line.blendMode } : undefined
|
|
1755
|
+
"
|
|
1756
|
+
/>
|
|
1757
|
+
<template v-if="line.dots">
|
|
1758
|
+
<circle
|
|
1759
|
+
v-for="(pt, j) in line.points"
|
|
1760
|
+
:key="'sld' + i + '-' + j"
|
|
1761
|
+
:cx="pt.x"
|
|
1762
|
+
:cy="pt.y"
|
|
1763
|
+
:r="line.dotRadius"
|
|
1764
|
+
:fill="line.color"
|
|
1765
|
+
:fill-opacity="line.opacity"
|
|
1766
|
+
:style="
|
|
1767
|
+
line.blendMode ? { mixBlendMode: line.blendMode } : undefined
|
|
1768
|
+
"
|
|
1769
|
+
/>
|
|
1770
|
+
</template>
|
|
1771
|
+
</template>
|
|
1772
|
+
<!-- Tooltip: interaction overlay -->
|
|
1773
|
+
<rect
|
|
1774
|
+
v-if="hasTooltipSlot"
|
|
1775
|
+
:x="padding.left"
|
|
1776
|
+
:y="padding.top"
|
|
1777
|
+
:width="innerW"
|
|
1778
|
+
:height="innerH"
|
|
1779
|
+
fill="transparent"
|
|
1780
|
+
:style="`cursor: crosshair; touch-action: ${isVertical ? 'pan-y' : 'pan-x'}`"
|
|
1781
|
+
v-on="tooltipHandlers"
|
|
1782
|
+
/>
|
|
1783
|
+
<!-- annotations (top layer) -->
|
|
1784
|
+
<ChartAnnotations
|
|
1785
|
+
v-if="annotations && annotations.length > 0"
|
|
1786
|
+
:annotations="annotations"
|
|
1787
|
+
:project="projectAnnotation"
|
|
1788
|
+
:bounds="bounds"
|
|
1789
|
+
/>
|
|
1790
|
+
</svg>
|
|
1791
|
+
<!-- Tooltip floating content -->
|
|
1792
|
+
<div
|
|
1793
|
+
v-if="hasTooltipSlot && hoverIndex !== null && hoverSlotProps"
|
|
1794
|
+
ref="tooltipRef"
|
|
1795
|
+
class="chart-tooltip-content"
|
|
1796
|
+
:style="{
|
|
1797
|
+
position: 'absolute',
|
|
1798
|
+
top: '0',
|
|
1799
|
+
left: '0',
|
|
1800
|
+
willChange: 'transform',
|
|
1801
|
+
transform: tooltipPos
|
|
1802
|
+
? `translate3d(${tooltipPos.left}px, ${tooltipPos.top}px, 0) translateY(-50%)`
|
|
1803
|
+
: 'translateY(-50%)',
|
|
1804
|
+
visibility: tooltipPos ? 'visible' : 'hidden',
|
|
1805
|
+
}"
|
|
1806
|
+
>
|
|
1807
|
+
<slot name="tooltip" v-bind="hoverSlotProps">
|
|
1808
|
+
<div class="bar-chart-tooltip">
|
|
1809
|
+
<div v-if="hoveredCategoryLabel" class="bar-chart-tooltip-label">
|
|
1810
|
+
{{ hoveredCategoryLabel }}
|
|
1811
|
+
</div>
|
|
1812
|
+
<div
|
|
1813
|
+
v-for="v in hoverSlotProps.values"
|
|
1814
|
+
:key="v.seriesIndex"
|
|
1815
|
+
class="bar-chart-tooltip-row"
|
|
1816
|
+
>
|
|
1817
|
+
<span
|
|
1818
|
+
class="bar-chart-tooltip-swatch"
|
|
1819
|
+
:style="{ background: v.color }"
|
|
1820
|
+
/>
|
|
1821
|
+
{{ isFinite(v.value) ? formatTooltipValue(v.value) : "—" }}
|
|
1822
|
+
</div>
|
|
1823
|
+
</div>
|
|
1824
|
+
</slot>
|
|
1825
|
+
</div>
|
|
1826
|
+
<a
|
|
1827
|
+
v-if="downloadLinkText"
|
|
1828
|
+
class="bar-chart-download-link"
|
|
1829
|
+
:href="csvHref!"
|
|
1830
|
+
:download="`${menuFilename()}.csv`"
|
|
1831
|
+
>
|
|
1832
|
+
{{ downloadLinkText }}
|
|
1833
|
+
</a>
|
|
1834
|
+
<button
|
|
1835
|
+
v-if="downloadButtonText"
|
|
1836
|
+
type="button"
|
|
1837
|
+
class="bar-chart-download-button"
|
|
1838
|
+
@click="triggerCsvDownload"
|
|
1839
|
+
>
|
|
1840
|
+
{{ downloadButtonText }}
|
|
1841
|
+
</button>
|
|
1842
|
+
</div>
|
|
1843
|
+
</Teleport>
|
|
1844
|
+
</template>
|
|
1845
|
+
|
|
1846
|
+
<style scoped>
|
|
1847
|
+
.bar-chart-wrapper {
|
|
1848
|
+
position: relative;
|
|
1849
|
+
width: 100%;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
.bar-chart-tooltip-label {
|
|
1853
|
+
font-weight: 600;
|
|
1854
|
+
margin-bottom: 0.25em;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
.bar-chart-tooltip-row {
|
|
1858
|
+
display: flex;
|
|
1859
|
+
align-items: center;
|
|
1860
|
+
gap: 0.375em;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
.bar-chart-download-link {
|
|
1864
|
+
display: block;
|
|
1865
|
+
text-align: right;
|
|
1866
|
+
font-size: var(--font-size-sm);
|
|
1867
|
+
margin-top: 0.25em;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
.bar-chart-tooltip-swatch {
|
|
1871
|
+
display: inline-block;
|
|
1872
|
+
width: 0.625em;
|
|
1873
|
+
height: 0.625em;
|
|
1874
|
+
border-radius: 50%;
|
|
1875
|
+
flex-shrink: 0;
|
|
1876
|
+
}
|
|
1877
|
+
</style>
|
|
1878
|
+
|
|
1879
|
+
<style>
|
|
1880
|
+
.bar-chart-download-button {
|
|
1881
|
+
display: inline-flex;
|
|
1882
|
+
align-items: center;
|
|
1883
|
+
margin-top: 0.5em;
|
|
1884
|
+
padding: 0.5em 1em;
|
|
1885
|
+
border: 1px solid var(--color-border);
|
|
1886
|
+
border-radius: 0.25em;
|
|
1887
|
+
background: var(--color-bg-0, #fff);
|
|
1888
|
+
color: var(--color-text);
|
|
1889
|
+
font-size: var(--font-size-sm);
|
|
1890
|
+
cursor: pointer;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
.bar-chart-download-button:hover {
|
|
1894
|
+
background: var(--color-bg-1, rgba(0, 0, 0, 0.05));
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
.bar-chart-download-button:focus-visible {
|
|
1898
|
+
outline: 2px solid var(--color-primary);
|
|
1899
|
+
outline-offset: 2px;
|
|
1900
|
+
}
|
|
1901
|
+
</style>
|