Index committed canvas relationships and reuse geometric projections

This commit is contained in:
2026-11-08 21:27:54 +08:00
parent b0afe8aa8c
commit b57c3404c5
10 changed files with 209 additions and 25 deletions
@@ -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<Id, { x: number; y: number }> } | null>(null);
const projectionCache = useRef<ReturnType<typeof createProjectionCache> | null>(null);
projectionCache.current ??= createProjectionCache();
const geometryCache=useRef<ReturnType<typeof createGeometryProjectionCache>|null>(null);geometryCache.current??=createGeometryProjectionCache();
const measurements = useRef(new Map<Id, { width: number; height: number; measured: { width: number; height: number } }>());
const imageInput = useRef<HTMLInputElement>(null), groups = useRef(new Map<Id, Id>());
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));
+21
View File
@@ -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<Id,{incarnation:number;geometry:Geometry;transform:Transform;value:NodeResult}>(),children=new Map<Id,{incarnation:number;geometry:Geometry;transform:Transform;units:unknown;value:Transform}>(),edges=new Map<Id,{incarnation:number;path:Json;transform:Transform;value:Json}>();let seenNodes=new Set<Id>(),seenChildren=new Set<Id>(),seenEdges=new Set<Id>();
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<typeof createGeometryProjectionCache>;
+18 -13
View File
@@ -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<CanvasEdgeModel,'data'>&{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<Id> | { 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<Id> | { 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<Id, Transform>([[canvasId, origin]]), hiddenBy = new Map<Id, Id>(), childByEntry = new Map<Id, Id>();
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<Id, ElementRecord[]>();
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<Id>();
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 };
}
+9 -4
View File
@@ -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<Runtime = undefined>(options: EditorOptions<Runtime
const document = createDocument(source.projectId, [...source.canvases.values()], [...source.elements.values()], source.tombstones);
validateDocument(document, options.registry);
let state: Snapshot<Runtime> = 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<Id, HistoryEntry>()) }), operations: options.recovery?.operations ?? readMap(new Map<Id, OperationIntent>()), 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<Runtime>) => void>();
@@ -72,7 +75,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
for(const [field,endpoint]of [['source',command.source],['target',command.destination]] as const)if(endpoint&&!equal(element[field],endpoint))changes=[...changes,{kind:'field',target:command.target,field,before:element[field],after:clone(endpoint)}];
if(changes.length)changes=[...changes,...repairConnectionOwners(document,applyChanges(document,changes))];
} else if (command.type === 'element.remove') {
changes = removalChanges(document,command.target);
changes = removalChanges(document,command.target,queriesByDocument.get(document));
} else if (command.type === 'element.move') {
if (!options.registry.supports(element, 'geometry.move')) throw fault('unsupported_capability', [element.id]);
changes = [{ kind: 'field', target: command.target, field: 'ownerCanvasId', before: element.ownerCanvasId, after: command.canvasId }];
@@ -178,7 +181,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
const commit = freeze({ revision: next.revision, origin, changes, operationIds });
const runtime = options.reduceRuntime ? options.reduceRuntime(state.runtime, next, commit) : state.runtime;
if (isThenable(runtime)) throw fault('async_transaction');
state = Object.freeze({ ...next, rootRevision: state.rootRevision + 1, runtime });
const nextState=Object.freeze({ ...next, rootRevision: state.rootRevision + 1, runtime }),nextQueries=advanceDocumentQueries(queries,document,options.registry,next.revision,changes);
state=nextState;queries=nextQueries;queriesByDocument.set(document,queries);
if (outbound) sequence = nextSequence;
emit(commit);
return Object.freeze({ status: 'applied', revision: state.revision, operationIds, ...(historyEntryId ? { historyEntryId } : {}) });
@@ -255,7 +259,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
} catch (error) { return reject(error); }
finally { entered = false; }
}
return Object.freeze({ getSnapshot: () => 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<Runtime>) => 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<Runtime = undefined>(options: EditorOptions<Runtime
for(const [id,entry]of history.entries)if(entry.id!==id||entry.operationIds.some(operationId=>!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([]) });
+3
View File
@@ -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';
+75
View File
@@ -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<Id,ReadonlyValueSet<Id>>;
type Spatial={cells:Relation;entries:Map<Id,Box>;oversized:ReadonlyValueSet<Id>};
type References={elements:ReadonlySet<Id>;canvases:ReadonlySet<Id>;complete:boolean};
type Tables={registry:Registry;members:Relation;children:Relation;childByEntry:Map<Id,Id>;incident:Relation;elementRefs:Relation;canvasRefs:Relation;forward:Map<Id,References>;opaque:ReadonlyValueSet<Id>;spatial:Map<Id,Spatial>;order:Map<Id,number>;canvasOrder:Map<Id,number>};
const cellSize=512,maxElementCells=256,maxQueryCells=4096;
const privateTables=new WeakMap<DocumentQueries,Tables>();
const empty=readSet<Id>([]);
const orderedBuckets=new WeakMap<object,WeakMap<object,readonly Id[]>>();
function sortedBucket(bucket:ReadonlyValueSet<Id>,ranks:Map<Id,number>){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<Id,Set<Id>>();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<Id>(),canvases=new Set<Id>(),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<Id,number>|undefined,ids:Iterable<Id>){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<Id,ReadonlyValueSet<Id>>;readonly canvasChildren:ReadonlyMap<Id,ReadonlyValueSet<Id>>;readonly childCanvasByEntry:ReadonlyMap<Id,Id>;readonly incidentEdges:ReadonlyMap<Id,ReadonlyValueSet<Id>>;readonly elementReferrers:ReadonlyMap<Id,ReadonlyValueSet<Id>>;readonly canvasReferrers:ReadonlyMap<Id,ReadonlyValueSet<Id>>;readonly opaqueReferences:ReadonlyValueSet<Id>;readonly referenceComplete:boolean};
readonly spatial:ReadonlyMap<Id,{readonly cells:ReadonlyMap<Id,ReadonlyValueSet<Id>>;readonly entries:ReadonlyMap<Id,Box>;readonly oversized:ReadonlyValueSet<Id>}>;
members(canvas:Id):readonly Id[];childCanvases(canvas:Id):readonly Id[];incident(element:Id):readonly Id[];orderedElements(ids:Iterable<Id>):readonly Id[];orderedCanvases(ids:Iterable<Id>):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<Id>,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<Id>(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<Id>)=>sorted(ids),orderedCanvases:(ids:Iterable<Id>)=>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<ElementRecord,{structure:'node'}>).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||revision<previous.revision||revision===previous.revision&&document!==previous.document))throw fault('invalid_query_revision');
const old=previous?privateTables.get(previous):undefined;if(previous&&!old)throw fault('invalid_query_snapshot');if(old&&old.registry!==registry)throw fault('query_registry_changed');if(previous?.document===document&&previous.revision===revision)return previous;
const oldDoc=previous?.document,ids=changes?new Set(changes.filter(change=>change.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<Id,Id>(),forward=old?.forward??new Map<Id,References>(),opaque=old?.opaque??empty,spatial=old?.spatial??new Map<Id,Spatial>();
const replaced=new Set<Id>(),dataFields=new Map<Id,Set<string>>();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<Id,Map<Id,Box|null>>();
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<Id>(),afterEndpoints=after?.structure==='connection'?new Set([after.source.elementId,after.target.elementId]):new Set<Id>();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);
}
+9 -2
View File
@@ -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<Id>([element.id]),canvases=new Set<Id>(),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<Id>([element.id]),canvases=new Set<Id>(),children=new Map<Id,Id[]>();
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);