Reputation: 21
I want to draw an AreaMark chart with SwiftUI, with different colors based on some properties of my points that are not y, like this:
Setting a foregroundStyle for each point will set the color of the last point. It looks pretty easy to set the color of linemarks and areamarks based on the y value, but I can't find how to base it on custom criteria. Is it possible to do it with Charts?
Upvotes: 1
Views: 1283
Reputation: 168
After some tests, it is a bit more complicated (and my formula was wrong).
Here is my code if anyone wants to reuse and improve it (please share your results then!).
I know this code does not support:
The idea is to compute a background image 1-pixel high and 300 pixels long (I picked 300 as it seamed reasonable for an iPhone, it can be changed easily.). The image must be pre-computed outside of the chart declaration, otherwise it will be computed for each point of the AreaMark.
Then you need to scale the image to perfectly match the chart width. Otherwise the background image will be displayed with its native size and repeat itself horizontally.
The hard part is to compute the chart size, excluding the axis marks. I used geometry readers to get the total size of the chart component, then the size of the Y axis marks, and added 4 for the margin between the 2.
The chartBackground(colorPicker:, size:)
method supposes that path
is a list of Point objects, ordered by a distance
variable. The actual implementation of Point
is custom but irrelevant here.
struct LinesGraphView: View {
var path: [Point]
@State var image = Image(pixels: [PixelData(a: 1, r: 1, g: 1, b: 1)], width: 1)
@State var chartSize: CGSize = CGSize(width: 1, height: 1)
@State var yMarksSize: CGSize = CGSize(width: 1, height: 1)
var body: some View {
Chart(path) {
switch graphType {
case .altitude:
AreaMark(
// your stuff
)
.foregroundStyle(.image(image,
scale: (chartSize.width - yMarksSize.width - 4) / 300.0))
}
}
.chartXScale(domain: yourMin...yourMax) // To avoid margins in the chart
.frame(height: 100)
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
Text("\(value)") // Put your custom format
.saveSize(in: $yMarksSize,
maximizeWidth: true)
}
}
}
.saveSize(in: $chartSize)
.onAppear {
computeImage()
}
}
func computeImage() {
let colorPicker: ((Point) -> Color) = {
// Your way of associating a Point to a color
$0.color
}
self.image = chartBackground(colorPicker: colorPicker,
size: 300)
self.graphType = type
}
func chartBackground(colorPicker: (Point) -> Color,
size: Int = 100) -> Image {
let distances = path.map { $0.distance }
// Avoid crash cases
guard path.count >= 2,
size > 2,
let min = distances.min(),
let max = distances.max() else {
return Image(pixels: [PixelData(a: 0, r: 0, g: 0, b: 0)], width: size)
}
// Compute a step distance per pixel.
let distance = max - min
let step = distance / (Double(size - 1))
// The algorithm will compute the relevant point for each pixel.
// If there are more pixels than points, the pixels will be duplicated with the color of the closest point.
var previous: PathPoint = path[0]
var currentDistance = min
var colors = [Color]()
for point in path {
// Case of first element
if point == previous {
continue
}
// If there are several pixels between the previous and the new point, fill with pixels colored according to the closest point
while currentDistance <= point.distance {
if point.distance - currentDistance > currentDistance - previous.distance {
colors.append(colorPicker(previous))
} else {
colors.append(colorPicker(point))
}
currentDistance += step
}
previous = point
}
// If Double precision issues make the final currentDistance greater than the last point distance, the last pixel may be missing
while colors.count < size {
colors.append(colorPicker(previous))
}
// Should not happen. Anyways.
if colors.count > size {
colors = Array(colors.prefix(size))
}
let pixels = colors.map {
PixelData(a: UInt8($0.components.opacity * 255.0),
r: UInt8($0.components.red * 255.0),
g: UInt8($0.components.green * 255.0),
b: UInt8($0.components.blue * 255.0))
}
let res = Image(pixels: pixels, width: size)
return res
}
}
saveSize
is a modifier found on the web to record the size more easily. I added a maximizeWidth
parameter to make sure I get the largest of the Y axis marks widths.
import SwiftUI
struct SizeCalculator: ViewModifier {
@Binding var size: CGSize
var maximizeWidth: Bool
func body(content: Content) -> some View {
content
.background(
GeometryReader { proxy in
Color.clear // we just want the reader to get triggered, so let's use an empty color
.onAppear {
if !maximizeWidth || size.width < proxy.size.width {
size = proxy.size
}
}
}
)
}
}
extension View {
func saveSize(in size: Binding<CGSize>, maximizeWidth: Bool = false) -> some View {
modifier(SizeCalculator(size: size, maximizeWidth: maximizeWidth))
}
}
Upvotes: 0
Reputation: 21
Ok, in the end I managed to do it. For those who would like to do the same thing, the idea is to create an image of height 1 and of width of the data array count.
Then, use the image shape style:
.foregroundStyle(.image(chartColors, scale: <Data length>))
Depending on the data size, you may want to duplicate the pixels to reduce the blur. The scale needs to be adapted then (divide by the number of duplications.
Upvotes: 0