@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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +221 -2
  3. package/bin/rhp-mcp.js +125 -0
  4. package/package.json +62 -4
  5. package/skill/SKILL.md +363 -0
  6. package/skill/recipes/area.html +150 -0
  7. package/skill/recipes/bar.html +139 -0
  8. package/skill/recipes/box-plot.html +158 -0
  9. package/skill/recipes/bubble.html +182 -0
  10. package/skill/recipes/bullet.html +132 -0
  11. package/skill/recipes/candlestick.html +173 -0
  12. package/skill/recipes/column.html +125 -0
  13. package/skill/recipes/diverging-bars.html +144 -0
  14. package/skill/recipes/donut.html +225 -0
  15. package/skill/recipes/dumbbell.html +131 -0
  16. package/skill/recipes/gantt.html +186 -0
  17. package/skill/recipes/grouped-bars.html +136 -0
  18. package/skill/recipes/heatmap.html +177 -0
  19. package/skill/recipes/histogram.html +193 -0
  20. package/skill/recipes/line.html +164 -0
  21. package/skill/recipes/live.html +249 -0
  22. package/skill/recipes/lollipop.html +146 -0
  23. package/skill/recipes/multi-line.html +223 -0
  24. package/skill/recipes/pyramid.html +156 -0
  25. package/skill/recipes/race.html +272 -0
  26. package/skill/recipes/radial-bars.html +151 -0
  27. package/skill/recipes/scatter.html +193 -0
  28. package/skill/recipes/slope.html +193 -0
  29. package/skill/recipes/sparklines.html +169 -0
  30. package/skill/recipes/stacked-100.html +152 -0
  31. package/skill/recipes/stacked-bars.html +144 -0
  32. package/skill/recipes/strip.html +215 -0
  33. package/skill/recipes/violin.html +163 -0
  34. package/skill/recipes/waffle.html +159 -0
  35. package/skill/recipes/waterfall.html +121 -0
  36. package/skill/references/api.md +1556 -0
  37. package/skill/references/design.md +763 -0
  38. package/skill/references/environments.md +1570 -0
  39. package/skill/references/forms.md +160 -0
  40. package/skill/references/interaction.md +792 -0
  41. package/skill/references/pitfalls.md +157 -0
  42. package/src/check/README.md +163 -0
  43. package/src/check/browser.js +92 -0
  44. package/src/check/build.js +439 -0
  45. package/src/check/colors.js +324 -0
  46. package/src/check/findings.js +247 -0
  47. package/src/check/index.js +419 -0
  48. package/src/check/inpage.js +1268 -0
  49. package/src/check/interact.js +266 -0
  50. package/src/check/png.js +98 -0
  51. package/src/check/report.js +103 -0
  52. package/src/check/source.js +387 -0
  53. package/src/check/sourcemap.js +75 -0
  54. package/src/guard/note.js +30 -0
  55. package/src/guard/render.js +17 -0
  56. package/src/guard/rhp.js +608 -0
  57. package/src/guard/rules.js +125 -0
  58. package/src/server.js +265 -0
  59. 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>