// ============================================================================ // Principia White Paper — Chart library (part 1: shared utils + crisis charts) // Hand-built SVG/CSS data visualizations. On-brand: navy / blue / accent, // restrained semantic accents (danger for decline, success for gains). // Exports to window for use by wp-sections.jsx + whitepaper-app.jsx. // ============================================================================ // ───── Data-viz palette ───── const C_INK = '#0A1F3D'; const C_BLUE = '#1C4A8F'; const C_ACCENT = '#3B7BC9'; const C_ACCENT2 = '#7FB0E6'; const C_DANGER = '#B83227'; const C_DANGER2 = '#D98379'; const C_SUCCESS = '#1F7A4D'; const C_MUTED = '#5B6B85'; const C_RULE = 'rgba(10,31,61,0.10)'; const C_RULE_STRONG = 'rgba(10,31,61,0.20)'; const C_PAPER = '#F4F6FB'; const C_SURF2 = '#F8FAFC'; const FONT_MONO = "'JetBrains Mono', ui-monospace, monospace"; const FONT_SANS = "'Source Sans 3', system-ui, sans-serif"; const FONT_DISP = "'Fraunces', Georgia, serif"; const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ───── Reveal hook — fires once when element scrolls into view ───── function useReveal(threshold = 0.22) { const ref = React.useRef(null); const [seen, setSeen] = React.useState(false); React.useEffect(() => { const el = ref.current; if (!el) return; const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (reduce || window.__WP_STATIC__) { setSeen(true); return; } const io = new IntersectionObserver((entries) => { entries.forEach((e) => { if (e.isIntersecting) { setSeen(true); io.disconnect(); } }); }, { threshold }); io.observe(el); return () => io.disconnect(); }, [threshold]); return [ref, seen]; } // ───── Count-up hook ───── function useCountUp(target, run, { duration = 1100, decimals = 0 } = {}) { const [val, setVal] = React.useState(0); React.useEffect(() => { if (!run) return; let raf, start; const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (reduce || window.__WP_STATIC__) { setVal(target); return; } const tick = (t) => { if (start == null) start = t; const p = Math.min((t - start) / duration, 1); const eased = 1 - Math.pow(1 - p, 3); setVal(target * eased); if (p < 1) raf = requestAnimationFrame(tick); else setVal(target); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [run, target, duration]); const f = decimals > 0 ? val.toFixed(decimals) : Math.round(val).toString(); return f; } // ───── Viewport hook — returns true when width ≤ breakpoint ───── function useIsMobile(bp = 640) { const [mobile, setMobile] = React.useState(() => window.innerWidth <= bp); React.useEffect(() => { const mq = window.matchMedia(`(max-width: ${bp}px)`); const onChange = (e) => setMobile(e.matches); mq.addEventListener('change', onChange); setMobile(mq.matches); return () => mq.removeEventListener('change', onChange); }, [bp]); return mobile; } // ───── Figure wrapper — figure number, title, the chart, source caption ───── function Figure({ n, title, sub, source, children, tint, style = {} }) { return (
FIG. {n}
{title}
{sub &&
{sub}
}
{children}
{source &&
{source}
}
); } // ───── Horizontal bar row (shared) ───── function HBar({ label, note, value, display, max, color, track, seen, delay = 0, height = 34, signed }) { const pct = Math.min(Math.abs(value) / max, 1) * 100; return (
{label}
{note &&
{note}
}
{display}
); } // ════════════════════════════════════════════════════════════════════════ // FIG — Correspondent banking decline by region (horizontal bars, decline) // ════════════════════════════════════════════════════════════════════════ function RegionalDeclineChart() { const [ref, seen] = useReveal(); const rows = [ { label: 'Global', value: -22, display: '−22%', color: C_DANGER2 }, { label: 'Latin America', value: -34, display: '−34%', color: '#C95B4E' }, { label: 'Caribbean', value: -41, display: '−41%', color: C_DANGER }, { label: 'Sub-Saharan Africa', note: 'mirrors Caribbean', value: -41, display: '≈−41%', color: C_DANGER }, ]; return (
{rows.map((r, i) => ( ))}
Decline in active correspondent relationships · since 2011
); } // ════════════════════════════════════════════════════════════════════════ // FIG — 5 of 192 UK banks willing to safeguard (unit / dot grid) // ════════════════════════════════════════════════════════════════════════ function BankAccessUnitChart() { const [ref, seen] = useReveal(0.15); const total = 192, hit = 5, cols = 24; const cells = Array.from({ length: total }); return (
{cells.map((_, i) => { const on = i < hit; return (
); })}
5 / 192
UK banks willing to hold safeguarding for the entire payment sector.
{['ClearBank', 'Griffin', 'Banking Circle', 'LHV', 'Starling'].map((b) => (
{b}
))}
None settle to Africa via SWIFT
); } // ════════════════════════════════════════════════════════════════════════ // FIG — Donut: 65% shortfall on EMI insolvency (≈35p recovered on the pound) // ════════════════════════════════════════════════════════════════════════ function ShortfallDonut() { const [ref, seen] = useReveal(); const R = 78, SW = 26, C = 2 * Math.PI * R; const lostFrac = 0.65; const lostLen = C * lostFrac; const big = useCountUp(65, seen, { duration: 1100 }); return (
{big}%
shortfall
); } function LegendRow({ swatch, label, value, sub }) { return (
{label}
{sub &&
{sub}
}
{value}
); } function MiniStat({ v, l }) { return (
{v}
{l}
); } // ════════════════════════════════════════════════════════════════════════ // FIG — Remittance cost: 8.78% today vs <3% SDG target vs our model // ════════════════════════════════════════════════════════════════════════ function RemittanceCostChart() { const [ref, seen] = useReveal(); const max = 10; const bars = [ { label: 'Send $200 to SSA', sub: 'today, global average', value: 8.78, display: '8.78%', color: C_DANGER }, { label: 'Typical MTO', sub: 'all-in to customer', value: 8.0, display: '8.00%', color: C_DANGER2 }, { label: 'On our rails', sub: 'settlement + payout', value: 2.65, display: '<3%', color: C_SUCCESS }, ]; const threshold = 3; return (
{/* SDG threshold line */}
SDG 10.c target · <3%
{bars.map((b, i) => (
{b.display}
))}
{bars.map((b) => (
{b.label}
{b.sub}
))}
); } // ════════════════════════════════════════════════════════════════════════ // FIG — Cost per transaction: intermediary hops vs direct rail (log scale) // ════════════════════════════════════════════════════════════════════════ function CostScaleChart() { const [ref, seen] = useReveal(); // log scale from €0.002 to €35 const lo = 0.002, hi = 35; const logPos = (v) => (Math.log10(v) - Math.log10(lo)) / (Math.log10(hi) - Math.log10(lo)) * 100; const ticks = [0.002, 0.02, 0.2, 2, 35]; const items = [ { label: 'Direct rail (TIPS)', range: [0.002, 0.49], display: '€0.002–£0.49', color: C_SUCCESS, side: 'our' }, { label: 'Intermediary hop', range: [15, 35], display: '$15–35 × 1–3 hops', color: C_DANGER, side: 'today' }, ]; return (
{items.map((it, i) => { const x1 = logPos(it.range[0]), x2 = logPos(it.range[1]); return (
{it.label} {it.display}
); })} {/* axis */}
{ticks.map((t) => (
{t < 1 ? '€' + t : '$' + t}
))}
cost per transaction · logarithmic scale
); } Object.assign(window, { C_INK, C_BLUE, C_ACCENT, C_ACCENT2, C_DANGER, C_DANGER2, C_SUCCESS, C_MUTED, C_RULE, C_RULE_STRONG, C_PAPER, C_SURF2, FONT_MONO, FONT_SANS, FONT_DISP, EASE, useReveal, useCountUp, useIsMobile, Figure, HBar, LegendRow, MiniStat, RegionalDeclineChart, BankAccessUnitChart, ShortfallDonut, RemittanceCostChart, CostScaleChart, });