@bezda/rhp-mcp 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +221 -2
- package/bin/rhp-mcp.js +125 -0
- package/package.json +62 -4
- package/skill/SKILL.md +363 -0
- package/skill/recipes/area.html +150 -0
- package/skill/recipes/bar.html +139 -0
- package/skill/recipes/box-plot.html +158 -0
- package/skill/recipes/bubble.html +182 -0
- package/skill/recipes/bullet.html +132 -0
- package/skill/recipes/candlestick.html +173 -0
- package/skill/recipes/column.html +125 -0
- package/skill/recipes/diverging-bars.html +144 -0
- package/skill/recipes/donut.html +225 -0
- package/skill/recipes/dumbbell.html +131 -0
- package/skill/recipes/gantt.html +186 -0
- package/skill/recipes/grouped-bars.html +136 -0
- package/skill/recipes/heatmap.html +177 -0
- package/skill/recipes/histogram.html +193 -0
- package/skill/recipes/line.html +164 -0
- package/skill/recipes/live.html +249 -0
- package/skill/recipes/lollipop.html +146 -0
- package/skill/recipes/multi-line.html +223 -0
- package/skill/recipes/pyramid.html +156 -0
- package/skill/recipes/race.html +272 -0
- package/skill/recipes/radial-bars.html +151 -0
- package/skill/recipes/scatter.html +193 -0
- package/skill/recipes/slope.html +193 -0
- package/skill/recipes/sparklines.html +169 -0
- package/skill/recipes/stacked-100.html +152 -0
- package/skill/recipes/stacked-bars.html +144 -0
- package/skill/recipes/strip.html +215 -0
- package/skill/recipes/violin.html +163 -0
- package/skill/recipes/waffle.html +159 -0
- package/skill/recipes/waterfall.html +121 -0
- package/skill/references/api.md +1556 -0
- package/skill/references/design.md +763 -0
- package/skill/references/environments.md +1570 -0
- package/skill/references/forms.md +160 -0
- package/skill/references/interaction.md +792 -0
- package/skill/references/pitfalls.md +157 -0
- package/src/check/README.md +163 -0
- package/src/check/browser.js +92 -0
- package/src/check/build.js +439 -0
- package/src/check/colors.js +324 -0
- package/src/check/findings.js +247 -0
- package/src/check/index.js +419 -0
- package/src/check/inpage.js +1268 -0
- package/src/check/interact.js +266 -0
- package/src/check/png.js +98 -0
- package/src/check/report.js +103 -0
- package/src/check/source.js +387 -0
- package/src/check/sourcemap.js +75 -0
- package/src/guard/note.js +30 -0
- package/src/guard/render.js +17 -0
- package/src/guard/rhp.js +608 -0
- package/src/guard/rules.js +125 -0
- package/src/server.js +265 -0
- package/src/skill.js +140 -0
|
@@ -0,0 +1,173 @@
|
|
|
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>A 15% drop erased the month's gains</title>
|
|
7
|
+
<meta name="description" content="Candlestick chart of one month of daily share prices: a wick from each day's low to its high and a body from its open to its close (hollow when it rose, filled when it fell), with a crosshair and a price readout for the picked day. Use it for open, high, low and close prices over days.">
|
|
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=Cormorant+Garamond:ital,wght@0,500..700;1,500..600&family=IBM+Plex+Mono:wght@400;500;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
|
+
:root { color-scheme: light; }
|
|
14
|
+
* { box-sizing: border-box; }
|
|
15
|
+
body { margin: 0; padding: 48px 16px; background: #27292c; font-family: "Cormorant Garamond", Georgia, serif; color: #22201b; }
|
|
16
|
+
/* The poster: an engraved share certificate, ivory paper inside a double rule */
|
|
17
|
+
.poster { max-width: 960px; margin: 0 auto; padding: 50px 56px 34px; line-height: 1.4;
|
|
18
|
+
background: repeating-linear-gradient(45deg, rgb(80 64 32 / .028) 0 1px, transparent 1px 5px), #f5efe0;
|
|
19
|
+
box-shadow: inset 0 0 0 12px #f5efe0, inset 0 0 0 13px #8f7f5f, inset 0 0 0 17px #f5efe0, inset 0 0 0 19px #8f7f5f, 0 26px 50px -24px rgb(0 0 0 / .7); }
|
|
20
|
+
.poster figcaption { display: grid; gap: 8px; margin-bottom: 16px; text-align: center; }
|
|
21
|
+
.poster .kicker { font: 600 12px/1.3 "IBM Plex Mono", ui-monospace, Menlo, monospace; letter-spacing: .24em; text-transform: uppercase; color: #6b6252; }
|
|
22
|
+
.poster .headline { font-size: 60px; font-weight: 700; line-height: .98; letter-spacing: -.01em; text-wrap: balance; }
|
|
23
|
+
.poster .dek { max-width: 54ch; margin: 2px auto 0; font-size: 19px; font-style: italic; font-weight: 500; color: #4a4437; }
|
|
24
|
+
.poster .note { display: block; margin-top: 16px; font: 400 12px/1.5 "IBM Plex Mono", ui-monospace, Menlo, monospace; color: #6b6252; text-align: center; }
|
|
25
|
+
/* The readout: the picked day's prices on one ticker line (two on a phone), so the chart never moves */
|
|
26
|
+
.ticker { display: flex; flex-wrap: wrap; justify-content: center; gap: 2px 18px; min-height: 2.6em; margin: 0 0 14px; padding: 8px 0;
|
|
27
|
+
border-block: 1px solid #8f7f5f; font: 500 14px/1.6 "IBM Plex Mono", ui-monospace, Menlo, monospace; font-variant-numeric: tabular-nums; }
|
|
28
|
+
.ticker b { font-weight: 600; }
|
|
29
|
+
.ticker span { color: #6b6252; }
|
|
30
|
+
.ticker .up { color: #16784f; font-weight: 600; }
|
|
31
|
+
.ticker .down { color: #b2352a; font-weight: 600; }
|
|
32
|
+
@media (max-width: 600px) {
|
|
33
|
+
body { padding: 16px; }
|
|
34
|
+
.poster { padding: 38px 24px 26px; box-shadow: inset 0 0 0 8px #f5efe0, inset 0 0 0 9px #8f7f5f, inset 0 0 0 12px #f5efe0, inset 0 0 0 13px #8f7f5f; }
|
|
35
|
+
.poster .kicker { font-size: 10.5px; letter-spacing: .16em; }
|
|
36
|
+
.poster .headline { font-size: 40px; }
|
|
37
|
+
.poster .dek { font-size: 17px; }
|
|
38
|
+
.ticker { gap: 0 12px; min-height: 4.4em; font-size: 12.5px; }
|
|
39
|
+
}
|
|
40
|
+
</style>
|
|
41
|
+
</head>
|
|
42
|
+
<body>
|
|
43
|
+
<div id="chart"></div>
|
|
44
|
+
<script type="module">
|
|
45
|
+
import { Chart, Plot, Bar, Tick, Label, Poster, Show, slat, nice, html, render, createSignal } from "@bezda/rhp/standalone";
|
|
46
|
+
|
|
47
|
+
// Data: one share's daily prices in dollars, one row per trading day, with a note for days worth naming. Illustrative. Replace with your own.
|
|
48
|
+
const DAYS = [
|
|
49
|
+
{ date: "2026-03-02", open: 46.10, high: 47.10, low: 45.80, close: 46.85 },
|
|
50
|
+
{ date: "2026-03-03", open: 46.90, high: 47.75, low: 46.55, close: 47.40 },
|
|
51
|
+
{ date: "2026-03-04", open: 47.35, high: 47.80, low: 46.70, close: 47.05 },
|
|
52
|
+
{ date: "2026-03-05", open: 47.10, high: 48.45, low: 46.95, close: 48.20 },
|
|
53
|
+
{ date: "2026-03-06", open: 48.25, high: 49.20, low: 47.90, close: 48.90 },
|
|
54
|
+
{ date: "2026-03-09", open: 48.80, high: 49.05, low: 47.85, close: 48.35 },
|
|
55
|
+
{ date: "2026-03-10", open: 48.40, high: 49.85, low: 48.20, close: 49.60 },
|
|
56
|
+
{ date: "2026-03-11", open: 49.65, high: 50.70, low: 49.30, close: 50.30 },
|
|
57
|
+
{ date: "2026-03-12", open: 50.20, high: 51.70, low: 50.05, close: 51.45 },
|
|
58
|
+
{ date: "2026-03-13", open: 51.50, high: 51.95, low: 50.60, close: 51.10 },
|
|
59
|
+
{ date: "2026-03-16", open: 51.20, high: 52.60, low: 50.95, close: 52.35 },
|
|
60
|
+
{ date: "2026-03-17", open: 52.40, high: 53.30, low: 52.05, close: 52.90 },
|
|
61
|
+
{ date: "2026-03-18", open: 46.10, high: 46.50, low: 43.90, close: 44.80, note: "Earnings" },
|
|
62
|
+
{ date: "2026-03-19", open: 44.70, high: 45.95, low: 44.10, close: 45.60 },
|
|
63
|
+
{ date: "2026-03-20", open: 45.50, high: 45.85, low: 44.40, close: 44.95 },
|
|
64
|
+
{ date: "2026-03-23", open: 45.10, high: 46.45, low: 44.85, close: 46.20 },
|
|
65
|
+
{ date: "2026-03-24", open: 46.30, high: 47.40, low: 46.00, close: 47.10 },
|
|
66
|
+
{ date: "2026-03-25", open: 47.05, high: 47.35, low: 46.20, close: 46.60 },
|
|
67
|
+
{ date: "2026-03-26", open: 46.70, high: 48.10, low: 46.45, close: 47.85 },
|
|
68
|
+
{ date: "2026-03-27", open: 47.90, high: 48.75, low: 47.55, close: 48.40 },
|
|
69
|
+
{ date: "2026-03-30", open: 48.30, high: 48.60, low: 47.60, close: 47.95 },
|
|
70
|
+
{ date: "2026-03-31", open: 48.10, high: 49.05, low: 47.85, close: 48.75 },
|
|
71
|
+
];
|
|
72
|
+
const FOCUS = "2026-03-18"; // the day the readout opens on
|
|
73
|
+
|
|
74
|
+
// Each day's change from the close before it (the first day from its own open)
|
|
75
|
+
const ROWS = DAYS.map((d, i) => ({ ...d, change: d.close / (i ? DAYS[i - 1].close : d.open) - 1 }));
|
|
76
|
+
const range = nice(Math.min(...DAYS.map((d) => d.low)), Math.max(...DAYS.map((d) => d.high)), 6); // a round price scale around the month
|
|
77
|
+
const opening = Math.max(0, DAYS.findIndex((d) => d.date === FOCUS));
|
|
78
|
+
const dollars = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
79
|
+
const whole = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
|
|
80
|
+
const percent = new Intl.NumberFormat("en-US", { style: "percent", minimumFractionDigits: 1, maximumFractionDigits: 1, signDisplay: "exceptZero" });
|
|
81
|
+
const day = new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" });
|
|
82
|
+
const date = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
|
|
83
|
+
const isMonday = (iso) => new Date(iso).getUTCDay() === 1;
|
|
84
|
+
|
|
85
|
+
// Slat types: a trading day (wick and body), and the crosshair at the picked day's close
|
|
86
|
+
const Candle = slat({
|
|
87
|
+
inset: 0.2,
|
|
88
|
+
room: { start: 28, end: 14 },
|
|
89
|
+
css: `
|
|
90
|
+
.wick { --rhp-radius: 0px; background: var(--move); }
|
|
91
|
+
.body { --rhp-radius: 1px; background: var(--move); }
|
|
92
|
+
.rose .body { background: var(--rhp-surface); box-shadow: inset 0 0 0 1.5px var(--move); }
|
|
93
|
+
.rose { --move: #16784f; }
|
|
94
|
+
.fell { --move: #c0392b; }
|
|
95
|
+
.day.on { background: rgb(80 64 32 / .08); }
|
|
96
|
+
.day:focus-visible { outline: 2px solid #22201b; outline-offset: -2px; }
|
|
97
|
+
.date { font: 500 11px/1.2 "IBM Plex Mono", ui-monospace, Menlo, monospace; color: var(--rhp-muted); white-space: nowrap; overflow: visible; text-overflow: clip; --rhp-label-gap: 8px; }
|
|
98
|
+
.note { padding: 0 0 6px; font: 600 11px/1 "IBM Plex Mono", ui-monospace, Menlo, monospace; letter-spacing: .08em; text-transform: uppercase; }
|
|
99
|
+
.say { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
100
|
+
@container (max-width: 480px) { .alt { display: none; } } /* every other Monday on a narrow chart */
|
|
101
|
+
`,
|
|
102
|
+
}, (d) => html`
|
|
103
|
+
<div class=${() => ["day", d.close >= d.open ? "rose" : "fell", d.picked === d.index && "on"].filter(Boolean).join(" ")} data-row=${() => d.index}>
|
|
104
|
+
<${Bar} from=${() => d.low} to=${() => d.high} thick="1.5px" class="wick" />
|
|
105
|
+
<${Bar} from=${() => d.open} to=${() => d.close} thick=${0.62} class="body" />
|
|
106
|
+
<${Show} when=${() => isMonday(d.date)}>
|
|
107
|
+
<${Label} edge="start" class=${() => (Math.floor((new Date(d.date).getUTCDate() - 1) / 7) % 2 ? "date alt" : "date")}>${() => date.format(new Date(d.date))}<//>
|
|
108
|
+
<//>
|
|
109
|
+
<${Show} when=${() => d.note}><${Label} at=${() => d.high} class="note">${() => d.note}<//><//>
|
|
110
|
+
<span class="say">${() => `${day.format(new Date(d.date))}: open ${dollars.format(d.open)}, high ${dollars.format(d.high)}, low ${dollars.format(d.low)}, close ${dollars.format(d.close)}`}</span>
|
|
111
|
+
</div>`);
|
|
112
|
+
|
|
113
|
+
const Crosshair = slat({
|
|
114
|
+
room: { after: 62 }, // a gutter right of the plot for the price
|
|
115
|
+
css: `
|
|
116
|
+
.level { --rhp-tick-width: 1px; background: #22201b; }
|
|
117
|
+
.price { left: 100%; translate: 6px 50%; padding: 2px 5px; border-radius: 2px; background: #22201b; color: #f5efe0;
|
|
118
|
+
font: 600 11px/1.3 "IBM Plex Mono", ui-monospace, Menlo, monospace; }
|
|
119
|
+
`,
|
|
120
|
+
}, (c) => html`
|
|
121
|
+
<div>
|
|
122
|
+
<${Tick} at=${() => c.close} thick=${1} class="level" />
|
|
123
|
+
<${Label} at=${() => c.close} class="price">${() => dollars.format(c.close)}<//>
|
|
124
|
+
</div>`);
|
|
125
|
+
|
|
126
|
+
// Chart component: the day the reader picks (pointer, tap or arrow keys) is read out and marked by the crosshair
|
|
127
|
+
function SharePrice() {
|
|
128
|
+
const [picked, setPicked] = createSignal(null);
|
|
129
|
+
const index = () => picked() ?? opening;
|
|
130
|
+
const shown = () => ROWS[index()];
|
|
131
|
+
// The row of the slat an element is in, or null (between slats, on the axis or the text)
|
|
132
|
+
const rowOf = (el) => {
|
|
133
|
+
const root = el.closest("[data-row]");
|
|
134
|
+
return root ? +root.dataset.row : null;
|
|
135
|
+
};
|
|
136
|
+
// A mouse or a pen picks as it moves, a tap or a click picks, the keyboard picks; on no slat the pick stays
|
|
137
|
+
const pick = (e) => {
|
|
138
|
+
const row = rowOf(e.target);
|
|
139
|
+
if (row != null) setPicked(row);
|
|
140
|
+
};
|
|
141
|
+
// Leaving the poster: 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="Share price · March 2026"
|
|
149
|
+
title="A 15% drop on earnings day erased the month's gains"
|
|
150
|
+
dek="Each candle is a trading day. Its body runs from the opening price to the closing price, hollow when the price rose and filled when it fell; its wick spans the day's low to its high."
|
|
151
|
+
note="Illustrative data. Point at a day, tap it, or Tab into the chart and use the arrow keys."
|
|
152
|
+
onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
|
|
153
|
+
onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
|
|
154
|
+
<p class="ticker">
|
|
155
|
+
<b>${() => day.format(new Date(shown().date))}</b>
|
|
156
|
+
<span>Open ${() => dollars.format(shown().open)}</span>
|
|
157
|
+
<span>High ${() => dollars.format(shown().high)}</span>
|
|
158
|
+
<span>Low ${() => dollars.format(shown().low)}</span>
|
|
159
|
+
<span>Close <b class=${() => (shown().change >= 0 ? "up" : "down")}>${() => `${dollars.format(shown().close)} ${percent.format(shown().change)}`}</b></span>
|
|
160
|
+
</p>
|
|
161
|
+
<${Chart} orientation="vertical" height=${320} scale=${[range.min, range.max]} ticks=${range.ticks} format=${(v) => whole.format(v)}
|
|
162
|
+
label="Candlestick chart of one share's daily prices in March 2026"
|
|
163
|
+
theme=${{ font: '"IBM Plex Mono", ui-monospace, Menlo, monospace', ink: "#22201b", muted: "#6b6252", grid: "#e2d9c4", surface: "#f5efe0" }}>
|
|
164
|
+
<${Plot} rows=${ROWS} picked=${index} keyboard=${true}>${Candle}<//>
|
|
165
|
+
<${Plot} overlap=${true} slats=${1} close=${() => shown().close} style=${{ "pointer-events": "none" }}>${Crosshair}<//>
|
|
166
|
+
<//>
|
|
167
|
+
<//>`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
render(() => html`<${SharePrice} />`, document.getElementById("chart"));
|
|
171
|
+
</script>
|
|
172
|
+
</body>
|
|
173
|
+
</html>
|
|
@@ -0,0 +1,125 @@
|
|
|
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>A monsoon year</title>
|
|
7
|
+
<meta name="description" content="Column chart of a value over twelve months, a season marked under its columns, with a scrub that reads out the running total by hover, focus and tap. Use it for values over periods (months, years) or a few 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=Archivo:wdth,wght@62..125,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: #cdd0d2; font-family: Archivo, "Helvetica Neue", Arial, sans-serif; }
|
|
15
|
+
/* The poster: a night sky of near-black paper with fine slanting rain, white type, the water in blue */
|
|
16
|
+
.poster { max-width: 960px; margin: 0 auto; padding: 48px 52px 30px; color: #eef2f5; border-radius: 6px;
|
|
17
|
+
background: repeating-linear-gradient(104deg, transparent 0 26px, rgb(255 255 255 / .035) 26px 27px), #141618;
|
|
18
|
+
box-shadow: 0 30px 60px -30px rgb(40 44 48 / .7); }
|
|
19
|
+
.poster figcaption { display: grid; gap: 14px; margin-bottom: 28px; }
|
|
20
|
+
.poster .kicker { font-size: 12px; font-weight: 700; letter-spacing: .22em; text-transform: uppercase; color: #6cc3ff; }
|
|
21
|
+
.poster .headline { max-width: 13em; font-size: 54px; font-weight: 800; font-stretch: 125%; line-height: 1; letter-spacing: -.01em; text-wrap: balance; }
|
|
22
|
+
.poster .dek { max-width: 54ch; font-size: 16px; line-height: 1.5; color: #b5bdc4; }
|
|
23
|
+
.poster .note { display: block; margin-top: 24px; font-size: 12px; line-height: 1.5; color: #8d969e; }
|
|
24
|
+
/* The readout: how much rain has fallen by the month in focus */
|
|
25
|
+
.readout { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 14px; min-height: 2lh; margin: 0 0 22px; padding-top: 16px; border-top: 1px solid #2c3135; font-size: 15px; line-height: 1.4; color: #b5bdc4; }
|
|
26
|
+
.readout b { font-size: 30px; font-weight: 700; font-stretch: 110%; color: #fff; font-variant-numeric: tabular-nums; }
|
|
27
|
+
@media (max-width: 560px) {
|
|
28
|
+
body { padding: 16px 16px 32px; }
|
|
29
|
+
.poster { padding: 26px 16px 18px; }
|
|
30
|
+
.poster .headline { font-size: 32px; }
|
|
31
|
+
.poster .dek { font-size: 15px; }
|
|
32
|
+
.readout { min-height: 4lh; } /* the longest readout, so the chart never moves */
|
|
33
|
+
.readout b { font-size: 24px; }
|
|
34
|
+
}
|
|
35
|
+
</style>
|
|
36
|
+
</head>
|
|
37
|
+
<body>
|
|
38
|
+
<div id="chart"></div>
|
|
39
|
+
<script type="module">
|
|
40
|
+
import { Chart, Plot, Bar, Label, Poster, Show, slat, nice, html, render, createSignal, createMemo } from "@bezda/rhp/standalone";
|
|
41
|
+
|
|
42
|
+
// Data: rainfall in mm per month, and the season marked under its months. Illustrative data; replace with your own.
|
|
43
|
+
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
|
44
|
+
const RAIN = [1, 1, 0, 1, 14, 520, 840, 590, 340, 90, 18, 6];
|
|
45
|
+
const SEASON = { name: "Monsoon", from: 5, to: 8 }; // June to September; null for no bracket
|
|
46
|
+
|
|
47
|
+
const mm = new Intl.NumberFormat("en-US");
|
|
48
|
+
const percent = new Intl.NumberFormat("en-US", { style: "percent" });
|
|
49
|
+
const year = RAIN.reduce((a, b) => a + b, 0);
|
|
50
|
+
const scale = nice(0, Math.max(...RAIN), 4); // 0 to 1,000 mm, a line every 250
|
|
51
|
+
|
|
52
|
+
// Slat type: a month as a rain gauge, a glass tube up to the top of the scale with the month's rain in it.
|
|
53
|
+
// The season's first month also draws the bracket under the season, as wide as its months.
|
|
54
|
+
const Month = slat({
|
|
55
|
+
inset: { vertical: 0.24 },
|
|
56
|
+
room: { start: 64, end: 22 },
|
|
57
|
+
css: `
|
|
58
|
+
.tube { background: rgb(255 255 255 / .04); box-shadow: inset 0 0 0 1px rgb(255 255 255 / .14); --rhp-radius: 3px; }
|
|
59
|
+
.water { background: linear-gradient(var(--rhp-toward-end), #1d5fae, #3d9be9); box-shadow: inset 0 2px 0 #9fd6ff; --rhp-radius: 2px; }
|
|
60
|
+
.mm { font-size: 12px; font-weight: 600; color: #e6ecf1; font-variant-numeric: tabular-nums; }
|
|
61
|
+
.name { font-size: 12px; font-weight: 600; color: #b5bdc4; text-transform: uppercase; letter-spacing: .06em; }
|
|
62
|
+
.now .tube { box-shadow: inset 0 0 0 1.5px #6cc3ff; }
|
|
63
|
+
.later .water { opacity: .3; }
|
|
64
|
+
.later .mm { opacity: .6; } /* faded, still readable */
|
|
65
|
+
.season { position: absolute; top: calc(100% + 30px); left: 24%; width: calc(var(--months) * 100% - 48%); height: 7px; border: 1.5px solid #6cc3ff; border-top: 0; }
|
|
66
|
+
.season b { position: absolute; top: 13px; left: 50%; translate: -50% 0; font-size: 11px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; color: #6cc3ff; white-space: nowrap; }
|
|
67
|
+
.month:focus-visible { outline: 2px solid #6cc3ff; outline-offset: -2px; border-radius: 4px; }
|
|
68
|
+
@container (max-width: 520px) { .name { font-size: 11px; font-stretch: 70%; letter-spacing: 0; } .mm { font-size: 11px; font-stretch: 80%; } }
|
|
69
|
+
`,
|
|
70
|
+
}, (d) => html`
|
|
71
|
+
<div class=${() => "month " + d.step} data-row=${() => d.index} aria-label=${() => `${d.month}: ${mm.format(d.mm)} mm`}>
|
|
72
|
+
<${Bar} to=${scale.max} class="tube" />
|
|
73
|
+
<${Bar} to=${() => d.mm} class="water" />
|
|
74
|
+
<${Label} at=${() => d.mm} class="mm">${() => mm.format(d.mm)}<//>
|
|
75
|
+
<${Label} edge="start" class="name">${() => d.month.slice(0, 3)}<//>
|
|
76
|
+
<${Show} when=${() => d.index === SEASON?.from}>
|
|
77
|
+
<span class="season" style=${() => `--months: ${SEASON.to - SEASON.from + 1}`}><b>${() => SEASON.name}</b></span>
|
|
78
|
+
<//>
|
|
79
|
+
</div>`);
|
|
80
|
+
|
|
81
|
+
// Chart component: the month the reader scrubs to splits the year, and the readout adds up the rain so far
|
|
82
|
+
function MonsoonYear() {
|
|
83
|
+
const [at, setAt] = createSignal(null);
|
|
84
|
+
const end = () => at() ?? MONTHS.length - 1;
|
|
85
|
+
const sofar = createMemo(() => RAIN.slice(0, end() + 1).reduce((a, b) => a + b, 0));
|
|
86
|
+
// The row of the slat an element is in, or null (between slats, on the axis or the text)
|
|
87
|
+
const rowOf = (el) => {
|
|
88
|
+
const root = el.closest("[data-row]");
|
|
89
|
+
return root ? +root.dataset.row : null;
|
|
90
|
+
};
|
|
91
|
+
// A mouse or a pen picks as it moves, a tap or a click picks, the keyboard picks; on no slat the pick stays
|
|
92
|
+
const pick = (e) => {
|
|
93
|
+
const row = rowOf(e.target);
|
|
94
|
+
if (row != null) setAt(row);
|
|
95
|
+
};
|
|
96
|
+
// Leaving the plot or poster: back to the slat that has focus (a keyboard's or a click's pick), or none
|
|
97
|
+
const leave = (e) => {
|
|
98
|
+
if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
|
|
99
|
+
setAt(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
|
|
100
|
+
};
|
|
101
|
+
return html`
|
|
102
|
+
<${Poster}
|
|
103
|
+
kicker="A monsoon city · rain by month"
|
|
104
|
+
title="Four months bring nearly all of the year's rain"
|
|
105
|
+
dek="Each tube holds a month's rain in millimetres. From June to September the monsoon fills them; for the rest of the year they stay almost dry."
|
|
106
|
+
note="Illustrative data, shaped like a monsoon city's year. Point at a month, tap it, or Tab into the chart and use the arrow keys to add up the year."
|
|
107
|
+
onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
|
|
108
|
+
onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setAt(null)}>
|
|
109
|
+
<p class="readout" aria-live="polite">
|
|
110
|
+
<b>${() => mm.format(sofar()) + " mm"}</b>
|
|
111
|
+
<span>${() => (at() == null ? "in the whole year" : `by the end of ${MONTHS[end()]}, ${percent.format(sofar() / year)} of the year's rain`)}</span>
|
|
112
|
+
</p>
|
|
113
|
+
<${Chart} orientation="vertical" height=${300} scale=${[scale.min, scale.max]} ticks=${scale.ticks} format=${(v) => mm.format(v)}
|
|
114
|
+
label="Rainfall by month, in millimetres"
|
|
115
|
+
theme=${{ font: 'Archivo, "Helvetica Neue", Arial, sans-serif', ink: "#eef2f5", muted: "#8d969e", grid: "#2c3135" }}>
|
|
116
|
+
<${Plot} month=${MONTHS} mm=${RAIN} step=${(d) => (d.index > end() ? "later" : d.index === at() ? "now" : "")} keyboard=${true}
|
|
117
|
+
ref=${(el) => el.addEventListener("pointerleave", leave)}>${Month}<//>
|
|
118
|
+
<//>
|
|
119
|
+
<//>`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
render(() => html`<${MonsoonYear} />`, document.getElementById("chart"));
|
|
123
|
+
</script>
|
|
124
|
+
</body>
|
|
125
|
+
</html>
|
|
@@ -0,0 +1,144 @@
|
|
|
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>Staff survey</title>
|
|
7
|
+
<meta name="description" content="Diverging stacked bar chart of survey answers, disagree to the left of zero and agree to the right, ranked by net agreement, with a switch between questions. Use it for Likert scales or any values on either side of zero.">
|
|
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=Hanken+Grotesk:wght@400..800&family=Syne:wght@700..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: #d9d6ce; font-family: "Hanken Grotesk", "Segoe UI", system-ui, sans-serif; }
|
|
15
|
+
/* The poster: a two-ink risograph print, red for disagree and blue for agree, solid when strong, dotted when not */
|
|
16
|
+
.poster { --red: #e0384f; --blue: #2756b0; max-width: 940px; margin: 0 auto; padding: 46px 52px 30px; color: #1f1d1a; border-radius: 2px;
|
|
17
|
+
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='s'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.7' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 9 0 0 0 -6.3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23s)' opacity='.16'/%3E%3C/svg%3E"), #f3efe4;
|
|
18
|
+
box-shadow: 0 26px 50px -30px rgb(66 62 52 / .6); }
|
|
19
|
+
.poster figcaption { display: grid; gap: 14px; margin-bottom: 24px; }
|
|
20
|
+
.poster .kicker { font-size: 12px; font-weight: 800; letter-spacing: .2em; text-transform: uppercase; color: var(--blue); }
|
|
21
|
+
.poster .headline { max-width: 16em; font: 800 46px/1.04 Syne, "Arial Black", system-ui, sans-serif; letter-spacing: -.01em; text-wrap: balance; }
|
|
22
|
+
.poster .dek { max-width: 58ch; font-size: 16px; line-height: 1.55; color: #4e4a43; }
|
|
23
|
+
.poster .note { display: block; margin-top: 22px; font-size: 12px; line-height: 1.5; color: #5d5a52; }
|
|
24
|
+
/* The question switch, the question itself, and the answer scale as a survey form prints it */
|
|
25
|
+
.questions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
26
|
+
.questions button { padding: 8px 14px; border: 2px solid #1f1d1a; border-radius: 99px; background: none; color: #1f1d1a; font: 700 14px/1 "Hanken Grotesk", system-ui, sans-serif; cursor: pointer; }
|
|
27
|
+
.questions button[aria-pressed="true"] { background: #1f1d1a; color: #f3efe4; }
|
|
28
|
+
.questions button:focus-visible { outline: 3px solid var(--blue); outline-offset: 2px; }
|
|
29
|
+
.question { margin: 20px 0 16px; font: 750 26px/1.2 Syne, "Arial Black", system-ui, sans-serif; color: var(--blue); }
|
|
30
|
+
/* Every question's headline (and its words) in one grid cell, only the picked one shown: the cell is as tall as the
|
|
31
|
+
longest, so a switch never moves the buttons or the chart */
|
|
32
|
+
.same-place { display: grid; }
|
|
33
|
+
.same-place > span { grid-area: 1 / 1; visibility: hidden; }
|
|
34
|
+
.same-place > .on { visibility: visible; }
|
|
35
|
+
.answers { display: flex; flex-wrap: wrap; gap: 6px 18px; margin: 0 0 14px; padding: 0; list-style: none; font-size: 13px; font-weight: 600; color: #4e4a43; }
|
|
36
|
+
.answers li { display: flex; align-items: center; gap: 7px; }
|
|
37
|
+
.answers i { width: 15px; height: 15px; border-radius: 50%; border: 2px solid #1f1d1a; }
|
|
38
|
+
.answers .no2 { background: var(--red); }
|
|
39
|
+
.answers .no1 { background: radial-gradient(circle, var(--red) 1.7px, transparent 2.1px) 0 0 / 5px 5px; }
|
|
40
|
+
.answers .neither { background: #f3efe4; }
|
|
41
|
+
.answers .yes1 { background: radial-gradient(circle, var(--blue) 1.7px, transparent 2.1px) 0 0 / 5px 5px; }
|
|
42
|
+
.answers .yes2 { background: var(--blue); }
|
|
43
|
+
@media (max-width: 600px) {
|
|
44
|
+
body { padding: 16px 16px 32px; }
|
|
45
|
+
.poster { padding: 26px 16px 18px; }
|
|
46
|
+
.poster .headline { font-size: 30px; }
|
|
47
|
+
.poster .dek { font-size: 15px; }
|
|
48
|
+
.questions { gap: 5px; }
|
|
49
|
+
.questions button { padding: 6px 8px; border-width: 1.5px; font-size: 12.5px; }
|
|
50
|
+
.question { font-size: 18px; }
|
|
51
|
+
}
|
|
52
|
+
</style>
|
|
53
|
+
</head>
|
|
54
|
+
<body>
|
|
55
|
+
<div id="chart"></div>
|
|
56
|
+
<script type="module">
|
|
57
|
+
import { Chart, Plot, Bar, Tick, Label, Poster, slat, sortBy, html, render, createSignal } from "@bezda/rhp/standalone";
|
|
58
|
+
|
|
59
|
+
// Data: % of each department giving each answer, from strongly disagree to strongly agree. Illustrative data; replace with your own.
|
|
60
|
+
const DEPARTMENTS = ["Engineering", "Product", "Design", "Sales", "Marketing", "Customer support", "Finance", "Operations"];
|
|
61
|
+
const QUESTIONS = [
|
|
62
|
+
{ short: "Valued", text: "I feel valued for my work.", finding: "Customer support feels the least valued",
|
|
63
|
+
answers: [[5, 11, 16, 44, 24], [4, 10, 14, 46, 26], [6, 12, 18, 42, 22], [9, 15, 15, 38, 23], [7, 13, 19, 41, 20], [18, 26, 17, 28, 11], [6, 12, 21, 43, 18], [12, 20, 20, 35, 13]] },
|
|
64
|
+
{ short: "Recommend", text: "I would recommend working here.", finding: "Product staff recommend working here the most",
|
|
65
|
+
answers: [[4, 8, 14, 46, 28], [3, 7, 12, 48, 30], [5, 10, 17, 44, 24], [8, 14, 16, 40, 22], [6, 12, 20, 42, 20], [14, 22, 20, 32, 12], [5, 11, 22, 44, 18], [10, 18, 22, 36, 14]] },
|
|
66
|
+
{ short: "Workload", text: "My workload is manageable.", finding: "Workload is the sore point in every department",
|
|
67
|
+
answers: [[16, 24, 18, 30, 12], [14, 22, 20, 32, 12], [12, 20, 22, 34, 12], [20, 26, 16, 28, 10], [13, 21, 22, 33, 11], [28, 30, 14, 20, 8], [10, 18, 24, 36, 12], [22, 27, 18, 24, 9]] },
|
|
68
|
+
{ short: "Direction", text: "I understand where the company is heading.", finding: "Support is the least sure where the company is heading",
|
|
69
|
+
answers: [[6, 14, 20, 40, 20], [3, 8, 13, 46, 30], [7, 15, 22, 38, 18], [4, 9, 14, 45, 28], [5, 11, 17, 44, 23], [15, 24, 22, 29, 10], [5, 12, 20, 43, 20], [11, 19, 24, 34, 12]] },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
// [strongly disagree, disagree, neither, agree, strongly agree] -> the two sides and the middle
|
|
73
|
+
const disagree = (a) => a[0] + a[1];
|
|
74
|
+
const agree = (a) => a[3] + a[4];
|
|
75
|
+
|
|
76
|
+
// Texts that change with the question, all in the same place (see .same-place), the one for question current() shown
|
|
77
|
+
const samePlace = (texts, current) => html`<span class="same-place">${texts.map((text, i) => html`<span class=${() => (current() === i ? "on" : "")}>${text}</span>`)}</span>`;
|
|
78
|
+
|
|
79
|
+
// Slat type: a department's name over its bar, which runs left of zero for disagree and right of it for agree.
|
|
80
|
+
// A strong part starts 2px after the plain part before it, and on zero when there is none; one too thin to show at
|
|
81
|
+
// this width keeps a 2px sliver, drawn over its neighbor.
|
|
82
|
+
const strong = (side, plain, share) => ["part", side, "strong", plain > 0 && "after", share > 0 && share < 3 && "thin"].filter(Boolean).join(" ");
|
|
83
|
+
const Department = slat({
|
|
84
|
+
thickness: 64,
|
|
85
|
+
inset: "21px",
|
|
86
|
+
room: { start: 18, end: 18 },
|
|
87
|
+
css: `
|
|
88
|
+
.name { position: absolute; left: 0; top: 1px; font-size: 14px; font-weight: 800; }
|
|
89
|
+
.neither { position: absolute; right: 0; top: 2px; font-size: 12px; font-weight: 600; color: var(--rhp-muted); }
|
|
90
|
+
.part { --rhp-radius: 0px; }
|
|
91
|
+
.part.no { background: radial-gradient(circle, #e0384f 1.7px, transparent 2.1px) 0 0 / 5px 5px; }
|
|
92
|
+
.part.yes { background: radial-gradient(circle, #2756b0 1.7px, transparent 2.1px) 0 0 / 5px 5px; }
|
|
93
|
+
.part.no.strong { background: #e0384f; --rhp-end-radius: 3px; }
|
|
94
|
+
.part.yes.strong { background: #2756b0; --rhp-end-radius: 3px; }
|
|
95
|
+
.after { --rhp-gap: 2px; }
|
|
96
|
+
.thin { min-width: 2px; z-index: 1; }
|
|
97
|
+
.zero { background: #1f1d1a; }
|
|
98
|
+
.total { font-size: 13px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
|
99
|
+
.total.no { color: #c02a40; }
|
|
100
|
+
.total.yes { color: #2756b0; }
|
|
101
|
+
`,
|
|
102
|
+
}, (d) => html`
|
|
103
|
+
<div aria-label=${() => `${d.name}: ${disagree(d.answers)}% disagree, ${agree(d.answers)}% agree, ${d.answers[2]}% neither`}>
|
|
104
|
+
<span class="name" aria-hidden="true">${() => d.name}</span>
|
|
105
|
+
<span class="neither" aria-hidden="true">${() => d.answers[2] + "% neither"}</span>
|
|
106
|
+
<${Bar} to=${() => -d.answers[1]} class="part no" />
|
|
107
|
+
<${Bar} from=${() => -d.answers[1]} to=${() => -disagree(d.answers)} class=${() => strong("no", d.answers[1], d.answers[0])} />
|
|
108
|
+
<${Bar} to=${() => d.answers[3]} class="part yes" />
|
|
109
|
+
<${Bar} from=${() => d.answers[3]} to=${() => agree(d.answers)} class=${() => strong("yes", d.answers[3], d.answers[4])} />
|
|
110
|
+
<${Tick} at=${0} thick="34px" class="zero" />
|
|
111
|
+
<${Label} at=${() => -disagree(d.answers)} side="before" class="total no" aria-hidden="true">${() => disagree(d.answers) + "%"}<//>
|
|
112
|
+
<${Label} at=${() => agree(d.answers)} class="total yes" aria-hidden="true">${() => agree(d.answers) + "%"}<//>
|
|
113
|
+
</div>`);
|
|
114
|
+
|
|
115
|
+
// Chart component: the question picked switches the data, the headline and the order of the departments
|
|
116
|
+
function StaffSurvey() {
|
|
117
|
+
const [q, setQ] = createSignal(0);
|
|
118
|
+
const question = () => QUESTIONS[q()];
|
|
119
|
+
return html`
|
|
120
|
+
<${Poster}
|
|
121
|
+
kicker="Staff survey · eight departments"
|
|
122
|
+
title=${samePlace(QUESTIONS.map((x) => x.finding), q)}
|
|
123
|
+
dek="The share of each department that disagrees (left of the line) or agrees (right of it), strongly in solid ink. Departments are ranked by how many more agree than disagree."
|
|
124
|
+
note="Illustrative data. Shares are rounded to whole percents.">
|
|
125
|
+
<div class="questions" role="group" aria-label="Question">
|
|
126
|
+
${QUESTIONS.map((x, i) => html`<button type="button" aria-pressed=${() => q() === i} onClick=${() => setQ(i)}>${x.short}</button>`)}
|
|
127
|
+
</div>
|
|
128
|
+
<p class="question" aria-live="polite">${samePlace(QUESTIONS.map((x) => "“" + x.text + "”"), q)}</p>
|
|
129
|
+
<ul class="answers" aria-hidden="true">
|
|
130
|
+
<li><i class="no2"></i>Strongly disagree</li><li><i class="no1"></i>Disagree</li><li><i class="neither"></i>Neither</li><li><i class="yes1"></i>Agree</li><li><i class="yes2"></i>Strongly agree</li>
|
|
131
|
+
</ul>
|
|
132
|
+
<${Chart} scale=${[-100, 100]} ticks=${[-100, -50, 0, 50, 100]} format=${(v) => Math.abs(v) + "%"}
|
|
133
|
+
label=${() => "Staff survey answers by department: " + question().text}
|
|
134
|
+
theme=${{ font: '"Hanken Grotesk", "Segoe UI", system-ui, sans-serif', ink: "#1f1d1a", muted: "#5d5a52", grid: "#ddd8cb" }}>
|
|
135
|
+
<${Plot} name=${DEPARTMENTS} answers=${() => question().answers} key="name"
|
|
136
|
+
order=${sortBy((d) => agree(d.answers) - disagree(d.answers), "desc")}>${Department}<//>
|
|
137
|
+
<//>
|
|
138
|
+
<//>`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
render(() => html`<${StaffSurvey} />`, document.getElementById("chart"));
|
|
142
|
+
</script>
|
|
143
|
+
</body>
|
|
144
|
+
</html>
|