@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,480 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/*
|
|
3
|
+
A year of days as a grid — one cell per date, shaded by how much, for reading SEASONALITY.
|
|
4
|
+
|
|
5
|
+
The chart answers "when in the year", not "how many on the 14th". Position carries the date and
|
|
6
|
+
shade carries the amount, and shade is the weaker of the two channels by a long way: an audience
|
|
7
|
+
can see a summer that runs darker than a spring, and cannot rank two cells three weeks apart. That
|
|
8
|
+
is the trade the shape is for. A slide making a point about a VALUE wants a line or a bar, where
|
|
9
|
+
the quantity is a length and can be read off.
|
|
10
|
+
|
|
11
|
+
PLAIN SVG, NO @unovis/vue. The library has no calendar mark — @unovis/vue@1.6.7's index.js exports
|
|
12
|
+
line, area, axis, scatter, bars, donut, nested-donut, sankey, chord, treemap, timeline, graph and
|
|
13
|
+
the map/legend html-components, and nothing that lays a value out on a date lattice. There is
|
|
14
|
+
nothing to configure and nothing to wrap, so the geometry is thirty lines of date arithmetic here.
|
|
15
|
+
The ceremony is still ChartFrame.vue: the frame never held a mark in the first place, so a
|
|
16
|
+
hand-drawn one changes nothing about the legend, the empty state or the type ramp.
|
|
17
|
+
|
|
18
|
+
NO `:duration="0"`, BECAUSE THERE IS NO CONTAINER TO SET IT ON. ChartFrame's doctrine — "the chart
|
|
19
|
+
is finished the moment its slide is" — is honoured by construction rather than by a prop: nothing
|
|
20
|
+
here animates, no cell carries a transition, and the grid is drawn in its final state on the first
|
|
21
|
+
paint. Recorded because the absence of the knob is the one thing that makes this file look
|
|
22
|
+
different from its five siblings, and it is not an oversight.
|
|
23
|
+
|
|
24
|
+
THE SEQUENTIAL RAMP, NOT THE SERIES PALETTE. --deck-series-1…5 name five different THINGS;
|
|
25
|
+
--deck-shade-1…5 are five amounts of one thing, which is what a day's value is. The ramp also
|
|
26
|
+
reverses itself on a dark slide (deck.css re-points all five under `.dark, .deck-dark`), so "more
|
|
27
|
+
ink means more" survives a `class: deck-dark` with no work here — the fills are `var()` references
|
|
28
|
+
and CSS re-resolves them on the flip.
|
|
29
|
+
|
|
30
|
+
FIVE BUCKETS BECAUSE THE RAMP HAS FIVE STEPS. Equal intervals over (0, max], so bucket n covers
|
|
31
|
+
the nth fifth of the range and the legend can state its top edge — an audience decoding a shade
|
|
32
|
+
gets a number, not a rank. Equal-COUNT (quantile) buckets read a skewed year more prettily and
|
|
33
|
+
lie about magnitude while they do it: two cells a bucket apart could be one ride apart or a
|
|
34
|
+
thousand. A count of buckets other than five either strands a token or needs a colour that does
|
|
35
|
+
not exist, exactly as the categorical ceiling of five works.
|
|
36
|
+
|
|
37
|
+
ZERO IS NOT THE PALEST SHADE. A day with no reading, or a reading of nought, takes the chart
|
|
38
|
+
track (--deck-surface) and no ramp step at all. Shading it --deck-shade-1 would say "a little
|
|
39
|
+
happened" about a day when nothing did, and across a quiet quarter that reads as a season.
|
|
40
|
+
|
|
41
|
+
UTC END TO END. A date-only ISO string parses as UTC midnight, so UTC getters return the day the
|
|
42
|
+
author wrote; local getters would move every date one back west of Greenwich, shifting the whole
|
|
43
|
+
grid a column and relabelling the months. Column arithmetic is integer milliseconds over UTC days,
|
|
44
|
+
which are all exactly 86,400,000ms — local-midnight arithmetic hits a 23- and a 25-hour day each
|
|
45
|
+
year and drops or doubles a column at the clock change.
|
|
46
|
+
*/
|
|
47
|
+
import { computed } from 'vue';
|
|
48
|
+
import ChartFrame from './ChartFrame.vue';
|
|
49
|
+
import { useDeckTip, tipFrame } from './chartTip';
|
|
50
|
+
import { formatValue, isPlottable, stateMessage } from './chartData';
|
|
51
|
+
|
|
52
|
+
const props = defineProps({
|
|
53
|
+
/** Rows: `[{ date, value }]`, one per day, in any order. `date` is an ISO `YYYY-MM-DD` string or a Date; days the array does not mention are drawn as track. */
|
|
54
|
+
data: { type: Array, required: true },
|
|
55
|
+
/** Cell side in px. The chart's HEIGHT is derived from it (seven rows plus the month band), which is why there is no `height` prop: a calendar's height is its cell size, and setting both would let them disagree. */
|
|
56
|
+
cell: { type: Number, default: 26 },
|
|
57
|
+
/** Top of the ramp. Defaults to the largest value present, so the busiest day is the deepest shade. Pin it to compare two years on one scale; a pin of zero or less is ignored, since a ramp needs a range. */
|
|
58
|
+
max: { type: Number, default: null },
|
|
59
|
+
/** Draw the ramp key — five swatches and the top of each bucket. A shaded grid with no key is a texture. */
|
|
60
|
+
legend: { type: Boolean, default: true },
|
|
61
|
+
/** Where the legend sits: `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center`
|
|
62
|
+
* or `bottom-right`. The EDGE is reading order — above is read before the marks, below is a
|
|
63
|
+
* caption read after them; the ALIGNMENT is for the column the chart sits in, centred under a
|
|
64
|
+
* full-width plot and pulled to an edge where a centred key would float in a gutter. */
|
|
65
|
+
legendPosition: {
|
|
66
|
+
type: String,
|
|
67
|
+
default: 'bottom-center',
|
|
68
|
+
validator: (p) => ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'].includes(p),
|
|
69
|
+
},
|
|
70
|
+
/** Formats the bucket edges in the legend. Defaults to British grouping, compact past four digits. */
|
|
71
|
+
format: { type: Function, default: formatValue },
|
|
72
|
+
/** The sentence drawn when there is nothing to plot. */
|
|
73
|
+
empty: { type: String, default: 'No data' },
|
|
74
|
+
/** Which day the week starts on — `sunday` or `monday`. It sets the ROW ORDER, so it moves every
|
|
75
|
+
* cell: a year drawn Sunday-first and the same year drawn Monday-first are different pictures of
|
|
76
|
+
* the same data, and the weekend sits either side of the block in one and at its foot in the other. */
|
|
77
|
+
weekStart: { type: String, default: 'monday', validator: (p) => ['sunday', 'monday'].includes(p) },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const DAY_MS = 86_400_000;
|
|
81
|
+
const ROWS = 7;
|
|
82
|
+
const STEPS = 5;
|
|
83
|
+
|
|
84
|
+
/*
|
|
85
|
+
★ THE CELL IS A MEASUREMENT, and it is the one number that decides whether this chart works.
|
|
86
|
+
|
|
87
|
+
A year is 53 week columns — 54 in the worst alignment, `ceil((6 + 366) / 7)`, which 2000–2039
|
|
88
|
+
hits (2012: a leap year opening on a Sunday, so it spends six days in column 0 before the first
|
|
89
|
+
Monday). The deck's text column is the 1920 canvas less --deck-inset-text on both edges: 1740px.
|
|
90
|
+
That is 1740 / 54 = 32.2px per column, so the pitch is 32 and the split inside it went as far
|
|
91
|
+
towards the cell as it could while leaving the gutter a fifth of the cell — 26px of ink, 6px of
|
|
92
|
+
air, and 27/5 would have put the gutter at 0.185 of the cell. A 53-column year draws 1690px wide
|
|
93
|
+
with 50px to spare.
|
|
94
|
+
|
|
95
|
+
26px is bigger than the smallest mark this deck draws on purpose: ChartFrame's legend bullet is
|
|
96
|
+
0.6em of the 36px body-sm role, 21.6px, and its own note calls that "a real disc at reading
|
|
97
|
+
distance". The gutter is the load-bearing half of the pair — drop it and a run of same-shade days
|
|
98
|
+
fuses into a bar, and a grid that cannot be counted is a heatmap of nothing.
|
|
99
|
+
*/
|
|
100
|
+
const GAP = 6;
|
|
101
|
+
const pitch = computed(() => props.cell + GAP);
|
|
102
|
+
|
|
103
|
+
/* The month band: a 36px label's baseline at 36 (its caps then sit inside the band) plus
|
|
104
|
+
--deck-space-2 of air before the first cell. The 16 is that token's value, written as a number
|
|
105
|
+
because SVG geometry is arithmetic here rather than CSS. */
|
|
106
|
+
const LABEL_BASELINE = 36;
|
|
107
|
+
const LABEL_BAND = LABEL_BASELINE + 16;
|
|
108
|
+
|
|
109
|
+
/* The widest en-GB short month is "Sept" — four glyphs (checked against Intl, which gives Jan Feb
|
|
110
|
+
Mar Apr May Jun Jul Aug Sept Oct Nov Dec), and the deck's own type note puts the average letter
|
|
111
|
+
at 0.51em, so 4 × 0.51 × 36px ≈ 73. Used twice below: as the collision distance between two
|
|
112
|
+
labels, and as the overhang that decides whether the last one is right-anchored. */
|
|
113
|
+
const LABEL_W = 73;
|
|
114
|
+
|
|
115
|
+
/*
|
|
116
|
+
★★ THE ROWS ARE A WEEKDAY AXIS AND NOTHING NAMED THEM. A reader could only learn which day a row
|
|
117
|
+
was by hovering a cell and counting, which is not an axis.
|
|
118
|
+
|
|
119
|
+
★ ONE LETTER, AND THAT IS WHAT LETS ALL SEVEN BE DRAWN. Written first as `Mon / Wed / Fri`, three
|
|
120
|
+
labels on alternate rows, because a three-glyph name at the 36px axis tier does not clear a row
|
|
121
|
+
pitch of `cell + GAP` — 32 at the default. A narrow name is one glyph, so every row can carry its
|
|
122
|
+
own and the axis stops asking the reader to interpolate. `M T W T F S S` repeats two letters; that
|
|
123
|
+
is the known cost of the narrow form and the reason the tooltip carries the full date.
|
|
124
|
+
|
|
125
|
+
⚠ AND THE GUTTER IS MEASURED, NOT ESTIMATED — twice now, and the estimate was wrong both times.
|
|
126
|
+
LABEL_W's 0.51em average gives 3 × 0.51 × 36 ≈ 55 for a three-glyph name; read off the render with
|
|
127
|
+
getComputedTextLength, "Wed" was 84.6 and "Mon" 78.2. Narrowed to one letter the same average
|
|
128
|
+
says 18, and the real widths are M 36.4 and W 43.9 against S 18.7 — because an average letter is
|
|
129
|
+
exactly the wrong estimator for a set that is mostly the alphabet's two widest capitals. 44, the
|
|
130
|
+
measured widest, plus --deck-space-2 of air before the first column.
|
|
131
|
+
|
|
132
|
+
The grid is translated by the gutter rather than every x being offset, so the month band and the
|
|
133
|
+
cells keep the arithmetic they had.
|
|
134
|
+
*/
|
|
135
|
+
const WEEKDAY_GUTTER = 44 + 16;
|
|
136
|
+
/* Intl, not a hand-typed list, so the names follow the same locale the month band already uses.
|
|
137
|
+
2024-01-07 was a Sunday, so adding the row index walks the week from whichever day starts it. */
|
|
138
|
+
const WEEKDAY_SUNDAY = Date.UTC(2024, 0, 7);
|
|
139
|
+
const WEEKDAY = new Intl.DateTimeFormat('en-GB', { weekday: 'narrow', timeZone: 'UTC' });
|
|
140
|
+
|
|
141
|
+
/** The UTC midnight of a row's date, or null when it is not a date at all — a bad row is skipped
|
|
142
|
+
* rather than binned to 1970, which would draw a stray cell fifty-odd years from the data. */
|
|
143
|
+
function dayOf(value) {
|
|
144
|
+
const d = value instanceof Date ? value : new Date(value);
|
|
145
|
+
return Number.isNaN(d.getTime()) ? null : Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/*
|
|
149
|
+
Row 0 is whichever day `weekStart` names. Sunday-first puts the weekend either side of the block;
|
|
150
|
+
Monday-first puts it at the foot — the same year, two different pictures, which is why this is a
|
|
151
|
+
prop rather than the constant it used to be.
|
|
152
|
+
|
|
153
|
+
★ THE DEFAULT IS THE LOCALE'S, not a preference: the UK week starts on Monday, and this deck is
|
|
154
|
+
British down to the en-GB the month band and the weekday names are formatted with. Sunday-first is
|
|
155
|
+
there for a deck that is not, rather than being the shape everyone gets and half have to undo.
|
|
156
|
+
|
|
157
|
+
`getUTCDay()` is Sunday-0, so Sunday-first is the identity and Monday-first is the shift.
|
|
158
|
+
*/
|
|
159
|
+
const rowOf = (day) => {
|
|
160
|
+
const dow = new Date(day).getUTCDay();
|
|
161
|
+
return props.weekStart === 'monday' ? (dow + 6) % 7 : dow;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/*
|
|
165
|
+
★★ THE SPAN IS WALKED, NOT THE ARRAY — and the prop doc has always said so while the code did the
|
|
166
|
+
other thing. "Days the array does not mention are drawn as track" was a claim nothing tested: this
|
|
167
|
+
mapped the rows given, so an unmentioned day drew NO CELL, leaving a hole to the slide ground
|
|
168
|
+
rather than an unshaded square. A fortnight the fleet did not run came out as a bite taken out of
|
|
169
|
+
the grid, which reads as a rendering fault rather than as an absence.
|
|
170
|
+
|
|
171
|
+
Walking first-to-last day makes the claim true and costs nothing: a day with no row carries an
|
|
172
|
+
undefined value, `shadeOf` returns null for it, and the cell takes the track fill — the same rule
|
|
173
|
+
StatusTrack states for a bucket with no reading. A REPEATED date now resolves to its last row
|
|
174
|
+
rather than drawing two stacked cells; that was documented behaviour nobody could see, and one
|
|
175
|
+
cell per day is the honest reading of a calendar.
|
|
176
|
+
|
|
177
|
+
Sorted implicitly by the walk, so an author writing a year by hand still need not.
|
|
178
|
+
*/
|
|
179
|
+
const days = computed(() => {
|
|
180
|
+
const given = new Map();
|
|
181
|
+
for (const row of props.data ?? []) {
|
|
182
|
+
const day = dayOf(row?.date);
|
|
183
|
+
if (day !== null) given.set(day, row?.value);
|
|
184
|
+
}
|
|
185
|
+
if (!given.size) return [];
|
|
186
|
+
const bounds = [...given.keys()].sort((a, b) => a - b);
|
|
187
|
+
const out = [];
|
|
188
|
+
for (let day = bounds[0]; day <= bounds[bounds.length - 1]; day += DAY_MS) {
|
|
189
|
+
out.push({ day, value: given.get(day) });
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
/*
|
|
195
|
+
The state decision, built here rather than taken whole from `chartState()`, because that function
|
|
196
|
+
resolves series from a row's numeric COLUMNS and knows nothing about dates. It answers the two
|
|
197
|
+
questions it can — nothing passed, and nothing numeric in `value` — and this adds the third: rows
|
|
198
|
+
that carry a value but no readable `date`. Without that clause `[{ label: 'Mon', value: 5 }]` (an
|
|
199
|
+
author reaching for LineChart's row shape) is `ready`, draws a zero-width grid, and prints no
|
|
200
|
+
sentence at all — a blank slide with nothing to say why.
|
|
201
|
+
|
|
202
|
+
Written as the same `ChartState` literals `stateMessage()` already reads, the way StatusTrack.vue
|
|
203
|
+
does, so the wording stays one string. If a second date-lattice chart ever lands, this belongs in
|
|
204
|
+
chartData.ts beside `scatterState()`, where a test can reach it.
|
|
205
|
+
*/
|
|
206
|
+
const state = computed(() => {
|
|
207
|
+
const rows = Array.isArray(props.data) ? props.data : [];
|
|
208
|
+
if (rows.length === 0) return { kind: 'empty', reason: 'no-rows' };
|
|
209
|
+
if (days.value.length === 0) return { kind: 'empty', reason: 'no-values' };
|
|
210
|
+
if (!days.value.some((d) => isPlottable(d.value))) return { kind: 'empty', reason: 'no-values' };
|
|
211
|
+
return { kind: 'ready' };
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const message = computed(() => (state.value.kind === 'ready' ? null : stateMessage(state.value, props.empty)));
|
|
215
|
+
|
|
216
|
+
/* Column 0 is the week the first day falls in, so that day keeps its real weekday row. The days
|
|
217
|
+
before it inside that week are simply not drawn — the grid starts where the data does. */
|
|
218
|
+
const origin = computed(() => (days.value.length ? days.value[0].day - rowOf(days.value[0].day) * DAY_MS : 0));
|
|
219
|
+
const cols = computed(() =>
|
|
220
|
+
days.value.length ? Math.floor((days.value[days.value.length - 1].day - origin.value) / (ROWS * DAY_MS)) + 1 : 0,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const width = computed(() => Math.max(cols.value * pitch.value - GAP, 0) + WEEKDAY_GUTTER);
|
|
224
|
+
|
|
225
|
+
/* Centred on the row it names: the cell's own middle, with the deck's cap-height correction rather
|
|
226
|
+
than `dominant-baseline: central`, which centres the em box and leaves a descender-less word
|
|
227
|
+
riding high — the same seating every other label in this theme takes. */
|
|
228
|
+
const weekdays = computed(() =>
|
|
229
|
+
Array.from({ length: ROWS }, (_, row) => ({
|
|
230
|
+
row,
|
|
231
|
+
name: WEEKDAY.format(new Date(WEEKDAY_SUNDAY + (props.weekStart === 'monday' ? row + 1 : row) * DAY_MS)),
|
|
232
|
+
y: LABEL_BAND + row * pitch.value + props.cell / 2,
|
|
233
|
+
})),
|
|
234
|
+
);
|
|
235
|
+
/*
|
|
236
|
+
★★ THE LAST ROW'S LETTER HANGS BELOW ITS OWN CELL, and the height has to carry it. A weekday is
|
|
237
|
+
centred on its row and set at the 36px axis tier, so its box reaches about 22 units below that
|
|
238
|
+
centre — further than a 26px cell's own 13. MEASURED by the audit before this: "S" on the bottom
|
|
239
|
+
row, 19% hidden, 9px cut off the bottom of the viewBox, on both calendar slides.
|
|
240
|
+
|
|
241
|
+
Expressed against `cell` rather than pinned, because the cell is a prop: at 44 or more the letter
|
|
242
|
+
fits inside its row and the drop is zero, which is the same arithmetic saying nothing is needed.
|
|
243
|
+
*/
|
|
244
|
+
const weekdayDrop = computed(() => Math.max(0, 22 - props.cell / 2));
|
|
245
|
+
const height = computed(() => LABEL_BAND + ROWS * pitch.value - GAP + weekdayDrop.value);
|
|
246
|
+
|
|
247
|
+
/* A pinned `max` has to be a real range: zero, a negative, or a NaN would divide the ramp into
|
|
248
|
+
five identical edges and paint every day the deepest shade under a key that reads "≤ 0". Falling
|
|
249
|
+
back to the data's own maximum is the honest reading of a pin that cannot be honoured.
|
|
250
|
+
`|| 1` then covers an all-zero year, which divides rather than producing NaN edges: every cell is
|
|
251
|
+
track, which is what a year of nothing looks like. */
|
|
252
|
+
const top = computed(() => {
|
|
253
|
+
const pinned = isPlottable(props.max) && props.max > 0 ? props.max : null;
|
|
254
|
+
return pinned ?? (Math.max(...days.value.map((d) => (isPlottable(d.value) ? d.value : 0)), 0) || 1);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
/*
|
|
258
|
+
★ THE FIFTHS ARE ROUNDED UP ONLY ABOVE FIVE, and five is the exact boundary rather than a taste.
|
|
259
|
+
|
|
260
|
+
`Math.ceil` on every edge collapsed the ramp for any small or fractional range. MEASURED, running
|
|
261
|
+
the old expression: top=1 gave [1,1,1,1,1] — five swatches in the key, all labelled "≤ 1", one
|
|
262
|
+
shade on the grid; top=2 gave [1,1,2,2,2]; top=3 gave [1,2,2,3,3], two duplicate labels and two
|
|
263
|
+
unreachable shades. A year of rates, percentages or availability figures hit it every time:
|
|
264
|
+
[0.4, 0.8, 1.0] rounded to [1,1,1,1,1] and drew flat. That falsified the header's own promise that
|
|
265
|
+
a decoded shade yields a number.
|
|
266
|
+
|
|
267
|
+
Two consecutive fifths land in the same integer as soon as top/5 < 1, and `ceil(top × 4/5) ≤ top`
|
|
268
|
+
needs top/5 ≥ 1 — the same threshold from both directions, so at or above five the rounded edges
|
|
269
|
+
are strictly increasing AND all sit under the top. Below it the edges stay exact and `format`
|
|
270
|
+
prints them. The last edge is `top` itself, not a fifth multiplied back: (904.290039095 × 5) / 5
|
|
271
|
+
is 904.2900390950001 in doubles, and an edge a float above or below the maximum would either
|
|
272
|
+
overstate the key or push the busiest day through the clamp below.
|
|
273
|
+
|
|
274
|
+
KNOWN CEILING: a range small enough that the default format renders several edges identically
|
|
275
|
+
(top = 0.001 prints four of them as "0") keys the swatches ambiguously. A caller with a range that
|
|
276
|
+
fine passes its own `format`.
|
|
277
|
+
|
|
278
|
+
Computed ONCE and read by both the fills and the legend — so a swatch can never claim an edge the
|
|
279
|
+
shading does not honour.
|
|
280
|
+
*/
|
|
281
|
+
const edges = computed(() => {
|
|
282
|
+
const t = top.value;
|
|
283
|
+
const rounded = t >= STEPS;
|
|
284
|
+
return Array.from({ length: STEPS }, (_, i) =>
|
|
285
|
+
i === STEPS - 1 ? t : rounded ? Math.ceil((t * (i + 1)) / STEPS) : (t * (i + 1)) / STEPS,
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
/* Null is the track: no reading, or nought. A value past the top edge — which now only happens when
|
|
290
|
+
`max` is pinned below the data — clamps into the deepest step rather than falling out of the
|
|
291
|
+
ramp. The clamp is not cosmetic: `var(--deck-shade-6)` is undeclared, and an undefined custom
|
|
292
|
+
property in a `fill` resolves to currentColor, so the day would paint the slide's INK and read as
|
|
293
|
+
a deliberate near-black. LondonMap.vue clamps its own shade for exactly this reason. */
|
|
294
|
+
const shadeOf = (v) => {
|
|
295
|
+
if (!isPlottable(v) || v <= 0) return null;
|
|
296
|
+
const i = edges.value.findIndex((e) => v <= e);
|
|
297
|
+
return i === -1 ? STEPS - 1 : i;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const cells = computed(() =>
|
|
301
|
+
days.value.map(({ day, value }, i) => {
|
|
302
|
+
const shade = shadeOf(value);
|
|
303
|
+
return {
|
|
304
|
+
key: `${day}-${i}`,
|
|
305
|
+
x: Math.floor((day - origin.value) / (ROWS * DAY_MS)) * pitch.value,
|
|
306
|
+
y: LABEL_BAND + rowOf(day) * pitch.value,
|
|
307
|
+
fill: shade === null ? 'var(--deck-surface)' : `var(--deck-shade-${shade + 1})`,
|
|
308
|
+
day,
|
|
309
|
+
value,
|
|
310
|
+
};
|
|
311
|
+
}),
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
/*
|
|
315
|
+
The tooltip is the whole reason to point at this chart: shade is the weaker channel by a long way
|
|
316
|
+
— the header says so — and a reader can see that a summer runs darker than a spring and cannot
|
|
317
|
+
rank two cells three weeks apart. The panel is where the number lives.
|
|
318
|
+
|
|
319
|
+
★ NOUGHT AND NO-READING ARE DIFFERENT SENTENCES, even though the grid paints them the same track
|
|
320
|
+
colour. That is the one ambiguity the drawing cannot resolve on its own, and it is exactly what a
|
|
321
|
+
reader points at a pale cell to ask.
|
|
322
|
+
|
|
323
|
+
The full date, not the short month the band carries: a label there marks the first week column
|
|
324
|
+
BEGINNING in its month and can sit up to six days early, so it names a region rather than a cell.
|
|
325
|
+
*/
|
|
326
|
+
const FULL_DATE = new Intl.DateTimeFormat('en-GB', {
|
|
327
|
+
weekday: 'short', day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC',
|
|
328
|
+
});
|
|
329
|
+
const tip = useDeckTip();
|
|
330
|
+
const tipFor = (c) => tipFrame(FULL_DATE.format(new Date(c.day)), [
|
|
331
|
+
{ swatch: c.fill, label: 'Value', value: isPlottable(c.value) ? props.format(c.value) : 'No reading' },
|
|
332
|
+
]);
|
|
333
|
+
|
|
334
|
+
/*
|
|
335
|
+
The ramp goes through ChartFrame's legend rather than the graded strip LondonMap.vue draws, and
|
|
336
|
+
the difference is what the key has to say. A choropleth's shade is a RANK between two named ends,
|
|
337
|
+
so its key shows the order and labels only the ends. Here every bucket has a numeric ceiling worth
|
|
338
|
+
stating, and five bullets carrying five numbers in ramp order is that statement — the same
|
|
339
|
+
furniture, the same tier, as every other chart's key on the next slide.
|
|
340
|
+
|
|
341
|
+
The ramp's names live here and nowhere else in this file's stack — the sequential set has no
|
|
342
|
+
accessor in chartData.ts the way `seriesVar()` owns the categorical one. LondonMap.vue is the
|
|
343
|
+
other consumer and clamps the same way; a third is the moment the clamp and the `var()` name
|
|
344
|
+
should move into chartData.ts, where a test can reach them.
|
|
345
|
+
*/
|
|
346
|
+
const legendSeries = computed(() =>
|
|
347
|
+
state.value.kind === 'ready'
|
|
348
|
+
? edges.value.map((edge, i) => ({
|
|
349
|
+
key: `shade-${i + 1}`,
|
|
350
|
+
name: `≤ ${props.format(edge)}`,
|
|
351
|
+
color: `var(--deck-shade-${i + 1})`,
|
|
352
|
+
}))
|
|
353
|
+
: [],
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
const MONTH = new Intl.DateTimeFormat('en-GB', { month: 'short', timeZone: 'UTC' });
|
|
357
|
+
|
|
358
|
+
/*
|
|
359
|
+
A label marks the first week column that BEGINS in its month, so it can sit up to six days early.
|
|
360
|
+
On a chart whose whole question is "which part of the year", that is under one cell of error, and
|
|
361
|
+
the alternative — pointing at the exact day — puts the word in the middle of a column with nothing
|
|
362
|
+
aligned to it.
|
|
363
|
+
|
|
364
|
+
Two rules keep the row readable at 36px. A label the next one would sit on top of is dropped: that
|
|
365
|
+
is almost always the stub column at the start, where the year's first week belongs to the previous
|
|
366
|
+
December. And the last label is right-anchored when it would otherwise run past the grid, which
|
|
367
|
+
happens on a year-to-date grid that ends within a fortnight of a month boundary — an SVG root
|
|
368
|
+
clips at its viewBox, so an overhanging label loses its final glyphs rather than overflowing.
|
|
369
|
+
*/
|
|
370
|
+
const months = computed(() => {
|
|
371
|
+
const found = [];
|
|
372
|
+
let previous = null;
|
|
373
|
+
for (let col = 0; col < cols.value; col += 1) {
|
|
374
|
+
const weekStart = new Date(origin.value + col * ROWS * DAY_MS);
|
|
375
|
+
const month = weekStart.getUTCMonth();
|
|
376
|
+
if (month !== previous) {
|
|
377
|
+
found.push({ key: `${weekStart.getUTCFullYear()}-${month}`, x: col * pitch.value, name: MONTH.format(weekStart) });
|
|
378
|
+
}
|
|
379
|
+
previous = month;
|
|
380
|
+
}
|
|
381
|
+
return found
|
|
382
|
+
.filter((label, i) => i === found.length - 1 || found[i + 1].x - label.x >= LABEL_W)
|
|
383
|
+
.map((label) =>
|
|
384
|
+
label.x + LABEL_W > width.value
|
|
385
|
+
? { ...label, x: width.value, anchor: 'end' }
|
|
386
|
+
: { ...label, anchor: 'start' },
|
|
387
|
+
);
|
|
388
|
+
});
|
|
389
|
+
</script>
|
|
390
|
+
|
|
391
|
+
<template>
|
|
392
|
+
<ChartFrame
|
|
393
|
+
:series="legendSeries"
|
|
394
|
+
:height="height"
|
|
395
|
+
:legend="legend"
|
|
396
|
+
:legend-position="legendPosition"
|
|
397
|
+
:message="message"
|
|
398
|
+
>
|
|
399
|
+
<!-- Drawn at its NATURAL size, with `max-width: 100%` to cope with a narrower column, rather
|
|
400
|
+
than stretched to the container: a quarter is fourteen columns, and a quarter stretched
|
|
401
|
+
across a full slide would draw 100px cells and read as a different chart entirely. A
|
|
402
|
+
calendar ends where its data ends. The height handed to ChartFrame is the grid's own, so an
|
|
403
|
+
empty state occupies the space the grid would have rather than a stock 480. -->
|
|
404
|
+
<svg
|
|
405
|
+
class="calgrid"
|
|
406
|
+
:viewBox="`0 0 ${width} ${height}`"
|
|
407
|
+
:width="width"
|
|
408
|
+
:height="height"
|
|
409
|
+
role="img"
|
|
410
|
+
aria-label="Daily values by date, shaded by amount"
|
|
411
|
+
@mouseleave="tip.hide()"
|
|
412
|
+
>
|
|
413
|
+
<!-- The weekday axis, OUTSIDE the translated grid: it sits in the gutter the grid is shifted
|
|
414
|
+
by, so its own x is measured from the viewBox rather than from the first column. -->
|
|
415
|
+
<text
|
|
416
|
+
v-for="d in weekdays"
|
|
417
|
+
:key="d.row"
|
|
418
|
+
class="calgrid-weekday"
|
|
419
|
+
:x="WEEKDAY_GUTTER - 16"
|
|
420
|
+
:y="d.y"
|
|
421
|
+
dy="0.35em"
|
|
422
|
+
text-anchor="end"
|
|
423
|
+
>
|
|
424
|
+
{{ d.name }}
|
|
425
|
+
</text>
|
|
426
|
+
<g :transform="`translate(${WEEKDAY_GUTTER}, 0)`">
|
|
427
|
+
<text
|
|
428
|
+
v-for="month in months"
|
|
429
|
+
:key="month.key"
|
|
430
|
+
class="calgrid-month"
|
|
431
|
+
:x="month.x"
|
|
432
|
+
:y="LABEL_BASELINE"
|
|
433
|
+
:text-anchor="month.anchor"
|
|
434
|
+
>
|
|
435
|
+
{{ month.name }}
|
|
436
|
+
</text>
|
|
437
|
+
<rect
|
|
438
|
+
v-for="c in cells"
|
|
439
|
+
:key="c.key"
|
|
440
|
+
class="calgrid-cell"
|
|
441
|
+
:x="c.x"
|
|
442
|
+
:y="c.y"
|
|
443
|
+
:width="cell"
|
|
444
|
+
:height="cell"
|
|
445
|
+
:fill="c.fill"
|
|
446
|
+
@mousemove="tip.show(tipFor(c), $event)"
|
|
447
|
+
/>
|
|
448
|
+
</g>
|
|
449
|
+
</svg>
|
|
450
|
+
</ChartFrame>
|
|
451
|
+
</template>
|
|
452
|
+
|
|
453
|
+
<style scoped>
|
|
454
|
+
.calgrid {
|
|
455
|
+
display: block;
|
|
456
|
+
max-width: 100%;
|
|
457
|
+
height: auto;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
.calgrid-cell {
|
|
461
|
+
/* A cell is a swatch, and the radius ladder has a step for exactly that (--deck-radius-item, 8px,
|
|
462
|
+
whose own note names code chips, tags and swatches) — set through the CSS geometry property
|
|
463
|
+
rather than an `rx` attribute so the corner follows the deck's one radius lever instead of
|
|
464
|
+
being a number this file invented. */
|
|
465
|
+
rx: var(--deck-radius-item);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/* The weekday takes the month band's tier exactly — they are one axis read two ways, and a
|
|
469
|
+
different size on the same chart would say they were different kinds of label. */
|
|
470
|
+
.calgrid-weekday,
|
|
471
|
+
.calgrid-month {
|
|
472
|
+
/* The same 36px tick-label tier ChartFrame gives every other chart's axis, in the same muted ink:
|
|
473
|
+
a month here does the job an x tick does there, and the two should not be different sizes on
|
|
474
|
+
consecutive slides. */
|
|
475
|
+
font-family: var(--font-sans);
|
|
476
|
+
font-size: var(--type-deck-body-sm-size);
|
|
477
|
+
font-weight: var(--type-deck-body-sm-weight);
|
|
478
|
+
fill: var(--deck-fg-muted);
|
|
479
|
+
}
|
|
480
|
+
</style>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/*
|
|
3
|
+
The deck's one container. A card is a ground and, if the author asks for one, an edge — never a
|
|
4
|
+
shadow — and its corner comes from the radius contract, so anything nested inside it lands on the
|
|
5
|
+
concentric step automatically (48 → 16 → 8, the same ladder .forest-card runs on the web).
|
|
6
|
+
|
|
7
|
+
A component rather than "remember to write class='deck-card'": the props are the point.
|
|
8
|
+
`background` is one of three kinds of ground, in order of how much the card claims. `transparent`
|
|
9
|
+
claims none — it borrows whatever it lands on and draws its edge in that ink, which is Badge-
|
|
10
|
+
Strip's `outline` applied to a container. `surface` is the derived step off the slide ground.
|
|
11
|
+
The four names are the table split's panel and stack's band read, so a card and a panel of the
|
|
12
|
+
same name are the same colour by construction; a named background re-points every ink inside the
|
|
13
|
+
card to the pair's own ink, so text stays legible without the author picking a colour.
|
|
14
|
+
|
|
15
|
+
`dark` renders the card in the dark register: the named fills flip to their dark half, and on
|
|
16
|
+
`surface` every token inside the card flips with it.
|
|
17
|
+
|
|
18
|
+
⚠ `dark` IS CLAMPED ON `transparent`, and silently doing nothing is the lesser evil here. The
|
|
19
|
+
class re-points the ink to the dark register's near-white, and a transparent card paints no fill
|
|
20
|
+
to put that ink on — so honouring it would land white text on whatever the card borrowed, which
|
|
21
|
+
on a light slide is the deck's own cream. The two props are independent everywhere else; this is
|
|
22
|
+
the one pair that cannot compose, because one of them names a fill the other one removes.
|
|
23
|
+
|
|
24
|
+
⚠ THIS WAS `variant: surface | accent`. The accent card painted `--deck-accent`, which is
|
|
25
|
+
forest-700 on light and forest-300 on dark — the vivid pair read AGAINST the register. That is
|
|
26
|
+
`background: vivid` with `dark: true` on a light slide, and the same pair pinned rather than
|
|
27
|
+
mirrored on a dark one; the mirrored reading lives on as `--deck-ink-<name>`, for strokes.
|
|
28
|
+
|
|
29
|
+
`border` is the edge, drawn in the card's own ink rather than a ground-derived token, and `none`
|
|
30
|
+
is the default: a card is a fill, and an edge is a thing you ask for. See base.css for the
|
|
31
|
+
measurements, and for the two backgrounds — `transparent` and `surface` — that have little else
|
|
32
|
+
to separate them from the page when you do not.
|
|
33
|
+
|
|
34
|
+
A named background also carries `deck-own-ground`: it paints its own fill, so a bare markdown
|
|
35
|
+
list inside it needs bullet/marker protection, and base.css's own-ground rules key off that one
|
|
36
|
+
class rather than a per-component name.
|
|
37
|
+
*/
|
|
38
|
+
import { BACKGROUND_NAMES, CARD_BACKGROUNDS } from './backgrounds.js';
|
|
39
|
+
|
|
40
|
+
const props = defineProps({
|
|
41
|
+
/** 'transparent' — no fill, an edge in the card's own ink · 'surface' — the quiet fill one step from the slide ground · 'warm' | 'accent' | 'vivid' | 'neutral' — a named background, the register picking the half */
|
|
42
|
+
background: {
|
|
43
|
+
type: String,
|
|
44
|
+
default: 'surface',
|
|
45
|
+
validator: (b) => CARD_BACKGROUNDS.includes(b),
|
|
46
|
+
},
|
|
47
|
+
/** Render the card in the dark register: a named background pins its dark half, `surface` becomes the dark ground's own step. No effect on `transparent`, which paints no fill to flip */
|
|
48
|
+
dark: { type: Boolean, default: false },
|
|
49
|
+
/** The edge, drawn in the card's own ink: 'none' (the default), 'hairline' 1px at 20%, 'strong' 4px at 40% */
|
|
50
|
+
border: {
|
|
51
|
+
type: String,
|
|
52
|
+
default: 'none',
|
|
53
|
+
validator: (e) => ['none', 'hairline', 'strong'].includes(e),
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<template>
|
|
59
|
+
<div
|
|
60
|
+
class="deck-card"
|
|
61
|
+
:class="[
|
|
62
|
+
BACKGROUND_NAMES.includes(props.background) ? `bg-${props.background} deck-own-ground` : '',
|
|
63
|
+
props.background === 'transparent' ? 'is-transparent' : '',
|
|
64
|
+
props.border === 'hairline' ? 'is-border-hairline' : props.border === 'strong' ? 'is-border-strong' : '',
|
|
65
|
+
props.dark && props.background !== 'transparent' ? 'deck-dark' : '',
|
|
66
|
+
]"
|
|
67
|
+
>
|
|
68
|
+
<slot />
|
|
69
|
+
</div>
|
|
70
|
+
</template>
|