Using the Canvas in SwiftUI for the Advent of Code

Using the Canvas in SwiftUI for the Advent of Code

ยท

6 min read

I discovered the Canvas in SwiftUI after a couple of live streams by Paul Hudson of Hacking With Swift

I thought day5 of #aoc #AdventOfCode2021 would be perfect to try to plot something. Quite fun actually. The solution is the number of intersections in the lines.

https://adventofcode.com/2021/day/5

If interested here is the code: https://github.com/multitudes/aoc2021-day5

A simple app

I made a simple app to show the solution.
Below you can see a couple of screenshots.
The first is the solution to the example challenge (the easy one given in the challenge text); the second is the solution to the actual challenge. Everyone gets different inputs, so the solution is not the same for everyone.

The question is: given the input with a series of lines, at 'how many points the lines overlap'? The solution of the first one, which is an example, is '12'.

IMG_6ED3DAD99AF7-1.jpeg

The actual puzzle solution is funnily enough a round number: '6666'.
IMG_2911.PNG

Setting up the model

The input file is a text file with a series of coordinates, something like this:

72,504 -> 422,154
877,851 -> 680,654
447,989 -> 517,989
173,125 -> 981,933
736,255 -> 374,617
...

Create a model.

First of all I need a model for my Point. I could use a CGPoint, but there is no need, since I can convert them in the Canvas, and a simple Point is more generic and easy to handle in the view model.

struct Point : Hashable {
    let x: Int
    let y: Int
    init(x: Int = 0, y: Int = 0) {
        self.x = x; self.y = y
    }
}

The line orientation can be either horizontal or vertical, or with a 45 degrees angle.
An Enum would be a great choice here:

enum LineOrientation {
    case horizontal, vertical, diagonal, unknown
}

With this out of the way I will decode each row in the text input as a Line.
This is the code to get the input. From the .txt file I get back an array of Line: (I used a custom String extension to get the value with a regex. Regexes are not as easy in Swift as in other languages yet. I hope this will change in the future ๐Ÿคท๐Ÿปโ€โ™‚๏ธ).


func getInputDay5() -> [Line] {
    var input: [Line] = []
    do {

        if let inputFileURL = Bundle.main.url(forResource: "input-5a", withExtension: "txt") {
            do {
                input = try String(contentsOf: inputFileURL)
                    .split(separator: "\n")
                    .map {
                        let row: [String] = String($0).getTrimmedCapturedGroupsFrom(regexPattern: "(\\d+),(\\d+) -> (\\d+),(\\d+)") ?? []
                        return Line(
                            pointA: Point(x: Int(row[0])!, y: Int(row[1])!),
                            pointB: Point(x: Int(row[2])!, y: Int(row[3])!))
                    }
            } catch {
                print(error.localizedDescription)
            }
        }
    }
    return input
}

extension String {
    func getTrimmedCapturedGroupsFrom(regexPattern: String)-> [String]? {
        let text = self
        let regex = try? NSRegularExpression(pattern: regexPattern)

        let match = regex?.firstMatch(in: text, range: NSRange(text.startIndex..., in: text))

        if let match = match {
            return (0..<match.numberOfRanges).compactMap {
                if let range = Range(match.range(at: $0), in: text) {
                    return $0 > 0 ? String(text[range]).trimmingCharacters(in: .whitespaces) : nil
                }
                return nil
            }
        }
        return nil
    }
}

The Line will be as follow. Each line is an array of Point. I later realised I might need only the start and end in the canvas, but here we are. Later optimisations of the code will be welcome. This is the first draft:


struct Line {
    var points = [Point]()
    var color = Color.blue // for swiftUI!
    var orientation: LineOrientation
    var width = 0.5

