website: redesign part 1

This commit is contained in:
nym21
2026-06-03 12:34:05 +02:00
parent 5f5563fece
commit 90e8741fb7
209 changed files with 23945 additions and 176 deletions
-262
View File
@@ -1,262 +0,0 @@
/**
* @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 CreateLCChart, LineStyle, createSeriesMarkers as CreateSeriesMarkers, SeriesMarker, ISeriesMarkersPluginApi } from './modules/lightweight-charts/5.2.0/dist/typings.js'
*
* @import * as Brk from "./modules/brk-client/index.js"
* @import { BrkClient, Index, SeriesData, Urpd } from "./modules/brk-client/index.js"
*
* @import { Options } from './options/full.js'
*
* @import { PersistedValue } from './utils/persisted.js'
*
* @import { SingleValueData, CandlestickData, Series, AnySeries, ISeries, HistogramData, LineData, BaselineData, LineSeriesPartialOptions, BaselineSeriesPartialOptions, HistogramSeriesPartialOptions, CandlestickSeriesPartialOptions, Chart, Legend } from "./utils/chart/index.js"
*
* @import { Color } from "./utils/colors.js"
*
* @import { HeatmapAxis, HeatmapAxisChoice, HeatmapDefaults, HeatmapGrid, HeatmapGridFactory, HeatmapPoints, HeatmapRange, HeatmapPointSource, HeatmapColorFn, HeatmapTooltipFn } from "../src/heatmap/types.js"
*
* @import { Option, PartialChartOption, ChartOption, AnyPartialOption, ProcessedOptionAddons, OptionsTree, AnySeriesBlueprint, SeriesType, AnyFetchedSeriesBlueprint, ExplorerOption, UrlOption, PartialOptionsGroup, OptionsGroup, PartialOptionsTree, UtxoCohortObject, AddrCohortObject, CohortObject, CohortGroupObject, FetchedLineSeriesBlueprint, FetchedBaselineSeriesBlueprint, FetchedHistogramSeriesBlueprint, FetchedDotsBaselineSeriesBlueprint, PatternAll, PatternFull, PatternWithAdjusted, PatternWithPercentiles, PatternBasic, PatternBasicWithMarketCap, PatternBasicWithoutMarketCap, PatternWithoutRelative, CohortAll, CohortFull, CohortWithAdjusted, CohortWithPercentiles, CohortBasic, CohortBasicWithMarketCap, CohortBasicWithoutMarketCap, CohortWithoutRelative, CohortAddr, CohortLongTerm, CohortAgeRange, CohortAgeRangeWithMatured, CohortGroupFull, CohortGroupWithAdjusted, CohortGroupWithPercentiles, CohortGroupLongTerm, CohortGroupAgeRange, CohortGroupBasic, CohortGroupBasicWithMarketCap, CohortGroupBasicWithoutMarketCap, CohortGroupWithoutRelative, CohortGroupAddr, UtxoCohortGroupObject, AddrCohortGroupObject, FetchedDotsSeriesBlueprint, PartialHeatmapOption, HeatmapOption, FetchedCandlestickSeriesBlueprint, FetchedPriceSeriesBlueprint, AnyPricePattern, AnyValuePattern } from "./options/partial.js"
*
*
* @import { UnitObject as Unit } from "./utils/units.js"
*
* @import { ChartableIndex, IndexLabel } from "./utils/serde.js";
*/
// import uFuzzy = require("./modules/leeoniya-ufuzzy/1.0.19/dist/uFuzzy.d.ts");
/**
* @typedef {[number, number, number, number]} OHLCTuple
*
* Lightweight Charts markers
* @typedef {ISeriesMarkersPluginApi<Time>} SeriesMarkersPlugin
* @typedef {SeriesMarker<Time>} TimeSeriesMarker
*
* Brk tree types (stable across regenerations)
* @typedef {Brk.SeriesTree_Cohorts_Utxo} UtxoCohortTree
* @typedef {Brk.SeriesTree_Cohorts_Addr} AddrCohortTree
* @typedef {Brk.SeriesTree_Cohorts_Utxo_All} AllUtxoPattern
* @typedef {Brk.SeriesTree_Cohorts_Utxo_Sth} ShortTermPattern
* @typedef {Brk.SeriesTree_Cohorts_Utxo_Lth} LongTermPattern
* @typedef {Brk.SeriesTree_Cohorts_Utxo_All_Unrealized} AllRelativePattern
* @typedef {keyof Brk.BtcCentsSatsUsdPattern} BtcSatsUsdKey
* @typedef {Brk.BtcCentsSatsUsdPattern} SupplyPattern
* @typedef {Brk.AverageBlockCumulativeMaxMedianMinPct10Pct25Pct75Pct90SumPattern} BlockSizePattern
* @typedef {keyof Brk.SeriesTree_Cohorts_Utxo_Type} SpendableType
* @typedef {Brk.SpendingSpentUnspentPattern} OutputsPattern
* @typedef {keyof Brk.SeriesTree_Addrs_Raw} AddressableType
*
* Brk pattern types (using new pattern names)
* @typedef {Brk.ActivityOutputsRealizedSupplyUnrealizedPattern} MaxAgePattern
* @typedef {Brk.ActivityOutputsRealizedSupplyUnrealizedPattern} AgeRangePattern
* @typedef {Brk.ActivityOutputsRealizedSupplyUnrealizedPattern2} UtxoAmountPattern
* @typedef {Brk.ActivityAddrOutputsRealizedSupplyUnrealizedPattern} AddrAmountPattern
* @typedef {Brk.ActivityOutputsRealizedSupplyUnrealizedPattern} BasicUtxoPattern
* @typedef {Brk.ActivityOutputsRealizedSupplyUnrealizedPattern} EpochPattern
* @typedef {Brk.ActivityOutputsRealizedSupplyUnrealizedPattern3} EmptyPattern
* @typedef {Brk._0sdM0M1M1sdM2M2sdM3sdP0P1P1sdP2P2sdP3sdSdZscorePattern} Ratio1ySdPattern
* @typedef {Brk.Dollars} Dollars
* @typedef {Brk.BlockInfo} BlockInfo
* @typedef {Brk.Height} Height
* @typedef {Brk.BlockHash} BlockHash
* @typedef {Brk.BlockInfoV1} BlockInfoV1
* @typedef {Brk.Transaction} Transaction
* @typedef {Brk.Txid} Txid
* @typedef {Brk.TxIndex} TxIndex
* @typedef {Brk.AddrStats} AddrStats
* @typedef {Brk.TxIn} TxIn
* @typedef {Brk.TxOut} TxOut
* @typedef {Brk.BlockTemplate} BlockTemplate
* @typedef {Brk.MempoolBlock} MempoolBlock
* @typedef {Brk.NextBlockHash} NextBlockHash
* ActivePriceRatioPattern: ratio pattern with price (extended)
* @typedef {Brk.BpsPriceRatioPattern} ActivePriceRatioPattern
* PriceRatioPercentilesPattern: price pattern with ratio + percentiles (no SMAs/stdDev)
* @typedef {Brk.BpsCentsPercentilesRatioSatsUsdPattern} PriceRatioPercentilesPattern
* AnyRatioPattern: full ratio pattern with percentiles, SMAs, and std dev bands
* @typedef {Brk.BpsCentsPercentilesRatioSatsSmaStdUsdPattern} AnyRatioPattern
* FullValuePattern: block + cumulative + sum + average rolling windows (sats/btc/cents/usd)
* @typedef {Brk.AverageBlockCumulativeSumPattern3} FullValuePattern
* RollingWindowSlot: a single rolling window with stats (pct10, pct25, median, pct75, pct90, max, min) per unit
* @typedef {Brk.MaxMedianMinPct10Pct25Pct75Pct90Pattern<number>} RollingWindowSlot
* @typedef {Brk.AnySeriesPattern} AnySeriesPattern
* @typedef {Brk.CentsSatsUsdPattern} ActivePricePattern
* @typedef {Brk.AnySeriesEndpoint} AnySeriesEndpoint
* @typedef {Brk.AnySeriesData} AnySeriesData
* Relative patterns by capability:
* Unrealized patterns by capability level
* @typedef {Brk.LossNetNuplProfitPattern} BasicRelativePattern
* @typedef {Brk.CapitalizedGrossInvestedLossNetNuplProfitSentimentPattern2} FullRelativePattern
*
* Profitability bucket pattern (supply + realized_cap + unrealized_pnl + nupl)
* @typedef {Brk.NuplRealizedSupplyUnrealizedPattern} RealizedSupplyPattern
*
* Realized pattern (full: cap + gross + capitalized + loss + mvrv + net + peak + price + profit + sell + sopr)
* @typedef {Brk.CapCapitalizedGrossLossMvrvNetPeakPriceProfitSellSoprPattern} RealizedPattern
*
* Transfer volume pattern (block + cumulative + inProfit/inLoss + sum windows)
* @typedef {Brk.AverageBlockCumulativeInSumPattern} TransferVolumePattern
*
* Realized profit/loss pattern (block + cumulative + sum windows, cents/usd)
* @typedef {Brk.BlockCumulativeSumPattern} RealizedProfitLossPattern
*
* Full activity pattern (coindays, coinyears, dormancy, transfer volume)
* @typedef {Brk.CoindaysCoinyearsDormancyTransferPattern} FullActivityPattern
*
*
* BPS + percent + ratio pattern
* @typedef {Brk.BpsPercentRatioPattern2} PercentRatioPattern
*
* Percent + ratio per window + cumulative (mirrors CountPattern but for percent)
* @typedef {Brk._1m1w1y24hBpsPercentRatioPattern} PercentRatioCumulativePattern
*
* BPS + ratio pattern (for NUPL and similar)
* @typedef {Brk.BpsRatioPattern} NuplPattern
*
* LTH realized tree
* @typedef {Brk.SeriesTree_Cohorts_Utxo_Lth_Realized} LthRealizedPattern
*
* Net PnL pattern with change (base + change + cumulative + delta + rel + sum)
* @typedef {Brk.BlockChangeCumulativeDeltaSumPattern} NetPnlFullPattern
*
* Net PnL basic pattern (base + cumulative + delta + sum)
* @typedef {Brk.BlockCumulativeDeltaSumPattern} NetPnlBasicPattern
*
* Mid realized pattern (cap + loss + MVRV + net + price + profit + SOPR)
* @typedef {Brk.CapLossMvrvNetPriceProfitSoprPattern} MidRealizedPattern
*
* Basic realized pattern (cap + loss + MVRV + price + profit, no net/sopr)
* @typedef {Brk.CapLossMvrvPriceProfitPattern} BasicRealizedPattern
*
* Moving average price ratio pattern (bps + cents + ratio + sats + usd)
* @typedef {Brk.BpsCentsRatioSatsUsdPattern} MaPriceRatioPattern
*
* Address count pattern (base + delta with absolute + rate)
* @typedef {Brk.BaseDeltaPattern} AddrCountPattern
* @typedef {Brk.AddrUtxoPattern} AvgAmountPattern
* @typedef {Brk.SeriesTree_Addrs_Exposed} ExposedTree
* @typedef {Brk.SeriesTree_Addrs_Reused} ReusedTree
* @typedef {Brk.SeriesTree_Addrs_Respent} RespentTree
*/
/**
* @template T
* @typedef {Brk.SeriesEndpoint<T>} SeriesEndpoint
*/
/**
* Rolling windows pattern (24h, 1w, 1m, 1y)
* @template T
* @typedef {Brk._1m1w1y24hPattern<T>} RollingWindowPattern
*/
/**
* Sell side risk rolling windows pattern
* @typedef {Brk._1m1w1y24hPattern8} SellSideRiskPattern
*/
/**
* Stats pattern: min, max, median, percentiles
* @typedef {Brk.MaxMedianMinPct10Pct25Pct75Pct90Pattern<number>} StatsPattern
*/
/**
* Full stats pattern: cumulative, sum, average, min, max, percentiles + rolling
* @typedef {Brk.AverageBlockCumulativeMaxMedianMinPct10Pct25Pct75Pct90SumPattern} FullStatsPattern
*/
/**
* Aggregated pattern: cumulative + rolling (with distribution stats) + sum (no base)
* @typedef {Brk.CumulativeRollingSumPattern} AggregatedPattern
*/
/**
* Count pattern: height, cumulative, and rolling sum windows
* @template T
* @typedef {Brk.AverageBlockCumulativeSumPattern<T>} CountPattern
*/
/**
* Full per-block pattern: height, cumulative, sum, and distribution stats (all flat)
* FullPerBlockPattern: cumulative + sum + average + distribution stats (used by chartsFromFull)
* Note: some callers also have .block but the function doesn't use it
* @typedef {Omit<Brk.AverageBlockCumulativeMaxMedianMinPct10Pct25Pct75Pct90SumPattern, 'block'>} FullPerBlockPattern
*/
/**
* Any stats pattern union
* @typedef {FullStatsPattern} AnyStatsPattern
*/
/**
* 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
*/
/**
*
* @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.SeriesTree_Market} Market
* @typedef {Brk.SeriesTree_Market_MovingAverage} MarketMovingAverage
* @typedef {Brk.SeriesTree_Investing} Investing
* @typedef {Brk._10y2y3y4y5y6y8yPattern} PeriodCagrPattern
* @typedef {FullStatsPattern} AnyFullStatsPattern
*
* DCA period keys - derived from pattern types
* @typedef {keyof Brk._10y2y3y4y5y6y8yPattern} LongPeriodKey
* @typedef {"_1w" | "_1m" | "_3m" | "_6m" | "_1y"} ShortPeriodKey
* @typedef {ShortPeriodKey | LongPeriodKey} AllPeriodKey
*
* Pattern unions by cohort type
* @typedef {AllUtxoPattern | AgeRangePattern | UtxoAmountPattern} UtxoCohortPattern
* @typedef {AddrAmountPattern} AddrCohortPattern
* @typedef {UtxoCohortPattern | AddrCohortPattern} CohortPattern
*
* Relative pattern capability types
* @typedef {BasicRelativePattern | FullRelativePattern | AllRelativePattern} RelativeWithMarketCap
* @typedef {FullRelativePattern | AllRelativePattern} RelativeWithOwnMarketCap
* @typedef {FullRelativePattern | AllRelativePattern} RelativeWithOwnPnl
* @typedef {BasicRelativePattern | FullRelativePattern | AllRelativePattern} RelativeWithNupl
* @typedef {BasicRelativePattern | FullRelativePattern | AllRelativePattern} RelativeWithInvestedCapitalPct
*
* Realized pattern capability types
* @typedef {RealizedPattern} AnyRealizedPattern
*
* Capability-based pattern groupings (patterns that have specific properties)
* @typedef {AllUtxoPattern | AgeRangePattern | UtxoAmountPattern} PatternWithRealizedPrice
* @typedef {AllUtxoPattern} PatternWithFullRealized
* @typedef {ShortTermPattern | LongTermPattern | MaxAgePattern | BasicUtxoPattern} PatternWithNupl
* @typedef {AllUtxoPattern | AgeRangePattern | UtxoAmountPattern} PatternWithCostBasis
* @typedef {AllUtxoPattern | AgeRangePattern | UtxoAmountPattern} PatternWithActivity
* @typedef {AllUtxoPattern | AgeRangePattern} PatternWithCostBasisPercentiles
* @typedef {Brk.Pct05Pct10Pct15Pct20Pct25Pct30Pct35Pct40Pct45Pct50Pct55Pct60Pct65Pct70Pct75Pct80Pct85Pct90Pct95Pattern} PercentilesPattern
*
* 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
*
* Cohorts with nupl + percentiles (CohortFull and CohortLongTerm both have nupl and percentiles)
* @typedef {CohortFull | CohortLongTerm} CohortWithNuplPercentiles
* @typedef {{ name: string, title: string, list: readonly CohortWithNuplPercentiles[], all: CohortAll }} CohortGroupWithNuplPercentiles
*
* Delta patterns with absolute + rate rolling windows
* @typedef {Brk.AbsoluteRatePattern} DeltaPattern
* @typedef {Brk.AbsoluteRatePattern2} FiatDeltaPattern
* @typedef {Brk.AbsoluteRatePattern3} AmountDeltaPattern
* @typedef {Brk.BtcSatsPattern} AmountPattern
*
* Capitalized price percentiles (pct1/2/5/95/98/99)
* @typedef {Brk.Pct0Pct1Pct2Pct5Pct95Pct98Pct99Pattern} CapitalizedPercentilesPattern
* @typedef {Brk.BpsPriceRatioPattern} CapitalizedPercentileEntry
*
* Generic tree node type for walking
* @typedef {AnySeriesPattern | Record<string, unknown>} TreeNode
*/
-1
View File
@@ -1 +0,0 @@
import "./main.js";
-142
View File
@@ -1,142 +0,0 @@
import { brk } from "../utils/client.js";
import { latestPrice } from "../utils/price.js";
import { createRow, formatBtc, renderTx, showPanel, hidePanel, TX_PAGE_SIZE } from "./render.js";
/** @type {HTMLDivElement} */ let el;
/** @type {HTMLElement[]} */ let valueEls;
/** @type {HTMLDivElement} */ let txSection;
/** @type {string} */ let currentAddr = "";
const ROW_LABELS = [
"Address",
"Confirmed Balance",
"Pending",
"Confirmed UTXOs",
"Pending UTXOs",
"Total Received",
"Tx Count",
"Type",
"Avg Cost Basis",
];
/** @param {HTMLElement} parent @param {(e: MouseEvent) => void} linkHandler */
export function initAddrDetails(parent, linkHandler) {
el = document.createElement("div");
el.id = "addr-details";
el.hidden = true;
parent.append(el);
el.addEventListener("click", linkHandler);
const title = document.createElement("h1");
title.textContent = "Address";
el.append(title);
valueEls = ROW_LABELS.map((label) => {
const { row, valueEl } = createRow(label);
el.append(row);
return valueEl;
});
txSection = document.createElement("div");
txSection.classList.add("transactions");
const heading = document.createElement("h2");
heading.textContent = "Transactions";
txSection.append(heading);
el.append(txSection);
}
/**
* @param {string} address
* @param {AbortSignal} signal
*/
export async function update(address, signal) {
currentAddr = address;
valueEls[0].textContent = address;
for (let i = 1; i < valueEls.length; i++) {
valueEls[i].textContent = "...";
valueEls[i].classList.add("dim");
}
while (txSection.children.length > 1) txSection.lastChild?.remove();
try {
const stats = await brk.getAddress(address, { signal });
if (signal.aborted || currentAddr !== address) return;
const chain = stats.chainStats;
const balance = chain.fundedTxoSum - chain.spentTxoSum;
const mempool = stats.mempoolStats;
const pending = mempool ? mempool.fundedTxoSum - mempool.spentTxoSum : 0;
const pendingUtxos = mempool
? mempool.fundedTxoCount - mempool.spentTxoCount
: 0;
const confirmedUtxos = chain.fundedTxoCount - chain.spentTxoCount;
const price = latestPrice();
const fmtUsd = (/** @type {number} */ sats) =>
price
? ` $${((sats / 1e8) * price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
: "";
const values = [
address,
`${formatBtc(balance)} BTC${fmtUsd(balance)}`,
`${pending >= 0 ? "+" : ""}${formatBtc(pending)} BTC${fmtUsd(pending)}`,
confirmedUtxos.toLocaleString(),
pendingUtxos.toLocaleString(),
`${formatBtc(chain.fundedTxoSum)} BTC`,
chain.txCount.toLocaleString(),
stats.addrType.replace(/^v\d+_/, "").toUpperCase(),
chain.realizedPrice
? `$${Number(chain.realizedPrice).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
: "N/A",
];
for (let i = 0; i < valueEls.length; i++) {
valueEls[i].textContent = values[i];
valueEls[i].classList.remove("dim");
}
let loading = false;
let pageIndex = 0;
/** @type {string | undefined} */
let afterTxid;
const observer = new IntersectionObserver((entries) => {
if (
entries[0].isIntersecting &&
!loading &&
pageIndex * TX_PAGE_SIZE < chain.txCount
)
loadMore();
});
async function loadMore() {
if (currentAddr !== address) return;
loading = true;
try {
const txs = afterTxid
? await brk.getAddressConfirmedTxsAfter(address, afterTxid, { signal })
: await brk.getAddressTxs(address, { signal });
if (currentAddr !== address) return;
for (const tx of txs) txSection.append(renderTx(tx));
pageIndex++;
if (txs.length) {
afterTxid = txs[txs.length - 1].txid;
observer.disconnect();
const last = txSection.lastElementChild;
if (last) observer.observe(last);
}
} catch (e) {
if (!signal.aborted) console.error("explorer addr txs:", e);
pageIndex = chain.txCount;
}
loading = false;
}
await loadMore();
} catch (e) {
if (!signal.aborted) console.error("explorer addr:", e);
}
}
export function show() { showPanel(el); }
export function hide() { hidePanel(el); }
-210
View File
@@ -1,210 +0,0 @@
import { brk } from "../utils/client.js";
import { createPersistedValue } from "../utils/persisted.js";
import { createRow, formatFeeRate, formatHeightPrefix, renderTx, showPanel, hidePanel, TX_PAGE_SIZE } from "./render.js";
/** @typedef {[string, (b: BlockInfoV1) => string | null, ((b: BlockInfoV1) => string | null)?]} RowDef */
/** @param {(x: BlockInfoV1["extras"]) => string | null} fn @returns {(b: BlockInfoV1) => string | null} */
const ext = (fn) => (b) => fn(b.extras);
/** @type {RowDef[]} */
const ROW_DEFS = [
["Hash", (b) => b.id, (b) => `/block/${b.id}`],
["Previous Hash", (b) => b.previousblockhash, (b) => `/block/${b.previousblockhash}`],
["Merkle Root", (b) => b.merkleRoot],
["Timestamp", (b) => new Date(b.timestamp * 1000).toUTCString()],
["Median Time", (b) => new Date(b.mediantime * 1000).toUTCString()],
["Version", (b) => `0x${b.version.toString(16)}`],
["Bits", (b) => b.bits.toString(16)],
["Nonce", (b) => b.nonce.toLocaleString()],
["Difficulty", (b) => Number(b.difficulty).toLocaleString()],
["Size", (b) => `${(b.size / 1_000_000).toFixed(2)} MB`],
["Weight", (b) => `${(b.weight / 1_000_000).toFixed(2)} MWU`],
["Transactions", (b) => b.txCount.toLocaleString()],
["Price", ext((x) => `$${x.price.toLocaleString()}`)],
["Pool", ext((x) => x.pool.name)],
["Pool ID", ext((x) => x.pool.id.toString())],
["Pool Slug", ext((x) => x.pool.slug)],
["Miner Names", ext((x) => x.pool.minerNames?.join(", ") || null)],
["Reward", ext((x) => `${(x.reward / 1e8).toFixed(8)} BTC`)],
["Total Fees", ext((x) => `${(x.totalFees / 1e8).toFixed(8)} BTC`)],
["Median Fee Rate", ext((x) => `${formatFeeRate(x.medianFee)} sat/vB`)],
["Avg Fee Rate", ext((x) => `${formatFeeRate(x.avgFeeRate)} sat/vB`)],
["Avg Fee", ext((x) => `${x.avgFee.toLocaleString()} sat`)],
["Median Fee", ext((x) => `${x.medianFeeAmt.toLocaleString()} sat`)],
["Fee Range", ext((x) => x.feeRange.map(formatFeeRate).join(", ") + " sat/vB")],
["Fee Percentiles", ext((x) => x.feePercentiles.map((f) => f.toLocaleString()).join(", ") + " sat")],
["Avg Tx Size", ext((x) => `${x.avgTxSize.toLocaleString()} B`)],
["Virtual Size", ext((x) => `${x.virtualSize.toLocaleString()} vB`)],
["Inputs", ext((x) => x.totalInputs.toLocaleString())],
["Outputs", ext((x) => x.totalOutputs.toLocaleString())],
["Total Input Amount", ext((x) => `${(x.totalInputAmt / 1e8).toFixed(8)} BTC`)],
["Total Output Amount", ext((x) => `${(x.totalOutputAmt / 1e8).toFixed(8)} BTC`)],
["UTXO Set Change", ext((x) => x.utxoSetChange.toLocaleString())],
["UTXO Set Size", ext((x) => x.utxoSetSize.toLocaleString())],
["SegWit Txs", ext((x) => x.segwitTotalTxs.toLocaleString())],
["SegWit Size", ext((x) => `${x.segwitTotalSize.toLocaleString()} B`)],
["SegWit Weight", ext((x) => `${x.segwitTotalWeight.toLocaleString()} WU`)],
["Coinbase Address", ext((x) => x.coinbaseAddress || null)],
["Coinbase Addresses", ext((x) => x.coinbaseAddresses.join(", ") || null)],
["Coinbase Raw", ext((x) => x.coinbaseRaw)],
["Coinbase Signature", ext((x) => x.coinbaseSignature)],
["Coinbase Signature ASCII", ext((x) => x.coinbaseSignatureAscii)],
["Header", ext((x) => x.header)],
];
/** @typedef {{ first: HTMLButtonElement, prev: HTMLButtonElement, label: HTMLSpanElement, next: HTMLButtonElement, last: HTMLButtonElement }} TxNav */
/** @type {HTMLDivElement} */ let el;
/** @type {HTMLSpanElement} */ let heightPrefix;
/** @type {HTMLSpanElement} */ let heightNum;
/** @type {{ row: HTMLDivElement, valueEl: HTMLElement }[]} */ let detailRows;
/** @type {HTMLDivElement} */ let txList;
/** @type {HTMLDivElement} */ let txSection;
/** @type {IntersectionObserver} */ let txObserver;
/** @type {TxNav[]} */ const txNavs = [];
/** @type {BlockInfoV1 | null} */ let txBlock = null;
let txTotalPages = 0;
let txLoading = false;
let txLoaded = false;
const txPageParam = createPersistedValue({
defaultValue: 0,
urlKey: "page",
serialize: (v) => String(v + 1),
deserialize: (s) => Math.max(0, Number(s) - 1),
});
/** @param {HTMLElement} parent @param {(e: MouseEvent) => void} linkHandler */
export function initBlockDetails(parent, linkHandler) {
el = document.createElement("div");
el.id = "block-details";
parent.append(el);
const title = document.createElement("h1");
title.textContent = "Block ";
const code = document.createElement("code");
const container = document.createElement("span");
heightPrefix = document.createElement("span");
heightPrefix.classList.add("dim");
heightPrefix.style.userSelect = "none";
heightNum = document.createElement("span");
container.append(heightPrefix, heightNum);
code.append(container);
title.append(code);
el.append(title);
el.addEventListener("click", linkHandler);
detailRows = ROW_DEFS.map(([label, , linkFn]) => {
const { row, valueEl } = createRow(label, Boolean(linkFn));
el.append(row);
return { row, valueEl };
});
txSection = document.createElement("div");
txSection.classList.add("transactions");
el.append(txSection);
const txHeader = document.createElement("div");
txHeader.classList.add("tx-header");
const heading = document.createElement("h2");
heading.textContent = "Transactions";
txHeader.append(heading, createTxNav());
txSection.append(txHeader);
txList = document.createElement("div");
txList.classList.add("tx-list");
txSection.append(txList, createTxNav());
txObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !txLoaded) {
loadTxPage(txPageParam.value, false);
}
});
txObserver.observe(txSection);
}
function createTxNav() {
const nav = document.createElement("div");
nav.classList.add("pagination");
const first = document.createElement("button");
first.textContent = "\u00AB";
const prev = document.createElement("button");
prev.textContent = "\u2190";
const label = document.createElement("span");
const next = document.createElement("button");
next.textContent = "\u2192";
const last = document.createElement("button");
last.textContent = "\u00BB";
nav.append(first, prev, label, next, last);
first.addEventListener("click", () => loadTxPage(0));
prev.addEventListener("click", () => loadTxPage(txPageParam.value - 1));
next.addEventListener("click", () => loadTxPage(txPageParam.value + 1));
last.addEventListener("click", () => loadTxPage(txTotalPages - 1));
txNavs.push({ first, prev, label, next, last });
return nav;
}
/** @param {number} page */
function updateTxNavs(page) {
const atFirst = page <= 0;
const atLast = page >= txTotalPages - 1;
for (const n of txNavs) {
n.label.textContent = `${page + 1} / ${txTotalPages}`;
n.first.disabled = atFirst;
n.prev.disabled = atFirst;
n.next.disabled = atLast;
n.last.disabled = atLast;
}
}
/** @param {BlockInfoV1} block */
export function update(block) {
heightPrefix.textContent = formatHeightPrefix(block.height);
heightNum.textContent = block.height.toString();
ROW_DEFS.forEach(([, getter, linkFn], i) => {
const value = getter(block);
const { row, valueEl } = detailRows[i];
if (value !== null) {
valueEl.textContent = value;
if (linkFn)
/** @type {HTMLAnchorElement} */ (valueEl).href = linkFn(block) ?? "";
row.hidden = false;
} else {
row.hidden = true;
}
});
txBlock = block;
txTotalPages = Math.ceil(block.txCount / TX_PAGE_SIZE);
if (txLoaded) txPageParam.setImmediate(0);
txLoaded = false;
updateTxNavs(txPageParam.value);
txList.innerHTML = "";
txObserver.disconnect();
txObserver.observe(txSection);
}
export function show() { showPanel(el); }
export function hide() { hidePanel(el); }
/** @param {number} page @param {boolean} [pushUrl] */
async function loadTxPage(page, pushUrl = true) {
const block = txBlock;
if (txLoading || !block || page < 0 || page >= txTotalPages) return;
txLoading = true;
txLoaded = true;
if (pushUrl) txPageParam.setImmediate(page);
updateTxNavs(page);
try {
const txs = await brk.getBlockTxsFromIndex(block.id, page * TX_PAGE_SIZE);
txList.innerHTML = "";
const ascii = block.extras.coinbaseSignatureAscii;
for (const tx of txs) txList.append(renderTx(tx, ascii));
} catch (e) {
console.error("explorer txs:", e);
}
txLoading = false;
}
-377
View File
@@ -1,377 +0,0 @@
import { brk } from "../utils/client.js";
import { onPlainClick } from "../utils/dom.js";
import { createCube } from "./cube.js";
import { initMempool, renderMempool } from "./mempool.js";
import { createHeightElement, formatFeeRate } from "./render.js";
const LOOKAHEAD = 15;
/** @type {HTMLDivElement} */ let chainEl;
/** @type {HTMLDivElement} */ let scrollEl;
/** @type {HTMLDivElement} */ let blocksEl;
/** @type {HTMLAnchorElement | null} */ let selectedCube = null;
/** @type {IntersectionObserver} */ let olderObserver;
/** @type {(block: BlockInfoV1) => void} */ let onSelect = () => {};
/** @type {(cube: HTMLAnchorElement) => void} */ let onCubeClick = () => {};
/** @type {() => void} */ let onTip = () => {};
/** @type {() => void} */ let onGenesis = () => {};
/** @type {Map<BlockHash, BlockInfoV1>} */
const blocksByHash = new Map();
let newestHeight = -1;
let oldestHeight = Infinity;
let loadingOlder = false;
let loadingNewer = false;
let reachedTip = false;
/**
* @param {HTMLElement} parent
* @param {{
* onSelect: (block: BlockInfoV1) => void,
* onCubeClick: (cube: HTMLAnchorElement) => void,
* onTip: () => void,
* onGenesis: () => void,
* }} callbacks
*/
export function initChain(parent, callbacks) {
onSelect = callbacks.onSelect;
onCubeClick = callbacks.onCubeClick;
onTip = callbacks.onTip;
onGenesis = callbacks.onGenesis;
chainEl = document.createElement("div");
chainEl.id = "chain";
parent.append(chainEl);
chainEl.append(
createControlLink("tip", "/block/tip", "Jump to chain tip", onTip),
);
chainEl.append(
createControlLink("gen", "/block/0", "Jump to genesis block", onGenesis),
);
scrollEl = document.createElement("div");
scrollEl.classList.add("chain-scroll");
chainEl.append(scrollEl);
blocksEl = document.createElement("div");
blocksEl.classList.add("blocks");
scrollEl.append(blocksEl);
initMempool(scrollEl);
olderObserver = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadOlder();
},
{ root: scrollEl },
);
scrollEl.addEventListener(
"scroll",
() => {
if (reachedTip || loadingNewer) return;
if (scrollEl.scrollTop <= 50 && scrollEl.scrollLeft <= 50) loadNewer();
},
{ passive: true },
);
}
/** @param {BlockHash | Height | null} [hashOrHeight] */
function findCube(hashOrHeight) {
if (hashOrHeight == null) {
return reachedTip && newestHeight >= 0
? /** @type {HTMLAnchorElement | null} */ (blocksEl.lastElementChild)
: null;
}
const attr = typeof hashOrHeight === "number" ? "height" : "hash";
return /** @type {HTMLAnchorElement | null} */ (
blocksEl.querySelector(`[data-${attr}="${hashOrHeight}"]`)
);
}
export function deselectCube() {
if (selectedCube) selectedCube.classList.remove("selected");
selectedCube = null;
}
/** @param {HTMLAnchorElement} cube @param {{ scroll?: "smooth" | "instant", silent?: boolean }} [opts] */
export function selectCube(cube, { scroll, silent } = {}) {
const changed = cube !== selectedCube;
if (changed) {
if (selectedCube) selectedCube.classList.remove("selected");
selectedCube = cube;
cube.classList.add("selected");
}
if (scroll) {
cube.scrollIntoView({ behavior: scroll, block: "center", inline: "center" });
}
if (!silent) {
const hash = cube.dataset.hash;
if (hash) {
const block = blocksByHash.get(hash);
if (block) onSelect(block);
}
}
}
function clear() {
newestHeight = -1;
oldestHeight = Infinity;
loadingOlder = false;
loadingNewer = false;
reachedTip = false;
selectedCube = null;
blocksEl.innerHTML = "";
olderObserver.disconnect();
}
function observeOldestEdge() {
olderObserver.disconnect();
const oldest = blocksEl.firstElementChild;
if (oldest) olderObserver.observe(oldest);
}
/** @param {BlockInfoV1[]} blocks */
function appendNewerBlocks(blocks) {
if (!blocks.length) return false;
const anchor = blocksEl.lastElementChild;
const anchorRect = anchor?.getBoundingClientRect();
for (let i = blocks.length - 1; i >= 0; i--) {
const b = blocks[i];
if (b.height > newestHeight) {
appendCube(createBlockCube(b));
} else {
blocksByHash.set(b.id, b);
}
}
newestHeight = Math.max(newestHeight, blocks[0].height);
if (anchor && anchorRect) {
const r = anchor.getBoundingClientRect();
scrollEl.scrollTop += r.top - anchorRect.top;
scrollEl.scrollLeft += r.left - anchorRect.left;
}
return true;
}
/** @param {number | null} [height] @returns {Promise<BlockHash>} */
async function loadInitial(height) {
const blocks =
height != null
? await brk.getBlocksV1FromHeight(height)
: await brk.getBlocksV1();
clear();
for (const b of blocks) prependCube(createBlockCube(b));
newestHeight = blocks[0].height;
oldestHeight = blocks[blocks.length - 1].height;
reachedTip = height == null;
observeOldestEdge();
if (!reachedTip) await loadNewer();
return blocks[0].id;
}
/** @param {BlockHash | Height | null} [hashOrHeight] @returns {Promise<Height | null>} */
async function resolveHeight(hashOrHeight) {
if (typeof hashOrHeight === "number") return hashOrHeight;
if (typeof hashOrHeight === "string") {
const cached = blocksByHash.get(hashOrHeight);
if (cached) return cached.height;
const block = await brk.getBlockV1(hashOrHeight);
blocksByHash.set(hashOrHeight, block);
return block.height;
}
return null;
}
/** @param {BlockHash | Height | string | null} [hashOrHeight] @param {{ silent?: boolean }} [options] */
export async function goToCube(hashOrHeight, { silent } = {}) {
if (hashOrHeight === "tip") hashOrHeight = null;
if (typeof hashOrHeight === "string" && /^\d+$/.test(hashOrHeight)) {
hashOrHeight = Number(hashOrHeight);
}
let cube = findCube(hashOrHeight);
if (cube) {
selectCube(cube, { scroll: "smooth", silent });
return;
}
for (const cube of blocksEl.children) cube.classList.add("skeleton");
let startHash;
try {
const height = await resolveHeight(hashOrHeight);
startHash = await loadInitial(height);
} catch (e) {
try { startHash = await loadInitial(null); } catch (_) { return; }
}
selectCube(/** @type {HTMLAnchorElement} */ (findCube(startHash)), { scroll: "instant", silent });
}
export async function poll() {
if (!reachedTip) return;
brk.getMempoolBlocks()
.then(renderMempool)
.catch((e) => console.error("mempool poll:", e));
try {
const blocks = await brk.getBlocksV1();
appendNewerBlocks(blocks);
} catch (e) {
console.error("explorer poll:", e);
}
}
async function loadOlder() {
if (loadingOlder || oldestHeight <= 0) return;
loadingOlder = true;
try {
const blocks = await brk.getBlocksV1FromHeight(oldestHeight - 1);
for (const block of blocks) prependCube(createBlockCube(block));
if (blocks.length) {
oldestHeight = blocks[blocks.length - 1].height;
observeOldestEdge();
}
} catch (e) {
console.error("explorer loadOlder:", e);
}
loadingOlder = false;
}
async function loadNewer() {
if (loadingNewer || newestHeight === -1 || reachedTip) return;
loadingNewer = true;
try {
const prevNewest = newestHeight;
const blocks = await brk.getBlocksV1FromHeight(newestHeight + LOOKAHEAD);
if (!appendNewerBlocks(blocks) || newestHeight === prevNewest) {
reachedTip = true;
}
} catch (e) {
console.error("explorer loadNewer:", e);
}
loadingNewer = false;
}
/** @param {string} name */
const poolSlug = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
/** @param {number} unixSec */
function formatShortDate(unixSec) {
const d = new Date(unixSec * 1000);
return `${MONTHS[d.getMonth()]} ${d.getDate()}`;
}
/** @param {number} unixSec */
function formatHHMM(unixSec) {
const d = new Date(unixSec * 1000);
return [String(d.getHours()).padStart(2, "0"), String(d.getMinutes()).padStart(2, "0")];
}
/** @param {string} text @param {string} [cls] */
function span(text, cls) {
const s = document.createElement("span");
if (cls) s.classList.add(cls);
s.textContent = text;
return s;
}
/** @param {BlockInfoV1} block */
function createBlockCube(block) {
const cubeElement = document.createElement("a");
cubeElement.classList.add("cube");
cubeElement.href = `/block/${block.id}`;
cubeElement.dataset.hash = block.id;
cubeElement.dataset.height = String(block.height);
cubeElement.dataset.timestamp = String(block.timestamp);
const { pool, medianFee, feeRange, virtualSize } = block.extras;
const fill = Math.min(1, virtualSize / 1_000_000);
const { topFace, rightFace, leftFace } = createCube(cubeElement, fill);
blocksByHash.set(block.id, block);
onPlainClick(cubeElement, () => onCubeClick(cubeElement));
const minerName = pool.name;
// Top: short date / HH:MM (colon dimmed).
const dateP = document.createElement("p");
dateP.textContent = formatShortDate(block.timestamp);
const [hh, mm] = formatHHMM(block.timestamp);
const timeP = document.createElement("p");
timeP.append(hh, span(":", "dim"), mm);
topFace.append(dateP, timeP);
// Right: block height / raw pool-logo + miner name.
const heightP = document.createElement("p");
heightP.classList.add("height");
heightP.append(createHeightElement(block.height));
const poolDiv = document.createElement("div");
poolDiv.classList.add("pool");
const logo = document.createElement("img");
logo.src = `/assets/pools/${poolSlug(minerName)}.svg`;
logo.alt = "";
logo.onerror = () => {
logo.onerror = null;
logo.src = "/assets/pools/default.svg";
};
const nameSpan = document.createElement("span");
nameSpan.textContent = minerName.replace(/\s+(Pool|USA)$/i, "").trim();
poolDiv.append(logo, nameSpan);
rightFace.append(heightP, poolDiv);
// Left: ~median / min-max / sat/vB fees stack.
const feesEl = document.createElement("div");
feesEl.classList.add("fees");
const avg = document.createElement("p");
avg.append(span("~", "dim"), formatFeeRate(medianFee));
const range = document.createElement("p");
range.append(
formatFeeRate(feeRange[0]),
span("-", "dim"),
formatFeeRate(feeRange[6]),
);
const unit = document.createElement("p");
unit.classList.add("dim");
unit.textContent = "sat/vB";
feesEl.append(avg, range, unit);
leftFace.append(feesEl);
return cubeElement;
}
/** @param {HTMLElement} cube */
function setGap(cube) {
const prev = /** @type {HTMLElement | null} */ (cube.previousElementSibling);
if (!prev) return;
const dt = Math.max(0, Number(cube.dataset.timestamp) - Number(prev.dataset.timestamp));
cube.style.setProperty("--dt", String(dt));
}
/** @param {HTMLAnchorElement} cube */
function prependCube(cube) {
const next = /** @type {HTMLElement | null} */ (blocksEl.firstElementChild);
blocksEl.prepend(cube);
if (next) setGap(next);
}
/** @param {HTMLAnchorElement} cube */
function appendCube(cube) {
blocksEl.append(cube);
setGap(cube);
}
/** @param {"tip" | "gen"} label @param {string} href @param {string} title @param {() => void} handler */
function createControlLink(label, href, title, handler) {
const a = document.createElement("a");
a.classList.add("chain-edge", label);
a.href = href;
a.title = title;
a.textContent = label;
onPlainClick(a, handler);
return a;
}
-51
View File
@@ -1,51 +0,0 @@
/**
* HTML cube generator. Populates a .cube element with 15 face divs
* styled in explorer.css. Uses pure CSS transforms (no SVG); the
* earlier SVG-based implementation broke in Safari due to its
* long-standing bugs around SVG transforms on <foreignObject>.
*
* Face order = z-order:
* 3× .glass rear — translucent glass back faces
* 3× .liquid rear — opaque liquid backing (hidden at fill 0)
* 3× .liquid front — opaque liquid front (the visible 3 faces)
* 3× .glass front — translucent glass front
* 3× .face-text — text overlays (top / right / left)
*
* @param {HTMLElement} cube
* @param {number} [fill]
* @returns {{ topFace: HTMLDivElement, rightFace: HTMLDivElement, leftFace: HTMLDivElement }}
*/
export function createCube(cube, fill = 1) {
cube.style.setProperty("--fill", String(fill));
/** @param {...string} cls */
const face = (...cls) => {
const d = document.createElement("div");
d.className = `face ${cls.join(" ")}`;
return /** @type {HTMLDivElement} */ (d);
};
const topFace = face("face-text", "top");
const rightFace = face("face-text", "right");
const leftFace = face("face-text", "left");
cube.append(
face("glass", "bottom"),
face("glass", "rear-right"),
face("glass", "rear-left"),
face("liquid", "bottom"),
face("liquid", "rear-right"),
face("liquid", "rear-left"),
face("liquid", "right"),
face("liquid", "left"),
face("liquid", "top"),
face("glass", "right"),
face("glass", "left"),
face("glass", "top"),
rightFace,
leftFace,
topFace,
);
return { topFace, rightFace, leftFace };
}
-198
View File
@@ -1,198 +0,0 @@
import { explorerElement } from "../utils/elements.js";
import { brk } from "../utils/client.js";
import {
initChain,
goToCube,
poll,
selectCube,
deselectCube,
} from "../../src/explorer/chain/index.js";
import {
initBlockDetails,
update as updateBlock,
show as showBlock,
hide as hideBlock,
} from "./block.js";
import {
initTxDetails,
update as updateTx,
clear as clearTx,
show as showTx,
hide as hideTx,
} from "./tx.js";
import {
initAddrDetails,
update as updateAddr,
show as showAddr,
hide as hideAddr,
} from "./address.js";
/** @type {number | undefined} */ let pollInterval;
let navController = new AbortController();
let lastLoadedUrl = "";
function navigate() {
navController.abort();
navController = new AbortController();
lastLoadedUrl = window.location.pathname;
return navController.signal;
}
function showPanel(/** @type {"block" | "tx" | "addr"} */ which) {
which === "block" ? showBlock() : hideBlock();
which === "tx" ? showTx() : hideTx();
which === "addr" ? showAddr() : hideAddr();
}
/** @param {MouseEvent} e */
function handleLinkClick(e) {
const a = /** @type {HTMLAnchorElement | null} */ (
/** @type {HTMLElement} */ (e.target).closest("a[href]")
);
if (!a) return;
const m = a.pathname.match(/^\/(block|tx|address)\/(.+)/);
if (!m) return;
e.preventDefault();
history.pushState(null, "", a.href);
if (m[1] === "block") {
navigateToBlock(m[2]);
} else if (m[1] === "tx") {
navigateToTx(m[2]);
} else {
navigateToAddr(m[2]);
}
}
/** @param {{ onChange: (cb: (option: Option) => void) => void }} selected */
export function init(selected) {
initChain(explorerElement, {
onSelect: (block) => {
updateBlock(block);
showPanel("block");
},
onCubeClick: (cube) => {
history.pushState(null, "", cube.href);
navigate();
selectCube(cube);
},
onTip: () => {
history.pushState(null, "", "/block/tip");
navigate();
goToCube(null);
},
onGenesis: () => {
history.pushState(null, "", "/block/0");
navigate();
goToCube(0);
},
});
initBlockDetails(explorerElement, handleLinkClick);
initTxDetails(explorerElement, handleLinkClick);
initAddrDetails(explorerElement, handleLinkClick);
new MutationObserver(() => {
if (explorerElement.hidden) stopPolling();
else startPolling();
}).observe(explorerElement, {
attributes: true,
attributeFilter: ["hidden"],
});
document.addEventListener("visibilitychange", () => {
if (!document.hidden && !explorerElement.hidden) poll();
});
selected.onChange((option) => {
if (option.kind === "explorer") {
const url = window.location.pathname;
if (url !== lastLoadedUrl) load();
}
});
}
function startPolling() {
stopPolling();
poll();
pollInterval = setInterval(poll, 1_000);
}
function stopPolling() {
if (pollInterval !== undefined) {
clearInterval(pollInterval);
pollInterval = undefined;
}
}
async function load() {
const signal = navigate();
try {
const [kind, value] = window.location.pathname.split("/").filter(Boolean);
if (kind === "tx" && value) {
const txid = await resolveTxid(value, { signal });
if (signal.aborted) return;
const tx = await brk.getTx(txid, { signal });
if (signal.aborted) return;
await goToCube(tx.status?.blockHash ?? tx.status?.blockHeight ?? null, { silent: true });
updateTx(tx);
showPanel("tx");
return;
}
if (kind === "address" && value) {
await goToCube(null, { silent: true });
navigateToAddr(value);
return;
}
await goToCube(kind === "block" ? value : null);
} catch (e) {
if (signal.aborted) return;
console.error("explorer load:", e);
await goToCube();
showPanel("block");
}
}
/** @param {string} hashOrHeight */
async function navigateToBlock(hashOrHeight) {
navigate();
await goToCube(hashOrHeight);
}
/** @param {Txid | TxIndex} value @param {{ signal?: AbortSignal }} [options] */
async function resolveTxid(value, { signal } = {}) {
return typeof value === "number" || /^\d+$/.test(value)
? await brk.getTxByIndex(Number(value), { signal })
: value;
}
/** @param {Txid | TxIndex} txidOrIndex */
async function navigateToTx(txidOrIndex) {
const signal = navigate();
clearTx();
showPanel("tx");
try {
const txid = await resolveTxid(txidOrIndex, { signal });
if (signal.aborted) return;
const tx = await brk.getTx(txid, { signal });
if (signal.aborted) return;
await goToCube(tx.status?.blockHash ?? tx.status?.blockHeight ?? null, { silent: true });
updateTx(tx);
} catch (e) {
if (!signal.aborted) {
console.error("explorer tx:", e);
await goToCube();
showPanel("block");
}
}
}
/** @param {string} address */
function navigateToAddr(address) {
const signal = navigate();
deselectCube();
updateAddr(address, signal);
showPanel("addr");
}
-88
View File
@@ -1,88 +0,0 @@
import { createCube } from "./cube.js";
import { formatFeeRate } from "./render.js";
import { createSpan } from "../utils/dom.js";
const NUM_BLOCKS = 8;
/**
* @typedef {{
* el: HTMLElement,
* topFace: HTMLDivElement,
* rightFace: HTMLDivElement,
* leftFace: HTMLDivElement,
* }} Cube
*/
/** @type {HTMLDivElement | null} */ let mempoolBlocksEl = null;
/** @type {Cube[]} */ const cubes = [];
/** @param {HTMLElement} parent the `.chain-scroll` element */
export function initMempool(parent) {
mempoolBlocksEl = document.createElement("div");
mempoolBlocksEl.classList.add("mempool-blocks");
mempoolBlocksEl.hidden = true;
parent.prepend(mempoolBlocksEl);
}
/** @param {MempoolBlock[]} blocks */
export function renderMempool(blocks) {
if (!mempoolBlocksEl) return;
mempoolBlocksEl.hidden = blocks.length === 0;
const want = Math.min(blocks.length, NUM_BLOCKS);
while (cubes.length > want) {
const last = cubes.pop();
if (last) last.el.remove();
}
while (cubes.length < want) {
const cube = createMempoolCube(cubes.length);
cubes.push(cube);
mempoolBlocksEl.append(cube.el);
}
for (let i = 0; i < want; i++) updateMempoolCube(cubes[i], blocks[i], i);
}
/** @param {number} position @returns {Cube} */
function createMempoolCube(position) {
const el = document.createElement("div");
el.classList.add("cube", "projected");
if (position === 0) el.classList.add("next");
const { topFace, rightFace, leftFace } = createCube(el, 0);
return { el, topFace, rightFace, leftFace };
}
/**
* @param {Cube} cube
* @param {MempoolBlock} block
* @param {number} position
*/
function updateMempoolCube(cube, block, position) {
const fill = Math.min(1, block.blockVSize / 1_000_000);
cube.el.style.setProperty("--fill", String(fill));
cube.topFace.textContent = "";
const label = document.createElement("p");
label.textContent = position === 0 ? "next" : `+${position}`;
cube.topFace.append(label);
cube.rightFace.textContent = "";
const txs = document.createElement("p");
txs.textContent = block.nTx.toLocaleString();
const txsUnit = document.createElement("p");
txsUnit.classList.add("dim");
txsUnit.textContent = block.nTx === 1 ? "tx" : "txs";
cube.rightFace.append(txs, txsUnit);
cube.leftFace.textContent = "";
const median = document.createElement("p");
const tilde = createSpan("~");
tilde.classList.add("dim");
median.append(tilde, formatFeeRate(block.medianFee));
const range = document.createElement("p");
const dash = createSpan("-");
dash.classList.add("dim");
range.append(formatFeeRate(block.feeRange[0]), dash, formatFeeRate(block.feeRange[6]));
const unit = document.createElement("p");
unit.classList.add("dim");
unit.textContent = "sat/vB";
cube.leftFace.append(median, range, unit);
}
-232
View File
@@ -1,232 +0,0 @@
export const TX_PAGE_SIZE = 25;
/** @param {HTMLElement} el */
export function showPanel(el) {
el.hidden = false;
el.scrollTop = 0;
}
/** @param {HTMLElement} el */
export function hidePanel(el) {
el.hidden = true;
}
/** @param {number} sats */
export function formatBtc(sats) {
return (sats / 1e8).toFixed(8);
}
/** @param {number} rate */
export function formatFeeRate(rate) {
if (rate >= 1_000_000) return `${(rate / 1_000_000).toFixed(1)}M`;
if (rate >= 100_000) return `${Math.round(rate / 1_000)}k`;
if (rate >= 1_000) return `${(rate / 1_000).toFixed(1)}k`;
if (rate >= 100) return Math.round(rate).toLocaleString();
if (rate >= 10) return rate.toFixed(1);
return rate.toFixed(2);
}
/** @param {string} text @param {HTMLElement} el */
export function setAddrContent(text, el) {
el.textContent = "";
if (text.length <= 6) {
el.textContent = text;
return;
}
const head = document.createElement("span");
head.classList.add("addr-head");
head.textContent = text.slice(0, -6);
const tail = document.createElement("span");
tail.classList.add("addr-tail");
tail.textContent = text.slice(-6);
el.append(head, tail);
}
/** @param {number} height */
export function formatHeightPrefix(height) {
return "#" + "0".repeat(Math.max(0, 7 - String(height).length));
}
/** @param {number} height */
export function createHeightElement(height) {
const container = document.createElement("span");
const str = height.toString();
const prefix = document.createElement("span");
prefix.classList.add("dim");
prefix.style.userSelect = "none";
prefix.textContent = formatHeightPrefix(height);
const num = document.createElement("span");
num.textContent = str;
container.append(prefix, num);
return container;
}
/**
* @param {string} label
* @param {boolean} [isLink]
* @returns {{ row: HTMLDivElement, valueEl: HTMLElement }}
*/
export function createRow(label, isLink = false) {
const row = document.createElement("div");
row.classList.add("row");
const labelEl = document.createElement("span");
labelEl.classList.add("label");
labelEl.textContent = label;
const valueEl = document.createElement(isLink ? "a" : "span");
valueEl.classList.add("value");
row.append(labelEl, valueEl);
return { row, valueEl };
}
/**
* @param {[string, string, (string | null)?][]} rows
* @param {HTMLElement} parent
*/
export function renderRows(rows, parent) {
for (const [label, value, href] of rows) {
const { row, valueEl } = createRow(label, Boolean(href));
valueEl.textContent = value;
if (href) /** @type {HTMLAnchorElement} */ (valueEl).href = href;
parent.append(row);
}
}
/**
* @param {TxIn} vin
* @param {string} [coinbaseAscii]
*/
function renderInput(vin, coinbaseAscii) {
const row = document.createElement("div");
row.classList.add("tx-io");
const addr = document.createElement("span");
addr.classList.add("addr");
if (vin.isCoinbase) {
addr.textContent = "Coinbase";
addr.classList.add("coinbase");
if (coinbaseAscii) {
const sig = document.createElement("div");
sig.classList.add("coinbase-sig");
sig.textContent = coinbaseAscii;
row.append(sig);
}
} else {
const addrStr = /** @type {string | undefined} */ (
/** @type {any} */ (vin.prevout)?.scriptpubkey_address
);
if (addrStr) {
const link = document.createElement("a");
link.href = `/address/${addrStr}`;
setAddrContent(addrStr, link);
addr.append(link);
} else {
addr.textContent = "Unknown";
}
}
const amt = document.createElement("span");
amt.classList.add("amount");
amt.textContent = vin.prevout ? `${formatBtc(vin.prevout.value)} BTC` : "";
row.append(addr, amt);
return row;
}
/** @param {TxOut} vout */
function renderOutput(vout) {
const row = document.createElement("div");
row.classList.add("tx-io");
const addr = document.createElement("span");
addr.classList.add("addr");
const type = /** @type {string | undefined} */ (
/** @type {any} */ (vout).scriptpubkey_type
);
const a = /** @type {string | undefined} */ (
/** @type {any} */ (vout).scriptpubkey_address
);
if (type === "op_return") {
addr.textContent = "OP_RETURN";
addr.classList.add("op-return");
} else if (a) {
const link = document.createElement("a");
link.href = `/address/${a}`;
setAddrContent(a, link);
addr.append(link);
} else {
setAddrContent(vout.scriptpubkey, addr);
}
const amt = document.createElement("span");
amt.classList.add("amount");
amt.textContent = `${formatBtc(vout.value)} BTC`;
row.append(addr, amt);
return row;
}
/**
* @template T
* @param {T[]} items
* @param {(item: T) => HTMLElement} render
* @param {HTMLElement} container
*/
function renderCapped(items, render, container, max = 10) {
const limit = Math.min(items.length, max);
for (let i = 0; i < limit; i++) container.append(render(items[i]));
if (items.length > max) {
const btn = document.createElement("button");
btn.classList.add("show-more");
btn.textContent = `Show ${items.length - max} more`;
btn.addEventListener("click", () => {
btn.remove();
for (let i = max; i < items.length; i++) container.append(render(items[i]));
});
container.append(btn);
}
}
/** @param {Transaction} tx @param {string} [coinbaseAscii] */
export function renderTx(tx, coinbaseAscii) {
const el = document.createElement("div");
el.classList.add("tx");
const head = document.createElement("div");
head.classList.add("tx-head");
const txidEl = document.createElement("a");
txidEl.classList.add("txid");
txidEl.textContent = tx.txid;
txidEl.href = `/tx/${tx.txid}`;
head.append(txidEl);
if (tx.status?.blockTime) {
const time = document.createElement("span");
time.classList.add("tx-time");
time.textContent = new Date(tx.status.blockTime * 1000).toLocaleString();
head.append(time);
}
el.append(head);
const body = document.createElement("div");
body.classList.add("tx-body");
const inputs = document.createElement("div");
inputs.classList.add("tx-inputs");
renderCapped(tx.vin, (vin) => renderInput(vin, coinbaseAscii), inputs);
const outputs = document.createElement("div");
outputs.classList.add("tx-outputs");
renderCapped(tx.vout, renderOutput, outputs);
const totalOut = tx.vout.reduce((s, v) => s + v.value, 0);
body.append(inputs, outputs);
el.append(body);
const foot = document.createElement("div");
foot.classList.add("tx-foot");
const feeInfo = document.createElement("span");
const vsize = Math.ceil(tx.weight / 4);
const feeRate = vsize > 0 ? tx.fee / vsize : 0;
feeInfo.textContent = `${formatFeeRate(feeRate)} sat/vB \u2013 ${tx.fee.toLocaleString()} sats`;
const total = document.createElement("span");
total.classList.add("amount", "total");
total.textContent = `${formatBtc(totalOut)} BTC`;
foot.append(feeInfo, total);
el.append(foot);
return el;
}
-80
View File
@@ -1,80 +0,0 @@
import { formatBtc, formatFeeRate, renderRows, renderTx, showPanel, hidePanel } from "./render.js";
/** @type {HTMLDivElement} */ let el;
/** @param {HTMLElement} parent @param {(e: MouseEvent) => void} linkHandler */
export function initTxDetails(parent, linkHandler) {
el = document.createElement("div");
el.id = "tx-details";
el.hidden = true;
parent.append(el);
el.addEventListener("click", linkHandler);
}
export function show() { showPanel(el); }
export function hide() { hidePanel(el); }
export function clear() {
if (el.children.length) {
el.querySelector(".transactions")?.remove();
for (const v of el.querySelectorAll(".row .value")) {
v.classList.add("dim");
}
} else {
const title = document.createElement("h1");
title.textContent = "Transaction";
el.append(title);
}
}
/** @param {Transaction} tx */
export function update(tx) {
el.innerHTML = "";
const title = document.createElement("h1");
title.textContent = "Transaction";
el.append(title);
const vsize = Math.ceil(tx.weight / 4);
const feeRate = vsize > 0 ? tx.fee / vsize : 0;
const totalIn = tx.vin.reduce((s, v) => s + (v.prevout?.value ?? 0), 0);
const totalOut = tx.vout.reduce((s, v) => s + v.value, 0);
renderRows(
[
["TXID", tx.txid],
[
"Status",
tx.status?.confirmed
? `Confirmed (block ${tx.status.blockHeight?.toLocaleString()})`
: "Unconfirmed",
tx.status?.blockHash ? `/block/${tx.status.blockHash}` : null,
],
[
"Timestamp",
tx.status?.blockTime
? new Date(tx.status.blockTime * 1000).toUTCString()
: "Pending",
],
["Size", `${tx.size.toLocaleString()} B`],
["Virtual Size", `${vsize.toLocaleString()} vB`],
["Weight", `${tx.weight.toLocaleString()} WU`],
["Fee", `${tx.fee.toLocaleString()} sat`],
["Fee Rate", `${formatFeeRate(feeRate)} sat/vB`],
["Inputs", `${tx.vin.length}`],
["Outputs", `${tx.vout.length}`],
["Total Input", `${formatBtc(totalIn)} BTC`],
["Total Output", `${formatBtc(totalOut)} BTC`],
["Version", `${tx.version}`],
["Locktime", `${tx.locktime}`],
],
el,
);
const section = document.createElement("div");
section.classList.add("transactions");
const heading = document.createElement("h2");
heading.textContent = "Inputs & Outputs";
section.append(heading, renderTx(tx));
el.append(section);
}
-262
View File
@@ -1,262 +0,0 @@
import { initPrice, onPrice } from "./utils/price.js";
import { brk } from "./utils/client.js";
import { onFirstIntersection, getElementById, isHidden } from "./utils/dom.js";
import { initOptions } from "./options/full.js";
import {
init as initChart,
setOption as setChartOption,
} from "./panes/chart.js";
import { init as initExplorer } from "./explorer/index.js";
import { init as initSearch } from "./panes/search.js";
import { setOption as setHeatmapOption } from "../src/heatmap/index.js";
import { readStored, removeStored, writeToStorage } from "./utils/storage.js";
import {
asideElement,
asideLabelElement,
chartElement,
explorerElement,
frameSelectorsElement,
mainElement,
navElement,
navLabelElement,
searchElement,
layoutButtonElement,
heatmapElement,
style,
} from "./utils/elements.js";
import { idle } from "./utils/timing.js";
const DESKTOP_QUERY = window.matchMedia("(min-width: 768px)");
const SPLIT = "split";
function updateLayout() {
const pref = readStored("split-view") !== "false";
const wasSplit = isSplit();
document.documentElement.dataset.layout =
DESKTOP_QUERY.matches && pref ? "split" : "full";
if (isSplit() !== wasSplit) syncFrame();
}
function isSplit() {
return document.documentElement.dataset.layout === SPLIT;
}
function syncFrame() {
if (isSplit()) navLabelElement.click();
else asideLabelElement.click();
}
DESKTOP_QUERY.addEventListener("change", updateLayout);
updateLayout();
layoutButtonElement.addEventListener("click", () => {
writeToStorage("split-view", String(!isSplit()));
updateLayout();
});
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;
}
}
}
syncFrame();
}
initFrameSelectors();
initPrice(brk);
onPrice((price) => {
console.log("close:", price);
window.document.title = `${price.toLocaleString("en-us")} | bitview`;
});
const options = initOptions();
window.addEventListener("popstate", () => options.resolveUrl());
function initSelected() {
let firstRun = true;
function initSelectedFrame() {
if (!firstRun) throw Error("Unreachable");
firstRun = false;
let previousElement = /** @type {HTMLElement | undefined} */ (undefined);
let firstTimeLoadingChart = true;
let firstTimeLoadingExplorer = true;
options.selected.onChange((option) => {
/** @type {HTMLElement | undefined} */
let element;
console.log(option);
switch (option.kind) {
case "explorer": {
element = explorerElement;
if (firstTimeLoadingExplorer) {
initExplorer(options.selected);
}
firstTimeLoadingExplorer = false;
break;
}
case "heatmap": {
element = heatmapElement;
setHeatmapOption(option);
break;
}
case "chart": {
element = chartElement;
if (firstTimeLoadingChart) {
initChart();
}
firstTimeLoadingChart = false;
setChartOption(option);
break;
}
case "link": {
return;
}
}
if (!element) throw "Element should be set";
if (element !== previousElement) {
if (previousElement) previousElement.hidden = true;
element.hidden = false;
}
previousElement = element;
});
}
let firstMobileSwitch = true;
options.selected.onChange(() => {
if (!firstMobileSwitch && !isHidden(asideLabelElement)) {
asideLabelElement.click();
}
firstMobileSwitch = false;
});
onFirstIntersection(asideElement, initSelectedFrame);
}
initSelected();
idle(() => options.setParent(navElement));
onFirstIntersection(navElement, () => {
options.setParent(navElement);
navElement
.querySelector(`a[href="${window.document.location.pathname}"]`)
?.scrollIntoView({
behavior: "instant",
block: "center",
});
});
function initResizeBar() {
const bar = getElementById("resize-bar");
const key = "bar-width";
const root = document.documentElement;
const max = () =>
(parseFloat(style.getPropertyValue("--max-main-width")) / 100) *
window.innerWidth;
const saved = readStored(key);
if (saved) root.style.setProperty("--sidebar-width", `${saved}px`);
/** @param {number | null} width */
function setWidth(width) {
if (width != null) {
const clamped = Math.min(width, max());
root.style.setProperty("--sidebar-width", `${clamped}px`);
writeToStorage(key, String(clamped));
} else {
root.style.removeProperty("--sidebar-width");
removeStored(key);
}
}
bar.addEventListener("pointerdown", (e) => {
e.preventDefault();
bar.setPointerCapture(e.pointerId);
const startX = e.clientX;
const startW = mainElement.clientWidth;
document.documentElement.dataset.resize = "";
/** @param {PointerEvent} e */
function onMove(e) {
setWidth(startW + (e.clientX - startX));
}
function onUp() {
delete document.documentElement.dataset.resize;
bar.removeEventListener("pointermove", onMove);
bar.removeEventListener("pointerup", onUp);
bar.removeEventListener("pointercancel", onUp);
}
bar.addEventListener("pointermove", onMove);
bar.addEventListener("pointerup", onUp);
bar.addEventListener("pointercancel", onUp);
});
bar.addEventListener("dblclick", () => setWidth(null));
}
initResizeBar();
onFirstIntersection(searchElement, () => {
initSearch(options);
});
-1
View File
@@ -1 +0,0 @@
../../modules
-447
View File
@@ -1,447 +0,0 @@
import { colors } from "../utils/colors.js";
import { brk } from "../utils/client.js";
import { Unit } from "../utils/units.js";
import {
dots,
line,
price,
multiSeriesTree,
percentRatioDots,
sumsAndAveragesCumulative,
} from "./series.js";
import { satsBtcUsd, priceRatioPercentilesTree } from "./shared.js";
/**
* Create Cointime section
* @returns {PartialOptionsGroup}
*/
export function createCointimeSection() {
const { cointime, cohorts, supply } = brk.series;
const {
prices: cointimePrices,
cap,
activity,
supply: cointimeSupply,
adjusted,
reserveRisk,
value,
} = cointime;
const { all } = cohorts.utxo;
// Reference lines for cap comparisons
const capReferenceLines = /** @type {const} */ ([
{ series: supply.marketCap.usd, name: "Market", color: colors.default },
{
series: all.realized.cap.usd,
name: "Realized",
color: colors.realized,
},
]);
/** @type {readonly { pattern: PriceRatioPercentilesPattern, name: string, title: (name: string) => string, color: Color, defaultActive: boolean }[]} */
const prices = [
{
pattern: cointimePrices.trueMarketMean,
name: "True Market Mean",
title: (name) => name,
color: colors.trueMarketMean,
defaultActive: true,
},
{
pattern: cointimePrices.vaulted,
name: "Vaulted",
title: (name) => `${name} Price`,
color: colors.vaulted,
defaultActive: true,
},
{
pattern: cointimePrices.active,
name: "Active",
title: (name) => `${name} Price`,
color: colors.active,
defaultActive: true,
},
{
pattern: cointimePrices.cointime,
name: "Cointime",
title: (name) => `${name} Price`,
color: colors.cointime,
defaultActive: true,
},
];
const caps = /** @type {const} */ ([
{
series: cap.vaulted.usd,
name: "Vaulted",
color: colors.vaulted,
defaultActive: true,
},
{
series: cap.active.usd,
name: "Active",
color: colors.active,
defaultActive: true,
},
{
series: cap.cointime.usd,
name: "Cointime",
color: colors.cointime,
defaultActive: true,
},
{
series: cap.investor.usd,
name: "Investor",
color: colors.investor,
defaultActive: false,
},
{
series: cap.thermo.usd,
name: "Thermo",
color: colors.thermo,
defaultActive: false,
},
]);
const supplyBreakdown = /** @type {const} */ ([
{ pattern: all.supply.total, name: "Total", color: colors.bitcoin },
{
pattern: cointimeSupply.vaulted,
name: "Vaulted",
color: colors.vaulted,
},
{
pattern: cointimeSupply.active,
name: "Active",
color: colors.active,
},
]);
const coinblocks = /** @type {const} */ ([
{
pattern: activity.coinblocksDestroyed,
name: "Destroyed",
title: "Coinblocks Destroyed",
color: colors.destroyed,
},
{
pattern: activity.coinblocksCreated,
name: "Created",
title: "Coinblocks Created",
color: colors.created,
},
{
pattern: activity.coinblocksStored,
name: "Stored",
title: "Coinblocks Stored",
color: colors.stored,
},
]);
// Colors aligned with coinblocks: Destroyed=red, Created=orange, Stored=green
const cointimeValues = /** @type {const} */ ([
{
pattern: value.created,
name: "Created",
title: "Cointime Value Created",
color: colors.created,
},
{
pattern: value.destroyed,
name: "Destroyed",
title: "Cointime Value Destroyed",
color: colors.destroyed,
},
{
pattern: value.stored,
name: "Stored",
title: "Cointime Value Stored",
color: colors.stored,
},
]);
const vocdd = /** @type {const} */ ({
pattern: value.vocdd,
name: "VOCDD",
title: "Value of Coin Days Destroyed",
color: colors.vocdd,
});
return {
name: "Cointime",
tree: [
{
name: "Prices",
tree: [
{
name: "Compare",
title: "Cointime Prices",
top: [
price({
series: all.realized.price,
name: "Realized",
color: colors.realized,
}),
price({
series: all.realized.capitalized.price,
name: "Capitalized",
color: colors.capitalized,
}),
...prices.map(({ pattern, name, color, defaultActive }) =>
price({ series: pattern, name, color, defaultActive }),
),
],
},
...prices.map(({ pattern, name, title, color }) => ({
name,
tree: priceRatioPercentilesTree({
pattern,
title: title(name),
legend: name,
color,
priceReferences: [
price({
series: all.realized.price,
name: "Realized",
color: colors.realized,
defaultActive: false,
}),
],
}),
})),
],
},
{
name: "Caps",
tree: [
{
name: "Compare",
title: "Cointime Caps",
bottom: [
...capReferenceLines.map(({ series, name, color }) =>
line({ series, name, color, unit: Unit.usd }),
),
...caps.map(({ series, name, color, defaultActive }) =>
line({ series, name, color, defaultActive, unit: Unit.usd }),
),
],
},
...caps.map(({ series, name, color }) => ({
name,
title: `${name} Cap`,
bottom: [
line({ series, name, color, unit: Unit.usd }),
...capReferenceLines.map((ref) =>
line({
series: ref.series,
name: ref.name,
color: ref.color,
unit: Unit.usd,
}),
),
],
})),
],
},
{
name: "Supply",
title: "Active vs Vaulted Supply",
bottom: supplyBreakdown.flatMap(({ pattern, name, color }) =>
satsBtcUsd({ pattern, name, color }),
),
},
{
name: "Activity",
title: "Liveliness & Vaultedness",
bottom: [
line({
series: activity.liveliness,
name: "Liveliness",
color: colors.liveliness,
unit: Unit.ratio,
}),
line({
series: activity.vaultedness,
name: "Vaultedness",
color: colors.vaulted,
unit: Unit.ratio,
}),
line({
series: activity.ratio,
name: "Liveliness / Vaultedness",
color: colors.activity,
unit: Unit.ratio,
defaultActive: false,
}),
],
},
{
name: "Coinblocks",
tree: [
...multiSeriesTree({
entries: coinblocks.map(({ pattern, name, color }) => ({
name,
color,
average: pattern.average,
sum: pattern.sum,
cumulative: pattern.cumulative,
})),
metric: "Coinblocks",
unit: Unit.coinblocks,
}),
...coinblocks.map(({ pattern, name, title: metric, color }) => ({
name,
tree: sumsAndAveragesCumulative({
sum: pattern.sum,
average: pattern.average,
cumulative: pattern.cumulative,
metric,
unit: Unit.coinblocks,
color,
}),
})),
],
},
{
name: "Value",
tree: [
...multiSeriesTree({
entries: [
...cointimeValues.map(({ pattern, name, color }) => ({
name,
color,
average: pattern.average,
sum: pattern.sum,
cumulative: pattern.cumulative,
})),
{
name: vocdd.name,
color: vocdd.color,
average: vocdd.pattern.average,
sum: vocdd.pattern.sum,
cumulative: vocdd.pattern.cumulative,
},
],
metric: "Cointime Value",
unit: Unit.usd,
}),
...cointimeValues.map(({ pattern, name, title: metric, color }) => ({
name,
tree: sumsAndAveragesCumulative({
sum: pattern.sum,
average: pattern.average,
cumulative: pattern.cumulative,
metric,
unit: Unit.usd,
color,
}),
})),
{
name: vocdd.name,
tree: sumsAndAveragesCumulative({
sum: vocdd.pattern.sum,
average: vocdd.pattern.average,
cumulative: vocdd.pattern.cumulative,
metric: vocdd.title,
unit: Unit.usd,
color: vocdd.color,
}),
},
],
},
{
name: "Indicators",
tree: [
{
name: "AVIV",
title: "AVIV Ratio",
bottom: [
line({
series: cap.aviv.ratio,
name: "AVIV",
unit: Unit.ratio,
}),
],
},
{
name: "Reserve Risk",
title: "Reserve Risk",
bottom: [
line({
series: reserveRisk.value,
name: "Ratio",
color: colors.reserveRisk,
unit: Unit.ratio,
}),
],
},
],
},
{
name: "Adjusted",
tree: [
{
name: "Inflation",
title: "Cointime-Adjusted Inflation",
bottom: [
dots({
series: supply.inflationRate.percent,
name: "Base",
color: colors.base,
unit: Unit.percentage,
}),
...percentRatioDots({
pattern: adjusted.inflationRate,
name: "Cointime-Adjusted",
color: colors.adjusted,
}),
],
},
{
name: "BTC Velocity",
title: "Cointime-Adjusted BTC Velocity",
bottom: [
line({
series: supply.velocity.native,
name: "Base",
color: colors.base,
unit: Unit.ratio,
}),
line({
series: adjusted.txVelocityNative,
name: "Cointime-Adjusted",
color: colors.adjusted,
unit: Unit.ratio,
}),
],
},
{
name: "USD Velocity",
title: "Cointime-Adjusted USD Velocity",
bottom: [
line({
series: supply.velocity.fiat,
name: "Base",
color: colors.thermo,
unit: Unit.ratio,
}),
line({
series: adjusted.txVelocityFiat,
name: "Cointime-Adjusted",
color: colors.vaulted,
unit: Unit.ratio,
}),
],
},
],
},
],
};
}
-53
View File
@@ -1,53 +0,0 @@
/** Constant helpers for creating price lines and reference lines */
import { colors } from "../utils/colors.js";
import { brk } from "../utils/client.js";
import { line } from "./series.js";
/**
* Get constant pattern by number dynamically from tree
* Examples: 0 → _0, 38.2 → _382, -1 → minus1
* @param {BrkClient["series"]["constants"]} constants
* @param {number} num
* @returns {AnySeriesPattern}
*/
export function getConstant(constants, num) {
const key =
num >= 0 ? `_${String(num).replace(".", "")}` : `minus${Math.abs(num)}`;
const constant = /** @type {AnySeriesPattern | undefined} */ (
/** @type {Record<string, AnySeriesPattern>} */ (constants)[key]
);
if (!constant) throw new Error(`Unknown constant: ${num} (key: ${key})`);
return constant;
}
/**
* Create a price line series (horizontal reference line)
* @param {{ number?: number, name?: string } & Omit<(Parameters<typeof line>)[0], 'name' | 'series'>} args
*/
export function priceLine(args) {
return line({
...args,
series: getConstant(brk.series.constants, args.number || 0),
name: args.name || `${args.number ?? 0}`,
color: args.color ?? colors.gray,
options: {
lineStyle: args.style ?? 4,
lastValueVisible: false,
crosshairMarkerVisible: false,
...args.options,
},
});
}
/**
* @param {{ numbers: number[] } & Omit<(Parameters<typeof priceLine>)[0], 'number'>} args
*/
export function priceLines(args) {
return args.numbers.map((number) =>
priceLine({
...args,
number,
}),
);
}
@@ -1,740 +0,0 @@
/**
* Activity section builders
*
* Capabilities by cohort type:
* - All/STH: activity (full), SOPR (rolling + adjusted), sell side risk, value (flows + breakdown), coins
* - LTH: activity (full), SOPR (rolling), sell side risk, value (flows + breakdown), coins
* - AgeRange/MaxAge: activity (basic), SOPR (24h only), value (no flows/breakdown), coins
* - Others (UtxoAmount, Empty, Address): no activity, value only
*/
import { Unit } from "../../utils/units.js";
import {
line,
baseline,
dotsBaseline,
percentRatio,
chartsFromCount,
averagesArray,
ROLLING_WINDOWS,
} from "../series.js";
import {
satsBtcUsd,
satsBtcUsdFullTree,
mapCohortsWithAll,
groupedWindowsCumulativeWithAll,
groupedWindowsCumulativeSatsBtcUsd,
} from "../shared.js";
import { colors } from "../../utils/colors.js";
// ============================================================================
// Shared Volume Helpers
// ============================================================================
/**
* @param {TransferVolumePattern} tv
* @param {Color} color
* @param {(name: string) => string} title
* @returns {PartialOptionsTree}
*/
function volumeTree(tv, color, title) {
return [
...satsBtcUsdFullTree({
pattern: tv,
title,
metric: "Transfer Volume",
color,
}),
{
name: "Profitability",
tree: [
...ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} Transfer Volume Profitability`),
bottom: [
...satsBtcUsd({
pattern: tv.inProfit.sum[w.key],
name: "In Profit",
color: colors.profit,
}),
...satsBtcUsd({
pattern: tv.inLoss.sum[w.key],
name: "In Loss",
color: colors.loss,
}),
],
})),
{
name: "Cumulative",
title: title("Cumulative Transfer Volume Profitability"),
bottom: [
...satsBtcUsd({
pattern: tv.inProfit.cumulative,
name: "In Profit",
color: colors.profit,
}),
...satsBtcUsd({
pattern: tv.inLoss.cumulative,
name: "In Loss",
color: colors.loss,
}),
],
},
{
name: "In Profit",
tree: satsBtcUsdFullTree({
pattern: tv.inProfit,
title,
metric: "Transfer Volume In Profit",
color: colors.profit,
}),
},
{
name: "In Loss",
tree: satsBtcUsdFullTree({
pattern: tv.inLoss,
title,
metric: "Transfer Volume In Loss",
color: colors.loss,
}),
},
],
},
];
}
/**
* @param {{ transferVolume: TransferVolumePattern }} activity
* @param {Color} color
* @param {(name: string) => string} title
* @returns {PartialOptionsGroup}
*/
function volumeFolder(activity, color, title) {
return { name: "Volume", tree: volumeTree(activity.transferVolume, color, title) };
}
/**
* @param {{ transferVolume: TransferVolumePattern }} activity
* @param {CountPattern<number>} adjustedTransferVolume
* @param {Color} color
* @param {(name: string) => string} title
* @returns {PartialOptionsGroup}
*/
function volumeFolderWithAdjusted(activity, adjustedTransferVolume, color, title) {
return {
name: "Volume",
tree: [
...volumeTree(activity.transferVolume, color, title),
{ name: "Adjusted", tree: chartsFromCount({ pattern: adjustedTransferVolume, title, metric: "Adjusted Transfer Volume", unit: Unit.usd }) },
],
};
}
// ============================================================================
// Shared SOPR Helpers
// ============================================================================
/**
* @param {RollingWindowPattern<number>} ratio
* @param {(name: string) => string} title
* @param {string} [prefix]
* @returns {PartialOptionsTree}
*/
function singleRollingSoprTree(ratio, title, prefix = "") {
return [
{
name: "Compare",
title: title(`${prefix}SOPR`),
bottom: ROLLING_WINDOWS.map((w) =>
baseline({
series: ratio[w.key],
name: w.name,
color: w.color,
unit: Unit.ratio,
base: 1,
}),
),
},
...ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} ${prefix}SOPR`.trim()),
bottom: [
baseline({
series: ratio[w.key],
name: "SOPR",
unit: Unit.ratio,
base: 1,
}),
],
})),
];
}
/**
* @param {CountPattern<number>} valueDestroyed
* @param {(name: string) => string} title
* @returns {PartialOptionsTree}
*/
function valueDestroyedTree(valueDestroyed, title) {
return chartsFromCount({ pattern: valueDestroyed, title, metric: "Value Destroyed", unit: Unit.usd });
}
/**
* @param {CountPattern<number>} valueDestroyed
* @param {(name: string) => string} title
* @returns {PartialOptionsGroup}
*/
function valueDestroyedFolder(valueDestroyed, title) {
return { name: "Value Destroyed", tree: valueDestroyedTree(valueDestroyed, title) };
}
/**
* @param {CountPattern<number>} valueDestroyed
* @param {CountPattern<number>} adjusted
* @param {(name: string) => string} title
* @returns {PartialOptionsGroup}
*/
function valueDestroyedFolderWithAdjusted(valueDestroyed, adjusted, title) {
return {
name: "Value Destroyed",
tree: [
...valueDestroyedTree(valueDestroyed, title),
{ name: "Adjusted", tree: chartsFromCount({ pattern: adjusted, title, metric: "Adjusted Value Destroyed", unit: Unit.usd }) },
],
};
}
// ============================================================================
// Shared Sell Side Risk Helpers
// ============================================================================
/**
* @param {SellSideRiskPattern} sellSideRisk
* @param {(name: string) => string} title
* @returns {PartialOptionsTree}
*/
function singleSellSideRiskTree(sellSideRisk, title) {
return [
{
name: "Compare",
title: title("Sell Side Risk"),
bottom: ROLLING_WINDOWS.flatMap((w) =>
percentRatio({
pattern: sellSideRisk[w.key],
name: w.name,
color: w.color,
}),
),
},
...ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} Sell Side Risk`),
bottom: percentRatio({
pattern: sellSideRisk[w.key],
name: "Sell Side Risk",
color: w.color,
}),
})),
];
}
// ============================================================================
// Single Cohort Activity Sections
// ============================================================================
/**
* Single activity tree items shared between WithAdjusted and basic
* @param {CohortAll | CohortFull | CohortLongTerm} cohort
* @param {(name: string) => string} title
* @param {PartialOptionsGroup} volumeItem
* @param {PartialOptionsGroup} soprFolder
* @param {PartialOptionsGroup} valueDestroyedItem
* @returns {PartialOptionsTree}
*/
function singleFullActivityTree(cohort, title, volumeItem, soprFolder, valueDestroyedItem) {
const { tree, color } = cohort;
return [
volumeItem,
soprFolder,
valueDestroyedItem,
{
name: "Coindays Destroyed",
tree: chartsFromCount({
pattern: tree.activity.coindaysDestroyed,
title,
metric: "Coindays Destroyed",
unit: Unit.coindays,
color,
}),
},
{
name: "Dormancy",
tree: averagesArray({
windows: tree.activity.dormancy,
title,
metric: "Dormancy",
unit: Unit.days,
}),
},
{
name: "Sell Side Risk",
tree: singleSellSideRiskTree(tree.realized.sellSideRiskRatio, title),
},
];
}
/** @param {{ cohort: CohortAll | CohortFull, title: (name: string) => string }} args */
export function createActivitySectionWithAdjusted({ cohort, title }) {
const { tree, color } = cohort;
const sopr = tree.realized.sopr;
return {
name: "Activity",
tree: singleFullActivityTree(cohort, title,
volumeFolderWithAdjusted(tree.activity, sopr.adjusted.transferVolume, color, title),
{
name: "SOPR",
tree: [
...singleRollingSoprTree(sopr.ratio, title),
{ name: "Adjusted", tree: singleRollingSoprTree(sopr.adjusted.ratio, title, "Adjusted ") },
],
},
valueDestroyedFolderWithAdjusted(sopr.valueDestroyed, sopr.adjusted.valueDestroyed, title),
),
};
}
/** @param {{ cohort: CohortFull | CohortLongTerm, title: (name: string) => string }} args */
export function createActivitySection({ cohort, title }) {
const { tree, color } = cohort;
return {
name: "Activity",
tree: singleFullActivityTree(cohort, title,
volumeFolder(tree.activity, color, title),
{ name: "SOPR", tree: singleRollingSoprTree(tree.realized.sopr.ratio, title) },
valueDestroyedFolder(tree.realized.sopr.valueDestroyed, title),
),
};
}
/**
* Activity section for cohorts with activity but basic realized (AgeRange/MaxAge — 24h SOPR only)
* @param {{ cohort: CohortAgeRange | CohortWithAdjusted, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createActivitySectionWithActivity({ cohort, title }) {
const { tree, color } = cohort;
const sopr = tree.realized.sopr;
return {
name: "Activity",
tree: [
volumeFolder(tree.activity, color, title),
{
name: "SOPR",
title: title("SOPR (24h)"),
bottom: [
dotsBaseline({
series: sopr.ratio._24h,
name: "SOPR",
unit: Unit.ratio,
base: 1,
}),
],
},
valueDestroyedFolder(sopr.valueDestroyed, title),
{
name: "Coindays Destroyed",
tree: chartsFromCount({
pattern: tree.activity.coindaysDestroyed,
title,
metric: "Coindays Destroyed",
unit: Unit.coindays,
color,
}),
},
],
};
}
/**
* Minimal activity section: volume only
* @param {{ cohort: CohortBasicWithMarketCap | CohortBasicWithoutMarketCap | CohortWithoutRelative | CohortAddr | AddrCohortObject, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createActivitySectionMinimal({ cohort, title }) {
return {
name: "Activity",
tree: satsBtcUsdFullTree({
pattern: cohort.tree.activity.transferVolume,
title,
metric: "Transfer Volume",
}),
};
}
/**
* Grouped minimal activity: volume
* @param {{ list: readonly (UtxoCohortObject | CohortWithoutRelative | CohortAddr | AddrCohortObject)[], all: CohortAll, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedActivitySectionMinimal({ list, all, title }) {
return {
name: "Activity",
tree: groupedWindowsCumulativeSatsBtcUsd({
list, all, title, metricTitle: "Transfer Volume",
getMetric: (c) => c.tree.activity.transferVolume,
}),
};
}
/**
* Grouped profitability folder (compare + in profit + in loss)
* @template {{ name: string, color: Color }} T
* @template {{ name: string, color: Color }} A
* @param {readonly T[]} list
* @param {A} all
* @param {(name: string) => string} title
* @param {(c: T | A) => { sum: Record<string, AnyValuePattern>, cumulative: AnyValuePattern }} getInProfit
* @param {(c: T | A) => { sum: Record<string, AnyValuePattern>, cumulative: AnyValuePattern }} getInLoss
* @returns {PartialOptionsTree}
*/
function groupedProfitabilityArray(list, all, title, getInProfit, getInLoss) {
return [
{
name: "In Profit",
tree: groupedWindowsCumulativeSatsBtcUsd({
list,
all,
title,
metricTitle: "Transfer Volume In Profit",
getMetric: (c) => getInProfit(c),
}),
},
{
name: "In Loss",
tree: groupedWindowsCumulativeSatsBtcUsd({
list,
all,
title,
metricTitle: "Transfer Volume In Loss",
getMetric: (c) => getInLoss(c),
}),
},
];
}
/**
* @template {{ name: string, color: Color }} T
* @template {{ name: string, color: Color }} A
* @param {readonly T[]} list
* @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
* @returns {PartialOptionsTree}
*/
function groupedVolumeTree(list, all, title, getTransferVolume) {
return [
...groupedWindowsCumulativeSatsBtcUsd({
list,
all,
title,
metricTitle: "Transfer Volume",
getMetric: (c) => getTransferVolume(c),
}),
...groupedProfitabilityArray(
list,
all,
title,
(c) => getTransferVolume(c).inProfit,
(c) => getTransferVolume(c).inLoss,
),
];
}
/**
* @template {{ name: string, color: Color }} T
* @template {{ name: string, color: Color }} A
* @param {readonly T[]} list
* @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
* @returns {PartialOptionsGroup}
*/
function groupedVolumeFolder(list, all, title, getTransferVolume) {
return { name: "Volume", tree: groupedVolumeTree(list, all, title, getTransferVolume) };
}
/**
* @template {{ name: string, color: Color }} T
* @template {{ name: string, color: Color }} A
* @param {readonly T[]} list
* @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<number>} getAdjustedTransferVolume
* @returns {PartialOptionsGroup}
*/
function groupedVolumeFolderWithAdjusted(list, all, title, getTransferVolume, getAdjustedTransferVolume) {
return {
name: "Volume",
tree: [
...groupedVolumeTree(list, all, title, getTransferVolume),
{
name: "Adjusted",
tree: groupedWindowsCumulativeWithAll({
list, all, title, metricTitle: "Adjusted Transfer Volume",
getWindowSeries: (c, key) => getAdjustedTransferVolume(c).sum[key],
getCumulativeSeries: (c) => getAdjustedTransferVolume(c).cumulative,
seriesFn: line, unit: Unit.usd,
}),
},
],
};
}
// ============================================================================
// Grouped SOPR Helpers
// ============================================================================
/**
* @template {{ color: Color, name: string }} T
* @template {{ color: Color, name: string }} A
* @param {readonly T[]} list
* @param {A} all
* @param {(item: T | A) => { _24h: AnySeriesPattern, _1w: AnySeriesPattern, _1m: AnySeriesPattern, _1y: AnySeriesPattern }} getRatio
* @param {(name: string) => string} title
* @param {string} [prefix]
* @returns {PartialOptionsTree}
*/
function groupedSoprCharts(list, all, getRatio, title, prefix = "") {
return ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} ${prefix}SOPR`.trim()),
bottom: mapCohortsWithAll(list, all, (c) =>
baseline({
series: getRatio(c)[w.key],
name: c.name,
color: c.color,
unit: Unit.ratio,
base: 1,
}),
),
}));
}
/**
* @template {{ name: string, color: Color }} T
* @template {{ name: string, color: Color }} A
* @param {readonly T[]} list
* @param {A} all
* @param {(name: string) => string} title
* @param {(c: T | A) => CountPattern<number>} getValueDestroyed
* @returns {PartialOptionsTree}
*/
function groupedValueDestroyedTree(list, all, title, getValueDestroyed) {
return groupedWindowsCumulativeWithAll({
list, all, title, metricTitle: "Value Destroyed",
getWindowSeries: (c, key) => getValueDestroyed(c).sum[key],
getCumulativeSeries: (c) => getValueDestroyed(c).cumulative,
seriesFn: line, unit: Unit.usd,
});
}
/**
* @template {{ name: string, color: Color }} T
* @template {{ name: string, color: Color }} A
* @param {readonly T[]} list
* @param {A} all
* @param {(name: string) => string} title
* @param {(c: T | A) => CountPattern<number>} getValueDestroyed
* @returns {PartialOptionsGroup}
*/
function groupedValueDestroyedFolder(list, all, title, getValueDestroyed) {
return { name: "Value Destroyed", tree: groupedValueDestroyedTree(list, all, title, getValueDestroyed) };
}
/**
* @template {{ name: string, color: Color }} T
* @template {{ name: string, color: Color }} A
* @param {readonly T[]} list
* @param {A} all
* @param {(name: string) => string} title
* @param {(c: T | A) => CountPattern<number>} getValueDestroyed
* @param {(c: T | A) => CountPattern<number>} getAdjustedValueDestroyed
* @returns {PartialOptionsGroup}
*/
function groupedValueDestroyedFolderWithAdjusted(list, all, title, getValueDestroyed, getAdjustedValueDestroyed) {
return {
name: "Value Destroyed",
tree: [
...groupedValueDestroyedTree(list, all, title, getValueDestroyed),
{ name: "Adjusted", tree: groupedValueDestroyedTree(list, all, title, getAdjustedValueDestroyed) },
],
};
}
// ============================================================================
// Grouped Activity Sections
// ============================================================================
/**
* Grouped activity tree items shared between WithAdjusted and basic
* @param {readonly (CohortFull | CohortLongTerm)[]} list
* @param {CohortAll} all
* @param {(name: string) => string} title
* @param {PartialOptionsGroup} volumeItem
* @param {PartialOptionsGroup} soprFolder
* @param {PartialOptionsGroup} valueDestroyedItem
* @returns {PartialOptionsTree}
*/
function groupedFullActivityTree(list, all, title, volumeItem, soprFolder, valueDestroyedItem) {
return [
volumeItem,
soprFolder,
valueDestroyedItem,
...groupedActivitySharedItems(list, all, title),
];
}
/** @param {{ list: readonly CohortFull[], all: CohortAll, title: (name: string) => string }} args */
export function createGroupedActivitySectionWithAdjusted({ list, all, title }) {
return {
name: "Activity",
tree: groupedFullActivityTree(list, all, title,
groupedVolumeFolderWithAdjusted(list, all, title, (c) => c.tree.activity.transferVolume, (c) => c.tree.realized.sopr.adjusted.transferVolume),
{
name: "SOPR",
tree: [
...groupedSoprCharts(list, all, (c) => c.tree.realized.sopr.ratio, title),
{ name: "Adjusted", tree: groupedSoprCharts(list, all, (c) => c.tree.realized.sopr.adjusted.ratio, title, "Adjusted ") },
],
},
groupedValueDestroyedFolderWithAdjusted(list, all, title, (c) => c.tree.realized.sopr.valueDestroyed, (c) => c.tree.realized.sopr.adjusted.valueDestroyed),
),
};
}
/** @param {{ list: readonly (CohortFull | CohortLongTerm)[], all: CohortAll, title: (name: string) => string }} args */
export function createGroupedActivitySection({ list, all, title }) {
return {
name: "Activity",
tree: groupedFullActivityTree(list, all, title,
groupedVolumeFolder(list, all, title, (c) => c.tree.activity.transferVolume),
{ name: "SOPR", tree: groupedSoprCharts(list, all, (c) => c.tree.realized.sopr.ratio, title) },
groupedValueDestroyedFolder(list, all, title, (c) => c.tree.realized.sopr.valueDestroyed),
),
};
}
/**
* Shared grouped activity items: coindays, dormancy, sell side risk
* @param {readonly (CohortFull | CohortLongTerm)[]} list
* @param {CohortAll} all
* @param {(name: string) => string} title
* @returns {PartialOptionsTree}
*/
function groupedActivitySharedItems(list, all, title) {
return [
{
name: "Coindays Destroyed",
tree: groupedWindowsCumulativeWithAll({
list,
all,
title,
metricTitle: "Coindays Destroyed",
getWindowSeries: (c, key) => c.tree.activity.coindaysDestroyed.sum[key],
getCumulativeSeries: (c) =>
c.tree.activity.coindaysDestroyed.cumulative,
seriesFn: line,
unit: Unit.coindays,
}),
},
{
name: "Dormancy",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} Dormancy`),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
line({
series: tree.activity.dormancy[w.key],
name,
color,
unit: Unit.days,
}),
),
})),
},
{
name: "Sell Side Risk",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} Sell Side Risk`),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
line({
series: tree.realized.sellSideRiskRatio[w.key].ratio,
name,
color,
unit: Unit.ratio,
}),
),
})),
},
];
}
/**
* Grouped activity for cohorts with activity but basic realized (AgeRange/MaxAge)
* @param {{ list: readonly (CohortAgeRange | CohortWithAdjusted)[], all: CohortAll, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedActivitySectionWithActivity({ list, all, title }) {
return {
name: "Activity",
tree: [
groupedVolumeFolder(list, all, title, (c) => c.tree.activity.transferVolume),
{
name: "SOPR",
title: title("SOPR (24h)"),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
baseline({
series: tree.realized.sopr.ratio._24h,
name,
color,
unit: Unit.ratio,
base: 1,
}),
),
},
groupedValueDestroyedFolder(list, all, title, (c) => c.tree.realized.sopr.valueDestroyed),
{
name: "Coindays Destroyed",
tree: [
...ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} Coindays Destroyed`),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
line({
series: tree.activity.coindaysDestroyed.sum[w.key],
name,
color,
unit: Unit.coindays,
}),
),
})),
{
name: "Cumulative",
title: title("Cumulative Coindays Destroyed"),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
line({
series: tree.activity.coindaysDestroyed.cumulative,
name,
color,
unit: Unit.coindays,
}),
),
},
],
},
],
};
}
@@ -1,211 +0,0 @@
/**
* Cost Basis section builders
*
* Structure:
* - Per Coin: sats-weighted (profitability + distribution)
* - Per Dollar: value-weighted (profitability + distribution)
* - Profitability: cross-cutting (per coin + per dollar on same chart)
* - Supply Density: cost basis supply density percentage
*
* Only for cohorts WITH costBasis (All, STH, LTH)
*/
import { colors } from "../../utils/colors.js";
import { entries } from "../../utils/array.js";
import { price, percentRatio } from "../series.js";
import { mapCohortsWithAll, flatMapCohortsWithAll } from "../shared.js";
const ACTIVE_PCTS = new Set(["pct75", "pct50", "pct25"]);
/**
* @param {PercentilesPattern} p
* @param {(name: string) => string} [n]
* @returns {FetchedPriceSeriesBlueprint[]}
*/
function percentileSeries(p, n = (x) => x) {
return entries(p)
.reverse()
.map(([key, s], i, arr) =>
price({
series: s,
name: n(key.replace("pct", "P")),
color: colors.at(i, arr.length),
...(ACTIVE_PCTS.has(key) ? {} : { defaultActive: false }),
}),
);
}
// ============================================================================
// Single cohort helpers
// ============================================================================
/**
* Per Coin or Per Dollar folder for a single cohort
* @param {Object} args
* @param {AnyPricePattern} args.avgPrice - realized price (per coin) or capitalized price (per dollar)
* @param {string} args.avgName
* @param {AnyPricePattern} args.inProfit
* @param {AnyPricePattern} args.inLoss
* @param {PercentilesPattern} args.percentiles
* @param {Color} args.color
* @param {string} args.weightLabel
* @param {(name: string) => string} args.title
* @param {AnyPricePattern} [args.min]
* @param {AnyPricePattern} [args.max]
* @returns {PartialOptionsTree}
*/
function singleWeightFolder({ avgPrice, avgName, inProfit, inLoss, percentiles, color, weightLabel, title, min, max }) {
return [
{
name: "Average",
title: title(`Cost Basis Average (${weightLabel})`),
top: [
price({ series: inProfit, name: "In Profit", color: colors.profit }),
price({ series: avgPrice, name: avgName, color }),
price({ series: inLoss, name: "In Loss", color: colors.loss }),
],
},
{
name: "Distribution",
title: title(`Cost Basis Distribution (${weightLabel})`),
top: [
price({ series: avgPrice, name: avgName, color }),
...(max ? [price({ series: max, name: "P100", color: colors.stat.max, defaultActive: false })] : []),
...percentileSeries(percentiles),
...(min ? [price({ series: min, name: "P0", color: colors.stat.min, defaultActive: false })] : []),
],
},
];
}
/**
* @param {{ cohort: CohortAll | CohortFull | CohortLongTerm, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createCostBasisSectionWithPercentiles({ cohort, title }) {
const { tree, color } = cohort;
const cb = tree.costBasis;
return {
name: "Cost Basis",
tree: [
{
name: "Per Coin",
tree: singleWeightFolder({
avgPrice: tree.realized.price, avgName: "All",
inProfit: cb.inProfit.perCoin, inLoss: cb.inLoss.perCoin,
percentiles: cb.perCoin, color, weightLabel: "BTC-weighted", title,
min: cb.min, max: cb.max,
}),
},
{
name: "Per Dollar",
tree: singleWeightFolder({
avgPrice: tree.realized.capitalized.price, avgName: "All",
inProfit: cb.inProfit.perDollar, inLoss: cb.inLoss.perDollar,
percentiles: cb.perDollar, color, weightLabel: "USD-weighted", title,
}),
},
{
name: "Supply Density",
title: title("Cost Basis Supply Density"),
bottom: percentRatio({ pattern: cb.supplyDensity, name: "Supply Density", color: colors.bitcoin }),
},
],
};
}
// ============================================================================
// Grouped cohort helpers
// ============================================================================
/**
* Per Coin or Per Dollar folder for grouped cohorts
* @param {Object} args
* @param {readonly (CohortAll | CohortFull | CohortLongTerm)[]} args.list
* @param {CohortAll} args.all
* @param {(c: CohortAll | CohortFull | CohortLongTerm) => AnyPricePattern} args.getAvgPrice
* @param {(c: CohortAll | CohortFull | CohortLongTerm) => AnyPricePattern} args.getInProfit
* @param {(c: CohortAll | CohortFull | CohortLongTerm) => AnyPricePattern} args.getInLoss
* @param {(c: CohortAll | CohortFull | CohortLongTerm) => PercentilesPattern} args.getPercentiles
* @param {string} args.avgTitle
* @param {string} args.weightLabel
* @param {(name: string) => string} args.title
* @returns {PartialOptionsTree}
*/
function groupedWeightFolder({ list, all, getAvgPrice, getInProfit, getInLoss, getPercentiles, avgTitle, weightLabel, title }) {
return [
{
name: "Average",
title: title(`Cost Basis ${avgTitle} (${weightLabel})`),
top: mapCohortsWithAll(list, all, (c) =>
price({ series: getAvgPrice(c), name: c.name, color: c.color }),
),
},
{
name: "In Profit",
title: title(`Cost Basis In Profit (${weightLabel})`),
top: mapCohortsWithAll(list, all, (c) =>
price({ series: getInProfit(c), name: c.name, color: c.color }),
),
},
{
name: "In Loss",
title: title(`Cost Basis In Loss (${weightLabel})`),
top: mapCohortsWithAll(list, all, (c) =>
price({ series: getInLoss(c), name: c.name, color: c.color }),
),
},
...(/** @type {const} */ ([
["pct50", "Median"],
["pct75", "Q3"],
["pct25", "Q1"],
])).map(([pct, label]) => ({
name: label,
title: title(`Cost Basis ${label} (${weightLabel})`),
top: mapCohortsWithAll(list, all, (c) =>
price({ series: getPercentiles(c)[pct], name: c.name, color: c.color }),
),
})),
];
}
/**
* @param {{ list: readonly (CohortAll | CohortFull | CohortLongTerm)[], all: CohortAll, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedCostBasisSectionWithPercentiles({ list, all, title }) {
return {
name: "Cost Basis",
tree: [
{
name: "Per Coin",
tree: groupedWeightFolder({
list, all, title,
getAvgPrice: (c) => c.tree.realized.price,
getInProfit: (c) => c.tree.costBasis.inProfit.perCoin,
getInLoss: (c) => c.tree.costBasis.inLoss.perCoin,
getPercentiles: (c) => c.tree.costBasis.perCoin,
avgTitle: "Average", weightLabel: "BTC-weighted",
}),
},
{
name: "Per Dollar",
tree: groupedWeightFolder({
list, all, title,
getAvgPrice: (c) => c.tree.realized.capitalized.price,
getInProfit: (c) => c.tree.costBasis.inProfit.perDollar,
getInLoss: (c) => c.tree.costBasis.inLoss.perDollar,
getPercentiles: (c) => c.tree.costBasis.perDollar,
avgTitle: "Average", weightLabel: "USD-weighted",
}),
},
{
name: "Supply Density",
title: title("Cost Basis Supply Density"),
bottom: flatMapCohortsWithAll(list, all, ({ name, color, tree }) =>
percentRatio({ pattern: tree.costBasis.supplyDensity, name, color }),
),
},
],
};
}
@@ -1,249 +0,0 @@
import { colors } from "../../utils/colors.js";
import { entries } from "../../utils/array.js";
import { brk } from "../../utils/client.js";
/** @type {readonly AddressableType[]} */
const ADDRESSABLE_TYPES = [
"p2a",
"p2tr",
"p2wsh",
"p2wpkh",
"p2sh",
"p2pkh",
"p2pk33",
"p2pk65",
];
/** @type {(key: SpendableType) => key is AddressableType} */
const isAddressable = (key) =>
/** @type {readonly string[]} */ (ADDRESSABLE_TYPES).includes(key);
export function buildCohortData() {
const utxoCohorts = brk.series.cohorts.utxo;
const addressCohorts = brk.series.cohorts.addr;
const { addrs } = brk.series;
const {
TERM_NAMES,
EPOCH_NAMES,
UNDER_AGE_NAMES,
OVER_AGE_NAMES,
AGE_RANGE_NAMES,
OVER_AMOUNT_NAMES,
UNDER_AMOUNT_NAMES,
AMOUNT_RANGE_NAMES,
SPENDABLE_TYPE_NAMES,
CLASS_NAMES,
PROFITABILITY_RANGE_NAMES,
PROFIT_NAMES,
LOSS_NAMES,
} = brk;
const cohortAll = {
name: "",
title: "",
color: colors.bitcoin,
tree: utxoCohorts.all,
addressCount: {
base: addrs.funded.all,
delta: addrs.delta.all,
},
avgAmount: addrs.avgAmount.all,
};
const shortNames = TERM_NAMES.short;
const termShort = {
name: shortNames.short,
title: shortNames.long,
color: colors.term.short,
tree: utxoCohorts.sth,
};
const longNames = TERM_NAMES.long;
const termLong = {
name: longNames.short,
title: longNames.long,
color: colors.term.long,
tree: utxoCohorts.lth,
};
// Under age cohorts
const underAge = entries(UNDER_AGE_NAMES).map(([key, names], i, arr) => ({
name: names.short,
title: `UTXOs ${names.long}`,
color: colors.at(i, arr.length),
tree: utxoCohorts.underAge[key],
}));
// Over age cohorts
const overAge = entries(OVER_AGE_NAMES).map(([key, names], i, arr) => ({
name: names.short,
title: `UTXOs ${names.long}`,
color: colors.at(i, arr.length),
tree: utxoCohorts.overAge[key],
}));
const ageRange = entries(AGE_RANGE_NAMES).map(([key, names], i, arr) => ({
name: names.short,
title: `UTXOs ${names.long}`,
color: colors.at(i, arr.length),
tree: utxoCohorts.ageRange[key],
matured: utxoCohorts.matured[key],
}));
const epoch = entries(EPOCH_NAMES).map(([key, names], i, arr) => ({
name: names.short,
title: names.long,
color: colors.at(i, arr.length),
tree: utxoCohorts.epoch[key],
}));
const utxosOverAmount = entries(OVER_AMOUNT_NAMES).map(
([key, names], i, arr) => ({
name: names.short,
title: `UTXOs ${names.long}`,
color: colors.at(i, arr.length),
tree: utxoCohorts.overAmount[key],
}),
);
const addressesOverAmount = entries(OVER_AMOUNT_NAMES).map(
([key, names], i, arr) => {
const cohort = addressCohorts.overAmount[key];
return {
name: names.short,
title: `Addresses ${names.long}`,
color: colors.at(i, arr.length),
tree: cohort,
addressCount: cohort.addrCount,
};
},
);
const utxosUnderAmount = entries(UNDER_AMOUNT_NAMES).map(
([key, names], i, arr) => ({
name: names.short,
title: `UTXOs ${names.long}`,
color: colors.at(i, arr.length),
tree: utxoCohorts.underAmount[key],
}),
);
const addressesUnderAmount = entries(UNDER_AMOUNT_NAMES).map(
([key, names], i, arr) => {
const cohort = addressCohorts.underAmount[key];
return {
name: names.short,
title: `Addresses ${names.long}`,
color: colors.at(i, arr.length),
tree: cohort,
addressCount: cohort.addrCount,
};
},
);
const utxosAmountRange = entries(AMOUNT_RANGE_NAMES).map(
([key, names], i, arr) => ({
name: names.short,
title: `UTXOs ${names.long}`,
color: colors.at(i, arr.length),
tree: utxoCohorts.amountRange[key],
}),
);
const addressesAmountRange = entries(AMOUNT_RANGE_NAMES).map(
([key, names], i, arr) => {
const cohort = addressCohorts.amountRange[key];
return {
name: names.short,
title: `Addresses ${names.long}`,
color: colors.at(i, arr.length),
tree: cohort,
addressCount: cohort.addrCount,
};
},
);
const typeAddressable = ADDRESSABLE_TYPES.map((key) => {
const names = SPENDABLE_TYPE_NAMES[key];
return {
key,
name: names.short,
title: names.short,
color: colors.scriptType[key],
tree: utxoCohorts.type[key],
addressCount: {
base: addrs.funded[key],
delta: addrs.delta[key],
},
avgAmount: addrs.avgAmount[key],
exposed: addrs.exposed,
reused: addrs.reused,
respent: addrs.respent,
};
});
const typeOther = entries(SPENDABLE_TYPE_NAMES)
.filter(([key]) => !isAddressable(key))
.map(([key, names]) => ({
key,
name: names.short,
title: names.short,
color: colors.scriptType[key],
tree: utxoCohorts.type[key],
}));
const class_ = entries(CLASS_NAMES)
.reverse()
.map(([key, names], i, arr) => ({
name: names.short,
title: names.long,
color: colors.at(i, arr.length),
tree: utxoCohorts.class[key],
}));
const { range, profit, loss } = utxoCohorts.profitability;
const profitabilityRange = entries(PROFITABILITY_RANGE_NAMES).map(
([key, names], i, arr) => ({
name: names.short,
color: colors.at(i, arr.length),
pattern: range[key],
}),
);
const profitabilityProfit = entries(PROFIT_NAMES).map(
([key, names], i, arr) => ({
name: names.short,
color: colors.at(i, arr.length),
pattern: profit[key],
}),
);
const profitabilityLoss = entries(LOSS_NAMES).map(([key, names], i, arr) => ({
name: names.short,
color: colors.at(i, arr.length),
pattern: loss[key],
}));
return {
cohortAll,
termShort,
termLong,
underAge,
overAge,
ageRange,
epoch,
utxosOverAmount,
addressesOverAmount,
utxosUnderAmount,
addressesUnderAmount,
utxosAmountRange,
addressesAmountRange,
typeAddressable,
typeOther,
class: class_,
profitabilityRange,
profitabilityProfit,
profitabilityLoss,
};
}
@@ -1,762 +0,0 @@
/**
* Holdings section builders
*
* Supply pattern capabilities by cohort type:
* - DeltaHalfInRelTotalPattern2 (STH/LTH): inProfit + inLoss + dominance + share
* - SeriesTree_Cohorts_Utxo_All_Supply (All): inProfit + inLoss + share (no dominance)
* - DeltaHalfInRelTotalPattern (AgeRange/MaxAge/Epoch): inProfit + inLoss + dominance (no share)
* - DeltaHalfInTotalPattern2 (Type.*): inProfit + inLoss (no rel)
* - DeltaHalfTotalPattern (Empty/UtxoAmount/AddrAmount): total + half only
*/
import { Unit } from "../../utils/units.js";
import {
ROLLING_WINDOWS,
line,
baseline,
sumsTreeBaseline,
amountSumsTreeBaseline,
rollingPercentRatioTree,
percentRatio,
percentRatioBaseline,
chartsFromCount,
} from "../series.js";
import {
amountBaseline,
satsBtcUsd,
flatMapCohorts,
mapCohortsWithAll,
flatMapCohortsWithAll,
groupedWindowsCumulativeWithAll,
} from "../shared.js";
import { colors } from "../../utils/colors.js";
import { priceLine } from "../constants.js";
/**
* Simple supply series (total + half only, no profit/loss)
* @param {{ total: AnyValuePattern }} supply
* @returns {AnyFetchedSeriesBlueprint[]}
*/
function simpleSupplySeries(supply) {
return satsBtcUsd({
pattern: supply.total,
name: "Total",
});
}
/**
* @param {readonly (UtxoCohortObject | CohortWithoutRelative)[]} list
* @param {CohortAll} all
* @param {(name: string) => string} title
*/
function groupedOutputsFolder(list, all, title) {
return {
name: "Outputs",
tree: [
{
name: "Unspent",
tree: [
{
name: "Count",
title: title("UTXO Count"),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
line({ series: tree.outputs.unspentCount.base, name, color, unit: Unit.count }),
),
},
...groupedDeltaItems(list, all, (c) => c.tree.outputs.unspentCount.delta, Unit.count, title, "UTXO Count"),
],
},
{
name: "Spent",
tree: groupedWindowsCumulativeWithAll({
list, all, title, metricTitle: "Spent UTXO Count",
getWindowSeries: (c, key) => c.tree.outputs.spentCount.sum[key],
getCumulativeSeries: (c) => c.tree.outputs.spentCount.cumulative,
seriesFn: line, unit: Unit.count,
}),
},
{
name: "Spending Rate",
title: title("Spending Rate"),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
line({ series: tree.outputs.spendingRate, name, color, unit: Unit.ratio }),
),
},
],
};
}
/**
* @param {{ absolute: { _24h: AnySeriesPattern, _1w: AnySeriesPattern, _1m: AnySeriesPattern, _1y: AnySeriesPattern }, rate: { _24h: { percent: AnySeriesPattern, ratio: AnySeriesPattern }, _1w: { percent: AnySeriesPattern, ratio: AnySeriesPattern }, _1m: { percent: AnySeriesPattern, ratio: AnySeriesPattern }, _1y: { percent: AnySeriesPattern, ratio: AnySeriesPattern } } }} delta
* @param {Unit} unit
* @param {(name: string) => string} title
* @param {string} name
* @returns {PartialOptionsTree}
*/
function singleDeltaItems(delta, unit, title, name) {
return [
{
...sumsTreeBaseline({
windows: delta.absolute,
title,
metric: `${name} Change`,
unit,
legend: "Change",
}),
name: "Change",
},
{
...rollingPercentRatioTree({
windows: delta.rate,
title,
metric: `${name} Growth Rate`,
}),
name: "Growth Rate",
},
];
}
/**
* @template {{ name: string, color: Color }} T
* @template {{ name: string, color: Color }} A
* @param {readonly T[]} list
* @param {A} all
* @param {(c: T | A) => DeltaPattern} getDelta
* @param {Unit} unit
* @param {(name: string) => string} title
* @param {string} name
* @returns {PartialOptionsTree}
*/
function groupedDeltaItems(list, all, getDelta, unit, title, name) {
return [
{
name: "Change",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} ${name} Change`),
bottom: mapCohortsWithAll(list, all, (c) =>
baseline({
series: getDelta(c).absolute[w.key],
name: c.name,
color: c.color,
unit,
}),
),
})),
},
{
name: "Growth Rate",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} ${name} Growth Rate`),
bottom: flatMapCohortsWithAll(list, all, (c) =>
percentRatioBaseline({
pattern: getDelta(c).rate[w.key],
name: c.name,
color: c.color,
}),
),
})),
},
];
}
/**
* Amount-valued single-cohort delta: Change exposes sats + lazy btc per window.
* @param {AmountDeltaPattern} delta
* @param {(name: string) => string} title
* @param {string} name
* @returns {PartialOptionsTree}
*/
function singleAmountDeltaItems(delta, title, name) {
return [
{
...amountSumsTreeBaseline({
windows: delta.absolute,
title,
metric: `${name} Change`,
legend: "Change",
}),
name: "Change",
},
{
...rollingPercentRatioTree({
windows: delta.rate,
title,
metric: `${name} Growth Rate`,
}),
name: "Growth Rate",
},
];
}
/**
* Amount-valued grouped-cohort delta: Change exposes sats + lazy btc per window.
* @template {{ name: string, color: Color }} T
* @template {{ name: string, color: Color }} A
* @param {readonly T[]} list
* @param {A} all
* @param {(c: T | A) => AmountDeltaPattern} getDelta
* @param {(name: string) => string} title
* @param {string} name
* @returns {PartialOptionsTree}
*/
function groupedAmountDeltaItems(list, all, getDelta, title, name) {
return [
{
name: "Change",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} ${name} Change`),
bottom: flatMapCohortsWithAll(list, all, (c) =>
amountBaseline({
pattern: getDelta(c).absolute[w.key],
name: c.name,
color: c.color,
}),
),
})),
},
{
name: "Growth Rate",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} ${name} Growth Rate`),
bottom: flatMapCohortsWithAll(list, all, (c) =>
percentRatioBaseline({
pattern: getDelta(c).rate[w.key],
name: c.name,
color: c.color,
}),
),
})),
},
];
}
// ============================================================================
// Single Cohort Composable Builders
// ============================================================================
/**
* Amount chart: total + halved + in profit + in loss in sats/btc/usd.
* @param {{ total: AnyValuePattern, half: AnyValuePattern, inProfit: AnyValuePattern, inLoss: AnyValuePattern }} supply
* @param {(name: string) => string} title
* @returns {PartialChartOption}
*/
function profitabilityAmountChart(supply, title) {
return {
name: "Amount",
title: title("Supply Profitability"),
bottom: [
...satsBtcUsd({ pattern: supply.total, name: "Total", color: colors.default }),
...satsBtcUsd({ pattern: supply.inProfit, name: "In Profit", color: colors.profit }),
...satsBtcUsd({ pattern: supply.inLoss, name: "In Loss", color: colors.loss }),
...satsBtcUsd({ pattern: supply.half, name: "Halved", color: colors.gray, style: 4 }),
],
};
}
/**
* Composition chart: in profit / in loss as % of own supply.
* @param {{ inProfit: { share: { percent: AnySeriesPattern, ratio: AnySeriesPattern } }, inLoss: { share: { percent: AnySeriesPattern, ratio: AnySeriesPattern } } }} supply
* @param {(name: string) => string} title
* @returns {PartialChartOption}
*/
function profitabilityCompositionChart(supply, title) {
return {
name: "Composition",
title: title("Supply Profitability Composition"),
bottom: [
...percentRatio({ pattern: supply.inProfit.share, name: "In Profit", color: colors.profit }),
...percentRatio({ pattern: supply.inLoss.share, name: "In Loss", color: colors.loss }),
priceLine({ number: 100, color: colors.default, style: 0, unit: Unit.percentage }),
priceLine({ number: 50, unit: Unit.percentage }),
],
};
}
/**
* @param {{ dominance: PercentRatioPattern }} supply
* @param {Color} color
* @param {(name: string) => string} title
* @returns {PartialChartOption}
*/
function dominanceChart(supply, color, title) {
return {
name: "Dominance",
title: title("Supply Dominance"),
bottom: percentRatio({ pattern: supply.dominance, name: "Dominance", color }),
};
}
/**
* @param {OutputsPattern} outputs
* @param {Color} color
* @param {(name: string) => string} title
* @returns {PartialOptionsGroup}
*/
function outputsFolder(outputs, color, title) {
return {
name: "Outputs",
tree: [
countFolder(outputs.unspentCount, "Unspent", "UTXO Count", color, title),
{
name: "Spent",
tree: chartsFromCount({ pattern: outputs.spentCount, title, metric: "Spent UTXO Count", unit: Unit.count, color }),
},
{
name: "Spending Rate",
title: title("Spending Rate"),
bottom: [
line({ series: outputs.spendingRate, name: "Rate", color, unit: Unit.ratio }),
],
},
],
};
}
/**
* @param {{ base: AnySeriesPattern, delta: DeltaPattern }} pattern
* @param {string} name
* @param {string} chartTitle
* @param {Color} color
* @param {(name: string) => string} title
* @returns {PartialOptionsGroup}
*/
function countFolder(pattern, name, chartTitle, color, title) {
return {
name,
tree: [
{
name: "Count",
title: title(chartTitle),
bottom: [
line({
series: pattern.base,
name: "Count",
color,
unit: Unit.count,
}),
],
},
...singleDeltaItems(pattern.delta, Unit.count, title, chartTitle),
],
};
}
// ============================================================================
// Single Cohort Holdings Sections
// ============================================================================
/**
* @param {{ cohort: UtxoCohortObject | CohortWithoutRelative, title: (name: string) => string }} args
* @returns {PartialOptionsTree}
*/
export function createHoldingsSection({ cohort, title }) {
const { supply } = cohort.tree;
return [
{
name: "Supply",
tree: [
{
name: "Total",
title: title("Supply"),
bottom: simpleSupplySeries(supply),
},
dominanceChart(supply, cohort.color, title),
...singleAmountDeltaItems(supply.delta, title, "Supply"),
],
},
outputsFolder(cohort.tree.outputs, cohort.color, title),
];
}
/**
* @param {{ cohort: CohortAll, title: (name: string) => string }} args
* @returns {PartialOptionsTree}
*/
export function createHoldingsSectionAll({ cohort, title }) {
const { supply } = cohort.tree;
return [
{
name: "Supply",
tree: [
{
name: "Total",
title: title("Supply"),
bottom: simpleSupplySeries(supply),
},
{
name: "Profitability",
tree: [
profitabilityAmountChart(supply, title),
profitabilityCompositionChart(supply, title),
],
},
...singleAmountDeltaItems(supply.delta, title, "Supply"),
],
},
outputsFolder(cohort.tree.outputs, cohort.color, title),
countFolder(cohort.addressCount, "Addresses", "Address Count", cohort.color, title),
];
}
/**
* @param {{ cohort: CohortFull | CohortLongTerm, title: (name: string) => string }} args
* @returns {PartialOptionsTree}
*/
export function createHoldingsSectionWithRelative({ cohort, title }) {
const { supply } = cohort.tree;
return [
{
name: "Supply",
tree: [
{
name: "Total",
title: title("Supply"),
bottom: simpleSupplySeries(supply),
},
dominanceChart(supply, cohort.color, title),
{
name: "Profitability",
tree: [
profitabilityAmountChart(supply, title),
profitabilityCompositionChart(supply, title),
],
},
...singleAmountDeltaItems(supply.delta, title, "Supply"),
],
},
outputsFolder(cohort.tree.outputs, cohort.color, title),
];
}
/**
* @param {{ cohort: CohortWithAdjusted | CohortAgeRange, title: (name: string) => string }} args
* @returns {PartialOptionsTree}
*/
export function createHoldingsSectionWithOwnSupply({ cohort, title }) {
const { supply } = cohort.tree;
return [
{
name: "Supply",
tree: [
{
name: "Total",
title: title("Supply"),
bottom: simpleSupplySeries(supply),
},
dominanceChart(supply, cohort.color, title),
{
name: "Profitability",
tree: [profitabilityAmountChart(supply, title)],
},
...singleAmountDeltaItems(supply.delta, title, "Supply"),
],
},
outputsFolder(cohort.tree.outputs, cohort.color, title),
];
}
/**
* @param {{ cohort: CohortWithoutRelative, title: (name: string) => string }} args
* @returns {PartialOptionsTree}
*/
export function createHoldingsSectionWithProfitLoss({ cohort, title }) {
const { supply } = cohort.tree;
return [
{
name: "Supply",
tree: [
{
name: "Total",
title: title("Supply"),
bottom: simpleSupplySeries(supply),
},
dominanceChart(supply, cohort.color, title),
{
name: "Profitability",
tree: [profitabilityAmountChart(supply, title)],
},
...singleAmountDeltaItems(supply.delta, title, "Supply"),
],
},
outputsFolder(cohort.tree.outputs, cohort.color, title),
];
}
/**
* @param {{ cohort: CohortAddr, title: (name: string) => string }} args
* @returns {PartialOptionsTree}
*/
export function createHoldingsSectionAddress({ cohort, title }) {
const { supply } = cohort.tree;
return [
{
name: "Supply",
tree: [
{
name: "Total",
title: title("Supply"),
bottom: simpleSupplySeries(supply),
},
dominanceChart(supply, cohort.color, title),
{
name: "Profitability",
tree: [profitabilityAmountChart(supply, title)],
},
...singleAmountDeltaItems(supply.delta, title, "Supply"),
],
},
outputsFolder(cohort.tree.outputs, cohort.color, title),
countFolder(cohort.addressCount, "Addresses", "Address Count", cohort.color, title),
];
}
/**
* @param {{ cohort: AddrCohortObject, title: (name: string) => string }} args
* @returns {PartialOptionsTree}
*/
export function createHoldingsSectionAddressAmount({ cohort, title }) {
const { supply } = cohort.tree;
return [
{
name: "Supply",
tree: [
{
name: "Total",
title: title("Supply"),
bottom: simpleSupplySeries(supply),
},
dominanceChart(supply, cohort.color, title),
...singleAmountDeltaItems(supply.delta, title, "Supply"),
],
},
outputsFolder(cohort.tree.outputs, cohort.color, title),
countFolder(cohort.addressCount, "Addresses", "Address Count", cohort.color, title),
];
}
// ============================================================================
// Grouped Cohort Supply Helpers
// ============================================================================
/**
* @template {{ name: string, color: Color, tree: { supply: { total: AnyValuePattern } } }} T
* @param {readonly T[]} list
* @param {CohortAll} all
* @param {(name: string) => string} title
* @returns {PartialChartOption}
*/
function groupedSupplyTotal(list, all, title) {
return { name: "Total", title: title("Supply"), bottom: flatMapCohortsWithAll(list, all, ({ name, color, tree }) => satsBtcUsd({ pattern: tree.supply.total, name, color })) };
}
/**
* @template {{ name: string, color: Color, tree: { supply: { inProfit: AnyValuePattern, inLoss: AnyValuePattern } } }} T
* @param {readonly T[]} list
* @param {CohortAll} all
* @param {(name: string) => string} title
* @returns {PartialOptionsTree}
*/
function groupedSupplyProfitLoss(list, all, title) {
return [
{ name: "In Profit", title: title("Supply In Profit"), bottom: flatMapCohortsWithAll(list, all, ({ name, color, tree }) => satsBtcUsd({ pattern: tree.supply.inProfit, name, color })) },
{ name: "In Loss", title: title("Supply In Loss"), bottom: flatMapCohortsWithAll(list, all, ({ name, color, tree }) => satsBtcUsd({ pattern: tree.supply.inLoss, name, color })) },
];
}
/**
* @template {{ name: string, color: Color, tree: { supply: { dominance: PercentRatioPattern } } }} T
* @param {readonly T[]} list
* @param {(name: string) => string} title
* @returns {PartialChartOption}
*/
function groupedDominanceChart(list, title) {
return {
name: "Dominance",
title: title("Supply Dominance"),
bottom: flatMapCohorts(list, ({ name, color, tree }) =>
percentRatio({ pattern: tree.supply.dominance, name, color }),
),
};
}
// ============================================================================
// Grouped Cohort Holdings Sections
// ============================================================================
/**
* @param {{ list: readonly CohortAddr[], all: CohortAll, title: (name: string) => string }} args
* @returns {PartialOptionsTree}
*/
export function createGroupedHoldingsSectionAddress({ list, all, title }) {
return [
{
name: "Supply",
tree: [
groupedSupplyTotal(list, all, title),
groupedDominanceChart(list, title),
{
name: "Profitability",
tree: groupedSupplyProfitLoss(list, all, title),
},
...groupedAmountDeltaItems(list, all, (c) => c.tree.supply.delta, title, "Supply"),
],
},
groupedOutputsFolder(list, all, title),
{
name: "Addresses",
tree: [
{
name: "Count",
title: title("Address Count"),
bottom: mapCohortsWithAll(list, all, ({ name, color, addressCount }) =>
line({ series: addressCount.base, name, color, unit: Unit.count }),
),
},
...groupedDeltaItems(list, all, (c) => c.addressCount.delta, Unit.count, title, "Address Count"),
],
},
{
name: "Average Holdings",
tree: [
{
name: "Per UTXO",
title: title("Average Holdings per UTXO"),
bottom: flatMapCohortsWithAll(list, all, ({ name, color, avgAmount }) =>
satsBtcUsd({ pattern: avgAmount.utxo, name, color }),
),
},
{
name: "Per Address",
title: title("Average Holdings per Funded Address"),
bottom: flatMapCohortsWithAll(list, all, ({ name, color, avgAmount }) =>
satsBtcUsd({ pattern: avgAmount.addr, name, color }),
),
},
],
},
];
}
/**
* Grouped holdings for address amount cohorts (no inProfit/inLoss, has address count)
* @param {{ list: readonly AddrCohortObject[], all: CohortAll, title: (name: string) => string }} args
* @returns {PartialOptionsTree}
*/
export function createGroupedHoldingsSectionAddressAmount({ list, all, title }) {
return [
{
name: "Supply",
tree: [
groupedSupplyTotal(list, all, title),
groupedDominanceChart(list, title),
...groupedAmountDeltaItems(list, all, (c) => c.tree.supply.delta, title, "Supply"),
],
},
groupedOutputsFolder(list, all, title),
{
name: "Addresses",
tree: [
{
name: "Count",
title: title("Address Count"),
bottom: mapCohortsWithAll(list, all, ({ name, color, addressCount }) =>
line({ series: addressCount.base, name, color, unit: Unit.count }),
),
},
...groupedDeltaItems(list, all, (c) => c.addressCount.delta, Unit.count, title, "Address Count"),
],
},
];
}
/** @param {{ list: readonly (UtxoCohortObject | CohortWithoutRelative)[], all: CohortAll, title: (name: string) => string }} args */
export function createGroupedHoldingsSection({ list, all, title }) {
return [
{
name: "Supply",
tree: [
groupedSupplyTotal(list, all, title),
groupedDominanceChart(list, title),
...groupedAmountDeltaItems(list, all, (c) => c.tree.supply.delta, title, "Supply"),
],
},
groupedOutputsFolder(list, all, title),
];
}
/** @param {{ list: readonly CohortWithoutRelative[], all: CohortAll, title: (name: string) => string }} args */
export function createGroupedHoldingsSectionWithProfitLoss({ list, all, title }) {
return [
{
name: "Supply",
tree: [
groupedSupplyTotal(list, all, title),
groupedDominanceChart(list, title),
{
name: "Profitability",
tree: groupedSupplyProfitLoss(list, all, title),
},
...groupedAmountDeltaItems(list, all, (c) => c.tree.supply.delta, title, "Supply"),
],
},
groupedOutputsFolder(list, all, title),
];
}
/** @param {{ list: readonly (CohortWithAdjusted | CohortAgeRange)[], all: CohortAll, title: (name: string) => string }} args */
export function createGroupedHoldingsSectionWithOwnSupply({ list, all, title }) {
return [
{
name: "Supply",
tree: [
groupedSupplyTotal(list, all, title),
groupedDominanceChart(list, title),
{
name: "Profitability",
tree: groupedSupplyProfitLoss(list, all, title),
},
...groupedAmountDeltaItems(list, all, (c) => c.tree.supply.delta, title, "Supply"),
],
},
groupedOutputsFolder(list, all, title),
];
}
/**
* Grouped holdings with full relative series (dominance + share)
* For: CohortFull, CohortLongTerm
* @param {{ list: readonly (CohortFull | CohortLongTerm)[], all: CohortAll, title: (name: string) => string }} args
* @returns {PartialOptionsTree}
*/
export function createGroupedHoldingsSectionWithRelative({ list, all, title }) {
return [
{
name: "Supply",
tree: [
groupedSupplyTotal(list, all, title),
groupedDominanceChart(list, title),
{
name: "Profitability",
tree: [
...groupedSupplyProfitLoss(list, all, title),
{
name: "Composition",
tree: [
{ name: "In Profit", title: title("Supply In Profit Composition"), bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) => line({ series: tree.supply.inProfit.share.percent, name, color, unit: Unit.percentage })) },
{ name: "In Loss", title: title("Supply In Loss Composition"), bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) => line({ series: tree.supply.inLoss.share.percent, name, color, unit: Unit.percentage })) },
],
},
],
},
...groupedAmountDeltaItems(list, all, (c) => c.tree.supply.delta, title, "Supply"),
],
},
groupedOutputsFolder(list, all, title),
];
}
@@ -1,767 +0,0 @@
/**
* Cohort module - exports all cohort-related functionality
*
* Folder builders compose sections from building blocks:
* - holdings.js: Supply, UTXO Count, Address Count
* - valuation.js: Realized Cap, Market Cap, MVRV
* - prices.js: Realized Price, ratios
* - cost-basis.js: Cost basis percentiles
* - profitability.js: Unrealized/Realized P&L, Invested Capital
* - activity.js: SOPR, Volume, Lifespan
*/
import {
formatCohortTitle,
amountBaseline,
satsBtcUsd,
satsBtcUsdFullTree,
avgHoldingsSubtree,
exposedSubtree,
reusedSubtree,
} from "../shared.js";
import {
ROLLING_WINDOWS,
line,
percentRatio,
amountSumsTreeBaseline,
rollingPercentRatioTree,
} from "../series.js";
import { Unit } from "../../utils/units.js";
import { colors } from "../../utils/colors.js";
import { brk } from "../../utils/client.js";
// Section builders
import {
createHoldingsSection,
createHoldingsSectionAll,
createHoldingsSectionAddress,
createHoldingsSectionAddressAmount,
createHoldingsSectionWithProfitLoss,
createHoldingsSectionWithRelative,
createHoldingsSectionWithOwnSupply,
createGroupedHoldingsSection,
createGroupedHoldingsSectionAddress,
createGroupedHoldingsSectionAddressAmount,
createGroupedHoldingsSectionWithRelative,
createGroupedHoldingsSectionWithOwnSupply,
} from "./holdings.js";
import {
createValuationSection,
createValuationSectionFull,
createGroupedValuationSection,
createGroupedValuationSectionWithOwnMarketCap,
} from "./valuation.js";
import {
createPricesSectionFull,
createPricesSectionBasic,
createGroupedPricesSection,
createGroupedPricesSectionFull,
} from "./prices.js";
import {
createCostBasisSectionWithPercentiles,
createGroupedCostBasisSectionWithPercentiles,
} from "./cost-basis.js";
import {
createProfitabilitySection,
createProfitabilitySectionAll,
createProfitabilitySectionFull,
createProfitabilitySectionWithProfitLoss,
createProfitabilitySectionWithInvestedCapitalPct,
createProfitabilitySectionLongTerm,
createGroupedProfitabilitySection,
createGroupedProfitabilitySectionWithProfitLoss,
createGroupedProfitabilitySectionWithNupl,
createGroupedProfitabilitySectionWithInvestedCapitalPct,
} from "./profitability.js";
import {
createActivitySection,
createActivitySectionWithAdjusted,
createActivitySectionWithActivity,
createGroupedActivitySection,
createGroupedActivitySectionWithActivity,
createActivitySectionMinimal,
createGroupedActivitySectionMinimal,
} from "./activity.js";
// Re-export data builder
export { buildCohortData } from "./data.js";
// ============================================================================
// Single Cohort Folder Builders
// ============================================================================
/**
* All folder: for the special "All" cohort
* @param {CohortAll} cohort
* @returns {PartialOptionsGroup}
*/
export function createCohortFolderAll(cohort) {
const title = formatCohortTitle(cohort.title);
return {
name: cohort.name || "all",
tree: [
...createHoldingsSectionAll({ cohort, title }),
createValuationSectionFull({ cohort, title }),
createPricesSectionFull({ cohort, title }),
createCostBasisSectionWithPercentiles({ cohort, title }),
createProfitabilitySectionAll({ cohort, title }),
createActivitySectionWithAdjusted({ cohort, title }),
avgHoldingsSubtree(cohort.avgAmount, title),
],
};
}
/**
* Full folder: adjustedSopr + percentiles + RelToMarketCap
* @param {CohortFull} cohort
* @returns {PartialOptionsGroup}
*/
export function createCohortFolderFull(cohort) {
const title = formatCohortTitle(cohort.title);
return {
name: cohort.name || "all",
tree: [
...createHoldingsSectionWithRelative({ cohort, title }),
createValuationSectionFull({ cohort, title }),
createPricesSectionFull({ cohort, title }),
createCostBasisSectionWithPercentiles({ cohort, title }),
createProfitabilitySectionFull({ cohort, title }),
createActivitySectionWithAdjusted({ cohort, title }),
],
};
}
/**
* Adjusted folder: adjustedSopr only, no percentiles
* @param {CohortWithAdjusted} cohort
* @returns {PartialOptionsGroup}
*/
export function createCohortFolderWithAdjusted(cohort) {
const title = formatCohortTitle(cohort.title);
return {
name: cohort.name || "all",
tree: [
...createHoldingsSectionWithOwnSupply({ cohort, title }),
createValuationSection({ cohort, title }),
createPricesSectionBasic({ cohort, title }),
createProfitabilitySectionWithInvestedCapitalPct({ cohort, title }),
createActivitySectionWithActivity({ cohort, title }),
],
};
}
/**
* Folder for cohorts with nupl + percentiles
* @param {CohortWithNuplPercentiles} cohort
* @returns {PartialOptionsGroup}
*/
export function createCohortFolderWithNupl(cohort) {
const title = formatCohortTitle(cohort.title);
return {
name: cohort.name || "all",
tree: [
...createHoldingsSectionWithRelative({ cohort, title }),
createValuationSectionFull({ cohort, title }),
createPricesSectionFull({ cohort, title }),
createCostBasisSectionWithPercentiles({ cohort, title }),
createProfitabilitySection({ cohort, title }),
createActivitySection({ cohort, title }),
],
};
}
/**
* LongTerm folder: has own market cap + NUPL + peak regret + P/L ratio
* @param {CohortLongTerm} cohort
* @returns {PartialOptionsGroup}
*/
export function createCohortFolderLongTerm(cohort) {
const title = formatCohortTitle(cohort.title);
return {
name: cohort.name || "all",
tree: [
...createHoldingsSectionWithRelative({ cohort, title }),
createValuationSectionFull({ cohort, title }),
createPricesSectionFull({ cohort, title }),
createCostBasisSectionWithPercentiles({ cohort, title }),
createProfitabilitySectionLongTerm({ cohort, title }),
createActivitySection({ cohort, title }),
],
};
}
/**
* Age range folder: no nupl
* @param {CohortAgeRange} cohort
* @returns {PartialOptionsGroup}
*/
export function createCohortFolderAgeRange(cohort) {
const title = formatCohortTitle(cohort.title);
return {
name: cohort.name || "all",
tree: [
...createHoldingsSectionWithOwnSupply({ cohort, title }),
createValuationSection({ cohort, title }),
createPricesSectionBasic({ cohort, title }),
createProfitabilitySectionWithInvestedCapitalPct({ cohort, title }),
createActivitySectionWithActivity({ cohort, title }),
],
};
}
/**
* Age range folder with matured supply
* @param {CohortAgeRangeWithMatured} cohort
* @returns {PartialOptionsGroup}
*/
export function createCohortFolderAgeRangeWithMatured(cohort) {
const folder = createCohortFolderAgeRange(cohort);
const title = formatCohortTitle(cohort.title);
folder.tree.push({
name: "Matured",
tree: satsBtcUsdFullTree({
pattern: cohort.matured,
title,
metric: "Matured Supply",
}),
});
return folder;
}
/**
* Basic folder WITH RelToMarketCap
* @param {CohortBasicWithMarketCap} cohort
* @returns {PartialOptionsGroup}
*/
export function createCohortFolderBasicWithMarketCap(cohort) {
const title = formatCohortTitle(cohort.title);
return {
name: cohort.name || "all",
tree: [
...createHoldingsSection({ cohort, title }),
createValuationSection({ cohort, title }),
createPricesSectionBasic({ cohort, title }),
createProfitabilitySection({ cohort, title }),
createActivitySectionMinimal({ cohort, title }),
],
};
}
/**
* Address folder: like basic but with address count
* @param {CohortAddr} cohort
* @returns {PartialOptionsGroup}
*/
export function createCohortFolderAddress(cohort) {
const title = formatCohortTitle(cohort.title);
return {
name: cohort.name || "all",
tree: [
...createHoldingsSectionAddress({ cohort, title }),
createValuationSection({ cohort, title }),
createPricesSectionBasic({ cohort, title }),
createProfitabilitySectionWithProfitLoss({ cohort, title }),
createActivitySectionMinimal({ cohort, title }),
avgHoldingsSubtree(cohort.avgAmount, title),
reusedSubtree(cohort.reused, cohort.respent, cohort.key, title),
exposedSubtree(cohort.exposed, cohort.key, title),
],
};
}
/**
* Folder for cohorts WITHOUT relative section
* @param {CohortWithoutRelative} cohort
* @returns {PartialOptionsGroup}
*/
export function createCohortFolderWithoutRelative(cohort) {
const title = formatCohortTitle(cohort.title);
return {
name: cohort.name || "all",
tree: [
...createHoldingsSectionWithProfitLoss({ cohort, title }),
createValuationSection({ cohort, title }),
createPricesSectionBasic({ cohort, title }),
createProfitabilitySectionWithProfitLoss({ cohort, title }),
createActivitySectionMinimal({ cohort, title }),
],
};
}
/**
* Address amount cohort folder: has NUPL + addrCount
* @param {AddrCohortObject} cohort
* @returns {PartialOptionsGroup}
*/
export function createAddressCohortFolder(cohort) {
const title = formatCohortTitle(cohort.title);
return {
name: cohort.name || "all",
tree: [
...createHoldingsSectionAddressAmount({ cohort, title }),
createValuationSection({ cohort, title }),
createPricesSectionBasic({ cohort, title }),
createProfitabilitySection({ cohort, title }),
createActivitySectionMinimal({ cohort, title }),
],
};
}
// ============================================================================
// Grouped Cohort Folder Builders
// ============================================================================
/**
* @param {CohortGroupWithAdjusted} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedCohortFolderWithAdjusted({
name,
title: groupTitle,
list,
all,
}) {
const title = formatCohortTitle(groupTitle);
return {
name: name || "all",
tree: [
...createGroupedHoldingsSectionWithOwnSupply({ list, all, title }),
createGroupedValuationSection({ list, all, title }),
createGroupedPricesSection({ list, all, title }),
createGroupedProfitabilitySectionWithInvestedCapitalPct({
list,
all,
title,
}),
createGroupedActivitySectionWithActivity({ list, all, title }),
],
};
}
/**
* @param {CohortGroupWithNuplPercentiles} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedCohortFolderWithNupl({
name,
title: groupTitle,
list,
all,
}) {
const title = formatCohortTitle(groupTitle);
return {
name: name || "all",
tree: [
...createGroupedHoldingsSectionWithRelative({ list, all, title }),
createGroupedValuationSectionWithOwnMarketCap({ list, all, title }),
createGroupedPricesSectionFull({ list, all, title }),
createGroupedCostBasisSectionWithPercentiles({ list, all, title }),
createGroupedProfitabilitySectionWithNupl({ list, all, title }),
createGroupedActivitySection({ list, all, title }),
],
};
}
/**
* @param {CohortGroupAgeRange} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedCohortFolderAgeRange({
name,
title: groupTitle,
list,
all,
}) {
const title = formatCohortTitle(groupTitle);
return {
name: name || "all",
tree: [
...createGroupedHoldingsSectionWithOwnSupply({ list, all, title }),
createGroupedValuationSection({ list, all, title }),
createGroupedPricesSection({ list, all, title }),
createGroupedProfitabilitySectionWithInvestedCapitalPct({
list,
all,
title,
}),
createGroupedActivitySectionWithActivity({ list, all, title }),
],
};
}
/**
* @param {{ name: string, title: string, list: readonly CohortAgeRangeWithMatured[], all: CohortAll }} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedCohortFolderAgeRangeWithMatured({
name,
title: groupTitle,
list,
all,
}) {
const folder = createGroupedCohortFolderAgeRange({
name,
title: groupTitle,
list,
all,
});
const title = formatCohortTitle(groupTitle);
folder.tree.push({
name: "Matured",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} Matured Supply`),
bottom: list.flatMap((cohort) =>
satsBtcUsd({
pattern: cohort.matured.sum[w.key],
name: cohort.name,
color: cohort.color,
}),
),
})),
});
return folder;
}
/**
* @param {CohortGroupBasicWithMarketCap} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedCohortFolderBasicWithMarketCap({
name,
title: groupTitle,
list,
all,
}) {
const title = formatCohortTitle(groupTitle);
return {
name: name || "all",
tree: [
...createGroupedHoldingsSection({ list, all, title }),
createGroupedValuationSection({ list, all, title }),
createGroupedPricesSection({ list, all, title }),
createGroupedProfitabilitySection({ list, all, title }),
createGroupedActivitySectionMinimal({ list, all, title }),
],
};
}
/**
* @param {CohortGroupAddr} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedCohortFolderAddress({
name,
title: groupTitle,
list,
all,
}) {
const title = formatCohortTitle(groupTitle);
return {
name: name || "all",
tree: [
...createGroupedHoldingsSectionAddress({ list, all, title }),
createGroupedValuationSection({ list, all, title }),
createGroupedPricesSection({ list, all, title }),
createGroupedProfitabilitySectionWithProfitLoss({
list,
all,
title,
}),
createGroupedActivitySectionMinimal({ list, all, title }),
],
};
}
/**
* @param {AddrCohortGroupObject} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedAddressCohortFolder({
name,
title: groupTitle,
list,
all,
}) {
const title = formatCohortTitle(groupTitle);
return {
name: name || "all",
tree: [
...createGroupedHoldingsSectionAddressAmount({ list, all, title }),
createGroupedValuationSection({ list, all, title }),
createGroupedPricesSection({ list, all, title }),
createGroupedProfitabilitySection({ list, all, title }),
createGroupedActivitySectionMinimal({ list, all, title }),
],
};
}
// ============================================================================
// UTXO Profitability Folder Builders
// ============================================================================
/**
* @param {{ name: string, color: Color, pattern: RealizedSupplyPattern }} bucket
* @param {string} [parentName]
* @returns {PartialOptionsGroup}
*/
function singleBucketFolder({ name, color, pattern }, parentName) {
const title = formatCohortTitle(parentName ? `${parentName} ${name}` : name);
return {
name,
tree: [
{
name: "Supply",
tree: [
{
name: "Total",
title: title("Supply"),
bottom: [
...satsBtcUsd({ pattern: pattern.supply.all, name: "Total" }),
...satsBtcUsd({
pattern: pattern.supply.sth,
name: "STH",
color: colors.term.short,
}),
],
},
{
...amountSumsTreeBaseline({
windows: pattern.supply.all.delta.absolute,
title,
metric: "Supply Change",
legend: "Change",
}),
name: "Change",
},
{
...rollingPercentRatioTree({
windows: pattern.supply.all.delta.rate,
title,
metric: "Supply Growth Rate",
}),
name: "Growth Rate",
},
],
},
{
name: "Realized Cap",
title: title("Realized Cap"),
bottom: [
line({
series: pattern.realizedCap.all,
name: "Total",
unit: Unit.usd,
}),
line({
series: pattern.realizedCap.sth,
name: "STH",
color: colors.term.short,
unit: Unit.usd,
}),
],
},
{
name: "Unrealized PnL",
title: title("Unrealized PnL"),
bottom: [
line({
series: pattern.unrealizedPnl.all,
name: "Total",
unit: Unit.usd,
}),
line({
series: pattern.unrealizedPnl.sth,
name: "STH",
color: colors.term.short,
unit: Unit.usd,
}),
],
},
{
name: "NUPL",
title: title("NUPL"),
bottom: [
line({ series: pattern.nupl.ratio, name, color, unit: Unit.ratio }),
],
},
],
};
}
/**
* @param {{ name: string, color: Color, pattern: RealizedSupplyPattern }[]} list
* @param {string} groupTitle
* @returns {PartialOptionsTree}
*/
function groupedBucketCharts(list, groupTitle) {
const title = formatCohortTitle(groupTitle);
return [
{
name: "Supply",
tree: [
{
name: "All",
title: title("Supply"),
bottom: list.flatMap(({ name, color, pattern }) =>
satsBtcUsd({ pattern: pattern.supply.all, name, color }),
),
},
{
name: "STH",
title: title("STH Supply"),
bottom: list.flatMap(({ name, color, pattern }) =>
satsBtcUsd({ pattern: pattern.supply.sth, name, color }),
),
},
{
name: "Change",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} Supply Change`),
bottom: list.flatMap(({ name, color, pattern }) =>
amountBaseline({
pattern: pattern.supply.all.delta.absolute[w.key],
name,
color,
}),
),
})),
},
{
name: "Growth Rate",
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,
}),
),
})),
},
],
},
{
name: "Realized Cap",
tree: [
{
name: "All",
title: title("Realized Cap"),
bottom: list.map(({ name, color, pattern }) =>
line({
series: pattern.realizedCap.all,
name,
color,
unit: Unit.usd,
}),
),
},
{
name: "STH",
title: title("STH Realized Cap"),
bottom: list.map(({ name, color, pattern }) =>
line({
series: pattern.realizedCap.sth,
name,
color,
unit: Unit.usd,
}),
),
},
],
},
{
name: "Unrealized PnL",
tree: [
{
name: "All",
title: title("Unrealized PnL"),
bottom: list.map(({ name, color, pattern }) =>
line({
series: pattern.unrealizedPnl.all,
name,
color,
unit: Unit.usd,
}),
),
},
{
name: "STH",
title: title("STH Unrealized PnL"),
bottom: list.map(({ name, color, pattern }) =>
line({
series: pattern.unrealizedPnl.sth,
name,
color,
unit: Unit.usd,
}),
),
},
],
},
{
name: "NUPL",
title: title("NUPL"),
bottom: list.map(({ name, color, pattern }) =>
line({ series: pattern.nupl.ratio, name, color, unit: Unit.ratio }),
),
},
];
}
/**
* @param {{ range: { name: string, color: Color, pattern: RealizedSupplyPattern }[], profit: { name: string, color: Color, pattern: RealizedSupplyPattern }[], loss: { name: string, color: Color, pattern: RealizedSupplyPattern }[] }} args
* @returns {PartialOptionsGroup}
*/
export function createUtxoProfitabilitySection({ range, profit, loss }) {
return {
name: "UTXO Profitability",
tree: [
{
name: "Range",
tree: [
{
name: "Compare",
tree: groupedBucketCharts(range, "Profitability Range"),
},
...range.map((bucket) => singleBucketFolder(bucket)),
],
},
{
name: "In Profit",
tree: [
{ name: "Compare", tree: groupedBucketCharts(profit, "In Profit") },
...profit.map((bucket) => singleBucketFolder(bucket, "In Profit")),
],
},
{
name: "In Loss",
tree: [
{ name: "Compare", tree: groupedBucketCharts(loss, "In Loss") },
...loss.map((bucket) => singleBucketFolder(bucket, "In Loss")),
],
},
],
};
}
/**
* Gini leaf for Distribution > Address Balance
* @returns {AnyPartialOption}
*/
export function createAddressBalanceGiniLeaf() {
return {
name: "Gini",
title: "Address Balance Gini Coefficient",
bottom: percentRatio({
pattern: brk.series.indicators.gini,
name: "Gini",
color: colors.loss,
}),
};
}
@@ -1,173 +0,0 @@
/**
* Prices section builders
*
* Structure (single cohort):
* - Compare: Both prices on one chart
* - Realized: Price + Ratio (MVRV) + Z-Scores (for full cohorts)
* - Capitalized: Price + Ratio + Z-Scores (for full cohorts)
*
* Structure (grouped cohorts):
* - Realized: Price + Ratio comparison across cohorts
* - Capitalized: Price + Ratio comparison across cohorts
*
* For cohorts WITHOUT full ratio patterns: basic Price/Ratio charts only (no Z-Scores)
*/
import { colors } from "../../utils/colors.js";
import { createPriceRatioCharts, mapCohortsWithAll, priceRatioPercentilesTree } from "../shared.js";
import { baseline, price } from "../series.js";
import { Unit } from "../../utils/units.js";
/**
* Create prices section for cohorts with full ratio patterns
* (CohortAll, CohortFull, CohortLongTerm)
* @param {{ cohort: CohortAll | CohortFull | CohortLongTerm, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createPricesSectionFull({ cohort, title }) {
const { tree, color } = cohort;
return {
name: "Prices",
tree: [
{
name: "Compare",
title: title("Realized Prices"),
top: [
price({ series: tree.realized.price, name: "Realized", color: colors.realized }),
price({ series: tree.realized.capitalized.price, name: "Capitalized", color: colors.capitalized }),
],
},
{
name: "Realized",
tree: createPriceRatioCharts({
context: cohort.title,
legend: "Realized",
pricePattern: tree.realized.price,
ratio: tree.realized.price,
color,
priceTitle: title("Realized Price"),
titlePrefix: "Realized Price",
}),
},
{
name: "Capitalized",
tree: priceRatioPercentilesTree({
pattern: tree.realized.capitalized.price,
title: title("Capitalized Price"),
ratioTitle: title("Capitalized Price Ratio"),
legend: "Capitalized",
color,
}),
},
],
};
}
/**
* Create prices section for cohorts with basic ratio patterns only
* (CohortWithAdjusted, CohortBasic, CohortAddr, CohortWithoutRelative)
* @param {{ cohort: CohortWithAdjusted | CohortBasic | CohortAddr | CohortWithoutRelative | CohortAgeRange, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createPricesSectionBasic({ cohort, title }) {
const { tree, color } = cohort;
return {
name: "Prices",
tree: [
{
name: "Realized",
tree: [
{
name: "Price",
title: title("Realized Price"),
top: [price({ series: tree.realized.price, name: "Realized", color })],
},
{
name: "Ratio",
title: title("Realized Price Ratio"),
bottom: [
baseline({
series: tree.realized.price.ratio,
name: "Ratio",
unit: Unit.ratio,
base: 1,
}),
],
},
],
},
],
};
}
/**
* Create prices section for grouped cohorts
* @param {{ list: readonly CohortObject[], all: CohortAll, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
/**
* @param {readonly CohortObject[]} list
* @param {CohortAll} all
* @param {(name: string) => string} title
* @returns {PartialOptionsTree}
*/
function groupedRealizedPriceItems(list, all, title) {
return [
{
name: "Realized",
tree: [
{
name: "Price",
title: title("Realized Price"),
top: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
price({ series: tree.realized.price, name, color }),
),
},
{
name: "Ratio",
title: title("Realized Price Ratio"),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
baseline({ series: tree.realized.mvrv, name, color, unit: Unit.ratio, base: 1 }),
),
},
],
},
];
}
/** @param {{ list: readonly CohortObject[], all: CohortAll, title: (name: string) => string }} args */
export function createGroupedPricesSection({ list, all, title }) {
return {
name: "Prices",
tree: groupedRealizedPriceItems(list, all, title),
};
}
/** @param {{ list: readonly (CohortAll | CohortFull | CohortLongTerm)[], all: CohortAll, title: (name: string) => string }} args */
export function createGroupedPricesSectionFull({ list, all, title }) {
return {
name: "Prices",
tree: [
...groupedRealizedPriceItems(list, all, title),
{
name: "Capitalized",
tree: [
{
name: "Price",
title: title("Capitalized Price"),
top: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
price({ series: tree.realized.capitalized.price, name, color }),
),
},
{
name: "Ratio",
title: title("Capitalized Price Ratio"),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
baseline({ series: tree.realized.capitalized.price.ratio, name, color, unit: Unit.ratio, base: 1 }),
),
},
],
},
],
};
}
File diff suppressed because it is too large Load Diff
@@ -1,193 +0,0 @@
/**
* Capitalization section builders
*/
import { Unit } from "../../utils/units.js";
import { colors } from "../../utils/colors.js";
import { ROLLING_WINDOWS, line, baseline, mapWindows, sumsTreeBaseline, rollingPercentRatioTree, percentRatio, percentRatioBaseline } from "../series.js";
import { ratioBottomSeries, mapCohortsWithAll, flatMapCohortsWithAll } from "../shared.js";
import { priceLine } from "../constants.js";
// ============================================================================
// Shared building blocks
// ============================================================================
/**
* Single cohort: Change + Growth Rate items (flat)
* @param {UtxoCohortObject["tree"]} tree
* @param {(name: string) => string} title
* @returns {PartialOptionsTree}
*/
function singleDeltaItems(tree, title) {
return [
{ ...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" },
];
}
/**
* Grouped: Change + Growth Rate + MVRV items (flat)
* @param {readonly (UtxoCohortObject | CohortWithoutRelative)[]} list
* @param {CohortAll} all
* @param {(name: string) => string} title
* @returns {PartialOptionsTree}
*/
function groupedDeltaAndMvrv(list, all, title) {
return [
{
name: "MVRV",
title: title("MVRV"),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
baseline({ series: tree.realized.mvrv, name, color, unit: Unit.ratio, base: 1 }),
),
},
{
name: "Change",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} Realized Cap Change`),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
baseline({ series: tree.realized.cap.delta.absolute[w.key].usd, name, color, unit: Unit.usd }),
),
})),
},
{
name: "Growth Rate",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} Realized Cap Growth Rate`),
bottom: flatMapCohortsWithAll(list, all, ({ name, color, tree }) =>
percentRatioBaseline({ pattern: tree.realized.cap.delta.rate[w.key], name, color }),
),
})),
},
];
}
// ============================================================================
// Single Cohort Sections
// ============================================================================
/**
* Full capitalization (has invested capital, own market cap ratio, full MVRV)
* @param {{ cohort: CohortAll | CohortFull | CohortLongTerm, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createValuationSectionFull({ cohort, title }) {
const { tree, color } = cohort;
return {
name: "Capitalization",
tree: [
{ name: "Total", title: title("Realized Cap"), bottom: [line({ series: tree.realized.cap.usd, name: "Realized Cap", color, unit: Unit.usd })] },
{
name: "Profitability",
tree: [
{
name: "Amount",
title: title("Invested Capital"),
bottom: [
line({ series: tree.realized.cap.usd, name: "Total", color: colors.default, unit: Unit.usd }),
line({ series: tree.unrealized.investedCapital.inProfit.usd, name: "In Profit", color: colors.profit, unit: Unit.usd }),
line({ series: tree.unrealized.investedCapital.inLoss.usd, name: "In Loss", color: colors.loss, unit: Unit.usd }),
],
},
{
name: "Composition",
title: title("Invested Capital Composition"),
bottom: [
...percentRatio({ pattern: tree.investedCapital.inProfit.share, name: "In Profit", color: colors.profit }),
...percentRatio({ pattern: tree.investedCapital.inLoss.share, name: "In Loss", color: colors.loss }),
priceLine({ number: 100, color: colors.default, style: 0, unit: Unit.percentage }),
priceLine({ number: 50, unit: Unit.percentage }),
],
},
],
},
{ name: "MVRV", title: title("MVRV"), bottom: ratioBottomSeries(tree.realized.price) },
...singleDeltaItems(tree, title),
{ name: "% of Own Market Cap", title: title("Realized Cap (% of Own Market Cap)"), bottom: percentRatioBaseline({ pattern: tree.realized.cap.toOwnMcap, name: "% of Own Market Cap", color }) },
],
};
}
/**
* Basic capitalization (no invested capital, simple MVRV)
* @param {{ cohort: CohortWithAdjusted | CohortBasic | CohortAddr | CohortWithoutRelative, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createValuationSection({ cohort, title }) {
const { tree } = cohort;
return {
name: "Capitalization",
tree: [
{ name: "Total", title: title("Realized Cap"), bottom: [line({ series: tree.realized.cap.usd, name: "Realized Cap", color: cohort.color, unit: Unit.usd })] },
...singleDeltaItems(tree, title),
{ name: "MVRV", title: title("MVRV"), bottom: [baseline({ series: tree.realized.mvrv, name: "MVRV", unit: Unit.ratio, base: 1 })] },
],
};
}
// ============================================================================
// Grouped Cohort Sections
// ============================================================================
/**
* @param {{ list: readonly (UtxoCohortObject | CohortWithoutRelative)[], all: CohortAll, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedValuationSection({ list, all, title }) {
return {
name: "Capitalization",
tree: [
{
name: "Total",
title: title("Realized Cap"),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
line({ series: tree.realized.cap.usd, name, color, unit: Unit.usd }),
),
},
...groupedDeltaAndMvrv(list, all, title),
],
};
}
/**
* @param {{ list: readonly (CohortAll | CohortFull | CohortLongTerm)[], all: CohortAll, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedValuationSectionWithOwnMarketCap({ list, all, title }) {
return {
name: "Capitalization",
tree: [
{
name: "Total",
title: title("Realized Cap"),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
line({ series: tree.realized.cap.usd, name, color, unit: Unit.usd }),
),
},
{
name: "In Profit",
title: title("Invested Capital In Profit"),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
line({ series: tree.unrealized.investedCapital.inProfit.usd, name, color, unit: Unit.usd }),
),
},
{
name: "In Loss",
title: title("Invested Capital In Loss"),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
line({ series: tree.unrealized.investedCapital.inLoss.usd, name, color, unit: Unit.usd }),
),
},
...groupedDeltaAndMvrv(list, all, title),
{
name: "% of Own Market Cap",
title: title("Realized Cap (% of Own Market Cap)"),
bottom: flatMapCohortsWithAll(list, all, ({ name, color, tree }) =>
percentRatio({ pattern: tree.realized.cap.toOwnMcap, name, color }),
),
},
],
};
}
-481
View File
@@ -1,481 +0,0 @@
import { createPartialOptions } from "./partial.js";
import {
createAnchorElement,
createButtonElement,
createSmall,
} 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 { logUnused } from "./unused.js";
import { setQr } from "../panes/share.js";
import { getConstant } from "./constants.js";
import { colors } from "../utils/colors.js";
import { Unit } from "../utils/units.js";
import { brk } from "../utils/client.js";
export function initOptions() {
const LS_SELECTED_KEY = `selected_path`;
const savedPath = /** @type {string[]} */ (
JSON.parse(readStored(LS_SELECTED_KEY) || "[]") || []
).filter((v) => v);
const partialOptions = createPartialOptions();
/** @type {Option[]} */
const list = [];
/** @type {Map<string, HTMLLIElement>} */
const liByPath = new Map();
/** @type {Set<(option: Option) => void>} */
const selectedListeners = new Set();
/** @type {HTMLLIElement[]} */
let highlightedLis = [];
/**
* @param {Option | undefined} sel
*/
function updateHighlight(sel) {
if (!sel) return;
for (const li of highlightedLis) {
delete li.dataset.highlight;
}
highlightedLis = [];
let pathKey = "";
for (const segment of sel.path) {
pathKey = pathKey ? `${pathKey}/${segment}` : segment;
const li = liByPath.get(pathKey);
if (li) {
li.dataset.highlight = "";
highlightedLis.push(li);
}
}
if (!highlightedLis.length) {
const li = liByPath.get(stringToId(sel.name));
if (li) {
li.dataset.highlight = "";
highlightedLis.push(li);
}
}
}
const selected = {
/** @type {Option | undefined} */
value: undefined,
/** @param {Option} v */
set(v) {
this.value = v;
updateHighlight(v);
selectedListeners.forEach((cb) => cb(v));
},
/** @param {(option: Option) => void} cb */
onChange(cb) {
selectedListeners.add(cb);
if (this.value) cb(this.value);
return () => selectedListeners.delete(cb);
},
};
/**
* @param {string[]} nodePath
*/
function isOnSelectedPath(nodePath) {
const selectedPath = selected.value?.path;
return (
selectedPath &&
nodePath.length <= selectedPath.length &&
nodePath.every((v, i) => v === selectedPath[i])
);
}
/**
* @template T
* @param {() => T} fn
* @returns {() => T}
*/
function lazy(fn) {
/** @type {T | undefined} */
let cached;
let computed = false;
return () => {
if (!computed) {
computed = true;
cached = fn();
}
return /** @type {T} */ (cached);
};
}
/**
* @param {(AnyFetchedSeriesBlueprint | FetchedPriceSeriesBlueprint)[]} [arr]
*/
function arrayToMap(arr) {
/** @type {Map<Unit, AnyFetchedSeriesBlueprint[]>} */
const map = new Map();
/** @type {Map<Unit, Set<number>>} */
const priceLines = new Map();
if (!arr) return map;
// Cache arrays for common units outside loop
/** @type {AnyFetchedSeriesBlueprint[] | undefined} */
let usdArr;
/** @type {AnyFetchedSeriesBlueprint[] | undefined} */
let satsArr;
for (let i = 0; i < arr.length; i++) {
const blueprint = arr[i];
// Check for undefined series
if (!blueprint.series) {
throw new Error(`Blueprint has undefined series: ${blueprint.title}`);
}
// Check for price pattern blueprint (has usd/sats sub-series)
// Use unknown cast for safe property access check
const maybePriceSeries =
/** @type {{ usd?: AnySeriesPattern, sats?: AnySeriesPattern }} */ (
/** @type {unknown} */ (blueprint.series)
);
if (maybePriceSeries.usd?.by && maybePriceSeries.sats?.by) {
const { usd, sats } = maybePriceSeries;
if (!usdArr) map.set(Unit.usd, (usdArr = []));
usdArr.push({ ...blueprint, series: usd, unit: Unit.usd });
if (!satsArr) map.set(Unit.sats, (satsArr = []));
satsArr.push({ ...blueprint, series: sats, unit: Unit.sats });
continue;
}
// After continue, we know this is a regular series blueprint
const regularBlueprint = /** @type {AnyFetchedSeriesBlueprint} */ (
blueprint
);
const s = regularBlueprint.series;
const unit = regularBlueprint.unit;
if (!unit) continue;
let unitArr = map.get(unit);
if (!unitArr) map.set(unit, (unitArr = []));
unitArr.push(regularBlueprint);
// Track baseline base values for auto price lines
const type = regularBlueprint.type;
if (type === "Baseline") {
let priceSet = priceLines.get(unit);
if (!priceSet) priceLines.set(unit, (priceSet = new Set()));
priceSet.add(regularBlueprint.options?.baseValue?.price ?? 0);
} else if (!type || type === "Line") {
// Check if manual price line - avoid Object.values() array allocation
const by = s.by;
for (const k in by) {
if (by[/** @type {Index} */ (k)]?.path?.includes("constant_")) {
priceLines.get(unit)?.delete(parseFloat(regularBlueprint.title));
}
break;
}
}
}
// Add price lines at end for remaining values
for (const [unit, values] of priceLines) {
const arr = map.get(unit);
if (!arr) continue;
for (const baseValue of values) {
const s = getConstant(brk.series.constants, baseValue);
arr.push({
series: s,
title: `${baseValue}`,
color: colors.gray,
unit,
options: {
lineStyle: 4,
lastValueVisible: false,
crosshairMarkerVisible: false,
},
});
}
}
return map;
}
/**
* @param {Option} option
*/
function selectOption(option) {
if (selected.value === option) return;
pushHistory(option.path);
resetParams(option);
writeToStorage(LS_SELECTED_KEY, JSON.stringify(option.path));
selected.set(option);
}
/**
* @param {Object} args
* @param {Option} args.option
* @param {string} [args.name]
*/
function createOptionElement({ option, name }) {
const title = option.title;
if (option.kind === "link") {
const href = option.url();
if (option.qrcode) {
return createButtonElement({
inside: option.name,
title,
onClick: () => {
setQr(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;
/**
* @typedef {{ type: "group"; name: string; serName: string; path: string[]; pathKey: string; count: number; children: ProcessedNode[] }} ProcessedGroup
* @typedef {{ type: "option"; option: Option; path: string[]; pathKey: string }} ProcessedOption
* @typedef {ProcessedGroup | ProcessedOption} ProcessedNode
*/
const savedPathStr = savedPath?.join("/");
/**
* @param {PartialOptionsTree} partialTree
* @param {string[]} parentPath
* @param {string} parentPathStr
* @returns {{ nodes: ProcessedNode[], count: number }}
*/
function processPartialTree(
partialTree,
parentPath = [],
parentPathStr = "",
) {
/** @type {ProcessedNode[]} */
const nodes = [];
let totalCount = 0;
for (let i = 0; i < partialTree.length; i++) {
const anyPartial = partialTree[i];
if ("tree" in anyPartial) {
const serName = stringToId(anyPartial.name);
const pathStr = parentPathStr ? `${parentPathStr}/${serName}` : serName;
const path = parentPath.concat(serName);
const { nodes: children, count } = processPartialTree(
anyPartial.tree,
path,
pathStr,
);
// Skip groups with no children
if (count === 0) continue;
totalCount += count;
nodes.push({
type: "group",
name: anyPartial.name,
serName,
path,
pathKey: pathStr,
count,
children,
});
} else {
const option = /** @type {Option} */ (anyPartial);
const name = option.name;
const serName = stringToId(name);
const pathStr = parentPathStr ? `${parentPathStr}/${serName}` : serName;
const path = parentPath.concat(serName);
// Transform partial to full option
if ("kind" in anyPartial && anyPartial.kind === "explorer") {
option.kind = anyPartial.kind;
option.path = [];
option.name = name;
} else if ("kind" in anyPartial && anyPartial.kind === "heatmap") {
Object.assign(
option,
/** @satisfies {HeatmapOption} */ ({
...anyPartial,
path,
}),
);
} else if ("url" in anyPartial) {
Object.assign(
option,
/** @satisfies {UrlOption} */ ({
kind: "link",
path,
name,
title: name,
qrcode: !!anyPartial.qrcode,
url: anyPartial.url,
}),
);
} else {
const title = option.title || name;
const topArr = anyPartial.top;
const bottomArr = anyPartial.bottom;
const topFn = lazy(() => arrayToMap(topArr));
const bottomFn = lazy(() => arrayToMap(bottomArr));
Object.assign(
option,
/** @satisfies {ChartOption} */ ({
kind: "chart",
name,
title,
path,
top: topFn,
bottom: bottomFn,
}),
);
}
list.push(option);
totalCount++;
if (savedPathStr && pathStr === savedPathStr) {
savedOption = option;
}
nodes.push({
type: "option",
option,
path,
pathKey: pathStr,
});
}
}
return { nodes, count: totalCount };
}
logUnused(brk.series, partialOptions);
const { nodes: processedTree } = processPartialTree(partialOptions);
/**
* @param {ProcessedNode[]} nodes
* @param {HTMLElement} parentEl
* @param {boolean} autoOpen
*/
function buildTreeDOM(nodes, parentEl, autoOpen) {
const ul = window.document.createElement("ul");
for (const node of nodes) {
const li = window.document.createElement("li");
ul.append(li);
liByPath.set(node.pathKey, li);
if (node.type === "group") {
const details = window.document.createElement("details");
details.dataset.name = node.serName;
li.appendChild(details);
const summary = window.document.createElement("summary");
details.append(summary);
summary.append(node.name);
summary.append(createSmall(`[${node.count.toLocaleString("en-us")}]`));
let built = false;
if (autoOpen && isOnSelectedPath(node.path)) {
built = true;
details.open = true;
buildTreeDOM(node.children, details, true);
}
details.addEventListener("toggle", () => {
if (details.open && !built) {
built = true;
buildTreeDOM(node.children, details, false);
}
updateHighlight(selected.value);
});
} else {
const element = createOptionElement({
option: node.option,
});
li.append(element);
}
}
parentEl.append(ul);
}
/** @type {HTMLElement | null} */
let parentEl = null;
/**
* @param {HTMLElement} el
*/
function setParent(el) {
if (parentEl) return;
parentEl = el;
buildTreeDOM(processedTree, el, true);
updateHighlight(selected.value);
}
const tree = /** @type {OptionsTree} */ (partialOptions);
function resolveUrl() {
const segments = window.location.pathname.split("/").filter((v) => v);
let folder = tree;
for (let i = 0; i < segments.length; i++) {
const match = folder.find((v) => segments[i] === stringToId(v.name));
if (!match) break;
if (i < segments.length - 1) {
if (!("tree" in match)) break;
folder = match.tree;
} else if (!("tree" in match)) {
selected.set(match);
return;
} else {
break;
}
}
selected.set(!segments.length && savedOption ? savedOption : list[0]);
}
resolveUrl();
if (!selected.value) {
const option = savedOption || list[0];
if (option) {
selected.set(option);
}
}
return {
selected,
list,
tree,
setParent,
createOptionElement,
selectOption,
resolveUrl,
};
}
/** @typedef {ReturnType<typeof initOptions>} Options */
-540
View File
@@ -1,540 +0,0 @@
/** Investing section - Investment strategy tools and analysis */
import { colors } from "../utils/colors.js";
import { brk } from "../utils/client.js";
import { percentRatioBaseline, price } from "./series.js";
import { satsBtcUsd } from "./shared.js";
import { periodIdToName } from "../utils/time.js";
const SHORT_PERIODS = /** @type {const} */ ([
"_1w",
"_1m",
"_3m",
"_6m",
"_1y",
]);
const LONG_PERIODS = /** @type {const} */ ([
"_2y",
"_3y",
"_4y",
"_5y",
"_6y",
"_8y",
"_10y",
]);
/** @typedef {typeof SHORT_PERIODS[number]} ShortPeriodKey */
/** @typedef {typeof LONG_PERIODS[number]} LongPeriodKey */
/** @typedef {ShortPeriodKey | LongPeriodKey} AllPeriodKey */
/**
* Add CAGR to a base entry item
* @param {BaseEntryItem} entry
* @param {PercentRatioPattern} cagr
* @returns {LongEntryItem}
*/
const withCagr = (entry, cagr) => ({ ...entry, cagr });
const YEARS_2020S = /** @type {const} */ ([
2026, 2025, 2024, 2023, 2022, 2021, 2020,
]);
const YEARS_2010S = /** @type {const} */ ([2019, 2018, 2017, 2016, 2015]);
/** @typedef {typeof YEARS_2020S[number] | typeof YEARS_2010S[number]} DcaYear */
/** @typedef {`from${DcaYear}`} DcaYearKey */
/** @param {AllPeriodKey} key */
const periodName = (key) => periodIdToName(key.slice(1), true);
/**
* @typedef {{ percent: AnySeriesPattern, ratio: AnySeriesPattern }} PercentRatioPattern
*/
/**
* Base entry item for compare and single-entry charts
* @typedef {Object} BaseEntryItem
* @property {string} name - Display name
* @property {Color} color - Item color
* @property {AnyPricePattern} costBasis - Cost basis series
* @property {PercentRatioPattern} returns - Returns series
* @property {AnyValuePattern} stack - Stack pattern
*/
/**
* Long-term entry item with CAGR
* @typedef {BaseEntryItem & { cagr: PercentRatioPattern }} LongEntryItem
*/
const ALL_YEARS = /** @type {const} */ ([...YEARS_2020S, ...YEARS_2010S]);
/**
* Build DCA class entry from year
* @param {Investing} investing
* @param {DcaYear} year
* @param {number} i
* @returns {BaseEntryItem}
*/
function buildYearEntry(investing, year, i) {
const key = /** @type {DcaYearKey} */ (`from${year}`);
return {
name: `${year}`,
color: colors.at(i, ALL_YEARS.length),
costBasis: investing.class.dcaCostBasis[key],
returns: investing.class.dcaReturn[key],
stack: investing.class.dcaStack[key],
};
}
/**
* Create Investing section
* @returns {PartialOptionsGroup}
*/
export function createInvestingSection() {
const { market, investing } = brk.series;
const { lookback, returns } = market;
return {
name: "Investing",
tree: [
createDcaVsLumpSumSection({ investing, lookback, returns }),
createDcaByPeriodSection({ investing, returns }),
createLumpSumByPeriodSection({ investing, lookback, returns }),
createDcaByStartYearSection({ investing }),
],
};
}
/**
* Create compare folder from items
* @param {string} context
* @param {Pick<BaseEntryItem, 'name' | 'color' | 'costBasis' | 'returns' | 'stack'>[]} items
*/
function createCompareFolder(context, items) {
const topPane = items.map(({ name, color, costBasis }) =>
price({ series: costBasis, name, color }),
);
return {
name: "Compare",
tree: [
{
name: "Cost Basis",
title: `Cost Basis: ${context}`,
top: topPane,
},
{
name: "Returns",
title: `Returns: ${context}`,
top: topPane,
bottom: items.flatMap(({ name, color, returns }) =>
percentRatioBaseline({
pattern: returns,
name,
color: [color, color],
}),
),
},
{
name: "Accumulated",
title: `Accumulated Value ($100/day): ${context}`,
top: topPane,
bottom: items.flatMap(({ name, color, stack }) =>
satsBtcUsd({ pattern: stack, name, color }),
),
},
],
};
}
/**
* Create compare folder from long items (includes CAGR chart)
* @param {string} context
* @param {LongEntryItem[]} items
*/
function createLongCompareFolder(context, items) {
const topPane = items.map(({ name, color, costBasis }) =>
price({ series: costBasis, name, color }),
);
return {
name: "Compare",
tree: [
{
name: "Cost Basis",
title: `Cost Basis: ${context}`,
top: topPane,
},
{
name: "Returns",
title: `Returns: ${context}`,
top: topPane,
bottom: items.flatMap(({ name, color, returns }) =>
percentRatioBaseline({
pattern: returns,
name,
color: [color, color],
}),
),
},
{
name: "CAGR",
title: `CAGR: ${context}`,
top: topPane,
bottom: items.flatMap(({ name, color, cagr }) =>
percentRatioBaseline({
pattern: cagr,
name,
color: [color, color],
}),
),
},
{
name: "Accumulated",
title: `Accumulated Value ($100/day): ${context}`,
top: topPane,
bottom: items.flatMap(({ name, color, stack }) =>
satsBtcUsd({ pattern: stack, name, color }),
),
},
],
};
}
/**
* Create single entry tree structure
* @param {BaseEntryItem & { titlePrefix?: string }} item
* @param {object[]} returnsBottom - Bottom pane items for returns chart
*/
function createSingleEntryTree(item, returnsBottom) {
const { name, titlePrefix = name, color, costBasis, stack } = item;
const top = [price({ series: costBasis, name: "Cost Basis", color })];
return {
name,
tree: [
{ name: "Cost Basis", title: `Cost Basis: ${titlePrefix}`, top },
{
name: "Returns",
title: `Returns: ${titlePrefix}`,
top,
bottom: returnsBottom,
},
{
name: "Accumulated",
title: `Accumulated Value ($100/day): ${titlePrefix}`,
top,
bottom: satsBtcUsd({ pattern: stack, name: "Value" }),
},
],
};
}
/**
* Create a single entry from a base item (no CAGR)
* @param {BaseEntryItem & { titlePrefix?: string }} item
*/
function createShortSingleEntry(item) {
return createSingleEntryTree(
item,
percentRatioBaseline({ pattern: item.returns, name: "Return" }),
);
}
/**
* Create a single entry from a long item (with CAGR as its own chart)
* @param {LongEntryItem & { titlePrefix?: string }} item
*/
function createLongSingleEntry(item) {
const {
name,
titlePrefix = name,
color,
costBasis,
returns,
cagr,
stack,
} = item;
const top = [price({ series: costBasis, name: "Cost Basis", color })];
return {
name,
tree: [
{ name: "Cost Basis", title: `Cost Basis: ${titlePrefix}`, top },
{
name: "Returns",
title: `Returns: ${titlePrefix}`,
top,
bottom: percentRatioBaseline({ pattern: returns, name: "Return" }),
},
{
name: "CAGR",
title: `CAGR: ${titlePrefix}`,
top,
bottom: percentRatioBaseline({ pattern: cagr, name: "CAGR" }),
},
{
name: "Accumulated",
title: `Accumulated Value ($100/day): ${titlePrefix}`,
top,
bottom: satsBtcUsd({ pattern: stack, name: "Value" }),
},
],
};
}
/**
* Create DCA vs Lump Sum section
* @param {Object} args
* @param {Investing} args.investing
* @param {Market["lookback"]} args.lookback
* @param {Market["returns"]} args.returns
*/
export function createDcaVsLumpSumSection({ investing, lookback, returns }) {
/** @param {AllPeriodKey} key */
const topPane = (key) => [
price({
series: investing.period.dcaCostBasis[key],
name: "DCA",
color: colors.profit,
}),
price({ series: lookback[key], name: "Lump Sum", color: colors.bitcoin }),
];
/** @param {string} name @param {AllPeriodKey} key */
const costBasisChart = (name, key) => ({
name: "Cost Basis",
title: `Cost Basis: ${name} DCA vs Lump Sum`,
top: topPane(key),
});
/** @param {string} name @param {AllPeriodKey} key */
const returnsChart = (name, key) => ({
name: "Returns",
title: `Returns: ${name} DCA vs Lump Sum`,
top: topPane(key),
bottom: [
...percentRatioBaseline({
pattern: investing.period.dcaReturn[key],
name: "DCA",
}),
...percentRatioBaseline({
pattern: investing.period.lumpSumReturn[key],
name: "Lump Sum",
color: colors.bi.p2,
}),
],
});
/** @param {string} name @param {LongPeriodKey} key */
const longCagrChart = (name, key) => ({
name: "CAGR",
title: `CAGR: ${name} DCA vs Lump Sum`,
top: topPane(key),
bottom: [
...percentRatioBaseline({
pattern: investing.period.dcaCagr[key],
name: "DCA",
}),
...percentRatioBaseline({
pattern: returns.cagr[key],
name: "Lump Sum",
color: colors.bi.p2,
}),
],
});
/** @param {string} name @param {AllPeriodKey} key */
const stackChart = (name, key) => ({
name: "Accumulated",
title: `Accumulated Value ($100/day): ${name} DCA vs Lump Sum`,
top: topPane(key),
bottom: [
...satsBtcUsd({
pattern: investing.period.dcaStack[key],
name: "DCA",
color: colors.profit,
}),
...satsBtcUsd({
pattern: investing.period.lumpSumStack[key],
name: "Lump Sum",
color: colors.bitcoin,
}),
],
});
/** @param {ShortPeriodKey} key */
const createShortPeriodEntry = (key) => {
const name = periodName(key);
return {
name,
tree: [
costBasisChart(name, key),
returnsChart(name, key),
stackChart(name, key),
],
};
};
/** @param {LongPeriodKey} key */
const createLongPeriodEntry = (key) => {
const name = periodName(key);
return {
name,
tree: [
costBasisChart(name, key),
returnsChart(name, key),
longCagrChart(name, key),
stackChart(name, key),
],
};
};
return {
name: "DCA vs Lump Sum",
title: "Compare Investment Strategies",
tree: [
{
name: "Short Term",
title: "Up to 1 Year",
tree: SHORT_PERIODS.map(createShortPeriodEntry),
},
{
name: "Long Term",
title: "2+ Years",
tree: LONG_PERIODS.map(createLongPeriodEntry),
},
],
};
}
/**
* Create period-based section (DCA or Lump Sum)
* @param {Object} args
* @param {Investing} args.investing
* @param {Market["lookback"]} [args.lookback]
* @param {Market["returns"]} args.returns
*/
function createPeriodSection({ investing, lookback, returns }) {
const isLumpSum = !!lookback;
const suffix = isLumpSum ? "Lump Sum" : "DCA";
const allPeriods = /** @type {const} */ ([...SHORT_PERIODS, ...LONG_PERIODS]);
/** @param {AllPeriodKey} key @param {number} i @returns {BaseEntryItem} */
const buildBaseEntry = (key, i) => ({
name: periodName(key),
color: colors.at(i, allPeriods.length),
costBasis: isLumpSum ? lookback[key] : investing.period.dcaCostBasis[key],
returns: isLumpSum
? investing.period.lumpSumReturn[key]
: investing.period.dcaReturn[key],
stack: isLumpSum
? investing.period.lumpSumStack[key]
: investing.period.dcaStack[key],
});
/** @param {LongPeriodKey} key @param {number} i @returns {LongEntryItem} */
const buildLongEntry = (key, i) =>
withCagr(
buildBaseEntry(key, i),
isLumpSum ? returns.cagr[key] : investing.period.dcaCagr[key],
);
/** @param {BaseEntryItem} entry */
const createShortEntry = (entry) =>
createShortSingleEntry({
...entry,
titlePrefix: `${entry.name} ${suffix}`,
});
/** @param {LongEntryItem} entry */
const createLongEntry = (entry) =>
createLongSingleEntry({
...entry,
titlePrefix: `${entry.name} ${suffix}`,
});
const shortEntries = SHORT_PERIODS.map((key, i) => buildBaseEntry(key, i));
const longEntries = LONG_PERIODS.map((key, i) =>
buildLongEntry(key, SHORT_PERIODS.length + i),
);
return {
name: `${suffix} by Period`,
title: `${suffix} Performance by Investment Period`,
tree: [
{
name: "Short Term",
title: "Up to 1 Year",
tree: [
createCompareFolder(`Short Term ${suffix}`, shortEntries),
...shortEntries.map(createShortEntry),
],
},
{
name: "Long Term",
title: "2+ Years",
tree: [
createLongCompareFolder(`Long Term ${suffix}`, longEntries),
...longEntries.map(createLongEntry),
],
},
],
};
}
/**
* Create DCA by Period section
* @param {Object} args
* @param {Investing} args.investing
* @param {Market["returns"]} args.returns
*/
export function createDcaByPeriodSection({ investing, returns }) {
return createPeriodSection({ investing, returns });
}
/**
* Create Lump Sum by Period section
* @param {Object} args
* @param {Investing} args.investing
* @param {Market["lookback"]} args.lookback
* @param {Market["returns"]} args.returns
*/
export function createLumpSumByPeriodSection({ investing, lookback, returns }) {
return createPeriodSection({ investing, lookback, returns });
}
/**
* Create DCA by Start Year section
* @param {Object} args
* @param {Investing} args.investing
*/
export function createDcaByStartYearSection({ investing }) {
/** @param {string} name @param {string} title @param {BaseEntryItem[]} entries */
const createDecadeGroup = (name, title, entries) => ({
name,
title,
tree: [
createCompareFolder(`${name} DCA`, entries),
...entries.map((entry) =>
createShortSingleEntry({
...entry,
titlePrefix: `${entry.name} DCA`,
}),
),
],
});
const entries2020s = YEARS_2020S.map((year, i) =>
buildYearEntry(investing, year, i),
);
const entries2010s = YEARS_2010S.map((year, i) =>
buildYearEntry(investing, year, YEARS_2020S.length + i),
);
return {
name: "DCA by Start Year",
title: "DCA Performance by When You Started",
tree: [
createCompareFolder("All Years DCA", [...entries2020s, ...entries2010s]),
createDecadeGroup("2020s", "2020-2026", entries2020s),
createDecadeGroup("2010s", "2015-2019", entries2010s),
],
};
}
File diff suppressed because it is too large Load Diff
-626
View File
@@ -1,626 +0,0 @@
/** Mining section - Network security and miner economics */
import { Unit } from "../utils/units.js";
import { entries, includes } from "../utils/array.js";
import { colors } from "../utils/colors.js";
import {
line,
dots,
dotted,
distributionBtcSatsUsd,
statsAtWindow,
ROLLING_WINDOWS,
percentRatio,
percentRatioBaseline,
chartsFromCount,
} from "./series.js";
import {
satsBtcUsdFrom,
satsBtcUsdFullTree,
revenueBtcSatsUsd,
revenueRollingBtcSatsUsd,
formatCohortTitle,
} from "./shared.js";
import { brk } from "../utils/client.js";
/** Major pools to show in Compare section (by current hashrate dominance) */
const MAJOR_POOL_IDS = /** @type {const} */ ([
"foundryusa",
"antpool",
"viabtc",
"f2pool",
"marapool",
"braiinspool",
"spiderpool",
"ocean",
]);
/**
* AntPool & friends - pools sharing AntPool's block templates
* Based on b10c's research: https://b10c.me/blog/015-bitcoin-mining-centralization/
*/
const ANTPOOL_AND_FRIENDS_IDS = /** @type {const} */ ([
"antpool",
"poolin",
"btccom",
"braiinspool",
"ultimuspool",
"binancepool",
"secpool",
"sigmapoolcom",
"rawpool",
"luxor",
]);
/**
* Create Mining section
* @returns {PartialOptionsGroup}
*/
export function createMiningSection() {
const { blocks, pools, mining } = brk.series;
const majorPoolData = entries(pools.major).map(([id, pool]) => ({
id,
name: brk.POOL_ID_TO_POOL_NAME[id],
pool,
}));
const minorPoolData = entries(pools.minor).map(([id, pool]) => ({
id,
name: brk.POOL_ID_TO_POOL_NAME[id],
pool,
}));
const featuredPools = majorPoolData.filter((p) =>
includes(MAJOR_POOL_IDS, p.id),
);
const antpoolFriends = majorPoolData.filter((p) =>
includes(ANTPOOL_AND_FRIENDS_IDS, p.id),
);
/**
* @param {(metric: string) => string} title
* @param {string} metric
* @param {DominancePattern} dominance
*/
const dominanceTree = (title, metric, dominance) => ({
name: "Dominance",
tree: [
{
name: "Compare",
title: title(metric),
bottom: [
...ROLLING_WINDOWS.flatMap((w) =>
percentRatio({
pattern: dominance[w.key],
name: w.name,
color: w.color,
defaultActive: w.key !== "_24h",
}),
),
...percentRatio({
pattern: dominance,
name: "All Time",
color: colors.time.all,
}),
],
},
...ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} ${metric}`),
bottom: percentRatio({
pattern: dominance[w.key],
name: "Dominance",
color: w.color,
}),
})),
{
name: "All Time",
title: title(`All Time ${metric}`),
bottom: percentRatio({
pattern: dominance,
name: "Dominance",
color: colors.time.all,
}),
},
],
});
/**
* @param {typeof majorPoolData} poolList
*/
const createPoolTree = (poolList) =>
poolList.map(({ name, pool }) => {
const title = formatCohortTitle(name);
return {
name,
tree: [
dominanceTree(title, "Dominance", pool.dominance),
{
name: "Blocks Mined",
tree: chartsFromCount({
pattern: pool.blocksMined,
title,
metric: "Blocks Mined",
unit: Unit.count,
}),
},
{
name: "Rewards",
tree: satsBtcUsdFullTree({
pattern: pool.rewards,
title,
metric: "Rewards",
}),
},
],
};
});
/**
* @param {typeof minorPoolData} poolList
*/
const createMinorPoolTree = (poolList) =>
poolList.map(({ name, pool }) => {
const title = formatCohortTitle(name);
return {
name,
tree: [
{
name: "Dominance",
title: title("Dominance"),
bottom: percentRatio({
pattern: pool.dominance,
name: "All Time",
color: colors.time.all,
}),
},
{
name: "Blocks Mined",
tree: chartsFromCount({
pattern: pool.blocksMined,
title,
metric: "Blocks Mined",
unit: Unit.count,
}),
},
],
};
});
/**
* @param {string} groupTitle
* @param {typeof majorPoolData} poolList
* @param {string} [name]
*/
const createPoolCompare = (groupTitle, poolList, name = "Compare") => ({
name,
tree: [
{
name: "Dominance",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: formatCohortTitle(groupTitle)(`${w.title} Dominance`),
bottom: poolList.flatMap((p, i) =>
percentRatio({
pattern: p.pool.dominance[w.key],
name: p.name,
color: colors.at(i, poolList.length),
}),
),
})),
},
{
name: "Blocks Mined",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: formatCohortTitle(groupTitle)(`${w.title} Blocks Mined`),
bottom: poolList.map((p, i) =>
line({
series: p.pool.blocksMined.sum[w.key],
name: p.name,
color: colors.at(i, poolList.length),
unit: Unit.count,
}),
),
})),
},
],
});
return {
name: "Mining",
tree: [
{
name: "Hashrate",
tree: [
{
name: "Current",
title: "Network Hashrate",
bottom: [
dots({
series: mining.hashrate.rate.base,
name: "Hashrate",
unit: Unit.hashRate,
}),
line({
series: mining.hashrate.rate.sma._1w,
name: "1w SMA",
color: colors.time._1w,
unit: Unit.hashRate,
defaultActive: false,
}),
line({
series: mining.hashrate.rate.sma._1m,
name: "1m SMA",
color: colors.time._1m,
unit: Unit.hashRate,
defaultActive: false,
}),
line({
series: mining.hashrate.rate.sma._2m,
name: "2m SMA",
color: colors.indicator.main,
unit: Unit.hashRate,
defaultActive: false,
}),
line({
series: mining.hashrate.rate.sma._1y,
name: "1y SMA",
color: colors.time._1y,
unit: Unit.hashRate,
defaultActive: false,
}),
dotted({
series: blocks.difficulty.hashrate,
name: "From Difficulty",
color: colors.default,
unit: Unit.hashRate,
}),
line({
series: mining.hashrate.rate.ath,
name: "ATH",
color: colors.loss,
unit: Unit.hashRate,
defaultActive: false,
}),
],
},
{
name: "ATH",
title: "Network Hashrate ATH",
bottom: [
line({
series: mining.hashrate.rate.ath,
name: "ATH",
color: colors.loss,
unit: Unit.hashRate,
}),
dots({
series: mining.hashrate.rate.base,
name: "Hashrate",
color: colors.bitcoin,
unit: Unit.hashRate,
}),
],
},
{
name: "Drawdown",
title: "Network Hashrate Drawdown",
bottom: percentRatio({
pattern: mining.hashrate.rate.drawdown,
name: "Drawdown",
color: colors.loss,
}),
},
],
},
{
name: "Revenue",
tree: [
...ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: `${w.title} Mining Revenue`,
bottom: revenueRollingBtcSatsUsd({
coinbase: mining.rewards.coinbase.average[w.key],
subsidy: mining.rewards.subsidy.average[w.key],
fee: mining.rewards.fees.average[w.key],
}),
})),
{
name: "Cumulative",
title: "Cumulative Mining Revenue",
bottom: revenueBtcSatsUsd({
coinbase: mining.rewards.coinbase,
subsidy: mining.rewards.subsidy,
fee: mining.rewards.fees,
key: "cumulative",
}),
},
{
name: "Coinbase",
tree: satsBtcUsdFullTree({
pattern: mining.rewards.coinbase,
metric: "Coinbase Rewards",
}),
},
{
name: "Subsidy",
tree: satsBtcUsdFullTree({
pattern: mining.rewards.subsidy,
metric: "Block Subsidy",
}),
},
{
name: "Fees",
tree: [
...satsBtcUsdFullTree({
pattern: mining.rewards.fees,
metric: "Transaction Fee Revenue",
}),
{
name: "Distribution",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: `${w.title} Fee Revenue per Block Distribution`,
bottom: distributionBtcSatsUsd(
statsAtWindow(mining.rewards.fees, w.key),
),
})),
},
],
},
{
name: "Dominance",
tree: [
...ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: `${w.title} Mining Revenue Dominance`,
bottom: [
...percentRatio({
pattern: mining.rewards.subsidy.dominance[w.key],
name: "Subsidy",
color: colors.mining.subsidy,
}),
...percentRatio({
pattern: mining.rewards.fees.dominance[w.key],
name: "Fees",
color: colors.mining.fee,
}),
],
})),
{
name: "All Time",
title: "All Time Mining Revenue Dominance",
bottom: [
...percentRatio({
pattern: mining.rewards.subsidy.dominance,
name: "Subsidy",
color: colors.mining.subsidy,
}),
...percentRatio({
pattern: mining.rewards.fees.dominance,
name: "Fees",
color: colors.mining.fee,
}),
],
},
],
},
{
name: "Fee-to-Subsidy",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: `${w.title} Fee-to-Subsidy Ratio`,
bottom: [
line({
series: mining.rewards.fees.toSubsidyRatio[w.key].ratio,
name: "Ratio",
color: colors.mining.fee,
unit: Unit.ratio,
}),
],
})),
},
{
name: "Unclaimed",
title: "Unclaimed Rewards",
bottom: satsBtcUsdFrom({
source: mining.rewards.unclaimed,
key: "cumulative",
name: "All Time",
}),
},
],
},
{
name: "Economics",
tree: [
{
name: "Hash Price",
title: "Hash Price",
bottom: [
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: "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: "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,
}),
],
},
],
},
{
name: "Halving",
tree: [
{
name: "Countdown",
title: "Next Halving",
bottom: [
line({
series: blocks.halving.blocksToHalving,
name: "Blocks",
unit: Unit.blocks,
}),
line({
series: blocks.halving.daysToHalving,
name: "Days",
unit: Unit.days,
}),
],
},
{
name: "Epoch",
title: "Halving Epoch",
bottom: [
line({
series: blocks.halving.epoch,
name: "Epoch",
unit: Unit.epoch,
}),
],
},
],
},
{
name: "Difficulty",
tree: [
{
name: "Current",
title: "Mining Difficulty",
bottom: [
line({
series: blocks.difficulty.value,
name: "Difficulty",
unit: Unit.difficulty,
}),
],
},
{
name: "Adjustment",
title: "Difficulty Adjustment",
bottom: percentRatioBaseline({
pattern: blocks.difficulty.adjustment,
name: "Change",
}),
},
{
name: "Countdown",
title: "Next Difficulty Adjustment",
bottom: [
line({
series: blocks.difficulty.blocksToRetarget,
name: "Blocks",
unit: Unit.blocks,
}),
line({
series: blocks.difficulty.daysToRetarget,
name: "Days",
unit: Unit.days,
}),
],
},
{
name: "Epoch",
title: "Difficulty Epoch",
bottom: [
line({
series: blocks.difficulty.epoch,
name: "Epoch",
unit: Unit.epoch,
}),
],
},
],
},
{
name: "Pools",
tree: [
createPoolCompare("Major Pools", featuredPools, "Featured"),
{
name: "AntPool & Friends",
tree: [
createPoolCompare("AntPool & Friends", antpoolFriends),
...createPoolTree(antpoolFriends),
],
},
{
name: "Major",
tree: createPoolTree(majorPoolData),
},
{
name: "Minor",
tree: createMinorPoolTree(minorPoolData),
},
],
},
],
};
}
File diff suppressed because it is too large Load Diff
-340
View File
@@ -1,340 +0,0 @@
/** Partial options - Main entry point */
import {
buildCohortData,
createCohortFolderAll,
createCohortFolderFull,
createCohortFolderWithAdjusted,
createCohortFolderLongTerm,
createCohortFolderAgeRangeWithMatured,
createCohortFolderBasicWithMarketCap,
createCohortFolderWithoutRelative,
createCohortFolderAddress,
createAddressCohortFolder,
createGroupedCohortFolderWithAdjusted,
createGroupedCohortFolderWithNupl,
createGroupedCohortFolderAgeRangeWithMatured,
createGroupedCohortFolderBasicWithMarketCap,
createGroupedCohortFolderAddress,
createGroupedAddressCohortFolder,
createUtxoProfitabilitySection,
createAddressBalanceGiniLeaf,
} from "./distribution/index.js";
import { createMarketSection } from "./market.js";
import { createNetworkSection } from "./network.js";
import { createMiningSection } from "./mining.js";
import { createCointimeSection } from "./cointime.js";
import { createInvestingSection } from "./investing.js";
import {
oracleOutputsHeatmapOption,
oraclePaymentsHeatmapOption,
} from "../../src/heatmap/oracle.js";
import {
urpdAgeBandHeatmapFolders,
urpdAllHeatmapOptions,
urpdLthHeatmapOptions,
urpdSthHeatmapOptions,
} from "../../src/heatmap/urpd.js";
// Re-export types for external consumers
export * from "./types.js";
/**
* Create partial options tree
* @returns {PartialOptionsTree}
*/
export function createPartialOptions() {
// Build cohort data
const {
cohortAll,
termShort,
termLong,
underAge,
overAge,
ageRange,
epoch,
utxosOverAmount,
addressesOverAmount,
utxosUnderAmount,
addressesUnderAmount,
utxosAmountRange,
addressesAmountRange,
typeAddressable,
typeOther,
class: class_,
profitabilityRange,
profitabilityProfit,
profitabilityLoss,
} = buildCohortData();
return [
{
name: "Explorer",
kind: "explorer",
title: "Explorer",
},
{
name: "Charts",
tree: [
createMarketSection(),
createNetworkSection(),
createMiningSection(),
{
name: "Distribution",
tree: [
createCohortFolderAll({ ...cohortAll, name: "Overview" }),
createGroupedCohortFolderWithNupl({
name: "STH vs LTH",
title: "STH vs LTH",
list: [termShort, termLong],
all: cohortAll,
}),
createCohortFolderFull(termShort),
createCohortFolderLongTerm(termLong),
{
name: "UTXO Age",
tree: [
{
name: "Under",
tree: [
createGroupedCohortFolderWithAdjusted({
name: "Compare",
title: "Under Age",
list: underAge,
all: cohortAll,
}),
...underAge.map(createCohortFolderWithAdjusted),
],
},
{
name: "Over",
tree: [
createGroupedCohortFolderWithAdjusted({
name: "Compare",
title: "Over Age",
list: overAge,
all: cohortAll,
}),
...overAge.map(createCohortFolderWithAdjusted),
],
},
{
name: "Range",
tree: [
createGroupedCohortFolderAgeRangeWithMatured({
name: "Compare",
title: "Age Ranges",
list: ageRange,
all: cohortAll,
}),
...ageRange.map(createCohortFolderAgeRangeWithMatured),
],
},
],
},
{
name: "UTXO Size",
tree: [
{
name: "Under",
tree: [
createGroupedCohortFolderBasicWithMarketCap({
name: "Compare",
title: "Under Amount",
list: utxosUnderAmount,
all: cohortAll,
}),
...utxosUnderAmount.map(
createCohortFolderBasicWithMarketCap,
),
],
},
{
name: "Over",
tree: [
createGroupedCohortFolderBasicWithMarketCap({
name: "Compare",
title: "Over Amount",
list: utxosOverAmount,
all: cohortAll,
}),
...utxosOverAmount.map(
createCohortFolderBasicWithMarketCap,
),
],
},
{
name: "Range",
tree: [
createGroupedCohortFolderBasicWithMarketCap({
name: "Compare",
title: "Amount Ranges",
list: utxosAmountRange,
all: cohortAll,
}),
...utxosAmountRange.map(
createCohortFolderBasicWithMarketCap,
),
],
},
],
},
createUtxoProfitabilitySection({
range: profitabilityRange,
profit: profitabilityProfit,
loss: profitabilityLoss,
}),
{
name: "Address Balance",
tree: [
{
name: "Under",
tree: [
createGroupedAddressCohortFolder({
name: "Compare",
title: "Under Balance",
list: addressesUnderAmount,
all: cohortAll,
}),
...addressesUnderAmount.map(createAddressCohortFolder),
],
},
{
name: "Over",
tree: [
createGroupedAddressCohortFolder({
name: "Compare",
title: "Over Balance",
list: addressesOverAmount,
all: cohortAll,
}),
...addressesOverAmount.map(createAddressCohortFolder),
],
},
{
name: "Range",
tree: [
createGroupedAddressCohortFolder({
name: "Compare",
title: "Balance Ranges",
list: addressesAmountRange,
all: cohortAll,
}),
...addressesAmountRange.map(createAddressCohortFolder),
],
},
createAddressBalanceGiniLeaf(),
],
},
{
name: "Script Type",
tree: [
createGroupedCohortFolderAddress({
name: "Compare",
title: "Script Type",
list: typeAddressable,
all: cohortAll,
}),
.../** @satisfies {readonly SpendableType[]} */ ([
"p2a",
"p2tr",
"p2wsh",
"p2wpkh",
"p2sh",
"p2ms",
"p2pkh",
"p2pk33",
"p2pk65",
"empty",
"unknown",
]).flatMap((key) => {
const addr = typeAddressable.find((t) => t.key === key);
if (addr) return [createCohortFolderAddress(addr)];
const other = typeOther.find((t) => t.key === key);
if (other) return [createCohortFolderWithoutRelative(other)];
return [];
}),
],
},
{
name: "Epoch",
tree: [
createGroupedCohortFolderWithAdjusted({
name: "Compare",
title: "Epoch",
list: epoch,
all: cohortAll,
}),
...epoch.map(createCohortFolderWithAdjusted),
],
},
{
name: "Class",
tree: [
createGroupedCohortFolderWithAdjusted({
name: "Compare",
title: "Class",
list: class_,
all: cohortAll,
}),
...class_.map(createCohortFolderWithAdjusted),
],
},
],
},
createInvestingSection(),
{
name: "Frameworks",
tree: [createCointimeSection()],
},
],
},
{
name: "Heatmaps",
tree: [
{
name: "Output Values",
tree: [oracleOutputsHeatmapOption, oraclePaymentsHeatmapOption],
},
{
name: "Price Distributions",
tree: [
...urpdAllHeatmapOptions,
{ name: "STH", tree: urpdSthHeatmapOptions },
{ name: "LTH", tree: urpdLthHeatmapOptions },
{ name: "Age Bands", tree: urpdAgeBandHeatmapFolders },
],
},
],
},
{
name: "API",
url: () => "/api",
title: "API documentation",
},
{
name: "Source",
url: () => "https://bitcoinresearchkit.org",
title: "Bitcoin Research Kit",
},
];
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-357
View File
@@ -1,357 +0,0 @@
/**
* @typedef {Object} BaseSeriesBlueprint
* @property {string} title
* @property {string} [key] - Optional key for persistence (derived from title if not provided)
* @property {boolean} [defaultActive]
*
* @typedef {Object} BaselineSeriesBlueprintSpecific
* @property {"Baseline"} type
* @property {Color} [color]
* @property {[Color, Color]} [colors]
* @property {BaselineSeriesPartialOptions} [options]
* @typedef {BaseSeriesBlueprint & BaselineSeriesBlueprintSpecific} BaselineSeriesBlueprint
*
* @typedef {Object} CandlestickSeriesBlueprintSpecific
* @property {"Candlestick"} type
* @property {[Color, Color]} [colors]
* @property {CandlestickSeriesPartialOptions} [options]
* @typedef {BaseSeriesBlueprint & CandlestickSeriesBlueprintSpecific} CandlestickSeriesBlueprint
*
* @typedef {Object} LineSeriesBlueprintSpecific
* @property {"Line"} [type]
* @property {Color} [color]
* @property {(value: number) => Color} [colorFn]
* @property {LineSeriesPartialOptions} [options]
* @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 {(value: number) => Color} [colorFn]
* @property {HistogramSeriesPartialOptions} [options]
* @typedef {BaseSeriesBlueprint & HistogramSeriesBlueprintSpecific} HistogramSeriesBlueprint
*
* @typedef {Object} DotsSeriesBlueprintSpecific
* @property {"Dots"} type
* @property {Color} [color]
* @property {LineSeriesPartialOptions} [options]
* @typedef {BaseSeriesBlueprint & DotsSeriesBlueprintSpecific} DotsSeriesBlueprint
*
* @typedef {Object} DotsBaselineSeriesBlueprintSpecific
* @property {"DotsBaseline"} type
* @property {Color} [color]
* @property {[Color, Color]} [colors]
* @property {BaselineSeriesPartialOptions} [options]
* @typedef {BaseSeriesBlueprint & DotsBaselineSeriesBlueprintSpecific} DotsBaselineSeriesBlueprint
*
* @typedef {Object} PriceSeriesBlueprintSpecific
* @property {"Price"} type
* @property {AnySeriesPattern} ohlcSeries - OHLC series for candlestick (>= 1h indexes)
* @property {[Color, Color]} [colors]
* @property {CandlestickSeriesPartialOptions} [options]
* @typedef {BaseSeriesBlueprint & PriceSeriesBlueprintSpecific} PriceSeriesBlueprint
*
* @typedef {BaselineSeriesBlueprint | CandlestickSeriesBlueprint | LineSeriesBlueprint | HistogramSeriesBlueprint | DotsSeriesBlueprint | DotsBaselineSeriesBlueprint | PriceSeriesBlueprint} AnySeriesBlueprint
*
* @typedef {AnySeriesBlueprint["type"]} SeriesType
*
* @typedef {{ series: AnySeriesPattern, unit?: Unit }} FetchedAnySeriesOptions
*
* @typedef {BaselineSeriesBlueprint & FetchedAnySeriesOptions} FetchedBaselineSeriesBlueprint
* @typedef {CandlestickSeriesBlueprint & FetchedAnySeriesOptions} FetchedCandlestickSeriesBlueprint
* @typedef {LineSeriesBlueprint & FetchedAnySeriesOptions} FetchedLineSeriesBlueprint
* @typedef {HistogramSeriesBlueprint & FetchedAnySeriesOptions} FetchedHistogramSeriesBlueprint
* @typedef {DotsSeriesBlueprint & FetchedAnySeriesOptions} FetchedDotsSeriesBlueprint
* @typedef {DotsBaselineSeriesBlueprint & FetchedAnySeriesOptions} FetchedDotsBaselineSeriesBlueprint
* @typedef {AnySeriesBlueprint & FetchedAnySeriesOptions} AnyFetchedSeriesBlueprint
*
* Any pattern with usd and sats sub-series (auto-expands to USD + sats)
* @typedef {{ usd: AnySeriesPattern, sats: AnySeriesPattern }} AnyPricePattern
*
* Any pattern with sats, btc, and usd sub-series (value patterns like stack)
* @typedef {{ sats: AnySeriesPattern, btc: AnySeriesPattern, usd: AnySeriesPattern }} AnyValuePattern
*
* Top pane price series - requires a price pattern with usd/sats, auto-expands to USD + sats
* @typedef {{ series: AnyPricePattern }} FetchedPriceSeriesOptions
* @typedef {LineSeriesBlueprint & FetchedPriceSeriesOptions} FetchedPriceSeriesBlueprint
*
* @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 {FetchedPriceSeriesBlueprint[]} [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} PartialHeatmapOptionSpecific
* @property {"heatmap"} kind
* @property {string} title
* @property {HeatmapPointSource} points
* @property {HeatmapGridFactory} grid
* @property {HeatmapColorFn} color
* @property {HeatmapAxis} [axis]
* @property {HeatmapDefaults} [defaults]
* @property {HeatmapTooltipFn} [tooltip]
*
* @typedef {PartialOption & PartialHeatmapOptionSpecific} PartialHeatmapOption
*
* @typedef {PartialHeatmapOption & ProcessedOptionAddons} HeatmapOption
*
* @typedef {Object} PartialUrlOptionSpecific
* @property {"link"} [kind]
* @property {() => string} url
* @property {string} title
* @property {boolean} [qrcode]
*
* @typedef {PartialOption & PartialUrlOptionSpecific} PartialUrlOption
*
* @typedef {Required<PartialUrlOption> & ProcessedOptionAddons} UrlOption
*
* @typedef {PartialExplorerOption | PartialChartOption | PartialUrlOption | PartialHeatmapOption} AnyPartialOption
*
* @typedef {ExplorerOption | ChartOption | UrlOption | HeatmapOption} 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 RelToMarketCap in relative (geAmount.*, ltAmount.*):
* @typedef {UtxoAmountPattern | AddrAmountPattern} PatternBasicWithMarketCap
*
* Patterns without RelToMarketCap in relative:
* - EpochPattern (epoch.*, year.*)
* - UtxoAmountPattern (amountRange.*)
* - OutputsRealizedSupplyUnrealizedPattern2 (addressable type.*)
* @typedef {EpochPattern | UtxoAmountPattern | EmptyPattern} PatternBasicWithoutMarketCap
*
* Patterns without relative section entirely (edge case output types):
* - EmptyPattern (type.empty, type.p2ms, type.unknown)
* @typedef {EmptyPattern} PatternWithoutRelative
*
* Union of basic patterns (for backwards compat)
* @typedef {PatternBasicWithMarketCap | PatternBasicWithoutMarketCap} 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
* @property {AddrCountPattern} addressCount
* @property {AvgAmountPattern} avgAmount
*
* 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
*
* Long term cohort (term.long) - has nupl via RelativePattern5
* @typedef {Object} CohortLongTerm
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {LongTermPattern} tree
*
* Age range cohort (ageRange.*) - no nupl via RelativePattern2
* @typedef {Object} CohortAgeRange
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {AgeRangePattern} tree
*
* Age range cohort with matured supply
* @typedef {CohortAgeRange & { matured: FullValuePattern }} CohortAgeRangeWithMatured
*
* Basic cohort WITH RelToMarketCap (geAmount.*, ltAmount.*)
* @typedef {Object} CohortBasicWithMarketCap
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {PatternBasicWithMarketCap} tree
*
* Basic cohort WITHOUT RelToMarketCap (epoch.*, amountRange.*, year.*, type.*)
* @typedef {Object} CohortBasicWithoutMarketCap
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {PatternBasicWithoutMarketCap} tree
*
* Cohort without relative section (edge case types: empty, p2ms, unknown)
* @typedef {Object} CohortWithoutRelative
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {PatternWithoutRelative} tree
*
* Union of basic cohort types
* @typedef {CohortBasicWithMarketCap | CohortBasicWithoutMarketCap} CohortBasic
*
* ============================================================================
* Extended Cohort Types (with address count)
* ============================================================================
*
* Addressable cohort with address count (for "type" cohorts - uses OutputsRealizedSupplyUnrealizedPattern2)
* @typedef {{ name: string, key: AddressableType, title: string, color: Color, tree: EmptyPattern, addressCount: AddrCountPattern, avgAmount: AvgAmountPattern, exposed: ExposedTree, reused: ReusedTree, respent: RespentTree }} CohortAddr
*
* ============================================================================
* Cohort Group Types (by capability)
* ============================================================================
*
* @typedef {Object} CohortGroupFull
* @property {string} name
* @property {string} title
* @property {readonly CohortFull[]} list
* @property {CohortAll} all
*
* @typedef {Object} CohortGroupWithAdjusted
* @property {string} name
* @property {string} title
* @property {readonly CohortWithAdjusted[]} list
* @property {CohortAll} all
*
* @typedef {Object} CohortGroupWithPercentiles
* @property {string} name
* @property {string} title
* @property {readonly CohortWithPercentiles[]} list
* @property {CohortAll} all
*
* @typedef {Object} CohortGroupLongTerm
* @property {string} name
* @property {string} title
* @property {readonly CohortLongTerm[]} list
* @property {CohortAll} all
*
* @typedef {Object} CohortGroupAgeRange
* @property {string} name
* @property {string} title
* @property {readonly CohortAgeRange[]} list
* @property {CohortAll} all
*
* @typedef {Object} CohortGroupBasicWithMarketCap
* @property {string} name
* @property {string} title
* @property {readonly CohortBasicWithMarketCap[]} list
* @property {CohortAll} all
*
* @typedef {Object} CohortGroupBasicWithoutMarketCap
* @property {string} name
* @property {string} title
* @property {readonly CohortBasicWithoutMarketCap[]} list
* @property {CohortAll} all
*
* @typedef {Object} CohortGroupWithoutRelative
* @property {string} name
* @property {string} title
* @property {readonly CohortWithoutRelative[]} list
* @property {CohortAll} all
*
* Union of basic cohort group types
* @typedef {CohortGroupBasicWithMarketCap | CohortGroupBasicWithoutMarketCap} CohortGroupBasic
*
* @typedef {Object} UtxoCohortGroupObject
* @property {string} name
* @property {string} title
* @property {readonly UtxoCohortObject[]} list
* @property {CohortAll} all
*
* @typedef {Object} AddrCohortObject
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {AddrCohortPattern} tree
* @property {AddrCountPattern} addressCount
*
* @typedef {UtxoCohortObject | AddrCohortObject | CohortWithoutRelative} CohortObject
*
* @typedef {Object} AddrCohortGroupObject
* @property {string} name
* @property {string} title
* @property {readonly AddrCohortObject[]} list
* @property {CohortAll} all
*
* @typedef {UtxoCohortGroupObject | AddrCohortGroupObject} CohortGroupObject
*
* @typedef {Object} CohortGroupAddr
* @property {string} name
* @property {string} title
* @property {readonly CohortAddr[]} list
* @property {CohortAll} all
*/
// Re-export for type consumers
export {};
-186
View File
@@ -1,186 +0,0 @@
import { localhost } from "../utils/env.js";
/**
* Walk a series tree and collect all chartable series patterns
* @param {TreeNode | null | undefined} node
* @param {Map<AnySeriesPattern, string[]>} map
* @param {string[]} path
*/
function walkSeries(node, map, path) {
if (node && "by" in node) {
const seriesNode = /** @type {AnySeriesPattern} */ (node);
if (!seriesNode.by.day1) return;
map.set(seriesNode, path);
} else if (node && typeof node === "object") {
for (const [key, value] of Object.entries(node)) {
const kn = key.toLowerCase();
if (
key === "sd24h" ||
key === "emaSlow" ||
key === "emaFast" ||
kn === "cents" ||
kn === "bps" ||
kn === "constants" ||
kn === "ohlc" ||
kn === "split" ||
kn === "spot" ||
kn.startsWith("timestamp") ||
kn.startsWith("coinyears") ||
kn.endsWith("index") ||
kn.endsWith("indexes")
)
continue;
const newPath = [...path, key];
const joined = newPath.join(".");
if (
joined.endsWith(".count.total.average") ||
joined.endsWith(".versions.v1.average") ||
joined.endsWith(".versions.v2.average") ||
joined.endsWith(".versions.v3.average")
)
continue;
walkSeries(/** @type {TreeNode | null | undefined} */ (value), map, newPath);
}
}
}
/**
* Walk partial options tree and delete referenced series from the map
* @param {PartialOptionsTree} options
* @param {Map<AnySeriesPattern, string[]>} map
*/
function walkOptions(options, map) {
for (const node of options) {
if ("tree" in node && node.tree) {
walkOptions(node.tree, map);
} else if ("top" in node || "bottom" in node) {
const chartNode = /** @type {PartialChartOption} */ (node);
markUsedBlueprints(map, chartNode.top);
markUsedBlueprints(map, chartNode.bottom);
}
}
}
/**
* @param {Map<AnySeriesPattern, string[]>} map
* @param {(AnyFetchedSeriesBlueprint | FetchedPriceSeriesBlueprint)[]} [arr]
*/
function markUsedBlueprints(map, arr) {
if (!arr) return;
for (let i = 0; i < arr.length; i++) {
const s = arr[i].series;
if (!s) continue;
const maybePriceSeries =
/** @type {{ usd?: AnySeriesPattern, sats?: AnySeriesPattern }} */ (
/** @type {unknown} */ (s)
);
if (maybePriceSeries.usd?.by && maybePriceSeries.sats?.by) {
map.delete(maybePriceSeries.usd);
map.delete(maybePriceSeries.sats);
} else {
map.delete(/** @type {AnySeriesPattern} */ (s));
}
}
}
/**
* Log unused series to console (localhost only)
* @param {TreeNode} seriesTree
* @param {PartialOptionsTree} partialOptions
*/
export function logUnused(seriesTree, partialOptions) {
if (!localhost) return;
console.log(extractTreeStructure(partialOptions));
/** @type {Map<AnySeriesPattern, string[]>} */
const all = new Map();
walkSeries(seriesTree, all, []);
walkOptions(partialOptions, all);
if (!all.size) return;
/** @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];
if (i === path.length - 1) {
current[part] = null;
} else {
current[part] = current[part] || {};
current = /** @type {Record<string, unknown>} */ (current[part]);
}
}
}
console.log("Unused series:", { count: all.size, tree });
}
/**
* Extract tree structure from partial options (names + hierarchy, series grouped by unit)
* @param {PartialOptionsTree} options
* @returns {object[]}
*/
export function extractTreeStructure(options) {
/**
* Group series by unit
* @param {(AnyFetchedSeriesBlueprint | FetchedPriceSeriesBlueprint)[]} series
* @param {boolean} isTop
* @returns {Record<string, string[]>}
*/
function groupByUnit(series, isTop) {
/** @type {Record<string, string[]>} */
const grouped = {};
for (const s of series) {
const pattern = /** @type {AnySeriesPattern | AnyPricePattern} */ (
s.series
);
if (isTop && "usd" in pattern && "sats" in pattern) {
const title = s.title || s.key || "unnamed";
(grouped["USD"] ??= []).push(title);
(grouped["sats"] ??= []).push(title);
} else {
const unit = /** @type {AnyFetchedSeriesBlueprint} */ (s).unit;
const unitName = unit?.name || "unknown";
const title = s.title || s.key || "unnamed";
(grouped[unitName] ??= []).push(title);
}
}
return grouped;
}
/**
* @param {AnyPartialOption | PartialOptionsGroup} node
* @returns {object}
*/
function processNode(node) {
if ("tree" in node && node.tree) {
return {
name: node.name,
children: node.tree.map(processNode),
};
}
if ("top" in node || "bottom" in node) {
const chartNode = /** @type {PartialChartOption} */ (node);
const top = chartNode.top ? groupByUnit(chartNode.top, true) : undefined;
const bottom = chartNode.bottom
? groupByUnit(chartNode.bottom, false)
: undefined;
return {
name: node.name,
title: chartNode.title,
...(top && Object.keys(top).length > 0 ? { top } : {}),
...(bottom && Object.keys(bottom).length > 0 ? { bottom } : {}),
};
}
if ("url" in node) {
return { name: node.name, url: true };
}
return { name: node.name };
}
return options.map(processNode);
}
-167
View File
@@ -1,167 +0,0 @@
import { createHeader } from "../utils/dom.js";
import { chartElement } from "../utils/elements.js";
import { INDEX_FROM_LABEL } from "../utils/serde.js";
import { Unit } from "../utils/units.js";
import { createChart } from "../utils/chart/index.js";
import { colors } from "../utils/colors.js";
import { latestPrice, onPrice } from "../utils/price.js";
import { brk } from "../utils/client.js";
const ONE_BTC_IN_SATS = 100_000_000;
/** @type {((opt: ChartOption) => void) | null} */
let _setOption = null;
/**
* @param {ChartOption} opt
*/
export function setOption(opt) {
if (!_setOption) throw new Error("Chart not initialized");
_setOption(opt);
}
export function init() {
const { headerElement, headingElement } = createHeader();
chartElement.append(headerElement);
const chart = createChart({
parent: chartElement,
brk,
});
const setChoices = chart.setIndexChoices;
/**
* Build top blueprints with price series prepended for each unit
* @param {Map<Unit, AnyFetchedSeriesBlueprint[]>} optionTop
* @returns {Map<Unit, AnyFetchedSeriesBlueprint[]>}
*/
function buildTopBlueprints(optionTop) {
/** @type {Map<Unit, AnyFetchedSeriesBlueprint[]>} */
const result = new Map();
const { ohlc, spot } = brk.series.prices;
result.set(Unit.usd, [
/** @type {AnyFetchedSeriesBlueprint} */ ({
type: "Price",
title: "Price",
series: spot.usd,
ohlcSeries: ohlc.usd,
}),
...(optionTop.get(Unit.usd) ?? []),
]);
result.set(Unit.sats, [
/** @type {AnyFetchedSeriesBlueprint} */ ({
type: "Price",
title: "Price",
series: spot.sats,
ohlcSeries: ohlc.sats,
colors: /** @type {const} */ ([colors.default, colors.background]),
}),
...(optionTop.get(Unit.sats) ?? []),
]);
return result;
}
function updatePriceWithLatest() {
const latest = latestPrice();
if (latest === null) return;
const priceSeries = chart.panes[0].series[0];
const unit = chart.panes[0].unit;
if (!priceSeries?.hasData() || !unit) return;
const last = priceSeries.getData().at(-1);
if (!last) return;
// Convert to sats if needed
const close =
unit === Unit.sats ? Math.floor(ONE_BTC_IN_SATS / latest) : latest;
if ("close" in last) {
// Candlestick data
priceSeries.update({ ...last, close });
} else {
// Line data
priceSeries.update({ ...last, value: close });
}
}
// Set up the setOption function
_setOption = (opt) => {
headingElement.innerHTML = opt.title;
// Set blueprints first so storageId is correct before any index change
chart.setBlueprints({
name: opt.title,
top: buildTopBlueprints(opt.top()),
bottom: opt.bottom(),
onDataLoaded: updatePriceWithLatest,
});
// Update index choices (may trigger rebuild if index changes)
setChoices(computeChoices(opt));
};
// Live price update listener
onPrice(updatePriceWithLatest);
}
/** @type {{ label: string, items: IndexLabel[] }[]} */
const ALL_GROUPS = [
{
label: "Time",
items: [
"10mn",
"30mn",
"1h",
"4h",
"12h",
"1d",
"3d",
"1w",
"1m",
"3m",
"6m",
"1y",
"10y",
],
},
{ label: "Block", items: ["blk", "epch", "halv"] },
];
const ALL_CHOICES = /** @satisfies {IndexLabel[]} */ (
ALL_GROUPS.flatMap((g) => g.items)
);
/**
* @param {ChartOption} opt
* @returns {{ choices: IndexLabel[], groups: { label: string, items: IndexLabel[] }[] }}
*/
function computeChoices(opt) {
if (!opt.top().size && !opt.bottom().size) {
return { choices: [...ALL_CHOICES], groups: ALL_GROUPS };
}
const rawIndexes = new Set(
[Array.from(opt.top().values()), Array.from(opt.bottom().values())]
.flat(2)
.filter((blueprint) => {
const path = Object.values(blueprint.series.by)[0]?.path ?? "";
return !path.includes("constant_");
})
.flatMap((blueprint) => blueprint.series.indexes()),
);
const groups = ALL_GROUPS.map(({ label, items }) => ({
label,
items: items.filter((choice) => rawIndexes.has(INDEX_FROM_LABEL[choice])),
})).filter(({ items }) => items.length > 0);
return {
choices: groups.flatMap((g) => g.items),
groups,
};
}
-191
View File
@@ -1,191 +0,0 @@
import {
searchInput,
searchLabelElement,
searchResultsElement,
} from "../utils/elements.js";
import { QuickMatch } from "../modules/quickmatch-js/0.5.0/src/index.js";
import { brk } from "../utils/client.js";
/**
* @param {Options} options
*/
export function init(options) {
console.log("search: init");
const haystack = options.list.map((option) => option.title.toLowerCase());
const titleToOption = new Map(
options.list.map((option) => [option.title.toLowerCase(), option]),
);
const matcher = new QuickMatch(haystack);
/** @type {HTMLLIElement | undefined} */
let highlighted;
/** @param {HTMLLIElement} [li] */
function setHighlight(li) {
if (highlighted) delete highlighted.dataset.highlight;
highlighted = li;
if (li) li.dataset.highlight = "";
}
const HEX64_RE = /^[0-9a-f]{64}$/i;
const ADDR_RE = /^([13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[a-z0-9]{8,87})$/;
/** @param {string} label @param {string} href @param {Element | null} [before] */
function createResultLink(label, href, before) {
const li = window.document.createElement("li");
const a = window.document.createElement("a");
a.href = href;
a.textContent = label;
a.title = label;
if (href === window.location.pathname) setHighlight(li);
a.addEventListener("click", (e) => {
e.preventDefault();
setHighlight(li);
history.pushState(null, "", href);
options.resolveUrl();
});
li.append(a);
searchResultsElement.insertBefore(li, before ?? null);
}
/** @type {AbortController | undefined} */
let lookupController;
/** @param {string} needle @param {AbortSignal} signal */
async function lookup(needle, signal) {
/** @type {Array<[string, string]>} */
const results = [];
if (HEX64_RE.test(needle)) {
const [blockRes, txRes] = await Promise.allSettled([
brk.getBlock(needle, { signal }),
brk.getTx(needle, { signal }),
]);
if (signal.aborted) return;
if (blockRes.status === "fulfilled")
results.push(["Block", `/block/${needle}`]);
if (txRes.status === "fulfilled")
results.push(["Transaction", `/tx/${needle}`]);
} else if (ADDR_RE.test(needle)) {
try {
const { isvalid } = await brk.validateAddress(needle, { signal });
if (signal.aborted || !isvalid) return;
results.push(["Address", `/address/${needle}`]);
} catch {
return;
}
} else {
return;
}
const before = searchResultsElement.firstElementChild;
for (const [label, href] of results) {
createResultLink(`${label} ${needle}`, href, before);
}
// Remove "No results" placeholder if present
const last = searchResultsElement.lastElementChild;
if (last && !last.querySelector("a")) last.remove();
}
function inputEvent() {
const needle = /** @type {string} */ (searchInput.value).trim();
if (lookupController) lookupController.abort();
searchResultsElement.scrollTo({ top: 0 });
searchResultsElement.innerHTML = "";
setHighlight();
if (!needle.length) return;
const matches = matcher.matches(needle);
const indexMatch = needle.match(
/^(?:(block|b)|(transaction|tx))?\s*#?\s*(\d+)$/i,
);
if (indexMatch) {
const num = indexMatch[3];
const entries = indexMatch[1]
? [["Block", `/block/${num}`]]
: indexMatch[2]
? [["Transaction", `/tx/${num}`]]
: [
["Block", `/block/${num}`],
["Transaction", `/tx/${num}`],
];
for (const [label, href] of entries) {
createResultLink(`${label} #${num}`, href);
}
}
lookupController = new AbortController();
lookup(needle, lookupController.signal);
if (matches.length) {
matches.forEach((title) => {
const option = titleToOption.get(title);
if (!option) return;
const li = window.document.createElement("li");
searchResultsElement.appendChild(li);
if (option === options.selected.value) setHighlight(li);
const element = options.createOptionElement({
option,
name: option.title,
});
if (element) li.append(element);
});
}
if (!searchResultsElement.children.length) {
const li = window.document.createElement("li");
li.textContent = "No results";
li.style.color = "var(--off-color)";
searchResultsElement.appendChild(li);
}
}
options.selected.onChange(() => {
const selected = options.selected.value;
const href =
selected?.kind === "explorer"
? window.location.pathname
: selected?.path.length
? `/${selected.path.join("/")}`
: null;
if (!href) return setHighlight();
for (const li of searchResultsElement.children) {
const a = li.querySelector("a");
if (a && a.getAttribute("href") === href) {
return setHighlight(/** @type {HTMLLIElement} */ (li));
}
}
setHighlight();
});
inputEvent();
searchInput.addEventListener("input", inputEvent);
const len = searchInput.value.length;
searchInput.setSelectionRange(len, len);
}
document.addEventListener("keydown", (e) => {
const el = document.activeElement;
const isTextInput =
el?.tagName === "INPUT" &&
/** @type {HTMLInputElement} */ (el).type === "text";
if (e.key === "/" && !isTextInput) {
e.preventDefault();
searchLabelElement.click();
searchInput.focus();
}
});
-42
View File
@@ -1,42 +0,0 @@
import { getElementById } from "../utils/dom.js";
import * as leanQr from "../modules/lean-qr/2.7.1/index.mjs";
const shareDiv = getElementById("share-div");
const shareContentDiv = getElementById("share-content-div");
const shareButton = getElementById("share-button");
const imgQrcode = /** @type {HTMLImageElement} */ (getElementById("share-img"));
const anchor = /** @type {HTMLAnchorElement} */ (
getElementById("share-anchor")
);
/** @param {string | null} url */
export function setQr(url) {
if (!url) {
shareDiv.hidden = true;
return;
}
anchor.href = url;
anchor.innerText =
(url.startsWith("http") ? url.split("//").at(-1) : url.split(":").at(-1)) ||
"";
imgQrcode.src =
// @ts-ignore — lean-qr types don't resolve for file path import
leanQr.generate(url)?.toDataURL({ padX: 0, padY: 0 }) || "";
shareDiv.hidden = false;
}
shareButton.addEventListener("click", () => {
setQr(window.location.href);
});
shareDiv.addEventListener("click", () => {
setQr(null);
});
shareContentDiv.addEventListener("click", (event) => {
event.stopPropagation();
event.preventDefault();
});
-34
View File
@@ -1,34 +0,0 @@
/**
* Typed Object.entries that preserves key types
* @template {Record<string, unknown>} T
* @param {T} obj
* @returns {[keyof T & string, T[keyof T & string]][]}
*/
export const entries = (obj) => /** @type {[keyof T & string, T[keyof T & string]][]} */ (Object.entries(obj));
/**
* Typed Object.fromEntries that preserves key/value types
* @template {string} K
* @template V
* @param {Iterable<readonly [K, V]>} pairs
* @returns {Record<K, V>}
*/
export const fromEntries = (pairs) => /** @type {Record<K, V>} */ (Object.fromEntries(pairs));
/**
* Type-safe includes that narrows the value type
* @template T
* @param {readonly T[]} arr
* @param {unknown} value
* @returns {value is T}
*/
export const includes = (arr, value) => arr.includes(/** @type {T} */ (value));
/**
* @template T
* @param {readonly T[]} arr
* @returns {T}
*/
export function randomFromArray(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
-119
View File
@@ -1,119 +0,0 @@
import { ios, canShare } from "../env.js";
import { style } from "../elements.js";
import { colors } from "../colors.js";
export const canCapture = !ios || canShare;
/**
* @param {Object} args
* @param {HTMLCanvasElement} args.screenshot
* @param {number} args.chartWidth
* @param {HTMLElement} args.parent
* @param {{ element: HTMLElement }[]} args.legends
*/
export function capture({ screenshot, chartWidth, parent, legends }) {
const dpr = screenshot.width / chartWidth;
const pad = Math.round(16 * dpr);
const fontSize = Math.round(14 * dpr);
const titleFontSize = Math.round(20 * dpr);
const circleRadius = Math.round(5 * dpr);
const legendHeight = Math.round(28 * dpr);
const titleHeight = Math.round(36 * dpr);
const title = (parent.querySelector("h1")?.textContent ?? "").toUpperCase();
const hasTitle = title.length > 0;
const hasTopLegend = legends[0].element.children.length > 0;
const hasBottomLegend = legends[1].element.children.length > 0;
const titleOffset = hasTitle ? titleHeight : 0;
const topLegendOffset = hasTopLegend ? legendHeight : 0;
const bottomOffset = hasBottomLegend ? legendHeight : 0;
const canvas = document.createElement("canvas");
canvas.width = screenshot.width + pad * 2;
canvas.height =
screenshot.height + pad * 2 + titleOffset + topLegendOffset + bottomOffset;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Background
const bodyBg = getComputedStyle(document.body).backgroundColor;
const htmlBg = getComputedStyle(document.documentElement).backgroundColor;
ctx.fillStyle = bodyBg === "rgba(0, 0, 0, 0)" ? htmlBg : bodyBg;
ctx.fillRect(0, 0, canvas.width, canvas.height);
/** @param {HTMLElement} legendEl @param {number} y */
const drawLegend = (legendEl, y) => {
ctx.font = `${fontSize}px ${style.fontFamily}`;
ctx.textAlign = "left";
ctx.textBaseline = "middle";
let x = pad;
for (const div of legendEl.children) {
const label = div.querySelector("label");
if (!label) continue;
const input = label.querySelector("input");
if (input && !input.checked) continue;
// Draw color circles
const colorSpans = label.querySelectorAll(".colors span");
for (const span of colorSpans) {
ctx.fillStyle = /** @type {HTMLElement} */ (span).style.backgroundColor;
ctx.beginPath();
ctx.arc(x + circleRadius, y, circleRadius, 0, Math.PI * 2);
ctx.fill();
x += circleRadius * 2 + Math.round(2 * dpr);
}
// Draw name
const name = label.querySelector(".name")?.textContent ?? "";
ctx.fillStyle = colors.default();
ctx.fillText(name, x + Math.round(4 * dpr), y);
x += ctx.measureText(name).width + Math.round(20 * dpr);
}
};
// Title
if (hasTitle) {
ctx.font = `${titleFontSize}px ${style.fontFamily}`;
ctx.fillStyle = colors.default();
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(title, pad, pad + titleHeight / 2);
}
// Top legend
if (hasTopLegend) {
drawLegend(legends[0].element, pad + titleOffset + topLegendOffset / 2);
}
// Chart
ctx.drawImage(screenshot, pad, pad + titleOffset + topLegendOffset);
// Bottom legend
if (hasBottomLegend) {
drawLegend(
legends[1].element,
pad +
titleOffset +
topLegendOffset +
screenshot.height +
legendHeight / 2,
);
}
// Watermark
ctx.fillStyle = colors.gray();
ctx.font = `${fontSize}px ${style.fontFamily}`;
ctx.textAlign = "right";
ctx.textBaseline = "bottom";
ctx.fillText(
window.location.host,
canvas.width - pad,
canvas.height - pad / 2,
);
// Open in new tab
canvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
window.open(url, "_blank");
setTimeout(() => URL.revokeObjectURL(url), 100);
}, "image/png");
}
File diff suppressed because it is too large Load Diff
-154
View File
@@ -1,154 +0,0 @@
import { createLabeledInput, createSpan, createSpanName } from "../dom.js";
import { stringToId } from "../format.js";
/** @param {HTMLElement} el */
function captureScroll(el) {
el.addEventListener("wheel", (e) => e.stopPropagation(), { passive: true });
el.addEventListener("touchstart", (e) => e.stopPropagation(), {
passive: true,
});
el.addEventListener("touchmove", (e) => e.stopPropagation(), {
passive: true,
});
}
/**
* Creates a `<legend>` with a scrollable `<div>`.
* Call `setPrefix(el)` to insert a prefix element followed by a `|` separator.
* Append further content to `scroller`.
*/
export function createLegend() {
const element = /** @type {HTMLLegendElement} */ (
window.document.createElement("legend")
);
const scroller = /** @type {HTMLDivElement} */ (
window.document.createElement("div")
);
element.append(scroller);
captureScroll(scroller);
const separator = createSpan("|");
captureScroll(separator);
return {
element,
scroller,
/** @param {HTMLElement} el */
setPrefix(el) {
const prev = separator.previousSibling;
if (prev) {
prev.replaceWith(el);
} else {
scroller.prepend(el, separator);
}
captureScroll(el);
},
};
}
export function createSeriesLegend() {
const legend = createLegend();
const items = window.document.createElement("div");
legend.scroller.append(items);
captureScroll(items);
/** @type {AnySeries | null} */
let hoveredSeries = null;
/** @type {Map<AnySeries, { span: HTMLSpanElement, color: Color }[]>} */
const seriesColorSpans = new Map();
/** @param {AnySeries | null} series */
function setHovered(series) {
if (hoveredSeries === series) return;
hoveredSeries = series;
for (const [entrySeries, colorSpans] of seriesColorSpans) {
const shouldHighlight = !hoveredSeries || hoveredSeries === entrySeries;
shouldHighlight ? entrySeries.highlight() : entrySeries.tame();
for (const { span, color } of colorSpans) {
span.style.backgroundColor = color.highlight(shouldHighlight);
}
}
}
/** @type {HTMLElement[]} */
const legends = [];
return {
element: legend.element,
setPrefix: legend.setPrefix,
/**
* @param {Object} args
* @param {AnySeries} args.series
* @param {string} args.name
* @param {number} args.order
* @param {Color[]} args.colors
*/
addOrReplace({ series, name, colors, order }) {
const div = window.document.createElement("div");
const prev = legends[order];
if (prev) {
prev.replaceWith(div);
} else {
const elementAtOrder = Array.from(items.children).at(order);
if (elementAtOrder) {
elementAtOrder.before(div);
} else {
items.append(div);
}
}
legends[order] = div;
const { label } = createLabeledInput({
inputId: stringToId(`legend-${series.id}`),
inputName: stringToId(`selected-${series.id}`),
inputValue: "value",
title: "Click to toggle",
inputChecked: series.active.value,
onClick: () => {
series.setActive(!series.active.value);
},
type: "checkbox",
});
const spanMain = window.document.createElement("span");
spanMain.classList.add("main");
label.append(spanMain);
const spanName = createSpanName(name);
spanMain.append(spanName);
div.append(label);
label.addEventListener("mouseover", () => setHovered(series));
label.addEventListener("mouseleave", () => setHovered(null));
const spanColors = window.document.createElement("span");
spanColors.classList.add("colors");
spanMain.prepend(spanColors);
/** @type {{ span: HTMLSpanElement, color: Color }[]} */
const colorSpans = [];
colors.forEach((color) => {
const spanColor = window.document.createElement("span");
spanColor.style.backgroundColor = color.highlight(true);
spanColors.append(spanColor);
colorSpans.push({ span: spanColor, color });
});
seriesColorSpans.set(series, colorSpans);
if (series.url) {
const anchor = window.document.createElement("a");
anchor.href = series.url;
anchor.target = "_blank";
anchor.rel = "noopener noreferrer";
anchor.title = "Open the series data in a new tab";
div.append(anchor);
}
},
/**
* @param {number} start
*/
removeFrom(start) {
legends.splice(start).forEach((child) => child.remove());
},
};
}
-8
View File
@@ -1,8 +0,0 @@
import { BrkClient } from "../modules/brk-client/index.js";
// const brk = new BrkClient("https://bitview.space");
const brk = new BrkClient("/");
console.log(`VERSION = ${brk.VERSION}`);
export { brk };
-403
View File
@@ -1,403 +0,0 @@
import { dark } from "./theme.js";
/** @type {Map<string, string>} */
const rgbaCache = new Map();
/**
* Convert oklch to rgba with caching
* @param {string} color - oklch color string
*/
function toRgba(color) {
if (color === "transparent") return color;
const cached = rgbaCache.get(color);
if (cached) return cached;
const rgba = oklchToRgba(color);
rgbaCache.set(color, rgba);
return rgba;
}
/**
* Reduce color opacity to 50% for dimming effect
* @param {string} color - oklch color string
*/
function tameColor(color) {
if (color === "transparent") return color;
return `${color.slice(0, -1)} / 25%)`;
}
/**
* @typedef {Object} ColorMethods
* @property {() => string} tame - Returns tamed (50% opacity) version
* @property {(highlighted: boolean) => string} highlight - Returns normal if highlighted, tamed otherwise
*/
/**
* @typedef {(() => string) & ColorMethods} Color
*/
/**
* Creates a Color object that is callable and has utility methods
* @param {() => string} getter
* @returns {Color}
*/
function createColor(getter) {
const color = /** @type {Color} */ (() => toRgba(getter()));
color.tame = () => toRgba(tameColor(getter()));
color.highlight = (highlighted) =>
highlighted ? toRgba(getter()) : toRgba(tameColor(getter()));
return color;
}
const globalComputedStyle = getComputedStyle(window.document.documentElement);
/**
* Resolve a light-dark() value based on current theme
* @param {string} value
*/
function resolveLightDark(value) {
if (value.startsWith("light-dark(")) {
const [light, _dark] = value.slice(11, -1).split(", ");
return dark ? _dark : light;
}
return value;
}
/**
* @param {string} name
*/
function getColor(name) {
return globalComputedStyle.getPropertyValue(`--${name}`).trim();
}
/**
* @param {string} property
*/
function getLightDarkValue(property) {
return resolveLightDark(
globalComputedStyle.getPropertyValue(property).trim(),
);
}
const palette = {
red: createColor(() => getColor("red")),
orange: createColor(() => getColor("orange")),
amber: createColor(() => getColor("amber")),
yellow: createColor(() => getColor("yellow")),
avocado: createColor(() => getColor("avocado")),
lime: createColor(() => getColor("lime")),
green: createColor(() => getColor("green")),
emerald: createColor(() => getColor("emerald")),
teal: createColor(() => getColor("teal")),
cyan: createColor(() => getColor("cyan")),
sky: createColor(() => getColor("sky")),
blue: createColor(() => getColor("blue")),
indigo: createColor(() => getColor("indigo")),
violet: createColor(() => getColor("violet")),
purple: createColor(() => getColor("purple")),
fuchsia: createColor(() => getColor("fuchsia")),
pink: createColor(() => getColor("pink")),
rose: createColor(() => getColor("rose")),
};
const paletteArr = Object.values(palette);
/**
* Get a palette color by index, spreading small groups for better separation
* @param {number} index
* @param {number} [length]
*/
function at(index, length) {
const n = paletteArr.length;
if (length && length <= n / 2) {
return paletteArr[Math.round((index * n) / length) % n];
}
return paletteArr[index % n];
}
/**
* Build a named color map from keys, using position-based palette assignment
* @param {readonly string[]} keys
*/
function seq(keys) {
return Object.fromEntries(keys.map((key, i) => [key, at(i, keys.length)]));
}
export const colors = {
transparent: createColor(() => "transparent"),
default: createColor(() => getLightDarkValue("--color")),
background: createColor(() => getLightDarkValue("--background-color")),
gray: createColor(() => getColor("gray")),
border: createColor(() => getLightDarkValue("--border-color")),
offBorder: createColor(() => getLightDarkValue("--off-border-color")),
// Directional
profit: palette.green,
loss: palette.red,
bitcoin: palette.orange,
usd: palette.green,
// Bi-color pairs for baselines (spaced by 2 in palette)
bi: {
/** @type {[Color, Color]} */
p1: [palette.green, palette.red],
/** @type {[Color, Color]} */
p2: [palette.teal, palette.amber],
/** @type {[Color, Color]} */
p3: [palette.sky, palette.avocado],
},
// Cointime economics
liveliness: palette.pink,
vaulted: palette.lime,
active: palette.rose,
activity: palette.purple,
cointime: palette.yellow,
destroyed: palette.red,
created: palette.orange,
stored: palette.green,
transfer: palette.cyan,
balanced: palette.indigo,
terminal: palette.fuchsia,
delta: palette.violet,
// Valuations
realized: palette.orange,
investor: palette.fuchsia,
capitalized: palette.green,
thermo: palette.emerald,
trueMarketMean: palette.blue,
vocdd: palette.purple,
hodlBank: palette.blue,
reserveRisk: palette.orange,
// Comparisons (base vs adjusted)
base: palette.orange,
adjusted: palette.purple,
adjustedCreated: palette.lime,
adjustedDestroyed: palette.pink,
// Realized P&L
gross: palette.yellow,
regret: palette.pink,
// Ratios
plRatio: palette.yellow,
// Mining
mining: seq(["coinbase", "subsidy", "fee"]),
// Network
segwit: palette.cyan,
// Entity (transactions, inputs, outputs)
entity: seq(["tx", "input", "output"]),
// Technical indicators
indicator: {
main: palette.indigo,
fast: palette.blue,
slow: palette.orange,
upper: palette.green,
lower: palette.red,
mid: palette.yellow,
},
stat: {
sum: palette.blue,
cumulative: palette.indigo,
avg: palette.orange,
max: palette.green,
pct90: palette.cyan,
pct75: palette.blue,
median: palette.yellow,
pct25: palette.violet,
pct10: palette.fuchsia,
min: palette.red,
},
// Ratio percentile bands (extreme values)
ratioPct: {
_99_5: palette.red,
_99: palette.orange,
_98: palette.amber,
_95: palette.yellow,
_5: palette.cyan,
_2: palette.sky,
_1: palette.blue,
_0_5: palette.indigo,
},
// Standard deviation bands (warm = positive, cool = negative)
sd: {
_0: palette.lime,
p05: palette.yellow,
m05: palette.teal,
p1: palette.amber,
m1: palette.cyan,
p15: palette.orange,
m15: palette.sky,
p2: palette.red,
m2: palette.blue,
p25: palette.rose,
m25: palette.indigo,
p3: palette.pink,
m3: palette.violet,
},
time: {
_24h: palette.red,
_1w: palette.yellow,
_1m: palette.green,
_1y: palette.blue,
all: palette.purple,
},
term: {
short: palette.yellow,
long: palette.fuchsia,
},
scriptType: {
p2pk65: palette.rose,
p2pk33: palette.pink,
p2pkh: palette.orange,
p2ms: palette.teal,
p2sh: palette.green,
p2wpkh: palette.red,
p2wsh: palette.yellow,
p2tr: palette.cyan,
p2a: palette.indigo,
opReturn: palette.purple,
unknown: palette.violet,
empty: palette.fuchsia,
},
arr: paletteArr,
at,
};
// ---
// oklch
// ---
/**
* @param {readonly [number, number, number, number, number, number, number, number, number]} A
* @param {readonly [number, number, number]} B
*/
function multiplyMatrices(A, B) {
return /** @type {const} */ ([
A[0] * B[0] + A[1] * B[1] + A[2] * B[2],
A[3] * B[0] + A[4] * B[1] + A[5] * B[2],
A[6] * B[0] + A[7] * B[1] + A[8] * B[2],
]);
}
/** @param {readonly [number, number, number]} param0 */
function oklch2oklab([l, c, h]) {
return /** @type {const} */ ([
l,
isNaN(h) ? 0 : c * Math.cos((h * Math.PI) / 180),
isNaN(h) ? 0 : c * Math.sin((h * Math.PI) / 180),
]);
}
/** @param {readonly [number, number, number]} rgb */
function srgbLinear2rgb(rgb) {
return rgb.map((c) =>
Math.abs(c) > 0.0031308
? (c < 0 ? -1 : 1) * (1.055 * Math.abs(c) ** (1 / 2.4) - 0.055)
: 12.92 * c,
);
}
/** @param {readonly [number, number, number]} lab */
function oklab2xyz(lab) {
const LMSg = multiplyMatrices(
[
1, 0.3963377773761749, 0.2158037573099136, 1, -0.1055613458156586,
-0.0638541728258133, 1, -0.0894841775298119, -1.2914855480194092,
],
lab,
);
const LMS = /** @type {[number, number, number]} */ (
LMSg.map((val) => val ** 3)
);
return multiplyMatrices(
[
1.2268798758459243, -0.5578149944602171, 0.2813910456659647,
-0.0405757452148008, 1.112286803280317, -0.0717110580655164,
-0.0763729366746601, -0.4214933324022432, 1.5869240198367816,
],
LMS,
);
}
/** @param {readonly [number, number, number]} xyz */
function xyz2rgbLinear(xyz) {
return multiplyMatrices(
[
3.2409699419045226, -1.537383177570094, -0.4986107602930034,
-0.9692436362808796, 1.8759675015077202, 0.04155505740717559,
0.05563007969699366, -0.20397695888897652, 1.0569715142428786,
],
xyz,
);
}
/** @type {Map<string, [number, number, number, number]>} */
const conversionCache = new Map();
/**
* Parse oklch string and return rgba tuple
* @param {string} oklch
* @returns {[number, number, number, number] | null}
*/
function parseOklch(oklch) {
if (!oklch.startsWith("oklch(")) return null;
const cached = conversionCache.get(oklch);
if (cached) return cached;
let str = oklch.slice(6, -1); // remove "oklch(" and ")"
let alpha = 1;
const slashIdx = str.indexOf(" / ");
if (slashIdx !== -1) {
const alphaPart = str.slice(slashIdx + 3);
alpha = alphaPart.includes("%")
? Number(alphaPart.replace("%", "")) / 100
: Number(alphaPart);
str = str.slice(0, slashIdx);
}
const parts = str.split(" ");
const l = parts[0].includes("%")
? Number(parts[0].replace("%", "")) / 100
: Number(parts[0]);
const c = Number(parts[1]);
const h = Number(parts[2]);
const rgb = srgbLinear2rgb(
xyz2rgbLinear(oklab2xyz(oklch2oklab([l, c, h]))),
).map((v) => Math.max(Math.min(Math.round(v * 255), 255), 0));
const result = /** @type {[number, number, number, number]} */ ([
...rgb,
alpha,
]);
conversionCache.set(oklch, result);
return result;
}
/**
* Convert oklch string to rgba string
* @param {string} oklch
* @returns {string}
*/
export function oklchToRgba(oklch) {
const result = parseOklch(oklch);
if (!result) return oklch;
const [r, g, b, a] = result;
return a === 1 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${a})`;
}
-383
View File
@@ -1,383 +0,0 @@
/**
* @param {string} id
* @returns {HTMLElement}
*/
export function getElementById(id) {
const element = window.document.getElementById(id);
if (!element) throw `Element with id = "${id}" should exist`;
return element;
}
/**
* @param {HTMLElement} element
*/
export function isHidden(element) {
return element.tagName !== "BODY" && !element.offsetParent;
}
/**
*
* @param {HTMLElement} element
* @param {VoidFunction} callback
*/
export function onFirstIntersection(element, callback) {
const observer = new IntersectionObserver((entries) => {
for (let i = 0; i < entries.length; i++) {
if (entries[i].isIntersecting) {
callback();
observer.disconnect();
}
}
});
observer.observe(element);
}
/**
* @param {string} text
*/
export function createSpan(text) {
const span = window.document.createElement("span");
span.textContent = text;
return span;
}
/**
* @param {string} text
*/
export function createSmall(text) {
const small = window.document.createElement("small");
small.textContent = text;
return small;
}
/**
* @param {string} name
*/
export function createSpanName(name) {
const spanName = window.document.createElement("span");
spanName.classList.add("name");
const [first, second, third] = name.split(" - ");
spanName.innerHTML = first;
if (second) {
const smallRest = window.document.createElement("small");
smallRest.innerHTML = `${second}`;
spanName.append(smallRest);
if (third) {
throw "Shouldn't have more than one dash";
}
}
return spanName;
}
/**
* @param {Object} arg
* @param {string} arg.href
* @param {string} arg.title
* @param {string} [arg.text]
* @param {boolean} [arg.blank]
* @param {VoidFunction} [arg.onClick]
* @param {boolean} [arg.preventDefault]
*/
export function createAnchorElement({
text,
href,
blank,
onClick,
title,
preventDefault,
}) {
const anchor = window.document.createElement("a");
anchor.href = href;
anchor.title = title.toUpperCase();
if (text) {
anchor.innerText = text;
}
if (blank) {
anchor.target = "_blank";
anchor.rel = "noopener noreferrer";
}
if (onClick || preventDefault) {
if (onClick) {
anchor.addEventListener("click", (event) => {
event.preventDefault();
onClick();
});
}
}
return anchor;
}
// Intercept plain left-clicks for SPA nav; let modified clicks
// (cmd/ctrl/shift/middle) and right-click fall through so the
// anchor's native open-in-new-tab / context-menu behavior works.
/** @param {HTMLElement} el @param {() => void} handler */
export function onPlainClick(el, handler) {
el.addEventListener("click", (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
handler();
});
}
/**
* @param {Object} arg
* @param {string | HTMLElement} arg.inside
* @param {string} arg.title
* @param {(event: MouseEvent) => void} arg.onClick
*/
export function createButtonElement({ inside: text, onClick, title }) {
const button = window.document.createElement("button");
button.append(text);
button.title = title.toUpperCase();
button.addEventListener("click", onClick);
return button;
}
/**
* @param {Object} args
* @param {string} args.inputName
* @param {string} args.inputId
* @param {string} args.inputValue
* @param {boolean} [args.inputChecked=false]
* @param {string} [args.title]
* @param {'radio' | 'checkbox'} args.type
* @param {(event: MouseEvent) => void} [args.onClick]
*/
export function createLabeledInput({
inputId,
inputName,
inputValue,
inputChecked = false,
title,
onClick,
type,
}) {
const label = window.document.createElement("label");
inputId = inputId.toLowerCase();
const input = window.document.createElement("input");
if (type === "radio") {
input.type = "radio";
input.name = inputName;
} else {
input.type = "checkbox";
}
input.id = inputId;
input.value = inputValue;
input.checked = inputChecked;
label.append(input);
label.id = `${inputId}-label`;
if (title) {
label.title = title;
}
if (onClick) {
input.addEventListener("click", onClick);
} else {
label.htmlFor = inputId;
}
return {
label,
input,
};
}
/**
* @template T
* @typedef {Object} Select
* @property {HTMLElement} element
* @property {() => T} get
* @property {(choice: T) => void} set
*/
/**
* @template T
* @param {Object} args
* @param {T} args.initialValue
* @param {string} [args.id]
* @param {readonly T[]} args.choices
* @param {(value: T) => void} [args.onChange]
* @param {(choice: T) => string} [args.toKey]
* @param {(choice: T) => string} [args.toLabel]
* @param {(choice: T) => string | undefined} [args.toTitle]
*/
export function createRadios({
id,
choices,
initialValue,
onChange,
toKey = /** @type {(choice: T) => string} */ ((c) => String(c)),
toLabel = /** @type {(choice: T) => string} */ ((c) => String(c)),
toTitle,
}) {
const fieldset = window.document.createElement("fieldset");
const initialKey = toKey(initialValue);
/** @param {string} key */
const fromKey = (key) =>
choices.find((c) => toKey(c) === key) ?? initialValue;
if (choices.length === 1) {
fieldset.append(createSpan(toLabel(choices[0])));
} else {
const groupId = id ?? "";
choices.forEach((choice) => {
const key = toKey(choice);
const { label } = createLabeledInput({
inputId: `${groupId}-${key.toLowerCase()}`,
inputName: groupId,
inputValue: key,
inputChecked: key === initialKey,
title: toTitle?.(choice),
type: "radio",
});
const text = window.document.createTextNode(toLabel(choice));
label.append(text);
fieldset.append(label);
});
fieldset.addEventListener("change", (event) => {
if (!(event.target instanceof HTMLInputElement)) return;
onChange?.(fromKey(event.target.value));
});
}
return fieldset;
}
/**
* @template T
* @param {Object} args
* @param {T} args.initialValue
* @param {string} [args.id]
* @param {string} [args.label]
* @param {readonly T[]} args.choices
* @param {(value: T) => void} [args.onChange]
* @param {(choice: T) => string} [args.toKey]
* @param {(choice: T) => string} [args.toLabel]
* @param {boolean} [args.sorted]
* @param {{ label: string, items: T[] }[]} [args.groups]
* @returns {Select<T>}
*/
export function createSelect({
id,
label,
choices: unsortedChoices,
groups,
initialValue,
onChange,
sorted,
toKey = /** @type {(choice: T) => string} */ ((c) => String(c)),
toLabel = /** @type {(choice: T) => string} */ ((c) => String(c)),
}) {
const choices = sorted
? unsortedChoices.toSorted((a, b) => toLabel(a).localeCompare(toLabel(b)))
: unsortedChoices;
const initialKey = toKey(initialValue);
/** @param {string} key */
const fromKey = (key) =>
choices.find((c) => toKey(c) === key) ?? initialValue;
if (choices.length === 1) {
return {
element: createSpan(toLabel(choices[0])),
get: () => initialValue,
set: () => {},
};
}
const element = window.document.createElement("label");
if (label) {
element.append(createSpan(label));
}
const select = window.document.createElement("select");
select.id = id ?? "";
select.name = id ?? "";
element.append(select);
/** @param {T} choice */
const createOption = (choice) => {
const key = toKey(choice);
const option = window.document.createElement("option");
option.value = key;
option.textContent = toLabel(choice);
if (key === initialKey) {
option.selected = true;
}
return option;
};
if (groups) {
groups.forEach(({ label, items }) => {
const optgroup = window.document.createElement("optgroup");
optgroup.label = label;
items.forEach((choice) => optgroup.append(createOption(choice)));
select.append(optgroup);
});
} else {
choices.forEach((choice) => select.append(createOption(choice)));
}
select.addEventListener("change", () => {
onChange?.(fromKey(select.value));
});
const remaining = choices.length - 1;
if (remaining > 0) {
element.append(createSmall(`+${remaining}`));
element.append(createSpan("↓"));
}
element.addEventListener("click", (e) => {
if (e.target !== select && "showPicker" in select) {
e.preventDefault();
select.showPicker();
}
});
return {
element,
get: () => fromKey(select.value),
set: (choice) => {
select.value = toKey(choice);
},
};
}
/**
* @param {string} [title]
* @param {1 | 2 | 3} [level]
*/
export function createHeader(title = "", level = 1) {
const headerElement = window.document.createElement("header");
const headingElement = window.document.createElement(`h${level}`);
headingElement.innerHTML = title;
headerElement.append(headingElement);
headingElement.style.display = "block";
return {
headerElement,
headingElement,
};
}
-24
View File
@@ -1,24 +0,0 @@
import { getElementById } from "./dom.js";
export const style = getComputedStyle(window.document.documentElement);
export const headElement = window.document.getElementsByTagName("head")[0];
export const bodyElement = window.document.body;
export const mainElement = getElementById("main");
export const asideElement = getElementById("aside");
export const searchElement = getElementById("search");
export const navElement = getElementById("nav");
export const chartElement = getElementById("chart");
export const explorerElement = getElementById("explorer");
export const heatmapElement = getElementById("heatmap");
export const asideLabelElement = getElementById("aside-selector-label");
export const navLabelElement = getElementById(`nav-selector-label`);
export const searchLabelElement = getElementById(`search-selector-label`);
export const searchInput = /** @type {HTMLInputElement} */ (
getElementById("search-input")
);
export const searchResultsElement = getElementById("search-results");
export const frameSelectorsElement = getElementById("frame-selectors");
export const layoutButtonElement = getElementById("layout-button");
-6
View File
@@ -1,6 +0,0 @@
export const localhost = window.location.hostname === "localhost";
const userAgent = navigator.userAgent.toLowerCase();
const iphone = userAgent.includes("iphone");
const ipad = userAgent.includes("ipad");
export const ios = iphone || ipad;
export const canShare = "canShare" in navigator;
-70
View File
@@ -1,70 +0,0 @@
/**
* @param {number} value
* @param {number} [digits]
* @param {Intl.NumberFormatOptions} [options]
*/
function numberToUSNumber(value, digits, options) {
return value.toLocaleString("en-us", {
...options,
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
/**
* @param {number} value
* @param {0 | 2} [digits]
*/
export function numberToShortUSFormat(value, digits) {
const absoluteValue = Math.abs(value);
if (isNaN(value) || !isFinite(value)) {
return "";
} else if (absoluteValue < 10) {
return numberToUSNumber(value, Math.min(3, digits || 10));
} else if (absoluteValue < 1_000) {
return numberToUSNumber(value, Math.min(2, digits || 10));
} else if (absoluteValue < 10_000) {
return numberToUSNumber(value, Math.min(1, digits || 10));
} else if (absoluteValue < 1_000_000) {
return numberToUSNumber(value, 0);
} else if (absoluteValue >= 1e27) {
return "Inf.";
}
const log = Math.floor(Math.log10(absoluteValue) - 6);
const suffices = ["M", "B", "T", "P", "E", "Z", "Y"];
const letterIndex = Math.floor(log / 3);
const letter = suffices[letterIndex];
const modulused = log % 3;
if (modulused === 0) {
return `${numberToUSNumber(
value / (1_000_000 * 1_000 ** letterIndex),
3,
)}${letter}`;
} else if (modulused === 1) {
return `${numberToUSNumber(
value / (1_000_000 * 1_000 ** letterIndex),
2,
)}${letter}`;
} else {
return `${numberToUSNumber(
value / (1_000_000 * 1_000 ** letterIndex),
1,
)}${letter}`;
}
}
/**
* @param {string} s
*/
export function stringToId(s) {
return s
.trim()
.replace(/[ /]+/g, "-")
.toLowerCase()
.replace(/%/g, "%25");
}
-79
View File
@@ -1,79 +0,0 @@
import { readParam, writeParam } from "./url.js";
import { readStored, writeToStorage } from "./storage.js";
import { debounce } from "./timing.js";
/**
* @template T
* @param {Object} args
* @param {T} args.defaultValue
* @param {string} [args.storageKey]
* @param {string} [args.urlKey]
* @param {(v: T) => string} args.serialize
* @param {(s: string) => T} args.deserialize
* @param {boolean} [args.saveDefaultValue]
* @param {(v: T) => void} [args.onChange]
*/
export function createPersistedValue({
defaultValue,
storageKey,
urlKey,
serialize,
deserialize,
saveDefaultValue = false,
onChange,
}) {
const defaultSerialized = serialize(defaultValue);
// Read: URL > localStorage > default
let serialized = urlKey ? readParam(urlKey) : null;
if (serialized === null && storageKey) {
serialized = readStored(storageKey);
}
let value = serialized !== null ? deserialize(serialized) : defaultValue;
/** @param {T} v */
const write = (v) => {
const s = serialize(v);
const isDefault = s === defaultSerialized;
if (storageKey) {
if (!isDefault || saveDefaultValue) {
writeToStorage(storageKey, s);
} else {
writeToStorage(storageKey, null);
}
}
if (urlKey) {
writeParam(urlKey, !isDefault || saveDefaultValue ? s : null);
}
};
const debouncedWrite = debounce(write, 250);
// Write initial value
write(value);
return {
get value() {
return value;
},
/** @param {T} v */
set(v) {
value = v;
debouncedWrite(v);
onChange?.(v);
},
/** @param {T} v */
setImmediate(v) {
value = v;
write(v);
onChange?.(v);
},
};
}
/**
* @template T
* @typedef {ReturnType<typeof createPersistedValue<T>>} PersistedValue
*/
-36
View File
@@ -1,36 +0,0 @@
let _latest = /** @type {number | null} */ (null);
/** @type {Set<(price: number) => void>} */
const listeners = new Set();
/** @param {(price: number) => void} callback */
export function onPrice(callback) {
listeners.add(callback);
if (_latest !== null) callback(_latest);
return () => listeners.delete(callback);
}
export function latestPrice() {
return _latest;
}
/** @param {BrkClient} brk */
export function initPrice(brk) {
async function poll() {
try {
const price = await brk.getLivePrice();
if (price !== _latest) {
_latest = price;
listeners.forEach((cb) => cb(price));
}
} catch (e) {
console.error("price poll:", e);
}
}
poll();
setInterval(poll, 1_000);
document.addEventListener("visibilitychange", () => {
!document.hidden && poll();
});
}
-47
View File
@@ -1,47 +0,0 @@
import { entries, fromEntries } from "./array.js";
export const serdeBool = {
/**
* @param {boolean} v
*/
serialize(v) {
return String(v);
},
/**
* @param {string} v
*/
deserialize(v) {
if (v === "true" || v === "1") {
return true;
} else {
return false;
}
},
};
export const INDEX_LABEL = /** @type {const} */ ({
height: "blk",
minute10: "10mn",
minute30: "30mn",
hour1: "1h",
hour4: "4h",
hour12: "12h",
day1: "1d",
day3: "3d",
week1: "1w",
month1: "1m",
month3: "3m",
month6: "6m",
year1: "1y",
year10: "10y",
halving: "halv",
epoch: "epch",
});
/** @typedef {typeof INDEX_LABEL} IndexLabelMap */
/** @typedef {keyof IndexLabelMap} ChartableIndex */
/** @typedef {IndexLabelMap[ChartableIndex]} IndexLabel */
export const INDEX_FROM_LABEL = fromEntries(
entries(INDEX_LABEL).map(([k, v]) => [v, k]),
);
-29
View File
@@ -1,29 +0,0 @@
/**
* @param {string} key
*/
export function readStored(key) {
try {
return localStorage.getItem(key);
} catch (_) {
return null;
}
}
/**
* @param {string} key
* @param {string | boolean | null | undefined} value
*/
export function writeToStorage(key, value) {
try {
value !== undefined && value !== null
? localStorage.setItem(key, String(value))
: localStorage.removeItem(key);
} catch (_) {}
}
/**
* @param {string} key
*/
export function removeStored(key) {
writeToStorage(key, undefined);
}
-73
View File
@@ -1,73 +0,0 @@
import { readStored, removeStored, writeToStorage } from "./storage.js";
const preferredColorSchemeMatchMedia = window.matchMedia(
"(prefers-color-scheme: dark)",
);
const stored = readStored("theme");
const initial = stored
? stored === "dark"
: preferredColorSchemeMatchMedia.matches;
export let dark = initial;
/** @type {Set<() => void>} */
const callbacks = new Set();
/** @param {() => void} callback */
export function onChange(callback) {
callbacks.add(callback);
return () => callbacks.delete(callback);
}
const themeButton = /** @type {HTMLButtonElement | null} */ (
document.getElementById("theme-button")
);
let running = false;
/** @param {boolean} value */
function setDark(value) {
if (running || dark === value) return;
dark = value;
running = true;
if (themeButton) themeButton.disabled = true;
const swap = () => {
apply(value);
callbacks.forEach((cb) => cb());
};
document.documentElement.classList.add("no-transitions");
const restore = () => {
document.documentElement.classList.remove("no-transitions");
running = false;
if (themeButton) themeButton.disabled = false;
};
if (document.startViewTransition) {
document.startViewTransition(swap).finished.finally(restore);
} else {
swap();
requestAnimationFrame(restore);
}
}
/** @param {boolean} isDark */
function apply(isDark) {
document.documentElement.style.colorScheme = isDark ? "dark" : "light";
}
apply(initial);
preferredColorSchemeMatchMedia.addEventListener("change", ({ matches }) => {
if (!readStored("theme")) {
setDark(matches);
}
});
function invert() {
const newValue = !dark;
setDark(newValue);
if (newValue === preferredColorSchemeMatchMedia.matches) {
removeStored("theme");
} else {
writeToStorage("theme", newValue ? "dark" : "light");
}
}
themeButton?.addEventListener("click", invert);
-23
View File
@@ -1,23 +0,0 @@
/**
* Convert period ID to readable name
* @param {string} id
* @param {boolean} [compoundAdjective]
*/
export function periodIdToName(id, compoundAdjective) {
const num = parseInt(id);
const s = compoundAdjective || num === 1 ? "" : "s";
switch (id.slice(-1)) {
case "h":
return `${num} Hour${s}`;
case "d":
return `${num} Day${s}`;
case "w":
return `${num} Week${s}`;
case "m":
return `${num} Month${s}`;
case "y":
return `${num} Year${s}`;
default:
return id;
}
}
-81
View File
@@ -1,81 +0,0 @@
/**
* @param {number} ms
*/
export function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export function next() {
return sleep(0);
}
/**
* @param {() => void} callback
*/
export function idle(callback) {
("requestIdleCallback" in window ? requestIdleCallback : setTimeout)(
callback,
);
}
/**
*
* @template {(...args: never[]) => unknown} F
* @param {F} callback
* @param {number} [wait]
*/
export function throttle(callback, wait = 1000) {
/** @type {number | null} */
let timeoutId = null;
/** @type {Parameters<F>} */
let latestArgs;
let hasTrailing = false;
return (/** @type {Parameters<F>} */ ...args) => {
latestArgs = args;
if (timeoutId) {
hasTrailing = true;
return;
}
callback(...latestArgs);
timeoutId = setTimeout(() => {
timeoutId = null;
if (hasTrailing) {
hasTrailing = false;
callback(...latestArgs);
}
}, wait);
};
}
/**
* @template {(...args: never[]) => unknown} F
* @param {F} callback
* @param {number} [wait]
* @returns {((...args: Parameters<F>) => void) & { cancel: () => void }}
*/
export function debounce(callback, wait = 1000) {
/** @type {number | null} */
let timeoutId = null;
const fn = (/** @type {Parameters<F>} */ ...args) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
callback(...args);
timeoutId = null;
}, wait);
};
fn.cancel = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
};
return fn;
}
-62
View File
@@ -1,62 +0,0 @@
/** Unit definitions for chart series */
/**
* Unit enum with id (for serialization) and name (for display)
*/
export const Unit = /** @type {const} */ ({
// Value units
sats: { id: "sats", name: "Satoshis" },
btc: { id: "btc", name: "Bitcoin" },
usd: { id: "usd", name: "US Dollars" },
// Ratios & percentages
percentage: { id: "percentage", name: "Percentage" },
cagr: { id: "cagr", name: "CAGR (%/year)" },
ratio: { id: "ratio", name: "Ratio" },
index: { id: "index", name: "Index" },
sd: { id: "sd", name: "Std Dev" },
// Relative percentages
pctSupply: { id: "pct-supply", name: "% of circulating" },
pctOwn: { id: "pct-own", name: "% of Own" },
// Time
days: { id: "days", name: "Days" },
years: { id: "years", name: "Years" },
secs: { id: "secs", name: "Seconds" },
// Counts
count: { id: "count", name: "Count" },
blocks: { id: "blocks", name: "Blocks" },
// Size
bytes: { id: "bytes", name: "Bytes" },
vb: { id: "vb", name: "Virtual Bytes" },
wu: { id: "wu", name: "Weight Units" },
// Mining
hashRate: { id: "hashrate", name: "Hash Rate" },
difficulty: { id: "difficulty", name: "Difficulty" },
epoch: { id: "epoch", name: "Epoch" },
// Fees
feeRate: { id: "feerate", name: "Sat/vByte" },
// Rates
perSec: { id: "per-sec", name: "Per Second" },
// Cointime
coinblocks: { id: "coinblocks", name: "Coinblocks" },
coindays: { id: "coindays", name: "Coindays" },
satblocks: { id: "satblocks", name: "Satblocks" },
satdays: { id: "satdays", name: "Satdays" },
// Hash price/value
usdPerThsPerDay: { id: "usd-ths-day", name: "USD/TH/s/Day" },
usdPerPhsPerDay: { id: "usd-phs-day", name: "USD/PH/s/Day" },
satsPerThsPerDay: { id: "sats-ths-day", name: "Sats/TH/s/Day" },
satsPerPhsPerDay: { id: "sats-phs-day", name: "Sats/PH/s/Day" },
});
/** @typedef {keyof typeof Unit} UnitKey */
/** @typedef {typeof Unit[UnitKey]} UnitObject */
-82
View File
@@ -1,82 +0,0 @@
/**
* @param {string | string[]} [pathname]
*/
function processPathname(pathname) {
pathname ||= window.location.pathname;
const result = Array.isArray(pathname) ? pathname.join("/") : pathname;
// Strip leading slash to avoid double slashes when prepending /
return result.startsWith("/") ? result.slice(1) : result;
}
const chartParamsWhitelist = ["range"];
/**
* @param {string | string[]} [pathname]
* @param {URLSearchParams} [urlParams]
*/
function buildUrl(pathname, urlParams) {
const path = processPathname(pathname);
const query = (urlParams ?? new URLSearchParams(window.location.search)).toString();
return `/${path}${query ? `?${query}` : ""}`;
}
/**
* @param {string | string[]} pathname
*/
export function pushHistory(pathname) {
try {
window.history.pushState(null, "", buildUrl(pathname));
} catch (_) {}
}
/**
* @param {Object} args
* @param {URLSearchParams} [args.urlParams]
* @param {string | string[]} [args.pathname]
*/
export function replaceHistory({ urlParams, pathname }) {
try {
window.history.replaceState(null, "", buildUrl(pathname, urlParams));
} catch (_) {}
}
/**
* @param {Option} option
*/
export function resetParams(option) {
const urlParams = new URLSearchParams();
if (option.kind === "chart") {
[...new URLSearchParams(window.location.search).entries()]
.filter(([key, _]) => chartParamsWhitelist.includes(key))
.forEach(([key, value]) => {
urlParams.set(key, value);
});
}
replaceHistory({ urlParams, pathname: option.path.join("/") });
}
/**
* @param {string} key
* @param {string | boolean | null | undefined} value
*/
export function writeParam(key, value) {
const urlParams = new URLSearchParams(window.location.search);
if (value !== null && value !== undefined) {
urlParams.set(key, String(value));
} else {
urlParams.delete(key);
}
replaceHistory({ urlParams });
}
/**
*
* @param {string} key
* @returns {string | null}
*/
export function readParam(key) {
const params = new URLSearchParams(window.location.search);
return params.get(key);
}