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