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:
Antigravity
2026-07-24 17:51:43 +00:00
parent f385d43d5d
commit 69c99e4f4f
67 changed files with 12005 additions and 33 deletions

View File

@@ -0,0 +1,81 @@
'use client'
import { useMemo } from 'react'
import katex from 'katex'
import 'katex/dist/katex.min.css'
import { cn } from '@/lib/utils'
/** Render node/callout label: plain lines + inline $KaTeX$. */
export function DemoRichLabel({
text,
className,
lit,
}: {
text: string
className?: string
lit?: boolean
}) {
const lines = useMemo(() => {
return text.split(/\\n|\n/).map((line) => {
const parts: Array<{ type: 'text' | 'math'; value: string }> = []
const re = /\$([^$]+)\$/g
let last = 0
let m: RegExpExecArray | null
while ((m = re.exec(line)) !== null) {
if (m.index > last) {
parts.push({ type: 'text', value: line.slice(last, m.index) })
}
parts.push({ type: 'math', value: m[1] || '' })
last = m.index + m[0].length
}
if (last < line.length) parts.push({ type: 'text', value: line.slice(last) })
if (parts.length === 0) parts.push({ type: 'text', value: line })
return parts
})
}, [text])
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-0.5 text-center leading-snug',
className
)}
>
{lines.map((parts, i) => (
<div
key={i}
className={cn(
'flex flex-wrap items-center justify-center gap-x-1',
i === 0
? lit
? 'text-[13px] font-semibold tracking-tight'
: 'text-[12.5px] font-semibold tracking-tight'
: 'text-[12px] font-medium opacity-90'
)}
>
{parts.map((p, j) => {
if (p.type === 'math') {
let html = p.value
try {
html = katex.renderToString(p.value, {
displayMode: false,
throwOnError: false,
})
} catch {
/* keep raw */
}
return (
<span
key={j}
className="katex-node inline-block [&_.katex]:text-[0.95em]"
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
return <span key={j}>{p.value}</span>
})}
</div>
))}
</div>
)
}

View 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>
)
}

View File

@@ -0,0 +1,62 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import katex from 'katex'
import { marked } from 'marked'
import { sanitizeRichHtml } from '@/lib/sanitize-content'
import 'katex/dist/katex.min.css'
/**
* Render demo `speak`: light markdown + inline $KaTeX$ via the note pipeline pieces.
*/
export function DemoSpeak({ speak, className }: { speak: string; className?: string }) {
const html = useMemo(() => {
const placeholders: string[] = []
const withSlots = speak.replace(/\$([^$]+)\$/g, (_, tex: string) => {
const i = placeholders.length
try {
placeholders.push(
katex.renderToString(tex, { displayMode: false, throwOnError: false })
)
} catch {
placeholders.push(tex)
}
return `%%KATEX${i}%%`
})
let md = marked.parse(withSlots, { gfm: true, breaks: true }) as string
placeholders.forEach((frag, i) => {
md = md.replace(`%%KATEX${i}%%`, frag)
})
return sanitizeRichHtml(md)
}, [speak])
return (
<div
className={className}
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
export function useDarkMode(): boolean {
const [isDark, setIsDark] = useState(() =>
typeof document !== 'undefined'
? document.documentElement.classList.contains('dark')
: false
)
useEffect(() => {
const check = () =>
setIsDark(document.documentElement.classList.contains('dark'))
check()
const obs = new MutationObserver(check)
obs.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
})
return () => obs.disconnect()
}, [])
return isDark
}

View File

