feat(ui): show what goes into the wormhole and what comes out
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:
+60
@@ -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"
|
||||
}
|
||||
-42
@@ -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"
|
||||
}
|
||||
@@ -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**:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'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'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 }}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user