vault backup: 2026-04-13 17:46:47
This commit is contained in:
@@ -0,0 +1,614 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user