mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-04-24 06:39:58 -07:00
website: snap
This commit is contained in:
@@ -8198,7 +8198,7 @@ pub struct BrkClient {
|
||||
|
||||
impl BrkClient {
|
||||
/// Client version.
|
||||
pub const VERSION: &'static str = "v0.3.0-alpha.5";
|
||||
pub const VERSION: &'static str = "v0.3.0-alpha.6";
|
||||
|
||||
/// Create a new client with the given base URL.
|
||||
pub fn new(base_url: impl Into<String>) -> Self {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use brk_types::TxidPrefix;
|
||||
use rustc_hash::FxHashMap;
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::{entry::Entry, types::TxIndex};
|
||||
|
||||
@@ -11,12 +12,20 @@ use crate::{entry::Entry, types::TxIndex};
|
||||
pub struct EntryPool {
|
||||
entries: Vec<Option<Entry>>,
|
||||
prefix_to_idx: FxHashMap<TxidPrefix, TxIndex>,
|
||||
parent_to_children: FxHashMap<TxidPrefix, SmallVec<[TxidPrefix; 2]>>,
|
||||
free_slots: Vec<TxIndex>,
|
||||
}
|
||||
|
||||
impl EntryPool {
|
||||
/// Insert an entry, returning its index.
|
||||
pub fn insert(&mut self, prefix: TxidPrefix, entry: Entry) -> TxIndex {
|
||||
for parent in &entry.depends {
|
||||
self.parent_to_children
|
||||
.entry(*parent)
|
||||
.or_default()
|
||||
.push(prefix);
|
||||
}
|
||||
|
||||
let idx = match self.free_slots.pop() {
|
||||
Some(idx) => {
|
||||
self.entries[idx.as_usize()] = Some(entry);
|
||||
@@ -39,9 +48,28 @@ impl EntryPool {
|
||||
self.entries.get(idx.as_usize())?.as_ref()
|
||||
}
|
||||
|
||||
/// Get direct children of a transaction (txs that depend on it).
|
||||
pub fn children(&self, prefix: &TxidPrefix) -> &[TxidPrefix] {
|
||||
self.parent_to_children
|
||||
.get(prefix)
|
||||
.map(SmallVec::as_slice)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Remove an entry by its txid prefix.
|
||||
pub fn remove(&mut self, prefix: &TxidPrefix) {
|
||||
if let Some(idx) = self.prefix_to_idx.remove(prefix) {
|
||||
if let Some(entry) = self.entries.get(idx.as_usize()).and_then(|e| e.as_ref()) {
|
||||
for parent in &entry.depends {
|
||||
if let Some(children) = self.parent_to_children.get_mut(parent) {
|
||||
children.retain(|c| c != prefix);
|
||||
if children.is_empty() {
|
||||
self.parent_to_children.remove(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.parent_to_children.remove(prefix);
|
||||
if let Some(slot) = self.entries.get_mut(idx.as_usize()) {
|
||||
*slot = None;
|
||||
}
|
||||
|
||||
@@ -74,10 +74,9 @@ impl Query {
|
||||
}
|
||||
}
|
||||
|
||||
// Descendants: find entries that depend on this tx's prefix
|
||||
let mut descendants = Vec::new();
|
||||
for e in entries.entries().iter().flatten() {
|
||||
if e.depends.contains(&prefix) {
|
||||
for child_prefix in entries.children(&prefix) {
|
||||
if let Some(e) = entries.get(child_prefix) {
|
||||
descendants.push(CpfpEntry {
|
||||
txid: e.txid.clone(),
|
||||
weight: Weight::from(e.vsize),
|
||||
|
||||
@@ -292,7 +292,6 @@ impl Query {
|
||||
let max_height = self.height().to_usize();
|
||||
let start = start_height.map(|h| h.to_usize()).unwrap_or(max_height);
|
||||
|
||||
// BytesVec reader gives O(1) mmap reads — efficient for backward scan
|
||||
let reader = computer.pools.pool.reader();
|
||||
let end = start.min(reader.len().saturating_sub(1));
|
||||
|
||||
@@ -307,12 +306,20 @@ impl Query {
|
||||
}
|
||||
}
|
||||
|
||||
// Group consecutive descending heights into ranges for batch reads
|
||||
let mut blocks = Vec::with_capacity(heights.len());
|
||||
for h in heights {
|
||||
if let Ok(mut v) = self.blocks_v1_range(h, h + 1) {
|
||||
let mut i = 0;
|
||||
while i < heights.len() {
|
||||
let hi = heights[i];
|
||||
while i + 1 < heights.len() && heights[i + 1] + 1 == heights[i] {
|
||||
i += 1;
|
||||
}
|
||||
if let Ok(mut v) = self.blocks_v1_range(heights[i], hi + 1) {
|
||||
blocks.append(&mut v);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
Ok(blocks)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use bitcoin::hex::DisplayHex;
|
||||
use bitcoin::hex::{DisplayHex, FromHex};
|
||||
use brk_error::{Error, Result};
|
||||
use brk_types::{
|
||||
BlockHash, Height, MerkleProof, Timestamp, Transaction, TxInIndex, TxIndex, TxOutspend,
|
||||
TxStatus, Txid, TxidPrefix, Vin, Vout,
|
||||
BlockHash, Height, MerkleProof, Timestamp, Transaction, TxInIndex, TxIndex, TxOutIndex,
|
||||
TxOutspend, TxStatus, Txid, TxidPrefix, Vin, Vout,
|
||||
};
|
||||
use vecdb::{ReadableVec, VecIndex};
|
||||
|
||||
@@ -71,6 +71,13 @@ impl Query {
|
||||
}
|
||||
|
||||
pub fn transaction_raw(&self, txid: &Txid) -> Result<Vec<u8>> {
|
||||
if let Some(mempool) = self.mempool()
|
||||
&& let Some(tx_with_hex) = mempool.get_txs().get(txid)
|
||||
{
|
||||
return Vec::from_hex(tx_with_hex.hex())
|
||||
.map_err(|_| Error::Parse("Failed to decode mempool tx hex".into()));
|
||||
}
|
||||
|
||||
let prefix = TxidPrefix::from(txid);
|
||||
let indexer = self.indexer();
|
||||
let Ok(Some(tx_index)) = indexer
|
||||
@@ -108,65 +115,40 @@ impl Query {
|
||||
}
|
||||
|
||||
pub fn outspend(&self, txid: &Txid, vout: Vout) -> Result<TxOutspend> {
|
||||
let all = self.outspends(txid)?;
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.nth(usize::from(vout))
|
||||
.unwrap_or(TxOutspend::UNSPENT))
|
||||
if self.mempool().is_some_and(|m| m.get_txs().contains_key(txid)) {
|
||||
return Ok(TxOutspend::UNSPENT);
|
||||
}
|
||||
let (_, first_txout, output_count) = self.resolve_tx_outputs(txid)?;
|
||||
if usize::from(vout) >= output_count {
|
||||
return Ok(TxOutspend::UNSPENT);
|
||||
}
|
||||
self.resolve_outspend(first_txout + vout)
|
||||
}
|
||||
|
||||
pub fn outspends(&self, txid: &Txid) -> Result<Vec<TxOutspend>> {
|
||||
// Mempool outputs are unspent in on-chain terms
|
||||
if let Some(mempool) = self.mempool()
|
||||
&& let Some(tx_with_hex) = mempool.get_txs().get(txid)
|
||||
{
|
||||
let output_count = tx_with_hex.tx().output.len();
|
||||
return Ok(vec![TxOutspend::UNSPENT; output_count]);
|
||||
return Ok(vec![TxOutspend::UNSPENT; tx_with_hex.tx().output.len()]);
|
||||
}
|
||||
let (_, first_txout, output_count) = self.resolve_tx_outputs(txid)?;
|
||||
|
||||
// Look up confirmed transaction
|
||||
let prefix = TxidPrefix::from(txid);
|
||||
let indexer = self.indexer();
|
||||
let Ok(Some(tx_index)) = indexer
|
||||
.stores
|
||||
.txid_prefix_to_tx_index
|
||||
.get(&prefix)
|
||||
.map(|opt| opt.map(|cow| cow.into_owned()))
|
||||
else {
|
||||
return Err(Error::UnknownTxid);
|
||||
};
|
||||
|
||||
// Get output range
|
||||
let first_txout_index = indexer
|
||||
.vecs
|
||||
.transactions
|
||||
.first_txout_index
|
||||
.read_once(tx_index)?;
|
||||
let next_first_txout_index = indexer
|
||||
.vecs
|
||||
.transactions
|
||||
.first_txout_index
|
||||
.read_once(tx_index.incremented())?;
|
||||
let output_count = usize::from(next_first_txout_index) - usize::from(first_txout_index);
|
||||
|
||||
// Get spend status for each output
|
||||
let computer = self.computer();
|
||||
let txin_index_reader = computer.outputs.spent.txin_index.reader();
|
||||
let txin_index_reader = self.computer().outputs.spent.txin_index.reader();
|
||||
let txid_reader = indexer.vecs.transactions.txid.reader();
|
||||
|
||||
// Cursors for PcoVec reads — buffer chunks so nearby indices share decompression
|
||||
// Cursors buffer chunks so nearby indices share decompression
|
||||
let mut input_tx_cursor = indexer.vecs.inputs.tx_index.cursor();
|
||||
let mut first_txin_cursor = indexer.vecs.transactions.first_txin_index.cursor();
|
||||
let mut height_cursor = indexer.vecs.transactions.height.cursor();
|
||||
let mut block_ts_cursor = indexer.vecs.blocks.timestamp.cursor();
|
||||
|
||||
// Block info cache — spending txs in the same block share block hash/time
|
||||
// Spending txs in the same block share block hash/time
|
||||
let mut cached_block: Option<(Height, BlockHash, Timestamp)> = None;
|
||||
|
||||
let mut outspends = Vec::with_capacity(output_count);
|
||||
for i in 0..output_count {
|
||||
let txout_index = first_txout_index + Vout::from(i);
|
||||
let txin_index = txin_index_reader.get(usize::from(txout_index));
|
||||
let txin_index = txin_index_reader.get(usize::from(first_txout + Vout::from(i)));
|
||||
|
||||
if txin_index == TxInIndex::UNSPENT {
|
||||
outspends.push(TxOutspend::UNSPENT);
|
||||
@@ -174,9 +156,9 @@ impl Query {
|
||||
}
|
||||
|
||||
let spending_tx_index = input_tx_cursor.get(usize::from(txin_index)).unwrap();
|
||||
let spending_first_txin_index =
|
||||
let spending_first_txin =
|
||||
first_txin_cursor.get(spending_tx_index.to_usize()).unwrap();
|
||||
let vin = Vin::from(usize::from(txin_index) - usize::from(spending_first_txin_index));
|
||||
let vin = Vin::from(usize::from(txin_index) - usize::from(spending_first_txin));
|
||||
let spending_txid = txid_reader.get(spending_tx_index.to_usize());
|
||||
let spending_height = height_cursor.get(spending_tx_index.to_usize()).unwrap();
|
||||
|
||||
@@ -207,6 +189,92 @@ impl Query {
|
||||
Ok(outspends)
|
||||
}
|
||||
|
||||
/// Resolve txid to (tx_index, first_txout_index, output_count).
|
||||
fn resolve_tx_outputs(&self, txid: &Txid) -> Result<(TxIndex, TxOutIndex, usize)> {
|
||||
let prefix = TxidPrefix::from(txid);
|
||||
let indexer = self.indexer();
|
||||
let tx_index: TxIndex = indexer
|
||||
.stores
|
||||
.txid_prefix_to_tx_index
|
||||
.get(&prefix)?
|
||||
.map(|cow| cow.into_owned())
|
||||
.ok_or(Error::UnknownTxid)?;
|
||||
let first = indexer
|
||||
.vecs
|
||||
.transactions
|
||||
.first_txout_index
|
||||
.read_once(tx_index)?;
|
||||
let next = indexer
|
||||
.vecs
|
||||
.transactions
|
||||
.first_txout_index
|
||||
.read_once(tx_index.incremented())?;
|
||||
Ok((tx_index, first, usize::from(next) - usize::from(first)))
|
||||
}
|
||||
|
||||
/// Resolve spend status for a single output.
|
||||
fn resolve_outspend(&self, txout_index: TxOutIndex) -> Result<TxOutspend> {
|
||||
let indexer = self.indexer();
|
||||
let txin_index = self
|
||||
.computer()
|
||||
.outputs
|
||||
.spent
|
||||
.txin_index
|
||||
.reader()
|
||||
.get(usize::from(txout_index));
|
||||
|
||||
if txin_index == TxInIndex::UNSPENT {
|
||||
return Ok(TxOutspend::UNSPENT);
|
||||
}
|
||||
|
||||
let spending_tx_index = indexer
|
||||
.vecs
|
||||
.inputs
|
||||
.tx_index
|
||||
.collect_one_at(usize::from(txin_index))
|
||||
.unwrap();
|
||||
let spending_first_txin = indexer
|
||||
.vecs
|
||||
.transactions
|
||||
.first_txin_index
|
||||
.collect_one(spending_tx_index)
|
||||
.unwrap();
|
||||
let spending_height = indexer
|
||||
.vecs
|
||||
.transactions
|
||||
.height
|
||||
.collect_one(spending_tx_index)
|
||||
.unwrap();
|
||||
|
||||
Ok(TxOutspend {
|
||||
spent: true,
|
||||
txid: Some(
|
||||
indexer
|
||||
.vecs
|
||||
.transactions
|
||||
.txid
|
||||
.reader()
|
||||
.get(spending_tx_index.to_usize()),
|
||||
),
|
||||
vin: Some(Vin::from(
|
||||
usize::from(txin_index) - usize::from(spending_first_txin),
|
||||
)),
|
||||
status: Some(TxStatus {
|
||||
confirmed: true,
|
||||
block_height: Some(spending_height),
|
||||
block_hash: Some(indexer.vecs.blocks.blockhash.read_once(spending_height)?),
|
||||
block_time: Some(
|
||||
indexer
|
||||
.vecs
|
||||
.blocks
|
||||
.timestamp
|
||||
.collect_one(spending_height)
|
||||
.unwrap(),
|
||||
),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// === Helper methods ===
|
||||
|
||||
pub fn transaction_by_index(&self, tx_index: TxIndex) -> Result<Transaction> {
|
||||
|
||||
@@ -106,6 +106,7 @@ impl AddrRoutes for ApiRouter<AppState> {
|
||||
.summary("Address mempool transactions")
|
||||
.description("Get unconfirmed transaction IDs for an address from the mempool (up to 50).\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-mempool)*")
|
||||
.json_response::<Vec<Txid>>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
.not_found()
|
||||
.server_error()
|
||||
|
||||
@@ -149,7 +149,7 @@ impl BlockRoutes for ApiRouter<AppState> {
|
||||
.description(
|
||||
"Returns the raw block data in binary format.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-raw)*",
|
||||
)
|
||||
.json_response::<Vec<u8>>()
|
||||
.binary_response()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
.not_found()
|
||||
|
||||
@@ -29,6 +29,7 @@ impl FeesRoutes for ApiRouter<AppState> {
|
||||
.summary("Projected mempool blocks")
|
||||
.description("Get projected blocks from the mempool for fee estimation.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-blocks-fees)*")
|
||||
.json_response::<Vec<MempoolBlock>>()
|
||||
.not_modified()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
@@ -49,6 +50,7 @@ impl FeesRoutes for ApiRouter<AppState> {
|
||||
.summary("Recommended fees")
|
||||
.description("Get recommended fee rates for different confirmation targets.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-recommended-fees)*")
|
||||
.json_response::<RecommendedFees>()
|
||||
.not_modified()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
@@ -69,6 +71,7 @@ impl FeesRoutes for ApiRouter<AppState> {
|
||||
.summary("Precise recommended fees")
|
||||
.description("Get recommended fee rates with up to 3 decimal places.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-recommended-fees-precise)*")
|
||||
.json_response::<RecommendedFees>()
|
||||
.not_modified()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
|
||||
@@ -55,6 +55,7 @@ impl GeneralRoutes for ApiRouter<AppState> {
|
||||
.summary("Current BTC price")
|
||||
.description("Returns bitcoin latest price (on-chain derived, USD only).\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-price)*")
|
||||
.json_response::<Prices>()
|
||||
.not_modified()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
|
||||
@@ -27,6 +27,7 @@ impl MempoolRoutes for ApiRouter<AppState> {
|
||||
.summary("Mempool statistics")
|
||||
.description("Get current mempool statistics including transaction count, total vsize, total fees, and fee histogram.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool)*")
|
||||
.json_response::<MempoolInfo>()
|
||||
.not_modified()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
@@ -45,6 +46,7 @@ impl MempoolRoutes for ApiRouter<AppState> {
|
||||
.summary("Mempool transaction IDs")
|
||||
.description("Get all transaction IDs currently in the mempool.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-transaction-ids)*")
|
||||
.json_response::<Vec<Txid>>()
|
||||
.not_modified()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
@@ -63,6 +65,7 @@ impl MempoolRoutes for ApiRouter<AppState> {
|
||||
.summary("Recent mempool transactions")
|
||||
.description("Get the last 10 transactions to enter the mempool.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-recent)*")
|
||||
.json_response::<Vec<MempoolRecentTx>>()
|
||||
.not_modified()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
@@ -85,6 +88,7 @@ impl MempoolRoutes for ApiRouter<AppState> {
|
||||
plus mempool.",
|
||||
)
|
||||
.json_response::<Dollars>()
|
||||
.not_modified()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
|
||||
@@ -299,6 +299,7 @@ impl MiningRoutes for ApiRouter<AppState> {
|
||||
.summary("Block fee rates")
|
||||
.description("Get block fee rate percentiles (min, 10th, 25th, median, 75th, 90th, max) for a time period. Valid periods: 24h, 3d, 1w, 1m, 3m, 6m, 1y, 2y, 3y\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-feerates)*")
|
||||
.json_response::<Vec<BlockFeeRatesEntry>>()
|
||||
.not_modified()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
|
||||
@@ -34,6 +34,7 @@ impl TxRoutes for ApiRouter<AppState> {
|
||||
.summary("CPFP info")
|
||||
.description("Returns ancestors and descendants for a CPFP transaction.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-children-pay-for-parent)*")
|
||||
.json_response::<CpfpInfo>()
|
||||
.not_modified()
|
||||
.not_found()
|
||||
.server_error(),
|
||||
),
|
||||
@@ -203,7 +204,7 @@ impl TxRoutes for ApiRouter<AppState> {
|
||||
.transactions_tag()
|
||||
.summary("Transaction raw")
|
||||
.description("Returns a transaction as binary data.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-raw)*")
|
||||
.json_response::<Vec<u8>>()
|
||||
.binary_response()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
.not_found()
|
||||
@@ -248,6 +249,7 @@ impl TxRoutes for ApiRouter<AppState> {
|
||||
.summary("Transaction first-seen times")
|
||||
.description("Returns timestamps when transactions were first seen in the mempool. Returns 0 for mined or unknown transactions.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-times)*")
|
||||
.json_response::<Vec<u64>>()
|
||||
.not_modified()
|
||||
.server_error(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ use brk_types::{
|
||||
|
||||
use crate::{
|
||||
CacheStrategy,
|
||||
cache::CACHE_CONTROL,
|
||||
extended::TransformResponseExtended,
|
||||
params::{CostBasisCohortParam, CostBasisParams, CostBasisQuery, SeriesParam},
|
||||
};
|
||||
@@ -29,8 +30,6 @@ pub mod legacy;
|
||||
const MAX_WEIGHT: usize = 4 * 8 * 10_000;
|
||||
/// Maximum allowed request weight for localhost (50MB)
|
||||
const MAX_WEIGHT_LOCALHOST: usize = 50 * 1_000_000;
|
||||
/// Cache control header for series data responses
|
||||
const CACHE_CONTROL: &str = "public, max-age=1, must-revalidate";
|
||||
|
||||
/// Returns the max weight for a request based on the client address.
|
||||
/// Localhost requests get a generous limit, external requests get a stricter one.
|
||||
@@ -262,6 +261,7 @@ impl ApiSeriesRoutes for ApiRouter<AppState> {
|
||||
"Returns the single most recent value for a series, unwrapped (not inside a SeriesData object)."
|
||||
)
|
||||
.json_response::<serde_json::Value>()
|
||||
.not_modified()
|
||||
.not_found(),
|
||||
),
|
||||
)
|
||||
@@ -284,6 +284,7 @@ impl ApiSeriesRoutes for ApiRouter<AppState> {
|
||||
.summary("Get series data length")
|
||||
.description("Returns the total number of data points for a series at the given index.")
|
||||
.json_response::<usize>()
|
||||
.not_modified()
|
||||
.not_found(),
|
||||
),
|
||||
)
|
||||
@@ -306,6 +307,7 @@ impl ApiSeriesRoutes for ApiRouter<AppState> {
|
||||
.summary("Get series version")
|
||||
.description("Returns the current version of a series. Changes when the series data is updated.")
|
||||
.json_response::<brk_types::Version>()
|
||||
.not_modified()
|
||||
.not_found(),
|
||||
),
|
||||
)
|
||||
@@ -343,6 +345,7 @@ impl ApiSeriesRoutes for ApiRouter<AppState> {
|
||||
.summary("Available cost basis cohorts")
|
||||
.description("List available cohorts for cost basis distribution.")
|
||||
.json_response::<Vec<String>>()
|
||||
.not_modified()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
@@ -366,6 +369,7 @@ impl ApiSeriesRoutes for ApiRouter<AppState> {
|
||||
.summary("Available cost basis dates")
|
||||
.description("List available dates for a cohort's cost basis distribution.")
|
||||
.json_response::<Vec<Date>>()
|
||||
.not_modified()
|
||||
.not_found()
|
||||
.server_error()
|
||||
},
|
||||
@@ -401,6 +405,7 @@ impl ApiSeriesRoutes for ApiRouter<AppState> {
|
||||
- `value`: supply (default, in BTC), realized (USD), unrealized (USD)",
|
||||
)
|
||||
.json_response::<CostBasisFormatted>()
|
||||
.not_modified()
|
||||
.not_found()
|
||||
.server_error()
|
||||
},
|
||||
|
||||
@@ -26,25 +26,47 @@ pub enum CacheStrategy {
|
||||
MempoolHash(u64),
|
||||
}
|
||||
|
||||
pub(crate) const CACHE_CONTROL: &str = "public, max-age=1, must-revalidate";
|
||||
|
||||
/// Resolved cache parameters
|
||||
pub struct CacheParams {
|
||||
pub etag: Option<String>,
|
||||
pub cache_control: String,
|
||||
pub cache_control: &'static str,
|
||||
}
|
||||
|
||||
impl CacheParams {
|
||||
pub fn immutable(version: Version) -> Self {
|
||||
pub fn tip(tip: BlockHashPrefix) -> Self {
|
||||
Self {
|
||||
etag: Some(format!("i{version}")),
|
||||
cache_control: "public, max-age=1, must-revalidate".into(),
|
||||
etag: Some(format!("t{:x}", *tip)),
|
||||
cache_control: CACHE_CONTROL,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn immutable(version: Version) -> Self {
|
||||
Self {
|
||||
etag: Some(format!("i{version}")),
|
||||
cache_control: CACHE_CONTROL,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn block_bound(version: Version, prefix: BlockHashPrefix) -> Self {
|
||||
Self {
|
||||
etag: Some(format!("b{version}-{:x}", *prefix)),
|
||||
cache_control: CACHE_CONTROL,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache params using CARGO_PKG_VERSION as etag (for openapi.json etc.)
|
||||
pub fn static_version() -> Self {
|
||||
Self {
|
||||
etag: Some(format!("s{VERSION}")),
|
||||
cache_control: "public, max-age=1, must-revalidate".into(),
|
||||
cache_control: CACHE_CONTROL,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mempool_hash(hash: u64) -> Self {
|
||||
Self {
|
||||
etag: Some(format!("m{hash:x}")),
|
||||
cache_control: CACHE_CONTROL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,28 +81,12 @@ impl CacheParams {
|
||||
}
|
||||
|
||||
pub fn resolve(strategy: &CacheStrategy, tip: impl FnOnce() -> BlockHashPrefix) -> Self {
|
||||
let cache_control = "public, max-age=1, must-revalidate".into();
|
||||
match strategy {
|
||||
CacheStrategy::Tip => Self {
|
||||
etag: Some(format!("t{:x}", *tip())),
|
||||
cache_control,
|
||||
},
|
||||
CacheStrategy::Immutable(v) => Self {
|
||||
etag: Some(format!("i{v}")),
|
||||
cache_control,
|
||||
},
|
||||
CacheStrategy::BlockBound(v, prefix) => Self {
|
||||
etag: Some(format!("b{v}-{:x}", **prefix)),
|
||||
cache_control,
|
||||
},
|
||||
CacheStrategy::Static => Self {
|
||||
etag: Some(format!("s{VERSION}")),
|
||||
cache_control,
|
||||
},
|
||||
CacheStrategy::MempoolHash(hash) => Self {
|
||||
etag: Some(format!("m{hash:x}")),
|
||||
cache_control,
|
||||
},
|
||||
CacheStrategy::Tip => Self::tip(tip()),
|
||||
CacheStrategy::Immutable(v) => Self::immutable(*v),
|
||||
CacheStrategy::BlockBound(v, prefix) => Self::block_bound(*v, *prefix),
|
||||
CacheStrategy::Static => Self::static_version(),
|
||||
CacheStrategy::MempoolHash(hash) => Self::mempool_hash(*hash),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ impl ResponseExtended for Response<Body> {
|
||||
|
||||
fn new_not_modified_with(params: &CacheParams) -> Response<Body> {
|
||||
let etag = Etag::from(params.etag_str());
|
||||
Self::new_not_modified(&etag, ¶ms.cache_control)
|
||||
Self::new_not_modified(&etag, params.cache_control)
|
||||
}
|
||||
|
||||
fn new_json_cached<T>(value: T, params: &CacheParams) -> Self
|
||||
@@ -51,7 +51,7 @@ impl ResponseExtended for Response<Body> {
|
||||
let mut response = Response::builder().body(bytes.into()).unwrap();
|
||||
let headers = response.headers_mut();
|
||||
headers.insert_content_type_application_json();
|
||||
headers.insert_cache_control(¶ms.cache_control);
|
||||
headers.insert_cache_control(params.cache_control);
|
||||
if let Some(etag) = ¶ms.etag {
|
||||
headers.insert_etag(etag);
|
||||
}
|
||||
@@ -83,7 +83,7 @@ impl ResponseExtended for Response<Body> {
|
||||
let h = response.headers_mut();
|
||||
h.insert(header::CONTENT_TYPE, content_type.parse().unwrap());
|
||||
h.insert(header::CONTENT_ENCODING, content_encoding.parse().unwrap());
|
||||
h.insert_cache_control(¶ms.cache_control);
|
||||
h.insert_cache_control(params.cache_control);
|
||||
if let Some(etag) = ¶ms.etag {
|
||||
h.insert_etag(etag);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ pub trait TransformResponseExtended<'t> {
|
||||
F: FnOnce(TransformResponse<'_, R>) -> TransformResponse<'_, R>;
|
||||
/// 200 with text/plain content type
|
||||
fn text_response(self) -> Self;
|
||||
/// 200 with application/octet-stream content type
|
||||
fn binary_response(self) -> Self;
|
||||
/// 200 with text/csv content type (adds CSV as alternative response format)
|
||||
fn csv_response(self) -> Self;
|
||||
/// 400
|
||||
@@ -108,6 +110,10 @@ impl<'t> TransformResponseExtended<'t> for TransformOperation<'t> {
|
||||
self.response_with::<200, String, _>(|res| res.description("Successful response"))
|
||||
}
|
||||
|
||||
fn binary_response(self) -> Self {
|
||||
self.response_with::<200, Vec<u8>, _>(|res| res.description("Raw binary data"))
|
||||
}
|
||||
|
||||
fn csv_response(mut self) -> Self {
|
||||
// Add text/csv content type to existing 200 response
|
||||
if let Some(responses) = &mut self.inner_mut().responses
|
||||
|
||||
@@ -165,7 +165,7 @@ impl AppState {
|
||||
let mut response = Response::new(Body::from(bytes));
|
||||
let h = response.headers_mut();
|
||||
h.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||
h.insert_cache_control(¶ms.cache_control);
|
||||
h.insert_cache_control(params.cache_control);
|
||||
h.insert_content_encoding(encoding);
|
||||
if let Some(etag) = ¶ms.etag {
|
||||
h.insert_etag(etag);
|
||||
|
||||
@@ -6590,7 +6590,7 @@ function createTransferPattern(client, acc) {
|
||||
* @extends BrkClientBase
|
||||
*/
|
||||
class BrkClient extends BrkClientBase {
|
||||
VERSION = "v0.3.0-alpha.5";
|
||||
VERSION = "v0.3.0-alpha.6";
|
||||
|
||||
INDEXES = /** @type {const} */ ([
|
||||
"minute10",
|
||||
|
||||
@@ -40,5 +40,5 @@
|
||||
"url": "git+https://github.com/bitcoinresearchkit/brk.git"
|
||||
},
|
||||
"type": "module",
|
||||
"version": "0.3.0-alpha.5"
|
||||
"version": "0.3.0-alpha.6"
|
||||
}
|
||||
|
||||
@@ -6033,7 +6033,7 @@ class SeriesTree:
|
||||
class BrkClient(BrkClientBase):
|
||||
"""Main BRK client with series tree and API methods."""
|
||||
|
||||
VERSION = "v0.3.0-alpha.5"
|
||||
VERSION = "v0.3.0-alpha.6"
|
||||
|
||||
INDEXES = [
|
||||
"minute10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "brk-client"
|
||||
version = "0.3.0-alpha.5"
|
||||
version = "0.3.0-alpha.6"
|
||||
description = "Bitcoin on-chain analytics client — thousands of metrics, block explorer, and address index"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
<label
|
||||
id="aside-selector-label"
|
||||
for="aside-selector"
|
||||
title="Side"
|
||||
title="View sidebar"
|
||||
class="md:hidden"
|
||||
>
|
||||
<input
|
||||
@@ -147,6 +147,7 @@
|
||||
|
||||
<button id="share-button" title="Share">Share</button>
|
||||
<button id="theme-button" title="Invert theme">Theme</button>
|
||||
<button id="pin-button" title="Unpin sidebar">Unpin</button>
|
||||
</fieldset>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*
|
||||
* @import { Color } from "./utils/colors.js"
|
||||
*
|
||||
* @import { Option, PartialChartOption, ChartOption, AnyPartialOption, ProcessedOptionAddons, OptionsTree, SimulationOption, AnySeriesBlueprint, SeriesType, AnyFetchedSeriesBlueprint, TableOption, 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, FetchedCandlestickSeriesBlueprint, FetchedPriceSeriesBlueprint, AnyPricePattern, AnyValuePattern } from "./options/partial.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, FetchedCandlestickSeriesBlueprint, FetchedPriceSeriesBlueprint, AnyPricePattern, AnyValuePattern } from "./options/partial.js"
|
||||
*
|
||||
*
|
||||
* @import { UnitObject as Unit } from "./utils/units.js"
|
||||
@@ -56,6 +56,7 @@
|
||||
* @typedef {Brk.BlockInfo} BlockInfo
|
||||
* @typedef {Brk.BlockHash} BlockHash
|
||||
* @typedef {Brk.BlockInfoV1} BlockInfoV1
|
||||
* @typedef {Brk.Transaction} Transaction
|
||||
* ActivePriceRatioPattern: ratio pattern with price (extended)
|
||||
* @typedef {Brk.BpsPriceRatioPattern} ActivePriceRatioPattern
|
||||
* PriceRatioPercentilesPattern: price pattern with ratio + percentiles (no SMAs/stdDev)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { initPrice, onPrice } from "./utils/price.js";
|
||||
import { brk } from "./client.js";
|
||||
import { stringToId } from "./utils/format.js";
|
||||
import { onFirstIntersection, getElementById, isHidden } from "./utils/dom.js";
|
||||
import { initOptions } from "./options/full.js";
|
||||
import {
|
||||
@@ -9,9 +8,8 @@ import {
|
||||
} from "./panes/chart.js";
|
||||
import { init as initExplorer } from "./panes/explorer.js";
|
||||
import { init as initSearch } from "./panes/search.js";
|
||||
import { replaceHistory } from "./utils/url.js";
|
||||
import { idle } from "./utils/timing.js";
|
||||
import { removeStored, writeToStorage } from "./utils/storage.js";
|
||||
import { readStored, removeStored, writeToStorage } from "./utils/storage.js";
|
||||
import {
|
||||
asideElement,
|
||||
asideLabelElement,
|
||||
@@ -22,10 +20,38 @@ import {
|
||||
mainElement,
|
||||
navElement,
|
||||
navLabelElement,
|
||||
pinButtonElement,
|
||||
searchElement,
|
||||
style,
|
||||
} from "./utils/elements.js";
|
||||
|
||||
const DESKTOP_QUERY = window.matchMedia("(min-width: 768px)");
|
||||
let sidebarPinned = readStored("sidebar-pinned") !== "false";
|
||||
|
||||
function updateLayout() {
|
||||
const layout = DESKTOP_QUERY.matches && sidebarPinned ? "desktop" : "mobile";
|
||||
document.documentElement.dataset.layout = layout;
|
||||
if (layout === "desktop") {
|
||||
asideElement.parentElement !== bodyElement &&
|
||||
bodyElement.append(asideElement);
|
||||
} else {
|
||||
asideElement.parentElement !== mainElement &&
|
||||
mainElement.append(asideElement);
|
||||
}
|
||||
}
|
||||
|
||||
DESKTOP_QUERY.addEventListener("change", updateLayout);
|
||||
updateLayout();
|
||||
|
||||
pinButtonElement.addEventListener("click", () => {
|
||||
sidebarPinned = !sidebarPinned;
|
||||
writeToStorage("sidebar-pinned", String(sidebarPinned));
|
||||
pinButtonElement.textContent = sidebarPinned ? "Unpin" : "Pin";
|
||||
pinButtonElement.title = sidebarPinned ? "Unpin sidebar" : "Pin sidebar";
|
||||
updateLayout();
|
||||
if (!sidebarPinned) asideLabelElement.click();
|
||||
});
|
||||
|
||||
function initFrameSelectors() {
|
||||
const children = Array.from(frameSelectorsElement.children);
|
||||
|
||||
@@ -77,7 +103,6 @@ function initFrameSelectors() {
|
||||
|
||||
asideLabelElement.click();
|
||||
|
||||
// When going from mobile view to desktop view, if selected frame was open, go to the nav frame
|
||||
new IntersectionObserver((entries) => {
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
if (
|
||||
@@ -89,22 +114,6 @@ function initFrameSelectors() {
|
||||
}
|
||||
}
|
||||
}).observe(asideLabelElement);
|
||||
|
||||
function setAsideParent() {
|
||||
const { clientWidth } = window.document.documentElement;
|
||||
const MEDIUM_WIDTH = 768;
|
||||
if (clientWidth >= MEDIUM_WIDTH) {
|
||||
asideElement.parentElement !== bodyElement &&
|
||||
bodyElement.append(asideElement);
|
||||
} else {
|
||||
asideElement.parentElement !== mainElement &&
|
||||
mainElement.append(asideElement);
|
||||
}
|
||||
}
|
||||
|
||||
setAsideParent();
|
||||
|
||||
window.addEventListener("resize", setAsideParent);
|
||||
}
|
||||
initFrameSelectors();
|
||||
|
||||
@@ -117,27 +126,7 @@ onPrice((price) => {
|
||||
|
||||
const options = initOptions();
|
||||
|
||||
window.addEventListener("popstate", (_event) => {
|
||||
const path = window.document.location.pathname.split("/").filter((v) => v);
|
||||
let folder = options.tree;
|
||||
|
||||
while (path.length) {
|
||||
const id = path.shift();
|
||||
const res = folder.find((v) => id === stringToId(v.name));
|
||||
if (!res) throw "Option not found";
|
||||
if (path.length >= 1) {
|
||||
if (!("tree" in res)) {
|
||||
throw "Unreachable";
|
||||
}
|
||||
folder = res.tree;
|
||||
} else {
|
||||
if ("tree" in res) {
|
||||
throw "Unreachable";
|
||||
}
|
||||
options.selected.set(res);
|
||||
}
|
||||
}
|
||||
});
|
||||
window.addEventListener("popstate", () => options.resolveUrl());
|
||||
|
||||
function initSelected() {
|
||||
let firstRun = true;
|
||||
@@ -188,10 +177,6 @@ function initSelected() {
|
||||
element.hidden = false;
|
||||
}
|
||||
|
||||
if (!previousElement) {
|
||||
replaceHistory({ pathname: option.path });
|
||||
}
|
||||
|
||||
previousElement = element;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,10 +13,6 @@ import { brk } from "../client.js";
|
||||
export function initOptions() {
|
||||
const LS_SELECTED_KEY = `selected_path`;
|
||||
|
||||
const urlPath_ = window.document.location.pathname
|
||||
.split("/")
|
||||
.filter((v) => v);
|
||||
const urlPath = urlPath_.length ? urlPath_ : undefined;
|
||||
const savedPath = /** @type {string[]} */ (
|
||||
JSON.parse(readStored(LS_SELECTED_KEY) || "[]") || []
|
||||
).filter((v) => v);
|
||||
@@ -254,8 +250,6 @@ export function initOptions() {
|
||||
* @typedef {ProcessedGroup | ProcessedOption} ProcessedNode
|
||||
*/
|
||||
|
||||
// Pre-compute path strings for faster comparison
|
||||
const urlPathStr = urlPath?.join("/");
|
||||
const savedPathStr = savedPath?.join("/");
|
||||
|
||||
/**
|
||||
@@ -308,15 +302,7 @@ export function initOptions() {
|
||||
// Transform partial to full option
|
||||
if ("kind" in anyPartial && anyPartial.kind === "explorer") {
|
||||
option.kind = anyPartial.kind;
|
||||
option.path = path;
|
||||
option.name = name;
|
||||
} else if ("kind" in anyPartial && anyPartial.kind === "table") {
|
||||
option.kind = anyPartial.kind;
|
||||
option.path = path;
|
||||
option.name = name;
|
||||
} else if ("kind" in anyPartial && anyPartial.kind === "simulation") {
|
||||
option.kind = anyPartial.kind;
|
||||
option.path = path;
|
||||
option.path = [];
|
||||
option.name = name;
|
||||
} else if ("url" in anyPartial) {
|
||||
Object.assign(
|
||||
@@ -352,10 +338,7 @@ export function initOptions() {
|
||||
list.push(option);
|
||||
totalCount++;
|
||||
|
||||
// Check if this matches URL or saved path (string comparison is faster)
|
||||
if (urlPathStr && pathStr === urlPathStr) {
|
||||
selected.set(option);
|
||||
} else if (savedPathStr && pathStr === savedPathStr) {
|
||||
if (savedPathStr && pathStr === savedPathStr) {
|
||||
savedOption = option;
|
||||
}
|
||||
|
||||
@@ -438,9 +421,29 @@ export function initOptions() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
selected.set(list[0]);
|
||||
}
|
||||
|
||||
resolveUrl();
|
||||
|
||||
if (!selected.value) {
|
||||
const option =
|
||||
savedOption || list.find((option) => option.kind === "chart");
|
||||
const option = savedOption || list[0];
|
||||
if (option) {
|
||||
selected.set(option);
|
||||
}
|
||||
@@ -449,10 +452,11 @@ export function initOptions() {
|
||||
return {
|
||||
selected,
|
||||
list,
|
||||
tree: /** @type {OptionsTree} */ (partialOptions),
|
||||
tree,
|
||||
setParent,
|
||||
createOptionElement,
|
||||
selectOption,
|
||||
resolveUrl,
|
||||
};
|
||||
}
|
||||
/** @typedef {ReturnType<typeof initOptions>} Options */
|
||||
|
||||
@@ -57,15 +57,11 @@ export function createPartialOptions() {
|
||||
} = buildCohortData();
|
||||
|
||||
return [
|
||||
...(location.hostname === "localhost" || location.hostname === "127.0.0.1"
|
||||
? [
|
||||
/** @type {any} */ ({
|
||||
name: "Explorer",
|
||||
kind: "explorer",
|
||||
title: "Explorer",
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "Explorer",
|
||||
kind: "explorer",
|
||||
title: "Explorer",
|
||||
},
|
||||
{
|
||||
name: "Charts",
|
||||
tree: [
|
||||
|
||||
@@ -104,22 +104,6 @@
|
||||
*
|
||||
* @typedef {Required<Omit<PartialChartOption, "top" | "bottom">> & ProcessedChartOptionAddons & ProcessedOptionAddons} ChartOption
|
||||
*
|
||||
* @typedef {Object} PartialTableOptionSpecific
|
||||
* @property {"table"} kind
|
||||
* @property {string} title
|
||||
*
|
||||
* @typedef {PartialOption & PartialTableOptionSpecific} PartialTableOption
|
||||
*
|
||||
* @typedef {Required<PartialTableOption> & ProcessedOptionAddons} TableOption
|
||||
*
|
||||
* @typedef {Object} PartialSimulationOptionSpecific
|
||||
* @property {"simulation"} kind
|
||||
* @property {string} title
|
||||
*
|
||||
* @typedef {PartialOption & PartialSimulationOptionSpecific} PartialSimulationOption
|
||||
*
|
||||
* @typedef {Required<PartialSimulationOption> & ProcessedOptionAddons} SimulationOption
|
||||
*
|
||||
* @typedef {Object} PartialUrlOptionSpecific
|
||||
* @property {"link"} [kind]
|
||||
* @property {() => string} url
|
||||
@@ -130,9 +114,9 @@
|
||||
*
|
||||
* @typedef {Required<PartialUrlOption> & ProcessedOptionAddons} UrlOption
|
||||
*
|
||||
* @typedef {PartialExplorerOption | PartialChartOption | PartialTableOption | PartialSimulationOption | PartialUrlOption} AnyPartialOption
|
||||
* @typedef {PartialExplorerOption | PartialChartOption | PartialUrlOption} AnyPartialOption
|
||||
*
|
||||
* @typedef {ExplorerOption | ChartOption | TableOption | SimulationOption | UrlOption} Option
|
||||
* @typedef {ExplorerOption | ChartOption | UrlOption} Option
|
||||
*
|
||||
* @typedef {(AnyPartialOption | PartialOptionsGroup)[]} PartialOptionsTree
|
||||
*
|
||||
|
||||
@@ -1,433 +0,0 @@
|
||||
// // @ts-nocheck
|
||||
|
||||
// import { randomFromArray } from "../utils/array.js";
|
||||
// import { createButtonElement, createHeader, createSelect } from "../utils/dom.js";
|
||||
// import { tableElement } from "../utils/elements.js";
|
||||
// import { serdeSeries, serdeString } from "../utils/serde.js";
|
||||
// import { resetParams } from "../utils/url.js";
|
||||
|
||||
// export function init() {
|
||||
// tableElement.innerHTML = "wip, will hopefuly be back soon, sorry !";
|
||||
|
||||
// // const parent = tableElement;
|
||||
// // const { headerElement } = createHeader("Table");
|
||||
// // parent.append(headerElement);
|
||||
|
||||
// // const div = window.document.createElement("div");
|
||||
// // parent.append(div);
|
||||
|
||||
// // const table = createTable({
|
||||
// // signals,
|
||||
// // brk,
|
||||
// // resources,
|
||||
// // option,
|
||||
// // });
|
||||
// // div.append(table.element);
|
||||
|
||||
// // const span = window.document.createElement("span");
|
||||
// // span.innerHTML = "Add column";
|
||||
// // div.append(
|
||||
// // createButtonElement({
|
||||
// // onClick: () => {
|
||||
// // table.addRandomCol?.();
|
||||
// // },
|
||||
// // inside: span,
|
||||
// // title: "Click or tap to add a column to the table",
|
||||
// // }),
|
||||
// // );
|
||||
// }
|
||||
|
||||
// // /**
|
||||
// // * @param {Object} args
|
||||
// // * @param {Option} args.option
|
||||
// // * @param {Signals} args.signals
|
||||
// // * @param {BrkClient} args.brk
|
||||
// // * @param {Resources} args.resources
|
||||
// // */
|
||||
// // function createTable({ brk, signals, option, resources }) {
|
||||
// // const indexToSeries = createIndexToMetrics(seriesToIndexes);
|
||||
|
||||
// // const serializedIndexes = createSerializedIndexes();
|
||||
// // /** @type {SerializedIndex} */
|
||||
// // const defaultSerializedIndex = "height";
|
||||
// // const serializedIndex = /** @type {Signal<SerializedIndex>} */ (
|
||||
// // signals.createSignal(
|
||||
// // /** @type {SerializedIndex} */ (defaultSerializedIndex),
|
||||
// // {
|
||||
// // save: {
|
||||
// // ...serdeString,
|
||||
// // keyPrefix: "table",
|
||||
// // key: "index",
|
||||
// // },
|
||||
// // },
|
||||
// // )
|
||||
// // );
|
||||
// // const index = signals.createMemo(() =>
|
||||
// // serializedIndexToIndex(serializedIndex()),
|
||||
// // );
|
||||
|
||||
// // const table = window.document.createElement("table");
|
||||
// // const obj = {
|
||||
// // element: table,
|
||||
// // /** @type {VoidFunction | undefined} */
|
||||
// // addRandomCol: undefined,
|
||||
// // };
|
||||
|
||||
// // signals.createEffect(index, (index, prevIndex) => {
|
||||
// // if (prevIndex !== undefined) {
|
||||
// // resetParams(option);
|
||||
// // }
|
||||
|
||||
// // const possibleSeries = indexToSeries[index];
|
||||
|
||||
// // const columns = signals.createSignal(/** @type {Metric[]} */ ([]), {
|
||||
// // equals: false,
|
||||
// // save: {
|
||||
// // ...serdeSeries,
|
||||
// // keyPrefix: `table-${serializedIndex()}`,
|
||||
// // key: `columns`,
|
||||
// // },
|
||||
// // });
|
||||
// // columns.set((l) => l.filter((id) => possibleSeries.includes(id)));
|
||||
|
||||
// // signals.createEffect(columns, (columns) => {
|
||||
// // console.log(columns);
|
||||
// // });
|
||||
|
||||
// // table.innerHTML = "";
|
||||
// // const thead = window.document.createElement("thead");
|
||||
// // table.append(thead);
|
||||
// // const trHead = window.document.createElement("tr");
|
||||
// // thead.append(trHead);
|
||||
// // const tbody = window.document.createElement("tbody");
|
||||
// // table.append(tbody);
|
||||
|
||||
// // const rowElements = signals.createSignal(
|
||||
// // /** @type {HTMLTableRowElement[]} */ ([]),
|
||||
// // );
|
||||
|
||||
// // /**
|
||||
// // * @param {Object} args
|
||||
// // * @param {HTMLSelectElement} args.select
|
||||
// // * @param {Unit} [args.unit]
|
||||
// // * @param {(event: MouseEvent) => void} [args.onLeft]
|
||||
// // * @param {(event: MouseEvent) => void} [args.onRight]
|
||||
// // * @param {(event: MouseEvent) => void} [args.onRemove]
|
||||
// // */
|
||||
// // function addThCol({ select, onLeft, onRight, onRemove, unit: _unit }) {
|
||||
// // const th = window.document.createElement("th");
|
||||
// // th.scope = "col";
|
||||
// // trHead.append(th);
|
||||
// // const div = window.document.createElement("div");
|
||||
// // div.append(select);
|
||||
// // // const top = window.document.createElement("div");
|
||||
// // // div.append(top);
|
||||
// // // top.append(select);
|
||||
// // // top.append(
|
||||
// // // createAnchorElement({
|
||||
// // // href: "",
|
||||
// // // blank: true,
|
||||
// // // }),
|
||||
// // // );
|
||||
// // const bottom = window.document.createElement("div");
|
||||
// // const unit = window.document.createElement("span");
|
||||
// // if (_unit) {
|
||||
// // unit.innerHTML = _unit;
|
||||
// // }
|
||||
// // const moveLeft = createButtonElement({
|
||||
// // inside: "←",
|
||||
// // title: "Move column to the left",
|
||||
// // onClick: onLeft || (() => {}),
|
||||
// // });
|
||||
// // const moveRight = createButtonElement({
|
||||
// // inside: "→",
|
||||
// // title: "Move column to the right",
|
||||
// // onClick: onRight || (() => {}),
|
||||
// // });
|
||||
// // const remove = createButtonElement({
|
||||
// // inside: "×",
|
||||
// // title: "Remove column",
|
||||
// // onClick: onRemove || (() => {}),
|
||||
// // });
|
||||
// // bottom.append(unit);
|
||||
// // bottom.append(moveLeft);
|
||||
// // bottom.append(moveRight);
|
||||
// // bottom.append(remove);
|
||||
// // div.append(bottom);
|
||||
// // th.append(div);
|
||||
// // return {
|
||||
// // element: th,
|
||||
// // /**
|
||||
// // * @param {Unit} _unit
|
||||
// // */
|
||||
// // setUnit(_unit) {
|
||||
// // unit.innerHTML = _unit;
|
||||
// // },
|
||||
// // };
|
||||
// // }
|
||||
|
||||
// // addThCol({
|
||||
// // ...createSelect({
|
||||
// // list: serializedIndexes,
|
||||
// // signal: serializedIndex,
|
||||
// // }),
|
||||
// // unit: "index",
|
||||
// // });
|
||||
|
||||
// // let from = 0;
|
||||
// // let to = 0;
|
||||
|
||||
// // resources
|
||||
// // .getOrCreate(index, serializedIndex())
|
||||
// // .fetch()
|
||||
// // .then((vec) => {
|
||||
// // if (!vec) return;
|
||||
// // from = /** @type {number} */ (vec[0]);
|
||||
// // to = /** @type {number} */ (vec.at(-1)) + 1;
|
||||
// // const trs = /** @type {HTMLTableRowElement[]} */ ([]);
|
||||
// // for (let i = vec.length - 1; i >= 0; i--) {
|
||||
// // const value = vec[i];
|
||||
// // const tr = window.document.createElement("tr");
|
||||
// // trs.push(tr);
|
||||
// // tbody.append(tr);
|
||||
// // const th = window.document.createElement("th");
|
||||
// // th.innerHTML = serializeValue({
|
||||
// // value,
|
||||
// // unit: "index",
|
||||
// // });
|
||||
// // th.scope = "row";
|
||||
// // tr.append(th);
|
||||
// // }
|
||||
// // rowElements.set(() => trs);
|
||||
// // });
|
||||
|
||||
// // const owner = signals.getOwner();
|
||||
|
||||
// // /**
|
||||
// // * @param {Series} s
|
||||
// // * @param {number} [_colIndex]
|
||||
// // */
|
||||
// // function addCol(s, _colIndex = columns().length) {
|
||||
// // signals.runWithOwner(owner, () => {
|
||||
// // /** @type {VoidFunction | undefined} */
|
||||
// // let dispose;
|
||||
// // signals.createRoot((_dispose) => {
|
||||
// // dispose = _dispose;
|
||||
|
||||
// // const seriesOption = signals.createSignal({
|
||||
// // name: s,
|
||||
// // value: s,
|
||||
// // });
|
||||
// // const { select } = createSelect({
|
||||
// // list: possibleSeries.map((s) => ({
|
||||
// // name: s,
|
||||
// // value: s,
|
||||
// // })),
|
||||
// // signal: seriesOption,
|
||||
// // });
|
||||
|
||||
// // signals.createEffect(seriesOption, (seriesOption) => {
|
||||
// // select.style.width = `${21 + 7.25 * seriesOption.name.length}px`;
|
||||
// // });
|
||||
|
||||
// // if (_colIndex === columns().length) {
|
||||
// // columns.set((l) => {
|
||||
// // l.push(s);
|
||||
// // return l;
|
||||
// // });
|
||||
// // }
|
||||
|
||||
// // const colIndex = signals.createSignal(_colIndex);
|
||||
|
||||
// // /**
|
||||
// // * @param {boolean} right
|
||||
// // * @returns {(event: MouseEvent) => void}
|
||||
// // */
|
||||
// // function createMoveColumnFunction(right) {
|
||||
// // return () => {
|
||||
// // const oldColIndex = colIndex();
|
||||
// // const newColIndex = oldColIndex + (right ? 1 : -1);
|
||||
|
||||
// // const currentTh = /** @type {HTMLTableCellElement} */ (
|
||||
// // trHead.childNodes[oldColIndex + 1]
|
||||
// // );
|
||||
// // const oterTh = /** @type {HTMLTableCellElement} */ (
|
||||
// // trHead.childNodes[newColIndex + 1]
|
||||
// // );
|
||||
|
||||
// // if (right) {
|
||||
// // oterTh.after(currentTh);
|
||||
// // } else {
|
||||
// // oterTh.before(currentTh);
|
||||
// // }
|
||||
|
||||
// // columns.set((l) => {
|
||||
// // [l[oldColIndex], l[newColIndex]] = [
|
||||
// // l[newColIndex],
|
||||
// // l[oldColIndex],
|
||||
// // ];
|
||||
// // return l;
|
||||
// // });
|
||||
|
||||
// // const rows = rowElements();
|
||||
// // for (let i = 0; i < rows.length; i++) {
|
||||
// // const element = rows[i].childNodes[oldColIndex + 1];
|
||||
// // const sibling = rows[i].childNodes[newColIndex + 1];
|
||||
// // const temp = element.textContent;
|
||||
// // element.textContent = sibling.textContent;
|
||||
// // sibling.textContent = temp;
|
||||
// // }
|
||||
// // };
|
||||
// // }
|
||||
|
||||
// // const th = addThCol({
|
||||
// // select,
|
||||
// // unit: serdeUnit.deserialize(s),
|
||||
// // onLeft: createMoveColumnFunction(false),
|
||||
// // onRight: createMoveColumnFunction(true),
|
||||
// // onRemove: () => {
|
||||
// // const ci = colIndex();
|
||||
// // trHead.childNodes[ci + 1].remove();
|
||||
// // columns.set((l) => {
|
||||
// // l.splice(ci, 1);
|
||||
// // return l;
|
||||
// // });
|
||||
// // const rows = rowElements();
|
||||
// // for (let i = 0; i < rows.length; i++) {
|
||||
// // rows[i].childNodes[ci + 1].remove();
|
||||
// // }
|
||||
// // dispose?.();
|
||||
// // },
|
||||
// // });
|
||||
|
||||
// // signals.createEffect(columns, () => {
|
||||
// // colIndex.set(Array.from(trHead.children).indexOf(th.element) - 1);
|
||||
// // });
|
||||
|
||||
// // console.log(colIndex());
|
||||
|
||||
// // signals.createEffect(rowElements, (rowElements) => {
|
||||
// // if (!rowElements.length) return;
|
||||
// // for (let i = 0; i < rowElements.length; i++) {
|
||||
// // const td = window.document.createElement("td");
|
||||
// // rowElements[i].append(td);
|
||||
// // }
|
||||
|
||||
// // signals.createEffect(
|
||||
// // () => seriesOption().name,
|
||||
// // (s, prevSeries) => {
|
||||
// // const unit = serdeUnit.deserialize(s);
|
||||
// // th.setUnit(unit);
|
||||
|
||||
// // const vec = resources.getOrCreate(index, s);
|
||||
|
||||
// // vec.fetch({ from, to });
|
||||
|
||||
// // const fetchedKey = resources.genFetchedKey({ from, to });
|
||||
|
||||
// // columns.set((l) => {
|
||||
// // const i = l.indexOf(prevSeries ?? s);
|
||||
// // if (i === -1) {
|
||||
// // l.push(s);
|
||||
// // } else {
|
||||
// // l[i] = s;
|
||||
// // }
|
||||
// // return l;
|
||||
// // });
|
||||
|
||||
// // signals.createEffect(
|
||||
// // () => vec.fetched().get(fetchedKey)?.vec(),
|
||||
// // (vec) => {
|
||||
// // if (!vec?.length) return;
|
||||
|
||||
// // const thIndex = colIndex() + 1;
|
||||
|
||||
// // for (let i = 0; i < rowElements.length; i++) {
|
||||
// // const iRev = vec.length - 1 - i;
|
||||
// // const value = vec[iRev];
|
||||
// // // @ts-ignore
|
||||
// // rowElements[i].childNodes[thIndex].innerHTML =
|
||||
// // serializeValue({
|
||||
// // value,
|
||||
// // unit,
|
||||
// // });
|
||||
// // }
|
||||
// // },
|
||||
// // );
|
||||
|
||||
// // return () => s;
|
||||
// // },
|
||||
// // );
|
||||
// // });
|
||||
// // });
|
||||
|
||||
// // signals.onCleanup(() => {
|
||||
// // dispose?.();
|
||||
// // });
|
||||
// // });
|
||||
// // }
|
||||
|
||||
// // columns().forEach((s, colIndex) => addCol(s, colIndex));
|
||||
|
||||
// // obj.addRandomCol = function () {
|
||||
// // addCol(randomFromArray(possibleSeries));
|
||||
// // };
|
||||
|
||||
// // return () => index;
|
||||
// // });
|
||||
|
||||
// // return obj;
|
||||
// // }
|
||||
|
||||
// /**
|
||||
// * @param {SeriesToIndexes} seriesToIndexes
|
||||
// */
|
||||
// function createIndexToMetrics(seriesToIndexes) {
|
||||
// // const indexToSeries = Object.entries(seriesToIndexes).reduce(
|
||||
// // (arr, [_id, indexes]) => {
|
||||
// // const id = /** @type {Series} */ (_id);
|
||||
// // indexes.forEach((i) => {
|
||||
// // arr[i] ??= [];
|
||||
// // arr[i].push(id);
|
||||
// // });
|
||||
// // return arr;
|
||||
// // },
|
||||
// // /** @type {Series[][]} */ (Array.from({ length: 24 })),
|
||||
// // );
|
||||
// // indexToSeries.forEach((arr) => {
|
||||
// // arr.sort();
|
||||
// // });
|
||||
// // return indexToSeries;
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * @param {Object} args
|
||||
// * @param {number | string | Object | Array<any>} args.value
|
||||
// * @param {Unit} args.unit
|
||||
// */
|
||||
// function serializeValue({ value, unit }) {
|
||||
// const t = typeof value;
|
||||
// if (value === null) {
|
||||
// return "null";
|
||||
// } else if (typeof value === "string") {
|
||||
// return value;
|
||||
// } else if (t !== "number") {
|
||||
// return JSON.stringify(value).replaceAll('"', "").slice(1, -1);
|
||||
// } else if (value !== 18446744073709552000) {
|
||||
// if (unit === "usd" || unit === "difficulty" || unit === "sat/vb") {
|
||||
// return value.toLocaleString("en-us", {
|
||||
// minimumFractionDigits: 2,
|
||||
// maximumFractionDigits: 2,
|
||||
// });
|
||||
// } else if (unit === "btc") {
|
||||
// return value.toLocaleString("en-us", {
|
||||
// minimumFractionDigits: 8,
|
||||
// maximumFractionDigits: 8,
|
||||
// });
|
||||
// } else {
|
||||
// return value.toLocaleString("en-us");
|
||||
// }
|
||||
// } else {
|
||||
// return "";
|
||||
// }
|
||||
// }
|
||||
@@ -79,9 +79,7 @@ export function init() {
|
||||
|
||||
// Convert to sats if needed
|
||||
const close =
|
||||
unit === Unit.sats
|
||||
? Math.floor(ONE_BTC_IN_SATS / latest)
|
||||
: latest;
|
||||
unit === Unit.sats ? Math.floor(ONE_BTC_IN_SATS / latest) : latest;
|
||||
|
||||
if ("close" in last) {
|
||||
// Candlestick data
|
||||
@@ -117,11 +115,19 @@ const ALL_GROUPS = [
|
||||
{
|
||||
label: "Time",
|
||||
items: [
|
||||
"10mn", "30mn",
|
||||
"1h", "4h", "12h",
|
||||
"1d", "3d", "1w",
|
||||
"1m", "3m", "6m",
|
||||
"1y", "10y",
|
||||
"10mn",
|
||||
"30mn",
|
||||
"1h",
|
||||
"4h",
|
||||
"12h",
|
||||
"1d",
|
||||
"3d",
|
||||
"1w",
|
||||
"1m",
|
||||
"3m",
|
||||
"6m",
|
||||
"1y",
|
||||
"10y",
|
||||
],
|
||||
},
|
||||
{ label: "Block", items: ["blk", "epch", "halv"] },
|
||||
@@ -149,16 +155,13 @@ function computeChoices(opt) {
|
||||
.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);
|
||||
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,20 +1,15 @@
|
||||
import { explorerElement } from "../utils/elements.js";
|
||||
import { brk } from "../client.js";
|
||||
|
||||
/** @type {HTMLDivElement} */
|
||||
let chain;
|
||||
const LOOKAHEAD = 15;
|
||||
const TX_PAGE_SIZE = 25;
|
||||
|
||||
/** @type {HTMLDivElement} */
|
||||
let blocksEl;
|
||||
|
||||
/** @type {HTMLDivElement} */
|
||||
let details;
|
||||
|
||||
/** @type {HTMLDivElement} */
|
||||
let olderSentinel;
|
||||
|
||||
/** @type {HTMLDivElement} */
|
||||
let newerSentinel;
|
||||
/** @type {HTMLDivElement} */ let chain;
|
||||
/** @type {HTMLDivElement} */ let blocksEl;
|
||||
/** @type {HTMLDivElement} */ let details;
|
||||
/** @type {HTMLDivElement | null} */ let selectedCube = null;
|
||||
/** @type {number | undefined} */ let pollInterval;
|
||||
/** @type {IntersectionObserver} */ let olderObserver;
|
||||
|
||||
/** @type {Map<BlockHash, BlockInfoV1>} */
|
||||
const blocksByHash = new Map();
|
||||
@@ -26,11 +21,67 @@ let loadingOlder = false;
|
||||
let loadingNewer = false;
|
||||
let reachedTip = false;
|
||||
|
||||
/** @type {HTMLDivElement | null} */
|
||||
let selectedCube = null;
|
||||
/** @type {HTMLSpanElement} */ let heightPrefix;
|
||||
/** @type {HTMLSpanElement} */ let heightNum;
|
||||
/** @type {{ row: HTMLDivElement, valueEl: HTMLSpanElement }[]} */ let detailRows;
|
||||
/** @type {HTMLDivElement} */ let txList;
|
||||
/** @type {HTMLDivElement} */ let txSection;
|
||||
/** @type {IntersectionObserver} */ let txObserver;
|
||||
|
||||
/** @type {number | undefined} */
|
||||
let pollInterval;
|
||||
/** @typedef {{ first: HTMLButtonElement, prev: HTMLButtonElement, label: HTMLSpanElement, next: HTMLButtonElement, last: HTMLButtonElement }} TxNav */
|
||||
/** @type {TxNav[]} */ let txNavs = [];
|
||||
/** @type {BlockInfoV1 | null} */ let txBlock = null;
|
||||
let txPage = -1;
|
||||
let txTotalPages = 0;
|
||||
let txLoading = false;
|
||||
|
||||
export function init() {
|
||||
chain = document.createElement("div");
|
||||
chain.id = "chain";
|
||||
explorerElement.append(chain);
|
||||
|
||||
blocksEl = document.createElement("div");
|
||||
blocksEl.classList.add("blocks");
|
||||
chain.append(blocksEl);
|
||||
|
||||
details = document.createElement("div");
|
||||
details.id = "block-details";
|
||||
explorerElement.append(details);
|
||||
|
||||
initDetails();
|
||||
|
||||
olderObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) loadOlder();
|
||||
},
|
||||
{ root: chain },
|
||||
);
|
||||
|
||||
chain.addEventListener(
|
||||
"scroll",
|
||||
() => {
|
||||
const nearStart =
|
||||
(chain.scrollHeight > chain.clientHeight && chain.scrollTop <= 50) ||
|
||||
(chain.scrollWidth > chain.clientWidth && chain.scrollLeft <= 50);
|
||||
if (nearStart && !reachedTip && !loadingNewer) loadNewer();
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
|
||||
new MutationObserver(() => {
|
||||
if (explorerElement.hidden) stopPolling();
|
||||
else startPolling();
|
||||
}).observe(explorerElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["hidden"],
|
||||
});
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden && !explorerElement.hidden) loadLatest();
|
||||
});
|
||||
|
||||
loadLatest();
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
@@ -45,60 +96,24 @@ function stopPolling() {
|
||||
}
|
||||
}
|
||||
|
||||
export function init() {
|
||||
chain = window.document.createElement("div");
|
||||
chain.id = "chain";
|
||||
explorerElement.append(chain);
|
||||
function observeOldestEdge() {
|
||||
olderObserver.disconnect();
|
||||
const oldest = blocksEl.firstElementChild;
|
||||
if (oldest) olderObserver.observe(oldest);
|
||||
}
|
||||
|
||||
newerSentinel = window.document.createElement("div");
|
||||
newerSentinel.classList.add("sentinel");
|
||||
chain.append(newerSentinel);
|
||||
|
||||
blocksEl = window.document.createElement("div");
|
||||
blocksEl.classList.add("blocks");
|
||||
chain.append(blocksEl);
|
||||
|
||||
details = window.document.createElement("div");
|
||||
details.id = "block-details";
|
||||
explorerElement.append(details);
|
||||
|
||||
olderSentinel = window.document.createElement("div");
|
||||
olderSentinel.classList.add("sentinel");
|
||||
blocksEl.append(olderSentinel);
|
||||
|
||||
function checkSentinels() {
|
||||
const p = chain.getBoundingClientRect();
|
||||
const older = olderSentinel.getBoundingClientRect();
|
||||
if (older.top < p.bottom + 200 && older.bottom > p.top) {
|
||||
loadOlder();
|
||||
}
|
||||
const newer = newerSentinel.getBoundingClientRect();
|
||||
if (newer.bottom > p.top - 200 && newer.top < p.bottom) {
|
||||
loadNewer();
|
||||
/** @param {BlockInfoV1[]} blocks */
|
||||
function appendNewerBlocks(blocks) {
|
||||
if (!blocks.length) return false;
|
||||
for (const b of [...blocks].reverse()) {
|
||||
if (b.height > newestHeight) {
|
||||
blocksEl.append(createBlockCube(b));
|
||||
} else {
|
||||
blocksByHash.set(b.id, b);
|
||||
}
|
||||
}
|
||||
|
||||
chain.addEventListener("scroll", checkSentinels, { passive: true });
|
||||
|
||||
// Self-contained lifecycle: poll when visible, stop when hidden
|
||||
new MutationObserver(() => {
|
||||
if (explorerElement.hidden) {
|
||||
stopPolling();
|
||||
} else {
|
||||
startPolling();
|
||||
}
|
||||
}).observe(explorerElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["hidden"],
|
||||
});
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden && !explorerElement.hidden) {
|
||||
loadLatest();
|
||||
}
|
||||
});
|
||||
|
||||
loadLatest();
|
||||
newestHeight = Math.max(newestHeight, blocks[0].height);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @returns {Promise<number | null>} */
|
||||
@@ -107,8 +122,7 @@ async function getStartHeight() {
|
||||
if (path[0] !== "block" || !path[1]) return null;
|
||||
const value = path[1];
|
||||
if (/^\d+$/.test(value)) return Number(value);
|
||||
const block = await brk.getBlockV1(value);
|
||||
return block.height;
|
||||
return (await brk.getBlockV1(value)).height;
|
||||
}
|
||||
|
||||
async function loadLatest() {
|
||||
@@ -122,41 +136,20 @@ async function loadLatest() {
|
||||
? await brk.getBlocksV1FromHeight(startHeight)
|
||||
: await brk.getBlocksV1();
|
||||
|
||||
// First load: insert all blocks between sentinels
|
||||
if (newestHeight === -1) {
|
||||
const cubes = blocks.map((b) => createBlockCube(b));
|
||||
for (const cube of cubes) {
|
||||
olderSentinel.after(cube);
|
||||
}
|
||||
for (const b of blocks) blocksEl.prepend(createBlockCube(b));
|
||||
newestHeight = blocks[0].height;
|
||||
oldestHeight = blocks[blocks.length - 1].height;
|
||||
if (startHeight === null) reachedTip = true;
|
||||
selectCube(cubes[0]);
|
||||
if (!reachedTip) {
|
||||
newerSentinel.style.minHeight = chain.clientHeight + "px";
|
||||
requestAnimationFrame(() => {
|
||||
if (selectedCube) {
|
||||
selectedCube.scrollIntoView({
|
||||
behavior: "instant",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
selectCube(/** @type {HTMLDivElement} */ (blocksEl.lastElementChild));
|
||||
loadingLatest = false;
|
||||
observeOldestEdge();
|
||||
if (!reachedTip) await loadNewer();
|
||||
return;
|
||||
} else {
|
||||
// Subsequent polls: append newer blocks to blocksEl
|
||||
const newBlocks = blocks.filter((b) => b.height > newestHeight);
|
||||
if (newBlocks.length) {
|
||||
newBlocks.sort((a, b) => a.height - b.height);
|
||||
for (const b of newBlocks) {
|
||||
blocksEl.append(createBlockCube(b));
|
||||
}
|
||||
newestHeight = newBlocks[newBlocks.length - 1].height;
|
||||
}
|
||||
reachedTip = true;
|
||||
}
|
||||
|
||||
appendNewerBlocks(blocks);
|
||||
reachedTip = true;
|
||||
} catch (e) {
|
||||
console.error("explorer poll:", e);
|
||||
}
|
||||
@@ -168,11 +161,10 @@ async function loadOlder() {
|
||||
loadingOlder = true;
|
||||
try {
|
||||
const blocks = await brk.getBlocksV1FromHeight(oldestHeight - 1);
|
||||
for (const block of blocks) {
|
||||
olderSentinel.after(createBlockCube(block));
|
||||
}
|
||||
for (const block of blocks) blocksEl.prepend(createBlockCube(block));
|
||||
if (blocks.length) {
|
||||
oldestHeight = blocks[blocks.length - 1].height;
|
||||
observeOldestEdge();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("explorer loadOlder:", e);
|
||||
@@ -184,17 +176,18 @@ async function loadNewer() {
|
||||
if (loadingNewer || newestHeight === -1 || reachedTip) return;
|
||||
loadingNewer = true;
|
||||
try {
|
||||
const blocks = await brk.getBlocksV1FromHeight(newestHeight + 15);
|
||||
const newer = blocks.filter((b) => b.height > newestHeight);
|
||||
if (newer.length) {
|
||||
newer.sort((a, b) => a.height - b.height);
|
||||
for (const b of newer) {
|
||||
blocksEl.append(createBlockCube(b));
|
||||
const anchor = blocksEl.lastElementChild;
|
||||
const anchorRect = anchor?.getBoundingClientRect();
|
||||
|
||||
const blocks = await brk.getBlocksV1FromHeight(newestHeight + LOOKAHEAD);
|
||||
if (appendNewerBlocks(blocks)) {
|
||||
if (anchor && anchorRect) {
|
||||
const r = anchor.getBoundingClientRect();
|
||||
chain.scrollTop += r.top - anchorRect.top;
|
||||
chain.scrollLeft += r.left - anchorRect.left;
|
||||
}
|
||||
newestHeight = newer[newer.length - 1].height;
|
||||
} else {
|
||||
reachedTip = true;
|
||||
newerSentinel.style.minHeight = "";
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("explorer loadNewer:", e);
|
||||
@@ -204,106 +197,279 @@ async function loadNewer() {
|
||||
|
||||
/** @param {HTMLDivElement} cube */
|
||||
function selectCube(cube) {
|
||||
if (selectedCube) {
|
||||
selectedCube.classList.remove("selected");
|
||||
}
|
||||
if (selectedCube) selectedCube.classList.remove("selected");
|
||||
selectedCube = cube;
|
||||
if (cube) {
|
||||
cube.classList.add("selected");
|
||||
const hash = cube.dataset.hash;
|
||||
if (hash) {
|
||||
renderDetails(blocksByHash.get(hash));
|
||||
}
|
||||
if (hash) updateDetails(blocksByHash.get(hash));
|
||||
}
|
||||
}
|
||||
|
||||
/** @typedef {[string, (b: BlockInfoV1) => string | null]} RowDef */
|
||||
|
||||
/** @type {RowDef[]} */
|
||||
const ROW_DEFS = [
|
||||
["Hash", (b) => b.id],
|
||||
["Previous Hash", (b) => 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", (b) => (b.extras ? `$${b.extras.price.toLocaleString()}` : null)],
|
||||
["Pool", (b) => b.extras?.pool.name ?? null],
|
||||
["Pool ID", (b) => b.extras?.pool.id.toString() ?? null],
|
||||
["Pool Slug", (b) => b.extras?.pool.slug ?? null],
|
||||
["Miner Names", (b) => b.extras?.pool.minerNames?.join(", ") || null],
|
||||
["Reward", (b) => (b.extras ? `${(b.extras.reward / 1e8).toFixed(8)} BTC` : null)],
|
||||
["Total Fees", (b) => (b.extras ? `${(b.extras.totalFees / 1e8).toFixed(8)} BTC` : null)],
|
||||
["Median Fee Rate", (b) => (b.extras ? `${formatFeeRate(b.extras.medianFee)} sat/vB` : null)],
|
||||
["Avg Fee Rate", (b) => (b.extras ? `${formatFeeRate(b.extras.avgFeeRate)} sat/vB` : null)],
|
||||
["Avg Fee", (b) => (b.extras ? `${b.extras.avgFee.toLocaleString()} sat` : null)],
|
||||
["Median Fee", (b) => (b.extras ? `${b.extras.medianFeeAmt.toLocaleString()} sat` : null)],
|
||||
["Fee Range", (b) => (b.extras ? b.extras.feeRange.map((f) => formatFeeRate(f)).join(", ") + " sat/vB" : null)],
|
||||
["Fee Percentiles", (b) => (b.extras ? b.extras.feePercentiles.map((f) => f.toLocaleString()).join(", ") + " sat" : null)],
|
||||
["Avg Tx Size", (b) => (b.extras ? `${b.extras.avgTxSize.toLocaleString()} B` : null)],
|
||||
["Virtual Size", (b) => (b.extras ? `${b.extras.virtualSize.toLocaleString()} vB` : null)],
|
||||
["Inputs", (b) => b.extras?.totalInputs.toLocaleString() ?? null],
|
||||
["Outputs", (b) => b.extras?.totalOutputs.toLocaleString() ?? null],
|
||||
["Total Input Amount", (b) => (b.extras ? `${(b.extras.totalInputAmt / 1e8).toFixed(8)} BTC` : null)],
|
||||
["Total Output Amount", (b) => (b.extras ? `${(b.extras.totalOutputAmt / 1e8).toFixed(8)} BTC` : null)],
|
||||
["UTXO Set Change", (b) => b.extras?.utxoSetChange.toLocaleString() ?? null],
|
||||
["UTXO Set Size", (b) => b.extras?.utxoSetSize.toLocaleString() ?? null],
|
||||
["SegWit Txs", (b) => b.extras?.segwitTotalTxs.toLocaleString() ?? null],
|
||||
["SegWit Size", (b) => (b.extras ? `${b.extras.segwitTotalSize.toLocaleString()} B` : null)],
|
||||
["SegWit Weight", (b) => (b.extras ? `${b.extras.segwitTotalWeight.toLocaleString()} WU` : null)],
|
||||
["Coinbase Address", (b) => b.extras?.coinbaseAddress || null],
|
||||
["Coinbase Addresses", (b) => b.extras?.coinbaseAddresses.join(", ") || null],
|
||||
["Coinbase Raw", (b) => b.extras?.coinbaseRaw ?? null],
|
||||
["Coinbase Signature", (b) => b.extras?.coinbaseSignature ?? null],
|
||||
["Coinbase Signature ASCII", (b) => b.extras?.coinbaseSignatureAscii ?? null],
|
||||
["Header", (b) => b.extras?.header ?? null],
|
||||
];
|
||||
|
||||
function initDetails() {
|
||||
const title = document.createElement("h1");
|
||||
title.textContent = "Block ";
|
||||
const code = document.createElement("code");
|
||||
const container = document.createElement("span");
|
||||
heightPrefix = document.createElement("span");
|
||||
heightPrefix.style.opacity = "0.5";
|
||||
heightPrefix.style.userSelect = "none";
|
||||
heightNum = document.createElement("span");
|
||||
container.append(heightPrefix, heightNum);
|
||||
code.append(container);
|
||||
title.append(code);
|
||||
details.append(title);
|
||||
|
||||
detailRows = ROW_DEFS.map(([label]) => {
|
||||
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("span");
|
||||
valueEl.classList.add("value");
|
||||
row.append(labelEl, valueEl);
|
||||
details.append(row);
|
||||
return { row, valueEl };
|
||||
});
|
||||
|
||||
txSection = document.createElement("div");
|
||||
txSection.classList.add("transactions");
|
||||
details.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 && txPage === -1) loadTxPage(0);
|
||||
});
|
||||
txObserver.observe(txSection);
|
||||
}
|
||||
|
||||
/** @returns {HTMLDivElement} */
|
||||
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(txPage - 1));
|
||||
next.addEventListener("click", () => loadTxPage(txPage + 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 | undefined} block */
|
||||
function renderDetails(block) {
|
||||
details.innerHTML = "";
|
||||
function updateDetails(block) {
|
||||
if (!block) return;
|
||||
details.scrollTop = 0;
|
||||
|
||||
const title = window.document.createElement("h1");
|
||||
title.textContent = "Block ";
|
||||
const titleCode = window.document.createElement("code");
|
||||
titleCode.append(createHeightElement(block.height));
|
||||
title.append(titleCode);
|
||||
details.append(title);
|
||||
const str = block.height.toString();
|
||||
heightPrefix.textContent = "#" + "0".repeat(7 - str.length);
|
||||
heightNum.textContent = str;
|
||||
|
||||
const extras = block.extras;
|
||||
ROW_DEFS.forEach(([, getter], i) => {
|
||||
const value = getter(block);
|
||||
const { row, valueEl } = detailRows[i];
|
||||
if (value !== null) {
|
||||
valueEl.textContent = value;
|
||||
row.hidden = false;
|
||||
} else {
|
||||
row.hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
/** @type {[string, string][]} */
|
||||
const rows = [
|
||||
["Hash", block.id],
|
||||
["Previous Hash", block.previousblockhash],
|
||||
["Merkle Root", block.merkleRoot],
|
||||
["Timestamp", new Date(block.timestamp * 1000).toUTCString()],
|
||||
["Median Time", new Date(block.mediantime * 1000).toUTCString()],
|
||||
["Version", `0x${block.version.toString(16)}`],
|
||||
["Bits", block.bits.toString(16)],
|
||||
["Nonce", block.nonce.toLocaleString()],
|
||||
["Difficulty", Number(block.difficulty).toLocaleString()],
|
||||
["Size", `${(block.size / 1_000_000).toFixed(2)} MB`],
|
||||
["Weight", `${(block.weight / 1_000_000).toFixed(2)} MWU`],
|
||||
["Transactions", block.txCount.toLocaleString()],
|
||||
];
|
||||
txBlock = block;
|
||||
txTotalPages = Math.ceil(block.txCount / TX_PAGE_SIZE);
|
||||
txPage = -1;
|
||||
updateTxNavs(0);
|
||||
txList.innerHTML = "";
|
||||
txObserver.disconnect();
|
||||
txObserver.observe(txSection);
|
||||
}
|
||||
|
||||
if (extras) {
|
||||
rows.push(
|
||||
["Price", `$${extras.price.toLocaleString()}`],
|
||||
["Pool", extras.pool.name],
|
||||
["Pool ID", extras.pool.id.toString()],
|
||||
["Pool Slug", extras.pool.slug],
|
||||
["Miner Names", extras.pool.minerNames?.join(", ") || "N/A"],
|
||||
["Reward", `${(extras.reward / 1e8).toFixed(8)} BTC`],
|
||||
["Total Fees", `${(extras.totalFees / 1e8).toFixed(8)} BTC`],
|
||||
["Median Fee Rate", `${formatFeeRate(extras.medianFee)} sat/vB`],
|
||||
["Avg Fee Rate", `${formatFeeRate(extras.avgFeeRate)} sat/vB`],
|
||||
["Avg Fee", `${extras.avgFee.toLocaleString()} sat`],
|
||||
["Median Fee", `${extras.medianFeeAmt.toLocaleString()} sat`],
|
||||
[
|
||||
"Fee Range",
|
||||
extras.feeRange.map((f) => formatFeeRate(f)).join(", ") + " sat/vB",
|
||||
],
|
||||
[
|
||||
"Fee Percentiles",
|
||||
extras.feePercentiles.map((f) => f.toLocaleString()).join(", ") +
|
||||
" sat",
|
||||
],
|
||||
["Avg Tx Size", `${extras.avgTxSize.toLocaleString()} B`],
|
||||
["Virtual Size", `${extras.virtualSize.toLocaleString()} vB`],
|
||||
["Inputs", extras.totalInputs.toLocaleString()],
|
||||
["Outputs", extras.totalOutputs.toLocaleString()],
|
||||
["Total Input Amount", `${(extras.totalInputAmt / 1e8).toFixed(8)} BTC`],
|
||||
[
|
||||
"Total Output Amount",
|
||||
`${(extras.totalOutputAmt / 1e8).toFixed(8)} BTC`,
|
||||
],
|
||||
["UTXO Set Change", extras.utxoSetChange.toLocaleString()],
|
||||
["UTXO Set Size", extras.utxoSetSize.toLocaleString()],
|
||||
["SegWit Txs", extras.segwitTotalTxs.toLocaleString()],
|
||||
["SegWit Size", `${extras.segwitTotalSize.toLocaleString()} B`],
|
||||
["SegWit Weight", `${extras.segwitTotalWeight.toLocaleString()} WU`],
|
||||
["Coinbase Address", extras.coinbaseAddress || "N/A"],
|
||||
["Coinbase Addresses", extras.coinbaseAddresses.join(", ") || "N/A"],
|
||||
["Coinbase Raw", extras.coinbaseRaw],
|
||||
["Coinbase Signature", extras.coinbaseSignature],
|
||||
["Coinbase Signature ASCII", extras.coinbaseSignatureAscii],
|
||||
["Header", extras.header],
|
||||
);
|
||||
/** @param {number} page */
|
||||
async function loadTxPage(page) {
|
||||
if (txLoading || !txBlock || page < 0 || page >= txTotalPages) return;
|
||||
txLoading = true;
|
||||
txPage = page;
|
||||
updateTxNavs(page);
|
||||
try {
|
||||
const txs = await brk.getBlockTxsFromIndex(txBlock.id, page * TX_PAGE_SIZE);
|
||||
txList.innerHTML = "";
|
||||
for (const tx of txs) txList.append(renderTx(tx));
|
||||
} catch (e) {
|
||||
console.error("explorer txs:", e);
|
||||
}
|
||||
txLoading = false;
|
||||
}
|
||||
|
||||
/** @param {Transaction} tx */
|
||||
function renderTx(tx) {
|
||||
const el = document.createElement("div");
|
||||
el.classList.add("tx");
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.classList.add("tx-head");
|
||||
const txidEl = document.createElement("span");
|
||||
txidEl.classList.add("txid");
|
||||
txidEl.textContent = 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");
|
||||
for (const vin of tx.vin) {
|
||||
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");
|
||||
} else {
|
||||
const a = /** @type {string | undefined} */ (/** @type {any} */ (vin.prevout)?.scriptpubkey_address);
|
||||
setAddrContent(a || "Unknown", addr);
|
||||
}
|
||||
const amt = document.createElement("span");
|
||||
amt.classList.add("amount");
|
||||
amt.textContent = vin.prevout ? `${formatBtc(vin.prevout.value)} BTC` : "";
|
||||
row.append(addr, amt);
|
||||
inputs.append(row);
|
||||
}
|
||||
|
||||
for (const [label, value] of rows) {
|
||||
const row = window.document.createElement("div");
|
||||
row.classList.add("row");
|
||||
const labelElement = window.document.createElement("span");
|
||||
labelElement.classList.add("label");
|
||||
labelElement.textContent = label;
|
||||
const valueElement = window.document.createElement("span");
|
||||
valueElement.classList.add("value");
|
||||
valueElement.textContent = value;
|
||||
row.append(labelElement, valueElement);
|
||||
details.append(row);
|
||||
const outputs = document.createElement("div");
|
||||
outputs.classList.add("tx-outputs");
|
||||
let totalOut = 0;
|
||||
for (const vout of tx.vout) {
|
||||
totalOut += vout.value;
|
||||
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 {
|
||||
setAddrContent(a || vout.scriptpubkey, addr);
|
||||
}
|
||||
const amt = document.createElement("span");
|
||||
amt.classList.add("amount");
|
||||
amt.textContent = `${formatBtc(vout.value)} BTC`;
|
||||
row.append(addr, amt);
|
||||
outputs.append(row);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** @param {number} sats */
|
||||
function formatBtc(sats) {
|
||||
return (sats / 1e8).toFixed(8);
|
||||
}
|
||||
|
||||
/** @param {number} rate */
|
||||
@@ -313,17 +479,33 @@ function formatFeeRate(rate) {
|
||||
return rate.toFixed(2);
|
||||
}
|
||||
|
||||
/** @param {string} text @param {HTMLElement} el */
|
||||
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 */
|
||||
function createHeightElement(height) {
|
||||
const container = window.document.createElement("span");
|
||||
const container = document.createElement("span");
|
||||
const str = height.toString();
|
||||
const spanPrefix = window.document.createElement("span");
|
||||
spanPrefix.style.opacity = "0.5";
|
||||
spanPrefix.style.userSelect = "none";
|
||||
spanPrefix.textContent = "#" + "0".repeat(7 - str.length);
|
||||
const spanHeight = window.document.createElement("span");
|
||||
spanHeight.textContent = str;
|
||||
container.append(spanPrefix, spanHeight);
|
||||
const prefix = document.createElement("span");
|
||||
prefix.style.opacity = "0.5";
|
||||
prefix.style.userSelect = "none";
|
||||
prefix.textContent = "#" + "0".repeat(7 - str.length);
|
||||
const num = document.createElement("span");
|
||||
num.textContent = str;
|
||||
container.append(prefix, num);
|
||||
return container;
|
||||
}
|
||||
|
||||
@@ -336,66 +518,56 @@ function createBlockCube(block) {
|
||||
blocksByHash.set(block.id, block);
|
||||
cubeElement.addEventListener("click", () => selectCube(cubeElement));
|
||||
|
||||
const heightElement = window.document.createElement("p");
|
||||
heightElement.append(createHeightElement(block.height));
|
||||
rightFaceElement.append(heightElement);
|
||||
const heightEl = document.createElement("p");
|
||||
heightEl.append(createHeightElement(block.height));
|
||||
rightFaceElement.append(heightEl);
|
||||
|
||||
const feesElement = window.document.createElement("div");
|
||||
feesElement.classList.add("fees");
|
||||
leftFaceElement.append(feesElement);
|
||||
const feesEl = document.createElement("div");
|
||||
feesEl.classList.add("fees");
|
||||
leftFaceElement.append(feesEl);
|
||||
const extras = block.extras;
|
||||
const medianFee = extras ? extras.medianFee : 0;
|
||||
const feeRange = extras ? extras.feeRange : [0, 0, 0, 0, 0, 0, 0];
|
||||
const averageFeeElement = window.document.createElement("p");
|
||||
feesElement.append(averageFeeElement);
|
||||
averageFeeElement.innerHTML = `~${formatFeeRate(medianFee)}`;
|
||||
const feeRangeElement = window.document.createElement("p");
|
||||
feesElement.append(feeRangeElement);
|
||||
const minFeeElement = window.document.createElement("span");
|
||||
minFeeElement.innerHTML = formatFeeRate(feeRange[0]);
|
||||
feeRangeElement.append(minFeeElement);
|
||||
const dashElement = window.document.createElement("span");
|
||||
dashElement.style.opacity = "0.5";
|
||||
dashElement.innerHTML = `-`;
|
||||
feeRangeElement.append(dashElement);
|
||||
const maxFeeElement = window.document.createElement("span");
|
||||
maxFeeElement.innerHTML = formatFeeRate(feeRange[6]);
|
||||
feeRangeElement.append(maxFeeElement);
|
||||
const feeUnitElement = window.document.createElement("p");
|
||||
feesElement.append(feeUnitElement);
|
||||
feeUnitElement.style.opacity = "0.5";
|
||||
feeUnitElement.innerHTML = `sat/vB`;
|
||||
const avg = document.createElement("p");
|
||||
avg.innerHTML = `~${formatFeeRate(medianFee)}`;
|
||||
feesEl.append(avg);
|
||||
const range = document.createElement("p");
|
||||
const min = document.createElement("span");
|
||||
min.innerHTML = formatFeeRate(feeRange[0]);
|
||||
const dash = document.createElement("span");
|
||||
dash.style.opacity = "0.5";
|
||||
dash.innerHTML = `-`;
|
||||
const max = document.createElement("span");
|
||||
max.innerHTML = formatFeeRate(feeRange[6]);
|
||||
range.append(min, dash, max);
|
||||
feesEl.append(range);
|
||||
const unit = document.createElement("p");
|
||||
unit.style.opacity = "0.5";
|
||||
unit.innerHTML = `sat/vB`;
|
||||
feesEl.append(unit);
|
||||
|
||||
const spanMiner = window.document.createElement("span");
|
||||
spanMiner.innerHTML = extras ? extras.pool.name : "Unknown";
|
||||
topFaceElement.append(spanMiner);
|
||||
const miner = document.createElement("span");
|
||||
miner.innerHTML = extras ? extras.pool.name : "Unknown";
|
||||
topFaceElement.append(miner);
|
||||
|
||||
return cubeElement;
|
||||
}
|
||||
|
||||
function createCube() {
|
||||
const cubeElement = window.document.createElement("div");
|
||||
const cubeElement = document.createElement("div");
|
||||
cubeElement.classList.add("cube");
|
||||
|
||||
const rightFaceElement = window.document.createElement("div");
|
||||
rightFaceElement.classList.add("face");
|
||||
rightFaceElement.classList.add("right");
|
||||
const rightFaceElement = document.createElement("div");
|
||||
rightFaceElement.classList.add("face", "right");
|
||||
cubeElement.append(rightFaceElement);
|
||||
|
||||
const leftFaceElement = window.document.createElement("div");
|
||||
leftFaceElement.classList.add("face");
|
||||
leftFaceElement.classList.add("left");
|
||||
const leftFaceElement = document.createElement("div");
|
||||
leftFaceElement.classList.add("face", "left");
|
||||
cubeElement.append(leftFaceElement);
|
||||
|
||||
const topFaceElement = window.document.createElement("div");
|
||||
topFaceElement.classList.add("face");
|
||||
topFaceElement.classList.add("top");
|
||||
const topFaceElement = document.createElement("div");
|
||||
topFaceElement.classList.add("face", "top");
|
||||
cubeElement.append(topFaceElement);
|
||||
|
||||
return {
|
||||
cubeElement,
|
||||
leftFaceElement,
|
||||
rightFaceElement,
|
||||
topFaceElement,
|
||||
};
|
||||
return { cubeElement, leftFaceElement, rightFaceElement, topFaceElement };
|
||||
}
|
||||
|
||||
@@ -20,3 +20,4 @@ export const searchInput = /** @type {HTMLInputElement} */ (
|
||||
);
|
||||
export const searchResultsElement = getElementById("search-results");
|
||||
export const frameSelectorsElement = getElementById("frame-selectors");
|
||||
export const pinButtonElement = getElementById("pin-button");
|
||||
|
||||
@@ -62,11 +62,8 @@ export function numberToShortUSFormat(value, digits) {
|
||||
* @param {string} s
|
||||
*/
|
||||
export function stringToId(s) {
|
||||
return (
|
||||
s
|
||||
// .replace(/\W/g, " ")
|
||||
.trim()
|
||||
.replace(/ +/g, "-")
|
||||
.toLowerCase()
|
||||
);
|
||||
return s
|
||||
.trim()
|
||||
.replace(/[ /]+/g, "-")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
@@ -54,11 +54,11 @@ aside {
|
||||
/*overflow-y: auto;*/
|
||||
flex: 1;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
html[data-layout="mobile"] & {
|
||||
padding-bottom: calc(var(--main-padding) + 0.5rem);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
html[data-layout="desktop"] & {
|
||||
border-left: 1px;
|
||||
order: 2;
|
||||
}
|
||||
@@ -75,7 +75,7 @@ body {
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
html[data-layout="desktop"] & {
|
||||
flex-direction: row;
|
||||
|
||||
html[data-display="standalone"] & {
|
||||
@@ -267,9 +267,13 @@ summary {
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.md\:hidden {
|
||||
display: none !important;
|
||||
html[data-layout="desktop"] .md\:hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#pin-button {
|
||||
@media (max-width: 767px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,13 +34,13 @@ main {
|
||||
);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
html[data-layout="mobile"] & {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
html[data-layout="desktop"] & {
|
||||
min-width: 300px;
|
||||
width: var(--default-main-width);
|
||||
max-width: 65dvw;
|
||||
@@ -134,7 +134,7 @@ main {
|
||||
border-color: var(--off-color) !important;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
html[data-layout="desktop"] & {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,5 +217,136 @@
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.transactions {
|
||||
margin-top: 1rem;
|
||||
|
||||
.tx-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
button {
|
||||
color: var(--off-color);
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.25;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tx {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.tx-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
.txid {
|
||||
font-family: Lilex;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-xs);
|
||||
color: var(--off-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tx-time {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.tx-body {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-xs);
|
||||
}
|
||||
|
||||
.tx-inputs,
|
||||
.tx-outputs {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.tx-outputs {
|
||||
padding-left: 0.5rem;
|
||||
border-left: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tx-io {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
|
||||
.addr {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
color: var(--off-color);
|
||||
|
||||
.addr-head {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.addr-tail {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.coinbase {
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
&.op-return {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.amount {
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.tx-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
opacity: 0.5;
|
||||
|
||||
.total {
|
||||
opacity: 1;
|
||||
color: var(--orange);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user