nuxt-charts 3.0.0-beta.4 → 3.1.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.
Files changed (58) hide show
  1. package/README.md +77 -6
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +56 -3
  4. package/dist/runtime/assets/components.css +1 -1
  5. package/dist/runtime/assets/theme.css +1 -1
  6. package/dist/runtime/components/AreaChart.vue +1 -0
  7. package/dist/runtime/components/BarChart.vue +1 -0
  8. package/dist/runtime/components/DataChart.vue +91 -0
  9. package/dist/runtime/components/DataChart.vue.d.ts +27 -0
  10. package/dist/runtime/components/DonutChart.vue +1 -1
  11. package/dist/runtime/components/DottedMap.vue +261 -0
  12. package/dist/runtime/components/DottedMap.vue.d.ts +45 -0
  13. package/dist/runtime/components/FunnelChart.vue +2 -2
  14. package/dist/runtime/components/LineChart.vue +1 -0
  15. package/dist/runtime/components/MetricChart.vue +322 -0
  16. package/dist/runtime/components/MetricChart.vue.d.ts +54 -0
  17. package/dist/runtime/components/RadialBarChart.vue +25 -3
  18. package/dist/runtime/components/SankeyChart.vue +3 -3
  19. package/dist/runtime/components/TopoJSONMap.vue +273 -0
  20. package/dist/runtime/components/TopoJSONMap.vue.d.ts +45 -0
  21. package/dist/runtime/components/internal/CartesianFrame.vue +74 -4
  22. package/dist/runtime/components/internal/CartesianFrame.vue.d.ts +5 -0
  23. package/dist/runtime/components/internal/ChartAccessibility.vue.d.ts +1 -1
  24. package/dist/runtime/components/internal/ChartLegend.vue.d.ts +1 -1
  25. package/dist/runtime/components/internal/CubeBarShape.vue.d.ts +1 -1
  26. package/dist/runtime/components/internal/MapCanvas.vue +297 -0
  27. package/dist/runtime/components/internal/MapCanvas.vue.d.ts +79 -0
  28. package/dist/runtime/components/internal/VariantBarShape.vue.d.ts +2 -2
  29. package/dist/runtime/data/world.json +182 -0
  30. package/dist/runtime/maps.d.ts +2 -0
  31. package/dist/runtime/maps.js +1 -0
  32. package/dist/runtime/spec/csv.d.ts +14 -0
  33. package/dist/runtime/spec/csv.js +13 -0
  34. package/dist/runtime/spec/index.d.ts +11 -0
  35. package/dist/runtime/spec/index.js +5 -0
  36. package/dist/runtime/spec/metric.d.ts +130 -0
  37. package/dist/runtime/spec/metric.js +63 -0
  38. package/dist/runtime/spec/resolve.d.ts +44 -0
  39. package/dist/runtime/spec/resolve.js +41 -0
  40. package/dist/runtime/spec/transform.d.ts +58 -0
  41. package/dist/runtime/spec/transform.js +199 -0
  42. package/dist/runtime/spec/types.d.ts +132 -0
  43. package/dist/runtime/spec/types.js +2 -0
  44. package/dist/runtime/types/charts.d.ts +131 -0
  45. package/dist/runtime/types/maps.d.ts +237 -0
  46. package/dist/runtime/types/maps.js +0 -0
  47. package/dist/runtime/types/shared.d.ts +5 -0
  48. package/dist/runtime/types.d.ts +3 -0
  49. package/dist/runtime/utils/axis.d.ts +18 -0
  50. package/dist/runtime/utils/axis.js +44 -1
  51. package/dist/runtime/utils/categories.d.ts +1 -1
  52. package/dist/runtime/utils/categories.js +2 -2
  53. package/dist/runtime/utils/dottedMap.d.ts +14 -0
  54. package/dist/runtime/utils/dottedMap.js +69 -0
  55. package/dist/runtime/utils/maps.d.ts +33 -0
  56. package/dist/runtime/utils/maps.js +77 -0
  57. package/dist/runtime/utils/theme.js +3 -0
  58. package/package.json +23 -3
