@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,244 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/*
|
|
3
|
+
A table sized for a room.
|
|
4
|
+
|
|
5
|
+
The 65-page Forest template catalogue puts a data table on two of its pages, and this theme had
|
|
6
|
+
no analog — which meant a slide wanting one had to hand-roll `<table>` markup, the exact thing a
|
|
7
|
+
brand guardrail exists to prevent. So: a small number of rows and columns, a row tall enough to
|
|
8
|
+
track across 1920px, figures in the tabular treatment, and a header that reads from the back.
|
|
9
|
+
|
|
10
|
+
── FIGURES ────────────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
Mohr ships no tabular figure set — measured in packages/tokens/type/type.css, its "1" is 13.01px
|
|
12
|
+
against "0" at 23.66px, and font-variant-numeric moves neither — so a column of Mohr numerals
|
|
13
|
+
cannot line up, whatever CSS is applied. That is why `.type-tabular` (the engine's own treatment,
|
|
14
|
+
used here rather than restated) swaps the family to --font-sans as well as switching the figure
|
|
15
|
+
set on. This component composes that class onto its numeric cells and never declares a
|
|
16
|
+
font-family on a cell of its own, so the class is what actually wins.
|
|
17
|
+
|
|
18
|
+
Kpi.vue makes the OPPOSITE call for the same reason, and both are right: its figure is a single
|
|
19
|
+
shout with nothing to align against, so it keeps the display face; a table's whole job is
|
|
20
|
+
alignment down a column, so it takes the sans. The formatting itself is shared — cellParts()
|
|
21
|
+
runs formatMetric(), the same function Kpi.vue formats with — so a figure in a table and the same
|
|
22
|
+
figure in a KPI are notated identically.
|
|
23
|
+
|
|
24
|
+
── THE TWO CEILINGS ───────────────────────────────────────────────────────────────────────────
|
|
25
|
+
Rows truncate and say so; columns refuse to draw at all. components/tableRules.ts's own header
|
|
26
|
+
carries the full reasoning and the measurement behind ROW_CEILING — the short version is that
|
|
27
|
+
dropping a row is a shorter list, and dropping a column is a different claim.
|
|
28
|
+
|
|
29
|
+
Not a sortable, scrollable, paginated table. A slide has no cursor, and a presenter cannot hover.
|
|
30
|
+
*/
|
|
31
|
+
import { computed } from 'vue';
|
|
32
|
+
import { cellParts, tableMessage, tableState, truncationNotice } from './tableRules';
|
|
33
|
+
|
|
34
|
+
const props = defineProps({
|
|
35
|
+
/** The rows, in the order they should read — one plain object per row, keyed by column key. */
|
|
36
|
+
rows: { type: Array, required: true },
|
|
37
|
+
/** Columns in draw order: `[{ key, label, align, numeric, format, precision, unit }]`. Omit it and the columns are read off the rows' own keys. */
|
|
38
|
+
columns: { type: Array, default: undefined },
|
|
39
|
+
/** How many rows to draw before the rest are withheld and counted on the slide. Clamped to 1…7; the default 5 is what fits under a heading, and 7 needs a slide with no heading at all. */
|
|
40
|
+
maxRows: { type: Number, default: 5 },
|
|
41
|
+
/** Default notation for every numeric column: 'full' spells out every digit, 'compact' abbreviates (1.2m, 999k). A column's own `format` overrides it. */
|
|
42
|
+
format: { type: String, default: 'full', validator: (f) => ['full', 'compact'].includes(f) },
|
|
43
|
+
/** Locale the figures are formatted in, e.g. 'en-GB'. */
|
|
44
|
+
locale: { type: String, default: 'en-GB' },
|
|
45
|
+
/** A source line under the table, e.g. "TfL journey data, Q2 2026". Rendered, never a tooltip. */
|
|
46
|
+
note: { type: String, default: '' },
|
|
47
|
+
/** The sentence drawn in place of the table when there is nothing to draw. */
|
|
48
|
+
empty: { type: String, default: 'No data' },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const state = computed(() => tableState(props.rows, props.columns, props.maxRows, props.format));
|
|
52
|
+
const message = computed(() => (state.value.kind === 'ready' ? null : tableMessage(state.value, props.empty)));
|
|
53
|
+
const notice = computed(() =>
|
|
54
|
+
state.value.kind === 'ready' ? truncationNotice(state.value.total, state.value.rows.length) : null,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
/* The whole table, resolved once: a row of `{ column, text, unit }` per data row. Built here rather
|
|
58
|
+
than called from the template so cellParts() runs once per cell instead of once per interpolation
|
|
59
|
+
— and so the shape the template walks is the same shape the tests assert on. */
|
|
60
|
+
const grid = computed(() =>
|
|
61
|
+
state.value.kind === 'ready'
|
|
62
|
+
? state.value.rows.map((row) =>
|
|
63
|
+
state.value.columns.map((column) => ({ column, ...cellParts(row?.[column.key], column, props.locale) })),
|
|
64
|
+
)
|
|
65
|
+
: [],
|
|
66
|
+
);
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<template>
|
|
70
|
+
<table v-if="state.kind === 'ready'" class="deck-data-table">
|
|
71
|
+
<!-- A real <caption>, not a sibling <div>: it belongs to the table, so a screen reader reaches
|
|
72
|
+
it with the table and it cannot drift away from the figure it qualifies. `caption-side`
|
|
73
|
+
puts it under the rows, which is where a source line and a truncation notice read. -->
|
|
74
|
+
<caption v-if="notice || note" class="deck-data-table-caption">
|
|
75
|
+
<span v-if="notice" class="deck-data-table-truncation">{{ notice }}</span>
|
|
76
|
+
<span v-if="notice && note" class="deck-data-table-sep" aria-hidden="true"> · </span>
|
|
77
|
+
<span v-if="note" class="deck-data-table-note">{{ note }}</span>
|
|
78
|
+
</caption>
|
|
79
|
+
|
|
80
|
+
<thead>
|
|
81
|
+
<tr>
|
|
82
|
+
<th
|
|
83
|
+
v-for="column in state.columns"
|
|
84
|
+
:key="column.key"
|
|
85
|
+
scope="col"
|
|
86
|
+
class="deck-data-table-head"
|
|
87
|
+
:class="`is-${column.align}`"
|
|
88
|
+
>{{ column.label }}</th>
|
|
89
|
+
</tr>
|
|
90
|
+
</thead>
|
|
91
|
+
|
|
92
|
+
<tbody>
|
|
93
|
+
<tr v-for="(cells, index) in grid" :key="index">
|
|
94
|
+
<!-- The first cell is the row's NAME, so it is a <th scope="row"> — the rest are data.
|
|
95
|
+
Semantics, not decoration: it is what lets a figure be announced with the row it
|
|
96
|
+
belongs to instead of on its own. No whitespace between a value and its unit — the
|
|
97
|
+
0.1em optical gap is set in CSS, because "12 %" is wrong in en-GB and a markup space
|
|
98
|
+
cannot be taken back. A multi-letter unit ("km") reads better named in the header. -->
|
|
99
|
+
<template v-for="(cell, position) in cells" :key="cell.column.key">
|
|
100
|
+
<th
|
|
101
|
+
v-if="position === 0"
|
|
102
|
+
scope="row"
|
|
103
|
+
class="deck-data-table-row-head"
|
|
104
|
+
:class="[`is-${cell.column.align}`, cell.column.numeric ? 'type-tabular' : null]"
|
|
105
|
+
>{{ cell.text }}<span v-if="cell.unit" class="deck-data-table-unit">{{ cell.unit }}</span></th>
|
|
106
|
+
<td
|
|
107
|
+
v-else
|
|
108
|
+
class="deck-data-table-cell"
|
|
109
|
+
:class="[`is-${cell.column.align}`, cell.column.numeric ? 'type-tabular' : null]"
|
|
110
|
+
>{{ cell.text }}<span v-if="cell.unit" class="deck-data-table-unit">{{ cell.unit }}</span></td>
|
|
111
|
+
</template>
|
|
112
|
+
</tr>
|
|
113
|
+
</tbody>
|
|
114
|
+
</table>
|
|
115
|
+
|
|
116
|
+
<!-- Same well ChartFrame.vue draws for a chart with nothing to plot: the honest empty state
|
|
117
|
+
occupies space rather than collapsing, so a slide does not silently look finished. -->
|
|
118
|
+
<div v-else class="deck-data-table-empty">{{ message }}</div>
|
|
119
|
+
</template>
|
|
120
|
+
|
|
121
|
+
<style scoped>
|
|
122
|
+
/* Every rule here has to out-rank base.css's own `.slidev-layout table/th/td` block, which is sized
|
|
123
|
+
for a markdown table in a paragraph — 8px of padding, muted cell ink. A scoped compound selector
|
|
124
|
+
(`.deck-data-table th` → `.deck-data-table th[data-v-…]`, 0-2-1) clears `.slidev-layout th`
|
|
125
|
+
(0-1-1) on specificity alone, so nothing here needs !important and nothing depends on order. */
|
|
126
|
+
.deck-data-table {
|
|
127
|
+
width: 100%;
|
|
128
|
+
/* `separate` with zero spacing, not `collapse`: collapse resolves adjacent borders against each
|
|
129
|
+
other and the header's accent rule loses half its weight to the first row's own border. */
|
|
130
|
+
border-collapse: separate;
|
|
131
|
+
border-spacing: 0;
|
|
132
|
+
/* Declared once, on the table — never on a cell. A numeric cell carries `.type-tabular`, whose
|
|
133
|
+
own font-family beats an INHERITED value but would lose to a scoped per-cell declaration. */
|
|
134
|
+
font-family: var(--type-deck-body-sm-family);
|
|
135
|
+
font-size: var(--type-deck-body-sm-size);
|
|
136
|
+
line-height: var(--type-deck-body-sm-line-height);
|
|
137
|
+
color: var(--deck-fg);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.deck-data-table th,
|
|
141
|
+
.deck-data-table td {
|
|
142
|
+
/* The row height. 24px above and below 36px type at 1.5 measures 104px including the rule — a
|
|
143
|
+
band the eye can track across 1920px, and the figure ROW_CEILING is derived from. */
|
|
144
|
+
padding-block: var(--deck-space-3);
|
|
145
|
+
padding-inline: var(--deck-space-4);
|
|
146
|
+
vertical-align: baseline;
|
|
147
|
+
}
|
|
148
|
+
/* Flush at both edges, so the table's first character sits on the same vertical as the slide's
|
|
149
|
+
heading. The gutter is BETWEEN columns; it is not a margin on the block. */
|
|
150
|
+
.deck-data-table tr > :first-child { padding-inline-start: 0; }
|
|
151
|
+
.deck-data-table tr > :last-child { padding-inline-end: 0; }
|
|
152
|
+
|
|
153
|
+
.deck-data-table .is-start { text-align: start; }
|
|
154
|
+
.deck-data-table .is-center { text-align: center; }
|
|
155
|
+
.deck-data-table .is-end { text-align: end; }
|
|
156
|
+
|
|
157
|
+
.deck-data-table-head {
|
|
158
|
+
font-size: var(--type-deck-overline-size);
|
|
159
|
+
font-weight: var(--type-deck-overline-weight);
|
|
160
|
+
letter-spacing: var(--type-deck-overline-tracking);
|
|
161
|
+
line-height: var(--type-deck-overline-line-height);
|
|
162
|
+
text-transform: uppercase;
|
|
163
|
+
color: var(--deck-fg-muted);
|
|
164
|
+
/* 3px, not base.css's 2: this is the one rule in the table that has to read as a division rather
|
|
165
|
+
than a separator, and a 2px line at presenting scale is a hairline. */
|
|
166
|
+
border-bottom: 3px solid var(--deck-accent);
|
|
167
|
+
white-space: nowrap;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.deck-data-table tbody tr td,
|
|
171
|
+
.deck-data-table tbody tr th {
|
|
172
|
+
border-bottom: 2px solid var(--deck-border);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.deck-data-table-row-head {
|
|
176
|
+
font-weight: var(--type-deck-label-weight);
|
|
177
|
+
color: var(--deck-fg);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.deck-data-table-cell {
|
|
181
|
+
/* Full ink, not base.css's --deck-fg-muted: a muted figure is a readable choice in a document and
|
|
182
|
+
the wrong one across a room, where the cell IS the message. */
|
|
183
|
+
color: var(--deck-fg);
|
|
184
|
+
}
|
|
185
|
+
/* Figures carry weight — the column an audience is actually reading. */
|
|
186
|
+
.deck-data-table-cell.type-tabular {
|
|
187
|
+
font-weight: var(--type-deck-label-weight);
|
|
188
|
+
}
|
|
189
|
+
/* Full size, NOT the 0.5em shrink Kpi.vue gives its own unit, and the difference is the tier: Kpi's
|
|
190
|
+
figure is 96px, so a half-size unit is still 48px and reads as a considered pair. At a 36px cell
|
|
191
|
+
the same move produced a 25px glyph that read as a typo — rendered and looked at. The unit is
|
|
192
|
+
distinguished by ink here instead, which costs the figure no height and no alignment. */
|
|
193
|
+
.deck-data-table-unit {
|
|
194
|
+
margin-inline-start: 0.1em;
|
|
195
|
+
color: var(--deck-fg-muted);
|
|
196
|
+
font-weight: var(--type-deck-body-sm-weight);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.deck-data-table-caption {
|
|
200
|
+
caption-side: bottom;
|
|
201
|
+
text-align: start;
|
|
202
|
+
/* space-2, not space-3. The caption's own height budget below is measured against the safe-area
|
|
203
|
+
floor, and the same arithmetic applies above it: at space-3 the truncation notice sat 92px from
|
|
204
|
+
the bottom edge, which cleared the old 90px text inset and misses the current 96px one by 4px.
|
|
205
|
+
scripts/deckAudit.ts caught it on slides 50, 51 and 52. 16px still separates the caption from
|
|
206
|
+
the last row by a full rung and buys 8px of clearance. */
|
|
207
|
+
padding-top: var(--deck-space-2);
|
|
208
|
+
font-size: var(--type-deck-caption-size);
|
|
209
|
+
line-height: var(--type-deck-caption-line-height);
|
|
210
|
+
}
|
|
211
|
+
/* ONE wrapping line, not a stacked pair, and that is a measured decision rather than a preference:
|
|
212
|
+
a caption line costs 50px, and 307 (heading) + 68 (header row) + 5 x 104 (rows) + two caption
|
|
213
|
+
lines is 1019 against a 990 safe-area floor — scripts/deckAudit.ts caught exactly that, 30px past
|
|
214
|
+
the bottom inset, on the first pass of this component. Inline spans on one line cost 74px total
|
|
215
|
+
and still wrap honestly on a narrow column, where the overflow would be visible rather than
|
|
216
|
+
silent. Nothing here sets `display`: a <caption> is a table-caption box, and forcing it to flex
|
|
217
|
+
takes it out of the table's own caption flow. */
|
|
218
|
+
/* The truncation notice takes the accent and the row-label weight, one step louder than the source
|
|
219
|
+
line beside it: "there is more data than this" is a statement about the figure itself, not a
|
|
220
|
+
footnote about where it came from. */
|
|
221
|
+
.deck-data-table-truncation {
|
|
222
|
+
font-weight: var(--type-deck-label-weight);
|
|
223
|
+
color: var(--deck-accent);
|
|
224
|
+
}
|
|
225
|
+
.deck-data-table-sep,
|
|
226
|
+
.deck-data-table-note {
|
|
227
|
+
color: var(--deck-fg-subtle);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* Lifted from ChartFrame.vue's own empty state, deliberately identical: a table with nothing to
|
|
231
|
+
draw and a chart with nothing to plot are the same failure and should look like it. */
|
|
232
|
+
.deck-data-table-empty {
|
|
233
|
+
display: flex;
|
|
234
|
+
align-items: center;
|
|
235
|
+
justify-content: center;
|
|
236
|
+
text-align: center;
|
|
237
|
+
padding: var(--deck-space-5);
|
|
238
|
+
background: var(--deck-surface);
|
|
239
|
+
border-radius: var(--deck-radius-nested);
|
|
240
|
+
color: var(--deck-fg-muted);
|
|
241
|
+
font-size: var(--type-deck-body-size);
|
|
242
|
+
line-height: var(--type-deck-body-line-height);
|
|
243
|
+
}
|
|
244
|
+
</style>
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/*
|
|
3
|
+
A LIVE Mapbox GL map on a slide, on Forest's own house style.
|
|
4
|
+
|
|
5
|
+
── WHY LIVE, AND NOT A PICTURE ────────────────────────────────────────────────────────────────
|
|
6
|
+
The Static Images API was tried first and DROPPED: on the Forest style it answers HTTP 200 with a
|
|
7
|
+
valid PNG that draws nothing — the style imports Standard, and Standard's imports do not compose
|
|
8
|
+
through the static renderer. A screenshot pasted into a deck goes stale the day the style is
|
|
9
|
+
republished, and cannot carry the marks a slide wants to put on it. So this is the real GL map.
|
|
10
|
+
|
|
11
|
+
── THE THREE THINGS THAT MAKE A GL MAP SAFE IN A DECK ─────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
★★ `preserveDrawingBuffer: true`, OR THE EXPORT IS BLANK. `slidev export` drives a real browser
|
|
14
|
+
and screenshots it. WebGL is allowed to discard the drawing buffer after each composite, and by
|
|
15
|
+
the time the PDF renderer reads the canvas back there is nothing in it — measured: a map that is
|
|
16
|
+
correct on screen prints as an empty rectangle, with no error anywhere. The flag costs a little
|
|
17
|
+
memory and is the whole reason a map can be in a deck at all.
|
|
18
|
+
|
|
19
|
+
★ NOT INTERACTIVE BY DEFAULT. A presenter scrolling past a slide should not leave the map at some
|
|
20
|
+
other city for the rest of the talk, and a stray trackpad nudge during a talk is unrecoverable in
|
|
21
|
+
front of a room. `interactive` opts back in for a live demo.
|
|
22
|
+
|
|
23
|
+
★ AN HONEST EMPTY STATE. A map with no token is a blank rectangle that looks like a bug in the
|
|
24
|
+
deck. Without `VITE_MAPBOX_TOKEN` — which is the case in CI, deliberately, since no token is
|
|
25
|
+
committed — this draws a panel saying exactly that instead. It keeps the audit deterministic: the
|
|
26
|
+
gate measures the same geometry on every run rather than depending on a network and a secret.
|
|
27
|
+
|
|
28
|
+
── THE TOKEN ─────────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
Read from `import.meta.env.VITE_MAPBOX_TOKEN`, the same name the rest of this repo uses, or passed
|
|
30
|
+
per-slide as `access-token`. A Mapbox `pk.…` token is a PUBLIC token and is designed to ship in a
|
|
31
|
+
client bundle — but note it BAKES INTO `dist/` at build time, so a deck built with one and then
|
|
32
|
+
handed to somebody carries it. Scope and restrict the token by URL in the Mapbox account; do not
|
|
33
|
+
reach for a secret `sk.…` token here, which must never be in a bundle.
|
|
34
|
+
|
|
35
|
+
The style is imported rather than restated: packages/ui/src/map/style.ts is a pure module with no
|
|
36
|
+
imports of its own, so the deck can share the VALUE without pulling in the product's component
|
|
37
|
+
layer — the same reach across the package boundary Kpi.vue and IconTile.vue already make.
|
|
38
|
+
*/
|
|
39
|
+
import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
|
|
40
|
+
import mapboxgl from 'mapbox-gl';
|
|
41
|
+
import 'mapbox-gl/dist/mapbox-gl.css';
|
|
42
|
+
import { FOREST_MAP_STYLE } from '@humanforest/ui/map/style';
|
|
43
|
+
|
|
44
|
+
const props = defineProps({
|
|
45
|
+
/** Centre longitude. Defaults to central London, which is the only city this deck is ever about. */
|
|
46
|
+
lng: { type: Number, default: -0.1005 },
|
|
47
|
+
/** Centre latitude. */
|
|
48
|
+
lat: { type: Number, default: 51.5155 },
|
|
49
|
+
/** Zoom. 10 holds most of Greater London; 13 is a borough; 15 is a street. */
|
|
50
|
+
zoom: { type: Number, default: 11 },
|
|
51
|
+
/** Tilt in degrees. Above 45 the horizon enters the frame and a slide loses its edge. */
|
|
52
|
+
pitch: { type: Number, default: 0 },
|
|
53
|
+
/** Rotation in degrees. */
|
|
54
|
+
bearing: { type: Number, default: 0 },
|
|
55
|
+
/** Mapbox style URL. Defaults to Forest's own house style. */
|
|
56
|
+
mapStyle: { type: String, default: FOREST_MAP_STYLE },
|
|
57
|
+
/**
|
|
58
|
+
* How the basemap is lit: `day` or `night`. This is what makes a map read as dark — the style is
|
|
59
|
+
* the same Forest basemap either way, so a dark slide keeps the brand map rather than swapping
|
|
60
|
+
* to a second one that would have to be kept in sync.
|
|
61
|
+
*
|
|
62
|
+
* ★ TWO VALUES, WHERE THE BASEMAP OFFERS FOUR. Mapbox also lights `dawn` and `dusk`, and
|
|
63
|
+
* packages/ui's FMap passes all four through because a product surface can want a time of day.
|
|
64
|
+
* A deck has one axis and it is the REGISTER: a slide is light or it is `deck-dark`, and there is
|
|
65
|
+
* no dusk register for a dusk map to belong to. Narrowed here rather than passed through, because
|
|
66
|
+
* a specimen book has to draw every value it claims and two of those four would be drawn to
|
|
67
|
+
* satisfy a gate rather than to show anything.
|
|
68
|
+
*
|
|
69
|
+
* ★ NO `auto` EITHER, where FMap has one. That follows the APP's colour mode; a deck has none —
|
|
70
|
+
* the register belongs to the slide — so a slide that wants a dark map says which one.
|
|
71
|
+
*
|
|
72
|
+
* ★★ APPLIED AT CREATION, NOT AFTER `style.load`. Setting it later paints the map in day and
|
|
73
|
+
* corrects it a beat afterwards, which is a visible flash of the wrong register on every load —
|
|
74
|
+
* and on an exported slide it is whichever frame the capture happened to catch.
|
|
75
|
+
*/
|
|
76
|
+
lightPreset: {
|
|
77
|
+
type: String,
|
|
78
|
+
default: 'day',
|
|
79
|
+
validator: (v) => ['day', 'night'].includes(v),
|
|
80
|
+
},
|
|
81
|
+
/** Public `pk.…` token. Falls back to `VITE_MAPBOX_TOKEN` from the build environment. */
|
|
82
|
+
accessToken: { type: String, default: '' },
|
|
83
|
+
/** Plot height in px. */
|
|
84
|
+
/** Height in px. **0 fills the parent** — see `.deck-map.is-fill` below for when to reach for it. */
|
|
85
|
+
height: { type: Number, default: 560 },
|
|
86
|
+
/** Let the presenter pan and zoom. Off by default — see the note above. */
|
|
87
|
+
interactive: { type: Boolean, default: false },
|
|
88
|
+
/**
|
|
89
|
+
* Points to draw, `[{ lng, lat }]`. Rendered as one GeoJSON circle layer in the deck accent
|
|
90
|
+
* rather than as N `mapboxgl.Marker` DOM nodes: markers are absolutely-positioned HTML that the
|
|
91
|
+
* geometry pass would have to measure and the export would have to composite separately, and a
|
|
92
|
+
* layer is one paint inside the canvas the rest of the slide already accounts for.
|
|
93
|
+
*/
|
|
94
|
+
marks: { type: Array, default: () => [] },
|
|
95
|
+
/** Sentence drawn in place of the map when it cannot be shown. */
|
|
96
|
+
empty: { type: String, default: 'No Mapbox token in this build — set VITE_MAPBOX_TOKEN to draw the map.' },
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const token = props.accessToken || import.meta.env?.VITE_MAPBOX_TOKEN || mapboxgl.accessToken || '';
|
|
100
|
+
const holder = ref(null);
|
|
101
|
+
const map = shallowRef(null);
|
|
102
|
+
/* A style that never arrives leaves a blank rectangle: a revoked token, a bad style id, a room with
|
|
103
|
+
no network. Held as a message so the frame can say WHICH rather than showing nothing. */
|
|
104
|
+
const ready = ref(false);
|
|
105
|
+
const failure = ref(token ? null : props.empty);
|
|
106
|
+
|
|
107
|
+
const MARK_SOURCE = 'deck-map-marks';
|
|
108
|
+
|
|
109
|
+
function addMarks(m) {
|
|
110
|
+
if (!props.marks.length) return;
|
|
111
|
+
m.addSource(MARK_SOURCE, {
|
|
112
|
+
type: 'geojson',
|
|
113
|
+
data: {
|
|
114
|
+
type: 'FeatureCollection',
|
|
115
|
+
features: props.marks.map((p) => ({
|
|
116
|
+
type: 'Feature',
|
|
117
|
+
geometry: { type: 'Point', coordinates: [p.lng, p.lat] },
|
|
118
|
+
properties: {},
|
|
119
|
+
})),
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
m.addLayer({
|
|
123
|
+
id: `${MARK_SOURCE}-circle`,
|
|
124
|
+
type: 'circle',
|
|
125
|
+
source: MARK_SOURCE,
|
|
126
|
+
/*
|
|
127
|
+
STACKING ORDER, and only that — the black marks below were a lighting problem, not this one.
|
|
128
|
+
The Forest style imports Mapbox Standard, which is SLOTTED: a layer added with no slot sits
|
|
129
|
+
low in the stack and the basemap's own labels can print over it. `top` is the value
|
|
130
|
+
packages/ui names MAP_SLOT.mark — "every mark: vehicles, places, counted bays, clusters,
|
|
131
|
+
selection rings" — spelled as a literal here rather than imported, for the same reason the
|
|
132
|
+
rest of this deck shares VALUES with the product and not runtime.
|
|
133
|
+
*/
|
|
134
|
+
slot: 'top',
|
|
135
|
+
paint: {
|
|
136
|
+
// Read off the deck's own custom properties at mount. A GL paint property is a value in a
|
|
137
|
+
// WebGL program, not a CSS declaration, so it cannot be a var() reference and does not follow
|
|
138
|
+
// a register flip on its own — which is why a deck map is a light-register device.
|
|
139
|
+
'circle-radius': 10,
|
|
140
|
+
'circle-color': readVar('--deck-accent', '#046c4c'),
|
|
141
|
+
'circle-stroke-width': 4,
|
|
142
|
+
'circle-stroke-color': readVar('--deck-bg', '#fff9f2'),
|
|
143
|
+
/*
|
|
144
|
+
★★ EMISSIVE, OR THE NIGHT PRESET LIGHTS THE MARK INTO THE GROUND. Mapbox Standard is a LIT
|
|
145
|
+
style: it carries a 3D lighting model, and a layer at the default emissive strength of 0 is
|
|
146
|
+
shaded by the scene like anything else in it. Under `lightPreset: night` the ambient light
|
|
147
|
+
is nearly black, so a mark declared #3bd996 rendered rgb(0,14,24) — measured at all four
|
|
148
|
+
projected centres, while `getPaintProperty` cheerfully reported the bright green it had been
|
|
149
|
+
given. The colour was never wrong and the slot was never wrong; the mark was being LIT.
|
|
150
|
+
|
|
151
|
+
1 means "emit this colour, ignore the scene", which is what a mark is: it belongs to the
|
|
152
|
+
deck's palette rather than to the basemap's world, and it has to read the same on both
|
|
153
|
+
presets. Costs nothing on `day`, where the ambient light is already near white.
|
|
154
|
+
*/
|
|
155
|
+
'circle-emissive-strength': 1,
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/*
|
|
161
|
+
★★ A DECK COLOUR REACHES MAPBOX ONLY AFTER BEING CONVERTED OUT OF P3. This design system serves
|
|
162
|
+
its palette in Display P3 — `getComputedStyle` hands back `color(display-p3 .1815 .4169 .3069)` —
|
|
163
|
+
and Mapbox's style spec parser accepts hex, rgb(), hsl() and the named colours, nothing else. It
|
|
164
|
+
does not merely ignore what it cannot parse: `addLayer` THROWS, the style never finishes loading,
|
|
165
|
+
and the whole map is blank. Measured, and it is the exact error this component's own panel
|
|
166
|
+
reported: `layers.deck-map-marks-circle.paint.circle-color: color expected,
|
|
167
|
+
"color(display-p3 .1815 .4169 .3069)" found`.
|
|
168
|
+
|
|
169
|
+
The canvas 2D context is the conversion, and it needs no library. That narrowing is a real loss —
|
|
170
|
+
a P3 green outside the sRGB gamut comes back clamped — but it is the right loss here, because a
|
|
171
|
+
WebGL basemap is being asked for an sRGB value either way.
|
|
172
|
+
|
|
173
|
+
★★ READING `fillStyle` BACK IS NO LONGER THE CONVERSION, AND THAT CHANGED UNDER US. This used to
|
|
174
|
+
assign the colour and return `ctx.fillStyle`, on the documented behaviour that the property
|
|
175
|
+
normalises what it stores to `#rrggbb`. Current Chrome PRESERVES the `color()` form: measured,
|
|
176
|
+
assigning `color(display-p3 0.1815 0.4169 0.3069)` reads back byte-identical, not as a hex. So
|
|
177
|
+
this handed Mapbox the very string the block above says it cannot parse, `addLayer` threw, and
|
|
178
|
+
the marks silently stopped drawing — `getSource` still returned the source, because a source
|
|
179
|
+
carries no colour, and `getLayer` returned undefined. Nothing was logged: the throw happens
|
|
180
|
+
inside the `load` handler, and the component's own error path only reports what Mapbox emits.
|
|
181
|
+
|
|
182
|
+
RASTERISING is the conversion that survives: fill one pixel and read it back, which forces the
|
|
183
|
+
paint through sRGB whatever the serialization does. Alpha is carried through as `rgba()` because
|
|
184
|
+
the style spec takes that and a hex would drop it.
|
|
185
|
+
*/
|
|
186
|
+
function toSrgb(value, fallback) {
|
|
187
|
+
try {
|
|
188
|
+
const canvas = document.createElement('canvas');
|
|
189
|
+
canvas.width = 1;
|
|
190
|
+
canvas.height = 1;
|
|
191
|
+
const ctx = canvas.getContext('2d');
|
|
192
|
+
if (!ctx) return fallback;
|
|
193
|
+
// A context's initial fillStyle is '#000000'; if the assignment is rejected as unparseable the
|
|
194
|
+
// property keeps its previous value, so an unchanged black is how a failure shows up here.
|
|
195
|
+
ctx.fillStyle = '#000000';
|
|
196
|
+
ctx.fillStyle = value;
|
|
197
|
+
if (ctx.fillStyle === '#000000' && value.trim() !== '#000000') return fallback;
|
|
198
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
199
|
+
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
|
|
200
|
+
const hex = (n) => n.toString(16).padStart(2, '0');
|
|
201
|
+
return a === 255 ? `#${hex(r)}${hex(g)}${hex(b)}` : `rgba(${r}, ${g}, ${b}, ${Number((a / 255).toFixed(3))})`;
|
|
202
|
+
} catch {
|
|
203
|
+
return fallback;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function readVar(name, fallback) {
|
|
208
|
+
if (typeof window === 'undefined' || !holder.value) return fallback;
|
|
209
|
+
const v = getComputedStyle(holder.value).getPropertyValue(name).trim();
|
|
210
|
+
return v ? toSrgb(v, fallback) : fallback;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let sizeWatcher = null;
|
|
214
|
+
|
|
215
|
+
onMounted(() => {
|
|
216
|
+
if (!token || !holder.value) return;
|
|
217
|
+
mapboxgl.accessToken = token;
|
|
218
|
+
let m;
|
|
219
|
+
try {
|
|
220
|
+
m = new mapboxgl.Map({
|
|
221
|
+
container: holder.value,
|
|
222
|
+
style: props.mapStyle,
|
|
223
|
+
center: [props.lng, props.lat],
|
|
224
|
+
zoom: props.zoom,
|
|
225
|
+
pitch: props.pitch,
|
|
226
|
+
bearing: props.bearing,
|
|
227
|
+
interactive: props.interactive,
|
|
228
|
+
config: { basemap: { lightPreset: props.lightPreset } },
|
|
229
|
+
// ★★ See the header. Without this the exported PDF is an empty rectangle.
|
|
230
|
+
preserveDrawingBuffer: true,
|
|
231
|
+
// A deck is never a place for the Mapbox logo to be clicked, but attribution is a licence
|
|
232
|
+
// term rather than a design choice — it stays, compact.
|
|
233
|
+
attributionControl: true,
|
|
234
|
+
});
|
|
235
|
+
} catch (e) {
|
|
236
|
+
failure.value = `Map could not start — ${String(e?.message ?? e)}`;
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
map.value = m;
|
|
240
|
+
/*
|
|
241
|
+
★★ ONLY A PRE-LOAD ERROR IS FATAL, and getting this wrong DESTROYS A WORKING MAP. Mapbox emits
|
|
242
|
+
`error` for plenty of things that are not the map failing: a blocked telemetry beacon
|
|
243
|
+
(events.mapbox.com answering with status 0 behind a content blocker — measured, that is exactly
|
|
244
|
+
what happens in the audit's own browser), a single tile that 404s, a sprite that arrives late.
|
|
245
|
+
Treating every one of them as fatal set `failure`, which swapped the holder out for the panel —
|
|
246
|
+
and because the holder is the map's CONTAINER, a live map with its style loaded and its tiles
|
|
247
|
+
painted was torn out of the document because a beacon was refused.
|
|
248
|
+
|
|
249
|
+
After `load` the map is a map. An error past that point is logged for whoever opens the console
|
|
250
|
+
and changes nothing on the slide.
|
|
251
|
+
*/
|
|
252
|
+
m.on('error', (e) => {
|
|
253
|
+
const message = String(e?.error?.message ?? 'style or token rejected');
|
|
254
|
+
if (ready.value || failure.value) {
|
|
255
|
+
console.warn(`DeckMap: non-fatal Mapbox error after load — ${message}`);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
failure.value = `Map failed to load — ${message}`;
|
|
259
|
+
});
|
|
260
|
+
m.on('load', () => {
|
|
261
|
+
ready.value = true;
|
|
262
|
+
addMarks(m);
|
|
263
|
+
// Slidev mounts neighbouring slides off-screen, where a container can measure zero. One resize
|
|
264
|
+
// once the style is up is what stops a map arriving at 0x0 and staying there.
|
|
265
|
+
m.resize();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
/*
|
|
269
|
+
★★ AND A RESIZE OBSERVER, BECAUSE ONE RESIZE ON `load` IS NOT ENOUGH — measured, not guessed.
|
|
270
|
+
Slidev mounts neighbouring slides, so this map is usually built while its slide is off-screen and
|
|
271
|
+
the container measures zero. Mapbox does not wait: it falls back to its default 400x300 canvas.
|
|
272
|
+
The `load` handler above then fires while the container is STILL zero, so the one corrective
|
|
273
|
+
resize corrects nothing and the canvas keeps 400x300 for the life of the slide.
|
|
274
|
+
|
|
275
|
+
Reproduced by arriving the way a presenter does, transitioning in from the slide before rather
|
|
276
|
+
than loading the URL directly: container 1740x520, canvas 400x300. A direct load looks perfect,
|
|
277
|
+
which is exactly why this survived — the map is only wrong on the path nobody tests.
|
|
278
|
+
|
|
279
|
+
On screen that is a strip of tiles filling the left quarter of a full-width grey box, which
|
|
280
|
+
reads as a half-loaded map rather than as a sizing bug.
|
|
281
|
+
|
|
282
|
+
So watch the box instead of guessing when it settles. The guard is on a NON-ZERO measurement:
|
|
283
|
+
the observer fires for the 0x0 state too, and resizing to zero is what put us here. No loop is
|
|
284
|
+
possible — resize() changes the CANVAS, never the holder this observes.
|
|
285
|
+
*/
|
|
286
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
287
|
+
sizeWatcher = new ResizeObserver(() => {
|
|
288
|
+
const el = holder.value;
|
|
289
|
+
if (!el || !map.value) return;
|
|
290
|
+
if (el.clientWidth > 0 && el.clientHeight > 0) map.value.resize();
|
|
291
|
+
});
|
|
292
|
+
sizeWatcher.observe(holder.value);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
onBeforeUnmount(() => {
|
|
297
|
+
sizeWatcher?.disconnect();
|
|
298
|
+
sizeWatcher = null;
|
|
299
|
+
map.value?.remove();
|
|
300
|
+
map.value = null;
|
|
301
|
+
});
|
|
302
|
+
</script>
|
|
303
|
+
|
|
304
|
+
<template>
|
|
305
|
+
<div class="deck-map" :class="{ 'is-fill': !height }" :style="height ? { height: `${height}px` } : undefined">
|
|
306
|
+
<div v-if="failure" class="deck-map-empty">{{ failure }}</div>
|
|
307
|
+
<div v-else ref="holder" class="deck-map-canvas" />
|
|
308
|
+
</div>
|
|
309
|
+
</template>
|
|
310
|
+
|
|
311
|
+
<style scoped>
|
|
312
|
+
/*
|
|
313
|
+
★★ THE NOTCH HAS TO BE HANDED DOWN TO THE CANVAS, because an ancestor does not clip one.
|
|
314
|
+
|
|
315
|
+
split.vue cuts its figure's corner with a `clip-path` so the running Forest mark sits in the
|
|
316
|
+
cut-out. That works for every other figure and does nothing here: a composited WebGL canvas is not
|
|
317
|
+
clipped by an ancestor's clip-path, so the mark was drawn straight over a live map while the
|
|
318
|
+
element around it was correctly cut. The same reason the map's own corner RADIUS has to land on
|
|
319
|
+
`.mapboxgl-map` rather than on anything above it.
|
|
320
|
+
|
|
321
|
+
`inherit` rather than a copy, so there is one path and this file never computes a shape. It walks
|
|
322
|
+
the chain a step at a time — the clip is on the figure, the grandparent — and it is safe when
|
|
323
|
+
there is no notch, because inheriting nothing computes to `none`. It is only correct because the
|
|
324
|
+
three boxes are the same size: measured 347x302 at figure, .deck-map and .mapboxgl-map alike,
|
|
325
|
+
which is what `is-fill` guarantees. A map given a fixed height inside a notched figure would be
|
|
326
|
+
clipped against a path drawn for a taller box.
|
|
327
|
+
*/
|
|
328
|
+
.deck-map {
|
|
329
|
+
clip-path: inherit;
|
|
330
|
+
}
|
|
331
|
+
.deck-map :deep(.mapboxgl-map) {
|
|
332
|
+
clip-path: inherit;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/*
|
|
336
|
+
`height: 0` means FILL — take whatever the parent gives, rather than a number the slide had to
|
|
337
|
+
guess. The same convention Carousel's `width: 0` already uses for its column.
|
|
338
|
+
|
|
339
|
+
★ It exists for `split` at `media: figure`, where the cell height is the layout's to decide and a
|
|
340
|
+
slide that wrote its own pixel figure would be encoding the anchor arithmetic by hand. With a
|
|
341
|
+
number, the map is that tall and the cell's leftover space sits empty below it.
|
|
342
|
+
*/
|
|
343
|
+
.deck-map.is-fill {
|
|
344
|
+
height: 100%;
|
|
345
|
+
}
|
|
346
|
+
.deck-map {
|
|
347
|
+
width: 100%;
|
|
348
|
+
border-radius: var(--deck-radius-nested);
|
|
349
|
+
/* ★ THE RADIUS HAS TO CLIP A COMPOSITED CANVAS. A WebGL canvas is composited on its own layer, so
|
|
350
|
+
a rounded corner on an ANCESTOR does not clip it — the map paints square corners over the
|
|
351
|
+
rounding and the slide looks broken at four points. `overflow: hidden` here plus the same
|
|
352
|
+
radius on mapbox's own container below is what actually cuts it. */
|
|
353
|
+
overflow: hidden;
|
|
354
|
+
background: var(--deck-surface);
|
|
355
|
+
}
|
|
356
|
+
.deck-map-canvas {
|
|
357
|
+
width: 100%;
|
|
358
|
+
height: 100%;
|
|
359
|
+
border-radius: var(--deck-radius-nested);
|
|
360
|
+
overflow: hidden;
|
|
361
|
+
}
|
|
362
|
+
.deck-map-canvas :deep(.mapboxgl-map) {
|
|
363
|
+
border-radius: var(--deck-radius-nested);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
.deck-map-empty {
|
|
367
|
+
display: flex;
|
|
368
|
+
align-items: center;
|
|
369
|
+
justify-content: center;
|
|
370
|
+
text-align: center;
|
|
371
|
+
height: 100%;
|
|
372
|
+
padding: var(--deck-space-5);
|
|
373
|
+
color: var(--deck-fg-muted);
|
|
374
|
+
font-size: var(--type-deck-body-size);
|
|
375
|
+
line-height: var(--type-deck-body-line-height);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/* Mapbox's own chrome is sized for a browser window. The attribution is a licence term and stays,
|
|
379
|
+
but at 10px on a projector it is noise rather than a credit — the deck's smallest real tier is
|
|
380
|
+
what it takes instead.
|
|
381
|
+
|
|
382
|
+
★★ IT NEEDS ITS OWN GROUND, AND THAT IS A DETERMINISM FIX, NOT A STYLING ONE. Text sitting
|
|
383
|
+
straight on the basemap is measured by scripts/deckAudit.ts against whatever is PAINTED beneath
|
|
384
|
+
it, and a live map paints asynchronously over the network — so the same slide scored 12 contrast
|
|
385
|
+
failures on one run and none on the next, purely on how far the tiles had got. A near-opaque
|
|
386
|
+
ground under the credit makes the pairing a constant: the audit measures ink against a known
|
|
387
|
+
surface instead of against a race. */
|
|
388
|
+
.deck-map :deep(.mapboxgl-ctrl-attrib) {
|
|
389
|
+
background: var(--deck-bg);
|
|
390
|
+
border-radius: var(--deck-radius-control);
|
|
391
|
+
padding: 0 var(--deck-space-2);
|
|
392
|
+
}
|
|
393
|
+
/* ★ A FIXED px SIZE, AND DELIBERATELY NOT A DECK TYPE ROLE. This took
|
|
394
|
+
`var(--type-deck-caption-size)` first, on the reasoning that every other piece of text in the
|
|
395
|
+
deck takes a role — and the roles are sized for a ROOM, so the credit rendered at caption tier
|
|
396
|
+
and ran straight out of the map box, wider than the picture it was crediting.
|
|
397
|
+
|
|
398
|
+
The attribution is the one string on a slide that is a LICENCE TERM rather than content: Mapbox
|
|
399
|
+
requires it to be legible and present, and requires nothing more than that. It cannot be removed,
|
|
400
|
+
and it should not be room-scale either — 16px on the 1920 canvas is readable up close, which is
|
|
401
|
+
the standard the term is written to, and invisible from the back, which is where the map is the
|
|
402
|
+
subject and the credit is not. */
|
|
403
|
+
.deck-map :deep(.mapboxgl-ctrl-attrib),
|
|
404
|
+
.deck-map :deep(.mapboxgl-ctrl-attrib a) {
|
|
405
|
+
font-size: 16px;
|
|
406
|
+
line-height: 1.4;
|
|
407
|
+
color: var(--deck-fg-muted);
|
|
408
|
+
}
|
|
409
|
+
</style>
|