@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,164 @@
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>Carbon dioxide is up a third</title>
7
+ <meta name="description" content="Line chart of one series over time on a second axis (years along the scale, values across it), with a callout that follows the pointer, a tap or the arrow keys. Use it for one measure over time.">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,400..800&family=JetBrains+Mono:wght@400;600&display=swap">
11
+ <script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>
12
+ <style>
13
+ * { box-sizing: border-box; }
14
+ body { margin: 0; padding: 48px 16px; background: #c7c9ca; font-family: Archivo, "Helvetica Neue", Arial, sans-serif; }
15
+ /* The poster: basalt black, with the contour lines of a volcano seen from above in one corner */
16
+ .poster { max-width: 960px; margin: 0 auto; padding: 48px 52px 30px; border-radius: 4px; color: #f2efe9;
17
+ background: linear-gradient(transparent 40%, #121314) no-repeat top right / 62% 300px,
18
+ repeating-radial-gradient(ellipse 120% 90% at 100% 0%, transparent 0 25px, rgb(255 255 255 / .11) 25px 26px) no-repeat top right / 62% 300px, #121314;
19
+ box-shadow: 0 30px 60px -30px rgb(20 22 24 / .7); }
20
+ .poster figcaption { display: grid; gap: 14px; margin-bottom: 30px; }
21
+ .poster .kicker { font: 600 12px/1.3 "JetBrains Mono", ui-monospace, Menlo, monospace; letter-spacing: .12em; text-transform: uppercase; color: #ff8a5c; }
22
+ .poster .headline { max-width: 15em; font-size: 54px; font-weight: 800; font-stretch: 112%; line-height: .98; letter-spacing: -.02em; text-wrap: balance; }
23
+ .poster .dek { max-width: 56ch; font-size: 17px; line-height: 1.5; color: #b9b4ac; }
24
+ .poster .dek b { font-weight: 600; color: #f2efe9; }
25
+ .poster .note { display: block; margin-top: 22px; padding-top: 12px; border-top: 1px solid rgb(255 255 255 / .14); font-size: 12px; line-height: 1.5; color: #9c978f; }
26
+ /* The chart's box: a finger dragging sideways moves through the years, up and down still scrolls */
27
+ .plot { touch-action: pan-y; border-radius: 4px; }
28
+ .plot:has(:focus-visible) { outline: 1px solid rgb(255 255 255 / .4); outline-offset: 10px; }
29
+ @media (max-width: 600px) {
30
+ body { padding: 16px 16px 32px; }
31
+ .poster { padding: 26px 18px 18px; background-size: 100% 260px, 100% 260px, auto; }
32
+ .poster .headline { font-size: 32px; }
33
+ .poster .dek { font-size: 15px; }
34
+ }
35
+ </style>
36
+ </head>
37
+ <body>
38
+ <div id="chart"></div>
39
+ <script type="module">
40
+ import { Chart, Plot, Scale, Line, Dot, Tick, Label, Place, Poster, slat, html, render, createSignal } from "@bezda/rhp/standalone";
41
+
42
+ // Data: yearly mean CO2 at Mauna Loa in parts per million, NOAA GML. Replace with your own series (x values sorted).
43
+ const PPM = [
44
+ 316.91, 317.64, 318.45, 318.99, 319.62, 320.04, 321.37, 322.18, 323.05, 324.62, // 1960s
45
+ 325.68, 326.32, 327.46, 329.68, 330.19, 331.13, 332.03, 333.84, 335.41, 336.84, // 1970s
46
+ 338.76, 340.12, 341.48, 343.15, 344.87, 346.35, 347.61, 349.31, 351.69, 353.2, // 1980s
47
+ 354.45, 355.7, 356.54, 357.21, 358.96, 360.97, 362.74, 363.88, 366.84, 368.54, // 1990s
48
+ 369.71, 371.32, 373.45, 375.98, 377.7, 379.98, 382.09, 384.02, 385.83, 387.64, // 2000s
49
+ 390.1, 391.85, 394.06, 396.74, 398.81, 401.01, 404.41, 406.76, 408.72, 411.65, // 2010s
50
+ 414.21, 416.41, 418.53, 421.08, 424.61, // 2020 to 2024
51
+ ];
52
+ const YEARS = PPM.map((_, i) => 1960 + i);
53
+ const FIRST = YEARS[0];
54
+ const LAST = YEARS.at(-1);
55
+
56
+ const ppm = new Intl.NumberFormat("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
57
+ const change = new Intl.NumberFormat("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1, signDisplay: "always" });
58
+ const theme = { font: '"JetBrains Mono", ui-monospace, Menlo, monospace', ink: "#f2efe9", muted: "#9c978f", grid: "rgb(255 255 255 / .12)", series: ["#ff7a45"] };
59
+
60
+ // Slat types
61
+ // The curve: one Line through every [year, ppm] point, on the Chart's second axis (cross)
62
+ const Curve = slat({ css: `.curve { stroke-width: 2.5px; }` }, (d) => html`
63
+ <div><${Line} points=${() => d.points} class="curve" /></div>`);
64
+
65
+ // The scale under the plot: a short mark and the year at each decade (every other decade on a phone), the first and
66
+ // the last kept inside the plot
67
+ const Decade = slat({
68
+ room: { after: 28 },
69
+ css: `
70
+ .mark { top: 100%; height: 6px; translate: -50% 0; --rhp-tick-width: 1px; background: rgb(255 255 255 / .35); }
71
+ .year { top: calc(100% + 10px); bottom: auto; translate: -50% 0; --rhp-label-gap: 0px; font-size: 11px; color: var(--rhp-muted); }
72
+ .first .year { translate: 0 0; }
73
+ .last .year { translate: -100% 0; }
74
+ @container (max-width: 480px) { .odd { display: none; } }
75
+ `,
76
+ }, (t) => html`
77
+ <div class=${() => (t.first ? "first" : t.last ? "last" : "")}>
78
+ <${Tick} at=${() => t.at} class="mark" />
79
+ <${Label} at=${() => t.at} class=${() => ((t.at / 10) % 2 ? "year odd" : "year")}>${() => t.at}<//>
80
+ </div>`);
81
+
82
+ // One invisible column per year: what the pointer, a tap and the keyboard pick, and what a screen reader reads
83
+ const Year = slat({
84
+ room: {},
85
+ css: `
86
+ .year { cursor: crosshair; outline: none; }
87
+ .say { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
88
+ `,
89
+ }, (d) => html`
90
+ <div class="year" data-i=${() => d.index}><span class="say">${() => `${d.year}: ${ppm.format(d.ppm)} parts per million`}</span></div>`);
91
+
92
+ // The year picked: a hairline, a dot on the curve, and a callout in the band above the plot (across 0 is the plot's top edge)
93
+ const Reading = slat({
94
+ room: { before: 78, end: 12 },
95
+ css: `
96
+ .hair { --rhp-tick-width: 1px; background: rgb(255 255 255 / .3); }
97
+ .dot { background: #fff; box-shadow: 0 0 0 3px #121314, 0 0 0 5px var(--rhp-series-1); }
98
+ .callout { position: absolute; bottom: 0; left: 0; translate: calc(var(--rhp-p) * -100%) 0; display: grid; gap: 1px;
99
+ padding: 7px 11px 8px; border-radius: 6px; white-space: nowrap; background: #25272a; box-shadow: inset 0 0 0 1px rgb(255 255 255 / .14); }
100
+ .callout small { font-size: 11px; font-weight: 600; letter-spacing: .08em; color: var(--rhp-muted); }
101
+ .callout b { font: 700 21px/1.1 Archivo, "Helvetica Neue", Arial, sans-serif; font-stretch: 105%; }
102
+ .callout span { font-size: 12px; color: var(--rhp-series-1); }
103
+ `,
104
+ }, (d) => html`
105
+ <div>
106
+ <${Tick} at=${() => d.year} thick=${1} class="hair" />
107
+ <${Dot} at=${() => d.year} cross=${() => d.ppm} size="11px" class="dot" />
108
+ <${Place} at=${() => d.year} across=${0}>
109
+ <div class="callout">
110
+ <small>${() => d.year}</small>
111
+ <b>${() => ppm.format(d.ppm)} ppm</b>
112
+ <span>${() => (d.year === FIRST ? "where the record starts" : `${change.format(d.ppm - PPM[0])} since ${FIRST}`)}</span>
113
+ </div>
114
+ <//>
115
+ </div>`);
116
+
117
+ // Chart component: the callout shows the year the pointer, a tap or the arrow keys are on, and the latest otherwise
118
+ function CarbonChart() {
119
+ const [picked, setPicked] = createSignal(null);
120
+ const i = () => picked() ?? PPM.length - 1;
121
+ // The row of the slat an element is in, or null (between slats, on the axis or the text)
122
+ const rowOf = (el) => {
123
+ const root = el.closest("[data-i]");
124
+ return root ? +root.dataset.i : null;
125
+ };
126
+ // A mouse or a pen picks as it moves, and so does a finger dragging sideways; a tap or a click picks, the keyboard
127
+ // picks; on no slat the pick stays
128
+ const pick = (e) => {
129
+ const row = rowOf(e.target);
130
+ if (row != null) setPicked(row);
131
+ };
132
+ let start = [0, 0]; // where the last finger went down
133
+ // A finger that moves more sideways than up or down scrubs; one that moves up or down scrolls the page
134
+ const sideways = (e) => Math.abs(e.clientX - start[0]) > Math.abs(e.clientY - start[1]);
135
+ // Leaving the chart: back to the slat that has focus (a keyboard's or a click's pick), or none
136
+ const leave = (e) => {
137
+ if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
138
+ setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
139
+ };
140
+ return html`
141
+ <${Poster}
142
+ kicker="Mauna Loa Observatory, Hawaii"
143
+ title="The air holds a third more carbon dioxide than in 1960"
144
+ dek=${html`<span>Yearly average CO₂ at 3,397 metres on Mauna Loa, in parts per million (ppm). It rose every single year, and <b>the yearly rise nearly tripled</b>, from 0.9 ppm in the 1960s to 2.4 in the 2010s. Tap or point at a year, or drag along the line.</span>`}
145
+ note="Source: NOAA Global Monitoring Laboratory, Mauna Loa annual means (Scripps Institution of Oceanography before 1974), retrieved 2026.">
146
+ <div class="plot" onPointerDown=${(e) => (start = [e.clientX, e.clientY])}
147
+ onPointerMove=${(e) => (e.pointerType !== "touch" || sideways(e)) && pick(e)} onClick=${pick} onFocusIn=${pick}
148
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
149
+ <!-- half a year past each end, so the year columns are centered on their years -->
150
+ <${Chart} scale=${[FIRST - 0.5, LAST + 0.5]} cross=${[300, 450]} crossTicks=${[300, 350, 400, 450]} height=${320} theme=${theme}
151
+ label=${`Yearly mean carbon dioxide at Mauna Loa, ${FIRST} to ${LAST}, in parts per million`}>
152
+ <${Scale} ticks=${YEARS.filter((y) => y % 10 === 0)}>${Decade}<//>
153
+ <${Plot} overlap=${true} points=${[YEARS.map((y, i) => [y, PPM[i]])]}>${Curve}<//>
154
+ <${Plot} overlap=${true} slats=${1} year=${() => YEARS[i()]} ppm=${() => PPM[i()]} style=${{ "pointer-events": "none" }}>${Reading}<//>
155
+ <${Plot} orientation="vertical" keyboard=${true} year=${YEARS} ppm=${PPM}>${Year}<//>
156
+ <//>
157
+ </div>
158
+ <//>`;
159
+ }
160
+
161
+ render(() => html`<${CarbonChart} />`, document.getElementById("chart"));
162
+ </script>
163
+ </body>
164
+ </html>
@@ -0,0 +1,249 @@
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>Northgate carries a third</title>
7
+ <meta name="description" content="Live board: requests per second for six regions, each slat a line of the last minute that scrolls as readings arrive, the current value beside it, with pause and resume and a readout on hover, focus and tap. The lines move by rhp's JS version (animate) because the feed changes the data without pause. Use it for live or streaming data: metrics, sensors, prices, counts.">
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=Doto:wght@900&family=Martian+Mono:wght@400;600&family=Oxanium:wght@400..700&display=swap">
11
+ <script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>
12
+ <style>
13
+ * { box-sizing: border-box; }
14
+ body { margin: 0; padding: 48px 16px; background: #b3b7b8; font-family: Oxanium, "Eurostile", "Bahnschrift", "DIN Alternate", sans-serif; }
15
+ /* The poster: a dot-matrix display in a control room. Graphite glass with a grid of unlit dots, amber for the story */
16
+ .poster { max-width: 980px; margin: 0 auto; padding: 46px 52px 30px; color: #e9ece9; border-radius: 18px;
17
+ background: radial-gradient(circle, rgb(255 255 255 / .05) 1px, transparent 1.3px) 0 0 / 6px 6px, #0f1112;
18
+ box-shadow: inset 0 0 0 1px rgb(255 255 255 / .07), 0 0 0 8px #232628, 0 0 0 9px #3a3e41, 0 34px 60px -28px rgb(16 18 19 / .7); }
19
+ .poster figcaption { display: grid; gap: 14px; margin-bottom: 30px; }
20
+ .poster .kicker { display: flex; align-items: center; gap: 10px; font: 600 12px/1.3 "Martian Mono", ui-monospace, Menlo, monospace; letter-spacing: .14em; text-transform: uppercase; color: #a5adaf; }
21
+ .led { width: 9px; height: 9px; border-radius: 50%; background: #ffb22e; animation: blink 1.2s steps(2, jump-none) infinite; }
22
+ .paused .led { background: transparent; box-shadow: inset 0 0 0 1.5px #8f9799; animation: none; }
23
+ @keyframes blink { 50% { opacity: .25; } }
24
+ .poster .headline { max-width: 14em; font-size: 54px; font-weight: 700; line-height: 1; letter-spacing: -.01em; text-wrap: balance; }
25
+ .poster .headline em { font-style: normal; color: #ffb22e; }
26
+ .poster .dek { max-width: 58ch; font-size: 17px; line-height: 1.5; color: #b8bfc0; }
27
+ /* Above the board: one line that reads out the region in focus (fixed height), and the pause button */
28
+ .status { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
29
+ .readout { min-width: 0; height: 24px; margin: 0; font-size: 16px; line-height: 24px; white-space: nowrap; color: #b8bfc0; }
30
+ .readout b { font-weight: 600; color: #e9ece9; }
31
+ .readout em { font: normal 900 19px/24px Doto, "Martian Mono", ui-monospace, monospace; color: #e9ece9; }
32
+ .pause { flex: none; display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 14px; border: 1px solid rgb(255 255 255 / .28); border-radius: 17px;
33
+ font: 600 12px/1 "Martian Mono", ui-monospace, Menlo, monospace; letter-spacing: .1em; text-transform: uppercase; color: #e9ece9; background: transparent; cursor: pointer; }
34
+ .pause i { width: 10px; height: 10px; border-inline: 3px solid currentColor; }
35
+ .pause[aria-pressed="true"] { border-color: #ffb22e; color: #0f1112; background: #ffb22e; }
36
+ .pause:focus-visible { outline: 2px solid #ffb22e; outline-offset: 3px; }
37
+ .board { padding: 4px 0 2px; border-top: 1px solid rgb(255 255 255 / .12); }
38
+ .poster .note { display: block; margin-top: 20px; padding-top: 12px; border-top: 1px solid rgb(255 255 255 / .12); font-size: 12.5px; line-height: 1.5; color: #8f9799; }
39
+ @media (prefers-reduced-motion: reduce) { .led { animation: none; } }
40
+ @media (max-width: 640px) {
41
+ body { padding: 16px 16px 32px; }
42
+ .poster { padding: 28px 18px 18px; border-radius: 12px; }
43
+ .poster .headline { font-size: 34px; }
44
+ .poster .dek { font-size: 15px; }
45
+ .poster figcaption { margin-bottom: 22px; }
46
+ .readout { font-size: 14px; }
47
+ .readout em { font-size: 16px; }
48
+ .pause { width: 34px; padding: 0; justify-content: center; }
49
+ .pause span { display: none; }
50
+ }
51
+ </style>
52
+ </head>
53
+ <body>
54
+ <div id="chart"></div>
55
+ <script type="module">
56
+ import { Chart, Plot, Line, Dot, Label, Poster, slat, html, render, createSignal, createMemo, onCleanup } from "@bezda/rhp/standalone";
57
+
58
+ // Data: requests per second answered by each region of an API (invented names), one reading every 0.4 s, from a
59
+ // simulated feed. Replace connect() below with your own feed; the board shows the last WINDOW seconds.
60
+ const REGIONS = [
61
+ { name: "Northgate", base: 4200, story: true },
62
+ { name: "Eastport", base: 3100 },
63
+ { name: "Bayside", base: 1900 },
64
+ { name: "Highmoor", base: 1500 },
65
+ { name: "Westmere", base: 1300 },
66
+ { name: "Southby", base: 800 },
67
+ ];
68
+ const TICK = 400; // ms between readings
69
+ const WINDOW = 60; // seconds of history on the board
70
+ const PEAK = 6000; // the rate that fills a slat's height: your capacity, or a round number above the data
71
+ const INSET = 0.14; // the empty share of a slat's band, half above the line and half below
72
+ const AMBER = "#ffb22e";
73
+ const GRAY = "#7e898d";
74
+
75
+ // A seeded generator (mulberry32), so the simulated feed is the same on every load; never Math.random
76
+ function mulberry32(seed) {
77
+ return () => {
78
+ seed = (seed + 0x6d2b79f5) | 0;
79
+ let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
80
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
81
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
82
+ };
83
+ }
84
+ const random = mulberry32(2026);
85
+ let sent = 0;
86
+ const drift = REGIONS.map(() => 0);
87
+ const surge = REGIONS.map(() => 0);
88
+
89
+ // One simulated reading: each region's rate sways slowly, wanders, and now and then surges and fades
90
+ function simulate() {
91
+ const t = (sent++ * TICK) / 1000;
92
+ const rps = {};
93
+ REGIONS.forEach((r, i) => {
94
+ drift[i] = 0.85 * drift[i] + 0.04 * (random() * 2 - 1);
95
+ if (random() < 0.006) surge[i] = 0.2;
96
+ surge[i] *= 0.93;
97
+ const sway = 0.05 * Math.sin((2 * Math.PI * t) / 37 + i);
98
+ rps[r.name] = Math.round(r.base * (1 + sway + drift[i] + surge[i]));
99
+ });
100
+ return { t, rps };
101
+ }
102
+
103
+ // THE FEED: calls onReading({ t, rps }) for every reading, in time order (t in seconds, rps by region name), and
104
+ // returns a function that stops it (Pause calls it; Resume connects again). This one is simulated: it sends the last
105
+ // minute at once, then one reading every TICK ms.
106
+ // A real feed plugs in the same way, for example:
107
+ // const socket = new WebSocket("wss://example.com/traffic"); // WebSocket
108
+ // socket.onmessage = (e) => onReading(JSON.parse(e.data));
109
+ // return () => socket.close();
110
+ // const source = new EventSource("/traffic/stream"); // server-sent events
111
+ // source.onmessage = (e) => onReading(JSON.parse(e.data));
112
+ // return () => source.close();
113
+ // const poll = setInterval(async () => onReading(await (await fetch("/traffic/now")).json()), TICK); // polling
114
+ // return () => clearInterval(poll);
115
+ // With a real feed, t is the server's time in seconds (Date.now() / 1000), and the history starts empty or from a fetch.
116
+ function connect(onReading) {
117
+ while (sent * TICK <= WINDOW * 1000) onReading(simulate());
118
+ const timer = setInterval(() => onReading(simulate()), TICK);
119
+ return () => clearInterval(timer);
120
+ }
121
+
122
+ // The line of the last WINDOW seconds up to `now`, as [seconds before now, rate] points (cut where the window
123
+ // starts and where now falls between two readings), and the rate at now
124
+ const lerp = (a, b, t) => a[1] + ((b[1] - a[1]) * (t - a[0])) / (b[0] - a[0]);
125
+ function lastMinute(trace, now) {
126
+ const from = now - WINDOW;
127
+ const points = [];
128
+ for (let i = 0; i < trace.length; i++) {
129
+ const [t, v] = trace[i];
130
+ const before = trace[i - 1];
131
+ if (before && before[0] < from && t > from) points.push([-WINDOW, lerp(before, trace[i], from)]);
132
+ if (t > now) {
133
+ const at = before ? lerp(before, trace[i], now) : v;
134
+ points.push([0, at]);
135
+ return { points, at };
136
+ }
137
+ if (t >= from) points.push([Math.max(-WINDOW, t - now), v]);
138
+ }
139
+ return { points, at: trace.at(-1)?.[1] ?? 0 };
140
+ }
141
+ const across = (v) => INSET / 2 + (1 - INSET) * (1 - Math.min(1, v / PEAK)); // a rate's place across the band, as the Line draws it
142
+ const rate = new Intl.NumberFormat("en-US");
143
+
144
+ // Slat type: the region, its last minute as a line that ends in a dot at now, and its latest reading in LED digits.
145
+ // The digits are monospaced and the reading keeps a width of five of them, so the room "auto" measures never changes
146
+ // and nothing moves sideways while the numbers change.
147
+ const Region = slat({
148
+ thickness: { horizontal: 62 },
149
+ inset: INSET,
150
+ room: "auto",
151
+ css: `
152
+ .region { outline: none; border-bottom: 1px solid rgb(255 255 255 / .07); }
153
+ .wash { position: absolute; inset: 0 -8px; border-radius: 6px; background: rgb(255 255 255 / .06); opacity: 0; transition: opacity .2s; }
154
+ .on .wash, .region:focus-visible .wash { opacity: 1; }
155
+ .region:focus-visible .wash { box-shadow: inset 0 0 0 1px rgb(255 255 255 / .4); }
156
+ .name { font: 600 16px Oxanium, "Eurostile", "Bahnschrift", sans-serif; color: #e9ece9; }
157
+ .trace { stroke-width: 1.75px; }
158
+ .pen { box-shadow: 0 0 0 3px #0f1112; }
159
+ .rate { --rhp-label-gap: 14px; min-width: 5ch; text-align: right; font: 900 25px/1 Doto, "Martian Mono", ui-monospace, monospace; color: #e9ece9; }
160
+ .story .name, .story .rate { color: #ffb22e; }
161
+ @container (max-width: 480px) { .name { font-size: 14px; } .rate { font-size: 20px; --rhp-label-gap: 8px; } }
162
+ @media (prefers-reduced-motion: reduce) { .wash { transition: none; } }
163
+ `,
164
+ }, (d) => {
165
+ const minute = createMemo(() => lastMinute(d.trace, d.now)); // once a frame, shared by the line and the dot
166
+ const color = () => (d.story ? AMBER : GRAY);
167
+ return html`
168
+ <div class=${() => `region${d.story ? " story" : ""}${d.on ? " on" : ""}`} data-row=${() => d.index} aria-label=${() => d.say}>
169
+ <span class="wash"></span>
170
+ <${Label} edge="start" class="name">${() => d.name}<//>
171
+ <${Line} points=${() => minute().points} peak=${PEAK} fill=${true} color=${color} class="trace" />
172
+ <${Dot} at=${0} across=${() => across(minute().at)} size="8px" color=${color} class="pen" />
173
+ <${Label} edge="end" class="rate">${() => rate.format(d.rps)}<//>
174
+ </div>`;
175
+ });
176
+
177
+ // Chart component: the readings are the app's state; the feed adds to them until it is paused
178
+ function TrafficBoard() {
179
+ const [readings, setReadings] = createSignal([]);
180
+ const keep = (r) => setReadings((list) => [...list.filter((x) => x.t >= r.t - WINDOW - 2), r]);
181
+ let stop = connect(keep); // before the board is drawn, so the first minute is there from the start
182
+ const [live, setLive] = createSignal(true);
183
+ const toggle = () => {
184
+ if (live()) stop();
185
+ else stop = connect(keep);
186
+ setLive(!live());
187
+ };
188
+ onCleanup(() => stop());
189
+
190
+ const latest = () => readings().at(-1);
191
+ const traces = createMemo(() => REGIONS.map((r) => readings().map((x) => [x.t, x.rps[r.name]])));
192
+ const rps = createMemo(() => REGIONS.map((r) => latest()?.rps[r.name] ?? 0));
193
+ const total = () => rps().reduce((sum, v) => sum + v, 0);
194
+ const say = () => REGIONS.map((r, i) => `${r.name}: ${rate.format(rps()[i])} requests a second`);
195
+
196
+ const [picked, setPicked] = createSignal(null);
197
+ // The row of the slat an element is in, or null (between slats, on the axis or the text)
198
+ const rowOf = (el) => {
199
+ const root = el.closest("[data-row]");
200
+ return root ? +root.dataset.row : null;
201
+ };
202
+ // A mouse or a pen picks as it moves, a tap or a click picks, the keyboard picks; on no slat the pick stays
203
+ const pick = (e) => {
204
+ const row = rowOf(e.target);
205
+ if (row != null) setPicked(row);
206
+ };
207
+ // Leaving the board: back to the slat that has focus (a keyboard's or a click's pick), or none
208
+ const leave = (e) => {
209
+ if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
210
+ setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
211
+ };
212
+ const readout = () => {
213
+ const i = picked();
214
+ if (i == null) return html`<b>All regions</b>: <em>${rate.format(total())}</em> requests a second`;
215
+ return html`<b>${REGIONS[i].name}</b>: <em>${rate.format(rps()[i])}</em> a second, ${Math.round((rps()[i] / (total() || 1)) * 100)}% of all`;
216
+ };
217
+
218
+ // JS version: the feed changes the data every TICK ms without pause, so the time `now` moves on rhp's clock, at an
219
+ // even speed: every line slides left without stopping, carried from one reading into the next. A move lasts one and a
220
+ // half intervals, so a reading that comes up to half an interval late (a real network) slows the lines instead of
221
+ // stopping them. The scale stays still (always the last minute), so animate goes on the Plot, naming the group.
222
+ return html`
223
+ <${Poster}
224
+ class=${() => (live() ? "" : "paused")}
225
+ kicker=${html`<i class="led"></i><span>API traffic, ${() => (live() ? "live" : "paused")}</span>`}
226
+ title=${html`<em>Northgate</em> carries a third of the traffic`}
227
+ dek="Requests per second answered by each region of the API over the last minute. A reading arrives every 0.4 seconds and the lines move left; the dot at the end of each line is now."
228
+ note="Illustrative data: invented regions and a simulated feed, the same on every load. Point at a region, tap it, or Tab into the board and use the arrow keys.">
229
+ <div class="status">
230
+ <p class="readout">${readout}</p>
231
+ <button type="button" class="pause" aria-label="Pause" aria-pressed=${() => String(!live())} onClick=${toggle}><i></i><span>Pause</span></button>
232
+ </div>
233
+ <div class="board"
234
+ onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
235
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
236
+ <${Chart} scale=${[-WINDOW, 0]} ticks=${[1, 0.75, 0.5, 0.25, 0].map((k) => -k * WINDOW)} format=${(v) => (v === 0 ? "now" : `${-v}s`)}
237
+ label="Requests per second by region over the last minute"
238
+ theme=${{ font: '"Martian Mono", ui-monospace, Menlo, monospace', ink: "#e9ece9", muted: "#8f9799", grid: "rgb(255 255 255 / .08)" }}>
239
+ <${Plot} keyboard=${true} rows=${REGIONS} trace=${traces} now=${() => latest()?.t} rps=${rps} say=${say}
240
+ on=${(d) => d.index === picked()} animate=${{ groups: ["now"], duration: TICK * 1.5, ease: "linear" }}>${Region}<//>
241
+ <//>
242
+ </div>
243
+ <//>`;
244
+ }
245
+
246
+ render(() => html`<${TrafficBoard} />`, document.getElementById("chart"));
247
+ </script>
248
+ </body>
249
+ </html>
@@ -0,0 +1,146 @@
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>The price of a Big Mac</title>
7
+ <meta name="description" content="Lollipop chart: ranked values as a thin stem and a dot, with a reference line and a switch between the values and their difference from the reference. Use it for a ranked list of prices, scores or rates, lighter than bars.">
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=Space+Mono:wght@400;700&display=swap">
11
+ <script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>
12
+ <style>
13
+ * { box-sizing: border-box; }
14
+ body { margin: 0; padding: 48px 16px; background: radial-gradient(90% 60% at 50% 30%, #4a4844, #2a2927); font-family: "Space Mono", ui-monospace, Menlo, Consolas, monospace; }
15
+ /* The poster: a till receipt with torn edges, printed in black and the register's red */
16
+ #chart { max-width: 760px; margin: 0 auto; filter: drop-shadow(0 22px 26px rgb(10 9 8 / .45)); }
17
+ .poster { --tooth: 9px; display: grid; margin: 0; padding: 46px 46px 34px; color: #232220; text-align: center; background: #fbfaf7;
18
+ mask: linear-gradient(#000 0 0) 0 var(--tooth) / 100% calc(100% - 2 * var(--tooth)) no-repeat,
19
+ conic-gradient(from 135deg at top, #0000, #000 1deg 89deg, #0000 90deg) top / calc(2 * var(--tooth)) var(--tooth) repeat-x,
20
+ conic-gradient(from -45deg at bottom, #0000, #000 1deg 89deg, #0000 90deg) bottom / calc(2 * var(--tooth)) var(--tooth) repeat-x; }
21
+ .poster > * { min-width: 0; }
22
+ .poster figcaption { display: grid; gap: 12px; padding-bottom: 20px; margin-bottom: 18px; border-bottom: 2px dashed #232220; }
23
+ .poster .kicker { font-size: 12px; letter-spacing: .14em; text-transform: uppercase; }
24
+ .poster .headline { font-size: 34px; font-weight: 700; line-height: 1.12; letter-spacing: -.03em; text-transform: uppercase; text-wrap: balance; }
25
+ .poster .headline em { font-style: normal; color: #c42f1d; }
26
+ .poster .dek { max-width: 54ch; margin: 0 auto; font-size: 14px; line-height: 1.55; color: #4c4a46; }
27
+ .poster .note { display: block; margin-top: 20px; padding-top: 16px; border-top: 2px dashed #232220; font-size: 11.5px; line-height: 1.55; color: #4c4a46; }
28
+ /* A barcode at the foot of the receipt */
29
+ .poster .note::after { content: ""; display: block; width: 190px; height: 34px; margin: 16px auto 0;
30
+ background: repeating-linear-gradient(90deg, #232220 0 2px, #0000 2px 4px, #232220 4px 5px, #0000 5px 8px, #232220 8px 11px, #0000 11px 12px, #232220 12px 13px, #0000 13px 17px); }
31
+ .switch { display: inline-flex; justify-self: center; margin-bottom: 12px; border: 2px solid #232220; border-radius: 6px; }
32
+ .switch button { padding: 7px 12px; border: 0; background: none; font: 700 12px/1.2 "Space Mono", ui-monospace, Menlo, monospace; letter-spacing: .04em; text-transform: uppercase; color: #232220; cursor: pointer; }
33
+ .switch button[aria-pressed="true"] { background: #232220; color: #fbfaf7; }
34
+ .switch button:focus-visible { outline: 2px solid #c42f1d; outline-offset: 2px; }
35
+ .legend { margin: 0 0 6px; font-size: 12px; color: #4c4a46; }
36
+ .legend i { display: inline-block; width: 10px; height: 10px; margin-right: 6px; border-radius: 50%; background: #c42f1d; vertical-align: -1px; }
37
+ @media (max-width: 520px) {
38
+ body { padding: 24px 16px 32px; }
39
+ .poster { padding: 36px 16px 26px; }
40
+ .poster .headline { font-size: 24px; }
41
+ .poster .dek { font-size: 13px; }
42
+ .switch button { padding: 7px 9px; font-size: 11px; }
43
+ }
44
+ </style>
45
+ </head>
46
+ <body>
47
+ <div id="chart"></div>
48
+ <script type="module">
49
+ import { Chart, Plot, Bar, Dot, Tick, Label, Poster, slat, sortBy, nice, html, render, createSignal, createMemo } from "@bezda/rhp/standalone";
50
+
51
+ // Data: the price of a Big Mac in each country in US dollars, from The Economist's Big Mac index of July 2026
52
+ // (local prices at that month's exchange rates), rounded to the cent. Replace with your own.
53
+ // BASE is the country the others are compared with.
54
+ const BURGERS = [
55
+ { country: "Switzerland", price: 9.04 },
56
+ { country: "Uruguay", price: 8.94 },
57
+ { country: "Norway", price: 8.05 },
58
+ { country: "Britain", price: 7.4 },
59
+ { country: "Mexico", price: 6.27 },
60
+ { country: "USA", price: 6.22 },
61
+ { country: "Brazil", price: 4.71 },
62
+ { country: "China", price: 3.91 },
63
+ { country: "South Africa", price: 3.49 },
64
+ { country: "Japan", price: 3.08 },
65
+ { country: "Taiwan", price: 2.42 },
66
+ { country: "Indonesia", price: 2.38 },
67
+ ];
68
+ const BASE = "USA";
69
+ const INK = "#232220", RED = "#c42f1d";
70
+
71
+ const base = BURGERS.find((b) => b.country === BASE).price;
72
+ const diff = (price) => (price / base - 1) * 100; // % above or below the base price
73
+ const prices = BURGERS.map((b) => b.price), diffs = prices.map(diff);
74
+ const dollars = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
75
+ const wholeDollars = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
76
+ const signed = new Intl.NumberFormat("en-US", { style: "percent", signDisplay: "exceptZero", maximumFractionDigits: 0 });
77
+ // The two views: a scale fitted to each with round ticks, and how its numbers read
78
+ const VIEWS = {
79
+ price: { name: "Price in US$", scale: nice(0, Math.max(...prices), 5), value: (p) => p, text: dollars.format, tick: wholeDollars.format },
80
+ diff: { name: "Vs. the US price", scale: nice(Math.min(...diffs), Math.max(...diffs), 5), value: diff, text: (v) => signed.format(v / 100), tick: (v) => signed.format(v / 100) },
81
+ };
82
+
83
+ // Slat types: a country is its name, a dotted stem like a receipt's leader dots, a dot, and its value in the price
84
+ // column; red is cheaper than the base. The base line is one slat on top of the others.
85
+ const Country = slat({
86
+ thickness: 34,
87
+ room: { start: "auto", end: 64 },
88
+ css: `
89
+ .name { font-size: 13px; --rhp-label-gap: 14px; }
90
+ .stem { background: radial-gradient(circle, var(--rhp-color) 1.2px, transparent 1.7px) 0 50% / 7px 100% repeat-x; }
91
+ .pop { box-shadow: 0 0 0 3px #fbfaf7; }
92
+ .price { justify-self: end; font-size: 13px; font-weight: 700; text-align: right; font-variant-numeric: tabular-nums; }
93
+ .cheap .price { color: #c42f1d; }
94
+ .base .name, .base .price { font-weight: 700; }
95
+ @container (max-width: 420px) { .name, .price { font-size: 12px; } }
96
+ `,
97
+ }, (d) => html`
98
+ <div class=${() => (d.country === BASE ? "base" : d.price < base ? "cheap" : "")}>
99
+ <${Label} edge="start" class="name">${() => d.country}<//>
100
+ <${Bar} to=${() => d.value} thick="4px" color=${() => d.ink} class="stem" />
101
+ <${Dot} at=${() => d.value} size="13px" color=${() => d.ink} class="pop" />
102
+ <${Label} edge="end" class="price">${() => d.text}<//>
103
+ </div>`);
104
+
105
+ const BaseLine = slat({
106
+ room: { before: 24 },
107
+ css: `
108
+ .rule { --rhp-tick-width: 2px; background: repeating-linear-gradient(0deg, var(--rhp-color) 0 5px, transparent 5px 9px); }
109
+ .tag { top: 0; translate: -50% -100%; padding: 0 0 5px; font-size: 11px; font-weight: 700; letter-spacing: .06em; }
110
+ `,
111
+ }, (d) => html`
112
+ <div>
113
+ <${Tick} at=${() => d.at} thick=${1} color=${INK} class="rule" />
114
+ <${Label} at=${() => d.at} class="tag">${() => d.text}<//>
115
+ </div>`);
116
+
117
+ // Chart component: the switch moves every dot from its price to its difference from the base, on a new scale
118
+ function BigMacs() {
119
+ const [view, setView] = createSignal("price");
120
+ const v = createMemo(() => VIEWS[view()]);
121
+ return html`
122
+ <${Poster}
123
+ kicker="The Big Mac index · 12 countries"
124
+ title=${html`<span>A Big Mac costs <em>nearly four times</em> as much in Switzerland as in Indonesia</span>`}
125
+ dek="The same burger, priced in each country and converted to US dollars. Where it is cheap, the local currency buys more than the exchange rate says."
126
+ note="Source: The Economist, Big Mac index, July 2026: local prices converted at that month's exchange rates, rounded to the cent. Switch the view to compare each price with the US price.">
127
+ <div class="switch" role="group" aria-label="View">
128
+ ${Object.keys(VIEWS).map((k) => html`<button type="button" aria-pressed=${() => view() === k} onClick=${() => setView(k)}>${VIEWS[k].name}</button>`)}
129
+ </div>
130
+ <p class="legend"><i></i>cheaper than in the US</p>
131
+ <${Chart} scale=${() => [v().scale.min, v().scale.max]} ticks=${() => v().scale.ticks} format=${(t) => v().tick(t)}
132
+ style=${{ "--rhp-length-time": ".5s", "--rhp-length-ease": "cubic-bezier(.3, 0, .2, 1)" }}
133
+ label="Price of a Big Mac by country, in US dollars"
134
+ theme=${{ font: "'Space Mono', ui-monospace, Menlo, monospace", ink: INK, muted: "#6b6964", grid: "#e3e1dc" }}>
135
+ <${Plot} rows=${BURGERS} order=${sortBy("price", "desc")} value=${(d) => v().value(d.price)} text=${(d) => v().text(v().value(d.price))}
136
+ ink=${(d) => (d.price < base ? RED : INK)}>${Country}<//>
137
+ <${Plot} overlap=${true} slats=${1} at=${() => v().value(base)} text=${() => (view() === "price" ? "US " + dollars.format(base) : "US PRICE")}
138
+ style=${{ "pointer-events": "none" }}>${BaseLine}<//>
139
+ <//>
140
+ <//>`;
141
+ }
142
+
143
+ render(() => html`<${BigMacs} />`, document.getElementById("chart"));
144
+ </script>
145
+ </body>
146
+ </html>