优化资源依赖图连线外观
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 3m5s
Project CI / Native shell tests (pull_request) Successful in 10m26s

将任务流分支改为平滑贝塞尔曲线
调整两类连线的线宽、虚线和箭头样式
补充连线几何测试与相关文档
This commit is contained in:
2026-07-31 18:36:21 +08:00
parent a431ab47cd
commit f582ecf032
5 changed files with 100 additions and 37 deletions
+39 -16
View File
@@ -3902,6 +3902,8 @@ iframe.preview-frame {
.game-resource-dependency-edge,
.game-resource-dependency-edge path {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
transition:
opacity 140ms ease,
@@ -3909,25 +3911,44 @@ iframe.preview-frame {
}
.game-resource-dependency-edge--reference {
stroke: #d87342;
stroke: #d96f3d;
stroke-width: 2.25px;
opacity: 0.94;
}
.game-resource-dependency-edge--task path {
stroke: #918b87;
stroke-width: 1.4px;
stroke-dasharray: 4 7;
}
.game-resource-dependency-edge--task .game-resource-dependency-trunk {
stroke-width: 1.7px;
opacity: 0.88;
}
.game-resource-dependency-edge--task .game-resource-dependency-branch {
opacity: 0.72;
}
.game-resource-dependency-edge.is-highlighted {
opacity: 1;
}
.game-resource-dependency-edge--reference.is-highlighted {
stroke-width: 3px;
}
.game-resource-dependency-edge--task.is-highlighted path {
opacity: 1;
stroke-width: 2px;
}
.game-resource-dependency-edge--task,
.game-resource-dependency-edge--task path {
stroke: #9d948f;
stroke-width: 1.5px;
stroke-dasharray: 7 6;
.game-resource-dependency-edge--task.is-highlighted .game-resource-dependency-trunk {
stroke-width: 2.4px;
}
.game-resource-dependency-edge.is-highlighted,
.game-resource-dependency-edge.is-highlighted path {
opacity: 1;
stroke-width: 2.8px;
}
.game-resource-dependency-edge.is-dimmed,
.game-resource-dependency-edge.is-dimmed path {
.game-resource-dependency-edge.is-dimmed {
opacity: 0.14;
}
@@ -3937,11 +3958,13 @@ iframe.preview-frame {
}
.game-resource-dependency-marker--reference path {
fill: #d87342;
fill: #d96f3d;
stroke-linejoin: round;
}
.game-resource-dependency-marker--task path {
fill: #9d948f;
fill: #918b87;
stroke-linejoin: round;
}
.game-resource-section {
@@ -36,6 +36,8 @@ export type ResourceDependencyOverlayProps = {
const SECTION_SELECTOR = '[data-resource-section-plane]';
const TASK_FLOW_HUB_GAP = 20;
const CONNECTION_MAX_HANDLE = 180;
const TASK_FLOW_BRANCH_MAX_HANDLE = 96;
const SELF_REFERENCE_LOOP_WIDTH = 56;
const SELF_REFERENCE_LOOP_ANCHOR_OFFSET = 18;
@@ -60,17 +62,39 @@ function connectionPath(source: Point, target: Point) {
} ${source.y + 48}, ${source.x} ${source.y + 1}`;
}
const direction = target.x >= source.x ? 1 : -1;
const bend = Math.max(36, Math.abs(target.x - source.x) * 0.45);
const bend = Math.min(
CONNECTION_MAX_HANDLE,
Math.max(
32,
Math.abs(target.x - source.x) * 0.42 +
Math.abs(target.y - source.y) * 0.08,
),
);
return `M ${source.x} ${source.y} C ${source.x + direction * bend} ${
source.y
}, ${target.x - direction * bend} ${target.y}, ${target.x} ${target.y}`;
}
function linePath(source: Point, target: Point) {
const middleX = (source.x + target.x) / 2;
return `M ${source.x} ${source.y} L ${middleX} ${source.y} L ${middleX} ${
target.y
} L ${target.x} ${target.y}`;
function taskFlowBranchPath(source: Point, target: Point) {
const horizontalDistance = Math.abs(target.x - source.x);
if (horizontalDistance < 1) {
const direction = target.y >= source.y ? 1 : -1;
const handle = Math.min(
TASK_FLOW_BRANCH_MAX_HANDLE,
Math.abs(target.y - source.y) * 0.5,
);
return `M ${source.x} ${source.y} C ${source.x} ${
source.y + direction * handle
}, ${target.x} ${target.y - direction * handle}, ${target.x} ${target.y}`;
}
const direction = target.x >= source.x ? 1 : -1;
const handle = Math.min(
TASK_FLOW_BRANCH_MAX_HANDLE,
horizontalDistance * 0.5,
);
return `M ${source.x} ${source.y} C ${source.x + direction * handle} ${
source.y
}, ${target.x - direction * handle} ${target.y}, ${target.x} ${target.y}`;
}
function rectCenter(rect: Rect): Point {
@@ -278,26 +302,28 @@ export function ResourceDependencyOverlay({
<marker
id={`${markerPrefix}-asset-reference-arrow`}
className="game-resource-dependency-marker game-resource-dependency-marker--reference"
markerWidth="8"
markerHeight="8"
refX="7"
refY="4"
markerWidth="9"
markerHeight="9"
refX="8"
refY="4.5"
orient="auto"
markerUnits="strokeWidth"
markerUnits="userSpaceOnUse"
viewBox="0 0 9 9"
>
<path d="M 0 0 L 8 4 L 0 8 z" />
<path d="M 1 1 L 8 4.5 L 1 8 z" />
</marker>
<marker
id={`${markerPrefix}-task-flow-arrow`}
className="game-resource-dependency-marker game-resource-dependency-marker--task"
markerWidth="8"
markerHeight="8"
refX="7"
refX="7.25"
refY="4"
orient="auto"
markerUnits="strokeWidth"
markerUnits="userSpaceOnUse"
viewBox="0 0 8 8"
>
<path d="M 0 0 L 8 4 L 0 8 z" />
<path d="M 1 1.25 L 7 4 L 1 6.75 z" />
</marker>
</defs>
@@ -328,7 +354,7 @@ export function ResourceDependencyOverlay({
key={`source:${resourceId}`}
className="game-resource-dependency-branch"
data-resource-id={resourceId}
d={linePath(point, geometry.sourceHub)}
d={taskFlowBranchPath(point, geometry.sourceHub)}
/>
))}
<path
@@ -340,7 +366,7 @@ export function ResourceDependencyOverlay({
key={`target:${resourceId}`}
className="game-resource-dependency-branch"
data-resource-id={resourceId}
d={linePath(geometry.targetHub, point)}
d={taskFlowBranchPath(geometry.targetHub, point)}
markerEnd={`url(#${markerPrefix}-task-flow-arrow)`}
/>
))}
@@ -120,8 +120,22 @@ describe('ResourceDependencyOverlay', () => {
);
const taskFlow = overlay.querySelector('[data-edge-kind="task-flow"]');
expect(taskFlow).not.toBeNull();
expect(taskFlow?.querySelectorAll('path')).toHaveLength(7);
const taskPaths = Array.from(
taskFlow?.querySelectorAll<SVGPathElement>('path') ?? [],
);
expect(taskPaths).toHaveLength(7);
expect(
taskPaths.every((path) => path.getAttribute('d')?.includes(' C ')),
).toBe(true);
expect(
taskPaths.some((path) => path.getAttribute('d')?.includes(' L ')),
).toBe(false);
expect(taskFlow?.querySelectorAll('path[marker-end]')).toHaveLength(3);
expect(
overlay
.querySelector('marker[id$="-task-flow-arrow"]')
?.getAttribute('markerUnits'),
).toBe('userSpaceOnUse');
});
it('filters hidden endpoints and updates path geometry when positions change', async () => {
@@ -251,8 +251,8 @@ type UpdateProjectResourceCanvasLayoutResult =
- 图层只在 dependency 模式挂载;type 模式不得渲染 SVG、连线或 marker。切换 mode、切换项目或卸载工作台时必须销毁旧图层,并清理尺寸观察和窗口事件监听。
- 输入固定为当前资源投影的全部卡片坐标与前端 `ProjectResourceGraph`;输出使用原生 SVG 的 path 和箭头 marker。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击和 Pointer Events 拖动。
- `asset-reference` 表示精确资源引用,使用橙色实线。`GameCreationAppAssetManifestEntry.source.referenceResourceIds` 中的外部资源 ID 必须先唯一匹配另一项资产的 `source.resourceId`,再映射为当前资源卡 ID;缺失、重复或已删除的目标均不得渲染幽灵连线。
- `task-flow` 表示任务产物流转,使用灰色虚线。任务依赖按 `sourceTaskId -> targetTaskId` 聚合为一条主线,两端资源仅绘制分支;禁止对上下游资源生成笛卡尔积连线。
- `asset-reference` 表示精确资源引用,使用橙色实线与连续贝塞尔曲线`GameCreationAppAssetManifestEntry.source.referenceResourceIds` 中的外部资源 ID 必须先唯一匹配另一项资产的 `source.resourceId`,再映射为当前资源卡 ID;缺失、重复或已删除的目标均不得渲染幽灵连线。
- `task-flow` 表示任务产物流转,使用灰色圆头虚线。任务依赖按 `sourceTaskId -> targetTaskId` 聚合为一条主线,两端资源仅绘制平滑曲线分支,不得出现直角折线;禁止对上下游资源生成笛卡尔积连线。任务主线与分支可以使用不同线宽和透明度表达聚合层级,但不能改变端点或方向语义。
- 图模型必须对资源引用图和完整任务依赖图做迭代式环检测,不得用无界递归遍历;参与环的可见边保留渲染并标记 cyclic,环本身不能造成重复生成或死循环。
- 资源自引用的起点与终点为同一张卡片时,必须绘制在卡片外侧的可见闭环并保留箭头,不得让路径穿过卡片后被底层 SVG 层级遮挡。
- 搜索只允许为当前可见端点生成几何;任一精确引用端点隐藏时该线隐藏,聚合任务流只保留仍可见的两端分支,任一侧没有可见资源时整条任务流隐藏。
@@ -371,7 +371,7 @@ game-project/
- `resourceDependencyGraphModel.ts` 负责从当前资源卡投影和 manifest task 构建 `ProjectResourceGraph`。精确引用把资产 `source.referenceResourceIds` 唯一匹配到另一资产的 `source.resourceId`,再转换为实际卡片 ID;无匹配、多匹配和已删除资源只记录为 unresolved,不生成边。引用边按 `sourceResourceId + targetResourceId` 稳定去重。
- task flow 只读取存在于当前 manifest 的任务依赖。资源按 `producerTaskId` 分组,每个 `sourceTaskId -> targetTaskId` 只生成一个聚合 flow;SVG 侧绘制 source 分支、唯一主线和 target 分支,路径数量为 `O(S+T)`,禁止资源笛卡尔积。
- 资源引用图和完整任务依赖图分别使用迭代式强连通分量分析。任务环检测不能依赖可视 task flow 是否有两端资源,否则无产物任务参与的环会漏报;循环边只带 cyclic 标记,不触发递归展开。
- `ResourceDependencyOverlay.tsx` 使用原生 SVG path/marker,绝对定位在 `.game-resource-canvas-content` 底层并统一 `pointer-events: none`。橙色实线表示 `asset-reference`,灰色虚线表示 `task-flow`;不引入 D3、React Flow 或其它图表依赖。
- `ResourceDependencyOverlay.tsx` 使用原生 SVG path/marker,绝对定位在 `.game-resource-canvas-content` 底层并统一 `pointer-events: none`。橙色实线表示 `asset-reference`,灰色圆头虚线表示 `task-flow`两类连线统一使用连续贝塞尔曲线,任务主线略强于两端分支,箭头使用不随高亮线宽缩放的稳定用户空间尺寸,避免直角折线、突兀拐弯和箭头跳变。不引入 D3、React Flow 或其它图表依赖。
- `asset-reference` 的 source / target 是同一资源时使用卡片右侧外绕贝塞尔闭环,两个锚点分开且 marker 保留在返回锚点;路径不穿过卡片,并与普通边一样直接由拖动预览坐标重算。
- 图层用 SVG 自身节点定位所属画布容器,测量各 section plane 相对原点;`ResizeObserver` 与 window resize 只负责重新测量,并在卸载时清理。这样不依赖父组件 ref 在子组件 layout effect 中的绑定时序。type 模式不挂载图层;项目身份作为 key,切换 mode、项目或工作台卸载都会销毁旧 SVG。
- 搜索可见 ID 在几何阶段过滤端点;选中资源只查询一跳上下游与关联边,关联卡片和边高亮,其余边弱化。拖动预览坐标直接替换当前卡片坐标传入图层,pointer move 实时更新 path;结束后仍只通过原布局 Hook 保存卡片坐标,SVG 几何从不持久化。