mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-12 07:53:32 -07:00
website: redesign part 11
This commit is contained in:
@@ -5,111 +5,281 @@ import { colors } from "../colors.js";
|
||||
export const canCapture = !ios || canShare;
|
||||
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {HTMLCanvasElement} args.screenshot
|
||||
* @param {number} args.chartWidth
|
||||
* @param {HTMLElement} args.parent
|
||||
* @param {{ element: HTMLElement }[]} args.legends
|
||||
* @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
|
||||
*/
|
||||
export function capture({ screenshot, chartWidth, parent, legends }) {
|
||||
const dpr = screenshot.width / chartWidth;
|
||||
const pad = Math.round(16 * dpr);
|
||||
const fontSize = Math.round(14 * dpr);
|
||||
const titleFontSize = Math.round(20 * dpr);
|
||||
const circleRadius = Math.round(5 * dpr);
|
||||
const legendHeight = Math.round(28 * dpr);
|
||||
const titleHeight = Math.round(36 * dpr);
|
||||
|
||||
const title = (parent.querySelector("h1")?.textContent ?? "").toUpperCase();
|
||||
const hasTitle = title.length > 0;
|
||||
const hasTopLegend = legends[0].element.children.length > 0;
|
||||
const hasBottomLegend = legends[1].element.children.length > 0;
|
||||
const titleOffset = hasTitle ? titleHeight : 0;
|
||||
const topLegendOffset = hasTopLegend ? legendHeight : 0;
|
||||
const bottomOffset = hasBottomLegend ? legendHeight : 0;
|
||||
/**
|
||||
* @param {string} value
|
||||
* @param {number} [fallback]
|
||||
*/
|
||||
function cssPx(value, fallback = 0) {
|
||||
return Number.parseFloat(value) || fallback;
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = screenshot.width + pad * 2;
|
||||
canvas.height =
|
||||
screenshot.height + pad * 2 + titleOffset + topLegendOffset + bottomOffset;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
/**
|
||||
* @param {CSSStyleDeclaration} computedStyle
|
||||
* @param {number} size
|
||||
*/
|
||||
function canvasFont(computedStyle, size) {
|
||||
return [
|
||||
computedStyle.fontStyle,
|
||||
computedStyle.fontWeight,
|
||||
`${size}px`,
|
||||
computedStyle.fontFamily || style.fontFamily,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
// Background
|
||||
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);
|
||||
/**
|
||||
* @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 {HTMLElement} legendEl @param {number} y */
|
||||
const drawLegend = (legendEl, y) => {
|
||||
ctx.font = `${fontSize}px ${style.fontFamily}`;
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
let x = pad;
|
||||
for (const div of legendEl.children) {
|
||||
const label = div.querySelector("label");
|
||||
if (!label) continue;
|
||||
const input = label.querySelector("input");
|
||||
if (input && !input.checked) continue;
|
||||
// Draw color circles
|
||||
const colorSpans = label.querySelectorAll(".colors span");
|
||||
for (const span of colorSpans) {
|
||||
ctx.fillStyle = /** @type {HTMLElement} */ (span).style.backgroundColor;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + circleRadius, y, circleRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
x += circleRadius * 2 + Math.round(2 * dpr);
|
||||
}
|
||||
// Draw name
|
||||
const name = label.querySelector(".name")?.textContent ?? "";
|
||||
ctx.fillStyle = colors.default();
|
||||
ctx.fillText(name, x + Math.round(4 * dpr), y);
|
||||
x += ctx.measureText(name).width + Math.round(20 * dpr);
|
||||
/**
|
||||
* @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),
|
||||
};
|
||||
}
|
||||
|
||||
// Title
|
||||
if (hasTitle) {
|
||||
ctx.font = `${titleFontSize}px ${style.fontFamily}`;
|
||||
ctx.fillStyle = colors.default();
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(title, pad, pad + titleHeight / 2);
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
// Top legend
|
||||
if (hasTopLegend) {
|
||||
drawLegend(legends[0].element, pad + titleOffset + topLegendOffset / 2);
|
||||
}
|
||||
if (row.length) rows.push(row);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Chart
|
||||
ctx.drawImage(screenshot, pad, pad + titleOffset + topLegendOffset);
|
||||
/**
|
||||
* @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();
|
||||
|
||||
// Bottom legend
|
||||
if (hasBottomLegend) {
|
||||
drawLegend(
|
||||
legends[1].element,
|
||||
pad +
|
||||
titleOffset +
|
||||
topLegendOffset +
|
||||
screenshot.height +
|
||||
legendHeight / 2,
|
||||
);
|
||||
}
|
||||
itemColors.forEach((color, index) => {
|
||||
const h = (radius * 2) / itemColors.length;
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x, y - radius + index * h, radius * 2, h);
|
||||
});
|
||||
|
||||
// Watermark
|
||||
ctx.fillStyle = colors.gray();
|
||||
ctx.font = `${fontSize}px ${style.fontFamily}`;
|
||||
ctx.textAlign = "right";
|
||||
ctx.textBaseline = "bottom";
|
||||
ctx.fillText(
|
||||
window.location.host,
|
||||
canvas.width - pad,
|
||||
canvas.height - pad / 2,
|
||||
);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Open in new tab
|
||||
/**
|
||||
* @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);
|
||||
@@ -117,3 +287,112 @@ export function capture({ screenshot, chartWidth, parent, legends }) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ import { Unit } from "../units.js";
|
||||
* @typedef {Object} Legend
|
||||
* @property {HTMLLegendElement} element
|
||||
* @property {function(HTMLElement): void} setPrefix
|
||||
* @property {function(): void} clearPrefix
|
||||
* @property {function({ series: AnySeries, name: string, order: number, colors: Color[] }): void} addOrReplace
|
||||
* @property {function(number): void} removeFrom
|
||||
*/
|
||||
@@ -1620,6 +1621,7 @@ export function createChart({ parent, brk, fitContent }) {
|
||||
const units = Array.from(map.keys());
|
||||
if (!units.length) {
|
||||
blueprints.panes[paneIndex].unit = null;
|
||||
legends[paneIndex].clearPrefix();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1678,6 +1680,7 @@ export function createChart({ parent, brk, fitContent }) {
|
||||
capture({
|
||||
screenshot: ichart.takeScreenshot(),
|
||||
chartWidth: chartEl.clientWidth,
|
||||
chartElement: chartEl,
|
||||
parent,
|
||||
legends,
|
||||
});
|
||||
|
||||
@@ -29,20 +29,23 @@ export function createLegend() {
|
||||
|
||||
const separator = createSpan("|");
|
||||
captureScroll(separator);
|
||||
/** @type {HTMLElement | null} */
|
||||
let prefix = null;
|
||||
|
||||
return {
|
||||
element,
|
||||
scroller,
|
||||
/** @param {HTMLElement} el */
|
||||
setPrefix(el) {
|
||||
const prev = separator.previousSibling;
|
||||
if (prev) {
|
||||
prev.replaceWith(el);
|
||||
} else {
|
||||
scroller.prepend(el, separator);
|
||||
}
|
||||
prefix ? prefix.replaceWith(el) : scroller.prepend(el, separator);
|
||||
prefix = el;
|
||||
captureScroll(el);
|
||||
},
|
||||
clearPrefix() {
|
||||
prefix?.remove();
|
||||
prefix = null;
|
||||
separator.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,6 +79,7 @@ export function createSeriesLegend() {
|
||||
return {
|
||||
element: legend.element,
|
||||
setPrefix: legend.setPrefix,
|
||||
clearPrefix: legend.clearPrefix,
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {AnySeries} args.series
|
||||
|
||||
Reference in New Issue
Block a user