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:
81
memento-note/components/interactive-demo/demo-rich-label.tsx
Normal file
81
memento-note/components/interactive-demo/demo-rich-label.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
62
memento-note/components/interactive-demo/demo-speak.tsx
Normal file
62
memento-note/components/interactive-demo/demo-speak.tsx
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user