Skip to content

Commit f2e31d8

Browse files
authored
ui: NodeGraph: Add API to find non-overlapping placement for new nodes (#3479)
1 parent bf049f3 commit f2e31d8

File tree

2 files changed

+149
-6
lines changed

2 files changed

+149
-6
lines changed

ui/src/plugins/dev.perfetto.WidgetsPage/nodegraph_demo.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@
1313
// limitations under the License.
1414

1515
import m from 'mithril';
16-
import {Connection, Node, NodeGraph} from '../../widgets/nodegraph';
16+
import {
17+
Connection,
18+
Node,
19+
NodeGraph,
20+
NodeGraphApi,
21+
} from '../../widgets/nodegraph';
1722
import {Checkbox} from '../../widgets/checkbox';
1823
import {PopupMenu} from '../../widgets/menu';
1924
import {MenuItem} from '../../widgets/menu';
@@ -39,8 +44,17 @@ interface NodeTemplate {
3944
allInputsLeft?: boolean;
4045
}
4146

42-
export function NodeGraphDemo() {
47+
interface NodeGraphDemoAttrs {
48+
readonly titleBars?: boolean;
49+
readonly accentBars?: boolean;
50+
readonly allInputsLeft?: boolean;
51+
readonly allOutputsRight?: boolean;
52+
readonly multiselect?: boolean;
53+
}
54+
55+
export function NodeGraphDemo(): m.Component<NodeGraphDemoAttrs> {
4356
let selectedNodeId: string | null = null;
57+
let graphApi: NodeGraphApi | undefined;
4458

4559
// State for select node checkboxes
4660
const columnOptions = {
@@ -61,17 +75,48 @@ export function NodeGraphDemo() {
6175
// State for filter expression
6276
let filterExpression = '';
6377

78+
// Helper to create a node template for placement calculation
79+
function createNodeTemplate(
80+
id: string,
81+
type: 'table' | 'select' | 'filter' | 'join',
82+
): Omit<Node, 'x' | 'y'> {
83+
const template = nodeTemplates[type];
84+
return {
85+
id,
86+
inputs: template.inputs,
87+
outputs: template.outputs,
88+
content: template.content,
89+
hue: nodeHues[type],
90+
};
91+
}
92+
6493
// Function to add a new node
6594
function addNode(
6695
type: 'table' | 'select' | 'filter' | 'join',
6796
toNodeId?: string,
6897
) {
6998
const id = uuidv4();
99+
100+
let x: number;
101+
let y: number;
102+
103+
// Use API to find optimal placement if available
104+
if (graphApi && !toNodeId) {
105+
const nodeTemplate = createNodeTemplate(id, type);
106+
const placement = graphApi.findPlacementForNode(nodeTemplate);
107+
x = placement.x;
108+
y = placement.y;
109+
} else {
110+
// Fallback to random position
111+
x = 100 + Math.random() * 200;
112+
y = 50 + Math.random() * 200;
113+
}
114+
70115
const newNode: ModelNode = {
71116
id: id,
72117
type: type,
73-
x: 100 + Math.random() * 200, // Random offset
74-
y: 50 + Math.random() * 200,
118+
x,
119+
y,
75120
};
76121
modelNodes.set(newNode.id, newNode);
77122
if (toNodeId) {
@@ -92,7 +137,6 @@ export function NodeGraphDemo() {
92137
// Model state - persists across renders
93138
const modelNodes: Map<string, ModelNode> = new Map();
94139
addNode('table');
95-
addNode('select');
96140

97141
// Template renderers - map from type to node template
98142
const nodeTemplates: Record<string, NodeTemplate> = {
@@ -327,6 +371,9 @@ export function NodeGraphDemo() {
327371
nodes: renderNodes(),
328372
connections: connections,
329373
selectedNodeId: selectedNodeId,
374+
onReady: (api: NodeGraphApi) => {
375+
graphApi = api;
376+
},
330377
onNodeDrag: (nodeId: string, x: number, y: number) => {
331378
const model = modelNodes.get(nodeId);
332379
if (model) {

ui/src/widgets/nodegraph.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ interface CanvasState {
9797
export interface NodeGraphApi {
9898
autoLayout: () => void;
9999
recenter: () => void;
100+
findPlacementForNode: (node: Omit<Node, 'x' | 'y'>) => Position;
100101
}
101102

102103
export interface NodeGraphAttrs {
@@ -1128,9 +1129,104 @@ export function NodeGraph(): m.Component<NodeGraphAttrs> {
11281129
autofit(nodes, canvas);
11291130
};
11301131

1132+
// Find a non-overlapping position for a new node
1133+
const findPlacementForNode = (
1134+
newNode: Omit<Node, 'x' | 'y'>,
1135+
): Position => {
1136+
if (latestVnode === null || canvasElement === null) {
1137+
return {x: 0, y: 0};
1138+
}
1139+
1140+
const {nodes = []} = latestVnode.attrs;
1141+
const canvas = canvasElement;
1142+
1143+
// Default starting position (center of viewport in canvas space)
1144+
const canvasRect = canvas.getBoundingClientRect();
1145+
const centerX =
1146+
(canvasRect.width / 2 - canvasState.panOffset.x) / canvasState.zoom;
1147+
const centerY =
1148+
(canvasRect.height / 2 - canvasState.panOffset.y) / canvasState.zoom;
1149+
1150+
// Create a temporary node with coordinates to render and measure
1151+
const tempNode: Node = {
1152+
...newNode,
1153+
x: centerX,
1154+
y: centerY,
1155+
};
1156+
1157+
// Create temporary DOM element to measure size
1158+
const tempContainer = document.createElement('div');
1159+
tempContainer.style.position = 'absolute';
1160+
tempContainer.style.left = '-9999px';
1161+
tempContainer.style.visibility = 'hidden';
1162+
canvas.appendChild(tempContainer);
1163+
1164+
// Render the node into the temporary container
1165+
m.render(
1166+
tempContainer,
1167+
m(
1168+
'.pf-node',
1169+
{
1170+
'data-node': tempNode.id,
1171+
'style': {
1172+
...(tempNode.hue !== undefined
1173+
? {'--pf-node-hue': `${tempNode.hue}`}
1174+
: {}),
1175+
},
1176+
},
1177+
[
1178+
tempNode.titleBar &&
1179+
m('.pf-node-header', [
1180+
m('.pf-node-title', tempNode.titleBar.title),
1181+
]),
1182+
m('.pf-node-body', [
1183+
tempNode.content !== undefined &&
1184+
m('.pf-node-content', tempNode.content),
1185+
tempNode.inputs
1186+
?.slice(tempNode.allInputsLeft ? 0 : 1)
1187+
.map((input: string) =>
1188+
m('.pf-port-row.pf-port-input', [m('span', input)]),
1189+
),
1190+
tempNode.outputs
1191+
?.slice(tempNode.allOutputsRight ? 0 : 1)
1192+
.map((output: string) =>
1193+
m('.pf-port-row.pf-port-output', [m('span', output)]),
1194+
),
1195+
]),
1196+
],
1197+
),
1198+
);
1199+
1200+
// Get dimensions from the rendered element
1201+
const dims = getNodeDimensions(tempNode.id);
1202+
1203+
// Calculate chain height
1204+
const chain = getChain(tempNode);
1205+
let chainHeight = 0;
1206+
chain.forEach((chainNode) => {
1207+
const chainDims = getNodeDimensions(chainNode.id);
1208+
chainHeight += chainDims.height;
1209+
});
1210+
1211+
// Clean up temporary element
1212+
canvas.removeChild(tempContainer);
1213+
1214+
// Find non-overlapping position starting from center
1215+
const finalPos = findNearestNonOverlappingPosition(
1216+
centerX,
1217+
centerY,
1218+
tempNode.id,
1219+
nodes,
1220+
dims.width,
1221+
chainHeight,
1222+
);
1223+
1224+
return finalPos;
1225+
};
1226+
11311227
// Provide API to parent
11321228
if (onReady) {
1133-
onReady({autoLayout, recenter});
1229+
onReady({autoLayout, recenter, findPlacementForNode});
11341230
}
11351231
},
11361232

0 commit comments

Comments
 (0)