feat: Add auto-arrange button and functionality in ObjectFlowDesigner

This commit is contained in:
Chop
2025-06-27 00:05:12 +02:00
parent 8feeddc067
commit 9e046f9fa2
3 changed files with 602 additions and 0 deletions

View File

@@ -83,6 +83,13 @@
>
<div class="icon-delete"></div>
</button>
<button
id="autoArrangeBtn"
class="icon-btn tool-btn"
title="Auto Arrange - One Click to Make Everything Look Nice and Tidy"
>
<div class="icon-auto-arrange"></div>
</button>
</div>
</div>
</div>

353
script.js
View File

@@ -57,6 +57,11 @@ class ObjectFlowDesigner {
.getElementById("deleteBtn")
.addEventListener("click", () => this.setMode("delete"));
// Auto-arrange button - one simple button to make everything tidy
document
.getElementById("autoArrangeBtn")
.addEventListener("click", () => this.autoArrangeObjects());
// Control buttons
document
.getElementById("clearBtn")
@@ -1892,6 +1897,354 @@ class ObjectFlowDesigner {
this.calculateVoltageDrops();
this.render();
}
// Simple Auto-Arrange Tool - One Button to Make Everything Tidy
getSelectedObjects() {
// For now, we'll work with all objects if none specifically selected
// Later this could be enhanced to work with multi-selection
return this.objects.filter((obj) => obj === this.selectedObject);
}
getAllObjects() {
return this.objects;
}
alignObjects(direction) {
const objectsToAlign = this.getSelectedObjects();
// If no specific selection, work with all objects
if (objectsToAlign.length === 0) {
if (this.objects.length < 2) {
this.showError("Need at least 2 objects to align");
return;
}
// Work with all objects
this.alignAllObjects(direction);
} else if (objectsToAlign.length < 2) {
this.showError("Select at least 2 objects to align");
return;
} else {
this.alignSelectedObjects(objectsToAlign, direction);
}
this.render();
}
alignAllObjects(direction) {
const objects = this.objects;
if (objects.length < 2) return;
// Calculate bounds
const bounds = this.calculateBounds(objects);
objects.forEach((obj) => {
switch (direction) {
case "left":
obj.x = bounds.minX;
break;
case "center":
obj.x = bounds.centerX - obj.width / 2;
break;
case "right":
obj.x = bounds.maxX - obj.width;
break;
case "top":
obj.y = bounds.minY;
break;
case "middle":
obj.y = bounds.centerY - obj.height / 2;
break;
case "bottom":
obj.y = bounds.maxY - obj.height;
break;
}
// Snap to grid
obj.x = Math.round(obj.x / this.gridSize) * this.gridSize;
obj.y = Math.round(obj.y / this.gridSize) * this.gridSize;
});
}
alignSelectedObjects(objects, direction) {
if (objects.length < 2) return;
// Use first object as reference
const reference = objects[0];
objects.slice(1).forEach((obj) => {
switch (direction) {
case "left":
obj.x = reference.x;
break;
case "center":
obj.x = reference.x + reference.width / 2 - obj.width / 2;
break;
case "right":
obj.x = reference.x + reference.width - obj.width;
break;
case "top":
obj.y = reference.y;
break;
case "middle":
obj.y = reference.y + reference.height / 2 - obj.height / 2;
break;
case "bottom":
obj.y = reference.y + reference.height - obj.height;
break;
}
// Snap to grid
obj.x = Math.round(obj.x / this.gridSize) * this.gridSize;
obj.y = Math.round(obj.y / this.gridSize) * this.gridSize;
});
}
distributeObjects(direction) {
const objects = this.objects;
if (objects.length < 3) {
this.showError("Need at least 3 objects to distribute");
return;
}
// Sort objects by position
const sortedObjects = [...objects].sort((a, b) => {
if (direction === "horizontal") {
return a.x - b.x;
} else {
return a.y - b.y;
}
});
const first = sortedObjects[0];
const last = sortedObjects[sortedObjects.length - 1];
if (direction === "horizontal") {
const totalSpace = last.x + last.width - first.x;
const objectsWidth = sortedObjects.reduce(
(sum, obj) => sum + obj.width,
0
);
const availableSpace = totalSpace - objectsWidth;
const spacing = availableSpace / (sortedObjects.length - 1);
let currentX = first.x + first.width;
for (let i = 1; i < sortedObjects.length - 1; i++) {
sortedObjects[i].x = currentX + spacing;
currentX = sortedObjects[i].x + sortedObjects[i].width;
// Snap to grid
sortedObjects[i].x =
Math.round(sortedObjects[i].x / this.gridSize) * this.gridSize;
}
} else {
const totalSpace = last.y + last.height - first.y;
const objectsHeight = sortedObjects.reduce(
(sum, obj) => sum + obj.height,
0
);
const availableSpace = totalSpace - objectsHeight;
const spacing = availableSpace / (sortedObjects.length - 1);
let currentY = first.y + first.height;
for (let i = 1; i < sortedObjects.length - 1; i++) {
sortedObjects[i].y = currentY + spacing;
currentY = sortedObjects[i].y + sortedObjects[i].height;
// Snap to grid
sortedObjects[i].y =
Math.round(sortedObjects[i].y / this.gridSize) * this.gridSize;
}
}
this.render();
}
autoArrangeObjects() {
if (this.objects.length === 0) return;
// Find transformer (should be at the top)
const transformer = this.objects.find((obj) => obj.type === "triangle");
if (!transformer) {
this.showError("No transformer found for auto-arrangement");
return;
}
// Position transformer at left center (for LEFT TO RIGHT flow)
transformer.x = Math.round(80 / this.gridSize) * this.gridSize;
transformer.y =
Math.round(
(this.canvas.height / 2 - transformer.height / 2) / this.gridSize
) * this.gridSize;
// Get all nodes (non-transformer objects)
const nodes = this.objects.filter((obj) => obj.type !== "triangle");
if (nodes.length === 0) {
this.render();
return;
}
// Arrange nodes in a tree-like structure based on connections
this.arrangeNodesInTree(transformer, nodes);
this.render();
}
arrangeNodesInTree(transformer, nodes) {
const arranged = new Set();
const levels = [];
// Level 0: directly connected to transformer
let currentLevel = [];
transformer.connections.outputs.forEach((conn) => {
if (nodes.includes(conn.to)) {
currentLevel.push(conn.to);
arranged.add(conn.to);
}
});
if (currentLevel.length > 0) {
levels.push(currentLevel);
}
// Build subsequent levels
while (currentLevel.length > 0) {
const nextLevel = [];
currentLevel.forEach((node) => {
node.connections.outputs.forEach((conn) => {
if (nodes.includes(conn.to) && !arranged.has(conn.to)) {
nextLevel.push(conn.to);
arranged.add(conn.to);
}
});
});
if (nextLevel.length > 0) {
levels.push(nextLevel);
currentLevel = nextLevel;
} else {
break;
}
}
// Add any remaining unconnected nodes to the last level
const unconnected = nodes.filter((node) => !arranged.has(node));
if (unconnected.length > 0) {
if (levels.length === 0) {
levels.push(unconnected);
} else {
levels[levels.length - 1].push(...unconnected);
}
}
// Position nodes level by level - LEFT TO RIGHT layout
const levelSpacing = 200; // Horizontal spacing between columns
const minNodeSpacing = 100; // Minimum vertical spacing between nodes
levels.forEach((level, levelIndex) => {
// Calculate X position - all nodes in same level have same X (horizontally aligned)
const x =
transformer.x + transformer.width + 50 + levelIndex * levelSpacing;
// Calculate vertical positioning for this level
const nodeCount = level.length;
if (nodeCount === 1) {
// Single node - center it vertically with transformer
const y = transformer.y + transformer.height / 2 - level[0].height / 2;
level[0].y = Math.round(y / this.gridSize) * this.gridSize;
level[0].x = Math.round(x / this.gridSize) * this.gridSize;
} else {
// Multiple nodes - distribute vertically across canvas height
const canvasHeight = this.canvas.height;
const margin = 60;
const availableHeight = canvasHeight - 2 * margin;
// Calculate spacing to use full height effectively
const nodeSpacing = Math.max(
minNodeSpacing,
availableHeight / (nodeCount + 1)
);
const totalUsedHeight = (nodeCount - 1) * nodeSpacing;
const startY = margin + (availableHeight - totalUsedHeight) / 2;
// Position each node in this level
level.forEach((node, nodeIndex) => {
// All nodes in same level have same X coordinate (horizontally aligned)
node.x = Math.round(x / this.gridSize) * this.gridSize;
// Distribute nodes vertically across available height
const y = startY + nodeIndex * nodeSpacing;
node.y = Math.round(y / this.gridSize) * this.gridSize;
});
}
});
// Optimize the tree layout to prevent overlaps and improve readability
this.optimizeTreeLayout(transformer, levels);
}
// Optimize the tree layout to prevent overlaps and improve readability
optimizeTreeLayout(transformer, levels) {
levels.forEach((level, levelIndex) => {
// Ensure nodes don't overlap horizontally with previous level
if (levelIndex > 0) {
const prevLevel = levels[levelIndex - 1];
const prevMaxX = Math.max(
...prevLevel.map((node) => node.x + node.width)
);
const currentMinX = Math.min(...level.map((node) => node.x));
if (currentMinX - prevMaxX < 40) {
const adjustment = 40 - (currentMinX - prevMaxX);
level.forEach((node) => {
node.x =
Math.round((node.x + adjustment) / this.gridSize) * this.gridSize;
});
}
}
// Ensure no nodes go off the top or bottom edge
level.forEach((node) => {
const minY = 20;
const maxY = this.canvas.height - node.height - 20;
if (node.y < minY) {
node.y = Math.round(minY / this.gridSize) * this.gridSize;
} else if (node.y > maxY) {
node.y = Math.round(maxY / this.gridSize) * this.gridSize;
}
});
});
}
calculateBounds(objects) {
if (objects.length === 0) return null;
let minX = objects[0].x;
let maxX = objects[0].x + objects[0].width;
let minY = objects[0].y;
let maxY = objects[0].y + objects[0].height;
objects.forEach((obj) => {
minX = Math.min(minX, obj.x);
maxX = Math.max(maxX, obj.x + obj.width);
minY = Math.min(minY, obj.y);
maxY = Math.max(maxY, obj.y + obj.height);
});
return {
minX,
maxX,
minY,
maxY,
centerX: (minX + maxX) / 2,
centerY: (minY + maxY) / 2,
width: maxX - minX,
height: maxY - minY,
};
}
}
// Initialize the application

