Design System: 图结构动画与交互逻辑

一套用于层级树、知识图谱、项目关系图和组织网络的动画逻辑。本文只定义图如何生成、运动、拖拽、过滤和更新,不规定颜色、字体、背景或节点视觉风格。

参考交互:Andrew Trousdale

1. 核心动画模型

图结构由三个同时工作的系统组成:

  1. 力导向布局: 节点通过连接力、排斥力和中心力自动寻找稳定位置。
  2. 实时连线: 节点坐标变化时,SVG 连线路径在每个 simulation tick 中同步更新。
  3. 渐进式视图: 点击节点后重新构建局部子图,新旧节点以不同动画策略过渡。

整体状态流:

载入数据
→ 构建当前图
→ 启动力导向模拟
→ 节点依次出现
→ 连线依次出现
→ 模拟逐渐冷却
→ 用户拖拽或点击
→ 提高模拟活性
→ 更新数据或坐标
→ 图重新稳定

图不应永久漂浮。没有交互时,它必须逐渐静止。

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();
}

初始化时:

  1. 读取完整层级数据。
  2. 生成层级结构。
  3. 通过 descendants() 得到节点。
  4. 通过 links() 得到父子边。
  5. 创建 SVG 连线。
  6. 创建可交互节点。
  7. 将节点和边交给力导向模拟。
  8. 播放第一次错峰进入动画。

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. 点击后的渐进过滤

点击节点后,不显示完整数据库,也不只留下一个孤立节点。

新的局部子图包含:

  1. 根节点到当前节点的祖先路径。
  2. 当前节点。
  3. 当前节点的直接子节点。
  4. 当前节点声明的跨域连接节点。
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("/");
}

恢复时需要:

  1. 保留全局数据。
  2. 重新生成节点和边集合。
  3. 对新出现元素播放进入动画。
  4. 对之前已存在元素保持连续状态。
  5. 提高模拟 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

窗口变化时:

  1. 使用约 200ms 防抖。
  2. 重新读取容器尺寸。
  3. 更新 SVG 的宽高。
  4. 更新中心力。
  5. 将 simulation alpha 提高到约 0.3
  6. 重新启动模拟。
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 不会改变物理布局。
  • 减少动态效果模式下没有错峰、旋转或闪烁。
  • 小屏设备使用列表或独立图探索模式。