@@ -0,0 +1,261 @@
1
+ <script setup>
2
+ import { computed, shallowRef } from "vue";
3
+ import { geoGraticule10, geoPath } from "d3-geo";
4
+ import { containsMapPoint, createMapProjection, DEFAULT_MAP_REGION, inRegion, MAP_REGIONS, mapFeatures, mapValue, projectMapPoint, regionGeometry } from "../utils/maps";
5
+ import { filterMapCountries, generateMapDots, getPin } from "../utils/dottedMap";
6
+ import { sampleData } from "../utils/data";
7
+ import MapCanvas from "./internal/MapCanvas.vue";
8
+ const props = defineProps({
9
+ geoJson: { type: Object, required: false },
10
+ mapFeatureKey: { type: String, required: false },
11
+ regionName: { type: String, required: false },
12
+ region: { type: Object, required: false },
13
+ countries: { type: Array, required: false },
14
+ pins: { type: Array, required: false },
15
+ mapHeight: { type: Number, required: false },
16
+ mapWidth: { type: Number, required: false },
17
+ grid: { type: String, required: false },
18
+ shape: { type: String, required: false },
19
+ dotSize: { type: Number, required: false },
20
+ color: { type: String, required: false },
21
+ countryColors: { type: Object, required: false },
22
+ strokeColor: { type: String, required: false },
23
+ strokeWidth: { type: Number, required: false },
24
+ strokeOpacity: { type: Number, required: false },
25
+ avoidOuterPins: { type: Boolean, required: false },
26
+ precomputedMap: { type: [String, Object], required: false },
27
+ defaultZoom: { type: Number, required: false },
28
+ showLegend: { type: Boolean, required: false },
29
+ legend: { type: Array, required: false },
30
+ maxHeight: { type: [Number, String], required: false },
31
+ pinColor: { type: [String, Function], required: false },
32
+ pinSize: { type: [Number, Function], required: false },
33
+ showPinLabels: { type: Boolean, required: false },
34
+ tooltipTitleFormatter: { type: Function, required: false },
35
+ height: { type: [Number, String], required: false },
36
+ width: { type: [Number, String], required: false },
37
+ projection: { type: [String, Function, Object], required: false },
38
+ fitView: { type: Boolean, required: false },
39
+ fitViewPadding: { type: Number, required: false },
40
+ padding: { type: [Number, Object], required: false },
41
+ disableZoom: { type: Boolean, required: false },
42
+ zoomFactor: { type: Number, required: false },
43
+ zoomExtent: { type: Array, required: false },
44
+ zoomOnScroll: { type: Boolean, required: false },
45
+ zoomDuration: { type: Number, required: false },
46
+ duration: { type: Number, required: false },
47
+ showControls: { type: Boolean, required: false },
48
+ hideTooltip: { type: Boolean, required: false },
49
+ hideLegend: { type: Boolean, required: false },
50
+ categories: { type: Object, required: false },
51
+ legendPosition: { type: String, required: false },
52
+ legendStyle: { type: [String, Object], required: false },
53
+ backgroundColor: { type: String, required: false },
54
+ theme: { type: Object, required: false },
55
+ showGraticule: { type: Boolean, required: false },
56
+ showSphere: { type: Boolean, required: false },
57
+ graticuleColor: { type: String, required: false },
58
+ sphereColor: { type: String, required: false },
59
+ ariaLabel: { type: String, required: false },
60
+ ariaDescription: { type: String, required: false },
61
+ accessibleDataTable: { type: Boolean, required: false },
62
+ maxDataPoints: { type: Number, required: false },
63
+ emptyLabel: { type: String, required: false },
64
+ error: { type: String, required: false },
65
+ legendVariant: { type: String, required: false },
66
+ tooltipVariant: { type: String, required: false },
67
+ tooltipRoundness: { type: String, required: false },
68
+ loading: { type: Boolean, required: false },
69
+ loadingLabel: { type: String, required: false }
70
+ });
71
+ defineSlots();
72
+ const emit = defineEmits(["pin-click", "point-click", "mouseenter", "mouseleave", "zoom"]);
73
+ const canvas = shallowRef();
74
+ const size = shallowRef([960, typeof props.height === "number" ? props.height : 400]);
75
+ const precomputed = computed(() => {
76
+ try {
77
+ if (!props.precomputedMap) return { map: void 0, error: void 0 };
78
+ const map = typeof props.precomputedMap === "string" ? JSON.parse(props.precomputedMap) : props.precomputedMap;
79
+ if (!(map.width > 0 && map.height > 0 && map.points)) {
80
+ throw new Error("Precomputed map must contain width, height, and points.");
81
+ }
82
+ return { map, error: void 0 };
83
+ } catch (error) {
84
+ return { map: void 0, error: error instanceof Error ? error.message : "Invalid precomputed map" };
85
+ }
86
+ });
87
+ const countries = computed(() => props.countries ?? precomputed.value.map?.countries ?? (props.regionName === "usa" ? ["USA"] : void 0));
88
+ const region = computed(() => {
89
+ if (props.region) return props.region;
90
+ if (precomputed.value.map?.region) return precomputed.value.map.region;
91
+ if (props.regionName && props.regionName !== "world") return MAP_REGIONS[props.regionName];
92
+ return !countries.value?.length && !props.geoJson ? DEFAULT_MAP_REGION : void 0;
93
+ });
94
+ const geometry = computed(() => {
95
+ try {
96
+ return {
97
+ collection: filterMapCountries(mapFeatures(props.geoJson, props.mapFeatureKey), countries.value),
98
+ error: void 0
99
+ };
100
+ } catch (error) {
101
+ return {
102
+ collection: { type: "FeatureCollection", features: [] },
103
+ error: error instanceof Error ? error.message : "Invalid map geometry"
104
+ };
105
+ }
106
+ });
107
+ const precomputedLayout = computed(() => {
108
+ const map = precomputed.value.map;
109
+ const scale = map ? Math.min(size.value[0] / map.width, size.value[1] / map.height) : 1;
110
+ return {
111
+ scale,
112
+ x: map ? (size.value[0] - map.width * scale) / 2 : 0,
113
+ y: map ? (size.value[1] - map.height * scale) / 2 : 0
114
+ };
115
+ });
116
+ const view = computed(() => {
117
+ const map = precomputed.value.map;
118
+ const land = geometry.value.collection;
119
+ const fitGeometry = region.value ? regionGeometry(region.value) : land.features.length ? land : { type: "Sphere" };
120
+ const projection = createMapProjection(
121
+ props.projection ?? map?.projection ?? "mercator",
122
+ fitGeometry,
123
+ size.value[0],
124
+ size.value[1],
125
+ props,
126
+ region.value
127
+ );
128
+ const layout = precomputedLayout.value;
129
+ if (map?.scale !== void 0 && map.translate) {
130
+ projection.scale(map.scale * layout.scale).translate([
131
+ map.translate[0] * layout.scale + layout.x,
132
+ map.translate[1] * layout.scale + layout.y
133
+ ]);
134
+ } else if (map?.X_MIN !== void 0 && map.Y_MAX !== void 0 && map.X_RANGE) {
135
+ const scale = map.width * 6378137 / map.X_RANGE;
136
+ projection.scale(scale * layout.scale).translate([
137
+ -map.X_MIN * map.width / map.X_RANGE * layout.scale + layout.x,
138
+ map.Y_MAX / 6378137 * scale * layout.scale + layout.y
139
+ ]);
140
+ }
141
+ return { projection, path: geoPath(projection) };
142
+ });
143
+ const grid = computed(() => {
144
+ try {
145
+ const map = precomputed.value.map;
146
+ if (map) {
147
+ const layout = precomputedLayout.value;
148
+ const dots2 = Object.values(map.points).map((dot) => ({
149
+ ...dot,
150
+ x: dot.x * layout.scale + layout.x,
151
+ y: dot.y * layout.scale + layout.y
152
+ }));
153
+ return { dots: dots2, spacing: layout.scale, error: void 0 };
154
+ }
155
+ const spacing = props.mapWidth ? size.value[0] / Math.max(1, props.mapWidth) : size.value[1] / Math.max(1, props.mapHeight ?? 60);
156
+ const dots = generateMapDots(
157
+ geometry.value.collection,
158
+ view.value.projection,
159
+ size.value[0],
160
+ size.value[1],
161
+ { ...props, region: region.value }
162
+ );
163
+ return { dots, spacing, error: void 0 };
164
+ } catch (error) {
165
+ return { dots: [], spacing: 1, error: error instanceof Error ? error.message : "Invalid dotted map data" };
166
+ }
167
+ });
168
+ function hexagon(x, y, radius) {
169
+ return Array.from({ length: 6 }, (_, index) => {
170
+ const angle = Math.PI / 3 * index;
171
+ return `${x + Math.cos(angle) * radius},${y + Math.sin(angle) * radius}`;
172
+ }).join(" ");
173
+ }
174
+ const marks = computed(() => {
175
+ const spacing = grid.value.spacing;
176
+ const dots = grid.value.dots.map((dot, index) => {
177
+ const radius = (dot.svgOptions?.radius ?? props.dotSize ?? 0.3) * spacing;
178
+ return {
179
+ key: `dot-${index}`,
180
+ kind: "dot",
181
+ values: dot,
182
+ x: dot.x,
183
+ y: dot.y,
184
+ radius,
185
+ polygon: props.shape === "hexagon" ? hexagon(dot.x, dot.y, radius) : void 0,
186
+ color: dot.svgOptions?.color ?? props.countryColors?.[dot.countryId ?? ""] ?? props.color ?? "var(--vc-map-dot-color)",
187
+ stroke: dot.svgOptions?.strokeColor ?? props.strokeColor,
188
+ strokeWidth: (dot.svgOptions?.strokeWidth ?? props.strokeWidth ?? 0) * spacing,
189
+ strokeOpacity: dot.svgOptions?.strokeOpacity ?? props.strokeOpacity ?? 1,
190
+ label: dot.label ?? dot.countryId
191
+ };
192
+ });
193
+ const pins = sampleData(props.pins ?? [], props.maxDataPoints ?? 2e3).flatMap((pin, index) => {
194
+ const coordinates = [pin.lng, pin.lat];
195
+ if (!inRegion(coordinates, region.value)) return [];
196
+ if (props.avoidOuterPins && !containsMapPoint(geometry.value.collection, coordinates)) return [];
197
+ let p = projectMapPoint(view.value.projection, coordinates);
198
+ if (!p) return [];
199
+ if (precomputed.value.map) {
200
+ const dot = getPin(precomputed.value.map, pin);
201
+ const layout = precomputedLayout.value;
202
+ p = [dot.x * layout.scale + layout.x, dot.y * layout.scale + layout.y];
203
+ }
204
+ const label = pin.label ?? String(pin.data?.name ?? pin.data?.city ?? pin.id ?? "Location");
205
+ return [{
206
+ key: `pin-${pin.id ?? index}`,
207
+ kind: "pin",
208
+ values: pin,
209
+ x: p[0],
210
+ y: p[1],
211
+ radius: mapValue(props.pinSize, pin, index, pin.radius ?? (pin.svgOptions?.radius === void 0 ? 5 : pin.svgOptions.radius * spacing)),
212
+ color: mapValue(props.pinColor, pin, index, pin.color ?? pin.svgOptions?.color ?? "var(--vc-series-0)"),
213
+ stroke: pin.svgOptions?.strokeColor ?? "var(--vc-surface-bg)",
214
+ strokeWidth: pin.svgOptions?.strokeWidth ?? 1,
215
+ strokeOpacity: pin.svgOptions?.strokeOpacity ?? 1,
216
+ label,
217
+ showLabel: props.showPinLabels
218
+ }];
219
+ });
220
+ return [...dots, ...pins];
221
+ });
222
+ const categories = computed(() => props.categories ?? (props.legend ? Object.fromEntries(props.legend.map((item, index) => [String(index), { name: item.label, color: item.color }])) : void 0));
223
+ function click(mark, event) {
224
+ if (mark.kind === "pin") emit("pin-click", mark.values, event);
225
+ else emit("point-click", event, mark.values);
226
+ }
227
+ defineExpose({
228
+ zoomIn: () => canvas.value?.zoomIn(),
229
+ zoomOut: () => canvas.value?.zoomOut(),
230
+ zoomTo: (k) => canvas.value?.zoomTo(k),
231
+ resetView: () => canvas.value?.resetView()
232
+ });
233
+ </script>
234
+
235
+ <template>
236
+ <MapCanvas
237
+ ref="canvas"
238
+ v-bind="props"
239
+ :style="{ maxHeight: typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight }"
240
+ :marks="marks"
241
+ :size="size"
242
+ :geometry-error="geometry.error ?? precomputed.error ?? grid.error"
243
+ :categories="categories"
244
+ :hide-legend="hideLegend || showLegend === false"
245
+ :zoom-factor="zoomFactor ?? defaultZoom"
246
+ :sphere="showSphere ? view.path({ type: 'Sphere' }) ?? void 0 : void 0"
247
+ :graticule="showGraticule ? view.path(geoGraticule10()) ?? void 0 : void 0"
248
+ :tooltip-title="tooltipTitleFormatter ? (datum) => tooltipTitleFormatter(datum) : void 0"
249
+ @resize="size = $event"
250
+ @click="click"
251
+ @mouseenter="(mark, event) => emit('mouseenter', mark.values, event)"
252
+ @mouseleave="(mark, event) => emit('mouseleave', mark.values, event)"
253
+ @zoom="emit('zoom', $event)"
254
+ >
255
+ <template v-if="$slots.tooltip" #tooltip="scope"><slot name="tooltip" v-bind="scope" /></template>
256
+ <template v-if="$slots.pin" #pin="scope"><slot name="pin" v-bind="scope" :values="scope.values" :projection="view.projection" :points="grid.dots" /></template>
257
+ <template v-if="$slots.dot" #dot="scope"><slot name="dot" v-bind="scope" :values="scope.values" :projection="view.projection" :points="grid.dots" /></template>
258
+ <template v-if="$slots.legend" #legend="scope"><slot name="legend" v-bind="scope" /></template>
259
+ <template v-if="$slots.default" #default="scope"><slot v-bind="scope" :projection="view.projection" :points="grid.dots" /></template>
260
+ </MapCanvas>
261
+ </template>
@@ -0,0 +1,45 @@
1
+ import type { DottedMapProps, MapDot, MapPin, MapZoom, MapMarkSlot, MapOverlaySlot, DottedMapTooltipSlot } from "../types/maps.js";
2
+ type __VLS_Slots = {
3
+ tooltip?: (props: DottedMapTooltipSlot) => unknown;
4
+ pin?: (props: MapMarkSlot<MapPin> & {
5
+ points: MapDot[];
6
+ }) => unknown;
7
+ dot?: (props: MapMarkSlot<MapDot> & {
8
+ points: MapDot[];
9
+ }) => unknown;
10
+ legend?: (props: {
11
+ items: Array<{
12
+ value: string;
13
+ color?: string;
14
+ }>;
15
+ }) => unknown;
16
+ default?: (props: MapOverlaySlot & {
17
+ points: MapDot[];
18
+ }) => unknown;
19
+ };
20
+ declare const __VLS_base: import("vue").DefineComponent<DottedMapProps, {
21
+ zoomIn: () => void | undefined;
22
+ zoomOut: () => void | undefined;
23
+ zoomTo: (k: number) => void | undefined;
24
+ resetView: () => void | undefined;
25
+ }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
26
+ mouseenter: (values: MapPin | MapDot, event: MouseEvent | FocusEvent) => any;
27
+ mouseleave: (values: MapPin | MapDot, event: MouseEvent | FocusEvent) => any;
28
+ zoom: (transform: MapZoom) => any;
29
+ "pin-click": (pin: MapPin, event: MouseEvent | KeyboardEvent) => any;
30
+ "point-click": (event: MouseEvent | KeyboardEvent, point: MapDot) => any;
31
+ }, string, import("vue").PublicProps, Readonly<DottedMapProps> & Readonly<{
32
+ onMouseenter?: ((values: MapPin | MapDot, event: MouseEvent | FocusEvent) => any) | undefined;
33
+ onMouseleave?: ((values: MapPin | MapDot, event: MouseEvent | FocusEvent) => any) | undefined;
34
+ onZoom?: ((transform: MapZoom) => any) | undefined;
35
+ "onPin-click"?: ((pin: MapPin, event: MouseEvent | KeyboardEvent) => any) | undefined;
36
+ "onPoint-click"?: ((event: MouseEvent | KeyboardEvent, point: MapDot) => any) | undefined;
37
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
38
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
39
+ declare const _default: typeof __VLS_export;
40
+ export default _default;
41
+ type __VLS_WithSlots<T, S> = T & {
42
+ new (): {
43
+ $slots: S;
44
+ };
45
+ };
@@ -64,8 +64,8 @@ const stages = computed(() => {
64
64
  name,
65
65
  value,
66
66
  formattedValue: props.valueFormatter?.(value, sourceIndex),
67
- fill: cats[sourceIndex]?.color ?? `var(--chart-color-${sourceIndex})`,
68
- color: cats[sourceIndex]?.color ?? `var(--chart-color-${sourceIndex})`
67
+ fill: cats[sourceIndex]?.color ?? `var(--vc-series-${sourceIndex % 8})`,
68
+ color: cats[sourceIndex]?.color ?? `var(--vc-series-${sourceIndex % 8})`
69
69
  }));
