@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,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>
|