@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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -2
  3. package/bin/rhp-mcp.js +129 -0
  4. package/package.json +62 -4
  5. package/skill/SKILL.md +450 -0
  6. package/skill/recipes/area.html +156 -0
  7. package/skill/recipes/bar.html +145 -0
  8. package/skill/recipes/box-plot.html +164 -0
  9. package/skill/recipes/bubble.html +188 -0
  10. package/skill/recipes/bullet.html +138 -0
  11. package/skill/recipes/candlestick.html +179 -0
  12. package/skill/recipes/column.html +138 -0
  13. package/skill/recipes/dial.html +349 -0
  14. package/skill/recipes/diverging-bars.html +150 -0
  15. package/skill/recipes/donut.html +231 -0
  16. package/skill/recipes/dumbbell.html +137 -0
  17. package/skill/recipes/gantt.html +196 -0
  18. package/skill/recipes/grouped-bars.html +142 -0
  19. package/skill/recipes/heatmap.html +183 -0
  20. package/skill/recipes/histogram.html +199 -0
  21. package/skill/recipes/line.html +170 -0
  22. package/skill/recipes/live.html +255 -0
  23. package/skill/recipes/log-line.html +493 -0
  24. package/skill/recipes/lollipop.html +152 -0
  25. package/skill/recipes/multi-line.html +228 -0
  26. package/skill/recipes/pyramid.html +162 -0
  27. package/skill/recipes/race.html +278 -0
  28. package/skill/recipes/radial-bars.html +157 -0
  29. package/skill/recipes/scatter.html +199 -0
  30. package/skill/recipes/silhouettes.html +352 -0
  31. package/skill/recipes/slope.html +199 -0
  32. package/skill/recipes/sparklines.html +175 -0
  33. package/skill/recipes/stacked-100.html +158 -0
  34. package/skill/recipes/stacked-bars.html +150 -0
  35. package/skill/recipes/strip.html +221 -0
  36. package/skill/recipes/unit-stack.html +276 -0
  37. package/skill/recipes/violin.html +169 -0
  38. package/skill/recipes/waffle.html +165 -0
  39. package/skill/recipes/waterfall.html +127 -0
  40. package/skill/references/api.md +1684 -0
  41. package/skill/references/design.md +740 -0
  42. package/skill/references/environments.md +1577 -0
  43. package/skill/references/forms.md +197 -0
  44. package/skill/references/interaction.md +794 -0
  45. package/skill/references/looks.md +267 -0
  46. package/skill/references/pitfalls.md +202 -0
  47. package/src/check/README.md +176 -0
  48. package/src/check/browser.js +92 -0
  49. package/src/check/build.js +439 -0
  50. package/src/check/colors.js +324 -0
  51. package/src/check/findings.js +247 -0
  52. package/src/check/index.js +508 -0
  53. package/src/check/inpage.js +1332 -0
  54. package/src/check/interact.js +266 -0
  55. package/src/check/png.js +98 -0
  56. package/src/check/report.js +106 -0
  57. package/src/check/source.js +387 -0
  58. package/src/check/sourcemap.js +75 -0
  59. package/src/guard/note.js +30 -0
  60. package/src/guard/render.js +17 -0
  61. package/src/guard/rhp.js +612 -0
  62. package/src/guard/rules.js +125 -0
  63. package/src/server.js +266 -0
  64. package/src/skill.js +140 -0
