1 contributor
//
// MeasurementChartView.swift
// USB Meter
//
// Created by Bogdan Timofte on 06/05/2020.
// Copyright © 2020 Bogdan Timofte. All rights reserved.
//
import SwiftUI
struct MeasurementChartView: View {
private let minimumTimeSpan: TimeInterval = 1
private let minimumVoltageSpan = 0.5
private let minimumCurrentSpan = 0.5
private let minimumPowerSpan = 0.5
private let axisColumnWidth: CGFloat = 46
private let chartSectionSpacing: CGFloat = 8
private let xAxisHeight: CGFloat = 28
@EnvironmentObject private var measurements: Measurements
var timeRange: ClosedRange<Date>? = nil
@State var displayVoltage: Bool = false
@State var displayCurrent: Bool = false
@State var displayPower: Bool = true
let xLabels: Int = 4
let yLabels: Int = 4
var body: some View {
let powerSeries = series(for: measurements.power, minimumYSpan: minimumPowerSpan)
let voltageSeries = series(for: measurements.voltage, minimumYSpan: minimumVoltageSpan)
let currentSeries = series(for: measurements.current, minimumYSpan: minimumCurrentSpan)
let primarySeries = displayedPrimarySeries(
powerSeries: powerSeries,
voltageSeries: voltageSeries,
currentSeries: currentSeries
)
Group {
if let primarySeries {
VStack(alignment: .leading, spacing: 12) {
chartToggleBar
GeometryReader { geometry in
let plotHeight = max(geometry.size.height - xAxisHeight, 140)
VStack(spacing: 6) {
HStack(spacing: chartSectionSpacing) {
primaryAxisView(
height: plotHeight,
powerSeries: powerSeries,
voltageSeries: voltageSeries,
currentSeries: currentSeries
)
.frame(width: axisColumnWidth, height: plotHeight)
ZStack {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color.primary.opacity(0.05))
RoundedRectangle(cornerRadius: 18, style: .continuous)
.stroke(Color.secondary.opacity(0.16), lineWidth: 1)
horizontalGuides(context: primarySeries.context)
verticalGuides(context: primarySeries.context)
renderedChart(
powerSeries: powerSeries,
voltageSeries: voltageSeries,
currentSeries: currentSeries
)
}
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
.frame(maxWidth: .infinity)
.frame(height: plotHeight)
secondaryAxisView(
height: plotHeight,
powerSeries: powerSeries,
voltageSeries: voltageSeries,
currentSeries: currentSeries
)
.frame(width: axisColumnWidth, height: plotHeight)
}
xAxisLabelsView(context: primarySeries.context)
.frame(height: xAxisHeight)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
}
} else {
VStack(alignment: .leading, spacing: 12) {
chartToggleBar
Text("Nothing to show!")
.foregroundColor(.secondary)
}
}
}
.font(.footnote)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var chartToggleBar: some View {
HStack(spacing: 8) {
Button(action: {
self.displayVoltage.toggle()
if self.displayVoltage {
self.displayPower = false
}
}) { Text("Voltage") }
.asEnableFeatureButton(state: displayVoltage)
Button(action: {
self.displayCurrent.toggle()
if self.displayCurrent {
self.displayPower = false
}
}) { Text("Current") }
.asEnableFeatureButton(state: displayCurrent)
Button(action: {
self.displayPower.toggle()
if self.displayPower {
self.displayCurrent = false
self.displayVoltage = false
}
}) { Text("Power") }
.asEnableFeatureButton(state: displayPower)
}
.frame(maxWidth: .infinity, alignment: .center)
}
@ViewBuilder
private func primaryAxisView(
height: CGFloat,
powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext)
) -> some View {
if displayPower {
yAxisLabelsView(
height: height,
context: powerSeries.context,
measurementUnit: "W",
tint: .red
)
} else if displayVoltage {
yAxisLabelsView(
height: height,
context: voltageSeries.context,
measurementUnit: "V",
tint: .green
)
} else if displayCurrent {
yAxisLabelsView(
height: height,
context: currentSeries.context,
measurementUnit: "A",
tint: .blue
)
}
}
@ViewBuilder
private func renderedChart(
powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext)
) -> some View {
if self.displayPower {
Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red)
.opacity(0.72)
} else {
if self.displayVoltage {
Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green)
.opacity(0.78)
}
if self.displayCurrent {
Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue)
.opacity(0.78)
}
}
}
@ViewBuilder
private func secondaryAxisView(
height: CGFloat,
powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext)
) -> some View {
if displayVoltage && displayCurrent {
yAxisLabelsView(
height: height,
context: currentSeries.context,
measurementUnit: "A",
tint: .blue
)
} else {
primaryAxisView(
height: height,
powerSeries: powerSeries,
voltageSeries: voltageSeries,
currentSeries: currentSeries
)
}
}
private func displayedPrimarySeries(
powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext)
) -> (points: [Measurements.Measurement.Point], context: ChartContext)? {
if displayPower {
return powerSeries.points.isEmpty ? nil : powerSeries
}
if displayVoltage {
return voltageSeries.points.isEmpty ? nil : voltageSeries
}
if displayCurrent {
return currentSeries.points.isEmpty ? nil : currentSeries
}
return nil
}
private func series(
for measurement: Measurements.Measurement,
minimumYSpan: Double
) -> (points: [Measurements.Measurement.Point], context: ChartContext) {
let points = measurement.points.filter { point in
guard let timeRange else { return true }
return timeRange.contains(point.timestamp)
}
let context = ChartContext()
for point in points {
context.include(point: point.point())
}
if !points.isEmpty {
context.ensureMinimumSize(
width: CGFloat(minimumTimeSpan),
height: CGFloat(minimumYSpan)
)
}
return (points, context)
}
private func yGuidePosition(
for labelIndex: Int,
context: ChartContext,
height: CGFloat
) -> CGFloat {
let value = context.yAxisLabel(for: labelIndex, of: yLabels)
let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
return context.placeInRect(point: anchorPoint).y * height
}
private func xGuidePosition(
for labelIndex: Int,
context: ChartContext,
width: CGFloat
) -> CGFloat {
let value = context.xAxisLabel(for: labelIndex, of: xLabels)
let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
return context.placeInRect(point: anchorPoint).x * width
}
// MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
fileprivate func xAxisLabelsView(
context: ChartContext
) -> some View {
var timeFormat: String?
switch context.size.width {
case 0..<3600: timeFormat = "HH:mm:ss"
case 3600...86400: timeFormat = "HH:mm"
default: timeFormat = "E HH:mm"
}
let labels = (1...xLabels).map {
Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!)
}
return HStack(spacing: chartSectionSpacing) {
Color.clear
.frame(width: axisColumnWidth)
GeometryReader { geometry in
let labelWidth = max(
geometry.size.width / CGFloat(max(xLabels - 1, 1)),
1
)
ZStack(alignment: .topLeading) {
Path { path in
for labelIndex in 1...self.xLabels {
let x = xGuidePosition(
for: labelIndex,
context: context,
width: geometry.size.width
)
path.move(to: CGPoint(x: x, y: 0))
path.addLine(to: CGPoint(x: x, y: 6))
}
}
.stroke(Color.secondary.opacity(0.26), lineWidth: 0.75)
ForEach(Array(labels.enumerated()), id: \.offset) { item in
let labelIndex = item.offset + 1
let centerX = xGuidePosition(
for: labelIndex,
context: context,
width: geometry.size.width
)
Text(item.element)
.font(.caption.weight(.semibold))
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.68)
.frame(width: labelWidth)
.position(
x: centerX,
y: geometry.size.height * 0.7
)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
Color.clear
.frame(width: axisColumnWidth)
}
}
fileprivate func yAxisLabelsView(
height: CGFloat,
context: ChartContext,
measurementUnit: String,
tint: Color
) -> some View {
GeometryReader { geometry in
ZStack(alignment: .top) {
ForEach(0..<yLabels, id: \.self) { row in
let labelIndex = yLabels - row
Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
.font(.caption2.weight(.semibold))
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.72)
.frame(width: max(geometry.size.width - 6, 0))
.position(
x: geometry.size.width / 2,
y: yGuidePosition(
for: labelIndex,
context: context,
height: geometry.size.height
)
)
}
Text(measurementUnit)
.font(.caption2.weight(.bold))
.foregroundColor(tint)
.padding(.horizontal, 6)
.padding(.vertical, 4)
.background(
Capsule(style: .continuous)
.fill(tint.opacity(0.14))
)
.padding(.top, 6)
}
}
.frame(height: height)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(tint.opacity(0.12))
)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(tint.opacity(0.20), lineWidth: 1)
)
}
fileprivate func horizontalGuides(context: ChartContext) -> some View {
GeometryReader { geometry in
Path { path in
for labelIndex in 1...self.yLabels {
let y = yGuidePosition(
for: labelIndex,
context: context,
height: geometry.size.height
)
path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
}
}
.stroke(Color.secondary.opacity(0.38), lineWidth: 0.85)
}
}
fileprivate func verticalGuides(context: ChartContext) -> some View {
GeometryReader { geometry in
Path { path in
for labelIndex in 2..<self.xLabels {
let x = xGuidePosition(
for: labelIndex,
context: context,
width: geometry.size.width
)
path.move(to: CGPoint(x: x, y: 0) )
path.addLine(to: CGPoint(x: path.currentPoint!.x, y: geometry.size.height) )
}
}
.stroke(Color.secondary.opacity(0.34), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
}
}
}
struct Chart : View {
let points: [Measurements.Measurement.Point]
let context: ChartContext
var areaChart: Bool = false
var strokeColor: Color = .black
var body : some View {
GeometryReader { geometry in
if self.areaChart {
self.path( geometry: geometry )
.fill(LinearGradient( gradient: .init(colors: [Color.red, Color.green]), startPoint: .init(x: 0.5, y: 0.1), endPoint: .init(x: 0.5, y: 0.9)))
} else {
self.path( geometry: geometry )
.stroke(self.strokeColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
}
}
}
fileprivate func path(geometry: GeometryProxy) -> Path {
return Path { path in
guard let first = points.first else { return }
let firstPoint = context.placeInRect(point: first.point())
path.move(to: CGPoint(x: firstPoint.x * geometry.size.width, y: firstPoint.y * geometry.size.height ) )
for item in points.map({ context.placeInRect(point: $0.point()) }) {
path.addLine(to: CGPoint(x: item.x * geometry.size.width, y: item.y * geometry.size.height ) )
}
if self.areaChart {
let lastPointX = context.placeInRect(point: CGPoint(x: points.last!.point().x, y: context.origin.y ))
let firstPointX = context.placeInRect(point: CGPoint(x: points.first!.point().x, y: context.origin.y ))
path.addLine(to: CGPoint(x: lastPointX.x * geometry.size.width, y: lastPointX.y * geometry.size.height ) )
path.addLine(to: CGPoint(x: firstPointX.x * geometry.size.width, y: firstPointX.y * geometry.size.height ) )
// MARK: Nu e nevoie. Fill inchide automat calea
// path.closeSubpath()
}
}
}
}