mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-04-24 14:49:58 -07:00
global: snapshot
This commit is contained in:
20
website/scripts/utils/array.js
Normal file
20
website/scripts/utils/array.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @param {number} start
|
||||
* @param {number} end
|
||||
*/
|
||||
export function range(start, end) {
|
||||
const range = [];
|
||||
while (start <= end) {
|
||||
range.push(start);
|
||||
start += 1;
|
||||
}
|
||||
return range;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T[]} array
|
||||
*/
|
||||
export function randomFromArray(array) {
|
||||
return array[Math.floor(Math.random() * array.length)];
|
||||
}
|
||||
116
website/scripts/utils/colors.js
Normal file
116
website/scripts/utils/colors.js
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @param {Accessor<boolean>} dark
|
||||
*/
|
||||
export function createColors(dark) {
|
||||
const globalComputedStyle = getComputedStyle(window.document.documentElement);
|
||||
/**
|
||||
* @param {string} color
|
||||
*/
|
||||
function getColor(color) {
|
||||
return globalComputedStyle.getPropertyValue(`--${color}`);
|
||||
}
|
||||
function red() {
|
||||
return getColor("red");
|
||||
}
|
||||
function orange() {
|
||||
return getColor("orange");
|
||||
}
|
||||
function amber() {
|
||||
return getColor("amber");
|
||||
}
|
||||
function yellow() {
|
||||
return getColor("yellow");
|
||||
}
|
||||
function avocado() {
|
||||
return getColor("avocado");
|
||||
}
|
||||
function lime() {
|
||||
return getColor("lime");
|
||||
}
|
||||
function green() {
|
||||
return getColor("green");
|
||||
}
|
||||
function emerald() {
|
||||
return getColor("emerald");
|
||||
}
|
||||
function teal() {
|
||||
return getColor("teal");
|
||||
}
|
||||
function cyan() {
|
||||
return getColor("cyan");
|
||||
}
|
||||
function sky() {
|
||||
return getColor("sky");
|
||||
}
|
||||
function blue() {
|
||||
return getColor("blue");
|
||||
}
|
||||
function indigo() {
|
||||
return getColor("indigo");
|
||||
}
|
||||
function violet() {
|
||||
return getColor("violet");
|
||||
}
|
||||
function purple() {
|
||||
return getColor("purple");
|
||||
}
|
||||
function fuchsia() {
|
||||
return getColor("fuchsia");
|
||||
}
|
||||
function pink() {
|
||||
return getColor("pink");
|
||||
}
|
||||
function rose() {
|
||||
return getColor("rose");
|
||||
}
|
||||
function gray() {
|
||||
return getColor("gray");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} property
|
||||
*/
|
||||
function getLightDarkValue(property) {
|
||||
const value = globalComputedStyle.getPropertyValue(property);
|
||||
const [light, _dark] = value.slice(11, -1).split(", ");
|
||||
return dark() ? _dark : light;
|
||||
}
|
||||
|
||||
function textColor() {
|
||||
return getLightDarkValue("--color");
|
||||
}
|
||||
function borderColor() {
|
||||
return getLightDarkValue("--border-color");
|
||||
}
|
||||
|
||||
return {
|
||||
default: textColor,
|
||||
gray,
|
||||
border: borderColor,
|
||||
|
||||
red,
|
||||
orange,
|
||||
amber,
|
||||
yellow,
|
||||
avocado,
|
||||
lime,
|
||||
green,
|
||||
emerald,
|
||||
teal,
|
||||
cyan,
|
||||
sky,
|
||||
blue,
|
||||
indigo,
|
||||
violet,
|
||||
purple,
|
||||
fuchsia,
|
||||
pink,
|
||||
rose,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {ReturnType<typeof createColors>} Colors
|
||||
* @typedef {Colors["orange"]} Color
|
||||
* @typedef {keyof Colors} ColorName
|
||||
*/
|
||||
58
website/scripts/utils/date.js
Normal file
58
website/scripts/utils/date.js
Normal file
@@ -0,0 +1,58 @@
|
||||
const ONE_DAY_IN_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
export function todayUTC() {
|
||||
const today = new Date();
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
today.getUTCFullYear(),
|
||||
today.getUTCMonth(),
|
||||
today.getUTCDate(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} date
|
||||
*/
|
||||
export function dateToDateIndex(date) {
|
||||
if (
|
||||
date.getUTCFullYear() === 2009 &&
|
||||
date.getUTCMonth() === 0 &&
|
||||
date.getUTCDate() === 3
|
||||
)
|
||||
return 0;
|
||||
return differenceBetweenDates(date, new Date("2009-01-09"));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} start
|
||||
* @param {Date} end
|
||||
*/
|
||||
export function createDateRange(start, end) {
|
||||
const dates = /** @type {Date[]} */ ([]);
|
||||
let currentDate = new Date(start);
|
||||
while (currentDate <= end) {
|
||||
dates.push(new Date(currentDate));
|
||||
currentDate.setUTCDate(currentDate.getUTCDate() + 1);
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} date1
|
||||
* @param {Date} date2
|
||||
*/
|
||||
export function differenceBetweenDates(date1, date2) {
|
||||
return Math.abs(date1.valueOf() - date2.valueOf()) / ONE_DAY_IN_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} date1
|
||||
* @param {Date} date2
|
||||
*/
|
||||
export function roundedDifferenceBetweenDates(date1, date2) {
|
||||
return Math.round(differenceBetweenDates(date1, date2));
|
||||
}
|
||||
514
website/scripts/utils/dom.js
Normal file
514
website/scripts/utils/dom.js
Normal file
@@ -0,0 +1,514 @@
|
||||
/**
|
||||
* @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} 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
label.htmlFor = inputId;
|
||||
|
||||
if (onClick) {
|
||||
label.addEventListener("click", onClick);
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
input,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} parent
|
||||
* @param {HTMLElement} child
|
||||
* @param {number} index
|
||||
*/
|
||||
export function insertElementAtIndex(parent, child, index) {
|
||||
if (!index) index = 0;
|
||||
if (index >= parent.children.length) {
|
||||
parent.appendChild(child);
|
||||
} else {
|
||||
parent.insertBefore(child, parent.children[index]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {boolean} [targetBlank]
|
||||
*/
|
||||
export function open(url, targetBlank) {
|
||||
console.log(`open: ${url}`);
|
||||
const a = window.document.createElement("a");
|
||||
window.document.body.append(a);
|
||||
a.href = url;
|
||||
|
||||
if (targetBlank) {
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener noreferrer";
|
||||
}
|
||||
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} href
|
||||
*/
|
||||
export function importStyle(href) {
|
||||
const link = document.createElement("link");
|
||||
link.href = href;
|
||||
link.type = "text/css";
|
||||
link.rel = "stylesheet";
|
||||
link.media = "screen,print";
|
||||
const head = window.document.getElementsByTagName("head")[0];
|
||||
head.appendChild(link);
|
||||
return link;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {Object} args
|
||||
* @param {T} args.defaultValue
|
||||
* @param {string} [args.id]
|
||||
* @param {readonly T[] | Accessor<readonly T[]>} args.choices
|
||||
* @param {string} [args.keyPrefix]
|
||||
* @param {string} args.key
|
||||
* @param {boolean} [args.sorted]
|
||||
* @param {Signals} args.signals
|
||||
* @param {(choice: T) => string} [args.toKey] - Extract string key for storage (defaults to identity for strings)
|
||||
* @param {(choice: T) => string} [args.toLabel] - Extract display label (defaults to identity for strings)
|
||||
* @param {"radio" | "select"} [args.type] - Render as radio buttons or select dropdown
|
||||
*/
|
||||
export function createChoiceField({
|
||||
id,
|
||||
choices: unsortedChoices,
|
||||
defaultValue,
|
||||
keyPrefix,
|
||||
key,
|
||||
signals,
|
||||
sorted,
|
||||
toKey = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c),
|
||||
toLabel = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c),
|
||||
type = /** @type {const} */ ("radio"),
|
||||
}) {
|
||||
const defaultKey = toKey(defaultValue);
|
||||
|
||||
const choices = signals.createMemo(() => {
|
||||
/** @type {readonly T[]} */
|
||||
let c;
|
||||
if (typeof unsortedChoices === "function") {
|
||||
c = unsortedChoices();
|
||||
} else {
|
||||
c = unsortedChoices;
|
||||
}
|
||||
|
||||
return sorted
|
||||
? /** @type {readonly T[]} */ (
|
||||
/** @type {any} */ (
|
||||
c.toSorted((a, b) => toLabel(a).localeCompare(toLabel(b)))
|
||||
)
|
||||
)
|
||||
: c;
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {string} storedKey
|
||||
* @returns {T}
|
||||
*/
|
||||
function fromKey(storedKey) {
|
||||
const found = choices().find((c) => toKey(c) === storedKey);
|
||||
return found ?? defaultValue;
|
||||
}
|
||||
|
||||
/** @type {Signal<T>} */
|
||||
const selected = signals.createSignal(defaultValue, {
|
||||
save: {
|
||||
serialize: (v) => toKey(v),
|
||||
deserialize: (s) => fromKey(s),
|
||||
keyPrefix: keyPrefix ?? "",
|
||||
key,
|
||||
saveDefaultValue: true,
|
||||
},
|
||||
});
|
||||
|
||||
const field = window.document.createElement("div");
|
||||
field.classList.add("field");
|
||||
|
||||
const div = window.document.createElement("div");
|
||||
field.append(div);
|
||||
|
||||
/** @type {HTMLElement | null} */
|
||||
let remainingSmall = null;
|
||||
if (type === "select") {
|
||||
remainingSmall = window.document.createElement("small");
|
||||
field.append(remainingSmall);
|
||||
}
|
||||
|
||||
signals.createEffect(choices, (choices) => {
|
||||
const s = selected();
|
||||
const sKey = toKey(s);
|
||||
const keys = choices.map(toKey);
|
||||
if (!keys.includes(sKey)) {
|
||||
if (keys.includes(defaultKey)) {
|
||||
selected.set(() => defaultValue);
|
||||
} else if (choices.length) {
|
||||
selected.set(() => choices[0]);
|
||||
}
|
||||
}
|
||||
|
||||
div.innerHTML = "";
|
||||
|
||||
if (choices.length === 1) {
|
||||
const span = window.document.createElement("span");
|
||||
span.textContent = toLabel(choices[0]);
|
||||
div.append(span);
|
||||
|
||||
if (remainingSmall) {
|
||||
remainingSmall.hidden = true;
|
||||
}
|
||||
} else if (type === "select") {
|
||||
const select = window.document.createElement("select");
|
||||
select.id = id ?? key;
|
||||
select.name = id ?? key;
|
||||
|
||||
choices.forEach((choice) => {
|
||||
const option = window.document.createElement("option");
|
||||
option.value = toKey(choice);
|
||||
option.textContent = toLabel(choice);
|
||||
if (toKey(choice) === sKey) {
|
||||
option.selected = true;
|
||||
}
|
||||
select.append(option);
|
||||
});
|
||||
|
||||
select.addEventListener("change", () => {
|
||||
selected.set(() => fromKey(select.value));
|
||||
});
|
||||
|
||||
div.append(select);
|
||||
|
||||
if (remainingSmall) {
|
||||
const remaining = choices.length - 1;
|
||||
if (remaining > 0) {
|
||||
remainingSmall.textContent = ` +${remaining}`;
|
||||
remainingSmall.hidden = false;
|
||||
} else {
|
||||
remainingSmall.hidden = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
choices.forEach((choice) => {
|
||||
const choiceKey = toKey(choice);
|
||||
const choiceLabel = toLabel(choice);
|
||||
const { label } = createLabeledInput({
|
||||
inputId: `${id ?? key}-${choiceKey.toLowerCase()}`,
|
||||
inputName: id ?? key,
|
||||
inputValue: choiceKey,
|
||||
inputChecked: choiceKey === sKey,
|
||||
// title: choiceLabel,
|
||||
type: "radio",
|
||||
});
|
||||
|
||||
const text = window.document.createTextNode(choiceLabel);
|
||||
label.append(text);
|
||||
div.append(label);
|
||||
});
|
||||
|
||||
field.addEventListener("change", (event) => {
|
||||
// @ts-ignore
|
||||
const value = event.target.value;
|
||||
selected.set(() => fromKey(value));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { field, selected };
|
||||
}
|
||||
|
||||
/**
|
||||
* @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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {string} Name
|
||||
* @template {string} Value
|
||||
* @template {Value | {name: Name; value: Value}} T
|
||||
* @param {T} arg
|
||||
*/
|
||||
export function createOption(arg) {
|
||||
const option = window.document.createElement("option");
|
||||
if (typeof arg === "object") {
|
||||
option.value = arg.value;
|
||||
option.innerText = arg.name;
|
||||
} else {
|
||||
option.value = arg;
|
||||
option.innerText = arg;
|
||||
}
|
||||
return option;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {string} Name
|
||||
* @template {string} Value
|
||||
* @template {Value | {name: Name; value: Value}} T
|
||||
* @param {Object} args
|
||||
* @param {string} [args.id]
|
||||
* @param {boolean} [args.deep]
|
||||
* @param {readonly ((T) | {name: string; list: T[]})[]} args.list
|
||||
* @param {Signal<T>} args.signal
|
||||
*/
|
||||
export function createSelect({ id, list, signal, deep = false }) {
|
||||
const select = window.document.createElement("select");
|
||||
|
||||
if (id) {
|
||||
select.name = id;
|
||||
select.id = id;
|
||||
}
|
||||
|
||||
/** @type {Record<string, VoidFunction>} */
|
||||
const setters = {};
|
||||
|
||||
list.forEach((anyOption, index) => {
|
||||
if (typeof anyOption === "object" && "list" in anyOption) {
|
||||
const { name, list } = anyOption;
|
||||
const optGroup = window.document.createElement("optgroup");
|
||||
optGroup.label = name;
|
||||
select.append(optGroup);
|
||||
list.forEach((option) => {
|
||||
optGroup.append(createOption(option));
|
||||
const key = /** @type {string} */ (
|
||||
typeof option === "object" ? option.value : option
|
||||
);
|
||||
setters[key] = () => signal.set(() => option);
|
||||
});
|
||||
} else {
|
||||
select.append(createOption(anyOption));
|
||||
const key = /** @type {string} */ (
|
||||
typeof anyOption === "object" ? anyOption.value : anyOption
|
||||
);
|
||||
setters[key] = () => signal.set(() => anyOption);
|
||||
}
|
||||
if (deep && index !== list.length - 1) {
|
||||
select.append(window.document.createElement("hr"));
|
||||
}
|
||||
});
|
||||
|
||||
select.addEventListener("change", () => {
|
||||
const callback = setters[select.value];
|
||||
// @ts-ignore
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
const initialSignal = signal();
|
||||
const initialValue =
|
||||
typeof initialSignal === "object" ? initialSignal.value : initialSignal;
|
||||
select.value = String(initialValue);
|
||||
|
||||
return { select, signal };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {string} args.title
|
||||
* @param {string} args.description
|
||||
* @param {HTMLElement} args.input
|
||||
*/
|
||||
export function createFieldElement({ title, description, input }) {
|
||||
const div = window.document.createElement("div");
|
||||
|
||||
const label = window.document.createElement("label");
|
||||
div.append(label);
|
||||
|
||||
const titleElement = window.document.createElement("span");
|
||||
titleElement.innerHTML = title;
|
||||
label.append(titleElement);
|
||||
|
||||
const descriptionElement = window.document.createElement("small");
|
||||
descriptionElement.innerHTML = description;
|
||||
label.append(descriptionElement);
|
||||
|
||||
div.append(input);
|
||||
|
||||
const forId = input.id || input.firstElementChild?.id;
|
||||
|
||||
if (!forId) {
|
||||
console.log(input);
|
||||
throw `Input should've an ID`;
|
||||
}
|
||||
|
||||
label.htmlFor = forId;
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {'left' | 'bottom' | 'top' | 'right'} position
|
||||
*/
|
||||
export function createShadow(position) {
|
||||
const div = window.document.createElement("div");
|
||||
div.classList.add(`shadow-${position}`);
|
||||
return div;
|
||||
}
|
||||
24
website/scripts/utils/elements.js
Normal file
24
website/scripts/utils/elements.js
Normal file
@@ -0,0 +1,24 @@
|
||||
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 tableElement = getElementById("table");
|
||||
export const explorerElement = getElementById("explorer");
|
||||
export const simulationElement = getElementById("simulation");
|
||||
|
||||
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");
|
||||
12
website/scripts/utils/env.js
Normal file
12
website/scripts/utils/env.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export const localhost = window.location.hostname === "localhost";
|
||||
export const standalone =
|
||||
"standalone" in window.navigator && !!window.navigator.standalone;
|
||||
export const userAgent = navigator.userAgent.toLowerCase();
|
||||
export const isChrome = userAgent.includes("chrome");
|
||||
export const safari = userAgent.includes("safari");
|
||||
export const safariOnly = safari && !isChrome;
|
||||
export const macOS = userAgent.includes("mac os");
|
||||
export const iphone = userAgent.includes("iphone");
|
||||
export const ipad = userAgent.includes("ipad");
|
||||
export const ios = iphone || ipad;
|
||||
export const canShare = "canShare" in navigator;
|
||||
38
website/scripts/utils/format.js
Normal file
38
website/scripts/utils/format.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @param {number} value
|
||||
* @param {number} [digits]
|
||||
* @param {Intl.NumberFormatOptions} [options]
|
||||
*/
|
||||
export function numberToUSNumber(value, digits, options) {
|
||||
return value.toLocaleString("en-us", {
|
||||
...options,
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
export const numberToDollars = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
export const numberToPercentage = new Intl.NumberFormat("en-US", {
|
||||
style: "percent",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
*/
|
||||
export function stringToId(s) {
|
||||
return (
|
||||
s
|
||||
// .replace(/\W/g, " ")
|
||||
.trim()
|
||||
.replace(/ +/g, "-")
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
225
website/scripts/utils/serde.js
Normal file
225
website/scripts/utils/serde.js
Normal file
@@ -0,0 +1,225 @@
|
||||
const localhost = window.location.hostname === "localhost";
|
||||
console.log({ localhost });
|
||||
|
||||
export const serdeString = {
|
||||
/**
|
||||
* @param {string} v
|
||||
*/
|
||||
serialize(v) {
|
||||
return v;
|
||||
},
|
||||
/**
|
||||
* @param {string} v
|
||||
*/
|
||||
deserialize(v) {
|
||||
return v;
|
||||
},
|
||||
};
|
||||
|
||||
export const serdeMetrics = {
|
||||
/**
|
||||
* @param {Metric[]} v
|
||||
*/
|
||||
serialize(v) {
|
||||
return v.join(",");
|
||||
},
|
||||
/**
|
||||
* @param {string} v
|
||||
*/
|
||||
deserialize(v) {
|
||||
return /** @type {Metric[]} */ (v.split(","));
|
||||
},
|
||||
};
|
||||
|
||||
export const serdeNumber = {
|
||||
/**
|
||||
* @param {number} v
|
||||
*/
|
||||
serialize(v) {
|
||||
return String(v);
|
||||
},
|
||||
/**
|
||||
* @param {string} v
|
||||
*/
|
||||
deserialize(v) {
|
||||
return Number(v);
|
||||
},
|
||||
};
|
||||
|
||||
export const serdeOptNumber = {
|
||||
/**
|
||||
* @param {number | null} v
|
||||
*/
|
||||
serialize(v) {
|
||||
return v !== null ? String(v) : "";
|
||||
},
|
||||
/**
|
||||
* @param {string} v
|
||||
*/
|
||||
deserialize(v) {
|
||||
return v ? Number(v) : null;
|
||||
},
|
||||
};
|
||||
|
||||
export const serdeDate = {
|
||||
/**
|
||||
* @param {Date} date
|
||||
*/
|
||||
serialize(date) {
|
||||
return date.toString();
|
||||
},
|
||||
/**
|
||||
* @param {string} v
|
||||
*/
|
||||
deserialize(v) {
|
||||
return new Date(v);
|
||||
},
|
||||
};
|
||||
|
||||
export const serdeOptDate = {
|
||||
/**
|
||||
* @param {Date | null} date
|
||||
*/
|
||||
serialize(date) {
|
||||
return date !== null ? date.toString() : "";
|
||||
},
|
||||
/**
|
||||
* @param {string} v
|
||||
*/
|
||||
deserialize(v) {
|
||||
return new Date(v);
|
||||
},
|
||||
};
|
||||
|
||||
export const serdeBool = {
|
||||
/**
|
||||
* @param {boolean} v
|
||||
*/
|
||||
serialize(v) {
|
||||
return String(v);
|
||||
},
|
||||
/**
|
||||
* @param {string} v
|
||||
*/
|
||||
deserialize(v) {
|
||||
if (v === "true" || v === "1") {
|
||||
return true;
|
||||
} else if (v === "false" || v === "0") {
|
||||
return false;
|
||||
} else {
|
||||
throw "deser bool err";
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const serdeChartableIndex = {
|
||||
/**
|
||||
* @param {IndexName} v
|
||||
* @returns {ChartableIndexName | null}
|
||||
*/
|
||||
serialize(v) {
|
||||
switch (v) {
|
||||
case "dateindex":
|
||||
return "date";
|
||||
case "decadeindex":
|
||||
return "decade";
|
||||
case "height":
|
||||
return "timestamp";
|
||||
case "monthindex":
|
||||
return "month";
|
||||
case "quarterindex":
|
||||
return "quarter";
|
||||
case "semesterindex":
|
||||
return "semester";
|
||||
case "weekindex":
|
||||
return "week";
|
||||
case "yearindex":
|
||||
return "year";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {ChartableIndexName} v
|
||||
* @returns {ChartableIndex}
|
||||
*/
|
||||
deserialize(v) {
|
||||
switch (v) {
|
||||
case "timestamp":
|
||||
return "height";
|
||||
case "date":
|
||||
return "dateindex";
|
||||
case "week":
|
||||
return "weekindex";
|
||||
case "month":
|
||||
return "monthindex";
|
||||
case "quarter":
|
||||
return "quarterindex";
|
||||
case "semester":
|
||||
return "semesterindex";
|
||||
case "year":
|
||||
return "yearindex";
|
||||
case "decade":
|
||||
return "decadeindex";
|
||||
default:
|
||||
throw Error("todo");
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {"" |
|
||||
* "%all" |
|
||||
* "%cmcap" |
|
||||
* "%cp+l" |
|
||||
* "%mcap" |
|
||||
* "%pnl" |
|
||||
* "%rcap" |
|
||||
* "%self" |
|
||||
* "/sec" |
|
||||
* "address data" |
|
||||
* "block" |
|
||||
* "blocks" |
|
||||
* "bool" |
|
||||
* "btc" |
|
||||
* "bytes" |
|
||||
* "cents" |
|
||||
* "coinblocks" |
|
||||
* "coindays" |
|
||||
* "constant" |
|
||||
* "count" |
|
||||
* "date" |
|
||||
* "days" |
|
||||
* "difficulty" |
|
||||
* "epoch" |
|
||||
* "gigabytes" |
|
||||
* "h/s" |
|
||||
* "hash" |
|
||||
* "height" |
|
||||
* "id" |
|
||||
* "index" |
|
||||
* "len" |
|
||||
* "locktime" |
|
||||
* "percentage" |
|
||||
* "position" |
|
||||
* "ratio" |
|
||||
* "sat/vb" |
|
||||
* "satblocks" |
|
||||
* "satdays" |
|
||||
* "sats" |
|
||||
* "sats/(ph/s)/day" |
|
||||
* "sats/(th/s)/day" |
|
||||
* "sd" |
|
||||
* "secs" |
|
||||
* "timestamp" |
|
||||
* "tx" |
|
||||
* "type" |
|
||||
* "usd" |
|
||||
* "usd/(ph/s)/day" |
|
||||
* "usd/(th/s)/day" |
|
||||
* "vb" |
|
||||
* "version" |
|
||||
* "wu" |
|
||||
* "years" |
|
||||
* "" } Unit
|
||||
*/
|
||||
51
website/scripts/utils/storage.js
Normal file
51
website/scripts/utils/storage.js
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
export function readStoredNumber(key) {
|
||||
const saved = readStored(key);
|
||||
if (saved) {
|
||||
return Number(saved);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
export function readStoredBool(key) {
|
||||
const saved = readStored(key);
|
||||
if (saved) {
|
||||
return saved === "true" || saved === "1";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
38
website/scripts/utils/timing.js
Normal file
38
website/scripts/utils/timing.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @param {number} ms
|
||||
*/
|
||||
export function sleep(ms) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
export function next() {
|
||||
return sleep(0);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @template {(...args: any[]) => any} F
|
||||
* @param {F} callback
|
||||
* @param {number} [wait]
|
||||
*/
|
||||
export function throttle(callback, wait = 1000) {
|
||||
/** @type {number | null} */
|
||||
let timeoutId = null;
|
||||
/** @type {Parameters<F>} */
|
||||
let latestArgs;
|
||||
|
||||
return (/** @type {Parameters<F>} */ ...args) => {
|
||||
latestArgs = args;
|
||||
|
||||
if (!timeoutId) {
|
||||
// Otherwise it optimizes away timeoutId in Chrome and FF
|
||||
timeoutId = timeoutId;
|
||||
timeoutId = setTimeout(() => {
|
||||
callback(...latestArgs); // Execute with latest args
|
||||
timeoutId = null;
|
||||
}, wait);
|
||||
}
|
||||
};
|
||||
}
|
||||
63
website/scripts/utils/units.js
Normal file
63
website/scripts/utils/units.js
Normal file
@@ -0,0 +1,63 @@
|
||||
/** 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" },
|
||||
ratio: { id: "ratio", name: "Ratio" },
|
||||
index: { id: "index", name: "Index" },
|
||||
sd: { id: "sd", name: "Std Dev" },
|
||||
|
||||
// Relative percentages
|
||||
pctSupply: { id: "pct-supply", name: "% of Supply" },
|
||||
pctOwn: { id: "pct-own", name: "% of Own Supply" },
|
||||
pctMcap: { id: "pct-mcap", name: "% of Market Cap" },
|
||||
pctRcap: { id: "pct-rcap", name: "% of Realized Cap" },
|
||||
pctOwnMcap: { id: "pct-own-mcap", name: "% of Own Market Cap" },
|
||||
pctOwnPnl: { id: "pct-own-pnl", name: "% of Own P&L" },
|
||||
|
||||
// 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: "Sats/vByte" },
|
||||
|
||||
// Rates
|
||||
perSec: { id: "per-sec", name: "Per Second" },
|
||||
|
||||
// Cointime
|
||||
coinblocks: { id: "coinblocks", name: "Coinblocks" },
|
||||
coindays: { id: "coindays", name: "Coindays" },
|
||||
|
||||
// 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 */
|
||||
107
website/scripts/utils/url.js
Normal file
107
website/scripts/utils/url.js
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @param {string | string[]} [pathname]
|
||||
*/
|
||||
function processPathname(pathname) {
|
||||
pathname ||= window.location.pathname;
|
||||
return Array.isArray(pathname) ? pathname.join("/") : pathname;
|
||||
}
|
||||
|
||||
const chartParamsWhitelist = ["from", "to"];
|
||||
|
||||
/**
|
||||
* @param {string | string[]} pathname
|
||||
*/
|
||||
export function pushHistory(pathname) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
pathname = processPathname(pathname);
|
||||
try {
|
||||
const url = `/${pathname}?${urlParams.toString()}`;
|
||||
console.log(`push history: ${url}`);
|
||||
window.history.pushState(null, "", url);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {URLSearchParams} [args.urlParams]
|
||||
* @param {string | string[]} [args.pathname]
|
||||
*/
|
||||
export function replaceHistory({ urlParams, pathname }) {
|
||||
urlParams ||= new URLSearchParams(window.location.search);
|
||||
pathname = processPathname(pathname);
|
||||
try {
|
||||
const url = `/${pathname}?${urlParams.toString()}`;
|
||||
console.log(`replace history: ${url}`);
|
||||
window.history.replaceState(null, "", url);
|
||||
} 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
|
||||
*/
|
||||
export function removeParam(key) {
|
||||
writeParam(key, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
export function readBoolParam(key) {
|
||||
const param = readParam(key);
|
||||
if (param) {
|
||||
return param === "true" || param === "1";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
export function readNumberParam(key) {
|
||||
const param = readParam(key);
|
||||
if (param) {
|
||||
return Number(param);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} key
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function readParam(key) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get(key);
|
||||
}
|
||||
114
website/scripts/utils/ws.js
Normal file
114
website/scripts/utils/ws.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import signals from "../signals.js";
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {(callback: (value: T) => void) => WebSocket} creator
|
||||
*/
|
||||
function createWebsocket(creator) {
|
||||
let ws = /** @type {WebSocket | null} */ (null);
|
||||
|
||||
const live = signals.createSignal(false);
|
||||
const latest = signals.createSignal(/** @type {T | null} */ (null));
|
||||
|
||||
function reinitWebSocket() {
|
||||
if (!ws || ws.readyState === ws.CLOSED) {
|
||||
console.log("ws: reinit");
|
||||
resource.open();
|
||||
}
|
||||
}
|
||||
|
||||
function reinitWebSocketIfDocumentNotHidden() {
|
||||
!window.document.hidden && reinitWebSocket();
|
||||
}
|
||||
|
||||
const resource = {
|
||||
live,
|
||||
latest,
|
||||
open() {
|
||||
ws = creator((value) => latest.set(() => value));
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
console.log("ws: open");
|
||||
live.set(true);
|
||||
});
|
||||
|
||||
ws.addEventListener("close", () => {
|
||||
console.log("ws: close");
|
||||
live.set(false);
|
||||
});
|
||||
|
||||
window.document.addEventListener(
|
||||
"visibilitychange",
|
||||
reinitWebSocketIfDocumentNotHidden,
|
||||
);
|
||||
|
||||
window.document.addEventListener("online", reinitWebSocket);
|
||||
},
|
||||
close() {
|
||||
ws?.close();
|
||||
window.document.removeEventListener(
|
||||
"visibilitychange",
|
||||
reinitWebSocketIfDocumentNotHidden,
|
||||
);
|
||||
window.document.removeEventListener("online", reinitWebSocket);
|
||||
live.set(false);
|
||||
ws = null;
|
||||
},
|
||||
};
|
||||
|
||||
return resource;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(candle: CandlestickData) => void} callback
|
||||
*/
|
||||
function krakenCandleWebSocketCreator(callback) {
|
||||
const ws = new WebSocket("wss://ws.kraken.com/v2");
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
method: "subscribe",
|
||||
params: {
|
||||
channel: "ohlc",
|
||||
symbol: ["BTC/USD"],
|
||||
interval: 1440,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (message) => {
|
||||
const result = JSON.parse(message.data);
|
||||
|
||||
if (result.channel !== "ohlc") return;
|
||||
|
||||
const { interval_begin, open, high, low, close } = result.data.at(-1);
|
||||
|
||||
/** @type {CandlestickData} */
|
||||
const candle = {
|
||||
// index: -1,
|
||||
time: /** @type {Time} */ (new Date(interval_begin).valueOf() / 1000),
|
||||
open: Number(open),
|
||||
high: Number(high),
|
||||
low: Number(low),
|
||||
close: Number(close),
|
||||
};
|
||||
|
||||
candle && callback({ ...candle });
|
||||
});
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
/** @type {ReturnType<typeof createWebsocket<CandlestickData>>} */
|
||||
const kraken1dCandle = createWebsocket((callback) =>
|
||||
krakenCandleWebSocketCreator(callback),
|
||||
);
|
||||
|
||||
kraken1dCandle.open();
|
||||
|
||||
export const webSockets = {
|
||||
kraken1dCandle,
|
||||
};
|
||||
/** @typedef {typeof webSockets} WebSockets */
|
||||
Reference in New Issue
Block a user