@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,329 @@
1
+ <script setup>
2
+ /*
3
+ A timeline chart sized for a room — labelled rows of bars, each bar a real span of dates.
4
+
5
+ ★ THIS IS NOT Timeline.vue, AND THE TWO ARE NOT SUBSTITUTES. Timeline.vue is presentational: a
6
+ sequence of stops on a rule, evenly spaced whatever their dates say, because its subject is the
7
+ ORDER things happened in and how far along the journey is. This one is a CHART — the x axis is
8
+ time to scale, so a bar's position is its real start and its width is its real duration, and a
9
+ three-month piece of work is a quarter of the width of a year-long one. Reach for Timeline.vue to
10
+ tell a story (2021 · 2023 · 2025 · now); reach for this when the OVERLAP is the point — what runs
11
+ alongside what, what waits on what, where the crunch is.
12
+
13
+ NO LEGEND, and the row labels are the reason. Every other chart here needs a legend because its
14
+ marks are unnamed; a timeline draws each row's name beside it, in place, at the height of the bars
15
+ it belongs to. A legend would say the same thing a second time and spend a whole line of the slide
16
+ doing it. Colour still carries the row — it is what lets the eye trace a bar back across a wide
17
+ plot to the name at the left — so the palette's five-colour ceiling is this chart's ROW ceiling,
18
+ and a sixth row draws the over-capacity message rather than a repeated colour. Those labels are
19
+ drawn only because `showRowLabels` is BOUND; the template records why that is not a formality.
20
+
21
+ THE FRAME TAKES ITS HEIGHT FROM ITS ROWS, unlike its siblings, because rows are discrete: two
22
+ rows in a 480px well would be two bars adrift in a field of nothing. Height is `rowHeight` per row
23
+ plus the top margin plus an allowance for the axis, so a two-row timeline is short and a five-row
24
+ one is tall — the same reason a table with two rows is shorter than one with five. `height` still
25
+ sizes the empty-state well.
26
+
27
+ Real @unovis/vue mark, ChartFrame.vue for ceremony, chartData.ts for shaping.
28
+ */
29
+ import { computed } from 'vue';
30
+ import { VisXYContainer, VisAxis, VisTimeline, VisTooltip, VisTimelineSelectors } from '@unovis/vue';
31
+ import { deckTooltip, tipFrame } from './chartTip';
32
+ import ChartFrame from './ChartFrame.vue';
33
+ import { SERIES_CEILING, isPlottable, scatterGroups, seriesVar, stateMessage, tickIndices } from './chartData';
34
+
35
+ const props = defineProps({
36
+ /** Rows: `[{ label, start, end, row? }]`. `start`/`end` are Dates or anything `new Date()` parses — a timeline's x is time, so a bare year number reads as 1970. `row` groups bars onto one line; omit it and each item gets its own row, named by its `label`. */
37
+ data: { type: Array, required: true },
38
+ /** Height of the EMPTY-STATE well in px; 0 fills. A drawn timeline ignores it — the frame takes
39
+ * `rowHeight` per row plus the axis, for the reason the header gives, so a number here can
40
+ * neither stretch nor squash the rows. */
41
+ height: { type: Number, default: 0 },
42
+ /** Height of one row in px, bar and gutter together. */
43
+ rowHeight: { type: Number, default: 96 },
44
+ /** Bar thickness in px. The remainder of `rowHeight` is the gutter, split above and below. */
45
+ barWidth: { type: Number, default: 56 },
46
+ /** Width of the row-label gutter in px. Fixed rather than measured, so consecutive timeline slides start their plots at the same x instead of shuffling. Longer names are trimmed at the end. */
47
+ labelWidth: { type: Number, default: 320 },
48
+ /** Formats the date tick labels. The default is resolved in the component rather than here because it reads the data's extent: a British short month, with the year added only when the timeline crosses one. */
49
+ format: { type: Function, default: null },
50
+ /** Ceiling on date tick labels. Deck labels are 36px, so more than a handful collide. */
51
+ maxTicks: { type: Number, default: 6 },
52
+ /** The sentence drawn when there is nothing to plot. */
53
+ empty: { type: String, default: 'No data' },
54
+ });
55
+
56
+ const toTime = (v) => (v instanceof Date ? v.getTime() : typeof v === 'string' ? Date.parse(v) : v);
57
+
58
+ /*
59
+ The row key is resolved HERE rather than left to an accessor, and it is never allowed to be empty
60
+ — not even when a caller passes `row: ''`. Unovis keys a row by
61
+ ``getString(d, config.lineRow) || `${i + 1}` `` (components/timeline/index.js, `_getRowLabels`), so
62
+ a falsy key silently becomes the item's own index: a set of unnamed items would split into N
63
+ numbered rows in the mark while this file, seeing one empty key, counted a single row and painted
64
+ them all series 1. Baking a non-empty key into the datum keeps the two counts the same by
65
+ construction.
66
+
67
+ A span with a missing or unparseable date is not a span, and neither is a backwards one — Unovis
68
+ warns on a negative length and draws nothing — so they are dropped here. A PARTIAL drop is SILENT:
69
+ the state below speaks only when nothing is left to plot, which is the same rule `scatterState`
70
+ applies to a point with no y. Worth knowing when a row goes missing from a slide.
71
+ */
72
+ const spans = computed(() =>
73
+ (Array.isArray(props.data) ? props.data : [])
74
+ .map((d, i) => ({
75
+ ...d,
76
+ row: String(d.row ?? d.label ?? '') || String(i + 1),
77
+ start: toTime(d.start),
78
+ end: toTime(d.end),
79
+ }))
80
+ .filter((d) => isPlottable(d.start) && isPlottable(d.end) && d.end >= d.start),
81
+ );
82
+
83
+ // The deck's distinct-in-first-seen-order helper. The name is the scatter's; the behaviour is general.
84
+ const rows = computed(() => scatterGroups(spans.value, 'row'));
85
+
86
+ /*
87
+ ⚠ FIRST-SEEN ORDER IS THE COLOUR ORDER, NOT ALWAYS THE VERTICAL ORDER. Unovis builds its rows with
88
+ `groupBy` — a plain-object reduce — and reads them back with `Object.entries`, which puts
89
+ integer-like keys FIRST and in ascending numeric order, so rows named "2024" and "2026" jump above
90
+ every worded row whatever the data said. Colour is keyed on the row NAME here, so it stays attached
91
+ to the right row either way; only the stacking order moves. Name rows with words and the two agree.
92
+
93
+ The decision itself is hand-rolled rather than taken from chartData.ts: `chartState` resolves
94
+ series from a row's numeric COLUMNS and would report `no-series` for every span list. The wording
95
+ and the ceiling still come from the shared module, so the sentence on the slide is the one every
96
+ other chart prints — but these three rules are not covered by test/chartData.static.test.ts.
97
+ */
98
+ const state = computed(() => {
99
+ if (!Array.isArray(props.data) || props.data.length === 0) return { kind: 'empty', reason: 'no-rows' };
100
+ if (spans.value.length === 0) return { kind: 'empty', reason: 'no-values' };
101
+ if (rows.value.length > SERIES_CEILING) return { kind: 'over-capacity', count: rows.value.length };
102
+ return { kind: 'ready', series: rows.value.map((name, i) => ({ key: name, name, color: seriesVar(i) })) };
103
+ });
104
+ const message = computed(() => (state.value.kind === 'ready' ? null : stateMessage(state.value, props.empty)));
105
+
106
+ const extent = computed(() => {
107
+ if (!spans.value.length) return [0, 0];
108
+ return [Math.min(...spans.value.map((s) => s.start)), Math.max(...spans.value.map((s) => s.end))];
109
+ });
110
+
111
+ /*
112
+ A moment — an item whose start and end are the same day — has zero width and draws nothing at all:
113
+ the mark's own floor is `Math.max(0, lineLength)` unless `showEmptySegments` is on, and that switch
114
+ also re-lengths and re-centres every short bar, which is a lie about a duration. The floor here is
115
+ 1% of the timeline's own extent, which over a year is 3.6 days and about 15px across a full-slide
116
+ plot: visible as a narrow bar, and far too small to inflate a span anyone is reading off the axis.
117
+ It scales with the data rather than being a fixed number of days, so a two-week timeline and a
118
+ five-year one both get a mark of the same size.
119
+
120
+ ★ AND WHEN EVERY SPAN IS THE SAME MOMENT the extent itself is zero, so a floor derived from it is
121
+ zero too: the chart drew nothing while its state still said ready — an empty plot with no sentence,
122
+ the exact silent failure the states exist to prevent. A collapsed extent therefore gets a one-day
123
+ window centred on the instant, passed as an explicit `xDomain`. Widening only the DURATION would
124
+ not do: the container derives its own domain from start + duration, so the moment would come back
125
+ as a bar filling the whole plot. `null` is the auto case — the container reads
126
+ `xDomain?.[0] ?? min` (containers/xy-container/index.js, `_updateScalesDomain`).
127
+ */
128
+ const DAY = 24 * 60 * 60 * 1000;
129
+ const axisSpan = computed(() => extent.value[1] - extent.value[0] || DAY);
130
+ const xDomain = computed(() => {
131
+ const [from, to] = extent.value;
132
+ return to > from ? null : [from - axisSpan.value / 2, from + axisSpan.value / 2];
133
+ });
134
+ const minSpan = computed(() => axisSpan.value * 0.01);
135
+
136
+ const x = (d) => d.start;
137
+ const lineDuration = (d) => Math.max(d.end - d.start, minSpan.value);
138
+ const lineRow = (d) => d.row;
139
+ const color = (d) => (state.value.kind === 'ready' ? state.value.series[rows.value.indexOf(d.row)]?.color : undefined);
140
+
141
+ /*
142
+ The plot's own height: a row band each, plus the top margin, plus an allowance for the axis.
143
+
144
+ The container hands its components `containerHeight - margin.top - margin.bottom`, where that
145
+ margin is the authored one PLUS the axis's own measured bbox (xy-container's `_getMargin`), so
146
+ anything left out of this sum comes off the ROW STACK — and the mark clips its rows to the height
147
+ it is given. 64px for the axis is arithmetic off the type roles rather than a measurement: a 36px
148
+ tick label plus 16px of tick padding plus the 2px domain rule is 54, and the remainder is the
149
+ label's descender and half-leading. Deliberately generous, because the two directions fail
150
+ differently — over-allow and the rows sit in a little spare ground with nothing drawn in it;
151
+ under-allow and the bottom row is clipped AND the mark's own scrollbar surfaces on the slide.
152
+ */
153
+ const MARGIN_TOP = 16;
154
+ const AXIS_HEIGHT = 64;
155
+ const plotHeight = computed(() => rows.value.length * props.rowHeight + MARGIN_TOP + AXIS_HEIGHT);
156
+
157
+ /*
158
+ The ticks are the data's OWN boundaries — every distinct start and end — rather than a scale's
159
+ round numbers. On a deck that is the better axis: the dates an audience wants are the ones things
160
+ begin and end on, and a tick that lands exactly on a bar's edge needs no tracing back. `tickIndices`
161
+ thins them the same way it thins a line chart's categories, which also guarantees the last boundary
162
+ keeps its label — an axis whose final date is missing reads as though the work stops early.
163
+
164
+ Thinning is by index, so two boundaries a day apart can both survive it; `tickTextHideOverlapping`
165
+ drops the loser at paint time.
166
+ */
167
+ const xTicks = computed(() => {
168
+ const marks = [...new Set(spans.value.flatMap((s) => [s.start, s.end]))].sort((a, b) => a - b);
169
+ return tickIndices(marks.length, props.maxTicks).map((i) => marks[i]);
170
+ });
171
+
172
+ // Within one calendar year the year is on every tick and says nothing six times over.
173
+ const MONTH = new Intl.DateTimeFormat('en-GB', { month: 'short' });
174
+ const MONTH_YEAR = new Intl.DateTimeFormat('en-GB', { month: 'short', year: 'numeric' });
175
+ const sameYear = computed(() => new Date(extent.value[0]).getFullYear() === new Date(extent.value[1]).getFullYear());
176
+ const xFormat = computed(() => props.format ?? ((t) => (sameYear.value ? MONTH : MONTH_YEAR).format(t)));
177
+
178
+ /*
179
+ A timeline bar is a DURATION, so the tooltip says when it starts and ends rather than repeating
180
+ the row label the axis already carries. Both dates go through the axis' own formatter, or the
181
+ panel and the ticks would spell the same day two ways.
182
+ */
183
+ const tip = (d) => tipFrame(String(d?.label ?? d?.row ?? ''), [
184
+ { label: 'From', value: xFormat.value(d?.start) },
185
+ { label: 'To', value: xFormat.value(d?.end) },
186
+ ]);
187
+ </script>
188
+
189
+ <template>
190
+ <ChartFrame :height="message ? height : plotHeight" :legend="false" :message="message">
191
+ <!-- The row labels are this chart's legend, drawn in place. See the header.
192
+
193
+ ★ THE FRAME IS HANDED THE PLOT'S OWN HEIGHT, which is what keeps it off ChartFrame's fill
194
+ path. `height: 0` there means FILL, and a filling frame stretches to the slide while the rows
195
+ stay `rowHeight` apart: the surplus lands as dead ground under the axis — measured on the
196
+ specimen's Timeline chart slide, a 337px plot in a 487px box — and it also swallows the free
197
+ space `align: body-center` exists to split, so its three auto margins all resolve to 0 and the
198
+ block sits hard under the heading. The empty state keeps `height`, which has a well to size. -->
199
+ <div class="deck-timeline">
200
+ <VisXYContainer
201
+ :data="spans"
202
+ :height="plotHeight"
203
+ :duration="0"
204
+ :x-domain="xDomain"
205
+ :margin="{ top: MARGIN_TOP, right: 24, bottom: 0, left: 0 }"
206
+ >
207
+ <!-- ★★ camelCase ON THIS ONE COMPONENT, kebab-case on its siblings in the same template,
208
+ and the difference is not a style choice.
209
+
210
+ @unovis/vue's timeline wrapper calls `defineComponent` with NO `props` option
211
+ (node_modules/@unovis/vue/components/timeline/index.js) — one of two wrappers in 1.6.7
212
+ that does, crosshair being the other. Vue files every undeclared prop under the key
213
+ exactly as authored (runtime-core's `setFullProps`: `attrs[key] = value`, no camelize),
214
+ and the wrapper's `useForwardProps` spreads those attrs straight over the config. So
215
+ `:row-height` arrives as the config key "row-height", which `merge(defaultConfig,
216
+ config)` keeps as a junk property while `rowHeight` sits at its default of 22. Compiled
217
+ the template to check the other half: the compiler preserves an attribute name's
218
+ authored case verbatim, so camelCase here reaches the config and kebab-case does not.
219
+
220
+ What that cost while it was kebab: `lineDuration` unset falls back to the deprecated
221
+ `length`, whose default reads `d.length` — undefined, so `Math.max(0, 0)` was the whole
222
+ bar and EVERY bar was a zero-width rect. `lineRow` unset falls back to `type`, so each
223
+ item became its own numbered row. `showRowLabels` unset falls back to `showLabels`,
224
+ which is false, so the labels this chart uses INSTEAD of a legend never drew.
225
+
226
+ `showRowLabels` is bound rather than written as a bare attribute for a second reason: a
227
+ valueless attribute compiles to the empty string, and the mark reads
228
+ `config.showRowLabels ?? config.showLabels` — "" is falsy but not nullish, so it takes
229
+ neither branch's truth and the labels stay off.
230
+
231
+ VisXYContainer and VisAxis both declare their props, so kebab is correct there.
232
+
233
+ One consequence there is no reaching from out here: the wrapper leaves `inheritAttrs` at
234
+ its default, so these also land on its empty `<div data-vis-component>` as DOM
235
+ attributes. Inert, and the price of the only channel the wrapper offers.
236
+
237
+ ★ THE CURRENT NAMES, NOT THE ALIASES. `type`, `length`, `showLabels`, `labelWidth` and
238
+ `maxLabelWidth` all still work and are all deprecated, and the config reads them as
239
+ fallbacks (`rowLabelWidth ?? labelWidth`), so a file mixing the two generations gets
240
+ whichever the reader did not expect.
241
+
242
+ `rowFillEmptySpace` is off because the row bands are the ROWS: left on, the mark fills
243
+ any spare height with further banded rects, which at deck scale reads as empty rows
244
+ nobody passed. -->
245
+ <VisTimeline
246
+ :x="x"
247
+ :color="color"
248
+ :lineRow="lineRow"
249
+ :lineDuration="lineDuration"
250
+ :rowHeight="rowHeight"
251
+ :lineWidth="barWidth"
252
+ :showRowLabels="true"
253
+ :rowLabelWidth="labelWidth"
254
+ :rowLabelMargin="[0, 24]"
255
+ :rowFillEmptySpace="false"
256
+ rowLabelTrimMode="end"
257
+ />
258
+ <!-- The grid runs here, unlike the category charts: a bar's ends are numbers on a scale rather
259
+ than names in a list, so a reader has to be able to drop a line from one to the axis. -->
260
+ <VisAxis
261
+ type="x"
262
+ grid-line
263
+ :tick-line="false"
264
+ :tick-values="xTicks"
265
+ :tick-format="xFormat"
266
+ :tick-padding="16"
267
+ tick-text-hide-overlapping
268
+ />
269
+ <VisTooltip v-bind="deckTooltip()" :triggers="{ [VisTimelineSelectors.line]: tip }" />
270
+ </VisXYContainer>
271
+ </div>
272
+ </ChartFrame>
273
+ </template>
274
+
275
+ <style scoped>
276
+ /*
277
+ ★★ THE ROW BANDS SHIP OPAQUE AND LIGHT, and turning them off does not help. Unovis injects
278
+ `--vis-timeline-row-even-fill-color` and `--vis-timeline-row-odd-fill-color` at `:root` at runtime
279
+ — a flat white and a pale grey-blue — and paints EVERY row rect with one of them at opacity 1, so
280
+ on a `deck-dark` slide the plot is a pale slab with the bars sitting on top of it.
281
+ `alternatingRowColors: false` only stops the odd class being added; the rects still fill, now
282
+ uniformly light. Re-pointing the two variables is the only fix, and the banding is worth keeping
283
+ once it is a deck colour: on a wide plot it is what carries the eye from a row's label across to
284
+ its bars.
285
+
286
+ Declared on a wrapper INSIDE the frame, the way ChordChart.vue declares its label size, and both
287
+ halves of that matter for the reasons ChartFrame's own adapter block records. A custom property
288
+ resolves at the NEAREST ancestor that declares it, so a wrapper around the mark beats a `:root`
289
+ default injected later whatever either selector's specificity; and that wrapper sits inside the
290
+ `.deck-dark` subtree, so `var(--deck-surface)` re-resolves with the slide instead of freezing at
291
+ its light value.
292
+
293
+ These live here rather than in ChartFrame because no other deck chart mounts a VisTimeline.
294
+ */
295
+ .deck-timeline {
296
+ --vis-timeline-row-even-fill-color: transparent;
297
+ /*
298
+ A TINT OF THE INK RATHER THAN THE `--deck-surface` FILL, for two reasons that pull the same
299
+ way. A band is the largest area this chart paints — 96px tall and the full plot wide — and a
300
+ tint reads heavier the more of it there is, so the well's 8% that is right for a status track's
301
+ thin gutter comes out as a slab here. And a mix against `transparent` composites over whatever
302
+ ground the chart is standing on, where a mix against `--deck-bg` bakes in the slide's own.
303
+ */
304
+ --vis-timeline-row-odd-fill-color: color-mix(in oklab, var(--deck-fg) 5%, transparent);
305
+
306
+ /* 12px and a grey-blue by default — a caption for a dashboard, invisible in a room. The row names
307
+ are the subject of this chart, not its scale, so they take full-strength ink where the date
308
+ ticks take the muted axis colour. */
309
+ --vis-timeline-label-font-size: var(--type-deck-body-sm-size);
310
+ --vis-timeline-label-color: var(--deck-fg);
311
+ }
312
+
313
+ /*
314
+ THE BAR CORNERS, WHICH THE COMPONENT'S OWN CONFIG CANNOT SET AT THIS SIZE. `lineCap` is the only
315
+ knob Unovis offers and it is binary: `.attr('rx', d => config.lineCap ? d._height / 2 : null)`, so
316
+ `true` on a 56px bar is a 28px corner — a pill, which reads as a token rather than as a span with
317
+ a start and an end. `false` (the built default, whatever config.d.ts's comment says) is the sharp
318
+ rect this file shipped. The item rung sits between them.
319
+
320
+ Matched on the class SUFFIX because Unovis emits emotion-hashed names — the bar is
321
+ `css-1my6t8v-line`, with no bare `.line` beside it — and `$=` rather than `*=` because
322
+ `-line-group`, `-line-start-icon` and `-lines` all contain the same four letters. The hash is not
323
+ in the selector, only the semantic tail emotion appends, which is the same handle base.css uses
324
+ for the donut's central label.
325
+ */
326
+ .deck-timeline :deep(rect[class$='-line']) {
327
+ rx: var(--deck-radius-item);
328
+ }
329
+ </style>
@@ -0,0 +1,267 @@
1
+ <script setup>
2
+ /*
3
+ A treemap sized for a room — part-to-whole when the parts are too many, or too uneven, for a donut.
4
+
5
+ A donut is read by ANGLE and dies past a handful of slices; a treemap is read by AREA and every
6
+ tile carries its own name, so twenty parts stay countable. The price is precision: nobody reads a
7
+ ratio off two rectangles of different aspect. It is the right mark when the point is "this one is
8
+ most of it", and the wrong one when the point is "these two are close".
9
+
10
+ ★ COLOUR IS BY GROUP, NOT BY TILE, which is the whole reason this chart can carry more parts than
11
+ the palette has colours. Rows with no `group` form one unnamed group and share series 1 — an
12
+ ungrouped treemap is deliberately monochrome, because the AREA is already the encoding and a
13
+ second one would be decoration. Grouped, the five-colour ceiling applies to the GROUPS, so five
14
+ groups of twenty tiles is a chart this palette can honestly paint, and a sixth group is the
15
+ over-capacity state rather than an invented colour.
16
+
17
+ Real @unovis/vue mark, ChartFrame.vue for ceremony, chartData.ts for shaping.
18
+ */
19
+ import { computed } from 'vue';
20
+ import { VisSingleContainer, VisTreemap, VisTooltip, VisTreemapSelectors } from '@unovis/vue';
21
+ import { deckTooltip, tipFrame } from './chartTip';
22
+ import ChartFrame from './ChartFrame.vue';
23
+ import { formatValue, isPlottable, labelAt, scatterGroups, shareState, stateMessage } from './chartData';
24
+
25
+ const props = defineProps({
26
+ /** Rows: `[{ label, value, group? }]`. `group` is optional; when present it is what the tile is coloured and legended by. */
27
+ data: { type: Array, required: true },
28
+ /** Plot height in px. 0 FILLS, and is the default: the chart takes whatever box it is given,
29
+ * which is what a chart under a heading wants. Unovis resolves `config.height || containerHeight`,
30
+ * so a falsy height makes the container measure itself. Pass a number where the box has no
31
+ * definite height of its own — a tiled grid cell, a figure beside copy. */
32
+ height: { type: Number, default: 0 },
33
+ /** Draw the group legend. Ignored when nothing is grouped — there is one colour and the tiles name themselves. On a GROUPED treemap the legend is the only thing that names a group, so turning it off leaves colour-coded tiles with nothing saying what the colours mean. */
34
+ legend: { type: Boolean, default: true },
35
+ /** Where the legend sits: `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center`
36
+ * or `bottom-right`. The EDGE is reading order — above is read before the marks, below is a
37
+ * caption read after them; the ALIGNMENT is for the column the chart sits in, centred under a
38
+ * full-width plot and pulled to an edge where a centred key would float in a gutter. */
39
+ legendPosition: {
40
+ type: String,
41
+ default: 'bottom-center',
42
+ validator: (p) => ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'].includes(p),
43
+ },
44
+ /** Formats the figure printed beside each tile's name. */
45
+ format: { type: Function, default: formatValue },
46
+ /** The sentence drawn when there is nothing to plot. */
47
+ empty: { type: String, default: 'No data' },
48
+ });
49
+
50
+ // The same mapping scatterGroups() uses, so the group a row is COUNTED in and the group it is DRAWN
51
+ // in can never disagree.
52
+ const groupOf = (row) => (row.group == null ? '' : String(row.group));
53
+
54
+ // What the legend calls the bucket that rows with no `group` land in. Only ever read in the MIXED
55
+ // case — with nothing grouped the legend is suppressed outright — but that case is the one that
56
+ // breaks without it: shareState() passes '' through as a series NAME (its `typeof r.label ===
57
+ // 'string'` test accepts the empty string), so ChartFrame draws a coloured bullet followed by
58
+ // nothing. ScatterChart closes the same hole with `name || 'Points'`.
59
+ const UNGROUPED = 'Other';
60
+
61
+ // A tile is an area, so only a positive reading can be one. A gap has no area to draw, and a
62
+ // negative value is summed into its parent by d3 and silently misallocates every sibling's share —
63
+ // so both are dropped here rather than passed through as a rect nobody can see.
64
+ const rows = computed(() => (props.data ?? []).filter((r) => isPlottable(r.value) && r.value > 0));
65
+
66
+ const groups = computed(() => scatterGroups(rows.value));
67
+ const grouped = computed(() => groups.value.some((g) => g !== ''));
68
+
69
+ /*
70
+ The state decision is the DONUT's — shareState() — taken over the GROUPS rather than the rows,
71
+ because the groups are what ask for colours. Ten tiles in three groups is `ready`; six groups is
72
+ over-capacity and says so on the slide.
73
+
74
+ ⚠ stateMessage() words that state "6 series — this palette carries 5". The count is right and the
75
+ ceiling is right; the NOUN is `series` where this chart is gating GROUPS, over an arbitrary number
76
+ of tiles. Left alone rather than patched around here: the wording lives in chartData.ts precisely
77
+ so it is one string a test can pin, and rewriting it downstream is how six components end up with
78
+ six near-identical sentences.
79
+ */
80
+ const groupRows = computed(() =>
81
+ groups.value.map((g) => ({
82
+ label: g || UNGROUPED,
83
+ value: rows.value.reduce((sum, r) => (groupOf(r) === g ? sum + r.value : sum), 0),
84
+ })),
85
+ );
86
+ const state = computed(() => shareState(groupRows.value));
87
+ const message = computed(() => (state.value.kind === 'ready' ? null : stateMessage(state.value, props.empty)));
88
+
89
+ // Re-keyed by POSITION. shareState() keys a series by its own value, which is unique enough for a
90
+ // donut's hand-authored slice rows but not here: the values it is handed are group SUMS, and two
91
+ // groups that happen to sum equal produce the same key twice in ChartFrame's legend `v-for` — a Vue
92
+ // warning, and a list that can mis-patch. A group's position is unique by construction
93
+ // (scatterGroups() dedupes), and this list, `groups` and the palette below all share that order.
94
+ const seriesList = computed(() =>
95
+ state.value.kind === 'ready' ? state.value.series.map((s, i) => ({ ...s, key: String(i) })) : [],
96
+ );
97
+
98
+ /*
99
+ The layers ARE the hierarchy: one accessor per level, each naming the node a row belongs to.
100
+ Unovis calls them as `getString(data[i], accessor, i)`, so the second argument is the row's own
101
+ index — exactly what labelAt() wants, including its fallback for a row with no label.
102
+
103
+ UNGROUPED, two rows of the same name MERGE into one tile whose value is their sum: d3's `group`
104
+ collects them under one key, and `rootNode.sum()` runs before the library collapses the extra
105
+ level of nesting. GROUPED they do NOT — the same label under two different groups is two separate
106
+ depth-2 nodes — and Unovis keys its tile data-join `${d.data.key}-${d.depth}`, with no parent in
107
+ it, so those two collide in the join and one exits and re-enters on every render. Inert at
108
+ `:duration="0"`, which every container here sets, but it is why a label is worth keeping unique
109
+ within its group.
110
+ */
111
+ const labelOf = (_row, i) => labelAt(rows.value, i);
112
+ const layers = computed(() => (grouped.value ? [groupOf, labelOf] : [labelOf]));
113
+
114
+ const value = (d) => d.value;
115
+ const bySize = (a, b) => b.value - a.value;
116
+
117
+ /*
118
+ ★★ EVERY NODE IS GIVEN A COLOUR, or the library invents one. A tile below the first level whose
119
+ accessor returns nothing falls through to `brighter(parent fill, …)` — an hcl lift that lands
120
+ outside the palette — and `enableLightnessVariance` (pinned off below) is a second helping of the
121
+ same idea. Answering for every node at every depth closes both paths.
122
+
123
+ ★★ AN INTERNAL TILE TAKES THE GROUND, NOT ITS GROUP'S COLOUR. The parent rect is painted UNDER its
124
+ children, and the padding between them is a hole in the children rather than in the parent — so a
125
+ group rect wearing the group colour shows that colour in every gap and its tiles fuse into one
126
+ blob. Painting it --deck-bg puts the slide back in the gaps, which is where the donut's segment
127
+ stroke and the bar's already are. It is also why the group name cannot be drawn on the group rect:
128
+ everything but a six-pixel band of it is covered by its own children.
129
+ */
130
+ const tileColor = computed(() => {
131
+ // `groups`, `seriesList` and this map are one order — the index is the only thing tying a group
132
+ // to its colour, and shareState() resolved the colours in exactly this sequence.
133
+ const palette = new Map(groups.value.map((g, i) => [g, seriesList.value[i]?.color]));
134
+ const [first] = seriesList.value;
135
+ // Ungrouped, the top-level parent IS the tile and its key is the row's label, so the lookup misses
136
+ // and every tile lands on series 1 — the monochrome case, by construction.
137
+ return (d) => (d.children ? 'var(--deck-bg)' : (palette.get(d.topLevelParent?.data?.key) ?? first?.color));
138
+ });
139
+
140
+ const tileLabel = (node) => `${node.data.key} ${props.format(node.value)}`;
141
+
142
+ /*
143
+ ★★ THE TILE LABEL TAKES --deck-on-accent, THE SYSTEM'S OWN INK-ON-A-FILL ROLE. A tile is a
144
+ saturated fill carrying text, which is the one question that role exists to answer, and it is the
145
+ house rule this chart was breaking: a fill at 500 and above takes white (or a 50/100 tint), and
146
+ only at 400 and below does a dark ink belong on it. The deck's series are Tailwind 600 steps on
147
+ light and 400 on dark, so the ink has to TURN OVER with the register — which is exactly what the
148
+ role does: white on light, #002a24 on dark.
149
+
150
+ The library's own choice fails this palette twice and is still overridden here:
151
+ 1. It picks between --vis-treemap-label-text-color and …-light from `isColorDark(fill)`, a
152
+ NON-linearised brightness against a 0.55 threshold. The dark register's pink reads as dark at
153
+ 0.541, takes the light ink, and lands at 2.76:1 — under the 3:1 large-text floor.
154
+ 2. --vis-treemap-label-opacity ships at 0.8, which fades whichever ink wins by about a fifth, so
155
+ even the tiles it gets right fail. Pinned to 1.
156
+
157
+ MEASURED on all twelve tiles (six colours × both registers), against the 3:1 large-text floor:
158
+ light 3.28 / 3.20 / 4.09 / 6.46 / 4.64 / 4.87, dark 8.70 / 9.04 / 7.02 / 5.00 / 5.60 / 5.39.
159
+ Worst case 3.20 on the light amber.
160
+
161
+ ⚠ THIS WAS A RAW --neutral-950, and the note defending it said the ink could not follow the slide
162
+ "because --deck-series-* is bound to `.dark` alone, so a dark slide inside a light deck paints
163
+ light-register tiles". That stopped being true when deck.css grew its `.deck-dark` block: the
164
+ series re-point per SLIDE now, measured on the charts-on-dark slide, so the ground under this ink
165
+ does follow the register and the ink can follow it back. Black scored 3.07 at its worst (the light
166
+ indigo) — it was never the safer choice, only the mode-invariant one.
167
+ */
168
+ const labelInk = {
169
+ '--vis-treemap-label-text-color': 'var(--deck-on-accent)',
170
+ '--vis-treemap-label-text-color-light': 'var(--deck-on-accent)',
171
+ '--vis-treemap-label-opacity': '1',
172
+ };
173
+
174
+
175
+ /*
176
+ The tile already prints its own name and value, so the tooltip adds the one thing the picture only
177
+ approximates: the share of the whole. Unovis hands the hierarchy node, whose `value` is the summed
178
+ leaf and whose `data.key` is the row's own label.
179
+ */
180
+ const treemapTotal = computed(() => rows.value.reduce((a, r) => a + (r.value ?? 0), 0));
181
+ const tip = (node) => {
182
+ const v = node?.value ?? 0;
183
+ const share = treemapTotal.value > 0 ? Math.round((v / treemapTotal.value) * 100) : 0;
184
+ return tipFrame(String(node?.data?.key ?? ''), [{ label: `${share}%`, value: props.format(v) }]);
185
+ };
186
+ </script>
187
+
188
+ <template>
189
+ <ChartFrame
190
+ :series="seriesList"
191
+ :height="height"
192
+ :legend="legend && grouped"
193
+ :legend-position="legendPosition"
194
+ :message="message"
195
+ :style="labelInk"
196
+ >
197
+ <VisSingleContainer :data="rows" :height="height" :duration="0">
198
+ <!-- ★ THE THREE LABEL SIZES ARE NOT OPTIONAL. `enableTileLabelFontSizeVariation` ships TRUE in
199
+ @unovis/ts 1.6.7's TreemapDefaultConfig (its own docstring says false), and it reaches the
200
+ config as true whether or not it is passed — the Vue wrapper forwards only the props the
201
+ parent actually wrote, so an absent Boolean is absent rather than the `false` Vue would
202
+ otherwise coerce it to. While it is on, leaf labels take these three numbers by value and
203
+ never read --vis-treemap-label-font-size at all: a deck that sets nothing here draws its
204
+ tiles at the library's 8/12/22px. The ladder is three deck roles — body-sm, body, title
205
+ (36/48/60px), the same tier ChartFrame gives the axis. Plain numbers because the config
206
+ takes numbers; no token can reach them, so a retune of --type-deck-* leaves them behind.
207
+
208
+ minTileSizeForLabel is 360, not the library's 20: below that a tile cannot hold a label
209
+ and the trim does not save it.
210
+
211
+ ★★ IT WAS 120, AND THAT WAS TUNED AGAINST THE WRONG TYPE. The reasoning said "at 36px
212
+ type... a smaller tile carries about five characters" — but 36px is the SMALL rung of the
213
+ ladder three lines up, and a real tile draws at 48 or 60. Measured on six boroughs: at
214
+ 60px "Lambeth 91k" is 353px wide, so 120 admits a tile that cannot hold any label at all.
215
+ Camden's tile came out 158px wide carrying a 231px label and Wandsworth's 218px carrying
216
+ 307px — both cut, both with the ellipsis already spent, so the cut fell PAST the "…" and
217
+ was unmarked. scripts/deckAudit.ts caught it as 31% hidden, 73px off the right; nothing
218
+ about it looked wrong in a screenshot, because a trimmed label and an over-trimmed one
219
+ are the same shape.
220
+
221
+ ★★ AND THE PROP COMPARES min(width, height), NOT WIDTH — established by measurement, not
222
+ from the docs. Setting it to 360 dropped EVERY label including the 932px-wide tile, which
223
+ only makes sense against the short side. At 260 exactly the tiles whose smaller dimension
224
+ fell below it went bare (Islington 237, Lambeth 250, Camden 158, Wandsworth 218) while
225
+ 580-tall tiles kept theirs. That is lucky rather than clever: a tile too narrow for a
226
+ horizontal label is narrow on its SHORT side, so the library's predicate guards the
227
+ dimension that actually matters here.
228
+
229
+ 230 because "Camden…" measures 231px at the ladder's 48px rung — so the threshold is
230
+ "the tile must be at least as wide as the shortest label we would draw", which is a
231
+ number with a reason rather than one fitted to this dataset. Below it the tile stays a
232
+ bare rectangle, which is what this prop was always for; 120 was simply describing a font
233
+ size the component had stopped using.
234
+
235
+ tilePadding is 6 rather than 2 — twice the stacked bar's stroke, because a treemap's gaps
236
+ are its ONLY structure: no axis, no baseline, no rule anywhere else on the mark.
237
+
238
+ ★ TRIM, NOT WRAP, AND THE TRIM HAS TO BE ASKED FOR. Left at the library defaults a label
239
+ wider than its tile is CLIPPED at the tile edge — measured, "Wandsworth 65k" rendered as
240
+ "Wandsw" against a hard edge, which reads as a rendering fault rather than as a name that
241
+ did not fit. `trim` with `end` ellipsises instead, so a narrow tile still says which
242
+ borough it is and admits it ran out of room. Wrapping is the wrong half of that choice
243
+ here: a tile is only as tall as its share of the area, so a second line is exactly what
244
+ the smallest tiles have no room for. -->
245
+ <VisTreemap
246
+ :value="value"
247
+ :layers="layers"
248
+ :tile-color="tileColor"
249
+ :tile-label="tileLabel"
250
+ :tile-sort="bySize"
251
+ :tile-padding="6"
252
+ :tile-border-radius="8"
253
+ :label-offset-x="12"
254
+ :label-offset-y="12"
255
+ :enable-lightness-variance="false"
256
+ :enable-tile-label-font-size-variation="true"
257
+ :tile-label-small-font-size="36"
258
+ :tile-label-medium-font-size="48"
259
+ :tile-label-large-font-size="60"
260
+ :min-tile-size-for-label="230"
261
+ label-fit="trim"
262
+ label-trim-mode="end"
263
+ />
264
+ <VisTooltip v-bind="deckTooltip()" :triggers="{ [VisTreemapSelectors.tile]: tip }" />
265
+ </VisSingleContainer>
266
+ </ChartFrame>
267
+ </template>