feat: page interactive, démos Play/Step et simulateur Carnot

Ajoute le pipeline PageSpec (validation, rendu, publication /p/{slug}),
les démos TipTap /demo, et le simulateur Carnot (modes frigo/PAC/moteur,
énergie kJ vs puissance W, unités K/°C/°F) avec correctifs d’équations KaTeX.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-07-24 17:51:43 +00:00
parent f385d43d5d
commit 69c99e4f4f
67 changed files with 12005 additions and 33 deletions

View File

@@ -0,0 +1,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>
)
}

View 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', 'η']
// PV 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>
{/* ══ PV diagram ══ */}
<g>
<text x={340} y={30} fontSize={12} fontWeight={700} fill={muted} style={{ fontFamily: 'var(--pp-mono, monospace)' }}>
PV
</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>
)
}

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

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

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

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

View File

@@ -0,0 +1,114 @@
'use client'
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
/**
* Ts 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 Ts du cycle de Carnot' : 'Ts 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>
)
}