mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-04-26 07:39:59 -07:00
global: snapshot
This commit is contained in:
@@ -52,7 +52,8 @@ import { Unit } from "../utils/units.js";
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Series<any>} AnySeries
|
||||
* @typedef {SingleValueData | CandlestickData | LineData | BaselineData | HistogramData | WhitespaceData} AnyChartData
|
||||
* @typedef {Series<AnyChartData>} AnySeries
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -69,7 +70,7 @@ import { Unit } from "../utils/units.js";
|
||||
* @property {function(number): void} removeFrom
|
||||
*/
|
||||
|
||||
const lineWidth = /** @type {any} */ (1.5);
|
||||
const lineWidth = /** @type {1} */ (/** @type {unknown} */ (1.5));
|
||||
|
||||
const MAX_SIZE = 10_000;
|
||||
|
||||
@@ -140,7 +141,7 @@ export function createChart({ parent, brk, fitContent }) {
|
||||
if (cached) {
|
||||
this.data = cached;
|
||||
}
|
||||
endpoint.slice(-MAX_SIZE).fetch((/** @type {any} */ result) => {
|
||||
endpoint.slice(-MAX_SIZE).fetch((/** @type {AnySeriesData} */ result) => {
|
||||
if (currentGen !== generation) return;
|
||||
cache.set(endpoint.path, result);
|
||||
this.data = result;
|
||||
@@ -150,7 +151,7 @@ export function createChart({ parent, brk, fitContent }) {
|
||||
};
|
||||
|
||||
// Memory cache for instant index switching
|
||||
/** @type {Map<string, SeriesData<any>>} */
|
||||
/** @type {Map<string, AnySeriesData>} */
|
||||
const cache = new Map();
|
||||
|
||||
// Range state: localStorage stores all ranges per-index, URL stores current range only
|
||||
@@ -432,9 +433,9 @@ export function createChart({ parent, brk, fitContent }) {
|
||||
* @param {boolean} [args.defaultActive]
|
||||
* @param {(order: number) => void} args.setOrder
|
||||
* @param {(active: boolean, highlighted: boolean) => void} args.applyOptions
|
||||
* @param {() => readonly any[]} args.getData
|
||||
* @param {(data: any[]) => void} args.setData
|
||||
* @param {(data: any) => void} args.update
|
||||
* @param {() => readonly AnyChartData[]} args.getData
|
||||
* @param {(data: AnyChartData[]) => void} args.setData
|
||||
* @param {(data: AnyChartData) => void} args.update
|
||||
* @param {() => void} args.onRemove
|
||||
*/
|
||||
create({
|
||||
@@ -791,8 +792,7 @@ export function createChart({ parent, brk, fitContent }) {
|
||||
const upColor = customColors?.[0] ?? colors.bi.p1[0];
|
||||
const downColor = customColors?.[1] ?? colors.bi.p1[1];
|
||||
|
||||
/** @type {CandlestickISeries} */
|
||||
const candlestickISeries = /** @type {any} */ (
|
||||
const candlestickISeries = /** @type {CandlestickISeries} */ (
|
||||
ichart.addSeries(
|
||||
/** @type {SeriesDefinition<'Candlestick'>} */ (CandlestickSeries),
|
||||
{ visible: false, borderVisible: false, ...options },
|
||||
@@ -800,8 +800,7 @@ export function createChart({ parent, brk, fitContent }) {
|
||||
)
|
||||
);
|
||||
|
||||
/** @type {LineISeries} */
|
||||
const lineISeries = /** @type {any} */ (
|
||||
const lineISeries = /** @type {LineISeries} */ (
|
||||
ichart.addSeries(
|
||||
/** @type {SeriesDefinition<'Line'>} */ (LineSeries),
|
||||
{ visible: false, lineWidth, priceLineVisible: true },
|
||||
@@ -851,9 +850,10 @@ export function createChart({ parent, brk, fitContent }) {
|
||||
});
|
||||
},
|
||||
setData: (data) => {
|
||||
candlestickISeries.setData(data);
|
||||
const cdata = /** @type {CandlestickData[]} */ (data);
|
||||
candlestickISeries.setData(cdata);
|
||||
lineISeries.setData(
|
||||
data.map((d) => ({ time: d.time, value: d.close })),
|
||||
cdata.map((d) => ({ time: d.time, value: d.close })),
|
||||
);
|
||||
requestAnimationFrame(() => {
|
||||
if (generation !== series.generation) return;
|
||||
@@ -862,8 +862,9 @@ export function createChart({ parent, brk, fitContent }) {
|
||||
});
|
||||
},
|
||||
update: (data) => {
|
||||
candlestickISeries.update(data);
|
||||
lineISeries.update({ time: data.time, value: data.close });
|
||||
const cd = /** @type {CandlestickData} */ (data);
|
||||
candlestickISeries.update(cd);
|
||||
lineISeries.update({ time: cd.time, value: cd.close });
|
||||
},
|
||||
getData: () => candlestickISeries.data(),
|
||||
onRemove: () => {
|
||||
@@ -903,8 +904,7 @@ export function createChart({ parent, brk, fitContent }) {
|
||||
const positiveColor = isDualColor ? color[0] : color;
|
||||
const negativeColor = isDualColor ? color[1] : color;
|
||||
|
||||
/** @type {HistogramISeries} */
|
||||
const iseries = /** @type {any} */ (
|
||||
const iseries = /** @type {HistogramISeries} */ (
|
||||
ichart.addSeries(
|
||||
/** @type {SeriesDefinition<'Histogram'>} */ (HistogramSeries),
|
||||
{ priceLineVisible: false, ...options },
|
||||
@@ -972,8 +972,7 @@ export function createChart({ parent, brk, fitContent }) {
|
||||
defaultActive,
|
||||
options,
|
||||
}) {
|
||||
/** @type {LineISeries} */
|
||||
const iseries = /** @type {any} */ (
|
||||
const iseries = /** @type {LineISeries} */ (
|
||||
ichart.addSeries(
|
||||
/** @type {SeriesDefinition<'Line'>} */ (LineSeries),
|
||||
{ lineWidth, priceLineVisible: false, ...options },
|
||||
@@ -1029,8 +1028,7 @@ export function createChart({ parent, brk, fitContent }) {
|
||||
defaultActive,
|
||||
options,
|
||||
}) {
|
||||
/** @type {LineISeries} */
|
||||
const iseries = /** @type {any} */ (
|
||||
const iseries = /** @type {LineISeries} */ (
|
||||
ichart.addSeries(
|
||||
/** @type {SeriesDefinition<'Line'>} */ (LineSeries),
|
||||
{
|
||||
@@ -1110,8 +1108,7 @@ export function createChart({ parent, brk, fitContent }) {
|
||||
bottomColor = colors.bi.p1[1],
|
||||
options,
|
||||
}) {
|
||||
/** @type {BaselineISeries} */
|
||||
const iseries = /** @type {any} */ (
|
||||
const iseries = /** @type {BaselineISeries} */ (
|
||||
ichart.addSeries(
|
||||
/** @type {SeriesDefinition<'Baseline'>} */ (BaselineSeries),
|
||||
{
|
||||
@@ -1182,8 +1179,7 @@ export function createChart({ parent, brk, fitContent }) {
|
||||
bottomColor = colors.bi.p1[1],
|
||||
options,
|
||||
}) {
|
||||
/** @type {BaselineISeries} */
|
||||
const iseries = /** @type {any} */ (
|
||||
const iseries = /** @type {BaselineISeries} */ (
|
||||
ichart.addSeries(
|
||||
/** @type {SeriesDefinition<'Baseline'>} */ (BaselineSeries),
|
||||
{
|
||||
|
||||
@@ -115,7 +115,7 @@ function volumeFolder(activity, color, title) {
|
||||
|
||||
/**
|
||||
* @param {{ transferVolume: TransferVolumePattern }} activity
|
||||
* @param {CountPattern<any>} adjustedTransferVolume
|
||||
* @param {CountPattern<number>} adjustedTransferVolume
|
||||
* @param {Color} color
|
||||
* @param {(name: string) => string} title
|
||||
* @returns {PartialOptionsGroup}
|
||||
@@ -171,7 +171,7 @@ function singleRollingSoprTree(ratio, title, prefix = "") {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CountPattern<any>} valueDestroyed
|
||||
* @param {CountPattern<number>} valueDestroyed
|
||||
* @param {(name: string) => string} title
|
||||
* @returns {PartialOptionsTree}
|
||||
*/
|
||||
@@ -180,7 +180,7 @@ function valueDestroyedTree(valueDestroyed, title) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CountPattern<any>} valueDestroyed
|
||||
* @param {CountPattern<number>} valueDestroyed
|
||||
* @param {(name: string) => string} title
|
||||
* @returns {PartialOptionsGroup}
|
||||
*/
|
||||
@@ -189,8 +189,8 @@ function valueDestroyedFolder(valueDestroyed, title) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CountPattern<any>} valueDestroyed
|
||||
* @param {CountPattern<any>} adjusted
|
||||
* @param {CountPattern<number>} valueDestroyed
|
||||
* @param {CountPattern<number>} adjusted
|
||||
* @param {(name: string) => string} title
|
||||
* @returns {PartialOptionsGroup}
|
||||
*/
|
||||
@@ -471,7 +471,7 @@ function groupedVolumeFolder(list, all, title, getTransferVolume) {
|
||||
* @param {A} all
|
||||
* @param {(name: string) => string} title
|
||||
* @param {(c: T | A) => { sum: Record<string, AnyValuePattern>, cumulative: AnyValuePattern, inProfit: { sum: Record<string, AnyValuePattern>, cumulative: AnyValuePattern }, inLoss: { sum: Record<string, AnyValuePattern>, cumulative: AnyValuePattern } }} getTransferVolume
|
||||
* @param {(c: T | A) => CountPattern<any>} getAdjustedTransferVolume
|
||||
* @param {(c: T | A) => CountPattern<number>} getAdjustedTransferVolume
|
||||
* @returns {PartialOptionsGroup}
|
||||
*/
|
||||
function groupedVolumeFolderWithAdjusted(list, all, title, getTransferVolume, getAdjustedTransferVolume) {
|
||||
@@ -528,7 +528,7 @@ function groupedSoprCharts(list, all, getRatio, title, prefix = "") {
|
||||
* @param {readonly T[]} list
|
||||
* @param {A} all
|
||||
* @param {(name: string) => string} title
|
||||
* @param {(c: T | A) => CountPattern<any>} getValueDestroyed
|
||||
* @param {(c: T | A) => CountPattern<number>} getValueDestroyed
|
||||
* @returns {PartialOptionsTree}
|
||||
*/
|
||||
function groupedValueDestroyedTree(list, all, title, getValueDestroyed) {
|
||||
@@ -546,7 +546,7 @@ function groupedValueDestroyedTree(list, all, title, getValueDestroyed) {
|
||||
* @param {readonly T[]} list
|
||||
* @param {A} all
|
||||
* @param {(name: string) => string} title
|
||||
* @param {(c: T | A) => CountPattern<any>} getValueDestroyed
|
||||
* @param {(c: T | A) => CountPattern<number>} getValueDestroyed
|
||||
* @returns {PartialOptionsGroup}
|
||||
*/
|
||||
function groupedValueDestroyedFolder(list, all, title, getValueDestroyed) {
|
||||
@@ -559,8 +559,8 @@ function groupedValueDestroyedFolder(list, all, title, getValueDestroyed) {
|
||||
* @param {readonly T[]} list
|
||||
* @param {A} all
|
||||
* @param {(name: string) => string} title
|
||||
* @param {(c: T | A) => CountPattern<any>} getValueDestroyed
|
||||
* @param {(c: T | A) => CountPattern<any>} getAdjustedValueDestroyed
|
||||
* @param {(c: T | A) => CountPattern<number>} getValueDestroyed
|
||||
* @param {(c: T | A) => CountPattern<number>} getAdjustedValueDestroyed
|
||||
* @returns {PartialOptionsGroup}
|
||||
*/
|
||||
function groupedValueDestroyedFolderWithAdjusted(list, all, title, getValueDestroyed, getAdjustedValueDestroyed) {
|
||||
|
||||
@@ -100,6 +100,7 @@ function singleDeltaItems(delta, unit, title, name) {
|
||||
title,
|
||||
metric: `${name} Change`,
|
||||
unit,
|
||||
legend: "Change",
|
||||
}),
|
||||
name: "Change",
|
||||
},
|
||||
|
||||
@@ -525,6 +525,7 @@ function singleBucketFolder({ name, color, pattern }, parentName) {
|
||||
title,
|
||||
metric: "Supply Change",
|
||||
unit: Unit.sats,
|
||||
legend: "Change",
|
||||
}),
|
||||
name: "Change",
|
||||
},
|
||||
@@ -610,63 +611,32 @@ function groupedBucketCharts(list, groupTitle) {
|
||||
},
|
||||
{
|
||||
name: "Change",
|
||||
tree: [
|
||||
{
|
||||
name: "Compare",
|
||||
title: title("Supply Change"),
|
||||
bottom: ROLLING_WINDOWS.flatMap((w) =>
|
||||
list.map(({ name, color, pattern }) =>
|
||||
baseline({
|
||||
series: pattern.supply.all.delta.absolute[w.key],
|
||||
name: `${name} ${w.name}`,
|
||||
color,
|
||||
unit: Unit.sats,
|
||||
}),
|
||||
),
|
||||
),
|
||||
},
|
||||
...ROLLING_WINDOWS.map((w) => ({
|
||||
name: w.name,
|
||||
title: title(`${w.title} Supply Change`),
|
||||
bottom: list.map(({ name, color, pattern }) =>
|
||||
baseline({
|
||||
series: pattern.supply.all.delta.absolute[w.key],
|
||||
name,
|
||||
color,
|
||||
unit: Unit.sats,
|
||||
}),
|
||||
),
|
||||
})),
|
||||
],
|
||||
tree: ROLLING_WINDOWS.map((w) => ({
|
||||
name: w.name,
|
||||
title: title(`${w.title} Supply Change`),
|
||||
bottom: list.map(({ name, color, pattern }) =>
|
||||
baseline({
|
||||
series: pattern.supply.all.delta.absolute[w.key],
|
||||
name,
|
||||
color,
|
||||
unit: Unit.sats,
|
||||
}),
|
||||
),
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "Growth Rate",
|
||||
tree: [
|
||||
{
|
||||
name: "Compare",
|
||||
title: title("Supply Growth Rate"),
|
||||
bottom: ROLLING_WINDOWS.flatMap((w) =>
|
||||
list.flatMap(({ name, color, pattern }) =>
|
||||
percentRatio({
|
||||
pattern: pattern.supply.all.delta.rate[w.key],
|
||||
name: `${name} ${w.name}`,
|
||||
color,
|
||||
}),
|
||||
),
|
||||
),
|
||||
},
|
||||
...ROLLING_WINDOWS.map((w) => ({
|
||||
name: w.name,
|
||||
title: title(`${w.title} Supply Growth Rate`),
|
||||
bottom: list.flatMap(({ name, color, pattern }) =>
|
||||
percentRatio({
|
||||
pattern: pattern.supply.all.delta.rate[w.key],
|
||||
name,
|
||||
color,
|
||||
}),
|
||||
),
|
||||
})),
|
||||
],
|
||||
tree: ROLLING_WINDOWS.map((w) => ({
|
||||
name: w.name,
|
||||
title: title(`${w.title} Supply Growth Rate`),
|
||||
bottom: list.flatMap(({ name, color, pattern }) =>
|
||||
percentRatio({
|
||||
pattern: pattern.supply.all.delta.rate[w.key],
|
||||
name,
|
||||
color,
|
||||
}),
|
||||
),
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -31,7 +31,7 @@ export function createPricesSectionFull({ cohort, title }) {
|
||||
tree: [
|
||||
{
|
||||
name: "Compare",
|
||||
title: title("Prices"),
|
||||
title: title("Realized Prices"),
|
||||
top: [
|
||||
price({ series: tree.realized.price, name: "Realized", color: colors.realized }),
|
||||
price({ series: tree.realized.investor.price, name: "Investor", color: colors.investor }),
|
||||
|
||||
@@ -399,6 +399,7 @@ function realizedNetFolder({ netPnl, title, extraChange = [] }) {
|
||||
title,
|
||||
metric: "Net Realized P&L Change",
|
||||
unit: Unit.usd,
|
||||
legend: "Change",
|
||||
}),
|
||||
name: "Change",
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@ import { ratioBottomSeries, mapCohortsWithAll, flatMapCohortsWithAll } from "../
|
||||
*/
|
||||
function singleDeltaItems(tree, title) {
|
||||
return [
|
||||
{ ...sumsTreeBaseline({ windows: mapWindows(tree.realized.cap.delta.absolute, (c) => c.usd), title, metric: "Realized Cap Change", unit: Unit.usd }), name: "Change" },
|
||||
{ ...sumsTreeBaseline({ windows: mapWindows(tree.realized.cap.delta.absolute, (c) => c.usd), title, metric: "Realized Cap Change", unit: Unit.usd, legend: "Change" }), name: "Change" },
|
||||
{ ...rollingPercentRatioTree({ windows: tree.realized.cap.delta.rate, title, metric: "Realized Cap Growth Rate" }), name: "Growth Rate" },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ function createCompareFolder(context, items) {
|
||||
},
|
||||
{
|
||||
name: "Accumulated",
|
||||
title: `Accumulated Value: ${context}`,
|
||||
title: `Accumulated Value ($100/day): ${context}`,
|
||||
top: topPane,
|
||||
bottom: items.flatMap(({ name, color, stack }) =>
|
||||
satsBtcUsd({ pattern: stack, name, color }),
|
||||
@@ -188,7 +188,7 @@ function createLongCompareFolder(context, items) {
|
||||
},
|
||||
{
|
||||
name: "Accumulated",
|
||||
title: `Accumulated Value: ${context}`,
|
||||
title: `Accumulated Value ($100/day): ${context}`,
|
||||
top: topPane,
|
||||
bottom: items.flatMap(({ name, color, stack }) =>
|
||||
satsBtcUsd({ pattern: stack, name, color }),
|
||||
@@ -218,7 +218,7 @@ function createSingleEntryTree(item, returnsBottom) {
|
||||
},
|
||||
{
|
||||
name: "Accumulated",
|
||||
title: `Accumulated Value: ${titlePrefix}`,
|
||||
title: `Accumulated Value ($100/day): ${titlePrefix}`,
|
||||
top,
|
||||
bottom: satsBtcUsd({ pattern: stack, name: "Value" }),
|
||||
},
|
||||
@@ -259,7 +259,7 @@ function createLongSingleEntry(item) {
|
||||
},
|
||||
{
|
||||
name: "Accumulated",
|
||||
title: `Accumulated Value: ${titlePrefix}`,
|
||||
title: `Accumulated Value ($100/day): ${titlePrefix}`,
|
||||
top,
|
||||
bottom: satsBtcUsd({ pattern: stack, name: "Value" }),
|
||||
},
|
||||
|
||||
@@ -122,7 +122,7 @@ function returnsSubSection(name, periods) {
|
||||
tree: [
|
||||
{
|
||||
name: "Compare",
|
||||
title: `${name} Returns`,
|
||||
title: `${name} Price Returns`,
|
||||
bottom: periods.flatMap((p) =>
|
||||
percentRatioBaseline({
|
||||
pattern: p.returns,
|
||||
@@ -133,8 +133,8 @@ function returnsSubSection(name, periods) {
|
||||
},
|
||||
...periods.map((p) => ({
|
||||
name: periodIdToName(p.id, true),
|
||||
title: `${periodIdToName(p.id, true)} Returns`,
|
||||
bottom: percentRatioBaseline({ pattern: p.returns, name: "Total" }),
|
||||
title: `${periodIdToName(p.id, true)} Price Returns`,
|
||||
bottom: percentRatioBaseline({ pattern: p.returns, name: "Return" }),
|
||||
})),
|
||||
],
|
||||
};
|
||||
@@ -153,7 +153,7 @@ function returnsSubSectionWithCagr(name, periods) {
|
||||
tree: [
|
||||
{
|
||||
name: "Compare",
|
||||
title: `${name} Total Returns`,
|
||||
title: `${name} Total Price Returns`,
|
||||
bottom: periods.flatMap((p) =>
|
||||
percentRatioBaseline({
|
||||
pattern: p.returns,
|
||||
@@ -164,8 +164,8 @@ function returnsSubSectionWithCagr(name, periods) {
|
||||
},
|
||||
...periods.map((p) => ({
|
||||
name: periodIdToName(p.id, true),
|
||||
title: `${periodIdToName(p.id, true)} Total Returns`,
|
||||
bottom: percentRatioBaseline({ pattern: p.returns, name: "Total" }),
|
||||
title: `${periodIdToName(p.id, true)} Total Price Returns`,
|
||||
bottom: percentRatioBaseline({ pattern: p.returns, name: "Return" }),
|
||||
})),
|
||||
],
|
||||
},
|
||||
@@ -174,7 +174,7 @@ function returnsSubSectionWithCagr(name, periods) {
|
||||
tree: [
|
||||
{
|
||||
name: "Compare",
|
||||
title: `${name} CAGR`,
|
||||
title: `${name} Price CAGR`,
|
||||
bottom: periods.flatMap((p) =>
|
||||
percentRatioBaseline({
|
||||
pattern: p.cagr,
|
||||
@@ -185,7 +185,7 @@ function returnsSubSectionWithCagr(name, periods) {
|
||||
},
|
||||
...periods.map((p) => ({
|
||||
name: periodIdToName(p.id, true),
|
||||
title: `${periodIdToName(p.id, true)} CAGR`,
|
||||
title: `${periodIdToName(p.id, true)} Price CAGR`,
|
||||
bottom: percentRatioBaseline({ pattern: p.cagr, name: "CAGR" }),
|
||||
})),
|
||||
],
|
||||
@@ -469,7 +469,7 @@ export function createMarketSection() {
|
||||
tree: [
|
||||
{
|
||||
name: "Compare",
|
||||
title: "Returns Comparison",
|
||||
title: "Price Returns",
|
||||
bottom: [...shortPeriods, ...longPeriods].flatMap((p) =>
|
||||
percentRatioBaseline({
|
||||
pattern: p.returns,
|
||||
@@ -490,7 +490,7 @@ export function createMarketSection() {
|
||||
tree: [
|
||||
{
|
||||
name: "Compare",
|
||||
title: "Historical Price Comparison",
|
||||
title: "Historical Prices",
|
||||
top: [...shortPeriods, ...longPeriods].map((p) =>
|
||||
price({
|
||||
series: p.lookback,
|
||||
@@ -592,7 +592,7 @@ export function createMarketSection() {
|
||||
bottom: [
|
||||
baseline({
|
||||
series: supply.marketMinusRealizedCapGrowthRate[w.key],
|
||||
name: w.name,
|
||||
name: "Spread",
|
||||
unit: Unit.percentage,
|
||||
}),
|
||||
],
|
||||
@@ -615,7 +615,7 @@ export function createMarketSection() {
|
||||
tree: [
|
||||
{
|
||||
name: "All Periods",
|
||||
title: "SMA vs EMA Comparison",
|
||||
title: "SMA vs EMA",
|
||||
top: smaVsEma.flatMap((p) => [
|
||||
price({
|
||||
series: p.sma,
|
||||
@@ -659,7 +659,7 @@ export function createMarketSection() {
|
||||
tree: [
|
||||
{
|
||||
name: "Compare",
|
||||
title: "RSI Comparison",
|
||||
title: "RSI",
|
||||
bottom: [
|
||||
...ROLLING_WINDOWS_TO_1M.flatMap((w) =>
|
||||
indexRatio({
|
||||
@@ -726,7 +726,7 @@ export function createMarketSection() {
|
||||
tree: [
|
||||
{
|
||||
name: "Compare",
|
||||
title: "MACD Comparison",
|
||||
title: "MACD",
|
||||
bottom: ROLLING_WINDOWS_TO_1M.map((w) =>
|
||||
line({
|
||||
series: technical.macd[w.key].line,
|
||||
@@ -789,7 +789,7 @@ export function createMarketSection() {
|
||||
bottom: [
|
||||
line({
|
||||
series: volatility[w.key],
|
||||
name: w.name,
|
||||
name: "Volatility",
|
||||
color: w.color,
|
||||
unit: Unit.percentage,
|
||||
}),
|
||||
|
||||
@@ -80,7 +80,7 @@ export function createMiningSection() {
|
||||
/**
|
||||
* @param {(metric: string) => string} title
|
||||
* @param {string} metric
|
||||
* @param {{ _24h: any, _1w: any, _1m: any, _1y: any, percent: any, ratio: any }} dominance
|
||||
* @param {DominancePattern} dominance
|
||||
*/
|
||||
const dominanceTree = (title, metric, dominance) => ({
|
||||
name: "Dominance",
|
||||
@@ -98,12 +98,12 @@ export function createMiningSection() {
|
||||
...ROLLING_WINDOWS.map((w) => ({
|
||||
name: w.name,
|
||||
title: title(`${w.title} ${metric}`),
|
||||
bottom: percentRatio({ pattern: dominance[w.key], name: w.name, color: w.color }),
|
||||
bottom: percentRatio({ pattern: dominance[w.key], name: "Dominance", color: w.color }),
|
||||
})),
|
||||
{
|
||||
name: "All Time",
|
||||
title: title(`${metric} All Time`),
|
||||
bottom: percentRatio({ pattern: dominance, name: "All Time", color: colors.time.all }),
|
||||
title: title(`All Time ${metric}`),
|
||||
bottom: percentRatio({ pattern: dominance, name: "Dominance", color: colors.time.all }),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -251,7 +251,7 @@ export function createMiningSection() {
|
||||
}),
|
||||
dotted({
|
||||
series: blocks.difficulty.hashrate,
|
||||
name: "Difficulty",
|
||||
name: "From Difficulty",
|
||||
color: colors.default,
|
||||
unit: Unit.hashRate,
|
||||
}),
|
||||
@@ -395,25 +395,25 @@ export function createMiningSection() {
|
||||
name: "Hash Price",
|
||||
title: "Hash Price",
|
||||
bottom: [
|
||||
line({ series: mining.hashrate.price.ths, name: "TH/s", color: colors.usd, unit: Unit.usdPerThsPerDay }),
|
||||
line({ series: mining.hashrate.price.phs, name: "PH/s", color: colors.usd, unit: Unit.usdPerPhsPerDay }),
|
||||
dotted({ series: mining.hashrate.price.thsMin, name: "TH/s ATL", color: colors.stat.min, unit: Unit.usdPerThsPerDay }),
|
||||
dotted({ series: mining.hashrate.price.phsMin, name: "PH/s ATL", color: colors.stat.min, unit: Unit.usdPerPhsPerDay }),
|
||||
line({ series: mining.hashrate.price.ths, name: "per TH/s", color: colors.usd, unit: Unit.usdPerThsPerDay }),
|
||||
line({ series: mining.hashrate.price.phs, name: "per PH/s", color: colors.usd, unit: Unit.usdPerPhsPerDay }),
|
||||
dotted({ series: mining.hashrate.price.thsMin, name: "per TH/s ATL", color: colors.stat.min, unit: Unit.usdPerThsPerDay }),
|
||||
dotted({ series: mining.hashrate.price.phsMin, name: "per PH/s ATL", color: colors.stat.min, unit: Unit.usdPerPhsPerDay }),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Hash Value",
|
||||
title: "Hash Value",
|
||||
bottom: [
|
||||
line({ series: mining.hashrate.value.ths, name: "TH/s", color: colors.bitcoin, unit: Unit.satsPerThsPerDay }),
|
||||
line({ series: mining.hashrate.value.phs, name: "PH/s", color: colors.bitcoin, unit: Unit.satsPerPhsPerDay }),
|
||||
dotted({ series: mining.hashrate.value.thsMin, name: "TH/s ATL", color: colors.stat.min, unit: Unit.satsPerThsPerDay }),
|
||||
dotted({ series: mining.hashrate.value.phsMin, name: "PH/s ATL", color: colors.stat.min, unit: Unit.satsPerPhsPerDay }),
|
||||
line({ series: mining.hashrate.value.ths, name: "per TH/s", color: colors.bitcoin, unit: Unit.satsPerThsPerDay }),
|
||||
line({ series: mining.hashrate.value.phs, name: "per PH/s", color: colors.bitcoin, unit: Unit.satsPerPhsPerDay }),
|
||||
dotted({ series: mining.hashrate.value.thsMin, name: "per TH/s ATL", color: colors.stat.min, unit: Unit.satsPerThsPerDay }),
|
||||
dotted({ series: mining.hashrate.value.phsMin, name: "per PH/s ATL", color: colors.stat.min, unit: Unit.satsPerPhsPerDay }),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Recovery",
|
||||
title: "Mining Recovery",
|
||||
title: "Hash Price & Value Recovery",
|
||||
bottom: [
|
||||
...percentRatio({ pattern: mining.hashrate.price.rebound, name: "Hash Price", color: colors.usd }),
|
||||
...percentRatio({ pattern: mining.hashrate.value.rebound, name: "Hash Value", color: colors.bitcoin }),
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
multiSeriesTree,
|
||||
percentRatioDots,
|
||||
} from "./series.js";
|
||||
import { satsBtcUsd, satsBtcUsdFrom, satsBtcUsdFullTree } from "./shared.js";
|
||||
import { satsBtcUsd, satsBtcUsdFrom, satsBtcUsdFullTree, formatCohortTitle } from "./shared.js";
|
||||
|
||||
/**
|
||||
* Create Network section
|
||||
@@ -114,15 +114,17 @@ export function createNetworkSection() {
|
||||
|
||||
/**
|
||||
* @param {AddressableType | "all"} key
|
||||
* @param {string} titlePrefix
|
||||
* @param {string} [typeName]
|
||||
*/
|
||||
const createAddressSeriesTree = (key, titlePrefix) => [
|
||||
const createAddressSeriesTree = (key, typeName) => {
|
||||
const title = formatCohortTitle(typeName);
|
||||
return [
|
||||
{
|
||||
name: "Count",
|
||||
tree: [
|
||||
{
|
||||
name: "Compare",
|
||||
title: `${titlePrefix}Address Count`,
|
||||
title: title("Address Count"),
|
||||
bottom: countMetrics.map((m) =>
|
||||
line({
|
||||
series: addrs[m.key][key],
|
||||
@@ -134,7 +136,7 @@ export function createNetworkSection() {
|
||||
},
|
||||
...countMetrics.map((m) => ({
|
||||
name: m.name,
|
||||
title: `${titlePrefix}${m.name} Addresses`,
|
||||
title: title(`${m.name} Addresses`),
|
||||
bottom: [
|
||||
line({ series: addrs[m.key][key], name: m.name, unit: Unit.count }),
|
||||
],
|
||||
@@ -143,7 +145,7 @@ export function createNetworkSection() {
|
||||
},
|
||||
...simpleDeltaTree({
|
||||
delta: addrs.delta[key],
|
||||
title: (s) => `${titlePrefix}${s}`,
|
||||
title,
|
||||
metric: "Address Count",
|
||||
unit: Unit.count,
|
||||
}),
|
||||
@@ -151,7 +153,7 @@ export function createNetworkSection() {
|
||||
name: "New",
|
||||
tree: chartsFromCount({
|
||||
pattern: addrs.new[key],
|
||||
title: (s) => `${titlePrefix}${s}`,
|
||||
title,
|
||||
metric: "New Addresses",
|
||||
unit: Unit.count,
|
||||
}),
|
||||
@@ -163,7 +165,7 @@ export function createNetworkSection() {
|
||||
name: "Compare",
|
||||
tree: ROLLING_WINDOWS.map((w) => ({
|
||||
name: w.name,
|
||||
title: `${w.title} ${titlePrefix}Active Addresses`,
|
||||
title: title(`${w.title} Active Addresses`),
|
||||
bottom: activityTypes.map((t, i) =>
|
||||
line({
|
||||
series: addrs.activity[key][t.key][w.key],
|
||||
@@ -178,7 +180,7 @@ export function createNetworkSection() {
|
||||
name: t.name,
|
||||
tree: averagesArray({
|
||||
windows: addrs.activity[key][t.key],
|
||||
title: (s) => `${titlePrefix}${s}`,
|
||||
title,
|
||||
metric: `${t.name} Addresses`,
|
||||
unit: Unit.count,
|
||||
}),
|
||||
@@ -186,6 +188,7 @@ export function createNetworkSection() {
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
/** @type {Record<string, typeof scriptTypes[number]>} */
|
||||
const byKey = Object.fromEntries(scriptTypes.map((t) => [t.key, t]));
|
||||
@@ -560,7 +563,7 @@ export function createNetworkSection() {
|
||||
{
|
||||
name: "Addresses",
|
||||
tree: [
|
||||
...createAddressSeriesTree("all", ""),
|
||||
...createAddressSeriesTree("all"),
|
||||
{
|
||||
name: "By Type",
|
||||
tree: [
|
||||
@@ -582,7 +585,7 @@ export function createNetworkSection() {
|
||||
},
|
||||
...addressTypes.map((t) => ({
|
||||
name: t.name,
|
||||
tree: createAddressSeriesTree(t.key, `${t.name} `),
|
||||
tree: createAddressSeriesTree(t.key, t.name),
|
||||
})),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -398,8 +398,8 @@ export function histogram({
|
||||
/**
|
||||
* Create series from an AverageHeightMaxMedianMinP10P25P75P90Pattern (height + rolling stats)
|
||||
* @param {Object} args
|
||||
* @param {{ height: AnySeriesPattern } & Record<string, any>} args.pattern - Pattern with .height and rolling stats (p10/p25/p75/p90 as _1y24h30d7dPattern)
|
||||
* @param {string} args.window - Rolling window key (e.g., '_24h', '_7d', '_30d', '_1y')
|
||||
* @param {{ height: AnySeriesPattern } & WindowedStats<AnySeriesPattern>} args.pattern - Pattern with .height and rolling stats
|
||||
* @param {string} args.window - Rolling window key (e.g., '_24h', '_1w', '_1m', '_1y')
|
||||
* @param {Unit} args.unit
|
||||
* @param {string} [args.title]
|
||||
* @param {Color} [args.baseColor]
|
||||
@@ -417,7 +417,7 @@ export function fromBaseStatsPattern({
|
||||
return [
|
||||
dots({
|
||||
series: pattern.height,
|
||||
name: title || "base",
|
||||
name: title || "Base",
|
||||
color: baseColor,
|
||||
unit,
|
||||
}),
|
||||
@@ -427,8 +427,10 @@ export function fromBaseStatsPattern({
|
||||
|
||||
/**
|
||||
* Extract stats at a specific rolling window
|
||||
* @param {Record<string, any>} pattern - Pattern with pct10/pct25/pct75/pct90 and median/max/min as _1y24h30d7dPattern
|
||||
* @template T
|
||||
* @param {WindowedStats<T>} pattern - Pattern with pct10/pct25/pct75/pct90 and median/max/min at each rolling window
|
||||
* @param {string} window
|
||||
* @returns {{ median: T, max: T, min: T, pct75: T, pct25: T, pct90: T, pct10: T }}
|
||||
*/
|
||||
export function statsAtWindow(pattern, window) {
|
||||
return {
|
||||
@@ -450,6 +452,7 @@ export function statsAtWindow(pattern, window) {
|
||||
* @param {(w: typeof ROLLING_WINDOWS[number]) => string} args.windowTitle
|
||||
* @param {Unit} args.unit
|
||||
* @param {string} args.name
|
||||
* @param {string} args.legend
|
||||
* @returns {PartialOptionsGroup}
|
||||
*/
|
||||
function rollingWindowsTreeBaseline({
|
||||
@@ -458,6 +461,7 @@ function rollingWindowsTreeBaseline({
|
||||
windowTitle,
|
||||
unit,
|
||||
name,
|
||||
legend,
|
||||
}) {
|
||||
return {
|
||||
name,
|
||||
@@ -477,7 +481,7 @@ function rollingWindowsTreeBaseline({
|
||||
...ROLLING_WINDOWS.map((w) => ({
|
||||
name: w.name,
|
||||
title: windowTitle(w),
|
||||
bottom: [baseline({ series: windows[w.key], name: w.name, unit })],
|
||||
bottom: [baseline({ series: windows[w.key], name: legend, unit })],
|
||||
})),
|
||||
],
|
||||
};
|
||||
@@ -605,15 +609,17 @@ export function sumsAndAveragesCumulative({
|
||||
* @param {(metric: string) => string} [args.title]
|
||||
* @param {string} args.metric
|
||||
* @param {Unit} args.unit
|
||||
* @param {string} [args.legend]
|
||||
* @returns {PartialOptionsGroup}
|
||||
*/
|
||||
export function sumsTreeBaseline({ windows, title = (s) => s, metric, unit }) {
|
||||
export function sumsTreeBaseline({ windows, title = (s) => s, metric, unit, legend = "Sum" }) {
|
||||
return rollingWindowsTreeBaseline({
|
||||
windows,
|
||||
title: title(metric),
|
||||
windowTitle: (w) => title(`${w.title} ${metric}`),
|
||||
unit,
|
||||
name: "Sums",
|
||||
legend,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -639,16 +645,16 @@ export function averagesArray({ windows, title = (s) => s, metric, unit }) {
|
||||
name: w.name,
|
||||
title: title(`${w.title} ${metric}`),
|
||||
bottom: [
|
||||
line({ series: windows[w.key], name: w.name, color: w.color, unit }),
|
||||
line({ series: windows[w.key], name: "Average", color: w.color, unit }),
|
||||
],
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Distribution folder tree with stats at each rolling window (24h/7d/30d/1y)
|
||||
* Create a Distribution folder tree with stats at each rolling window (24h/1w/1m/1y)
|
||||
* @param {Object} args
|
||||
* @param {Record<string, any>} args.pattern - Pattern with pct10/pct25/... and average/median/... as _1y24h30d7dPattern
|
||||
* @param {WindowedStats<AnySeriesPattern>} args.pattern - Pattern with pct10/pct25/... and average/median/... at each rolling window
|
||||
* @param {AnySeriesPattern} [args.base] - Optional base series to show as dots on each chart
|
||||
* @param {(metric: string) => string} [args.title]
|
||||
* @param {string} args.metric
|
||||
@@ -675,7 +681,7 @@ export function distributionWindowsTree({ pattern, base, title = (s) => s, metri
|
||||
name: w.name,
|
||||
title: title(`${w.title} ${metric} Distribution`),
|
||||
bottom: [
|
||||
...(base ? [line({ series: base, name: "base", unit })] : []),
|
||||
...(base ? [line({ series: base, name: "Base", unit })] : []),
|
||||
...percentileSeries({ pattern: statsAtWindow(pattern, w.key), unit }),
|
||||
],
|
||||
})),
|
||||
@@ -871,7 +877,7 @@ export function rollingPercentRatioTree({
|
||||
...ROLLING_WINDOWS.map((w) => ({
|
||||
name: w.name,
|
||||
title: title(`${w.title} ${metric}`),
|
||||
bottom: percentRatioBaseline({ pattern: windows[w.key], name: w.name }),
|
||||
bottom: percentRatioBaseline({ pattern: windows[w.key], name: "Rate" }),
|
||||
})),
|
||||
],
|
||||
};
|
||||
@@ -911,7 +917,7 @@ export function deltaTree({ delta, title = (s) => s, metric, unit, extract }) {
|
||||
bottom: [
|
||||
baseline({
|
||||
series: extract(delta.absolute[w.key]),
|
||||
name: w.name,
|
||||
name: "Change",
|
||||
unit,
|
||||
}),
|
||||
],
|
||||
@@ -1068,7 +1074,7 @@ export function chartsFromBlockAnd6b({ pattern, title = (s) => s, metric, unit }
|
||||
/**
|
||||
* Averages + Sums + Cumulative charts
|
||||
* @param {Object} args
|
||||
* @param {CountPattern<any>} args.pattern
|
||||
* @param {CountPattern<number>} args.pattern
|
||||
* @param {(metric: string) => string} [args.title]
|
||||
* @param {string} args.metric
|
||||
* @param {Unit} args.unit
|
||||
@@ -1090,7 +1096,7 @@ export function chartsFromCount({ pattern, title = (s) => s, metric, unit, color
|
||||
/**
|
||||
* Windowed sums + cumulative for multiple named entries (e.g. transaction versions)
|
||||
* @param {Object} args
|
||||
* @param {Array<[string, CountPattern<any>]>} args.entries
|
||||
* @param {Array<[string, CountPattern<number>]>} args.entries
|
||||
* @param {(metric: string) => string} [args.title]
|
||||
* @param {string} args.metric
|
||||
* @param {Unit} args.unit
|
||||
|
||||
@@ -203,7 +203,7 @@ export function revenueBtcSatsUsd({ coinbase, subsidy, fee, key }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create sats/btc/usd series from a rolling window (24h/7d/30d/1y sum)
|
||||
* Create sats/btc/usd series from a rolling window (24h/1w/1m/1y sum)
|
||||
* @param {Object} args
|
||||
* @param {AnyValuePattern} args.pattern - A BtcSatsUsdPattern (e.g., source.rolling._24h.sum)
|
||||
* @param {string} args.name
|
||||
@@ -540,14 +540,15 @@ export function ratioBottomSeries(ratio) {
|
||||
* @param {AnyRatioPattern} args.ratio
|
||||
* @param {Color} args.color
|
||||
* @param {string} [args.name]
|
||||
* @param {string} [args.legend]
|
||||
* @returns {PartialChartOption}
|
||||
*/
|
||||
export function createRatioChart({ title, pricePattern, ratio, color, name }) {
|
||||
export function createRatioChart({ title, pricePattern, ratio, color, name, legend }) {
|
||||
return {
|
||||
name: name ?? "Ratio",
|
||||
title: title(name ?? "Ratio"),
|
||||
top: [
|
||||
price({ series: pricePattern, name: "Price", color }),
|
||||
price({ series: pricePattern, name: legend ?? "Price", color }),
|
||||
...percentileUsdMap(ratio).map(({ name, prop, color }) =>
|
||||
price({
|
||||
series: prop,
|
||||
@@ -742,6 +743,7 @@ export function createPriceRatioCharts({
|
||||
pricePattern,
|
||||
ratio,
|
||||
color,
|
||||
legend,
|
||||
}),
|
||||
createZScoresFolder({
|
||||
formatTitle: (name) =>
|
||||
|
||||
@@ -100,9 +100,10 @@ export function logUnused(seriesTree, partialOptions) {
|
||||
|
||||
if (!all.size) return;
|
||||
|
||||
/** @type {Record<string, any>} */
|
||||
/** @type {Record<string, unknown>} */
|
||||
const tree = {};
|
||||
for (const path of all.values()) {
|
||||
/** @type {Record<string, unknown>} */
|
||||
let current = tree;
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const part = path[i];
|
||||
@@ -110,7 +111,7 @@ export function logUnused(seriesTree, partialOptions) {
|
||||
current[part] = null;
|
||||
} else {
|
||||
current[part] = current[part] || {};
|
||||
current = current[part];
|
||||
current = /** @type {Record<string, unknown>} */ (current[part]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
searchLabelElement,
|
||||
searchResultsElement,
|
||||
} from "../utils/elements.js";
|
||||
import { QuickMatch } from "../modules/quickmatch-js/0.3.1/src/index.js";
|
||||
import { QuickMatch } from "../modules/quickmatch-js/0.4.0/src/index.js";
|
||||
|
||||
/**
|
||||
* @param {Options} options
|
||||
@@ -24,11 +24,7 @@ export function initSearch(options) {
|
||||
searchResultsElement.scrollTo({ top: 0 });
|
||||
searchResultsElement.innerHTML = "";
|
||||
|
||||
if (needle.length < 3) {
|
||||
const li = window.document.createElement("li");
|
||||
li.textContent = 'e.g. "BTC"';
|
||||
li.style.color = "var(--off-color)";
|
||||
searchResultsElement.appendChild(li);
|
||||
if (!needle.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,11 +22,8 @@ export function setQr(url) {
|
||||
"";
|
||||
|
||||
imgQrcode.src =
|
||||
leanQr.generate(/** @type {any} */ (url))?.toDataURL({
|
||||
// @ts-ignore
|
||||
padX: 0,
|
||||
padY: 0,
|
||||
}) || "";
|
||||
// @ts-ignore — lean-qr types don't resolve for file path import
|
||||
leanQr.generate(url)?.toDataURL({ padX: 0, padY: 0 }) || "";
|
||||
|
||||
shareDiv.hidden = false;
|
||||
}
|
||||
|
||||
@@ -160,6 +160,15 @@
|
||||
* Distribution stats: min, max, median, pct10/25/75/90
|
||||
* @typedef {{ min: AnySeriesPattern, max: AnySeriesPattern, median: AnySeriesPattern, pct10: AnySeriesPattern, pct25: AnySeriesPattern, pct75: AnySeriesPattern, pct90: AnySeriesPattern }} DistributionStats
|
||||
*/
|
||||
/**
|
||||
* Windowed distribution stats: each stat property is a rolling window record
|
||||
* @template T
|
||||
* @typedef {{ median: Record<string, T>, max: Record<string, T>, min: Record<string, T>, pct75: Record<string, T>, pct25: Record<string, T>, pct90: Record<string, T>, pct10: Record<string, T> }} WindowedStats
|
||||
*/
|
||||
/**
|
||||
* Dominance pattern: percent/ratio at top level + per rolling window
|
||||
* @typedef {Brk._1m1w1y24hBpsPercentRatioPattern} DominancePattern
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Typed Object.entries that preserves key types
|
||||
* @template {Record<string, any>} T
|
||||
* @template {Record<string, unknown>} T
|
||||
* @param {T} obj
|
||||
* @returns {[keyof T & string, T[keyof T & string]][]}
|
||||
*/
|
||||
|
||||
@@ -183,8 +183,8 @@ export function createRadios({
|
||||
choices,
|
||||
initialValue,
|
||||
onChange,
|
||||
toKey = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c),
|
||||
toLabel = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c),
|
||||
toKey = /** @type {(choice: T) => string} */ ((c) => String(c)),
|
||||
toLabel = /** @type {(choice: T) => string} */ ((c) => String(c)),
|
||||
toTitle,
|
||||
}) {
|
||||
const field = window.document.createElement("div");
|
||||
@@ -247,8 +247,8 @@ export function createSelect({
|
||||
initialValue,
|
||||
onChange,
|
||||
sorted,
|
||||
toKey = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c),
|
||||
toLabel = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c),
|
||||
toKey = /** @type {(choice: T) => string} */ ((c) => String(c)),
|
||||
toLabel = /** @type {(choice: T) => string} */ ((c) => String(c)),
|
||||
}) {
|
||||
const choices = sorted
|
||||
? unsortedChoices.toSorted((a, b) => toLabel(a).localeCompare(toLabel(b)))
|
||||
|
||||
@@ -22,7 +22,7 @@ export function idle(callback) {
|
||||
|
||||
/**
|
||||
*
|
||||
* @template {(...args: any[]) => any} F
|
||||
* @template {(...args: never[]) => unknown} F
|
||||
* @param {F} callback
|
||||
* @param {number} [wait]
|
||||
*/
|
||||
@@ -51,7 +51,7 @@ export function throttle(callback, wait = 1000) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {(...args: any[]) => any} F
|
||||
* @template {(...args: never[]) => unknown} F
|
||||
* @param {F} callback
|
||||
* @param {number} [wait]
|
||||
* @returns {((...args: Parameters<F>) => void) & { cancel: () => void }}
|
||||
|
||||
Reference in New Issue
Block a user