@bezda/rhp-mcp 0.0.0-stage → 0.1.1
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 +21 -0
- package/README.md +222 -2
- package/bin/rhp-mcp.js +129 -0
- package/package.json +62 -4
- package/skill/SKILL.md +450 -0
- package/skill/recipes/area.html +156 -0
- package/skill/recipes/bar.html +145 -0
- package/skill/recipes/box-plot.html +164 -0
- package/skill/recipes/bubble.html +188 -0
- package/skill/recipes/bullet.html +138 -0
- package/skill/recipes/candlestick.html +179 -0
- package/skill/recipes/column.html +138 -0
- package/skill/recipes/dial.html +349 -0
- package/skill/recipes/diverging-bars.html +150 -0
- package/skill/recipes/donut.html +231 -0
- package/skill/recipes/dumbbell.html +137 -0
- package/skill/recipes/gantt.html +196 -0
- package/skill/recipes/grouped-bars.html +142 -0
- package/skill/recipes/heatmap.html +183 -0
- package/skill/recipes/histogram.html +199 -0
- package/skill/recipes/line.html +170 -0
- package/skill/recipes/live.html +255 -0
- package/skill/recipes/log-line.html +493 -0
- package/skill/recipes/lollipop.html +152 -0
- package/skill/recipes/multi-line.html +228 -0
- package/skill/recipes/pyramid.html +162 -0
- package/skill/recipes/race.html +278 -0
- package/skill/recipes/radial-bars.html +157 -0
- package/skill/recipes/scatter.html +199 -0
- package/skill/recipes/silhouettes.html +352 -0
- package/skill/recipes/slope.html +199 -0
- package/skill/recipes/sparklines.html +175 -0
- package/skill/recipes/stacked-100.html +158 -0
- package/skill/recipes/stacked-bars.html +150 -0
- package/skill/recipes/strip.html +221 -0
- package/skill/recipes/unit-stack.html +276 -0
- package/skill/recipes/violin.html +169 -0
- package/skill/recipes/waffle.html +165 -0
- package/skill/recipes/waterfall.html +127 -0
- package/skill/references/api.md +1684 -0
- package/skill/references/design.md +740 -0
- package/skill/references/environments.md +1577 -0
- package/skill/references/forms.md +197 -0
- package/skill/references/interaction.md +794 -0
- package/skill/references/looks.md +267 -0
- package/skill/references/pitfalls.md +202 -0
- package/src/check/README.md +176 -0
- package/src/check/browser.js +92 -0
- package/src/check/build.js +439 -0
- package/src/check/colors.js +324 -0
- package/src/check/findings.js +247 -0
- package/src/check/index.js +508 -0
- package/src/check/inpage.js +1332 -0
- package/src/check/interact.js +266 -0
- package/src/check/png.js +98 -0
- package/src/check/report.js +106 -0
- package/src/check/source.js +387 -0
- package/src/check/sourcemap.js +75 -0
- package/src/guard/note.js +30 -0
- package/src/guard/render.js +17 -0
- package/src/guard/rhp.js +612 -0
- package/src/guard/rules.js +125 -0
- package/src/server.js +266 -0
- package/src/skill.js +140 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Teenagers sleep longer at weekends</title>
|
|
7
|
+
<meta name="description" content="Violin plot of hours of sleep by age group made with density(): a mirrored Area per group with its median, over a band of the recommended range, and a switch between weeknights and weekends that morphs the shapes. Use it to compare the full distribution of several groups.">
|
|
8
|
+
<!--
|
|
9
|
+
A recipe gives technique, not a design.
|
|
10
|
+
Take: a mirrored Area per group from density(), its median, a band for a recommended range, and a switch that morphs the shapes.
|
|
11
|
+
Leave: the design. This poster was made for its own subject (teenagers' sleep under a night sky, with faint stars and a crescent moon).
|
|
12
|
+
Its look, the place of its parts and the way its marks are drawn say that subject: design yours from your own (design.md, section 2).
|
|
13
|
+
-->
|
|
14
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
15
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
16
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@1,6..72,400..600&family=Albert+Sans:wght@400..800&display=swap">
|
|
17
|
+
<script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>
|
|
18
|
+
<style>
|
|
19
|
+
:root { color-scheme: dark; }
|
|
20
|
+
* { box-sizing: border-box; }
|
|
21
|
+
body { margin: 0; padding: 48px 16px; background: #050506; font-family: "Albert Sans", "Helvetica Neue", Arial, sans-serif; color: #f1eee6; }
|
|
22
|
+
/* The poster: a night sky with a scatter of faint stars and a crescent moon */
|
|
23
|
+
.poster { position: relative; max-width: 900px; margin: 0 auto; padding: 40px 48px 28px; border-radius: 20px; line-height: 1.45;
|
|
24
|
+
background:
|
|
25
|
+
radial-gradient(1px 1px at 23px 41px, rgb(255 255 255 / .55), transparent),
|
|
26
|
+
radial-gradient(1px 1px at 131px 97px, rgb(255 255 255 / .4), transparent),
|
|
27
|
+
radial-gradient(1.5px 1.5px at 202px 19px, rgb(255 255 255 / .5), transparent),
|
|
28
|
+
radial-gradient(1px 1px at 77px 173px, rgb(255 255 255 / .35), transparent),
|
|
29
|
+
radial-gradient(1px 1px at 251px 151px, rgb(255 255 255 / .45), transparent),
|
|
30
|
+
#101115;
|
|
31
|
+
background-size: 270px 210px, 270px 210px, 270px 210px, 270px 210px, 270px 210px, auto;
|
|
32
|
+
box-shadow: 0 0 0 1px rgb(255 255 255 / .06); }
|
|
33
|
+
.poster::before { content: ""; position: absolute; top: 34px; right: 44px; width: 58px; height: 58px; border-radius: 50%; box-shadow: 15px -9px 0 0 #efe4c4; }
|
|
34
|
+
.poster figcaption { display: grid; gap: 10px; margin-bottom: 22px; padding-right: 90px; }
|
|
35
|
+
.poster .kicker { font-size: 12px; font-weight: 700; letter-spacing: .2em; text-transform: uppercase; color: #a8a7ad; }
|
|
36
|
+
.poster .headline { font: italic 500 62px/1 Newsreader, Georgia, serif; letter-spacing: -.015em; text-wrap: balance; }
|
|
37
|
+
.poster .dek { max-width: 58ch; font-size: 16px; color: #c6c4cb; }
|
|
38
|
+
.poster .note { display: block; margin-top: 18px; font-size: 12px; color: #9a99a0; }
|
|
39
|
+
/* The switch between the two kinds of night, with the band's key beside it */
|
|
40
|
+
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 22px; margin: 0 0 18px; }
|
|
41
|
+
.switch { display: inline-flex; padding: 3px; border-radius: 99px; background: #1d1e24; box-shadow: inset 0 0 0 1px #2e2f37; }
|
|
42
|
+
.switch button { display: inline-flex; align-items: center; gap: 8px; padding: 7px 16px; border: 0; border-radius: 99px; background: none; color: #b4b3ba;
|
|
43
|
+
font: 700 14px/1 "Albert Sans", "Helvetica Neue", Arial, sans-serif; cursor: pointer; }
|
|
44
|
+
.switch button i { width: 10px; height: 10px; border-radius: 50%; }
|
|
45
|
+
.switch button[aria-pressed="true"] { background: #f1eee6; color: #101115; }
|
|
46
|
+
.switch button:focus-visible { outline: 2px solid #f1eee6; outline-offset: 2px; }
|
|
47
|
+
.keys { display: flex; flex-wrap: wrap; gap: 8px 18px; font-size: 13px; color: #a8a7ad; }
|
|
48
|
+
.keys span { display: inline-flex; align-items: center; gap: 8px; }
|
|
49
|
+
.keys .band { width: 22px; height: 12px; border-radius: 2px; background: rgb(255 255 255 / .05); box-shadow: inset 0 1px 0 rgb(255 255 255 / .38), inset 0 -1px 0 rgb(255 255 255 / .38); }
|
|
50
|
+
.keys .median { position: relative; width: 22px; height: 12px; border-radius: 6px; background: #5b5c66; }
|
|
51
|
+
.keys .median::after { content: ""; position: absolute; left: 5px; right: 5px; top: 5px; height: 2px; background: #101115; }
|
|
52
|
+
@media (max-width: 600px) {
|
|
53
|
+
body { padding: 16px; }
|
|
54
|
+
.poster { padding: 28px 18px 20px; border-radius: 16px; }
|
|
55
|
+
.poster::before { top: 22px; right: 22px; width: 38px; height: 38px; box-shadow: 10px -6px 0 0 #efe4c4; }
|
|
56
|
+
.poster figcaption { padding-right: 52px; margin-bottom: 18px; }
|
|
57
|
+
.poster .kicker { font-size: 11px; letter-spacing: .14em; }
|
|
58
|
+
.poster .headline { font-size: 40px; }
|
|
59
|
+
.poster .dek { font-size: 14.5px; }
|
|
60
|
+
.switch button { padding: 7px 13px; font-size: 13px; }
|
|
61
|
+
}
|
|
62
|
+
</style>
|
|
63
|
+
</head>
|
|
64
|
+
<body>
|
|
65
|
+
<div id="chart"></div>
|
|
66
|
+
<script type="module">
|
|
67
|
+
import { Chart, Plot, Bar, Tick, Label, Area, Poster, slat, density, summary, extent, html, render, createSignal, createMemo } from "@bezda/rhp/standalone";
|
|
68
|
+
|
|
69
|
+
// Data: hours slept per night from a week of sleep diaries, one number per person, by age group. Illustrative. Replace with your own.
|
|
70
|
+
const SLEEP = {
|
|
71
|
+
weeknights: {
|
|
72
|
+
"14-17": [7.75, 6.5, 7.25, 6, 7, 7.75, 6.5, 7.25, 5.75, 8, 6.75, 7.5, 6.25, 7, 4.75, 7.75, 6.5, 7.25, 6, 8.5, 6.75, 7.5, 6.25, 7, 5.5, 7.75, 6.5, 7.25, 6, 6.75, 7.5, 6.25, 7, 5.75, 8, 6.75, 7.5, 6.25, 7, 5],
|
|
73
|
+
"18-25": [8, 6.75, 7.5, 6, 7, 8, 6.5, 7.5, 5.75, 8.5, 7, 7.75, 6.5, 7.25, 4.75, 8.25, 6.75, 7.5, 6, 9, 7, 7.75, 6.5, 7.25, 5.5, 8.25, 6.75, 7.5, 6, 7, 7.75, 6.5, 7.25, 5.75, 8.5, 6.75, 7.75, 6.25, 7.25, 5.25],
|
|
74
|
+
"26-44": [7.75, 6.5, 7.25, 6, 7, 7.5, 6.5, 7.25, 5.75, 8, 6.75, 7.5, 6.25, 7, 4.75, 7.75, 6.75, 7.25, 6, 8.25, 6.75, 7.5, 6.25, 7, 5.5, 7.75, 6.5, 7.25, 6, 6.75, 7.5, 6.5, 7, 5.75, 8, 6.75, 7.25, 6.25, 7, 5.25],
|
|
75
|
+
"45-64": [7.75, 6.5, 7.25, 6, 6.75, 7.5, 6.25, 7, 5.5, 8, 6.75, 7.5, 6.25, 7, 4.5, 7.75, 6.5, 7.25, 5.75, 8.25, 6.75, 7.5, 6.25, 7, 5.25, 7.75, 6.5, 7.25, 6, 6.75, 7.5, 6.25, 7, 5.5, 8, 6.5, 7.25, 6, 7, 5],
|
|
76
|
+
"65+": [8.25, 7, 7.75, 6.25, 7.25, 8, 6.5, 7.5, 5.75, 8.5, 7, 7.75, 6.25, 7.25, 4.5, 8, 6.75, 7.5, 6, 9, 7, 7.75, 6.5, 7.25, 5.5, 8.25, 6.75, 7.75, 6.25, 7.25, 8, 6.75, 7.5, 6, 8.75, 7, 8, 6.5, 7.5, 5.25],
|
|
77
|
+
},
|
|
78
|
+
weekends: {
|
|
79
|
+
"14-17": [8.75, 9.5, 8.25, 9, 10, 8.5, 9.25, 8, 11, 8.75, 9.75, 8.5, 9.25, 7.75, 10.25, 8.75, 9.5, 8.25, 9, 10, 8.5, 9.25, 7.75, 10.5, 8.75, 9.5, 8.25, 9, 7.25, 10, 8.5, 9.5, 8, 11.5, 9, 10, 8.5, 9.25, 7.75, 10.5],
|
|
80
|
+
"18-25": [8.25, 9, 7.5, 8.5, 9.5, 8, 8.75, 7.25, 10.5, 8.25, 9.25, 7.75, 8.75, 7, 10, 8.25, 9, 7.5, 8.5, 9.5, 8, 8.75, 7.25, 10.25, 8.25, 9, 7.5, 8.5, 6.5, 9.75, 8, 9, 7.5, 11.25, 8.5, 9.5, 8, 8.75, 7, 10],
|
|
81
|
+
"26-44": [7.5, 8.25, 7, 7.75, 8.75, 7.25, 8, 6.75, 9.5, 7.75, 8.5, 7.25, 8, 6.5, 9, 7.5, 8.25, 7, 7.75, 8.75, 7.25, 8, 6.75, 9.25, 7.5, 8.25, 7, 7.75, 6, 8.75, 7.5, 8.25, 7, 10.25, 7.75, 8.75, 7.25, 8, 6.75, 9.25],
|
|
82
|
+
"45-64": [7, 7.75, 6.5, 7.25, 8, 6.75, 7.5, 6.25, 9, 7.25, 8, 6.75, 7.5, 6, 8.5, 7, 7.75, 6.5, 7.25, 8, 6.75, 7.5, 6.25, 8.75, 7, 7.75, 6.5, 7.25, 5.75, 8.25, 7, 7.75, 6.5, 9.75, 7.25, 8.25, 7, 7.5, 6.25, 8.75],
|
|
83
|
+
"65+": [7, 7.75, 6.5, 7.25, 8.5, 7, 7.75, 6.25, 9.5, 7.25, 8.25, 6.75, 7.5, 5.75, 8.75, 7, 7.75, 6.25, 7.25, 8.25, 6.75, 7.5, 6, 9, 7.25, 8, 6.75, 7.5, 5.5, 8.5, 7, 7.75, 6.25, 10, 7.25, 8.25, 6.75, 7.5, 5.75, 8.75],
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
// The sleep the National Sleep Foundation recommends for each age, in hours (2015)
|
|
87
|
+
const RECOMMENDED = { "14-17": [8, 10], "18-25": [7, 9], "26-44": [7, 9], "45-64": [7, 9], "65+": [7, 8] };
|
|
88
|
+
const NIGHTS = [
|
|
89
|
+
{ key: "weeknights", name: "Weeknights", color: "#7489ee" },
|
|
90
|
+
{ key: "weekends", name: "Weekends", color: "#c98224" },
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const AGES = Object.keys(RECOMMENDED);
|
|
94
|
+
const SCALE = [4, 12]; // hours shown
|
|
95
|
+
// A group's outline: density() a little past its shortest and longest night so the tips taper, kept inside the scale
|
|
96
|
+
const outline = (hours) => {
|
|
97
|
+
const [lo, hi] = extent(hours);
|
|
98
|
+
return density(hours, { points: 48, domain: [Math.max(SCALE[0], lo - 1), Math.min(SCALE[1], hi + 1)] });
|
|
99
|
+
};
|
|
100
|
+
// One peak for every shape, both kinds of night, so widths compare across groups and the switch never rescales them
|
|
101
|
+
const PEAK = Math.max(...NIGHTS.flatMap((n) => AGES.flatMap((a) => outline(SLEEP[n.key][a]).map((p) => p[1]))));
|
|
102
|
+
const share = new Intl.NumberFormat("en-US", { style: "percent" });
|
|
103
|
+
const teensShort = SLEEP.weeknights["14-17"].filter((h) => h < RECOMMENDED["14-17"][0]).length / SLEEP.weeknights["14-17"].length;
|
|
104
|
+
const clock = (hours) => {
|
|
105
|
+
const minutes = Math.round(hours * 60);
|
|
106
|
+
return `${Math.floor(minutes / 60)} h ${String(minutes % 60).padStart(2, "0")}`;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Slat type: an age group; the recommended band, the shape of its nights (density, mirrored) and a line at the median
|
|
110
|
+
const AgeGroup = slat({
|
|
111
|
+
inset: 0.06,
|
|
112
|
+
room: { start: 48, end: 8 },
|
|
113
|
+
css: `
|
|
114
|
+
.age { --rhp-length-time: .6s; --rhp-length-ease: cubic-bezier(.3, .7, .2, 1); }
|
|
115
|
+
.band { --rhp-radius: 2px; background: rgb(255 255 255 / .04); box-shadow: inset 0 1px 0 rgb(255 255 255 / .34), inset 0 -1px 0 rgb(255 255 255 / .34); }
|
|
116
|
+
.shape { fill: var(--rhp-color); stroke: none; }
|
|
117
|
+
@media (prefers-reduced-motion: no-preference) { .shape path { transition-property: d, fill; } } /* the color fades as the outline moves */
|
|
118
|
+
.median { --rhp-tick-width: 2px; background: #101115; }
|
|
119
|
+
.name { font-size: 15px; font-weight: 800; color: var(--rhp-ink); --rhp-label-gap: 10px; }
|
|
120
|
+
.name small { display: block; margin-top: 3px; font-size: 12px; font-weight: 600; color: var(--rhp-muted); font-variant-numeric: tabular-nums; }
|
|
121
|
+
@container (max-width: 420px) { .name { font-size: 13px; } .name small { font-size: 11px; } }
|
|
122
|
+
.say { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
123
|
+
`,
|
|
124
|
+
}, (d) => {
|
|
125
|
+
const shape = createMemo(() => outline(d.hours));
|
|
126
|
+
const median = createMemo(() => summary(d.hours).median);
|
|
127
|
+
return html`
|
|
128
|
+
<div class="age">
|
|
129
|
+
<${Bar} from=${() => d.low} to=${() => d.high} thick=${0.92} class="band" />
|
|
130
|
+
<${Area} points=${shape} mirror=${true} smooth=${true} peak=${() => d.peak} color=${() => d.color} class="shape" />
|
|
131
|
+
<${Tick} at=${median} thick=${0.36} class="median" />
|
|
132
|
+
<${Label} edge="start" class="name">${() => d.age}<small>${() => clock(median())}</small><//>
|
|
133
|
+
<span class="say">${() => `: median night ${clock(median())}, ${d.low} to ${d.high} hours recommended`}</span>
|
|
134
|
+
</div>`;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Chart component: the switch picks which nights the shapes show; they morph from one to the other
|
|
138
|
+
function SleepByAge() {
|
|
139
|
+
const [when, setWhen] = createSignal("weeknights");
|
|
140
|
+
const night = () => NIGHTS.find((n) => n.key === when());
|
|
141
|
+
return html`
|
|
142
|
+
<${Poster}
|
|
143
|
+
kicker="Sleep diaries · hours per night"
|
|
144
|
+
title="Teenagers sleep two hours longer at weekends"
|
|
145
|
+
dek=${`Each shape is wide where many people in an age group slept that long. The band is the sleep recommended for the age: on weeknights, ${share.format(teensShort)} of teenagers got less than 8 hours.`}
|
|
146
|
+
note="Sleep diaries: illustrative data, 40 people per age group. Recommended hours: National Sleep Foundation, 2015. Use the switch to compare weeknights with weekends.">
|
|
147
|
+
<div class="controls">
|
|
148
|
+
<div class="switch" role="group" aria-label="Nights shown">
|
|
149
|
+
${NIGHTS.map((n) => html`
|
|
150
|
+
<button type="button" aria-pressed=${() => String(when() === n.key)} onClick=${() => setWhen(n.key)}><i style=${{ background: n.color }}></i>${n.name}</button>`)}
|
|
151
|
+
</div>
|
|
152
|
+
<div class="keys">
|
|
153
|
+
<span><i class="band"></i>Recommended for the age</span>
|
|
154
|
+
<span><i class="median"></i>Median night</span>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
<${Chart} orientation="vertical" height=${340} scale=${SCALE} ticks=${[4, 6, 8, 10, 12]} format=${(v) => `${v} h`}
|
|
158
|
+
label=${() => `Violin plot of hours of sleep on ${night().name.toLowerCase()} by age group`}
|
|
159
|
+
theme=${{ font: '"Albert Sans", "Helvetica Neue", Arial, sans-serif', ink: "#f1eee6", muted: "#9a99a0", grid: "#24252b", surface: "#101115" }}>
|
|
160
|
+
<${Plot} age=${AGES} hours=${() => AGES.map((a) => SLEEP[when()][a])} low=${AGES.map((a) => RECOMMENDED[a][0])} high=${AGES.map((a) => RECOMMENDED[a][1])}
|
|
161
|
+
color=${() => night().color} peak=${PEAK}>${AgeGroup}<//>
|
|
162
|
+
<//>
|
|
163
|
+
<//>`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
render(() => html`<${SleepByAge} />`, document.getElementById("chart"));
|
|
167
|
+
</script>
|
|
168
|
+
</body>
|
|
169
|
+
</html>
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Blood types of 100 people</title>
|
|
7
|
+
<meta name="description" content="Waffle chart: 100 units in a 10 by 10 grid, one per person, colored by group, with a picker that lights the units a chosen group relates to. Use it for counts out of 100 or shares of a population.">
|
|
8
|
+
<!--
|
|
9
|
+
A recipe gives technique, not a design.
|
|
10
|
+
Take: ten slats of ten Cells with an inline SVG in each, and a picker that lights the units a chosen group relates to.
|
|
11
|
+
Leave: the design. This poster was made for its own subject (blood types as a donor card torn off along its perforation, each person a drop).
|
|
12
|
+
Its look, the place of its parts and the way its marks are drawn say that subject: design yours from your own (design.md, section 2).
|
|
13
|
+
-->
|
|
14
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
15
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
16
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Public+Sans:wght@400..800&display=swap">
|
|
17
|
+
<script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>
|
|
18
|
+
<style>
|
|
19
|
+
* { box-sizing: border-box; }
|
|
20
|
+
body { margin: 0; padding: 48px 16px; background: #dddbd6; font-family: "Public Sans", "Helvetica Neue", Arial, sans-serif; }
|
|
21
|
+
/* The poster: a donor card on white card stock, torn off along its perforated top edge */
|
|
22
|
+
.poster { position: relative; display: grid; max-width: 980px; margin: 0 auto; padding: 50px 48px 30px; color: #1b1d21;
|
|
23
|
+
background: repeating-linear-gradient(0deg, rgb(27 29 33 / .035) 0 1px, transparent 1px 28px), #fbfbf9;
|
|
24
|
+
border-radius: 4px 4px 14px 14px; box-shadow: 0 1px 2px rgb(40 38 34 / .16), 0 24px 44px -26px rgb(40 38 34 / .5); }
|
|
25
|
+
.poster > * { min-width: 0; }
|
|
26
|
+
.poster::before { content: ""; position: absolute; top: 18px; left: 22px; right: 22px; height: 6px;
|
|
27
|
+
background: radial-gradient(circle, #dddbd6 2.4px, transparent 2.9px) 0 50% / 14px 6px space no-repeat; }
|
|
28
|
+
.poster figcaption { display: grid; gap: 12px; margin-bottom: 24px; }
|
|
29
|
+
.poster .kicker { font-size: 12px; font-weight: 800; letter-spacing: .16em; text-transform: uppercase; color: #a3202f; }
|
|
30
|
+
.poster .headline { font-size: 46px; font-weight: 800; line-height: 1.02; letter-spacing: -.03em; text-wrap: balance; }
|
|
31
|
+
.poster .dek { max-width: 46ch; font-size: 16px; line-height: 1.5; color: #4a4d53; }
|
|
32
|
+
.poster .note { display: block; margin-top: 22px; font-size: 12px; line-height: 1.5; color: #62656b; }
|
|
33
|
+
/* The picker: one button per type, with its drop and how many of the 100 have it */
|
|
34
|
+
.types { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; max-width: 420px; }
|
|
35
|
+
.types button { display: flex; align-items: center; gap: 7px; padding: 8px 10px; border: 1.5px solid #d3d1cb; border-radius: 10px; background: #fff;
|
|
36
|
+
font: 700 15px/1 "Public Sans", Arial, sans-serif; color: #1b1d21; cursor: pointer; }
|
|
37
|
+
.types button span { margin-left: auto; font-weight: 500; font-size: 13px; color: #62656b; font-variant-numeric: tabular-nums; }
|
|
38
|
+
.types button[aria-pressed="true"] { border-color: #1b1d21; box-shadow: inset 0 0 0 1px #1b1d21; }
|
|
39
|
+
.types button:hover { border-color: #8d8f94; }
|
|
40
|
+
.types button:focus-visible { outline: 2px solid #1b1d21; outline-offset: 2px; }
|
|
41
|
+
.types svg { flex: none; width: 14px; height: 17px; fill: var(--c); fill-rule: evenodd; }
|
|
42
|
+
.readout { margin: 16px 0 8px; font-size: 16px; font-weight: 700; line-height: 1.3; min-height: 1.3em; }
|
|
43
|
+
.chart { width: 100%; max-width: 470px; justify-self: center; margin-top: 8px; }
|
|
44
|
+
@media (min-width: 860px) {
|
|
45
|
+
.poster { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: auto auto auto 1fr; column-gap: 52px; }
|
|
46
|
+
.poster figcaption { grid-column: 1; grid-row: 1; }
|
|
47
|
+
.types { grid-column: 1; grid-row: 2; }
|
|
48
|
+
.readout { grid-column: 1; grid-row: 3; }
|
|
49
|
+
.poster .chart { grid-column: 2; grid-row: 1 / span 4; align-self: center; margin-top: 0; }
|
|
50
|
+
.poster .note { grid-column: 1; grid-row: 4; align-self: end; }
|
|
51
|
+
}
|
|
52
|
+
@media (max-width: 520px) {
|
|
53
|
+
body { padding: 24px 16px 32px; }
|
|
54
|
+
.poster { padding: 42px 18px 20px; }
|
|
55
|
+
.poster .headline { font-size: 33px; }
|
|
56
|
+
.poster .dek { font-size: 15px; }
|
|
57
|
+
.types button { padding: 8px 7px; gap: 5px; font-size: 14px; }
|
|
58
|
+
.readout { font-size: 15px; }
|
|
59
|
+
}
|
|
60
|
+
</style>
|
|
61
|
+
</head>
|
|
62
|
+
<body>
|
|
63
|
+
<div id="chart"></div>
|
|
64
|
+
<script type="module">
|
|
65
|
+
import { Chart, Plot, Cell, Poster, slat, shape, html, render, createSignal, For } from "@bezda/rhp/standalone";
|
|
66
|
+
|
|
67
|
+
// Data: the blood types of 100 people in the US, in the order they fill the grid: Stanford Blood Center's shares of the
|
|
68
|
+
// US population (from the AABB Technical Manual, 18th edition, 2014), rounded to whole people that add up to 100.
|
|
69
|
+
// Replace with your own counts; they must add up to 100.
|
|
70
|
+
const TYPES = [
|
|
71
|
+
{ type: "O+", people: 37 },
|
|
72
|
+
{ type: "O−", people: 7 },
|
|
73
|
+
{ type: "A+", people: 36 },
|
|
74
|
+
{ type: "A−", people: 6 },
|
|
75
|
+
{ type: "B+", people: 8 },
|
|
76
|
+
{ type: "B−", people: 2 },
|
|
77
|
+
{ type: "AB+", people: 3 },
|
|
78
|
+
{ type: "AB−", people: 1 },
|
|
79
|
+
];
|
|
80
|
+
const GROUP = { O: "#c62a3a", A: "#3b6fd8", B: "#e39a13", AB: "#16a085" };
|
|
81
|
+
const FADED = "#e4e2dc";
|
|
82
|
+
const TOTAL = TYPES.reduce((sum, t) => sum + t.people, 0);
|
|
83
|
+
const PEOPLE = TYPES.flatMap((t, i) => Array(t.people).fill(i)); // one type number per person
|
|
84
|
+
const LINES = Array.from({ length: 10 }, (_, k) => PEOPLE.slice(k * 10, k * 10 + 10)); // ten people per line
|
|
85
|
+
|
|
86
|
+
const colorOf = (t) => GROUP[t.type.slice(0, -1)];
|
|
87
|
+
const negative = (t) => t.type.endsWith("−");
|
|
88
|
+
// Red cells can go to anyone who has every antigen (A, B, Rh) the donor's cells have
|
|
89
|
+
const canGive = (from, to) => ["A", "B", "+"].every((k) => !from.includes(k) || to.includes(k));
|
|
90
|
+
const reach = (i) => TYPES.filter((t) => canGive(TYPES[i].type, t.type)).reduce((sum, t) => sum + t.people, 0);
|
|
91
|
+
|
|
92
|
+
// A blood drop for a Cell, in the Cell's own box. The first number runs along the value axis, which in these
|
|
93
|
+
// cells (a Plot across a flat chart) runs up, so the tip is at 0.92.
|
|
94
|
+
// The same drop for the picker's buttons, as an SVG path (a hole for Rh-negative)
|
|
95
|
+
const SWATCH = "M7 1C8.6 3.3 12.6 7 12.6 10.9A5.6 5.6 0 0 1 1.4 10.9C1.4 7 5.4 3.3 7 1Z";
|
|
96
|
+
const HOLE = "M7 8.4A2.5 2.5 0 1 0 7 13.4A2.5 2.5 0 1 0 7 8.4Z";
|
|
97
|
+
const DROP = shape(["M", 0.92, 0.5], ["C", 0.78, 0.6, 0.58, 0.8, 0.38, 0.8], ["C", 0.214, 0.8, 0.08, 0.666, 0.08, 0.5],
|
|
98
|
+
["C", 0.08, 0.334, 0.214, 0.2, 0.38, 0.2], ["C", 0.58, 0.2, 0.78, 0.4, 0.92, 0.5], ["Z"]);
|
|
99
|
+
|
|
100
|
+
// Slat types: a person is one drop, colored by group and hollow when Rh-negative; a line is ten people side by side
|
|
101
|
+
const Person = slat({
|
|
102
|
+
css: `
|
|
103
|
+
.drop { --rhp-cell-gap: 0px; --rhp-radius: 0px; cursor: pointer; }
|
|
104
|
+
.neg { background: radial-gradient(circle closest-side at 50% 62%, #fbfbf9 50%, var(--rhp-color) 53%); }
|
|
105
|
+
`,
|
|
106
|
+
}, (p) => html`
|
|
107
|
+
<div data-type=${() => p.type}>
|
|
108
|
+
<${Cell} shape=${DROP} class=${() => (negative(TYPES[p.type]) ? "drop neg" : "drop")}
|
|
109
|
+
color=${() => (p.active == null || canGive(TYPES[p.active].type, TYPES[p.type].type) ? colorOf(TYPES[p.type]) : FADED)} />
|
|
110
|
+
</div>`);
|
|
111
|
+
|
|
112
|
+
const TenPeople = slat({ room: { start: 0, end: 0 } }, (d) => html`
|
|
113
|
+
<div><${Plot} orientation="across" type=${() => d.people} active=${() => d.active}>${Person}<//></div>`);
|
|
114
|
+
|
|
115
|
+
// Chart component: a type picked with its button or by tapping a drop (or previewed under a mouse or a pen) lights
|
|
116
|
+
// everyone its red cells can be given to
|
|
117
|
+
function BloodTypes() {
|
|
118
|
+
const [picked, setPicked] = createSignal(null);
|
|
119
|
+
const [preview, setPreview] = createSignal(null);
|
|
120
|
+
const active = () => preview() ?? picked();
|
|
121
|
+
const typeAt = (e) => {
|
|
122
|
+
const el = e.target.closest("[data-type]");
|
|
123
|
+
return el ? +el.dataset.type : null;
|
|
124
|
+
};
|
|
125
|
+
const pick = (i) => setPicked(picked() === i ? null : i);
|
|
126
|
+
// A mouse or a pen previews the type under it; between the drops the preview stays, and off the chart (on the
|
|
127
|
+
// buttons) it ends, so the picked type shows
|
|
128
|
+
const hover = (e) => {
|
|
129
|
+
if (e.pointerType === "touch") return;
|
|
130
|
+
if (typeAt(e) != null) setPreview(typeAt(e));
|
|
131
|
+
else if (!e.target.closest(".chart")) setPreview(null);
|
|
132
|
+
};
|
|
133
|
+
const readout = () => {
|
|
134
|
+
const i = active();
|
|
135
|
+
if (i == null) return "Pick a type to see who can receive it.";
|
|
136
|
+
const n = reach(i);
|
|
137
|
+
return `${TYPES[i].type} blood can go to ${n === TOTAL ? "all" : n + " of"} ${TOTAL} people`;
|
|
138
|
+
};
|
|
139
|
+
return html`
|
|
140
|
+
<${Poster}
|
|
141
|
+
kicker="Blood types · 100 people"
|
|
142
|
+
title="Seven in 100 Americans have the blood anyone can receive"
|
|
143
|
+
dek="Each drop is one person, colored by blood group, and a hollow drop is Rh-negative. Only O− red cells can be given to every type, so its donors are always in demand."
|
|
144
|
+
note="Source: Stanford Blood Center, shares of the US population (from the AABB Technical Manual, 18th edition, 2014), rounded to whole people. Compatibility of red cells. Point at a drop, tap it, or pick a type."
|
|
145
|
+
onPointerMove=${hover}
|
|
146
|
+
onPointerLeave=${(e) => setPreview(null)}
|
|
147
|
+
onClick=${(e) => typeAt(e) != null && pick(typeAt(e))}>
|
|
148
|
+
<div class="types" role="group" aria-label="Blood type">
|
|
149
|
+
<${For} each=${TYPES}>${(t, i) => html`
|
|
150
|
+
<button type="button" aria-pressed=${() => picked() === i()} onClick=${() => pick(i())}>
|
|
151
|
+
<svg viewBox="0 0 14 17" aria-hidden="true" style=${{ "--c": colorOf(t) }}><path d=${negative(t) ? SWATCH + HOLE : SWATCH} /></svg>${t.type}<span>${t.people}</span>
|
|
152
|
+
</button>`}<//>
|
|
153
|
+
</div>
|
|
154
|
+
<p class="readout" aria-live="polite">${readout}</p>
|
|
155
|
+
<${Chart} class="chart" aspect=${1} ticks=${false} label="Blood types of 100 people in the US, one drop per person"
|
|
156
|
+
theme=${{ font: "'Public Sans', 'Helvetica Neue', Arial, sans-serif", ink: "#1b1d21", surface: "#fbfbf9" }}>
|
|
157
|
+
<${Plot} people=${LINES} active=${active}>${TenPeople}<//>
|
|
158
|
+
<//>
|
|
159
|
+
<//>`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
render(() => html`<${BloodTypes} />`, document.getElementById("chart"));
|
|
163
|
+
</script>
|
|
164
|
+
</body>
|
|
165
|
+
</html>
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Profit bridge</title>
|
|
7
|
+
<meta name="description" content="Waterfall chart from a starting value through gains and losses to a final total, each step linked to the next, with a switch between periods. Use it to show how one amount becomes another, step by step.">
|
|
8
|
+
<!--
|
|
9
|
+
A recipe gives technique, not a design.
|
|
10
|
+
Take: Bars between running totals with a link from each step to the next, and a switch between periods.
|
|
11
|
+
Leave: the design. This poster was made for its own subject (a profit bridge as a page of an accountant's ledger, one ruled line per step and the bottom line double ruled).
|
|
12
|
+
Its look, the place of its parts and the way its marks are drawn say that subject: design yours from your own (design.md, section 2).
|
|
13
|
+
-->
|
|
14
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
15
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
16
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:opsz,wght@9..40,400..700&family=DM+Serif+Display&display=swap">
|
|
17
|
+
<script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>
|
|
18
|
+
<style>
|
|
19
|
+
* { box-sizing: border-box; }
|
|
20
|
+
body { margin: 0; padding: 48px 16px; background: #d6d3cb; font-family: "DM Sans", "Segoe UI", system-ui, sans-serif; }
|
|
21
|
+
/* The poster: an accountant's ledger, serif headline, figures in mono, one ruled line per step, the bottom line double-ruled */
|
|
22
|
+
.poster { max-width: 900px; margin: 0 auto; padding: 46px 52px 30px; color: #2a2f38; border-radius: 3px; background: #f6f3ea;
|
|
23
|
+
box-shadow: 0 26px 50px -30px rgb(62 58 50 / .6); }
|
|
24
|
+
.ledger { background: repeating-linear-gradient(transparent 0 49px, rgb(42 47 56 / .12) 49px 50px) 0 2px; }
|
|
25
|
+
.poster figcaption { display: grid; gap: 12px; margin-bottom: 24px; }
|
|
26
|
+
.poster .kicker { font: 500 12px/1.3 "DM Mono", ui-monospace, Menlo, monospace; letter-spacing: .14em; text-transform: uppercase; color: #646b78; }
|
|
27
|
+
.poster .headline { max-width: 15em; font: 400 52px/1.02 "DM Serif Display", Georgia, serif; letter-spacing: -.01em; text-wrap: balance; }
|
|
28
|
+
.poster .dek { max-width: 56ch; font-size: 16px; line-height: 1.55; color: #4f5562; }
|
|
29
|
+
.poster .note { display: block; margin-top: 22px; font-size: 12px; line-height: 1.5; color: #646b78; }
|
|
30
|
+
/* The quarter switch, as ledger tabs, and the one-line readout of the quarter shown */
|
|
31
|
+
.quarters { display: inline-flex; border: 1.5px solid #2a2f38; border-radius: 6px; overflow: hidden; }
|
|
32
|
+
.quarters button { padding: 8px 16px; border: 0; border-left: 1.5px solid #2a2f38; background: #f6f3ea; color: #2a2f38; font: 500 14px/1 "DM Mono", ui-monospace, Menlo, monospace; cursor: pointer; }
|
|
33
|
+
.quarters button:first-child { border-left: 0; }
|
|
34
|
+
.quarters button[aria-pressed="true"] { background: #2a2f38; color: #f6f3ea; }
|
|
35
|
+
.quarters button:focus-visible { outline: 3px solid #2457c5; outline-offset: -3px; }
|
|
36
|
+
.readout { min-height: 1lh; margin: 14px 0 18px; font-size: 15px; line-height: 1.45; color: #4f5562; }
|
|
37
|
+
.readout b { font: 500 17px/1 "DM Mono", ui-monospace, Menlo, monospace; color: #1f4fae; }
|
|
38
|
+
@media (max-width: 600px) {
|
|
39
|
+
body { padding: 16px 16px 32px; }
|
|
40
|
+
.poster { padding: 26px 16px 18px; }
|
|
41
|
+
.poster .headline { font-size: 34px; }
|
|
42
|
+
.poster .dek { font-size: 15px; }
|
|
43
|
+
.readout { min-height: 2lh; }
|
|
44
|
+
}
|
|
45
|
+
</style>
|
|
46
|
+
</head>
|
|
47
|
+
<body>
|
|
48
|
+
<div id="chart"></div>
|
|
49
|
+
<script type="module">
|
|
50
|
+
import { Chart, Plot, Bar, Tick, Label, Poster, Show, slat, nice, running, html, render, createSignal, createMemo } from "@bezda/rhp/standalone";
|
|
51
|
+
|
|
52
|
+
// Data: a company's quarters in millions of dollars, revenue first, then each cost as a negative. Illustrative data; replace with your own.
|
|
53
|
+
const ITEMS = ["Revenue", "Cost of sales", "Research", "Sales & marketing", "Admin", "Tax"];
|
|
54
|
+
const TOTAL = "Net profit"; // the last step: what is left
|
|
55
|
+
const QUARTERS = [
|
|
56
|
+
{ name: "Q1", amounts: [120, -52, -22, -26, -9, -3] },
|
|
57
|
+
{ name: "Q2", amounts: [128, -54, -23, -24, -9, -4] },
|
|
58
|
+
{ name: "Q3", amounts: [135, -56, -24, -21, -10, -5] },
|
|
59
|
+
{ name: "Q4", amounts: [151, -61, -25, -22, -10, -7] },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const compact = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: "compact", maximumFractionDigits: 0 });
|
|
63
|
+
const money = (m) => compact.format(m * 1e6).replace("-", "−"); // -61 -> −$61M, with a real minus sign
|
|
64
|
+
const percent = new Intl.NumberFormat("en-US", { style: "percent" });
|
|
65
|
+
const scale = nice(0, Math.max(...QUARTERS.map((q) => q.amounts[0])) * 1.15, 4); // 0 to $200M, room for the labels
|
|
66
|
+
|
|
67
|
+
// Slat type: a step from the running total before it to the one after, a hairline down to the next step, and its amount
|
|
68
|
+
const Step = slat({
|
|
69
|
+
thickness: 50,
|
|
70
|
+
inset: "12px",
|
|
71
|
+
room: { start: "auto", end: 24 }, // the end: half of the last axis number, "$200M"
|
|
72
|
+
css: `
|
|
73
|
+
.item { font-size: 15px; font-weight: 600; --rhp-label-gap: 14px; }
|
|
74
|
+
.bar { --rhp-radius: 2px; }
|
|
75
|
+
.in .bar { background: #5d6676; }
|
|
76
|
+
.out .bar { background: #c9472b; }
|
|
77
|
+
.total .bar { background: #2457c5; }
|
|
78
|
+
.link { background: #8b909a; --rhp-tick-width: 1px; }
|
|
79
|
+
.link:horizontal { top: calc(100% - 12px); height: 24px; translate: -50% 0; }
|
|
80
|
+
.link:vertical { left: calc(100% - 12px); width: 24px; translate: 0 50%; }
|
|
81
|
+
.amount { font: 500 13px/1 "DM Mono", ui-monospace, Menlo, monospace; --rhp-label-gap: 8px; }
|
|
82
|
+
.out .amount { color: #b23a22; }
|
|
83
|
+
.total .amount { color: #1f4fae; font-size: 14px; text-decoration: underline double; text-underline-offset: 5px; }
|
|
84
|
+
@container (max-width: 420px) { .item { max-width: 6.5em; font-size: 13px; line-height: 1.1; white-space: normal; --rhp-label-gap: 8px; } .amount { font-size: 12px; } }
|
|
85
|
+
`,
|
|
86
|
+
}, (d) => html`
|
|
87
|
+
<div class=${() => "step " + d.kind}>
|
|
88
|
+
<${Label} edge="start" class="item">${() => d.item}<//>
|
|
89
|
+
<${Bar} from=${() => d.from} to=${() => d.to} class="bar" />
|
|
90
|
+
<${Show} when=${() => d.kind !== "total"}><${Tick} at=${() => d.to} class="link" /><//>
|
|
91
|
+
<${Label} at=${() => Math.max(d.from, d.to)} class="amount">${() => money(d.to - d.from)}<//>
|
|
92
|
+
</div>`);
|
|
93
|
+
|
|
94
|
+
// Chart component: the quarter picked feeds running(), so every step, the profit and the readout move together
|
|
95
|
+
function ProfitBridge() {
|
|
96
|
+
const [q, setQ] = createSignal(QUARTERS.length - 1);
|
|
97
|
+
const quarter = () => QUARTERS[q()];
|
|
98
|
+
const steps = createMemo(() => {
|
|
99
|
+
const r = running(quarter().amounts); // each step from the total before it to the one after
|
|
100
|
+
return { from: [...r.from, 0], to: [...r.to, r.to.at(-1)] }; // and the profit, from 0
|
|
101
|
+
});
|
|
102
|
+
const profit = () => steps().to.at(-1);
|
|
103
|
+
return html`
|
|
104
|
+
<${Poster}
|
|
105
|
+
kicker="Quarterly results · profit bridge"
|
|
106
|
+
title="Profit tripled over the year while revenue grew by a quarter"
|
|
107
|
+
dek="How each quarter's revenue became profit, in millions of dollars. Each cost comes off one step at a time, and what is left at the bottom is the profit."
|
|
108
|
+
note="Illustrative data for a fictional company.">
|
|
109
|
+
<div class="quarters" role="group" aria-label="Quarter">
|
|
110
|
+
${QUARTERS.map((x, i) => html`<button type="button" aria-pressed=${() => q() === i} onClick=${() => setQ(i)}>${x.name}</button>`)}
|
|
111
|
+
</div>
|
|
112
|
+
<p class="readout" aria-live="polite">
|
|
113
|
+
${() => quarter().name + ": "}<b>${() => money(profit())}</b>${() => ` net profit on ${money(quarter().amounts[0])} of revenue, a margin of ${percent.format(profit() / quarter().amounts[0])}.`}
|
|
114
|
+
</p>
|
|
115
|
+
<${Chart} class="ledger" scale=${[scale.min, scale.max]} ticks=${scale.ticks} format=${money}
|
|
116
|
+
label=${() => "Profit bridge for " + quarter().name + ", in millions of dollars"}
|
|
117
|
+
theme=${{ font: '"DM Sans", "Segoe UI", system-ui, sans-serif', ink: "#2a2f38", muted: "#646b78", grid: "#dcd8cd" }}>
|
|
118
|
+
<${Plot} item=${[...ITEMS, TOTAL]} from=${() => steps().from} to=${() => steps().to}
|
|
119
|
+
kind=${(d) => (d.index === ITEMS.length ? "total" : d.to >= d.from ? "in" : "out")}>${Step}<//>
|
|
120
|
+
<//>
|
|
121
|
+
<//>`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
render(() => html`<${ProfitBridge} />`, document.getElementById("chart"));
|
|
125
|
+
</script>
|
|
126
|
+
</body>
|
|
127
|
+
</html>
|