clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

main
atlas / render / scene / observe.ts
observe.ts 3.6K
1// Watching the scene node across morph swaps.2//3// `#atlas-viewport-state` is an `outerMorph` region: an interaction that moves4// it replaces the node **whole**, so a listener bound to the old node is bound5// to a node no longer in the page. Two mechanisms together survive that:6//7// A MutationObserver on the node itself catches attribute and child changes8// when a morph patches it in place, which is what htmx does when the shape9// has not changed.10//11// A re-resolve by selector catches the replacement, which is what happens12// when it has. The boot module calls `rescan()` after every swap โ€” the one13// after-swap hook issue #5 ยง4.3 allows โ€” and this class notices the node14// under its selector is a different element and re-attaches.15//16// Everything downstream sees the same thing either way: one callback carrying17// the new scene and what moved. That is the whole point of reconciling on a18// described scene rather than on events โ€” the seam never has to know which19// interaction produced a change, only what the change was.2021import { logger } from "../log.ts";22import type { Scene, SceneChange, StateNode } from "./read.ts";23import { EMPTY_SCENE, readScene, sceneChange } from "./read.ts";2425const log = logger("scene");2627/** What a consumer is told when the scene moves. */28export type SceneListener = (scene: Scene, change: SceneChange) => void;2930/**31 * The live scene: what the node says now, and a callback when it changes.32 *33 * The selector is the contract, not the element: the node is looked up fresh34 * on every rescan, so a swap that replaced it is a re-attach rather than a35 * lost seam.36 */37export class SceneWatcher {38 private node: Element | null = null;39 private observer: MutationObserver | null = null;40 private held: Scene = EMPTY_SCENE;4142 private readonly selector: string;43 private readonly listener: SceneListener;44 private readonly root: ParentNode;4546 constructor(selector: string, listener: SceneListener, root: ParentNode = document) {47 this.selector = selector;48 this.listener = listener;49 this.root = root;50 }5152 /** The scene as last read. */53 get scene(): Scene {54 return this.held;55 }5657 /** Attach to whatever is under the selector now, and read it. */58 start(): void {59 this.rescan();60 }6162 /** Stop watching. The seam keeps its last scene; it simply stops hearing. */63 stop(): void {64 this.observer?.disconnect();65 this.observer = null;66 this.node = null;67 }6869 /**70 * Re-resolve the node and re-read the scene.71 *72 * Called after every swap. It is cheap and idempotent by design: reading a73 * scene is parsing a handful of attributes, and an unchanged scene tells74 * nobody anything.75 */76 rescan(): void {77 const found = this.root.querySelector(this.selector);78 if (found !== this.node) {79 this.observer?.disconnect();80 this.node = found;81 if (found) {82 this.observer = new MutationObserver(() => this.reread());83 this.observer.observe(found, {84 attributes: true, childList: true, subtree: true,85 });86 log.debug("the scene node is being watched", { op: "render" });87 } else {88 this.observer = null;89 }90 }91 this.reread();92 }9394 private reread(): void {95 const now = readScene(this.node as StateNode | null);96 const change = sceneChange(this.held, now);97 if (!change.any) return;98 this.held = now;99 log.debug("the scene moved", {100 op: "render", volume: now.volume, world: now.world, lens: now.lens,101 moved: Object.entries(change)102 .filter(([key, moved]) => moved && key !== "any")103 .map(([key]) => key),104 });105 this.listener(now, change);106 }107}