global: snapshot

This commit is contained in:
nym21
2026-01-14 16:38:53 +01:00
parent ddb1db7a8e
commit d75c2a881b
226 changed files with 7776 additions and 20942 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,100 @@
export function createOklchToRGBA() {
{
/**
*
* @param {readonly [number, number, number, number, number, number, number, number, number]} A
* @param {readonly [number, number, number]} B
* @returns
*/
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(
/** @type {const} */ ([
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(
/** @type {const} */ ([
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,
);
}
/** @param {string} oklch */
return function (oklch) {
oklch = oklch.replace("oklch(", "");
oklch = oklch.replace(")", "");
let splitOklch = oklch.split(" / ");
let alpha = 1;
if (splitOklch.length === 2) {
alpha = Number(splitOklch.pop()?.replace("%", "")) / 100;
}
splitOklch = oklch.split(" ");
const lch = splitOklch.map((v, i) => {
if (!i && v.includes("%")) {
return Number(v.replace("%", "")) / 100;
} else {
return Number(v);
}
});
const rgb = srgbLinear2rgb(
xyz2rgbLinear(
oklab2xyz(oklch2oklab(/** @type {[number, number, number]} */ (lch))),
),
).map((v) => {
return Math.max(Math.min(Math.round(v * 255), 255), 0);
});
return [...rgb, alpha];
};
}
}

138
website/scripts/entry.js Normal file
View File

@@ -0,0 +1,138 @@
/**
* @import * as _ from "./modules/leeoniya-ufuzzy/1.0.19/dist/uFuzzy.d.ts"
*
* @import { IChartApi, ISeriesApi as _ISeriesApi, SeriesDefinition, SingleValueData as _SingleValueData, CandlestickData as _CandlestickData, BaselineData as _BaselineData, HistogramData as _HistogramData, SeriesType as LCSeriesType, IPaneApi, LineSeriesPartialOptions as _LineSeriesPartialOptions, HistogramSeriesPartialOptions as _HistogramSeriesPartialOptions, BaselineSeriesPartialOptions as _BaselineSeriesPartialOptions, CandlestickSeriesPartialOptions as _CandlestickSeriesPartialOptions, WhitespaceData, DeepPartial, ChartOptions, Time, LineData as _LineData, createChart as CreateChart, LineStyle } from './modules/lightweight-charts/5.1.0/dist/typings.js'
*
* @import { Signal, Signals, Accessor } from "./signals.js";
*
* @import * as Brk from "./modules/brk-client/index.js"
* @import { BrkClient, Index, Metric, MetricData } from "./modules/brk-client/index.js"
*
* @import { Resources, MetricResource } from './resources.js'
*
* @import { Valued, SingleValueData, CandlestickData, Series, AnySeries, ISeries, HistogramData, LineData, BaselineData, LineSeriesPartialOptions, BaselineSeriesPartialOptions, HistogramSeriesPartialOptions, CandlestickSeriesPartialOptions, CreateChartElement, Chart, Legend } from "./chart/index.js"
*
* @import { Color, ColorName, Colors } 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, PartialContext, PatternAll, PatternFull, PatternWithAdjusted, PatternWithPercentiles, PatternBasic, CohortAll, CohortFull, CohortWithAdjusted, CohortWithPercentiles, CohortBasic, CohortGroupFull, CohortGroupWithAdjusted, CohortGroupWithPercentiles, CohortGroupBasic, UtxoCohortGroupObject, AddressCohortGroupObject, FetchedDotsSeriesBlueprint, FetchedCandlestickSeriesBlueprint } from "./options/partial.js"
*
* @import { line as LineSeriesFn, dots as DotsSeriesFn, candlestick as CandlestickSeriesFn, baseline as BaselineSeriesFn, histogram as HistogramSeriesFn } from "./options/series.js"
*
* @import { UnitObject as Unit } from "./utils/units.js"
*
* @import { ChartableIndexName } from "./panes/chart/index.js";
*/
// import uFuzzy = require("./modules/leeoniya-ufuzzy/1.0.19/dist/uFuzzy.d.ts");
/**
* @typedef {[number, number, number, number]} OHLCTuple
*
* Brk type aliases
* @typedef {Brk.MetricsTree_Distribution_UtxoCohorts} UtxoCohortTree
* @typedef {Brk.MetricsTree_Distribution_AddressCohorts} AddressCohortTree
* @typedef {Brk.MetricsTree_Distribution_UtxoCohorts_All} AllUtxoPattern
* @typedef {Brk.MetricsTree_Distribution_UtxoCohorts_Term_Short} ShortTermPattern
* @typedef {Brk.MetricsTree_Distribution_UtxoCohorts_Term_Long} LongTermPattern
* @typedef {Brk._10yPattern} MaxAgePattern
* @typedef {Brk._10yTo12yPattern} AgeRangePattern
* @typedef {Brk._0satsPattern2} UtxoAmountPattern
* @typedef {Brk._0satsPattern} AddressAmountPattern
* @typedef {Brk._100btcPattern} BasicUtxoPattern
* @typedef {Brk._0satsPattern2} EpochPattern
* @typedef {Brk.Ratio1ySdPattern} Ratio1ySdPattern
* @typedef {Brk.Dollars} Dollars
* @typedef {Brk.Price111dSmaPattern} EmaRatioPattern
* @typedef {Brk.CoinbasePattern} CoinbasePattern
* @typedef {Brk.ActivePriceRatioPattern} ActivePriceRatioPattern
* @typedef {Brk.UnclaimedRewardsPattern} ValuePattern
* @typedef {Brk.AnyMetricPattern} AnyMetricPattern
* @typedef {Brk.AnyMetricEndpointBuilder} AnyMetricEndpoint
* @typedef {Brk.AnyMetricData} AnyMetricData
* @typedef {Brk.AddrCountPattern} AddrCountPattern
* @typedef {Brk.MetricsTree_Blocks_Interval} IntervalPattern
* @typedef {Brk.MetricsTree_Supply_Circulating} SupplyPattern
* @typedef {Brk.RelativePattern} GlobalRelativePattern
* @typedef {Brk.RelativePattern2} OwnRelativePattern
* @typedef {Brk.RelativePattern5} FullRelativePattern
* @typedef {Brk.MetricsTree_Distribution_UtxoCohorts_All_Relative} AllRelativePattern
*/
/**
* @template T
* @typedef {Brk.BlockCountPattern<T>} BlockCountPattern
*/
/**
* @template T
* @typedef {Brk.FullnessPattern<T>} FullnessPattern
*/
/**
* @template T
* @typedef {Brk.FeeRatePattern<T>} FeeRatePattern
*/
/**
* @template T
* @typedef {Brk.MetricEndpointBuilder<T>} MetricEndpoint
*/
/**
* @template T
* @typedef {Brk.DollarsPattern<T>} SizePattern
*/
/**
* @template T
* @typedef {Brk.CountPattern2<T>} CountStatsPattern
*/
/**
* @typedef {Brk.MetricsTree_Blocks_Size} BlockSizePattern
*/
/**
* Stats pattern union - accepts both CountStatsPattern and BlockSizePattern
* @typedef {CountStatsPattern<any> | BlockSizePattern} AnyStatsPattern
*/
/**
*
* @typedef {InstanceType<typeof BrkClient>["INDEXES"]} Indexes
* @typedef {Indexes[number]} IndexName
* @typedef {InstanceType<typeof BrkClient>["POOL_ID_TO_POOL_NAME"]} PoolIdToPoolName
* @typedef {keyof PoolIdToPoolName} PoolId
*
* Tree branch types
* @typedef {Brk.MetricsTree_Market} Market
* @typedef {Brk.MetricsTree_Market_MovingAverage} MarketMovingAverage
* @typedef {Brk.MetricsTree_Market_Dca} MarketDca
*
* Pattern unions by cohort type
* @typedef {AllUtxoPattern | AgeRangePattern | UtxoAmountPattern} UtxoCohortPattern
* @typedef {AddressAmountPattern} AddressCohortPattern
* @typedef {UtxoCohortPattern | AddressCohortPattern} CohortPattern
*
* Relative pattern capability types
* @typedef {GlobalRelativePattern | FullRelativePattern} RelativeWithMarketCap
* @typedef {OwnRelativePattern | FullRelativePattern} RelativeWithOwnMarketCap
* @typedef {OwnRelativePattern | FullRelativePattern | AllRelativePattern} RelativeWithOwnPnl
*
* Capability-based pattern groupings (patterns that have specific properties)
* @typedef {AllUtxoPattern | AgeRangePattern | UtxoAmountPattern} PatternWithRealizedPrice
* @typedef {AllUtxoPattern} PatternWithFullRealized
* @typedef {AllUtxoPattern | AgeRangePattern | UtxoAmountPattern} PatternWithNupl
* @typedef {AllUtxoPattern | AgeRangePattern | UtxoAmountPattern} PatternWithCostBasis
* @typedef {AllUtxoPattern | AgeRangePattern | UtxoAmountPattern} PatternWithActivity
* @typedef {AllUtxoPattern | AgeRangePattern} PatternWithCostBasisPercentiles
*
* Cohort objects with specific pattern capabilities
* @typedef {{ name: string, title: string, color: Color, tree: PatternWithRealizedPrice }} CohortWithRealizedPrice
* @typedef {{ name: string, title: string, color: Color, tree: PatternWithFullRealized }} CohortWithFullRealized
* @typedef {{ name: string, title: string, color: Color, tree: PatternWithNupl }} CohortWithNupl
* @typedef {{ name: string, title: string, color: Color, tree: PatternWithCostBasis }} CohortWithCostBasis
* @typedef {{ name: string, title: string, color: Color, tree: PatternWithActivity }} CohortWithActivity
* @typedef {{ name: string, title: string, color: Color, tree: PatternWithCostBasisPercentiles }} CohortWithCostBasisPercentiles
*
* Generic tree node type for walking
* @typedef {AnyMetricPattern | Record<string, unknown>} TreeNode
*
* Chartable index IDs (subset of IndexName that can be charted)
* @typedef {"height" | "dateindex" | "weekindex" | "monthindex" | "quarterindex" | "semesterindex" | "yearindex" | "decadeindex"} ChartableIndex
*/

649
website/scripts/main.js Normal file
View File

@@ -0,0 +1,649 @@
import { createColors } from "./utils/colors.js";
import { webSockets } from "./utils/ws.js";
import * as formatters from "./utils/format.js";
import { onFirstIntersection, getElementById, isHidden } from "./utils/dom.js";
import signals from "./signals.js";
import { BrkClient } from "./modules/brk-client/index.js";
import { initOptions } from "./options/full.js";
import ufuzzy from "./modules/leeoniya-ufuzzy/1.0.19/dist/uFuzzy.mjs";
import * as leanQr from "./modules/lean-qr/2.7.1/index.mjs";
import { init as initExplorer } from "./panes/explorer.js";
import { init as initChart } from "./panes/chart/index.js";
import { init as initTable } from "./panes/table.js";
import { init as initSimulation } from "./panes/simulation.js";
import { next } from "./utils/timing.js";
import { replaceHistory } from "./utils/url.js";
import { removeStored, writeToStorage } from "./utils/storage.js";
import {
asideElement,
asideLabelElement,
bodyElement,
chartElement,
explorerElement,
frameSelectorsElement,
mainElement,
navElement,
navLabelElement,
searchElement,
searchInput,
searchResultsElement,
simulationElement,
style,
tableElement,
} from "./utils/elements.js";
function initFrameSelectors() {
const children = Array.from(frameSelectorsElement.children);
/** @type {HTMLElement | undefined} */
let focusedFrame = undefined;
for (let i = 0; i < children.length; i++) {
const element = children[i];
switch (element.tagName) {
case "LABEL": {
element.addEventListener("click", () => {
const inputId = element.getAttribute("for");
if (!inputId) {
console.log(element, element.getAttribute("for"));
throw "Input id in label not found";
}
const input = window.document.getElementById(inputId);
if (!input || !("value" in input)) {
throw "Not input or no value";
}
const frame = window.document.getElementById(
/** @type {string} */ (input.value),
);
if (!frame) {
console.log(input.value);
throw "Frame element doesn't exist";
}
if (frame === focusedFrame) {
return;
}
frame.hidden = false;
if (focusedFrame) {
focusedFrame.hidden = true;
}
focusedFrame = frame;
});
break;
}
}
}
asideLabelElement.click();
// When going from mobile view to desktop view, if selected frame was open, go to the nav frame
new IntersectionObserver((entries) => {
for (let i = 0; i < entries.length; i++) {
if (
!entries[i].isIntersecting &&
entries[i].target === asideLabelElement &&
focusedFrame == asideElement
) {
navLabelElement.click();
}
}
}).observe(asideLabelElement);
function setAsideParent() {
const { clientWidth } = window.document.documentElement;
const MEDIUM_WIDTH = 768;
if (clientWidth >= MEDIUM_WIDTH) {
asideElement.parentElement !== bodyElement &&
bodyElement.append(asideElement);
} else {
asideElement.parentElement !== mainElement &&
mainElement.append(asideElement);
}
}
setAsideParent();
window.addEventListener("resize", setAsideParent);
}
initFrameSelectors();
signals.createRoot(() => {
const brk = new BrkClient("/");
const owner = signals.getOwner();
console.log(`VERSION = ${brk.VERSION}`);
function initDark() {
const preferredColorSchemeMatchMedia = window.matchMedia(
"(prefers-color-scheme: dark)",
);
const dark = signals.createSignal(preferredColorSchemeMatchMedia.matches);
preferredColorSchemeMatchMedia.addEventListener("change", ({ matches }) => {
dark.set(matches);
});
return dark;
}
const dark = initDark();
const qrcode = signals.createSignal(/** @type {string | null} */ (null));
signals.createEffect(webSockets.kraken1dCandle.latest, (latest) => {
if (latest) {
console.log("close:", latest.close);
window.document.title = `${latest.close.toLocaleString("en-us")} | ${window.location.host}`;
}
});
// function createLastHeightResource() {
// const lastHeight = signals.createSignal(0);
// function fetchLastHeight() {
// utils.api.fetchLast(
// (h) => {
// lastHeight.set(h);
// },
// /** @satisfies {Height} */ (5),
// "height",
// );
// }
// fetchLastHeight();
// setInterval(fetchLastHeight, 10_000);
// return lastHeight;
// }
// const lastHeight = createLastHeightResource();
const colors = createColors(dark);
const options = initOptions({
colors,
signals,
brk,
qrcode,
});
window.addEventListener("popstate", (_event) => {
const path = window.document.location.pathname.split("/").filter((v) => v);
let folder = options.tree;
while (path.length) {
const id = path.shift();
const res = folder.find((v) => id === formatters.stringToId(v.name));
if (!res) throw "Option not found";
if (path.length >= 1) {
if (!("tree" in res)) {
throw "Unreachable";
}
folder = res.tree;
} else {
if ("tree" in res) {
throw "Unreachable";
}
options.selected.set(res);
}
}
});
function initSelected() {
let firstRun = true;
function initSelectedFrame() {
if (!firstRun) throw Error("Unreachable");
firstRun = false;
const owner = signals.getOwner();
const chartOption = signals.createSignal(
/** @type {ChartOption | null} */ (null),
);
const simOption = signals.createSignal(
/** @type {SimulationOption | null} */ (null),
);
let previousElement = /** @type {HTMLElement | undefined} */ (undefined);
let firstTimeLoadingChart = true;
let firstTimeLoadingTable = true;
let firstTimeLoadingSimulation = true;
let firstTimeLoadingExplorer = true;
signals.createEffect(options.selected, (option) => {
/** @type {HTMLElement} */
let element;
switch (option.kind) {
case "explorer": {
element = explorerElement;
if (firstTimeLoadingExplorer) {
signals.runWithOwner(owner, () => initExplorer());
}
firstTimeLoadingExplorer = false;
break;
}
case "chart": {
element = chartElement;
chartOption.set(option);
if (firstTimeLoadingChart) {
signals.runWithOwner(owner, () =>
initChart({
colors,
option: /** @type {Accessor<ChartOption>} */ (chartOption),
brk,
}),
);
}
firstTimeLoadingChart = false;
break;
}
case "table": {
element = tableElement;
if (firstTimeLoadingTable) {
signals.runWithOwner(owner, () => initTable());
}
firstTimeLoadingTable = false;
break;
}
case "simulation": {
element = simulationElement;
simOption.set(option);
if (firstTimeLoadingSimulation) {
signals.runWithOwner(owner, () =>
initSimulation({
colors,
}),
);
}
firstTimeLoadingSimulation = false;
break;
}
case "url": {
return;
}
}
if (element !== previousElement) {
if (previousElement) previousElement.hidden = true;
element.hidden = false;
}
if (!previousElement) {
replaceHistory({ pathname: option.path });
}
previousElement = element;
});
}
function createMobileSwitchEffect() {
let firstRun = true;
signals.createEffect(options.selected, () => {
if (!firstRun && !isHidden(asideLabelElement)) {
asideLabelElement.click();
}
firstRun = false;
});
}
createMobileSwitchEffect();
onFirstIntersection(asideElement, () =>
signals.runWithOwner(owner, initSelectedFrame),
);
}
initSelected();
onFirstIntersection(navElement, async () => {
options.parent.set(navElement);
const option = options.selected();
if (!option) throw "Selected should be set by now";
const path = [...option.path];
/** @type {HTMLUListElement | null} */
let ul = /** @type {any} */ (null);
async function getFirstChild() {
try {
ul = /** @type {HTMLUListElement} */ (navElement.firstElementChild);
await next();
if (!ul) {
await getFirstChild();
}
} catch (_) {
await next();
await getFirstChild();
}
}
await getFirstChild();
if (!ul) throw Error("Unreachable");
while (path.length > 1) {
const name = path.shift();
if (!name) throw "Unreachable";
/** @type {HTMLDetailsElement[]} */
let detailsList = [];
while (!detailsList.length) {
detailsList = Array.from(ul.querySelectorAll(":scope > li > details"));
if (!detailsList.length) {
await next();
}
}
const details = detailsList.find((s) => s.dataset.name == name);
if (!details) return;
details.open = true;
ul = null;
while (!ul) {
const uls = /** @type {HTMLUListElement[]} */ (
Array.from(details.querySelectorAll(":scope > ul"))
);
if (!uls.length) {
await next();
} else if (uls.length > 1) {
throw "Shouldn't be possible";
} else {
ul = /** @type {HTMLUListElement} */ (uls.pop());
}
}
}
/** @type {HTMLAnchorElement[]} */
let anchors = [];
while (!anchors.length) {
anchors = Array.from(ul.querySelectorAll(":scope > li > a"));
if (!anchors.length) {
await next();
}
}
anchors
.find((a) => a.getAttribute("href") == window.document.location.pathname)
?.scrollIntoView({
behavior: "instant",
block: "center",
});
});
onFirstIntersection(searchElement, () => {
console.log("search: init");
const haystack = options.list.map((option) => option.title);
const RESULTS_PER_PAGE = 100;
/**
* @param {uFuzzy.SearchResult} searchResult
* @param {number} pageIndex
*/
function computeResultPage(searchResult, pageIndex) {
/** @type {{ option: Option, title: string }[]} */
let list = [];
let [indexes, _info, order] = searchResult || [null, null, null];
const minIndex = pageIndex * RESULTS_PER_PAGE;
if (indexes?.length) {
const maxIndex = Math.min(
(order || indexes).length - 1,
minIndex + RESULTS_PER_PAGE - 1,
);
list = Array(maxIndex - minIndex + 1);
for (let i = minIndex; i <= maxIndex; i++) {
let index = indexes[i];
const title = haystack[index];
list[i % 100] = {
option: options.list[index],
title,
};
}
}
return list;
}
/** @type {uFuzzy.Options} */
const config = {
intraIns: Infinity,
intraChars: `[a-z\d' ]`,
};
const fuzzyMultiInsert = /** @type {uFuzzy} */ (
ufuzzy({
intraIns: 1,
})
);
const fuzzyMultiInsertFuzzier = /** @type {uFuzzy} */ (ufuzzy(config));
const fuzzySingleError = /** @type {uFuzzy} */ (
ufuzzy({
intraMode: 1,
...config,
})
);
const fuzzySingleErrorFuzzier = /** @type {uFuzzy} */ (
ufuzzy({
intraMode: 1,
...config,
})
);
/** @type {VoidFunction | undefined} */
let dispose;
function inputEvent() {
signals.createRoot((_dispose) => {
const needle = /** @type {string} */ (searchInput.value);
dispose?.();
dispose = _dispose;
searchResultsElement.scrollTo({
top: 0,
});
if (!needle) {
searchResultsElement.innerHTML = "";
return;
}
const outOfOrder = 5;
const infoThresh = 5_000;
let result = fuzzyMultiInsert?.search(
haystack,
needle,
undefined,
infoThresh,
);
if (!result?.[0]?.length || !result?.[1]) {
result = fuzzyMultiInsert?.search(
haystack,
needle,
outOfOrder,
infoThresh,
);
}
if (!result?.[0]?.length || !result?.[1]) {
result = fuzzySingleError?.search(
haystack,
needle,
outOfOrder,
infoThresh,
);
}
if (!result?.[0]?.length || !result?.[1]) {
result = fuzzySingleErrorFuzzier?.search(
haystack,
needle,
outOfOrder,
infoThresh,
);
}
if (!result?.[0]?.length || !result?.[1]) {
result = fuzzyMultiInsertFuzzier?.search(
haystack,
needle,
undefined,
infoThresh,
);
}
if (!result?.[0]?.length || !result?.[1]) {
result = fuzzyMultiInsertFuzzier?.search(
haystack,
needle,
outOfOrder,
infoThresh,
);
}
searchResultsElement.innerHTML = "";
const list = computeResultPage(result, 0);
list.forEach(({ option, title }) => {
const li = window.document.createElement("li");
searchResultsElement.appendChild(li);
const element = options.createOptionElement({
option,
name: title,
qrcode,
});
if (element) {
li.append(element);
}
});
});
}
if (searchInput.value) {
inputEvent();
}
searchInput.addEventListener("input", inputEvent);
});
function initShare() {
const shareDiv = getElementById("share-div");
const shareContentDiv = getElementById("share-content-div");
shareDiv.addEventListener("click", () => {
qrcode.set(null);
});
shareContentDiv.addEventListener("click", (event) => {
event.stopPropagation();
event.preventDefault();
});
signals.runWithOwner(owner, () => {
const imgQrcode = /** @type {HTMLImageElement} */ (
getElementById("share-img")
);
const anchor = /** @type {HTMLAnchorElement} */ (
getElementById("share-anchor")
);
signals.createEffect(qrcode, (qrcode) => {
if (!qrcode) {
shareDiv.hidden = true;
return;
}
const href = qrcode;
anchor.href = href;
anchor.innerText =
(href.startsWith("http")
? href.split("//").at(-1)
: href.split(":").at(-1)) || "";
imgQrcode.src =
leanQr.generate(/** @type {any} */ (href))?.toDataURL({
// @ts-ignore
padX: 0,
padY: 0,
}) || "";
shareDiv.hidden = false;
});
});
}
initShare();
function initDesktopResizeBar() {
const resizeBar = getElementById("resize-bar");
let resize = false;
let startingWidth = 0;
let startingClientX = 0;
const barWidthLocalStorageKey = "bar-width";
/**
* @param {number | null} width
*/
function setBarWidth(width) {
// TODO: Check if should be a signal ??
try {
if (typeof width === "number") {
mainElement.style.width = `${width}px`;
writeToStorage(barWidthLocalStorageKey, String(width));
} else {
mainElement.style.width = style.getPropertyValue(
"--default-main-width",
);
removeStored(barWidthLocalStorageKey);
}
} catch (_) {}
}
/**
* @param {MouseEvent} event
*/
function mouseMoveEvent(event) {
if (resize) {
setBarWidth(startingWidth + (event.clientX - startingClientX));
}
}
resizeBar.addEventListener("mousedown", (event) => {
startingClientX = event.clientX;
startingWidth = mainElement.clientWidth;
resize = true;
window.document.documentElement.dataset.resize = "";
window.addEventListener("mousemove", mouseMoveEvent);
});
resizeBar.addEventListener("dblclick", () => {
setBarWidth(null);
});
const setResizeFalse = () => {
resize = false;
delete window.document.documentElement.dataset.resize;
window.removeEventListener("mousemove", mouseMoveEvent);
};
window.addEventListener("mouseup", setResizeFalse);
window.addEventListener("mouseleave", setResizeFalse);
}
initDesktopResizeBar();
});

1
website/scripts/modules Symbolic link
View File

@@ -0,0 +1 @@
../../modules

View File

@@ -0,0 +1,821 @@
/** Chain section builder - typed tree-based patterns */
import { Unit } from "../utils/units.js";
/**
* Create Chain section
* @param {PartialContext} ctx
* @returns {PartialOptionsGroup}
*/
export function createChainSection(ctx) {
const {
colors,
brk,
line,
dots,
createPriceLine,
fromSizePattern,
fromFullnessPattern,
fromFeeRatePattern,
fromCoinbasePattern,
fromValuePattern,
fromBlockCountWithUnit,
fromIntervalPattern,
fromSupplyPattern,
} = ctx;
const {
blocks,
transactions,
pools,
inputs,
outputs,
market,
scripts,
supply,
} = brk.metrics;
// Build pools tree dynamically
const poolEntries = Object.entries(pools.vecs);
const poolsTree = poolEntries.map(([key, pool]) => {
const poolName =
brk.POOL_ID_TO_POOL_NAME[
/** @type {keyof typeof brk.POOL_ID_TO_POOL_NAME} */ (key.toLowerCase())
] || key;
return {
name: poolName,
tree: [
{
name: "Dominance",
title: `Mining Dominance of ${poolName}`,
bottom: [
line({
metric: pool._24hDominance,
name: "24h",
color: colors.orange,
unit: Unit.percentage,
defaultActive: false,
}),
line({
metric: pool._1wDominance,
name: "1w",
color: colors.red,
unit: Unit.percentage,
defaultActive: false,
}),
line({
metric: pool._1mDominance,
name: "1m",
unit: Unit.percentage,
}),
line({
metric: pool._1yDominance,
name: "1y",
color: colors.lime,
unit: Unit.percentage,
defaultActive: false,
}),
line({
metric: pool.dominance,
name: "all time",
color: colors.teal,
unit: Unit.percentage,
defaultActive: false,
}),
],
},
{
name: "Blocks mined",
title: `Blocks mined by ${poolName}`,
bottom: [
line({
metric: pool.blocksMined.sum,
name: "Sum",
unit: Unit.count,
}),
line({
metric: pool.blocksMined.cumulative,
name: "Cumulative",
color: colors.blue,
unit: Unit.count,
}),
line({
metric: pool._1wBlocksMined,
name: "1w Sum",
color: colors.red,
unit: Unit.count,
defaultActive: false,
}),
line({
metric: pool._1mBlocksMined,
name: "1m Sum",
color: colors.pink,
unit: Unit.count,
defaultActive: false,
}),
line({
metric: pool._1yBlocksMined,
name: "1y Sum",
color: colors.purple,
unit: Unit.count,
defaultActive: false,
}),
],
},
{
name: "Rewards",
title: `Rewards collected by ${poolName}`,
bottom: [
...fromValuePattern(
pool.coinbase,
"coinbase",
colors.orange,
colors.red,
),
...fromValuePattern(
pool.subsidy,
"subsidy",
colors.lime,
colors.emerald,
),
...fromValuePattern(pool.fee, "fee", colors.cyan, colors.indigo),
],
},
{
name: "Days since block",
title: `Days since ${poolName} mined a block`,
bottom: [
line({
metric: pool.daysSinceBlock,
name: "Since block",
unit: Unit.days,
}),
],
},
],
};
});
return {
name: "Chain",
tree: [
// Block
{
name: "Block",
tree: [
{
name: "Count",
title: "Block Count",
bottom: [
...fromBlockCountWithUnit(
blocks.count.blockCount,
"Block",
Unit.count,
),
line({
metric: blocks.count.blockCountTarget,
name: "Target",
color: colors.gray,
unit: Unit.count,
options: { lineStyle: 4 },
}),
line({
metric: blocks.count._1wBlockCount,
name: "1w sum",
color: colors.red,
unit: Unit.count,
defaultActive: false,
}),
line({
metric: blocks.count._1mBlockCount,
name: "1m sum",
color: colors.pink,
unit: Unit.count,
defaultActive: false,
}),
line({
metric: blocks.count._1yBlockCount,
name: "1y sum",
color: colors.purple,
unit: Unit.count,
defaultActive: false,
}),
],
},
{
name: "Interval",
title: "Block Interval",
bottom: [
...fromIntervalPattern(blocks.interval, "Interval", Unit.secs),
createPriceLine({ unit: Unit.secs, name: "Target", number: 600 }),
],
},
{
name: "Size",
title: "Block Size",
bottom: [
...fromSizePattern(blocks.size, "Size", Unit.bytes),
...fromFullnessPattern(blocks.vbytes, "Vbytes", Unit.vb),
...fromFullnessPattern(blocks.weight, "Weight", Unit.wu),
],
},
],
},
// Transaction
{
name: "Transaction",
tree: [
{
name: "Count",
title: "Transaction Count",
bottom: fromFullnessPattern(
transactions.count.txCount,
"Count",
Unit.count,
),
},
{
name: "Volume",
title: "Transaction Volume",
bottom: [
line({
metric: transactions.volume.sentSum.sats,
name: "Sent",
unit: Unit.sats,
}),
line({
metric: transactions.volume.sentSum.bitcoin,
name: "Sent",
unit: Unit.btc,
}),
line({
metric: transactions.volume.sentSum.dollars,
name: "Sent",
unit: Unit.usd,
}),
line({
metric: transactions.volume.annualizedVolume.sats,
name: "annualized",
color: colors.red,
unit: Unit.sats,
defaultActive: false,
}),
line({
metric: transactions.volume.annualizedVolume.bitcoin,
name: "annualized",
color: colors.red,
unit: Unit.btc,
defaultActive: false,
}),
line({
metric: transactions.volume.annualizedVolume.dollars,
name: "annualized",
color: colors.lime,
unit: Unit.usd,
defaultActive: false,
}),
],
},
{
name: "Size",
title: "Transaction Size",
bottom: [
...fromFeeRatePattern(
transactions.size.weight,
"weight",
Unit.wu,
),
...fromFeeRatePattern(transactions.size.vsize, "vsize", Unit.vb),
],
},
{
name: "Versions",
title: "Transaction Versions",
bottom: [
...fromBlockCountWithUnit(
transactions.versions.v1,
"v1",
Unit.count,
colors.orange,
colors.red,
),
...fromBlockCountWithUnit(
transactions.versions.v2,
"v2",
Unit.count,
colors.cyan,
colors.blue,
),
...fromBlockCountWithUnit(
transactions.versions.v3,
"v3",
Unit.count,
colors.lime,
colors.green,
),
],
},
{
name: "Velocity",
title: "Transactions Velocity",
bottom: [
line({
metric: supply.velocity.btc,
name: "bitcoin",
unit: Unit.ratio,
}),
line({
metric: supply.velocity.usd,
name: "dollars",
color: colors.emerald,
unit: Unit.ratio,
}),
],
},
{
name: "Speed",
title: "Transactions Per Second",
bottom: [
line({
metric: transactions.volume.txPerSec,
name: "Transactions",
unit: Unit.perSec,
}),
],
},
],
},
// Input
{
name: "Input",
tree: [
{
name: "Count",
title: "Transaction Input Count",
bottom: [...fromSizePattern(inputs.count, "Input", Unit.count)],
},
{
name: "Speed",
title: "Inputs Per Second",
bottom: [
line({
metric: transactions.volume.inputsPerSec,
name: "Inputs",
unit: Unit.perSec,
}),
],
},
],
},
// Output
{
name: "Output",
tree: [
{
name: "Count",
title: "Transaction Output Count",
bottom: [
...fromSizePattern(
outputs.count.totalCount,
"Output",
Unit.count,
),
],
},
{
name: "Speed",
title: "Outputs Per Second",
bottom: [
line({
metric: transactions.volume.outputsPerSec,
name: "Outputs",
unit: Unit.perSec,
}),
],
},
],
},
{
name: "UTXO",
tree: [
{
name: "Count",
title: "UTXO Count",
bottom: [
line({
metric: outputs.count.utxoCount,
name: "Count",
unit: Unit.count,
}),
],
},
],
},
// Coinbase
{
name: "Coinbase",
title: "Coinbase Rewards",
bottom: fromCoinbasePattern(blocks.rewards.coinbase, "Coinbase"),
},
// Subsidy
{
name: "Subsidy",
title: "Block Subsidy",
bottom: [
...fromCoinbasePattern(blocks.rewards.subsidy, "Subsidy"),
line({
metric: blocks.rewards.subsidyDominance,
name: "Dominance",
color: colors.purple,
unit: Unit.percentage,
defaultActive: false,
}),
],
},
// Fee
{
name: "Fee",
tree: [
{
name: "Total",
title: "Transaction Fees",
bottom: [
line({
metric: transactions.fees.fee.sats.sum,
name: "Sum",
unit: Unit.sats,
}),
line({
metric: transactions.fees.fee.sats.cumulative,
name: "Cumulative",
color: colors.blue,
unit: Unit.sats,
defaultActive: false,
}),
line({
metric: transactions.fees.fee.bitcoin.sum,
name: "Sum",
unit: Unit.btc,
}),
line({
metric: transactions.fees.fee.bitcoin.cumulative,
name: "Cumulative",
color: colors.blue,
unit: Unit.btc,
defaultActive: false,
}),
line({
metric: transactions.fees.fee.dollars.sum,
name: "Sum",
unit: Unit.usd,
}),
line({
metric: transactions.fees.fee.dollars.cumulative,
name: "Cumulative",
color: colors.blue,
unit: Unit.usd,
defaultActive: false,
}),
line({
metric: blocks.rewards.feeDominance,
name: "Dominance",
color: colors.purple,
unit: Unit.percentage,
defaultActive: false,
}),
],
},
{
name: "Rate",
title: "Fee Rate",
bottom: [
line({
metric: transactions.fees.feeRate.median,
name: "Median",
color: colors.purple,
unit: Unit.feeRate,
}),
line({
metric: transactions.fees.feeRate.average,
name: "Average",
color: colors.blue,
unit: Unit.feeRate,
defaultActive: false,
}),
line({
metric: transactions.fees.feeRate.min,
name: "Min",
color: colors.red,
unit: Unit.feeRate,
defaultActive: false,
}),
line({
metric: transactions.fees.feeRate.max,
name: "Max",
color: colors.green,
unit: Unit.feeRate,
defaultActive: false,
}),
line({
metric: transactions.fees.feeRate.pct10,
name: "pct10",
color: colors.rose,
unit: Unit.feeRate,
defaultActive: false,
}),
line({
metric: transactions.fees.feeRate.pct25,
name: "pct25",
color: colors.pink,
unit: Unit.feeRate,
defaultActive: false,
}),
line({
metric: transactions.fees.feeRate.pct75,
name: "pct75",
color: colors.violet,
unit: Unit.feeRate,
defaultActive: false,
}),
line({
metric: transactions.fees.feeRate.pct90,
name: "pct90",
color: colors.fuchsia,
unit: Unit.feeRate,
defaultActive: false,
}),
],
},
],
},
// Mining
{
name: "Mining",
tree: [
{
name: "Hashrate",
title: "Network Hashrate",
bottom: [
dots({
metric: blocks.mining.hashRate,
name: "Hashrate",
unit: Unit.hashRate,
}),
line({
metric: blocks.mining.hashRate1wSma,
name: "1w SMA",
color: colors.red,
unit: Unit.hashRate,
defaultActive: false,
}),
line({
metric: blocks.mining.hashRate1mSma,
name: "1m SMA",
color: colors.orange,
unit: Unit.hashRate,
defaultActive: false,
}),
line({
metric: blocks.mining.hashRate2mSma,
name: "2m SMA",
color: colors.yellow,
unit: Unit.hashRate,
defaultActive: false,
}),
line({
metric: blocks.mining.hashRate1ySma,
name: "1y SMA",
color: colors.lime,
unit: Unit.hashRate,
defaultActive: false,
}),
],
},
{
name: "Difficulty",
title: "Network Difficulty",
bottom: [
line({
metric: blocks.difficulty.raw,
name: "Difficulty",
unit: Unit.difficulty,
}),
line({
metric: blocks.difficulty.adjustment,
name: "Adjustment",
color: colors.orange,
unit: Unit.percentage,
defaultActive: false,
}),
line({
metric: blocks.difficulty.asHash,
name: "As hash",
color: colors.default,
unit: Unit.hashRate,
defaultActive: false,
options: { lineStyle: 1 },
}),
line({
metric: blocks.difficulty.blocksBeforeNextAdjustment,
name: "Blocks until adj.",
color: colors.indigo,
unit: Unit.blocks,
defaultActive: false,
}),
line({
metric: blocks.difficulty.daysBeforeNextAdjustment,
name: "Days until adj.",
color: colors.purple,
unit: Unit.days,
defaultActive: false,
}),
],
},
{
name: "Hash Price",
title: "Hash Price",
bottom: [
line({
metric: blocks.mining.hashPriceThs,
name: "TH/s",
color: colors.emerald,
unit: Unit.usdPerThsPerDay,
}),
line({
metric: blocks.mining.hashPricePhs,
name: "PH/s",
color: colors.emerald,
unit: Unit.usdPerPhsPerDay,
}),
line({
metric: blocks.mining.hashPriceRebound,
name: "Rebound",
color: colors.yellow,
unit: Unit.percentage,
}),
line({
metric: blocks.mining.hashPriceThsMin,
name: "TH/s Min",
color: colors.red,
unit: Unit.usdPerThsPerDay,
options: { lineStyle: 1 },
}),
line({
metric: blocks.mining.hashPricePhsMin,
name: "PH/s Min",
color: colors.red,
unit: Unit.usdPerPhsPerDay,
options: { lineStyle: 1 },
}),
],
},
{
name: "Hash Value",
title: "Hash Value",
bottom: [
line({
metric: blocks.mining.hashValueThs,
name: "TH/s",
color: colors.orange,
unit: Unit.satsPerThsPerDay,
}),
line({
metric: blocks.mining.hashValuePhs,
name: "PH/s",
color: colors.orange,
unit: Unit.satsPerPhsPerDay,
}),
line({
metric: blocks.mining.hashValueRebound,
name: "Rebound",
color: colors.yellow,
unit: Unit.percentage,
}),
line({
metric: blocks.mining.hashValueThsMin,
name: "TH/s Min",
color: colors.red,
unit: Unit.satsPerThsPerDay,
options: { lineStyle: 1 },
}),
line({
metric: blocks.mining.hashValuePhsMin,
name: "PH/s Min",
color: colors.red,
unit: Unit.satsPerPhsPerDay,
options: { lineStyle: 1 },
}),
],
},
{
name: "Halving",
title: "Halving Info",
bottom: [
line({
metric: blocks.halving.blocksBeforeNextHalving,
name: "Blocks until halving",
unit: Unit.blocks,
}),
line({
metric: blocks.halving.daysBeforeNextHalving,
name: "Days until halving",
color: colors.orange,
unit: Unit.days,
}),
line({
metric: blocks.halving.epoch,
name: "Halving epoch",
color: colors.purple,
unit: Unit.epoch,
defaultActive: false,
}),
],
},
{
name: "Puell Multiple",
title: "Puell Multiple",
bottom: [
line({
metric: market.indicators.puellMultiple,
name: "Puell Multiple",
unit: Unit.ratio,
}),
createPriceLine({ unit: Unit.ratio, number: 1 }),
],
},
],
},
// Pools
{
name: "Pools",
tree: poolsTree,
},
// Unspendable
{
name: "Unspendable",
tree: [
{
name: "Supply",
title: "Unspendable Supply",
bottom: fromValuePattern(supply.burned.unspendable, "Supply"),
},
{
name: "OP_RETURN",
tree: [
{
name: "Outputs",
title: "OP_RETURN Outputs",
bottom: fromFullnessPattern(
scripts.count.opreturn,
"Count",
Unit.count,
),
},
{
name: "Supply",
title: "OP_RETURN Supply",
bottom: fromValuePattern(supply.burned.opreturn, "Supply"),
},
],
},
],
},
// Supply
{
name: "Supply",
title: "Circulating Supply",
bottom: fromSupplyPattern(supply.circulating, "Supply"),
},
// Inflation
{
name: "Inflation",
title: "Inflation Rate",
bottom: [
line({
metric: supply.inflation,
name: "Rate",
unit: Unit.percentage,
}),
],
},
// Unclaimed Rewards
{
name: "Unclaimed Rewards",
title: "Unclaimed Block Rewards",
bottom: fromValuePattern(blocks.rewards.unclaimedRewards, "Unclaimed"),
},
],
};
}

View File

@@ -0,0 +1,400 @@
/**
* Address cohort folder builder
* Creates option trees for address-based cohorts (has addrCount)
* Address cohorts use _0satsPattern which has CostBasisPattern (no percentiles)
*/
import { Unit } from "../../utils/units.js";
import {
createSingleSupplySeries,
createGroupedSupplyTotalSeries,
createGroupedSupplyInProfitSeries,
createGroupedSupplyInLossSeries,
createUtxoCountSeries,
createAddressCountSeries,
createRealizedPriceSeries,
createRealizedPriceRatioSeries,
} from "./shared.js";
/**
* Create a cohort folder for address cohorts
* Includes address count section (addrCount exists on AddressCohortObject)
* @param {PartialContext} ctx
* @param {AddressCohortObject | AddressCohortGroupObject} args
* @returns {PartialOptionsGroup}
*/
export function createAddressCohortFolder(ctx, args) {
const list = "list" in args ? args.list : [args];
const useGroupName = "list" in args;
const isSingle = !("list" in args);
const title = args.title ? `${useGroupName ? "by" : "of"} ${args.title}` : "";
return {
name: args.name || "all",
tree: [
// Supply section
isSingle
? {
name: "supply",
title: `Supply ${title}`,
bottom: createSingleSupplySeries(
ctx,
/** @type {AddressCohortObject} */ (args),
),
}
: {
name: "supply",
tree: [
{
name: "total",
title: `Supply ${title}`,
bottom: createGroupedSupplyTotalSeries(ctx, list),
},
{
name: "in profit",
title: `Supply In Profit ${title}`,
bottom: createGroupedSupplyInProfitSeries(ctx, list),
},
{
name: "in loss",
title: `Supply In Loss ${title}`,
bottom: createGroupedSupplyInLossSeries(ctx, list),
},
],
},
// UTXO count
{
name: "utxo count",
title: `UTXO Count ${title}`,
bottom: createUtxoCountSeries(ctx, list, useGroupName),
},
// Address count (ADDRESS COHORTS ONLY - fully type safe!)
{
name: "address count",
title: `Address Count ${title}`,
bottom: createAddressCountSeries(ctx, list, useGroupName),
},
// Realized section
{
name: "Realized",
tree: [
...(useGroupName
? [
{
name: "Price",
title: `Realized Price ${title}`,
top: createRealizedPriceSeries(ctx, list),
},
{
name: "Ratio",
title: `Realized Price Ratio ${title}`,
bottom: createRealizedPriceRatioSeries(ctx, list),
},
]
: createRealizedPriceOptions(
ctx,
/** @type {AddressCohortObject} */ (args),
title,
)),
{
name: "capitalization",
title: `Realized Capitalization ${title}`,
bottom: createRealizedCapWithExtras(ctx, list, args, useGroupName),
},
...(!useGroupName
? createRealizedPnlSection(
ctx,
/** @type {AddressCohortObject} */ (args),
title,
)
: []),
],
},
// Unrealized section
...createUnrealizedSection(ctx, list, useGroupName, title),
// Cost basis section (no percentiles for address cohorts)
...createCostBasisSection(ctx, list, useGroupName, title),
// Activity section
...createActivitySection(ctx, list, useGroupName, title),
],
};
}
/**
* Create realized price options for single cohort
* @param {PartialContext} ctx
* @param {AddressCohortObject} args
* @param {string} title
* @returns {PartialOptionsTree}
*/
function createRealizedPriceOptions(ctx, args, title) {
const { line } = ctx;
const { tree, color } = args;
return [
{
name: "price",
title: `Realized Price ${title}`,
top: [
line({
metric: tree.realized.realizedPrice,
name: "Realized",
color,
unit: Unit.usd,
}),
],
},
];
}
/**
* Create realized cap with extras
* @param {PartialContext} ctx
* @param {readonly AddressCohortObject[]} list
* @param {AddressCohortObject | AddressCohortGroupObject} args
* @param {boolean} useGroupName
* @returns {AnyFetchedSeriesBlueprint[]}
*/
function createRealizedCapWithExtras(ctx, list, args, useGroupName) {
const { line, baseline, createPriceLine } = ctx;
const isSingle = !("list" in args);
return list.flatMap(({ color, name, tree }) => [
line({
metric: tree.realized.realizedCap,
name: useGroupName ? name : "Capitalization",
color,
unit: Unit.usd,
}),
...(isSingle
? [
baseline({
metric: tree.realized.realizedCap30dDelta,
name: "30d Change",
unit: Unit.usd,
defaultActive: false,
}),
createPriceLine({ unit: Unit.usd, defaultActive: false }),
]
: []),
// RealizedPattern (address cohorts) doesn't have realizedCapRelToOwnMarketCap
]);
}
/**
* Create realized PnL section for single cohort
* @param {PartialContext} ctx
* @param {AddressCohortObject} args
* @param {string} title
* @returns {PartialOptionsTree}
*/
function createRealizedPnlSection(ctx, args, title) {
const { colors, line } = ctx;
const { realized } = args.tree;
return [
{
name: "pnl",
title: `Realized Profit And Loss ${title}`,
bottom: [
line({
metric: realized.realizedProfit.sum,
name: "Profit",
color: colors.green,
unit: Unit.usd,
}),
line({
metric: realized.realizedLoss.sum,
name: "Loss",
color: colors.red,
unit: Unit.usd,
defaultActive: false,
}),
// RealizedPattern (address cohorts) doesn't have realizedProfitToLossRatio
line({
metric: realized.totalRealizedPnl,
name: "Total",
color: colors.default,
defaultActive: false,
unit: Unit.usd,
}),
line({
metric: realized.negRealizedLoss.sum,
name: "Negative Loss",
color: colors.red,
unit: Unit.usd,
}),
line({
metric: realized.negRealizedLoss.cumulative,
name: "Negative Loss",
color: colors.red,
unit: Unit.usd,
}),
],
},
];
}
/**
* Create unrealized section
* @param {PartialContext} ctx
* @param {readonly AddressCohortObject[]} list
* @param {boolean} useGroupName
* @param {string} title
* @returns {PartialOptionsTree}
*/
function createUnrealizedSection(ctx, list, useGroupName, title) {
const { colors, line, baseline } = ctx;
return [
{
name: "Unrealized",
tree: [
{
name: "nupl",
title: `Net Unrealized Profit/Loss ${title}`,
bottom: list.flatMap(({ color, name, tree }) => [
baseline({
metric: tree.unrealized.netUnrealizedPnl,
name: useGroupName ? name : "NUPL",
color: useGroupName ? color : [colors.red, colors.green],
unit: Unit.ratio,
options: { baseValue: { price: 0 } },
}),
]),
},
{
name: "profit",
title: `Unrealized Profit ${title}`,
bottom: list.flatMap(({ color, name, tree }) => [
line({
metric: tree.unrealized.unrealizedProfit,
name: useGroupName ? name : "Profit",
color,
unit: Unit.usd,
}),
]),
},
{
name: "loss",
title: `Unrealized Loss ${title}`,
bottom: list.flatMap(({ color, name, tree }) => [
line({
metric: tree.unrealized.unrealizedLoss,
name: useGroupName ? name : "Loss",
color,
unit: Unit.usd,
}),
]),
},
],
},
];
}
/**
* Create cost basis section (no percentiles for address cohorts)
* @param {PartialContext} ctx
* @param {readonly AddressCohortObject[]} list
* @param {boolean} useGroupName
* @param {string} title
* @returns {PartialOptionsTree}
*/
function createCostBasisSection(ctx, list, useGroupName, title) {
const { line } = ctx;
return [
{
name: "Cost Basis",
tree: [
{
name: "min",
title: `Min Cost Basis ${title}`,
top: list.map(({ color, name, tree }) =>
line({
metric: tree.costBasis.min,
name: useGroupName ? name : "Min",
color,
unit: Unit.usd,
}),
),
},
{
name: "max",
title: `Max Cost Basis ${title}`,
top: list.map(({ color, name, tree }) =>
line({
metric: tree.costBasis.max,
name: useGroupName ? name : "Max",
color,
unit: Unit.usd,
}),
),
},
],
},
];
}
/**
* Create activity section
* @param {PartialContext} ctx
* @param {readonly AddressCohortObject[]} list
* @param {boolean} useGroupName
* @param {string} title
* @returns {PartialOptionsTree}
*/
function createActivitySection(ctx, list, useGroupName, title) {
const { line } = ctx;
return [
{
name: "Activity",
tree: [
{
name: "coinblocks destroyed",
title: `Coinblocks Destroyed ${title}`,
bottom: list.flatMap(({ color, name, tree }) => [
line({
metric: tree.activity.coinblocksDestroyed.sum,
name: useGroupName ? name : "Coinblocks",
color,
unit: Unit.coinblocks,
}),
line({
metric: tree.activity.coinblocksDestroyed.cumulative,
name: useGroupName ? name : "Coinblocks",
color,
unit: Unit.coinblocks,
}),
]),
},
{
name: "coindays destroyed",
title: `Coindays Destroyed ${title}`,
bottom: list.flatMap(({ color, name, tree }) => [
line({
metric: tree.activity.coindaysDestroyed.sum,
name: useGroupName ? name : "Coindays",
color,
unit: Unit.coindays,
}),
line({
metric: tree.activity.coindaysDestroyed.cumulative,
name: useGroupName ? name : "Coindays",
color,
unit: Unit.coindays,
}),
]),
},
],
},
];
}

View File

@@ -0,0 +1,229 @@
/** Build cohort data arrays from brk.metrics */
import {
termColors,
maxAgeColors,
minAgeColors,
ageRangeColors,
epochColors,
geAmountColors,
ltAmountColors,
amountRangeColors,
spendableTypeColors,
} from "../colors/index.js";
/**
* @template {Record<string, any>} T
* @param {T} obj
* @returns {[keyof T & string, T[keyof T & string]][]}
*/
const entries = (obj) =>
/** @type {[keyof T & string, T[keyof T & string]][]} */ (
Object.entries(obj)
);
/**
* Build all cohort data from brk tree
* @param {Colors} colors
* @param {BrkClient} brk
*/
export function buildCohortData(colors, brk) {
const utxoCohorts = brk.metrics.distribution.utxoCohorts;
const addressCohorts = brk.metrics.distribution.addressCohorts;
const {
TERM_NAMES,
EPOCH_NAMES,
MAX_AGE_NAMES,
MIN_AGE_NAMES,
AGE_RANGE_NAMES,
GE_AMOUNT_NAMES,
LT_AMOUNT_NAMES,
AMOUNT_RANGE_NAMES,
SPENDABLE_TYPE_NAMES,
} = brk;
// Base cohort representing "all" - CohortAll (adjustedSopr + percentiles but no RelToMarketCap)
/** @type {CohortAll} */
const cohortAll = {
name: "",
title: "",
color: colors.orange,
tree: utxoCohorts.all,
};
// Term cohorts - split because short is CohortFull, long is CohortWithPercentiles
const shortNames = TERM_NAMES.short;
/** @type {CohortFull} */
const termShort = {
name: shortNames.short,
title: shortNames.long,
color: colors[termColors.short],
tree: utxoCohorts.term.short,
};
const longNames = TERM_NAMES.long;
/** @type {CohortWithPercentiles} */
const termLong = {
name: longNames.short,
title: longNames.long,
color: colors[termColors.long],
tree: utxoCohorts.term.long,
};
// Max age cohorts (up to X time) - CohortWithAdjusted (adjustedSopr only)
/** @type {readonly CohortWithAdjusted[]} */
const upToDate = entries(utxoCohorts.maxAge).map(([key, tree]) => {
const names = MAX_AGE_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[maxAgeColors[key]],
tree,
};
});
// Min age cohorts (from X time) - CohortBasic (neither adjustedSopr nor percentiles)
/** @type {readonly CohortBasic[]} */
const fromDate = entries(utxoCohorts.minAge).map(([key, tree]) => {
const names = MIN_AGE_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[minAgeColors[key]],
tree,
};
});
// Age range cohorts - CohortWithPercentiles (percentiles only)
/** @type {readonly CohortWithPercentiles[]} */
const dateRange = entries(utxoCohorts.ageRange).map(([key, tree]) => {
const names = AGE_RANGE_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[ageRangeColors[key]],
tree,
};
});
// Epoch cohorts - CohortBasic (neither adjustedSopr nor percentiles)
/** @type {readonly CohortBasic[]} */
const epoch = entries(utxoCohorts.epoch).map(([key, tree]) => {
const names = EPOCH_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[epochColors[key]],
tree,
};
});
// UTXOs above amount - CohortBasic (neither adjustedSopr nor percentiles)
/** @type {readonly CohortBasic[]} */
const utxosAboveAmount = entries(utxoCohorts.geAmount).map(([key, tree]) => {
const names = GE_AMOUNT_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[geAmountColors[key]],
tree,
};
});
// Addresses above amount
/** @type {readonly AddressCohortObject[]} */
const addressesAboveAmount = entries(addressCohorts.geAmount).map(
([key, tree]) => {
const names = GE_AMOUNT_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[geAmountColors[key]],
tree,
};
},
);
// UTXOs under amount - CohortBasic (neither adjustedSopr nor percentiles)
/** @type {readonly CohortBasic[]} */
const utxosUnderAmount = entries(utxoCohorts.ltAmount).map(([key, tree]) => {
const names = LT_AMOUNT_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[ltAmountColors[key]],
tree,
};
});
// Addresses under amount
/** @type {readonly AddressCohortObject[]} */
const addressesUnderAmount = entries(addressCohorts.ltAmount).map(
([key, tree]) => {
const names = LT_AMOUNT_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[ltAmountColors[key]],
tree,
};
},
);
// UTXOs amount ranges - CohortBasic (neither adjustedSopr nor percentiles)
/** @type {readonly CohortBasic[]} */
const utxosAmountRanges = entries(utxoCohorts.amountRange).map(
([key, tree]) => {
const names = AMOUNT_RANGE_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[amountRangeColors[key]],
tree,
};
},
);
// Addresses amount ranges
/** @type {readonly AddressCohortObject[]} */
const addressesAmountRanges = entries(addressCohorts.amountRange).map(
([key, tree]) => {
const names = AMOUNT_RANGE_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[amountRangeColors[key]],
tree,
};
},
);
// Spendable type cohorts - CohortBasic (neither adjustedSopr nor percentiles)
/** @type {readonly CohortBasic[]} */
const type = entries(utxoCohorts.type).map(([key, tree]) => {
const names = SPENDABLE_TYPE_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[spendableTypeColors[key]],
tree,
};
});
return {
cohortAll,
termShort,
termLong,
upToDate,
fromDate,
dateRange,
epoch,
utxosAboveAmount,
addressesAboveAmount,
utxosUnderAmount,
addressesUnderAmount,
utxosAmountRanges,
addressesAmountRanges,
type,
};
}

