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