diff --git a/packages/graph/src/lib/scene/SceneCanvasLayer.test.ts b/packages/graph/src/lib/scene/SceneCanvasLayer.test.ts new file mode 100644 index 00000000..7310df75 --- /dev/null +++ b/packages/graph/src/lib/scene/SceneCanvasLayer.test.ts @@ -0,0 +1,249 @@ +import { Graph } from "../../graph"; + +import { SceneCanvasLayer } from "./SceneCanvasLayer"; +import { ScenePathBatch } from "./ScenePathBatch"; +import { SceneStore } from "./SceneStore"; + +beforeAll(() => { + // jest-canvas-mock lacks roundRect and Path2D.addPath; stub them as drawPrimitives.test.ts does. + for (const [proto, name] of [ + [CanvasRenderingContext2D.prototype, "roundRect"], + [Path2D.prototype, "addPath"], + ] as const) { + if (!(name in proto)) { + Object.defineProperty(proto, name, { value: function stub(): void {}, configurable: true, writable: true }); + } + } +}); + +describe("SceneCanvasLayer", () => { + let graph: Graph; + + beforeEach(() => { + graph = new Graph({}); + }); + + afterEach(() => graph.unmount()); + + it("draws the elements of the store that meet the viewport", () => { + const store = new SceneStore(100); + const circle = { kind: "circle" as const, cx: 5, cy: 5, r: 5, fill: "#000" }; + store.add({ + id: "near", + zIndex: 1, + x: 0, + y: 0, + bounds: { minX: 0, minY: 0, maxX: 10, maxY: 10 }, + primitives: [circle], + }); + store.add({ + id: "far", + zIndex: 1, + x: 900, + y: 900, + bounds: { minX: 0, minY: 0, maxX: 10, maxY: 10 }, + primitives: [circle], + }); + const layer = new SceneCanvasLayer({ graph, camera: graph.cameraService, store }); + layer.attachLayer(document.createElement("div")); + const fill = jest.spyOn(layer.context.ctx as CanvasRenderingContext2D, "fill"); + + layer.drawScene({ minX: 0, minY: 0, maxX: 100, maxY: 100 }); + + expect(layer.stats).toEqual({ elements: 1, primitives: 1, batchedPrimitives: 0, chunks: 0 }); + expect(fill).toHaveBeenCalledTimes(1); + layer.detachLayer(); + }); + + it("passes maxRun to the drawing of runs", () => { + const store = new SceneStore(100); + const segments = new Float32Array([0, 0, 1, 0, 2, 0, 3, 0]); + for (const id of ["a", "b", "c"]) { + store.add({ + id, + zIndex: 1, + x: 0, + y: 0, + bounds: { minX: 0, minY: 0, maxX: 10, maxY: 10 }, + primitives: [{ kind: "path", segments, stroke: { color: "#123", width: 2 } }], + }); + } + const layer = new SceneCanvasLayer({ graph, camera: graph.cameraService, store, maxRun: 2 }); + layer.attachLayer(document.createElement("div")); + const stroke = jest.spyOn(layer.context.ctx as CanvasRenderingContext2D, "stroke"); + + layer.drawScene({ minX: 0, minY: 0, maxX: 100, maxY: 100 }); + + expect(stroke).toHaveBeenCalledTimes(2); + layer.detachLayer(); + }); + + it("draws a batch between the store elements by z-index", () => { + const store = new SceneStore(100); + const bounds = { minX: 0, minY: 0, maxX: 10, maxY: 10 }; + for (const [id, zIndex] of [ + ["low", 0], + ["high", 2], + ] as const) { + store.add({ + id, + zIndex, + x: 0, + y: 0, + bounds, + primitives: [{ kind: "rect", x: 0, y: 0, width: 5, height: 5, fill: "#000" }], + }); + } + const batch = new ScenePathBatch({ zIndex: 1 }); + batch.add({ + id: "p", + x: 0, + y: 0, + bounds, + primitives: [{ kind: "path", segments: new Float32Array([0, 0, 1, 0]), stroke: { color: "#123", width: 2 } }], + }); + const layer = new SceneCanvasLayer({ graph, camera: graph.cameraService, store, batches: [batch] }); + layer.attachLayer(document.createElement("div")); + const ctx = layer.context.ctx as CanvasRenderingContext2D; + const calls: string[] = []; + jest.spyOn(ctx, "fill").mockImplementation(() => { + calls.push("fill"); + }); + jest.spyOn(ctx, "stroke").mockImplementation(() => { + calls.push("stroke"); + }); + + layer.drawScene({ minX: 0, minY: 0, maxX: 100, maxY: 100 }); + + expect(calls).toEqual(["fill", "stroke", "fill"]); + expect(layer.stats).toEqual({ elements: 2, primitives: 3, batchedPrimitives: 1, chunks: 1 }); + layer.detachLayer(); + }); + + describe("z-order of batches", () => { + const bounds = { minX: 0, minY: 0, maxX: 10, maxY: 10 }; + const viewport = { minX: 0, minY: 0, maxX: 100, maxY: 100 }; + + function elementFill(zIndex: number): string { + return `#00${(zIndex + 50).toString(16).padStart(2, "0")}00`; + } + + function batchStroke(zIndex: number): string { + return `#${(zIndex + 50).toString(16).padStart(2, "0")}0000`; + } + + /** Draws a scene of rect elements and path batches and returns what was painted, in order. */ + function paint(elementZ: number[], batchZ: number[], maxRun?: number): string[] { + const store = new SceneStore(100); + elementZ.forEach((zIndex, i) => { + store.add({ + id: `e${i}`, + zIndex, + x: 0, + y: 0, + bounds, + primitives: [{ kind: "rect", x: 0, y: 0, width: 5, height: 5, fill: elementFill(zIndex) }], + }); + }); + const batches = batchZ.map((zIndex) => { + const batch = new ScenePathBatch({ zIndex }); + batch.add({ + id: `b${zIndex}`, + x: 0, + y: 0, + bounds, + primitives: [ + { + kind: "path", + segments: new Float32Array([0, 0, 1, 0]), + stroke: { color: batchStroke(zIndex), width: 2 }, + }, + ], + }); + return batch; + }); + const layer = new SceneCanvasLayer({ graph, camera: graph.cameraService, store, batches, maxRun }); + layer.attachLayer(document.createElement("div")); + const ctx = layer.context.ctx as CanvasRenderingContext2D; + const painted: string[] = []; + jest.spyOn(ctx, "fill").mockImplementation(() => { + painted.push(`e${(ctx.fillStyle as string).slice(3, 5)}`); + }); + jest.spyOn(ctx, "stroke").mockImplementation(() => { + painted.push(`b${(ctx.strokeStyle as string).slice(1, 3)}`); + }); + layer.drawScene(viewport); + layer.detachLayer(); + return painted; + } + + const e = (z: number): string => `e${(z + 50).toString(16).padStart(2, "0")}`; + const b = (z: number): string => `b${(z + 50).toString(16).padStart(2, "0")}`; + + it("draws an element of the same z-index before the batch", () => { + expect(paint([1], [1])).toEqual([e(1), b(1)]); + }); + + it("draws elements of the same z-index before the batch and higher ones after", () => { + expect(paint([0, 1, 2], [1])).toEqual([e(0), e(1), b(1), e(2)]); + }); + + it("draws batches passed out of order by ascending z-index with elements between them", () => { + expect(paint([0, 2, 4], [3, 1])).toEqual([e(0), b(1), e(2), b(3), e(4)]); + }); + + it("draws a batch below all elements first and a batch above all elements last", () => { + expect(paint([2, 3], [1, 5])).toEqual([b(1), e(2), e(3), b(5)]); + }); + + it("orders a negative z-index element and batch before the rest", () => { + expect(paint([-2, 0], [-1])).toEqual([e(-2), b(-1), e(0)]); + expect(paint([0], [-3])).toEqual([b(-3), e(0)]); + }); + + it("draws every element once when batches have no elements between them", () => { + expect(paint([1, 1, 2], [1, 1, 2])).toEqual([e(1), e(1), b(1), b(1), e(2), b(2)]); + }); + + it("applies maxRun to the slices of elements on the batches path", () => { + const store = new SceneStore(100); + for (const id of ["a", "b", "c"]) { + store.add({ + id, + zIndex: 1, + x: 0, + y: 0, + bounds, + primitives: [ + { kind: "path", segments: new Float32Array([0, 0, 1, 0]), stroke: { color: "#123456", width: 2 } }, + ], + }); + } + const batch = new ScenePathBatch({ zIndex: 2 }); + batch.add({ + id: "p", + x: 0, + y: 0, + bounds, + primitives: [ + { kind: "path", segments: new Float32Array([0, 0, 1, 0]), stroke: { color: "#654321", width: 2 } }, + ], + }); + const layer = new SceneCanvasLayer({ graph, camera: graph.cameraService, store, batches: [batch], maxRun: 2 }); + layer.attachLayer(document.createElement("div")); + const stroke = jest.spyOn(layer.context.ctx as CanvasRenderingContext2D, "stroke"); + + layer.drawScene(viewport); + + // Two runs for three paths (maxRun 2), plus one stroke for the batch. + expect(stroke).toHaveBeenCalledTimes(3); + layer.detachLayer(); + }); + }); + + it("turns the camera state into a world viewport", () => { + const layer = new SceneCanvasLayer({ graph, camera: graph.cameraService, store: new SceneStore() }); + graph.cameraService.set({ x: 100, y: 50, width: 800, height: 600, scale: 0.5 }); + expect(layer.getWorldViewport()).toEqual({ minX: -200, minY: -100, maxX: 1400, maxY: 1100 }); + }); +}); diff --git a/packages/graph/src/lib/scene/SceneCanvasLayer.ts b/packages/graph/src/lib/scene/SceneCanvasLayer.ts new file mode 100644 index 00000000..dd82c5a8 --- /dev/null +++ b/packages/graph/src/lib/scene/SceneCanvasLayer.ts @@ -0,0 +1,71 @@ +import { Layer } from "../../services/Layer"; +import type { LayerProps } from "../../services/Layer"; +import type { TTileRect } from "../culling/TileIndex"; + +import type { ScenePathBatch } from "./ScenePathBatch"; +import type { SceneStore } from "./SceneStore"; +import { drawElements } from "./drawPrimitives"; + +export type TSceneCanvasLayerProps = LayerProps & { + store: SceneStore; + /** Passed to drawElements(): ends a path run after this many paths and polygons; unbounded by default. */ + maxRun?: number; + /** Cached path batches, each drawn after the store elements whose zIndex is not above its own. */ + batches?: readonly ScenePathBatch[]; +}; + +/** Research prototype (E2): draws a SceneStore on its canvas with Canvas2D, without walking components. */ +export class SceneCanvasLayer extends Layer { + /** What the last frame drew. */ + public readonly stats = { elements: 0, primitives: 0, batchedPrimitives: 0, chunks: 0 }; + + constructor(props: TSceneCanvasLayerProps) { + super({ + ...props, + canvas: { zIndex: 2, respectPixelRatio: true, transformByCameraPosition: true, ...props.canvas }, + }); + } + + /** The world rectangle the camera shows; screen = world × scale + camera offset. */ + public getWorldViewport(): TTileRect { + const { x, y, width, height, scale } = this.props.graph.cameraService.getCameraState(); + return { minX: -x / scale, minY: -y / scale, maxX: (width - x) / scale, maxY: (height - y) / scale }; + } + + public drawScene(viewport: TTileRect = this.getWorldViewport()): void { + const ctx = this.context.ctx; + if (!ctx) return; + const elements = this.props.store.query(viewport); + const scale = this.props.graph.cameraService.getCameraScale(); + const { maxRun, batches } = this.props; + this.stats.elements = elements.length; + if (!batches || batches.length === 0) { + this.stats.primitives = drawElements(ctx, elements, scale, { maxRun }); + this.stats.batchedPrimitives = 0; + this.stats.chunks = 0; + return; + } + let primitives = 0; + let batchedPrimitives = 0; + let chunks = 0; + let next = 0; + for (const batch of [...batches].sort((a, b) => a.zIndex - b.zIndex)) { + let end = next; + while (end < elements.length && elements[end].zIndex <= batch.zIndex) end++; + if (end > next) primitives += drawElements(ctx, elements.slice(next, end), scale, { maxRun }); + next = end; + const drawn = batch.render(ctx, viewport); + batchedPrimitives += drawn.primitives; + chunks += drawn.chunks; + } + if (next < elements.length) primitives += drawElements(ctx, elements.slice(next), scale, { maxRun }); + this.stats.primitives = primitives + batchedPrimitives; + this.stats.batchedPrimitives = batchedPrimitives; + this.stats.chunks = chunks; + } + + protected render(): void { + super.render(); + this.drawScene(); + } +} diff --git a/packages/graph/src/lib/scene/ScenePathBatch.test.ts b/packages/graph/src/lib/scene/ScenePathBatch.test.ts new file mode 100644 index 00000000..c763f5bd --- /dev/null +++ b/packages/graph/src/lib/scene/ScenePathBatch.test.ts @@ -0,0 +1,215 @@ +import { ScenePathBatch } from "./ScenePathBatch"; +import type { TScenePathBatchInput } from "./ScenePathBatch"; + +let addPath: jest.SpyInstance; + +beforeAll(() => { + // Stub addPath when jest-canvas-mock lacks it, as drawPrimitives.test.ts does for roundRect. + if (!("addPath" in Path2D.prototype)) { + Object.defineProperty(Path2D.prototype, "addPath", { + value: function addPath(): void {}, + configurable: true, + writable: true, + }); + } +}); + +beforeEach(() => { + addPath = jest.spyOn(Path2D.prototype, "addPath"); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +function createContext(): CanvasRenderingContext2D { + return document.createElement("canvas").getContext("2d") as CanvasRenderingContext2D; +} + +const SEGMENTS = new Float32Array([0, 0, 1, 0, 2, 0, 3, 0]); +const POINTS = new Float32Array([0, 0, 4, 0, 4, 4]); +const STROKE = { color: "#123", width: 2 }; +const VIEWPORT = { minX: 0, minY: 0, maxX: 1000, maxY: 1000 }; + +function line(id: string, x = 0, y = 0): TScenePathBatchInput { + return { + id, + x, + y, + bounds: { minX: 0, minY: 0, maxX: 10, maxY: 10 }, + primitives: [{ kind: "path", segments: SEGMENTS, stroke: STROKE }], + }; +} + +describe("ScenePathBatch", () => { + it("strokes three paths of one style in one call", () => { + const batch = new ScenePathBatch({ zIndex: 1 }); + const ctx = createContext(); + const stroke = jest.spyOn(ctx, "stroke"); + for (const id of ["a", "b", "c"]) batch.add(line(id, 10, 10)); + + expect(batch.render(ctx, VIEWPORT)).toEqual({ primitives: 3, chunks: 1 }); + expect(stroke).toHaveBeenCalledTimes(1); + }); + + it("builds a chunk once and reuses it", () => { + const batch = new ScenePathBatch({ zIndex: 1 }); + const ctx = createContext(); + for (const id of ["a", "b", "c"]) batch.add(line(id, 10, 10)); + + batch.render(ctx, VIEWPORT); + expect(addPath).toHaveBeenCalledTimes(3); + addPath.mockClear(); + batch.render(ctx, VIEWPORT); + expect(addPath).toHaveBeenCalledTimes(0); + }); + + it("neither builds nor draws a chunk outside the viewport", () => { + const batch = new ScenePathBatch({ zIndex: 1 }); + const ctx = createContext(); + batch.add(line("near", 0, 0)); + batch.add(line("far", 20000, 0)); + + expect(batch.render(ctx, VIEWPORT)).toEqual({ primitives: 1, chunks: 1 }); + expect(addPath).toHaveBeenCalledTimes(1); + }); + + it("rebuilds only the chunk of the updated element", () => { + const batch = new ScenePathBatch({ zIndex: 1 }); + const ctx = createContext(); + batch.add(line("a1", 0, 0)); + batch.add(line("a2", 100, 0)); + batch.add(line("b", 9000, 0)); + const wide = { minX: 0, minY: 0, maxX: 20000, maxY: 1000 }; + batch.render(ctx, wide); + addPath.mockClear(); + + batch.update("a1", { x: 50 }); + batch.render(ctx, wide); + expect(addPath).toHaveBeenCalledTimes(2); + }); + + it("draws lines, then polygon fills, then polygon strokes", () => { + const batch = new ScenePathBatch({ zIndex: 1 }); + const ctx = createContext(); + const calls: string[] = []; + const fills: unknown[] = []; + jest.spyOn(ctx, "stroke").mockImplementation(() => { + calls.push("stroke"); + }); + jest.spyOn(ctx, "fill").mockImplementation(() => { + calls.push("fill"); + fills.push(ctx.fillStyle); + }); + batch.add({ + id: "c", + x: 0, + y: 0, + bounds: { minX: 0, minY: 0, maxX: 10, maxY: 10 }, + primitives: [ + { kind: "path", segments: SEGMENTS, stroke: STROKE }, + { kind: "polygon", points: POINTS, fill: "#ff0000", stroke: { color: "#00f", width: 1 } }, + ], + }); + + batch.render(ctx, VIEWPORT); + expect(calls).toEqual(["stroke", "fill", "stroke"]); + expect(fills).toEqual(["#ff0000"]); + }); + + it("rejects unsupported input", () => { + const batch = new ScenePathBatch({ zIndex: 1 }); + const bounds = { minX: 0, minY: 0, maxX: 10, maxY: 10 }; + expect(() => + batch.add({ id: "r", x: 0, y: 0, bounds, primitives: [{ kind: "rect", x: 0, y: 0, width: 1, height: 1 }] }) + ).toThrow(); + expect(() => + batch.add({ + id: "s", + x: 0, + y: 0, + bounds, + primitives: [{ kind: "path", segments: SEGMENTS, stroke: STROKE, minScale: 0.5 }], + }) + ).toThrow(); + batch.add(line("a")); + expect(() => batch.add(line("a"))).toThrow(); + expect(() => batch.update("missing", { x: 1 })).toThrow(); + expect(() => batch.delete("missing")).not.toThrow(); + expect(batch.size).toBe(1); + batch.delete("a"); + expect(batch.size).toBe(0); + }); + + it("splits a cell into chunks of chunkSize items", () => { + const batch = new ScenePathBatch({ zIndex: 1, chunkSize: 2 }); + const ctx = createContext(); + const stroke = jest.spyOn(ctx, "stroke"); + for (const id of ["a", "b", "c", "d", "e"]) batch.add(line(id, 10, 10)); + + expect(batch.render(ctx, VIEWPORT)).toEqual({ primitives: 5, chunks: 3 }); + expect(stroke).toHaveBeenCalledTimes(3); + }); + + it("keeps the element when update is rejected", () => { + const batch = new ScenePathBatch({ zIndex: 1 }); + const ctx = createContext(); + const stroke = jest.spyOn(ctx, "stroke"); + batch.add(line("a", 10, 10)); + const rect = { kind: "rect", x: 0, y: 0, width: 1, height: 1 } as const; + + expect(() => batch.update("a", { primitives: [rect] })).toThrow(); + expect(batch.size).toBe(1); + expect(batch.render(ctx, VIEWPORT)).toEqual({ primitives: 1, chunks: 1 }); + expect(stroke).toHaveBeenCalledTimes(1); + }); + + it("does not fill a polygon with an empty fill", () => { + const batch = new ScenePathBatch({ zIndex: 1 }); + const ctx = createContext(); + const fill = jest.spyOn(ctx, "fill"); + batch.add({ + id: "p", + x: 0, + y: 0, + bounds: { minX: 0, minY: 0, maxX: 10, maxY: 10 }, + primitives: [{ kind: "polygon", points: POINTS, fill: "", stroke: STROKE }], + }); + + batch.render(ctx, VIEWPORT); + expect(fill).not.toHaveBeenCalled(); + }); + + it("counts correctly after delete", () => { + const batch = new ScenePathBatch({ zIndex: 1 }); + const ctx = createContext(); + for (const id of ["a", "b", "c"]) batch.add(line(id, 10, 10)); + batch.render(ctx, VIEWPORT); + + batch.delete("b"); + expect(batch.size).toBe(2); + expect(batch.render(ctx, VIEWPORT)).toEqual({ primitives: 2, chunks: 1 }); + batch.delete("a"); + batch.delete("c"); + expect(batch.size).toBe(0); + expect(batch.render(ctx, VIEWPORT)).toEqual({ primitives: 0, chunks: 0 }); + }); + + it("moves an element to another cell and rebuilds only the two chunks", () => { + const batch = new ScenePathBatch({ zIndex: 1 }); + const ctx = createContext(); + const wide = { minX: 0, minY: 0, maxX: 30000, maxY: 1000 }; + batch.add(line("a", 0, 0)); + batch.add(line("m", 100, 0)); + batch.add(line("c", 20000, 0)); + expect(batch.render(ctx, wide)).toEqual({ primitives: 3, chunks: 2 }); + addPath.mockClear(); + + batch.update("m", { x: 9000 }); + // Cell A keeps "a" (rebuilt: 1 item), the new cell holds "m" (1 item); cell of "c" is untouched. + expect(batch.render(ctx, wide)).toEqual({ primitives: 3, chunks: 3 }); + expect(addPath).toHaveBeenCalledTimes(2); + expect(batch.render(ctx, { minX: 0, minY: 0, maxX: 500, maxY: 1000 })).toEqual({ primitives: 1, chunks: 1 }); + expect(batch.render(ctx, { minX: 8900, minY: 0, maxX: 9500, maxY: 1000 })).toEqual({ primitives: 1, chunks: 1 }); + }); +}); diff --git a/packages/graph/src/lib/scene/ScenePathBatch.ts b/packages/graph/src/lib/scene/ScenePathBatch.ts new file mode 100644 index 00000000..99bf6900 --- /dev/null +++ b/packages/graph/src/lib/scene/ScenePathBatch.ts @@ -0,0 +1,178 @@ +import { BatchScene } from "../batch/BatchScene"; +import type { IBatchBackend, IBatchItem, TPathBounds } from "../batch/types"; +import type { TTileRect } from "../culling/TileIndex"; + +import { appendPolygon, appendSegments, applyStroke, strokeKey } from "./drawPrimitives"; +import type { TSceneElement, TSceneStroke } from "./types"; + +/** What the batch needs from an element: its origin, bounds and primitives. */ +export type TScenePathBatchInput = Pick; + +export type TScenePathBatchOptions = { + zIndex: number; + /** Edge of a chunk cell in world units. Default: 8192. */ + cellSize?: number; + /** Most items in one chunk. Default: 200. */ + chunkSize?: number; +}; + +const DEFAULT_CELL_SIZE = 8192; +const DEFAULT_CHUNK_SIZE = 200; + +/** One path or polygon primitive of an element, placed in world space. */ +class PathBatchItem implements IBatchItem { + public readonly group: string; + + public readonly kind: "path" | "polygon"; + + public readonly stroke: TSceneStroke | undefined; + + public readonly fill: string | undefined; + + private readonly bounds: TPathBounds; + + private path: Path2D | undefined = undefined; + + constructor( + input: TScenePathBatchInput, + private readonly source: { kind: "path"; segments: Float32Array } | { kind: "polygon"; points: Float32Array }, + stroke: TSceneStroke | undefined, + fill: string | undefined, + private readonly ox: number, + private readonly oy: number + ) { + this.kind = source.kind; + this.stroke = stroke; + this.fill = fill; + this.group = `${source.kind}|${strokeKey(stroke)}|${fill ?? ""}`; + this.bounds = [ + input.bounds.minX + input.x, + input.bounds.minY + input.y, + input.bounds.maxX + input.x, + input.bounds.maxY + input.y, + ]; + } + + public getPathBounds(): TPathBounds { + return this.bounds; + } + + /** The item's own path at its offset, built on first use. */ + public getPath(): Path2D { + if (!this.path) { + const path = new Path2D(); + if (this.source.kind === "path") appendSegments(path, this.source.segments, this.ox, this.oy); + else appendPolygon(path, this.source.points, this.ox, this.oy); + this.path = path; + } + return this.path; + } +} + +/** + * Research prototype (E2b). Caches the paths and polygons of scene elements per grid cell, the way + * `BatchPath2DRenderer` caches connections: every path or polygon primitive is one `BatchScene` item, a chunk of items + * of one style is one combined `Path2D`, and a camera move rebuilds nothing. + */ +export class ScenePathBatch { + public readonly zIndex: number; + + private readonly scene: BatchScene; + + private readonly elements = new Map(); + + private drawnPrimitives = 0; + + private drawnChunks = 0; + + constructor(options: TScenePathBatchOptions) { + this.zIndex = options.zIndex; + const backend: IBatchBackend = { + build: (items): Path2D => { + const path = new Path2D(); + for (const item of items) path.addPath(item.getPath()); + return path; + }, + draw: (ctx, path, items): void => { + // Items of one group share a style; it comes from the chunk's first item. + const first = items[0]; + ctx.save(); + if (first.kind === "polygon") { + if (first.fill) { + ctx.fillStyle = first.fill; + ctx.fill(path); + } + } + if (first.stroke) { + applyStroke(ctx, first.stroke); + ctx.stroke(path); + } + ctx.restore(); + this.drawnChunks++; + this.drawnPrimitives += items.length; + }, + }; + this.scene = new BatchScene(backend, { + cellSize: options.cellSize ?? DEFAULT_CELL_SIZE, + chunkSize: options.chunkSize ?? DEFAULT_CHUNK_SIZE, + }); + } + + /** Number of elements. */ + public get size(): number { + return this.elements.size; + } + + public add(input: TScenePathBatchInput): void { + if (this.elements.has(input.id)) throw new Error(`ScenePathBatch: duplicate element id "${input.id}"`); + this.insert(input, this.buildItems(input)); + } + + private buildItems(input: TScenePathBatchInput): PathBatchItem[] { + const items: PathBatchItem[] = []; + for (const primitive of input.primitives) { + if (primitive.minScale !== undefined || primitive.maxScale !== undefined) { + throw new Error(`ScenePathBatch: element "${input.id}" has a primitive with minScale/maxScale`); + } + if (primitive.kind === "path") { + items.push(new PathBatchItem(input, primitive, primitive.stroke, undefined, input.x, input.y)); + } else if (primitive.kind === "polygon") { + items.push(new PathBatchItem(input, primitive, primitive.stroke, primitive.fill, input.x, input.y)); + } else { + throw new Error(`ScenePathBatch: element "${input.id}" has a "${primitive.kind}" primitive`); + } + } + return items; + } + + private insert(input: TScenePathBatchInput, items: PathBatchItem[]): void { + for (const item of items) this.scene.add(item, { zIndex: this.zIndex, group: item.group }); + this.elements.set(input.id, { input, items }); + } + + /** Replaces the element: deletes its items and adds new ones, so only the chunks it touches are rebuilt. */ + public update(id: string, patch: Partial>): void { + const element = this.elements.get(id); + if (!element) throw new Error(`ScenePathBatch: unknown element id "${id}"`); + const next: TScenePathBatchInput = { ...element.input, ...patch }; + // Validate and build first: a rejected patch leaves the old element in place. + const items = this.buildItems(next); + this.delete(id); + this.insert(next, items); + } + + public delete(id: string): void { + const element = this.elements.get(id); + if (!element) return; + for (const item of element.items) this.scene.delete(item); + this.elements.delete(id); + } + + /** Draws the chunks that meet the viewport; returns how many primitives and chunks this frame drew. */ + public render(ctx: CanvasRenderingContext2D, viewport: TTileRect): { primitives: number; chunks: number } { + this.drawnPrimitives = 0; + this.drawnChunks = 0; + this.scene.render(ctx, viewport); + return { primitives: this.drawnPrimitives, chunks: this.drawnChunks }; + } +} diff --git a/packages/graph/src/lib/scene/SceneStore.test.ts b/packages/graph/src/lib/scene/SceneStore.test.ts new file mode 100644 index 00000000..25549677 --- /dev/null +++ b/packages/graph/src/lib/scene/SceneStore.test.ts @@ -0,0 +1,49 @@ +import { SceneStore } from "./SceneStore"; +import type { TSceneElementInput } from "./SceneStore"; + +function input(id: string, patch: Partial = {}): TSceneElementInput { + return { id, zIndex: 1, x: 0, y: 0, bounds: { minX: 0, minY: 0, maxX: 10, maxY: 10 }, primitives: [], ...patch }; +} + +const ALL = { minX: -1e6, minY: -1e6, maxX: 1e6, maxY: 1e6 }; + +describe("SceneStore", () => { + it("returns visible elements that meet the rect, ordered by z-level and then by sequence", () => { + const store = new SceneStore(100); + store.add(input("block", { zIndex: 2 })); + store.add(input("group", { zIndex: 0 })); + store.add(input("far", { x: 5000, y: 5000 })); + store.add(input("line")); + store.add(input("hidden", { hidden: true })); + expect(store.query({ minX: 0, minY: 0, maxX: 50, maxY: 50 }).map((element) => element.id)).toEqual([ + "group", + "line", + "block", + ]); + }); + + it("puts a raised element last within its z-level", () => { + const store = new SceneStore(); + store.add(input("a")); + store.add(input("b")); + store.raise("a"); + expect(store.query(ALL).map((element) => element.id)).toEqual(["b", "a"]); + }); + + it("finds a moved element at its new place only", () => { + const store = new SceneStore(100); + store.add(input("a")); + store.update("a", { x: 1000, y: 1000 }); + expect(store.query({ minX: 0, minY: 0, maxX: 50, maxY: 50 })).toEqual([]); + expect(store.query({ minX: 990, minY: 990, maxX: 1020, maxY: 1020 }).map((element) => element.id)).toEqual(["a"]); + }); + + it("forgets deleted elements and rejects duplicate ids", () => { + const store = new SceneStore(); + store.add(input("a")); + expect(() => store.add(input("a"))).toThrow("Scene element a already exists"); + store.delete("a"); + expect(store.size).toBe(0); + expect(store.query(ALL)).toEqual([]); + }); +}); diff --git a/packages/graph/src/lib/scene/SceneStore.ts b/packages/graph/src/lib/scene/SceneStore.ts new file mode 100644 index 00000000..238740bf --- /dev/null +++ b/packages/graph/src/lib/scene/SceneStore.ts @@ -0,0 +1,84 @@ +import { TileIndex } from "../culling/TileIndex"; +import type { TTileRect } from "../culling/TileIndex"; + +import type { TSceneElement } from "./types"; + +export type TSceneElementInput = Omit & { hidden?: boolean }; + +/** Draw order of the scene: z-level first, then the order of mounting and raising. */ +export function compareSceneOrder(a: TSceneElement, b: TSceneElement): number { + return a.zIndex - b.zIndex || a.sequence - b.sequence; +} + +/** Research prototype (E2): the elements of a scene, found by world rect and returned in draw order. */ +export class SceneStore { + private readonly elements = new Map(); + + private readonly index: TileIndex; + + private sequence = 0; + + constructor(tileSize = 1024) { + this.index = new TileIndex({ tileSize }); + } + + public get size(): number { + return this.elements.size; + } + + public get(id: string): TSceneElement | undefined { + return this.elements.get(id); + } + + public add(input: TSceneElementInput): TSceneElement { + if (this.elements.has(input.id)) { + throw new Error(`Scene element ${input.id} already exists`); + } + const element: TSceneElement = { ...input, hidden: input.hidden ?? false, sequence: ++this.sequence }; + this.elements.set(element.id, element); + this.place(element); + return element; + } + + public update( + id: string, + patch: Partial> + ): void { + const element = this.require(id); + Object.assign(element, patch); + if (patch.x !== undefined || patch.y !== undefined || patch.bounds !== undefined) { + this.place(element); + } + } + + public raise(id: string): void { + this.require(id).sequence = ++this.sequence; + } + + public delete(id: string): void { + const element = this.elements.get(id); + if (!element) return; + this.elements.delete(id); + this.index.delete(element); + } + + /** Visible elements whose bounds meet the rect, in draw order. */ + public query(rect: TTileRect): TSceneElement[] { + const found: TSceneElement[] = []; + this.index.query(rect, (element) => { + if (!element.hidden) found.push(element); + }); + return found.sort(compareSceneOrder); + } + + private place(element: TSceneElement): void { + const { minX, minY, maxX, maxY } = element.bounds; + this.index.set(element, element.x + minX, element.y + minY, element.x + maxX, element.y + maxY); + } + + private require(id: string): TSceneElement { + const element = this.elements.get(id); + if (!element) throw new Error(`Scene element ${id} does not exist`); + return element; + } +} diff --git a/packages/graph/src/lib/scene/drawPrimitives.test.ts b/packages/graph/src/lib/scene/drawPrimitives.test.ts new file mode 100644 index 00000000..068c5b49 --- /dev/null +++ b/packages/graph/src/lib/scene/drawPrimitives.test.ts @@ -0,0 +1,143 @@ +import { drawElements, isPrimitiveVisible, textWorldSize } from "./drawPrimitives"; +import type { TSceneElement } from "./types"; + +beforeAll(() => { + // jest-canvas-mock 2.5 has no roundRect. + if (!("roundRect" in CanvasRenderingContext2D.prototype)) { + Object.defineProperty(CanvasRenderingContext2D.prototype, "roundRect", { + value: function roundRect(): void {}, + configurable: true, + writable: true, + }); + } +}); + +function createContext(): CanvasRenderingContext2D { + return document.createElement("canvas").getContext("2d") as CanvasRenderingContext2D; +} + +function element(patch: Partial): TSceneElement { + return { + id: "element", + zIndex: 0, + sequence: 1, + x: 0, + y: 0, + bounds: { minX: 0, minY: 0, maxX: 10, maxY: 10 }, + primitives: [], + hidden: false, + ...patch, + }; +} + +const SEGMENTS = new Float32Array([0, 0, 1, 0, 2, 0, 3, 0]); + +function line(id: string): TSceneElement { + return element({ id, primitives: [{ kind: "path", segments: SEGMENTS, stroke: { color: "#123", width: 2 } }] }); +} + +describe("scene primitives", () => { + it("shows a primitive only inside its scale range", () => { + const circle = { kind: "circle" as const, cx: 0, cy: 0, r: 1, minScale: 0.2, maxScale: 0.7 }; + expect(isPrimitiveVisible(circle, 0.2)).toBe(true); + expect(isPrimitiveVisible(circle, 0.69)).toBe(true); + expect(isPrimitiveVisible(circle, 0.7)).toBe(false); + expect(isPrimitiveVisible(circle, 0.1)).toBe(false); + }); + + it("holds a screen size within world limits", () => { + expect(textWorldSize(12, 0.5)).toBe(12); + expect(textWorldSize({ px: 9, minWorld: 14 }, 0.25)).toBe(36); + expect(textWorldSize({ px: 9, minWorld: 14 }, 1)).toBe(14); + expect(textWorldSize({ px: 14, maxWorld: 28 }, 0.25)).toBe(28); + }); + + it("insets an inside border and outsets an outside one by half its width", () => { + const ctx = createContext(); + const roundRect = jest.spyOn(ctx, "roundRect"); + const rect = { kind: "rect" as const, x: 0, y: 0, width: 40, height: 20, radius: 8 }; + drawElements( + ctx, + [ + element({ x: 100, y: 50, primitives: [{ ...rect, border: { color: "#000", width: 4, align: "inside" } }] }), + element({ x: 100, y: 50, primitives: [{ ...rect, border: { color: "#000", width: 4, align: "outside" } }] }), + ], + 1 + ); + expect(roundRect).toHaveBeenNthCalledWith(1, 102, 52, 36, 16, 6); + expect(roundRect).toHaveBeenNthCalledWith(2, 98, 48, 44, 24, 10); + }); + + it("strokes consecutive paths of one stroke style with one call", () => { + const ctx = createContext(); + const stroke = jest.spyOn(ctx, "stroke"); + expect(drawElements(ctx, [line("a"), line("b"), line("c")], 1)).toBe(3); + expect(stroke).toHaveBeenCalledTimes(1); + }); + + it("ends a run after maxRun paths and polygons", () => { + const ctx = createContext(); + const stroke = jest.spyOn(ctx, "stroke"); + expect(drawElements(ctx, [line("a"), line("b"), line("c")], 1, { maxRun: 2 })).toBe(3); + expect(stroke).toHaveBeenCalledTimes(2); + }); + + it("treats maxRun Infinity as unbounded", () => { + const ctx = createContext(); + const stroke = jest.spyOn(ctx, "stroke"); + drawElements(ctx, [line("a"), line("b"), line("c")], 1, { maxRun: Infinity }); + expect(stroke).toHaveBeenCalledTimes(1); + }); + + it.each([0, -1, 1.5, NaN])("rejects maxRun %p, which is not a positive integer", (maxRun) => { + const ctx = createContext(); + expect(() => drawElements(ctx, [line("a")], 1, { maxRun })).toThrow(RangeError); + }); + + it("flushes a path run before a later shape so the draw order holds", () => { + const ctx = createContext(); + const stroke = jest.spyOn(ctx, "stroke"); + const fill = jest.spyOn(ctx, "fill"); + const block = element({ + id: "block", + primitives: [{ kind: "rect", x: 0, y: 0, width: 10, height: 10, fill: "#fff" }], + }); + drawElements(ctx, [line("a"), block, line("b")], 1); + expect(stroke).toHaveBeenCalledTimes(2); + expect(fill).toHaveBeenCalledTimes(1); + expect(stroke.mock.invocationCallOrder[0]).toBeLessThan(fill.mock.invocationCallOrder[0]); + expect(fill.mock.invocationCallOrder[0]).toBeLessThan(stroke.mock.invocationCallOrder[1]); + }); + + it("fills a polygon run once and strokes it with the paths of the same style", () => { + const ctx = createContext(); + const stroke = jest.spyOn(ctx, "stroke"); + const fill = jest.spyOn(ctx, "fill"); + const arrow = new Float32Array([0, 0, 4, 2, 0, 4]); + const connection = (id: string): TSceneElement => + element({ + id, + primitives: [ + { kind: "path", segments: SEGMENTS, stroke: { color: "#123", width: 2 } }, + { kind: "polygon", points: arrow, fill: "#123", stroke: { color: "#123", width: 2 } }, + ], + }); + drawElements(ctx, [connection("a"), connection("b")], 1); + expect(fill).toHaveBeenCalledTimes(1); + expect(stroke).toHaveBeenCalledTimes(1); + }); + + it("skips hidden elements and primitives outside their scale range", () => { + const ctx = createContext(); + const visible = { kind: "circle" as const, cx: 0, cy: 0, r: 1, fill: "#000" }; + const drawn = drawElements( + ctx, + [ + element({ hidden: true, primitives: [visible] }), + element({ primitives: [visible, { ...visible, minScale: 0.5 }] }), + ], + 0.3 + ); + expect(drawn).toBe(1); + }); +}); diff --git a/packages/graph/src/lib/scene/drawPrimitives.ts b/packages/graph/src/lib/scene/drawPrimitives.ts new file mode 100644 index 00000000..03711863 --- /dev/null +++ b/packages/graph/src/lib/scene/drawPrimitives.ts @@ -0,0 +1,230 @@ +import { renderText } from "../../utils/renderers/text"; + +import type { TSceneElement, TScenePrimitive, TSceneStroke, TSceneTextSize } from "./types"; + +export function isPrimitiveVisible(primitive: TScenePrimitive, scale: number): boolean { + if (primitive.minScale !== undefined && scale < primitive.minScale) return false; + if (primitive.maxScale !== undefined && scale >= primitive.maxScale) return false; + return true; +} + +export function textWorldSize(size: TSceneTextSize, scale: number): number { + if (typeof size === "number") return size; + return Math.min(Math.max(size.px / scale, size.minWorld ?? 0), size.maxWorld ?? Infinity); +} + +/** How far a stroke's centre line moves inwards from the shape's edge. */ +function strokeInset(stroke: TSceneStroke): number { + if (stroke.align === "inside") return stroke.width / 2; + if (stroke.align === "outside") return -stroke.width / 2; + return 0; +} + +function insetRadius(radius: number | readonly number[] | undefined, inset: number): number | number[] { + if (radius === undefined) return 0; + if (typeof radius === "number") return Math.max(0, radius - inset); + return radius.map((value) => Math.max(0, value - inset)); +} + +export function applyStroke(ctx: CanvasRenderingContext2D, stroke: TSceneStroke): void { + ctx.strokeStyle = stroke.color; + ctx.lineWidth = stroke.width; + ctx.setLineDash(stroke.dash ? [...stroke.dash] : []); +} + +export function strokeKey(stroke: TSceneStroke | undefined): string { + return stroke ? `${stroke.color}|${stroke.width}|${stroke.dash?.join(",") ?? ""}` : "none"; +} + +/** Draws a rect, circle, text or icon whose coordinates are relative to (ox, oy). */ +function drawShape( + ctx: CanvasRenderingContext2D, + primitive: TScenePrimitive, + ox: number, + oy: number, + scale: number +): void { + switch (primitive.kind) { + case "rect": { + const x = ox + primitive.x; + const y = oy + primitive.y; + if (primitive.fill) { + ctx.fillStyle = primitive.fill; + ctx.beginPath(); + ctx.roundRect(x, y, primitive.width, primitive.height, insetRadius(primitive.radius, 0)); + ctx.fill(); + } + if (primitive.border) { + const inset = strokeInset(primitive.border); + applyStroke(ctx, primitive.border); + ctx.beginPath(); + ctx.roundRect( + x + inset, + y + inset, + primitive.width - 2 * inset, + primitive.height - 2 * inset, + insetRadius(primitive.radius, inset) + ); + ctx.stroke(); + } + return; + } + case "circle": { + ctx.beginPath(); + ctx.arc(ox + primitive.cx, oy + primitive.cy, primitive.r, 0, 2 * Math.PI); + if (primitive.fill) { + ctx.fillStyle = primitive.fill; + ctx.fill(); + } + if (primitive.border) { + applyStroke(ctx, primitive.border); + ctx.stroke(); + } + return; + } + case "text": { + const size = textWorldSize(primitive.size, scale); + ctx.fillStyle = primitive.color; + ctx.textAlign = primitive.align ?? "left"; + renderText( + primitive.text, + ctx, + { x: ox + primitive.x, y: oy + primitive.y, width: primitive.width, height: primitive.height }, + { font: `${primitive.weight ?? "normal"} ${size}px ${primitive.family}`, lineHeight: primitive.lineHeight } + ); + return; + } + case "icon": { + const factor = primitive.size / primitive.viewBox; + ctx.save(); + ctx.translate(ox + primitive.x, oy + primitive.y); + ctx.scale(factor, factor); + ctx.fillStyle = primitive.color; + ctx.fill(primitive.path, "evenodd"); + ctx.restore(); + return; + } + default: + return; + } +} + +/** Consecutive paths and polygons of one stroke style, drawn with one fill and one stroke. */ +type TRun = { + key: string; + stroke?: TSceneStroke; + fill?: string; + strokePath: Path2D; + fillPath: Path2D; + /** Paths and polygons in the run. */ + size: number; +}; + +export type TDrawElementsOptions = { + /** + * Ends a run after this many paths and polygons: a positive integer, or `Infinity` / `undefined` for unbounded (the + * default). Any other value throws a RangeError. Chromium's Path2D.closePath() costs time in proportion to the path + * built so far, so one run of thousands of polygons costs quadratic time. + */ + maxRun?: number; +}; + +function resolveMaxRun(maxRun: number | undefined): number { + if (maxRun === undefined || maxRun === Infinity) return Infinity; + if (!Number.isInteger(maxRun) || maxRun < 1) { + throw new RangeError(`drawElements: maxRun must be a positive integer or Infinity, got ${maxRun}`); + } + return maxRun; +} + +export function appendSegments(path: Path2D, segments: Float32Array, ox: number, oy: number): void { + let lastX = NaN; + let lastY = NaN; + for (let i = 0; i + 8 <= segments.length; i += 8) { + const x0 = ox + segments[i]; + const y0 = oy + segments[i + 1]; + if (x0 !== lastX || y0 !== lastY) path.moveTo(x0, y0); + lastX = ox + segments[i + 6]; + lastY = oy + segments[i + 7]; + path.bezierCurveTo( + ox + segments[i + 2], + oy + segments[i + 3], + ox + segments[i + 4], + oy + segments[i + 5], + lastX, + lastY + ); + } +} + +export function appendPolygon(path: Path2D, points: Float32Array, ox: number, oy: number): void { + for (let i = 0; i + 1 < points.length; i += 2) { + if (i === 0) path.moveTo(ox + points[i], oy + points[i + 1]); + else path.lineTo(ox + points[i], oy + points[i + 1]); + } + path.closePath(); +} + +/** + * Draws visible primitives of the elements in the given order. Paths and polygons of one stroke style that follow each + * other form a run, drawn with one `fill` of all its polygons and then one `stroke` of all its paths and polygon + * outlines. Inside a run the order is therefore not kept: every polygon fill lies under every stroke of the run, also + * under the stroke of a path that came before the polygon. Any other primitive ends the run first, so the order between + * runs and the other primitives is kept. + */ +export function drawElements( + ctx: CanvasRenderingContext2D, + elements: readonly TSceneElement[], + scale: number, + options: TDrawElementsOptions = {} +): number { + const maxRun = resolveMaxRun(options.maxRun); + let run: TRun | undefined; + let drawn = 0; + const flush = (): void => { + if (!run) return; + if (run.fill) { + ctx.fillStyle = run.fill; + ctx.fill(run.fillPath); + } + if (run.stroke) { + applyStroke(ctx, run.stroke); + ctx.stroke(run.strokePath); + } + run = undefined; + }; + for (let e = 0; e < elements.length; e++) { + const element = elements[e]; + if (element.hidden) continue; + for (let p = 0; p < element.primitives.length; p++) { + const primitive = element.primitives[p]; + if (!isPrimitiveVisible(primitive, scale)) continue; + drawn++; + if (primitive.kind === "path" || primitive.kind === "polygon") { + const stroke = primitive.stroke; + const fill = primitive.kind === "polygon" ? primitive.fill : undefined; + const key = strokeKey(stroke); + if ( + run && + (run.key !== key || run.size >= maxRun || (fill !== undefined && run.fill !== undefined && run.fill !== fill)) + ) { + flush(); + } + if (!run) run = { key, stroke, strokePath: new Path2D(), fillPath: new Path2D(), size: 0 }; + run.size++; + if (primitive.kind === "path") { + appendSegments(run.strokePath, primitive.segments, element.x, element.y); + } else { + run.fill = fill; + appendPolygon(run.fillPath, primitive.points, element.x, element.y); + if (stroke) appendPolygon(run.strokePath, primitive.points, element.x, element.y); + } + continue; + } + flush(); + drawShape(ctx, primitive, element.x, element.y, scale); + } + } + flush(); + return drawn; +} diff --git a/packages/graph/src/lib/scene/types.ts b/packages/graph/src/lib/scene/types.ts new file mode 100644 index 00000000..f8077fde --- /dev/null +++ b/packages/graph/src/lib/scene/types.ts @@ -0,0 +1,69 @@ +import type { TTileRect } from "../culling/TileIndex"; + +/** Stroke of a scene primitive; lengths in world units. */ +export type TSceneStroke = { + color: string; + width: number; + /** Where the stroke of a closed shape lies relative to its edge. Default: "center". */ + align?: "inside" | "center" | "outside"; + dash?: readonly number[]; +}; + +/** A size in world units, or a screen size: world size = clamp(px / scale, minWorld, maxWorld). */ +export type TSceneTextSize = number | { px: number; minWorld?: number; maxWorld?: number }; + +/** Shown while minScale <= camera scale < maxScale; either bound may be absent. */ +export type TSceneVisibility = { minScale?: number; maxScale?: number }; + +/** Coordinates are relative to the element's origin, in world units. */ +export type TScenePrimitive = TSceneVisibility & + ( + | { + kind: "rect"; + x: number; + y: number; + width: number; + height: number; + /** One radius or [top-left, top-right, bottom-right, bottom-left]. */ + radius?: number | readonly number[]; + fill?: string; + border?: TSceneStroke; + } + | { kind: "circle"; cx: number; cy: number; r: number; fill?: string; border?: TSceneStroke } + | { + kind: "text"; + text: string; + family: string; + /** A keyword weight ("normal", "bold"): layoutText reads digits of the font string as a line height. */ + weight?: string; + size: TSceneTextSize; + /** Line height in world units. */ + lineHeight: number; + color: string; + x: number; + y: number; + width: number; + height?: number; + align?: CanvasTextAlign; + } + | { kind: "icon"; path: Path2D; viewBox: number; x: number; y: number; size: number; color: string } + /** Cubic segments, 8 numbers each (p0, c1, c2, p3), as the connection geometries produce them. */ + | { kind: "path"; segments: Float32Array; stroke: TSceneStroke } + /** A closed polygon given as x, y pairs. */ + | { kind: "polygon"; points: Float32Array; fill: string; stroke?: TSceneStroke } + ); + +/** One drawn entity of the scene: a block with its anchors, a group, a connection. */ +export type TSceneElement = { + readonly id: string; + zIndex: number; + /** Order within a z-level; a raise gives a new, larger value. */ + sequence: number; + /** Origin of the primitives in world units. */ + x: number; + y: number; + /** Bounds of every primitive relative to the origin, strokes included. */ + bounds: TTileRect; + primitives: readonly TScenePrimitive[]; + hidden: boolean; +}; diff --git a/scripts/bench/culling/README.md b/scripts/bench/culling/README.md index 0ce6aafa..f9ec61ad 100644 --- a/scripts/bench/culling/README.md +++ b/scripts/bench/culling/README.md @@ -70,3 +70,91 @@ or extra block or connection, differs by a much larger delta. Chromium comes from Playwright (`npx playwright install chromium`). On macOS it uses ANGLE Metal for a hardware canvas. Inside a sandbox that blocks Mach ports, run it outside the sandbox. + +## Scene bench (WebGL scene research, E1 and E2) + +`scene-run.mjs` measures the working tree only. It draws one Nirvana-like scene in two columns: + +- `walk`: today's component walk. `NirvanaLikeBlock`, core anchors, `MultipointConnection` on the Path2D batch and a + `BlockGroups` layer. +- `interpreter`: the scene prototype (`packages/graph/src/lib/scene`). A `SceneCanvasLayer` draws the same look from + scene data without walking components. + +Both columns draw blocks through the same `drawElements()`, and the `pixels` line checks that the pictures match. The +expected residue is a few levels of anti-aliasing and alpha rounding on edges. The `severe` count (pixels off by more +than 8 levels) stays under ~100 with `--gpu`. With SwiftShader it reaches tens of thousands at scales 0.3 and 0.6, on +thin vertical lines where connection segments overlap. The likely cause, supported but not proven: the two columns +group overlapping segments into different `stroke` calls (walk: per chunk; interpreter: per run). SwiftShader draws one +stroke over coincident lines as one line, but separate strokes darken the edges. A run cap can raise or lower the +count. + +A tick is one run of the global scheduler (`graph.scheduler.performUpdate()`), with every layer included. A sample is +the work of one action (a camera step or a drag step): the durations of consecutive ticks from the first tick after the +action until the first idle tick (under 0.05 ms), at most 5 ticks. It is summed because walk drag spills into the next +tick (`api.updateBlock` / `updateConnection` re-render one tick later); the first tick alone would under-measure it. +`( ticks)` is the mean number of working ticks per action. `frames` are the intervals between the first ticks of +consecutive actions. + +Reading these numbers: + +- **Drag** is the cost of one isolated drag step: the time to the final picture. In a continuous drag the walk's + second redraw would merge into the next frame's tick, so a per-frame comparison is closer to pan. +- **Timer.** A `file://` page is not cross-origin isolated, so `performance.now()` is coarsened to ~0.1 ms. An idle + tick can then read above `IDLE_TICK_MS` and count as a working tick; that is part of every `(1.1 ticks)`. +- **Anchor counter.** The wrapper on `Anchor.prototype.render` that counts visible anchors stays installed during the + walk's measured ticks. + +At the 1600×1000 view the guarantee scene fits at scale ≈ 0.038 (all 10,000 blocks visible). At 0.05 only ~59% of +its blocks are visible. + +The scenes: + +- `guarantee`: the sizes Nirvana guarantees. ~10k blocks with ≥3 anchors, ~10k orthogonal connections with arrows, ~1k + groups. +- `median`: 130 blocks. +- `stress`: 100k blocks. + +Labels and custom elements are not part of these scenes yet (E11, E8). + +```bash +node scripts/bench/culling/scene-run.mjs --scenes median,guarantee --scales 0.05,0.15,0.3,0.6 --modes pan,zoom,drag +node scripts/bench/culling/scene-run.mjs --scenes guarantee --scales 0.05 --modes pan --repeats 1 --profile /tmp/scene-profiles +``` + +`--profile ` writes a Chrome CPU profile of the first `pan` run per column (open it in Chrome DevTools → +Performance). `--shots ` keeps the compared screenshots. + +By default Playwright's Chromium renders with SwiftShader, a software rasterizer: the Canvas2D calls and the canvas +raster then cost main-thread time that a hardware canvas does not. `--gpu` launches Chromium as `run.mjs` does +(`channel: "chromium"`, ANGLE Metal on macOS, no SwiftShader fallback). The second line of the output names the WebGL +renderer, so every run says which one it used. + +`--run-cap N` passes `maxRun` to the interpreter's `drawElements()`: a run of same-style paths and polygons is drawn +after N of them. The column is then named `interpreter-capN`. A list such as `--run-cap none,200` measures one +interpreter column per value; `none` is the unbounded default. Each value must be `none` or a positive integer, and +none may repeat. The cap exists because Chromium's `Path2D.closePath()` costs time in proportion to the path built so +far: one run with all connection arrows of a zoomed-out frame costs quadratic time. + +`cached` (E2b, `--renderers walk,interpreter,cached`) is the interpreter with the connections held in a +`ScenePathBatch` (`zIndex: 1`, cell 8192, chunk 200) instead of the store. The batch caches chunk paths per grid cell +as the walk's `BatchPath2DRenderer` does, so it answers whether the interpreter still loses once it stops +rebuilding connection paths every frame. Known differences from the walk: the bounds carry a 2-unit pad; arrows are +drawn over the lines (the walk draws them under); there is no per-connection label pass; a drag can move items into +other chunks. A drag updates the moved connections with `batch.update()`. `--run-cap` does +not apply to this column (there is one `cached` column and no `maxRun`): its store holds no paths or polygons. Its +visible line adds `batched N chunks M`, the primitives and chunks the batch drew in the frame; `elements` counts store +elements only. Its pixels are compared with `walk` like the other columns. E2b command line (outside the sandbox): + +``` +node scripts/bench/culling/scene-run.mjs --gpu --scenes median --scales 0.05,0.3 \ + --renderers walk,interpreter,cached --run-cap 25 --repeats 1 +``` + +Each mode's line ends with `action N ms`: the mean synchronous cost of the call that performs one measured action +(`column.drag()` or the camera call), timed outside the scheduler ticks. A sample is only the ticks, so the full cost of +an action is the measured ticks plus `action`. In `drag`, `interpreter` and `cached` compute the routes and geometry of +the moved connections inside `action`, while the walk does that work inside its ticks (about 2.1 ticks against 1.1), so +compare drag columns on ticks plus `action`, not on ticks alone. + +`--query-timing [rounds]` (default 40): after the screenshot, each interpreter column times the tile-index query, the +sort of its result, `SceneStore.query()` and one `drawScene()` apart at the measured view, and prints the medians. diff --git a/scripts/bench/culling/scene-run.mjs b/scripts/bench/culling/scene-run.mjs new file mode 100644 index 00000000..94d48db2 --- /dev/null +++ b/scripts/bench/culling/scene-run.mjs @@ -0,0 +1,312 @@ +#!/usr/bin/env node +// scripts/bench/culling/scene-run.mjs +// Scene bench (WebGL scene research, E1, E2, E2b): the working tree only, one column per renderer, scene and scale: +// walk = today's component walk, interpreter = the scene prototype drawing the same picture from data (one column per +// --run-cap value), cached = the interpreter with its connections in a ScenePathBatch (cached chunk paths). +import { chromium } from "@playwright/test"; +import esbuild from "esbuild"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repo = path.resolve(here, "../../.."); +const args = process.argv.slice(2); +function option(name, fallback) { + const index = args.indexOf(`--${name}`); + if (index === -1) return fallback; + const value = args[index + 1]; + return value === undefined || value.startsWith("--") ? true : value; +} +const list = (value) => String(value).split(",").filter(Boolean); + +const scenes = list(option("scenes", "median,guarantee")); +const scales = list(option("scales", "0.05,0.15,0.3,0.6")).map(Number); +const modes = list(option("modes", "pan,zoom,drag")); +const renderers = list(option("renderers", "walk,interpreter")); +for (const renderer of renderers) { + if (!["walk", "interpreter", "cached"].includes(renderer)) { + console.error(`scene-run: --renderers: "${renderer}" is not one of walk, interpreter, cached`); + process.exit(1); + } +} +const repeats = Number(option("repeats", 3)); +const steps = Number(option("steps", 40)); +const headed = Boolean(option("headed", false)); +const profileDir = option("profile", undefined); +const shotsDir = option("shots", undefined); +// --gpu: launch Chromium as run.mjs does, for a hardware canvas (ANGLE Metal on macOS, no SwiftShader fallback). +// Without it Playwright's Chromium renders with SwiftShader, a software rasterizer. +const gpu = Boolean(option("gpu", false)); +// --query-timing [rounds]: after the screenshot, time the interpreter's tile query, sort, store.query() and drawScene() +// apart at the measured view (median of `rounds` rounds, default 40). +const queryTimingOption = option("query-timing", false); +const queryRounds = queryTimingOption === false ? 0 : queryTimingOption === true ? 40 : Number(queryTimingOption); +function fail(message) { + console.error(`scene-run: ${message}`); + process.exit(1); +} +// --run-cap N (or a list such as none,200): the interpreter's maxRun; one interpreter column per value. Each value is +// "none" (unbounded) or a positive integer, and no value may repeat: anything else would mislabel a column. +const runCapValues = list(option("run-cap", "none")); +for (const [index, value] of runCapValues.entries()) { + if (value !== "none" && !/^[1-9]\d*$/.test(value)) fail(`--run-cap: "${value}" is not "none" or a positive integer`); + if (runCapValues.indexOf(value) !== index) fail(`--run-cap: "${value}" is given twice`); +} +const runCaps = runCapValues.map((value) => (value === "none" ? undefined : Number(value))); +// walk and cached (E2b) get no run cap: the cached store holds no paths or polygons. +const columns = renderers.flatMap((renderer) => + renderer === "interpreter" + ? runCaps.map((maxRun) => ({ + renderer, + maxRun, + name: maxRun === undefined ? "interpreter" : `interpreter-cap${maxRun}`, + })) + : [{ renderer, maxRun: undefined, name: renderer }] +); +const VIEW = { width: 1600, height: 1000 }; +// A pixel whose channel differs by more than this is a real difference, not edge anti-aliasing or alpha rounding. +const SEVERE_DELTA = 8; + +const work = fs.mkdtempSync(path.join(os.tmpdir(), "graph-scene-bench-")); + +async function build() { + const source = path.join(repo, "packages/graph/src"); + await esbuild.build({ + entryPoints: [path.join(here, "scene/entry.ts")], + bundle: true, + outfile: path.join(work, "scene.js"), + format: "iife", + platform: "browser", + target: ["es2020"], + tsconfig: path.join(repo, "packages/graph/tsconfig.json"), + alias: { GRAPH: path.join(source, "index.ts"), GRAPH_SRC: source }, + nodePaths: [path.join(repo, "packages/graph/node_modules"), path.join(repo, "node_modules")], + plugins: [ + { + name: "inline-css", + setup(css) { + css.onLoad({ filter: /\.css$/ }, async (file) => ({ + contents: `const s=document.createElement("style");s.textContent=${JSON.stringify( + await fs.promises.readFile(file.path, "utf8") + )};document.head.appendChild(s);`, + loader: "js", + })); + }, + }, + ], + logLevel: "error", + }); + const page = path.join(work, "scene.html"); + fs.writeFileSync( + page, + `` + + `
` + ); + return page; +} + +const sorted = (values) => [...values].sort((a, b) => a - b); +const median = (values) => { + const s = sorted(values); + const m = s.length >> 1; + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; +}; +const percentile = (values, q) => { + const s = sorted(values); + return s[Math.min(s.length - 1, Math.floor(q * s.length))]; +}; +const mean = (values) => values.reduce((sum, value) => sum + value, 0) / values.length; + +async function countDifferentPixels(browser, a, b, severeDelta) { + const page = await browser.newPage(); + const url = (buffer) => `data:image/png;base64,${buffer.toString("base64")}`; + const result = await page.evaluate( + async ([first, second, severeDelta]) => { + const load = (src) => + new Promise((resolve) => { + const image = new Image(); + image.onload = () => resolve(image); + image.src = src; + }); + const pixels = async (src) => { + const image = await load(src); + const canvas = Object.assign(document.createElement("canvas"), { width: image.width, height: image.height }); + const ctx = canvas.getContext("2d"); + ctx.drawImage(image, 0, 0); + return ctx.getImageData(0, 0, image.width, image.height).data; + }; + const [left, right] = await Promise.all([pixels(first), pixels(second)]); + const width = (await load(first)).width; + let different = 0; + let delta = 0; + let severe = 0; + let box = null; + for (let i = 0; i < left.length; i += 4) { + const d = Math.max( + Math.abs(left[i] - right[i]), + Math.abs(left[i + 1] - right[i + 1]), + Math.abs(left[i + 2] - right[i + 2]), + Math.abs(left[i + 3] - right[i + 3]) + ); + if (d > 0) { + different++; + delta = Math.max(delta, d); + if (d > severeDelta) { + severe++; + const pixel = i >> 2; + const x = pixel % width; + const y = (pixel / width) | 0; + box = box + ? [Math.min(box[0], x), Math.min(box[1], y), Math.max(box[2], x), Math.max(box[3], y)] + : [x, y, x, y]; + } + } + } + return { different, delta, severe, box }; + }, + [url(a), url(b), severeDelta] + ); + await page.close(); + return result; +} + +const pagePath = await build(); +const browser = await chromium.launch({ + headless: !headed, + args: [ + "--enable-precise-memory-info", + ...(gpu && process.platform === "darwin" ? ["--use-angle=metal", "--use-gl=angle"] : []), + ], + ...(gpu ? { channel: "chromium", ignoreDefaultArgs: ["--enable-unsafe-swiftshader"] } : {}), +}); +/** The WebGL renderer string: says whether this browser rasterizes on the GPU or with SwiftShader. */ +async function rendererName() { + const page = await browser.newPage(); + const name = await page.evaluate(() => { + const gl = document.createElement("canvas").getContext("webgl2"); + const ext = gl?.getExtension("WEBGL_debug_renderer_info"); + return gl ? gl.getParameter(ext ? ext.UNMASKED_RENDERER_WEBGL : gl.RENDERER) : "no webgl2"; + }); + await page.close(); + return name; +} +console.log( + `repeats=${repeats} steps=${steps} view=${VIEW.width}x${VIEW.height} columns=${columns.map((c) => c.name).join(",")}` +); +console.log(`chromium ${browser.version()} gpu=${gpu} renderer: ${await rendererName()}`); +for (const scene of scenes) { + for (const scale of scales) { + const shots = {}; + for (const { renderer, maxRun, name } of columns) { + const results = Object.fromEntries( + modes.map((mode) => [mode, { samples: [], frames: [], ticks: [], timeouts: 0, actionMs: [] }]) + ); + let first = []; + let sizes; + let counts; + let timing; + for (let repeat = 0; repeat < repeats; repeat++) { + const context = await browser.newContext({ viewport: VIEW, deviceScaleFactor: 2 }); + const page = await context.newPage(); + await page.goto(`file://${pagePath}`); + sizes = await page.evaluate((options) => window.bench.setup(options), { + scene, + renderer, + width: VIEW.width, + height: VIEW.height, + scale, + maxRun, + }); + first.push(...sizes.first); + for (const mode of modes) { + const profiling = profileDir && repeat === 0 && mode === "pan"; + const cdp = profiling ? await context.newCDPSession(page) : undefined; + if (cdp) { + await cdp.send("Profiler.enable"); + await cdp.send("Profiler.start"); + } + const result = await page.evaluate((options) => window.bench.run(options), { + mode, + warmup: 10, + steps, + scale, + }); + if (cdp) { + const { profile } = await cdp.send("Profiler.stop"); + fs.mkdirSync(profileDir, { recursive: true }); + fs.writeFileSync( + path.join(profileDir, `${scene}-${scale}-${name}-pan.cpuprofile`), + JSON.stringify(profile) + ); + } + results[mode].samples.push(...result.samples); + results[mode].frames.push(...result.frames); + results[mode].ticks.push(...result.ticks); + results[mode].timeouts += result.timeouts; + results[mode].actionMs.push(result.actionMs); + } + if (repeat === 0) { + counts = await page.evaluate((options) => window.bench.visible(options), { scale }); + await page.evaluate((options) => window.bench.settle(options), { scale }); + shots[name] = await page.screenshot(); + if (shotsDir) { + fs.mkdirSync(shotsDir, { recursive: true }); + fs.writeFileSync(path.join(shotsDir, `${scene}-${scale}-${name}.png`), shots[name]); + } + // After the screenshot: drawScene() below draws over the picture. + if (queryRounds) { + timing = await page.evaluate((options) => window.bench.queryTiming(options), { + scale, + rounds: queryRounds, + }); + } + } + await context.close(); + } + const parts = modes.map((mode) => { + const r = results[mode]; + const timeouts = r.timeouts ? ` timeouts ${r.timeouts}` : ""; + const frames = `${median(r.frames).toFixed(1)}/${percentile(r.frames, 0.95).toFixed(1)}`; + const ticks = mean(r.ticks).toFixed(1); + const action = ` action ${mean(r.actionMs).toFixed(2)} ms`; + return `${mode} ${mean(r.samples).toFixed(2)} ms (${ticks} ticks) frames ${frames}${timeouts}${action}`; + }); + console.log( + `${scene.padEnd(9)} scale=${String(scale).padEnd(5)} ${name.padEnd(18)} ` + + `[${sizes.blocks} blocks ${sizes.connections} connections ${sizes.groups} groups] ` + + `first two frames ${Math.round(first.reduce((sum, value) => sum + value, 0) / repeats)} ms | ` + + `${parts.join(" | ")} | visible blocks ${counts.blocks} anchors ${counts.anchors} ` + + `elements ${counts.elements}` + + (counts.batched === undefined ? "" : ` batched ${counts.batched} chunks ${counts.chunks}`) + ); + if (timing && renderer === "cached") { + console.log(" query timing cached: connections are not in the store; they live in the ScenePathBatch"); + } + if (timing) { + const ms = (value) => `${value.toFixed(3)} ms`; + console.log( + ` query timing ${name} (median of ${queryRounds}, ${timing.elements} elements): ` + + `index query ${ms(timing.indexQuery)} | sort ${ms(timing.sort)} | store.query ${ms(timing.storeQuery)} | ` + + `drawScene ${ms(timing.drawScene)}` + ); + } + } + for (const { name } of columns) { + if (name === "walk" || !shots.walk || !shots[name]) continue; + const { different, delta, severe, box } = await countDifferentPixels( + browser, + shots.walk, + shots[name], + SEVERE_DELTA + ); + console.log( + ` pixels ${name} vs walk: ${different} px differ by up to ${delta} | ` + + `severe (>${SEVERE_DELTA}): ${severe}` + + (box ? ` in box x ${box[0]}..${box[2]} y ${box[1]}..${box[3]}` : "") + ); + } + } +} +await browser.close(); diff --git a/scripts/bench/culling/scene/entry.ts b/scripts/bench/culling/scene/entry.ts new file mode 100644 index 00000000..75e5f25d --- /dev/null +++ b/scripts/bench/culling/scene/entry.ts @@ -0,0 +1,210 @@ +// scripts/bench/culling/scene/entry.ts +// Browser side of the scene bench (E1, E2): one frame = one tick of the global scheduler, every layer included. +import { Anchor } from "GRAPH"; +import type { Graph } from "GRAPH"; + +import { buildScene } from "./scenes"; +import type { TBenchScene, TSceneKind } from "./scenes"; +import { setupInterpreter } from "./interpreter"; +import { resetWalkCounters, setupWalk } from "./walk"; +import type { TColumn, TQueryTiming } from "./walk"; + +const raf = (): Promise => new Promise((resolve) => requestAnimationFrame(resolve)); + +const state: { + column?: TColumn; + scene?: TBenchScene; + samples: number[]; + center: { x: number; y: number }; + anchors: number; +} = { + samples: [], + center: { x: 0, y: 0 }, + anchors: 0, +}; + +function graph(): Graph { + if (!state.column) throw new Error("setup() first"); + return state.column.graph; +} + +function centerAt(scale: number): void { + const camera = graph().cameraService; + const { width, height } = camera.getCameraState(); + camera.set({ scale, x: width / 2 - state.center.x * scale, y: height / 2 - state.center.y * scale }); +} + +/** A scheduler tick shorter than this did no work: nothing was scheduled by the action any more. */ +const IDLE_TICK_MS = 0.05; +/** An action's work is summed over at most this many consecutive ticks. */ +const MAX_ACTION_TICKS = 5; + +/** Waits up to 20 frames for a scheduler tick after `previous` samples; false when none arrived. */ +async function waitForTick(previous: number): Promise { + for (let i = 0; i < 20 && state.samples.length === previous; i++) await raf(); + return state.samples.length !== previous; +} + +function instrument(target: Graph): void { + const scheduler = target.scheduler as unknown as { performUpdate(): void }; + const original = scheduler.performUpdate.bind(scheduler); + scheduler.performUpdate = (): void => { + const start = performance.now(); + original(); + state.samples.push(performance.now() - start); + }; + const anchorProto = Anchor.prototype as unknown as { render(): void }; + const render = anchorProto.render; + anchorProto.render = function (this: unknown): void { + state.anchors++; + render.call(this); + }; +} + +async function setup(options: { + scene: TSceneKind; + renderer: "walk" | "interpreter" | "cached"; + width: number; + height: number; + scale: number; + /** Interpreter only: ends a path run after this many paths and polygons (drawElements' maxRun). */ + maxRun?: number; +}): Promise<{ blocks: number; connections: number; groups: number; first: number[] }> { + const root = document.getElementById("root") as HTMLDivElement; + root.style.width = `${options.width}px`; + root.style.height = `${options.height}px`; + const scene = buildScene(options.scene); + state.scene = scene; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const block of scene.blocks) { + minX = Math.min(minX, block.x); + minY = Math.min(minY, block.y); + maxX = Math.max(maxX, block.x + block.width); + maxY = Math.max(maxY, block.y + block.height); + } + state.center = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }; + if (options.renderer === "walk") state.column = setupWalk(root, scene); + else if (options.renderer === "cached") state.column = setupInterpreter(root, scene, { batchConnections: true }); + else state.column = setupInterpreter(root, scene, { maxRun: options.maxRun }); + instrument(state.column.graph); + centerAt(options.scale); + for (let i = 0; i < 10; i++) await raf(); + const first = state.samples.slice(0, 2); + state.samples = []; + return { blocks: scene.blocks.length, connections: scene.connections.length, groups: scene.groups.length, first }; +} + +/** Same history for every column before measuring (JIT feedback), then `warmup + steps` measured actions. */ +async function run(options: { + mode: "pan" | "zoom" | "drag"; + warmup: number; + steps: number; + scale: number; +}): Promise<{ samples: number[]; frames: number[]; timeouts: number; ticks: number[]; actionMs: number }> { + if (options.warmup < 1) throw new Error("run() needs warmup >= 1: the first zoom step does not change the scale"); + const column = state.column; + if (!column) throw new Error("setup() first"); + const camera = column.graph.cameraService; + centerAt(0.01); + await raf(); + for (let i = 0; i < 60; i++) { + const previous = state.samples.length; + const k = i < 30 ? i / 30 : (60 - i) / 30; + const { width, height } = camera.getCameraState(); + camera.zoom(width / 2, height / 2, 0.01 * (1 + 3 * k)); + await waitForTick(previous); + } + centerAt(options.scale); + await raf(); + await raf(); + const base = camera.getCameraState().scale; + const measured: number[] = []; + const tickCounts: number[] = []; + const stamps: number[] = []; + /** The synchronous cost of each measured action call (outside the scheduler ticks), summed. */ + let actionTotal = 0; + let timeouts = 0; + for (let i = 0; i < options.warmup + options.steps; i++) { + const previous = state.samples.length; + const actionStart = performance.now(); + if (options.mode === "pan") { + const direction = Math.floor(i / 15) % 2 === 0 ? 1 : -1; + camera.move(2 * direction, direction); + } else if (options.mode === "zoom") { + const phase = i % 30; + const { width, height } = camera.getCameraState(); + camera.zoom(width / 2, height / 2, base * (1 + 0.003 * (phase < 15 ? phase : 30 - phase))); + } else { + column.drag(i % 2 ? 0 : 3); + } + if (i >= options.warmup) actionTotal += performance.now() - actionStart; + const ticked = await waitForTick(previous); + const stamp = performance.now(); + // The work of one action is every tick from the first one until the first idle tick (walk drag spills into the + // next one). + let work = 0; + let worked = 0; + if (ticked) { + let index = previous; + while (worked < MAX_ACTION_TICKS) { + if (state.samples.length === index) await raf(); + if (state.samples.length === index) break; + const duration = state.samples[index++]; + if (duration < IDLE_TICK_MS) break; + work += duration; + worked++; + } + } + if (i >= options.warmup) { + if (ticked) { + measured.push(work); + tickCounts.push(worked); + } else timeouts++; + stamps.push(stamp); + } + } + if (options.mode === "drag") { + column.drag(0); + await raf(); + } + const frames = stamps.slice(1).map((stamp, index) => stamp - stamps[index]); + return { samples: measured, frames, timeouts, ticks: tickCounts, actionMs: actionTotal / options.steps }; +} + +async function settle(options: { scale: number }): Promise { + centerAt(options.scale); + for (let i = 0; i < 10; i++) await raf(); +} + +/** Blocks, anchors and scene elements drawn by one frame at the measured view. */ +async function visible(options: { + scale: number; +}): Promise<{ blocks: number; anchors: number; elements: number; batched?: number; chunks?: number }> { + const column = state.column; + if (!column) throw new Error("setup() first"); + centerAt(options.scale); + await raf(); + resetWalkCounters(); + state.anchors = 0; + const previous = state.samples.length; + column.graph.cameraService.move(1, 0); + await waitForTick(previous); + const counts = column.visible(); + // Counts core Anchor.render calls: non-zero only in the walk column; the interpreter draws anchors as primitives + // inside `elements`. + return { ...counts, anchors: state.anchors }; +} + +/** Interpreter only: the query, the sort and the drawing of one frame at the measured view, timed apart. */ +async function queryTiming(options: { scale: number; rounds: number }): Promise { + const column = state.column; + if (!column?.timeQuery) return undefined; + centerAt(options.scale); + for (let i = 0; i < 3; i++) await raf(); + return column.timeQuery(options.rounds); +} + +(window as unknown as { bench: unknown }).bench = { setup, run, settle, visible, queryTiming }; diff --git a/scripts/bench/culling/scene/interpreter.ts b/scripts/bench/culling/scene/interpreter.ts new file mode 100644 index 00000000..0596f08f --- /dev/null +++ b/scripts/bench/culling/scene/interpreter.ts @@ -0,0 +1,195 @@ +// E2: the same picture from scene data. The graph has no entities: it only provides the camera, the below layer and +// the scheduler. A SceneCanvasLayer draws groups (z 0), connections (z 1) and blocks with their anchors (z 2). +import { Graph, getSegmentsBounds, multipointGeometry } from "GRAPH"; +import { SceneCanvasLayer } from "GRAPH_SRC/lib/scene/SceneCanvasLayer"; +import { ScenePathBatch } from "GRAPH_SRC/lib/scene/ScenePathBatch"; +import { SceneStore, compareSceneOrder } from "GRAPH_SRC/lib/scene/SceneStore"; +import type { TileIndex, TTileRect } from "GRAPH_SRC/lib/culling/TileIndex"; +import type { TSceneElement, TScenePrimitive } from "GRAPH_SRC/lib/scene/types"; + +import { anchorPrimitives, blockBounds, blockPrimitives, connectionPrimitives, groupPrimitives } from "./look"; +import { routePoints } from "./scenes"; +import type { TBenchBlock, TBenchConnection, TBenchScene } from "./scenes"; +import { dragTarget } from "./walk"; +import type { TColumn, TQueryTiming } from "./walk"; + +type TGeometry = { segments: Float32Array; arrow?: { points: Float32Array; closed: boolean } }; + +function connectionGeometry(points: Array<{ x: number; y: number }>, connection: TBenchConnection): TGeometry { + const input = { + source: points[0], + target: points[points.length - 1], + points, + useBezier: false, + bezierDirection: "horizontal", + connection, + }; + return (multipointGeometry as unknown as (value: typeof input) => TGeometry)(input); +} + +function connectionElement(geometry: TGeometry): { bounds: TTileRect; primitives: TScenePrimitive[] } { + // getSegmentsBounds(segments, tight): tight = exact cubic extrema, as with EXP_USE_TIGHT_BEZIER_BOUNDS. + const box = getSegmentsBounds(geometry.segments, true) ?? [0, 0, 0, 0]; + let [minX, minY, maxX, maxY] = box; + const arrow = geometry.arrow?.points ?? new Float32Array(); + for (let i = 0; i + 1 < arrow.length; i += 2) { + minX = Math.min(minX, arrow[i]); + maxX = Math.max(maxX, arrow[i]); + minY = Math.min(minY, arrow[i + 1]); + maxY = Math.max(maxY, arrow[i + 1]); + } + const pad = 2; + return { + bounds: { minX: minX - pad, minY: minY - pad, maxX: maxX + pad, maxY: maxY + pad }, + primitives: connectionPrimitives(geometry), + }; +} + +/** + * `batchConnections` (E2b, the `cached` column): the connections go to a ScenePathBatch, which caches their chunk paths + * per grid cell as the walk's BatchPath2DRenderer does, instead of the store, which rebuilds them every frame. + */ +export function setupInterpreter( + root: HTMLDivElement, + scene: TBenchScene, + options: { maxRun?: number; batchConnections?: boolean } = {} +): TColumn { + const graph = new Graph( + { settings: { canDragCamera: true, canZoomCamera: true } } as unknown as ConstructorParameters[0], + root + ); + const store = new SceneStore(1024); + const batch = options.batchConnections ? new ScenePathBatch({ zIndex: 1 }) : undefined; + for (const group of scene.groups) { + const { x, y, width, height } = group.rect; + store.add({ + id: group.id, + zIndex: 0, + x, + y, + bounds: { minX: -1, minY: -1, maxX: width + 1, maxY: height + 1 }, + primitives: groupPrimitives(width, height), + }); + } + for (const connection of scene.connections) { + const element = connectionElement(connectionGeometry(connection.points, connection)); + if (batch) batch.add({ id: connection.id, x: 0, y: 0, ...element }); + else store.add({ id: connection.id, zIndex: 1, x: 0, y: 0, ...element }); + } + for (const block of scene.blocks) { + store.add({ + id: block.id, + zIndex: 2, + x: block.x, + y: block.y, + bounds: blockBounds(block.height), + primitives: [...blockPrimitives(block.meta, block.height), ...anchorPrimitives(block.meta)], + }); + } + // The walk column's hit-test usable rect (HitTest.updateUsableRect: the union of the hit boxes of blocks, groups and + // connections) tints the below layer's background. This graph has no components, so the same rect is set by hand: + // otherwise the below layer paints the outside colour. Read back from the walk column it is identical (median: + // -20,-20 4480x3090; guarantee: -20,-20 41860x25970): the groups bound it, so no hit-box padding matters here. + { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + const extend = (x0: number, y0: number, x1: number, y1: number): void => { + minX = Math.min(minX, x0); + minY = Math.min(minY, y0); + maxX = Math.max(maxX, x1); + maxY = Math.max(maxY, y1); + }; + for (const block of scene.blocks) extend(block.x, block.y, block.x + block.width, block.y + block.height); + for (const { rect } of scene.groups) extend(rect.x, rect.y, rect.x + rect.width, rect.y + rect.height); + for (const connection of scene.connections) { + for (const point of connection.points) extend(point.x, point.y, point.x, point.y); + } + graph.hitTest.$usableRect.value = { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + } + const layer = graph.addLayer(SceneCanvasLayer, { + store, + maxRun: options.maxRun, + batches: batch ? [batch] : undefined, + }); + graph.start(); + + const target = dragTarget(scene); + const byId = new Map(scene.blocks.map((block) => [block.id, block])); + const touching = scene.connections.filter( + (connection) => connection.sourceBlockId === target.id || connection.targetBlockId === target.id + ); + const index = (anchorId: string): number => Number(anchorId.replace(/^.*_(in|out)/, "")); + return { + graph, + drag(offset: number): void { + const moved: TBenchBlock = { ...target, x: target.x + offset }; + store.update(target.id, { x: moved.x }); + for (const connection of touching) { + const source = + connection.sourceBlockId === target.id ? moved : (byId.get(connection.sourceBlockId) as TBenchBlock); + const destination = + connection.targetBlockId === target.id ? moved : (byId.get(connection.targetBlockId) as TBenchBlock); + const points = routePoints( + source, + index(connection.sourceAnchorId), + destination, + index(connection.targetAnchorId) + ); + const element = connectionElement(connectionGeometry(points, connection)); + if (batch) batch.update(connection.id, element); + else store.update(connection.id, element); + } + layer.performRender(); + }, + visible(): { blocks: number; anchors: number; elements: number; batched?: number; chunks?: number } { + const { elements, batchedPrimitives, chunks } = layer.stats; + return { blocks: 0, anchors: 0, elements, ...(batch ? { batched: batchedPrimitives, chunks } : {}) }; + }, + timeQuery(rounds: number): TQueryTiming { + // SceneStore.query() is the tile-index query followed by a sort; the index is private, so it is reached here to + // time the two parts apart. Each part runs REPS times per round so that the 0.1 ms timer resolution matters less. + const REPS = 5; + const tiles = (store as unknown as { index: TileIndex }).index; + const viewport = layer.getWorldViewport(); + const collect = (): TSceneElement[] => { + const found: TSceneElement[] = []; + tiles.query(viewport, (element) => { + if (!element.hidden) found.push(element); + }); + return found; + }; + const parts = { + indexQuery: [] as number[], + sort: [] as number[], + storeQuery: [] as number[], + drawScene: [] as number[], + }; + let elements = 0; + for (let round = 0; round < rounds; round++) { + let start = performance.now(); + for (let r = 0; r < REPS; r++) collect(); + parts.indexQuery.push((performance.now() - start) / REPS); + const copies = Array.from({ length: REPS }, collect); + start = performance.now(); + for (const copy of copies) copy.sort(compareSceneOrder); + parts.sort.push((performance.now() - start) / REPS); + start = performance.now(); + for (let r = 0; r < REPS; r++) elements = store.query(viewport).length; + parts.storeQuery.push((performance.now() - start) / REPS); + start = performance.now(); + layer.drawScene(viewport); + parts.drawScene.push(performance.now() - start); + } + const median = (values: number[]): number => [...values].sort((a, b) => a - b)[values.length >> 1]; + return { + elements, + indexQuery: median(parts.indexQuery), + sort: median(parts.sort), + storeQuery: median(parts.storeQuery), + drawScene: median(parts.drawScene), + }; + }, + }; +} diff --git a/scripts/bench/culling/scene/look.ts b/scripts/bench/culling/scene/look.ts new file mode 100644 index 00000000..3cf04e3d --- /dev/null +++ b/scripts/bench/culling/scene/look.ts @@ -0,0 +1,222 @@ +// scripts/bench/culling/scene/look.ts +// The Nirvana-like look of the scene bench. Both columns draw it: walk through NirvanaLikeBlock, interpreter through +// scene elements. Colours repeat the defaults of packages/graph/src/graphConfig.ts, so no setColors() is needed. +import type { TTileRect } from "GRAPH_SRC/lib/culling/TileIndex"; +import type { TScenePrimitive, TSceneStroke } from "GRAPH_SRC/lib/scene/types"; + +export const COLORS = { + blockText: "#272727", + blockBorder: "#dfdfdf", + anchor: "#4a4a4a", + connection: "#272727", + progress: "#5b8def", + icon: "#5c5c5c", +}; + +export type TStatus = "queued" | "running" | "completed" | "failed" | "cancelled"; + +export const STATUSES: readonly TStatus[] = ["queued", "running", "completed", "failed", "cancelled"]; + +const STATUS_FILL: Record = { + queued: "#e0e0e0", + running: "#cfe3ff", + completed: "#d6f0d6", + failed: "#f8d4d4", + cancelled: "#ececec", +}; + +export type TBlockMeta = { + status: TStatus; + progress: number; + name: string; + subname: string; + version: string; + icons: number; + inputs: number; + outputs: number; +}; + +export const BLOCK = { width: 240, header: 72, row: 26, footer: 40, radius: 8, border: 2, anchorRadius: 9 }; + +/** Core block text threshold and the default LOD thresholds. */ +export const TEXT_SCALE = 0.125; +export const SCHEMATIC_SCALE = 0.225; +export const DETAILED_SCALE = 0.7; + +export const GROUP_STYLE = { + background: "rgba(100, 100, 100, 0.1)", + border: "rgba(100, 100, 100, 0.3)", + borderWidth: 2, + selectedBackground: "rgba(100, 100, 100, 0.2)", + selectedBorder: "rgba(100, 100, 100, 0.5)", + highlightedBackground: "rgba(100, 200, 100, 0.3)", + highlightedBorder: "rgba(100, 200, 100, 0.8)", +}; + +export const CONNECTION_STROKE: TSceneStroke = { color: COLORS.connection, width: 2 }; + +const ICON_VIEWBOX = 16; +const ICON_SOURCES = ["M2 2h12v12H2z", "M8 1l7 14H1z", "M8 1a7 7 0 1 1-0.01 0z", "M2 6h12v4H2zM6 2h4v12H6z"]; +let icons: Path2D[] | undefined; + +/** Path2D exists only in the browser; build the icons on first use. */ +function icon(index: number): Path2D { + icons ??= ICON_SOURCES.map((source) => new Path2D(source)); + return icons[index % icons.length]; +} + +export function blockHeight(meta: TBlockMeta): number { + return BLOCK.header + BLOCK.row * Math.max(meta.inputs, meta.outputs) + BLOCK.footer; +} + +export function anchorPosition(isInput: boolean, index: number): { x: number; y: number } { + return { x: isInput ? 0 : BLOCK.width, y: BLOCK.header + BLOCK.row * (index + 0.5) }; +} + +/** Body, status, progress, texts and icons; anchors are separate (core components in the walk column). */ +export function blockPrimitives(meta: TBlockMeta, height: number): TScenePrimitive[] { + const width = BLOCK.width; + const primitives: TScenePrimitive[] = [ + { + kind: "rect", + x: 0, + y: 0, + width, + height, + radius: BLOCK.radius, + fill: STATUS_FILL[meta.status], + border: { color: COLORS.blockBorder, width: BLOCK.border, align: "outside" }, + }, + ]; + if (meta.status === "running") { + primitives.push({ + kind: "rect", + x: 0, + y: height - 6, + width: width * meta.progress, + height: 6, + radius: [0, 0, 0, BLOCK.radius], + fill: COLORS.progress, + }); + } + primitives.push( + { + kind: "text", + text: meta.name, + family: "sans-serif", + weight: "bold", + size: 16, + lineHeight: 20, + color: COLORS.blockText, + x: 12, + y: 12, + width: width - 24, + height: 40, + align: "center", + minScale: TEXT_SCALE, + }, + { + kind: "text", + text: meta.subname, + family: "sans-serif", + size: 13, + lineHeight: 16, + color: COLORS.blockText, + x: 12, + y: 52, + width: width - 24, + height: 16, + align: "center", + minScale: SCHEMATIC_SCALE, + }, + { + kind: "text", + text: meta.version, + family: "sans-serif", + size: 12, + lineHeight: 14, + color: COLORS.blockText, + x: 12, + y: height - 28, + width: 80, + height: 14, + align: "left", + minScale: SCHEMATIC_SCALE, + } + ); + for (let i = 0; i < meta.icons; i++) { + primitives.push({ + kind: "icon", + path: icon(i), + viewBox: ICON_VIEWBOX, + x: width - 12 - (i + 1) * 20, + y: height - 30, + size: 16, + color: COLORS.icon, + minScale: SCHEMATIC_SCALE, + }); + } + return primitives; +} + +/** Anchors as the core Anchor draws them: a circle of radius 9, hidden at the Detailed level. */ +export function anchorPrimitives(meta: TBlockMeta): TScenePrimitive[] { + const primitives: TScenePrimitive[] = []; + const add = (isInput: boolean, count: number): void => { + for (let i = 0; i < count; i++) { + const point = anchorPosition(isInput, i); + primitives.push({ + kind: "circle", + cx: point.x, + cy: point.y, + r: BLOCK.anchorRadius, + fill: COLORS.anchor, + maxScale: DETAILED_SCALE, + }); + } + }; + add(true, meta.inputs); + add(false, meta.outputs); + return primitives; +} + +export function blockBounds(height: number): TTileRect { + const side = Math.max(BLOCK.border, BLOCK.anchorRadius); + return { minX: -side, minY: -BLOCK.border, maxX: BLOCK.width + side, maxY: height + BLOCK.border }; +} + +/** + * The default Group look: a rounded rect with a centred border. The walk column defines groups with zero padding, + * so both columns draw exactly the given rect. + */ +export function groupPrimitives(width: number, height: number): TScenePrimitive[] { + return [ + { + kind: "rect", + x: 0, + y: 0, + width, + height, + radius: 8, + fill: GROUP_STYLE.background, + border: { color: GROUP_STYLE.border, width: GROUP_STYLE.borderWidth }, + }, + ]; +} + +/** What Path2DLine draws for a multipoint geometry: the line, and a closed arrow filled and stroked. */ +export function connectionPrimitives(geometry: { + segments: Float32Array; + arrow?: { points: Float32Array; closed: boolean }; +}): TScenePrimitive[] { + const primitives: TScenePrimitive[] = [{ kind: "path", segments: geometry.segments, stroke: CONNECTION_STROKE }]; + if (geometry.arrow?.closed) { + primitives.push({ + kind: "polygon", + points: geometry.arrow.points, + fill: COLORS.connection, + stroke: CONNECTION_STROKE, + }); + } + return primitives; +} diff --git a/scripts/bench/culling/scene/scenes.ts b/scripts/bench/culling/scene/scenes.ts new file mode 100644 index 00000000..bdf6e67b --- /dev/null +++ b/scripts/bench/culling/scene/scenes.ts @@ -0,0 +1,170 @@ +// scripts/bench/culling/scene/scenes.ts +// Deterministic scenes of the scene bench. "guarantee" follows the sizes Nirvana guarantees (user, 2026-11-09): +// ~10k blocks with at least 3 anchors each, ~10k connections, ~1k groups. "median" is Nirvana's median graph, +// "stress" the 100k stress case. +import { STATUSES, anchorPosition, blockHeight } from "./look"; +import type { TBlockMeta } from "./look"; + +export type TSceneKind = "guarantee" | "median" | "stress"; + +export type TBenchBlock = { + id: string; + is: "nirvana-like"; + x: number; + y: number; + width: number; + height: number; + selected: false; + name: string; + anchors: Array<{ id: string; blockId: string; type: "IN" | "OUT"; index: number }>; + meta: TBlockMeta; +}; + +export type TBenchConnection = { + id: string; + sourceBlockId: string; + sourceAnchorId: string; + targetBlockId: string; + targetAnchorId: string; + points: Array<{ x: number; y: number }>; +}; + +export type TBenchGroup = { id: string; rect: { x: number; y: number; width: number; height: number } }; + +export type TBenchScene = { blocks: TBenchBlock[]; connections: TBenchConnection[]; groups: TBenchGroup[] }; + +const SIZES: Record = { + guarantee: { blocks: 10000, blocksPerGroup: 10 }, + median: { blocks: 130, blocksPerGroup: 10 }, + stress: { blocks: 100000, blocksPerGroup: 10 }, +}; + +const COLUMN_STEP = 420; +const ROW_STEP = 260; +const WORDS = ["Extract", "features", "from", "the", "dataset", "and", "join", "with", "dictionary", "by", "key"]; + +function mulberry32(seed: number): () => number { + return () => { + let t = (seed += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** An orthogonal route from an output anchor to an input anchor, through the middle of the gap. */ +export function routePoints( + source: TBenchBlock, + sourceIndex: number, + target: TBenchBlock, + targetIndex: number +): Array<{ x: number; y: number }> { + const from = anchorPosition(false, sourceIndex); + const to = anchorPosition(true, targetIndex); + const sx = source.x + from.x; + const sy = source.y + from.y; + const tx = target.x + to.x; + const ty = target.y + to.y; + if (sy === ty) + return [ + { x: sx, y: sy }, + { x: tx, y: ty }, + ]; + const middle = (sx + tx) / 2; + return [ + { x: sx, y: sy }, + { x: middle, y: sy }, + { x: middle, y: ty }, + { x: tx, y: ty }, + ]; +} + +export function buildScene(kind: TSceneKind): TBenchScene { + const random = mulberry32(9); + const { blocks: count, blocksPerGroup } = SIZES[kind]; + const columns = Math.ceil(Math.sqrt(count)); + const blocks: TBenchBlock[] = []; + for (let i = 0; i < count; i++) { + const column = Math.floor(i / columns); + const row = i % columns; + const inputs = 1 + Math.floor(random() * 2); + const outputs = 2 + Math.floor(random() * 2); + const words = 2 + Math.floor(random() * 5); + const name = Array.from({ length: words }, () => WORDS[Math.floor(random() * WORDS.length)]).join(" "); + const meta: TBlockMeta = { + status: STATUSES[Math.floor(random() * STATUSES.length)], + progress: random(), + name: `${name} ${i}`, + subname: `team-${i % 37}/operation`, + version: `v${1 + (i % 5)}.${i % 10}.0`, + icons: Math.floor(random() * 7), + inputs, + outputs, + }; + const id = `b${i}`; + const anchors: TBenchBlock["anchors"] = []; + for (let k = 0; k < inputs; k++) anchors.push({ id: `${id}_in${k}`, blockId: id, type: "IN", index: k }); + for (let k = 0; k < outputs; k++) anchors.push({ id: `${id}_out${k}`, blockId: id, type: "OUT", index: k }); + blocks.push({ + id, + is: "nirvana-like", + x: column * COLUMN_STEP, + y: row * ROW_STEP, + width: 240, + height: blockHeight(meta), + selected: false, + name: meta.name, + anchors, + meta, + }); + } + + const connections: TBenchConnection[] = []; + for (let i = columns; i < count; i++) { + const target = blocks[i]; + const column = Math.floor(i / columns); + const sourceRow = Math.floor(random() * columns); + const source = blocks[(column - 1) * columns + sourceRow]; + const sourceIndex = Math.floor(random() * source.meta.outputs); + const targetIndex = Math.floor(random() * target.meta.inputs); + connections.push({ + id: `c${i}`, + sourceBlockId: source.id, + sourceAnchorId: `${source.id}_out${sourceIndex}`, + targetBlockId: target.id, + targetAnchorId: `${target.id}_in${targetIndex}`, + points: routePoints(source, sourceIndex, target, targetIndex), + }); + } + + // Groups cover tiles of 2 columns × (blocksPerGroup / 2) rows. + const groups: TBenchGroup[] = []; + const rowsPerGroup = Math.max(1, Math.floor(blocksPerGroup / 2)); + const groupColumns = Math.ceil(Math.ceil(count / columns) / 2); + const groupRows = Math.ceil(columns / rowsPerGroup); + for (let gx = 0; gx < groupColumns; gx++) { + for (let gy = 0; gy < groupRows; gy++) { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (let c = gx * 2; c < gx * 2 + 2; c++) { + for (let r = gy * rowsPerGroup; r < (gy + 1) * rowsPerGroup && r < columns; r++) { + const block = blocks[c * columns + r]; + if (!block) continue; + minX = Math.min(minX, block.x); + minY = Math.min(minY, block.y); + maxX = Math.max(maxX, block.x + block.width); + maxY = Math.max(maxY, block.y + block.height); + } + } + if (minX === Infinity) continue; + const pad = 20; + groups.push({ + id: `g${gx}_${gy}`, + rect: { x: minX - pad, y: minY - pad, width: maxX - minX + 2 * pad, height: maxY - minY + 2 * pad }, + }); + } + } + return { blocks, connections, groups }; +} diff --git a/scripts/bench/culling/scene/sizes.check.ts b/scripts/bench/culling/scene/sizes.check.ts new file mode 100644 index 00000000..f56277cb --- /dev/null +++ b/scripts/bench/culling/scene/sizes.check.ts @@ -0,0 +1,13 @@ +// scripts/bench/culling/scene/sizes.check.ts — prints scene sizes; run through esbuild (see the plan, Task 4). +import { buildScene } from "./scenes"; + +for (const kind of ["median", "guarantee", "stress"] as const) { + const scene = buildScene(kind); + const anchors = scene.blocks.reduce((sum, block) => sum + block.anchors.length, 0); + console.log(kind, { + blocks: scene.blocks.length, + anchors, + connections: scene.connections.length, + groups: scene.groups.length, + }); +} diff --git a/scripts/bench/culling/scene/walk.ts b/scripts/bench/culling/scene/walk.ts new file mode 100644 index 00000000..e31add1b --- /dev/null +++ b/scripts/bench/culling/scene/walk.ts @@ -0,0 +1,155 @@ +// E1: today's path. Every frame walks NirvanaLikeBlock components and their core anchors; connections are +// MultipointConnection on the Path2D batch; groups are a BlockGroups layer. +import { batch } from "@preact/signals-core"; +import { BlockGroups, CanvasBlock, ECanDrag, Graph, Group, MultipointConnection } from "GRAPH"; +import { drawElements } from "GRAPH_SRC/lib/scene/drawPrimitives"; +import type { TSceneElement } from "GRAPH_SRC/lib/scene/types"; + +import { GROUP_STYLE, anchorPosition, blockBounds, blockPrimitives } from "./look"; +import type { TBlockMeta } from "./look"; +import { routePoints } from "./scenes"; +import type { TBenchBlock, TBenchScene } from "./scenes"; + +/** Median ms per call of the parts of one interpreter frame, measured directly (`--query-timing`). */ +export type TQueryTiming = { + elements: number; + indexQuery: number; + sort: number; + storeQuery: number; + drawScene: number; +}; + +export type TColumn = { + graph: Graph; + drag(offset: number): void; + visible(): { blocks: number; anchors: number; elements: number }; + /** Interpreter only. */ + timeQuery?(rounds: number): TQueryTiming; +}; + +/** The CanvasBlock members this block uses; they are protected in the library. */ +interface IBlockBase { + state: { id: string; x: number; y: number; meta: TBlockMeta; height: number }; + context: { camera: { getCameraScale(): number } }; +} +const BlockBase = CanvasBlock as unknown as new (...args: unknown[]) => IBlockBase; + +const counters = { blocks: 0 }; + +/** Draws the shared look through drawElements(), so both columns run the same drawing code. */ +class NirvanaLikeBlock extends BlockBase { + /** The block as a scene element; its primitives are rebuilt only when the block's meta object changes. */ + private element?: TSceneElement; + + private elementMeta?: TBlockMeta; + + public getAnchorPosition(anchor: { id: string; type: string }): { x: number; y: number } { + return anchorPosition(anchor.type === "IN", Number(anchor.id.replace(/^.*_(in|out)/, ""))); + } + + private draw(ctx: CanvasRenderingContext2D): void { + const { id, x, y, meta, height } = this.state; + if (!this.element || this.elementMeta !== meta) { + this.elementMeta = meta; + this.element = { + id, + zIndex: 2, + sequence: 0, + x, + y, + bounds: blockBounds(height), + primitives: blockPrimitives(meta, height), + hidden: false, + }; + } + this.element.x = x; + this.element.y = y; + counters.blocks++; + drawElements(ctx, [this.element], this.context.camera.getCameraScale()); + } + + public renderMinimalisticBlock(ctx: CanvasRenderingContext2D): void { + this.draw(ctx); + } + + public renderSchematicView(ctx: CanvasRenderingContext2D): void { + this.draw(ctx); + } + + public renderDetailedView(ctx: CanvasRenderingContext2D): void { + this.draw(ctx); + } +} + +/** The block with the most connections: the one the drag mode moves. */ +export function dragTarget(scene: TBenchScene): TBenchBlock { + const degree = new Map(); + for (const connection of scene.connections) { + for (const id of [connection.sourceBlockId, connection.targetBlockId]) degree.set(id, (degree.get(id) ?? 0) + 1); + } + let best = scene.blocks[0]; + for (const block of scene.blocks) if ((degree.get(block.id) ?? 0) > (degree.get(best.id) ?? 0)) best = block; + return best; +} + +export function setupWalk(root: HTMLDivElement, scene: TBenchScene): TColumn { + const graph = new Graph( + { + settings: { + canDragCamera: true, + canZoomCamera: true, + canDrag: ECanDrag.ALL, + useBlocksAnchors: true, + showConnectionArrows: true, + showConnectionLabels: false, + connection: MultipointConnection, + blockComponents: { "nirvana-like": NirvanaLikeBlock }, + }, + } as unknown as ConstructorParameters[0], + root + ); + const BenchGroup = Group.define({ style: GROUP_STYLE, geometry: { padding: [0, 0, 0, 0] } }); + const groups = graph.addLayer(BlockGroups, { draggable: false }); + groups.setGroups(scene.groups.map((group) => ({ id: group.id, rect: group.rect, component: BenchGroup }))); + graph.setEntities(scene as unknown as Parameters[0]); + graph.start(); + + const target = dragTarget(scene); + const byId = new Map(scene.blocks.map((block) => [block.id, block])); + const touching = scene.connections.filter( + (connection) => connection.sourceBlockId === target.id || connection.targetBlockId === target.id + ); + const index = (anchorId: string): number => Number(anchorId.replace(/^.*_(in|out)/, "")); + return { + graph, + drag(offset: number): void { + const moved = { ...target, x: target.x + offset }; + batch(() => { + graph.api.updateBlock({ id: target.id, x: moved.x }); + for (const connection of touching) { + const source = + connection.sourceBlockId === target.id ? moved : (byId.get(connection.sourceBlockId) as TBenchBlock); + const destination = + connection.targetBlockId === target.id ? moved : (byId.get(connection.targetBlockId) as TBenchBlock); + const points = routePoints( + source, + index(connection.sourceAnchorId), + destination, + index(connection.targetAnchorId) + ); + graph.api.updateConnection(connection.id, { points } as unknown as Parameters< + Graph["api"]["updateConnection"] + >[1]); + } + }); + }, + visible(): { blocks: number; anchors: number; elements: number } { + return { blocks: counters.blocks, anchors: 0, elements: 0 }; + }, + }; +} + +/** Resets the per-frame counters; the harness calls it before the frame it counts. */ +export function resetWalkCounters(): void { + counters.blocks = 0; +}