View File

@@ -0,0 +1,31 @@
/**
* Cohort module - exports all cohort-related functionality
*/
// Cohort data builder
export { buildCohortData } from "./data.js";
// Cohort folder builders (type-safe!)
export {
createCohortFolderAll,
createCohortFolderFull,
createCohortFolderWithAdjusted,
createCohortFolderWithPercentiles,
createCohortFolderBasic,
} from "./utxo.js";
export { createAddressCohortFolder } from "./address.js";
// Shared helpers
export {
createSingleSupplySeries,
createGroupedSupplyTotalSeries,
createGroupedSupplyInProfitSeries,
createGroupedSupplyInLossSeries,
createUtxoCountSeries,
createAddressCountSeries,
createRealizedPriceSeries,
createRealizedPriceRatioSeries,
createRealizedCapSeries,
createCostBasisMinMaxSeries,
createCostBasisPercentilesSeries,
} from "./shared.js";

View File

@@ -0,0 +1,417 @@
/** Shared cohort chart section builders */
import { Unit } from "../../utils/units.js";
/**
* Create supply section for a single cohort
* @param {PartialContext} ctx
* @param {CohortObject} cohort
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function createSingleSupplySeries(ctx, cohort) {
const { colors, line, createPriceLine } = ctx;
const { tree } = cohort;
return [
line({
metric: tree.supply.total.sats,
name: "Supply",
color: colors.default,
unit: Unit.sats,
}),
line({
metric: tree.supply.total.bitcoin,
name: "Supply",
color: colors.default,
unit: Unit.btc,
}),
line({
metric: tree.supply.total.dollars,
name: "Supply",
color: colors.default,
unit: Unit.usd,
}),
...("supplyRelToCirculatingSupply" in tree.relative
? [
line({
metric: tree.relative.supplyRelToCirculatingSupply,
name: "Supply",
color: colors.default,
unit: Unit.pctSupply,
}),
]
: []),
line({
metric: tree.unrealized.supplyInProfit.sats,
name: "In Profit",
color: colors.green,
unit: Unit.sats,
}),
line({
metric: tree.unrealized.supplyInProfit.bitcoin,
name: "In Profit",
color: colors.green,
unit: Unit.btc,
}),
line({
metric: tree.unrealized.supplyInProfit.dollars,
name: "In Profit",
color: colors.green,
unit: Unit.usd,
}),
line({
metric: tree.unrealized.supplyInLoss.sats,
name: "In Loss",
color: colors.red,
unit: Unit.sats,
}),
line({
metric: tree.unrealized.supplyInLoss.bitcoin,
name: "In Loss",
color: colors.red,
unit: Unit.btc,
}),
line({
metric: tree.unrealized.supplyInLoss.dollars,
name: "In Loss",
color: colors.red,
unit: Unit.usd,
}),
line({
metric: tree.supply.halved.sats,
name: "half",
color: colors.gray,
unit: Unit.sats,
options: { lineStyle: 4 },
}),
line({
metric: tree.supply.halved.bitcoin,
name: "half",
color: colors.gray,
unit: Unit.btc,
options: { lineStyle: 4 },
}),
line({
metric: tree.supply.halved.dollars,
name: "half",
color: colors.gray,
unit: Unit.usd,
options: { lineStyle: 4 },
}),
...("supplyInProfitRelToCirculatingSupply" in tree.relative
? [
line({
metric: tree.relative.supplyInProfitRelToCirculatingSupply,
name: "In Profit",
color: colors.green,
unit: Unit.pctSupply,
}),
line({
metric: tree.relative.supplyInLossRelToCirculatingSupply,
name: "In Loss",
color: colors.red,
unit: Unit.pctSupply,
}),
]
: []),
line({
metric: tree.relative.supplyInProfitRelToOwnSupply,
name: "In Profit",
color: colors.green,
unit: Unit.pctOwn,
}),
line({
metric: tree.relative.supplyInLossRelToOwnSupply,
name: "In Loss",
color: colors.red,
unit: Unit.pctOwn,
}),
createPriceLine({
unit: Unit.pctOwn,
number: 100,
lineStyle: 0,
color: colors.default,
}),
createPriceLine({ unit: Unit.pctOwn, number: 50 }),
];
}
/**
* Create supply total series for grouped cohorts
* @param {PartialContext} ctx
* @param {readonly CohortObject[]} list
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function createGroupedSupplyTotalSeries(ctx, list) {
const { line, brk } = ctx;
const constant100 = brk.metrics.constants.constant100;
return list.flatMap(({ color, name, tree }) => [
line({ metric: tree.supply.total.sats, name, color, unit: Unit.sats }),
line({ metric: tree.supply.total.bitcoin, name, color, unit: Unit.btc }),
line({ metric: tree.supply.total.dollars, name, color, unit: Unit.usd }),
"supplyRelToCirculatingSupply" in tree.relative
? line({
metric: tree.relative.supplyRelToCirculatingSupply,
name,
color,
unit: Unit.pctSupply,
})
: line({ metric: constant100, name, color, unit: Unit.pctSupply }),
]);
}
/**
* Create supply in profit series for grouped cohorts
* @param {PartialContext} ctx
* @param {readonly CohortObject[]} list
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function createGroupedSupplyInProfitSeries(ctx, list) {
const { line } = ctx;
return list.flatMap(({ color, name, tree }) => [
line({
metric: tree.unrealized.supplyInProfit.sats,
name,
color,
unit: Unit.sats,
}),
line({
metric: tree.unrealized.supplyInProfit.bitcoin,
name,
color,
unit: Unit.btc,
}),
line({
metric: tree.unrealized.supplyInProfit.dollars,
name,
color,
unit: Unit.usd,
}),
...("supplyInProfitRelToCirculatingSupply" in tree.relative
? [
line({
metric: tree.relative.supplyInProfitRelToCirculatingSupply,
name,
color,
unit: Unit.pctSupply,
}),
]
: []),
]);
}
/**
* Create supply in loss series for grouped cohorts
* @param {PartialContext} ctx
* @param {readonly CohortObject[]} list
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function createGroupedSupplyInLossSeries(ctx, list) {
const { line } = ctx;
return list.flatMap(({ color, name, tree }) => [
line({
metric: tree.unrealized.supplyInLoss.sats,
name,
color,
unit: Unit.sats,
}),
line({
metric: tree.unrealized.supplyInLoss.bitcoin,
name,
color,
unit: Unit.btc,
}),
line({
metric: tree.unrealized.supplyInLoss.dollars,
name,
color,
unit: Unit.usd,
}),
...("supplyInLossRelToCirculatingSupply" in tree.relative
? [
line({
metric: tree.relative.supplyInLossRelToCirculatingSupply,
name,
color,
unit: Unit.pctSupply,
}),
]
: []),
]);
}
/**
* Create UTXO count series
* @param {PartialContext} ctx
* @param {readonly CohortObject[]} list
* @param {boolean} useGroupName
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function createUtxoCountSeries(ctx, list, useGroupName) {
const { line } = ctx;
return list.flatMap(({ color, name, tree }) => [
line({
metric: tree.outputs.utxoCount,
name: useGroupName ? name : "Count",
color,
unit: Unit.count,
}),
]);
}
/**
* Create address count series (for address cohorts only)
* @param {PartialContext} ctx
* @param {readonly AddressCohortObject[]} list
* @param {boolean} useGroupName
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function createAddressCountSeries(ctx, list, useGroupName) {
const { line, colors } = ctx;
return list.flatMap(({ color, name, tree }) => [
line({
metric: tree.addrCount,
name: useGroupName ? name : "Count",
color: useGroupName ? color : colors.orange,
unit: Unit.count,
}),
]);
}
/**
* Create realized price series for grouped cohorts
* @param {PartialContext} ctx
* @param {readonly CohortObject[]} list
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function createRealizedPriceSeries(ctx, list) {
const { line } = ctx;
return list.map(({ color, name, tree }) =>
line({ metric: tree.realized.realizedPrice, name, color, unit: Unit.usd }),
);
}
/**
* Create realized price ratio series for grouped cohorts
* @param {PartialContext} ctx
* @param {readonly CohortObject[]} list
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function createRealizedPriceRatioSeries(ctx, list) {
const { line, createPriceLine } = ctx;
return [
...list.map(({ color, name, tree }) =>
line({
metric: tree.realized.realizedPriceExtra.ratio,
name,
color,
unit: Unit.ratio,
}),
),
createPriceLine({ unit: Unit.ratio, number: 1 }),
];
}
/**
* Create realized capitalization series
* @param {PartialContext} ctx
* @param {readonly CohortObject[]} list
* @param {boolean} useGroupName
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function createRealizedCapSeries(ctx, list, useGroupName) {
const { line } = ctx;
return list.flatMap(({ color, name, tree }) => [
line({
metric: tree.realized.realizedCap,
name: useGroupName ? name : "Capitalization",
color,
unit: Unit.usd,
}),
]);
}
/**
* Create cost basis min/max series (available on all cohorts)
* @param {PartialContext} ctx
* @param {readonly CohortObject[]} list
* @param {boolean} useGroupName
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function createCostBasisMinMaxSeries(ctx, list, useGroupName) {
const { line } = ctx;
return list.flatMap(({ color, name, tree }) => [
line({
metric: tree.costBasis.min,
name: useGroupName ? `${name} min` : "Min",
color,
unit: Unit.usd,
}),
line({
metric: tree.costBasis.max,
name: useGroupName ? `${name} max` : "Max",
color,
unit: Unit.usd,
}),
]);
}
/**
* Create cost basis percentile series (only for cohorts with CostBasisPattern2)
* @param {PartialContext} ctx
* @param {readonly CohortWithCostBasisPercentiles[]} list
* @param {boolean} useGroupName
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function createCostBasisPercentilesSeries(ctx, list, useGroupName) {
const { line } = ctx;
return list.flatMap(({ color, name, tree }) => {
const percentiles = tree.costBasis.percentiles;
return [
line({
metric: percentiles.pct10,
name: useGroupName ? `${name} p10` : "p10",
color,
unit: Unit.usd,
defaultActive: false,
}),
line({
metric: percentiles.pct25,
name: useGroupName ? `${name} p25` : "p25",
color,
unit: Unit.usd,
defaultActive: false,
}),
line({
metric: percentiles.pct50,
name: useGroupName ? `${name} p50` : "p50",
color,
unit: Unit.usd,
}),
line({
metric: percentiles.pct75,
name: useGroupName ? `${name} p75` : "p75",
color,
unit: Unit.usd,
defaultActive: false,
}),
line({
metric: percentiles.pct90,
name: useGroupName ? `${name} p90` : "p90",
color,
unit: Unit.usd,
defaultActive: false,
}),
];
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,488 @@
/** Cointime section builder - typed tree-based patterns */
import { Unit } from "../utils/units.js";
/**
* Create price with ratio options for cointime prices
* @param {PartialContext} ctx
* @param {Object} args
* @param {string} args.title
* @param {string} args.legend
* @param {AnyMetricPattern} args.price
* @param {ActivePriceRatioPattern} args.ratio
* @param {Color} [args.color]
* @returns {PartialOptionsTree}
*/
function createCointimePriceWithRatioOptions(
ctx,
{ title, legend, price, ratio, color },
) {
const { line, colors, createPriceLine } = ctx;
// Percentile USD mappings
const percentileUsdMap = [
{ name: "pct99", prop: ratio.ratioPct99Usd, color: colors.rose },
{ name: "pct98", prop: ratio.ratioPct98Usd, color: colors.pink },
{ name: "pct95", prop: ratio.ratioPct95Usd, color: colors.fuchsia },
{ name: "pct5", prop: ratio.ratioPct5Usd, color: colors.cyan },
{ name: "pct2", prop: ratio.ratioPct2Usd, color: colors.sky },
{ name: "pct1", prop: ratio.ratioPct1Usd, color: colors.blue },
];
// Percentile ratio mappings
const percentileMap = [
{ name: "pct99", prop: ratio.ratioPct99, color: colors.rose },
{ name: "pct98", prop: ratio.ratioPct98, color: colors.pink },
{ name: "pct95", prop: ratio.ratioPct95, color: colors.fuchsia },
{ name: "pct5", prop: ratio.ratioPct5, color: colors.cyan },
{ name: "pct2", prop: ratio.ratioPct2, color: colors.sky },
{ name: "pct1", prop: ratio.ratioPct1, color: colors.blue },
];
// SD patterns by window
const sdPatterns = [
{ nameAddon: "all", titleAddon: "", sd: ratio.ratioSd },
{ nameAddon: "4y", titleAddon: "4y", sd: ratio.ratio4ySd },
{ nameAddon: "2y", titleAddon: "2y", sd: ratio.ratio2ySd },
{ nameAddon: "1y", titleAddon: "1y", sd: ratio.ratio1ySd },
];
/** @param {Ratio1ySdPattern} sd */
const getSdBands = (sd) => [
{ name: "0σ", prop: sd._0sdUsd, color: colors.lime },
{ name: "+0.5σ", prop: sd.p05sdUsd, color: colors.yellow },
{ name: "+1σ", prop: sd.p1sdUsd, color: colors.amber },
{ name: "+1.5σ", prop: sd.p15sdUsd, color: colors.orange },
{ name: "+2σ", prop: sd.p2sdUsd, color: colors.red },
{ name: "+2.5σ", prop: sd.p25sdUsd, color: colors.rose },
{ name: "+3σ", prop: sd.p3sd, color: colors.pink },
{ name: "0.5σ", prop: sd.m05sdUsd, color: colors.teal },
{ name: "1σ", prop: sd.m1sdUsd, color: colors.cyan },
{ name: "1.5σ", prop: sd.m15sdUsd, color: colors.sky },
{ name: "2σ", prop: sd.m2sdUsd, color: colors.blue },
{ name: "2.5σ", prop: sd.m25sdUsd, color: colors.indigo },
{ name: "3σ", prop: sd.m3sd, color: colors.violet },
];
return [
{
name: "price",
title,
top: [line({ metric: price, name: legend, color, unit: Unit.usd })],
},
{
name: "Ratio",
title: `${title} Ratio`,
top: [
line({ metric: price, name: legend, color, unit: Unit.usd }),
...percentileUsdMap.map(({ name: pctName, prop, color: pctColor }) =>
line({
metric: prop,
name: pctName,
color: pctColor,
defaultActive: false,
unit: Unit.usd,
options: { lineStyle: 1 },
}),
),
],
bottom: [
line({ metric: ratio.ratio, name: "Ratio", color, unit: Unit.ratio }),
line({
metric: ratio.ratio1wSma,
name: "1w SMA",
color: colors.lime,
unit: Unit.ratio,
}),
line({
metric: ratio.ratio1mSma,
name: "1m SMA",
color: colors.teal,
unit: Unit.ratio,
}),
line({
metric: ratio.ratio1ySd.sma,
name: "1y SMA",
color: colors.sky,
unit: Unit.ratio,
}),
line({
metric: ratio.ratio2ySd.sma,
name: "2y SMA",
color: colors.indigo,
unit: Unit.ratio,
}),
line({
metric: ratio.ratio4ySd.sma,
name: "4y SMA",
color: colors.purple,
unit: Unit.ratio,
}),
line({
metric: ratio.ratioSd.sma,
name: "All SMA",
color: colors.rose,
unit: Unit.ratio,
}),
...percentileMap.map(({ name: pctName, prop, color: pctColor }) =>
line({
metric: prop,
name: pctName,
color: pctColor,
defaultActive: false,
unit: Unit.ratio,
options: { lineStyle: 1 },
}),
),
createPriceLine({ unit: Unit.ratio, number: 1 }),
],
},
{
name: "ZScores",
tree: sdPatterns.map(({ nameAddon, titleAddon, sd }) => ({
name: nameAddon,
title: `${title} ${titleAddon} Z-Score`,
top: getSdBands(sd).map(({ name: bandName, prop, color: bandColor }) =>
line({
metric: prop,
name: bandName,
color: bandColor,
unit: Unit.usd,
}),
),
bottom: [
line({ metric: sd.zscore, name: "Z-Score", color, unit: Unit.sd }),
createPriceLine({ unit: Unit.sd, number: 3 }),
createPriceLine({ unit: Unit.sd, number: 2 }),
createPriceLine({ unit: Unit.sd, number: 1 }),
createPriceLine({ unit: Unit.sd, number: 0 }),
createPriceLine({ unit: Unit.sd, number: -1 }),
createPriceLine({ unit: Unit.sd, number: -2 }),
createPriceLine({ unit: Unit.sd, number: -3 }),
],
})),
},
];
}
/**
* Create Cointime section
* @param {PartialContext} ctx
* @returns {PartialOptionsGroup}
*/
export function createCointimeSection(ctx) {
const { colors, brk, line } = ctx;
const { cointime, distribution, supply } = brk.metrics;
const { pricing, cap, activity, supply: cointimeSupply, adjusted } = cointime;
const { all } = distribution.utxoCohorts;
// Cointime prices data
const cointimePrices = [
{
price: pricing.trueMarketMean,
ratio: pricing.trueMarketMeanRatio,
name: "True market mean",
title: "true market mean",
color: colors.blue,
},
{
price: pricing.vaultedPrice,
ratio: pricing.vaultedPriceRatio,
name: "Vaulted",
title: "vaulted price",
color: colors.lime,
},
{
price: pricing.activePrice,
ratio: pricing.activePriceRatio,
name: "Active",
title: "active price",
color: colors.rose,
},
{
price: pricing.cointimePrice,
ratio: pricing.cointimePriceRatio,
name: "cointime",
title: "cointime price",
color: colors.yellow,
},
];
// Cointime capitalizations data
const cointimeCapitalizations = [
{
metric: cap.vaultedCap,
name: "vaulted",
title: "vaulted Capitalization",
color: colors.lime,
},
{
metric: cap.activeCap,
name: "active",
title: "active Capitalization",
color: colors.rose,
},
{
metric: cap.cointimeCap,
name: "cointime",
title: "cointime Capitalization",
color: colors.yellow,
},
{
metric: cap.investorCap,
name: "investor",
title: "investor Capitalization",
color: colors.fuchsia,
},
{
metric: cap.thermoCap,
name: "thermo",
title: "thermo Capitalization",
color: colors.emerald,
},
];
return {
name: "Cointime",
tree: [
// Prices
{
name: "Prices",
tree: [
{
name: "Compare",
title: "Compare Cointime Prices",
top: cointimePrices.map(({ price, name, color }) =>
line({ metric: price, name, color, unit: Unit.usd }),
),
},
...cointimePrices.map(({ price, ratio, name, color, title }) => ({
name,
tree: createCointimePriceWithRatioOptions(ctx, {
price,
ratio,
legend: name,
color,
title,
}),
})),
],
},
// Capitalization
{
name: "Capitalization",
tree: [
{
name: "Compare",
title: "Compare Cointime Capitalizations",
bottom: [
line({
metric: supply.marketCap,
name: "Market",
color: colors.default,
unit: Unit.usd,
}),
line({
metric: all.realized.realizedCap,
name: "Realized",
color: colors.orange,
unit: Unit.usd,
}),
...cointimeCapitalizations.map(({ metric, name, color }) =>
line({ metric, name, color, unit: Unit.usd }),
),
],
},
...cointimeCapitalizations.map(({ metric, name, color, title }) => ({
name,
title,
bottom: [
line({ metric, name, color, unit: Unit.usd }),
line({
metric: supply.marketCap,
name: "Market",
color: colors.default,
unit: Unit.usd,
}),
line({
metric: all.realized.realizedCap,
name: "Realized",
color: colors.orange,
unit: Unit.usd,
}),
],
})),
],
},
// Supply
{
name: "Supply",
title: "Cointime Supply",
bottom: [
// All supply (different pattern structure)
line({
metric: all.supply.total.sats,
name: "All",
color: colors.orange,
unit: Unit.sats,
}),
line({
metric: all.supply.total.bitcoin,
name: "All",
color: colors.orange,
unit: Unit.btc,
}),
line({
metric: all.supply.total.dollars,
name: "All",
color: colors.orange,
unit: Unit.usd,
}),
// Cointime supplies (ActiveSupplyPattern)
.../** @type {const} */ ([
[cointimeSupply.vaultedSupply, "Vaulted", colors.lime],
[cointimeSupply.activeSupply, "Active", colors.rose],
]).flatMap(([supplyItem, name, color]) => [
line({ metric: supplyItem.sats, name, color, unit: Unit.sats }),
line({ metric: supplyItem.bitcoin, name, color, unit: Unit.btc }),
line({ metric: supplyItem.dollars, name, color, unit: Unit.usd }),
]),
],
},
// Liveliness & Vaultedness
{
name: "Liveliness & Vaultedness",
title: "Liveliness & Vaultedness",
bottom: [
line({
metric: activity.liveliness,
name: "Liveliness",
color: colors.rose,
unit: Unit.ratio,
}),
line({
metric: activity.vaultedness,
name: "Vaultedness",
color: colors.lime,
unit: Unit.ratio,
}),
line({
metric: activity.activityToVaultednessRatio,
name: "Liveliness / Vaultedness",
color: colors.purple,
unit: Unit.ratio,
}),
],
},
// Coinblocks
{
name: "Coinblocks",
title: "Coinblocks",
bottom: [
// Destroyed comes from the all cohort's activity
line({
metric: all.activity.coinblocksDestroyed.sum,
name: "Destroyed",
color: colors.red,
unit: Unit.coinblocks,
}),
line({
metric: all.activity.coinblocksDestroyed.cumulative,
name: "Cumulative Destroyed",
color: colors.red,
defaultActive: false,
unit: Unit.coinblocks,
}),
// Created and stored from cointime
line({
metric: activity.coinblocksCreated.sum,
name: "Created",
color: colors.orange,
unit: Unit.coinblocks,
}),
line({
metric: activity.coinblocksCreated.cumulative,
name: "Cumulative Created",
color: colors.orange,
defaultActive: false,
unit: Unit.coinblocks,
}),
line({
metric: activity.coinblocksStored.sum,
name: "Stored",
color: colors.green,
unit: Unit.coinblocks,
}),
line({
metric: activity.coinblocksStored.cumulative,
name: "Cumulative Stored",
color: colors.green,
defaultActive: false,
unit: Unit.coinblocks,
}),
],
},
// Adjusted metrics
{
name: "Adjusted",
tree: [
// Inflation
{
name: "Inflation",
title: "Cointime-Adjusted Inflation Rate",
bottom: [
line({
metric: supply.inflation,
name: "Base",
color: colors.orange,
unit: Unit.percentage,
}),
line({
metric: adjusted.cointimeAdjInflationRate,
name: "Adjusted",
color: colors.purple,
unit: Unit.percentage,
}),
],
},
// Velocity
{
name: "Velocity",
title: "Cointime-Adjusted Transactions Velocity",
bottom: [
line({
metric: supply.velocity.btc,
name: "BTC",
color: colors.orange,
unit: Unit.ratio,
}),
line({
metric: adjusted.cointimeAdjTxBtcVelocity,
name: "Adj. BTC",
color: colors.red,
unit: Unit.ratio,
}),
line({
metric: supply.velocity.usd,
name: "USD",
color: colors.emerald,
unit: Unit.ratio,
}),
line({
metric: adjusted.cointimeAdjTxUsdVelocity,
name: "Adj. USD",
color: colors.lime,
unit: Unit.ratio,
}),
],
},
],
},
],
};
}

