Files
accounted/components/agent-knowledge/LedgerGraph.tsx
T
Jakob Wennberg 1a41119682 perf(bundle): drop the BAS chart and the Node crypto polyfill from the shared client baseline (#1942)
* perf(bundle): drop the BAS chart and the Node crypto polyfill from the shared client baseline

Two chunks rode along in the first-load JS of almost every dashboard route:
the full BAS 2026 chart (315 KB uncompressed, in 81 route manifests) and
the browser polyfill for Node's crypto/vm/Buffer (327 KB, in 26 routes
incl. login and register). Neither was needed on first paint; both got
there through static imports of helpers that happen to live next to code
that needs the data or the builtin.

Node polyfill (4 pure splits, behaviour unchanged, re-exported from the
original modules for server callers):
- lib/auth/bankid-flags.ts: isBankIdEnabled (login, register, security
  settings imported it from bankid.ts, which imports crypto).
- lib/import/bank-file/formats.ts: the format registry + detection (the
  import history imported getFormat from parser.ts, which hashes).
- lib/salary/personnummer-format.ts: parsing/validation/formatting (the
  employee forms reached the encrypting personnummer.ts via tax-column).
- lib/auth/api-key-scopes.ts: scope catalogue, groups, tool map, helpers
  (the API key panel imported STAGING_SCOPES from the key generator).

BAS chart:
- lib/bookkeeping/bas-lazy.ts + use-bas-reference.ts: the chart becomes a
  dynamic import, fetched once per session after first paint; components
  that show BAS names/descriptions call useBasReference() and re-render
  when it lands. Until then (and on the server) only the hardcoded
  account-descriptions answer, so SSR and hydration agree.
- lib/bookkeeping/bas-labels.ts: class/group labels out of bas-reference.ts
  (account-descriptions needed a label and paid for the whole chart).
- lib/bookkeeping/bas-account-numbers.ts (generated, ~11 KB) +
  scripts/generate-bas-account-numbers.ts (--check) + parity test:
  isStandardBASAccountNumber for AddAccountDialog/ChartOfAccountsManager.
- lib/bookkeeping/account-classifier-{heuristic,client}.ts: the BAS-aligned
  heuristic shared by the server classifier and a client variant that uses
  the lazy chart.
- lib/bookkeeping/invoice-accounts.ts: INVOICE_FX_RATE_MISSING,
  InvoiceFxRateMissingError, getRevenueAccount, getOutputVatAccount out of
  invoice-entries.ts, whose engine import pulled account-backfill and the
  chart into SendInvoiceDialog/PaymentBookingDialog.
- CorrectOpeningBalanceDialog re-seeds names when the chart lands;
  OpeningBalanceRowEditor builds its Fuse indexes lazily; the
  ChartOfAccountsManager BAS-katalog tab awaits the chunk.

Tooling:
- scripts/perf/client-import-closure.mjs: static import closure of every
  'use client' module with the shortest chain to a target (file or bare
  specifier); found every path above without a build.
- scripts/checks/client-node-builtin.mjs wired into check:guards: a client
  module reaching a Node builtin is a hard failure (0 today).

Left as is: invoices/[id], its credit page and SendInvoiceDialog still
reach the chart through lib/invoices/issue-credit-note -> invoice-entries
-> engine -> account-backfill; splitting the engine is out of scope here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(perf): unambiguous import-edge regex in the closure walker (CodeQL js/redos)

