Rendre le dashboard actionnable (inbox, peek, carte mentale), aligner la facturation sur l’essai 7 jours, et bloquer le login e-mail tant que l’adresse n’est pas confirmée. Co-authored-by: Cursor <cursoragent@cursor.com>
972 lines
28 KiB
TypeScript
972 lines
28 KiB
TypeScript
'use client'
|
|
|
|
import { useId, useMemo } from 'react'
|
|
import {
|
|
Area,
|
|
AreaChart,
|
|
Bar,
|
|
BarChart,
|
|
CartesianGrid,
|
|
Line,
|
|
LineChart,
|
|
ResponsiveContainer,
|
|
Tooltip,
|
|
XAxis,
|
|
YAxis,
|
|
} from 'recharts'
|
|
import {
|
|
badgeGlyph,
|
|
dimOpacity,
|
|
intentColor,
|
|
spotlightColor,
|
|
} from '@/lib/interactive-demo/intent-colors'
|
|
import type { DemoScene, Panel } from '@/lib/interactive-demo/types'
|
|
import type {
|
|
ResolvedAnnotation,
|
|
StepResolvedState,
|
|
} from '@/lib/interactive-demo/resolve'
|
|
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
|
|
import { DemoRichLabel } from '@/components/interactive-demo/demo-rich-label'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
type AnnKind = ResolvedAnnotation['kind']
|
|
|
|
export type DemoSceneViewProps = {
|
|
scene: DemoScene
|
|
state: StepResolvedState
|
|
reducedMotion?: boolean
|
|
className?: string
|
|
}
|
|
|
|
type NodePos = {
|
|
x: number
|
|
y: number
|
|
w: number
|
|
h: number
|
|
}
|
|
|
|
function elementOpacity(
|
|
id: string,
|
|
state: StepResolvedState,
|
|
dim: number
|
|
): number {
|
|
// Overview = full scene at full brightness (matches edges behavior)
|
|
if (state.overview) return 1
|
|
if (!(id in state.revealed)) return 0
|
|
if (state.spotlight.length === 0) return 1
|
|
return state.spotlight.includes(id) ? 1 : dim
|
|
}
|
|
|
|
function estimateNodeSize(label: string): { w: number; h: number } {
|
|
const lines = label.split(/\\n|\n/)
|
|
const hasMath = /\$/.test(label)
|
|
const visualLen = (s: string) =>
|
|
s.replace(/\$[^$]+\$/g, (m) => 'x'.repeat(Math.min(18, Math.max(6, m.length * 0.45))))
|
|
.length
|
|
const longest = Math.max(...lines.map(visualLen), 6)
|
|
const w = Math.min(280, Math.max(128, longest * 7.4 + 40))
|
|
const h = Math.max(
|
|
hasMath ? 58 : 48,
|
|
30 + lines.length * (hasMath ? 24 : 18)
|
|
)
|
|
return { w, h }
|
|
}
|
|
|
|
/** Prefer circular layout when edges form a closed loop covering most nodes. */
|
|
function findCycleOrder(
|
|
ids: string[],
|
|
edges: { from: string; to: string }[]
|
|
): string[] | null {
|
|
if (ids.length < 3) return null
|
|
const idSet = new Set(ids)
|
|
const outs = new Map<string, string[]>()
|
|
for (const id of ids) outs.set(id, [])
|
|
for (const e of edges) {
|
|
if (!idSet.has(e.from) || !idSet.has(e.to) || e.from === e.to) continue
|
|
outs.get(e.from)!.push(e.to)
|
|
}
|
|
|
|
for (const start of ids) {
|
|
const path = [start]
|
|
const seen = new Set([start])
|
|
let cur = start
|
|
while (path.length < ids.length) {
|
|
const nexts = (outs.get(cur) ?? []).filter((t) => !seen.has(t))
|
|
if (nexts.length === 0) break
|
|
// Prefer continuing a simple cycle (single unused neighbor)
|
|
const n = nexts[0]!
|
|
path.push(n)
|
|
seen.add(n)
|
|
cur = n
|
|
}
|
|
const closes = (outs.get(cur) ?? []).includes(start)
|
|
if (closes && path.length >= 3 && path.length >= Math.ceil(ids.length * 0.75)) {
|
|
return path
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Layout strategies:
|
|
* 1. AttnRes trunk pattern (residualTrunk + layers)
|
|
* 2. Closed cycle → circular
|
|
* 3. DAG layered (top → bottom)
|
|
* 4. Fallback grid
|
|
*/
|
|
function layoutNodes(
|
|
nodes: { id: string; label?: string }[],
|
|
edges: { from: string; to: string }[]
|
|
): { positions: Map<string, NodePos>; width: number; height: number } {
|
|
const sizes = new Map(
|
|
nodes.map((n) => [n.id, estimateNodeSize(n.label ?? n.id)] as const)
|
|
)
|
|
const positions = new Map<string, NodePos>()
|
|
const padX = 40
|
|
const padY = 36
|
|
const gapX = 52
|
|
const gapY = 32
|
|
|
|
const trunk = nodes.find((n) => n.id === 'residualTrunk')
|
|
const layersOnly = nodes.filter((n) => n.id !== 'residualTrunk')
|
|
|
|
if (trunk && layersOnly.length >= 2) {
|
|
const layerSizes = layersOnly.map((n) => sizes.get(n.id)!)
|
|
const maxLayerW = Math.max(...layerSizes.map((s) => s.w), 96)
|
|
const trunkSize = sizes.get(trunk.id)!
|
|
const contentH =
|
|
padY * 2 +
|
|
layerSizes.reduce((acc, s) => acc + s.h, 0) +
|
|
gapY * Math.max(0, layersOnly.length - 1)
|
|
const height = Math.max(240, contentH)
|
|
const width = padX * 2 + maxLayerW + gapX + trunkSize.w + 24
|
|
|
|
let y = padY
|
|
layersOnly.forEach((n) => {
|
|
const s = sizes.get(n.id)!
|
|
positions.set(n.id, {
|
|
x: padX + maxLayerW / 2,
|
|
y: y + s.h / 2,
|
|
w: s.w,
|
|
h: s.h,
|
|
})
|
|
y += s.h + gapY
|
|
})
|
|
positions.set(trunk.id, {
|
|
x: padX + maxLayerW + gapX + trunkSize.w / 2,
|
|
y: height / 2,
|
|
w: trunkSize.w,
|
|
h: trunkSize.h,
|
|
})
|
|
return { positions, width, height }
|
|
}
|
|
|
|
const cycle = findCycleOrder(
|
|
nodes.map((n) => n.id),
|
|
edges
|
|
)
|
|
if (cycle) {
|
|
const maxW = Math.max(...cycle.map((id) => sizes.get(id)!.w), 128)
|
|
const maxH = Math.max(...cycle.map((id) => sizes.get(id)!.h), 48)
|
|
const radius = Math.max(110, (cycle.length * 42) / (2 * Math.PI) + maxW * 0.35)
|
|
const leftovers = nodes.filter((n) => !cycle.includes(n.id))
|
|
const leftoverRowH = leftovers.length ? maxH + gapY : 0
|
|
const width = Math.max(
|
|
420,
|
|
padX * 2 + radius * 2 + maxW,
|
|
padX * 2 + leftovers.reduce((acc, n) => acc + sizes.get(n.id)!.w, 0) + gapX * Math.max(0, leftovers.length - 1)
|
|
)
|
|
const height = Math.max(360, padY * 2 + radius * 2 + maxH + leftoverRowH)
|
|
const cx = width / 2
|
|
const cy = padY + radius + maxH / 2
|
|
cycle.forEach((id, i) => {
|
|
const s = sizes.get(id)!
|
|
const angle = -Math.PI / 2 + (2 * Math.PI * i) / cycle.length
|
|
positions.set(id, {
|
|
x: cx + radius * Math.cos(angle),
|
|
y: cy + radius * Math.sin(angle),
|
|
w: s.w,
|
|
h: s.h,
|
|
})
|
|
})
|
|
// Leftover nodes: horizontal row BELOW the ring (never overlapping it)
|
|
if (leftovers.length) {
|
|
const totalW =
|
|
leftovers.reduce((acc, n) => acc + sizes.get(n.id)!.w, 0) +
|
|
gapX * (leftovers.length - 1)
|
|
let x = cx - totalW / 2
|
|
const y = cy + radius + maxH / 2 + gapY + maxH / 2
|
|
for (const n of leftovers) {
|
|
const s = sizes.get(n.id)!
|
|
positions.set(n.id, { x: x + s.w / 2, y, w: s.w, h: s.h })
|
|
x += s.w + gapX
|
|
}
|
|
}
|
|
return { positions, width, height }
|
|
}
|
|
|
|
// Topological layers (sources at top)
|
|
const ids = nodes.map((n) => n.id)
|
|
const idSet = new Set(ids)
|
|
const indeg = new Map(ids.map((id) => [id, 0]))
|
|
const outs = new Map(ids.map((id) => [id, [] as string[]]))
|
|
for (const e of edges) {
|
|
if (!idSet.has(e.from) || !idSet.has(e.to) || e.from === e.to) continue
|
|
indeg.set(e.to, (indeg.get(e.to) ?? 0) + 1)
|
|
outs.get(e.from)!.push(e.to)
|
|
}
|
|
|
|
const queue = ids.filter((id) => (indeg.get(id) ?? 0) === 0)
|
|
const order: string[] = []
|
|
const depth = new Map<string, number>()
|
|
queue.forEach((id) => depth.set(id, 0))
|
|
const q = [...queue]
|
|
while (q.length) {
|
|
const u = q.shift()!
|
|
order.push(u)
|
|
for (const v of outs.get(u) ?? []) {
|
|
depth.set(v, Math.max(depth.get(v) ?? 0, (depth.get(u) ?? 0) + 1))
|
|
indeg.set(v, (indeg.get(v) ?? 1) - 1)
|
|
if ((indeg.get(v) ?? 0) === 0) q.push(v)
|
|
}
|
|
}
|
|
|
|
const isDag = order.length === ids.length && edges.length > 0
|
|
|
|
if (isDag) {
|
|
const byDepth = new Map<number, string[]>()
|
|
for (const id of ids) {
|
|
const d = depth.get(id) ?? 0
|
|
if (!byDepth.has(d)) byDepth.set(d, [])
|
|
byDepth.get(d)!.push(id)
|
|
}
|
|
const maxDepth = Math.max(...byDepth.keys(), 0)
|
|
const rowHeights: number[] = []
|
|
let width = padX * 2
|
|
for (let d = 0; d <= maxDepth; d++) {
|
|
const row = byDepth.get(d) ?? []
|
|
const rowH = Math.max(...row.map((id) => sizes.get(id)!.h), 36)
|
|
rowHeights.push(rowH)
|
|
const rowW =
|
|
row.reduce((acc, id) => acc + sizes.get(id)!.w, 0) +
|
|
gapX * Math.max(0, row.length - 1)
|
|
width = Math.max(width, padX * 2 + rowW)
|
|
}
|
|
const height =
|
|
padY * 2 +
|
|
rowHeights.reduce((a, b) => a + b, 0) +
|
|
gapY * Math.max(0, maxDepth)
|
|
|
|
let y = padY
|
|
for (let d = 0; d <= maxDepth; d++) {
|
|
const row = byDepth.get(d) ?? []
|
|
const rowH = rowHeights[d]!
|
|
const rowW =
|
|
row.reduce((acc, id) => acc + sizes.get(id)!.w, 0) +
|
|
gapX * Math.max(0, row.length - 1)
|
|
let x = (width - rowW) / 2
|
|
for (const id of row) {
|
|
const s = sizes.get(id)!
|
|
positions.set(id, {
|
|
x: x + s.w / 2,
|
|
y: y + rowH / 2,
|
|
w: s.w,
|
|
h: s.h,
|
|
})
|
|
x += s.w + gapX
|
|
}
|
|
y += rowH + gapY
|
|
}
|
|
return { positions, width: Math.max(400, width), height: Math.max(220, height) }
|
|
}
|
|
|
|
// Grid fallback
|
|
const cols = Math.min(3, Math.max(1, Math.ceil(Math.sqrt(nodes.length))))
|
|
const rows = Math.ceil(nodes.length / cols)
|
|
const colW: number[] = Array.from({ length: cols }, (_, c) => {
|
|
let max = 120
|
|
nodes.forEach((n, i) => {
|
|
if (i % cols === c) max = Math.max(max, sizes.get(n.id)!.w)
|
|
})
|
|
return max
|
|
})
|
|
const rowH: number[] = Array.from({ length: rows }, (_, r) => {
|
|
let max = 48
|
|
nodes.forEach((n, i) => {
|
|
if (Math.floor(i / cols) === r) max = Math.max(max, sizes.get(n.id)!.h)
|
|
})
|
|
return max
|
|
})
|
|
const width =
|
|
padX * 2 + colW.reduce((a, b) => a + b, 0) + gapX * Math.max(0, cols - 1)
|
|
const height =
|
|
padY * 2 + rowH.reduce((a, b) => a + b, 0) + gapY * Math.max(0, rows - 1)
|
|
|
|
nodes.forEach((n, i) => {
|
|
const c = i % cols
|
|
const r = Math.floor(i / cols)
|
|
const s = sizes.get(n.id)!
|
|
const xOff =
|
|
padX +
|
|
colW.slice(0, c).reduce((a, b) => a + b, 0) +
|
|
gapX * c +
|
|
colW[c]! / 2
|
|
const yOff =
|
|
padY +
|
|
rowH.slice(0, r).reduce((a, b) => a + b, 0) +
|
|
gapY * r +
|
|
rowH[r]! / 2
|
|
positions.set(n.id, { x: xOff, y: yOff, w: s.w, h: s.h })
|
|
})
|
|
|
|
return { positions, width: Math.max(400, width), height: Math.max(220, height) }
|
|
}
|
|
|
|
/** Border intersection: line from center A → center B, clipped to rect A. */
|
|
function borderPoint(
|
|
from: NodePos,
|
|
to: NodePos
|
|
): { x: number; y: number } {
|
|
const dx = to.x - from.x
|
|
const dy = to.y - from.y
|
|
if (dx === 0 && dy === 0) return { x: from.x, y: from.y }
|
|
const hw = from.w / 2
|
|
const hh = from.h / 2
|
|
const ax = Math.abs(dx) / (hw || 1)
|
|
const ay = Math.abs(dy) / (hh || 1)
|
|
const t = 1 / Math.max(ax, ay)
|
|
return { x: from.x + dx * t, y: from.y + dy * t }
|
|
}
|
|
|
|
function edgePath(a: NodePos, b: NodePos, tipInset = 10): string {
|
|
const p0 = borderPoint(a, b)
|
|
const p1 = borderPoint(b, a)
|
|
const dx = p1.x - p0.x
|
|
const dy = p1.y - p0.y
|
|
const len = Math.hypot(dx, dy) || 1
|
|
// Stop short of the target so markerEnd tip lands on the node border, not under the card
|
|
const inset = Math.min(tipInset, len * 0.35)
|
|
const endX = p1.x - (dx / len) * inset
|
|
const endY = p1.y - (dy / len) * inset
|
|
const mx = (p0.x + endX) / 2
|
|
const my = (p0.y + endY) / 2
|
|
const bend = Math.min(36, len * 0.22)
|
|
const cx = mx - (dy / len) * bend
|
|
const cy = my + (dx / len) * bend
|
|
return `M ${p0.x} ${p0.y} Q ${cx} ${cy} ${endX} ${endY}`
|
|
}
|
|
|
|
function AnnotationsOverlay({
|
|
annotations,
|
|
getAnchor,
|
|
dark,
|
|
markerId,
|
|
shadowId,
|
|
}: {
|
|
annotations: ResolvedAnnotation[]
|
|
getAnchor: (
|
|
id: string,
|
|
kind: AnnKind
|
|
) => { x: number; y: number } | null
|
|
dark: boolean
|
|
markerId: string
|
|
shadowId?: string
|
|
}) {
|
|
const outline = spotlightColor(dark)
|
|
return (
|
|
<g className="demo-annotations" pointerEvents="none">
|
|
{annotations.map((ann, i) => {
|
|
const target = ann.targetIds[0]
|
|
if (!target) return null
|
|
const pos = getAnchor(target, ann.kind)
|
|
if (!pos) return null
|
|
const color = intentColor(ann.intent, dark)
|
|
const key = `${ann.kind}-${target}-${i}`
|
|
|
|
if (ann.kind === 'circle') {
|
|
return (
|
|
<circle
|
|
key={key}
|
|
cx={pos.x}
|
|
cy={pos.y}
|
|
r={22}
|
|
fill="none"
|
|
stroke={color}
|
|
strokeWidth={2.5}
|
|
opacity={0.9}
|
|
/>
|
|
)
|
|
}
|
|
if (ann.kind === 'badge') {
|
|
const n = ann.badgeIndex ?? i + 1
|
|
return (
|
|
<g key={key} transform={`translate(${pos.x}, ${pos.y})`}>
|
|
<circle r={11} fill={outline} opacity={0.95} />
|
|
<text
|
|
textAnchor="middle"
|
|
dominantBaseline="central"
|
|
fill="#fff"
|
|
fontSize={11}
|
|
fontWeight={700}
|
|
fontFamily="ui-sans-serif, system-ui, sans-serif"
|
|
>
|
|
{badgeGlyph(n)}
|
|
</text>
|
|
</g>
|
|
)
|
|
}
|
|
if (ann.kind === 'callout') {
|
|
const text = ann.text ?? ''
|
|
const w = Math.min(200, Math.max(72, text.length * 6.5 + 20))
|
|
return (
|
|
<g key={key} transform={`translate(${pos.x}, ${pos.y})`}>
|
|
<rect
|
|
x={8}
|
|
y={-14}
|
|
width={w}
|
|
height={28}
|
|
rx={8}
|
|
fill={dark ? '#1a1a1a' : '#ffffff'}
|
|
stroke={color}
|
|
strokeWidth={1.5}
|
|
filter={shadowId ? `url(#${shadowId})` : undefined}
|
|
/>
|
|
<text
|
|
x={18}
|
|
y={5}
|
|
fontSize={12}
|
|
fontWeight={500}
|
|
fill={dark ? '#f0f0f0' : '#1a1a1a'}
|
|
fontFamily="ui-sans-serif, system-ui, sans-serif"
|
|
>
|
|
{text}
|
|
</text>
|
|
</g>
|
|
)
|
|
}
|
|
if (ann.kind === 'arrow') {
|
|
return (
|
|
<path
|
|
key={key}
|
|
d={`M ${pos.x - 28} ${pos.y - 28} L ${pos.x - 6} ${pos.y - 6}`}
|
|
stroke={color}
|
|
strokeWidth={2.5}
|
|
strokeLinecap="round"
|
|
markerEnd={`url(#${markerId})`}
|
|
fill="none"
|
|
/>
|
|
)
|
|
}
|
|
return null
|
|
})}
|
|
</g>
|
|
)
|
|
}
|
|
|
|
function SvgScenePanel({
|
|
panel,
|
|
state,
|
|
reducedMotion,
|
|
dark,
|
|
}: {
|
|
panel: Extract<Panel, { type: 'svg-scene' }>
|
|
state: StepResolvedState
|
|
reducedMotion?: boolean
|
|
dark: boolean
|
|
}) {
|
|
const uid = useId().replace(/:/g, '')
|
|
const nodes = panel.payload.nodes
|
|
const edges = panel.payload.edges ?? []
|
|
const dim = dimOpacity(dark)
|
|
const outline = spotlightColor(dark)
|
|
const markerId = `demo-arrow-${uid}`
|
|
const dotsId = `demo-dots-${uid}`
|
|
|
|
const { positions, width, height } = useMemo(
|
|
() => layoutNodes(nodes, edges),
|
|
[nodes, edges]
|
|
)
|
|
|
|
const transition = reducedMotion
|
|
? 'none'
|
|
: 'opacity 220ms ease, transform 220ms ease, box-shadow 220ms ease'
|
|
|
|
const gridStroke = dark ? 'rgba(255,255,255,0.07)' : 'rgba(15,23,42,0.07)'
|
|
const boardBg = dark ? '#0c0e12' : '#f7f5f0'
|
|
|
|
return (
|
|
<div
|
|
className="relative w-full overflow-hidden"
|
|
style={{
|
|
background: boardBg,
|
|
aspectRatio: `${width} / ${height}`,
|
|
}}
|
|
>
|
|
<svg
|
|
viewBox={`0 0 ${width} ${height}`}
|
|
className="absolute inset-0 h-full w-full"
|
|
aria-hidden
|
|
>
|
|
<defs>
|
|
<pattern
|
|
id={dotsId}
|
|
width="18"
|
|
height="18"
|
|
patternUnits="userSpaceOnUse"
|
|
>
|
|
<circle cx="1.2" cy="1.2" r="1" fill={gridStroke} />
|
|
</pattern>
|
|
<marker
|
|
id={markerId}
|
|
markerWidth="10"
|
|
markerHeight="8"
|
|
refX="9"
|
|
refY="4"
|
|
orient="auto"
|
|
markerUnits="userSpaceOnUse"
|
|
>
|
|
<path d="M0,0 L10,4 L0,8 Z" fill={outline} />
|
|
</marker>
|
|
</defs>
|
|
<rect width={width} height={height} fill={`url(#${dotsId})`} />
|
|
|
|
{edges.map((e) => {
|
|
const a = positions.get(e.from)
|
|
const b = positions.get(e.to)
|
|
if (!a || !b) return null
|
|
const revealed = e.id in state.revealed || state.overview
|
|
if (!revealed) return null
|
|
const op = elementOpacity(e.id, state, dim)
|
|
if (op === 0) return null
|
|
const lit = state.overview || state.spotlight.includes(e.id)
|
|
const stroke = intentColor(e.intent, dark)
|
|
const widthStroke = (lit ? 2.8 : 1.8) + (e.weight ?? 1) * 0.45
|
|
return (
|
|
<path
|
|
key={e.id}
|
|
d={edgePath(a, b)}
|
|
stroke={stroke}
|
|
strokeWidth={widthStroke}
|
|
strokeDasharray={e.style === 'dashed' ? '7 5' : undefined}
|
|
strokeLinecap="round"
|
|
fill="none"
|
|
opacity={op}
|
|
markerEnd={`url(#${markerId})`}
|
|
style={{ transition }}
|
|
/>
|
|
)
|
|
})}
|
|
|
|
<AnnotationsOverlay
|
|
annotations={state.annotations}
|
|
dark={dark}
|
|
markerId={markerId}
|
|
getAnchor={(id, kind) => {
|
|
const p = positions.get(id)
|
|
if (!p) return null
|
|
if (kind === 'badge') {
|
|
return { x: p.x + p.w / 2 - 2, y: p.y - p.h / 2 + 2 }
|
|
}
|
|
if (kind === 'callout') {
|
|
return { x: p.x + p.w / 2 - 4, y: p.y }
|
|
}
|
|
return { x: p.x, y: p.y }
|
|
}}
|
|
/>
|
|
</svg>
|
|
|
|
{nodes.map((n) => {
|
|
const p = positions.get(n.id)
|
|
if (!p) return null
|
|
const op = elementOpacity(n.id, state, dim)
|
|
if (op === 0) return null
|
|
const lit = state.overview || state.spotlight.includes(n.id)
|
|
const accent = intentColor(n.intent, dark)
|
|
const label = n.label ?? n.id
|
|
return (
|
|
<div
|
|
key={n.id}
|
|
className={cn(
|
|
'absolute flex flex-col justify-center rounded-2xl px-3 py-2.5 backdrop-blur-[2px]',
|
|
dark ? 'bg-[#141820]/ee text-zinc-50' : 'bg-white/95 text-zinc-900'
|
|
)}
|
|
style={{
|
|
left: `${((p.x - p.w / 2) / width) * 100}%`,
|
|
top: `${((p.y - p.h / 2) / height) * 100}%`,
|
|
width: `${(p.w / width) * 100}%`,
|
|
minHeight: `${(p.h / height) * 100}%`,
|
|
opacity: op,
|
|
borderStyle: 'solid',
|
|
borderTopWidth: lit ? 2 : 1.5,
|
|
borderRightWidth: lit ? 2 : 1.5,
|
|
borderBottomWidth: lit ? 2 : 1.5,
|
|
borderLeftWidth: 5,
|
|
borderTopColor: lit ? outline : accent,
|
|
borderRightColor: lit ? outline : accent,
|
|
borderBottomColor: lit ? outline : accent,
|
|
borderLeftColor: accent,
|
|
boxShadow: lit
|
|
? `0 0 0 3px ${outline}33, 0 10px 28px -8px ${outline}66`
|
|
: dark
|
|
? '0 8px 24px -10px rgba(0,0,0,0.65)'
|
|
: '0 8px 22px -10px rgba(15,23,42,0.18)',
|
|
transition,
|
|
}}
|
|
>
|
|
<DemoRichLabel text={label} lit={lit} />
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ChartPanel({
|
|
panel,
|
|
state,
|
|
reducedMotion,
|
|
dark,
|
|
}: {
|
|
panel: Extract<Panel, { type: 'chart' }>
|
|
state: StepResolvedState
|
|
reducedMotion?: boolean
|
|
dark: boolean
|
|
}) {
|
|
const series = panel.payload.series
|
|
const dim = dimOpacity(dark)
|
|
const maxLen = Math.max(...series.map((s) => s.values.length), 0)
|
|
const data = useMemo(() => {
|
|
return Array.from({ length: maxLen }, (_, i) => {
|
|
const row: Record<string, number | string> = { i: String(i + 1) }
|
|
for (const s of series) {
|
|
if (!(s.id in state.revealed)) continue
|
|
row[s.id] = s.values[i] ?? 0
|
|
}
|
|
return row
|
|
})
|
|
}, [maxLen, series, state.revealed])
|
|
|
|
const Chart =
|
|
panel.payload.chartType === 'bar'
|
|
? BarChart
|
|
: panel.payload.chartType === 'area'
|
|
? AreaChart
|
|
: LineChart
|
|
|
|
return (
|
|
<div className="w-full h-52 min-h-[13rem] min-w-0">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<Chart data={data} margin={{ top: 12, right: 16, left: 4, bottom: 8 }}>
|
|
<CartesianGrid
|
|
strokeDasharray="3 3"
|
|
stroke={dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}
|
|
/>
|
|
<XAxis
|
|
dataKey="i"
|
|
tick={{ fontSize: 11, fill: dark ? '#a1a1aa' : '#71717a' }}
|
|
axisLine={false}
|
|
tickLine={false}
|
|
/>
|
|
<YAxis
|
|
tick={{ fontSize: 11, fill: dark ? '#a1a1aa' : '#71717a' }}
|
|
width={40}
|
|
axisLine={false}
|
|
tickLine={false}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
borderRadius: 10,
|
|
border: '1px solid var(--border)',
|
|
background: dark ? '#18181b' : '#fff',
|
|
fontSize: 12,
|
|
}}
|
|
/>
|
|
{series.map((s) => {
|
|
if (!(s.id in state.revealed)) return null
|
|
const op = elementOpacity(s.id, state, dim)
|
|
const stroke = intentColor(s.intent, dark)
|
|
if (panel.payload.chartType === 'bar') {
|
|
return (
|
|
<Bar
|
|
key={s.id}
|
|
dataKey={s.id}
|
|
fill={stroke}
|
|
fillOpacity={op}
|
|
radius={[4, 4, 0, 0]}
|
|
isAnimationActive={!reducedMotion}
|
|
/>
|
|
)
|
|
}
|
|
if (panel.payload.chartType === 'area') {
|
|
return (
|
|
<Area
|
|
key={s.id}
|
|
type="monotone"
|
|
dataKey={s.id}
|
|
stroke={stroke}
|
|
fill={stroke}
|
|
fillOpacity={op * 0.22}
|
|
strokeOpacity={op}
|
|
strokeWidth={2}
|
|
isAnimationActive={!reducedMotion}
|
|
/>
|
|
)
|
|
}
|
|
return (
|
|
<Line
|
|
key={s.id}
|
|
type="monotone"
|
|
dataKey={s.id}
|
|
stroke={stroke}
|
|
strokeOpacity={op}
|
|
strokeWidth={2.5}
|
|
dot={false}
|
|
isAnimationActive={!reducedMotion}
|
|
/>
|
|
)
|
|
})}
|
|
</Chart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function HeatmapPanel({
|
|
panel,
|
|
state,
|
|
reducedMotion,
|
|
dark,
|
|
}: {
|
|
panel: Extract<Panel, { type: 'heatmap-matrix' }>
|
|
state: StepResolvedState
|
|
reducedMotion?: boolean
|
|
dark: boolean
|
|
}) {
|
|
const { rows, cols, values, rowLabels, colLabels, triangular } = panel.payload
|
|
const cell = 34
|
|
const labelW = 44
|
|
const labelH = 26
|
|
const w = labelW + cols * cell + 8
|
|
const h = labelH + rows * cell + 8
|
|
const transition = reducedMotion ? 'none' : 'opacity 220ms ease'
|
|
const dim = dimOpacity(dark)
|
|
const fillIntent = spotlightColor(dark)
|
|
const ghostStroke = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.16)'
|
|
// Intensity ∝ value — normalized so real-world scales (not just [0,1]) work
|
|
const maxAbsV = Math.max(
|
|
1e-9,
|
|
...values.flat().map((v) => Math.abs(v))
|
|
)
|
|
|
|
const cellRect = (r: number, c: number) => ({
|
|
x: labelW + (c - 1) * cell,
|
|
y: labelH + (r - 1) * cell,
|
|
})
|
|
|
|
const positions = useMemo(() => {
|
|
const map = new Map<
|
|
string,
|
|
{ cx: number; cy: number; trX: number; trY: number }
|
|
>()
|
|
for (let r = 1; r <= rows; r++) {
|
|
for (let c = 1; c <= cols; c++) {
|
|
if (triangular === 'lower' && c > r) continue
|
|
if (triangular === 'upper' && c < r) continue
|
|
const id = `r${r}.c${c}`
|
|
const { x, y } = cellRect(r, c)
|
|
map.set(id, {
|
|
cx: x + cell / 2,
|
|
cy: y + cell / 2,
|
|
trX: x + cell - 2,
|
|
trY: y + 2,
|
|
})
|
|
}
|
|
}
|
|
return map
|
|
}, [rows, cols, triangular])
|
|
|
|
const uid = useId().replace(/:/g, '')
|
|
const markerId = `demo-arrowhead-hm-${uid}`
|
|
|
|
return (
|
|
<svg viewBox={`0 0 ${w} ${h}`} className="w-full h-auto max-w-lg mx-auto">
|
|
<defs>
|
|
<marker
|
|
id={markerId}
|
|
markerWidth="9"
|
|
markerHeight="9"
|
|
refX="7"
|
|
refY="3.5"
|
|
orient="auto"
|
|
>
|
|
<path d="M0,0 L7,3.5 L0,7 Z" fill={fillIntent} />
|
|
</marker>
|
|
</defs>
|
|
{colLabels?.map((lab, i) => (
|
|
<text
|
|
key={`c-${i}`}
|
|
x={labelW + i * cell + cell / 2}
|
|
y={labelH / 2 + 1}
|
|
textAnchor="middle"
|
|
dominantBaseline="central"
|
|
fontSize={10}
|
|
fontWeight={500}
|
|
fill={dark ? '#a1a1aa' : '#71717a'}
|
|
>
|
|
{lab}
|
|
</text>
|
|
))}
|
|
{rowLabels?.map((lab, i) => (
|
|
<text
|
|
key={`r-${i}`}
|
|
x={labelW / 2}
|
|
y={labelH + i * cell + cell / 2}
|
|
textAnchor="middle"
|
|
dominantBaseline="central"
|
|
fontSize={10}
|
|
fontWeight={500}
|
|
fill={dark ? '#a1a1aa' : '#71717a'}
|
|
>
|
|
{lab}
|
|
</text>
|
|
))}
|
|
{Array.from({ length: rows }, (_, ri) =>
|
|
Array.from({ length: cols }, (_, ci) => {
|
|
const r = ri + 1
|
|
const c = ci + 1
|
|
if (triangular === 'lower' && c > r) return null
|
|
if (triangular === 'upper' && c < r) return null
|
|
const id = `r${r}.c${c}`
|
|
const { x, y } = cellRect(r, c)
|
|
const revealed = id in state.revealed
|
|
const v = values[ri]?.[ci] ?? 0
|
|
const lit = state.overview || state.spotlight.includes(id)
|
|
const op = revealed ? elementOpacity(id, state, dim) : 1
|
|
|
|
if (!revealed) {
|
|
return (
|
|
<rect
|
|
key={id}
|
|
x={x + 1.5}
|
|
y={y + 1.5}
|
|
width={cell - 3}
|
|
height={cell - 3}
|
|
rx={5}
|
|
fill="none"
|
|
stroke={ghostStroke}
|
|
strokeWidth={1}
|
|
strokeDasharray="3 2"
|
|
opacity={0.8}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const vNorm = Math.min(1, Math.abs(v) / maxAbsV)
|
|
const fillOpacity = 0.14 + vNorm * 0.78
|
|
const textFill =
|
|
vNorm > 0.45 ? (dark ? '#0a0a0a' : '#fff') : dark ? '#eee' : '#111'
|
|
|
|
return (
|
|
<g key={id} opacity={op} style={{ transition }}>
|
|
<rect
|
|
x={x + 1.5}
|
|
y={y + 1.5}
|
|
width={cell - 3}
|
|
height={cell - 3}
|
|
rx={5}
|
|
fill={fillIntent}
|
|
fillOpacity={fillOpacity}
|
|
stroke={lit ? fillIntent : 'transparent'}
|
|
strokeWidth={lit ? 2 : 0}
|
|
/>
|
|
<text
|
|
x={x + cell / 2}
|
|
y={y + cell / 2}
|
|
textAnchor="middle"
|
|
dominantBaseline="central"
|
|
fontSize={9}
|
|
fontWeight={600}
|
|
fill={textFill}
|
|
>
|
|
{v.toFixed(2)}
|
|
</text>
|
|
</g>
|
|
)
|
|
})
|
|
)}
|
|
<AnnotationsOverlay
|
|
annotations={state.annotations}
|
|
dark={dark}
|
|
markerId={markerId}
|
|
getAnchor={(id, kind) => {
|
|
const p = positions.get(id)
|
|
if (!p) return null
|
|
if (kind === 'badge') return { x: p.trX, y: p.trY }
|
|
if (kind === 'callout') return { x: p.trX + 4, y: p.cy }
|
|
return { x: p.cx, y: p.cy }
|
|
}}
|
|
/>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
export function DemoSceneView({
|
|
scene,
|
|
state,
|
|
reducedMotion,
|
|
className,
|
|
}: DemoSceneViewProps) {
|
|
const dark = useDarkMode()
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'grid gap-4',
|
|
scene.panels.length === 2 ? 'md:grid-cols-2' : 'grid-cols-1',
|
|
className
|
|
)}
|
|
>
|
|
{scene.panels.map((panel) => (
|
|
<div
|
|
key={panel.id}
|
|
className={cn(
|
|
'overflow-hidden rounded-xl border shadow-sm',
|
|
dark
|
|
? 'border-white/10 bg-[#0f1115]'
|
|
: 'border-black/10 bg-[#faf9f6]'
|
|
)}
|
|
>
|
|
{panel.type === 'svg-scene' && (
|
|
<SvgScenePanel
|
|
panel={panel}
|
|
state={state}
|
|
reducedMotion={reducedMotion}
|
|
dark={dark}
|
|
/>
|
|
)}
|
|
{panel.type === 'chart' && (
|
|
<div className="p-3">
|
|
<ChartPanel
|
|
panel={panel}
|
|
state={state}
|
|
reducedMotion={reducedMotion}
|
|
dark={dark}
|
|
/>
|
|
</div>
|
|
)}
|
|
{panel.type === 'heatmap-matrix' && (
|
|
<div className="p-3">
|
|
<HeatmapPanel
|
|
panel={panel}
|
|
state={state}
|
|
reducedMotion={reducedMotion}
|
|
dark={dark}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|