View File

@@ -0,0 +1,152 @@
/** Cohort color mappings */
/** @type {Readonly<Record<string, ColorName>>} */
export const termColors = {
short: "yellow",
long: "fuchsia",
};
/** @type {Readonly<Record<string, ColorName>>} */
export const maxAgeColors = {
_1w: "red",
_1m: "orange",
_2m: "amber",
_3m: "yellow",
_4m: "lime",
_5m: "green",
_6m: "teal",
_1y: "sky",
_2y: "indigo",
_3y: "violet",
_4y: "purple",
_5y: "fuchsia",
_6y: "pink",
_7y: "red",
_8y: "orange",
_10y: "amber",
_12y: "yellow",
_15y: "lime",
};
/** @type {Readonly<Record<string, ColorName>>} */
export const minAgeColors = {
_1d: "red",
_1w: "orange",
_1m: "yellow",
_2m: "lime",
_3m: "green",
_4m: "teal",
_5m: "cyan",
_6m: "blue",
_1y: "indigo",
_2y: "violet",
_3y: "purple",
_4y: "fuchsia",
_5y: "pink",
_6y: "rose",
_7y: "red",
_8y: "orange",
_10y: "yellow",
_12y: "lime",
};
/** @type {Readonly<Record<string, ColorName>>} */
export const ageRangeColors = {
upTo1d: "pink",
_1dTo1w: "red",
_1wTo1m: "orange",
_1mTo2m: "yellow",
_2mTo3m: "yellow",
_3mTo4m: "lime",
_4mTo5m: "lime",
_5mTo6m: "lime",
_6mTo1y: "green",
_1yTo2y: "cyan",
_2yTo3y: "blue",
_3yTo4y: "indigo",
_4yTo5y: "violet",
_5yTo6y: "purple",
_6yTo7y: "purple",
_7yTo8y: "fuchsia",
_8yTo10y: "fuchsia",
_10yTo12y: "pink",
_12yTo15y: "red",
from15y: "orange",
};
/** @type {Readonly<Record<string, ColorName>>} */
export const epochColors = {
_0: "red",
_1: "yellow",
_2: "orange",
_3: "lime",
_4: "green",
};
/** @type {Readonly<Record<string, ColorName>>} */
export const geAmountColors = {
_1sat: "orange",
_10sats: "orange",
_100sats: "yellow",
_1kSats: "lime",
_10kSats: "green",
_100kSats: "cyan",
_1mSats: "blue",
_10mSats: "indigo",
_1btc: "purple",
_10btc: "violet",
_100btc: "fuchsia",
_1kBtc: "pink",
_10kBtc: "red",
};
/** @type {Readonly<Record<string, ColorName>>} */
export const ltAmountColors = {
_10sats: "orange",
_100sats: "yellow",
_1kSats: "lime",
_10kSats: "green",
_100kSats: "cyan",
_1mSats: "blue",
_10mSats: "indigo",
_1btc: "purple",
_10btc: "violet",
_100btc: "fuchsia",
_1kBtc: "pink",
_10kBtc: "red",
_100kBtc: "orange",
};
/** @type {Readonly<Record<string, ColorName>>} */
export const amountRangeColors = {
_0sats: "red",
_1satTo10sats: "orange",
_10satsTo100sats: "yellow",
_100satsTo1kSats: "lime",
_1kSatsTo10kSats: "green",
_10kSatsTo100kSats: "cyan",
_100kSatsTo1mSats: "blue",
_1mSatsTo10mSats: "indigo",
_10mSatsTo1btc: "purple",
_1btcTo10btc: "violet",
_10btcTo100btc: "fuchsia",
_100btcTo1kBtc: "pink",
_1kBtcTo10kBtc: "red",
_10kBtcTo100kBtc: "orange",
_100kBtcOrMore: "yellow",
};
/** @type {Readonly<Record<string, ColorName>>} */
export const spendableTypeColors = {
p2pk65: "red",
p2pk33: "orange",
p2pkh: "yellow",
p2ms: "lime",
p2sh: "green",
p2wpkh: "teal",
p2wsh: "blue",
p2tr: "indigo",
p2a: "purple",
unknown: "violet",
empty: "fuchsia",
};

