mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-04-24 14:49:58 -07:00
global: snap
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -560,7 +560,6 @@ dependencies = [
|
|||||||
"fjall",
|
"fjall",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rayon",
|
"rayon",
|
||||||
"rlimit",
|
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -648,6 +647,7 @@ dependencies = [
|
|||||||
"derive_more",
|
"derive_more",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rayon",
|
"rayon",
|
||||||
|
"rlimit",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -8364,8 +8364,8 @@ impl BrkClient {
|
|||||||
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-raw)*
|
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-raw)*
|
||||||
///
|
///
|
||||||
/// Endpoint: `GET /api/block/{hash}/raw`
|
/// Endpoint: `GET /api/block/{hash}/raw`
|
||||||
pub fn get_block_raw(&self, hash: BlockHash) -> Result<Vec<f64>> {
|
pub fn get_block_raw(&self, hash: BlockHash) -> Result<String> {
|
||||||
self.base.get_json(&format!("/api/block/{hash}/raw"))
|
self.base.get_text(&format!("/api/block/{hash}/raw"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Block status
|
/// Block status
|
||||||
@@ -8789,8 +8789,8 @@ impl BrkClient {
|
|||||||
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-raw)*
|
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-raw)*
|
||||||
///
|
///
|
||||||
/// Endpoint: `GET /api/tx/{txid}/raw`
|
/// Endpoint: `GET /api/tx/{txid}/raw`
|
||||||
pub fn get_tx_raw(&self, txid: Txid) -> Result<Vec<f64>> {
|
pub fn get_tx_raw(&self, txid: Txid) -> Result<String> {
|
||||||
self.base.get_json(&format!("/api/tx/{txid}/raw"))
|
self.base.get_text(&format!("/api/tx/{txid}/raw"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transaction status
|
/// Transaction status
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ schemars = { workspace = true }
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
rayon = { workspace = true }
|
rayon = { workspace = true }
|
||||||
rlimit = "0.11.0"
|
|
||||||
rustc-hash = { workspace = true }
|
rustc-hash = { workspace = true }
|
||||||
vecdb = { workspace = true }
|
vecdb = { workspace = true }
|
||||||
|
|
||||||
|
|||||||
@@ -66,14 +66,6 @@ impl Indexer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn forced_import_inner(outputs_dir: &Path, can_retry: bool) -> Result<Self> {
|
fn forced_import_inner(outputs_dir: &Path, can_retry: bool) -> Result<Self> {
|
||||||
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...");
|
info!("Importing indexer...");
|
||||||
|
|
||||||
let indexed_path = outputs_dir.join("indexed");
|
let indexed_path = outputs_dir.join("indexed");
|
||||||
|
|||||||
@@ -97,10 +97,7 @@ impl Query {
|
|||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<Transaction>> {
|
) -> Result<Vec<Transaction>> {
|
||||||
let txindices = self.addr_txindices(&addr, after_txid, limit)?;
|
let txindices = self.addr_txindices(&addr, after_txid, limit)?;
|
||||||
txindices
|
self.transactions_by_indices(&txindices)
|
||||||
.into_iter()
|
|
||||||
.map(|tx_index| self.transaction_by_index(tx_index))
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn addr_txids(
|
pub fn addr_txids(
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ impl Query {
|
|||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let count = BLOCK_TXS_PAGE_SIZE.min(tx_count - start);
|
let count = BLOCK_TXS_PAGE_SIZE.min(tx_count - start);
|
||||||
self.transactions_by_range(first + start, count)
|
let indices: Vec<TxIndex> = (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<Txid> {
|
pub fn block_txid_at_index(&self, hash: &BlockHash, index: TxIndex) -> Result<Txid> {
|
||||||
@@ -33,48 +36,55 @@ impl Query {
|
|||||||
self.block_txid_at_index_by_height(height, index.into())
|
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`.
|
pub(crate) fn block_txids_by_height(&self, height: Height) -> Result<Vec<Txid>> {
|
||||||
/// Block info is cached per unique height — free for same-block batches.
|
let (first, tx_count) = self.block_tx_range(height)?;
|
||||||
pub fn transactions_by_range(&self, start: usize, count: usize) -> Result<Vec<Transaction>> {
|
Ok(self
|
||||||
if count == 0 {
|
.indexer()
|
||||||
|
.vecs
|
||||||
|
.transactions
|
||||||
|
.txid
|
||||||
|
.collect_range_at(first, first + tx_count))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block_txid_at_index_by_height(&self, height: Height, index: usize) -> Result<Txid> {
|
||||||
|
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<Vec<Transaction>> {
|
||||||
|
if indices.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let len = indices.len();
|
||||||
|
|
||||||
|
// Sort positions ascending for sequential I/O (O(n) when already sorted)
|
||||||
|
let mut order: Vec<usize> = (0..len).collect();
|
||||||
|
order.sort_unstable_by_key(|&i| indices[i]);
|
||||||
|
|
||||||
let indexer = self.indexer();
|
let indexer = self.indexer();
|
||||||
let reader = self.reader();
|
let reader = self.reader();
|
||||||
let end = start + count;
|
|
||||||
|
|
||||||
// 7 range reads instead of count * 7 point reads
|
let mut txid_cursor = indexer.vecs.transactions.txid.cursor();
|
||||||
let txids: Vec<Txid> = indexer.vecs.transactions.txid.collect_range_at(start, end);
|
let mut height_cursor = indexer.vecs.transactions.height.cursor();
|
||||||
let heights: Vec<Height> = indexer
|
let mut locktime_cursor = indexer.vecs.transactions.raw_locktime.cursor();
|
||||||
.vecs
|
let mut total_size_cursor = indexer.vecs.transactions.total_size.cursor();
|
||||||
.transactions
|
let mut first_txin_cursor = indexer.vecs.transactions.first_txin_index.cursor();
|
||||||
.height
|
let mut position_cursor = indexer.vecs.transactions.position.cursor();
|
||||||
.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);
|
|
||||||
|
|
||||||
// Readers for prevout lookups (created once)
|
|
||||||
let txid_reader = indexer.vecs.transactions.txid.reader();
|
let txid_reader = indexer.vecs.transactions.txid.reader();
|
||||||
let first_txout_index_reader = indexer.vecs.transactions.first_txout_index.reader();
|
let first_txout_index_reader = indexer.vecs.transactions.first_txout_index.reader();
|
||||||
let value_reader = indexer.vecs.outputs.value.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 type_index_reader = indexer.vecs.outputs.type_index.reader();
|
||||||
let addr_readers = indexer.vecs.addrs.addr_readers();
|
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 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<Option<Transaction>> = (0..len).map(|_| None).collect();
|
||||||
|
|
||||||
for i in 0..count {
|
for &pos in &order {
|
||||||
let height = heights[i];
|
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
|
let (block_hash, block_time) = if let Some((h, ref bh, bt)) = cached_block
|
||||||
&& h == height
|
&& h == height
|
||||||
{
|
{
|
||||||
@@ -102,15 +119,13 @@ impl Query {
|
|||||||
(bh, bt)
|
(bh, bt)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Decode raw transaction from blk file
|
let buffer = reader.read_raw_bytes(position, *total_size as usize)?;
|
||||||
let buffer = reader.read_raw_bytes(positions[i], *total_sizes[i] as usize)?;
|
|
||||||
let tx = bitcoin::Transaction::consensus_decode(&mut Cursor::new(buffer))
|
let tx = bitcoin::Transaction::consensus_decode(&mut Cursor::new(buffer))
|
||||||
.map_err(|_| Error::Parse("Failed to decode transaction".into()))?;
|
.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(
|
let outpoints = indexer.vecs.inputs.outpoint.collect_range_at(
|
||||||
usize::from(first_txin_indices[i]),
|
usize::from(first_txin_index),
|
||||||
usize::from(first_txin_indices[i]) + tx.input.len(),
|
usize::from(first_txin_index) + tx.input.len(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let input: Vec<TxIn> = tx
|
let input: Vec<TxIn> = tx
|
||||||
@@ -178,11 +193,11 @@ impl Query {
|
|||||||
let output: Vec<TxOut> = tx.output.into_iter().map(TxOut::from).collect();
|
let output: Vec<TxOut> = tx.output.into_iter().map(TxOut::from).collect();
|
||||||
|
|
||||||
let mut transaction = Transaction {
|
let mut transaction = Transaction {
|
||||||
index: Some(TxIndex::from(start + i)),
|
index: Some(tx_index),
|
||||||
txid: txids[i].clone(),
|
txid,
|
||||||
version: tx.version.into(),
|
version: tx.version.into(),
|
||||||
lock_time: lock_times[i],
|
lock_time,
|
||||||
total_size: *total_sizes[i] as usize,
|
total_size: *total_size as usize,
|
||||||
weight,
|
weight,
|
||||||
total_sigop_cost,
|
total_sigop_cost,
|
||||||
fee: Sats::ZERO,
|
fee: Sats::ZERO,
|
||||||
@@ -197,36 +212,10 @@ impl Query {
|
|||||||
};
|
};
|
||||||
|
|
||||||
transaction.compute_fee();
|
transaction.compute_fee();
|
||||||
txs.push(transaction);
|
txs[pos] = Some(transaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(txs)
|
Ok(txs.into_iter().map(Option::unwrap).collect())
|
||||||
}
|
|
||||||
|
|
||||||
// === Helper methods ===
|
|
||||||
|
|
||||||
pub(crate) fn block_txids_by_height(&self, height: Height) -> Result<Vec<Txid>> {
|
|
||||||
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<Txid> {
|
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns (first_tx_raw_index, tx_count) for a block at `height`.
|
/// Returns (first_tx_raw_index, tx_count) for a block at `height`.
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ impl Query {
|
|||||||
// === Helper methods ===
|
// === Helper methods ===
|
||||||
|
|
||||||
pub fn transaction_by_index(&self, tx_index: TxIndex) -> Result<Transaction> {
|
pub fn transaction_by_index(&self, tx_index: TxIndex) -> Result<Transaction> {
|
||||||
self.transactions_by_range(tx_index.to_usize(), 1)?
|
self.transactions_by_indices(&[tx_index])?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.next()
|
.next()
|
||||||
.ok_or(Error::NotFound("Transaction not found".into()))
|
.ok_or(Error::NotFound("Transaction not found".into()))
|
||||||
|
|||||||
@@ -20,3 +20,4 @@ derive_more = { workspace = true }
|
|||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
parking_lot = { workspace = true }
|
parking_lot = { workspace = true }
|
||||||
rayon = { workspace = true }
|
rayon = { workspace = true }
|
||||||
|
rlimit = "0.11.0"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use std::{
|
|||||||
fs::{self, File},
|
fs::{self, File},
|
||||||
io::{Read, Seek, SeekFrom},
|
io::{Read, Seek, SeekFrom},
|
||||||
ops::ControlFlow,
|
ops::ControlFlow,
|
||||||
|
os::unix::fs::FileExt,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
thread,
|
thread,
|
||||||
@@ -53,6 +54,7 @@ impl Reader {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ReaderInner {
|
pub struct ReaderInner {
|
||||||
blk_index_to_blk_path: Arc<RwLock<BlkIndexToBlkPath>>,
|
blk_index_to_blk_path: Arc<RwLock<BlkIndexToBlkPath>>,
|
||||||
|
blk_file_cache: RwLock<BTreeMap<u16, File>>,
|
||||||
xor_bytes: XORBytes,
|
xor_bytes: XORBytes,
|
||||||
blocks_dir: PathBuf,
|
blocks_dir: PathBuf,
|
||||||
client: Client,
|
client: Client,
|
||||||
@@ -60,11 +62,19 @@ pub struct ReaderInner {
|
|||||||
|
|
||||||
impl ReaderInner {
|
impl ReaderInner {
|
||||||
pub fn new(blocks_dir: PathBuf, client: Client) -> Self {
|
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 {
|
Self {
|
||||||
xor_bytes: XORBytes::from(blocks_dir.as_path()),
|
xor_bytes: XORBytes::from(blocks_dir.as_path()),
|
||||||
blk_index_to_blk_path: Arc::new(RwLock::new(BlkIndexToBlkPath::scan(
|
blk_index_to_blk_path: Arc::new(RwLock::new(BlkIndexToBlkPath::scan(
|
||||||
blocks_dir.as_path(),
|
blocks_dir.as_path(),
|
||||||
))),
|
))),
|
||||||
|
blk_file_cache: RwLock::new(BTreeMap::new()),
|
||||||
blocks_dir,
|
blocks_dir,
|
||||||
client,
|
client,
|
||||||
}
|
}
|
||||||
@@ -86,26 +96,43 @@ impl ReaderInner {
|
|||||||
self.xor_bytes
|
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<Vec<u8>> {
|
pub fn read_raw_bytes(&self, position: BlkPosition, size: usize) -> Result<Vec<u8>> {
|
||||||
|
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_paths = self.blk_index_to_blk_path();
|
||||||
let blk_path = blk_paths
|
let blk_path = blk_paths
|
||||||
.get(&position.blk_index())
|
.get(&blk_index)
|
||||||
.ok_or(Error::NotFound("Blk file not found".into()))?;
|
.ok_or(Error::NotFound("Blk file not found".into()))?;
|
||||||
|
let file = File::open(blk_path)?;
|
||||||
let mut file = File::open(blk_path)?;
|
|
||||||
file.seek(SeekFrom::Start(position.offset() as u64))?;
|
|
||||||
|
|
||||||
let mut buffer = vec![0u8; size];
|
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();
|
self.blk_file_cache.write().entry(blk_index).or_insert(file);
|
||||||
xori.add_assign(position.offset() as usize);
|
|
||||||
xori.bytes(&mut buffer, self.xor_bytes);
|
|
||||||
|
|
||||||
Ok(buffer)
|
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.
|
/// Returns a receiver streaming `ReadBlock`s from `hash + 1` to the chain tip.
|
||||||
/// If `hash` is `None`, starts from genesis.
|
/// If `hash` is `None`, starts from genesis.
|
||||||
pub fn after(&self, hash: Option<BlockHash>) -> Result<Receiver<ReadBlock>> {
|
pub fn after(&self, hash: Option<BlockHash>) -> Result<Receiver<ReadBlock>> {
|
||||||
|
|||||||
@@ -9612,7 +9612,7 @@ class BrkClient extends BrkClientBase {
|
|||||||
* Endpoint: `GET /api/block/{hash}/raw`
|
* Endpoint: `GET /api/block/{hash}/raw`
|
||||||
*
|
*
|
||||||
* @param {BlockHash} hash
|
* @param {BlockHash} hash
|
||||||
* @returns {Promise<number[]>}
|
* @returns {Promise<*>}
|
||||||
*/
|
*/
|
||||||
async getBlockRaw(hash) {
|
async getBlockRaw(hash) {
|
||||||
return this.getJson(`/api/block/${hash}/raw`);
|
return this.getJson(`/api/block/${hash}/raw`);
|
||||||
@@ -10219,7 +10219,7 @@ class BrkClient extends BrkClientBase {
|
|||||||
* Endpoint: `GET /api/tx/{txid}/raw`
|
* Endpoint: `GET /api/tx/{txid}/raw`
|
||||||
*
|
*
|
||||||
* @param {Txid} txid
|
* @param {Txid} txid
|
||||||
* @returns {Promise<number[]>}
|
* @returns {Promise<*>}
|
||||||
*/
|
*/
|
||||||
async getTxRaw(txid) {
|
async getTxRaw(txid) {
|
||||||
return this.getJson(`/api/tx/${txid}/raw`);
|
return this.getJson(`/api/tx/${txid}/raw`);
|
||||||
|
|||||||
@@ -7303,7 +7303,7 @@ class BrkClient(BrkClientBase):
|
|||||||
Endpoint: `GET /api/block/{hash}/header`"""
|
Endpoint: `GET /api/block/{hash}/header`"""
|
||||||
return self.get_text(f'/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.
|
"""Raw block.
|
||||||
|
|
||||||
Returns the raw block data in binary format.
|
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)*
|
*[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-raw)*
|
||||||
|
|
||||||
Endpoint: `GET /api/block/{hash}/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:
|
def get_block_status(self, hash: BlockHash) -> BlockStatus:
|
||||||
"""Block status.
|
"""Block status.
|
||||||
@@ -7685,7 +7685,7 @@ class BrkClient(BrkClientBase):
|
|||||||
Endpoint: `GET /api/tx/{txid}/outspends`"""
|
Endpoint: `GET /api/tx/{txid}/outspends`"""
|
||||||
return self.get_json(f'/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.
|
"""Transaction raw.
|
||||||
|
|
||||||
Returns a transaction as binary data.
|
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)*
|
*[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-raw)*
|
||||||
|
|
||||||
Endpoint: `GET /api/tx/{txid}/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:
|
def get_tx_status(self, txid: Txid) -> TxStatus:
|
||||||
"""Transaction status.
|
"""Transaction status.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
*
|
*
|
||||||
* @import { PersistedValue } from './utils/persisted.js'
|
* @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"
|
* @import { Color } from "./utils/colors.js"
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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<string, [number, number, number, number]>} */
|
|
||||||
const conversionCache = new Map();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse oklch string and return rgba tuple
|
|
||||||
* @param {string} oklch
|
|
||||||
* @returns {[number, number, number, number] | null}
|
|
||||||
*/
|
|
||||||
function parseOklch(oklch) {
|
|
||||||
if (!oklch.startsWith("oklch(")) return null;
|
|
||||||
|
|
||||||
const cached = conversionCache.get(oklch);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
let str = oklch.slice(6, -1); // remove "oklch(" and ")"
|
|
||||||
let alpha = 1;
|
|
||||||
|
|
||||||
const slashIdx = str.indexOf(" / ");
|
|
||||||
if (slashIdx !== -1) {
|
|
||||||
const alphaPart = str.slice(slashIdx + 3);
|
|
||||||
alpha = alphaPart.includes("%")
|
|
||||||
? Number(alphaPart.replace("%", "")) / 100
|
|
||||||
: Number(alphaPart);
|
|
||||||
str = str.slice(0, slashIdx);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = str.split(" ");
|
|
||||||
const l = parts[0].includes("%") ? Number(parts[0].replace("%", "")) / 100 : Number(parts[0]);
|
|
||||||
const c = Number(parts[1]);
|
|
||||||
const h = Number(parts[2]);
|
|
||||||
|
|
||||||
const rgb = srgbLinear2rgb(xyz2rgbLinear(oklab2xyz(oklch2oklab([l, c, h]))))
|
|
||||||
.map((v) => Math.max(Math.min(Math.round(v * 255), 255), 0));
|
|
||||||
|
|
||||||
const result = /** @type {[number, number, number, number]} */ ([...rgb, alpha]);
|
|
||||||
conversionCache.set(oklch, result);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert oklch string to rgba string
|
|
||||||
* @param {string} oklch
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function oklchToRgba(oklch) {
|
|
||||||
const result = parseOklch(oklch);
|
|
||||||
if (!result) return oklch;
|
|
||||||
const [r, g, b, a] = result;
|
|
||||||
return a === 1 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${a})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@ import { colors } from "../utils/colors.js";
|
|||||||
import { brk } from "../client.js";
|
import { brk } from "../client.js";
|
||||||
import { percentRatioBaseline, price } from "./series.js";
|
import { percentRatioBaseline, price } from "./series.js";
|
||||||
import { satsBtcUsd } from "./shared.js";
|
import { satsBtcUsd } from "./shared.js";
|
||||||
import { periodIdToName } from "./utils.js";
|
import { periodIdToName } from "../utils/time.js";
|
||||||
|
|
||||||
const SHORT_PERIODS = /** @type {const} */ ([
|
const SHORT_PERIODS = /** @type {const} */ ([
|
||||||
"_1w",
|
"_1w",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/** Market section */
|
/** Market section */
|
||||||
|
|
||||||
import { colors } from "../utils/colors.js";
|
import { colors } from "../utils/colors.js";
|
||||||
|
import { periodIdToName } from "../utils/time.js";
|
||||||
import { brk } from "../client.js";
|
import { brk } from "../client.js";
|
||||||
import { includes } from "../utils/array.js";
|
import { includes } from "../utils/array.js";
|
||||||
import { Unit } from "../utils/units.js";
|
import { Unit } from "../utils/units.js";
|
||||||
@@ -17,7 +18,6 @@ import {
|
|||||||
ROLLING_WINDOWS_TO_1M,
|
ROLLING_WINDOWS_TO_1M,
|
||||||
} from "./series.js";
|
} from "./series.js";
|
||||||
import { simplePriceRatioTree, percentileBands, priceBands } from "./shared.js";
|
import { simplePriceRatioTree, percentileBands, priceBands } from "./shared.js";
|
||||||
import { periodIdToName } from "./utils.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} Period
|
* @typedef {Object} Period
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createHeader } from "../utils/dom.js";
|
|||||||
import { chartElement } from "../utils/elements.js";
|
import { chartElement } from "../utils/elements.js";
|
||||||
import { INDEX_FROM_LABEL } from "../utils/serde.js";
|
import { INDEX_FROM_LABEL } from "../utils/serde.js";
|
||||||
import { Unit } from "../utils/units.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 { colors } from "../utils/colors.js";
|
||||||
import { latestPrice, onPrice } from "../utils/price.js";
|
import { latestPrice, onPrice } from "../utils/price.js";
|
||||||
import { brk } from "../client.js";
|
import { brk } from "../client.js";
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { explorerElement } from "../utils/elements.js";
|
import { explorerElement } from "../utils/elements.js";
|
||||||
import { brk } from "../client.js";
|
import { brk } from "../client.js";
|
||||||
|
import { createPersistedValue } from "../utils/persisted.js";
|
||||||
|
|
||||||
const LOOKAHEAD = 15;
|
const LOOKAHEAD = 15;
|
||||||
const TX_PAGE_SIZE = 25;
|
const TX_PAGE_SIZE = 25;
|
||||||
|
|
||||||
/** @type {HTMLDivElement} */ let chain;
|
/** @type {HTMLDivElement} */ let chain;
|
||||||
/** @type {HTMLDivElement} */ let blocksEl;
|
/** @type {HTMLDivElement} */ let blocksEl;
|
||||||
/** @type {HTMLDivElement} */ let details;
|
/** @type {HTMLDivElement} */ let blockDetails;
|
||||||
|
/** @type {HTMLDivElement} */ let txDetails;
|
||||||
/** @type {HTMLDivElement | null} */ let selectedCube = null;
|
/** @type {HTMLDivElement | null} */ let selectedCube = null;
|
||||||
/** @type {number | undefined} */ let pollInterval;
|
/** @type {number | undefined} */ let pollInterval;
|
||||||
/** @type {IntersectionObserver} */ let olderObserver;
|
/** @type {IntersectionObserver} */ let olderObserver;
|
||||||
@@ -31,9 +33,20 @@ let reachedTip = false;
|
|||||||
/** @typedef {{ first: HTMLButtonElement, prev: HTMLButtonElement, label: HTMLSpanElement, next: HTMLButtonElement, last: HTMLButtonElement }} TxNav */
|
/** @typedef {{ first: HTMLButtonElement, prev: HTMLButtonElement, label: HTMLSpanElement, next: HTMLButtonElement, last: HTMLButtonElement }} TxNav */
|
||||||
/** @type {TxNav[]} */ let txNavs = [];
|
/** @type {TxNav[]} */ let txNavs = [];
|
||||||
/** @type {BlockInfoV1 | null} */ let txBlock = null;
|
/** @type {BlockInfoV1 | null} */ let txBlock = null;
|
||||||
let txPage = -1;
|
|
||||||
let txTotalPages = 0;
|
let txTotalPages = 0;
|
||||||
let txLoading = false;
|
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() {
|
export function init() {
|
||||||
chain = document.createElement("div");
|
chain = document.createElement("div");
|
||||||
@@ -44,11 +57,17 @@ export function init() {
|
|||||||
blocksEl.classList.add("blocks");
|
blocksEl.classList.add("blocks");
|
||||||
chain.append(blocksEl);
|
chain.append(blocksEl);
|
||||||
|
|
||||||
details = document.createElement("div");
|
blockDetails = document.createElement("div");
|
||||||
details.id = "block-details";
|
blockDetails.id = "block-details";
|
||||||
explorerElement.append(details);
|
explorerElement.append(blockDetails);
|
||||||
|
|
||||||
initDetails();
|
txDetails = document.createElement("div");
|
||||||
|
txDetails.id = "tx-details";
|
||||||
|
txDetails.hidden = true;
|
||||||
|
explorerElement.append(txDetails);
|
||||||
|
|
||||||
|
initBlockDetails();
|
||||||
|
initTxDetails();
|
||||||
|
|
||||||
olderObserver = new IntersectionObserver(
|
olderObserver = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
@@ -80,6 +99,17 @@ export function init() {
|
|||||||
if (!document.hidden && !explorerElement.hidden) loadLatest();
|
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();
|
loadLatest();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +135,8 @@ function observeOldestEdge() {
|
|||||||
/** @param {BlockInfoV1[]} blocks */
|
/** @param {BlockInfoV1[]} blocks */
|
||||||
function appendNewerBlocks(blocks) {
|
function appendNewerBlocks(blocks) {
|
||||||
if (!blocks.length) return false;
|
if (!blocks.length) return false;
|
||||||
|
const anchor = blocksEl.lastElementChild;
|
||||||
|
const anchorRect = anchor?.getBoundingClientRect();
|
||||||
for (const b of [...blocks].reverse()) {
|
for (const b of [...blocks].reverse()) {
|
||||||
if (b.height > newestHeight) {
|
if (b.height > newestHeight) {
|
||||||
blocksEl.append(createBlockCube(b));
|
blocksEl.append(createBlockCube(b));
|
||||||
@@ -113,16 +145,57 @@ function appendNewerBlocks(blocks) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
newestHeight = Math.max(newestHeight, blocks[0].height);
|
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;
|
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<number | null>} */
|
/** @returns {Promise<number | null>} */
|
||||||
|
/** @type {Transaction | null} */
|
||||||
|
let pendingTx = null;
|
||||||
|
|
||||||
async function getStartHeight() {
|
async function getStartHeight() {
|
||||||
const path = window.location.pathname.split("/").filter((v) => v);
|
if (pendingTx) return pendingTx.status?.blockHeight ?? null;
|
||||||
if (path[0] !== "block" || !path[1]) return null;
|
const [kind, value] = pathSegments();
|
||||||
const value = path[1];
|
if (!value) return null;
|
||||||
if (/^\d+$/.test(value)) return Number(value);
|
if (kind === "block") {
|
||||||
return (await brk.getBlockV1(value)).height;
|
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() {
|
async function loadLatest() {
|
||||||
@@ -141,7 +214,21 @@ async function loadLatest() {
|
|||||||
newestHeight = blocks[0].height;
|
newestHeight = blocks[0].height;
|
||||||
oldestHeight = blocks[blocks.length - 1].height;
|
oldestHeight = blocks[blocks.length - 1].height;
|
||||||
if (startHeight === null) reachedTip = true;
|
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;
|
loadingLatest = false;
|
||||||
observeOldestEdge();
|
observeOldestEdge();
|
||||||
if (!reachedTip) await loadNewer();
|
if (!reachedTip) await loadNewer();
|
||||||
@@ -176,17 +263,8 @@ async function loadNewer() {
|
|||||||
if (loadingNewer || newestHeight === -1 || reachedTip) return;
|
if (loadingNewer || newestHeight === -1 || reachedTip) return;
|
||||||
loadingNewer = true;
|
loadingNewer = true;
|
||||||
try {
|
try {
|
||||||
const anchor = blocksEl.lastElementChild;
|
|
||||||
const anchorRect = anchor?.getBoundingClientRect();
|
|
||||||
|
|
||||||
const blocks = await brk.getBlocksV1FromHeight(newestHeight + LOOKAHEAD);
|
const blocks = await brk.getBlocksV1FromHeight(newestHeight + LOOKAHEAD);
|
||||||
if (appendNewerBlocks(blocks)) {
|
if (!appendNewerBlocks(blocks)) {
|
||||||
if (anchor && anchorRect) {
|
|
||||||
const r = anchor.getBoundingClientRect();
|
|
||||||
chain.scrollTop += r.top - anchorRect.top;
|
|
||||||
chain.scrollLeft += r.left - anchorRect.left;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
reachedTip = true;
|
reachedTip = true;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -195,23 +273,32 @@ async function loadNewer() {
|
|||||||
loadingNewer = false;
|
loadingNewer = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {HTMLDivElement} cube */
|
/** @param {HTMLDivElement} cube @param {{ pushUrl?: boolean, scroll?: boolean }} [opts] */
|
||||||
function selectCube(cube) {
|
function selectCube(cube, { pushUrl = false, scroll = false } = {}) {
|
||||||
|
if (cube === selectedCube) return;
|
||||||
if (selectedCube) selectedCube.classList.remove("selected");
|
if (selectedCube) selectedCube.classList.remove("selected");
|
||||||
selectedCube = cube;
|
selectedCube = cube;
|
||||||
if (cube) {
|
if (cube) {
|
||||||
cube.classList.add("selected");
|
cube.classList.add("selected");
|
||||||
|
if (scroll) cube.scrollIntoView({ behavior: "smooth" });
|
||||||
const hash = cube.dataset.hash;
|
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[]} */
|
/** @type {RowDef[]} */
|
||||||
const ROW_DEFS = [
|
const ROW_DEFS = [
|
||||||
["Hash", (b) => b.id],
|
["Hash", (b) => b.id, (b) => `/block/${b.id}`],
|
||||||
["Previous Hash", (b) => b.previousblockhash],
|
[
|
||||||
|
"Previous Hash",
|
||||||
|
(b) => b.previousblockhash,
|
||||||
|
(b) => `/block/${b.previousblockhash}`,
|
||||||
|
],
|
||||||
["Merkle Root", (b) => b.merkleRoot],
|
["Merkle Root", (b) => b.merkleRoot],
|
||||||
["Timestamp", (b) => new Date(b.timestamp * 1000).toUTCString()],
|
["Timestamp", (b) => new Date(b.timestamp * 1000).toUTCString()],
|
||||||
["Median Time", (b) => new Date(b.mediantime * 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 ID", (b) => b.extras?.pool.id.toString() ?? null],
|
||||||
["Pool Slug", (b) => b.extras?.pool.slug ?? null],
|
["Pool Slug", (b) => b.extras?.pool.slug ?? null],
|
||||||
["Miner Names", (b) => b.extras?.pool.minerNames?.join(", ") || 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)],
|
"Reward",
|
||||||
["Median Fee Rate", (b) => (b.extras ? `${formatFeeRate(b.extras.medianFee)} sat/vB` : null)],
|
(b) => (b.extras ? `${(b.extras.reward / 1e8).toFixed(8)} BTC` : 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)],
|
"Total Fees",
|
||||||
["Fee Range", (b) => (b.extras ? b.extras.feeRange.map((f) => formatFeeRate(f)).join(", ") + " sat/vB" : null)],
|
(b) => (b.extras ? `${(b.extras.totalFees / 1e8).toFixed(8)} BTC` : 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)],
|
"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],
|
["Inputs", (b) => b.extras?.totalInputs.toLocaleString() ?? null],
|
||||||
["Outputs", (b) => b.extras?.totalOutputs.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 Change", (b) => b.extras?.utxoSetChange.toLocaleString() ?? null],
|
||||||
["UTXO Set Size", (b) => b.extras?.utxoSetSize.toLocaleString() ?? null],
|
["UTXO Set Size", (b) => b.extras?.utxoSetSize.toLocaleString() ?? null],
|
||||||
["SegWit Txs", (b) => b.extras?.segwitTotalTxs.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 Address", (b) => b.extras?.coinbaseAddress || null],
|
||||||
["Coinbase Addresses", (b) => b.extras?.coinbaseAddresses.join(", ") || null],
|
["Coinbase Addresses", (b) => b.extras?.coinbaseAddresses.join(", ") || null],
|
||||||
["Coinbase Raw", (b) => b.extras?.coinbaseRaw ?? null],
|
["Coinbase Raw", (b) => b.extras?.coinbaseRaw ?? null],
|
||||||
@@ -254,7 +393,27 @@ const ROW_DEFS = [
|
|||||||
["Header", (b) => b.extras?.header ?? null],
|
["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");
|
const title = document.createElement("h1");
|
||||||
title.textContent = "Block ";
|
title.textContent = "Block ";
|
||||||
const code = document.createElement("code");
|
const code = document.createElement("code");
|
||||||
@@ -266,24 +425,26 @@ function initDetails() {
|
|||||||
container.append(heightPrefix, heightNum);
|
container.append(heightPrefix, heightNum);
|
||||||
code.append(container);
|
code.append(container);
|
||||||
title.append(code);
|
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");
|
const row = document.createElement("div");
|
||||||
row.classList.add("row");
|
row.classList.add("row");
|
||||||
const labelEl = document.createElement("span");
|
const labelEl = document.createElement("span");
|
||||||
labelEl.classList.add("label");
|
labelEl.classList.add("label");
|
||||||
labelEl.textContent = label;
|
labelEl.textContent = label;
|
||||||
const valueEl = document.createElement("span");
|
const valueEl = document.createElement(linkFn ? "a" : "span");
|
||||||
valueEl.classList.add("value");
|
valueEl.classList.add("value");
|
||||||
row.append(labelEl, valueEl);
|
row.append(labelEl, valueEl);
|
||||||
details.append(row);
|
blockDetails.append(row);
|
||||||
return { row, valueEl };
|
return { row, valueEl };
|
||||||
});
|
});
|
||||||
|
|
||||||
txSection = document.createElement("div");
|
txSection = document.createElement("div");
|
||||||
txSection.classList.add("transactions");
|
txSection.classList.add("transactions");
|
||||||
details.append(txSection);
|
blockDetails.append(txSection);
|
||||||
|
|
||||||
const txHeader = document.createElement("div");
|
const txHeader = document.createElement("div");
|
||||||
txHeader.classList.add("tx-header");
|
txHeader.classList.add("tx-header");
|
||||||
@@ -297,7 +458,9 @@ function initDetails() {
|
|||||||
txSection.append(txList, createTxNav());
|
txSection.append(txList, createTxNav());
|
||||||
|
|
||||||
txObserver = new IntersectionObserver((entries) => {
|
txObserver = new IntersectionObserver((entries) => {
|
||||||
if (entries[0].isIntersecting && txPage === -1) loadTxPage(0);
|
if (entries[0].isIntersecting && !txLoaded) {
|
||||||
|
loadTxPage(txPageParam.value, false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
txObserver.observe(txSection);
|
txObserver.observe(txSection);
|
||||||
}
|
}
|
||||||
@@ -317,8 +480,8 @@ function createTxNav() {
|
|||||||
last.textContent = "\u00BB";
|
last.textContent = "\u00BB";
|
||||||
nav.append(first, prev, label, next, last);
|
nav.append(first, prev, label, next, last);
|
||||||
first.addEventListener("click", () => loadTxPage(0));
|
first.addEventListener("click", () => loadTxPage(0));
|
||||||
prev.addEventListener("click", () => loadTxPage(txPage - 1));
|
prev.addEventListener("click", () => loadTxPage(txPageParam.value - 1));
|
||||||
next.addEventListener("click", () => loadTxPage(txPage + 1));
|
next.addEventListener("click", () => loadTxPage(txPageParam.value + 1));
|
||||||
last.addEventListener("click", () => loadTxPage(txTotalPages - 1));
|
last.addEventListener("click", () => loadTxPage(txTotalPages - 1));
|
||||||
txNavs.push({ first, prev, label, next, last });
|
txNavs.push({ first, prev, label, next, last });
|
||||||
return nav;
|
return nav;
|
||||||
@@ -340,17 +503,21 @@ function updateTxNavs(page) {
|
|||||||
/** @param {BlockInfoV1 | undefined} block */
|
/** @param {BlockInfoV1 | undefined} block */
|
||||||
function updateDetails(block) {
|
function updateDetails(block) {
|
||||||
if (!block) return;
|
if (!block) return;
|
||||||
details.scrollTop = 0;
|
blockDetails.hidden = false;
|
||||||
|
txDetails.hidden = true;
|
||||||
|
blockDetails.scrollTop = 0;
|
||||||
|
|
||||||
const str = block.height.toString();
|
const str = block.height.toString();
|
||||||
heightPrefix.textContent = "#" + "0".repeat(7 - str.length);
|
heightPrefix.textContent = "#" + "0".repeat(7 - str.length);
|
||||||
heightNum.textContent = str;
|
heightNum.textContent = str;
|
||||||
|
|
||||||
ROW_DEFS.forEach(([, getter], i) => {
|
ROW_DEFS.forEach(([, getter, linkFn], i) => {
|
||||||
const value = getter(block);
|
const value = getter(block);
|
||||||
const { row, valueEl } = detailRows[i];
|
const { row, valueEl } = detailRows[i];
|
||||||
if (value !== null) {
|
if (value !== null) {
|
||||||
valueEl.textContent = value;
|
valueEl.textContent = value;
|
||||||
|
if (linkFn)
|
||||||
|
/** @type {HTMLAnchorElement} */ (valueEl).href = linkFn(block) ?? "";
|
||||||
row.hidden = false;
|
row.hidden = false;
|
||||||
} else {
|
} else {
|
||||||
row.hidden = true;
|
row.hidden = true;
|
||||||
@@ -359,18 +526,209 @@ function updateDetails(block) {
|
|||||||
|
|
||||||
txBlock = block;
|
txBlock = block;
|
||||||
txTotalPages = Math.ceil(block.txCount / TX_PAGE_SIZE);
|
txTotalPages = Math.ceil(block.txCount / TX_PAGE_SIZE);
|
||||||
txPage = -1;
|
if (txLoaded) txPageParam.setImmediate(0);
|
||||||
updateTxNavs(0);
|
txLoaded = false;
|
||||||
|
updateTxNavs(txPageParam.value);
|
||||||
txList.innerHTML = "";
|
txList.innerHTML = "";
|
||||||
txObserver.disconnect();
|
txObserver.disconnect();
|
||||||
txObserver.observe(txSection);
|
txObserver.observe(txSection);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {number} page */
|
function initTxDetails() {
|
||||||
async function loadTxPage(page) {
|
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;
|
if (txLoading || !txBlock || page < 0 || page >= txTotalPages) return;
|
||||||
txLoading = true;
|
txLoading = true;
|
||||||
txPage = page;
|
txLoaded = true;
|
||||||
|
if (pushUrl) txPageParam.setImmediate(page);
|
||||||
updateTxNavs(page);
|
updateTxNavs(page);
|
||||||
try {
|
try {
|
||||||
const txs = await brk.getBlockTxsFromIndex(txBlock.id, page * TX_PAGE_SIZE);
|
const txs = await brk.getBlockTxsFromIndex(txBlock.id, page * TX_PAGE_SIZE);
|
||||||
@@ -389,9 +747,10 @@ function renderTx(tx) {
|
|||||||
|
|
||||||
const head = document.createElement("div");
|
const head = document.createElement("div");
|
||||||
head.classList.add("tx-head");
|
head.classList.add("tx-head");
|
||||||
const txidEl = document.createElement("span");
|
const txidEl = document.createElement("a");
|
||||||
txidEl.classList.add("txid");
|
txidEl.classList.add("txid");
|
||||||
txidEl.textContent = tx.txid;
|
txidEl.textContent = tx.txid;
|
||||||
|
txidEl.href = `/tx/${tx.txid}`;
|
||||||
head.append(txidEl);
|
head.append(txidEl);
|
||||||
if (tx.status?.blockTime) {
|
if (tx.status?.blockTime) {
|
||||||
const time = document.createElement("span");
|
const time = document.createElement("span");
|
||||||
@@ -414,9 +773,25 @@ function renderTx(tx) {
|
|||||||
if (vin.isCoinbase) {
|
if (vin.isCoinbase) {
|
||||||
addr.textContent = "Coinbase";
|
addr.textContent = "Coinbase";
|
||||||
addr.classList.add("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 {
|
} else {
|
||||||
const a = /** @type {string | undefined} */ (/** @type {any} */ (vin.prevout)?.scriptpubkey_address);
|
const addrStr = /** @type {string | undefined} */ (
|
||||||
setAddrContent(a || "Unknown", addr);
|
/** @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");
|
const amt = document.createElement("span");
|
||||||
amt.classList.add("amount");
|
amt.classList.add("amount");
|
||||||
@@ -434,13 +809,22 @@ function renderTx(tx) {
|
|||||||
row.classList.add("tx-io");
|
row.classList.add("tx-io");
|
||||||
const addr = document.createElement("span");
|
const addr = document.createElement("span");
|
||||||
addr.classList.add("addr");
|
addr.classList.add("addr");
|
||||||
const type = /** @type {string | undefined} */ (/** @type {any} */ (vout).scriptpubkey_type);
|
const type = /** @type {string | undefined} */ (
|
||||||
const a = /** @type {string | undefined} */ (/** @type {any} */ (vout).scriptpubkey_address);
|
/** @type {any} */ (vout).scriptpubkey_type
|
||||||
|
);
|
||||||
|
const a = /** @type {string | undefined} */ (
|
||||||
|
/** @type {any} */ (vout).scriptpubkey_address
|
||||||
|
);
|
||||||
if (type === "op_return") {
|
if (type === "op_return") {
|
||||||
addr.textContent = "OP_RETURN";
|
addr.textContent = "OP_RETURN";
|
||||||
addr.classList.add("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 {
|
} else {
|
||||||
setAddrContent(a || vout.scriptpubkey, addr);
|
setAddrContent(vout.scriptpubkey, addr);
|
||||||
}
|
}
|
||||||
const amt = document.createElement("span");
|
const amt = document.createElement("span");
|
||||||
amt.classList.add("amount");
|
amt.classList.add("amount");
|
||||||
@@ -516,7 +900,9 @@ function createBlockCube(block) {
|
|||||||
|
|
||||||
cubeElement.dataset.hash = block.id;
|
cubeElement.dataset.hash = block.id;
|
||||||
blocksByHash.set(block.id, block);
|
blocksByHash.set(block.id, block);
|
||||||
cubeElement.addEventListener("click", () => selectCube(cubeElement));
|
cubeElement.addEventListener("click", () =>
|
||||||
|
selectCube(cubeElement, { pushUrl: true }),
|
||||||
|
);
|
||||||
|
|
||||||
const heightEl = document.createElement("p");
|
const heightEl = document.createElement("p");
|
||||||
heightEl.append(createHeightElement(block.height));
|
heightEl.append(createHeightElement(block.height));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ios, canShare } from "../utils/env.js";
|
import { ios, canShare } from "../env.js";
|
||||||
import { style } from "../utils/elements.js";
|
import { style } from "../elements.js";
|
||||||
import { colors } from "../utils/colors.js";
|
import { colors } from "../colors.js";
|
||||||
|
|
||||||
export const canCapture = !ios || canShare;
|
export const canCapture = !ios || canShare;
|
||||||
|
|
||||||
@@ -4,18 +4,18 @@ import {
|
|||||||
HistogramSeries,
|
HistogramSeries,
|
||||||
LineSeries,
|
LineSeries,
|
||||||
BaselineSeries,
|
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 { createLegend, createSeriesLegend } from "./legend.js";
|
||||||
import { capture } from "./capture.js";
|
import { capture } from "./capture.js";
|
||||||
import { colors } from "../utils/colors.js";
|
import { colors } from "../colors.js";
|
||||||
import { createRadios, createSelect, getElementById } from "../utils/dom.js";
|
import { createRadios, createSelect, getElementById } from "../dom.js";
|
||||||
import { createPersistedValue } from "../utils/persisted.js";
|
import { createPersistedValue } from "../persisted.js";
|
||||||
import { onChange as onThemeChange } from "../utils/theme.js";
|
import { onChange as onThemeChange } from "../theme.js";
|
||||||
import { throttle, debounce } from "../utils/timing.js";
|
import { throttle, debounce } from "../timing.js";
|
||||||
import { serdeBool, INDEX_FROM_LABEL } from "../utils/serde.js";
|
import { serdeBool, INDEX_FROM_LABEL } from "../serde.js";
|
||||||
import { stringToId, numberToShortUSFormat } from "../utils/format.js";
|
import { stringToId, numberToShortUSFormat } from "../format.js";
|
||||||
import { style } from "../utils/elements.js";
|
import { style } from "../elements.js";
|
||||||
import { Unit } from "../utils/units.js";
|
import { Unit } from "../units.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {_ISeriesApi<LCSeriesType>} ISeries
|
* @typedef {_ISeriesApi<LCSeriesType>} ISeries
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import { createLabeledInput, createSpanName } from "../utils/dom.js";
|
import { createLabeledInput, createSpanName } from "../dom.js";
|
||||||
import { stringToId } from "../utils/format.js";
|
import { stringToId } from "../format.js";
|
||||||
|
|
||||||
/** @param {HTMLElement} el */
|
/** @param {HTMLElement} el */
|
||||||
function captureScroll(el) {
|
function captureScroll(el) {
|
||||||
el.addEventListener("wheel", (e) => e.stopPropagation(), { passive: true });
|
el.addEventListener("wheel", (e) => e.stopPropagation(), { passive: true });
|
||||||
el.addEventListener("touchstart", (e) => e.stopPropagation(), { passive: true });
|
el.addEventListener("touchstart", (e) => e.stopPropagation(), {
|
||||||
el.addEventListener("touchmove", (e) => e.stopPropagation(), { passive: true });
|
passive: true,
|
||||||
|
});
|
||||||
|
el.addEventListener("touchmove", (e) => e.stopPropagation(), {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { oklchToRgba } from "../chart/oklch.js";
|
|
||||||
import { dark } from "./theme.js";
|
import { dark } from "./theme.js";
|
||||||
|
|
||||||
/** @type {Map<string, string>} */
|
/** @type {Map<string, string>} */
|
||||||
@@ -275,3 +274,128 @@ export const colors = {
|
|||||||
|
|
||||||
at,
|
at,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---
|
||||||
|
// oklch
|
||||||
|
// ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {readonly [number, number, number, number, number, number, number, number, number]} A
|
||||||
|
* @param {readonly [number, number, number]} B
|
||||||
|
*/
|
||||||
|
function multiplyMatrices(A, B) {
|
||||||
|
return /** @type {const} */ ([
|
||||||
|
A[0] * B[0] + A[1] * B[1] + A[2] * B[2],
|
||||||
|
A[3] * B[0] + A[4] * B[1] + A[5] * B[2],
|
||||||
|
A[6] * B[0] + A[7] * B[1] + A[8] * B[2],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {readonly [number, number, number]} param0 */
|
||||||
|
function oklch2oklab([l, c, h]) {
|
||||||
|
return /** @type {const} */ ([
|
||||||
|
l,
|
||||||
|
isNaN(h) ? 0 : c * Math.cos((h * Math.PI) / 180),
|
||||||
|
isNaN(h) ? 0 : c * Math.sin((h * Math.PI) / 180),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {readonly [number, number, number]} rgb */
|
||||||
|
function srgbLinear2rgb(rgb) {
|
||||||
|
return rgb.map((c) =>
|
||||||
|
Math.abs(c) > 0.0031308
|
||||||
|
? (c < 0 ? -1 : 1) * (1.055 * Math.abs(c) ** (1 / 2.4) - 0.055)
|
||||||
|
: 12.92 * c,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {readonly [number, number, number]} lab */
|
||||||
|
function oklab2xyz(lab) {
|
||||||
|
const LMSg = multiplyMatrices(
|
||||||
|
[
|
||||||
|
1, 0.3963377773761749, 0.2158037573099136, 1, -0.1055613458156586,
|
||||||
|
-0.0638541728258133, 1, -0.0894841775298119, -1.2914855480194092,
|
||||||
|
],
|
||||||
|
lab,
|
||||||
|
);
|
||||||
|
const LMS = /** @type {[number, number, number]} */ (
|
||||||
|
LMSg.map((val) => val ** 3)
|
||||||
|
);
|
||||||
|
return multiplyMatrices(
|
||||||
|
[
|
||||||
|
1.2268798758459243, -0.5578149944602171, 0.2813910456659647,
|
||||||
|
-0.0405757452148008, 1.112286803280317, -0.0717110580655164,
|
||||||
|
-0.0763729366746601, -0.4214933324022432, 1.5869240198367816,
|
||||||
|
],
|
||||||
|
LMS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {readonly [number, number, number]} xyz */
|
||||||
|
function xyz2rgbLinear(xyz) {
|
||||||
|
return multiplyMatrices(
|
||||||
|
[
|
||||||
|
3.2409699419045226, -1.537383177570094, -0.4986107602930034,
|
||||||
|
-0.9692436362808796, 1.8759675015077202, 0.04155505740717559,
|
||||||
|
0.05563007969699366, -0.20397695888897652, 1.0569715142428786,
|
||||||
|
],
|
||||||
|
xyz,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Map<string, [number, number, number, number]>} */
|
||||||
|
const conversionCache = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse oklch string and return rgba tuple
|
||||||
|
* @param {string} oklch
|
||||||
|
* @returns {[number, number, number, number] | null}
|
||||||
|
*/
|
||||||
|
function parseOklch(oklch) {
|
||||||
|
if (!oklch.startsWith("oklch(")) return null;
|
||||||
|
|
||||||
|
const cached = conversionCache.get(oklch);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
let str = oklch.slice(6, -1); // remove "oklch(" and ")"
|
||||||
|
let alpha = 1;
|
||||||
|
|
||||||
|
const slashIdx = str.indexOf(" / ");
|
||||||
|
if (slashIdx !== -1) {
|
||||||
|
const alphaPart = str.slice(slashIdx + 3);
|
||||||
|
alpha = alphaPart.includes("%")
|
||||||
|
? Number(alphaPart.replace("%", "")) / 100
|
||||||
|
: Number(alphaPart);
|
||||||
|
str = str.slice(0, slashIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = str.split(" ");
|
||||||
|
const l = parts[0].includes("%")
|
||||||
|
? Number(parts[0].replace("%", "")) / 100
|
||||||
|
: Number(parts[0]);
|
||||||
|
const c = Number(parts[1]);
|
||||||
|
const h = Number(parts[2]);
|
||||||
|
|
||||||
|
const rgb = srgbLinear2rgb(
|
||||||
|
xyz2rgbLinear(oklab2xyz(oklch2oklab([l, c, h]))),
|
||||||
|
).map((v) => Math.max(Math.min(Math.round(v * 255), 255), 0));
|
||||||
|
|
||||||
|
const result = /** @type {[number, number, number, number]} */ ([
|
||||||
|
...rgb,
|
||||||
|
alpha,
|
||||||
|
]);
|
||||||
|
conversionCache.set(oklch, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert oklch string to rgba string
|
||||||
|
* @param {string} oklch
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function oklchToRgba(oklch) {
|
||||||
|
const result = parseOklch(oklch);
|
||||||
|
if (!result) return oklch;
|
||||||
|
const [r, g, b, a] = result;
|
||||||
|
return a === 1 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,73 +21,27 @@ export const serdeBool = {
|
|||||||
|
|
||||||
export const INDEX_LABEL = /** @type {const} */ ({
|
export const INDEX_LABEL = /** @type {const} */ ({
|
||||||
height: "blk",
|
height: "blk",
|
||||||
minute10: "10mn", minute30: "30mn",
|
minute10: "10mn",
|
||||||
hour1: "1h", hour4: "4h", hour12: "12h",
|
minute30: "30mn",
|
||||||
day1: "1d", day3: "3d", week1: "1w",
|
hour1: "1h",
|
||||||
month1: "1m", month3: "3m", month6: "6m",
|
hour4: "4h",
|
||||||
year1: "1y", year10: "10y",
|
hour12: "12h",
|
||||||
halving: "halv", epoch: "epch",
|
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 {typeof INDEX_LABEL} IndexLabelMap */
|
||||||
/** @typedef {keyof IndexLabelMap} ChartableIndex */
|
/** @typedef {keyof IndexLabelMap} ChartableIndex */
|
||||||
/** @typedef {IndexLabelMap[ChartableIndex]} IndexLabel */
|
/** @typedef {IndexLabelMap[ChartableIndex]} IndexLabel */
|
||||||
|
|
||||||
export const INDEX_FROM_LABEL = fromEntries(entries(INDEX_LABEL).map(([k, v]) => [v, k]));
|
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
|
|
||||||
*/
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
/** Market utilities */
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert period ID to readable name
|
* Convert period ID to readable name
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
@@ -179,7 +179,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#block-details {
|
#block-details,
|
||||||
|
#tx-details {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
line-height: var(--line-height-sm);
|
line-height: var(--line-height-sm);
|
||||||
@@ -321,6 +322,15 @@
|
|||||||
color: var(--orange);
|
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 {
|
&.op-return {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user