feat(ui): a live wormhole, drawn from the blocks as they land
The wormhole page described a pool that was never shown doing anything. It now opens with a stage where a note arriving falls inward and is swallowed, and a note leaving climbs out and escapes — driven entirely by blocks this observer has actually decoded. Nothing is on a loop: a still stage means a still chain. `index_events` now reports what each block did to the pool, split by the producing extrinsic exactly as the daily flow query and `exit_cohorts` are — a settled batch releases value, anything else commits it, and the recorded sender cannot say because it is the same sentinel either way. The exit extrinsics are collected inside `index_extrinsics`, the one place the body is already decoded; finding them in a second pass would re-run `decode_extrinsic` over Dilithium signatures at 5.3 KiB apiece. It is broadcast after the block rather than inside it, because the counts cost two more round trips and the site's ticker must not wait for one panel. A block that touched no notes broadcasts nothing. On the front end the pulse takes a side channel — `Observer.onWormhole`, apart from the state listeners — because a pulse is an event, not a fact about the page, and routing it through `ObserverState` would re-render the leaderboard, the ticker and the whole tree once per block for one animation. Marks are pooled SVG nodes mutated from a loop that starts on a pulse and ends itself. Honest by construction: marks are capped and the caption is not, so a block committing 900 notes draws 28 and says nine hundred. `prefers-reduced-motion` stops the motion and keeps the information — no marks, caption still live, aria-label still describing the last block. The stage takes the headline stat row's place on this page. Hashrate, difficulty, block time, miners and height are on every other page and say nothing about the pool. Refs #17 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
32
CLAUDE.md
32
CLAUDE.md
@@ -750,6 +750,38 @@ real numbers, and why the readout borrows the tile's note line instead of
|
||||
floating a tooltip: five overlays in a row of 180px tiles is not a hover layer,
|
||||
it is a pile.
|
||||
|
||||
**The live stage is driven by blocks, never by a timer.** `ServerMessage::Wormhole`
|
||||
carries what one block did to the pool — notes committed, notes an exit
|
||||
released, and both amounts — broadcast from `index_events` after
|
||||
`ServerMessage::Block` rather than folded into it, because the counts need two
|
||||
more RPC round trips and the whole site's ticker must not wait for one panel. A
|
||||
block that touched no notes sends nothing: an empty pulse every twelve seconds
|
||||
would be noise, and **a still stage means a still chain**, which is information.
|
||||
|
||||
Three rules keep `WormholeStage` honest, and each cost a rewrite:
|
||||
|
||||
- **Marks are capped; the caption is not.** A block committing 900 notes draws
|
||||
`PER_PULSE_CAP` of them and says nine hundred in words. A picture nobody can
|
||||
count is decoration, so the number lives in the text where it is exact.
|
||||
- **It renders outside React.** Pulses go through `Observer.onWormhole`, a
|
||||
separate subscriber set from the state listeners, because a pulse is an event
|
||||
and not a fact about the page — putting it in `ObserverState` would re-render
|
||||
the leaderboard, the ticker and the whole tree once per block for one
|
||||
animation. The marks are pooled SVG nodes mutated from one `rAF` loop that
|
||||
**starts on a pulse and ends itself** when nothing is in flight.
|
||||
- **`prefers-reduced-motion` stops the motion, not the information.** No marks
|
||||
spawn, the caption still updates with real numbers and the `aria-label` still
|
||||
describes the last block. Verified by forcing the hook rather than assuming.
|
||||
|
||||
Direction is the same encoding as the chart — inward against outward — for the
|
||||
same measured reason, and the mouth's `ry` is capped so the outer ring fits the
|
||||
stage and reused as the squash on every note's path: a mark has to travel in the
|
||||
plane of the thing it is falling into or it reads as passing in front of it.
|
||||
|
||||
On `/:chain/wormhole` the stage replaces the headline stat row. Hashrate,
|
||||
difficulty, block time, miners and height are on every other page and say
|
||||
nothing about the pool.
|
||||
|
||||
**A diverging chart with one hue puts direction in the geometry.** In and out
|
||||
are opposite directions, which is the textbook diverging case — two hues either
|
||||
side of a neutral midpoint. This site cannot have them, so `FlowChart` carries
|
||||
|
||||
@@ -996,7 +996,7 @@ async fn index_events(
|
||||
})
|
||||
.collect();
|
||||
|
||||
if let Some(body) = body {
|
||||
let exits = if let Some(body) = body {
|
||||
index_extrinsics(
|
||||
chain,
|
||||
store,
|
||||
@@ -1006,8 +1006,15 @@ async fn index_events(
|
||||
at,
|
||||
&runtime,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
.await
|
||||
} else {
|
||||
// No body means no way to tell an exit from an arrival. Reporting every
|
||||
// transfer as an arrival would draw a pool that only ever fills, so the
|
||||
// pulse is skipped entirely for this block.
|
||||
std::collections::HashSet::new()
|
||||
};
|
||||
|
||||
broadcast_wormhole_pulse(chain, height, &decoded, &exits);
|
||||
|
||||
let keep: Vec<blackbeard_data::store::EventRecord> = decoded
|
||||
.into_iter()
|
||||
@@ -1038,6 +1045,68 @@ async fn index_events(
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the page what this block did to the wormhole, if it did anything.
|
||||
///
|
||||
/// Direction is a fact about the producing extrinsic, exactly as it is in
|
||||
/// `exit_cohorts` and the daily flow query — a settled batch releases value,
|
||||
/// anything else commits it. The recorded sender cannot say: it is the same
|
||||
/// sentinel whichever way the value is going.
|
||||
///
|
||||
/// A block that touched no notes broadcasts nothing. On a chain producing a
|
||||
/// block every twelve seconds an empty pulse would be pure noise, and a client
|
||||
/// that sees silence correctly draws nothing.
|
||||
fn broadcast_wormhole_pulse(
|
||||
chain: &Arc<ChainRuntime>,
|
||||
height: u64,
|
||||
decoded: &[blackbeard_core::runtime::DecodedEvent],
|
||||
exits: &std::collections::HashSet<u32>,
|
||||
) {
|
||||
let (mut notes_in, mut notes_out) = (0u32, 0u32);
|
||||
let (mut amount_in, mut amount_out) = (0u128, 0u128);
|
||||
|
||||
for event in decoded {
|
||||
if event.pallet != "Wormhole" || event.variant != "NativeTransferred" {
|
||||
continue;
|
||||
}
|
||||
// Saturating: an amount this decoder cannot read as a u128 must not
|
||||
// wrap a total into something small and plausible. It contributes its
|
||||
// count and no value, which understates rather than invents.
|
||||
let amount = event
|
||||
.fields
|
||||
.get("amount")
|
||||
.and_then(|v| match v {
|
||||
serde_json::Value::String(s) => s.parse::<u128>().ok(),
|
||||
serde_json::Value::Number(n) => n.as_u64().map(u128::from),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
if event.extrinsic_index.is_some_and(|i| exits.contains(&i)) {
|
||||
notes_out = notes_out.saturating_add(1);
|
||||
amount_out = amount_out.saturating_add(amount);
|
||||
} else {
|
||||
notes_in = notes_in.saturating_add(1);
|
||||
amount_in = amount_in.saturating_add(amount);
|
||||
}
|
||||
}
|
||||
|
||||
if notes_in == 0 && notes_out == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
chain.broadcast(
|
||||
&ServerMessage::Wormhole {
|
||||
chain: chain.id(),
|
||||
height,
|
||||
notes_in,
|
||||
notes_out,
|
||||
amount_in: blackbeard_entities::BigUintDec(amount_in.to_string()),
|
||||
amount_out: blackbeard_entities::BigUintDec(amount_out.to_string()),
|
||||
},
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Store a block's extrinsics, decoded against the runtime that produced it.
|
||||
///
|
||||
/// Best effort, like the events beside it: an extrinsic that will not decode
|
||||
@@ -1057,29 +1126,39 @@ async fn index_extrinsics(
|
||||
outcomes: &std::collections::HashMap<u32, bool>,
|
||||
at: Option<DateTime<Utc>>,
|
||||
runtime: &Arc<blackbeard_core::runtime::Runtime>,
|
||||
) {
|
||||
) -> std::collections::HashSet<u32> {
|
||||
let mut keep = Vec::with_capacity(raw.len());
|
||||
// Which extrinsics settled a wormhole exit. Collected here because this is
|
||||
// the one place the body is already decoded — a second pass to find them
|
||||
// would re-run `decode_extrinsic` over Dilithium signatures at 5.3 KiB
|
||||
// apiece, which is most of the cost of reading a block.
|
||||
let mut exits = std::collections::HashSet::new();
|
||||
for (index, hex_str) in raw.iter().enumerate() {
|
||||
let Ok(bytes) = hex::decode(hex_str.trim_start_matches("0x")) else {
|
||||
continue;
|
||||
};
|
||||
let index = index as u32;
|
||||
match runtime.decode_extrinsic(index, &bytes) {
|
||||
Ok(x) => keep.push(blackbeard_data::store::ExtrinsicRecord {
|
||||
chain: chain.id(),
|
||||
height,
|
||||
extrinsic_index: index,
|
||||
pallet: x.pallet,
|
||||
call: x.call,
|
||||
signer: x.signer,
|
||||
signature: x.signature,
|
||||
signature_bytes: x.signature_bytes,
|
||||
args: x.args,
|
||||
extra: x.extra,
|
||||
success: outcomes.get(&index).copied(),
|
||||
accounts: x.accounts,
|
||||
at,
|
||||
}),
|
||||
Ok(x) => {
|
||||
if x.pallet == "Wormhole" && is_exit_settlement(&x.call) {
|
||||
exits.insert(index);
|
||||
}
|
||||
keep.push(blackbeard_data::store::ExtrinsicRecord {
|
||||
chain: chain.id(),
|
||||
height,
|
||||
extrinsic_index: index,
|
||||
pallet: x.pallet,
|
||||
call: x.call,
|
||||
signer: x.signer,
|
||||
signature: x.signature,
|
||||
signature_bytes: x.signature_bytes,
|
||||
args: x.args,
|
||||
extra: x.extra,
|
||||
success: outcomes.get(&index).copied(),
|
||||
accounts: x.accounts,
|
||||
at,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
chain = %chain.id(), height, index, error = %e,
|
||||
@@ -1090,11 +1169,22 @@ async fn index_extrinsics(
|
||||
}
|
||||
|
||||
if keep.is_empty() {
|
||||
return;
|
||||
return exits;
|
||||
}
|
||||
if let Err(e) = store.record_extrinsics(&keep).await {
|
||||
tracing::warn!(chain = %chain.id(), height, error = %e, "extrinsics not persisted");
|
||||
}
|
||||
exits
|
||||
}
|
||||
|
||||
/// Whether a `Wormhole` call is one that settles an exit.
|
||||
///
|
||||
/// Both batch verifiers release value from the pool; nothing else in the pallet
|
||||
/// does. Matched on the name rather than a call index because an index is a
|
||||
/// position in a runtime's dispatch table and a runtime upgrade may renumber
|
||||
/// it, while these two names are the pallet's public surface.
|
||||
fn is_exit_settlement(call: &str) -> bool {
|
||||
call == "verify_public_batch" || call == "verify_private_batch"
|
||||
}
|
||||
|
||||
/// Read every block's events from the tip back to genesis, and keep up.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::{ChainId, ChainInfo, ChainSummary, LeaderboardRow, RecentBlock};
|
||||
use crate::{BigUintDec, ChainId, ChainInfo, ChainSummary, LeaderboardRow, RecentBlock};
|
||||
|
||||
/// How far back a leaderboard looks.
|
||||
///
|
||||
@@ -190,6 +190,34 @@ pub enum ServerMessage {
|
||||
#[ts(type = "number | null")]
|
||||
window_seconds: Option<f64>,
|
||||
},
|
||||
/// What one block did to the wormhole, as soon as its events are decoded.
|
||||
///
|
||||
/// Sent after [`ServerMessage::Block`] rather than folded into it: the
|
||||
/// counts come from `index_events`, which runs a couple of RPC round trips
|
||||
/// later, and delaying the block broadcast to wait for them would make the
|
||||
/// whole site's ticker lag for one panel's benefit.
|
||||
///
|
||||
/// A block with no wormhole activity sends nothing at all. Silence is the
|
||||
/// common case on a quiet chain and an empty pulse every twelve seconds
|
||||
/// would be pure noise on the wire.
|
||||
Wormhole {
|
||||
/// Which chain.
|
||||
chain: ChainId,
|
||||
/// The block these came from.
|
||||
#[ts(type = "number")]
|
||||
height: u64,
|
||||
/// Notes created — value arriving in the pool.
|
||||
#[ts(type = "number")]
|
||||
notes_in: u32,
|
||||
/// Notes whose value an exit released, settled by
|
||||
/// `verify_public_batch` or `verify_private_batch`.
|
||||
#[ts(type = "number")]
|
||||
notes_out: u32,
|
||||
/// Value arriving, smallest unit.
|
||||
amount_in: BigUintDec,
|
||||
/// Value leaving, smallest unit.
|
||||
amount_out: BigUintDec,
|
||||
},
|
||||
/// A chain's reachability changed — the node went away, or a chain that was
|
||||
/// awaiting launch has started producing blocks.
|
||||
ChainStatus {
|
||||
|
||||
@@ -33,6 +33,7 @@ import { SectionNav } from './components/SectionNav'
|
||||
import { ThemeToggle } from './components/ThemeToggle'
|
||||
import { StateIndex } from './components/StateIndex'
|
||||
import { StatBar } from './components/StatBar'
|
||||
import { WormholeStage } from './components/WormholeStage'
|
||||
import { measuredSpan, seconds, windowSpan } from './lib/format'
|
||||
import { href, isStandings, parse, WINDOWS } from './lib/routes'
|
||||
import { useObserver, usePinnedMiners, useWatch } from './lib/store'
|
||||
@@ -187,7 +188,15 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StatBar summary={state.summary} windowName={route.window} />
|
||||
{/* The wormhole page trades the headline row for the live stage. Hashrate,
|
||||
difficulty, block time, miners and height are on every other page and
|
||||
say nothing about the pool; the stage is the one thing that can only
|
||||
be shown here, and it wants the width. */}
|
||||
{route.index === 'wormhole' ? (
|
||||
<WormholeStage decimals={info?.token_decimals ?? 12} symbol={info?.token_symbol ?? ''} />
|
||||
) : (
|
||||
<StatBar summary={state.summary} windowName={route.window} />
|
||||
)}
|
||||
|
||||
{route.block && chain && (
|
||||
<BlockPanel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { BigUintDec } from "./BigUintDec";
|
||||
import type { ChainId } from "./ChainId";
|
||||
import type { ChainInfo } from "./ChainInfo";
|
||||
import type { ChainSummary } from "./ChainSummary";
|
||||
@@ -76,7 +77,32 @@ rows: Array<LeaderboardRow>,
|
||||
/**
|
||||
* What the window actually spanned, by the chain's own clock.
|
||||
*/
|
||||
window_seconds: number | null, } | { "type": "chain_status",
|
||||
window_seconds: number | null, } | { "type": "wormhole",
|
||||
/**
|
||||
* Which chain.
|
||||
*/
|
||||
chain: ChainId,
|
||||
/**
|
||||
* The block these came from.
|
||||
*/
|
||||
height: number,
|
||||
/**
|
||||
* Notes created — value arriving in the pool.
|
||||
*/
|
||||
notes_in: number,
|
||||
/**
|
||||
* Notes whose value an exit released, settled by
|
||||
* `verify_public_batch` or `verify_private_batch`.
|
||||
*/
|
||||
notes_out: number,
|
||||
/**
|
||||
* Value arriving, smallest unit.
|
||||
*/
|
||||
amount_in: BigUintDec,
|
||||
/**
|
||||
* Value leaving, smallest unit.
|
||||
*/
|
||||
amount_out: BigUintDec, } | { "type": "chain_status",
|
||||
/**
|
||||
* Which chain.
|
||||
*/
|
||||
|
||||
@@ -61,6 +61,25 @@ const INITIAL: ObserverState = {
|
||||
ready: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* What one block did to the wormhole.
|
||||
*
|
||||
* Deliberately **not** part of `ObserverState`. A pulse is an event, not a
|
||||
* fact about the page: it happens, it is drawn, and a second later it means
|
||||
* nothing. Putting it in the state object would re-render every subscriber —
|
||||
* the leaderboard, the ticker, the whole tree — once per block for the benefit
|
||||
* of one animation, and the animation would still have to diff to notice it
|
||||
* had fired twice with the same numbers.
|
||||
*/
|
||||
export interface WormholePulse {
|
||||
chain: string
|
||||
height: number
|
||||
notesIn: number
|
||||
notesOut: number
|
||||
amountIn: string
|
||||
amountOut: string
|
||||
}
|
||||
|
||||
/** Blocks kept in the ticker. Matches the backend's replay length. */
|
||||
const TICKER_LIMIT = 40
|
||||
|
||||
@@ -98,12 +117,28 @@ function socketUrl(): string {
|
||||
export class Observer {
|
||||
private state: ObserverState = INITIAL
|
||||
private listeners = new Set<() => void>()
|
||||
/** Pulse subscribers, kept apart from `listeners` — see `WormholePulse`. */
|
||||
private pulseListeners = new Set<(pulse: WormholePulse) => void>()
|
||||
private socket: WebSocket | null = null
|
||||
private reconnectDelay = RECONNECT_MIN_MS
|
||||
private reconnectTimer: number | null = null
|
||||
private pingTimer: number | null = null
|
||||
private closed = false
|
||||
|
||||
/**
|
||||
* Hear every wormhole pulse for the subscribed chain.
|
||||
*
|
||||
* Separate from `subscribe` because this is a stream of events rather than a
|
||||
* store — there is no "current pulse" to read, and a component that misses
|
||||
* one has missed a block's animation and nothing else.
|
||||
*/
|
||||
onWormhole = (listener: (pulse: WormholePulse) => void): (() => void) => {
|
||||
this.pulseListeners.add(listener)
|
||||
return () => {
|
||||
this.pulseListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
/** `useSyncExternalStore` subscribe. */
|
||||
subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener)
|
||||
@@ -238,6 +273,29 @@ export class Observer {
|
||||
break
|
||||
}
|
||||
|
||||
case 'wormhole': {
|
||||
if (message.chain !== this.state.chain) return
|
||||
const pulse: WormholePulse = {
|
||||
chain: message.chain,
|
||||
height: message.height,
|
||||
notesIn: message.notes_in,
|
||||
notesOut: message.notes_out,
|
||||
amountIn: message.amount_in,
|
||||
amountOut: message.amount_out,
|
||||
}
|
||||
// A throwing subscriber must not take the socket's reader loop with
|
||||
// it: the rest of the site depends on this loop, and an animation is
|
||||
// the least important thing on the page.
|
||||
for (const listener of this.pulseListeners) {
|
||||
try {
|
||||
listener(pulse)
|
||||
} catch (e) {
|
||||
console.warn('[observer] wormhole subscriber threw', e)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'chain_status':
|
||||
this.patch({
|
||||
chains: this.state.chains.map((c) => (c.id === message.chain ? message.info : c)),
|
||||
|
||||
331
web/src/components/WormholeStage.tsx
Normal file
331
web/src/components/WormholeStage.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* The wormhole, drawn as it actually happens.
|
||||
*
|
||||
* Every block this observer decodes reports how many notes it committed and
|
||||
* how many an exit released (`ServerMessage::Wormhole`). A note arriving falls
|
||||
* inward and is swallowed; a note leaving climbs out and escapes. Nothing here
|
||||
* is on a timer or a loop — **a still stage means a still chain**, and that is
|
||||
* information rather than a bug.
|
||||
*
|
||||
* Three things keep it honest:
|
||||
*
|
||||
* - **Particles are capped and the caption is not.** A block committing 900
|
||||
* notes draws `MAX_PARTICLES` of them and says nine hundred in words. Drawing
|
||||
* one mark per note would melt the tab and still be uncountable, and a
|
||||
* picture nobody can count is decoration — so the number lives in the text,
|
||||
* where it is exact.
|
||||
* - **Direction is the same encoding the chart uses**: inward against outward,
|
||||
* never two hues. This site has one data colour and the two-shade
|
||||
* alternative fails CVD separation, so a note's direction is its travel.
|
||||
* - **It is never the only way to read this.** The chart and the tables below
|
||||
* carry every figure; this is the same events with the latency removed.
|
||||
*
|
||||
* Rendered by mutating pooled SVG nodes from one `requestAnimationFrame` loop
|
||||
* rather than through React state. Sixty state updates a second across a tree
|
||||
* this size is the cost React is bad at, and none of it is worth a re-render.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { WormholePulse } from '../api/socket'
|
||||
import { tokens } from '../lib/format'
|
||||
import { useObserverInstance } from '../lib/observer-context'
|
||||
|
||||
const HEIGHT = 210
|
||||
const FALLBACK_WIDTH = 720
|
||||
/**
|
||||
* Most marks alive at once.
|
||||
*
|
||||
* A ceiling on work, not a claim about the chain: mainnet commits a few
|
||||
* thousand notes on a busy day and a single block has carried hundreds. The
|
||||
* caption always says the real count.
|
||||
*/
|
||||
const MAX_PARTICLES = 96
|
||||
/** How long one note takes to fall in or climb out. */
|
||||
const FLIGHT_MS = 1500
|
||||
/** Most marks any single block may spawn, so one big block cannot fill the pool. */
|
||||
const PER_PULSE_CAP = 28
|
||||
|
||||
interface Particle {
|
||||
/** Set while in flight; a free slot is `null`. */
|
||||
born: number
|
||||
out: boolean
|
||||
/** Where it crosses the rim, in radians. */
|
||||
angle: number
|
||||
/** Start (or end) distance from the mouth, in px. */
|
||||
reach: number
|
||||
/** 0.6–1 — a larger note draws a larger mark. */
|
||||
scale: number
|
||||
}
|
||||
|
||||
function useMeasuredWidth(): [React.RefObject<HTMLDivElement | null>, number] {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [width, setWidth] = useState(FALLBACK_WIDTH)
|
||||
useEffect(() => {
|
||||
const node = ref.current
|
||||
if (!node) return
|
||||
const observe = new ResizeObserver(([entry]) => {
|
||||
if (entry) setWidth(Math.max(280, entry.contentRect.width))
|
||||
})
|
||||
observe.observe(node)
|
||||
return () => observe.disconnect()
|
||||
}, [])
|
||||
return [ref, width]
|
||||
}
|
||||
|
||||
/** `true` when the viewer has asked for less motion, and whenever that changes. */
|
||||
function useReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(false)
|
||||
useEffect(() => {
|
||||
// `matchMedia` is absent in some embedded viewers; absent means no stated
|
||||
// preference, which is the same as not having asked for less motion.
|
||||
if (typeof window.matchMedia !== 'function') return
|
||||
const query = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
setReduced(query.matches)
|
||||
const onChange = (e: MediaQueryListEvent) => setReduced(e.matches)
|
||||
query.addEventListener('change', onChange)
|
||||
return () => query.removeEventListener('change', onChange)
|
||||
}, [])
|
||||
return reduced
|
||||
}
|
||||
|
||||
/** Eased 0→1. Slow at the rim, quick at the mouth: a fall, not a slide. */
|
||||
function ease(t: number): number {
|
||||
return t * t * (3 - 2 * t)
|
||||
}
|
||||
|
||||
export function WormholeStage({ decimals, symbol }: { decimals: number; symbol: string }) {
|
||||
const observer = useObserverInstance()
|
||||
const [wrap, width] = useMeasuredWidth()
|
||||
const reduced = useReducedMotion()
|
||||
const [last, setLast] = useState<WormholePulse | null>(null)
|
||||
|
||||
const pool = useRef<(SVGCircleElement | null)[]>([])
|
||||
const particles = useRef<Particle[]>(
|
||||
Array.from({ length: MAX_PARTICLES }, () => ({
|
||||
born: 0,
|
||||
out: false,
|
||||
angle: 0,
|
||||
reach: 0,
|
||||
scale: 1,
|
||||
})),
|
||||
)
|
||||
/** Which slots are in flight. A slot is reusable the moment its note lands. */
|
||||
const live = useRef<Set<number>>(new Set())
|
||||
/** Whether a frame is already scheduled, so a burst of pulses starts one loop. */
|
||||
const running = useRef(false)
|
||||
const mounted = useRef(true)
|
||||
const geometry = useRef({
|
||||
cx: FALLBACK_WIDTH / 2,
|
||||
cy: HEIGHT / 2,
|
||||
rim: 150,
|
||||
rx: 100,
|
||||
squash: 0.42,
|
||||
})
|
||||
|
||||
// The mouth scales with the panel. Fixed radii left a 1200px-wide stage mostly
|
||||
// empty with a small ellipse adrift in it, and pushed the rim so far out that
|
||||
// notes spent most of their flight off-canvas — visible only for the last
|
||||
// moment before they landed, which is the half that reads as nothing
|
||||
// happening.
|
||||
const rx = Math.min(width * 0.3, 300)
|
||||
// How flat the mouth is. Capped so the outermost ring fits the stage rather
|
||||
// than being cut off top and bottom, and reused as the squash on every note's
|
||||
// path — the marks have to travel in the plane of the thing they are falling
|
||||
// into, or they read as passing in front of it.
|
||||
const ry = Math.min(rx * 0.42, HEIGHT / 2 - 12)
|
||||
const geo = {
|
||||
cx: width / 2,
|
||||
cy: HEIGHT / 2,
|
||||
// Just past the mouth rather than past the panel: a note has to be on
|
||||
// screen for its whole travel or the motion is not the thing being read.
|
||||
rim: rx * 1.5,
|
||||
rx,
|
||||
squash: ry / rx,
|
||||
}
|
||||
geometry.current = geo
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
// Captured rather than read in the cleanup: the `Set` is created once by
|
||||
// `useRef` and never replaced, so this is the same object either way — but
|
||||
// holding it locally is what makes that promise visible, here and to the
|
||||
// exhaustive-deps rule.
|
||||
const inFlight = live.current
|
||||
return () => {
|
||||
mounted.current = false
|
||||
inFlight.clear()
|
||||
running.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Advance every mark in flight by one frame.
|
||||
*
|
||||
* Held in a ref and reassigned each render so the loop always sees the
|
||||
* current geometry without being torn down and restarted on every resize.
|
||||
*/
|
||||
const step = useRef<() => void>(() => {})
|
||||
step.current = () => {
|
||||
const now = performance.now()
|
||||
const { cx: x0, cy: y0, squash } = geometry.current
|
||||
for (const slot of Array.from(live.current)) {
|
||||
const p = particles.current[slot]!
|
||||
const node = pool.current[slot]
|
||||
const age = now - p.born
|
||||
// Staggered: a pulse's notes are dealt out over a few frames rather than
|
||||
// arriving as one ring, which reads as a burst instead of a shockwave.
|
||||
if (age < 0) continue
|
||||
if (age > FLIGHT_MS) {
|
||||
live.current.delete(slot)
|
||||
node?.setAttribute('opacity', '0')
|
||||
continue
|
||||
}
|
||||
const t = ease(age / FLIGHT_MS)
|
||||
// In falls from the rim to the mouth; out climbs the other way.
|
||||
const distance = p.out ? p.reach * t : p.reach * (1 - t)
|
||||
// A little swirl, so it reads as a throat rather than a drain pipe.
|
||||
const angle = p.angle + (p.out ? -1 : 1) * t * 0.9
|
||||
if (!node) continue
|
||||
node.setAttribute('cx', String(x0 + Math.cos(angle) * distance))
|
||||
// Flattened vertically to match the ellipses: the mouth is seen at an
|
||||
// angle, so a note's path has to lie in the same plane.
|
||||
node.setAttribute('cy', String(y0 + Math.sin(angle) * distance * squash))
|
||||
node.setAttribute('r', String(1.6 + p.scale * 2.2 * (p.out ? t : 1 - t)))
|
||||
// Fades at the far end either way: arriving notes vanish into the mouth,
|
||||
// leaving ones dissolve into the rest of the chain.
|
||||
node.setAttribute('opacity', String(p.out ? 1 - t * t : Math.min(1, (1 - t) * 2.4)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run frames until nothing is in flight, then stop completely.
|
||||
*
|
||||
* A still chain schedules no frames at all — the loop is started by a pulse
|
||||
* and ends itself. A page left open on a quiet chain costs nothing, which an
|
||||
* always-on `requestAnimationFrame` would not.
|
||||
*/
|
||||
const start = useRef<() => void>(() => {})
|
||||
start.current = () => {
|
||||
if (running.current || live.current.size === 0) return
|
||||
running.current = true
|
||||
const frame = () => {
|
||||
if (!mounted.current) {
|
||||
running.current = false
|
||||
return
|
||||
}
|
||||
step.current()
|
||||
if (live.current.size > 0) {
|
||||
requestAnimationFrame(frame)
|
||||
} else {
|
||||
running.current = false
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(frame)
|
||||
}
|
||||
|
||||
// Subscribe to pulses. Deliberately outside React state: `setLast` is the
|
||||
// only thing that re-renders, and it carries the caption, not the motion.
|
||||
useEffect(() => {
|
||||
return observer.onWormhole((pulse: WormholePulse) => {
|
||||
setLast(pulse)
|
||||
if (reduced) return
|
||||
|
||||
const spawn = (count: number, out: boolean, total: string) => {
|
||||
const draw = Math.min(count, PER_PULSE_CAP)
|
||||
if (draw === 0) return
|
||||
// Mean value of the notes this pulse stands in for, as a fraction of a
|
||||
// whole token, so a block moving thousands draws heavier marks than one
|
||||
// moving change. Crude on purpose: it is a size, not a figure.
|
||||
let mean = 0
|
||||
try {
|
||||
mean = Number(BigInt(total) / BigInt(count)) / 10 ** decimals
|
||||
} catch {
|
||||
mean = 0
|
||||
}
|
||||
const weight = Math.min(1, Math.max(0, Math.log10(Math.max(mean, 0.01)) + 2) / 4)
|
||||
for (let i = 0; i < draw; i += 1) {
|
||||
let slot = -1
|
||||
for (let s = 0; s < MAX_PARTICLES; s += 1) {
|
||||
if (!live.current.has(s)) {
|
||||
slot = s
|
||||
break
|
||||
}
|
||||
}
|
||||
// Pool exhausted: this note is not drawn. The caption still counts it.
|
||||
if (slot === -1) return
|
||||
live.current.add(slot)
|
||||
const p = particles.current[slot]!
|
||||
p.born = performance.now() + i * 45
|
||||
p.out = out
|
||||
p.angle = Math.random() * Math.PI * 2
|
||||
p.reach = geometry.current.rim * (0.75 + Math.random() * 0.35)
|
||||
p.scale = 0.6 + weight * 0.4
|
||||
}
|
||||
}
|
||||
|
||||
spawn(pulse.notesIn, false, pulse.amountIn)
|
||||
spawn(pulse.notesOut, true, pulse.amountOut)
|
||||
start.current()
|
||||
})
|
||||
}, [observer, reduced, decimals])
|
||||
|
||||
const { cx, cy } = geo
|
||||
|
||||
return (
|
||||
<div ref={wrap} className="stage-wrap">
|
||||
<svg
|
||||
className="chart stage"
|
||||
width={width}
|
||||
height={HEIGHT}
|
||||
viewBox={`0 0 ${width} ${HEIGHT}`}
|
||||
role="img"
|
||||
aria-label={
|
||||
last === null
|
||||
? 'The wormhole, waiting for the next block.'
|
||||
: `Block ${last.height} committed ${last.notesIn} notes and released ${last.notesOut}.`
|
||||
}
|
||||
>
|
||||
{/* The mouth. Three ellipses of the one hue at descending wash weight —
|
||||
a throat seen edge-on, not a second series. */}
|
||||
<ellipse className="stage-rim stage-rim-3" cx={cx} cy={cy} rx={rx} ry={ry} />
|
||||
<ellipse className="stage-rim stage-rim-2" cx={cx} cy={cy} rx={rx * 0.64} ry={ry * 0.64} />
|
||||
<ellipse className="stage-rim stage-rim-1" cx={cx} cy={cy} rx={rx * 0.33} ry={ry * 0.33} />
|
||||
<ellipse className="stage-core" cx={cx} cy={cy} rx={rx * 0.11} ry={ry * 0.11} />
|
||||
|
||||
{particles.current.map((_, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
className="stage-note"
|
||||
ref={(node) => {
|
||||
pool.current[i] = node
|
||||
}}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={2}
|
||||
opacity={0}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
<div className="stage-caption">
|
||||
{last === null ? (
|
||||
<span className="stage-idle">Watching for the next block.</span>
|
||||
) : (
|
||||
<>
|
||||
<strong>#{last.height.toLocaleString('en-US')}</strong>
|
||||
<span>
|
||||
{last.notesIn.toLocaleString('en-US')} in
|
||||
{last.notesIn > 0 && ` · ${tokens(last.amountIn, decimals, 1)} ${symbol}`}
|
||||
</span>
|
||||
<span>
|
||||
{last.notesOut.toLocaleString('en-US')} out
|
||||
{last.notesOut > 0 && ` · ${tokens(last.amountOut, decimals, 1)} ${symbol}`}
|
||||
</span>
|
||||
{reduced && <span className="stage-idle">motion off</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1032,6 +1032,75 @@ tr.mine .share-bar > i {
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
/* The live wormhole stage.
|
||||
*
|
||||
* Every value is one hue at a different wash weight — a throat seen edge-on,
|
||||
* not a set of series. Direction is carried by which way a note travels, the
|
||||
* same encoding the flow chart uses and for the same measured reason. */
|
||||
.stage-wrap {
|
||||
position: relative;
|
||||
background: var(--surface-1);
|
||||
border: var(--rule);
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: 26px;
|
||||
/* Matches the stat row it stands in for, so swapping one for the other does
|
||||
not move the page underneath. */
|
||||
padding: 6px 0 2px;
|
||||
}
|
||||
|
||||
.stage {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stage-rim {
|
||||
fill: none;
|
||||
stroke: var(--data);
|
||||
}
|
||||
|
||||
.stage-rim-3 {
|
||||
stroke-width: 1;
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
.stage-rim-2 {
|
||||
stroke-width: 1;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.stage-rim-1 {
|
||||
stroke-width: 1.5;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.stage-core {
|
||||
fill: var(--data-wash);
|
||||
stroke: var(--data);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.stage-note {
|
||||
fill: var(--data-bright);
|
||||
}
|
||||
|
||||
.stage-caption {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 18px;
|
||||
align-items: baseline;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
padding: 0 4px 4px;
|
||||
}
|
||||
|
||||
.stage-caption strong {
|
||||
color: var(--data-bright);
|
||||
}
|
||||
|
||||
.stage-idle {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* A meter: one ratio against its limit.
|
||||
*
|
||||
* The track is the same hue at wash weight rather than a neutral grey, so the
|
||||
|
||||
Reference in New Issue
Block a user