View File

@@ -0,0 +1,14 @@
// Re-export all color mappings
export {
termColors,
maxAgeColors,
minAgeColors,
ageRangeColors,
epochColors,
geAmountColors,
ltAmountColors,
amountRangeColors,
spendableTypeColors,
} from "./cohorts.js";
export { averageColors, dcaColors } from "./misc.js";

View File

@@ -0,0 +1,42 @@
/** Miscellaneous color mappings for DCA and averages */
/**
* Moving average period colors
* Format: [periodId, days, colorName]
* @type {readonly [string, number, ColorName][]}
*/
export const averageColors = [
["1w", 7, "red"],
["8d", 8, "orange"],
["13d", 13, "amber"],
["21d", 21, "yellow"],
["1m", 30, "lime"],
["34d", 34, "green"],
["55d", 55, "emerald"],
["89d", 89, "teal"],
["144d", 144, "cyan"],
["200d", 200, "sky"],
["1y", 365, "blue"],
["2y", 730, "indigo"],
["200w", 1400, "violet"],
["4y", 1460, "purple"],
];
/**
* DCA class colors by year
* Format: [year, colorName, defaultActive]
* @type {readonly [number, ColorName, boolean][]}
*/
export const dcaColors = [
[2015, "pink", false],
[2016, "red", false],
[2017, "orange", true],
[2018, "yellow", true],
[2019, "green", true],
[2020, "teal", true],
[2021, "sky", true],
[2022, "blue", true],
[2023, "purple", true],
[2024, "fuchsia", true],
[2025, "pink", true],
];

View File

@@ -0,0 +1,118 @@
/** Constant helpers for creating price lines and reference lines */
import { line } from "./series.js";
/**
* Get constant pattern by number dynamically from tree
* Examples: 0 → constant0, 38.2 → constant382, -1 → constantMinus1
* @param {BrkClient["metrics"]["constants"]} constants
* @param {number} num
* @returns {AnyMetricPattern}
*/
export function getConstant(constants, num) {
const key =
num >= 0
? `constant${String(num).replace(".", "")}`
: `constantMinus${Math.abs(num)}`;
const constant = /** @type {AnyMetricPattern | undefined} */ (
/** @type {Record<string, AnyMetricPattern>} */ (constants)[key]
);
if (!constant) throw new Error(`Unknown constant: ${num} (key: ${key})`);
return constant;
}
/**
* Create a price line series (horizontal reference line)
* @param {Object} args
* @param {BrkClient["metrics"]["constants"]} args.constants
* @param {Colors} args.colors
* @param {number} [args.number]
* @param {string} [args.name]
* @param {boolean} [args.defaultActive]
* @param {number} [args.lineStyle]
* @param {Color} [args.color]
* @param {Unit} args.unit
* @returns {FetchedLineSeriesBlueprint}
*/
export function createPriceLine({
constants,
colors,
number = 0,
unit,
defaultActive,
color,
name,
lineStyle,
}) {
return {
metric: getConstant(constants, number),
title: name ?? `${number}`,
unit,
defaultActive,
color: color ?? colors.gray,
options: {
lineStyle: lineStyle ?? 4,
lastValueVisible: false,
crosshairMarkerVisible: false,
},
};
}
/**
* Create multiple price lines from an array of numbers
* @param {Object} args
* @param {BrkClient["metrics"]["constants"]} args.constants
* @param {Colors} args.colors
* @param {number[]} args.numbers
* @param {Unit} args.unit
* @returns {FetchedLineSeriesBlueprint[]}
*/
export function createPriceLines({ constants, colors, numbers, unit }) {
return numbers.map((number) => ({
metric: getConstant(constants, number),
title: `${number}`,
unit,
defaultActive: !number,
color: colors.gray,
options: {
lineStyle: 4,
lastValueVisible: false,
crosshairMarkerVisible: false,
},
}));
}
/**
* Create a constant line series
* @param {Object} args
* @param {Colors} args.colors
* @param {AnyMetricPattern} args.constant
* @param {string} args.name
* @param {Unit} args.unit
* @param {Color} [args.color]
* @param {number} [args.lineStyle]
* @param {boolean} [args.defaultActive]
* @returns {FetchedLineSeriesBlueprint}
*/
export function constantLine({
colors,
constant,
name,
unit,
color,
lineStyle,
defaultActive,
}) {
return line({
metric: constant,
name,
unit,
defaultActive,
color: color ?? colors.gray,
options: {
lineStyle: lineStyle ?? 4,
lastValueVisible: false,
crosshairMarkerVisible: false,
},
});
}

View File

@@ -0,0 +1,96 @@
import {
line,
dots,
candlestick,
baseline,
histogram,
fromBlockCount,
fromBitcoin,
fromBlockSize,
fromSizePattern,
fromFullnessPattern,
fromFeeRatePattern,
fromCoinbasePattern,
fromValuePattern,
fromBitcoinPatternWithUnit,
fromBlockCountWithUnit,
fromIntervalPattern,
fromSupplyPattern,
} from "./series.js";
import {
createPriceLine,
createPriceLines,
constantLine,
} from "./constants.js";
/**
* Create a context object with all dependencies for building partial options
* @param {Object} args
* @param {Colors} args.colors
* @param {BrkClient} args.brk
* @returns {PartialContext}
*/
export function createContext({ colors, brk }) {
const constants = brk.metrics.constants;
return {
colors,
brk,
// Series helpers
line,
dots,
candlestick,
baseline,
histogram,
fromBlockCount: (pattern, title, color) =>
fromBlockCount(colors, pattern, title, color),
fromBitcoin: (pattern, title, color) =>
fromBitcoin(colors, pattern, title, color),
fromBlockSize: (pattern, title, color) =>
fromBlockSize(colors, pattern, title, color),
fromSizePattern: (pattern, title, unit) =>
fromSizePattern(colors, pattern, title, unit),
fromFullnessPattern: (pattern, title, unit) =>
fromFullnessPattern(colors, pattern, title, unit),
fromFeeRatePattern: (pattern, title, unit) =>
fromFeeRatePattern(colors, pattern, title, unit),
fromCoinbasePattern: (pattern, title) =>
fromCoinbasePattern(colors, pattern, title),
fromValuePattern: (pattern, title, sumColor, cumulativeColor) =>
fromValuePattern(colors, pattern, title, sumColor, cumulativeColor),
fromBitcoinPatternWithUnit: (
pattern,
title,
unit,
sumColor,
cumulativeColor,
) =>
fromBitcoinPatternWithUnit(
colors,
pattern,
title,
unit,
sumColor,
cumulativeColor,
),
fromBlockCountWithUnit: (pattern, title, unit, sumColor, cumulativeColor) =>
fromBlockCountWithUnit(
colors,
pattern,
title,
unit,
sumColor,
cumulativeColor,
),
fromIntervalPattern: (pattern, title, unit, color) =>
fromIntervalPattern(colors, pattern, title, unit, color),
fromSupplyPattern: (pattern, title, color) =>
fromSupplyPattern(colors, pattern, title, color),
createPriceLine: (args) => createPriceLine({ constants, colors, ...args }),
createPriceLines: (args) =>
createPriceLines({ constants, colors, ...args }),
constantLine: (args) => constantLine({ colors, ...args }),
};
}

View File

@@ -0,0 +1,378 @@
import { createPartialOptions } from "./partial.js";
import {
createButtonElement,
createAnchorElement,
insertElementAtIndex,
} from "../utils/dom.js";
import { pushHistory, resetParams } from "../utils/url.js";
import { readStored, writeToStorage } from "../utils/storage.js";
import { stringToId } from "../utils/format.js";
import { collect, markUsed, logUnused } from "./unused.js";
/**
* @param {Object} args
* @param {Colors} args.colors
* @param {Signals} args.signals
* @param {BrkClient} args.brk
* @param {Signal<string | null>} args.qrcode
*/
export function initOptions({ colors, signals, brk, qrcode }) {
collect(brk.metrics);
const LS_SELECTED_KEY = `selected_path`;
const urlPath_ = window.document.location.pathname
.split("/")
.filter((v) => v);
const urlPath = urlPath_.length ? urlPath_ : undefined;
const savedPath = /** @type {string[]} */ (
JSON.parse(readStored(LS_SELECTED_KEY) || "[]") || []
).filter((v) => v);
console.log(savedPath);
/** @type {Signal<Option>} */
const selected = signals.createSignal(/** @type {any} */ (undefined));
const partialOptions = createPartialOptions({
colors,
brk,
});
/** @type {Option[]} */
const list = [];
const parent = signals.createSignal(/** @type {HTMLElement | null} */ (null));
/**
* @param {AnyFetchedSeriesBlueprint[]} [arr]
*/
function arrayToMap(arr = []) {
/** @type {Map<Unit, AnyFetchedSeriesBlueprint[]>} */
const map = new Map();
for (const blueprint of arr || []) {
if (!blueprint.metric) {
throw new Error(
`Blueprint missing metric: ${JSON.stringify(blueprint)}`,
);
}
if (!blueprint.unit) {
throw new Error(`Blueprint missing unit: ${blueprint.title}`);
}
markUsed(blueprint.metric);
const unit = blueprint.unit;
if (!map.has(unit)) {
map.set(unit, []);
}
map.get(unit)?.push(blueprint);
}
return map;
}
/**
* @param {Option} option
*/
function selectOption(option) {
pushHistory(option.path);
resetParams(option);
writeToStorage(LS_SELECTED_KEY, JSON.stringify(option.path));
selected.set(option);
}
/**
* @param {Object} args
* @param {Option} args.option
* @param {Signal<string | null>} args.qrcode
* @param {string} [args.name]
*/
function createOptionElement({ option, name, qrcode }) {
const title = option.title;
if (option.kind === "url") {
const href = option.url();
if (option.qrcode) {
return createButtonElement({
inside: option.name,
title,
onClick: () => {
qrcode.set(option.url);
},
});
} else {
return createAnchorElement({
href,
blank: true,
text: option.name,
title,
});
}
} else {
return createAnchorElement({
href: `/${option.path.join("/")}`,
title,
text: name || option.name,
onClick: () => {
selectOption(option);
},
});
}
}
/** @type {Option | undefined} */
let savedOption;
/**
* @param {PartialOptionsTree} partialTree
* @param {Accessor<HTMLElement | null>} parent
* @param {string[] | undefined} parentPath
* @returns {Accessor<number>}
*/
function recursiveProcessPartialTree(
partialTree,
parent,
parentPath = [],
depth = 0,
) {
/** @type {Accessor<number>[]} */
const listForSum = [];
const ul = signals.createMemo(
// @ts_ignore
(_previous) => {
const previous = /** @type {HTMLUListElement | null} */ (_previous);
previous?.remove();
const _parent = parent();
if (_parent) {
if ("open" in _parent && !_parent.open) {
throw "Set accesor to null instead";
}
const ul = window.document.createElement("ul");
_parent.append(ul);
return ul;
} else {
return null;
}
},
null,
);
partialTree.forEach((anyPartial, partialIndex) => {
const renderLi = signals.createSignal(true);
const li = signals.createMemo((_previous) => {
const previous = _previous;
previous?.remove();
const _ul = ul();
if (renderLi() && _ul) {
const li = window.document.createElement("li");
insertElementAtIndex(_ul, li, partialIndex);
return li;
} else {
return null;
}
}, /** @type {HTMLLIElement | null} */ (null));
if ("tree" in anyPartial) {
/** @type {Omit<OptionsGroup, keyof PartialOptionsGroup>} */
const groupAddons = {};
Object.assign(anyPartial, groupAddons);
const passedDetails = signals.createSignal(
/** @type {HTMLDivElement | HTMLDetailsElement | null} */ (null),
);
const serName = stringToId(anyPartial.name);
const path = [...parentPath, serName];
const childOptionsCount = recursiveProcessPartialTree(
anyPartial.tree,
passedDetails,
path,
depth + 1,
);
listForSum.push(childOptionsCount);
signals.createEffect(li, (li) => {
if (!li) {
passedDetails.set(null);
return;
}
signals.createEffect(selected, (selected) => {
if (
path.length <= selected.path.length &&
path.every((v, i) => selected.path.at(i) === v)
) {
li.dataset.highlight = "";
} else {
delete li.dataset.highlight;
}
});
const details = window.document.createElement("details");
details.dataset.name = serName;
li.appendChild(details);
const summary = window.document.createElement("summary");
details.append(summary);
summary.append(anyPartial.name);
const supCount = window.document.createElement("sup");
summary.append(supCount);
signals.createEffect(childOptionsCount, (childOptionsCount) => {
supCount.innerHTML = childOptionsCount.toLocaleString("en-us");
});
details.addEventListener("toggle", () => {
const open = details.open;
if (open) {
passedDetails.set(details);
} else {
passedDetails.set(null);
}
});
});
function createRenderLiEffect() {
signals.createEffect(childOptionsCount, (count) => {
renderLi.set(!!count);
});
}
createRenderLiEffect();
} else {
const option = /** @type {Option} */ (anyPartial);
const name = option.name;
const path = [...parentPath, stringToId(option.name)];
if ("kind" in anyPartial && anyPartial.kind === "explorer") {
Object.assign(
option,
/** @satisfies {ExplorerOption} */ ({
kind: anyPartial.kind,
path,
name,
title: option.title,
}),
);
} else if ("kind" in anyPartial && anyPartial.kind === "table") {
Object.assign(
option,
/** @satisfies {TableOption} */ ({
kind: anyPartial.kind,
path,
name,
title: option.title,
}),
);
} else if ("kind" in anyPartial && anyPartial.kind === "simulation") {
Object.assign(
option,
/** @satisfies {SimulationOption} */ ({
kind: anyPartial.kind,
path,
name,
title: anyPartial.title,
}),
);
} else if ("url" in anyPartial) {
Object.assign(
option,
/** @satisfies {UrlOption} */ ({
kind: "url",
path,
name,
title: name,
qrcode: !!anyPartial.qrcode,
url: anyPartial.url,
}),
);
} else {
const title = option.title || option.name;
Object.assign(
option,
/** @satisfies {ChartOption} */ ({
kind: "chart",
name,
title,
path,
top: arrayToMap(anyPartial.top),
bottom: arrayToMap(anyPartial.bottom),
}),
);
}
list.push(option);
if (urlPath) {
const sameAsURLPath =
urlPath.length === path.length &&
urlPath.every((val, i) => val === path[i]);
if (sameAsURLPath) {
selected.set(option);
}
} else if (savedPath) {
const sameAsSavedPath =
savedPath.length === path.length &&
savedPath.every((val, i) => val === path[i]);
if (sameAsSavedPath) {
savedOption = option;
}
}
signals.createEffect(li, (li) => {
if (!li) {
return;
}
signals.createEffect(selected, (selected) => {
if (selected === option) {
li.dataset.highlight = "";
} else {
delete li.dataset.highlight;
}
});
const element = createOptionElement({
option,
qrcode,
});
li.append(element);
});
listForSum.push(() => 1);
}
});
return signals.createMemo(() =>
listForSum.reduce((acc, s) => acc + s(), 0),
);
}
recursiveProcessPartialTree(partialOptions, parent);
logUnused();
if (!selected()) {
const option =
savedOption || list.find((option) => option.kind === "chart");
if (option) {
selected.set(option);
}
}
return {
selected,
list,
tree: /** @type {OptionsTree} */ (partialOptions),
parent,
createOptionElement,
selectOption,
};
}
/** @typedef {ReturnType<typeof initOptions>} Options */

View File

