@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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -2
  3. package/bin/rhp-mcp.js +129 -0
  4. package/package.json +62 -4
  5. package/skill/SKILL.md +450 -0
  6. package/skill/recipes/area.html +156 -0
  7. package/skill/recipes/bar.html +145 -0
  8. package/skill/recipes/box-plot.html +164 -0
  9. package/skill/recipes/bubble.html +188 -0
  10. package/skill/recipes/bullet.html +138 -0
  11. package/skill/recipes/candlestick.html +179 -0
  12. package/skill/recipes/column.html +138 -0
  13. package/skill/recipes/dial.html +349 -0
  14. package/skill/recipes/diverging-bars.html +150 -0
  15. package/skill/recipes/donut.html +231 -0
  16. package/skill/recipes/dumbbell.html +137 -0
  17. package/skill/recipes/gantt.html +196 -0
  18. package/skill/recipes/grouped-bars.html +142 -0
  19. package/skill/recipes/heatmap.html +183 -0
  20. package/skill/recipes/histogram.html +199 -0
  21. package/skill/recipes/line.html +170 -0
  22. package/skill/recipes/live.html +255 -0
  23. package/skill/recipes/log-line.html +493 -0
  24. package/skill/recipes/lollipop.html +152 -0
  25. package/skill/recipes/multi-line.html +228 -0
  26. package/skill/recipes/pyramid.html +162 -0
  27. package/skill/recipes/race.html +278 -0
  28. package/skill/recipes/radial-bars.html +157 -0
  29. package/skill/recipes/scatter.html +199 -0
  30. package/skill/recipes/silhouettes.html +352 -0
  31. package/skill/recipes/slope.html +199 -0
  32. package/skill/recipes/sparklines.html +175 -0
  33. package/skill/recipes/stacked-100.html +158 -0
  34. package/skill/recipes/stacked-bars.html +150 -0
  35. package/skill/recipes/strip.html +221 -0
  36. package/skill/recipes/unit-stack.html +276 -0
  37. package/skill/recipes/violin.html +169 -0
  38. package/skill/recipes/waffle.html +165 -0
  39. package/skill/recipes/waterfall.html +127 -0
  40. package/skill/references/api.md +1684 -0
  41. package/skill/references/design.md +740 -0
  42. package/skill/references/environments.md +1577 -0
  43. package/skill/references/forms.md +197 -0
  44. package/skill/references/interaction.md +794 -0
  45. package/skill/references/looks.md +267 -0
  46. package/skill/references/pitfalls.md +202 -0
  47. package/src/check/README.md +176 -0
  48. package/src/check/browser.js +92 -0
  49. package/src/check/build.js +439 -0
  50. package/src/check/colors.js +324 -0
  51. package/src/check/findings.js +247 -0
  52. package/src/check/index.js +508 -0
  53. package/src/check/inpage.js +1332 -0
  54. package/src/check/interact.js +266 -0
  55. package/src/check/png.js +98 -0
  56. package/src/check/report.js +106 -0
  57. package/src/check/source.js +387 -0
  58. package/src/check/sourcemap.js +75 -0
  59. package/src/guard/note.js +30 -0
  60. package/src/guard/render.js +17 -0
  61. package/src/guard/rhp.js +612 -0
  62. package/src/guard/rules.js +125 -0
  63. package/src/server.js +266 -0
  64. package/src/skill.js +140 -0
