Search code examples
iosswiftswiftuiswiftui-charts

Swift Charts: How to draw a Line Chart with a fixed Gradient independent of data?


Closely related to this question, the Linear Gradient code works well but it is always tied to its data, so for example this code below, if you have an array of heart rates from 70 to 110 the lower heart rates are always gray and upper always purple, but so is an array of 90 to 195. How can I map the stops so that the color corresponds to the heart rate zone? In other words, heart rates from 70-110 would only show e.g. blue to orange?

 Chart {
            ForEach(smoothHeartRatesEMA(customHeartRates, decayFactor: 0.3)) { heartRate in
                LineMark(
                    x: .value("Sample Time", heartRate.startDate, unit: .nanosecond), //changed these to .nanosecond to fix Nike Run Club bug (some how Nike Run Club gets more frequent HR samples than other apps?)
                    y: .value("Heart Rate", heartRate.doubleValue)
                )
                .lineStyle(StrokeStyle(lineWidth: 3.0))
                .foregroundStyle(
                    .linearGradient(
                        stops: [
                            .init(color: Color.gray, location: 0.0),
                            .init(color:  TrackerConstants.AppleFitnessBlue, location: 0.16),
                            .init(color: TrackerConstants.AppleFitnessYellow, location: 0.33),
                            .init(color: TrackerConstants.AppleFitnessOrange, location: 0.5),
                            .init(color: TrackerConstants.AppleFitnessRed, location: 0.66),
                            .init(color: TrackerConstants.AppleFitnessPurple, location: 1.0) //how do I get these to allign with a range of e.g. 170-210.  I.e. if no heart rate is above 170bpm, the line is never purple?
                        ],
                        startPoint: .bottom,
                        endPoint: .top)
                         )
            }
        }
        .chartYScale(domain: 50...210)

Solution

  • With the way the linear gradient is applied, you will need to transform the stop locations to a different range dynamically.

    Your stops are set up to be 0...1, with the expected graph data range of 50...210. But since 50...210 isn't set in stone, your stops shouldn't be either.

    As you've seen, for a portion of a gradient to be seen on the graph, its stop must be within or on 0...1. Colors below 0 or above 1 will either not be seen at all, or only partially. What we need to do is take your stops and map them to a range wider than 0...1, based on the max and min of the line marks.

    To start, we need to get your stops out of their 0...1 form and instead have a 50...210 representation instead. This is simply taking (210 - 50) * stop_value (ex: blue_stop = (210 - 50) * 0.16) ~= 75). I chose to round all the values for simplicity.

    With these values, we just map them to the drawn range of your graph. The drawn range being the minimum_y...maximum_y. Below is a function that will help up map from one range to another.

    func transform<T: FloatingPoint>(_ input: T, from inputRange: ClosedRange<T>, to outputRange: ClosedRange<T>) -> T {
        // need to determine what that value would be in (to.low, to.high)
        // difference in output range / difference in input range = slope
        let slope = (outputRange.upperBound - outputRange.lowerBound) / (inputRange.upperBound - inputRange.lowerBound)
        // slope * normalized input + output lower
        let output = slope * (input - inputRange.lowerBound) + outputRange.lowerBound
        return output
    }
    

    Now that we have our values to be mapped, and a function to map them. We can dynamically generate the stops when we load the graph. Below is a function to generate stops.

    func generateStops(minValue: Double, maxValue: Double) -> [Gradient.Stop] {
        var realStops = [Gradient.Stop]()
        // ideal stop values, if the range were to be from 50 to 210
        let idealStops: [(Double, Color)] = [
            (50, .gray),
            (75, .blue),
            (100, .yellow),
            (130, .orange),
            (155, .red),
            (210, .purple)
        ]
        for (idealValue, color) in idealStops {
            let transformedValue = transform(idealValue, from: minValue...maxValue, to: 0...1)
            realStops.append(Gradient.Stop(color: color, location: transformedValue))
        }
        return realStops
    }
    

    Using generateStops we can update the original view to get a gradient that is always accurate to the data points. Note that some stuff is changed from your original to accommodate the example.

    struct MyGraphView : View {
        @State var values: [(id: Int, x: Date, y: Double)] = []
        @State var stops: [Gradient.Stop] = []
    
        var body: some View {
            Chart {
                ForEach(values, id: \.id) { value in
                    LineMark(
                        x: .value("Sample Time", value.x, unit: .nanosecond),
                        y: .value("Heart Rate", value.y)
                    )
                    .lineStyle(StrokeStyle(lineWidth: 3.0))
                }
            }
            .chartYScale(domain: 50...210)
            .foregroundStyle(
                .linearGradient(
                    stops: stops,
                    startPoint: .bottom,
                    endPoint: .top
                )
            )
            .onAppear(perform: {
                values = exampleValues
                stops = generateStops(minValue: values.first!.y, maxValue: values.last!.y)
            })
        }
    }