Files
Momento/memento-note/components/simulators/carnot-cycle-view.tsx
Antigravity 69c99e4f4f 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>
2026-07-24 17:51:43 +00:00

673 lines
20 KiB
TypeScript

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