Newer Older
422 lines | 13.121kb
Bogdan Timofte authored a month ago
1
//
2
//  TimeSeriesChart.swift
3
//  USB Meter
4
//
5

            
6
import CoreGraphics
7
import Foundation
8
import SwiftUI
9

            
10
enum TimeSeriesChartPointKind: Hashable {
11
    case sample
12
    case discontinuity
13
}
14

            
15
protocol TimeSeriesChartPointRepresentable {
16
    var chartPointID: Int { get }
17
    var chartTimestamp: Date { get }
18
    var chartValue: Double { get }
19
    var chartPointKind: TimeSeriesChartPointKind { get }
20
}
21

            
22
extension TimeSeriesChartPointRepresentable {
23
    var isChartSample: Bool {
24
        chartPointKind == .sample
25
    }
26

            
27
    var isChartDiscontinuity: Bool {
28
        chartPointKind == .discontinuity
29
    }
30

            
31
    func chartCGPoint() -> CGPoint {
32
        CGPoint(x: chartTimestamp.timeIntervalSince1970, y: chartValue)
33
    }
34
}
35

            
36
struct TimeSeriesChartStyle {
37
    var drawsArea: Bool
38
    var strokeColor: Color
39
    var areaFillColor: Color?
40
    var lineWidth: CGFloat
41

            
42
    static func line(
43
        strokeColor: Color = .black,
44
        lineWidth: CGFloat = 2
45
    ) -> TimeSeriesChartStyle {
46
        TimeSeriesChartStyle(
47
            drawsArea: false,
48
            strokeColor: strokeColor,
49
            areaFillColor: nil,
50
            lineWidth: lineWidth
51
        )
52
    }
53

            
54
    static func area(
55
        strokeColor: Color = .black,
56
        areaFillColor: Color? = nil,
57
        lineWidth: CGFloat = 2
58
    ) -> TimeSeriesChartStyle {
59
        TimeSeriesChartStyle(
60
            drawsArea: true,
61
            strokeColor: strokeColor,
62
            areaFillColor: areaFillColor,
63
            lineWidth: lineWidth
64
        )
65
    }
66
}
67

            
68
struct TimeSeriesChart<Point: TimeSeriesChartPointRepresentable>: View {
69
    @Environment(\.displayScale) private var displayScale
70

            
71
    let points: [Point]
72
    let context: ChartContext
73
    let style: TimeSeriesChartStyle
74

            
75
    init(
76
        points: [Point],
77
        context: ChartContext,
78
        style: TimeSeriesChartStyle
79
    ) {
80
        self.points = points
81
        self.context = context
82
        self.style = style
83
    }
84

            
85
    init(
86
        points: [Point],
87
        context: ChartContext,
88
        areaChart: Bool = false,
89
        strokeColor: Color = .black,
90
        areaFillColor: Color? = nil
91
    ) {
92
        self.points = points
93
        self.context = context
94
        self.style = areaChart
95
            ? .area(strokeColor: strokeColor, areaFillColor: areaFillColor)
96
            : .line(strokeColor: strokeColor)
97
    }
98

            
99
    var body: some View {
100
        GeometryReader { geometry in
101
            if style.drawsArea {
102
                let fillColor = style.areaFillColor ?? style.strokeColor.opacity(0.2)
103
                path(geometry: geometry)
104
                    .fill(
105
                        LinearGradient(
106
                            gradient: .init(
107
                                colors: [
108
                                    fillColor.opacity(0.72),
109
                                    fillColor.opacity(0.18)
110
                                ]
111
                            ),
112
                            startPoint: .init(x: 0.5, y: 0.08),
113
                            endPoint: .init(x: 0.5, y: 0.92)
114
                        )
115
                    )
116
            } else {
117
                path(geometry: geometry)
118
                    .stroke(
119
                        style.strokeColor,
120
                        style: StrokeStyle(
121
                            lineWidth: style.lineWidth,
122
                            lineCap: .round,
123
                            lineJoin: .round
124
                        )
125
                    )
126
            }
127
        }
128
    }
129

            
130
    private func path(geometry: GeometryProxy) -> Path {
131
        let displayedPoints = scaledPoints(for: geometry.size.width)
132
        let baselineY = context.placeInRect(
133
            point: CGPoint(x: context.origin.x, y: context.origin.y)
134
        ).y * geometry.size.height
135

            
136
        return Path { path in
137
            var firstRenderedPoint: CGPoint?
138
            var lastRenderedPoint: CGPoint?
139
            var needsMove = true
140

            
141
            for point in displayedPoints {
142
                if point.isDiscontinuity {
143
                    closeAreaSegment(
144
                        in: &path,
145
                        firstPoint: firstRenderedPoint,
146
                        lastPoint: lastRenderedPoint,
147
                        baselineY: baselineY
148
                    )
149
                    firstRenderedPoint = nil
150
                    lastRenderedPoint = nil
151
                    needsMove = true
152
                    continue
153
                }
154

            
155
                let item = context.placeInRect(point: point.cgPoint)
156
                let renderedPoint = CGPoint(
157
                    x: item.x * geometry.size.width,
158
                    y: item.y * geometry.size.height
159
                )
160

            
161
                if needsMove {
162
                    path.move(to: renderedPoint)
163
                    firstRenderedPoint = renderedPoint
164
                    needsMove = false
165
                } else {
166
                    path.addLine(to: renderedPoint)
167
                }
168

            
169
                lastRenderedPoint = renderedPoint
170
            }
171

            
172
            closeAreaSegment(
173
                in: &path,
174
                firstPoint: firstRenderedPoint,
175
                lastPoint: lastRenderedPoint,
176
                baselineY: baselineY
177
            )
178
        }
179
    }
180

            
181
    private func closeAreaSegment(
182
        in path: inout Path,
183
        firstPoint: CGPoint?,
184
        lastPoint: CGPoint?,
185
        baselineY: CGFloat
186
    ) {
187
        guard style.drawsArea, let firstPoint, let lastPoint, firstPoint != lastPoint else { return }
188

            
189
        path.addLine(to: CGPoint(x: lastPoint.x, y: baselineY))
190
        path.addLine(to: CGPoint(x: firstPoint.x, y: baselineY))
191
        path.closeSubpath()
192
    }
193

            
194
    private func scaledPoints(for width: CGFloat) -> [TimeSeriesChartRenderPoint] {
195
        let renderPoints = points.map(TimeSeriesChartRenderPoint.init)
196
        let sampleCount = renderPoints.reduce(into: 0) { partialResult, point in
197
            if point.isSample {
198
                partialResult += 1
199
            }
200
        }
201

            
202
        let displayColumns = max(Int((width * max(displayScale, 1)).rounded(.up)), 1)
203
        let maximumSamplesToRender = max(displayColumns * (style.drawsArea ? 3 : 4), 240)
204

            
205
        guard sampleCount > maximumSamplesToRender, context.isValid else {
206
            return renderPoints
207
        }
208

            
209
        var scaledPoints: [TimeSeriesChartRenderPoint] = []
210
        var currentSegment: [TimeSeriesChartRenderPoint] = []
211

            
212
        for point in renderPoints {
213
            if point.isDiscontinuity {
214
                appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns)
215
                currentSegment.removeAll(keepingCapacity: true)
216

            
217
                if !scaledPoints.isEmpty, scaledPoints.last?.isDiscontinuity == false {
218
                    appendScaledPoint(point, to: &scaledPoints)
219
                }
220
            } else {
221
                currentSegment.append(point)
222
            }
223
        }
224

            
225
        appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns)
