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,59 @@
# Simulateurs interactifs (plugins)
Bibliothèque de simulateurs pédagogiques pour les **pages interactives** (`/p/{slug}`).
L'IA ne code jamais une simulation : elle **choisit** un simulateur de cette liste et le
**configure** (`preset` = valeurs de la note), ou utilise `generic-formula` si aucun ne
correspond au sujet.
## Liste des simulateurs disponibles
| `simId` | Famille | Sujet | Interaction |
|---|---|---|---|
| `carnot-cycle` | `sim` | Machine frigorifique / PAC / moteur de Carnot (2ᵉ principe) | Curseurs T_c, T_h, Q_c → COP, η, W min, Q_h en direct |
| `carnot-cycle-anim` | `anim` | Cycle de Carnot animé (piston + diagramme PV live, 5 étapes) | Play / Pause / Step / Reset / vitesse + narration |
| `generic-formula` | `sim` | (générique) toute relation chiffrée y = f(paramètres) | Curseurs définis par l'IA, expressions sûres (aucun `eval`) |
Deux familles de plugins : `sim` (manipulation de paramètres, calcul en direct) et
`anim` (scène animée codée en dur, pilotée par le player Play/Step — narration par
battements). Les deux sont choisis par l'IA via le même bloc `{ "type": "sim" }`.
## Comment l'IA les utilise
1. Le prompt de génération de section injecte `catalogForPrompt(lang)` (registry.ts) —
id + résumé + mots-clés + bornes de chaque simulateur.
2. Si le contenu de la note correspond (`keywords`), l'IA émet
`{ "type": "sim", "sim": { "simId": "carnot-cycle", "preset": { "t_hot": 300 } } }`.
3. `validateInteractivePage` vérifie : `simId` connu, preset dans les bornes,
`compute(preset)` fini — sinon rejet (renvoyé au LLM ou fallback).
## Ajouter un plugin (5 étapes)
Pour un **`sim`** (curseurs) :
1. **`lib/simulators/<id>.ts`** — implémenter `SimulatorPlugin` (`family: 'sim'`) :
`id`, `title`/`summary` fr+en (le summary sert au matching IA), `keywords`,
`params` (curseurs : bornes, pas, défaut, unité, intent), `outputs` (symbole KaTeX,
unité), et `compute(env)` **pur et déterministe**.
2. **`lib/simulators/index.ts`** — ajouter au `REGISTRY`.
3. **`components/simulators/<id>-view.tsx`** — composant sur mesure
(props : `preset`, `title`, `disclaimer`, `lang`). Réutiliser
`SimSlider` / `SimOutputCard` / `SimHeading` / `SimKaTeX` de `sim-controls.tsx`.
4. **`components/simulators/index.ts`** — enregistrer dans `SIMULATOR_VIEWS`.
5. Vérifier : `compute` fini aux valeurs par défaut, `npx tsc --noEmit`, tester sur
`/dev/interactive-page`.
Pour une **`anim`** (scène animée Play/Step) :
1. **`lib/simulators/<id>.ts`** — implémenter `AnimPlugin` (`family: 'anim'`) :
`id`, `title`/`summary` fr+en, `keywords`, `disclaimer?`, `beats` (narration
fr+en par étape — KaTeX inline `$…$` dans `speak`).
2. **`lib/simulators/index.ts`** — ajouter au `REGISTRY`.
3. **`components/simulators/<id>-anim-view.tsx`** — scène SVG/React pilotée par
`step` (props : `step`, `lang`). Transitions CSS sur `transform`/`opacity`
uniquement. Le chrome (Play/Pause/Step/Reset/vitesse + panneau de narration +
clavier Espace/←/→/R) est fourni par `AnimPlayerShell` — ne pas le réécrire.
4. **`components/simulators/index.ts`** — enregistrer dans `ANIM_VIEWS`.
5. Vérifier : chaque `step` rend un état cohérent (y compris état final sans JS),
`npx tsc --noEmit`, tester sur `/dev/interactive-page`.
Règles : pas de couleurs en dur (intents / tokens `--pp-*`), labels fr+en dans le
plugin, tout calcul côté `compute` ou scène codée (jamais de logique LLM), SSR =
état lisible sans JS.

View File

