Files
brk/website/scripts/utils/chart/capture.js
T
2026-06-06 22:29:33 +02:00

399 lines
11 KiB
JavaScript

import { ios, canShare } from "../env.js";
import { style } from "../elements.js";
import { colors } from "../colors.js";
export const canCapture = !ios || canShare;
/**
* @typedef {Object} LegendItem
* @property {string} text
* @property {string[]} colors
* @property {boolean} muted
*
* @typedef {Object} LegendCapture
* @property {number} x
* @property {number} y
* @property {number} width
* @property {LegendItem[]} items
*
* @typedef {Object} LegendMetrics
* @property {number} dot
* @property {number} fontSize
* @property {number} itemGap
* @property {number} lineHeight
* @property {number} rowGap
* @property {number} textGap
*/
/**
* @param {string} value
* @param {number} [fallback]
*/
function cssPx(value, fallback = 0) {
return Number.parseFloat(value) || fallback;
}
/**
* @param {CSSStyleDeclaration} computedStyle
* @param {number} size
*/
function canvasFont(computedStyle, size) {
return [
computedStyle.fontStyle,
computedStyle.fontWeight,
`${size}px`,
computedStyle.fontFamily || style.fontFamily,
].join(" ");
}
/**
* @param {CanvasRenderingContext2D} ctx
* @param {CSSStyleDeclaration} computedStyle
* @param {number} size
* @param {number} [letterSpacing]
*/
function setFont(ctx, computedStyle, size, letterSpacing = 0) {
ctx.font = canvasFont(computedStyle, size);
if ("letterSpacing" in ctx) ctx.letterSpacing = `${letterSpacing}px`;
}
/**
* @param {string} text
* @param {HTMLElement} element
*/
function transformText(text, element) {
switch (getComputedStyle(element).textTransform) {
case "lowercase":
return text.toLowerCase();
case "uppercase":
return text.toUpperCase();
case "capitalize":
return text.replace(/\b\w/g, (char) => char.toUpperCase());
default:
return text;
}
}
/** @param {HTMLElement} element */
function visibleText(element) {
const select = element.querySelector("select");
if (!(select instanceof HTMLSelectElement)) {
return transformText(element.textContent?.trim() ?? "", element);
}
const selected =
select.selectedOptions[0]?.textContent?.trim() || select.value.trim();
return transformText(selected, element);
}
/**
* @param {string} text
* @param {Partial<LegendItem>} [item]
* @returns {LegendItem[]}
*/
function legendText(text, item = {}) {
return text
? [
{
text,
colors: [],
muted: false,
...item,
},
]
: [];
}
/**
* @param {HTMLElement} legend
* @returns {LegendItem[]}
*/
function legendItems(legend) {
const scroller = legend.firstElementChild;
if (!(scroller instanceof HTMLElement)) return [];
const children = Array.from(scroller.children);
const seriesRoot =
children.find((child) =>
child.querySelector('label input[type="checkbox"]'),
) ?? children.at(-1);
const prefix = children[0] !== seriesRoot ? children[0] : null;
const separator = prefix ? children[1] : null;
/** @type {LegendItem[]} */
const seriesItems = [];
const root = seriesRoot instanceof HTMLElement ? seriesRoot : scroller;
for (const label of root.querySelectorAll("label")) {
const input = label.querySelector('input[type="checkbox"]');
const name = label.querySelector(".name");
if (
!(input instanceof HTMLInputElement) ||
!(name instanceof HTMLElement) ||
!input.checked
) {
continue;
}
const text = transformText(name.textContent?.trim() ?? "", name);
if (!text) continue;
seriesItems.push({
text,
muted: false,
colors: Array.from(label.querySelectorAll(".colors span"))
.map((span) =>
span instanceof HTMLElement ? span.style.backgroundColor : "",
)
.filter(Boolean),
});
}
if (!seriesItems.length) return [];
const prefixText = prefix instanceof HTMLElement ? visibleText(prefix) : "";
return [
...legendText(prefixText),
...(prefixText && separator instanceof HTMLElement
? legendText(visibleText(separator), { muted: true })
: []),
...seriesItems,
];
}
/**
* @param {HTMLElement} legend
* @param {DOMRect} parentRect
* @param {(value: number) => number} scale
* @returns {LegendCapture}
*/
function captureLegend(legend, parentRect, scale) {
const scroller = legend.firstElementChild;
if (!(scroller instanceof HTMLElement)) {
return { x: 0, y: 0, width: 0, items: [] };
}
const rect = scroller.getBoundingClientRect();
const computedStyle = getComputedStyle(scroller);
const left = cssPx(computedStyle.paddingLeft);
const right = cssPx(computedStyle.paddingRight);
return {
x: scale(rect.left - parentRect.left + left),
y: scale(rect.top - parentRect.top + cssPx(computedStyle.paddingTop, 6)),
width: Math.max(0, scale(rect.width - left - right)),
items: legendItems(legend),
};
}
/**
* @param {CanvasRenderingContext2D} ctx
* @param {LegendItem} item
* @param {LegendMetrics} metrics
*/
function measureItem(ctx, item, metrics) {
const swatch = item.colors.length ? metrics.dot * 2 + metrics.textGap : 0;
return swatch + ctx.measureText(item.text).width + metrics.itemGap;
}
/**
* @param {CanvasRenderingContext2D} ctx
* @param {LegendItem[]} items
* @param {number} width
* @param {LegendMetrics} metrics
*/
function layoutLegend(ctx, items, width, metrics) {
/** @type {LegendItem[][]} */
const rows = [];
/** @type {LegendItem[]} */
let row = [];
let rowWidth = 0;
for (const item of items) {
const itemWidth = measureItem(ctx, item, metrics);
if (row.length && rowWidth + itemWidth > width) {
rows.push(row);
row = [];
rowWidth = 0;
}
row.push(item);
rowWidth += itemWidth;
}
if (row.length) rows.push(row);
return rows;
}
/**
* @param {CanvasRenderingContext2D} ctx
* @param {string[]} itemColors
* @param {number} x
* @param {number} y
* @param {number} radius
*/
function drawSwatch(ctx, itemColors, x, y, radius) {
ctx.save();
ctx.beginPath();
ctx.arc(x + radius, y, radius, 0, Math.PI * 2);
ctx.clip();
itemColors.forEach((color, index) => {
const h = (radius * 2) / itemColors.length;
ctx.fillStyle = color;
ctx.fillRect(x, y - radius + index * h, radius * 2, h);
});
ctx.restore();
}
/**
* @param {CanvasRenderingContext2D} ctx
* @param {LegendItem[][]} rows
* @param {number} x
* @param {number} y
* @param {LegendMetrics} metrics
*/
function drawLegend(ctx, rows, x, y, metrics) {
ctx.textAlign = "left";
ctx.textBaseline = "middle";
rows.forEach((row, rowIndex) => {
let itemX = x;
const itemY =
y + metrics.lineHeight / 2 + rowIndex * (metrics.lineHeight + metrics.rowGap);
for (const item of row) {
if (item.colors.length) {
drawSwatch(ctx, item.colors, itemX, itemY, metrics.dot);
itemX += metrics.dot * 2 + metrics.textGap;
}
const textWidth = ctx.measureText(item.text).width;
ctx.fillStyle = item.muted ? colors.gray() : colors.default();
ctx.fillText(item.text, itemX, itemY);
itemX += textWidth + metrics.itemGap;
}
});
}
/** @param {HTMLCanvasElement} canvas */
function openCanvas(canvas) {
canvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
window.open(url, "_blank");
setTimeout(() => URL.revokeObjectURL(url), 100);
}, "image/png");
}
/**
* @param {Object} args
* @param {HTMLCanvasElement} args.screenshot
* @param {number} args.chartWidth
* @param {HTMLElement} args.chartElement
* @param {HTMLElement} args.parent
* @param {{ element: HTMLElement }[]} args.legends
*/
export function capture({
screenshot,
chartWidth,
chartElement,
parent,
legends,
}) {
const dpr =
chartWidth > 0 ? screenshot.width / chartWidth : window.devicePixelRatio;
const scale = (/** @type {number} */ value) => Math.round(value * dpr);
const parentRect = parent.getBoundingClientRect();
const chartRect = chartElement.getBoundingClientRect();
const parentStyle = getComputedStyle(parent);
const rootStyle = getComputedStyle(document.documentElement);
const pad = scale(
cssPx(
parentStyle.paddingLeft,
cssPx(rootStyle.getPropertyValue("--main-padding"), 32),
),
);
const chartX = scale(chartRect.left - parentRect.left);
const chartY = scale(chartRect.top - parentRect.top);
const title = parent.querySelector("h1");
const titleText = title?.textContent?.trim() ?? "";
const titleStyle = title
? getComputedStyle(title)
: getComputedStyle(document.documentElement);
const titleSize = scale(cssPx(titleStyle.fontSize, 32));
const legendStyle = getComputedStyle(legends[0].element);
const metrics = {
dot: scale(5),
fontSize: scale(cssPx(legendStyle.fontSize, 12)),
itemGap: scale(16),
lineHeight: scale(
cssPx(legendStyle.lineHeight, cssPx(legendStyle.fontSize, 12) * 1.333),
),
rowGap: scale(3),
textGap: scale(4),
};
const top = captureLegend(legends[0].element, parentRect, scale);
const bottom = captureLegend(legends[1].element, parentRect, scale);
const measureCtx = document.createElement("canvas").getContext("2d");
if (!measureCtx) return;
setFont(measureCtx, legendStyle, metrics.fontSize);
const topRows = layoutLegend(measureCtx, top.items, top.width, metrics);
const bottomRows = layoutLegend(measureCtx, bottom.items, bottom.width, metrics);
const canvas = document.createElement("canvas");
canvas.width = Math.max(screenshot.width + chartX, scale(parentRect.width));
canvas.height = Math.max(
chartY + screenshot.height + pad,
scale(parentRect.height),
);
const ctx = canvas.getContext("2d");
if (!ctx) return;
const bodyBg = getComputedStyle(document.body).backgroundColor;
const htmlBg = getComputedStyle(document.documentElement).backgroundColor;
ctx.fillStyle = bodyBg === "rgba(0, 0, 0, 0)" ? htmlBg : bodyBg;
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (titleText && title instanceof HTMLElement) {
const rect = title.getBoundingClientRect();
setFont(
ctx,
titleStyle,
titleSize,
scale(cssPx(titleStyle.letterSpacing)),
);
ctx.fillStyle = colors.default();
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(
titleText,
scale(rect.left - parentRect.left),
scale(rect.top - parentRect.top + rect.height / 2),
);
}
ctx.drawImage(screenshot, chartX, chartY);
setFont(ctx, legendStyle, metrics.fontSize);
drawLegend(ctx, topRows, top.x, top.y, metrics);
drawLegend(ctx, bottomRows, bottom.x, bottom.y, metrics);
ctx.fillStyle = colors.gray();
setFont(ctx, legendStyle, metrics.fontSize);
ctx.textAlign = "right";
ctx.textBaseline = "bottom";
ctx.fillText(window.location.host, canvas.width - pad, canvas.height - pad / 2);
openCanvas(canvas);
}