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:
227
memento-note/lib/interactive-demo/resolve.ts
Normal file
227
memento-note/lib/interactive-demo/resolve.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user