@humanforest/charts 0.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.
- package/LICENSE +67 -0
- package/package.json +32 -0
- package/src/FBoroughShape.vue +52 -0
- package/src/FCalendarGrid.vue +332 -0
- package/src/FCellLegend.vue +54 -0
- package/src/FChartFrame.vue +201 -0
- package/src/FChartFrameSingle.vue +91 -0
- package/src/FChartLegend.vue +26 -0
- package/src/FDistributionBar.vue +224 -0
- package/src/FDottedMap.vue +335 -0
- package/src/FLondonMap.vue +399 -0
- package/src/FSparkline.vue +198 -0
- package/src/FStatusTrack.vue +151 -0
- package/src/cellSize.ts +46 -0
- package/src/distribution.ts +99 -0
- package/src/engine.ts +15 -0
- package/src/forestTooltip.ts +110 -0
- package/src/fromCategories.ts +43 -0
- package/src/index.ts +87 -0
- package/src/londonAreas.ts +75 -0
- package/src/londonGrid.ts +67 -0
- package/src/motionDuration.ts +49 -0
- package/src/presets.ts +143 -0
- package/src/resolveVar.ts +21 -0
- package/src/snapTooltip.ts +154 -0
- package/src/tooltipStandIn.ts +58 -0
- package/src/useCellScale.ts +155 -0
- package/src/useChartPalette.ts +61 -0
- package/src/useChartRepaintKey.ts +22 -0
- package/src/useThemeVersion.ts +17 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
<script setup lang="ts" generic="Datum extends Record<string, unknown>">
|
|
2
|
+
// The XY frame. Doctrine: wrap by rot-risk, not chart type — never wrap mark props; wrap only
|
|
3
|
+
// ceremony (container, axes, legend, tooltip/crosshair, repaint). The mark arrives through the
|
|
4
|
+
// #mark slot as real @unovis/vue components, so mark props are Unovis' own ConfigInterface,
|
|
5
|
+
// never re-enumerated here. Every library-shaped surface passes through as a typed
|
|
6
|
+
// Partial<…ConfigInterface> object prop whose runtime is a blind spread over the house preset.
|
|
7
|
+
import { computed, ref } from 'vue';
|
|
8
|
+
import { VisXYContainer, VisAxis, VisCrosshair, VisTooltip, VisBulletLegend } from '@unovis/vue';
|
|
9
|
+
import { Position } from '@unovis/ts';
|
|
10
|
+
import type {
|
|
11
|
+
AxisConfigInterface,
|
|
12
|
+
BulletLegendItemInterface,
|
|
13
|
+
TooltipConfigInterface,
|
|
14
|
+
XYContainerConfigInterface,
|
|
15
|
+
} from '@unovis/ts';
|
|
16
|
+
// Relative file imports (not the package barrel): the barrel exports this component, and a
|
|
17
|
+
// self-import through it would create a module cycle.
|
|
18
|
+
import { fromCategories } from './fromCategories';
|
|
19
|
+
import { forestTooltip, type ForestTooltipOptions } from './forestTooltip';
|
|
20
|
+
import { motionDuration } from './motionDuration';
|
|
21
|
+
import {
|
|
22
|
+
axisX as axisXPreset,
|
|
23
|
+
axisY as axisYPreset,
|
|
24
|
+
tooltipDefaults,
|
|
25
|
+
tooltipContainer,
|
|
26
|
+
} from './presets';
|
|
27
|
+
import { useThemeVersion } from './useThemeVersion';
|
|
28
|
+
import { useTooltipSnap, nextTipId } from './snapTooltip';
|
|
29
|
+
|
|
30
|
+
defineOptions({ inheritAttrs: false });
|
|
31
|
+
|
|
32
|
+
const props = withDefaults(
|
|
33
|
+
defineProps<{
|
|
34
|
+
data: Datum[];
|
|
35
|
+
/** Palette categories (from useChartPalette) — series order, names, colours. */
|
|
36
|
+
categories: Record<string, BulletLegendItemInterface>;
|
|
37
|
+
height?: number;
|
|
38
|
+
/** Numeric field to plot on x (e.g. a timestamp). Default: the row index. */
|
|
39
|
+
xKey?: keyof Datum & string;
|
|
40
|
+
/** Blind spread onto VisXYContainer — any XYContainerConfigInterface key, presets last-wins. */
|
|
41
|
+
container?: Partial<XYContainerConfigInterface<Datum>>;
|
|
42
|
+
/** Merged over the house x-axis preset. `false` removes the axis. */
|
|
43
|
+
axisX?: Partial<AxisConfigInterface<Datum>> | false;
|
|
44
|
+
axisY?: Partial<AxisConfigInterface<Datum>> | false;
|
|
45
|
+
/** Merged over the house tooltip preset. `false` removes tooltip AND crosshair. */
|
|
46
|
+
tooltip?: Partial<TooltipConfigInterface> | false;
|
|
47
|
+
/** Let the panel track the pointer instead of snapping to the datum. Off by default: on a
|
|
48
|
+
* crosshair chart the panel locks to the crosshair's x at the top of the plot, so it steps
|
|
49
|
+
* between rows and holds still in between; without a crosshair it anchors to the hovered
|
|
50
|
+
* mark. Turn it on for a free-floating panel. */
|
|
51
|
+
followCursor?: boolean;
|
|
52
|
+
/* The `| false` unions above compile to runtime type [Object, Boolean], and Vue casts an
|
|
53
|
+
ABSENT Boolean-typed prop to `false` unless it has a default — so each carries an empty
|
|
54
|
+
object default below; without it an unpassed axis or tooltip suppresses itself. */
|
|
55
|
+
/** `false` keeps the tooltip and drops the crosshair. For marks with no continuous value at
|
|
56
|
+
* an x — a timeline row carries a span and a scatter is a cloud, so there is nothing to snap
|
|
57
|
+
* to and the tooltip fires from the mark's own `triggers` instead. */
|
|
58
|
+
crosshair?: boolean;
|
|
59
|
+
/** Title/value formatting for the house crosshair template. */
|
|
60
|
+
format?: ForestTooltipOptions<Datum>;
|
|
61
|
+
/** The mark runs horizontally (set `orientation` on the mark itself). The frame's index
|
|
62
|
+
* machinery — tick pinning, the edge gutter, grid-line defaults — follows the category
|
|
63
|
+
* dimension onto y; formatters stay yours to place per axis. */
|
|
64
|
+
horizontal?: boolean;
|
|
65
|
+
legend?: boolean;
|
|
66
|
+
/** Where the legend sits. `bottom` (default) renders centred under the chart, caption-style;
|
|
67
|
+
* `top` renders start-aligned above it, editorial-style. */
|
|
68
|
+
legendPosition?: 'top' | 'bottom';
|
|
69
|
+
/** Animation ms for the whole container (marks + axes). Default: motionDuration('slow'). */
|
|
70
|
+
duration?: number;
|
|
71
|
+
/** Extra signals that must remount the chart (theme flips and palette identity are automatic). */
|
|
72
|
+
repaintOn?: unknown[];
|
|
73
|
+
}>(),
|
|
74
|
+
{
|
|
75
|
+
height: 280,
|
|
76
|
+
horizontal: false,
|
|
77
|
+
crosshair: true,
|
|
78
|
+
followCursor: false,
|
|
79
|
+
legend: true,
|
|
80
|
+
legendPosition: 'bottom',
|
|
81
|
+
repaintOn: () => [],
|
|
82
|
+
axisX: () => ({}),
|
|
83
|
+
axisY: () => ({}),
|
|
84
|
+
tooltip: () => ({}),
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
defineSlots<{
|
|
89
|
+
mark(p: {
|
|
90
|
+
data: Datum[];
|
|
91
|
+
x: (d: Datum, i: number) => number;
|
|
92
|
+
y: Array<(d: Datum) => number | null | undefined>;
|
|
93
|
+
colors: string[];
|
|
94
|
+
}): unknown;
|
|
95
|
+
}>();
|
|
96
|
+
|
|
97
|
+
const acc = computed(() => fromCategories<Datum>(props.categories, props.data, props.xKey));
|
|
98
|
+
|
|
99
|
+
// Index charts get tick positions at the row indices — without this the axis invents fractional
|
|
100
|
+
// ticks between rows. The pin sits on the category axis: x normally, y when `horizontal` (Unovis
|
|
101
|
+
// swaps which scale carries the index). Grid lines follow the value axis the same way. An xKey
|
|
102
|
+
// chart scales its own axis.
|
|
103
|
+
const indexTicks = computed(() =>
|
|
104
|
+
props.xKey ? undefined : { tickValues: props.data.map((_, i) => i) },
|
|
105
|
+
);
|
|
106
|
+
const xAxisConfig = computed<Partial<AxisConfigInterface<Datum>>>(() => ({
|
|
107
|
+
...axisXPreset,
|
|
108
|
+
...(props.horizontal ? { gridLine: true } : indexTicks.value),
|
|
109
|
+
...(props.axisX || {}),
|
|
110
|
+
}));
|
|
111
|
+
const yAxisConfig = computed<Partial<AxisConfigInterface<Datum>>>(() => ({
|
|
112
|
+
...axisYPreset,
|
|
113
|
+
...(props.horizontal ? { gridLine: false, ...indexTicks.value } : {}),
|
|
114
|
+
...(props.axisY || {}),
|
|
115
|
+
}));
|
|
116
|
+
|
|
117
|
+
// Index charts also get half a row of outer gutter: the category domain extends past the first
|
|
118
|
+
// and last rows so edge marks (bars especially) stop sitting flush against the plot edges. Ticks
|
|
119
|
+
// stay on the row indices, and the gutter follows the category dimension under `horizontal`. An
|
|
120
|
+
// xKey chart plots a real field and keeps the library's exact-extent default; an explicit
|
|
121
|
+
// `container` domain overrides this like any other container key.
|
|
122
|
+
const containerConfig = computed<Partial<XYContainerConfigInterface<Datum>>>(() => {
|
|
123
|
+
const gutter: [number, number] = [-0.5, props.data.length - 0.5];
|
|
124
|
+
return {
|
|
125
|
+
...(props.xKey || props.data.length === 0
|
|
126
|
+
? {}
|
|
127
|
+
: props.horizontal
|
|
128
|
+
? { yDomain: gutter }
|
|
129
|
+
: { xDomain: gutter }),
|
|
130
|
+
...(props.container || {}),
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const tipFn = computed(() => forestTooltip<Datum>(props.categories, props.format));
|
|
135
|
+
|
|
136
|
+
// The panel is snapped to the datum by useTooltipSnap below, which rewrites its position after
|
|
137
|
+
// Unovis places it. What Unovis is configured to do here is therefore only what shows when the
|
|
138
|
+
// snap stands down (`followCursor`), plus the show/hide and content it always owns: pointer
|
|
139
|
+
// tracking as the base, and Auto placement so Crosshair's own left/right flip is free to run.
|
|
140
|
+
// The `data-forest-tip` attribute is how the snap finds this frame's panel among body's children,
|
|
141
|
+
// so it is merged last — a call site's own attributes are kept, but cannot displace the hook.
|
|
142
|
+
const tipId = nextTipId();
|
|
143
|
+
const tooltipConfig = computed<Partial<TooltipConfigInterface>>(() => ({
|
|
144
|
+
...tooltipDefaults,
|
|
145
|
+
container: tooltipContainer(),
|
|
146
|
+
followCursor: true,
|
|
147
|
+
...(props.crosshair ? { horizontalPlacement: Position.Auto } : {}),
|
|
148
|
+
...(props.tooltip || {}),
|
|
149
|
+
attributes: { ...(props.tooltip || {}).attributes, 'data-forest-tip': tipId },
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
const rootEl = ref<HTMLElement>();
|
|
153
|
+
useTooltipSnap(rootEl, tipId, props.crosshair ? 'crosshair' : 'mark', () => !props.followCursor);
|
|
154
|
+
|
|
155
|
+
// Charts animate on the motion tokens (reduced motion resolves to 0); container duration
|
|
156
|
+
// propagates to marks and axes. Override via the prop or container.duration.
|
|
157
|
+
const resolvedDuration = computed(() => props.duration ?? motionDuration());
|
|
158
|
+
|
|
159
|
+
// Theme flips and palette identity changes both remount: Unovis caches resolved colours on mount.
|
|
160
|
+
const themeVersion = useThemeVersion();
|
|
161
|
+
const repaintKey = computed(() =>
|
|
162
|
+
[themeVersion.value, acc.value.colors.join('|'), ...props.repaintOn].map(String).join('-'),
|
|
163
|
+
);
|
|
164
|
+
</script>
|
|
165
|
+
|
|
166
|
+
<template>
|
|
167
|
+
<div ref="rootEl" style="display: flex; flex-direction: column; gap: 0.75rem">
|
|
168
|
+
<VisXYContainer
|
|
169
|
+
:key="repaintKey"
|
|
170
|
+
:data="data"
|
|
171
|
+
:height="height"
|
|
172
|
+
:duration="resolvedDuration"
|
|
173
|
+
v-bind="{ ...containerConfig, ...$attrs }"
|
|
174
|
+
>
|
|
175
|
+
<slot name="mark" :data="data" :x="acc.xAccessor" :y="acc.yAccessors" :colors="acc.colors" />
|
|
176
|
+
<VisAxis v-if="axisX !== false" v-bind="xAxisConfig" />
|
|
177
|
+
<VisAxis v-if="axisY !== false" v-bind="yAxisConfig" />
|
|
178
|
+
<template v-if="tooltip !== false">
|
|
179
|
+
<!-- duration 0 is deliberate, and load-bearing twice over. Unovis animates the crosshair
|
|
180
|
+
line's x through a transition, so it eases toward the row the pointer just snapped to
|
|
181
|
+
— which reads as lag on the one element that should track the pointer exactly, and
|
|
182
|
+
leaves the line's real position mid-flight while the snap is reading it as the
|
|
183
|
+
tooltip's anchor. Easing it would put the panel on the wrong side of the plot. -->
|
|
184
|
+
<VisCrosshair
|
|
185
|
+
v-if="crosshair"
|
|
186
|
+
:x="acc.xAccessor"
|
|
187
|
+
:y="acc.yAccessors"
|
|
188
|
+
:color="acc.colors"
|
|
189
|
+
:template="tipFn"
|
|
190
|
+
:duration="0"
|
|
191
|
+
/>
|
|
192
|
+
<VisTooltip v-bind="tooltipConfig" />
|
|
193
|
+
</template>
|
|
194
|
+
</VisXYContainer>
|
|
195
|
+
<VisBulletLegend
|
|
196
|
+
v-if="legend"
|
|
197
|
+
:items="acc.legendItems"
|
|
198
|
+
:style="legendPosition === 'top' ? { order: -1, alignSelf: 'flex-start' } : { alignSelf: 'center' }"
|
|
199
|
+
/>
|
|
200
|
+
</div>
|
|
201
|
+
</template>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<script setup lang="ts" generic="Data">
|
|
2
|
+
// The single-container frame, for the non-XY charts (Donut, Sankey, Graph, Treemap). Same
|
|
3
|
+
// doctrine as FChartFrame: wrap only ceremony — container, legend, tooltip wiring, repaint —
|
|
4
|
+
// never mark props. `data` is whatever the mark's container expects (number[] for a donut,
|
|
5
|
+
// { nodes, links } for a sankey) and passes through untyped by the frame, typed by the mark.
|
|
6
|
+
import { computed, ref } from 'vue';
|
|
7
|
+
import { VisSingleContainer, VisTooltip, VisBulletLegend } from '@unovis/vue';
|
|
8
|
+
import type { BulletLegendItemInterface, TooltipConfigInterface } from '@unovis/ts';
|
|
9
|
+
// Relative file imports (not the package barrel): the barrel exports this component, and a
|
|
10
|
+
// self-import through it would create a module cycle.
|
|
11
|
+
import { fromCategories } from './fromCategories';
|
|
12
|
+
import { motionDuration } from './motionDuration';
|
|
13
|
+
import { tooltipDefaults, tooltipContainer } from './presets';
|
|
14
|
+
import { useThemeVersion } from './useThemeVersion';
|
|
15
|
+
import { useTooltipSnap, nextTipId } from './snapTooltip';
|
|
16
|
+
|
|
17
|
+
defineOptions({ inheritAttrs: false });
|
|
18
|
+
|
|
19
|
+
const props = withDefaults(
|
|
20
|
+
defineProps<{
|
|
21
|
+
data: Data;
|
|
22
|
+
/** Optional: series colours + legend. Omit for charts that label directly (Sankey, Graph). */
|
|
23
|
+
categories?: Record<string, BulletLegendItemInterface>;
|
|
24
|
+
height?: number;
|
|
25
|
+
/** Rendered only when given — single-container tooltips need `triggers` to show anything. */
|
|
26
|
+
tooltip?: Partial<TooltipConfigInterface>;
|
|
27
|
+
/** Let the panel track the pointer instead of snapping to the hovered mark. Off by default:
|
|
28
|
+
* the panel anchors to the centre of the shape under the cursor and holds there, so it moves
|
|
29
|
+
* when the reading changes rather than with every pixel. Turn it on for a free-floating panel.
|
|
30
|
+
*/
|
|
31
|
+
followCursor?: boolean;
|
|
32
|
+
legend?: boolean;
|
|
33
|
+
/** Where the legend sits. `bottom` (default) renders centred under the chart, caption-style;
|
|
34
|
+
* `top` renders start-aligned above it, editorial-style. */
|
|
35
|
+
legendPosition?: 'top' | 'bottom';
|
|
36
|
+
/** Animation ms for the container's components. Default: motionDuration('slow'). */
|
|
37
|
+
duration?: number;
|
|
38
|
+
/** Extra signals that must remount the chart (theme flips and palette identity are automatic). */
|
|
39
|
+
repaintOn?: unknown[];
|
|
40
|
+
}>(),
|
|
41
|
+
{ height: 260, legend: true, legendPosition: 'bottom', followCursor: false, repaintOn: () => [] },
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
defineSlots<{
|
|
45
|
+
mark(p: { data: Data; colors: string[] }): unknown;
|
|
46
|
+
}>();
|
|
47
|
+
|
|
48
|
+
const acc = computed(() => fromCategories(props.categories ?? {}));
|
|
49
|
+
|
|
50
|
+
// As in FChartFrame: the panel is snapped to the hovered mark by useTooltipSnap, so what Unovis is
|
|
51
|
+
// configured to do is the fallback for when the snap stands down. Pointer tracking is that
|
|
52
|
+
// fallback — never the library's element pinning, which anchors to a bounding box that for these
|
|
53
|
+
// marks (arcs, groups, links) is nothing like the shape under the cursor.
|
|
54
|
+
const tipId = nextTipId();
|
|
55
|
+
const tooltipConfig = computed<Partial<TooltipConfigInterface>>(() => ({
|
|
56
|
+
...tooltipDefaults,
|
|
57
|
+
container: tooltipContainer(),
|
|
58
|
+
followCursor: true,
|
|
59
|
+
...(props.tooltip || {}),
|
|
60
|
+
attributes: { ...(props.tooltip || {}).attributes, 'data-forest-tip': tipId },
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
const rootEl = ref<HTMLElement>();
|
|
64
|
+
useTooltipSnap(rootEl, tipId, 'mark', () => !props.followCursor);
|
|
65
|
+
const resolvedDuration = computed(() => props.duration ?? motionDuration());
|
|
66
|
+
|
|
67
|
+
const themeVersion = useThemeVersion();
|
|
68
|
+
const repaintKey = computed(() =>
|
|
69
|
+
[themeVersion.value, acc.value.colors.join('|'), ...props.repaintOn].map(String).join('-'),
|
|
70
|
+
);
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<template>
|
|
74
|
+
<div ref="rootEl" style="display: flex; flex-direction: column; gap: 0.75rem">
|
|
75
|
+
<VisSingleContainer
|
|
76
|
+
:key="repaintKey"
|
|
77
|
+
:data="data"
|
|
78
|
+
:height="height"
|
|
79
|
+
:duration="resolvedDuration"
|
|
80
|
+
v-bind="$attrs"
|
|
81
|
+
>
|
|
82
|
+
<slot name="mark" :data="data" :colors="acc.colors" />
|
|
83
|
+
<VisTooltip v-if="tooltip" v-bind="tooltipConfig" />
|
|
84
|
+
</VisSingleContainer>
|
|
85
|
+
<VisBulletLegend
|
|
86
|
+
v-if="legend && acc.legendItems.length"
|
|
87
|
+
:items="acc.legendItems"
|
|
88
|
+
:style="legendPosition === 'top' ? { order: -1, alignSelf: 'flex-start' } : { alignSelf: 'center' }"
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
</template>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The house-positioned legend, for charts assembled without a frame. FChartFrame and
|
|
3
|
+
// FChartFrameSingle place their own legend; a bare VisBulletLegend has no opinion about where it
|
|
4
|
+
// sits, so direct charts would otherwise each hand-roll the same wrapper. Same items prop and the
|
|
5
|
+
// same two positions the frames take, so a chart reads identically whichever way it is built.
|
|
6
|
+
import { VisBulletLegend } from '@unovis/vue';
|
|
7
|
+
import type { BulletLegendItemInterface } from '@unovis/ts';
|
|
8
|
+
|
|
9
|
+
withDefaults(
|
|
10
|
+
defineProps<{
|
|
11
|
+
items: BulletLegendItemInterface[];
|
|
12
|
+
/** `bottom` (default) centres it under the chart; `top` sits it start-aligned above. */
|
|
13
|
+
position?: 'top' | 'bottom';
|
|
14
|
+
}>(),
|
|
15
|
+
{ position: 'bottom' },
|
|
16
|
+
);
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<div
|
|
21
|
+
style="display: flex"
|
|
22
|
+
:style="{ justifyContent: position === 'top' ? 'flex-start' : 'center' }"
|
|
23
|
+
>
|
|
24
|
+
<VisBulletLegend :items="items" />
|
|
25
|
+
</div>
|
|
26
|
+
</template>
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// One bar split into its parts: what a figure is made of, at the size of a figure. Sits in an FKpi
|
|
3
|
+
// `visual` slot beside the number it decomposes, where a donut would need a legend, a centre label
|
|
4
|
+
// and more height than a tile has.
|
|
5
|
+
//
|
|
6
|
+
// Plain DOM on a grid rather than SVG. Grid takes the gaps out of the track widths itself, so the
|
|
7
|
+
// segments still sum to the bar — the same layout in flex needs percentages that the gaps then
|
|
8
|
+
// overflow.
|
|
9
|
+
//
|
|
10
|
+
// Deliberately not a stacked bar chart: one bar, one moment. A composition that moves over time is
|
|
11
|
+
// a chart, and FChartFrame is the component for that.
|
|
12
|
+
//
|
|
13
|
+
// Two looks. Split (default) gives each part its own pill, which says "these are separate things,
|
|
14
|
+
// here is their relative size". `joined` closes the gaps and rounds only the bar's outside corners,
|
|
15
|
+
// which says "this is one quantity, here is what it is made of". The corner shaping is what does
|
|
16
|
+
// the work: pills at gap 0 pinch at every seam.
|
|
17
|
+
//
|
|
18
|
+
// Joined is NOT the component for a plain progress or capacity bar — one value against a maximum,
|
|
19
|
+
// no parts, is UProgress, which owns the semantics (role, value text, step labels) and the intent
|
|
20
|
+
// colours. This is for the case UProgress cannot draw: the filled length itself split by what made
|
|
21
|
+
// it up.
|
|
22
|
+
//
|
|
23
|
+
// In an FKpi `visual` slot, pass `visual-fit="inset"` on the tile. The slot's default fit bleeds to
|
|
24
|
+
// the card's side and bottom edges, which is right for a sparkline — it holds no text and its
|
|
25
|
+
// baseline is the card's. This holds a legend, so it wants the padding back and top alignment.
|
|
26
|
+
import { computed, ref } from 'vue';
|
|
27
|
+
import { categoricalVar, type CategoricalPalette } from './useChartPalette';
|
|
28
|
+
import {
|
|
29
|
+
distributionShares,
|
|
30
|
+
formatShare,
|
|
31
|
+
segmentRadius,
|
|
32
|
+
type DistributionSegment,
|
|
33
|
+
} from './distribution';
|
|
34
|
+
|
|
35
|
+
const props = withDefaults(
|
|
36
|
+
defineProps<{
|
|
37
|
+
/** The parts. Order is the drawing order, left to right, and fixes each part's palette slot. */
|
|
38
|
+
data: DistributionSegment[];
|
|
39
|
+
/**
|
|
40
|
+
* The whole the parts are measured against. Defaults to their sum, which makes the bar a pure
|
|
41
|
+
* composition. Pass a larger number to leave a remainder — "78% of capacity, and here is what
|
|
42
|
+
* used it" is one bar, not two.
|
|
43
|
+
*/
|
|
44
|
+
total?: number;
|
|
45
|
+
/** Bar thickness. */
|
|
46
|
+
height?: number;
|
|
47
|
+
/** Space between segments. Ignored when `joined` — a joined bar has no space to give. */
|
|
48
|
+
gap?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Draws the parts as ONE continuous bar: no gaps, and only the outside corners rounded, so the
|
|
51
|
+
* seams are square. For a quantity read as one thing that happens to be made of parts, where
|
|
52
|
+
* separate pills would read as separate objects. One value and no parts is UProgress, not this.
|
|
53
|
+
*/
|
|
54
|
+
joined?: boolean;
|
|
55
|
+
palette?: CategoricalPalette;
|
|
56
|
+
legend?: boolean;
|
|
57
|
+
/** Adds each part's percentage to its legend entry. The bar states shares by length either way. */
|
|
58
|
+
showShare?: boolean;
|
|
59
|
+
locale?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Hover and focus text on the segments. `true` uses "<label> — <value> (<share>)"; a function
|
|
62
|
+
* writes its own. Given either, the segments become real buttons so a keyboard reaches them —
|
|
63
|
+
* which is also why it is opt-in: three tiles of three parts is nine extra tab stops for a
|
|
64
|
+
* figure the legend already states.
|
|
65
|
+
*/
|
|
66
|
+
tooltip?: boolean | ((segment: DistributionSegment, share: number) => string);
|
|
67
|
+
/** Overrides the generated sentence for assistive tech. */
|
|
68
|
+
ariaLabel?: string;
|
|
69
|
+
}>(),
|
|
70
|
+
{
|
|
71
|
+
height: 8,
|
|
72
|
+
gap: 2,
|
|
73
|
+
joined: false,
|
|
74
|
+
palette: 'cvdSafe',
|
|
75
|
+
legend: true,
|
|
76
|
+
showShare: false,
|
|
77
|
+
locale: 'en-GB',
|
|
78
|
+
total: undefined,
|
|
79
|
+
/* Vue casts an ABSENT Boolean-typed prop to false, which is the intended default here — but it
|
|
80
|
+
is spelled out so the union with the formatter function keeps a declared default either way. */
|
|
81
|
+
tooltip: false,
|
|
82
|
+
ariaLabel: undefined,
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const shares = computed(() => distributionShares(props.data, props.total));
|
|
87
|
+
|
|
88
|
+
const colorOf = (part: { segment: DistributionSegment; index: number }) =>
|
|
89
|
+
part.segment.color ?? categoricalVar(props.palette, part.index);
|
|
90
|
+
|
|
91
|
+
// A zero-width track would still take its share of the gap, leaving a stray notch where nothing is.
|
|
92
|
+
const drawn = computed(() => shares.value.parts.filter((p) => p.share > 0));
|
|
93
|
+
|
|
94
|
+
// Grid tracks in fr, so the gaps come out of the tracks rather than pushing the last one out.
|
|
95
|
+
const columns = computed(() => {
|
|
96
|
+
const tracks = drawn.value.map((p) => `${p.share}fr`);
|
|
97
|
+
if (shares.value.remainder > 0) tracks.push(`${shares.value.remainder}fr`);
|
|
98
|
+
return tracks.join(' ');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// How many tracks actually render — the drawn segments plus the remainder when there is one. The
|
|
102
|
+
// corner shaping needs this, and it is NOT `data.length`: zero-share parts are filtered out, so the
|
|
103
|
+
// last DRAWN segment is what carries the bar's right-hand corners.
|
|
104
|
+
const trackCount = computed(() => drawn.value.length + (shares.value.remainder > 0 ? 1 : 0));
|
|
105
|
+
const radiusAt = (index: number) =>
|
|
106
|
+
segmentRadius(index, trackCount.value, props.height / 2, props.joined);
|
|
107
|
+
|
|
108
|
+
// The bar's lengths are the only place the shares are stated unless `showShare` is on, so the
|
|
109
|
+
// sentence carries them regardless.
|
|
110
|
+
const label = computed(() => {
|
|
111
|
+
if (props.ariaLabel) return props.ariaLabel;
|
|
112
|
+
const parts = shares.value.parts.map(
|
|
113
|
+
(p) => `${p.segment.label} ${formatShare(p.share, props.locale)}`,
|
|
114
|
+
);
|
|
115
|
+
if (shares.value.remainder > 0)
|
|
116
|
+
parts.push(`unaccounted ${formatShare(shares.value.remainder, props.locale)}`);
|
|
117
|
+
return parts.join(', ');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const interactive = computed(() => props.tooltip !== false);
|
|
121
|
+
|
|
122
|
+
const num = (n: number) => n.toLocaleString(props.locale);
|
|
123
|
+
|
|
124
|
+
const tipFor = (segment: DistributionSegment, share: number) =>
|
|
125
|
+
typeof props.tooltip === 'function'
|
|
126
|
+
? props.tooltip(segment, share)
|
|
127
|
+
: `${segment.label} — ${num(segment.value)} (${formatShare(share, props.locale)})`;
|
|
128
|
+
|
|
129
|
+
// The remainder is not one of `data`, so it has no segment to hand a formatter. It gets the same
|
|
130
|
+
// sentence shape from the numbers the shares already carry.
|
|
131
|
+
const remainderTip = computed(
|
|
132
|
+
() =>
|
|
133
|
+
`Unaccounted — ${num(Math.round(shares.value.remainder * shares.value.total))} (${formatShare(shares.value.remainder, props.locale)})`,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// One UTooltip for the whole bar, re-anchored to the segment under the pointer, rather than one
|
|
137
|
+
// per segment: `reference` moves the popper's anchor while the trigger stays the bar itself, so a
|
|
138
|
+
// bar of any width costs a single tooltip root.
|
|
139
|
+
//
|
|
140
|
+
// `tipOpen` owns visibility; the text and the anchor are left alone on close. Clearing them
|
|
141
|
+
// together with the flag emptied the bubble mid-exit-animation and dropped the anchor back to the
|
|
142
|
+
// trigger, so leaving a segment flashed a blank pill at the container's centre for ~80ms.
|
|
143
|
+
//
|
|
144
|
+
// Anchored to the segment, not the pointer, so a keyboard focus places it too.
|
|
145
|
+
const tip = ref<{ text: string; el: HTMLElement | null }>({ text: '', el: null });
|
|
146
|
+
const tipOpen = ref(false);
|
|
147
|
+
const showTip = (event: Event, text: string) => {
|
|
148
|
+
tip.value = { text, el: event.currentTarget as HTMLElement };
|
|
149
|
+
tipOpen.value = true;
|
|
150
|
+
};
|
|
151
|
+
const hideTip = () => {
|
|
152
|
+
tipOpen.value = false;
|
|
153
|
+
};
|
|
154
|
+
</script>
|
|
155
|
+
|
|
156
|
+
<template>
|
|
157
|
+
<div class="flex w-full flex-col gap-2">
|
|
158
|
+
<!-- `img` makes its contents presentational, which is right while the bar is a graphic and
|
|
159
|
+
wrong the moment the segments are buttons — the buttons would be hidden from the very
|
|
160
|
+
readers the role is for. Interactive, it becomes a labelled group whose children stay
|
|
161
|
+
reachable, and each segment states itself. -->
|
|
162
|
+
<UTooltip :text="tip.text" :reference="tip.el" :open="tipOpen">
|
|
163
|
+
<div
|
|
164
|
+
class="grid w-full"
|
|
165
|
+
:role="interactive ? 'group' : 'img'"
|
|
166
|
+
:aria-label="label"
|
|
167
|
+
:style="{
|
|
168
|
+
gridTemplateColumns: columns,
|
|
169
|
+
gap: `${joined ? 0 : gap}px`,
|
|
170
|
+
height: `${height}px`,
|
|
171
|
+
}"
|
|
172
|
+
>
|
|
173
|
+
<component
|
|
174
|
+
:is="interactive ? 'button' : 'span'"
|
|
175
|
+
v-for="(p, i) in drawn"
|
|
176
|
+
:key="p.index"
|
|
177
|
+
:type="interactive ? 'button' : undefined"
|
|
178
|
+
:class="
|
|
179
|
+
interactive
|
|
180
|
+
? 'cursor-pointer transition-[filter] duration-150 hover:brightness-110 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ui-primary)]'
|
|
181
|
+
: ''
|
|
182
|
+
"
|
|
183
|
+
:style="{ background: colorOf(p), borderRadius: radiusAt(i) }"
|
|
184
|
+
:aria-label="interactive ? tipFor(p.segment, p.share) : undefined"
|
|
185
|
+
@mouseenter="interactive && showTip($event, tipFor(p.segment, p.share))"
|
|
186
|
+
@mouseleave="hideTip"
|
|
187
|
+
@focus="interactive && showTip($event, tipFor(p.segment, p.share))"
|
|
188
|
+
@blur="hideTip"
|
|
189
|
+
/>
|
|
190
|
+
<!-- The remainder is the track colour, not a palette slot: it is the absence of a category,
|
|
191
|
+
and giving it a colour would make it read as one more. -->
|
|
192
|
+
<component
|
|
193
|
+
:is="interactive ? 'button' : 'span'"
|
|
194
|
+
v-if="shares.remainder > 0"
|
|
195
|
+
:type="interactive ? 'button' : undefined"
|
|
196
|
+
class="bg-[var(--ui-bg-accented)]"
|
|
197
|
+
:class="
|
|
198
|
+
interactive
|
|
199
|
+
? 'cursor-pointer transition-[filter] duration-150 hover:brightness-110 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ui-primary)]'
|
|
200
|
+
: ''
|
|
201
|
+
"
|
|
202
|
+
:style="{ borderRadius: radiusAt(trackCount - 1) }"
|
|
203
|
+
:aria-label="interactive ? remainderTip : undefined"
|
|
204
|
+
@mouseenter="interactive && showTip($event, remainderTip)"
|
|
205
|
+
@mouseleave="hideTip"
|
|
206
|
+
@focus="interactive && showTip($event, remainderTip)"
|
|
207
|
+
@blur="hideTip"
|
|
208
|
+
/>
|
|
209
|
+
</div>
|
|
210
|
+
</UTooltip>
|
|
211
|
+
|
|
212
|
+
<!-- Centred: the entries are a caption for the bar rather than a column of their own, and a
|
|
213
|
+
wrapped second row reads as ragged against a left edge the bar does not share. -->
|
|
214
|
+
<div v-if="legend" class="flex flex-wrap items-center justify-center gap-x-4 gap-y-1">
|
|
215
|
+
<span v-for="p in shares.parts" :key="p.index" class="flex items-center gap-1.5">
|
|
216
|
+
<span class="size-2 rounded-full" :style="{ background: colorOf(p) }" aria-hidden="true" />
|
|
217
|
+
<span class="type-caption text-muted">{{ p.segment.label }}</span>
|
|
218
|
+
<span v-if="showShare" class="type-caption text-dimmed">
|
|
219
|
+
{{ formatShare(p.share, locale) }}
|
|
220
|
+
</span>
|
|
221
|
+
</span>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
</template>
|