@bezda/rhp-mcp 0.1.6 → 0.1.7
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.
- package/package.json +1 -1
- package/skill/SKILL.md +108 -18
- package/skill/recipes/tape.html +578 -0
- package/skill/references/api.md +6 -6
- package/skill/references/design.md +18 -0
- package/skill/references/forms.md +3 -2
- package/skill/references/interaction.md +8 -4
- package/skill/references/pitfalls.md +3 -2
- package/skill/scripts/add-download.mjs +33 -0
- package/src/check/README.md +18 -2
- package/src/check/findings.js +92 -3
- package/src/check/index.js +158 -17
- package/src/check/inpage.js +276 -1
- package/src/check/interact.js +12 -0
- package/src/check/report.js +9 -2
- package/src/check/selection.js +16 -1
- package/src/server.js +4 -4
|
@@ -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>
|
package/skill/references/api.md
CHANGED
|
@@ -42,7 +42,7 @@ 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
|
|
45
|
+
- experimental point collection: [`ManyDots`](#manydots-experimental-point-collection), in rhp 2.0.3 and later
|
|
46
46
|
- functions: `slat`, `restyle`, `linkedCss`, `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
|
|
|
@@ -661,10 +661,10 @@ render(ChangeChart, document.getElementById("chart"));
|
|
|
661
661
|
### ManyDots (experimental point collection)
|
|
662
662
|
|
|
663
663
|
`ManyDots` draws a bulk collection of points directly inside a `Chart` with `scale` and `cross`.
|
|
664
|
-
It is
|
|
664
|
+
It is in rhp 2.0.3 and later, from npm and from the `@2` CDN URL.
|
|
665
665
|
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
|
-
|
|
667
|
-
Both package entry points export it
|
|
666
|
+
It is experimental: its API may still change.
|
|
667
|
+
Both package entry points export it, and Solid users can import `ManyDotsProps<T>` and `ManyDotsValue<T, V>` for its types.
|
|
668
668
|
|
|
669
669
|
Choose it explicitly for bulk HTML points, or keep `Dot` for individual blocks, rich point content, and Plot behavior at any point count.
|
|
670
670
|
Neither component switches into the other automatically, including at 500 points.
|
|
@@ -1601,8 +1601,8 @@ Every one of these was found by running rhp 2.0.1.
|
|
|
1601
1601
|
4. **Use style objects on Chart, Plot and a slat's root.**
|
|
1602
1602
|
`style="pointer-events: none"` on a Plot or Chart is dropped without a message; write `style=${{ "pointer-events": "none" }}`.
|
|
1603
1603
|
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
|
-
|
|
1605
|
-
|
|
1604
|
+
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
|
+
rhp 2.0.3 fixed it: Blocks keep their geometry when strings change or style is removed.
|
|
1606
1606
|
Plain children that hold no rhp geometry can use changing strings.
|
|
1607
1607
|
5. **`className` breaks a block.**
|
|
1608
1608
|
It replaces rhp's class, so the block is no longer drawn.
|
|
@@ -537,6 +537,24 @@ At 390px:
|
|
|
537
537
|
Use an ellipsis only when the full name is in the slat's `aria-label` and in the readout.
|
|
538
538
|
- `room: { start: "auto" }` sizes the names' room to the longest name, up to `--rhp-gutter-max` (40% of the chart).
|
|
539
539
|
|
|
540
|
+
### Fit the label gutters
|
|
541
|
+
|
|
542
|
+
For a poster row containing one main plot, usually fit whichever side gutters contain labels to the text that actually occupies them, so the plot uses the remaining width.
|
|
543
|
+
This helps smaller screens and keeps the outer labels aligned with the caption, controls, readout and footer throughout the poster.
|
|
544
|
+
Treat this as a design preference and let the intended composition take priority, including deliberate spacing or different alignments.
|
|
545
|
+
The poster's own padding supplies the outer margins; do not add a second empty buffer past the labels unless the design calls for one.
|
|
546
|
+
|
|
547
|
+
- Prefer `room: { start: "auto", end: "auto" }` when the labels are direct edge Labels, or use "auto" only on the side that needs it.
|
|
548
|
+
- Labels anchored with `at` are not measured by "auto".
|
|
549
|
+
Measure their rendered sizes and pass reactive pixel values to the appropriate `room` sides instead of choosing fixed desktop and phone guesses.
|
|
550
|
+
- Measure after fonts load and when text, font size, wrapping or stacking changes, including gradual changes between breakpoints.
|
|
551
|
+
Keep each label's box tight to its content so an unnecessarily wide wrapper does not become empty gutter space.
|
|
552
|
+
- Include the clearance between a point and its label, but account for a point sitting inside the scale's end so that inset is not reserved twice.
|
|
553
|
+
The outermost text should reach the poster's content edge without crossing it.
|
|
554
|
+
- Use natural layout sizes, such as `ResizeObserver` border-box sizes, so fitting or zooming the poster does not alter its gutters.
|
|
555
|
+
Schedule size updates in an animation frame outside ResizeObserver delivery to avoid resize loops.
|
|
556
|
+
- Check both label layouts and the widths between breakpoints, then fit the poster and verify that its labels, marks and PNG export keep the same alignment.
|
|
557
|
+
|
|
540
558
|
How many slats:
|
|
541
559
|
|
|
542
560
|
- Horizontal slats of 28 to 50px; up to about 15 read at a glance, about 30 at 22px each.
|