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