@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,272 @@
|
|
|
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>China's economy caught up</title>
|
|
7
|
+
<meta name="description" content="Bar chart race: ten economies ranked by GDP from 1990 to 2024, the bars gliding from year to year and sliding past each other as ranks change, with a play button, a year slider that lands on whole years, and a readout on hover, focus and tap. A playhead moves on every frame and each value is interpolated between the years around it, so the bars move by rhp's JS version (animate). Use it for a ranking that changes 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=Bodoni+Moda:ital,opsz,wght@0,6..96,400..800;1,6..96,400..800&family=Jost:wght@400;500;600&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: #cbcfc9; font-family: Jost, "Avenir Next", "Century Gothic", sans-serif; }
|
|
15
|
+
/* The poster: an engraved banknote. Cool cotton paper, a guilloche rosette in the corner, a double rule inside the edge,
|
|
16
|
+
dollar-green ink for the leader and gold for the story */
|
|
17
|
+
.poster { max-width: 960px; margin: 0 auto; padding: 58px 62px 36px; color: #17201b; border-radius: 2px;
|
|
18
|
+
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-100 -100 200 200'%3E%3Cg fill='none' stroke='%239aa49c' stroke-width='.4'%3E%3Cg id='a'%3E%3Cellipse id='e' rx='98' ry='30'/%3E%3Cuse href='%23e' transform='rotate(6)'/%3E%3Cuse href='%23e' transform='rotate(12)'/%3E%3Cuse href='%23e' transform='rotate(18)'/%3E%3Cuse href='%23e' transform='rotate(24)'/%3E%3C/g%3E%3Cuse href='%23a' transform='rotate(30)'/%3E%3Cuse href='%23a' transform='rotate(60)'/%3E%3Cuse href='%23a' transform='rotate(90)'/%3E%3Cuse href='%23a' transform='rotate(120)'/%3E%3Cuse href='%23a' transform='rotate(150)'/%3E%3Ccircle r='30'/%3E%3Ccircle r='27'/%3E%3C/g%3E%3C/svg%3E") no-repeat right -170px top -170px / 340px 340px, #f2f3ee;
|
|
19
|
+
box-shadow: inset 0 0 0 10px #f2f3ee, inset 0 0 0 11px #9ba69e, inset 0 0 0 14px #f2f3ee, inset 0 0 0 15px #c3cac3, 0 30px 60px -30px rgb(36 44 40 / .55); }
|
|
20
|
+
.poster figcaption { display: grid; gap: 14px; }
|
|
21
|
+
.poster .kicker { font-size: 12px; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; color: #4c5650; }
|
|
22
|
+
.poster .headline { max-width: 15em; font: 600 62px/.98 "Bodoni Moda", Didot, "Bodoni 72", Georgia, serif; letter-spacing: -.012em; text-wrap: balance; }
|
|
23
|
+
.poster .headline .cn { color: #8a6412; font-style: italic; }
|
|
24
|
+
.poster .headline .us { color: #1d5b44; font-style: italic; }
|
|
25
|
+
.poster .dek { max-width: 54ch; font-size: 17px; line-height: 1.5; color: #434b46; }
|
|
26
|
+
.waves { height: 14px; margin: 30px 0 22px;
|
|
27
|
+
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='14'%3E%3Cg fill='none' stroke='%23a3ada5' stroke-width='.6'%3E%3Cpath id='w' d='M-24 7Q-18-1-12 7T0 7T12 7T24 7T36 7T48 7'/%3E%3Cuse href='%23w' x='8'/%3E%3Cuse href='%23w' x='16'/%3E%3C/g%3E%3C/svg%3E") repeat-x center / 24px 14px; }
|
|
28
|
+
/* The controls: play, the year slider, and the year in big engraved numerals. The year keeps the width of the widest
|
|
29
|
+
year, so the slider beside it keeps its length while the handle moves. */
|
|
30
|
+
.controls { display: flex; align-items: center; gap: 18px; }
|
|
31
|
+
.play { flex: none; display: grid; place-items: center; width: 50px; height: 50px; padding: 0; border: 0; border-radius: 50%; color: #f2f3ee; background: #1d5b44; cursor: pointer; }
|
|
32
|
+
.play svg { width: 18px; height: 18px; fill: currentColor; }
|
|
33
|
+
.play:focus-visible { outline: 2px solid #17201b; outline-offset: 3px; }
|
|
34
|
+
.scrub { flex: 1; min-width: 0; height: 30px; margin: 0; background: transparent; cursor: pointer; appearance: none; -webkit-appearance: none; }
|
|
35
|
+
.scrub::-webkit-slider-runnable-track { height: 2px; background: #17201b; }
|
|
36
|
+
.scrub::-moz-range-track { height: 2px; background: #17201b; }
|
|
37
|
+
.scrub::-webkit-slider-thumb { width: 20px; height: 20px; margin-top: -9px; border: 2px solid #17201b; border-radius: 50%; background: #f2f3ee; -webkit-appearance: none; }
|
|
38
|
+
.scrub::-moz-range-thumb { width: 16px; height: 16px; border: 2px solid #17201b; border-radius: 50%; background: #f2f3ee; }
|
|
39
|
+
.scrub:focus-visible { outline: 2px solid #1d5b44; outline-offset: 4px; border-radius: 2px; }
|
|
40
|
+
.year { flex: none; min-width: 2.4em; text-align: right; font: italic 500 92px/.8 "Bodoni Moda", Didot, "Bodoni 72", Georgia, serif; font-variant-numeric: lining-nums tabular-nums; color: #1d5b44; }
|
|
41
|
+
/* One line that reads out the country in focus; its height is fixed, so nothing below it moves */
|
|
42
|
+
.readout { height: 24px; margin: 18px 0 12px; font-size: 16px; line-height: 24px; white-space: nowrap; color: #434b46; }
|
|
43
|
+
.readout b { font-weight: 600; color: #17201b; }
|
|
44
|
+
.unit { display: block; margin-top: 4px; font-size: 12px; text-align: right; color: #5b645e; }
|
|
45
|
+
.poster .note { display: block; margin-top: 22px; padding-top: 12px; border-top: 1px solid #c3cac3; font-size: 12px; line-height: 1.5; color: #5b645e; }
|
|
46
|
+
@media (max-width: 640px) {
|
|
47
|
+
body { padding: 16px 16px 32px; }
|
|
48
|
+
.poster { padding: 32px 22px 22px; background-position: right -70px top -70px, 0 0; background-size: 140px 140px, auto; }
|
|
49
|
+
.poster .headline { font-size: 37px; }
|
|
50
|
+
.poster .dek { font-size: 15px; }
|
|
51
|
+
.waves { margin: 22px 0 16px; }
|
|
52
|
+
.controls { gap: 12px; }
|
|
53
|
+
.play { width: 44px; height: 44px; }
|
|
54
|
+
.year { font-size: 54px; }
|
|
55
|
+
.readout { font-size: 14px; margin: 14px 0 8px; }
|
|
56
|
+
}
|
|
57
|
+
</style>
|
|
58
|
+
</head>
|
|
59
|
+
<body>
|
|
60
|
+
<div id="chart"></div>
|
|
61
|
+
<script type="module">
|
|
62
|
+
import { Chart, Plot, Bar, Label, Poster, slat, sortBy, html, render, createSignal, createMemo, onMount, onCleanup } from "@bezda/rhp/standalone";
|
|
63
|
+
|
|
64
|
+
// Data: GDP in trillions of current US dollars, one value per year from 1990 to 2024, for the ten largest economies
|
|
65
|
+
// of 2024. World Bank, World Development Indicators, rounded to $10 billion; Brazil's 1991 to 1993 are interpolated.
|
|
66
|
+
// Replace with your own: one row per item, one value per step.
|
|
67
|
+
const FIRST = 1990;
|
|
68
|
+
const LAST = 2024;
|
|
69
|
+
const ECONOMIES = [
|
|
70
|
+
{ name: "United States", short: "the US", kind: "lead", byYear: [5.96, 6.16, 6.52, 6.86, 7.29, 7.64, 8.07, 8.58, 9.06, 9.63, 10.25, 10.58, 10.94, 11.46, 12.22, 13.04, 13.82, 14.47, 14.77, 14.48, 15.05, 15.6, 16.25, 16.88, 17.61, 18.3, 18.8, 19.61, 20.66, 21.54, 21.35, 23.68, 26.01, 27.72, 29.18] },
|
|
71
|
+
{ name: "China", kind: "story", byYear: [0.36, 0.38, 0.43, 0.44, 0.56, 0.73, 0.86, 0.96, 1.03, 1.09, 1.21, 1.34, 1.47, 1.66, 1.96, 2.29, 2.75, 3.55, 4.59, 5.1, 6.09, 7.55, 8.53, 9.57, 10.48, 11.06, 11.23, 12.31, 13.89, 14.28, 14.69, 17.82, 17.88, 18.27, 18.74] },
|
|
72
|
+
{ name: "Germany", byYear: [1.77, 1.87, 2.13, 2.07, 2.21, 2.59, 2.5, 2.22, 2.24, 2.2, 1.95, 1.95, 2.08, 2.5, 2.81, 2.85, 2.99, 3.43, 3.75, 3.41, 3.4, 3.75, 3.53, 3.73, 3.89, 3.36, 3.47, 3.69, 3.97, 3.89, 3.89, 4.28, 4.16, 4.53, 4.66] },
|
|
73
|
+
{ name: "Japan", byYear: [3.13, 3.58, 3.91, 4.45, 5, 5.55, 4.92, 4.49, 4.1, 4.64, 4.97, 4.37, 4.18, 4.52, 4.89, 4.83, 4.6, 4.58, 5.11, 5.29, 5.76, 6.23, 6.27, 5.21, 4.9, 4.44, 5, 4.93, 5.04, 5.12, 5.06, 5.03, 4.26, 4.21, 4.03] },
|
|
74
|
+
{ name: "India", byYear: [0.32, 0.27, 0.29, 0.28, 0.33, 0.36, 0.39, 0.42, 0.42, 0.46, 0.47, 0.49, 0.51, 0.61, 0.71, 0.82, 0.94, 1.22, 1.2, 1.34, 1.68, 1.82, 1.83, 1.86, 2.04, 2.1, 2.29, 2.65, 2.7, 2.84, 2.67, 3.17, 3.35, 3.64, 3.91] },
|
|
75
|
+
{ name: "United Kingdom", byYear: [1.09, 1.14, 1.18, 1.06, 1.14, 1.34, 1.42, 1.56, 1.65, 1.69, 1.67, 1.65, 1.79, 2.05, 2.42, 2.54, 2.71, 3.09, 2.93, 2.41, 2.49, 2.66, 2.71, 2.78, 3.06, 2.93, 2.69, 2.68, 2.87, 2.85, 2.7, 3.14, 3.11, 3.38, 3.64] },
|
|
76
|
+
{ name: "France", byYear: [1.27, 1.27, 1.4, 1.32, 1.39, 1.6, 1.61, 1.45, 1.5, 1.49, 1.36, 1.38, 1.49, 1.84, 2.12, 2.2, 2.32, 2.66, 2.92, 2.69, 2.64, 2.86, 2.68, 2.81, 2.86, 2.44, 2.47, 2.6, 2.79, 2.73, 2.65, 2.96, 2.78, 3.05, 3.16] },
|
|
77
|
+
{ name: "Italy", byYear: [1.18, 1.25, 1.32, 1.06, 1.1, 1.17, 1.31, 1.24, 1.27, 1.25, 1.14, 1.16, 1.27, 1.57, 1.8, 1.85, 1.94, 2.2, 2.39, 2.19, 2.13, 2.29, 2.09, 2.14, 2.16, 1.84, 1.88, 1.96, 2.09, 2.01, 1.9, 2.18, 2.1, 2.3, 2.37] },
|
|
78
|
+
{ name: "Canada", byYear: [0.59, 0.61, 0.59, 0.58, 0.58, 0.6, 0.63, 0.65, 0.63, 0.68, 0.74, 0.74, 0.76, 0.9, 1.03, 1.17, 1.32, 1.47, 1.55, 1.37, 1.61, 1.79, 1.83, 1.85, 1.81, 1.56, 1.53, 1.65, 1.73, 1.74, 1.66, 2.01, 2.16, 2.14, 2.24] },
|
|
79
|
+
{ name: "Brazil", byYear: [0.46, 0.49, 0.51, 0.54, 0.56, 0.77, 0.85, 0.88, 0.86, 0.6, 0.66, 0.56, 0.51, 0.56, 0.67, 0.89, 1.11, 1.4, 1.7, 1.67, 2.21, 2.62, 2.47, 2.47, 2.46, 1.8, 1.8, 2.06, 1.92, 1.87, 1.48, 1.67, 1.95, 2.19, 2.17] },
|
|
80
|
+
];
|
|
81
|
+
const STORY = ECONOMIES.findIndex((e) => e.kind === "story");
|
|
82
|
+
const INK = { lead: "#1d5b44", story: "#c4962a", other: "#7f8b84" };
|
|
83
|
+
const YEAR_MS = 700; // how long a year takes while playing
|
|
84
|
+
|
|
85
|
+
// GDP at t, a year with a fraction: each value on the straight line between the years around t, and at a whole year
|
|
86
|
+
// that year's own figures
|
|
87
|
+
const gdpAt = (t) => {
|
|
88
|
+
const k = Math.min(Math.floor(t), LAST - 1) - FIRST;
|
|
89
|
+
const f = t - FIRST - k;
|
|
90
|
+
return ECONOMIES.map((e) => e.byYear[k] * (1 - f) + e.byYear[k + 1] * f);
|
|
91
|
+
};
|
|
92
|
+
const cents = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
93
|
+
const tenths = new Intl.NumberFormat("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
|
94
|
+
const trillions = (v) => (v < 9.995 ? cents : tenths).format(v); // 0.36, 5.96, 29.2: in-between values print the same way
|
|
95
|
+
|
|
96
|
+
// Slat type: the country's name, an engraved bar, and its GDP, rounded while it moves (d.gdp is fractional then)
|
|
97
|
+
const Economy = slat({
|
|
98
|
+
thickness: { horizontal: 38 },
|
|
99
|
+
inset: 0.2,
|
|
100
|
+
room: { start: "auto", end: 50 },
|
|
101
|
+
css: `
|
|
102
|
+
.economy { outline: none; }
|
|
103
|
+
.name { font-size: 15px; font-weight: 500; }
|
|
104
|
+
.lead .name, .story .name { font-weight: 600; }
|
|
105
|
+
.lead .name { color: #1d5b44; }
|
|
106
|
+
.story .name { color: #7c5a10; }
|
|
107
|
+
.bar { --rhp-radius: 1px; background: repeating-linear-gradient(to bottom, var(--tone) 0 1.5px, transparent 1.5px 3.5px); box-shadow: inset 0 0 0 1px var(--tone); }
|
|
108
|
+
.lead .bar, .story .bar { background: var(--tone); }
|
|
109
|
+
.value { --rhp-label-gap: 8px; font-size: 14px; font-weight: 500; }
|
|
110
|
+
.ring { position: absolute; inset: -4px; border: 1.5px solid #17201b; border-radius: 3px; opacity: 0; transition: opacity .2s; }
|
|
111
|
+
.on .ring, .economy:focus-visible .ring { opacity: 1; }
|
|
112
|
+
@container (max-width: 480px) { .name { font-size: 13px; } .value { font-size: 12px; } }
|
|
113
|
+
@media (prefers-reduced-motion: reduce) { .ring { transition: none; } }
|
|
114
|
+
`,
|
|
115
|
+
}, (d) => html`
|
|
116
|
+
<div class=${() => `economy ${d.kind ?? "other"}${d.on ? " on" : ""}`} style=${() => ({ "--tone": INK[d.kind ?? "other"] })}
|
|
117
|
+
data-row=${() => d.index} aria-label=${() => d.say}>
|
|
118
|
+
<${Label} edge="start" class="name">${() => d.name}<//>
|
|
119
|
+
<${Bar} to=${() => d.gdp} class="bar"><i class="ring"></i><//>
|
|
120
|
+
<${Label} at=${() => d.gdp} class="value">${() => trillions(d.gdp)}<//>
|
|
121
|
+
</div>`);
|
|
122
|
+
|
|
123
|
+
const PLAY = () => html`<svg viewBox="0 0 18 18" aria-hidden="true"><path d="M4 2l12 7-12 7z" /></svg>`;
|
|
124
|
+
const PAUSE = () => html`<svg viewBox="0 0 18 18" aria-hidden="true"><path d="M3 2h4v14H3zM11 2h4v14h-4z" /></svg>`;
|
|
125
|
+
// The slider's keys: how many years each one moves from the nearest whole year
|
|
126
|
+
const KEYS = { ArrowLeft: -1, ArrowDown: -1, ArrowRight: 1, ArrowUp: 1, PageDown: -5, PageUp: 5, Home: -Infinity, End: Infinity };
|
|
127
|
+
|
|
128
|
+
// Chart component: the playhead t, a year with a fraction, is the app's state, and the bars show the values at t.
|
|
129
|
+
// Play moves t on with time, Pause leaves it where it is (between two years too), and the slider sets it to whole years.
|
|
130
|
+
function GdpRace() {
|
|
131
|
+
const still = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
132
|
+
const [t, setT] = createSignal(still ? LAST : FIRST);
|
|
133
|
+
const [playing, setPlaying] = createSignal(false);
|
|
134
|
+
const [picked, setPicked] = createSignal(null);
|
|
135
|
+
const gdp = createMemo(() => gdpAt(t()));
|
|
136
|
+
const top = () => Math.max(...gdp());
|
|
137
|
+
// The year shown: the whole year nearest to t, whose figures the bars are closest to. It turns halfway between two
|
|
138
|
+
// years, and the slider's keys move from it.
|
|
139
|
+
const year = () => Math.round(t());
|
|
140
|
+
// What screen readers get: while playing, that year (so their text changes once a year, not every frame); when
|
|
141
|
+
// paused, t itself, which may be between two years
|
|
142
|
+
const told = createMemo(() => (playing() ? year() : t()));
|
|
143
|
+
const when = () => (Number.isInteger(told()) ? `${told()}` : `between ${Math.floor(told())} and ${Math.ceil(told())}`);
|
|
144
|
+
const say = () => gdpAt(told()).map((v, i) => `${ECONOMIES[i].name}, ${when()}: ${trillions(v)} trillion dollars`);
|
|
145
|
+
|
|
146
|
+
let frame;
|
|
147
|
+
let autoplay;
|
|
148
|
+
let auto = !still; // the race may start by itself until the reader uses a control
|
|
149
|
+
const pause = () => {
|
|
150
|
+
cancelAnimationFrame(frame);
|
|
151
|
+
setPlaying(false);
|
|
152
|
+
};
|
|
153
|
+
// Each frame moves the playhead on by the time since the last frame, and the race stops at the last year. A frame
|
|
154
|
+
// counts at most 250 ms, so a race in a tab the reader left resumes where it was. Under reduced motion t takes whole
|
|
155
|
+
// years only: the bars step from year to year without gliding.
|
|
156
|
+
const play = () => {
|
|
157
|
+
let p = t() < LAST ? t() : FIRST; // from the last year, the race starts again from the first
|
|
158
|
+
let last = null;
|
|
159
|
+
const move = (now) => {
|
|
160
|
+
p = Math.min(LAST, p + Math.min(now - (last ?? now), 250) / YEAR_MS);
|
|
161
|
+
last = now;
|
|
162
|
+
setT(still ? Math.floor(p) : p);
|
|
163
|
+
if (p < LAST) frame = requestAnimationFrame(move);
|
|
164
|
+
else pause();
|
|
165
|
+
};
|
|
166
|
+
setPlaying(true);
|
|
167
|
+
frame = requestAnimationFrame(move);
|
|
168
|
+
};
|
|
169
|
+
const toggle = () => {
|
|
170
|
+
auto = false;
|
|
171
|
+
if (playing()) pause();
|
|
172
|
+
else play();
|
|
173
|
+
};
|
|
174
|
+
// A drag or a tap on the slider pauses on the nearest whole year, and the handle shows that year at once (also when t
|
|
175
|
+
// was that year already, so a drag never leaves the handle between years)
|
|
176
|
+
const seek = (e) => {
|
|
177
|
+
auto = false;
|
|
178
|
+
pause();
|
|
179
|
+
// Whole years, because each year is a measured value (the values between are interpolated); a timeline with no
|
|
180
|
+
// such points keeps a continuous slider: setT(+e.currentTarget.value)
|
|
181
|
+
setT(Math.round(+e.currentTarget.value));
|
|
182
|
+
e.currentTarget.value = t();
|
|
183
|
+
};
|
|
184
|
+
// Its keys move from the nearest whole year, handled here so that every browser moves the same
|
|
185
|
+
const key = (e) => {
|
|
186
|
+
const by = KEYS[e.key];
|
|
187
|
+
if (by === undefined || e.altKey || e.ctrlKey || e.metaKey) return;
|
|
188
|
+
e.preventDefault();
|
|
189
|
+
auto = false;
|
|
190
|
+
pause();
|
|
191
|
+
setT(Math.min(LAST, Math.max(FIRST, year() + by)));
|
|
192
|
+
};
|
|
193
|
+
onCleanup(() => {
|
|
194
|
+
cancelAnimationFrame(frame);
|
|
195
|
+
clearTimeout(autoplay);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// The race starts on its own once half the chart is in view, unless the reader asked for reduced motion
|
|
199
|
+
let stage;
|
|
200
|
+
onMount(() => {
|
|
201
|
+
if (!auto) return;
|
|
202
|
+
const seen = new IntersectionObserver(([e]) => {
|
|
203
|
+
if (!e.isIntersecting) return;
|
|
204
|
+
seen.disconnect();
|
|
205
|
+
autoplay = setTimeout(() => auto && play(), 1000);
|
|
206
|
+
}, { threshold: 0.5 });
|
|
207
|
+
seen.observe(stage);
|
|
208
|
+
onCleanup(() => seen.disconnect());
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// JS version: while playing, the values change on every frame, so rhp moves each one on its own clock instead of
|
|
212
|
+
// restarting a CSS transition each frame. Its default 150 ms keeps the bars a few pixels behind the playhead, rounds
|
|
213
|
+
// off the change of speed at each year, and makes a jump of the slider a short move. The scale follows the leader, so
|
|
214
|
+
// animate goes on the Chart: the axis and the bars move together (the Plot takes the Chart's animate).
|
|
215
|
+
const motion = { slide: 300 };
|
|
216
|
+
|
|
217
|
+
// The row of the slat an element is in, or null (between slats, on the axis or the text)
|
|
218
|
+
const rowOf = (el) => {
|
|
219
|
+
const root = el.closest("[data-row]");
|
|
220
|
+
return root ? +root.dataset.row : null;
|
|
221
|
+
};
|
|
222
|
+
// A mouse or a pen picks as it moves, a tap or a click picks, the keyboard picks; on no slat the pick stays
|
|
223
|
+
const pick = (e) => {
|
|
224
|
+
const row = rowOf(e.target);
|
|
225
|
+
if (row != null) setPicked(row);
|
|
226
|
+
};
|
|
227
|
+
// Leaving the chart: back to the slat that has focus (a keyboard's or a click's pick), or none
|
|
228
|
+
const leave = (e) => {
|
|
229
|
+
if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
|
|
230
|
+
setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
|
|
231
|
+
};
|
|
232
|
+
const readout = () => {
|
|
233
|
+
const i = picked() ?? STORY;
|
|
234
|
+
const all = gdp();
|
|
235
|
+
const lead = all.indexOf(top());
|
|
236
|
+
const share = Math.round((all[i] / all[lead]) * 100);
|
|
237
|
+
const leader = ECONOMIES[lead].short ?? ECONOMIES[lead].name;
|
|
238
|
+
return html`<b>${ECONOMIES[i].name}</b>: $${trillions(all[i])} trillion, ${i === lead ? "the largest" : `${share}% of ${leader}`}`;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
return html`
|
|
242
|
+
<${Poster}
|
|
243
|
+
kicker="GDP, 1990 to 2024"
|
|
244
|
+
title=${html`<span class="cn">China</span>'s economy grew from 6% of <span class="us">America</span>'s to 64%`}
|
|
245
|
+
dek="The ten largest economies of 2024, in trillions of current US dollars, every year since 1990. The scale follows the leader, so every bar is measured against America's. Press play, drag through the years, or point at a country."
|
|
246
|
+
note="Source: World Bank, World Development Indicators, GDP in current US dollars (2025 release), rounded to $10 billion. Brazil's figures for 1991 to 1993 are interpolated between 1990 and 1994. Tab into the chart and use the arrow keys to read each country.">
|
|
247
|
+
<div class="waves" aria-hidden="true"></div>
|
|
248
|
+
<div class="controls">
|
|
249
|
+
<button type="button" class="play" aria-label="Play" aria-pressed=${() => String(playing())} onClick=${toggle}>${() => (playing() ? PAUSE() : PLAY())}</button>
|
|
250
|
+
<input type="range" class="scrub" aria-label="Year" min=${FIRST} max=${LAST} step="any" value=${t}
|
|
251
|
+
aria-valuetext=${when} onInput=${seek} onKeyDown=${key}>
|
|
252
|
+
<span class="year" aria-hidden="true">${year}</span>
|
|
253
|
+
</div>
|
|
254
|
+
<p class="readout" aria-live=${() => (playing() ? "off" : "polite")}>${readout}</p>
|
|
255
|
+
<div class="stage" ref=${(el) => (stage = el)}
|
|
256
|
+
onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
|
|
257
|
+
onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
|
|
258
|
+
<${Chart} scale=${() => [0, top() * 1.1]} ticks=${4} animate=${motion}
|
|
259
|
+
label=${() => `GDP of the ten largest economies of 2024, in ${when()}, in trillions of US dollars`}
|
|
260
|
+
theme=${{ font: 'Jost, "Avenir Next", "Century Gothic", sans-serif', ink: "#17201b", muted: "#5b645e", grid: "#dde1db" }}>
|
|
261
|
+
<${Plot} keyboard=${true} rows=${ECONOMIES} gdp=${gdp} say=${say} on=${(d) => d.index === picked()}
|
|
262
|
+
order=${sortBy("gdp", "desc")}>${Economy}<//>
|
|
263
|
+
<//>
|
|
264
|
+
<span class="unit">Trillions of US dollars</span>
|
|
265
|
+
</div>
|
|
266
|
+
<//>`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
render(() => html`<${GdpRace} />`, document.getElementById("chart"));
|
|
270
|
+
</script>
|
|
271
|
+
</body>
|
|
272
|
+
</html>
|
|
@@ -0,0 +1,151 @@
|
|
|
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>A week of daily goals</title>
|
|
7
|
+
<meta name="description" content="Radial bars: one ring per goal, its arc the share of the goal reached, with a day switch that sweeps the arcs to another day. Use it for progress toward a few goals or targets, in percent.">
|
|
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=Young+Serif&family=Hanken+Grotesk:wght@400..800&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: 56px 16px 48px; background: #d6d4cf; font-family: "Hanken Grotesk", "Helvetica Neue", Arial, sans-serif; }
|
|
15
|
+
/* The poster: a planner page on dotted paper, taped to the wall */
|
|
16
|
+
.poster { position: relative; display: grid; max-width: 980px; margin: 0 auto; padding: 46px 48px 30px; color: #1d1c1a;
|
|
17
|
+
background: radial-gradient(circle, rgb(29 28 26 / .16) 1px, transparent 1.3px) 0 0 / 18px 18px, #f6f5f1;
|
|
18
|
+
box-shadow: 0 1px 2px rgb(40 38 34 / .18), 0 24px 44px -26px rgb(40 38 34 / .55); }
|
|
19
|
+
.poster > * { min-width: 0; }
|
|
20
|
+
.poster::before { content: ""; position: absolute; top: -16px; left: 50%; width: 132px; height: 32px; translate: -50% 0; rotate: -2.5deg;
|
|
21
|
+
background: repeating-linear-gradient(-45deg, rgb(255 255 255 / .55) 0 6px, rgb(255 255 255 / .2) 6px 12px), rgb(222 219 210 / .82);
|
|
22
|
+
box-shadow: 0 1px 2px rgb(40 38 34 / .14); }
|
|
23
|
+
.poster figcaption { display: grid; gap: 12px; margin-bottom: 24px; }
|
|
24
|
+
.poster .kicker { font-size: 12px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; color: #6a665e; }
|
|
25
|
+
.poster .headline { font: 400 48px/1.02 "Young Serif", Georgia, serif; letter-spacing: -.015em; text-wrap: balance; }
|
|
26
|
+
.poster .dek { max-width: 44ch; font-size: 17px; line-height: 1.5; color: #4b4842; }
|
|
27
|
+
.poster .note { display: block; margin-top: 22px; font-size: 12px; line-height: 1.5; color: #6a665e; }
|
|
28
|
+
/* The day switch: each day with a dot for every goal it met */
|
|
29
|
+
.days { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 6px; max-width: 420px; margin-bottom: 8px; }
|
|
30
|
+
.days button { display: grid; justify-items: center; gap: 6px; padding: 8px 0 9px; border: 1.5px solid #1d1c1a; border-radius: 12px; background: #f6f5f1;
|
|
31
|
+
font: 700 13px/1 "Hanken Grotesk", Arial, sans-serif; color: #1d1c1a; cursor: pointer; }
|
|
32
|
+
.days button[aria-pressed="true"] { background: #1d1c1a; color: #f6f5f1; }
|
|
33
|
+
.days button:not([aria-pressed="true"]):hover { background: #e9e7e1; }
|
|
34
|
+
.days button:focus-visible { outline: 2px solid #1d1c1a; outline-offset: 2px; }
|
|
35
|
+
.days span { display: flex; gap: 2px; }
|
|
36
|
+
.days i { width: 5px; height: 5px; border-radius: 50%; box-shadow: inset 0 0 0 1px currentColor; opacity: .35; }
|
|
37
|
+
.days i.met { background: var(--c); box-shadow: none; opacity: 1; }
|
|
38
|
+
.chart { width: 100%; max-width: 500px; justify-self: center; }
|
|
39
|
+
/* The middle of the rings: how many goals the day met */
|
|
40
|
+
.hub { position: relative; z-index: 2; display: grid; place-content: center; gap: 4px; text-align: center; pointer-events: none; }
|
|
41
|
+
.hub b { font: 400 clamp(22px, 7cqw, 40px)/1 "Young Serif", Georgia, serif; }
|
|
42
|
+
.hub span { font-size: clamp(11px, 2.6cqw, 13px); font-weight: 600; color: #4b4842; }
|
|
43
|
+
@media (min-width: 860px) {
|
|
44
|
+
.poster { grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); grid-template-rows: auto auto 1fr; column-gap: 44px; }
|
|
45
|
+
.poster figcaption { grid-column: 1; grid-row: 1; }
|
|
46
|
+
.days { grid-column: 1; grid-row: 2; }
|
|
47
|
+
.poster .chart { grid-column: 2; grid-row: 1 / span 3; align-self: center; }
|
|
48
|
+
.poster .note { grid-column: 1; grid-row: 3; align-self: end; }
|
|
49
|
+
}
|
|
50
|
+
@media (max-width: 520px) {
|
|
51
|
+
body { padding: 40px 16px 32px; }
|
|
52
|
+
.poster { padding: 34px 18px 20px; }
|
|
53
|
+
.poster .headline { font-size: 34px; }
|
|
54
|
+
.poster .dek { font-size: 15px; }
|
|
55
|
+
.days { margin-bottom: 14px; }
|
|
56
|
+
}
|
|
57
|
+
</style>
|
|
58
|
+
</head>
|
|
59
|
+
<body>
|
|
60
|
+
<div id="chart"></div>
|
|
61
|
+
<script type="module">
|
|
62
|
+
import { Chart, Plot, Bar, Place, Poster, slat, html, render, createSignal, For } from "@bezda/rhp/standalone";
|
|
63
|
+
|
|
64
|
+
// Data: a week against five daily goals, Monday first. Illustrative. Replace with your own.
|
|
65
|
+
// The first goal is the outer ring.
|
|
66
|
+
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
67
|
+
const GOALS = [
|
|
68
|
+
{ goal: "Move", target: 600, unit: "kcal", color: "#eb6834", week: [520, 610, 480, 700, 650, 380, 560] },
|
|
69
|
+
{ goal: "Exercise", target: 30, unit: "min", color: "#1baf7a", week: [25, 40, 30, 45, 20, 60, 35] },
|
|
70
|
+
{ goal: "Stand", target: 12, unit: "hours", color: "#eda100", week: [10, 12, 9, 12, 11, 7, 10] },
|
|
71
|
+
{ goal: "Water", target: 8, unit: "glasses", color: "#2a78d6", week: [6, 8, 5, 7, 8, 4, 6] },
|
|
72
|
+
{ goal: "Sleep", target: 8, unit: "hours", color: "#4a3aa7", week: [6.5, 7.2, 6.1, 6.8, 7.5, 8.6, 6.9] },
|
|
73
|
+
];
|
|
74
|
+
const percent = new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 0 });
|
|
75
|
+
const share = (g, day) => g.week[day] / g.target;
|
|
76
|
+
|
|
77
|
+
// Slat type: one goal, drawn as a ring around the plot's middle. rhp gives the CSS numbers from 0 to 1, and the CSS
|
|
78
|
+
// spends them on angles: --rhp-hi (the Bar's end) sweeps the arc, --rhp-p (the Place's value) turns its round cap,
|
|
79
|
+
// and --rhp-position (the slat's place) sets the ring's radius. A full arc is 270 degrees, which leaves the top left
|
|
80
|
+
// quarter free for the names.
|
|
81
|
+
const Goal = slat({
|
|
82
|
+
room: { start: 0, end: 0 },
|
|
83
|
+
css: `
|
|
84
|
+
@property --swept { syntax: "<angle>"; inherits: false; initial-value: 0deg; }
|
|
85
|
+
.goal {
|
|
86
|
+
--outer: calc(50cqw - 4px);
|
|
87
|
+
--band: calc(var(--outer) * .12);
|
|
88
|
+
--step: calc(var(--outer) * .155);
|
|
89
|
+
--r: calc(var(--outer) - var(--band) / 2 - var(--rhp-position) * var(--step));
|
|
90
|
+
--mid: calc((var(--rhp-n) / 2 - var(--rhp-position)) * 100%);
|
|
91
|
+
--tint: color-mix(in oklab, var(--c) 16%, #f6f5f1);
|
|
92
|
+
--ease: .7s cubic-bezier(.3, 0, .2, 1);
|
|
93
|
+
}
|
|
94
|
+
.ring, .arc, .end, .start, .name { position: absolute; left: var(--mid); top: 50%; bottom: auto; }
|
|
95
|
+
.ring, .arc { width: calc(var(--r) * 2 + var(--band)); height: calc(var(--r) * 2 + var(--band)); translate: -50% -50%; border-radius: 50%;
|
|
96
|
+
mask: radial-gradient(farthest-side, transparent calc(100% - var(--band) - .5px), #000 calc(100% - var(--band) + .5px)); }
|
|
97
|
+
.ring { background: conic-gradient(var(--tint) 0 270deg, transparent 0); }
|
|
98
|
+
.ring::after { content: ""; position: absolute; left: 0; top: 50%; width: var(--band); height: var(--band); translate: 0 -50%; border-radius: 50%; background: var(--tint); }
|
|
99
|
+
.arc { background: none; }
|
|
100
|
+
.arc i { position: absolute; inset: 0; --swept: calc(var(--rhp-hi) * 270deg); transition: --swept var(--ease);
|
|
101
|
+
background: conic-gradient(var(--c) 0 var(--swept), transparent 0); }
|
|
102
|
+
.start, .end i { width: var(--band); height: var(--band); border-radius: 50%; background: var(--c); }
|
|
103
|
+
.start { translate: -50% calc(-50% - var(--r)); }
|
|
104
|
+
.end { translate: none; }
|
|
105
|
+
.end i { position: absolute; left: calc(var(--band) / -2); top: calc(var(--band) / -2); transition: transform var(--ease);
|
|
106
|
+
transform: rotate(calc(var(--rhp-p) * 270deg)) translateY(calc(var(--r) * -1)); }
|
|
107
|
+
.name { translate: calc(-100% - var(--band) / 2 - 8px) calc(-50% - var(--r)); white-space: nowrap; font-size: clamp(11px, 2.9cqw, 14px); }
|
|
108
|
+
.name b { font-weight: 700; }
|
|
109
|
+
.name span { color: #4b4842; font-variant-numeric: tabular-nums; }
|
|
110
|
+
`,
|
|
111
|
+
}, (d) => html`
|
|
112
|
+
<div class="goal" style=${() => ({ "--c": d.color })}>
|
|
113
|
+
<i class="ring"></i>
|
|
114
|
+
<${Bar} to=${() => Math.min(100, d.pct)} class="arc"><i></i><//>
|
|
115
|
+
<i class="start"></i>
|
|
116
|
+
<${Place} at=${() => Math.min(100, d.pct)} class="end"><i></i><//>
|
|
117
|
+
<span class="name"><b>${() => d.goal}</b><span>${() => (d.pct >= 100 ? " ✓ " : " ") + percent.format(d.pct / 100)}</span></span>
|
|
118
|
+
</div>`);
|
|
119
|
+
|
|
120
|
+
// Chart component: the day switch picks a day, and the arcs sweep to it
|
|
121
|
+
function DailyGoals() {
|
|
122
|
+
const [day, setDay] = createSignal(DAYS.length - 1);
|
|
123
|
+
const met = () => GOALS.filter((g) => share(g, day()) >= 1).length;
|
|
124
|
+
return html`
|
|
125
|
+
<${Poster}
|
|
126
|
+
kicker="Daily goals · one week"
|
|
127
|
+
title="Sleep is the goal that slips: met one night in seven"
|
|
128
|
+
dek="Each ring is a daily goal, and a full ring means it was met. Pick a day to see how close each one came."
|
|
129
|
+
note="Illustrative data. Goals: move 600 kcal, exercise 30 minutes, stand 12 hours, water 8 glasses, sleep 8 hours.">
|
|
130
|
+
<div class="days" role="group" aria-label="Day of the week">
|
|
131
|
+
<${For} each=${DAYS}>${(name, i) => html`
|
|
132
|
+
<button type="button" aria-pressed=${() => i() === day()} onClick=${() => setDay(i())}>
|
|
133
|
+
${name}
|
|
134
|
+
<span aria-hidden="true">${GOALS.map((g) => html`<i class=${share(g, i()) >= 1 ? "met" : ""} style=${{ "--c": g.color }}></i>`)}</span>
|
|
135
|
+
</button>`}<//>
|
|
136
|
+
</div>
|
|
137
|
+
<${Chart} class="chart" orientation="vertical" aspect=${1} ticks=${false} label="Share of each daily goal reached"
|
|
138
|
+
theme=${{ font: "'Hanken Grotesk', 'Helvetica Neue', Arial, sans-serif", ink: "#1d1c1a", muted: "#6a665e", surface: "#f6f5f1" }}>
|
|
139
|
+
<div class="hub" aria-live="polite">
|
|
140
|
+
<b>${() => met()} of ${GOALS.length}</b>
|
|
141
|
+
<span>goals met</span>
|
|
142
|
+
</div>
|
|
143
|
+
<${Plot} rows=${GOALS} pct=${(d) => share(d, day()) * 100}>${Goal}<//>
|
|
144
|
+
<//>
|
|
145
|
+
<//>`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
render(() => html`<${DailyGoals} />`, document.getElementById("chart"));
|
|
149
|
+
</script>
|
|
150
|
+
</body>
|
|
151
|
+
</html>
|
|
@@ -0,0 +1,193 @@
|
|
|
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>Wealth and health</title>
|
|
7
|
+
<meta name="description" content="Scatter plot of two measures per item on a second axis, with a log scale, a few points labeled (short codes on phones) and a readout of the point nearest the pointer, a tap or the arrow keys. Use it to show how two measures relate across items.">
|
|
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=Bodoni+Moda:opsz,wght@6..96,500..800&family=Manrope: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: #d9d7d0; font-family: Manrope, "Helvetica Neue", Arial, sans-serif; }
|
|
15
|
+
/* The poster: banknote paper, with a quarter of an engraved seal in the corner: fine rays and rings in a band
|
|
16
|
+
(the top layer paints the paper back inside and outside the band) */
|
|
17
|
+
.poster { max-width: 900px; margin: 0 auto; padding: 46px 50px 26px; color: #191c1f; border-radius: 3px;
|
|
18
|
+
background: radial-gradient(circle at 100% 0%, #f2f0e9 0 64px, transparent 65px 150px, #f2f0e9 151px),
|
|
19
|
+
repeating-radial-gradient(circle at 100% 0%, transparent 0 9px, rgb(25 28 31 / .07) 9px 10px),
|
|
20
|
+
repeating-conic-gradient(from 0deg at 100% 0%, rgb(25 28 31 / .1) 0 .8deg, transparent .8deg 3deg), #f2f0e9;
|
|
21
|
+
box-shadow: 0 1px 2px rgb(25 28 31 / .15), 0 26px 50px -26px rgb(25 28 31 / .5); }
|
|
22
|
+
.poster figcaption { display: grid; gap: 12px; margin-bottom: 20px; }
|
|
23
|
+
.poster .kicker { font-size: 12px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; color: #2e6a50; }
|
|
24
|
+
.poster .headline { max-width: 14em; font: 700 50px/1.02 "Bodoni Moda", Didot, Georgia, serif; letter-spacing: -.01em; text-wrap: balance; }
|
|
25
|
+
.poster .dek { max-width: 58ch; font-size: 16px; line-height: 1.5; color: #4a4d52; }
|
|
26
|
+
.poster .note { display: block; margin-top: 16px; font-size: 12px; line-height: 1.5; color: #686b70; }
|
|
27
|
+
/* The readout: the country picked, on one line (two on a phone) whatever the country, so the chart never moves */
|
|
28
|
+
.readout { margin: 0 0 10px; padding: 9px 0; border-block: 1px solid #dcd8cc; font-size: 14px; color: #4a4d52; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
29
|
+
.readout b { color: #191c1f; }
|
|
30
|
+
.axis { margin: 0 0 8px; font-size: 12px; color: #686b70; }
|
|
31
|
+
.axis.x { margin: 8px 0 0; text-align: right; }
|
|
32
|
+
.plot { border-radius: 4px; }
|
|
33
|
+
.plot:has(:focus-visible) { outline: 2px solid #191c1f; outline-offset: 4px; } /* 6px out: clear of the axis names, 8px away */
|
|
34
|
+
@media (max-width: 600px) {
|
|
35
|
+
body { padding: 16px 16px 32px; }
|
|
36
|
+
.poster { padding: 26px 18px 18px; }
|
|
37
|
+
.poster { background: radial-gradient(circle at 100% 0%, #f2f0e9 0 40px, transparent 41px 92px, #f2f0e9 93px),
|
|
38
|
+
repeating-radial-gradient(circle at 100% 0%, transparent 0 7px, rgb(25 28 31 / .07) 7px 8px),
|
|
39
|
+
repeating-conic-gradient(from 0deg at 100% 0%, rgb(25 28 31 / .1) 0 .8deg, transparent .8deg 3deg), #f2f0e9; }
|
|
40
|
+
.poster .headline { font-size: 34px; }
|
|
41
|
+
.poster .dek { font-size: 15px; }
|
|
42
|
+
.readout { box-sizing: content-box; min-height: 2.8em; font-size: 13px; line-height: 1.4; white-space: normal; }
|
|
43
|
+
}
|
|
44
|
+
</style>
|
|
45
|
+
</head>
|
|
46
|
+
<body>
|
|
47
|
+
<div id="chart"></div>
|
|
48
|
+
<script type="module">
|
|
49
|
+
import { Chart, Plot, Scale, Dot, Tick, Label, Poster, slat, html, render, createSignal, Show } from "@bezda/rhp/standalone";
|
|
50
|
+
|
|
51
|
+
// Data: GDP per person (2021 international dollars, adjusted for prices; World Bank) and life expectancy at birth
|
|
52
|
+
// (UN World Population Prospects 2024), 2023, via Our World in Data. Replace with your own rows, sorted by x.
|
|
53
|
+
// `side` labels a point on the chart, before or after its dot.
|
|
54
|
+
const COUNTRIES = [
|
|
55
|
+
{ name: "DR Congo", code: "COD", gdp: 1559, life: 61.9 },
|
|
56
|
+
{ name: "Niger", code: "NER", gdp: 1713, life: 61.2 },
|
|
57
|
+
{ name: "Chad", code: "TCD", gdp: 2434, life: 55.1 },
|
|
58
|
+
{ name: "Ethiopia", code: "ETH", gdp: 2758, life: 67.3 },
|
|
59
|
+
{ name: "Pakistan", code: "PAK", gdp: 5419, life: 67.6 },
|
|
60
|
+
{ name: "Nigeria", code: "NGA", gdp: 7843, life: 54.5, side: "after" },
|
|
61
|
+
{ name: "Bangladesh", code: "BGD", gdp: 8242, life: 74.7 },
|
|
62
|
+
{ name: "India", code: "IND", gdp: 8871, life: 72.0 },
|
|
63
|
+
{ name: "Vietnam", code: "VNM", gdp: 13535, life: 74.6 },
|
|
64
|
+
{ name: "South Africa", code: "ZAF", gdp: 13695, life: 66.1 },
|
|
65
|
+
{ name: "Indonesia", code: "IDN", gdp: 13890, life: 71.1 },
|
|
66
|
+
{ name: "Brazil", code: "BRA", gdp: 19080, life: 75.8 },
|
|
67
|
+
{ name: "Mexico", code: "MEX", gdp: 21865, life: 75.1 },
|
|
68
|
+
{ name: "China", code: "CHN", gdp: 22687, life: 78.0 },
|
|
69
|
+
{ name: "Costa Rica", code: "CRI", gdp: 26379, life: 80.8, side: "before" },
|
|
70
|
+
{ name: "Russia", code: "RUS", gdp: 39881, life: 73.2 },
|
|
71
|
+
{ name: "Japan", code: "JPN", gdp: 47387, life: 84.7, side: "before" },
|
|
72
|
+
{ name: "Germany", code: "DEU", gdp: 63140, life: 81.4 },
|
|
73
|
+
{ name: "United States", code: "USA", gdp: 74352, life: 79.3, side: "before" },
|
|
74
|
+
{ name: "Norway", code: "NOR", gdp: 94365, life: 83.3, side: "before" },
|
|
75
|
+
];
|
|
76
|
+
const STORY = COUNTRIES.findIndex((c) => c.code === "USA"); // drawn in the accent color, and read out when nothing is picked
|
|
77
|
+
|
|
78
|
+
// The x axis is a log scale: the chart gets log10 of each value, and the ticks are turned back into dollars
|
|
79
|
+
const X = [3, 5]; // log10 of $1,000 and of $100,000
|
|
80
|
+
const Y = [50, 90];
|
|
81
|
+
const X_TICKS = [1000, 2000, 5000, 10000, 20000, 50000, 100000].map(Math.log10);
|
|
82
|
+
const dollars = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
|
|
83
|
+
const short = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: "compact" });
|
|
84
|
+
const years = new Intl.NumberFormat("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
|
85
|
+
|
|
86
|
+
// Slat types
|
|
87
|
+
// The scale under the plot: a short mark and the dollars at each tick (only $1K, $10K, $100K on a phone), the two
|
|
88
|
+
// ends kept inside the chart
|
|
89
|
+
const Money = slat({
|
|
90
|
+
room: { after: 26 },
|
|
91
|
+
css: `
|
|
92
|
+
.mark { top: 100%; height: 6px; translate: -50% 0; --rhp-tick-width: 1px; background: #a7a398; }
|
|
93
|
+
.amount { top: calc(100% + 10px); bottom: auto; translate: -50% 0; --rhp-label-gap: 0px; font-size: 11px; color: var(--rhp-muted); }
|
|
94
|
+
.first .amount { translate: 0 0; }
|
|
95
|
+
.last .amount { translate: -100% 0; }
|
|
96
|
+
@container (max-width: 520px) { .minor .amount { display: none; } }
|
|
97
|
+
`,
|
|
98
|
+
}, (t) => html`
|
|
99
|
+
<div class=${() => (t.first ? "first" : t.last ? "last" : Number.isInteger(t.at) ? "" : "minor")}>
|
|
100
|
+
<${Tick} at=${() => t.at} class="mark" />
|
|
101
|
+
<${Label} at=${() => t.at} class="amount">${() => short.format(10 ** t.at)}<//>
|
|
102
|
+
</div>`);
|
|
103
|
+
|
|
104
|
+
// A country: its dot, its name when the data labels it (its code on a phone), and a sentence for screen readers.
|
|
105
|
+
// The slats let the pointer through: the chart finds the nearest dot itself.
|
|
106
|
+
const Country = slat({
|
|
107
|
+
css: `
|
|
108
|
+
.country { pointer-events: none; outline: none; }
|
|
109
|
+
.dot { background: #2e6a50; box-shadow: 0 0 0 1.5px #f2f0e9; }
|
|
110
|
+
.story .dot { background: #c2362b; }
|
|
111
|
+
.on .dot { z-index: 2; box-shadow: 0 0 0 2px #f2f0e9, 0 0 0 4px #191c1f; }
|
|
112
|
+
.name { --rhp-label-gap: 10px; font-size: 12px; font-weight: 700; }
|
|
113
|
+
.code { display: none; }
|
|
114
|
+
@container (max-width: 520px) { .full { display: none; } .code { display: inline; } }
|
|
115
|
+
.say { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
116
|
+
`,
|
|
117
|
+
}, (d) => html`
|
|
118
|
+
<div class=${() => ["country", d.index === STORY && "story", d.on && "on"].filter(Boolean).join(" ")} data-i=${() => d.index}>
|
|
119
|
+
<${Dot} at=${() => Math.log10(d.gdp)} cross=${() => d.life} size="11px" class="dot" />
|
|
120
|
+
<${Show} when=${() => d.side}>
|
|
121
|
+
<${Label} at=${() => Math.log10(d.gdp)} cross=${() => d.life} side=${() => d.side} class="name">
|
|
122
|
+
<span class="full">${() => d.name}</span><span class="code">${() => d.code}</span>
|
|
123
|
+
<//>
|
|
124
|
+
<//>
|
|
125
|
+
<span class="say">${() => `${d.name}: ${dollars.format(d.gdp)} a person, ${years.format(d.life)} years`}</span>
|
|
126
|
+
</div>`);
|
|
127
|
+
|
|
128
|
+
// Chart component: the readout and the ring show the country nearest the pointer (within 40px), a tap or the arrow
|
|
129
|
+
// keys, and the United States otherwise
|
|
130
|
+
function WealthChart() {
|
|
131
|
+
const [picked, setPicked] = createSignal(null);
|
|
132
|
+
const shown = () => COUNTRIES[picked() ?? STORY];
|
|
133
|
+
let plot;
|
|
134
|
+
const nearest = (e) => {
|
|
135
|
+
const r = plot.getBoundingClientRect();
|
|
136
|
+
let best = null;
|
|
137
|
+
let reach = 40 ** 2;
|
|
138
|
+
COUNTRIES.forEach((c, i) => {
|
|
139
|
+
const dx = ((Math.log10(c.gdp) - X[0]) / (X[1] - X[0])) * r.width - (e.clientX - r.left);
|
|
140
|
+
const dy = (1 - (c.life - Y[0]) / (Y[1] - Y[0])) * r.height - (e.clientY - r.top);
|
|
141
|
+
if (dx * dx + dy * dy < reach) {
|
|
142
|
+
reach = dx * dx + dy * dy;
|
|
143
|
+
best = i;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
return best;
|
|
147
|
+
};
|
|
148
|
+
// The country of the dot an element is in (the dot the keyboard focuses), or null
|
|
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 the nearest dot as it moves, a tap or a click picks it; with no dot in reach the pick stays
|
|
154
|
+
const point = (e) => {
|
|
155
|
+
const i = nearest(e);
|
|
156
|
+
if (i != null) setPicked(i);
|
|
157
|
+
};
|
|
158
|
+
// The keyboard picks the dot it focuses
|
|
159
|
+
const focus = (e) => {
|
|
160
|
+
const i = rowOf(e.target);
|
|
161
|
+
if (i != null) setPicked(i);
|
|
162
|
+
};
|
|
163
|
+
// Leaving the chart: back to the dot that has focus (the keyboard's pick), or none
|
|
164
|
+
const leave = (e) => {
|
|
165
|
+
if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
|
|
166
|
+
setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
|
|
167
|
+
};
|
|
168
|
+
return html`
|
|
169
|
+
<${Poster}
|
|
170
|
+
kicker="Wealth and health of nations"
|
|
171
|
+
title="Life expectancy climbs fast with income, then levels off"
|
|
172
|
+
dek="Twenty countries in 2023. Above about $25,000 a person, higher income goes with little extra life: the United States, richer than all but Norway here, has a lower life expectancy than Costa Rica, with about a third of its income. Tap or point at a dot."
|
|
173
|
+
note="Sources: World Bank (GDP per person, 2021 international dollars) and UN World Population Prospects 2024 (life expectancy), via Our World in Data.">
|
|
174
|
+
<p class="readout"><b>${() => shown().name}</b><span>${() => `: ${dollars.format(shown().gdp)} a person, ${years.format(shown().life)} years`}</span></p>
|
|
175
|
+
<p class="axis">Life expectancy at birth, years</p>
|
|
176
|
+
<div class="plot"
|
|
177
|
+
onPointerMove=${(e) => e.pointerType !== "touch" && point(e)} onClick=${point} onFocusIn=${focus}
|
|
178
|
+
onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
|
|
179
|
+
<${Chart} scale=${X} cross=${Y} crossTicks=${[50, 60, 70, 80, 90]} height=${340}
|
|
180
|
+
label="Life expectancy at birth against GDP per person, twenty countries, 2023"
|
|
181
|
+
theme=${{ font: 'Manrope, "Helvetica Neue", Arial, sans-serif', ink: "#191c1f", muted: "#686b70", grid: "#dfdbd0" }}>
|
|
182
|
+
<${Scale} ticks=${X_TICKS}>${Money}<//>
|
|
183
|
+
<${Plot} overlap=${true} keyboard=${true} rows=${COUNTRIES} on=${(d) => d.index === picked()} ref=${(el) => (plot = el)}>${Country}<//>
|
|
184
|
+
<//>
|
|
185
|
+
</div>
|
|
186
|
+
<p class="axis x">GDP per person, log scale</p>
|
|
187
|
+
<//>`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
render(() => html`<${WealthChart} />`, document.getElementById("chart"));
|
|
191
|
+
</script>
|
|
192
|
+
</body>
|
|
193
|
+
</html>
|