mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-11 15:33:33 -07:00
website: redesign part 1
This commit is contained in:
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Typed Object.entries that preserves key types
|
||||
* @template {Record<string, unknown>} T
|
||||
* @param {T} obj
|
||||
* @returns {[keyof T & string, T[keyof T & string]][]}
|
||||
*/
|
||||
export const entries = (obj) => /** @type {[keyof T & string, T[keyof T & string]][]} */ (Object.entries(obj));
|
||||
|
||||
/**
|
||||
* Typed Object.fromEntries that preserves key/value types
|
||||
* @template {string} K
|
||||
* @template V
|
||||
* @param {Iterable<readonly [K, V]>} pairs
|
||||
* @returns {Record<K, V>}
|
||||
*/
|
||||
export const fromEntries = (pairs) => /** @type {Record<K, V>} */ (Object.fromEntries(pairs));
|
||||
|
||||
/**
|
||||
* Type-safe includes that narrows the value type
|
||||
* @template T
|
||||
* @param {readonly T[]} arr
|
||||
* @param {unknown} value
|
||||
* @returns {value is T}
|
||||
*/
|
||||
export const includes = (arr, value) => arr.includes(/** @type {T} */ (value));
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {readonly T[]} arr
|
||||
* @returns {T}
|
||||
*/
|
||||
export function randomFromArray(arr) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
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
@@ -1,154 +0,0 @@
|
||||
import { createLabeledInput, createSpan, 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 = createSpan("|");
|
||||
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());
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { BrkClient } from "../modules/brk-client/index.js";
|
||||
|
||||
// const brk = new BrkClient("https://bitview.space");
|
||||
const brk = new BrkClient("/");
|
||||
|
||||
console.log(`VERSION = ${brk.VERSION}`);
|
||||
|
||||
export { brk };
|
||||
@@ -1,403 +0,0 @@
|
||||
import { dark } from "./theme.js";
|
||||
|
||||
/** @type {Map<string, string>} */
|
||||
const rgbaCache = new Map();
|
||||
|
||||
/**
|
||||
* Convert oklch to rgba with caching
|
||||
* @param {string} color - oklch color string
|
||||
*/
|
||||
function toRgba(color) {
|
||||
if (color === "transparent") return color;
|
||||
const cached = rgbaCache.get(color);
|
||||
if (cached) return cached;
|
||||
const rgba = oklchToRgba(color);
|
||||
rgbaCache.set(color, rgba);
|
||||
return rgba;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce color opacity to 50% for dimming effect
|
||||
* @param {string} color - oklch color string
|
||||
*/
|
||||
function tameColor(color) {
|
||||
if (color === "transparent") return color;
|
||||
return `${color.slice(0, -1)} / 25%)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} ColorMethods
|
||||
* @property {() => string} tame - Returns tamed (50% opacity) version
|
||||
* @property {(highlighted: boolean) => string} highlight - Returns normal if highlighted, tamed otherwise
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {(() => string) & ColorMethods} Color
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a Color object that is callable and has utility methods
|
||||
* @param {() => string} getter
|
||||
* @returns {Color}
|
||||
*/
|
||||
function createColor(getter) {
|
||||
const color = /** @type {Color} */ (() => toRgba(getter()));
|
||||
color.tame = () => toRgba(tameColor(getter()));
|
||||
color.highlight = (highlighted) =>
|
||||
highlighted ? toRgba(getter()) : toRgba(tameColor(getter()));
|
||||
return color;
|
||||
}
|
||||
|
||||
const globalComputedStyle = getComputedStyle(window.document.documentElement);
|
||||
|
||||
/**
|
||||
* Resolve a light-dark() value based on current theme
|
||||
* @param {string} value
|
||||
*/
|
||||
function resolveLightDark(value) {
|
||||
if (value.startsWith("light-dark(")) {
|
||||
const [light, _dark] = value.slice(11, -1).split(", ");
|
||||
return dark ? _dark : light;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
function getColor(name) {
|
||||
return globalComputedStyle.getPropertyValue(`--${name}`).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} property
|
||||
*/
|
||||
function getLightDarkValue(property) {
|
||||
return resolveLightDark(
|
||||
globalComputedStyle.getPropertyValue(property).trim(),
|
||||
);
|
||||
}
|
||||
|
||||
const palette = {
|
||||
red: createColor(() => getColor("red")),
|
||||
orange: createColor(() => getColor("orange")),
|
||||
amber: createColor(() => getColor("amber")),
|
||||
yellow: createColor(() => getColor("yellow")),
|
||||
avocado: createColor(() => getColor("avocado")),
|
||||
lime: createColor(() => getColor("lime")),
|
||||
green: createColor(() => getColor("green")),
|
||||
emerald: createColor(() => getColor("emerald")),
|
||||
teal: createColor(() => getColor("teal")),
|
||||
cyan: createColor(() => getColor("cyan")),
|
||||
sky: createColor(() => getColor("sky")),
|
||||
blue: createColor(() => getColor("blue")),
|
||||
indigo: createColor(() => getColor("indigo")),
|
||||
violet: createColor(() => getColor("violet")),
|
||||
purple: createColor(() => getColor("purple")),
|
||||
fuchsia: createColor(() => getColor("fuchsia")),
|
||||
pink: createColor(() => getColor("pink")),
|
||||
rose: createColor(() => getColor("rose")),
|
||||
};
|
||||
|
||||
const paletteArr = Object.values(palette);
|
||||
|
||||
/**
|
||||
* Get a palette color by index, spreading small groups for better separation
|
||||
* @param {number} index
|
||||
* @param {number} [length]
|
||||
*/
|
||||
function at(index, length) {
|
||||
const n = paletteArr.length;
|
||||
if (length && length <= n / 2) {
|
||||
return paletteArr[Math.round((index * n) / length) % n];
|
||||
}
|
||||
return paletteArr[index % n];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a named color map from keys, using position-based palette assignment
|
||||
* @param {readonly string[]} keys
|
||||
*/
|
||||
function seq(keys) {
|
||||
return Object.fromEntries(keys.map((key, i) => [key, at(i, keys.length)]));
|
||||
}
|
||||
|
||||
export const colors = {
|
||||
transparent: createColor(() => "transparent"),
|
||||
default: createColor(() => getLightDarkValue("--color")),
|
||||
background: createColor(() => getLightDarkValue("--background-color")),
|
||||
gray: createColor(() => getColor("gray")),
|
||||
border: createColor(() => getLightDarkValue("--border-color")),
|
||||
offBorder: createColor(() => getLightDarkValue("--off-border-color")),
|
||||
|
||||
// Directional
|
||||
profit: palette.green,
|
||||
loss: palette.red,
|
||||
bitcoin: palette.orange,
|
||||
usd: palette.green,
|
||||
|
||||
// Bi-color pairs for baselines (spaced by 2 in palette)
|
||||
bi: {
|
||||
/** @type {[Color, Color]} */
|
||||
p1: [palette.green, palette.red],
|
||||
/** @type {[Color, Color]} */
|
||||
p2: [palette.teal, palette.amber],
|
||||
/** @type {[Color, Color]} */
|
||||
p3: [palette.sky, palette.avocado],
|
||||
},
|
||||
|
||||
// Cointime economics
|
||||
liveliness: palette.pink,
|
||||
vaulted: palette.lime,
|
||||
active: palette.rose,
|
||||
activity: palette.purple,
|
||||
cointime: palette.yellow,
|
||||
destroyed: palette.red,
|
||||
created: palette.orange,
|
||||
stored: palette.green,
|
||||
transfer: palette.cyan,
|
||||
balanced: palette.indigo,
|
||||
terminal: palette.fuchsia,
|
||||
delta: palette.violet,
|
||||
|
||||
// Valuations
|
||||
realized: palette.orange,
|
||||
investor: palette.fuchsia,
|
||||
capitalized: palette.green,
|
||||
thermo: palette.emerald,
|
||||
trueMarketMean: palette.blue,
|
||||
vocdd: palette.purple,
|
||||
hodlBank: palette.blue,
|
||||
reserveRisk: palette.orange,
|
||||
|
||||
// Comparisons (base vs adjusted)
|
||||
base: palette.orange,
|
||||
adjusted: palette.purple,
|
||||
adjustedCreated: palette.lime,
|
||||
adjustedDestroyed: palette.pink,
|
||||
|
||||
// Realized P&L
|
||||
gross: palette.yellow,
|
||||
regret: palette.pink,
|
||||
|
||||
// Ratios
|
||||
plRatio: palette.yellow,
|
||||
|
||||
// Mining
|
||||
mining: seq(["coinbase", "subsidy", "fee"]),
|
||||
|
||||
// Network
|
||||
segwit: palette.cyan,
|
||||
|
||||
// Entity (transactions, inputs, outputs)
|
||||
entity: seq(["tx", "input", "output"]),
|
||||
|
||||
// Technical indicators
|
||||
indicator: {
|
||||
main: palette.indigo,
|
||||
fast: palette.blue,
|
||||
slow: palette.orange,
|
||||
upper: palette.green,
|
||||
lower: palette.red,
|
||||
mid: palette.yellow,
|
||||
},
|
||||
|
||||
stat: {
|
||||
sum: palette.blue,
|
||||
cumulative: palette.indigo,
|
||||
avg: palette.orange,
|
||||
max: palette.green,
|
||||
pct90: palette.cyan,
|
||||
pct75: palette.blue,
|
||||
median: palette.yellow,
|
||||
pct25: palette.violet,
|
||||
pct10: palette.fuchsia,
|
||||
min: palette.red,
|
||||
},
|
||||
|
||||
// Ratio percentile bands (extreme values)
|
||||
ratioPct: {
|
||||
_99_5: palette.red,
|
||||
_99: palette.orange,
|
||||
_98: palette.amber,
|
||||
_95: palette.yellow,
|
||||
_5: palette.cyan,
|
||||
_2: palette.sky,
|
||||
_1: palette.blue,
|
||||
_0_5: palette.indigo,
|
||||
},
|
||||
|
||||
// Standard deviation bands (warm = positive, cool = negative)
|
||||
sd: {
|
||||
_0: palette.lime,
|
||||
p05: palette.yellow,
|
||||
m05: palette.teal,
|
||||
p1: palette.amber,
|
||||
m1: palette.cyan,
|
||||
p15: palette.orange,
|
||||
m15: palette.sky,
|
||||
p2: palette.red,
|
||||
m2: palette.blue,
|
||||
p25: palette.rose,
|
||||
m25: palette.indigo,
|
||||
p3: palette.pink,
|
||||
m3: palette.violet,
|
||||
},
|
||||
|
||||
time: {
|
||||
_24h: palette.red,
|
||||
_1w: palette.yellow,
|
||||
_1m: palette.green,
|
||||
_1y: palette.blue,
|
||||
all: palette.purple,
|
||||
},
|
||||
|
||||
term: {
|
||||
short: palette.yellow,
|
||||
long: palette.fuchsia,
|
||||
},
|
||||
|
||||
scriptType: {
|
||||
p2pk65: palette.rose,
|
||||
p2pk33: palette.pink,
|
||||
p2pkh: palette.orange,
|
||||
p2ms: palette.teal,
|
||||
p2sh: palette.green,
|
||||
p2wpkh: palette.red,
|
||||
p2wsh: palette.yellow,
|
||||
p2tr: palette.cyan,
|
||||
p2a: palette.indigo,
|
||||
opReturn: palette.purple,
|
||||
unknown: palette.violet,
|
||||
empty: palette.fuchsia,
|
||||
},
|
||||
|
||||
arr: paletteArr,
|
||||
|
||||
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})`;
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function getElementById(id) {
|
||||
const element = window.document.getElementById(id);
|
||||
if (!element) throw `Element with id = "${id}" should exist`;
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} element
|
||||
*/
|
||||
export function isHidden(element) {
|
||||
return element.tagName !== "BODY" && !element.offsetParent;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
* @param {VoidFunction} callback
|
||||
*/
|
||||
export function onFirstIntersection(element, callback) {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
if (entries[i].isIntersecting) {
|
||||
callback();
|
||||
observer.disconnect();
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
export function createSpan(text) {
|
||||
const span = window.document.createElement("span");
|
||||
span.textContent = text;
|
||||
return span;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
export function createSmall(text) {
|
||||
const small = window.document.createElement("small");
|
||||
small.textContent = text;
|
||||
return small;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
export function createSpanName(name) {
|
||||
const spanName = window.document.createElement("span");
|
||||
spanName.classList.add("name");
|
||||
const [first, second, third] = name.split(" - ");
|
||||
spanName.innerHTML = first;
|
||||
|
||||
if (second) {
|
||||
const smallRest = window.document.createElement("small");
|
||||
smallRest.innerHTML = ` — ${second}`;
|
||||
spanName.append(smallRest);
|
||||
|
||||
if (third) {
|
||||
throw "Shouldn't have more than one dash";
|
||||
}
|
||||
}
|
||||
|
||||
return spanName;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} arg
|
||||
* @param {string} arg.href
|
||||
* @param {string} arg.title
|
||||
* @param {string} [arg.text]
|
||||
* @param {boolean} [arg.blank]
|
||||
* @param {VoidFunction} [arg.onClick]
|
||||
* @param {boolean} [arg.preventDefault]
|
||||
*/
|
||||
export function createAnchorElement({
|
||||
text,
|
||||
href,
|
||||
blank,
|
||||
onClick,
|
||||
title,
|
||||
preventDefault,
|
||||
}) {
|
||||
const anchor = window.document.createElement("a");
|
||||
anchor.href = href;
|
||||
anchor.title = title.toUpperCase();
|
||||
|
||||
if (text) {
|
||||
anchor.innerText = text;
|
||||
}
|
||||
|
||||
if (blank) {
|
||||
anchor.target = "_blank";
|
||||
anchor.rel = "noopener noreferrer";
|
||||
}
|
||||
|
||||
if (onClick || preventDefault) {
|
||||
if (onClick) {
|
||||
anchor.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
onClick();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return anchor;
|
||||
}
|
||||
|
||||
// Intercept plain left-clicks for SPA nav; let modified clicks
|
||||
// (cmd/ctrl/shift/middle) and right-click fall through so the
|
||||
// anchor's native open-in-new-tab / context-menu behavior works.
|
||||
/** @param {HTMLElement} el @param {() => void} handler */
|
||||
export function onPlainClick(el, handler) {
|
||||
el.addEventListener("click", (e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
handler();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} arg
|
||||
* @param {string | HTMLElement} arg.inside
|
||||
* @param {string} arg.title
|
||||
* @param {(event: MouseEvent) => void} arg.onClick
|
||||
*/
|
||||
export function createButtonElement({ inside: text, onClick, title }) {
|
||||
const button = window.document.createElement("button");
|
||||
|
||||
button.append(text);
|
||||
|
||||
button.title = title.toUpperCase();
|
||||
|
||||
button.addEventListener("click", onClick);
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {string} args.inputName
|
||||
* @param {string} args.inputId
|
||||
* @param {string} args.inputValue
|
||||
* @param {boolean} [args.inputChecked=false]
|
||||
* @param {string} [args.title]
|
||||
* @param {'radio' | 'checkbox'} args.type
|
||||
* @param {(event: MouseEvent) => void} [args.onClick]
|
||||
*/
|
||||
export function createLabeledInput({
|
||||
inputId,
|
||||
inputName,
|
||||
inputValue,
|
||||
inputChecked = false,
|
||||
title,
|
||||
onClick,
|
||||
type,
|
||||
}) {
|
||||
const label = window.document.createElement("label");
|
||||
|
||||
inputId = inputId.toLowerCase();
|
||||
|
||||
const input = window.document.createElement("input");
|
||||
if (type === "radio") {
|
||||
input.type = "radio";
|
||||
input.name = inputName;
|
||||
} else {
|
||||
input.type = "checkbox";
|
||||
}
|
||||
input.id = inputId;
|
||||
input.value = inputValue;
|
||||
input.checked = inputChecked;
|
||||
label.append(input);
|
||||
|
||||
label.id = `${inputId}-label`;
|
||||
if (title) {
|
||||
label.title = title;
|
||||
}
|
||||
|
||||
if (onClick) {
|
||||
input.addEventListener("click", onClick);
|
||||
} else {
|
||||
label.htmlFor = inputId;
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
input,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {Object} Select
|
||||
* @property {HTMLElement} element
|
||||
* @property {() => T} get
|
||||
* @property {(choice: T) => void} set
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {Object} args
|
||||
* @param {T} args.initialValue
|
||||
* @param {string} [args.id]
|
||||
* @param {readonly T[]} args.choices
|
||||
* @param {(value: T) => void} [args.onChange]
|
||||
* @param {(choice: T) => string} [args.toKey]
|
||||
* @param {(choice: T) => string} [args.toLabel]
|
||||
* @param {(choice: T) => string | undefined} [args.toTitle]
|
||||
*/
|
||||
export function createRadios({
|
||||
id,
|
||||
choices,
|
||||
initialValue,
|
||||
onChange,
|
||||
toKey = /** @type {(choice: T) => string} */ ((c) => String(c)),
|
||||
toLabel = /** @type {(choice: T) => string} */ ((c) => String(c)),
|
||||
toTitle,
|
||||
}) {
|
||||
const fieldset = window.document.createElement("fieldset");
|
||||
|
||||
const initialKey = toKey(initialValue);
|
||||
|
||||
/** @param {string} key */
|
||||
const fromKey = (key) =>
|
||||
choices.find((c) => toKey(c) === key) ?? initialValue;
|
||||
|
||||
if (choices.length === 1) {
|
||||
fieldset.append(createSpan(toLabel(choices[0])));
|
||||
} else {
|
||||
const groupId = id ?? "";
|
||||
choices.forEach((choice) => {
|
||||
const key = toKey(choice);
|
||||
const { label } = createLabeledInput({
|
||||
inputId: `${groupId}-${key.toLowerCase()}`,
|
||||
inputName: groupId,
|
||||
inputValue: key,
|
||||
inputChecked: key === initialKey,
|
||||
title: toTitle?.(choice),
|
||||
type: "radio",
|
||||
});
|
||||
|
||||
const text = window.document.createTextNode(toLabel(choice));
|
||||
label.append(text);
|
||||
fieldset.append(label);
|
||||
});
|
||||
|
||||
fieldset.addEventListener("change", (event) => {
|
||||
if (!(event.target instanceof HTMLInputElement)) return;
|
||||
onChange?.(fromKey(event.target.value));
|
||||
});
|
||||
}
|
||||
|
||||
return fieldset;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {Object} args
|
||||
* @param {T} args.initialValue
|
||||
* @param {string} [args.id]
|
||||
* @param {string} [args.label]
|
||||
* @param {readonly T[]} args.choices
|
||||
* @param {(value: T) => void} [args.onChange]
|
||||
* @param {(choice: T) => string} [args.toKey]
|
||||
* @param {(choice: T) => string} [args.toLabel]
|
||||
* @param {boolean} [args.sorted]
|
||||
* @param {{ label: string, items: T[] }[]} [args.groups]
|
||||
* @returns {Select<T>}
|
||||
*/
|
||||
export function createSelect({
|
||||
id,
|
||||
label,
|
||||
choices: unsortedChoices,
|
||||
groups,
|
||||
initialValue,
|
||||
onChange,
|
||||
sorted,
|
||||
toKey = /** @type {(choice: T) => string} */ ((c) => String(c)),
|
||||
toLabel = /** @type {(choice: T) => string} */ ((c) => String(c)),
|
||||
}) {
|
||||
const choices = sorted
|
||||
? unsortedChoices.toSorted((a, b) => toLabel(a).localeCompare(toLabel(b)))
|
||||
: unsortedChoices;
|
||||
|
||||
const initialKey = toKey(initialValue);
|
||||
|
||||
/** @param {string} key */
|
||||
const fromKey = (key) =>
|
||||
choices.find((c) => toKey(c) === key) ?? initialValue;
|
||||
|
||||
if (choices.length === 1) {
|
||||
return {
|
||||
element: createSpan(toLabel(choices[0])),
|
||||
get: () => initialValue,
|
||||
set: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const element = window.document.createElement("label");
|
||||
if (label) {
|
||||
element.append(createSpan(label));
|
||||
}
|
||||
|
||||
const select = window.document.createElement("select");
|
||||
select.id = id ?? "";
|
||||
select.name = id ?? "";
|
||||
element.append(select);
|
||||
|
||||
/** @param {T} choice */
|
||||
const createOption = (choice) => {
|
||||
const key = toKey(choice);
|
||||
const option = window.document.createElement("option");
|
||||
option.value = key;
|
||||
option.textContent = toLabel(choice);
|
||||
if (key === initialKey) {
|
||||
option.selected = true;
|
||||
}
|
||||
return option;
|
||||
};
|
||||
|
||||
if (groups) {
|
||||
groups.forEach(({ label, items }) => {
|
||||
const optgroup = window.document.createElement("optgroup");
|
||||
optgroup.label = label;
|
||||
items.forEach((choice) => optgroup.append(createOption(choice)));
|
||||
select.append(optgroup);
|
||||
});
|
||||
} else {
|
||||
choices.forEach((choice) => select.append(createOption(choice)));
|
||||
}
|
||||
|
||||
select.addEventListener("change", () => {
|
||||
onChange?.(fromKey(select.value));
|
||||
});
|
||||
|
||||
const remaining = choices.length - 1;
|
||||
if (remaining > 0) {
|
||||
element.append(createSmall(`+${remaining}`));
|
||||
element.append(createSpan("↓"));
|
||||
}
|
||||
|
||||
element.addEventListener("click", (e) => {
|
||||
if (e.target !== select && "showPicker" in select) {
|
||||
e.preventDefault();
|
||||
select.showPicker();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
element,
|
||||
get: () => fromKey(select.value),
|
||||
set: (choice) => {
|
||||
select.value = toKey(choice);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [title]
|
||||
* @param {1 | 2 | 3} [level]
|
||||
*/
|
||||
export function createHeader(title = "", level = 1) {
|
||||
const headerElement = window.document.createElement("header");
|
||||
|
||||
const headingElement = window.document.createElement(`h${level}`);
|
||||
headingElement.innerHTML = title;
|
||||
headerElement.append(headingElement);
|
||||
headingElement.style.display = "block";
|
||||
|
||||
return {
|
||||
headerElement,
|
||||
headingElement,
|
||||
};
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { getElementById } from "./dom.js";
|
||||
|
||||
export const style = getComputedStyle(window.document.documentElement);
|
||||
|
||||
export const headElement = window.document.getElementsByTagName("head")[0];
|
||||
export const bodyElement = window.document.body;
|
||||
|
||||
export const mainElement = getElementById("main");
|
||||
export const asideElement = getElementById("aside");
|
||||
export const searchElement = getElementById("search");
|
||||
export const navElement = getElementById("nav");
|
||||
export const chartElement = getElementById("chart");
|
||||
export const explorerElement = getElementById("explorer");
|
||||
export const heatmapElement = getElementById("heatmap");
|
||||
|
||||
export const asideLabelElement = getElementById("aside-selector-label");
|
||||
export const navLabelElement = getElementById(`nav-selector-label`);
|
||||
export const searchLabelElement = getElementById(`search-selector-label`);
|
||||
export const searchInput = /** @type {HTMLInputElement} */ (
|
||||
getElementById("search-input")
|
||||
);
|
||||
export const searchResultsElement = getElementById("search-results");
|
||||
export const frameSelectorsElement = getElementById("frame-selectors");
|
||||
export const layoutButtonElement = getElementById("layout-button");
|
||||
@@ -1,6 +0,0 @@
|
||||
export const localhost = window.location.hostname === "localhost";
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
const iphone = userAgent.includes("iphone");
|
||||
const ipad = userAgent.includes("ipad");
|
||||
export const ios = iphone || ipad;
|
||||
export const canShare = "canShare" in navigator;
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* @param {number} value
|
||||
* @param {number} [digits]
|
||||
* @param {Intl.NumberFormatOptions} [options]
|
||||
*/
|
||||
function numberToUSNumber(value, digits, options) {
|
||||
return value.toLocaleString("en-us", {
|
||||
...options,
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
* @param {0 | 2} [digits]
|
||||
*/
|
||||
export function numberToShortUSFormat(value, digits) {
|
||||
const absoluteValue = Math.abs(value);
|
||||
|
||||
if (isNaN(value) || !isFinite(value)) {
|
||||
return "";
|
||||
} else if (absoluteValue < 10) {
|
||||
return numberToUSNumber(value, Math.min(3, digits || 10));
|
||||
} else if (absoluteValue < 1_000) {
|
||||
return numberToUSNumber(value, Math.min(2, digits || 10));
|
||||
} else if (absoluteValue < 10_000) {
|
||||
return numberToUSNumber(value, Math.min(1, digits || 10));
|
||||
} else if (absoluteValue < 1_000_000) {
|
||||
return numberToUSNumber(value, 0);
|
||||
} else if (absoluteValue >= 1e27) {
|
||||
return "Inf.";
|
||||
}
|
||||
|
||||
const log = Math.floor(Math.log10(absoluteValue) - 6);
|
||||
|
||||
const suffices = ["M", "B", "T", "P", "E", "Z", "Y"];
|
||||
const letterIndex = Math.floor(log / 3);
|
||||
const letter = suffices[letterIndex];
|
||||
|
||||
const modulused = log % 3;
|
||||
|
||||
if (modulused === 0) {
|
||||
return `${numberToUSNumber(
|
||||
value / (1_000_000 * 1_000 ** letterIndex),
|
||||
3,
|
||||
)}${letter}`;
|
||||
} else if (modulused === 1) {
|
||||
return `${numberToUSNumber(
|
||||
value / (1_000_000 * 1_000 ** letterIndex),
|
||||
2,
|
||||
)}${letter}`;
|
||||
} else {
|
||||
return `${numberToUSNumber(
|
||||
value / (1_000_000 * 1_000 ** letterIndex),
|
||||
1,
|
||||
)}${letter}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
*/
|
||||
export function stringToId(s) {
|
||||
return s
|
||||
.trim()
|
||||
.replace(/[ /]+/g, "-")
|
||||
.toLowerCase()
|
||||
.replace(/%/g, "%25");
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { readParam, writeParam } from "./url.js";
|
||||
import { readStored, writeToStorage } from "./storage.js";
|
||||
import { debounce } from "./timing.js";
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {Object} args
|
||||
* @param {T} args.defaultValue
|
||||
* @param {string} [args.storageKey]
|
||||
* @param {string} [args.urlKey]
|
||||
* @param {(v: T) => string} args.serialize
|
||||
* @param {(s: string) => T} args.deserialize
|
||||
* @param {boolean} [args.saveDefaultValue]
|
||||
* @param {(v: T) => void} [args.onChange]
|
||||
*/
|
||||
export function createPersistedValue({
|
||||
defaultValue,
|
||||
storageKey,
|
||||
urlKey,
|
||||
serialize,
|
||||
deserialize,
|
||||
saveDefaultValue = false,
|
||||
onChange,
|
||||
}) {
|
||||
const defaultSerialized = serialize(defaultValue);
|
||||
|
||||
// Read: URL > localStorage > default
|
||||
let serialized = urlKey ? readParam(urlKey) : null;
|
||||
if (serialized === null && storageKey) {
|
||||
serialized = readStored(storageKey);
|
||||
}
|
||||
let value = serialized !== null ? deserialize(serialized) : defaultValue;
|
||||
|
||||
/** @param {T} v */
|
||||
const write = (v) => {
|
||||
const s = serialize(v);
|
||||
const isDefault = s === defaultSerialized;
|
||||
|
||||
if (storageKey) {
|
||||
if (!isDefault || saveDefaultValue) {
|
||||
writeToStorage(storageKey, s);
|
||||
} else {
|
||||
writeToStorage(storageKey, null);
|
||||
}
|
||||
}
|
||||
|
||||
if (urlKey) {
|
||||
writeParam(urlKey, !isDefault || saveDefaultValue ? s : null);
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedWrite = debounce(write, 250);
|
||||
|
||||
// Write initial value
|
||||
write(value);
|
||||
|
||||
return {
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
/** @param {T} v */
|
||||
set(v) {
|
||||
value = v;
|
||||
debouncedWrite(v);
|
||||
onChange?.(v);
|
||||
},
|
||||
/** @param {T} v */
|
||||
setImmediate(v) {
|
||||
value = v;
|
||||
write(v);
|
||||
onChange?.(v);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {ReturnType<typeof createPersistedValue<T>>} PersistedValue
|
||||
*/
|
||||
@@ -1,36 +0,0 @@
|
||||
let _latest = /** @type {number | null} */ (null);
|
||||
|
||||
/** @type {Set<(price: number) => void>} */
|
||||
const listeners = new Set();
|
||||
|
||||
/** @param {(price: number) => void} callback */
|
||||
export function onPrice(callback) {
|
||||
listeners.add(callback);
|
||||
if (_latest !== null) callback(_latest);
|
||||
return () => listeners.delete(callback);
|
||||
}
|
||||
|
||||
export function latestPrice() {
|
||||
return _latest;
|
||||
}
|
||||
|
||||
/** @param {BrkClient} brk */
|
||||
export function initPrice(brk) {
|
||||
async function poll() {
|
||||
try {
|
||||
const price = await brk.getLivePrice();
|
||||
if (price !== _latest) {
|
||||
_latest = price;
|
||||
listeners.forEach((cb) => cb(price));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("price poll:", e);
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
setInterval(poll, 1_000);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
!document.hidden && poll();
|
||||
});
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { entries, fromEntries } from "./array.js";
|
||||
|
||||
export const serdeBool = {
|
||||
/**
|
||||
* @param {boolean} v
|
||||
*/
|
||||
serialize(v) {
|
||||
return String(v);
|
||||
},
|
||||
/**
|
||||
* @param {string} v
|
||||
*/
|
||||
deserialize(v) {
|
||||
if (v === "true" || v === "1") {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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",
|
||||
});
|
||||
|
||||
/** @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]),
|
||||
);
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
export function readStored(key) {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {string | boolean | null | undefined} value
|
||||
*/
|
||||
export function writeToStorage(key, value) {
|
||||
try {
|
||||
value !== undefined && value !== null
|
||||
? localStorage.setItem(key, String(value))
|
||||
: localStorage.removeItem(key);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
export function removeStored(key) {
|
||||
writeToStorage(key, undefined);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { readStored, removeStored, writeToStorage } from "./storage.js";
|
||||
|
||||
const preferredColorSchemeMatchMedia = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)",
|
||||
);
|
||||
const stored = readStored("theme");
|
||||
const initial = stored
|
||||
? stored === "dark"
|
||||
: preferredColorSchemeMatchMedia.matches;
|
||||
|
||||
export let dark = initial;
|
||||
|
||||
/** @type {Set<() => void>} */
|
||||
const callbacks = new Set();
|
||||
|
||||
/** @param {() => void} callback */
|
||||
export function onChange(callback) {
|
||||
callbacks.add(callback);
|
||||
return () => callbacks.delete(callback);
|
||||
}
|
||||
|
||||
const themeButton = /** @type {HTMLButtonElement | null} */ (
|
||||
document.getElementById("theme-button")
|
||||
);
|
||||
let running = false;
|
||||
|
||||
/** @param {boolean} value */
|
||||
function setDark(value) {
|
||||
if (running || dark === value) return;
|
||||
dark = value;
|
||||
running = true;
|
||||
if (themeButton) themeButton.disabled = true;
|
||||
const swap = () => {
|
||||
apply(value);
|
||||
callbacks.forEach((cb) => cb());
|
||||
};
|
||||
document.documentElement.classList.add("no-transitions");
|
||||
const restore = () => {
|
||||
document.documentElement.classList.remove("no-transitions");
|
||||
running = false;
|
||||
if (themeButton) themeButton.disabled = false;
|
||||
};
|
||||
if (document.startViewTransition) {
|
||||
document.startViewTransition(swap).finished.finally(restore);
|
||||
} else {
|
||||
swap();
|
||||
requestAnimationFrame(restore);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {boolean} isDark */
|
||||
function apply(isDark) {
|
||||
document.documentElement.style.colorScheme = isDark ? "dark" : "light";
|
||||
}
|
||||
apply(initial);
|
||||
|
||||
preferredColorSchemeMatchMedia.addEventListener("change", ({ matches }) => {
|
||||
if (!readStored("theme")) {
|
||||
setDark(matches);
|
||||
}
|
||||
});
|
||||
|
||||
function invert() {
|
||||
const newValue = !dark;
|
||||
setDark(newValue);
|
||||
if (newValue === preferredColorSchemeMatchMedia.matches) {
|
||||
removeStored("theme");
|
||||
} else {
|
||||
writeToStorage("theme", newValue ? "dark" : "light");
|
||||
}
|
||||
}
|
||||
|
||||
themeButton?.addEventListener("click", invert);
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* @param {number} ms
|
||||
*/
|
||||
export function sleep(ms) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
export function next() {
|
||||
return sleep(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {() => void} callback
|
||||
*/
|
||||
export function idle(callback) {
|
||||
("requestIdleCallback" in window ? requestIdleCallback : setTimeout)(
|
||||
callback,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @template {(...args: never[]) => unknown} F
|
||||
* @param {F} callback
|
||||
* @param {number} [wait]
|
||||
*/
|
||||
export function throttle(callback, wait = 1000) {
|
||||
/** @type {number | null} */
|
||||
let timeoutId = null;
|
||||
/** @type {Parameters<F>} */
|
||||
let latestArgs;
|
||||
let hasTrailing = false;
|
||||
|
||||
return (/** @type {Parameters<F>} */ ...args) => {
|
||||
latestArgs = args;
|
||||
if (timeoutId) {
|
||||
hasTrailing = true;
|
||||
return;
|
||||
}
|
||||
callback(...latestArgs);
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutId = null;
|
||||
if (hasTrailing) {
|
||||
hasTrailing = false;
|
||||
callback(...latestArgs);
|
||||
}
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {(...args: never[]) => unknown} F
|
||||
* @param {F} callback
|
||||
* @param {number} [wait]
|
||||
* @returns {((...args: Parameters<F>) => void) & { cancel: () => void }}
|
||||
*/
|
||||
export function debounce(callback, wait = 1000) {
|
||||
/** @type {number | null} */
|
||||
let timeoutId = null;
|
||||
|
||||
const fn = (/** @type {Parameters<F>} */ ...args) => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
timeoutId = setTimeout(() => {
|
||||
callback(...args);
|
||||
timeoutId = null;
|
||||
}, wait);
|
||||
};
|
||||
|
||||
fn.cancel = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
};
|
||||
|
||||
return fn;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/** Unit definitions for chart series */
|
||||
|
||||
/**
|
||||
* Unit enum with id (for serialization) and name (for display)
|
||||
*/
|
||||
export const Unit = /** @type {const} */ ({
|
||||
// Value units
|
||||
sats: { id: "sats", name: "Satoshis" },
|
||||
btc: { id: "btc", name: "Bitcoin" },
|
||||
usd: { id: "usd", name: "US Dollars" },
|
||||
|
||||
// Ratios & percentages
|
||||
percentage: { id: "percentage", name: "Percentage" },
|
||||
cagr: { id: "cagr", name: "CAGR (%/year)" },
|
||||
ratio: { id: "ratio", name: "Ratio" },
|
||||
index: { id: "index", name: "Index" },
|
||||
sd: { id: "sd", name: "Std Dev" },
|
||||
|
||||
// Relative percentages
|
||||
pctSupply: { id: "pct-supply", name: "% of circulating" },
|
||||
pctOwn: { id: "pct-own", name: "% of Own" },
|
||||
|
||||
// Time
|
||||
days: { id: "days", name: "Days" },
|
||||
years: { id: "years", name: "Years" },
|
||||
secs: { id: "secs", name: "Seconds" },
|
||||
|
||||
// Counts
|
||||
count: { id: "count", name: "Count" },
|
||||
blocks: { id: "blocks", name: "Blocks" },
|
||||
|
||||
// Size
|
||||
bytes: { id: "bytes", name: "Bytes" },
|
||||
vb: { id: "vb", name: "Virtual Bytes" },
|
||||
wu: { id: "wu", name: "Weight Units" },
|
||||
|
||||
// Mining
|
||||
hashRate: { id: "hashrate", name: "Hash Rate" },
|
||||
difficulty: { id: "difficulty", name: "Difficulty" },
|
||||
epoch: { id: "epoch", name: "Epoch" },
|
||||
|
||||
// Fees
|
||||
feeRate: { id: "feerate", name: "Sat/vByte" },
|
||||
|
||||
// Rates
|
||||
perSec: { id: "per-sec", name: "Per Second" },
|
||||
|
||||
// Cointime
|
||||
coinblocks: { id: "coinblocks", name: "Coinblocks" },
|
||||
coindays: { id: "coindays", name: "Coindays" },
|
||||
satblocks: { id: "satblocks", name: "Satblocks" },
|
||||
satdays: { id: "satdays", name: "Satdays" },
|
||||
|
||||
// Hash price/value
|
||||
usdPerThsPerDay: { id: "usd-ths-day", name: "USD/TH/s/Day" },
|
||||
usdPerPhsPerDay: { id: "usd-phs-day", name: "USD/PH/s/Day" },
|
||||
satsPerThsPerDay: { id: "sats-ths-day", name: "Sats/TH/s/Day" },
|
||||
satsPerPhsPerDay: { id: "sats-phs-day", name: "Sats/PH/s/Day" },
|
||||
});
|
||||
|
||||
/** @typedef {keyof typeof Unit} UnitKey */
|
||||
/** @typedef {typeof Unit[UnitKey]} UnitObject */
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* @param {string | string[]} [pathname]
|
||||
*/
|
||||
function processPathname(pathname) {
|
||||
pathname ||= window.location.pathname;
|
||||
const result = Array.isArray(pathname) ? pathname.join("/") : pathname;
|
||||
// Strip leading slash to avoid double slashes when prepending /
|
||||
return result.startsWith("/") ? result.slice(1) : result;
|
||||
}
|
||||
|
||||
const chartParamsWhitelist = ["range"];
|
||||
|
||||
/**
|
||||
* @param {string | string[]} [pathname]
|
||||
* @param {URLSearchParams} [urlParams]
|
||||
*/
|
||||
function buildUrl(pathname, urlParams) {
|
||||
const path = processPathname(pathname);
|
||||
const query = (urlParams ?? new URLSearchParams(window.location.search)).toString();
|
||||
return `/${path}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | string[]} pathname
|
||||
*/
|
||||
export function pushHistory(pathname) {
|
||||
try {
|
||||
window.history.pushState(null, "", buildUrl(pathname));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {URLSearchParams} [args.urlParams]
|
||||
* @param {string | string[]} [args.pathname]
|
||||
*/
|
||||
export function replaceHistory({ urlParams, pathname }) {
|
||||
try {
|
||||
window.history.replaceState(null, "", buildUrl(pathname, urlParams));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Option} option
|
||||
*/
|
||||
export function resetParams(option) {
|
||||
const urlParams = new URLSearchParams();
|
||||
if (option.kind === "chart") {
|
||||
[...new URLSearchParams(window.location.search).entries()]
|
||||
.filter(([key, _]) => chartParamsWhitelist.includes(key))
|
||||
.forEach(([key, value]) => {
|
||||
urlParams.set(key, value);
|
||||
});
|
||||
}
|
||||
replaceHistory({ urlParams, pathname: option.path.join("/") });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {string | boolean | null | undefined} value
|
||||
*/
|
||||
export function writeParam(key, value) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
if (value !== null && value !== undefined) {
|
||||
urlParams.set(key, String(value));
|
||||
} else {
|
||||
urlParams.delete(key);
|
||||
}
|
||||
|
||||
replaceHistory({ urlParams });
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} key
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function readParam(key) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get(key);
|
||||
}
|
||||
Reference in New Issue
Block a user