feat: page interactive, démos Play/Step et simulateur Carnot
Ajoute le pipeline PageSpec (validation, rendu, publication /p/{slug}),
les démos TipTap /demo, et le simulateur Carnot (modes frigo/PAC/moteur,
énergie kJ vs puissance W, unités K/°C/°F) avec correctifs d’équations KaTeX.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
948
memento-note/components/interactive-demo/demo-scene-view.tsx
Normal file
948
memento-note/components/interactive-demo/demo-scene-view.tsx
Normal file
@@ -0,0 +1,948 @@
|
||||
'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 {
|
||||
if (!(id in state.revealed)) return 0
|
||||
if (state.overview || 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)'
|
||||
|
||||
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])
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} className="w-full h-auto max-w-lg mx-auto">
|
||||
{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 fillOpacity = 0.14 + v * 0.78
|
||||
const textFill =
|
||||
v > 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="demo-arrowhead-hm"
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user