@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>A monsoon year</title>
7
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
+ -->
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=Archivo:wdth,wght@62..125,400..800&display=swap">
@@ -22,14 +28,16 @@
22
28
  .poster .dek { max-width: 54ch; font-size: 16px; line-height: 1.5; color: #b5bdc4; }
23
29
  .poster .note { display: block; margin-top: 24px; font-size: 12px; line-height: 1.5; color: #8d969e; }
24
30
  /* The readout: how much rain has fallen by the month in focus */
25
- .readout { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 14px; min-height: 2lh; margin: 0 0 22px; padding-top: 16px; border-top: 1px solid #2c3135; font-size: 15px; line-height: 1.4; color: #b5bdc4; }
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; }
26
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; } }
27
36
  @media (max-width: 560px) {
28
37
  body { padding: 16px 16px 32px; }
29
38
  .poster { padding: 26px 16px 18px; }
30
39
  .poster .headline { font-size: 32px; }
31
40
  .poster .dek { font-size: 15px; }
32
- .readout { min-height: 4lh; } /* the longest readout, so the chart never moves */
33
41
  .readout b { font-size: 24px; }
34
42
  }
35
43
  </style>
@@ -46,6 +54,8 @@ const SEASON = { name: "Monsoon", from: 5, to: 8 }; // June to September; null f
46
54
 
47
55
  const mm = new Intl.NumberFormat("en-US");
48
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));
49
59
  const year = RAIN.reduce((a, b) => a + b, 0);
50
60
  const scale = nice(0, Math.max(...RAIN), 4); // 0 to 1,000 mm, a line every 250
51
61
 
@@ -65,14 +75,17 @@ const Month = slat({
65
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; }
66
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; }
67
77
  .month:focus-visible { outline: 2px solid #6cc3ff; outline-offset: -2px; border-radius: 4px; }
78
+ .name .short { display: none; }
68
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; } }
69
82
  `,
70
83
  }, (d) => html`
71
84
  <div class=${() => "month " + d.step} data-row=${() => d.index} aria-label=${() => `${d.month}: ${mm.format(d.mm)} mm`}>
72
85
  <${Bar} to=${scale.max} class="tube" />
73
86
  <${Bar} to=${() => d.mm} class="water" />
74
87
  <${Label} at=${() => d.mm} class="mm">${() => mm.format(d.mm)}<//>
75
- <${Label} edge="start" class="name">${() => d.month.slice(0, 3)}<//>
88
+ <${Label} edge="start" class="name"><span class="long">${() => d.month.slice(0, 3)}</span><span class="short">${() => d.month[0]}</span><//>
76
89
  <${Show} when=${() => d.index === SEASON?.from}>
77
90
  <span class="season" style=${() => `--months: ${SEASON.to - SEASON.from + 1}`}><b>${() => SEASON.name}</b></span>
78
91
  <//>
@@ -103,12 +116,12 @@ function MonsoonYear() {
103
116
  kicker="A monsoon city · rain by month"
104
117
  title="Four months bring nearly all of the year's rain"
105
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."
106
- note="Illustrative data, shaped like a monsoon city's year. Point at a month, tap it, or Tab into the chart and use the arrow keys to add up the year."
119
+ note="Illustrative data, shaped like a monsoon city's year."
107
120
  onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
108
121
  onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setAt(null)}>
109
122
  <p class="readout" aria-live="polite">
110
123
  <b>${() => mm.format(sofar()) + " mm"}</b>
111
- <span>${() => (at() == null ? "in the whole year" : `by the end of ${MONTHS[end()]}, ${percent.format(sofar() / year)} of the year's rain`)}</span>
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>
112
125
  </p>
113
126
  <${Chart} orientation="vertical" height=${300} scale=${[scale.min, scale.max]} ticks=${scale.ticks} format=${(v) => mm.format(v)}
114
127
  label="Rainfall by month, in millimetres"
