@bezda/rhp-mcp 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +222 -2
- package/bin/rhp-mcp.js +129 -0
- package/package.json +62 -4
- package/skill/SKILL.md +450 -0
- package/skill/recipes/area.html +156 -0
- package/skill/recipes/bar.html +145 -0
- package/skill/recipes/box-plot.html +164 -0
- package/skill/recipes/bubble.html +188 -0
- package/skill/recipes/bullet.html +138 -0
- package/skill/recipes/candlestick.html +179 -0
- package/skill/recipes/column.html +138 -0
- package/skill/recipes/dial.html +349 -0
- package/skill/recipes/diverging-bars.html +150 -0
- package/skill/recipes/donut.html +231 -0
- package/skill/recipes/dumbbell.html +137 -0
- package/skill/recipes/gantt.html +196 -0
- package/skill/recipes/grouped-bars.html +142 -0
- package/skill/recipes/heatmap.html +183 -0
- package/skill/recipes/histogram.html +199 -0
- package/skill/recipes/line.html +170 -0
- package/skill/recipes/live.html +255 -0
- package/skill/recipes/log-line.html +493 -0
- package/skill/recipes/lollipop.html +152 -0
- package/skill/recipes/multi-line.html +228 -0
- package/skill/recipes/pyramid.html +162 -0
- package/skill/recipes/race.html +278 -0
- package/skill/recipes/radial-bars.html +157 -0
- package/skill/recipes/scatter.html +199 -0
- package/skill/recipes/silhouettes.html +352 -0
- package/skill/recipes/slope.html +199 -0
- package/skill/recipes/sparklines.html +175 -0
- package/skill/recipes/stacked-100.html +158 -0
- package/skill/recipes/stacked-bars.html +150 -0
- package/skill/recipes/strip.html +221 -0
- package/skill/recipes/unit-stack.html +276 -0
- package/skill/recipes/violin.html +169 -0
- package/skill/recipes/waffle.html +165 -0
- package/skill/recipes/waterfall.html +127 -0
- package/skill/references/api.md +1684 -0
- package/skill/references/design.md +740 -0
- package/skill/references/environments.md +1577 -0
- package/skill/references/forms.md +197 -0
- package/skill/references/interaction.md +794 -0
- package/skill/references/looks.md +267 -0
- package/skill/references/pitfalls.md +202 -0
- package/src/check/README.md +176 -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 +508 -0
- package/src/check/inpage.js +1332 -0
- package/src/check/interact.js +266 -0
- package/src/check/png.js +98 -0
- package/src/check/report.js +106 -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 +612 -0
- package/src/guard/rules.js +125 -0
- package/src/server.js +266 -0
- package/src/skill.js +140 -0
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
<!--
|
|
9
|
+
A recipe gives technique, not a design.
|
|
10
|
+
Take: a Line in each slat scaled to itself, the start level dashed, numbers in columns, and a year scrub that moves a dot along every line.
|
|
11
|
+
Leave: the design. This poster was made for its own subject (incomes since 1990 as a table page of a statistical yearbook, cream paper between a thick and a thin rule).
|
|
12
|
+
Its look, the place of its parts and the way its marks are drawn say that subject: design yours from your own (design.md, section 2).
|
|
13
|
+
-->
|
|
14
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
15
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
16
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Libre+Caslon+Text:wght@400;700&family=Libre+Franklin:wght@400..800&display=swap">
|
|
17
|
+
<script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>
|
|
18
|
+
<style>
|
|
19
|
+
* { box-sizing: border-box; }
|
|
20
|
+
body { margin: 0; padding: 48px 16px; background: #d9d5cc; font-family: "Libre Franklin", "Helvetica Neue", Arial, sans-serif; }
|
|
21
|
+
/* The poster: a table page from a statistical yearbook, cream paper between a thick and a thin rule */
|
|
22
|
+
.poster { max-width: 900px; margin: 0 auto; padding: 40px 52px 28px; color: #1f1c17; border-top: 6px solid #1f1c17;
|
|
23
|
+
background: linear-gradient(#1f1c17 0 0) 52px 10px / calc(100% - 104px) 1px no-repeat, #f7f3ea;
|
|
24
|
+
box-shadow: 0 1px 2px rgb(50 44 34 / .18), 0 26px 50px -28px rgb(50 44 34 / .55); }
|
|
25
|
+
.poster figcaption { display: grid; gap: 12px; margin-bottom: 22px; }
|
|
26
|
+
.poster .kicker { font-size: 12px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; color: #a3360d; }
|
|
27
|
+
.poster .headline { max-width: 15em; font: 700 48px/1.05 "Libre Caslon Text", Georgia, serif; letter-spacing: -.015em; text-wrap: balance; }
|
|
28
|
+
.poster .dek { max-width: 58ch; font-size: 16px; line-height: 1.55; color: #4c463d; }
|
|
29
|
+
.poster .dek i { display: inline-block; width: 18px; margin: 0 3px; border-top: 1.5px dashed #6b645a; vertical-align: 4px; }
|
|
30
|
+
.poster .note { display: block; margin-top: 18px; padding-top: 10px; border-top: 1px solid #d9d2c4; font-size: 12px; line-height: 1.5; color: #6b645a; }
|
|
31
|
+
/* The table's head: the year being read, and what the two number columns hold */
|
|
32
|
+
.head { display: flex; align-items: baseline; justify-content: space-between; gap: 6px 16px; flex-wrap: wrap; margin: 0 0 6px; padding: 8px 0;
|
|
33
|
+
border-block: 1px solid #1f1c17; font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: #6b645a; }
|
|
34
|
+
.head b { font: 700 26px/1 "Libre Caslon Text", Georgia, serif; letter-spacing: 0; color: #1f1c17; font-variant-numeric: tabular-nums; }
|
|
35
|
+
.head b:focus-visible { outline: 2px solid #c2410c; outline-offset: 4px; border-radius: 2px; }
|
|
36
|
+
.plot { touch-action: pan-y; } /* a finger dragging sideways moves through the years; up and down still scrolls */
|
|
37
|
+
/* On a phone the table is taller than half the screen: its head, with the year, stays at the top of the screen while
|
|
38
|
+
the lines scroll under it */
|
|
39
|
+
@media (max-width: 600px) {
|
|
40
|
+
body { padding: 16px 16px 32px; }
|
|
41
|
+
.poster { padding: 30px 16px 18px; background-position: 16px 8px, 0 0; background-size: calc(100% - 32px) 1px, auto; }
|
|
42
|
+
.poster .headline { font-size: 32px; }
|
|
43
|
+
.poster .dek { font-size: 15px; }
|
|
44
|
+
.head { position: sticky; top: 0; z-index: 2; background: #f7f3ea; font-size: 11px; }
|
|
45
|
+
.head b { font-size: 22px; }
|
|
46
|
+
}
|
|
47
|
+
</style>
|
|
48
|
+
</head>
|
|
49
|
+
<body>
|
|
50
|
+
<div id="chart"></div>
|
|
51
|
+
<script type="module">
|
|
52
|
+
import { Chart, Plot, Line, Dot, Tick, Label, Poster, slat, sortBy, html, render, createSignal } from "@bezda/rhp/standalone";
|
|
53
|
+
|
|
54
|
+
// Data: GDP per person from 1990 to 2024, in constant 2021 international dollars (adjusted for prices and for what
|
|
55
|
+
// money buys in each country), World Bank via Our World in Data. Replace with your own rows: one value per year each.
|
|
56
|
+
const FIRST = 1990;
|
|
57
|
+
const COUNTRIES = [
|
|
58
|
+
{ 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] },
|
|
59
|
+
{ 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] },
|
|
60
|
+
{ 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] },
|
|
61
|
+
{ 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] },
|
|
62
|
+
{ 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] },
|
|
63
|
+
{ 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] },
|
|
64
|
+
{ 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] },
|
|
65
|
+
{ 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] },
|
|
66
|
+
{ 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] },
|
|
67
|
+
{ 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] },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const YEARS = COUNTRIES[0].gdp.map((_, i) => FIRST + i);
|
|
71
|
+
const LAST = YEARS.at(-1);
|
|
72
|
+
const INSET = 0.2; // the empty share of each slat's band, half above and half below its line
|
|
73
|
+
const dollars = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
|
|
74
|
+
const times = new Intl.NumberFormat("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
|
75
|
+
// 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
|
|
76
|
+
const across = (value, peak) => INSET / 2 + (1 - INSET) * (1 - value / peak);
|
|
77
|
+
|
|
78
|
+
// Slat types
|
|
79
|
+
// A country: its name, its line (scaled to its own peak, over a hairline at zero), its 1990 level dashed, a dot at the
|
|
80
|
+
// year being read, and that year's value with how many times its 1990 value it is
|
|
81
|
+
const Country = slat({
|
|
82
|
+
thickness: 44,
|
|
83
|
+
inset: INSET,
|
|
84
|
+
room: "auto",
|
|
85
|
+
css: `
|
|
86
|
+
.country { background: linear-gradient(var(--rhp-grid) 0 0) 0 ${(1 - INSET / 2) * 100}% / 100% 1px no-repeat; }
|
|
87
|
+
.name { font-size: 14px; font-weight: 700; --rhp-label-gap: 14px; }
|
|
88
|
+
.spark { stroke-width: 1.75px; }
|
|
89
|
+
.start path { stroke-dasharray: 3 3; }
|
|
90
|
+
.now { background: #c2410c; box-shadow: 0 0 0 2px #f7f3ea; }
|
|
91
|
+
.numbers { display: flex; justify-content: flex-end; align-items: baseline; gap: 10px; justify-self: stretch; font-variant-numeric: tabular-nums; --rhp-label-gap: 14px; }
|
|
92
|
+
.numbers b { font-size: 14px; font-weight: 700; }
|
|
93
|
+
.numbers span { min-width: 3.2em; font-size: 13px; font-weight: 600; color: var(--rhp-muted); text-align: right; }
|
|
94
|
+
.poorer .numbers span { color: #b42318; }
|
|
95
|
+
@container (max-width: 520px) {
|
|
96
|
+
.name { font-size: 13px; --rhp-label-gap: 10px; }
|
|
97
|
+
.numbers { flex-direction: column; align-items: flex-end; gap: 1px; line-height: 1.15; --rhp-label-gap: 10px; }
|
|
98
|
+
.numbers b { font-size: 13px; }
|
|
99
|
+
.numbers span { font-size: 12px; }
|
|
100
|
+
}
|
|
101
|
+
`,
|
|
102
|
+
}, (d) => html`
|
|
103
|
+
<div class=${() => (d.gdp[d.i] < d.gdp[0] ? "country poorer" : "country")}
|
|
104
|
+
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`}>
|
|
105
|
+
<${Label} edge="start" class="name">${() => d.name}<//>
|
|
106
|
+
<${Line} points=${() => d.gdp.map((v, i) => [FIRST + i, v])} peak=${() => d.peak} color="ink" class="spark" />
|
|
107
|
+
<${Line} points=${() => [[FIRST, d.gdp[0]], [LAST, d.gdp[0]]]} peak=${() => d.peak} color="muted" class="start" />
|
|
108
|
+
<${Dot} at=${() => d.year} across=${() => across(d.gdp[d.i], d.peak)} size="7px" class="now" />
|
|
109
|
+
<${Label} edge="end" class="numbers"><b>${() => dollars.format(d.gdp[d.i])}</b><span>${() => times.format(d.gdp[d.i] / d.gdp[0]) + "×"}</span><//>
|
|
110
|
+
</div>`);
|
|
111
|
+
|
|
112
|
+
// The year being read: a hairline down every slat
|
|
113
|
+
const Crosshair = slat({ room: {}, css: `.hair { --rhp-tick-width: 1px; background: #c2410c; opacity: .55; }` }, (d) => html`
|
|
114
|
+
<${Tick} at=${() => d.year} thick=${1} class="hair" />`);
|
|
115
|
+
|
|
116
|
+
// Chart component: hovering previews a year; a click, tap, sideways drag or the arrow keys saves it for every slat.
|
|
117
|
+
// With no saved year, the latest shows. The year in the table's head is a slider for the keyboard and screen readers.
|
|
118
|
+
function RicherSince1990() {
|
|
119
|
+
const [picked, setPicked] = createSignal(null);
|
|
120
|
+
const [hovered, setHovered] = createSignal(null);
|
|
121
|
+
const i = () => hovered() ?? picked() ?? YEARS.length - 1;
|
|
122
|
+
// The year under the pointer, measured on a line (every line runs from the first year to the last), or null beside it
|
|
123
|
+
const point = (e) => {
|
|
124
|
+
const r = e.currentTarget.querySelector(".spark").getBoundingClientRect();
|
|
125
|
+
const k = Math.round(((e.clientX - r.left) / r.width) * (YEARS.length - 1));
|
|
126
|
+
return k >= 0 && k < YEARS.length ? k : null;
|
|
127
|
+
};
|
|
128
|
+
const pick = (e) => {
|
|
129
|
+
const k = point(e);
|
|
130
|
+
if (k == null) return;
|
|
131
|
+
setPicked(k);
|
|
132
|
+
setHovered(null);
|
|
133
|
+
};
|
|
134
|
+
const STEPS = { ArrowLeft: -1, ArrowDown: -1, ArrowRight: 1, ArrowUp: 1, PageDown: -10, PageUp: 10, Home: -Infinity, End: Infinity };
|
|
135
|
+
const key = (e) => {
|
|
136
|
+
if (!(e.key in STEPS)) return;
|
|
137
|
+
e.preventDefault();
|
|
138
|
+
setPicked(Math.max(0, Math.min(YEARS.length - 1, i() + STEPS[e.key])));
|
|
139
|
+
setHovered(null);
|
|
140
|
+
};
|
|
141
|
+
let start = [0, 0]; // where the last finger went down
|
|
142
|
+
// A finger that moves more sideways than up or down scrubs; one that moves up or down scrolls the page
|
|
143
|
+
const sideways = (e) => Math.abs(e.clientX - start[0]) > Math.abs(e.clientY - start[1]);
|
|
144
|
+
// Leaving the lines ends the preview and restores the saved year; a finger lifting after a tap keeps its selection
|
|
145
|
+
const leave = (e) => e.pointerType !== "touch" && setHovered(null);
|
|
146
|
+
const said = () => `${YEARS[i()]}: ` + COUNTRIES.map((c) => `${c.name} ${dollars.format(c.gdp[i()])}`).join(", ");
|
|
147
|
+
return html`
|
|
148
|
+
<${Poster}
|
|
149
|
+
kicker=${`Income per person, ${FIRST} to ${LAST}`}
|
|
150
|
+
title="China's income per person is 14 times what it was in 1990"
|
|
151
|
+
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>`}
|
|
152
|
+
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.">
|
|
153
|
+
<p class="head">
|
|
154
|
+
<b tabindex="0" role="slider" aria-label="Year read on every line" aria-valuemin=${FIRST} aria-valuemax=${LAST}
|
|
155
|
+
aria-valuenow=${() => YEARS[i()]} aria-valuetext=${said} onKeyDown=${key}>${() => YEARS[i()]}</b>
|
|
156
|
+
<span aria-hidden="true">GDP per person · × its ${FIRST} level</span>
|
|
157
|
+
</p>
|
|
158
|
+
<div class="plot" onPointerDown=${(e) => (start = [e.clientX, e.clientY])}
|
|
159
|
+
onPointerMove=${(e) => e.pointerType === "touch" ? sideways(e) && pick(e) : setHovered(point(e))} onClick=${pick} onPointerLeave=${leave}>
|
|
160
|
+
<!-- half a year past each end, so each year's column is centered on it -->
|
|
161
|
+
<${Chart} scale=${[FIRST - 0.5, LAST + 0.5]} ticks=${YEARS.filter((y) => y % 10 === 0)}
|
|
162
|
+
label=${`GDP per person in ten countries, ${FIRST} to ${LAST}, each line scaled to its own peak`}
|
|
163
|
+
theme=${{ font: '"Libre Franklin", "Helvetica Neue", Arial, sans-serif', ink: "#1f1c17", muted: "#6b645a", grid: "#e2dccf" }}>
|
|
164
|
+
<${Plot} rows=${COUNTRIES} peak=${(d) => Math.max(...d.gdp)} growth=${(d) => d.gdp.at(-1) / d.gdp[0]}
|
|
165
|
+
order=${sortBy("growth", "desc")} year=${() => YEARS[i()]} i=${i}>${Country}<//>
|
|
166
|
+
<${Plot} overlap=${true} slats=${1} year=${() => YEARS[i()]} style=${{ "pointer-events": "none" }}>${Crosshair}<//>
|
|
167
|
+
<//>
|
|
168
|
+
</div>
|
|
169
|
+
<//>`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
render(() => html`<${RicherSince1990} />`, document.getElementById("chart"));
|
|
173
|
+
</script>
|
|
174
|
+
</body>
|
|
175
|
+
</html>
|
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
<!--
|
|
9
|
+
A recipe gives technique, not a design.
|
|
10
|
+
Take: stackUp() of shares() in an overlap inner Plot, labels inside where they fit, and a key that lights one part in every bar.
|
|
11
|
+
Leave: the design. This poster was made for its own subject (ways to commute as a transit map: enamel white, geometric type, the key drawn as a metro line whose stations are the buttons).
|
|
12
|
+
Its look, the place of its parts and the way its marks are drawn say that subject: design yours from your own (design.md, section 2).
|
|
13
|
+
-->
|
|
14
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
15
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
16
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Jost:wght@400..800&display=swap">
|
|
17
|
+
<script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>
|
|
18
|
+
<style>
|
|
19
|
+
* { box-sizing: border-box; }
|
|
20
|
+
body { margin: 0; padding: 48px 16px; background: #dedcd8; font-family: Jost, "Futura", "Century Gothic", system-ui, sans-serif; }
|
|
21
|
+
/* The poster: a transit map's white paper with faint 45 degree hatching, bold geometric type, the key drawn as a metro line */
|
|
22
|
+
.poster { max-width: 940px; margin: 0 auto; padding: 46px 52px 30px; color: #1d1b17; border-radius: 10px;
|
|
23
|
+
background: repeating-linear-gradient(45deg, rgb(29 27 23 / .028) 0 1px, transparent 1px 11px), #fbfbf9;
|
|
24
|
+
box-shadow: 0 28px 56px -30px rgb(64 60 52 / .6); }
|
|
25
|
+
.poster figcaption { display: grid; gap: 12px; margin-bottom: 26px; }
|
|
26
|
+
.poster .kicker { font-size: 13px; font-weight: 600; letter-spacing: .16em; text-transform: uppercase; color: #2f5bd3; }
|
|
27
|
+
.poster .headline { max-width: 15em; font-size: 50px; font-weight: 700; line-height: 1.02; letter-spacing: -.015em; text-wrap: balance; }
|
|
28
|
+
.poster .dek { max-width: 56ch; font-size: 17px; line-height: 1.5; color: #4b4840; }
|
|
29
|
+
.poster .note { display: block; margin-top: 20px; font-size: 13px; line-height: 1.5; color: #6b675e; }
|
|
30
|
+
/* The key: a metro line whose stations are the modes; pointing at or focusing a station lights that mode */
|
|
31
|
+
.route { display: flex; max-width: 480px; margin: 0 0 24px; }
|
|
32
|
+
.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; }
|
|
33
|
+
.route button:last-child { flex: none; }
|
|
34
|
+
.route button:not(:last-child)::before { content: ""; position: absolute; top: 8px; left: 11px; width: 100%; height: 6px; background: var(--c); }
|
|
35
|
+
.route i { position: relative; width: 22px; height: 22px; border: 5px solid var(--c); border-radius: 50%; background: #fbfbf9; }
|
|
36
|
+
.route button:is(:hover, .on) i { background: var(--c); }
|
|
37
|
+
.route button[aria-pressed="true"] { font-weight: 800; }
|
|
38
|
+
.route button:focus-visible { outline: 2px solid #1d1b17; outline-offset: 4px; border-radius: 4px; }
|
|
39
|
+
@media (max-width: 600px) {
|
|
40
|
+
body { padding: 16px 16px 32px; }
|
|
41
|
+
.poster { padding: 26px 16px 18px; }
|
|
42
|
+
.poster .headline { font-size: 33px; }
|
|
43
|
+
.poster .dek { font-size: 16px; }
|
|
44
|
+
.route button { font-size: 14px; }
|
|
45
|
+
}
|
|
46
|
+
</style>
|
|
47
|
+
</head>
|
|
48
|
+
<body>
|
|
49
|
+
<div id="chart"></div>
|
|
50
|
+
<script type="module">
|
|
51
|
+
import { Chart, Plot, Bar, Label, Poster, slat, sortBy, stackUp, shares, html, render, createSignal, createMemo } from "@bezda/rhp/standalone";
|
|
52
|
+
|
|
53
|
+
// Data: how people usually get to work in six invented cities, as % of commuters. Illustrative data; replace with your own.
|
|
54
|
+
const MODES = [
|
|
55
|
+
{ key: "car", name: "Car", color: "#d0453a" },
|
|
56
|
+
{ key: "transit", name: "Transit", color: "#2f5bd3" },
|
|
57
|
+
{ key: "bike", name: "Bike", color: "#2e9a62" },
|
|
58
|
+
{ key: "walk", name: "Walk", color: "#e0a40c" },
|
|
59
|
+
];
|
|
60
|
+
const CITIES = [
|
|
61
|
+
{ city: "Riverton", car: 84, transit: 8, bike: 1, walk: 7 },
|
|
62
|
+
{ city: "Harbour City", car: 29, transit: 49, bike: 5, walk: 17 },
|
|
63
|
+
{ city: "Millbrook", car: 26, transit: 21, bike: 41, walk: 12 },
|
|
64
|
+
{ city: "Greenhaven", car: 20, transit: 22, bike: 36, walk: 22 },
|
|
65
|
+
{ city: "Port Ellis", car: 13, transit: 62, bike: 8, walk: 17 },
|
|
66
|
+
{ city: "Lakemont", car: 12, transit: 64, bike: 12, walk: 12 },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const KEYS = MODES.map((m) => m.key);
|
|
70
|
+
const percent = (v) => Math.round(v) + "%";
|
|
71
|
+
|
|
72
|
+
// 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.
|
|
73
|
+
// A share starts 2px after the one before it, and the first starts on the baseline. A share too thin to show at this
|
|
74
|
+
// width keeps a 2px sliver, drawn over its neighbor, so it shows at every width.
|
|
75
|
+
const size = (share) => (share < 3 ? "thin small" : share < 9 ? "small" : share < 15 ? "mid" : "");
|
|
76
|
+
const Share = slat({
|
|
77
|
+
inset: 0,
|
|
78
|
+
css: `
|
|
79
|
+
.share { --rhp-radius: 0px; display: grid; place-items: center; cursor: pointer; }
|
|
80
|
+
.after { --rhp-gap: 2px; }
|
|
81
|
+
.first { --rhp-start-radius: 14px; }
|
|
82
|
+
.share:last-child { --rhp-end-radius: 14px; }
|
|
83
|
+
.thin { min-width: 2px; z-index: 1; }
|
|
84
|
+
.share > span { font-size: 13px; font-weight: 700; color: #fff; font-variant-numeric: tabular-nums; }
|
|
85
|
+
.share:is(.bike, .walk) > span { color: #1d1b17; } /* ink on the light colors, white on the dark ones */
|
|
86
|
+
.small > span, .off > span { display: none; }
|
|
87
|
+
.off { opacity: .2; }
|
|
88
|
+
.on { z-index: 2; }
|
|
89
|
+
.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; }
|
|
90
|
+
.on.first:is(.small, .mid) > span { left: 2px; translate: 0 -50%; }
|
|
91
|
+
.on.share:last-child:is(.small, .mid) > span { left: auto; right: 2px; translate: 0 -50%; }
|
|
92
|
+
@container (max-width: 420px) { .mid:not(.on) > span { display: none; } }
|
|
93
|
+
`,
|
|
94
|
+
}, (s) => html`
|
|
95
|
+
<${Bar} from=${() => s.from} to=${() => s.to} color=${() => s.color} data-mode=${() => s.mode}
|
|
96
|
+
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(" ")}>
|
|
97
|
+
<span>${() => percent(s.to - s.from)}</span>
|
|
98
|
+
<//>`);
|
|
99
|
+
|
|
100
|
+
const City = slat({
|
|
101
|
+
thickness: 52,
|
|
102
|
+
room: { start: "auto", end: 18 },
|
|
103
|
+
css: `
|
|
104
|
+
.city { font-size: 17px; font-weight: 600; --rhp-label-gap: 14px; }
|
|
105
|
+
@container (max-width: 420px) { .city { max-width: 7em; font-size: 14px; line-height: 1.1; white-space: normal; --rhp-label-gap: 10px; } }
|
|
106
|
+
`,
|
|
107
|
+
}, (d) => {
|
|
108
|
+
const share = createMemo(() => shares(KEYS.map((k) => d[k]))); // each mode as a share of 100
|
|
109
|
+
const stack = createMemo(() => stackUp(share())); // the shares end to end
|
|
110
|
+
return html`
|
|
111
|
+
<div aria-label=${() => `${d.city}: ` + MODES.map((m, i) => `${m.name.toLowerCase()} ${percent(share()[i])}`).join(", ")}>
|
|
112
|
+
<${Label} edge="start" class="city">${() => d.city}<//>
|
|
113
|
+
<${Plot} overlap=${true} thick="28px" mode=${KEYS} color=${MODES.map((m) => m.color)} lit=${() => d.lit}
|
|
114
|
+
from=${() => stack().from} to=${() => stack().to}>${Share}<//>
|
|
115
|
+
</div>`;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Chart component: the mode pointed at or reached with the keyboard (else the one clicked or tapped) lights up in every
|
|
119
|
+
// city
|
|
120
|
+
function Commutes() {
|
|
121
|
+
const [hovered, setHovered] = createSignal(null);
|
|
122
|
+
const [pinned, setPinned] = createSignal(null);
|
|
123
|
+
const lit = () => hovered() ?? pinned();
|
|
124
|
+
const modeOf = (el) => el?.closest("[data-mode]")?.dataset.mode ?? null;
|
|
125
|
+
// A mouse or a pen previews a mode; away from the line and bars, the keyboard's or kept mode shows
|
|
126
|
+
const hover = (e) => e.pointerType !== "touch" && setHovered(modeOf(e.target) ?? modeOf(e.currentTarget.querySelector(":focus-visible")));
|
|
127
|
+
// The keyboard lights the mode it reaches. A click or a tap focuses its button too, so only a focus ring counts.
|
|
128
|
+
const focus = (e) => e.target.matches(":focus-visible") && setHovered(modeOf(e.target));
|
|
129
|
+
// Leaving the poster: back to the mode the keyboard is on, or none, so the kept mode shows
|
|
130
|
+
const leave = (e) => e.pointerType !== "touch" && setHovered(modeOf(e.currentTarget.querySelector(":focus-visible")));
|
|
131
|
+
// A click or a tap keeps a mode lit, and another on it lets it go
|
|
132
|
+
const keep = (e) => setPinned((p) => (modeOf(e.target) === p ? null : modeOf(e.target)));
|
|
133
|
+
return html`
|
|
134
|
+
<${Poster}
|
|
135
|
+
kicker="The commute in six cities"
|
|
136
|
+
title="Only in Riverton do most people drive to work"
|
|
137
|
+
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."
|
|
138
|
+
note="Illustrative data for six invented cities. Shares are rounded to whole percents."
|
|
139
|
+
onPointerMove=${hover} onPointerLeave=${leave} onFocusIn=${focus}
|
|
140
|
+
onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setHovered(null)}
|
|
141
|
+
onClick=${keep} onKeyDown=${(e) => e.key === "Escape" && setPinned(null)}>
|
|
142
|
+
<div class="route" role="group" aria-label="Modes of transport">
|
|
143
|
+
${MODES.map((m) => html`
|
|
144
|
+
<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>`)}
|
|
145
|
+
</div>
|
|
146
|
+
<!-- no axis: every bar runs from 0% to 100%, and the shares are written in the bars -->
|
|
147
|
+
<${Chart} scale=${[0, 100]} ticks=${false}
|
|
148
|
+
label="How commuters get to work in six cities, as a share of all commuters"
|
|
149
|
+
theme=${{ font: 'Jost, "Futura", "Century Gothic", system-ui, sans-serif', ink: "#1d1b17", muted: "#6b675e" }}>
|
|
150
|
+
<${Plot} rows=${CITIES} order=${sortBy("car", "desc")} lit=${lit}>${City}<//>
|
|
151
|
+
<//>
|
|
152
|
+
<//>`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
render(() => html`<${Commutes} />`, document.getElementById("chart"));
|
|
156
|
+
</script>
|
|
157
|
+
</body>
|
|
158
|
+
</html>
|
|
@@ -0,0 +1,150 @@
|
|
|
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>Europe's power mix</title>
|
|
7
|
+
<meta name="description" content="Stacked bar chart of parts that add up to a total per category, with a key that moves one part to the start of every bar and ranks the bars by it. Use it for totals made of a few parts.">
|
|
8
|
+
<!--
|
|
9
|
+
A recipe gives technique, not a design.
|
|
10
|
+
Take: stackUp() parts in an overlap inner Plot, and a key that moves one part to the start of every bar and ranks by it.
|
|
11
|
+
Leave: the design. This poster was made for its own subject (Europe's power mix on drafting paper, a line of pylons under the headline).
|
|
12
|
+
Its look, the place of its parts and the way its marks are drawn say that subject: design yours from your own (design.md, section 2).
|
|
13
|
+
-->
|
|
14
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
15
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
16
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Oswald:wght@500..700&family=Public+Sans:wght@400..800&display=swap">
|
|
17
|
+
<script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>
|
|
18
|
+
<style>
|
|
19
|
+
* { box-sizing: border-box; }
|
|
20
|
+
body { margin: 0; padding: 48px 16px; background: #d5d6d2; font-family: "Public Sans", "Segoe UI", system-ui, sans-serif; }
|
|
21
|
+
/* The poster: drafting paper with a faint grid, condensed headline type, a line of pylons under the headline */
|
|
22
|
+
.poster { max-width: 980px; margin: 0 auto; padding: 46px 52px 30px; color: #1b1d21; border-radius: 4px;
|
|
23
|
+
background: linear-gradient(rgb(27 29 33 / .03) 1px, transparent 1px) 0 0 / 100% 24px, linear-gradient(90deg, rgb(27 29 33 / .03) 1px, transparent 1px) 0 0 / 24px 100%, #f4f3ef;
|
|
24
|
+
box-shadow: 0 28px 56px -32px rgb(56 58 54 / .65); }
|
|
25
|
+
.poster figcaption { display: grid; gap: 14px; }
|
|
26
|
+
.poster .kicker { font-size: 12px; font-weight: 800; letter-spacing: .2em; text-transform: uppercase; color: #5a3aa6; }
|
|
27
|
+
.poster .headline { max-width: 17em; font: 600 52px/1.02 Oswald, "Arial Narrow", sans-serif; letter-spacing: -.005em; text-wrap: balance; }
|
|
28
|
+
.poster .dek { max-width: 58ch; font-size: 16px; line-height: 1.55; color: #4a4d53; }
|
|
29
|
+
.poster .note { display: block; margin-top: 22px; font-size: 12px; line-height: 1.5; color: #686b72; }
|
|
30
|
+
.pylons { height: 48px; margin: 22px 0 18px; opacity: .7;
|
|
31
|
+
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='48' fill='none' stroke='%231b1d21' stroke-width='1'%3E%3Cpath d='M4 48 10 14M20 48 14 14M10 14 12 3 14 14M2 10h20M4 18h16M6 38l12-10M18 38 6 28M8 26l8-6M16 26l-8-6M7 47h10'/%3E%3Cpath d='M22 10q90 16 180 0M20 18q92 16 184 0' stroke-width='.8'/%3E%3C/svg%3E") repeat-x 0 0 / 200px 48px; }
|
|
32
|
+
/* The key: one button per source, which also picks it */
|
|
33
|
+
.key { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 18px; }
|
|
34
|
+
.key button { display: inline-flex; align-items: center; gap: 8px; padding: 7px 12px 7px 9px; border: 1.5px solid #d3d2cc; border-radius: 4px; background: #f4f3ef; color: #1b1d21; font: 600 13px/1 "Public Sans", system-ui, sans-serif; cursor: pointer; }
|
|
35
|
+
.key button i { width: 12px; height: 12px; border-radius: 2px; }
|
|
36
|
+
.key button[aria-pressed="true"] { border-color: #1b1d21; background: #1b1d21; color: #f4f3ef; }
|
|
37
|
+
.key button:focus-visible { outline: 2px solid #5a3aa6; outline-offset: 2px; }
|
|
38
|
+
@media (max-width: 600px) {
|
|
39
|
+
body { padding: 16px 16px 32px; }
|
|
40
|
+
.poster { padding: 26px 16px 18px; }
|
|
41
|
+
.poster .headline { font-size: 34px; }
|
|
42
|
+
.poster .dek { font-size: 15px; }
|
|
43
|
+
.pylons { margin: 16px 0 14px; }
|
|
44
|
+
.key button { padding: 6px 10px 6px 8px; }
|
|
45
|
+
}
|
|
46
|
+
</style>
|
|
47
|
+
</head>
|
|
48
|
+
<body>
|
|
49
|
+
<div id="chart"></div>
|
|
50
|
+
<script type="module">
|
|
51
|
+
import { Chart, Plot, Bar, Label, Poster, slat, sortBy, nice, stackUp, html, render, createSignal, createMemo } from "@bezda/rhp/standalone";
|
|
52
|
+
|
|
53
|
+
// Data: electricity generated in 2025 by source, in terawatt-hours (TWh) rounded to whole numbers, from Ember's yearly
|
|
54
|
+
// electricity data. Replace with your own.
|
|
55
|
+
const SOURCES = [
|
|
56
|
+
{ key: "coal", name: "Coal", color: "#57493f" },
|
|
57
|
+
{ key: "gas", name: "Gas", color: "#de7a36" },
|
|
58
|
+
{ key: "nuclear", name: "Nuclear", color: "#5a3aa6" },
|
|
59
|
+
{ key: "hydro", name: "Hydro", color: "#4ea6e6" },
|
|
60
|
+
{ key: "wind", name: "Wind", color: "#2f8f55" },
|
|
61
|
+
{ key: "solar", name: "Solar", color: "#e3b400" },
|
|
62
|
+
];
|
|
63
|
+
const COUNTRIES = [
|
|
64
|
+
{ country: "France", coal: 2, gas: 17, nuclear: 392, hydro: 59, wind: 46, solar: 32 },
|
|
65
|
+
{ country: "Germany", coal: 103, gas: 83, nuclear: 0, hydro: 20, wind: 136, solar: 90 },
|
|
66
|
+
{ country: "Spain", coal: 1, gas: 62, nuclear: 54, hydro: 33, wind: 59, solar: 63 },
|
|
67
|
+
{ country: "United Kingdom", coal: 0, gas: 91, nuclear: 36, hydro: 6, wind: 86, solar: 19 },
|
|
68
|
+
{ country: "Italy", coal: 4, gas: 126, nuclear: 0, hydro: 42, wind: 21, solar: 45 },
|
|
69
|
+
{ country: "Poland", coal: 87, gas: 25, nuclear: 0, hydro: 2, wind: 24, solar: 20 },
|
|
70
|
+
{ country: "Norway", coal: 0, gas: 1, nuclear: 0, hydro: 145, wind: 14, solar: 1 },
|
|
71
|
+
{ country: "Sweden", coal: 0, gas: 0, nuclear: 47, hydro: 68, wind: 39, solar: 4 },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const KEYS = SOURCES.map((s) => s.key);
|
|
75
|
+
const twh = new Intl.NumberFormat("en-US");
|
|
76
|
+
const total = (d) => KEYS.reduce((sum, k) => sum + d[k], 0);
|
|
77
|
+
const scale = nice(0, Math.max(...COUNTRIES.map(total)), 5); // 0 to 600 TWh
|
|
78
|
+
|
|
79
|
+
// Slat types: one source's segment, and a country holding its segments in one band (an overlap Plot) and its number.
|
|
80
|
+
// A segment starts 2px after the one before it, and the first starts on the baseline. A segment too thin to show at
|
|
81
|
+
// this width keeps a 2px sliver, drawn over its neighbor, so it shows at every width.
|
|
82
|
+
const thin = (value) => value > 0 && value / scale.max < 0.03;
|
|
83
|
+
const Layer = slat({
|
|
84
|
+
css: `
|
|
85
|
+
.layer { --rhp-radius: 0px; cursor: pointer; }
|
|
86
|
+
.after { --rhp-gap: 2px; }
|
|
87
|
+
.thin { min-width: 2px; z-index: 1; }
|
|
88
|
+
.off { opacity: .22; }
|
|
89
|
+
`,
|
|
90
|
+
}, (s) => html`
|
|
91
|
+
<${Bar} from=${() => s.from} to=${() => s.to} color=${() => s.color} data-source=${() => s.source}
|
|
92
|
+
class=${() => ["layer", s.from > 0 && "after", thin(s.to - s.from) && "thin", s.pick && s.pick !== s.source && "off"].filter(Boolean).join(" ")} />`);
|
|
93
|
+
|
|
94
|
+
const Country = slat({
|
|
95
|
+
thickness: 46,
|
|
96
|
+
inset: "9px",
|
|
97
|
+
room: "auto",
|
|
98
|
+
css: `
|
|
99
|
+
.name { font-size: 15px; font-weight: 700; --rhp-label-gap: 12px; }
|
|
100
|
+
.twh { font-size: 14px; font-weight: 800; font-variant-numeric: tabular-nums; --rhp-label-gap: 10px; }
|
|
101
|
+
.twh small { margin-left: 3px; font-size: 11px; font-weight: 600; color: var(--rhp-muted); }
|
|
102
|
+
@container (max-width: 420px) { .name { max-width: 6.5em; font-size: 13px; line-height: 1.1; white-space: normal; --rhp-label-gap: 8px; } .twh { font-size: 13px; --rhp-label-gap: 6px; } }
|
|
103
|
+
`,
|
|
104
|
+
}, (d) => {
|
|
105
|
+
// The picked source first, so it starts at 0; then the others in the key's order
|
|
106
|
+
const stack = createMemo(() => {
|
|
107
|
+
const keys = d.pick ? [d.pick, ...KEYS.filter((k) => k !== d.pick)] : KEYS;
|
|
108
|
+
const { from, to } = stackUp(keys.map((k) => d[k]));
|
|
109
|
+
return Object.fromEntries(keys.map((k, i) => [k, [from[i], to[i]]]));
|
|
110
|
+
});
|
|
111
|
+
return html`
|
|
112
|
+
<div aria-label=${() => `${d.country}: ` + SOURCES.map((s) => `${s.name.toLowerCase()} ${twh.format(d[s.key])}`).join(", ") + " TWh"}>
|
|
113
|
+
<${Label} edge="start" class="name">${() => d.country}<//>
|
|
114
|
+
<${Plot} overlap=${true} source=${KEYS} color=${SOURCES.map((s) => s.color)} pick=${() => d.pick}
|
|
115
|
+
from=${() => KEYS.map((k) => stack()[k][0])} to=${() => KEYS.map((k) => stack()[k][1])}>${Layer}<//>
|
|
116
|
+
<${Label} edge="end" class="twh">${() => twh.format(d.pick ? d[d.pick] : total(d))}<small>TWh</small><//>
|
|
117
|
+
</div>`;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Chart component: picking a source (in the key or on a bar) moves it to the start of every bar and ranks the countries by it
|
|
121
|
+
function PowerMix() {
|
|
122
|
+
const [pick, setPick] = createSignal(null);
|
|
123
|
+
const choose = (e) => {
|
|
124
|
+
const key = e.target.closest("[data-source]")?.dataset.source;
|
|
125
|
+
if (key) setPick((p) => (p === key ? null : key));
|
|
126
|
+
};
|
|
127
|
+
return html`
|
|
128
|
+
<${Poster}
|
|
129
|
+
kicker="Europe's electricity, by source"
|
|
130
|
+
title="France runs on nuclear, Norway on water and Poland on coal"
|
|
131
|
+
dek="Electricity generated in 2025, in terawatt-hours. Pick a source to line it up at the start of every bar and rank the countries by it; pick it again to go back to the totals."
|
|
132
|
+
note="Source: Ember's yearly electricity data for 2025, rounded to whole terawatt-hours. Bioenergy, oil and other sources are left out."
|
|
133
|
+
onClick=${choose}>
|
|
134
|
+
<div class="pylons" aria-hidden="true"></div>
|
|
135
|
+
<div class="key" role="group" aria-label="Pick a source">
|
|
136
|
+
${SOURCES.map((s) => html`
|
|
137
|
+
<button type="button" data-source=${s.key} aria-pressed=${() => pick() === s.key}><i style=${"background: " + s.color}></i>${s.name}</button>`)}
|
|
138
|
+
</div>
|
|
139
|
+
<${Chart} scale=${[scale.min, scale.max]} ticks=${scale.ticks} format=${(v) => twh.format(v)}
|
|
140
|
+
label="Electricity generated by source in eight European countries in 2025, in terawatt-hours"
|
|
141
|
+
theme=${{ font: '"Public Sans", "Segoe UI", system-ui, sans-serif', ink: "#1b1d21", muted: "#686b72", grid: "#d2d0c8" }}>
|
|
142
|
+
<${Plot} rows=${COUNTRIES} key="country" pick=${pick} order=${() => sortBy((d) => (pick() ? d[pick()] : total(d)), "desc")}>${Country}<//>
|
|
143
|
+
<//>
|
|
144
|
+
<//>`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
render(() => html`<${PowerMix} />`, document.getElementById("chart"));
|
|
148
|
+
</script>
|
|
149
|
+
</body>
|
|
150
|
+
</html>
|