Search code examples
swiftswiftuigeometryreader

An ellipse in SwiftUI centered with GeometryReader is not aligning properly. It should center with the center point of two points


I am trying to center the ellipse with the two points as the foci. The equation ought to be sufficient, but the ellipse is offset. No solution seems to work. I can rotate the ellipse, but the problem remains.

Ellipse offset from points

The ellipse ought to be around the line and points. The rotation as the green point is dragged works.

import SwiftUI
import Foundation

struct ContentView: View {
    // Interactive variables for draggable source point
    @State private var s_x: Double = 0
    @State private var s_y: Double = -100
    
    // Fixed focus point (d_x, d_y) set to the center of the screen
    @State private var d_x: Double = 0
    @State private var d_y: Double = 0
    
    // Constants for the sliders (tempo and note value)
    @State private var T: Double = 185
    @State private var selectedNoteValue: String = "32nd Note"
    @State private var M: Double = 20
    
    // Error calculation mode (1 for E1, 2 for E2)
    @State private var selectedErrorCalculation: Int = 1
    
    // Note value mapping to musical durations
    let noteValueMap: [String: Double] = [
        "Half Note": 0.5,
        "Quarter Note": 1,
        "8th Note": 2,
        "Triplet": 3,
        "16th Note": 4,
        "32nd Note": 8,
        "64th Note": 16,
        "128th Note": 32
    ]
    
    // Computed Variables for the distance formula
    var I: Double {
        return (22.5 / 12) / 1150
    }
    
    var E1: Double {
        return 60 / (T * noteValueMap[selectedNoteValue]! * I)
    }
    
    var E2: Double {
        return 13.397244094488 * M / 22.5
    }
    
    // Euclidean Distance Function
    func C(x: Double, y: Double, a: Double, b: Double) -> Double {
        return sqrt(pow(x - a, 2) + pow(y - b, 2))
    }
    
    // Focal Distance (constant sum of distances from any point to the foci)
    func F() -> Double {
        let distanceBetweenFoci = C(x: s_x, y: s_y, a: d_x, b: d_y)
        return distanceBetweenFoci + (selectedErrorCalculation == 1 ? E1 : E2)
    }
    
    // Semi-Major and Semi-Minor Axes for the Ellipse
    func ellipseAxes() -> (semiMajor: Double, semiMinor: Double) {
        let distanceBetweenFoci = C(x: s_x, y: s_y, a: d_x, b: d_y)
        let F_value = F()
        
        // Semi-major axis is the half of the sum of distances (F), semi-minor axis is the difference
        let semiMajor = F_value / 2
        let semiMinor = semiMajor - distanceBetweenFoci / 2
        
        return (semiMajor, semiMinor)
    }
    