One quantifier per span: a greedy [^'"]* up to the specifier quote, which it
cannot cross, so a run of whitespace has a single parse. Same edges as
before (multi-line named imports, re-exports, side-effect imports; type-only
imports still skipped).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 15:07:49 +02:00

843 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 { 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<Cadence, string> = {
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<Cadence, number> = { 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<string | null>(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 (
<div
className="rounded-lg border border-border p-16 text-center text-sm"
style={{ background: INK, borderColor: HAIR, color: MUTED }}
>
{t('none_cp')}
</div>
)
}
// 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<string>()
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 (
<div
className="relative overflow-hidden rounded-lg border border-border"
style={{
borderColor: HAIR_STRONG,
background: `radial-gradient(120% 120% at 50% 42%, #17171b 0%, ${INK} 62%)`,
}}
>
<style>{keyframes}</style>
{/* header */}
<div className="flex items-start justify-between gap-4 px-5 pt-5 md:px-7 md:pt-6">
<div>
<h2
className="font-display text-lg tracking-tight md:text-xl"
style={{ color: PAPER }}
>
{t('graph_title')}
</h2>
<p className="mt-1 max-w-md text-sm leading-relaxed" style={{ color: MUTED }}>
{t('graph_description')}
</p>
</div>
{!reduce && (
<button
type="button"
onClick={() => setRunKey((k) => k + 1)}
className="inline-flex shrink-0 items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition-colors"
style={{ borderColor: HAIR_STRONG, color: MUTED }}
>
<RotateCw className="h-3.5 w-3.5" />
{t('graph_replay')}
</button>
)}
</div>
{/* stage */}
<div className="relative mx-auto aspect-square w-full max-w-[680px]">
<svg
viewBox={`0 0 ${W} ${H}`}
className="block h-full w-full"
role="img"
aria-label={t('graph_aria', { payees: model.payees.length, accounts: model.accounts.length })}
>
<defs>
{DOF.map((sd, i) => (
<filter key={i} id={`dof-${i}`} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation={sd} />
</filter>
))}
<radialGradient id="aurora-hub" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor={PAPER} stopOpacity="0.22" />
<stop offset="100%" stopColor={PAPER} stopOpacity="0" />
</radialGradient>
</defs>
<g key={runKey}>
{/* account wedge arcs (the spine) */}
{model.accounts.map((a) => (
<g key={`acc:${a.number}`} opacity={lit(`acc:${a.number}`) ? 1 : 0.14}>
<motion.path
d={a.arc}
fill="none"
stroke={HAIR_STRONG}
strokeWidth={1.4}
strokeLinecap="round"
initial={reduce ? false : { pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: 0.7, delay: a.revealDelay, ease: 'easeInOut' }}
/>
<AccountHit account={a} caption={accountCaption(a)} onHover={setHover} lit={lit(`acc:${a.number}`)} />
</g>
))}
{/* threads: base vein + travelling cadence pulse */}
{model.payees.map((p) => (
<g key={`thread:${p.id}`} opacity={lit(p.id) ? 1 : 0.08}>
<motion.path
d={p.thread}
fill="none"
stroke={PAPER}
strokeOpacity={0.16}
strokeWidth={0.9 + 1.1 * (p.entity.dominant_account_share ?? 0.6)}
initial={reduce ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.6, delay: p.revealDelay, ease: 'easeOut' }}
/>
<path
d={p.thread}
fill="none"
stroke={CAD[p.cadence]}
strokeWidth={2.4}
strokeLinecap="round"
pathLength={1}
className={reduce ? undefined : 'aurora-pulse'}
style={{
strokeDasharray: '0.035 1',
opacity: resolved && lit(p.id) ? 0.9 : 0,
transition: 'opacity .6s ease',
animationDuration: `${PULSE_DUR[p.cadence]}s`,
animationDelay: `${rand01(p.id + 'd') * -PULSE_DUR[p.cadence]}s`,
}}
/>
</g>
))}
{/* payee nodes, blurry buckets first so the confident ones sit on top */}
{[...model.payees]
.sort((a, b) => b.bucket - a.bucket)
.map((p) => (
<PayeeGlyph
key={p.id}
p={p}
reduce={reduce}
resolved={resolved}
lit={lit(p.id)}
focused={hover === p.id}
caption={payeeCaption(p)}
onHover={setHover}
/>
))}
{/* company hub */}
<g>
<circle cx={CX} cy={CY} r={R_HUB * 2.6} fill="url(#aurora-hub)" />
<circle
cx={CX}
cy={CY}
r={R_HUB}
fill={PAPER}
className={reduce ? undefined : 'aurora-breathe'}
style={{ transformOrigin: `${CX}px ${CY}px` }}
/>
<text
x={CX}
y={CY}
fill={INK}
fontSize={20}
fontWeight={600}
textAnchor="middle"
dominantBaseline="central"
>
{initialsOf(companyName)}
</text>
</g>
</g>
</svg>
{/* tally: the "understood" count-up */}
<div
className="pointer-events-none absolute bottom-3 left-4 text-xs tabular-nums md:bottom-4 md:left-6"
style={{ color: MUTED }}
>
<span style={{ color: PAPER }}>
<CountUp target={model.totals.tx} run={runKey} reduce={reduce} />
</span>{' '}
{t('graph_tally', {
payees: model.totals.payees,
accounts: model.totals.accounts,
})}
</div>
{/* hover detail card, anchored to the node */}
<AnimatePresence>
{hoveredPayee && (
<DetailCard key={hoveredPayee.id} p={hoveredPayee} t={t} />
)}
</AnimatePresence>
</div>
{/* legend */}
<div
className="flex flex-wrap items-center gap-x-5 gap-y-2 border-t px-5 py-3 text-xs md:px-7"
style={{ borderColor: HAIR, color: MUTED }}
>
<span className="inline-flex items-center gap-2">
<Dot fill={CAD.weekly} /> {t('cadence_weekly')}
</span>
<span className="inline-flex items-center gap-2">
<Dot fill={CAD.monthly} /> {t('cadence_monthly')}
</span>
<span className="inline-flex items-center gap-2">
<Dot fill={CAD.irregular} /> {t('cadence_irregular')}
</span>
{/* 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. */}
<span className="opacity-70">
{model.basis === 'amount' ? t('legend_size') : t('legend_size_volume')}
</span>
<span className="opacity-70">{t('legend_focus')}</span>
{model.truncated && <span className="italic opacity-70">{t('graph_truncated')}</span>}
</div>
{/* screen-reader alternative: the full payload as a plain list */}
<ul className="sr-only">
{model.payees.map((p) => (
<li key={`sr:${p.id}`}>{payeeCaption(p)}</li>
))}
</ul>
</div>
)
}
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 (
<g
tabIndex={0}
role="button"
aria-label={caption}
onMouseEnter={() => onHover(id)}
onMouseLeave={() => onHover(null)}
onFocus={() => onHover(id)}
onBlur={() => onHover(null)}
className="cursor-pointer outline-none [&:focus-visible>circle]:opacity-100"
>
<title>{caption}</title>
<circle cx={account.x} cy={account.y} r={22} fill="transparent" />
<circle cx={account.x} cy={account.y} r={26} fill="none" stroke={PAPER} strokeWidth={1.25} opacity={0} />
<text
x={inside.x}
y={inside.y}
fill={lit ? PAPER : MUTED}
fontSize={13}
fontWeight={500}
textAnchor="middle"
dominantBaseline="central"
style={{ fontFamily: 'var(--font-geist-mono, ui-monospace, monospace)' }}
>
{account.number}
</text>
</g>
)
}
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 <g> (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.
<g
transform={`translate(${p.x} ${p.y})`}
filter={`url(#dof-${focused || !settled ? 0 : p.bucket})`}
tabIndex={0}
role="button"
aria-label={caption}
onMouseEnter={() => onHover(p.id)}
onMouseLeave={() => onHover(null)}
onFocus={() => onHover(p.id)}
onBlur={() => onHover(null)}
className="cursor-pointer outline-none"
>
<title>{caption}</title>
{/* always-full-size transparent hit target */}
<circle cx={0} cy={0} r={Math.max(p.r + 8, 16)} fill="transparent" />
{/* opacity layer: entrance fade + hover dimming. Animates opacity only, so
framer never sets a CSS transform that would fight the attribute. */}
<motion.g
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: lit ? 1 : 0.12 }}
transition={{ duration: reduce ? 0.2 : 0.4, delay: reduce ? 0 : p.merge ? 0.1 : p.revealDelay }}
>
{/* the money shot: raw bank descriptors that collapse inward. Kept OUT
of the scaling group so they show at full size during the scatter. */}
<AnimatePresence>
{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 (
<motion.text
key={chip + i}
fontSize={12}
fill={MUTED}
textAnchor="middle"
dominantBaseline="central"
initial={{ opacity: 0, x: ox * 1.55, y: oy * 1.55 }}
animate={{ opacity: 0.7, x: ox, y: oy }}
exit={{ opacity: 0, x: 0, y: 0, scale: 0.4 }}
transition={{ duration: 0.5, delay: 0.1 + i * 0.05, ease: 'easeOut' }}
>
{chip.length > 18 ? chip.slice(0, 17) + '…' : chip}
</motion.text>
)
})}
</AnimatePresence>
{/* scale layer: the node grows in around its centre (fill-box) */}
<motion.g
initial={reduce ? false : { scale: 0 }}
animate={{ scale: 1 }}
onAnimationComplete={() => 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 && (
<circle cx={0} cy={0} r={p.r + 5} fill="none" stroke={colour} strokeWidth={0.75} strokeOpacity={0.35} />
)}
{focused && <circle cx={0} cy={0} r={p.r + 6} fill="none" stroke={PAPER} strokeWidth={1.5} />}
{/* the glyph: supplier = filled, counterparty = open */}
<circle
cx={0}
cy={0}
r={p.r}
fill={p.entity.kind === 'supplier' ? colour : INK}
fillOpacity={p.entity.kind === 'supplier' ? 0.85 : 1}
stroke={colour}
strokeWidth={2}
/>
{/* "×N" merge badge */}
{p.entity.variant_count > 1 && (resolved || reduce) && (
<g transform={`translate(${p.r * 0.72} ${-p.r * 0.72})`}>
<circle r={8.5} fill={INK} stroke={colour} strokeWidth={1} />
<text fill={PAPER} fontSize={9} fontWeight={600} textAnchor="middle" dominantBaseline="central">
{p.merge ? (
<CountUp target={p.entity.variant_count} run={resolved ? 1 : 0} reduce={reduce} prefix="×" duration={0.5} />
) : (
`×${p.entity.variant_count}`
)}
</text>
</g>
)}
</motion.g>
{bigLabel && (
<text
x={lx}
y={0}
fill={lit ? PAPER : MUTED}
fontSize={12}
dominantBaseline="central"
textAnchor={p.labelRight ? 'start' : 'end'}
>
{label}
</text>
)}
</motion.g>
</g>
)
}
function DetailCard({ p, t }: { p: Payee; t: ReturnType<typeof useTranslations> }) {
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 (
<motion.div
className="pointer-events-none absolute z-10 w-60 rounded-lg border p-3 backdrop-blur-sm"
style={{
left: `${left}%`,
top: `${top}%`,
transform: `translate(${right ? '14px' : 'calc(-100% - 14px)'}, -50%)`,
background: 'rgba(14,14,17,0.92)',
borderColor: HAIR_STRONG,
color: PAPER,
}}
initial={{ opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ type: 'spring', stiffness: 320, damping: 26 }}
>
<div className="flex items-center gap-2">
<span className="inline-block h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: CAD[p.cadence] }} />
<span className="truncate font-medium">{e.name}</span>
</div>
<div className="mt-2 space-y-1.5 text-xs" style={{ color: MUTED }}>
<div className="flex justify-between gap-3">
<span>{t('cap_bookings', { n: e.occurrences })}</span>
<span style={{ color: PAPER }}>{t(`cadence_${p.cadence}`)}</span>
</div>
{e.variant_count > 1 && (
<div className="flex justify-between gap-3">
<span>{t('card_variants')}</span>
<span style={{ color: PAPER }}>×{e.variant_count}</span>
</div>
)}
{/* 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. */}
<div className="flex justify-between gap-3">
<span>{t('card_amount')}</span>
<span className="tabular-nums" style={{ color: PAPER }}>
{e.total_amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
<div className="mt-2 border-t pt-2" style={{ borderColor: HAIR }}>
<div className="flex items-center justify-between gap-3">
<span className="font-mono" style={{ color: PAPER }}>
{e.dominant_account_number}
{nm ? ` ${nm}` : ''}
</span>
{share !== null && <span className="tabular-nums">{t('card_confidence', { share })}</span>}
</div>
{share !== null && (
<div className="mt-1.5 h-1 overflow-hidden rounded-full" style={{ background: HAIR }}>
<div className="h-full rounded-full" style={{ width: `${share}%`, background: CAD[p.cadence] }} />
</div>
)}
{e.dominant_account_count != null && e.dominant_account_total != null && (
<div className="mt-1 tabular-nums opacity-80">
{t('card_evidence', { k: e.dominant_account_count, n: e.dominant_account_total })}
</div>
)}
</div>
{e.variants.length > 1 && (
<div className="pt-1 leading-relaxed opacity-80">
{e.variants.slice(0, 4).join(' · ')}
{e.variant_count > 4 ? ' …' : ''}
</div>
)}
</div>
</motion.div>
)
}
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 <span className="inline-block h-2.5 w-2.5 rounded-full" style={{ background: fill }} />
}
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; }
}
`