@@ -0,0 +1,387 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Pause,
Play,
RotateCcw,
SkipBack,
SkipForward,
StepBack,
StepForward,
} from 'lucide-react'
import { DemoSceneView } from '@/components/interactive-demo/demo-scene-view'
import { DemoSpeak } from '@/components/interactive-demo/demo-speak'
import {
resolveInteractiveDemo,
type InteractiveDemoV1,
} from '@/lib/interactive-demo'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
const SPEEDS = [0.5, 1, 2, 4] as const
const WPM = 220
const STEP_FLOOR_MS = 1500
const STEP_CEIL_MS = 6000
const ACT_CHANGE_PAUSE_MS = 800
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(false)
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
setReduced(mq.matches)
const onChange = () => setReduced(mq.matches)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}, [])
return reduced
}
function stepDurationMs(speak: string, speed: number): number {
const words = speak.trim().split(/\s+/).filter(Boolean).length
const raw = words * (60_000 / WPM)
const clamped = Math.min(STEP_CEIL_MS, Math.max(STEP_FLOOR_MS, raw))
return clamped / speed
}
export type InteractiveDemoPlayerProps = {
demo: InteractiveDemoV1
mode?: 'interactive' | 'static'
className?: string
}
export function InteractiveDemoPlayer({
demo,
mode = 'interactive',
className,
}: InteractiveDemoPlayerProps) {
const { t } = useLanguage()
const reducedMotion = usePrefersReducedMotion()
const resolved = useMemo(() => resolveInteractiveDemo(demo), [demo])
const [actIndex, setActIndex] = useState(0)
const [stepIndex, setStepIndex] = useState(0)
const [playing, setPlaying] = useState(false)
const [speedIdx, setSpeedIdx] = useState(1)
const speed = SPEEDS[speedIdx] ?? 1
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const actPauseRef = useRef(false)
/** Keyboard shortcuts only fire for the hovered/focused player. */
const activeRef = useRef(false)
const act = resolved.acts[actIndex] ?? resolved.acts[0]
const scenes = useMemo(() => {
let scene = demo.scene
const map: (typeof demo.scene)[] = []
for (const a of demo.acts) {
if (a.scene) scene = a.scene
map.push(scene)
}
return map
}, [demo])
const scene = scenes[actIndex] ?? demo.scene
const state =
mode === 'static'
? act?.final
: act?.steps[stepIndex] ?? act?.final
const clearTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}, [])
const goStep = useCallback(
(next: number) => {
if (!act) return
if (next < 0) {
setStepIndex(0)
return
}
if (next >= act.steps.length) {
setPlaying(false)
setStepIndex(act.steps.length - 1)
return
}
setStepIndex(next)
},
[act]
)
const resetAct = useCallback(() => {
clearTimer()
actPauseRef.current = false
setPlaying(false)
setStepIndex(0)
}, [clearTimer])
const resetDemo = useCallback(() => {
clearTimer()
actPauseRef.current = false
setPlaying(false)
setActIndex(0)
setStepIndex(0)
}, [clearTimer])
const skipAct = useCallback(() => {
clearTimer()
actPauseRef.current = false
setPlaying(false)
if (actIndex >= resolved.acts.length - 1) {
setStepIndex((act?.steps.length ?? 1) - 1)
return
}
setActIndex((i) => i + 1)
setStepIndex(0)
}, [actIndex, act, resolved.acts.length, clearTimer])
const prevAct = useCallback(() => {
clearTimer()
actPauseRef.current = false
setPlaying(false)
if (actIndex <= 0) {
setStepIndex(0)
return
}
setActIndex((i) => i - 1)
setStepIndex(0)
}, [actIndex, clearTimer])
// Auto-play: duration recalculates immediately when speed/step changes
useEffect(() => {
clearTimer()
if (!playing || mode !== 'interactive' || !act || !state) return
const atLastStep = stepIndex >= act.steps.length - 1
const ms = stepDurationMs(state.speak, speed)
timerRef.current = setTimeout(() => {
if (atLastStep) {
// End of act while playing
if (actIndex < resolved.acts.length - 1) {
actPauseRef.current = true
timerRef.current = setTimeout(() => {
actPauseRef.current = false
setActIndex((i) => i + 1)
setStepIndex(0)
}, ACT_CHANGE_PAUSE_MS)
} else {
// End of demo — pause on final state
setPlaying(false)
}
return
}
setStepIndex((s) => s + 1)
}, ms)
return clearTimer
}, [
playing,
stepIndex,
speed,
act,
state,
mode,
actIndex,
resolved.acts.length,
clearTimer,
])
useEffect(() => {
if (mode !== 'interactive') return
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName
if (
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
(e.target as HTMLElement)?.isContentEditable
) {
return
}
// Multi-player pages: only the hovered/focused player answers the keyboard
if (!activeRef.current) return
if (e.code === 'Space') {
e.preventDefault()
setPlaying((p) => !p)
} else if (e.code === 'ArrowRight' && e.shiftKey) {
e.preventDefault()
skipAct()
} else if (e.code === 'ArrowLeft' && e.shiftKey) {
e.preventDefault()
prevAct()
} else if (e.code === 'ArrowRight') {
e.preventDefault()
setPlaying(false)
goStep(stepIndex + 1)
} else if (e.code === 'ArrowLeft') {
e.preventDefault()
setPlaying(false)
goStep(stepIndex - 1)
} else if (e.code === 'KeyR' && e.shiftKey) {
e.preventDefault()
resetDemo()
} else if (e.code === 'KeyR') {
e.preventDefault()
resetAct()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [mode, stepIndex, goStep, resetAct, resetDemo, skipAct, prevAct])
if (!act || !state || !scene) {
return (
<div className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
{t('interactiveDemo.empty') || 'Interactive demo unavailable'}
</div>
)
}
const progressLabel = `${act.title} · ${t('interactiveDemo.step') || 'étape'} ${stepIndex + 1} / ${act.steps.length}`
return (
<div
className={cn(
'interactive-demo-player my-4 overflow-hidden rounded-2xl border border-border/80 bg-card text-card-foreground shadow-sm',
className
)}
data-demo-id={demo.id}
data-mode={mode}
onPointerEnter={() => {
activeRef.current = true
}}
onPointerLeave={() => {
activeRef.current = false
}}
onFocusCapture={() => {
activeRef.current = true
}}
onBlurCapture={() => {
activeRef.current = false
}}
>
{demo.disclaimer && (
<p className="border-b border-border/40 bg-muted/30 px-4 py-2.5 text-[11px] leading-relaxed text-muted-foreground italic">
{demo.disclaimer}
</p>
)}
<div className="p-3 sm:p-4">
<DemoSceneView
scene={scene}
state={state}
reducedMotion={reducedMotion || mode === 'static'}
/>
</div>
<div className="border-t border-border/50 bg-gradient-to-b from-muted/40 to-muted/10 px-4 pb-3 pt-1">
<DemoSpeak
speak={state.speak}
className="py-3 text-[15px] leading-relaxed text-foreground/90 prose prose-sm dark:prose-invert max-w-none [&>p]:my-0 [&>p]:font-medium"
/>
<div className="flex items-center justify-between gap-2 pb-2.5 text-[11px] uppercase tracking-wide text-muted-foreground">
<span className="truncate font-semibold normal-case tracking-normal text-foreground/70">
{progressLabel}
</span>
</div>
{mode === 'interactive' && (
<div className="flex flex-wrap items-center gap-2 pb-2">
<div className="flex items-center gap-1 rounded-lg border border-border/70 bg-background/80 p-0.5 shadow-sm">
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-primary text-primary-foreground hover:opacity-90"
onClick={() => setPlaying((p) => !p)}
aria-label={playing ? 'Pause' : 'Play'}
>
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</button>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
onClick={() => {
setPlaying(false)
goStep(stepIndex - 1)
}}
aria-label="Previous step"
>
<StepBack className="h-4 w-4" />
</button>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
onClick={() => {
setPlaying(false)
goStep(stepIndex + 1)
}}
aria-label="Next step"
>
<StepForward className="h-4 w-4" />
</button>
<div className="mx-0.5 h-5 w-px bg-border" />
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
onClick={prevAct}
aria-label="Previous act"
>
<SkipBack className="h-4 w-4" />
</button>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
onClick={skipAct}
aria-label="Next act"
>
<SkipForward className="h-4 w-4" />
</button>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
onClick={resetAct}
aria-label="Reset act"
title="R"
>
<RotateCcw className="h-4 w-4" />
</button>
</div>
<div
className="ml-auto flex items-center gap-0.5 rounded-lg border border-border/70 bg-background/80 p-0.5 shadow-sm"
role="group"
aria-label={t('interactiveDemo.speed') || 'Vitesse'}
>
{SPEEDS.map((s, i) => (
<button
key={s}
type="button"
onClick={() => setSpeedIdx(i)}
aria-pressed={speedIdx === i}
className={cn(
'h-7 min-w-[2.35rem] rounded-md px-1.5 text-xs font-semibold tabular-nums transition-colors',
speedIdx === i
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
{s}×
</button>
))}
</div>
</div>
)}
</div>
<noscript>
<ol className="space-y-1 list-inside list-decimal px-4 pb-4 text-sm">
{act.steps.map((s) => (
<li key={s.stepId}>{s.speak}</li>
))}
</ol>
</noscript>
</div>
)
}