@@ -0,0 +1,237 @@
/** Moving averages section */
import { Unit } from "../../utils/units.js";
import { periodIdToName } from "./utils.js";
/**
* Build averages data array from market patterns
* @param {Colors} colors
* @param {MarketMovingAverage} ma
*/
export function buildAverages(colors, ma) {
return /** @type {const} */ ([
["1w", 7, "red", ma.price1wSma, ma.price1wEma],
["8d", 8, "orange", ma.price8dSma, ma.price8dEma],
["13d", 13, "amber", ma.price13dSma, ma.price13dEma],
["21d", 21, "yellow", ma.price21dSma, ma.price21dEma],
["1m", 30, "lime", ma.price1mSma, ma.price1mEma],
["34d", 34, "green", ma.price34dSma, ma.price34dEma],
["55d", 55, "emerald", ma.price55dSma, ma.price55dEma],
["89d", 89, "teal", ma.price89dSma, ma.price89dEma],
["144d", 144, "cyan", ma.price144dSma, ma.price144dEma],
["200d", 200, "sky", ma.price200dSma, ma.price200dEma],
["1y", 365, "blue", ma.price1ySma, ma.price1yEma],
["2y", 730, "indigo", ma.price2ySma, ma.price2yEma],
["200w", 1400, "violet", ma.price200wSma, ma.price200wEma],
["4y", 1460, "purple", ma.price4ySma, ma.price4yEma],
]).map(([id, days, colorKey, sma, ema]) => ({
id,
name: periodIdToName(id, true),
days,
color: colors[colorKey],
sma,
ema,
}));
}
/**
* Create price with ratio options (for moving averages)
* @param {PartialContext} ctx
* @param {Object} args
* @param {string} args.title
* @param {string} args.legend
* @param {EmaRatioPattern} args.ratio
* @param {Color} [args.color]
* @returns {PartialOptionsTree}
*/
export function createPriceWithRatioOptions(
ctx,
{ title, legend, ratio, color },
) {
const { line, colors, createPriceLine } = ctx;
const priceMetric = ratio.price;
const percentileUsdMap = [
{ name: "pct99", prop: ratio.ratioPct99Usd, color: colors.rose },
{ name: "pct98", prop: ratio.ratioPct98Usd, color: colors.pink },
{ name: "pct95", prop: ratio.ratioPct95Usd, color: colors.fuchsia },
{ name: "pct5", prop: ratio.ratioPct5Usd, color: colors.cyan },
{ name: "pct2", prop: ratio.ratioPct2Usd, color: colors.sky },
{ name: "pct1", prop: ratio.ratioPct1Usd, color: colors.blue },
];
const percentileMap = [
{ name: "pct99", prop: ratio.ratioPct99, color: colors.rose },
{ name: "pct98", prop: ratio.ratioPct98, color: colors.pink },
{ name: "pct95", prop: ratio.ratioPct95, color: colors.fuchsia },
{ name: "pct5", prop: ratio.ratioPct5, color: colors.cyan },
{ name: "pct2", prop: ratio.ratioPct2, color: colors.sky },
{ name: "pct1", prop: ratio.ratioPct1, color: colors.blue },
];
const sdPatterns = [
{ nameAddon: "all", titleAddon: "", sd: ratio.ratioSd },
{ nameAddon: "4y", titleAddon: "4y", sd: ratio.ratio4ySd },
{ nameAddon: "2y", titleAddon: "2y", sd: ratio.ratio2ySd },
{ nameAddon: "1y", titleAddon: "1y", sd: ratio.ratio1ySd },
];
/** @param {Ratio1ySdPattern} sd */
const getSdBands = (sd) => [
{ name: "0σ", prop: sd._0sdUsd, color: colors.lime },
{ name: "+0.5σ", prop: sd.p05sdUsd, color: colors.yellow },
{ name: "+1σ", prop: sd.p1sdUsd, color: colors.amber },
{ name: "+1.5σ", prop: sd.p15sdUsd, color: colors.orange },
{ name: "+2σ", prop: sd.p2sdUsd, color: colors.red },
{ name: "+2.5σ", prop: sd.p25sdUsd, color: colors.rose },
{ name: "+3σ", prop: sd.p3sd, color: colors.pink },
{ name: "0.5σ", prop: sd.m05sdUsd, color: colors.teal },
{ name: "1σ", prop: sd.m1sdUsd, color: colors.cyan },
{ name: "1.5σ", prop: sd.m15sdUsd, color: colors.sky },
{ name: "2σ", prop: sd.m2sdUsd, color: colors.blue },
{ name: "2.5σ", prop: sd.m25sdUsd, color: colors.indigo },
{ name: "3σ", prop: sd.m3sd, color: colors.violet },
];
return [
{
name: "price",
title,
top: [line({ metric: priceMetric, name: legend, color, unit: Unit.usd })],
},
{
name: "Ratio",
title: `${title} Ratio`,
top: [
line({ metric: priceMetric, name: legend, color, unit: Unit.usd }),
...percentileUsdMap.map(({ name: pctName, prop, color: pctColor }) =>
line({
metric: prop,
name: pctName,
color: pctColor,
defaultActive: false,
unit: Unit.usd,
options: { lineStyle: 1 },
}),
),
],
bottom: [
line({ metric: ratio.ratio, name: "Ratio", color, unit: Unit.ratio }),
line({
metric: ratio.ratio1wSma,
name: "1w SMA",
color: colors.lime,
unit: Unit.ratio,
}),
line({
metric: ratio.ratio1mSma,
name: "1m SMA",
color: colors.teal,
unit: Unit.ratio,
}),
line({
metric: ratio.ratio1ySd.sma,
name: "1y SMA",
color: colors.sky,
unit: Unit.ratio,
}),
line({
metric: ratio.ratio2ySd.sma,
name: "2y SMA",
color: colors.indigo,
unit: Unit.ratio,
}),
line({
metric: ratio.ratio4ySd.sma,
name: "4y SMA",
color: colors.purple,
unit: Unit.ratio,
}),
line({
metric: ratio.ratioSd.sma,
name: "All SMA",
color: colors.rose,
unit: Unit.ratio,
}),
...percentileMap.map(({ name: pctName, prop, color: pctColor }) =>
line({
metric: prop,
name: pctName,
color: pctColor,
defaultActive: false,
unit: Unit.ratio,
options: { lineStyle: 1 },
}),
),
createPriceLine({ unit: Unit.ratio, number: 1 }),
],
},
{
name: "ZScores",
tree: sdPatterns.map(({ nameAddon, titleAddon, sd }) => ({
name: nameAddon,
title: `${title} ${titleAddon} Z-Score`,
top: getSdBands(sd).map(({ name: bandName, prop, color: bandColor }) =>
line({
metric: prop,
name: bandName,
color: bandColor,
unit: Unit.usd,
}),
),
bottom: [
line({ metric: sd.zscore, name: "Z-Score", color, unit: Unit.sd }),
createPriceLine({ unit: Unit.sd, number: 3 }),
createPriceLine({ unit: Unit.sd, number: 2 }),
createPriceLine({ unit: Unit.sd, number: 1 }),
createPriceLine({ unit: Unit.sd, number: 0 }),
createPriceLine({ unit: Unit.sd, number: -1 }),
createPriceLine({ unit: Unit.sd, number: -2 }),
createPriceLine({ unit: Unit.sd, number: -3 }),
],
})),
},
];
}
/**
* Create Averages section
* @param {PartialContext} ctx
* @param {ReturnType<typeof buildAverages>} averages
*/
export function createAveragesSection(ctx, averages) {
const { line } = ctx;
return {
name: "Averages",
tree: [
{ nameAddon: "Simple", metricAddon: /** @type {const} */ ("sma") },
{ nameAddon: "Exponential", metricAddon: /** @type {const} */ ("ema") },
].map(({ nameAddon, metricAddon }) => ({
name: nameAddon,
tree: [
{
name: "Compare",
title: `Market Price ${nameAddon} Moving Averages`,
top: averages.map(({ id, color, sma, ema }) =>
line({
metric: (metricAddon === "sma" ? sma : ema).price,
name: id,
color,
unit: Unit.usd,
}),
),
},
...averages.map(({ name, color, sma, ema }) => ({
name,
tree: createPriceWithRatioOptions(ctx, {
ratio: metricAddon === "sma" ? sma : ema,
title: `${name} Market Price ${nameAddon} Moving Average`,
legend: "average",
color,
}),
})),
],
})),
};
}

View File

@@ -0,0 +1,118 @@
/** Market section - Main entry point */
import { localhost } from "../../utils/env.js";
import { Unit } from "../../utils/units.js";
import { buildAverages, createAveragesSection } from "./averages.js";
import { createPerformanceSection } from "./performance.js";
import { createIndicatorsSection } from "./indicators/index.js";
import { createInvestingSection } from "./investing.js";
/**
* Create Market section
* @param {PartialContext} ctx
* @returns {PartialOptionsGroup}
*/
export function createMarketSection(ctx) {
const { colors, brk, line, candlestick } = ctx;
const { market, supply, price } = brk.metrics;
const {
movingAverage,
ath,
returns,
volatility,
range,
dca,
lookback,
indicators,
} = market;
const averages = buildAverages(colors, movingAverage);
return {
name: "Market",
tree: [
// Price
{
name: "Price",
title: "Bitcoin Price",
...(localhost && {
top: [
candlestick({
metric: price.oracle.ohlcDollars,
name: "Oracle",
unit: Unit.usd,
colors: [colors.cyan, colors.purple],
}),
],
}),
},
// Capitalization
{
name: "Capitalization",
title: "Market Capitalization",
bottom: [
line({
metric: supply.marketCap,
name: "Capitalization",
unit: Unit.usd,
}),
],
},
// All Time High
{
name: "All Time High",
title: "All Time High",
top: [line({ metric: ath.priceAth, name: "ATH", unit: Unit.usd })],
bottom: [
line({
metric: ath.priceDrawdown,
name: "Drawdown",
color: colors.red,
unit: Unit.percentage,
}),
line({
metric: ath.daysSincePriceAth,
name: "Since",
unit: Unit.days,
}),
line({
metric: ath.yearsSincePriceAth,
name: "Since",
unit: Unit.years,
}),
line({
metric: ath.maxDaysBetweenPriceAths,
name: "Max",
color: colors.red,
unit: Unit.days,
}),
line({
metric: ath.maxYearsBetweenPriceAths,
name: "Max",
color: colors.red,
unit: Unit.years,
}),
],
},
// Averages
createAveragesSection(ctx, averages),
// Performance
createPerformanceSection(ctx, returns),
// Indicators
createIndicatorsSection(ctx, {
volatility,
range,
movingAverage,
indicators,
}),
// Investing
createInvestingSection(ctx, { dca, lookback, returns }),
],
};
}

View File

@@ -0,0 +1,90 @@
/** Bands indicators (MinMax, Mayer Multiple) */
import { Unit } from "../../../utils/units.js";
/**
* Create Bands section
* @param {PartialContext} ctx
* @param {Object} args
* @param {Market["range"]} args.range
* @param {Market["movingAverage"]} args.movingAverage
*/
export function createBandsSection(ctx, { range, movingAverage }) {
const { line, colors } = ctx;
return {
name: "Bands",
tree: [
{
name: "MinMax",
tree: [
{
id: "1w",
title: "1 Week",
min: range.price1wMin,
max: range.price1wMax,
},
{
id: "2w",
title: "2 Week",
min: range.price2wMin,
max: range.price2wMax,
},
{
id: "1m",
title: "1 Month",
min: range.price1mMin,
max: range.price1mMax,
},
{
id: "1y",
title: "1 Year",
min: range.price1yMin,
max: range.price1yMax,
},
].map(({ id, title, min, max }) => ({
name: id,
title: `Bitcoin Price ${title} MinMax Bands`,
top: [
line({
metric: min,
name: "Min",
color: colors.red,
unit: Unit.usd,
}),
line({
metric: max,
name: "Max",
color: colors.green,
unit: Unit.usd,
}),
],
})),
},
{
name: "Mayer Multiple",
title: "Mayer Multiple",
top: [
line({
metric: movingAverage.price200dSma.price,
name: "200d SMA",
color: colors.yellow,
unit: Unit.usd,
}),
line({
metric: movingAverage.price200dSmaX24,
name: "200d SMA x2.4",
color: colors.green,
unit: Unit.usd,
}),
line({
metric: movingAverage.price200dSmaX08,
name: "200d SMA x0.8",
color: colors.red,
unit: Unit.usd,
}),
],
},
],
};
}

View File

@@ -0,0 +1,27 @@
/** Indicators section - Main entry point */
import { createMomentumSection } from "./momentum.js";
import { createVolatilitySection } from "./volatility.js";
import { createBandsSection } from "./bands.js";
import { createOnchainSection } from "./onchain.js";
/**
* Create Indicators section
* @param {PartialContext} ctx
* @param {Object} args
* @param {Market["volatility"]} args.volatility
* @param {Market["range"]} args.range
* @param {Market["movingAverage"]} args.movingAverage
* @param {Market["indicators"]} args.indicators
*/
export function createIndicatorsSection(ctx, { volatility, range, movingAverage, indicators }) {
return {
name: "Indicators",
tree: [
createMomentumSection(ctx, indicators),
createVolatilitySection(ctx, { volatility, range }),
createBandsSection(ctx, { range, movingAverage }),
createOnchainSection(ctx, { indicators, movingAverage }),
],
};
}

View File

@@ -0,0 +1,111 @@
/** Momentum indicators (RSI, StochRSI, Stochastic, MACD) */
import { Unit } from "../../../utils/units.js";
/**
* Create Momentum section
* @param {PartialContext} ctx
* @param {Market["indicators"]} indicators
*/
export function createMomentumSection(ctx, indicators) {
const { line, histogram, colors, createPriceLine } = ctx;
return {
name: "Momentum",
tree: [
{
name: "RSI",
title: "Relative Strength Index (14d)",
bottom: [
line({
metric: indicators.rsi14d,
name: "RSI",
color: colors.indigo,
unit: Unit.index,
}),
line({
metric: indicators.rsi14dMin,
name: "Min",
color: colors.red,
defaultActive: false,
unit: Unit.index,
}),
line({
metric: indicators.rsi14dMax,
name: "Max",
color: colors.green,
defaultActive: false,
unit: Unit.index,
}),
createPriceLine({ unit: Unit.index, number: 70 }),
createPriceLine({
unit: Unit.index,
number: 50,
defaultActive: false,
}),
createPriceLine({ unit: Unit.index, number: 30 }),
],
},
{
name: "StochRSI",
title: "Stochastic RSI",
bottom: [
// line({
// metric: indicators.stochRsi,
// name: "Stoch RSI",
// color: colors.purple,
// unit: Unit.index,
// }),
line({
metric: indicators.stochRsiK,
name: "K",
color: colors.blue,
unit: Unit.index,
}),
line({
metric: indicators.stochRsiD,
name: "D",
color: colors.orange,
unit: Unit.index,
}),
createPriceLine({ unit: Unit.index, number: 80 }),
createPriceLine({ unit: Unit.index, number: 20 }),
],
},
// {
// name: "Stochastic",
// title: "Stochastic Oscillator",
// bottom: [
// line({ metric: indicators.stochK, name: "K", color: colors.blue, unit: Unit.index }),
// line({ metric: indicators.stochD, name: "D", color: colors.orange, unit: Unit.index }),
// createPriceLine({ unit: Unit.index, number: 80 }),
// createPriceLine({ unit: Unit.index, number: 20 }),
// ],
// },
{
name: "MACD",
title: "Moving Average Convergence Divergence",
bottom: [
line({
metric: indicators.macdLine,
name: "MACD",
color: colors.blue,
unit: Unit.usd,
}),
line({
metric: indicators.macdSignal,
name: "Signal",
color: colors.orange,
unit: Unit.usd,
}),
histogram({
metric: indicators.macdHistogram,
name: "Histogram",
unit: Unit.usd,
}),
createPriceLine({ unit: Unit.usd }),
],
},
],
};
}

View File

@@ -0,0 +1,83 @@
/** On-chain indicators (Pi Cycle, Puell, NVT, Gini) */
import { Unit } from "../../../utils/units.js";
/**
* Create On-chain section
* @param {PartialContext} ctx
* @param {Object} args
* @param {Market["indicators"]} args.indicators
* @param {Market["movingAverage"]} args.movingAverage
*/
export function createOnchainSection(ctx, { indicators, movingAverage }) {
const { line, colors, createPriceLine } = ctx;
return {
name: "On-chain",
tree: [
{
name: "Pi Cycle",
title: "Pi Cycle Top Indicator",
top: [
line({
metric: movingAverage.price111dSma.price,
name: "111d SMA",
color: colors.green,
unit: Unit.usd,
}),
line({
metric: movingAverage.price350dSmaX2,
name: "350d SMA x2",
color: colors.red,
unit: Unit.usd,
}),
],
bottom: [
line({
metric: indicators.piCycle,
name: "Pi Cycle",
color: colors.purple,
unit: Unit.ratio,
}),
createPriceLine({ unit: Unit.ratio, number: 1 }),
],
},
{
name: "Puell Multiple",
title: "Puell Multiple",
bottom: [
line({
metric: indicators.puellMultiple,
name: "Puell",
color: colors.green,
unit: Unit.ratio,
}),
],
},
{
name: "NVT",
title: "Network Value to Transactions Ratio",
bottom: [
line({
metric: indicators.nvt,
name: "NVT",
color: colors.orange,
unit: Unit.ratio,
}),
],
},
{
name: "Gini",
title: "Gini Coefficient",
bottom: [
line({
metric: indicators.gini,
name: "Gini",
color: colors.red,
unit: Unit.ratio,
}),
],
},
],
};
}

View File

@@ -0,0 +1,120 @@
/** Volatility indicators (Index, True Range, Choppiness, Sharpe, Sortino) */
import { Unit } from "../../../utils/units.js";
/**
* Create Volatility section
* @param {PartialContext} ctx
* @param {Object} args
* @param {Market["volatility"]} args.volatility
* @param {Market["range"]} args.range
*/
export function createVolatilitySection(ctx, { volatility, range }) {
const { line, colors, createPriceLine } = ctx;
return {
name: "Volatility",
tree: [
{
name: "Index",
title: "Bitcoin Price Volatility Index",
bottom: [
line({
metric: volatility.price1wVolatility,
name: "1w",
color: colors.red,
unit: Unit.percentage,
}),
line({
metric: volatility.price1mVolatility,
name: "1m",
color: colors.orange,
unit: Unit.percentage,
}),
line({
metric: volatility.price1yVolatility,
name: "1y",
color: colors.lime,
unit: Unit.percentage,
}),
],
},
{
name: "True Range",
title: "Bitcoin Price True Range",
bottom: [
line({
metric: range.priceTrueRange,
name: "Value",
color: colors.yellow,
unit: Unit.usd,
}),
],
},
{
name: "Choppiness",
title: "Bitcoin Price Choppiness Index",
bottom: [
line({
metric: range.price2wChoppinessIndex,
name: "2w",
color: colors.red,
unit: Unit.index,
}),
createPriceLine({ unit: Unit.index, number: 61.8 }),
createPriceLine({ unit: Unit.index, number: 38.2 }),
],
},
{
name: "Sharpe Ratio",
title: "Sharpe Ratio",
bottom: [
line({
metric: volatility.sharpe1w,
name: "1w",
color: colors.red,
unit: Unit.ratio,
}),
line({
metric: volatility.sharpe1m,
name: "1m",
color: colors.orange,
unit: Unit.ratio,
}),
line({
metric: volatility.sharpe1y,
name: "1y",
color: colors.lime,
unit: Unit.ratio,
}),
createPriceLine({ unit: Unit.ratio }),
],
},
{
name: "Sortino Ratio",
title: "Sortino Ratio",
bottom: [
line({
metric: volatility.sortino1w,
name: "1w",
color: colors.red,
unit: Unit.ratio,
}),
line({
metric: volatility.sortino1m,
name: "1m",
color: colors.orange,
unit: Unit.ratio,
}),
line({
metric: volatility.sortino1y,
name: "1y",
color: colors.lime,
unit: Unit.ratio,
}),
createPriceLine({ unit: Unit.ratio }),
],
},
],
};
}

View File

@@ -0,0 +1,284 @@
/** Investing section (DCA) */
import { Unit } from "../../utils/units.js";
import { periodIdToName } from "./utils.js";
/**
* Build DCA classes data array
* @param {Colors} colors
* @param {MarketDca} dca
*/
export function buildDcaClasses(colors, dca) {
return /** @type {const} */ ([
[2025, "pink", true],
[2024, "fuchsia", true],
[2023, "purple", true],
[2022, "blue", true],
[2021, "sky", true],
[2020, "teal", true],
[2019, "green", true],
[2018, "yellow", true],
[2017, "orange", true],
[2016, "red", false],
[2015, "pink", false],
]).map(([year, colorKey, defaultActive]) => ({
year,
color: colors[colorKey],
defaultActive,
costBasis: dca.classAveragePrice[`_${year}`],
returns: dca.classReturns[`_${year}`],
stack: dca.classStack[`_${year}`],
}));
}
/**
* Create Investing section
* @param {PartialContext} ctx
* @param {Object} args
* @param {Market["dca"]} args.dca
* @param {Market["lookback"]} args.lookback
* @param {Market["returns"]} args.returns
*/
export function createInvestingSection(ctx, { dca, lookback, returns }) {
const { line, baseline, colors, createPriceLine } = ctx;
const dcaClasses = buildDcaClasses(colors, dca);
return {
name: "Investing",
tree: [
// DCA vs Lump sum
{
name: "DCA vs Lump sum",
tree: /** @type {const} */ ([
["1w", "_1w"],
["1m", "_1m"],
["3m", "_3m"],
["6m", "_6m"],
["1y", "_1y"],
["2y", "_2y"],
["3y", "_3y"],
["4y", "_4y"],
["5y", "_5y"],
["6y", "_6y"],
["8y", "_8y"],
["10y", "_10y"],
]).map(([id, key]) => {
const name = periodIdToName(id, true);
const priceAgo = lookback[key];
const priceReturns = returns.priceReturns[key];
const dcaCostBasis = dca.periodAveragePrice[key];
const dcaReturns = dca.periodReturns[key];
const dcaStack = dca.periodStack[key];
const lumpSumStack = dca.periodLumpSumStack[key];
return {
name,
tree: [
{
name: "Cost basis",
title: `${name} DCA vs Lump Sum (Cost Basis)`,
top: [
line({
metric: dcaCostBasis,
name: "DCA",
color: colors.green,
unit: Unit.usd,
}),
line({
metric: priceAgo,
name: "Lump sum",
color: colors.orange,
unit: Unit.usd,
}),
],
},
{
name: "Returns",
title: `${name} DCA vs Lump Sum (Returns)`,
bottom: [
baseline({
metric: dcaReturns,
name: "DCA",
unit: Unit.percentage,
}),
baseline({
metric: priceReturns,
name: "Lump sum",
color: [colors.lime, colors.red],
unit: Unit.percentage,
}),
createPriceLine({ unit: Unit.percentage }),
],
},
{
name: "Stack",
title: `${name} DCA vs Lump Sum Stack ($100/day)`,
bottom: [
line({
metric: dcaStack.sats,
name: "DCA",
color: colors.green,
unit: Unit.sats,
}),
line({
metric: dcaStack.bitcoin,
name: "DCA",
color: colors.green,
unit: Unit.btc,
}),
line({
metric: dcaStack.dollars,
name: "DCA",
color: colors.green,
unit: Unit.usd,
}),
line({
metric: lumpSumStack.sats,
name: "Lump sum",
color: colors.orange,
unit: Unit.sats,
}),
line({
metric: lumpSumStack.bitcoin,
name: "Lump sum",
color: colors.orange,
unit: Unit.btc,
}),
line({
metric: lumpSumStack.dollars,
name: "Lump sum",
color: colors.orange,
unit: Unit.usd,
}),
],
},
],
};
}),
},
// DCA classes
{
name: "DCA classes",
tree: [
// Comparison charts (all years overlaid)
{
name: "Compare",
tree: [
{
name: "Cost basis",
title: "DCA Cost Basis by Year",
top: dcaClasses.map(
({ year, color, defaultActive, costBasis }) =>
line({
metric: costBasis,
name: `${year}`,
color,
defaultActive,
unit: Unit.usd,
}),
),
},
{
name: "Returns",
title: "DCA Returns by Year",
bottom: dcaClasses.map(
({ year, color, defaultActive, returns }) =>
baseline({
metric: returns,
name: `${year}`,
color,
defaultActive,
unit: Unit.percentage,
}),
),
},
{
name: "Stack",
title: "DCA Stack by Year ($100/day)",
bottom: dcaClasses.flatMap(
({ year, color, defaultActive, stack }) => [
line({
metric: stack.sats,
name: `${year}`,
color,
defaultActive,
unit: Unit.sats,
}),
line({
metric: stack.bitcoin,
name: `${year}`,
color,
defaultActive,
unit: Unit.btc,
}),
line({
metric: stack.dollars,
name: `${year}`,
color,
defaultActive,
unit: Unit.usd,
}),
],
),
},
],
},
// Individual year charts
...dcaClasses.map(({ year, color, costBasis, returns, stack }) => ({
name: `${year}`,
tree: [
{
name: "Cost basis",
title: `DCA Class ${year} Cost Basis`,
top: [
line({
metric: costBasis,
name: "Cost basis",
color,
unit: Unit.usd,
}),
],
},
{
name: "Returns",
title: `DCA Class ${year} Returns`,
bottom: [
baseline({
metric: returns,
name: "Returns",
color,
unit: Unit.percentage,
}),
],
},
{
name: "Stack",
title: `DCA Class ${year} Stack ($100/day)`,
bottom: [
line({
metric: stack.sats,
name: "Stack",
color,
unit: Unit.sats,
}),
line({
metric: stack.bitcoin,
name: "Stack",
color,
unit: Unit.btc,
}),
line({
metric: stack.dollars,
name: "Stack",
color,
unit: Unit.usd,
}),
],
},
],
})),
],
},
],
};
}

