JavaScript is required

Zodiac Compatibility Matcher

This example turns a force-layout graph into a two-sided zodiac matcher with mirrored card clusters and a hidden summary node. After one sign is selected on each side, the graph reveals a large in-canvas result card and rewires two connectors to the chosen pair.

Two-Sided Zodiac Compatibility Matcher with a Dynamic Result Card

What This Example Builds

This example builds a full-screen zodiac compatibility matcher inside one relation-graph canvas. Users see two mirrored clusters of zodiac cards, pick one sign from the left group and one from the right group, and then get a large central result card that summarizes the pair with pros and cons.

The most important point is that the graph is not only a viewer. It also acts as the selection surface and the result surface at the same time. The matching outcome is revealed by graph state changes, not by opening a separate modal or side panel.

How the Data Is Organized

The graph data is declared inline in MyGraph.tsx as one RGJsonData object with rootId: 'root', 28 nodes, and 26 lines. The structure includes one hidden scaffold root, two fixed group hubs (g1 and g2), one hidden summary node (merge-result), and 12 zodiac cards on each side. All scaffold lines are loaded with opacity: 0, so they organize the layout without becoming the main visible content.

There is light preprocessing before the data is loaded. Every node gets node.data.checked = false, and every line receives a generated ID if one is missing. After setJsonData(...), the example repositions the two hub nodes onto opposite sides of the force layout before running doLayout().

The compatibility text is stored separately in ZodiacPartnerData.ts as one local lookup string. searchResult(...) searches that string for the selected zodiac pair in either order and returns the matching good and bad text. In a real product, the same graph structure could be fed by a compatibility matrix, recommendation pairs, mentor-mentee fit scores, buyer-seller matching rules, or any other two-sided comparison dataset.

How relation-graph Is Used

The page is mounted under RGProvider, and MyGraph uses RGHooks.useGraphInstance() as the main control surface. The graph options keep the scene in viewer mode while still allowing runtime changes: layoutName: 'force', maxLayoutTimes: 160, force_node_repulsion: 1, RGNodeShape.rect, auto-sized node dimensions, RGLineShape.StandardCurve, path-following line labels, and a horizontal toolbar at the bottom-right corner. The default node fill is transparent and the default line color is muted, because the visible presentation is supplied by custom slot markup instead of the built-in node renderer.

The main relation-graph workflow is instance-driven. initializeGraph() stops auto layout, loads the prepared JSON, fixes the two hub nodes onto opposite sides, runs layout, enables canvas animation, and calls zoomToFit() twice to frame the scene. Later, onNodeClick(...) uses getNodes(), updateNode(...), and updateNodeData(...) to enforce one checked zodiac per side. mergeNodes() then uses getNodeById(...), getLinks(), removeLink(...), removeLineByIds(...), and addLines(...) to rebuild the two connectors that point to the result card.

RGSlotOnNode is the key rendering customization. It branches between a placeholder root node, a large merge-result panel, and the regular zodiac cards. The regular cards use a remote sprite sheet plus imgOffset to choose the current zodiac image, and checked cards expand from a short card to a taller one. Styling is split between Tailwind utility classes in the slot markup and a local SCSS file that overrides internal checked-node and checked-line styles.

The floating helper window is a shared subcomponent rather than zodiac-specific graph logic. DraggableWindow can be dragged or minimized, and its settings overlay uses RGHooks.useGraphStore() plus setOptions(...) to switch wheel and drag behavior. The same helper also exposes image export by calling prepareForImageGeneration() and restoreAfterImageGeneration().

Key Interactions

  • Clicking a zodiac card ignores the scaffold nodes and toggles one active selection within that card’s side only.
  • Selecting a new card on one side clears any earlier checked card from the same group before the new state is applied.
  • When both sides have a checked card, the hidden merge-result node becomes visible and shows a title plus pros and cons for the chosen pair.
  • Changing either selection removes old result links and adds two fresh connectors from the currently selected cards to the summary node.
  • Dragging a card is temporary; onNodeDragEnd restarts automatic layout so the force scene settles again.
  • The floating helper window can be moved, minimized, switched into a settings panel, and used to export the graph as an image.

Key Code Fragments

This fragment shows that the graph data is lightly preprocessed before it is loaded: every node gets a checked flag and every line gets a stable 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}`
    }))
};

This fragment shows the initialization flow that loads the data, fixes the authored composition, runs layout, and fits the viewport.

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

This fragment proves that compatibility text comes from a local lookup helper instead of from edge labels or precomputed node fields.

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

This fragment shows that selection is exclusive within each side of the graph, not a multi-select highlight system.

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

This fragment shows how the result card is driven by live graph updates: old result links are removed, the hidden node is shown, and two new connectors are attached to the selected pair.

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

This fragment shows that one slot renderer is responsible for both the large summary panel and the sprite-backed zodiac cards.

<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;

What Makes This Example Distinct

According to the comparison data, this example is most distinct as a paired-choice graph rather than as a generic force-layout showcase. Compared with toys-galaxy, it uses hidden scaffold structure and drag-end layout resumption for a very different purpose: choice-driven synthesis instead of animated orbit behavior or scaffold inspection. Compared with multiple-expand-buttons, the two-sided composition is not about branch visibility. It is about keeping one active choice per side and producing a third in-graph surface that summarizes the selected pair.

The strongest rare combination is the one the preprocessing data already highlighted: force layout stabilized by fixed opposing hubs, RGSlotOnNode for both compact cards and a large result panel, exclusive per-group selection updates, and selection-driven reveal plus rewiring of a hidden summary node. The styling also pushes the example away from ordinary technical demos. The soft pink canvas, sprite-backed cards, and expanding checked state make it read more like a lifestyle matching interface than a diagram viewer.

Relative to styling-oriented neighbors such as css-theme and HTML-card demos such as table-relationship, the custom presentation is not the whole lesson. The more reusable idea is that graph state, slot-rendered UI, and pairwise business logic are tied together so that two independent picks can drive one synthesized answer inside the same canvas.

Where Else This Pattern Applies

This pattern transfers well to any viewer where one choice from group A and one choice from group B should produce a combined outcome. Examples include candidate-role matching, mentor-mentee pairing, buyer-seller compatibility, symptom-treatment recommendation viewers, product bundle fit exploration, or friend-and-destination trip matching.

It is also useful when teams want the graph canvas itself to host both the chooser and the explanation surface. The same structure can be extended to scoring summaries, confidence badges, warnings, or comparison cards without turning the example into a full editor or rebuilding the whole dataset after each click.