Silsilah is a cross-platform family tree application designed to let users build, visualize, and interact with complex genealogy graphs. Delivering a local-first user experience across iOS, Android, and Web clients introduced unique architectural challenges, particularly regarding relational database integrity and graph layout rendering.

In this log, I'll walk through how I structured the relational database schema in SwiftData, resolved cascading deletions on cyclical data configurations, and engineered the layout algorithms for the rendering canvas.

1. Relational Modeling with SwiftData

Genealogy trees are graphs rather than simple hierarchies. A person can have multiple marriages, step-children, and complex ancestral loops. SwiftData represents these as linked objects, but its default compiler macros require careful handling to avoid retain cycles and database deadlocks.

I structured the schema around two main entities: Person and Marriage. By representing marriages as distinct entities rather than direct connections between people, Silsilah can model children belonging to specific partnerships, divorces, and blended families.

@Model
final class Person {
    @Attribute(.unique) var id: UUID
    var name: String
    var birthDate: Date?
    
    // Relationships
    @Relationship(deleteRule: .cascade, inverse: \Marriage.husband)
    var husbandMarriages: [Marriage] = []
    
    @Relationship(deleteRule: .cascade, inverse: \Marriage.wife)
    var wifeMarriages: [Marriage] = []
    
    @Relationship(inverse: \Marriage.children)
    var parentMarriage: Marriage?
    
    init(name: String) {
        self.id = UUID()
        self.name = name
    }
}

@Model
final class Marriage {
    @Attribute(.unique) var id: UUID
    
    var husband: Person?
    var wife: Person?
    
    @Relationship(deleteRule: .nullify)
    var children: [Person] = []
    
    init(husband: Person?, wife: Person?) {
        self.id = UUID()
        self.husband = husband
        self.wife = wife
    }
}

Cascading Deletion Rules

To prevent orphaned database records while avoiding cyclical deletion loops (which crash SwiftData's persistent store), the deletion rules are configured as follows:

  • Deleting a Person cascades and deletes any associated Marriage objects they participated in as husband or wife.
  • Deleting a Marriage does not delete the spouses; it merely nullifies their relationship references.
  • Deleting a Marriage nullifies the child references, leaving the children's Person records intact in the system.

2. The Graph Traversal & Coordinate Layout Challenge

Drawing a genealogy tree requires mapping individuals to 2D coordinates (X, Y). Standard tree-drawing algorithms (e.g., Reingold-Tilford) assume a strict single-root tree structure, which fails when husbands and wives introduce multiple parent lines.

Silsilah resolves layout calculations using a depth-first traversal variant that establishes generational columns (Y-axis) and lineage lanes (X-axis):

// Pseudocode of the coordinate assignment loop
func calculatePositions(for person: Person, generation: Int, offset: Double) {
    // 1. Assign generational Y coordinate
    person.y = Double(generation) * verticalSpacing
    
    // 2. Traverse spouses
    let marriages = person.marriages.sortedByDate()
    for (index, marriage) in marriages.enumerated() {
        let spouse = marriage.partner(of: person)
        spouse.y = person.y
        spouse.x = person.x + horizontalSpouseSpacing
        
        // 3. Coordinate children below spouses
        let children = marriage.children.sortedByBirthDate()
        var currentChildOffset = spouse.x - (Double(children.count) * childSpacing / 2)
        for child in children {
            calculatePositions(for: child, generation: generation + 1, offset: currentChildOffset)
            currentChildOffset += childSpacing
        }
    }
}

To prevent overlapping branches on massive family trees, a secondary "overlap resolver" pass traverses the calculated tree, measures collisions between sub-trees, and pushes colliding branches horizontally using dynamic spacers.

3. Custom Drawing with Bezier Paths

In SwiftUI, rendering connection lines dynamically between spouse blocks and child blocks is achieved using a custom Shape structure drawing smooth Bezier curves.

struct ConnectionLine: Shape {
    var from: CGPoint
    var to: CGPoint
    
    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: from)
        
        // Calculate control points for smooth S-curves
        let midY = (from.y + to.y) / 2
        let control1 = CGPoint(x: from.x, y: midY)
        let control2 = CGPoint(x: to.x, y: midY)
        
        path.addCurve(to: to, control1: control1, control2: control2)
        return path
    }
}

4. Cross-Platform Adaptations

While iOS runs the drawing operations on a GPU-backed SwiftUI Canvas, the Android client uses Jetpack Compose Canvas. I optimized Compose canvas draw cycles by wrapping the layout computations inside static state snapshots, ensuring that pan-and-zoom actions do not trigger garbage collection spikes.

For the web frontend built with SvelteKit, using standard HTML5 Canvas would restrict web accessibility. Instead, I designed the web tree viewer using an absolute-positioned DOM grid. This design allows screen readers to navigate parent-child hierarchies via native HTML structure, while enabling search engine bots to index historical names and branches.