mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(canvas): shared 26px grid + collision-free node placement
Nodes snapped to the dot-grid lattice on drop; occupied spots resolve to the nearest free lattice cell instead of stacking. Same protection inside groups (sibling-aware), on inspector assign, and on ungroup. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
7fc6ed1215
commit
6661357963
2 files changed
+182
-39
No files matched your search
@@ -180,6 +180,59 @@ const GROUP_PAD_X = 28;
|
|||||||
const GROUP_PAD_TOP = 56;
|
const GROUP_PAD_TOP = 56;
|
||||||
const GROUP_PAD_BOTTOM = 24;
|
const GROUP_PAD_BOTTOM = 24;
|
||||||
const GROUP_ROW_GAP = 16;
|
const GROUP_ROW_GAP = 16;
|
||||||
|
const GRID_STEP = 26;
|
||||||
|
const NODE_GAP = 14;
|
||||||
|
|
||||||
|
type Rect = { x: number; y: number; w: number; h: number };
|
||||||
|
|
||||||
|
function rectsOverlap(a: Rect, b: Rect): boolean {
|
||||||
|
return (
|
||||||
|
a.x < b.x + b.w + NODE_GAP &&
|
||||||
|
a.x + a.w + NODE_GAP > b.x &&
|
||||||
|
a.y < b.y + b.h + NODE_GAP &&
|
||||||
|
a.y + a.h + NODE_GAP > b.y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nearest grid-aligned position where a node fits without touching any
|
||||||
|
// occupied rect. Spiral-searches outward on the canvas lattice; falls back to
|
||||||
|
// the given position when the neighbourhood is fully boxed in.
|
||||||
|
function resolveFreeSpot(
|
||||||
|
desired: { x: number; y: number },
|
||||||
|
occupied: Rect[],
|
||||||
|
fallback: { x: number; y: number },
|
||||||
|
bounds?: Rect,
|
||||||
|
): { x: number; y: number } {
|
||||||
|
const snap = (v: number) => Math.round(v / GRID_STEP) * GRID_STEP;
|
||||||
|
const base = { x: snap(desired.x), y: snap(desired.y) };
|
||||||
|
const fits = (x: number, y: number) => {
|
||||||
|
if (bounds && (x < bounds.x || y < bounds.y || x + NODE_WIDTH > bounds.x + bounds.w || y + NODE_HEIGHT > bounds.y + bounds.h)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !occupied.some((rect) => rectsOverlap({ x, y, w: NODE_WIDTH, h: NODE_HEIGHT }, rect));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fits(base.x, base.y)) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let ring = 1; ring <= 12; ring++) {
|
||||||
|
for (let dx = -ring; dx <= ring; dx++) {
|
||||||
|
for (let dy = -ring; dy <= ring; dy++) {
|
||||||
|
if (Math.max(Math.abs(dx), Math.abs(dy)) !== ring) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const x = base.x + dx * GRID_STEP;
|
||||||
|
const y = base.y + dy * GRID_STEP;
|
||||||
|
if (fits(x, y)) {
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
// Where an incoming service lands inside a group frame. If the point already
|
// Where an incoming service lands inside a group frame. If the point already
|
||||||
// fits inside the padded area, honour it. Otherwise append beneath the lowest
|
// fits inside the padded area, honour it. Otherwise append beneath the lowest
|
||||||
@@ -196,14 +249,29 @@ function groupSlot(
|
|||||||
const width = typeof group.style?.width === 'number' ? group.style.width : GROUP_DEFAULT_WIDTH;
|
const width = typeof group.style?.width === 'number' ? group.style.width : GROUP_DEFAULT_WIDTH;
|
||||||
const height = typeof group.style?.height === 'number' ? group.style.height : GROUP_DEFAULT_HEIGHT;
|
const height = typeof group.style?.height === 'number' ? group.style.height : GROUP_DEFAULT_HEIGHT;
|
||||||
|
|
||||||
const fits =
|
const members = nodes.filter(
|
||||||
desiredX >= GROUP_PAD_X &&
|
(node) => node.type === 'serviceNode' && node.parentId === groupId && node.id !== ignoreServiceId,
|
||||||
desiredY >= GROUP_PAD_TOP &&
|
);
|
||||||
desiredX + NODE_WIDTH <= width - GROUP_PAD_X &&
|
const memberRects: Rect[] = members.map((node) => ({
|
||||||
desiredY + NODE_HEIGHT <= height - GROUP_PAD_BOTTOM;
|
x: node.position.x,
|
||||||
|
y: node.position.y,
|
||||||
|
w: NODE_WIDTH,
|
||||||
|
h: NODE_HEIGHT,
|
||||||
|
}));
|
||||||
|
const snap = (v: number) => Math.round(v / GRID_STEP) * GRID_STEP;
|
||||||
|
const snappedX = snap(desiredX);
|
||||||
|
const snappedY = snap(desiredY);
|
||||||
|
const snappedFits =
|
||||||
|
snappedX >= GROUP_PAD_X &&
|
||||||
|
snappedY >= GROUP_PAD_TOP &&
|
||||||
|
snappedX + NODE_WIDTH <= width - GROUP_PAD_X &&
|
||||||
|
snappedY + NODE_HEIGHT <= height - GROUP_PAD_BOTTOM;
|
||||||
|
const collides = memberRects.some((rect) =>
|
||||||
|
rectsOverlap({ x: snappedX, y: snappedY, w: NODE_WIDTH, h: NODE_HEIGHT }, rect),
|
||||||
|
);
|
||||||
|
|
||||||
if (fits) {
|
if (snappedFits && !collides) {
|
||||||
return { x: desiredX, y: desiredY, width, height };
|
return { x: snappedX, y: snappedY, width, height };
|
||||||
}
|
}
|
||||||
|
|
||||||
let bottom = GROUP_PAD_TOP - GROUP_ROW_GAP;
|
let bottom = GROUP_PAD_TOP - GROUP_ROW_GAP;
|
||||||
@@ -561,6 +629,27 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
return null;
|
return null;
|
||||||
}, [getInternalNode, nodes]);
|
}, [getInternalNode, nodes]);
|
||||||
|
|
||||||
|
const dragStartPositions = useRef<Map<string, { x: number; y: number }>>(new Map());
|
||||||
|
|
||||||
|
const onNodeDragStart = useCallback(
|
||||||
|
(_event: MouseEvent, node: CanvasNode) => {
|
||||||
|
const abs = getInternalNode(node.id)?.internals.positionAbsolute ?? node.position;
|
||||||
|
dragStartPositions.current.set(node.id, { ...abs });
|
||||||
|
},
|
||||||
|
[getInternalNode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const absoluteServiceRects = useCallback(
|
||||||
|
(excludeId: string): Rect[] =>
|
||||||
|
nodes
|
||||||
|
.filter((node) => node.type === 'serviceNode' && node.id !== excludeId)
|
||||||
|
.map((node) => {
|
||||||
|
const abs = getInternalNode(node.id)?.internals.positionAbsolute ?? node.position;
|
||||||
|
return { x: abs.x, y: abs.y, w: NODE_WIDTH, h: NODE_HEIGHT };
|
||||||
|
}),
|
||||||
|
[getInternalNode, nodes],
|
||||||
|
);
|
||||||
|
|
||||||
const onNodeDragStop = useCallback(
|
const onNodeDragStop = useCallback(
|
||||||
(_event: MouseEvent, movedNode: CanvasNode) => {
|
(_event: MouseEvent, movedNode: CanvasNode) => {
|
||||||
if (movedNode.type !== 'serviceNode') {
|
if (movedNode.type !== 'serviceNode') {
|
||||||
@@ -568,6 +657,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
}
|
}
|
||||||
|
|
||||||
const basePosition = getInternalNode(movedNode.id)?.internals.positionAbsolute ?? movedNode.position;
|
const basePosition = getInternalNode(movedNode.id)?.internals.positionAbsolute ?? movedNode.position;
|
||||||
|
const fallback = dragStartPositions.current.get(movedNode.id) ?? basePosition;
|
||||||
const centerX = basePosition.x + NODE_WIDTH / 2;
|
const centerX = basePosition.x + NODE_WIDTH / 2;
|
||||||
const centerY = basePosition.y + NODE_HEIGHT / 2;
|
const centerY = basePosition.y + NODE_HEIGHT / 2;
|
||||||
const targetGroup = getGroupUnderPoint(centerX, centerY, movedNode.parentId);
|
const targetGroup = getGroupUnderPoint(centerX, centerY, movedNode.parentId);
|
||||||
@@ -604,26 +694,54 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!movedNode.parentId) {
|
// Member staying inside its own frame — keep membership, but resolve
|
||||||
return;
|
// against siblings so nodes can't stack on each other.
|
||||||
}
|
if (movedNode.parentId) {
|
||||||
|
const parent = nodes.find((entry) => entry.id === movedNode.parentId && entry.type === 'groupNode');
|
||||||
// Still inside its own frame — keep membership, the relative move stands.
|
if (parent) {
|
||||||
const parent = nodes.find((entry) => entry.id === movedNode.parentId && entry.type === 'groupNode');
|
const parentAbs = getInternalNode(parent.id)?.internals.positionAbsolute ?? parent.position;
|
||||||
if (parent) {
|
const parentW = typeof parent.style?.width === 'number' ? parent.style.width : GROUP_DEFAULT_WIDTH;
|
||||||
const parentAbs = getInternalNode(parent.id)?.internals.positionAbsolute ?? parent.position;
|
const parentH = typeof parent.style?.height === 'number' ? parent.style.height : GROUP_DEFAULT_HEIGHT;
|
||||||
const parentW = typeof parent.style?.width === 'number' ? parent.style.width : GROUP_DEFAULT_WIDTH;
|
const inside =
|
||||||
const parentH = typeof parent.style?.height === 'number' ? parent.style.height : GROUP_DEFAULT_HEIGHT;
|
centerX >= parentAbs.x &&
|
||||||
const inside =
|
centerX <= parentAbs.x + parentW &&
|
||||||
centerX >= parentAbs.x &&
|
centerY >= parentAbs.y &&
|
||||||
centerX <= parentAbs.x + parentW &&
|
centerY <= parentAbs.y + parentH;
|
||||||
centerY >= parentAbs.y &&
|
if (inside) {
|
||||||
centerY <= parentAbs.y + parentH;
|
const siblingRects: Rect[] = nodes
|
||||||
if (inside) {
|
.filter(
|
||||||
return;
|
(entry) =>
|
||||||
|
entry.type === 'serviceNode' && entry.parentId === parent.id && entry.id !== movedNode.id,
|
||||||
|
)
|
||||||
|
.map((entry) => ({ x: entry.position.x, y: entry.position.y, w: NODE_WIDTH, h: NODE_HEIGHT }));
|
||||||
|
const relDesired = { x: basePosition.x - parentAbs.x, y: basePosition.y - parentAbs.y };
|
||||||
|
const relFallback = {
|
||||||
|
x: Math.max(GROUP_PAD_X, Math.min(parentW - GROUP_PAD_X - NODE_WIDTH, fallback.x - parentAbs.x)),
|
||||||
|
y: Math.max(GROUP_PAD_TOP, Math.min(parentH - GROUP_PAD_BOTTOM - NODE_HEIGHT, fallback.y - parentAbs.y)),
|
||||||
|
};
|
||||||
|
const bounds: Rect = {
|
||||||
|
x: GROUP_PAD_X,
|
||||||
|
y: GROUP_PAD_TOP,
|
||||||
|
w: Math.max(NODE_WIDTH, parentW - GROUP_PAD_X * 2),
|
||||||
|
h: Math.max(NODE_HEIGHT, parentH - GROUP_PAD_TOP - GROUP_PAD_BOTTOM),
|
||||||
|
};
|
||||||
|
const spot = resolveFreeSpot(relDesired, siblingRects, relFallback, bounds);
|
||||||
|
setNodes((current) =>
|
||||||
|
current.map((entry) =>
|
||||||
|
entry.id === movedNode.id && entry.type === 'serviceNode'
|
||||||
|
? { ...entry, position: spot }
|
||||||
|
: entry,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Free placement — snapped to the canvas grid and pushed off any node it
|
||||||
|
// would overlap; reverts to the pre-drag spot if fully boxed in.
|
||||||
|
const spot = resolveFreeSpot(basePosition, absoluteServiceRects(movedNode.id), fallback);
|
||||||
|
|
||||||
setNodes((current) =>
|
setNodes((current) =>
|
||||||
current.map((node) => {
|
current.map((node) => {
|
||||||
if (node.id !== movedNode.id || node.type !== 'serviceNode') {
|
if (node.id !== movedNode.id || node.type !== 'serviceNode') {
|
||||||
@@ -634,15 +752,12 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
...node,
|
...node,
|
||||||
parentId: undefined,
|
parentId: undefined,
|
||||||
extent: undefined,
|
extent: undefined,
|
||||||
position: {
|
position: spot,
|
||||||
x: basePosition.x,
|
|
||||||
y: basePosition.y,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[getGroupUnderPoint, getInternalNode, nodes, setNodes],
|
[absoluteServiceRects, getGroupUnderPoint, getInternalNode, nodes, setNodes],
|
||||||
);
|
);
|
||||||
|
|
||||||
const addGroup = useCallback(
|
const addGroup = useCallback(
|
||||||
@@ -696,13 +811,20 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
const abs = getInternalNode(serviceId)?.internals.positionAbsolute ?? node.position;
|
const abs = getInternalNode(serviceId)?.internals.positionAbsolute ?? node.position;
|
||||||
|
|
||||||
if (groupId === null) {
|
if (groupId === null) {
|
||||||
setNodes((current) =>
|
setNodes((current) => {
|
||||||
current.map((entry) =>
|
const occupied: Rect[] = current
|
||||||
|
.filter((entry) => entry.type === 'serviceNode' && entry.id !== serviceId)
|
||||||
|
.map((entry) => {
|
||||||
|
const entryAbs = getInternalNode(entry.id)?.internals.positionAbsolute ?? entry.position;
|
||||||
|
return { x: entryAbs.x, y: entryAbs.y, w: NODE_WIDTH, h: NODE_HEIGHT };
|
||||||
|
});
|
||||||
|
const spot = resolveFreeSpot(abs, occupied, abs);
|
||||||
|
return current.map((entry) =>
|
||||||
entry.id === serviceId && entry.type === 'serviceNode'
|
entry.id === serviceId && entry.type === 'serviceNode'
|
||||||
? { ...entry, parentId: undefined, extent: undefined, position: abs }
|
? { ...entry, parentId: undefined, extent: undefined, position: spot }
|
||||||
: entry,
|
: entry,
|
||||||
),
|
);
|
||||||
);
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,12 +895,27 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
const deleteGroup = useCallback(
|
const deleteGroup = useCallback(
|
||||||
(groupId: string) => {
|
(groupId: string) => {
|
||||||
setNodes((current) => {
|
setNodes((current) => {
|
||||||
|
const memberIds = new Set(
|
||||||
|
current
|
||||||
|
.filter((entry) => entry.type === 'serviceNode' && entry.parentId === groupId)
|
||||||
|
.map((entry) => entry.id),
|
||||||
|
);
|
||||||
|
// Freed members must not land on free nodes — resolve each in turn and
|
||||||
|
// treat already-placed members as occupied for the next.
|
||||||
|
const occupied: Rect[] = current
|
||||||
|
.filter((entry) => entry.type === 'serviceNode' && !memberIds.has(entry.id))
|
||||||
|
.map((entry) => {
|
||||||
|
const abs = getInternalNode(entry.id)?.internals.positionAbsolute ?? entry.position;
|
||||||
|
return { x: abs.x, y: abs.y, w: NODE_WIDTH, h: NODE_HEIGHT };
|
||||||
|
});
|
||||||
const unparented = current.map((entry) => {
|
const unparented = current.map((entry) => {
|
||||||
if (entry.type !== 'serviceNode' || entry.parentId !== groupId) {
|
if (entry.type !== 'serviceNode' || entry.parentId !== groupId) {
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
const abs = getInternalNode(entry.id)?.internals.positionAbsolute ?? entry.position;
|
const abs = getInternalNode(entry.id)?.internals.positionAbsolute ?? entry.position;
|
||||||
return { ...entry, parentId: undefined, extent: undefined, position: abs };
|
const spot = resolveFreeSpot(abs, occupied, abs);
|
||||||
|
occupied.push({ x: spot.x, y: spot.y, w: NODE_WIDTH, h: NODE_HEIGHT });
|
||||||
|
return { ...entry, parentId: undefined, extent: undefined, position: spot };
|
||||||
});
|
});
|
||||||
return unparented.filter((entry) => entry.id !== groupId);
|
return unparented.filter((entry) => entry.id !== groupId);
|
||||||
});
|
});
|
||||||
@@ -913,7 +1050,10 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
|||||||
}}
|
}}
|
||||||
onNodeContextMenu={onNodeContextMenu}
|
onNodeContextMenu={onNodeContextMenu}
|
||||||
onPaneContextMenu={onPaneContextMenu}
|
onPaneContextMenu={onPaneContextMenu}
|
||||||
|
onNodeDragStart={onNodeDragStart}
|
||||||
onNodeDragStop={onNodeDragStop}
|
onNodeDragStop={onNodeDragStop}
|
||||||
|
snapToGrid
|
||||||
|
snapGrid={[GRID_STEP, GRID_STEP]}
|
||||||
onMoveEnd={() => setViewportTick((value) => value + 1)}
|
onMoveEnd={() => setViewportTick((value) => value + 1)}
|
||||||
panOnDrag
|
panOnDrag
|
||||||
zoomOnScroll
|
zoomOnScroll
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { inferAutoConnections } from '../auto-connections';
|
|||||||
|
|
||||||
export const NODE_WIDTH = 240;
|
export const NODE_WIDTH = 240;
|
||||||
export const NODE_HEIGHT = 104;
|
export const NODE_HEIGHT = 104;
|
||||||
export const COL_GAP = 340;
|
// Multiples of the 26px canvas grid so laid-out nodes land on the lattice.
|
||||||
export const ROW_GAP = 172;
|
export const COL_GAP = 338;
|
||||||
|
export const ROW_GAP = 182;
|
||||||
|
|
||||||
type LinkEdge = { sourceServiceId: string; targetServiceId: string };
|
type LinkEdge = { sourceServiceId: string; targetServiceId: string };
|
||||||
|
|
||||||
@@ -81,8 +82,10 @@ export function computeLayeredLayout(
|
|||||||
looseServices.length,
|
looseServices.length,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const gridOffset = (value: number) => Math.round(value / 26) * 26;
|
||||||
|
|
||||||
for (const [col, list] of columns) {
|
for (const [col, list] of columns) {
|
||||||
const offsetY = ((maxRows - list.length) * ROW_GAP) / 2;
|
const offsetY = gridOffset(((maxRows - list.length) * ROW_GAP) / 2);
|
||||||
list.forEach((service, row) => {
|
list.forEach((service, row) => {
|
||||||
positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP });
|
positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP });
|
||||||
});
|
});
|
||||||
@@ -90,7 +93,7 @@ export function computeLayeredLayout(
|
|||||||
|
|
||||||
looseServices.forEach((service, row) => {
|
looseServices.forEach((service, row) => {
|
||||||
const col = connectedColCount > 0 ? connectedColCount : 0;
|
const col = connectedColCount > 0 ? connectedColCount : 0;
|
||||||
const offsetY = ((maxRows - looseServices.length) * ROW_GAP) / 2;
|
const offsetY = gridOffset(((maxRows - looseServices.length) * ROW_GAP) / 2);
|
||||||
positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP });
|
positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user