feat(ui): show what goes into the wormhole and what comes out
deploy / build (push) Successful in 7m33s
deploy / deploy-web (push) Successful in 6s
deploy / deploy-api (push) Successful in 15s

The wormhole page counted the pool but never drew the flow through it. It now
leads with a meter — 42% of every note ever created is still unspent — and a
per-day chart of value arriving against value leaving.

Direction is read from the producing extrinsic, never from the sender, which
is the same sentinel whichever way the value is going: anything settled by
`verify_public_batch` or `verify_private_batch` is leaving, everything else is
arriving. `entered + left` reconciles to the day's total exactly.

**The chart is diverging with a single hue.** In and out want two hues either
side of a neutral midpoint, and this site has one data colour. The two-shade
alternative was measured rather than assumed and fails outright — `#bd8829`
against `#e0a63a` scores a normal-vision ΔE of 9.9, under the 15 floor. So
direction is carried by which side of the zero rule a bar sits on, which
survives greyscale, print, forced-colors and every form of colour vision, and
leaves no categorical pair to validate.

Rendering it is what found the rest. Same hue plus touching bars drew one
continuous mark through the rule and the encoding disappeared, so a surface gap
is held open at zero. Five days across a full-width panel drew 170px slabs, so
bar width is capped and the columns space out instead. And `rx` rounds all four
corners of a rect, so bars read as detached lozenges until they became paths
with rounded data-ends only.

Ships with the hover layer, direct labels on the busiest day each way, and a
visually-hidden table so the series exists outside the picture.

