From f022f62ccebe7f0dd544ca96a9cfd04ddb6daf9d Mon Sep 17 00:00:00 2001 From: nym21 Date: Tue, 7 Apr 2026 13:49:02 +0200 Subject: [PATCH] global: snap --- Cargo.lock | 2 +- crates/brk_client/src/lib.rs | 8 +- crates/brk_indexer/Cargo.toml | 1 - crates/brk_indexer/src/lib.rs | 8 - crates/brk_query/src/impl/addr.rs | 5 +- crates/brk_query/src/impl/block/txs.rs | 143 +++-- crates/brk_query/src/impl/tx.rs | 2 +- crates/brk_reader/Cargo.toml | 1 + crates/brk_reader/src/lib.rs | 45 +- modules/brk-client/index.js | 4 +- packages/brk_client/brk_client/__init__.py | 8 +- website/scripts/_types.js | 2 +- website/scripts/chart/oklch.js | 107 ---- website/scripts/options/investing.js | 2 +- website/scripts/options/market.js | 2 +- website/scripts/panes/chart.js | 2 +- website/scripts/panes/explorer.js | 516 +++++++++++++++--- website/scripts/{ => utils}/chart/capture.js | 6 +- website/scripts/{ => utils}/chart/index.js | 20 +- website/scripts/{ => utils}/chart/legend.js | 12 +- website/scripts/utils/colors.js | 126 ++++- website/scripts/utils/serde.js | 82 +-- .../{options/utils.js => utils/time.js} | 2 - website/styles/panes/explorer.css | 12 +- 24 files changed, 746 insertions(+), 372 deletions(-) delete mode 100644 website/scripts/chart/oklch.js rename website/scripts/{ => utils}/chart/capture.js (96%) rename website/scripts/{ => utils}/chart/index.js (98%) rename website/scripts/{ => utils}/chart/legend.js (96%) rename website/scripts/{options/utils.js => utils/time.js} (95%) diff --git a/Cargo.lock b/Cargo.lock index 9331ddb33..27f1f7819 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -560,7 +560,6 @@ dependencies = [ "fjall", "parking_lot", "rayon", - "rlimit", "rustc-hash", "schemars", "serde", @@ -648,6 +647,7 @@ dependencies = [ "derive_more", "parking_lot", "rayon", + "rlimit", "tracing", ] diff --git a/crates/brk_client/src/lib.rs b/crates/brk_client/src/lib.rs index d56746aa1..ab04b2275 100644 --- a/crates/brk_client/src/lib.rs +++ b/crates/brk_client/src/lib.rs @@ -8364,8 +8364,8 @@ impl BrkClient { /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-raw)* /// /// Endpoint: `GET /api/block/{hash}/raw` - pub fn get_block_raw(&self, hash: BlockHash) -> Result> { - self.base.get_json(&format!("/api/block/{hash}/raw")) + pub fn get_block_raw(&self, hash: BlockHash) -> Result { + self.base.get_text(&format!("/api/block/{hash}/raw")) } /// Block status @@ -8789,8 +8789,8 @@ impl BrkClient { /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-raw)* /// /// Endpoint: `GET /api/tx/{txid}/raw` - pub fn get_tx_raw(&self, txid: Txid) -> Result> { - self.base.get_json(&format!("/api/tx/{txid}/raw")) + pub fn get_tx_raw(&self, txid: Txid) -> Result { + self.base.get_text(&format!("/api/tx/{txid}/raw")) } /// Transaction status diff --git a/crates/brk_indexer/Cargo.toml b/crates/brk_indexer/Cargo.toml index 80ae0d108..479aa8269 100644 --- a/crates/brk_indexer/Cargo.toml +++ b/crates/brk_indexer/Cargo.toml @@ -24,7 +24,6 @@ schemars = { workspace = true } serde = { workspace = true } tracing = { workspace = true } rayon = { workspace = true } -rlimit = "0.11.0" rustc-hash = { workspace = true } vecdb = { workspace = true } diff --git a/crates/brk_indexer/src/lib.rs b/crates/brk_indexer/src/lib.rs index 71d7aaa3d..3a9e9315a 100644 --- a/crates/brk_indexer/src/lib.rs +++ b/crates/brk_indexer/src/lib.rs @@ -66,14 +66,6 @@ impl Indexer { } fn forced_import_inner(outputs_dir: &Path, can_retry: bool) -> Result { - info!("Increasing number of open files limit..."); - let no_file_limit = rlimit::getrlimit(rlimit::Resource::NOFILE)?; - rlimit::setrlimit( - rlimit::Resource::NOFILE, - no_file_limit.0.max(10_000), - no_file_limit.1, - )?; - info!("Importing indexer..."); let indexed_path = outputs_dir.join("indexed"); diff --git a/crates/brk_query/src/impl/addr.rs b/crates/brk_query/src/impl/addr.rs index 6705f42dd..6713c8b3c 100644 --- a/crates/brk_query/src/impl/addr.rs +++ b/crates/brk_query/src/impl/addr.rs @@ -97,10 +97,7 @@ impl Query { limit: usize, ) -> Result> { let txindices = self.addr_txindices(&addr, after_txid, limit)?; - txindices - .into_iter() - .map(|tx_index| self.transaction_by_index(tx_index)) - .collect() + self.transactions_by_indices(&txindices) } pub fn addr_txids( diff --git a/crates/brk_query/src/impl/block/txs.rs b/crates/brk_query/src/impl/block/txs.rs index 3d295ed3b..ad127cfa4 100644 --- a/crates/brk_query/src/impl/block/txs.rs +++ b/crates/brk_query/src/impl/block/txs.rs @@ -25,7 +25,10 @@ impl Query { return Ok(Vec::new()); } let count = BLOCK_TXS_PAGE_SIZE.min(tx_count - start); - self.transactions_by_range(first + start, count) + let indices: Vec = (first + start..first + start + count) + .map(TxIndex::from) + .collect(); + self.transactions_by_indices(&indices) } pub fn block_txid_at_index(&self, hash: &BlockHash, index: TxIndex) -> Result { @@ -33,48 +36,55 @@ impl Query { self.block_txid_at_index_by_height(height, index.into()) } - // === Bulk transaction read === + // === Helper methods === - /// Batch-read `count` consecutive transactions starting at raw index `start`. - /// Block info is cached per unique height — free for same-block batches. - pub fn transactions_by_range(&self, start: usize, count: usize) -> Result> { - if count == 0 { + pub(crate) fn block_txids_by_height(&self, height: Height) -> Result> { + let (first, tx_count) = self.block_tx_range(height)?; + Ok(self + .indexer() + .vecs + .transactions + .txid + .collect_range_at(first, first + tx_count)) + } + + fn block_txid_at_index_by_height(&self, height: Height, index: usize) -> Result { + let (first, tx_count) = self.block_tx_range(height)?; + if index >= tx_count { + return Err(Error::OutOfRange("Transaction index out of range".into())); + } + Ok(self + .indexer() + .vecs + .transactions + .txid + .reader() + .get(first + index)) + } + + /// Batch-read transactions at arbitrary indices. + /// Reads in ascending index order for I/O locality, returns in caller's order. + pub fn transactions_by_indices(&self, indices: &[TxIndex]) -> Result> { + if indices.is_empty() { return Ok(Vec::new()); } + let len = indices.len(); + + // Sort positions ascending for sequential I/O (O(n) when already sorted) + let mut order: Vec = (0..len).collect(); + order.sort_unstable_by_key(|&i| indices[i]); + let indexer = self.indexer(); let reader = self.reader(); - let end = start + count; - // 7 range reads instead of count * 7 point reads - let txids: Vec = indexer.vecs.transactions.txid.collect_range_at(start, end); - let heights: Vec = indexer - .vecs - .transactions - .height - .collect_range_at(start, end); - let lock_times = indexer - .vecs - .transactions - .raw_locktime - .collect_range_at(start, end); - let total_sizes = indexer - .vecs - .transactions - .total_size - .collect_range_at(start, end); - let first_txin_indices = indexer - .vecs - .transactions - .first_txin_index - .collect_range_at(start, end); - let positions = indexer - .vecs - .transactions - .position - .collect_range_at(start, end); + let mut txid_cursor = indexer.vecs.transactions.txid.cursor(); + let mut height_cursor = indexer.vecs.transactions.height.cursor(); + let mut locktime_cursor = indexer.vecs.transactions.raw_locktime.cursor(); + let mut total_size_cursor = indexer.vecs.transactions.total_size.cursor(); + let mut first_txin_cursor = indexer.vecs.transactions.first_txin_index.cursor(); + let mut position_cursor = indexer.vecs.transactions.position.cursor(); - // Readers for prevout lookups (created once) let txid_reader = indexer.vecs.transactions.txid.reader(); let first_txout_index_reader = indexer.vecs.transactions.first_txout_index.reader(); let value_reader = indexer.vecs.outputs.value.reader(); @@ -82,15 +92,22 @@ impl Query { let type_index_reader = indexer.vecs.outputs.type_index.reader(); let addr_readers = indexer.vecs.addrs.addr_readers(); - // Block info cache — for same-block batches, read once let mut cached_block: Option<(Height, BlockHash, Timestamp)> = None; - let mut txs = Vec::with_capacity(count); + // Read in sorted order, write directly to original position + let mut txs: Vec> = (0..len).map(|_| None).collect(); - for i in 0..count { - let height = heights[i]; + for &pos in &order { + let tx_index = indices[pos]; + let idx = tx_index.to_usize(); + + let txid = txid_cursor.get(idx).unwrap(); + let height = height_cursor.get(idx).unwrap(); + let lock_time = locktime_cursor.get(idx).unwrap(); + let total_size = total_size_cursor.get(idx).unwrap(); + let first_txin_index = first_txin_cursor.get(idx).unwrap(); + let position = position_cursor.get(idx).unwrap(); - // Reuse block info if same height as previous tx let (block_hash, block_time) = if let Some((h, ref bh, bt)) = cached_block && h == height { @@ -102,15 +119,13 @@ impl Query { (bh, bt) }; - // Decode raw transaction from blk file - let buffer = reader.read_raw_bytes(positions[i], *total_sizes[i] as usize)?; + let buffer = reader.read_raw_bytes(position, *total_size as usize)?; let tx = bitcoin::Transaction::consensus_decode(&mut Cursor::new(buffer)) .map_err(|_| Error::Parse("Failed to decode transaction".into()))?; - // Batch-read outpoints for this tx's inputs let outpoints = indexer.vecs.inputs.outpoint.collect_range_at( - usize::from(first_txin_indices[i]), - usize::from(first_txin_indices[i]) + tx.input.len(), + usize::from(first_txin_index), + usize::from(first_txin_index) + tx.input.len(), ); let input: Vec = tx @@ -178,11 +193,11 @@ impl Query { let output: Vec = tx.output.into_iter().map(TxOut::from).collect(); let mut transaction = Transaction { - index: Some(TxIndex::from(start + i)), - txid: txids[i].clone(), + index: Some(tx_index), + txid, version: tx.version.into(), - lock_time: lock_times[i], - total_size: *total_sizes[i] as usize, + lock_time, + total_size: *total_size as usize, weight, total_sigop_cost, fee: Sats::ZERO, @@ -197,36 +212,10 @@ impl Query { }; transaction.compute_fee(); - txs.push(transaction); + txs[pos] = Some(transaction); } - Ok(txs) - } - - // === Helper methods === - - pub(crate) fn block_txids_by_height(&self, height: Height) -> Result> { - let (first, tx_count) = self.block_tx_range(height)?; - Ok(self - .indexer() - .vecs - .transactions - .txid - .collect_range_at(first, first + tx_count)) - } - - fn block_txid_at_index_by_height(&self, height: Height, index: usize) -> Result { - let (first, tx_count) = self.block_tx_range(height)?; - if index >= tx_count { - return Err(Error::OutOfRange("Transaction index out of range".into())); - } - Ok(self - .indexer() - .vecs - .transactions - .txid - .reader() - .get(first + index)) + Ok(txs.into_iter().map(Option::unwrap).collect()) } /// Returns (first_tx_raw_index, tx_count) for a block at `height`. diff --git a/crates/brk_query/src/impl/tx.rs b/crates/brk_query/src/impl/tx.rs index 2ae14f336..8ba2e530c 100644 --- a/crates/brk_query/src/impl/tx.rs +++ b/crates/brk_query/src/impl/tx.rs @@ -278,7 +278,7 @@ impl Query { // === Helper methods === pub fn transaction_by_index(&self, tx_index: TxIndex) -> Result { - self.transactions_by_range(tx_index.to_usize(), 1)? + self.transactions_by_indices(&[tx_index])? .into_iter() .next() .ok_or(Error::NotFound("Transaction not found".into())) diff --git a/crates/brk_reader/Cargo.toml b/crates/brk_reader/Cargo.toml index b4c20b33a..b9a828b5d 100644 --- a/crates/brk_reader/Cargo.toml +++ b/crates/brk_reader/Cargo.toml @@ -20,3 +20,4 @@ derive_more = { workspace = true } tracing = { workspace = true } parking_lot = { workspace = true } rayon = { workspace = true } +rlimit = "0.11.0" diff --git a/crates/brk_reader/src/lib.rs b/crates/brk_reader/src/lib.rs index 698e40b45..032b398c7 100644 --- a/crates/brk_reader/src/lib.rs +++ b/crates/brk_reader/src/lib.rs @@ -5,6 +5,7 @@ use std::{ fs::{self, File}, io::{Read, Seek, SeekFrom}, ops::ControlFlow, + os::unix::fs::FileExt, path::{Path, PathBuf}, sync::Arc, thread, @@ -53,6 +54,7 @@ impl Reader { #[derive(Debug)] pub struct ReaderInner { blk_index_to_blk_path: Arc>, + blk_file_cache: RwLock>, xor_bytes: XORBytes, blocks_dir: PathBuf, client: Client, @@ -60,11 +62,19 @@ pub struct ReaderInner { impl ReaderInner { pub fn new(blocks_dir: PathBuf, client: Client) -> Self { + let no_file_limit = rlimit::getrlimit(rlimit::Resource::NOFILE).unwrap_or((0, 0)); + let _ = rlimit::setrlimit( + rlimit::Resource::NOFILE, + no_file_limit.0.max(15_000), + no_file_limit.1, + ); + Self { xor_bytes: XORBytes::from(blocks_dir.as_path()), blk_index_to_blk_path: Arc::new(RwLock::new(BlkIndexToBlkPath::scan( blocks_dir.as_path(), ))), + blk_file_cache: RwLock::new(BTreeMap::new()), blocks_dir, client, } @@ -86,26 +96,43 @@ impl ReaderInner { self.xor_bytes } - /// Read raw bytes from a blk file at the given position with XOR decoding + /// Read raw bytes from a blk file at the given position with XOR decoding. + /// File handles are cached per blk_index; reads use pread (no seek, thread-safe). pub fn read_raw_bytes(&self, position: BlkPosition, size: usize) -> Result> { + let blk_index = position.blk_index(); + + { + let cache = self.blk_file_cache.read(); + if let Some(file) = cache.get(&blk_index) { + let mut buffer = vec![0u8; size]; + file.read_at(&mut buffer, position.offset() as u64)?; + self.xor_decode(&mut buffer, position.offset()); + return Ok(buffer); + } + } + + // Cache miss: open file, insert, and read let blk_paths = self.blk_index_to_blk_path(); let blk_path = blk_paths - .get(&position.blk_index()) + .get(&blk_index) .ok_or(Error::NotFound("Blk file not found".into()))?; - - let mut file = File::open(blk_path)?; - file.seek(SeekFrom::Start(position.offset() as u64))?; + let file = File::open(blk_path)?; let mut buffer = vec![0u8; size]; - file.read_exact(&mut buffer)?; + file.read_at(&mut buffer, position.offset() as u64)?; + self.xor_decode(&mut buffer, position.offset()); - let mut xori = XORIndex::default(); - xori.add_assign(position.offset() as usize); - xori.bytes(&mut buffer, self.xor_bytes); + self.blk_file_cache.write().entry(blk_index).or_insert(file); Ok(buffer) } + fn xor_decode(&self, buffer: &mut [u8], offset: u32) { + let mut xori = XORIndex::default(); + xori.add_assign(offset as usize); + xori.bytes(buffer, self.xor_bytes); + } + /// Returns a receiver streaming `ReadBlock`s from `hash + 1` to the chain tip. /// If `hash` is `None`, starts from genesis. pub fn after(&self, hash: Option) -> Result> { diff --git a/modules/brk-client/index.js b/modules/brk-client/index.js index 2503c5f40..2a06d5d4a 100644 --- a/modules/brk-client/index.js +++ b/modules/brk-client/index.js @@ -9612,7 +9612,7 @@ class BrkClient extends BrkClientBase { * Endpoint: `GET /api/block/{hash}/raw` * * @param {BlockHash} hash - * @returns {Promise} + * @returns {Promise<*>} */ async getBlockRaw(hash) { return this.getJson(`/api/block/${hash}/raw`); @@ -10219,7 +10219,7 @@ class BrkClient extends BrkClientBase { * Endpoint: `GET /api/tx/{txid}/raw` * * @param {Txid} txid - * @returns {Promise} + * @returns {Promise<*>} */ async getTxRaw(txid) { return this.getJson(`/api/tx/${txid}/raw`); diff --git a/packages/brk_client/brk_client/__init__.py b/packages/brk_client/brk_client/__init__.py index a7a3e1ebe..fded1b176 100644 --- a/packages/brk_client/brk_client/__init__.py +++ b/packages/brk_client/brk_client/__init__.py @@ -7303,7 +7303,7 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/block/{hash}/header`""" return self.get_text(f'/api/block/{hash}/header') - def get_block_raw(self, hash: BlockHash) -> List[float]: + def get_block_raw(self, hash: BlockHash) -> str: """Raw block. Returns the raw block data in binary format. @@ -7311,7 +7311,7 @@ class BrkClient(BrkClientBase): *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-raw)* Endpoint: `GET /api/block/{hash}/raw`""" - return self.get_json(f'/api/block/{hash}/raw') + return self.get_text(f'/api/block/{hash}/raw') def get_block_status(self, hash: BlockHash) -> BlockStatus: """Block status. @@ -7685,7 +7685,7 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api/tx/{txid}/outspends`""" return self.get_json(f'/api/tx/{txid}/outspends') - def get_tx_raw(self, txid: Txid) -> List[float]: + def get_tx_raw(self, txid: Txid) -> str: """Transaction raw. Returns a transaction as binary data. @@ -7693,7 +7693,7 @@ class BrkClient(BrkClientBase): *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-raw)* Endpoint: `GET /api/tx/{txid}/raw`""" - return self.get_json(f'/api/tx/{txid}/raw') + return self.get_text(f'/api/tx/{txid}/raw') def get_tx_status(self, txid: Txid) -> TxStatus: """Transaction status. diff --git a/website/scripts/_types.js b/website/scripts/_types.js index 3cab868e8..55d2200cc 100644 --- a/website/scripts/_types.js +++ b/website/scripts/_types.js @@ -8,7 +8,7 @@ * * @import { PersistedValue } from './utils/persisted.js' * - * @import { SingleValueData, CandlestickData, Series, AnySeries, ISeries, HistogramData, LineData, BaselineData, LineSeriesPartialOptions, BaselineSeriesPartialOptions, HistogramSeriesPartialOptions, CandlestickSeriesPartialOptions, Chart, Legend } from "./chart/index.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" * diff --git a/website/scripts/chart/oklch.js b/website/scripts/chart/oklch.js deleted file mode 100644 index b4b42bb5f..000000000 --- a/website/scripts/chart/oklch.js +++ /dev/null @@ -1,107 +0,0 @@ -/** - * @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} */ -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})`; -} - diff --git a/website/scripts/options/investing.js b/website/scripts/options/investing.js index ba878512d..ab9606298 100644 --- a/website/scripts/options/investing.js +++ b/website/scripts/options/investing.js @@ -4,7 +4,7 @@ import { colors } from "../utils/colors.js"; import { brk } from "../client.js"; import { percentRatioBaseline, price } from "./series.js"; import { satsBtcUsd } from "./shared.js"; -import { periodIdToName } from "./utils.js"; +import { periodIdToName } from "../utils/time.js"; const SHORT_PERIODS = /** @type {const} */ ([ "_1w", diff --git a/website/scripts/options/market.js b/website/scripts/options/market.js index 6344021fb..5f70fb5ea 100644 --- a/website/scripts/options/market.js +++ b/website/scripts/options/market.js @@ -1,6 +1,7 @@ /** Market section */ import { colors } from "../utils/colors.js"; +import { periodIdToName } from "../utils/time.js"; import { brk } from "../client.js"; import { includes } from "../utils/array.js"; import { Unit } from "../utils/units.js"; @@ -17,7 +18,6 @@ import { ROLLING_WINDOWS_TO_1M, } from "./series.js"; import { simplePriceRatioTree, percentileBands, priceBands } from "./shared.js"; -import { periodIdToName } from "./utils.js"; /** * @typedef {Object} Period diff --git a/website/scripts/panes/chart.js b/website/scripts/panes/chart.js index f51c323d5..1ebcab711 100644 --- a/website/scripts/panes/chart.js +++ b/website/scripts/panes/chart.js @@ -2,7 +2,7 @@ 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 "../chart/index.js"; +import { createChart } from "../utils/chart/index.js"; import { colors } from "../utils/colors.js"; import { latestPrice, onPrice } from "../utils/price.js"; import { brk } from "../client.js"; diff --git a/website/scripts/panes/explorer.js b/website/scripts/panes/explorer.js index d08135a67..03c8ef3eb 100644 --- a/website/scripts/panes/explorer.js +++ b/website/scripts/panes/explorer.js @@ -1,12 +1,14 @@ import { explorerElement } from "../utils/elements.js"; import { brk } from "../client.js"; +import { createPersistedValue } from "../utils/persisted.js"; const LOOKAHEAD = 15; const TX_PAGE_SIZE = 25; /** @type {HTMLDivElement} */ let chain; /** @type {HTMLDivElement} */ let blocksEl; -/** @type {HTMLDivElement} */ let details; +/** @type {HTMLDivElement} */ let blockDetails; +/** @type {HTMLDivElement} */ let txDetails; /** @type {HTMLDivElement | null} */ let selectedCube = null; /** @type {number | undefined} */ let pollInterval; /** @type {IntersectionObserver} */ let olderObserver; @@ -31,9 +33,20 @@ let reachedTip = false; /** @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; +let txLoaded = false; +const txPageParam = createPersistedValue({ + defaultValue: 0, + urlKey: "page", + serialize: (v) => String(v + 1), + deserialize: (s) => Math.max(0, Number(s) - 1), +}); + +/** @returns {string[]} */ +function pathSegments() { + return window.location.pathname.split("/").filter((v) => v); +} export function init() { chain = document.createElement("div"); @@ -44,11 +57,17 @@ export function init() { blocksEl.classList.add("blocks"); chain.append(blocksEl); - details = document.createElement("div"); - details.id = "block-details"; - explorerElement.append(details); + blockDetails = document.createElement("div"); + blockDetails.id = "block-details"; + explorerElement.append(blockDetails); - initDetails(); + txDetails = document.createElement("div"); + txDetails.id = "tx-details"; + txDetails.hidden = true; + explorerElement.append(txDetails); + + initBlockDetails(); + initTxDetails(); olderObserver = new IntersectionObserver( (entries) => { @@ -80,6 +99,17 @@ export function init() { if (!document.hidden && !explorerElement.hidden) loadLatest(); }); + window.addEventListener("popstate", () => { + const [kind, value] = pathSegments(); + if (kind === "block" && value) navigateToBlock(value, false); + else if (kind === "tx" && value) showTxDetail(value); + else if (kind === "address" && value) showAddrDetail(value); + else { + blockDetails.hidden = false; + txDetails.hidden = true; + } + }); + loadLatest(); } @@ -105,6 +135,8 @@ function observeOldestEdge() { /** @param {BlockInfoV1[]} blocks */ function appendNewerBlocks(blocks) { if (!blocks.length) return false; + const anchor = blocksEl.lastElementChild; + const anchorRect = anchor?.getBoundingClientRect(); for (const b of [...blocks].reverse()) { if (b.height > newestHeight) { blocksEl.append(createBlockCube(b)); @@ -113,16 +145,57 @@ function appendNewerBlocks(blocks) { } } newestHeight = Math.max(newestHeight, blocks[0].height); + if (anchor && anchorRect) { + const r = anchor.getBoundingClientRect(); + chain.scrollTop += r.top - anchorRect.top; + chain.scrollLeft += r.left - anchorRect.left; + } return true; } +/** @param {string} hash @param {boolean} [pushUrl] */ +function navigateToBlock(hash, pushUrl = true) { + if (pushUrl) history.pushState(null, "", `/block/${hash}`); + const cube = /** @type {HTMLDivElement | null} */ ( + blocksEl.querySelector(`[data-hash="${hash}"]`) + ); + if (cube) { + selectCube(cube, { scroll: true }); + } else { + resetExplorer(); + } +} + +function resetExplorer() { + newestHeight = -1; + oldestHeight = Infinity; + loadingLatest = false; + loadingOlder = false; + loadingNewer = false; + reachedTip = false; + selectedCube = null; + blocksEl.innerHTML = ""; + olderObserver.disconnect(); + loadLatest(); +} + /** @returns {Promise} */ +/** @type {Transaction | null} */ +let pendingTx = null; + async function getStartHeight() { - const path = window.location.pathname.split("/").filter((v) => v); - if (path[0] !== "block" || !path[1]) return null; - const value = path[1]; - if (/^\d+$/.test(value)) return Number(value); - return (await brk.getBlockV1(value)).height; + if (pendingTx) return pendingTx.status?.blockHeight ?? null; + const [kind, value] = pathSegments(); + if (!value) return null; + if (kind === "block") { + if (/^\d+$/.test(value)) return Number(value); + return (await brk.getBlockV1(value)).height; + } + if (kind === "tx") { + pendingTx = await brk.getTx(value); + return pendingTx.status?.blockHeight ?? null; + } + return null; } async function loadLatest() { @@ -141,7 +214,21 @@ async function loadLatest() { newestHeight = blocks[0].height; oldestHeight = blocks[blocks.length - 1].height; if (startHeight === null) reachedTip = true; - selectCube(/** @type {HTMLDivElement} */ (blocksEl.lastElementChild)); + const [kind, value] = pathSegments(); + if (pendingTx) { + const hash = pendingTx.status?.blockHash; + const cube = /** @type {HTMLDivElement | null} */ ( + hash ? blocksEl.querySelector(`[data-hash="${hash}"]`) : null + ); + if (cube) selectCube(cube); + showTxFromData(pendingTx); + pendingTx = null; + } else if (kind === "address" && value) { + selectCube(/** @type {HTMLDivElement} */ (blocksEl.lastElementChild)); + showAddrDetail(value); + } else { + selectCube(/** @type {HTMLDivElement} */ (blocksEl.lastElementChild)); + } loadingLatest = false; observeOldestEdge(); if (!reachedTip) await loadNewer(); @@ -176,17 +263,8 @@ async function loadNewer() { if (loadingNewer || newestHeight === -1 || reachedTip) return; loadingNewer = true; try { - 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; - } - } else { + if (!appendNewerBlocks(blocks)) { reachedTip = true; } } catch (e) { @@ -195,23 +273,32 @@ async function loadNewer() { loadingNewer = false; } -/** @param {HTMLDivElement} cube */ -function selectCube(cube) { +/** @param {HTMLDivElement} cube @param {{ pushUrl?: boolean, scroll?: boolean }} [opts] */ +function selectCube(cube, { pushUrl = false, scroll = false } = {}) { + if (cube === selectedCube) return; if (selectedCube) selectedCube.classList.remove("selected"); selectedCube = cube; if (cube) { cube.classList.add("selected"); + if (scroll) cube.scrollIntoView({ behavior: "smooth" }); const hash = cube.dataset.hash; - if (hash) updateDetails(blocksByHash.get(hash)); + if (hash) { + updateDetails(blocksByHash.get(hash)); + if (pushUrl) history.pushState(null, "", `/block/${hash}`); + } } } -/** @typedef {[string, (b: BlockInfoV1) => string | null]} RowDef */ +/** @typedef {[string, (b: BlockInfoV1) => string | null, ((b: BlockInfoV1) => string | null)?]} RowDef */ /** @type {RowDef[]} */ const ROW_DEFS = [ - ["Hash", (b) => b.id], - ["Previous Hash", (b) => b.previousblockhash], + ["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()], @@ -227,25 +314,77 @@ const ROW_DEFS = [ ["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)], + [ + "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)], + [ + "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)], + [ + "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], @@ -254,7 +393,27 @@ const ROW_DEFS = [ ["Header", (b) => b.extras?.header ?? null], ]; -function initDetails() { +/** @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(); + if (m[1] === "block") { + navigateToBlock(m[2]); + } else if (m[1] === "tx") { + history.pushState(null, "", a.href); + showTxDetail(m[2]); + } else { + history.pushState(null, "", a.href); + showAddrDetail(m[2]); + } +} + +function initBlockDetails() { const title = document.createElement("h1"); title.textContent = "Block "; const code = document.createElement("code"); @@ -266,24 +425,26 @@ function initDetails() { container.append(heightPrefix, heightNum); code.append(container); title.append(code); - details.append(title); + blockDetails.append(title); - detailRows = ROW_DEFS.map(([label]) => { + blockDetails.addEventListener("click", handleLinkClick); + + detailRows = ROW_DEFS.map(([label, , linkFn]) => { 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"); + const valueEl = document.createElement(linkFn ? "a" : "span"); valueEl.classList.add("value"); row.append(labelEl, valueEl); - details.append(row); + blockDetails.append(row); return { row, valueEl }; }); txSection = document.createElement("div"); txSection.classList.add("transactions"); - details.append(txSection); + blockDetails.append(txSection); const txHeader = document.createElement("div"); txHeader.classList.add("tx-header"); @@ -297,7 +458,9 @@ function initDetails() { txSection.append(txList, createTxNav()); txObserver = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && txPage === -1) loadTxPage(0); + if (entries[0].isIntersecting && !txLoaded) { + loadTxPage(txPageParam.value, false); + } }); txObserver.observe(txSection); } @@ -317,8 +480,8 @@ function createTxNav() { 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)); + 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; @@ -340,17 +503,21 @@ function updateTxNavs(page) { /** @param {BlockInfoV1 | undefined} block */ function updateDetails(block) { if (!block) return; - details.scrollTop = 0; + blockDetails.hidden = false; + txDetails.hidden = true; + blockDetails.scrollTop = 0; const str = block.height.toString(); heightPrefix.textContent = "#" + "0".repeat(7 - str.length); heightNum.textContent = str; - ROW_DEFS.forEach(([, getter], i) => { + 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; @@ -359,18 +526,209 @@ function updateDetails(block) { txBlock = block; txTotalPages = Math.ceil(block.txCount / TX_PAGE_SIZE); - txPage = -1; - updateTxNavs(0); + if (txLoaded) txPageParam.setImmediate(0); + txLoaded = false; + updateTxNavs(txPageParam.value); txList.innerHTML = ""; txObserver.disconnect(); txObserver.observe(txSection); } -/** @param {number} page */ -async function loadTxPage(page) { +function initTxDetails() { + txDetails.addEventListener("click", handleLinkClick); +} + +/** @param {string} txid */ +async function showTxDetail(txid) { + try { + const tx = await brk.getTx(txid); + if (tx.status?.blockHash) { + const cube = /** @type {HTMLDivElement | null} */ ( + blocksEl.querySelector(`[data-hash="${tx.status.blockHash}"]`) + ); + if (cube) { + selectCube(cube, { scroll: true }); + showTxFromData(tx); + return; + } + pendingTx = tx; + resetExplorer(); + return; + } + showTxFromData(tx); + } catch (e) { + console.error("explorer tx:", e); + } +} + +/** @param {Transaction} tx */ +function showTxFromData(tx) { + blockDetails.hidden = true; + txDetails.hidden = false; + txDetails.scrollTop = 0; + txDetails.innerHTML = ""; + + const title = document.createElement("h1"); + title.textContent = "Transaction"; + txDetails.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); + + /** @type {[string, string, (string | null)?][]} */ + const rows = [ + ["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}`], + ]; + + for (const [label, value, href] of rows) { + 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(href ? "a" : "span"); + valueEl.classList.add("value"); + valueEl.textContent = value; + if (href) /** @type {HTMLAnchorElement} */ (valueEl).href = href; + row.append(labelEl, valueEl); + txDetails.append(row); + } + + const section = document.createElement("div"); + section.classList.add("transactions"); + const heading = document.createElement("h2"); + heading.textContent = "Inputs & Outputs"; + section.append(heading); + section.append(renderTx(tx)); + txDetails.append(section); +} + +/** @param {string} address */ +async function showAddrDetail(address) { + blockDetails.hidden = true; + txDetails.hidden = false; + txDetails.scrollTop = 0; + txDetails.innerHTML = ""; + + try { + const stats = await brk.getAddress(address); + const chain = stats.chainStats; + + const title = document.createElement("h1"); + title.textContent = "Address"; + txDetails.append(title); + + const addrEl = document.createElement("div"); + addrEl.classList.add("row"); + const addrLabel = document.createElement("span"); + addrLabel.classList.add("label"); + addrLabel.textContent = "Address"; + const addrValue = document.createElement("span"); + addrValue.classList.add("value"); + addrValue.textContent = address; + addrEl.append(addrLabel, addrValue); + txDetails.append(addrEl); + + const balance = chain.fundedTxoSum - chain.spentTxoSum; + + /** @type {[string, string][]} */ + const rows = [ + ["Balance", `${formatBtc(balance)} BTC`], + ["Total Received", `${formatBtc(chain.fundedTxoSum)} BTC`], + ["Total Sent", `${formatBtc(chain.spentTxoSum)} BTC`], + ["Tx Count", chain.txCount.toLocaleString()], + ["Funded Outputs", chain.fundedTxoCount.toLocaleString()], + ["Spent Outputs", chain.spentTxoCount.toLocaleString()], + ]; + + for (const [label, value] of rows) { + 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"); + valueEl.textContent = value; + row.append(labelEl, valueEl); + txDetails.append(row); + } + + const section = document.createElement("div"); + section.classList.add("transactions"); + const heading = document.createElement("h2"); + heading.textContent = "Transactions"; + section.append(heading); + txDetails.append(section); + + let loadingAddr = false; + let addrTxCount = 0; + /** @type {string | undefined} */ + let afterTxid; + + const addrTxObserver = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !loadingAddr && addrTxCount < chain.txCount) + loadMore(); + }); + + async function loadMore() { + loadingAddr = true; + try { + const txs = await brk.getAddressTxs(address, afterTxid); + for (const tx of txs) section.append(renderTx(tx)); + addrTxCount += txs.length; + if (txs.length) { + afterTxid = txs[txs.length - 1].txid; + addrTxObserver.disconnect(); + const last = section.lastElementChild; + if (last) addrTxObserver.observe(last); + } + } catch (e) { + console.error("explorer addr txs:", e); + addrTxCount = chain.txCount; + } + loadingAddr = false; + } + + await loadMore(); + } catch (e) { + console.error("explorer addr:", e); + txDetails.textContent = "Address not found"; + } +} + +/** @param {number} page @param {boolean} [pushUrl] */ +async function loadTxPage(page, pushUrl = true) { if (txLoading || !txBlock || page < 0 || page >= txTotalPages) return; txLoading = true; - txPage = page; + txLoaded = true; + if (pushUrl) txPageParam.setImmediate(page); updateTxNavs(page); try { const txs = await brk.getBlockTxsFromIndex(txBlock.id, page * TX_PAGE_SIZE); @@ -389,9 +747,10 @@ function renderTx(tx) { const head = document.createElement("div"); head.classList.add("tx-head"); - const txidEl = document.createElement("span"); + 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"); @@ -414,9 +773,25 @@ function renderTx(tx) { if (vin.isCoinbase) { addr.textContent = "Coinbase"; addr.classList.add("coinbase"); + const ascii = txBlock?.extras?.coinbaseSignatureAscii; + if (ascii) { + const sig = document.createElement("span"); + sig.classList.add("coinbase-sig"); + sig.textContent = ascii; + row.append(sig); + } } else { - const a = /** @type {string | undefined} */ (/** @type {any} */ (vin.prevout)?.scriptpubkey_address); - setAddrContent(a || "Unknown", addr); + 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"); @@ -434,13 +809,22 @@ function renderTx(tx) { 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); + 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(a || vout.scriptpubkey, addr); + setAddrContent(vout.scriptpubkey, addr); } const amt = document.createElement("span"); amt.classList.add("amount"); @@ -516,7 +900,9 @@ function createBlockCube(block) { cubeElement.dataset.hash = block.id; blocksByHash.set(block.id, block); - cubeElement.addEventListener("click", () => selectCube(cubeElement)); + cubeElement.addEventListener("click", () => + selectCube(cubeElement, { pushUrl: true }), + ); const heightEl = document.createElement("p"); heightEl.append(createHeightElement(block.height)); diff --git a/website/scripts/chart/capture.js b/website/scripts/utils/chart/capture.js similarity index 96% rename from website/scripts/chart/capture.js rename to website/scripts/utils/chart/capture.js index 5cfa1a749..a7e0e1fb1 100644 --- a/website/scripts/chart/capture.js +++ b/website/scripts/utils/chart/capture.js @@ -1,6 +1,6 @@ -import { ios, canShare } from "../utils/env.js"; -import { style } from "../utils/elements.js"; -import { colors } from "../utils/colors.js"; +import { ios, canShare } from "../env.js"; +import { style } from "../elements.js"; +import { colors } from "../colors.js"; export const canCapture = !ios || canShare; diff --git a/website/scripts/chart/index.js b/website/scripts/utils/chart/index.js similarity index 98% rename from website/scripts/chart/index.js rename to website/scripts/utils/chart/index.js index b81cdb7e3..e19033458 100644 --- a/website/scripts/chart/index.js +++ b/website/scripts/utils/chart/index.js @@ -4,18 +4,18 @@ import { HistogramSeries, LineSeries, BaselineSeries, -} from "../modules/lightweight-charts/5.1.0/dist/lightweight-charts.standalone.production.mjs"; +} from "../../modules/lightweight-charts/5.1.0/dist/lightweight-charts.standalone.production.mjs"; import { createLegend, createSeriesLegend } from "./legend.js"; import { capture } from "./capture.js"; -import { colors } from "../utils/colors.js"; -import { createRadios, createSelect, getElementById } from "../utils/dom.js"; -import { createPersistedValue } from "../utils/persisted.js"; -import { onChange as onThemeChange } from "../utils/theme.js"; -import { throttle, debounce } from "../utils/timing.js"; -import { serdeBool, INDEX_FROM_LABEL } from "../utils/serde.js"; -import { stringToId, numberToShortUSFormat } from "../utils/format.js"; -import { style } from "../utils/elements.js"; -import { Unit } from "../utils/units.js"; +import { colors } from "../colors.js"; +import { createRadios, createSelect, getElementById } from "../dom.js"; +import { createPersistedValue } from "../persisted.js"; +import { onChange as onThemeChange } from "../theme.js"; +import { throttle, debounce } from "../timing.js"; +import { serdeBool, INDEX_FROM_LABEL } from "../serde.js"; +import { stringToId, numberToShortUSFormat } from "../format.js"; +import { style } from "../elements.js"; +import { Unit } from "../units.js"; /** * @typedef {_ISeriesApi} ISeries diff --git a/website/scripts/chart/legend.js b/website/scripts/utils/chart/legend.js similarity index 96% rename from website/scripts/chart/legend.js rename to website/scripts/utils/chart/legend.js index 085a450d4..701cdfd93 100644 --- a/website/scripts/chart/legend.js +++ b/website/scripts/utils/chart/legend.js @@ -1,11 +1,15 @@ -import { createLabeledInput, createSpanName } from "../utils/dom.js"; -import { stringToId } from "../utils/format.js"; +import { createLabeledInput, 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 }); + el.addEventListener("touchstart", (e) => e.stopPropagation(), { + passive: true, + }); + el.addEventListener("touchmove", (e) => e.stopPropagation(), { + passive: true, + }); } /** diff --git a/website/scripts/utils/colors.js b/website/scripts/utils/colors.js index c0e1a31e1..5745d8928 100644 --- a/website/scripts/utils/colors.js +++ b/website/scripts/utils/colors.js @@ -1,4 +1,3 @@ -import { oklchToRgba } from "../chart/oklch.js"; import { dark } from "./theme.js"; /** @type {Map} */ @@ -275,3 +274,128 @@ export const colors = { 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} */ +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})`; +} diff --git a/website/scripts/utils/serde.js b/website/scripts/utils/serde.js index 77d12bc4e..cfdacac39 100644 --- a/website/scripts/utils/serde.js +++ b/website/scripts/utils/serde.js @@ -21,73 +21,27 @@ export const serdeBool = { 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", + 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])); - -/** - * @typedef {"" | - * "%all" | - * "%cmcap" | - * "%cp+l" | - * "%mcap" | - * "%pnl" | - * "%rcap" | - * "%self" | - * "/sec" | - * "address data" | - * "block" | - * "blocks" | - * "bool" | - * "btc" | - * "bytes" | - * "cents" | - * "coinblocks" | - * "coindays" | - * "constant" | - * "count" | - * "date" | - * "days" | - * "difficulty" | - * "epoch" | - * "gigabytes" | - * "h/s" | - * "hash" | - * "height" | - * "id" | - * "index" | - * "len" | - * "locktime" | - * "percentage" | - * "position" | - * "ratio" | - * "sat/vb" | - * "satblocks" | - * "satdays" | - * "sats" | - * "sats/(ph/s)/day" | - * "sats/(th/s)/day" | - * "sd" | - * "secs" | - * "timestamp" | - * "tx" | - * "type" | - * "usd" | - * "usd/(ph/s)/day" | - * "usd/(th/s)/day" | - * "vb" | - * "version" | - * "wu" | - * "years" | - * "" } Unit - */ +export const INDEX_FROM_LABEL = fromEntries( + entries(INDEX_LABEL).map(([k, v]) => [v, k]), +); diff --git a/website/scripts/options/utils.js b/website/scripts/utils/time.js similarity index 95% rename from website/scripts/options/utils.js rename to website/scripts/utils/time.js index 3e3047e91..4c5673145 100644 --- a/website/scripts/options/utils.js +++ b/website/scripts/utils/time.js @@ -1,5 +1,3 @@ -/** Market utilities */ - /** * Convert period ID to readable name * @param {string} id diff --git a/website/styles/panes/explorer.css b/website/styles/panes/explorer.css index 9b2639602..da7b93c93 100644 --- a/website/styles/panes/explorer.css +++ b/website/styles/panes/explorer.css @@ -179,7 +179,8 @@ } } - #block-details { + #block-details, + #tx-details { flex: 1; font-size: var(--font-size-sm); line-height: var(--line-height-sm); @@ -321,6 +322,15 @@ color: var(--orange); } + .coinbase-sig { + font-family: Lilex; + font-size: var(--font-size-xs); + opacity: 0.5; + display: block; + overflow: hidden; + text-overflow: ellipsis; + } + &.op-return { opacity: 0.5; }