website: redesign part 1

This commit is contained in:
nym21
2026-06-03 12:34:05 +02:00
parent 5f5563fece
commit 90e8741fb7
209 changed files with 23945 additions and 176 deletions
-34
View File
@@ -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)];
}
-119
View File
@@ -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
-154
View File
@@ -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());
},
};
}
-8
View File
@@ -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 };
-403
View File
@@ -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})`;
}
-383
View File
@@ -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,
};
}
-24
View File
@@ -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");
-6
View File
@@ -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;
-70
View File
@@ -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");
}
-79
View File
@@ -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
*/
-36
View File
@@ -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();
});
}
-47
View File
@@ -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]),
);
-29
View File
@@ -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);
}
-73
View File
@@ -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);
-23
View File
@@ -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;
}
}
-81
View File
@@ -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;
}
-62
View File
@@ -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 */
-82
View File
@@ -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);
}