@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,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>Eggs doubled</title>
|
|
7
|
+
<meta name="description" content="Slope chart: each item's value at two dates joined by a line on a second axis, labels kept apart on both sides, the change written beside each item, and the line nearest the pointer, a tap or the arrow keys picked out. Use it to compare change between two dates 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=Azeret+Mono: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: 48px 16px; background: #bfbcb5; font-family: "Azeret Mono", ui-monospace, Menlo, monospace; }
|
|
15
|
+
/* The poster: a till receipt, torn at the top and the bottom, its shadow following the tear */
|
|
16
|
+
#chart { filter: drop-shadow(0 18px 22px rgb(40 38 34 / .35)); }
|
|
17
|
+
.poster { max-width: 640px; margin: 0 auto; padding: 44px 40px 40px; color: #1d1d1b; background: #fdfdfb;
|
|
18
|
+
mask: conic-gradient(from 135deg at top, #0000, #000 1deg 89deg, #0000 90deg) top / 14px 51% repeat-x,
|
|
19
|
+
conic-gradient(from -45deg at bottom, #0000, #000 1deg 89deg, #0000 90deg) bottom / 14px 51% repeat-x; }
|
|
20
|
+
.poster figcaption { display: grid; gap: 12px; margin-bottom: 22px; padding-bottom: 20px; border-bottom: 1.5px dashed #1d1d1b; text-align: center; }
|
|
21
|
+
.poster .kicker { font-size: 12px; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; }
|
|
22
|
+
.poster .headline { font-size: 34px; font-weight: 800; line-height: 1.05; letter-spacing: -.03em; text-transform: uppercase; text-wrap: balance; }
|
|
23
|
+
.poster .dek { font-size: 13px; line-height: 1.55; color: #55534e; text-wrap: pretty; }
|
|
24
|
+
.poster .note { display: block; margin-top: 22px; padding-top: 16px; border-top: 1.5px dashed #1d1d1b; font-size: 11px; line-height: 1.55; color: #55534e; }
|
|
25
|
+
/* The ornament: a barcode at the foot of the receipt */
|
|
26
|
+
.poster::after { content: ""; display: block; width: min(260px, 70%); height: 46px; margin: 18px auto 0;
|
|
27
|
+
background: repeating-linear-gradient(90deg, #1d1d1b 0 2px, transparent 2px 5px, #1d1d1b 5px 6px, transparent 6px 9px, #1d1d1b 9px 12px, transparent 12px 13px),
|
|
28
|
+
repeating-linear-gradient(90deg, transparent 0 17px, #fdfdfb 17px 20px); }
|
|
29
|
+
.plot { border-radius: 4px; }
|
|
30
|
+
.plot:has(:focus-visible) { outline: 2px solid #1d1d1b; outline-offset: 6px; }
|
|
31
|
+
@media (max-width: 600px) {
|
|
32
|
+
body { padding: 16px 12px 32px; }
|
|
33
|
+
.poster { padding: 36px 16px 34px; }
|
|
34
|
+
.poster .headline { font-size: 25px; }
|
|
35
|
+
}
|
|
36
|
+
</style>
|
|
37
|
+
</head>
|
|
38
|
+
<body>
|
|
39
|
+
<div id="chart"></div>
|
|
40
|
+
<script type="module">
|
|
41
|
+
import { Chart, Plot, Scale, Line, Dot, Tick, Label, Poster, slat, html, render, createSignal } from "@bezda/rhp/standalone";
|
|
42
|
+
|
|
43
|
+
// Data: US city average prices, the mean of each year's twelve monthly prices, Bureau of Labor Statistics. Replace with
|
|
44
|
+
// your own items and two values each.
|
|
45
|
+
const ITEMS = [
|
|
46
|
+
{ name: "Coffee, lb", before: 4.433, after: 6.322 },
|
|
47
|
+
{ name: "Ground beef, lb", before: 4.115, after: 5.393 },
|
|
48
|
+
{ name: "Milk, gallon", before: 3.318, after: 3.984 },
|
|
49
|
+
{ name: "Eggs, dozen", before: 1.506, after: 3.172 },
|
|
50
|
+
{ name: "Bread, lb", before: 1.452, after: 1.970 },
|
|
51
|
+
{ name: "Bananas, lb", before: 0.578, after: 0.620 },
|
|
52
|
+
];
|
|
53
|
+
const YEARS = [2020, 2024];
|
|
54
|
+
const STORY = ITEMS.findIndex((d) => d.name.startsWith("Eggs")); // drawn in red until another item is picked
|
|
55
|
+
|
|
56
|
+
const TOP = 7; // the scale runs from $0 to $7
|
|
57
|
+
const HEIGHT = 380;
|
|
58
|
+
const price = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
|
|
59
|
+
const change = new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 0, signDisplay: "always" });
|
|
60
|
+
|
|
61
|
+
// Places for a column of labels: in order, at least `px` apart, as near their values as that allows
|
|
62
|
+
function dodge(values, px) {
|
|
63
|
+
const gap = (px / HEIGHT) * TOP;
|
|
64
|
+
const order = values.map((_, i) => i).sort((a, b) => values[a] - values[b]);
|
|
65
|
+
const out = values.slice();
|
|
66
|
+
for (let pass = 0; pass < 50; pass++) {
|
|
67
|
+
let moved = false;
|
|
68
|
+
for (let k = 1; k < order.length; k++) {
|
|
69
|
+
const lo = order[k - 1], hi = order[k];
|
|
70
|
+
const push = gap - (out[hi] - out[lo]);
|
|
71
|
+
if (push > 1e-6) {
|
|
72
|
+
out[lo] -= push / 2;
|
|
73
|
+
out[hi] += push / 2;
|
|
74
|
+
moved = true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!moved) break;
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
const LEFT = dodge(ITEMS.map((d) => d.before), 18);
|
|
82
|
+
const RIGHT = dodge(ITEMS.map((d) => d.after), 36);
|
|
83
|
+
|
|
84
|
+
// Slat types
|
|
85
|
+
// The two dates: a rule down the plot and the year above it (the scale runs from 0, the first date, to 1, the second)
|
|
86
|
+
const Year = slat({
|
|
87
|
+
room: { before: 30 },
|
|
88
|
+
css: `
|
|
89
|
+
.rule { --rhp-tick-width: 1px; background: #c9c6be; }
|
|
90
|
+
.year { top: auto; bottom: calc(100% + 10px); translate: -50% 0; --rhp-label-gap: 0px; font-size: 13px; font-weight: 700; letter-spacing: .1em; }
|
|
91
|
+
`,
|
|
92
|
+
}, (t) => html`
|
|
93
|
+
<div>
|
|
94
|
+
<${Tick} at=${() => t.at} thick=${1} class="rule" />
|
|
95
|
+
<${Label} at=${() => t.at} class="year">${() => YEARS[t.at]}<//>
|
|
96
|
+
</div>`);
|
|
97
|
+
|
|
98
|
+
// An item: its line from the first price to the second, a dot at each end, the first price on the left, and its
|
|
99
|
+
// name, second price and change on the right (each label at its dodged place)
|
|
100
|
+
const Item = slat({
|
|
101
|
+
room: { start: 52, end: 128 },
|
|
102
|
+
css: `
|
|
103
|
+
.item { pointer-events: none; outline: none; transition: opacity .2s; }
|
|
104
|
+
.dim { opacity: .2; }
|
|
105
|
+
.slope { stroke: #8f8b83; stroke-width: 2px; }
|
|
106
|
+
.end { background: #8f8b83; }
|
|
107
|
+
.story .slope, .on .slope { stroke-width: 3px; }
|
|
108
|
+
.story .slope { stroke: #d62828; }
|
|
109
|
+
.story .end { background: #d62828; }
|
|
110
|
+
.on .slope { stroke: #1d1d1b; }
|
|
111
|
+
.on .end { background: #1d1d1b; }
|
|
112
|
+
.first { --rhp-label-gap: 10px; font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
113
|
+
.second { --rhp-label-gap: 10px; font-size: 12px; line-height: 1.3; }
|
|
114
|
+
.second small { display: block; font-size: 11px; color: #55534e; }
|
|
115
|
+
.second b { font-size: 13px; }
|
|
116
|
+
.second em { margin-left: 6px; font-style: normal; font-weight: 700; color: #b21f1f; }
|
|
117
|
+
.say { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
118
|
+
`,
|
|
119
|
+
}, (d) => html`
|
|
120
|
+
<div class=${() => ["item", d.index === STORY && !d.someone && "story", d.on && "on", d.someone && !d.on && "dim"].filter(Boolean).join(" ")} data-i=${() => d.index}>
|
|
121
|
+
<${Line} points=${() => [[0, d.before], [1, d.after]]} class="slope" />
|
|
122
|
+
<${Dot} at=${0} cross=${() => d.before} size="8px" class="end" />
|
|
123
|
+
<${Dot} at=${1} cross=${() => d.after} size="8px" class="end" />
|
|
124
|
+
<${Label} at=${0} cross=${() => d.left} side="before" class="first">${() => price.format(d.before)}<//>
|
|
125
|
+
<${Label} at=${1} cross=${() => d.right} class="second">
|
|
126
|
+
<small>${() => d.name}</small><b>${() => price.format(d.after)}</b><em>${() => change.format(d.after / d.before - 1)}</em>
|
|
127
|
+
<//>
|
|
128
|
+
<span class="say">${() => `${d.name}: ${price.format(d.before)} in ${YEARS[0]}, ${price.format(d.after)} in ${YEARS[1]}, ${change.format(d.after / d.before - 1)}`}</span>
|
|
129
|
+
</div>`);
|
|
130
|
+
|
|
131
|
+
// Chart component: the item nearest the pointer (its line, or its labels beside the plot), a tap or the arrow keys is
|
|
132
|
+
// picked out and the others fade
|
|
133
|
+
function GroceryChart() {
|
|
134
|
+
const [picked, setPicked] = createSignal(null);
|
|
135
|
+
let plot;
|
|
136
|
+
const nearest = (e) => {
|
|
137
|
+
const r = plot.getBoundingClientRect();
|
|
138
|
+
const t = (e.clientX - r.left) / r.width; // 0 at the first date, 1 at the second
|
|
139
|
+
const y = (1 - (e.clientY - r.top) / r.height) * TOP; // the price under the pointer
|
|
140
|
+
let best = null;
|
|
141
|
+
let reach = (16 / HEIGHT) * TOP;
|
|
142
|
+
ITEMS.forEach((d, i) => {
|
|
143
|
+
const at = t < 0 ? LEFT[i] : t > 1 ? RIGHT[i] : d.before + (d.after - d.before) * t;
|
|
144
|
+
if (Math.abs(at - y) < reach) {
|
|
145
|
+
reach = Math.abs(at - y);
|
|
146
|
+
best = i;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
return best;
|
|
150
|
+
};
|
|
151
|
+
// The item an element is in (the item the keyboard focuses), or null
|
|
152
|
+
const rowOf = (el) => {
|
|
153
|
+
const root = el.closest("[data-i]");
|
|
154
|
+
return root ? +root.dataset.i : null;
|
|
155
|
+
};
|
|
156
|
+
// A mouse or a pen picks the nearest line as it moves, a tap or a click picks it; with no line in reach the pick stays
|
|
157
|
+
const point = (e) => {
|
|
158
|
+
const i = nearest(e);
|
|
159
|
+
if (i != null) setPicked(i);
|
|
160
|
+
};
|
|
161
|
+
// The keyboard picks the item it focuses
|
|
162
|
+
const focus = (e) => {
|
|
163
|
+
const i = rowOf(e.target);
|
|
164
|
+
if (i != null) setPicked(i);
|
|
165
|
+
};
|
|
166
|
+
// Leaving the chart: back to the item that has focus (the keyboard's pick), or none
|
|
167
|
+
const leave = (e) => {
|
|
168
|
+
if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
|
|
169
|
+
setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
|
|
170
|
+
};
|
|
171
|
+
return html`
|
|
172
|
+
<${Poster}
|
|
173
|
+
kicker="Price check, US city average"
|
|
174
|
+
title="A dozen eggs cost twice as much in 2024 as in 2020"
|
|
175
|
+
dek="Six staples, each at its average price in 2020 and in 2024. Coffee went up 43%, bread 36%, beef 31% and milk 20%; bananas went up 4 cents."
|
|
176
|
+
note="Source: US Bureau of Labor Statistics, average price data for 2020 and 2024 (the mean of each year's twelve monthly prices). Point at a line, tap it, or Tab into the chart and use the arrow keys.">
|
|
177
|
+
<div class="plot" 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=${[0, 1]} cross=${[0, TOP]} crossTicks=${false} height=${HEIGHT}
|
|
180
|
+
label=${`Average US prices of six groceries in ${YEARS[0]} and ${YEARS[1]}`}
|
|
181
|
+
theme=${{ font: '"Azeret Mono", ui-monospace, Menlo, monospace', ink: "#1d1d1b", muted: "#55534e" }}>
|
|
182
|
+
<${Scale} ticks=${[0, 1]}>${Year}<//>
|
|
183
|
+
<${Plot} overlap=${true} keyboard=${true} rows=${ITEMS} left=${LEFT} right=${RIGHT} ref=${(el) => (plot = el)}
|
|
184
|
+
on=${(d) => d.index === picked()} someone=${() => picked() != null}>${Item}<//>
|
|
185
|
+
<//>
|
|
186
|
+
</div>
|
|
187
|
+
<//>`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
render(() => html`<${GroceryChart} />`, document.getElementById("chart"));
|
|
191
|
+
</script>
|
|
192
|
+
</body>
|
|
193
|
+
</html>
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Richer since 1990</title>
|
|
7
|
+
<meta name="description" content="Sparkline table: one small line per item, each scaled to its own peak, with the start level dashed, and a scrub through the years (pointer, tap, a sideways drag or arrow keys) that moves a dot along every line and updates each item's value and its change since the start. Use it for many series side by side, a table of trends.">
|
|
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=Libre+Caslon+Text:wght@400;700&family=Libre+Franklin: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: 48px 16px; background: #d9d5cc; font-family: "Libre Franklin", "Helvetica Neue", Arial, sans-serif; }
|
|
15
|
+
/* The poster: a table page from a statistical yearbook, cream paper between a thick and a thin rule */
|
|
16
|
+
.poster { max-width: 900px; margin: 0 auto; padding: 40px 52px 28px; color: #1f1c17; border-top: 6px solid #1f1c17;
|
|
17
|
+
background: linear-gradient(#1f1c17 0 0) 52px 10px / calc(100% - 104px) 1px no-repeat, #f7f3ea;
|
|
18
|
+
box-shadow: 0 1px 2px rgb(50 44 34 / .18), 0 26px 50px -28px rgb(50 44 34 / .55); }
|
|
19
|
+
.poster figcaption { display: grid; gap: 12px; margin-bottom: 22px; }
|
|
20
|
+
.poster .kicker { font-size: 12px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; color: #a3360d; }
|
|
21
|
+
.poster .headline { max-width: 15em; font: 700 48px/1.05 "Libre Caslon Text", Georgia, serif; letter-spacing: -.015em; text-wrap: balance; }
|
|
22
|
+
.poster .dek { max-width: 58ch; font-size: 16px; line-height: 1.55; color: #4c463d; }
|
|
23
|
+
.poster .dek i { display: inline-block; width: 18px; margin: 0 3px; border-top: 1.5px dashed #6b645a; vertical-align: 4px; }
|
|
24
|
+
.poster .note { display: block; margin-top: 18px; padding-top: 10px; border-top: 1px solid #d9d2c4; font-size: 12px; line-height: 1.5; color: #6b645a; }
|
|
25
|
+
/* The table's head: the year being read, and what the two number columns hold */
|
|
26
|
+
.head { display: flex; align-items: baseline; justify-content: space-between; gap: 6px 16px; flex-wrap: wrap; margin: 0 0 6px; padding: 8px 0;
|
|
27
|
+
border-block: 1px solid #1f1c17; font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: #6b645a; }
|
|
28
|
+
.head b { font: 700 26px/1 "Libre Caslon Text", Georgia, serif; letter-spacing: 0; color: #1f1c17; font-variant-numeric: tabular-nums; }
|
|
29
|
+
.head b:focus-visible { outline: 2px solid #c2410c; outline-offset: 4px; border-radius: 2px; }
|
|
30
|
+
.plot { touch-action: pan-y; } /* a finger dragging sideways moves through the years; up and down still scrolls */
|
|
31
|
+
/* On a phone the table is taller than half the screen: its head, with the year, stays at the top of the screen while
|
|
32
|
+
the lines scroll under it */
|
|
33
|
+
@media (max-width: 600px) {
|
|
34
|
+
body { padding: 16px 16px 32px; }
|
|
35
|
+
.poster { padding: 30px 16px 18px; background-position: 16px 8px, 0 0; background-size: calc(100% - 32px) 1px, auto; }
|
|
36
|
+
.poster .headline { font-size: 32px; }
|
|
37
|
+
.poster .dek { font-size: 15px; }
|
|
38
|
+
.head { position: sticky; top: 0; z-index: 2; background: #f7f3ea; font-size: 11px; }
|
|
39
|
+
.head b { font-size: 22px; }
|
|
40
|
+
}
|
|
41
|
+
</style>
|
|
42
|
+
</head>
|
|
43
|
+
<body>
|
|
44
|
+
<div id="chart"></div>
|
|
45
|
+
<script type="module">
|
|
46
|
+
import { Chart, Plot, Line, Dot, Tick, Label, Poster, slat, sortBy, html, render, createSignal } from "@bezda/rhp/standalone";
|
|
47
|
+
|
|
48
|
+
// Data: GDP per person from 1990 to 2024, in constant 2021 international dollars (adjusted for prices and for what
|
|
49
|
+
// money buys in each country), World Bank via Our World in Data. Replace with your own rows: one value per year each.
|
|
50
|
+
const FIRST = 1990;
|
|
51
|
+
const COUNTRIES = [
|
|
52
|
+
{ name: "China", gdp: [1667, 1798, 2030, 2287, 2557, 2808, 3056, 3306, 3534, 3774, 4066, 4372, 4744, 5192, 5684, 6298, 7057, 8014, 8744, 9519, 10476, 11405, 12218, 13081, 13969, 14857, 15773, 16758, 17807, 18821, 19215, 20843, 21499, 22687, 23841] },
|
|
53
|
+
{ name: "Vietnam", gdp: [2468, 2561, 2726, 2890, 3090, 3328, 3584, 3820, 3983, 4119, 4349, 4570, 4810, 5090, 5421, 5776, 6098, 6419, 6659, 6914, 7275, 7653, 7980, 8324, 8752, 9248, 9743, 10291, 10937, 11629, 11851, 12049, 12980, 13535, 14396] },
|
|
54
|
+
{ name: "India", gdp: [2140, 2116, 2185, 2241, 2342, 2469, 2603, 2656, 2766, 2954, 3010, 3097, 3157, 3347, 3551, 3770, 4011, 4254, 4322, 4595, 4914, 5099, 5304, 5567, 5905, 6302, 6741, 7116, 7493, 7702, 7187, 7819, 8347, 8871, 9416] },
|
|
55
|
+
{ name: "South Korea", gdp: [14378, 15784, 16613, 17596, 19052, 20698, 22140, 23311, 22000, 24382, 26404, 27441, 29395, 30142, 31580, 32886, 34430, 36257, 37064, 37176, 39574, 40719, 41538, 42710, 43807, 44849, 46088, 47537, 48834, 49791, 49374, 51718, 53229, 54029, 55071] },
|
|
56
|
+
{ name: "Poland", gdp: [12821, 11880, 12141, 12563, 13200, 14228, 15083, 16032, 16767, 17556, 18566, 18801, 19167, 19856, 20879, 21569, 22921, 24484, 25554, 26204, 27112, 28522, 28953, 29169, 30335, 31700, 32675, 34355, 36500, 38182, 37853, 41060, 43405, 43673, 45153] },
|
|
57
|
+
{ name: "Ethiopia", gdp: [874, 778, 681, 744, 741, 760, 827, 826, 773, 788, 810, 851, 838, 796, 877, 952, 1025, 1109, 1194, 1262, 1381, 1493, 1577, 1697, 1821, 1957, 2085, 2223, 2311, 2438, 2517, 2588, 2656, 2758, 2892] },
|
|
58
|
+
{ name: "United States", gdp: [44384, 43748, 44665, 45293, 46543, 47227, 48442, 49991, 51626, 53481, 55046, 55024, 55443, 56505, 58139, 59612, 60684, 61314, 60806, 58722, 59797, 60253, 61142, 61967, 63027, 64368, 65026, 66164, 67728, 69121, 67407, 71441, 72829, 74352, 75698] },
|
|
59
|
+
{ name: "Japan", gdp: [36138, 37226, 37403, 37089, 37353, 38138, 39222, 39662, 38853, 38647, 39732, 39749, 39690, 40152, 40987, 41740, 42359, 43042, 42519, 40000, 41647, 41641, 42395, 43304, 43754, 44589, 44927, 45693, 46133, 46055, 44213, 45999, 46819, 47387, 47480] },
|
|
60
|
+
{ name: "Italy", gdp: [42657, 43283, 43614, 43216, 44137, 45410, 46008, 46869, 47701, 48494, 50353, 51332, 51369, 51126, 51495, 51586, 52304, 52747, 51806, 48774, 49302, 49473, 47796, 46833, 46826, 47312, 47988, 48849, 49356, 49691, 45505, 49825, 52333, 52843, 53285] },
|
|
61
|
+
{ name: "Ukraine", gdp: [22816, 20784, 18672, 16012, 12411, 10983, 9970, 9754, 9646, 9704, 10364, 11379, 12093, 13343, 15021, 15611, 16902, 18374, 18884, 16093, 16812, 17784, 17849, 17889, 16979, 15404, 15857, 16310, 16979, 17637, 17093, 17846, 13787, 15917, 16371] },
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const YEARS = COUNTRIES[0].gdp.map((_, i) => FIRST + i);
|
|
65
|
+
const LAST = YEARS.at(-1);
|
|
66
|
+
const INSET = 0.2; // the empty share of each slat's band, half above and half below its line
|
|
67
|
+
const dollars = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
|
|
68
|
+
const times = new Intl.NumberFormat("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
|
69
|
+
// Where a value sits across its slat: a Line in a slat draws 0 at the bottom of its band and the peak at the top
|
|
70
|
+
const across = (value, peak) => INSET / 2 + (1 - INSET) * (1 - value / peak);
|
|
71
|
+
|
|
72
|
+
// Slat types
|
|
73
|
+
// A country: its name, its line (scaled to its own peak, over a hairline at zero), its 1990 level dashed, a dot at the
|
|
74
|
+
// year being read, and that year's value with how many times its 1990 value it is
|
|
75
|
+
const Country = slat({
|
|
76
|
+
thickness: 44,
|
|
77
|
+
inset: INSET,
|
|
78
|
+
room: "auto",
|
|
79
|
+
css: `
|
|
80
|
+
.country { background: linear-gradient(var(--rhp-grid) 0 0) 0 ${(1 - INSET / 2) * 100}% / 100% 1px no-repeat; }
|
|
81
|
+
.name { font-size: 14px; font-weight: 700; --rhp-label-gap: 14px; }
|
|
82
|
+
.spark { stroke-width: 1.75px; }
|
|
83
|
+
.start path { stroke-dasharray: 3 3; }
|
|
84
|
+
.now { background: #c2410c; box-shadow: 0 0 0 2px #f7f3ea; }
|
|
85
|
+
.numbers { display: flex; justify-content: flex-end; align-items: baseline; gap: 10px; justify-self: stretch; font-variant-numeric: tabular-nums; --rhp-label-gap: 14px; }
|
|
86
|
+
.numbers b { font-size: 14px; font-weight: 700; }
|
|
87
|
+
.numbers span { min-width: 3.2em; font-size: 13px; font-weight: 600; color: var(--rhp-muted); text-align: right; }
|
|
88
|
+
.poorer .numbers span { color: #b42318; }
|
|
89
|
+
@container (max-width: 520px) {
|
|
90
|
+
.name { font-size: 13px; --rhp-label-gap: 10px; }
|
|
91
|
+
.numbers { flex-direction: column; align-items: flex-end; gap: 1px; line-height: 1.15; --rhp-label-gap: 10px; }
|
|
92
|
+
.numbers b { font-size: 13px; }
|
|
93
|
+
.numbers span { font-size: 12px; }
|
|
94
|
+
}
|
|
95
|
+
`,
|
|
96
|
+
}, (d) => html`
|
|
97
|
+
<div class=${() => (d.gdp[d.i] < d.gdp[0] ? "country poorer" : "country")}
|
|
98
|
+
aria-label=${() => `${d.name}: ${dollars.format(d.gdp[d.i])} a person in ${d.year}, ${times.format(d.gdp[d.i] / d.gdp[0])} times its ${FIRST} level`}>
|
|
99
|
+
<${Label} edge="start" class="name">${() => d.name}<//>
|
|
100
|
+
<${Line} points=${() => d.gdp.map((v, i) => [FIRST + i, v])} peak=${() => d.peak} color="ink" class="spark" />
|
|
101
|
+
<${Line} points=${() => [[FIRST, d.gdp[0]], [LAST, d.gdp[0]]]} peak=${() => d.peak} color="muted" class="start" />
|
|
102
|
+
<${Dot} at=${() => d.year} across=${() => across(d.gdp[d.i], d.peak)} size="7px" class="now" />
|
|
103
|
+
<${Label} edge="end" class="numbers"><b>${() => dollars.format(d.gdp[d.i])}</b><span>${() => times.format(d.gdp[d.i] / d.gdp[0]) + "×"}</span><//>
|
|
104
|
+
</div>`);
|
|
105
|
+
|
|
106
|
+
// The year being read: a hairline down every slat
|
|
107
|
+
const Crosshair = slat({ room: {}, css: `.hair { --rhp-tick-width: 1px; background: #c2410c; opacity: .55; }` }, (d) => html`
|
|
108
|
+
<div><${Tick} at=${() => d.year} thick=${1} class="hair" /></div>`);
|
|
109
|
+
|
|
110
|
+
// Chart component: hovering previews a year; a click, tap, sideways drag or the arrow keys saves it for every slat.
|
|
111
|
+
// With no saved year, the latest shows. The year in the table's head is a slider for the keyboard and screen readers.
|
|
112
|
+
function RicherSince1990() {
|
|
113
|
+
const [picked, setPicked] = createSignal(null);
|
|
114
|
+
const [hovered, setHovered] = createSignal(null);
|
|
115
|
+
const i = () => hovered() ?? picked() ?? YEARS.length - 1;
|
|
116
|
+
// The year under the pointer, measured on a line (every line runs from the first year to the last), or null beside it
|
|
117
|
+
const point = (e) => {
|
|
118
|
+
const r = e.currentTarget.querySelector(".spark").getBoundingClientRect();
|
|
119
|
+
const k = Math.round(((e.clientX - r.left) / r.width) * (YEARS.length - 1));
|
|
120
|
+
return k >= 0 && k < YEARS.length ? k : null;
|
|
121
|
+
};
|
|
122
|
+
const pick = (e) => {
|
|
123
|
+
const k = point(e);
|
|
124
|
+
if (k == null) return;
|
|
125
|
+
setPicked(k);
|
|
126
|
+
setHovered(null);
|
|
127
|
+
};
|
|
128
|
+
const STEPS = { ArrowLeft: -1, ArrowDown: -1, ArrowRight: 1, ArrowUp: 1, PageDown: -10, PageUp: 10, Home: -Infinity, End: Infinity };
|
|
129
|
+
const key = (e) => {
|
|
130
|
+
if (!(e.key in STEPS)) return;
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
setPicked(Math.max(0, Math.min(YEARS.length - 1, i() + STEPS[e.key])));
|
|
133
|
+
setHovered(null);
|
|
134
|
+
};
|
|
135
|
+
let start = [0, 0]; // where the last finger went down
|
|
136
|
+
// A finger that moves more sideways than up or down scrubs; one that moves up or down scrolls the page
|
|
137
|
+
const sideways = (e) => Math.abs(e.clientX - start[0]) > Math.abs(e.clientY - start[1]);
|
|
138
|
+
// Leaving the lines ends the preview and restores the saved year; a finger lifting after a tap keeps its selection
|
|
139
|
+
const leave = (e) => e.pointerType !== "touch" && setHovered(null);
|
|
140
|
+
const said = () => `${YEARS[i()]}: ` + COUNTRIES.map((c) => `${c.name} ${dollars.format(c.gdp[i()])}`).join(", ");
|
|
141
|
+
return html`
|
|
142
|
+
<${Poster}
|
|
143
|
+
kicker=${`Income per person, ${FIRST} to ${LAST}`}
|
|
144
|
+
title="China's income per person is 14 times what it was in 1990"
|
|
145
|
+
dek=${html`<span>GDP per person in ten countries, adjusted for prices. Each line rises from zero, the hairline under it, to its own peak, and its dashes <i></i> mark its ${FIRST} level: Ukraine is still below it, and Japan and Italy barely rose above it.</span>`}
|
|
146
|
+
note="Source: World Bank, GDP per person in constant 2021 international dollars (purchasing power parity), via Our World in Data, retrieved 2026. Point at a year to preview it, click or tap to keep it, drag across the lines, or Tab to the year and use the arrow keys.">
|
|
147
|
+
<p class="head">
|
|
148
|
+
<b tabindex="0" role="slider" aria-label="Year read on every line" aria-valuemin=${FIRST} aria-valuemax=${LAST}
|
|
149
|
+
aria-valuenow=${() => YEARS[i()]} aria-valuetext=${said} onKeyDown=${key}>${() => YEARS[i()]}</b>
|
|
150
|
+
<span aria-hidden="true">GDP per person · × its ${FIRST} level</span>
|
|
151
|
+
</p>
|
|
152
|
+
<div class="plot" onPointerDown=${(e) => (start = [e.clientX, e.clientY])}
|
|
153
|
+
onPointerMove=${(e) => e.pointerType === "touch" ? sideways(e) && pick(e) : setHovered(point(e))} onClick=${pick} onPointerLeave=${leave}>
|
|
154
|
+
<!-- half a year past each end, so each year's column is centered on it -->
|
|
155
|
+
<${Chart} scale=${[FIRST - 0.5, LAST + 0.5]} ticks=${YEARS.filter((y) => y % 10 === 0)}
|
|
156
|
+
label=${`GDP per person in ten countries, ${FIRST} to ${LAST}, each line scaled to its own peak`}
|
|
157
|
+
theme=${{ font: '"Libre Franklin", "Helvetica Neue", Arial, sans-serif', ink: "#1f1c17", muted: "#6b645a", grid: "#e2dccf" }}>
|
|
158
|
+
<${Plot} rows=${COUNTRIES} peak=${(d) => Math.max(...d.gdp)} growth=${(d) => d.gdp.at(-1) / d.gdp[0]}
|
|
159
|
+
order=${sortBy("growth", "desc")} year=${() => YEARS[i()]} i=${i}>${Country}<//>
|
|
160
|
+
<${Plot} overlap=${true} slats=${1} year=${() => YEARS[i()]} style=${{ "pointer-events": "none" }}>${Crosshair}<//>
|
|
161
|
+
<//>
|
|
162
|
+
</div>
|
|
163
|
+
<//>`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
render(() => html`<${RicherSince1990} />`, document.getElementById("chart"));
|
|
167
|
+
</script>
|
|
168
|
+
</body>
|
|
169
|
+
</html>
|
|
@@ -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>How cities get to work</title>
|
|
7
|
+
<meta name="description" content="100% stacked bar chart of shares per category, labeled where they fit, with a key that lights one part in every bar on hover, focus and tap. Use it to compare how a whole splits across several categories.">
|
|
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=Jost: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: 48px 16px; background: #dedcd8; font-family: Jost, "Futura", "Century Gothic", system-ui, sans-serif; }
|
|
15
|
+
/* The poster: a transit map's white paper with faint 45 degree hatching, bold geometric type, the key drawn as a metro line */
|
|
16
|
+
.poster { max-width: 940px; margin: 0 auto; padding: 46px 52px 30px; color: #1d1b17; border-radius: 10px;
|
|
17
|
+
background: repeating-linear-gradient(45deg, rgb(29 27 23 / .028) 0 1px, transparent 1px 11px), #fbfbf9;
|
|
18
|
+
box-shadow: 0 28px 56px -30px rgb(64 60 52 / .6); }
|
|
19
|
+
.poster figcaption { display: grid; gap: 12px; margin-bottom: 26px; }
|
|
20
|
+
.poster .kicker { font-size: 13px; font-weight: 600; letter-spacing: .16em; text-transform: uppercase; color: #2f5bd3; }
|
|
21
|
+
.poster .headline { max-width: 15em; font-size: 50px; font-weight: 700; line-height: 1.02; letter-spacing: -.015em; text-wrap: balance; }
|
|
22
|
+
.poster .dek { max-width: 56ch; font-size: 17px; line-height: 1.5; color: #4b4840; }
|
|
23
|
+
.poster .note { display: block; margin-top: 20px; font-size: 13px; line-height: 1.5; color: #6b675e; }
|
|
24
|
+
/* The key: a metro line whose stations are the modes; pointing at or focusing a station lights that mode */
|
|
25
|
+
.route { display: flex; max-width: 480px; margin: 0 0 24px; }
|
|
26
|
+
.route button { position: relative; flex: 1; display: grid; justify-items: start; gap: 8px; padding: 0 0 4px; border: 0; background: none; color: #1d1b17; font: 600 15px/1 Jost, system-ui, sans-serif; cursor: pointer; }
|
|
27
|
+
.route button:last-child { flex: none; }
|
|
28
|
+
.route button:not(:last-child)::before { content: ""; position: absolute; top: 8px; left: 11px; width: 100%; height: 6px; background: var(--c); }
|
|
29
|
+
.route i { position: relative; width: 22px; height: 22px; border: 5px solid var(--c); border-radius: 50%; background: #fbfbf9; }
|
|
30
|
+
.route button:is(:hover, .on) i { background: var(--c); }
|
|
31
|
+
.route button[aria-pressed="true"] { font-weight: 800; }
|
|
32
|
+
.route button:focus-visible { outline: 2px solid #1d1b17; outline-offset: 4px; border-radius: 4px; }
|
|
33
|
+
@media (max-width: 600px) {
|
|
34
|
+
body { padding: 16px 16px 32px; }
|
|
35
|
+
.poster { padding: 26px 16px 18px; }
|
|
36
|
+
.poster .headline { font-size: 33px; }
|
|
37
|
+
.poster .dek { font-size: 16px; }
|
|
38
|
+
.route button { font-size: 14px; }
|
|
39
|
+
}
|
|
40
|
+
</style>
|
|
41
|
+
</head>
|
|
42
|
+
<body>
|
|
43
|
+
<div id="chart"></div>
|
|
44
|
+
<script type="module">
|
|
45
|
+
import { Chart, Plot, Bar, Label, Poster, slat, sortBy, stackUp, shares, html, render, createSignal, createMemo } from "@bezda/rhp/standalone";
|
|
46
|
+
|
|
47
|
+
// Data: how people usually get to work in six invented cities, as % of commuters. Illustrative data; replace with your own.
|
|
48
|
+
const MODES = [
|
|
49
|
+
{ key: "car", name: "Car", color: "#d0453a" },
|
|
50
|
+
{ key: "transit", name: "Transit", color: "#2f5bd3" },
|
|
51
|
+
{ key: "bike", name: "Bike", color: "#2e9a62" },
|
|
52
|
+
{ key: "walk", name: "Walk", color: "#e0a40c" },
|
|
53
|
+
];
|
|
54
|
+
const CITIES = [
|
|
55
|
+
{ city: "Riverton", car: 84, transit: 8, bike: 1, walk: 7 },
|
|
56
|
+
{ city: "Harbour City", car: 29, transit: 49, bike: 5, walk: 17 },
|
|
57
|
+
{ city: "Millbrook", car: 26, transit: 21, bike: 41, walk: 12 },
|
|
58
|
+
{ city: "Greenhaven", car: 20, transit: 22, bike: 36, walk: 22 },
|
|
59
|
+
{ city: "Port Ellis", car: 13, transit: 62, bike: 8, walk: 17 },
|
|
60
|
+
{ city: "Lakemont", car: 12, transit: 64, bike: 12, walk: 12 },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const KEYS = MODES.map((m) => m.key);
|
|
64
|
+
const percent = (v) => Math.round(v) + "%";
|
|
65
|
+
|
|
66
|
+
// Slat types: one mode's share of a city (its number inside it when it fits), and a city holding its shares in one band.
|
|
67
|
+
// A share starts 2px after the one before it, and the first starts on the baseline. A share too thin to show at this
|
|
68
|
+
// width keeps a 2px sliver, drawn over its neighbor, so it shows at every width.
|
|
69
|
+
const size = (share) => (share < 3 ? "thin small" : share < 9 ? "small" : share < 15 ? "mid" : "");
|
|
70
|
+
const Share = slat({
|
|
71
|
+
inset: 0,
|
|
72
|
+
css: `
|
|
73
|
+
.share { --rhp-radius: 0px; display: grid; place-items: center; cursor: pointer; }
|
|
74
|
+
.after { --rhp-gap: 2px; }
|
|
75
|
+
.first { --rhp-start-radius: 14px; }
|
|
76
|
+
.share:last-child { --rhp-end-radius: 14px; }
|
|
77
|
+
.thin { min-width: 2px; z-index: 1; }
|
|
78
|
+
.share > span { font-size: 13px; font-weight: 700; color: #fff; font-variant-numeric: tabular-nums; }
|
|
79
|
+
.share:is(.bike, .walk) > span { color: #1d1b17; } /* ink on the light colors, white on the dark ones */
|
|
80
|
+
.small > span, .off > span { display: none; }
|
|
81
|
+
.off { opacity: .2; }
|
|
82
|
+
.on { z-index: 2; }
|
|
83
|
+
.on:is(.small, .mid) > span { display: block; position: absolute; left: 50%; top: 50%; translate: -50% -50%; padding: 4px 8px; border-radius: 99px; background: var(--rhp-color); box-shadow: 0 0 0 2px #fbfbf9; }
|
|
84
|
+
.on.first:is(.small, .mid) > span { left: 2px; translate: 0 -50%; }
|
|
85
|
+
.on.share:last-child:is(.small, .mid) > span { left: auto; right: 2px; translate: 0 -50%; }
|
|
86
|
+
@container (max-width: 420px) { .mid:not(.on) > span { display: none; } }
|
|
87
|
+
`,
|
|
88
|
+
}, (s) => html`
|
|
89
|
+
<${Bar} from=${() => s.from} to=${() => s.to} color=${() => s.color} data-mode=${() => s.mode}
|
|
90
|
+
class=${() => ["share", s.mode, s.from > 0 ? "after" : "first", s.to > s.from && size(s.to - s.from), s.lit && (s.lit === s.mode ? "on" : "off")].filter(Boolean).join(" ")}>
|
|
91
|
+
<span>${() => percent(s.to - s.from)}</span>
|
|
92
|
+
<//>`);
|
|
93
|
+
|
|
94
|
+
const City = slat({
|
|
95
|
+
thickness: 52,
|
|
96
|
+
room: { start: "auto", end: 18 },
|
|
97
|
+
css: `
|
|
98
|
+
.city { font-size: 17px; font-weight: 600; --rhp-label-gap: 14px; }
|
|
99
|
+
@container (max-width: 420px) { .city { max-width: 7em; font-size: 14px; line-height: 1.1; white-space: normal; --rhp-label-gap: 10px; } }
|
|
100
|
+
`,
|
|
101
|
+
}, (d) => {
|
|
102
|
+
const share = createMemo(() => shares(KEYS.map((k) => d[k]))); // each mode as a share of 100
|
|
103
|
+
const stack = createMemo(() => stackUp(share())); // the shares end to end
|
|
104
|
+
return html`
|
|
105
|
+
<div aria-label=${() => `${d.city}: ` + MODES.map((m, i) => `${m.name.toLowerCase()} ${percent(share()[i])}`).join(", ")}>
|
|
106
|
+
<${Label} edge="start" class="city">${() => d.city}<//>
|
|
107
|
+
<${Plot} overlap=${true} thick="28px" mode=${KEYS} color=${MODES.map((m) => m.color)} lit=${() => d.lit}
|
|
108
|
+
from=${() => stack().from} to=${() => stack().to}>${Share}<//>
|
|
109
|
+
</div>`;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Chart component: the mode pointed at or reached with the keyboard (else the one clicked or tapped) lights up in every
|
|
113
|
+
// city
|
|
114
|
+
function Commutes() {
|
|
115
|
+
const [hovered, setHovered] = createSignal(null);
|
|
116
|
+
const [pinned, setPinned] = createSignal(null);
|
|
117
|
+
const lit = () => hovered() ?? pinned();
|
|
118
|
+
const modeOf = (el) => el?.closest("[data-mode]")?.dataset.mode ?? null;
|
|
119
|
+
// A mouse or a pen previews a mode; away from the line and bars, the keyboard's or kept mode shows
|
|
120
|
+
const hover = (e) => e.pointerType !== "touch" && setHovered(modeOf(e.target) ?? modeOf(e.currentTarget.querySelector(":focus-visible")));
|
|
121
|
+
// The keyboard lights the mode it reaches. A click or a tap focuses its button too, so only a focus ring counts.
|
|
122
|
+
const focus = (e) => e.target.matches(":focus-visible") && setHovered(modeOf(e.target));
|
|
123
|
+
// Leaving the poster: back to the mode the keyboard is on, or none, so the kept mode shows
|
|
124
|
+
const leave = (e) => e.pointerType !== "touch" && setHovered(modeOf(e.currentTarget.querySelector(":focus-visible")));
|
|
125
|
+
// A click or a tap keeps a mode lit, and another on it lets it go
|
|
126
|
+
const keep = (e) => setPinned((p) => (modeOf(e.target) === p ? null : modeOf(e.target)));
|
|
127
|
+
return html`
|
|
128
|
+
<${Poster}
|
|
129
|
+
kicker="The commute in six cities"
|
|
130
|
+
title="Only in Riverton do most people drive to work"
|
|
131
|
+
dek="How commuters usually get to work, as a share of all of them. Point at a mode on the line or in a bar to follow it through every city; click or tap it to keep it lit."
|
|
132
|
+
note="Illustrative data for six invented cities. Shares are rounded to whole percents."
|
|
133
|
+
onPointerMove=${hover} onPointerLeave=${leave} onFocusIn=${focus}
|
|
134
|
+
onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setHovered(null)}
|
|
135
|
+
onClick=${keep} onKeyDown=${(e) => e.key === "Escape" && setPinned(null)}>
|
|
136
|
+
<div class="route" role="group" aria-label="Modes of transport">
|
|
137
|
+
${MODES.map((m) => html`
|
|
138
|
+
<button type="button" data-mode=${m.key} style=${"--c: " + m.color} aria-pressed=${() => pinned() === m.key} class=${() => (lit() === m.key ? "on" : "")}><i></i>${m.name}</button>`)}
|
|
139
|
+
</div>
|
|
140
|
+
<!-- no axis: every bar runs from 0% to 100%, and the shares are written in the bars -->
|
|
141
|
+
<${Chart} scale=${[0, 100]} ticks=${false}
|
|
142
|
+
label="How commuters get to work in six cities, as a share of all commuters"
|
|
143
|
+
theme=${{ font: 'Jost, "Futura", "Century Gothic", system-ui, sans-serif', ink: "#1d1b17", muted: "#6b675e" }}>
|
|
144
|
+
<${Plot} rows=${CITIES} order=${sortBy("car", "desc")} lit=${lit}>${City}<//>
|
|
145
|
+
<//>
|
|
146
|
+
<//>`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
render(() => html`<${Commutes} />`, document.getElementById("chart"));
|
|
150
|
+
</script>
|
|
151
|
+
</body>
|
|
152
|
+
</html>
|