clone

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

feat/atlas-next-horizon
atlas / render / scene / read.ts
read.ts 7.0K
1// The scene description, read.2//3// `#atlas-viewport-state` is an inert node the server renders and the seam4// observes: data attributes for the singular facts, `<data>` children for the5// three lists (docs/app.md §4). It is the whole of what the application tells6// the seam, and **data flows one way** — nothing here writes to it, and the7// only things that ever travel the other way are the pick event and the8// debounced camera report.9//10// This module is deliberately a pure function of a node-shaped thing. It11// takes the smallest structural interface that a real `Element` satisfies, so12// the reader can be tested over a synthetic node in Node with no DOM at all,13// and so nothing about parsing a scene depends on being in a browser.14//15// Reading is lenient in the format's own spirit: a missing attribute is its16// empty value, a malformed number is as good as absent, and an unknown17// attribute is ignored. A scene that arrives half-written must draw the half18// it can rather than throw a page away.1920/** The smallest shape a scene can be read out of. `Element` satisfies it. */21export interface AttributeSource {22 getAttribute(name: string): string | null;23}2425/** The state node, structurally. `Element` satisfies this too. */26export interface StateNode extends AttributeSource {27 querySelectorAll(selectors: string): Iterable<AttributeSource>;28}2930/** The camera the session handed back, in world coordinates. */31export interface Camera {32 readonly x: number;33 readonly y: number;34 readonly zoom: number;35 readonly rotation: number;36}3738/** A reader-set label policy, keyed by collection id as a string. */39export type LabelPolicy = "always" | "quiet";4041/** Everything the application says about what to picture. */42export interface Scene {43 readonly volume: string;44 readonly base: string;45 readonly world: string;46 readonly lens: string;47 readonly lensIndex: number;48 readonly surface: "plane" | "sphere";49 readonly selected: string;50 readonly search: string;51 readonly gridSystem: string;52 readonly gridCell: string;53 readonly subgrid: number;54 readonly hidden: ReadonlySet<string>;55 readonly highlighted: ReadonlySet<string>;56 readonly overrides: ReadonlyMap<string, LabelPolicy>;57 readonly camera: Camera | null;58}5960/** The scene of a page with no volume open: everything empty, nothing drawn. */61export const EMPTY_SCENE: Scene = {62 volume: "", base: "", world: "", lens: "", lensIndex: 0, surface: "plane",63 selected: "", search: "", gridSystem: "", gridCell: "", subgrid: 0,64 hidden: new Set(), highlighted: new Set(), overrides: new Map(), camera: null,65};6667function attribute(node: AttributeSource, name: string): string {68 return node.getAttribute(name) ?? "";69}7071function whole(node: AttributeSource, name: string): number {72 const value = Number(attribute(node, name));73 return Number.isFinite(value) ? value : 0;74}7576function values(node: StateNode, className: string): string[] {77 const found: string[] = [];78 for (const child of node.querySelectorAll(`data.${className}`)) {79 const value = child.getAttribute("value");80 if (value) found.push(value);81 }82 return found;83}8485/**86 * The camera attribute: `x,y,zoom,rotation`.87 *88 * Absent until the seam has reported one (docs/app.md §6.1), which is the89 * difference between "open this volume where you left it" and "open it at the90 * fit". Anything that does not parse as four numbers is treated as absent,91 * because a camera half-read is a camera pointed somewhere nobody chose.92 */93export function readCamera(value: string): Camera | null {94 if (!value) return null;95 const parts = value.split(",").map(Number);96 if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) return null;97 const [x, y, zoom, rotation] = parts as [number, number, number, number];98 return { x, y, zoom, rotation };99}100101/** The scene as one node says it. */102export function readScene(node: StateNode | null): Scene {103 if (!node) return EMPTY_SCENE;104 const overrides = new Map<string, LabelPolicy>();105 for (const entry of values(node, "label-override")) {106 const cut = entry.lastIndexOf("=");107 if (cut <= 0) continue;108 const policy = entry.slice(cut + 1);109 if (policy === "always" || policy === "quiet") {110 overrides.set(entry.slice(0, cut), policy);111 }112 }113 return {114 volume: attribute(node, "data-volume"),115 base: attribute(node, "data-base"),116 world: attribute(node, "data-world"),117 lens: attribute(node, "data-lens"),118 lensIndex: whole(node, "data-lens-index"),119 surface: attribute(node, "data-surface") === "sphere" ? "sphere" : "plane",120 selected: attribute(node, "data-selected"),121 search: attribute(node, "data-search"),122 gridSystem: attribute(node, "data-grid-system"),123 gridCell: attribute(node, "data-grid-cell"),124 subgrid: whole(node, "data-subgrid"),125 hidden: new Set(values(node, "hidden-collection")),126 highlighted: new Set(values(node, "highlighted-feature")),127 overrides,128 camera: readCamera(attribute(node, "data-camera")),129 };130}131132function sameSet(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {133 if (a.size !== b.size) return false;134 for (const value of a) if (!b.has(value)) return false;135 return true;136}137138function sameMap(139 a: ReadonlyMap<string, LabelPolicy>,140 b: ReadonlyMap<string, LabelPolicy>,141): boolean {142 if (a.size !== b.size) return false;143 for (const [key, value] of a) if (b.get(key) !== value) return false;144 return true;145}146147/**148 * What moved between two scenes.149 *150 * The seam reconciles rather than rebuilds: a swap that only turned a151 * collection off must not refetch a payload, and a swap that changed the152 * world must not keep the pins of the last one. Naming the differences is how153 * that stays honest — every consumer asks this rather than comparing154 * attributes for itself.155 */156export interface SceneChange {157 readonly volume: boolean;158 readonly world: boolean;159 readonly lens: boolean;160 readonly filters: boolean;161 readonly selection: boolean;162 readonly grid: boolean;163 readonly camera: boolean;164 readonly any: boolean;165}166167/** Compare two scenes, field by field, in the terms the seam acts on. */168export function sceneChange(was: Scene, now: Scene): SceneChange {169 const volume = was.volume !== now.volume || was.base !== now.base;170 const world = volume || was.world !== now.world;171 const lens = world || was.lens !== now.lens || was.lensIndex !== now.lensIndex;172 const filters = !sameSet(was.hidden, now.hidden) ||173 !sameSet(was.highlighted, now.highlighted) ||174 !sameMap(was.overrides, now.overrides) ||175 was.search !== now.search;176 const selection = was.selected !== now.selected;177 const grid = was.gridSystem !== now.gridSystem || was.gridCell !== now.gridCell ||178 was.subgrid !== now.subgrid;179 const camera = was.camera?.x !== now.camera?.x || was.camera?.y !== now.camera?.y ||180 was.camera?.zoom !== now.camera?.zoom || was.camera?.rotation !== now.camera?.rotation;181 return {182 volume, world, lens, filters, selection, grid, camera,183 any: volume || world || lens || filters || selection || grid || camera ||184 was.surface !== now.surface,185 };186}