@@ -0,0 +1,145 @@
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>The most spoken languages</title>
7
+ <meta name="description" content="Horizontal bar chart, ranked, the top bar highlighted, with a readout on hover, focus and tap. Use it to rank categories by one value, especially with long names.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: ranked Bars with names in an "auto" room, the leader lit, and a readout on hover, focus and tap that stays at the top of a phone's screen.
11
+ Leave: the design. This poster was made for its own subject (the world's languages as speech: each bar a speech bubble, each name in its own script, the share under a big quotation mark).
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=Instrument+Sans:wght@400..700&family=Instrument+Serif:ital@0;1&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: #d8d4cc; font-family: "Instrument Sans", "Helvetica Neue", Arial, sans-serif; }
21
+ /* The poster: warm paper with a fine grain, black type, one vermilion accent */
22
+ .poster { display: grid; grid-template-columns: minmax(0, 1fr) 230px; column-gap: 48px; max-width: 940px; margin: 0 auto; padding: 52px 56px 32px; color: #161412; border-radius: 3px;
23
+ background: #f4f1ea url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .09 0 0 0 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E");
24
+ box-shadow: 0 1px 2px rgb(60 55 48 / .2), 0 28px 50px -28px rgb(60 55 48 / .55); }
25
+ .poster > * { grid-column: 1 / -1; min-width: 0; }
26
+ .poster figcaption { grid-column: 1; display: grid; gap: 14px; margin-bottom: 34px; }
27
+ .poster .kicker { font-size: 12px; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; }
28
+ .poster .headline { font: 400 68px/.94 "Instrument Serif", Georgia, serif; letter-spacing: -.01em; text-wrap: balance; }
29
+ .poster .headline em { color: #c8391a; }
30
+ .poster .dek { max-width: 52ch; font-size: 17px; line-height: 1.5; color: #48433c; }
31
+ .poster .note { display: block; margin-top: 26px; padding-top: 12px; border-top: 1px solid #d6d0c4; font-size: 12px; line-height: 1.5; color: #6b655c; }
32
+ /* The readout: the share of the world that speaks the language in focus, under a big quotation mark */
33
+ .readout { grid-column: 2; grid-row: 1; align-self: end; margin: 0 0 34px; font-size: 15px; line-height: 1.35; color: #48433c; }
34
+ .readout::before { content: "\201C" / ""; display: block; height: 72px; font: 400 200px/1 "Instrument Serif", Georgia, serif; color: #de4a24; }
35
+ .readout b { display: block; margin-bottom: 14px; font: 400 92px/.9 "Instrument Serif", Georgia, serif; color: #161412; }
36
+ .readout strong { display: block; font-size: 22px; font-weight: 600; color: #b8321a; }
37
+ /* On a phone the chart is taller than half the screen, so the readout stays at the top of the screen while the
38
+ chart scrolls under it (the poster is a flex column there: a sticky grid item can't leave its own row) */
39
+ @media (max-width: 760px) {
40
+ body { padding: 16px 16px 32px; }
41
+ .poster { display: flex; flex-direction: column; padding: 26px 18px 18px; }
42
+ .poster figcaption { margin-bottom: 18px; }
43
+ .poster .headline { font-size: 42px; }
44
+ .poster .dek { font-size: 15px; }
45
+ .readout { position: sticky; top: 0; z-index: 2; align-self: stretch; display: grid; grid-template-columns: auto 1fr; align-items: center; column-gap: 14px; margin-bottom: 18px; padding: 10px 0; border-block: 1px solid #d6d0c4; background: inherit; }
46
+ .readout::before { display: none; }
47
+ .readout b { grid-row: span 2; margin: 0; font-size: 56px; }
48
+ .readout strong { font-size: 18px; }
49
+ }
50
+ </style>
51
+ </head>
52
+ <body>
53
+ <div id="chart"></div>
54
+ <script type="module">
55
+ import { Chart, Plot, Bar, Label, Poster, slat, sortBy, nice, html, render, createSignal } from "@bezda/rhp/standalone";
56
+
57
+ // Data: total speakers (first and second language) in millions, from Ethnologue, 2023. Replace with your own.
58
+ const LANGUAGES = [
59
+ { name: "English", own: "English", millions: 1456 },
60
+ { name: "Mandarin Chinese", own: "普通话", millions: 1138 },
61
+ { name: "Hindi", own: "हिन्दी", millions: 610 },
62
+ { name: "Spanish", own: "Español", millions: 559 },
63
+ { name: "French", own: "Français", millions: 310 },
64
+ { name: "Standard Arabic", own: "العربية", millions: 274 },
65
+ { name: "Bengali", own: "বাংলা", millions: 273 },
66
+ { name: "Portuguese", own: "Português", millions: 264 },
67
+ { name: "Russian", own: "Русский", millions: 255 },
68
+ { name: "Urdu", own: "اردو", millions: 232 },
69
+ ];
70
+ const WORLD = 8045; // world population in 2023, millions (UN)
71
+
72
+ const count = new Intl.NumberFormat("en-US");
73
+ const percent = new Intl.NumberFormat("en-US", { style: "percent", maximumSignificantDigits: 2 });
74
+ const scale = nice(0, Math.max(...LANGUAGES.map((d) => d.millions)), 3); // 0 to 1,500, ticks every 500
75
+ const leader = LANGUAGES.reduce((best, d, i) => (d.millions > LANGUAGES[best].millions ? i : best), 0);
76
+
77
+ // Slat type: a language's name over its own name for itself, a speech-bubble bar, and its total
78
+ const Language = slat({
79
+ thickness: 46,
80
+ inset: "12px",
81
+ room: { start: "auto", end: 64 },
82
+ css: `
83
+ .name { font-size: 15px; font-weight: 600; line-height: 1.15; }
84
+ .name small { display: block; font-size: 12px; font-weight: 400; color: var(--rhp-muted); }
85
+ .bar { --fill: #8c867c; background: var(--fill); --rhp-radius: 10px; --rhp-start-radius: 4px; }
86
+ .bar::after { content: ""; position: absolute; left: 8px; top: calc(100% - 1px); border-top: 7px solid var(--fill); border-right: 9px solid transparent; transition: border-color .15s; }
87
+ .bar:vertical::after { display: none; }
88
+ .value { font-size: 14px; font-weight: 600; font-variant-numeric: tabular-nums; --rhp-label-gap: 8px; }
89
+ .lit .bar { --fill: #de4a24; }
90
+ .lit .name, .lit .value { color: #b8321a; }
91
+ .language:focus-visible { outline: 2px solid #161412; outline-offset: -2px; border-radius: 4px; }
92
+ @container (max-width: 420px) { .name { font-size: 13px; } .name small { font-size: 11px; } .value { font-size: 12px; } }
93
+ @container (max-width: 300px) { .name { max-width: 6.5em; white-space: normal; } } /* long names wrap, so the bars keep their room */
94
+ `,
95
+ }, (d) => html`
96
+ <div class=${() => (d.lit ? "language lit" : "language")} data-row=${() => d.index} aria-label=${() => `${d.name}: ${count.format(d.millions)} million speakers`}>
97
+ <${Label} edge="start" class="name">${() => d.name}<small>${() => d.own}</small><//>
98
+ <${Bar} to=${() => d.millions} class="bar" />
99
+ <${Label} at=${() => d.millions} class="value">${() => count.format(d.millions)}<//>
100
+ </div>`);
101
+
102
+ // Chart component: the language the reader points at, taps or reaches with the keyboard is lit and read out
103
+ function SpokenLanguages() {
104
+ const [picked, setPicked] = createSignal(null);
105
+ const lit = () => picked() ?? leader;
106
+ // The row of the slat an element is in, or null (between slats, on the axis or the text)
107
+ const rowOf = (el) => {
108
+ const root = el.closest("[data-row]");
109
+ return root ? +root.dataset.row : null;
110
+ };
111
+ // A mouse or a pen picks as it moves, a tap or a click picks, the keyboard picks; on no slat the pick stays
112
+ const pick = (e) => {
113
+ const row = rowOf(e.target);
114
+ if (row != null) setPicked(row);
115
+ };
116
+ // Leaving the poster: back to the slat that has focus (a keyboard's or a click's pick), or none
117
+ const leave = (e) => {
118
+ if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
119
+ setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
120
+ };
121
+ return html`
122
+ <${Poster}
123
+ kicker="The world's languages"
124
+ title=${html`<em>English</em> is the world's most spoken language`}
125
+ dek="Total speakers in millions, counting everyone who speaks it as a first or a second language. Mandarin has more native speakers; English has more learners. Tap or point at a language."
126
+ note="Source: Ethnologue, 2023 (speakers); United Nations, 2023 (world population, 8.05 billion)."
127
+ onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
128
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
129
+ <p class="readout" aria-live="polite">
130
+ <b>${() => percent.format(LANGUAGES[lit()].millions / WORLD)}</b>
131
+ <span>of people on Earth speak</span>
132
+ <strong>${() => LANGUAGES[lit()].name}</strong>
133
+ </p>
134
+ <${Chart} scale=${[scale.min, scale.max]} ticks=${scale.ticks} format=${(v) => count.format(v)}
135
+ label="The ten most spoken languages, total speakers in millions"
136
+ theme=${{ font: '"Instrument Sans", "Helvetica Neue", Arial, sans-serif', ink: "#161412", muted: "#6b655c", grid: "#ddd7cb" }}>
137
+ <${Plot} rows=${LANGUAGES} order=${sortBy("millions", "desc")} lit=${(d) => d.index === lit()} keyboard=${true}>${Language}<//>
138
+ <//>
139
+ <//>`;
140
+ }
141
+
142
+ render(() => html`<${SpokenLanguages} />`, document.getElementById("chart"));
143
+ </script>
144
+ </body>
145
+ </html>
@@ -0,0 +1,164 @@
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>Bikes beat cars, and keep to time</title>
7
+ <meta name="description" content="Box plot of commute times by mode of transport made with summary(): whiskers, the middle half as a box, the median and outliers, with a readout of the picked group. Use it to compare the spread of several groups at a glance.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: summary() drawn as whiskers, a box and a median in one slat, outliers in an overlay Plot, and a readout of the picked group.
11
+ Leave: the design. This poster was made for its own subject (commute times on the enamel map board of a tiled station wall, the kicker as the station's sign).
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=Overpass:wght@400..900&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: light; }
20
+ * { box-sizing: border-box; }
21
+ /* The page is a tiled station wall; the poster is the enamel map board hung on it */
22
+ body { margin: 0; padding: 48px 16px; font-family: Overpass, "Helvetica Neue", Arial, sans-serif; color: #16171a;
23
+ background: #e3e3de url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='64'%3E%3Cpath d='M0 1H96M0 33H96M1 0V32M49 32V64' fill='none' stroke='%23cdcdc8' stroke-width='2'/%3E%3C/svg%3E"); }
24
+ .poster { max-width: 960px; margin: 0 auto; padding: 0 44px 26px; border-radius: 6px; line-height: 1.45; overflow: hidden;
25
+ background: linear-gradient(170deg, #fdfdfb, #f5f5f1) #f8f8f4;
26
+ box-shadow: 0 0 0 1px rgb(0 0 0 / .08), 0 22px 40px -22px rgb(0 0 0 / .5); }
27
+ .poster figcaption { display: grid; gap: 10px; margin-bottom: 18px; }
28
+ /* The kicker is the station sign across the top */
29
+ .poster .kicker { display: block; margin: 0 -44px 18px; padding: 13px 44px 10px; background: #16171a; color: #fff;
30
+ font-size: 13px; font-weight: 800; letter-spacing: .16em; text-transform: uppercase; }
31
+ .poster .headline { font-size: 56px; font-weight: 900; line-height: .98; letter-spacing: -.025em; text-wrap: balance; }
32
+ .poster .dek { max-width: 58ch; font-size: 16px; color: #4a4c52; }
33
+ .poster .note { display: block; margin-top: 18px; font-size: 12px; color: #66686e; }
34
+ /* How to read a box, drawn with the chart's own marks */
35
+ .key { display: flex; flex-wrap: wrap; gap: 6px 20px; margin: 0 0 14px; padding: 0; list-style: none; font-size: 13px; color: #4a4c52; }
36
+ .key li { display: flex; align-items: center; gap: 8px; }
37
+ .key i { display: inline-block; flex: none; }
38
+ .key .line { width: 26px; height: 3px; border-radius: 2px; background: #8d9097; }
39
+ .key .box { width: 26px; height: 12px; border-radius: 6px; background: #8d9097; }
40
+ .key .stop { width: 4px; height: 18px; border-radius: 2px; background: #16171a; box-shadow: 0 0 0 2px #fbfbf8; }
41
+ .key .ring { width: 9px; height: 9px; border-radius: 50%; box-shadow: inset 0 0 0 2px #8d9097; }
42
+ /* The readout: the picked mode in one sentence, two lines tall so the chart never moves */
43
+ .readout { min-height: 3.1em; margin: 0 0 6px; padding: 10px 0 0; border-top: 2px solid #16171a; font-size: 16px; line-height: 1.45; }
44
+ .readout b { font-weight: 800; }
45
+ .readout i { display: inline-block; width: 12px; height: 12px; margin-right: 7px; border-radius: 50%; vertical-align: -1px; }
46
+ .axis-name { margin: 4px 0 0; font-size: 12px; font-weight: 700; color: #66686e; text-align: right; }
47
+ @media (max-width: 600px) {
48
+ body { padding: 16px; }
49
+ .poster { padding: 0 16px 18px; }
50
+ .poster .kicker { margin: 0 -16px 14px; padding: 11px 16px 8px; font-size: 11px; letter-spacing: .12em; }
51
+ .poster .headline { font-size: 36px; }
52
+ .poster .dek { font-size: 14.5px; }
53
+ .key { gap: 4px 14px; font-size: 12px; }
54
+ .readout { min-height: 4.4em; font-size: 14.5px; }
55
+ }
56
+ </style>
57
+ </head>
58
+ <body>
59
+ <div id="chart"></div>
60
+ <script type="module">
61
+ import { Chart, Plot, Bar, Dot, Tick, Label, Poster, slat, summary, sortBy, every, html, render, createSignal } from "@bezda/rhp/standalone";
62
+
63
+ // Data: one-way commute times in minutes, one per trip, by how people travel. Illustrative. Replace with your own.
64
+ const TRIPS = {
65
+ Walk: [8, 19, 13, 25, 17, 10, 21, 14, 28, 6, 17, 11, 22, 14, 40, 8, 18, 12, 25, 16, 10, 21, 15, 31],
66
+ Bike: [14, 22, 34, 18, 24, 21, 28, 16, 23, 19, 25, 11, 21, 30, 16, 22, 19, 26, 14, 21, 17, 23, 20, 28, 16, 22, 19, 26],
67
+ Car: [19, 28, 46, 22, 33, 17, 58, 25, 38, 19, 29, 11, 50, 23, 34, 17, 85, 26, 42, 20, 31, 15, 55, 24, 37, 19, 29, 48, 23, 35, 18, 72, 27, 42, 21, 32, 15, 54, 25, 37],
68
+ Metro: [30, 40, 26, 33, 19, 29, 37, 24, 32, 49, 28, 35, 23, 31, 40, 27, 34, 18, 29, 37, 24, 31, 42, 27, 34, 21, 29, 38, 24, 31, 57, 27, 34, 22],
69
+ Bus: [29, 40, 60, 33, 45, 24, 37, 53, 30, 41, 17, 69, 34, 46, 26, 38, 56, 31, 43, 23, 36, 50, 29, 41, 62, 34, 46, 26, 38, 56, 32, 44, 22, 80, 36, 49],
70
+ Train: [55, 45, 32, 51, 42, 60, 48, 38, 54, 45, 67, 29, 51, 41, 58, 46, 34, 52, 42, 60, 48, 38],
71
+ };
72
+ const LINE_COLORS = ["#1baf7a", "#eb6834", "#4a3aa7", "#eda100", "#2a78d6", "#e34948"]; // one per mode, in TRIPS order
73
+ const STORY = "Bike"; // the mode the readout opens on
74
+
75
+ // One row per mode: its name, color, number of trips and its box-plot numbers ({ low, q1, median, q3, high, outliers })
76
+ const MODES = Object.entries(TRIPS).map(([mode, trips], i) => ({ mode, color: LINE_COLORS[i], trips: trips.length, ...summary(trips) }));
77
+ const longest = Math.max(...MODES.map((m) => m.max));
78
+ const scaleMax = Math.ceil(longest / 15) * 15; // quarter hours
79
+ const minutes = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
80
+
81
+ // Slat types: an unusually slow trip is a ring; a mode is a transit line, its box the thick stretch and its median a station named with its time
82
+ const Outlier = slat({ css: `.slow { width: 9px; height: 9px; background: var(--rhp-surface); box-shadow: inset 0 0 0 2px var(--rhp-color); }` },
83
+ (o) => html`<${Dot} at=${() => o.at} color=${() => o.color} class="slow" />`);
84
+
85
+ const Mode = slat({
86
+ thickness: 58,
87
+ room: { start: "auto", end: 16 },
88
+ css: `
89
+ .name { display: flex; align-items: center; gap: 9px; justify-self: stretch; font-size: 15px; font-weight: 800; --rhp-label-gap: 16px; }
90
+ .name i { width: 13px; height: 13px; border-radius: 50%; background: var(--line); }
91
+ .range { --rhp-radius: 2px; background: var(--line); }
92
+ .end { --rhp-tick-width: 3px; background: var(--line); border-radius: 2px; }
93
+ .middle { --rhp-radius: 99px; background: var(--line); }
94
+ .stop { --rhp-tick-width: 4px; border-radius: 2px; background: #16171a; box-shadow: 0 0 0 2px var(--rhp-surface); }
95
+ .time { top: calc(50% - 13px); translate: -50% -100%; padding: 0; font-size: 12.5px; font-weight: 800; }
96
+ .mode:focus-visible { outline: 2px solid #16171a; outline-offset: -2px; border-radius: 6px; }
97
+ .mode.on { background: rgb(22 23 26 / .05); border-radius: 6px; }
98
+ .dim > :not(.name, .time) { opacity: .28; }
99
+ .dim :is(.name, .time) { color: var(--rhp-muted); }
100
+ .say { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
101
+ `,
102
+ }, (d) => html`
103
+ <div class=${() => (d.picked == null ? "mode" : d.picked === d.index ? "mode on" : "mode dim")} data-row=${() => d.index} style=${() => ({ "--line": d.color })}>
104
+ <${Label} edge="start" class="name"><i></i>${() => d.mode}<//>
105
+ <${Bar} from=${() => d.low} to=${() => d.high} thick="3px" class="range" />
106
+ <${Tick} at=${() => d.low} thick="12px" class="end" />
107
+ <${Tick} at=${() => d.high} thick="12px" class="end" />
108
+ <${Bar} from=${() => d.q1} to=${() => d.q3} thick="12px" class="middle" />
109
+ <${Plot} overlap=${true} at=${() => d.outliers} color=${() => d.color}>${Outlier}<//>
110
+ <${Tick} at=${() => d.median} thick="20px" class="stop" />
111
+ <${Label} at=${() => d.median} class="time">${() => `${minutes.format(d.median)} min`}<//>
112
+ <span class="say">${() => `: half of all trips take ${minutes.format(d.q1)} to ${minutes.format(d.q3)} minutes, usually ${minutes.format(d.low)} to ${minutes.format(d.high)}`}</span>
113
+ </div>`);
114
+
115
+ // Chart component: the picked mode (pointer, tap or arrow keys) is read out above the chart and the others fade
116
+ function CommuteTimes() {
117
+ const [picked, setPicked] = createSignal(null);
118
+ const story = Math.max(0, MODES.findIndex((m) => m.mode === STORY));
119
+ const shown = () => MODES[picked() ?? story];
120
+ // The row of the slat an element is in, or null (between slats, on the axis or the text)
121
+ const rowOf = (el) => {
122
+ const root = el.closest("[data-row]");
123
+ return root ? +root.dataset.row : null;
124
+ };
125
+ // A mouse or a pen picks as it moves, a tap or a click picks, the keyboard picks; on no slat the pick stays
126
+ const pick = (e) => {
127
+ const row = rowOf(e.target);
128
+ if (row != null) setPicked(row);
129
+ };
130
+ // Leaving the poster: back to the slat that has focus (a keyboard's or a click's pick), or none
131
+ const leave = (e) => {
132
+ if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
133
+ setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
134
+ };
135
+ return html`
136
+ <${Poster}
137
+ kicker="Commute times, door to door"
138
+ title="Bikes beat cars, and keep to time"
139
+ dek=${`One-way commutes timed over a month, ${MODES.reduce((n, m) => n + m.trips, 0)} trips in all. A cyclist's trips stay within a few minutes of each other; a driver's can take twice as long on a bad day. Tap or point at a mode for its numbers.`}
140
+ note="Illustrative data."
141
+ onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
142
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
143
+ <ul class="key">
144
+ <li><i class="box"></i>Middle half of trips</li>
145
+ <li><i class="stop"></i>Typical trip (median)</li>
146
+ <li><i class="line"></i>Usual range</li>
147
+ <li><i class="ring"></i>Unusually slow trip</li>
148
+ </ul>
149
+ <p class="readout">
150
+ <i style=${() => ({ background: shown().color })}></i><b>${() => shown().mode}</b>: the typical trip takes <b>${() => minutes.format(shown().median)} minutes</b>, and half of all trips take between ${() => minutes.format(shown().q1)} and ${() => minutes.format(shown().q3)}.
151
+ </p>
152
+ <${Chart} scale=${[0, scaleMax]} ticks=${every(15)} format=${(v) => minutes.format(v)}
153
+ label="Box plot of one-way commute times in minutes, by mode of transport"
154
+ theme=${{ font: 'Overpass, "Helvetica Neue", Arial, sans-serif', ink: "#16171a", muted: "#66686e", grid: "#e2e2dd", surface: "#fbfbf8" }}>
155
+ <${Plot} rows=${MODES} order=${sortBy("median")} picked=${picked} keyboard=${true}>${Mode}<//>
156
+ <//>
157
+ <p class="axis-name">Minutes, door to door</p>
158
+ <//>`;
159
+ }
160
+
161
+ render(() => html`<${CommuteTimes} />`, document.getElementById("chart"));
162
+ </script>
163
+ </body>
164
+ </html>
@@ -0,0 +1,188 @@
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>Two ways to hold 20 million</title>
7
+ <meta name="description" content="Bubble chart: two measures per item on a second axis (log scales) and a third as the bubble's area, with guide lines of equal size, labels for the story's items and a readout for the bubble under the pointer, a tap or the arrow keys. Use it for items with two measures and a size.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: Dots on two log axes sized by area, guide lines of equal size, labels placed per row, and a readout of the bubble under the pointer.
11
+ Leave: the design. This poster was made for its own subject (cities seen from orbit at night, with a map's scale bar under the kicker).
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=Sora:wght@400..700&family=DM+Mono:wght@400;500&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: #3a3d42; font-family: Sora, "Helvetica Neue", Arial, sans-serif; }
21
+ /* The poster: a city seen from orbit at night, with a map's scale bar under the kicker */
22
+ .poster { max-width: 920px; margin: 0 auto; padding: 44px 48px 26px; color: #ece8df; border-radius: 6px; background: #0e1116;
23
+ box-shadow: 0 30px 60px -30px rgb(0 0 0 / .8); }
24
+ .poster figcaption { display: grid; gap: 12px; margin-bottom: 18px; }
25
+ .poster .kicker { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; font: 500 12px/1.3 "DM Mono", ui-monospace, Menlo, monospace; letter-spacing: .14em; text-transform: uppercase; color: #f5a524; }
26
+ .scalebar { display: inline-grid; grid-template-columns: repeat(2, 36px); row-gap: 3px; font-size: 10px; letter-spacing: .04em; color: #9aa1aa; }
27
+ .scalebar::before { content: ""; grid-column: 1 / -1; height: 5px; border: 1px solid #9aa1aa; background: linear-gradient(90deg, #9aa1aa 0 25%, transparent 0 50%, #9aa1aa 0 75%, transparent 0); }
28
+ .scalebar span:last-child { text-align: right; }
29
+ .poster .headline { max-width: 16em; font-size: 46px; font-weight: 700; line-height: 1.04; letter-spacing: -.03em; text-wrap: balance; }
30
+ .poster .dek { max-width: 60ch; font-size: 15px; line-height: 1.55; color: #b9bcc1; }
31
+ .poster .note { display: block; margin-top: 14px; font-size: 12px; line-height: 1.5; color: #9aa1aa; }
32
+ /* The readout: the city picked, one line tall (two on a phone) whatever the city, so the chart never moves */
33
+ .readout { box-sizing: content-box; min-height: 1.4em; margin: 0 0 8px; padding: 9px 0; border-block: 1px solid rgb(255 255 255 / .12);
34
+ font: 13px/1.4 "DM Mono", ui-monospace, monospace; color: #b9bcc1; }
35
+ .readout b { font-family: Sora, sans-serif; font-weight: 600; color: #ece8df; }
36
+ .axis { margin: 0 0 8px; font-size: 11px; color: #9aa1aa; }
37
+ .axis.x { margin: 8px 0 0; text-align: right; }
38
+ .plot { border-radius: 4px; }
39
+ .plot:has(:focus-visible) { outline: 2px solid #ece8df; outline-offset: 4px; } /* 6px out: clear of the axis names, 8px away */
40
+ @media (max-width: 600px) {
41
+ body { padding: 16px 16px 32px; }
42
+ .poster { padding: 26px 18px 18px; }
43
+ .poster .headline { font-size: 32px; }
44
+ .readout { min-height: 2.8em; font-size: 12px; }
45
+ }
46
+ </style>
47
+ </head>
48
+ <body>
49
+ <div id="chart"></div>
50
+ <script type="module">
51
+ import { Chart, Plot, Scale, Line, Dot, Tick, Label, Poster, slat, html, render, createSignal, Show } from "@bezda/rhp/standalone";
52
+
53
+ // Data: built-up urban areas from Demographia World Urban Areas, 20th edition (2025): people, land in km², people per
54
+ // km². Replace with your own rows, largest first, so the small bubbles are drawn on top. `side` labels a bubble, and
55
+ // `code` is the short label it shows where the chart is too narrow for its name (never hide a label instead).
56
+ const CITIES = [
57
+ { name: "Tokyo", code: "TYO", people: 37325000, km2: 8775, density: 4254, side: "after" },
58
+ { name: "Jakarta", people: 36877000, km2: 3546, density: 10400 },
59
+ { name: "Delhi", people: 33224000, km2: 2344, density: 14174 },
60
+ { name: "Mumbai", people: 26237000, km2: 1067, density: 24588, side: "after" },
61
+ { name: "Cairo", people: 22684000, km2: 2696, density: 8413 },
62
+ { name: "São Paulo", people: 21747000, km2: 3649, density: 5959 },
63
+ { name: "New York", code: "NYC", people: 20892000, km2: 11344, density: 1842, side: "before" },
64
+ { name: "Kinshasa", people: 13060000, km2: 518, density: 25212 },
65
+ { name: "London", people: 11360000, km2: 1738, density: 6537 },
66
+ { name: "Paris", people: 11282000, km2: 2854, density: 3953 },
67
+ { name: "Hong Kong", people: 7117000, km2: 290, density: 24535 },
68
+ { name: "Atlanta", code: "ATL", people: 5495000, km2: 7151, density: 768, side: "after" },
69
+ ];
70
+ const STORY = CITIES.findIndex((c) => c.name === "Mumbai"); // read out when nothing is picked
71
+
72
+ // Both axes are log scales: the chart gets log10 of each value, and the ticks are turned back into numbers.
73
+ // A bubble's area follows its population: its diameter is 8% of the chart's width (8cqw) for the largest city.
74
+ const X = [2.2, 4.4]; // log10 of land area, about 160 to 25,000 km²
75
+ const Y = [2.6, 4.7]; // log10 of people per km², about 400 to 50,000
76
+ const MAX = Math.max(...CITIES.map((c) => c.people));
77
+ const diameter = (people) => 8 * Math.sqrt(people / MAX); // in cqw
78
+ const count = new Intl.NumberFormat("en-US");
79
+ const millions = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 });
80
+
81
+ // Guide lines: on these log scales, the places where land times density gives the same number of people are diagonals
82
+ const GUIDES = [10e6, 30e6].map((people) => {
83
+ const top = Math.log10(people) - Y[1]; // where the diagonal meets the top of the plot
84
+ return { people, top, points: [[top, Y[1]], [X[1], Math.log10(people) - X[1]]] };
85
+ });
86
+
87
+ // Slat types
88
+ // The scale under the plot: a short mark and the land area at each tick. Its room also makes the left side wider than
89
+ // the cross axis' default 42px, so its widest number (30,000) stays inside the chart.
90
+ const Land = slat({
91
+ room: { start: 48, after: 26 },
92
+ css: `
93
+ .mark { top: 100%; height: 6px; translate: -50% 0; --rhp-tick-width: 1px; background: rgb(255 255 255 / .35); }
94
+ .amount { top: calc(100% + 10px); bottom: auto; translate: -50% 0; --rhp-label-gap: 0px; font-size: 11px; color: var(--rhp-muted); }
95
+ `,
96
+ }, (t) => html`
97
+ <div>
98
+ <${Tick} at=${() => t.at} class="mark" />
99
+ <${Label} at=${() => t.at} class="amount">${() => count.format(Math.round(10 ** t.at))}<//>
100
+ </div>`);
101
+
102
+ // A guide line, named at its top end (in millions on a phone)
103
+ const Guide = slat({
104
+ css: `
105
+ .guide { stroke: rgb(255 255 255 / .28); stroke-width: 1px; }
106
+ .people { --rhp-label-gap: 7px; font-size: 11px; color: #9aa1aa; }
107
+ .short { display: none; }
108
+ @container (max-width: 520px) { .long { display: none; } .short { display: inline; } }
109
+ `,
110
+ }, (d) => html`
111
+ <div>
112
+ <${Line} points=${() => d.points} class="guide" />
113
+ <${Label} at=${() => d.top} cross=${Y[1] - 0.08} class="people">
114
+ <span class="long">${() => `${millions.format(d.people / 1e6)} million people`}</span><span class="short">${() => `${millions.format(d.people / 1e6)}M`}</span>
115
+ <//>
116
+ </div>`);
117
+
118
+ // A city: its bubble, its name when the data labels it (its code on a narrow chart), and a sentence for screen readers. Only the bubble takes the
119
+ // pointer, since the slat itself covers the whole plot.
120
+ const City = slat({
121
+ css: `
122
+ .city { pointer-events: none; outline: none; }
123
+ .bubble { pointer-events: auto; cursor: pointer; background: rgb(245 165 36 / .3); box-shadow: inset 0 0 0 1.5px #f5a524; }
124
+ .bubble::before { content: ""; position: absolute; inset: -6px; border-radius: 50%; }
125
+ .labeled .bubble { background: rgb(245 165 36 / .55); }
126
+ .on .bubble { z-index: 2; background: rgb(245 165 36 / .7); box-shadow: inset 0 0 0 1.5px #f5a524, 0 0 0 2px #0e1116, 0 0 0 4px #ece8df; }
127
+ .name { font-size: 13px; font-weight: 600; }
128
+ .code { display: none; }
129
+ @container (max-width: 260px) { .full { display: none; } .code { display: inline; } } /* where the names stop fitting */
130
+ .say { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
131
+ `,
132
+ }, (d) => html`
133
+ <div class=${() => ["city", d.side && "labeled", d.on && "on"].filter(Boolean).join(" ")} data-i=${() => d.index}>
134
+ <${Dot} at=${() => Math.log10(d.km2)} cross=${() => Math.log10(d.density)} size=${() => `${diameter(d.people)}cqw`} class="bubble" />
135
+ <${Show} when=${() => d.side}>
136
+ <${Label} at=${() => Math.log10(d.km2)} cross=${() => Math.log10(d.density)} side=${() => d.side}
137
+ style=${() => ({ "--rhp-label-gap": `calc(${diameter(d.people) / 2}cqw + 6px)` })} class="name">
138
+ <span class="full">${() => d.name}</span><span class="code">${() => d.code ?? d.name}</span>
139
+ <//>
140
+ <//>
141
+ <span class="say">${() => `${d.name}: ${millions.format(d.people / 1e6)} million people on ${count.format(d.km2)} square kilometres, ${count.format(d.density)} per square kilometre`}</span>
142
+ </div>`);
143
+
144
+ // Chart component: the readout and the ring show the city under the pointer, a tap or the arrow keys, and Mumbai otherwise
145
+ function CitiesChart() {
146
+ const [picked, setPicked] = createSignal(null);
147
+ const shown = () => CITIES[picked() ?? STORY];
148
+ // The row of the slat an element is in, or null (between slats, on the axis or the text)
149
+ const rowOf = (el) => {
150
+ const root = el.closest("[data-i]");
151
+ return root ? +root.dataset.i : null;
152
+ };
153
+ // A mouse or a pen picks as it moves, a tap or a click picks, the keyboard picks; on no slat the pick stays
154
+ const pick = (e) => {
155
+ const row = rowOf(e.target);
156
+ if (row != null) setPicked(row);
157
+ };
158
+ // Leaving the chart: back to the slat that has focus (a keyboard's or a click's pick), or none
159
+ const leave = (e) => {
160
+ if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
161
+ setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
162
+ };
163
+ return html`
164
+ <${Poster}
165
+ kicker=${html`<span>Megacities from above <span class="scalebar" aria-hidden="true"><span>0</span><span>10 km</span></span></span>`}
166
+ title="Mumbai fits more people than New York on a tenth of the land"
167
+ dek="Twelve of the world's largest built-up areas by land and by crowding. Bubble area shows population; along each diagonal, cities hold the same number of people. Tap or point at a city."
168
+ note="Source: Demographia World Urban Areas, 20th edition (2025), built-up urban areas.">
169
+ <p class="readout"><b>${() => shown().name}</b><span>${() => `: ${millions.format(shown().people / 1e6)} million people on ${count.format(shown().km2)} km², ${count.format(shown().density)} per km²`}</span></p>
170
+ <p class="axis">People per km², log scale</p>
171
+ <div class="plot" onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
172
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
173
+ <${Chart} scale=${X} cross=${Y} crossTicks=${[500, 1000, 3000, 10000, 30000].map(Math.log10)} crossFormat=${(v) => count.format(Math.round(10 ** v))} height=${360}
174
+ label="Twelve megacities by land area and density, bubble area for population"
175
+ theme=${{ font: 'Sora, "Helvetica Neue", Arial, sans-serif', ink: "#ece8df", muted: "#9aa1aa", grid: "rgb(255 255 255 / .1)" }}>
176
+ <${Scale} ticks=${[300, 1000, 3000, 10000].map(Math.log10)}>${Land}<//>
177
+ <${Plot} overlap=${true} rows=${GUIDES} style=${{ "pointer-events": "none" }}>${Guide}<//>
178
+ <${Plot} overlap=${true} keyboard=${true} rows=${CITIES} on=${(d) => d.index === picked()}>${City}<//>
179
+ <//>
180
+ </div>
181
+ <p class="axis x">Built-up land, km², log scale</p>
182
+ <//>`;
183
+ }
184
+
185
+ render(() => html`<${CitiesChart} />`, document.getElementById("chart"));
186
+ </script>
187
+ </body>
188
+ </html>