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'.
The actual puzzle solution is funnily enough a round number: '6666'.
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 :)