feat(import): the Din historia reveal after SIE import (#1473)

* feat(import): the Din historia reveal after a successful SIE import

Fourth slice of the activation concept, stacked on the theater (#1471).
When the theater ran, the result step's success header becomes the
reveal: the settled constellation beside the personalized story
({years} år av historia, verifikat/konton/motparter, the balance
tie-out) and the bank bridge ("Historiken är på plats. Det som saknas
är nuet: banken.") deep-linking into the bank connect flow.

Honesty guards from the adversarial pass: the reveal requires actually
imported entries (an opening-balances-only run keeps the plain header),
the balance claim is suppressed when unbalanced vouchers were skipped,
and sandbox hides the bank bridge (live connections are stripped from
/import there). Failures and theater-less successes render the exact
previous header + stats grid.

The canvas is extracted to a shared TheaterCanvas (build mode driven by
the narration timeline via an imperative handle; settled mode for the
reveal: everything born, camera home, breathing only).

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

* fix(import): tame the constellation labels against real-world data

Real BAS names are paragraph-length and real files cluster in one class
region, which piled fifteen full labels into mush (founder screenshot,
Arcim Technology import). Labels now truncate hard (24/16 chars), only
the five heaviest accounts and counterparties carry labels, spread and
radius widen within a bucket, and a greedy per-frame collision pass
skips any label that would overlap one already drawn (importance order:
hub, buckets, heaviest first). The reveal also drops its meta line:
less text, the story is the headline + stats + bridge.

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>
This commit is contained in:
Jakob Wennberg
2026-08-09 20:05:00 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent d4ef4f8bc4
commit 170b2722d6
6 changed files with 515 additions and 350 deletions
+24 -321
View File
@@ -2,21 +2,17 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslations } from 'next-intl'
import TheaterCanvas, { type TheaterCanvasHandle } from '@/components/import/TheaterCanvas'
import type { ImportPreview } from '@/lib/import/types'
import type { TheaterBucket, TheaterModel } from '@/lib/import/theater-model'
import type { TheaterModel } from '@/lib/import/theater-model'
/**
* The import theater: while the SIE import commits server-side (one opaque
* call, up to ~5 minutes), the client-parsed file drives a knowledge graph
* that draws itself: the company as hub, fiscal years as tree rings, account
* classes as anchors, top accounts and recognized counterparties as an ink
* constellation. Narration lines pace alongside; the final line holds with
* the elapsed counter until the server answers, so the animation never
* pretends to know more than the import does.
*
* Ink-on-paper: colors come from the app tokens per frame (theme/palette
* reactive, same idiom as JourneyOrb). Reduced motion renders the settled
* graph and all narration instantly.
* that draws itself, with narration lines pacing alongside. The final line
* holds with the elapsed counter until the server answers, so the animation
* never pretends to know more than the import does. Reduced motion renders
* the settled graph and all narration instantly.
*/
interface ImportTheaterProps {
@@ -26,128 +22,9 @@ interface ImportTheaterProps {
elapsed: number
}
const BUCKET_ANGLE: Record<TheaterBucket, number> = {
tillgangar: 0.31, // right, slightly down
skulder: 1.62, // bottom
intakter: -0.92, // upper right
kostnader: 2.75, // left
}
const BUCKET_LABEL: Record<TheaterBucket, string> = {
tillgangar: 'TILLGÅNGAR',
skulder: 'SKULDER',
intakter: 'INTÄKTER',
kostnader: 'KOSTNADER',
}
/** Deterministic [0,1) hash so the constellation is stable per file. */
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)
return ((h ^ (h >>> 13)) >>> 0) / 4294967296
}
interface Node {
id: string
kind: 'hub' | 'ring' | 'bucket' | 'account' | 'counterparty'
x: number
y: number
r: number
ring?: number
label?: string
lab?: boolean
bucket?: TheaterBucket
wave: number
born: number | null
}
interface Engine {
nodes: Node[]
edges: { a: string; b: string }[]
stream: { x: number; y: number; target: Node }[]
feedUntil: number
pulse: number
cam: number
}
function buildEngine(model: TheaterModel, reduced: boolean): Engine {
const nodes: Node[] = []
const edges: { a: string; b: string }[] = []
nodes.push({ id: 'hub', kind: 'hub', x: 0, y: 0, r: 7, label: model.companyName, lab: true, wave: 0, born: null })
model.years.forEach((y, i) => {
nodes.push({ id: `ring${i}`, kind: 'ring', x: 0, y: 0, r: 0, ring: 58 + i * 24, label: y.start.slice(0, 4), wave: 0, born: null })
})
const present = new Set(model.buckets.map((b) => b.id))
for (const bucket of present) {
const p = BUCKET_ANGLE[bucket]
nodes.push({ id: bucket, kind: 'bucket', x: Math.cos(p) * 148, y: Math.sin(p) * 148, r: 2.2, label: BUCKET_LABEL[bucket], lab: true, wave: 0, born: null })
edges.push({ a: 'hub', b: bucket })
}
const maxAccountWeight = Math.max(1, ...model.accounts.map((a) => a.weight))
model.accounts.forEach((a, i) => {
const base = BUCKET_ANGLE[a.bucket]
const spread = (rand01(a.number) - 0.5) * 0.9
const rad = 192 + rand01(a.number + 'r') * 40
nodes.push({
id: a.number,
kind: 'account',
x: Math.cos(base + spread) * rad,
y: Math.sin(base + spread) * rad,
r: 2.4 + (a.weight / maxAccountWeight) * 3.4,
label: `${a.number} ${a.name}`.trim(),
lab: i < 8,
bucket: a.bucket,
wave: i % 4,
born: null,
})
edges.push({ a: a.bucket, b: a.number })
})
const maxCpWeight = Math.max(1, ...model.counterparties.map((c) => c.weight))
model.counterparties.forEach((c, i) => {
const anchor = nodes.find((n) => n.id === c.account)
const base = anchor ? Math.atan2(anchor.y, anchor.x) : BUCKET_ANGLE.kostnader
const spread = (rand01(c.name) - 0.5) * 0.5
const rad = 262 + rand01(c.name + 'r') * 38
nodes.push({
id: `cp${i}`,
kind: 'counterparty',
x: Math.cos(base + spread) * rad,
y: Math.sin(base + spread) * rad,
r: 1.8 + (c.weight / maxCpWeight) * 2.4,
label: c.name,
lab: i < 7,
wave: i % 3,
born: null,
})
if (anchor) edges.push({ a: c.account, b: `cp${i}` })
})
const now = typeof performance !== 'undefined' ? performance.now() : 0
if (reduced) for (const n of nodes) n.born = now - 10_000
return { nodes, edges, stream: [], feedUntil: 0, pulse: 0, cam: reduced ? 1 : 1.45 }
}
function tokenColors(canvas: HTMLCanvasElement) {
const root = getComputedStyle(document.documentElement)
const hsl = (name: string, fallback: string) => {
const v = root.getPropertyValue(name).trim()
return v ? `hsl(${v})` : fallback
}
return {
ink: getComputedStyle(canvas).color,
mut: hsl('--muted-foreground', '#8a8378'),
hair: hsl('--border', '#e5e2da'),
sage: hsl('--success', '#5d8a6f'),
paper: hsl('--background', '#ffffff'),
}
}
export default function ImportTheater({ model, preview, elapsed }: ImportTheaterProps) {
const t = useTranslations('import')
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const engineRef = useRef<Engine | null>(null)
const canvasRef = useRef<TheaterCanvasHandle | null>(null)
const [lineCount, setLineCount] = useState(0)
const [voucherTick, setVoucherTick] = useState(0)
@@ -187,16 +64,10 @@ export default function ImportTheater({ model, preview, elapsed }: ImportTheater
]
const holdingVisible = lineCount > lines.length
// Narration pacing + graph spawn hooks. Spawn assignments live here so the
// narration and the constellation always agree on what has "happened".
// Narration pacing + graph spawn hooks: the narration and the constellation
// always agree on what has "happened".
useEffect(() => {
const engine = buildEngine(model, reduced)
engineRef.current = engine
const spawn = (f: (n: Node) => boolean) => {
const now = performance.now()
for (const n of engine.nodes) if (n.born == null && f(n)) n.born = now
}
spawn((n) => n.kind === 'hub')
const canvas = () => canvasRef.current
if (reduced) {
setLineCount(lines.length + 1)
setVoucherTick(preview.voucherCount)
@@ -204,19 +75,17 @@ export default function ImportTheater({ model, preview, elapsed }: ImportTheater
}
const timers: number[] = []
const at = (ms: number, fn: () => void) => timers.push(window.setTimeout(fn, ms))
const counterLine = 1
const cpLine = model.totalCounterparties > 0 ? 3 : -1
const balanceLine = model.totalCounterparties > 0 ? 4 : 3
const hasCps = model.totalCounterparties > 0
at(300, () => {
setLineCount(1)
spawn((n) => n.kind === 'ring')
canvas()?.spawn('ring')
})
at(1500, () => {
setLineCount(counterLine + 1)
spawn((n) => n.kind === 'bucket')
engine.feedUntil = performance.now() + 2100
setLineCount(2)
canvas()?.spawn('bucket')
canvas()?.feed(2100)
;[0, 1, 2, 3].forEach((w) =>
timers.push(window.setTimeout(() => spawn((n) => n.kind === 'account' && n.wave === w), 200 + w * 420))
timers.push(window.setTimeout(() => canvas()?.spawn('account', w), 200 + w * 420))
)
const t0 = performance.now()
const tick = () => {
@@ -227,190 +96,24 @@ export default function ImportTheater({ model, preview, elapsed }: ImportTheater
requestAnimationFrame(tick)
})
at(3400, () => setLineCount(3))
if (cpLine > 0) {
if (hasCps) {
at(4600, () => {
setLineCount(cpLine + 1)
setLineCount(4)
;[0, 1, 2].forEach((w) =>
timers.push(window.setTimeout(() => spawn((n) => n.kind === 'counterparty' && n.wave === w), 120 + w * 420))
timers.push(window.setTimeout(() => canvas()?.spawn('counterparty', w), 120 + w * 420))
)
})
}
at(cpLine > 0 ? 6000 : 4600, () => {
setLineCount(balanceLine + 1)
engine.pulse = performance.now()
at(hasCps ? 6000 : 4600, () => {
setLineCount(hasCps ? 5 : 4)
canvas()?.pulse()
})
at(cpLine > 0 ? 7100 : 5700, () => setLineCount(lines.length + 1))
at(hasCps ? 7100 : 5700, () => setLineCount(lines.length + 1))
return () => timers.forEach((id) => window.clearTimeout(id))
// The theater runs once per mount for one (model, preview) pair.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Canvas render loop (refs only; React never sees per-frame state).
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
let running = true
const draw = () => {
const engine = engineRef.current
if (!canvas.isConnected || !engine) return
const wrap = canvas.parentElement
if (!wrap) return
const W = wrap.clientWidth
const H = wrap.clientHeight
if (!W || !H) {
if (running && !reduced) requestAnimationFrame(draw)
return
}
const dpr = Math.min(window.devicePixelRatio || 1, 2)
if (canvas.width !== W * dpr || canvas.height !== H * dpr) {
canvas.width = W * dpr
canvas.height = H * dpr
}
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, W, H)
const colors = tokenColors(canvas)
const now = performance.now()
const born = engine.nodes.filter((n) => n.born != null).length
const camTarget = reduced ? 1 : 1.45 - 0.45 * Math.min(1, born / (engine.nodes.length * 0.85))
engine.cam += (camTarget - engine.cam) * (reduced ? 1 : 0.05)
const k = (Math.min(W, H) / 640) * engine.cam
const cx = W / 2
const cy = H / 2
const pos = (n: Node) => ({ x: cx + n.x * k, y: cy + n.y * k })
const age = (n: Node) =>
n.born == null ? 0 : Math.min(1, (now - n.born) / (reduced ? 1 : n.kind === 'ring' ? 900 : 420))
const ease = (v: number) => 1 - Math.pow(1 - v, 3)
const byId = new Map(engine.nodes.map((n) => [n.id, n]))
for (const n of engine.nodes) {
if (n.kind !== 'ring' || n.born == null) continue
const p = ease(age(n))
const rad = (n.ring ?? 0) * k
ctx.strokeStyle = colors.hair
ctx.globalAlpha = 0.75 * p
ctx.lineWidth = 0.8
ctx.beginPath()
ctx.arc(cx, cy, rad, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * p)
ctx.stroke()
ctx.globalAlpha = Math.max(0, p * 1.3 - 0.3)
const lx = cx + Math.cos(-1.15) * rad
const ly = cy + Math.sin(-1.15) * rad
ctx.fillStyle = colors.paper
ctx.fillRect(lx - 15, ly - 6, 30, 12)
ctx.fillStyle = colors.mut
ctx.font = '10px system-ui, sans-serif'
ctx.textAlign = 'center'
if (n.label) ctx.fillText(n.label, lx, ly + 3.5)
ctx.globalAlpha = 1
}
for (const e of engine.edges) {
const A = byId.get(e.a)
const B = byId.get(e.b)
if (!A || !B || A.born == null || B.born == null) continue
const p = ease(Math.min(age(A), age(B)))
const pa = pos(A)
const pb = pos(B)
ctx.strokeStyle = colors.hair
ctx.lineWidth = 0.8
ctx.globalAlpha = 0.65
ctx.beginPath()
ctx.moveTo(pa.x, pa.y)
ctx.lineTo(pa.x + (pb.x - pa.x) * p, pa.y + (pb.y - pa.y) * p)
ctx.stroke()
ctx.globalAlpha = 1
}
if (now < engine.feedUntil && !reduced && engine.stream.length < 60) {
const targets = engine.nodes.filter((n) => n.kind === 'account' && n.born != null)
if (targets.length) {
for (let i = 0; i < 2; i++) {
engine.stream.push({
x: 8,
y: cy + (Math.random() - 0.5) * 170,
target: targets[(Math.random() * targets.length) | 0],
})
}
}
}
if (engine.stream.length) {
ctx.fillStyle = colors.mut
engine.stream = engine.stream.filter((p) => {
const tp = pos(p.target)
p.x += (tp.x - p.x) * 0.085
p.y += (tp.y - p.y) * 0.085
if (Math.abs(tp.x - p.x) + Math.abs(tp.y - p.y) < 7) return false
ctx.globalAlpha = 0.5
ctx.fillRect(p.x, p.y, 1.6, 1.6)
return true
})
ctx.globalAlpha = 1
}
if (engine.pulse > 0) {
const p = Math.min(1, (now - engine.pulse) / 900)
if (p < 1) {
ctx.strokeStyle = colors.sage
ctx.globalAlpha = (1 - p) * 0.5
ctx.lineWidth = 1.2
ctx.beginPath()
ctx.arc(cx, cy, 18 + p * 290 * (Math.min(W, H) / 640), 0, Math.PI * 2)
ctx.stroke()
ctx.globalAlpha = 1
}
}
for (const n of engine.nodes) {
if (n.born == null || n.kind === 'ring') continue
const p = ease(age(n))
const q = pos(n)
const breathe = reduced ? 0 : Math.sin(now / 2600 + n.x + n.y) * 0.06
const splat = p < 1 ? 1 + 0.3 * Math.sin(p * Math.PI) : 1
const r = n.r * (1 + breathe) * p * k * 1.55 * splat
ctx.fillStyle = n.kind === 'bucket' ? colors.mut : colors.ink
ctx.beginPath()
ctx.arc(q.x, q.y, r, 0, Math.PI * 2)
ctx.fill()
if (n.label && n.lab) {
ctx.globalAlpha = Math.max(0, p * 1.2 - 0.2)
ctx.fillStyle = n.kind === 'hub' ? colors.ink : colors.mut
ctx.font =
n.kind === 'hub'
? '500 13px system-ui, sans-serif'
: n.kind === 'bucket'
? '600 9px system-ui, sans-serif'
: '10.5px system-ui, sans-serif'
const align = q.x > cx + 8 ? 'left' : q.x < cx - 8 ? 'right' : 'center'
ctx.textAlign = align
const dx = align === 'left' ? r + 5 : align === 'right' ? -r - 5 : 0
const tw = ctx.measureText(n.label).width
let lx = q.x + dx
if (align === 'left') lx = Math.min(lx, W - 6 - tw)
else if (align === 'right') lx = Math.max(lx, 6 + tw)
else lx = Math.min(Math.max(lx, 6 + tw / 2), W - 6 - tw / 2)
ctx.fillText(n.label, lx, q.y + (n.kind === 'hub' ? r + 15 : 3.5))
ctx.globalAlpha = 1
}
}
if (running && !reduced) requestAnimationFrame(draw)
}
requestAnimationFrame(draw)
if (reduced) {
// A couple of extra frames so layout settles, then hold the still.
const id = window.setTimeout(() => draw(), 120)
return () => {
running = false
window.clearTimeout(id)
}
}
return () => {
running = false
}
}, [reduced])
return (
<div className="grid gap-6 md:grid-cols-[280px_1fr]">
<div role="status" aria-live="polite">
@@ -448,7 +151,7 @@ export default function ImportTheater({ model, preview, elapsed }: ImportTheater
)}
</div>
<div className="relative min-h-[340px] md:min-h-[420px]">
<canvas ref={canvasRef} className="h-full w-full text-foreground" aria-hidden="true" />
<TheaterCanvas ref={canvasRef} model={model} />
</div>
</div>
)