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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Clapperboard, Loader2, Globe } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { PageView } from '@/components/interactive-page/page-view'
|
||||
import {
|
||||
generateInteractivePage,
|
||||
generateInteractivePagePlan,
|
||||
generateInteractivePageSection,
|
||||
type PagePlan,
|
||||
} from '@/lib/ai/services/interactive-page-client.service'
|
||||
import {
|
||||
validateInteractivePage,
|
||||
type PageSection,
|
||||
type PageSpecV1,
|
||||
} from '@/lib/interactive-page'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
type Phase = 'idle' | 'generating' | 'preview' | 'publishing' | 'error'
|
||||
|
||||
function slugId(title: string): string {
|
||||
const s = title
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 40)
|
||||
return s ? `page.${s}` : 'page.generated'
|
||||
}
|
||||
|
||||
/** Degraded section when its LLM call failed — page still completes. */
|
||||
function fallbackSection(
|
||||
sectionId: string,
|
||||
plan: PagePlan['sections'][number],
|
||||
lang: string
|
||||
): PageSection {
|
||||
const fr = lang.startsWith('fr')
|
||||
return {
|
||||
id: sectionId,
|
||||
title: plan.title,
|
||||
blocks: [
|
||||
{ type: 'prose', md: plan.goal },
|
||||
{
|
||||
type: 'callout',
|
||||
kind: 'note',
|
||||
title: fr ? 'En bref' : 'In short',
|
||||
md: plan.demoGoal || plan.goal,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function assemblePage(
|
||||
plan: PagePlan,
|
||||
sections: PageSection[],
|
||||
lang: string
|
||||
): PageSpecV1 {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: slugId(plan.heroTitle),
|
||||
lang,
|
||||
hero: {
|
||||
kicker: lang.startsWith('fr')
|
||||
? 'EXPLAINER INTERACTIF'
|
||||
: 'INTERACTIVE EXPLAINER',
|
||||
title: plan.heroTitle,
|
||||
subtitle: plan.heroSubtitle,
|
||||
meta: lang.startsWith('fr')
|
||||
? 'Généré depuis votre note'
|
||||
: 'Generated from your note',
|
||||
},
|
||||
overview: {
|
||||
lead: plan.overviewLead,
|
||||
cards: plan.overviewCards.map((c) => ({
|
||||
badge: c.badge,
|
||||
title: c.title,
|
||||
body: c.body,
|
||||
|
||||
intent: c.intent as any,
|
||||
})),
|
||||
},
|
||||
sections,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Author flow (§8.7): LLM plan → one LLM call per section (progress shown)
|
||||
* → validate → preview → publish (pageSpec snapshot, no double quota).
|
||||
* Falls back to the deterministic page when the LLM path is unavailable.
|
||||
*/
|
||||
export function InteractivePagePublishDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
noteId,
|
||||
content,
|
||||
language,
|
||||
onPublished,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
noteId: string
|
||||
content: string
|
||||
language: string
|
||||
onPublished: (slug: string) => void
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const [phase, setPhase] = useState<Phase>('idle')
|
||||
const [page, setPage] = useState<PageSpecV1 | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [progress, setProgress] = useState<string | null>(null)
|
||||
const [elapsedSec, setElapsedSec] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || phase !== 'generating') {
|
||||
if (!open) setElapsedSec(0)
|
||||
return
|
||||
}
|
||||
const t0 = Date.now()
|
||||
const id = window.setInterval(() => {
|
||||
setElapsedSec(Math.floor((Date.now() - t0) / 1000))
|
||||
}, 500)
|
||||
return () => window.clearInterval(id)
|
||||
}, [open, phase])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPhase('idle')
|
||||
setPage(null)
|
||||
setError(null)
|
||||
setProgress(null)
|
||||
return
|
||||
}
|
||||
// Guard: empty content from editor race
|
||||
const wordCount = content
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean).length
|
||||
if (wordCount < 30) {
|
||||
setPhase('error')
|
||||
setError(
|
||||
t('richTextEditor.publishInteractivePageTooShort') ||
|
||||
'Note trop courte — ajoutez du contenu puis réessayez'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const run = async () => {
|
||||
setPhase('generating')
|
||||
setError(null)
|
||||
setPage(null)
|
||||
|
||||
const legacyFallback = async (notice?: string) => {
|
||||
const result = await generateInteractivePage({
|
||||
content,
|
||||
lang: language,
|
||||
noteId,
|
||||
})
|
||||
if (cancelled) return
|
||||
if (!result.ok) {
|
||||
setPhase('error')
|
||||
setError(
|
||||
result.reason ||
|
||||
result.error ||
|
||||
t('richTextEditor.publishInteractivePageFailed') ||
|
||||
'Échec de la page interactive'
|
||||
)
|
||||
if (result.quotaExceeded) {
|
||||
toast.error(t('ai.quotaExceeded'))
|
||||
}
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
return
|
||||
}
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
if (notice) toast.info(notice)
|
||||
setPage(result.page)
|
||||
setPhase('preview')
|
||||
}
|
||||
|
||||
// 1. LLM plan (billed)
|
||||
setProgress(
|
||||
t('richTextEditor.publishInteractivePagePlanning') ||
|
||||
'Analyse du contenu — plan de la page…'
|
||||
)
|
||||
const planResult = await generateInteractivePagePlan({
|
||||
content,
|
||||
lang: language,
|
||||
noteId,
|
||||
})
|
||||
if (cancelled) return
|
||||
if (!planResult.ok) {
|
||||
if (planResult.quotaExceeded) {
|
||||
setPhase('error')
|
||||
setError(planResult.error)
|
||||
toast.error(t('ai.quotaExceeded'))
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
return
|
||||
}
|
||||
if (planResult.error === 'unsuitable_content') {
|
||||
setPhase('error')
|
||||
setError(
|
||||
planResult.reason ||
|
||||
t('richTextEditor.publishInteractivePageFailed') ||
|
||||
'Contenu inadapté'
|
||||
)
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
return
|
||||
}
|
||||
// LLM plan unavailable → deterministic full page
|
||||
await legacyFallback(
|
||||
t('richTextEditor.publishInteractivePageFallback') ||
|
||||
'Génération IA indisponible — page simplifiée affichée'
|
||||
)
|
||||
return
|
||||
}
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
|
||||
// 2. One LLM call per section, with real progress
|
||||
const plan = planResult.plan
|
||||
const sections: PageSection[] = []
|
||||
let degraded = 0
|
||||
for (let i = 0; i < plan.sections.length; i++) {
|
||||
const planSection = plan.sections[i]
|
||||
const sectionId = `s${i + 1}`
|
||||
setProgress(
|
||||
(
|
||||
t('richTextEditor.publishInteractivePageSectionProgress') ||
|
||||
'Section {current}/{total} : {title}'
|
||||
)
|
||||
.replace('{current}', String(i + 1))
|
||||
.replace('{total}', String(plan.sections.length))
|
||||
.replace('{title}', planSection.title)
|
||||
)
|
||||
const sectionResult = await generateInteractivePageSection({
|
||||
content,
|
||||
lang: language,
|
||||
noteId,
|
||||
pageTitle: plan.heroTitle,
|
||||
sectionId,
|
||||
section: planSection,
|
||||
})
|
||||
if (cancelled) return
|
||||
if (sectionResult.ok) {
|
||||
sections.push(sectionResult.section)
|
||||
} else {
|
||||
degraded += 1
|
||||
sections.push(fallbackSection(sectionId, planSection, language))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Assemble + hard validation client-side
|
||||
const candidate = assemblePage(plan, sections, language)
|
||||
const validated = validateInteractivePage(candidate)
|
||||
if (!validated.ok) {
|
||||
await legacyFallback(
|
||||
t('richTextEditor.publishInteractivePageFallback') ||
|
||||
'Génération IA indisponible — page simplifiée affichée'
|
||||
)
|
||||
return
|
||||
}
|
||||
if (degraded > 0) {
|
||||
toast.info(
|
||||
t('richTextEditor.publishInteractivePagePartialFallback') ||
|
||||
'Certaines sections ont été générées en mode simplifié'
|
||||
)
|
||||
}
|
||||
setPage(validated.page)
|
||||
setPhase('preview')
|
||||
}
|
||||
void run()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// Intentionally omit `t` — unstable identity cancels in-flight generation
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, content, language, noteId])
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!page || phase === 'publishing') return
|
||||
setPhase('publishing')
|
||||
try {
|
||||
const res = await fetch('/api/notes/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
noteId,
|
||||
action: 'publish',
|
||||
mode: 'interactive-page',
|
||||
template: 'interactive-page',
|
||||
language,
|
||||
pageSpec: page,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
toast.error(
|
||||
data.reason ||
|
||||
data.error ||
|
||||
t('richTextEditor.publishInteractivePageFailed') ||
|
||||
'Échec de la publication'
|
||||
)
|
||||
setPhase('preview')
|
||||
return
|
||||
}
|
||||
toast.success(
|
||||
t('richTextEditor.publishInteractivePageSuccess') ||
|
||||
'Page interactive publiée !'
|
||||
)
|
||||
onPublished(data.slug)
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
toast.error(
|
||||
t('richTextEditor.publishInteractivePageFailed') ||
|
||||
'Échec de la publication'
|
||||
)
|
||||
setPhase('preview')
|
||||
}
|
||||
}
|
||||
|
||||
const generatingHint =
|
||||
progress ||
|
||||
(elapsedSec < 15
|
||||
? t('richTextEditor.publishInteractivePageGenerating') ||
|
||||
'Génération de la page…'
|
||||
: elapsedSec < 40
|
||||
? t('richTextEditor.publishInteractivePageGeneratingWait') ||
|
||||
'Construction des sections et démos…'
|
||||
: t('richTextEditor.publishInteractivePageGeneratingLong') ||
|
||||
'Encore un instant — au-delà de ~90 s, annulez et réessayez')
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[92vh] w-[min(1100px,96vw)] max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none">
|
||||
<DialogHeader className="shrink-0 border-b border-border px-5 py-4">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<Clapperboard className="h-4 w-4 text-brand-accent" />
|
||||
{t('richTextEditor.publishInteractivePage') ||
|
||||
'Page interactive'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{phase === 'generating'
|
||||
? generatingHint
|
||||
: t('richTextEditor.publishInteractivePagePreviewHint') ||
|
||||
'Aperçu — vérifiez puis publiez sur l’URL publique'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-background">
|
||||
{phase === 'generating' ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-24 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-brand-accent" />
|
||||
<p>{generatingHint}</p>
|
||||
<p className="font-mono text-xs tabular-nums text-muted-foreground/80">
|
||||
{elapsedSec}s
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === 'error' ? (
|
||||
<div className="mx-auto max-w-lg px-6 py-16 text-sm text-destructive">
|
||||
<p className="font-medium">
|
||||
{t('richTextEditor.publishInteractivePageFailed') ||
|
||||
'Échec de la page interactive'}
|
||||
</p>
|
||||
<p className="mt-2 text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === 'preview' || phase === 'publishing' ? (
|
||||
page ? <PageView page={page} demoMode="interactive" /> : null
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t border-border px-5 py-3 sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={phase === 'publishing'}
|
||||
>
|
||||
{t('general.cancel') || 'Annuler'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handlePublish}
|
||||
disabled={phase !== 'preview' || !page}
|
||||
className="gap-2"
|
||||
>
|
||||
{phase === 'publishing' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Globe className="h-4 w-4" />
|
||||
)}
|
||||
{t('richTextEditor.publishInteractivePageConfirm') ||
|
||||
'Publier la page'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
|
||||
import { PageView } from '@/components/interactive-page/page-view'
|
||||
import { validateInteractivePage, type PageSpecV1 } from '@/lib/interactive-page'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Public / preview shell for published interactive pages.
|
||||
* Parses stored PageSpecV1 JSON from `publishedContent`.
|
||||
*/
|
||||
export function InteractivePublishedPage({
|
||||
publishedContent,
|
||||
isStale,
|
||||
}: {
|
||||
publishedContent: string
|
||||
isStale?: boolean
|
||||
}) {
|
||||
let page: PageSpecV1 | null = null
|
||||
let error: string | null = null
|
||||
try {
|
||||
const raw = JSON.parse(publishedContent)
|
||||
const result = validateInteractivePage(raw)
|
||||
if (result.ok) page = result.page
|
||||
else error = result.issues[0]?.message || 'PageSpec invalide'
|
||||
} catch {
|
||||
error = 'JSON de page interactive illisible'
|
||||
}
|
||||
|
||||
if (!page) {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-lg gap-3 p-10 text-sm text-destructive">
|
||||
<AlertCircle className="h-5 w-5 shrink-0" />
|
||||
<p>{error || 'Page interactive indisponible'}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isStale ? (
|
||||
<div className="border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-center text-xs text-amber-800 dark:text-amber-200">
|
||||
Le contenu source a évolué — cette page interactive est à régénérer.
|
||||
</div>
|
||||
) : null}
|
||||
<PageView page={page} demoMode="interactive" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
382
memento-note/components/interactive-page/page-blocks.tsx
Normal file
382
memento-note/components/interactive-page/page-blocks.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import { InteractiveDemoPlayer } from '@/components/interactive-demo/interactive-demo-player'
|
||||
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
|
||||
import { PageFormula, PageMd } from '@/components/interactive-page/page-md'
|
||||
import { SimBlockView } from '@/components/interactive-page/sim-block'
|
||||
import { intentColor } from '@/lib/interactive-demo/intent-colors'
|
||||
import type { IntentId, InteractiveDemoV1 } from '@/lib/interactive-demo/types'
|
||||
import type { PageBlock } from '@/lib/interactive-page'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** Intents actually used inside a demo (legend per demo, brainstorm P11). */
|
||||
function collectDemoIntents(demo: InteractiveDemoV1): IntentId[] {
|
||||
const set = new Set<IntentId>()
|
||||
for (const panel of demo.scene.panels) {
|
||||
if (panel.type === 'svg-scene') {
|
||||
for (const n of panel.payload.nodes) if (n.intent) set.add(n.intent)
|
||||
for (const e of panel.payload.edges ?? []) if (e.intent) set.add(e.intent)
|
||||
}
|
||||
if (panel.type === 'chart') {
|
||||
for (const s of panel.payload.series) if (s.intent) set.add(s.intent)
|
||||
}
|
||||
}
|
||||
for (const act of demo.acts) {
|
||||
for (const step of act.steps) {
|
||||
for (const a of step.annotate ?? []) if (a.intent) set.add(a.intent)
|
||||
}
|
||||
}
|
||||
return [...set]
|
||||
}
|
||||
|
||||
const DEMO_INTENT_LABELS: Record<IntentId, { fr: string; en: string }> = {
|
||||
highlight: { fr: 'Focus', en: 'Focus' },
|
||||
flow: { fr: 'Flux', en: 'Flow' },
|
||||
cache: { fr: 'Mémoire', en: 'Memory' },
|
||||
compute: { fr: 'Calcul', en: 'Compute' },
|
||||
output: { fr: 'Résultat', en: 'Result' },
|
||||
warning: { fr: 'Attention', en: 'Warning' },
|
||||
}
|
||||
|
||||
function DemoLegend({ demo, lang }: { demo: InteractiveDemoV1; lang: string }) {
|
||||
const dark = useDarkMode()
|
||||
const fr = lang.startsWith('fr')
|
||||
const intents = collectDemoIntents(demo)
|
||||
if (!intents.length) return null
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3 text-[11px] text-muted-foreground">
|
||||
<span className="font-semibold uppercase tracking-[0.14em] text-[10px]">
|
||||
{fr ? 'Légende' : 'Legend'}
|
||||
</span>
|
||||
{intents.map((id) => (
|
||||
<span key={id} className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: intentColor(id, dark) }}
|
||||
/>
|
||||
{fr ? DEMO_INTENT_LABELS[id].fr : DEMO_INTENT_LABELS[id].en}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CALLOUT_STYLES: Record<
|
||||
string,
|
||||
{ border: string; bg: string; badge: string }
|
||||
> = {
|
||||
definition: {
|
||||
border: 'border-sky-500/30',
|
||||
bg: 'bg-sky-500/5',
|
||||
badge: 'text-sky-700 dark:text-sky-300',
|
||||
},
|
||||
warning: {
|
||||
border: 'border-amber-500/35',
|
||||
bg: 'bg-amber-500/5',
|
||||
badge: 'text-amber-800 dark:text-amber-300',
|
||||
},
|
||||
tip: {
|
||||
border: 'border-emerald-500/30',
|
||||
bg: 'bg-emerald-500/5',
|
||||
badge: 'text-emerald-800 dark:text-emerald-300',
|
||||
},
|
||||
note: {
|
||||
border: 'border-border',
|
||||
bg: 'bg-muted/40',
|
||||
badge: 'text-muted-foreground',
|
||||
},
|
||||
}
|
||||
|
||||
function IntentBadge({
|
||||
label,
|
||||
intent,
|
||||
}: {
|
||||
label: string
|
||||
intent?: IntentId
|
||||
}) {
|
||||
const dark = useDarkMode()
|
||||
const color = intentColor(intent, dark)
|
||||
return (
|
||||
<span
|
||||
className="inline-flex rounded-md px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em]"
|
||||
style={{
|
||||
color,
|
||||
backgroundColor: `${color}22`,
|
||||
border: `1px solid ${color}44`,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ChartBlockView({
|
||||
block,
|
||||
}: {
|
||||
block: Extract<PageBlock, { type: 'chart' }>
|
||||
}) {
|
||||
const dark = useDarkMode()
|
||||
const series = block.payload.series
|
||||
const maxLen = Math.max(...series.map((s) => s.values.length), 0)
|
||||
const data = Array.from({ length: maxLen }, (_, i) => {
|
||||
const row: Record<string, number | string> = { i: String(i + 1) }
|
||||
for (const s of series) row[s.id] = s.values[i] ?? 0
|
||||
return row
|
||||
})
|
||||
const Chart =
|
||||
block.payload.chartType === 'bar'
|
||||
? BarChart
|
||||
: block.payload.chartType === 'area'
|
||||
? AreaChart
|
||||
: LineChart
|
||||
|
||||
return (
|
||||
<figure
|
||||
className="my-6 rounded-xl border p-4"
|
||||
style={{ background: 'var(--pp-card)', borderColor: 'var(--pp-line)' }}
|
||||
>
|
||||
<div className="h-56 min-h-[14rem] min-w-0 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<Chart data={data} margin={{ top: 8, right: 12, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke={dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}
|
||||
/>
|
||||
<XAxis dataKey="i" tick={{ fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11 }} width={36} axisLine={false} tickLine={false} />
|
||||
<Tooltip />
|
||||
{series.map((s) => {
|
||||
const stroke = intentColor(s.intent, dark)
|
||||
if (block.payload.chartType === 'bar') {
|
||||
return (
|
||||
<Bar
|
||||
key={s.id}
|
||||
dataKey={s.id}
|
||||
fill={stroke}
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (block.payload.chartType === 'area') {
|
||||
return (
|
||||
<Area
|
||||
key={s.id}
|
||||
type="monotone"
|
||||
dataKey={s.id}
|
||||
stroke={stroke}
|
||||
fill={stroke}
|
||||
fillOpacity={0.22}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Line
|
||||
key={s.id}
|
||||
type="monotone"
|
||||
dataKey={s.id}
|
||||
stroke={stroke}
|
||||
strokeWidth={2.5}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Chart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{block.caption ? (
|
||||
<figcaption className="mt-3 text-sm text-muted-foreground">
|
||||
{block.caption}
|
||||
</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageBlockView({
|
||||
block,
|
||||
demoMode = 'static',
|
||||
lang = 'fr',
|
||||
}: {
|
||||
block: PageBlock
|
||||
demoMode?: 'interactive' | 'static'
|
||||
lang?: string
|
||||
}) {
|
||||
if (block.type === 'prose') {
|
||||
return <PageMd md={block.md} className="my-4 text-[17px] leading-[1.7]" />
|
||||
}
|
||||
|
||||
if (block.type === 'formula') {
|
||||
return <PageFormula tex={block.tex} caption={block.caption} />
|
||||
}
|
||||
|
||||
if (block.type === 'callout') {
|
||||
const style = CALLOUT_STYLES[block.kind] ?? CALLOUT_STYLES.note!
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'my-5 rounded-xl border px-4 py-3.5 shadow-sm',
|
||||
style.border,
|
||||
style.bg
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
'mb-1.5 text-[10px] font-semibold uppercase tracking-[0.16em]',
|
||||
style.badge
|
||||
)}
|
||||
>
|
||||
{block.kind} · {block.title}
|
||||
</p>
|
||||
<PageMd md={block.md} className="text-[15px] [&_p]:my-1" />
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.type === 'demo') {
|
||||
return (
|
||||
<figure className="my-8">
|
||||
{block.caption ? (
|
||||
<p className="mb-3 text-[15px] leading-relaxed text-muted-foreground">
|
||||
{block.caption}
|
||||
</p>
|
||||
) : null}
|
||||
<InteractiveDemoPlayer demo={block.demo} mode={demoMode} />
|
||||
<DemoLegend demo={block.demo} lang={lang} />
|
||||
{demoMode === 'static' ? (
|
||||
<noscript>
|
||||
<ol className="mt-3 list-inside list-decimal space-y-1 text-sm text-muted-foreground">
|
||||
{block.demo.acts.flatMap((a) =>
|
||||
a.steps.map((s) => (
|
||||
<li key={s.id}>
|
||||
<strong>{a.title}</strong> — {s.speak}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ol>
|
||||
</noscript>
|
||||
) : null}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.type === 'sim') {
|
||||
return <SimBlockView block={block} lang={lang} />
|
||||
}
|
||||
|
||||
if (block.type === 'chart') {
|
||||
return <ChartBlockView block={block} />
|
||||
}
|
||||
|
||||
if (block.type === 'stats') {
|
||||
return (
|
||||
<div className="my-8 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{block.items.map((item, i) => (
|
||||
<div
|
||||
key={`${item.label}-${i}`}
|
||||
className="rounded-[14px] border px-4 py-3.5"
|
||||
style={{ background: 'var(--pp-paper)', borderColor: 'var(--pp-line)' }}
|
||||
>
|
||||
<p
|
||||
className="font-extrabold leading-tight tabular-nums"
|
||||
style={{
|
||||
fontSize: 'clamp(22px, 2.4vw, 30px)',
|
||||
color: 'var(--pp-plum)',
|
||||
}}
|
||||
>
|
||||
{item.value}
|
||||
</p>
|
||||
<p className="mt-1.5 text-[13px] leading-snug" style={{ color: 'var(--pp-ink)' }}>
|
||||
{item.label}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.type === 'table') {
|
||||
return (
|
||||
<figure
|
||||
className="my-6 overflow-x-auto rounded-xl border"
|
||||
style={{ borderColor: 'var(--pp-line)', background: 'var(--pp-card)' }}
|
||||
>
|
||||
<table className="w-full min-w-[460px] border-collapse text-[13.5px]">
|
||||
{block.caption ? (
|
||||
<caption
|
||||
className="px-3 py-2.5 text-left text-xs"
|
||||
style={{ fontFamily: 'var(--pp-mono)', color: 'var(--pp-muted)' }}
|
||||
>
|
||||
{block.caption}
|
||||
</caption>
|
||||
) : null}
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--pp-line)', background: 'var(--pp-paper)' }}>
|
||||
{block.columns.map((c) => (
|
||||
<th
|
||||
key={c}
|
||||
className="px-3 py-2.5 text-left font-semibold tracking-tight"
|
||||
>
|
||||
{c}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.rows.map((row, ri) => (
|
||||
<tr
|
||||
key={ri}
|
||||
style={{ borderBottom: ri < block.rows.length - 1 ? '1px solid var(--pp-line)' : undefined }}
|
||||
className="last:border-0"
|
||||
>
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} className="px-3 py-2.5 align-top">
|
||||
<PageMd md={cell} className="text-sm [&_p]:my-0" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.type === 'image') {
|
||||
return (
|
||||
<figure className="my-6">
|
||||
{ }
|
||||
<img
|
||||
src={block.src}
|
||||
alt={block.alt}
|
||||
className="mx-auto max-h-[480px] w-auto max-w-full rounded-xl border border-border/50 shadow-sm"
|
||||
/>
|
||||
{block.caption ? (
|
||||
<figcaption className="mt-2 text-center text-sm text-muted-foreground">
|
||||
{block.caption}
|
||||
</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export { IntentBadge }
|
||||
91
memento-note/components/interactive-page/page-md.tsx
Normal file
91
memento-note/components/interactive-page/page-md.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import katex from 'katex'
|
||||
import { marked } from 'marked'
|
||||
import { sanitizeRichHtml } from '@/lib/sanitize-content'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** Light markdown + inline $KaTeX$ for page prose / callouts / speak. */
|
||||
export function PageMd({
|
||||
md,
|
||||
className,
|
||||
}: {
|
||||
md: string
|
||||
className?: string
|
||||
}) {
|
||||
const html = useMemo(() => {
|
||||
const placeholders: string[] = []
|
||||
const withSlots = md.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 out = marked.parse(withSlots, { gfm: true, breaks: true }) as string
|
||||
placeholders.forEach((frag, i) => {
|
||||
out = out.replace(`%%KATEX${i}%%`, frag)
|
||||
})
|
||||
return sanitizeRichHtml(out)
|
||||
}, [md])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'prose prose-neutral dark:prose-invert max-w-none prose-p:leading-relaxed prose-headings:tracking-tight',
|
||||
className
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageFormula({
|
||||
tex,
|
||||
caption,
|
||||
}: {
|
||||
tex: string
|
||||
caption?: string
|
||||
}) {
|
||||
const html = useMemo(() => {
|
||||
try {
|
||||
return katex.renderToString(tex, {
|
||||
displayMode: true,
|
||||
throwOnError: false,
|
||||
})
|
||||
} catch {
|
||||
return tex
|
||||
}
|
||||
}, [tex])
|
||||
|
||||
return (
|
||||
<figure
|
||||
className="my-5 overflow-x-auto rounded-xl border px-4 py-4 md:px-5"
|
||||
style={{
|
||||
background: 'var(--pp-paper)',
|
||||
borderColor: 'var(--pp-line)',
|
||||
borderLeft: '4px solid var(--pp-plum)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-[1.15em] [&_.katex-display]:m-0"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
{caption ? (
|
||||
<figcaption
|
||||
className="mt-2.5 text-sm"
|
||||
style={{ color: 'var(--pp-muted)' }}
|
||||
>
|
||||
{caption}
|
||||
</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
151
memento-note/components/interactive-page/page-sticky-nav.tsx
Normal file
151
memento-note/components/interactive-page/page-sticky-nav.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { PageSpecV1 } from '@/lib/interactive-page'
|
||||
import { intentColor } from '@/lib/interactive-demo/intent-colors'
|
||||
import type { IntentId } from '@/lib/interactive-demo/types'
|
||||
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function collectIntents(page: PageSpecV1): IntentId[] {
|
||||
const set = new Set<IntentId>()
|
||||
for (const card of page.overview?.cards ?? []) {
|
||||
if (card.intent) set.add(card.intent)
|
||||
}
|
||||
for (const section of page.sections) {
|
||||
for (const block of section.blocks) {
|
||||
if (block.type === 'stats') {
|
||||
for (const item of block.items) {
|
||||
if (item.intent) set.add(item.intent)
|
||||
}
|
||||
}
|
||||
if (block.type === 'chart') {
|
||||
for (const s of block.payload.series) {
|
||||
if (s.intent) set.add(s.intent)
|
||||
}
|
||||
}
|
||||
if (block.type === 'demo') {
|
||||
for (const panel of block.demo.scene.panels) {
|
||||
if (panel.type === 'svg-scene') {
|
||||
for (const n of panel.payload.nodes) if (n.intent) set.add(n.intent)
|
||||
for (const e of panel.payload.edges ?? []) if (e.intent) set.add(e.intent)
|
||||
}
|
||||
if (panel.type === 'chart') {
|
||||
for (const s of panel.payload.series) if (s.intent) set.add(s.intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...set]
|
||||
}
|
||||
|
||||
const INTENT_LABELS_FR: Record<IntentId, string> = {
|
||||
highlight: 'Focus',
|
||||
flow: 'Flux',
|
||||
cache: 'Mémoire',
|
||||
compute: 'Calcul',
|
||||
output: 'Résultat',
|
||||
warning: 'Attention',
|
||||
}
|
||||
|
||||
const INTENT_LABELS_EN: Record<IntentId, string> = {
|
||||
highlight: 'Focus',
|
||||
flow: 'Flow',
|
||||
cache: 'Memory',
|
||||
compute: 'Compute',
|
||||
output: 'Result',
|
||||
warning: 'Warning',
|
||||
}
|
||||
|
||||
export function PageStickyNav({ page }: { page: PageSpecV1 }) {
|
||||
const [active, setActive] = useState(page.sections[0]?.id ?? '')
|
||||
const dark = useDarkMode()
|
||||
const intents = useMemo(() => collectIntents(page), [page])
|
||||
|
||||
useEffect(() => {
|
||||
const nodes = page.sections
|
||||
.map((s) => document.getElementById(s.id))
|
||||
.filter(Boolean) as HTMLElement[]
|
||||
if (!nodes.length) return
|
||||
|
||||
const obs = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)
|
||||
const top = visible[0]?.target?.id
|
||||
if (top) setActive(top)
|
||||
},
|
||||
{ rootMargin: '-20% 0px -55% 0px', threshold: [0.1, 0.25, 0.5] }
|
||||
)
|
||||
nodes.forEach((n) => obs.observe(n))
|
||||
return () => obs.disconnect()
|
||||
}, [page.sections])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="sticky top-0 z-30 backdrop-blur-sm"
|
||||
style={{
|
||||
background: 'color-mix(in oklab, var(--pp-paper) 96%, transparent)',
|
||||
borderTop: '1px solid var(--pp-line)',
|
||||
borderBottom: '1px solid var(--pp-line)',
|
||||
}}
|
||||
>
|
||||
<nav
|
||||
className="mx-auto flex max-w-[1100px] gap-1 overflow-x-auto px-5 py-2.5 scrollbar-thin"
|
||||
aria-label="Sections"
|
||||
style={{ fontFamily: 'var(--pp-mono)' }}
|
||||
>
|
||||
{page.sections.map((s, i) => (
|
||||
<a
|
||||
key={s.id}
|
||||
href={`#${s.id}`}
|
||||
className={cn(
|
||||
'shrink-0 rounded-lg px-2.5 py-1.5 text-xs transition-colors',
|
||||
active === s.id ? 'font-semibold' : 'hover:opacity-80'
|
||||
)}
|
||||
style={
|
||||
active === s.id
|
||||
? { background: 'var(--pp-ink)', color: 'var(--pp-paper)' }
|
||||
: { color: 'var(--pp-muted)' }
|
||||
}
|
||||
>
|
||||
{i + 1} · {s.title}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
{intents.length > 0 ? (
|
||||
<div
|
||||
className="mx-auto flex max-w-[1100px] flex-wrap gap-3 px-5 py-2 text-[11px]"
|
||||
style={{
|
||||
borderTop: '1px solid var(--pp-line)',
|
||||
fontFamily: 'var(--pp-mono)',
|
||||
color: 'var(--pp-muted)',
|
||||
}}
|
||||
>
|
||||
<span className="font-semibold uppercase tracking-[0.14em] text-[10px]">
|
||||
{page.lang.startsWith('fr') ? 'Légende' : 'Legend'}
|
||||
</span>
|
||||
{intents.map((id) => {
|
||||
const color = intentColor(id, dark)
|
||||
return (
|
||||
<span
|
||||
key={id}
|
||||
className="inline-flex items-center gap-1.5"
|
||||
>
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
{page.lang.startsWith('fr')
|
||||
? INTENT_LABELS_FR[id]
|
||||
: INTENT_LABELS_EN[id]}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
270
memento-note/components/interactive-page/page-view.tsx
Normal file
270
memento-note/components/interactive-page/page-view.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, type CSSProperties } from 'react'
|
||||
import { PageBlockView, IntentBadge } from '@/components/interactive-page/page-blocks'
|
||||
import { PageMd } from '@/components/interactive-page/page-md'
|
||||
import { PageStickyNav } from '@/components/interactive-page/page-sticky-nav'
|
||||
import type { PageSpecV1 } from '@/lib/interactive-page'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type PageViewProps = {
|
||||
page: PageSpecV1
|
||||
/** interactive = hydrate demo players; static = final-state only (SSR/noscript) */
|
||||
demoMode?: 'interactive' | 'static'
|
||||
className?: string
|
||||
paper?: boolean
|
||||
}
|
||||
|
||||
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 useScrollReveal(enabled: boolean) {
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
document
|
||||
.querySelectorAll<HTMLElement>('[data-scroll-init]')
|
||||
.forEach((el) => el.setAttribute('data-scroll-visible', 'true'))
|
||||
return
|
||||
}
|
||||
const nodes = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-scroll-init]')
|
||||
)
|
||||
const obs = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
e.target.setAttribute('data-scroll-visible', 'true')
|
||||
obs.unobserve(e.target)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: '0px 0px -8% 0px', threshold: 0.12 }
|
||||
)
|
||||
nodes.forEach((n) => obs.observe(n))
|
||||
return () => obs.disconnect()
|
||||
}, [enabled])
|
||||
}
|
||||
|
||||
/**
|
||||
* PageView — Kimi/AttnRes-style explainer page.
|
||||
* Design tokens replicated from the reference page (paper, plum accent,
|
||||
* mono labels, formula left-bar, stat cards) with dark-mode adaptation.
|
||||
*/
|
||||
export function PageView({
|
||||
page,
|
||||
demoMode = 'interactive',
|
||||
className,
|
||||
paper = true,
|
||||
}: PageViewProps) {
|
||||
const reducedMotion = usePrefersReducedMotion()
|
||||
useScrollReveal(!reducedMotion)
|
||||
|
||||
const paperStyle = {
|
||||
'--page-paper': paper ? 'var(--pp-paper)' : 'var(--background)',
|
||||
} as CSSProperties
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn('interactive-page min-h-screen', paper && 'page-paper', className)}
|
||||
data-page-id={page.id}
|
||||
style={paperStyle}
|
||||
>
|
||||
<style>{`
|
||||
.interactive-page {
|
||||
--pp-paper: #F4F0E8;
|
||||
--pp-paper-deep: #EAE4D9;
|
||||
--pp-card: #FFFDF8;
|
||||
--pp-ink: #242422;
|
||||
--pp-muted: #686762;
|
||||
--pp-line: #D6D0C6;
|
||||
--pp-plum: #9F3F70;
|
||||
--pp-plum-soft: #F2DCE8;
|
||||
--pp-blue: #3F6F9F;
|
||||
--pp-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
color: var(--pp-ink);
|
||||
}
|
||||
.dark .interactive-page {
|
||||
--pp-paper: #17140F;
|
||||
--pp-paper-deep: #201C15;
|
||||
--pp-card: #221E17;
|
||||
--pp-ink: #EDE7DB;
|
||||
--pp-muted: #A39C8D;
|
||||
--pp-line: #3B352A;
|
||||
--pp-plum: #D07AA6;
|
||||
--pp-plum-soft: #3A2530;
|
||||
--pp-blue: #7FA8CC;
|
||||
}
|
||||
.interactive-page.page-paper {
|
||||
background-color: var(--pp-paper);
|
||||
background-image: radial-gradient(
|
||||
color-mix(in oklab, var(--pp-ink) 7%, transparent) 0.7px,
|
||||
transparent 0.7px
|
||||
);
|
||||
background-size: 18px 18px;
|
||||
}
|
||||
.pp-card {
|
||||
background: var(--pp-card);
|
||||
border: 1px solid var(--pp-line);
|
||||
border-radius: 14px;
|
||||
}
|
||||
.pp-mono {
|
||||
font-family: var(--pp-mono);
|
||||
}
|
||||
.interactive-page [data-scroll-init]:not([data-scroll-visible]) {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
.interactive-page [data-scroll-init] {
|
||||
transition: opacity 420ms ease, transform 420ms ease;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
.interactive-page [data-scroll-visible] {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.interactive-page [data-scroll-init] {
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* ── Hero (kicker / 800 title / ink subtitle / mono meta) ── */}
|
||||
<header className="mx-auto max-w-[1100px] px-5 pb-8 pt-12 md:pt-16">
|
||||
<p
|
||||
className="pp-mono mb-2.5 text-xs uppercase"
|
||||
style={{ letterSpacing: '0.14em', color: 'var(--pp-plum)' }}
|
||||
>
|
||||
{page.hero.kicker}
|
||||
</p>
|
||||
<h1
|
||||
className="max-w-[22ch] font-extrabold leading-[1.14] tracking-[-0.015em]"
|
||||
style={{ fontSize: 'clamp(26px, 3.4vw, 44px)' }}
|
||||
>
|
||||
{page.hero.title}
|
||||
</h1>
|
||||
{page.hero.subtitle ? (
|
||||
<p
|
||||
className="mt-2.5 font-semibold"
|
||||
style={{ fontSize: 'clamp(16px, 1.6vw, 21px)' }}
|
||||
>
|
||||
{page.hero.subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
{page.hero.meta ? (
|
||||
<p
|
||||
className="pp-mono mt-3 leading-relaxed"
|
||||
style={{ fontSize: '12.5px', color: 'var(--pp-muted)' }}
|
||||
>
|
||||
{page.hero.meta}
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<PageStickyNav page={page} />
|
||||
|
||||
<div className="mx-auto max-w-[1100px] px-5 pb-24 pt-10">
|
||||
{/* ── One-minute overview ── */}
|
||||
{page.overview ? (
|
||||
<section className="pp-card mb-20 p-6 shadow-sm md:p-8" data-scroll-init>
|
||||
<p
|
||||
className="pp-mono mb-3 text-[11px] uppercase"
|
||||
style={{ letterSpacing: '0.14em', color: 'var(--pp-muted)' }}
|
||||
>
|
||||
{page.lang.startsWith('fr')
|
||||
? 'L’essentiel en une minute'
|
||||
: 'One-minute overview'}
|
||||
</p>
|
||||
<PageMd
|
||||
md={page.overview.lead}
|
||||
className="max-w-[75ch] text-lg leading-relaxed [&_p]:my-0"
|
||||
/>
|
||||
<div className="mt-5 grid gap-3.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{page.overview.cards.map((card) => (
|
||||
<div
|
||||
key={card.badge + card.title}
|
||||
className="rounded-xl border p-4"
|
||||
style={{
|
||||
background: 'var(--pp-paper)',
|
||||
borderColor: 'var(--pp-line)',
|
||||
}}
|
||||
>
|
||||
<IntentBadge label={card.badge} intent={card.intent} />
|
||||
<h3 className="mt-3 text-base font-semibold tracking-tight">
|
||||
{card.title}
|
||||
</h3>
|
||||
<PageMd
|
||||
md={card.body}
|
||||
className="mt-1.5 text-sm text-muted-foreground [&_p]:my-0"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* ── Sections ── */}
|
||||
<div className="space-y-20">
|
||||
{page.sections.map((section, i) => (
|
||||
<section
|
||||
key={section.id}
|
||||
id={section.id}
|
||||
className="scroll-mt-28"
|
||||
data-scroll-init
|
||||
>
|
||||
<h2 className="mb-6 flex items-baseline gap-3 text-2xl font-extrabold tracking-tight md:text-[28px]">
|
||||
<span
|
||||
className="pp-mono text-sm font-semibold"
|
||||
style={{ color: 'var(--pp-plum)' }}
|
||||
>
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
{section.title}
|
||||
</h2>
|
||||
<div>
|
||||
{section.blocks.map((block, bi) => {
|
||||
const narrow =
|
||||
block.type === 'prose' ||
|
||||
block.type === 'formula' ||
|
||||
block.type === 'callout'
|
||||
return (
|
||||
<div
|
||||
key={`${section.id}-${bi}`}
|
||||
className={narrow ? 'max-w-[75ch]' : 'max-w-[880px]'}
|
||||
>
|
||||
<PageBlockView
|
||||
block={block}
|
||||
demoMode={demoMode}
|
||||
lang={page.lang}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{page.footer ? (
|
||||
<footer
|
||||
className="pp-mono mt-16 border-t pt-5 text-xs italic"
|
||||
style={{ borderColor: 'var(--pp-line)', color: 'var(--pp-muted)' }}
|
||||
>
|
||||
{page.footer}
|
||||
</footer>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
104
memento-note/components/interactive-page/sim-block.tsx
Normal file
104
memento-note/components/interactive-page/sim-block.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
'use client'
|
||||
|
||||
import { GENERIC_SIM_ID, getPlugin } from '@/lib/simulators'
|
||||
import { ANIM_VIEWS, SIMULATOR_VIEWS } from '@/components/simulators'
|
||||
import { AnimPlayerShell } from '@/components/simulators/anim-player-shell'
|
||||
import { GenericFormulaView } from '@/components/simulators/generic-formula-view'
|
||||
import type { SimBlock } from '@/lib/interactive-page'
|
||||
|
||||
/** Renders a `sim` block: catalog plugin (bespoke view) or generic formula. */
|
||||
export function SimBlockView({
|
||||
block,
|
||||
lang,
|
||||
}: {
|
||||
block: SimBlock
|
||||
lang: string
|
||||
}) {
|
||||
const sim = block.sim
|
||||
const fr = lang.startsWith('fr')
|
||||
const plugin = sim.simId === GENERIC_SIM_ID ? null : getPlugin(sim.simId)
|
||||
const kindLabel =
|
||||
plugin?.family === 'anim'
|
||||
? fr
|
||||
? 'Animation interactive'
|
||||
: 'Interactive animation'
|
||||
: fr
|
||||
? 'Simulation interactive'
|
||||
: 'Interactive simulation'
|
||||
|
||||
let title: string | undefined
|
||||
let body: React.ReactNode = null
|
||||
|
||||
if (sim.simId === GENERIC_SIM_ID) {
|
||||
title = sim.title
|
||||
body = (
|
||||
<GenericFormulaView
|
||||
sim={sim as Extract<typeof sim, { simId: 'generic-formula' }>}
|
||||
lang={lang}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
if (!plugin) {
|
||||
return (
|
||||
<div className="my-6 rounded-xl border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||
{fr ? 'Simulateur indisponible' : 'Simulator unavailable'} ({sim.simId})
|
||||
</div>
|
||||
)
|
||||
}
|
||||
title = sim.title || (fr ? plugin.title.fr : plugin.title.en)
|
||||
|
||||
if (plugin.family === 'anim') {
|
||||
const AnimScene = ANIM_VIEWS[sim.simId]
|
||||
if (!AnimScene) {
|
||||
return (
|
||||
<div className="my-6 rounded-xl border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||
{fr ? 'Animation indisponible' : 'Animation unavailable'} ({sim.simId})
|
||||
</div>
|
||||
)
|
||||
}
|
||||
body = (
|
||||
<AnimPlayerShell beats={plugin.beats} lang={lang} disclaimer={plugin.disclaimer}>
|
||||
{(stepIndex) => <AnimScene step={stepIndex} lang={lang} />}
|
||||
</AnimPlayerShell>
|
||||
)
|
||||
} else {
|
||||
const View = SIMULATOR_VIEWS[sim.simId]
|
||||
if (!View) {
|
||||
return (
|
||||
<div className="my-6 rounded-xl border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||
{fr ? 'Simulateur indisponible' : 'Simulator unavailable'} ({sim.simId})
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const preset = (sim as { preset?: Record<string, number> }).preset
|
||||
body = (
|
||||
<View
|
||||
preset={preset}
|
||||
title={title}
|
||||
disclaimer={sim.disclaimer}
|
||||
lang={lang}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<figure
|
||||
className="my-8 rounded-2xl border p-4 md:p-5"
|
||||
style={{ background: 'var(--pp-card)', borderColor: 'var(--pp-line)' }}
|
||||
>
|
||||
<div className="mb-4 flex items-baseline justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold tracking-tight">{title}</h3>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{kindLabel}
|
||||
</span>
|
||||
</div>
|
||||
{body}
|
||||
{block.caption ? (
|
||||
<figcaption className="mt-3 text-center text-sm text-muted-foreground">
|
||||
{block.caption}
|
||||
</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
@@ -19,10 +19,11 @@ import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
|
||||
Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal,
|
||||
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer, PenTool, Loader2 as Loader2Icon, Globe, ExternalLink, History
|
||||
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer, PenTool, Loader2 as Loader2Icon, Globe, ExternalLink, History, Clapperboard
|
||||
} from 'lucide-react'
|
||||
import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog'
|
||||
import { NoteShareDialog } from './note-share-dialog'
|
||||
import { InteractivePagePublishDialog } from '@/components/interactive-page/interactive-page-publish-dialog'
|
||||
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
|
||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
@@ -53,6 +54,8 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
const [flashcardsOpen, setFlashcardsOpen] = useState(false)
|
||||
const [publishOpen, setPublishOpen] = useState(false)
|
||||
const [publishLoading, setPublishLoading] = useState(false)
|
||||
const [interactivePageOpen, setInteractivePageOpen] = useState(false)
|
||||
const [interactivePageContent, setInteractivePageContent] = useState('')
|
||||
const [publishMeta, setPublishMeta] = useState({
|
||||
isPublic: Boolean(note.isPublic),
|
||||
slug: note.publicSlug ?? null,
|
||||
@@ -343,6 +346,28 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
magazine: t('richTextEditor.publishTemplateMagazine'),
|
||||
brief: t('richTextEditor.publishTemplateBrief'),
|
||||
essay: t('richTextEditor.publishTemplateEssay'),
|
||||
'interactive-page': t('richTextEditor.publishTemplateInteractivePage') || 'Page interactive',
|
||||
}
|
||||
|
||||
const classicPublishTemplates = PUBLISH_TEMPLATES.filter(
|
||||
(tpl) => tpl !== 'interactive-page'
|
||||
)
|
||||
|
||||
const handlePublishInteractivePage = async () => {
|
||||
if (publishLoading) return
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
if (state.isDirty && !state.isSaving) {
|
||||
await actions.handleSaveInPlace()
|
||||
}
|
||||
const html =
|
||||
richTextEditorRef?.current?.getEditor()?.getHTML?.() ||
|
||||
state.content ||
|
||||
note.content ||
|
||||
''
|
||||
setInteractivePageContent(html)
|
||||
setPublishOpen(false)
|
||||
setInteractivePageOpen(true)
|
||||
}
|
||||
|
||||
const handlePublishWithAi = async () => {
|
||||
@@ -664,6 +689,25 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
{t('richTextEditor.publishSimpleHint')}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePublishInteractivePage}
|
||||
disabled={publishLoading}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg border border-brand-accent/40 bg-brand-accent/5 text-sm font-medium text-brand-accent hover:bg-brand-accent/10 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{publishLoading ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Clapperboard size={14} />
|
||||
)}
|
||||
{t('richTextEditor.publishInteractivePage') ||
|
||||
'Page interactive'}
|
||||
</button>
|
||||
<p className="text-[10px] text-muted-foreground text-center -mt-1">
|
||||
{t('richTextEditor.publishInteractivePageHint') ||
|
||||
'Hero, sections, démos Play/Step — 20 crédits'}
|
||||
</p>
|
||||
|
||||
<div className="border-t border-border/50 pt-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Sparkles size={13} className="text-brand-accent shrink-0" />
|
||||
@@ -679,7 +723,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
</p>
|
||||
{/* Sélection template */}
|
||||
<div className="space-y-1">
|
||||
{PUBLISH_TEMPLATES.map((tpl) => (
|
||||
{classicPublishTemplates.map((tpl) => (
|
||||
<label
|
||||
key={tpl}
|
||||
className={cn(
|
||||
@@ -1009,6 +1053,21 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
}}
|
||||
/>
|
||||
|
||||
<InteractivePagePublishDialog
|
||||
open={interactivePageOpen}
|
||||
onOpenChange={setInteractivePageOpen}
|
||||
noteId={note.id}
|
||||
content={interactivePageContent}
|
||||
language={language}
|
||||
onPublished={(slug) => {
|
||||
setPublishMeta({
|
||||
isPublic: true,
|
||||
slug,
|
||||
template: 'interactive-page',
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
aria-label={t('notes.documentInfoAria')}
|
||||
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { ChartSuggestionsDialog } from './chart-suggestions-dialog'
|
||||
import { UniqueIdExtension } from './tiptap-unique-id-extension'
|
||||
import { LiveBlockExtension } from './tiptap-live-block-extension'
|
||||
import { StructuredViewBlockExtension, insertStructuredViewBlockAtSelection } from './tiptap-structured-view-block-extension'
|
||||
import { InteractiveDemoExtension, insertInteractiveDemoAtSelection } from './tiptap-interactive-demo-extension'
|
||||
import { ToggleExtension, insertToggleBlock } from './tiptap-toggle-extension'
|
||||
import { CalloutExtension, insertCalloutBlock } from './tiptap-callout-extension'
|
||||
import { OutlineExtension, insertOutlineBlock } from './tiptap-outline-extension'
|
||||
@@ -70,7 +71,7 @@ import {
|
||||
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
||||
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
||||
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
|
||||
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2, Trash2
|
||||
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2, Trash2, Clapperboard
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
@@ -298,6 +299,9 @@ const slashCommands: SlashItem[] = [
|
||||
title: 'Database', description: 'Inline database', icon: Database, category: 'Basic blocks', shortcut: '/database',
|
||||
command: (e) => { insertStructuredViewBlockAtSelection(e) },
|
||||
},
|
||||
{
|
||||
title: 'Interactive Demo', description: 'AI pedagogical step-by-step demo', icon: Clapperboard, category: 'IA Note', isAi: true, command: () => {},
|
||||
},
|
||||
{
|
||||
title: 'Toggle', description: 'Collapsible section', icon: ChevronsRightLeft, category: 'Basic blocks', shortcut: '>',
|
||||
command: (e) => { insertToggleBlock(e) },
|
||||
@@ -385,7 +389,7 @@ function useImageInsert() {
|
||||
|
||||
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
|
||||
function RichTextEditor({ content, onChange, onChangeImmediate, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) {
|
||||
const { t } = useLanguage()
|
||||
const { t, language } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const imageInsert = useImageInsert()
|
||||
const [blockPickerOpen, setBlockPickerOpen] = useState(false)
|
||||
@@ -589,6 +593,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
UndoRedoFeedbackExtension,
|
||||
LiveBlockExtension,
|
||||
StructuredViewBlockExtension,
|
||||
InteractiveDemoExtension,
|
||||
ToggleExtension,
|
||||
CalloutExtension,
|
||||
OutlineExtension,
|
||||
@@ -951,6 +956,100 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
setChartSuggestionsOpen(true)
|
||||
}, [editor, requestAiConsent])
|
||||
|
||||
const handleGenerateInteractiveDemo = useCallback(async () => {
|
||||
if (!editor || !editor.isEditable) return
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
// Prefer HTML so TipTap math nodes (data-latex) reach formula extractors
|
||||
let content = ''
|
||||
let selection: string | null = null
|
||||
try {
|
||||
content = editor.getHTML() || editor.state.doc.textContent || ''
|
||||
const { from, to, empty } = editor.state.selection
|
||||
if (!empty) {
|
||||
const parts: string[] = []
|
||||
editor.state.doc.nodesBetween(from, to, (node) => {
|
||||
const latex = (node.attrs as { latex?: string } | undefined)?.latex
|
||||
if (typeof latex === 'string' && latex.trim()) {
|
||||
parts.push(
|
||||
node.type.name === 'mathEquationBlock'
|
||||
? `$$${latex}$$`
|
||||
: `$${latex}$`
|
||||
)
|
||||
return false
|
||||
}
|
||||
if (node.isText && node.text) {
|
||||
parts.push(node.text)
|
||||
}
|
||||
return true
|
||||
})
|
||||
selection = parts.join(' ').trim() || null
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[interactive-demo] content extract fallback', err)
|
||||
content = editor.state.doc.textContent || ''
|
||||
}
|
||||
|
||||
const sourceForWords = (selection || editor.state.doc.textContent || '').trim()
|
||||
const words = sourceForWords.split(/\s+/).filter(Boolean).length
|
||||
if (words < 20) {
|
||||
toast.error(
|
||||
t('interactiveDemo.needMoreText') ||
|
||||
'Sélectionne au moins ~20 mots (ou écris plus de contenu) pour générer une démo'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const toastId = toast.loading(
|
||||
t('interactiveDemo.generating') || 'Génération de la démo interactive…'
|
||||
)
|
||||
try {
|
||||
const { generateInteractiveDemo } = await import(
|
||||
'@/lib/ai/services/interactive-demo-client.service'
|
||||
)
|
||||
const result = await generateInteractiveDemo({
|
||||
content,
|
||||
selection,
|
||||
lang: language || 'fr',
|
||||
noteId: noteId || undefined,
|
||||
})
|
||||
if (!result.ok) {
|
||||
if (result.quotaExceeded) {
|
||||
toast.error(t('interactiveDemo.quotaExceeded') || 'Quota IA insuffisant', {
|
||||
id: toastId,
|
||||
})
|
||||
} else {
|
||||
toast.error(
|
||||
typeof result.error === 'string'
|
||||
? result.error
|
||||
: t('interactiveDemo.generateFailed') || 'Échec de la génération',
|
||||
{ id: toastId }
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!insertInteractiveDemoAtSelection(editor, result.demo)) {
|
||||
toast.error(
|
||||
t('interactiveDemo.insertFailed') || 'Impossible d’insérer la démo',
|
||||
{ id: toastId }
|
||||
)
|
||||
return
|
||||
}
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
toast.success(
|
||||
t('interactiveDemo.generateSuccess') || 'Démo interactive insérée',
|
||||
{ id: toastId }
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[interactive-demo] generate error', err)
|
||||
toast.error(
|
||||
t('interactiveDemo.generateFailed') || 'Échec de la génération',
|
||||
{ id: toastId }
|
||||
)
|
||||
}
|
||||
}, [editor, requestAiConsent, t, language, noteId])
|
||||
|
||||
const insertCitationInEditor = useCallback((
|
||||
payload: { noteId: string; noteTitle: string; excerpt: string },
|
||||
options?: { atEnd?: boolean }
|
||||
@@ -1321,7 +1420,14 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
</BubbleMenu>
|
||||
)}
|
||||
|
||||
{editor && <SlashCommandMenu editor={editor} onInsertImage={imageInsert.requestInsert} onSuggestCharts={handleOpenChartSuggestions} />}
|
||||
{editor && (
|
||||
<SlashCommandMenu
|
||||
editor={editor}
|
||||
onInsertImage={imageInsert.requestInsert}
|
||||
onSuggestCharts={handleOpenChartSuggestions}
|
||||
onGenerateInteractiveDemo={handleGenerateInteractiveDemo}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EditorBlockDragHandle editor={editor} onOpenMenu={openBlockActionMenu} />
|
||||
|
||||
@@ -1910,7 +2016,7 @@ function SlashPreview({ itemTitle, t }: { itemTitle: string; t: (k: string) => s
|
||||
}
|
||||
}
|
||||
|
||||
function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: Editor; onInsertImage: (editor: Editor) => void; onSuggestCharts: () => void }) {
|
||||
function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts, onGenerateInteractiveDemo }: { editor: Editor; onInsertImage: (editor: Editor) => void; onSuggestCharts: () => void; onGenerateInteractiveDemo: () => void }) {
|
||||
const { t } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
@@ -1968,6 +2074,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
{ ...sc('Suggest Charts'), title: t('richTextEditor.slashCharts') || 'Graphiques IA', description: t('richTextEditor.slashChartsDesc') || 'IA suggère des graphiques', categoryId: 'ai' },
|
||||
{ ...sc('Living Block'), title: t('richTextEditor.slashLivingBlock') || 'Bloc vivant', description: t('richTextEditor.slashLivingBlockDesc') || 'Insérer depuis une autre note', categoryId: 'embed' },
|
||||
{ ...sc('Database'), title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'structured', 'structuree', 'structurée'] },
|
||||
{ ...sc('Interactive Demo'), title: t('richTextEditor.slashInteractiveDemo') || 'Démo interactive', description: t('richTextEditor.slashInteractiveDemoDesc') || 'Démo pédagogique étape par étape', categoryId: 'ai', slashKeywords: ['demo', 'interactive', 'attn', 'tutorial', 'démo', 'demo interactive'] },
|
||||
{ ...sc('Toggle'), title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] },
|
||||
{ ...sc('Callout'), title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] },
|
||||
{ ...sc('Outline'), title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'matieres', 'matières', 'plan'] },
|
||||
@@ -2083,6 +2190,11 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
|| item.title === (t('richTextEditor.slashCharts') || 'Graphiques IA')
|
||||
) {
|
||||
deleteSlashText(); closeMenu(); onSuggestCharts()
|
||||
} else if (
|
||||
item.title === 'Interactive Demo'
|
||||
|| item.title === (t('richTextEditor.slashInteractiveDemo') || 'Démo interactive')
|
||||
) {
|
||||
deleteSlashText(); closeMenu(); onGenerateInteractiveDemo()
|
||||
} else if (item.title === t('richTextEditor.slashDatabase')) {
|
||||
deleteSlashText(); closeMenu()
|
||||
const currentNotebookId = (editor.storage as any).structuredViewBlock?.notebookId as string | null
|
||||
@@ -2092,7 +2204,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
} else {
|
||||
deleteSlashText(); item.command(editor); closeMenu()
|
||||
}
|
||||
}, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, t, requestAiConsent])
|
||||
}, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, onGenerateInteractiveDemo, t, requestAiConsent])
|
||||
|
||||
// Charger les favoris fréquents lors de l'ouverture
|
||||
useEffect(() => {
|
||||
|
||||
@@ -32,6 +32,8 @@ const FEATURE_LABEL_KEYS: Record<string, string> = {
|
||||
brainstorm_expand: 'usageMeter.featureBrainstormExpand',
|
||||
brainstorm_enrich: 'usageMeter.featureBrainstormEnrich',
|
||||
suggest_charts: 'usageMeter.featureCharts',
|
||||
interactive_demo: 'usageMeter.featureInteractiveDemo',
|
||||
interactive_page: 'usageMeter.featureInteractivePage',
|
||||
publish_enhance: 'usageMeter.featurePublishEnhance',
|
||||
ai_flashcard: 'usageMeter.featureFlashcards',
|
||||
voice_transcribe: 'usageMeter.featureVoice',
|
||||
|
||||
231
memento-note/components/simulators/anim-player-shell.tsx
Normal file
231
memento-note/components/simulators/anim-player-shell.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Pause, Play, RotateCcw, StepForward } from 'lucide-react'
|
||||
import { PageMd } from '@/components/interactive-page/page-md'
|
||||
import type { AnimBeat, SimI18n } from '@/lib/simulators'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const WPM = 220
|
||||
const STEP_FLOOR_MS = 1500
|
||||
const STEP_CEIL_MS = 6000
|
||||
const SPEEDS = [0.5, 1, 2, 4]
|
||||
|
||||
function stepDurationMs(speak: string, speed: number): number {
|
||||
const words = speak.trim().split(/\s+/).filter(Boolean).length
|
||||
const raw = words * (60_000 / WPM)
|
||||
return Math.min(STEP_CEIL_MS, Math.max(STEP_FLOOR_MS, raw)) / speed
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export type AnimPlayerShellProps = {
|
||||
beats: AnimBeat[]
|
||||
lang: string
|
||||
disclaimer?: SimI18n
|
||||
/** Scene renderer — receives the current beat index (0-based). */
|
||||
children: (stepIndex: number) => React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared chrome for curated animation plugins: Play/Pause/Step/Reset/Speed
|
||||
* + narration panel, same pacing rules as InteractiveDemoPlayer.
|
||||
*/
|
||||
export function AnimPlayerShell({
|
||||
beats,
|
||||
lang,
|
||||
disclaimer,
|
||||
children,
|
||||
}: AnimPlayerShellProps) {
|
||||
const fr = lang.startsWith('fr')
|
||||
const reducedMotion = usePrefersReducedMotion()
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [speed, setSpeed] = useState(1)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
/** Keyboard shortcuts only fire for the hovered/focused player. */
|
||||
const activeRef = useRef(false)
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const atLast = stepIndex >= beats.length - 1
|
||||
|
||||
useEffect(() => {
|
||||
clearTimer()
|
||||
if (!playing) return
|
||||
const beat = beats[stepIndex]
|
||||
if (!beat) return
|
||||
const speak = fr ? beat.speak.fr : beat.speak.en
|
||||
timerRef.current = setTimeout(
|
||||
() => {
|
||||
if (stepIndex >= beats.length - 1) {
|
||||
setPlaying(false)
|
||||
return
|
||||
}
|
||||
setStepIndex((s) => s + 1)
|
||||
},
|
||||
reducedMotion ? 250 : stepDurationMs(speak, speed)
|
||||
)
|
||||
return clearTimer
|
||||
}, [playing, stepIndex, beats, speed, fr, reducedMotion, clearTimer])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement)?.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') 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.preventDefault()
|
||||
setPlaying(false)
|
||||
setStepIndex((s) => Math.min(beats.length - 1, s + 1))
|
||||
} else if (e.code === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
setPlaying(false)
|
||||
setStepIndex((s) => Math.max(0, s - 1))
|
||||
} else if (e.code === 'KeyR') {
|
||||
e.preventDefault()
|
||||
setPlaying(false)
|
||||
setStepIndex(0)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [beats.length])
|
||||
|
||||
const beat = beats[stepIndex]
|
||||
const speak = beat ? (fr ? beat.speak.fr : beat.speak.en) : ''
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerEnter={() => {
|
||||
activeRef.current = true
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
activeRef.current = false
|
||||
}}
|
||||
onFocusCapture={() => {
|
||||
activeRef.current = true
|
||||
}}
|
||||
onBlurCapture={() => {
|
||||
activeRef.current = false
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl border" style={{ borderColor: 'var(--pp-line)' }}>
|
||||
{children(stepIndex)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-2 flex flex-wrap items-center gap-1 rounded-lg border px-2 py-1.5"
|
||||
style={{ borderColor: 'var(--pp-line)', background: 'var(--pp-card)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlaying((p) => !p)}
|
||||
aria-label={playing ? 'Pause' : 'Play'}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
|
||||
style={{ color: 'var(--pp-plum)' }}
|
||||
>
|
||||
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPlaying(false)
|
||||
setStepIndex((s) => Math.min(beats.length - 1, s + 1))
|
||||
}}
|
||||
aria-label={fr ? 'Étape suivante' : 'Next step'}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
|
||||
style={{ color: 'var(--pp-ink)' }}
|
||||
>
|
||||
<StepForward className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPlaying(false)
|
||||
setStepIndex(0)
|
||||
}}
|
||||
aria-label="Reset"
|
||||
title="R"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
|
||||
style={{ color: 'var(--pp-ink)' }}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<span
|
||||
className="ml-2 text-xs tabular-nums"
|
||||
style={{ color: 'var(--pp-muted)', fontFamily: 'var(--pp-mono)' }}
|
||||
>
|
||||
{fr ? 'étape' : 'step'} {stepIndex + 1} / {beats.length}
|
||||
</span>
|
||||
|
||||
<div
|
||||
className="ml-auto flex items-center gap-0.5 rounded-md border p-0.5"
|
||||
style={{ borderColor: 'var(--pp-line)' }}
|
||||
role="group"
|
||||
aria-label={fr ? 'Vitesse' : 'Speed'}
|
||||
>
|
||||
{SPEEDS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setSpeed(s)}
|
||||
className={cn(
|
||||
'rounded px-1.5 py-0.5 text-[11px] tabular-nums transition-colors',
|
||||
speed === s ? 'font-semibold' : 'opacity-60 hover:opacity-100'
|
||||
)}
|
||||
style={
|
||||
speed === s
|
||||
? { background: 'var(--pp-ink)', color: 'var(--pp-paper)' }
|
||||
: { color: 'var(--pp-muted)' }
|
||||
}
|
||||
>
|
||||
{s}×
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-2 rounded-lg border px-4 py-3"
|
||||
style={{ borderColor: 'var(--pp-line)', background: 'var(--pp-card)' }}
|
||||
>
|
||||
<PageMd md={speak} className="text-[15px] leading-relaxed [&_p]:my-0" />
|
||||
</div>
|
||||
|
||||
{disclaimer ? (
|
||||
<p className="mt-2 text-xs italic" style={{ color: 'var(--pp-muted)' }}>
|
||||
{fr ? disclaimer.fr : disclaimer.en}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<noscript>
|
||||
<ol className="mt-3 list-inside list-decimal space-y-1 text-sm">
|
||||
{beats.map((b) => (
|
||||
<li key={b.id}>{fr ? b.speak.fr : b.speak.en}</li>
|
||||
))}
|
||||
</ol>
|
||||
</noscript>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
153
memento-note/components/simulators/carnot-cycle-anim-view.tsx
Normal file
153
memento-note/components/simulators/carnot-cycle-anim-view.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
|
||||
|
||||
/**
|
||||
* Carnot cycle animated scene — piston apparatus + live P-V diagram.
|
||||
* Driven by AnimPlayerShell via `step` (0..4). Pure SVG, transitions on
|
||||
* transform/opacity only (GPU-friendly, reduced-motion safe).
|
||||
*/
|
||||
|
||||
const PISTON_Y = [105, 70, 135, 170, 110]
|
||||
const GAS_HOT = [1, 0.55, 0.12, 0.6, 0.5]
|
||||
const GAS_COLD = [0, 0.45, 0.9, 0.4, 0.4]
|
||||
const T_LABEL = ['T_h', 'T_h → T_c', 'T_c', 'T_c → T_h', 'η']
|
||||
|
||||
// P–V anchors (illustrative)
|
||||
const A = { x: 405, y: 50 }
|
||||
const B = { x: 495, y: 159 }
|
||||
const C = { x: 630, y: 212 }
|
||||
const D = { x: 465, y: 175 }
|
||||
const ANCHORS = [A, B, C, D]
|
||||
const CYCLE = `${A.x},${A.y} ${B.x},${B.y} ${C.x},${C.y} ${D.x},${D.y}`
|
||||
|
||||
const EASE = 'transform 700ms cubic-bezier(0.22, 1, 0.36, 1), opacity 500ms ease'
|
||||
|
||||
export function CarnotCycleAnimView({ step, lang }: { step: number; lang: string }) {
|
||||
const fr = lang.startsWith('fr')
|
||||
const dark = useDarkMode()
|
||||
const s = Math.min(step, 4)
|
||||
|
||||
const ink = dark ? '#EDE7DB' : '#242422'
|
||||
const muted = dark ? '#A39C8D' : '#686762'
|
||||
const plum = dark ? '#D07AA6' : '#9F3F70'
|
||||
const blue = dark ? '#7FA8CC' : '#3F6F9F'
|
||||
const line = dark ? '#3B352A' : '#D6D0C6'
|
||||
const card = dark ? '#221E17' : '#FFFDF8'
|
||||
const paper = dark ? '#17140F' : '#F4F0E8'
|
||||
|
||||
const pistonY = PISTON_Y[s]
|
||||
const showHotPlate = s === 0
|
||||
const showColdPlate = s === 2
|
||||
const showInsulation = s === 1 || s === 3
|
||||
const showQh = s === 0
|
||||
const showQc = s === 2
|
||||
const showWout = s === 0 || s === 1
|
||||
const showWin = s === 2 || s === 3
|
||||
const marker = ANCHORS[Math.min(s, 3)]
|
||||
const segDone = s // segments A→B (0), B→C (1), C→D (2), D→A (3)
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 680 300"
|
||||
className="w-full"
|
||||
role="img"
|
||||
aria-label={fr ? 'Animation du cycle de Carnot' : 'Carnot cycle animation'}
|
||||
style={{ background: paper, display: 'block' }}
|
||||
>
|
||||
{/* ══ Piston apparatus ══ */}
|
||||
<g>
|
||||
{/* cylinder walls */}
|
||||
<rect x={70} y={40} width={110} height={195} fill={card} stroke={ink} strokeWidth={2} rx={4} />
|
||||
{/* gas: cold + hot crossfade */}
|
||||
<g style={{ transform: `translateY(${pistonY - 45}px)`, transition: EASE }}>
|
||||
<rect x={74} y={45} width={102} height={186 - (pistonY - 45)} fill={blue} opacity={GAS_COLD[s] * 0.35} style={{ transition: 'opacity 600ms ease' }} />
|
||||
<rect x={74} y={45} width={102} height={186 - (pistonY - 45)} fill={plum} opacity={GAS_HOT[s] * 0.35} style={{ transition: 'opacity 600ms ease' }} />
|
||||
</g>
|
||||
{/* piston */}
|
||||
<g style={{ transform: `translateY(${pistonY - 95}px)`, transition: EASE }}>
|
||||
<rect x={74} y={95} width={102} height={14} fill={ink} rx={3} opacity={0.9} />
|
||||
<rect x={118} y={60} width={14} height={36} fill={ink} rx={3} opacity={0.9} />
|
||||
<rect x={105} y={48} width={40} height={12} fill={ink} rx={4} opacity={0.9} />
|
||||
</g>
|
||||
{/* hot plate */}
|
||||
<rect x={60} y={240} width={130} height={12} rx={4} fill={plum} opacity={showHotPlate ? 0.9 : s === 4 ? 0 : 0.12} style={{ transition: 'opacity 500ms ease' }} />
|
||||
{showHotPlate ? (
|
||||
<text x={125} y={266} textAnchor="middle" fontSize={11} fill={plum}>
|
||||
{fr ? 'Source chaude' : 'Hot'} T_h
|
||||
</text>
|
||||
) : null}
|
||||
{/* cold plate */}
|
||||
<rect x={60} y={240} width={130} height={12} rx={4} fill={blue} opacity={showColdPlate ? 0.9 : s === 4 ? 0 : 0.12} style={{ transition: 'opacity 500ms ease' }} />
|
||||
{showColdPlate ? (
|
||||
<text x={125} y={266} textAnchor="middle" fontSize={11} fill={blue}>
|
||||
{fr ? 'Source froide' : 'Cold'} T_c
|
||||
</text>
|
||||
) : null}
|
||||
{/* insulation */}
|
||||
<rect x={60} y={240} width={130} height={12} rx={4} fill="none" stroke={muted} strokeWidth={2} strokeDasharray="6 4" opacity={showInsulation ? 1 : 0} style={{ transition: 'opacity 500ms ease' }} />
|
||||
{showInsulation ? (
|
||||
<text x={125} y={266} textAnchor="middle" fontSize={11} fill={muted}>
|
||||
{fr ? 'Isolant (Q = 0)' : 'Insulated (Q = 0)'}
|
||||
</text>
|
||||
) : null}
|
||||
{/* Q_h arrow in */}
|
||||
<g opacity={showQh ? 1 : 0} style={{ transition: 'opacity 400ms ease' }}>
|
||||
<line x1={95} y1={238} x2={95} y2={196} stroke={plum} strokeWidth={5} strokeLinecap="round" />
|
||||
<polygon points="95,190 89,202 101,202" fill={plum} />
|
||||
<text x={70} y={210} fontSize={12} fontWeight={700} fill={plum}>Q_h</text>
|
||||
</g>
|
||||
{/* Q_c arrow out */}
|
||||
<g opacity={showQc ? 1 : 0} style={{ transition: 'opacity 400ms ease' }}>
|
||||
<line x1={155} y1={196} x2={155} y2={238} stroke={blue} strokeWidth={5} strokeLinecap="round" />
|
||||
<polygon points="155,244 149,232 161,232" fill={blue} />
|
||||
<text x={164} y={226} fontSize={12} fontWeight={700} fill={blue}>Q_c</text>
|
||||
</g>
|
||||
{/* W arrows */}
|
||||
<g opacity={showWout ? 1 : 0} style={{ transition: 'opacity 400ms ease' }}>
|
||||
<line x1={210} y1={180} x2={210} y2={120} stroke={ink} strokeWidth={4} strokeLinecap="round" />
|
||||
<polygon points="210,112 204,124 216,124" fill={ink} />
|
||||
<text x={220} y={152} fontSize={12} fontWeight={700} fill={ink}>W</text>
|
||||
</g>
|
||||
<g opacity={showWin ? 1 : 0} style={{ transition: 'opacity 400ms ease' }}>
|
||||
<line x1={210} y1={120} x2={210} y2={180} stroke={ink} strokeWidth={4} strokeLinecap="round" />
|
||||
<polygon points="210,188 204,176 216,176" fill={ink} />
|
||||
<text x={220} y={152} fontSize={12} fontWeight={700} fill={ink}>W</text>
|
||||
</g>
|
||||
{/* temperature label inside gas */}
|
||||
<text x={125} y={pistonY + 40} textAnchor="middle" fontSize={13} fontWeight={700} fill={ink} style={{ transition: EASE }}>
|
||||
{T_LABEL[s]}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* ══ P–V diagram ══ */}
|
||||
<g>
|
||||
<text x={340} y={30} fontSize={12} fontWeight={700} fill={muted} style={{ fontFamily: 'var(--pp-mono, monospace)' }}>
|
||||
P–V
|
||||
</text>
|
||||
{/* axes */}
|
||||
<line x1={330} y1={250} x2={660} y2={250} stroke={ink} strokeWidth={1.5} />
|
||||
<line x1={330} y1={250} x2={330} y2={30} stroke={ink} strokeWidth={1.5} />
|
||||
<text x={655} y={266} fontSize={11} fill={muted} textAnchor="end">V</text>
|
||||
<text x={322} y={40} fontSize={11} fill={muted} textAnchor="end">P</text>
|
||||
{/* isotherms */}
|
||||
<path d="M 395 56 Q 460 130 660 218" fill="none" stroke={plum} strokeWidth={1} strokeDasharray="4 4" opacity={0.5} />
|
||||
<text x={620} y={200} fontSize={10} fill={plum}>T_h</text>
|
||||
<path d="M 400 175 Q 520 208 660 230" fill="none" stroke={blue} strokeWidth={1} strokeDasharray="4 4" opacity={0.5} />
|
||||
<text x={620} y={242} fontSize={10} fill={blue}>T_c</text>
|
||||
{/* cycle area (final beat) */}
|
||||
<polygon points={CYCLE} fill={plum} opacity={s === 4 ? 0.14 : 0} style={{ transition: 'opacity 600ms ease' }} />
|
||||
{s === 4 ? (
|
||||
<text x={505} y={150} fontSize={15} fontWeight={800} fill={plum} textAnchor="middle">W</text>
|
||||
) : null}
|
||||
{/* cycle segments, revealed progressively */}
|
||||
<polyline points={`${A.x},${A.y} ${B.x},${B.y}`} fill="none" stroke={ink} strokeWidth={2.5} opacity={segDone >= 0 ? 1 : 0.15} />
|
||||
<polyline points={`${B.x},${B.y} ${C.x},${C.y}`} fill="none" stroke={ink} strokeWidth={2.5} opacity={segDone >= 1 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
|
||||
<polyline points={`${C.x},${C.y} ${D.x},${D.y}`} fill="none" stroke={ink} strokeWidth={2.5} opacity={segDone >= 2 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
|
||||
<polyline points={`${D.x},${D.y} ${A.x},${A.y}`} fill="none" stroke={ink} strokeWidth={2.5} opacity={segDone >= 3 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
|
||||
{/* current state marker */}
|
||||
<circle cx={marker.x} cy={marker.y} r={6} fill={plum} stroke={card} strokeWidth={2} style={{ transition: EASE }} />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
672
memento-note/components/simulators/carnot-cycle-view.tsx
Normal file
672
memento-note/components/simulators/carnot-cycle-view.tsx
Normal file
@@ -0,0 +1,672 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
carnotCycleSimulator as sim,
|
||||
fridgeLoadFromModeLoad,
|
||||
modeLoadFromFridgeLoad,
|
||||
resolveCarnotPhysics,
|
||||
type CarnotMode,
|
||||
type CarnotQuantity,
|
||||
} from '@/lib/simulators/carnot-cycle'
|
||||
import { intentColor } from '@/lib/interactive-demo/intent-colors'
|
||||
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
|
||||
import { SimHeading, SimOutputCard, SimSlider } from './sim-controls'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const SVG_W = 360
|
||||
const SVG_H = 270
|
||||
|
||||
type TempUnit = 'K' | 'C' | 'F'
|
||||
|
||||
const TEMP_UNITS: { id: TempUnit; label: string }[] = [
|
||||
{ id: 'K', label: 'K' },
|
||||
{ id: 'C', label: '°C' },
|
||||
{ id: 'F', label: '°F' },
|
||||
]
|
||||
|
||||
const MODES: { id: CarnotMode; fr: string; en: string }[] = [
|
||||
{ id: 'fridge', fr: 'Frigo', en: 'Fridge' },
|
||||
{ id: 'heat_pump', fr: 'PAC', en: 'Heat pump' },
|
||||
{ id: 'engine', fr: 'Moteur', en: 'Engine' },
|
||||
]
|
||||
|
||||
const QTY: { id: CarnotQuantity; fr: string; en: string }[] = [
|
||||
{ id: 'energy', fr: 'Énergie (kJ)', en: 'Energy (kJ)' },
|
||||
{ id: 'power', fr: 'Puissance (W)', en: 'Power (W)' },
|
||||
]
|
||||
|
||||
function kelvinToDisplay(k: number, unit: TempUnit): number {
|
||||
if (unit === 'C') return k - 273.15
|
||||
if (unit === 'F') return (k * 9) / 5 - 459.67
|
||||
return k
|
||||
}
|
||||
|
||||
function displayToKelvin(v: number, unit: TempUnit): number {
|
||||
if (unit === 'C') return v + 273.15
|
||||
if (unit === 'F') return ((v + 459.67) * 5) / 9
|
||||
return v
|
||||
}
|
||||
|
||||
function formatTemp(k: number, unit: TempUnit): string {
|
||||
const v = kelvinToDisplay(k, unit)
|
||||
if (unit === 'K') return `${Math.round(v)} K`
|
||||
if (unit === 'C') {
|
||||
const r = Math.round(v * 10) / 10
|
||||
return `${Number.isInteger(r) ? r : r.toFixed(1)} °C`
|
||||
}
|
||||
return `${Math.round(v)} °F`
|
||||
}
|
||||
|
||||
function tempSliderMeta(
|
||||
param: { min: number; max: number; step: number },
|
||||
unit: TempUnit
|
||||
): { min: number; max: number; step: number; unitLabel: string } {
|
||||
if (unit === 'K') {
|
||||
return { min: param.min, max: param.max, step: param.step, unitLabel: 'K' }
|
||||
}
|
||||
if (unit === 'C') {
|
||||
return {
|
||||
min: Math.round((param.min - 273.15) * 10) / 10,
|
||||
max: Math.round((param.max - 273.15) * 10) / 10,
|
||||
step: 0.5,
|
||||
unitLabel: '°C',
|
||||
}
|
||||
}
|
||||
return {
|
||||
min: Math.round(((param.min * 9) / 5 - 459.67) * 10) / 10,
|
||||
max: Math.round(((param.max * 9) / 5 - 459.67) * 10) / 10,
|
||||
step: 1,
|
||||
unitLabel: '°F',
|
||||
}
|
||||
}
|
||||
|
||||
function formatQty(v: number): string {
|
||||
if (!Number.isFinite(v)) return '—'
|
||||
if (Math.abs(v - Math.round(v)) < 0.05) return String(Math.round(v))
|
||||
return v.toFixed(1)
|
||||
}
|
||||
|
||||
function Segmented<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
ariaLabel,
|
||||
}: {
|
||||
value: T
|
||||
onChange: (v: T) => void
|
||||
options: { id: T; label: string }[]
|
||||
ariaLabel: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="inline-flex max-w-full flex-wrap rounded-lg border border-border/60 bg-background/80 p-0.5"
|
||||
role="group"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => onChange(o.id)}
|
||||
className={cn(
|
||||
'rounded-md px-2 py-1 text-[11px] font-semibold transition-colors',
|
||||
value === o.id
|
||||
? 'bg-foreground text-background'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
aria-pressed={value === o.id}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Arrowhead + shaft, thickness ∝ |value|/maxRef. */
|
||||
function FlowArrow({
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
value,
|
||||
maxRef,
|
||||
color,
|
||||
label,
|
||||
unit,
|
||||
labelSide = 'right',
|
||||
}: {
|
||||
x1: number
|
||||
y1: number
|
||||
x2: number
|
||||
y2: number
|
||||
value: number
|
||||
maxRef: number
|
||||
color: string
|
||||
label: string
|
||||
unit?: string
|
||||
labelSide?: 'left' | 'right' | 'above' | 'below'
|
||||
}) {
|
||||
const mag = Math.max(0, value)
|
||||
const t = 2.2 + (mag / Math.max(1, maxRef)) * 10
|
||||
const dx = x2 - x1
|
||||
const dy = y2 - y1
|
||||
const len = Math.hypot(dx, dy) || 1
|
||||
const ux = dx / len
|
||||
const uy = dy / len
|
||||
const headLen = Math.min(14, Math.max(9, 7 + t * 0.45))
|
||||
const headHalf = Math.min(7, 3.2 + t * 0.35)
|
||||
const bx = x2 - ux * headLen
|
||||
const by = y2 - uy * headLen
|
||||
const px = -uy
|
||||
const py = ux
|
||||
const mx = (x1 + bx) / 2
|
||||
const my = (y1 + by) / 2
|
||||
const labelGap = 11 + t * 0.45
|
||||
|
||||
let lx = mx
|
||||
let ly = my
|
||||
let textAnchor: 'start' | 'middle' | 'end' = 'middle'
|
||||
if (labelSide === 'above') ly = my - labelGap
|
||||
else if (labelSide === 'below') ly = my + labelGap
|
||||
else if (labelSide === 'right') {
|
||||
lx = mx + labelGap
|
||||
textAnchor = 'start'
|
||||
} else {
|
||||
lx = mx - labelGap
|
||||
textAnchor = 'end'
|
||||
}
|
||||
|
||||
return (
|
||||
<g>
|
||||
<line
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={bx}
|
||||
y2={by}
|
||||
stroke={color}
|
||||
strokeWidth={t}
|
||||
strokeLinecap="round"
|
||||
opacity={0.9}
|
||||
/>
|
||||
<polygon
|
||||
points={`${x2},${y2} ${bx + px * headHalf},${by + py * headHalf} ${bx - px * headHalf},${by - py * headHalf}`}
|
||||
fill={color}
|
||||
opacity={0.95}
|
||||
/>
|
||||
<text
|
||||
x={lx}
|
||||
y={ly}
|
||||
fontSize={11}
|
||||
fill={color}
|
||||
fontWeight={600}
|
||||
dominantBaseline="middle"
|
||||
textAnchor={textAnchor}
|
||||
>
|
||||
{label} {formatQty(value)}
|
||||
{unit ? ` ${unit}` : ''}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
export function CarnotCycleView({
|
||||
preset,
|
||||
disclaimer,
|
||||
lang,
|
||||
}: {
|
||||
preset?: Record<string, number>
|
||||
title?: string
|
||||
disclaimer?: string
|
||||
lang: string
|
||||
}) {
|
||||
const fr = lang.startsWith('fr')
|
||||
const dark = useDarkMode()
|
||||
const [tempUnit, setTempUnit] = useState<TempUnit>('K')
|
||||
const [mode, setMode] = useState<CarnotMode>('fridge')
|
||||
const [qty, setQty] = useState<CarnotQuantity>('energy')
|
||||
const [values, setValues] = useState<Record<string, number>>(() => {
|
||||
const env: Record<string, number> = {}
|
||||
for (const p of sim.params) env[p.id] = preset?.[p.id] ?? p.defaultValue
|
||||
return env
|
||||
})
|
||||
|
||||
const phys = useMemo(
|
||||
() =>
|
||||
resolveCarnotPhysics(
|
||||
values.t_cold,
|
||||
values.t_hot,
|
||||
modeLoadFromFridgeLoad(values.t_cold, values.t_hot, values.q_cold, mode),
|
||||
mode
|
||||
),
|
||||
[values.t_cold, values.t_hot, values.q_cold, mode]
|
||||
)
|
||||
|
||||
const unitE = qty === 'energy' ? 'kJ' : 'W'
|
||||
const workName =
|
||||
qty === 'energy'
|
||||
? fr
|
||||
? 'Travail'
|
||||
: 'Work'
|
||||
: fr
|
||||
? 'Puissance'
|
||||
: 'Power'
|
||||
|
||||
const maxRef = Math.max(phys.qh || 0, phys.qc || 0, phys.w || 0, 1)
|
||||
const cHot = intentColor('warning', dark)
|
||||
const cCold = intentColor('cache', dark)
|
||||
const cWork = intentColor('compute', dark)
|
||||
const text = dark ? '#e4e4e7' : '#27272a'
|
||||
|
||||
const cx = SVG_W / 2 - 20
|
||||
const hotY = 28
|
||||
const coldY = SVG_H - 42
|
||||
const midY = SVG_H / 2 - 4
|
||||
const r = 32
|
||||
const qOffset = 42
|
||||
const isEngine = mode === 'engine'
|
||||
|
||||
const loadMeta = useMemo(() => {
|
||||
if (mode === 'fridge') {
|
||||
return {
|
||||
symbol: qty === 'energy' ? 'Q_c' : '\\dot{Q}_c',
|
||||
label: fr ? 'Chaleur extraite (froid)' : 'Heat extracted (cold)',
|
||||
hint: fr
|
||||
? 'Charge utile du réfrigérateur'
|
||||
: 'Useful fridge cooling load',
|
||||
}
|
||||
}
|
||||
if (mode === 'heat_pump') {
|
||||
return {
|
||||
symbol: qty === 'energy' ? 'Q_h' : '\\dot{Q}_h',
|
||||
label: fr ? 'Chaleur fournie (chaud)' : 'Heat delivered (hot)',
|
||||
hint: fr ? 'Charge utile de la PAC' : 'Useful heat-pump output',
|
||||
}
|
||||
}
|
||||
return {
|
||||
symbol: qty === 'energy' ? 'Q_h' : '\\dot{Q}_h',
|
||||
label: fr ? 'Chaleur absorbée (chaud)' : 'Heat absorbed (hot)',
|
||||
hint: fr ? 'Entrée thermique du moteur' : 'Engine heat input',
|
||||
}
|
||||
}, [mode, qty, fr])
|
||||
|
||||
const modeLoad = modeLoadFromFridgeLoad(
|
||||
values.t_cold,
|
||||
values.t_hot,
|
||||
values.q_cold,
|
||||
mode
|
||||
)
|
||||
|
||||
const workSym = qty === 'energy' ? 'W' : 'P'
|
||||
const qLabel = (base: 'c' | 'h') => (qty === 'energy' ? `Q_${base}` : `Q̇_${base}`)
|
||||
|
||||
const lawLine = !phys.ok
|
||||
? fr
|
||||
? 'Il faut T_h > T_c (températures absolues).'
|
||||
: 'Need T_h > T_c (absolute temperatures).'
|
||||
: fr
|
||||
? `1ᵉʳ principe : ${qLabel('h')} = ${qLabel('c')} + ${workSym} → ${formatQty(phys.qh)} = ${formatQty(phys.qc)} + ${formatQty(phys.w)} ${unitE}`
|
||||
: `1st law: ${qLabel('h')} = ${qLabel('c')} + ${workSym} → ${formatQty(phys.qh)} = ${formatQty(phys.qc)} + ${formatQty(phys.w)} ${unitE}`
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Segmented
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
ariaLabel={fr ? 'Mode machine' : 'Machine mode'}
|
||||
options={MODES.map((m) => ({ id: m.id, label: fr ? m.fr : m.en }))}
|
||||
/>
|
||||
<Segmented
|
||||
value={qty}
|
||||
onChange={setQty}
|
||||
ariaLabel={fr ? 'Énergie ou puissance' : 'Energy or power'}
|
||||
options={QTY.map((q) => ({ id: q.id, label: fr ? q.fr : q.en }))}
|
||||
/>
|
||||
<Segmented
|
||||
value={tempUnit}
|
||||
onChange={setTempUnit}
|
||||
ariaLabel={fr ? 'Unité de température' : 'Temperature unit'}
|
||||
options={TEMP_UNITS.map((u) => ({ id: u.id, label: u.label }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]">
|
||||
<div>
|
||||
<svg
|
||||
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
|
||||
className="mx-auto w-full max-w-md"
|
||||
role="img"
|
||||
aria-label={fr ? sim.title.fr : sim.title.en}
|
||||
>
|
||||
<rect
|
||||
x={cx - 105}
|
||||
y={hotY - 18}
|
||||
width={210}
|
||||
height={34}
|
||||
rx={10}
|
||||
fill={`${cHot}14`}
|
||||
stroke={cHot}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<text
|
||||
x={cx}
|
||||
y={hotY}
|
||||
textAnchor="middle"
|
||||
fontSize={11.5}
|
||||
fontWeight={600}
|
||||
fill={cHot}
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{fr ? 'Source chaude' : 'Hot'} · T_h = {formatTemp(values.t_hot, tempUnit)}
|
||||
</text>
|
||||
|
||||
<rect
|
||||
x={cx - 105}
|
||||
y={coldY - 18}
|
||||
width={210}
|
||||
height={34}
|
||||
rx={10}
|
||||
fill={`${cCold}14`}
|
||||
stroke={cCold}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<text
|
||||
x={cx}
|
||||
y={coldY}
|
||||
textAnchor="middle"
|
||||
fontSize={11.5}
|
||||
fontWeight={600}
|
||||
fill={cCold}
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{fr ? 'Source froide' : 'Cold'} · T_c ={' '}
|
||||
{formatTemp(values.t_cold, tempUnit)}
|
||||
</text>
|
||||
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={midY}
|
||||
r={r}
|
||||
fill={dark ? '#141820' : '#ffffff'}
|
||||
stroke={text}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<text
|
||||
x={cx}
|
||||
y={midY}
|
||||
textAnchor="middle"
|
||||
fontSize={11}
|
||||
fontWeight={600}
|
||||
fill={text}
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{fr ? 'Machine' : 'Engine'}
|
||||
</text>
|
||||
|
||||
{phys.ok && !isEngine ? (
|
||||
<>
|
||||
{/* Fridge / PAC: Qc↑ into machine, W→ into machine, Qh↑ to hot */}
|
||||
<FlowArrow
|
||||
x1={cx - qOffset}
|
||||
y1={coldY - 22}
|
||||
x2={cx - qOffset}
|
||||
y2={midY + r + 2}
|
||||
value={phys.qc}
|
||||
maxRef={maxRef}
|
||||
color={cCold}
|
||||
label={qLabel('c')}
|
||||
unit={unitE}
|
||||
labelSide="left"
|
||||
/>
|
||||
<FlowArrow
|
||||
x1={cx + qOffset}
|
||||
y1={midY - r - 2}
|
||||
x2={cx + qOffset}
|
||||
y2={hotY + 22}
|
||||
value={phys.qh}
|
||||
maxRef={maxRef}
|
||||
color={cHot}
|
||||
label={qLabel('h')}
|
||||
unit={unitE}
|
||||
/>
|
||||
<FlowArrow
|
||||
x1={cx + r + 78}
|
||||
y1={midY}
|
||||
x2={cx + r + 4}
|
||||
y2={midY}
|
||||
value={phys.w}
|
||||
maxRef={maxRef}
|
||||
color={cWork}
|
||||
label={workSym}
|
||||
unit={unitE}
|
||||
labelSide="above"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{phys.ok && isEngine ? (
|
||||
<>
|
||||
{/* Engine: Qh↓ from hot into machine, W→ out, Qc↓ to cold */}
|
||||
<FlowArrow
|
||||
x1={cx + qOffset}
|
||||
y1={hotY + 22}
|
||||
x2={cx + qOffset}
|
||||
y2={midY - r - 2}
|
||||
value={phys.qh}
|
||||
maxRef={maxRef}
|
||||
color={cHot}
|
||||
label={qLabel('h')}
|
||||
unit={unitE}
|
||||
/>
|
||||
<FlowArrow
|
||||
x1={cx - qOffset}
|
||||
y1={midY + r + 2}
|
||||
x2={cx - qOffset}
|
||||
y2={coldY - 22}
|
||||
value={phys.qc}
|
||||
maxRef={maxRef}
|
||||
color={cCold}
|
||||
label={qLabel('c')}
|
||||
unit={unitE}
|
||||
labelSide="left"
|
||||
/>
|
||||
<FlowArrow
|
||||
x1={cx + r + 4}
|
||||
y1={midY}
|
||||
x2={cx + r + 78}
|
||||
y2={midY}
|
||||
value={phys.w}
|
||||
maxRef={maxRef}
|
||||
color={cWork}
|
||||
label={workSym}
|
||||
unit={unitE}
|
||||
labelSide="above"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<text x={10} y={SVG_H - 10} fontSize={10} fill={dark ? '#8b8b93' : '#6b7280'}>
|
||||
{fr
|
||||
? qty === 'energy'
|
||||
? 'Épaisseur ∝ énergie (kJ) — W = travail (pas le watt)'
|
||||
: 'Épaisseur ∝ puissance — P et W (watt) = même unité ici'
|
||||
: qty === 'energy'
|
||||
? 'Thickness ∝ energy (kJ) — W = work (not the watt)'
|
||||
: 'Thickness ∝ power — P uses watts'}
|
||||
</text>
|
||||
</svg>
|
||||
<p className="mt-2 text-center text-[11px] text-muted-foreground">{lawLine}</p>
|
||||
{phys.ok && phys.entropyOk ? (
|
||||
<p className="mt-1 text-center text-[10px] text-muted-foreground/80">
|
||||
{fr
|
||||
? '2ᵉ principe (réversible) : Q_c/T_c = Q_h/T_h'
|
||||
: '2nd law (reversible): Q_c/T_c = Q_h/T_h'}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<SimHeading>{fr ? 'Paramètres' : 'Parameters'}</SimHeading>
|
||||
<div className="space-y-2">
|
||||
{sim.params
|
||||
.filter((p) => p.id === 't_cold' || p.id === 't_hot')
|
||||
.map((p) => {
|
||||
const meta = tempSliderMeta(p, tempUnit)
|
||||
const displayVal = kelvinToDisplay(values[p.id], tempUnit)
|
||||
return (
|
||||
<SimSlider
|
||||
key={`${p.id}-${tempUnit}`}
|
||||
symbol={p.symbol}
|
||||
label={fr ? p.label.fr : p.label.en}
|
||||
unit={meta.unitLabel}
|
||||
min={meta.min}
|
||||
max={meta.max}
|
||||
step={meta.step}
|
||||
value={
|
||||
tempUnit === 'K'
|
||||
? Math.round(displayVal)
|
||||
: Math.round(displayVal * 10) / 10
|
||||
}
|
||||
intent={p.intent}
|
||||
onChange={(v) => {
|
||||
const k = displayToKelvin(v, tempUnit)
|
||||
const clamped = Math.min(p.max, Math.max(p.min, k))
|
||||
setValues((s) => ({ ...s, [p.id]: clamped }))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
<SimSlider
|
||||
key={`load-${mode}-${qty}`}
|
||||
symbol={loadMeta.symbol}
|
||||
label={loadMeta.label}
|
||||
unit={unitE}
|
||||
min={10}
|
||||
max={500}
|
||||
step={5}
|
||||
value={Math.round(modeLoad * 10) / 10}
|
||||
intent="flow"
|
||||
onChange={(v) => {
|
||||
const fridgeQc = fridgeLoadFromModeLoad(
|
||||
values.t_cold,
|
||||
values.t_hot,
|
||||
v,
|
||||
mode
|
||||
)
|
||||
setValues((s) => ({
|
||||
...s,
|
||||
q_cold: Math.min(500, Math.max(10, fridgeQc)),
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
<p className="px-1 text-[10px] text-muted-foreground">{loadMeta.hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SimHeading>
|
||||
{fr ? 'Résultats (limites de Carnot)' : 'Results (Carnot limits)'}
|
||||
</SimHeading>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{mode === 'fridge' || mode === 'heat_pump' ? (
|
||||
<>
|
||||
<SimOutputCard
|
||||
symbol={String.raw`\mathrm{COP}_{R}`}
|
||||
label={fr ? 'COP frigo' : 'Fridge COP'}
|
||||
value={phys.copR}
|
||||
intent="output"
|
||||
digits={2}
|
||||
/>
|
||||
<SimOutputCard
|
||||
symbol={String.raw`\mathrm{COP}_{HP}`}
|
||||
label={fr ? 'COP PAC' : 'Heat-pump COP'}
|
||||
value={phys.copHP}
|
||||
intent="output"
|
||||
digits={2}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<SimOutputCard
|
||||
symbol={String.raw`\eta`}
|
||||
label={fr ? 'Rendement moteur' : 'Engine efficiency'}
|
||||
value={phys.eta * 100}
|
||||
unit="%"
|
||||
intent="highlight"
|
||||
digits={1}
|
||||
/>
|
||||
)}
|
||||
<SimOutputCard
|
||||
symbol={workSym}
|
||||
label={
|
||||
isEngine
|
||||
? fr
|
||||
? `${workName} fourni`
|
||||
: `${workName} out`
|
||||
: fr
|
||||
? `${workName} minimale`
|
||||
: `Minimum ${workName.toLowerCase()}`
|
||||
}
|
||||
value={phys.w}
|
||||
unit={unitE}
|
||||
intent="compute"
|
||||
digits={1}
|
||||
/>
|
||||
<SimOutputCard
|
||||
symbol={
|
||||
qty === 'energy' ? String.raw`Q_h` : String.raw`\dot{Q}_h`
|
||||
}
|
||||
label={
|
||||
isEngine
|
||||
? fr
|
||||
? 'Chaleur absorbée'
|
||||
: 'Heat absorbed'
|
||||
: fr
|
||||
? 'Chaleur côté chaud'
|
||||
: 'Hot-side heat'
|
||||
}
|
||||
value={phys.qh}
|
||||
unit={unitE}
|
||||
intent="flow"
|
||||
digits={1}
|
||||
/>
|
||||
<SimOutputCard
|
||||
symbol={
|
||||
qty === 'energy' ? String.raw`Q_c` : String.raw`\dot{Q}_c`
|
||||
}
|
||||
label={
|
||||
isEngine
|
||||
? fr
|
||||
? 'Chaleur rejetée (froid)'
|
||||
: 'Heat rejected (cold)'
|
||||
: fr
|
||||
? 'Chaleur côté froid'
|
||||
: 'Cold-side heat'
|
||||
}
|
||||
value={phys.qc}
|
||||
unit={unitE}
|
||||
intent="cache"
|
||||
digits={1}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] leading-relaxed text-muted-foreground">
|
||||
{qty === 'energy'
|
||||
? fr
|
||||
? 'W = travail (énergie en kJ), pas le watt. Passe en « Puissance (W) » pour raisonner en watts.'
|
||||
: 'W = work (energy in kJ), not the watt. Switch to “Power (W)” to use watts.'
|
||||
: fr
|
||||
? 'Mode puissance : P, Q̇_c et Q̇_h sont en watts (W). Les COP / η restent sans unité.'
|
||||
: 'Power mode: P, Q̇_c and Q̇_h are in watts (W). COP / η stay dimensionless.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{disclaimer ? (
|
||||
<p className="mt-3 text-xs italic text-muted-foreground">{disclaimer}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
205
memento-note/components/simulators/generic-formula-view.tsx
Normal file
205
memento-note/components/simulators/generic-formula-view.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceDot,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Bar,
|
||||
BarChart,
|
||||
} from 'recharts'
|
||||
import type { GenericFormulaSim } from '@/lib/interactive-page'
|
||||
import { parseSimExpr } from '@/lib/interactive-page/sim-eval'
|
||||
import { intentColor } from '@/lib/interactive-demo/intent-colors'
|
||||
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
|
||||
import { SimHeading, SimOutputCard, SimSlider } from './sim-controls'
|
||||
|
||||
const CURVE_SAMPLES = 60
|
||||
|
||||
/** Generic slider-driven formula simulator (safe exprs, no eval). */
|
||||
export function GenericFormulaView({
|
||||
sim,
|
||||
lang,
|
||||
}: {
|
||||
sim: GenericFormulaSim
|
||||
lang: string
|
||||
}) {
|
||||
const fr = lang.startsWith('fr')
|
||||
const dark = useDarkMode()
|
||||
const [values, setValues] = useState<Record<string, number>>(() => {
|
||||
const env: Record<string, number> = {}
|
||||
for (const p of sim.params) env[p.id] = p.defaultValue
|
||||
return env
|
||||
})
|
||||
|
||||
const compiled = useMemo(
|
||||
() =>
|
||||
sim.computed.map((c) => ({
|
||||
def: c,
|
||||
parsed: parseSimExpr(c.expr),
|
||||
})),
|
||||
[sim.computed]
|
||||
)
|
||||
|
||||
const results = useMemo(() => {
|
||||
const env = { ...values }
|
||||
const out: Record<string, number> = {}
|
||||
for (const c of compiled) {
|
||||
const v = 'message' in c.parsed ? NaN : c.parsed.evaluate(env)
|
||||
env[c.def.id] = v
|
||||
out[c.def.id] = v
|
||||
}
|
||||
return out
|
||||
}, [compiled, values])
|
||||
|
||||
const visual = sim.visual
|
||||
const curve = useMemo(() => {
|
||||
if (visual.kind !== 'curve') return null
|
||||
const xParam = sim.params.find((p) => p.id === visual.xParamId)
|
||||
const parsed = parseSimExpr(visual.expr)
|
||||
if (!xParam || 'message' in parsed) return null
|
||||
const pts: { x: number; y: number }[] = []
|
||||
for (let i = 0; i <= CURVE_SAMPLES; i++) {
|
||||
const x = xParam.min + ((xParam.max - xParam.min) * i) / CURVE_SAMPLES
|
||||
const y = parsed.evaluate({ ...values, [xParam.id]: x })
|
||||
pts.push({ x: Number(x.toFixed(4)), y: Number.isFinite(y) ? Number(y.toFixed(6)) : 0 })
|
||||
}
|
||||
return { pts, xParam, currentX: values[xParam.id], currentY: parsed.evaluate(values) }
|
||||
}, [visual, sim.params, values])
|
||||
|
||||
const accent = intentColor('highlight', dark)
|
||||
const gridStroke = dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'
|
||||
|
||||
return (
|
||||
<div>
|
||||
{sim.intro ? (
|
||||
<p className="mb-4 text-[15px] leading-relaxed text-muted-foreground">
|
||||
{sim.intro}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid gap-5 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div className="space-y-3">
|
||||
<SimHeading>{fr ? 'Paramètres' : 'Parameters'}</SimHeading>
|
||||
<div className="space-y-2">
|
||||
{sim.params.map((p) => (
|
||||
<SimSlider
|
||||
key={p.id}
|
||||
symbol={p.symbol}
|
||||
label={p.label}
|
||||
unit={p.unit}
|
||||
min={p.min}
|
||||
max={p.max}
|
||||
step={p.step}
|
||||
value={values[p.id]}
|
||||
intent={p.intent}
|
||||
onChange={(v) => setValues((s) => ({ ...s, [p.id]: v }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<SimHeading>{fr ? 'Résultats' : 'Results'}</SimHeading>
|
||||
{sim.visual.kind === 'gauges' ? (
|
||||
<div className="space-y-2">
|
||||
{sim.computed.map((c) => {
|
||||
const v = results[c.id]
|
||||
const maxRef = Math.max(
|
||||
...sim.computed.map((o) => Math.abs(results[o.id] || 0)),
|
||||
1
|
||||
)
|
||||
const color = intentColor(c.intent, dark)
|
||||
return (
|
||||
<div key={c.id} className="rounded-xl border border-border/50 bg-background/70 px-3 py-2">
|
||||
<div className="flex items-baseline justify-between text-xs">
|
||||
<span className="text-muted-foreground">{c.label}</span>
|
||||
<span className="font-semibold tabular-nums" style={{ color }}>
|
||||
{Number.isFinite(v) ? v.toFixed(2) : '—'}
|
||||
{c.unit ? ` ${c.unit}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 h-2 overflow-hidden rounded-full bg-muted/60">
|
||||
<div
|
||||
className="h-full rounded-full transition-[width] duration-150"
|
||||
style={{
|
||||
width: `${Math.min(100, (Math.abs(v || 0) / maxRef) * 100)}%`,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sim.visual.kind === 'bars' ? (
|
||||
<div className="h-48 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={sim.computed.map((c) => ({
|
||||
name: c.label,
|
||||
value: Number.isFinite(results[c.id]) ? results[c.id] : 0,
|
||||
}))}
|
||||
margin={{ top: 8, right: 8, left: 0, bottom: 4 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 10 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 10 }} width={40} axisLine={false} tickLine={false} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="value" fill={accent} radius={[4, 4, 0, 0]} isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sim.visual.kind === 'curve' && curve ? (
|
||||
<div className="h-48 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={curve.pts} margin={{ top: 8, right: 8, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
||||
<XAxis
|
||||
dataKey="x"
|
||||
type="number"
|
||||
domain={[curve.xParam.min, curve.xParam.max]}
|
||||
tick={{ fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v: number) => String(Math.round(v))}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 10 }} width={40} axisLine={false} tickLine={false} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="y" stroke={accent} strokeWidth={2.5} dot={false} isAnimationActive={false} />
|
||||
{Number.isFinite(curve.currentY) ? (
|
||||
<ReferenceDot x={curve.currentX} y={curve.currentY} r={5} fill={accent} stroke="none" />
|
||||
) : null}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{sim.computed.map((c) => (
|
||||
<SimOutputCard
|
||||
key={c.id}
|
||||
symbol={c.symbol}
|
||||
label={c.label}
|
||||
value={results[c.id]}
|
||||
unit={c.unit}
|
||||
intent={c.intent}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{sim.disclaimer ? (
|
||||
<p className="mt-3 text-xs italic text-muted-foreground">{sim.disclaimer}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
memento-note/components/simulators/index.ts
Normal file
27
memento-note/components/simulators/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import { CarnotCycleView } from './carnot-cycle-view'
|
||||
import { CarnotCycleAnimView } from './carnot-cycle-anim-view'
|
||||
import { TsDiagramView } from './ts-diagram-view'
|
||||
|
||||
export type CatalogSimViewProps = {
|
||||
preset?: Record<string, number>
|
||||
title?: string
|
||||
disclaimer?: string
|
||||
lang: string
|
||||
}
|
||||
|
||||
export type CatalogAnimViewProps = {
|
||||
step: number
|
||||
lang: string
|
||||
}
|
||||
|
||||
/** Registry: catalog simId → bespoke slider-simulation view. */
|
||||
export const SIMULATOR_VIEWS: Record<string, ComponentType<CatalogSimViewProps>> = {
|
||||
'carnot-cycle': CarnotCycleView,
|
||||
}
|
||||
|
||||
/** Registry: catalog simId → bespoke animated scene (step-driven). */
|
||||
export const ANIM_VIEWS: Record<string, ComponentType<CatalogAnimViewProps>> = {
|
||||
'carnot-cycle-anim': CarnotCycleAnimView,
|
||||
'ts-diagram': TsDiagramView,
|
||||
}
|
||||
144
memento-note/components/simulators/sim-controls.tsx
Normal file
144
memento-note/components/simulators/sim-controls.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import katex from 'katex'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import { intentColor } from '@/lib/interactive-demo/intent-colors'
|
||||
import type { IntentId } from '@/lib/interactive-demo/types'
|
||||
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function SimKaTeX({ tex, className }: { tex: string; className?: string }) {
|
||||
const html = useMemo(() => {
|
||||
try {
|
||||
// output:'html' avoids MathML annotation leaking as visible "mathrm{…}" in flex layouts
|
||||
return katex.renderToString(tex, { throwOnError: false, output: 'html' })
|
||||
} catch {
|
||||
return tex
|
||||
}
|
||||
}, [tex])
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-block max-w-full [&_.katex]:text-[1em]', className)}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SimSlider({
|
||||
symbol,
|
||||
label,
|
||||
unit,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
value,
|
||||
intent,
|
||||
onChange,
|
||||
}: {
|
||||
symbol: string
|
||||
label: string
|
||||
unit?: string
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
value: number
|
||||
intent?: IntentId
|
||||
onChange: (v: number) => void
|
||||
}) {
|
||||
const dark = useDarkMode()
|
||||
const accent = intentColor(intent, dark)
|
||||
return (
|
||||
<label className="block rounded-xl border border-border/50 bg-background/70 px-3 py-2.5">
|
||||
<span className="flex items-baseline justify-between gap-2">
|
||||
<span className="flex items-baseline gap-2 text-sm font-medium">
|
||||
<SimKaTeX tex={symbol} />
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</span>
|
||||
<span
|
||||
className="rounded-md px-1.5 py-0.5 text-xs font-semibold tabular-nums"
|
||||
style={{ color: accent, backgroundColor: `${accent}18` }}
|
||||
>
|
||||
{value}
|
||||
{unit ? ` ${unit}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="sim-slider mt-2 w-full"
|
||||
style={{ accentColor: accent }}
|
||||
aria-label={label}
|
||||
/>
|
||||
<span className="mt-0.5 flex justify-between text-[10px] tabular-nums text-muted-foreground/70">
|
||||
<span>
|
||||
{min}
|
||||
{unit ? ` ${unit}` : ''}
|
||||
</span>
|
||||
<span>
|
||||
{max}
|
||||
{unit ? ` ${unit}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function SimOutputCard({
|
||||
symbol,
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
intent,
|
||||
digits = 2,
|
||||
}: {
|
||||
symbol: string
|
||||
label: string
|
||||
value: number
|
||||
unit?: string
|
||||
intent?: IntentId
|
||||
digits?: number
|
||||
}) {
|
||||
const dark = useDarkMode()
|
||||
const accent = intentColor(intent, dark)
|
||||
const finite = Number.isFinite(value)
|
||||
return (
|
||||
<div className="rounded-xl border border-border/50 bg-background/70 px-3 py-2.5 text-center">
|
||||
<div className="text-[11px] text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 flex items-baseline justify-center gap-1.5">
|
||||
<SimKaTeX tex={symbol} className="text-sm" />
|
||||
<span
|
||||
className={cn('text-lg font-semibold tabular-nums', !finite && 'text-muted-foreground')}
|
||||
style={finite ? { color: accent } : undefined}
|
||||
>
|
||||
{finite ? value.toFixed(digits) : '—'}
|
||||
{finite && unit ? ` ${unit}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Small section heading inside a simulator card. */
|
||||
export function SimHeading({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
'mb-2 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
114
memento-note/components/simulators/ts-diagram-view.tsx
Normal file
114
memento-note/components/simulators/ts-diagram-view.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
'use client'
|
||||
|
||||
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
|
||||
|
||||
/**
|
||||
* T–s diagram of the Carnot cycle — canonical 2nd-law diagram.
|
||||
* Driven by AnimPlayerShell via `step` (0..4). SVG, transform/opacity only.
|
||||
* Cycle = rectangle: horizontal isotherms (Th, Tc), vertical adiabatics.
|
||||
*/
|
||||
|
||||
const X0 = 110 // y-axis x
|
||||
const Y0 = 280 // x-axis y
|
||||
const S1 = 190 // entropy left
|
||||
const S2 = 520 // entropy right
|
||||
const YH = 80 // T_h line y
|
||||
const YC = 220 // T_c line y
|
||||
|
||||
const EASE = 'transform 700ms cubic-bezier(0.22, 1, 0.36, 1), opacity 500ms ease'
|
||||
|
||||
// marker position per beat (end of each phase)
|
||||
const MARKER = [
|
||||
{ x: S2, y: YH }, // b1 end: right-top
|
||||
{ x: S2, y: YC }, // b2 end: right-bottom
|
||||
{ x: S1, y: YC }, // b3 end: left-bottom
|
||||
{ x: S1, y: YH }, // b4 end: left-top
|
||||
{ x: S1, y: YH }, // b5: stay
|
||||
]
|
||||
|
||||
export function TsDiagramView({ step, lang }: { step: number; lang: string }) {
|
||||
const fr = lang.startsWith('fr')
|
||||
const dark = useDarkMode()
|
||||
const s = Math.min(step, 4)
|
||||
|
||||
const ink = dark ? '#EDE7DB' : '#242422'
|
||||
const muted = dark ? '#A39C8D' : '#686762'
|
||||
const plum = dark ? '#D07AA6' : '#9F3F70'
|
||||
const blue = dark ? '#7FA8CC' : '#3F6F9F'
|
||||
const paper = dark ? '#17140F' : '#F4F0E8'
|
||||
|
||||
const m = MARKER[s]
|
||||
const qhW = S2 - S1
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 680 320"
|
||||
className="w-full"
|
||||
role="img"
|
||||
aria-label={fr ? 'Diagramme T–s du cycle de Carnot' : 'T–s diagram of the Carnot cycle'}
|
||||
style={{ background: paper, display: 'block' }}
|
||||
>
|
||||
{/* axes */}
|
||||
<line x1={X0} y1={Y0} x2={650} y2={Y0} stroke={ink} strokeWidth={1.5} />
|
||||
<line x1={X0} y1={Y0} x2={X0} y2={30} stroke={ink} strokeWidth={1.5} />
|
||||
<text x={648} y={Y0 + 18} fontSize={12} fill={muted} textAnchor="end" fontStyle="italic">s</text>
|
||||
<text x={X0 - 8} y={40} fontSize={12} fill={muted} textAnchor="end" fontStyle="italic">T</text>
|
||||
|
||||
{/* isotherm T_h */}
|
||||
<line x1={X0} y1={YH} x2={650} y2={YH} stroke={plum} strokeWidth={1} strokeDasharray="5 4" opacity={0.45} />
|
||||
<text x={X0 - 8} y={YH + 4} fontSize={11} fill={plum} textAnchor="end" fontWeight={600}>T_h</text>
|
||||
{/* isotherm T_c */}
|
||||
<line x1={X0} y1={YC} x2={650} y2={YC} stroke={blue} strokeWidth={1} strokeDasharray="5 4" opacity={0.45} />
|
||||
<text x={X0 - 8} y={YC + 4} fontSize={11} fill={blue} textAnchor="end" fontWeight={600}>T_c</text>
|
||||
|
||||
{/* Q_h area (beat ≥ 0, shown from beat 0) */}
|
||||
<rect
|
||||
x={S1} y={YH} width={qhW} height={Y0 - YH}
|
||||
fill={plum} opacity={s >= 0 ? 0.10 : 0}
|
||||
style={{ transition: 'opacity 600ms ease' }}
|
||||
/>
|
||||
{s === 0 ? (
|
||||
<text x={(S1 + S2) / 2} y={(YH + Y0) / 2} textAnchor="middle" fontSize={15} fontWeight={800} fill={plum}>Q_h</text>
|
||||
) : null}
|
||||
{/* Q_c area (from beat 2) */}
|
||||
<rect
|
||||
x={S1} y={YC} width={qhW} height={Y0 - YC}
|
||||
fill={blue} opacity={s >= 2 ? 0.16 : 0}
|
||||
style={{ transition: 'opacity 600ms ease' }}
|
||||
/>
|
||||
{s >= 2 && s < 4 ? (
|
||||
<text x={(S1 + S2) / 2} y={(YC + Y0) / 2} textAnchor="middle" fontSize={15} fontWeight={800} fill={blue}>Q_c</text>
|
||||
) : null}
|
||||
{/* W = cycle area (beat 4) */}
|
||||
<rect
|
||||
x={S1} y={YH} width={qhW} height={YC - YH}
|
||||
fill={plum} opacity={s === 4 ? 0.18 : 0}
|
||||
style={{ transition: 'opacity 600ms ease' }}
|
||||
/>
|
||||
{s === 4 ? (
|
||||
<text x={(S1 + S2) / 2} y={(YH + YC) / 2} textAnchor="middle" fontSize={17} fontWeight={800} fill={plum}>W</text>
|
||||
) : null}
|
||||
|
||||
{/* cycle edges, drawn progressively */}
|
||||
{/* top: b1 (isothermal expansion) */}
|
||||
<line x1={S1} y1={YH} x2={S2} y2={YH} stroke={ink} strokeWidth={2.5} opacity={s >= 0 ? 1 : 0.15} />
|
||||
<polygon points={`${S2 - 14},${YH - 5} ${S2 - 4},${YH} ${S2 - 14},${YH + 5}`} fill={ink} opacity={s >= 0 ? 1 : 0.15} />
|
||||
{/* right: b2 (adiabatic expansion) */}
|
||||
<line x1={S2} y1={YH} x2={S2} y2={YC} stroke={ink} strokeWidth={2.5} opacity={s >= 1 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
|
||||
<polygon points={`${S2 - 5},${YC - 14} ${S2},${YC - 4} ${S2 + 5},${YC - 14}`} fill={ink} opacity={s >= 1 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
|
||||
{/* bottom: b3 (isothermal compression) */}
|
||||
<line x1={S2} y1={YC} x2={S1} y2={YC} stroke={ink} strokeWidth={2.5} opacity={s >= 2 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
|
||||
<polygon points={`${S1 + 14},${YC - 5} ${S1 + 4},${YC} ${S1 + 14},${YC + 5}`} fill={ink} opacity={s >= 2 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
|
||||
{/* left: b4 (adiabatic compression) */}
|
||||
<line x1={S1} y1={YC} x2={S1} y2={YH} stroke={ink} strokeWidth={2.5} opacity={s >= 3 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
|
||||
<polygon points={`${S1 - 5},${YH + 14} ${S1},${YH + 4} ${S1 + 5},${YH + 14}`} fill={ink} opacity={s >= 3 ? 1 : 0.15} style={{ transition: 'opacity 500ms ease' }} />
|
||||
|
||||
{/* entropy ticks */}
|
||||
<text x={S1} y={Y0 + 16} fontSize={10} fill={muted} textAnchor="middle">s₁</text>
|
||||
<text x={S2} y={Y0 + 16} fontSize={10} fill={muted} textAnchor="middle">s₂</text>
|
||||
|
||||
{/* state marker */}
|
||||
<circle cx={m.x} cy={m.y} r={6} fill={plum} stroke={paper} strokeWidth={2} style={{ transition: EASE }} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
131
memento-note/components/tiptap-interactive-demo-extension.tsx
Normal file
131
memento-note/components/tiptap-interactive-demo-extension.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import {
|
||||
ReactNodeViewRenderer,
|
||||
NodeViewWrapper,
|
||||
type NodeViewProps,
|
||||
} from '@tiptap/react'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import { InteractiveDemoPlayer } from '@/components/interactive-demo/interactive-demo-player'
|
||||
import {
|
||||
validateInteractiveDemo,
|
||||
type InteractiveDemoV1,
|
||||
} from '@/lib/interactive-demo'
|
||||
import attnresFixture from '@/lib/interactive-demo/fixtures/attnres.demo.json'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
function InteractiveDemoView(props: NodeViewProps) {
|
||||
const { t } = useLanguage()
|
||||
const raw = props.node.attrs.demoJson as string
|
||||
|
||||
const parsed = useMemo(() => {
|
||||
try {
|
||||
const data = JSON.parse(raw || '{}')
|
||||
return validateInteractiveDemo(data)
|
||||
} catch {
|
||||
return {
|
||||
ok: false as const,
|
||||
issues: [{ code: 'invalid_json', path: '', message: 'Invalid JSON' }],
|
||||
}
|
||||
}
|
||||
}, [raw])
|
||||
|
||||
if (!parsed.ok) {
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className="interactive-demo-block my-4"
|
||||
data-drag-handle
|
||||
contentEditable={false}
|
||||
>
|
||||
<div className="rounded-xl border border-dashed border-destructive/40 bg-destructive/5 p-4 flex gap-3 text-sm">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t('interactiveDemo.invalid') || 'Interactive demo invalide'}
|
||||
</p>
|
||||
<ul className="mt-1 text-muted-foreground list-disc list-inside">
|
||||
{parsed.issues.slice(0, 5).map((iss, i) => (
|
||||
<li key={i}>
|
||||
{iss.path ? `${iss.path}: ` : ''}
|
||||
{iss.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className="interactive-demo-block my-4"
|
||||
data-drag-handle
|
||||
contentEditable={false}
|
||||
>
|
||||
<InteractiveDemoPlayer demo={parsed.demo} mode="interactive" />
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const InteractiveDemoExtension = Node.create({
|
||||
name: 'interactiveDemo',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
demoJson: {
|
||||
default: '{}',
|
||||
parseHTML: (el) => el.getAttribute('data-demo-json') || '{}',
|
||||
renderHTML: (attrs) => ({
|
||||
'data-demo-json': attrs.demoJson || '{}',
|
||||
}),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'div[data-interactive-demo]' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes(HTMLAttributes, { 'data-interactive-demo': 'true' }),
|
||||
]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(InteractiveDemoView)
|
||||
},
|
||||
})
|
||||
|
||||
export function insertInteractiveDemoAtSelection(
|
||||
editor: Editor,
|
||||
demo?: InteractiveDemoV1
|
||||
): boolean {
|
||||
const type = editor.schema.nodes.interactiveDemo
|
||||
if (!type) return false
|
||||
|
||||
const payload = demo ?? (attnresFixture as InteractiveDemoV1)
|
||||
const check = validateInteractiveDemo(payload)
|
||||
// Prefer validated shape; if already-server-validated payload fails client Zod
|
||||
// (HMR drift), still insert so the block appears — NodeView shows issues if needed.
|
||||
const attrs = {
|
||||
demoJson: JSON.stringify(check.ok ? check.demo : payload),
|
||||
}
|
||||
const { empty, $from } = editor.state.selection
|
||||
const pos = empty ? $from.pos : editor.state.selection.from
|
||||
|
||||
return editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(pos, { type: 'interactiveDemo', attrs })
|
||||
.run()
|
||||
}
|
||||
@@ -40,6 +40,8 @@ const FEATURE_LABEL_KEYS: Record<string, string> = {
|
||||
brainstorm_expand: 'usageMeter.featureBrainstormExpand',
|
||||
brainstorm_enrich: 'usageMeter.featureBrainstormEnrich',
|
||||
suggest_charts: 'usageMeter.featureCharts',
|
||||
interactive_demo: 'usageMeter.featureInteractiveDemo',
|
||||
interactive_page: 'usageMeter.featureInteractivePage',
|
||||
publish_enhance: 'usageMeter.featurePublishEnhance',
|
||||
ai_flashcard: 'usageMeter.featureFlashcards',
|
||||
voice_transcribe: 'usageMeter.featureVoice',
|
||||
|
||||
Reference in New Issue
Block a user