'use client' import { useEffect, useMemo, useState } from 'react' import { motion, AnimatePresence, useReducedMotion, animate } from 'framer-motion' import { RotateCw } from 'lucide-react' import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions' import { useBasReference } from '@/lib/bookkeeping/use-bas-reference' import { useTranslations } from 'next-intl' import { formatAmount, formatCurrency } from '@/lib/utils' import type { DeepEntity, DeepLedgerContext } from '@/lib/agent-context/ledger-deep' import { entityMagnitude, selectAccountRing, type RingBasis } from './ledger-graph-magnitude' /** * "Reconciliation Aurora" - the cinematic hero of the "Vad din agent vet" page. * * A self-contained dark SVG panel. The company is a luminous hub; the BAS * accounts it books to form an inner ring of wedges (each wedge sized by the * money that flows through it); the counterparties/suppliers live in the outer * band, each constrained to its dominant account's angular slice so threads can * never cross between accounts (the anti-hairball guarantee). * * Four orthogonal channels, so no two compete: * - node AREA = magnitude (radius = k·√x), see ledger-graph-magnitude.ts * - node COLOUR = booking cadence (weekly / monthly / irregular) - the one * semantic axis; everything else stays achromatic * - node SHAPE = kind (supplier = filled, counterparty = open ring) * - node FOCUS = the agent's confidence in the account mapping, rendered as * optical depth of field (sure = crisp/forward, unsure = soft) * * On mount it performs the signature act: for the most-merged payees, the raw * bank descriptors ("CLAUDE.AI", "Anthropic PBC", "claude.ai*sub") fly in as * ghost chips and magnetically collapse into one named node with a "×N" badge - * the agent resolving chaos into knowledge, live, in front of the customer. * * Deterministic (seeded jitter, fixed input order) so the demo looks identical * on every load. Motion is CSS-driven (single-clock idle, GPU dash pulses) with * framer only orchestrating the entrance, the merge and the hover card. Fully * keyboard-navigable; a visually-hidden table carries the payload for readers; * honours prefers-reduced-motion (jumps straight to the settled state). */ const W = 1000 const H = 1000 const CX = W / 2 const CY = H / 2 const R_HUB = 34 const R_ACCOUNT = 178 const R_PAYEE_MIN = 258 const R_PAYEE_MAX = 432 const WEDGE_GAP = 0.07 // radians of padding between account wedges // This panel is its own dark world regardless of the app theme, so the depth of // field, the glow and the cadence hues all read. Achromatic chrome; colour only // ever means cadence. const INK = '#0a0a0c' const PAPER = '#ecebe6' const HAIR = 'rgba(236,235,230,0.13)' const HAIR_STRONG = 'rgba(236,235,230,0.30)' const MUTED = 'rgba(236,235,230,0.52)' const CAD: Record = { weekly: '#e0895f', // terracotta - fast, recurring monthly: '#d7a648', // ochre - monthly irregular: '#83a98d', // sage - one-off / irregular } // Depth-of-field buckets: stdDeviation in viewBox units (~0.64× on screen). const DOF = [0, 1.7, 3.4, 5.2] type Cadence = 'weekly' | 'monthly' | 'irregular' interface Payee { id: string entity: DeepEntity accountNumber: string x: number y: number r: number cadence: Cadence bucket: number // depth-of-field bucket index into DOF thread: string // svg path from hub to node, bowed through the account anchor labelRight: boolean revealDelay: number merge: boolean // show the ghost-descriptor collapse on mount chips: string[] } interface Account { number: string name: string | null x: number y: number midAngle: number arc: string revealDelay: number } interface Model { accounts: Account[] payees: Payee[] truncated: boolean /** What the wedge widths and node areas actually measure. See the legend. */ basis: RingBasis totals: { tx: number; payees: number; accounts: number } } function polar(cx: number, cy: number, r: number, a: number) { return { x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) } } function arcPath(cx: number, cy: number, r: number, a0: number, a1: number) { const s = polar(cx, cy, r, a0) const e = polar(cx, cy, r, a1) const large = a1 - a0 > Math.PI ? 1 : 0 return `M ${s.x.toFixed(2)} ${s.y.toFixed(2)} A ${r} ${r} 0 ${large} 1 ${e.x.toFixed(2)} ${e.y.toFixed(2)}` } function lerp(a: number, b: number, t: number) { return a + (b - a) * t } // Deterministic [0,1) hash of a string (xmur3 → mulberry32), so seeded jitter is // byte-identical on every render: the live demo never reshuffles. function rand01(seed: string): number { let h = 1779033703 ^ seed.length for (let i = 0; i < seed.length; i++) { h = Math.imul(h ^ seed.charCodeAt(i), 3432918353) h = (h << 13) | (h >>> 19) } h = Math.imul(h ^ (h >>> 16), 2246822507) h = Math.imul(h ^ (h >>> 13), 3266489909) h ^= h >>> 16 return (h >>> 0) / 4294967296 } function cadenceOf(e: DeepEntity): Cadence { const cd = e.cadence_days if (cd === null || e.occurrences < 3) return 'irregular' if (cd >= 4 && cd <= 10) return 'weekly' if (cd > 10 && cd <= 45) return 'monthly' return 'irregular' } function bucketOf(share: number | null): number { if (share === null) return 3 if (share >= 0.85) return 0 if (share >= 0.7) return 1 if (share >= 0.5) return 2 return 3 } // Center-out slot order: biggest spender sits at the wedge's angular centre. function centerOut(n: number): number[] { const mid = (n - 1) / 2 return Array.from({ length: n }, (_, i) => i).sort( (a, b) => Math.abs(a - mid) - Math.abs(b - mid), ) } function buildModel(deep: DeepLedgerContext): Model { const all: DeepEntity[] = [ ...(deep?.counterparty_entities ?? []), ...(deep?.supplier_entities ?? []), ].filter((e) => e.dominant_account_number) const totalTx = all.reduce((s, e) => s + e.occurrences, 0) // Grouping, weighting, ranking and truncation of the account ring live in // ledger-graph-magnitude.ts so they can be unit tested. The one rule that // matters here: every wedge weight below is expressed in the SAME unit // (`ring.basis`), so the shared denominator never adds kronor to booking // counts and no account is squeezed to an invisible sliver, or truncated // away entirely, just for being measured differently. const ring = selectAccountRing(all) const { basis, groups, truncated } = ring // The most name-merged payees earn the on-mount "descriptors collapse" moment. const mergeIds = new Set( all .filter((e) => e.variant_count >= 3) .sort((a, b) => b.variant_count - a.variant_count) .slice(0, 5) .map((e) => `${e.dominant_account_number}:${e.key}`), ) const accounts: Account[] = [] const payees: Payee[] = [] const spans = 2 * Math.PI - WEDGE_GAP * groups.length let angle = -Math.PI / 2 + WEDGE_GAP / 2 groups.forEach((g, gi) => { const width = spans * (g.weight / ring.totalWeight) const mid = angle + width / 2 const anchor = polar(CX, CY, R_ACCOUNT, mid) accounts.push({ number: g.number, name: getAccountDescription(g.number)?.name ?? null, x: anchor.x, y: anchor.y, midAngle: mid, arc: arcPath(CX, CY, R_ACCOUNT, angle + width * 0.04, angle + width * 0.96), revealDelay: 0.2 + gi * 0.06, }) const n = g.items.length const inner = width * 0.16 const order = centerOut(n) // order[rank] = slot for that rank (rank 0 = biggest → centre) g.items.forEach((e, rank) => { const slot = order[rank] const t = n === 1 ? 0.5 : slot / (n - 1) const pa = angle + inner + t * (width - 2 * inner) // Same unit as the wedge it sits in, so a node is never sized against a // maximum measured in something else. const sizeFrac = Math.sqrt(entityMagnitude(e, basis) / ring.maxMagnitude) const radius = lerp(R_PAYEE_MIN, R_PAYEE_MAX, rand01(e.key)) + sizeFrac * 14 const pos = polar(CX, CY, Math.min(radius, R_PAYEE_MAX + 10), pa) const id = `${g.number}:${e.key}` payees.push({ id, entity: e, accountNumber: g.number, x: pos.x, y: pos.y, r: 7 + 19 * sizeFrac, cadence: cadenceOf(e), bucket: bucketOf(e.dominant_account_share), thread: `M ${CX} ${CY} Q ${anchor.x.toFixed(2)} ${anchor.y.toFixed(2)} ${pos.x.toFixed(2)} ${pos.y.toFixed(2)}`, labelRight: Math.cos(pa) >= 0, revealDelay: 0.55 + gi * 0.05 + rank * 0.045, merge: mergeIds.has(id), chips: e.variants.slice(0, 6), }) angle += 0 }) angle += width + WEDGE_GAP }) return { accounts, payees, truncated, basis, totals: { tx: totalTx, payees: payees.length, accounts: accounts.length }, } } // Weekly beats faster than monthly; irregular drifts slow. Seconds per pulse. const PULSE_DUR: Record = { weekly: 1.15, monthly: 2.7, irregular: 4.3 } export function LedgerGraph({ deep, companyName }: { deep: DeepLedgerContext; companyName: string }) { const t = useTranslations('agentKnowledge') // Loads the BAS chart chunk after mount and re-renders once names and // descriptions for non-hardcoded accounts are available. useBasReference() const reduce = useReducedMotion() ?? false const model = useMemo(() => buildModel(deep), [deep]) const [hover, setHover] = useState(null) const [runKey, setRunKey] = useState(0) // Track which run has finished its intro rather than a bare boolean, so a // replay (runKey++) resets to "unresolved" by derivation, without a // setState-in-effect. Chips fly in, then collapse into their node. const [resolvedRun, setResolvedRun] = useState(-1) useEffect(() => { const id = setTimeout(() => setResolvedRun(runKey), reduce ? 0 : 1300) return () => clearTimeout(id) }, [runKey, reduce]) const resolved = reduce || resolvedRun === runKey const payeeById = useMemo(() => new Map(model.payees.map((p) => [p.id, p])), [model]) function payeeCaption(p: Payee): string { const e = p.entity const nm = getAccountDescription(e.dominant_account_number ?? '')?.name return [ e.name, t('cap_bookings', { n: e.occurrences }), e.variant_count > 1 ? t('cap_variants', { n: e.variant_count }) : null, t(`cadence_${p.cadence}`), formatCurrency(e.total_amount), `${e.dominant_account_number}${nm ? ` ${nm}` : ''}${ e.dominant_account_share !== null ? ` · ${Math.round(e.dominant_account_share * 100)}%` : '' }`, ] .filter(Boolean) .join(' · ') } if (model.payees.length === 0) { return (
{t('none_cp')}
) } // Which ids are "lit" given the current hover (a payee lights its account and // the reverse); everything else recedes into the depth of field. const active = new Set() if (hover) { active.add(hover) if (hover.startsWith('acc:')) { const num = hover.slice(4) model.payees.forEach((p) => p.accountNumber === num && active.add(p.id)) } else { const p = payeeById.get(hover) if (p) active.add(`acc:${p.accountNumber}`) } } const lit = (id: string) => !hover || active.has(id) const hoveredPayee = hover && !hover.startsWith('acc:') ? payeeById.get(hover) ?? null : null return (
{/* header */}

