diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 0679570..62e4110 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -9,6 +9,7 @@ import { CanvasRendererCommandsContext, type CanvasRendererCommands } from "@/li import { defaultCanvasRenderers } from "./canvas-renderers"; import type { Data, ElementRecord, Geometry, Id, Json, Result } from "@/lib/canvas-core/types"; import { equal } from "@/lib/canvas-core/values"; +import {createGeometryProjectionCache} from "@/lib/canvas-bindings/geometry-cache"; import { createProjectionCache } from "@/lib/canvas-bindings/projection-cache"; import { ancestors, commonCanvas } from "@/lib/canvas-core/document"; import { projectCanvas, toLocal, transformPath, type ProjectionNode } from "@/lib/canvas-bindings/projection"; @@ -43,10 +44,11 @@ function CanvasView({ engine, board, viewId, renderers }: { engine: ProjectEngin const pointerStart = useRef<{ x: number; y: number } | null>(null), drag = useRef<{ token: number; start: { x: number; y: number }; positions: Map } | null>(null); const projectionCache = useRef | null>(null); projectionCache.current ??= createProjectionCache(); + const geometryCache=useRef|null>(null);geometryCache.current??=createGeometryProjectionCache(); const measurements = useRef(new Map()); const imageInput = useRef(null), groups = useRef(new Map()); const projection = useMemo(() => { - const result = projectCanvas(root.document, view.canvasId, engine.editor.registry, view.selection, engine.sessions.effectiveGeometry, sessions.connectionGesture,renderers.catalog); + const result = projectCanvas(root.document, view.canvasId, engine.editor.registry, view.selection, engine.sessions.effectiveGeometry, sessions.connectionGesture,renderers.catalog,{queries:engine.editor.getQueries(root.document),geometry:geometryCache.current!,owner:engine}); // Preserve renderer measurements across metadata/preview updates. Dropping them // makes React Flow discard handle bounds and unmount active edge controls. const visibleIds = new Set(result.nodes.map(node => node.id)); diff --git a/src/lib/canvas-bindings/geometry-cache.ts b/src/lib/canvas-bindings/geometry-cache.ts new file mode 100644 index 0000000..2403b77 --- /dev/null +++ b/src/lib/canvas-bindings/geometry-cache.ts @@ -0,0 +1,21 @@ +import type {Geometry,Id,Json} from '../canvas-core/types.ts'; +import {childCanvasTransform,toWorld,transformPath,type Transform} from '../canvas-core/coordinates.ts'; +import {equal,clone,freeze} from '../canvas-core/values.ts'; +const sameTransform=(a:Transform,b:Transform)=>a.x===b.x&&a.y===b.y&&a.scale===b.scale&&a.rotation===b.rotation; +const sameGeometry=(a:Geometry,b:Geometry)=>a.x===b.x&&a.y===b.y&&a.width===b.width&&a.height===b.height&&a.rotation===b.rotation; +const units=(value:Json|undefined)=>value&&typeof value==='object'&&!Array.isArray(value)?[value.contentScale,value.unitScale]:[]; +/** View-local numeric/path projection only. No editable records, history, visibility or task facts. */ +export function createGeometryProjectionCache(){ + let owner:object|undefined,project:Id|undefined,canvas:Id|undefined,nodesComputed=0,childrenComputed=0,edgesComputed=0; + type NodeResult={position:{x:number;y:number};width:number;height:number;rotation:number}; + const nodes=new Map(),children=new Map(),edges=new Map();let seenNodes=new Set(),seenChildren=new Set(),seenEdges=new Set(); + return Object.freeze({ + begin(scope:object,projectId:Id,canvasId:Id){if(owner!==scope||project!==projectId||canvas!==canvasId){nodes.clear();children.clear();edges.clear();owner=scope;project=projectId;canvas=canvasId;}seenNodes=new Set();seenChildren=new Set();seenEdges=new Set();}, + node(id:Id,incarnation:number,g:Geometry,t:Transform):NodeResult{seenNodes.add(id);const before=nodes.get(id);if(before&&before.incarnation===incarnation&&sameGeometry(before.geometry,g)&&sameTransform(before.transform,t))return before.value;const center=toWorld(t,{x:g.x+g.width/2,y:g.y+g.height/2}),width=g.width*t.scale,height=g.height*t.scale,value=freeze({position:{x:center.x-width/2,y:center.y-height/2},width,height,rotation:(g.rotation+t.rotation)%360});nodes.set(id,{incarnation,geometry:{...g},transform:{...t},value});nodesComputed++;return value;}, + child(id:Id,incarnation:number,g:Geometry,t:Transform,presentation:Json|undefined):Transform{seenChildren.add(id);const before=children.get(id),currentUnits=units(presentation);if(before&&before.incarnation===incarnation&&sameGeometry(before.geometry,g)&&sameTransform(before.transform,t)&&equal(before.units,currentUnits))return before.value;const value=freeze(childCanvasTransform(t,g,presentation));children.set(id,{incarnation,geometry:{...g},transform:{...t},units:currentUnits,value});childrenComputed++;return value;}, + edge(id:Id,incarnation:number,path:Json,t:Transform):Json{seenEdges.add(id);const before=edges.get(id);if(before&&before.incarnation===incarnation&&equal(before.path,path)&&sameTransform(before.transform,t))return before.value;const captured=freeze(clone(path)),value=freeze(transformPath(captured,t));edges.set(id,{incarnation,path:captured,transform:{...t},value});edgesComputed++;return value;}, + end(){for(const id of nodes.keys())if(!seenNodes.has(id))nodes.delete(id);for(const id of children.keys())if(!seenChildren.has(id))children.delete(id);for(const id of edges.keys())if(!seenEdges.has(id))edges.delete(id);}, + stats:()=>Object.freeze({nodesComputed,childrenComputed,edgesComputed,nodes:nodes.size,children:children.size,edges:edges.size}), + }); +} +export type GeometryProjectionCache=ReturnType; diff --git a/src/lib/canvas-bindings/projection.ts b/src/lib/canvas-bindings/projection.ts index 4b39daa..869330a 100644 --- a/src/lib/canvas-bindings/projection.ts +++ b/src/lib/canvas-bindings/projection.ts @@ -1,3 +1,5 @@ +import type {DocumentQueries} from '../canvas-core/queries.ts'; +import type {GeometryProjectionCache} from './geometry-cache.ts'; import type { Document, ElementRecord, Geometry, Id, Json, Point, Registry } from '../canvas-core/types.ts'; import { builtinRendererCatalog, type RendererCatalog } from './renderers.ts'; import { connectionPathValid } from '../canvas-core/features.ts'; @@ -16,45 +18,47 @@ export type ProjectionEdge=Omit&{data:EdgeData&{__headle const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {}; /** Renderer projection retains identity/owner in the model, including nested and collapsed canvases. */ -export function projectCanvas(document: Document, canvasId: Id, registry: Registry, selected: ReadonlySet | { has(id: Id): boolean }, geometry: (id: Id) => Geometry | undefined, edgePreview?: { target: { id: Id }; path: Json } | null, renderers:RendererCatalog=builtinRendererCatalog) { +export function projectCanvas(document: Document, canvasId: Id, registry: Registry, selected: ReadonlySet | { has(id: Id): boolean }, geometry: (id: Id) => Geometry | undefined, edgePreview?: { target: { id: Id }; path: Json } | null, renderers:RendererCatalog=builtinRendererCatalog,services:{queries?:DocumentQueries;geometry?:GeometryProjectionCache;owner?:object}={}) { if (!document.canvases.has(canvasId)) throw fault('canvas_not_loaded', [canvasId]); + const queries=services.queries,cache=services.geometry;if(queries&&queries.document!==document)throw fault('stale_query_snapshot');cache?.begin(services.owner??cache,document.projectId,canvasId); const transforms = new Map([[canvasId, origin]]), hiddenBy = new Map(), childByEntry = new Map(); - for (const canvas of document.canvases.values()) if (canvas.parent) childByEntry.set(canvas.parent.entryElementId, canvas.id); + if(!queries)for (const canvas of document.canvases.values()) if (canvas.parent) childByEntry.set(canvas.parent.entryElementId, canvas.id); const members = new Map(); - for (const element of document.elements.values()) { const list = members.get(element.ownerCanvasId) ?? []; list.push(element); members.set(element.ownerCanvasId, list); } + if(!queries)for (const element of document.elements.values()) { const list = members.get(element.ownerCanvasId) ?? []; list.push(element); members.set(element.ownerCanvasId, list); } + const getMembers=(id:Id)=>queries?queries.members(id).map(id=>document.elements.get(id)!):members.get(id)??[]; const nodes: ProjectionNode[] = [], visible = new Set(); const visit = (id: Id, depth: number) => { const transform = transforms.get(id)!; - for (const element of members.get(id) ?? []) if (element.structure === 'node') { - const g = geometry(element.id) ?? element.geometry, center = toWorld(transform, { x: g.x + g.width / 2, y: g.y + g.height / 2 }); - const width = g.width * transform.scale, height = g.height * transform.scale, known = registry.resolve(element) === 'known'; + for (const element of getMembers(id)) if (element.structure === 'node') { + const g = geometry(element.id) ?? element.geometry, projectedGeometry=cache?.node(element.id,element.incarnation,g,transform),center = projectedGeometry?undefined:toWorld(transform, { x: g.x + g.width / 2, y: g.y + g.height / 2 }); + const width = projectedGeometry?.width??g.width * transform.scale, height = projectedGeometry?.height??g.height * transform.scale, known = registry.resolve(element) === 'known'; const resolved=registry.resolve(element),renderer=renderers.resolve(element); const fallback:FallbackProjection|undefined=!known||!renderer?{reason:resolved==='known'?'renderer-unavailable':resolved,componentId:element.componentId,dataVersion:element.dataVersion,handles:[]}:undefined; const type = fallback ? 'unsupported' : renderer!.rendererId; - const child = childByEntry.get(element.id), data = element.data; - const projected: ProjectionNode = { id: element.id, type, position: { x: center.x - width / 2, y: center.y - height / 2 }, width, height, selected: selected.has(element.id), draggable: known && registry.supports(element, 'geometry.move'), zIndex: element.componentId === 'frame' ? depth - 100 : depth * 2, + const child = queries?queries.relations.childCanvasByEntry.get(element.id):childByEntry.get(element.id), data = element.data; + const projected: ProjectionNode = { id: element.id, type, position: projectedGeometry?{...projectedGeometry.position}:{ x: center!.x - width / 2, y: center!.y - height / 2 }, width, height, selected: selected.has(element.id), draggable: known && registry.supports(element, 'geometry.move'), zIndex: element.componentId === 'frame' ? depth - 100 : depth * 2, style: { width, height, ...(element.componentId === 'frame'&&!fallback&&renderer?.projection==='compat-v1' ? { pointerEvents: 'none' as const } : {}) }, - data: { ...(fallback?{}:data), kind: element.componentId as CanvasNode['data']['kind'], title: typeof data.title === 'string' ? data.title : element.componentId, text: !fallback&&typeof data.text === 'string' ? data.text : '', __headlessFallback:fallback, __headlessRecord:!fallback&&renderer?.projection==='record-v1'?element:undefined,__headlessTransform:!fallback&&renderer?.projection==='record-v1'?transform:undefined, rotation: (g.rotation + transform.rotation) % 360, viewScale: transform.scale*(!fallback?unitScale(data.presentation):1), ...(child ? { subgraph: { boardId: child } } : {}) } as CanvasNode['data'], + data: { ...(fallback?{}:data), kind: element.componentId as CanvasNode['data']['kind'], title: typeof data.title === 'string' ? data.title : element.componentId, text: !fallback&&typeof data.text === 'string' ? data.text : '', __headlessFallback:fallback, __headlessRecord:!fallback&&renderer?.projection==='record-v1'?element:undefined,__headlessTransform:!fallback&&renderer?.projection==='record-v1'?transform:undefined, rotation: projectedGeometry?.rotation??(g.rotation + transform.rotation) % 360, viewScale: transform.scale*(!fallback?unitScale(data.presentation):1), ...(child ? { subgraph: { boardId: child } } : {}) } as CanvasNode['data'], }; nodes.push(projected); visible.add(element.id); if (child) { const presentation = object(data.presentation); if (!!fallback || presentation.collapsed === true) { - const stack = [child]; while (stack.length) { const hidden = stack.pop()!; hiddenBy.set(hidden, element.id); for (const canvas of document.canvases.values()) if (canvas.parent?.canvasId === hidden) stack.push(canvas.id); } + const stack = [child]; while (stack.length) { const hidden = stack.pop()!; hiddenBy.set(hidden, element.id); if(queries)stack.push(...queries.childCanvases(hidden));else for (const canvas of document.canvases.values()) if (canvas.parent?.canvasId === hidden) stack.push(canvas.id); } } else { - transforms.set(child, childCanvasTransform(transform,g,data.presentation)); visit(child, depth + 1); + transforms.set(child, cache?cache.child(child,element.incarnation,g,transform,data.presentation):childCanvasTransform(transform,g,data.presentation)); visit(child, depth + 1); } } } }; visit(canvasId, 0); const edges: ProjectionEdge[] = []; - for (const element of document.elements.values()) if (element.structure === 'connection' && transforms.has(element.ownerCanvasId)) { + for (const element of queries?queries.orderedElements([...transforms.keys()].flatMap(id=>queries.members(id).filter(id=>document.elements.get(id)?.structure==='connection'))).map(id=>document.elements.get(id)!):document.elements.values()) if (element.structure === 'connection' && transforms.has(element.ownerCanvasId)) { const endpoint = (id: Id) => visible.has(id) ? id : hiddenBy.get(document.elements.get(id)?.ownerCanvasId ?? ''); const source = endpoint(element.source.elementId), target = endpoint(element.target.elementId); if (!source || !target || source === target) continue; const renderer=renderers.resolve(element),fallbackEdge=registry.resolve(element)!=='known'||!renderer; - const path = fallbackEdge||renderer?.projection==='record-v1' ? undefined : transformPath(edgePreview?.target.id === element.id ? edgePreview.path : element.path, transforms.get(element.ownerCanvasId)!); + const path = fallbackEdge||renderer?.projection==='record-v1' ? undefined : (cache?cache.edge(element.id,element.incarnation,edgePreview?.target.id===element.id?edgePreview.path:element.path,transforms.get(element.ownerCanvasId)!):transformPath(edgePreview?.target.id === element.id ? edgePreview.path : element.path, transforms.get(element.ownerCanvasId)!)); const renderPath = path!==undefined&&connectionPathValid(path) ? path : undefined; const { path: unusedPath, ...edgeData } = element.data; void unusedPath; edges.push({ id: element.id, ...(fallbackEdge?{type:'unsupported'}:renderer!.rendererId==='default'?{}:{type:renderer!.rendererId}), source, target, sourceHandle: source === element.source.elementId ? element.source.portId : undefined, targetHandle: target === element.target.elementId ? element.target.portId : undefined, selected: selected.has(element.id), data: fallbackEdge?{}:renderer?.projection==='record-v1'?{__headlessRecord:element,__headlessTransform:transforms.get(element.ownerCanvasId)}:{ ...edgeData, ...(renderPath ? { path: renderPath } : {}) } as EdgeData }); @@ -77,5 +81,6 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist const field=end==='source'?'sourceHandle':'targetHandle',handle=edge[field]|| (end==='source'?'right':'left');edge[field]=handle; const seen=handleIds.get(edge[end])!;if(!seen.has(handle)){seen.add(handle);fallback.handles.push({id:handle,side:end==='source'?'right':'left'});} } + cache?.end(); return { nodes, edges, transforms }; } diff --git a/src/lib/canvas-core/editor.ts b/src/lib/canvas-core/editor.ts index 04ee364..e857123 100644 --- a/src/lib/canvas-core/editor.ts +++ b/src/lib/canvas-core/editor.ts @@ -2,6 +2,7 @@ import type { Applied, Availability, Capability, Change, Command, Commit, CoreSn import { applyChanges, createDocument, invert, targetElement, validateDocument } from './document.ts'; import { clone, equal, fault, freeze, readMap, reject, validJson } from './values.ts'; +import {createDocumentQueries,advanceDocumentQueries} from './queries.ts'; import { canvasTransform, convertGeometry } from './coordinates.ts'; import { unitScale, withUnitScale } from './units.ts'; import { removalChanges, repairConnectionOwners } from './structure.ts'; @@ -31,6 +32,8 @@ export function createEditor(options: EditorOptions = Object.freeze({ revision: options.recovery?.revision ?? 0, rootRevision: options.recovery?.rootRevision ?? 0, document, history: options.recovery?.history ?? Object.freeze({ undo: Object.freeze([]), redo: Object.freeze([]), entries: readMap(new Map()) }), operations: options.recovery?.operations ?? readMap(new Map()), runtime: options.runtime as Runtime }); + let queries=createDocumentQueries(document,options.registry,state.revision); + const queriesByDocument=new WeakMap([[document,queries]]); let entered = false, disposed = false, sequence = 0; for (const intent of state.operations.values()) sequence = Math.max(sequence, intent.sequence); const listeners = new Set<(commit: Commit, snapshot: Snapshot) => void>(); @@ -72,7 +75,7 @@ export function createEditor(options: EditorOptions(options: EditorOptions(options: EditorOptions state, registry: options.registry, dispatch, transaction, can, + return Object.freeze({ getSnapshot: () => state,getQueries:(document=state.document)=>{const value=queriesByDocument.get(document);if(!value)throw fault('query_snapshot_unavailable');return value;}, registry: options.registry, dispatch, transaction, can, subscribe(listener: (commit: Commit, snapshot: Snapshot) => void) { if (disposed) throw fault('editor_disposed'); listeners.add(listener); return () => { listeners.delete(listener); }; }, /** Engine-only metadata commit, sharing the document/history publication boundary. */ updateRuntime(reduce: (runtime: Runtime) => Runtime, expectedRootRevision = state.rootRevision): Result { @@ -291,7 +295,8 @@ export function createEditor(options: EditorOptions!operations.has(operationId)))throw fault('invalid_history'); } const changed = !documentsEqual(next, state.document); - state = Object.freeze({ ...state, ...(replacement?{history:replacement.history,operations:replacement.operations}:{}), document: changed ? next : state.document, runtime, revision: state.revision + Number(changed), rootRevision: state.rootRevision + 1 }); + const nextState=Object.freeze({ ...state, ...(replacement?{history:replacement.history,operations:replacement.operations}:{}), document: changed ? next : state.document, runtime, revision: state.revision + Number(changed), rootRevision: state.rootRevision + 1 }),nextQueries=changed?advanceDocumentQueries(queries,next,options.registry,state.revision+1):queries; + state=nextState;queries=nextQueries;if(changed)queriesByDocument.set(next,queries); sequence=nextSequence; emit(freeze({ revision: state.revision, origin: 'ack', changes: [], operationIds: [] })); return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) }); diff --git a/src/lib/canvas-core/index.ts b/src/lib/canvas-core/index.ts index b3d9012..435daf8 100644 --- a/src/lib/canvas-core/index.ts +++ b/src/lib/canvas-core/index.ts @@ -13,3 +13,6 @@ export {planDuplicate,planDuplicateSteps,remapCopyData} from './duplicate.ts'; export type {DuplicatePlan,DuplicateOptions} from './duplicate.ts'; export {createDuplicatePlanner,validateDuplicateCheckpoint} from './duplicate-checkpoint.ts'; export type {DuplicateCheckpoint} from './duplicate-checkpoint.ts'; + +export {createDocumentQueries,advanceDocumentQueries} from './queries.ts'; +export type {DocumentQueries,Bounds} from './queries.ts'; diff --git a/src/lib/canvas-core/queries.ts b/src/lib/canvas-core/queries.ts new file mode 100644 index 0000000..93a4f12 --- /dev/null +++ b/src/lib/canvas-core/queries.ts @@ -0,0 +1,75 @@ +import type {Change,Document,ElementRecord,Geometry,Id,Point,ReadonlyValueSet,Registry} from './types.ts'; +import {copyReferenceIds} from './copy-paths.ts'; +import {equal,fault,readMap,readSet} from './values.ts'; +export type Bounds={readonly x:number;readonly y:number;readonly width:number;readonly height:number}; +type Box={readonly minX:number;readonly minY:number;readonly maxX:number;readonly maxY:number}; +type Relation=Map>; +type Spatial={cells:Relation;entries:Map;oversized:ReadonlyValueSet}; +type References={elements:ReadonlySet;canvases:ReadonlySet;complete:boolean}; +type Tables={registry:Registry;members:Relation;children:Relation;childByEntry:Map;incident:Relation;elementRefs:Relation;canvasRefs:Relation;forward:Map;opaque:ReadonlyValueSet;spatial:Map;order:Map;canvasOrder:Map}; +const cellSize=512,maxElementCells=256,maxQueryCells=4096; +const privateTables=new WeakMap(); +const empty=readSet([]); +const orderedBuckets=new WeakMap>(); +function sortedBucket(bucket:ReadonlyValueSet,ranks:Map){let versions=orderedBuckets.get(bucket);if(!versions){versions=new WeakMap();orderedBuckets.set(bucket,versions);}let ids=versions.get(ranks);if(!ids){ids=Object.freeze([...bucket].sort((a,b)=>ranks.get(a)!-ranks.get(b)!));versions.set(ranks,ids);}return ids;} +/** Copy only touched buckets. Final buckets expose no native Set mutation methods. */ +function editRelation(base:Relation){const changed=new Map>();return{change(key:Id,id:Id,add:boolean){const current=changed.get(key)??base.get(key);if(!!current?.has(id)===add)return;let bucket=changed.get(key);if(!bucket){bucket=new Set(current??[]);changed.set(key,bucket);}if(add)bucket.add(id);else bucket.delete(id);},finish(){let next=base;for(const[key,ids]of changed){const before=base.get(key);if((before?.size??0)===ids.size&&[...ids].every(id=>before!.has(id)))continue;if(next===base)next=new Map(base);if(ids.size)next.set(key,readSet(ids));else next.delete(key);}return next;}};} +function nodeBox(g:Geometry):Box{const angle=g.rotation*Math.PI/180,c=Math.abs(Math.cos(angle)),s=Math.abs(Math.sin(angle)),halfX=(g.width*c+g.height*s)/2,halfY=(g.width*s+g.height*c)/2,x=g.x+g.width/2,y=g.y+g.height/2,epsilon=Number.EPSILON*32*Math.max(1,Math.abs(x),Math.abs(y),g.width,g.height);return Object.freeze({minX:x-halfX-epsilon,minY:y-halfY-epsilon,maxX:x+halfX+epsilon,maxY:y+halfY+epsilon});} +function cells(box:Box,limit:number):string[]|undefined{ + const x0=Math.floor(box.minX/cellSize),x1=Math.floor(box.maxX/cellSize),y0=Math.floor(box.minY/cellSize),y1=Math.floor(box.maxY/cellSize),count=(x1-x0+1)*(y1-y0+1); + if(![x0,x1,y0,y1,count].every(Number.isSafeInteger)||count<1||count>limit)return; + const keys:string[]=[];for(let x=x0;x<=x1;x++)for(let y=y0;y<=y1;y++)keys.push(`${x},${y}`);return keys; +} +function refs(record:ElementRecord,registry:Registry):References{ + const elements=new Set(),canvases=new Set(),schema=registry.components.get(record.componentId)?.copy; + if(registry.resolve(record)!=='known'||!schema)return{elements,canvases,complete:false}; + try{for(const ref of schema.references)for(const id of copyReferenceIds(record.data,ref))(ref.target==='element'?elements:canvases).add(id);return{elements,canvases,complete:true};}catch{return{elements:new Set(),canvases:new Set(),complete:false};} +} +function order(previous:Map|undefined,ids:Iterable){const list=[...ids];if(previous&&previous.size===list.length&&list.every((id,index)=>previous.get(id)===index))return previous;return new Map(list.map((id,index)=>[id,index]));} +function protectSpatial(spatial:Spatial){return Object.freeze({cells:readMap(spatial.cells),entries:readMap(spatial.entries),oversized:spatial.oversized});} +function intersect(a:Box,b:Box){return a.minX<=b.maxX&&a.maxX>=b.minX&&a.minY<=b.maxY&&a.maxY>=b.minY;} +function finite(box:Box){return Object.values(box).every(Number.isFinite);} +export type DocumentQueries={ + readonly document:Document;readonly revision:number; + readonly relations:{readonly ownerMembers:ReadonlyMap>;readonly canvasChildren:ReadonlyMap>;readonly childCanvasByEntry:ReadonlyMap;readonly incidentEdges:ReadonlyMap>;readonly elementReferrers:ReadonlyMap>;readonly canvasReferrers:ReadonlyMap>;readonly opaqueReferences:ReadonlyValueSet;readonly referenceComplete:boolean}; + readonly spatial:ReadonlyMap>;readonly entries:ReadonlyMap;readonly oversized:ReadonlyValueSet}>; + members(canvas:Id):readonly Id[];childCanvases(canvas:Id):readonly Id[];incident(element:Id):readonly Id[];orderedElements(ids:Iterable):readonly Id[];orderedCanvases(ids:Iterable):readonly Id[];candidateNodes(canvas:Id,rect:Bounds):readonly Id[];hitNodeBounds(canvas:Id,point:Point,tolerance?:number):{readonly ids:readonly Id[];readonly complete:boolean}; +}; +function expose(document:Document,revision:number,tables:Tables,previous?:DocumentQueries):DocumentQueries{ + const sorted=(ids:Iterable,ranks=tables.order)=>Object.freeze([...ids].filter(id=>ranks.has(id)).sort((a,b)=>ranks.get(a)!-ranks.get(b)!)); + const relation=Object.freeze({ownerMembers:previous&&tables.members===privateTables.get(previous)!.members?previous.relations.ownerMembers:readMap(tables.members),canvasChildren:previous&&tables.children===privateTables.get(previous)!.children?previous.relations.canvasChildren:readMap(tables.children),childCanvasByEntry:previous&&tables.childByEntry===privateTables.get(previous)!.childByEntry?previous.relations.childCanvasByEntry:readMap(tables.childByEntry),incidentEdges:previous&&tables.incident===privateTables.get(previous)!.incident?previous.relations.incidentEdges:readMap(tables.incident),elementReferrers:previous&&tables.elementRefs===privateTables.get(previous)!.elementRefs?previous.relations.elementReferrers:readMap(tables.elementRefs),canvasReferrers:previous&&tables.canvasRefs===privateTables.get(previous)!.canvasRefs?previous.relations.canvasReferrers:readMap(tables.canvasRefs),opaqueReferences:tables.opaque,referenceComplete:tables.opaque.size===0}); + const spatial=previous&&tables.spatial===privateTables.get(previous)!.spatial?previous.spatial:readMap(new Map([...tables.spatial].map(([id,value])=>[id,previous&&privateTables.get(previous)!.spatial.get(id)===value?previous.spatial.get(id)!:protectSpatial(value)]))); + function candidates(canvas:Id,rect:Bounds){ + if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);if(![rect.x,rect.y,rect.width,rect.height].every(Number.isFinite)||rect.width<0||rect.height<0)throw fault('invalid_query_bounds'); + const table=tables.spatial.get(canvas);if(!table)return Object.freeze([]) as readonly Id[];const box={minX:rect.x,minY:rect.y,maxX:rect.x+rect.width,maxY:rect.y+rect.height},keys=cells(box,maxQueryCells),ids=new Set(keys?table.oversized:table.entries.keys());if(keys)for(const key of keys)for(const id of table.cells.get(key)??[])ids.add(id); + return sorted([...ids].filter(id=>{const entry=table.entries.get(id)!;return !finite(entry)||intersect(box,entry);})); + } + const view=Object.freeze({document,revision,relations:relation,spatial, + members:(canvas:Id)=>{if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);return sortedBucket(tables.members.get(canvas)??empty,tables.order);},childCanvases:(canvas:Id)=>{if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);return sortedBucket(tables.children.get(canvas)??empty,tables.canvasOrder);},incident:(element:Id)=>{if(!document.elements.has(element))throw fault('element_missing',[element]);return sortedBucket(tables.incident.get(element)??empty,tables.order);},orderedElements:(ids:Iterable)=>sorted(ids),orderedCanvases:(ids:Iterable)=>sorted(ids,tables.canvasOrder),candidateNodes:candidates, + hitNodeBounds(canvas:Id,point:Point,tolerance=0){if(![point.x,point.y,tolerance].every(Number.isFinite)||tolerance<0)throw fault('invalid_query_point');const ids=candidates(canvas,{x:point.x-tolerance,y:point.y-tolerance,width:2*tolerance,height:2*tolerance}),hits:Id[]=[];let complete=true;for(const id of ids){const g=(document.elements.get(id)! as Extract).geometry,box=tables.spatial.get(canvas)!.entries.get(id)!;if(!finite(box)){complete=false;continue;}const angle=-g.rotation*Math.PI/180,c=Math.cos(angle),s=Math.sin(angle),dx=point.x-g.x-g.width/2,dy=point.y-g.y-g.height/2,x=dx*c-dy*s,y=dx*s+dy*c,ox=Math.max(0,Math.abs(x)-g.width/2),oy=Math.max(0,Math.abs(y)-g.height/2);if(Math.hypot(ox,oy)<=tolerance+Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),g.width,g.height))hits.push(id);}return Object.freeze({ids:Object.freeze(hits),complete});}, + });privateTables.set(view,tables);return view; +} +/** Pure derived query snapshot. Never serialize these tables or publish them as a second document. */ +export function createDocumentQueries(document:Document,registry:Registry,revision=0):DocumentQueries{return advanceDocumentQueries(undefined,document,registry,revision);} +export function advanceDocumentQueries(previous:DocumentQueries|undefined,document:Document,registry:Registry,revision:number,changes?:readonly Change[]):DocumentQueries{ + if(!Number.isSafeInteger(revision)||revision<0||previous&&(previous.document.projectId!==document.projectId||revisionchange.kind!=='canvas').map(change=>change.kind==='field'?change.target.id:change.id)):new Set([...(oldDoc?.elements.keys()??[]),...document.elements.keys()]),canvasIds=changes?new Set(changes.filter(change=>change.kind==='canvas').map(change=>change.id)):new Set([...(oldDoc?.canvases.keys()??[]),...document.canvases.keys()]); + const members=editRelation(old?.members??new Map()),children=editRelation(old?.children??new Map()),incident=editRelation(old?.incident??new Map()),elementRefs=editRelation(old?.elementRefs??new Map()),canvasRefs=editRelation(old?.canvasRefs??new Map());let childByEntry=old?.childByEntry??new Map(),forward=old?.forward??new Map(),opaque=old?.opaque??empty,spatial=old?.spatial??new Map(); + const replaced=new Set(),dataFields=new Map>();if(changes)for(const change of changes){if(change.kind==='element')replaced.add(change.id);else if(change.kind==='field'&&change.field.startsWith('data.')){let fields=dataFields.get(change.target.id);if(!fields){fields=new Set();dataFields.set(change.target.id,fields);}fields.add(change.field.slice(5));}} + const spatialChanges=new Map>(); + const spatialChange=(canvas:Id,id:Id,box:Box|null)=>{let list=spatialChanges.get(canvas);if(!list){list=new Map();spatialChanges.set(canvas,list);}list.set(id,box);}; + for(const id of ids){const before=oldDoc?.elements.get(id),after=document.elements.get(id);if(before===after)continue; + if(before?.ownerCanvasId!==after?.ownerCanvasId){if(before)members.change(before.ownerCanvasId,id,false);if(after)members.change(after.ownerCanvasId,id,true);} + const beforeEndpoints=before?.structure==='connection'?new Set([before.source.elementId,before.target.elementId]):new Set(),afterEndpoints=after?.structure==='connection'?new Set([after.source.elementId,after.target.elementId]):new Set();for(const endpoint of beforeEndpoints)if(!afterEndpoints.has(endpoint))incident.change(endpoint,id,false);for(const endpoint of afterEndpoints)if(!beforeEndpoints.has(endpoint))incident.change(endpoint,id,true); + const oldRefs=forward.get(id),schema=after?registry.components.get(after.componentId)?.copy:undefined,referenceFieldsUnchanged=!!changes&&!replaced.has(id)&&!!schema&&schema.references.every(ref=>!dataFields.get(id)?.has(ref.path[0])),nextRefs=after?(before&&oldRefs&&before.componentId===after.componentId&&before.dataVersion===after.dataVersion&&(referenceFieldsUnchanged||equal(before.data,after.data))?oldRefs:refs(after,registry)):undefined; + if(oldRefs!==nextRefs)for(const kind of ['elements','canvases'] as const){const table=kind==='elements'?elementRefs:canvasRefs;for(const target of oldRefs?.[kind]??[])if(!nextRefs?.[kind].has(target))table.change(target,id,false);for(const target of nextRefs?.[kind]??[])if(!oldRefs?.[kind].has(target))table.change(target,id,true);} + if(oldRefs!==nextRefs&&(!!oldRefs?.complete!==!!nextRefs?.complete||!equal([...(oldRefs?.elements??[])],[...(nextRefs?.elements??[])])||!equal([...(oldRefs?.canvases??[])],[...(nextRefs?.canvases??[])])||!oldRefs&&nextRefs||oldRefs&&!nextRefs)){if(forward===old?.forward)forward=new Map(forward);if(nextRefs)forward.set(id,nextRefs);else forward.delete(id);} + const shouldOpaque=!!after&&!nextRefs?.complete;if(opaque.has(id)!==shouldOpaque){const next=new Set(opaque);if(shouldOpaque)next.add(id);else next.delete(id);opaque=readSet(next);} + const was=before?.structure==='node'?before:undefined,now=after?.structure==='node'?after:undefined;if(was?.ownerCanvasId!==now?.ownerCanvasId||!equal(was?.geometry,now?.geometry)){if(was)spatialChange(was.ownerCanvasId,id,null);if(now)spatialChange(now.ownerCanvasId,id,nodeBox(now.geometry));} + } + for(const id of canvasIds){const before=oldDoc?.canvases.get(id)?.parent,after=document.canvases.get(id)?.parent;if(equal(before,after))continue;if(before){children.change(before.canvasId,id,false);if(childByEntry===old?.childByEntry)childByEntry=new Map(childByEntry);childByEntry.delete(before.entryElementId);}} + for(const id of canvasIds){const before=oldDoc?.canvases.get(id)?.parent,after=document.canvases.get(id)?.parent;if(equal(before,after)||!after)continue;children.change(after.canvasId,id,true);if(childByEntry===old?.childByEntry)childByEntry=new Map(childByEntry);childByEntry.set(after.entryElementId,id);} + if(spatialChanges.size){spatial=new Map(spatial);for(const[canvas,updates]of spatialChanges){const base=spatial.get(canvas),entries=new Map(base?.entries),grid=editRelation(base?.cells??new Map()),large=new Set(base?.oversized??[]);for(const[id,box]of updates){const before=entries.get(id);if(before)for(const key of cells(before,maxElementCells)??[])grid.change(key,id,false);large.delete(id);if(box){entries.set(id,box);const keys=cells(box,maxElementCells);if(keys)for(const key of keys)grid.change(key,id,true);else large.add(id);}else entries.delete(id);}if(entries.size)spatial.set(canvas,{entries,cells:grid.finish(),oversized:readSet(large)});else spatial.delete(canvas);}} + const tables:Tables={registry,members:members.finish(),children:children.finish(),childByEntry,incident:incident.finish(),elementRefs:elementRefs.finish(),canvasRefs:canvasRefs.finish(),forward,opaque,spatial,order:old&&changes&&!changes.some(change=>change.kind==='element'&&(!change.before||!change.after))?old.order:order(old?.order,document.elements.keys()),canvasOrder:old&&changes&&!changes.some(change=>change.kind==='canvas'&&(!change.before||!change.after))?old.canvasOrder:order(old?.canvasOrder,document.canvases.keys())};return expose(document,revision,tables,previous); +} diff --git a/src/lib/canvas-core/structure.ts b/src/lib/canvas-core/structure.ts index b19ae84..5d98ed7 100644 --- a/src/lib/canvas-core/structure.ts +++ b/src/lib/canvas-core/structure.ts @@ -1,10 +1,17 @@ +import type {DocumentQueries} from './queries.ts'; import type { Change, Document, Id, Target } from './types.ts'; import { commonCanvas, targetElement } from './document.ts'; import { canvasTransform, convertPath } from './coordinates.ts'; -import { equal, freeze } from './values.ts'; +import { equal, freeze, fault } from './values.ts'; /** Own the subtree explicitly; geometry/visibility never selects which descendants are deleted. */ -export function removalChanges(document:Document,target:Target):readonly Change[] { +export function removalChanges(document:Document,target:Target,queries?:DocumentQueries):readonly Change[] { + if(queries){ + if(queries.document!==document)throw fault('stale_query_snapshot');const element=targetElement(document,target),elements=new Set([element.id]),canvases=new Set(),child=queries.relations.childCanvasByEntry.get(element.id),stack=child?[child]:[]; + while(stack.length){const id=stack.pop()!;if(canvases.has(id))continue;canvases.add(id);stack.push(...queries.childCanvases(id));for(const member of queries.members(id))elements.add(member);} + for(const id of [...elements])for(const edge of queries.incident(id))elements.add(edge); + return freeze([...queries.orderedElements(elements).map(id=>({kind:'element' as const,id,before:document.elements.get(id)!,after:null})),...queries.orderedCanvases(canvases).map(id=>({kind:'canvas' as const,id,before:document.canvases.get(id)!,after:null}))]); + } const element=targetElement(document,target),elements=new Set([element.id]),canvases=new Set(),children=new Map(); for(const canvas of document.canvases.values())if(canvas.parent){const list=children.get(canvas.parent.canvasId)??[];list.push(canvas.id);children.set(canvas.parent.canvasId,list);} const stack=[...document.canvases.values()].filter(canvas=>canvas.parent?.entryElementId===element.id).map(canvas=>canvas.id); diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs index e1332e1..2197b02 100644 --- a/tests/headless/browser-storage-cases.mjs +++ b/tests/headless/browser-storage-cases.mjs @@ -394,14 +394,16 @@ async function duplicateRecoveryCase(name,assert){ const model=participants=>({documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:[],canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))}); const transport=async request=>{const body=JSON.parse(request.body);for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'copy lost expected version');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(Object.keys(body.expected_versions))}};}; const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})}; + let queryMismatch=false,queryPublications=0; + const open=async()=>{const current=await openProjectEngine(options);current.editor.subscribe((_,snapshot)=>{queryPublications++;try{const queries=current.editor.getQueries(snapshot.document);if(queries.document!==snapshot.document||queries.revision!==snapshot.revision)queryMismatch=true;}catch{queryMismatch=true;}});return current;}; try{ - engine=await openProjectEngine(options);const id=await engine.imports.duplicate([{id:'F',incarnation:1},{id:'A',incarnation:1}]); - await engine.imports.pause(id);await engine.settle();assert(engine.imports.getJobs().get(id).source.kind==='duplicate-snapshot','copy did not expose its preparing source');await engine.close();engine=await openProjectEngine(options);assert(engine.imports.getJobs().get(id).status==='paused'&&engine.imports.getJobs().get(id).preparation.selection.length===2,'preparing copy source/selection did not survive IndexedDB');await engine.imports.resume(id); + engine=await open();const id=await engine.imports.duplicate([{id:'F',incarnation:1},{id:'A',incarnation:1}]); + await engine.imports.pause(id);await engine.settle();assert(engine.imports.getJobs().get(id).source.kind==='duplicate-snapshot','copy did not expose its preparing source');await engine.close();engine=await open();assert(engine.imports.getJobs().get(id).status==='paused'&&engine.imports.getJobs().get(id).preparation.selection.length===2,'preparing copy source/selection did not survive IndexedDB');await engine.imports.resume(id); await new Promise(resolve=>{if(engine.imports.getJobs().get(id).committedSourceKeys.length){resolve();return;}const release=engine.editor.subscribe((_,snapshot)=>{if(snapshot.runtime.imports.get(id).committedSourceKeys.length===1){release();resolve();}});});await engine.imports.pause(id);await engine.settle(); - const before=engine.imports.getJobs().get(id),copied=before.stableIds['element:F'];assert(before.status==='paused','copy pause lost');engine.editor.dispatch({type:'element.data',target:{id:copied,incarnation:1},patch:{title:'edited copy'}});await engine.settle();await engine.close();engine=await openProjectEngine(options); + const before=engine.imports.getJobs().get(id),copied=before.stableIds['element:F'];assert(before.status==='paused','copy pause lost');engine.editor.dispatch({type:'element.data',target:{id:copied,incarnation:1},patch:{title:'edited copy'}});await engine.settle();await engine.close();engine=await open(); const restored=engine.imports.getJobs().get(id);assert(restored.source.kind==='duplicate-plan'&&JSON.stringify(restored.stableIds)===JSON.stringify(before.stableIds),'copy source or mapping changed on reopen');await engine.imports.resume(id);await engine.settle();const job=engine.imports.getJobs().get(id),image=document.elements.get(job.stableIds['element:A']),incoming=document.elements.get(job.stableIds['element:incoming']); - assert(job.status==='completed'&&job.failures.length===0,'copy did not complete after reopening');assert(document.elements.get(copied).data.title==='edited copy','copy replay overwrote an edit');assert(image.data.assetId==='shared'&&!Object.hasOwn(image.data,'runId'),'copy reissued image execution');assert(incoming.source.elementId==='C'&&incoming.target.elementId===copied&&incoming.ownerCanvasId===root,'copy lost upper incoming source');assert(document.elements.has('incoming')&&document.canvases.has(job.stableIds[`canvas:${child}`]),'copy changed originals or lost its child canvas'); - assert(engine.editor.history.undo().status==='applied','copy undo failed');await engine.settle();const count=document.elements.size;await engine.close();engine=await openProjectEngine(options);await engine.settle();assert(document.elements.size===count&&!document.elements.has(job.stableIds['element:A']),'terminal copy revived undone records'); + assert(job.status==='completed'&&job.failures.length===0,'copy did not complete after reopening');assert(document.elements.get(copied).data.title==='edited copy','copy replay overwrote an edit');assert(image.data.assetId==='shared'&&!Object.hasOwn(image.data,'runId'),'copy reissued image execution');assert(incoming.source.elementId==='C'&&incoming.target.elementId===copied&&incoming.ownerCanvasId===root,'copy lost upper incoming source');assert(document.elements.has('incoming')&&document.canvases.has(job.stableIds[`canvas:${child}`]),'copy changed originals or lost its child canvas');const queries=engine.editor.getQueries();assert(queries.members(job.stableIds[`canvas:${child}`]).includes(job.stableIds['element:A']),'recovered query lost copied member');assert(queries.relations.incidentEdges.get('C').has(job.stableIds['element:incoming']),'query lost upper incoming edge'); + assert(engine.editor.history.undo().status==='applied','copy undo failed');await engine.settle();const count=document.elements.size;await engine.close();engine=await open();await engine.settle();assert(document.elements.size===count&&!document.elements.has(job.stableIds['element:A']),'terminal copy revived undone records');assert(!engine.editor.getQueries().relations.childCanvasByEntry.has(copied),'undo retained a stale child binding');assert(!queryMismatch&&queryPublications>0,'publication exposed a document/index revision mismatch'); }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);} } diff --git a/tests/headless/geometry-cache.test.mjs b/tests/headless/geometry-cache.test.mjs new file mode 100644 index 0000000..df603ea --- /dev/null +++ b/tests/headless/geometry-cache.test.mjs @@ -0,0 +1,23 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createDocument,createRegistry,builtinComponents,createEditor} from '../../src/lib/canvas-core/index.ts'; +import {createGeometryProjectionCache} from '../../src/lib/canvas-bindings/geometry-cache.ts'; +import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts'; +const registry=createRegistry(builtinComponents()),node=(id,owner='P',componentId='text')=>({id,incarnation:1,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:10,y:20,width:200,height:100,rotation:0}}),edge=(id,source,target,owner,path=null)=>({id,incarnation:1,ownerCanvasId:owner,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:source},target:{elementId:target},path}); +const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('C'),{...node('F','P','frame'),data:{title:'F',text:'',presentation:{contentScale:2,unitScale:.75}}},node('A','S'),node('B','S'),edge('in','C','A','P',{type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:500,y:200},in:{x:10,y:0},out:{x:20,y:0}}]}),edge('local','A','B','S')]); + +test('indexed renderer projection preserves legacy output and avoids unrelated geometry work on title, selection and ACK',()=>{ + let id=0;const editor=createEditor({document,registry,createId:()=>`operation-${++id}`,runtime:0}),cache=createGeometryProjectionCache(),owner={},selected=new Set(),draw=()=>{const doc=editor.getSnapshot().document,actual=projectCanvas(doc,'P',registry,selected,()=>undefined,null,undefined,{queries:editor.getQueries(doc),geometry:cache,owner});assert.deepEqual(actual,projectCanvas(doc,'P',registry,selected,()=>undefined));return actual;}; + draw();const before=cache.stats();assert.deepEqual({nodes:before.nodesComputed,children:before.childrenComputed,edges:before.edgesComputed},{nodes:4,children:1,edges:2});editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{title:'only title'}});draw();assert.deepEqual(cache.stats(),before);selected.add('B');draw();assert.deepEqual(cache.stats(),before);editor.updateRuntime(()=>1);draw();assert.deepEqual(cache.stats(),before); + editor.dispatch({type:'element.geometry',target:{id:'A',incarnation:1},patch:{x:300,rotation:25}});draw();assert.equal(cache.stats().nodesComputed,before.nodesComputed+1);assert.equal(cache.stats().childrenComputed,before.childrenComputed);assert.equal(cache.stats().edgesComputed,before.edgesComputed); + editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}});draw();assert.equal(cache.stats().nodesComputed,before.nodesComputed+4);assert.equal(cache.stats().childrenComputed,before.childrenComputed+1);assert.equal(cache.stats().edgesComputed,before.edgesComputed+1); +}); + +test('geometry cache prunes collapsed/deleted data, respects previews/lifecycles and rejects stale query snapshots',()=>{ + let id=0;const editor=createEditor({document,registry,createId:()=>`operation-${++id}`}),cache=createGeometryProjectionCache(),owner={},draw=(geometry=()=>undefined)=>{const doc=editor.getSnapshot().document;return projectCanvas(doc,'P',registry,new Set(),geometry,null,undefined,{queries:editor.getQueries(doc),geometry:cache,owner});};draw();const old=editor.getQueries();editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{contentScale:2,unitScale:.75,collapsed:true}}});draw();assert.equal(cache.stats().nodes,2);assert.equal(cache.stats().children,0);assert.equal(cache.stats().edges,1);assert.throws(()=>projectCanvas(editor.getSnapshot().document,'P',registry,new Set(),()=>undefined,null,undefined,{queries:old}),/stale_query_snapshot/); + editor.history.undo();draw();assert.equal(cache.stats().nodes,4);const before=cache.stats().nodesComputed,preview={...editor.getSnapshot().document.elements.get('A').geometry,x:900};draw(id=>id==='A'?preview:undefined);assert.equal(cache.stats().nodesComputed,before+1);draw();assert.equal(cache.stats().nodesComputed,before+2);editor.dispatch({type:'element.remove',target:{id:'A',incarnation:1}});draw();assert.equal(cache.stats().nodes,3);editor.history.undo();draw();assert.equal(cache.stats().nodes,4);assert.equal(editor.getSnapshot().document.elements.get('A').incarnation,2); +}); + +test('path cache snapshots mutable preview inputs and geometry cache cannot be corrupted through returned values',()=>{ + const cache=createGeometryProjectionCache(),owner={},t={x:1,y:2,scale:2,rotation:0},path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:10,y:20},in:{x:0,y:0},out:{x:0,y:0}}]};cache.begin(owner,'project','P');const first=cache.edge('e',1,path,t);path.knots[0].position.x=30;const second=cache.edge('e',1,path,t);assert.notEqual(first,second);assert.equal(second.knots[0].position.x,61);assert.equal(first.knots[0].position.x,21);assert.ok(Object.isFrozen(second.knots[0].position));cache.end();cache.begin({},'project','P');cache.node('A',1,node('A').geometry,t);cache.end();assert.equal(cache.stats().edges,0); +}); diff --git a/tests/headless/queries.test.mjs b/tests/headless/queries.test.mjs new file mode 100644 index 0000000..2e4ea42 --- /dev/null +++ b/tests/headless/queries.test.mjs @@ -0,0 +1,41 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createDocument,createRegistry,builtinComponents,createEditor,createDocumentQueries,advanceDocumentQueries,applyChanges} from '../../src/lib/canvas-core/index.ts'; +import {removalChanges} from '../../src/lib/canvas-core/structure.ts'; +const registry=createRegistry(builtinComponents()),node=(id,owner='P',componentId='text',geometry={})=>({id,incarnation:1,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:''},geometry:{x:10,y:20,width:100,height:60,rotation:0,...geometry}}),edge=(id,source,target,owner='P')=>({id,incarnation:1,structure:'connection',ownerCanvasId:owner,componentId:'connection',dataVersion:1,source:{elementId:source},target:{elementId:target},data:{},path:null}); +const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('C'),{...node('F','P','frame'),data:{title:'F',text:'',ports:[{id:'p',nodeId:'A'}]}},node('A','S'),node('B','S'),edge('in','C','A'),edge('local','A','B','S')]); +const sets=map=>[...map].map(([id,ids])=>[id,[...ids].sort()]).sort(([a],[b])=>a.localeCompare(b)); +function parity(actual,expected){for(const key of ['ownerMembers','canvasChildren','incidentEdges','elementReferrers','canvasReferrers'])assert.deepEqual(sets(actual.relations[key]),sets(expected.relations[key]),key);assert.deepEqual([...actual.relations.childCanvasByEntry].sort(),[...expected.relations.childCanvasByEntry].sort());assert.deepEqual([...actual.relations.opaqueReferences].sort(),[...expected.relations.opaqueReferences].sort());for(const id of actual.document.canvases.keys()){assert.deepEqual(actual.members(id),expected.members(id));assert.deepEqual(actual.childCanvases(id),expected.childCanvases(id));assert.deepEqual([...actual.spatial.get(id)?.entries??[]].sort(([a],[b])=>a.localeCompare(b)),[...expected.spatial.get(id)?.entries??[]].sort(([a],[b])=>a.localeCompare(b)));assert.deepEqual(sets(actual.spatial.get(id)?.cells??new Map()),sets(expected.spatial.get(id)?.cells??new Map()));assert.deepEqual([...(actual.spatial.get(id)?.oversized??[])].sort(),[...(expected.spatial.get(id)?.oversized??[])].sort());}} + +test('query snapshots index ownership, incoming edges and declared array references without mutable maps',()=>{ + const q=createDocumentQueries(document,registry);assert.deepEqual(q.members('P'),['C','F','in']);assert.deepEqual(q.members('S'),['A','B','local']);assert.deepEqual(q.incident('A'),['in','local']);assert.equal(q.relations.childCanvasByEntry.get('F'),'S');assert.deepEqual([...q.relations.elementReferrers.get('A')],['F']);assert.equal(q.relations.referenceComplete,true);assert.equal(q.relations.ownerMembers.set,undefined);assert.equal(q.relations.ownerMembers.get('S').add,undefined);assert.equal(q.spatial.get('S').cells.set,undefined);assert.ok(Object.isFrozen(q.spatial.get('S').entries.get('A')));assert.deepEqual(removalChanges(document,{id:'F',incarnation:1},q),removalChanges(document,{id:'F',incarnation:1})); + const opaque=createDocument('project',[...document.canvases.values()],[...document.elements.values(),node('unknown','P','future')]),partial=createDocumentQueries(opaque,registry);assert.equal(partial.relations.referenceComplete,false);assert.deepEqual([...partial.relations.opaqueReferences],['unknown']);assert.throws(()=>q.members('missing'),/canvas_not_loaded/);assert.throws(()=>advanceDocumentQueries(q,document,createRegistry(builtinComponents()),0),/query_registry_changed/); +}); + +test('title and metadata commits reuse spatial tables; listeners observe matching document/index revisions and rejected commits keep both',()=>{ + let fail=false,next=0;const editor=createEditor({document,registry,createId:()=>`operation-${++next}`,runtime:{tag:0},reduceRuntime:previous=>{if(fail)throw Error('runtime failure');return previous;}}),initial=editor.getQueries(),members=initial.members('S');editor.subscribe((_,snapshot)=>{const q=editor.getQueries();assert.equal(q.document,snapshot.document);assert.equal(q.revision,snapshot.revision);}); + assert.equal(editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{title:'renamed'}}).status,'applied');const titled=editor.getQueries();assert.equal(titled.spatial,initial.spatial);assert.equal(titled.relations.ownerMembers,initial.relations.ownerMembers);assert.equal(titled.members('S'),members);assert.equal(editor.getQueries(initial.document),initial);assert.equal(editor.updateRuntime(()=>({tag:1})).status,'applied');assert.equal(editor.getQueries(),titled);fail=true;const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'element.geometry',target:{id:'A',incarnation:1},patch:{x:700}}).status,'rejected');assert.equal(editor.getSnapshot(),before);assert.equal(editor.getQueries(),titled);fail=false; + assert.equal(editor.dispatch({type:'element.geometry',target:{id:'A',incarnation:1},patch:{x:700}}).status,'applied');assert.equal(editor.getQueries().spatial.get('P'),initial.spatial.get('P'));assert.notEqual(editor.getQueries().spatial.get('S'),initial.spatial.get('S'));assert.deepEqual(initial.candidateNodes('S',{x:0,y:0,width:150,height:150}),['A','B']);assert.deepEqual(editor.getQueries().candidateNodes('S',{x:0,y:0,width:150,height:150}),['B']);assert.equal(editor.history.undo().status,'applied');parity(editor.getQueries(),createDocumentQueries(editor.getSnapshot().document,registry,editor.getSnapshot().revision)); +}); + +test('incremental queries match rebuilt indexes through transactions, cross-owner repair, deletion/restore and canonical reordering',()=>{ + let next=0;const editor=createEditor({document,registry,createId:()=>`operation-${++next}`,runtime:0}),check=()=>parity(editor.getQueries(),createDocumentQueries(editor.getSnapshot().document,registry,editor.getSnapshot().revision)); + for(let i=0;i<40;i++){const target=editor.getSnapshot().document.elements.get(i%2?'A':'B');assert.equal(editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{x:i*27,rotation:(i*11)%360}});tx.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{ports:[{id:'p',nodeId:target.id}]}});}).status,'applied');check();if(i%5===0){assert.equal(editor.dispatch({type:'element.move',target,canvasId:target.ownerCanvasId==='S'?'P':'S'}).status,'applied');check();}} + assert.equal(editor.dispatch({type:'element.remove',target:{id:'F',incarnation:1}}).status,'applied');check();assert.equal(editor.history.undo().status,'applied');check();assert.equal(editor.history.redo().status,'applied');check();assert.equal(editor.history.undo().status,'applied');check(); + const root=editor.getSnapshot(),canonical=createDocument(root.document.projectId,[...root.document.canvases.values()].reverse(),[...root.document.elements.values()].reverse(),root.document.tombstones);assert.equal(editor.reconcile(canonical,1,root.rootRevision).status,'applied');check();const after=editor.getQueries(),same=createDocument(canonical.projectId,[...canonical.canvases.values()],[...canonical.elements.values()],canonical.tombstones);assert.equal(editor.reconcile(same,2,editor.getSnapshot().rootRevision).status,'applied');assert.equal(editor.getQueries(),after); +}); + +test('owner-local grid conservatively selects rotated/huge nodes and precisely hits corners with finite query budgets',()=>{ + const source=createDocument('project',[{id:'P',parent:null}],[node('rotated','P','text',{x:0,y:0,width:100,height:10,rotation:45}),node('huge','P','text',{x:-1000000,y:-1000000,width:2000000,height:2000000}),node('overflow','P','text',{x:Number.MAX_VALUE,y:0,width:Number.MAX_VALUE,height:100})]),q=createDocumentQueries(source,registry);assert.ok(q.spatial.get('P').oversized.has('huge'));assert.ok(q.spatial.get('P').oversized.has('overflow'));assert.deepEqual(q.hitNodeBounds('P',{x:50,y:5}).ids,['rotated','huge']);assert.equal(q.hitNodeBounds('P',{x:50,y:5}).complete,false);assert.ok(!q.hitNodeBounds('P',{x:10,y:40}).ids.includes('rotated'));assert.ok(q.candidateNodes('P',{x:-1e12,y:-1e12,width:2e12,height:2e12}).includes('rotated'));assert.throws(()=>q.candidateNodes('P',{x:0,y:0,width:-1,height:10}),/invalid_query_bounds/); + const g=source.elements.get('rotated').geometry,angle=Math.PI/4,x=g.x+g.width/2+(g.width/2*Math.cos(angle)-g.height/2*Math.sin(angle)),y=g.y+g.height/2+(g.width/2*Math.sin(angle)+g.height/2*Math.cos(angle));assert.ok(q.hitNodeBounds('P',{x,y}).ids.includes('rotated')); +}); + +test('rotated rectangles are found across grid cells with forward-generated inside/outside points',()=>{ + const nodes=Array.from({length:100},(_,i)=>node(`n-${i}`,'P','text',{x:i*1000,y:(i%5)*700,width:50+i,height:20+i%9,rotation:(i*37)%360})),doc=createDocument('project',[{id:'P',parent:null}],nodes),q=createDocumentQueries(doc,registry); + for(const record of nodes){const g=record.geometry,angle=g.rotation*Math.PI/180,world=(x,y)=>({x:g.x+g.width/2+x*Math.cos(angle)-y*Math.sin(angle),y:g.y+g.height/2+x*Math.sin(angle)+y*Math.cos(angle)}),inside=world(g.width*.4,g.height*.4),outside=world(g.width/2+5,0);assert.ok(q.hitNodeBounds('P',inside).ids.includes(record.id));assert.ok(!q.hitNodeBounds('P',outside).ids.includes(record.id));assert.ok(q.hitNodeBounds('P',outside,6).ids.includes(record.id));} +}); + +test('reverse element/canvas references remove obsolete subjects and mark malformed or unsupported declarations incomplete',()=>{ + const custom=createRegistry([...builtinComponents(),{id:'refs',dataVersion:1,structure:'node',validate:()=>true,features:[],copy:{references:[{path:['targets'],many:true,target:'element',external:'preserve'},{path:['scopes'],many:true,target:'canvas',external:'preserve'}],omit:[]}}]),r={...node('R','P','refs'),data:{targets:['A','A'],scopes:['S']}},r2={...node('R2','P','refs'),data:{targets:['B'],scopes:['S']}},doc=createDocument('project',[...document.canvases.values()],[...document.elements.values(),r,r2]),q=createDocumentQueries(doc,custom); + assert.deepEqual([...q.relations.canvasReferrers.get('S')],['R','R2']);assert.deepEqual([...q.relations.elementReferrers.get('A')],['F','R']);const change={kind:'field',target:{id:'R',incarnation:1},field:'data.scopes',before:['S'],after:['P']},next=applyChanges(doc,[change]),updated=advanceDocumentQueries(q,next,custom,1,[change]);assert.deepEqual([...updated.relations.canvasReferrers.get('S')],['R2']);assert.deepEqual([...updated.relations.canvasReferrers.get('P')],['R']);assert.equal(updated.relations.elementReferrers,q.relations.elementReferrers);const invalid={kind:'field',target:{id:'R',incarnation:1},field:'data.targets',before:['A','A'],after:'invalid'},bad=advanceDocumentQueries(updated,applyChanges(next,[invalid]),custom,2,[invalid]);assert.equal(bad.relations.referenceComplete,false);assert.deepEqual([...bad.relations.opaqueReferences],['R']);assert.equal(bad.relations.canvasReferrers.has('P'),false);assert.deepEqual([...bad.relations.elementReferrers.get('A')],['F']); +});