@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,264 @@
1
+ <script setup>
2
+ /*
3
+ A chord diagram sized for a room — the flows BETWEEN the members of one set, drawn as a ring.
4
+
5
+ A chord answers "who trades with whom": every member both sends and receives, and the ribbon
6
+ joining a pair is the volume between them. When the flow only ever runs forwards, through stages
7
+ that never send anything back — visits into sign-ups into rides — the ring is the wrong shape and
8
+ the chart is a Sankey. A circle invites an audience to look for a circularity the data does not
9
+ contain.
10
+
11
+ FIVE NODES, no sixth. The ring inherits the palette ceiling, and here the ceiling is doing
12
+ legibility work as well as palette work: five members already allow ten ribbons, which makes this
13
+ the densest mark in the deck's set. A sixth draws chartData.ts's `over-capacity` sentence instead.
14
+
15
+ ONE LEVEL. `nodeLevels` bands the ring into parents and children, which is a drill-down for a
16
+ dashboard somebody can hover; a slide is read in seconds and has no hover, so the ring stays flat.
17
+ That also makes `radiusScaleExponent` inert — with a single level every leaf sits at y1 = 1, the
18
+ top of the radius scale's domain, so the exponent has nothing to bend.
19
+
20
+ Real @unovis/vue mark, ChartFrame.vue for the ceremony, chartData.ts for the palette and states.
21
+ */
22
+ import { computed } from 'vue';
23
+ import { VisSingleContainer, VisChordDiagram, VisTooltip, VisChordDiagramSelectors } from '@unovis/vue';
24
+ import { deckTooltip, tipFrame } from './chartTip';
25
+ import ChartFrame from './ChartFrame.vue';
26
+ import { SERIES_CEILING, formatValue, isPlottable, seriesVar, stateMessage } from './chartData';
27
+
28
+ const props = defineProps({
29
+ /** The ring's members: `[{ id, label }]`, clockwise from twelve. `id` is what the links point at, compared as text, so `1` and `'1'` name the same member; `label` is what the legend calls it. */
30
+ nodes: { type: Array, required: true },
31
+ /** The flows: `[{ source, target, value }]`, source and target being node ids. A flow from a member to itself is not a chord, and neither is one naming an id that is not in `nodes`: both are dropped, and a ring left with no flows draws the empty state. */
32
+ links: { type: Array, required: true },
33
+ /** Plot height in px. 0 FILLS, and is the default: the chart takes whatever box it is given,
34
+ * which is what a chart under a heading wants. Unovis resolves `config.height || containerHeight`,
35
+ * so a falsy height makes the container measure itself. Pass a number where the box has no
36
+ * definite height of its own — a tiled grid cell, a figure beside copy. */
37
+ height: { type: Number, default: 0 },
38
+ /** Thickness of the arc band in px. The library's 15 is a rim on a dashboard and a hairline on a projector. */
39
+ nodeWidth: { type: Number, default: 28 },
40
+ /** Name each arc on the ring itself. Off by default — see the note at the mark; the labels are charged against the radius. */
41
+ nodeLabels: { type: Boolean, default: false },
42
+ /** Id of the one node the slide is about: its arc and every ribbon touching it stay lit, everything else dims. An id no node carries is not a highlight. */
43
+ highlight: { type: String, default: null },
44
+ /** Draw the node legend. A ring with neither legend nor labels says nothing. */
45
+ legend: { type: Boolean, default: true },
46
+ /** Where the legend sits: `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center`
47
+ * or `bottom-right`. The EDGE is reading order — above is read before the marks, below is a
48
+ * caption read after them; the ALIGNMENT is for the column the chart sits in, centred under a
49
+ * full-width plot and pulled to an edge where a centred key would float in a gutter. */
50
+ legendPosition: {
51
+ type: String,
52
+ default: 'bottom-center',
53
+ validator: (p) => ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'].includes(p),
54
+ },
55
+ /** The sentence drawn when there is nothing to plot — including a set of flows that are all zero. */
56
+ empty: { type: String, default: 'No data' },
57
+ });
58
+
59
+ /** The identity a node is matched on, here and inside Unovis: its own id, or its position. */
60
+ const idOf = (n, i) => String(n?.id ?? i);
61
+
62
+ const nodeList = computed(() => (Array.isArray(props.nodes) ? props.nodes : []));
63
+ const ids = computed(() => new Set(nodeList.value.map(idOf)));
64
+
65
+ /*
66
+ ★ A LINK UNOVIS WOULD NOT DRAW NEVER REACHES IT, and none of these three clauses is tidiness.
67
+ Each is the difference between an honest empty state and a ring of slivers.
68
+
69
+ NO FINITE, POSITIVE VALUE. `_layoutData` adds every link's value into BOTH endpoints' running
70
+ totals and only afterwards filters the valueless links out (`links.filter(d => d._state.value)`),
71
+ so a MISSING `value` — undefined, or any non-numeric field — puts `0 + undefined`, NaN, into two
72
+ nodes' totals. d3's `sum` reads that back as `+NaN || 0`, both arcs collapse, and every other
73
+ ribbon at those two nodes is then sized by `value / node.value`, i.e. divided by nought. One bad
74
+ row, two dead arcs, no error. A `null` is the quiet case — `0 + null` is 0, so it poisons nothing
75
+ — but it is still not a flow, and a negative one has no ribbon either: the width would run
76
+ backwards.
77
+
78
+ AN ENDPOINT THAT IS NOT A MEMBER, OR IS BOTH ENDPOINTS. The graph data model drops both itself
79
+ (data-models/graph.js: `l.source === l.target` with a console warning, then `l.source && l.target`
80
+ once `findNode` has failed) — but it does that AFTER this component has already called the chart
81
+ ready, so without these clauses a slide whose links name a member that was since renamed draws the
82
+ slivers described below and says nothing about why. Matched as text against the ids handed to
83
+ Unovis, which is why the set is built from `idOf` rather than from `n.id`.
84
+ */
85
+ const flows = computed(() =>
86
+ (Array.isArray(props.links) ? props.links : []).filter(
87
+ (l) =>
88
+ isPlottable(l.value) &&
89
+ l.value > 0 &&
90
+ String(l.source) !== String(l.target) &&
91
+ ids.value.has(String(l.source)) &&
92
+ ids.value.has(String(l.target)),
93
+ ),
94
+ );
95
+
96
+ /*
97
+ The ring's own state decision, spoken in chartData.ts's vocabulary so `stateMessage` prints the
98
+ same sentences the rest of the chart set does. The palette ceiling counts NODES here, and a ring
99
+ whose flows have all dropped out is `no-values`: Unovis would still draw the members, as a set of
100
+ disconnected slivers each exactly Math.PI/180 wide — one degree, the arc generator's Math.PI/360
101
+ either side of a node whose value is nought — which looks like a chart rather than like missing
102
+ data.
103
+ */
104
+ const state = computed(() => {
105
+ const nodes = nodeList.value;
106
+ if (nodes.length === 0) return { kind: 'empty', reason: 'no-rows' };
107
+ if (nodes.length > SERIES_CEILING) return { kind: 'over-capacity', count: nodes.length };
108
+ if (flows.value.length === 0) return { kind: 'empty', reason: 'no-values' };
109
+ return {
110
+ kind: 'ready',
111
+ series: nodes.map((n, i) => ({ key: idOf(n, i), name: String(n.label ?? n.id ?? i + 1), color: seriesVar(i) })),
112
+ };
113
+ });
114
+ const message = computed(() => (state.value.kind === 'ready' ? null : stateMessage(state.value, props.empty)));
115
+ const seriesList = computed(() => (state.value.kind === 'ready' ? state.value.series : []));
116
+
117
+ /*
118
+ Both tables are normalised to the id space above before the model sees them, endpoints included.
119
+ `findNode` reads a NUMERIC source as an INDEX into the node array and a string as an id, so an
120
+ unstringed `source: 1` beside a node carrying `id: 1` resolves to whichever member happens to sit
121
+ second — agreeing with this component's own matching by accident, and stopping the moment the
122
+ array is reordered. Handing Unovis strings removes the second addressing mode entirely.
123
+ */
124
+ const data = computed(() => ({
125
+ nodes: nodeList.value.map((n, i) => ({ ...n, id: idOf(n, i) })),
126
+ links: flows.value.map((l) => ({ ...l, source: String(l.source), target: String(l.target) })),
127
+ }));
128
+
129
+ // Accessors key off the id, never object identity: the graph data model deep-clones what it is
130
+ // handed, so the node this is called with is never the node the slide wrote.
131
+ const colorById = computed(() => new Map(seriesList.value.map((s) => [s.key, s.color])));
132
+ const nodeColor = (n) => colorById.value.get(String(n.id)) ?? '';
133
+ // A ribbon takes its SOURCE's colour, so the ring reads as "what leaves each member" rather than as
134
+ // a set of unattributable bands. Unovis' own default is a flat pale blue for every flow.
135
+ const linkColor = (l) => colorById.value.get(String(l.source?.id)) ?? '';
136
+
137
+ /*
138
+ ★ THE ACCESSOR'S IDENTITY IS THE PROP, which is why this is a computed and not one stable arrow
139
+ reading `props.nodeLabels` from inside. The @unovis/vue wrapper re-configures its mark only when
140
+ `arePropsEqual` reports the forwarded props changed, and that comparison ends at `a === b` for
141
+ anything that is not an object (utils/data.js, `isEqual`) — so a function whose reference never
142
+ changes is never a change, whatever it has closed over. `nodeLabels` is not forwarded to the mark
143
+ at all, so nothing else would fire either, and `:node-labels="$clicks > 0"` would reveal nothing
144
+ until an unrelated prop happened to change in the same tick.
145
+ */
146
+ const nodeLabel = computed(() => (props.nodeLabels ? (n) => String(n.label ?? n.id) : () => ''));
147
+
148
+ /*
149
+ ★ `undefined`, NOT null, for the highlight: `_forceHighlight` tests `highlightedNodeId !==
150
+ undefined`, so a null turns the dimming on and lights nothing. An id no member carries does the
151
+ same thing for a different reason — the whole ring at 0.25 opacity, no explanation — so it is not
152
+ a highlight either.
153
+ */
154
+ const highlighted = computed(() =>
155
+ props.highlight != null && ids.value.has(props.highlight) ? props.highlight : undefined,
156
+ );
157
+
158
+ /*
159
+ ★★ HIGHLIGHTING A NODE DIMS EVERY RIBBON, ITS OWN INCLUDED, unless this list is computed here.
160
+ Setting `highlightedNodeId` turns on the component's `_forceHighlight`, which puts the `transparent`
161
+ class on the whole group; that class drops every link WITHOUT the highlighted class to 0.25
162
+ opacity, and the only thing that ever adds the highlighted class is
163
+ `highlightedLinkIds?.includes(id)`. Unovis fills that list from hover, which a projected slide
164
+ never receives, so a deck has to name the ribbons itself.
165
+
166
+ The id each link is matched on is its own `id` when it has one and otherwise `_indexGlobal`, its
167
+ INDEX in the array handed to the container — which is why this maps over the filtered flows and
168
+ not the prop.
169
+ */
170
+ const highlightedLinkIds = computed(() =>
171
+ highlighted.value === undefined
172
+ ? []
173
+ : flows.value.flatMap((l, i) =>
174
+ String(l.source) === highlighted.value || String(l.target) === highlighted.value ? [l.id ?? i] : [],
175
+ ),
176
+ );
177
+
178
+ /*
179
+ A chord answers the same two questions a Sankey does — a NODE is everything through one member, a
180
+ RIBBON is one pair — so it takes the same pair of templates.
181
+
182
+ ★ THE NAME IS READ DIRECTLY, not through `nodeLabel`: that computed returns an empty string when
183
+ `nodeLabels` is off, which is right for the ring and wrong for a tooltip, whose whole job is to
184
+ name the thing under the pointer.
185
+ */
186
+ const chordName = (n) => String(n?.label ?? n?.id ?? n?.data?.id ?? '');
187
+ const nodeTip = (n) => tipFrame(chordName(n?.data ?? n), [
188
+ { swatch: nodeColor(n?.data ?? n), label: 'Total', value: formatValue(n?.value ?? 0) },
189
+ ]);
190
+ const linkTip = (l) => tipFrame(
191
+ `${chordName(l?.source?.data ?? l?.source)} ↔ ${chordName(l?.target?.data ?? l?.target)}`,
192
+ [{ label: 'Flow', value: formatValue(l?.data?.value ?? l?.value ?? 0) }],
193
+ );
194
+ </script>
195
+
196
+ <template>
197
+ <ChartFrame
198
+ :series="seriesList"
199
+ :height="height"
200
+ :legend="legend"
201
+ :legend-position="legendPosition"
202
+ :message="message"
203
+ >
204
+ <div class="deck-chord">
205
+ <VisSingleContainer :data="data" :height="height" :duration="0">
206
+ <!--
207
+ `perpendicular` labels, never the library's `along`. An along-label is set on a textPath
208
+ following its own arc, so it reads upside down for every member on the bottom half of the
209
+ ring, and it is charged against the length of that arc: modules/label.js trims the string
210
+ to fit BEFORE it measures, so the ordinary outcome for a long name is a middle-ellipsised
211
+ one, and the residual case — where even the trimmed string still measures wider than the
212
+ arc — appends no textPath at all and drops the label silently. Perpendicular labels are
213
+ always drawn and always upright; what they cost is radius, which is what the style block
214
+ measures and why `nodeLabels` is off.
215
+
216
+ 0.04rad of pad, against the library's 0.02: the pad is the ONLY thing separating two arcs
217
+ here, where the donut also carries a 4px stroke in the ground colour. A gap is
218
+ padAngle × radius — about 4px at this height, where 9px reads from the back of a room.
219
+
220
+ Corner radius 4, not the donut's 8: a radius approaching half of the 28px band would turn
221
+ each arc into a pill.
222
+ -->
223
+ <VisChordDiagram
224
+ :node-color="nodeColor"
225
+ :link-color="linkColor"
226
+ :node-width="nodeWidth"
227
+ :node-label="nodeLabel"
228
+ node-label-color="var(--deck-fg)"
229
+ node-label-alignment="perpendicular"
230
+ :pad-angle="0.04"
231
+ :corner-radius="4"
232
+ :highlighted-node-id="highlighted"
233
+ :highlighted-link-ids="highlightedLinkIds"
234
+ />
235
+ <VisTooltip v-bind="deckTooltip()" :triggers="{ [VisChordDiagramSelectors.node]: nodeTip, [VisChordDiagramSelectors.link]: linkTip }" />
236
+ </VisSingleContainer>
237
+ </div>
238
+ </ChartFrame>
239
+ </template>
240
+
241
+ <style scoped>
242
+ .deck-chord {
243
+ /*
244
+ The label tier, and it is not only a type decision. Unovis sizes chord labels at `1em`, which
245
+ inherits whatever the slide is running, so a ring label would silently be a different size from
246
+ every axis tick in the deck; this pins it to the same small body tier (2.25rem / 36px) that
247
+ ChartFrame gives the axes.
248
+
249
+ ★ THE SAME NUMBER SETS THE RADIUS. `bleed` reads this variable back out of the DOM
250
+ (getCSSVariableValueInPixels against the chord's own element — a computed custom property is
251
+ inherited, so any ancestor would serve; it is declared HERE rather than in ChartFrame's adapter
252
+ because it belongs to this one mark and the adapter is the ceremony every chart shares) and
253
+ charges each label's ESTIMATED width against the ring: chars × font-size × --vis-font-wh-ratio,
254
+ the ratio being 0.5 and read off document.body. Radius is then min(width, height) / 2 minus the
255
+ widest of those, each of which also carries the library's own 3px of padding either side.
256
+
257
+ So at 36px each character of the longest name costs 18px of radius. "Westminster" is eleven of
258
+ them: 198px, 204px of bleed with the padding, and on the default 480px plot — a 240px radius —
259
+ that leaves a 36px ring. Short names, or no names, which is what the legend is for and why
260
+ `nodeLabels` defaults off.
261
+ */
262
+ --vis-chord-diagram-label-text-font-size: var(--type-deck-body-sm-size);
263
+ }
264
+ </style>
@@ -0,0 +1,187 @@
1
+ <script setup>
2
+ /*
3
+ A composed chart sized for a room: volume as bars, with a second quantity drawn over them as a
4
+ line, on one shared x axis. Real @unovis/vue marks — VisStackedBar and VisLine inside ONE
5
+ VisXYContainer — ChartFrame.vue for ceremony, chartData.ts for shaping.
6
+
7
+ ★ BOTH MARKS ARE MEASURED AGAINST THE SAME Y SCALE. An XY container resolves one y domain across
8
+ every child — `_updateScalesDomain` merges every component's extent and sets the SAME domain on
9
+ all of them — so the line reads off the axis the bars read off, and there is no second axis here
10
+ to rescue it. That is honest only while the two carry the SAME UNIT — rides against rides, pounds
11
+ against pounds — where the line is a subtotal, a rolling mean, a target, or last year's version of
12
+ the quantity the bars count.
13
+
14
+ Two different units on that one scale is where the chart starts lying, and it lies in both
15
+ directions. A percentage plotted beside a count in the thousands lies flat along the floor and
16
+ says nothing is happening. Rescaling it to be visible — the twin-axis chart every spreadsheet
17
+ offers — is worse, because the height at which the line crosses the bars is then chosen by
18
+ whoever picked the scaling: any two series can be made to appear to move together, or not to, and
19
+ an audience reading a slide in seconds has no way to see which was done.
20
+
21
+ So when the units differ, fix it at the CALL SITE, not with a second axis. Either express the
22
+ second quantity in the bars' own unit — 8% of 13,000 rides is 1,040 rides, so plot 1,040 — or
23
+ draw two charts stacked over the same x, each keeping its own axis, where neither mark borrows
24
+ authority from the other.
25
+
26
+ Note also that the bars' contribution to that shared domain is the STACK TOTAL, not any one
27
+ segment: StackedBar overrides `getYDataExtent` to return the summed extent. A line whose values
28
+ are of the order of a single segment sits low against a tall stack, which is correct arithmetic
29
+ and still a poor picture — another case for two charts rather than one.
30
+
31
+ Which series is a bar and which is a line is not something the data can say, so nothing is
32
+ inferred here: `barSeries` and `lineSeries` are both required and both explicit. The two resolve
33
+ through a single chartData call so the palette runs across the pair without repeating a colour,
34
+ and so the five-colour ceiling counts the whole chart rather than each mark separately.
35
+ */
36
+ import { computed } from 'vue';
37
+ import { VisXYContainer, VisAxis, VisStackedBar, VisLine, VisCrosshair, VisTooltip } from '@unovis/vue';
38
+ import { deckTooltip, seriesTip } from './chartTip';
39
+ import { motionDuration } from './motionDuration';
40
+ import ChartFrame from './ChartFrame.vue';
41
+ import { chartState, formatValue, labelAt, stateMessage, tickIndices, CROSSHAIR_CIRCLE_R, CROSSHAIR_HIDE_WHEN_FAR } from './chartData';
42
+
43
+ const props = defineProps({
44
+ /** Rows: `[{ label, <seriesKey>: number, … }]`. One row is one column; `label` names it. */
45
+ data: { type: Array, required: true },
46
+ /** Series drawn as bars, stacked bottom to top and first in legend order: `[{ key, name }]`. */
47
+ barSeries: { type: Array, required: true },
48
+ /** Series drawn as lines, over the bars and after them in legend order: `[{ key, name }]`. Same unit as the bars — see the header. */
49
+ lineSeries: { type: Array, required: true },
50
+ /** Plot height in px. 0 FILLS, and is the default: the chart takes whatever box it is given,
51
+ * which is what a chart under a heading wants. Unovis resolves `config.height || containerHeight`,
52
+ * so a falsy height makes the container measure itself. Pass a number where the box has no
53
+ * definite height of its own — a tiled grid cell, a figure beside copy. */
54
+ height: { type: Number, default: 0 },
55
+ /** Draw the series legend. Two marks on one axis are unreadable without one. */
56
+ legend: { type: Boolean, default: true },
57
+ /** Where the legend sits: `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center`
58
+ * or `bottom-right`. The EDGE is reading order — above is read before the marks, below is a
59
+ * caption read after them; the ALIGNMENT is for the column the chart sits in, centred under a
60
+ * full-width plot and pulled to an edge where a centred key would float in a gutter. */
61
+ legendPosition: {
62
+ type: String,
63
+ default: 'bottom-center',
64
+ validator: (p) => ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'].includes(p),
65
+ },
66
+ /** Title under the x axis. Omit when the labels already say what they are. */
67
+ xLabel: { type: String, default: null },
68
+ /** Title beside the y axis. It titles BOTH marks, so name the unit the two share. */
69
+ yLabel: { type: String, default: null },
70
+ /** Formats the y tick labels. Defaults to British grouping, compact past four digits. */
71
+ format: { type: Function, default: formatValue },
72
+ /** Ceiling on x tick labels. Deck labels are 36px, so more than a handful collide. */
73
+ maxTicks: { type: Number, default: 8 },
74
+ /** The sentence drawn when there is nothing to plot. */
75
+ empty: { type: String, default: 'No data' },
76
+ });
77
+
78
+ // Bars first, then lines, as one list: resolveSeries colours by position, so this ordering is what
79
+ // makes the palette run across the two marks instead of starting again at series 1 for the line.
80
+ // Passing an empty list would send chartData off to INFER series from the first row, which is the
81
+ // one thing this chart must not do — the inference cannot know which column is a line, and every
82
+ // column would land on the wrong side of the split below. Empty is the empty state instead.
83
+ const declared = computed(() => [...(props.barSeries ?? []), ...(props.lineSeries ?? [])]);
84
+ const state = computed(() =>
85
+ declared.value.length ? chartState(props.data, declared.value) : { kind: 'empty', reason: 'no-series' },
86
+ );
87
+ const message = computed(() => (state.value.kind === 'ready' ? null : stateMessage(state.value, props.empty)));
88
+ const seriesList = computed(() => (state.value.kind === 'ready' ? state.value.series : []));
89
+
90
+ // The same split, back apart, at the boundary the two props defined. The legend keeps the whole
91
+ // list — one chart, one key — which does mean a line and a bar both read as a disc there. Name the
92
+ // line series for what it is ("Rolling mean", "Target") and the name carries what the bullet cannot.
93
+ const barList = computed(() => seriesList.value.slice(0, props.barSeries?.length ?? 0));
94
+ const lineList = computed(() => seriesList.value.slice(props.barSeries?.length ?? 0));
95
+
96
+ const x = (_d, i) => i;
97
+ const barY = computed(() => barList.value.map((s) => (d) => d[s.key]));
98
+ const lineY = computed(() => lineList.value.map((s) => (d) => d[s.key]));
99
+ const barColors = computed(() => barList.value.map((s) => s.color));
100
+ const lineColors = computed(() => lineList.value.map((s) => s.color));
101
+ /* The crosshair spans BOTH families, so its swatches are the whole series list in legend order —
102
+ bars then lines — not one of the two colour arrays the marks bind. */
103
+ const allColors = computed(() => seriesList.value.map((s) => s.color));
104
+ const allY = computed(() => seriesList.value.map((s) => (d) => d[s.key]));
105
+
106
+ // Half a column of outer gutter, as in StackedBarChart.vue. Both marks read the same index
107
+ // accessor against it, which is what puts each line point over the centre of its own column.
108
+ const xDomain = computed(() => [-0.5, (props.data?.length ?? 0) - 0.5]);
109
+ const xTicks = computed(() => tickIndices(props.data?.length ?? 0, props.maxTicks));
110
+ const xFormat = (v) => labelAt(props.data ?? [], v);
111
+
112
+ /*
113
+ The crosshair carries the tooltip: every series has a value at the hovered x, so the target is the
114
+ COLUMN rather than any one mark — which is also the only target anyone can hit from a lectern.
115
+ `forestTooltip` titles the row from its first property, and the deck's rows lead with `label`.
116
+ */
117
+ /* Read at mount so `prefers-reduced-motion` is honoured; see motionDuration.ts. */
118
+ const crosshairMs = motionDuration('instant');
119
+
120
+ const tip = computed(() => seriesTip(seriesList.value, props.format));
121
+ </script>
122
+
123
+ <template>
124
+ <ChartFrame
125
+ :series="seriesList"
126
+ :height="height"
127
+ :legend="legend"
128
+ :legend-position="legendPosition"
129
+ :message="message"
130
+ >
131
+ <VisXYContainer
132
+ :data="data"
133
+ :height="height"
134
+ :duration="0"
135
+ :x-domain="xDomain"
136
+ :margin="{ top: 16, right: 24, bottom: 0, left: 0 }"
137
+ >
138
+ <!-- DECLARATION ORDER IS Z-ORDER, and it is load-bearing rather than incidental: XYContainer
139
+ appends each component's element to the SVG in the order the components registered, after
140
+ both axes, and the Vue wrappers register on mount in template order. So the bars are
141
+ declared first and the line lands on top of them, which is the only stacking that works —
142
+ a line hidden behind a column is a line nobody reads.
143
+ 8px corners, matching --deck-radius-item: Unovis clamps the radius against the segment's
144
+ own height, so a thin slice degrades to a square rather than deforming. -->
145
+ <VisStackedBar :x="x" :y="barY" :color="barColors" :rounded-corners="8" :bar-padding="0.25" />
146
+ <!-- 6px, as in LineChart.vue: a 2px stroke is a hairline on a projector, and here it also has
147
+ to hold its own against columns of solid colour underneath it.
148
+
149
+ ⚠ A ONE-COLUMN COMPOSED CHART DRAWS ITS BAR AND NO LINE, with no message. Unovis builds
150
+ the stroke with d3-shape's `line()`, which emits `M100,50Z` for a single point — a
151
+ zero-length subpath — and sets no `stroke-linecap`, so at the default `butt` nothing is
152
+ painted. The state stays `ready` because the row and the reading both exist. Harmless in
153
+ the two-mark case this chart is for (a target line needs a run to be a target), and
154
+ recorded because the bars still render, so the slide LOOKS finished while a whole series
155
+ is missing. A composed chart wants at least two columns; one column is a Kpi. -->
156
+ <VisLine :x="x" :y="lineY" :color="lineColors" :line-width="6" />
157
+ <VisAxis
158
+ type="x"
159
+ :grid-line="false"
160
+ :tick-line="false"
161
+ :tick-values="xTicks"
162
+ :tick-format="xFormat"
163
+ :tick-padding="16"
164
+ :label="xLabel"
165
+ :label-margin="24"
166
+ tick-text-hide-overlapping
167
+ />
168
+ <!-- One y axis, titled once, because there is one unit. See the header for the case where
169
+ that is not true and what to do about it. -->
170
+ <VisAxis
171
+ type="y"
172
+ :tick-format="format"
173
+ :num-ticks="4"
174
+ :tick-line="false"
175
+ :domain-line="false"
176
+ :tick-padding="16"
177
+ :label="yLabel"
178
+ :label-margin="24"
179
+ />
180
+ <!-- ★ BOTH, because this chart is both: the bar family stacks and the line family
181
+ rides over it, so the bars' circles come from the accumulator and the lines' from their
182
+ own values. Passing one accessor for all of them puts a circle where neither mark is. -->
183
+ <VisCrosshair :duration="crosshairMs" :hideWhenFarFromPointer="CROSSHAIR_HIDE_WHEN_FAR" :circleRadius="CROSSHAIR_CIRCLE_R" :x="x" :y="lineY" :yStacked="barY" :template="tip" :color="allColors" />
184
+ <VisTooltip v-bind="deckTooltip()" />
185
+ </VisXYContainer>
186
+ </ChartFrame>
187
+ </template>
@@ -0,0 +1,86 @@
1
+ <!--
2
+ The deck's contents page, built from the dividers rather than from a per-slide declaration.
3
+
4
+ ★★ THIS EXISTS BECAUSE SLIDEV'S OWN `<Toc>` CANNOT BE MADE TO NEST WITHOUT TOUCHING EVERY SLIDE.
5
+
6
+ `useTocTree` builds its tree from every slide that has a TITLE — and a title is just the first
7
+ `#`-heading in the body, so all 294 slides in the specimen book qualify, not the 34 that are
8
+ structure. Depth comes from a `level` frontmatter key, defaulting to the heading's own depth, so
9
+ an ordinary content slide is `level: 1` and sits at the TOP of the tree. The next subsection then
10
+ nests under THAT SLIDE rather than under its section, and `filterTree` — which drops a hidden item
11
+ together with its whole subtree — deletes the subsection along with the content slide hiding it.
12
+
13
+ Two things follow, both measured on the running deck rather than argued:
14
+
15
+ · Nesting needs a deeper `level` on all 260 content slides. There is no way round it: the
16
+ parser reads `slide.level` from a slide's OWN frontmatter BEFORE the headmatter's `defaults:`
17
+ merge, so it cannot be defaulted. Tried it — the contents came back with FIFTEEN entries
18
+ instead of thirty-four, the nine sections plus only the first subsection of each, those being
19
+ the ones with no content slide between them and their divider.
20
+
21
+ · `Toc.vue` imports `TocList.vue` by RELATIVE PATH, and neither takes a slot. A theme cannot
22
+ override either, so an entry cannot be restyled at all — which this deck needs, since a
23
+ contents page here is typeset like everything else.
24
+
25
+ So the tree is derived from what the dividers ALREADY say. `divider()` and `subsection()` write a
26
+ `rank`, that rank is the depth, and no other slide in the deck carries a single key for the sake
27
+ of this page.
28
+
29
+ ★ `hideInToc` IS STILL READ, and three slides set it. A divider is not automatically a contents
30
+ entry: the Frames section specimens the divider layout itself, and those slides carry a real
31
+ `rank` and a real h1 while being PICTURES of openers rather than openers. Without the opt-out the
32
+ contents lists sections called "Section" and "Subsection". The key is Slidev's own and means
33
+ exactly what it says here, which is why it is reused rather than replaced with a private one.
34
+ -->
35
+ <script setup>
36
+ import { computed } from 'vue';
37
+ import { useNav } from '@slidev/client';
38
+
39
+ const { slides } = useNav();
40
+
41
+ /*
42
+ ★ A DIVIDER WITH NO TITLE IS NOT AN ENTRY EITHER, and that is load-bearing rather than defensive:
43
+ the layout gallery at the head of Frames draws one slide per layout including `divider`, and that
44
+ slide has no h1 because it is a diagram of the layout's regions. It needs no opt-out — it has
45
+ nothing to list — so the title check does the work and the frontmatter stays clean.
46
+
47
+ `topic` is excluded by rank: it is the third divider rank, used inside a run rather than to open
48
+ one, and a contents page that listed topics would be a list of slides again.
49
+ */
50
+ const entries = computed(() => {
51
+ const out = [];
52
+ for (const route of slides.value) {
53
+ const fm = route.meta?.slide?.frontmatter ?? {};
54
+ const title = route.meta?.slide?.title;
55
+ if (fm.layout !== 'divider' || !title || fm.hideInToc) continue;
56
+ // `rank` defaults to section in divider.vue, so an absent rank is a section here too.
57
+ const rank = fm.rank ?? 'section';
58
+ if (rank === 'topic') continue;
59
+ if (rank === 'subsection' && out.length) out[out.length - 1].children.push({ no: route.no, title });
60
+ else out.push({ no: route.no, title, children: [] });
61
+ }
62
+ return out;
63
+ });
64
+ </script>
65
+
66
+ <template>
67
+ <div class="deck-contents">
68
+ <!--
69
+ ★ `<Link>` RATHER THAN A MARKDOWN LINK OR A BARE <a>, and the difference is whether it works.
70
+ Measured on this page in an earlier cut: `[**Frames**](/21)` changed location.pathname to /21
71
+ and left the deck on slide 3 — a plain href is not a RouterLink, vue-router does not intercept
72
+ it, and no full load happens either. Slidev ships the component for exactly this, and it falls
73
+ back to an `#N` anchor in print mode.
74
+ -->
75
+ <ol class="deck-contents-sections">
76
+ <li v-for="section of entries" :key="section.no" class="deck-contents-section">
77
+ <Link :to="section.no" class="deck-contents-link is-section">{{ section.title }}</Link>
78
+ <ol v-if="section.children.length" class="deck-contents-subsections">
79
+ <li v-for="child of section.children" :key="child.no" class="deck-contents-subsection">
80
+ <Link :to="child.no" class="deck-contents-link">{{ child.title }}</Link>
81
+ </li>
82
+ </ol>
83
+ </li>
84
+ </ol>
85
+ </div>
86
+ </template>