A state every 32 revisions, folded forward on demand; live appends extend the last checkpoint. Replaces foldAll in the viewer, which kept one full state per revision and cannot hold a daily-sized log.
- commit
- 7d518304bdacb0ac1075a0ffb3e57cffb9278bda
- parent
- d6328fd2223862028f90a78469fcdb0054aecc11
- author
- dami <gazey55@gmail.com>
| M | site/ | 18 | +++++--- |
| A | site/ | 70 | ++++++++++++++++++++++++++++++ |
| M | site/ | 21 | +++++++++ |
@@ -13,7 +13,6 @@ import { useEffect, useMemo, useRef, useState } from "preact/hooks";
1313 import {1414 type AgentInfo,1515 emptyState,16- foldAll,1716 type GameEvent,1817 type Geometry,1918 parseLog,@@ -45,6 +44,7 @@ import {
4544 type View,4645 } from "../lib/viewer/tilemap.ts";4746 import { agentHues } from "../lib/viewer/hues.ts";47+import { Checkpoints } from "../lib/viewer/checkpoints.ts";4848 import {4949 density,5050 type Marker,@@ -752,15 +752,21 @@ export default function Viewer(
752752 return () => es.close();753753 }, [props.replay]);754754 755- const states = useMemo(() => foldAll(events), [events]);755+ // checkpointed fold: a state every K revisions, folded forward on demand756+ const cps = useRef(new Checkpoints(32));757+ cps.current.feed(events);756758 useEffect(() => {757759 if (live) setRev(events.length - 1);758760 }, [events, live]);759761 760- const state: State = rev >= 0 && states[rev]761- ? states[rev]762- : (states.length ? states[0] : emptyState());763- const final = states.length ? states[states.length - 1] : state;762+ const state: State = useMemo(763+ () => (events.length ? cps.current.at(rev < 0 ? 0 : rev) : emptyState()),764+ [events, rev],765+ );766+ const final: State = useMemo(767+ () => (events.length ? cps.current.at(events.length - 1) : state),768+ [events],769+ );764770 765771 // hues are assigned from the full agent list, so a scrubbed frame and766772 // the final frame agree@@ -0,0 +1,70 @@
1+// Checkpointed folding for the viewer. Folding every revision into its own2+// state (foldAll) is fine for a hundred events on a small board and3+// impossible for ten thousand on a daily-sized one, where each state4+// carries a map of every known cell. The viewer keeps a checkpoint every5+// K revisions and folds forward from the nearest one on demand; live6+// appends extend the last checkpoint. Scrubbing costs at most K applies.7+8+import { applyEvent, emptyState, type GameEvent, type State } from "../fold.ts";9+10+export class Checkpoints {11+ private cps: State[] = []; // cps[i] = state at revision i * K12+ private events: GameEvent[] = [];13+ private last: { rev: number; state: State } | null = null;14+ readonly K: number;15+16+ constructor(K = 32) {17+ this.K = K;18+ }19+20+ /** Replace or extend the event list (append-only in live mode). */21+ feed(events: GameEvent[]): void {22+ const shared = this.events.length <= events.length &&23+ this.events.every((e, i) => e === events[i]);24+ if (!shared) {25+ this.cps = [];26+ this.last = null;27+ }28+ this.events = events;29+ }30+31+ get length(): number {32+ return this.events.length;33+ }34+35+ /** State at a revision (index into the event list), folded on demand. */36+ at(rev: number): State {37+ const n = this.events.length;38+ if (n === 0) return emptyState();39+ rev = Math.max(0, Math.min(n - 1, rev));40+ // extend checkpoints as far as needed41+ const need = Math.floor(rev / this.K);42+ if (this.cps.length === 0) {43+ this.cps.push(applyEvent(emptyState(), this.events[0]));44+ }45+ while (this.cps.length <= need) {46+ const i = this.cps.length;47+ let s = this.cps[i - 1];48+ for (let r = (i - 1) * this.K + 1; r <= i * this.K; r++) {49+ s = applyEvent(s, this.events[r]);50+ }51+ this.cps.push(s);52+ }53+ // fold forward from the last answer when it is behind and closer54+ let start: number, s: State;55+ if (this.last && this.last.rev <= rev && this.last.rev >= need * this.K) {56+ start = this.last.rev;57+ s = this.last.state;58+ } else {59+ start = need * this.K;60+ s = this.cps[need];61+ }62+ for (let r = start + 1; r <= rev; r++) s = applyEvent(s, this.events[r]);63+ this.last = { rev, state: s };64+ return s;65+ }66+67+ checkpointCount(): number {68+ return this.cps.length;69+ }70+}@@ -28,6 +28,8 @@ import {
2828 } from "../lib/viewer/tilemap.ts";2929 import { agentHues } from "../lib/viewer/hues.ts";3030 import { density, markers, stepFor } from "../lib/viewer/timeline.ts";31+import { Checkpoints } from "../lib/viewer/checkpoints.ts";32+import { foldAll } from "../lib/fold.ts";3133 import { scatterLayout, synthesise, withSeeds } from "./fixture.ts";3234 3335 Deno.test("level of detail by view scale, raster zoom never upscales", () => {@@ -278,3 +280,22 @@ Deno.test("a daily-sized field needs few map tiles per view and per event", () =
278280 // a map tile far away stays valid279281 assert(log.valid("16:0:0", 0, 1));280282 });283+284+Deno.test("checkpointed fold equals foldAll at every revision, any order", () => {285+ const evs = fakeEvents();286+ const all = foldAll(evs);287+ const cp = new Checkpoints(3);288+ cp.feed(evs);289+ const order = [9, 0, 4, 4, 8, 2, 7, 1, 9, 5];290+ for (const r of order) assertEquals(cp.at(r), all[r], `rev ${r}`);291+ assert(cp.checkpointCount() >= 3);292+ // live append: the same object list grown by one keeps the checkpoints293+ const grown = [...evs, { rev: 10, t: evs[0].t, kind: "end" as const }];294+ cp.feed(grown);295+ assertEquals(cp.checkpointCount() >= 3, true);296+ assertEquals(cp.at(10).revision, foldAll(grown)[10].revision);297+ // a different list resets298+ cp.feed(evs.slice(0, 5));299+ assertEquals(cp.length, 5);300+ assertEquals(cp.at(4), all[4]);301+});