mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-10 15:03:32 -07:00
399 lines
11 KiB
JavaScript
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);
|
|
}
|