@humanforest/slidev-theme 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/README.md +577 -0
- package/assetUrl.js +38 -0
- package/components/Alert.vue +209 -0
- package/components/AreaBox.vue +152 -0
- package/components/AreaChart.vue +145 -0
- package/components/Avatar.vue +267 -0
- package/components/AvatarGroup.vue +94 -0
- package/components/BadgeStrip.vue +203 -0
- package/components/Bars.vue +207 -0
- package/components/BoroughShape.vue +127 -0
- package/components/BrowserFrame.vue +484 -0
- package/components/CalendarGrid.vue +480 -0
- package/components/Card.vue +70 -0
- package/components/Carousel.vue +266 -0
- package/components/ChartFrame.vue +426 -0
- package/components/ChordChart.vue +264 -0
- package/components/ComposedChart.vue +187 -0
- package/components/Contents.vue +86 -0
- package/components/DataTable.vue +244 -0
- package/components/DeckMap.vue +409 -0
- package/components/DeviceFrame.vue +331 -0
- package/components/DonutChart.vue +103 -0
- package/components/DottedMap.vue +126 -0
- package/components/Endorsement.vue +76 -0
- package/components/Grid.vue +169 -0
- package/components/GroupedBarChart.vue +141 -0
- package/components/IconTile.vue +116 -0
- package/components/Kpi.vue +188 -0
- package/components/LineChart.vue +133 -0
- package/components/Logo.vue +203 -0
- package/components/LondonMap.vue +348 -0
- package/components/MarkPair.vue +109 -0
- package/components/Marquee.vue +263 -0
- package/components/NestedDonutChart.vue +295 -0
- package/components/Placeholder.vue +88 -0
- package/components/SankeyChart.vue +223 -0
- package/components/ScatterChart.vue +142 -0
- package/components/StackedBarChart.vue +143 -0
- package/components/StatCard.vue +134 -0
- package/components/StatusTrack.vue +334 -0
- package/components/Timeline.vue +249 -0
- package/components/TimelineChart.vue +329 -0
- package/components/TreemapChart.vue +267 -0
- package/components/backgrounds.js +135 -0
- package/components/boroughGeometry.ts +59 -0
- package/components/chartData.ts +277 -0
- package/components/chartTip.ts +201 -0
- package/components/codeTheme.ts +239 -0
- package/components/deckPalette.ts +157 -0
- package/components/dottedMapGeometry.js +60 -0
- package/components/iconSlugs.js +55 -0
- package/components/iconUrl.js +51 -0
- package/components/mermaidTheme.ts +972 -0
- package/components/motionDuration.ts +41 -0
- package/components/tableRules.ts +257 -0
- package/confidential-mark.vue +151 -0
- package/custom-nav-controls.vue +79 -0
- package/endorsements/b-corp-black.svg +30 -0
- package/endorsements/b-corp-white.svg +30 -0
- package/global-top.vue +1662 -0
- package/icons/forest/bike-asterisk.svg +21 -0
- package/icons/forest/bike-down.svg +19 -0
- package/icons/forest/bike-off.svg +20 -0
- package/icons/forest/bike-up.svg +19 -0
- package/icons/forest/bike-x.svg +19 -0
- package/icons/forest/bike.svg +18 -0
- package/icons/forest/mark-fill.svg +6 -0
- package/icons/forest/mark.svg +6 -0
- package/icons/forest/parking.svg +3 -0
- package/icons/forest/star-fill.svg +4 -0
- package/icons/forest/star.svg +4 -0
- package/icons/forest/traffic-light-caution.svg +8 -0
- package/icons/forest/traffic-light-go.svg +8 -0
- package/icons/forest/traffic-light-lit.svg +8 -0
- package/icons/forest/traffic-light-stop.svg +8 -0
- package/icons/forest/traffic-light.svg +8 -0
- package/icons/lucide/battery.svg +1 -0
- package/icons/lucide/bike.svg +1 -0
- package/icons/lucide/chart-line.svg +1 -0
- package/icons/lucide/check.svg +1 -0
- package/icons/lucide/circle-check.svg +1 -0
- package/icons/lucide/clock.svg +1 -0
- package/icons/lucide/code.svg +1 -0
- package/icons/lucide/component.svg +1 -0
- package/icons/lucide/frame.svg +1 -0
- package/icons/lucide/layout-grid.svg +1 -0
- package/icons/lucide/map-pin.svg +1 -0
- package/icons/lucide/map.svg +1 -0
- package/icons/lucide/palette.svg +1 -0
- package/icons/lucide/table.svg +1 -0
- package/icons/lucide/triangle-alert.svg +1 -0
- package/icons/lucide/type.svg +1 -0
- package/icons/lucide/wrench.svg +1 -0
- package/layouts/bleed.vue +317 -0
- package/layouts/cover.vue +1514 -0
- package/layouts/default.vue +187 -0
- package/layouts/divider.vue +286 -0
- package/layouts/end.vue +384 -0
- package/layouts/quote.vue +218 -0
- package/layouts/split.vue +1875 -0
- package/layouts/stack.vue +944 -0
- package/layouts/statement.vue +67 -0
- package/manifest/chrome.ts +36 -0
- package/manifest/components.ts +49 -0
- package/manifest/index.ts +57 -0
- package/manifest/layouts.ts +37 -0
- package/manifest/manifest.json +6514 -0
- package/manifest/presets.ts +635 -0
- package/notch.js +458 -0
- package/notchPath.js +188 -0
- package/package.json +77 -0
- package/section.js +110 -0
- package/setup/mermaid-renderer.ts +2120 -0
- package/setup/mermaid.ts +30 -0
- package/setup/shiki.ts +44 -0
- package/setup/shortcuts.ts +28 -0
- package/slide-bottom.vue +305 -0
- package/slide-top.vue +268 -0
- package/styles/base.css +2611 -0
- package/styles/index.ts +71 -0
- package/styles/tokens.css +144 -0
- package/uno.config.ts +231 -0
- package/useNotchCuts.js +200 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/*
|
|
3
|
+
A Sankey sized for a room — where a quantity goes as it moves through stages, with the WIDTH of
|
|
4
|
+
every ribbon being the quantity itself.
|
|
5
|
+
|
|
6
|
+
A Sankey is the right mark when the story is CONSERVATION: a total arrives, it splits, and every
|
|
7
|
+
part of it lands somewhere. Where the columns are only categories measured twice, the ribbons
|
|
8
|
+
claim a continuity that is not there and the chart wants bars.
|
|
9
|
+
|
|
10
|
+
Real @unovis/vue mark, ChartFrame.vue for ceremony, chartData.ts for the palette and the states.
|
|
11
|
+
|
|
12
|
+
★ THE FIVE-COLOUR CEILING COUNTS NODES, and it bites harder here than on a line chart by design.
|
|
13
|
+
Colour in a Sankey is IDENTITY — it is how an audience traces one flow across three columns — so
|
|
14
|
+
a sixth block cannot borrow a colour that has already been spent without telling the room that two
|
|
15
|
+
different things are the same thing. Past five, chartData's `over-capacity` message draws instead.
|
|
16
|
+
Aggregate the tail into a "Rest" node at the call site, the way a donut's slices do.
|
|
17
|
+
|
|
18
|
+
Ribbons take the colour of the block they LEAVE, which is what makes a split legible: three
|
|
19
|
+
ribbons out of one node read as one green dividing, rather than as three unrelated bands.
|
|
20
|
+
|
|
21
|
+
★ NODE COLOUR MUST BE A FUNCTION, NEVER AN ARRAY, and the failure is silent all the way down.
|
|
22
|
+
VisLine and VisDonut take a colours ARRAY because they hand the library's `getColor` an index; the
|
|
23
|
+
Sankey calls `getColor(node, config.nodeColor)` with no index at all (components/sankey/modules/
|
|
24
|
+
node.js), so the `Array.isArray(accessor) && isFinite(index)` branch never fires and `getString`
|
|
25
|
+
returns the array itself — a non-function accessor is passed straight back (utils/data.js,
|
|
26
|
+
`getValue`). d3 stringifies that into a comma-joined list, which is not a valid paint, so
|
|
27
|
+
`setProperty` drops the declaration and every block falls back to the class rule's
|
|
28
|
+
`--vis-sankey-node-color`. That resolves to `--vis-color-main`, declared on `:root` and therefore
|
|
29
|
+
computed there — out of reach of ChartFrame's `--vis-color0` override, which is declared on
|
|
30
|
+
`.deck-chart`. The whole diagram comes out in Unovis' own default blue, with no error anywhere.
|
|
31
|
+
|
|
32
|
+
★ `heightNormalizationCoeff` IS A DEAD KEY in @unovis/ts 1.6.7. It is declared in the config
|
|
33
|
+
interface and defaulted to 1/16, and nothing in the shipped package ever reads it — the only
|
|
34
|
+
matches in the whole build are the declaration, the default and the sourcemap. If a Sankey ever
|
|
35
|
+
comes out shorter than the height it was given, this is not the lever.
|
|
36
|
+
|
|
37
|
+
Two of the library's own label defaults are wrong on this canvas. `--vis-sankey-node-label-color`
|
|
38
|
+
is a literal grey rather than a role (components/sankey/style.js), so it vanishes on a
|
|
39
|
+
`class: deck-dark` slide — `labelColor` is passed a deck token instead and flips with the slide.
|
|
40
|
+
`--vis-sankey-node-label-font-weight` is 600, which is off the deck's two-weight system, and is
|
|
41
|
+
left alone deliberately: forest-fonts.css declares exactly two GT Haptik faces, 400 and 700, and
|
|
42
|
+
CSS weight matching resolves a request above 500 UP to the next real file rather than synthesising
|
|
43
|
+
one, so the rendered label is already on-system.
|
|
44
|
+
*/
|
|
45
|
+
import { computed } from 'vue';
|
|
46
|
+
import { VisSingleContainer, VisSankey, VisTooltip, VisSankeySelectors } from '@unovis/vue';
|
|
47
|
+
import { deckTooltip, tipFrame } from './chartTip';
|
|
48
|
+
import ChartFrame from './ChartFrame.vue';
|
|
49
|
+
import { SERIES_CEILING, formatValue, isPlottable, seriesVar, stateMessage } from './chartData';
|
|
50
|
+
|
|
51
|
+
/*
|
|
52
|
+
--type-deck-body-sm-size is 2.25rem against the deck's 16px root: 36px, the same tier ChartFrame
|
|
53
|
+
sets every axis tick label at. Restated here as a NUMBER because `labelFontSize` is a layout input
|
|
54
|
+
rather than a style — Unovis measures it to reserve the bleed a label needs (`labelFontSize * 2.5`
|
|
55
|
+
plus block padding, for an assumed 2.5 lines, components/sankey/index.js) before any of this has
|
|
56
|
+
rendered. Left unset it falls back to `--vis-sankey-node-label-font-size`, which is 12px: a
|
|
57
|
+
caption on a dashboard and a speck in a room. It is not a prop, because a deck cannot express an
|
|
58
|
+
off-system size.
|
|
59
|
+
*/
|
|
60
|
+
const LABEL_FONT_SIZE = 36;
|
|
61
|
+
|
|
62
|
+
const props = defineProps({
|
|
63
|
+
/**
|
|
64
|
+
* Stages: `[{ id, label }]`, in the order they take their colours. `id` is what links name it by;
|
|
65
|
+
* `label` is what the room reads.
|
|
66
|
+
*
|
|
67
|
+
* ★ Give every node a STRING id. Unovis' graph model matches a link's `source`/`target` against
|
|
68
|
+
* `node.id` when it is a string, but treats a NUMBER as an INDEX into this array
|
|
69
|
+
* (data-models/graph.js, `findNode`) — so `id: 0` and `source: 0` agree by accident and stop
|
|
70
|
+
* agreeing the moment the array is reordered. A node carrying no id at all falls back to its own
|
|
71
|
+
* index, here and in the library alike.
|
|
72
|
+
*/
|
|
73
|
+
nodes: { type: Array, required: true },
|
|
74
|
+
/** Flows: `[{ source, target, value }]`. `source` and `target` are node ids; `value` is the ribbon's width. A flow with no width, one whose endpoint names no node, and one pointing a node at itself are all dropped before the mark sees them. */
|
|
75
|
+
links: { type: Array, required: true },
|
|
76
|
+
/** Plot height in px. 0 FILLS, and is the default: the chart takes whatever box it is given,
|
|
77
|
+
* which is what a chart under a heading wants. Unovis resolves `config.height || containerHeight`,
|
|
78
|
+
* so a falsy height makes the container measure itself. Pass a number where the box has no
|
|
79
|
+
* definite height of its own — a tiled grid cell, a figure beside copy. */
|
|
80
|
+
height: { type: Number, default: 0 },
|
|
81
|
+
/** Block width in px. The library's 25 is a sliver standing next to 36px type. */
|
|
82
|
+
nodeWidth: { type: Number, default: 40 },
|
|
83
|
+
/** Vertical gap between the blocks in one column, in px. The library's 2 does not exist at this distance. */
|
|
84
|
+
nodePadding: { type: Number, default: 24 },
|
|
85
|
+
/** Draw the node legend. The diagram already labels every block on the canvas, so this repeats those names underneath it. */
|
|
86
|
+
legend: { type: Boolean, default: true },
|
|
87
|
+
/** Where the legend sits: `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center`
|
|
88
|
+
* or `bottom-right`. The EDGE is reading order — above is read before the marks, below is a
|
|
89
|
+
* caption read after them; the ALIGNMENT is for the column the chart sits in, centred under a
|
|
90
|
+
* full-width plot and pulled to an edge where a centred key would float in a gutter. */
|
|
91
|
+
legendPosition: {
|
|
92
|
+
type: String,
|
|
93
|
+
default: 'bottom-center',
|
|
94
|
+
validator: (p) => ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'].includes(p),
|
|
95
|
+
},
|
|
96
|
+
/** The sentence drawn when there is nothing to plot. */
|
|
97
|
+
empty: { type: String, default: 'No data' },
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/*
|
|
101
|
+
★ EVERY NODE IS GIVEN AN ID HERE, because the library gives it one anyway and the two have to be
|
|
102
|
+
the same one. `nodeId` (data-models/graph.js) takes `n.id` when it is a string or a finite number
|
|
103
|
+
and falls back to the node's INDEX otherwise, PER NODE — so a set of id-less nodes still draws as
|
|
104
|
+
N distinct blocks, while any map this component keys by `String(n.id)` collapses them into one
|
|
105
|
+
"undefined" entry and paints every block the last node's colour. That is exactly the lie the
|
|
106
|
+
header says a repeated colour tells a room, arriving with no message at all. Deriving the same
|
|
107
|
+
fallback here keeps the colour map, the legend keys and the blocks on the canvas naming the same
|
|
108
|
+
things.
|
|
109
|
+
|
|
110
|
+
It is written onto a COPY, which is what makes it true for Unovis too: the id this component
|
|
111
|
+
resolved is the id the library receives. Endpoints are matched against those ids as strings, so a
|
|
112
|
+
link naming a node OBJECT rather than an id is not supported — the filter below drops it.
|
|
113
|
+
*/
|
|
114
|
+
const idOf = (n, i) => (typeof n?.id === 'string' || Number.isFinite(n?.id) ? String(n.id) : String(i));
|
|
115
|
+
const labelOf = (n) => String(n.label ?? n.id);
|
|
116
|
+
|
|
117
|
+
const blocks = computed(() => (props.nodes ?? []).map((n, i) => ({ ...n, id: idOf(n, i) })));
|
|
118
|
+
|
|
119
|
+
/*
|
|
120
|
+
A ribbon with no width is not a flow; a link whose endpoint names no node is not one either; and a
|
|
121
|
+
link from a node to itself is not one at all. Unovis drops the last two on its own
|
|
122
|
+
(data-models/graph.js filters `source === target` with a console.warn, then any endpoint
|
|
123
|
+
`findNode` could not resolve) — but silently as far as the room is concerned, so a diagram built
|
|
124
|
+
entirely from them renders as a bare column of rectangles with nothing between them and no hint
|
|
125
|
+
that the flows were meant to be there.
|
|
126
|
+
|
|
127
|
+
Filtered HERE, and the same filtered table is what the mark is handed, so the emptiness decision
|
|
128
|
+
and the drawing agree. Unovis does not check the value: a NEGATIVE one survives untouched and
|
|
129
|
+
d3-sankey sums it into the node's own height, laying out a ribbon of negative width.
|
|
130
|
+
|
|
131
|
+
A partial drop is still silent — four good flows and one bad one draws four ribbons and says
|
|
132
|
+
nothing. Reporting that needs a state chartData.ts does not carry.
|
|
133
|
+
*/
|
|
134
|
+
const flows = computed(() => {
|
|
135
|
+
const ids = new Set(blocks.value.map((n) => n.id));
|
|
136
|
+
return (props.links ?? []).filter(
|
|
137
|
+
(l) =>
|
|
138
|
+
isPlottable(l.value) &&
|
|
139
|
+
l.value > 0 &&
|
|
140
|
+
ids.has(String(l.source)) &&
|
|
141
|
+
ids.has(String(l.target)) &&
|
|
142
|
+
String(l.source) !== String(l.target),
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
/*
|
|
147
|
+
A graph is not a table, so `chartState` — which reads rows against a series per column — cannot
|
|
148
|
+
make this decision and the decision is made here. The WORDING still comes from `stateMessage`:
|
|
149
|
+
one pinned string shared across every chart in this folder, rather than a seventh that drifts.
|
|
150
|
+
|
|
151
|
+
⚠ This belongs in chartData.ts as `sankeyState(nodes, links)`, beside `shareState` and
|
|
152
|
+
`scatterState`, where the package's node-only vitest run can reach the rules. It is the only
|
|
153
|
+
chart-state logic in this folder no test can see. Left here until that export exists.
|
|
154
|
+
*/
|
|
155
|
+
const state = computed(() => {
|
|
156
|
+
if (blocks.value.length === 0) return { kind: 'empty', reason: 'no-rows' };
|
|
157
|
+
if (blocks.value.length > SERIES_CEILING) return { kind: 'over-capacity', count: blocks.value.length };
|
|
158
|
+
if (flows.value.length === 0) return { kind: 'empty', reason: 'no-values' };
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
kind: 'ready',
|
|
162
|
+
// Past the ceiling the colours are never asked for at all: the branch above has already stopped
|
|
163
|
+
// the chart, and `seriesVar` throws rather than wrapping the palette back to its first colour.
|
|
164
|
+
series: blocks.value.map((n, i) => ({ key: n.id, name: labelOf(n), color: seriesVar(i) })),
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const message = computed(() => (state.value.kind === 'ready' ? null : stateMessage(state.value, props.empty)));
|
|
169
|
+
const seriesList = computed(() => (state.value.kind === 'ready' ? state.value.series : []));
|
|
170
|
+
|
|
171
|
+
// Unovis wants the two tables as one object; the split into two props is only at the call site.
|
|
172
|
+
const data = computed(() => ({ nodes: blocks.value, links: flows.value }));
|
|
173
|
+
const colourOf = computed(() => new Map(seriesList.value.map((s) => [s.key, s.color])));
|
|
174
|
+
|
|
175
|
+
const nodeColor = (n) => colourOf.value.get(n.id);
|
|
176
|
+
// `l.source` is the resolved node OBJECT by the time an accessor runs — the graph model replaces
|
|
177
|
+
// the id with it before d3-sankey sees the link — and every node in it carries the id assigned
|
|
178
|
+
// above, so there is no raw-endpoint case left to fall back to.
|
|
179
|
+
const linkColor = (l) => colourOf.value.get(l.source?.id);
|
|
180
|
+
|
|
181
|
+
/*
|
|
182
|
+
THE RIBBON CARRIES THE TOOLTIP, and only the ribbon. A flow is the reading a Sankey exists for —
|
|
183
|
+
this much, from here to there — and it is the one a block cannot show, since a block's total is
|
|
184
|
+
just the ribbons meeting at it.
|
|
185
|
+
|
|
186
|
+
The block carries a second one: a block's total is only ever the ribbons meeting at it, so the
|
|
187
|
+
reading it adds is the sum, named.
|
|
188
|
+
*/
|
|
189
|
+
const nodeTip = (n) => tipFrame(
|
|
190
|
+
labelOf(n ?? {}),
|
|
191
|
+
[{ swatch: nodeColor(n), label: 'Total', value: formatValue(n?.value ?? 0) }],
|
|
192
|
+
);
|
|
193
|
+
const linkTip = (l) => tipFrame(
|
|
194
|
+
`${labelOf(l?.source ?? {})} \u2192 ${labelOf(l?.target ?? {})}`,
|
|
195
|
+
[{ swatch: linkColor(l), label: 'Flow', value: formatValue(l?.value ?? 0) }],
|
|
196
|
+
);
|
|
197
|
+
</script>
|
|
198
|
+
|
|
199
|
+
<template>
|
|
200
|
+
<ChartFrame
|
|
201
|
+
:series="seriesList"
|
|
202
|
+
:height="height"
|
|
203
|
+
:legend="legend"
|
|
204
|
+
:legend-position="legendPosition"
|
|
205
|
+
:message="message"
|
|
206
|
+
>
|
|
207
|
+
<VisSingleContainer :data="data" :height="height" :duration="0">
|
|
208
|
+
<!-- No margin: the Sankey computes its own bleed from the labels it is about to draw, and a
|
|
209
|
+
margin on top of that is space subtracted from the ribbons twice. -->
|
|
210
|
+
<VisSankey
|
|
211
|
+
:node-width="nodeWidth"
|
|
212
|
+
:node-padding="nodePadding"
|
|
213
|
+
:node-color="nodeColor"
|
|
214
|
+
:link-color="linkColor"
|
|
215
|
+
:label="labelOf"
|
|
216
|
+
:label-font-size="LABEL_FONT_SIZE"
|
|
217
|
+
label-color="var(--deck-fg)"
|
|
218
|
+
label-fit="wrap"
|
|
219
|
+
/>
|
|
220
|
+
<VisTooltip v-bind="deckTooltip()" :triggers="{ [VisSankeySelectors.link]: linkTip, [VisSankeySelectors.node]: nodeTip }" />
|
|
221
|
+
</VisSingleContainer>
|
|
222
|
+
</ChartFrame>
|
|
223
|
+
</template>
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/*
|
|
3
|
+
A scatter sized for a room — two measurements against each other, one dot per observation, with
|
|
4
|
+
an optional third read as dot size.
|
|
5
|
+
|
|
6
|
+
What a scatter is FOR is the shape of a cloud: a correlation, a cluster, an outlier. It is the one
|
|
7
|
+
chart on this deck whose message is not a number, which is why it carries no value labels by
|
|
8
|
+
default — an audience reads the pattern, and the presenter says what it means.
|
|
9
|
+
|
|
10
|
+
Deck-scale dots are large (18–64px, against the library's 4–10), because a 6px dot projected
|
|
11
|
+
across a room is dust. That makes overlap the real risk, so points carry a ground-coloured stroke
|
|
12
|
+
and a little transparency: two overlapping dots stay countable instead of merging into a blob.
|
|
13
|
+
|
|
14
|
+
Real @unovis/vue mark, ChartFrame.vue for ceremony, chartData.ts for shaping.
|
|
15
|
+
*/
|
|
16
|
+
import { computed } from 'vue';
|
|
17
|
+
import { VisXYContainer, VisAxis, VisScatter, VisTooltip, VisScatterSelectors } from '@unovis/vue';
|
|
18
|
+
import { deckTooltip, tipFrame } from './chartTip';
|
|
19
|
+
import ChartFrame from './ChartFrame.vue';
|
|
20
|
+
import { formatValue, scatterGroups, scatterState, stateMessage } from './chartData';
|
|
21
|
+
|
|
22
|
+
const props = defineProps({
|
|
23
|
+
/** Rows: `[{ x, y, size?, label?, group? }]`. `x` and `y` are required numbers; a row missing either is not a point. */
|
|
24
|
+
data: { type: Array, required: true },
|
|
25
|
+
/** Plot height in px. 0 FILLS, and is the default: the chart takes whatever box it is given,
|
|
26
|
+
* which is what a chart under a heading wants. Unovis resolves `config.height || containerHeight`,
|
|
27
|
+
* so a falsy height makes the container measure itself. Pass a number where the box has no
|
|
28
|
+
* definite height of its own — a tiled grid cell, a figure beside copy. */
|
|
29
|
+
height: { type: Number, default: 0 },
|
|
30
|
+
/** Draw the group legend. Only meaningful when the rows carry a `group`. */
|
|
31
|
+
legend: { type: Boolean, default: true },
|
|
32
|
+
/** Where the legend sits: `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center`
|
|
33
|
+
* or `bottom-right`. The EDGE is reading order — above is read before the marks, below is a
|
|
34
|
+
* caption read after them; the ALIGNMENT is for the column the chart sits in, centred under a
|
|
35
|
+
* full-width plot and pulled to an edge where a centred key would float in a gutter. */
|
|
36
|
+
legendPosition: {
|
|
37
|
+
type: String,
|
|
38
|
+
default: 'bottom-center',
|
|
39
|
+
validator: (p) => ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'].includes(p),
|
|
40
|
+
},
|
|
41
|
+
/** Title under the x axis. A scatter almost always needs both, because neither axis is self-evident. */
|
|
42
|
+
xLabel: { type: String, default: null },
|
|
43
|
+
/** Title beside the y axis. */
|
|
44
|
+
yLabel: { type: String, default: null },
|
|
45
|
+
/** Formats both axes' tick labels. Defaults to British grouping, compact past four digits. */
|
|
46
|
+
format: { type: Function, default: formatValue },
|
|
47
|
+
/** Smallest and largest dot diameter in px, mapped across the rows' `size` values. */
|
|
48
|
+
sizeRange: { type: Array, default: () => [18, 64] },
|
|
49
|
+
/** Print each row's `label` beside its dot. Off by default — a cloud of labels is not a chart. */
|
|
50
|
+
labels: { type: Boolean, default: false },
|
|
51
|
+
/** The sentence drawn when there is nothing to plot. */
|
|
52
|
+
empty: { type: String, default: 'No data' },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const state = computed(() => scatterState(props.data));
|
|
56
|
+
const message = computed(() => (state.value.kind === 'ready' ? null : stateMessage(state.value, props.empty)));
|
|
57
|
+
// A single unnamed cloud has nothing to put in a legend, so the frame draws none.
|
|
58
|
+
const seriesList = computed(() => {
|
|
59
|
+
if (state.value.kind !== 'ready') return [];
|
|
60
|
+
return state.value.series.length === 1 && state.value.series[0].key === 'points' ? [] : state.value.series;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const groups = computed(() => scatterGroups(props.data ?? []));
|
|
64
|
+
const x = (d) => d.x;
|
|
65
|
+
const y = (d) => d.y;
|
|
66
|
+
const size = (d) => (typeof d.size === 'number' && Number.isFinite(d.size) ? d.size : 1);
|
|
67
|
+
const color = (d) => {
|
|
68
|
+
if (state.value.kind !== 'ready') return undefined;
|
|
69
|
+
const name = d.group == null ? '' : String(d.group);
|
|
70
|
+
const i = groups.value.indexOf(name);
|
|
71
|
+
return state.value.series[Math.max(i, 0)]?.color;
|
|
72
|
+
};
|
|
73
|
+
const label = (d) => (props.labels && d.label != null ? String(d.label) : '');
|
|
74
|
+
|
|
75
|
+
/*
|
|
76
|
+
A scatter's point IS the datum, so the tooltip hangs off the mark rather than a column — there is
|
|
77
|
+
no shared x to snap to, which is the difference between this and the line family.
|
|
78
|
+
|
|
79
|
+
Rows are named by the AXIS LABELS where the slide gave them: a bare `x` and `y` name the geometry
|
|
80
|
+
rather than the measurement.
|
|
81
|
+
*/
|
|
82
|
+
const tip = (d) => tipFrame(String(d?.label ?? d?.group ?? ''), [
|
|
83
|
+
{ swatch: color(d), label: props.xLabel ?? 'x', value: props.format(d?.x ?? 0) },
|
|
84
|
+
{ label: props.yLabel ?? 'y', value: props.format(d?.y ?? 0) },
|
|
85
|
+
]);
|
|
86
|
+
</script>
|
|
87
|
+
|
|
88
|
+
<template>
|
|
89
|
+
<ChartFrame
|
|
90
|
+
:series="seriesList"
|
|
91
|
+
:height="height"
|
|
92
|
+
:legend="legend"
|
|
93
|
+
:legend-position="legendPosition"
|
|
94
|
+
:message="message"
|
|
95
|
+
>
|
|
96
|
+
<!-- `padding` insets the MARKS from the plot edges while the axes stay put — the one thing a
|
|
97
|
+
scatter needs that no other chart here does. Deck dots are up to 64px across, so a point at
|
|
98
|
+
the extreme of either scale would otherwise be sliced in half by the plot's own boundary.
|
|
99
|
+
Half the largest dot, rounded up. -->
|
|
100
|
+
<VisXYContainer
|
|
101
|
+
:data="data"
|
|
102
|
+
:height="height"
|
|
103
|
+
:duration="0"
|
|
104
|
+
:margin="{ top: 32, right: 40, bottom: 0, left: 0 }"
|
|
105
|
+
:padding="{ top: 40, right: 40, bottom: 40, left: 40 }"
|
|
106
|
+
>
|
|
107
|
+
<VisScatter
|
|
108
|
+
:x="x"
|
|
109
|
+
:y="y"
|
|
110
|
+
:size="size"
|
|
111
|
+
:color="color"
|
|
112
|
+
:size-range="sizeRange"
|
|
113
|
+
:label="label"
|
|
114
|
+
label-hide-overlapping
|
|
115
|
+
/>
|
|
116
|
+
<!-- Both axes carry grid lines here, unlike the category charts: neither axis of a scatter is
|
|
117
|
+
a list of names, so a reader needs to be able to trace a dot back to both scales. -->
|
|
118
|
+
<VisAxis
|
|
119
|
+
type="x"
|
|
120
|
+
grid-line
|
|
121
|
+
:tick-line="false"
|
|
122
|
+
:num-ticks="4"
|
|
123
|
+
:tick-format="format"
|
|
124
|
+
:tick-padding="16"
|
|
125
|
+
:label="xLabel"
|
|
126
|
+
:label-margin="24"
|
|
127
|
+
tick-text-hide-overlapping
|
|
128
|
+
/>
|
|
129
|
+
<VisAxis
|
|
130
|
+
type="y"
|
|
131
|
+
:num-ticks="4"
|
|
132
|
+
:tick-format="format"
|
|
133
|
+
:tick-line="false"
|
|
134
|
+
:domain-line="false"
|
|
135
|
+
:tick-padding="16"
|
|
136
|
+
:label="yLabel"
|
|
137
|
+
:label-margin="24"
|
|
138
|
+
/>
|
|
139
|
+
<VisTooltip v-bind="deckTooltip()" :triggers="{ [VisScatterSelectors.point]: tip }" />
|
|
140
|
+
</VisXYContainer>
|
|
141
|
+
</ChartFrame>
|
|
142
|
+
</template>
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/*
|
|
3
|
+
A stacked bar chart sized for a room: each column is one category, split into the parts that make
|
|
4
|
+
it up. Real @unovis/vue marks, ChartFrame.vue for ceremony, chartData.ts for shaping.
|
|
5
|
+
|
|
6
|
+
Stacking is a CLAIM: the segments sum to the column, and the column is the thing being compared.
|
|
7
|
+
When the series are independent quantities that merely share an axis, the honest mark is
|
|
8
|
+
GroupedBarChart.vue — a stack invites the audience to read a total that does not exist.
|
|
9
|
+
|
|
10
|
+
Bars start at zero. Not because a truncated axis is always a lie — it isn't, and the research does
|
|
11
|
+
not support the absolute rule — but because a slide is read in seconds from the back of a room,
|
|
12
|
+
with no axis to inspect, so length has to be the whole message. Unovis stacks from zero and this
|
|
13
|
+
component never moves the baseline.
|
|
14
|
+
|
|
15
|
+
Vertical by default because a stack reads as a column of parts. A category set whose labels are
|
|
16
|
+
long words wants Bars.vue instead, which lays them out horizontally and labels each bar directly.
|
|
17
|
+
*/
|
|
18
|
+
import { computed } from 'vue';
|
|
19
|
+
import { VisXYContainer, VisAxis, VisStackedBar, VisCrosshair, VisTooltip } from '@unovis/vue';
|
|
20
|
+
import { deckTooltip, seriesTip } from './chartTip';
|
|
21
|
+
import { motionDuration } from './motionDuration';
|
|
22
|
+
import ChartFrame from './ChartFrame.vue';
|
|
23
|
+
import { chartState, formatValue, labelAt, stateMessage, tickIndices, CROSSHAIR_CIRCLE_R, CROSSHAIR_HIDE_WHEN_FAR } from './chartData';
|
|
24
|
+
|
|
25
|
+
const props = defineProps({
|
|
26
|
+
/** Rows: `[{ label, <seriesKey>: number, … }]`. One row is one column; `label` names it. */
|
|
27
|
+
data: { type: Array, required: true },
|
|
28
|
+
/** Series to stack, bottom to top and in legend order: `[{ key, name }]`. Omit and every numeric field on the first row becomes a series. */
|
|
29
|
+
series: { type: Array, default: null },
|
|
30
|
+
/** Plot height in px. 0 FILLS, and is the default: the chart takes whatever box it is given,
|
|
31
|
+
* which is what a chart under a heading wants. Unovis resolves `config.height || containerHeight`,
|
|
32
|
+
* so a falsy height makes the container measure itself. Pass a number where the box has no
|
|
33
|
+
* definite height of its own — a tiled grid cell, a figure beside copy. */
|
|
34
|
+
height: { type: Number, default: 0 },
|
|
35
|
+
/** Draw the series legend. A stack is unreadable without one unless the segments are labelled some other way. */
|
|
36
|
+
legend: { type: Boolean, default: true },
|
|
37
|
+
/** Where the legend sits: `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center`
|
|
38
|
+
* or `bottom-right`. The EDGE is reading order — above is read before the marks, below is a
|
|
39
|
+
* caption read after them; the ALIGNMENT is for the column the chart sits in, centred under a
|
|
40
|
+
* full-width plot and pulled to an edge where a centred key would float in a gutter. */
|
|
41
|
+
legendPosition: {
|
|
42
|
+
type: String,
|
|
43
|
+
default: 'bottom-center',
|
|
44
|
+
validator: (p) => ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'].includes(p),
|
|
45
|
+
},
|
|
46
|
+
/** Title under the x axis. Omit when the labels already say what they are. */
|
|
47
|
+
xLabel: { type: String, default: null },
|
|
48
|
+
/** Title beside the y axis. Omit when the unit is in the slide's heading. */
|
|
49
|
+
yLabel: { type: String, default: null },
|
|
50
|
+
/** Formats the y tick labels. Defaults to British grouping, compact past four digits. */
|
|
51
|
+
format: { type: Function, default: formatValue },
|
|
52
|
+
/** Ceiling on x tick labels. Deck labels are 36px, so more than a handful collide. */
|
|
53
|
+
maxTicks: { type: Number, default: 8 },
|
|
54
|
+
/** Draw the two axes. Off is a SHAPE reading — the marks alone, no scale — for a chart that is
|
|
55
|
+
* being shown rather than read: a specimen, a register test, a sparkline beside a figure.
|
|
56
|
+
*
|
|
57
|
+
* ★ IT REMOVES THEM, IT DOES NOT HIDE THEM. Unovis reserves the axis band whether or not the
|
|
58
|
+
* axis paints, so hiding one in CSS leaves the plot inset by a tick row and a label column that
|
|
59
|
+
* are no longer there — measured at a 150px plot, the marks drew 22px of a 63px box. Not
|
|
60
|
+
* rendering the component is what gives the room back.
|
|
61
|
+
*
|
|
62
|
+
* ⚠ A chart carrying VALUES needs its scale. Turning this off says the reading is the shape. */
|
|
63
|
+
axes: { type: Boolean, default: true },
|
|
64
|
+
/** The sentence drawn when there is nothing to plot. */
|
|
65
|
+
empty: { type: String, default: 'No data' },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const state = computed(() => chartState(props.data, props.series));
|
|
69
|
+
const message = computed(() => (state.value.kind === 'ready' ? null : stateMessage(state.value, props.empty)));
|
|
70
|
+
const seriesList = computed(() => (state.value.kind === 'ready' ? state.value.series : []));
|
|
71
|
+
|
|
72
|
+
const x = (_d, i) => i;
|
|
73
|
+
const y = computed(() => seriesList.value.map((s) => (d) => d[s.key]));
|
|
74
|
+
const colors = computed(() => seriesList.value.map((s) => s.color));
|
|
75
|
+
|
|
76
|
+
// Half a column of outer gutter, so the first and last bars stop sitting flush against the plot
|
|
77
|
+
// edges — the same rule packages/charts' FChartFrame applies to every index chart.
|
|
78
|
+
const xDomain = computed(() => [-0.5, (props.data?.length ?? 0) - 0.5]);
|
|
79
|
+
const xTicks = computed(() => tickIndices(props.data?.length ?? 0, props.maxTicks));
|
|
80
|
+
const xFormat = (v) => labelAt(props.data ?? [], v);
|
|
81
|
+
|
|
82
|
+
/*
|
|
83
|
+
The crosshair carries the tooltip: every series has a value at the hovered x, so the target is the
|
|
84
|
+
COLUMN rather than any one mark — which is also the only target anyone can hit from a lectern.
|
|
85
|
+
`forestTooltip` titles the row from its first property, and the deck's rows lead with `label`.
|
|
86
|
+
*/
|
|
87
|
+
/* Read at mount so `prefers-reduced-motion` is honoured; see motionDuration.ts. */
|
|
88
|
+
const crosshairMs = motionDuration('instant');
|
|
89
|
+
|
|
90
|
+
const tip = computed(() => seriesTip(seriesList.value, props.format));
|
|
91
|
+
</script>
|
|
92
|
+
|
|
93
|
+
<template>
|
|
94
|
+
<ChartFrame
|
|
95
|
+
:series="seriesList"
|
|
96
|
+
:height="height"
|
|
97
|
+
:legend="legend"
|
|
98
|
+
:legend-position="legendPosition"
|
|
99
|
+
:message="message"
|
|
100
|
+
>
|
|
101
|
+
<VisXYContainer
|
|
102
|
+
:data="data"
|
|
103
|
+
:height="height"
|
|
104
|
+
:duration="0"
|
|
105
|
+
:x-domain="xDomain"
|
|
106
|
+
:margin="{ top: 16, right: 24, bottom: 0, left: 0 }"
|
|
107
|
+
>
|
|
108
|
+
<!-- 8px corners, matching --deck-radius-item: Unovis clamps the radius against the segment
|
|
109
|
+
height, so a thin slice degrades to a square rather than deforming. -->
|
|
110
|
+
<VisStackedBar :x="x" :y="y" :color="colors" :rounded-corners="8" :bar-padding="0.25" />
|
|
111
|
+
<VisAxis
|
|
112
|
+
v-if="axes"
|
|
113
|
+
type="x"
|
|
114
|
+
:grid-line="false"
|
|
115
|
+
:tick-line="false"
|
|
116
|
+
:tick-values="xTicks"
|
|
117
|
+
:tick-format="xFormat"
|
|
118
|
+
:tick-padding="16"
|
|
119
|
+
:label="xLabel"
|
|
120
|
+
:label-margin="24"
|
|
121
|
+
tick-text-hide-overlapping
|
|
122
|
+
/>
|
|
123
|
+
<VisAxis
|
|
124
|
+
v-if="axes"
|
|
125
|
+
type="y"
|
|
126
|
+
:tick-format="format"
|
|
127
|
+
:num-ticks="4"
|
|
128
|
+
:tick-line="false"
|
|
129
|
+
:domain-line="false"
|
|
130
|
+
:tick-padding="16"
|
|
131
|
+
:label="yLabel"
|
|
132
|
+
:label-margin="24"
|
|
133
|
+
/>
|
|
134
|
+
<!-- ★★ `y-stacked`, NOT `y`, WHEREVER THE MARKS STACK. The crosshair plots a circle per
|
|
135
|
+
series, and given `y` it plots each at its RAW value — which on a stacked chart is a
|
|
136
|
+
position nothing is drawn at: the second series' circle lands inside the first band
|
|
137
|
+
rather than on the boundary between them. `yStacked` is Unovis' own accumulator, so the
|
|
138
|
+
circles sit where the bands actually meet. -->
|
|
139
|
+
<VisCrosshair :duration="crosshairMs" :hideWhenFarFromPointer="CROSSHAIR_HIDE_WHEN_FAR" :circleRadius="CROSSHAIR_CIRCLE_R" :x="x" :yStacked="y" :template="tip" :color="colors" />
|
|
140
|
+
<VisTooltip v-bind="deckTooltip()" />
|
|
141
|
+
</VisXYContainer>
|
|
142
|
+
</ChartFrame>
|
|
143
|
+
</template>
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/*
|
|
3
|
+
A stat inside a card: the deck's <Kpi> figure, framed by the deck's one container.
|
|
4
|
+
|
|
5
|
+
This is NOT a second definition of a KPI — Kpi.vue already owns the numeral treatment, the
|
|
6
|
+
label treatment and the delta rule (arrow = direction, colour = judgement), and this component
|
|
7
|
+
reuses all three wholesale rather than restating them. StatCard's own job is FRAMING
|
|
8
|
+
only: which background the stat sits on, how the figure aligns in the tile, and — on a named
|
|
9
|
+
background specifically — keeping Card's own promise that "text inside it stays legible without the
|
|
10
|
+
author picking a colour". Card already fulfils that promise for plain prose by re-pointing
|
|
11
|
+
h1–h6/p/.deck-caption to the pair's ink (see base.css's `.deck-card.bg-*` rules), but Kpi's
|
|
12
|
+
figure, label and delta are their own classes, not those elements, so that rule never reaches
|
|
13
|
+
them: measured live when this was the accent variant, `<Card variant="accent"><Kpi/></Card>`
|
|
14
|
+
rendered the figure in `--deck-accent` text on a `--deck-accent` fill — the exact same colour,
|
|
15
|
+
invisible. Re-pointing those classes to the card's ink when framed this way is the one place this
|
|
16
|
+
component touches colour, and it is Card's contract StatCard is completing, not a second
|
|
17
|
+
definition of Kpi's judgement-colour rule.
|
|
18
|
+
|
|
19
|
+
Every prop below except `background`, `dark` and `align` is Kpi's own name, passed straight through
|
|
20
|
+
unchanged — including its slots: `#metric` forwards for pre-formatted text, so
|
|
21
|
+
`<StatCard><template #metric>12k</template></StatCard>` — the usage this component's own doc used
|
|
22
|
+
to describe without actually wiring it up — works. Inventing a parallel name (`value` for
|
|
23
|
+
`metric`, say) is exactly the drift this component exists to avoid.
|
|
24
|
+
*/
|
|
25
|
+
import { BACKGROUND_NAMES, CARD_BACKGROUNDS } from './backgrounds.js';
|
|
26
|
+
|
|
27
|
+
defineProps({
|
|
28
|
+
/** What the stat sits on — 'transparent' for no fill at all, 'surface' the quiet fill, or 'warm' | 'accent' | 'vivid' | 'neutral', a named background. Forwarded to Card's own `background`. */
|
|
29
|
+
background: {
|
|
30
|
+
type: String,
|
|
31
|
+
default: 'surface',
|
|
32
|
+
validator: (b) => CARD_BACKGROUNDS.includes(b),
|
|
33
|
+
},
|
|
34
|
+
/** Render the card in the dark register. Forwarded to Card's own `dark`, which clamps it on `transparent`. */
|
|
35
|
+
dark: { type: Boolean, default: false },
|
|
36
|
+
/** The card's edge: 'none' (the default) | 'hairline' | 'strong'. Forwarded to Card's own `border`. */
|
|
37
|
+
border: {
|
|
38
|
+
type: String,
|
|
39
|
+
default: 'none',
|
|
40
|
+
validator: (e) => ['none', 'hairline', 'strong'].includes(e),
|
|
41
|
+
},
|
|
42
|
+
/** How the figure sits in the tile: 'center' (default) reads best as one tile among several in a grid; 'start' keeps Kpi's own left-aligned layout. */
|
|
43
|
+
align: {
|
|
44
|
+
type: String,
|
|
45
|
+
// A prop rather than a bare scoped rule: `.stat-card` is scoped, so its `[data-v-…]` selector
|
|
46
|
+
// outranks a plain `items-start`/`text-left` utility passed at the tag — the same reason Grid
|
|
47
|
+
// exposes `align` instead of leaving cross-axis alignment to a class an author would
|
|
48
|
+
// reasonably expect to work.
|
|
49
|
+
default: 'center',
|
|
50
|
+
validator: (a) => ['center', 'start'].includes(a),
|
|
51
|
+
},
|
|
52
|
+
/** Kpi's name for the figure, shown above it. */
|
|
53
|
+
label: { type: String, required: true },
|
|
54
|
+
/** Kpi's number. Pre-formatted text goes through the `#metric` slot instead — forwarded to Kpi's own slot of the same name. */
|
|
55
|
+
metric: { type: Number, default: null },
|
|
56
|
+
/** How the figure is notated: 'full' spells out every digit, 'compact' abbreviates (1.2m, 999k). Kpi's own prop, forwarded unchanged. */
|
|
57
|
+
format: { type: String, default: 'compact' },
|
|
58
|
+
/** Decimal places to force on the formatted figure; omit to let the format's own rounding decide. Kpi's own prop, forwarded unchanged. */
|
|
59
|
+
precision: { type: Number, default: undefined },
|
|
60
|
+
/** Locale the figure and its delta are formatted in, e.g. 'en-GB'. Kpi's own prop, forwarded unchanged. */
|
|
61
|
+
locale: { type: String, default: 'en-GB' },
|
|
62
|
+
/** Suffix rendered immediately after the figure, e.g. '%' or 'km'. */
|
|
63
|
+
unit: { type: String, default: '' },
|
|
64
|
+
/** Required for an aggregate ("last 7 days"); omit for an instantaneous reading. */
|
|
65
|
+
period: { type: String, default: '' },
|
|
66
|
+
/** Rendered, never tooltip-only — a figure with a caveat has the caveat on the slide. */
|
|
67
|
+
caveat: { type: String, default: '' },
|
|
68
|
+
/** 'higher-is-better' | 'lower-is-better' | 'neutral' — Kpi's own delta-colour rule. */
|
|
69
|
+
sentiment: { type: String, default: 'neutral' },
|
|
70
|
+
/** { value, unit: 'percent'|'pp'|'absolute', comparisonLabel, previous?, display? } — Kpi's own delta shape. */
|
|
71
|
+
delta: { type: Object, default: undefined },
|
|
72
|
+
});
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<template>
|
|
76
|
+
<Card
|
|
77
|
+
:background="background"
|
|
78
|
+
:dark="dark"
|
|
79
|
+
:border="border"
|
|
80
|
+
class="stat-card"
|
|
81
|
+
:class="[{ 'is-named': BACKGROUND_NAMES.includes(background) }, `is-align-${align === 'start' ? 'start' : 'center'}`]"
|
|
82
|
+
>
|
|
83
|
+
<Kpi
|
|
84
|
+
:label="label"
|
|
85
|
+
:metric="metric"
|
|
86
|
+
:format="format"
|
|
87
|
+
:precision="precision"
|
|
88
|
+
:locale="locale"
|
|
89
|
+
:unit="unit"
|
|
90
|
+
:period="period"
|
|
91
|
+
:caveat="caveat"
|
|
92
|
+
:sentiment="sentiment"
|
|
93
|
+
:delta="delta"
|
|
94
|
+
>
|
|
95
|
+
<template v-if="$slots.metric" #metric><slot name="metric" /></template>
|
|
96
|
+
</Kpi>
|
|
97
|
+
</Card>
|
|
98
|
+
</template>
|
|
99
|
+
|
|
100
|
+
<style scoped>
|
|
101
|
+
/* Framing only — nothing here touches a type role Kpi already owns. Kpi's own label/figure/
|
|
102
|
+
delta/foot rows are left-aligned block-and-flex content, so `text-align` on the card alone
|
|
103
|
+
reaches the label and figure but not the flex rows — those need their own `justify-content`. */
|
|
104
|
+
.stat-card.is-align-center {
|
|
105
|
+
display: flex;
|
|
106
|
+
flex-direction: column;
|
|
107
|
+
align-items: center;
|
|
108
|
+
text-align: center;
|
|
109
|
+
}
|
|
110
|
+
.stat-card.is-align-center :deep(.kpi-delta),
|
|
111
|
+
.stat-card.is-align-center :deep(.kpi-foot) {
|
|
112
|
+
justify-content: center;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* Completing Card's own legibility contract on a named background — see the doc comment above
|
|
116
|
+
for why base.css's `.deck-card.bg-*` rules never reach these classes on their own. One flat ink
|
|
117
|
+
for every role, inherited from the card, matching how base.css already flattens h1–h6/p to it: a
|
|
118
|
+
claim card states one thing, not a nuanced multi-colour reading. `surface` and `transparent`
|
|
119
|
+
need nothing here, dark or not — they borrow the page's ink, Kpi's own roles are tokens, and
|
|
120
|
+
`.deck-dark` on the card flips them. */
|
|
121
|
+
.stat-card.is-named :deep(.kpi-label),
|
|
122
|
+
.stat-card.is-named :deep(.kpi-figure),
|
|
123
|
+
.stat-card.is-named :deep(.kpi-delta),
|
|
124
|
+
.stat-card.is-named :deep(.kpi-delta.is-success),
|
|
125
|
+
.stat-card.is-named :deep(.kpi-delta.is-error),
|
|
126
|
+
.stat-card.is-named :deep(.kpi-foot) {
|
|
127
|
+
color: inherit;
|
|
128
|
+
}
|
|
129
|
+
.stat-card.is-named :deep(.kpi-comparison),
|
|
130
|
+
.stat-card.is-named :deep(.kpi-caveat) {
|
|
131
|
+
color: inherit;
|
|
132
|
+
opacity: 0.85;
|
|
133
|
+
}
|
|
134
|
+
</style>
|