@@ -0,0 +1,76 @@
import type { AnimPlugin } from './types'
/**
* Carnot cycle — animated piston + live P-V diagram, 5 beats.
* Scene rendered by components/simulators/carnot-cycle-anim-view.tsx.
*/
export const carnotCycleAnim: AnimPlugin = {
family: 'anim',
id: 'carnot-cycle-anim',
title: {
fr: 'Le cycle de Carnot en mouvement',
en: 'The Carnot cycle in motion',
},
summary: {
fr: 'Animation du cycle de Carnot : piston, détentes/compressions isothermes et adiabatiques, diagramme P-V tracé en direct, échanges Q et W. 2ᵉ principe, machine thermique, réfrigérateur.',
en: 'Animated Carnot cycle: piston, isothermal and adiabatic expansion/compression, live P-V diagram, Q and W exchanges. Second law, heat engine, refrigerator.',
},
keywords: [
'carnot',
'thermodynamique',
'thermodynamics',
'cycle',
'piston',
'isotherme',
'adiabatique',
'isothermal',
'adiabatic',
'entropie',
'entropy',
'machine thermique',
'heat engine',
'deuxième principe',
'second law',
],
disclaimer: {
fr: 'Schéma pédagogique — grandeurs illustratives, gaz parfait.',
en: 'Teaching schematic — illustrative values, ideal gas.',
},
beats: [
{
id: 'b1',
speak: {
fr: '**Détente isotherme** à $T_h$ : le gaz pousse le piston, la chaleur $Q_h$ entre depuis la source chaude.',
en: '**Isothermal expansion** at $T_h$: the gas pushes the piston, heat $Q_h$ flows in from the hot reservoir.',
},
},
{
id: 'b2',
speak: {
fr: '**Détente adiabatique** : isolé, le gaz continue de se détendre et se refroidit de $T_h$ à $T_c$.',
en: '**Adiabatic expansion**: insulated, the gas keeps expanding and cools from $T_h$ down to $T_c$.',
},
},
{
id: 'b3',
speak: {
fr: '**Compression isotherme** à $T_c$ : on travaille sur le gaz, la chaleur $Q_c$ sort vers la source froide.',
en: '**Isothermal compression** at $T_c$: work is done on the gas, heat $Q_c$ flows out to the cold reservoir.',
},
},
{
id: 'b4',
speak: {
fr: '**Compression adiabatique** : le gaz remonte de $T_c$ à $T_h$ — le cycle se referme.',
en: '**Adiabatic compression**: the gas warms back from $T_c$ to $T_h$ — the cycle closes.',
},
},
{
id: 'b5',
speak: {
fr: "Le **travail net** $W = Q_h - Q_c$ est l'aire du cycle sur le diagramme $P$$V$. Le rendement $\eta = 1 - T_c/T_h$ est le maximum permis par le 2ᵉ principe.",
en: '**Net work** $W = Q_h - Q_c$ is the cycle area on the $P$$V$ diagram. Efficiency $\eta = 1 - T_c/T_h$ is the second-law maximum.',
},
},
],
}

View File

