@bezda/rhp-mcp 0.1.7 → 0.1.9
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 +2 -2
- package/skill/SKILL.md +1 -1
- package/skill/recipes/area.html +349 -138
- package/skill/recipes/bar.html +349 -138
- package/skill/recipes/box-plot.html +349 -138
- package/skill/recipes/bubble.html +349 -138
- package/skill/recipes/bullet.html +349 -138
- package/skill/recipes/candlestick.html +349 -138
- package/skill/recipes/column.html +349 -138
- package/skill/recipes/dial.html +349 -138
- package/skill/recipes/diverging-bars.html +349 -138
- package/skill/recipes/donut.html +349 -138
- package/skill/recipes/dumbbell.html +349 -138
- package/skill/recipes/gantt.html +349 -138
- package/skill/recipes/grouped-bars.html +349 -138
- package/skill/recipes/heatmap.html +349 -138
- package/skill/recipes/histogram.html +349 -138
- package/skill/recipes/line.html +349 -138
- package/skill/recipes/live.html +349 -138
- package/skill/recipes/log-line.html +349 -138
- package/skill/recipes/lollipop.html +349 -138
- package/skill/recipes/multi-line.html +349 -138
- package/skill/recipes/pyramid.html +349 -138
- package/skill/recipes/race.html +363 -139
- package/skill/recipes/radial-bars.html +349 -138
- package/skill/recipes/scatter.html +349 -138
- package/skill/recipes/silhouettes.html +349 -138
- package/skill/recipes/slope.html +349 -138
- package/skill/recipes/sparklines.html +349 -138
- package/skill/recipes/stacked-100.html +349 -138
- package/skill/recipes/stacked-bars.html +349 -138
- package/skill/recipes/strip.html +349 -138
- package/skill/recipes/tape.html +415 -165
- package/skill/recipes/unit-stack.html +349 -138
- package/skill/recipes/violin.html +349 -138
- package/skill/recipes/waffle.html +349 -138
- package/skill/recipes/waterfall.html +349 -138
- package/skill/references/api.md +4 -2
- package/skill/references/design.md +13 -0
- package/skill/references/download.md +372 -140
- package/skill/references/environments.md +1 -1
- package/skill/references/interaction.md +2 -0
- package/skill/references/performance.md +45 -0
- package/skill/references/pitfalls.md +9 -0
- package/src/check/README.md +2 -1
- package/src/check/findings.js +17 -1
- package/src/check/inpage.js +53 -5
- package/src/check/report.js +1 -1
- package/src/check/source.js +2 -1
- package/src/guard/rules.js +2 -2
package/skill/recipes/tape.html
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
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
8
|
<!--
|
|
9
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.
|
|
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; section headings that toggle all their parts; one group lit at rest; a big-number readout.
|
|
11
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
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
13
|
-->
|
|
@@ -43,10 +43,11 @@
|
|
|
43
43
|
|
|
44
44
|
/* The readout: the stretch picked, in big printed numerals; it keeps its height so nothing below moves */
|
|
45
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; }
|
|
46
|
+
.readout .big { flex: none; width: 3.1em; font: 700 clamp(48px, 9vw, 76px)/1 var(--display); font-variant-numeric: tabular-nums; color: var(--accent); white-space: nowrap; }
|
|
47
47
|
.readout .big span { margin: 0 .12em 0 .05em; font-size: .46em; font-weight: 600; }
|
|
48
48
|
.readout .what { min-width: 0; font-size: 16px; line-height: 1.3; color: var(--soft); }
|
|
49
49
|
.readout .what strong { display: block; font: 700 clamp(22px, 3.4vw, 28px)/1.1 var(--display); color: var(--ink); }
|
|
50
|
+
.readout .detail { display: block; }
|
|
50
51
|
|
|
51
52
|
/* The tapes: three lengths of 8 hours, one above the other */
|
|
52
53
|
.tapes { grid-area: tapes; display: grid; gap: 14px; min-width: 0; }
|
|
@@ -64,12 +65,17 @@
|
|
|
64
65
|
.key button b { font: 700 16px/1.2 var(--display); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
65
66
|
.key button.on { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 2px; }
|
|
66
67
|
.key button.on i { background: var(--accent); box-shadow: none; }
|
|
68
|
+
.key h3 button { display: flex; justify-content: space-between; align-items: normal; gap: 10px; padding: 0;
|
|
69
|
+
color: inherit; font: inherit; letter-spacing: inherit; text-transform: inherit; }
|
|
67
70
|
|
|
68
71
|
@media (min-width: 860px) {
|
|
69
72
|
.poster { grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); column-gap: 40px;
|
|
70
73
|
grid-template-areas: "kicker kicker" "headline headline" "dek readout" "tapes tapes" "key key" "note note"; }
|
|
71
74
|
.readout { margin: 0; }
|
|
72
75
|
}
|
|
76
|
+
@media (min-width: 860px) and (max-width: 1000px) {
|
|
77
|
+
.readout .what strong { min-height: 2lh; }
|
|
78
|
+
}
|
|
73
79
|
@media (max-width: 640px) {
|
|
74
80
|
body { padding: 16px 16px 32px; }
|
|
75
81
|
.poster { padding: 24px 16px 16px; }
|
|
@@ -77,6 +83,10 @@
|
|
|
77
83
|
.readout { gap: 12px; min-height: 128px; }
|
|
78
84
|
.readout .what { font-size: 14px; }
|
|
79
85
|
}
|
|
86
|
+
@media (max-width: 390px) {
|
|
87
|
+
.readout .what strong { min-height: 2lh; }
|
|
88
|
+
.readout .detail { min-height: 4lh; }
|
|
89
|
+
}
|
|
80
90
|
</style>
|
|
81
91
|
</head>
|
|
82
92
|
<body>
|
|
@@ -153,24 +163,22 @@ const Piece = slat({
|
|
|
153
163
|
thickness: TAPE,
|
|
154
164
|
room: { start: PAD, end: PAD },
|
|
155
165
|
css: `
|
|
156
|
-
.seg { pointer-events: none; }
|
|
157
166
|
.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:
|
|
167
|
+
padding: 0 8px 7px; overflow: hidden; cursor: pointer; color: var(--rhp-ink);
|
|
159
168
|
box-shadow: inset 0 3px 0 var(--rhp-ink), inset 0 -3px 0 var(--rhp-ink), inset -3px 0 0 var(--rhp-ink); }
|
|
160
|
-
.
|
|
161
|
-
.on
|
|
169
|
+
.bar.first { 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); }
|
|
170
|
+
.bar.on { color: var(--rhp-surface); }
|
|
162
171
|
.nm { font: 700 19px/1 "Barlow Condensed", Arial, sans-serif; white-space: nowrap; }
|
|
163
172
|
.tm { font: 600 16px/1 "Barlow Condensed", Arial, sans-serif; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
164
|
-
.
|
|
165
|
-
.seg:focus-visible .bar { outline: 3px solid var(--rhp-ink); outline-offset: 2px; }
|
|
173
|
+
.bar:focus-visible { outline: 3px solid var(--rhp-ink); outline-offset: 2px; }
|
|
166
174
|
@media (max-width: 640px) { .nm { font-size: 17px; } .tm { font-size: 14px; } }
|
|
167
175
|
`,
|
|
168
176
|
}, (d) => html`
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
177
|
+
<${Bar} from=${() => d.from} to=${() => d.to} color=${() => (d.on ? "series-1" : "series-2")} thick=${1}
|
|
178
|
+
class=${() => `seg bar${d.on ? " on" : ""}${d.from === 0 ? " first" : ""}`}
|
|
179
|
+
data-act=${() => d.act} role="button" aria-pressed=${() => d.on} aria-label=${() => say(d.act)}>
|
|
180
|
+
<span class="nm">${() => nameOf(d)}</span><span class="tm">${() => timeOf(d)}</span>
|
|
181
|
+
<//>`);
|
|
174
182
|
|
|
175
183
|
// The tape's printing: a hairline at every hour across the tape (so the hours can be counted), notches at the quarter
|
|
176
184
|
// hours, and a numeral above each hour
|
|
@@ -195,32 +203,59 @@ const Hour = slat({
|
|
|
195
203
|
</div>`;
|
|
196
204
|
});
|
|
197
205
|
|
|
198
|
-
// Chart component:
|
|
206
|
+
// Chart component: section toggles and individual picks share one selection; hover only previews
|
|
199
207
|
function DayTape() {
|
|
208
|
+
const members = GROUPS.map((_, g) => ACTS.flatMap((a, i) => a.group === g ? [i] : []));
|
|
209
|
+
const [selected, setSelected] = createSignal(members[LEAD]);
|
|
200
210
|
const [on, setOn] = createSignal(null);
|
|
201
|
-
const lit = (i) => (
|
|
202
|
-
const shown = () =>
|
|
211
|
+
const lit = (i) => selected().includes(i);
|
|
212
|
+
const shown = () => ACTS[on() ?? (selected().length === 1 ? selected()[0] : -1)];
|
|
213
|
+
const allSelected = (g) => members[g].every((i) => selected().includes(i));
|
|
214
|
+
const groupState = (g) => allSelected(g) ? "true" : members[g].some((i) => selected().includes(i)) ? "mixed" : "false";
|
|
215
|
+
const selectedGroup = () => {
|
|
216
|
+
const group = ACTS[selected()[0]]?.group;
|
|
217
|
+
return group != null && selected().every((i) => ACTS[i].group === group) ? group : null;
|
|
218
|
+
};
|
|
219
|
+
const selectedHours = () => selected().reduce((sum, i) => sum + ACTS[i].hours, 0);
|
|
203
220
|
|
|
204
221
|
// The activity of the element under the pointer or focus, or null
|
|
205
222
|
const actOf = (el) => {
|
|
206
223
|
const hit = el.closest?.("[data-act]");
|
|
207
224
|
return hit ? +hit.dataset.act : null;
|
|
208
225
|
};
|
|
209
|
-
//
|
|
226
|
+
// Hover and keyboard focus inspect an activity without changing the selection
|
|
210
227
|
const pick = (e) => {
|
|
211
228
|
const act = actOf(e.target);
|
|
212
229
|
if (act != null) setOn(act);
|
|
230
|
+
else if (e.target.closest("[data-group]")) setOn(null);
|
|
213
231
|
};
|
|
214
232
|
const tap = (e) => {
|
|
233
|
+
const heading = e.target.closest("[data-group]");
|
|
234
|
+
if (heading) {
|
|
235
|
+
const group = +heading.dataset.group;
|
|
236
|
+
heading.focus({ preventScroll: true });
|
|
237
|
+
const clear = allSelected(group);
|
|
238
|
+
setSelected((now) => clear ? now.filter((i) => ACTS[i].group !== group)
|
|
239
|
+
: ACTS.flatMap((a, i) => now.includes(i) || a.group === group ? [i] : []));
|
|
240
|
+
setOn(null);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
215
243
|
const act = actOf(e.target);
|
|
216
244
|
if (act == null) return;
|
|
217
|
-
|
|
218
|
-
e.
|
|
245
|
+
setSelected((now) => now.includes(act) ? now.filter((i) => i !== act) : [...now, act].sort((a, b) => a - b));
|
|
246
|
+
e.target.closest("[data-act]")?.focus({ preventScroll: true });
|
|
247
|
+
setOn(null);
|
|
219
248
|
};
|
|
220
|
-
|
|
249
|
+
const key = (e) => {
|
|
250
|
+
if (!e.target.closest(".seg") || (e.key !== "Enter" && e.key !== " ")) return;
|
|
251
|
+
e.preventDefault();
|
|
252
|
+
if (!e.repeat) tap(e);
|
|
253
|
+
};
|
|
254
|
+
// Leaving the poster: back to the keyboard's preview, or the committed selection
|
|
221
255
|
const leave = (e) => {
|
|
222
256
|
if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
|
|
223
|
-
|
|
257
|
+
const focused = document.activeElement;
|
|
258
|
+
setOn(focused && e.currentTarget.contains(focused) && focused.matches(":focus-visible") ? actOf(focused) : null);
|
|
224
259
|
};
|
|
225
260
|
const move = (e) => e.pointerType !== "touch" && (e.target.closest(".tapes, .key") ? pick(e) : leave(e));
|
|
226
261
|
|
|
@@ -243,13 +278,17 @@ function DayTape() {
|
|
|
243
278
|
title=${HEADLINE}
|
|
244
279
|
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
280
|
note=${NOTE}
|
|
246
|
-
onPointerMove=${move} onClick=${tap} onFocusIn=${pick}
|
|
281
|
+
onPointerMove=${move} onClick=${tap} onFocusIn=${pick} onKeyDown=${key}
|
|
247
282
|
onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setOn(null)}>
|
|
248
283
|
<div class="readout" aria-live="polite">
|
|
249
|
-
<b class="big">${() => big(shown() ? shown().hours :
|
|
284
|
+
<b class="big">${() => big(shown() ? shown().hours : selectedHours())}</b>
|
|
250
285
|
<span class="what">
|
|
251
|
-
<strong>${() =>
|
|
252
|
-
|
|
286
|
+
<strong>${() => shown() ? shown().name : selectedGroup() != null ? GROUPS[selectedGroup()] : selected().length ? "Selected activities" : "No activities selected"}</strong>
|
|
287
|
+
<span class="detail">${() => shown() ? `${share(shown().hours)}, part of ${GROUPS[shown().group].toLowerCase()}` : selected().length
|
|
288
|
+
? selectedGroup() === LEAD && allSelected(LEAD) ? `${share(selectedHours())}, all four kinds of it together`
|
|
289
|
+
: selectedGroup() != null ? `${share(selectedHours())}, ${selected().length} of ${members[selectedGroup()].length} activities selected`
|
|
290
|
+
: `${share(selectedHours())}, ${selected().length} activities selected`
|
|
291
|
+
: "Choose a section heading or an activity."}</span>
|
|
253
292
|
</span>
|
|
254
293
|
</div>
|
|
255
294
|
<div class="tapes">
|
|
@@ -263,10 +302,10 @@ function DayTape() {
|
|
|
263
302
|
<div class="key" onPointerLeave=${leave}>
|
|
264
303
|
${GROUPS.map((g, gi) => html`
|
|
265
304
|
<section>
|
|
266
|
-
<h3>${g}<span>${short(groupHours[gi])}</span></h3>
|
|
305
|
+
<h3><button type="button" data-group=${gi} aria-pressed=${() => groupState(gi)}>${g}<span>${short(groupHours[gi])}</span></button></h3>
|
|
267
306
|
<ul>
|
|
268
307
|
${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))}
|
|
308
|
+
<li><button type="button" data-act=${i} aria-pressed=${() => lit(i)} class=${() => (lit(i) ? "on" : "")}><i></i><span>${x.name}</span><b>${short(x.hours)}</b></button></li>` : null))}
|
|
270
309
|
</ul>
|
|
271
310
|
</section>`)}
|
|
272
311
|
</div>
|
|
@@ -277,7 +316,7 @@ render(() => html`<${DayTape} />`, document.getElementById("chart"));
|
|
|
277
316
|
</script>
|
|
278
317
|
<style id="poster-download-styles">
|
|
279
318
|
.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; }
|
|
319
|
+
.poster-actions { position: relative; display: flex; align-items: center; margin-left: auto; }
|
|
281
320
|
.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
321
|
.poster-actions svg { display: block; width: 20px; height: 20px; }
|
|
283
322
|
.poster-actions button:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
|
|
@@ -288,7 +327,14 @@ render(() => html`<${DayTape} />`, document.getElementById("chart"));
|
|
|
288
327
|
.poster-fit-frame.is-fitted { display: block; margin: 0 auto; overflow: clip; }
|
|
289
328
|
.poster-fit-frame.is-fitted > .poster-fit-content { display: block; transform-origin: top left; }
|
|
290
329
|
.poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
|
|
291
|
-
|
|
330
|
+
.poster-download-progress { font: 600 11px/1 system-ui, sans-serif; font-variant-numeric: tabular-nums; }
|
|
331
|
+
.poster-download-menu { position: absolute; top: calc(100% + 4px); right: 0; z-index: 20; display: grid; gap: 2px; min-width: 220px; padding: 6px; border: 1px solid color-mix(in srgb, currentColor 24%, transparent); border-radius: 8px; background: var(--poster-tools-ground, Canvas); box-shadow: 0 8px 24px rgb(0 0 0 / .28); }
|
|
332
|
+
.poster-download-menu[hidden] { display: none; }
|
|
333
|
+
.poster-download-menu button { display: grid; gap: 2px; width: 100%; padding: 8px 10px; border: 0; border-radius: 5px; background: none; color: inherit; font: 600 14px/1.3 system-ui, sans-serif; text-align: left; cursor: pointer; }
|
|
334
|
+
.poster-download-menu button span { font-weight: 400; font-size: 12.5px; opacity: .8; }
|
|
335
|
+
.poster-download-menu button:hover { background: color-mix(in srgb, currentColor 9%, transparent); }
|
|
336
|
+
.poster-download-menu button:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
|
|
337
|
+
@media print { .poster-download-tools, .poster-actions, .poster-download-menu, .poster-download-error { display: none !important; } }
|
|
292
338
|
</style>
|
|
293
339
|
<script type="module" id="poster-download-script">
|
|
294
340
|
function addPosterFit(poster, button, changed) {
|
|
@@ -366,6 +412,8 @@ function addPosterDownloads() {
|
|
|
366
412
|
tools.style.color = light[0] * .2126 + light[1] * .7152 + light[2] * .0722 < .179 ? '#eef2f5' : '#1d1b17';
|
|
367
413
|
}
|
|
368
414
|
}
|
|
415
|
+
const ground = getComputedStyle(document.body).backgroundColor;
|
|
416
|
+
if (!/^(transparent|rgba\(0, 0, 0, 0\))$/.test(ground)) tools.style.setProperty('--poster-tools-ground', ground);
|
|
369
417
|
const button = document.createElement('button');
|
|
370
418
|
button.type = 'button';
|
|
371
419
|
button.className = 'poster-download';
|
|
@@ -411,154 +459,356 @@ function addPosterDownloads() {
|
|
|
411
459
|
for (const type of ['keydown', 'keyup']) actions.addEventListener(type, event => {
|
|
412
460
|
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
|
|
413
461
|
});
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
462
|
+
// A poster that plays through its data gives itself a playback ({ duration, position, seek }): its download then
|
|
463
|
+
// offers the poster as it is now, a PNG, or the whole playback, an animated GIF
|
|
464
|
+
let exporter, encoder, retries = 0, busy = false, making = null, cancelled = false;
|
|
465
|
+
const playable = () => typeof poster.playback?.seek === 'function';
|
|
466
|
+
const icon = button.innerHTML;
|
|
467
|
+
const menu = document.createElement('div');
|
|
468
|
+
menu.className = 'poster-download-menu';
|
|
469
|
+
menu.setAttribute('role', 'menu');
|
|
470
|
+
menu.setAttribute('aria-label', 'Download poster');
|
|
471
|
+
menu.hidden = true;
|
|
472
|
+
menu.innerHTML = '<button type="button" role="menuitem" data-format="png">PNG image<span>The poster as it is now</span></button>'
|
|
473
|
+
+ '<button type="button" role="menuitem" data-format="gif">Animated GIF<span>The whole playback</span></button>';
|
|
474
|
+
const items = [...menu.querySelectorAll('[role="menuitem"]')];
|
|
475
|
+
actions.append(menu);
|
|
476
|
+
const label = text => { button.setAttribute('aria-label', text); button.title = text; };
|
|
477
|
+
const rest = () => {
|
|
478
|
+
label(playable() ? 'Download poster' : 'Download poster as PNG');
|
|
479
|
+
if (playable()) { button.setAttribute('aria-haspopup', 'menu'); button.setAttribute('aria-expanded', String(!menu.hidden)); }
|
|
480
|
+
};
|
|
481
|
+
rest();
|
|
482
|
+
const closeMenu = focus => {
|
|
483
|
+
if (menu.hidden) return;
|
|
484
|
+
menu.hidden = true;
|
|
485
|
+
rest();
|
|
486
|
+
if (focus) button.focus();
|
|
487
|
+
};
|
|
488
|
+
const openMenu = focus => {
|
|
489
|
+
menu.hidden = false;
|
|
490
|
+
rest();
|
|
491
|
+
if (focus) items[0].focus();
|
|
492
|
+
};
|
|
493
|
+
document.addEventListener('pointerdown', event => { if (!actions.contains(event.target)) closeMenu(false); }, true);
|
|
494
|
+
menu.addEventListener('keydown', event => {
|
|
495
|
+
const at = items.indexOf(document.activeElement);
|
|
496
|
+
const to = { ArrowDown: at + 1, ArrowUp: at - 1, Home: 0, End: items.length - 1 }[event.key];
|
|
497
|
+
if (to !== undefined) { event.preventDefault(); items[(to + items.length) % items.length].focus(); }
|
|
498
|
+
else if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeMenu(true); }
|
|
499
|
+
else if (event.key === 'Tab') closeMenu(false);
|
|
500
|
+
});
|
|
501
|
+
menu.addEventListener('focusout', event => { if (!menu.contains(event.relatedTarget) && event.relatedTarget !== button) closeMenu(false); });
|
|
502
|
+
// The capture options, made once for a download and for every frame of a GIF
|
|
503
|
+
const loadExporter = () => (exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : '')));
|
|
504
|
+
async function captureOptions() {
|
|
505
|
+
await document.fonts.ready;
|
|
506
|
+
// Preserve author layout rules where computed styles lose the grid's used geometry.
|
|
507
|
+
// Safari misreports percentage insets in subgrids, and grid auto margins resolve to zero.
|
|
508
|
+
const autoMargins = new Set(), positioned = new Set();
|
|
509
|
+
const isMargin = name => name === 'margin' || name.startsWith('margin-');
|
|
510
|
+
const isInset = name => name === 'inset' || name.startsWith('inset-') || ['top', 'right', 'bottom', 'left'].includes(name);
|
|
511
|
+
const layoutRules = rules => [...rules].map(rule => {
|
|
512
|
+
if (rule.selectorText && rule.style) {
|
|
513
|
+
const margins = [...rule.style].filter(isMargin);
|
|
514
|
+
if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
|
|
515
|
+
const insets = [...rule.style].filter(isInset);
|
|
516
|
+
if (insets.length) positioned.add(rule.selectorText);
|
|
517
|
+
const layout = [...margins, ...insets];
|
|
518
|
+
return layout.length ? `${rule.selectorText} { ${layout.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
|
|
519
|
+
}
|
|
520
|
+
if (!rule.cssRules) return /^@layer\b/.test(rule.cssText) ? rule.cssText : '';
|
|
521
|
+
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? layoutRules(rule.cssRules) : '';
|
|
522
|
+
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? layoutRules(rule.cssRules) : '';
|
|
523
|
+
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + layoutRules(rule.cssRules) + '}';
|
|
524
|
+
}).join('\n');
|
|
525
|
+
const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
|
|
526
|
+
const layout = sheets.map(sheet => {
|
|
527
|
+
try { return layoutRules(sheet.cssRules); } catch (cause) { return ''; }
|
|
528
|
+
}).join('\n');
|
|
529
|
+
// Omit display-none HTML subtrees, such as collapsed chart rows, from the image and its SVG payload.
|
|
530
|
+
const includeNode = node => !(node instanceof HTMLElement) || getComputedStyle(node).display !== 'none';
|
|
531
|
+
const restoreLayout = (source, clone) => {
|
|
532
|
+
if (clone.style && source !== poster) {
|
|
533
|
+
const margins = [...autoMargins].some(selector => source.matches(selector));
|
|
534
|
+
const computed = getComputedStyle(source);
|
|
535
|
+
const insets = computed.position === 'absolute' && [...positioned].some(selector => source.matches(selector));
|
|
536
|
+
const restore = name => (margins && isMargin(name)) || (insets && isInset(name));
|
|
537
|
+
[...clone.style].filter(restore).forEach(name => clone.style.removeProperty(name));
|
|
538
|
+
[...source.style].filter(restore).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
|
|
539
|
+
if (insets) {
|
|
540
|
+
// Only the scaled axis needs its percentage rule. Freeze the other axis in pixels:
|
|
541
|
+
// Firefox does not reevaluate container queries reliably inside an SVG foreignObject.
|
|
542
|
+
const axis = source.getAttribute('data-rhp-o');
|
|
543
|
+
const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
|
|
544
|
+
fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName) && includeNode(node));
|
|
548
|
+
[...clone.children].forEach((child, i) => { if (children[i]) restoreLayout(children[i], child); });
|
|
549
|
+
};
|
|
550
|
+
// Cross-origin font stylesheets are readable through fetch, but not through cssRules.
|
|
551
|
+
const fontFiles = new Map();
|
|
552
|
+
const fontRules = await Promise.all(sheets.map(async sheet => {
|
|
553
|
+
let css;
|
|
554
|
+
try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
|
|
555
|
+
catch (cause) {
|
|
556
|
+
if (!sheet.href) return '';
|
|
557
|
+
const response = await fetch(sheet.href);
|
|
558
|
+
if (!response.ok) throw new Error('Font stylesheet could not be loaded');
|
|
559
|
+
css = await response.text();
|
|
560
|
+
}
|
|
561
|
+
const rules = css.match(/@font-face\s*\{[^}]*\}/g) || [];
|
|
562
|
+
return (await Promise.all(rules.map(async rule => {
|
|
563
|
+
for (const match of [...rule.matchAll(/url\(["']?([^"')]+)["']?\)/g)]) {
|
|
564
|
+
const source = new URL(match[1], sheet.href || document.baseURI).href;
|
|
565
|
+
if (!fontFiles.has(source)) fontFiles.set(source, (async () => {
|
|
566
|
+
const response = await fetch(source);
|
|
567
|
+
if (!response.ok) throw new Error('Font could not be loaded');
|
|
568
|
+
const blob = await response.blob();
|
|
569
|
+
return new Promise((resolve, reject) => {
|
|
570
|
+
const reader = new FileReader();
|
|
571
|
+
reader.onload = () => resolve(reader.result);
|
|
572
|
+
reader.onerror = reject;
|
|
573
|
+
reader.readAsDataURL(blob);
|
|
574
|
+
});
|
|
575
|
+
})());
|
|
576
|
+
rule = rule.replace(match[1], await fontFiles.get(source));
|
|
577
|
+
}
|
|
578
|
+
return rule;
|
|
579
|
+
}))).join('\n');
|
|
580
|
+
}));
|
|
581
|
+
// Rasterize SVG paper textures before nesting them inside the exporter's SVG.
|
|
582
|
+
// This keeps SVG filter fragment references local to their own image.
|
|
583
|
+
const textures = new Map();
|
|
584
|
+
const embedTexture = async (node) => {
|
|
585
|
+
if (!node.style) return;
|
|
586
|
+
let background = node.style.backgroundImage;
|
|
587
|
+
for (const [, source] of background.matchAll(/url\("(data:image\/svg\+xml[^"]+)"\)/g)) {
|
|
588
|
+
if (!textures.has(source)) textures.set(source, (async () => {
|
|
589
|
+
const image = new Image();
|
|
590
|
+
image.src = source;
|
|
591
|
+
await image.decode();
|
|
592
|
+
const canvas = document.createElement('canvas');
|
|
593
|
+
canvas.width = image.naturalWidth;
|
|
594
|
+
canvas.height = image.naturalHeight;
|
|
595
|
+
canvas.getContext('2d').drawImage(image, 0, 0);
|
|
596
|
+
return canvas.toDataURL();
|
|
597
|
+
})());
|
|
598
|
+
background = background.replace(source, await textures.get(source));
|
|
599
|
+
}
|
|
600
|
+
node.style.backgroundImage = background;
|
|
601
|
+
};
|
|
602
|
+
return {
|
|
603
|
+
...fit.dimensions(), style: { margin: '0' }, filter: includeNode,
|
|
604
|
+
font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
|
|
605
|
+
onCloneNode: clone => {
|
|
606
|
+
restoreLayout(poster, clone);
|
|
607
|
+
const style = document.createElement('style');
|
|
608
|
+
style.textContent = layout;
|
|
609
|
+
clone.prepend(style);
|
|
610
|
+
},
|
|
611
|
+
// CSS content's alternative text follows a slash and must not be painted.
|
|
612
|
+
onCreateForeignObjectSvg: svg => svg.querySelectorAll('style').forEach(style => {
|
|
613
|
+
style.textContent = style.textContent.replace(/(content:\s*')([^']*)(')/g, (_, start, text, end) => start + text.split(' / ')[0] + end);
|
|
614
|
+
}),
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
let url;
|
|
618
|
+
const save = (blob, extension) => {
|
|
619
|
+
url = URL.createObjectURL(blob);
|
|
620
|
+
const link = document.createElement('a');
|
|
621
|
+
const title = poster.querySelector('.headline')?.textContent || document.title || 'poster';
|
|
622
|
+
link.download = (title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '').slice(0, 100) || 'poster') + '.' + extension;
|
|
623
|
+
link.href = url;
|
|
624
|
+
link.addEventListener('click', event => event.stopPropagation(), { once: true });
|
|
625
|
+
document.body.append(link);
|
|
626
|
+
link.click();
|
|
627
|
+
link.remove();
|
|
628
|
+
};
|
|
629
|
+
const begin = kind => {
|
|
418
630
|
busy = true;
|
|
419
|
-
|
|
631
|
+
making = kind;
|
|
420
632
|
button.setAttribute('aria-busy', 'true');
|
|
421
633
|
fitButton.setAttribute('aria-disabled', 'true');
|
|
422
|
-
button
|
|
634
|
+
// A GIF takes a while: the button shows its progress and cancels it
|
|
635
|
+
if (kind === 'gif') label('Cancel the GIF');
|
|
636
|
+
else { button.setAttribute('aria-disabled', 'true'); label('Preparing poster PNG'); }
|
|
423
637
|
error.hidden = true;
|
|
424
|
-
|
|
638
|
+
};
|
|
639
|
+
const end = () => {
|
|
640
|
+
if (url) setTimeout(() => URL.revokeObjectURL(url), 60000);
|
|
641
|
+
url = null;
|
|
642
|
+
busy = false;
|
|
643
|
+
making = null;
|
|
644
|
+
cancelled = false;
|
|
645
|
+
button.innerHTML = icon;
|
|
646
|
+
button.removeAttribute('aria-disabled');
|
|
647
|
+
button.removeAttribute('aria-busy');
|
|
648
|
+
fitButton.removeAttribute('aria-disabled');
|
|
649
|
+
rest();
|
|
650
|
+
};
|
|
651
|
+
const fail = message => {
|
|
652
|
+
exporter = null;
|
|
653
|
+
encoder = null;
|
|
654
|
+
retries++;
|
|
655
|
+
error.textContent = message;
|
|
656
|
+
error.hidden = false;
|
|
657
|
+
};
|
|
658
|
+
async function savePng() {
|
|
659
|
+
begin('png');
|
|
425
660
|
try {
|
|
426
|
-
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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'));
|
|
661
|
+
const { domToBlob } = await loadExporter();
|
|
662
|
+
const blob = await domToBlob(poster, { ...(await captureOptions()), scale: 2 });
|
|
663
|
+
if (!blob) throw new Error('No PNG was created');
|
|
664
|
+
save(blob, 'png');
|
|
665
|
+
} catch (cause) {
|
|
666
|
+
fail('The PNG could not be saved. Check your connection and try again.');
|
|
667
|
+
} finally {
|
|
668
|
+
end();
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
// The GIF's clock: while it is made, rhp's motion (performance.now and animation frames) and the page's CSS
|
|
672
|
+
// animations run on a time of their own, which only moves on when a frame asks it to, so every frame shows the chart
|
|
673
|
+
// exactly as it is that many milliseconds into playing, its glides and slides included
|
|
674
|
+
const gifClock = () => {
|
|
675
|
+
const raf = window.requestAnimationFrame, caf = window.cancelAnimationFrame;
|
|
676
|
+
const queue = new Map(), started = new Map();
|
|
677
|
+
let time = performance.now(), id = 0, pump = 0;
|
|
678
|
+
const flush = () => {
|
|
679
|
+
const due = [...queue.values()];
|
|
680
|
+
queue.clear();
|
|
681
|
+
due.forEach(callback => { try { callback(time); } catch (cause) { reportError(cause); } });
|
|
682
|
+
};
|
|
683
|
+
// Callbacks still run every real frame, at the clock's time, so whatever waits for a frame is not held up
|
|
684
|
+
const loop = () => { flush(); pump = raf.call(window, loop); };
|
|
685
|
+
window.requestAnimationFrame = callback => { queue.set(++id, callback); return id; };
|
|
686
|
+
window.cancelAnimationFrame = n => queue.delete(n);
|
|
687
|
+
performance.now = () => time;
|
|
688
|
+
pump = raf.call(window, loop);
|
|
689
|
+
return {
|
|
690
|
+
// Two of the browser's frames: callbacks that were already waiting for one (rhp's ticker) come over to the clock
|
|
691
|
+
ready: new Promise(resolve => raf.call(window, () => raf.call(window, resolve))),
|
|
692
|
+
advance(ms, steps = 1) {
|
|
693
|
+
for (let i = 0; i < steps; i++) {
|
|
694
|
+
time += ms / steps;
|
|
695
|
+
// A frame's motion, then the style writes it schedules
|
|
696
|
+
flush(); flush(); flush();
|
|
697
|
+
for (const animation of document.getAnimations()) {
|
|
698
|
+
if (!started.has(animation)) started.set(animation, time - (animation.currentTime ?? 0));
|
|
699
|
+
animation.pause();
|
|
700
|
+
animation.currentTime = time - started.get(animation);
|
|
468
701
|
}
|
|
469
702
|
}
|
|
470
|
-
|
|
471
|
-
|
|
703
|
+
},
|
|
704
|
+
stop() {
|
|
705
|
+
caf.call(window, pump);
|
|
706
|
+
window.requestAnimationFrame = raf;
|
|
707
|
+
window.cancelAnimationFrame = caf;
|
|
708
|
+
delete performance.now;
|
|
709
|
+
for (const callback of queue.values()) raf.call(window, callback);
|
|
710
|
+
started.forEach((_, animation) => animation.play());
|
|
711
|
+
},
|
|
712
|
+
};
|
|
713
|
+
};
|
|
714
|
+
// The GIF: the playback at its own pace, 25 frames a second, at most 1200 pixels wide. One palette from the
|
|
715
|
+
// first and the last frame serves every frame; its last color is never drawn and marks the pixels a frame leaves as
|
|
716
|
+
// the frame before had them, so each frame stores only what changed.
|
|
717
|
+
async function saveGif() {
|
|
718
|
+
const playback = poster.playback;
|
|
719
|
+
const from = playback.position();
|
|
720
|
+
let clock = null;
|
|
721
|
+
begin('gif');
|
|
722
|
+
try {
|
|
723
|
+
encoder ||= import('https://cdn.jsdelivr.net/npm/gifenc@1.0.3/+esm' + (retries ? `?retry=${retries}` : ''));
|
|
724
|
+
const [{ domToCanvas }, { GIFEncoder, quantize, applyPalette }] = await Promise.all([loadExporter(), encoder]);
|
|
725
|
+
const options = await captureOptions();
|
|
726
|
+
const scale = Math.min(1, 1200 / options.width);
|
|
727
|
+
// The GIF plays at the chart's own pace: its playback's duration, in frames 40 ms apart
|
|
728
|
+
const step = 40, count = Math.max(2, Math.round((playback.duration ?? 15000) / step) + 1);
|
|
729
|
+
const capture = async () => {
|
|
730
|
+
const canvas = await domToCanvas(poster, { ...options, scale });
|
|
731
|
+
return canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height);
|
|
472
732
|
};
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
733
|
+
clock = gifClock();
|
|
734
|
+
await clock.ready;
|
|
735
|
+
// The end and the start, each given a second to settle, for the palette; the start is also the first frame
|
|
736
|
+
playback.seek(1);
|
|
737
|
+
clock.advance(1000, 25);
|
|
738
|
+
const last = await capture();
|
|
739
|
+
playback.seek(0);
|
|
740
|
+
clock.advance(1000, 25);
|
|
741
|
+
const first = await capture();
|
|
742
|
+
const both = new Uint8ClampedArray(first.data.length + last.data.length);
|
|
743
|
+
both.set(first.data);
|
|
744
|
+
both.set(last.data, first.data.length);
|
|
745
|
+
const palette = quantize(both, 255), kept = palette.length;
|
|
746
|
+
// A flat color that fills a part of the poster keeps its exact value: it takes the nearest palette entry
|
|
747
|
+
const counts = new Map(), taken = new Set();
|
|
748
|
+
for (let j = 0; j < both.length; j += 4) {
|
|
749
|
+
const color = (both[j] << 16) | (both[j + 1] << 8) | both[j + 2];
|
|
750
|
+
counts.set(color, (counts.get(color) ?? 0) + 1);
|
|
751
|
+
}
|
|
752
|
+
for (const [color] of [...counts].filter(([, n]) => n > both.length / 8000).sort((a, b) => b[1] - a[1])) {
|
|
753
|
+
const rgb = [color >> 16, (color >> 8) & 255, color & 255];
|
|
754
|
+
let nearest = -1, distance = Infinity;
|
|
755
|
+
palette.forEach((entry, k) => {
|
|
756
|
+
const d = (entry[0] - rgb[0]) ** 2 + (entry[1] - rgb[1]) ** 2 + (entry[2] - rgb[2]) ** 2;
|
|
757
|
+
if (!taken.has(k) && d < distance) { nearest = k; distance = d; }
|
|
758
|
+
});
|
|
759
|
+
if (nearest >= 0) { palette[nearest] = rgb; taken.add(nearest); }
|
|
760
|
+
}
|
|
761
|
+
palette.push([255, 0, 255]);
|
|
762
|
+
const gif = GIFEncoder();
|
|
763
|
+
let previous = null;
|
|
764
|
+
for (let i = 0; i < count; i++) {
|
|
765
|
+
if (cancelled) return;
|
|
766
|
+
if (i > 0) {
|
|
767
|
+
playback.seek(i / (count - 1));
|
|
768
|
+
clock.advance(step);
|
|
769
|
+
// The last frame is held: it shows the end settled
|
|
770
|
+
if (i === count - 1) clock.advance(1000, 25);
|
|
483
771
|
}
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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();
|
|
772
|
+
const image = i === 0 ? first : await capture();
|
|
773
|
+
const index = applyPalette(image.data, palette.slice(0, kept));
|
|
774
|
+
const pixels = previous ? index.map((p, j) => (p === previous[j] ? kept : p)) : index;
|
|
775
|
+
gif.writeFrame(pixels, image.width, image.height, {
|
|
776
|
+
palette: i === 0 ? palette : undefined, delay: i === 0 ? 1000 : i === count - 1 ? 3000 : step,
|
|
777
|
+
transparent: i > 0, transparentIndex: kept, dispose: 1,
|
|
778
|
+
});
|
|
779
|
+
previous = index;
|
|
780
|
+
const done = Math.round(((i + 1) / count) * 100);
|
|
781
|
+
button.innerHTML = `<span class="poster-download-progress" aria-hidden="true">${done}%</span>`;
|
|
782
|
+
label(`Cancel the GIF, ${done}% made`);
|
|
783
|
+
}
|
|
784
|
+
gif.finish();
|
|
785
|
+
save(new Blob([gif.bytes()], { type: 'image/gif' }), 'gif');
|
|
549
786
|
} catch (cause) {
|
|
550
|
-
|
|
551
|
-
retries++;
|
|
552
|
-
error.textContent = 'The PNG could not be saved. Check your connection and try again.';
|
|
553
|
-
error.hidden = false;
|
|
787
|
+
fail('The GIF could not be made. Check your connection and try again.');
|
|
554
788
|
} finally {
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
button.removeAttribute('aria-busy');
|
|
559
|
-
fitButton.removeAttribute('aria-disabled');
|
|
560
|
-
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
789
|
+
clock?.stop();
|
|
790
|
+
playback.seek(from);
|
|
791
|
+
end();
|
|
561
792
|
}
|
|
793
|
+
}
|
|
794
|
+
button.addEventListener('click', event => {
|
|
795
|
+
event.stopPropagation();
|
|
796
|
+
if (busy) {
|
|
797
|
+
if (making === 'gif') cancelled = true;
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
if (!playable()) return savePng();
|
|
801
|
+
// A key press is a click with no pointer detail: it opens the menu on its first item
|
|
802
|
+
if (menu.hidden) openMenu(event.detail === 0);
|
|
803
|
+
else closeMenu(event.detail === 0);
|
|
804
|
+
});
|
|
805
|
+
menu.addEventListener('click', event => {
|
|
806
|
+
const item = event.target.closest('[data-format]');
|
|
807
|
+
if (!item) return;
|
|
808
|
+
event.stopPropagation();
|
|
809
|
+
closeMenu(event.detail === 0);
|
|
810
|
+
if (item.dataset.format === 'gif') saveGif();
|
|
811
|
+
else savePng();
|
|
562
812
|
});
|
|
563
813
|
tools.append(actions);
|
|
564
814
|
});
|