226
        return scaledPoints.isEmpty ? renderPoints : scaledPoints
227
    }
228

            
229
    private func appendScaledSegment(
230
        _ segment: [TimeSeriesChartRenderPoint],
231
        to scaledPoints: inout [TimeSeriesChartRenderPoint],
232
        displayColumns: Int
233
    ) {
234
        guard !segment.isEmpty else { return }
235

            
236
        if segment.count <= max(displayColumns * 2, 120) {
237
            for point in segment {
238
                appendScaledPoint(point, to: &scaledPoints)
239
            }
240
            return
241
        }
242

            
243
        var bucket: [TimeSeriesChartRenderPoint] = []
244
        var currentColumn: Int?
245

            
246
        for point in segment {
247
            let column = displayColumn(for: point, totalColumns: displayColumns)
248

            
249
            if let currentColumn, currentColumn != column {
250
                appendBucket(bucket, to: &scaledPoints)
251
                bucket.removeAll(keepingCapacity: true)
252
            }
253

            
254
            bucket.append(point)
255
            currentColumn = column
256
        }
257

            
258
        appendBucket(bucket, to: &scaledPoints)
259
    }
260

            
261
    private func appendBucket(
262
        _ bucket: [TimeSeriesChartRenderPoint],
263
        to scaledPoints: inout [TimeSeriesChartRenderPoint]
264
    ) {
265
        guard !bucket.isEmpty else { return }
266

            
267
        if bucket.count <= 2 {
268
            for point in bucket {
269
                appendScaledPoint(point, to: &scaledPoints)
270
            }
271
            return
272
        }
273

            
274
        let firstPoint = bucket.first!
275
        let lastPoint = bucket.last!
276
        let minimumPoint = bucket.min { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
277
        let maximumPoint = bucket.max { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
278

            
279
        let orderedPoints = [firstPoint, minimumPoint, maximumPoint, lastPoint]
280
            .sorted { lhs, rhs in
281
                if lhs.timestamp == rhs.timestamp {
282
                    return lhs.sourceID < rhs.sourceID
283
                }
284
                return lhs.timestamp < rhs.timestamp
285
            }
286

            
287
        var emittedPointIDs: Set<Int> = []
288
        for point in orderedPoints where emittedPointIDs.insert(point.sourceID).inserted {
289
            appendScaledPoint(point, to: &scaledPoints)
290
        }
291
    }
292

            
293
    private func appendScaledPoint(
294
        _ point: TimeSeriesChartRenderPoint,
295
        to scaledPoints: inout [TimeSeriesChartRenderPoint]
296
    ) {
297
        guard !(scaledPoints.last?.timestamp == point.timestamp &&
298
                scaledPoints.last?.value == point.value &&
299
                scaledPoints.last?.kind == point.kind) else {
300
            return
301
        }
302

            
303
        scaledPoints.append(point)
304
    }
305

            
306
    private func displayColumn(
307
        for point: TimeSeriesChartRenderPoint,
308
        totalColumns: Int
309
    ) -> Int {
310
        let totalColumns = max(totalColumns, 1)
311
        let timeSpan = max(Double(context.size.width), 1)
312
        let normalizedOffset = min(
313
            max((point.timestamp.timeIntervalSince1970 - Double(context.origin.x)) / timeSpan, 0),
314
            1
315
        )
316

            
317
        return min(
318
            Int((normalizedOffset * Double(totalColumns - 1)).rounded(.down)),
319
            totalColumns - 1
320
        )
321
    }
322
}
323

            
324
struct TimeSeriesChartHorizontalGuides: View {
325
    let context: ChartContext
326
    let labelCount: Int
327
    var strokeColor: Color = Color.secondary.opacity(0.38)
328
    var lineWidth: CGFloat = 0.85
329

            
330
    var body: some View {
331
        GeometryReader { geometry in
332
            Path { path in
333
                for labelIndex in 1...max(labelCount, 1) {
334
                    let y = context.yGuidePosition(
335
                        for: labelIndex,
336
                        of: labelCount,
337
                        height: geometry.size.height
338
                    )
339
                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
340
                }
341
            }
342
            .stroke(strokeColor, lineWidth: lineWidth)
343
        }
344
    }
345
}
346

            
347
struct TimeSeriesChartVerticalGuides: View {
348
    let context: ChartContext
349
    let labelCount: Int
350
    var visibleLabelRange: Range<Int>? = nil
351
    var strokeColor: Color = Color.secondary.opacity(0.34)
352
    var strokeStyle: StrokeStyle = StrokeStyle(lineWidth: 0.8, dash: [4, 4])
353

            
354
    var body: some View {
355
        GeometryReader { geometry in
356
            Path { path in
357
                for labelIndex in resolvedLabelRange {
358
                    let x = context.xGuidePosition(
359
                        for: labelIndex,
360
                        of: labelCount,
361
                        width: geometry.size.width
362
                    )
363
                    path.move(to: CGPoint(x: x, y: 0))
364
                    path.addLine(to: CGPoint(x: x, y: geometry.size.height))
365
                }
366
            }
367
            .stroke(strokeColor, style: strokeStyle)
368
        }
369
    }
370

            
371
    private var resolvedLabelRange: Range<Int> {
372
        visibleLabelRange ?? 2..<max(labelCount, 2)
373
    }
374
}
375

            
376
private struct TimeSeriesChartRenderPoint: Hashable {
377
    let sourceID: Int
378
    let timestamp: Date
379
    let value: Double
380
    let kind: TimeSeriesChartPointKind
381

            
382
    init<Point: TimeSeriesChartPointRepresentable>(_ point: Point) {
383
        self.sourceID = point.chartPointID
384
        self.timestamp = point.chartTimestamp
385
        self.value = point.chartValue
386
        self.kind = point.chartPointKind
387
    }
388

            
389
    var isSample: Bool {
390
        kind == .sample
391
    }
392

            
393
    var isDiscontinuity: Bool {
394
        kind == .discontinuity
395
    }
396

            
397
    var cgPoint: CGPoint {
398
        CGPoint(x: timestamp.timeIntervalSince1970, y: value)
399
    }
400
}
401

            
402
extension ChartContext {
403
    func yGuidePosition(
404
        for labelIndex: Int,
405
        of labelCount: Int,
406
        height: CGFloat
407
    ) -> CGFloat {
408
        let value = yAxisLabel(for: labelIndex, of: max(labelCount, 2))
409
        let anchorPoint = CGPoint(x: origin.x, y: CGFloat(value))
410
        return placeInRect(point: anchorPoint).y * height
411
    }
412

            
413
    func xGuidePosition(
414
        for labelIndex: Int,
415
        of labelCount: Int,
416
        width: CGFloat
417
    ) -> CGFloat {
418
        let value = xAxisLabel(for: labelIndex, of: max(labelCount, 2))
419
        let anchorPoint = CGPoint(x: CGFloat(value), y: origin.y)
420
        return placeInRect(point: anchorPoint).x * width
421
    }
422
}