@@ -0,0 +1,138 @@
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>Sales against target by region</title>
7
+ <meta name="description" content="Bullet chart: actual against target for each item, over gray bands for poor, fair and good, with a switch that sorts by the actual value or by the share of target. Use it for performance against goals, budgets or quotas.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: band Bars, the actual Bar and a target Tick in one slat, and a switch that sorts by value or by share of target.
11
+ Leave: the design. This poster was made for its own subject (a page of an annual report, black on white with one orange, the year on a tab under the top rule).
12
+ Its look, the place of its parts and the way its marks are drawn say that subject: design yours from your own (design.md, section 2).
13
+ -->
14
+ <link rel="preconnect" href="https://fonts.googleapis.com">
15
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
16
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,400..900&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: #d4d3cf; font-family: Archivo, "Helvetica Neue", Arial, sans-serif; }
21
+ /* The poster: a page of an annual report, black on white with one orange, and the year on a tab under the top rule */
22
+ .poster { position: relative; display: grid; max-width: 960px; margin: 0 auto; padding: 48px 52px 30px; overflow: hidden; color: #141414;
23
+ background: linear-gradient(#141414 0 0) 52px 34px / calc(100% - 104px) 3px no-repeat, #fcfcfa; box-shadow: 0 1px 2px rgb(30 29 26 / .2), 0 26px 48px -28px rgb(30 29 26 / .55); }
24
+ .poster > * { min-width: 0; }
25
+ .poster::after { content: "FY 2025"; position: absolute; top: 37px; right: 52px; padding: 7px 10px 6px; background: #141414; color: #fcfcfa;
26
+ font: 800 12px/1 Archivo, Arial, sans-serif; font-stretch: 112%; letter-spacing: .14em; }
27
+ .poster figcaption { display: grid; gap: 12px; margin-bottom: 20px; padding-top: 18px; }
28
+ .poster .kicker { font-size: 12px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; color: #c4430c; }
29
+ .poster .headline { max-width: 15em; font-size: 50px; font-weight: 800; font-stretch: 112%; line-height: .98; letter-spacing: -.03em; text-wrap: balance; }
30
+ .poster .dek { max-width: 56ch; font-size: 16px; line-height: 1.5; color: #4b4a47; }
31
+ .poster .note { display: block; margin-top: 18px; padding-top: 12px; border-top: 1px solid #141414; font-size: 12px; line-height: 1.5; color: #5d5c58; }
32
+ /* The key and the sort switch, on one line above the chart */
33
+ .bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 20px; margin-bottom: 18px; }
34
+ .key { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: 0; font-size: 12px; color: #4b4a47; }
35
+ .key span { display: inline-flex; align-items: center; gap: 6px; }
36
+ .key i { display: inline-block; width: 16px; height: 12px; }
37
+ .switch { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
38
+ .switch button { padding: 6px 11px; border: 1.5px solid #141414; border-radius: 99px; background: none; font: 700 13px/1 Archivo, Arial, sans-serif; color: #141414; cursor: pointer; }
39
+ .switch button[aria-pressed="true"] { background: #141414; color: #fcfcfa; }
40
+ .switch button:focus-visible { outline: 2px solid #e04e0e; outline-offset: 2px; }
41
+ @media (max-width: 560px) {
42
+ body { padding: 24px 16px 32px; }
43
+ .poster { padding: 38px 18px 20px; background-position: 18px 26px, 0 0; background-size: calc(100% - 36px) 3px, auto; }
44
+ .poster::after { top: 29px; right: 18px; padding: 6px 8px 5px; font-size: 11px; }
45
+ .poster figcaption { padding-top: 30px; }
46
+ .poster .headline { font-size: 34px; }
47
+ .poster .dek { font-size: 15px; }
48
+ }
49
+ </style>
50
+ </head>
51
+ <body>
52
+ <div id="chart"></div>
53
+ <script type="module">
54
+ import { Chart, Plot, Bar, Tick, Label, Poster, slat, sortBy, nice, html, render, createSignal } from "@bezda/rhp/standalone";
55
+
56
+ // Data: a fictional company's sales and target for each region in a year, in millions of US dollars. Illustrative.
57
+ // Replace with your own.
58
+ const REGIONS = [
59
+ { region: "North America", sales: 12.4, target: 11 },
60
+ { region: "Europe", sales: 9.1, target: 10 },
61
+ { region: "Asia Pacific", sales: 10.6, target: 9.5 },
62
+ { region: "Latin America", sales: 3, target: 4 },
63
+ { region: "Middle East & Africa", sales: 2.1, target: 2 },
64
+ ];
65
+ // The bands, as shares of each region's target: poor below 80%, fair to 100%, good to 120%
66
+ const POOR = 0.8, GOOD = 1.2;
67
+ const BANDS = { poor: "#928e86", fair: "#b4b1aa", good: "#e0ded8" };
68
+ const ORANGE = "#e04e0e";
69
+
70
+ const scale = nice(0, Math.max(...REGIONS.map((r) => Math.max(r.sales, r.target * GOOD))), 5);
71
+ const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: "compact", minimumFractionDigits: 1, maximumFractionDigits: 1 });
72
+ const tickMoney = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: "compact", maximumFractionDigits: 1 });
73
+ const percent = new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 0 });
74
+
75
+ // Slat type: a region's name and numbers over its bullet. The bands are three Bars end to end, the sales a thinner Bar
76
+ // over them, the target a Tick across, and the share of target sits past the chart's end.
77
+ const Region = slat({
78
+ thickness: 74,
79
+ room: { start: 4, end: 92 },
80
+ css: `
81
+ .head { position: absolute; top: 2px; left: 0; white-space: nowrap; font-size: 14px; }
82
+ .head b { font-weight: 750; }
83
+ .head span { color: #5d5c58; font-variant-numeric: tabular-nums; }
84
+ .band { --rhp-radius: 0px; }
85
+ .poor { --rhp-start-radius: 3px; }
86
+ .good { --rhp-end-radius: 3px; }
87
+ .sales { --rhp-radius: 1px; }
88
+ .target { --rhp-tick-width: 3px; border-radius: 2px; }
89
+ .pct { font-size: 20px; font-weight: 800; font-stretch: 110%; letter-spacing: -.02em; text-align: right; font-variant-numeric: tabular-nums; }
90
+ .pct small { font-size: 11px; vertical-align: 3px; margin-right: 3px; }
91
+ .short .pct { color: #5d5c58; }
92
+ @container (max-width: 480px) { .head { font-size: 13px; } .pct { font-size: 17px; } }
93
+ `,
94
+ }, (d) => html`
95
+ <div class=${() => (d.sales >= d.target ? "region" : "region short")}>
96
+ <div class="head"><b>${() => d.region}</b><span>${() => ` ${money.format(d.sales * 1e6)} of ${money.format(d.target * 1e6)}`}</span></div>
97
+ <${Bar} to=${() => d.target * POOR} thick="22px" color=${BANDS.poor} class="band poor" />
98
+ <${Bar} from=${() => d.target * POOR} to=${() => d.target} thick="22px" color=${BANDS.fair} class="band fair" />
99
+ <${Bar} from=${() => d.target} to=${() => d.target * GOOD} thick="22px" color=${BANDS.good} class="band good" />
100
+ <${Bar} to=${() => d.sales} thick="8px" color="ink" class="sales" />
101
+ <${Tick} at=${() => d.target} thick="32px" color=${ORANGE} class="target" />
102
+ <${Label} edge="end" class="pct"><small>${() => (d.sales >= d.target ? "▲" : "▼")}</small>${() => percent.format(d.sales / d.target)}<//>
103
+ </div>`);
104
+
105
+ // Chart component: the switch sorts the regions by sales or by their share of target, and the slats slide
106
+ function SalesByRegion() {
107
+ const [by, setBy] = createSignal("sales");
108
+ const SORTS = { sales: "Sales", share: "% of target" };
109
+ return html`
110
+ <${Poster}
111
+ kicker="Sales by region"
112
+ title="Three of five regions beat their sales target"
113
+ dek="Each region against its own target. Latin America fell furthest short, at three quarters of its target; North America beat its target by the most."
114
+ note="Illustrative data for a fictional company, in millions of US dollars. Bands: poor is under 80% of target, fair is 80 to 100%, good is above it.">
115
+ <div class="bar">
116
+ <p class="key">
117
+ <span><i style=${{ background: BANDS.poor }}></i>Poor</span>
118
+ <span><i style=${{ background: BANDS.fair }}></i>Fair</span>
119
+ <span><i style=${{ background: BANDS.good }}></i>Good</span>
120
+ <span><i style=${{ height: "4px", background: "#141414" }}></i>Sales</span>
121
+ <span><i style=${{ width: "3px", height: "14px", background: ORANGE }}></i>Target</span>
122
+ </p>
123
+ <span class="switch" role="group" aria-label="Sort by">Sort
124
+ ${Object.keys(SORTS).map((k) => html`<button type="button" aria-pressed=${() => by() === k} onClick=${() => setBy(k)}>${SORTS[k]}</button>`)}
125
+ </span>
126
+ </div>
127
+ <${Chart} scale=${[scale.min, scale.max]} ticks=${scale.ticks} format=${(v) => (v ? tickMoney.format(v * 1e6) : "$0")}
128
+ label="Sales against target by region, in millions of US dollars"
129
+ theme=${{ font: "Archivo, 'Helvetica Neue', Arial, sans-serif", ink: "#141414", muted: "#5d5c58", grid: "#e2e0db" }}>
130
+ <${Plot} rows=${REGIONS} rank=${(d) => (by() === "sales" ? d.sales : d.sales / d.target)} order=${sortBy("rank", "desc")}>${Region}<//>
131
+ <//>
132
+ <//>`;
133
+ }
134
+
135
+ render(() => html`<${SalesByRegion} />`, document.getElementById("chart"));
136
+ </script>
137
+ </body>
138
+ </html>
@@ -0,0 +1,179 @@
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
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: a thin Bar for the wick and a wide one for the body (hollow when the day rose), a crosshair in an overlay Plot, and a price readout on one line.
11
+ Leave: the design. This poster was made for its own subject (a stock's month printed on an engraved share certificate, ivory paper inside a double rule).
12
+ Its look, the place of its parts and the way its marks are drawn say that subject: design yours from your own (design.md, section 2).
13
+ -->
14
+ <link rel="preconnect" href="https://fonts.googleapis.com">
15
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
16
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,500..700;1,500..600&family=IBM+Plex+Mono:wght@400;500;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
+ :root { color-scheme: light; }
20
+ * { box-sizing: border-box; }
21
+ body { margin: 0; padding: 48px 16px; background: #27292c; font-family: "Cormorant Garamond", Georgia, serif; color: #22201b; }
22
+ /* The poster: an engraved share certificate, ivory paper inside a double rule */
23
+ .poster { max-width: 960px; margin: 0 auto; padding: 50px 56px 34px; line-height: 1.4;
24
+ background: repeating-linear-gradient(45deg, rgb(80 64 32 / .028) 0 1px, transparent 1px 5px), #f5efe0;
25
+ 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); }
26
+ .poster figcaption { display: grid; gap: 8px; margin-bottom: 16px; text-align: center; }
27
+ .poster .kicker { font: 600 12px/1.3 "IBM Plex Mono", ui-monospace, Menlo, monospace; letter-spacing: .24em; text-transform: uppercase; color: #6b6252; }
28
+ .poster .headline { font-size: 60px; font-weight: 700; line-height: .98; letter-spacing: -.01em; text-wrap: balance; }
29
+ .poster .dek { max-width: 54ch; margin: 2px auto 0; font-size: 19px; font-style: italic; font-weight: 500; color: #4a4437; }
30
+ .poster .note { display: block; margin-top: 16px; font: 400 12px/1.5 "IBM Plex Mono", ui-monospace, Menlo, monospace; color: #6b6252; text-align: center; }
31
+ /* The readout: the picked day's prices on one ticker line (two on a phone), so the chart never moves */
32
+ .ticker { display: flex; flex-wrap: wrap; justify-content: center; gap: 2px 18px; min-height: 2.6em; margin: 0 0 14px; padding: 8px 0;
33
+ border-block: 1px solid #8f7f5f; font: 500 14px/1.6 "IBM Plex Mono", ui-monospace, Menlo, monospace; font-variant-numeric: tabular-nums; }
34
+ .ticker b { font-weight: 600; }
35
+ .ticker span { color: #6b6252; }
36
+ .ticker .up { color: #16784f; font-weight: 600; }
37
+ .ticker .down { color: #b2352a; font-weight: 600; }
38
+ @media (max-width: 600px) {
39
+ body { padding: 16px; }
40
+ .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; }
41
+ .poster .kicker { font-size: 10.5px; letter-spacing: .16em; }
42
+ .poster .headline { font-size: 40px; }
43
+ .poster .dek { font-size: 17px; }
44
+ .ticker { gap: 0 12px; min-height: 4.4em; font-size: 12.5px; }
45
+ }
46
+ </style>
47
+ </head>
48
+ <body>
49
+ <div id="chart"></div>
50
+ <script type="module">
51
+ import { Chart, Plot, Bar, Tick, Label, Poster, Show, slat, nice, html, render, createSignal } from "@bezda/rhp/standalone";
52
+
53
+ // 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.
54
+ const DAYS = [
55
+ { date: "2026-03-02", open: 46.10, high: 47.10, low: 45.80, close: 46.85 },
56
+ { date: "2026-03-03", open: 46.90, high: 47.75, low: 46.55, close: 47.40 },
57
+ { date: "2026-03-04", open: 47.35, high: 47.80, low: 46.70, close: 47.05 },
58
+ { date: "2026-03-05", open: 47.10, high: 48.45, low: 46.95, close: 48.20 },
59
+ { date: "2026-03-06", open: 48.25, high: 49.20, low: 47.90, close: 48.90 },
60
+ { date: "2026-03-09", open: 48.80, high: 49.05, low: 47.85, close: 48.35 },
61
+ { date: "2026-03-10", open: 48.40, high: 49.85, low: 48.20, close: 49.60 },
62
+ { date: "2026-03-11", open: 49.65, high: 50.70, low: 49.30, close: 50.30 },
63
+ { date: "2026-03-12", open: 50.20, high: 51.70, low: 50.05, close: 51.45 },
64
+ { date: "2026-03-13", open: 51.50, high: 51.95, low: 50.60, close: 51.10 },
65
+ { date: "2026-03-16", open: 51.20, high: 52.60, low: 50.95, close: 52.35 },
66
+ { date: "2026-03-17", open: 52.40, high: 53.30, low: 52.05, close: 52.90 },
67
+ { date: "2026-03-18", open: 46.10, high: 46.50, low: 43.90, close: 44.80, note: "Earnings" },
68
+ { date: "2026-03-19", open: 44.70, high: 45.95, low: 44.10, close: 45.60 },
69
+ { date: "2026-03-20", open: 45.50, high: 45.85, low: 44.40, close: 44.95 },
70
+ { date: "2026-03-23", open: 45.10, high: 46.45, low: 44.85, close: 46.20 },
71
+ { date: "2026-03-24", open: 46.30, high: 47.40, low: 46.00, close: 47.10 },
72
+ { date: "2026-03-25", open: 47.05, high: 47.35, low: 46.20, close: 46.60 },
73
+ { date: "2026-03-26", open: 46.70, high: 48.10, low: 46.45, close: 47.85 },
74
+ { date: "2026-03-27", open: 47.90, high: 48.75, low: 47.55, close: 48.40 },
75
+ { date: "2026-03-30", open: 48.30, high: 48.60, low: 47.60, close: 47.95 },
76
+ { date: "2026-03-31", open: 48.10, high: 49.05, low: 47.85, close: 48.75 },
77
+ ];
78
+ const FOCUS = "2026-03-18"; // the day the readout opens on
79
+
80
+ // Each day's change from the close before it (the first day from its own open)
81
+ const ROWS = DAYS.map((d, i) => ({ ...d, change: d.close / (i ? DAYS[i - 1].close : d.open) - 1 }));
82
+ 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
83
+ const opening = Math.max(0, DAYS.findIndex((d) => d.date === FOCUS));
84
+ const dollars = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
85
+ const whole = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
86
+ const percent = new Intl.NumberFormat("en-US", { style: "percent", minimumFractionDigits: 1, maximumFractionDigits: 1, signDisplay: "exceptZero" });
87
+ const day = new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" });
88
+ const date = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
89
+ const isMonday = (iso) => new Date(iso).getUTCDay() === 1;
90
+
91
+ // Slat types: a trading day (wick and body), and the crosshair at the picked day's close
92
+ const Candle = slat({
93
+ inset: 0.2,
94
+ room: { start: 28, end: 14 },
95
+ css: `
96
+ .wick { --rhp-radius: 0px; background: var(--move); }
97
+ .body { --rhp-radius: 1px; background: var(--move); }
98
+ .rose .body { background: var(--rhp-surface); box-shadow: inset 0 0 0 1.5px var(--move); }
99
+ .rose { --move: #16784f; }
100
+ .fell { --move: #c0392b; }
101
+ .day.on { background: rgb(80 64 32 / .08); }
102
+ .day:focus-visible { outline: 2px solid #22201b; outline-offset: -2px; }
103
+ .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; }
104
+ .note { padding: 0 0 6px; font: 600 11px/1 "IBM Plex Mono", ui-monospace, Menlo, monospace; letter-spacing: .08em; text-transform: uppercase; }
105
+ .say { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
106
+ @container (max-width: 480px) { .alt { display: none; } } /* every other Monday on a narrow chart */
107
+ `,
108
+ }, (d) => html`
109
+ <div class=${() => ["day", d.close >= d.open ? "rose" : "fell", d.picked === d.index && "on"].filter(Boolean).join(" ")} data-row=${() => d.index}>
110
+ <${Bar} from=${() => d.low} to=${() => d.high} thick="1.5px" class="wick" />
111
+ <${Bar} from=${() => d.open} to=${() => d.close} thick=${0.62} class="body" />
112
+ <${Show} when=${() => isMonday(d.date)}>
113
+ <${Label} edge="start" class=${() => (Math.floor((new Date(d.date).getUTCDate() - 1) / 7) % 2 ? "date alt" : "date")}>${() => date.format(new Date(d.date))}<//>
114
+ <//>
115
+ <${Show} when=${() => d.note}><${Label} at=${() => d.high} class="note">${() => d.note}<//><//>
116
+ <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>
117
+ </div>`);
118
+
119
+ const Crosshair = slat({
120
+ room: { after: 62 }, // a gutter right of the plot for the price
121
+ css: `
122
+ .level { --rhp-tick-width: 1px; background: #22201b; }
123
+ .price { left: 100%; translate: 6px 50%; padding: 2px 5px; border-radius: 2px; background: #22201b; color: #f5efe0;
124
+ font: 600 11px/1.3 "IBM Plex Mono", ui-monospace, Menlo, monospace; }
125
+ `,
126
+ }, (c) => html`
127
+ <div>
128
+ <${Tick} at=${() => c.close} thick=${1} class="level" />
129
+ <${Label} at=${() => c.close} class="price">${() => dollars.format(c.close)}<//>
130
+ </div>`);
131
+
132
+ // Chart component: the day the reader picks (pointer, tap or arrow keys) is read out and marked by the crosshair
133
+ function SharePrice() {
134
+ const [picked, setPicked] = createSignal(null);
135
+ const index = () => picked() ?? opening;
136
+ const shown = () => ROWS[index()];
137
+ // The row of the slat an element is in, or null (between slats, on the axis or the text)
138
+ const rowOf = (el) => {
139
+ const root = el.closest("[data-row]");
140
+ return root ? +root.dataset.row : null;
141
+ };
142
+ // A mouse or a pen picks as it moves, a tap or a click picks, the keyboard picks; on no slat the pick stays
143
+ const pick = (e) => {
144
+ const row = rowOf(e.target);
145
+ if (row != null) setPicked(row);
146
+ };
147
+ // Leaving the poster: back to the slat that has focus (a keyboard's or a click's pick), or none
148
+ const leave = (e) => {
149
+ if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
150
+ setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
151
+ };
152
+ return html`
153
+ <${Poster}
154
+ kicker="Share price · March 2026"
155
+ title="A 15% drop on earnings day erased the month's gains"
156
+ 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."
157
+ note="Illustrative data. Point at a day, tap it, or Tab into the chart and use the arrow keys."
158
+ onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
159
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
160
+ <p class="ticker">
161
+ <b>${() => day.format(new Date(shown().date))}</b>
162
+ <span>Open ${() => dollars.format(shown().open)}</span>
163
+ <span>High ${() => dollars.format(shown().high)}</span>
164
+ <span>Low ${() => dollars.format(shown().low)}</span>
165
+ <span>Close <b class=${() => (shown().change >= 0 ? "up" : "down")}>${() => `${dollars.format(shown().close)} ${percent.format(shown().change)}`}</b></span>
166
+ </p>
167
+ <${Chart} orientation="vertical" height=${320} scale=${[range.min, range.max]} ticks=${range.ticks} format=${(v) => whole.format(v)}
168
+ label="Candlestick chart of one share's daily prices in March 2026"
169
+ theme=${{ font: '"IBM Plex Mono", ui-monospace, Menlo, monospace', ink: "#22201b", muted: "#6b6252", grid: "#e2d9c4", surface: "#f5efe0" }}>
170
+ <${Plot} rows=${ROWS} picked=${index} keyboard=${true}>${Candle}<//>
171
+ <${Plot} overlap=${true} slats=${1} close=${() => shown().close} style=${{ "pointer-events": "none" }}>${Crosshair}<//>
172
+ <//>
173
+ <//>`;
174
+ }
175
+
176
+ render(() => html`<${SharePrice} />`, document.getElementById("chart"));
177
+ </script>
178
+ </body>
179
+ </html>
@@ -0,0 +1,138 @@
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
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: vertical Bars over twelve periods, a bracket under a group of columns, and a scrub that reads out a running total.
11
+ Leave: the design. This poster was made for its own subject (a monsoon year as twelve rain gauges in the night rain, each month a glass tube with its water inside).
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&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: #cdd0d2; font-family: Archivo, "Helvetica Neue", Arial, sans-serif; }
21
+ /* The poster: a night sky of near-black paper with fine slanting rain, white type, the water in blue */
22
+ .poster { max-width: 960px; margin: 0 auto; padding: 48px 52px 30px; color: #eef2f5; border-radius: 6px;
23
+ background: repeating-linear-gradient(104deg, transparent 0 26px, rgb(255 255 255 / .035) 26px 27px), #141618;
24
+ box-shadow: 0 30px 60px -30px rgb(40 44 48 / .7); }
25
+ .poster figcaption { display: grid; gap: 14px; margin-bottom: 28px; }
26
+ .poster .kicker { font-size: 12px; font-weight: 700; letter-spacing: .22em; text-transform: uppercase; color: #6cc3ff; }
27
+ .poster .headline { max-width: 13em; font-size: 54px; font-weight: 800; font-stretch: 125%; line-height: 1; letter-spacing: -.01em; text-wrap: balance; }
28
+ .poster .dek { max-width: 54ch; font-size: 16px; line-height: 1.5; color: #b5bdc4; }
29
+ .poster .note { display: block; margin-top: 24px; font-size: 12px; line-height: 1.5; color: #8d969e; }
30
+ /* The readout: how much rain has fallen by the month in focus */
31
+ .readout { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 14px; margin: 0 0 22px; padding-top: 16px; border-top: 1px solid #2c3135; font-size: 15px; line-height: 1.4; color: #b5bdc4; }
32
+ .readout b { font-size: 30px; font-weight: 700; font-stretch: 110%; color: #fff; font-variant-numeric: tabular-nums; }
33
+ /* Where the figure and its longest text no longer fit on one line, the text goes under the figure with two lines
34
+ kept, so the readout has one height whatever it says and the chart never moves */
35
+ @media (max-width: 720px) { .readout { flex-direction: column; align-items: start; } .readout span { min-height: 2lh; } }
36
+ @media (max-width: 560px) {
37
+ body { padding: 16px 16px 32px; }
38
+ .poster { padding: 26px 16px 18px; }
39
+ .poster .headline { font-size: 32px; }
40
+ .poster .dek { font-size: 15px; }
41
+ .readout b { font-size: 24px; }
42
+ }
43
+ </style>
44
+ </head>
45
+ <body>
46
+ <div id="chart"></div>
47
+ <script type="module">
48
+ import { Chart, Plot, Bar, Label, Poster, Show, slat, nice, html, render, createSignal, createMemo } from "@bezda/rhp/standalone";
49
+
50
+ // Data: rainfall in mm per month, and the season marked under its months. Illustrative data; replace with your own.
51
+ const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
52
+ const RAIN = [1, 1, 0, 1, 14, 520, 840, 590, 340, 90, 18, 6];
53
+ const SEASON = { name: "Monsoon", from: 5, to: 8 }; // June to September; null for no bracket
54
+
55
+ const mm = new Intl.NumberFormat("en-US");
56
+ const percent = new Intl.NumberFormat("en-US", { style: "percent" });
57
+ // A share in whole percents that never rounds some rain to "0%" or a year not yet over to "100%"
58
+ const share = (x) => (x > 0 && x < 0.005 ? "under 1%" : x >= 0.995 && x < 1 ? "over 99%" : percent.format(x));
59
+ const year = RAIN.reduce((a, b) => a + b, 0);
60
+ const scale = nice(0, Math.max(...RAIN), 4); // 0 to 1,000 mm, a line every 250
61
+
62
+ // 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.
63
+ // The season's first month also draws the bracket under the season, as wide as its months.
64
+ const Month = slat({
65
+ inset: { vertical: 0.24 },
66
+ room: { start: 64, end: 22 },
67
+ css: `
68
+ .tube { background: rgb(255 255 255 / .04); box-shadow: inset 0 0 0 1px rgb(255 255 255 / .14); --rhp-radius: 3px; }
69
+ .water { background: linear-gradient(var(--rhp-toward-end), #1d5fae, #3d9be9); box-shadow: inset 0 2px 0 #9fd6ff; --rhp-radius: 2px; }
70
+ .mm { font-size: 12px; font-weight: 600; color: #e6ecf1; font-variant-numeric: tabular-nums; }
71
+ .name { font-size: 12px; font-weight: 600; color: #b5bdc4; text-transform: uppercase; letter-spacing: .06em; }
72
+ .now .tube { box-shadow: inset 0 0 0 1.5px #6cc3ff; }
73
+ .later .water { opacity: .3; }
74
+ .later .mm { opacity: .6; } /* faded, still readable */
75
+ .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; }
76
+ .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; }
77
+ .month:focus-visible { outline: 2px solid #6cc3ff; outline-offset: -2px; border-radius: 4px; }
78
+ .name .short { display: none; }
79
+ @container (max-width: 520px) { .name { font-size: 11px; font-stretch: 70%; letter-spacing: 0; } .mm { font-size: 11px; font-stretch: 80%; } }
80
+ /* Columns under about 21px: the month's first letter, the whole name staying in the readout and the slat's label */
81
+ @container (max-width: 293px) { .name .long { display: none; } .name .short { display: inline; } }
82
+ `,
83
+ }, (d) => html`
84
+ <div class=${() => "month " + d.step} data-row=${() => d.index} aria-label=${() => `${d.month}: ${mm.format(d.mm)} mm`}>
85
+ <${Bar} to=${scale.max} class="tube" />
86
+ <${Bar} to=${() => d.mm} class="water" />
87
+ <${Label} at=${() => d.mm} class="mm">${() => mm.format(d.mm)}<//>
88
+ <${Label} edge="start" class="name"><span class="long">${() => d.month.slice(0, 3)}</span><span class="short">${() => d.month[0]}</span><//>
89
+ <${Show} when=${() => d.index === SEASON?.from}>
90
+ <span class="season" style=${() => `--months: ${SEASON.to - SEASON.from + 1}`}><b>${() => SEASON.name}</b></span>
91
+ <//>
92
+ </div>`);
93
+
94
+ // Chart component: the month the reader scrubs to splits the year, and the readout adds up the rain so far
95
+ function MonsoonYear() {
96
+ const [at, setAt] = createSignal(null);
97
+ const end = () => at() ?? MONTHS.length - 1;
98
+ const sofar = createMemo(() => RAIN.slice(0, end() + 1).reduce((a, b) => a + b, 0));
99
+ // The row of the slat an element is in, or null (between slats, on the axis or the text)
100
+ const rowOf = (el) => {
101
+ const root = el.closest("[data-row]");
102
+ return root ? +root.dataset.row : null;
103
+ };
104
+ // A mouse or a pen picks as it moves, a tap or a click picks, the keyboard picks; on no slat the pick stays
105
+ const pick = (e) => {
106
+ const row = rowOf(e.target);
107
+ if (row != null) setAt(row);
108
+ };
109
+ // Leaving the plot or poster: back to the slat that has focus (a keyboard's or a click's pick), or none
110
+ const leave = (e) => {
111
+ if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
112
+ setAt(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
113
+ };
114
+ return html`
115
+ <${Poster}
116
+ kicker="A monsoon city · rain by month"
117
+ title="Four months bring nearly all of the year's rain"
118
+ 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."
119
+ note="Illustrative data, shaped like a monsoon city's year."
120
+ onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
121
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setAt(null)}>
122
+ <p class="readout" aria-live="polite">
123
+ <b>${() => mm.format(sofar()) + " mm"}</b>
124
+ <span>${() => (at() == null ? "in the whole year. Tap or point at a month to add it up." : `by the end of ${MONTHS[end()]}, ${share(sofar() / year)} of the year's rain`)}</span>
125
+ </p>
126
+ <${Chart} orientation="vertical" height=${300} scale=${[scale.min, scale.max]} ticks=${scale.ticks} format=${(v) => mm.format(v)}
127
+ label="Rainfall by month, in millimetres"
128
+ theme=${{ font: 'Archivo, "Helvetica Neue", Arial, sans-serif', ink: "#eef2f5", muted: "#8d969e", grid: "#2c3135" }}>
129
+ <${Plot} month=${MONTHS} mm=${RAIN} step=${(d) => (d.index > end() ? "later" : d.index === at() ? "now" : "")} keyboard=${true}
130
+ ref=${(el) => el.addEventListener("pointerleave", leave)}>${Month}<//>
131
+ <//>
132
+ <//>`;
133
+ }
134
+
135
+ render(() => html`<${MonsoonYear} />`, document.getElementById("chart"));
136
+ </script>
137
+ </body>
138
+ </html>