@bezda/rhp-mcp 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +221 -2
- package/bin/rhp-mcp.js +125 -0
- package/package.json +62 -4
- package/skill/SKILL.md +363 -0
- package/skill/recipes/area.html +150 -0
- package/skill/recipes/bar.html +139 -0
- package/skill/recipes/box-plot.html +158 -0
- package/skill/recipes/bubble.html +182 -0
- package/skill/recipes/bullet.html +132 -0
- package/skill/recipes/candlestick.html +173 -0
- package/skill/recipes/column.html +125 -0
- package/skill/recipes/diverging-bars.html +144 -0
- package/skill/recipes/donut.html +225 -0
- package/skill/recipes/dumbbell.html +131 -0
- package/skill/recipes/gantt.html +186 -0
- package/skill/recipes/grouped-bars.html +136 -0
- package/skill/recipes/heatmap.html +177 -0
- package/skill/recipes/histogram.html +193 -0
- package/skill/recipes/line.html +164 -0
- package/skill/recipes/live.html +249 -0
- package/skill/recipes/lollipop.html +146 -0
- package/skill/recipes/multi-line.html +223 -0
- package/skill/recipes/pyramid.html +156 -0
- package/skill/recipes/race.html +272 -0
- package/skill/recipes/radial-bars.html +151 -0
- package/skill/recipes/scatter.html +193 -0
- package/skill/recipes/slope.html +193 -0
- package/skill/recipes/sparklines.html +169 -0
- package/skill/recipes/stacked-100.html +152 -0
- package/skill/recipes/stacked-bars.html +144 -0
- package/skill/recipes/strip.html +215 -0
- package/skill/recipes/violin.html +163 -0
- package/skill/recipes/waffle.html +159 -0
- package/skill/recipes/waterfall.html +121 -0
- package/skill/references/api.md +1556 -0
- package/skill/references/design.md +763 -0
- package/skill/references/environments.md +1570 -0
- package/skill/references/forms.md +160 -0
- package/skill/references/interaction.md +792 -0
- package/skill/references/pitfalls.md +157 -0
- package/src/check/README.md +163 -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 +419 -0
- package/src/check/inpage.js +1268 -0
- package/src/check/interact.js +266 -0
- package/src/check/png.js +98 -0
- package/src/check/report.js +103 -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 +608 -0
- package/src/guard/rules.js +125 -0
- package/src/server.js +265 -0
- package/src/skill.js +140 -0
|
@@ -0,0 +1,223 @@
|
|
|
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
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
|
+
<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">
|
|
11
|
+
<script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>
|
|
12
|
+
<style>
|
|
13
|
+
* { box-sizing: border-box; }
|
|
14
|
+
body { margin: 0; padding: 48px 16px; background: #e3e0d8; font-family: "Public Sans", "Helvetica Neue", Arial, sans-serif; }
|
|
15
|
+
/* The poster: laid paper (fine horizontal lines), a report's serif headline, an hourglass by the kicker */
|
|
16
|
+
.poster { max-width: 980px; margin: 0 auto; padding: 46px 52px 28px; color: #1b1d22; border-radius: 2px;
|
|
17
|
+
background: repeating-linear-gradient(0deg, rgb(40 36 30 / .03) 0 1px, transparent 1px 5px), #faf8f3;
|
|
18
|
+
box-shadow: 0 1px 2px rgb(40 36 30 / .15), 0 24px 48px -24px rgb(40 36 30 / .45); }
|
|
19
|
+
.poster figcaption { display: grid; gap: 12px; margin-bottom: 26px; }
|
|
20
|
+
.poster .kicker { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; color: #a8401f; }
|
|
21
|
+
.poster .kicker svg { width: 11px; height: 16px; flex: none; }
|
|
22
|
+
.poster .headline { font: 700 50px/1.02 Newsreader, Georgia, serif; letter-spacing: -.015em; text-wrap: balance; }
|
|
23
|
+
.poster .dek { max-width: 60ch; font: 400 19px/1.45 Newsreader, Georgia, serif; color: #45474d; }
|
|
24
|
+
.poster .note { display: block; margin-top: 18px; padding-top: 10px; border-top: 1px solid #ddd8cc; font-size: 12px; line-height: 1.5; color: #686b72; }
|
|
25
|
+
/* The readout: the year, then every country with its color and its value that year */
|
|
26
|
+
.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; }
|
|
27
|
+
.readout .year { min-width: 2.6em; font: 700 22px/1 Newsreader, Georgia, serif; color: #1b1d22; font-variant-numeric: tabular-nums; }
|
|
28
|
+
.readout span { white-space: nowrap; }
|
|
29
|
+
.readout i { display: inline-block; width: 14px; height: 3px; margin-right: 6px; border-radius: 2px; vertical-align: 3px; }
|
|
30
|
+
.readout strong { display: inline-block; min-width: 2.2em; font-weight: 700; color: #1b1d22; font-variant-numeric: tabular-nums; }
|
|
31
|
+
/* The chart's box: a finger dragging sideways moves through the years, up and down still scrolls */
|
|
32
|
+
.plot { touch-action: pan-y; border-radius: 4px; }
|
|
33
|
+
.plot:has(:focus-visible) { outline: 2px solid #1b1d22; outline-offset: 8px; }
|
|
34
|
+
@media (max-width: 600px) {
|
|
35
|
+
body { padding: 16px 16px 32px; }
|
|
36
|
+
.poster { padding: 26px 18px 18px; }
|
|
37
|
+
.poster .headline { font-size: 34px; }
|
|
38
|
+
.poster .dek { font-size: 17px; }
|
|
39
|
+
.readout { gap: 4px 14px; font-size: 12px; }
|
|
40
|
+
}
|
|
41
|
+
</style>
|
|
42
|
+
</head>
|
|
43
|
+
<body>
|
|
44
|
+
<div id="chart"></div>
|
|
45
|
+
<script type="module">
|
|
46
|
+
import { Chart, Plot, Scale, Line, Dot, Tick, Label, Poster, slat, nice, html, render, createSignal, onCleanup } from "@bezda/rhp/standalone";
|
|
47
|
+
|
|
48
|
+
// Data: life expectancy at birth in years, 1960 to 2020, UN World Population Prospects 2024. Replace with your own
|
|
49
|
+
// series (one value per year each). The colors passed the color-blind checks in this order. `code` is the short name a
|
|
50
|
+
// phone shows at the line's end.
|
|
51
|
+
const FIRST = 1960;
|
|
52
|
+
const COUNTRIES = [
|
|
53
|
+
{ name: "Japan", code: "JPN", color: "#2a78d6", values: [
|
|
54
|
+
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,
|
|
55
|
+
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,
|
|
56
|
+
] },
|
|
57
|
+
{ name: "South Korea", code: "KOR", color: "#e0522b", values: [
|
|
58
|
+
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,
|
|
59
|
+
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,
|
|
60
|
+
] },
|
|
61
|
+
{ name: "United States", code: "USA", color: "#169e6e", values: [
|
|
62
|
+
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,
|
|
63
|
+
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,
|
|
64
|
+
] },
|
|
65
|
+
{ name: "Brazil", code: "BRA", color: "#8a5cd0", values: [
|
|
66
|
+
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,
|
|
67
|
+
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,
|
|
68
|
+
] },
|
|
69
|
+
{ name: "Nigeria", code: "NGA", color: "#b98400", values: [
|
|
70
|
+
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,
|
|
71
|
+
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,
|
|
72
|
+
] },
|
|
73
|
+
];
|
|
74
|
+
const STORY = "South Korea"; // its line is drawn thicker
|
|
75
|
+
|
|
76
|
+
const YEARS = COUNTRIES[0].values.map((_, i) => FIRST + i);
|
|
77
|
+
const LAST = YEARS.at(-1);
|
|
78
|
+
const all = COUNTRIES.flatMap((c) => c.values);
|
|
79
|
+
const y = nice(Math.min(...all), Math.max(...all), 6); // 30 to 90 years, a line every 10
|
|
80
|
+
const HEIGHT = 340;
|
|
81
|
+
const years = new Intl.NumberFormat("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
|
82
|
+
|
|
83
|
+
// Places for the end labels: in order, at least 17px apart, as near their line ends as that allows
|
|
84
|
+
function dodge(values, gap) {
|
|
85
|
+
const order = values.map((_, i) => i).sort((a, b) => values[a] - values[b]);
|
|
86
|
+
const out = values.slice();
|
|
87
|
+
for (let pass = 0; pass < 50; pass++) {
|
|
88
|
+
let moved = false;
|
|
89
|
+
for (let k = 1; k < order.length; k++) {
|
|
90
|
+
const lo = order[k - 1], hi = order[k];
|
|
91
|
+
const push = gap - (out[hi] - out[lo]);
|
|
92
|
+
if (push > 1e-6) {
|
|
93
|
+
out[lo] -= push / 2;
|
|
94
|
+
out[hi] += push / 2;
|
|
95
|
+
moved = true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!moved) break;
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
const labelAt = dodge(COUNTRIES.map((c) => c.values.at(-1)), (17 / HEIGHT) * (y.max - y.min));
|
|
103
|
+
|
|
104
|
+
// Slat types
|
|
105
|
+
// The scale under the plot: a short mark and the year at each decade (every other decade on a phone), the first and
|
|
106
|
+
// the last kept inside the plot
|
|
107
|
+
const Decade = slat({
|
|
108
|
+
room: { after: 28 },
|
|
109
|
+
css: `
|
|
110
|
+
.mark { top: 100%; height: 6px; translate: -50% 0; --rhp-tick-width: 1px; background: #b9b3a6; }
|
|
111
|
+
.year { top: calc(100% + 10px); bottom: auto; translate: -50% 0; --rhp-label-gap: 0px; font-size: 11px; color: var(--rhp-muted); }
|
|
112
|
+
.first .year { translate: 0 0; }
|
|
113
|
+
.last .year { translate: -100% 0; }
|
|
114
|
+
@container (max-width: 480px) { .odd { display: none; } }
|
|
115
|
+
`,
|
|
116
|
+
}, (t) => html`
|
|
117
|
+
<div class=${() => (t.first ? "first" : t.last ? "last" : "")}>
|
|
118
|
+
<${Tick} at=${() => t.at} class="mark" />
|
|
119
|
+
<${Label} at=${() => t.at} class=${() => ((t.at / 10) % 2 ? "year odd" : "year")}>${() => t.at}<//>
|
|
120
|
+
</div>`);
|
|
121
|
+
|
|
122
|
+
// 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
|
|
123
|
+
// stays with its series. A phone shows the codes, in a room that fits them, so the lines keep most of a narrow chart.
|
|
124
|
+
const country = (room, text) => slat({
|
|
125
|
+
room: { end: room },
|
|
126
|
+
css: `
|
|
127
|
+
.line { stroke-width: 2px; }
|
|
128
|
+
.story .line { stroke-width: 3.5px; }
|
|
129
|
+
.name { --rhp-label-gap: 4px; font-size: 13px; font-weight: 600; }
|
|
130
|
+
.name i { display: inline-block; width: 10px; height: 2px; margin-right: 5px; vertical-align: 4px; border-radius: 1px; }
|
|
131
|
+
.story .name { font-weight: 800; }
|
|
132
|
+
`,
|
|
133
|
+
}, (d) => html`
|
|
134
|
+
<div class=${() => (d.name === STORY ? "story" : "")}>
|
|
135
|
+
<${Line} points=${() => d.values.map((v, i) => [FIRST + i, v])} color=${() => d.color} class="line" />
|
|
136
|
+
<${Label} at=${LAST} cross=${() => d.labelAt} class="name"><i style=${() => ({ background: d.color })}></i>${() => text(d)}<//>
|
|
137
|
+
</div>`);
|
|
138
|
+
const Country = country(112, (d) => d.name);
|
|
139
|
+
const CountryCode = country(52, (d) => d.code);
|
|
140
|
+
|
|
141
|
+
// The year picked: a hairline across the plot, then a dot on each line
|
|
142
|
+
const Crosshair = slat({ css: `.hair { --rhp-tick-width: 1px; background: #1b1d22; opacity: .45; }` }, (d) => html`
|
|
143
|
+
<div><${Tick} at=${() => d.year} thick=${1} class="hair" /></div>`);
|
|
144
|
+
|
|
145
|
+
const Mark = slat({
|
|
146
|
+
css: `.dot { box-shadow: 0 0 0 2px #faf8f3; } .story .dot { z-index: 1; }`,
|
|
147
|
+
}, (d) => html`
|
|
148
|
+
<div class=${() => (d.name === STORY ? "story" : "")}>
|
|
149
|
+
<${Dot} at=${() => d.year} cross=${() => d.values[d.i]} color=${() => d.color} size="9px" class="dot" />
|
|
150
|
+
</div>`);
|
|
151
|
+
|
|
152
|
+
// One invisible column per year: what the pointer, a tap and the keyboard pick, and what a screen reader reads
|
|
153
|
+
const Year = slat({
|
|
154
|
+
room: {},
|
|
155
|
+
css: `
|
|
156
|
+
.year { cursor: crosshair; outline: none; }
|
|
157
|
+
.say { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
158
|
+
`,
|
|
159
|
+
}, (d) => html`
|
|
160
|
+
<div class="year" data-i=${() => d.index}>
|
|
161
|
+
<span class="say">${() => `${d.year}: ` + COUNTRIES.map((c) => `${c.name} ${years.format(c.values[d.index])}`).join(", ") + " years"}</span>
|
|
162
|
+
</div>`);
|
|
163
|
+
|
|
164
|
+
// Chart component: the readout and the crosshair show the year under the pointer, a tap or the arrow keys, and the latest otherwise
|
|
165
|
+
function LifeChart() {
|
|
166
|
+
const roomy = matchMedia("(min-width: 480px)");
|
|
167
|
+
const [wide, setWide] = createSignal(roomy.matches);
|
|
168
|
+
const turn = (e) => setWide(e.matches);
|
|
169
|
+
roomy.addEventListener("change", turn);
|
|
170
|
+
onCleanup(() => roomy.removeEventListener("change", turn));
|
|
171
|
+
|
|
172
|
+
const [picked, setPicked] = createSignal(null);
|
|
173
|
+
const i = () => picked() ?? YEARS.length - 1;
|
|
174
|
+
// The row of the slat an element is in, or null (between slats, on the axis or the text)
|
|
175
|
+
const rowOf = (el) => {
|
|
176
|
+
const root = el.closest("[data-i]");
|
|
177
|
+
return root ? +root.dataset.i : null;
|
|
178
|
+
};
|
|
179
|
+
// A mouse or a pen picks as it moves, and so does a finger dragging sideways; a tap or a click picks, the keyboard
|
|
180
|
+
// picks; on no slat the pick stays
|
|
181
|
+
const pick = (e) => {
|
|
182
|
+
const row = rowOf(e.target);
|
|
183
|
+
if (row != null) setPicked(row);
|
|
184
|
+
};
|
|
185
|
+
let start = [0, 0]; // where the last finger went down
|
|
186
|
+
// A finger that moves more sideways than up or down scrubs; one that moves up or down scrolls the page
|
|
187
|
+
const sideways = (e) => Math.abs(e.clientX - start[0]) > Math.abs(e.clientY - start[1]);
|
|
188
|
+
// Leaving the chart: back to the slat that has focus (a keyboard's or a click's pick), or none
|
|
189
|
+
const leave = (e) => {
|
|
190
|
+
if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
|
|
191
|
+
setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
|
|
192
|
+
};
|
|
193
|
+
return html`
|
|
194
|
+
<${Poster}
|
|
195
|
+
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>`}
|
|
196
|
+
title="South Koreans can expect to live 30 years longer than in 1960"
|
|
197
|
+
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."
|
|
198
|
+
note="Source: UN World Population Prospects 2024, via Our World in Data.">
|
|
199
|
+
<p class="readout">
|
|
200
|
+
<b class="year">${() => YEARS[i()]}</b>
|
|
201
|
+
${COUNTRIES.map((c) => html`<span><i style=${{ background: c.color }}></i>${c.name + " "}<strong>${() => years.format(c.values[i()])}</strong></span>`)}
|
|
202
|
+
</p>
|
|
203
|
+
<div class="plot" onPointerDown=${(e) => (start = [e.clientX, e.clientY])}
|
|
204
|
+
onPointerMove=${(e) => (e.pointerType !== "touch" || sideways(e)) && pick(e)} onClick=${pick} onFocusIn=${pick}
|
|
205
|
+
onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
|
|
206
|
+
<!-- half a year past each end, so the year columns are centered on their years -->
|
|
207
|
+
<${Chart} scale=${[FIRST - 0.5, LAST + 0.5]} cross=${[y.min, y.max]} crossTicks=${y.ticks} height=${HEIGHT}
|
|
208
|
+
label=${`Life expectancy at birth in ${COUNTRIES.map((c) => c.name).join(", ")}, ${FIRST} to ${LAST}`}
|
|
209
|
+
theme=${{ font: '"Public Sans", "Helvetica Neue", Arial, sans-serif', ink: "#1b1d22", muted: "#686b72", grid: "#e1dccf" }}>
|
|
210
|
+
<${Scale} ticks=${YEARS.filter((year) => year % 10 === 0)}>${Decade}<//>
|
|
211
|
+
${() => html`<${Plot} overlap=${true} rows=${COUNTRIES} labelAt=${labelAt}>${wide() ? Country : CountryCode}<//>`}
|
|
212
|
+
<${Plot} overlap=${true} slats=${1} year=${() => YEARS[i()]} style=${{ "pointer-events": "none" }}>${Crosshair}<//>
|
|
213
|
+
<${Plot} overlap=${true} rows=${COUNTRIES} year=${() => YEARS[i()]} i=${i} style=${{ "pointer-events": "none" }}>${Mark}<//>
|
|
214
|
+
<${Plot} orientation="vertical" keyboard=${true} year=${YEARS}>${Year}<//>
|
|
215
|
+
<//>
|
|
216
|
+
</div>
|
|
217
|
+
<//>`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
render(() => html`<${LifeChart} />`, document.getElementById("chart"));
|
|
221
|
+
</script>
|
|
222
|
+
</body>
|
|
223
|
+
</html>
|
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
|
+
<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">
|
|
11
|
+
<script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>
|
|
12
|
+
<style>
|
|
13
|
+
* { box-sizing: border-box; }
|
|
14
|
+
body { margin: 0; padding: 48px 16px; background: #d9d6ce; font-family: "Zen Kaku Gothic New", "Hiragino Sans", "Helvetica Neue", Arial, sans-serif; }
|
|
15
|
+
/* The poster: washi paper with a fine grain, sumi ink, the two sides in hanada blue and vermilion, and a line of
|
|
16
|
+
type set vertically down the right edge */
|
|
17
|
+
.poster { position: relative; display: grid; max-width: 940px; margin: 0 auto; padding: 50px 64px 30px 52px; color: #1e1d1b;
|
|
18
|
+
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");
|
|
19
|
+
box-shadow: 0 1px 2px rgb(40 36 30 / .18), 0 26px 46px -28px rgb(40 36 30 / .55); }
|
|
20
|
+
.poster > * { min-width: 0; }
|
|
21
|
+
.poster::after { content: "Five-year age bands · millions of people"; position: absolute; top: 50px; right: 22px; writing-mode: vertical-rl;
|
|
22
|
+
font-size: 11px; font-weight: 500; letter-spacing: .3em; text-transform: uppercase; color: #6d6a63; }
|
|
23
|
+
.poster figcaption { display: grid; gap: 12px; margin-bottom: 20px; }
|
|
24
|
+
.poster .kicker { font-size: 12px; font-weight: 700; letter-spacing: .18em; text-transform: uppercase; color: #9c3422; }
|
|
25
|
+
.poster .headline { font: 900 46px/1.08 "Zen Old Mincho", "Hiragino Mincho ProN", Georgia, serif; letter-spacing: -.01em; text-wrap: balance; }
|
|
26
|
+
.poster .dek { max-width: 54ch; font-size: 16px; line-height: 1.6; color: #4d4a44; }
|
|
27
|
+
.poster .note { display: block; margin-top: 18px; font-size: 12px; line-height: 1.55; color: #5f5c55; }
|
|
28
|
+
/* The readout: the share of people at the picked age or older, and how many */
|
|
29
|
+
.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; }
|
|
30
|
+
.readout b { grid-row: span 2; font: 900 58px/1 "Zen Old Mincho", Georgia, serif; letter-spacing: -.02em; }
|
|
31
|
+
.readout span { font-size: 15px; line-height: 1.35; }
|
|
32
|
+
.readout span + span { color: #5f5c55; font-size: 13px; }
|
|
33
|
+
.key { display: flex; gap: 14px; margin-bottom: 8px; font-size: 13px; color: #4d4a44; }
|
|
34
|
+
.key i { display: inline-block; width: 12px; height: 12px; margin-right: 6px; border-radius: 2px; vertical-align: -1px; }
|
|
35
|
+
/* Widen the plot by 5%, keeping the two 40px label rooms the same size. */
|
|
36
|
+
.chart { width: calc(105% - 4px); max-width: 584px; justify-self: center; align-self: center; }
|
|
37
|
+
.poster .chart { margin-bottom: 35px; }
|
|
38
|
+
@media (min-width: 860px) {
|
|
39
|
+
.poster { grid-template-columns: minmax(0, .85fr) minmax(0, 1.15fr); grid-template-rows: auto auto auto 1fr; column-gap: 40px; }
|
|
40
|
+
.poster figcaption { grid-column: 1; grid-row: 1; }
|
|
41
|
+
.readout { grid-column: 1; grid-row: 2; }
|
|
42
|
+
.key { grid-column: 1; grid-row: 3; }
|
|
43
|
+
.poster .chart { grid-column: 2; grid-row: 1 / span 4; align-self: center; }
|
|
44
|
+
.poster .note { grid-column: 1; grid-row: 4; align-self: end; }
|
|
45
|
+
}
|
|
46
|
+
/* On a phone the chart is taller than half the screen: the readout stays at the top of the screen while the chart
|
|
47
|
+
scrolls under it (the poster is a flex column there: a sticky grid item can't leave its own row) */
|
|
48
|
+
@media (max-width: 560px) {
|
|
49
|
+
body { padding: 24px 16px 32px; }
|
|
50
|
+
.poster { display: flex; flex-direction: column; padding: 34px 34px 20px 16px; }
|
|
51
|
+
.poster::after { top: 34px; right: 10px; font-size: 10px; }
|
|
52
|
+
.poster .headline { font-size: 31px; }
|
|
53
|
+
.poster .dek { font-size: 15px; }
|
|
54
|
+
.readout { position: sticky; top: 0; z-index: 2; background: inherit; }
|
|
55
|
+
.readout b { font-size: 46px; }
|
|
56
|
+
.readout span { font-size: 14px; }
|
|
57
|
+
}
|
|
58
|
+
</style>
|
|
59
|
+
</head>
|
|
60
|
+
<body>
|
|
61
|
+
<div id="chart"></div>
|
|
62
|
+
<script type="module">
|
|
63
|
+
import { Chart, Plot, Bar, Label, Poster, slat, html, render, createSignal, Show } from "@bezda/rhp/standalone";
|
|
64
|
+
|
|
65
|
+
// Data: people in each age band on October 1, 2023, in millions rounded to two decimals, youngest first, from the
|
|
66
|
+
// Statistics Bureau of Japan's population estimates (total population). Replace with your own.
|
|
67
|
+
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",
|
|
68
|
+
"65-69", "70-74", "75-79", "80-84", "85-89", "90+"];
|
|
69
|
+
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];
|
|
70
|
+
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];
|
|
71
|
+
const FROM = 13; // the age band picked at rest: 65 and older
|
|
72
|
+
const COLORS = { men: "#00749a", women: "#c8442f" };
|
|
73
|
+
|
|
74
|
+
// Both sides start SPINE away from 0, which leaves a strip down the middle for the ages. The axis ticks are moved
|
|
75
|
+
// out by the same amount, so they still read in millions.
|
|
76
|
+
const SPINE = 0.9;
|
|
77
|
+
const WIDEST = Math.ceil(Math.max(...MEN, ...WOMEN));
|
|
78
|
+
const STEPS = Array.from({ length: WIDEST + 1 }, (_, k) => k);
|
|
79
|
+
const TICKS = [...STEPS.map((k) => -(SPINE + k)).reverse(), ...STEPS.map((k) => SPINE + k)];
|
|
80
|
+
const TOTAL = [...MEN, ...WOMEN].reduce((sum, v) => sum + v, 0);
|
|
81
|
+
const millions = new Intl.NumberFormat("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
|
82
|
+
const percent = new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 0 });
|
|
83
|
+
const pale = (c) => `color-mix(in oklab, ${c} 28%, #f3f0e8)`;
|
|
84
|
+
|
|
85
|
+
// Slat type: an age band. The bands at or above the picked age are in full color; the picked one shows its numbers.
|
|
86
|
+
const Band = slat({
|
|
87
|
+
thickness: 23.1,
|
|
88
|
+
inset: "1.5px",
|
|
89
|
+
room: { start: 40, end: 40 },
|
|
90
|
+
css: `
|
|
91
|
+
.side { --rhp-start-radius: 0px; --rhp-end-radius: 3px; }
|
|
92
|
+
.age { padding: 0; translate: -50% -50%; font-size: 11px; font-weight: 500; color: #6d6a63; font-variant-numeric: tabular-nums; }
|
|
93
|
+
.old .age { color: #1e1d1b; font-weight: 700; }
|
|
94
|
+
.on .age { color: #1e1d1b; }
|
|
95
|
+
.val { font-size: 12px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
|
96
|
+
.band:focus-visible { outline: 2px solid #1e1d1b; outline-offset: -1px; border-radius: 3px; }
|
|
97
|
+
`,
|
|
98
|
+
}, (d) => html`
|
|
99
|
+
<div class=${() => ["band", d.old && "old", d.on && "on"].filter(Boolean).join(" ")} data-row=${() => d.index}>
|
|
100
|
+
<${Bar} from=${-SPINE} to=${() => -(SPINE + d.men)} color=${() => (d.old ? COLORS.men : pale(COLORS.men))} class="side" />
|
|
101
|
+
<${Bar} from=${SPINE} to=${() => SPINE + d.women} color=${() => (d.old ? COLORS.women : pale(COLORS.women))} class="side" />
|
|
102
|
+
<${Label} at=${0} class="age">${() => d.age}<//>
|
|
103
|
+
<${Show} when=${() => d.on}>
|
|
104
|
+
<${Label} at=${() => -(SPINE + d.men)} side="before" class="val">${() => millions.format(d.men)}<//>
|
|
105
|
+
<${Label} at=${() => SPINE + d.women} class="val">${() => millions.format(d.women)}<//>
|
|
106
|
+
<//>
|
|
107
|
+
</div>`);
|
|
108
|
+
|
|
109
|
+
// Chart component: the band the reader points at, taps or reaches with the keyboard sets the age, and the readout
|
|
110
|
+
// counts everyone that age or older
|
|
111
|
+
function AgeingJapan() {
|
|
112
|
+
const [picked, setPicked] = createSignal(null);
|
|
113
|
+
const from = () => picked() ?? FROM;
|
|
114
|
+
// The row of the slat an element is in, or null (between slats, on the axis or the text)
|
|
115
|
+
const rowOf = (el) => {
|
|
116
|
+
const root = el.closest("[data-row]");
|
|
117
|
+
return root ? +root.dataset.row : null;
|
|
118
|
+
};
|
|
119
|
+
// A mouse or a pen picks as it moves, a tap or a click picks, the keyboard picks; on no slat the pick stays
|
|
120
|
+
const pick = (e) => {
|
|
121
|
+
const row = rowOf(e.target);
|
|
122
|
+
if (row != null) setPicked(row);
|
|
123
|
+
};
|
|
124
|
+
// Leaving the poster: back to the slat that has focus (a keyboard's or a click's pick), or none
|
|
125
|
+
const leave = (e) => {
|
|
126
|
+
if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
|
|
127
|
+
setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
|
|
128
|
+
};
|
|
129
|
+
const older = () => MEN.slice(from()).concat(WOMEN.slice(from())).reduce((sum, v) => sum + v, 0);
|
|
130
|
+
return html`
|
|
131
|
+
<${Poster}
|
|
132
|
+
kicker="Japan · population by age and sex"
|
|
133
|
+
title="Nearly three in ten people in Japan are 65 or older"
|
|
134
|
+
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."
|
|
135
|
+
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."
|
|
136
|
+
onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
|
|
137
|
+
onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
|
|
138
|
+
<p class="readout" aria-live="polite">
|
|
139
|
+
<b>${() => percent.format(older() / TOTAL)}</b>
|
|
140
|
+
<span>${() => (from() ? `are ${parseInt(AGES[from()])} or older` : "of all ages")}</span>
|
|
141
|
+
<span>${() => `${millions.format(older())} million people`}</span>
|
|
142
|
+
</p>
|
|
143
|
+
<p class="key"><span><i style=${{ background: COLORS.men }}></i>Men</span><span><i style=${{ background: COLORS.women }}></i>Women</span></p>
|
|
144
|
+
<${Chart} class="chart" scale=${[-(SPINE + WIDEST), SPINE + WIDEST]} ticks=${TICKS} format=${(v) => (Math.abs(v) - SPINE ? Math.round(Math.abs(v) - SPINE) + "M" : "0")}
|
|
145
|
+
label="Japan's population by age band in 2023, men and women, in millions"
|
|
146
|
+
theme=${{ font: "'Zen Kaku Gothic New', 'Helvetica Neue', Arial, sans-serif", ink: "#1e1d1b", muted: "#6d6a63", grid: "#dfdad0" }}>
|
|
147
|
+
<${Plot} keyboard=${true} age=${AGES} men=${MEN} women=${WOMEN} order=${AGES.map((_, i) => AGES.length - 1 - i)}
|
|
148
|
+
old=${(d) => d.index >= from()} on=${(d) => d.index === from()}>${Band}<//>
|
|
149
|
+
<//>
|
|
150
|
+
<//>`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
render(() => html`<${AgeingJapan} />`, document.getElementById("chart"));
|
|
154
|
+
</script>
|
|
155
|
+
</body>
|
|
156
|
+
</html>
|