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:
@@ -837,7 +837,10 @@ function SIEImportWizard() {
|
||||
onExecute={handleExecuteImport} onBack={goBack} isLoading={isLoading}
|
||||
theaterModel={theaterModel} />
|
||||
)}
|
||||
{step === 'result' && importResult && <ImportResultStep result={importResult} onNewImport={handleNewImport} onUndo={handleUndo} />}
|
||||
{step === 'result' && importResult && (
|
||||
<ImportResultStep result={importResult} onNewImport={handleNewImport} onUndo={handleUndo}
|
||||
preview={preview} theaterModel={theaterModel} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -19,16 +20,39 @@ import {
|
||||
useDestructiveConfirm,
|
||||
} from '@/components/ui/destructive-confirm-dialog'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { ImportResult } from '@/lib/import/types'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import TheaterCanvas from '@/components/import/TheaterCanvas'
|
||||
import type { ImportPreview, ImportResult } from '@/lib/import/types'
|
||||
import type { TheaterModel } from '@/lib/import/theater-model'
|
||||
|
||||
interface ImportResultStepProps {
|
||||
result: ImportResult
|
||||
onNewImport: () => void
|
||||
onUndo?: (importId: string) => Promise<void> | void
|
||||
/** When the theater ran, the reveal replaces the plain success header:
|
||||
* the settled constellation beside the personalized story + the bank
|
||||
* bridge. Absent (failure, oversized file, parse miss) = plain header. */
|
||||
preview?: ImportPreview | null
|
||||
theaterModel?: TheaterModel | null
|
||||
}
|
||||
|
||||
export default function ImportResultStep({ result, onNewImport, onUndo }: ImportResultStepProps) {
|
||||
export default function ImportResultStep({
|
||||
result,
|
||||
onNewImport,
|
||||
onUndo,
|
||||
preview = null,
|
||||
theaterModel = null,
|
||||
}: ImportResultStepProps) {
|
||||
const t = useTranslations('import')
|
||||
const { dialogProps, confirm } = useDestructiveConfirm()
|
||||
const { isSandbox } = useCompany()
|
||||
const hasBanking = ENABLED_EXTENSION_IDS.has('enable-banking')
|
||||
// The reveal only tells a story that is true: it needs the theater model,
|
||||
// the preview, and actual imported entries (an opening-balances-only run
|
||||
// must not claim "history in place" over an untouched constellation).
|
||||
const showReveal =
|
||||
result.success && !!theaterModel && !!preview && result.journalEntriesCreated > 0
|
||||
|
||||
const handleUndoClick = async () => {
|
||||
if (!result.importId || !onUndo) return
|
||||
@@ -54,31 +78,88 @@ export default function ImportResultStep({ result, onNewImport, onUndo }: Import
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Success/Failure header */}
|
||||
<Card className={result.success ? 'border-border' : 'border-destructive/50'}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{result.success ? (
|
||||
<>
|
||||
<CheckCircle className="h-6 w-6 text-success" />
|
||||
Import genomförd
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-6 w-6 text-destructive" />
|
||||
Import misslyckades
|
||||
</>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{result.success
|
||||
? skipped && skipped.total > 0
|
||||
? `Din bokföring har importerats. ${result.journalEntriesCreated} verifikationer skapades, ${skipped.total} hoppades över: se detaljer nedan.`
|
||||
: 'Din bokföring har importerats framgångsrikt.'
|
||||
: 'Det uppstod fel under importen. Läs felmeddelanden nedan för att förstå vad som gick snett och hur du kan åtgärda det.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
{/* "Din historia" reveal (theater ran) or the plain success/failure header */}
|
||||
{showReveal && theaterModel && preview ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid items-center gap-6 md:grid-cols-[minmax(280px,380px)_1fr]">
|
||||
<div>
|
||||
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('reveal_eyebrow')}
|
||||
</p>
|
||||
<h2 className="mt-2 font-display text-2xl leading-8 tracking-tight text-balance">
|
||||
{t('reveal_title', { years: Math.max(theaterModel.years.length, 1) })}
|
||||
</h2>
|
||||
<p className="mt-3 text-[13px] text-muted-foreground tabular-nums">
|
||||
{t('reveal_stats', {
|
||||
vouchers: result.journalEntriesCreated,
|
||||
accounts: preview.accountCount,
|
||||
counterparties: theaterModel.totalCounterparties,
|
||||
})}
|
||||
</p>
|
||||
{/* The balance claim comes from the pre-import file check; it
|
||||
stays honest only when no unbalanced voucher was skipped. */}
|
||||
{preview.trialBalance.isBalanced && !(skipped && skipped.unbalanced > 0) && (
|
||||
<p className="mt-1 text-[13px] tabular-nums text-success">{t('reveal_tie_ok')}</p>
|
||||
)}
|
||||
{skipped && skipped.total > 0 && (
|
||||
<p className="mt-1 text-[13px] text-warning">
|
||||
{t('reveal_skipped', { count: skipped.total })}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-6 border-t border-border pt-4">
|
||||
{/* Sandbox strips live bank connections from /import, so the
|
||||
bridge quiets down to the plain way onward there. */}
|
||||
{!isSandbox && <p className="font-display text-base">{t('reveal_bridge')}</p>}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
{!isSandbox && (
|
||||
<Button asChild size="sm">
|
||||
<Link href={hasBanking ? '/import?mode=psd2' : '/import?mode=bank'}>
|
||||
{t('reveal_cta_bank')}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xs text-muted-foreground underline decoration-border underline-offset-4 transition-colors hover:text-foreground"
|
||||
>
|
||||
{t('reveal_cta_open')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative hidden min-h-[360px] md:block">
|
||||
<TheaterCanvas model={theaterModel} settled />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className={result.success ? 'border-border' : 'border-destructive/50'}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{result.success ? (
|
||||
<>
|
||||
<CheckCircle className="h-6 w-6 text-success" />
|
||||
Import genomförd
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-6 w-6 text-destructive" />
|
||||
Import misslyckades
|
||||
</>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{result.success
|
||||
? skipped && skipped.total > 0
|
||||
? `Din bokföring har importerats. ${result.journalEntriesCreated} verifikationer skapades, ${skipped.total} hoppades över: se detaljer nedan.`
|
||||
: 'Din bokföring har importerats framgångsrikt.'
|
||||
: 'Det uppstod fel under importen. Läs felmeddelanden nedan för att förstå vad som gick snett och hur du kan åtgärda det.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* IB resync notice (prior-year backfill) */}
|
||||
{result.success && result.nextPeriodIBResync && (
|
||||
@@ -140,7 +221,7 @@ export default function ImportResultStep({ result, onNewImport, onUndo }: Import
|
||||
)}
|
||||
|
||||
{/* Statistics */}
|
||||
{result.success && (
|
||||
{result.success && !showReveal && (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
|
||||
import type { TheaterBucket, TheaterModel } from '@/lib/import/theater-model'
|
||||
|
||||
/**
|
||||
* The constellation canvas shared by the import theater (build mode: nodes
|
||||
* spawn under the narration's control) and the result reveal (settled mode:
|
||||
* everything born, camera home, gentle breathing only). Deterministic radial
|
||||
* layout, no physics; colors and fonts read from the app tokens per frame
|
||||
* (theme/palette reactive, same idiom as JourneyOrb).
|
||||
*/
|
||||
|
||||
export interface TheaterCanvasHandle {
|
||||
spawn: (kind: NodeKind, wave?: number) => void
|
||||
pulse: () => void
|
||||
feed: (ms: number) => void
|
||||
}
|
||||
|
||||
type NodeKind = 'hub' | 'ring' | 'bucket' | 'account' | 'counterparty'
|
||||
|
||||
const BUCKET_ANGLE: Record<TheaterBucket, number> = {
|
||||
tillgangar: 0.31,
|
||||
skulder: 1.62,
|
||||
intakter: -0.92,
|
||||
kostnader: 2.75,
|
||||
}
|
||||
const BUCKET_LABEL: Record<TheaterBucket, string> = {
|
||||
tillgangar: 'TILLGÅNGAR',
|
||||
skulder: 'SKULDER',
|
||||
intakter: 'INTÄKTER',
|
||||
kostnader: 'KOSTNADER',
|
||||
}
|
||||
|
||||
/** Real BAS names run long ("4531 Inköp av tjänster från ett land utanför
|
||||
* EU, 25 % moms"); labels truncate hard so the constellation stays a
|
||||
* constellation. */
|
||||
function truncateLabel(label: string, max: number): string {
|
||||
return label.length <= max ? label : label.slice(0, max - 1).trimEnd() + '…'
|
||||
}
|
||||
|
||||
/** 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: NodeKind
|
||||
x: number
|
||||
y: number
|
||||
r: number
|
||||
ring?: number
|
||||
label?: string
|
||||
lab?: boolean
|
||||
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, allBorn: 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 })
|
||||
})
|
||||
for (const bucket of model.buckets) {
|
||||
const p = BUCKET_ANGLE[bucket.id]
|
||||
nodes.push({ id: bucket.id, kind: 'bucket', x: Math.cos(p) * 148, y: Math.sin(p) * 148, r: 2.2, label: BUCKET_LABEL[bucket.id], lab: true, wave: 0, born: null })
|
||||
edges.push({ a: 'hub', b: bucket.id })
|
||||
}
|
||||
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) * 1.2
|
||||
const rad = 185 + rand01(a.number + 'r') * 70
|
||||
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: truncateLabel(`${a.number} ${a.name}`.trim(), 24),
|
||||
lab: i < 5,
|
||||
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.7
|
||||
const rad = 258 + rand01(c.name + 'r') * 52
|
||||
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: truncateLabel(c.name, 16),
|
||||
lab: i < 5,
|
||||
wave: i % 3,
|
||||
born: null,
|
||||
})
|
||||
if (anchor) edges.push({ a: c.account, b: `cp${i}` })
|
||||
})
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : 0
|
||||
if (allBorn) for (const n of nodes) n.born = now - 10_000
|
||||
return { nodes, edges, stream: [], feedUntil: 0, pulse: 0, cam: allBorn ? 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'),
|
||||
}
|
||||
}
|
||||
|
||||
const TheaterCanvas = forwardRef<TheaterCanvasHandle, {
|
||||
model: TheaterModel
|
||||
/** Settled: everything born at mount, camera home, breathing only. */
|
||||
settled?: boolean
|
||||
className?: string
|
||||
}>(function TheaterCanvas({ model, settled = false, className }, ref) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const engineRef = useRef<Engine | null>(null)
|
||||
const reduced =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
spawn: (kind, wave) => {
|
||||
const engine = engineRef.current
|
||||
if (!engine) return
|
||||
const now = performance.now()
|
||||
for (const n of engine.nodes) {
|
||||
if (n.born == null && n.kind === kind && (wave === undefined || n.wave === wave)) n.born = now
|
||||
}
|
||||
},
|
||||
pulse: () => {
|
||||
if (engineRef.current) engineRef.current.pulse = performance.now()
|
||||
},
|
||||
feed: (ms) => {
|
||||
if (engineRef.current) engineRef.current.feedUntil = performance.now() + ms
|
||||
},
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
const engine = buildEngine(model, settled || reduced)
|
||||
engineRef.current = engine
|
||||
if (!settled && !reduced) {
|
||||
const now = performance.now()
|
||||
for (const n of engine.nodes) if (n.kind === 'hub') n.born = now
|
||||
}
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
let running = true
|
||||
// Settled mode still breathes; only reduced motion freezes the frame.
|
||||
const still = reduced
|
||||
const draw = () => {
|
||||
if (!canvas.isConnected || !engineRef.current) return
|
||||
const wrap = canvas.parentElement
|
||||
if (!wrap) return
|
||||
const W = wrap.clientWidth
|
||||
const H = wrap.clientHeight
|
||||
if (!W || !H) {
|
||||
if (running && !still) 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 = settled || 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
|
||||
}
|
||||
}
|
||||
|
||||
// Greedy label collision culling: node order is hub → buckets →
|
||||
// heaviest accounts → heaviest counterparties, so when labels would
|
||||
// overlap (real files cluster hard in one region), the least important
|
||||
// one silently keeps only its dot.
|
||||
const drawnLabels: { x0: number; y0: number; x1: number; y1: number }[] = []
|
||||
const overlaps = (x0: number, y0: number, x1: number, y1: number) =>
|
||||
drawnLabels.some((b) => x0 < b.x1 && x1 > b.x0 && y0 < b.y1 && y1 > b.y0)
|
||||
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)
|
||||
const ly = q.y + (n.kind === 'hub' ? r + 15 : 3.5)
|
||||
const x0 = align === 'left' ? lx : align === 'right' ? lx - tw : lx - tw / 2
|
||||
const rect = { x0: x0 - 3, y0: ly - 11, x1: x0 + tw + 3, y1: ly + 4 }
|
||||
if (n.kind === 'hub' || n.kind === 'bucket' || !overlaps(rect.x0, rect.y0, rect.x1, rect.y1)) {
|
||||
drawnLabels.push(rect)
|
||||
ctx.fillText(n.label, lx, ly)
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
}
|
||||
|
||||
if (running && !still) requestAnimationFrame(draw)
|
||||
}
|
||||
requestAnimationFrame(draw)
|
||||
if (still) {
|
||||
const id = window.setTimeout(() => draw(), 120)
|
||||
return () => {
|
||||
running = false
|
||||
window.clearTimeout(id)
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
running = false
|
||||
}
|
||||
// One engine per (model, settled) pair for this mount.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settled, reduced])
|
||||
|
||||
return <canvas ref={canvasRef} className={className ?? 'h-full w-full text-foreground'} aria-hidden="true" />
|
||||
})
|
||||
|
||||
export default TheaterCanvas
|
||||
@@ -6498,6 +6498,14 @@
|
||||
"theater_balance_ok": "Every year balances",
|
||||
"theater_balance_warn": "Imbalance found, see the warnings afterwards",
|
||||
"theater_writing": "Writing to the journal ...",
|
||||
"reveal_eyebrow": "Your history",
|
||||
"reveal_title": "{years, plural, one {Your history is in place.} other {# years of history, in place.}}",
|
||||
"reveal_stats": "{vouchers} vouchers · {accounts} accounts · {counterparties} counterparties",
|
||||
"reveal_tie_ok": "Every year balances: 0.00 kr off",
|
||||
"reveal_skipped": "{count, plural, one {# voucher was skipped, see details below.} other {# vouchers were skipped, see details below.}}",
|
||||
"reveal_bridge": "The history is in place. What's missing is the present: the bank.",
|
||||
"reveal_cta_bank": "Connect the bank",
|
||||
"reveal_cta_open": "Open Accounted",
|
||||
"export_title": "Export",
|
||||
"export_subtitle": "Download your bookkeeping as a SIE file or back it up to Google Drive",
|
||||
"tab_import": "Import",
|
||||
|
||||
@@ -6498,6 +6498,14 @@
|
||||
"theater_balance_ok": "Varje år balanserar",
|
||||
"theater_balance_warn": "Obalans hittad, se varningarna efteråt",
|
||||
"theater_writing": "Skriver till journalen ...",
|
||||
"reveal_eyebrow": "Din historia",
|
||||
"reveal_title": "{years, plural, one {Historiken är på plats.} other {# år av historia, på plats.}}",
|
||||
"reveal_stats": "{vouchers} verifikat · {accounts} konton · {counterparties} motparter",
|
||||
"reveal_tie_ok": "Varje år balanserar: 0,00 kr i diff",
|
||||
"reveal_skipped": "{count, plural, one {# verifikat hoppades över, se detaljer nedan.} other {# verifikat hoppades över, se detaljer nedan.}}",
|
||||
"reveal_bridge": "Historiken är på plats. Det som saknas är nuet: banken.",
|
||||
"reveal_cta_bank": "Koppla banken",
|
||||
"reveal_cta_open": "Öppna Accounted",
|
||||
"export_title": "Exportera",
|
||||
"export_subtitle": "Ladda ner bokföringen som SIE-fil eller säkerhetskopia till Google Drive",
|
||||
"tab_import": "Importera",
|
||||
|
||||
Reference in New Issue
Block a user