{t('graph_title')}

{t('graph_description')}

{!reduce && ( )}
{/* stage */}
{DOF.map((sd, i) => ( ))} {/* account wedge arcs (the spine) */} {model.accounts.map((a) => ( ))} {/* threads: base vein + travelling cadence pulse */} {model.payees.map((p) => ( ))} {/* payee nodes, blurry buckets first so the confident ones sit on top */} {[...model.payees] .sort((a, b) => b.bucket - a.bucket) .map((p) => ( ))} {/* company hub */} {initialsOf(companyName)} {/* tally: the "understood" count-up */}
{' '} {t('graph_tally', { payees: model.totals.payees, accounts: model.totals.accounts, })}
{/* hover detail card, anchored to the node */} {hoveredPayee && ( )}
{/* legend */}
{t('cadence_weekly')} {t('cadence_monthly')} {t('cadence_irregular')} {/* Says what the sizes actually measure. The ring drops to booking volume whenever any account lacks a usable amount, and claiming "Storlek = belopp" there would describe a unit nothing is drawn in. */} {model.basis === 'amount' ? t('legend_size') : t('legend_size_volume')} {t('legend_focus')} {model.truncated && {t('graph_truncated')}}
{/* screen-reader alternative: the full payload as a plain list */}
    {model.payees.map((p) => (
  • {payeeCaption(p)}
  • ))}
) } function accountCaption(a: Account): string { return `${a.number}${a.name ? ` · ${a.name}` : ''}` } // Enlarged invisible hit target + keyboard focus for an account arc. function AccountHit({ account, caption, onHover, lit, }: { account: Account caption: string onHover: (id: string | null) => void lit: boolean }) { const id = `acc:${account.number}` const inside = polar(CX, CY, R_ACCOUNT - 20, account.midAngle) return ( onHover(id)} onMouseLeave={() => onHover(null)} onFocus={() => onHover(id)} onBlur={() => onHover(null)} className="cursor-pointer outline-none [&:focus-visible>circle]:opacity-100" > {caption} {account.number} ) } function PayeeGlyph({ p, reduce, resolved, lit, focused, caption, onHover, }: { p: Payee reduce: boolean resolved: boolean lit: boolean focused: boolean caption: string onHover: (id: string | null) => void }) { const colour = CAD[p.cadence] const showChips = !reduce && p.merge && !resolved // The depth-of-field blur only attaches once the entrance spring settles, so // feGaussianBlur never re-rasterizes per frame while the node is moving (and // the blur "racking in" as the node comes to rest is the intended focus pull). const [settled, setSettled] = useState(reduce) // Only the biggest spenders keep a resting label; the rest reveal on focus. const bigLabel = p.r >= 14 const label = p.entity.name.length > 16 ? p.entity.name.slice(0, 15) + '…' : p.entity.name const lx = p.labelRight ? p.r + 8 : -(p.r + 8) return ( // Positioning lives on a plain (SVG transform attribute) so it can never // be clobbered by framer's CSS transform on the scaling child below. Hover // "racks focus" onto a node by dropping it to the crisp filter bucket. onHover(p.id)} onMouseLeave={() => onHover(null)} onFocus={() => onHover(p.id)} onBlur={() => onHover(null)} className="cursor-pointer outline-none" > {caption} {/* always-full-size transparent hit target */} {/* opacity layer: entrance fade + hover dimming. Animates opacity only, so framer never sets a CSS transform that would fight the attribute. */} {/* the money shot: raw bank descriptors that collapse inward. Kept OUT of the scaling group so they show at full size during the scatter. */} {showChips && p.chips.map((chip, i) => { const ang = rand01(p.id + chip) * Math.PI * 2 const rad = 34 + rand01(chip + String(i)) * 52 const ox = Math.cos(ang) * rad const oy = Math.sin(ang) * rad return ( {chip.length > 18 ? chip.slice(0, 17) + '…' : chip} ) })} {/* scale layer: the node grows in around its centre (fill-box) */} setSettled(true)} transition={ reduce ? { duration: 0 } : { type: 'spring', stiffness: 150, damping: 17, delay: p.merge ? 1.32 : p.revealDelay } } style={{ transformBox: 'fill-box', transformOrigin: 'center' }} > {/* variant echo ring: faint concentric hint that this node is a merge */} {p.entity.variant_count > 2 && ( )} {focused && } {/* the glyph: supplier = filled, counterparty = open */} {/* "×N" merge badge */} {p.entity.variant_count > 1 && (resolved || reduce) && ( {p.merge ? ( ) : ( `×${p.entity.variant_count}` )} )} {bigLabel && ( {label} )} ) } function DetailCard({ p, t }: { p: Payee; t: ReturnType }) { const e = p.entity const nm = getAccountDescription(e.dominant_account_number ?? '')?.name const share = e.dominant_account_share !== null ? Math.round(e.dominant_account_share * 100) : null // Anchor to the node: viewBox coords → % of the square stage. Flip sides so it // never spills off the edge. const left = (p.x / W) * 100 const top = (p.y / H) * 100 const right = p.x < CX return (
{e.name}
{t('cap_bookings', { n: e.occurrences })} {t(`cadence_${p.cadence}`)}
{e.variant_count > 1 && (
{t('card_variants')} ×{e.variant_count}
)} {/* Its own label: `legend_size` now describes whichever unit the ring settled on, which is not always the amount. Rendered as a plain number with NO currency suffix: total_amount is the RAW FOREIGN amount when no SEK equivalent exists (ledger-graph-magnitude.ts), so labelling it "kr" would show a 500 EUR supplier as "500 kr". Proper per-currency display is deferred to the RPC fix documented in that module. */}
{t('card_amount')} {formatAmount(e.total_amount)}
{e.dominant_account_number} {nm ? ` ${nm}` : ''} {share !== null && {t('card_confidence', { share })}}
{share !== null && (
)} {e.dominant_account_count != null && e.dominant_account_total != null && (
{t('card_evidence', { k: e.dominant_account_count, n: e.dominant_account_total })}
)}
{e.variants.length > 1 && (
{e.variants.slice(0, 4).join(' · ')} {e.variant_count > 4 ? ' …' : ''}
)}
) } function CountUp({ target, run, reduce, prefix = '', duration = 1, }: { target: number run: number reduce: boolean prefix?: string duration?: number }) { const [v, setV] = useState(0) // Re-run whenever `run` bumps (mount / replay / resolve). No ref-guard: it // would early-return on React StrictMode's second effect setup in dev and // freeze the number at 0 (the cleanup already stops any prior animation). useEffect(() => { if (reduce) return const controls = animate(0, target, { duration, ease: 'easeOut', onUpdate: (x) => setV(Math.round(x)), }) return () => controls.stop() }, [target, run, reduce, duration]) const shown = reduce ? target : v return <>{prefix}{shown.toLocaleString('sv-SE')} } function Dot({ fill }: { fill: string }) { return } function initialsOf(name: string): string { return ( name .split(/\s+/) .filter(Boolean) .slice(0, 2) .map((w) => w[0]?.toUpperCase() ?? '') .join('') || '•' ) } // One clock for all continuous life: GPU-friendly CSS keyframes, frozen for // prefers-reduced-motion users. const keyframes = ` @keyframes aurora-pulse { to { stroke-dashoffset: -1; } } @keyframes aurora-breathe { 0%,100% { transform: scale(1); } 50% { transform: scale(1.03); } } .aurora-pulse { animation-name: aurora-pulse; animation-timing-function: linear; animation-iteration-count: infinite; } .aurora-breathe { animation: aurora-breathe 5.5s ease-in-out infinite; } @media (prefers-reduced-motion: reduce) { .aurora-pulse, .aurora-breathe { animation: none !important; } } `