Skip to content

Commit 2ad87ae

Browse files
committed
Add rank direction controls and update layout handling
1 parent 42232aa commit 2ad87ae

File tree

7 files changed

+76
-29
lines changed

7 files changed

+76
-29
lines changed

langgraphics-web/src/components/Controls.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import {useEffect, useRef, useState} from "react";
22
import type {ColorMode} from "@xyflow/react";
3+
import type {RankDir} from "../layout";
34

45
interface ControlsProps {
56
colorMode: ColorMode;
67
setColorMode: (v: ColorMode) => void;
8+
rankDir: RankDir;
9+
setRankDir: (v: RankDir) => void;
710
goAuto: () => void;
811
goManual: () => void;
912
isManual: boolean;
@@ -15,7 +18,7 @@ const themeOptions: { value: ColorMode; label: string }[] = [
1518
{value: "dark", label: "Dark"},
1619
];
1720

18-
export function Controls({isManual, colorMode, setColorMode, goAuto, goManual}: ControlsProps) {
21+
export function Controls({isManual, colorMode, setColorMode, rankDir, setRankDir, goAuto, goManual}: ControlsProps) {
1922
const [open, setOpen] = useState(false);
2023
const ref = useRef<HTMLDivElement>(null);
2124

@@ -38,6 +41,10 @@ export function Controls({isManual, colorMode, setColorMode, goAuto, goManual}:
3841
<button className={isManual ? "" : "active"} onClick={goAuto}>Auto</button>
3942
<button className={isManual ? "active" : ""} onClick={goManual}>Manual</button>
4043
</div>
44+
<div className="mode-toggle">
45+
<button className={rankDir === "TB" ? "active" : ""} onClick={() => setRankDir("TB")}></button>
46+
<button className={rankDir === "LR" ? "active" : ""} onClick={() => setRankDir("LR")}></button>
47+
</div>
4148
<div className="theme-select" ref={ref}>
4249
<button className="theme-select-trigger" onClick={() => setOpen((v) => !v)}>
4350
{current.label}

langgraphics-web/src/components/GraphCanvas.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import {type ReactNode, useState} from "react";
1+
import {type ReactNode, useCallback, useState} from "react";
22
import {Background, type ColorMode, type Edge, type Node, type NodeTypes, ReactFlow} from "@xyflow/react";
33
import {Controls} from "./Controls";
44
import {CustomNode} from "./CustomNode";
55
import {useFocus} from "../hooks/useFocus";
66
import type {EdgeData, NodeData} from "../types";
7+
import type {RankDir} from "../layout";
78

89
const nodeTypes: NodeTypes = {custom: CustomNode as NodeTypes[string]};
910

@@ -12,11 +13,21 @@ interface GraphCanvasProps {
1213
edges: Edge<EdgeData>[];
1314
activeNodeId: string | null;
1415
inspect: ReactNode;
16+
initialRankDir?: RankDir;
17+
initialColorMode?: ColorMode;
18+
onRankDirChange?: (v: RankDir) => void;
1519
}
1620

17-
export function GraphCanvas({nodes, edges, activeNodeId, inspect}: GraphCanvasProps) {
18-
const [colorMode, setColorMode] = useState<ColorMode>("system");
19-
const {isManual, goAuto, goManual} = useFocus({nodes, edges, activeNodeId});
21+
export function GraphCanvas({nodes, edges, activeNodeId, inspect, initialColorMode = "system", initialRankDir = "TB", onRankDirChange}: GraphCanvasProps) {
22+
const [rankDir, setRankDir] = useState<RankDir>(initialRankDir);
23+
const [colorMode, setColorMode] = useState<ColorMode>(initialColorMode);
24+
const {isManual, goAuto, goManual, fitContent} = useFocus({nodes, edges, activeNodeId, rankDir});
25+
26+
const handleRankDirChange = useCallback(async (v: RankDir) => {
27+
setRankDir(v);
28+
onRankDirChange?.(v);
29+
await fitContent();
30+
}, [onRankDirChange, fitContent])
2031

2132
return (
2233
<ReactFlow
@@ -31,10 +42,12 @@ export function GraphCanvas({nodes, edges, activeNodeId, inspect}: GraphCanvasPr
3142
>
3243
<Controls
3344
goAuto={goAuto}
45+
rankDir={rankDir}
3446
goManual={goManual}
3547
isManual={isManual}
3648
colorMode={colorMode}
3749
setColorMode={setColorMode}
50+
setRankDir={handleRankDirChange}
3851
/>
3952
<Background/>
4053
{inspect}

langgraphics-web/src/hooks/useFocus.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,20 @@ import {useCallback, useEffect, useMemo, useRef, useState} from "react";
22
import type {Edge, Node} from "@xyflow/react";
33
import {useReactFlow} from "@xyflow/react";
44
import type {EdgeData, NodeData} from "../types";
5-
import {IS_HORIZONTAL} from "../layout";
5+
import type {RankDir} from "../layout";
66

77
interface UseFocusOptions {
88
nodes: Node<NodeData>[];
99
edges: Edge<EdgeData>[];
1010
activeNodeId: string | null;
11+
rankDir?: RankDir;
1112
}
1213

1314
const FIT_VIEW_DURATION = 1500;
1415

15-
function getNeighbourIds(nodeId: string, nodes: Node<NodeData>[], edges: Edge<EdgeData>[]): any[] {
16+
function getNeighbourIds(nodeId: string, nodes: Node<NodeData>[], edges: Edge<EdgeData>[], isHorizontal: boolean): any[] {
1617
const nodeRank = new Map<string, number>(
17-
nodes.map((n) => [n.id, IS_HORIZONTAL ? n.position.x : n.position.y]),
18+
nodes.map((n) => [n.id, isHorizontal ? n.position.x : n.position.y]),
1819
);
1920
const selfRank = nodeRank.get(nodeId) ?? 0;
2021

@@ -35,20 +36,25 @@ function getNeighbourIds(nodeId: string, nodes: Node<NodeData>[], edges: Edge<Ed
3536
return [before?.id, after?.id].filter((id): id is string => id !== undefined).map((id) => ({id}));
3637
}
3738

38-
export function useFocus({nodes, edges, activeNodeId}: UseFocusOptions) {
39+
export function useFocus({nodes, edges, activeNodeId, rankDir = "TB"}: UseFocusOptions) {
3940
const {fitView} = useReactFlow();
4041
const [mode, setMode] = useState<"auto" | "manual">("auto");
4142
const prevMode = useRef<"auto" | "manual">(mode);
4243
const initialDone = useRef(false);
4344
const prevFocusId = useRef<string | null>(null);
4445

4546
const isManual = useMemo(() => mode === "manual", [mode]);
47+
const isHorizontal = useMemo(() => ["LR", "RL"].includes(rankDir), [rankDir]);
4648

47-
const goAuto = useCallback(async () => {
48-
setMode("auto");
49+
const fitContent = useCallback(async () => {
4950
await fitView({duration: FIT_VIEW_DURATION});
5051
}, [fitView])
5152

53+
const goAuto = useCallback(async () => {
54+
setMode("auto");
55+
await fitContent();
56+
}, [fitContent])
57+
5258
const goManual = useCallback(() => {
5359
setMode("manual");
5460
}, [])
@@ -61,7 +67,7 @@ export function useFocus({nodes, edges, activeNodeId}: UseFocusOptions) {
6167
const startNode = nodes.find((n) => n.data.nodeType === "start");
6268
if (startNode) {
6369
fitView({
64-
nodes: [startNode, ...getNeighbourIds(startNode.id, nodes, edges)],
70+
nodes: [startNode, ...getNeighbourIds(startNode.id, nodes, edges, isHorizontal)],
6571
duration: 0,
6672
}).then();
6773
}
@@ -83,12 +89,12 @@ export function useFocus({nodes, edges, activeNodeId}: UseFocusOptions) {
8389
fitView({duration: FIT_VIEW_DURATION}).then();
8490
} else {
8591
fitView({
86-
nodes: [{id: activeNodeId}, ...getNeighbourIds(activeNodeId, nodes, edges)],
92+
nodes: [{id: activeNodeId}, ...getNeighbourIds(activeNodeId, nodes, edges, isHorizontal)],
8793
duration: FIT_VIEW_DURATION,
8894
}).then();
8995
}
9096
}
91-
}, [nodes, edges, activeNodeId, fitView, mode]);
97+
}, [nodes, edges, activeNodeId, fitView, mode, isHorizontal]);
9298

93-
return {isManual, goAuto, goManual};
99+
return {isManual, goAuto, goManual, fitContent};
94100
}

langgraphics-web/src/hooks/useGraphState.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {useMemo} from "react";
22
import {type Edge, MarkerType, type Node} from "@xyflow/react";
33
import type {EdgeData, EdgeStatus, ExecutionEvent, GraphMessage, NodeData, NodeStatus} from "../types";
4-
import {computeLayout} from "../layout";
4+
import {computeLayout, type RankDir} from "../layout";
55

66
export function computeStatuses(events: ExecutionEvent[]): {
77
nodeStatuses: Map<string, NodeStatus>;
@@ -45,11 +45,11 @@ export function computeStatuses(events: ExecutionEvent[]): {
4545
return {nodeStatuses, edgeStatuses};
4646
}
4747

48-
export function useGraphState(topology: GraphMessage | null, events: ExecutionEvent[]) {
48+
export function useGraphState(topology: GraphMessage | null, events: ExecutionEvent[], rankDir: RankDir = "TB") {
4949
const base = useMemo(() => {
5050
if (!topology) return {nodes: [] as Node<NodeData>[], edges: [] as Edge<EdgeData>[]};
51-
return computeLayout(topology);
52-
}, [topology]);
51+
return computeLayout(topology, rankDir);
52+
}, [topology, rankDir]);
5353

5454
return useMemo(() => {
5555
if (events.length === 0) return {nodes: base.nodes, edges: base.edges, activeNodeId: null as string | null};

langgraphics-web/src/hooks/useInspectTree.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export function useInspectTree(
116116
nodeOutputLog: NodeOutputEntry[],
117117
nodeStepLog: NodeStepEntry[],
118118
) {
119-
const [selectedKey, setSelectedKey] = useState<string>("");
119+
const [selectedKey, setSelectedKey] = useState<string>("log-0");
120120

121121
const depthMap = useMemo(
122122
() => topology ? computeDepthMap(topology) : new Map<string, NodeMeta>(),

langgraphics-web/src/layout.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,28 @@ import {Position} from "@xyflow/react";
33
import type {Edge, Node} from "@xyflow/react";
44
import type {EdgeData, GraphMessage, NodeData} from "./types";
55

6-
const RANK_DIR = "TB";
6+
export type RankDir = "TB" | "LR";
7+
78
const NODE_WIDTH = 180;
89
const NODE_HEIGHT = 60;
910
const SMALL_NODE_WIDTH = 120;
1011
const SMALL_NODE_HEIGHT = 40;
11-
const DIRECTIONS_MAP: any = {
12+
const DIRECTIONS_MAP: Record<string, Position> = {
1213
T: Position.Top, L: Position.Left,
1314
R: Position.Right, B: Position.Bottom,
14-
}
15-
const RANK_TO = DIRECTIONS_MAP[RANK_DIR[1]] as Position;
16-
const RANK_FROM = DIRECTIONS_MAP[RANK_DIR[0]] as Position;
17-
export const IS_HORIZONTAL = ["LR", "RL"].includes(RANK_DIR);
15+
};
1816

19-
export function computeLayout(topology: GraphMessage): {
17+
export function computeLayout(topology: GraphMessage, rankDir: RankDir = "TB"): {
2018
nodes: Node<NodeData>[];
2119
edges: Edge<EdgeData>[];
2220
} {
21+
const RANK_TO = DIRECTIONS_MAP[rankDir[1]] as Position;
22+
const RANK_FROM = DIRECTIONS_MAP[rankDir[0]] as Position;
23+
const IS_HORIZONTAL = ["LR", "RL"].includes(rankDir);
24+
2325
const g = new dagre.graphlib.Graph();
2426
g.setDefaultEdgeLabel(() => ({}));
25-
g.setGraph({rankdir: RANK_DIR, ranksep: 80, nodesep: 60, marginx: 20, marginy: 20});
27+
g.setGraph({rankdir: rankDir, ranksep: 80, nodesep: 60, marginx: 20, marginy: 20});
2628

2729
for (const n of topology.nodes) {
2830
const isTerminal = n.node_type === "start" || n.node_type === "end";

langgraphics-web/src/main.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,43 @@
1+
import {useState} from "react";
12
import {createRoot} from "react-dom/client";
3+
import {type ColorMode} from "@xyflow/react";
24
import {ReactFlowProvider} from "@xyflow/react";
35
import {useWebSocket} from "./hooks/useWebSocket";
46
import {useGraphState} from "./hooks/useGraphState";
57
import {GraphCanvas} from "./components/GraphCanvas";
68
import {InspectPanel} from "./components/InspectPanel";
9+
import type {RankDir} from "./layout";
710
import "@xyflow/react/dist/style.css";
811
import "./index.css";
912

1013
const WS_URL = "ws://localhost:8765";
1114

15+
function parseParams(): {colorMode: ColorMode; rankDir: RankDir} {
16+
const p = new URLSearchParams(window.location.search);
17+
const theme = p.get("theme") ?? "system";
18+
const direction = p.get("direction") ?? "TB";
19+
return {
20+
colorMode: (["system", "light", "dark"].includes(theme) ? theme : "system") as ColorMode,
21+
rankDir: (["TB", "LR"].includes(direction) ? direction : "TB") as RankDir,
22+
};
23+
}
24+
25+
const {colorMode: initialColorMode, rankDir: initialRankDir} = parseParams();
26+
1227
function Index() {
28+
const [rankDir, setRankDir] = useState<RankDir>(initialRankDir);
1329
const {topology, events, nodeOutputLog, nodeStepLog} = useWebSocket(WS_URL);
14-
const {nodes, edges, activeNodeId} = useGraphState(topology, events);
30+
const {nodes, edges, activeNodeId} = useGraphState(topology, events, rankDir);
1531

1632
return (
1733
<ReactFlowProvider>
1834
<GraphCanvas
1935
nodes={nodes}
2036
edges={edges}
2137
activeNodeId={activeNodeId}
38+
initialColorMode={initialColorMode}
39+
initialRankDir={initialRankDir}
40+
onRankDirChange={setRankDir}
2241
inspect={
2342
<InspectPanel
2443
nodes={nodes}

0 commit comments

Comments
 (0)