View File

@@ -0,0 +1,58 @@
/** Performance section */
import { Unit } from "../../utils/units.js";
import { periodIdToName } from "./utils.js";
/**
* Create Performance section
* @param {PartialContext} ctx
* @param {Market["returns"]} returns
*/
export function createPerformanceSection(ctx, returns) {
const { colors, baseline, createPriceLine } = ctx;
return {
name: "Performance",
tree: /** @type {const} */ ([
["1d", "_1d", undefined],
["1w", "_1w", undefined],
["1m", "_1m", undefined],
["3m", "_3m", undefined],
["6m", "_6m", undefined],
["1y", "_1y", undefined],
["2y", "_2y", "_2y"],
["3y", "_3y", "_3y"],
["4y", "_4y", "_4y"],
["5y", "_5y", "_5y"],
["6y", "_6y", "_6y"],
["8y", "_8y", "_8y"],
["10y", "_10y", "_10y"],
]).map(([id, returnKey, cagrKey]) => {
const priceReturns = returns.priceReturns[returnKey];
const cagr = cagrKey ? returns.cagr[cagrKey] : undefined;
const name = periodIdToName(id, true);
return {
name,
title: `${name} Performance`,
bottom: [
baseline({
metric: priceReturns,
name: "Total",
unit: Unit.percentage,
}),
...(cagr
? [
baseline({
metric: cagr,
name: "CAGR",
color: [colors.lime, colors.pink],
unit: Unit.percentage,
}),
]
: []),
createPriceLine({ unit: Unit.percentage }),
],
};
}),
};
}

View File

@@ -0,0 +1,23 @@
/** Market utilities */
/**
* 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 "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;
}
}

View File

@@ -0,0 +1,362 @@
/** Partial options - Main entry point */
import { localhost } from "../utils/env.js";
import { createContext } from "./context.js";
import {
buildCohortData,
createCohortFolderAll,
createCohortFolderFull,
createCohortFolderWithAdjusted,
createCohortFolderWithPercentiles,
createCohortFolderBasic,
createAddressCohortFolder,
} from "./cohorts/index.js";
import { createMarketSection } from "./market/index.js";
import { createChainSection } from "./chain.js";
import { createCointimeSection } from "./cointime.js";
// Re-export types for external consumers
export * from "./types.js";
/**
* Create partial options tree
* @param {Object} args
* @param {Colors} args.colors
* @param {BrkClient} args.brk
* @returns {PartialOptionsTree}
*/
export function createPartialOptions({ colors, brk }) {
// Create context with all helpers
const ctx = createContext({ colors, brk });
// Build cohort data
const {
cohortAll,
termShort,
termLong,
upToDate,
fromDate,
dateRange,
epoch,
utxosAboveAmount,
addressesAboveAmount,
utxosUnderAmount,
addressesUnderAmount,
utxosAmountRanges,
addressesAmountRanges,
type,
} = buildCohortData(colors, brk);
// Helpers to map cohorts by capability type
/** @param {CohortWithAdjusted} cohort */
const mapWithAdjusted = (cohort) => createCohortFolderWithAdjusted(ctx, cohort);
/** @param {CohortWithPercentiles} cohort */
const mapWithPercentiles = (cohort) => createCohortFolderWithPercentiles(ctx, cohort);
/** @param {CohortBasic} cohort */
const mapBasic = (cohort) => createCohortFolderBasic(ctx, cohort);
/** @param {AddressCohortObject} cohort */
const mapAddressCohorts = (cohort) => createAddressCohortFolder(ctx, cohort);
return [
// Debug explorer (localhost only)
...(localhost
? [
{
kind: /** @type {const} */ ("explorer"),
name: "Explorer",
title: "Debug explorer",
},
]
: []),
// Charts section
{
name: "Charts",
tree: [
// Market section
createMarketSection(ctx),
// Chain section
createChainSection(ctx),
// Cohorts section
{
name: "Cohorts",
tree: [
// All UTXOs - CohortAll (adjustedSopr + percentiles but no RelToMarketCap)
createCohortFolderAll(ctx, cohortAll),
// Terms (STH/LTH) - Short is Full, Long is WithPercentiles
{
name: "terms",
tree: [
// Individual cohorts with their specific capabilities
createCohortFolderFull(ctx, termShort),
createCohortFolderWithPercentiles(ctx, termLong),
],
},
// Epochs - CohortBasic (neither adjustedSopr nor percentiles)
{
name: "Epochs",
tree: [
createCohortFolderBasic(ctx, {
name: "Compare",
title: "Epoch",
list: epoch,
}),
...epoch.map(mapBasic),
],
},
// Types - CohortBasic
{
name: "types",
tree: [
createCohortFolderBasic(ctx, {
name: "Compare",
title: "Type",
list: type,
}),
...type.map(mapBasic),
],
},
// UTXOs Up to age - CohortWithAdjusted (adjustedSopr only)
{
name: "UTXOs Up to age",
tree: [
createCohortFolderWithAdjusted(ctx, {
name: "Compare",
title: "UTXOs Up To Age",
list: upToDate,
}),
...upToDate.map(mapWithAdjusted),
],
},
// UTXOs from age - CohortBasic
{
name: "UTXOs from age",
tree: [
createCohortFolderBasic(ctx, {
name: "Compare",
title: "UTXOs from age",
list: fromDate,
}),
...fromDate.map(mapBasic),
],
},
// UTXOs age ranges - CohortWithPercentiles (percentiles only)
{
name: "UTXOs age Ranges",
tree: [
createCohortFolderWithPercentiles(ctx, {
name: "Compare",
title: "UTXOs Age Range",
list: dateRange,
}),
...dateRange.map(mapWithPercentiles),
],
},
// UTXOs under amounts - CohortBasic
{
name: "UTXOs under amounts",
tree: [
createCohortFolderBasic(ctx, {
name: "Compare",
title: "UTXOs under amount",
list: utxosUnderAmount,
}),
...utxosUnderAmount.map(mapBasic),
],
},
// UTXOs above amounts - CohortBasic
{
name: "UTXOs Above Amounts",
tree: [
createCohortFolderBasic(ctx, {
name: "Compare",
title: "UTXOs Above Amount",
list: utxosAboveAmount,
}),
...utxosAboveAmount.map(mapBasic),
],
},
// UTXOs between amounts - CohortBasic
{
name: "UTXOs between amounts",
tree: [
createCohortFolderBasic(ctx, {
name: "Compare",
title: "UTXOs between amounts",
list: utxosAmountRanges,
}),
...utxosAmountRanges.map(mapBasic),
],
},
// Addresses under amount (TYPE SAFE - uses createAddressCohortFolder!)
{
name: "Addresses under amount",
tree: [
createAddressCohortFolder(ctx, {
name: "Compare",
title: "Addresses under Amount",
list: addressesUnderAmount,
}),
...addressesUnderAmount.map(mapAddressCohorts),
],
},
// Addresses above amount (TYPE SAFE - uses createAddressCohortFolder!)
{
name: "Addresses above amount",
tree: [
createAddressCohortFolder(ctx, {
name: "Compare",
title: "Addresses above amount",
list: addressesAboveAmount,
}),
...addressesAboveAmount.map(mapAddressCohorts),
],
},
// Addresses between amounts (TYPE SAFE - uses createAddressCohortFolder!)
{
name: "Addresses between amounts",
tree: [
createAddressCohortFolder(ctx, {
name: "Compare",
title: "Addresses between amounts",
list: addressesAmountRanges,
}),
...addressesAmountRanges.map(mapAddressCohorts),
],
},
],
},
// Cointime section
createCointimeSection(ctx),
],
},
// Table section
{
kind: /** @type {const} */ ("table"),
title: "Table",
name: "Table",
},
// Simulations section
{
name: "Simulations",
tree: [
{
kind: /** @type {const} */ ("simulation"),
name: "Save In Bitcoin",
title: "Save In Bitcoin",
},
],
},
// Tools section
{
name: "Tools",
tree: [
{
name: "Documentation",
tree: [
{
name: "API",
url: () => "/api",
title: "API documentation",
},
{
name: "MCP",
url: () =>
"https://github.com/bitcoinresearchkit/brk/blob/main/crates/brk_mcp/README.md#brk_mcp",
title: "Model Context Protocol documentation",
},
{
name: "Crate",
url: () => "/crate",
title: "View on crates.io",
},
{
name: "Source",
url: () => "/github",
title: "Source code and issues",
},
{
name: "Changelog",
url: () => "/changelog",
title: "Release notes and changelog",
},
],
},
{
name: "Hosting",
tree: [
{
name: "Status",
url: () => "/status",
title: "Service status and uptime",
},
{
name: "Self-host",
url: () => "/install",
title: "Install and run yourself",
},
{
name: "Service",
url: () => "/service",
title: "Hosted service offering",
},
],
},
{
name: "Community",
tree: [
{
name: "Discord",
url: () => "/discord",
title: "Join the Discord server",
},
{
name: "GitHub",
url: () => "/github",
title: "Source code and issues",
},
{
name: "Nostr",
url: () => "/nostr",
title: "Follow on Nostr",
},
],
},
],
},
// Donate
{
name: "Donate",
qrcode: true,
url: () => "bitcoin:bc1q098zsm89m7kgyze338vfejhpdt92ua9p3peuve",
title: "Bitcoin address for donations",
},
// Share
{
name: "Share",
qrcode: true,
url: () => window.location.href,
title: "Share",
},
];
}

View File

@@ -0,0 +1,736 @@
/** Series helpers for creating chart series blueprints */
import { Unit } from "../utils/units.js";
/**
* Create a Line series
* @param {Object} args
* @param {AnyMetricPattern} args.metric
* @param {string} args.name
* @param {Unit} args.unit
* @param {Color} [args.color]
* @param {boolean} [args.defaultActive]
* @param {LineSeriesPartialOptions} [args.options]
* @returns {FetchedLineSeriesBlueprint}
*/
export function line({ metric, name, color, defaultActive, unit, options }) {
return {
metric,
title: name,
color,
unit,
defaultActive,
options,
};
}
/**
* Create a Dots series (line with only point markers visible)
* @param {Object} args
* @param {AnyMetricPattern} args.metric
* @param {string} args.name
* @param {Unit} args.unit
* @param {Color} [args.color]
* @param {boolean} [args.defaultActive]
* @param {LineSeriesPartialOptions} [args.options]
* @returns {FetchedDotsSeriesBlueprint}
*/
export function dots({ metric, name, color, defaultActive, unit, options }) {
return {
type: /** @type {const} */ ("Dots"),
metric,
title: name,
color,
unit,
defaultActive,
options,
};
}
/**
* Create a Candlestick series
* @param {Object} args
* @param {AnyMetricPattern} args.metric
* @param {string} args.name
* @param {Unit} args.unit
* @param {[Color, Color]} [args.colors] - [upColor, downColor] for legend
* @param {boolean} [args.defaultActive]
* @param {CandlestickSeriesPartialOptions} [args.options]
* @returns {FetchedCandlestickSeriesBlueprint}
*/
export function candlestick({
metric,
name,
colors,
defaultActive,
unit,
options,
}) {
return {
type: /** @type {const} */ ("Candlestick"),
metric,
title: name,
colors,
unit,
defaultActive,
options,
};
}
/**
* Create a Baseline series
* @param {Object} args
* @param {AnyMetricPattern} args.metric
* @param {string} args.name
* @param {Unit} args.unit
* @param {Color | [Color, Color]} [args.color]
* @param {boolean} [args.defaultActive]
* @param {BaselineSeriesPartialOptions} [args.options]
* @returns {FetchedBaselineSeriesBlueprint}
*/
export function baseline({
metric,
name,
color,
defaultActive,
unit,
options,
}) {
const isTuple = Array.isArray(color);
return {
type: /** @type {const} */ ("Baseline"),
metric,
title: name,
color: isTuple ? undefined : color,
colors: isTuple ? color : undefined,
unit,
defaultActive,
options,
};
}
/**
* Create a Histogram series
* @param {Object} args
* @param {AnyMetricPattern} args.metric
* @param {string} args.name
* @param {Unit} args.unit
* @param {Color | [Color, Color]} [args.color]
* @param {boolean} [args.defaultActive]
* @param {HistogramSeriesPartialOptions} [args.options]
* @returns {FetchedHistogramSeriesBlueprint}
*/
export function histogram({
metric,
name,
color,
defaultActive,
unit,
options,
}) {
return {
type: /** @type {const} */ ("Histogram"),
metric,
title: name,
color,
unit,
defaultActive,
options,
};
}
/**
* Create series from a BlockCountPattern ({ base, sum, cumulative })
* @param {Colors} colors
* @param {BlockCountPattern<any>} pattern
* @param {string} title
* @param {Color} [color]
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function fromBlockCount(colors, pattern, title, color) {
return [
{ metric: pattern.sum, title, color: color ?? colors.default },
{
metric: pattern.cumulative,
title: `${title} (cum.)`,
color: colors.cyan,
defaultActive: false,
},
];
}
/**
* Create series from a FullnessPattern ({ base, sum, cumulative, average, min, max, percentiles })
* @param {Colors} colors
* @param {FullnessPattern<any>} pattern
* @param {string} title
* @param {Color} [color]
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function fromBitcoin(colors, pattern, title, color) {
return [
{ metric: pattern.base, title, color: color ?? colors.default },
{ metric: pattern.average, title: "Average", defaultActive: false },
{
metric: pattern.max,
title: "Max",
color: colors.pink,
defaultActive: false,
},
{
metric: pattern.min,
title: "Min",
color: colors.green,
defaultActive: false,
},
{
metric: pattern.median,
title: "Median",
color: colors.amber,
defaultActive: false,
},
{
metric: pattern.pct75,
title: "pct75",
color: colors.red,
defaultActive: false,
},
{
metric: pattern.pct25,
title: "pct25",
color: colors.yellow,
defaultActive: false,
},
{
metric: pattern.pct90,
title: "pct90",
color: colors.rose,
defaultActive: false,
},
{
metric: pattern.pct10,
title: "pct10",
color: colors.lime,
defaultActive: false,
},
];
}
/**
* Create series from a SizePattern ({ sum, cumulative, average, min, max, percentiles })
* @param {Colors} colors
* @param {AnyStatsPattern} pattern
* @param {string} title
* @param {Color} [color]
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function fromBlockSize(colors, pattern, title, color) {
return [
{ metric: pattern.sum, title, color: color ?? colors.default },
{ metric: pattern.average, title: "Average", defaultActive: false },
{
metric: pattern.cumulative,
title: `${title} (cum.)`,
color: colors.cyan,
defaultActive: false,
},
{
metric: pattern.max,
title: "Max",
color: colors.pink,
defaultActive: false,
},
{
metric: pattern.min,
title: "Min",
color: colors.green,
defaultActive: false,
},
{
metric: pattern.median,
title: "Median",
color: colors.amber,
defaultActive: false,
},
{
metric: pattern.pct75,
title: "pct75",
color: colors.red,
defaultActive: false,
},
{
metric: pattern.pct25,
title: "pct25",
color: colors.yellow,
defaultActive: false,
},
{
metric: pattern.pct90,
title: "pct90",
color: colors.rose,
defaultActive: false,
},
{
metric: pattern.pct10,
title: "pct10",
color: colors.lime,
defaultActive: false,
},
];
}
/**
* Create series from a SizePattern ({ average, sum, cumulative, min, max, percentiles })
* @param {Colors} colors
* @param {AnyStatsPattern} pattern
* @param {string} title
* @param {Unit} unit
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function fromSizePattern(colors, pattern, title, unit) {
return [
{ metric: pattern.average, title: `${title} avg`, unit },
{
metric: pattern.sum,
title: `${title} sum`,
color: colors.blue,
unit,
defaultActive: false,
},
{
metric: pattern.cumulative,
title: `${title} cumulative`,
color: colors.indigo,
unit,
defaultActive: false,
},
{
metric: pattern.min,
title: `${title} min`,
color: colors.red,
unit,
defaultActive: false,
},
{
metric: pattern.max,
title: `${title} max`,
color: colors.green,
unit,
defaultActive: false,
},
{
metric: pattern.pct10,
title: `${title} pct10`,
color: colors.rose,
unit,
defaultActive: false,
},
{
metric: pattern.pct25,
title: `${title} pct25`,
color: colors.pink,
unit,
defaultActive: false,
},
{
metric: pattern.median,
title: `${title} median`,
color: colors.purple,
unit,
defaultActive: false,
},
{
metric: pattern.pct75,
title: `${title} pct75`,
color: colors.violet,
unit,
defaultActive: false,
},
{
metric: pattern.pct90,
title: `${title} pct90`,
color: colors.fuchsia,
unit,
defaultActive: false,
},
];
}
/**
* Create series from a FullnessPattern ({ base, average, sum, cumulative, min, max, percentiles })
* @param {Colors} colors
* @param {FullnessPattern<any>} pattern
* @param {string} title
* @param {Unit} unit
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function fromFullnessPattern(colors, pattern, title, unit) {
return [
{ metric: pattern.base, title, unit },
{
metric: pattern.average,
title: `${title} avg`,
color: colors.purple,
unit,
defaultActive: false,
},
{
metric: pattern.min,
title: `${title} min`,
color: colors.red,
unit,
defaultActive: false,
},
{
metric: pattern.max,
title: `${title} max`,
color: colors.green,
unit,
defaultActive: false,
},
{
metric: pattern.pct10,
title: `${title} pct10`,
color: colors.rose,
unit,
defaultActive: false,
},
{
metric: pattern.pct25,
title: `${title} pct25`,
color: colors.pink,
unit,
defaultActive: false,
},
{
metric: pattern.median,
title: `${title} median`,
color: colors.violet,
unit,
defaultActive: false,
},
{
metric: pattern.pct75,
title: `${title} pct75`,
color: colors.fuchsia,
unit,
defaultActive: false,
},
{
metric: pattern.pct90,
title: `${title} pct90`,
color: colors.amber,
unit,
defaultActive: false,
},
];
}
/**
* Create series from a FeeRatePattern ({ average, min, max, percentiles })
* @param {Colors} colors
* @param {FeeRatePattern<any>} pattern
* @param {string} title
* @param {Unit} unit
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function fromFeeRatePattern(colors, pattern, title, unit) {
return [
{ metric: pattern.average, title: `${title} avg`, unit },
{
metric: pattern.min,
title: `${title} min`,
color: colors.red,
unit,
defaultActive: false,
},
{
metric: pattern.max,
title: `${title} max`,
color: colors.green,
unit,
defaultActive: false,
},
{
metric: pattern.pct10,
title: `${title} pct10`,
color: colors.rose,
unit,
defaultActive: false,
},
{
metric: pattern.pct25,
title: `${title} pct25`,
color: colors.pink,
unit,
defaultActive: false,
},
{
metric: pattern.median,
title: `${title} median`,
color: colors.purple,
unit,
defaultActive: false,
},
{
metric: pattern.pct75,
title: `${title} pct75`,
color: colors.violet,
unit,
defaultActive: false,
},
{
metric: pattern.pct90,
title: `${title} pct90`,
color: colors.fuchsia,
unit,
defaultActive: false,
},
];
}
/**
* Create series from a CoinbasePattern ({ sats, bitcoin, dollars } each as FullnessPattern)
* @param {Colors} colors
* @param {CoinbasePattern} pattern
* @param {string} title
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function fromCoinbasePattern(colors, pattern, title) {
return [
...fromFullnessPattern(colors, pattern.sats, title, Unit.sats),
...fromFullnessPattern(colors, pattern.bitcoin, title, Unit.btc),
...fromFullnessPattern(colors, pattern.dollars, title, Unit.usd),
];
}
/**
* Create series from a ValuePattern ({ sats, bitcoin, dollars } each as BlockCountPattern with sum + cumulative)
* @param {Colors} colors
* @param {ValuePattern} pattern
* @param {string} title
* @param {Color} [sumColor]
* @param {Color} [cumulativeColor]
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function fromValuePattern(
colors,
pattern,
title,
sumColor,
cumulativeColor,
) {
return [
{
metric: pattern.sats.sum,
title,
color: sumColor,
unit: Unit.sats,
},
{
metric: pattern.sats.cumulative,
title: `${title} cumulative`,
color: cumulativeColor ?? colors.blue,
unit: Unit.sats,
defaultActive: false,
},
{
metric: pattern.bitcoin.sum,
title,
color: sumColor,
unit: Unit.btc,
},
{
metric: pattern.bitcoin.cumulative,
title: `${title} cumulative`,
color: cumulativeColor ?? colors.blue,
unit: Unit.btc,
defaultActive: false,
},
{
metric: pattern.dollars.sum,
title,
color: sumColor,
unit: Unit.usd,
},
{
metric: pattern.dollars.cumulative,
title: `${title} cumulative`,
color: cumulativeColor ?? colors.blue,
unit: Unit.usd,
defaultActive: false,
},
];
}
/**
* Create sum/cumulative series from a BitcoinPattern ({ sum, cumulative }) with explicit unit and colors
* @param {Colors} colors
* @param {{ sum: AnyMetricPattern, cumulative: AnyMetricPattern }} pattern
* @param {string} title
* @param {Unit} unit
* @param {Color} [sumColor]
* @param {Color} [cumulativeColor]
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function fromBitcoinPatternWithUnit(
colors,
pattern,
title,
unit,
sumColor,
cumulativeColor,
) {
return [
{
metric: pattern.sum,
title: `${title} sum`,
color: sumColor,
unit,
},
{
metric: pattern.cumulative,
title: `${title} cumulative`,
color: cumulativeColor ?? colors.blue,
unit,
defaultActive: false,
},
];
}
/**
* Create sum/cumulative series from a BlockCountPattern with explicit unit and colors
* @param {Colors} colors
* @param {BlockCountPattern<any>} pattern
* @param {string} title
* @param {Unit} unit
* @param {Color} [sumColor]
* @param {Color} [cumulativeColor]
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function fromBlockCountWithUnit(
colors,
pattern,
title,
unit,
sumColor,
cumulativeColor,
) {
return [
{
metric: pattern.sum,
title: `${title} sum`,
color: sumColor,
unit,
},
{
metric: pattern.cumulative,
title: `${title} cumulative`,
color: cumulativeColor ?? colors.blue,
unit,
defaultActive: false,
},
];
}
/**
* Create series from an IntervalPattern (base + average/min/max/median/percentiles, no sum/cumulative)
* @param {Colors} colors
* @param {IntervalPattern} pattern
* @param {string} title
* @param {Unit} unit
* @param {Color} [color]
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function fromIntervalPattern(colors, pattern, title, unit, color) {
return [
{ metric: pattern.base, title, color, unit },
{
metric: pattern.average,
title: `${title} avg`,
color: colors.purple,
unit,
defaultActive: false,
},
{
metric: pattern.min,
title: `${title} min`,
color: colors.red,
unit,
defaultActive: false,
},
{
metric: pattern.max,
title: `${title} max`,
color: colors.green,
unit,
defaultActive: false,
},
{
metric: pattern.median,
title: `${title} median`,
color: colors.violet,
unit,
defaultActive: false,
},
{
metric: pattern.pct10,
title: `${title} pct10`,
color: colors.rose,
unit,
defaultActive: false,
},
{
metric: pattern.pct25,
title: `${title} pct25`,
color: colors.pink,
unit,
defaultActive: false,
},
{
metric: pattern.pct75,
title: `${title} pct75`,
color: colors.fuchsia,
unit,
defaultActive: false,
},
{
metric: pattern.pct90,
title: `${title} pct90`,
color: colors.amber,
unit,
defaultActive: false,
},
];
}
/**
* Create series from a SupplyPattern (sats/bitcoin/dollars, no sum/cumulative)
* @param {Colors} colors
* @param {SupplyPattern} pattern
* @param {string} title
* @param {Color} [color]
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function fromSupplyPattern(colors, pattern, title, color) {
return [
{
metric: pattern.sats,
title,
color: color ?? colors.default,
unit: Unit.sats,
},
{
metric: pattern.bitcoin,
title,
color: color ?? colors.default,
unit: Unit.btc,
},
{
metric: pattern.dollars,
title,
color: color ?? colors.default,
unit: Unit.usd,
},
];
}

View File

@@ -0,0 +1,267 @@
/**
* @typedef {Object} BaseSeriesBlueprint
* @property {string} title
* @property {boolean} [defaultActive]
*
* @typedef {Object} BaselineSeriesBlueprintSpecific
* @property {"Baseline"} type
* @property {Color} [color]
* @property {[Color, Color]} [colors]
* @property {BaselineSeriesPartialOptions} [options]
* @property {Accessor<BaselineData[]>} [data]
* @typedef {BaseSeriesBlueprint & BaselineSeriesBlueprintSpecific} BaselineSeriesBlueprint
*
* @typedef {Object} CandlestickSeriesBlueprintSpecific
* @property {"Candlestick"} type
* @property {[Color, Color]} [colors]
* @property {CandlestickSeriesPartialOptions} [options]
* @property {Accessor<CandlestickData[]>} [data]
* @typedef {BaseSeriesBlueprint & CandlestickSeriesBlueprintSpecific} CandlestickSeriesBlueprint
*
* @typedef {Object} LineSeriesBlueprintSpecific
* @property {"Line"} [type]
* @property {Color} [color]
* @property {LineSeriesPartialOptions} [options]
* @property {Accessor<LineData[]>} [data]
* @typedef {BaseSeriesBlueprint & LineSeriesBlueprintSpecific} LineSeriesBlueprint
*
* @typedef {Object} HistogramSeriesBlueprintSpecific
* @property {"Histogram"} type
* @property {Color | [Color, Color]} [color] - Single color or [positive, negative] colors (defaults to green/red)
* @property {HistogramSeriesPartialOptions} [options]
* @property {Accessor<HistogramData[]>} [data]
* @typedef {BaseSeriesBlueprint & HistogramSeriesBlueprintSpecific} HistogramSeriesBlueprint
*
* @typedef {Object} DotsSeriesBlueprintSpecific
* @property {"Dots"} type
* @property {Color} [color]
* @property {LineSeriesPartialOptions} [options]
* @property {Accessor<LineData[]>} [data]
* @typedef {BaseSeriesBlueprint & DotsSeriesBlueprintSpecific} DotsSeriesBlueprint
*
* @typedef {BaselineSeriesBlueprint | CandlestickSeriesBlueprint | LineSeriesBlueprint | HistogramSeriesBlueprint | DotsSeriesBlueprint} AnySeriesBlueprint
*
* @typedef {AnySeriesBlueprint["type"]} SeriesType
*
* @typedef {{ metric: AnyMetricPattern, unit?: Unit }} FetchedAnySeriesOptions
*
* @typedef {BaselineSeriesBlueprint & FetchedAnySeriesOptions} FetchedBaselineSeriesBlueprint
* @typedef {CandlestickSeriesBlueprint & FetchedAnySeriesOptions} FetchedCandlestickSeriesBlueprint
* @typedef {LineSeriesBlueprint & FetchedAnySeriesOptions} FetchedLineSeriesBlueprint
* @typedef {HistogramSeriesBlueprint & FetchedAnySeriesOptions} FetchedHistogramSeriesBlueprint
* @typedef {DotsSeriesBlueprint & FetchedAnySeriesOptions} FetchedDotsSeriesBlueprint
* @typedef {AnySeriesBlueprint & FetchedAnySeriesOptions} AnyFetchedSeriesBlueprint
*
* @typedef {Object} PartialOption
* @property {string} name
*
* @typedef {Object} ProcessedOptionAddons
* @property {string} title
* @property {string[]} path
*
* @typedef {Object} PartialExplorerOptionSpecific
* @property {"explorer"} kind
* @property {string} title
*
* @typedef {PartialOption & PartialExplorerOptionSpecific} PartialExplorerOption
*
* @typedef {Required<PartialExplorerOption> & ProcessedOptionAddons} ExplorerOption
*
* @typedef {Object} PartialChartOptionSpecific
* @property {"chart"} [kind]
* @property {string} title
* @property {AnyFetchedSeriesBlueprint[]} [top]
* @property {AnyFetchedSeriesBlueprint[]} [bottom]
*
* @typedef {PartialOption & PartialChartOptionSpecific} PartialChartOption
*
* @typedef {Object} ProcessedChartOptionAddons
* @property {Map<Unit, AnyFetchedSeriesBlueprint[]>} top
* @property {Map<Unit, AnyFetchedSeriesBlueprint[]>} bottom
*
* @typedef {Required<Omit<PartialChartOption, "top" | "bottom">> & ProcessedChartOptionAddons & ProcessedOptionAddons} ChartOption
*
* @typedef {Object} PartialTableOptionSpecific
* @property {"table"} kind
* @property {string} title
*
* @typedef {PartialOption & PartialTableOptionSpecific} PartialTableOption
*
* @typedef {Required<PartialTableOption> & ProcessedOptionAddons} TableOption
*
* @typedef {Object} PartialSimulationOptionSpecific
* @property {"simulation"} kind
* @property {string} title
*
* @typedef {PartialOption & PartialSimulationOptionSpecific} PartialSimulationOption
*
* @typedef {Required<PartialSimulationOption> & ProcessedOptionAddons} SimulationOption
*
* @typedef {Object} PartialUrlOptionSpecific
* @property {"url"} [kind]
* @property {() => string} url
* @property {string} title
* @property {boolean} [qrcode]
*
* @typedef {PartialOption & PartialUrlOptionSpecific} PartialUrlOption
*
* @typedef {Required<PartialUrlOption> & ProcessedOptionAddons} UrlOption
*
* @typedef {PartialExplorerOption | PartialChartOption | PartialTableOption | PartialSimulationOption | PartialUrlOption} AnyPartialOption
*
* @typedef {ExplorerOption | ChartOption | TableOption | SimulationOption | UrlOption} Option
*
* @typedef {(AnyPartialOption | PartialOptionsGroup)[]} PartialOptionsTree
*
* @typedef {Object} PartialOptionsGroup
* @property {string} name
* @property {PartialOptionsTree} tree
*
* @typedef {Object} OptionsGroup
* @property {string} name
* @property {OptionsTree} tree
*
* @typedef {(Option | OptionsGroup)[]} OptionsTree
*
* @typedef {Object} UtxoCohortObject
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {UtxoCohortPattern} tree
*
* ============================================================================
* UTXO Cohort Pattern Types (based on brk client patterns)
* ============================================================================
*
* Patterns with adjustedSopr + percentiles + RelToMarketCap:
* - ShortTermPattern (term.short)
* @typedef {ShortTermPattern} PatternFull
*
* The "All" pattern is special - has adjustedSopr + percentiles but NO RelToMarketCap
* @typedef {AllUtxoPattern} PatternAll
*
* Patterns with adjustedSopr only (RealizedPattern4, CostBasisPattern):
* - MaxAgePattern (maxAge.*)
* @typedef {MaxAgePattern} PatternWithAdjusted
*
* Patterns with percentiles only (RealizedPattern2, CostBasisPattern2):
* - LongTermPattern (term.long)
* - AgeRangePattern (ageRange.*)
* @typedef {LongTermPattern | AgeRangePattern} PatternWithPercentiles
*
* Patterns with neither (RealizedPattern/2, CostBasisPattern):
* - BasicUtxoPattern (minAge.*, geAmount.*, ltAmount.*)
* - EpochPattern (epoch.*)
* @typedef {BasicUtxoPattern | EpochPattern} PatternBasic
*
* ============================================================================
* Cohort Object Types (by capability)
* ============================================================================
*
* All cohort: adjustedSopr + percentiles but NO RelToMarketCap (special)
* @typedef {Object} CohortAll
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {PatternAll} tree
*
* Full cohort: adjustedSopr + percentiles + RelToMarketCap (term.short)
* @typedef {Object} CohortFull
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {PatternFull} tree
*
* Cohort with adjustedSopr only (maxAge.*)
* @typedef {Object} CohortWithAdjusted
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {PatternWithAdjusted} tree
*
* Cohort with percentiles only (term.long, ageRange.*)
* @typedef {Object} CohortWithPercentiles
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {PatternWithPercentiles} tree
*
* Basic cohort: neither (minAge.*, epoch.*, amount cohorts)
* @typedef {Object} CohortBasic
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {PatternBasic} tree
*
* ============================================================================
* Cohort Group Types (by capability)
* ============================================================================
*
* @typedef {Object} CohortGroupFull
* @property {string} name
* @property {string} title
* @property {readonly CohortFull[]} list
*
* @typedef {Object} CohortGroupWithAdjusted
* @property {string} name
* @property {string} title
* @property {readonly CohortWithAdjusted[]} list
*
* @typedef {Object} CohortGroupWithPercentiles
* @property {string} name
* @property {string} title
* @property {readonly CohortWithPercentiles[]} list
*
* @typedef {Object} CohortGroupBasic
* @property {string} name
* @property {string} title
* @property {readonly CohortBasic[]} list
*
* @typedef {Object} UtxoCohortGroupObject
* @property {string} name
* @property {string} title
* @property {readonly UtxoCohortObject[]} list
*
* @typedef {Object} AddressCohortObject
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {AddressCohortPattern} tree
*
* @typedef {UtxoCohortObject | AddressCohortObject} CohortObject
*
*
* @typedef {Object} AddressCohortGroupObject
* @property {string} name
* @property {string} title
* @property {readonly AddressCohortObject[]} list
*
* @typedef {UtxoCohortGroupObject | AddressCohortGroupObject} CohortGroupObject
*
* @typedef {Object} PartialContext
* @property {Colors} colors
* @property {BrkClient} brk
* @property {LineSeriesFn} line
* @property {DotsSeriesFn} dots
* @property {CandlestickSeriesFn} candlestick
* @property {BaselineSeriesFn} baseline
* @property {HistogramSeriesFn} histogram
* @property {(pattern: BlockCountPattern<any>, title: string, color?: Color) => AnyFetchedSeriesBlueprint[]} fromBlockCount
* @property {(pattern: FullnessPattern<any>, title: string, color?: Color) => AnyFetchedSeriesBlueprint[]} fromBitcoin
* @property {(pattern: AnyStatsPattern, title: string, color?: Color) => AnyFetchedSeriesBlueprint[]} fromBlockSize
* @property {(pattern: AnyStatsPattern, title: string, unit: Unit) => AnyFetchedSeriesBlueprint[]} fromSizePattern
* @property {(pattern: FullnessPattern<any>, title: string, unit: Unit) => AnyFetchedSeriesBlueprint[]} fromFullnessPattern
* @property {(pattern: FeeRatePattern<any>, title: string, unit: Unit) => AnyFetchedSeriesBlueprint[]} fromFeeRatePattern
* @property {(pattern: CoinbasePattern, title: string) => AnyFetchedSeriesBlueprint[]} fromCoinbasePattern
* @property {(pattern: ValuePattern, title: string, sumColor?: Color, cumulativeColor?: Color) => AnyFetchedSeriesBlueprint[]} fromValuePattern
* @property {(pattern: { sum: AnyMetricPattern, cumulative: AnyMetricPattern }, title: string, unit: Unit, sumColor?: Color, cumulativeColor?: Color) => AnyFetchedSeriesBlueprint[]} fromBitcoinPatternWithUnit
* @property {(pattern: BlockCountPattern<any>, title: string, unit: Unit, sumColor?: Color, cumulativeColor?: Color) => AnyFetchedSeriesBlueprint[]} fromBlockCountWithUnit
* @property {(pattern: IntervalPattern, title: string, unit: Unit, color?: Color) => AnyFetchedSeriesBlueprint[]} fromIntervalPattern
* @property {(pattern: SupplyPattern, title: string, color?: Color) => AnyFetchedSeriesBlueprint[]} fromSupplyPattern
* @property {(args: { number?: number, name?: string, defaultActive?: boolean, lineStyle?: LineStyle, color?: Color, unit: Unit }) => FetchedLineSeriesBlueprint} createPriceLine
* @property {(args: { numbers: number[], unit: Unit }) => FetchedLineSeriesBlueprint[]} createPriceLines
* @property {(args: { constant: AnyMetricPattern, name: string, unit: Unit, color?: Color, lineStyle?: number, defaultActive?: boolean }) => FetchedLineSeriesBlueprint} constantLine
*/
// Re-export for type consumers
export {};

