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