615 lines
16 KiB
Markdown
615 lines
16 KiB
Markdown
# Layout Algorithms for Obsidian Canvas
|
|
|
|
Detailed algorithms for positioning nodes in MindMap and Freeform layouts.
|
|
|
|
## Layout Principles
|
|
|
|
### Universal Spacing Constants
|
|
|
|
```
|
|
HORIZONTAL_SPACING = 320 // Minimum horizontal space between node centers
|
|
VERTICAL_SPACING = 200 // Minimum vertical space between node centers
|
|
NODE_PADDING = 20 // Internal padding within nodes
|
|
```
|
|
|
|
### Collision Detection
|
|
|
|
Before finalizing any node position, verify:
|
|
|
|
```python
|
|
def check_collision(node1, node2):
|
|
"""Returns True if nodes overlap or are too close"""
|
|
center1_x = node1.x + node1.width / 2
|
|
center1_y = node1.y + node1.height / 2
|
|
center2_x = node2.x + node2.width / 2
|
|
center2_y = node2.y + node2.height / 2
|
|
|
|
dx = abs(center1_x - center2_x)
|
|
dy = abs(center1_y - center2_y)
|
|
|
|
min_dx = (node1.width + node2.width) / 2 + HORIZONTAL_SPACING
|
|
min_dy = (node1.height + node2.height) / 2 + VERTICAL_SPACING
|
|
|
|
return dx < min_dx or dy < min_dy
|
|
```
|
|
|
|
## MindMap Layout Algorithm
|
|
|
|
### 1. Radial Tree Layout
|
|
|
|
Place root at center, arrange children radially.
|
|
|
|
#### Step 1: Position Root Node
|
|
|
|
```python
|
|
root = {
|
|
"x": 0 - (root_width / 2), # Center horizontally
|
|
"y": 0 - (root_height / 2), # Center vertically
|
|
"width": root_width,
|
|
"height": root_height
|
|
}
|
|
```
|
|
|
|
#### Step 2: Calculate Primary Branch Positions
|
|
|
|
Distribute first-level children around root:
|
|
|
|
```python
|
|
def position_primary_branches(root, children, radius=400):
|
|
"""Position first-level children in a circle around root"""
|
|
n = len(children)
|
|
angle_step = 2 * pi / n
|
|
|
|
positions = []
|
|
for i, child in enumerate(children):
|
|
angle = i * angle_step
|
|
|
|
# Calculate position on circle
|
|
x = root.center_x + radius * cos(angle) - child.width / 2
|
|
y = root.center_y + radius * sin(angle) - child.height / 2
|
|
|
|
positions.append({"x": x, "y": y})
|
|
|
|
return positions
|
|
```
|
|
|
|
**Radius Selection:**
|
|
- Small canvases (≤10 children): 400px
|
|
- Medium canvases (11-20 children): 500px
|
|
- Large canvases (>20 children): 600px
|
|
|
|
#### Step 3: Position Secondary Branches
|
|
|
|
For each primary branch, arrange its children:
|
|
|
|
**Horizontal Layout** (preferred for most cases):
|
|
|
|
```python
|
|
def position_secondary_horizontal(parent, children, distance=350):
|
|
"""Arrange children horizontally to the right of parent"""
|
|
n = len(children)
|
|
total_height = sum(child.height for child in children)
|
|
total_spacing = (n - 1) * VERTICAL_SPACING
|
|
|
|
# Start position (top of vertical arrangement)
|
|
start_y = parent.center_y - (total_height + total_spacing) / 2
|
|
|
|
positions = []
|
|
current_y = start_y
|
|
|
|
for child in children:
|
|
x = parent.x + parent.width + distance
|
|
y = current_y
|
|
|
|
positions.append({"x": x, "y": y})
|
|
current_y += child.height + VERTICAL_SPACING
|
|
|
|
return positions
|
|
```
|
|
|
|
**Vertical Layout** (for left/right primary branches):
|
|
|
|
```python
|
|
def position_secondary_vertical(parent, children, distance=250):
|
|
"""Arrange children vertically below parent"""
|
|
n = len(children)
|
|
total_width = sum(child.width for child in children)
|
|
total_spacing = (n - 1) * HORIZONTAL_SPACING
|
|
|
|
# Start position (left of horizontal arrangement)
|
|
start_x = parent.center_x - (total_width + total_spacing) / 2
|
|
|
|
positions = []
|
|
current_x = start_x
|
|
|
|
for child in children:
|
|
x = current_x
|
|
y = parent.y + parent.height + distance
|
|
|
|
positions.append({"x": x, "y": y})
|
|
current_x += child.width + HORIZONTAL_SPACING
|
|
|
|
return positions
|
|
```
|
|
|
|
#### Step 4: Balance and Adjust
|
|
|
|
After initial placement, check for collisions and adjust:
|
|
|
|
```python
|
|
def balance_layout(nodes):
|
|
"""Adjust nodes to prevent overlaps"""
|
|
max_iterations = 10
|
|
|
|
for iteration in range(max_iterations):
|
|
collisions = find_all_collisions(nodes)
|
|
if not collisions:
|
|
break
|
|
|
|
for node1, node2 in collisions:
|
|
# Move node2 away from node1
|
|
dx = node2.center_x - node1.center_x
|
|
dy = node2.center_y - node1.center_y
|
|
distance = sqrt(dx*dx + dy*dy)
|
|
|
|
# Calculate required distance
|
|
min_dist = calculate_min_distance(node1, node2)
|
|
|
|
if distance > 0:
|
|
# Move proportionally
|
|
move_x = (dx / distance) * (min_dist - distance) / 2
|
|
move_y = (dy / distance) * (min_dist - distance) / 2
|
|
|
|
node2.x += move_x
|
|
node2.y += move_y
|
|
```
|
|
|
|
### 2. Tree Layout (Hierarchical Top-Down)
|
|
|
|
Alternative for deep hierarchies.
|
|
|
|
#### Positioning Formula
|
|
|
|
```python
|
|
def position_tree_layout(root, tree):
|
|
"""Top-down tree layout"""
|
|
# Level 0 (root)
|
|
root.x = 0 - root.width / 2
|
|
root.y = 0 - root.height / 2
|
|
|
|
# Process each level
|
|
for level in range(1, max_depth):
|
|
nodes_at_level = get_nodes_at_level(tree, level)
|
|
|
|
# Calculate horizontal spacing
|
|
total_width = sum(node.width for node in nodes_at_level)
|
|
total_spacing = (len(nodes_at_level) - 1) * HORIZONTAL_SPACING
|
|
|
|
start_x = -(total_width + total_spacing) / 2
|
|
y = level * (150 + VERTICAL_SPACING) # 150px level height
|
|
|
|
current_x = start_x
|
|
for node in nodes_at_level:
|
|
node.x = current_x
|
|
node.y = y
|
|
current_x += node.width + HORIZONTAL_SPACING
|
|
```
|
|
|
|
## Freeform Layout Algorithm
|
|
|
|
### 1. Content-Based Grouping
|
|
|
|
First, identify natural groupings in content:
|
|
|
|
```python
|
|
def identify_groups(nodes, content_structure):
|
|
"""Group nodes by semantic relationships"""
|
|
groups = []
|
|
|
|
# Analyze content structure
|
|
for section in content_structure:
|
|
group_nodes = [node for node in nodes if node.section == section]
|
|
|
|
if len(group_nodes) > 1:
|
|
groups.append({
|
|
"label": section.title,
|
|
"nodes": group_nodes
|
|
})
|
|
|
|
return groups
|
|
```
|
|
|
|
### 2. Grid-Based Zone Layout
|
|
|
|
Divide canvas into zones for different groups:
|
|
|
|
```python
|
|
def layout_zones(groups, canvas_width=2000, canvas_height=1500):
|
|
"""Arrange groups in grid zones"""
|
|
n_groups = len(groups)
|
|
|
|
# Calculate grid dimensions
|
|
cols = ceil(sqrt(n_groups))
|
|
rows = ceil(n_groups / cols)
|
|
|
|
zone_width = canvas_width / cols
|
|
zone_height = canvas_height / rows
|
|
|
|
# Assign zones
|
|
zones = []
|
|
for i, group in enumerate(groups):
|
|
col = i % cols
|
|
row = i // cols
|
|
|
|
zone = {
|
|
"x": col * zone_width - canvas_width / 2,
|
|
"y": row * zone_height - canvas_height / 2,
|
|
"width": zone_width * 0.9, # Leave 10% margin
|
|
"height": zone_height * 0.9,
|
|
"group": group
|
|
}
|
|
zones.append(zone)
|
|
|
|
return zones
|
|
```
|
|
|
|
### 3. Within-Zone Node Positioning
|
|
|
|
Position nodes within each zone:
|
|
|
|
**Option A: Organic Flow**
|
|
|
|
```python
|
|
def position_organic(zone, nodes):
|
|
"""Organic, flowing arrangement within zone"""
|
|
positions = []
|
|
|
|
# Start at zone top-left with margin
|
|
current_x = zone.x + 50
|
|
current_y = zone.y + 50
|
|
row_height = 0
|
|
|
|
for node in nodes:
|
|
# Check if node fits in current row
|
|
if current_x + node.width > zone.x + zone.width - 50:
|
|
# Move to next row
|
|
current_x = zone.x + 50
|
|
current_y += row_height + VERTICAL_SPACING
|
|
row_height = 0
|
|
|
|
positions.append({
|
|
"x": current_x,
|
|
"y": current_y
|
|
})
|
|
|
|
current_x += node.width + HORIZONTAL_SPACING
|
|
row_height = max(row_height, node.height)
|
|
|
|
return positions
|
|
```
|
|
|
|
**Option B: Structured Grid**
|
|
|
|
```python
|
|
def position_grid(zone, nodes):
|
|
"""Grid arrangement within zone"""
|
|
n = len(nodes)
|
|
cols = ceil(sqrt(n))
|
|
rows = ceil(n / cols)
|
|
|
|
cell_width = (zone.width - 100) / cols # 50px margin each side
|
|
cell_height = (zone.height - 100) / rows
|
|
|
|
positions = []
|
|
for i, node in enumerate(nodes):
|
|
col = i % cols
|
|
row = i // cols
|
|
|
|
# Center node in cell
|
|
x = zone.x + 50 + col * cell_width + (cell_width - node.width) / 2
|
|
y = zone.y + 50 + row * cell_height + (cell_height - node.height) / 2
|
|
|
|
positions.append({"x": x, "y": y})
|
|
|
|
return positions
|
|
```
|
|
|
|
### 4. Cross-Zone Connections
|
|
|
|
Calculate optimal edge paths between zones:
|
|
|
|
```python
|
|
def calculate_edge_path(from_node, to_node):
|
|
"""Determine edge connection points"""
|
|
# Calculate centers
|
|
from_center = (from_node.x + from_node.width/2,
|
|
from_node.y + from_node.height/2)
|
|
to_center = (to_node.x + to_node.width/2,
|
|
to_node.y + to_node.height/2)
|
|
|
|
# Determine best sides to connect
|
|
dx = to_center[0] - from_center[0]
|
|
dy = to_center[1] - from_center[1]
|
|
|
|
# Choose sides based on direction
|
|
if abs(dx) > abs(dy):
|
|
# Horizontal connection
|
|
from_side = "right" if dx > 0 else "left"
|
|
to_side = "left" if dx > 0 else "right"
|
|
else:
|
|
# Vertical connection
|
|
from_side = "bottom" if dy > 0 else "top"
|
|
to_side = "top" if dy > 0 else "bottom"
|
|
|
|
return {
|
|
"fromSide": from_side,
|
|
"toSide": to_side
|
|
}
|
|
```
|
|
|
|
## Advanced Techniques
|
|
|
|
### Force-Directed Layout
|
|
|
|
For complex networks with many cross-connections:
|
|
|
|
```python
|
|
def force_directed_layout(nodes, edges, iterations=100):
|
|
"""Spring-based layout algorithm"""
|
|
# Constants
|
|
SPRING_LENGTH = 200
|
|
SPRING_CONSTANT = 0.1
|
|
REPULSION_CONSTANT = 5000
|
|
|
|
for iteration in range(iterations):
|
|
# Calculate repulsive forces (all pairs)
|
|
for node1 in nodes:
|
|
force_x, force_y = 0, 0
|
|
|
|
for node2 in nodes:
|
|
if node1 == node2:
|
|
continue
|
|
|
|
dx = node1.x - node2.x
|
|
dy = node1.y - node2.y
|
|
distance = sqrt(dx*dx + dy*dy)
|
|
|
|
if distance > 0:
|
|
# Repulsive force
|
|
force = REPULSION_CONSTANT / (distance * distance)
|
|
force_x += (dx / distance) * force
|
|
force_y += (dy / distance) * force
|
|
|
|
node1.force_x = force_x
|
|
node1.force_y = force_y
|
|
|
|
# Calculate attractive forces (connected nodes)
|
|
for edge in edges:
|
|
node1 = get_node(edge.fromNode)
|
|
node2 = get_node(edge.toNode)
|
|
|
|
dx = node2.x - node1.x
|
|
dy = node2.y - node1.y
|
|
distance = sqrt(dx*dx + dy*dy)
|
|
|
|
# Spring force
|
|
force = SPRING_CONSTANT * (distance - SPRING_LENGTH)
|
|
|
|
node1.force_x += (dx / distance) * force
|
|
node1.force_y += (dy / distance) * force
|
|
node2.force_x -= (dx / distance) * force
|
|
node2.force_y -= (dy / distance) * force
|
|
|
|
# Apply forces
|
|
for node in nodes:
|
|
node.x += node.force_x
|
|
node.y += node.force_y
|
|
```
|
|
|
|
### Hierarchical Clustering
|
|
|
|
Group related nodes automatically:
|
|
|
|
```python
|
|
def hierarchical_cluster(nodes, similarity_threshold=0.7):
|
|
"""Cluster nodes by content similarity"""
|
|
clusters = []
|
|
|
|
# Calculate similarity matrix
|
|
similarity = calculate_similarity_matrix(nodes)
|
|
|
|
# Agglomerative clustering
|
|
current_clusters = [[node] for node in nodes]
|
|
|
|
while len(current_clusters) > 1:
|
|
# Find most similar clusters
|
|
max_sim = 0
|
|
merge_i, merge_j = 0, 1
|
|
|
|
for i in range(len(current_clusters)):
|
|
for j in range(i + 1, len(current_clusters)):
|
|
sim = cluster_similarity(current_clusters[i],
|
|
current_clusters[j],
|
|
similarity)
|
|
if sim > max_sim:
|
|
max_sim = sim
|
|
merge_i, merge_j = i, j
|
|
|
|
if max_sim < similarity_threshold:
|
|
break
|
|
|
|
# Merge clusters
|
|
current_clusters[merge_i].extend(current_clusters[merge_j])
|
|
current_clusters.pop(merge_j)
|
|
|
|
return current_clusters
|
|
```
|
|
|
|
## Layout Optimization
|
|
|
|
### Minimize Edge Crossings
|
|
|
|
```python
|
|
def minimize_crossings(nodes, edges):
|
|
"""Reduce edge crossing through node repositioning"""
|
|
crossings = count_crossings(edges)
|
|
|
|
# Try swapping adjacent nodes
|
|
improved = True
|
|
while improved:
|
|
improved = False
|
|
|
|
for i in range(len(nodes) - 1):
|
|
# Swap nodes i and i+1
|
|
swap_positions(nodes[i], nodes[i+1])
|
|
new_crossings = count_crossings(edges)
|
|
|
|
if new_crossings < crossings:
|
|
crossings = new_crossings
|
|
improved = True
|
|
else:
|
|
# Swap back
|
|
swap_positions(nodes[i], nodes[i+1])
|
|
```
|
|
|
|
### Visual Balance
|
|
|
|
```python
|
|
def calculate_visual_weight(canvas):
|
|
"""Calculate center of mass for visual balance"""
|
|
total_weight = 0
|
|
weighted_x = 0
|
|
weighted_y = 0
|
|
|
|
for node in canvas.nodes:
|
|
# Weight is proportional to area
|
|
weight = node.width * node.height
|
|
total_weight += weight
|
|
|
|
weighted_x += node.center_x * weight
|
|
weighted_y += node.center_y * weight
|
|
|
|
center_x = weighted_x / total_weight
|
|
center_y = weighted_y / total_weight
|
|
|
|
# Shift entire canvas to center at (0, 0)
|
|
offset_x = -center_x
|
|
offset_y = -center_y
|
|
|
|
for node in canvas.nodes:
|
|
node.x += offset_x
|
|
node.y += offset_y
|
|
```
|
|
|
|
## Performance Optimization
|
|
|
|
### Spatial Indexing
|
|
|
|
For large canvases, use spatial indexing to speed up collision detection:
|
|
|
|
```python
|
|
class SpatialGrid:
|
|
"""Grid-based spatial index for fast collision detection"""
|
|
|
|
def __init__(self, cell_size=500):
|
|
self.cell_size = cell_size
|
|
self.grid = {}
|
|
|
|
def add_node(self, node):
|
|
"""Add node to grid"""
|
|
cells = self.get_cells(node)
|
|
for cell in cells:
|
|
if cell not in self.grid:
|
|
self.grid[cell] = []
|
|
self.grid[cell].append(node)
|
|
|
|
def get_cells(self, node):
|
|
"""Get grid cells node occupies"""
|
|
min_x = int(node.x / self.cell_size)
|
|
max_x = int((node.x + node.width) / self.cell_size)
|
|
min_y = int(node.y / self.cell_size)
|
|
max_y = int((node.y + node.height) / self.cell_size)
|
|
|
|
cells = []
|
|
for x in range(min_x, max_x + 1):
|
|
for y in range(min_y, max_y + 1):
|
|
cells.append((x, y))
|
|
return cells
|
|
|
|
def get_nearby_nodes(self, node):
|
|
"""Get nodes in nearby cells"""
|
|
cells = self.get_cells(node)
|
|
nearby = set()
|
|
|
|
for cell in cells:
|
|
if cell in self.grid:
|
|
nearby.update(self.grid[cell])
|
|
|
|
return nearby
|
|
```
|
|
|
|
## Common Layout Patterns
|
|
|
|
### Timeline Layout
|
|
|
|
For chronological content:
|
|
|
|
```python
|
|
def layout_timeline(events, direction="horizontal"):
|
|
"""Create timeline layout"""
|
|
if direction == "horizontal":
|
|
for i, event in enumerate(events):
|
|
event.x = i * (event.width + HORIZONTAL_SPACING)
|
|
event.y = 0
|
|
else: # vertical
|
|
for i, event in enumerate(events):
|
|
event.x = 0
|
|
event.y = i * (event.height + VERTICAL_SPACING)
|
|
```
|
|
|
|
### Circular Layout
|
|
|
|
For cyclical processes:
|
|
|
|
```python
|
|
def layout_circular(nodes, radius=500):
|
|
"""Arrange nodes in a circle"""
|
|
n = len(nodes)
|
|
angle_step = 2 * pi / n
|
|
|
|
for i, node in enumerate(nodes):
|
|
angle = i * angle_step
|
|
node.x = radius * cos(angle) - node.width / 2
|
|
node.y = radius * sin(angle) - node.height / 2
|
|
```
|
|
|
|
### Matrix Layout
|
|
|
|
For comparing multiple dimensions:
|
|
|
|
```python
|
|
def layout_matrix(nodes, rows, cols):
|
|
"""Arrange nodes in a matrix"""
|
|
cell_width = 400
|
|
cell_height = 250
|
|
|
|
for i, node in enumerate(nodes):
|
|
row = i // cols
|
|
col = i % cols
|
|
|
|
node.x = col * cell_width
|
|
node.y = row * cell_height
|
|
```
|
|
|
|
## Quality Checks
|
|
|
|
Before finalizing layout, verify:
|
|
|
|
1. **No Overlaps**: All nodes have minimum spacing
|
|
2. **Balanced**: Visual center near (0, 0)
|
|
3. **Accessible**: All nodes reachable via edges
|
|
4. **Readable**: Text sizes appropriate for zoom level
|
|
5. **Efficient**: Edge paths reasonably direct
|
|
|
|
Use these algorithms as foundations, adapting to specific content and user preferences.
|