@@ -0,0 +1,254 @@
import type { SimulatorPlugin } from './types'
/**
* Ideal Carnot machine between two reservoirs (2nd law).
*
* Physics (absolute temperatures only):
* - Refrigerator: COP_R = Tc/(ThTc), W_min = Qc/COP_R, Qh = Qc+W
* - Heat pump: COP_HP = Th/(ThTc) = COP_R+1, W_min = Qh/COP_HP
* - Engine: η = 1Tc/Th, W_out = η·Qh, Qc = QhW
*
* `q_cold` is the primary load magnitude (kJ or W — same number; unit is UI-only).
* For fridge it is Qc; the view remaps for PAC / engine. Temperatures always Kelvin.
*
* Refs: COP_R Carnot = Tc/(ThTc); 1st law Qh=Qc+W; energy↔power interchangeable
* if all rates use the same time basis (see standard thermo textbooks / Carnot fridge calculators).
*/
export const carnotCycleSimulator: SimulatorPlugin = {
family: 'sim',
id: 'carnot-cycle',
title: {
fr: 'Machine de Carnot (frigo / PAC / moteur)',
en: 'Carnot machine (fridge / heat pump / engine)',
},
summary: {
fr: 'Limites de Carnot entre deux sources : COP frigo Tc/(ThTc), COP pompe à chaleur Th/(ThTc), rendement moteur η=1Tc/Th, travail ou puissance minimal(e). 1er et 2e principes.',
en: 'Carnot limits between two reservoirs: fridge COP Tc/(ThTc), heat-pump COP Th/(ThTc), engine η=1Tc/Th, minimum work or power. 1st and 2nd laws.',
},
keywords: [
'carnot',
'thermodynamique',
'thermodynamics',
'cop',
'réfrigérateur',
'frigo',
'refrigerator',
'pompe à chaleur',
'heat pump',
'moteur',
'engine',
'rendement',
'efficiency',
'watt',
'puissance',
'power',
'deuxième principe',
'second law',
],
params: [
{
id: 't_cold',
symbol: 'T_c',
label: { fr: 'Source froide', en: 'Cold reservoir' },
min: 200,
max: 320,
step: 1,
defaultValue: 260,
unit: 'K',
intent: 'cache',
},
{
id: 't_hot',
symbol: 'T_h',
label: { fr: 'Source chaude', en: 'Hot reservoir' },
min: 273,
max: 400,
step: 1,
defaultValue: 300,
unit: 'K',
intent: 'warning',
},
{
id: 'q_cold',
symbol: 'Q_c',
label: { fr: 'Charge (Qc frigo)', en: 'Load (fridge Qc)' },
min: 10,
max: 500,
step: 5,
defaultValue: 100,
unit: 'kJ',
intent: 'flow',
},
],
outputs: [
{
id: 'cop_fridge',
symbol: '\\mathrm{COP}_{R}',
label: { fr: 'COP réfrigérateur', en: 'Fridge COP' },
intent: 'output',
digits: 2,
},
{
id: 'cop_hp',
symbol: '\\mathrm{COP}_{HP}',
label: { fr: 'COP pompe à chaleur', en: 'Heat-pump COP' },
intent: 'output',
digits: 2,
},
{
id: 'eta',
symbol: '\\eta',
label: { fr: 'Rendement moteur', en: 'Engine efficiency' },
unit: '%',
intent: 'highlight',
digits: 1,
},
{
id: 'w_min',
symbol: 'W',
label: { fr: 'Travail (énergie)', en: 'Work (energy)' },
unit: 'kJ',
intent: 'compute',
digits: 1,
},
{
id: 'q_hot',
symbol: 'Q_h',
label: { fr: 'Chaleur côté chaud', en: 'Hot-side heat' },
unit: 'kJ',
intent: 'flow',
digits: 1,
},
],
compute(env) {
const tc = env.t_cold
const th = env.t_hot
const qc = env.q_cold
if (!(th > tc) || !(qc > 0)) {
return {
cop_fridge: NaN,
cop_hp: NaN,
eta: NaN,
w_min: NaN,
q_hot: NaN,
}
}
const copFridge = tc / (th - tc)
const copHp = th / (th - tc)
const eta = 1 - tc / th
const wMin = qc / copFridge
return {
cop_fridge: copFridge,
cop_hp: copHp,
eta: eta * 100,
w_min: wMin,
q_hot: qc + wMin,
}
},
}
/** Operating mode for the interactive view (UI-only; physics shared). */
export type CarnotMode = 'fridge' | 'heat_pump' | 'engine'
/** Energy (kJ) vs power (W) — same ratios; only unit labels change. */
export type CarnotQuantity = 'energy' | 'power'
export type CarnotPhysics = {
ok: boolean
tc: number
th: number
copR: number
copHP: number
eta: number
/** Heat exchanged with cold reservoir (magnitude > 0). */
qc: number
/** Heat exchanged with hot reservoir (magnitude > 0). */
qh: number
/** Work magnitude > 0 (input for fridge/PAC, output for engine). */
w: number
/** Reversible check: Qc/Tc ≈ Qh/Th */
entropyOk: boolean
}
/**
* Resolve magnitudes for the selected mode.
* `load` is the primary useful quantity:
* - fridge: Qc extracted from cold
* - heat_pump: Qh delivered to hot
* - engine: Qh absorbed from hot
*/
export function resolveCarnotPhysics(
tc: number,
th: number,
load: number,
mode: CarnotMode
): CarnotPhysics {
if (!(th > tc) || !(load > 0) || !Number.isFinite(tc) || !Number.isFinite(th)) {
return {
ok: false,
tc,
th,
copR: NaN,
copHP: NaN,
eta: NaN,
qc: NaN,
qh: NaN,
w: NaN,
entropyOk: false,
}
}
const copR = tc / (th - tc)
const copHP = th / (th - tc)
const eta = 1 - tc / th
let qc: number
let qh: number
let w: number
if (mode === 'fridge') {
qc = load
w = qc / copR
qh = qc + w
} else if (mode === 'heat_pump') {
qh = load
w = qh / copHP
qc = qh - w
} else {
qh = load
w = eta * qh
qc = qh - w
}
const ratioC = qc / tc
const ratioH = qh / th
const entropyOk =
Number.isFinite(ratioC) &&
Number.isFinite(ratioH) &&
Math.abs(ratioC - ratioH) / Math.max(ratioC, ratioH, 1e-9) < 1e-6
return { ok: true, tc, th, copR, copHP, eta, qc, qh, w, entropyOk }
}
/** Convert fridge-stored Qc load ↔ display load for other modes (fixture-compatible). */
export function fridgeLoadFromModeLoad(
tc: number,
th: number,
modeLoad: number,
mode: CarnotMode
): number {
if (!(th > tc) || !(modeLoad > 0)) return modeLoad
if (mode === 'fridge') return modeLoad
if (mode === 'heat_pump') return modeLoad * (tc / th) // Qc = Qh · Tc/Th
return modeLoad * (tc / th) // engine: Qc = Qh · (1η) = Qh · Tc/Th
}
export function modeLoadFromFridgeLoad(
tc: number,
th: number,
fridgeQc: number,
mode: CarnotMode
): number {
if (!(th > tc) || !(fridgeQc > 0)) return fridgeQc
if (mode === 'fridge') return fridgeQc
if (mode === 'heat_pump') return fridgeQc * (th / tc) // Qh = Qc · Th/Tc
return fridgeQc * (th / tc) // engine Qh = Qc / (1η) = Qc · Th/Tc
}