View File

@@ -0,0 +1,44 @@
/** Track unused metrics (dev only) */
import { localhost } from "../utils/env.js";
/** @type {Set<AnyMetricPattern> | null} */
export const unused = localhost ? new Set() : null;
/**
* Walk and collect AnyMetricPatterns
* @param {TreeNode | null | undefined} node
* @param {Set<AnyMetricPattern>} set
*/
function walk(node, set) {
if (node && "by" in node) {
set.add(/** @type {AnyMetricPattern} */ (node));
} else if (node && typeof node === "object") {
for (const value of Object.values(node)) {
walk(/** @type {TreeNode | null | undefined} */ (value), set);
}
}
}
/**
* Collect all AnyMetricPatterns from tree
* @param {TreeNode} tree
*/
export function collect(tree) {
if (unused) walk(tree, unused);
}
/**
* Mark a metric as used
* @param {AnyMetricPattern} metric
*/
export function markUsed(metric) {
unused?.delete(metric);
}
/** Log unused metrics to console */
export function logUnused() {
if (!unused?.size) return;
const paths = [...unused].map((m) => Object.values(m.by)[0].path);
console.warn("Unused metrics:", paths);
}

View File

@@ -0,0 +1,525 @@
import {
createShadow,
createChoiceField,
createHeader,
} from "../../utils/dom.js";
import { chartElement } from "../../utils/elements.js";
import { ios, canShare } from "../../utils/env.js";
import { serdeChartableIndex, serdeOptNumber } from "../../utils/serde.js";
import { throttle } from "../../utils/timing.js";
import { Unit } from "../../utils/units.js";
import signals from "../../signals.js";
import { createChartElement } from "../../chart/index.js";
import { webSockets } from "../../utils/ws.js";
const keyPrefix = "chart";
const ONE_BTC_IN_SATS = 100_000_000;
/**
* @typedef {"timestamp" | "date" | "week" | "month" | "quarter" | "semester" | "year" | "decade" } ChartableIndexName
*/
/**
* @param {Object} args
* @param {Colors} args.colors
* @param {Accessor<ChartOption>} args.option
* @param {BrkClient} args.brk
*/
export function init({ colors, option, brk }) {
chartElement.append(createShadow("left"));
chartElement.append(createShadow("right"));
const { headerElement, headingElement } = createHeader();
chartElement.append(headerElement);
const { index, fieldset } = createIndexSelector(option);
const TIMERANGE_LS_KEY = signals.createMemo(
() => `chart-timerange-${index()}`,
);
let firstRun = true;
const from = signals.createSignal(/** @type {number | null} */ (null), {
save: {
...serdeOptNumber,
keyPrefix: TIMERANGE_LS_KEY,
key: "from",
serializeParam: firstRun,
},
});
const to = signals.createSignal(/** @type {number | null} */ (null), {
save: {
...serdeOptNumber,
keyPrefix: TIMERANGE_LS_KEY,
key: "to",
serializeParam: firstRun,
},
});
const chart = createChartElement({
parent: chartElement,
signals,
colors,
id: "charts",
brk,
index,
timeScaleSetCallback: (unknownTimeScaleCallback) => {
// TODO: Although it mostly works in practice, need to make it more robust, there is no guarantee that this runs in order and wait for `from` and `to` to update when `index` and thus `TIMERANGE_LS_KEY` is updated
// Need to have the right values before the update
const from_ = from();
const to_ = to();
if (from_ !== null && to_ !== null) {
chart.inner.timeScale().setVisibleLogicalRange({
from: from_,
to: to_,
});
} else {
unknownTimeScaleCallback();
}
},
});
if (!(ios && !canShare)) {
const chartBottomRightCanvas = Array.from(
chart.inner.chartElement().getElementsByTagName("tr"),
).at(-1)?.lastChild?.firstChild?.firstChild;
if (chartBottomRightCanvas) {
const domain = window.document.createElement("p");
domain.innerText = `${window.location.host}`;
domain.id = "domain";
const screenshotButton = window.document.createElement("button");
screenshotButton.id = "screenshot";
const camera = "[ ◉¯]";
screenshotButton.innerHTML = camera;
screenshotButton.title = "Screenshot";
chartBottomRightCanvas.replaceWith(screenshotButton);
screenshotButton.addEventListener("click", () => {
import("./screenshot").then(async ({ screenshot }) => {
chartElement.dataset.screenshot = "true";
chartElement.append(domain);
try {
await screenshot({
element: chartElement,
name: option().path.join("-"),
title: option().title,
});
} catch {}
chartElement.removeChild(domain);
chartElement.dataset.screenshot = "false";
});
});
}
}
chart.inner.timeScale().subscribeVisibleLogicalRangeChange(
throttle((t) => {
if (!t) return;
from.set(t.from);
to.set(t.to);
}, 250),
);
chartElement.append(fieldset);
const { field: topUnitField, selected: topUnit } = createChoiceField({
defaultValue: Unit.usd,
keyPrefix,
key: "unit-0",
choices: [Unit.usd, Unit.sats],
toKey: (u) => u.id,
toLabel: (u) => u.name,
signals,
sorted: true,
type: "select",
});
chart.addFieldsetIfNeeded({
id: "charts-unit-0",
paneIndex: 0,
position: "nw",
createChild() {
return topUnitField;
},
});
const seriesListTop = /** @type {AnySeries[]} */ ([]);
const seriesListBottom = /** @type {AnySeries[]} */ ([]);
/**
* @param {Object} params
* @param {AnySeries} params.series
* @param {Unit} params.unit
* @param {IndexName} params.index
*/
function printLatest({ series, unit, index }) {
const _latest = webSockets.kraken1dCandle.latest();
if (!_latest) return;
const latest = { ..._latest };
if (unit === Unit.sats) {
latest.open = Math.floor(ONE_BTC_IN_SATS / latest.open);
latest.high = Math.floor(ONE_BTC_IN_SATS / latest.high);
latest.low = Math.floor(ONE_BTC_IN_SATS / latest.low);
latest.close = Math.floor(ONE_BTC_IN_SATS / latest.close);
}
const last_ = series.getData().at(-1);
if (!last_) return;
const last = { ...last_ };
if ("close" in last) {
last.close = latest.close;
}
if ("value" in last) {
last.value = latest.close;
}
const date = new Date(/** @type {number} */ (latest.time) * 1000);
switch (index) {
case "height":
case "difficultyepoch":
case "halvingepoch": {
if ("close" in last) {
last.low = Math.min(last.low, latest.close);
last.high = Math.max(last.high, latest.close);
}
series.update(last);
break;
}
default: {
if (index === "weekindex") {
date.setUTCDate(date.getUTCDate() - ((date.getUTCDay() + 6) % 7));
} else if (index === "monthindex") {
date.setUTCDate(1);
} else if (index === "quarterindex") {
const month = date.getUTCMonth();
date.setUTCMonth(month - (month % 3), 1);
} else if (index === "semesterindex") {
const month = date.getUTCMonth();
date.setUTCMonth(month - (month % 6), 1);
} else if (index === "yearindex") {
date.setUTCMonth(0, 1);
} else if (index === "decadeindex") {
date.setUTCFullYear(
Math.floor(date.getUTCFullYear() / 10) * 10,
0,
1,
);
} else if (index !== "dateindex") {
throw Error("Unsupported");
}
const time = date.valueOf() / 1000;
if (time === last.time) {
if ("close" in last) {
last.low = Math.min(last.low, latest.low);
last.high = Math.max(last.high, latest.high);
}
series.update(last);
} else {
last.time = time;
series.update(last);
}
}
}
}
signals.createEffect(option, (option) => {
headingElement.innerHTML = option.title;
const bottomUnits = Array.from(option.bottom.keys());
/** @type {{ field: HTMLDivElement, selected: Accessor<Unit> } | undefined} */
let bottomUnitSelector;
if (bottomUnits.length) {
bottomUnitSelector = createChoiceField({
defaultValue: bottomUnits[0],
keyPrefix,
key: "unit-1",
choices: bottomUnits,
toKey: (u) => u.id,
toLabel: (u) => u.name,
signals,
sorted: true,
type: "select",
});
const field = bottomUnitSelector.field;
chart.addFieldsetIfNeeded({
id: "charts-unit-1",
paneIndex: 1,
position: "nw",
createChild() {
return field;
},
});
} else {
// Clean up bottom pane when new option has no bottom series
seriesListBottom.forEach((series) => series.remove());
seriesListBottom.length = 0;
chart.legendBottom.removeFrom(0);
}
signals.createEffect(index, (index) => {
signals.createEffect(topUnit, (topUnit) => {
/** @type {AnySeries | undefined} */
let series;
switch (topUnit) {
case Unit.usd: {
series = chart.addCandlestickSeries({
metric: brk.metrics.price.usd.ohlc,
name: "Price",
unit: topUnit,
order: 0,
});
break;
}
case Unit.sats: {
series = chart.addCandlestickSeries({
metric: brk.metrics.price.sats.ohlc,
name: "Price",
unit: topUnit,
inverse: true,
order: 0,
});
break;
}
}
if (!series) throw Error("Unreachable");
seriesListTop[0]?.remove();
seriesListTop[0] = series;
signals.createEffect(
() => ({
latest: webSockets.kraken1dCandle.latest(),
hasData: series.hasData(),
}),
({ latest, hasData }) => {
if (!series || !latest || !hasData) return;
printLatest({ series, unit: topUnit, index });
},
);
});
/**
* @param {Object} args
* @param {Map<Unit, AnyFetchedSeriesBlueprint[]>} args.blueprints
* @param {number} args.paneIndex
* @param {Accessor<Unit>} args.unit
* @param {AnySeries[]} args.seriesList
* @param {number} args.orderStart
* @param {Legend} args.legend
*/
function processPane({
blueprints,
paneIndex,
unit,
seriesList,
orderStart,
legend,
}) {
signals.createEffect(unit, (unit) => {
legend.removeFrom(orderStart);
seriesList.splice(orderStart).forEach((series) => {
series.remove();
});
blueprints.get(unit)?.forEach((blueprint, order) => {
order += orderStart;
const options = blueprint.options;
// Tree-first: metric is now an accessor with .by property
const indexes = Object.keys(blueprint.metric.by);
if (indexes.includes(index)) {
switch (blueprint.type) {
case "Baseline": {
seriesList.push(
chart.addBaselineSeries({
metric: blueprint.metric,
name: blueprint.title,
unit,
defaultActive: blueprint.defaultActive,
paneIndex,
options: {
...options,
topLineColor:
blueprint.color?.() ?? blueprint.colors?.[0](),
bottomLineColor:
blueprint.color?.() ?? blueprint.colors?.[1](),
},
order,
}),
);
break;
}
case "Histogram": {
seriesList.push(
chart.addHistogramSeries({
metric: blueprint.metric,
name: blueprint.title,
unit,
color: blueprint.color,
defaultActive: blueprint.defaultActive,
paneIndex,
options,
order,
}),
);
break;
}
case "Candlestick": {
seriesList.push(
chart.addCandlestickSeries({
metric: blueprint.metric,
name: blueprint.title,
unit,
colors: blueprint.colors,
defaultActive: blueprint.defaultActive,
paneIndex,
options,
order,
}),
);
break;
}
case "Dots": {
seriesList.push(
chart.addDotsSeries({
metric: blueprint.metric,
color: blueprint.color,
name: blueprint.title,
unit,
defaultActive: blueprint.defaultActive,
paneIndex,
options,
order,
}),
);
break;
}
case "Line":
case undefined:
seriesList.push(
chart.addLineSeries({
metric: blueprint.metric,
color: blueprint.color,
name: blueprint.title,
unit,
defaultActive: blueprint.defaultActive,
paneIndex,
options,
order,
}),
);
}
}
});
});
}
processPane({
blueprints: option.top,
paneIndex: 0,
unit: topUnit,
seriesList: seriesListTop,
orderStart: 1,
legend: chart.legendTop,
});
if (bottomUnitSelector) {
processPane({
blueprints: option.bottom,
paneIndex: 1,
unit: bottomUnitSelector.selected,
seriesList: seriesListBottom,
orderStart: 0,
legend: chart.legendBottom,
});
}
firstRun = false;
});
});
}
/**
* @param {Accessor<ChartOption>} option
*/
function createIndexSelector(option) {
const choices_ = /** @satisfies {ChartableIndexName[]} */ ([
"timestamp",
"date",
"week",
"month",
"quarter",
"semester",
"year",
"decade",
]);
/** @type {Accessor<typeof choices_>} */
const choices = signals.createMemo(() => {
const o = option();
if (!o.top.size && !o.bottom.size) {
return [...choices_];
}
const rawIndexes = new Set(
[Array.from(o.top.values()), Array.from(o.bottom.values())]
.flat(2)
.filter((blueprint) => {
const path = Object.values(blueprint.metric.by)[0]?.path ?? "";
return !path.includes("constant_");
})
.flatMap((blueprint) => blueprint.metric.indexes()),
);
const serializedIndexes = [...rawIndexes].flatMap((index) => {
const c = serdeChartableIndex.serialize(index);
return c ? [c] : [];
});
return /** @type {any} */ (
choices_.filter((choice) => serializedIndexes.includes(choice))
);
});
/** @type {ChartableIndexName} */
const defaultIndex = "date";
const { field, selected } = createChoiceField({
defaultValue: defaultIndex,
keyPrefix,
key: "index",
choices,
id: "index",
signals,
});
const fieldset = window.document.createElement("fieldset");
fieldset.id = "interval";
const screenshotSpan = window.document.createElement("span");
screenshotSpan.innerText = "interval:";
fieldset.append(screenshotSpan);
fieldset.append(field);
fieldset.dataset.size = "sm";
const index = signals.createMemo(() =>
serdeChartableIndex.deserialize(selected()),
);
return { fieldset, index };
}

