@bezda/rhp-mcp 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +20 -5
  2. package/bin/rhp-mcp.js +5 -1
  3. package/package.json +3 -3
  4. package/skill/SKILL.md +116 -26
  5. package/skill/recipes/area.html +6 -0
  6. package/skill/recipes/bar.html +9 -1
  7. package/skill/recipes/box-plot.html +9 -1
  8. package/skill/recipes/bubble.html +6 -0
  9. package/skill/recipes/bullet.html +6 -0
  10. package/skill/recipes/candlestick.html +9 -1
  11. package/skill/recipes/column.html +18 -5
  12. package/skill/recipes/dial.html +349 -0
  13. package/skill/recipes/diverging-bars.html +6 -0
  14. package/skill/recipes/donut.html +9 -1
  15. package/skill/recipes/dumbbell.html +6 -0
  16. package/skill/recipes/gantt.html +17 -5
  17. package/skill/recipes/grouped-bars.html +6 -0
  18. package/skill/recipes/heatmap.html +9 -1
  19. package/skill/recipes/histogram.html +9 -1
  20. package/skill/recipes/line.html +7 -1
  21. package/skill/recipes/live.html +6 -0
  22. package/skill/recipes/log-line.html +493 -0
  23. package/skill/recipes/lollipop.html +6 -0
  24. package/skill/recipes/multi-line.html +10 -5
  25. package/skill/recipes/pyramid.html +9 -1
  26. package/skill/recipes/race.html +6 -0
  27. package/skill/recipes/radial-bars.html +6 -0
  28. package/skill/recipes/scatter.html +23 -4
  29. package/skill/recipes/silhouettes.html +354 -0
  30. package/skill/recipes/slope.html +23 -4
  31. package/skill/recipes/sparklines.html +7 -1
  32. package/skill/recipes/stacked-100.html +6 -0
  33. package/skill/recipes/stacked-bars.html +6 -0
  34. package/skill/recipes/strip.html +9 -1
  35. package/skill/recipes/unit-stack.html +276 -0
  36. package/skill/recipes/violin.html +6 -0
  37. package/skill/recipes/waffle.html +6 -0
  38. package/skill/recipes/waterfall.html +6 -0
  39. package/skill/references/api.md +150 -22
  40. package/skill/references/design.md +303 -326
  41. package/skill/references/environments.md +11 -4
  42. package/skill/references/forms.md +41 -4
  43. package/skill/references/interaction.md +7 -2
  44. package/skill/references/looks.md +267 -0
  45. package/skill/references/performance.md +143 -0
  46. package/skill/references/pitfalls.md +52 -7
  47. package/src/check/README.md +33 -3
  48. package/src/check/browser.js +11 -5
  49. package/src/check/findings.js +15 -1
  50. package/src/check/index.js +182 -31
  51. package/src/check/inpage.js +82 -18
  52. package/src/check/interact.js +1 -19
  53. package/src/check/network.js +168 -0
  54. package/src/check/png.js +18 -0
  55. package/src/check/report.js +4 -1
  56. package/src/check/selection.js +123 -0
  57. package/src/guard/rhp.js +4 -0
  58. package/src/guard/rules.js +1 -1
  59. package/src/server.js +9 -8
@@ -5,6 +5,12 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Japan's population by age</title>
7
7
  <meta name="description" content="Population pyramid: two sides of one population (men and women) by age band, oldest on top, with an age picked by pointer, tap or keyboard that lights everyone that age or older and reads out their share. Use it for any two groups compared across ordered bands.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: a Bar to each side of zero per age band, and an age picked by pointer, tap or keyboard that lights everyone that age or older.