View File

@@ -0,0 +1,65 @@
import type { IntentId } from '@/lib/interactive-demo/types'
import { carnotCycleSimulator } from './carnot-cycle'
import { carnotCycleAnim } from './carnot-cycle-anim'
import { tsDiagramAnim } from './ts-diagram'
import type { AnimPlugin, AnyPlugin, SimulatorPlugin } from './types'
export const GENERIC_SIM_ID = 'generic-formula' as const
const REGISTRY: Record<string, AnyPlugin> = {
[carnotCycleSimulator.id]: carnotCycleSimulator,
[carnotCycleAnim.id]: carnotCycleAnim,
[tsDiagramAnim.id]: tsDiagramAnim,
}
export function getPlugin(id: string): AnyPlugin | null {
return REGISTRY[id] ?? null
}
export function getSimulator(id: string): SimulatorPlugin | null {
const p = REGISTRY[id]
return p?.family === 'sim' ? p : null
}
export function getAnimPlugin(id: string): AnimPlugin | null {
const p = REGISTRY[id]
return p?.family === 'anim' ? p : null
}
export function isCatalogSimId(id: string): boolean {
return id !== GENERIC_SIM_ID && id in REGISTRY
}
export function listSimulators(): AnyPlugin[] {
return Object.values(REGISTRY)
}
/**
* Compact catalog injected into the section-generation prompt so the LLM
* can pick a curated plugin (sim or anim) and bind note values into a preset.
*/
export function catalogForPrompt(lang: string): string {
const fr = lang.startsWith('fr')
const catalog = listSimulators().map((plugin) => ({
simId: plugin.id,
family: plugin.family,
summary: fr ? plugin.summary.fr : plugin.summary.en,
keywords: plugin.keywords.slice(0, 10),
...(plugin.family === 'sim'
? {
params: plugin.params.map((p) => ({
id: p.id,
symbol: p.symbol,
range: [p.min, p.max],
default: p.defaultValue,
unit: p.unit,
})),
outputs: plugin.outputs.map((o) => o.symbol),
}
: {}),
}))
return JSON.stringify(catalog, null, 1)
}
export type { SimulatorPlugin, AnimPlugin, AnyPlugin, AnimBeat, SimI18n, SimParamDef, SimOutputDef } from './types'
export type { IntentId }

View File

