mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
689 lines
24 KiB
TypeScript
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>
|
|
)
|
|
}
|