diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 00000000..60e30109 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,64 @@ +disabled_rules: +- explicit_acl +- trailing_whitespace +- force_cast +- unused_closure_parameter +- multiple_closures_with_trailing_closure +opt_in_rules: +- anyobject_protocol +- array_init +- attributes +- collection_alignment +- colon +- conditional_returns_on_newline +- convenience_type +- empty_count +- empty_string +- empty_collection_literal +- enum_case_associated_values_count +- function_default_parameter_at_end +- fatal_error_message +- file_name +- first_where +- modifier_order +- toggle_bool +- unused_private_declaration +- yoda_condition +excluded: +- Carthage +- Pods +- SwiftLint/Common/3rdPartyLib +identifier_name: + excluded: + - a + - b + - c + - i + - id + - t + - to + - x + - y +line_length: + warning: 150 + error: 200 + ignores_function_declarations: true + ignores_comments: true + ignores_urls: true +function_body_length: + warning: 300 + error: 500 +function_parameter_count: + warning: 6 + error: 8 +type_body_length: + warning: 300 + error: 400 +file_length: + warning: 500 + error: 1200 + ignore_comment_only_lines: true +cyclomatic_complexity: + warning: 15 + error: 21 +reporter: "xcode" diff --git a/.swiftpm/xcode/package.xcworkspace/xcuserdata/samuandris.xcuserdatad/UserInterfaceState.xcuserstate b/.swiftpm/xcode/package.xcworkspace/xcuserdata/samuandris.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 00000000..a5f64f0a Binary files /dev/null and b/.swiftpm/xcode/package.xcworkspace/xcuserdata/samuandris.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/.swiftpm/xcode/xcuserdata/samuandras.xcuserdatad/xcschemes/xcschememanagement.plist b/.swiftpm/xcode/xcuserdata/samuandras.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 00000000..a0f26bbb --- /dev/null +++ b/.swiftpm/xcode/xcuserdata/samuandras.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + SwiftUICharts.xcscheme_^#shared#^_ + + orderHint + 2 + + + + diff --git a/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 00000000..867e4fe8 --- /dev/null +++ b/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,6 @@ + + + diff --git a/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcschemes/xcschememanagement.plist b/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcschemes/xcschememanagement.plist index 540c36e2..1be8e537 100644 --- a/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/.swiftpm/xcode/xcuserdata/samuandris.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,20 @@ SwiftUICharts.xcscheme_^#shared#^_ orderHint - 1 + 0 + + + SuppressBuildableAutocreation + + SwiftUICharts + + primary + + + SwiftUIChartsTests + + primary + diff --git a/Package.swift b/Package.swift index ffd10e06..3339018c 100644 --- a/Package.swift +++ b/Package.swift @@ -6,13 +6,13 @@ import PackageDescription let package = Package( name: "SwiftUICharts", platforms: [ - .iOS(.v13),.watchOS(.v6) + .iOS(.v13), .watchOS(.v6), .macOS(.v10_15) ], products: [ // Products define the executables and libraries produced by a package, and make them visible to other packages. .library( name: "SwiftUICharts", - targets: ["SwiftUICharts"]), + targets: ["SwiftUICharts"]) ], dependencies: [ // Dependencies declare other packages that this package depends on. @@ -26,6 +26,6 @@ let package = Package( dependencies: []), .testTarget( name: "SwiftUIChartsTests", - dependencies: ["SwiftUICharts"]), + dependencies: ["SwiftUICharts"]) ] ) diff --git a/Sources/SwiftUICharts/BarChart/BarChartCell.swift b/Sources/SwiftUICharts/BarChart/BarChartCell.swift deleted file mode 100644 index a3500b7f..00000000 --- a/Sources/SwiftUICharts/BarChart/BarChartCell.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// ChartCell.swift -// ChartView -// -// Created by András Samu on 2019. 06. 12.. -// Copyright © 2019. András Samu. All rights reserved. -// - -import SwiftUI - -public struct BarChartCell : View { - var value: Double - var index: Int = 0 - var width: Float - var numberOfDataPoints: Int - var cellWidth: Double { - return Double(width)/(Double(numberOfDataPoints) * 1.5) - } - var accentColor: Color - var gradient: GradientColor? - - @State var scaleValue: Double = 0 - @Binding var touchLocation: CGFloat - public var body: some View { - ZStack { - RoundedRectangle(cornerRadius: 4) - .fill(LinearGradient(gradient: gradient?.getGradient() ?? GradientColor(start: accentColor, end: accentColor).getGradient(), startPoint: .bottom, endPoint: .top)) - } - .frame(width: CGFloat(self.cellWidth)) - .scaleEffect(CGSize(width: 1, height: self.scaleValue), anchor: .bottom) - .onAppear(){ - self.scaleValue = self.value - } - .animation(Animation.spring().delay(self.touchLocation < 0 ? Double(self.index) * 0.04 : 0)) - } -} - -#if DEBUG -struct ChartCell_Previews : PreviewProvider { - static var previews: some View { - BarChartCell(value: Double(0.75), width: 320, numberOfDataPoints: 12, accentColor: Colors.OrangeStart, gradient: nil, touchLocation: .constant(-1)) - } -} -#endif diff --git a/Sources/SwiftUICharts/BarChart/BarChartRow.swift b/Sources/SwiftUICharts/BarChart/BarChartRow.swift deleted file mode 100644 index 59b6a6d5..00000000 --- a/Sources/SwiftUICharts/BarChart/BarChartRow.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// ChartRow.swift -// ChartView -// -// Created by András Samu on 2019. 06. 12.. -// Copyright © 2019. András Samu. All rights reserved. -// - -import SwiftUI - -public struct BarChartRow : View { - var data: [Double] - var accentColor: Color - var gradient: GradientColor? - var maxValue: Double { - data.max() ?? 0 - } - @Binding var touchLocation: CGFloat - public var body: some View { - GeometryReader { geometry in - HStack(alignment: .bottom, spacing: (geometry.frame(in: .local).width-22)/CGFloat(self.data.count * 3)){ - ForEach(0.. CGFloat(i)/CGFloat(self.data.count) && self.touchLocation < CGFloat(i+1)/CGFloat(self.data.count) ? CGSize(width: 1.4, height: 1.1) : CGSize(width: 1, height: 1), anchor: .bottom) - .animation(.spring()) - - } - } - .padding([.top, .leading, .trailing], 10) - } - } - - func normalizedValue(index: Int) -> Double { - return Double(self.data[index])/Double(self.maxValue) - } -} - -#if DEBUG -struct ChartRow_Previews : PreviewProvider { - static var previews: some View { - BarChartRow(data: [8,23,54,32,12,37,7], accentColor: Colors.OrangeStart, touchLocation: .constant(-1)) - } -} -#endif diff --git a/Sources/SwiftUICharts/BarChart/BarChartView.swift b/Sources/SwiftUICharts/BarChart/BarChartView.swift deleted file mode 100644 index 541d8b5f..00000000 --- a/Sources/SwiftUICharts/BarChart/BarChartView.swift +++ /dev/null @@ -1,148 +0,0 @@ -// -// ChartView.swift -// ChartView -// -// Created by András Samu on 2019. 06. 12.. -// Copyright © 2019. András Samu. All rights reserved. -// - -import SwiftUI - -public struct BarChartView : View { - @Environment(\.colorScheme) var colorScheme: ColorScheme - private var data: ChartData - public var title: String - public var legend: String? - public var style: ChartStyle - public var darkModeStyle: ChartStyle - public var formSize:CGSize - public var dropShadow: Bool - public var cornerImage: Image - public var valueSpecifier:String - - @State private var touchLocation: CGFloat = -1.0 - @State private var showValue: Bool = false - @State private var showLabelValue: Bool = false - @State private var currentValue: Double = 0 { - didSet{ - if(oldValue != self.currentValue && self.showValue) { - HapticFeedback.playSelection() - } - } - } - var isFullWidth:Bool { - return self.formSize == ChartForm.large - } - public init(data:ChartData, title: String, legend: String? = nil, style: ChartStyle = Styles.barChartStyleOrangeLight, form: CGSize? = ChartForm.medium, dropShadow: Bool? = true, cornerImage:Image? = Image(systemName: "waveform.path.ecg"), valueSpecifier: String? = "%.1f"){ - self.data = data - self.title = title - self.legend = legend - self.style = style - self.darkModeStyle = style.darkModeStyle != nil ? style.darkModeStyle! : Styles.barChartStyleOrangeDark - self.formSize = form! - self.dropShadow = dropShadow! - self.cornerImage = cornerImage! - self.valueSpecifier = valueSpecifier! - } - - public var body: some View { - ZStack{ - Rectangle() - .fill(self.colorScheme == .dark ? self.darkModeStyle.backgroundColor : self.style.backgroundColor) - .cornerRadius(20) - .shadow(color: self.style.dropShadowColor, radius: self.dropShadow ? 8 : 0) - VStack(alignment: .leading){ - HStack{ - if(!showValue){ - Text(self.title) - .font(.headline) - .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.textColor : self.style.textColor) - }else{ - Text("\(self.currentValue, specifier: self.valueSpecifier)") - .font(.headline) - .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.textColor : self.style.textColor) - } - if(self.formSize == ChartForm.large && self.legend != nil && !showValue) { - Text(self.legend!) - .font(.callout) - .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.accentColor : self.style.accentColor) - .transition(.opacity) - .animation(.easeOut) - } - Spacer() - self.cornerImage - .imageScale(.large) - .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.legendTextColor : self.style.legendTextColor) - }.padding() - BarChartRow(data: data.points.map{$0.1}, - accentColor: self.colorScheme == .dark ? self.darkModeStyle.accentColor : self.style.accentColor, - gradient: self.colorScheme == .dark ? self.darkModeStyle.gradientColor : self.style.gradientColor, - touchLocation: self.$touchLocation) - if self.legend != nil && self.formSize == ChartForm.medium && !self.showLabelValue{ - Text(self.legend!) - .font(.headline) - .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.legendTextColor : self.style.legendTextColor) - .padding() - }else if (self.data.valuesGiven && self.getCurrentValue() != nil) { - LabelView(arrowOffset: self.getArrowOffset(touchLocation: self.touchLocation), - title: .constant(self.getCurrentValue()!.0)) - .offset(x: self.getLabelViewOffset(touchLocation: self.touchLocation), y: -6) - .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.legendTextColor : self.style.legendTextColor) - } - - } - }.frame(minWidth:self.formSize.width, - maxWidth: self.isFullWidth ? .infinity : self.formSize.width, - minHeight:self.formSize.height, - maxHeight:self.formSize.height) - .gesture(DragGesture() - .onChanged({ value in - self.touchLocation = value.location.x/self.formSize.width - self.showValue = true - self.currentValue = self.getCurrentValue()?.1 ?? 0 - if(self.data.valuesGiven && self.formSize == ChartForm.medium) { - self.showLabelValue = true - } - }) - .onEnded({ value in - self.showValue = false - self.showLabelValue = false - self.touchLocation = -1 - }) - ) - .gesture(TapGesture() - ) - } - - func getArrowOffset(touchLocation:CGFloat) -> Binding { - let realLoc = (self.touchLocation * self.formSize.width) - 50 - if realLoc < 10 { - return .constant(realLoc - 10) - }else if realLoc > self.formSize.width-110 { - return .constant((self.formSize.width-110 - realLoc) * -1) - } else { - return .constant(0) - } - } - - func getLabelViewOffset(touchLocation:CGFloat) -> CGFloat { - return min(self.formSize.width-110,max(10,(self.touchLocation * self.formSize.width) - 50)) - } - - func getCurrentValue() -> (String,Double)? { - guard self.data.points.count > 0 else { return nil} - let index = max(0,min(self.data.points.count-1,Int(floor((self.touchLocation*self.formSize.width)/(self.formSize.width/CGFloat(self.data.points.count)))))) - return self.data.points[index] - } -} - -#if DEBUG -struct ChartView_Previews : PreviewProvider { - static var previews: some View { - BarChartView(data: TestData.values , - title: "Model 3 sales", - legend: "Quarterly", - valueSpecifier: "%.0f") - } -} -#endif diff --git a/Sources/SwiftUICharts/BarChart/LabelView.swift b/Sources/SwiftUICharts/BarChart/LabelView.swift deleted file mode 100644 index f17ae7be..00000000 --- a/Sources/SwiftUICharts/BarChart/LabelView.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// LabelView.swift -// BarChart -// -// Created by Samu András on 2020. 01. 08.. -// Copyright © 2020. Samu András. All rights reserved. -// - -import SwiftUI - -struct LabelView: View { - @Binding var arrowOffset: CGFloat - @Binding var title:String - var body: some View { - VStack{ - ArrowUp().fill(Color.white).frame(width: 20, height: 12, alignment: .center).shadow(color: Color.gray, radius: 8, x: 0, y: 0).offset(x: getArrowOffset(offset:self.arrowOffset), y: 12) - ZStack{ - RoundedRectangle(cornerRadius: 8).frame(width: 100, height: 32, alignment: .center).foregroundColor(Color.white).shadow(radius: 8) - Text(self.title).font(.caption).bold() - ArrowUp().fill(Color.white).frame(width: 20, height: 12, alignment: .center).zIndex(999).offset(x: getArrowOffset(offset:self.arrowOffset), y: -20) - - } - } - } - - func getArrowOffset(offset: CGFloat) -> CGFloat { - return max(-36,min(36, offset)) - } -} - -struct ArrowUp: Shape { - func path(in rect: CGRect) -> Path { - var path = Path() - path.move(to: CGPoint(x: 0, y: rect.height)) - path.addLine(to: CGPoint(x: rect.width/2, y: 0)) - path.addLine(to: CGPoint(x: rect.width, y: rect.height)) - path.closeSubpath() - return path - } -} - -struct LabelView_Previews: PreviewProvider { - static var previews: some View { - LabelView(arrowOffset: .constant(0), title: .constant("Tesla model 3")) - } -} diff --git a/Sources/SwiftUICharts/Base/Axis/AxisLabels.swift b/Sources/SwiftUICharts/Base/Axis/AxisLabels.swift new file mode 100644 index 00000000..0efaa897 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Axis/AxisLabels.swift @@ -0,0 +1,98 @@ +import SwiftUI + +public struct AxisLabels: View { + struct YAxisViewKey: ViewPreferenceKey { } + struct ChartViewKey: ViewPreferenceKey { } + + var axisLabelsData = AxisLabelsData() + var axisLabelsStyle = AxisLabelsStyle() + + @State private var yAxisWidth: CGFloat = 25 + @State private var chartWidth: CGFloat = 0 + @State private var chartHeight: CGFloat = 0 + + let content: () -> Content + + public init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + + var yAxis: some View { + VStack(spacing: 0.0) { + ForEach(Array(axisLabelsData.axisYLabels.reversed().enumerated()), id: \.element) { index, axisYData in + Text(axisYData) + .font(axisLabelsStyle.axisFont) + .foregroundColor(axisLabelsStyle.axisFontColor) + .frame(height: getYHeight(index: index, + chartHeight: chartHeight, + count: axisLabelsData.axisYLabels.count), + alignment: getYAlignment(index: index, count: axisLabelsData.axisYLabels.count)) + } + } + .padding([.leading, .trailing], 4.0) + .background(ViewGeometry()) + .onPreferenceChange(YAxisViewKey.self) { value in + yAxisWidth = value.first?.size.width ?? 0.0 + } + } + + func xAxis(chartWidth: CGFloat) -> some View { + HStack(spacing: 0.0) { + ForEach(Array(axisLabelsData.axisXLabels.enumerated()), id: \.element) { index, axisXData in + Text(axisXData) + .font(axisLabelsStyle.axisFont) + .foregroundColor(axisLabelsStyle.axisFontColor) + .frame(width: chartWidth / CGFloat(axisLabelsData.axisXLabels.count - 1)) + } + } + .frame(height: 24.0, alignment: .top) + } + + var chart: some View { + self.content() + .background(ViewGeometry()) + .onPreferenceChange(ChartViewKey.self) { value in + chartWidth = value.first?.size.width ?? 0.0 + chartHeight = value.first?.size.height ?? 0.0 + } + } + + public var body: some View { + VStack(spacing: 0.0) { + HStack { + if axisLabelsStyle.axisLabelsYPosition == .leading { + yAxis + } else { + Spacer(minLength: yAxisWidth) + } + chart + if axisLabelsStyle.axisLabelsYPosition == .leading { + Spacer(minLength: yAxisWidth) + } else { + yAxis + } + } + xAxis(chartWidth: chartWidth) + } + } + + private func getYHeight(index: Int, chartHeight: CGFloat, count: Int) -> CGFloat { + if index == 0 || index == count - 1 { + return chartHeight / (CGFloat(count - 1) * 2) + 10 + } + + return chartHeight / CGFloat(count - 1) + } + + private func getYAlignment(index: Int, count: Int) -> Alignment { + if index == 0 { + return .top + } + + if index == count - 1 { + return .bottom + } + + return .center + } +} diff --git a/Sources/SwiftUICharts/Base/Axis/Extension/AxisLabels+Extension.swift b/Sources/SwiftUICharts/Base/Axis/Extension/AxisLabels+Extension.swift new file mode 100644 index 00000000..7698ac4e --- /dev/null +++ b/Sources/SwiftUICharts/Base/Axis/Extension/AxisLabels+Extension.swift @@ -0,0 +1,57 @@ +import SwiftUI + +extension AxisLabels { + public func setAxisYLabels(_ labels: [String], + position: AxisLabelsYPosition = .leading) -> AxisLabels { + self.axisLabelsData.axisYLabels = labels + self.axisLabelsStyle.axisLabelsYPosition = position + return self + } + + public func setAxisXLabels(_ labels: [String]) -> AxisLabels { + self.axisLabelsData.axisXLabels = labels + return self + } + + public func setAxisYLabels(_ labels: [(Double, String)], + range: ClosedRange, + position: AxisLabelsYPosition = .leading) -> AxisLabels { + let overreach = range.overreach + 1 + var labelArray = [String](repeating: "", count: overreach) + labels.forEach { + let index = Int($0.0) - range.lowerBound + if labelArray[safe: index] != nil { + labelArray[index] = $0.1 + } + } + + self.axisLabelsData.axisYLabels = labelArray + self.axisLabelsStyle.axisLabelsYPosition = position + + return self + } + + public func setAxisXLabels(_ labels: [(Double, String)], range: ClosedRange) -> AxisLabels { + let overreach = range.overreach + 1 + var labelArray = [String](repeating: "", count: overreach) + labels.forEach { + let index = Int($0.0) - range.lowerBound + if labelArray[safe: index] != nil { + labelArray[index] = $0.1 + } + } + + self.axisLabelsData.axisXLabels = labelArray + return self + } + + public func setColor(_ color: Color) -> AxisLabels { + self.axisLabelsStyle.axisFontColor = color + return self + } + + public func setFont(_ font: Font) -> AxisLabels { + self.axisLabelsStyle.axisFont = font + return self + } +} diff --git a/Sources/SwiftUICharts/Base/Axis/Model/AxisLabelsPosition.swift b/Sources/SwiftUICharts/Base/Axis/Model/AxisLabelsPosition.swift new file mode 100644 index 00000000..66735d7b --- /dev/null +++ b/Sources/SwiftUICharts/Base/Axis/Model/AxisLabelsPosition.swift @@ -0,0 +1,11 @@ +import Foundation + +public enum AxisLabelsYPosition { + case leading + case trailing +} + +public enum AxisLabelsXPosition { + case top + case bottom +} diff --git a/Sources/SwiftUICharts/Base/Axis/Model/AxisLabelsStyle.swift b/Sources/SwiftUICharts/Base/Axis/Model/AxisLabelsStyle.swift new file mode 100644 index 00000000..58221426 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Axis/Model/AxisLabelsStyle.swift @@ -0,0 +1,11 @@ +import SwiftUI + +public final class AxisLabelsStyle: ObservableObject { + @Published public var axisFont: Font = .callout + @Published public var axisFontColor: Color = .primary + @Published var axisLabelsYPosition: AxisLabelsYPosition = .leading + @Published var axisLabelsXPosition: AxisLabelsXPosition = .bottom + public init() { + // no-op + } +} diff --git a/Sources/SwiftUICharts/Base/Axis/Model/AxisLablesData.swift b/Sources/SwiftUICharts/Base/Axis/Model/AxisLablesData.swift new file mode 100644 index 00000000..f28f35ac --- /dev/null +++ b/Sources/SwiftUICharts/Base/Axis/Model/AxisLablesData.swift @@ -0,0 +1,10 @@ +import SwiftUI + +public final class AxisLabelsData: ObservableObject { + @Published public var axisYLabels: [String] = [] + @Published public var axisXLabels: [String] = [] + + public init() { + // no-op + } +} diff --git a/Sources/SwiftUICharts/Base/CardView/CardView.swift b/Sources/SwiftUICharts/Base/CardView/CardView.swift new file mode 100644 index 00000000..e5d7eb3a --- /dev/null +++ b/Sources/SwiftUICharts/Base/CardView/CardView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +/// View containing data and some kind of chart content +public struct CardView: View, ChartBase { + public var chartData = ChartData() + let content: () -> Content + + private var showShadow: Bool + + @EnvironmentObject var style: ChartStyle + + /// Initialize with view options and a nested `ViewBuilder` + /// - Parameters: + /// - showShadow: should card have a rounded-rectangle shadow around it + /// - content: <#content description#> + public init(showShadow: Bool = true, @ViewBuilder content: @escaping () -> Content) { + self.showShadow = showShadow + self.content = content + } + + /// The content and behavior of the `CardView`. + /// + /// + public var body: some View { + ZStack{ + if showShadow { + RoundedRectangle(cornerRadius: 20) + .fill(Color.white) + .shadow(color: Color(white: 0.9, opacity: 1), radius: 8) + } + VStack (alignment: .leading) { + self.content() + } + .clipShape(RoundedRectangle(cornerRadius: showShadow ? 20 : 0)) + } + } +} diff --git a/Sources/SwiftUICharts/Base/Chart/ChartBase.swift b/Sources/SwiftUICharts/Base/Chart/ChartBase.swift new file mode 100644 index 00000000..f1876dcb --- /dev/null +++ b/Sources/SwiftUICharts/Base/Chart/ChartBase.swift @@ -0,0 +1,6 @@ +import SwiftUI + +/// Protocol for any type of chart, to get access to underlying data +public protocol ChartBase: View { + var chartData: ChartData { get } +} diff --git a/Sources/SwiftUICharts/Base/Chart/ChartData.swift b/Sources/SwiftUICharts/Base/Chart/ChartData.swift new file mode 100644 index 00000000..f1eeec36 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Chart/ChartData.swift @@ -0,0 +1,74 @@ +import SwiftUI + +/// An observable wrapper for an array of data for use in any chart +public class ChartData: ObservableObject { + @Published public var data: [(Double, Double)] = [] + public var rangeY: ClosedRange? + public var rangeX: ClosedRange? + + var points: [Double] { + data.filter { rangeX?.contains($0.0) ?? true }.map { $0.1 } + } + + var values: [Double] { + data.filter { rangeX?.contains($0.0) ?? true }.map { $0.0 } + } + + var normalisedPoints: [Double] { + let absolutePoints = points.map { abs($0) } + var maxPoint = absolutePoints.max() + if let rangeY = rangeY { + maxPoint = Double(rangeY.overreach) + return points.map { ($0 - rangeY.lowerBound) / (maxPoint ?? 1.0) } + } + + return points.map { $0 / (maxPoint ?? 1.0) } + } + + var normalisedValues: [Double] { + let absoluteValues = values.map { abs($0) } + var maxValue = absoluteValues.max() + if let rangeX = rangeX { + maxValue = Double(rangeX.overreach) + return values.map { ($0 - rangeX.lowerBound) / (maxValue ?? 1.0) } + } + + return values.map { $0 / (maxValue ?? 1.0) } + } + + var normalisedData: [(Double, Double)] { + Array(zip(normalisedValues, normalisedPoints)) + } + + var normalisedYRange: Double { + return rangeY == nil ? (normalisedPoints.max() ?? 0.0) - (normalisedPoints.min() ?? 0.0) : 1 + } + + var normalisedXRange: Double { + return rangeX == nil ? (normalisedValues.max() ?? 0.0) - (normalisedValues.min() ?? 0.0) : 1 + } + + var isInNegativeDomain: Bool { + if let rangeY = rangeY { + return rangeY.lowerBound < 0 + } + + return (points.min() ?? 0.0) < 0 + } + + /// Initialize with data array + /// - Parameter data: Array of `Double` + public init(_ data: [Double], rangeY: ClosedRange? = nil) { + self.data = data.enumerated().map{ (index, value) in (Double(index), value) } + self.rangeY = rangeY + } + + public init(_ data: [(Double, Double)], rangeY: ClosedRange? = nil) { + self.data = data + self.rangeY = rangeY + } + + public init() { + self.data = [] + } +} diff --git a/Sources/SwiftUICharts/Base/Chart/ChartValue.swift b/Sources/SwiftUICharts/Base/Chart/ChartValue.swift new file mode 100644 index 00000000..48ff0c99 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Chart/ChartValue.swift @@ -0,0 +1,7 @@ +import SwiftUI + +/// Representation of a single data point in a chart that is being observed +public class ChartValue: ObservableObject { + @Published var currentValue: Double = 0 + @Published var interactionInProgress: Bool = false +} diff --git a/Sources/SwiftUICharts/Base/Common/ViewGeometry.swift b/Sources/SwiftUICharts/Base/Common/ViewGeometry.swift new file mode 100644 index 00000000..ea8357f1 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Common/ViewGeometry.swift @@ -0,0 +1,10 @@ +import SwiftUI + +public struct ViewGeometry: View where T: PreferenceKey { + public var body: some View { + GeometryReader { geometry in + Color.clear + .preference(key: T.self, value: [ViewSizeData(size: geometry.size)] as! T.Value) + } + } +} diff --git a/Sources/SwiftUICharts/Base/Common/ViewPreferenceKey.swift b/Sources/SwiftUICharts/Base/Common/ViewPreferenceKey.swift new file mode 100644 index 00000000..d3c4c1f1 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Common/ViewPreferenceKey.swift @@ -0,0 +1,15 @@ +import SwiftUI + +public protocol ViewPreferenceKey: PreferenceKey { + typealias Value = [ViewSizeData] +} + +public extension ViewPreferenceKey { + static var defaultValue: [ViewSizeData] { + [] + } + + static func reduce(value: inout [ViewSizeData], nextValue: () -> [ViewSizeData]) { + value.append(contentsOf: nextValue()) + } +} diff --git a/Sources/SwiftUICharts/Base/Common/ViewSizeData.swift b/Sources/SwiftUICharts/Base/Common/ViewSizeData.swift new file mode 100644 index 00000000..9a53cec3 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Common/ViewSizeData.swift @@ -0,0 +1,14 @@ +import SwiftUI + +public struct ViewSizeData: Identifiable, Equatable, Hashable { + public let id: UUID = UUID() + public let size: CGSize + + public static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/Sources/SwiftUICharts/Base/Extensions/Array+Extension.swift b/Sources/SwiftUICharts/Base/Extensions/Array+Extension.swift new file mode 100644 index 00000000..1e4bedd7 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Extensions/Array+Extension.swift @@ -0,0 +1,26 @@ +import Foundation + +extension Array where Element == ColorGradient { + + /// <#Description#> + /// - Parameter index: offset in data table + /// - Returns: <#description#> + func rotate(for index: Int) -> ColorGradient { + if self.isEmpty { + return ColorGradient.orangeBright + } + + if self.count <= index { + return self[index % self.count] + } + + return self[index] + } +} + +extension Collection { + /// Returns the element at the specified index if it is within bounds, otherwise nil. + subscript (safe index: Index) -> Element? { + return indices.contains(index) ? self[index] : nil + } +} diff --git a/Sources/SwiftUICharts/Base/Extensions/CGPoint+Extension.swift b/Sources/SwiftUICharts/Base/Extensions/CGPoint+Extension.swift new file mode 100644 index 00000000..31c401d9 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Extensions/CGPoint+Extension.swift @@ -0,0 +1,47 @@ +import SwiftUI + +extension CGPoint { + + /// Calculate X and Y delta for each data point, based on data min/max and enclosing frame. + /// - Parameters: + /// - frame: Rectangle of enclosing frame + /// - data: array of `Double` + /// - Returns: X and Y delta as a `CGPoint` + static func getStep(frame: CGRect, data: [Double]) -> CGPoint { + let padding: CGFloat = 0 + + // stepWidth + var stepWidth: CGFloat = 0.0 + if data.count < 2 { + stepWidth = 0.0 + } + stepWidth = frame.size.width / CGFloat(data.count - 1) + + // stepHeight + var stepHeight: CGFloat = 0.0 + + var min: Double? + var max: Double? + if let minPoint = data.min(), let maxPoint = data.max(), minPoint != maxPoint { + min = minPoint + max = maxPoint + } else { + return .zero + } + if let min = min, let max = max, min != max { + if min <= 0 { + stepHeight = (frame.size.height - padding) / CGFloat(max - min) + } else { + stepHeight = (frame.size.height - padding) / CGFloat(max + min) + } + } + + return CGPoint(x: stepWidth, y: stepHeight) + } + + func denormalize(with geometry: GeometryProxy) -> CGPoint { + let width = geometry.frame(in: .local).width + let height = geometry.frame(in: .local).height + return CGPoint(x: self.x * width, y: self.y * height) + } +} diff --git a/Sources/SwiftUICharts/Base/Extensions/CGRect+Extension.swift b/Sources/SwiftUICharts/Base/Extensions/CGRect+Extension.swift new file mode 100644 index 00000000..e66ee562 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Extensions/CGRect+Extension.swift @@ -0,0 +1,11 @@ +import Foundation +import SwiftUI + +extension CGRect { + + /// Midpoint of rectangle + /// - Returns: the coordinate for a rectangle center + public var mid: CGPoint { + return CGPoint(x: self.midX, y: self.midY) + } +} diff --git a/Sources/SwiftUICharts/Base/Extensions/ChartBase+Extension.swift b/Sources/SwiftUICharts/Base/Extensions/ChartBase+Extension.swift new file mode 100644 index 00000000..96ec022a --- /dev/null +++ b/Sources/SwiftUICharts/Base/Extensions/ChartBase+Extension.swift @@ -0,0 +1,23 @@ +import SwiftUI + +extension ChartBase { + public func data(_ data: [Double]) -> some ChartBase { + chartData.data = data.enumerated().map{ (index, value) in (Double(index), value) } + return self + } + + public func data(_ data: [(Double, Double)]) -> some ChartBase { + chartData.data = data + return self + } + + public func rangeY(_ range: ClosedRange) -> some ChartBase{ + chartData.rangeY = range + return self + } + + public func rangeX(_ range: ClosedRange) -> some ChartBase{ + chartData.rangeX = range + return self + } +} diff --git a/Sources/SwiftUICharts/Base/Extensions/Color+Extension.swift b/Sources/SwiftUICharts/Base/Extensions/Color+Extension.swift new file mode 100644 index 00000000..6f3bd22f --- /dev/null +++ b/Sources/SwiftUICharts/Base/Extensions/Color+Extension.swift @@ -0,0 +1,25 @@ +import SwiftUI + +extension Color { + /// Create a `Color` from a hexadecimal representation + /// - Parameter hexString: 3, 6, or 8-character string, with optional (ignored) punctuation such as "#" + init(hexString: String) { + let hex = hexString.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int = UInt64() + Scanner(string: hex).scanHexInt64(&int) + let red, green, blue: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (red, green, blue) = ((int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (red, green, blue) = (int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + // FIXME: I think we need an an alpha value on this one. See link below. + // https://stackoverflow.com/a/56874327/4475605 + (red, green, blue) = (int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (red, green, blue) = (0, 0, 0) + } + self.init(red: Double(red) / 255, green: Double(green) / 255, blue: Double(blue) / 255) + } +} diff --git a/Sources/SwiftUICharts/LineChart/Path+QuadCurve.swift b/Sources/SwiftUICharts/Base/Extensions/Path+QuadCurve.swift similarity index 54% rename from Sources/SwiftUICharts/LineChart/Path+QuadCurve.swift rename to Sources/SwiftUICharts/Base/Extensions/Path+QuadCurve.swift index 83cf114b..8085fd0c 100644 --- a/Sources/SwiftUICharts/LineChart/Path+QuadCurve.swift +++ b/Sources/SwiftUICharts/Base/Extensions/Path+QuadCurve.swift @@ -1,37 +1,30 @@ -// -// File.swift -// -// -// Created by xspyhack on 2020/1/21. -// - import SwiftUI extension Path { func trimmedPath(for percent: CGFloat) -> Path { - // percent difference between points let boundsDistance: CGFloat = 0.001 let completion: CGFloat = 1 - boundsDistance let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent) - + + // Start/end points centered around given percentage, but capped if right at the very end let start = pct > completion ? completion : pct - boundsDistance let end = pct > completion ? 1 : pct + boundsDistance return trimmedPath(from: start, to: end) } - + func point(for percent: CGFloat) -> CGPoint { let path = trimmedPath(for: percent) return CGPoint(x: path.boundingRect.midX, y: path.boundingRect.midY) } - + func point(to maxX: CGFloat) -> CGPoint { let total = length let sub = length(to: maxX) let percent = sub / total return point(for: percent) } - + var length: CGFloat { var ret: CGFloat = 0.0 var start: CGPoint? @@ -63,7 +56,7 @@ extension Path { } return ret } - + func length(to maxX: CGFloat) -> CGFloat { var ret: CGFloat = 0.0 var start: CGPoint? @@ -114,78 +107,153 @@ extension Path { } return ret } - - static func quadCurvedPathWithPoints(points:[Double], step:CGPoint, globalOffset: Double? = nil) -> Path { + + static func quadCurvedPathWithPoints(points: [Double], step: CGPoint, globalOffset: Double? = nil) -> Path { var path = Path() - if (points.count < 2){ + if points.count < 2 { return path } let offset = globalOffset ?? points.min()! -// guard let offset = points.min() else { return path } - var p1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y) - path.move(to: p1) + // guard let offset = points.min() else { return path } + var point1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y) + path.move(to: point1) for pointIndex in 1.. Path { + + static func quadCurvedPathWithPoints(data: [(Double, Double)], in rect: CGRect) -> Path { var path = Path() - if (points.count < 2){ + if data.count < 2 { return path } - let offset = globalOffset ?? points.min()! -// guard let offset = points.min() else { return path } - path.move(to: .zero) - var p1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y) - path.addLine(to: p1) - for pointIndex in 1.. Path { + var path = Path() + let filteredData = data.filter { $0.1 <= 1 && $0.1 >= 0 } + + if filteredData.count < 1 { + return path + } + + let convertedXValues = filteredData.map { CGFloat($0.0) * rect.width } + let convertedYPoints = filteredData.map { CGFloat($0.1) * rect.height } + + let markerSize = CGSize(width: 8, height: 8) + for pointIndex in 0.. Path { + var path = Path() + + for index in 0.. Path { + var path = Path() + if data.count < 2 { + return path + } + + let convertedXValues = data.map { CGFloat($0.0) * rect.width } + let convertedYPoints = data.map { CGFloat($0.1) * rect.height } + + path.move(to: CGPoint(x: convertedXValues[0], y: 0)) + var point1 = CGPoint(x: convertedXValues[0], y: convertedYPoints[0]) + path.addLine(to: point1) + for pointIndex in 1.. Path { + + static func linePathWithPoints(data: [(Double, Double)], in rect: CGRect) -> Path { var path = Path() - if (points.count < 2){ + if data.count < 2 { return path } - guard let offset = points.min() else { return path } - let p1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y) - path.move(to: p1) - for pointIndex in 1.. Path { + + static func closedLinePathWithPoints(data: [(Double, Double)], in rect: CGRect) -> Path { var path = Path() - if (points.count < 2){ + if data.count < 2 { return path } - guard let offset = points.min() else { return path } - var p1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y) - path.move(to: p1) - for pointIndex in 1.. CGFloat { dist(to: to) } - + func line(to: CGPoint, x: CGFloat) -> CGFloat { dist(to: point(to: to, x: x)) } - + func quadCurve(to: CGPoint, control: CGPoint) -> CGFloat { var dist: CGFloat = 0 let steps: CGFloat = 100 @@ -220,7 +288,7 @@ extension CGPoint { } return dist } - + func quadCurve(to: CGPoint, control: CGPoint, x: CGFloat) -> CGFloat { var dist: CGFloat = 0 let steps: CGFloat = 100 @@ -245,14 +313,14 @@ extension CGPoint { } return dist } - + func point(to: CGPoint, t: CGFloat, control: CGPoint) -> CGPoint { let x = CGPoint.value(x: self.x, y: to.x, t: t, c: control.x) let y = CGPoint.value(x: self.y, y: to.y, t: t, c: control.y) return CGPoint(x: x, y: y) } - + func curve(to: CGPoint, control1: CGPoint, control2: CGPoint) -> CGFloat { var dist: CGFloat = 0 let steps: CGFloat = 100 @@ -269,7 +337,7 @@ extension CGPoint { return dist } - + func curve(to: CGPoint, control1: CGPoint, control2: CGPoint, x: CGFloat) -> CGFloat { var dist: CGFloat = 0 let steps: CGFloat = 100 @@ -296,14 +364,14 @@ extension CGPoint { return dist } - + func point(to: CGPoint, t: CGFloat, control1: CGPoint, control2: CGPoint) -> CGPoint { - let x = CGPoint.value(x: self.x, y: to.x, t: t, c1: control1.x, c2: control2.x) - let y = CGPoint.value(x: self.y, y: to.y, t: t, c1: control1.y, c2: control2.x) + let x = CGPoint.value(x: self.x, y: to.x, t: t, control1: control1.x, control2: control2.x) + let y = CGPoint.value(x: self.y, y: to.y, t: t, control1: control1.y, control2: control2.x) return CGPoint(x: x, y: y) } - + static func value(x: CGFloat, y: CGFloat, t: CGFloat, c: CGFloat) -> CGFloat { var value: CGFloat = 0.0 // (1-t)^2 * p0 + 2 * (1-t) * t * c1 + t^2 * p1 @@ -312,42 +380,43 @@ extension CGPoint { value += pow(t, 2) * y return value } - - static func value(x: CGFloat, y: CGFloat, t: CGFloat, c1: CGFloat, c2: CGFloat) -> CGFloat { + + static func value(x: CGFloat, y: CGFloat, t: CGFloat, control1: CGFloat, control2: CGFloat) -> CGFloat { var value: CGFloat = 0.0 // (1-t)^3 * p0 + 3 * (1-t)^2 * t * c1 + 3 * (1-t) * t^2 * c2 + t^3 * p1 value += pow(1-t, 3) * x - value += 3 * pow(1-t, 2) * t * c1 - value += 3 * (1-t) * pow(t, 2) * c2 + value += 3 * pow(1-t, 2) * t * control1 + value += 3 * (1-t) * pow(t, 2) * control2 value += pow(t, 3) * y return value } - + static func getMidPoint(point1: CGPoint, point2: CGPoint) -> CGPoint { return CGPoint( x: point1.x + (point2.x - point1.x) / 2, y: point1.y + (point2.y - point1.y) / 2 ) } - + func dist(to: CGPoint) -> CGFloat { return sqrt((pow(self.x - to.x, 2) + pow(self.y - to.y, 2))) } - - static func midPointForPoints(p1:CGPoint, p2:CGPoint) -> CGPoint { - return CGPoint(x:(p1.x + p2.x) / 2,y: (p1.y + p2.y) / 2) + + static func midPointForPoints(firstPoint: CGPoint, secondPoint: CGPoint) -> CGPoint { + return CGPoint( + x: (firstPoint.x + secondPoint.x) / 2, + y: (firstPoint.y + secondPoint.y) / 2) } - - static func controlPointForPoints(p1:CGPoint, p2:CGPoint) -> CGPoint { - var controlPoint = CGPoint.midPointForPoints(p1:p1, p2:p2) - let diffY = abs(p2.y - controlPoint.y) + + static func controlPointForPoints(firstPoint: CGPoint, secondPoint: CGPoint) -> CGPoint { + var controlPoint = CGPoint.midPointForPoints(firstPoint: firstPoint, secondPoint: secondPoint) + let diffY = abs(secondPoint.y - controlPoint.y) - if (p1.y < p2.y){ + if firstPoint.y < secondPoint.y { controlPoint.y += diffY - } else if (p1.y > p2.y) { + } else if firstPoint.y > secondPoint.y { controlPoint.y -= diffY } return controlPoint } } - diff --git a/Sources/SwiftUICharts/Base/Extensions/Range+Extension.swift b/Sources/SwiftUICharts/Base/Extensions/Range+Extension.swift new file mode 100644 index 00000000..547a0435 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Extensions/Range+Extension.swift @@ -0,0 +1,7 @@ +import Foundation + +public extension ClosedRange where Bound: AdditiveArithmetic { + var overreach: Bound { + self.upperBound - self.lowerBound + } +} diff --git a/Sources/SwiftUICharts/Base/Extensions/Shape+Extension.swift b/Sources/SwiftUICharts/Base/Extensions/Shape+Extension.swift new file mode 100644 index 00000000..5624e0db --- /dev/null +++ b/Sources/SwiftUICharts/Base/Extensions/Shape+Extension.swift @@ -0,0 +1,17 @@ +import SwiftUI + +extension Shape { + func fill(_ fillStyle: Fill, strokeBorder strokeStyle: Stroke, lineWidth: Double = 1) -> some View { + self + .stroke(strokeStyle, lineWidth: lineWidth) + .background(self.fill(fillStyle)) + } +} + +extension InsettableShape { + func fill(_ fillStyle: Fill, strokeBorder strokeStyle: Stroke, lineWidth: Double = 1) -> some View { + self + .strokeBorder(strokeStyle, lineWidth: lineWidth) + .background(self.fill(fillStyle)) + } +} diff --git a/Sources/SwiftUICharts/Base/Extensions/View+Extension.swift b/Sources/SwiftUICharts/Base/Extensions/View+Extension.swift new file mode 100644 index 00000000..1edeb22e --- /dev/null +++ b/Sources/SwiftUICharts/Base/Extensions/View+Extension.swift @@ -0,0 +1,17 @@ +import SwiftUI + +extension View { + + /// Attach chart style to a View + /// - Parameter style: chart style + /// - Returns: `View` with chart style attached + public func chartStyle(_ style: ChartStyle) -> some View { + self.environmentObject(style) + } + + public func toStandardCoordinateSystem() -> some View { + self + .rotationEffect(.degrees(180), anchor: .center) + .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0)) + } +} diff --git a/Sources/SwiftUICharts/Base/Grid/ChartGrid.swift b/Sources/SwiftUICharts/Base/Grid/ChartGrid.swift new file mode 100644 index 00000000..ba8bfe8d --- /dev/null +++ b/Sources/SwiftUICharts/Base/Grid/ChartGrid.swift @@ -0,0 +1,24 @@ +import SwiftUI + +public struct ChartGrid: View { + let content: () -> Content + public var gridOptions = GridOptions() + + public init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + + public var body: some View { + ZStack { + ChartGridShape(numberOfHorizontalLines: gridOptions.numberOfHorizontalLines, + numberOfVerticalLines: gridOptions.numberOfVerticalLines) + .stroke(gridOptions.color, style: gridOptions.strokeStyle) + if gridOptions.showBaseLine { + ChartGridBaseShape() + .stroke(gridOptions.color, style: gridOptions.baseStrokeStyle) + .rotationEffect(.degrees(180), anchor: .center) + } + self.content() + } + } +} diff --git a/Sources/SwiftUICharts/Base/Grid/ChartGridBaseShape.swift b/Sources/SwiftUICharts/Base/Grid/ChartGridBaseShape.swift new file mode 100644 index 00000000..6b9bd68c --- /dev/null +++ b/Sources/SwiftUICharts/Base/Grid/ChartGridBaseShape.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct ChartGridBaseShape: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: 0, y: 0)) + path.addLine(to: CGPoint(x: rect.width, y: 0)) + return path + } +} + +struct ChartGridBaseShape_Previews: PreviewProvider { + static var previews: some View { + ChartGridBaseShape() + .stroke() + .rotationEffect(.degrees(180), anchor: .center) + } +} diff --git a/Sources/SwiftUICharts/Base/Grid/ChartGridShape.swift b/Sources/SwiftUICharts/Base/Grid/ChartGridShape.swift new file mode 100644 index 00000000..ccb6e18b --- /dev/null +++ b/Sources/SwiftUICharts/Base/Grid/ChartGridShape.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct ChartGridShape: Shape { + var numberOfHorizontalLines: Int + var numberOfVerticalLines: Int + + func path(in rect: CGRect) -> Path { + let path = Path.drawGridLines(numberOfHorizontalLines: numberOfHorizontalLines, + numberOfVerticalLines: numberOfVerticalLines, + in: rect) + return path + } +} + +struct ChartGridShape_Previews: PreviewProvider { + static var previews: some View { + Group { + ChartGridShape(numberOfHorizontalLines: 5, numberOfVerticalLines: 0) + .stroke() + .toStandardCoordinateSystem() + + ChartGridShape(numberOfHorizontalLines: 4, numberOfVerticalLines: 4) + .stroke() + .toStandardCoordinateSystem() + } + .padding() + } +} diff --git a/Sources/SwiftUICharts/Base/Grid/Extension/Grid+Extension.swift b/Sources/SwiftUICharts/Base/Grid/Extension/Grid+Extension.swift new file mode 100644 index 00000000..05e15ed8 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Grid/Extension/Grid+Extension.swift @@ -0,0 +1,31 @@ +import SwiftUI + +extension ChartGrid { + public func setNumberOfHorizontalLines(_ numberOfLines: Int) -> ChartGrid { + self.gridOptions.numberOfHorizontalLines = numberOfLines + return self + } + + public func setNumberOfVerticalLines(_ numberOfLines: Int) -> ChartGrid { + self.gridOptions.numberOfVerticalLines = numberOfLines + return self + } + + public func setStoreStyle(_ strokeStyle: StrokeStyle) -> ChartGrid { + self.gridOptions.strokeStyle = strokeStyle + return self + } + + public func setColor(_ color: Color) -> ChartGrid { + self.gridOptions.color = color + return self + } + + public func showBaseLine(_ show: Bool, with style: StrokeStyle? = nil) -> ChartGrid { + self.gridOptions.showBaseLine = show + if let style = style { + self.gridOptions.baseStrokeStyle = style + } + return self + } +} diff --git a/Sources/SwiftUICharts/Base/Grid/Model/GridOptions.swift b/Sources/SwiftUICharts/Base/Grid/Model/GridOptions.swift new file mode 100644 index 00000000..2e107a30 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Grid/Model/GridOptions.swift @@ -0,0 +1,14 @@ +import SwiftUI + +public final class GridOptions: ObservableObject { + @Published public var numberOfHorizontalLines: Int = 3 + @Published public var numberOfVerticalLines: Int = 3 + @Published public var strokeStyle: StrokeStyle = StrokeStyle(lineWidth: 1, lineCap: .round, dash: [5, 10]) + @Published public var color: Color = Color(white: 0.85) + @Published public var showBaseLine: Bool = true + @Published public var baseStrokeStyle: StrokeStyle = StrokeStyle(lineWidth: 1.5, lineCap: .round, dash: [5, 0]) + + public init() { + // no-op + } +} diff --git a/Sources/SwiftUICharts/Base/Label/ChartLabel.swift b/Sources/SwiftUICharts/Base/Label/ChartLabel.swift new file mode 100644 index 00000000..fb1a8909 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Label/ChartLabel.swift @@ -0,0 +1,107 @@ +import SwiftUI + +/// What kind of label - this affects color, size, position of the label +public enum ChartLabelType { + case title + case subTitle + case largeTitle + case custom(size: CGFloat, padding: EdgeInsets, color: Color) + case legend +} + +/// A chart may contain any number of labels in pre-set positions based on their `ChartLabelType` +public struct ChartLabel: View { + @EnvironmentObject var chartValue: ChartValue + @State var textToDisplay:String = "" + var format: String = "%.01f" + + private var title: String + + /// Label font size + /// - Returns: the font size of the label + private var labelSize: CGFloat { + switch labelType { + case .title: + return 32.0 + case .legend: + return 14.0 + case .subTitle: + return 24.0 + case .largeTitle: + return 38.0 + case .custom(let size, _, _): + return size + } + } + + /// Padding around label + /// - Returns: the edge padding to use based on position of the label + private var labelPadding: EdgeInsets { + switch labelType { + case .title: + return EdgeInsets(top: 16.0, leading: 0, bottom: 0.0, trailing: 8.0) + case .legend: + return EdgeInsets(top: 4.0, leading: 0, bottom: 0.0, trailing: 8.0) + case .subTitle: + return EdgeInsets(top: 8.0, leading: 0, bottom: 0.0, trailing: 8.0) + case .largeTitle: + return EdgeInsets(top: 24.0, leading: 0, bottom: 0.0, trailing: 8.0) + case .custom(_, let padding, _): + return padding + } + } + + /// Which type (color, size, position) for label + private let labelType: ChartLabelType + + /// Foreground color for this label + /// - Returns: Color of label based on its `ChartLabelType` + private var labelColor: Color { + switch labelType { + case .title: + return Color.primary + case .legend: + return Color.secondary + case .subTitle: + return Color.primary + case .largeTitle: + return Color.primary + case .custom(_, _, let color): + return color + } + } + + /// Initialize + /// - Parameters: + /// - title: Any `String` + /// - type: Which `ChartLabelType` to use + public init (_ title: String, + type: ChartLabelType = .title, + format: String = "%.01f") { + self.title = title + labelType = type + self.format = format + } + + /// The content and behavior of the `ChartLabel`. + /// + /// Displays current value if chart is currently being touched along a data point, otherwise the specified text. + public var body: some View { + HStack { + Text(textToDisplay) + .font(.system(size: labelSize)) + .bold() + .foregroundColor(self.labelColor) + .padding(self.labelPadding) + .onAppear { + self.textToDisplay = self.title + } + .onReceive(self.chartValue.objectWillChange) { _ in + self.textToDisplay = self.chartValue.interactionInProgress ? String(format: format, self.chartValue.currentValue) : self.title + } + if !self.chartValue.interactionInProgress { + Spacer() + } + } + } +} diff --git a/Sources/SwiftUICharts/Base/Style/ChartStyle.swift b/Sources/SwiftUICharts/Base/Style/ChartStyle.swift new file mode 100644 index 00000000..5ecc3a44 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Style/ChartStyle.swift @@ -0,0 +1,27 @@ +import SwiftUI + +public class ChartStyle: ObservableObject { + public let backgroundColor: ColorGradient + public let foregroundColor: [ColorGradient] + + public init(backgroundColor: Color, foregroundColor: [ColorGradient]) { + self.backgroundColor = ColorGradient.init(backgroundColor) + self.foregroundColor = foregroundColor + } + + public init(backgroundColor: Color, foregroundColor: ColorGradient) { + self.backgroundColor = ColorGradient.init(backgroundColor) + self.foregroundColor = [foregroundColor] + } + + public init(backgroundColor: ColorGradient, foregroundColor: ColorGradient) { + self.backgroundColor = backgroundColor + self.foregroundColor = [foregroundColor] + } + + public init(backgroundColor: ColorGradient, foregroundColor: [ColorGradient]) { + self.backgroundColor = backgroundColor + self.foregroundColor = foregroundColor + } + +} diff --git a/Sources/SwiftUICharts/Base/Style/ColorGradient.swift b/Sources/SwiftUICharts/Base/Style/ColorGradient.swift new file mode 100644 index 00000000..6625428e --- /dev/null +++ b/Sources/SwiftUICharts/Base/Style/ColorGradient.swift @@ -0,0 +1,33 @@ +import SwiftUI + +public struct ColorGradient: Equatable { + public let startColor: Color + public let endColor: Color + + public init(_ color: Color) { + self.startColor = color + self.endColor = color + } + + public init(_ startColor: Color, _ endColor: Color) { + self.startColor = startColor + self.endColor = endColor + } + + public var gradient: Gradient { + return Gradient(colors: [startColor, endColor]) + } +} + +extension ColorGradient { + public func linearGradient(from startPoint: UnitPoint, to endPoint: UnitPoint) -> LinearGradient { + return LinearGradient(gradient: self.gradient, startPoint: startPoint, endPoint: endPoint) + } +} + +extension ColorGradient { + public static let orangeBright = ColorGradient(ChartColors.orangeBright) + public static let redBlack = ColorGradient(.red, .black) + public static let greenRed = ColorGradient(.green, .red) + public static let whiteBlack = ColorGradient(.white, .black) +} diff --git a/Sources/SwiftUICharts/Base/Style/Colors.swift b/Sources/SwiftUICharts/Base/Style/Colors.swift new file mode 100644 index 00000000..a230e901 --- /dev/null +++ b/Sources/SwiftUICharts/Base/Style/Colors.swift @@ -0,0 +1,9 @@ +import SwiftUI + +public enum ChartColors { + public static let orangeBright = Color(hexString: "#FF782C") + public static let orangeDark = Color(hexString: "#EC2301") + + public static let legendColor: Color = Color(hexString: "#E8E7EA") + public static let indicatorKnob: Color = Color(hexString: "#FF57A6") +} diff --git a/Sources/SwiftUICharts/Charts/BarChart/BarChart.swift b/Sources/SwiftUICharts/Charts/BarChart/BarChart.swift new file mode 100644 index 00000000..5727831c --- /dev/null +++ b/Sources/SwiftUICharts/Charts/BarChart/BarChart.swift @@ -0,0 +1,13 @@ +import SwiftUI + +public struct BarChart: ChartBase { + public var chartData = ChartData() + + @EnvironmentObject var style: ChartStyle + + public var body: some View { + BarChartRow(chartData: chartData, style: style) + } + + public init() {} +} diff --git a/Sources/SwiftUICharts/Charts/BarChart/BarChartCell.swift b/Sources/SwiftUICharts/Charts/BarChart/BarChartCell.swift new file mode 100644 index 00000000..13153557 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/BarChart/BarChartCell.swift @@ -0,0 +1,52 @@ +import SwiftUI + +public struct BarChartCell: View { + var value: Double + var index: Int = 0 + var gradientColor: ColorGradient + var touchLocation: CGFloat + + @State private var didCellAppear: Bool = false + + public init( value: Double, + index: Int = 0, + gradientColor: ColorGradient, + touchLocation: CGFloat) { + self.value = value + self.index = index + self.gradientColor = gradientColor + self.touchLocation = touchLocation + } + + public var body: some View { + BarChartCellShape(value: didCellAppear ? value : 0.0) + .fill(gradientColor.linearGradient(from: .bottom, to: .top)) .onAppear { + self.didCellAppear = true + } + .onDisappear { + self.didCellAppear = false + } + .transition(.slide) + .animation(Animation.spring().delay(self.touchLocation < 0 || !didCellAppear ? Double(self.index) * 0.04 : 0)) + } +} + +struct BarChartCell_Previews: PreviewProvider { + static var previews: some View { + Group { + Group { + BarChartCell(value: 0, gradientColor: ColorGradient.greenRed, touchLocation: CGFloat()) + + BarChartCell(value: 0.5, gradientColor: ColorGradient.greenRed, touchLocation: CGFloat()) + BarChartCell(value: 0.75, gradientColor: ColorGradient.whiteBlack, touchLocation: CGFloat()) + BarChartCell(value: 1, gradientColor: ColorGradient(.purple), touchLocation: CGFloat()) + } + + Group { + BarChartCell(value: 1, gradientColor: ColorGradient.greenRed, touchLocation: CGFloat()) + BarChartCell(value: 1, gradientColor: ColorGradient.whiteBlack, touchLocation: CGFloat()) + BarChartCell(value: 1, gradientColor: ColorGradient(.purple), touchLocation: CGFloat()) + }.environment(\.colorScheme, .dark) + } + } +} diff --git a/Sources/SwiftUICharts/Charts/BarChart/BarChartCellShape.swift b/Sources/SwiftUICharts/Charts/BarChart/BarChartCellShape.swift new file mode 100644 index 00000000..268f4229 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/BarChart/BarChartCellShape.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct BarChartCellShape: Shape, Animatable { + var value: Double + var cornerRadius: CGFloat = 6.0 + + var animatableData: CGFloat { + get { CGFloat(value) } + set { value = Double(newValue) } + } + + func path(in rect: CGRect) -> Path { + let adjustedOriginY = rect.height - (rect.height * CGFloat(value)) + var path = Path() + path.move(to: CGPoint(x: 0.0 , y: rect.height)) + path.addLine(to: CGPoint(x: 0.0, y: adjustedOriginY + cornerRadius)) + path.addArc(center: CGPoint(x: cornerRadius, y: adjustedOriginY + cornerRadius), + radius: cornerRadius, + startAngle: Angle(radians: Double.pi), + endAngle: Angle(radians: value < 0 ? Double.pi/2 : -Double.pi/2), + clockwise: value < 0 ? true : false) + path.addLine(to: CGPoint(x: rect.width - cornerRadius, y: value < 0 ? adjustedOriginY + 2 * cornerRadius : adjustedOriginY)) + path.addArc(center: CGPoint(x: rect.width - cornerRadius, y: adjustedOriginY + cornerRadius), + radius: cornerRadius, + startAngle: Angle(radians: value < 0 ? Double.pi/2 : -Double.pi/2), + endAngle: Angle(radians: 0), + clockwise: value < 0 ? true : false) + path.addLine(to: CGPoint(x: rect.width, y: rect.height)) + path.closeSubpath() + + return path + } +} + +struct BarChartCellShape_Previews: PreviewProvider { + static var previews: some View { + Group { + BarChartCellShape(value: 0.75) + .fill(Color.red) + + BarChartCellShape(value: 0.3) + .fill(Color.blue) + + BarChartCellShape(value: -0.3) + .fill(Color.blue) + .offset(x: 0, y: -600) + } + } +} diff --git a/Sources/SwiftUICharts/Charts/BarChart/BarChartRow.swift b/Sources/SwiftUICharts/Charts/BarChart/BarChartRow.swift new file mode 100644 index 00000000..1115d30b --- /dev/null +++ b/Sources/SwiftUICharts/Charts/BarChart/BarChartRow.swift @@ -0,0 +1,70 @@ +import SwiftUI + +public struct BarChartRow: View { + @EnvironmentObject var chartValue: ChartValue + @ObservedObject var chartData: ChartData + @State private var touchLocation: CGFloat = -1.0 + + var style: ChartStyle + + var maxValue: Double { + guard let max = chartData.points.max() else { + return 1 + } + return max != 0 ? max : 1 + } + + public var body: some View { + GeometryReader { geometry in + HStack(alignment: .bottom, + spacing: geometry.frame(in: .local).width / CGFloat(chartData.data.count * 3)) { + ForEach(0.. CGSize { + if touchLocation > CGFloat(index)/CGFloat(chartData.data.count) && + touchLocation < CGFloat(index+1)/CGFloat(chartData.data.count) { + return CGSize(width: 1.4, height: 1.1) + } + return CGSize(width: 1, height: 1) + } + + func getCurrentValue(width: CGFloat) -> Double? { + guard self.chartData.data.count > 0 else { return nil} + let index = max(0,min(self.chartData.data.count-1,Int(floor((self.touchLocation*width)/(width/CGFloat(self.chartData.data.count)))))) + return self.chartData.points[index] + } +} + +struct BarChartRow_Previews: PreviewProvider { + static let chartData = ChartData([6, 2, 5, 8, 6]) + static let chartStyle = ChartStyle(backgroundColor: .white, foregroundColor: .orangeBright) + static var previews: some View { + BarChartRow(chartData: chartData, style: chartStyle) + } +} diff --git a/Sources/SwiftUICharts/Charts/LineChart/Extension/LineChart+Extension.swift b/Sources/SwiftUICharts/Charts/LineChart/Extension/LineChart+Extension.swift new file mode 100644 index 00000000..03920e1e --- /dev/null +++ b/Sources/SwiftUICharts/Charts/LineChart/Extension/LineChart+Extension.swift @@ -0,0 +1,29 @@ +import SwiftUI + +extension LineChart { + public func setLineWidth(width: CGFloat) -> LineChart { + self.chartProperties.lineWidth = width + return self + } + + public func setBackground(colorGradient: ColorGradient) -> LineChart { + self.chartProperties.backgroundGradient = colorGradient + return self + } + + public func showChartMarks(_ show: Bool, with color: ColorGradient? = nil) -> LineChart { + self.chartProperties.showChartMarks = show + self.chartProperties.customChartMarksColors = color + return self + } + + public func setLineStyle(to style: LineStyle) -> LineChart { + self.chartProperties.lineStyle = style + return self + } + + public func withAnimation(_ enabled: Bool) -> LineChart { + self.chartProperties.animationEnabled = enabled + return self + } +} diff --git a/Sources/SwiftUICharts/LineChart/IndicatorPoint.swift b/Sources/SwiftUICharts/Charts/LineChart/IndicatorPoint.swift similarity index 52% rename from Sources/SwiftUICharts/LineChart/IndicatorPoint.swift rename to Sources/SwiftUICharts/Charts/LineChart/IndicatorPoint.swift index 2e8667da..69d8d88c 100644 --- a/Sources/SwiftUICharts/LineChart/IndicatorPoint.swift +++ b/Sources/SwiftUICharts/Charts/LineChart/IndicatorPoint.swift @@ -1,23 +1,15 @@ -// -// IndicatorPoint.swift -// LineChart -// -// Created by András Samu on 2019. 09. 03.. -// Copyright © 2019. András Samu. All rights reserved. -// - import SwiftUI struct IndicatorPoint: View { - var body: some View { - ZStack{ + public var body: some View { + ZStack { Circle() - .fill(Colors.IndicatorKnob) + .fill(ChartColors.indicatorKnob) Circle() .stroke(Color.white, style: StrokeStyle(lineWidth: 4)) } .frame(width: 14, height: 14) - .shadow(color: Colors.LegendColor, radius: 6, x: 0, y: 6) + .shadow(color: ChartColors.legendColor, radius: 6, x: 0, y: 6) } } diff --git a/Sources/SwiftUICharts/Charts/LineChart/Line.swift b/Sources/SwiftUICharts/Charts/LineChart/Line.swift new file mode 100644 index 00000000..2e2fc214 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/LineChart/Line.swift @@ -0,0 +1,111 @@ +import SwiftUI + +/// A single line of data, a view in a `LineChart` +public struct Line: View { + @ObservedObject var chartData: ChartData + @ObservedObject var chartProperties: LineChartProperties + + var curvedLines: Bool = true + var style: ChartStyle + + @State private var showIndicator: Bool = false + @State private var touchLocation: CGPoint = .zero + @State private var didCellAppear: Bool = false + + var path: Path { + Path.quadCurvedPathWithPoints(points: chartData.normalisedPoints, + step: CGPoint(x: 1.0, y: 1.0)) + } + + public init(chartData: ChartData, + style: ChartStyle, + chartProperties: LineChartProperties) { + self.chartData = chartData + self.style = style + self.chartProperties = chartProperties + } + + public var body: some View { + GeometryReader { geometry in + ZStack { + if self.didCellAppear, let backgroundColor = chartProperties.backgroundGradient { + LineBackgroundShapeView(chartData: chartData, + geometry: geometry, + backgroundColor: backgroundColor) + } + lineShapeView(geometry: geometry) + } + .onAppear { + didCellAppear = true + } + .onDisappear() { + didCellAppear = false + } + } + } + + @ViewBuilder + private func lineShapeView(geometry: GeometryProxy) -> some View { + if chartProperties.animationEnabled { + LineShapeView(chartData: chartData, + chartProperties: chartProperties, + geometry: geometry, + style: style, + trimTo: didCellAppear ? 1.0 : 0.0) + .animation(Animation.easeIn(duration: 0.75)) + } else { + LineShapeView(chartData: chartData, + chartProperties: chartProperties, + geometry: geometry, + style: style, + trimTo: 1.0) + } + } +} + +// MARK: - Private functions + +extension Line { + /// Calculate point closest to where the user touched + /// - Parameter touchLocation: location in view where touched + /// - Returns: `CGPoint` of data point on chart + private func getClosestPointOnPath(geometry: GeometryProxy, touchLocation: CGPoint) -> CGPoint { + let geometryWidth = geometry.frame(in: .local).width + let normalisedTouchLocationX = (touchLocation.x / geometryWidth) * CGFloat(chartData.normalisedPoints.count - 1) + let closest = self.path.point(to: normalisedTouchLocationX) + var denormClosest = closest.denormalize(with: geometry) + denormClosest.x = denormClosest.x / CGFloat(chartData.normalisedPoints.count - 1) + denormClosest.y = denormClosest.y / CGFloat(chartData.normalisedYRange) + return denormClosest + } + +// /// Figure out where closest touch point was +// /// - Parameter point: location of data point on graph, near touch location + private func getClosestDataPoint(geometry: GeometryProxy, touchLocation: CGPoint) { + let geometryWidth = geometry.frame(in: .local).width + let index = Int(round((touchLocation.x / geometryWidth) * CGFloat(chartData.points.count - 1))) + if (index >= 0 && index < self.chartData.data.count){ +// self.chartValue.currentValue = self.chartData.points[index] + } + } +} + +struct Line_Previews: PreviewProvider { + /// Predefined style, black over white, for preview + static let blackLineStyle = ChartStyle(backgroundColor: ColorGradient(.white), foregroundColor: ColorGradient(.black)) + + /// Predefined style red over white, for preview + static let redLineStyle = ChartStyle(backgroundColor: .whiteBlack, foregroundColor: ColorGradient(.red)) + + static var previews: some View { + Group { + Line(chartData: ChartData([8, 23, 32, 7, 23, -4]), + style: blackLineStyle, + chartProperties: LineChartProperties()) + Line(chartData: ChartData([8, 23, 32, 7, 23, 43]), + style: redLineStyle, + chartProperties: LineChartProperties()) + } + } +} + diff --git a/Sources/SwiftUICharts/Charts/LineChart/LineBackgroundShape.swift b/Sources/SwiftUICharts/Charts/LineChart/LineBackgroundShape.swift new file mode 100644 index 00000000..3b57733e --- /dev/null +++ b/Sources/SwiftUICharts/Charts/LineChart/LineBackgroundShape.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct LineBackgroundShape: Shape { + var data: [(Double, Double)] + func path(in rect: CGRect) -> Path { + let path = Path.quadClosedCurvedPathWithPoints(data: data, in: rect) + return path + } +} + +struct LineBackgroundShape_Previews: PreviewProvider { + static var previews: some View { + Group { + GeometryReader { geometry in + LineBackgroundShape(data: [(0, -0.5), (0.25, 0.8), (0.5,-0.6), (0.75,0.6), (1, 1)]) + .fill(Color.red) + .toStandardCoordinateSystem() + } + GeometryReader { geometry in + LineBackgroundShape(data: [(0, 0), (0.25, 0.5), (0.5,0.8), (0.75, 0.6), (1, 1)]) + .fill(Color.blue) + .toStandardCoordinateSystem() + } + } + } +} + diff --git a/Sources/SwiftUICharts/Charts/LineChart/LineBackgroundShapeView.swift b/Sources/SwiftUICharts/Charts/LineChart/LineBackgroundShapeView.swift new file mode 100644 index 00000000..2a4f45eb --- /dev/null +++ b/Sources/SwiftUICharts/Charts/LineChart/LineBackgroundShapeView.swift @@ -0,0 +1,16 @@ +import SwiftUI + +struct LineBackgroundShapeView: View { + var chartData: ChartData + var geometry: GeometryProxy + var backgroundColor: ColorGradient + + var body: some View { + LineBackgroundShape(data: chartData.normalisedData) + .fill(LinearGradient(gradient: Gradient(colors: [backgroundColor.startColor, + backgroundColor.endColor]), + startPoint: .bottom, + endPoint: .top)) + .toStandardCoordinateSystem() + } +} diff --git a/Sources/SwiftUICharts/Charts/LineChart/LineChart.swift b/Sources/SwiftUICharts/Charts/LineChart/LineChart.swift new file mode 100644 index 00000000..c069ec0b --- /dev/null +++ b/Sources/SwiftUICharts/Charts/LineChart/LineChart.swift @@ -0,0 +1,15 @@ +import SwiftUI + +public struct LineChart: ChartBase { + public var chartData = ChartData() + @EnvironmentObject var style: ChartStyle + public var chartProperties = LineChartProperties() + + public var body: some View { + Line(chartData: chartData, + style: style, + chartProperties: chartProperties) + } + + public init() {} +} diff --git a/Sources/SwiftUICharts/Charts/LineChart/LineShape.swift b/Sources/SwiftUICharts/Charts/LineChart/LineShape.swift new file mode 100644 index 00000000..e22be2f4 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/LineChart/LineShape.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct LineShape: Shape { + var data: [(Double, Double)] + var lineStyle: LineStyle = .curved + func path(in rect: CGRect) -> Path { + var path = Path() + switch lineStyle { + case .curved: + path = Path.quadCurvedPathWithPoints(data: data, in: rect) + case .straight: + path = Path.linePathWithPoints(data: data, in: rect) + } + return path + } +} + +struct LineShape_Previews: PreviewProvider { + static var previews: some View { + Group { + LineShape(data: [(0, 0), (0.25, 0.5), (0.5,0.8), (0.75, 0.6), (1, 1)]) + .stroke() + .toStandardCoordinateSystem() + + LineShape(data: [(0, -0.5), (0.25, 0.8), (0.5,-0.6), (0.75,0.6), (1, 1)], lineStyle: .straight) + .stroke() + .toStandardCoordinateSystem() + } + } +} diff --git a/Sources/SwiftUICharts/Charts/LineChart/LineShapeView.swift b/Sources/SwiftUICharts/Charts/LineChart/LineShapeView.swift new file mode 100644 index 00000000..308debbc --- /dev/null +++ b/Sources/SwiftUICharts/Charts/LineChart/LineShapeView.swift @@ -0,0 +1,83 @@ +import SwiftUI + +struct LineShapeView: View, Animatable { + var chartData: ChartData + var chartProperties: LineChartProperties + + var geometry: GeometryProxy + var style: ChartStyle + var trimTo: Double = 0 + + var animatableData: CGFloat { + get { CGFloat(trimTo) } + set { trimTo = Double(newValue) } + } + + var chartMarkColor: LinearGradient { + if let customColor = chartProperties.customChartMarksColors { + return customColor.linearGradient(from: .leading, to: .trailing) + } + + return LinearGradient(gradient: style.foregroundColor.first?.gradient ?? ColorGradient.orangeBright.gradient, + startPoint: .leading, + endPoint: .trailing) + } + + var body: some View { + ZStack { + LineShape(data: chartData.normalisedData, lineStyle: chartProperties.lineStyle) + .trim(from: 0, to: CGFloat(trimTo)) + .stroke(LinearGradient(gradient: style.foregroundColor.first?.gradient ?? ColorGradient.orangeBright.gradient, + startPoint: .leading, + endPoint: .trailing), + style: StrokeStyle(lineWidth: chartProperties.lineWidth, lineJoin: .round)) + .toStandardCoordinateSystem() + .clipped() + if chartProperties.showChartMarks { + MarkerShape(data: chartData.normalisedData) + .trim(from: 0, to: CGFloat(trimTo)) + .fill(.white, + strokeBorder: chartMarkColor, + lineWidth: chartProperties.lineWidth) + .toStandardCoordinateSystem() + } + } + } +} + +struct LineShapeView_Previews: PreviewProvider { + static let chartData = ChartData([6, 8, 6], rangeY: 6...10) + static let chartDataOutOfRange = ChartData([-1, 8, 6, 12, 3], rangeY: -5...15) + + static let chartDataOutOfRange2 = ChartData([6,6,8,5], rangeY: 5...10) + + static let chartStyle = ChartStyle(backgroundColor: Color.white, + foregroundColor: [ColorGradient(Color.orange, Color.red)]) + static var previews: some View { + Group { + GeometryReader { geometry in + LineShapeView(chartData: chartData, + chartProperties: LineChartProperties(), + geometry: geometry, + style: chartStyle, + trimTo: 1.0) + } + GeometryReader { geometry in + LineShapeView(chartData: chartDataOutOfRange, + chartProperties: LineChartProperties(), + geometry: geometry, + style: chartStyle, + trimTo: 1.0) + } + GeometryReader { geometry in + LineShapeView(chartData: chartDataOutOfRange2, + chartProperties: LineChartProperties(), + geometry: geometry, + style: chartStyle, + trimTo: 1.0) + } + } + } +} + + diff --git a/Sources/SwiftUICharts/Charts/LineChart/MarkerShape.swift b/Sources/SwiftUICharts/Charts/LineChart/MarkerShape.swift new file mode 100644 index 00000000..0207b896 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/LineChart/MarkerShape.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct MarkerShape: Shape { + var data: [(Double, Double)] + func path(in rect: CGRect) -> Path { + let path = Path.drawChartMarkers(data: data, in: rect) + return path + } +} + +struct MarkerShape_Previews: PreviewProvider { + static var previews: some View { + Group { + MarkerShape(data: [(0, 0), (0.25, 0.5), (0.5,0.8), (0.75, 0.6), (1, 1)]) + .stroke() + .toStandardCoordinateSystem() + + MarkerShape(data: [(0, -0.5), (0.25, 0.8), (0.5,-0.6), (0.75,0.6), (1, 1)]) + .stroke() + .toStandardCoordinateSystem() + } + } +} diff --git a/Sources/SwiftUICharts/Charts/LineChart/Model/LineChartProperties.swift b/Sources/SwiftUICharts/Charts/LineChart/Model/LineChartProperties.swift new file mode 100644 index 00000000..24893be2 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/LineChart/Model/LineChartProperties.swift @@ -0,0 +1,13 @@ +import SwiftUI + +public class LineChartProperties: ObservableObject { + @Published var lineWidth: CGFloat = 2.0 + @Published var backgroundGradient: ColorGradient? + @Published var showChartMarks: Bool = true + @Published var customChartMarksColors: ColorGradient? + @Published var lineStyle: LineStyle = .curved + @Published var animationEnabled: Bool = true + public init() { + // no-op + } +} diff --git a/Sources/SwiftUICharts/Charts/LineChart/Model/LineStyle.swift b/Sources/SwiftUICharts/Charts/LineChart/Model/LineStyle.swift new file mode 100644 index 00000000..c3c3d4a2 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/LineChart/Model/LineStyle.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum LineStyle { + case curved + case straight +} diff --git a/Sources/SwiftUICharts/Charts/PieChart/PieChart.swift b/Sources/SwiftUICharts/Charts/PieChart/PieChart.swift new file mode 100644 index 00000000..63e91260 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/PieChart/PieChart.swift @@ -0,0 +1,17 @@ +import SwiftUI + +/// A type of chart that displays a slice of "pie" for each data point +public struct PieChart: ChartBase { + public var chartData = ChartData() + + @EnvironmentObject var style: ChartStyle + + /// The content and behavior of the `PieChart`. + /// + /// + public var body: some View { + PieChartRow(chartData: chartData, style: style) + } + + public init() {} +} diff --git a/Sources/SwiftUICharts/Charts/PieChart/PieChartCell.swift b/Sources/SwiftUICharts/Charts/PieChart/PieChartCell.swift new file mode 100644 index 00000000..f733bcd7 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/PieChart/PieChartCell.swift @@ -0,0 +1,116 @@ +import SwiftUI + +/// One slice of a `PieChartRow` +struct PieSlice: Identifiable { + var id = UUID() + var startDeg: Double + var endDeg: Double + var value: Double +} + +/// A single row of data, a view in a `PieChart` +public struct PieChartCell: View { + @State private var show: Bool = false + var rect: CGRect + var radius: CGFloat { + return min(rect.width, rect.height)/2 + } + var startDeg: Double + var endDeg: Double + + /// Path representing this slice + var path: Path { + var path = Path() + path.addArc( + center: rect.mid, + radius: self.radius, + startAngle: Angle(degrees: self.startDeg), + endAngle: Angle(degrees: self.endDeg), + clockwise: false) + path.addLine(to: rect.mid) + path.closeSubpath() + return path + } + var index: Int + + // Section line border color + var backgroundColor: Color + + // Section color + var accentColor: ColorGradient + + /// The content and behavior of the `PieChartCell`. + /// + /// Fills and strokes with 2-pixel line (unless start/end degrees not yet set). Animates by scaling up to 100% when first appears. + public var body: some View { + Group { + path + .fill(self.accentColor.linearGradient(from: .bottom, to: .top)) + .overlay(path.stroke(self.backgroundColor, lineWidth: (startDeg == 0 && endDeg == 0 ? 0 : 2))) + .scaleEffect(self.show ? 1 : 0) + .animation(Animation.spring().delay(Double(self.index) * 0.04)) + .onAppear { + self.show = true + } + + } + } +} + +struct PieChartCell_Previews: PreviewProvider { + static var previews: some View { + Group { + + GeometryReader { geometry in + PieChartCell( + rect: geometry.frame(in: .local), + startDeg: 00.0, + endDeg: 90.0, + index: 0, + backgroundColor: Color.red, + accentColor: ColorGradient.greenRed) + }.frame(width: 100, height: 100) + + GeometryReader { geometry in + PieChartCell( + rect: geometry.frame(in: .local), + startDeg: 0.0, + endDeg: 90.0, + index: 0, + backgroundColor: Color.green, + accentColor: ColorGradient.redBlack) + }.frame(width: 100, height: 100) + + GeometryReader { geometry in + PieChartCell( + rect: geometry.frame(in: .local), + startDeg: 100.0, + endDeg: 135.0, + index: 0, + backgroundColor: Color.black, + accentColor: ColorGradient.whiteBlack) + }.frame(width: 100, height: 100) + + GeometryReader { geometry in + PieChartCell( + rect: geometry.frame(in: .local), + startDeg: 185.0, + endDeg: 290.0, + index: 1, + backgroundColor: Color.purple, + accentColor: ColorGradient(.purple)) + }.frame(width: 100, height: 100) + + GeometryReader { geometry in + PieChartCell( + rect: geometry.frame(in: .local), + startDeg: 0, + endDeg: 0, + index: 0, + backgroundColor: Color.purple, + accentColor: ColorGradient(.purple)) + }.frame(width: 100, height: 100) + + }.previewLayout(.fixed(width: 125, height: 125)) + } +} diff --git a/Sources/SwiftUICharts/Charts/PieChart/PieChartHelpers.swift b/Sources/SwiftUICharts/Charts/PieChart/PieChartHelpers.swift new file mode 100644 index 00000000..d9c68d8b --- /dev/null +++ b/Sources/SwiftUICharts/Charts/PieChart/PieChartHelpers.swift @@ -0,0 +1,34 @@ +import SwiftUI + +func isPointInCircle(point: CGPoint, circleRect: CGRect) -> Bool { + let r = min(circleRect.width, circleRect.height) / 2 + let center = CGPoint(x: circleRect.midX, y: circleRect.midY) + let dx = point.x - center.x + let dy = point.y - center.y + let distance = sqrt(dx * dx + dy * dy) + return distance <= r +} + +func degree(for point: CGPoint, inCircleRect circleRect: CGRect) -> Double { + let center = CGPoint(x: circleRect.midX, y: circleRect.midY) + let dx = point.x - center.x + let dy = point.y - center.y + let acuteDegree = Double(atan(dy / dx)) * (180 / .pi) + + let isInBottomRight = dx >= 0 && dy >= 0 + let isInBottomLeft = dx <= 0 && dy >= 0 + let isInTopLeft = dx <= 0 && dy <= 0 + let isInTopRight = dx >= 0 && dy <= 0 + + if isInBottomRight { + return acuteDegree + } else if isInBottomLeft { + return 180 - abs(acuteDegree) + } else if isInTopLeft { + return 180 + abs(acuteDegree) + } else if isInTopRight { + return 360 - abs(acuteDegree) + } + + return 0 +} diff --git a/Sources/SwiftUICharts/Charts/PieChart/PieChartRow.swift b/Sources/SwiftUICharts/Charts/PieChart/PieChartRow.swift new file mode 100644 index 00000000..f812a17e --- /dev/null +++ b/Sources/SwiftUICharts/Charts/PieChart/PieChartRow.swift @@ -0,0 +1,69 @@ +import SwiftUI + +/// A single "row" (slice) of data, a view in a `PieChart` +public struct PieChartRow: View { + @ObservedObject var chartData: ChartData + @EnvironmentObject var chartValue: ChartValue + + var style: ChartStyle + + var slices: [PieSlice] { + var tempSlices: [PieSlice] = [] + var lastEndDeg: Double = 0 + let maxValue: Double = chartData.points.reduce(0, +) + + for slice in chartData.points { + let normalized: Double = Double(slice) / (maxValue == 0 ? 1 : maxValue) + let startDeg = lastEndDeg + let endDeg = lastEndDeg + (normalized * 360) + lastEndDeg = endDeg + tempSlices.append(PieSlice(startDeg: startDeg, endDeg: endDeg, value: slice)) + } + + return tempSlices + } + + @State private var currentTouchedIndex = -1 { + didSet { + if oldValue != currentTouchedIndex { + chartValue.interactionInProgress = currentTouchedIndex != -1 + guard currentTouchedIndex != -1 else { return } + chartValue.currentValue = slices[currentTouchedIndex].value + } + } + } + + public var body: some View { + GeometryReader { geometry in + ZStack { + ForEach(0.. touchDegree }) ?? -1 + } else { + currentTouchedIndex = -1 + } + }) + .onEnded({ value in + currentTouchedIndex = -1 + }) + ) + } + } +} diff --git a/Sources/SwiftUICharts/Charts/RingsChart/Ring.swift b/Sources/SwiftUICharts/Charts/RingsChart/Ring.swift new file mode 100644 index 00000000..dee10540 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/RingsChart/Ring.swift @@ -0,0 +1,187 @@ +// +// Ring.swift +// ChartViewV2Demo +// +// Created by Dan Wood on 8/20/20. +// Based on article and playground code by Frank Jia +// https://medium.com/@frankjia/creating-activity-rings-in-swiftui-11ef7d336676 + +import SwiftUI + + +extension Double { + func toRadians() -> Double { + return self * Double.pi / 180 + } + func toCGFloat() -> CGFloat { + return CGFloat(self) + } +} + +struct RingShape: Shape { + /// Helper function to convert percent values to angles in degrees + /// - Parameters: + /// - percent: percent, greater than 100 is OK + /// - startAngle: angle to add after converting + /// - Returns: angle in degrees + static func percentToAngle(percent: Double, startAngle: Double) -> Double { + (percent / 100 * 360) + startAngle + } + private var percent: Double + private var startAngle: Double + private let drawnClockwise: Bool + + // This allows animations to run smoothly for percent values + var animatableData: Double { + get { + return percent + } + set { + percent = newValue + } + } + + init(percent: Double = 100, startAngle: Double = -90, drawnClockwise: Bool = false) { + self.percent = percent + self.startAngle = startAngle + self.drawnClockwise = drawnClockwise + } + + /// This draws a simple arc from the start angle to the end angle + /// + /// - Parameter rect: The frame of reference for describing this shape. + /// - Returns: A path that describes this shape. + func path(in rect: CGRect) -> Path { + let width = rect.width + let height = rect.height + let radius = min(width, height) / 2 + let center = CGPoint(x: width / 2, y: height / 2) + let endAngle = Angle(degrees: RingShape.percentToAngle(percent: self.percent, startAngle: self.startAngle)) + return Path { path in + path.addArc(center: center, radius: radius, startAngle: Angle(degrees: startAngle), endAngle: endAngle, clockwise: drawnClockwise) + } + } +} + +struct Ring: View { + + private static let ShadowColor: Color = Color.black.opacity(0.2) + private static let ShadowRadius: CGFloat = 5 + private static let ShadowOffsetMultiplier: CGFloat = ShadowRadius + 2 + + private let ringWidth: CGFloat + private let percent: Double + private let foregroundColor: ColorGradient + private let startAngle: Double = -90 + + private let touchLocation: CGFloat + + + + private var gradientStartAngle: Double { + self.percent >= 100 ? relativePercentageAngle - 360 : startAngle + } + private var absolutePercentageAngle: Double { + RingShape.percentToAngle(percent: self.percent, startAngle: 0) + } + private var relativePercentageAngle: Double { + // Take into account the startAngle + absolutePercentageAngle + startAngle + } + private var lastGradientColor: Color { + self.foregroundColor.endColor + } + + private var ringGradient: AngularGradient { + AngularGradient( + gradient: self.foregroundColor.gradient, + center: .center, + startAngle: Angle(degrees: self.gradientStartAngle), + endAngle: Angle(degrees: relativePercentageAngle) + ) + } + + init(ringWidth: CGFloat, percent: Double, foregroundColor: ColorGradient, touchLocation:CGFloat) { + self.ringWidth = ringWidth + self.percent = percent + self.foregroundColor = foregroundColor + self.touchLocation = touchLocation + } + + var body: some View { + GeometryReader { geometry in + ZStack { + // Background for the ring. Use the final color with reduced opacity + RingShape() + .stroke(style: StrokeStyle(lineWidth: self.ringWidth)) + .fill(lastGradientColor.opacity(0.142857)) + // Foreground + RingShape(percent: self.percent, startAngle: self.startAngle) + .stroke(style: StrokeStyle(lineWidth: self.ringWidth, lineCap: .round)) + .fill(self.ringGradient) + // End of ring with drop shadow + if self.getShowShadow(frame: geometry.size) { + Circle() + .fill(self.lastGradientColor) + .frame(width: self.ringWidth, height: self.ringWidth, alignment: .center) + .offset(x: self.getEndCircleLocation(frame: geometry.size).0, + y: self.getEndCircleLocation(frame: geometry.size).1) + .shadow(color: Ring.ShadowColor, + radius: Ring.ShadowRadius, + x: self.getEndCircleShadowOffset().0, + y: self.getEndCircleShadowOffset().1) + } + } + } + // Padding to ensure that the entire ring fits within the view size allocated + .padding(self.ringWidth / 2) + } + + private func getEndCircleLocation(frame: CGSize) -> (CGFloat, CGFloat) { + // Get angle of the end circle with respect to the start angle + let angleOfEndInRadians: Double = relativePercentageAngle.toRadians() + let offsetRadius = min(frame.width, frame.height) / 2 + return (offsetRadius * cos(angleOfEndInRadians).toCGFloat(), offsetRadius * sin(angleOfEndInRadians).toCGFloat()) + } + + private func getEndCircleShadowOffset() -> (CGFloat, CGFloat) { + let angleForOffset = absolutePercentageAngle + (self.startAngle + 90) + let angleForOffsetInRadians = angleForOffset.toRadians() + let relativeXOffset = cos(angleForOffsetInRadians) + let relativeYOffset = sin(angleForOffsetInRadians) + let xOffset = relativeXOffset.toCGFloat() * Ring.ShadowOffsetMultiplier + let yOffset = relativeYOffset.toCGFloat() * Ring.ShadowOffsetMultiplier + return (xOffset, yOffset) + } + + private func getShowShadow(frame: CGSize) -> Bool { + if self.percent >= 100 { + return true + } + let circleRadius = min(frame.width, frame.height) / 2 + let remainingAngleInRadians = (360 - absolutePercentageAngle).toRadians().toCGFloat() + + return circleRadius * remainingAngleInRadians <= self.ringWidth + } +} + +struct Ring_Previews: PreviewProvider { + static var previews: some View { + VStack { + Ring( + ringWidth: 50, percent: 5 , + foregroundColor: ColorGradient(.green, .blue), touchLocation: -1.0 + ) + .frame(width: 200, height: 200) + + Ring( + ringWidth: 20, percent: 110 , + foregroundColor: ColorGradient(.red, .blue), touchLocation: -1.0 + ) + .frame(width: 200, height: 200) + + + + } + } +} diff --git a/Sources/SwiftUICharts/Charts/RingsChart/RingsChart.swift b/Sources/SwiftUICharts/Charts/RingsChart/RingsChart.swift new file mode 100644 index 00000000..a46e0408 --- /dev/null +++ b/Sources/SwiftUICharts/Charts/RingsChart/RingsChart.swift @@ -0,0 +1,15 @@ +import SwiftUI + +public struct RingsChart: ChartBase { + public var chartData = ChartData() + + @EnvironmentObject var style: ChartStyle + + // TODO - should put background opacity, ring width & spacing as chart style values + + public var body: some View { + RingsChartRow(width:10.0, spacing:5.0, chartData: chartData, style: style) + } + + public init() {} +} diff --git a/Sources/SwiftUICharts/Charts/RingsChart/RingsChartRow.swift b/Sources/SwiftUICharts/Charts/RingsChart/RingsChartRow.swift new file mode 100644 index 00000000..7e2dd82b --- /dev/null +++ b/Sources/SwiftUICharts/Charts/RingsChart/RingsChartRow.swift @@ -0,0 +1,133 @@ +// +// RingsChartRow.swift +// ChartViewV2Demo +// +// Created by Dan Wood on 8/20/20. +// + +import SwiftUI + +public struct RingsChartRow: View { + + var width : CGFloat + var spacing : CGFloat + + @EnvironmentObject var chartValue: ChartValue + @ObservedObject var chartData: ChartData + @State var touchRadius: CGFloat = -1.0 + + var style: ChartStyle + + public var body: some View { + GeometryReader { geometry in + + ZStack { + + // FIXME: Why is background circle offset strangely when frame isn't specified? See Preview below. Related to the .animation somehow ???? + Circle() + .fill(RadialGradient(gradient: self.style.backgroundColor.gradient, center: .center, startRadius: min(geometry.size.width, geometry.size.height)/2.0, endRadius: 1.0)) + + ForEach(0.. Bool { + let radius = min(size.width, size.height) / 2.0 + return index == self.touchedCircleIndex(maxRadius: radius) + } + + /// Find which circle has been touched + /// - Parameter maxRadius: radius of overall view circle + /// - Returns: which circle index was touched, if found. 0 = outer, 1 = next one in, etc. + func touchedCircleIndex(maxRadius: CGFloat) -> Int? { + guard self.chartData.data.count > 0 else { return nil } // no data + + // Pretend actual circle goes ½ the inter-ring spacing out, so that a touch + // is registered on either side of each ring + let radialDistanceFromEdge = (maxRadius + spacing/2) - self.touchRadius; + guard radialDistanceFromEdge >= 0 else { return nil } // touched outside of ring + + let touchIndex = Int(floor(radialDistanceFromEdge / (width + spacing))) + + if touchIndex >= self.chartData.data.count { return nil } // too far from outside, no ring + + return touchIndex + } + + /// Description + /// - Parameter maxRadius: radius of overall view circle + /// - Returns: percentage value of the touched circle, based on `touchRadius` if found + func getCurrentValue(maxRadius: CGFloat) -> Double? { + + guard let index = self.touchedCircleIndex(maxRadius: maxRadius) else { return nil } + return self.chartData.points[index] + } +} + + +struct RingsChartRow_Previews: PreviewProvider { + static var previews: some View { + + let multiStyle = ChartStyle(backgroundColor: ColorGradient(Color.black.opacity(0.05), Color.white), + foregroundColor: + [ColorGradient(.purple, .blue), + ColorGradient(.orange, .red), + ColorGradient(.green, .yellow), + ]) + + return RingsChartRow(width:20.0, spacing:10.0, chartData: ChartData([25,50,75,100,125]), style: multiStyle) + + // and why does this not get centered when frame isn't specified? + .frame(width:300, height:400) + } +} + + diff --git a/Sources/SwiftUICharts/Helpers.swift b/Sources/SwiftUICharts/Helpers.swift deleted file mode 100644 index a79bce54..00000000 --- a/Sources/SwiftUICharts/Helpers.swift +++ /dev/null @@ -1,278 +0,0 @@ -// -// File.swift -// -// -// Created by András Samu on 2019. 07. 19.. -// - -import Foundation -import SwiftUI - -public struct Colors { - public static let color1:Color = Color(hexString: "#E2FAE7") - public static let color1Accent:Color = Color(hexString: "#72BF82") - public static let color2:Color = Color(hexString: "#EEF1FF") - public static let color2Accent:Color = Color(hexString: "#4266E8") - public static let color3:Color = Color(hexString: "#FCECEA") - public static let color3Accent:Color = Color(hexString: "#E1614C") - public static let OrangeEnd:Color = Color(hexString: "#FF782C") - public static let OrangeStart:Color = Color(hexString: "#EC2301") - public static let LegendText:Color = Color(hexString: "#A7A6A8") - public static let LegendColor:Color = Color(hexString: "#E8E7EA") - public static let LegendDarkColor:Color = Color(hexString: "#545454") - public static let IndicatorKnob:Color = Color(hexString: "#FF57A6") - public static let GradientUpperBlue:Color = Color(hexString: "#C2E8FF") - public static let GradinetUpperBlue1:Color = Color(hexString: "#A8E1FF") - public static let GradientPurple:Color = Color(hexString: "#7B75FF") - public static let GradientNeonBlue:Color = Color(hexString: "#6FEAFF") - public static let GradientLowerBlue:Color = Color(hexString: "#F1F9FF") - public static let DarkPurple:Color = Color(hexString: "#1B205E") - public static let BorderBlue:Color = Color(hexString: "#4EBCFF") -} - -public struct GradientColor { - public let start: Color - public let end: Color - - public init(start: Color, end: Color) { - self.start = start - self.end = end - } - - public func getGradient() -> Gradient { - return Gradient(colors: [start, end]) - } -} - -public struct GradientColors { - public static let orange = GradientColor(start: Colors.OrangeStart, end: Colors.OrangeEnd) - public static let blue = GradientColor(start: Colors.GradientPurple, end: Colors.GradientNeonBlue) - public static let green = GradientColor(start: Color(hexString: "0BCDF7"), end: Color(hexString: "A2FEAE")) - public static let blu = GradientColor(start: Color(hexString: "0591FF"), end: Color(hexString: "29D9FE")) - public static let bluPurpl = GradientColor(start: Color(hexString: "4ABBFB"), end: Color(hexString: "8C00FF")) - public static let purple = GradientColor(start: Color(hexString: "741DF4"), end: Color(hexString: "C501B0")) - public static let prplPink = GradientColor(start: Color(hexString: "BC05AF"), end: Color(hexString: "FF1378")) - public static let prplNeon = GradientColor(start: Color(hexString: "FE019A"), end: Color(hexString: "FE0BF4")) - public static let orngPink = GradientColor(start: Color(hexString: "FF8E2D"), end: Color(hexString: "FF4E7A")) -} - -public struct Styles { - public static let lineChartStyleOne = ChartStyle( - backgroundColor: Color.white, - accentColor: Colors.OrangeStart, - secondGradientColor: Colors.OrangeEnd, - textColor: Color.black, - legendTextColor: Color.gray, - dropShadowColor: Color.gray) - - public static let barChartStyleOrangeLight = ChartStyle( - backgroundColor: Color.white, - accentColor: Colors.OrangeStart, - secondGradientColor: Colors.OrangeEnd, - textColor: Color.black, - legendTextColor: Color.gray, - dropShadowColor: Color.gray) - - public static let barChartStyleOrangeDark = ChartStyle( - backgroundColor: Color.black, - accentColor: Colors.OrangeStart, - secondGradientColor: Colors.OrangeEnd, - textColor: Color.white, - legendTextColor: Color.gray, - dropShadowColor: Color.gray) - - public static let barChartStyleNeonBlueLight = ChartStyle( - backgroundColor: Color.white, - accentColor: Colors.GradientNeonBlue, - secondGradientColor: Colors.GradientPurple, - textColor: Color.black, - legendTextColor: Color.gray, - dropShadowColor: Color.gray) - - public static let barChartStyleNeonBlueDark = ChartStyle( - backgroundColor: Color.black, - accentColor: Colors.GradientNeonBlue, - secondGradientColor: Colors.GradientPurple, - textColor: Color.white, - legendTextColor: Color.gray, - dropShadowColor: Color.gray) - - public static let barChartMidnightGreenDark = ChartStyle( - backgroundColor: Color(hexString: "#36534D"), //3B5147, 313D34 - accentColor: Color(hexString: "#FFD603"), - secondGradientColor: Color(hexString: "#FFCA04"), - textColor: Color.white, - legendTextColor: Color(hexString: "#D2E5E1"), - dropShadowColor: Color.gray) - - public static let barChartMidnightGreenLight = ChartStyle( - backgroundColor: Color.white, - accentColor: Color(hexString: "#84A094"), //84A094 , 698378 - secondGradientColor: Color(hexString: "#50675D"), - textColor: Color.black, - legendTextColor:Color.gray, - dropShadowColor: Color.gray) - - public static let pieChartStyleOne = ChartStyle( - backgroundColor: Color.white, - accentColor: Colors.OrangeEnd, - secondGradientColor: Colors.OrangeStart, - textColor: Color.black, - legendTextColor: Color.gray, - dropShadowColor: Color.gray) - - public static let lineViewDarkMode = ChartStyle( - backgroundColor: Color.black, - accentColor: Colors.OrangeStart, - secondGradientColor: Colors.OrangeEnd, - textColor: Color.white, - legendTextColor: Color.white, - dropShadowColor: Color.gray) -} - -public struct ChartForm { - #if os(watchOS) - public static let small = CGSize(width:120, height:90) - public static let medium = CGSize(width:120, height:160) - public static let large = CGSize(width:180, height:90) - public static let detail = CGSize(width:180, height:160) - #else - public static let small = CGSize(width:180, height:120) - public static let medium = CGSize(width:180, height:240) - public static let large = CGSize(width:360, height:120) - public static let detail = CGSize(width:180, height:120) - #endif - - -} - -public class ChartStyle { - public var backgroundColor: Color - public var accentColor: Color - public var gradientColor: GradientColor - public var textColor: Color - public var legendTextColor: Color - public var dropShadowColor: Color - public weak var darkModeStyle: ChartStyle? - - public init(backgroundColor: Color, accentColor: Color, secondGradientColor: Color, textColor: Color, legendTextColor: Color, dropShadowColor: Color){ - self.backgroundColor = backgroundColor - self.accentColor = accentColor - self.gradientColor = GradientColor(start: accentColor, end: secondGradientColor) - self.textColor = textColor - self.legendTextColor = legendTextColor - self.dropShadowColor = dropShadowColor - } - - public init(backgroundColor: Color, accentColor: Color, gradientColor: GradientColor, textColor: Color, legendTextColor: Color, dropShadowColor: Color){ - self.backgroundColor = backgroundColor - self.accentColor = accentColor - self.gradientColor = gradientColor - self.textColor = textColor - self.legendTextColor = legendTextColor - self.dropShadowColor = dropShadowColor - } - - public init(formSize: CGSize){ - self.backgroundColor = Color.white - self.accentColor = Colors.OrangeStart - self.gradientColor = GradientColors.orange - self.legendTextColor = Color.gray - self.textColor = Color.black - self.dropShadowColor = Color.gray - } -} - -public class ChartData: ObservableObject, Identifiable { - @Published var points: [(String,Double)] - var valuesGiven: Bool = false - var ID = UUID() - - public init(points:[N]) { - self.points = points.map{("", Double($0))} - } - public init(values:[(String,N)]){ - self.points = values.map{($0.0, Double($0.1))} - self.valuesGiven = true - } - public init(values:[(String,N)]){ - self.points = values.map{($0.0, Double($0.1))} - self.valuesGiven = true - } - public init(numberValues:[(N,N)]){ - self.points = numberValues.map{(String($0.0), Double($0.1))} - self.valuesGiven = true - } - public init(numberValues:[(N,N)]){ - self.points = numberValues.map{(String($0.0), Double($0.1))} - self.valuesGiven = true - } - - public func onlyPoints() -> [Double] { - return self.points.map{ $0.1 } - } -} - -public class MultiLineChartData: ChartData { - var gradient: GradientColor - - public init(points:[N], gradient: GradientColor) { - self.gradient = gradient - super.init(points: points) - } - - public init(points:[N], color: Color) { - self.gradient = GradientColor(start: color, end: color) - super.init(points: points) - } - - public func getGradient() -> GradientColor { - return self.gradient - } -} - -public class TestData{ - static public var data:ChartData = ChartData(points: [37,72,51,22,39,47,66,85,50]) - static public var values:ChartData = ChartData(values: [("2017 Q3",220), - ("2017 Q4",1550), - ("2018 Q1",8180), - ("2018 Q2",18440), - ("2018 Q3",55840), - ("2018 Q4",63150), ("2019 Q1",50900), ("2019 Q2",77550), ("2019 Q3",79600), ("2019 Q4",92550)]) - -} - -extension Color { - init(hexString: String) { - let hex = hexString.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) - var int = UInt64() - Scanner(string: hex).scanHexInt64(&int) - let r, g, b: UInt64 - switch hex.count { - case 3: // RGB (12-bit) - (r, g, b) = ((int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) - case 6: // RGB (24-bit) - (r, g, b) = (int >> 16, int >> 8 & 0xFF, int & 0xFF) - case 8: // ARGB (32-bit) - (r, g, b) = (int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) - default: - (r, g, b) = (0, 0, 0) - } - self.init(red: Double(r) / 255, green: Double(g) / 255, blue: Double(b) / 255) - } -} - -class HapticFeedback { - #if os(watchOS) - //watchOS implementation - static func playSelection() -> Void { - WKInterfaceDevice.current().play(.click) - } - #else - //iOS implementation - let selectionFeedbackGenerator = UISelectionFeedbackGenerator() - static func playSelection() -> Void { - UISelectionFeedbackGenerator().selectionChanged() - } - #endif -} diff --git a/Sources/SwiftUICharts/LineChart/Legend.swift b/Sources/SwiftUICharts/LineChart/Legend.swift deleted file mode 100644 index b613cb06..00000000 --- a/Sources/SwiftUICharts/LineChart/Legend.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// Legend.swift -// LineChart -// -// Created by András Samu on 2019. 09. 02.. -// Copyright © 2019. András Samu. All rights reserved. -// - -import SwiftUI - -struct Legend: View { - @ObservedObject var data: ChartData - @Binding var frame: CGRect - @Binding var hideHorizontalLines: Bool - @Environment(\.colorScheme) var colorScheme: ColorScheme - let padding:CGFloat = 3 - - var stepWidth: CGFloat { - if data.points.count < 2 { - return 0 - } - return frame.size.width / CGFloat(data.points.count-1) - } - var stepHeight: CGFloat { - let points = self.data.onlyPoints() - if let min = points.min(), let max = points.max(), min != max { - if (min < 0){ - return (frame.size.height-padding) / CGFloat(max - min) - }else{ - return (frame.size.height-padding) / CGFloat(max + min) - } - } - return 0 - } - - var min: CGFloat { - let points = self.data.onlyPoints() - return CGFloat(points.min() ?? 0) - } - - var body: some View { - ZStack(alignment: .topLeading){ - ForEach((0...4), id: \.self) { height in - HStack(alignment: .center){ - Text("\(self.getYLegendSafe(height: height), specifier: "%.2f")").offset(x: 0, y: self.getYposition(height: height) ) - .foregroundColor(Colors.LegendText) - .font(.caption) - self.line(atHeight: self.getYLegendSafe(height: height), width: self.frame.width) - .stroke(self.colorScheme == .dark ? Colors.LegendDarkColor : Colors.LegendColor, style: StrokeStyle(lineWidth: 1.5, lineCap: .round, dash: [5,height == 0 ? 0 : 10])) - .opacity((self.hideHorizontalLines && height != 0) ? 0 : 1) - .rotationEffect(.degrees(180), anchor: .center) - .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0)) - .animation(.easeOut(duration: 0.2)) - .clipped() - } - - } - - } - } - - func getYLegendSafe(height:Int)->CGFloat{ - if let legend = getYLegend() { - return CGFloat(legend[height]) - } - return 0 - } - - func getYposition(height: Int)-> CGFloat { - if let legend = getYLegend() { - return (self.frame.height-((CGFloat(legend[height]) - min)*self.stepHeight))-(self.frame.height/2) - } - return 0 - - } - - func line(atHeight: CGFloat, width: CGFloat) -> Path { - var hLine = Path() - hLine.move(to: CGPoint(x:5, y: (atHeight-min)*stepHeight)) - hLine.addLine(to: CGPoint(x: width, y: (atHeight-min)*stepHeight)) - return hLine - } - - func getYLegend() -> [Double]? { - let points = self.data.onlyPoints() - guard let max = points.max() else { return nil } - guard let min = points.min() else { return nil } - let step = Double(max - min)/4 - return [min+step * 0, min+step * 1, min+step * 2, min+step * 3, min+step * 4] - } -} - -struct Legend_Previews: PreviewProvider { - static var previews: some View { - GeometryReader{ geometry in - Legend(data: ChartData(points: [0.2,0.4,1.4,4.5]), frame: .constant(geometry.frame(in: .local)), hideHorizontalLines: .constant(false)) - }.frame(width: 320, height: 200) - } -} diff --git a/Sources/SwiftUICharts/LineChart/Line.swift b/Sources/SwiftUICharts/LineChart/Line.swift deleted file mode 100644 index e85a8c3a..00000000 --- a/Sources/SwiftUICharts/LineChart/Line.swift +++ /dev/null @@ -1,107 +0,0 @@ -// -// Line.swift -// LineChart -// -// Created by András Samu on 2019. 08. 30.. -// Copyright © 2019. András Samu. All rights reserved. -// - -import SwiftUI - -public struct Line: View { - @ObservedObject var data: ChartData - @Binding var frame: CGRect - @Binding var touchLocation: CGPoint - @Binding var showIndicator: Bool - @Binding var minDataValue: Double? - @Binding var maxDataValue: Double? - @State private var showFull: Bool = false - @State var showBackground: Bool = true - var gradient: GradientColor = GradientColor(start: Colors.GradientPurple, end: Colors.GradientNeonBlue) - var index:Int = 0 - let padding:CGFloat = 30 - var curvedLines: Bool = true - var stepWidth: CGFloat { - if data.points.count < 2 { - return 0 - } - return frame.size.width / CGFloat(data.points.count-1) - } - var stepHeight: CGFloat { - var min: Double? - var max: Double? - let points = self.data.onlyPoints() - if minDataValue != nil && maxDataValue != nil { - min = minDataValue! - max = maxDataValue! - print(min,max) - }else if let minPoint = points.min(), let maxPoint = points.max(), minPoint != maxPoint { - min = minPoint - max = maxPoint - }else { - return 0 - } - if let min = min, let max = max, min != max { - if (min <= 0){ - return (frame.size.height-padding) / CGFloat(max - min) - }else{ - return (frame.size.height-padding) / CGFloat(max + min) - } - } - return 0 - } - var path: Path { - let points = self.data.onlyPoints() - return curvedLines ? Path.quadCurvedPathWithPoints(points: points, step: CGPoint(x: stepWidth, y: stepHeight), globalOffset: minDataValue) : Path.linePathWithPoints(points: points, step: CGPoint(x: stepWidth, y: stepHeight)) - } - var closedPath: Path { - let points = self.data.onlyPoints() - return curvedLines ? Path.quadClosedCurvedPathWithPoints(points: points, step: CGPoint(x: stepWidth, y: stepHeight), globalOffset: minDataValue) : Path.closedLinePathWithPoints(points: points, step: CGPoint(x: stepWidth, y: stepHeight)) - } - - public var body: some View { - ZStack { - if(self.showFull && self.showBackground){ - self.closedPath - .fill(LinearGradient(gradient: Gradient(colors: [Colors.GradientUpperBlue, .white]), startPoint: .bottom, endPoint: .top)) - .rotationEffect(.degrees(180), anchor: .center) - .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0)) - .transition(.opacity) - .animation(.easeIn(duration: 1.6)) - } - self.path - .trim(from: 0, to: self.showFull ? 1:0) - .stroke(LinearGradient(gradient: gradient.getGradient(), startPoint: .leading, endPoint: .trailing) ,style: StrokeStyle(lineWidth: 3, lineJoin: .round)) - .rotationEffect(.degrees(180), anchor: .center) - .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0)) - .animation(Animation.easeOut(duration: 1.2).delay(Double(self.index)*0.4)) - .onAppear { - self.showFull = true - } - .onDisappear { - self.showFull = false - } - .drawingGroup() - if(self.showIndicator) { - IndicatorPoint() - .position(self.getClosestPointOnPath(touchLocation: self.touchLocation)) - .rotationEffect(.degrees(180), anchor: .center) - .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0)) - } - } - } - - func getClosestPointOnPath(touchLocation: CGPoint) -> CGPoint { - let closest = self.path.point(to: touchLocation.x) - return closest - } - -} - -struct Line_Previews: PreviewProvider { - static var previews: some View { - GeometryReader{ geometry in - Line(data: ChartData(points: [12,-230,10,54]), frame: .constant(geometry.frame(in: .local)), touchLocation: .constant(CGPoint(x: 100, y: 12)), showIndicator: .constant(true), minDataValue: .constant(nil), maxDataValue: .constant(nil)) - }.frame(width: 320, height: 160) - } -} diff --git a/Sources/SwiftUICharts/LineChart/LineChartView.swift b/Sources/SwiftUICharts/LineChart/LineChartView.swift deleted file mode 100644 index 2726f083..00000000 --- a/Sources/SwiftUICharts/LineChart/LineChartView.swift +++ /dev/null @@ -1,148 +0,0 @@ -// -// LineCard.swift -// LineChart -// -// Created by András Samu on 2019. 08. 31.. -// Copyright © 2019. András Samu. All rights reserved. -// - -import SwiftUI - -public struct LineChartView: View { - @Environment(\.colorScheme) var colorScheme: ColorScheme - @ObservedObject var data:ChartData - public var title: String - public var legend: String? - public var style: ChartStyle - public var darkModeStyle: ChartStyle - - public var formSize:CGSize - public var dropShadow: Bool - public var valueSpecifier:String - - @State private var touchLocation:CGPoint = .zero - @State private var showIndicatorDot: Bool = false - @State private var currentValue: Double = 2 { - didSet{ - if (oldValue != self.currentValue && showIndicatorDot) { - HapticFeedback.playSelection() - } - - } - } - let frame = CGSize(width: 180, height: 120) - private var rateValue: Int? - - public init(data: [Double], - title: String, - legend: String? = nil, - style: ChartStyle = Styles.lineChartStyleOne, - form: CGSize? = ChartForm.medium, - rateValue: Int? = 14, - dropShadow: Bool? = true, - valueSpecifier: String? = "%.1f") { - - self.data = ChartData(points: data) - self.title = title - self.legend = legend - self.style = style - self.darkModeStyle = style.darkModeStyle != nil ? style.darkModeStyle! : Styles.lineViewDarkMode - self.formSize = form! - self.dropShadow = dropShadow! - self.valueSpecifier = valueSpecifier! - self.rateValue = rateValue - } - - public var body: some View { - ZStack(alignment: .center){ - RoundedRectangle(cornerRadius: 20) - .fill(self.colorScheme == .dark ? self.darkModeStyle.backgroundColor : self.style.backgroundColor) - .frame(width: frame.width, height: 240, alignment: .center) - .shadow(color: self.style.dropShadowColor, radius: self.dropShadow ? 8 : 0) - VStack(alignment: .leading){ - if(!self.showIndicatorDot){ - VStack(alignment: .leading, spacing: 8){ - Text(self.title) - .font(.title) - .bold() - .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.textColor : self.style.textColor) - if (self.legend != nil){ - Text(self.legend!) - .font(.callout) - .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.legendTextColor :self.style.legendTextColor) - } - HStack { - - if (self.rateValue ?? 0 != 0) - { - if (self.rateValue ?? 0 >= 0){ - Image(systemName: "arrow.up") - }else{ - Image(systemName: "arrow.down") - } - Text("\(self.rateValue!)%") - } - } - } - .transition(.opacity) - .animation(.easeIn(duration: 0.1)) - .padding([.leading, .top]) - }else{ - HStack{ - Spacer() - Text("\(self.currentValue, specifier: self.valueSpecifier)") - .font(.system(size: 41, weight: .bold, design: .default)) - .offset(x: 0, y: 30) - Spacer() - } - .transition(.scale) - } - Spacer() - GeometryReader{ geometry in - Line(data: self.data, - frame: .constant(geometry.frame(in: .local)), - touchLocation: self.$touchLocation, - showIndicator: self.$showIndicatorDot, - minDataValue: .constant(nil), - maxDataValue: .constant(nil) - ) - } - .frame(width: frame.width, height: frame.height + 30) - .clipShape(RoundedRectangle(cornerRadius: 20)) - .offset(x: 0, y: 0) - }.frame(width: self.formSize.width, height: self.formSize.height) - } - .gesture(DragGesture() - .onChanged({ value in - self.touchLocation = value.location - self.showIndicatorDot = true - self.getClosestDataPoint(toPoint: value.location, width:self.frame.width, height: self.frame.height) - }) - .onEnded({ value in - self.showIndicatorDot = false - }) - ) - } - - @discardableResult func getClosestDataPoint(toPoint: CGPoint, width:CGFloat, height: CGFloat) -> CGPoint { - let points = self.data.onlyPoints() - let stepWidth: CGFloat = width / CGFloat(points.count-1) - let stepHeight: CGFloat = height / CGFloat(points.max()! + points.min()!) - - let index:Int = Int(round((toPoint.x)/stepWidth)) - if (index >= 0 && index < points.count){ - self.currentValue = points[index] - return CGPoint(x: CGFloat(index)*stepWidth, y: CGFloat(points[index])*stepHeight) - } - return .zero - } -} - -struct WidgetView_Previews: PreviewProvider { - static var previews: some View { - Group { - LineChartView(data: [8,23,54,32,12,37,7,23,43], title: "Line chart", legend: "Basic") - .environment(\.colorScheme, .light) - } - } -} diff --git a/Sources/SwiftUICharts/LineChart/LineView.swift b/Sources/SwiftUICharts/LineChart/LineView.swift deleted file mode 100644 index c4313aaf..00000000 --- a/Sources/SwiftUICharts/LineChart/LineView.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// LineView.swift -// LineChart -// -// Created by András Samu on 2019. 09. 02.. -// Copyright © 2019. András Samu. All rights reserved. -// - -import SwiftUI - -public struct LineView: View { - @ObservedObject var data: ChartData - public var title: String? - public var legend: String? - public var style: ChartStyle - public var darkModeStyle: ChartStyle - public var valueSpecifier:String - - @Environment(\.colorScheme) var colorScheme: ColorScheme - @State private var showLegend = false - @State private var dragLocation:CGPoint = .zero - @State private var indicatorLocation:CGPoint = .zero - @State private var closestPoint: CGPoint = .zero - @State private var opacity:Double = 0 - @State private var currentDataNumber: Double = 0 - @State private var hideHorizontalLines: Bool = false - - public init(data: [Double], - title: String? = nil, - legend: String? = nil, - style: ChartStyle = Styles.lineChartStyleOne, - valueSpecifier: String? = "%.1f") { - - self.data = ChartData(points: data) - self.title = title - self.legend = legend - self.style = style - self.valueSpecifier = valueSpecifier! - self.darkModeStyle = style.darkModeStyle != nil ? style.darkModeStyle! : Styles.lineViewDarkMode - } - - public var body: some View { - GeometryReader{ geometry in - VStack(alignment: .leading, spacing: 8) { - Group{ - if (self.title != nil){ - Text(self.title!) - .font(.title) - .bold().foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.textColor : self.style.textColor) - } - if (self.legend != nil){ - Text(self.legend!) - .font(.callout) - .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.legendTextColor : self.style.legendTextColor) - } - }.offset(x: 0, y: 20) - ZStack{ - GeometryReader{ reader in - Rectangle() - .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.backgroundColor : self.style.backgroundColor) - if(self.showLegend){ - Legend(data: self.data, - frame: .constant(reader.frame(in: .local)), hideHorizontalLines: self.$hideHorizontalLines) - .transition(.opacity) - .animation(Animation.easeOut(duration: 1).delay(1)) - } - Line(data: self.data, - frame: .constant(CGRect(x: 0, y: 0, width: reader.frame(in: .local).width - 30, height: reader.frame(in: .local).height)), - touchLocation: self.$indicatorLocation, - showIndicator: self.$hideHorizontalLines, - minDataValue: .constant(nil), - maxDataValue: .constant(nil), - showBackground: false, - gradient: self.style.gradientColor - ) - .offset(x: 30, y: 0) - .onAppear(){ - self.showLegend = true - } - .onDisappear(){ - self.showLegend = false - } - } - .frame(width: geometry.frame(in: .local).size.width, height: 240) - .offset(x: 0, y: 40 ) - MagnifierRect(currentNumber: self.$currentDataNumber, valueSpecifier: self.valueSpecifier) - .opacity(self.opacity) - .offset(x: self.dragLocation.x - geometry.frame(in: .local).size.width/2, y: 36) - } - .frame(width: geometry.frame(in: .local).size.width, height: 240) - .gesture(DragGesture() - .onChanged({ value in - self.dragLocation = value.location - self.indicatorLocation = CGPoint(x: max(value.location.x-30,0), y: 32) - self.opacity = 1 - self.closestPoint = self.getClosestDataPoint(toPoint: value.location, width: geometry.frame(in: .local).size.width-30, height: 240) - self.hideHorizontalLines = true - }) - .onEnded({ value in - self.opacity = 0 - self.hideHorizontalLines = false - }) - ) - } - } - } - - func getClosestDataPoint(toPoint: CGPoint, width:CGFloat, height: CGFloat) -> CGPoint { - let points = self.data.onlyPoints() - let stepWidth: CGFloat = width / CGFloat(points.count-1) - let stepHeight: CGFloat = height / CGFloat(points.max()! + points.min()!) - - let index:Int = Int(floor((toPoint.x-15)/stepWidth)) - if (index >= 0 && index < points.count){ - self.currentDataNumber = points[index] - return CGPoint(x: CGFloat(index)*stepWidth, y: CGFloat(points[index])*stepHeight) - } - return .zero - } -} - -struct LineView_Previews: PreviewProvider { - static var previews: some View { - LineView(data: [8,23,54,32,12,37,7,23,43], title: "Full chart", style: Styles.lineChartStyleOne) - } -} - diff --git a/Sources/SwiftUICharts/LineChart/MagnifierRect.swift b/Sources/SwiftUICharts/LineChart/MagnifierRect.swift deleted file mode 100644 index 4d3fd869..00000000 --- a/Sources/SwiftUICharts/LineChart/MagnifierRect.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// MagnifierRect.swift -// -// -// Created by Samu András on 2020. 03. 04.. -// - -import SwiftUI - -public struct MagnifierRect: View { - @Binding var currentNumber: Double - var valueSpecifier:String - @Environment(\.colorScheme) var colorScheme: ColorScheme - public var body: some View { - ZStack{ - Text("\(self.currentNumber, specifier: valueSpecifier)") - .font(.system(size: 18, weight: .bold)) - .offset(x: 0, y:-110) - .foregroundColor(self.colorScheme == .dark ? Color.white : Color.black) - if (self.colorScheme == .dark ){ - RoundedRectangle(cornerRadius: 16) - .stroke(Color.white, lineWidth: self.colorScheme == .dark ? 2 : 0) - .frame(width: 60, height: 260) - }else{ - RoundedRectangle(cornerRadius: 16) - .frame(width: 60, height: 280) - .foregroundColor(Color.white) - .shadow(color: Colors.LegendText, radius: 12, x: 0, y: 6 ) - .blendMode(.multiply) - } - } - } -} diff --git a/Sources/SwiftUICharts/LineChart/MultiLineChartView.swift b/Sources/SwiftUICharts/LineChart/MultiLineChartView.swift deleted file mode 100644 index 720da66d..00000000 --- a/Sources/SwiftUICharts/LineChart/MultiLineChartView.swift +++ /dev/null @@ -1,163 +0,0 @@ -// -// File.swift -// -// -// Created by Samu András on 2020. 02. 19.. -// - -import SwiftUI - -public struct MultiLineChartView: View { - @Environment(\.colorScheme) var colorScheme: ColorScheme - var data:[MultiLineChartData] - public var title: String - public var legend: String? - public var style: ChartStyle - public var darkModeStyle: ChartStyle - public var formSize:CGSize - public var dropShadow: Bool - public var valueSpecifier:String - - @State private var touchLocation:CGPoint = .zero - @State private var showIndicatorDot: Bool = false - @State private var currentValue: Double = 2 { - didSet{ - if (oldValue != self.currentValue && showIndicatorDot) { - HapticFeedback.playSelection() - } - - } - } - - var globalMin:Double { - if let min = data.flatMap({$0.onlyPoints()}).min() { - return min - } - return 0 - } - - var globalMax:Double { - if let max = data.flatMap({$0.onlyPoints()}).max() { - return max - } - return 0 - } - - let frame = CGSize(width: 180, height: 120) - private var rateValue: Int - - public init(data: [([Double], GradientColor)], - title: String, - legend: String? = nil, - style: ChartStyle = Styles.lineChartStyleOne, - form: CGSize? = ChartForm.medium, - rateValue: Int? = 14, - dropShadow: Bool? = true, - valueSpecifier: String? = "%.1f") { - - self.data = data.map({ MultiLineChartData(points: $0.0, gradient: $0.1)}) - self.title = title - self.legend = legend - self.style = style - self.darkModeStyle = style.darkModeStyle != nil ? style.darkModeStyle! : Styles.lineViewDarkMode - self.formSize = form! - self.rateValue = rateValue! - self.dropShadow = dropShadow! - self.valueSpecifier = valueSpecifier! - } - - public var body: some View { - ZStack(alignment: .center){ - RoundedRectangle(cornerRadius: 20) - .fill(self.colorScheme == .dark ? self.darkModeStyle.backgroundColor : self.style.backgroundColor) - .frame(width: frame.width, height: 240, alignment: .center) - .shadow(radius: self.dropShadow ? 8 : 0) - VStack(alignment: .leading){ - if(!self.showIndicatorDot){ - VStack(alignment: .leading, spacing: 8){ - Text(self.title) - .font(.title) - .bold() - .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.textColor : self.style.textColor) - if (self.legend != nil){ - Text(self.legend!) - .font(.callout) - .foregroundColor(self.colorScheme == .dark ? self.darkModeStyle.legendTextColor : self.style.legendTextColor) - } - HStack { - if (self.rateValue >= 0){ - Image(systemName: "arrow.up") - }else{ - Image(systemName: "arrow.down") - } - Text("\(self.rateValue)%") - } - } - .transition(.opacity) - .animation(.easeIn(duration: 0.1)) - .padding([.leading, .top]) - }else{ - HStack{ - Spacer() - Text("\(self.currentValue, specifier: self.valueSpecifier)") - .font(.system(size: 41, weight: .bold, design: .default)) - .offset(x: 0, y: 30) - Spacer() - } - .transition(.scale) - } - Spacer() - GeometryReader{ geometry in - ZStack{ - ForEach(0.. CGPoint { -// let points = self.data.onlyPoints() -// let stepWidth: CGFloat = width / CGFloat(points.count-1) -// let stepHeight: CGFloat = height / CGFloat(points.max()! + points.min()!) -// -// let index:Int = Int(round((toPoint.x)/stepWidth)) -// if (index >= 0 && index < points.count){ -// self.currentValue = points[index] -// return CGPoint(x: CGFloat(index)*stepWidth, y: CGFloat(points[index])*stepHeight) -// } -// return .zero -// } -} - -struct MultiWidgetView_Previews: PreviewProvider { - static var previews: some View { - Group { - MultiLineChartView(data: [([8,23,54,32,12,37,7,23,43], GradientColors.orange)], title: "Line chart", legend: "Basic") - .environment(\.colorScheme, .light) - } - } -} diff --git a/Sources/SwiftUICharts/PieChart/PieChartCell.swift b/Sources/SwiftUICharts/PieChart/PieChartCell.swift deleted file mode 100644 index f511165e..00000000 --- a/Sources/SwiftUICharts/PieChart/PieChartCell.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// PieChartCell.swift -// ChartView -// -// Created by András Samu on 2019. 06. 12.. -// Copyright © 2019. András Samu. All rights reserved. -// - -import SwiftUI - -struct PieSlice: Identifiable { - var id = UUID() - var startDeg: Double - var endDeg: Double - var value: Double - var normalizedValue: Double -} - -public struct PieChartCell : View { - @State private var show:Bool = false - var rect: CGRect - var radius: CGFloat { - return min(rect.width, rect.height)/2 - } - var startDeg: Double - var endDeg: Double - var path: Path { - var path = Path() - path.addArc(center:rect.mid , radius:self.radius, startAngle: Angle(degrees: self.startDeg), endAngle: Angle(degrees: self.endDeg), clockwise: false) - path.addLine(to: rect.mid) - path.closeSubpath() - return path - } - var index: Int - var backgroundColor:Color - var accentColor:Color - public var body: some View { - path - .fill() - .foregroundColor(self.accentColor) - .overlay(path.stroke(self.backgroundColor, lineWidth: 2)) - .scaleEffect(self.show ? 1 : 0) - .animation(Animation.spring().delay(Double(self.index) * 0.04)) - .onAppear(){ - self.show = true - } - } -} - -extension CGRect { - var mid: CGPoint { - return CGPoint(x:self.midX, y: self.midY) - } -} - -#if DEBUG -struct PieChartCell_Previews : PreviewProvider { - static var previews: some View { - GeometryReader { geometry in - PieChartCell(rect: geometry.frame(in: .local),startDeg: 0.0,endDeg: 90.0, index: 0, backgroundColor: Color(red: 252.0/255.0, green: 236.0/255.0, blue: 234.0/255.0), accentColor: Color(red: 225.0/255.0, green: 97.0/255.0, blue: 76.0/255.0)) - }.frame(width:100, height:100) - - } -} -#endif diff --git a/Sources/SwiftUICharts/PieChart/PieChartRow.swift b/Sources/SwiftUICharts/PieChart/PieChartRow.swift deleted file mode 100644 index dd690d02..00000000 --- a/Sources/SwiftUICharts/PieChart/PieChartRow.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// PieChartRow.swift -// ChartView -// -// Created by András Samu on 2019. 06. 12.. -// Copyright © 2019. András Samu. All rights reserved. -// - -import SwiftUI - -public struct PieChartRow : View { - var data: [Double] - var backgroundColor: Color - var accentColor: Color - var slices: [PieSlice] { - var tempSlices:[PieSlice] = [] - var lastEndDeg:Double = 0 - let maxValue = data.reduce(0, +) - for slice in data { - let normalized:Double = Double(slice)/Double(maxValue) - let startDeg = lastEndDeg - let endDeg = lastEndDeg + (normalized * 360) - lastEndDeg = endDeg - tempSlices.append(PieSlice(startDeg: startDeg, endDeg: endDeg, value: slice, normalizedValue: normalized)) - } - return tempSlices - } - public var body: some View { - GeometryReader { geometry in - ZStack{ - ForEach(0.. [XCTestCaseEntry] { return [ - testCase(SwiftUIChartsTests.allTests), + testCase(SwiftUIChartsTests.allTests) ] } #endif