@bezda/rhp-mcp 0.1.6 → 0.1.8

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.
@@ -0,0 +1,578 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>A day on one tape</title>
7
+ <meta name="description" content="Tape: the parts of one whole laid end to end along a ruled measure and cut into equal lengths (a Chart per length, an overlap Plot of the parts that fall in it, a Scale of rules, notches and numerals that counts on), names and times inside a part only where the measured text fits, one group lit at rest, a big-number readout and a grouped key that picks too. Use it for how a day, a budget or any whole splits into parts that add up, read as lengths along one measure.">
8
+ <!--
9
+ A recipe gives technique, not a design.
10
+ Take: the parts of one whole laid end to end (where each starts and ends, summed in order) and cut into equal lengths, a Chart per length with an overlap Plot of the parts that fall in it (a part carried over from the length before is drawn open at its start); a Scale of rules, notches and numerals that counts on from the length before; names and times inside a part only where the text, measured once its font is in, fits; picking a part by pointer, tap, keyboard or its line in the key; one group lit at rest; a big-number readout.
11
+ Leave: the design. This poster was made for its own subject (the average American day as a yellow tape measure in three 8-hour lengths, its numerals and rules printed in black, leisure in one deep blue, a grouped key under the 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
+ -->
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=Barlow+Condensed:wght@600;700&family=Barlow:wght@400;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
+ /* Tokens: tape-measure yellow, the ink of printed numerals, one deep blue for the story */
20
+ :root {
21
+ --page: #d1ad44;
22
+ --paper: #edc965;
23
+ --ink: #231a05;
24
+ --soft: #4a3a0e;
25
+ --rule: #d4b33c;
26
+ --accent: #163077;
27
+ --tint: #f7e5a8;
28
+ --display: "Barlow Condensed", "Arial Narrow", Arial, sans-serif;
29
+ --text: "Barlow", "Helvetica Neue", Arial, sans-serif;
30
+ }
31
+ * { box-sizing: border-box; }
32
+ body { margin: 0; padding: 48px 16px; background: var(--page, #d1ad44); }
33
+
34
+ .poster { display: grid; grid-template-columns: minmax(0, 1fr); row-gap: 14px; max-width: 1040px; margin: 0 auto; padding: 44px 48px 28px;
35
+ grid-template-areas: "kicker" "headline" "dek" "readout" "tapes" "key" "note";
36
+ background: var(--paper); color: var(--ink); font: 16px/1.5 var(--text); }
37
+ .poster figcaption { display: contents; }
38
+ .poster .kicker { grid-area: kicker; font: 700 14px/1.3 var(--display); letter-spacing: .2em; text-transform: uppercase; color: var(--accent); }
39
+ .poster .headline { grid-area: headline; max-width: 17em; font: 700 clamp(36px, 6vw, 64px)/.98 var(--display); letter-spacing: -.005em; text-wrap: balance; }
40
+ .poster .dek { grid-area: dek; max-width: 36em; font-size: 17px; line-height: 1.45; color: var(--soft); }
41
+ .poster .note { grid-area: note; display: block; margin-top: 10px; padding-top: 12px; border-top: 2px solid var(--ink); font-size: 12px; line-height: 1.5; color: var(--soft); }
42
+ .poster :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
43
+
44
+ /* The readout: the stretch picked, in big printed numerals; it keeps its height so nothing below moves */
45
+ .readout { grid-area: readout; display: flex; align-items: center; gap: 18px; min-height: 84px; margin: 6px 0 10px; }
46
+ .readout .big { flex: none; font: 700 clamp(48px, 9vw, 76px)/1 var(--display); font-variant-numeric: tabular-nums; color: var(--accent); white-space: nowrap; }
47
+ .readout .big span { margin: 0 .12em 0 .05em; font-size: .46em; font-weight: 600; }
48
+ .readout .what { min-width: 0; font-size: 16px; line-height: 1.3; color: var(--soft); }
49
+ .readout .what strong { display: block; font: 700 clamp(22px, 3.4vw, 28px)/1.1 var(--display); color: var(--ink); }
50
+
51
+ /* The tapes: three lengths of 8 hours, one above the other */
52
+ .tapes { grid-area: tapes; display: grid; gap: 14px; min-width: 0; }
53
+ .tapes .hours { pointer-events: none; }
54
+
55
+ /* The key: every stretch of tape, grouped, in tape order */
56
+ .key { grid-area: key; display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 18px 28px; margin-top: 8px; min-width: 0; }
57
+ .key h3 { display: flex; justify-content: space-between; gap: 10px; margin: 0 0 4px; padding-bottom: 5px; border-bottom: 2px solid var(--ink);
58
+ font: 700 15px/1.2 var(--display); letter-spacing: .1em; text-transform: uppercase; }
59
+ .key h3 span { font-variant-numeric: tabular-nums; letter-spacing: .04em; white-space: nowrap; }
60
+ .key ul { margin: 0; padding: 0; list-style: none; }
61
+ .key button { display: grid; grid-template-columns: 14px minmax(0, 1fr) auto; align-items: baseline; gap: 9px; width: 100%; margin: 0; padding: 3px 0;
62
+ border: 0; background: none; color: var(--ink); font: 400 15px/1.3 var(--text); text-align: left; cursor: pointer; }
63
+ .key button i { align-self: center; width: 14px; height: 14px; background: var(--tint); box-shadow: inset 0 0 0 2px var(--ink); }
64
+ .key button b { font: 700 16px/1.2 var(--display); font-variant-numeric: tabular-nums; white-space: nowrap; }
65
+ .key button.on { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 2px; }
66
+ .key button.on i { background: var(--accent); box-shadow: none; }
67
+
68
+ @media (min-width: 860px) {
69
+ .poster { grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); column-gap: 40px;
70
+ grid-template-areas: "kicker kicker" "headline headline" "dek readout" "tapes tapes" "key key" "note note"; }
71
+ .readout { margin: 0; }
72
+ }
73
+ @media (max-width: 640px) {
74
+ body { padding: 16px 16px 32px; }
75
+ .poster { padding: 24px 16px 16px; }
76
+ .poster .dek { font-size: 15px; }
77
+ .readout { gap: 12px; min-height: 128px; }
78
+ .readout .what { font-size: 14px; }
79
+ }
80
+ </style>
81
+ </head>
82
+ <body>
83
+ <div id="chart"></div>
84
+ <script type="module">
85
+ import { Chart, Plot, Scale, Bar, Tick, Place, Poster, slat, every, html, render, createSignal } from "@bezda/rhp/standalone";
86
+
87
+ // Data: average hours per day in 2025, for Americans aged 15 and older, over all seven days of the week, people with
88
+ // and without jobs alike. U.S. Bureau of Labor Statistics, American Time Use Survey, 2025 results (released June 25,
89
+ // 2026), table 1. Each activity includes the travel that goes with it.
90
+ const GROUPS = ["Sleep and self-care", "Work and school", "Home and family", "Leisure and sports", "Everything else"];
91
+ const DAY = [
92
+ { group: 0, name: "Sleep", short: "Sleep", hours: 9.03 },
93
+ { group: 0, name: "Eating and drinking", short: "Meals", hours: 1.21 },
94
+ { group: 0, name: "Grooming and health", short: "Grooming", hours: 0.77 },
95
+ { group: 1, name: "Paid work", short: "Work", hours: 3.32 },
96
+ { group: 1, name: "Classes and homework", short: "School", hours: 0.44 },
97
+ { group: 2, name: "Housework, cooking and yard", short: "Chores", hours: 1.99 },
98
+ { group: 2, name: "Shopping and errands", short: "Errands", hours: 0.69 },
99
+ { group: 2, name: "Caring for others", short: "Care", hours: 0.64 },
100
+ { group: 3, name: "Watching TV", short: "TV", hours: 2.61 },
101
+ { group: 3, name: "Reading, games and other leisure", short: "Leisure", hours: 1.63 },
102
+ { group: 3, name: "Socializing", short: "Social", hours: 0.58 },
103
+ { group: 3, name: "Sports and exercise", short: "Sport", hours: 0.34 },
104
+ { group: 4, name: "Worship, volunteering, calls, mail and other", short: "Other", hours: 0.74 },
105
+ ];
106
+ 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.";
107
+
108
+ // What depends on the data: where each activity starts and ends on the tape, group totals, the headline's numbers
109
+ let at = 0;
110
+ const ACTS = DAY.map((a) => ({ ...a, from: at, to: (at += a.hours) }));
111
+ const groupHours = GROUPS.map((_, g) => DAY.filter((a) => a.group === g).reduce((sum, a) => sum + a.hours, 0));
112
+ const LEAD = 3; // the group the headline names: leisure, lit until the reader picks a stretch
113
+ const WORK = DAY.find((a) => a.name === "Paid work").hours;
114
+ const percent = new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 0 });
115
+ const minutes = (hours) => Math.round(hours * 60);
116
+ const short = (hours) => {
117
+ const m = minutes(hours);
118
+ return m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, "0")}`;
119
+ };
120
+ const HEADLINE = groupHours[LEAD] >= WORK
121
+ ? `Leisure gets ${short(groupHours[LEAD])} of the day, paid work only ${short(WORK)}`
122
+ : `Paid work gets ${short(WORK)} of the day, leisure only ${short(groupHours[LEAD])}`;
123
+ const say = (i) => `${ACTS[i].name}: ${short(ACTS[i].hours)} a day, ${percent.format(ACTS[i].hours / 24)} of the day`;
124
+
125
+ // The tape: three lengths of 8 hours. A length holds the part of each activity that falls inside its 8 hours.
126
+ const LENGTH = 8, PAD = 12, TAPE = 66;
127
+ const piecesOf = (r) => ACTS.flatMap((a, i) => {
128
+ const lo = r * LENGTH, from = Math.max(a.from, lo), to = Math.min(a.to, lo + LENGTH);
129
+ return to > from + 1e-6 ? [{ act: i, from: from - lo, to: to - lo, cont: a.from < lo }] : [];
130
+ });
131
+
132
+ // Labels go on the tape only where they fit, measured once the font is in; the key names every stretch
133
+ const LABEL_FONT = '700 100px "Barlow Condensed", Arial, sans-serif';
134
+ const [ems, setEms] = createSignal(null);
135
+ const [perHour, setPerHour] = createSignal(0);
136
+ const pen = document.createElement("canvas").getContext("2d");
137
+ document.fonts.load(LABEL_FONT).finally(() => {
138
+ pen.font = LABEL_FONT;
139
+ setEms(() => (text) => pen.measureText(text).width / 100);
140
+ });
141
+ const room = (d, text, size) => ems() != null && ems()(text) * size + 16 <= (d.to - d.from) * perHour();
142
+ const nameOf = (d) => (room(d, ACTS[d.act].short, 19) ? ACTS[d.act].short : "");
143
+ const timeOf = (d) => (!d.cont && nameOf(d) && room(d, short(ACTS[d.act].hours), 16) ? short(ACTS[d.act].hours) : "");
144
+
145
+ const THEME = {
146
+ font: "var(--text)", ink: "var(--ink)", muted: "var(--soft)", grid: "var(--rule)", surface: "var(--paper)",
147
+ series: ["var(--accent)", "var(--tint)"],
148
+ };
149
+
150
+ // Slat types
151
+ // A stretch of tape: a Bar from where the activity starts to where it ends, with its name and time inside when they fit
152
+ const Piece = slat({
153
+ thickness: TAPE,
154
+ room: { start: PAD, end: PAD },
155
+ css: `
156
+ .seg { pointer-events: none; }
157
+ .bar { pointer-events: auto; --rhp-start-radius: 0px; --rhp-end-radius: 0px; display: flex; flex-direction: column; justify-content: flex-end; gap: 1px;
158
+ padding: 0 8px 7px; overflow: hidden; cursor: default; color: var(--rhp-ink);
159
+ box-shadow: inset 0 3px 0 var(--rhp-ink), inset 0 -3px 0 var(--rhp-ink), inset -3px 0 0 var(--rhp-ink); }
160
+ .seg.first .bar { box-shadow: inset 0 3px 0 var(--rhp-ink), inset 0 -3px 0 var(--rhp-ink), inset -3px 0 0 var(--rhp-ink), inset 3px 0 0 var(--rhp-ink); }
161
+ .on .bar { color: var(--rhp-surface); }
162
+ .nm { font: 700 19px/1 "Barlow Condensed", Arial, sans-serif; white-space: nowrap; }
163
+ .tm { font: 600 16px/1 "Barlow Condensed", Arial, sans-serif; font-variant-numeric: tabular-nums; white-space: nowrap; }
164
+ .seg:focus-visible { outline: none; }
165
+ .seg:focus-visible .bar { outline: 3px solid var(--rhp-ink); outline-offset: 2px; }
166
+ @media (max-width: 640px) { .nm { font-size: 17px; } .tm { font-size: 14px; } }
167
+ `,
168
+ }, (d) => html`
169
+ <div class=${() => `seg${d.on ? " on" : ""}${d.from === 0 ? " first" : ""}`} data-act=${() => d.act} role="img" aria-label=${() => say(d.act)}>
170
+ <${Bar} from=${() => d.from} to=${() => d.to} color=${() => (d.on ? "series-1" : "series-2")} class="bar" thick=${1}>
171
+ <span class="nm">${() => nameOf(d)}</span><span class="tm">${() => timeOf(d)}</span>
172
+ <//>
173
+ </div>`);
174
+
175
+ // The tape's printing: a hairline at every hour across the tape (so the hours can be counted), notches at the quarter
176
+ // hours, and a numeral above each hour
177
+ const Hour = slat({
178
+ room: { start: PAD, end: PAD, before: 26 },
179
+ css: `
180
+ .line { --rhp-tick-width: 1.5px; background: color-mix(in oklab, var(--rhp-ink) 22%, transparent); pointer-events: none; }
181
+ .notch { position: absolute; left: 0; top: 0; translate: -50% 0; width: 2px; background: var(--rhp-ink); pointer-events: none; }
182
+ .notch.q { height: 6px; }
183
+ .notch.h { height: 11px; }
184
+ .num { position: absolute; left: 0; top: -22px; translate: -50% 0; font: 700 17px/1 "Barlow Condensed", Arial, sans-serif; color: var(--rhp-ink); font-variant-numeric: tabular-nums; pointer-events: none; }
185
+ `,
186
+ }, (t) => {
187
+ const whole = Math.abs(t.at - Math.round(t.at)) < 1e-6, half = Math.abs(t.at * 2 - Math.round(t.at * 2)) < 1e-6;
188
+ return html`
189
+ <div>
190
+ ${whole ? html`<${Tick} at=${t.at} thick=${1} class="line" />` : null}
191
+ <${Place} at=${t.at} across=${0}>
192
+ <i class=${whole ? "notch h" : half ? "notch h" : "notch q"}></i>
193
+ ${whole ? html`<span class="num">${t.at + t.base}</span>` : null}
194
+ <//>
195
+ </div>`;
196
+ });
197
+
198
+ // Chart component: the stretch the reader points at, taps or reaches with the keyboard is lit, the rest is quiet
199
+ function DayTape() {
200
+ const [on, setOn] = createSignal(null);
201
+ const lit = (i) => (on() == null ? ACTS[i].group === LEAD : on() === i);
202
+ const shown = () => (on() == null ? null : ACTS[on()]);
203
+
204
+ // The activity of the element under the pointer or focus, or null
205
+ const actOf = (el) => {
206
+ const hit = el.closest?.("[data-act]");
207
+ return hit ? +hit.dataset.act : null;
208
+ };
209
+ // A mouse or a pen picks as it moves; a tap, a click or the keyboard picks; on no stretch the pick stays
210
+ const pick = (e) => {
211
+ const act = actOf(e.target);
212
+ if (act != null) setOn(act);
213
+ };
214
+ const tap = (e) => {
215
+ const act = actOf(e.target);
216
+ if (act == null) return;
217
+ setOn(act);
218
+ e.currentTarget.querySelector(`.seg[data-act="${act}"]`)?.focus({ preventScroll: true });
219
+ };
220
+ // Leaving the poster: back to the stretch that has focus (a keyboard's or a tap's pick), or to the lead
221
+ const leave = (e) => {
222
+ if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
223
+ setOn(actOf(document.activeElement && e.currentTarget.contains(document.activeElement) ? document.activeElement : e.currentTarget));
224
+ };
225
+ const move = (e) => e.pointerType !== "touch" && (e.target.closest(".tapes, .key") ? pick(e) : leave(e));
226
+
227
+ const big = (hours) => {
228
+ const m = minutes(hours);
229
+ 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>`;
230
+ };
231
+ const share = (hours) => `${percent.format(hours / 24)} of the day`;
232
+
233
+ // Watches the first tape's width: one hour is a fixed width, the same on all three tapes
234
+ const measure = (el) => {
235
+ const update = () => setPerHour((el.clientWidth - 2 * PAD) / LENGTH);
236
+ new ResizeObserver(() => requestAnimationFrame(update)).observe(el);
237
+ update();
238
+ };
239
+
240
+ return html`
241
+ <${Poster}
242
+ kicker="One day, 24 hours of tape"
243
+ title=${HEADLINE}
244
+ dek="The average American day as a tape measure cut into three lengths of 8 hours: each stretch is how long an activity takes in all, laid end to end, so read the numbers as hours used up, not as clock times. Point at or tap a stretch to read it."
245
+ note=${NOTE}
246
+ onPointerMove=${move} onClick=${tap} onFocusIn=${pick}
247
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setOn(null)}>
248
+ <div class="readout" aria-live="polite">
249
+ <b class="big">${() => big(shown() ? shown().hours : groupHours[LEAD])}</b>
250
+ <span class="what">
251
+ <strong>${() => (shown() ? shown().name : GROUPS[LEAD])}</strong>
252
+ ${() => (shown() ? `${share(shown().hours)}, part of ${GROUPS[shown().group].toLowerCase()}` : `${share(groupHours[LEAD])}, all four kinds of it together`)}
253
+ </span>
254
+ </div>
255
+ <div class="tapes">
256
+ ${[0, 1, 2].map((r) => html`
257
+ <${Chart} scale=${[0, LENGTH]} height=${TAPE} ticks=${false} theme=${THEME} ref=${r === 0 ? measure : undefined}
258
+ label=${`Hours ${r * LENGTH} to ${(r + 1) * LENGTH} of the day, by activity`}>
259
+ <${Plot} overlap=${true} keyboard=${true} rows=${piecesOf(r)} on=${(d) => lit(d.act)}>${Piece}<//>
260
+ <${Scale} ticks=${every(0.25)} base=${r * LENGTH} class="hours">${Hour}<//>
261
+ <//>`)}
262
+ </div>
263
+ <div class="key" onPointerLeave=${leave}>
264
+ ${GROUPS.map((g, gi) => html`
265
+ <section>
266
+ <h3>${g}<span>${short(groupHours[gi])}</span></h3>
267
+ <ul>
268
+ ${ACTS.map((x, i) => (x.group === gi ? html`
269
+ <li><button type="button" data-act=${i} class=${() => (lit(i) ? "on" : "")}><i></i><span>${x.name}</span><b>${short(x.hours)}</b></button></li>` : null))}
270
+ </ul>
271
+ </section>`)}
272
+ </div>
273
+ <//>`;
274
+ }
275
+
276
+ render(() => html`<${DayTape} />`, document.getElementById("chart"));
277
+ </script>
278
+ <style id="poster-download-styles">
279
+ .poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
280
+ .poster-actions { display: flex; align-items: center; margin-left: auto; }
281
+ .poster-download, .poster-fit-toggle { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 44px; height: 44px; padding: 10px; border: 0; border-radius: 4px; background: transparent; color: inherit; cursor: pointer; }
282
+ .poster-actions svg { display: block; width: 20px; height: 20px; }
283
+ .poster-actions button:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
284
+ .poster-actions button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
285
+ .poster-actions button[aria-disabled="true"] { cursor: progress; opacity: .5; }
286
+ .poster-fit-frame, .poster-fit-content { display: contents; }
287
+ .poster-fit-frame { max-height: 95svh; }
288
+ .poster-fit-frame.is-fitted { display: block; margin: 0 auto; overflow: clip; }
289
+ .poster-fit-frame.is-fitted > .poster-fit-content { display: block; transform-origin: top left; }
290
+ .poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
291
+ @media print { .poster-download-tools, .poster-actions, .poster-download-error { display: none !important; } }
292
+ </style>
293
+ <script type="module" id="poster-download-script">
294
+ function addPosterFit(poster, button, changed) {
295
+ const frame = document.createElement('div'), content = document.createElement('div');
296
+ frame.className = 'poster-fit-frame'; content.className = 'poster-fit-content';
297
+ poster.before(frame); frame.append(content); content.append(poster);
298
+ const properties = ['width', 'max-width', 'margin', 'box-sizing'];
299
+ const original = properties.map(name => [name, poster.style.getPropertyValue(name), poster.style.getPropertyPriority(name)]);
300
+ let fitted = false, scale = 1, request = 0, layout = null;
301
+ const availableHeight = () => parseFloat(getComputedStyle(frame).maxHeight) || innerHeight * .95;
302
+ const layoutKey = () => {
303
+ const css = getComputedStyle(poster);
304
+ return [innerWidth, availableHeight(), css.width, css.height].join('/');
305
+ };
306
+ const restore = () => original.forEach(([name, value, priority]) => {
307
+ if (value) poster.style.setProperty(name, value, priority);
308
+ else poster.style.removeProperty(name);
309
+ });
310
+ function update() {
311
+ request = 0;
312
+ if (fitted && layout === layoutKey()) return;
313
+ frame.classList.remove('is-fitted'); frame.style.width = ''; frame.style.height = '';
314
+ content.style.width = ''; content.style.transform = ''; restore(); scale = 1;
315
+ if (fitted) {
316
+ const box = poster.getBoundingClientRect();
317
+ scale = Math.min(1, availableHeight() / box.height);
318
+ poster.style.setProperty('box-sizing', 'border-box');
319
+ poster.style.setProperty('width', `${box.width}px`);
320
+ poster.style.setProperty('max-width', 'none');
321
+ poster.style.setProperty('margin', '0');
322
+ content.style.width = `${box.width}px`;
323
+ content.style.transform = `scale(${scale})`;
324
+ frame.style.width = `${box.width * scale}px`; frame.style.height = `${box.height * scale}px`;
325
+ frame.classList.add('is-fitted');
326
+ }
327
+ layout = layoutKey();
328
+ const label = fitted ? 'Restore poster size' : 'Fit poster to screen';
329
+ button.setAttribute('aria-label', label); button.title = label;
330
+ button.setAttribute('aria-pressed', String(fitted));
331
+ button.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${fitted ? 'M15 3h6v6m0-6-7 7M9 21H3v-6m0 6 7-7' : 'M4 4l6 6m0-6v6H4m16 10-6-6m0 6v-6h6'}"/></svg>`;
332
+ changed();
333
+ poster.dispatchEvent(new Event('poster-view-change'));
334
+ }
335
+ const schedule = () => { if (fitted && !request && layout !== layoutKey()) request = requestAnimationFrame(update); };
336
+ new ResizeObserver(schedule).observe(poster);
337
+ addEventListener('resize', schedule);
338
+ document.fonts.ready.then(schedule);
339
+ update();
340
+ return {
341
+ toggle() { fitted = !fitted; layout = null; cancelAnimationFrame(request); update(); },
342
+ dimensions() {
343
+ if (!fitted) { const box = poster.getBoundingClientRect(); return {width:box.width,height:box.height}; }
344
+ const css = getComputedStyle(poster);
345
+ return {width:parseFloat(css.width),height:parseFloat(css.height)};
346
+ },
347
+ };
348
+ }
349
+ function addPosterDownloads() {
350
+ document.querySelectorAll('.poster').forEach((poster, index) => {
351
+ const navigation = index === 0 && document.querySelector('.recipe-navigation');
352
+ const tools = navigation || document.createElement('div');
353
+ if (tools.querySelector('.poster-download')) return;
354
+ const align = () => { if (!navigation) tools.style.width = `${poster.getBoundingClientRect().width}px`; };
355
+ if (!navigation) {
356
+ tools.className = 'poster-download-tools';
357
+ poster.before(tools);
358
+ new ResizeObserver(align).observe(poster);
359
+ align();
360
+ const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
361
+ if (rgb) {
362
+ const light = rgb.slice(0, 3).map(Number).map(v => {
363
+ v /= 255;
364
+ return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
365
+ });
366
+ tools.style.color = light[0] * .2126 + light[1] * .7152 + light[2] * .0722 < .179 ? '#eef2f5' : '#1d1b17';
367
+ }
368
+ }
369
+ const button = document.createElement('button');
370
+ button.type = 'button';
371
+ button.className = 'poster-download';
372
+ button.setAttribute('aria-label', 'Download poster as PNG');
373
+ button.title = 'Download poster as PNG';
374
+ button.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12m-5-5 5 5 5-5M5 16v4h14v-4"/></svg>';
375
+ const actions = document.createElement('div'), fitButton = document.createElement('button');
376
+ actions.className = 'poster-actions'; fitButton.className = 'poster-fit-toggle'; fitButton.type = 'button';
377
+ if (!poster.id) {
378
+ let id = `rhp-poster-${index + 1}`;
379
+ while (document.getElementById(id)) id += '-view';
380
+ poster.id = id;
381
+ }
382
+ fitButton.setAttribute('aria-controls', poster.id);
383
+ fitButton.setAttribute('data-poster-view-toggle', '');
384
+ const fit = addPosterFit(poster, fitButton, align);
385
+ fitButton.addEventListener('click', event => { event.stopPropagation(); if (!busy) fit.toggle(); });
386
+ actions.append(fitButton, button);
387
+ const error = document.createElement('p');
388
+ error.className = 'poster-download-error';
389
+ error.setAttribute('role', 'alert');
390
+ error.hidden = true;
391
+ tools.after(error);
392
+ // Download is part of the poster interaction, even though its control sits outside the image.
393
+ actions.addEventListener('pointerdown', event => {
394
+ event.stopPropagation();
395
+ });
396
+ actions.addEventListener('mousedown', event => {
397
+ // Pointer activation downloads without taking focus from the selected chart item.
398
+ event.preventDefault();
399
+ event.stopPropagation();
400
+ });
401
+ poster.addEventListener('focusout', event => {
402
+ if (actions.contains(event.relatedTarget) || actions.querySelector(':active')) event.stopImmediatePropagation();
403
+ }, true);
404
+ poster.addEventListener('pointerleave', event => {
405
+ if (actions.contains(event.relatedTarget) || actions.contains(document.activeElement)) event.stopImmediatePropagation();
406
+ }, true);
407
+ poster.addEventListener('pointermove', event => {
408
+ // Safari can move the pointer over the title while a keyboard-triggered download is being prepared.
409
+ if (actions.contains(document.activeElement) && !event.target.closest('.rhp-chart')) event.stopImmediatePropagation();
410
+ }, true);
411
+ for (const type of ['keydown', 'keyup']) actions.addEventListener(type, event => {
412
+ if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
413
+ });
414
+ let exporter, retries = 0, busy = false;
415
+ button.addEventListener('click', async event => {
416
+ event.stopPropagation();
417
+ if (busy) return;
418
+ busy = true;
419
+ button.setAttribute('aria-disabled', 'true');
420
+ button.setAttribute('aria-busy', 'true');
421
+ fitButton.setAttribute('aria-disabled', 'true');
422
+ button.setAttribute('aria-label', 'Preparing poster PNG');
423
+ error.hidden = true;
424
+ let url;
425
+ try {
426
+ exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
427
+ const { domToBlob } = await exporter;
428
+ await document.fonts.ready;
429
+ // Preserve author layout rules where computed styles lose the grid's used geometry.
430
+ // Safari misreports percentage insets in subgrids, and grid auto margins resolve to zero.
431
+ const autoMargins = new Set(), positioned = new Set();
432
+ const isMargin = name => name === 'margin' || name.startsWith('margin-');
433
+ const isInset = name => name === 'inset' || name.startsWith('inset-') || ['top', 'right', 'bottom', 'left'].includes(name);
434
+ const layoutRules = rules => [...rules].map(rule => {
435
+ if (rule.selectorText && rule.style) {
436
+ const margins = [...rule.style].filter(isMargin);
437
+ if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
438
+ const insets = [...rule.style].filter(isInset);
439
+ if (insets.length) positioned.add(rule.selectorText);
440
+ const layout = [...margins, ...insets];
441
+ return layout.length ? `${rule.selectorText} { ${layout.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
442
+ }
443
+ if (!rule.cssRules) return /^@layer\b/.test(rule.cssText) ? rule.cssText : '';
444
+ if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? layoutRules(rule.cssRules) : '';
445
+ if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? layoutRules(rule.cssRules) : '';
446
+ return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + layoutRules(rule.cssRules) + '}';
447
+ }).join('\n');
448
+ const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
449
+ const layout = sheets.map(sheet => {
450
+ try { return layoutRules(sheet.cssRules); } catch (cause) { return ''; }
451
+ }).join('\n');
452
+ // Omit display-none HTML subtrees, such as collapsed chart rows, from the image and its SVG payload.
453
+ const includeNode = node => !(node instanceof HTMLElement) || getComputedStyle(node).display !== 'none';
454
+ const restoreLayout = (source, clone) => {
455
+ if (clone.style && source !== poster) {
456
+ const margins = [...autoMargins].some(selector => source.matches(selector));
457
+ const computed = getComputedStyle(source);
458
+ const insets = computed.position === 'absolute' && [...positioned].some(selector => source.matches(selector));
459
+ const restore = name => (margins && isMargin(name)) || (insets && isInset(name));
460
+ [...clone.style].filter(restore).forEach(name => clone.style.removeProperty(name));
461
+ [...source.style].filter(restore).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
462
+ if (insets) {
463
+ // Only the scaled axis needs its percentage rule. Freeze the other axis in pixels:
464
+ // Firefox does not reevaluate container queries reliably inside an SVG foreignObject.
465
+ const axis = source.getAttribute('data-rhp-o');
466
+ const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
467
+ fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
468
+ }
469
+ }
470
+ const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName) && includeNode(node));
471
+ [...clone.children].forEach((child, i) => { if (children[i]) restoreLayout(children[i], child); });
472
+ };
473
+ // Cross-origin font stylesheets are readable through fetch, but not through cssRules.
474
+ const fontFiles = new Map();
475
+ const fontRules = await Promise.all(sheets.map(async sheet => {
476
+ let css;
477
+ try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
478
+ catch (cause) {
479
+ if (!sheet.href) return '';
480
+ const response = await fetch(sheet.href);
481
+ if (!response.ok) throw new Error('Font stylesheet could not be loaded');
482
+ css = await response.text();
483
+ }
484
+ const rules = css.match(/@font-face\s*\{[^}]*\}/g) || [];
485
+ return (await Promise.all(rules.map(async rule => {
486
+ for (const match of [...rule.matchAll(/url\(["']?([^"')]+)["']?\)/g)]) {
487
+ const source = new URL(match[1], sheet.href || document.baseURI).href;
488
+ if (!fontFiles.has(source)) fontFiles.set(source, (async () => {
489
+ const response = await fetch(source);
490
+ if (!response.ok) throw new Error('Font could not be loaded');
491
+ const blob = await response.blob();
492
+ return new Promise((resolve, reject) => {
493
+ const reader = new FileReader();
494
+ reader.onload = () => resolve(reader.result);
495
+ reader.onerror = reject;
496
+ reader.readAsDataURL(blob);
497
+ });
498
+ })());
499
+ rule = rule.replace(match[1], await fontFiles.get(source));
500
+ }
501
+ return rule;
502
+ }))).join('\n');
503
+ }));
504
+ // Rasterize SVG paper textures before nesting them inside the exporter's SVG.
505
+ // This keeps SVG filter fragment references local to their own image.
506
+ const textures = new Map();
507
+ const embedTexture = async (node) => {
508
+ if (!node.style) return;
509
+ let background = node.style.backgroundImage;
510
+ for (const [, source] of background.matchAll(/url\("(data:image\/svg\+xml[^"]+)"\)/g)) {
511
+ if (!textures.has(source)) textures.set(source, (async () => {
512
+ const image = new Image();
513
+ image.src = source;
514
+ await image.decode();
515
+ const canvas = document.createElement('canvas');
516
+ canvas.width = image.naturalWidth;
517
+ canvas.height = image.naturalHeight;
518
+ canvas.getContext('2d').drawImage(image, 0, 0);
519
+ return canvas.toDataURL();
520
+ })());
521
+ background = background.replace(source, await textures.get(source));
522
+ }
523
+ node.style.backgroundImage = background;
524
+ };
525
+ const blob = await domToBlob(poster, {
526
+ ...fit.dimensions(), scale: 2, style: { margin: '0' }, filter: includeNode,
527
+ font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
528
+ onCloneNode: clone => {
529
+ restoreLayout(poster, clone);
530
+ const style = document.createElement('style');
531
+ style.textContent = layout;
532
+ clone.prepend(style);
533
+ },
534
+ // CSS content's alternative text follows a slash and must not be painted.
535
+ onCreateForeignObjectSvg: svg => svg.querySelectorAll('style').forEach(style => {
536
+ style.textContent = style.textContent.replace(/(content:\s*')([^']*)(')/g, (_, start, text, end) => start + text.split(' / ')[0] + end);
537
+ }),
538
+ });
539
+ if (!blob) throw new Error('No PNG was created');
540
+ url = URL.createObjectURL(blob);
541
+ const link = document.createElement('a');
542
+ const title = poster.querySelector('.headline')?.textContent || document.title || 'poster';
543
+ link.download = (title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '').slice(0, 100) || 'poster') + '.png';
544
+ link.href = url;
545
+ link.addEventListener('click', event => event.stopPropagation(), { once: true });
546
+ document.body.append(link);
547
+ link.click();
548
+ link.remove();
549
+ } catch (cause) {
550
+ exporter = null;
551
+ retries++;
552
+ error.textContent = 'The PNG could not be saved. Check your connection and try again.';
553
+ error.hidden = false;
554
+ } finally {
555
+ if (url) setTimeout(() => URL.revokeObjectURL(url), 60000);
556
+ busy = false;
557
+ button.removeAttribute('aria-disabled');
558
+ button.removeAttribute('aria-busy');
559
+ fitButton.removeAttribute('aria-disabled');
560
+ button.setAttribute('aria-label', 'Download poster as PNG');
561
+ }
562
+ });
563
+ tools.append(actions);
564
+ });
565
+ }
566
+ // A chart module may await fonts or data after DOMContentLoaded has already fired.
567
+ if (document.querySelector('.poster')) addPosterDownloads();
568
+ else {
569
+ const observer = new MutationObserver(() => {
570
+ if (!document.querySelector('.poster')) return;
571
+ observer.disconnect();
572
+ addPosterDownloads();
573
+ });
574
+ observer.observe(document.getElementById('chart') || document.body, { childList: true, subtree: true });
575
+ }
576
+ </script>
577
+ </body>
578
+ </html>
@@ -42,8 +42,8 @@ Both `@bezda/rhp` and `@bezda/rhp/standalone` export rhp's whole API:
42
42
 
43
43
  - components: `Chart`, `Plot`, `Scale`, `Axis`, `Theme`, `Poster`
44
44
  - blocks: `Bar`, `Dot`, `Tick`, `Label`, `Cell`, `Place`, `Area`, `Line`
45
- - experimental point collection: [`ManyDots`](#manydots-experimental-point-collection), in builds of this repository that export it
46
- - functions: `slat`, `restyle`, `linkedCss`, `shape`, `at`, `useOrientation`, `series`, `cycle`, `sortBy`, `extent`, `every`, `nice`, `stackUp`, `shares`, `running`, `summary`, `bins`, `density`, `animated`, `curve`, `drawing`
45
+ - experimental point collection: [`ManyDots`](#manydots-experimental-point-collection), in rhp 2.0.3 and later
46
+ - functions: `slat`, `restyle`, `linkedCss`, `noCssGuard`, `inheritPosition`, `shape`, `at`, `useOrientation`, `series`, `cycle`, `sortBy`, `extent`, `every`, `nice`, `stackUp`, `shares`, `running`, `summary`, `bins`, `density`, `animated`, `curve`, `drawing`
47
47
  - the object `THEME` (the default theme)
48
48
 
49
49
  `@bezda/rhp/standalone` adds these from Solid, and nothing else: `render`, `html`, `createSignal`, `createMemo`, `createEffect`, `createRoot`, `createSelector`, `createComputed`, `on`, `onMount`, `onCleanup`, `batch`, `untrack`, `mergeProps`, `splitProps`, `For`, `Index`, `Show`, `Switch`, `Match`, `createStore`, `reconcile`, `produce`, `unwrap`.
@@ -169,6 +169,7 @@ export default function FruitChart() {
169
169
  | `animate` | `true` or `{ duration, ease, slide }` | off | the JS version of motion (section 12); the Plots inside take it |
170
170
  | `theme` | theme object (section 8) | `THEME` | colors and font, over those of a `<Theme>` around it |
171
171
  | `static` | boolean | `false` | for data that never changes: slats are drawn once and keep no signals; a data change draws them all again, without motion |
172
+ | `noCssGuard` | boolean | `false` | faster, and page CSS reaches the chart (performance.md) |
172
173
  | `cross` | `[min, max]` | none | a second axis across the band (scatter plots, line charts; section 7) |
173
174
  | `crossTicks`, `crossFormat` | like `ticks`, `format` | about 5, the number | the second axis |
174
175
  | `crossGrid` | boolean | `true` | `false` hides only the second axis' grid lines, keeping its numbers. |
@@ -661,10 +662,10 @@ render(ChangeChart, document.getElementById("chart"));
661
662
  ### ManyDots (experimental point collection)
662
663
 
663
664
  `ManyDots` draws a bulk collection of points directly inside a `Chart` with `scale` and `cross`.
664
- It is part of the upcoming npm 2.0.3 release.
665
+ It is in rhp 2.0.3 and later, from npm and from the `@2` CDN URL.
665
666
  It is a collection rather than a block in a slat, so do not wrap it in a `Plot` or supply a slat function.
666
- Use a repository build that exports `ManyDots`; the version-2 CDN URL in the page skeleton does not guarantee this experimental API is available.
667
- Both package entry points export it in that build, and Solid users can import `ManyDotsProps<T>` and `ManyDotsValue<T, V>` for its types.
667
+ It is experimental: its API may still change.
668
+ Both package entry points export it, and Solid users can import `ManyDotsProps<T>` and `ManyDotsValue<T, V>` for its types.
668
669
 
669
670
  Choose it explicitly for bulk HTML points, or keep `Dot` for individual blocks, rich point content, and Plot behavior at any point count.
670
671
  Neither component switches into the other automatically, including at 500 points.
@@ -1219,7 +1220,7 @@ Even where page CSS reaches rhp's custom properties, set them in the slat's `css
1219
1220
  | `--rhp-slide-time`, `--rhp-slide-ease` | `.3s`, `ease-in-out` | how slats slide to new places |
1220
1221
  | `--rhp-gutter-max` | `40cqw` | with `room: "auto"`, the widest a name (and the room) can be |
1221
1222
 
1222
- **Variables a slat's CSS reads**: the theme (`--rhp-ink`, `--rhp-muted`, `--rhp-grid`, `--rhp-surface`, `--rhp-series-1`…, `--rhp-positive`, `--rhp-negative`, `--rhp-low`, `--rhp-high`, `--rhp-font`), `--rhp-color` (the block's color), `--rhp-toward-end` (the direction from a Bar's start to its end: `linear-gradient(var(--rhp-toward-end), …)`), `--rhp-p` (where a Dot, Tick, Label or Place sits on the scale, 0 to 1: a Label's room to the scale's end is `calc((1 - var(--rhp-p)) * 100%)`), `--rhp-lo` and `--rhp-hi` (where a Bar, an Area or a Line starts and ends on the scale, 0 to 1, clipped to it: the donut and radial-bars recipes turn a Bar's into angles), `--rhp-min`, `--rhp-max`, a block's `--rhp-from`, `--rhp-to`, `--rhp-at`, `--rhp-value`, a slat's `--rhp-position`, and `--rhp-n` (how many places the slat's Plot has on screen).
1223
+ **Variables a slat's CSS reads**: the theme (`--rhp-ink`, `--rhp-muted`, `--rhp-grid`, `--rhp-surface`, `--rhp-series-1`…, `--rhp-positive`, `--rhp-negative`, `--rhp-low`, `--rhp-high`, `--rhp-font`), `--rhp-color` (the block's color), `--rhp-toward-end` (the direction from a Bar's start to its end: `linear-gradient(var(--rhp-toward-end), …)`), `--rhp-p` (where a Dot, Tick, Label or Place sits on the scale, 0 to 1: a Label's room to the scale's end is `calc((1 - var(--rhp-p)) * 100%)`), `--rhp-lo` and `--rhp-hi` (where a Bar, an Area or a Line starts and ends on the scale, 0 to 1, clipped to it: the donut and radial-bars recipes turn a Bar's into angles), `--rhp-min`, `--rhp-max`, a block's `--rhp-from`, `--rhp-to`, `--rhp-at`, `--rhp-value`, a slat's `--rhp-position` (its place in its Plot, 0 for the first), and `--rhp-n` (how many places the slat's Plot has on screen).
1223
1224
 
1224
1225
  **Styling the axis.** Page CSS and slat CSS cannot reach rhp's grid lines and numbers.
1225
1226
  Their colors are the theme's `grid` and `muted`, their font the theme's `font`; `ticks` and `format` choose them (`format` may return an element with a class, which page CSS styles).
@@ -1601,8 +1602,8 @@ Every one of these was found by running rhp 2.0.1.
1601
1602
  4. **Use style objects on Chart, Plot and a slat's root.**
1602
1603
  `style="pointer-events: none"` on a Plot or Chart is dropped without a message; write `style=${{ "pointer-events": "none" }}`.
1603
1604
  On a slat's root, a string that changes or a style that becomes `undefined` replaces the whole style attribute and erases the stack position, so keep it an object.
1604
- Published rhp 2.0.2 has the same problem on Blocks: a Bar can disappear and SVG geometry can be lost, so use `style=${() => ({ "--n": d.n })}` there too.
1605
- Current source fixes the Block replacement bug for the upcoming 2.0.3 release; Blocks then retain their geometry when strings change or style is removed.
1605
+ rhp 2.0.2 has the same problem on Blocks: a Bar can disappear and SVG geometry can be lost, so use `style=${() => ({ "--n": d.n })}` there too.
1606
+ rhp 2.0.3 fixed it: Blocks keep their geometry when strings change or style is removed.
1606
1607
  Plain children that hold no rhp geometry can use changing strings.
1607
1608
  5. **`className` breaks a block.**
1608
1609
  It replaces rhp's class, so the block is no longer drawn.
@@ -1682,3 +1683,4 @@ Every one of these was found by running rhp 2.0.1.
1682
1683
  Write the space as `${" "}`, or keep it next to other text (`<b>Name</b>: <em>12</em>`).
1683
1684
  36. **A Plot sits at `z-index: 1`.**
1684
1685
  A page element that overlaps a chart (a readout pinned with `position: sticky`, a menu that opens over the chart) is drawn under the slats unless its own `z-index` is 2 or more.
1686
+ 37. **`var(--rhp-position)` in a `style` prop inside a slat is empty** (rhp 2.1 and later): read it in the slat's `css`.