import { markRaw, reactive, shallowReactive, shallowRef } from "vue"; import type { OriginAction, OriginHistoryMode, OriginView, } from "../navigation"; import { clamp01, type OriginRect } from "../shared"; import type { MutableOriginOperation, OriginContext, OriginFinishOptions, OriginSceneNodeState, } from "./types"; /** Mutable storage for one mounted scene node. */ export interface SceneNodeRecord { key: string; sequence: number; view: OriginView; /** Retained-history link: the entry a committed back returns to. */ previousNodeKey?: string; state: OriginSceneNodeState; /** Visual link: the operation edge positioning this node as its target. */ incomingOperationId?: number; } /** * Why a scene operation was rejected. * * - `missing-node`: the node no longer exists. * - `missing-origin`: the origin node does not exist. * - `inert-origin`: the origin is parked or exposed and cannot originate. * - `origin-busy`: the origin already has a live outgoing operation. * - `invalid-action`: the action cannot be executed as written. * - `no-previous-entry`: back was requested without a retained entry. * - `undecided-forward`: back was requested through an unreleased forward. * - `target-busy`: the retained back target is transitioning, or still * positions the origin. * - `outside-scene`: `useOrigin()` was called outside an `` view. */ export type OriginSceneErrorCode = | "missing-node" | "missing-origin" | "inert-origin" | "origin-busy" | "invalid-action" | "no-previous-entry" | "undecided-forward" | "target-busy" | "outside-scene"; /** Error thrown or rejected by scene operations, with a stable `code`. */ export class OriginSceneError extends Error { override readonly name = "OriginSceneError"; constructor( readonly code: OriginSceneErrorCode, message: string, ) { super(message); } } /** * Progress at which a release commits when the choreography does not supply * its own `commitThreshold`. */ export const DEFAULT_COMMIT_THRESHOLD = 0.36; /** * Release velocity (progress units per second) that commits a flick at any * progress when the choreography does not supply its own `commitVelocity`. */ export const DEFAULT_COMMIT_VELOCITY = 0.3; /** Decide a release outcome from explicit options or progress/velocity. */ export const shouldCommit = ( operation: MutableOriginOperation, options: OriginFinishOptions, ): boolean => { if (options.commit !== undefined) return options.commit; const { commitThreshold, commitVelocity } = operation.choreography; return ( operation.progress >= (commitThreshold ?? DEFAULT_COMMIT_THRESHOLD) || operation.velocity >= (commitVelocity ?? DEFAULT_COMMIT_VELOCITY) ); }; /** * The scene model: nodes, operation edges, and the rules that rewrite them. * * Two independent structures share these records: * * - **History** links each node to its retained previous entry through * `previousNodeKey`. It changes only when operations commit or cancel. * - **Visual graph** links each target to the operation that positions it * through `incomingOperationId`; each operation links back to its origin. * Nodes without an incoming edge are listed in `roots`. * * Nodes are stored flat so Vue never re-parents a component when edges are * added, spliced, or removed. Every method is synchronous; timing (mounting, * measuring, settling) belongs to `Scene`. */ export class SceneGraph { readonly nodes = shallowReactive(new Map()); readonly operations = shallowReactive( new Map(), ); readonly roots = shallowRef([]); /** * Connected resting edges temporarily removed by a back operation, keyed by * that back operation's id. Cancelling the back operation restores the edge. */ private readonly suspendedEdges = new Map(); /** * Operations indexed by origin key, in insertion order. Every addition and * removal goes through {@link attach} and {@link detach} to keep it in sync * with `operations`. */ private readonly outgoing = shallowReactive( new Map(), ); private nodeSequence = 0; private operationSequence = 0; constructor(initial: readonly OriginView[]) { this.roots.value = initial.map((view) => this.createNode(view).key); } // Queries ----------------------------------------------------------------- node(key: string): SceneNodeRecord | undefined { return this.nodes.get(key); } operation(id: number): MutableOriginOperation | undefined { return this.operations.get(id); } /** The edge positioning `node` as its target, including resting edges. */ incomingOf(node: SceneNodeRecord): MutableOriginOperation | undefined { return node.incomingOperationId ? this.operations.get(node.incomingOperationId) : undefined; } /** Incoming edges positioning `node`, from the node outward to its root. */ incomingChain(node: SceneNodeRecord): MutableOriginOperation[] { const chain: MutableOriginOperation[] = []; const visited = new Set(); let current: SceneNodeRecord | undefined = node; while (current && !visited.has(current.key)) { visited.add(current.key); const incoming = this.incomingOf(current); if (!incoming) break; chain.push(incoming); current = this.nodes.get(incoming.originKey); } return chain; } /** The unfinished operation originated by a node, if any. */ liveOutgoingOf(nodeKey: string): MutableOriginOperation | undefined { return this.outgoing .get(nodeKey) ?.find((operation) => operation.phase !== "finished"); } /** * The operation whose source effects apply to a node. Unlike * {@link liveOutgoingOf}, this includes a finished, connected resting edge. */ presentedOutgoingOf(nodeKey: string): MutableOriginOperation | undefined { return this.outgoing.get(nodeKey)?.[0]; } /** Retained entries preceding `node`, oldest first. */ historyOf(node: SceneNodeRecord): SceneNodeRecord[] { const history: SceneNodeRecord[] = []; const visited = new Set([node.key]); let previousKey = node.previousNodeKey; while (previousKey && !visited.has(previousKey)) { visited.add(previousKey); const previous = this.nodes.get(previousKey); if (!previous) break; history.push(previous); previousKey = previous.previousNodeKey; } return history.reverse(); } contextFor(nodeKey: string): OriginContext { const node = this.nodes.get(nodeKey); if (!node) throw new OriginSceneError( "missing-node", `Origin scene node "${nodeKey}" no longer exists.`, ); const history = this.historyOf(node).map((entry) => entry.view); return { nodeKey, view: node.view, canGoBack: Boolean(node.previousNodeKey), previous: history.at(-1), history, }; } /** * Whether a node should currently receive input. * * Parked and exposed entries are inert. A target being cancelled is already * scheduled to disappear, and a source settling toward commit has ceded new * interactions to its target. This is node-local fate, not a global "active * view". */ isInteractive(nodeKey: string): boolean { const node = this.nodes.get(nodeKey); if (!node || node.state === "parked" || node.state === "exposed") return false; if (this.incomingOf(node)?.intent === "cancel") return false; return this.liveOutgoingOf(nodeKey)?.intent !== "commit"; } // Transitions ------------------------------------------------------------- /** * Validate an action and add its operation edge (and, for push/replace, its * target node). The edge starts in the `preparing` phase. * * @throws If the origin cannot originate, or the action cannot be resolved. */ begin( originKey: string, action: OriginAction, originRect: OriginRect | undefined, ): MutableOriginOperation { const origin = this.assertCanOriginate(originKey); const history = action.history ?? "push"; if (action.choreography.persistAtRest && history !== "push") throw new OriginSceneError( "invalid-action", "persistAtRest choreographies require retained-history push navigation.", ); const { target, suspendedEdge } = history === "back" ? this.resolveBackTarget(origin) : { target: this.createForwardTarget(origin, action, history) }; const operation = this.createOperation({ originKey, targetKey: target.key, entryTargetKey: target.key, choreography: action.choreography, placement: action.placement ?? "above", history, originRect, }); target.incomingOperationId = operation.id; target.state = "transitioning"; origin.state = "transitioning"; if (suspendedEdge) this.suspendedEdges.set(operation.id, suspendedEdge); return operation; } /** Apply interactive progress to an unfinished operation. */ update(id: number, progress: number, velocity: number): void { const operation = this.operations.get(id); if (!operation || operation.phase === "finished") return; operation.progress = clamp01(progress); operation.velocity = velocity; if (operation.phase !== "preparing") operation.phase = "interactive"; } /** * Finalize a committed operation. * * A normal push parks its origin and collapses the temporary visual edge, so * the target takes the origin's place in the visual graph. A connected * (`persistAtRest`) push keeps the edge at progress 1 and leaves the origin * exposed but inert. Replace and back remove the origin. * * @returns Whether the operation's nodes still existed to be committed. */ commit(id: number): boolean { const operation = this.operations.get(id); if (!operation) return false; const origin = this.nodes.get(operation.originKey); const target = this.nodes.get(operation.targetKey); if (!origin || !target) return false; if (operation.history === "push" && operation.choreography.persistAtRest) { operation.progress = 1; operation.velocity = 0; operation.intent = "commit"; operation.phase = "finished"; origin.state = "exposed"; this.activate(target); return true; } this.transferPosition(origin, target); operation.phase = "finished"; this.detach(operation); this.suspendedEdges.delete(operation.id); if (operation.history === "push") { origin.incomingOperationId = undefined; origin.state = "parked"; } else { // Back pops the current entry; replace discards it in favor of the new // target. Both preserve every earlier retained instance, and any entry // still retained above the origin now sits on the target instead. for (const node of this.nodes.values()) if (node.previousNodeKey === origin.key && node !== target) node.previousNodeKey = target.key; this.removeNode(origin.key); } this.activate(target); return true; } /** * Finalize a cancelled operation, restoring the origin. * * Everything the edge created or now positions is undone. Entries from the * origin's retained history (a back target, or a replacement of one) return * to history; anything newer is discarded. A cancelled back that suspended * a connected resting edge reconnects it instead. */ cancel(id: number): void { const operation = this.operations.get(id); if (!operation) return; const origin = this.nodes.get(operation.originKey); const entry = this.nodes.get(operation.entryTargetKey); const current = this.nodes.get(operation.targetKey); const suspendedEdge = this.suspendedEdges.get(id); const retained = origin ? this.retainedKeys(origin) : new Set(); operation.phase = "finished"; this.detach(operation); this.suspendedEdges.delete(id); if (suspendedEdge && origin && entry && current === entry) { // Put the retained page back into its former position, then reconnect // the resting page→drawer edge. Both nodes keep their VNodes. this.discardOutgoing(entry.key, new Set(retained)); this.transferPosition(origin, entry); this.attach(suspendedEdge); origin.incomingOperationId = suspendedEdge.id; entry.state = "exposed"; } else { for (const node of new Set([entry, current])) { if (!node || node === origin) continue; if (retained.has(node.key)) this.returnToHistory(node, retained); else this.removeBranch(node.key, new Set(retained)); } } if (origin) this.activate(origin); } // Transition steps -------------------------------------------------------- private assertCanOriginate(originKey: string): SceneNodeRecord { const origin = this.nodes.get(originKey); if (!origin) throw new OriginSceneError( "missing-origin", `Cannot animate from missing origin "${originKey}".`, ); if (origin.state === "parked" || origin.state === "exposed") throw new OriginSceneError( "inert-origin", `Cannot animate from ${origin.state} origin "${originKey}".`, ); if (this.liveOutgoingOf(originKey)) throw new OriginSceneError( "origin-busy", `Origin "${originKey}" already has an outgoing operation. ` + "A descendant target may start its own operation instead.", ); return origin; } /** * Resolve the retained previous entry for a back operation. * * If the origin's incoming edge comes from that previous entry (a forward * push still settling, or a connected resting edge), the edge is collapsed * first. Creating a new edge back to its origin would otherwise form X→Y→X. * Reciprocal choreographies begin at the same visual endpoints, so this * handoff is continuous without remounting either node. */ private resolveBackTarget(origin: SceneNodeRecord): { target: SceneNodeRecord; suspendedEdge?: MutableOriginOperation; } { const previousKey = origin.previousNodeKey; if (!previousKey) throw new OriginSceneError( "no-previous-entry", `Cannot go back from origin "${origin.key}" without a previous entry.`, ); const target = this.nodes.get(previousKey); if (!target) throw new OriginSceneError( "no-previous-entry", `Cannot go back to missing retained entry "${previousKey}".`, ); let suspendedEdge: MutableOriginOperation | undefined; const incoming = this.incomingOf(origin); if (incoming?.originKey === target.key) { if (incoming.intent !== "commit") throw new OriginSceneError( "undecided-forward", "Cannot go back through an undecided forward operation.", ); if (incoming.phase !== "finished") this.commit(incoming.id); const resting = this.operations.get(incoming.id); if (resting?.phase === "finished" && resting.choreography.persistAtRest) { // Re-root A→page→drawer as A→drawer before adding drawer→page. this.detach(resting); this.transferPosition(target, origin); target.incomingOperationId = undefined; suspendedEdge = resting; } } // A target still positioning the origin (through edges other than the // one collapsed above) would close a visual cycle. if ( this.incomingChain(origin).some( (operation) => operation.originKey === target.key, ) ) throw new OriginSceneError( "target-busy", `Retained target "${target.key}" still positions origin "${origin.key}".`, ); if (this.liveOutgoingOf(target.key)) throw new OriginSceneError( "target-busy", `Retained target "${target.key}" already has an outgoing operation.`, ); if (target.incomingOperationId) throw new OriginSceneError( "target-busy", `Retained target "${target.key}" is already transitioning.`, ); return { target, suspendedEdge }; } /** Create the target node for a push or replace operation. */ private createForwardTarget( origin: SceneNodeRecord, action: OriginAction, history: Exclude, ): SceneNodeRecord { if (!action.target) throw new OriginSceneError( "invalid-action", `A "${history}" operation requires a target view recipe.`, ); // A replacement skips the origin in the retained chain. The origin stays // mounted until commit so an interactive replacement can still cancel. const previousKey = history === "push" ? origin.key : origin.previousNodeKey; return this.createNode(action.target, previousKey); } /** * Make a node presentable again. A node still holding a connected resting * edge (an open drawer, say) stays exposed rather than becoming active. */ private activate(node: SceneNodeRecord): void { const resting = this.presentedOutgoingOf(node.key); if (resting?.phase === "finished") { node.state = "exposed"; return; } node.state = "active"; this.refreshPresentedState(node); } /** * Recompute whether a presented node is participating in a live operation. * Parked and exposed nodes keep their retained state. */ private refreshPresentedState(node: SceneNodeRecord): void { if (node.state === "parked" || node.state === "exposed") return; const incoming = this.incomingOf(node); node.state = (incoming && incoming.phase !== "finished") || this.liveOutgoingOf(node.key) ? "transitioning" : "active"; } // Structural primitives --------------------------------------------------- private createNode( view: OriginView, previousNodeKey?: string, ): SceneNodeRecord { const sequence = ++this.nodeSequence; const node: SceneNodeRecord = shallowReactive({ key: `${view.key ?? view.name ?? "view"}::${sequence}`, sequence, view: markRaw(view), previousNodeKey, state: "active", incomingOperationId: undefined, }); this.nodes.set(node.key, node); return node; } private createOperation( init: Pick< MutableOriginOperation, | "originKey" | "targetKey" | "entryTargetKey" | "choreography" | "placement" | "history" | "originRect" >, ): MutableOriginOperation { const operation = reactive({ id: ++this.operationSequence, originKey: init.originKey, targetKey: init.targetKey, entryTargetKey: init.entryTargetKey, choreography: markRaw(init.choreography), placement: init.placement, history: init.history, progress: 0, velocity: 0, phase: "preparing", intent: "undecided", originRect: init.originRect, }); this.attach(operation); return operation; } private attach(operation: MutableOriginOperation): void { this.operations.set(operation.id, operation); const siblings = this.outgoing.get(operation.originKey) ?? []; this.outgoing.set(operation.originKey, [...siblings, operation]); } private detach(operation: MutableOriginOperation): void { this.operations.delete(operation.id); const siblings = (this.outgoing.get(operation.originKey) ?? []).filter( (sibling) => sibling.id !== operation.id, ); if (siblings.length) this.outgoing.set(operation.originKey, siblings); else this.outgoing.delete(operation.originKey); } private removeNode(nodeKey: string): void { this.nodes.delete(nodeKey); this.roots.value = this.roots.value.filter((key) => key !== nodeKey); } /** * Resolve the operations a node started, because the node is leaving * the visible graph: nodes they created (push/replace targets, including * replacements whose history skips this node) are removed, and retained * entries they revealed (back targets) are restored as if that back * operation were cancelled. */ /** Park a retained entry again, discarding what it started meanwhile. */ private returnToHistory(node: SceneNodeRecord, retained: Set): void { this.discardOutgoing(node.key, new Set(retained)); node.incomingOperationId = undefined; node.state = "parked"; } /** A node's key plus its retained history, which a cancel must keep. */ private retainedKeys(node: SceneNodeRecord): Set { return new Set([node.key, ...this.historyOf(node).map(({ key }) => key)]); } private discardOutgoing(nodeKey: string, visited = new Set()): void { for (const operation of [...(this.outgoing.get(nodeKey) ?? [])]) { // Includes finished resting edges: an open drawer closes with its page. if (operation.history === "back") { this.cancel(operation.id); } else { this.detach(operation); this.removeBranch(operation.entryTargetKey, visited); this.removeBranch(operation.targetKey, visited); } } } /** * Remove a node, every node whose retained history passes through it, and * every operation touching any of them. Earlier entries are untouched. * Operations the node itself started are discarded first. */ private removeBranch(nodeKey: string, visited = new Set()): void { if (visited.has(nodeKey)) return; visited.add(nodeKey); for (const node of [...this.nodes.values()]) if (node.previousNodeKey === nodeKey) this.removeBranch(node.key, visited); this.discardOutgoing(nodeKey, visited); for (const operation of [...this.operations.values()]) if ( operation.originKey === nodeKey || operation.targetKey === nodeKey || operation.entryTargetKey === nodeKey ) this.detach(operation); this.removeNode(nodeKey); } /** * Give `target` the visual-graph position currently held by `from`: either * as the target of `from`'s incoming edge, or as a root. Neither Vue VNode * moves; only edge bookkeeping changes. */ private transferPosition(from: SceneNodeRecord, target: SceneNodeRecord) { const parent = this.incomingOf(from); if (parent) { parent.targetKey = target.key; target.incomingOperationId = parent.id; } else { this.roots.value = this.roots.value.map((key) => key === from.key ? target.key : key, ); target.incomingOperationId = undefined; } } }