@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.
Files changed (124) hide show
  1. package/LICENSE +67 -0
  2. package/README.md +577 -0
  3. package/assetUrl.js +38 -0
  4. package/components/Alert.vue +209 -0
  5. package/components/AreaBox.vue +152 -0
  6. package/components/AreaChart.vue +145 -0
  7. package/components/Avatar.vue +267 -0
  8. package/components/AvatarGroup.vue +94 -0
  9. package/components/BadgeStrip.vue +203 -0
  10. package/components/Bars.vue +207 -0
  11. package/components/BoroughShape.vue +127 -0
  12. package/components/BrowserFrame.vue +484 -0
  13. package/components/CalendarGrid.vue +480 -0
  14. package/components/Card.vue +70 -0
  15. package/components/Carousel.vue +266 -0
  16. package/components/ChartFrame.vue +426 -0
  17. package/components/ChordChart.vue +264 -0
  18. package/components/ComposedChart.vue +187 -0
  19. package/components/Contents.vue +86 -0
  20. package/components/DataTable.vue +244 -0
  21. package/components/DeckMap.vue +409 -0
  22. package/components/DeviceFrame.vue +331 -0
  23. package/components/DonutChart.vue +103 -0
  24. package/components/DottedMap.vue +126 -0
  25. package/components/Endorsement.vue +76 -0
  26. package/components/Grid.vue +169 -0
  27. package/components/GroupedBarChart.vue +141 -0
  28. package/components/IconTile.vue +116 -0
  29. package/components/Kpi.vue +188 -0
  30. package/components/LineChart.vue +133 -0
  31. package/components/Logo.vue +203 -0
  32. package/components/LondonMap.vue +348 -0
  33. package/components/MarkPair.vue +109 -0
  34. package/components/Marquee.vue +263 -0
  35. package/components/NestedDonutChart.vue +295 -0
  36. package/components/Placeholder.vue +88 -0
  37. package/components/SankeyChart.vue +223 -0
  38. package/components/ScatterChart.vue +142 -0
  39. package/components/StackedBarChart.vue +143 -0
  40. package/components/StatCard.vue +134 -0
  41. package/components/StatusTrack.vue +334 -0
  42. package/components/Timeline.vue +249 -0
  43. package/components/TimelineChart.vue +329 -0
  44. package/components/TreemapChart.vue +267 -0
  45. package/components/backgrounds.js +135 -0
  46. package/components/boroughGeometry.ts +59 -0
  47. package/components/chartData.ts +277 -0
  48. package/components/chartTip.ts +201 -0
  49. package/components/codeTheme.ts +239 -0
  50. package/components/deckPalette.ts +157 -0
  51. package/components/dottedMapGeometry.js +60 -0
  52. package/components/iconSlugs.js +55 -0
  53. package/components/iconUrl.js +51 -0
  54. package/components/mermaidTheme.ts +972 -0
  55. package/components/motionDuration.ts +41 -0
  56. package/components/tableRules.ts +257 -0
  57. package/confidential-mark.vue +151 -0
  58. package/custom-nav-controls.vue +79 -0
  59. package/endorsements/b-corp-black.svg +30 -0
  60. package/endorsements/b-corp-white.svg +30 -0
  61. package/global-top.vue +1662 -0
  62. package/icons/forest/bike-asterisk.svg +21 -0
  63. package/icons/forest/bike-down.svg +19 -0
  64. package/icons/forest/bike-off.svg +20 -0
  65. package/icons/forest/bike-up.svg +19 -0
  66. package/icons/forest/bike-x.svg +19 -0
  67. package/icons/forest/bike.svg +18 -0
  68. package/icons/forest/mark-fill.svg +6 -0
  69. package/icons/forest/mark.svg +6 -0
  70. package/icons/forest/parking.svg +3 -0
  71. package/icons/forest/star-fill.svg +4 -0
  72. package/icons/forest/star.svg +4 -0
  73. package/icons/forest/traffic-light-caution.svg +8 -0
  74. package/icons/forest/traffic-light-go.svg +8 -0
  75. package/icons/forest/traffic-light-lit.svg +8 -0
  76. package/icons/forest/traffic-light-stop.svg +8 -0
  77. package/icons/forest/traffic-light.svg +8 -0
  78. package/icons/lucide/battery.svg +1 -0
  79. package/icons/lucide/bike.svg +1 -0
  80. package/icons/lucide/chart-line.svg +1 -0
  81. package/icons/lucide/check.svg +1 -0
  82. package/icons/lucide/circle-check.svg +1 -0
  83. package/icons/lucide/clock.svg +1 -0
  84. package/icons/lucide/code.svg +1 -0
  85. package/icons/lucide/component.svg +1 -0
  86. package/icons/lucide/frame.svg +1 -0
  87. package/icons/lucide/layout-grid.svg +1 -0
  88. package/icons/lucide/map-pin.svg +1 -0
  89. package/icons/lucide/map.svg +1 -0
  90. package/icons/lucide/palette.svg +1 -0
  91. package/icons/lucide/table.svg +1 -0
  92. package/icons/lucide/triangle-alert.svg +1 -0
  93. package/icons/lucide/type.svg +1 -0
  94. package/icons/lucide/wrench.svg +1 -0
  95. package/layouts/bleed.vue +317 -0
  96. package/layouts/cover.vue +1514 -0
  97. package/layouts/default.vue +187 -0
  98. package/layouts/divider.vue +286 -0
  99. package/layouts/end.vue +384 -0
  100. package/layouts/quote.vue +218 -0
  101. package/layouts/split.vue +1875 -0
  102. package/layouts/stack.vue +944 -0
  103. package/layouts/statement.vue +67 -0
  104. package/manifest/chrome.ts +36 -0
  105. package/manifest/components.ts +49 -0
  106. package/manifest/index.ts +57 -0
  107. package/manifest/layouts.ts +37 -0
  108. package/manifest/manifest.json +6514 -0
  109. package/manifest/presets.ts +635 -0
  110. package/notch.js +458 -0
  111. package/notchPath.js +188 -0
  112. package/package.json +77 -0
  113. package/section.js +110 -0
  114. package/setup/mermaid-renderer.ts +2120 -0
  115. package/setup/mermaid.ts +30 -0
  116. package/setup/shiki.ts +44 -0
  117. package/setup/shortcuts.ts +28 -0
  118. package/slide-bottom.vue +305 -0
  119. package/slide-top.vue +268 -0
  120. package/styles/base.css +2611 -0
  121. package/styles/index.ts +71 -0
  122. package/styles/tokens.css +144 -0
  123. package/uno.config.ts +231 -0
  124. 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>