View File

@@ -0,0 +1,38 @@
import { ios } from "../../utils/env.js";
import { domToBlob } from "../../modules/modern-screenshot/4.6.7/dist/index.mjs";
/**
* @param {Object} args
* @param {Element} args.element
* @param {string} args.name
* @param {string} args.title
*/
export async function screenshot({ element, name, title }) {
const blob = await domToBlob(element, {
scale: 2,
});
if (ios) {
const file = new File(
[blob],
`bitview-${name}-${new Date().toJSON().split(".")[0]}.png`,
{
type: "image/png",
},
);
try {
await navigator.share({
files: [file],
title: `${title} on ${window.document.location.hostname}`,
});
return;
} catch (err) {
console.log(err);
}
}
const url = URL.createObjectURL(blob);
window.open(url, "_blank");
setTimeout(() => URL.revokeObjectURL(url), 100);
}

View File

@@ -0,0 +1,99 @@
import { randomFromArray } from "../utils/array.js";
import { explorerElement } from "../utils/elements.js";
export function init() {
const chain = window.document.createElement("div");
chain.id = "chain";
explorerElement.append(chain);
// vecsResources.getOrCreate(/** @satisfies {Height}*/ (5), "height");
//
const miners = [
{ name: "Foundry USA", color: "orange" },
{ name: "Via BTC", color: "teal" },
{ name: "Ant Pool", color: "emerald" },
{ name: "F2Pool", color: "indigo" },
{ name: "Spider Pool", color: "yellow" },
{ name: "Mara Pool", color: "amber" },
{ name: "SEC Pool", color: "violet" },
{ name: "Luxor", color: "orange" },
{ name: "Brains Pool", color: "cyan" },
];
for (let i = 0; i <= 10; i++) {
const { name, color: _color } = randomFromArray(miners);
const { cubeElement, leftFaceElement, rightFaceElement, topFaceElement } =
createCube();
// cubeElement.style.setProperty("--color", `var(--${color})`);
const heightElement = window.document.createElement("p");
const height = (1_000_002 - i).toString();
const prefixLength = 7 - height.length;
const spanPrefix = window.document.createElement("span");
spanPrefix.style.opacity = "0.5";
spanPrefix.style.userSelect = "none";
heightElement.append(spanPrefix);
spanPrefix.innerHTML = "#" + "0".repeat(prefixLength);
const spanHeight = window.document.createElement("span");
heightElement.append(spanHeight);
spanHeight.innerHTML = height;
rightFaceElement.append(heightElement);
const feesElement = window.document.createElement("div");
feesElement.classList.add("fees");
leftFaceElement.append(feesElement);
const averageFeeElement = window.document.createElement("p");
feesElement.append(averageFeeElement);
averageFeeElement.innerHTML = `~1.41`;
const feeRangeElement = window.document.createElement("p");
feesElement.append(feeRangeElement);
const minFeeElement = window.document.createElement("span");
minFeeElement.innerHTML = `0.11`;
feeRangeElement.append(minFeeElement);
const dashElement = window.document.createElement("span");
dashElement.style.opacity = "0.5";
dashElement.innerHTML = `-`;
feeRangeElement.append(dashElement);
const maxFeeElement = window.document.createElement("span");
maxFeeElement.innerHTML = `12.1`;
feeRangeElement.append(maxFeeElement);
const feeUnitElement = window.document.createElement("p");
feesElement.append(feeUnitElement);
feeUnitElement.style.opacity = "0.5";
feeUnitElement.innerHTML = `sat/vB`;
const spanMiner = window.document.createElement("span");
spanMiner.innerHTML = name;
topFaceElement.append(spanMiner);
chain.prepend(cubeElement);
}
}
function createCube() {
const cubeElement = window.document.createElement("div");
cubeElement.classList.add("cube");
const rightFaceElement = window.document.createElement("div");
rightFaceElement.classList.add("face");
rightFaceElement.classList.add("right");
cubeElement.append(rightFaceElement);
const leftFaceElement = window.document.createElement("div");
leftFaceElement.classList.add("face");
leftFaceElement.classList.add("left");
cubeElement.append(leftFaceElement);
const topFaceElement = window.document.createElement("div");
topFaceElement.classList.add("face");
topFaceElement.classList.add("top");
cubeElement.append(topFaceElement);
return {
cubeElement,
leftFaceElement,
rightFaceElement,
topFaceElement,
};
}

View File

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,433 @@
// @ts-nocheck
import { randomFromArray } from "../utils/array.js";
import { createButtonElement, createHeader, createSelect } from "../utils/dom.js";
import { tableElement } from "../utils/elements.js";
import { serdeMetrics, serdeString } from "../utils/serde.js";
import { resetParams } from "../utils/url.js";
export function init() {
tableElement.innerHTML = "wip, will hopefuly be back soon, sorry !";
// const parent = tableElement;
// const { headerElement } = createHeader("Table");
// parent.append(headerElement);
// const div = window.document.createElement("div");
// parent.append(div);
// const table = createTable({
// signals,
// brk,
// resources,
// option,
// });
// div.append(table.element);
// const span = window.document.createElement("span");
// span.innerHTML = "Add column";
// div.append(
// createButtonElement({
// onClick: () => {
// table.addRandomCol?.();
// },
// inside: span,
// title: "Click or tap to add a column to the table",
// }),
// );
}
// /**
// * @param {Object} args
// * @param {Option} args.option
// * @param {Signals} args.signals
// * @param {BrkClient} args.brk
// * @param {Resources} args.resources
// */
// function createTable({ brk, signals, option, resources }) {
// const indexToMetrics = createIndexToMetrics(metricToIndexes);
// const serializedIndexes = createSerializedIndexes();
// /** @type {SerializedIndex} */
// const defaultSerializedIndex = "height";
// const serializedIndex = /** @type {Signal<SerializedIndex>} */ (
// signals.createSignal(
// /** @type {SerializedIndex} */ (defaultSerializedIndex),
// {
// save: {
// ...serdeString,
// keyPrefix: "table",
// key: "index",
// },
// },
// )
// );
// const index = signals.createMemo(() =>
// serializedIndexToIndex(serializedIndex()),
// );
// const table = window.document.createElement("table");
// const obj = {
// element: table,
// /** @type {VoidFunction | undefined} */
// addRandomCol: undefined,
// };
// signals.createEffect(index, (index, prevIndex) => {
// if (prevIndex !== undefined) {
// resetParams(option);
// }
// const possibleMetrics = indexToMetrics[index];
// const columns = signals.createSignal(/** @type {Metric[]} */ ([]), {
// equals: false,
// save: {
// ...serdeMetrics,
// keyPrefix: `table-${serializedIndex()}`,
// key: `columns`,
// },
// });
// columns.set((l) => l.filter((id) => possibleMetrics.includes(id)));
// signals.createEffect(columns, (columns) => {
// console.log(columns);
// });
// table.innerHTML = "";
// const thead = window.document.createElement("thead");
// table.append(thead);
// const trHead = window.document.createElement("tr");
// thead.append(trHead);
// const tbody = window.document.createElement("tbody");
// table.append(tbody);
// const rowElements = signals.createSignal(
// /** @type {HTMLTableRowElement[]} */ ([]),
// );
// /**
// * @param {Object} args
// * @param {HTMLSelectElement} args.select
// * @param {Unit} [args.unit]
// * @param {(event: MouseEvent) => void} [args.onLeft]
// * @param {(event: MouseEvent) => void} [args.onRight]
// * @param {(event: MouseEvent) => void} [args.onRemove]
// */
// function addThCol({ select, onLeft, onRight, onRemove, unit: _unit }) {
// const th = window.document.createElement("th");
// th.scope = "col";
// trHead.append(th);
// const div = window.document.createElement("div");
// div.append(select);
// // const top = window.document.createElement("div");
// // div.append(top);
// // top.append(select);
// // top.append(
// // createAnchorElement({
// // href: "",
// // blank: true,
// // }),
// // );
// const bottom = window.document.createElement("div");
// const unit = window.document.createElement("span");
// if (_unit) {
// unit.innerHTML = _unit;
// }
// const moveLeft = createButtonElement({
// inside: "←",
// title: "Move column to the left",
// onClick: onLeft || (() => {}),
// });
// const moveRight = createButtonElement({
// inside: "→",
// title: "Move column to the right",
// onClick: onRight || (() => {}),
// });
// const remove = createButtonElement({
// inside: "×",
// title: "Remove column",
// onClick: onRemove || (() => {}),
// });
// bottom.append(unit);
// bottom.append(moveLeft);
// bottom.append(moveRight);
// bottom.append(remove);
// div.append(bottom);
// th.append(div);
// return {
// element: th,
// /**
// * @param {Unit} _unit
// */
// setUnit(_unit) {
// unit.innerHTML = _unit;
// },
// };
// }
// addThCol({
// ...createSelect({
// list: serializedIndexes,
// signal: serializedIndex,
// }),
// unit: "index",
// });
// let from = 0;
// let to = 0;
// resources
// .getOrCreate(index, serializedIndex())
// .fetch()
// .then((vec) => {
// if (!vec) return;
// from = /** @type {number} */ (vec[0]);
// to = /** @type {number} */ (vec.at(-1)) + 1;
// const trs = /** @type {HTMLTableRowElement[]} */ ([]);
// for (let i = vec.length - 1; i >= 0; i--) {
// const value = vec[i];
// const tr = window.document.createElement("tr");
// trs.push(tr);
// tbody.append(tr);
// const th = window.document.createElement("th");
// th.innerHTML = serializeValue({
// value,
// unit: "index",
// });
// th.scope = "row";
// tr.append(th);
// }
// rowElements.set(() => trs);
// });
// const owner = signals.getOwner();
// /**
// * @param {Metric} metric
// * @param {number} [_colIndex]
// */
// function addCol(metric, _colIndex = columns().length) {
// signals.runWithOwner(owner, () => {
// /** @type {VoidFunction | undefined} */
// let dispose;
// signals.createRoot((_dispose) => {
// dispose = _dispose;
// const metricOption = signals.createSignal({
// name: metric,
// value: metric,
// });
// const { select } = createSelect({
// list: possibleMetrics.map((metric) => ({
// name: metric,
// value: metric,
// })),
// signal: metricOption,
// });
// signals.createEffect(metricOption, (metricOption) => {
// select.style.width = `${21 + 7.25 * metricOption.name.length}px`;
// });
// if (_colIndex === columns().length) {
// columns.set((l) => {
// l.push(metric);
// return l;
// });
// }
// const colIndex = signals.createSignal(_colIndex);
// /**
// * @param {boolean} right
// * @returns {(event: MouseEvent) => void}
// */
// function createMoveColumnFunction(right) {
// return () => {
// const oldColIndex = colIndex();
// const newColIndex = oldColIndex + (right ? 1 : -1);
// const currentTh = /** @type {HTMLTableCellElement} */ (
// trHead.childNodes[oldColIndex + 1]
// );
// const oterTh = /** @type {HTMLTableCellElement} */ (
// trHead.childNodes[newColIndex + 1]
// );
// if (right) {
// oterTh.after(currentTh);
// } else {
// oterTh.before(currentTh);
// }
// columns.set((l) => {
// [l[oldColIndex], l[newColIndex]] = [
// l[newColIndex],
// l[oldColIndex],
// ];
// return l;
// });
// const rows = rowElements();
// for (let i = 0; i < rows.length; i++) {
// const element = rows[i].childNodes[oldColIndex + 1];
// const sibling = rows[i].childNodes[newColIndex + 1];
// const temp = element.textContent;
// element.textContent = sibling.textContent;
// sibling.textContent = temp;
// }
// };
// }
// const th = addThCol({
// select,
// unit: serdeUnit.deserialize(metric),
// onLeft: createMoveColumnFunction(false),
// onRight: createMoveColumnFunction(true),
// onRemove: () => {
// const ci = colIndex();
// trHead.childNodes[ci + 1].remove();
// columns.set((l) => {
// l.splice(ci, 1);
// return l;
// });
// const rows = rowElements();
// for (let i = 0; i < rows.length; i++) {
// rows[i].childNodes[ci + 1].remove();
// }
// dispose?.();
// },
// });
// signals.createEffect(columns, () => {
// colIndex.set(Array.from(trHead.children).indexOf(th.element) - 1);
// });
// console.log(colIndex());
// signals.createEffect(rowElements, (rowElements) => {
// if (!rowElements.length) return;
// for (let i = 0; i < rowElements.length; i++) {
// const td = window.document.createElement("td");
// rowElements[i].append(td);
// }
// signals.createEffect(
// () => metricOption().name,
// (metric, prevMetric) => {
// const unit = serdeUnit.deserialize(metric);
// th.setUnit(unit);
// const vec = resources.getOrCreate(index, metric);
// vec.fetch({ from, to });
// const fetchedKey = resources.genFetchedKey({ from, to });
// columns.set((l) => {
// const i = l.indexOf(prevMetric ?? metric);
// if (i === -1) {
// l.push(metric);
// } else {
// l[i] = metric;
// }
// return l;
// });
// signals.createEffect(
// () => vec.fetched().get(fetchedKey)?.vec(),
// (vec) => {
// if (!vec?.length) return;
// const thIndex = colIndex() + 1;
// for (let i = 0; i < rowElements.length; i++) {
// const iRev = vec.length - 1 - i;
// const value = vec[iRev];
// // @ts-ignore
// rowElements[i].childNodes[thIndex].innerHTML =
// serializeValue({
// value,
// unit,
// });
// }
// },
// );
// return () => metric;
// },
// );
// });
// });
// signals.onCleanup(() => {
// dispose?.();
// });
// });
// }
// columns().forEach((metric, colIndex) => addCol(metric, colIndex));
// obj.addRandomCol = function () {
// addCol(randomFromArray(possibleMetrics));
// };
// return () => index;
// });
// return obj;
// }
/**
* @param {MetricToIndexes} metricToIndexes
*/
function createIndexToMetrics(metricToIndexes) {
// const indexToMetrics = Object.entries(metricToIndexes).reduce(
// (arr, [_id, indexes]) => {
// const id = /** @type {Metric} */ (_id);
// indexes.forEach((i) => {
// arr[i] ??= [];
// arr[i].push(id);
// });
// return arr;
// },
// /** @type {Metric[][]} */ (Array.from({ length: 24 })),
// );
// indexToMetrics.forEach((arr) => {
// arr.sort();
// });
// return indexToMetrics;
}
/**
* @param {Object} args
* @param {number | string | Object | Array<any>} args.value
* @param {Unit} args.unit
*/
function serializeValue({ value, unit }) {
const t = typeof value;
if (value === null) {
return "null";
} else if (typeof value === "string") {
return value;
} else if (t !== "number") {
return JSON.stringify(value).replaceAll('"', "").slice(1, -1);
} else if (value !== 18446744073709552000) {
if (unit === "usd" || unit === "difficulty" || unit === "sat/vb") {
return value.toLocaleString("en-us", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
} else if (unit === "btc") {
return value.toLocaleString("en-us", {
minimumFractionDigits: 8,
maximumFractionDigits: 8,
});
} else {
return value.toLocaleString("en-us");
}
} else {
return "";
}
}

View File

@@ -0,0 +1,130 @@
/**
* @template T
* @typedef {Object} Resource
* @property {Signal<T | null>} data
* @property {Signal<boolean>} loading
* @property {Signal<Error | null>} error
* @property {(...args: any[]) => Promise<T | null>} fetch
*/
/**
* @template T
* @typedef {Object} RangeState
* @property {Signal<MetricData<T> | null>} response
* @property {Signal<boolean>} loading
*/
/** @typedef {RangeState<unknown>} AnyRangeState */
/**
* @template T
* @typedef {Object} MetricResource
* @property {string} path
* @property {(from?: number, to?: number) => RangeState<T>} range
* @property {(from?: number, to?: number) => Promise<MetricData<T> | null>} fetch
*/
/** @typedef {MetricResource<unknown>} AnyMetricResource */
/**
* @typedef {{ createResource: typeof createResource, useMetricEndpoint: typeof useMetricEndpoint }} Resources
*/
import signals from "./signals.js";
/**
* Create a generic reactive resource wrapper for any async fetcher
* @template T
* @template {any[]} Args
* @param {(...args: Args) => Promise<T>} fetcher
* @returns {Resource<T>}
*/
function createResource(fetcher) {
const owner = signals.getOwner();
return signals.runWithOwner(owner, () => {
const data = signals.createSignal(/** @type {T | null} */ (null));
const loading = signals.createSignal(false);
const error = signals.createSignal(/** @type {Error | null} */ (null));
return {
data,
loading,
error,
/**
* @param {Args} args
*/
async fetch(...args) {
loading.set(true);
error.set(null);
try {
const result = await fetcher(...args);
data.set(() => result);
return result;
} catch (e) {
error.set(e instanceof Error ? e : new Error(String(e)));
return null;
} finally {
loading.set(false);
}
},
};
});
}
/**
* Create a reactive resource wrapper for a MetricEndpoint with multi-range support
* @template T
* @param {MetricEndpoint<T>} endpoint
* @returns {MetricResource<T>}
*/
function useMetricEndpoint(endpoint) {
const owner = signals.getOwner();
return signals.runWithOwner(owner, () => {
/** @type {Map<string, RangeState<T>>} */
const ranges = new Map();
/**
* Get or create range state
* @param {number} [from=-10000]
* @param {number} [to]
* @returns {RangeState<T>}
*/
function range(from = -10000, to) {
const key = `${from}-${to ?? ""}`;
const existing = ranges.get(key);
if (existing) return existing;
/** @type {RangeState<T>} */
const state = {
response: signals.createSignal(
/** @type {MetricData<T> | null} */ (null),
),
loading: signals.createSignal(false),
};
ranges.set(key, state);
return state;
}
return {
path: endpoint.path,
range,
/**
* Fetch data for a range
* @param {number} [start=-10000]
* @param {number} [end]
*/
async fetch(start = -10000, end) {
const r = range(start, end);
r.loading.set(true);
try {
const result = await endpoint
.slice(start, end)
.fetch(r.response.set);
return result;
} finally {
r.loading.set(false);
}
},
};
});
}
export const resources = { createResource, useMetricEndpoint };

218
website/scripts/signals.js Normal file
View File

@@ -0,0 +1,218 @@
/**
* @import { SignalOptions } from "./modules/solidjs-signals/0.6.3/dist/types/core/core.js"
* @import { getOwner as GetOwner, onCleanup as OnCleanup } from "./modules/solidjs-signals/0.6.3/dist/types/core/owner.js"
* @import { createSignal as CreateSignal, createEffect as CreateEffect, createMemo as CreateMemo, createRoot as CreateRoot, runWithOwner as RunWithOwner, Setter } from "./modules/solidjs-signals/0.6.3/dist/types/signals.js";
*/
/**
* @template T
* @typedef {() => T} Accessor
*/
/**
* @template T
* @typedef {Accessor<T> & { set: Setter<T>; reset: VoidFunction }} Signal
*/
import {
createSignal,
createEffect,
getOwner,
createMemo,
createRoot,
runWithOwner,
onCleanup,
} from "./modules/solidjs-signals/0.6.3/dist/prod.js";
// let effectCount = 0;
const signals = {
createSolidSignal: /** @type {typeof CreateSignal} */ (createSignal),
createSolidEffect: /** @type {typeof CreateEffect} */ (createEffect),
createEffect: /** @type {typeof CreateEffect} */ (
// @ts-ignore
(compute, effect) => {
let dispose = /** @type {VoidFunction | null} */ (null);
if (getOwner() === null) {
throw Error("No owner");
}
function cleanup() {
if (dispose) {
dispose();
dispose = null;
// console.log("effectCount = ", --effectCount);
}
}
// @ts-ignore
createEffect(compute, (v, oldV) => {
// console.log("effectCount = ", ++effectCount);
cleanup();
signals.createRoot((_dispose) => {
dispose = _dispose;
return effect(v, oldV);
});
signals.onCleanup(cleanup);
});
signals.onCleanup(cleanup);
}
),
createMemo: /** @type {typeof CreateMemo} */ (createMemo),
createRoot: /** @type {typeof CreateRoot} */ (createRoot),
getOwner: /** @type {typeof GetOwner} */ (getOwner),
runWithOwner: /** @type {typeof RunWithOwner} */ (runWithOwner),
onCleanup: /** @type {typeof OnCleanup} */ (onCleanup),
/**
* @template T
* @param {T} initialValue
* @param {SignalOptions<T> & {save?: {keyPrefix: string | Accessor<string>; key: string; serialize: (v: T) => string; deserialize: (v: string) => T; serializeParam?: boolean; saveDefaultValue?: boolean}}} [options]
* @returns {Signal<T>}
*/
createSignal(initialValue, options) {
const [get, set] = this.createSolidSignal(
/** @type {any} */ (initialValue),
options,
);
// @ts-ignore
get.set = set;
// @ts-ignore
get.reset = () => set(initialValue);
if (options?.save) {
const save = options.save;
const paramKey = save.key;
const storageKey = this.createMemo(
() =>
`${
typeof save.keyPrefix === "string"
? save.keyPrefix
: save.keyPrefix()
}-${paramKey}`,
);
// /** @type { ((this: Window, ev: PopStateEvent) => any) | undefined} */
// let popstateCallback;
let serialized = /** @type {string | null} */ (null);
if (options.save.serializeParam !== false) {
serialized = new URLSearchParams(window.location.search).get(paramKey);
// popstateCallback =
// /** @type {(this: Window, ev: PopStateEvent) => any} */ (
// (_) => {
// serialized = new URLSearchParams(window.location.search).get(
// paramKey,
// );
// set(() =>
// serialized ? save.deserialize(serialized) : initialValue,
// );
// }
// );
// if (!popstateCallback) throw "Unreachable";
// window.addEventListener("popstate", popstateCallback);
// signals.onCleanup(() => {
// if (popstateCallback)
// window.removeEventListener("popstate", popstateCallback);
// });
}
if (serialized === null) {
try {
serialized = localStorage.getItem(storageKey());
} catch (_) {}
}
if (serialized) {
set(() => (serialized ? save.deserialize(serialized) : initialValue));
}
let firstRun1 = true;
this.createEffect(storageKey, (storageKey) => {
if (!firstRun1) {
try {
serialized = localStorage.getItem(storageKey);
set(() =>
serialized ? save.deserialize(serialized) : initialValue,
);
} catch (_) {}
}
firstRun1 = false;
});
let firstRun2 = true;
this.createEffect(get, (value) => {
if (!save) return;
if (!firstRun2) {
try {
if (
value !== undefined &&
value !== null &&
(initialValue === undefined ||
initialValue === null ||
save.saveDefaultValue ||
save.serialize(value) !== save.serialize(initialValue))
) {
localStorage.setItem(storageKey(), save.serialize(value));
} else {
localStorage.removeItem(storageKey());
}
} catch (_) {}
}
if (
value !== undefined &&
value !== null &&
(initialValue === undefined ||
initialValue === null ||
save.saveDefaultValue ||
save.serialize(value) !== save.serialize(initialValue))
) {
writeParam(paramKey, save.serialize(value));
} else {
removeParam(paramKey);
}
firstRun2 = false;
});
}
// @ts-ignore
return get;
},
};
/** @typedef {typeof signals} Signals */
/**
* @param {string} key
* @param {string | undefined} value
*/
function writeParam(key, value) {
const urlParams = new URLSearchParams(window.location.search);
if (value !== undefined) {
urlParams.set(key, String(value));
} else {
urlParams.delete(key);
}
try {
window.history.replaceState(
null,
"",
`${window.location.pathname}?${urlParams.toString()}`,
);
} catch (_) {}
}
/**
* @param {string} key
*/
function removeParam(key) {
writeParam(key, undefined);
}
export default signals;

View 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)];
}

View 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
*/

View 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));
}

View 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;
}

View 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");

View 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;

View 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()
);
}

View 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
*/

View 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);
}

View 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);
}
};
}

View 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 */

View 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
View 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 */