@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,352 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>The world's tallest buildings</title>
7
+ <meta name="description" content="Silhouettes: each item drawn in its own outline (a shape() per row on a vertical Bar) to one scale on one street, its height on its top, the picked one's level carried across to the tallest with a dimension line, names on the street on wide screens and ranks with a key on narrow ones. Use it for things a reader knows by their shape: buildings, mountains, ships, bottles, animals.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: an outline per row built from measured profiles with shape(), drawn to one scale on one ground line; a dashed level from the picked item's top across to the leader, with a dimension line for the gap; a slat type per width (names on the street, or ranks with a key below); and a readout of the picked item.
11
+ Leave: the design. This poster was made for its own subject (the ten tallest buildings as a skyline on one street under a pale blue sky, the headline set in the sky over the lowest towers, the street a dark band holding the names, the readout and the note).
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=League+Gothic&family=Barlow:wght@400;500;600;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
+ /* Look: the sky over one imaginary street. A pale daytime sky for the paper, the towers in its own haze,
20
+ the one in focus in deep navy, the street a navy ground, and a surveyor's red for the measurement. */
21
+ :root {
22
+ --page: #9fb9d4; --sky: #c4d9ee; --paper: #dbe8f5; --ink: #0f2540; --soft: #3d5878; --rule: #b7cde2;
23
+ --accent: #c8102e; --quiet: #5b789c;
24
+ --ground: #0f2540; --on-ground: #e4edf7; --ground-soft: #a9bdd6;
25
+ --display: "League Gothic", "Arial Narrow", Impact, sans-serif;
26
+ --text: Barlow, "Helvetica Neue", Arial, sans-serif;
27
+ --pad: 60px;
28
+ }
29
+ * { box-sizing: border-box; }
30
+ body { margin: 0; padding: 48px 16px; background: var(--page, #9fb9d4); }
31
+
32
+ /* The poster: sky on top, the street at the foot. Every part is a grid item, placed per width below. */
33
+ .poster { display: grid; grid-template-columns: minmax(0, 1fr); max-width: 1120px; margin: 0 auto; padding: var(--pad) var(--pad) 0; overflow: hidden;
34
+ color: var(--ink); font: 16px/1.5 var(--text); background: linear-gradient(var(--sky), var(--paper) 72%);
35
+ box-shadow: 0 30px 60px -34px rgb(15 37 64 / .6);
36
+ grid-template-areas: "kicker" "headline" "dek" "stage" "readout" "key" "note"; }
37
+ .poster figcaption { display: contents; }
38
+ .poster .kicker { grid-area: kicker; margin-bottom: 12px; font: 700 13px/1.3 var(--text); letter-spacing: .16em; text-transform: uppercase; color: var(--ink); }
39
+ .poster .kicker::before { content: ""; display: inline-block; width: 9px; height: 9px; margin-right: 10px; border-radius: 50%; background: var(--accent); vertical-align: 1px; }
40
+ .poster .headline { grid-area: headline; max-width: 13em; font: 400 clamp(44px, 5.6vw, 66px)/.95 var(--display); letter-spacing: .005em; text-wrap: balance; }
41
+ .poster .dek { grid-area: dek; max-width: 46ch; margin-top: 16px; font-size: 17px; line-height: 1.45; color: var(--soft); }
42
+ .poster .stage { grid-area: stage; position: relative; min-width: 0; margin-top: 18px; }
43
+ /* The street: a navy ground under the towers, from edge to edge of the poster, as tall as the room for the names */
44
+ .poster .stage::before { content: ""; position: absolute; left: calc(-1 * var(--pad)); right: calc(-1 * var(--pad)); bottom: 0; height: var(--street); background: var(--ground); }
45
+ /* Everything after the chart stands on the street */
46
+ .poster .readout, .poster .key, .poster .note { margin-inline: calc(-1 * var(--pad)); padding-inline: var(--pad); background: var(--ground); color: var(--on-ground); }
47
+ /* Every version of the readout in one grid cell, only the current one shown: it is as tall as the longest at every
48
+ width, so nothing under it moves whichever tower is picked */
49
+ .poster .readout { grid-area: readout; display: grid; margin-top: 0; margin-bottom: 0; padding-top: 8px; padding-bottom: 10px; font-size: 17px; line-height: 1.4; font-variant-numeric: tabular-nums; }
50
+ .poster .readout b { font-weight: 700; }
51
+ .poster .readout > .v { grid-area: 1 / 1; visibility: hidden; }
52
+ .poster .readout > .v.on { visibility: visible; }
53
+ .poster .readout .v span { display: block; color: var(--ground-soft); }
54
+ .poster .key { grid-area: key; margin-block: 0; padding-block: 6px 4px; list-style: none; font-size: 15px; line-height: 1.35; }
55
+ .poster .key li { display: grid; grid-template-columns: 1.6em minmax(0, 1fr) auto; column-gap: 8px; padding: 5px 0; border-top: 1px solid rgb(255 255 255 / .12); cursor: default; }
56
+ .poster .key li b { font: 400 19px/1 var(--display); color: var(--ground-soft); }
57
+ .poster .key li em { font-style: normal; color: var(--ground-soft); }
58
+ .poster .key li span { font-variant-numeric: tabular-nums; }
59
+ /* The picked tower's line is marked by a band, not by weight: bold text would wrap and move what is under it */
60
+ .poster .key li.lit { background: rgb(255 255 255 / .1); box-shadow: -8px 0 0 rgb(255 255 255 / .1), 8px 0 0 rgb(255 255 255 / .1); }
61
+ .poster .key li.lit b { color: var(--on-ground); }
62
+ .poster .note { grid-area: note; display: block; padding-top: 12px; padding-bottom: 26px; border-top: 1px solid rgb(255 255 255 / .16); font-size: 12.5px; line-height: 1.5; color: var(--ground-soft); }
63
+ .poster :focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
64
+
65
+ /* Wide screens: the kicker and the headline float in the sky over the five lowest towers, the dek over the next three.
66
+ The columns follow the towers: the plot is the whole stage, one tenth per tower.
67
+ This place suits these heights: with other data, check that the words still clear the tips (pitfalls.md, "Text and
68
+ layers laid over the chart"). */
69
+ @media (min-width: 980px) {
70
+ .poster { grid-template-columns: calc(100% * .5) calc(100% * .3) minmax(0, 1fr); grid-template-rows: auto auto 1fr auto auto;
71
+ grid-template-areas: "kicker dek ." "headline dek ." ". . ." "readout readout readout" "note note note"; }
72
+ .poster .headline, .poster .kicker, .poster .dek { position: relative; z-index: 2; }
73
+ .poster .headline { padding-right: 28px; }
74
+ .poster .dek { align-self: start; margin-top: 2px; padding-right: 20px; font-size: 16px; }
75
+ .poster .stage { grid-area: 1 / 1 / 4 / 4; align-self: end; margin-top: 64px; }
76
+ .poster .key { display: none; }
77
+ }
78
+ @media (max-width: 640px) {
79
+ body { padding: 16px 16px 32px; }
80
+ :root { --pad: 16px; }
81
+ .poster { padding-top: 24px; }
82
+ .poster .kicker { font-size: 12px; margin-bottom: 10px; }
83
+ .poster .dek { margin-top: 12px; font-size: 15px; }
84
+ .poster .stage { margin-top: 14px; }
85
+ .poster .readout { font-size: 15px; }
86
+ }
87
+ </style>
88
+ </head>
89
+ <body>
90
+ <div id="chart"></div>
91
+ <script type="module">
92
+ import { Chart, Plot, Bar, Tick, Label, Place, Poster, Show, slat, shape, nice, html, render, createSignal } from "@bezda/rhp/standalone";
93
+
94
+ // Data: the ten tallest completed buildings in the world, height to the architectural top in metres
95
+ // (spires count, antennas do not), from the Council on Tall Buildings and Urban Habitat, October 2026.
96
+ const BUILDINGS = [
97
+ { name: "Burj Khalifa", city: "Dubai", year: 2010, floors: 163, height: 828 },
98
+ { name: "Menara Merdeka Maybank", formerly: "Merdeka 118", city: "Kuala Lumpur", year: 2023, floors: 118, height: 679 },
99
+ { name: "Shanghai Tower", city: "Shanghai", year: 2015, floors: 128, height: 632 },
100
+ { name: "Makkah Royal Clock Tower", city: "Mecca", year: 2012, floors: 120, height: 601, clock: { at: 428, size: 43 } }, // its faces: 43 m across, their middle 428 m up
101
+ { name: "Ping An Finance Center", city: "Shenzhen", year: 2017, floors: 115, height: 599 },
102
+ { name: "Lotte World Tower", city: "Seoul", year: 2017, floors: 123, height: 555 },
103
+ { name: "One World Trade Center", city: "New York", year: 2014, floors: 94, height: 541 },
104
+ { name: "Guangzhou CTF Finance Centre", city: "Guangzhou", year: 2016, floors: 111, height: 530 },
105
+ { name: "Tianjin CTF Finance Centre", city: "Tianjin", year: 2019, floors: 97, height: 530 },
106
+ { name: "CITIC Tower", city: "Beijing", year: 2018, floors: 109, height: 528 },
107
+ ];
108
+ const AS_OF = 2026;
109
+ const SOURCE = "Source: Council on Tall Buildings and Urban Habitat (CTBUH), The Skyscraper Center, completed buildings, October 2026. "
110
+ + "Height to the architectural top: spires count, antennas do not. Merdeka 118 was renamed Menara Merdeka Maybank in July 2026. "
111
+ + "Outlines are simplified side views, all drawn to one scale.";
112
+
113
+ // Outlines: each building seen from the side, as [metres above the street, metres from its axis to its edge] from the
114
+ // street up; the left side, and the right side when it differs (an edge past the axis has a negative distance).
115
+ // Measured every 2 m from Phoenix CZE's to-scale elevation diagram, "Tallest buildings 2026" (Wikimedia Commons,
116
+ // CC BY-SA 4.0), and simplified to within a metre; podiums are left out, and the Clock Tower's crescent, too small to draw,
117
+ // ends its spire in a point. Every outline is drawn on one scale, BOX metres wide.
118
+ const PROFILE = {
119
+ "Burj Khalifa": {
120
+ left: [[0, 47], [125, 47], [126, 38], [223, 38], [224, 29.5], [349, 28.5], [350, 20], [504, 20], [506, 14.5], [568, 14.5], [570, 11], [604, 11], [606, 8.5], [740, 7], [742, 4], [776, 4], [778, 2.5], [790, 2.5], [792, 1], [828, 1]],
121
+ right: [[0, 47.5], [99, 47.5], [100, 39], [191, 39], [192, 30], [345, 30], [346, 21.5], [442, 21.5], [444, 16], [526, 16], [528, 12.5], [586, 12.5], [588, 9.5], [644, 9.5], [646, 7], [694, 6.5], [696, 5], [756, 4], [758, 2.5], [790, 2.5], [792, 1], [828, 1]],
122
+ },
123
+ "Menara Merdeka Maybank": {
124
+ left: [[0, -4.5], [76, 6], [152, -2], [222, 6], [294, -1], [310, -1], [346, 3.5], [380, 6], [452, -2], [566, 10], [568, 4.5], [584, 3], [676, 3], [679, 1.5]],
125
+ right: [[0, 81.5], [106, 81.5], [296, 61.5], [380, 68.5], [528, 53.5], [553, 53.5], [554, 7.5], [556, 5], [594, 3], [676, 3], [679, 1.5]],
126
+ },
127
+ "Shanghai Tower": {
128
+ left: [[0, 36.5], [38, 36.5], [92, 39.5], [178, 39.5], [374, 24.5], [448, 21.5], [632, 21.5]],
129
+ right: [[0, 45.5], [38, 45.5], [96, 39.5], [388, 39.5], [396, 38], [480, 35.5], [536, 31], [588, 24.5], [612, 19], [616, 14.5], [618, 9.5], [620, 7], [632, 7]],
130
+ },
131
+ "Makkah Royal Clock Tower": {
132
+ left: [[0, 47], [357, 47.5], [358, 37.5], [397, 41], [398, 34.5], [402, 34.5], [404, 33], [452, 33], [458, 30], [463, 30], [464, 21], [469, 21], [470, 12.5], [472, 10.5], [486, 13], [490, 11.5], [496, 6], [558, 4.5], [601, 0.6]],
133
+ right: [[0, 48], [357, 48], [358, 37.5], [397, 41], [398, 34.5], [402, 34.5], [404, 33], [452, 33], [458, 30], [460, 30.5], [465, 28], [466, 21], [469, 21], [470, 11.5], [474, 10.5], [486, 13], [492, 10], [496, 6], [558, 4.5], [601, 0.6]],
134
+ },
135
+ "Ping An Finance Center": {
136
+ left: [[0, 41.5], [84, 39], [404, 39], [416, 37.5], [538, 32.5], [550, 31], [599, 0.6]],
137
+ right: [[0, 39.5], [402, 39], [404, 37.5], [412, 36.5], [540, 31], [550, 29.5], [599, 0.6]],
138
+ },
139
+ "Lotte World Tower": {
140
+ left: [[0, 44.5], [138, 44.5], [256, 37.5], [555, 10.5]],
141
+ right: [[0, 45.5], [140, 44.5], [254, 38], [555, 8.5]],
142
+ },
143
+ "One World Trade Center": {
144
+ left: [[0, 31.5], [413, 31.5], [414, 17], [424, 17], [426, 12.5], [452, 6], [464, 1], [541, 1]],
145
+ right: [[0, 31.5], [413, 31.5], [414, 17], [424, 17], [426, 11.5], [446, 7.5], [464, 1], [541, 1]],
146
+ },
147
+ "Guangzhou CTF Finance Centre": {
148
+ left: [[0, 39.5], [530, 39.5]],
149
+ right: [[0, 39.5], [437, 39.5], [438, 24], [500, 24], [508, 8.5], [512, -3], [526, -35.5], [530, -35.5]],
150
+ },
151
+ "Tianjin CTF Finance Centre": {
152
+ // its crown ends in two rounded lobes with a dip between them
153
+ left: [[0, 38], [130, 38], [228, 29.5], [436, 23], [518, 15], [524, 12], [528, 9], [530, 6], [529, 3.5], [525, 0.3]],
154
+ right: [[0, 38], [130, 38], [216, 29.5], [308, 27], [328, 28], [438, 23], [518, 14.5], [524, 11], [528, 8.5], [530, 6], [529, 3.5], [525, 0.3]],
155
+ },
156
+ "CITIC Tower": {
157
+ left: [[0, 43.5], [32, 43.5], [108, 38], [138, 38], [140, 36.5], [150, 35.5], [264, 31], [460, 31], [520, 36.5], [528, 35.5]],
158
+ right: [[0, 44.5], [30, 44.5], [46, 42], [140, 35.5], [250, 32], [470, 32], [518, 35.5], [528, 35]],
159
+ },
160
+ };
161
+ const BOX = 165;
162
+ // A profile as a Bar's outline: along runs from the street (0) to the top (1), across from the left (0) to the right (1);
163
+ // shift moves the building's axis across its slat, in metres
164
+ const outline = ({ left, right = left }, shift = 0) => {
165
+ const top = Math.max(...left.map(([h]) => h), ...right.map(([h]) => h));
166
+ return shape(
167
+ ...left.map(([h, w], i) => [i ? "L" : "M", h / top, 0.5 + (shift - w) / BOX]),
168
+ ...[...right].reverse().map(([h, w]) => ["L", h / top, 0.5 + (shift + w) / BOX]),
169
+ ["Z"],
170
+ );
171
+ };
172
+
173
+ // What depends on the data: ranks, places on the street (lowest on the left), the leader and the runner-up
174
+ const HEIGHT = BUILDINGS.map((b) => b.height);
175
+ const scale = { min: 0, max: Math.ceil(Math.max(...HEIGHT) / 10) * 10 }; // 0 to 830 m: the top of the plot is just over the tallest tip
176
+ const byHeight = BUILDINGS.map((b, i) => i).sort((a, b) => HEIGHT[b] - HEIGHT[a]); // tallest first
177
+ const LEAD = byHeight[0];
178
+ const SECOND = byHeight[1];
179
+ const RANK = HEIGHT.map((h) => 1 + HEIGHT.filter((other) => other > h).length); // ties share a rank, as in CTBUH's table
180
+ const POS = BUILDINGS.map((b, i) => BUILDINGS.length - 1 - byHeight.indexOf(i)); // place on screen, lowest first
181
+ const profileOf = (b) => PROFILE[b.name] ?? { left: [[0, 30], [b.height, 30]] };
182
+ // Even gaps: every building's widest extent is laid along the street with the same space between each, and each axis
183
+ // moves across its slat to where that puts it (SHIFT, a share of the slat, which the labels and lines follow)
184
+ const EXTENT = BUILDINGS.map((b) => {
185
+ const { left, right = left } = profileOf(b);
186
+ return [Math.max(...left.map(([, w]) => w)), Math.max(...right.map(([, w]) => w))];
187
+ });
188
+ const SHIFT = new Array(BUILDINGS.length);
189
+ {
190
+ const placed = POS.map((p, i) => [p, i]).sort((a, b) => a[0] - b[0]).map(([, i]) => i); // left to right
191
+ const space = (BUILDINGS.length * BOX - EXTENT.reduce((sum, [l, r]) => sum + l + r, 0)) / BUILDINGS.length;
192
+ let x = space / 2;
193
+ placed.forEach((i, place) => {
194
+ SHIFT[i] = (x + EXTENT[i][0] - BOX * (place + 0.5)) / BOX;
195
+ x += EXTENT[i][0] + EXTENT[i][1] + space;
196
+ });
197
+ }
198
+ const OUTLINE = BUILDINGS.map((b, i) => outline(profileOf(b), SHIFT[i] * BOX));
199
+ const WORDS = ["no", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve",
200
+ "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen", "twenty"];
201
+ const word = (n) => WORDS[n] ?? String(n);
202
+ const cap = (s) => s[0].toUpperCase() + s.slice(1);
203
+ const metres = (m) => `${m} m`;
204
+ const fullName = (i) => BUILDINGS[i].name + (BUILDINGS[i].formerly ? ` (formerly ${BUILDINGS[i].formerly})` : "");
205
+ const lead = BUILDINGS[LEAD];
206
+ const margin = lead.height - BUILDINGS[SECOND].height;
207
+ const cities = new Set(BUILDINGS.map((b) => b.city)).size;
208
+ // The tower the picked one is measured against the leader with: itself, or the runner-up when the leader is picked
209
+ const against = (i) => (i === LEAD ? SECOND : i);
210
+ const compare = (i) => (i === LEAD
211
+ ? `${metres(margin)} taller than ${BUILDINGS[SECOND].name}`
212
+ : `${metres(lead.height - HEIGHT[i])} shorter than the ${lead.name}`);
213
+ const facts = (i) => `${metres(HEIGHT[i])}, ${BUILDINGS[i].floors} floors, finished ${BUILDINGS[i].year}`;
214
+ const say = (i) => `${RANK[i]}. ${fullName(i)}, ${BUILDINGS[i].city}: ${facts(i)}; ${compare(i)}.`;
215
+
216
+ // Slat type: one tower in its own outline, its height over its top, its name on the street. The level of the tower the
217
+ // reader picked runs from its top across to the leader, where a red dimension line measures what is left above it.
218
+ const TOWER_CSS = `
219
+ .tower { cursor: default; }
220
+ /* Focus rings the name on the street: a ring round the whole tower would cross the headline set in the sky */
221
+ .tower:focus-visible { outline: none; }
222
+ .tower:focus-visible .name { outline: 2px solid var(--rhp-surface); outline-offset: 3px; }
223
+ .tip { --rhp-label-gap: 5px; font: 400 21px/1 "League Gothic", "Arial Narrow", sans-serif; letter-spacing: .02em; color: var(--rhp-muted); }
224
+ .lit .tip { color: var(--rhp-ink); font-size: 25px; }
225
+ .level { --rhp-tick-width: 2px; background: repeating-linear-gradient(90deg, var(--rhp-ink) 0 5px, transparent 5px 9px); }
226
+ .level.none { visibility: hidden; }
227
+ .level.first { translate: 0 50%; }
228
+ .level.last { translate: -100% 50%; }
229
+ .dim { translate: var(--dx) 0; }
230
+ /* A clock face: a ring cut out of the tower, round whatever the chart's proportions, in the sky's color */
231
+ .clock { position: absolute; left: calc(50% + var(--shift) * 100%); bottom: calc(var(--at) * 100%); width: calc(var(--size) * 100%); aspect-ratio: 1;
232
+ translate: -50% 50%; border: 2px solid #d0e1f2; border-radius: 50%; }
233
+ /* The labels and lines follow the building's axis, moved across the slat by --shift for even gaps */
234
+ .tip, .dim, .cap, .mark, .level.first, .level.last { margin-left: calc(var(--shift) * 100%); }
235
+ .cap { translate: 0 50%; }
236
+ .gap { position: absolute; left: calc(var(--dx) + 7px); top: 0; translate: 0 -50%; font: 400 36px/1 "League Gothic", "Arial Narrow", sans-serif; color: var(--rhp-series-1); white-space: nowrap; }
237
+ .name { --rhp-label-gap: 10px; white-space: normal; text-align: center; line-height: 1.15; font-size: 12.5px; color: var(--rhp-surface); }
238
+ .name b { display: block; padding-bottom: 4px; font-weight: 600; }
239
+ .name i, .name small { display: block; font-style: normal; font-size: 11.5px; color: color-mix(in oklab, var(--rhp-surface) 72%, var(--rhp-ink)); }
240
+ .lit .name b { font-weight: 700; text-decoration: underline 2px; text-underline-offset: 2px; }
241
+ .rank { font: 400 19px/1 "League Gothic", "Arial Narrow", sans-serif; color: color-mix(in oklab, var(--rhp-surface) 72%, var(--rhp-ink)); }
242
+ .lit .rank { color: var(--rhp-surface); }
243
+ @container (max-width: 640px) {
244
+ .tip { font-size: 16px; }
245
+ .lit .tip { font-size: 19px; }
246
+ .tip .u { display: none; }
247
+ .gap { font-size: 26px; }
248
+ .clock { border-width: 1px; }
249
+ }
250
+ `;
251
+ const tower = (d) => html`
252
+ <div class=${() => (d.lit ? "tower lit" : "tower")} data-row=${() => d.index} aria-label=${() => say(d.index)}
253
+ style=${() => `--dx: ${d.wide ? 14 : 8}px; --shift: ${d.shift}`}>
254
+ <${Bar} to=${() => d.height} thick=${1} shape=${() => d.outline} color=${() => (d.lit ? "ink" : "series-2")}>
255
+ <${Show} when=${() => d.clock}>
256
+ <i class="clock" aria-hidden="true" style=${() => `--at: ${d.clock.at / d.height}; --size: ${d.clock.size / BOX}`}></i>
257
+ <//>
258
+ <//>
259
+ <${Tick} at=${() => d.level} thick=${() => ({ mid: 1, first: 0.5 - d.shift, last: 0.5 + d.shift })[d.span] ?? 0.5} class=${() => "level " + d.span} />
260
+ <${Label} at=${() => d.height} class="tip">${() => d.height}<span class="u"> m</span><//>
261
+ <${Label} edge="start" class="name">
262
+ <${Show} when=${() => d.wide} fallback=${html`<b class="rank">${() => d.rank}</b>`}>
263
+ <b>${() => d.name}</b>
264
+ <${Show} when=${() => d.formerly}><i>(${() => d.formerly})</i><//>
265
+ <small>${() => d.city}</small>
266
+ <//>
267
+ <//>
268
+ <${Show} when=${() => d.lead}>
269
+ <${Bar} from=${() => d.level} to=${() => d.height} thick="2px" color="series-1" class="dim" />
270
+ <${Tick} at=${() => d.level} thick=${() => `${d.wide ? 22 : 14}px`} color="series-1" class="cap" />
271
+ <${Tick} at=${() => d.height} thick=${() => `${d.wide ? 22 : 14}px`} color="series-1" class="cap" />
272
+ <${Place} at=${() => (d.level + d.height) / 2} class="mark"><b class="gap">${() => metres(d.height - d.level)}</b><//>
273
+ <//>
274
+ </div>`;
275
+ // The same tower with the room each width needs: names on the street on wide screens, ranks on narrow ones
276
+ const TowerNamed = slat({ room: { start: 88, end: 0, after: 0 }, css: TOWER_CSS }, tower);
277
+ const TowerRanked = slat({ room: { start: 32, end: 32, after: 46 }, css: TOWER_CSS }, tower);
278
+
279
+ // The chart's font and colors, read from the page's tokens
280
+ const THEME = {
281
+ font: "var(--text)", ink: "var(--ink)", muted: "var(--soft)", grid: "var(--rule)", surface: "var(--on-ground)",
282
+ series: ["var(--accent)", "var(--quiet)"],
283
+ };
284
+
285
+ // Chart component: the tower the reader points at, taps or reaches with the keyboard is lit and measured against the leader
286
+ function Skyline() {
287
+ const media = matchMedia("(min-width: 980px)");
288
+ const [wide, setWide] = createSignal(media.matches);
289
+ media.addEventListener("change", (e) => setWide(e.matches));
290
+ const [picked, setPicked] = createSignal(null);
291
+ const lit = () => picked() ?? LEAD;
292
+ const ref = () => against(lit());
293
+ // Where the level line runs: half of the measured tower's band (first), every band between (mid), half of the leader's (last)
294
+ const span = (i) => {
295
+ const p = POS[i], a = POS[ref()], b = POS[LEAD];
296
+ return p === a ? "first" : p === b ? "last" : p > a && p < b ? "mid" : "none";
297
+ };
298
+ // The row of the element an event landed on (a tower, or a line of the key), or null
299
+ const rowOf = (el) => {
300
+ const root = el.closest("[data-row]");
301
+ return root ? +root.dataset.row : null;
302
+ };
303
+ // A mouse or a pen picks as it moves, a tap or a click picks, the keyboard picks; on no tower the pick stays
304
+ const pick = (e) => {
305
+ const row = rowOf(e.target);
306
+ if (row != null) setPicked(row);
307
+ };
308
+ // Leaving the poster: back to the tower that has focus (a keyboard's or a click's pick), or none
309
+ const leave = (e) => {
310
+ if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
311
+ setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
312
+ };
313
+ const towers = (type) => html`
314
+ <${Plot} rows=${BUILDINGS} order=${POS} keyboard=${true} rank=${RANK} outline=${OUTLINE} shift=${SHIFT}
315
+ lit=${(d) => d.index === lit()} lead=${(d) => d.index === LEAD} span=${(d) => span(d.index)}
316
+ level=${() => HEIGHT[ref()]} wide=${wide}>${type}<//>`;
317
+ return html`
318
+ <${Poster}
319
+ kicker=${`The world's tallest buildings, ${AS_OF}`}
320
+ title=${`${cap(word(AS_OF - lead.year))} years on, the ${lead.name} still tops the rest by ${metres(margin)}`}
321
+ dek=${`The ${word(BUILDINGS.length)} tallest finished buildings on Earth, from ${word(cities)} cities, share one street here, each in its own outline and all at one scale. Heights in metres.`}
322
+ note=${SOURCE}
323
+ onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
324
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
325
+ <div class="stage" style=${() => `--street: ${wide() ? 88 : 32}px`}>
326
+ <${Chart} orientation="vertical" height=${() => (wide() ? 680 : 420)} scale=${[scale.min, scale.max]} ticks=${false}
327
+ label=${`The ${word(BUILDINGS.length)} tallest completed buildings in the world, by height in metres`} theme=${THEME}>
328
+ <${Show} when=${wide} fallback=${() => towers(TowerRanked)}>${() => towers(TowerNamed)}<//>
329
+ <//>
330
+ </div>
331
+ <p class="readout" aria-hidden="true">
332
+ <span class=${() => (picked() == null ? "v on" : "v")}>Tap or point at a tower to measure it against the Burj Khalifa.</span>
333
+ ${BUILDINGS.map((b, i) => html`
334
+ <span class=${() => (picked() === i ? "v on" : "v")}><b>${b.name}</b>, ${b.city}<span>${facts(i)}. ${cap(compare(i))}.</span></span>`)}
335
+ </p>
336
+ <${Show} when=${() => !wide()}>
337
+ <ol class="key" aria-hidden="true">
338
+ ${byHeight.map((i) => html`
339
+ <li data-row=${i} class=${() => (lit() === i ? "lit" : "")}>
340
+ <b>${RANK[i]}</b>
341
+ <div>${fullName(i)}<em>, ${BUILDINGS[i].city}</em></div>
342
+ <span>${metres(HEIGHT[i])}</span>
343
+ </li>`)}
344
+ </ol>
345
+ <//>
346
+ <//>`;
347
+ }
348
+
349
+ render(() => html`<${Skyline} />`, document.getElementById("chart"));
350
+ </script>
351
+ </body>
352
+ </html>
@@ -0,0 +1,199 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Eggs doubled</title>
7
+ <meta name="description" content="Slope chart: each item's value at two dates joined by a line on a second axis, labels kept apart on both sides, the change written beside each item, and the line nearest the pointer, a tap or the arrow keys picked out. Use it to compare change between two dates across items.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: a Line of two points per item on a second axis, labels kept apart on both sides, and the line nearest the pointer picked out.
11
+ Leave: the design. This poster was made for its own subject (the price of eggs as a till receipt, torn at the top and the bottom, a barcode at its foot).
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=Azeret+Mono:wght@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: #bfbcb5; font-family: "Azeret Mono", ui-monospace, Menlo, monospace; }
21
+ /* The poster: a till receipt, torn at the top and the bottom, its shadow following the tear */
22
+ #chart { filter: drop-shadow(0 18px 22px rgb(40 38 34 / .35)); }
23
+ .poster { max-width: 640px; margin: 0 auto; padding: 44px 40px 40px; color: #1d1d1b; background: #fdfdfb;
24
+ mask: conic-gradient(from 135deg at top, #0000, #000 1deg 89deg, #0000 90deg) top / 14px 51% repeat-x,
25
+ conic-gradient(from -45deg at bottom, #0000, #000 1deg 89deg, #0000 90deg) bottom / 14px 51% repeat-x; }
26
+ .poster figcaption { display: grid; gap: 12px; margin-bottom: 22px; padding-bottom: 20px; border-bottom: 1.5px dashed #1d1d1b; text-align: center; }
27
+ .poster .kicker { font-size: 12px; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; }
28
+ .poster .headline { font-size: 34px; font-weight: 800; line-height: 1.05; letter-spacing: -.03em; text-transform: uppercase; text-wrap: balance; }
29
+ .poster .dek { font-size: 13px; line-height: 1.55; color: #55534e; text-wrap: pretty; }
30
+ .poster .note { display: block; margin-top: 22px; padding-top: 16px; border-top: 1.5px dashed #1d1d1b; font-size: 11px; line-height: 1.55; color: #55534e; }
31
+ /* The ornament: a barcode at the foot of the receipt */
32
+ .poster::after { content: ""; display: block; width: min(260px, 70%); height: 46px; margin: 18px auto 0;
33
+ background: repeating-linear-gradient(90deg, #1d1d1b 0 2px, transparent 2px 5px, #1d1d1b 5px 6px, transparent 6px 9px, #1d1d1b 9px 12px, transparent 12px 13px),
34
+ repeating-linear-gradient(90deg, transparent 0 17px, #fdfdfb 17px 20px); }
35
+ .plot { border-radius: 4px; }
36
+ .plot:has(:focus-visible) { outline: 2px solid #1d1d1b; outline-offset: 6px; }
37
+ @media (max-width: 600px) {
38
+ body { padding: 16px 12px 32px; }
39
+ .poster { padding: 36px 16px 34px; }
40
+ .poster .headline { font-size: 25px; }
41
+ }
42
+ </style>
43
+ </head>
44
+ <body>
45
+ <div id="chart"></div>
46
+ <script type="module">
47
+ import { Chart, Plot, Scale, Line, Dot, Tick, Label, Poster, slat, html, render, createSignal } from "@bezda/rhp/standalone";
48
+
49
+ // Data: US city average prices, the mean of each year's twelve monthly prices, Bureau of Labor Statistics. Replace with
50
+ // your own items and two values each.
51
+ const ITEMS = [
52
+ { name: "Coffee, lb", before: 4.433, after: 6.322 },
53
+ { name: "Ground beef, lb", before: 4.115, after: 5.393 },
54
+ { name: "Milk, gallon", before: 3.318, after: 3.984 },
55
+ { name: "Eggs, dozen", before: 1.506, after: 3.172 },
56
+ { name: "Bread, lb", before: 1.452, after: 1.970 },
57
+ { name: "Bananas, lb", before: 0.578, after: 0.620 },
58
+ ];
59
+ const YEARS = [2020, 2024];
60
+ const STORY = ITEMS.findIndex((d) => d.name.startsWith("Eggs")); // drawn in red until another item is picked
61
+
62
+ const TOP = 7; // the scale runs from $0 to $7
63
+ const HEIGHT = 380;
64
+ const price = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
65
+ const change = new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 0, signDisplay: "always" });
66
+
67
+ // Places for a column of labels: in order, at least `px` apart, as near their values as that allows
68
+ function dodge(values, px) {
69
+ const gap = (px / HEIGHT) * TOP;
70
+ const order = values.map((_, i) => i).sort((a, b) => values[a] - values[b]);
71
+ const out = values.slice();
72
+ for (let pass = 0; pass < 50; pass++) {
73
+ let moved = false;
74
+ for (let k = 1; k < order.length; k++) {
75
+ const lo = order[k - 1], hi = order[k];
76
+ const push = gap - (out[hi] - out[lo]);
77
+ if (push > 1e-6) {
78
+ out[lo] -= push / 2;
79
+ out[hi] += push / 2;
80
+ moved = true;
81
+ }
82
+ }
83
+ if (!moved) break;
84
+ }
85
+ return out;
86
+ }
87
+ const LEFT = dodge(ITEMS.map((d) => d.before), 18);
88
+ const RIGHT = dodge(ITEMS.map((d) => d.after), 36);
89
+
90
+ // Slat types
91
+ // The two dates: a rule down the plot and the year above it (the scale runs from 0, the first date, to 1, the second)
92
+ const Year = slat({
93
+ room: { before: 30 },
94
+ css: `
95
+ .rule { --rhp-tick-width: 1px; background: #c9c6be; }
96
+ .year { top: auto; bottom: calc(100% + 10px); translate: -50% 0; --rhp-label-gap: 0px; font-size: 13px; font-weight: 700; letter-spacing: .1em; }
97
+ `,
98
+ }, (t) => html`
99
+ <div>
100
+ <${Tick} at=${() => t.at} thick=${1} class="rule" />
101
+ <${Label} at=${() => t.at} class="year">${() => YEARS[t.at]}<//>
102
+ </div>`);
103
+
104
+ // An item: its line from the first price to the second, a dot at each end, the first price on the left, and its
105
+ // name, second price and change on the right (each label at its dodged place)
106
+ const Item = slat({
107
+ room: { start: 52, end: 128 },
108
+ css: `
109
+ .item { pointer-events: none; outline: none; transition: opacity .2s; }
110
+ .dim { opacity: .2; }
111
+ .slope { stroke: #8f8b83; stroke-width: 2px; }
112
+ .end { background: #8f8b83; }
113
+ .story .slope, .on .slope { stroke-width: 3px; }
114
+ .story .slope { stroke: #d62828; }
115
+ .story .end { background: #d62828; }
116
+ .on .slope { stroke: #1d1d1b; }
117
+ .on .end { background: #1d1d1b; }
118
+ .first { --rhp-label-gap: 10px; font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; }
119
+ .second { --rhp-label-gap: 10px; font-size: 12px; line-height: 1.3; }
120
+ .second small { display: block; font-size: 11px; color: #55534e; }
121
+ .second b { font-size: 13px; }
122
+ .second em { margin-left: 6px; font-style: normal; font-weight: 700; color: #b21f1f; }
123
+ .say { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
124
+ `,
125
+ }, (d) => html`
126
+ <div class=${() => ["item", d.index === STORY && !d.someone && "story", d.on && "on", d.someone && !d.on && "dim"].filter(Boolean).join(" ")} data-i=${() => d.index}>
127
+ <${Line} points=${() => [[0, d.before], [1, d.after]]} class="slope" />
128
+ <${Dot} at=${0} cross=${() => d.before} size="8px" class="end" />
129
+ <${Dot} at=${1} cross=${() => d.after} size="8px" class="end" />
130
+ <${Label} at=${0} cross=${() => d.left} side="before" class="first">${() => price.format(d.before)}<//>
131
+ <${Label} at=${1} cross=${() => d.right} class="second">
132
+ <small>${() => d.name}</small><b>${() => price.format(d.after)}</b><em>${() => change.format(d.after / d.before - 1)}</em>
133
+ <//>
134
+ <span class="say">${() => `${d.name}: ${price.format(d.before)} in ${YEARS[0]}, ${price.format(d.after)} in ${YEARS[1]}, ${change.format(d.after / d.before - 1)}`}</span>
135
+ </div>`);
136
+
137
+ // Chart component: the item nearest the pointer (its line, or its labels beside the plot), a tap or the arrow keys is
138
+ // picked out and the others fade
139
+ function GroceryChart() {
140
+ const [picked, setPicked] = createSignal(null);
141
+ let plot;
142
+ const nearest = (e) => {
143
+ const r = plot.getBoundingClientRect();
144
+ const t = (e.clientX - r.left) / r.width; // 0 at the first date, 1 at the second
145
+ const y = (1 - (e.clientY - r.top) / r.height) * TOP; // the price under the pointer
146
+ let best = null;
147
+ let reach = (16 / HEIGHT) * TOP;
148
+ ITEMS.forEach((d, i) => {
149
+ const at = t < 0 ? LEFT[i] : t > 1 ? RIGHT[i] : d.before + (d.after - d.before) * t;
150
+ if (Math.abs(at - y) < reach) {
151
+ reach = Math.abs(at - y);
152
+ best = i;
153
+ }
154
+ });
155
+ return best;
156
+ };
157
+ // The item an element is in (the item the keyboard focuses), or null
158
+ const rowOf = (el) => {
159
+ const root = el.closest("[data-i]");
160
+ return root ? +root.dataset.i : null;
161
+ };
162
+ // A mouse or a pen picks the nearest line as it moves, a tap or a click picks it; with no line in reach the pick stays
163
+ const point = (e) => {
164
+ const i = nearest(e);
165
+ if (i != null) setPicked(i);
166
+ };
167
+ // The keyboard picks the item it focuses
168
+ const focus = (e) => {
169
+ const i = rowOf(e.target);
170
+ if (i != null) setPicked(i);
171
+ };
172
+ // Leaving the chart: back to the item that has focus (the keyboard's pick), or none
173
+ const leave = (e) => {
174
+ if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
175
+ setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
176
+ };
177
+ return html`
178
+ <${Poster}
179
+ kicker="Price check, US city average"
180
+ title="A dozen eggs cost twice as much in 2024 as in 2020"
181
+ dek="Six staples, each at its average price in 2020 and in 2024. Coffee went up 43%, bread 36%, beef 31% and milk 20%; bananas went up 4 cents."
182
+ note="Source: US Bureau of Labor Statistics, average price data for 2020 and 2024 (the mean of each year's twelve monthly prices). Point at a line, tap it, or Tab into the chart and use the arrow keys.">
183
+ <div class="plot" onPointerMove=${(e) => e.pointerType !== "touch" && point(e)} onClick=${point} onFocusIn=${focus}
184
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
185
+ <${Chart} scale=${[0, 1]} cross=${[0, TOP]} crossTicks=${false} height=${HEIGHT}
186
+ label=${`Average US prices of six groceries in ${YEARS[0]} and ${YEARS[1]}`}
187
+ theme=${{ font: '"Azeret Mono", ui-monospace, Menlo, monospace', ink: "#1d1d1b", muted: "#55534e" }}>
188
+ <${Scale} ticks=${[0, 1]}>${Year}<//>
189
+ <${Plot} overlap=${true} keyboard=${true} rows=${ITEMS} left=${LEFT} right=${RIGHT} ref=${(el) => (plot = el)}
190
+ on=${(d) => d.index === picked()} someone=${() => picked() != null}>${Item}<//>
191
+ <//>
192
+ </div>
193
+ <//>`;
194
+ }
195
+
196
+ render(() => html`<${GroceryChart} />`, document.getElementById("chart"));
197
+ </script>
198
+ </body>
199
+ </html>