星座配对
这个示例把力导图变成双侧星座配对器,左右镜像卡片簇加一个隐藏的汇总节点。左右各选一个星座后,图内会显示大尺寸结果卡,并把两条连接线重连到所选配对。
带动态结果卡片的双侧星座契合度匹配器
这个示例构建了什么
这个示例在一个 relation-graph 画布中构建了一个全屏星座契合度匹配器。用户会看到两组镜像分布的星座卡片,从左侧选择一个星座、从右侧选择一个星座,然后获得一张位于中央的大型结果卡片,用来汇总这对组合的优点和缺点。
最重要的一点是,这张图不仅仅是一个查看器。它同时也是选择界面和结果展示界面。配对结果是通过图状态变化来揭示的,而不是通过打开单独的模态框或侧边面板来展示。
数据是如何组织的
图数据直接在 MyGraph.tsx 中以内联方式声明为一个 RGJsonData 对象,其中包含 rootId: 'root'、28 个节点和 26 条连线。其结构包括一个隐藏的脚手架根节点、两个固定的分组中心节点(g1 和 g2)、一个隐藏的汇总节点(merge-result),以及左右两侧各 12 张星座卡片。所有脚手架连线都以 opacity: 0 加载,因此它们用于组织布局,但不会成为主要的可见内容。
在数据加载之前会做一层轻量预处理。每个节点都会得到 node.data.checked = false,每条连线如果缺少 ID 则会生成一个。执行 setJsonData(...) 之后,这个示例会先将两个中心节点重新放置到力导布局的两侧,再运行 doLayout()。
契合度文本单独存放在 ZodiacPartnerData.ts 中,形式是一个本地查找字符串。searchResult(...) 会按任意顺序搜索所选的星座组合,并返回匹配到的 good 和 bad 文本。在真实产品中,同样的图结构也可以由契合度矩阵、推荐配对、导师与学员匹配分数、买卖双方匹配规则,或任何其他双侧比较数据集来驱动。
relation-graph 是如何使用的
页面挂载在 RGProvider 下,MyGraph 使用 RGHooks.useGraphInstance() 作为主要控制入口。图配置让场景保持在查看模式,同时仍允许在运行时发生变化:layoutName: 'force'、maxLayoutTimes: 160、force_node_repulsion: 1、RGNodeShape.rect、自动尺寸节点、RGLineShape.StandardCurve、沿路径排列的连线标签,以及位于右下角的水平工具栏。默认节点填充色是透明的,默认连线颜色也较为克制,因为实际可见的表现由自定义 slot 标记提供,而不是内置节点渲染器。
relation-graph 的主要工作流是由实例驱动的。initializeGraph() 会停止自动布局、加载准备好的 JSON、将两个中心节点固定到两侧、运行布局、启用画布动画,并调用两次 zoomToFit() 来完成场景取景。之后,onNodeClick(...) 使用 getNodes()、updateNode(...) 和 updateNodeData(...) 来保证每一侧只会有一个被选中的星座。随后,mergeNodes() 会使用 getNodeById(...)、getLinks()、removeLink(...)、removeLineByIds(...) 和 addLines(...) 重新构建两条指向结果卡片的连接线。
RGSlotOnNode 是关键的渲染定制点。它会根据节点类型在占位根节点、大型 merge-result 面板和普通星座卡片之间切换。普通卡片使用远程雪碧图配合 imgOffset 来选择当前星座图像,被选中的卡片会从较短的卡片扩展为更高的卡片。样式拆分在 slot 标记中的 Tailwind 工具类与本地 SCSS 文件之间,后者负责覆盖内部的 checked-node 和 checked-line 样式。
悬浮辅助窗口是一个共享子组件,而不是星座专用的图逻辑。DraggableWindow 可以拖拽或最小化,它的设置面板通过 RGHooks.useGraphStore() 加上 setOptions(...) 来切换滚轮和拖拽行为。这个辅助窗口还通过调用 prepareForImageGeneration() 和 restoreAfterImageGeneration() 暴露了图片导出能力。
关键交互
- 点击星座卡片时会忽略脚手架节点,并且只会在该卡片所属的一侧切换一个激活选择。
- 在某一侧选择新的卡片时,会先清除该分组中此前任何已勾选的卡片,再应用新的状态。
- 当左右两侧都各有一张已勾选的卡片时,隐藏的
merge-result节点会变为可见,并显示所选组合的标题以及优缺点。 - 任意一侧的选择发生变化时,旧的结果连线会被移除,并从当前选中的卡片向汇总节点新增两条连接线。
- 拖拽卡片只是临时行为;
onNodeDragEnd会重新启动自动布局,让力导场景再次收敛。 - 悬浮辅助窗口可以被移动、最小化、切换到设置面板,并用于将图导出为图片。
关键代码片段
这个片段展示了图数据在加载前会经过轻量预处理:每个节点都会得到一个 checked 标记,每条连线都会得到一个稳定的 ID。
graphJsonData.nodes.forEach(node => {
node.data.checked = false;
});
const myJsonData: RGJsonData = {
...graphJsonData,
lines: graphJsonData.lines.map((line, index) => ({
...line,
id: line.id || `line-${index}`
}))
};
这个片段展示了初始化流程:加载数据、固定已设计好的构图、运行布局,并让视口适配内容。
graphInstance.stopAutoLayout();
await graphInstance.setJsonData(myJsonData);
rotateLevel1Nodes();
await graphInstance.doLayout();
graphInstance.enableCanvasAnimation();
await graphInstance.sleep(200);
graphInstance.zoomToFit();
await graphInstance.sleep(800);
graphInstance.zoomToFit();
graphInstance.disableCanvasAnimation();
这个片段证明,契合度文本来自本地查找辅助逻辑,而不是来自边标签或预计算的节点字段。
const searchResultByTitle = (title: string) => {
const startIndex = ZodiacDataBaseString.indexOf(title);
if (startIndex === -1) {
return null;
}
const goodStart = ZodiacDataBaseString.indexOf('好的方面:', startIndex);
const goodEnd = ZodiacDataBaseString.indexOf('\n', goodStart);
const badStart = ZodiacDataBaseString.indexOf('坏的方面:', goodEnd);
const badEnd = ZodiacDataBaseString.indexOf('\n', badStart);
return {
good: ZodiacDataBaseString.substring(goodStart + 5, goodEnd),
bad: ZodiacDataBaseString.substring(badStart + 5, badEnd)
};
};
这个片段展示了选择在图的每一侧都是互斥的,而不是一个多选高亮系统。
const onNodeClick = (node: RGNode) => {
if (node.id === 'root' || node.id === 'merge-result') return;
const currentChecked = !node.data?.checked;
const group = node.data?.group;
graphInstance.getNodes().forEach(n => {
if (n.data?.group === group) {
graphInstance.updateNode(n.id, { force_weight: 1 });
graphInstance.updateNodeData(n.id, { checked: false });
}
});
这个片段展示了结果卡片如何由实时图更新驱动:旧的结果连线会被移除,隐藏节点会显示出来,并向所选组合附加两条新的连接线。
if (leftNode && rightNode && mergeNode) {
const result = searchResult(leftNode.text, rightNode.text);
setResultContent({ title: `${leftNode.text} & ${rightNode.text}`, good: result?.good || '', bad: result?.bad || '' });
graphInstance.getLinks().forEach(link => {
if (link.toNode.id === 'merge-result') graphInstance.removeLink(link);
});
graphInstance.updateNode('merge-result', { hidden: false });
graphInstance.removeLineByIds(['left-to-merge-line', 'right-to-merge-line']);
graphInstance.addLines([
{ id: 'left-to-merge-line', from: leftNode.id, to: mergeNode.id, lineWidth: 3, color: '#172144', toJunctionPoint: RGJunctionPoint.right },
{ id: 'right-to-merge-line', from: rightNode.id, to: mergeNode.id, lineWidth: 3, color: '#172144', toJunctionPoint: RGJunctionPoint.left }
]);
}
这个片段展示了一个 slot 渲染器同时负责大型汇总面板和基于雪碧图的星座卡片。
<RGSlotOnNode>
{({ node }) => {
if (node.id === 'merge-result') return (
<div className="w-[650px] h-[325px] bg-white border-2 border-[#172144] rounded-xl rounded-tl-[50px] rounded-br-[50px] p-8 shadow-2xl overflow-hidden">
<div className="text-[40px] font-bold text-[#172144] mb-4">{resultContent.title} Match Result</div>
{/* pros and cons blocks omitted */}
</div>
);
const isChecked = node.data?.checked;
这个示例的独特之处
根据对比数据,这个示例最突出的特点是它是一张成对选择图,而不是一个通用的力导布局展示。与 toys-galaxy 相比,它同样使用了隐藏脚手架结构和拖拽结束后恢复布局,但目的完全不同:这里是为了由选择驱动的合成结果,而不是动画轨道行为或脚手架检查。与 multiple-expand-buttons 相比,这种双侧构图也不是为了分支可见性,而是为了让每一侧始终保持一个激活选择,并在图内生成第三个界面来汇总所选组合。
最强的稀有组合正是预处理数据已经凸显出的那组特征:由固定对向中心节点稳定下来的力导布局、用 RGSlotOnNode 同时渲染紧凑卡片和大型结果面板、按分组互斥的选择更新,以及由选择驱动的隐藏汇总节点显示与重新连线。样式设计也让这个示例进一步偏离了普通技术演示。柔和的粉色画布、基于雪碧图的卡片和被选中后的展开状态,让它看起来更像一个生活方式配对界面,而不是图表查看器。
相较于像 css-theme 这样偏重样式的邻近示例,以及像 table-relationship 这样的 HTML 卡片示例,这里的自定义呈现并不是唯一重点。更可复用的思路在于,图状态、slot 渲染 UI 与成对业务逻辑被绑定在一起,使两个独立选择能够在同一张画布中驱动一个合成答案。
这种模式还适用于哪里
这种模式很适合迁移到任何需要从 A 组选择一个对象、从 B 组选择一个对象,并生成组合结果的查看器中。比如候选人与岗位匹配、导师与学员配对、买卖双方契合度、症状与治疗推荐查看器、产品组合适配探索,或朋友与目的地的旅行匹配。
当团队希望图画布本身既承载选择器,也承载解释界面时,这种模式也非常有用。在不把示例改造成完整编辑器、也不需要在每次点击后重建整个数据集的前提下,同样的结构还可以扩展为评分汇总、置信徽标、警告信息或对比卡片。