global: snap

This commit is contained in:
nym21
2026-04-07 13:49:02 +02:00
parent e91f1386b1
commit f022f62cce
24 changed files with 746 additions and 372 deletions

View File

@@ -0,0 +1,119 @@
import { ios, canShare } from "../env.js";
import { style } from "../elements.js";
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
*/
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;
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;
// 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 {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);
}
};
// 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);
}
// Top legend
if (hasTopLegend) {
drawLegend(legends[0].element, pad + titleOffset + topLegendOffset / 2);
}
// Chart
ctx.drawImage(screenshot, pad, pad + titleOffset + topLegendOffset);
// Bottom legend
if (hasBottomLegend) {
drawLegend(
legends[1].element,
pad +
titleOffset +
topLegendOffset +
screenshot.height +
legendHeight / 2,
);
}
// 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,
);
// Open in new tab
canvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
window.open(url, "_blank");
setTimeout(() => URL.revokeObjectURL(url), 100);
}, "image/png");
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,155 @@
import { createLabeledInput, createSpanName } from "../dom.js";
import { stringToId } from "../format.js";
/** @param {HTMLElement} el */
function captureScroll(el) {
el.addEventListener("wheel", (e) => e.stopPropagation(), { passive: true });
el.addEventListener("touchstart", (e) => e.stopPropagation(), {
passive: true,
});
el.addEventListener("touchmove", (e) => e.stopPropagation(), {
passive: true,
});
}
/**
* Creates a `<legend>` with a scrollable `<div>`.
* Call `setPrefix(el)` to insert a prefix element followed by a `|` separator.
* Append further content to `scroller`.
*/
export function createLegend() {
const element = /** @type {HTMLLegendElement} */ (
window.document.createElement("legend")
);
const scroller = /** @type {HTMLDivElement} */ (
window.document.createElement("div")
);
element.append(scroller);
captureScroll(scroller);
const separator = window.document.createElement("span");
separator.textContent = "|";
captureScroll(separator);
return {
element,
scroller,
/** @param {HTMLElement} el */
setPrefix(el) {
const prev = separator.previousSibling;
if (prev) {
prev.replaceWith(el);
} else {
scroller.prepend(el, separator);
}
captureScroll(el);
},
};
}
export function createSeriesLegend() {
const legend = createLegend();
const items = window.document.createElement("div");
legend.scroller.append(items);
captureScroll(items);
/** @type {AnySeries | null} */
let hoveredSeries = null;
/** @type {Map<AnySeries, { span: HTMLSpanElement, color: Color }[]>} */
const seriesColorSpans = new Map();
/** @param {AnySeries | null} series */
function setHovered(series) {
if (hoveredSeries === series) return;
hoveredSeries = series;
for (const [entrySeries, colorSpans] of seriesColorSpans) {
const shouldHighlight = !hoveredSeries || hoveredSeries === entrySeries;
shouldHighlight ? entrySeries.highlight() : entrySeries.tame();
for (const { span, color } of colorSpans) {
span.style.backgroundColor = color.highlight(shouldHighlight);
}
}
}
/** @type {HTMLElement[]} */
const legends = [];
return {
element: legend.element,
setPrefix: legend.setPrefix,
/**
* @param {Object} args
* @param {AnySeries} args.series
* @param {string} args.name
* @param {number} args.order
* @param {Color[]} args.colors
*/
addOrReplace({ series, name, colors, order }) {
const div = window.document.createElement("div");
const prev = legends[order];
if (prev) {
prev.replaceWith(div);
} else {
const elementAtOrder = Array.from(items.children).at(order);
if (elementAtOrder) {
elementAtOrder.before(div);
} else {
items.append(div);
}
}
legends[order] = div;
const { label } = createLabeledInput({
inputId: stringToId(`legend-${series.id}`),
inputName: stringToId(`selected-${series.id}`),
inputValue: "value",
title: "Click to toggle",
inputChecked: series.active.value,
onClick: () => {
series.setActive(!series.active.value);
},
type: "checkbox",
});
const spanMain = window.document.createElement("span");
spanMain.classList.add("main");
label.append(spanMain);
const spanName = createSpanName(name);
spanMain.append(spanName);
div.append(label);
label.addEventListener("mouseover", () => setHovered(series));
label.addEventListener("mouseleave", () => setHovered(null));
const spanColors = window.document.createElement("span");
spanColors.classList.add("colors");
spanMain.prepend(spanColors);
/** @type {{ span: HTMLSpanElement, color: Color }[]} */
const colorSpans = [];
colors.forEach((color) => {
const spanColor = window.document.createElement("span");
spanColor.style.backgroundColor = color.highlight(true);
spanColors.append(spanColor);
colorSpans.push({ span: spanColor, color });
});
seriesColorSpans.set(series, colorSpans);
if (series.url) {
const anchor = window.document.createElement("a");
anchor.href = series.url;
anchor.target = "_blank";
anchor.rel = "noopener noreferrer";
anchor.title = "Open the series data in a new tab";
div.append(anchor);
}
},
/**
* @param {number} start
*/
removeFrom(start) {
legends.splice(start).forEach((child) => child.remove());
},
};
}

