Design System: 图结构动画与交互逻辑
一套用于层级树、知识图谱、项目关系图和组织网络的动画逻辑。本文只定义图如何生成、运动、拖拽、过滤和更新,不规定颜色、字体、背景或节点视觉风格。
参考交互:Andrew Trousdale。
1. 核心动画模型
图结构由三个同时工作的系统组成:
- 力导向布局: 节点通过连接力、排斥力和中心力自动寻找稳定位置。
- 实时连线: 节点坐标变化时,SVG 连线路径在每个 simulation tick 中同步更新。
- 渐进式视图: 点击节点后重新构建局部子图,新旧节点以不同动画策略过渡。
整体状态流:
载入数据
→ 构建当前图
→ 启动力导向模拟
→ 节点依次出现
→ 连线依次出现
→ 模拟逐渐冷却
→ 用户拖拽或点击
→ 提高模拟活性
→ 更新数据或坐标
→ 图重新稳定
图不应永久漂浮。没有交互时,它必须逐渐静止。
2. 数据模型
type GraphNode = {
id: string;
uri: string;
title: string;
type?: string;
children?: GraphNode[];
connectedNodeIds?: string[];
isFeatured?: boolean;
isHighlighted?: boolean;
isSecondary?: boolean;
originDate?: string;
expirationDate?: string;
};
运行时由层级数据产生:
type RuntimeNode = GraphNode & {
x: number;
y: number;
vx: number;
vy: number;
fx: number | null;
fy: number | null;
depth: number;
};
type RuntimeLink = {
source: RuntimeNode;
target: RuntimeNode;
};
3. 初始化流程
async function initializeGraph() {
const data = await loadGraphData();
currentNode = null;
previousNodeIds = new Set();
previousLinkIds = new Set();
renderGraph(data);
bindGraphEvents();
}
初始化时:
- 读取完整层级数据。
- 生成层级结构。
- 通过
descendants()得到节点。 - 通过
links()得到父子边。 - 创建 SVG 连线。
- 创建可交互节点。
- 将节点和边交给力导向模拟。
- 播放第一次错峰进入动画。
4. 力导向模拟
推荐使用 D3 Force 或等价的物理布局引擎。
参考参数:
simulation = forceSimulation(nodes)
.force(
"link",
forceLink(links)
.id(node => node.id)
.distance(70)
.strength(0.1)
)
.force(
"charge",
forceManyBody()
.strength(-400)
.distanceMin(10)
.distanceMax(300)
)
.force(
"center",
forceCenter(centerX, centerY)
);
力的职责
- Link Force: 让存在关系的节点保持相对接近。
- Charge Force: 防止节点堆叠,并为标签预留空间。
- Center Force: 防止整张图漂出容器。
参数调节
- 节点过于拥挤:提高排斥力绝对值或增加连接距离。
- 图过于松散:降低排斥力或缩短连接距离。
- 图持续晃动:降低
alphaTarget,让模拟自然冷却。 - 图移动过慢:在更新发生时暂时提高
alpha。
5. Tick 更新
模拟的每个 tick 执行两类更新:
simulation.on("tick", () => {
updateNodePositions();
updateLinkPaths();
});
节点位置
nodeElements
.style("left", node => `${node.x}px`)
.style("top", node => `${node.y}px`);
也可以使用 transform:
nodeElements.style(
"transform",
node => `translate3d(${node.x}px, ${node.y}px, 0)`
);
如果使用 transform,节点自身的 hover scale 应放在内部元素上,避免覆盖位置 transform。
连线位置
linkElements.attr(
"d",
link => createPath(link.source, link.target)
);
节点和连线必须在同一个 tick 更新,避免拖拽时出现断线或一帧延迟。
6. 连线端点
如果节点坐标表示节点容器左上角:
const sourceX = source.x + nodeSize / 2;
const sourceY = source.y + nodeSize / 2;
const targetX = target.x + nodeSize / 2;
const targetY = target.y + nodeSize / 2;
直线路径:
function createStraightPath(source, target) {
return [
`M ${source.x + nodeSize / 2}`,
`${source.y + nodeSize / 2}`,
`L ${target.x + nodeSize / 2}`,
`${target.y + nodeSize / 2}`
].join(" ");
}
所有连线算法必须基于节点中心,而不是 DOM 左上角。
7. 折线连接动画
某些层级可使用带中间控制段的折线:
function createAngledPath(source, target, bend) {
const sx = source.x + nodeSize / 2;
const sy = source.y + nodeSize / 2;
const tx = target.x + nodeSize / 2;
const ty = target.y + nodeSize / 2;
const x1 = source.x + (target.x - source.x) / 3;
const y1 =
source.y +
(target.y - source.y) / 3 +
bend;
const x2 =
source.x +
2 * (target.x - source.x) / 3;
const y2 =
source.y +
(target.y - source.y) / 3 +
bend;
return `M ${sx},${sy}
L ${x1},${y1}
L ${x2},${y2}
L ${tx},${ty}`;
}
弯折方向在第一次生成时确定:
link.bend =
source.y > target.y ? -30 : 30;
将方向保存在 link 对象上,避免每次 tick 因节点上下关系变化而突然翻转。
8. 节点拖拽
Drag Start
function dragStarted(event, node) {
if (!event.active) {
simulation.alphaTarget(0.3).restart();
}
node.fx = node.x;
node.fy = node.y;
}
- 暂时提高模拟活性。
- 将节点固定在当前位置。
- 其他节点会受力产生轻微让位。
Drag
function dragged(event, node) {
node.fx = event.x;
node.fy = event.y;
}
- 当前节点跟随指针。
- 连线通过 tick 自动更新。
- 不直接手动修改 SVG 路径。
Drag End
function dragEnded(event, node) {
if (!event.active) {
simulation.alphaTarget(0);
}
node.fx = null;
node.fy = null;
}
- 释放节点固定坐标。
- 节点根据连接力重新寻找平衡。
- 节点不需要返回原始位置。
点击与拖拽冲突
必须设置移动阈值:
const DRAG_THRESHOLD = 4;
如果指针移动距离大于阈值:
- 判定为拖拽。
- 松手时不触发节点打开。
如果移动距离未超过阈值:
- 判定为点击。
- 执行节点选择逻辑。
9. 点击后的渐进过滤
点击节点后,不显示完整数据库,也不只留下一个孤立节点。
新的局部子图包含:
- 根节点到当前节点的祖先路径。
- 当前节点。
- 当前节点的直接子节点。
- 当前节点声明的跨域连接节点。
function createFocusedSubgraph(selectedNode) {
return {
ancestors: getAncestorChain(selectedNode),
current: selectedNode,
children: getDirectChildren(selectedNode),
connected: getConnectedNodes(selectedNode)
};
}
祖先路径
通过递归搜索构建:
function findAncestorChain(root, selected, chain = []) {
if (root.id === selected.id) {
return [...chain, root];
}
for (const child of root.children ?? []) {
const result = findAncestorChain(
child,
selected,
[...chain, root]
);
if (result) return result;
}
return null;
}
直接子节点
过滤视图中只展开一层:
focused.children = selected.children.map(child => ({
...child,
children: []
}));
防止点击一个节点后瞬间展开它的全部后代。
跨域连接
for (const id of selected.connectedNodeIds ?? []) {
const node = findNodeById(fullGraph, id);
if (node) {
focused.children.push({
...node,
children: [],
isConnected: true
});
}
}
10. 返回全局图
点击以下对象时恢复全局视图:
- 当前节点自身。
- 根节点。
- 详情面板的关闭操作。
function resetGraph() {
currentNode = null;
const globalGraph =
filterGraphByCurrentDate(fullGraph);
renderGraph(globalGraph);
closeDetailPanel();
updateRoute("/");
}
恢复时需要:
- 保留全局数据。
- 重新生成节点和边集合。
- 对新出现元素播放进入动画。
- 对之前已存在元素保持连续状态。
- 提高模拟 alpha,让图重新排布。
11. 新旧元素识别
每次渲染后保存稳定 ID:
previousNodeIds = new Set(
nodes.map(node => node.id)
);
previousLinkIds = new Set(
links.map(link =>
`${link.source.id}>${link.target.id}`
)
);
下一次渲染时分类:
const enteringNodes = nodes.filter(
node => !previousNodeIds.has(node.id)
);
const persistentNodes = nodes.filter(
node => previousNodeIds.has(node.id)
);
const exitingNodes = previousNodes.filter(
node => !currentNodeIds.has(node.id)
);
Persistent
- 立即保持可见。
- 不重复播放淡入。
- 通过力布局自然移动到新位置。
Entering
- 初始透明。
- 播放错峰淡入。
- 参与新的力导向计算。
Exiting
- 可以立即移除。
- 如果视觉变化较大,可先快速淡出再移除。
- 淡出不应阻塞新图生成。
12. 节点错峰进入
参考时序:
let nodeDelay = 500;
for (const node of enteringNodes) {
setTimeout(() => {
node.element.classList.add("is-visible");
}, nodeDelay);
nodeDelay += 100;
}
- 第一批节点约在
500ms后开始出现。 - 相邻节点间隔约
100ms。 - 单个节点淡入时长约
1000ms。 - 已存在节点直接显示。
CSS 只需负责透明度:
.graph-node {
opacity: 0;
}
.graph-node.is-visible {
opacity: 1;
transition: opacity 1000ms;
}
13. 连线错峰进入
连线比节点晚出现:
let linkDelay = 1000;
for (const link of enteringLinks) {
setTimeout(() => {
link.element.classList.add("is-visible");
}, linkDelay);
linkDelay += 100;
}
- 第一条新连线约在
1000ms后出现。 - 相邻连线间隔约
100ms。 - 单条连线淡入约
1000ms。
顺序逻辑:
节点出现
→ 用户识别对象
→ 连线出现
→ 用户理解关系
不要让连线先于目标节点出现。
14. 更新视图
function renderGraph(data) {
const hierarchy = createHierarchy(data);
const nodes = hierarchy.descendants();
const links = hierarchy.links();
updateLinks(links);
updateNodes(nodes);
simulation.nodes(nodes);
simulation.force("link").links(links);
simulation.alpha(0.3).restart();
previousNodeIds = collectNodeIds(nodes);
previousLinkIds = collectLinkIds(links);
}
使用稳定 ID进行数据绑定:
nodeSelection.data(
nodes,
node => node.data.uri
);
linkSelection.data(
links,
link =>
`${link.source.data.uri}-${link.target.data.uri}`
);
如果使用数组索引作为 key,过滤后节点会被错误复用,产生跳跃。
15. 容器尺寸动画
详情面板打开、关闭或宽度改变时,图的可用空间也会改变。
不要瞬间跳到新宽度。使用插值:
function animateGraphWidth(
startWidth,
targetWidth,
duration = 800
) {
const startedAt = performance.now();
function frame(now) {
const elapsed = now - startedAt;
const progress = Math.min(elapsed / duration, 1);
const eased = easeOutExpo(progress);
const width =
startWidth +
(targetWidth - startWidth) * eased;
graph.style.width = `${width}px`;
simulation.force(
"center",
forceCenter(width / 2, graphHeight / 2)
);
simulation.alpha(0.3).restart();
if (progress < 1) {
requestAnimationFrame(frame);
}
}
requestAnimationFrame(frame);
}
缓动:
function easeOutExpo(progress) {
return progress === 1
? 1
: 1 - Math.pow(2, -10 * progress);
}
参考时长约 800ms。
16. Resize
窗口变化时:
- 使用约
200ms防抖。 - 重新读取容器尺寸。
- 更新 SVG 的宽高。
- 更新中心力。
- 将 simulation alpha 提高到约
0.3。 - 重新启动模拟。
window.addEventListener(
"resize",
debounce(() => {
resizeGraph();
}, 200)
);
不要在每个原始 resize 事件中创建新 simulation。
17. 路由与状态同步
当前节点必须能反映到 URL:
/nodes/research/project-a
点击节点:
currentNode = selected;
renderFocusedGraph(selected);
openDetailPanel(selected);
history.pushState(state, "", selected.uri);
浏览器返回:
window.addEventListener("popstate", event => {
const uri = getUriFromLocation();
if (!uri) {
resetGraph();
return;
}
const node = findNodeByUri(fullGraph, uri);
if (node) {
currentNode = node;
renderFocusedGraph(node);
openDetailPanel(node);
}
});
图、URL 和详情面板始终以同一个节点 ID 为状态来源。
18. 可选时间过滤
如果节点具有时间属性:
function isVisibleAt(node, selectedDate) {
const start = new Date(node.originDate);
const end = node.expirationDate
? new Date(node.expirationDate)
: null;
return (
start <= selectedDate &&
(!end || end > selectedDate)
);
}
时间变化后:
const filteredGraph =
filterGraphByDate(fullGraph, selectedDate);
renderGraph(filteredGraph);
时间过滤只在数据确实有历史演化意义时使用。
19. Hover 与 Focus 动画
Hover 只作用于节点内部视觉元素,不改变节点的物理坐标:
.node-visual {
transition: transform 100ms ease-in-out;
}
.graph-node:hover .node-visual,
.graph-node:focus-visible .node-visual {
transform: scale(1.1);
}
可选的补充信息:
.node-summary {
opacity: 0;
transition: opacity 200ms;
}
.graph-node:hover .node-summary,
.graph-node:focus-visible .node-summary,
.graph-node.is-current .node-summary {
opacity: 1;
}
Hover 不应:
- 修改 simulation。
- 移动相邻节点。
- 改变连线形状。
- 引发图的整体重新布局。
20. 高亮提示动画
需要提示关注的节点可以使用低成本闪烁:
@keyframes graph-highlight {
0%, 100% { opacity: 0; }
50% { opacity: 1; }
}
参考周期 1s。
限制:
- 只作用于一个小型提示元素。
- 不让整个节点闪烁。
- 同时高亮的节点数量保持很少。
- 减少动态效果模式下关闭闪烁。
21. 减少动态效果
@media (prefers-reduced-motion: reduce) {
.graph-node,
.graph-link,
.node-visual,
.node-summary {
transition-duration: 1ms !important;
animation: none !important;
}
}
减少动态效果时:
- 节点和连线直接出现。
- 关闭错峰延迟。
- 关闭持续旋转和闪烁。
- 力导向可以先在内存中完成若干 tick,再一次性显示稳定布局。
- 拖拽仍可用,但释放后的回稳尽量缩短。
22. 移动端逻辑
在小屏设备中,力导向图容易与页面滚动和触摸操作冲突。
推荐:
≤768px时隐藏桌面图。- 使用树形列表或分组索引表达相同数据。
- 点击列表项仍调用相同的节点选择、路由和详情逻辑。
如果必须保留图:
- 使用独立的全屏探索模式。
- 拖动空白区域用于平移。
- 拖动节点与平移手势必须区分。
- 节点点击区域至少
44px。 - 不在正文滚动页面里直接嵌入可拖拽全屏图。
23. 性能约束
- 当前视图建议同时显示
8–30个节点。 - 高密度数据先过滤或聚合。
- 只创建一个 simulation。
- 数据变化时更新 simulation 的 nodes 和 links,不重复创建所有事件监听器。
- 使用稳定 ID完成 enter/update/exit。
- 每个 tick 只更新位置和路径。
- 容器 resize 使用防抖。
- 图稳定后让 alpha 自然降到停止状态。
- 只有拖拽、过滤和容器变化时重新提高 alpha。
- 取消已失效的
requestAnimationFrame。 - 容器动画结束后停止额外刷新。
24. 禁用逻辑
- 不让图在无交互时永久漂动。
- 不把所有数据一次性加入 simulation。
- 不在 hover 时重启整张图。
- 不在每个 tick 重新创建 DOM。
- 不用数组索引作为节点和边的 key。
- 不让连线比目标节点更早出现。
- 不在拖拽后永久固定节点,除非支持保存布局。
- 不因拖拽结束而误触节点打开。
- 不在容器尺寸变化时瞬间跳位。
- 不点击节点后丢失全部祖先上下文。
- 不让 URL、详情面板和图选中状态各自维护独立状态。
- 不在移动端强行缩小桌面力导向图。
25. 验收标准
- 初始节点先出现,连线后出现。
- 已存在节点在视图更新时不会重复闪烁。
- 点击节点后保留祖先路径、当前节点、直接子节点和相关连接节点。
- 节点拖动时连线逐帧跟随,没有明显延迟。
- 松手后节点解除固定并自然回稳。
- 拖拽不会误触点击。
- 图更新后能在短时间内重新静止。
- 详情面板变化时,图宽度与中心位置平滑调整。
- 浏览器返回能够恢复上一个图状态。
- Resize 后图不会溢出或聚集在旧中心。
- Hover 和 Focus 不会改变物理布局。
- 减少动态效果模式下没有错峰、旋转或闪烁。
- 小屏设备使用列表或独立图探索模式。