@bezda/rhp-mcp 0.1.5 → 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 +6 -1
- package/skill/SKILL.md +113 -22
- package/skill/recipes/area.html +129 -33
- package/skill/recipes/bar.html +129 -33
- package/skill/recipes/box-plot.html +129 -33
- package/skill/recipes/bubble.html +129 -33
- package/skill/recipes/bullet.html +129 -33
- package/skill/recipes/candlestick.html +129 -33
- package/skill/recipes/column.html +129 -33
- package/skill/recipes/dial.html +129 -33
- package/skill/recipes/diverging-bars.html +129 -33
- package/skill/recipes/donut.html +129 -33
- package/skill/recipes/dumbbell.html +129 -33
- package/skill/recipes/gantt.html +129 -33
- package/skill/recipes/grouped-bars.html +129 -33
- package/skill/recipes/heatmap.html +129 -33
- package/skill/recipes/histogram.html +129 -33
- package/skill/recipes/line.html +129 -33
- package/skill/recipes/live.html +129 -33
- package/skill/recipes/log-line.html +129 -33
- package/skill/recipes/lollipop.html +129 -33
- package/skill/recipes/multi-line.html +129 -33
- package/skill/recipes/pyramid.html +129 -33
- package/skill/recipes/race.html +129 -33
- package/skill/recipes/radial-bars.html +129 -33
- package/skill/recipes/scatter.html +129 -33
- package/skill/recipes/silhouettes.html +129 -33
- package/skill/recipes/slope.html +129 -33
- package/skill/recipes/sparklines.html +129 -33
- package/skill/recipes/stacked-100.html +129 -33
- package/skill/recipes/stacked-bars.html +129 -33
- package/skill/recipes/strip.html +129 -33
- package/skill/recipes/tape.html +578 -0
- package/skill/recipes/unit-stack.html +129 -33
- package/skill/recipes/violin.html +129 -33
- package/skill/recipes/waffle.html +129 -33
- package/skill/recipes/waterfall.html +129 -33
- package/skill/references/api.md +6 -6
- package/skill/references/design.md +18 -0
- package/skill/references/download.md +135 -35
- 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 +29 -5
- package/src/check/build.js +4 -3
- package/src/check/colors.js +4 -2
- package/src/check/findings.js +99 -3
- package/src/check/index.js +158 -17
- package/src/check/inpage.js +292 -5
- package/src/check/interact.js +22 -4
- package/src/check/png.js +6 -1
- package/src/check/report.js +9 -2
- package/src/check/selection-controls.js +105 -0
- package/src/check/selection.js +54 -12
- package/src/check/source.js +21 -0
- package/src/server.js +4 -4
|
@@ -163,24 +163,84 @@ render(() => html`<${BloodTypes} />`, document.getElementById("chart"));
|
|
|
163
163
|
</script>
|
|
164
164
|
<style id="poster-download-styles">
|
|
165
165
|
.poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
|
|
166
|
-
.poster-
|
|
167
|
-
.poster-download
|
|
168
|
-
.poster-
|
|
169
|
-
.poster-
|
|
170
|
-
.poster-
|
|
166
|
+
.poster-actions { display: flex; align-items: center; margin-left: auto; }
|
|
167
|
+
.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; }
|
|
168
|
+
.poster-actions svg { display: block; width: 20px; height: 20px; }
|
|
169
|
+
.poster-actions button:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
|
|
170
|
+
.poster-actions button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
171
|
+
.poster-actions button[aria-disabled="true"] { cursor: progress; opacity: .5; }
|
|
172
|
+
.poster-fit-frame, .poster-fit-content { display: contents; }
|
|
173
|
+
.poster-fit-frame { max-height: 95svh; }
|
|
174
|
+
.poster-fit-frame.is-fitted { display: block; margin: 0 auto; overflow: clip; }
|
|
175
|
+
.poster-fit-frame.is-fitted > .poster-fit-content { display: block; transform-origin: top left; }
|
|
171
176
|
.poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
|
|
172
|
-
@media print { .poster-download-tools, .poster-
|
|
177
|
+
@media print { .poster-download-tools, .poster-actions, .poster-download-error { display: none !important; } }
|
|
173
178
|
</style>
|
|
174
179
|
<script type="module" id="poster-download-script">
|
|
180
|
+
function addPosterFit(poster, button, changed) {
|
|
181
|
+
const frame = document.createElement('div'), content = document.createElement('div');
|
|
182
|
+
frame.className = 'poster-fit-frame'; content.className = 'poster-fit-content';
|
|
183
|
+
poster.before(frame); frame.append(content); content.append(poster);
|
|
184
|
+
const properties = ['width', 'max-width', 'margin', 'box-sizing'];
|
|
185
|
+
const original = properties.map(name => [name, poster.style.getPropertyValue(name), poster.style.getPropertyPriority(name)]);
|
|
186
|
+
let fitted = false, scale = 1, request = 0, layout = null;
|
|
187
|
+
const availableHeight = () => parseFloat(getComputedStyle(frame).maxHeight) || innerHeight * .95;
|
|
188
|
+
const layoutKey = () => {
|
|
189
|
+
const css = getComputedStyle(poster);
|
|
190
|
+
return [innerWidth, availableHeight(), css.width, css.height].join('/');
|
|
191
|
+
};
|
|
192
|
+
const restore = () => original.forEach(([name, value, priority]) => {
|
|
193
|
+
if (value) poster.style.setProperty(name, value, priority);
|
|
194
|
+
else poster.style.removeProperty(name);
|
|
195
|
+
});
|
|
196
|
+
function update() {
|
|
197
|
+
request = 0;
|
|
198
|
+
if (fitted && layout === layoutKey()) return;
|
|
199
|
+
frame.classList.remove('is-fitted'); frame.style.width = ''; frame.style.height = '';
|
|
200
|
+
content.style.width = ''; content.style.transform = ''; restore(); scale = 1;
|
|
201
|
+
if (fitted) {
|
|
202
|
+
const box = poster.getBoundingClientRect();
|
|
203
|
+
scale = Math.min(1, availableHeight() / box.height);
|
|
204
|
+
poster.style.setProperty('box-sizing', 'border-box');
|
|
205
|
+
poster.style.setProperty('width', `${box.width}px`);
|
|
206
|
+
poster.style.setProperty('max-width', 'none');
|
|
207
|
+
poster.style.setProperty('margin', '0');
|
|
208
|
+
content.style.width = `${box.width}px`;
|
|
209
|
+
content.style.transform = `scale(${scale})`;
|
|
210
|
+
frame.style.width = `${box.width * scale}px`; frame.style.height = `${box.height * scale}px`;
|
|
211
|
+
frame.classList.add('is-fitted');
|
|
212
|
+
}
|
|
213
|
+
layout = layoutKey();
|
|
214
|
+
const label = fitted ? 'Restore poster size' : 'Fit poster to screen';
|
|
215
|
+
button.setAttribute('aria-label', label); button.title = label;
|
|
216
|
+
button.setAttribute('aria-pressed', String(fitted));
|
|
217
|
+
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>`;
|
|
218
|
+
changed();
|
|
219
|
+
poster.dispatchEvent(new Event('poster-view-change'));
|
|
220
|
+
}
|
|
221
|
+
const schedule = () => { if (fitted && !request && layout !== layoutKey()) request = requestAnimationFrame(update); };
|
|
222
|
+
new ResizeObserver(schedule).observe(poster);
|
|
223
|
+
addEventListener('resize', schedule);
|
|
224
|
+
document.fonts.ready.then(schedule);
|
|
225
|
+
update();
|
|
226
|
+
return {
|
|
227
|
+
toggle() { fitted = !fitted; layout = null; cancelAnimationFrame(request); update(); },
|
|
228
|
+
dimensions() {
|
|
229
|
+
if (!fitted) { const box = poster.getBoundingClientRect(); return {width:box.width,height:box.height}; }
|
|
230
|
+
const css = getComputedStyle(poster);
|
|
231
|
+
return {width:parseFloat(css.width),height:parseFloat(css.height)};
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
175
235
|
function addPosterDownloads() {
|
|
176
236
|
document.querySelectorAll('.poster').forEach((poster, index) => {
|
|
177
237
|
const navigation = index === 0 && document.querySelector('.recipe-navigation');
|
|
178
238
|
const tools = navigation || document.createElement('div');
|
|
179
239
|
if (tools.querySelector('.poster-download')) return;
|
|
240
|
+
const align = () => { if (!navigation) tools.style.width = `${poster.getBoundingClientRect().width}px`; };
|
|
180
241
|
if (!navigation) {
|
|
181
242
|
tools.className = 'poster-download-tools';
|
|
182
243
|
poster.before(tools);
|
|
183
|
-
const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
|
|
184
244
|
new ResizeObserver(align).observe(poster);
|
|
185
245
|
align();
|
|
186
246
|
const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
|
|
@@ -198,27 +258,43 @@ function addPosterDownloads() {
|
|
|
198
258
|
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
199
259
|
button.title = 'Download poster as PNG';
|
|
200
260
|
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>';
|
|
261
|
+
const actions = document.createElement('div'), fitButton = document.createElement('button');
|
|
262
|
+
actions.className = 'poster-actions'; fitButton.className = 'poster-fit-toggle'; fitButton.type = 'button';
|
|
263
|
+
if (!poster.id) {
|
|
264
|
+
let id = `rhp-poster-${index + 1}`;
|
|
265
|
+
while (document.getElementById(id)) id += '-view';
|
|
266
|
+
poster.id = id;
|
|
267
|
+
}
|
|
268
|
+
fitButton.setAttribute('aria-controls', poster.id);
|
|
269
|
+
fitButton.setAttribute('data-poster-view-toggle', '');
|
|
270
|
+
const fit = addPosterFit(poster, fitButton, align);
|
|
271
|
+
fitButton.addEventListener('click', event => { event.stopPropagation(); if (!busy) fit.toggle(); });
|
|
272
|
+
actions.append(fitButton, button);
|
|
201
273
|
const error = document.createElement('p');
|
|
202
274
|
error.className = 'poster-download-error';
|
|
203
275
|
error.setAttribute('role', 'alert');
|
|
204
276
|
error.hidden = true;
|
|
205
277
|
tools.after(error);
|
|
206
278
|
// Download is part of the poster interaction, even though its control sits outside the image.
|
|
207
|
-
|
|
279
|
+
actions.addEventListener('pointerdown', event => {
|
|
208
280
|
event.stopPropagation();
|
|
209
281
|
});
|
|
210
|
-
|
|
282
|
+
actions.addEventListener('mousedown', event => {
|
|
211
283
|
// Pointer activation downloads without taking focus from the selected chart item.
|
|
212
284
|
event.preventDefault();
|
|
213
285
|
event.stopPropagation();
|
|
214
286
|
});
|
|
215
287
|
poster.addEventListener('focusout', event => {
|
|
216
|
-
if (
|
|
288
|
+
if (actions.contains(event.relatedTarget) || actions.querySelector(':active')) event.stopImmediatePropagation();
|
|
217
289
|
}, true);
|
|
218
290
|
poster.addEventListener('pointerleave', event => {
|
|
219
|
-
if (
|
|
291
|
+
if (actions.contains(event.relatedTarget) || actions.contains(document.activeElement)) event.stopImmediatePropagation();
|
|
220
292
|
}, true);
|
|
221
|
-
|
|
293
|
+
poster.addEventListener('pointermove', event => {
|
|
294
|
+
// Safari can move the pointer over the title while a keyboard-triggered download is being prepared.
|
|
295
|
+
if (actions.contains(document.activeElement) && !event.target.closest('.rhp-chart')) event.stopImmediatePropagation();
|
|
296
|
+
}, true);
|
|
297
|
+
for (const type of ['keydown', 'keyup']) actions.addEventListener(type, event => {
|
|
222
298
|
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
|
|
223
299
|
});
|
|
224
300
|
let exporter, retries = 0, busy = false;
|
|
@@ -228,6 +304,7 @@ function addPosterDownloads() {
|
|
|
228
304
|
busy = true;
|
|
229
305
|
button.setAttribute('aria-disabled', 'true');
|
|
230
306
|
button.setAttribute('aria-busy', 'true');
|
|
307
|
+
fitButton.setAttribute('aria-disabled', 'true');
|
|
231
308
|
button.setAttribute('aria-label', 'Preparing poster PNG');
|
|
232
309
|
error.hidden = true;
|
|
233
310
|
let url;
|
|
@@ -235,35 +312,53 @@ function addPosterDownloads() {
|
|
|
235
312
|
exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
|
|
236
313
|
const { domToBlob } = await exporter;
|
|
237
314
|
await document.fonts.ready;
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
const
|
|
315
|
+
// Preserve author layout rules where computed styles lose the grid's used geometry.
|
|
316
|
+
// Safari misreports percentage insets in subgrids, and grid auto margins resolve to zero.
|
|
317
|
+
const autoMargins = new Set(), positioned = new Set();
|
|
318
|
+
const isMargin = name => name === 'margin' || name.startsWith('margin-');
|
|
319
|
+
const isInset = name => name === 'inset' || name.startsWith('inset-') || ['top', 'right', 'bottom', 'left'].includes(name);
|
|
320
|
+
const layoutRules = rules => [...rules].map(rule => {
|
|
241
321
|
if (rule.selectorText && rule.style) {
|
|
242
|
-
const margins = [...rule.style].filter(
|
|
322
|
+
const margins = [...rule.style].filter(isMargin);
|
|
243
323
|
if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
|
|
244
|
-
|
|
324
|
+
const insets = [...rule.style].filter(isInset);
|
|
325
|
+
if (insets.length) positioned.add(rule.selectorText);
|
|
326
|
+
const layout = [...margins, ...insets];
|
|
327
|
+
return layout.length ? `${rule.selectorText} { ${layout.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
|
|
245
328
|
}
|
|
246
|
-
if (!rule.cssRules) return '';
|
|
247
|
-
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ?
|
|
248
|
-
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ?
|
|
249
|
-
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) +
|
|
329
|
+
if (!rule.cssRules) return /^@layer\b/.test(rule.cssText) ? rule.cssText : '';
|
|
330
|
+
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? layoutRules(rule.cssRules) : '';
|
|
331
|
+
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? layoutRules(rule.cssRules) : '';
|
|
332
|
+
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + layoutRules(rule.cssRules) + '}';
|
|
250
333
|
}).join('\n');
|
|
251
|
-
const
|
|
252
|
-
|
|
334
|
+
const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
|
|
335
|
+
const layout = sheets.map(sheet => {
|
|
336
|
+
try { return layoutRules(sheet.cssRules); } catch (cause) { return ''; }
|
|
253
337
|
}).join('\n');
|
|
254
338
|
// Omit display-none HTML subtrees, such as collapsed chart rows, from the image and its SVG payload.
|
|
255
339
|
const includeNode = node => !(node instanceof HTMLElement) || getComputedStyle(node).display !== 'none';
|
|
256
|
-
const
|
|
257
|
-
if (clone.style && source !== poster
|
|
258
|
-
[...
|
|
259
|
-
|
|
340
|
+
const restoreLayout = (source, clone) => {
|
|
341
|
+
if (clone.style && source !== poster) {
|
|
342
|
+
const margins = [...autoMargins].some(selector => source.matches(selector));
|
|
343
|
+
const computed = getComputedStyle(source);
|
|
344
|
+
const insets = computed.position === 'absolute' && [...positioned].some(selector => source.matches(selector));
|
|
345
|
+
const restore = name => (margins && isMargin(name)) || (insets && isInset(name));
|
|
346
|
+
[...clone.style].filter(restore).forEach(name => clone.style.removeProperty(name));
|
|
347
|
+
[...source.style].filter(restore).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
|
|
348
|
+
if (insets) {
|
|
349
|
+
// Only the scaled axis needs its percentage rule. Freeze the other axis in pixels:
|
|
350
|
+
// Firefox does not reevaluate container queries reliably inside an SVG foreignObject.
|
|
351
|
+
const axis = source.getAttribute('data-rhp-o');
|
|
352
|
+
const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
|
|
353
|
+
fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
|
|
354
|
+
}
|
|
260
355
|
}
|
|
261
356
|
const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName) && includeNode(node));
|
|
262
|
-
[...clone.children].forEach((child, i) => { if (children[i])
|
|
357
|
+
[...clone.children].forEach((child, i) => { if (children[i]) restoreLayout(children[i], child); });
|
|
263
358
|
};
|
|
264
359
|
// Cross-origin font stylesheets are readable through fetch, but not through cssRules.
|
|
265
360
|
const fontFiles = new Map();
|
|
266
|
-
const fontRules = await Promise.all(
|
|
361
|
+
const fontRules = await Promise.all(sheets.map(async sheet => {
|
|
267
362
|
let css;
|
|
268
363
|
try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
|
|
269
364
|
catch (cause) {
|
|
@@ -314,12 +409,12 @@ function addPosterDownloads() {
|
|
|
314
409
|
node.style.backgroundImage = background;
|
|
315
410
|
};
|
|
316
411
|
const blob = await domToBlob(poster, {
|
|
317
|
-
scale: 2, style: { margin: '0' }, filter: includeNode,
|
|
412
|
+
...fit.dimensions(), scale: 2, style: { margin: '0' }, filter: includeNode,
|
|
318
413
|
font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
|
|
319
414
|
onCloneNode: clone => {
|
|
320
|
-
|
|
415
|
+
restoreLayout(poster, clone);
|
|
321
416
|
const style = document.createElement('style');
|
|
322
|
-
style.textContent =
|
|
417
|
+
style.textContent = layout;
|
|
323
418
|
clone.prepend(style);
|
|
324
419
|
},
|
|
325
420
|
// CSS content's alternative text follows a slash and must not be painted.
|
|
@@ -347,10 +442,11 @@ function addPosterDownloads() {
|
|
|
347
442
|
busy = false;
|
|
348
443
|
button.removeAttribute('aria-disabled');
|
|
349
444
|
button.removeAttribute('aria-busy');
|
|
445
|
+
fitButton.removeAttribute('aria-disabled');
|
|
350
446
|
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
351
447
|
}
|
|
352
448
|
});
|
|
353
|
-
tools.append(
|
|
449
|
+
tools.append(actions);
|
|
354
450
|
});
|
|
355
451
|
}
|
|
356
452
|
// A chart module may await fonts or data after DOMContentLoaded has already fired.
|
|
@@ -125,24 +125,84 @@ render(() => html`<${ProfitBridge} />`, document.getElementById("chart"));
|
|
|
125
125
|
</script>
|
|
126
126
|
<style id="poster-download-styles">
|
|
127
127
|
.poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
|
|
128
|
-
.poster-
|
|
129
|
-
.poster-download
|
|
130
|
-
.poster-
|
|
131
|
-
.poster-
|
|
132
|
-
.poster-
|
|
128
|
+
.poster-actions { display: flex; align-items: center; margin-left: auto; }
|
|
129
|
+
.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; }
|
|
130
|
+
.poster-actions svg { display: block; width: 20px; height: 20px; }
|
|
131
|
+
.poster-actions button:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
|
|
132
|
+
.poster-actions button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
133
|
+
.poster-actions button[aria-disabled="true"] { cursor: progress; opacity: .5; }
|
|
134
|
+
.poster-fit-frame, .poster-fit-content { display: contents; }
|
|
135
|
+
.poster-fit-frame { max-height: 95svh; }
|
|
136
|
+
.poster-fit-frame.is-fitted { display: block; margin: 0 auto; overflow: clip; }
|
|
137
|
+
.poster-fit-frame.is-fitted > .poster-fit-content { display: block; transform-origin: top left; }
|
|
133
138
|
.poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
|
|
134
|
-
@media print { .poster-download-tools, .poster-
|
|
139
|
+
@media print { .poster-download-tools, .poster-actions, .poster-download-error { display: none !important; } }
|
|
135
140
|
</style>
|
|
136
141
|
<script type="module" id="poster-download-script">
|
|
142
|
+
function addPosterFit(poster, button, changed) {
|
|
143
|
+
const frame = document.createElement('div'), content = document.createElement('div');
|
|
144
|
+
frame.className = 'poster-fit-frame'; content.className = 'poster-fit-content';
|
|
145
|
+
poster.before(frame); frame.append(content); content.append(poster);
|
|
146
|
+
const properties = ['width', 'max-width', 'margin', 'box-sizing'];
|
|
147
|
+
const original = properties.map(name => [name, poster.style.getPropertyValue(name), poster.style.getPropertyPriority(name)]);
|
|
148
|
+
let fitted = false, scale = 1, request = 0, layout = null;
|
|
149
|
+
const availableHeight = () => parseFloat(getComputedStyle(frame).maxHeight) || innerHeight * .95;
|
|
150
|
+
const layoutKey = () => {
|
|
151
|
+
const css = getComputedStyle(poster);
|
|
152
|
+
return [innerWidth, availableHeight(), css.width, css.height].join('/');
|
|
153
|
+
};
|
|
154
|
+
const restore = () => original.forEach(([name, value, priority]) => {
|
|
155
|
+
if (value) poster.style.setProperty(name, value, priority);
|
|
156
|
+
else poster.style.removeProperty(name);
|
|
157
|
+
});
|
|
158
|
+
function update() {
|
|
159
|
+
request = 0;
|
|
160
|
+
if (fitted && layout === layoutKey()) return;
|
|
161
|
+
frame.classList.remove('is-fitted'); frame.style.width = ''; frame.style.height = '';
|
|
162
|
+
content.style.width = ''; content.style.transform = ''; restore(); scale = 1;
|
|
163
|
+
if (fitted) {
|
|
164
|
+
const box = poster.getBoundingClientRect();
|
|
165
|
+
scale = Math.min(1, availableHeight() / box.height);
|
|
166
|
+
poster.style.setProperty('box-sizing', 'border-box');
|
|
167
|
+
poster.style.setProperty('width', `${box.width}px`);
|
|
168
|
+
poster.style.setProperty('max-width', 'none');
|
|
169
|
+
poster.style.setProperty('margin', '0');
|
|
170
|
+
content.style.width = `${box.width}px`;
|
|
171
|
+
content.style.transform = `scale(${scale})`;
|
|
172
|
+
frame.style.width = `${box.width * scale}px`; frame.style.height = `${box.height * scale}px`;
|
|
173
|
+
frame.classList.add('is-fitted');
|
|
174
|
+
}
|
|
175
|
+
layout = layoutKey();
|
|
176
|
+
const label = fitted ? 'Restore poster size' : 'Fit poster to screen';
|
|
177
|
+
button.setAttribute('aria-label', label); button.title = label;
|
|
178
|
+
button.setAttribute('aria-pressed', String(fitted));
|
|
179
|
+
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>`;
|
|
180
|
+
changed();
|
|
181
|
+
poster.dispatchEvent(new Event('poster-view-change'));
|
|
182
|
+
}
|
|
183
|
+
const schedule = () => { if (fitted && !request && layout !== layoutKey()) request = requestAnimationFrame(update); };
|
|
184
|
+
new ResizeObserver(schedule).observe(poster);
|
|
185
|
+
addEventListener('resize', schedule);
|
|
186
|
+
document.fonts.ready.then(schedule);
|
|
187
|
+
update();
|
|
188
|
+
return {
|
|
189
|
+
toggle() { fitted = !fitted; layout = null; cancelAnimationFrame(request); update(); },
|
|
190
|
+
dimensions() {
|
|
191
|
+
if (!fitted) { const box = poster.getBoundingClientRect(); return {width:box.width,height:box.height}; }
|
|
192
|
+
const css = getComputedStyle(poster);
|
|
193
|
+
return {width:parseFloat(css.width),height:parseFloat(css.height)};
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
137
197
|
function addPosterDownloads() {
|
|
138
198
|
document.querySelectorAll('.poster').forEach((poster, index) => {
|
|
139
199
|
const navigation = index === 0 && document.querySelector('.recipe-navigation');
|
|
140
200
|
const tools = navigation || document.createElement('div');
|
|
141
201
|
if (tools.querySelector('.poster-download')) return;
|
|
202
|
+
const align = () => { if (!navigation) tools.style.width = `${poster.getBoundingClientRect().width}px`; };
|
|
142
203
|
if (!navigation) {
|
|
143
204
|
tools.className = 'poster-download-tools';
|
|
144
205
|
poster.before(tools);
|
|
145
|
-
const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
|
|
146
206
|
new ResizeObserver(align).observe(poster);
|
|
147
207
|
align();
|
|
148
208
|
const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
|
|
@@ -160,27 +220,43 @@ function addPosterDownloads() {
|
|
|
160
220
|
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
161
221
|
button.title = 'Download poster as PNG';
|
|
162
222
|
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>';
|
|
223
|
+
const actions = document.createElement('div'), fitButton = document.createElement('button');
|
|
224
|
+
actions.className = 'poster-actions'; fitButton.className = 'poster-fit-toggle'; fitButton.type = 'button';
|
|
225
|
+
if (!poster.id) {
|
|
226
|
+
let id = `rhp-poster-${index + 1}`;
|
|
227
|
+
while (document.getElementById(id)) id += '-view';
|
|
228
|
+
poster.id = id;
|
|
229
|
+
}
|
|
230
|
+
fitButton.setAttribute('aria-controls', poster.id);
|
|
231
|
+
fitButton.setAttribute('data-poster-view-toggle', '');
|
|
232
|
+
const fit = addPosterFit(poster, fitButton, align);
|
|
233
|
+
fitButton.addEventListener('click', event => { event.stopPropagation(); if (!busy) fit.toggle(); });
|
|
234
|
+
actions.append(fitButton, button);
|
|
163
235
|
const error = document.createElement('p');
|
|
164
236
|
error.className = 'poster-download-error';
|
|
165
237
|
error.setAttribute('role', 'alert');
|
|
166
238
|
error.hidden = true;
|
|
167
239
|
tools.after(error);
|
|
168
240
|
// Download is part of the poster interaction, even though its control sits outside the image.
|
|
169
|
-
|
|
241
|
+
actions.addEventListener('pointerdown', event => {
|
|
170
242
|
event.stopPropagation();
|
|
171
243
|
});
|
|
172
|
-
|
|
244
|
+
actions.addEventListener('mousedown', event => {
|
|
173
245
|
// Pointer activation downloads without taking focus from the selected chart item.
|
|
174
246
|
event.preventDefault();
|
|
175
247
|
event.stopPropagation();
|
|
176
248
|
});
|
|
177
249
|
poster.addEventListener('focusout', event => {
|
|
178
|
-
if (
|
|
250
|
+
if (actions.contains(event.relatedTarget) || actions.querySelector(':active')) event.stopImmediatePropagation();
|
|
179
251
|
}, true);
|
|
180
252
|
poster.addEventListener('pointerleave', event => {
|
|
181
|
-
if (
|
|
253
|
+
if (actions.contains(event.relatedTarget) || actions.contains(document.activeElement)) event.stopImmediatePropagation();
|
|
254
|
+
}, true);
|
|
255
|
+
poster.addEventListener('pointermove', event => {
|
|
256
|
+
// Safari can move the pointer over the title while a keyboard-triggered download is being prepared.
|
|
257
|
+
if (actions.contains(document.activeElement) && !event.target.closest('.rhp-chart')) event.stopImmediatePropagation();
|
|
182
258
|
}, true);
|
|
183
|
-
for (const type of ['keydown', 'keyup'])
|
|
259
|
+
for (const type of ['keydown', 'keyup']) actions.addEventListener(type, event => {
|
|
184
260
|
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
|
|
185
261
|
});
|
|
186
262
|
let exporter, retries = 0, busy = false;
|
|
@@ -190,6 +266,7 @@ function addPosterDownloads() {
|
|
|
190
266
|
busy = true;
|
|
191
267
|
button.setAttribute('aria-disabled', 'true');
|
|
192
268
|
button.setAttribute('aria-busy', 'true');
|
|
269
|
+
fitButton.setAttribute('aria-disabled', 'true');
|
|
193
270
|
button.setAttribute('aria-label', 'Preparing poster PNG');
|
|
194
271
|
error.hidden = true;
|
|
195
272
|
let url;
|
|
@@ -197,35 +274,53 @@ function addPosterDownloads() {
|
|
|
197
274
|
exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
|
|
198
275
|
const { domToBlob } = await exporter;
|
|
199
276
|
await document.fonts.ready;
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
const
|
|
277
|
+
// Preserve author layout rules where computed styles lose the grid's used geometry.
|
|
278
|
+
// Safari misreports percentage insets in subgrids, and grid auto margins resolve to zero.
|
|
279
|
+
const autoMargins = new Set(), positioned = new Set();
|
|
280
|
+
const isMargin = name => name === 'margin' || name.startsWith('margin-');
|
|
281
|
+
const isInset = name => name === 'inset' || name.startsWith('inset-') || ['top', 'right', 'bottom', 'left'].includes(name);
|
|
282
|
+
const layoutRules = rules => [...rules].map(rule => {
|
|
203
283
|
if (rule.selectorText && rule.style) {
|
|
204
|
-
const margins = [...rule.style].filter(
|
|
284
|
+
const margins = [...rule.style].filter(isMargin);
|
|
205
285
|
if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
|
|
206
|
-
|
|
286
|
+
const insets = [...rule.style].filter(isInset);
|
|
287
|
+
if (insets.length) positioned.add(rule.selectorText);
|
|
288
|
+
const layout = [...margins, ...insets];
|
|
289
|
+
return layout.length ? `${rule.selectorText} { ${layout.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
|
|
207
290
|
}
|
|
208
|
-
if (!rule.cssRules) return '';
|
|
209
|
-
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ?
|
|
210
|
-
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ?
|
|
211
|
-
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) +
|
|
291
|
+
if (!rule.cssRules) return /^@layer\b/.test(rule.cssText) ? rule.cssText : '';
|
|
292
|
+
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? layoutRules(rule.cssRules) : '';
|
|
293
|
+
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? layoutRules(rule.cssRules) : '';
|
|
294
|
+
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + layoutRules(rule.cssRules) + '}';
|
|
212
295
|
}).join('\n');
|
|
213
|
-
const
|
|
214
|
-
|
|
296
|
+
const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
|
|
297
|
+
const layout = sheets.map(sheet => {
|
|
298
|
+
try { return layoutRules(sheet.cssRules); } catch (cause) { return ''; }
|
|
215
299
|
}).join('\n');
|
|
216
300
|
// Omit display-none HTML subtrees, such as collapsed chart rows, from the image and its SVG payload.
|
|
217
301
|
const includeNode = node => !(node instanceof HTMLElement) || getComputedStyle(node).display !== 'none';
|
|
218
|
-
const
|
|
219
|
-
if (clone.style && source !== poster
|
|
220
|
-
[...
|
|
221
|
-
|
|
302
|
+
const restoreLayout = (source, clone) => {
|
|
303
|
+
if (clone.style && source !== poster) {
|
|
304
|
+
const margins = [...autoMargins].some(selector => source.matches(selector));
|
|
305
|
+
const computed = getComputedStyle(source);
|
|
306
|
+
const insets = computed.position === 'absolute' && [...positioned].some(selector => source.matches(selector));
|
|
307
|
+
const restore = name => (margins && isMargin(name)) || (insets && isInset(name));
|
|
308
|
+
[...clone.style].filter(restore).forEach(name => clone.style.removeProperty(name));
|
|
309
|
+
[...source.style].filter(restore).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
|
|
310
|
+
if (insets) {
|
|
311
|
+
// Only the scaled axis needs its percentage rule. Freeze the other axis in pixels:
|
|
312
|
+
// Firefox does not reevaluate container queries reliably inside an SVG foreignObject.
|
|
313
|
+
const axis = source.getAttribute('data-rhp-o');
|
|
314
|
+
const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
|
|
315
|
+
fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
|
|
316
|
+
}
|
|
222
317
|
}
|
|
223
318
|
const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName) && includeNode(node));
|
|
224
|
-
[...clone.children].forEach((child, i) => { if (children[i])
|
|
319
|
+
[...clone.children].forEach((child, i) => { if (children[i]) restoreLayout(children[i], child); });
|
|
225
320
|
};
|
|
226
321
|
// Cross-origin font stylesheets are readable through fetch, but not through cssRules.
|
|
227
322
|
const fontFiles = new Map();
|
|
228
|
-
const fontRules = await Promise.all(
|
|
323
|
+
const fontRules = await Promise.all(sheets.map(async sheet => {
|
|
229
324
|
let css;
|
|
230
325
|
try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
|
|
231
326
|
catch (cause) {
|
|
@@ -276,12 +371,12 @@ function addPosterDownloads() {
|
|
|
276
371
|
node.style.backgroundImage = background;
|
|
277
372
|
};
|
|
278
373
|
const blob = await domToBlob(poster, {
|
|
279
|
-
scale: 2, style: { margin: '0' }, filter: includeNode,
|
|
374
|
+
...fit.dimensions(), scale: 2, style: { margin: '0' }, filter: includeNode,
|
|
280
375
|
font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
|
|
281
376
|
onCloneNode: clone => {
|
|
282
|
-
|
|
377
|
+
restoreLayout(poster, clone);
|
|
283
378
|
const style = document.createElement('style');
|
|
284
|
-
style.textContent =
|
|
379
|
+
style.textContent = layout;
|
|
285
380
|
clone.prepend(style);
|
|
286
381
|
},
|
|
287
382
|
// CSS content's alternative text follows a slash and must not be painted.
|
|
@@ -309,10 +404,11 @@ function addPosterDownloads() {
|
|
|
309
404
|
busy = false;
|
|
310
405
|
button.removeAttribute('aria-disabled');
|
|
311
406
|
button.removeAttribute('aria-busy');
|
|
407
|
+
fitButton.removeAttribute('aria-disabled');
|
|
312
408
|
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
313
409
|
}
|
|
314
410
|
});
|
|
315
|
-
tools.append(
|
|
411
|
+
tools.append(actions);
|
|
316
412
|
});
|
|
317
413
|
}
|
|
318
414
|
// A chart module may await fonts or data after DOMContentLoaded has already fired.
|
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.
|