mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-08 14:11:56 -07:00
website: redesign part 1
This commit is contained in:
@@ -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 +0,0 @@
|
||||
import "./main.js";
|
||||
@@ -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); }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
../../modules
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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 }),
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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})`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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]),
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user