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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user