76 lines
15 KiB
TypeScript
76 lines
15 KiB
TypeScript
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);
|
|
}
|