docs(bindings): explain canvas projection and connection paint bounds
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 旧卡片 renderer 使用的结构化输入类型,与 headless 元素 schema 分离。
|
||||
* 这些类型描述投影/显示约定,不能用类型断言替代 registry 对原始数据、版本和引用的运行时校验。
|
||||
* 本文件仅定义 TypeScript 形状,不运行默认值/迁移/编辑行为;投影器负责从标准 element 生成这些显示字段。
|
||||
*/
|
||||
/** 旧绘制路径使用的二维点;是否局部/场景由投影路径的调用约定决定。 */
|
||||
type Point={x:number;y:number};
|
||||
|
||||
@@ -11,7 +11,9 @@ 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. */
|
||||
/** 可选缓存的共享实现,限制本次边数和总曲线段数。
|
||||
* 缓存命中仍扣减段预算,不让缓存历史悄悄放宽相同查询的工作量语义。 */
|
||||
* 缓存命中仍扣减段预算,不让缓存历史悄悄放宽相同查询的工作量语义。
|
||||
* maxEdges 限访问的边数,maxSegments 同时作单边与总段数限;存在待查端点时仍遍历所有 projected nodes。
|
||||
* 几何回调同步执行且没有时间抢占,回调产出数组前的成本不由预算控制;输入须属于同一有效显示帧。 */
|
||||
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>();
|
||||
@@ -29,21 +31,25 @@ function connectionPaint(projection:ReturnType<typeof projectCanvas>,registry:Re
|
||||
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;
|
||||
// 同时捕获显示边、真实记录、端点位置/大小、锚点及变换;预览/移动都可能改变曲线范围。
|
||||
// 签名复制锚点/端点数值/transform,但 record 与 edge 借用原不可变引用,缓存不深捕获它们。
|
||||
// 外部几何服务/协议配置不在签名内,须通过下方 geometryRevision 或 registry 对象变化失效。
|
||||
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);
|
||||
// 即使本边范围是空,也可缓存“已证明没有额外范围”,避免重复调用几何服务。
|
||||
// 锚点与基础输入每轮仍重新求取;签名相等才复用曲线范围/段数,跳过 segments/stroke 计算。
|
||||
// 空 region 可表示笔画未超原生范围或空 segments,不能将其误当“整条边没有显示内容”。
|
||||
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。
|
||||
// 非空段数超过本次剩余预算则追加全局 paint_budget 并终止后续边;空数组仍要求有效 stroke。
|
||||
// 空数组缓存0段/无region,属本几何协议声明无笔画,不误标 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();
|
||||
// 原生范围由两端节点矩形并集包围;只有实际笔画包围盒超出它,才添加补充空间行。
|
||||
// 原生范围取两端节点未旋转矩形的联合 AABB;曲线控制点凸包+半线宽 AABB 超出时才添整块补充范围。
|
||||
// 这是保守估计而非曲线精确极值,允许额外保留可见候选;不计算命中距离或直接控制 renderer 挂载。
|
||||
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,不影响其它已知范围。
|
||||
@@ -56,11 +62,15 @@ function connectionPaint(projection:ReturnType<typeof projectCanvas>,registry:Re
|
||||
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,
|
||||
* 仅换闭包或修改服务内部状态不在元素签名中,不能指望缓存自行察觉。 */
|
||||
* 仅换闭包或修改服务内部状态不在元素签名中,不能指望缓存自行察觉。
|
||||
* 每视图独立实例;缓存按 registry 对象/project/canvas/revision 隔离,不另核 editor owner。
|
||||
* cache 保留边几何结果,index 存本轮额外区域;超过预算的其它边可暂留缓存但不进入本轮 regions。 */
|
||||
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 是累计重新计算数,不把缓存命中记成一次曲线计算。 */
|
||||
* unknown 中元素 ID 表示该边无法解释,字符串 paint_budget 表示余下查询不完整,不能据空 regions 判不可见。
|
||||
* query 是可变 index 的方法,旧返回对象也查最新网格,unknown 则是本轮冻结列表。
|
||||
* computed 计通过锚点/变换检查后未命中的几何尝试(可随后失败),不等于成功区域数;clear 不重置累计值。 */
|
||||
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,6 +1,7 @@
|
||||
/**
|
||||
* 可移植 bindings 的公共入口,集中导出投影、查询、输入与显示租约工具。
|
||||
* 不导入 React/React Flow;框架 hooks、组件注册与命令 Context 通过 react-flow.ts 明确选择。
|
||||
* 此入口只重导出,不创建 view、订阅或缓存实例;scene coordinator 与分桶源的内部接线不在此集中公开。
|
||||
*/
|
||||
/** Portable scene projection and hit queries: no React or React Flow imports. */
|
||||
// 模型到场景投影、几何缓存和受预算控制的命中查询。
|
||||
|
||||
@@ -16,7 +16,8 @@ import { childCanvasTransform, toWorld, transformPath, type Transform } from '..
|
||||
export { toWorld, toLocal, transformPath, type Transform } from '../canvas-core/coordinates.ts';
|
||||
/** 当前显示 canvas 自身作为场景原点,不叠加它在外层画布中的布局。 */
|
||||
const origin:Transform={x:0,y:0,scale:1,rotation:0};
|
||||
/** 无法解释或缺 renderer 时的降级外壳;只暴露类型/版本及从已知结构派生的连接把手。 */
|
||||
/** 降级原因/类型/版本及从已知结构派生的把手;节点显示还可保留字符串标题和通用身份/几何。
|
||||
* 不透传整份未知 data 给旧 renderer,不等于删除原 payload 或保证显示完全不读取任何通用字段。 */
|
||||
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}};
|
||||
@@ -28,7 +29,10 @@ const object = (value: Json | undefined) => value && typeof value === 'object' &
|
||||
|
||||
/** Construct one node. Scene updates and full traversal share exactly the same projection rules. */
|
||||
/** 优先采用手势预览几何,再计算场景中心/尺寸/旋转;已知组件的移动能力仍由 registry 判断。
|
||||
* renderer 不可用与数据未知分别记录 fallback 原因,不修改原始 payload 来伪造兼容数据。 */
|
||||
* renderer 不可用与数据未知分别记录 fallback 原因,不修改原始 payload 来伪造兼容数据。
|
||||
* 已知 data 字段浅展开后由通用显示字段覆盖,record-v1 另借用原 element/transform,不深复制模型记录。
|
||||
* draggable 只按 known 与 geometry.move 能力判定,缺 renderer 的 fallback 不必自动只读;策略仍由 editor 检查。
|
||||
* 返回可变派生对象供后续补把手/结构共享,只有 __headlessTarget 单独冻结,不是完整输出深冻结。 */
|
||||
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';
|
||||
@@ -47,7 +51,9 @@ 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 混入已验证的结构路径。 */
|
||||
* compat-v1 只对 target.id 匹配的边采用预览路径,变到场景后通过 connectionPathValid 才输出。
|
||||
* record-v1 接原记录/transform,不在此嵌入预览或转换结构路径;原生 renderer 须从自己的受控显示上下文读取预览。
|
||||
* data.path 从兼容 payload 中剔除,避免残留字段覆盖结构 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 接记录和变换自行绘制;兼容路径转换到场景,未知边不解释自定义路径。
|
||||
@@ -59,7 +65,10 @@ export function projectEdge(element:Extract<ElementRecord,{structure:'connection
|
||||
|
||||
/** Renderer projection retains identity/owner in the model, including nested and collapsed canvases. */
|
||||
/** 从显示根深度优先投影展开分区,记录可见节点和被折叠祖先代理的子 canvas。
|
||||
* 只输出 owner 在当前显示范围的连线,再把不可见子节点端点映射到其折叠入口。 */
|
||||
* 只输出 owner 在当前展开范围的连线,再把隐藏后代端点映射到可见折叠入口。
|
||||
* 输入须是已验证的文档树/registry;这里查显示根与可选 queries 身份,不重做完整文档校验或检测所有递归环。
|
||||
* 不做 viewport 裁剪或网络/资源读取;输出全体展开场景,再由 renderer/paint 索引决定屏幕保留。
|
||||
* 派生容器虽可变,消费者仍应按只读约定使用;transforms 可复用共享 origin/几何缓存值,不应原地改它们。 */
|
||||
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 隔离。
|
||||
@@ -72,7 +81,8 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
|
||||
/** 有查询索引用其稳定成员序;无索引时由模型直接归属派生一次成员表,不维护第二份事实。 */
|
||||
const getMembers=(id:Id)=>queries?queries.members(id).map(id=>document.elements.get(id)!):members.get(id)??[];
|
||||
const nodes: ProjectionNode[] = [], visible = new Set<Id>();
|
||||
/** 递归展开只改变投影:先输出入口,再按其当前几何/单位比例变换子分区。 */
|
||||
/** 按成员稳定顺序深度优先:先投影入口,再用同一份预览几何/单位构造内部 canvas 变换。
|
||||
* 隐藏分支用栈遍历后代建立 hiddenBy,不生成内部节点/边变换;无 queries 时反复从 canvas 表找子项。 */
|
||||
const visit = (id: Id, depth: number) => {
|
||||
const transform = transforms.get(id)!;
|
||||
for (const element of getMembers(id)) if (element.structure === 'node') {
|
||||
@@ -91,10 +101,12 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
|
||||
}
|
||||
};
|
||||
visit(canvasId, 0);
|
||||
// 只从已展开 owner 找边;边在模型中仍归其原 canvas,显示代理不是把边迁入子 canvas。
|
||||
// 节点/hiddenBy 全部就绪后再投影边,避免端点代理依赖节点是否已遍历。
|
||||
// queries 路径汇集展开 owner 的边再按文档层序排,无 queries 则扫描 element 表;模型归属始终不变。
|
||||
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;隐藏端点改用折叠祖先入口。无法显示或两端同代理时仅省略本帧边。 */
|
||||
/** 端点节点可见则保留 ID,否则按其 owner 的 hiddenBy 找折叠代理。
|
||||
* 缺显示端点或两端同代理时仅省略本帧边,不从模型删除,也不改为自由端点。 */
|
||||
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;
|
||||
@@ -121,7 +133,8 @@ 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'});}
|
||||
}
|
||||
// 本轮遍历结束后删除未访问的几何缓存项,只返回派生场景,不发布 editor 修改。
|
||||
// 成功遍历结束才 end 清未访问几何项;不是 finally,异常时由下次完整轮次或视图 clear 清理缓存。
|
||||
// document 是原不可变引用,nodes/edges/transforms 是本次派生容器,不发布 editor 修改。
|
||||
cache?.end();
|
||||
return { document,canvasId,nodes, edges, transforms };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user