View File

@@ -1,4 +1,3 @@
import { oklchToRgba } from "../chart/oklch.js";
import { dark } from "./theme.js";
/** @type {Map<string, string>} */
@@ -275,3 +274,128 @@ export const colors = {
at,
};
// ---
// oklch
// ---
/**
* @param {readonly [number, number, number, number, number, number, number, number, number]} A
* @param {readonly [number, number, number]} B
*/
function multiplyMatrices(A, B) {
return /** @type {const} */ ([
A[0] * B[0] + A[1] * B[1] + A[2] * B[2],
A[3] * B[0] + A[4] * B[1] + A[5] * B[2],
A[6] * B[0] + A[7] * B[1] + A[8] * B[2],
]);
}
/** @param {readonly [number, number, number]} param0 */
function oklch2oklab([l, c, h]) {
return /** @type {const} */ ([
l,
isNaN(h) ? 0 : c * Math.cos((h * Math.PI) / 180),
isNaN(h) ? 0 : c * Math.sin((h * Math.PI) / 180),
]);
}
/** @param {readonly [number, number, number]} rgb */
function srgbLinear2rgb(rgb) {
return rgb.map((c) =>
Math.abs(c) > 0.0031308
? (c < 0 ? -1 : 1) * (1.055 * Math.abs(c) ** (1 / 2.4) - 0.055)
: 12.92 * c,
);
}
/** @param {readonly [number, number, number]} lab */
function oklab2xyz(lab) {
const LMSg = multiplyMatrices(
[
1, 0.3963377773761749, 0.2158037573099136, 1, -0.1055613458156586,
-0.0638541728258133, 1, -0.0894841775298119, -1.2914855480194092,
],
lab,
);
const LMS = /** @type {[number, number, number]} */ (
LMSg.map((val) => val ** 3)
);
return multiplyMatrices(
[
1.2268798758459243, -0.5578149944602171, 0.2813910456659647,
-0.0405757452148008, 1.112286803280317, -0.0717110580655164,
-0.0763729366746601, -0.4214933324022432, 1.5869240198367816,
],
LMS,
);
}
/** @param {readonly [number, number, number]} xyz */
function xyz2rgbLinear(xyz) {
return multiplyMatrices(
[
3.2409699419045226, -1.537383177570094, -0.4986107602930034,
-0.9692436362808796, 1.8759675015077202, 0.04155505740717559,
0.05563007969699366, -0.20397695888897652, 1.0569715142428786,
],
xyz,
);
}
/** @type {Map<string, [number, number, number, number]>} */
const conversionCache = new Map();
/**
* Parse oklch string and return rgba tuple
* @param {string} oklch
* @returns {[number, number, number, number] | null}
*/
function parseOklch(oklch) {
if (!oklch.startsWith("oklch(")) return null;
const cached = conversionCache.get(oklch);
if (cached) return cached;
let str = oklch.slice(6, -1); // remove "oklch(" and ")"
let alpha = 1;
const slashIdx = str.indexOf(" / ");
if (slashIdx !== -1) {
const alphaPart = str.slice(slashIdx + 3);
alpha = alphaPart.includes("%")
? Number(alphaPart.replace("%", "")) / 100
: Number(alphaPart);
str = str.slice(0, slashIdx);
}
const parts = str.split(" ");
const l = parts[0].includes("%")
? Number(parts[0].replace("%", "")) / 100
: Number(parts[0]);
const c = Number(parts[1]);
const h = Number(parts[2]);
const rgb = srgbLinear2rgb(
xyz2rgbLinear(oklab2xyz(oklch2oklab([l, c, h]))),
).map((v) => Math.max(Math.min(Math.round(v * 255), 255), 0));
const result = /** @type {[number, number, number, number]} */ ([
...rgb,
alpha,
]);
conversionCache.set(oklch, result);
return result;
}
/**
* Convert oklch string to rgba string
* @param {string} oklch
* @returns {string}
*/
export function oklchToRgba(oklch) {
const result = parseOklch(oklch);
if (!result) return oklch;
const [r, g, b, a] = result;
return a === 1 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${a})`;
}

View File

@@ -21,73 +21,27 @@ export const serdeBool = {
export const INDEX_LABEL = /** @type {const} */ ({
height: "blk",
minute10: "10mn", minute30: "30mn",
hour1: "1h", hour4: "4h", hour12: "12h",
day1: "1d", day3: "3d", week1: "1w",
month1: "1m", month3: "3m", month6: "6m",
year1: "1y", year10: "10y",
halving: "halv", epoch: "epch",
minute10: "10mn",
minute30: "30mn",
hour1: "1h",
hour4: "4h",
hour12: "12h",
day1: "1d",
day3: "3d",
week1: "1w",
month1: "1m",
month3: "3m",
month6: "6m",
year1: "1y",
year10: "10y",
halving: "halv",
epoch: "epch",
});
/** @typedef {typeof INDEX_LABEL} IndexLabelMap */
/** @typedef {keyof IndexLabelMap} ChartableIndex */
/** @typedef {IndexLabelMap[ChartableIndex]} IndexLabel */
export const INDEX_FROM_LABEL = fromEntries(entries(INDEX_LABEL).map(([k, v]) => [v, k]));
/**
* @typedef {"" |
* "%all" |
* "%cmcap" |
* "%cp+l" |
* "%mcap" |
* "%pnl" |
* "%rcap" |
* "%self" |
* "/sec" |
* "address data" |
* "block" |
* "blocks" |
* "bool" |
* "btc" |
* "bytes" |
* "cents" |
* "coinblocks" |
* "coindays" |
* "constant" |
* "count" |
* "date" |
* "days" |
* "difficulty" |
* "epoch" |
* "gigabytes" |
* "h/s" |
* "hash" |
* "height" |
* "id" |
* "index" |
* "len" |
* "locktime" |
* "percentage" |
* "position" |
* "ratio" |
* "sat/vb" |
* "satblocks" |
* "satdays" |
* "sats" |
* "sats/(ph/s)/day" |
* "sats/(th/s)/day" |
* "sd" |
* "secs" |
* "timestamp" |
* "tx" |
* "type" |
* "usd" |
* "usd/(ph/s)/day" |
* "usd/(th/s)/day" |
* "vb" |
* "version" |
* "wu" |
* "years" |
* "" } Unit
*/
export const INDEX_FROM_LABEL = fromEntries(
entries(INDEX_LABEL).map(([k, v]) => [v, k]),
);

View File

@@ -0,0 +1,23 @@
/**
* Convert period ID to readable name
* @param {string} id
* @param {boolean} [compoundAdjective]
*/
export function periodIdToName(id, compoundAdjective) {
const num = parseInt(id);
const s = compoundAdjective || num === 1 ? "" : "s";
switch (id.slice(-1)) {
case "h":
return `${num} Hour${s}`;
case "d":
return `${num} Day${s}`;
case "w":
return `${num} Week${s}`;
case "m":
return `${num} Month${s}`;
case "y":
return `${num} Year${s}`;
default:
return id;
}
}