@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,135 @@
|
|
|
1
|
+
// The named background/ink pair NAMES, derived from the generated manifest rather than restated.
|
|
2
|
+
//
|
|
3
|
+
// A name here is a background PLUS A HALF — `accent-dark`, `warm-light` — because that is what the
|
|
4
|
+
// generator emits and what an OBJECT on the deck needs: a fixed pair that reads the same wherever
|
|
5
|
+
// it is dropped. A SURFACE of the deck reads the unsuffixed `--deck-bg-<name>` instead, which
|
|
6
|
+
// follows the register. Both come from one table; see scripts/deck.ts's PANEL.
|
|
7
|
+
//
|
|
8
|
+
// This used to be six poetic names (forest, deep, night, bark, mint, peach) in a table of their
|
|
9
|
+
// own, five of which were a panel half written a second time. They drifted the first time one
|
|
10
|
+
// moved.
|
|
11
|
+
//
|
|
12
|
+
// Defect #9 of this project's one recurring shape: `scripts/deck.ts`'s PANEL table is the single
|
|
13
|
+
// place the pairs are DEFINED, but before this file existed, BadgeStrip.vue and IconTile.vue each
|
|
14
|
+
// hard-coded their own copy of the names (for their `tone` validator), and IconTile.vue
|
|
15
|
+
// hard-coded `peach` a third time directly in CSS (`var(--deck-bg-warm-light)`). Nothing linked any
|
|
16
|
+
// of the three back to the generator. Rename `peach` -> `sand` in scripts/deck.ts and regenerate:
|
|
17
|
+
// every existing gate (deckCoverage, the frontmatter suite, deckAudit) stayed green, because none
|
|
18
|
+
// of them read the components' hard-coded lists against the generator's — and every IconTile went
|
|
19
|
+
// invalid-at-computed-value-time, an invisible 80x80 blank, with no warning anywhere.
|
|
20
|
+
//
|
|
21
|
+
// Importing deck.json directly — the same technique DottedMap.vue already uses for
|
|
22
|
+
// `@humanforest/tokens/geo/london-dots-coarse.json` — makes that class of drift structurally
|
|
23
|
+
// impossible: there is exactly one place the names live, and every consumer reads it.
|
|
24
|
+
import deckTokens from '@humanforest/tokens/deck/deck.json';
|
|
25
|
+
|
|
26
|
+
/** The four names the generator publishes a background for, bare — no half. */
|
|
27
|
+
export const BACKGROUND_NAMES = Object.keys(deckTokens.backgrounds);
|
|
28
|
+
|
|
29
|
+
/** Every `<name>-<half>` the generator emits a `--deck-bg-*` / `--deck-on-bg-*` pair for. */
|
|
30
|
+
export const BACKGROUNDS = BACKGROUND_NAMES.flatMap((n) => [`${n}-light`, `${n}-dark`]);
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
★★ A HALF IS A CHOICE ONLY WHERE THERE IS SOMETHING TO CHOOSE, which is why a background is twelve
|
|
34
|
+
values and not eight.
|
|
35
|
+
|
|
36
|
+
The generator publishes each row of the table twice over. FOLLOWING tokens
|
|
37
|
+
(`--deck-bg-<name>`, `--deck-ink-<name>`) carry no half because the REGISTER picks the column;
|
|
38
|
+
PINNED tokens (`--deck-bg-<name>-<half>`) name the column outright, so the author picks it and the
|
|
39
|
+
value is the same on every slide.
|
|
40
|
+
|
|
41
|
+
A consumer therefore takes one vocabulary or the other, never both: a solid badge paints its own
|
|
42
|
+
ground and so must be pinned (`accent-dark`), and an outline badge borrows the slide's ground and
|
|
43
|
+
so must follow (`accent`). The half is not "optional" in the second case — it is unexpressible,
|
|
44
|
+
because there is no second value for an author to choose between.
|
|
45
|
+
|
|
46
|
+
Both live in one prop because they are one axis — WHICH FAMILY — asked at two levels of
|
|
47
|
+
commitment. Splitting them into `background` and `pinnedBackground` would make a caller name the family twice.
|
|
48
|
+
*/
|
|
49
|
+
export const BACKGROUND_VALUES = [...BACKGROUNDS, ...BACKGROUND_NAMES];
|
|
50
|
+
|
|
51
|
+
/** What a Card (and StatCard) can sit on, in order of how much ground it claims: `transparent`
|
|
52
|
+
* paints none and draws its edge in its own ink, `surface` is the derived step off the slide
|
|
53
|
+
* ground, and the four names are the table's pairs read as FOLLOWING tokens — the register picks
|
|
54
|
+
* the half, and the card's own `dark` pins the dark one. The pinned `<name>-<half>` spellings are
|
|
55
|
+
* not here: a card is a surface OF the slide, not an object dropped on it. */
|
|
56
|
+
export const CARD_BACKGROUNDS = ['transparent', 'surface', ...BACKGROUND_NAMES];
|
|
57
|
+
|
|
58
|
+
/** What a component falls back to when asked for a name the generator doesn't have. */
|
|
59
|
+
export const DEFAULT_BACKGROUND = 'vivid-dark';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Clamps an arbitrary tone value to one the generator actually emits — the house lookup-table
|
|
63
|
+
* idiom (Placeholder's `ratio`, Grid's `cols`/`gap`/`align`), applied to chip tone. Vue strips
|
|
64
|
+
* prop validators from production builds, so without this, `<BadgeStrip tone="olive">` (or a renamed
|
|
65
|
+
* tone the caller didn't know about) would build `var(--deck-bg-olive)` — undefined, so BOTH
|
|
66
|
+
* the fill and the ink go invalid at computed-value time: an unstyled, transparent chip, no
|
|
67
|
+
* warning. Clamping first means the interpolation below only ever sees a name that exists.
|
|
68
|
+
*/
|
|
69
|
+
export const clampBackground = (name) => {
|
|
70
|
+
if (BACKGROUNDS.includes(name)) return name;
|
|
71
|
+
// A bare name reaching a PINNED consumer is a caller that asked to follow the register where
|
|
72
|
+
// following is not on offer. It keeps its family and takes that family's dark half — the register
|
|
73
|
+
// a badge is drawn in by default, and the half every pinned default in the theme already names.
|
|
74
|
+
if (BACKGROUND_NAMES.includes(name)) return `${name}-dark`;
|
|
75
|
+
return DEFAULT_BACKGROUND;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** The same clamp for a FOLLOWING consumer: the bare family name, with any half discarded. */
|
|
79
|
+
export const clampBackgroundName = (name) => {
|
|
80
|
+
const base = String(name).replace(/-(light|dark)$/, '');
|
|
81
|
+
return BACKGROUND_NAMES.includes(base) ? base : DEFAULT_BACKGROUND.replace(/-(light|dark)$/, '');
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** The inline `background`/`color` pair for a chip tone, already clamped — never raw
|
|
85
|
+
interpolation into the custom-property name. */
|
|
86
|
+
export const backgroundStyle = (name) => {
|
|
87
|
+
const n = clampBackground(name);
|
|
88
|
+
return { background: `var(--deck-bg-${n})`, color: `var(--deck-on-bg-${n})` };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/*
|
|
92
|
+
★★ THE RUNNING CHROME DOES NOT PAINT FROM A CHIP, and that is the whole point of this helper.
|
|
93
|
+
|
|
94
|
+
Both pills used `backgroundStyle('vivid-dark')`, and a pinned half is register-INVARIANT by design — an object sits ON the deck, so it reads the same on either ground. Correct for an object;
|
|
95
|
+
wrong for the running chrome, which lands on whatever ground the SLIDE gives it. With the pill
|
|
96
|
+
pinned to forest-700, every dark panel ground failed the 3:1 non-text floor beneath it: bark
|
|
97
|
+
1.09:1, forest-900 1.77:1, neutral-800 2.33:1. Moving with the register makes those 3.86, 6.28 and
|
|
98
|
+
8.28.
|
|
99
|
+
|
|
100
|
+
⚠ IT PAINTS THE ACCENT PAIR, AND IT USED TO HAVE A PAIR OF ITS OWN. --deck-chrome-pill /
|
|
101
|
+
--deck-on-chrome-pill existed "so the chrome can be re-tuned without moving every accent" — a lever
|
|
102
|
+
created already equal to the accent on both registers, and never once turned. Forty-two things
|
|
103
|
+
painted the accent; one painted the pill. A role whose only difference from another is its name is
|
|
104
|
+
a second thing to keep in sync, and its specimen drew two identical boxes under two labels. If the
|
|
105
|
+
chrome ever does need to step away from the accent, that is the moment to add a role, with a value.
|
|
106
|
+
*/
|
|
107
|
+
export const chromePillStyle = () => ({
|
|
108
|
+
background: 'var(--deck-accent)',
|
|
109
|
+
color: 'var(--deck-on-accent)',
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/*
|
|
113
|
+
The UNFILLED counterpart: a stroke and a label in the background's ink, on whatever ground the slide
|
|
114
|
+
gives it. `--deck-ink-<name>` FOLLOWS the register, so this takes a bare name — see the ★★ note
|
|
115
|
+
above on why the half is unexpressible here rather than merely unused.
|
|
116
|
+
|
|
117
|
+
`borderColor` is left to `currentColor` in CSS rather than named again here, so the stroke and the
|
|
118
|
+
label can never drift apart.
|
|
119
|
+
*/
|
|
120
|
+
export const outlineStyle = (name) => ({ color: `var(--deck-ink-${clampBackgroundName(name)})` });
|
|
121
|
+
|
|
122
|
+
/*
|
|
123
|
+
The FILLED counterpart of `outlineStyle`: the same bare name, painted as a ground rather than as
|
|
124
|
+
ink, and following the register the way `--deck-bg-<name>` does.
|
|
125
|
+
|
|
126
|
+
★ IT IS NOT `backgroundStyle` WITH THE HALF LEFT OFF. That one clamps a bare name UP to the
|
|
127
|
+
family's dark half and pins it, which is correct for an object dropped on the deck and wrong for a
|
|
128
|
+
fill that belongs to a surface — see the ★★ note above on why the half is unexpressible for a
|
|
129
|
+
following consumer. Avatar's monogram disc is the second case: it sits inside a ring that already
|
|
130
|
+
follows the page's ink, so a pinned fill would flip out of step with the edge around it.
|
|
131
|
+
*/
|
|
132
|
+
export const groundStyle = (name) => {
|
|
133
|
+
const n = clampBackgroundName(name);
|
|
134
|
+
return { background: `var(--deck-bg-${n})`, color: `var(--deck-on-bg-${n})` };
|
|
135
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The pure lookup and framing BoroughShape.vue resolves through — pulled out of the SFC so it is
|
|
3
|
+
unit-testable in the package's node-only vitest run, the same shape as dottedMapGeometry.js.
|
|
4
|
+
|
|
5
|
+
SOURCE OF TRUTH for the idea: packages/charts/src/londonAreas.ts (`indexAreas`) and
|
|
6
|
+
FBoroughShape.vue's own framing maths. This is a deliberate deck-local re-implementation rather
|
|
7
|
+
than an import: `@humanforest/charts` peer-depends on `@nuxt/ui`, and bun auto-installs peer
|
|
8
|
+
dependencies, so taking the dependency drags Nuxt UI and Tailwind into a deck that runs UnoCSS.
|
|
9
|
+
The GEOMETRY is genuinely shared — both sides read the same generated
|
|
10
|
+
@humanforest/tokens/geo/london-boroughs.json — so a deck borough and a product borough are the
|
|
11
|
+
same outline, which is the part that has to match.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** One borough, as scripts/londonAreas.ts generates it. */
|
|
15
|
+
export interface Borough {
|
|
16
|
+
/** ONS code — E09… for a borough. */
|
|
17
|
+
code: string;
|
|
18
|
+
name: string;
|
|
19
|
+
/** SVG path data, in viewBox units. */
|
|
20
|
+
d: string;
|
|
21
|
+
/** Bounding box of the largest ring, in viewBox units: origin (x, y) then size (w, h). */
|
|
22
|
+
x: number;
|
|
23
|
+
y: number;
|
|
24
|
+
w: number;
|
|
25
|
+
h: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Index boroughs by ONS code and by name, both lower-cased.
|
|
30
|
+
*
|
|
31
|
+
* Codes are indexed first because they are unambiguous. Names are indexed too because real slide
|
|
32
|
+
* copy says "Southwark", not "E09000028" — and unlike wards, no two boroughs share a name, so the
|
|
33
|
+
* name key is safe here without the uniqueness guard londonAreas.ts needs for the ward level.
|
|
34
|
+
*/
|
|
35
|
+
export const indexBoroughs = (areas: readonly Borough[]): Map<string, Borough> => {
|
|
36
|
+
const byKey = new Map<string, Borough>();
|
|
37
|
+
for (const a of areas) {
|
|
38
|
+
byKey.set(a.code.toLowerCase(), a);
|
|
39
|
+
byKey.set(a.name.toLowerCase(), a);
|
|
40
|
+
}
|
|
41
|
+
return byKey;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Look one borough up by code or name, case-insensitively. `undefined` for an unknown key —
|
|
45
|
+
* never a stray full-London outline, which would be a wrong answer wearing a right one's clothes. */
|
|
46
|
+
export const findBorough = (areas: readonly Borough[], key: string | null | undefined): Borough | undefined =>
|
|
47
|
+
key == null ? undefined : indexBoroughs(areas).get(String(key).trim().toLowerCase());
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The viewBox that frames one borough to its own bounds, with `pad` of breathing room expressed as
|
|
51
|
+
* a fraction of the shape's LONGER side — so a long thin borough gets the same visual margin all
|
|
52
|
+
* the way round rather than a wide one on the short axis. Built from the stored bounding box, so
|
|
53
|
+
* nothing here parses a path.
|
|
54
|
+
*/
|
|
55
|
+
export const frameViewBox = (area: Borough | undefined, pad = 0.04): string | undefined => {
|
|
56
|
+
if (!area) return undefined;
|
|
57
|
+
const p = Math.max(area.w, area.h) * pad;
|
|
58
|
+
return `${area.x - p} ${area.y - p} ${area.w + p * 2} ${area.h + p * 2}`;
|
|
59
|
+
};
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The pure half of the deck's chart set — data shaping, series resolution, tick thinning and the
|
|
3
|
+
empty-state decision. Everything here is a plain function over plain data: no Vue, no DOM, no
|
|
4
|
+
Unovis. That is what makes it testable in the package's node-only vitest run
|
|
5
|
+
(test/chartData.static.test.ts), the same reason chipTones.js/dottedMapGeometry.js exist as modules
|
|
6
|
+
rather than living inline in the components that use them.
|
|
7
|
+
|
|
8
|
+
WHY A DECK-LOCAL COPY. `packages/charts` already owns this shape — `fromCategories.ts` derives
|
|
9
|
+
the same ordered accessor arrays from a categories map, and `useChartPalette.ts` owns the palette
|
|
10
|
+
accessors. Those two files are the SOURCE OF TRUTH for the idea; this file is a deliberate
|
|
11
|
+
re-implementation, not a fork of convenience. Measured reason: adding `@humanforest/charts` to
|
|
12
|
+
this package's dependencies pulls `@nuxt/ui` and `tailwindcss` into the deck's install tree —
|
|
13
|
+
bun auto-installs peer dependencies, and that package peer-depends on `@nuxt/ui: ^4`. Measured on
|
|
14
|
+
2026-08-25: 326 top-level entries / 306 MB with it, 204 / 199 MB without. The deck runs UnoCSS and
|
|
15
|
+
the two pipelines do not coexist, so the dependency is the thing to avoid, not the duplication.
|
|
16
|
+
|
|
17
|
+
What is shared instead is what actually has to match: the VALUES. Series colours resolve to
|
|
18
|
+
--deck-series-1…6, which alias the published categorical palette in the product's own order, so a
|
|
19
|
+
deck chart and a product chart of the same data are the same chart.
|
|
20
|
+
|
|
21
|
+
THE CEILING IS A HARD LIMIT, not a soft one — and it is SIX. It said five here, on the strength of
|
|
22
|
+
a claim that the palette had no sixth; that was never true of the design system, which publishes
|
|
23
|
+
six on the vivid track (twelve with labels) and six brand pairs. A chart that needs a SEVENTH
|
|
24
|
+
series is the wrong chart — so `chartState()` reports that as its own state and the components
|
|
25
|
+
draw a real message, rather than wrapping back to series 1 (which quietly tells the audience two
|
|
26
|
+
different things are the same thing) or falling through to Unovis' own --vis-color6.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* How many categorical series the deck palette can express.
|
|
31
|
+
*
|
|
32
|
+
* SIX. It was five, on the strength of a claim that the palette had no sixth — which was never true
|
|
33
|
+
* of the design system: colourEngine publishes six on the vivid track (twelve with labels) and six
|
|
34
|
+
* brand pairs. Measured, the sixth costs nothing in contrast on either deck ground.
|
|
35
|
+
*
|
|
36
|
+
* ★ Both registers carry six. Vivid's is `red`; the .deck-pairs register's is river-soft. Watch the
|
|
37
|
+
* one collision: on a dark ground vivid red sits dE00 3 from --deck-status-danger-mark, so a
|
|
38
|
+
* six-series chart beside a status mark reads its last series as an alarm.
|
|
39
|
+
*/
|
|
40
|
+
export const SERIES_CEILING = 6;
|
|
41
|
+
|
|
42
|
+
/** One series: which field of a row it reads, and what the legend calls it. */
|
|
43
|
+
export interface Series {
|
|
44
|
+
key: string;
|
|
45
|
+
name?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A resolved series — key, display name, and the CSS colour reference that paints it. */
|
|
49
|
+
export interface ResolvedSeries {
|
|
50
|
+
key: string;
|
|
51
|
+
name: string;
|
|
52
|
+
color: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A row of an XY chart: a category label plus one numeric field per series. */
|
|
56
|
+
export type Row = Record<string, unknown>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The colour that paints series `index` (0-based), as a `var()` reference rather than a resolved
|
|
60
|
+
* value — CSS resolves it continuously, so a `.deck-dark` slide flips every chart with no JS at
|
|
61
|
+
* all. Mirrors `useChartPalette`'s own "colours are var() STRINGS, not hexes" contract.
|
|
62
|
+
*
|
|
63
|
+
* Beyond the ceiling this THROWS rather than wrapping: a silent wrap paints two series the same
|
|
64
|
+
* colour, which is a lie the audience cannot see. Callers gate on `chartState()` first.
|
|
65
|
+
*/
|
|
66
|
+
export function seriesVar(index: number): string {
|
|
67
|
+
if (!Number.isInteger(index) || index < 0 || index >= SERIES_CEILING) {
|
|
68
|
+
throw new RangeError(
|
|
69
|
+
`seriesVar(${index}): the deck palette carries ${SERIES_CEILING} categorical colours (0…${SERIES_CEILING - 1}). ` +
|
|
70
|
+
'A chart that needs more is the wrong chart — split it, or aggregate the tail.',
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return `var(--deck-series-${index + 1})`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** True for a value a chart can actually plot. Anything else is a gap, never a zero. */
|
|
77
|
+
export const isPlottable = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The series a chart will draw, given what the author passed and what the data actually contains.
|
|
81
|
+
* An explicit `series` list wins; otherwise every numeric field on the first row becomes a series,
|
|
82
|
+
* in the order the object declares them, minus the reserved label field. That affordance is what
|
|
83
|
+
* lets a slide say `:data="[{ label: 'Jan', rides: 12 }]"` and get a legend without repeating
|
|
84
|
+
* itself.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveSeries(data: readonly Row[], series?: readonly Series[], labelKey = 'label'): ResolvedSeries[] {
|
|
87
|
+
const declared = series?.length
|
|
88
|
+
? series
|
|
89
|
+
: Object.keys(data[0] ?? {})
|
|
90
|
+
.filter((k) => k !== labelKey && isPlottable((data[0] as Row)[k]))
|
|
91
|
+
.map((key) => ({ key }));
|
|
92
|
+
|
|
93
|
+
return declared.map((s, i) => ({
|
|
94
|
+
key: s.key,
|
|
95
|
+
name: s.name ?? s.key,
|
|
96
|
+
// Past the ceiling the colour is not asked for at all — chartState() has already stopped the
|
|
97
|
+
// chart drawing, and calling seriesVar() here would throw before that message could render.
|
|
98
|
+
color: i < SERIES_CEILING ? seriesVar(i) : '',
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** What a chart should do with what it was handed. */
|
|
103
|
+
export type ChartState =
|
|
104
|
+
| { kind: 'ready'; series: ResolvedSeries[] }
|
|
105
|
+
| { kind: 'empty'; reason: 'no-rows' | 'no-series' | 'no-values' }
|
|
106
|
+
| { kind: 'over-capacity'; count: number };
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The honest-degradation decision, in one pure function so both the components and the tests read
|
|
110
|
+
* the same rules.
|
|
111
|
+
*
|
|
112
|
+
* no-rows nothing was passed, or an empty array.
|
|
113
|
+
* no-series rows exist but carry no numeric field to plot.
|
|
114
|
+
* no-values series exist but every cell is null/undefined/NaN. Drawn as an empty state
|
|
115
|
+
* rather than a flat line along zero: a missing reading is not a reading of nought,
|
|
116
|
+
* and on a slide read in seconds from the back of a room there is no axis to
|
|
117
|
+
* inspect and no tooltip to interrogate.
|
|
118
|
+
* over-capacity more series than the palette has colours. See the header.
|
|
119
|
+
*/
|
|
120
|
+
export function chartState(data: readonly Row[] | null | undefined, series?: readonly Series[], labelKey = 'label'): ChartState {
|
|
121
|
+
const rows = Array.isArray(data) ? data : [];
|
|
122
|
+
if (rows.length === 0) return { kind: 'empty', reason: 'no-rows' };
|
|
123
|
+
|
|
124
|
+
const resolved = resolveSeries(rows, series, labelKey);
|
|
125
|
+
if (resolved.length === 0) return { kind: 'empty', reason: 'no-series' };
|
|
126
|
+
if (resolved.length > SERIES_CEILING) return { kind: 'over-capacity', count: resolved.length };
|
|
127
|
+
|
|
128
|
+
const anyValue = rows.some((row) => resolved.some((s) => isPlottable(row[s.key])));
|
|
129
|
+
if (!anyValue) return { kind: 'empty', reason: 'no-values' };
|
|
130
|
+
|
|
131
|
+
return { kind: 'ready', series: resolved };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The same decision for the single-value charts (donut), whose rows carry one `value` field rather
|
|
136
|
+
* than a series per column. A total of zero is empty, not a disc: an all-zero donut renders as
|
|
137
|
+
* either nothing or a NaN arc depending on the mark, and neither says "no data".
|
|
138
|
+
*/
|
|
139
|
+
export function shareState(data: readonly Row[] | null | undefined, valueKey = 'value'): ChartState {
|
|
140
|
+
const rows = Array.isArray(data) ? data : [];
|
|
141
|
+
if (rows.length === 0) return { kind: 'empty', reason: 'no-rows' };
|
|
142
|
+
if (rows.length > SERIES_CEILING) return { kind: 'over-capacity', count: rows.length };
|
|
143
|
+
|
|
144
|
+
const values = rows.map((r) => (isPlottable(r[valueKey]) ? r[valueKey] : 0));
|
|
145
|
+
if (values.every((v) => v <= 0)) return { kind: 'empty', reason: 'no-values' };
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
kind: 'ready',
|
|
149
|
+
series: rows.map((r, i) => ({
|
|
150
|
+
key: String(r[valueKey] ?? i),
|
|
151
|
+
name: typeof r.label === 'string' ? r.label : String(i + 1),
|
|
152
|
+
color: seriesVar(i),
|
|
153
|
+
})),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The numeric column a donut hands its container, gaps read as zero — a missing slice has no
|
|
158
|
+
* share, which is the one place a gap and a nought genuinely mean the same thing. */
|
|
159
|
+
export const shareValues = (data: readonly Row[], valueKey = 'value'): number[] =>
|
|
160
|
+
data.map((r) => (isPlottable(r[valueKey]) ? r[valueKey] : 0));
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Which row indices carry a tick.
|
|
164
|
+
*
|
|
165
|
+
* A deck axis is not a screen axis. Tick labels are set at --type-deck-body-sm-size (36px) so they
|
|
166
|
+
* survive a projector, which means far fewer of them fit: twelve months across a half-slide column
|
|
167
|
+
* collide into a grey smear long before they become unreadable individually. This thins the set to
|
|
168
|
+
* at most `maxTicks` by taking every nth row, and ALWAYS keeps the last row — an axis whose final
|
|
169
|
+
* tick is missing reads as though the data stops early.
|
|
170
|
+
*/
|
|
171
|
+
export function tickIndices(count: number, maxTicks = 6): number[] {
|
|
172
|
+
if (count <= 0) return [];
|
|
173
|
+
if (count <= maxTicks) return Array.from({ length: count }, (_, i) => i);
|
|
174
|
+
|
|
175
|
+
const stride = Math.ceil((count - 1) / Math.max(maxTicks - 1, 1));
|
|
176
|
+
const out: number[] = [];
|
|
177
|
+
for (let i = 0; i < count; i += stride) out.push(i);
|
|
178
|
+
const last = count - 1;
|
|
179
|
+
if (out[out.length - 1] !== last) {
|
|
180
|
+
// Replace rather than append when the penultimate tick would sit within half a stride of the
|
|
181
|
+
// end: two labels that close together at 36px overlap, which is the exact failure this thins for.
|
|
182
|
+
if (last - out[out.length - 1] < stride / 2) out[out.length - 1] = last;
|
|
183
|
+
else out.push(last);
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The category label for row `index`, or the index itself when the row does not carry one. */
|
|
189
|
+
export const labelAt = (data: readonly Row[], index: number, labelKey = 'label'): string => {
|
|
190
|
+
const row = data[index];
|
|
191
|
+
if (!row) return '';
|
|
192
|
+
const label = row[labelKey];
|
|
193
|
+
return label == null ? String(index + 1) : String(label);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The deck's default number format: British grouping, and COMPACT past four digits.
|
|
198
|
+
*
|
|
199
|
+
* Compact is the deck default rather than the web one because of where the figure is read. "12,480"
|
|
200
|
+
* at 36px is eleven glyphs an audience has to parse mid-sentence; "12k" is three, and a slide is
|
|
201
|
+
* making a point about magnitude, not reconciling a ledger. A chart that genuinely needs the exact
|
|
202
|
+
* figure passes its own `format`.
|
|
203
|
+
*/
|
|
204
|
+
export const formatValue = (v: number): string =>
|
|
205
|
+
Math.abs(v) >= 10_000
|
|
206
|
+
? new Intl.NumberFormat('en-GB', { notation: 'compact', maximumFractionDigits: 1 }).format(v)
|
|
207
|
+
: new Intl.NumberFormat('en-GB').format(v);
|
|
208
|
+
|
|
209
|
+
/** A percentage for a donut slice, of the whole its own data sums to. Returns null for a zero
|
|
210
|
+
* total rather than NaN%, so a caller never prints "NaN%" on a slide. */
|
|
211
|
+
export const shareOf = (values: readonly number[], index: number): number | null => {
|
|
212
|
+
const total = values.reduce((a, b) => a + (isPlottable(b) ? b : 0), 0);
|
|
213
|
+
if (total <= 0) return null;
|
|
214
|
+
return ((values[index] ?? 0) / total) * 100;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/** Scatter points, grouped by their `group` field into at most five drawable groups. Rows with no
|
|
218
|
+
* group all land in one unnamed group, which is the common single-cloud case. */
|
|
219
|
+
export function scatterGroups(data: readonly Row[], groupKey = 'group'): string[] {
|
|
220
|
+
const seen: string[] = [];
|
|
221
|
+
for (const row of data) {
|
|
222
|
+
const g = row[groupKey];
|
|
223
|
+
const name = g == null ? '' : String(g);
|
|
224
|
+
if (!seen.includes(name)) seen.push(name);
|
|
225
|
+
}
|
|
226
|
+
return seen;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The state decision for a scatter: rows need a finite x AND y to be a point at all. */
|
|
230
|
+
export function scatterState(data: readonly Row[] | null | undefined, groupKey = 'group'): ChartState {
|
|
231
|
+
const rows = Array.isArray(data) ? data : [];
|
|
232
|
+
if (rows.length === 0) return { kind: 'empty', reason: 'no-rows' };
|
|
233
|
+
|
|
234
|
+
const plottable = rows.filter((r) => isPlottable(r.x) && isPlottable(r.y));
|
|
235
|
+
if (plottable.length === 0) return { kind: 'empty', reason: 'no-values' };
|
|
236
|
+
|
|
237
|
+
const groups = scatterGroups(plottable, groupKey);
|
|
238
|
+
if (groups.length > SERIES_CEILING) return { kind: 'over-capacity', count: groups.length };
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
kind: 'ready',
|
|
242
|
+
series: groups.map((name, i) => ({ key: name || 'points', name: name || 'Points', color: seriesVar(i) })),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The message a component prints for a non-ready state. Kept here, not in the templates, so the
|
|
247
|
+
* wording is one string a test can pin rather than six that drift apart. */
|
|
248
|
+
/**
|
|
249
|
+
* The crosshair's circle, in px. Unovis draws it at 4, which is sized against a 2px line on a
|
|
250
|
+
* dashboard; this deck draws its lines at 6 and is read from across a room, so the circle takes
|
|
251
|
+
* one and a half times that stroke — big enough to read as a mark on the series it belongs to
|
|
252
|
+
* rather than as a speck on the rule.
|
|
253
|
+
*/
|
|
254
|
+
export const CROSSHAIR_CIRCLE_R = 9;
|
|
255
|
+
|
|
256
|
+
/*
|
|
257
|
+
★★ UNOVIS BLINKS THE CROSSHAIR OUT BETWEEN COLUMNS, AND THIS DECK IS ITS WORST CASE.
|
|
258
|
+
|
|
259
|
+
`hideWhenFarFromPointer` defaults TRUE at a distance of 100, and with `snapToData` the rule reads
|
|
260
|
+
"hide once the snapped column is more than 100px from the pointer". Unovis' own comment says the
|
|
261
|
+
flag is for "low data resolution" — which is every chart here: six monthly points across a 1740px
|
|
262
|
+
plot sit ~290px apart, so a band roughly 90px wide around each midpoint hides the line, the
|
|
263
|
+
circles AND the tooltip. MEASURED on the Line slide, sweeping the pointer smoothly across the
|
|
264
|
+
plot: 41 of 200 frames drawn with nothing on screen, and it reads as a flicker rather than as a
|
|
265
|
+
rule, because the pointer never left the plot.
|
|
266
|
+
|
|
267
|
+
A presenter sweeping a chart wants the nearest column's reading, always — which is what the panel
|
|
268
|
+
already promises by snapping. Off.
|
|
269
|
+
*/
|
|
270
|
+
export const CROSSHAIR_HIDE_WHEN_FAR = false;
|
|
271
|
+
|
|
272
|
+
export const stateMessage = (state: ChartState, empty: string): string => {
|
|
273
|
+
if (state.kind === 'over-capacity') {
|
|
274
|
+
return `${state.count} series — this palette carries ${SERIES_CEILING}`;
|
|
275
|
+
}
|
|
276
|
+
return empty;
|
|
277
|
+
};
|