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 / diagnostics.ts
diagnostics.ts 8.4K
1// The diagnostics duty (issue #5 §5.5).2//3// The seam publishes what it knows about itself under one fixed set of key4// names (docs/render-seam.md §8), because a snapshot is diffed key for key5// against the application's half of the same account, and a seam that6// renamed its own diagnostics would be unreadable to every driver of the7// report. This is the fourth contract the lane stands on, and the only one8// that points outward.9//10// THE SPLIT, and why it is where it is. A snapshot is two halves. The11// application's half — what is hidden, what the footer says, what the library12// offers, the saved arrangement — is server state, and it arrives as the JSON13// island `#atlas-session-island` (docs/app.md §6). The seam's half is14// everything only a renderer can answer: where the camera is, how many tiles15// it took, what is standing, what the grid drew, what the sphere built. Two16// halves, one object, and neither half guesses at the other's keys.17//18// THREE GLOBALS, all of them read-only observations:19//20// `__atlasSeam` this lane's half, and the canonical entry point.21// `__atlasGlobe` the sphere's own counts, and only once it exists —22// its mere presence is the answer to "has the sphere23// been entered this session".24// `render_game_to_text()` / `__atlasDebug` / `advanceTime()`25// the entry points a headless driver calls. They are26// published names, and renaming them would buy nothing.2728import { logger } from "./log.ts";29import { COORDINATE_SYSTEM, RASTER_CACHE_SIZE, viewMaxZoom } from "./chart/projection.ts";30import type { DrawnCell } from "./chart/grid.ts";31import type { AtlasViewport } from "./viewport.ts";32import { cellSystems } from "@atlas/analysis";3334const log = logger("diagnostics");3536/** The seam's half of a diagnostics snapshot. */37export interface SeamSnapshot {38 coordinateSystem: string;39 world: string;40 lens: string;41 zoom: number | null;42 center: [number, number] | null;43 resolution: number | null;44 nativeMaxZoom: number | null;45 maxZoom: number | null;46 interpolate: boolean | null;47 tileStats: { requested: number; loaded: number; errors: number; peakPending: number };48 pins: number;49 eligibleLocations: number;50 domNodes: number;51 canvases: number;52 rasterCacheSize: number;53 labelsHeld: boolean;54 hoveredPin: string | null;55 selectedPin: string | null;56 fitZoom: number | null;57 zones: {58 visible: boolean;59 count: number;60 highlighted: string[];61 focusedPins: number;62 };63 sync: { drawn: number; listable: number };64 grid: {65 enabled: boolean;66 system: string;67 prefix: string;68 maximumDepth: number;69 extent: readonly number[] | null;70 cells: DrawnCell[];71 priorityPins: number;72 };73}7475/** Take one reading of the seam. */76export function snapshot(viewport: AtlasViewport): SeamSnapshot {77 const context = viewport.current;78 const chart = viewport.chart;79 const readings = chart?.diagnostics();80 const model = context?.model;81 const visibility = context?.visibility;82 const shapes = visibility?.shapesShown ?? [];83 const selected = context ? model?.feature(context.scene.selected) ?? null : null;84 const hovered = context?.hovered ? model?.pointByID.get(context.hovered) ?? null : null;85 const defaultSystem = cellSystems.systems[0];86 const system = context?.system ?? (context ? defaultSystem ?? null : null);8788 return {89 coordinateSystem: COORDINATE_SYSTEM,90 world: context?.worldTitle ?? "",91 lens: context?.lens?.name ?? "",92 zoom: readings?.zoom ?? null,93 center: readings?.center ?? null,94 resolution: readings?.resolution ?? null,95 nativeMaxZoom: context?.lens?.maxZoom ?? null,96 maxZoom: context?.lens ? viewMaxZoom(context.lens) : null,97 interpolate: context?.lens?.interpolate ?? null,98 tileStats: { ...(chart?.tileStats ?? { requested: 0, loaded: 0, errors: 0, peakPending: 0 }) },99 // The registry's own size, and the part of it the filters left standing.100 pins: model?.points.length ?? 0,101 eligibleLocations: visibility?.eligible ?? 0,102 domNodes: document.querySelectorAll("*").length,103 canvases: document.querySelectorAll("canvas").length,104 rasterCacheSize: RASTER_CACHE_SIZE,105 labelsHeld: context?.labelsHeld ?? false,106 hoveredPin: hovered?.title ?? null,107 // A *pin*, and only a pin. The selection is put down when a card opens108 // on ground, so a shape being read about is recorded here as nothing.109 selectedPin: selected && "coordinate" in selected ? selected.title || null : null,110 fitZoom: readings?.fitZoom ?? null,111 // `focused` is deliberately absent: which shape a reader has open is the112 // application's answer, not a renderer's, and the merge below leaves the113 // application's half of `zones` standing rather than writing a null over114 // it.115 zones: {116 visible: shapes.length > 0,117 count: visibility?.shapesHere.length ?? 0,118 highlighted: (visibility?.highlightedShapes ?? []).map((shape) => shape.title),119 focusedPins: visibility?.focusedPins ?? 0,120 },121 // The model's own count of what the map is drawing. What the footer and122 // the dock *say* is the application's half of this key, rendered in Go.123 sync: { drawn: visibility?.drawn ?? 0, listable: visibility?.listable ?? 0 },124 grid: {125 enabled: Boolean(context?.scene.gridSystem),126 system: context?.scene.gridSystem || defaultSystem?.slug || "",127 prefix: context?.cell ?? "",128 maximumDepth: context && system ? system.maxLevel(context.ground) : 0,129 extent: readings?.grid.extent ?? null,130 cells: readings?.grid.cells ?? [],131 priorityPins: visibility?.priorityPins ?? 0,132 },133 };134}135136/** Merge the application's half under the seam's, one level deep. */137function mergeHalves(138 app: Record<string, unknown>,139 seam: SeamSnapshot,140): Record<string, unknown> {141 const out: Record<string, unknown> = { ...app };142 for (const [key, value] of Object.entries(seam)) {143 const held = out[key];144 if (isPlain(held) && isPlain(value)) out[key] = { ...held, ...value };145 else out[key] = value;146 }147 return out;148}149150function isPlain(value: unknown): value is Record<string, unknown> {151 return typeof value === "object" && value !== null && !Array.isArray(value);152}153154/** The window a headless driver reads. Typed here rather than cast at every use. */155interface DiagnosticsWindow {156 __atlasSeam?: { snapshot(): SeamSnapshot; level: string };157 __atlasGlobe?: unknown;158 __atlasDebug?: { snapshot(): SeamSnapshot };159 __atlasAppDiagnostics?: () => Record<string, unknown>;160 render_game_to_text?: () => string;161 advanceTime?: () => void;162}163164/**165 * Open the seams a headless driver reads.166 *167 * `__atlasGlobe` is a getter rather than an assignment: its mere presence168 * says "the sphere has been entered at least once this session", so it must169 * not exist before the sphere is built.170 */171export function expose(held: AtlasViewport): void {172 const host = window as unknown as DiagnosticsWindow;173 // The viewport is resolved at every reading rather than captured once. A174 // navigation swaps the whole shell, which replaces the element these seams175 // were opened over -- and a snapshot taken through the old one answers for176 // the volume the reader has left, forever. The element held at boot is the177 // fallback for a page with none on it.178 const live = (): AtlasViewport =>179 document.querySelector<AtlasViewport>("atlas-viewport") ?? held;180 const take = () => snapshot(live());181 host.__atlasSeam = { snapshot: take, level: "1" };182 host.__atlasDebug = { snapshot: take };183 host.advanceTime = () => live().chart?.renderSync();184 // The two halves, merged one level deep.185 //186 // A flat spread would be wrong for the three keys both halves speak to:187 // `sync` is the model's counts from here and the rendered words from the188 // application, `zones` is what is drawn from here and what is open from189 // there. Merging only the top level would let this half's four keys erase190 // the application's four, which is how a snapshot ends up missing the191 // footer's own sentence. Nested objects are merged; everything else, this192 // half wins, because it is the half that watched it happen.193 host.render_game_to_text = () => JSON.stringify(194 mergeHalves(host.__atlasAppDiagnostics?.() ?? {}, take()));195 Object.defineProperty(window, "__atlasGlobe", {196 configurable: true,197 get: () => live().globe?.built ? live().globe?.diagnostics() : undefined,198 });199 log.debug("the diagnostics seams are open", { op: "render" });200}