70
70
  });
71
71
  const accessibleRows = computed(() => stages.value.map((stage) => ({ label: stage.name, values: [{ label: "Value", value: stage.formattedValue ?? stage.value }] })));
@@ -109,6 +109,7 @@ defineSlots();
109
109
  :frame-class="frameClass"
110
110
  skeleton-shape="wave"
111
111
  v-bind="props"
112
+ :value-stacked="stacked === true"
112
113
  >
113
114
  <!--
114
115
  Built with `h()` so the filter lands in the SVG namespace and the
@@ -0,0 +1,322 @@
1
+ <script setup>
2
+ import { computed, ref, useId, watch } from "vue";
3
+ import DataChart from "./DataChart.vue";
4
+ import { transform } from "../spec/transform";
5
+ import { formatBucket, seriesLabel } from "../spec/resolve";
6
+ import { toCsv } from "../spec/csv";
7
+ import {
8
+ DEFAULT_RANGES,
9
+ formatChange,
10
+ formatMetricValue,
11
+ metricDelta,
12
+ metricSpec,
13
+ resolveRange
14
+ } from "../spec/metric";
15
+ const props = defineProps({
16
+ data: { type: Array, required: true },
17
+ title: { type: String, required: false },
18
+ description: { type: String, required: false },
19
+ x: { type: String, required: true },
20
+ y: { type: String, required: false },
21
+ series: { type: String, required: false },
22
+ aggregate: { type: String, required: false, default: "sum" },
23
+ interval: { type: String, required: false },
24
+ compare: { type: String, required: false },
25
+ type: { type: String, required: false, default: "line" },
26
+ format: { type: String, required: false, default: "number" },
27
+ currency: { type: String, required: false, default: "EUR" },
28
+ locale: { type: String, required: false },
29
+ formatOptions: { type: null, required: false },
30
+ valueFormatter: { type: Function, required: false },
31
+ inverseSentiment: { type: Boolean, required: false },
32
+ ranges: { type: Array, required: false },
33
+ range: { type: String, required: false },
34
+ window: { type: Object, required: false },
35
+ anchor: { type: [String, Number, Date], required: false },
36
+ height: { type: Number, required: false, default: 220 },
37
+ stacked: { type: Boolean, required: false },
38
+ hideLegend: { type: Boolean, required: false },
39
+ theme: { type: Object, required: false },
40
+ showTable: { type: Boolean, required: false },
41
+ exportable: { type: Boolean, required: false },
42
+ exportFilename: { type: String, required: false },
43
+ hideValue: { type: Boolean, required: false },
44
+ ariaLabel: { type: String, required: false },
45
+ ariaDescription: { type: String, required: false },
46
+ accessibleDataTable: { type: Boolean, required: false },
47
+ maxDataPoints: { type: Number, required: false },
48
+ emptyLabel: { type: String, required: false },
49
+ error: { type: String, required: false },
50
+ legendVariant: { type: String, required: false },
51
+ tooltipVariant: { type: String, required: false },
52
+ tooltipRoundness: { type: String, required: false },
53
+ loading: { type: Boolean, required: false },
54
+ loadingLabel: { type: String, required: false }
55
+ });
56
+ const rangeModel = defineModel("range", { type: null, ...{ default: void 0 } });
57
+ const emit = defineEmits(["update:window", "export"]);
58
+ const ranges = computed(() => props.ranges ?? DEFAULT_RANGES);
59
+ const activeRange = computed(() => {
60
+ const list = ranges.value;
61
+ if (!list.length) return void 0;
62
+ return list.find((range) => range.key === rangeModel.value) ?? list[1] ?? list[0];
63
+ });
64
+ const window = computed(() => {
65
+ if (props.window) return props.window;
66
+ const range = activeRange.value;
67
+ if (!range) return void 0;
68
+ return resolveRange(range, props.anchor);
69
+ });
70
+ watch(window, (value) => {
71
+ if (value) emit("update:window", value);
72
+ }, { immediate: true });
73
+ const interval = computed(() => props.interval ?? activeRange.value?.interval ?? "day");
74
+ const spec = computed(
75
+ () => metricSpec({
76
+ type: props.type,
77
+ x: props.x,
78
+ y: props.y,
79
+ series: props.series,
80
+ aggregate: props.aggregate,
81
+ interval: interval.value,
82
+ compare: props.compare,
83
+ // With no ranges and no explicit window, the metric covers all of history:
84
+ // an empty range would filter every row out, which reads as "no data" when
85
+ // the truth is "no window was asked for".
86
+ range: window.value ?? { from: "0000-01-01", to: "9999-12-31" },
87
+ label: props.title
88
+ })
89
+ );
90
+ const result = computed(() => transform(props.data ?? [], spec.value));
91
+ const formatValue = computed(() => {
92
+ if (props.valueFormatter) return props.valueFormatter;
93
+ return (value) => formatMetricValue(value, {
94
+ format: props.format,
95
+ currency: props.currency,
96
+ locale: props.locale,
97
+ options: props.formatOptions
98
+ });
99
+ });
100
+ const headline = computed(() => {
101
+ const { total, data, series } = result.value;
102
+ const aggregate = props.aggregate;
103
+ if (aggregate === "sum" || aggregate === "count") return total;
104
+ const primary = series.find((key) => key !== "compare");
105
+ if (!primary) return total;
106
+ const values = data.map((datum) => datum[primary]).filter((value) => typeof value === "number");
107
+ if (!values.length) return 0;
108
+ if (aggregate === "min") return Math.min(...values);
109
+ if (aggregate === "max") return Math.max(...values);
110
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
111
+ });
112
+ const headlineText = computed(() => formatValue.value(headline.value));
113
+ const delta = computed(() => {
114
+ const change = result.value.change;
115
+ const text = formatChange(change, props.locale);
116
+ if (text === void 0) return void 0;
117
+ return { text, ...metricDelta(change, props.inverseSentiment) };
118
+ });
119
+ const compareLabel = computed(
120
+ () => props.compare === "previous-year" ? "vs previous year" : "vs previous period"
121
+ );
122
+ const absoluteChange = computed(() => {
123
+ const { total, compareTotal } = result.value;
124
+ if (compareTotal === void 0) return void 0;
125
+ const difference = total - compareTotal;
126
+ const sign = difference > 0 ? "+" : difference < 0 ? "\u2212" : "";
127
+ return `${sign}${formatValue.value(Math.abs(difference))}`;
128
+ });
129
+ const isEmpty = computed(
130
+ () => !props.loading && !props.error && result.value.data.length === 0
131
+ );
132
+ const showingTable = ref(false);
133
+ const tableColumns = computed(
134
+ () => result.value.series.map((key) => ({ key, label: seriesLabel(key, spec.value) }))
135
+ );
136
+ const tableRows = computed(
137
+ () => result.value.data.map((datum) => ({
138
+ x: formatBucket(datum.x, interval.value, props.locale),
139
+ values: result.value.series.map((key) => {
140
+ const value = datum[key];
141
+ return typeof value === "number" ? formatValue.value(value) : "\u2014";
142
+ })
143
+ }))
144
+ );
145
+ function slugify(value) {
146
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "metric";
147
+ }
148
+ function exportCsv() {
149
+ const headers = Object.fromEntries(
150
+ tableColumns.value.map((column) => [column.key, column.label])
151
+ );
152
+ const csv = toCsv(result.value.data, result.value.series, headers, props.x);
153
+ emit("export", csv);
154
+ if (typeof document === "undefined") return;
155
+ const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" });
156
+ const url = URL.createObjectURL(blob);
157
+ const link = document.createElement("a");
158
+ link.href = url;
159
+ link.download = props.exportFilename ?? `${slugify(props.title ?? "metric")}.csv`;
160
+ link.click();
161
+ setTimeout(() => URL.revokeObjectURL(url), 0);
162
+ }
163
+ const titleId = useId();
164
+ const valueId = useId();
165
+ const valueLabel = computed(() => {
166
+ const parts = [props.title, headlineText.value].filter(Boolean);
167
+ if (delta.value) {
168
+ const direction = delta.value.direction === "up" ? "up" : delta.value.direction === "down" ? "down" : "unchanged";
169
+ parts.push(`${direction} ${delta.value.text} ${compareLabel.value}`);
170
+ }
171
+ return parts.join(", ");
172
+ });
173
+ defineExpose({ result, spec, exportCsv });
174
+ defineSlots();
175
+ </script>
176
+
177
+ <template>
178
+ <section
179
+ class="vc-metric"
180
+ :aria-labelledby="title ? titleId : void 0"
181
+ :aria-busy="loading ? 'true' : void 0"
182
+ >
183
+ <header class="vc-metric__header">
184
+ <slot name="header">
185
+ <div class="vc-metric__heading">
186
+ <h3 v-if="title" :id="titleId" class="vc-metric__title">{{ title }}</h3>
187
+ <p v-if="description" class="vc-metric__description">{{ description }}</p>
188
+ </div>
189
+
190
+ <slot
191
+ name="value"
192
+ :value="headline"
193
+ :formatted="headlineText"
194
+ :change="result.change"
195
+ >
196
+ <div v-if="!hideValue" class="vc-metric__value-row">
197
+ <!--
198
+ The skeleton stands in for the number rather than showing a stale
199
+ or zero total while data is in flight: a headline that renders
200
+ "€0" and then jumps is worse than one that visibly waits.
201
+ -->
202
+ <div v-if="loading" class="vc-metric__value-skeleton" aria-hidden="true" />
203
+ <template v-else>
204
+ <p :id="valueId" class="vc-metric__value" :aria-label="valueLabel">
205
+ {{ headlineText }}
206
+ </p>
207
+ <p
208
+ v-if="delta"
209
+ class="vc-metric__delta"
210
+ :data-sentiment="delta.sentiment"
211
+ aria-hidden="true"
212
+ >
213
+ <span class="vc-metric__delta-arrow">{{
214
+ delta.direction === "up" ? "\u2191" : delta.direction === "down" ? "\u2193" : "\u2192"
215
+ }}</span>
216
+ <span>{{ delta.text }}</span>
217
+ <span v-if="absoluteChange" class="vc-metric__delta-absolute">({{ absoluteChange }})</span>
218
+ <span class="vc-metric__delta-label">{{ compareLabel }}</span>
219
+ </p>
220
+ </template>
221
+ </div>
222
+ </slot>
223
+ </slot>
224
+ </header>
225
+
226
+ <!--
227
+ The table replaces the chart rather than sitting beside it, so the two
228
+ never disagree about how much vertical space the card takes — a card that
229
+ grows on toggle shoves everything below it down the page.
230
+ -->
231
+ <div v-if="showingTable" class="vc-metric__table-wrap" :style="{ minHeight: `${height}px` }">
232
+ <table class="vc-metric__table">
233
+ <caption class="vc-sr-only">{{ title ?? "Metric data" }}</caption>
234
+ <thead>
235
+ <tr>
236
+ <th scope="col">{{ x }}</th>
237
+ <th v-for="column in tableColumns" :key="column.key" scope="col">{{ column.label }}</th>
238
+ </tr>
239
+ </thead>
240
+ <tbody>
241
+ <tr v-for="(row, index) in tableRows" :key="index">
242
+ <th scope="row">{{ row.x }}</th>
243
+ <td v-for="(value, column) in row.values" :key="column">{{ value }}</td>
244
+ </tr>
245
+ </tbody>
246
+ </table>
247
+ </div>
248
+
249
+ <DataChart
250
+ v-else
251
+ :data="data"
252
+ :spec="spec"
253
+ :height="height"
254
+ :locale="locale"
255
+ :stacked="stacked"
256
+ :hide-legend="hideLegend ?? (!compare && !series)"
257
+ :theme="theme"
258
+ :value-formatter="formatValue"
259
+ :loading="loading"
260
+ :loading-label="loadingLabel"
261
+ :error="error"
262
+ :empty-label="emptyLabel"
263
+ :aria-label="ariaLabel ?? title"
264
+ :aria-description="ariaDescription ?? (delta ? valueLabel : void 0)"
265
+ :accessible-data-table="accessibleDataTable"
266
+ :max-data-points="maxDataPoints"
267
+ :legend-variant="legendVariant"
268
+ :tooltip-variant="tooltipVariant"
269
+ :tooltip-roundness="tooltipRoundness"
270
+ />
271
+
272
+ <footer v-if="ranges.length || showTable || exportable || $slots.actions" class="vc-metric__footer">
273
+ <!--
274
+ A radiogroup rather than a row of buttons: the ranges are one mutually
275
+ exclusive choice, and that is what lets a screen reader announce
276
+ "30D, selected, 2 of 4" instead of four unrelated buttons.
277
+ -->
278
+ <div
279
+ v-if="ranges.length && !window"
280
+ class="vc-metric__ranges"
281
+ role="radiogroup"
282
+ aria-label="Time range"
283
+ >
284
+ <button
285
+ v-for="range in ranges"
286
+ :key="range.key"
287
+ type="button"
288
+ role="radio"
289
+ class="vc-metric__range"
290
+ :aria-checked="range.key === activeRange?.key"
291
+ :aria-label="range.title ?? range.label"
292
+ :data-active="range.key === activeRange?.key ? 'true' : void 0"
293
+ @click="rangeModel = range.key"
294
+ >
295
+ {{ range.label }}
296
+ </button>
297
+ </div>
298
+
299
+ <div class="vc-metric__actions">
300
+ <slot name="actions" />
301
+ <button
302
+ v-if="showTable"
303
+ type="button"
304
+ class="vc-metric__action"
305
+ :aria-pressed="showingTable"
306
+ @click="showingTable = !showingTable"
307
+ >
308
+ {{ showingTable ? "Chart" : "Table" }}
309
+ </button>
310
+ <button
311
+ v-if="exportable"
312
+ type="button"
313
+ class="vc-metric__action"
314
+ :disabled="isEmpty"
315
+ @click="exportCsv"
316
+ >
317
+ Export
318
+ </button>
319
+ </div>
320
+ </footer>
321
+ </section>
322
+ </template>