Files

689 lines
24 KiB
TypeScript

'use client'
/**
* The /kai hero: a decision graph resolving, live.
*
* Evidence streams run into one shared state, and a Decision Program's typed
* variables resolve out of it in parallel. The confident freeze on the first
* pass; the uncertain and the dependent pulse once and are refined on the
* second; a decision the evidence cannot carry defers and names what it needs.
* Switching a source off re-runs only the decisions that read it — which is the
* claim the page makes about compute following uncertainty, shown rather than
* said.
*
* EVERY NUMBER HERE IS SCRIPTED (components/kai/scenario.ts) and the figure says
* so in its bar and under its controls. The model is in training; this draws the
* mechanism, not a run of it.
*
* TWO GEOMETRIES, ONE STATE. The wide drawing puts the streams on the left and
* the program on the right; the tall one stacks them for a phone. Both are in
* the document and gui's `$max-sm` shows one: a drawing is only legible at one
* scale, and switching on `matchMedia` after hydration would repaint a phone's
* hero once it had already drawn the wrong one.
*
* EVERY INK IS STATE. The phase, the hovered source and the busy core are React
* state already, so each shape takes its fill and stroke from them directly, in
* design tokens. One hue, `--state-online`: a decision still resolving.
*
* MOTION IS SCRIPT, AND ONLY WITH MOTION ALLOWED. The streams scroll and flow
* by SMIL, the ring and the flash play once by the Web Animations API, and the
* core and the dot pulse on @hanzo/design's `hanzo-pulse-dot`. None of it is
* drawn until the page knows motion is wanted, so the export and a reader who
* declines motion get the still picture.
*
* THE FILE SHIPS THE ANSWERS. Every value is in the markup at `opacity: 0`, and
* with no script the export's no-JS pass shows them, so the reader gets the
* settled program. The `[mask]` placeholders are drawn only once the page runs.
*/
import { useCallback, useEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react'
import { RotateCcw } from 'lucide-react'
import { motion } from '@hanzo/design'
import { Box, Text, View } from '@hanzo/ui'
import { Window } from '@/components/frame/Term'
import { Dot, Tag } from './figures'
import {
ALL,
DECISIONS,
FREEZE,
SOURCES,
answer,
draft,
prob,
reach,
type On,
type Source,
type Wave,
} from './scenario'
type Phase = 'masked' | 'uncertain' | 'resolved'
type Step = 'encode' | 'pass1' | 'pass2' | 'check' | 'done'
type Status = { step: Step; n: number }
const IDS = DECISIONS.map((d) => d.id)
const MASKED = Object.fromEntries(IDS.map((id) => [id, 'masked'])) as Record<string, Phase>
/** A scope window's width, in user units. Every trace repeats within it, so a
* scroll of exactly this much is seamless. */
const W = 48
/** How long one window of each trace takes to scroll past, in seconds. */
const PACE: Record<Wave, number> = {
frames: 2.4,
slow: 6,
ping: 3,
bits: 2,
noise: 1.6,
struct: 5,
saw: 3.6,
sparse: 7,
}
/** A deterministic scatter in [-1, 1], so the audio trace is noise that repeats. */
const jitter = (i: number) => {
const x = Math.sin(i * 12.9898 + 78.233) * 43758.5453
return (x - Math.floor(x)) * 2 - 1
}
/** One trace, two windows long, at amplitude `a`. */
function trace(kind: Wave, a: number): string {
const pts: [number, number][] = []
for (let base = 0; base < 2 * W; base += W) {
switch (kind) {
case 'slow':
for (let x = 0; x <= W; x += 2) pts.push([base + x, -Math.sin((x / W) * 2 * Math.PI) * 0.7])
break
case 'frames':
for (let x = 0; x < W; x += 12) pts.push([base + x, 0.6], [base + x + 3, 0.6], [base + x + 3, -0.7], [base + x + 6, -0.7], [base + x + 6, 0.6])
pts.push([base + W, 0.6])
break
case 'ping':
for (let x = 0; x < W; x += 24) pts.push([base + x, 0], [base + x + 10, 0], [base + x + 11, -1], [base + x + 12, 0.8], [base + x + 13, -0.3], [base + x + 14, 0])
pts.push([base + W, 0])
break
case 'bits': {
const bits = [1, 0, 1, 1, 0, 0, 1, 0, 1, 1, 1, 0]
bits.forEach((b, i) => pts.push([base + i * 4, b ? -0.6 : 0.6], [base + i * 4 + 4, b ? -0.6 : 0.6]))
break
}
case 'noise':
for (let i = 0; i <= W / 2; i++) pts.push([base + i * 2, i === W / 2 ? jitter(0) * 0.8 : jitter(i) * 0.8])
break
case 'struct': {
const steps = [0.5, 0.5, -0.5, 0]
for (let x = 0; x < W; x += 16) steps.forEach((l, i) => pts.push([base + x + i * 4, l], [base + x + i * 4 + 4, l]))
break
}
case 'saw':
for (let x = 0; x < W; x += 24) pts.push([base + x, 0.7], [base + x + 24, -0.7], [base + x + 24, 0.7])
break
case 'sparse':
pts.push([base, 0], [base + 18, 0], [base + 19, -0.6], [base + 20, 0.5], [base + 21, 0], [base + 34, 0], [base + 35, -0.3], [base + 36, 0], [base + W, 0])
break
}
}
return 'M' + pts.map(([x, y]) => `${+x.toFixed(1)} ${+(y * a).toFixed(1)}`).join(' L')
}
/** A drawing of the program at one scale. Every coordinate is a user unit. */
type Geo = {
shape: 'wide' | 'tall'
box: [number, number]
amp: number
scope: number
source: (i: number) => { y: number; label: number; scope: number; stream: string }
core: { x: number; y: number; w: number; h: number; caption: [number, number] }
row: (i: number) => number
node: { x: number; w: number; h: number; label: number; value: number; p: number }
out: (y: number) => string
spine: number
}
const WIDE: Geo = {
shape: 'wide',
box: [640, 452],
amp: 6,
scope: 18,
source: (i) => {
const y = 62 + i * 46
return { y, label: 54, scope: 60, stream: `M112 ${y} C160 ${y} 170 226 206 226` }
},
core: { x: 206, y: 202, w: 68, h: 48, caption: [240, 270] },
row: (i) => 40 + i * 34,
node: { x: 314, w: 288, h: 26, label: 324, value: 458, p: 594 },
out: (y) => `M274 226 C294 226 294 ${y} 314 ${y}`,
spine: 614,
}
const TALL: Geo = {
shape: 'tall',
box: [360, 616],
amp: 4.5,
scope: 14,
source: (i) => {
const y = 18 + i * 26
return { y, label: 52, scope: 58, stream: `M110 ${y} C170 ${y} 190 109 236 109` }
},
core: { x: 236, y: 86, w: 80, h: 46, caption: [276, 152] },
row: (i) => 246 + i * 31,
node: { x: 22, w: 308, h: 24, label: 30, value: 166, p: 322 },
out: (y) => `M12 ${y} H22`,
spine: 340,
}
/** The tall drawing's bus: down from the core, across, and down the left edge. */
const BUS = `M276 132 V224 H12 V${TALL.row(DECISIONS.length - 1)}`
/** Parents and the dependents drawn under them, for the brackets on the right. */
const FAMILIES = DECISIONS.filter((d) => DECISIONS.some((c) => c.after === d.id)).map((p) => ({
parent: p.id,
children: DECISIONS.filter((c) => c.after === p.id).map((c) => c.id),
}))
/** The inks the drawing is made of. */
const INK = {
lit: 'var(--foreground)',
text: 'var(--muted-foreground)',
mid: 'var(--text-secondary)',
dim: 'var(--text-tertiary)',
off: 'var(--text-disabled)',
live: 'var(--state-online)',
ground: 'var(--background)',
}
/** The live hue at a share, for an edge that is resolving rather than resolved. */
const live = (share: number) => `color-mix(in oklab, var(--state-online) ${share}%, transparent)`
/** An edge: a hairline with no fill. Paint is SVG presentation attributes, which read the design tokens as they are. */
const EDGE = { fill: 'none', strokeWidth: 1 } as const
/** A keyframe list played once, as the shape it is handed mounts. */
const once = (frames: Keyframe[], ms: number) => (el: Element | null) => {
el?.animate(frames, { duration: ms, easing: motion['ease-out'], fill: 'both' })
}
/** The one pulse an uncertain decision gets. The rect mounts with the phase, so it plays once per refinement;
* it scales about its own centre, which the animation states along with the scale. */
const RING = once(
[
{ opacity: 0.9, transform: 'scale(1)', transformBox: 'fill-box', transformOrigin: 'center' },
{ opacity: 0, transform: 'scale(1.04, 1.5)', transformBox: 'fill-box', transformOrigin: 'center' },
],
1100,
)
/** The glint a decision gives as it settles. */
const FLASH = once([{ opacity: 1 }, { opacity: 0 }], 800)
function says(s: Status, decided: number, deferred: number): string {
switch (s.step) {
case 'encode':
return 'encoding evidence'
case 'pass1':
return s.n ? `pass 1 · ${s.n} resolved` : 'pass 1 · every node at once'
case 'pass2':
return s.n ? `pass 2 · ${s.n} resolved` : 'pass 2 · the uncertain and dependent'
case 'check':
return 'constraint pass · 0 conflicts'
case 'done':
return `settled · ${decided} decided${deferred ? ` · ${deferred} deferred` : ''}`
}
}
function Drawing({
geo,
on,
phase,
hot,
busy,
run,
moving,
mounted,
}: {
geo: Geo
on: On
phase: Record<string, Phase>
hot: Source | null
busy: boolean
run: number
/** Motion is allowed and the page is running: the streams move and the one-shots play. */
moving: boolean
/** The page is running, so a masked value can show its placeholder. */
mounted: boolean
}) {
const clip = `kai-scope-${geo.shape}`
const lit = new Set(hot ? reach(hot) : [])
const { node: n } = geo
const right = n.x + n.w
const flat = `M0 0 H${2 * W}`
return (
// The drawing's face and inset ride the box around it; the SVG fills it and
// takes its height from its own aspect.
<Text display="flex" pt={4} px={geo.shape === 'tall' ? 4 : 12} pb={8} fontFamily="$mono" fontSize="$1" color="inherit">
<svg
data-shape={geo.shape}
viewBox={`0 0 ${geo.box[0]} ${geo.box[1]}`}
role="img"
aria-labelledby={`kai-title-${geo.shape}`}
width="100%"
>
<title id={`kai-title-${geo.shape}`}>
Example decision graph: evidence from camera, thermal, radar, CAN, audio, SysML, simulation and documents
flows into Kai, and twelve typed decisions about vehicle readiness resolve in parallel.
</title>
<defs>
<clipPath id={clip}>
<rect x={0} y={-geo.scope / 2} width={W} height={geo.scope} />
</clipPath>
</defs>
{SOURCES.map((s, i) => {
const at = geo.source(i)
const flowing = on[s.id]
const here = hot === s.id
return (
<g key={s.id}>
<text x={at.label} y={at.y + 4} textAnchor="end" fill={here ? INK.lit : flowing ? INK.dim : INK.off}>
{s.label}
</text>
<g transform={`translate(${at.scope} ${at.y})`}>
<rect
x={0}
y={-geo.scope / 2}
width={W}
height={geo.scope}
rx={2}
fill="none"
stroke="var(--white-10)"
/>
<g clipPath={`url(#${clip})`}>
<path
d={flowing ? trace(s.wave, geo.amp) : flat}
fill="none"
stroke={INK.lit}
strokeWidth={1}
strokeLinejoin="round"
opacity={flowing ? 0.75 : 0.25}
>
{moving && flowing ? (
<animateTransform
attributeName="transform"
type="translate"
from="0 0"
to={`${-W} 0`}
dur={`${PACE[s.wave]}s`}
repeatCount="indefinite"
/>
) : null}
</path>
</g>
</g>
<path
d={at.stream}
fill="none"
stroke={here ? INK.lit : flowing ? 'var(--white-30)' : 'var(--white-08)'}
strokeWidth={1}
strokeDasharray="2 6"
>
{moving && flowing ? (
<animate attributeName="stroke-dashoffset" from="0" to="-8" dur="1.4s" repeatCount="indefinite" />
) : null}
</path>
</g>
)
})}
{geo.shape === 'tall' ? <path d={BUS} {...EDGE} stroke="var(--white-10)" /> : null}
<g>
<rect
x={geo.core.x}
y={geo.core.y}
width={geo.core.w}
height={geo.core.h}
rx={10}
fill={INK.ground}
stroke={busy ? INK.lit : 'var(--white-40)'}
strokeWidth={1}
>
{moving && busy ? (
<animate
attributeName="stroke-opacity"
values="1;0.4;1"
keyTimes="0;0.5;1"
calcMode="spline"
keySplines="0.4 0 0.2 1;0.4 0 0.2 1"
dur="1.6s"
repeatCount="indefinite"
/>
) : null}
</rect>
<text
x={geo.core.x + geo.core.w / 2}
y={geo.core.y + geo.core.h / 2 + 5}
textAnchor="middle"
fontSize="var(--text-sm)"
fontWeight={600}
fill={INK.lit}
>
kai
</text>
</g>
<text x={geo.core.caption[0]} y={geo.core.caption[1]} textAnchor="middle" fill={INK.dim}>
shared state
</text>
{FAMILIES.map(({ parent, children }) => {
const yp = geo.row(IDS.indexOf(parent))
const ys = children.map((c) => geo.row(IDS.indexOf(c)))
const resolving = children.some((c) => phase[c] === 'uncertain')
return (
<path
key={parent}
d={`M${right} ${yp} H${geo.spine} V${Math.max(...ys)} ${ys.map((y) => `M${right} ${y} H${geo.spine}`).join(' ')}`}
fill="none"
stroke={resolving ? live(60) : 'var(--white-15)'}
strokeDasharray="2 3"
/>
)
})}
{DECISIONS.map((d, i) => {
const y = geo.row(i)
const p = phase[d.id]
const a = answer(d, on)
const shown = p === 'uncertain' ? prob(draft(d, on)) : a.defer ? '—' : prob(a.p)
const masked = p === 'masked'
// A deferred answer reads quiet whatever its phase; a resolving one reads a step down, its probability live.
const ink = a.defer ? INK.text : p === 'uncertain' ? INK.mid : INK.lit
const odds = a.defer ? INK.text : p === 'uncertain' ? INK.live : INK.lit
const box = lit.has(d.id) ? INK.lit : p === 'uncertain' ? live(70) : p === 'resolved' ? 'var(--white-30)' : 'var(--white-10)'
return (
<g key={d.id}>
<path
d={geo.out(y)}
{...EDGE}
stroke={p === 'uncertain' ? live(55) : p === 'resolved' ? 'var(--white-20)' : 'var(--white-10)'}
/>
<g
data-node={d.id}
data-phase={p}
data-p={shown}
data-defer={a.defer ? '' : undefined}
transform={`translate(0 ${y})`}
>
<rect x={n.x} y={-n.h / 2} width={n.w} height={n.h} rx={6} {...EDGE} fill={INK.ground} stroke={box} />
{moving && p === 'resolved' ? (
<rect
key={`lit-${run}`}
ref={FLASH}
x={n.x}
y={-n.h / 2}
width={n.w}
height={n.h}
rx={6}
fill="var(--white-10)"
/>
) : null}
{moving && p === 'uncertain' ? (
<rect
key={`ring-${run}`}
ref={RING}
x={n.x}
y={-n.h / 2}
width={n.w}
height={n.h}
rx={6}
fill="none"
stroke={INK.live}
strokeWidth={1.5}
/>
) : null}
<text x={n.label} y={4} fill={INK.dim}>
{d.id}
</text>
{mounted && masked ? <Mask x={n.value}>[mask]</Mask> : null}
{/* A value waiting on its pass is held at nothing once the page runs; before that — the export, a reader with no script — it reads. */}
<text data-v="" x={n.value} y={4} fill={ink} opacity={masked && mounted ? 0 : 1}>
{a.defer ? `defer → ${a.defer}` : a.value}
</text>
{mounted && masked ? (
<Mask x={n.p} end>
··
</Mask>
) : null}
<text data-v="" x={n.p} y={4} textAnchor="end" fill={odds} opacity={masked && mounted ? 0 : 1}>
{shown}
</text>
</g>
</g>
)
})}
</svg>
</Text>
)
}
/** A value's placeholder while its decision is masked. */
function Mask({ x, end, children }: { x: number; end?: boolean; children: ReactNode }) {
return (
<text x={x} y={4} textAnchor={end ? 'end' : undefined} fill={INK.off}>
{children}
</text>
)
}
/** A source's switch, or the replay. Hover and focus light its edge through `lit`, the state the drawing reads too. */
function Chip({
pressed,
lit,
quiet,
children,
...rest
}: {
pressed?: boolean
lit?: boolean
quiet?: boolean
children: ReactNode
} & Omit<ComponentProps<'button'>, 'style' | 'children' | 'type'>) {
const off = pressed === false
const ink = quiet ? 'var(--muted-foreground)' : off ? 'var(--text-tertiary)' : '$ink'
// 20 of line and 4 above and below, inside two hairlines, is the 30px chip.
// No height is stated: a coarse pointer's 44px floor is @hanzo/design's base
// rule, and a stated one would beat it.
return (
<Text
render={<button type="button" aria-pressed={pressed} {...rest} />}
display="inline-flex"
items="center"
gap={6}
py={4}
px={12}
lineHeight={20}
borderWidth={1}
borderStyle={off ? 'dashed' : 'solid'}
borderColor={quiet ? 'transparent' : lit ? 'var(--white-40)' : off ? 'var(--white-15)' : 'var(--white-20)'}
rounded="var(--radius-full)"
bg="transparent"
color={ink}
ml={quiet ? 'auto' : undefined}
cursor="pointer"
>
{quiet ? null : (
<View
render="span"
aria-hidden
width={6}
height={6}
rounded={1}
bg={off ? 'transparent' : ink}
boxShadow={off ? 'inset 0 0 0 1px currentColor' : undefined}
/>
)}
{children}
</Text>
)
}
export default function Graph() {
const [on, setOn] = useState<On>(ALL)
const [phase, setPhase] = useState<Record<string, Phase>>(MASKED)
const [status, setStatus] = useState<Status | null>(null)
const [hot, setHot] = useState<Source | null>(null)
const [run, setRun] = useState(0)
/** Unknown until the page runs; then whether the reader allows motion. */
const [moves, setMoves] = useState<boolean | null>(null)
const timers = useRef<number[]>([])
const still = useRef(false)
const clear = useCallback(() => {
for (const t of timers.current) window.clearTimeout(t)
timers.current = []
}, [])
/**
* Resolve `ids` against the evidence in `next`: every one masked, then all of
* them in one pass, then the uncertain and the dependent in a second. A
* dependent whose parent is refined waits for it — the depth of the graph,
* not the number of nodes, is what costs a pass.
*/
const play = useCallback(
(ids: string[], next: On) => {
clear()
setRun((r) => r + 1)
const targets = DECISIONS.filter((d) => ids.includes(d.id))
const first: Record<string, boolean> = {}
for (const d of targets) {
const parent = !d.after || !ids.includes(d.after) || first[d.after]
first[d.id] = parent && draft(d, next) >= FREEZE
}
const settle = (list: string[]) =>
setPhase((p) => ({ ...p, ...Object.fromEntries(list.map((id) => [id, 'resolved' as Phase])) }))
if (still.current) {
settle(ids)
setStatus({ step: 'done', n: 0 })
return
}
const at = (ms: number, f: () => void) => timers.current.push(window.setTimeout(f, ms))
setPhase((p) => ({ ...p, ...Object.fromEntries(ids.map((id) => [id, 'masked' as Phase])) }))
setStatus({ step: 'encode', n: 0 })
const STEP = 90
const t1 = 800
at(t1, () => setStatus({ step: 'pass1', n: 0 }))
targets.forEach((d, i) =>
at(t1 + 200 + i * STEP, () =>
setPhase((p) => ({ ...p, [d.id]: first[d.id] ? 'resolved' : 'uncertain' })),
),
)
const frozen = targets.filter((d) => first[d.id]).length
const t2 = t1 + 200 + targets.length * STEP
at(t2, () => setStatus({ step: 'pass1', n: frozen }))
const second = targets.filter((d) => !first[d.id])
const t3 = t2 + 1300
at(t3, () => setStatus({ step: 'pass2', n: 0 }))
second.forEach((d, i) => at(t3 + 300 + i * 240, () => settle([d.id])))
const t4 = t3 + 300 + second.length * 240
at(t4, () => setStatus({ step: 'pass2', n: second.length }))
at(t4 + 1100, () => setStatus({ step: 'check', n: 0 }))
at(t4 + 2000, () => setStatus({ step: 'done', n: 0 }))
},
[clear],
)
useEffect(() => {
still.current = window.matchMedia('(prefers-reduced-motion: reduce)').matches
setMoves(!still.current)
play(IDS, ALL)
return clear
}, [play, clear])
const flip = (s: Source) => {
const next = { ...on, [s]: !on[s] }
setOn(next)
play(reach(s), next)
}
const answers = DECISIONS.map((d) => answer(d, on))
const deferred = answers.filter((a) => a.defer).length
const busy = status !== null && status.step !== 'done'
const state = { on, phase, hot, busy, run, moving: moves === true, mounted: moves !== null }
return (
<Window title="vehicle.readiness@3" aside={<Tag>example · scripted</Tag>}>
<View display="grid">
<Text
render="p"
aria-live="polite"
display="flex"
items="center"
gap={8}
minH={36}
m={0}
pt={12}
px={16}
fontFamily="$mono"
fontSize="$1"
color="var(--muted-foreground)"
>
<Dot live={busy} />
<Text data-status color="inherit">
{status ? says(status, answers.length - deferred, deferred) : ' '}
</Text>
</Text>
<Box display="block" $max-sm={{ display: 'none' }}>
<Drawing geo={WIDE} {...state} />
</Box>
<Box display="none" $max-sm={{ display: 'block' }}>
<Drawing geo={TALL} {...state} />
</Box>
<Text
role="group"
aria-label="Evidence sources"
display="flex"
flexWrap="wrap"
items="center"
gap={8}
pt={12}
px={16}
pb={6}
borderTopWidth={1}
borderColor="$edge"
fontFamily="$mono"
fontSize="$1"
color="inherit"
>
<Text mr={4} color="var(--text-tertiary)">
evidence
</Text>
{SOURCES.filter((s) => s.toggle).map((s) => (
<Chip
key={s.id}
pressed={on[s.id]}
lit={hot === s.id}
data-source={s.id}
onClick={() => flip(s.id)}
onPointerEnter={() => setHot(s.id)}
onPointerLeave={() => setHot(null)}
onFocus={() => setHot(s.id)}
onBlur={() => setHot(null)}
>
{s.name}
</Chip>
))}
<Chip quiet onClick={() => play(IDS, on)}>
<RotateCcw size={12} aria-hidden /> replay
</Chip>
</Text>
<Text render="p" display="block" m={0} pt={4} px={16} pb={16} fontSize="$1" lineHeight="$1" color="var(--text-tertiary)">
Example: a scripted scenario, not model output. Switch a source off and only the decisions that read it run
again.
</Text>
</View>
</Window>
)
}