website: redesign part 11

This commit is contained in:
nym21
2026-06-06 22:29:33 +02:00
parent 66dc7cd8f5
commit 041c542046
49 changed files with 17275 additions and 193 deletions
+370 -91
View File
@@ -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);
}
+3
View File
@@ -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,
});
+10 -6
View File
@@ -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