    var body: some View {
        GeometryReader { geometry in
            VStack {
                ZStack {
                    // Draw the line connecting the two foci (source and fixed point)
                    Path { path in
                        path.move(to: CGPoint(x: s_x + geometry.size.width / 2, y: s_y + geometry.size.height / 2))
                        path.addLine(to: CGPoint(x: d_x + geometry.size.width / 2, y: d_y + geometry.size.height / 2))
                    }
                    .stroke(Color.blue, lineWidth: 2)
                    
                    // Compute Ellipse Properties
                    let centerX = (s_x + d_x) / 2 + geometry.size.width / 2
                    let centerY = (s_y + d_y) / 2 + geometry.size.height / 2
                    let focalDistance = C(x: s_x, y: s_y, a: d_x, b: d_y)
                    let semiMajor = F() / 2
                    let semiMinor = sqrt(pow(semiMajor, 2) - pow(focalDistance / 2, 2))
                    
                    // Adjust angle for flipped Y-axis
                    let angle = atan2(s_y - d_y, s_x - d_x)  // Correct the angle calculation for the Y-axis flip
                    
                    // Draw the ellipse
                    Path { path in
                        let rect = CGRect(x: -semiMajor, y: -semiMinor, width: semiMajor * 2, height: semiMinor * 2)
                        path.addEllipse(in: rect)
                    }
                    .stroke(Color.red, lineWidth: 2)
                    .frame(width: semiMajor * 2, height: semiMinor * 2)
                    .rotationEffect(Angle(radians: angle))
                    .position(x: centerX, y: centerY)
                    
                    // Draggable Source Point (focal point 1)
                    Circle()
                        .fill(Color.green)
                        .frame(width: 10, height: 10)
                        .position(x: s_x + geometry.size.width / 2, y: s_y + geometry.size.height / 2)
                        .gesture(
                            DragGesture()
                                .onChanged { value in
                                    self.s_x = Double(value.location.x - geometry.size.width / 2)
                                    self.s_y = Double(value.location.y - geometry.size.height / 2)
                                }
                        )
                    
                    // Fixed Point (focal point 2)
                    Circle()
                        .fill(Color.blue)
                        .frame(width: 10, height: 10)
                        .position(x: d_x + geometry.size.width / 2, y: d_y + geometry.size.height / 2)
                }
                
                Spacer()
                
                VStack(spacing: 10) {
                    Text("Control Parameters")
                        .font(.headline)
                    
                    HStack {
                        Text("Note Value (N):")
                        Picker("Note Value", selection: $selectedNoteValue) {
                            ForEach(noteValueMap.keys.sorted(), id: \.self) { note in
                                Text(note).tag(note)
                            }
                        }
                        .pickerStyle(MenuPickerStyle())
                    }
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding([.horizontal])
                    
                    VStack {
                        HStack {
                            Text("Tempo (T): \(Int(T))")
                            Slider(value: $T, in: 60...240, step: 1)
                                .accentColor(.green)
                        }
                        HStack {
                            Text("MSec Error: \(Int(M))")
                            Slider(value: $M, in: 1...100, step: 1)
                                .accentColor(.purple)
                        }
                    }
                    .padding([.horizontal])
                    
                    Picker("Select Error Calculation", selection: $selectedErrorCalculation) {
                        Text("E1 (Musically Based)").tag(1)
                        Text("E2 (Error in Milliseconds)").tag(2)
                    }
                    .pickerStyle(SegmentedPickerStyle())
                    .padding([.top, .horizontal])
                    
                    DisclosureGroup("Details") {
                        VStack {
                            Text("I (Seconds per Cell)= \(I, specifier: "%.5f")")
                            Text("E1 (60/TNI)= \(E1, specifier: "%.5f")")
                            Text("E2 (MSec Error/I) = \(E2, specifier: "%.5f")")
                            Text("F (S to D + E)= \(F(), specifier: "%.5f")")
                            Text("Source: (\(Int(s_x)), \(Int(s_y)))")
                            Text("Destination: (\(Int(d_x)), \(Int(d_y)))")
                        }
                        .padding([.top, .horizontal])
                    }
                    .padding([.top, .horizontal])
                }
                .frame(maxWidth: .infinity)
                .padding([.bottom, .horizontal])
                .background(Color.white)
            }
        }
    }
    
    struct ContentView_Previews: PreviewProvider {
        static var previews: some View {
            ContentView()
        }
    }

  
}
#Preview {
    ContentView()
}

Solution

  • You are drawing the ellipse with its center being at the top left of the Path view. Add a border to the Path and comment out the rotationEffect and position to see this for yourself.

    Path { path in
        let rect = CGRect(x: -semiMajor, y: -semiMinor, width: semiMajor * 2, height: semiMinor * 2)
        path.addEllipse(in: rect)
    }
    .stroke(Color.red, lineWidth: 2)
    .frame(width: semiMajor * 2, height: semiMinor * 2)
    // add a border
    .border(.blue)
    // .rotationEffect(Angle(radians: angle))
    // .position(x: centerX, y: centerY)
    

    enter image description here

    The blue border is the "logical frame" of the Path view, so rotationEffect and position will operate on that blue rectangle. It correctly transforms the blue border to the desired position. Uncomment the two lines and you get:

    enter image description here

    If you want to position the ellipse using rotationEffect and position in this way, the ellipse should be drawn inside of the "logical frame" of the Path view. This means the rectangle in which you draw the ellipse should have (0, 0) as its origin

    Path { path in
        let rect = CGRect(x: 0, y: 0, width: semiMajor * 2, height: semiMinor * 2)
        path.addEllipse(in: rect)
    }
    .stroke(Color.red, lineWidth: 2)
    .frame(width: semiMajor * 2, height: semiMinor * 2)
    .border(.blue)
    .rotationEffect(Angle(radians: angle))
    .position(x: centerX, y: centerY)
    

    This is equivalent to just using the built-in Ellipse shape:

    Ellipse()
        .stroke(Color.red, lineWidth: 2)
        .frame(width: semiMajor * 2, height: semiMinor * 2)
        .border(.blue)
        .rotationEffect(Angle(radians: angle))
        .position(x: centerX, y: centerY)
    

    Alternatively, you can keep the position and size of the Path view constant, and only transform the path's drawing instructions.

    Path { path in
        let rect = CGRect(x: -semiMajor, y: -semiMinor, width: semiMajor * 2, height: semiMinor * 2)
        path.addEllipse(in: rect)
    }
    .rotation(Angle(radians: angle), anchor: .topLeading)
    .offset(x: centerX, y: centerY)
    .stroke(Color.red, lineWidth: 2)
    

    Here I used rotation and offset, which both transforms the path's drawing instructions, as opposed to the "logical frame" of the Path view. The "logical frame" of the Path view will always fill all the available space.

    anchor: .topLeading is needed to rotate the view about (0, 0), instead of the center of the path, which is the default.