Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7140b8b6fd | |||
| 7fd5b185f8 | |||
| ebaaf81d19 | |||
| d7e9802deb | |||
| bd29afc4c9 | |||
| caa75ecbc0 | |||
| 7861bbcad1 |
Generated
BIN
Binary file not shown.
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>SwiftUICharts.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>2</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Bucket
|
||||
uuid = "91E23D30-CB6C-44DA-BEFC-9D39A1DA2242"
|
||||
type = "1"
|
||||
version = "2.0">
|
||||
</Bucket>
|
||||
+14
-1
@@ -7,7 +7,20 @@
|
||||
<key>SwiftUICharts.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>2</integer>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
<dict>
|
||||
<key>SwiftUICharts</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>SwiftUIChartsTests</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct AxisLabels<Content: View>: 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<YAxisViewKey>())
|
||||
.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<ChartViewKey>())
|
||||
.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
|
||||
}
|
||||
}
|
||||
@@ -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<Int>,
|
||||
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<Int>) -> 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
public enum AxisLabelsYPosition {
|
||||
case leading
|
||||
case trailing
|
||||
}
|
||||
|
||||
public enum AxisLabelsXPosition {
|
||||
case top
|
||||
case bottom
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,9 @@ public struct CardView<Content: View>: View, ChartBase {
|
||||
if showShadow {
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.fill(Color.white)
|
||||
.shadow(color: Color.gray, radius: 8)
|
||||
.shadow(color: Color(white: 0.9, opacity: 1), radius: 8)
|
||||
}
|
||||
VStack {
|
||||
VStack (alignment: .leading) {
|
||||
self.content()
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: showShadow ? 20 : 0))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Protocol for any type of chart, to get access to underlying data
|
||||
public protocol ChartBase {
|
||||
public protocol ChartBase: View {
|
||||
var chartData: ChartData { get }
|
||||
}
|
||||
|
||||
@@ -2,24 +2,70 @@ import SwiftUI
|
||||
|
||||
/// An observable wrapper for an array of data for use in any chart
|
||||
public class ChartData: ObservableObject {
|
||||
@Published public var data: [(String, Double)] = []
|
||||
@Published public var data: [(Double, Double)] = []
|
||||
public var rangeY: ClosedRange<Double>?
|
||||
public var rangeX: ClosedRange<Double>?
|
||||
|
||||
var points: [Double] {
|
||||
data.map { $0.1 }
|
||||
data.filter { rangeX?.contains($0.0) ?? true }.map { $0.1 }
|
||||
}
|
||||
|
||||
var values: [String] {
|
||||
data.map { $0.0 }
|
||||
var values: [Double] {
|
||||
data.filter { rangeX?.contains($0.0) ?? true }.map { $0.0 }
|
||||
}
|
||||
|
||||
/// Initialize with data array
|
||||
/// - Parameter data: Array of `Double`
|
||||
public init(_ data: [Double]) {
|
||||
self.data = data.map { ("", $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) }
|
||||
}
|
||||
|
||||
public init(_ data: [(String, Double)]) {
|
||||
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<FloatLiteralType>? = nil) {
|
||||
self.data = data.enumerated().map{ (index, value) in (Double(index), value) }
|
||||
self.rangeY = rangeY
|
||||
}
|
||||
|
||||
public init(_ data: [(Double, Double)], rangeY: ClosedRange<FloatLiteralType>? = nil) {
|
||||
self.data = data
|
||||
self.rangeY = rangeY
|
||||
}
|
||||
|
||||
public init() {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct ViewGeometry<T>: 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -17,3 +17,10 @@ extension Array where Element == ColorGradient {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ extension CGPoint {
|
||||
/// - data: array of `Double`
|
||||
/// - Returns: X and Y delta as a `CGPoint`
|
||||
static func getStep(frame: CGRect, data: [Double]) -> CGPoint {
|
||||
let padding: CGFloat = 30.0
|
||||
let padding: CGFloat = 0
|
||||
|
||||
// stepWidth
|
||||
var stepWidth: CGFloat = 0.0
|
||||
@@ -38,4 +38,10 @@ extension CGPoint {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import SwiftUI
|
||||
|
||||
extension View where Self: ChartBase {
|
||||
|
||||
/// Set data for a chart
|
||||
/// - Parameter data: array of `Double`
|
||||
/// - Returns: modified `View` with data attached
|
||||
public func data(_ data: [Double]) -> some View {
|
||||
chartData.data = data.map { ("", $0) }
|
||||
extension ChartBase {
|
||||
public func data(_ data: [Double]) -> some ChartBase {
|
||||
chartData.data = data.enumerated().map{ (index, value) in (Double(index), value) }
|
||||
return self
|
||||
.environmentObject(chartData)
|
||||
.environmentObject(ChartValue())
|
||||
}
|
||||
|
||||
public func data(_ data: [(String, Double)]) -> some View {
|
||||
public func data(_ data: [(Double, Double)]) -> some ChartBase {
|
||||
chartData.data = data
|
||||
return self
|
||||
.environmentObject(chartData)
|
||||
.environmentObject(ChartValue())
|
||||
}
|
||||
|
||||
public func rangeY(_ range: ClosedRange<FloatLiteralType>) -> some ChartBase{
|
||||
chartData.rangeY = range
|
||||
return self
|
||||
}
|
||||
|
||||
public func rangeX(_ range: ClosedRange<FloatLiteralType>) -> some ChartBase{
|
||||
chartData.rangeX = range
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,31 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Path {
|
||||
|
||||
/// Returns a tiny segment of path based on percentage along the path
|
||||
///
|
||||
/// TODO: Explain why more than 1 gets 0 and why less than 0 gets 1
|
||||
/// - Parameter percent: fraction along data set, between 0.0 and 1.0 (underflow and overflow are handled)
|
||||
/// - Returns: tiny path right around the requested fraction
|
||||
func trimmedPath(for percent: CGFloat) -> Path {
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
|
||||
/// Find the `CGPoint` for the given fraction along the path.
|
||||
///
|
||||
/// This works by requesting a very tiny trimmed section of the path, then getting the center of the bounds rectangle
|
||||
/// - Parameter percent: fraction along data set, between 0.0 and 1.0 (underflow and overflow are handled)
|
||||
/// - Returns: a `CGPoint` representing the location of that section of the path
|
||||
func point(for percent: CGFloat) -> CGPoint {
|
||||
let path = trimmedPath(for: percent)
|
||||
return CGPoint(x: path.boundingRect.midX, y: path.boundingRect.midY)
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameter maxX: <#maxX description#>
|
||||
/// - Returns: <#description#>
|
||||
func point(to maxX: CGFloat) -> CGPoint {
|
||||
let total = length
|
||||
let sub = length(to: maxX)
|
||||
let percent = sub / total
|
||||
return point(for: percent)
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Returns: <#description#>
|
||||
var length: CGFloat {
|
||||
|
||||
var length: CGFloat {
|
||||
var ret: CGFloat = 0.0
|
||||
var start: CGPoint?
|
||||
var point = CGPoint.zero
|
||||
@@ -73,9 +57,6 @@ extension Path {
|
||||
return ret
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameter maxX: <#maxX description#>
|
||||
/// - Returns: <#description#>
|
||||
func length(to maxX: CGFloat) -> CGFloat {
|
||||
var ret: CGFloat = 0.0
|
||||
var start: CGPoint?
|
||||
@@ -127,19 +108,13 @@ extension Path {
|
||||
return ret
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - points: <#points description#>
|
||||
/// - step: <#step description#>
|
||||
/// - globalOffset: <#globalOffset description#>
|
||||
/// - Returns: <#description#>
|
||||
static func quadCurvedPathWithPoints(points: [Double], step: CGPoint, globalOffset: Double? = nil) -> Path {
|
||||
var path = Path()
|
||||
if points.count < 2 {
|
||||
return path
|
||||
}
|
||||
let offset = globalOffset ?? points.min()!
|
||||
// guard let offset = points.min() else { return path }
|
||||
// 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..<points.count {
|
||||
@@ -152,25 +127,85 @@ extension Path {
|
||||
return path
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - points: <#points description#>
|
||||
/// - step: <#step description#>
|
||||
/// - globalOffset: <#globalOffset description#>
|
||||
/// - Returns: <#description#>
|
||||
static func quadClosedCurvedPathWithPoints(points: [Double], step: CGPoint, globalOffset: Double? = nil) -> 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 point1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y)
|
||||
let convertedXValues = data.map { CGFloat($0.0) * rect.width }
|
||||
let convertedYPoints = data.map { CGFloat($0.1) * rect.height }
|
||||
|
||||
var point1 = CGPoint(x: convertedXValues[0], y: convertedYPoints[0])
|
||||
path.move(to: point1)
|
||||
for pointIndex in 1..<data.count {
|
||||
let point2 = CGPoint(x: CGFloat(convertedXValues[pointIndex]), y: CGFloat(convertedYPoints[pointIndex]))
|
||||
let midPoint = CGPoint.midPointForPoints(firstPoint: point1, secondPoint: point2)
|
||||
path.addQuadCurve(to: midPoint, control: CGPoint.controlPointForPoints(firstPoint: midPoint, secondPoint: point1))
|
||||
path.addQuadCurve(to: point2, control: CGPoint.controlPointForPoints(firstPoint: midPoint, secondPoint: point2))
|
||||
point1 = point2
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
static func drawChartMarkers(data: [(Double, Double)], in rect: CGRect) -> 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..<filteredData.count {
|
||||
path.addRoundedRect(in: CGRect(origin: CGPoint(x: convertedXValues[pointIndex] - markerSize.width / 2,
|
||||
y: convertedYPoints[pointIndex] - markerSize.height / 2),
|
||||
size: markerSize),
|
||||
cornerSize: CGSize(width: markerSize.width / 2,
|
||||
height: markerSize.height / 2))
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
static func drawGridLines(numberOfHorizontalLines: Int, numberOfVerticalLines: Int, in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
|
||||
for index in 0..<numberOfHorizontalLines {
|
||||
let normalisedSpacing = 1.0 / CGFloat(numberOfHorizontalLines - 1)
|
||||
let startPoint = CGPoint(x: 0, y: normalisedSpacing * CGFloat(index) * rect.height)
|
||||
let endPoint = CGPoint(x: rect.width, y: normalisedSpacing * CGFloat(index) * rect.height)
|
||||
path.move(to: startPoint)
|
||||
path.addLine(to: endPoint)
|
||||
}
|
||||
|
||||
for index in 0..<numberOfVerticalLines {
|
||||
let normalisedSpacing = 1.0 / CGFloat(numberOfVerticalLines - 1)
|
||||
let startPoint = CGPoint(x: normalisedSpacing * CGFloat(index) * rect.width, y: 0)
|
||||
let endPoint = CGPoint(x: normalisedSpacing * CGFloat(index) * rect.width, y: rect.height)
|
||||
path.move(to: startPoint)
|
||||
path.addLine(to: endPoint)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
static func quadClosedCurvedPathWithPoints(data: [(Double, Double)], in rect: CGRect) -> 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..<points.count {
|
||||
let point2 = CGPoint(x: step.x * CGFloat(pointIndex), y: step.y*CGFloat(points[pointIndex]-offset))
|
||||
for pointIndex in 1..<data.count {
|
||||
let point2 = CGPoint(x: CGFloat(convertedXValues[pointIndex]), y: CGFloat(convertedYPoints[pointIndex]))
|
||||
let midPoint = CGPoint.midPointForPoints(firstPoint: point1, secondPoint: point2)
|
||||
path.addQuadCurve(to: midPoint, control: CGPoint.controlPointForPoints(firstPoint: midPoint, secondPoint: point1))
|
||||
path.addQuadCurve(to: point2, control: CGPoint.controlPointForPoints(firstPoint: midPoint, secondPoint: point2))
|
||||
@@ -181,88 +216,64 @@ extension Path {
|
||||
return path
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - points: <#points description#>
|
||||
/// - step: <#step description#>
|
||||
/// - Returns: <#description#>
|
||||
static func linePathWithPoints(points: [Double], step: CGPoint) -> 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 point1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y)
|
||||
|
||||
let convertedXValues = data.map { CGFloat($0.0) * rect.width }
|
||||
let convertedYPoints = data.map { CGFloat($0.1) * rect.height }
|
||||
|
||||
let point1 = CGPoint(x: convertedXValues[0], y: convertedYPoints[0])
|
||||
path.move(to: point1)
|
||||
for pointIndex in 1..<points.count {
|
||||
let point2 = CGPoint(x: step.x * CGFloat(pointIndex), y: step.y*CGFloat(points[pointIndex]-offset))
|
||||
for pointIndex in 1..<data.count {
|
||||
let point2 = CGPoint(x: CGFloat(convertedXValues[pointIndex]), y: CGFloat(convertedYPoints[pointIndex]))
|
||||
path.addLine(to: point2)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - points: <#points description#>
|
||||
/// - step: <#step description#>
|
||||
/// - Returns: <#description#>
|
||||
static func closedLinePathWithPoints(points: [Double], step: CGPoint) -> 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 point1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y)
|
||||
path.move(to: point1)
|
||||
for pointIndex in 1..<points.count {
|
||||
point1 = CGPoint(x: step.x * CGFloat(pointIndex), y: step.y*CGFloat(points[pointIndex]-offset))
|
||||
path.addLine(to: point1)
|
||||
|
||||
let convertedXValues = data.map { CGFloat($0.0) * rect.width }
|
||||
let convertedYPoints = data.map { CGFloat($0.1) * rect.height }
|
||||
path.move(to: .zero)
|
||||
|
||||
let point1 = CGPoint(x: convertedXValues[0], y: convertedYPoints[0])
|
||||
path.addLine(to: point1)
|
||||
|
||||
for pointIndex in 1..<data.count {
|
||||
let point2 = CGPoint(x: CGFloat(convertedXValues[pointIndex]), y: CGFloat(convertedYPoints[pointIndex]))
|
||||
path.addLine(to: point2)
|
||||
}
|
||||
path.addLine(to: CGPoint(x: point1.x, y: 0))
|
||||
path.closeSubpath()
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension CGPoint {
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - to: <#to description#>
|
||||
/// - x: <#x description#>
|
||||
/// - Returns: <#description#>
|
||||
func point(to: CGPoint, x: CGFloat) -> CGPoint {
|
||||
let a = (to.y - self.y) / (to.x - self.x)
|
||||
let y = self.y + (x - self.x) * a
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameter to: <#to description#>
|
||||
/// - Returns: <#description#>
|
||||
func line(to: CGPoint) -> CGFloat {
|
||||
dist(to: to)
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - to: <#to description#>
|
||||
/// - x: <#x description#>
|
||||
/// - Returns: <#description#>
|
||||
func line(to: CGPoint, x: CGFloat) -> CGFloat {
|
||||
dist(to: point(to: to, x: x))
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - to: <#to description#>
|
||||
/// - control: <#control description#>
|
||||
/// - Returns: <#description#>
|
||||
func quadCurve(to: CGPoint, control: CGPoint) -> CGFloat {
|
||||
var dist: CGFloat = 0
|
||||
let steps: CGFloat = 100
|
||||
@@ -278,12 +289,6 @@ extension CGPoint {
|
||||
return dist
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - to: <#to description#>
|
||||
/// - control: <#control description#>
|
||||
/// - x: <#x description#>
|
||||
/// - Returns: <#description#>
|
||||
func quadCurve(to: CGPoint, control: CGPoint, x: CGFloat) -> CGFloat {
|
||||
var dist: CGFloat = 0
|
||||
let steps: CGFloat = 100
|
||||
@@ -309,12 +314,6 @@ extension CGPoint {
|
||||
return dist
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - to: <#to description#>
|
||||
/// - t: <#t description#>
|
||||
/// - control: <#control description#>
|
||||
/// - Returns: <#description#>
|
||||
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)
|
||||
@@ -322,12 +321,6 @@ extension CGPoint {
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - to: <#to description#>
|
||||
/// - control1: <#control1 description#>
|
||||
/// - control2: <#control2 description#>
|
||||
/// - Returns: <#description#>
|
||||
func curve(to: CGPoint, control1: CGPoint, control2: CGPoint) -> CGFloat {
|
||||
var dist: CGFloat = 0
|
||||
let steps: CGFloat = 100
|
||||
@@ -345,13 +338,6 @@ extension CGPoint {
|
||||
return dist
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - to: <#to description#>
|
||||
/// - control1: <#control1 description#>
|
||||
/// - control2: <#control2 description#>
|
||||
/// - x: <#x description#>
|
||||
/// - Returns: <#description#>
|
||||
func curve(to: CGPoint, control1: CGPoint, control2: CGPoint, x: CGFloat) -> CGFloat {
|
||||
var dist: CGFloat = 0
|
||||
let steps: CGFloat = 100
|
||||
@@ -379,13 +365,6 @@ extension CGPoint {
|
||||
return dist
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - to: <#to description#>
|
||||
/// - t: <#t description#>
|
||||
/// - control1: <#control1 description#>
|
||||
/// - control2: <#control2 description#>
|
||||
/// - Returns: <#description#>
|
||||
func point(to: CGPoint, t: CGFloat, control1: CGPoint, control2: CGPoint) -> CGPoint {
|
||||
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)
|
||||
@@ -393,13 +372,6 @@ extension CGPoint {
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - x: <#x description#>
|
||||
/// - y: <#y description#>
|
||||
/// - t: <#t description#>
|
||||
/// - c: <#c description#>
|
||||
/// - Returns: <#description#>
|
||||
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
|
||||
@@ -409,14 +381,6 @@ extension CGPoint {
|
||||
return value
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - x: <#x description#>
|
||||
/// - y: <#y description#>
|
||||
/// - t: <#t description#>
|
||||
/// - control1: <#control1 description#>
|
||||
/// - control2: <#control2 description#>
|
||||
/// - Returns: <#description#>
|
||||
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
|
||||
@@ -427,11 +391,6 @@ extension CGPoint {
|
||||
return value
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - point1: <#point1 description#>
|
||||
/// - point2: <#point2 description#>
|
||||
/// - Returns: <#description#>
|
||||
static func getMidPoint(point1: CGPoint, point2: CGPoint) -> CGPoint {
|
||||
return CGPoint(
|
||||
x: point1.x + (point2.x - point1.x) / 2,
|
||||
@@ -439,29 +398,16 @@ extension CGPoint {
|
||||
)
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameter to: <#to description#>
|
||||
/// - Returns: <#description#>
|
||||
func dist(to: CGPoint) -> CGFloat {
|
||||
return sqrt((pow(self.x - to.x, 2) + pow(self.y - to.y, 2)))
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - firstPoint: <#firstPoint description#>
|
||||
/// - secondPoint: <#secondPoint description#>
|
||||
/// - Returns: <#description#>
|
||||
static func midPointForPoints(firstPoint: CGPoint, secondPoint: CGPoint) -> CGPoint {
|
||||
return CGPoint(
|
||||
x: (firstPoint.x + secondPoint.x) / 2,
|
||||
y: (firstPoint.y + secondPoint.y) / 2)
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - firstPoint: <#firstPoint description#>
|
||||
/// - secondPoint: <#secondPoint description#>
|
||||
/// - Returns: <#description#>
|
||||
static func controlPointForPoints(firstPoint: CGPoint, secondPoint: CGPoint) -> CGPoint {
|
||||
var controlPoint = CGPoint.midPointForPoints(firstPoint: firstPoint, secondPoint: secondPoint)
|
||||
let diffY = abs(secondPoint.y - controlPoint.y)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
public extension ClosedRange where Bound: AdditiveArithmetic {
|
||||
var overreach: Bound {
|
||||
self.upperBound - self.lowerBound
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Shape {
|
||||
func fill<Fill: ShapeStyle, Stroke: ShapeStyle>(_ fillStyle: Fill, strokeBorder strokeStyle: Stroke, lineWidth: Double = 1) -> some View {
|
||||
self
|
||||
.stroke(strokeStyle, lineWidth: lineWidth)
|
||||
.background(self.fill(fillStyle))
|
||||
}
|
||||
}
|
||||
|
||||
extension InsettableShape {
|
||||
func fill<Fill: ShapeStyle, Stroke: ShapeStyle>(_ fillStyle: Fill, strokeBorder strokeStyle: Stroke, lineWidth: Double = 1) -> some View {
|
||||
self
|
||||
.strokeBorder(strokeStyle, lineWidth: lineWidth)
|
||||
.background(self.fill(fillStyle))
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,10 @@ extension View {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
import SwiftUI
|
||||
|
||||
/// <#Description#>
|
||||
public struct ChartGrid<Content: View>: View, ChartBase {
|
||||
public var chartData = ChartData()
|
||||
public struct ChartGrid<Content: View>: View {
|
||||
let content: () -> Content
|
||||
public var gridOptions = GridOptions()
|
||||
|
||||
@EnvironmentObject var data: ChartData
|
||||
@EnvironmentObject var style: ChartStyle
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameter content: <#content description#>
|
||||
public init(@ViewBuilder content: @escaping () -> Content) {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
/// The content and behavior of the `ChartGrid`.
|
||||
///
|
||||
/// TODO: Explain why this is in a `ZStack`
|
||||
public var body: some View {
|
||||
ZStack{
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -39,13 +39,13 @@ public struct ChartLabel: View {
|
||||
private var labelPadding: EdgeInsets {
|
||||
switch labelType {
|
||||
case .title:
|
||||
return EdgeInsets(top: 16.0, leading: 8.0, bottom: 0.0, trailing: 8.0)
|
||||
return EdgeInsets(top: 16.0, leading: 0, bottom: 0.0, trailing: 8.0)
|
||||
case .legend:
|
||||
return EdgeInsets(top: 4.0, leading: 8.0, bottom: 0.0, trailing: 8.0)
|
||||
return EdgeInsets(top: 4.0, leading: 0, bottom: 0.0, trailing: 8.0)
|
||||
case .subTitle:
|
||||
return EdgeInsets(top: 8.0, leading: 8.0, bottom: 0.0, trailing: 8.0)
|
||||
return EdgeInsets(top: 8.0, leading: 0, bottom: 0.0, trailing: 8.0)
|
||||
case .largeTitle:
|
||||
return EdgeInsets(top: 24.0, leading: 8.0, bottom: 0.0, trailing: 8.0)
|
||||
return EdgeInsets(top: 24.0, leading: 0, bottom: 0.0, trailing: 8.0)
|
||||
case .custom(_, let padding, _):
|
||||
return padding
|
||||
}
|
||||
@@ -59,13 +59,13 @@ public struct ChartLabel: View {
|
||||
private var labelColor: Color {
|
||||
switch labelType {
|
||||
case .title:
|
||||
return Color(UIColor.label)
|
||||
return Color.primary
|
||||
case .legend:
|
||||
return Color(UIColor.secondaryLabel)
|
||||
return Color.secondary
|
||||
case .subTitle:
|
||||
return Color(UIColor.label)
|
||||
return Color.primary
|
||||
case .largeTitle:
|
||||
return Color(UIColor.label)
|
||||
return Color.primary
|
||||
case .custom(_, _, let color):
|
||||
return color
|
||||
}
|
||||
|
||||
@@ -1,44 +1,24 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Descripton of colors/styles for any kind of chart
|
||||
public class ChartStyle: ObservableObject {
|
||||
|
||||
/// colors for background are of chart
|
||||
public let backgroundColor: ColorGradient
|
||||
/// colors for foreground fill of chart
|
||||
public let foregroundColor: [ColorGradient]
|
||||
|
||||
/// Initialize with a single background color and an array of `ColorGradient` for the foreground
|
||||
/// - Parameters:
|
||||
/// - backgroundColor: a `Color`
|
||||
/// - foregroundColor: array of `ColorGradient`
|
||||
public init(backgroundColor: Color, foregroundColor: [ColorGradient]) {
|
||||
self.backgroundColor = ColorGradient.init(backgroundColor)
|
||||
self.foregroundColor = foregroundColor
|
||||
}
|
||||
|
||||
/// Initialize with a single background color and a single `ColorGradient` for the foreground
|
||||
/// - Parameters:
|
||||
/// - backgroundColor: a `Color`
|
||||
/// - foregroundColor: a `ColorGradient`
|
||||
public init(backgroundColor: Color, foregroundColor: ColorGradient) {
|
||||
self.backgroundColor = ColorGradient.init(backgroundColor)
|
||||
self.foregroundColor = [foregroundColor]
|
||||
}
|
||||
|
||||
/// Initialize with a single background `ColorGradient` and a single `ColorGradient` for the foreground
|
||||
/// - Parameters:
|
||||
/// - backgroundColor: a `ColorGradient`
|
||||
/// - foregroundColor: a `ColorGradient`
|
||||
public init(backgroundColor: ColorGradient, foregroundColor: ColorGradient) {
|
||||
self.backgroundColor = backgroundColor
|
||||
self.foregroundColor = [foregroundColor]
|
||||
}
|
||||
|
||||
/// Initialize with a single background `ColorGradient` and an array of `ColorGradient` for the foreground
|
||||
/// - Parameters:
|
||||
/// - backgroundColor: a `ColorGradient`
|
||||
/// - foregroundColor: array of `ColorGradient`
|
||||
public init(backgroundColor: ColorGradient, foregroundColor: [ColorGradient]) {
|
||||
self.backgroundColor = backgroundColor
|
||||
self.foregroundColor = foregroundColor
|
||||
|
||||
@@ -1,39 +1,25 @@
|
||||
import SwiftUI
|
||||
|
||||
/// An encapsulation of a simple gradient between one color and another
|
||||
public struct ColorGradient: Equatable {
|
||||
public let startColor: Color
|
||||
public let endColor: Color
|
||||
|
||||
/// Initialize as a solid color
|
||||
/// - Parameter color: a single `Color` (no gradient effect visible)
|
||||
public init(_ color: Color) {
|
||||
self.startColor = color
|
||||
self.endColor = color
|
||||
}
|
||||
|
||||
/// Initialize a color gradient from two specified colors
|
||||
/// - Parameters:
|
||||
/// - startColor: starting color
|
||||
/// - endColor: ending color
|
||||
public init(_ startColor: Color, _ endColor: Color) {
|
||||
self.startColor = startColor
|
||||
self.endColor = endColor
|
||||
}
|
||||
|
||||
/// Convert to a `Gradient` object (more complicated than just two colors)
|
||||
/// - Returns: a `Gradient` between the specified start and end colors
|
||||
public var gradient: Gradient {
|
||||
return Gradient(colors: [startColor, endColor])
|
||||
}
|
||||
}
|
||||
|
||||
extension ColorGradient {
|
||||
/// Convenience method to return a SwiftUI LinearGradient view from the ColorGradient
|
||||
/// - Parameters:
|
||||
/// - startPoint: starting point
|
||||
/// - endPoint: ending point
|
||||
/// - Returns: a Linear gradient
|
||||
public func linearGradient(from startPoint: UnitPoint, to endPoint: UnitPoint) -> LinearGradient {
|
||||
return LinearGradient(gradient: self.gradient, startPoint: startPoint, endPoint: endPoint)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Some predefined colors, used for demos, defaults if color is missing, and data indicator point
|
||||
public enum ChartColors {
|
||||
// Orange
|
||||
public static let orangeBright = Color(hexString: "#FF782C")
|
||||
public static let orangeDark = Color(hexString: "#EC2301")
|
||||
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A type of chart that displays vertical bars for each data point
|
||||
public struct BarChart: View, ChartBase {
|
||||
public struct BarChart: ChartBase {
|
||||
public var chartData = ChartData()
|
||||
|
||||
@EnvironmentObject var data: ChartData
|
||||
@EnvironmentObject var style: ChartStyle
|
||||
|
||||
/// The content and behavior of the `BarChart`.
|
||||
///
|
||||
///
|
||||
public var body: some View {
|
||||
BarChartRow(chartData: data, style: style)
|
||||
BarChartRow(chartData: chartData, style: style)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
@@ -1,52 +1,33 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A single vertical bar in a `BarChart`
|
||||
public struct BarChartCell: View {
|
||||
var value: Double
|
||||
var index: Int = 0
|
||||
var width: Float
|
||||
var numberOfDataPoints: Int
|
||||
var gradientColor: ColorGradient
|
||||
var touchLocation: CGFloat
|
||||
|
||||
var cellWidth: Double {
|
||||
return Double(width)/(Double(numberOfDataPoints) * 1.5)
|
||||
}
|
||||
|
||||
@State private var firstDisplay: Bool = true
|
||||
@State private var didCellAppear: Bool = false
|
||||
|
||||
public init( value: Double,
|
||||
index: Int = 0,
|
||||
width: Float,
|
||||
numberOfDataPoints: Int,
|
||||
gradientColor: ColorGradient,
|
||||
touchLocation: CGFloat) {
|
||||
self.value = value
|
||||
self.index = index
|
||||
self.width = width
|
||||
self.numberOfDataPoints = numberOfDataPoints
|
||||
self.gradientColor = gradientColor
|
||||
self.touchLocation = touchLocation
|
||||
}
|
||||
|
||||
/// The content and behavior of the `BarChartCell`.
|
||||
///
|
||||
/// Animated when first displayed, using the `firstDisplay` variable, with an increasing delay through the data set.
|
||||
public var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(gradientColor.linearGradient(from: .bottom, to: .top))
|
||||
}
|
||||
.frame(width: CGFloat(self.cellWidth))
|
||||
.scaleEffect(CGSize(width: 1, height: self.firstDisplay ? 0.0 : self.value), anchor: .bottom)
|
||||
.onAppear {
|
||||
self.firstDisplay = false
|
||||
BarChartCellShape(value: didCellAppear ? value : 0.0)
|
||||
.fill(gradientColor.linearGradient(from: .bottom, to: .top)) .onAppear {
|
||||
self.didCellAppear = true
|
||||
}
|
||||
.onDisappear {
|
||||
self.firstDisplay = true
|
||||
self.didCellAppear = false
|
||||
}
|
||||
.transition(.slide)
|
||||
.animation(Animation.spring().delay(self.touchLocation < 0 || !firstDisplay ? Double(self.index) * 0.04 : 0))
|
||||
.animation(Animation.spring().delay(self.touchLocation < 0 || !didCellAppear ? Double(self.index) * 0.04 : 0))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,17 +35,17 @@ struct BarChartCell_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
Group {
|
||||
BarChartCell(value: 0, width: 50, numberOfDataPoints: 1, gradientColor: ColorGradient.greenRed, touchLocation: CGFloat())
|
||||
BarChartCell(value: 0, gradientColor: ColorGradient.greenRed, touchLocation: CGFloat())
|
||||
|
||||
BarChartCell(value: 1, width: 50, numberOfDataPoints: 1, gradientColor: ColorGradient.greenRed, touchLocation: CGFloat())
|
||||
BarChartCell(value: 1, width: 50, numberOfDataPoints: 1, gradientColor: ColorGradient.whiteBlack, touchLocation: CGFloat())
|
||||
BarChartCell(value: 1, width: 50, numberOfDataPoints: 1, gradientColor: ColorGradient(.purple), 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, width: 50, numberOfDataPoints: 1, gradientColor: ColorGradient.greenRed, touchLocation: CGFloat())
|
||||
BarChartCell(value: 1, width: 50, numberOfDataPoints: 1, gradientColor: ColorGradient.whiteBlack, touchLocation: CGFloat())
|
||||
BarChartCell(value: 1, width: 50, numberOfDataPoints: 1, gradientColor: ColorGradient(.purple), touchLocation: CGFloat())
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A single row of data, a view in a `BarChart`
|
||||
public struct BarChartRow: View {
|
||||
@EnvironmentObject var chartValue: ChartValue
|
||||
@ObservedObject var chartData: ChartData
|
||||
@State private var touchLocation: CGFloat = -1.0
|
||||
|
||||
enum Constant {
|
||||
static let spacing: CGFloat = 16.0
|
||||
}
|
||||
|
||||
var style: ChartStyle
|
||||
|
||||
var maxValue: Double {
|
||||
@@ -19,28 +14,21 @@ public struct BarChartRow: View {
|
||||
return max != 0 ? max : 1
|
||||
}
|
||||
|
||||
/// The content and behavior of the `BarChartRow`.
|
||||
///
|
||||
/// Shows each `BarChartCell` in an `HStack`; may be scaled up if it's the one currently being touched.
|
||||
/// Not using a drawing group for optimizing animation.
|
||||
/// As touched (dragged) the `touchLocation` is updated and the current value is highlighted.
|
||||
public var body: some View {
|
||||
GeometryReader { geometry in
|
||||
HStack(alignment: .bottom,
|
||||
spacing: (geometry.frame(in: .local).width - Constant.spacing) / CGFloat(self.chartData.data.count * 3)) {
|
||||
ForEach(0..<self.chartData.data.count, id: \.self) { index in
|
||||
BarChartCell(value: self.normalizedValue(index: index),
|
||||
spacing: geometry.frame(in: .local).width / CGFloat(chartData.data.count * 3)) {
|
||||
ForEach(0..<chartData.data.count, id: \.self) { index in
|
||||
BarChartCell(value: chartData.normalisedPoints[index],
|
||||
index: index,
|
||||
width: Float(geometry.frame(in: .local).width - Constant.spacing),
|
||||
numberOfDataPoints: self.chartData.data.count,
|
||||
gradientColor: self.style.foregroundColor.rotate(for: index),
|
||||
touchLocation: self.touchLocation)
|
||||
.scaleEffect(self.getScaleSize(touchLocation: self.touchLocation, index: index), anchor: .bottom)
|
||||
.animation(Animation.easeIn(duration: 0.2))
|
||||
}
|
||||
// .drawingGroup()
|
||||
// .drawingGroup()
|
||||
}
|
||||
.padding([.top, .leading, .trailing], 10)
|
||||
.frame(maxHeight: chartData.isInNegativeDomain ? geometry.size.height / 2 : geometry.size.height)
|
||||
.gesture(DragGesture()
|
||||
.onChanged({ value in
|
||||
let width = geometry.frame(in: .local).width
|
||||
@@ -58,18 +46,6 @@ public struct BarChartRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Value relative to maximum value
|
||||
/// - Parameter index: index into array of data
|
||||
/// - Returns: data value at given index, divided by data maximum
|
||||
func normalizedValue(index: Int) -> Double {
|
||||
return Double(chartData.points[index])/Double(maxValue)
|
||||
}
|
||||
|
||||
/// Size to scale the touch indicator
|
||||
/// - Parameters:
|
||||
/// - touchLocation: fraction of width where touch is happening
|
||||
/// - index: index into data array
|
||||
/// - Returns: a scale larger than 1.0 if in bounds; 1.0 (unscaled) if not in bounds
|
||||
func getScaleSize(touchLocation: CGFloat, index: Int) -> CGSize {
|
||||
if touchLocation > CGFloat(index)/CGFloat(chartData.data.count) &&
|
||||
touchLocation < CGFloat(index+1)/CGFloat(chartData.data.count) {
|
||||
@@ -78,12 +54,17 @@ public struct BarChartRow: View {
|
||||
return CGSize(width: 1, height: 1)
|
||||
}
|
||||
|
||||
/// Get data value where touch happened
|
||||
/// - Parameter width: width of chart
|
||||
/// - Returns: value as `Double` if chart has data
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A dot representing a single data point as user moves finger over line in `LineChart`
|
||||
struct IndicatorPoint: View {
|
||||
/// The content and behavior of the `IndicatorPoint`.
|
||||
///
|
||||
/// A filled circle with a thick white outline and a shadow
|
||||
public var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
|
||||
@@ -2,102 +2,63 @@ import SwiftUI
|
||||
|
||||
/// A single line of data, a view in a `LineChart`
|
||||
public struct Line: View {
|
||||
@EnvironmentObject var chartValue: ChartValue
|
||||
@State private var frame: CGRect = .zero
|
||||
@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 showFull: Bool = false
|
||||
@State private var showBackground: Bool = true
|
||||
var curvedLines: Bool = true
|
||||
@State private var didCellAppear: Bool = false
|
||||
|
||||
/// Step for plotting through data
|
||||
/// - Returns: X and Y delta between each data point based on data and view's frame
|
||||
var step: CGPoint {
|
||||
return CGPoint.getStep(frame: frame, data: chartData.points)
|
||||
}
|
||||
|
||||
/// Path of line graph
|
||||
/// - Returns: A path for stroking representing the data, either curved or jagged.
|
||||
var path: Path {
|
||||
let points = chartData.points
|
||||
|
||||
if curvedLines {
|
||||
return Path.quadCurvedPathWithPoints(points: points,
|
||||
step: step,
|
||||
globalOffset: nil)
|
||||
}
|
||||
|
||||
return Path.linePathWithPoints(points: points, step: step)
|
||||
}
|
||||
|
||||
/// Path of linegraph, but also closed at the bottom side
|
||||
/// - Returns: A path for filling representing the data, either curved or jagged
|
||||
var closedPath: Path {
|
||||
let points = chartData.points
|
||||
|
||||
if curvedLines {
|
||||
return Path.quadClosedCurvedPathWithPoints(points: points,
|
||||
step: step,
|
||||
globalOffset: nil)
|
||||
}
|
||||
|
||||
return Path.closedLinePathWithPoints(points: points, step: step)
|
||||
Path.quadCurvedPathWithPoints(points: chartData.normalisedPoints,
|
||||
step: CGPoint(x: 1.0, y: 1.0))
|
||||
}
|
||||
|
||||
// see https://stackoverflow.com/a/62370919
|
||||
// This lets geometry be recalculated when device rotates. However it doesn't cover issue of app changing
|
||||
// from full screen to split view. Not possible in SwiftUI? Feedback submitted to apple FB8451194.
|
||||
let orientationChanged = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)
|
||||
.makeConnectable()
|
||||
.autoconnect()
|
||||
public init(chartData: ChartData,
|
||||
style: ChartStyle,
|
||||
chartProperties: LineChartProperties) {
|
||||
self.chartData = chartData
|
||||
self.style = style
|
||||
self.chartProperties = chartProperties
|
||||
}
|
||||
|
||||
/// The content and behavior of the `Line`.
|
||||
/// Draw the background if showing the full line (?) and the `showBackground` option is set. Above that draw the line, and then the data indicator if the graph is currently being touched.
|
||||
/// On appear, set the frame so that the data graph metrics can be calculated. On a drag (touch) gesture, highlight the closest touched data point.
|
||||
/// TODO: explain rotation
|
||||
public var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
if self.showFull && self.showBackground {
|
||||
self.getBackgroundPathView()
|
||||
}
|
||||
self.getLinePathView()
|
||||
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))
|
||||
if self.didCellAppear, let backgroundColor = chartProperties.backgroundGradient {
|
||||
LineBackgroundShapeView(chartData: chartData,
|
||||
geometry: geometry,
|
||||
backgroundColor: backgroundColor)
|
||||
}
|
||||
lineShapeView(geometry: geometry)
|
||||
}
|
||||
.onAppear {
|
||||
self.frame = geometry.frame(in: .local)
|
||||
|
||||
didCellAppear = true
|
||||
}
|
||||
.onReceive(orientationChanged) { _ in
|
||||
// When we receive notification here, the geometry is still the old value
|
||||
// so delay evaluation to get the new frame!
|
||||
DispatchQueue.main.async {
|
||||
self.frame = geometry.frame(in: .local) // recalculate layout with new frame
|
||||
}
|
||||
}
|
||||
|
||||
.gesture(DragGesture()
|
||||
.onChanged({ value in
|
||||
self.touchLocation = value.location
|
||||
self.showIndicator = true
|
||||
self.getClosestDataPoint(point: self.getClosestPointOnPath(touchLocation: value.location))
|
||||
self.chartValue.interactionInProgress = true
|
||||
})
|
||||
.onEnded({ value in
|
||||
self.touchLocation = .zero
|
||||
self.showIndicator = false
|
||||
self.chartValue.interactionInProgress = false
|
||||
})
|
||||
)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,79 +66,46 @@ public struct Line: View {
|
||||
// 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(touchLocation: CGPoint) -> CGPoint {
|
||||
let closest = self.path.point(to: touchLocation.x)
|
||||
return closest
|
||||
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(point: CGPoint) {
|
||||
let index = Int(round((point.x)/step.x))
|
||||
// /// 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]
|
||||
// self.chartValue.currentValue = self.chartData.points[index]
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the view representing the filled in background below the chart, filled with the foreground color's gradient
|
||||
///
|
||||
/// TODO: explain rotations
|
||||
/// - Returns: SwiftUI `View`
|
||||
private func getBackgroundPathView() -> some View {
|
||||
self.closedPath
|
||||
.fill(LinearGradient(gradient: Gradient(colors: [
|
||||
style.foregroundColor.first?.startColor ?? .white,
|
||||
style.foregroundColor.first?.endColor ?? .white,
|
||||
.clear]),
|
||||
startPoint: .bottom,
|
||||
endPoint: .top))
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
.opacity(0.2)
|
||||
.transition(.opacity)
|
||||
.animation(.easeIn(duration: 1.6))
|
||||
}
|
||||
|
||||
/// Get the view representing the line stroked in the `foregroundColor`
|
||||
///
|
||||
/// TODO: Explain how `showFull` works
|
||||
/// TODO: explain rotations
|
||||
/// - Returns: SwiftUI `View`
|
||||
private func getLinePathView() -> some View {
|
||||
self.path
|
||||
.trim(from: 0, to: self.showFull ? 1:0)
|
||||
.stroke(LinearGradient(gradient: style.foregroundColor.first?.gradient ?? ColorGradient.orangeBright.gradient,
|
||||
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))
|
||||
.onAppear {
|
||||
self.showFull = true
|
||||
}
|
||||
.onDisappear {
|
||||
self.showFull = false
|
||||
}
|
||||
.drawingGroup()
|
||||
}
|
||||
}
|
||||
|
||||
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, 43]), style: blackLineStyle)
|
||||
Line(chartData: ChartData([8, 23, 32, 7, 23, 43]), style: redLineStyle)
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Predefined style, black over white, for preview
|
||||
private let blackLineStyle = ChartStyle(backgroundColor: ColorGradient(.white), foregroundColor: ColorGradient(.black))
|
||||
|
||||
/// Predefined stylem red over white, for preview
|
||||
private let redLineStyle = ChartStyle(backgroundColor: .whiteBlack, foregroundColor: ColorGradient(.red))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A type of chart that displays a line connecting the data points
|
||||
public struct LineChart: View, ChartBase {
|
||||
public struct LineChart: ChartBase {
|
||||
public var chartData = ChartData()
|
||||
|
||||
@EnvironmentObject var data: ChartData
|
||||
@EnvironmentObject var style: ChartStyle
|
||||
public var chartProperties = LineChartProperties()
|
||||
|
||||
/// The content and behavior of the `LineChart`.
|
||||
///
|
||||
///
|
||||
public var body: some View {
|
||||
Line(chartData: data, style: style)
|
||||
Line(chartData: chartData,
|
||||
style: style,
|
||||
chartProperties: chartProperties)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
public enum LineStyle {
|
||||
case curved
|
||||
case straight
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A type of chart that displays a slice of "pie" for each data point
|
||||
public struct PieChart: View, ChartBase {
|
||||
public struct PieChart: ChartBase {
|
||||
public var chartData = ChartData()
|
||||
|
||||
@EnvironmentObject var data: ChartData
|
||||
@EnvironmentObject var style: ChartStyle
|
||||
|
||||
/// The content and behavior of the `PieChart`.
|
||||
///
|
||||
///
|
||||
public var body: some View {
|
||||
PieChartRow(chartData: data, style: style)
|
||||
PieChartRow(chartData: chartData, style: style)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
//
|
||||
// RingsChart.swift
|
||||
// ChartViewV2Demo
|
||||
//
|
||||
// Created by Dan Wood on 8/20/20.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct RingsChart: View, ChartBase {
|
||||
public struct RingsChart: ChartBase {
|
||||
public var chartData = ChartData()
|
||||
|
||||
@EnvironmentObject var data: 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: data, style: style)
|
||||
RingsChartRow(width:10.0, spacing:5.0, chartData: chartData, style: style)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Nicolas Savoini on 2020-05-25.
|
||||
//
|
||||
|
||||
@testable import SwiftUICharts
|
||||
import XCTest
|
||||
|
||||
|
||||
Reference in New Issue
Block a user