Files
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

228 lines
5.7 KiB
TypeScript

import { collectSceneElementIds } from './validate'
import type {
Annotation,
DemoAct,
DemoScene,
DemoStep,
InteractiveDemoV1,
ScopeId,
} from './types'
const SCOPE_RANK: Record<ScopeId, number> = {
transient: 1,
act: 2,
scene: 3,
}
function longestScope(a: ScopeId, b: ScopeId): ScopeId {
return SCOPE_RANK[a] >= SCOPE_RANK[b] ? a : b
}
export type ResolvedAnnotation = Annotation & { badgeIndex?: number }
export type StepResolvedState = {
actId: string
stepId: string
stepIndex: number
speak: string
/** Elements revealed and their effective scope */
revealed: Record<string, ScopeId>
/** Spotlight targets this step (empty ⇒ overview / full brightness) */
spotlight: string[]
/** Active annotations after applying this step (transient of prior steps purged) */
annotations: ResolvedAnnotation[]
overview: boolean
}
export type ActResolvedState = {
actId: string
title: string
steps: StepResolvedState[]
/** State after the last step — static/SSR/print/export default */
final: StepResolvedState
}
function purgeScope(
revealed: Map<string, ScopeId>,
annotations: ResolvedAnnotation[],
scopes: ScopeId[]
): { revealed: Map<string, ScopeId>; annotations: ResolvedAnnotation[] } {
const drop = new Set(scopes)
const nextRevealed = new Map<string, ScopeId>()
for (const [id, scope] of revealed) {
if (!drop.has(scope)) nextRevealed.set(id, scope)
}
const nextAnn = annotations.filter((a) => !drop.has(a.scope))
return { revealed: nextRevealed, annotations: nextAnn }
}
function mergeReveal(
revealed: Map<string, ScopeId>,
id: string,
scope: ScopeId
): void {
const prev = revealed.get(id)
revealed.set(id, prev ? longestScope(prev, scope) : scope)
}
function applyStep(
step: DemoStep,
elementIds: Set<string>,
revealed: Map<string, ScopeId>,
annotations: ResolvedAnnotation[],
badgeCounter: { n: number }
): {
revealed: Map<string, ScopeId>
annotations: ResolvedAnnotation[]
spotlight: string[]
overview: boolean
} {
// Drop previous step's transient
;({ revealed, annotations } = purgeScope(revealed, annotations, ['transient']))
const spotlightSet = new Set<string>()
const revealId = (id: string, scope: ScopeId) => {
if (id === '*') {
for (const eid of elementIds) {
if (!revealed.has(eid)) mergeReveal(revealed, eid, scope)
}
return
}
mergeReveal(revealed, id, scope)
}
for (const rev of step.reveal ?? []) {
for (const id of rev.ids) {
revealId(id, rev.scope)
if (id !== '*') spotlightSet.add(id)
}
}
// Designation ⇒ reveal (pointTo default act)
for (const id of step.pointTo ?? []) {
revealId(id, 'act')
spotlightSet.add(id)
}
for (const ann of step.annotate ?? []) {
for (const id of ann.targetIds) {
revealId(id, ann.scope)
spotlightSet.add(id)
}
const resolved: ResolvedAnnotation = { ...ann }
if (ann.kind === 'badge') {
badgeCounter.n += 1
resolved.badgeIndex = badgeCounter.n
}
annotations.push(resolved)
}
const overview = spotlightSet.size === 0
return {
revealed,
annotations,
spotlight: [...spotlightSet],
overview,
}
}
function resolveAct(
act: DemoAct,
scene: DemoScene,
sceneChanged: boolean,
carried: {
revealed: Map<string, ScopeId>
annotations: ResolvedAnnotation[]
}
): ActResolvedState {
let { revealed, annotations } = carried
if (sceneChanged) {
// New scene: purge everything from old scene (refs would be dead)
revealed = new Map()
annotations = []
} else {
// Soft reset: purge transient + act, keep scene-scoped
;({ revealed, annotations } = purgeScope(revealed, annotations, [
'transient',
'act',
]))
}
const { ids: elementIds } = collectSceneElementIds(scene)
const badgeCounter = { n: 0 }
const steps: StepResolvedState[] = []
for (const [stepIndex, step] of act.steps.entries()) {
const applied = applyStep(
step,
elementIds,
revealed,
annotations,
badgeCounter
)
revealed = applied.revealed
annotations = applied.annotations
const snapshot: StepResolvedState = {
actId: act.id,
stepId: step.id,
stepIndex,
speak: step.speak,
revealed: Object.fromEntries(revealed),
spotlight: applied.spotlight,
annotations: annotations.map((a) => ({ ...a })),
overview: applied.overview,
}
steps.push(snapshot)
}
const last = steps[steps.length - 1]!
return {
actId: act.id,
title: act.title,
steps,
final: last,
}
}
/**
* Pure resolver: auto-reveal, wildcard, longest-scope-wins, spotlight.
* Shared by player / SSR / noscript / print / export — no React.
*/
export function resolveInteractiveDemo(demo: InteractiveDemoV1): {
acts: ActResolvedState[]
} {
let scene = demo.scene
let revealed = new Map<string, ScopeId>()
let annotations: ResolvedAnnotation[] = []
const acts: ActResolvedState[] = []
for (const act of demo.acts) {
const sceneChanged = Boolean(act.scene)
if (act.scene) scene = act.scene
const resolved = resolveAct(act, scene, sceneChanged, {
revealed,
annotations,
})
acts.push(resolved)
// Carry state into next act (may be purged on soft reset / scene change)
const last = resolved.final
revealed = new Map(Object.entries(last.revealed) as [string, ScopeId][])
annotations = last.annotations.map((a) => ({ ...a }))
}
return { acts }
}
/** Convenience: final static state for a given act (P10 default). */
export function resolveActFinalState(
demo: InteractiveDemoV1,
actId: string
): StepResolvedState | undefined {
return resolveInteractiveDemo(demo).acts.find((a) => a.actId === actId)?.final
}