@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,263 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/*
|
|
3
|
+
A band of words moving slowly across the slide — city names, values, partner names, the fleet's
|
|
4
|
+
vehicle types. It is the one piece of MOTION this deck owns as content rather than as a
|
|
5
|
+
transition, and it earns its place for a reason that only applies in a room: a static list of
|
|
6
|
+
twenty place names is a wall of small type, while the same twenty moving through a single band
|
|
7
|
+
are read one at a time, at full size, by an audience that is looking at the presenter anyway.
|
|
8
|
+
|
|
9
|
+
★★ THE TRACK IS DUPLICATED, AND THAT IS THE WHOLE MECHANISM. One copy of the items translated
|
|
10
|
+
from 0% to -100% snaps back to a gap; two identical copies translated by exactly -50% of the pair
|
|
11
|
+
put copy B where copy A started, so the loop has no seam. `aria-hidden` on the second copy stops a
|
|
12
|
+
screen reader announcing every item twice.
|
|
13
|
+
|
|
14
|
+
★ IT MUST BE LEGIBLE STANDING STILL, which is not a nicety — it is the state this thing is in
|
|
15
|
+
whenever it matters most:
|
|
16
|
+
|
|
17
|
+
· REDUCED MOTION. The engine's floor (@humanforest/tokens/motion/motion.css) sets
|
|
18
|
+
`animation-duration: 1ms` and `animation-iteration-count: 1` on everything, so the band lands
|
|
19
|
+
on its first frame and stops. That frame is the track at translate(0), i.e. the items in
|
|
20
|
+
order from the left — which is why the animation runs 0 -> -50% and never the reverse. A
|
|
21
|
+
marquee written to start mid-cycle would honour reduced motion by freezing on a half-word.
|
|
22
|
+
· EXPORT. `slidev export` drives a real browser and screenshots it, so a PDF would otherwise
|
|
23
|
+
catch the band wherever it happened to be. Slidev wraps the printed deck in `#print-container`
|
|
24
|
+
— the animation is switched off inside it, so an exported slide shows the same first frame.
|
|
25
|
+
|
|
26
|
+
No `background` or `variant`. A marquee is a band of text on the slide's own ground; a coloured strip
|
|
27
|
+
behind it is a Card's job, and a deck that wants one wraps this in one.
|
|
28
|
+
*/
|
|
29
|
+
import { computed, onMounted, ref } from 'vue';
|
|
30
|
+
import { iconUrlOrFallback } from './iconUrl';
|
|
31
|
+
import { assetUrl } from '../assetUrl.js';
|
|
32
|
+
|
|
33
|
+
const props = defineProps({
|
|
34
|
+
/**
|
|
35
|
+
* The items, in order. Short — each one is read on its own as it passes. Either a plain string,
|
|
36
|
+
* or `{ label?, icon?, image? }`:
|
|
37
|
+
* · `icon` — a namespaced slug (`forest:bike`, `lucide:battery`), drawn as a CSS mask in the
|
|
38
|
+
* deck's own ink, so it inherits the band's colour like the words beside it do.
|
|
39
|
+
* · `image` — a URL, for artwork this repo does not own the vector of (a partner mark, a photo).
|
|
40
|
+
* A mark with no `label` is aria-hidden in BOTH copies rather than only the second: an icon with
|
|
41
|
+
* no words is decoration, and a screen reader announcing "image" eleven times is worse than
|
|
42
|
+
* silence.
|
|
43
|
+
*/
|
|
44
|
+
items: {
|
|
45
|
+
type: Array,
|
|
46
|
+
required: true,
|
|
47
|
+
validator: (v) =>
|
|
48
|
+
Array.isArray(v) &&
|
|
49
|
+
v.length > 0 &&
|
|
50
|
+
v.every((i) =>
|
|
51
|
+
typeof i === 'string'
|
|
52
|
+
? i.trim().length > 0
|
|
53
|
+
: !!i && typeof i === 'object' && [i.label, i.icon, i.image].some((f) => typeof f === 'string' && f.trim()),
|
|
54
|
+
),
|
|
55
|
+
},
|
|
56
|
+
/** Seconds for one full cycle. Longer is slower; the default is tuned so a word stays readable
|
|
57
|
+
* for about as long as it takes to say it. */
|
|
58
|
+
duration: { type: Number, default: 40 },
|
|
59
|
+
/** Run right-to-left instead. Two marquees at opposite settings is the classic pair — it reads as
|
|
60
|
+
* a texture rather than as a list going somewhere. */
|
|
61
|
+
reverse: { type: Boolean, default: false },
|
|
62
|
+
/**
|
|
63
|
+
* Height of an `image` item, in px. 0 (the default) sizes it against the band's own type instead,
|
|
64
|
+
* which is right when a picture travels BESIDE a word and wrong when it travels alone: a band of
|
|
65
|
+
* pictures with no labels has no type to be in proportion to, and inherits a height set for a
|
|
66
|
+
* mark sitting next to a title. Icons are deliberately not covered — a mark is punctuation for
|
|
67
|
+
* the word beside it and stays locked to it.
|
|
68
|
+
*/
|
|
69
|
+
imageHeight: { type: Number, default: 0 },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/*
|
|
73
|
+
★★ THE BAND DOES NOT PAINT UNTIL ITS OWN METRICS ARE FINAL, and that is a layout fact rather than
|
|
74
|
+
a polish one. Every item is `inline-flex` inside a `width: max-content` track, so each width is
|
|
75
|
+
its own content's and a late arrival re-widths the whole row — and because the row is a row, the
|
|
76
|
+
deltas ACCUMULATE: item two moves by item one's change, item three by the sum of both. Measured on
|
|
77
|
+
the specimen's own three bands, with each cause isolated:
|
|
78
|
+
|
|
79
|
+
· the webfont swap the eighth word of the city band lands 123px from where the fallback face
|
|
80
|
+
put it (packages/fonts sets `font-display: swap`, so the fallback really is
|
|
81
|
+
rendered first).
|
|
82
|
+
· a remote picture 2114px by the tenth item of the image band. An <img> whose height is CSS
|
|
83
|
+
and whose src is a URL is a REAL height and a ZERO width until it lands, so
|
|
84
|
+
the pictures shove each other along the track as they arrive one by one.
|
|
85
|
+
|
|
86
|
+
Hidden, never unmounted, and that is the whole reason this is `visibility` rather than `v-if` or
|
|
87
|
+
`display: none`: the band's HEIGHT is CSS in every case (a line box, or --marquee-image-h), never
|
|
88
|
+
content, so the space is already the right size and nothing below the band moves when it appears.
|
|
89
|
+
The track keeps animating while hidden, so it reveals mid-scroll rather than from a standing
|
|
90
|
+
start — the first frame contract above is about a STOPPED marquee, which is a different state.
|
|
91
|
+
*/
|
|
92
|
+
const band = ref(null);
|
|
93
|
+
const ready = ref(false);
|
|
94
|
+
onMounted(async () => {
|
|
95
|
+
const root = band.value;
|
|
96
|
+
/* The band's OWN face, asked for by name rather than waiting on document.fonts.ready alone:
|
|
97
|
+
`ready` resolves whenever font loading happens to be idle, which at mount can be before this
|
|
98
|
+
slide's faces have been requested at all. */
|
|
99
|
+
const item = root?.querySelector('.deck-marquee-item');
|
|
100
|
+
const settled = Promise.all([
|
|
101
|
+
item && document.fonts ? document.fonts.load(`1em ${getComputedStyle(item).fontFamily}`).catch(() => {}) : null,
|
|
102
|
+
...[...(root?.querySelectorAll('img') ?? [])].map((img) => img.decode().catch(() => {})),
|
|
103
|
+
]);
|
|
104
|
+
/* ★ THE HOLD HAS A CEILING, and it is the same 3s font-display: block would have used. Catching
|
|
105
|
+
every rejection covers a 404 picture and a face that fails to resolve; it does NOT cover a
|
|
106
|
+
request that simply never answers, which is the projector-on-hotel-wifi case — and a band that
|
|
107
|
+
stays blank for the length of the talk is a worse failure than the jump it was hiding. */
|
|
108
|
+
await Promise.race([settled, new Promise((resolve) => setTimeout(resolve, 3000))]);
|
|
109
|
+
ready.value = true;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/* One shape for the template to iterate, so the string form stays the shorthand it always was and
|
|
113
|
+
the template never branches on the item's TYPE — only on which parts it carries. */
|
|
114
|
+
const normalised = computed(() =>
|
|
115
|
+
props.items.map((item) => {
|
|
116
|
+
const it = typeof item === 'string' ? { label: item } : item;
|
|
117
|
+
return {
|
|
118
|
+
label: typeof it.label === 'string' ? it.label : '',
|
|
119
|
+
image: typeof it.image === 'string' ? it.image : '',
|
|
120
|
+
// Resolved here, not in the template: iconUrlOrFallback falls back to the Forest mark, so a
|
|
121
|
+
// slug that never resolves is visibly the wrong glyph rather than an invisible gap in the
|
|
122
|
+
// band — which is what an unresolved mask leaves, with no console error and geometry the
|
|
123
|
+
// audit still measures as fine.
|
|
124
|
+
mask: it.icon ? `url("${iconUrlOrFallback(it.icon)}")` : '',
|
|
125
|
+
};
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
</script>
|
|
129
|
+
|
|
130
|
+
<template>
|
|
131
|
+
<div
|
|
132
|
+
ref="band"
|
|
133
|
+
class="deck-marquee"
|
|
134
|
+
:class="{ 'is-reverse': reverse, 'is-ready': ready }"
|
|
135
|
+
:style="{ '--marquee-duration': `${duration}s`, ...(imageHeight ? { '--marquee-image-h': `${imageHeight}px` } : {}) }"
|
|
136
|
+
>
|
|
137
|
+
<div class="deck-marquee-track">
|
|
138
|
+
<!-- Copy A: the real one, and the one a stopped marquee shows. -->
|
|
139
|
+
<span
|
|
140
|
+
v-for="(item, i) in normalised"
|
|
141
|
+
:key="`a-${i}`"
|
|
142
|
+
class="deck-marquee-item"
|
|
143
|
+
:aria-hidden="item.label ? undefined : 'true'"
|
|
144
|
+
>
|
|
145
|
+
<img v-if="item.image" class="deck-marquee-image" :src="assetUrl(item.image)" :alt="item.label" decoding="async" >
|
|
146
|
+
<span v-else-if="item.mask" class="deck-marquee-glyph" :style="{ maskImage: item.mask, webkitMaskImage: item.mask }" />
|
|
147
|
+
<span v-if="item.label" class="deck-marquee-label">{{ item.label }}</span>
|
|
148
|
+
</span>
|
|
149
|
+
<!-- Copy B: the seam filler. Hidden from assistive tech — same items, no second reading. -->
|
|
150
|
+
<span v-for="(item, i) in normalised" :key="`b-${i}`" class="deck-marquee-item" aria-hidden="true">
|
|
151
|
+
<img v-if="item.image" class="deck-marquee-image" :src="assetUrl(item.image)" alt="" decoding="async" >
|
|
152
|
+
<span v-else-if="item.mask" class="deck-marquee-glyph" :style="{ maskImage: item.mask, webkitMaskImage: item.mask }" />
|
|
153
|
+
<span v-if="item.label" class="deck-marquee-label">{{ item.label }}</span>
|
|
154
|
+
</span>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</template>
|
|
158
|
+
|
|
159
|
+
<style scoped>
|
|
160
|
+
.deck-marquee {
|
|
161
|
+
overflow: hidden;
|
|
162
|
+
/* The band is a full-bleed device — it wants the slide's whole width, not the text measure. The
|
|
163
|
+
layout's own padding is escaped by the deck's inset so the words really do run off both edges,
|
|
164
|
+
which is what makes it read as a band rather than as a wide paragraph. */
|
|
165
|
+
width: 100%;
|
|
166
|
+
-webkit-mask-image: linear-gradient(to right, transparent, #000 6%, #000 94%, transparent);
|
|
167
|
+
mask-image: linear-gradient(to right, transparent, #000 6%, #000 94%, transparent);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.deck-marquee-track {
|
|
171
|
+
display: flex;
|
|
172
|
+
width: max-content;
|
|
173
|
+
align-items: center;
|
|
174
|
+
animation: deck-marquee-scroll var(--marquee-duration) linear infinite;
|
|
175
|
+
}
|
|
176
|
+
/* See the `ready` block in the script — the track holds its space and its animation, and paints
|
|
177
|
+
only once the face and the pictures that set its widths have both landed. */
|
|
178
|
+
.deck-marquee:not(.is-ready) .deck-marquee-track {
|
|
179
|
+
visibility: hidden;
|
|
180
|
+
}
|
|
181
|
+
.is-reverse .deck-marquee-track {
|
|
182
|
+
animation-direction: reverse;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* ★ -50%, NOT -100%. The track holds TWO copies of the list, so half its width is one full cycle,
|
|
186
|
+
and at -50% copy B sits exactly where copy A began. Animating `translate` rather than `transform`
|
|
187
|
+
deliberately: in this system `transform` is a separate property and naming the wrong one animates
|
|
188
|
+
nothing here while leaving the element still transformed elsewhere. */
|
|
189
|
+
@keyframes deck-marquee-scroll {
|
|
190
|
+
from { translate: 0 0; }
|
|
191
|
+
to { translate: -50% 0; }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/* ★★ THE EXPORT FREEZE IS NOT HERE, AND IT CANNOT BE. It lives in styles/base.css, unscoped,
|
|
195
|
+
because Vue's scoped-CSS compiler DESTROYS the rule this component needs:
|
|
196
|
+
|
|
197
|
+
written :global(#print-container) .deck-marquee-track { animation: none }
|
|
198
|
+
compiled #print-container { animation: none }
|
|
199
|
+
|
|
200
|
+
The descendant is dropped and the declaration lands on the print container itself, which is a
|
|
201
|
+
no-op — so the band kept animating and the exported PDF caught it mid-word ("…thwark" where
|
|
202
|
+
"Southwark" should be). Verified by reading the compiled stylesheet out of dist/, after the PDF
|
|
203
|
+
showed it. A rule whose subject is an ANCESTOR outside this component belongs with the other
|
|
204
|
+
Slidev-integration rules, not in a scoped block that will silently rewrite it. */
|
|
205
|
+
|
|
206
|
+
.deck-marquee-item {
|
|
207
|
+
/* inline-flex, so a mark and its word sit on one baseline-centred row. The dot below is a flex
|
|
208
|
+
child now rather than an inline box — which is why it is centred by `align-items` here and not
|
|
209
|
+
by its own `vertical-align`. */
|
|
210
|
+
display: inline-flex;
|
|
211
|
+
align-items: center;
|
|
212
|
+
gap: var(--deck-space-3);
|
|
213
|
+
font-family: var(--type-deck-title-family);
|
|
214
|
+
font-size: var(--type-deck-title-size);
|
|
215
|
+
font-weight: var(--type-deck-title-weight);
|
|
216
|
+
line-height: 1.1;
|
|
217
|
+
color: var(--deck-fg);
|
|
218
|
+
white-space: nowrap;
|
|
219
|
+
padding-inline: var(--deck-space-5);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* A mark is sized against the TYPE it travels with, in `em`, not against a spacing rung: the band's
|
|
223
|
+
whole premise is that an item is read at title size as it passes, and a glyph pinned to a fixed
|
|
224
|
+
px would drift out of proportion the moment the type ramp moves. */
|
|
225
|
+
.deck-marquee-glyph {
|
|
226
|
+
display: block;
|
|
227
|
+
flex: none;
|
|
228
|
+
width: 1.1em;
|
|
229
|
+
height: 1.1em;
|
|
230
|
+
/* currentColor, so a mark inherits the band's ink exactly as the words beside it do — the mask
|
|
231
|
+
technique's one real advantage over an <img> for artwork this repo owns the vector of. */
|
|
232
|
+
background-color: currentColor;
|
|
233
|
+
mask-repeat: no-repeat;
|
|
234
|
+
mask-position: center;
|
|
235
|
+
mask-size: contain;
|
|
236
|
+
-webkit-mask-repeat: no-repeat;
|
|
237
|
+
-webkit-mask-position: center;
|
|
238
|
+
-webkit-mask-size: contain;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/* An image cannot take the band's ink, so it keeps its own colour — correct for a partner mark,
|
|
242
|
+
which is exactly the case that cannot be a mask. Height-constrained and width-auto so logos of
|
|
243
|
+
different aspect ratios all sit on the same optical line. */
|
|
244
|
+
.deck-marquee-image {
|
|
245
|
+
display: block;
|
|
246
|
+
flex: none;
|
|
247
|
+
height: var(--marquee-image-h, 1.4em);
|
|
248
|
+
width: auto;
|
|
249
|
+
object-fit: contain;
|
|
250
|
+
}
|
|
251
|
+
/* The separator is a dot in the accent, drawn between items rather than after them — an item is
|
|
252
|
+
the thing being read, the dot is only there so two words do not run together. */
|
|
253
|
+
.deck-marquee-item::after {
|
|
254
|
+
content: '';
|
|
255
|
+
display: inline-block;
|
|
256
|
+
width: 0.28em;
|
|
257
|
+
height: 0.28em;
|
|
258
|
+
border-radius: var(--deck-radius-full);
|
|
259
|
+
background: var(--deck-accent);
|
|
260
|
+
vertical-align: 0.28em;
|
|
261
|
+
margin-inline-start: var(--deck-space-5);
|
|
262
|
+
}
|
|
263
|
+
</style>
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/*
|
|
3
|
+
A nested donut sized for a room — one whole, split, and each part split again.
|
|
4
|
+
|
|
5
|
+
Reach for this over DonutChart.vue only when the second level is the POINT. A donut is already
|
|
6
|
+
read by area rather than angle, and a second ring asks the audience to compare areas inside areas;
|
|
7
|
+
if the inner split is incidental, it costs the outer comparison and buys nothing. Two rings is the
|
|
8
|
+
honest ceiling, and the geometry below says why: a third leaves no hole for the total.
|
|
9
|
+
|
|
10
|
+
THE OUTER RING IS THE CHART. It carries the five-colour palette and the legend, and inherits the
|
|
11
|
+
ceiling whole — chartData.ts's `shareState` is handed one row per top-level group, so six groups
|
|
12
|
+
draws the over-capacity message rather than a sixth colour that does not exist. The inner ring
|
|
13
|
+
carries no colours of its own: each segment is a TINT of its parent, so the family reads as a
|
|
14
|
+
family and the inner ring can hold as many slices as the arcs can fit.
|
|
15
|
+
|
|
16
|
+
★ THE LIBRARY'S OWN TINT RAMP IS UNUSABLE AT ROOM SCALE, which is why `segmentColor` below is
|
|
17
|
+
passed rather than left to default. With no accessor, @unovis/ts inherits the parent's fill and
|
|
18
|
+
fades it with `scaleLinear().domain([-1, n]).range([1, 0])` (components/nested-donut/index.js),
|
|
19
|
+
so child i of n lands at alpha 1 − (i+1)/(n+1): five children give 0.83, 0.67, 0.5, 0.33, 0.17.
|
|
20
|
+
The last is the slide ground with a rumour of colour on it — a dashboard's idea of a subtle
|
|
21
|
+
distinction, and the same defect class as a 2px line on a projector. The mix here runs 85% of the
|
|
22
|
+
base down to 55% instead, and mixes toward `--deck-bg` rather than toward transparency, so the
|
|
23
|
+
ramp runs the same direction on a light slide and a dark one and stays a `var()` reference that
|
|
24
|
+
re-resolves on a theme flip. Both ends of that range are load-bearing — see `TINT_CEILING`.
|
|
25
|
+
|
|
26
|
+
ROW ORDER IS THE RAMP ORDER. Unovis leaves `sort` undefined and d3's pie keeps input order, so
|
|
27
|
+
the first sub-row of a group is the strongest tint. A lightness ramp reads as an ordering whether
|
|
28
|
+
or not one was meant, so order the rows by the quantity, or by something the audience already
|
|
29
|
+
knows the order of.
|
|
30
|
+
|
|
31
|
+
Real @unovis/vue mark, ChartFrame.vue for ceremony, chartData.ts for shaping.
|
|
32
|
+
*/
|
|
33
|
+
import { computed } from 'vue';
|
|
34
|
+
import { VisSingleContainer, VisNestedDonut, VisTooltip, VisNestedDonutSelectors } from '@unovis/vue';
|
|
35
|
+
import { deckTooltip, tipFrame } from './chartTip';
|
|
36
|
+
import ChartFrame from './ChartFrame.vue';
|
|
37
|
+
import { formatValue, isPlottable, scatterGroups, shareState, stateMessage } from './chartData';
|
|
38
|
+
|
|
39
|
+
const props = defineProps({
|
|
40
|
+
/** Rows, one per leaf: `[{ <outer field>, <inner field>, value }]`. Flat — the rings are derived, not nested. A row that does not name EVERY ring cannot be drawn on all of them, so it is dropped from the chart, the legend and the centre total alike. */
|
|
41
|
+
data: { type: Array, required: true },
|
|
42
|
+
/** Field names, OUTERMOST FIRST — Unovis' default `inwards` direction puts the first layer on the outer radius. Omit and every string field of the first row becomes a ring, in declaration order. */
|
|
43
|
+
layers: { type: Array, default: null },
|
|
44
|
+
/** The figure in the hole. Omit and the groups' own total is used; pass an empty string for no centre at all. */
|
|
45
|
+
centralLabel: { type: String, default: null },
|
|
46
|
+
/** The word under that figure — what the total counts. One line: it does not wrap, and the hole is 208px wide at the default geometry, so a phrase runs out over the arcs. */
|
|
47
|
+
centralSubLabel: { type: String, default: null },
|
|
48
|
+
/** Plot height in px. 0 FILLS, and is the default: the chart takes whatever box it is given,
|
|
49
|
+
* which is what a chart under a heading wants. Unovis resolves `config.height || containerHeight`,
|
|
50
|
+
* so a falsy height makes the container measure itself. Pass a number where the box has no
|
|
51
|
+
* definite height of its own — a tiled grid cell, a figure beside copy. */
|
|
52
|
+
height: { type: Number, default: 0 },
|
|
53
|
+
/** Thickness of ONE ring in px — DonutChart's `arcWidth` is the same measurement for its single ring. The hole is what is left: `min(width, height) / 2 − rings × ringWidth − (rings − 1) × 8`. At the default height with two rings that is a 208px hole, which holds a 60px figure over a 36px sub-label; three rings at the same height leaves 32px and needs a taller chart or a thinner ring. The library's own default (`radius × 0.75 / rings`, a 120px hole at two rings) does not. */
|
|
54
|
+
ringWidth: { type: Number, default: 64 },
|
|
55
|
+
/** Name the arcs in place. Off by default: Unovis measures a `perpendicular` label against the RING WIDTH and hides what overflows, so a 36px label inside a 64px ring is hidden on nearly every arc. `along` is the alignment that fits, and it rotates the text — worth switching on only for a handful of short names. */
|
|
56
|
+
segmentLabels: { type: Boolean, default: false },
|
|
57
|
+
/** Draw the group legend. It names the outer ring; nothing names the inner one but `segmentLabels`. */
|
|
58
|
+
legend: { type: Boolean, default: true },
|
|
59
|
+
/** Where the legend sits: `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center`
|
|
60
|
+
* or `bottom-right`. The EDGE is reading order — above is read before the marks, below is a
|
|
61
|
+
* caption read after them; the ALIGNMENT is for the column the chart sits in, centred under a
|
|
62
|
+
* full-width plot and pulled to an edge where a centred key would float in a gutter. */
|
|
63
|
+
legendPosition: {
|
|
64
|
+
type: String,
|
|
65
|
+
default: 'bottom-center',
|
|
66
|
+
validator: (p) => ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'].includes(p),
|
|
67
|
+
},
|
|
68
|
+
/** Formats the default centre figure. Ignored when `centralLabel` is set. */
|
|
69
|
+
format: { type: Function, default: formatValue },
|
|
70
|
+
/** The sentence drawn when there is nothing to plot — including a set of groups that all sum to zero. */
|
|
71
|
+
empty: { type: String, default: 'No data' },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const layerKeys = computed(() => {
|
|
75
|
+
if (props.layers?.length) return props.layers;
|
|
76
|
+
const first = props.data?.[0] ?? {};
|
|
77
|
+
return Object.keys(first).filter((k) => typeof first[k] === 'string');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/*
|
|
81
|
+
★★ A ROW MUST NAME EVERY RING, AND THIS IS THE ONLY LIST ANYTHING READS — the legend, the centre
|
|
82
|
+
figure and the data the mark is handed all come from here, which is the whole reason it exists.
|
|
83
|
+
|
|
84
|
+
Unovis keeps a segment only when it has a key AND its parent has a value
|
|
85
|
+
(`descendants().filter(d => d.parent?.value && d.data.key)`, components/nested-donut/index.js).
|
|
86
|
+
That drops an empty-keyed node but NOT its children. Passing the raw rows while filtering only the
|
|
87
|
+
totals therefore paints the one picture this chart must never paint: a row with no outer field is
|
|
88
|
+
absent from the legend and from the centre, its own outer arc is dropped for an empty key — and
|
|
89
|
+
its inner-ring child survives, inheriting the fill that dropped parent was handed. That fill comes
|
|
90
|
+
out of Unovis' own --vis-color ramp by index, because `getColor` falls back whenever
|
|
91
|
+
`segmentColor` cannot resolve a root it has never seen: replayed over six rows with one such
|
|
92
|
+
orphan, the leaf drew at `var(--vis-color2)` and 0.5 opacity — half-strength series 3, sitting
|
|
93
|
+
under a hole in the outer ring, for a group in neither the legend nor the total. The centre read
|
|
94
|
+
29 while the geometry divided 32. Filtering ONCE, here, is what keeps the three agreeing about
|
|
95
|
+
what the whole is.
|
|
96
|
+
*/
|
|
97
|
+
const rows = computed(() => {
|
|
98
|
+
const keys = layerKeys.value;
|
|
99
|
+
if (!keys.length) return [];
|
|
100
|
+
return (props.data ?? []).filter((row) => keys.every((k) => String(row?.[k] ?? '') !== ''));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/*
|
|
104
|
+
One row per top-level group, which is the shape `shareState` already decides on — so the ceiling,
|
|
105
|
+
the empty cases and the wording all come from chartData.ts rather than being restated here.
|
|
106
|
+
`scatterGroups` is the distinct-values-in-first-seen-order helper; it is named for the scatter
|
|
107
|
+
because that is what first needed it, but nothing in it is about points.
|
|
108
|
+
*/
|
|
109
|
+
const groupTotals = computed(() => {
|
|
110
|
+
const [outer] = layerKeys.value;
|
|
111
|
+
if (!outer) return [];
|
|
112
|
+
return scatterGroups(rows.value, outer).map((name) => ({
|
|
113
|
+
label: name,
|
|
114
|
+
value: rows.value.reduce(
|
|
115
|
+
(sum, row) => sum + (String(row[outer]) === name && isPlottable(row.value) ? row.value : 0),
|
|
116
|
+
0,
|
|
117
|
+
),
|
|
118
|
+
}));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const state = computed(() => shareState(groupTotals.value));
|
|
122
|
+
const message = computed(() => (state.value.kind === 'ready' ? null : stateMessage(state.value, props.empty)));
|
|
123
|
+
|
|
124
|
+
/* The legend key is the group NAME rather than `shareState`'s own. That default is `String(value)`,
|
|
125
|
+
and the values handed to it here are group SUMS — two groups that happen to sum alike would carry
|
|
126
|
+
the same key through ChartFrame's `v-for`. Names cannot collide: `scatterGroups` returns each one
|
|
127
|
+
once. */
|
|
128
|
+
const seriesList = computed(() =>
|
|
129
|
+
state.value.kind === 'ready' ? state.value.series.map((s) => ({ ...s, key: s.name })) : [],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const total = computed(() => groupTotals.value.reduce((sum, g) => sum + g.value, 0));
|
|
133
|
+
const centre = computed(() => props.centralLabel ?? props.format(total.value));
|
|
134
|
+
|
|
135
|
+
/** Group name → its palette colour, so an arc and its legend key are tied by NAME. Position would
|
|
136
|
+
* also work today — d3's pie leaves unsorted children in data order — but a name cannot drift. */
|
|
137
|
+
const tone = computed(() => new Map(seriesList.value.map((s) => [s.name, s.color])));
|
|
138
|
+
|
|
139
|
+
const layerAccessors = computed(() => layerKeys.value.map((k) => (d) => String(d[k] ?? '')));
|
|
140
|
+
|
|
141
|
+
/* ★ AN ACCESSOR, NOT THE SETTINGS OBJECT ITSELF. The @unovis/vue wrapper declares
|
|
142
|
+
`layerSettings: { type: [Function, null] }` (components/nested-donut/index.js), and Vue's
|
|
143
|
+
`assertType` reads `Function` as a simple type: an object fails `value instanceof Function`, and
|
|
144
|
+
the `null` arm wants `value === null`. A plain object matches neither, so every mount of every
|
|
145
|
+
nested-donut slide logs `Invalid prop: type check failed for prop "layerSettings"`. The value
|
|
146
|
+
does still reach the config — validation only warns — but a deck that cries wolf on its own
|
|
147
|
+
components is a deck whose console nobody reads. `getValue(layerId, layerSettings)` calls a
|
|
148
|
+
function accessor and returns anything else unchanged, so the two forms are identical downstream
|
|
149
|
+
and this costs one closure. */
|
|
150
|
+
const layerSettings = computed(() => () => ({ width: props.ringWidth, labelAlignment: 'along' }));
|
|
151
|
+
|
|
152
|
+
const value = (d) => (isPlottable(d.value) ? d.value : 0);
|
|
153
|
+
|
|
154
|
+
/** The ends of the tint ramp, as a percentage of the parent's own palette colour.
|
|
155
|
+
*
|
|
156
|
+
* The top is not 100. `color-mix(in oklab, <base> 100%, …)` IS the base, so a ramp starting there
|
|
157
|
+
* hands the first — and largest — child of every group its parent's exact fill: two rings of one
|
|
158
|
+
* hue meeting edge to edge, which is the reading the 8px of layer padding is there to break, and
|
|
159
|
+
* more than 8px on a 1920px canvas can break on its own.
|
|
160
|
+
*
|
|
161
|
+
* The bottom is not lower because below roughly half, a mid-tone palette colour mixed into the
|
|
162
|
+
* ground stops reading as that colour at all. */
|
|
163
|
+
const TINT_CEILING = 85;
|
|
164
|
+
const TINT_FLOOR = 55;
|
|
165
|
+
|
|
166
|
+
/* Every node carries `data.root` — the key of its top-level ancestor, filled in by Unovis' own
|
|
167
|
+
breadth-first pass — so the base colour is one lookup at any depth rather than a parent walk. */
|
|
168
|
+
function segmentColor(segment) {
|
|
169
|
+
const base = tone.value.get(segment.data.root);
|
|
170
|
+
if (!base) return null;
|
|
171
|
+
if (segment.depth === 1) return base;
|
|
172
|
+
const siblings = segment.parent?.children ?? [];
|
|
173
|
+
const step = siblings.length > 1 ? siblings.indexOf(segment) / (siblings.length - 1) : 0;
|
|
174
|
+
const mix = Math.round(TINT_CEILING - step * (TINT_CEILING - TINT_FLOOR));
|
|
175
|
+
return `color-mix(in oklab, ${base} ${mix}%, var(--deck-bg))`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* Passed rather than left to the library's brightness test, which cannot run here: it parses the
|
|
179
|
+
fill with d3-color, and d3-color returns null for a `color-mix()` string, so every tinted arc
|
|
180
|
+
would score brightness 0 and take the light-on-dark ink whatever it is actually sitting on.
|
|
181
|
+
The rule is simpler than the test anyway — depth 1 is the palette colour at full strength, so the
|
|
182
|
+
ground is the right ink for it; every depth-2 arc is that colour moved TOWARDS the ground, 85% of
|
|
183
|
+
it at the very most, so the ground is the wrong ink for those. The two cases cannot meet in the
|
|
184
|
+
middle precisely because the ramp starts below 100%. */
|
|
185
|
+
const segmentLabelColor = (segment) => (segment.depth === 1 ? 'var(--deck-bg)' : 'var(--deck-fg)');
|
|
186
|
+
|
|
187
|
+
/*
|
|
188
|
+
A nested donut's segment carries its own ring, so the tooltip names the value and leaves the
|
|
189
|
+
hierarchy to the drawing — which ring a segment sits in is the one thing the picture already says
|
|
190
|
+
unambiguously.
|
|
191
|
+
*/
|
|
192
|
+
const tip = (d) => tipFrame(String(d?.data?.key ?? d?.key ?? d?.data?.label ?? ''), [
|
|
193
|
+
{ label: 'Value', value: props.format(d?.value ?? 0) },
|
|
194
|
+
]);
|
|
195
|
+
</script>
|
|
196
|
+
|
|
197
|
+
<template>
|
|
198
|
+
<ChartFrame
|
|
199
|
+
class="deck-nested-donut"
|
|
200
|
+
:series="seriesList"
|
|
201
|
+
:height="height"
|
|
202
|
+
:legend="legend"
|
|
203
|
+
:legend-position="legendPosition"
|
|
204
|
+
:message="message"
|
|
205
|
+
>
|
|
206
|
+
<VisSingleContainer :data="rows" :height="height" :duration="0">
|
|
207
|
+
<!-- 8px between rings, on top of the 4px ground stroke each arc already carries: the stroke
|
|
208
|
+
separates neighbours WITHIN a ring, but two rings of the same hue meeting edge to edge
|
|
209
|
+
read as one thick arc, which is the one relationship this chart exists to show.
|
|
210
|
+
|
|
211
|
+
★ `central-sub-label-wrap` OFF, because the library wraps that text against the wrong
|
|
212
|
+
radius here. `_render` calls `wrapSVGText(this.centralSubLabel, layers[0]._innerRadius *
|
|
213
|
+
1.9)`, and `_getLayerSettings` REVERSES the layer array for the `inwards` direction — the
|
|
214
|
+
default, and the one that puts the first layer on the outside — so `layers[0]` is the
|
|
215
|
+
outermost ring rather than the hole. At the documented geometry (height 480, ringWidth 64,
|
|
216
|
+
layerPadding 8) that is 176 × 1.9 = 334px of wrap width inside a 208px hole: a sub-label
|
|
217
|
+
long enough to wrap breaks out over the arcs instead of staying in the middle. VisDonut
|
|
218
|
+
has no such problem — its `layers[0]` inner radius IS the hole. Off, a sub-label that does
|
|
219
|
+
not fit overruns in one line the author can see and shorten. -->
|
|
220
|
+
<VisNestedDonut
|
|
221
|
+
:value="value"
|
|
222
|
+
:layers="layerAccessors"
|
|
223
|
+
:layer-settings="layerSettings"
|
|
224
|
+
:layer-padding="8"
|
|
225
|
+
:segment-color="segmentColor"
|
|
226
|
+
:segment-label-color="segmentLabelColor"
|
|
227
|
+
:show-segment-labels="segmentLabels"
|
|
228
|
+
:corner-radius="8"
|
|
229
|
+
:central-label="centre"
|
|
230
|
+
:central-sub-label="centralSubLabel ?? undefined"
|
|
231
|
+
:central-sub-label-wrap="false"
|
|
232
|
+
/>
|
|
233
|
+
<VisTooltip v-bind="deckTooltip()" :triggers="{ [VisNestedDonutSelectors.segment]: tip }" />
|
|
234
|
+
</VisSingleContainer>
|
|
235
|
+
</ChartFrame>
|
|
236
|
+
</template>
|
|
237
|
+
|
|
238
|
+
<style scoped>
|
|
239
|
+
/*
|
|
240
|
+
The nested donut has its OWN variable namespace, and it is not the donut's: `--vis-nested-donut-*`
|
|
241
|
+
against `--vis-donut-*`, with `sublabel` where the donut writes `sub-label`. None of ChartFrame's
|
|
242
|
+
donut block reaches this mark, so the type ramp and the arc separator are restated here — same
|
|
243
|
+
values, same reasoning, different names.
|
|
244
|
+
|
|
245
|
+
Declared on the frame's own element rather than `:root`, for both of the reasons ChartFrame
|
|
246
|
+
records: Unovis injects its `:root` defaults at runtime and would win on order, and a custom
|
|
247
|
+
property computes where it is declared, so a reference resolved here flips with a `deck-dark`
|
|
248
|
+
slide and one resolved at `:root` would not. The `--vis-dark-nested-donut-*` twins are left alone
|
|
249
|
+
for the same reason — the deck's tokens already carry the dark values.
|
|
250
|
+
|
|
251
|
+
Left at the library default: `--vis-nested-donut-background-color`, which only paints under
|
|
252
|
+
`showBackground`, and that stays off.
|
|
253
|
+
*/
|
|
254
|
+
.deck-nested-donut {
|
|
255
|
+
/* The arc separator is the SLIDE GROUND, so segments read as separated rather than outlined.
|
|
256
|
+
The library's default is its own background lilac at 1px — an off-brand hairline. */
|
|
257
|
+
--vis-nested-donut-segment-stroke-color: var(--deck-bg);
|
|
258
|
+
--vis-nested-donut-segment-stroke-width: 4px;
|
|
259
|
+
|
|
260
|
+
/* The centre. Title tier for the figure, small body for what it counts — the same pair
|
|
261
|
+
ChartFrame sets for the donut, and the pair its offset correction below is measured against. */
|
|
262
|
+
--vis-nested-donut-central-label-font-size: var(--type-deck-title-size);
|
|
263
|
+
--vis-nested-donut-central-label-font-weight: var(--type-deck-title-weight);
|
|
264
|
+
--vis-nested-donut-central-label-text-color: var(--deck-fg);
|
|
265
|
+
--vis-nested-donut-central-sublabel-font-size: var(--type-deck-body-sm-size);
|
|
266
|
+
--vis-nested-donut-central-sublabel-font-weight: var(--type-deck-body-sm-weight);
|
|
267
|
+
--vis-nested-donut-central-sublabel-text-color: var(--deck-fg-muted);
|
|
268
|
+
|
|
269
|
+
/* Caption tier for an arc label. This is a step DOWN from the library's `1em`, which inherits the
|
|
270
|
+
slide's body (3rem / 48px) and fits no arc at all — but it is not a step below the axis ticks:
|
|
271
|
+
caption, body-sm and label all resolve to 2.25rem / 36px on this deck (packages/tokens/type/
|
|
272
|
+
type.deck.css), and the only smaller role in the ramp is overline, which is an eyebrow rather
|
|
273
|
+
than a label. 36px is why `segmentLabels` is off by default: it fits the long arcs and nothing
|
|
274
|
+
else. */
|
|
275
|
+
--vis-nested-donut-segment-label-font-size: var(--type-deck-caption-size);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/*
|
|
279
|
+
★★ CHARTFRAME'S CENTRE CORRECTION ONLY HALF-REACHES THIS MARK, and half is worse than none.
|
|
280
|
+
|
|
281
|
+
Both donuts place the pair at dy ∓0.55em of their OWN unequal font sizes, so both hang above the
|
|
282
|
+
ring's true centre and both want the same downward nudge. ChartFrame applies it with
|
|
283
|
+
`[class*='-central-label']`, which works there because @unovis/ts's donut labels BOTH texts
|
|
284
|
+
`central-label` (components/donut/style.js). The nested donut labels its second text
|
|
285
|
+
`central-sub-label` — and `-central-sub-label` does not contain the substring `-central-label`.
|
|
286
|
+
Unpatched, the figure moves 16.8px and the sub-label stays, splitting a pair the correction exists
|
|
287
|
+
to keep together.
|
|
288
|
+
|
|
289
|
+
Same expression as ChartFrame's, deliberately: it is one measurement, not two, and writing it
|
|
290
|
+
again as a literal would let the halves drift apart the moment either type role is retuned.
|
|
291
|
+
*/
|
|
292
|
+
:deep([class*='-central-sub-label']) {
|
|
293
|
+
translate: 0 calc((var(--type-deck-title-size) - var(--type-deck-body-sm-size)) * 0.7);
|
|
294
|
+
}
|
|
295
|
+
</style>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/*
|
|
3
|
+
"An image goes here." Deliberately obvious rather than decorative — a placeholder that looks like
|
|
4
|
+
a real image invites someone to ship it.
|
|
5
|
+
|
|
6
|
+
Drawn from --deck-* roles: no network, no raster asset, no licensing question, and identical
|
|
7
|
+
dimensions on every run. That last property is what keeps scripts/deckAudit.ts meaningful — a real
|
|
8
|
+
photo's intrinsic size varies and would make overflow results non-repeatable.
|
|
9
|
+
|
|
10
|
+
★★ HATCHING, NOT AN ICON, AND THE REASON IS WHAT THE THING IS FOR. This drew a small picture-frame
|
|
11
|
+
glyph — a rect, a circle and a mountain path, hand-drawn here rather than taken from any icon set,
|
|
12
|
+
which made it the only hand-authored pictogram in the theme and a fourth place to maintain one.
|
|
13
|
+
Worse, it read as CONTENT: a tidy centred icon on a clean fill is what an empty state looks like,
|
|
14
|
+
and an empty state is a thing that is finished. Diagonal hatching cannot be mistaken for finished
|
|
15
|
+
work — it is the universal "nothing here yet" of a drawing, and it survives being glanced at from
|
|
16
|
+
the back of a room, which is the only viewing condition this deck has.
|
|
17
|
+
|
|
18
|
+
The stripes are a repeating gradient rather than an SVG <pattern> because a gradient scales with
|
|
19
|
+
the box for free: the same 12px pitch holds at every ratio and every size without a viewBox to
|
|
20
|
+
keep in sync.
|
|
21
|
+
*/
|
|
22
|
+
const RATIOS = { '16:9': 16 / 9, '4:3': 4 / 3, '1:1': 1, '3:4': 3 / 4 };
|
|
23
|
+
|
|
24
|
+
defineProps({
|
|
25
|
+
/** Aspect ratio of the frame. Matches how the layouts reserve space for real media. */
|
|
26
|
+
ratio: {
|
|
27
|
+
type: String,
|
|
28
|
+
default: '16:9',
|
|
29
|
+
// Vue hoists defineProps() out of setup() at build time, so this validator cannot close over
|
|
30
|
+
// the RATIOS const above (that only compiles in dev, not in a production build) — it repeats
|
|
31
|
+
// the same four keys inline instead.
|
|
32
|
+
validator: (r) => ['16:9', '4:3', '1:1', '3:4'].includes(r),
|
|
33
|
+
},
|
|
34
|
+
/** Short caption naming what belongs here, e.g. "Rider on a Forest bike". */
|
|
35
|
+
label: { type: String, default: 'Image' },
|
|
36
|
+
});
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<div class="deck-placeholder" :style="{ aspectRatio: String(RATIOS[ratio] ?? 16 / 9) }" role="img" :aria-label="`Placeholder: ${label}`">
|
|
41
|
+
<span class="deck-placeholder-label">{{ label }}</span>
|
|
42
|
+
</div>
|
|
43
|
+
</template>
|
|
44
|
+
|
|
45
|
+
<style scoped>
|
|
46
|
+
.deck-placeholder {
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
justify-content: center;
|
|
50
|
+
width: 100%;
|
|
51
|
+
/*
|
|
52
|
+
The hatch sits on the subtle fill rather than replacing it, so the box still reads as a surface
|
|
53
|
+
when the stripes are too fine to resolve at a distance. `--deck-border` (not -strong) keeps the
|
|
54
|
+
stripes quieter than the dashed edge that frames them: the border says "this is a box", the
|
|
55
|
+
hatch says "and it is empty", and the border has to win that hierarchy or the panel turns into
|
|
56
|
+
texture with no shape.
|
|
57
|
+
*/
|
|
58
|
+
background-color: var(--deck-surface);
|
|
59
|
+
background-image: repeating-linear-gradient(
|
|
60
|
+
-45deg,
|
|
61
|
+
var(--deck-border) 0,
|
|
62
|
+
var(--deck-border) 1.5px,
|
|
63
|
+
transparent 1.5px,
|
|
64
|
+
transparent 12px
|
|
65
|
+
);
|
|
66
|
+
/* 4px, not 2. CSS derives the dash LENGTH from the border width, so a thin dashed border is also
|
|
67
|
+
a finely dashed one — at 2px on a 1920 canvas the dashes fall under a pixel once the slide is
|
|
68
|
+
scaled to any real screen and the edge reads as a plain hairline. The dashes have to survive
|
|
69
|
+
the room, same as the hatch. */
|
|
70
|
+
border: 4px dashed var(--deck-border-strong);
|
|
71
|
+
border-radius: var(--deck-radius-card);
|
|
72
|
+
color: var(--deck-fg-muted);
|
|
73
|
+
}
|
|
74
|
+
.deck-placeholder-label {
|
|
75
|
+
/*
|
|
76
|
+
The label needs its own ground: set straight onto the hatch it is read through the stripes, and
|
|
77
|
+
at caption size the 1.5px lines cross the letterforms. The chip is the fill without the hatch,
|
|
78
|
+
which is why it uses the same token rather than a new one.
|
|
79
|
+
*/
|
|
80
|
+
padding: var(--deck-space-1) var(--deck-space-2);
|
|
81
|
+
background: var(--deck-surface);
|
|
82
|
+
border-radius: var(--deck-radius-nested);
|
|
83
|
+
font-size: var(--type-deck-caption-size);
|
|
84
|
+
font-family: var(--font-sans);
|
|
85
|
+
text-align: center;
|
|
86
|
+
max-width: 20em;
|
|
87
|
+
}
|
|
88
|
+
</style>
|