@@ -0,0 +1,74 @@
import type { AnimPlugin } from './types'
/**
* Ts diagram of the Carnot cycle — the canonical 2nd-law diagram:
* isotherms are horizontal, adiabatics vertical, heat = area.
* Same 5 phases as carnot-cycle-anim (piston) so both tell one story.
*/
export const tsDiagramAnim: AnimPlugin = {
family: 'anim',
id: 'ts-diagram',
title: {
fr: 'Le cycle de Carnot sur le diagramme Ts',
en: 'The Carnot cycle on the Ts diagram',
},
summary: {
fr: 'Diagramme températureentropie (Ts) du cycle de Carnot : isothermes horizontales, adiabatiques verticales, aires = chaleurs Q_h et Q_c, aire du cycle = travail W. 2ᵉ principe, entropie, rendement.',
en: 'Temperatureentropy (Ts) diagram of the Carnot cycle: horizontal isotherms, vertical adiabatics, areas = heats Q_h and Q_c, cycle area = work W. Second law, entropy, efficiency.',
},
keywords: [
'carnot',
'entropie',
'entropy',
'diagramme t-s',
't-s diagram',
'thermodynamique',
'thermodynamics',
'deuxième principe',
'second law',
'rendement',
'efficiency',
'cycle',
],
disclaimer: {
fr: 'Diagramme pédagogique — grandeurs illustratives.',
en: 'Teaching diagram — illustrative values.',
},
beats: [
{
id: 'b1',
speak: {
fr: '**Détente isotherme** à $T_h$ : lentropie croît, la chaleur $Q_h = T_h \\Delta s$ est laire sous lisotherme.',
en: '**Isothermal expansion** at $T_h$: entropy grows, heat $Q_h = T_h \\Delta s$ is the area under the isotherm.',
},
},
{
id: 'b2',
speak: {
fr: '**Détente adiabatique** : verticale — lentropie est constante, la température chute de $T_h$ à $T_c$.',
en: '**Adiabatic expansion**: vertical line — entropy is constant, temperature drops from $T_h$ to $T_c$.',
},
},
{
id: 'b3',
speak: {
fr: '**Compression isotherme** à $T_c$ : lentropie décroît, la chaleur $Q_c = T_c \\Delta s$ est rejetée — laire bleue.',
en: '**Isothermal compression** at $T_c$: entropy decreases, heat $Q_c = T_c \\Delta s$ is rejected — the blue area.',
},
},
{
id: 'b4',
speak: {
fr: '**Compression adiabatique** : remontée verticale de $T_c$ à $T_h$ — le rectangle se referme.',
en: '**Adiabatic compression**: vertical climb from $T_c$ back to $T_h$ — the rectangle closes.',
},
},
{
id: 'b5',
speak: {
fr: 'Le **travail net** $W = (T_h - T_c)\\Delta s$ est laire du rectangle. Rapport des aires = rendement $\\eta = 1 - T_c/T_h$ — le maximum du 2ᵉ principe.',
en: '**Net work** $W = (T_h - T_c)\\Delta s$ is the rectangle area. Area ratio = efficiency $\\eta = 1 - T_c/T_h$ — the second-law maximum.',
},
},
],
}

View File

@@ -0,0 +1,72 @@
import type { IntentId } from '@/lib/interactive-demo/types'
/** Bilingual label — page content follows the note's language, not the UI locale. */
export type SimI18n = { fr: string; en: string }
export type SimParamDef = {
id: string
/** KaTeX symbol (without $…$), e.g. "T_c" */
symbol: string
label: SimI18n
min: number
max: number
step: number
defaultValue: number
unit?: string
intent?: IntentId
}
export type SimOutputDef = {
id: string
/** KaTeX symbol (without $…$), e.g. "\\mathrm{COP}" */
symbol: string
label: SimI18n
unit?: string
intent?: IntentId
/** Fraction digits for display (default 2). */
digits?: number
}
/**
* A curated interactive simulator plugin (catalog).
* The AI never writes simulation code — it picks a plugin and a preset.
* `compute` must be pure and deterministic; it runs client-side on every
* slider move and once server-side at validation time.
*/
export type SimulatorPlugin = {
family: 'sim'
id: string
title: SimI18n
/** Shown to the LLM for content matching. */
summary: SimI18n
keywords: string[]
params: SimParamDef[]
outputs: SimOutputDef[]
compute(env: Record<string, number>): Record<string, number>
}
// ── Animated pedagogical scenes (Play/Step narration over a coded scene) ────
export type AnimBeat = {
id: string
speak: SimI18n
}
/**
* A curated ANIMATION plugin: a hand-coded parametric scene (like the demos
* on distill.pub / the Kimi AttnRes page) driven beat-by-beat by the shared
* player chrome. The view component receives `stepIndex` and renders the
* scene state — transitions are CSS (transform/opacity) only.
*/
export type AnimPlugin = {
family: 'anim'
id: string
title: SimI18n
summary: SimI18n
keywords: string[]
disclaimer?: SimI18n
/** Narration beats; step N of the player = beat N. */
beats: AnimBeat[]
}
export type AnyPlugin = SimulatorPlugin | AnimPlugin