From d881cb952b9b01dd27e7f8b2a2c9c552daddbf98 Mon Sep 17 00:00:00 2001 From: Kiryl Kaveryn Date: Tue, 5 Nov 2024 15:32:12 +0400 Subject: [PATCH] [ios] refactor Chart framework 1. Fixed big issue when the track point's array indexes are used for the xAxis values instead of the actual distances. Old approach cause wrong elevation profile shape and wrong slope ratio. 2. Track start/end clipper (chartPreviewView) set to hidden because it overload the screen 3. Fix code formatting, naming and style 4. Added TapGesture to handle single taps on the chart Signed-off-by: Kiryl Kaveryn --- iphone/Chart/Chart.xcodeproj/project.pbxproj | 8 + iphone/Chart/Chart/ChartData/ChartData.swift | 36 ++- .../Chart/ChartData/ChartPathBuilder.swift | 131 +++++++++++ .../ChartData/ChartPresentationData.swift | 211 ++---------------- .../ChartData/ChartPresentationLine.swift | 20 ++ .../Views/ChartInfo/ChartPointInfoView.swift | 30 ++- iphone/Chart/Chart/Views/ChartView.swift | 61 ++--- iphone/Chart/Chart/Views/ChartXAxisView.swift | 33 ++- iphone/Chart/Chart/Views/ChartYAxisView.swift | 2 - 9 files changed, 275 insertions(+), 257 deletions(-) create mode 100644 iphone/Chart/Chart/ChartData/ChartPathBuilder.swift create mode 100644 iphone/Chart/Chart/ChartData/ChartPresentationLine.swift diff --git a/iphone/Chart/Chart.xcodeproj/project.pbxproj b/iphone/Chart/Chart.xcodeproj/project.pbxproj index 1913b52e49..5a622e5284 100644 --- a/iphone/Chart/Chart.xcodeproj/project.pbxproj +++ b/iphone/Chart/Chart.xcodeproj/project.pbxproj @@ -20,6 +20,8 @@ 47375E522420E97100FFCC49 /* ChartYAxisView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47375E472420E96F00FFCC49 /* ChartYAxisView.swift */; }; 47375E542420E97100FFCC49 /* ChartPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47375E492420E97100FFCC49 /* ChartPreviewView.swift */; }; 47D48BD324302FE200FEFB1F /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 47D48BD524302FE200FEFB1F /* Localizable.strings */; }; + ED46DE1E2D09B8A6007CACD6 /* ChartPathBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED46DE1C2D09B8A6007CACD6 /* ChartPathBuilder.swift */; }; + ED46DE1F2D09B8A6007CACD6 /* ChartPresentationLine.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED46DE1D2D09B8A6007CACD6 /* ChartPresentationLine.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -66,6 +68,8 @@ 47D48BF12430333900FEFB1F /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; 47D48BF22430334B00FEFB1F /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; 47D48BF32430335F00FEFB1F /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; + ED46DE1C2D09B8A6007CACD6 /* ChartPathBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartPathBuilder.swift; sourceTree = ""; }; + ED46DE1D2D09B8A6007CACD6 /* ChartPresentationLine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartPresentationLine.swift; sourceTree = ""; }; FA4C8E53263B1FA80048FA99 /* common-release.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = "common-release.xcconfig"; path = "../../xcode/common-release.xcconfig"; sourceTree = ""; }; FA4C8E54263B1FA80048FA99 /* common-debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = "common-debug.xcconfig"; path = "../../xcode/common-debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -113,6 +117,8 @@ 47375DC22420D60200FFCC49 /* ChartData */ = { isa = PBXGroup; children = ( + ED46DE1C2D09B8A6007CACD6 /* ChartPathBuilder.swift */, + ED46DE1D2D09B8A6007CACD6 /* ChartPresentationLine.swift */, 47375E3B2420E94E00FFCC49 /* ChartData.swift */, 47375E392420E94C00FFCC49 /* ChartPresentationData.swift */, ); @@ -256,6 +262,8 @@ 47375E512420E97100FFCC49 /* ChartMyPositionView.swift in Sources */, 47375E3E2420E94E00FFCC49 /* ChartData.swift in Sources */, 47375E4C2420E97100FFCC49 /* ChartLineView.swift in Sources */, + ED46DE1E2D09B8A6007CACD6 /* ChartPathBuilder.swift in Sources */, + ED46DE1F2D09B8A6007CACD6 /* ChartPresentationLine.swift in Sources */, 47375E502420E97100FFCC49 /* ChartPointIntersectionView.swift in Sources */, 47375E522420E97100FFCC49 /* ChartYAxisView.swift in Sources */, 47375E4B2420E97100FFCC49 /* ChartXAxisView.swift in Sources */, diff --git a/iphone/Chart/Chart/ChartData/ChartData.swift b/iphone/Chart/Chart/ChartData/ChartData.swift index d810ca57e3..2750fa3612 100644 --- a/iphone/Chart/Chart/ChartData/ChartData.swift +++ b/iphone/Chart/Chart/ChartData/ChartData.swift @@ -10,20 +10,42 @@ public enum ChartLineType: String { case lineArea = "lineArea" } -public protocol IFormatter { - func distanceString(from value: Double) -> String - func altitudeString(from value: Double) -> String +public protocol ChartFormatter { + func xAxisString(from value: Double) -> String + func yAxisString(from value: Double) -> String + func yAxisLowerBound(from value: CGFloat) -> CGFloat + func yAxisUpperBound(from value: CGFloat) -> CGFloat + func yAxisSteps(lowerBound: CGFloat, upperBound: CGFloat) -> [CGFloat] } -public protocol IChartData { +public protocol ChartData { var xAxisValues: [Double] { get } - var lines: [IChartLine] { get } + var lines: [ChartLine] { get } var type: ChartType { get } } -public protocol IChartLine { - var values: [Int] { get } +public protocol ChartLine { + var values: [ChartValue] { get } var name: String { get } var color: UIColor { get } var type: ChartLineType { get } } + +public struct ChartValue { + let x: CGFloat + let y: CGFloat + + public init(xValues: CGFloat, y: CGFloat) { + self.x = xValues + self.y = y + } +} + +extension Array where Element == ChartValue { + var maxDistance: CGFloat { return map { $0.x }.max() ?? 0 } + + func altitude(at relativeDistance: CGFloat) -> CGFloat { + guard let distance = last?.x else { return 0 } + return first { $0.x >= distance * relativeDistance }?.y ?? 0 + } +} diff --git a/iphone/Chart/Chart/ChartData/ChartPathBuilder.swift b/iphone/Chart/Chart/ChartData/ChartPathBuilder.swift new file mode 100644 index 0000000000..82484e0a83 --- /dev/null +++ b/iphone/Chart/Chart/ChartData/ChartPathBuilder.swift @@ -0,0 +1,131 @@ +import UIKit + +protocol ChartPathBuilder { + func build(_ lines: [ChartPresentationLine]) + func makeLinePath(line: ChartPresentationLine) -> UIBezierPath + func makePercentLinePath(line: ChartPresentationLine, bottomLine: ChartPresentationLine?) -> UIBezierPath +} + +extension ChartPathBuilder { + func makeLinePreviewPath(line: ChartPresentationLine) -> UIBezierPath { + let path = UIBezierPath() + let values = line.values + let xScale = CGFloat(values.count) / values.maxDistance + for i in 0.. UIBezierPath { + let path = UIBezierPath() + let values = line.values + let xScale = CGFloat(values.count) / values.maxDistance + for i in 0.. UIBezierPath { + let path = UIBezierPath() + path.move(to: CGPoint(x: 0, y: 0)) + let aggregatedValues = line.aggregatedValues + let xScale = CGFloat(aggregatedValues.count) / aggregatedValues.maxDistance + for i in 0.. UIBezierPath { + let path = UIBezierPath() + path.move(to: CGPoint(x: 0, y: 0)) + let aggregatedValues = line.aggregatedValues + let xScale = CGFloat(aggregatedValues.count) / aggregatedValues.maxDistance + for i in 0.. String { - formatter.distanceString(from: xAxisValueAt(point)) + formatter.xAxisString(from: xAxisValueAt(point)) } func xAxisValueAt(_ point: CGFloat) -> Double { - let p1 = Int(floor(point)) - let p2 = Int(ceil(point)) - let v1 = chartData.xAxisValues[p1] - let v2 = chartData.xAxisValues[p2] + let distance = chartData.xAxisValues.last! + let p1 = floor(point) + let p2 = ceil(point) + let v1 = p1 / CGFloat(pointsCount) * distance + let v2 = p2 / CGFloat(pointsCount) * distance return v1 + (v2 - v1) * Double(point.truncatingRemainder(dividingBy: 1)) } @@ -40,7 +39,7 @@ public class ChartPresentationData { presentationLines[index] } - private func recalcBounds() { + private func recalculateBounds() { presentationLines.forEach { $0.aggregatedValues = [] } pathBuilder.build(presentationLines, type: type) @@ -55,177 +54,3 @@ public class ChartPresentationData { } } -class ChartPresentationLine { - private let chartLine: IChartLine - private let useFilter: Bool - - var aggregatedValues: [CGFloat] = [] - var minY: CGFloat = CGFloat(Int.max) - var maxY: CGFloat = CGFloat(Int.min) - var path = UIBezierPath() - var previewPath = UIBezierPath() - - var values: [CGFloat] - var originalValues: [Int] { return chartLine.values } - var color: UIColor { return chartLine.color } - var name: String { return chartLine.name } - var type: ChartLineType { return chartLine.type } - - init(_ chartLine: IChartLine, useFilter: Bool = false) { - self.chartLine = chartLine - self.useFilter = useFilter - if useFilter { - var filter = LowPassFilter(value: CGFloat(chartLine.values[0]), filterFactor: 0.8) - values = chartLine.values.map { - filter.update(newValue: CGFloat($0)) - return filter.value - } - } else { - values = chartLine.values.map { CGFloat($0) } - } - } -} - -protocol IChartPathBuilder { - func build(_ lines: [ChartPresentationLine]) - func makeLinePath(line: ChartPresentationLine) -> UIBezierPath - func makePercentLinePath(line: ChartPresentationLine, bottomLine: ChartPresentationLine?) -> UIBezierPath -} - -extension IChartPathBuilder { - func makeLinePreviewPath(line: ChartPresentationLine) -> UIBezierPath { - var filter = LowPassFilter(value: 0, filterFactor: 0.3) - let path = UIBezierPath() - let step = 5 - let values = line.values.enumerated().compactMap { $0 % step == 0 ? $1 : nil } - for i in 0.. UIBezierPath { - let path = UIBezierPath() - let values = line.values - for i in 0.. UIBezierPath { - let path = UIBezierPath() - path.move(to: CGPoint(x: 0, y: 0)) - var filter = LowPassFilter(value: 0, filterFactor: 0.3) - - let step = 5 - let aggregatedValues = line.aggregatedValues.enumerated().compactMap { $0 % step == 0 ? $1 : nil } - for i in 0.. UIBezierPath { - let path = UIBezierPath() - path.move(to: CGPoint(x: 0, y: 0)) - - let aggregatedValues = line.aggregatedValues - for i in 0.. String { + return String(isInterfaceRightToLeft ? "\(point.formattedValue) ▲" : "▲ \(point.formattedValue)") + } + override func layoutSubviews() { super.layoutSubviews() diff --git a/iphone/Chart/Chart/Views/ChartView.swift b/iphone/Chart/Chart/Views/ChartView.swift index 8802b42681..fc2ba88ca5 100644 --- a/iphone/Chart/Chart/Views/ChartView.swift +++ b/iphone/Chart/Chart/Views/ChartView.swift @@ -13,7 +13,9 @@ public class ChartView: UIView { let xAxisView = ChartXAxisView() let chartInfoView = ChartInfoView() var lineViews: [ChartLineView] = [] + var showPreview: Bool = false // Set true to show the preview + private var tapGR: UITapGestureRecognizer! private var panStartPoint = 0 private var panGR: UIPanGestureRecognizer! private var pinchStartLower = 0 @@ -112,7 +114,7 @@ public class ChartView: UIView { chartInfoView.textColor = textColor chartsContainerView.addSubview(chartInfoView) - xAxisView.values = chartData.labels + xAxisView.values = chartData.xAxisValues.enumerated().map { ChartXAxisView.Value(index: $0.offset, value: $0.element, text: chartData.labels[$0.offset]) } chartPreviewView.chartData = chartData xAxisView.setBounds(lower: chartPreviewView.minX, upper: chartPreviewView.maxX) updateCharts() @@ -144,12 +146,16 @@ public class ChartView: UIView { chartInfoView.tooltipBackgroundColor = backgroundColor ?? .white yAxisView.textBackgroundColor = infoBackgroundColor.withAlphaComponent(0.7) + tapGR = UITapGestureRecognizer(target: self, action: #selector(onTap(_:))) + chartsContainerView.addGestureRecognizer(tapGR) panGR = UIPanGestureRecognizer(target: self, action: #selector(onPan(_:))) chartsContainerView.addGestureRecognizer(panGR) pinchGR = UIPinchGestureRecognizer(target: self, action: #selector(onPinch(_:))) chartsContainerView.addGestureRecognizer(pinchGR) addSubview(chartsContainerView) - addSubview(chartPreviewView) + if showPreview { + addSubview(chartPreviewView) + } chartPreviewView.delegate = self addSubview(xAxisView) } @@ -188,7 +194,7 @@ public class ChartView: UIView { override public func layoutSubviews() { super.layoutSubviews() - let previewFrame = CGRect(x: bounds.minX, y: bounds.maxY - 30, width: bounds.width, height: 30) + let previewFrame = showPreview ? CGRect(x: bounds.minX, y: bounds.maxY - 30, width: bounds.width, height: 30) : .zero chartPreviewView.frame = previewFrame let xAxisFrame = CGRect(x: bounds.minX, y: bounds.maxY - previewFrame.height - 26, width: bounds.width, height: 26) @@ -206,6 +212,16 @@ public class ChartView: UIView { return rect.contains(point) } + @objc func onTap(_ sender: UITapGestureRecognizer) { + guard sender.state == .ended else { + return + } + let point = sender.location(in: chartInfoView) + updateCharts(animationStyle: .none) + chartInfoView.update(point.x) + chartInfoView(chartInfoView, didMoveToPoint: point.x) + } + @objc func onPinch(_ sender: UIPinchGestureRecognizer) { if sender.state == .began { pinchStartLower = xAxisView.lowerBound @@ -262,30 +278,23 @@ public class ChartView: UIView { let line = chartData.lineAt(i) let subrange = line.aggregatedValues[xAxisView.lowerBound...xAxisView.upperBound] subrange.forEach { - upper = max($0, upper) + upper = max($0.y, upper) if line.type == .line || line.type == .lineArea { - lower = min($0, lower) + lower = min($0.y, lower) } } } let padding = round((upper - lower) / 10) - lower = max(0, lower - padding) - upper = upper + padding - - let stepsCount = 3 - let step = ceil((upper - lower) / CGFloat(stepsCount)) - upper = lower + step * CGFloat(stepsCount) - var steps: [CGFloat] = [] - for i in 0...stepsCount { - steps.append(lower + step * CGFloat(i)) - } + lower = chartData.formatter.yAxisLowerBound(from: max(0, lower - padding)) + upper = chartData.formatter.yAxisUpperBound(from: upper + padding) + let steps = chartData.formatter.yAxisSteps(lowerBound: lower, upperBound: upper) if yAxisView.upperBound != upper || yAxisView.lowerBound != lower { yAxisView.setBounds(lower: lower, upper: upper, - lowerLabel: chartData.formatter.altitudeString(from: Double(lower)), - upperLabel: chartData.formatter.altitudeString(from: Double(upper)), + lowerLabel: chartData.formatter.yAxisString(from: Double(lower)), + upperLabel: chartData.formatter.yAxisString(from: Double(upper)), steps: steps, animationStyle: animationStyle) } @@ -325,30 +334,28 @@ extension ChartView: ChartInfoViewDelegate { func chartInfoView(_ view: ChartInfoView, infoAtPointX pointX: CGFloat) -> (String, [ChartLineInfo])? { let p = convert(CGPoint(x: pointX, y: 0), from: view) let x = (p.x / bounds.width) * CGFloat(xAxisView.upperBound - xAxisView.lowerBound) + CGFloat(xAxisView.lowerBound) - let x1 = Int(floor(x)) - let x2 = Int(ceil(x)) - guard x1 < chartData.labels.count && x >= 0 else { return nil } + let x1 = floor(x) + let x2 = ceil(x) + guard Int(x1) < chartData.labels.count && x >= 0 else { return nil } let label = chartData.labelAt(x) var result: [ChartLineInfo] = [] for i in 0.. UILabel { + private func makeLabel(text: String) -> UILabel { let label = UILabel() label.font = font label.textColor = textColor label.text = text - label.frame = CGRect(x: 0, y: 0, width: 50, height: 15) + label.frame = CGRect(x: 0, y: 0, width: 60, height: 15) return label } @@ -60,7 +60,7 @@ fileprivate class ChartXAxisInnerView: UIView { updateLabels() } - func updateLabels() { + private func updateLabels() { let step = CGFloat(upperBound - lowerBound) / CGFloat(labels.count - 1) for i in 0.. 0 ? x / bounds.width : 0 f.origin = CGPoint(x: x - f.width * adjust, y: 0) - l.frame = f.integral + l.frame = f } } } class ChartXAxisView: UIView { + + struct Value { + let index: Int + let value: Double + let text: String + } + var lowerBound = 0 var upperBound = 0 - - var values: [String] = [] + var values: [Value] = [] var font: UIFont = UIFont.systemFont(ofSize: 12, weight: .regular) { didSet { @@ -96,14 +102,17 @@ class ChartXAxisView: UIView { func setBounds(lower: Int, upper: Int) { lowerBound = lower upperBound = upper - let step = CGFloat(upper - lower) / 5 - var steps: [String] = [] + let begin = values[lower].value + let end = values[upper].value + let step = CGFloat(end - begin) / 5 + var labels: [String] = [] for i in 0..<5 { - let x = lower + Int(round(step * CGFloat(i))) - steps.append(values[x]) + if let x = values.first(where: { $0.value >= (begin + step * CGFloat(i)) }) { + labels.append(x.text) + } } - steps.append(values[upper]) + labels.append(values[upper].text) let lv = ChartXAxisInnerView() lv.frame = bounds @@ -115,7 +124,7 @@ class ChartXAxisView: UIView { labelsView.removeFromSuperview() } - lv.setBounds(lower: lower, upper: upper, steps: steps) + lv.setBounds(lower: lower, upper: upper, steps: labels) labelsView = lv } } diff --git a/iphone/Chart/Chart/Views/ChartYAxisView.swift b/iphone/Chart/Chart/Views/ChartYAxisView.swift index 579078035f..473b9b8794 100644 --- a/iphone/Chart/Chart/Views/ChartYAxisView.swift +++ b/iphone/Chart/Chart/Views/ChartYAxisView.swift @@ -61,8 +61,6 @@ fileprivate class ChartYAxisInnerView: UIView { upperLabel.translatesAutoresizingMaskIntoConstraints = false lowerLabelBackground.translatesAutoresizingMaskIntoConstraints = false upperLabelBackground.translatesAutoresizingMaskIntoConstraints = false - lowerLabelBackground.backgroundColor = UIColor(white: 1, alpha: 0.8) - upperLabelBackground.backgroundColor = UIColor(white: 1, alpha: 0.8) lowerLabelBackground.addSubview(lowerLabel) upperLabelBackground.addSubview(upperLabel)