// ============================================================================
// 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 (
);
}
// ════════════════════════════════════════════════════════════════════════
// 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 (
);
}
function LegendRow({ swatch, label, value, sub }) {
return (
);
}
function MiniStat({ v, l }) {
return (
);
}
// ════════════════════════════════════════════════════════════════════════
// 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) => (
))}
);
}
// ════════════════════════════════════════════════════════════════════════
// 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 (
);
})}
{/* 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,
});