Refs #17

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:06:57 +03:00
co-authored by Claude Opus 5
parent f81ebd4746
commit 864a76ffb6
10 changed files with 666 additions and 50 deletions
@@ -0,0 +1,60 @@
{
"db_name": "PostgreSQL",
"query": "\n with span as (\n select generate_series(\n date_trunc('day', now()) - make_interval(days => $3::int - 1),\n date_trunc('day', now()),\n interval '1 day'\n ) as day\n ),\n moves as (\n -- Direction is a fact about the producing extrinsic, never\n -- about the event: a settled batch is value leaving the pool,\n -- while an ordinary transfer or a mining reward is value\n -- arriving in it. The recorded sender is no help — it is the\n -- same sentinel for both.\n select date_trunc('day', e.at) as day,\n e.fields->>'from' = $2 as hidden,\n (e.fields->>'amount')::numeric as amount,\n (x.pallet = 'Wormhole' and x.call in\n ('verify_public_batch', 'verify_private_batch')) as is_exit\n from chain_event e\n left join chain_extrinsic x\n on x.chain = e.chain\n and x.height = e.height\n and x.extrinsic_index = e.extrinsic_index\n where e.chain = $1 and e.at is not null\n and e.pallet = 'Wormhole' and e.variant = 'NativeTransferred'\n )\n select span.day as \"day!\",\n coalesce((select count(*) from moves m where m.day = span.day), 0) as \"notes!\",\n coalesce((select sum(amount) from moves m where m.day = span.day), 0) as \"amount!\",\n coalesce((select count(*) from moves m\n where m.day = span.day and m.hidden), 0) as \"hidden!\",\n coalesce((select sum(amount) from moves m\n where m.day = span.day and not coalesce(m.is_exit, false)), 0) as \"entered!\",\n coalesce((select sum(amount) from moves m\n where m.day = span.day and coalesce(m.is_exit, false)), 0) as \"left!\",\n coalesce((select count(*) from moves m\n where m.day = span.day and coalesce(m.is_exit, false)), 0) as \"exits!\"\n from span\n order by span.day asc\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "day!",
"type_info": "Timestamptz"
},
{
"ordinal": 1,
"name": "notes!",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "amount!",
"type_info": "Numeric"
},
{
"ordinal": 3,
"name": "hidden!",
"type_info": "Int8"
},
{
"ordinal": 4,
"name": "entered!",
"type_info": "Numeric"
},
{
"ordinal": 5,
"name": "left!",
"type_info": "Numeric"
},
{
"ordinal": 6,
"name": "exits!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int4"
]
},
"nullable": [
null,
null,
null,
null,
null,
null,
null
]
},
"hash": "877f86208531b98aae00c6d902cb92c9de2b982d88ceb2752363003f2e0d24eb"
}
@@ -1,42 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n with span as (\n select generate_series(\n date_trunc('day', now()) - make_interval(days => $3::int - 1),\n date_trunc('day', now()),\n interval '1 day'\n ) as day\n ),\n moves as (\n select date_trunc('day', at) as day,\n fields->>'from' = $2 as hidden,\n (fields->>'amount')::numeric as amount\n from chain_event\n where chain = $1 and at is not null\n and pallet = 'Wormhole' and variant = 'NativeTransferred'\n )\n select span.day as \"day!\",\n coalesce((select count(*) from moves m where m.day = span.day), 0) as \"notes!\",\n coalesce((select sum(amount) from moves m where m.day = span.day), 0) as \"amount!\",\n coalesce((select count(*) from moves m\n where m.day = span.day and m.hidden), 0) as \"hidden!\"\n from span\n order by span.day asc\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "day!",
"type_info": "Timestamptz"
},
{
"ordinal": 1,
"name": "notes!",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "amount!",
"type_info": "Numeric"
},
{
"ordinal": 3,
"name": "hidden!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int4"
]
},
"nullable": [
null,
null,
null,
null
]
},
"hash": "9c577458e595877d421482f1835df795487fc67e77a796eb387f265532e9a23c"
}
+27
View File
@@ -750,6 +750,33 @@ 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.
**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
direction by *which side of the zero rule a bar sits on* and paints both sides
the same bronze. That is a stronger encoding than hue anyway: it survives
greyscale, print, `forced-colors` and every form of colour vision, and it leaves
no categorical pair to validate.
The two-shade alternative was **measured, not assumed**, and it fails:
`#bd8829` against `#e0a63a` scores a normal-vision ΔE of **9.9**, under the 15
floor, which is a hard fail no legend excuses. Do not give `.flow-in` and
`.flow-out` different fills.
Three things make the geometry actually readable, and the chart was wrong
without each of them — all three were caught by rendering it and looking, not by
review:
- **A surface gap at zero.** Same hue plus touching bars draws one continuous
mark straight through the rule, so the encoding vanishes exactly where it has
to work. `ZERO_GAP` holds the house's 2px apart and the rule sits in it.
- **A width cap.** Five days across a full-width panel drew 170px slabs — the
"thick saturated blocks" anti-pattern, wide enough to bury the rule.
`MAX_BAR_WIDTH` keeps marks thin and spaces the columns out instead.
- **Paths, not rects.** `rx` rounds all four corners, so the end meeting the
baseline rounded too and every bar read as a detached lozenge. The mark spec
wants rounded *data-ends*; only a path does that.
`web/src/index.css` documents the validated values. Re-run the validator after
touching them — **both modes, each against its own surface**:
+3
View File
@@ -845,6 +845,9 @@ async fn wormhole(
notes: d.notes,
hidden: d.hidden,
amount: BigUintDec(d.amount),
entered: BigUintDec(d.entered),
left: BigUintDec(d.left),
exits: d.exits,
})
.collect();
let scan = state
+36 -7
View File
@@ -125,6 +125,15 @@ pub struct DailyWormhole {
pub hidden: u64,
/// Summed amount.
pub amount: String,
/// Value that entered the pool: everything not settled by an exit — an
/// ordinary transfer to a wormhole address, or a mining reward credited at
/// block initialisation.
pub entered: String,
/// Value an exit released into a spendable account, settled by
/// `verify_public_batch` or `verify_private_batch`.
pub left: String,
/// How many transfers made up `left`.
pub exits: u64,
}
/// What an account has received and sent, over the indexed range.
@@ -2280,18 +2289,35 @@ impl Store {
) as day
),
moves as (
select date_trunc('day', at) as day,
fields->>'from' = $2 as hidden,
(fields->>'amount')::numeric as amount
from chain_event
where chain = $1 and at is not null
and pallet = 'Wormhole' and variant = 'NativeTransferred'
-- Direction is a fact about the producing extrinsic, never
-- about the event: a settled batch is value leaving the pool,
-- while an ordinary transfer or a mining reward is value
-- arriving in it. The recorded sender is no help — it is the
-- same sentinel for both.
select date_trunc('day', e.at) as day,
e.fields->>'from' = $2 as hidden,
(e.fields->>'amount')::numeric as amount,
(x.pallet = 'Wormhole' and x.call in
('verify_public_batch', 'verify_private_batch')) as is_exit
from chain_event e
left join chain_extrinsic x
on x.chain = e.chain
and x.height = e.height
and x.extrinsic_index = e.extrinsic_index
where e.chain = $1 and e.at is not null
and e.pallet = 'Wormhole' and e.variant = 'NativeTransferred'
)
select span.day as "day!",
coalesce((select count(*) from moves m where m.day = span.day), 0) as "notes!",
coalesce((select sum(amount) from moves m where m.day = span.day), 0) as "amount!",
coalesce((select count(*) from moves m
where m.day = span.day and m.hidden), 0) as "hidden!"
where m.day = span.day and m.hidden), 0) as "hidden!",
coalesce((select sum(amount) from moves m
where m.day = span.day and not coalesce(m.is_exit, false)), 0) as "entered!",
coalesce((select sum(amount) from moves m
where m.day = span.day and coalesce(m.is_exit, false)), 0) as "left!",
coalesce((select count(*) from moves m
where m.day = span.day and coalesce(m.is_exit, false)), 0) as "exits!"
from span
order by span.day asc
"#,
@@ -2308,6 +2334,9 @@ impl Store {
day: r.day,
notes: r.notes as u64,
hidden: r.hidden as u64,
entered: r.entered.to_string(),
left: r.left.to_string(),
exits: r.exits as u64,
amount: r.amount.to_string(),
})
.collect())
+19
View File
@@ -612,4 +612,23 @@ pub struct DailyWormhole {
pub hidden: u64,
/// Summed amount.
pub amount: BigUintDec,
/// Value that arrived in the pool — an ordinary transfer to a wormhole
/// address, or a mining reward credited at block initialisation.
///
/// Direction is read from the **producing extrinsic**, never from the
/// recorded sender, which is the same sentinel whichever way the value is
/// going.
pub entered: BigUintDec,
/// Value an exit released into a spendable account, settled by
/// `verify_public_batch` or `verify_private_batch`.
///
/// Worth knowing before this is read as a drain: an exit *also* inserts a
/// fresh leaf for its recipient, so value that leaves is immediately
/// representable again. The two are not a closed in/out ledger, and the
/// page says so.
pub left: BigUintDec,
/// How many transfers made up `left`.
#[ts(type = "number")]
pub exits: u64,
}
+24 -1
View File
@@ -20,4 +20,27 @@ hidden: number,
/**
* Summed amount.
*/
amount: BigUintDec, };
amount: BigUintDec,
/**
* Value that arrived in the pool — an ordinary transfer to a wormhole
* address, or a mining reward credited at block initialisation.
*
* Direction is read from the **producing extrinsic**, never from the
* recorded sender, which is the same sentinel whichever way the value is
* going.
*/
entered: BigUintDec,
/**
* Value an exit released into a spendable account, settled by
* `verify_public_batch` or `verify_private_batch`.
*
* Worth knowing before this is read as a drain: an exit *also* inserts a
* fresh leaf for its recipient, so value that leaves is immediately
* representable again. The two are not a closed in/out ledger, and the
* page says so.
*/
left: BigUintDec,
/**
* How many transfers made up `left`.
*/
exits: number, };
+389
View File
@@ -0,0 +1,389 @@
/**
* What entered the wormhole each day, and what left it.
*
* A **diverging bar chart with no second hue.** In and out are opposite
* directions, which is the textbook case for a diverging palette — two hues
* either side of a neutral midpoint. This site cannot have one: bronze and
* crimson are adjacent hues that fail CVD separation as a categorical pair, so
* every chart here is single-series (see `index.css` and `CLAUDE.md`).
*
* The resolution is that **direction is carried by position, not by colour**.
* A bar above the zero rule arrived; one below it left. That is a stronger
* encoding than hue anyway — it survives greyscale, print, forced-colors and
* every form of colour vision — and it means the chart has no categorical pair
* to validate. One hue, one axis, magnitude by length.
*
* One axis is load-bearing: both directions are the same measure in the same
* unit, scaled symmetrically about zero. Two scales would invent a relationship
* between arrivals and departures that is not in the data.
*
* Inline SVG for the same reason `ShareChart` is: the whole thing is a handful
* of rects and two rules, and a charting library would be a second set of
* theming rules to keep in step with the custom properties above it.
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import type { DailyWormhole } from '../api/generated/DailyWormhole'
import { tokens } from '../lib/format'
const HEIGHT = 260
const PAD = { top: 20, right: 16, bottom: 34, left: 62 }
/** Used until the container has been measured, and if ResizeObserver is absent. */
const FALLBACK_WIDTH = 720
/** The 2px surface gap the house style puts between adjacent fills. */
const BAR_GAP = 2
/** Rounded data-ends, per the mark spec. Clamped for bars shorter than this. */
const BAR_RADIUS = 4
/**
* Widest a single day may be drawn.
*
* Without it a short window spreads five days across a full-width panel and
* draws 170px slabs — the "thick saturated blocks" anti-pattern, and wide
* enough that the zero rule disappears under them and the two directions read
* as one mass. Marks stay thin; the columns space out instead.
*/
const MAX_BAR_WIDTH = 34
/**
* Half the surface gap held open at the zero rule.
*
* Load-bearing, not decoration. The two directions wear the same hue, so a day
* whose bars meet at zero draws as one continuous mark running from below the
* rule to above it — the direction encoding disappears exactly where it has to
* work. The house style already asks for a 2px surface gap between adjacent
* fills; this is that gap, and it is what lets the rule be seen between them.
*/
const ZERO_GAP = 1.5
interface Placed {
day: DailyWormhole
/** Left edge of the day's column. */
x: number
w: number
/** Value in, as a float of whole tokens. */
inValue: number
outValue: 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 observer = new ResizeObserver(([entry]) => {
if (entry) setWidth(Math.max(320, entry.contentRect.width))
})
observer.observe(node)
return () => observer.disconnect()
}, [])
return [ref, width]
}
/**
* A raw amount as a float of whole tokens.
*
* Only ever used to decide how tall to draw a rectangle. Every figure a reader
* actually sees goes through `tokens`, which is exact — a chain amount is a
* 128-bit integer and `Number` loses it above 2^53, so this is safe for
* geometry and nothing else.
*/
function scaled(raw: string, decimals: number): number {
const n = Number(raw)
if (!Number.isFinite(n)) return 0
return n / 10 ** decimals
}
function dayLabel(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
/** An axis figure: thousands, rounded, no decimals — the tooltip carries exact. */
function axisValue(v: number): string {
const abs = Math.abs(v)
if (abs >= 1000) return `${Math.round(abs / 100) / 10}k`
if (abs >= 10) return String(Math.round(abs))
return String(Math.round(abs * 10) / 10)
}
export function FlowChart({
days,
decimals,
symbol,
}: {
days: DailyWormhole[]
decimals: number
symbol: string
}) {
const [wrap, width] = useMeasuredWidth()
const [hover, setHover] = useState<Placed | null>(null)
const chart = useMemo(() => {
// Days before anything was indexed are not zero-value days, they are days
// with no data — drawing them as an empty run implies the pool was idle.
const firstActive = days.findIndex((d) => d.entered !== '0' || d.left !== '0')
const shown = firstActive === -1 ? [] : days.slice(firstActive)
if (shown.length === 0) return null
const plotW = width - PAD.left - PAD.right
const plotH = HEIGHT - PAD.top - PAD.bottom
// Symmetric about zero: the same length means the same amount whichever
// way it points, which is the whole claim a diverging chart makes.
const peak = Math.max(
...shown.map((d) => Math.max(scaled(d.entered, decimals), scaled(d.left, decimals))),
1,
)
const top = peak * 1.1
const zero = PAD.top + plotH / 2
const half = plotH / 2
const slot = plotW / shown.length
const w = Math.min(MAX_BAR_WIDTH, Math.max(2, slot - BAR_GAP))
const placed: Placed[] = shown.map((day, i) => ({
day,
// Centred in its slot rather than left-aligned: once the width is capped
// the two stop being the same thing, and a left-aligned run of narrow
// bars drifts away from its own axis labels.
x: PAD.left + i * slot + (slot - w) / 2,
w,
inValue: scaled(day.entered, decimals),
outValue: scaled(day.left, decimals),
}))
const h = (v: number) => (v / top) * half
// Ends and middle only; more collide on a narrow panel and the tooltip
// carries the exact day for any bar.
const xTicks = [0, Math.floor((shown.length - 1) / 2), shown.length - 1]
.filter((i, n, a) => a.indexOf(i) === n)
.map((i) => ({ i, p: placed[i] }))
.filter((t): t is { i: number; p: Placed } => t.p !== undefined)
const peakIn = placed.reduce((a, b) => (b.inValue > a.inValue ? b : a), placed[0]!)
const peakOut = placed.reduce((a, b) => (b.outValue > a.outValue ? b : a), placed[0]!)
const totalIn = placed.reduce((s, p) => s + p.inValue, 0)
const totalOut = placed.reduce((s, p) => s + p.outValue, 0)
return { placed, zero, half, top, h, xTicks, peakIn, peakOut, totalIn, totalOut }
}, [days, width, decimals])
if (!chart) {
return <p className="empty">Nothing has moved through the wormhole in this window yet.</p>
}
const onMove = (event: React.PointerEvent<SVGSVGElement>) => {
const rect = event.currentTarget.getBoundingClientRect()
const local = event.clientX - rect.left
let nearest: Placed | null = null
let best = Infinity
for (const p of chart.placed) {
// Distance to the column's centre, so the hit target is the whole slot
// rather than the drawn bar — a near-zero day is still reachable.
const d = Math.abs(p.x + p.w / 2 - local)
if (d < best) {
best = d
nearest = p
}
}
setHover(nearest)
}
/**
* One bar, as a path rather than a rect.
*
* The mark spec asks for rounded *data-ends* anchored to the baseline. An
* SVG `rect` cannot do that — `rx` rounds all four corners, so the end that
* meets the zero rule gets a radius too and each bar reads as a detached
* lozenge with a notch where it should be anchored. A path rounds the two
* corners at the far end and leaves the baseline square.
*
* A bar shorter than its own radius takes a smaller one rather than curling.
*/
const barPath = (x: number, w: number, height: number, up: boolean): string => {
const h = Math.max(height, 0)
if (h <= 0) return ''
const r = Math.min(BAR_RADIUS, w / 2, h)
const s = up ? 1 : -1
const y0 = chart.zero - s * ZERO_GAP
const y1 = y0 - s * h
return [
`M${x} ${y0}`,
`L${x} ${y1 + s * r}`,
`Q${x} ${y1} ${x + r} ${y1}`,
`L${x + w - r} ${y1}`,
`Q${x + w} ${y1} ${x + w} ${y1 + s * r}`,
`L${x + w} ${y0}`,
'Z',
].join(' ')
}
const gridLines = [1, 0.5, -0.5, -1].map((f) => ({
f,
y: chart.zero - f * chart.half,
label: axisValue(chart.top * Math.abs(f)),
}))
return (
<div ref={wrap} style={{ position: 'relative' }}>
<svg
className="chart"
width={width}
height={HEIGHT}
viewBox={`0 0 ${width} ${HEIGHT}`}
role="img"
aria-label={`Value through the wormhole each day. Over this window ${tokens(
String(Math.round(chart.totalIn)),
0,
0,
)} ${symbol} arrived and ${tokens(String(Math.round(chart.totalOut)), 0, 0)} ${symbol} left. Bars above the centre rule arrived, bars below it left.`}
onPointerMove={onMove}
onPointerLeave={() => setHover(null)}
>
{gridLines.map((g) => (
<g key={g.f}>
<line className="grid" x1={PAD.left} x2={width - PAD.right} y1={g.y} y2={g.y} />
<text className="chart-axis" x={PAD.left - 8} y={g.y + 3.5} textAnchor="end">
{g.label}
</text>
</g>
))}
<text className="chart-axis" x={PAD.left - 8} y={chart.zero + 3.5} textAnchor="end">
0
</text>
{/* Direction, said in words. The whole design rests on side-of-the-rule
carrying what a second hue would, so it is labelled rather than
left to be inferred. */}
<text className="chart-axis" x={width - PAD.right} y={PAD.top - 7} textAnchor="end">
in ↑
</text>
<text
className="chart-axis"
x={width - PAD.right}
y={HEIGHT - PAD.bottom + 14}
textAnchor="end"
>
out ↓
</text>
{chart.placed.map((p) => (
<g key={p.day.day}>
<path className="flow-in" d={barPath(p.x, p.w, chart.h(p.inValue), true)} />
<path className="flow-out" d={barPath(p.x, p.w, chart.h(p.outValue), false)} />
</g>
))}
{/* The zero rule is drawn last so it sits *on* the bars. It is what
makes a bar's side meaningful, and a spine hidden behind the marks
it anchors is no spine at all. */}
<line
className="flow-zero"
x1={PAD.left}
x2={width - PAD.right}
y1={chart.zero}
y2={chart.zero}
/>
{hover && (
<rect
className="flow-hover"
x={hover.x - BAR_GAP / 2}
y={PAD.top}
width={hover.w + BAR_GAP}
height={HEIGHT - PAD.top - PAD.bottom}
/>
)}
{chart.xTicks.map((t, i) => (
<text
key={t.i}
className="chart-axis"
x={t.p.x + t.p.w / 2}
y={HEIGHT - 8}
textAnchor={i === 0 ? 'start' : i === chart.xTicks.length - 1 ? 'end' : 'middle'}
>
{dayLabel(t.p.day.day)}
</text>
))}
{/* Two direct labels — the busiest day each way. Selective, per the
mark spec: a number on every bar would go unread. */}
{!hover && (
<>
<text
className="chart-axis"
x={chart.peakIn.x + chart.peakIn.w / 2}
y={Math.max(chart.zero - chart.h(chart.peakIn.inValue) - 6, PAD.top + 9)}
textAnchor="middle"
style={{ fill: 'var(--data-bright)', fontSize: 11 }}
>
{axisValue(chart.peakIn.inValue)}
</text>
<text
className="chart-axis"
x={chart.peakOut.x + chart.peakOut.w / 2}
y={Math.min(
chart.zero + chart.h(chart.peakOut.outValue) + 13,
HEIGHT - PAD.bottom - 2,
)}
textAnchor="middle"
style={{ fill: 'var(--data-bright)', fontSize: 11 }}
>
{axisValue(chart.peakOut.outValue)}
</text>
</>
)}
</svg>
{hover && (
<div
className="tooltip"
style={{
left: hover.x,
top: 4,
transform: hover.x > width / 2 ? 'translate(-100%, 0)' : 'translate(10px, 0)',
}}
>
<div className="tooltip-label">{dayLabel(hover.day.day)}</div>
<div>
<strong>{tokens(hover.day.entered, decimals, 1)}</strong> {symbol} in
</div>
<div>
<strong>{tokens(hover.day.left, decimals, 1)}</strong> {symbol} out
{hover.day.exits > 0 && `, over ${hover.day.exits.toLocaleString('en-US')} exits`}
</div>
<div style={{ color: 'var(--text-secondary)' }}>
{hover.day.notes.toLocaleString('en-US')} notes created
</div>
</div>
)}
{/* The table view the chart's numbers would otherwise only exist in.
Visually hidden rather than absent: a screen reader, a text browser and
a copy-paste all reach the series this way, and the summary in
`aria-label` is a summary rather than the data. */}
<table className="visually-hidden">
<caption>Value through the wormhole each day</caption>
<thead>
<tr>
<th scope="col">Day</th>
<th scope="col">In ({symbol})</th>
<th scope="col">Out ({symbol})</th>
<th scope="col">Exits</th>
</tr>
</thead>
<tbody>
{chart.placed.map((p) => (
<tr key={p.day.day}>
<th scope="row">{dayLabel(p.day.day)}</th>
<td>{tokens(p.day.entered, decimals, 1)}</td>
<td>{tokens(p.day.left, decimals, 1)}</td>
<td>{p.day.exits}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
+57
View File
@@ -17,6 +17,7 @@
import { useEffect, useState } from 'react'
import type { WormholeSummary } from '../api/generated/WormholeSummary'
import { FlowChart } from './FlowChart'
import { RequestFailed, fetchWormhole } from '../api/rest'
import { height as fmtHeight, tokens } from '../lib/format'
@@ -143,6 +144,14 @@ export function WormholePanel({
return Number((machinery * 1000n) / total) / 10
})()
// Notes still inside, as a share of every note ever created. A whole number:
// the figure is a headline, and a decimal place on it would imply a precision
// the ceiling-limited nullifier count cannot promise.
const unspentShare =
outstanding !== null && data.notes_created !== null && data.notes_created > 0
? Math.round((outstanding / data.notes_created) * 100)
: null
// Mean credit, computed from the summed amount rather than sent per-row: the
// difference between the machinery's average and a payee's is most of what
// separates them, and rounding it away would flatten the table.
@@ -204,6 +213,54 @@ export function WormholePanel({
A zero under proofs rejected is a finding rather than a gap: it says no proof has ever
been refused, which is a different statement from nobody having tried.
</p>
{unspentShare !== null && (
<>
{/* A single ratio against a limit, so a meter rather than a chart:
one bar, one hue, the track being the same hue at wash weight.
A two-slice pie of the same fact would be the anti-pattern. */}
<div
className="meter"
role="img"
aria-label={`${unspentShare}% of every note ever created is still unspent`}
>
<div className="meter-fill" style={{ width: `${unspentShare}%` }} />
</div>
<p className="panel-note">
<strong>{unspentShare}% of every note ever created is still unspent.</strong> That is
the standing claim on this chain&apos;s privacy: value already committed, whose
movement — when it comes — will name a recipient and no sender.
</p>
</>
)}
</section>
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">What goes in, what comes out</h2>
<span className="eyebrow">{symbol} per day · this observer&apos;s index</span>
</div>
<FlowChart days={data.days} decimals={decimals} symbol={symbol} />
<p className="panel-note">
Bars above the rule arrived in the pool; bars below it left. There is no second colour
because this site has one data hue, and the two-shade alternative was measured rather than
assumed — the pair scores a normal-vision separation well under the floor, so direction is
carried by <em>side of the rule</em>, which no reader can fail to see.
</p>
<p className="panel-note">
<strong>Direction is read from the extrinsic that produced each transfer</strong>, never
from the sender, which is the same sentinel whichever way the value is going. Anything
settled by <code>verify_public_batch</code> or <code>verify_private_batch</code> is value
leaving; everything else — an ordinary transfer, a mining reward at block initialisation —
is value arriving.
</p>
<p className="panel-note">
Two things stop this being a closed ledger, and both are worth holding in mind. An exit{' '}
<em>also</em> inserts a fresh note for whoever it credits, so value that leaves is
immediately committed again — the pool is not drained by its own outflow. And the inbound
side is dominated by the chain paying its miners rather than by anyone choosing to
deposit, because every transfer on this chain is recorded as a note whether its owner
wanted privacy or not.
</p>
</section>
<section className="panel" style={{ marginBottom: 26 }}>
+51
View File
@@ -1032,6 +1032,57 @@ tr.mine .share-bar > i {
stroke-width: 2;
}
/* A meter: one ratio against its limit.
*
* The track is the same hue at wash weight rather than a neutral grey, so the
* whole figure reads as one quantity partly filled instead of as two competing
* categories. Same reason the site has no two-slice pies. */
.meter {
height: 8px;
background: var(--data-wash);
border: 1px solid var(--border);
margin: 18px 0 14px;
overflow: hidden;
}
.meter-fill {
height: 100%;
background: var(--data);
/* Rounded data-end, anchored to the track's start, per the mark spec. */
border-radius: 0 4px 4px 0;
}
/* Flow bars: both directions wear the SAME hue.
*
* In and out are opposite directions, which normally calls for a diverging
* palette. This site has one data hue, and the two-shade alternative was
* measured rather than assumed: `#bd8829` against `#e0a63a` scores a
* normal-vision ΔE of 9.9, well under the 15 floor — full-colour readers
* cannot reliably tell them apart, so it is a hard fail and not something a
* legend would excuse. Direction is therefore carried entirely by which side
* of the zero rule a bar sits on, which survives greyscale, print,
* forced-colors and every form of colour vision. Do not give these two
* different fills. */
.chart .flow-in,
.chart .flow-out {
fill: var(--data);
}
/* The chart's spine, drawn over the bars it anchors. A step stronger than the
* grid, because which side of it a bar sits on is the entire direction
* encoding. */
.chart .flow-zero {
stroke: var(--border-strong);
stroke-width: 1;
}
/* The hovered column, behind its bars: a wash rather than an outline, so the
* hit target reads without drawing a border around the marks. */
.chart .flow-hover {
fill: var(--data-wash);
pointer-events: none;
}
.chart-axis {
font-family: var(--font-mono);
font-size: 10px;