@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,152 @@
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 price of a Big Mac</title>
7
+ <meta name="description" content="Lollipop chart: ranked values as a thin stem and a dot, with a reference line and a switch between the values and their difference from the reference. Use it for a ranked list of prices, scores or rates, lighter than bars.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: a thin Bar and a Dot per slat, a dashed reference line in an overlay Plot, and a switch between values and differences.
11
+ Leave: the design. This poster was made for its own subject (Big Mac prices as the till receipt a burger comes with: torn edges, dotted leaders, a barcode).
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=Space+Mono:wght@400;700&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: radial-gradient(90% 60% at 50% 30%, #4a4844, #2a2927); font-family: "Space Mono", ui-monospace, Menlo, Consolas, monospace; }
21
+ /* The poster: a till receipt with torn edges, printed in black and the register's red */
22
+ #chart { max-width: 760px; margin: 0 auto; filter: drop-shadow(0 22px 26px rgb(10 9 8 / .45)); }
23
+ .poster { --tooth: 9px; display: grid; margin: 0; padding: 46px 46px 34px; color: #232220; text-align: center; background: #fbfaf7;
24
+ mask: linear-gradient(#000 0 0) 0 var(--tooth) / 100% calc(100% - 2 * var(--tooth)) no-repeat,
25
+ conic-gradient(from 135deg at top, #0000, #000 1deg 89deg, #0000 90deg) top / calc(2 * var(--tooth)) var(--tooth) repeat-x,
26
+ conic-gradient(from -45deg at bottom, #0000, #000 1deg 89deg, #0000 90deg) bottom / calc(2 * var(--tooth)) var(--tooth) repeat-x; }
27
+ .poster > * { min-width: 0; }
28
+ .poster figcaption { display: grid; gap: 12px; padding-bottom: 20px; margin-bottom: 18px; border-bottom: 2px dashed #232220; }
29
+ .poster .kicker { font-size: 12px; letter-spacing: .14em; text-transform: uppercase; }
30
+ .poster .headline { font-size: 34px; font-weight: 700; line-height: 1.12; letter-spacing: -.03em; text-transform: uppercase; text-wrap: balance; }
31
+ .poster .headline em { font-style: normal; color: #c42f1d; }
32
+ .poster .dek { max-width: 54ch; margin: 0 auto; font-size: 14px; line-height: 1.55; color: #4c4a46; }
33
+ .poster .note { display: block; margin-top: 20px; padding-top: 16px; border-top: 2px dashed #232220; font-size: 11.5px; line-height: 1.55; color: #4c4a46; }
34
+ /* A barcode at the foot of the receipt */
35
+ .poster .note::after { content: ""; display: block; width: 190px; height: 34px; margin: 16px auto 0;
36
+ background: repeating-linear-gradient(90deg, #232220 0 2px, #0000 2px 4px, #232220 4px 5px, #0000 5px 8px, #232220 8px 11px, #0000 11px 12px, #232220 12px 13px, #0000 13px 17px); }
37
+ .switch { display: inline-flex; justify-self: center; margin-bottom: 12px; border: 2px solid #232220; border-radius: 6px; }
38
+ .switch button { padding: 7px 12px; border: 0; background: none; font: 700 12px/1.2 "Space Mono", ui-monospace, Menlo, monospace; letter-spacing: .04em; text-transform: uppercase; color: #232220; cursor: pointer; }
39
+ .switch button[aria-pressed="true"] { background: #232220; color: #fbfaf7; }
40
+ .switch button:focus-visible { outline: 2px solid #c42f1d; outline-offset: 2px; }
41
+ .legend { margin: 0 0 6px; font-size: 12px; color: #4c4a46; }
42
+ .legend i { display: inline-block; width: 10px; height: 10px; margin-right: 6px; border-radius: 50%; background: #c42f1d; vertical-align: -1px; }
43
+ @media (max-width: 520px) {
44
+ body { padding: 24px 16px 32px; }
45
+ .poster { padding: 36px 16px 26px; }
46
+ .poster .headline { font-size: 24px; }
47
+ .poster .dek { font-size: 13px; }
48
+ .switch button { padding: 7px 9px; font-size: 11px; }
49
+ }
50
+ </style>
51
+ </head>
52
+ <body>
53
+ <div id="chart"></div>
54
+ <script type="module">
55
+ import { Chart, Plot, Bar, Dot, Tick, Label, Poster, slat, sortBy, nice, html, render, createSignal, createMemo } from "@bezda/rhp/standalone";
56
+
57
+ // Data: the price of a Big Mac in each country in US dollars, from The Economist's Big Mac index of July 2026
58
+ // (local prices at that month's exchange rates), rounded to the cent. Replace with your own.
59
+ // BASE is the country the others are compared with.
60
+ const BURGERS = [
61
+ { country: "Switzerland", price: 9.04 },
62
+ { country: "Uruguay", price: 8.94 },
63
+ { country: "Norway", price: 8.05 },
64
+ { country: "Britain", price: 7.4 },
65
+ { country: "Mexico", price: 6.27 },
66
+ { country: "USA", price: 6.22 },
67
+ { country: "Brazil", price: 4.71 },
68
+ { country: "China", price: 3.91 },
69
+ { country: "South Africa", price: 3.49 },
70
+ { country: "Japan", price: 3.08 },
71
+ { country: "Taiwan", price: 2.42 },
72
+ { country: "Indonesia", price: 2.38 },
73
+ ];
74
+ const BASE = "USA";
75
+ const INK = "#232220", RED = "#c42f1d";
76
+
77
+ const base = BURGERS.find((b) => b.country === BASE).price;
78
+ const diff = (price) => (price / base - 1) * 100; // % above or below the base price
79
+ const prices = BURGERS.map((b) => b.price), diffs = prices.map(diff);
80
+ const dollars = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
81
+ const wholeDollars = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
82
+ const signed = new Intl.NumberFormat("en-US", { style: "percent", signDisplay: "exceptZero", maximumFractionDigits: 0 });
83
+ // The two views: a scale fitted to each with round ticks, and how its numbers read
84
+ const VIEWS = {
85
+ price: { name: "Price in US$", scale: nice(0, Math.max(...prices), 5), value: (p) => p, text: dollars.format, tick: wholeDollars.format },
86
+ diff: { name: "Vs. the US price", scale: nice(Math.min(...diffs), Math.max(...diffs), 5), value: diff, text: (v) => signed.format(v / 100), tick: (v) => signed.format(v / 100) },
87
+ };
88
+
89
+ // Slat types: a country is its name, a dotted stem like a receipt's leader dots, a dot, and its value in the price
90
+ // column; red is cheaper than the base. The base line is one slat on top of the others.
91
+ const Country = slat({
92
+ thickness: 34,
93
+ room: { start: "auto", end: 64 },
94
+ css: `
95
+ .name { font-size: 13px; --rhp-label-gap: 14px; }
96
+ .stem { background: radial-gradient(circle, var(--rhp-color) 1.2px, transparent 1.7px) 0 50% / 7px 100% repeat-x; }
97
+ .pop { box-shadow: 0 0 0 3px #fbfaf7; }
98
+ .price { justify-self: end; font-size: 13px; font-weight: 700; text-align: right; font-variant-numeric: tabular-nums; }
99
+ .cheap .price { color: #c42f1d; }
100
+ .base .name, .base .price { font-weight: 700; }
101
+ @container (max-width: 420px) { .name, .price { font-size: 12px; } }
102
+ `,
103
+ }, (d) => html`
104
+ <div class=${() => (d.country === BASE ? "base" : d.price < base ? "cheap" : "")}>
105
+ <${Label} edge="start" class="name">${() => d.country}<//>
106
+ <${Bar} to=${() => d.value} thick="4px" color=${() => d.ink} class="stem" />
107
+ <${Dot} at=${() => d.value} size="13px" color=${() => d.ink} class="pop" />
108
+ <${Label} edge="end" class="price">${() => d.text}<//>
109
+ </div>`);
110
+
111
+ const BaseLine = slat({
112
+ room: { before: 24 },
113
+ css: `
114
+ .rule { --rhp-tick-width: 2px; background: repeating-linear-gradient(0deg, var(--rhp-color) 0 5px, transparent 5px 9px); }
115
+ .tag { top: 0; translate: -50% -100%; padding: 0 0 5px; font-size: 11px; font-weight: 700; letter-spacing: .06em; }
116
+ `,
117
+ }, (d) => html`
118
+ <div>
119
+ <${Tick} at=${() => d.at} thick=${1} color=${INK} class="rule" />
120
+ <${Label} at=${() => d.at} class="tag">${() => d.text}<//>
121
+ </div>`);
122
+
123
+ // Chart component: the switch moves every dot from its price to its difference from the base, on a new scale
124
+ function BigMacs() {
125
+ const [view, setView] = createSignal("price");
126
+ const v = createMemo(() => VIEWS[view()]);
127
+ return html`
128
+ <${Poster}
129
+ kicker="The Big Mac index · 12 countries"
130
+ title=${html`<span>A Big Mac costs <em>nearly four times</em> as much in Switzerland as in Indonesia</span>`}
131
+ dek="The same burger, priced in each country and converted to US dollars. Where it is cheap, the local currency buys more than the exchange rate says."
132
+ note="Source: The Economist, Big Mac index, July 2026: local prices converted at that month's exchange rates, rounded to the cent. Switch the view to compare each price with the US price.">
133
+ <div class="switch" role="group" aria-label="View">
134
+ ${Object.keys(VIEWS).map((k) => html`<button type="button" aria-pressed=${() => view() === k} onClick=${() => setView(k)}>${VIEWS[k].name}</button>`)}
135
+ </div>
136
+ <p class="legend"><i></i>cheaper than in the US</p>
137
+ <${Chart} scale=${() => [v().scale.min, v().scale.max]} ticks=${() => v().scale.ticks} format=${(t) => v().tick(t)}
138
+ style=${{ "--rhp-length-time": ".5s", "--rhp-length-ease": "cubic-bezier(.3, 0, .2, 1)" }}
139
+ label="Price of a Big Mac by country, in US dollars"
140
+ theme=${{ font: "'Space Mono', ui-monospace, Menlo, monospace", ink: INK, muted: "#6b6964", grid: "#e3e1dc" }}>
141
+ <${Plot} rows=${BURGERS} order=${sortBy("price", "desc")} value=${(d) => v().value(d.price)} text=${(d) => v().text(v().value(d.price))}
142
+ ink=${(d) => (d.price < base ? RED : INK)}>${Country}<//>
143
+ <${Plot} overlap=${true} slats=${1} at=${() => v().value(base)} text=${() => (view() === "price" ? "US " + dollars.format(base) : "US PRICE")}
144
+ style=${{ "pointer-events": "none" }}>${BaseLine}<//>
145
+ <//>
146
+ <//>`;
147
+ }
148
+
149
+ render(() => html`<${BigMacs} />`, document.getElementById("chart"));
150
+ </script>
151
+ </body>
152
+ </html>
@@ -0,0 +1,228 @@
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>Thirty more years</title>
7
+ <meta name="description" content="Multi-line chart of several series over time on a second axis, with labels at the line ends kept apart and a crosshair whose readout lists every series for the year under the pointer, a tap or the arrow keys. Use it to compare a few series over time.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: several Lines on a second axis, end labels kept apart, and a crosshair whose readout lists every series.
11
+ Leave: the design. This poster was made for its own subject (life expectancy since 1960 as a report on laid paper, a serif headline and an hourglass by 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=Newsreader:opsz,wght@6..72,400..700&family=Public+Sans:wght@400..700&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: #e3e0d8; font-family: "Public Sans", "Helvetica Neue", Arial, sans-serif; }
21
+ /* The poster: laid paper (fine horizontal lines), a report's serif headline, an hourglass by the kicker */
22
+ .poster { max-width: 980px; margin: 0 auto; padding: 46px 52px 28px; color: #1b1d22; border-radius: 2px;
23
+ background: repeating-linear-gradient(0deg, rgb(40 36 30 / .03) 0 1px, transparent 1px 5px), #faf8f3;
24
+ box-shadow: 0 1px 2px rgb(40 36 30 / .15), 0 24px 48px -24px rgb(40 36 30 / .45); }
25
+ .poster figcaption { display: grid; gap: 12px; margin-bottom: 26px; }
26
+ .poster .kicker { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; color: #a8401f; }
27
+ .poster .kicker svg { width: 11px; height: 16px; flex: none; }
28
+ .poster .headline { font: 700 50px/1.02 Newsreader, Georgia, serif; letter-spacing: -.015em; text-wrap: balance; }
29
+ .poster .dek { max-width: 60ch; font: 400 19px/1.45 Newsreader, Georgia, serif; color: #45474d; }
30
+ .poster .note { display: block; margin-top: 18px; padding-top: 10px; border-top: 1px solid #ddd8cc; font-size: 12px; line-height: 1.5; color: #686b72; }
31
+ /* The readout: the year, then every country with its color and its value that year */
32
+ .readout { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 18px; margin: 0 0 18px; padding: 10px 0; border-block: 1px solid #ddd8cc; font-size: 13px; color: #45474d; }
33
+ .readout .year { min-width: 2.6em; font: 700 22px/1 Newsreader, Georgia, serif; color: #1b1d22; font-variant-numeric: tabular-nums; }
34
+ .readout span { white-space: nowrap; }
35
+ .readout i { display: inline-block; width: 14px; height: 3px; margin-right: 6px; border-radius: 2px; vertical-align: 3px; }
36
+ .readout strong { display: inline-block; min-width: 2.2em; font-weight: 700; color: #1b1d22; font-variant-numeric: tabular-nums; }
37
+ /* The chart's box: a finger dragging sideways moves through the years, up and down still scrolls */
38
+ .plot { touch-action: pan-y; border-radius: 4px; }
39
+ .plot:has(:focus-visible) { outline: 2px solid #1b1d22; outline-offset: 8px; }
40
+ @media (max-width: 600px) {
41
+ body { padding: 16px 16px 32px; }
42
+ .poster { padding: 26px 18px 18px; }
43
+ .poster .headline { font-size: 34px; }
44
+ .poster .dek { font-size: 17px; }
45
+ .readout { gap: 4px 14px; font-size: 12px; }
46
+ }
47
+ </style>
48
+ </head>
49
+ <body>
50
+ <div id="chart"></div>
51
+ <script type="module">
52
+ import { Chart, Plot, Scale, Line, Dot, Tick, Label, Poster, slat, nice, html, render, createSignal, onCleanup } from "@bezda/rhp/standalone";
53
+
54
+ // Data: life expectancy at birth in years, 1960 to 2020, UN World Population Prospects 2024. Replace with your own
55
+ // series (one value per year each). The colors passed the color-blind checks in this order. `code` is the short name a
56
+ // phone shows at the line's end.
57
+ const FIRST = 1960;
58
+ const COUNTRIES = [
59
+ { name: "Japan", code: "JPN", color: "#2a78d6", values: [
60
+ 67.7, 68.4, 68.7, 69.7, 70.2, 70.3, 71.1, 71.4, 71.7, 71.9, 72, 72.8, 73.3, 73.4, 73.8, 74.4, 74.8, 75.3, 75.7, 76.2, 76.1, 76.6, 77.1, 77.1, 77.5, 77.8, 78.2, 78.6, 78.5, 79, 79,
61
+ 79.3, 79.3, 79.4, 79.8, 79.7, 80.4, 80.6, 80.6, 80.6, 81.2, 81.5, 81.8, 81.9, 82.1, 82, 82.4, 82.5, 82.6, 83, 82.9, 82.7, 83.2, 83.4, 83.6, 83.9, 84.1, 84.2, 84.3, 84.4, 84.7,
62
+ ] },
63
+ { name: "South Korea", code: "KOR", color: "#e0522b", values: [
64
+ 53.8, 54.4, 55, 55, 55.3, 55.8, 55.4, 60.9, 60.9, 61.4, 62.2, 62.7, 63.2, 63.8, 64.2, 64.7, 65, 64.6, 65, 66.1, 64.8, 67.1, 67.1, 66.7, 68.1, 68.4, 69, 69.3, 70.2, 70.7, 71,
65
+ 71.4, 72.2, 72.7, 72.9, 73.4, 74, 74.5, 75, 75.6, 76.2, 76.9, 77.2, 77.5, 78, 78.4, 79, 79.4, 80, 80.6, 80.7, 81.2, 81.3, 81.8, 82.4, 82.5, 82.9, 83.3, 83.3, 83.7, 83.7,
66
+ ] },
67
+ { name: "United States", code: "USA", color: "#169e6e", values: [
68
+ 69.8, 70.3, 70.1, 69.9, 70.2, 70.2, 70.2, 70.5, 70.2, 70.5, 70.7, 71.1, 71.2, 71.4, 72, 72.5, 72.8, 73.2, 73.4, 73.8, 73.7, 74.1, 74.5, 74.6, 74.7, 74.7, 74.7, 74.9, 74.8, 75.1, 75.4,
69
+ 75.5, 75.8, 75.6, 75.7, 75.9, 76.2, 76.5, 76.7, 76.7, 76.8, 76.9, 77, 77.1, 77.5, 77.5, 77.8, 78, 78.1, 78.5, 78.7, 78.7, 78.8, 78.8, 78.9, 78.7, 78.7, 78.6, 78.8, 78.9, 77,
70
+ ] },
71
+ { name: "Brazil", code: "BRA", color: "#8a5cd0", values: [
72
+ 53.2, 53.6, 54, 54.5, 54.8, 55.2, 55.6, 56, 56.5, 56.9, 57.3, 57.8, 58.3, 58.7, 59.1, 59.6, 60, 60.5, 60.9, 61.4, 61.8, 62.3, 62.7, 63.1, 63.6, 64, 64.4, 64.8, 65.2, 65.5, 65.9,
73
+ 66.2, 66.5, 66.9, 67.3, 67.7, 68.1, 68.5, 68.8, 69.2, 69.6, 70, 70.4, 70.9, 71.4, 71.8, 72.3, 72.7, 73.1, 73.5, 73.8, 74, 74.3, 74.6, 74.8, 75.1, 75.1, 75.4, 75.6, 75.8, 74.5,
74
+ ] },
75
+ { name: "Nigeria", code: "NGA", color: "#b98400", values: [
76
+ 37.2, 37.4, 37.6, 37.8, 38, 38.2, 38.4, 37.5, 38.6, 38.9, 40, 40.6, 41.3, 41.8, 42.5, 43.1, 44.2, 44.8, 45.3, 45.7, 46, 46.3, 46.4, 46.4, 46.3, 46.2, 46, 45.9, 45.8, 45.8, 45.7,
77
+ 45.5, 45.6, 45.8, 45.7, 45.9, 45.8, 46, 46.1, 46.6, 47.1, 47.5, 48, 48.6, 49.1, 49.5, 49.9, 50.4, 50.7, 51.1, 51.3, 51.5, 51.7, 51.8, 51.9, 51.9, 52.2, 52.4, 52.7, 53, 53.1,
78
+ ] },
79
+ ];
80
+ const STORY = "South Korea"; // its line is drawn thicker
81
+
82
+ const YEARS = COUNTRIES[0].values.map((_, i) => FIRST + i);
83
+ const LAST = YEARS.at(-1);
84
+ const all = COUNTRIES.flatMap((c) => c.values);
85
+ const y = nice(Math.min(...all), Math.max(...all), 6); // 30 to 90 years, a line every 10
86
+ const HEIGHT = 340;
87
+ const years = new Intl.NumberFormat("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
88
+
89
+ // Places for the end labels: in order, at least 17px apart, as near their line ends as that allows
90
+ function dodge(values, gap) {
91
+ const order = values.map((_, i) => i).sort((a, b) => values[a] - values[b]);
92
+ const out = values.slice();
93
+ for (let pass = 0; pass < 50; pass++) {
94
+ let moved = false;
95
+ for (let k = 1; k < order.length; k++) {
96
+ const lo = order[k - 1], hi = order[k];
97
+ const push = gap - (out[hi] - out[lo]);
98
+ if (push > 1e-6) {
99
+ out[lo] -= push / 2;
100
+ out[hi] += push / 2;
101
+ moved = true;
102
+ }
103
+ }
104
+ if (!moved) break;
105
+ }
106
+ return out;
107
+ }
108
+ const labelAt = dodge(COUNTRIES.map((c) => c.values.at(-1)), (17 / HEIGHT) * (y.max - y.min));
109
+
110
+ // Slat types
111
+ // The scale under the plot: a short mark and the year at each decade (every other decade on a phone), the first and
112
+ // the last kept inside the plot
113
+ const Decade = slat({
114
+ room: { after: 28 },
115
+ css: `
116
+ .mark { top: 100%; height: 6px; translate: -50% 0; --rhp-tick-width: 1px; background: #b9b3a6; }
117
+ .year { top: calc(100% + 10px); bottom: auto; translate: -50% 0; --rhp-label-gap: 0px; font-size: 11px; color: var(--rhp-muted); }
118
+ .first .year { translate: 0 0; }
119
+ .last .year { translate: -100% 0; }
120
+ @container (max-width: 480px) { .odd { display: none; } }
121
+ `,
122
+ }, (t) => html`
123
+ <div class=${() => (t.first ? "first" : t.last ? "last" : "")}>
124
+ <${Tick} at=${() => t.at} class="mark" />
125
+ <${Label} at=${() => t.at} class=${() => ((t.at / 10) % 2 ? "year odd" : "year")}>${() => t.at}<//>
126
+ </div>`);
127
+
128
+ // A country's line and its name at the end (a short stroke of its color leads to it), in the same slat so the name
129
+ // stays with its series. A phone shows the codes, in a room that fits them, so the lines keep most of a narrow chart.
130
+ const country = (room, text) => slat({
131
+ room: { end: room },
132
+ css: `
133
+ .line { stroke-width: 2px; }
134
+ .story .line { stroke-width: 3.5px; }
135
+ .name { --rhp-label-gap: 4px; font-size: 13px; font-weight: 600; }
136
+ .name i { display: inline-block; width: 10px; height: 2px; margin-right: 5px; vertical-align: 4px; border-radius: 1px; }
137
+ .story .name { font-weight: 800; }
138
+ `,
139
+ }, (d) => html`
140
+ <div class=${() => (d.name === STORY ? "story" : "")}>
141
+ <${Line} points=${() => d.values.map((v, i) => [FIRST + i, v])} color=${() => d.color} class="line" />
142
+ <${Label} at=${LAST} cross=${() => d.labelAt} class="name"><i style=${() => ({ background: d.color })}></i>${() => text(d)}<//>
143
+ </div>`);
144
+ const Country = country(112, (d) => d.name);
145
+ const CountryCode = country(52, (d) => d.code);
146
+
147
+ // The year picked: a hairline across the plot, then a dot on each line
148
+ const Crosshair = slat({ css: `.hair { --rhp-tick-width: 1px; background: #1b1d22; opacity: .45; }` }, (d) => html`
149
+ <${Tick} at=${() => d.year} thick=${1} class="hair" />`);
150
+
151
+ const Mark = slat({
152
+ css: `.dot { box-shadow: 0 0 0 2px #faf8f3; } .dot.story { z-index: 1; }`,
153
+ }, (d) => html`
154
+ <${Dot} at=${() => d.year} cross=${() => d.values[d.i]} color=${() => d.color} size="9px"
155
+ class=${() => (d.name === STORY ? "dot story" : "dot")} />`);
156
+
157
+ // One invisible column per year: what the pointer, a tap and the keyboard pick, and what a screen reader reads
158
+ const Year = slat({
159
+ room: {},
160
+ css: `
161
+ .year { cursor: crosshair; outline: none; }
162
+ .say { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
163
+ `,
164
+ }, (d) => html`
165
+ <div class="year" data-i=${() => d.index}>
166
+ <span class="say">${() => `${d.year}: ` + COUNTRIES.map((c) => `${c.name} ${years.format(c.values[d.index])}`).join(", ") + " years"}</span>
167
+ </div>`);
168
+
169
+ // Chart component: the readout and the crosshair show the year under the pointer, a tap or the arrow keys, and the latest otherwise
170
+ function LifeChart() {
171
+ const roomy = matchMedia("(min-width: 480px)");
172
+ const [wide, setWide] = createSignal(roomy.matches);
173
+ const turn = (e) => setWide(e.matches);
174
+ roomy.addEventListener("change", turn);
175
+ onCleanup(() => roomy.removeEventListener("change", turn));
176
+
177
+ const [picked, setPicked] = createSignal(null);
178
+ const i = () => picked() ?? YEARS.length - 1;
179
+ // The row of the slat an element is in, or null (between slats, on the axis or the text)
180
+ const rowOf = (el) => {
181
+ const root = el.closest("[data-i]");
182
+ return root ? +root.dataset.i : null;
183
+ };
184
+ // A mouse or a pen picks as it moves, and so does a finger dragging sideways; a tap or a click picks, the keyboard
185
+ // picks; on no slat the pick stays
186
+ const pick = (e) => {
187
+ const row = rowOf(e.target);
188
+ if (row != null) setPicked(row);
189
+ };
190
+ let start = [0, 0]; // where the last finger went down
191
+ // A finger that moves more sideways than up or down scrubs; one that moves up or down scrolls the page
192
+ const sideways = (e) => Math.abs(e.clientX - start[0]) > Math.abs(e.clientY - start[1]);
193
+ // Leaving the chart: back to the slat that has focus (a keyboard's or a click's pick), or none
194
+ const leave = (e) => {
195
+ if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
196
+ setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
197
+ };
198
+ return html`
199
+ <${Poster}
200
+ kicker=${html`<span><svg viewBox="0 0 11 16" aria-hidden="true"><path d="M1 1h9M1 15h9M2 1c0 5 3.5 5 3.5 7S2 10 2 15M9 1c0 5-3.5 5-3.5 7S9 10 9 15" fill="none" stroke="currentColor" stroke-width="1.5"/></svg> Life expectancy at birth</span>`}
201
+ title="South Koreans can expect to live 30 years longer than in 1960"
202
+ dek="South Korea started level with Brazil and 16 years behind the United States. It caught the US in 2001 and has stayed ahead. Japan has topped all five since 1965; Nigeria gained 16 years and still trails it by three decades. Tap or point at a year, or drag along the lines."
203
+ note="Source: UN World Population Prospects 2024, via Our World in Data.">
204
+ <p class="readout">
205
+ <b class="year">${() => YEARS[i()]}</b>
206
+ ${COUNTRIES.map((c) => html`<span><i style=${{ background: c.color }}></i>${c.name + " "}<strong>${() => years.format(c.values[i()])}</strong></span>`)}
207
+ </p>
208
+ <div class="plot" onPointerDown=${(e) => (start = [e.clientX, e.clientY])}
209
+ onPointerMove=${(e) => (e.pointerType !== "touch" || sideways(e)) && pick(e)} onClick=${pick} onFocusIn=${pick}
210
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
211
+ <!-- half a year past each end, so the year columns are centered on their years -->
212
+ <${Chart} scale=${[FIRST - 0.5, LAST + 0.5]} cross=${[y.min, y.max]} crossTicks=${y.ticks} height=${HEIGHT}
213
+ label=${`Life expectancy at birth in ${COUNTRIES.map((c) => c.name).join(", ")}, ${FIRST} to ${LAST}`}
214
+ theme=${{ font: '"Public Sans", "Helvetica Neue", Arial, sans-serif', ink: "#1b1d22", muted: "#686b72", grid: "#e1dccf" }}>
215
+ <${Scale} ticks=${YEARS.filter((year) => year % 10 === 0)}>${Decade}<//>
216
+ ${() => html`<${Plot} overlap=${true} rows=${COUNTRIES} labelAt=${labelAt}>${wide() ? Country : CountryCode}<//>`}
217
+ <${Plot} overlap=${true} slats=${1} year=${() => YEARS[i()]} style=${{ "pointer-events": "none" }}>${Crosshair}<//>
218
+ <${Plot} overlap=${true} rows=${COUNTRIES} year=${() => YEARS[i()]} i=${i} style=${{ "pointer-events": "none" }}>${Mark}<//>
219
+ <${Plot} orientation="vertical" keyboard=${true} year=${YEARS}>${Year}<//>
220
+ <//>
221
+ </div>
222
+ <//>`;
223
+ }
224
+
225
+ render(() => html`<${LifeChart} />`, document.getElementById("chart"));
226
+ </script>
227
+ </body>
228
+ </html>
@@ -0,0 +1,162 @@
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>Japan's population by age</title>
7
+ <meta name="description" content="Population pyramid: two sides of one population (men and women) by age band, oldest on top, with an age picked by pointer, tap or keyboard that lights everyone that age or older and reads out their share. Use it for any two groups compared across ordered bands.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: a Bar to each side of zero per age band, and an age picked by pointer, tap or keyboard that lights everyone that age or older.
11
+ Leave: the design. This poster was made for its own subject (Japan's population on washi paper in sumi ink, the two sides in hanada blue and vermilion).
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=Zen+Old+Mincho:wght@700;900&family=Zen+Kaku+Gothic+New:wght@400;500;700&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: #d9d6ce; font-family: "Zen Kaku Gothic New", "Hiragino Sans", "Helvetica Neue", Arial, sans-serif; }
21
+ /* The poster: washi paper with a fine grain, sumi ink, the two sides in hanada blue and vermilion, and a line of
22
+ type set vertically down the right edge */
23
+ .poster { position: relative; display: grid; max-width: 940px; margin: 0 auto; padding: 50px 64px 30px 52px; color: #1e1d1b;
24
+ background: #f3f0e8 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.012 .9' numOctaves='2' seed='4'/%3E%3CfeColorMatrix values='0 0 0 0 .45 0 0 0 0 .42 0 0 0 0 .36 0 0 0 .38 -.17'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23f)'/%3E%3C/svg%3E");
25
+ box-shadow: 0 1px 2px rgb(40 36 30 / .18), 0 26px 46px -28px rgb(40 36 30 / .55); }
26
+ .poster > * { min-width: 0; }
27
+ .poster::after { content: "Five-year age bands · millions of people"; position: absolute; top: 50px; right: 22px; writing-mode: vertical-rl;
28
+ font-size: 11px; font-weight: 500; letter-spacing: .3em; text-transform: uppercase; color: #6d6a63; }
29
+ .poster figcaption { display: grid; gap: 12px; margin-bottom: 20px; }
30
+ .poster .kicker { font-size: 12px; font-weight: 700; letter-spacing: .18em; text-transform: uppercase; color: #9c3422; }
31
+ .poster .headline { font: 900 46px/1.08 "Zen Old Mincho", "Hiragino Mincho ProN", Georgia, serif; letter-spacing: -.01em; text-wrap: balance; }
32
+ .poster .dek { max-width: 54ch; font-size: 16px; line-height: 1.6; color: #4d4a44; }
33
+ .poster .note { display: block; margin-top: 18px; font-size: 12px; line-height: 1.55; color: #5f5c55; }
34
+ /* The readout: the share of people at the picked age or older, and how many */
35
+ .readout { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 4px 14px; margin: 4px 0 14px; padding: 12px 0; border-block: 1px solid #d8d3c8; }
36
+ .readout b { grid-row: span 2; font: 900 58px/1 "Zen Old Mincho", Georgia, serif; letter-spacing: -.02em; }
37
+ .readout span { font-size: 15px; line-height: 1.35; }
38
+ .readout span + span { color: #5f5c55; font-size: 13px; }
39
+ .key { display: flex; gap: 14px; margin-bottom: 8px; font-size: 13px; color: #4d4a44; }
40
+ .key i { display: inline-block; width: 12px; height: 12px; margin-right: 6px; border-radius: 2px; vertical-align: -1px; }
41
+ /* Widen the plot by 5%, keeping the two 40px label rooms the same size. */
42
+ .chart { width: calc(105% - 4px); max-width: 584px; justify-self: center; align-self: center; }
43
+ .poster .chart { margin-bottom: 35px; }
44
+ @media (min-width: 860px) {
45
+ .poster { grid-template-columns: minmax(0, .85fr) minmax(0, 1.15fr); grid-template-rows: auto auto auto 1fr; column-gap: 40px; }
46
+ .poster figcaption { grid-column: 1; grid-row: 1; }
47
+ .readout { grid-column: 1; grid-row: 2; }
48
+ .key { grid-column: 1; grid-row: 3; }
49
+ .poster .chart { grid-column: 2; grid-row: 1 / span 4; align-self: center; }
50
+ .poster .note { grid-column: 1; grid-row: 4; align-self: end; }
51
+ }
52
+ /* On a phone the chart is taller than half the screen: the readout stays at the top of the screen while the chart
53
+ scrolls under it (the poster is a flex column there: a sticky grid item can't leave its own row) */
54
+ @media (max-width: 560px) {
55
+ body { padding: 24px 16px 32px; }
56
+ .poster { display: flex; flex-direction: column; padding: 34px 34px 20px 16px; }
57
+ .poster::after { top: 34px; right: 10px; font-size: 10px; }
58
+ .poster .headline { font-size: 31px; }
59
+ .poster .dek { font-size: 15px; }
60
+ .readout { position: sticky; top: 0; z-index: 2; background: inherit; }
61
+ .readout b { font-size: 46px; }
62
+ .readout span { font-size: 14px; }
63
+ }
64
+ </style>
65
+ </head>
66
+ <body>
67
+ <div id="chart"></div>
68
+ <script type="module">
69
+ import { Chart, Plot, Bar, Label, Poster, slat, html, render, createSignal, Show } from "@bezda/rhp/standalone";
70
+
71
+ // Data: people in each age band on October 1, 2023, in millions rounded to two decimals, youngest first, from the
72
+ // Statistics Bureau of Japan's population estimates (total population). Replace with your own.
73
+ const AGES = ["0-4", "5-9", "10-14", "15-19", "20-24", "25-29", "30-34", "35-39", "40-44", "45-49", "50-54", "55-59", "60-64",
74
+ "65-69", "70-74", "75-79", "80-84", "85-89", "90+"];
75
+ const MEN = [2.09, 2.48, 2.69, 2.82, 3.21, 3.33, 3.27, 3.59, 3.94, 4.62, 4.87, 4.14, 3.72, 3.57, 4.16, 3.37, 2.46, 1.45, 0.71];
76
+ const WOMEN = [2.00, 2.36, 2.56, 2.68, 3.02, 3.15, 3.11, 3.45, 3.83, 4.50, 4.78, 4.14, 3.79, 3.76, 4.66, 4.11, 3.44, 2.53, 2.02];
77
+ const FROM = 13; // the age band picked at rest: 65 and older
78
+ const COLORS = { men: "#00749a", women: "#c8442f" };
79
+
80
+ // Both sides start SPINE away from 0, which leaves a strip down the middle for the ages. The axis ticks are moved
81
+ // out by the same amount, so they still read in millions.
82
+ const SPINE = 0.9;
83
+ const WIDEST = Math.ceil(Math.max(...MEN, ...WOMEN));
84
+ const STEPS = Array.from({ length: WIDEST + 1 }, (_, k) => k);
85
+ const TICKS = [...STEPS.map((k) => -(SPINE + k)).reverse(), ...STEPS.map((k) => SPINE + k)];
86
+ const TOTAL = [...MEN, ...WOMEN].reduce((sum, v) => sum + v, 0);
87
+ const millions = new Intl.NumberFormat("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
88
+ const percent = new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 0 });
89
+ const pale = (c) => `color-mix(in oklab, ${c} 28%, #f3f0e8)`;
90
+
91
+ // Slat type: an age band. The bands at or above the picked age are in full color; the picked one shows its numbers.
92
+ const Band = slat({
93
+ thickness: 23.1,
94
+ inset: "1.5px",
95
+ room: { start: 40, end: 40 },
96
+ css: `
97
+ .side { --rhp-start-radius: 0px; --rhp-end-radius: 3px; }
98
+ .age { padding: 0; translate: -50% -50%; font-size: 11px; font-weight: 500; color: #6d6a63; font-variant-numeric: tabular-nums; }
99
+ .old .age { color: #1e1d1b; font-weight: 700; }
100
+ .on .age { color: #1e1d1b; }
101
+ .val { font-size: 12px; font-weight: 700; font-variant-numeric: tabular-nums; }
102
+ .band:focus-visible { outline: 2px solid #1e1d1b; outline-offset: -1px; border-radius: 3px; }
103
+ `,
104
+ }, (d) => html`
105
+ <div class=${() => ["band", d.old && "old", d.on && "on"].filter(Boolean).join(" ")} data-row=${() => d.index}>
106
+ <${Bar} from=${-SPINE} to=${() => -(SPINE + d.men)} color=${() => (d.old ? COLORS.men : pale(COLORS.men))} class="side" />
107
+ <${Bar} from=${SPINE} to=${() => SPINE + d.women} color=${() => (d.old ? COLORS.women : pale(COLORS.women))} class="side" />
108
+ <${Label} at=${0} class="age">${() => d.age}<//>
109
+ <${Show} when=${() => d.on}>
110
+ <${Label} at=${() => -(SPINE + d.men)} side="before" class="val">${() => millions.format(d.men)}<//>
111
+ <${Label} at=${() => SPINE + d.women} class="val">${() => millions.format(d.women)}<//>
112
+ <//>
113
+ </div>`);
114
+
115
+ // Chart component: the band the reader points at, taps or reaches with the keyboard sets the age, and the readout
116
+ // counts everyone that age or older
117
+ function AgeingJapan() {
118
+ const [picked, setPicked] = createSignal(null);
119
+ const from = () => picked() ?? FROM;
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
+ const older = () => MEN.slice(from()).concat(WOMEN.slice(from())).reduce((sum, v) => sum + v, 0);
136
+ return html`
137
+ <${Poster}
138
+ kicker="Japan · population by age and sex"
139
+ title="Nearly three in ten people in Japan are 65 or older"
140
+ dek="Men to the left, women to the right, in millions, the oldest at the top. Two bulges show the baby booms of the late 1940s and early 1970s; the base keeps shrinking."
141
+ note="Source: Statistics Bureau of Japan, population estimates for October 1, 2023 (124.35 million people), rounded to the nearest 10,000. Point at an age band, tap it, or Tab into the chart and use the arrow keys."
142
+ onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
143
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
144
+ <p class="readout" aria-live="polite">
145
+ <b>${() => percent.format(older() / TOTAL)}</b>
146
+ <span>${() => (from() ? `are ${parseInt(AGES[from()])} or older` : "of all ages")}</span>
147
+ <span>${() => `${millions.format(older())} million people`}</span>
148
+ </p>
149
+ <p class="key"><span><i style=${{ background: COLORS.men }}></i>Men</span><span><i style=${{ background: COLORS.women }}></i>Women</span></p>
150
+ <${Chart} class="chart" scale=${[-(SPINE + WIDEST), SPINE + WIDEST]} ticks=${TICKS} format=${(v) => (Math.abs(v) - SPINE ? Math.round(Math.abs(v) - SPINE) + "M" : "0")}
151
+ label="Japan's population by age band in 2023, men and women, in millions"
152
+ theme=${{ font: "'Zen Kaku Gothic New', 'Helvetica Neue', Arial, sans-serif", ink: "#1e1d1b", muted: "#6d6a63", grid: "#dfdad0" }}>
153
+ <${Plot} keyboard=${true} age=${AGES} men=${MEN} women=${WOMEN} order=${AGES.map((_, i) => AGES.length - 1 - i)}
154
+ old=${(d) => d.index >= from()} on=${(d) => d.index === from()}>${Band}<//>
155
+ <//>
156
+ <//>`;
157
+ }
158
+
159
+ render(() => html`<${AgeingJapan} />`, document.getElementById("chart"));
160
+ </script>
161
+ </body>
162
+ </html>