From 5feb7d9c5253db3403aa4caae8ba159de46eb9f7 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Wed, 23 Oct 2024 20:12:41 +1100 Subject: [PATCH] Improve performance by projecting contents just once and caching result --- .../GeoDrawer/GeoDrawer+CoreGraphics.swift | 174 ++++++++++-------- Sources/GeoDrawer/GeoDrawer.swift | 63 ++++++- Sources/GeoDrawer/GeoMap+UIKit.swift | 23 ++- 3 files changed, 176 insertions(+), 84 deletions(-) diff --git a/Sources/GeoDrawer/GeoDrawer+CoreGraphics.swift b/Sources/GeoDrawer/GeoDrawer+CoreGraphics.swift index 9509b09..b84981e 100644 --- a/Sources/GeoDrawer/GeoDrawer+CoreGraphics.swift +++ b/Sources/GeoDrawer/GeoDrawer+CoreGraphics.swift @@ -20,103 +20,111 @@ extension GeoDrawer { /// Draws the line into the current context public func draw(_ line: GeoJSON.LineString, strokeColor: CGColor, strokeWidth: Double = 2, in context: CGContext) { - for points in convertLine(line.positions) { - - let path = CGMutablePath() - path.move(to: points[0].cgPoint) - for point in points[1...] { - path.addLine(to: point.cgPoint) - } - - context.setStrokeColor(strokeColor) - - context.setLineWidth(strokeWidth) - context.setLineCap(.round) - context.setLineJoin(.round) - - context.addPath(path) - context.strokePath() + for line in project(line) { + draw(line, strokeColor: strokeColor, strokeWidth: strokeWidth, in: context) } } public func draw(_ polygon: GeoJSON.Polygon, fillColor: CGColor? = nil, strokeColor: CGColor? = nil, strokeWidth: Double = 2, frame: CGRect, in context: CGContext) { - // In some projections such as Azimuthal, we might need to colour a cut-out - // rather than the projected polygon. - let invert: Bool = invertCheck?(polygon) ?? false + for polygon in project(polygon) { + draw(polygon, fillColor: fillColor, strokeColor: strokeColor, strokeWidth: strokeWidth, frame: frame, in: context) + } + } + + func drawCircle(_ position: GeoJSON.Position, radius: CGFloat, fillColor: CGColor, strokeColor: CGColor? = nil, strokeWidth: Double = 2, in context: CGContext) { + guard let center = converter(position)?.0 else { return } + drawCircle(center, radius: radius, fillColor: fillColor, strokeColor: strokeColor, strokeWidth: strokeWidth, in: context) + } + + func draw(_ projectedLine: ProjectedLineString, strokeColor: CGColor, strokeWidth: Double, in context: CGContext) { + let points = projectedLine.points + let path = CGMutablePath() + path.move(to: points[0].cgPoint) + for point in points[1...] { + path.addLine(to: point.cgPoint) + } - for points in convertLine(polygon.exterior.positions) { - - let path = CGMutablePath() - path.move(to: points[0].cgPoint) - for point in points[1...] { - path.addLine(to: point.cgPoint) - } + context.setStrokeColor(strokeColor) + + context.setLineWidth(strokeWidth) + context.setLineCap(.round) + context.setLineJoin(.round) + + context.addPath(path) + context.strokePath() + } + + func draw(_ polygon: ProjectedPolygon, fillColor: CGColor?, strokeColor: CGColor?, strokeWidth: Double, frame: CGRect, in context: CGContext) { + let invert = polygon.invert + + let points = polygon.exterior + let path = CGMutablePath() + path.move(to: points[0].cgPoint) + for point in points[1...] { + path.addLine(to: point.cgPoint) + } + + // First we need to create a clip path, i.e., the area where we allowed to fill in the actual polygon. + // This is the whole frame *minus* the interior polygons. + // Useful example: https://samplecodebank.blogspot.com/2013/06/UIBezierPath-addClip-example.html + if !polygon.interiors.isEmpty { + let rect = CGMutablePath() + rect.move(to: frame.origin) + rect.addLine(to: CGPoint(x: frame.minX, y: frame.maxY)) + rect.addLine(to: CGPoint(x: frame.maxX, y: frame.maxY)) + rect.addLine(to: CGPoint(x: frame.maxX, y: frame.minY)) - // First we need to create a clip path, i.e., the area where we allowed to fill in the actual polygon. - // This is the whole frame *minus* the interior polygons. - // Useful example: https://samplecodebank.blogspot.com/2013/06/UIBezierPath-addClip-example.html - if !polygon.interiors.isEmpty { - let rect = CGMutablePath() - rect.move(to: frame.origin) - rect.addLine(to: CGPoint(x: frame.minX, y: frame.maxY)) - rect.addLine(to: CGPoint(x: frame.maxX, y: frame.maxY)) - rect.addLine(to: CGPoint(x: frame.maxX, y: frame.minY)) - - for interior in polygon.interiors { - for interiorPoints in convertLine(interior.positions) { - rect.move(to: interiorPoints[0].cgPoint) - for point in interiorPoints[1...] { - rect.addLine(to: point.cgPoint) - } - } + for interiorPoints in polygon.interiors { + rect.move(to: interiorPoints[0].cgPoint) + for point in interiorPoints[1...] { + rect.addLine(to: point.cgPoint) } - - context.addPath(rect) - context.clip(using: .evenOdd) } - // Then we can draw the polygon - - context.addPath(path) + context.addPath(rect) + context.clip(using: .evenOdd) + } + + // Then we can draw the polygon + + context.addPath(path) - if let fillColor { - if invert { - // TODO: This doesn't actually invert. Would be nice to do that later. - context.setStrokeColor(fillColor) - context.setLineWidth(strokeWidth) - context.setLineCap(.round) - context.setLineJoin(.round) - context.strokePath() - - } else { - context.setFillColor(fillColor) - context.setLineWidth(0) - context.fillPath() - } - - } - - if let strokeColor { - context.addPath(path) - context.setStrokeColor(strokeColor) + if let fillColor { + if invert { + // TODO: This doesn't actually invert. Would be nice to do that later. + context.setStrokeColor(fillColor) context.setLineWidth(strokeWidth) context.setLineCap(.round) context.setLineJoin(.round) context.strokePath() + + } else { + context.setFillColor(fillColor) + context.setLineWidth(0) + context.fillPath() } + + } + + if let strokeColor { + context.addPath(path) + context.setStrokeColor(strokeColor) + context.setLineWidth(strokeWidth) + context.setLineCap(.round) + context.setLineJoin(.round) + context.strokePath() } } + - func drawCircle(_ position: GeoJSON.Position, radius: CGFloat, fillColor: CGColor, strokeColor: CGColor? = nil, strokeWidth: Double = 2, in context: CGContext) { - guard let origin = converter(position) else { return } - - context.addArc(center: origin.0.cgPoint, radius: radius / 2, startAngle: 0, endAngle: CGFloat(Double.pi * 2), clockwise: true) + func drawCircle(_ center: Point, radius: CGFloat, fillColor: CGColor, strokeColor: CGColor? = nil, strokeWidth: Double = 2, in context: CGContext) { + context.addArc(center: center.cgPoint, radius: radius / 2, startAngle: 0, endAngle: CGFloat(Double.pi * 2), clockwise: true) context.setFillColor(fillColor) context.fillPath() if let strokeColor { - context.addArc(center: origin.0.cgPoint, radius: radius / 2, startAngle: 0, endAngle: CGFloat(Double.pi * 2), clockwise: true) + context.addArc(center: center.cgPoint, radius: radius / 2, startAngle: 0, endAngle: CGFloat(Double.pi * 2), clockwise: true) context.setStrokeColor(strokeColor) context.setLineWidth(strokeWidth) context.strokePath() @@ -181,7 +189,11 @@ extension GeoDrawer { extension GeoDrawer { public func draw(_ contents: [Content], mapBackground: CGColor? = nil, mapOutline: CGColor? = nil, mapBackdrop: CGColor? = nil, in context: CGContext) { - + let projected = contents.compactMap(project) + draw(projected, mapBackground: mapBackground, mapOutline: mapOutline, mapBackdrop: mapBackdrop, in: context) + } + + func draw(_ contents: [ProjectedContent], mapBackground: CGColor?, mapOutline: CGColor?, mapBackdrop: CGColor?, in context: CGContext) { let size = CGSize(width: self.size.width, height: self.size.height) let bounds = CGRect(origin: .zero, size: size) @@ -201,10 +213,14 @@ extension GeoDrawer { switch content { case .circle: break // this will go above the outline, as they might go outside projection - case let .line(line, stroke, strokeWidth): - draw(line, strokeColor: stroke, strokeWidth: strokeWidth, in: context) - case let .polygon(polygon, fill, stroke, strokeWidth): - draw(polygon, fillColor: fill, strokeColor: stroke, strokeWidth: strokeWidth, frame: bounds, in: context) + case let .line(lines, stroke, strokeWidth): + for line in lines { + draw(line, strokeColor: stroke, strokeWidth: strokeWidth, in: context) + } + case let .polygon(polygons, fill, stroke, strokeWidth): + for polygon in polygons { + draw(polygon, fillColor: fill, strokeColor: stroke, strokeWidth: strokeWidth, frame: bounds, in: context) + } } } diff --git a/Sources/GeoDrawer/GeoDrawer.swift b/Sources/GeoDrawer/GeoDrawer.swift index d6da4e3..c0e2a02 100644 --- a/Sources/GeoDrawer/GeoDrawer.swift +++ b/Sources/GeoDrawer/GeoDrawer.swift @@ -178,16 +178,72 @@ extension GeoDrawer { } +// MARK: - Projected content + +extension GeoDrawer { + + struct ProjectedLineString: Hashable { + let points: [Point] + } + + struct ProjectedPolygon: Hashable { + let exterior: [Point] + let interiors: [[Point]] + + // In some projections such as Azimuthal, we might need to colour a cut-out + // rather than the projected polygon. + let invert: Bool + } + + enum ProjectedContent: Hashable { + case line([ProjectedLineString], stroke: Color, strokeWidth: Double) + case polygon([ProjectedPolygon], fill: Color, stroke: Color?, strokeWidth: Double) + case circle(Point, radius: Double, fill: Color, stroke: Color?, strokeWidth: Double) + } +} + +extension GeoDrawer { + func project(_ line: GeoJSON.LineString) -> [ProjectedLineString] { + let lines = convertLine(line.positions) + return lines.map(ProjectedLineString.init(points:)) + } + + func project(_ polygon: GeoJSON.Polygon) -> [ProjectedPolygon] { + let invert: Bool = invertCheck?(polygon) ?? false + let interiors = polygon.interiors.flatMap { convertLine($0.positions) } + return convertLine(polygon.exterior.positions).map { points in + return .init(exterior: points, interiors: interiors, invert: invert) + } + } + + func project(_ content: Content) -> ProjectedContent? { + switch content { + case let .line(line, stroke, strokeWidth): + return .line(project(line), stroke: stroke, strokeWidth: strokeWidth) + case let .polygon(polygon, fill, stroke, strokeWidth): + return .polygon(project(polygon), fill: fill, stroke: stroke, strokeWidth: strokeWidth) + case let .circle(center, radius, fill, stroke, strokeWidth): + guard let point = converter(center)?.0 else { return nil } + return .circle(point, radius: radius, fill: fill, stroke: stroke, strokeWidth: strokeWidth) + } + } +} + // MARK: - Line helper extension GeoDrawer { - private enum Grouping: Equatable { + enum Grouping: Equatable { case wrapped case notWrapped case notProjected } + /// - Returns: Typically returns a single element, but can return multiple, if the line wraps around + func convertLine(_ positions: [GeoJSON.Position]) -> [[Point]] { + Self.convertLine(positions, projection: projection, size: size, zoomTo: zoomTo, insets: insets, converter: converter) + } + private static func projectLine(_ positions: [GeoJSON.Position], projection: Projection) -> [(Point, Point?)] { // 1. Turn degrees into radians @@ -208,10 +264,11 @@ extension GeoDrawer { } /// - Returns: Typically returns a single element, but can return multiple, if the line wraps around - func convertLine(_ positions: [GeoJSON.Position]) -> [[Point]] { + private static func convertLine(_ positions: [GeoJSON.Position], projection: Projection?, size: Size, zoomTo: Rect?, insets: EdgeInsets, converter: (GeoJSON.Position) -> (Point, Bool)?) -> [[Point]] { + guard let projection else { return [positions.compactMap { - self.converter($0)?.0 + converter($0)?.0 }] } diff --git a/Sources/GeoDrawer/GeoMap+UIKit.swift b/Sources/GeoDrawer/GeoMap+UIKit.swift index 5ed4d50..0c244ff 100644 --- a/Sources/GeoDrawer/GeoMap+UIKit.swift +++ b/Sources/GeoDrawer/GeoMap+UIKit.swift @@ -16,12 +16,16 @@ import GeoJSONKit public class GeoMapView: UIView { public var contents: [GeoDrawer.Content] = [] { - didSet { setNeedsDisplay(bounds) } + didSet { + _projected = nil + setNeedsDisplay(bounds) + } } public var projection: Projection = Projections.Equirectangular() { didSet { _drawer = nil + _projected = nil setNeedsDisplay(bounds) } } @@ -29,6 +33,7 @@ public class GeoMapView: UIView { public var zoomTo: GeoJSON.BoundingBox? = nil { didSet { _drawer = nil + _projected = nil setNeedsDisplay(bounds) } } @@ -36,6 +41,7 @@ public class GeoMapView: UIView { public var insets: GeoProjector.EdgeInsets = .zero { didSet { _drawer = nil + _projected = nil setNeedsDisplay(bounds) } } @@ -56,10 +62,23 @@ public class GeoMapView: UIView { public override var frame: CGRect { didSet { _drawer = nil + _projected = nil setNeedsDisplay(bounds) } } + + private var _projected: [GeoDrawer.ProjectedContent]! + private var projected: [GeoDrawer.ProjectedContent] { + if let _projected { + return _projected + } else { + let projected = contents.compactMap(drawer.project(_:)) + _projected = projected + return projected + } + } + private var _drawer: GeoDrawer! private var drawer: GeoDrawer { if let _drawer { @@ -93,7 +112,7 @@ public class GeoMapView: UIView { // Use Core Graphics functions to draw the content of your view drawer.draw( - contents, + projected, mapBackground: mapBackground.cgColor, mapOutline: mapOutline.cgColor, mapBackdrop: background.cgColor,