11
+ Leave: the design. This poster was made for its own subject (Japan's population on washi paper in sumi ink, the two sides in hanada blue and vermilion).
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
+ -->
8
14
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
15
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
16
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Zen+Old+Mincho:wght@700;900&family=Zen+Kaku+Gothic+New:wght@400;500;700&display=swap">
@@ -126,6 +132,8 @@ function AgeingJapan() {
126
132
  if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
127
133
  setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
128
134
  };
135
+ // Off the chart (on the headline, the readout or the note), pointing is as if the pointer had left the poster
136
+ const move = (e) => e.pointerType !== "touch" && (e.target.closest(".rhp-chart") ? pick(e) : leave(e));
129
137
  const older = () => MEN.slice(from()).concat(WOMEN.slice(from())).reduce((sum, v) => sum + v, 0);
130
138
  return html`
131
139
  <${Poster}
@@ -133,7 +141,7 @@ function AgeingJapan() {
133
141
  title="Nearly three in ten people in Japan are 65 or older"
134
142
  dek="Men to the left, women to the right, in millions, the oldest at the top. Two bulges show the baby booms of the late 1940s and early 1970s; the base keeps shrinking."
135
143
  note="Source: Statistics Bureau of Japan, population estimates for October 1, 2023 (124.35 million people), rounded to the nearest 10,000. Point at an age band, tap it, or Tab into the chart and use the arrow keys."
136
- onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
144
+ onPointerMove=${move} onClick=${pick} onFocusIn=${pick}
137
145
  onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
138
146
  <p class="readout" aria-live="polite">
139
147
  <b>${() => percent.format(older() / TOTAL)}</b>
@@ -5,6 +5,12 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>China's economy caught up</title>
7
7
  <meta name="description" content="Bar chart race: ten economies ranked by GDP from 1990 to 2024, the bars gliding from year to year and sliding past each other as ranks change, with a play button, a year slider that lands on whole years, and a readout on hover, focus and tap. A playhead moves on every frame and each value is interpolated between the years around it, so the bars move by rhp's JS version (animate). Use it for a ranking that changes over time.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: JS motion on a continuous playhead, bars that glide and pass each other, play and pause, a year slider, and a scale that follows the leader.
11
+ Leave: the design. This poster was made for its own subject (three decades of GDP as an engraved banknote: cotton paper, a guilloche rosette, the year in engraved numerals).
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
+ -->
8
14
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
15
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
16
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bodoni+Moda:ital,opsz,wght@0,6..96,400..800;1,6..96,400..800&family=Jost:wght@400;500;600&display=swap">
@@ -5,6 +5,12 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>A week of daily goals</title>
7
7
  <meta name="description" content="Radial bars: one ring per goal, its arc the share of the goal reached, with a day switch that sweeps the arcs to another day. Use it for progress toward a few goals or targets, in percent.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: rings drawn in the slat's CSS from where each Bar starts and ends on the scale, and a day switch that sweeps the arcs.
11
+ Leave: the design. This poster was made for its own subject (a week of daily goals on a dotted planner page taped to the wall, each day's switch dotted with the goals it met).
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
+ -->
8
14
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
15
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
16
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Young+Serif&family=Hanken+Grotesk:wght@400..800&display=swap">
@@ -5,6 +5,12 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Wealth and health</title>
7
7
  <meta name="description" content="Scatter plot of two measures per item on a second axis, with a log scale, a few points labeled (short codes on phones) and a readout of the point nearest the pointer, a tap or the arrow keys. Use it to show how two measures relate across items.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: Dots on two axes with a log scale, a few points labeled (short codes on phones), and a readout of the nearest point.
11
+ Leave: the design. This poster was made for its own subject (wealth and health on banknote paper, a quarter of an engraved seal in the corner).
12
+ Its look, the place of its parts and the way its marks are drawn say that subject: design yours from your own (design.md, section 2).
13
+ -->
8
14
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
15
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
16
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bodoni+Moda:opsz,wght@6..96,500..800&family=Manrope:wght@400..700&display=swap">
@@ -129,6 +135,7 @@ const Country = slat({
129
135
  // keys, and the United States otherwise
130
136
  function WealthChart() {
131
137
  const [picked, setPicked] = createSignal(null);
138
+ const [pinned, setPinned] = createSignal(null); // the dot a click or a tap picked: the chart comes back to it
132
139
  const shown = () => COUNTRIES[picked() ?? STORY];
133
140
  let plot;
134
141
  const nearest = (e) => {
@@ -155,18 +162,30 @@ function WealthChart() {
155
162
  const i = nearest(e);
156
163
  if (i != null) setPicked(i);
157
164
  };
165
+ // A tap or a click picks it too, and pins it
166
+ const pin = (e) => {
167
+ point(e);
168
+ setPinned(picked());
169
+ };
158
170
  // The keyboard picks the dot it focuses
159
171
  const focus = (e) => {
160
172
  const i = rowOf(e.target);
161
173
  if (i != null) setPicked(i);
162
174
  };
163
- // Leaving the chart: back to the dot that has focus (the keyboard's pick), or none
175
+ // Leaving the chart: back to the dot that has focus (the keyboard's pick), else the pinned one, else none
164
176
  const leave = (e) => {
165
177
  if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
166
- setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
178
+ setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : pinned());
179
+ };
180
+ // Clicking poster text or margin resets this existing pick to the chart's original default.
181
+ const reset = (e) => {
182
+ if (e.target.closest(".plot, a, button, input, select, textarea, label, [role=button], [role=slider]")) return;
183
+ setPinned(null);
184
+ setPicked(null);
167
185
  };
168
186
  return html`
169
187
  <${Poster}
188
+ onClick=${reset}
170
189
  kicker="Wealth and health of nations"
171
190
  title="Life expectancy climbs fast with income, then levels off"
172
191
  dek="Twenty countries in 2023. Above about $25,000 a person, higher income goes with little extra life: the United States, richer than all but Norway here, has a lower life expectancy than Costa Rica, with about a third of its income. Tap or point at a dot."
@@ -174,8 +193,8 @@ function WealthChart() {
174
193
  <p class="readout"><b>${() => shown().name}</b><span>${() => `: ${dollars.format(shown().gdp)} a person, ${years.format(shown().life)} years`}</span></p>
175
194
  <p class="axis">Life expectancy at birth, years</p>
176
195
  <div class="plot"
177
- onPointerMove=${(e) => e.pointerType !== "touch" && point(e)} onClick=${point} onFocusIn=${focus}
178
- onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
196
+ onPointerMove=${(e) => e.pointerType !== "touch" && point(e)} onClick=${pin} onFocusIn=${focus}
197
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(pinned())}>
179
198
  <${Chart} scale=${X} cross=${Y} crossTicks=${[50, 60, 70, 80, 90]} height=${340}
180
199
  label="Life expectancy at birth against GDP per person, twenty countries, 2023"
181
200
  theme=${{ font: 'Manrope, "Helvetica Neue", Arial, sans-serif', ink: "#191c1f", muted: "#686b70", grid: "#dfdbd0" }}>
@@ -0,0 +1,354 @@
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: 32px; }
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: 32, after: 32 }, 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
+ // Off the chart (on the headline, the readout or the note), pointing is as if the pointer had left the poster
314
+ const move = (e) => e.pointerType !== "touch" && (e.target.closest(".rhp-chart") ? pick(e) : leave(e));
315
+ const towers = (type) => html`
316
+ <${Plot} rows=${BUILDINGS} order=${POS} keyboard=${true} rank=${RANK} outline=${OUTLINE} shift=${SHIFT}
317
+ lit=${(d) => d.index === lit()} lead=${(d) => d.index === LEAD} span=${(d) => span(d.index)}
318
+ level=${() => HEIGHT[ref()]} wide=${wide}>${type}<//>`;
319
+ return html`
320
+ <${Poster}
321
+ kicker=${`The world's tallest buildings, ${AS_OF}`}
322
+ title=${`${cap(word(AS_OF - lead.year))} years on, the ${lead.name} still tops the rest by ${metres(margin)}`}
323
+ 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.`}
324
+ note=${SOURCE}
325
+ onPointerMove=${move} onClick=${pick} onFocusIn=${pick}
326
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
327
+ <div class="stage" style=${() => `--street: ${wide() ? 88 : 32}px`}>
328
+ <${Chart} orientation="vertical" height=${() => (wide() ? 680 : 420)} scale=${[scale.min, scale.max]} ticks=${false}
329
+ label=${`The ${word(BUILDINGS.length)} tallest completed buildings in the world, by height in metres`} theme=${THEME}>
330
+ <${Show} when=${wide} fallback=${() => towers(TowerRanked)}>${() => towers(TowerNamed)}<//>
331
+ <//>
332
+ </div>
333
+ <p class="readout" aria-hidden="true">
334
+ <span class=${() => (picked() == null ? "v on" : "v")}>Tap or point at a tower to measure it against the Burj Khalifa.</span>
335
+ ${BUILDINGS.map((b, i) => html`
336
+ <span class=${() => (picked() === i ? "v on" : "v")}><b>${b.name}</b>, ${b.city}<span>${facts(i)}. ${cap(compare(i))}.</span></span>`)}
337
+ </p>
338
+ <${Show} when=${() => !wide()}>
339
+ <ol class="key" aria-hidden="true">
340
+ ${byHeight.map((i) => html`
341
+ <li data-row=${i} class=${() => (lit() === i ? "lit" : "")}>
342
+ <b>${RANK[i]}</b>
343
+ <div>${fullName(i)}<em>, ${BUILDINGS[i].city}</em></div>
344
+ <span>${metres(HEIGHT[i])}</span>
345
+ </li>`)}
346
+ </ol>
347
+ <//>
348
+ <//>`;
349
+ }
350
+
351
+ render(() => html`<${Skyline} />`, document.getElementById("chart"));
352
+ </script>
353
+ </body>
354
+ </html>
@@ -5,6 +5,12 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Eggs doubled</title>
7
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
+ -->
8
14
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
15
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
16
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Azeret+Mono:wght@400..800&display=swap">
@@ -132,6 +138,7 @@ const Item = slat({
132
138
  // picked out and the others fade
133
139
  function GroceryChart() {
134
140
  const [picked, setPicked] = createSignal(null);
141
+ const [pinned, setPinned] = createSignal(null); // the item a click or a tap picked: the chart comes back to it
135
142
  let plot;
136
143
  const nearest = (e) => {
137
144
  const r = plot.getBoundingClientRect();
@@ -158,24 +165,36 @@ function GroceryChart() {
158
165
  const i = nearest(e);
159
166
  if (i != null) setPicked(i);
160
167
  };
168
+ // A tap or a click picks it too, and pins it
169
+ const pin = (e) => {
170
+ point(e);
171
+ setPinned(picked());
172
+ };
161
173
  // The keyboard picks the item it focuses
162
174
  const focus = (e) => {
163
175
  const i = rowOf(e.target);
164
176
  if (i != null) setPicked(i);
165
177
  };
166
- // Leaving the chart: back to the item that has focus (the keyboard's pick), or none
178
+ // Leaving the chart: back to the item that has focus (the keyboard's pick), else the pinned one, else none
167
179
  const leave = (e) => {
168
180
  if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
169
- setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
181
+ setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : pinned());
182
+ };
183
+ // Clicking poster text or margin resets this existing pick to the chart's original default.
184
+ const reset = (e) => {
185
+ if (e.target.closest(".plot, a, button, input, select, textarea, label, [role=button], [role=slider]")) return;
186
+ setPinned(null);
187
+ setPicked(null);
170
188
  };
171
189
  return html`
172
190
  <${Poster}
191
+ onClick=${reset}
173
192
  kicker="Price check, US city average"
174
193
  title="A dozen eggs cost twice as much in 2024 as in 2020"
175
194
  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."
176
195
  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.">
177
- <div class="plot" onPointerMove=${(e) => e.pointerType !== "touch" && point(e)} onClick=${point} onFocusIn=${focus}
178
- onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
196
+ <div class="plot" onPointerMove=${(e) => e.pointerType !== "touch" && point(e)} onClick=${pin} onFocusIn=${focus}
197
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(pinned())}>
179
198
  <${Chart} scale=${[0, 1]} cross=${[0, TOP]} crossTicks=${false} height=${HEIGHT}
180
199
  label=${`Average US prices of six groceries in ${YEARS[0]} and ${YEARS[1]}`}
181
200
  theme=${{ font: '"Azeret Mono", ui-monospace, Menlo, monospace', ink: "#1d1d1b", muted: "#55534e" }}>
@@ -5,6 +5,12 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Richer since 1990</title>
7
7
  <meta name="description" content="Sparkline table: one small line per item, each scaled to its own peak, with the start level dashed, and a scrub through the years (pointer, tap, a sideways drag or arrow keys) that moves a dot along every line and updates each item's value and its change since the start. Use it for many series side by side, a table of trends.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: a Line in each slat scaled to itself, the start level dashed, numbers in columns, and a year scrub that moves a dot along every line.
11
+ Leave: the design. This poster was made for its own subject (incomes since 1990 as a table page of a statistical yearbook, cream paper between a thick and a thin 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
+ -->
8
14
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
15
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
16
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Libre+Caslon+Text:wght@400;700&family=Libre+Franklin:wght@400..800&display=swap">
@@ -105,7 +111,7 @@ const Country = slat({
105
111
 
106
112
  // The year being read: a hairline down every slat
107
113
  const Crosshair = slat({ room: {}, css: `.hair { --rhp-tick-width: 1px; background: #c2410c; opacity: .55; }` }, (d) => html`
108
- <div><${Tick} at=${() => d.year} thick=${1} class="hair" /></div>`);
114
+ <${Tick} at=${() => d.year} thick=${1} class="hair" />`);
109
115
 
110
116
  // Chart component: hovering previews a year; a click, tap, sideways drag or the arrow keys saves it for every slat.
111
117
  // With no saved year, the latest shows. The year in the table's head is a slider for the keyboard and screen readers.
@@ -5,6 +5,12 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>How cities get to work</title>
7
7
  <meta name="description" content="100% stacked bar chart of shares per category, labeled where they fit, with a key that lights one part in every bar on hover, focus and tap. Use it to compare how a whole splits across several categories.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: stackUp() of shares() in an overlap inner Plot, labels inside where they fit, and a key that lights one part in every bar.
11
+ Leave: the design. This poster was made for its own subject (ways to commute as a transit map: enamel white, geometric type, the key drawn as a metro line whose stations are the buttons).
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
+ -->
8
14
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
15
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
16
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Jost:wght@400..800&display=swap">
@@ -5,6 +5,12 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Europe's power mix</title>
7
7
  <meta name="description" content="Stacked bar chart of parts that add up to a total per category, with a key that moves one part to the start of every bar and ranks the bars by it. Use it for totals made of a few parts.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: stackUp() parts in an overlap inner Plot, and a key that moves one part to the start of every bar and ranks by it.
11
+ Leave: the design. This poster was made for its own subject (Europe's power mix on drafting paper, a line of pylons under the headline).
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
+ -->
8
14
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
15
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
16
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Oswald:wght@500..700&family=Public+Sans:wght@400..800&display=swap">
@@ -5,6 +5,12 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Burger places are hit or miss</title>
7
7
  <meta name="description" content="Strip plot of restaurant ratings by cuisine: one dot per restaurant in its cuisine's band (an overlap Plot inside each slat), a median tick, and a readout of the restaurant the reader points at, taps or reaches with the arrow keys. Use it to show every value of a few groups.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: an overlap Plot of Dots inside each group's slat, a median Tick, and a readout of the dot under the pointer.
11
+ Leave: the design. This poster was made for its own subject (restaurant ratings as a chalkboard menu in a wooden frame, its slate smudged where it was wiped).
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
+ -->
8
14
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
15
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
16
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cabin+Sketch:wght@700&family=Karla:wght@400..800&display=swap">
@@ -186,13 +192,15 @@ function RestaurantRatings() {
186
192
  if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
187
193
  setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
188
194
  };
195
+ // Off the chart (on the headline, the readout or the note), pointing is as if the pointer had left the poster
196
+ const move = (e) => e.pointerType !== "touch" && (e.target.closest(".rhp-chart") ? pick(e) : leave(e));
189
197
  return html`
190
198
  <${Poster}
191
199
  kicker="City food guide · ratings out of 5"
192
200
  title="Burger places are hit or miss"
193
201
  dek="Every dot is a restaurant, placed by its average rating. Ethiopian kitchens all score between 4.3 and 4.8; burgers run from under 3 to almost 5."
194
202
  note="Illustrative data for invented restaurants. Point at a dot or tap it for the restaurant, or Tab into a cuisine and use the arrow keys."
195
- onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
203
+ onPointerMove=${move} onClick=${pick} onFocusIn=${pick}
196
204
  onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
197
205
  <div class="readout">
198
206
  <span class="stars">${() => one.format(place().rating)} ★</span>