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>
228 lines
5.7 KiB
TypeScript
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
|
|
}
|