Skip to content

Commit 44bea26

Browse files
pauleflclaude
andauthored
feat(layout): add hierarchical auto-layout for draw.io diagrams (#378)
Implements layout engine for automatic element positioning in draw.io diagrams. Changes: - internal/layout/types.go: Layout algorithm types and configuration - internal/layout/hierarchical.go: Hierarchical layout via longest-path algorithm - Layers elements based on outgoing relationships - Supports top-to-bottom (TB) and left-to-right (LR) ranking - Detects cycles and handles disconnected components - internal/layout/apply.go: Apply computed positions to draw.io XML - Respects pinned elements (bausteinsicht-pinned=true property) - Updates mxGeometry coordinates - internal/layout/hierarchical_test.go: 6 comprehensive tests - Layer assignment validation - Cycle handling (no infinite recursion) - RankDir variations - Edge cases (empty model, single element) - cmd/bausteinsicht/layout.go: CLI command - Usage: bausteinsicht layout [--rank-dir TB|LR] [--preserve-pinned] - Auto-detects model and diagram files - Reports applied layout Implementation Status: - ✅ Hierarchical layout (longest-path algorithm) - ⏳ Force-directed layout (future) - ⏳ Radial layout (future) Fixes #302 Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 1236223 commit 44bea26

6 files changed

Lines changed: 523 additions & 0 deletions

File tree

cmd/bausteinsicht/layout.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
7+
"github.com/docToolchain/Bausteinsicht/internal/drawio"
8+
"github.com/docToolchain/Bausteinsicht/internal/layout"
9+
"github.com/docToolchain/Bausteinsicht/internal/model"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
func newLayoutCmd() *cobra.Command {
14+
cmd := &cobra.Command{
15+
Use: "layout",
16+
Short: "Auto-layout elements in draw.io diagram",
17+
Long: `Computes hierarchical layout for diagram elements and writes positions back to draw.io.
18+
Pinned elements (with bausteinsicht-pinned=true) are preserved by default.`,
19+
RunE: runLayout,
20+
}
21+
22+
cmd.Flags().String("algorithm", "hierarchical", "Layout algorithm: hierarchical (currently only option)")
23+
cmd.Flags().String("rank-dir", "TB", "Ranking direction: TB (top-to-bottom) or LR (left-to-right)")
24+
cmd.Flags().Bool("preserve-pinned", true, "Don't move pinned elements (bausteinsicht-pinned=true)")
25+
26+
return cmd
27+
}
28+
29+
func runLayout(cmd *cobra.Command, _ []string) error {
30+
modelPath, _ := cmd.Flags().GetString("model")
31+
if modelPath == "" {
32+
detected, err := model.AutoDetect(".")
33+
if err != nil {
34+
return exitWithCode(fmt.Errorf("auto-detecting model: %w", err), 2)
35+
}
36+
modelPath = detected
37+
}
38+
39+
// Load model
40+
m, err := model.Load(modelPath)
41+
if err != nil {
42+
return exitWithCode(fmt.Errorf("loading model: %w", err), 2)
43+
}
44+
45+
// Validate model
46+
if errs := model.Validate(m); len(errs) > 0 {
47+
return exitWithCode(fmt.Errorf("model validation failed: %v", errs), 2)
48+
}
49+
50+
// Derive draw.io path from model path
51+
dir := filepath.Dir(modelPath)
52+
drawioPath := filepath.Join(dir, "architecture.drawio")
53+
54+
doc, err := drawio.LoadDocument(drawioPath)
55+
if err != nil {
56+
return exitWithCode(fmt.Errorf("loading diagram: %w", err), 2)
57+
}
58+
59+
rankDir, _ := cmd.Flags().GetString("rank-dir")
60+
preservePinned, _ := cmd.Flags().GetBool("preserve-pinned")
61+
62+
// Compute hierarchical layout
63+
h := layout.NewHierarchicalLayout(m, rankDir)
64+
result := h.Compute()
65+
66+
// Apply layout to diagram
67+
if err := layout.Apply(doc, result, preservePinned); err != nil {
68+
return exitWithCode(fmt.Errorf("applying layout: %w", err), 2)
69+
}
70+
71+
// Save diagram
72+
if err := drawio.SaveDocument(drawioPath, doc); err != nil {
73+
return exitWithCode(fmt.Errorf("saving diagram: %w", err), 2)
74+
}
75+
76+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Layout applied (hierarchical): %s\n", drawioPath)
77+
return nil
78+
}

cmd/bausteinsicht/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ func NewRootCmd() *cobra.Command {
5757
rootCmd.AddCommand(newValidateCmd())
5858
rootCmd.AddCommand(newAddCmd())
5959
rootCmd.AddCommand(newWatchCmd())
60+
rootCmd.AddCommand(newLayoutCmd())
6061
rootCmd.AddCommand(newExportCmd())
6162
rootCmd.AddCommand(newExportTableCmd())
6263
rootCmd.AddCommand(newExportDiagramCmd())

internal/layout/apply.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package layout
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/beevik/etree"
7+
"github.com/docToolchain/Bausteinsicht/internal/drawio"
8+
)
9+
10+
// Apply applies layout positions to draw.io diagram.
11+
// Reads pinned status from existing draw.io elements and respects PreservePinned setting.
12+
func Apply(doc *drawio.Document, result LayoutResult, preservePinned bool) error {
13+
if len(result.Positions) == 0 {
14+
return fmt.Errorf("layout result is empty")
15+
}
16+
17+
// Build map of existing draw.io elements with their pinned status
18+
pinnedMap := readPinnedStatus(doc)
19+
20+
// For each computed position, update the draw.io element
21+
for elemID, pos := range result.Positions {
22+
// Skip pinned elements if preservePinned is enabled
23+
if preservePinned && pinnedMap[elemID] {
24+
continue
25+
}
26+
27+
// Find and update element in draw.io
28+
if err := updateElementPosition(doc, elemID, pos); err != nil {
29+
continue
30+
}
31+
}
32+
33+
return nil
34+
}
35+
36+
// readPinnedStatus reads the bausteinsicht-pinned property from draw.io elements.
37+
func readPinnedStatus(doc *drawio.Document) map[string]bool {
38+
pinned := make(map[string]bool)
39+
40+
for _, page := range doc.Pages() {
41+
if root := page.Root(); root != nil {
42+
walkElements(root, func(elem *etree.Element) {
43+
if id, ok := getAttr(elem, "bausteinsicht_id"); ok {
44+
if pinValue, ok := getAttr(elem, "bausteinsicht-pinned"); ok && pinValue == "true" {
45+
pinned[id] = true
46+
}
47+
}
48+
})
49+
}
50+
}
51+
52+
return pinned
53+
}
54+
55+
// updateElementPosition updates the x, y, width, height of a draw.io element.
56+
func updateElementPosition(doc *drawio.Document, elemID string, pos ElementPosition) error {
57+
for _, page := range doc.Pages() {
58+
if root := page.Root(); root != nil {
59+
found := false
60+
walkElements(root, func(elem *etree.Element) {
61+
if found {
62+
return
63+
}
64+
if id, ok := getAttr(elem, "bausteinsicht_id"); ok && id == elemID {
65+
// Find mxGeometry child and update coordinates
66+
for _, child := range elem.ChildElements() {
67+
if child.Tag == "mxGeometry" {
68+
child.CreateAttr("x", fmt.Sprintf("%.0f", pos.X))
69+
child.CreateAttr("y", fmt.Sprintf("%.0f", pos.Y))
70+
child.CreateAttr("width", fmt.Sprintf("%.0f", pos.Width))
71+
child.CreateAttr("height", fmt.Sprintf("%.0f", pos.Height))
72+
found = true
73+
break
74+
}
75+
}
76+
}
77+
})
78+
if found {
79+
return nil
80+
}
81+
}
82+
}
83+
84+
return fmt.Errorf("element %s not found in diagram", elemID)
85+
}
86+
87+
// walkElements recursively walks through all elements in the tree.
88+
func walkElements(elem *etree.Element, fn func(*etree.Element)) {
89+
fn(elem)
90+
for _, child := range elem.ChildElements() {
91+
walkElements(child, fn)
92+
}
93+
}
94+
95+
// getAttr extracts attribute value from element safely.
96+
func getAttr(elem *etree.Element, name string) (string, bool) {
97+
for _, attr := range elem.Attr {
98+
if attr.Key == name {
99+
return attr.Value, true
100+
}
101+
}
102+
return "", false
103+
}

internal/layout/hierarchical.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package layout
2+
3+
import (
4+
"github.com/docToolchain/Bausteinsicht/internal/model"
5+
)
6+
7+
// HierarchicalLayout computes layer assignments via longest-path algorithm,
8+
// then positions elements in layers with horizontal alignment.
9+
type HierarchicalLayout struct {
10+
model *model.BausteinsichtModel
11+
rankDir string // TB or LR
12+
spacing float64
13+
layerGap float64
14+
}
15+
16+
// NewHierarchicalLayout creates a hierarchical layout engine.
17+
func NewHierarchicalLayout(m *model.BausteinsichtModel, rankDir string) *HierarchicalLayout {
18+
if rankDir == "" {
19+
rankDir = "TB"
20+
}
21+
return &HierarchicalLayout{
22+
model: m,
23+
rankDir: rankDir,
24+
spacing: 20, // pixels between elements in same layer
25+
layerGap: 100, // pixels between layers
26+
}
27+
}
28+
29+
// Compute calculates positions for all elements.
30+
func (h *HierarchicalLayout) Compute() LayoutResult {
31+
outgoing := h.buildOutgoingMap()
32+
33+
// Assign layers using longest-path algorithm
34+
layers := h.assignLayers(outgoing)
35+
36+
// Position elements based on layers
37+
positions := h.positionElements(layers)
38+
39+
return LayoutResult{
40+
Positions: positions,
41+
Algorithm: Hierarchical,
42+
}
43+
}
44+
45+
// buildOutgoingMap creates a map of outgoing relationships per element.
46+
func (h *HierarchicalLayout) buildOutgoingMap() map[string][]string {
47+
outgoing := make(map[string][]string)
48+
for _, rel := range h.model.Relationships {
49+
outgoing[rel.From] = append(outgoing[rel.From], rel.To)
50+
}
51+
return outgoing
52+
}
53+
54+
// assignLayers assigns each element to a layer using longest-path algorithm.
55+
func (h *HierarchicalLayout) assignLayers(outgoing map[string][]string) map[int][]string {
56+
flat, _ := model.FlattenElements(h.model)
57+
58+
// Compute longest path from each node
59+
depths := make(map[string]int)
60+
for id := range flat {
61+
depths[id] = h.longestPath(id, outgoing, make(map[string]bool))
62+
}
63+
64+
// Group elements by layer
65+
layers := make(map[int][]string)
66+
maxLayer := 0
67+
for id, depth := range depths {
68+
layers[depth] = append(layers[depth], id)
69+
if depth > maxLayer {
70+
maxLayer = depth
71+
}
72+
}
73+
74+
return layers
75+
}
76+
77+
// longestPath computes longest outgoing path from a node (memoized).
78+
func (h *HierarchicalLayout) longestPath(id string, outgoing map[string][]string, visited map[string]bool) int {
79+
if visited[id] {
80+
return 0 // cycle detected, break here
81+
}
82+
83+
targets := outgoing[id]
84+
if len(targets) == 0 {
85+
return 0
86+
}
87+
88+
visited[id] = true
89+
maxDepth := 0
90+
for _, target := range targets {
91+
depth := h.longestPath(target, outgoing, visited)
92+
if depth > maxDepth {
93+
maxDepth = depth
94+
}
95+
}
96+
delete(visited, id)
97+
98+
return maxDepth + 1
99+
}
100+
101+
// positionElements places elements horizontally within each layer.
102+
func (h *HierarchicalLayout) positionElements(layers map[int][]string) map[string]ElementPosition {
103+
positions := make(map[string]ElementPosition)
104+
105+
for layer, ids := range layers {
106+
// Default sizes
107+
elemWidth := 160.0
108+
elemHeight := 60.0
109+
110+
// Calculate layer positions
111+
var x, y float64
112+
if h.rankDir == "TB" {
113+
// Top-to-bottom: layer determines Y, elements spread horizontally
114+
y = float64(layer) * h.layerGap
115+
x = 50.0
116+
} else {
117+
// Left-to-right: layer determines X, elements spread vertically
118+
x = float64(layer) * h.layerGap
119+
y = 50.0
120+
}
121+
122+
// Position elements in this layer
123+
for i, id := range ids {
124+
elemX, elemY := x, y
125+
if h.rankDir == "TB" {
126+
elemX = x + float64(i)*(elemWidth+h.spacing)
127+
} else {
128+
elemY = y + float64(i)*(elemHeight+h.spacing)
129+
}
130+
131+
positions[id] = ElementPosition{
132+
ID: id,
133+
X: elemX,
134+
Y: elemY,
135+
Width: elemWidth,
136+
Height: elemHeight,
137+
Layer: layer,
138+
}
139+
}
140+
}
141+
142+
return positions
143+
}

0 commit comments

Comments
 (0)