    init(pointA: Point, pointB: Point) {
        var allPoints: Set<Point> = []
        self.highestX = Int(max(pointA.x, pointB.x))
        self.highestY = Int(max(pointA.y, pointB.y))
        self.lowestX = Int(min(pointA.x, pointB.x))
        self.lowestY = Int(min(pointA.y, pointB.y))

        /// get orientation
        if pointA.y == pointB.y {
            self.orientation = .horizontal
            let y = lowestY
            for x in lowestX...highestX {
                let p = Point(x: x, y: y)
                allPoints.insert(p)
            }
            self.points = Array(allPoints)
        } else if pointA.x == pointB.x {
            self.orientation = .vertical
            let x = lowestX
            for y in lowestY...highestY {
                let p = Point(x: x, y: y)
                allPoints.insert(p)
            }
            self.points = Array(allPoints)
        } else {
            self.orientation = .diagonal
            let diff = pointB.x - pointA.x
            if pointA.x < pointB.x && pointA.y < pointB.y {
                var x = pointA.x
                var y = pointA.y
                for _ in 0...abs(diff) {
                    let p = Point(x: x, y: y)
                    allPoints.insert(p)
                    x += 1; y += 1
                }
            }
            if pointA.x > pointB.x && pointA.y < pointB.y {
                var x = pointA.x
                var y = pointA.y
                for _ in 0...abs(diff) {
                    let p = Point(x: x, y: y)
                    allPoints.insert(p)
                    x -= 1; y += 1
                }
            }
            if pointA.x < pointB.x && pointA.y > pointB.y {
                var x = pointA.x
                var y = pointA.y
                for _ in 0...abs(diff) {
                    let p = Point(x: x, y: y)
                    allPoints.insert(p)
                    x += 1; y -= 1
                }
            }
            if pointA.x > pointB.x && pointA.y > pointB.y {
                var x = pointA.x
                var y = pointA.y
                for _ in 0...abs(diff) {
                    let p = Point(x: x, y: y)
                    allPoints.insert(p)
                    x -= 1; y -= 1
                }
            }
            self.points = Array(allPoints)
        }
    }
    var highestX: Int
    var highestY: Int
    var lowestX: Int
    var lowestY: Int
}

Getting to the ViewModel!

ViewModels in SwiftUI are usually classes and conform to the ObservableObject protocol. I made a class called Drawing.

class Drawing: ObservableObject {
    let canvasWidth: Int
    let canvasHeight: Int
    let lines: [Line]
    let intersections: [Point]

    init() {
        let input: [Line] = getInputDay5()

        /// this if I wantto draw them on the iPad later
        self.canvasWidth = input.reduce(0) { max($0, Int($1.highestX)) }
        self.canvasHeight = input.reduce(0) { Int(max($0, Int($1.highestY))) }
        print("canvasWidth \(canvasWidth), canvasHeight \(canvasHeight)")
        self.lines = input
        self.intersections = getIntersections(for: input)
    }
}

The reason that I made it conform to the ObservableObject protocol, even when I do not have @Published values is to be able to pass the data into the environment:

            ContentView()
                .environmentObject(Drawing())

Finally the view!

In ContentView I will draw the data into a canvas!


struct ContentView: View {
    @EnvironmentObject var drawing: Drawing

    var body: some View {
        NavigationView {
            VStack {
                Text("Advent of code 2021\n - Day5 - Hydrothermal Venture")
                    .font(.subheadline).bold()
                    .multilineTextAlignment(.center)
                    .padding()
                Text("Example data")
                    .font(.body).bold()
//                Text("Solution")
//                    .font(.body).bold()
                Canvas {context, size in
                    for line in drawing.lines {
                        var path = Path()
                        /// create a line
                        var newLine = [CGPoint]()
                        for point in line.points {
                            let xPos = (point.x * (Int(size.width )) / drawing.canvasWidth)
                            let yPos = (point.y * (Int(size.height)) / drawing.canvasHeight)
                            let newPoint = CGPoint(x: xPos, y: yPos)
                            newLine.append(newPoint)
                        }
                        /// and add it to the canvas
                        path.addLines(newLine)
                        context.stroke(path, with: .color(line.color),style: StrokeStyle(lineWidth: line.width, lineCap: .round, lineJoin: .round ))
                    }
                    /// once the lines are drawn, add the intersections
                    let baseTransform = context.transform
                    for point in drawing.intersections {
                        let xPos = (point.x * (Int(size.width )) / drawing.canvasWidth)
                        let yPos = (point.y * (Int(size.height)) / drawing.canvasHeight)
                        let newPoint = CGPoint(x: xPos, y: yPos)
                        context.translateBy(x: newPoint.x, y: newPoint.y)
                        context.stroke(
                            Path(ellipseIn: CGRect(origin: CGPoint(x: -0.5, y: -0.5), size: CGSize(width: 1, height: 1))),
                                with: .color(.pink),
                            lineWidth: 4)
                        context.transform = baseTransform
                    }
                }
                .aspectRatio(1, contentMode: .fit)
                .padding()
                .border(Color.blue, width: 1)

                Spacer()
            }
            .padding(30)
            .navigationTitle("Plotting On Canvas with SwiftUI")
        }
        .navigationViewStyle(.stack)
    }
}

The last piece of code to get the solution is to keep track of the intersections. For this I added a function doing just that:

func checkIntersections(for input: [Line]) -> Int {
    /// empty start
    var intersections: Set<Point> = []
    var accumulator: Set<Point> = Set(input.first!.points)
    for line in input.dropFirst() {
        let intersection = accumulator.intersection(Set(line.points))
        intersections = intersections.union(intersection)
        accumulator = accumulator.union(Set(line.points))
    }
    return intersections.count
}

I hope this has been somewhat fun. The code is on GitHub. Kudos to Paul Hudson for showing me how to use the Canvas :)