View File

@@ -396,6 +396,248 @@ body {
background: white;
}
/* Alignment Icons */
.icon-align-left {
width: 20px;
height: 16px;
position: relative;
}
.icon-align-left::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 2px;
background: #4a5568;
border-radius: 1px;
box-shadow: 0 4px 0 #4a5568, 0 8px 0 #4a5568, 0 12px 0 #4a5568;
}
.icon-btn.active .icon-align-left::before {
background: white;
box-shadow: 0 4px 0 white, 0 8px 0 white, 0 12px 0 white;
}
.icon-align-center {
width: 20px;
height: 16px;
position: relative;
}
.icon-align-center::before {
content: "";
position: absolute;
top: 0;
left: 2px;
width: 16px;
height: 2px;
background: #4a5568;
border-radius: 1px;
box-shadow: 0 4px 0 #4a5568, 0 8px 0 #4a5568, 0 12px 0 #4a5568;
}
.icon-btn.active .icon-align-center::before {
background: white;
box-shadow: 0 4px 0 white, 0 8px 0 white, 0 12px 0 white;
}
.icon-align-right {
width: 20px;
height: 16px;
position: relative;
}
.icon-align-right::before {
content: "";
position: absolute;
top: 0;
right: 0;
width: 100%;
height: 2px;
background: #4a5568;
border-radius: 1px;
box-shadow: 0 4px 0 #4a5568, 0 8px 0 #4a5568, 0 12px 0 #4a5568;
}
.icon-btn.active .icon-align-right::before {
background: white;
box-shadow: 0 4px 0 white, 0 8px 0 white, 0 12px 0 white;
}
.icon-align-top {
width: 16px;
height: 20px;
position: relative;
}
.icon-align-top::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 2px;
height: 100%;
background: #4a5568;
border-radius: 1px;
box-shadow: 4px 0 0 #4a5568, 8px 0 0 #4a5568, 12px 0 0 #4a5568;
}
.icon-btn.active .icon-align-top::before {
background: white;
box-shadow: 4px 0 0 white, 8px 0 0 white, 12px 0 0 white;
}
.icon-align-middle {
width: 16px;
height: 20px;
position: relative;
}
.icon-align-middle::before {
content: "";
position: absolute;
top: 2px;
left: 0;
width: 2px;
height: 16px;
background: #4a5568;
border-radius: 1px;
box-shadow: 4px 0 0 #4a5568, 8px 0 0 #4a5568, 12px 0 0 #4a5568;
}
.icon-btn.active .icon-align-middle::before {
background: white;
box-shadow: 4px 0 0 white, 8px 0 0 white, 12px 0 0 white;
}
.icon-align-bottom {
width: 16px;
height: 20px;
position: relative;
}
.icon-align-bottom::before {
content: "";
position: absolute;
bottom: 0;
left: 0;
width: 2px;
height: 100%;
background: #4a5568;
border-radius: 1px;
box-shadow: 4px 0 0 #4a5568, 8px 0 0 #4a5568, 12px 0 0 #4a5568;
}
.icon-btn.active .icon-align-bottom::before {
background: white;
box-shadow: 4px 0 0 white, 8px 0 0 white, 12px 0 0 white;
}
.icon-distribute-h {
width: 20px;
height: 16px;
position: relative;
}
.icon-distribute-h::before {
content: "";
position: absolute;
top: 7px;
left: 0;
width: 4px;
height: 2px;
background: #4a5568;
border-radius: 1px;
box-shadow: 8px 0 0 #4a5568, 16px 0 0 #4a5568;
}
.icon-distribute-h::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 1px;
height: 12px;
background: #4a5568;
border-radius: 0.5px;
box-shadow: 8px 0 0 #4a5568, 16px 0 0 #4a5568;
}
.icon-btn.active .icon-distribute-h::before {
background: white;
box-shadow: 8px 0 0 white, 16px 0 0 white;
}
.icon-btn.active .icon-distribute-h::after {
background: white;
box-shadow: 8px 0 0 white, 16px 0 0 white;
}
.icon-distribute-v {
width: 16px;
height: 20px;
position: relative;
}
.icon-distribute-v::before {
content: "";
position: absolute;
top: 0;
left: 7px;
width: 2px;
height: 4px;
background: #4a5568;
border-radius: 1px;
box-shadow: 0 8px 0 #4a5568, 0 16px 0 #4a5568;
}
.icon-distribute-v::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 1px;
background: #4a5568;
border-radius: 0.5px;
box-shadow: 0 8px 0 #4a5568, 0 16px 0 #4a5568;
}
.icon-btn.active .icon-distribute-v::before {
background: white;
box-shadow: 0 8px 0 white, 0 16px 0 white;
}
.icon-btn.active .icon-distribute-v::after {
background: white;
box-shadow: 0 8px 0 white, 0 16px 0 white;
}
.icon-auto-arrange {
width: 20px;
height: 20px;
position: relative;
}
.icon-auto-arrange::before {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 6px;
height: 6px;
border: 2px solid #4a5568;
border-radius: 1px;
box-shadow: 10px 0 0 #4a5568, 0 10px 0 #4a5568, 10px 10px 0 #4a5568;
}
.icon-btn.active .icon-auto-arrange::before {
border-color: white;
box-shadow: 10px 0 0 white, 0 10px 0 white, 10px 10px 0 white;
}
.canvas-container {
flex: 1;
position: relative;