@@ -0,0 +1,349 @@
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>Where the average day goes</title>
7
+ <meta name="description" content="Dial: the parts of one whole drawn as arcs around a 24-hour dial (a Bar per row that gives up its box to an SVG arc), labels set along the arcs where they fit, a hand that turns the short way to the part picked, a readout in the hub and a grouped key. Use it for how a day, a week or any cycle splits into parts.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: Bars that give up their box to SVG arcs around a dial, labels set along the arcs only where they fit, a Scale of hours drawn as a bezel, picking by the pointer's angle, a hand that turns the short way round, a readout in the hub, and a key grouped by kind.
11
+ Leave: the design. This poster was made for its own subject (the average American day as the 24-hour dial of a watch: royal blue face, a navy bezel with its numerals and crown, a gold hand and arc for the part picked, a spec-sheet key beside it).
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=Albert+Sans:wght@400;600;700&family=Saira:wdth,wght@100..125,500..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
+ /* Tokens: a royal blue watch dial, a navy bezel, white printing and gold lume */
20
+ :root {
21
+ color-scheme: dark;
22
+ --page: #102659;
23
+ --paper: #1d3f8c;
24
+ --bezel: #0e2253;
25
+ --ink: #f4f6fb;
26
+ --soft: #c9d4ee;
27
+ --rule: #3a5aa6;
28
+ --accent: #ffd45c;
29
+ --quiet: #8fa5db;
30
+ --steel: #a9b8dc;
31
+ --display: "Saira", "Eurostile", "Arial Narrow", sans-serif;
32
+ --text: "Albert Sans", "Helvetica Neue", Arial, sans-serif;
33
+ }
34
+ * { box-sizing: border-box; }
35
+ body { margin: 0; padding: 48px 16px; background: var(--page, #102659); }
36
+
37
+ /* The poster: the parts are grid items, placed by the areas below */
38
+ .poster { display: grid; grid-template-columns: minmax(0, 1fr); row-gap: 14px; max-width: 1080px; margin: 0 auto; padding: 48px 52px 30px;
39
+ grid-template-areas: "kicker" "headline" "dek" "stage" "key" "note";
40
+ color: var(--ink); font: 16px/1.5 var(--text);
41
+ background: radial-gradient(110% 70% at 15% 0%, rgb(255 255 255 / .06), transparent 70%), var(--paper); }
42
+ .poster figcaption { display: contents; }
43
+ .poster .kicker { grid-area: kicker; font: 600 13px/1.3 var(--display); font-stretch: 125%; letter-spacing: .16em; text-transform: uppercase; color: var(--accent); }
44
+ .poster .headline { grid-area: headline; max-width: 19em; margin-bottom: 6px; font: 700 clamp(32px, 5vw, 56px)/1.02 var(--display); font-stretch: 105%; letter-spacing: -.01em; text-wrap: balance; }
45
+ .poster .dek { grid-area: dek; max-width: 34em; font-size: 17px; line-height: 1.5; color: var(--soft); }
46
+ .poster .stage { grid-area: stage; align-self: center; min-width: 0; margin-top: 10px; }
47
+ .poster .key { grid-area: key; min-width: 0; }
48
+ .poster .note { grid-area: note; display: block; margin-top: 18px; padding-top: 12px; border-top: 1px solid var(--rule); font-size: 12px; line-height: 1.5; color: var(--soft); }
49
+ .poster :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
50
+
51
+ /* The watch: the chart is the dial, its element painted as the face, the minute track, the bezel and the case */
52
+ .face { position: relative; max-width: 580px; margin: 0 auto; }
53
+ .face .rhp-chart { border-radius: 50%;
54
+ background:
55
+ radial-gradient(closest-side, #22489a 0, #1c3d88 79.4%, transparent 79.6% 82.6%, var(--bezel) 82.8% 96.6%, var(--steel) 96.8% 98.8%, #7d8fbf 99%),
56
+ repeating-conic-gradient(from -.5deg, var(--ink) 0 1deg, transparent 0 15deg),
57
+ repeating-conic-gradient(from -.2deg, rgb(201 212 238 / .7) 0 .4deg, #1c3d88 0 2.5deg); }
58
+ /* The crown at three o'clock, knurled */
59
+ .face::after { content: ""; position: absolute; top: 50%; right: -9px; width: 11px; height: 9%; translate: 0 -50%; border-radius: 3px;
60
+ background: repeating-linear-gradient(0deg, var(--steel) 0 2px, #6f83b8 2px 4px); }
61
+ .hint { width: 200px; margin: 24px auto 0; font-size: 14px; text-align: center; color: var(--soft); }
62
+
63
+ /* The middle of the dial: the hand, and the activity it points at */
64
+ .center { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
65
+ /* The hand runs from the middle to just inside the band and turns about its foot; only its tip shows past the hub */
66
+ .hand { position: absolute; left: 48.6%; top: 20.6%; width: 2.8%; height: 29.4%; transform-origin: 50% 100%;
67
+ rotate: calc(var(--turn) * 1turn); transition: rotate .45s cubic-bezier(.25, .8, .3, 1); }
68
+ .hand i { position: absolute; left: 0; top: 0; width: 100%; height: 30%; background: var(--accent);
69
+ clip-path: polygon(50% 0, 100% 34%, 64% 34%, 64% 100%, 36% 100%, 36% 34%, 0 34%); }
70
+ .hub { position: absolute; inset: 29%; display: grid; place-content: center; justify-items: center; gap: 2px; border-radius: 50%; text-align: center;
71
+ container-type: inline-size; }
72
+ .hub small { max-width: 12ch; font: 600 clamp(9px, 5.4cqi, 12px)/1.25 var(--display); font-stretch: 112%; letter-spacing: .12em; text-transform: uppercase; color: var(--soft); text-wrap: balance; }
73
+ .hub strong { font: 600 clamp(13px, 7.6cqi, 19px)/1.2 var(--text); max-width: 15ch; text-wrap: balance; }
74
+ .hub b { font: 600 clamp(26px, 19cqi, 50px)/1 var(--display); font-stretch: 112%; font-variant-numeric: tabular-nums; color: var(--accent); white-space: nowrap; }
75
+ .hub b span { font-size: .48em; font-weight: 500; margin: 0 .12em 0 .06em; }
76
+ .hub em { font-style: normal; font-size: clamp(11px, 5.8cqi, 14px); line-height: 1.3; color: var(--soft); }
77
+ @media (prefers-reduced-motion: reduce) { .hand { transition: none; } }
78
+
79
+ /* The key: every activity, in the dial's order, under its group */
80
+ .key { display: grid; gap: 16px; align-content: start; }
81
+ .key section { display: grid; gap: 2px; }
82
+ .key h3 { display: flex; justify-content: space-between; gap: 12px; margin: 0 0 2px; padding-bottom: 5px; border-bottom: 1px solid var(--rule);
83
+ font: 600 12px/1.3 var(--display); font-stretch: 118%; letter-spacing: .12em; text-transform: uppercase; color: var(--soft); }
84
+ .key h3 span { font-variant-numeric: tabular-nums; letter-spacing: .04em; white-space: nowrap; }
85
+ .key ul { margin: 0; padding: 0; list-style: none; }
86
+ .key li { display: grid; grid-template-columns: 16px minmax(0, 1fr) auto; align-items: baseline; gap: 10px; padding: 3px 0; font-size: 15px; line-height: 1.35; cursor: default; }
87
+ .key li i { align-self: center; width: 16px; height: 7px; border-radius: 2px; background: var(--quiet); }
88
+ .key li b { font: 600 15px/1.35 var(--display); font-stretch: 108%; font-variant-numeric: tabular-nums; white-space: nowrap; }
89
+ .key li.on { color: var(--accent); }
90
+ .key li.on i { background: var(--accent); }
91
+
92
+ /* Middle widths: the key in two columns under the dial, so each time stays near its name */
93
+ @media (min-width: 641px) and (max-width: 899px) {
94
+ .key { display: block; columns: 2; column-gap: 44px; }
95
+ .key section { break-inside: avoid; margin-bottom: 16px; }
96
+ }
97
+ /* Wide: the headline across the top, as on a watch advertisement; the dial below it, with the dek and the key beside it */
98
+ @media (min-width: 900px) {
99
+ .poster { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); column-gap: 56px; grid-template-rows: auto auto auto 1fr auto;
100
+ grid-template-areas: "kicker kicker" "headline headline" "stage dek" "stage key" "note note"; }
101
+ .poster .stage { margin-top: 18px; }
102
+ .poster .dek { margin-top: 18px; font-size: 16px; }
103
+ .poster .key { margin-top: 6px; }
104
+ .key li { padding: 2px 0; }
105
+ }
106
+ @media (max-width: 640px) {
107
+ body { padding: 16px 16px 32px; }
108
+ .poster { padding: 26px 18px 18px; }
109
+ .poster .kicker { font-stretch: 100%; letter-spacing: .12em; }
110
+ .poster .dek { font-size: 15px; }
111
+ .key li, .key li b { font-size: 14px; }
112
+ }
113
+ </style>
114
+ </head>
115
+ <body>
116
+ <div id="chart"></div>
117
+ <script type="module">
118
+ import { Chart, Plot, Scale, Bar, Place, Poster, slat, every, running, html, render, createSignal, createEffect } from "@bezda/rhp/standalone";
119
+
120
+ // Data: average hours per day in 2025, for Americans aged 15 and older, over all seven days of the week, people with
121
+ // and without jobs alike. U.S. Bureau of Labor Statistics, American Time Use Survey, 2025 results (released June 25,
122
+ // 2026), table 1. Each activity includes the travel that goes with it. The rows run clockwise round the dial.
123
+ const GROUPS = ["Sleep, meals & self-care", "Work & school", "Home & family", "Leisure & sports", "Everything else"];
124
+ const DAY = [
125
+ { group: 0, name: "Sleep", short: "Sleep", hours: 9.03 },
126
+ { group: 0, name: "Eating and drinking", short: "Meals", hours: 1.21 },
127
+ { group: 0, name: "Grooming and health", short: "Grooming", hours: 0.77 }, // personal care (9.80) less sleep
128
+ { group: 1, name: "Paid work", short: "Work", hours: 3.32 }, // working and work-related activities
129
+ { group: 1, name: "Classes and homework", short: "School", hours: 0.44 },
130
+ { group: 2, name: "Housework, cooking and yard", short: "Chores", hours: 1.99 },
131
+ { group: 2, name: "Shopping and errands", short: "Errands", hours: 0.69 },
132
+ { group: 2, name: "Caring for others", short: "Care", hours: 0.64 }, // household members (0.49) and others (0.15)
133
+ { group: 3, name: "Watching TV", short: "TV", hours: 2.61 },
134
+ { group: 3, name: "Reading, games and leisure", short: "Leisure", hours: 1.63 }, // leisure (5.16) less the three around it
135
+ { group: 3, name: "Socializing", short: "Social", hours: 0.58 },
136
+ { group: 3, name: "Sports and exercise", short: "Sport", hours: 0.34 },
137
+ { group: 4, name: "Worship, volunteering, calls, mail and other", short: "Other", hours: 0.74 }, // 0.28 + 0.19 + 0.27
138
+ ];
139
+ const NOTE = "Source: U.S. Bureau of Labor Statistics, American Time Use Survey, 2025 results (June 2026), table 1: average hours per day, people aged 15 and older, all days of the week. Each activity includes the travel it takes. Times are rounded, so they may not add up to exactly 24 hours.";
140
+
141
+ // What depends on the data: each activity's stretch of the dial, the group totals and the headline's numbers
142
+ const { from, to } = running(DAY.map((a) => a.hours)); // where each activity starts and ends, in hours from the top
143
+ const groupHours = GROUPS.map((_, g) => DAY.filter((a) => a.group === g).reduce((sum, a) => sum + a.hours, 0));
144
+ const LEAD = DAY.findIndex((a) => a.name === "Watching TV"); // the activity the headline names: lit until the reader picks another
145
+ const tvShare = DAY[LEAD].hours / groupHours[DAY[LEAD].group]; // 0.51 of leisure
146
+ const percent = new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 0 });
147
+ const minutes = (hours) => Math.round(hours * 60);
148
+ const short = (hours) => {
149
+ const m = minutes(hours);
150
+ return m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, "0")}`;
151
+ };
152
+ const long = (hours) => {
153
+ const m = minutes(hours), h = Math.floor(m / 60), r = m % 60;
154
+ return [h && `${h} hour${h > 1 ? "s" : ""}`, r && `${r} minute${r > 1 ? "s" : ""}`].filter(Boolean).join(" ");
155
+ };
156
+ const share = Math.abs(tvShare - 0.5) < 0.025 ? "Half" : percent.format(tvShare);
157
+ const HEADLINE = `${share} of our leisure time goes to the TV: ${long(DAY[LEAD].hours)} a day`;
158
+ const say = (i) => `${DAY[i].name}: ${long(DAY[i].hours)} a day, ${percent.format(DAY[i].hours / 24)} of the day`;
159
+
160
+ // The dial's drawing, in a 100 x 100 box around the middle (radius 50): the activities' band runs from radius 30 to 38,
161
+ // with a hairline gap at each end (under 2 minutes of the day, so even the shortest stretch keeps its length).
162
+ // Angles are turns from twelve o'clock, clockwise.
163
+ const TAU = Math.PI * 2;
164
+ const R_IN = 30, R_OUT = 38, R_TEXT = 34, GAP = 0.0012;
165
+ const xy = (t, r) => `${(r * Math.sin(t * TAU)).toFixed(3)},${(-r * Math.cos(t * TAU)).toFixed(3)}`;
166
+ const band = (fromH, toH) => {
167
+ const a = fromH / 24 + GAP, b = toH / 24 - GAP, big = b - a > 0.5 ? 1 : 0;
168
+ return `M${xy(a, R_OUT)}A${R_OUT},${R_OUT} 0 ${big} 1 ${xy(b, R_OUT)}L${xy(b, R_IN)}A${R_IN},${R_IN} 0 ${big} 0 ${xy(a, R_IN)}Z`;
169
+ };
170
+ // The path a label runs along: clockwise on the top half, the other way on the bottom half, so the text stands upright
171
+ const textPath = (fromH, toH) => {
172
+ const a = fromH / 24, b = toH / 24, mid = (a + b) / 2, big = b - a > 0.5 ? 1 : 0;
173
+ return mid > 0.25 && mid < 0.75
174
+ ? `M${xy(b, R_TEXT)}A${R_TEXT},${R_TEXT} 0 ${big} 0 ${xy(a, R_TEXT)}`
175
+ : `M${xy(a, R_TEXT)}A${R_TEXT},${R_TEXT} 0 ${big} 1 ${xy(b, R_TEXT)}`;
176
+ };
177
+ // A label goes on the band only where it fits, with room at both ends: its name and time, its name alone, or nothing.
178
+ // Widths are measured in ems once the label font is in; until then no label is drawn.
179
+ const FONT_PHONE = 3.6, FONT_WIDE = 2.9; // in the box's units: 11.6px on a 322px dial, 16px on a 560px one
180
+ const LABEL_FONT = '600 100px "Albert Sans", Arial, sans-serif';
181
+ const [ems, setEms] = createSignal(null);
182
+ const pen = document.createElement("canvas").getContext("2d");
183
+ document.fonts.load(LABEL_FONT).finally(() => {
184
+ pen.font = LABEL_FONT;
185
+ setEms(() => (text) => pen.measureText(text).width / 100);
186
+ });
187
+ const arcLength = (hours) => TAU * R_TEXT * (hours / 24 - 2 * GAP);
188
+ const fits = (text, hours, size) => ems()(text) * size + 3 <= arcLength(hours);
189
+ const labelOf = (a, size) => {
190
+ if (!ems()) return "";
191
+ const full = `${a.short} ${short(a.hours)}`;
192
+ return fits(full, a.hours, size) ? full : fits(a.short, a.hours, size) ? a.short : "";
193
+ };
194
+
195
+ const THEME = {
196
+ font: "var(--text)", ink: "var(--ink)", muted: "var(--soft)", grid: "var(--rule)", surface: "var(--paper)",
197
+ series: ["var(--accent)", "var(--quiet)", "var(--bezel)"],
198
+ };
199
+
200
+ // Slat types
201
+ // One hour of the bezel: a numeral every two hours, a dot between them, the bezel's triangle at the top.
202
+ // The Place gives up its point to the middle of the dial and turns its mark round it by --rhp-p (its value, 0 to 1).
203
+ const Hour = slat({
204
+ room: {},
205
+ css: `
206
+ .hour { pointer-events: none; container-type: size; }
207
+ .pt { left: 50%; top: 50%; }
208
+ .pt > * { position: absolute; left: 0; top: 0; translate: -50% -50%;
209
+ transform: rotate(calc(var(--rhp-p) * 1turn)) translateY(-45.4cqmin) rotate(calc(var(--rhp-p) * -1turn)); }
210
+ .num { font: 600 clamp(11px, 3.7cqmin, 18px)/1 "Saira", "Arial Narrow", sans-serif; font-stretch: 112%; color: var(--rhp-ink); font-variant-numeric: tabular-nums; }
211
+ .dot { width: 1.1cqmin; height: 1.1cqmin; border-radius: 50%; background: var(--rhp-muted); }
212
+ .tri { width: 4.4cqmin; height: 3.6cqmin; background: var(--rhp-series-1); clip-path: polygon(0 0, 100% 0, 50% 100%); }
213
+ `,
214
+ }, (t) => html`
215
+ <div class="hour">
216
+ <${Place} at=${() => t.at} class="pt">
217
+ ${() => (t.last ? "" : t.at === 0 ? html`<i class="tri"></i>` : t.at % 2 ? html`<i class="dot"></i>` : html`<span class="num">${t.at}</span>`)}
218
+ <//>
219
+ </div>`);
220
+
221
+ // One activity: a Bar from where it starts to where it ends on the 24 hours, which gives up its box to an SVG arc.
222
+ // The arc is drawn in the Bar's color, so the lit one turns gold; the focused one gets an outline.
223
+ const Activity = slat({
224
+ room: { start: 0, end: 0 },
225
+ css: `
226
+ .act { pointer-events: none; container-type: size; outline: none; }
227
+ .arc { inset: 0; width: 100%; height: 100%; translate: none; background: none; }
228
+ .arc svg { display: block; width: 100%; height: 100%; overflow: visible; }
229
+ .band { fill: var(--rhp-color); stroke: transparent; stroke-width: .9; transition: fill .2s; }
230
+ .act:focus-visible .band { stroke: var(--rhp-ink); }
231
+ .lab { font: 600 ${FONT_PHONE}px "Albert Sans", Arial, sans-serif; fill: var(--rhp-series-3); dominant-baseline: central; }
232
+ .lab.wide { display: none; }
233
+ @container (min-width: 460px) {
234
+ .lab { display: none; }
235
+ .lab.wide { display: inline; font-size: ${FONT_WIDE}px; }
236
+ }
237
+ @media (prefers-reduced-motion: reduce) { .band { transition: none; } }
238
+ `,
239
+ }, (d) => html`
240
+ <div class=${() => (d.on ? "act on" : "act")} data-row=${() => d.index} role="img" aria-label=${() => say(d.index)}>
241
+ <${Bar} from=${() => d.from} to=${() => d.to} color=${() => (d.on ? "series-1" : "series-2")} class="arc">
242
+ <svg viewBox="-50 -50 100 100" aria-hidden="true">
243
+ <path class="band" d=${() => band(d.from, d.to)} />
244
+ <path id=${() => `arc-${d.index}`} d=${() => textPath(d.from, d.to)} fill="none" />
245
+ <text class="lab"><textPath href=${() => `#arc-${d.index}`} startOffset="50%" text-anchor="middle">${() => labelOf(d, FONT_PHONE)}</textPath></text>
246
+ <text class="lab wide"><textPath href=${() => `#arc-${d.index}`} startOffset="50%" text-anchor="middle">${() => labelOf(d, FONT_WIDE)}</textPath></text>
247
+ </svg>
248
+ <//>
249
+ </div>`);
250
+
251
+ // Chart component: the activity the reader points at, taps or reaches with the keyboard is lit, and the hand turns to it
252
+ function DayDial() {
253
+ const [on, setOn] = createSignal(null);
254
+ const shown = () => on() ?? LEAD;
255
+ // The hand turns the short way round to the middle of the activity shown
256
+ const middle = (i) => (from[i] + to[i]) / 48;
257
+ const [turn, setTurn] = createSignal(middle(LEAD));
258
+ createEffect(() => {
259
+ const target = middle(shown());
260
+ setTurn((t) => t + ((((target - t) % 1) + 1.5) % 1) - 0.5);
261
+ });
262
+
263
+ // The activity under the pointer: on the dial, the one whose stretch holds the pointer's angle; in the key, its line
264
+ const rowAt = (e) => {
265
+ const face = e.target.closest?.(".face");
266
+ if (face) {
267
+ const box = face.getBoundingClientRect();
268
+ const x = e.clientX - box.left - box.width / 2, y = e.clientY - box.top - box.height / 2;
269
+ const r = Math.hypot(x, y) / (box.width / 2);
270
+ if (r > 1 || r < 0.12) return null;
271
+ const hour = ((Math.atan2(x, -y) / TAU + 1) % 1) * 24;
272
+ const i = to.findIndex((end) => hour < end);
273
+ return i < 0 ? DAY.length - 1 : i;
274
+ }
275
+ const line = e.target.closest?.("[data-row]");
276
+ return line ? +line.dataset.row : null;
277
+ };
278
+ const focused = (el) => el.closest(".poster").querySelector(".act[data-row]:focus");
279
+ // A mouse or a pen picks as it moves; a tap or a click picks and gives that activity focus; the keyboard picks
280
+ const point = (e) => {
281
+ const row = rowAt(e);
282
+ if (row != null) setOn(row);
283
+ };
284
+ const tap = (e) => {
285
+ const row = rowAt(e);
286
+ if (row == null) return;
287
+ setOn(row);
288
+ e.currentTarget.querySelector(`.act[data-row="${row}"]`)?.focus({ preventScroll: true });
289
+ };
290
+ const focus = (e) => {
291
+ const act = e.target.closest?.(".act[data-row]");
292
+ if (act) setOn(+act.dataset.row);
293
+ };
294
+ // Leaving the dial, the key or the poster: back to the activity that has focus (a keyboard's or a tap's pick), or to the lead
295
+ const leave = (e) => {
296
+ if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
297
+ const act = focused(e.currentTarget);
298
+ setOn(act ? +act.dataset.row : null);
299
+ };
300
+
301
+ const a = () => DAY[shown()];
302
+ return html`
303
+ <${Poster}
304
+ kicker="The average American day, 2025"
305
+ title=${HEADLINE}
306
+ dek="How Americans aged 15 and older spend 24 hours, averaged over every day of the week and over people with and without jobs. Each stretch of the dial is how long an activity takes in all, not when it happens."
307
+ note=${NOTE}
308
+ onPointerMove=${(e) => e.pointerType !== "touch" && point(e)} onClick=${tap} onFocusIn=${focus}
309
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setOn(null)}>
310
+ <div class="stage">
311
+ <!-- A press on the face keeps focus where it is, so the pick goes straight from one activity to the next -->
312
+ <div class="face" onMouseDown=${(e) => e.preventDefault()} onPointerLeave=${leave}>
313
+ <${Chart} scale=${[0, 24]} aspect=${1} ticks=${false} theme=${THEME}
314
+ label="The average day in 24 hours: each activity's share of the dial">
315
+ <${Scale} ticks=${every(1)}>${Hour}<//>
316
+ <${Plot} overlap=${true} keyboard=${true} rows=${DAY} from=${from} to=${to} on=${(d) => shown() === d.index}>${Activity}<//>
317
+ <div class="center" aria-hidden="true">
318
+ <div class="hand" style=${() => ({ "--turn": turn() })}><i></i></div>
319
+ <div class="hub">
320
+ <small>${() => GROUPS[a().group]}</small>
321
+ <strong>${() => a().name}</strong>
322
+ <b>${() => {
323
+ const m = minutes(a().hours);
324
+ return m < 60 ? html`${m}<span>min</span>` : html`${Math.floor(m / 60)}<span>h</span>${String(m % 60).padStart(2, "0")}<span>min</span>`;
325
+ }}</b>
326
+ <em>${() => `${percent.format(a().hours / 24)} of the day`}</em>
327
+ </div>
328
+ </div>
329
+ <//>
330
+ </div>
331
+ <p class="hint">Point at the dial or tap it. With a keyboard, Tab to it and use the arrow keys.</p>
332
+ </div>
333
+ <div class="key" onPointerLeave=${leave}>
334
+ ${GROUPS.map((g, gi) => html`
335
+ <section>
336
+ <h3>${g}<span>${DAY.filter((x) => x.group === gi).length > 1 ? short(groupHours[gi]) : ""}</span></h3>
337
+ <ul>
338
+ ${DAY.map((x, i) => (x.group === gi ? html`
339
+ <li data-row=${i} class=${() => (shown() === i ? "on" : "")}><i></i><span>${x.name}</span><b>${short(x.hours)}</b></li>` : null))}
340
+ </ul>
341
+ </section>`)}
342
+ </div>
343
+ <//>`;
344
+ }
345
+
346
+ render(() => html`<${DayDial} />`, document.getElementById("chart"));
347
+ </script>
348
+ </body>
349
+ </html>
@@ -5,6 +5,12 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Staff survey</title>
7
7
  <meta name="description" content="Diverging stacked bar chart of survey answers, disagree to the left of zero and agree to the right, ranked by net agreement, with a switch between questions. Use it for Likert scales or any values on either side of zero.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: Bars to both sides of zero stacked by answer, ranked by net agreement, and a switch between questions whose headlines share one grid cell.
11
+ Leave: the design. This poster was made for its own subject (a staff survey as a two-ink risograph form, red for disagree and blue for agree, solid when strong and dotted when not).
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=Hanken+Grotesk:wght@400..800&family=Syne:wght@700..800&display=swap">
@@ -5,6 +5,12 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Who ships the world's phones</title>
7
7
  <meta name="description" content="Donut chart: a few parts of one whole, each labeled inside its slice (or in a key under the donut when its label does not fit), the total in the middle, and the slice the reader points at, taps or reaches with the keyboard read out in its place. Use it for two to six parts of a total, one or two of them the story.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: SVG wedges drawn in Bars from stackUp() of shares(), the total in the hub, and the picked slice read out in its place.
11
+ Leave: the design. This poster was made for its own subject (phone makers' shipments shown on a phone's own screen, white glass with a status bar and two brands in color).
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=Geist:wght@400..800&family=Geist+Mono:wght@500&display=swap">
@@ -193,6 +199,8 @@ function PhoneMakers() {
193
199
  if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
194
200
  setOn(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
195
201
  };
202
+ // Off the chart (on the headline, the readout or the note), pointing is as if the pointer had left the poster
203
+ const move = (e) => e.pointerType !== "touch" && (e.target.closest(".rhp-chart") ? pick(e) : leave(e));
196
204
  const company = () => (on() == null ? null : COMPANIES[on()]);
197
205
  return html`
198
206
  <${Poster}
@@ -200,7 +208,7 @@ function PhoneMakers() {
200
208
  title=${html`<em>Apple</em> and <em>Samsung</em> ship more than a third of all smartphones`}
201
209
  dek="Of 1.24 billion smartphones shipped in 2024, the two biggest companies shipped 455.5 million. The next three together shipped 380.2 million. Tap or point at a slice."
202
210
  note="Source: IDC Worldwide Quarterly Mobile Phone Tracker, preliminary results for 2024 (January 2025), in millions of phones."
203
- onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
211
+ onPointerMove=${move} onClick=${pick} onFocusIn=${pick}
204
212
  onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setOn(null)}>
205
213
  <${Chart} class="chart" aspect=${1} ticks=${false} label="Smartphones shipped in 2024 by company, as shares of the total"
206
214
  theme=${{ font: "Geist, 'Helvetica Neue', Arial, sans-serif", ink: "#16181b", muted: "#5d636c", surface: "#f1f1ef" }}>
@@ -5,6 +5,12 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Ethiopia gained 21 years of life</title>
7
7
  <meta name="description" content="Dumbbell chart of life expectancy in 1990 and 2020 for ten countries: a ring for the first value, a dot for the second and a bar between them, with a switch that re-sorts the countries by level or by change. Use it to compare two values per item and the gap between them.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: a ring, a dot and the Bar between them in one slat, the change in the end room, and a switch that re-sorts by level or by change.
11
+ Leave: the design. This poster was made for its own subject (life expectancy as a health report, with a pulse line across the page, teal for years gained and red for years lost).
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=Schibsted+Grotesk:wght@400..900&display=swap">
@@ -5,6 +5,12 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>The build is a week behind</title>
7
7
  <meta name="description" content="Gantt chart of a twelve-week product launch: a bar per task from its start to its end, the part done, the work that is late, a milestone, a today line drawn by an overlay Plot and week ticks drawn by a Scale, with a readout of the picked task and what it depends on. Use it for tasks over time and a schedule's progress.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: planned, done and late Bars per task, a milestone, a today line in an overlay Plot, week ticks from a Scale, and a readout with dependencies.
11
+ Leave: the design. This poster was made for its own subject (a launch plan on a sheet of dotted planner paper, held to the wall by a strip of tape).
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=Space+Grotesk:wght@400..700&display=swap">
@@ -29,8 +35,11 @@
29
35
  .poster .dek i.late { background: #d93a32; }
30
36
  .poster .dek i.todo { background: #dcdad3; }
31
37
  .poster .note { display: block; margin-top: 14px; font-size: 12px; color: #63676e; }
32
- /* The readout: the picked task in one line (two on a phone), so the chart never moves */
33
- .readout { min-height: 2.2em; margin: 0 0 18px; padding: 9px 12px; border-radius: 6px; background: #1c1f24; color: #f4f4f1; font-size: 15px; }
38
+ /* The readout: every task's sentence in one grid cell, only the picked one shown, so it is as tall as the longest at
39
+ every width and the chart never moves */
40
+ .readout { display: grid; margin: 0 0 18px; padding: 9px 12px; border-radius: 6px; background: #1c1f24; color: #f4f4f1; font-size: 15px; }
41
+ .readout > .v { grid-area: 1 / 1; visibility: hidden; }
42
+ .readout > .v.on { visibility: visible; }
34
43
  .readout b { font-weight: 700; }
35
44
  .readout strong { font-weight: 400; }
36
45
  .readout .late { color: #ff8a80; font-weight: 700; }
@@ -43,7 +52,7 @@
43
52
  .poster .kicker { font-size: 11px; letter-spacing: .1em; }
44
53
  .poster .headline { font-size: 32px; }
45
54
  .poster .dek { font-size: 14.5px; }
46
- .readout { position: sticky; top: 8px; z-index: 2; min-height: 4.6em; font-size: 14px; }
55
+ .readout { position: sticky; top: 8px; z-index: 2; font-size: 14px; }
47
56
  }
48
57
  </style>
49
58
  </head>
@@ -160,16 +169,19 @@ function LaunchPlan() {
160
169
  if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
161
170
  setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
162
171
  };
172
+ // Off the chart (on the headline, the readout or the note), pointing is as if the pointer had left the poster
173
+ const move = (e) => e.pointerType !== "touch" && (e.target.closest(".rhp-chart") ? pick(e) : leave(e));
163
174
  return html`
164
175
  <${Poster}
165
176
  kicker=${`Launch plan · week ${Math.floor(now) + 1} of ${WEEKS}`}
166
177
  title="The build is a week behind, and testing waits on it"
167
178
  dek=${html`<span>Twelve weeks from scope to launch day, as of ${long.format(dateOf(now))}. Each bar is a task: <i class="done"></i>done, <i class="late"></i>should be done by now, <i class="todo"></i>still to do.</span>`}
168
179
  note="Illustrative data. Point at a task or tap it to see what it waits on, or Tab into the chart and use the arrow keys."
169
- onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
180
+ onPointerMove=${move} onClick=${pick} onFocusIn=${pick}
170
181
  onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
171
182
  <p class="readout">
172
- <b>${() => shown().task}</b>, ${() => span(shown())}: <strong class=${() => (shown().late >= 0.5 ? "late" : "")}>${() => statusOf(shown())}</strong>${() => (shown().after.length ? html`<span class="after"> · after ${shown().after.join(" and ")}</span>` : "")}
183
+ ${ROWS.map((t) => html`
184
+ <span class=${() => (shown() === t ? "v on" : "v")}><b>${t.task}</b>, ${span(t)}: <strong class=${t.late >= 0.5 ? "late" : ""}>${statusOf(t)}</strong>${t.after.length ? html`<span class="after"> · after ${t.after.join(" and ")}</span>` : ""}</span>`)}
173
185
  </p>
174
186
  <${Chart} scale=${[0, WEEKS]} label="Gantt chart of a twelve-week product launch plan, with today marked"
175
187
  theme=${{ font: '"Space Grotesk", "Helvetica Neue", Arial, sans-serif', ink: "#1c1f24", muted: "#63676e", grid: "#e4e2dc", surface: "#fbfbf8" }}>
@@ -5,6 +5,12 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Weekend screen time</title>
7
7
  <meta name="description" content="Grouped bar chart, two bars per category with the gap between them in the end gutter, and a switch that sorts the groups. Use it to compare two to four values side by side for each category.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: an inner Plot that sets two bars side by side per category, the gap between them in the end room, and a sort switch.
11
+ Leave: the design. This poster was made for its own subject (screen time on a pixel grid in cobalt and orange, the headline number on a starburst sticker).
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=Manrope:wght@400..800&family=Unbounded:wght@600..800&display=swap">
@@ -5,6 +5,12 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>When the code gets written</title>
7
7
  <meta name="description" content="Heatmap: a value over two categories (weekday by hour), one colored cell each, with a readout of the cell the reader points at or taps and of the day in keyboard focus. It turns its days into columns on a phone. Use it for activity by day and hour, or any grid of counts.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: a Plot of Cells inside each slat, a color key, a readout of the picked cell, and a grid that turns on phones.
11
+ Leave: the design. This poster was made for its own subject (a year of commits as a keyboard on a desk, its keys lit by how often they were pressed).
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=Sora:wght@500..800&family=JetBrains+Mono:wght@400..700&display=swap">
@@ -142,6 +148,8 @@ function Commits() {
142
148
  if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
143
149
  setAt(e.currentTarget.contains(document.activeElement) ? held : null);
144
150
  };
151
+ // Off the chart (on the headline, the readout or the note), pointing is as if the pointer had left the poster
152
+ const move = (e) => e.pointerType !== "touch" && (e.target.closest(".rhp-chart") ? pick(e) : leave(e));
145
153
  const readout = () => {
146
154
  const [day, hour] = at() ?? [1, 15];
147
155
  const lead = at() ? "" : "Busiest hour: ";
@@ -157,7 +165,7 @@ function Commits() {
157
165
  title="More than half of all commits land on weekday afternoons"
158
166
  dek="One team's commits over a year, by weekday and hour. The keys darken from 2 to 5 pm on every working day, the lunch hour dips, and weekends barely move."
159
167
  note="Illustrative data, in local time. Point at a key or tap it to read its hour; Tab into the chart and use the arrow keys to read a day."
160
- onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${click} onFocusIn=${focusDay}
168
+ onPointerMove=${move} onClick=${click} onFocusIn=${focusDay}
161
169
  onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setAt(null)}>
162
170
  <div class="readout">
163
171
  <output aria-live="polite">${readout}</output>
@@ -5,6 +5,12 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Runners sprint to beat four hours</title>
7
7
  <meta name="description" content="Histogram of marathon finish times made with bins(), one column per 5-minute bin, with a readout of the picked bin and the share of runners ahead of it. Use it to show how one measure is distributed and where values bunch.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: bins() drawn as columns, a readout of the picked bin and the share ahead of it, and a sideways finger drag through the columns.
11
+ Leave: the design. This poster was made for its own subject (marathon finish times on the race bib pinned to a dark running vest, with a finish-line clock as the readout).
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=Archivo:wdth,wght@62..125,400..900&display=swap">
@@ -163,6 +169,8 @@ function FinishTimes() {
163
169
  if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
164
170
  setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
165
171
  };
172
+ // Off the chart (on the headline, the readout or the note), pointing is as if the pointer had left the poster
173
+ const move = (e) => (e.pointerType !== "touch" || sideways(e)) && (e.target.closest(".rhp-chart") ? pick(e) : leave(e));
166
174
  return html`
167
175
  <${Poster}
168
176
  kicker=${`City marathon · ${count.format(FINISH_TIMES.length)} finishers`}
@@ -171,7 +179,7 @@ function FinishTimes() {
171
179
  finished between ${clock(FOCUS)} and ${clock(FOCUS + BIN - 1)}, and only ${count.format(tally[opening + 1])} in the 5 minutes after.</span>`}
172
180
  note="Illustrative data. Point at a column, tap it or drag across the chart, or Tab into it and use the arrow keys."
173
181
  onPointerDown=${(e) => (start = [e.clientX, e.clientY])}
174
- onPointerMove=${(e) => (e.pointerType !== "touch" || sideways(e)) && pick(e)} onClick=${pick} onFocusIn=${pick}
182
+ onPointerMove=${move} onClick=${pick} onFocusIn=${pick}
175
183
  onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
176
184
  <div class="board">
177
185
  <div><b>${() => `${clock(x0[shown()])} to ${clock(x1[shown()] - 1)}`}</b><span>Finish time</span></div>