global: snapshot

This commit is contained in:
nym21
2026-02-13 13:54:09 +01:00
parent b779edc0d6
commit 80b2c636b0
53 changed files with 1819 additions and 1184 deletions

View File

@@ -472,10 +472,48 @@ export function createChart({ parent, brk, fitContent }) {
lastStamp: null,
/** @type {string | null} */
lastTimeStamp: null,
/** @type {number | null} */
lastVersion: null,
/** @type {number | null} */
lastTimeVersion: null,
/** @type {VoidFunction | null} */
fetch: null,
/** @type {((data: MetricData<number>) => void) | null} */
onTime: null,
reset() {
this.hasData = false;
this.lastTime = -Infinity;
this.lastStamp = null;
this.lastTimeStamp = null;
this.lastVersion = null;
this.lastTimeVersion = null;
},
/**
* @param {string | null} valuesStamp
* @param {string} timeStamp
* @param {number | null} valuesVersion
* @param {number} timeVersion
*/
shouldProcess(valuesStamp, timeStamp, valuesVersion, timeVersion) {
if (
valuesStamp === this.lastStamp &&
timeStamp === this.lastTimeStamp
)
return false;
// Version change means data was recomputed, needs full reload
if (
valuesVersion !== this.lastVersion ||
timeVersion !== this.lastTimeVersion
) {
this.hasData = false;
this.lastTime = -Infinity;
}
this.lastStamp = valuesStamp;
this.lastTimeStamp = timeStamp;
this.lastVersion = valuesVersion;
this.lastTimeVersion = timeVersion;
return true;
},
};
/** @type {AnySeries} */
@@ -523,8 +561,7 @@ export function createChart({ parent, brk, fitContent }) {
/** @param {ChartableIndex} idx */
function setupIndexEffect(idx) {
// Reset data state for new index
state.hasData = false;
state.lastTime = -Infinity;
state.reset();
state.fetch = null;
const _valuesEndpoint = metric.by[idx];
@@ -663,17 +700,21 @@ export function createChart({ parent, brk, fitContent }) {
let valuesData = null;
/** @type {string | null} */
let valuesStamp = null;
/** @type {number | null} */
let valuesVersion = null;
function tryProcess() {
if (seriesGeneration !== generation) return;
if (!timeData || !valuesData) return;
if (
valuesStamp === state.lastStamp &&
timeData.stamp === state.lastTimeStamp
!state.shouldProcess(
valuesStamp,
timeData.stamp,
valuesVersion,
timeData.version,
)
)
return;
state.lastStamp = valuesStamp;
state.lastTimeStamp = timeData.stamp;
if (timeData.data.length && valuesData.length) {
processData(timeData.data, valuesData);
}
@@ -691,12 +732,14 @@ export function createChart({ parent, brk, fitContent }) {
if (cachedValues) {
valuesData = cachedValues.data;
valuesStamp = cachedValues.stamp;
valuesVersion = cachedValues.version;
tryProcess();
}
await valuesEndpoint.slice(-10000).fetch((result) => {
cache.set(valuesEndpoint.path, result);
valuesData = result.data;
valuesStamp = result.stamp;
valuesVersion = result.version;
tryProcess();
});
}
@@ -1253,6 +1296,7 @@ export function createChart({ parent, brk, fitContent }) {
persisted.set(value);
applyScaleForUnit(paneIndex);
},
toTitle: (c) => (c === "lin" ? "Linear scale" : "Logarithmic scale"),
});
td.append(radios);
}

View File

@@ -11,9 +11,9 @@ export function createLegend() {
/** @param {HTMLElement} el */
function captureScroll(el) {
el.addEventListener("wheel", (e) => e.stopPropagation());
el.addEventListener("touchstart", (e) => e.stopPropagation());
el.addEventListener("touchmove", (e) => e.stopPropagation());
el.addEventListener("wheel", (e) => e.stopPropagation(), { passive: true });
el.addEventListener("touchstart", (e) => e.stopPropagation(), { passive: true });
el.addEventListener("touchmove", (e) => e.stopPropagation(), { passive: true });
}
captureScroll(items);

View File

@@ -1,5 +1,6 @@
import { webSockets } from "./utils/ws.js";
import * as formatters from "./utils/format.js";
import { initPrice, onPrice } from "./utils/price.js";
import { brk } from "./client.js";
import { stringToId } from "./utils/format.js";
import { onFirstIntersection, getElementById, isHidden } from "./utils/dom.js";
import { initOptions } from "./options/full.js";
import {
@@ -105,9 +106,11 @@ function initFrameSelectors() {
}
initFrameSelectors();
webSockets.kraken1dCandle.onLatest((latest) => {
console.log("close:", latest.close);
window.document.title = `${latest.close.toLocaleString("en-us")} | ${window.location.host}`;
initPrice(brk);
onPrice((price) => {
console.log("close:", price);
window.document.title = `${price.toLocaleString("en-us")} | ${window.location.host}`;
});
const options = initOptions();
@@ -118,7 +121,7 @@ window.addEventListener("popstate", (_event) => {
while (path.length) {
const id = path.shift();
const res = folder.find((v) => id === formatters.stringToId(v.name));
const res = folder.find((v) => id === stringToId(v.name));
if (!res) throw "Option not found";
if (path.length >= 1) {
if (!("tree" in res)) {

View File

@@ -4,7 +4,7 @@ import { serdeChartableIndex } from "../utils/serde.js";
import { Unit } from "../utils/units.js";
import { createChart } from "../chart/index.js";
import { colors } from "../utils/colors.js";
import { webSockets } from "../utils/ws.js";
import { latestPrice, onPrice } from "../utils/price.js";
import { brk } from "../client.js";
const ONE_BTC_IN_SATS = 100_000_000;
@@ -63,8 +63,8 @@ export function init() {
}
function updatePriceWithLatest() {
const latest = webSockets.kraken1dCandle.latest();
if (!latest) return;
const latest = latestPrice();
if (latest === null) return;
const priceSeries = chart.panes[0].series[0];
const unit = chart.panes[0].unit;
@@ -78,8 +78,8 @@ export function init() {
// Convert to sats if needed
const close =
unit === Unit.sats
? Math.floor(ONE_BTC_IN_SATS / latest.close)
: latest.close;
? Math.floor(ONE_BTC_IN_SATS / latest)
: latest;
priceSeries.update({ ...last, close });
}
@@ -101,7 +101,7 @@ export function init() {
};
// Live price update listener
webSockets.kraken1dCandle.onLatest(updatePriceWithLatest);
onPrice(updatePriceWithLatest);
}
const ALL_CHOICES = /** @satisfies {ChartableIndexName[]} */ ([

View File

@@ -12,8 +12,6 @@
*
* @import { Color } from "./utils/colors.js"
*
* @import { WebSockets } from "./utils/ws.js"
*
* @import { Option, PartialChartOption, ChartOption, AnyPartialOption, ProcessedOptionAddons, OptionsTree, SimulationOption, AnySeriesBlueprint, SeriesType, AnyFetchedSeriesBlueprint, TableOption, ExplorerOption, UrlOption, PartialOptionsGroup, OptionsGroup, PartialOptionsTree, UtxoCohortObject, AddressCohortObject, CohortObject, CohortGroupObject, FetchedLineSeriesBlueprint, FetchedBaselineSeriesBlueprint, FetchedHistogramSeriesBlueprint, FetchedDotsBaselineSeriesBlueprint, PatternAll, PatternFull, PatternWithAdjusted, PatternWithPercentiles, PatternBasic, PatternBasicWithMarketCap, PatternBasicWithoutMarketCap, PatternWithoutRelative, CohortAll, CohortFull, CohortWithAdjusted, CohortWithPercentiles, CohortBasic, CohortBasicWithMarketCap, CohortBasicWithoutMarketCap, CohortWithoutRelative, CohortAddress, CohortLongTerm, CohortAgeRange, CohortMinAge, CohortGroupFull, CohortGroupWithAdjusted, CohortGroupWithPercentiles, CohortGroupLongTerm, CohortGroupAgeRange, CohortGroupBasic, CohortGroupBasicWithMarketCap, CohortGroupBasicWithoutMarketCap, CohortGroupWithoutRelative, CohortGroupMinAge, CohortGroupAddress, UtxoCohortGroupObject, AddressCohortGroupObject, FetchedDotsSeriesBlueprint, FetchedCandlestickSeriesBlueprint, FetchedPriceSeriesBlueprint, AnyPricePattern, AnyValuePattern } from "./options/partial.js"
*
*

View File

@@ -1,24 +1,3 @@
/**
* @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)];
}
/**
* Typed Object.entries that preserves key types
* @template {Record<string, any>} T

View File

@@ -244,20 +244,20 @@ export const colors = {
long: palette.fuchsia,
},
scriptType: seq([
"p2pk65",
"p2pk33",
"p2pkh",
"p2ms",
"p2sh",
"p2wpkh",
"p2wsh",
"p2tr",
"p2a",
"opreturn",
"unknown",
"empty",
]),
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,

View File

@@ -1,58 +0,0 @@
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));
}

View File

@@ -176,6 +176,7 @@ export function createLabeledInput({
* @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,
@@ -184,6 +185,7 @@ export function createRadios({
onChange,
toKey = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c),
toLabel = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c),
toTitle,
}) {
const field = window.document.createElement("div");
field.classList.add("field");
@@ -208,6 +210,7 @@ export function createRadios({
inputName: fieldId,
inputValue: choiceKey,
inputChecked: choiceKey === initialKey,
title: toTitle?.(choice),
type: "radio",
});
@@ -314,31 +317,3 @@ export function createHeader(title = "", level = 1) {
};
}
/**
* @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;
}
/**
* @param {'left' | 'bottom' | 'top' | 'right'} position
*/
export function createShadow(position) {
const div = window.document.createElement("div");
div.classList.add(`shadow-${position}`);
return div;
}

View File

@@ -10,9 +10,6 @@ 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`);

View File

@@ -1,12 +1,6 @@
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");
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;

View File

@@ -3,7 +3,7 @@
* @param {number} [digits]
* @param {Intl.NumberFormatOptions} [options]
*/
export function numberToUSNumber(value, digits, options) {
function numberToUSNumber(value, digits, options) {
return value.toLocaleString("en-us", {
...options,
minimumFractionDigits: digits,
@@ -11,19 +11,6 @@ export function numberToUSNumber(value, digits, options) {
});
}
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 {number} value
* @param {0 | 2} [digits]

View File

@@ -0,0 +1,36 @@
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, 5_000);
document.addEventListener("visibilitychange", () => {
!document.hidden && poll();
});
}

View File

@@ -1,96 +1,3 @@
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

View File

@@ -18,7 +18,7 @@ export function onChange(callback) {
}
/** @param {boolean} value */
export function setDark(value) {
function setDark(value) {
if (dark === value) return;
dark = value;
apply(value);

View File

@@ -67,35 +67,6 @@ export function writeParam(key, value) {
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

View File

@@ -1,126 +0,0 @@
/**
* @template T
* @param {(callback: (value: T) => void) => WebSocket} creator
*/
function createWebsocket(creator) {
let ws = /** @type {WebSocket | null} */ (null);
let _live = false;
let _latest = /** @type {T | null} */ (null);
/** @type {Set<(value: T) => void>} */
const listeners = new Set();
function reinitWebSocket() {
if (!ws || ws.readyState === ws.CLOSED) {
console.log("ws: reinit");
resource.open();
}
}
function reinitWebSocketIfDocumentNotHidden() {
!window.document.hidden && reinitWebSocket();
}
const resource = {
live() {
return _live;
},
latest() {
return _latest;
},
/** @param {(value: T) => void} callback */
onLatest(callback) {
listeners.add(callback);
return () => listeners.delete(callback);
},
open() {
ws = creator((value) => {
_latest = value;
listeners.forEach((cb) => cb(value));
});
ws.addEventListener("open", () => {
console.log("ws: open");
_live = true;
});
ws.addEventListener("close", () => {
console.log("ws: close");
_live = 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 = 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 */