docs(canvas): explain scene projection and conservative paint caches
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 连线超出原生端点包围范围的额外绘制索引。用曲线控制点凸包加半线宽保守包住笔画,
|
||||
* 不要求求出三次曲线的精确极值。无法获取锚点/协议或耗尽预算时返回 unknown,宿主应保守保留显示。
|
||||
*/
|
||||
import type {Registry} from '../canvas-core/types.ts';
|
||||
import type {projectCanvas} from './projection.ts';
|
||||
import type {HitServices} from './hit-query.ts';
|
||||
@@ -6,39 +10,57 @@ import {equal} from '../canvas-core/values.ts';
|
||||
import {compatConnector} from './connectors.ts';
|
||||
/** Conservative cubic control hull + half stroke, in the displayed scene space.
|
||||
* Missing/custom geometry stays unknown so a host cannot interpret it as invisible. */
|
||||
/** 可选缓存的共享实现,限制本次边数和总曲线段数。
|
||||
* 缓存命中仍扣减段预算,不让缓存历史悄悄放宽相同查询的工作量语义。 */
|
||||
function connectionPaint(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096,cache?:Map<string,{signature:unknown;segments:number;region?:PaintRegion}>){
|
||||
if(![maxEdges,maxSegments].every(n=>Number.isSafeInteger(n)&&n>0&&n<=1000000))throw Error('invalid_paint_budget');
|
||||
const regions:PaintRegion[]=[],unknown:string[]=[],nodes=new Map<string,typeof projection.nodes[number]>(),endpoints=new Set<string>();
|
||||
// Only inspected edges need node entries. Preserve last-ID-wins behavior without
|
||||
// allocating an entry and pair array for every unrelated projected node.
|
||||
// 仅为边预算内真正要检查的端点建节点表;仍遍历节点,但不为所有无关节点分配 Map 条目。
|
||||
for(let i=0;i<Math.min(maxEdges,projection.edges.length);i++){const edge=projection.edges[i];endpoints.add(edge.source);endpoints.add(edge.target);}
|
||||
if(endpoints.size)for(const node of projection.nodes)if(endpoints.has(node.id))nodes.set(node.id,node);
|
||||
let remainingSegments=maxSegments,computed=0;
|
||||
for(let index=0;index<projection.edges.length;index++){
|
||||
// 超出边预算给全局 paint_budget,而非声称未检查的边没有额外范围。
|
||||
if(index>=maxEdges){unknown.push('paint_budget');break;}const edge=projection.edges[index];
|
||||
try{const record=projection.document.elements.get(edge.id),source=nodes.get(edge.source),target=nodes.get(edge.target);if(!record||record.structure!=='connection'||!source||!target)throw Error();
|
||||
/** 优先用 renderer 锚点;原生 record-v1 不能猜旧卡片把手,缺测量即归 unknown。 */
|
||||
const anchor=(node:typeof source,handle:string|null|undefined,end:'source'|'target')=>services.anchor?services.anchor(node,handle,end,edge):node.data.__headlessRecord?undefined:compatConnector(node,handle,end==='source'?'right':'left');
|
||||
const from=anchor(source,edge.sourceHandle,'source'),to=anchor(target,edge.targetHandle,'target'),transform=projection.transforms.get(record.ownerCanvasId);if(!from||!to||!transform||![from.point,from.normal,to.point,to.normal].every(point=>point&&Number.isFinite(point.x)&&Number.isFinite(point.y))||![transform.x,transform.y,transform.scale,transform.rotation].every(Number.isFinite)||transform.scale<=0)throw Error();
|
||||
const protocol=edge.type!=='unsupported'&®istry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined;
|
||||
// 同时捕获显示边、真实记录、端点位置/大小、锚点及变换;预览/移动都可能改变曲线范围。
|
||||
const signature={record,edge,from:{point:{...from.point},normal:{...from.normal}},to:{point:{...to.point},normal:{...to.normal}},transform:{...transform},source:{position:{...source.position},width:source.width,height:source.height},target:{position:{...target.position},width:target.width,height:target.height}},old=cache?.get(edge.id);
|
||||
// 即使本边范围是空,也可缓存“已证明没有额外范围”,避免重复调用几何服务。
|
||||
if(old&&equal(old.signature,signature)){if(old.segments>remainingSegments){unknown.push('paint_budget');break;}remainingSegments-=old.segments;if(old.region)regions.push(old.region);continue;}
|
||||
computed++;
|
||||
// renderer 服务优先,随后用组件几何协议;stroke 也必须来自同一显示语义。
|
||||
const segments=services.edgeGeometry?.(edge,from,to)??(protocol?.kind==='connection'?protocol.segments({element:record,source:from,target:to,displayPath:edge.data?.__headlessRecord?undefined:edge.data?.path??null,transform}):undefined),stroke=services.strokeWidth?.(edge)??(protocol?.kind==='connection'?protocol.strokeWidth({element:record,transform}):undefined);
|
||||
// 校验每边段数、线宽,再扣全局剩余段数;空段列表是已知无笔画,不应误标 unknown。
|
||||
if(!segments||!Array.isArray(segments)||segments.length>maxSegments||typeof stroke!=='number'||!Number.isFinite(stroke)||stroke<0)throw Error();if(!segments.length){cache?.set(edge.id,{signature,segments:0});continue;}if(segments.length>remainingSegments){unknown.push('paint_budget');break;}remainingSegments-=segments.length;
|
||||
let left=Infinity,top=Infinity,right=-Infinity,bottom=-Infinity;
|
||||
// 三次曲线完全位于控制点凸包内,所有控制点的轴对齐包围盒提供保守范围。
|
||||
for(const segment of segments){if(!Array.isArray(segment)||segment.length!==4)throw Error();for(const point of segment){if(!point||!Number.isFinite(point.x)||!Number.isFinite(point.y))throw Error();left=Math.min(left,point.x);top=Math.min(top,point.y);right=Math.max(right,point.x);bottom=Math.max(bottom,point.y);}}
|
||||
// 向四侧扩半线宽,同时检查端点相加不溢出,否则当前边转 unknown。
|
||||
const half=stroke/2,x=left-half,y=top-half,width=right-left+stroke,height=bottom-top+stroke;if(![x,y,width,height,x+width,y+height].every(Number.isFinite))throw Error();
|
||||
// 原生范围由两端节点矩形并集包围;只有实际笔画包围盒超出它,才添加补充空间行。
|
||||
const native={left:Math.min(source.position.x,target.position.x),top:Math.min(source.position.y,target.position.y),right:Math.max(source.position.x+source.width!,target.position.x+target.width!),bottom:Math.max(source.position.y+source.height!,target.position.y+target.height!)};
|
||||
if(!Object.values(native).every(Number.isFinite))throw Error();const region=x<native.left||y<native.top||x+width>native.right||y+height>native.bottom?{id:edge.id,bounds:{x,y,width,height}}:undefined;if(region)regions.push(region);cache?.set(edge.id,{signature,segments:segments.length,region});
|
||||
// 异常几何不保留过时缓存;单边失败隔离为该 ID 的 unknown,不影响其它已知范围。
|
||||
}catch{cache?.delete(edge.id);unknown.push(edge.id);}
|
||||
}
|
||||
return{regions,unknown,computed};
|
||||
}
|
||||
|
||||
/** 无缓存的单次计算入口,供调用方独立获取额外范围及未知原因。 */
|
||||
export function projectedConnectionPaintOverflow(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096){return connectionPaint(projection,registry,services,maxEdges,maxSegments);}
|
||||
/** Host revision invalidates geometry services whose external configuration changes. */
|
||||
/** 视图级曲线范围缓存与网格。外部锚点/样式服务配置改变时,宿主必须同步推进 geometryRevision,
|
||||
* 仅换闭包或修改服务内部状态不在元素签名中,不能指望缓存自行察觉。 */
|
||||
export function createConnectionPaintIndex(){
|
||||
const cache=new Map<string,{signature:unknown;segments:number;region?:PaintRegion}>(),index=createPaintIndex();let owner:Registry|undefined,project:string|undefined,canvas:string|undefined,revision:unknown,computed=0;
|
||||
/** 同步当前场景;范围身份或 geometryRevision 变化整体失效,删除已移出的边缓存。
|
||||
* query 与 unknown 配套使用;computed 是累计重新计算数,不把缓存命中记成一次曲线计算。 */
|
||||
return Object.freeze({sync(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},geometryRevision?:unknown,budget:{maxEdges?:number;maxSegments?:number}={}){
|
||||
if(owner!==registry||project!==projection.document.projectId||canvas!==projection.canvasId||!Object.is(revision,geometryRevision)){cache.clear();index.clear();owner=registry;project=projection.document.projectId;canvas=projection.canvasId;revision=geometryRevision;}
|
||||
const result=connectionPaint(projection,registry,services,budget.maxEdges??4096,budget.maxSegments??4096,cache),seen=new Set(projection.edges.map(edge=>edge.id));for(const id of cache.keys())if(!seen.has(id))cache.delete(id);index.sync(result.regions);computed+=result.computed;return Object.freeze({query:index.query,unknown:Object.freeze(result.unknown)});
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
/**
|
||||
* 提供旧卡片兼容布局与原生测量布局的连线锚点。
|
||||
* 两种协议不能混猜:compat-v1 依卡片边框/ports 排布,record-v1 应使用 renderer 实测 Handle。
|
||||
*/
|
||||
import type {CanvasGeometryNode} from './compat-types.ts';
|
||||
import type {ProjectionNode} from './projection.ts';
|
||||
import {nodeConnector,type Connector,type ConnectorSide} from '../canvas-geometry.ts';
|
||||
/** 兼容几何节点加投影 data,保留 measured 尺寸和旧卡片端口声明。 */
|
||||
type CompatNode=CanvasGeometryNode&{data:ProjectionNode['data']};
|
||||
/** Same card-border and port layout used by CanvasEdge. Never infer it for record-v1 DOM. */
|
||||
/** 方向优先取降级外壳提示,再取标准 handle 名、port 方向或调用方 fallback。
|
||||
* 同方向多个 port 沿高度均匀分布,与兼容 CanvasEdge 使用同一布局。 */
|
||||
export function compatConnector(node:CompatNode,handle:string|null|undefined,fallback:ConnectorSide):Connector{
|
||||
const port=Array.isArray(node.data.ports)?node.data.ports.find(port=>port&&`port:${port.id}`===handle):undefined,hints=(node.data as ProjectionNode['data']&{__headlessFallback?:{handles?:{id:string;side:ConnectorSide}[]}}).__headlessFallback?.handles,projected=Array.isArray(hints)?hints.find(hint=>hint?.id===handle&&['top','right','bottom','left'].includes(hint.side))?.side:undefined,side=projected??(['top','right','bottom','left'].includes(handle??'')?handle as ConnectorSide:port?.direction==='input'?'left':port?'right':fallback),result=nodeConnector(node,side);
|
||||
// 输入/输出各自独立排序,用测量高度优先;默认400仅属于旧卡片布局约定。
|
||||
if(port){const peers=node.data.ports!.filter(item=>item.direction===port.direction);result.point.y+=((peers.indexOf(port)+1)/(peers.length+1)-.5)*(node.measured?.height||node.height||400);}return result;
|
||||
}
|
||||
/** renderer 测量端口:绝对位置与 source/target handle 边界,避免在这里依赖整个 UI store。 */
|
||||
export type MeasuredNode={readonly internals:{readonly positionAbsolute:{x:number;y:number};readonly handleBounds?:{readonly source?:readonly MeasuredHandle[]|null;readonly target?:readonly MeasuredHandle[]|null}}};
|
||||
/** 单个 Handle 在节点中的相对矩形及所在边。 */
|
||||
type MeasuredHandle={readonly id?:string|null;readonly position:ConnectorSide;readonly x:number;readonly y:number;readonly width:number;readonly height:number};
|
||||
/** Native Handle outer edge, matching the installed React Flow loose connection mode. */
|
||||
/** 从实际 Handle 外侧中心生成锚点与朝外法线。target 可采用 source 把手以匹配 loose 模式,
|
||||
* 指定 handleId 未找到或尚无测量时返回 undefined,不编造中心点。 */
|
||||
export function measuredConnector(node:MeasuredNode|undefined,handleId:string|null|undefined,end:'source'|'target'):Connector|undefined{
|
||||
const bounds=node?.internals.handleBounds;if(!node||!bounds)return;const handles=end==='source'?bounds.source??[]:[...(bounds.target??[]),...(bounds.source??[])],handle=handleId?handles.find(handle=>handle.id===handleId):handles[0];if(!handle)return;
|
||||
const {x,y}=node.internals.positionAbsolute,side=handle.position,normals={top:{x:0,y:-1},right:{x:1,y:0},bottom:{x:0,y:1},left:{x:-1,y:0}},point={x:x+handle.x+(side==='left'?0:side==='right'?handle.width:handle.width/2),y:y+handle.y+(side==='top'?0:side==='bottom'?handle.height:handle.height/2)};return{point,normal:normals[side]};
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
/**
|
||||
* 显示空间的可重建均匀网格,用于额外绘制范围的可见性查询。
|
||||
* 大区域、大查询或超出安全格坐标时用保守回退,避免为了空间索引速度漏掉可见元素。
|
||||
*/
|
||||
import type {Id} from '../canvas-core/types.ts';
|
||||
import type {Rect} from '../canvas-core/geometry.ts';
|
||||
import {fault} from '../canvas-core/values.ts';
|
||||
/** 区域 ID 与场景轴对齐矩形;不保存节点内容、选择或交互状态。 */
|
||||
export type PaintRegion={readonly id:Id;readonly bounds:Rect};
|
||||
/** 同时校验端点,防止有限 x/width 相加后溢出;允许零面积边界。 */
|
||||
const valid=(r:Rect)=>r&&[r.x,r.y,r.width,r.height,r.x+r.width,r.y+r.height].every(Number.isFinite)&&r.width>=0&&r.height>=0;
|
||||
/** 包含边界接触的保守交集,与 nativeRectVisible 的正面积判定用途不同。 */
|
||||
const intersects=(a:Rect,b:Rect)=>a.x<=b.x+b.width&&b.x<=a.x+a.width&&a.y<=b.y+b.height&&b.y<=a.y+a.height;
|
||||
/** Rebuildable view-space grid. Oversized regions/queries fall back without false negatives. */
|
||||
/** cellSize 决定网格尺度,maxCells 限制单区域/查询展开的格数,默认512和256。 */
|
||||
export function createPaintIndex(cellSize=512,maxCells=256){
|
||||
if(!Number.isFinite(cellSize)||cellSize<=0||!Number.isSafeInteger(maxCells)||maxCells<1)throw fault('invalid_paint_index');
|
||||
const rows=new Map<Id,Rect>(),cells=new Map<string,Set<Id>>(),members=new Map<Id,readonly string[]>(),overflow=new Set<Id>();
|
||||
/** 算出覆盖格键;负坐标用 floor,过大或不安全的格范围返回 undefined 走保守路径。 */
|
||||
function keys(r:Rect){const x=Math.floor(r.x/cellSize),y=Math.floor(r.y/cellSize),right=Math.floor((r.x+r.width)/cellSize),bottom=Math.floor((r.y+r.height)/cellSize);if(![x,y,right,bottom].every(Number.isSafeInteger)||(right-x+1)*(bottom-y+1)>maxCells)return;const result:string[]=[];for(let a=x;a<=right;a++)for(let b=y;b<=bottom;b++)result.push(`${a},${b}`);return result;}
|
||||
/** 同时清除反向成员表、格桶与溢出集合,空桶立即释放。 */
|
||||
function remove(id:Id){for(const key of members.get(id)??[]){const bucket=cells.get(key)!;bucket.delete(id);if(!bucket.size)cells.delete(key);}members.delete(id);overflow.delete(id);rows.delete(id);}
|
||||
return Object.freeze({
|
||||
/** 先验证完整输入的 ID 唯一性和矩形,再更新差异;超大区域归入 overflow,每次查询都检查。 */
|
||||
sync(regions:readonly PaintRegion[]){const incoming=new Map<Id,Rect>();for(const region of regions){if(!region.id||incoming.has(region.id)||!valid(region.bounds))throw fault('invalid_paint_region');incoming.set(region.id,{...region.bounds});}for(const id of rows.keys())if(!incoming.has(id))remove(id);for(const [id,r]of incoming){const old=rows.get(id);if(old&&old.x===r.x&&old.y===r.y&&old.width===r.width&&old.height===r.height)continue;remove(id);rows.set(id,r);const list=keys(r);if(!list){overflow.add(id);continue;}members.set(id,list);for(const key of list){let bucket=cells.get(key);if(!bucket)cells.set(key,bucket=new Set());bucket.add(id);}}},
|
||||
/** 普通查询查覆盖格加 overflow,大查询扫描全部行,最后统一按矩形相交过滤。 */
|
||||
query(region:Rect):ReadonlySet<Id>{if(!valid(region))throw fault('invalid_paint_region');const list=keys(region),candidates=list?new Set([...overflow,...list.flatMap(key=>[...cells.get(key)??[]])]):new Set(rows.keys());return new Set([...candidates].filter(id=>intersects(rows.get(id)!,region)));},
|
||||
/** 释放所有可重建空间表,不保留跨场景的区域引用。 */
|
||||
clear(){rows.clear();cells.clear();members.clear();overflow.clear();},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 节点额外绘制范围的保守索引。原生 renderer 的未旋转矩形不一定包含旋转/自定义组件的全部笔画,
|
||||
* 因此用 geometry.paint bounds 补充超出的范围;无法解释的节点标 unknown,不能当作已证明不可见。
|
||||
*/
|
||||
import {clone,equal} from '../canvas-core/values.ts';
|
||||
import {rectangleGeometry} from '../canvas-core/geometry.ts';
|
||||
import {toLocal,toWorld,type Transform} from '../canvas-core/coordinates.ts';
|
||||
@@ -5,13 +9,17 @@ import type {ElementRecord,Geometry,GeometryProtocol,Registry} from '../canvas-c
|
||||
import type {projectCanvas,ProjectionNode} from './projection.ts';
|
||||
import {createPaintIndex,type PaintRegion} from './paint-index.ts';
|
||||
/** Extra node paint extents beyond native unrotated React Flow bounds. */
|
||||
/** 对每个显示节点还原局部几何,调用组件 paint 边界,再把四角变到场景取轴对齐包围盒。
|
||||
* 只记录超出原生矩形的节点;单节点协议失败记 unknown,其余节点继续处理。 */
|
||||
export function projectedPaintOverflow(projection:ReturnType<typeof projectCanvas>,registry:Registry){
|
||||
const regions:PaintRegion[]=[],unknown:string[]=[];
|
||||
for(const node of projection.nodes)try{
|
||||
const element=projection.document.elements.get(node.id),transform=element&&projection.transforms.get(element.ownerCanvasId);if(!element||element.structure!=='node'||!transform)throw Error('missing_geometry');
|
||||
const width=node.width!/transform.scale,height=node.height!/transform.scale,center=toLocal(transform,{x:node.position.x+node.width!/2,y:node.position.y+node.height!/2}),geometry={x:center.x-width/2,y:center.y-height/2,width,height,rotation:((node.data.rotation??0)-transform.rotation+360)%360};
|
||||
// 未知/降级节点用标准矩形边界;有效组件可声明更宽的笔画或旋转后的范围。
|
||||
const definition=!node.data.__headlessFallback&®istry.resolve(element)==='known'?registry.components.get(element.componentId)?.geometry:undefined,protocol=definition?.kind==='node'?definition:rectangleGeometry;
|
||||
const local=protocol.bounds({element,geometry,role:'paint'});if(![local.x,local.y,local.width,local.height].every(Number.isFinite)||local.width<0||local.height<0)throw Error('invalid_paint_bounds');
|
||||
// 局部边界再经分区变换,必须考虑父级旋转/缩放,不能只给原矩形加一个固定像素边距。
|
||||
const points=[{x:local.x,y:local.y},{x:local.x+local.width,y:local.y},{x:local.x,y:local.y+local.height},{x:local.x+local.width,y:local.y+local.height}].map(p=>toWorld(transform,p)),x=Math.min(...points.map(p=>p.x)),y=Math.min(...points.map(p=>p.y)),right=Math.max(...points.map(p=>p.x)),bottom=Math.max(...points.map(p=>p.y));
|
||||
if(![x,y,right,bottom,right-x,bottom-y].every(Number.isFinite))throw Error('invalid_paint_bounds');
|
||||
if(x<node.position.x||y<node.position.y||right>node.position.x+node.width!||bottom>node.position.y+node.height!)regions.push({id:node.id,bounds:{x,y,width:right-x,height:bottom-y}});
|
||||
@@ -19,10 +27,16 @@ export function projectedPaintOverflow(projection:ReturnType<typeof projectCanva
|
||||
return{regions,unknown};
|
||||
}
|
||||
|
||||
/** 只接节点几何协议;连线范围有独立的曲线/线宽路径。 */
|
||||
type NodeProtocol=Extract<GeometryProtocol,{kind:'node'}>;
|
||||
/** 缓存签名覆盖生命周期、模型几何、显示预览、变换、协议身份和其声明的数据依赖。 */
|
||||
type PaintSignature={incarnation?:number;owner?:string;component?:string;version?:number;geometry?:Geometry;transform?:Transform;x:number;y:number;width?:number;height?:number;rotation:unknown;protocol:NodeProtocol;data:unknown};
|
||||
/** 可缺失几何按全数值比较,外部对象换引用不等于显示几何改变。 */
|
||||
const sameGeometry=(a:Geometry|undefined,b:Geometry|undefined)=>a===undefined?b===undefined:b!==undefined&&a.x===b.x&&a.y===b.y&&a.width===b.width&&a.height===b.height&&a.rotation===b.rotation;
|
||||
/** 变换值比较避免无关根发布反复计算 paint 边界。 */
|
||||
const sameTransform=(a:Transform|undefined,b:Transform|undefined)=>a===undefined?b===undefined:b!==undefined&&a.x===b.x&&a.y===b.y&&a.scale===b.scale&&a.rotation===b.rotation;
|
||||
/** 验证缓存输入是否等价;协议未声明 dataDependencies 时保守比较整个 element.data,
|
||||
* 显式声明后只比较列出的字段,声明遗漏会由协议实现者负责,不能由缓存自动推导。 */
|
||||
function sameSignature(old:PaintSignature,node:ProjectionNode,element:ElementRecord|undefined,t:Transform|undefined,protocol:NodeProtocol){
|
||||
if(old.incarnation!==element?.incarnation||old.owner!==element?.ownerCanvasId||old.component!==element?.componentId||old.version!==element?.dataVersion||old.protocol!==protocol||!sameGeometry(old.geometry,element?.structure==='node'?element.geometry:undefined)||!sameTransform(old.transform,t)||old.x!==node.position.x||old.y!==node.position.y||old.width!==node.width||old.height!==node.height||old.rotation!==node.data.rotation)return false;
|
||||
const dependencies=protocol.dataDependencies;
|
||||
@@ -31,30 +45,37 @@ function sameSignature(old:PaintSignature,node:ProjectionNode,element:ElementRec
|
||||
for(let i=0;i<dependencies.length;i++)if(!equal(values[i],element?.data[dependencies[i]]))return false;
|
||||
return true;
|
||||
}
|
||||
/** 复制实际依赖值而非持有调用方可变子对象;后续比较用捕获值发现内容变化。 */
|
||||
function captureSignature(node:ProjectionNode,element:ElementRecord|undefined,t:Transform|undefined,protocol:NodeProtocol):PaintSignature{
|
||||
const dependencies=protocol.dataDependencies;
|
||||
return{incarnation:element?.incarnation,owner:element?.ownerCanvasId,component:element?.componentId,version:element?.dataVersion,geometry:element?.structure==='node'?{...element.geometry}:undefined,transform:t?{...t}:undefined,x:node.position.x,y:node.position.y,width:node.width,height:node.height,rotation:node.data.rotation,protocol,data:dependencies===undefined?clone(element?.data):dependencies.map(key=>clone(element?.data[key]))};
|
||||
}
|
||||
/** View-owned incremental paint grid; unchanged rows retain their captured dependencies. */
|
||||
/** 视图级增量 paint 网格,每个节点缓存边界结果,只重新计算签名改变的条目。 */
|
||||
export function createProjectedPaintIndex(){
|
||||
let registryOwner:Registry|undefined,canvas:string|undefined,project:string|undefined,computed=0,captured=0;
|
||||
type Projection=ReturnType<typeof projectCanvas>;
|
||||
type Entry={signature:PaintSignature;result:ReturnType<typeof projectedPaintOverflow>};
|
||||
const entries=new Map<string,Entry>(),index=createPaintIndex();
|
||||
return Object.freeze({
|
||||
/** 切 registry/project/canvas 清空缓存;逐节点收集额外范围与 unknown,再同步网格。 */
|
||||
sync(projection:Projection,registry:Registry){
|
||||
if(registryOwner!==registry||canvas!==projection.canvasId||project!==projection.document.projectId){entries.clear();index.clear();registryOwner=registry;canvas=projection.canvasId;project=projection.document.projectId;}
|
||||
const seen=new Set<string>(),regions:PaintRegion[]=[],unknown:string[]=[];
|
||||
for(const node of projection.nodes){seen.add(node.id);const element=projection.document.elements.get(node.id),t=element&&projection.transforms.get(element.ownerCanvasId),old=entries.get(node.id),declared=element&&!node.data.__headlessFallback&®istry.resolve(element)==='known'?registry.components.get(element.componentId)?.geometry:undefined,protocol=declared?.kind==='node'?declared:rectangleGeometry;
|
||||
let entry=old;
|
||||
// 单节点计算复用完整算法,保证增量缓存与无缓存查询使用相同几何规则。
|
||||
if(!entry||!sameSignature(entry.signature,node,element,t,protocol)){
|
||||
const signature=captureSignature(node,element,t,protocol),result=projectedPaintOverflow({...projection,nodes:[node]},registry);captured++;computed++;entry={signature,result};entries.set(node.id,entry);
|
||||
}
|
||||
regions.push(...entry.result.regions);unknown.push(...entry.result.unknown);
|
||||
}
|
||||
// 删除已不在场景中的缓存,返回可查询索引与冻结的未知列表;调用方须保守处理 unknown。
|
||||
for(const id of entries.keys())if(!seen.has(id))entries.delete(id);index.sync(regions);return Object.freeze({query:index.query,unknown:Object.freeze(unknown)});
|
||||
},
|
||||
/** 累计实际计算/捕获次数和当前项数,仅供诊断,不是帧耗时或文档 revision。 */
|
||||
stats:()=>Object.freeze({computed,captured,entries:entries.size}),
|
||||
/** 解除场景与 registry 身份引用并清空网格;累计诊断计数仍保留。 */
|
||||
clear(){entries.clear();index.clear();registryOwner=undefined;canvas=undefined;project=undefined;},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
/**
|
||||
* React Flow 输入对象的结构共享缓存,与计算场景的 scene-projection 分工不同。
|
||||
* 按元素代次与 owner 判定同一生命周期,复用值相等的 data/style/position 和整个数组,减少显示刷新。
|
||||
* 缓存属于一个 owner/project/视图 canvas,既不是业务文档,也不是跨项目身份仓库。
|
||||
*/
|
||||
import type {Document,Id} from '../canvas-core/types.ts';
|
||||
import {equal,fault} from '../canvas-core/values.ts';
|
||||
import type {projectCanvas} from './projection.ts';
|
||||
|
||||
/** 只处理已经生成的投影,不在这里解释元素组件或计算坐标。 */
|
||||
type Projection=ReturnType<typeof projectCanvas>;
|
||||
/** 原对象的生命周期证据;同 ID 的重建或移动 owner 后不能直接继承旧显示对象。 */
|
||||
type Retained<T>={incarnation:number;ownerCanvasId:Id;value:T};
|
||||
/** View-owned rendering cache, never a document store. Keep unchanged React Flow input identities. */
|
||||
/** 创建视图专属结构共享器;reuse 会就地替换传入投影数组中的等价条目,调用方应提供派生数组。 */
|
||||
export function createProjectionCache(){
|
||||
let scope:object|undefined,projectId:Id|undefined,canvasId:Id|undefined;
|
||||
let nodeArray:Projection['nodes']|undefined,edgeArray:Projection['edges']|undefined;
|
||||
/** 数组长度与逐项引用完全一致时沿用旧数组,顺序改变也会产生新数组身份。 */
|
||||
const stable=<T>(previous:T[]|undefined,next:T[])=>previous?.length===next.length&&next.every((value,index)=>value===previous[index])?previous:next;
|
||||
let nodes=new Map<Id,Retained<Projection['nodes'][number]>>(),edges=new Map<Id,Retained<Projection['edges'][number]>>();
|
||||
/** 对每个显示 ID 核验模型身份,再逐层共享等价字段;返回仅包含本帧元素的新缓存表。 */
|
||||
function retain<T extends{id:Id}>(document:Document,values:T[],previous:Map<Id,Retained<T>>){
|
||||
const next=new Map<Id,Retained<T>>();
|
||||
for(let index=0;index<values.length;index++){
|
||||
@@ -17,6 +27,7 @@ export function createProjectionCache(){
|
||||
const old=previous.get(value.id),sameLifetime=old&&old.incarnation===record.incarnation&&old.ownerCanvasId===record.ownerCanvasId;
|
||||
if(sameLifetime&&old.value===value){next.set(value.id,old);continue;}
|
||||
let shared=value;
|
||||
// 常见嵌套字段独立共享,使其它显示属性变化时也能保留未变化的 data/style/position。
|
||||
if(sameLifetime)for(const key of ['data','style','position'] as const){const incoming=(shared as Record<string,unknown>)[key],before=(old.value as Record<string,unknown>)[key];if(Object.hasOwn(shared,key)&&Object.hasOwn(old.value,key)&&!Object.is(incoming,before)&&equal(incoming,before))shared={...shared,[key]:before};}
|
||||
const retained=sameLifetime&&equal(old.value,shared)?old.value:shared;
|
||||
values[index]=retained;next.set(value.id,{incarnation:record.incarnation,ownerCanvasId:record.ownerCanvasId,value:retained});
|
||||
@@ -24,17 +35,21 @@ export function createProjectionCache(){
|
||||
return next;
|
||||
}
|
||||
return Object.freeze({
|
||||
/** 范围改变先整体清空;保存规范化后的对象表及稳定数组,返回包装后的投影。 */
|
||||
reuse(owner:object,document:Document,viewCanvasId:Id,projection:Projection):Projection{
|
||||
if(scope!==owner||projectId!==document.projectId||canvasId!==viewCanvasId){nodes.clear();edges.clear();nodeArray=undefined;edgeArray=undefined;scope=owner;projectId=document.projectId;canvasId=viewCanvasId;}
|
||||
const nextNodes=retain(document,projection.nodes,nodes),nextEdges=retain(document,projection.edges,edges);
|
||||
nodes=nextNodes;edges=nextEdges;nodeArray=stable(nodeArray,projection.nodes);edgeArray=stable(edgeArray,projection.edges);
|
||||
return{...projection,nodes:nodeArray,edges:edgeArray};
|
||||
},
|
||||
/** 读取本轮保留的节点显示对象,供 renderer 测量等显示流程使用。 */
|
||||
getNode(id:Id){return nodes.get(id)?.value;},
|
||||
/** 只有上下文、生命周期、renderer type、尺寸和 data 均兼容才可沿用旧测量,不能只比较 ID。 */
|
||||
canRetainMeasurement(owner:object,document:Document,viewCanvasId:Id,value:Projection['nodes'][number]){
|
||||
const old=nodes.get(value.id),record=document.elements.get(value.id);
|
||||
return scope===owner&&projectId===document.projectId&&canvasId===viewCanvasId&&!!old&&!!record&&old.incarnation===record.incarnation&&old.ownerCanvasId===record.ownerCanvasId&&old.value.type===value.type&&old.value.width===value.width&&old.value.height===value.height&&equal(old.value.data,value.data);
|
||||
},
|
||||
/** 清空对象/数组和范围身份,调用方无需也不应据此清除业务文档。 */
|
||||
clear(){nodes.clear();edges.clear();nodeArray=undefined;edgeArray=undefined;scope=undefined;projectId=undefined;canvasId=undefined;},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* 把编辑器文档与瞬态手势投影成 renderer 可消费的节点、边与坐标变换。
|
||||
* 模型 ID/代次/owner 保留原意;折叠分区只改变显示端点,不迁移连线归属或修改真实引用。
|
||||
* 同一套 projectNode/projectEdge 被完整遍历与增量补丁共用,避免两条路径产生不同显示规则。
|
||||
*/
|
||||
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';
|
||||
@@ -9,21 +14,30 @@ import type {CanvasNodeData,EdgeData} from './compat-types.ts';
|
||||
|
||||
import { childCanvasTransform, toWorld, transformPath, type Transform } from '../canvas-core/coordinates.ts';
|
||||
export { toWorld, toLocal, transformPath, type Transform } from '../canvas-core/coordinates.ts';
|
||||
/** 当前显示 canvas 自身作为场景原点,不叠加它在外层画布中的布局。 */
|
||||
const origin:Transform={x:0,y:0,scale:1,rotation:0};
|
||||
/** 无法解释或缺 renderer 时的降级外壳;只暴露类型/版本及从已知结构派生的连接把手。 */
|
||||
export type FallbackProjection = {reason:'unknown-component'|'unsupported-version'|'renderer-unavailable';componentId:string;dataVersion:number;handles:{id:string;side:'top'|'right'|'bottom'|'left'}[]};
|
||||
/** renderer 节点形状。__headlessTarget 绑定真实身份,record-v1 另带模型记录与所属 canvas 变换。 */
|
||||
export type ProjectionNode = {id:Id;type:string;position:Point;width?:number;height?:number;selected?:boolean;draggable?:boolean;zIndex?:number;measured?:{width?:number;height?:number};style?:{width?:number|string;height?:number|string;pointerEvents?:'none'|'auto'};data:CanvasNodeData&{__headlessFallback?:FallbackProjection;__headlessRecord?:ElementRecord;__headlessTransform?:Transform}};
|
||||
|
||||
/** 显示端点可以指向折叠入口;record-v1 把路径解释留给 renderer,compat-v1 接已转换路径。 */
|
||||
export type ProjectionEdge={id:Id;type?:string;source:Id;target:Id;sourceHandle?:string|null;targetHandle?:string|null;selected?:boolean;zIndex?:number;data:EdgeData&{__headlessRecord?:ElementRecord;__headlessTransform?:Transform}};
|
||||
/** 仅将 JSON 对象当作 presentation 读取,空值/数组不推测为有效展示配置。 */
|
||||
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
|
||||
/** Construct one node. Scene updates and full traversal share exactly the same projection rules. */
|
||||
/** 优先采用手势预览几何,再计算场景中心/尺寸/旋转;已知组件的移动能力仍由 registry 判断。
|
||||
* renderer 不可用与数据未知分别记录 fallback 原因,不修改原始 payload 来伪造兼容数据。 */
|
||||
export function projectNode(element:Extract<ElementRecord,{structure:'node'}>,canvasId:Id,registry:Registry,selected:{has(id:Id):boolean},geometry:Geometry|undefined,transform:Transform,depth:number,child:Id|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionNode{
|
||||
const g = geometry ?? 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, resolved=registry.resolve(element),known=resolved==='known';
|
||||
const renderer=renderers.resolve(element);
|
||||
// 组件未知、版本不支持、renderer 缺失是不同失败边界;降级时不展开任意组件数据给旧 renderer。
|
||||
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 data = element.data;
|
||||
// 几何统一在场景单位;frame 层序按深度调整,兼容 frame 的内容区不截住其内部元素点击。
|
||||
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), __headlessTarget:Object.freeze({id:element.id,incarnation:element.incarnation,ownerCanvasId:element.ownerCanvasId,canvasId:canvasId}), kind: element.componentId as CanvasNodeData['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 CanvasNodeData,
|
||||
@@ -32,8 +46,11 @@ export function projectNode(element:Extract<ElementRecord,{structure:'node'}>,ca
|
||||
}
|
||||
|
||||
/** Endpoints are already resolved to their displayed node IDs by the caller. */
|
||||
/** 调用方已确定显示 source/target;真实端点被入口替代时清除原 portId,避免端口指向错误外壳。
|
||||
* 预览只替代本条边的路径;不把 data 内可能残留的 path 混入已验证的结构路径。 */
|
||||
export function projectEdge(element:Extract<ElementRecord,{structure:'connection'}>,source:Id,target:Id,registry:Registry,selected:{has(id:Id):boolean},transform:Transform,edgePreview:{target:{id:Id};path:Json}|null|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionEdge{
|
||||
const renderer=renderers.resolve(element),fallbackEdge=registry.resolve(element)!=='known'||!renderer;
|
||||
// 原生 record-v1 接记录和变换自行绘制;兼容路径转换到场景,未知边不解释自定义路径。
|
||||
const path = fallbackEdge||renderer?.projection==='record-v1' ? undefined : (cache?cache.edge(element.id,element.incarnation,edgePreview?.target.id===element.id?edgePreview.path:element.path,transform):transformPath(edgePreview?.target.id === element.id ? edgePreview.path : element.path, transform));
|
||||
const renderPath = path!==undefined&&connectionPathValid(path) ? path : undefined;
|
||||
const { path: unusedPath, ...edgeData } = element.data; void unusedPath;
|
||||
@@ -41,15 +58,21 @@ export function projectEdge(element:Extract<ElementRecord,{structure:'connection
|
||||
}
|
||||
|
||||
/** Renderer projection retains identity/owner in the model, including nested and collapsed canvases. */
|
||||
/** 从显示根深度优先投影展开分区,记录可见节点和被折叠祖先代理的子 canvas。
|
||||
* 只输出 owner 在当前显示范围的连线,再把不可见子节点端点映射到其折叠入口。 */
|
||||
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]);
|
||||
// 索引必须对应同一 document/registry;几何缓存以 owner/project/canvas 隔离。
|
||||
const queries=services.queries,cache=services.geometry;if(queries&&queries.document!==document)throw fault('stale_query_snapshot');if(queries&&queries.registry!==registry)throw fault('query_registry_changed');cache?.begin(services.owner??cache,document.projectId,canvasId);
|
||||
// transforms 只包含展开范围;hiddenBy 把所有折叠后代 canvas 指向当前可见的入口节点。
|
||||
const transforms = new Map<Id, Transform>([[canvasId, origin]]), hiddenBy = new Map<Id, Id>(), childByEntry = new Map<Id, 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[]>();
|
||||
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 getMembers(id)) if (element.structure === 'node') {
|
||||
@@ -58,6 +81,7 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
|
||||
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); 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 {
|
||||
@@ -67,14 +91,17 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
|
||||
}
|
||||
};
|
||||
visit(canvasId, 0);
|
||||
// 只从已展开 owner 找边;边在模型中仍归其原 canvas,显示代理不是把边迁入子 canvas。
|
||||
const edges: ProjectionEdge[] = [];
|
||||
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)) {
|
||||
/** 可见端点保留原 ID;隐藏端点改用折叠祖先入口。无法显示或两端同代理时仅省略本帧边。 */
|
||||
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;
|
||||
edges.push(projectEdge(element,source,target,registry,selected,transforms.get(element.ownerCanvasId)!,edgePreview,renderers,cache));
|
||||
}
|
||||
// Handles come from known envelope endpoints, never from opaque component payloads.
|
||||
// 降级把手只取标准方向与边结构的端口引用,不从未知组件的任意 payload 推测端口声明。
|
||||
const nodesById=new Map(nodes.map(node=>[node.id,node]));
|
||||
const fallbackNodes=new Map(nodes.filter(node=>node.data.__headlessFallback).map(node=>[node.id,node.data.__headlessFallback!]));
|
||||
const handleIds=new Map<Id,Set<string>>();
|
||||
@@ -82,6 +109,7 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
|
||||
for(const edge of edges)for(const end of ['source','target'] as const){
|
||||
const fallback=fallbackNodes.get(edge[end]);
|
||||
if(!fallback){
|
||||
// 未知边接兼容节点时只保留实际已知 handle,防止 renderer 寻找不存在的 DOM 端口。
|
||||
if(edge.type==='unsupported'&&!nodesById.get(edge[end])?.data.__headlessRecord){
|
||||
const node=nodesById.get(edge[end]),field=end==='source'?'sourceHandle':'targetHandle',handle=edge[field];
|
||||
if(!handleIds.has(edge[end])){const ids=new Set(['top','right','bottom','left']);if(Array.isArray(node?.data.ports))for(const port of node.data.ports)if(port&&typeof port.id==='string')ids.add(`port:${port.id}`);handleIds.set(edge[end],ids);}
|
||||
@@ -89,9 +117,11 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 降级节点可补出边明确引用的把手,分配一个可显示的方向;这是显示外壳,不更改模型 portId。
|
||||
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'});}
|
||||
}
|
||||
// 本轮遍历结束后删除未访问的几何缓存项,只返回派生场景,不发布 editor 修改。
|
||||
cache?.end();
|
||||
return { document,canvasId,nodes, edges, transforms };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 场景的受检增量修补。只在能够证明拓扑、身份和坐标关系仍兼容时替换少量显示对象;
|
||||
* 不确定就返回 undefined,让调用方完整投影。缓存命中率不能高于模型与显示一致性的优先级。
|
||||
*/
|
||||
import type {DocumentQueries} from '../canvas-core/queries.ts';
|
||||
import type {Document,Id,Registry} from '../canvas-core/types.ts';
|
||||
import type {SessionSnapshot,ViewState} from '../canvas-core/gestures.ts';
|
||||
@@ -5,10 +9,15 @@ import {equal} from '../canvas-core/values.ts';
|
||||
import {projectNode,projectEdge,type projectCanvas} from './projection.ts';
|
||||
import type {RendererCatalog} from './renderers.ts';
|
||||
|
||||
/** 与完整 projectCanvas 保持同一结果结构,避免增量路径自行定义另一套显示规则。 */
|
||||
type Projection=ReturnType<typeof projectCanvas>;
|
||||
/** 连续发布的可信写集合;from/to 必须恰好连接旧投影与新文档,topology 表示禁止局部修补。 */
|
||||
export type SceneChangeHint={from:Document;to:Document;ids:Set<Id>;topology:boolean};
|
||||
/** 几何手势的瞬态预览,不将 pointermove 变成文档修改。 */
|
||||
type Preview=NonNullable<SessionSnapshot['gesture']>['preview']|undefined;
|
||||
/** 真正影响场景内容的输入;viewport/tool/mode 等状态由别的显示通道处理。 */
|
||||
export type SceneInputs={owner:object;document:Document;registry:Registry;catalog:RendererCatalog;canvasId:Id;selection:ViewState['selection'];preview:Preview;edgeId:Id|undefined;path:NonNullable<SessionSnapshot['connectionGesture']>['path']|undefined};
|
||||
/** 为上一帧建立 ID→数组位置、分区入口及深度索引;发现降级节点/边后禁用快速修补。 */
|
||||
export function indexScene(projection:Projection){
|
||||
const nodes=new Map(projection.nodes.map((node,index)=>[node.id,index])),edges=new Map(projection.edges.map((edge,index)=>[edge.id,index])),entries=new Map<Id,Id>(),depths=new Map<Id,number>([[projection.canvasId,0]]);
|
||||
for(const canvas of projection.document.canvases.values())if(canvas.parent)entries.set(canvas.parent.entryElementId,canvas.id);
|
||||
@@ -16,12 +25,15 @@ export function indexScene(projection:Projection){
|
||||
return{nodes,edges,entries,depths,unsupported:projection.nodes.some(node=>!!node.data.__headlessFallback)||projection.edges.some(edge=>edge.type==='unsupported')};
|
||||
}
|
||||
/** Reuse existing projections for checked field edits and ordinary node insertions. */
|
||||
/** 纯候选投影修补:合并文档/选择/预览脏项,先核对全体脏项再构造新数组。
|
||||
* 允许普通节点新增与受检字段编辑;删除、代次/类型/owner/连线端点变更交给完整路径。 */
|
||||
export function patchScene(base:Projection,layout:ReturnType<typeof indexScene>,next:SceneInputs,previous:SceneInputs,connection:SessionSnapshot['connectionGesture'],hint?:SceneChangeHint,queries?:DocumentQueries):Projection|undefined{
|
||||
const {document,selection,preview,registry,catalog,canvasId}=next;
|
||||
const {selection:oldSelection,preview:oldPreview,edgeId:oldEdgeId,path:oldPath}=previous;
|
||||
if(layout.unsupported||document.projectId!==base.document.projectId)return;
|
||||
const dirty=new Set<Id>(),created=new Set<Id>();
|
||||
if(document!==base.document){
|
||||
// 可信连续 changes 可直接路由 ID;拓扑提示则立刻放弃。无提示时需要逐项核对表顺序及内容。
|
||||
if(hint?.from===base.document&&hint.to===document){if(hint.topology)return;for(const id of hint.ids)dirty.add(id);}
|
||||
else{
|
||||
// Canonical replacement and missed publications carry no trustworthy write set.
|
||||
@@ -33,16 +45,21 @@ export function patchScene(base:Projection,layout:ReturnType<typeof indexScene>,
|
||||
for(const[id,record]of document.elements){if(id!==elementOrder.next().value)return;if(record!==base.document.elements.get(id)&&!equal(record,base.document.elements.get(id)))dirty.add(id);}
|
||||
}
|
||||
}
|
||||
// 选择变化取对称差;预览变化同时检查新旧集合,取消预览也必须重画旧目标。
|
||||
if(selection!==oldSelection){for(const id of oldSelection)if(!selection.has(id))dirty.add(id);for(const id of selection)if(!oldSelection.has(id))dirty.add(id);}
|
||||
if(preview!==oldPreview){for(const[id,g]of oldPreview??[])if(!equal(g,preview?.get(id)))dirty.add(id);for(const[id,g]of preview??[])if(!equal(g,oldPreview?.get(id)))dirty.add(id);}
|
||||
// 连线预览换目标或结束时,把旧边和新边都标脏,以去掉上一条线的预览。
|
||||
if(connection?.target.id!==oldEdgeId||connection?.path!==oldPath){if(oldEdgeId)dirty.add(oldEdgeId);if(connection)dirty.add(connection.target.id);}
|
||||
// Validate the whole dirty footprint before constructing or publishing any replacement.
|
||||
for(const id of dirty){const before=base.document.elements.get(id),after=document.elements.get(id);
|
||||
// 新节点暂记 created;未改分区拓扑且所属 canvas 已有变换时才可能插入当前场景。
|
||||
if(!before&&after?.structure==='node'){created.add(id);continue;}
|
||||
if(!before||!after||before.incarnation!==after.incarnation||before.structure!==after.structure||before.componentId!==after.componentId||before.dataVersion!==after.dataVersion||before.ownerCanvasId!==after.ownerCanvasId)return;
|
||||
if(before.structure==='connection'&&after.structure==='connection'&&(!equal(before.source,after.source)||!equal(before.target,after.target)))return;
|
||||
// 入口几何/单位展示或其预览变化会影响整棵子树变换,不能只修补入口本身。
|
||||
if(before.structure==='node'&&after.structure==='node'&&layout.entries.has(id)&&(!equal(before.geometry,after.geometry)||!equal(before.data.presentation,after.data.presentation)||!equal(oldPreview?.get(id),preview?.get(id))))return;
|
||||
}
|
||||
// 懒复制:未受影响的数组与节点/边对象沿用原引用,新对象仍调用完整路径共用的投影函数。
|
||||
let nodes=base.nodes,edges=base.edges;const inserted=new Map<Id,Projection['nodes'][number]>();
|
||||
for(const id of dirty){const element=document.elements.get(id)!;
|
||||
if(element.structure==='node'){
|
||||
@@ -58,13 +75,16 @@ export function patchScene(base:Projection,layout:ReturnType<typeof indexScene>,
|
||||
if(edges===base.edges)edges=[...edges];edges[index]=edge;
|
||||
}
|
||||
}
|
||||
// 插入后按显示根的深度优先成员序重排引用,不能直接 append 而破坏嵌套绘制层序。
|
||||
if(inserted.size){
|
||||
// Membership order is depth-first, not global insertion order. Reorder references
|
||||
// without reconstructing unaffected nodes, transforms or edge projections.
|
||||
const members=new Map<Id,Id[]>();if(!queries)for(const element of document.elements.values()){const list=members.get(element.ownerCanvasId)??[];list.push(element.id);members.set(element.ownerCanvasId,list);}
|
||||
const ordered:Projection['nodes']=[];let complete=true;
|
||||
/** 只重排节点引用,不重算未变化节点;任一所需投影缺失就放弃这份候选。 */
|
||||
const visit=(owner:Id)=>{for(const id of queries?queries.members(owner):members.get(owner)??[]){const element=document.elements.get(id)!;if(element.structure!=='node')continue;const projected=inserted.get(id)??nodes[layout.nodes.get(id)!];if(!projected){complete=false;return;}ordered.push(projected);const child=layout.entries.get(id);if(child&&base.transforms.has(child))visit(child);}};
|
||||
visit(canvasId);if(!complete)return;nodes=ordered;
|
||||
}
|
||||
// 文档及数组均未变化时返回原场景,否则只替换必要字段,不发布任何业务状态。
|
||||
return nodes===base.nodes&&edges===base.edges&&document===base.document?base:{...base,document,nodes,edges};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 一个视图拥有的派生场景协调器。组合几何缓存、可信 changes 提示和完整/增量投影,
|
||||
* 读取固定的 session 快照;不把 viewport、工具状态或同步任务状态当成场景重算条件。
|
||||
*/
|
||||
import {fault} from '../canvas-core/values.ts';
|
||||
import type {Change,Document,Registry} from '../canvas-core/types.ts';
|
||||
import type {SessionSnapshot,ViewState} from '../canvas-core/gestures.ts';
|
||||
@@ -8,20 +12,26 @@ import {indexScene,patchScene,type SceneChangeHint,type SceneInputs} from './sce
|
||||
import type {RendererCatalog} from './renderers.ts';
|
||||
|
||||
/** One view's derived scene. Viewport/tool/mode and task status do not change scene inputs. */
|
||||
/** 每个视图独立创建;advance 接 editor 发布,project 接当前显示输入,clear 释放全部派生缓存。 */
|
||||
export function createSceneProjection(){
|
||||
let geometry=createGeometryProjectionCache();
|
||||
|
||||
let previous:SceneInputs|undefined,value:ReturnType<typeof projectCanvas>|undefined,layout:ReturnType<typeof indexScene>|undefined,pending:SceneChangeHint|undefined;
|
||||
return Object.freeze({
|
||||
/** 累积从上次投影文档到当前文档的连续写集合;丢发布、无 changes 的替换或大范围改动退回全量。 */
|
||||
advance(before:Document,after:Document,changes:readonly Change[]){
|
||||
if(before===after)return;
|
||||
if(!previous||previous.document===after){pending=undefined;return;}
|
||||
// 只有链尾接上 before 才能继续累计;不连贯时仅从恰好匹配的旧场景重新起链。
|
||||
if(pending?.to!==before){pending=previous.document===before?{from:before,to:after,ids:new Set(),topology:false}:undefined;}
|
||||
if(!pending)return;pending.to=after;
|
||||
if(!changes.length){pending=undefined;return;}
|
||||
// canvas 结构改动或超过16384个脏 ID 时不再扩充集合,标拓扑失效并清空大集合。
|
||||
for(const change of changes){if(change.kind==='canvas'){pending.topology=true;break;}pending.ids.add(change.kind==='element'?change.id:change.target.id);if(pending.ids.size>16384){pending.topology=true;break;}}
|
||||
if(pending.topology)pending.ids.clear();
|
||||
},
|
||||
/** 固定 document/registry/catalog/显示 canvas 和捕获的 session 输入;相同引用直接复用场景。
|
||||
* 可兼容才尝试 patch,失败正常回退 projectCanvas;查询必须与同一份文档/registry 绑定。 */
|
||||
project(owner:object,document:Document,view:ViewState,sessions:SessionSnapshot,registry:Registry,catalog:RendererCatalog,queries?:DocumentQueries){
|
||||
if(queries&&queries.document!==document)throw fault('stale_query_snapshot');
|
||||
if(queries&&queries.registry!==registry)throw fault('query_registry_changed');
|
||||
@@ -29,12 +39,15 @@ export function createSceneProjection(){
|
||||
const next:SceneInputs={owner,document,registry,catalog,canvasId:view.canvasId,selection:view.selection,preview:preview?.size?preview:undefined,edgeId:connection?.target.id,path:connection?.path};
|
||||
if(previous&&Object.keys(next).every(key=>Object.is(next[key as keyof SceneInputs],previous![key as keyof SceneInputs])))return value!;
|
||||
// Read the captured session, not a newer live gesture during an older React render.
|
||||
// owner/registry/renderer catalog/根 canvas 改变意味着解释上下文变了,不能沿用局部补丁。
|
||||
const compatible=previous&&value&&layout&&previous.owner===owner&&previous.registry===registry&&previous.catalog===catalog&&previous.canvasId===view.canvasId;
|
||||
const patched=compatible?patchScene(value!,layout!,next,previous!,connection,pending,queries):undefined;
|
||||
const result=patched??projectCanvas(document,view.canvasId,registry,view.selection,id=>next.preview?.get(id),connection,catalog,{queries,geometry,owner});
|
||||
// 全量重建或节点数量改变才重建布局索引;成功采用结果后消费并清掉 changes 提示。
|
||||
if(!patched||result.nodes.length!==value!.nodes.length)layout=indexScene(result);
|
||||
previous=next;value=result;pending=undefined;return result;
|
||||
},
|
||||
/** 撤销上一帧、写集合和几何缓存,解除对旧文档/视图的引用。 */
|
||||
clear(){previous=undefined;value=undefined;layout=undefined;pending=undefined;geometry=createGeometryProjectionCache();},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user