diff --git a/CLAUDE.md b/CLAUDE.md index e9bbba9..bd704c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,6 +117,17 @@ native, and smooth enough to impress. live is drawn in ink on the same curve. No dot travels along a line. - **Every chart takes a finger.** The point under it is marked, and the line under the chart says what was there. Two fingers give the span. +- **History sits small and grows** (Fredrik, 14 Oct 2026). A node's chart + is compact, so the view never scrolls for it; a tap opens it on the + whole screen, growing out of the small one. Chips add or take away + series, kept per viewer; the last one stays, and a hidden series never + changes what the others say. +- **One material for every power trace** (Fredrik, 14 Oct 2026). Live, + Today and 48 h, small and enlarged, draw through `TraceInk` (band, + body, line, glow only while live), `ChartAxes`, `LockOn` and + `SlidingSurface` (a slide of 0.25 s, a spring of 0.35 s), and the hero + follows the finger on each. Going from Live to Today changes the time, + never the material. A new chart starts from these. - **Charts draw on the engine.** Geometry is a value built once per data change (`FTWShared/Charts`, tested). A chart is one Canvas (`ChartSurface`) that never sees the finger and a small `ScrubOverlay` diff --git a/appleApp/FTW/UI/ChartMotion.swift b/appleApp/FTW/UI/ChartMotion.swift deleted file mode 100644 index 3e67a7b..0000000 --- a/appleApp/FTW/UI/ChartMotion.swift +++ /dev/null @@ -1,139 +0,0 @@ -import FTWKit -import SwiftUI - -/// How a chart's points move when its data changes: each point that -/// changed springs from where it was drawn to its new value, a few -/// milliseconds after the point before it, so a curve grows into shape -/// rather than jumping. An estimate the data replaces, or a hole the box -/// confirms, fades as the line settles. Under Reduce Motion the new data -/// shows at once and only the fade remains. -/// -/// The points are computed once per change of data; the motion only -/// interpolates between two lists it already has. -enum ChartMotion { - /// The spring Fredrik's brief names: response 0.55 s, damping 0.72. - static let response = 0.55 - static let damping = 0.72 - /// Between one changed point and the next. - static let stagger = 0.008 - /// The whole stagger never takes longer than this, however many points. - static let staggerCap = 0.35 - /// How long a fading estimate takes to go. - static let fade = 0.3 - - /// A damped spring's step from 0 to 1 at `t` seconds, as - /// `.spring(response:dampingFraction:)` moves a value. It overshoots a - /// little and settles. - static func spring(_ t: Double) -> Double { - guard t > 0 else { return 0 } - let omega = 2 * Double.pi / response - let zeta = damping - let wd = omega * (1 - zeta * zeta).squareRoot() - let decay = exp(-zeta * omega * t) - return 1 - decay * (cos(wd * t) + zeta * omega / wd * sin(wd * t)) - } - - /// When a spring has settled to within a hair of its value. - static var settle: Double { response * 2 } -} - -/// One change of a chart's points, followed while it moves. -struct ChartChange: Equatable { - /// Where each changed point was drawn when the change began, by id. - var from: [Double: Double] = [:] - /// Each changed point's place in the stagger, by id. - var order: [Double: Int] = [:] - /// Estimates the change took away, drawn fading. - var fading: [HistoryPlot.Point] = [] - var began: Date = .distantPast - var stagger: Double = ChartMotion.stagger - - /// How long this change moves for. - var duration: Double { ChartMotion.settle + Double(order.count) * stagger } - - /// The points as drawn at `date`. - func drawn(_ points: [HistoryPlot.Point], at date: Date) -> [HistoryPlot.Point] { - guard !order.isEmpty else { return points } - let elapsed = date.timeIntervalSince(began) - return points.map { p in - guard let i = order[p.id], let start = from[p.id] else { return p } - let f = ChartMotion.spring(elapsed - Double(i) * stagger) - return HistoryPlot.Point(t: p.t, v: start + (p.v - start) * f, kind: p.kind, run: p.run, id: p.id) - } - } - - /// How much of the fading estimates is left at `date`. - func fadeLeft(at date: Date) -> Double { - max(0, 1 - date.timeIntervalSince(began) / ChartMotion.fade) - } - - /// The change from what was drawn to `new`. A point that is new springs - /// from its neighbour on the left, or from zero on a chart's first data, - /// so it grows in rather than appearing. - static func between(_ old: [HistoryPlot.Point], drawn: [HistoryPlot.Point], _ new: [HistoryPlot.Point], at date: Date, still: Bool) -> ChartChange { - var change = ChartChange(began: date) - let before = Dictionary(drawn.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) - let oldByID = Dictionary(old.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) - let newIDs = Set(new.filter { $0.kind == .estimate }.map(\.id)) - change.fading = old.filter { $0.kind == .estimate && !newIDs.contains($0.id) } - guard !still else { return change } - var order = 0 - var left: Double = 0 - for p in new { - if let was = before[p.id] { - if abs(was.v - p.v) > 0.5 || oldByID[p.id]?.kind != p.kind { - change.from[p.id] = was.v - change.order[p.id] = order - order += 1 - } - } else { - change.from[p.id] = old.isEmpty ? 0 : left - change.order[p.id] = order - order += 1 - } - left = p.v - } - if order > 0 { change.stagger = min(ChartMotion.stagger, ChartMotion.staggerCap / Double(order)) } - return change - } -} - -/// Holds a chart's points and moves between them: the chart draws what -/// `content` is handed, at the display's rate while a change moves and not -/// at all otherwise. -struct MovingPoints: View { - let points: [HistoryPlot.Point] - @ViewBuilder let content: (_ drawn: [HistoryPlot.Point], _ fading: [HistoryPlot.Point], _ fadeLeft: Double) -> Content - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var shown: [HistoryPlot.Point] = [] - @State private var change = ChartChange() - @State private var moving = false - - var body: some View { - TimelineView(.animation(paused: !moving)) { context in - let now = moving ? context.date : .distantFuture - content(change.drawn(shown, at: now), change.fading, change.fading.isEmpty ? 0 : change.fadeLeft(at: now)) - } - .onAppear { begin(points) } - .onChange(of: points) { _, new in begin(new) } - } - - private func begin(_ new: [HistoryPlot.Point]) { - let now = Date() - // Interrupted mid-move: start from where the points are drawn now. - let drawn = change.drawn(shown, at: now) - change = ChartChange.between(shown, drawn: drawn, new, at: now, still: reduceMotion) - shown = new - let length = max(change.order.isEmpty ? 0 : change.duration, change.fading.isEmpty ? 0 : ChartMotion.fade) - guard length > 0 else { - moving = false - return - } - moving = true - let began = change.began - Task { @MainActor in - try? await Task.sleep(for: .seconds(length + 0.05)) - if change.began == began { moving = false } - } - } -} diff --git a/appleApp/FTW/UI/ChartScrub.swift b/appleApp/FTW/UI/ChartScrub.swift deleted file mode 100644 index de09a85..0000000 --- a/appleApp/FTW/UI/ChartScrub.swift +++ /dev/null @@ -1,122 +0,0 @@ -import Charts -import FTWKit -import SwiftUI - -/// The line under a chart: its readout while the finger is down, else its -/// own sentence. The swap is instant, as the finger is, and nothing below -/// them moves. -struct ReadoutLine: View { - let readout: ChartReadout? - let otherwise: String - - var body: some View { - ZStack(alignment: .topLeading) { - // The sentence keeps its room, so a shorter readout does not - // pull the card up. - Text(otherwise) - .opacity(readout == nil ? 1 : 0) - if let readout { - (Text(readout.when).font(.footnote.weight(.semibold).monospacedDigit()).foregroundStyle(Theme.fg) - + Text(verbatim: " ") - + Text(readout.what).foregroundStyle(Theme.fg)) - .accessibilityIdentifier("chart.readout") - } - } - .font(.footnote) - .foregroundStyle(Theme.fgDim) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - .transaction { $0.animation = nil } - } -} - -/// The finger on a chart: one slot, or two fingers' range. A light tick -/// per slot (never more than about 25 a second) and a firmer one at a -/// landmark: now, the edges of the cheapest hours, the day's high and low, -/// the car's goal. Nothing on a data update. -struct ChartScrub: ViewModifier { - @Binding var selected: Date? - @Binding var range: ClosedRange? - /// The slot a moment falls in, so a tick marks a step, not a pixel. - let slot: (Date) -> Double? - /// Whether a slot is a landmark. - let landmark: (Double) -> Bool - @State private var ticks = 0 - @State private var bumps = 0 - @State private var lastSlot: Double? - @State private var lastTick: ContinuousClock.Instant? - - func body(content: Content) -> some View { - // The selection never animates: the marker is where the finger is. - content - .chartXSelection(value: $selected) - .chartXSelection(range: $range) - .onChange(of: selected) { _, date in - guard let date else { - lastSlot = nil - return - } - // A moment with no slot keeps the last one, so coming back - // to it does not tick again. - guard let s = slot(date), s != lastSlot else { return } - lastSlot = s - let now = ContinuousClock.now - if landmark(s) { - bumps += 1 - } else if lastTick.map({ now - $0 >= .milliseconds(40) }) ?? true { - lastTick = now - ticks += 1 - } - } - .sensoryFeedback(.selection, trigger: ticks) - .sensoryFeedback(.impact(weight: .light), trigger: bumps) - } -} - -extension View { - func chartScrub(selected: Binding, range: Binding?>, slot: @escaping (Date) -> Double?, landmark: @escaping (Double) -> Bool = { _ in false }) -> some View { - modifier(ChartScrub(selected: selected, range: range, slot: slot, landmark: landmark)) - } -} - -/// The marks for the place under the finger: a thin rule, and a dot on the -/// line where there is one. -@ChartContentBuilder -func scrubMarks(_ date: Date?, value: Double?, color: Color) -> some ChartContent { - if let date { - RuleMark(x: .value("Selected", date)) - .foregroundStyle(Theme.fgDim.opacity(0.6)) - .lineStyle(StrokeStyle(lineWidth: 1)) - if let value { - PointMark(x: .value("Selected", date), y: .value("Power", value)) - .foregroundStyle(color) - .symbolSize(60) - } - } -} - -/// History as VoiceOver reads it: an hour a step, with the power and which -/// way it flowed. -struct HistoryDescriptor: AXChartDescriptorRepresentable { - let node: EnergyNode - let samples: [HistorySample] - let fromMs: Double - - func makeChartDescriptor() -> AXChartDescriptor { - let hour: Double = 3_600_000 - var hours: [(t: Double, v: Double)] = [] - let byHour = Dictionary(grouping: samples.filter { $0.watts != nil }) { ($0.startMs / hour).rounded(.down) } - for key in byHour.keys.sorted() { - let values = byHour[key]!.compactMap(\.watts) - hours.append((max(fromMs, key * hour), values.reduce(0, +) / Double(values.count))) - } - let lo = min(0, hours.map(\.v).min() ?? 0), hi = max(0, hours.map(\.v).max() ?? 0) - let end = samples.last.map { $0.startMs + $0.stepMs } ?? fromMs - let x = AXNumericDataAxisDescriptor(title: L("Time"), range: fromMs...max(fromMs + 1, end), gridlinePositions: []) { Readouts.clock($0) } - let y = AXNumericDataAxisDescriptor(title: L("Power"), range: lo...max(lo + 1, hi), gridlinePositions: []) { AppWords.power(abs($0)) } - let series = AXDataSeriesDescriptor(name: node.title, isContinuous: true, dataPoints: hours.map { - AXDataPoint(x: $0.t, y: $0.v, additionalValues: [], label: Readouts.word(node, $0.v)) - }) - return AXChartDescriptor(title: node.title, summary: nil, xAxis: x, yAxis: y, additionalAxes: [], series: [series]) - } -} diff --git a/appleApp/FTW/UI/NodeCharts.swift b/appleApp/FTW/UI/NodeCharts.swift deleted file mode 100644 index deaab63..0000000 --- a/appleApp/FTW/UI/NodeCharts.swift +++ /dev/null @@ -1,441 +0,0 @@ -import Charts -import FTWKit -import SwiftUI - -/// The curves of a node's view. Each is `Equatable` on the values it draws, -/// so a frame that changed nothing it shows does not redraw it. -/// -/// Watts are drawn as the node shows them (solar as what it makes), signed -/// where direction matters: up is into the home's battery or from the grid, -/// down is out. The axis says magnitudes and a word says which way; no -/// minus sign reaches the screen. - -private func date(_ ms: Double) -> Date { Date(timeIntervalSince1970: ms / 1000) } - -/// "15", on the 24-hour clock FTW uses in both languages: an English phone -/// would otherwise label the afternoon "03". -func hourLabel(_ value: AxisValue) -> some View { - Text(value.as(Date.self).map { String(format: "%02d", Calendar.current.component(.hour, from: $0)) } ?? "") - .font(.caption2) - .foregroundStyle(Theme.fgMuted) -} - -/// The largest text a chart's labels take: a chart is a drawing, and its -/// figures are said again in words under it and to VoiceOver. -let chartTextCap = DynamicTypeSize.xxLarge - -/// An axis label in a column of one width for the text size, so charts -/// stacked one above the other put the same hour at the same place. -struct AxisText: View { - let text: String - @ScaledMetric(relativeTo: .caption2) private var width: CGFloat = 44 - - init(_ text: String) { self.text = text } - - var body: some View { - Text(text) - .font(.caption2) - .foregroundStyle(Theme.fgMuted) - .lineLimit(1) - .frame(width: width, alignment: .leading) - } -} - -/// Axis labels as magnitudes: "2 kW" above the line and below it. The zero -/// line speaks for itself. -private func powerAxis() -> some AxisContent { - AxisMarks(position: .trailing, values: .automatic(desiredCount: 3)) { value in - AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)).foregroundStyle(Theme.line) - AxisValueLabel { - if let w = value.as(Double.self), w != 0 { - AxisText(PowerFormat.scale(w)) - } - } - } -} - -/// How a node's charts draw it: one colour pair for every chart of the -/// node, live, past and ahead, and for a node whose power runs both ways -/// the word for each direction inside the plot. -struct ChartLook: Equatable { - /// Into the battery, from the grid, or the node's only direction. - var up: Color - /// Out of the battery, to the grid. - var down: Color - /// The line over the areas. - var line: Color - var upWord: String? - var downWord: String? - - var signed: Bool { upWord != nil } -} - -/// "↑ charging" at the top of the plot and "↓ giving power" at its foot, so -/// a signed curve says which way is which where the eye already is. -private struct DirectionWords: ViewModifier { - let look: ChartLook - - func body(content: Content) -> some View { - content.chartPlotStyle { plot in - plot - .overlay(alignment: .topLeading) { word(look.upWord, "arrow.up") } - .overlay(alignment: .bottomLeading) { word(look.downWord, "arrow.down") } - } - } - - @ViewBuilder private func word(_ text: String?, _ symbol: String) -> some View { - if let text { - Label(text, systemImage: symbol) - .labelStyle(.titleAndIcon) - .font(.caption2.weight(.medium)) - .foregroundStyle(Theme.fgDim) - .lineLimit(1) - .padding(.horizontal, 4) - .padding(.vertical, 2) - .allowsHitTesting(false) - .accessibilityHidden(true) - } - } -} - -extension View { - func directionWords(_ look: ChartLook) -> some View { modifier(DirectionWords(look: look)) } -} - -/// A run of points drawn as two areas, the part above zero in one colour of -/// the pair and the part below in the other, under one line. -@ChartContentBuilder -private func signedRun(_ t: Double, _ v: Double, run: Int, look: ChartLook, opacity: (top: Double, bottom: Double), lineWidth: CGFloat) -> some ChartContent { - AreaMark(x: .value("Time", date(t)), yStart: .value("Zero", 0), yEnd: .value("Power", max(0, v)), series: .value("Run", "\(run)+")) - .foregroundStyle(LinearGradient(colors: [look.up.opacity(opacity.top), look.up.opacity(opacity.bottom)], startPoint: .top, endPoint: .bottom)) - .interpolationMethod(.monotone) - if look.signed { - AreaMark(x: .value("Time", date(t)), yStart: .value("Power", min(0, v)), yEnd: .value("Zero", 0), series: .value("Run", "\(run)-")) - .foregroundStyle(LinearGradient(colors: [look.down.opacity(opacity.bottom), look.down.opacity(opacity.top)], startPoint: .top, endPoint: .bottom)) - .interpolationMethod(.monotone) - } - LineMark(x: .value("Time", date(t)), y: .value("Power", v), series: .value("Run", "\(run)")) - .foregroundStyle(look.line) - .lineStyle(StrokeStyle(lineWidth: lineWidth, lineCap: .round)) - .interpolationMethod(.monotone) -} - -/// Today or the last two days, five minutes a point, from the box's tiles. -struct HistoryChart: View, Equatable { - let node: EnergyNode - let samples: [HistorySample] - let fromMs: Double - let toMs: Double - let look: ChartLook - let strideHours: Int - /// What the finger is on, for the line under the chart. - var onScrub: (ChartReadout?) -> Void = { _ in } - @State private var selected: Date? - @State private var range: ClosedRange? - - nonisolated static func == (a: Self, b: Self) -> Bool { - a.node == b.node && a.samples == b.samples && a.fromMs == b.fromMs && a.toMs == b.toMs && a.look == b.look && a.strideHours == b.strideHours - } - - var body: some View { - // Worked out once per change of data; the motion only moves - // between two lists. - MovingPoints(points: HistoryPlot.points(samples, fromMs: fromMs)) { drawn, fading, fadeLeft in - Chart { - if look.signed { - RuleMark(y: .value("Zero", 0)).foregroundStyle(Theme.line) - } - ForEach(drawn.filter { $0.kind == .measured }) { p in - signedRun(p.t, p.v, run: p.run, look: look, opacity: (0.35, 0.05), lineWidth: 1.5) - } - // A stretch still on its way: a faint band that says so, and - // a dashed estimate across it, never a value. - ForEach(Array(Self.bridges(drawn).enumerated()), id: \.offset) { i, bridge in - if let span = estimateSpan(bridge) { - RectangleMark(xStart: .value("From", date(span.lowerBound)), xEnd: .value("To", date(span.upperBound))) - .foregroundStyle(Theme.fgDim.opacity(0.07)) - .annotation(position: .overlay, alignment: .top) { - Text(L("fetching…")) - .font(.caption2) - .foregroundStyle(Theme.fgMuted) - .padding(.top, 4) - } - } - ForEach(bridge) { p in - LineMark(x: .value("Time", date(p.t)), y: .value("Power", p.v), series: .value("Estimate", "e\(i)")) - .foregroundStyle(look.line.opacity(0.6)) - .lineStyle(StrokeStyle(lineWidth: 1.5, lineCap: .round, dash: [3, 4])) - } - } - if fadeLeft > 0 { - ForEach(fading) { p in - LineMark(x: .value("Time", date(p.t)), y: .value("Power", p.v), series: .value("Estimate", "gone \(p.run)")) - .foregroundStyle(look.line.opacity(0.6 * fadeLeft)) - .lineStyle(StrokeStyle(lineWidth: 1.5, lineCap: .round, dash: [3, 4])) - } - } - if let range { - RectangleMark(xStart: .value("From", range.lowerBound), xEnd: .value("To", range.upperBound)) - .foregroundStyle(Theme.fgDim.opacity(0.12)) - } - scrubMarks(selected, value: selectedSample?.watts, color: (selectedSample?.watts ?? 0) < 0 ? look.down : look.line) - } - .chartXScale(domain: date(fromMs)...date(toMs)) - .chartXAxis { - AxisMarks(values: .stride(by: .hour, count: strideHours)) { value in - AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)).foregroundStyle(Theme.line) - AxisValueLabel { hourLabel(value) } - } - } - .chartYAxis { powerAxis() } - .chartYScale(domain: .automatic(includesZero: true)) - .directionWords(look) - .chartScrub(selected: $selected, range: $range, slot: { d in - samples.last { $0.startMs <= d.timeIntervalSince1970 * 1000 }?.startMs - }, landmark: { slot in landmarks.contains(slot) }) - } - .dynamicTypeSize(...chartTextCap) - .onChange(of: selected) { _, _ in report() } - .onChange(of: range) { _, _ in report() } - .accessibilityChartDescriptor(HistoryDescriptor(node: node, samples: samples, fromMs: fromMs)) - } - - private var selectedSample: HistorySample? { - guard let selected else { return nil } - let ms = selected.timeIntervalSince1970 * 1000 - return samples.last { $0.startMs <= ms } - } - - /// The day's high and low, for a firmer tick. - private var landmarks: Set { - let measured = samples.filter { $0.watts != nil } - var out = Set() - if let hi = measured.max(by: { $0.watts! < $1.watts! }) { out.insert(hi.startMs) } - if let lo = measured.min(by: { $0.watts! < $1.watts! }) { out.insert(lo.startMs) } - return out - } - - private func report() { - if let range { - onScrub(Readouts.history(node, samples, range: range.lowerBound.timeIntervalSince1970 * 1000...range.upperBound.timeIntervalSince1970 * 1000)) - } else if let selected { - onScrub(Readouts.history(node, samples, fromMs: fromMs, at: selected.timeIntervalSince1970 * 1000)) - } else { - onScrub(nil) - } - } - - /// The steps a bridge stands in for. - private func estimateSpan(_ bridge: [HistoryPlot.Point]) -> ClosedRange? { - let estimates = bridge.filter { $0.kind == .estimate } - guard let first = estimates.first, let last = estimates.last else { return nil } - let step = samples.first?.stepMs ?? 300_000 - return max(fromMs, first.id)...(last.id + step) - } - - /// Each run of estimates with the measured point either side, so the - /// dashed line meets the solid one. - static func bridges(_ points: [HistoryPlot.Point]) -> [[HistoryPlot.Point]] { - var out = [[HistoryPlot.Point]]() - var current = [HistoryPlot.Point]() - var previous: HistoryPlot.Point? - for p in points { - if p.kind == .estimate { - if current.isEmpty, let previous, previous.kind == .measured { current.append(previous) } - current.append(p) - } else if !current.isEmpty { - current.append(p) - out.append(current) - current = [] - } - previous = p - } - if !current.isEmpty { out.append(current) } - return out - } -} - -/// The car's energy hour by hour, from the box's ledger. -struct CarHoursChart: View, Equatable { - let hours: [CarEnergyModel.Hour] - let fromMs: Double - let toMs: Double - let strideHours: Int - var onScrub: (ChartReadout?) -> Void = { _ in } - @State private var selected: Date? - @State private var range: ClosedRange? - - nonisolated static func == (a: Self, b: Self) -> Bool { - a.hours == b.hours && a.fromMs == b.fromMs && a.toMs == b.toMs && a.strideHours == b.strideHours - } - - var body: some View { - Chart { - ForEach(hours.filter { $0.startMs + $0.lenMs > fromMs }) { h in - RectangleMark( - xStart: .value("From", date(max(fromMs, h.startMs))), - xEnd: .value("To", date(h.startMs + h.lenMs)), - yStart: .value("Zero", 0), - yEnd: .value("Energy", h.wh / 1000) - ) - .foregroundStyle(Theme.mobility) - .cornerRadius(2) - } - scrubMarks(selected, value: nil, color: Theme.mobility) - } - .chartScrub(selected: $selected, range: $range, slot: { d in - let ms = d.timeIntervalSince1970 * 1000 - return hours.first { $0.startMs <= ms && ms < $0.startMs + $0.lenMs }?.startMs - }) - .onChange(of: selected) { _, d in onScrub(d.flatMap { Readouts.carHour(hours, at: $0.timeIntervalSince1970 * 1000) }) } - .chartXScale(domain: date(fromMs)...date(toMs)) - .chartXAxis { - AxisMarks(values: .stride(by: .hour, count: strideHours)) { value in - AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)).foregroundStyle(Theme.line) - AxisValueLabel { hourLabel(value) } - } - } - .chartYAxis { - AxisMarks(position: .trailing, values: .automatic(desiredCount: 3)) { value in - AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)).foregroundStyle(Theme.line) - AxisValueLabel { - if let kwh = value.as(Double.self), kwh != 0 { - AxisText(AppWords.energy(kwh * 1000)) - } - } - } - } - .dynamicTypeSize(...chartTextCap) - } -} - -/// The hours ahead: what the box means each slot to do, as bars, and the -/// price under the same hours, so a plan and its reason sit side by side. -struct AheadChart: View, Equatable { - typealias Bar = ChartSlot - typealias Price = ChartPrice - - let node: EnergyNode - let bars: [Bar] - let prices: [Price] - let currency: String - let nowMs: Double - let look: ChartLook - /// Whether the bars are a plan (battery, grid, car) or a forecast. - var forecast = false - var onScrub: (ChartReadout?) -> Void = { _ in } - @State private var selected: Date? - @State private var range: ClosedRange? - - nonisolated static func == (a: Self, b: Self) -> Bool { - a.node == b.node && a.bars == b.bars && a.prices == b.prices && a.currency == b.currency && a.nowMs == b.nowMs && a.look == b.look && a.forecast == b.forecast - } - - var body: some View { - let to = nowMs + PlanModel.aheadMs - VStack(spacing: 8) { - Chart { - ForEach(bars) { b in - RectangleMark( - xStart: .value("From", date(max(nowMs, b.startMs))), - xEnd: .value("To", date(min(to, b.endMs))), - yStart: .value("Zero", 0), - yEnd: .value("Power", b.watts) - ) - .foregroundStyle(b.watts >= 0 ? look.up : look.down) - .cornerRadius(1.5) - } - if look.signed { - RuleMark(y: .value("Zero", 0)).foregroundStyle(Theme.line) - } - if let range { - RectangleMark(xStart: .value("From", range.lowerBound), xEnd: .value("To", range.upperBound)) - .foregroundStyle(Theme.fgDim.opacity(0.12)) - } - scrubMarks(selected, value: nil, color: look.line) - } - .chartXScale(domain: date(nowMs)...date(to)) - .chartXAxis(.hidden) - .chartYAxis { powerAxis() } - .chartYScale(domain: .automatic(includesZero: true)) - .directionWords(look) - .chartScrub(selected: $selected, range: $range, slot: { d in - Self.slot(bars, prices, d.timeIntervalSince1970 * 1000) - }, landmark: { slot in slot == bars.first?.startMs || slot == cheapest }) - .onChange(of: selected) { _, _ in report() } - .onChange(of: range) { _, _ in report() } - if !prices.isEmpty { - PriceStripChart(prices: prices, currency: currency, fromMs: nowMs, toMs: to) - .frame(height: 36) - } - } - .dynamicTypeSize(...chartTextCap) - } -} - -extension AheadChart { - /// The slot a moment falls in: a bar's, else a price's. - static func slot(_ bars: [Bar], _ prices: [Price], _ ms: Double) -> Double? { - bars.first { $0.startMs <= ms && ms < $0.endMs }?.startMs ?? prices.first { $0.startMs <= ms && ms < $0.endMs }?.startMs - } - - /// The cheapest price ahead, for a firmer tick. - private var cheapest: Double? { prices.min { $0.minor < $1.minor }?.startMs } - - private func report() { - if let range { - onScrub(Readouts.ahead(node, bars, prices, currency: currency, forecast: forecast, range: range.lowerBound.timeIntervalSince1970 * 1000...range.upperBound.timeIntervalSince1970 * 1000)) - } else if let selected { - onScrub(Readouts.ahead(node, bars, prices, currency: currency, forecast: forecast, at: max(nowMs, selected.timeIntervalSince1970 * 1000))) - } else { - onScrub(nil) - } - } -} - -/// Prices across the same hours as the chart above, coloured cheap to dear -/// against their own average, with the hours named under them. -struct PriceStripChart: View, Equatable { - let prices: [AheadChart.Price] - let currency: String - let fromMs: Double - let toMs: Double - - var body: some View { - let shown = prices.filter { $0.endMs > fromMs && $0.startMs < toMs } - let mean = shown.isEmpty ? 0 : shown.map(\.minor).reduce(0, +) / Double(shown.count) - Chart { - ForEach(shown) { p in - RectangleMark( - xStart: .value("From", date(max(fromMs, p.startMs))), - xEnd: .value("To", date(min(toMs, p.endMs))), - yStart: .value("Zero", 0), - yEnd: .value("Price", PriceUnits.display(p.minor, currency)) - ) - .foregroundStyle(Theme.price(p.minor > mean * 1.05 ? .dear : p.minor < mean * 0.95 ? .cheap : .flat)) - .accessibilityLabel(AppWords.clock(p.startMs, zone: .current)) - .accessibilityValue("\(AppWords.decimal(PriceStrip.text(p.minor, currency))) \(PriceUnits.unit(currency).perKwh)") - } - } - .chartXScale(domain: date(fromMs)...date(toMs)) - .chartXAxis { - AxisMarks(preset: .aligned, values: .stride(by: .hour, count: 3)) { value in - AxisValueLabel { hourLabel(value) } - } - } - .chartYAxis { - // The dearest hour, in the column the chart above keeps for its - // own scale: the price's scale, and the hours line up. - AxisMarks(position: .trailing, values: shown.map(\.minor).max().map { [PriceUnits.display($0, currency)] } ?? []) { value in - // Hung below its tick, inside the strip, clear of the scale - // of the chart above. - AxisValueLabel(anchor: .topLeading) { - if let v = value.as(Double.self) { - AxisText("\(Int(v.rounded())) \(PriceUnits.unit(currency).label)") - } - } - } - } - .accessibilityLabel(L("Prices ahead")) - } -} diff --git a/appleApp/FTW/UI/NodeHistory.swift b/appleApp/FTW/UI/NodeHistory.swift new file mode 100644 index 0000000..482b5a5 --- /dev/null +++ b/appleApp/FTW/UI/NodeHistory.swift @@ -0,0 +1,412 @@ +import FTWKit +import SwiftUI + +// MARK: - Which series a chart shows + +/// The series a node's history shows, per chart kind, kept per viewer. +/// The node's own series first; at least one always stays. +enum SeriesChoice { + static func key(_ node: EnergyNode) -> String { "ftw.chart.series.\(node.rawValue)" } + + static func load(_ node: EnergyNode) -> [EnergyNode] { + SeriesPick.read(UserDefaults.standard.string(forKey: key(node)), own: node) + } + + static func save(_ series: [EnergyNode], for node: EnergyNode) { + UserDefaults.standard.set(series.map(\.rawValue).joined(separator: ","), forKey: key(node)) + } +} + +/// Quiet chips for the series a history can show: on, a series speaks in +/// its colour with a small dot; off, it is ink. A tap adds or takes away a +/// series, and the last one stays. +struct SeriesChips: View { + let own: EnergyNode + let available: [EnergyNode] + @Binding var chosen: [EnergyNode] + @State private var taps = 0 + @ScaledMetric(relativeTo: .caption2) private var size: CGFloat = 10.5 + + var body: some View { + HStack(spacing: 6) { + ForEach(available, id: \.self) { node in + let on = chosen.contains(node) + Button { + let next = SeriesPick.toggled(chosen, node, own: own) + guard next != chosen else { return } + withAnimation(Theme.spring) { chosen = next } + SeriesChoice.save(next, for: own) + taps += 1 + } label: { + HStack(spacing: 4) { + Circle() + .fill(on ? NodeChartLook.color(node) : Color.clear) + .strokeBorder(on ? Color.clear : Theme.ink3, lineWidth: 1) + .frame(width: 6, height: 6) + Text(node.caps) + .font(.system(size: size, weight: .medium, design: .monospaced)) + .tracking(0.5) + .foregroundStyle(on ? NodeChartLook.color(node) : Theme.ink3) + } + .padding(.horizontal, 8) + .frame(minHeight: 28) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel(node.title) + .accessibilityAddTraits(on ? .isSelected : []) + .accessibilityIdentifier("chart.series.\(node.rawValue)") + } + Spacer(minLength: 0) + } + .lineLimit(1) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + .sensoryFeedback(.selection, trigger: taps) + } +} + +/// One colour pair per node, for every chart of it. +enum NodeChartLook { + static func color(_ node: EnergyNode) -> Color { + switch node { + case .solar: return Theme.solar + case .battery: return Theme.stores + case .home: return Theme.ink + case .grid: return Theme.grid + case .car: return Theme.car + } + } + + static func look(_ node: EnergyNode) -> TraceLook { + let words = node.directions + switch node { + case .battery: return TraceLook(up: Theme.stores, down: Theme.gives, upWord: words.map { "↑ " + $0.up.uppercased() }, downWord: words.map { "↓ " + $0.down.uppercased() }) + case .grid: return TraceLook(up: Theme.grid, down: Theme.grid, upWord: words.map { "↑ " + $0.up.uppercased() }, downWord: words.map { "↓ " + $0.down.uppercased() }) + default: return TraceLook(up: color(node), down: color(node)) + } + } +} + +// MARK: - What the line under the chart says + +/// The words for every slot of the chart on screen, made once per change of +/// data, so the line under the chart and the hero cost a subscript per +/// slot. +@Observable +@MainActor +final class PeriodText { + /// What the hero says under a finger, as on the live trace: the node's + /// own value and "14:30–15:00 · charging". No watts over a hole. + struct Hero { + var watts: Double? + var signed: Double + var word: String + } + + private(set) var slots: [String] = [] + private(set) var spans: (ClosedRange) -> String = { _ in "" } + private(set) var heroes: [Hero] = [] + private(set) var heroSpans: (ClosedRange) -> Hero? = { _ in nil } + + func set(_ slots: [String], spans: @escaping (ClosedRange) -> String, heroes: [Hero] = [], heroSpans: @escaping (ClosedRange) -> Hero? = { _ in nil }) { + self.slots = slots + self.spans = spans + self.heroes = heroes + self.heroSpans = heroSpans + } + + func text(_ scrub: ScrubModel) -> String? { + if let i = scrub.index, slots.indices.contains(i) { return slots[i] } + if let r = scrub.span, r.upperBound < slots.count { return spans(r) } + return nil + } + + func hero(_ scrub: ScrubModel) -> Hero? { + if let i = scrub.index, heroes.indices.contains(i) { return heroes[i] } + if let r = scrub.span, r.upperBound < heroes.count { return heroSpans(r) } + return nil + } +} + +// MARK: - Today and two days + +/// Today on its fixed day, or the last two days, on the engine, in the live +/// trace's material: each chosen series with its band, the node's own with +/// its body, holes as they are, and the finger reading one slot at a time, +/// up to the last slot with data. Under the finger the hero and the line +/// below speak as on Live. +struct HistoryChartArea: View { + let site: SiteModel + let node: EnergyNode + let home: HomeModels + let scrub: ScrubModel + let period: NodeView.Timebase + let series: [EnergyNode] + let text: PeriodText + @State private var size: CGSize = .zero + @State private var shown: YDomain? + @ScaledMetric(relativeTo: .caption2) private var axis: CGFloat = 10 + @ScaledMetric(relativeTo: .caption) private var tag: CGFloat = 11 + + var body: some View { + let frame = ChartFrame(top: tag * 1.35 + 8, bottom: axis * 1.4 + 4, trailing: axis * 3.2) + let plot = frame.plot(in: size).size + let picture = build(plot) + let own = picture?.layers.first + ZStack { + Color.clear + if let picture, let own { + let looks = Dictionary(uniqueKeysWithValues: picture.layers.compactMap { l in EnergyNode(rawValue: l.key).map { (l.key, NodeChartLook.look($0)) } }) + let labels = ChartLabels(fetching: L("FETCHING…"), asOf: picture.asOfMs.map { NodeWords.format("AS OF %@", AppWords.clock($0, zone: .current)) }, now: NodeWords.text("NOW")) + let live = site.freshness(node).live + SlidingSurface(picture: picture) { from, slide, spring in + HistoryDrawing(picture: picture, looks: looks, frame: frame, words: labels, axis: axis, live: live, from: from, slide: slide, spring: spring) + } + ScrubOverlay( + track: ScrubTrack( + grid: picture.grid, + domain: picture.domain, + values: own.series.buckets.map(\.mean), + atMs: own.series.buckets.map(\.atMs), + tags: own.series.readouts.when.indices.map { own.series.readouts.when[$0] + " " + (own.series.readouts.what[$0].components(separatedBy: " · ").first ?? "") }, + landmarks: own.series.readouts.landmarks.union([0, max(0, picture.limit ?? 0)]), + color: NodeChartLook.look(node).up, + downColor: NodeChartLook.look(node).down, + limit: picture.limit + ), + frame: frame, + model: scrub, + tagSize: tag, + // As on the live trace: "07:30–14:30 avg 522 W". + spanTag: { r in + let readouts = own.series.readouts + let when = (readouts.when[r.lowerBound].components(separatedBy: "–").first ?? "") + "–" + (readouts.when[r.upperBound].components(separatedBy: "–").last ?? "") + let means = own.series.buckets[r].compactMap(\.mean) + return means.isEmpty ? when : when + " " + NodeWords.format("avg %@", AppWords.power(abs(means.reduce(0, +) / Double(means.count)))) + } + ) + } else if let problem = home.nodes.history.problem { + Text(problem).font(.footnote).foregroundStyle(Theme.ink3).multilineTextAlignment(.center) + } + } + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + .onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 } + .onChange(of: picture?.domain) { _, now in shown = now } + .onChange(of: picture, initial: true) { _, now in words(now) } + .accessibilityElement() + .accessibilityLabel(node.title) + .accessibilityChartDescriptor(PictureDescriptor(node: node, picture: picture)) + } + + /// The picture for the chosen series, the node's own first. + private func build(_ plot: CGSize) -> HistoryPicture? { + guard plot.width > 0, plot.height > 0 else { return nil } + let clock = ZoneClock.of(.current) + let now = site.minuteMs + 60_000 + let window = period == .today ? HistoryPicture.day(now, clock: clock) : HistoryPicture.twoDays(now) + var inputs: [(key: String, samples: [ChartSample])] = [] + for n in series { + let samples = Self.samples(n, home: home, fromMs: window.fromMs) + if !samples.isEmpty || n == node { inputs.append((n.rawValue, samples)) } + } + let words = ReadoutWords(value: { AppWords.power(abs($0)) }, range: { "\(AppWords.decimal(PowerFormat.fixed(abs($0) / 1000, 1)))–\(AppWords.decimal(PowerFormat.fixed(abs($1) / 1000, 1)))" }, none: NodeWords.text("no data")) + let weekday: @Sendable (Double) -> String = { ms in + Date(timeIntervalSince1970: ms / 1000).formatted(.dateTime.weekday(.abbreviated).locale(Locale(identifier: AppWords.language == .sv ? "sv_SE" : "en_US"))).uppercased().replacingOccurrences(of: ".", with: "") + } + return HistoryPicture.build(inputs, fromMs: window.fromMs, toMs: window.toMs, nowMs: now, size: plot, previous: shown, floor: node.scaleFloorW(), clock: clock, everyHours: period == .today ? 6 : 12, weekday: period == .today ? nil : weekday, words: words) + } + + /// One node's five-minute means as the chart reads them. The box keeps + /// no car tiles, so the car's hours stand in, each hour's mean power + /// over its twelve five-minute steps. + static func samples(_ node: EnergyNode, home: HomeModels, fromMs: Double) -> [ChartSample] { + if node == .car { + guard home.nodes.carEnergy.loaded else { return [] } + return home.nodes.carEnergy.hours.filter { $0.startMs + $0.lenMs > fromMs }.flatMap { h -> [ChartSample] in + let watts = h.wh * 3_600_000 / max(1, h.lenMs) + return stride(from: h.startMs, to: h.startMs + h.lenMs, by: 300_000).map { ChartSample(startMs: $0, value: watts) } + } + } + return home.nodes.history.samples(node, fromMs: fromMs).map { ChartSample(startMs: $0.startMs, value: $0.watts, fetching: $0.fetching) } + } + + /// What a finger says, per slot, in the live trace's words. The hero: + /// the node's own mean and "14:30–15:00 · charging". The line under the + /// chart: the energy so far, then each other series' mean. Two fingers: + /// their mean in the hero, the energy between under the chart. A + /// hidden series changes nothing the others say. + private func words(_ picture: HistoryPicture?) { + guard let picture, let own = picture.layers.first else { return text.set([], spans: { _ in "" }) } + let node = node + let others = picture.layers.dropFirst().compactMap { l in EnergyNode(rawValue: l.key).map { ($0, l.series) } } + let readouts = own.series.readouts + let buckets = own.series.buckets + let slots = readouts.when.indices.map { i -> String in + var parts = [NodeLive.soFar(node, readouts.soFar(i))] + for (n, s) in others where s.readouts.what.indices.contains(i) { + parts.append(n.title + " " + (s.readouts.what[i].components(separatedBy: " · ").first ?? "")) + } + return parts.joined(separator: " · ") + } + let heroes = buckets.indices.map { i -> PeriodText.Hero in + let when = readouts.when[i] + guard let m = buckets[i].mean else { + return PeriodText.Hero(watts: nil, signed: 0, word: when + " · " + (buckets[i].fetching ? L("FETCHING…").lowercased() : NodeWords.text("no data"))) + } + return PeriodText.Hero(watts: abs(m), signed: m, word: when + " · " + Readouts.word(node, m)) + } + func span(_ r: ClosedRange) -> String { + (readouts.when[r.lowerBound].components(separatedBy: "–").first ?? "") + "–" + (readouts.when[r.upperBound].components(separatedBy: "–").last ?? "") + } + text.set(slots, spans: { r in NodeLive.between(node, readouts.between(r.lowerBound, r.upperBound)) }, heroes: heroes, heroSpans: { r in + let means = buckets[r].compactMap(\.mean) + guard !means.isEmpty else { return PeriodText.Hero(watts: nil, signed: 0, word: span(r) + " · " + NodeWords.text("no data")) } + let mean = means.reduce(0, +) / Double(means.count) + return PeriodText.Hero(watts: abs(mean), signed: mean, word: span(r) + " · " + NodeWords.format("average %@", AppWords.power(abs(mean)))) + }) + } +} + +/// A history as VoiceOver reads it: the chart's own slot means, in the +/// slot's length. +private struct PictureDescriptor: AXChartDescriptorRepresentable { + let node: EnergyNode + let picture: HistoryPicture? + + func makeChartDescriptor() -> AXChartDescriptor { + let buckets = picture?.layers.first?.series.buckets ?? [] + let start = buckets.first?.startMs ?? 0, end = max(start + 1, buckets.last?.endMs ?? 1) + let values = buckets.compactMap(\.mean) + let lo = min(0, values.min() ?? 0), hi = max(lo + 1, values.max() ?? 1) + let x = AXNumericDataAxisDescriptor(title: L("Time"), range: start...end, gridlinePositions: []) { Readouts.clock($0) } + let y = AXNumericDataAxisDescriptor(title: L("Power"), range: lo...hi, gridlinePositions: []) { AppWords.power(abs($0)) } + let series = AXDataSeriesDescriptor(name: node.title, isContinuous: true, dataPoints: buckets.compactMap { b in + b.mean.map { AXDataPoint(x: b.startMs, y: $0, additionalValues: [], label: Readouts.word(node, $0)) } + }) + let minutes = Int(((picture?.stepMs ?? 1_800_000) / 60_000).rounded()) + return AXChartDescriptor(title: node.title, summary: NodeWords.format("%@-minute means", String(minutes)), xAxis: x, yAxis: y, additionalAxes: [], series: [series]) + } +} + +// MARK: - Ahead + +/// The hours ahead on the engine: the plan's quarters as ghost bars for the +/// battery, the grid and the car; the forecast as a dashed line for the sun +/// and the home; the price under the same slots. The finger lights a plan +/// slot as its whole bar. +struct AheadChartArea: View { + let site: SiteModel + let node: EnergyNode + let home: HomeModels + let scrub: ScrubModel + let text: PeriodText + @State private var size: CGSize = .zero + @ScaledMetric(relativeTo: .caption2) private var axis: CGFloat = 10 + @ScaledMetric(relativeTo: .caption) private var tag: CGFloat = 11 + + var body: some View { + let strip: CGFloat = 18 + let frame = ChartFrame(top: tag * 1.35 + 8, bottom: 6 + strip + axis * 1.4 + 4, trailing: axis * 3.2) + let made = build(frame.plot(in: size).size) + ZStack { + Color.clear + if let made { + let picture = made.picture, bars = made.bars, prices = made.prices, currency = made.currency, forecast = made.forecast + let look = NodeChartLook.look(node) + SlidingSurface(picture: picture) { from, slide, spring in + AheadDrawing(picture: picture, look: look, frame: frame, axis: axis, nowWord: NodeWords.text("NOW"), strip: strip, from: from, slide: slide, spring: spring) + } + ScrubOverlay( + track: ScrubTrack( + grid: picture.grid, + domain: picture.domain, + values: picture.values, + tags: picture.grid.count == 0 ? [] : (0.. Built? { + guard plot.width > 0, plot.height > 0 else { return nil } + let now = site.minuteMs + let plan = home.nodes.plan + let window = home.prices.prices + let currency = window?.currency ?? "SEK" + var bars: [ChartSlot] = [] + var forecast = false + switch node { + case .battery, .grid: + bars = plan.ahead(nowMs: now).map { ChartSlot(startMs: $0.startMs, endMs: $0.endMs, watts: node == .battery ? $0.batteryW : $0.gridW, reason: $0.reason) } + case .solar, .home: + forecast = true + bars = plan.forecastAhead(nowMs: now).map { ChartSlot(startMs: $0.startMs, endMs: $0.startMs + $0.durationMs, watts: node == .solar ? $0.pvW : $0.loadW) } + case .car: + let points = home.loadpoints.points + for lp in points { + for w in home.loadpoints.windows[lp.id] ?? lp.planWindows where w.toMs > now { + // A window that names no peak is drawn at its mean. + let mean: Double = (w.energyWh ?? 0) * 3_600_000 / max(1, w.toMs - w.fromMs) + bars.append(ChartSlot(startMs: w.fromMs, endMs: w.toMs, watts: w.peakW ?? mean)) + } + } + } + guard !bars.isEmpty || node == .car else { return nil } + let prices = (window?.slots ?? []).map { ChartPrice(startMs: $0.startMs, endMs: $0.startMs + $0.durationMs, minor: $0.totalMinor) } + let slots: [(startMs: Double, endMs: Double, watts: Double?)] = bars.map { ($0.startMs, $0.endMs, $0.watts) } + let rates: [(startMs: Double, endMs: Double, minor: Double)] = prices.map { ($0.startMs, $0.endMs, $0.minor) } + let picture = AheadPicture.build( + kind: forecast ? .forecast : .plan, + slots: slots, + prices: rates, + nowMs: now, + spanMs: PlanModel.aheadMs, + size: plot, + floor: node.scaleFloorW(), + clock: ZoneClock.of(.current) + ) + return Built(picture: picture, bars: bars, prices: prices, currency: currency, forecast: forecast) + } +} diff --git a/appleApp/FTW/UI/NodeView.swift b/appleApp/FTW/UI/NodeView.swift index 9b7ec95..ebcc671 100644 --- a/appleApp/FTW/UI/NodeView.swift +++ b/appleApp/FTW/UI/NodeView.swift @@ -28,7 +28,10 @@ struct NodeView: View { @State private var trace = NodeTraceModel() @State private var choosingMode = false @State private var timebase: Timebase - @State private var readout: ChartReadout? + @State private var text = PeriodText() + @State private var series: [EnergyNode] + @State private var enlarged = false + @Namespace private var chartZoom @Environment(\.dynamicTypeSize) private var typeSize init(app: AppModel, home: HomeModels, node: EnergyNode, zoom: Namespace.ID) { @@ -37,6 +40,7 @@ struct NodeView: View { self.node = node self.zoom = zoom _timebase = State(initialValue: Timebase.remembered(node)) + _series = State(initialValue: SeriesChoice.load(node)) } /// Live, today, the last two days and the hours ahead: one chart, one @@ -101,7 +105,6 @@ struct NodeView: View { } .onChange(of: timebase) { _, now in UserDefaults.standard.set(now.rawValue, forKey: Timebase.key(node)) - readout = nil scrub.lift() } .onChange(of: scrub.isDown) { _, down in @@ -110,20 +113,52 @@ struct NodeView: View { .sheet(isPresented: $choosingMode) { ModeSheet(mode: models.mode) } + .fullScreenCover(isPresented: $enlarged) { + ChartFullScreen(site: site, node: node, home: home, timebase: $timebase, series: $series, timebases: timebases) + .navigationTransition(.zoom(sourceID: "chart", in: chartZoom)) + } } + private var timebases: [Timebase] { Timebase.allCases.filter { $0 != .ahead || hasAhead } } + private func page(fill: Bool) -> some View { - let timebases = Timebase.allCases.filter { $0 != .ahead || hasAhead } return VStack(alignment: .leading, spacing: 0) { - NodeHero(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase) - .padding(.horizontal, 20) - NodeStatusRow(site: site, node: node) + NodeHero(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, text: text) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) .padding(.horizontal, 20) - .padding(.top, 20) - NodeChart(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, readout: $readout) - .frame(minHeight: fill ? 180 : 240, idealHeight: 300, maxHeight: 340) - .padding(.top, 8) + HStack(spacing: 8) { + NodeStatusRow(site: site, node: node) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + Spacer(minLength: 4) + Button { enlarged = true } label: { + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(Theme.ink3) + .frame(minWidth: 32, minHeight: 28) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(L("Enlarge the chart")) + .accessibilityIdentifier("node.chart.enlarge") + } + .padding(.horizontal, 20) + .padding(.top, 16) + // The series a history shows; the row keeps its room on every + // timebase, so nothing moves on a switch. + SeriesChips(own: node, available: Self.chartable(node), chosen: $series) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + .padding(.horizontal, 14) + .opacity(timebase == .today || timebase == .twoDays ? 1 : 0) + .allowsHitTesting(timebase == .today || timebase == .twoDays) + .accessibilityHidden(!(timebase == .today || timebase == .twoDays)) + // Compact: the view never scrolls for it. A tap opens it on the + // whole screen, growing out of this one. + NodeChart(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, series: series, text: text) + .frame(minHeight: fill ? 150 : 200, idealHeight: 200, maxHeight: 230) .padding(.horizontal, 4) + .contentShape(Rectangle()) + .simultaneousGesture(TapGesture().onEnded { enlarged = true }) + .matchedTransitionSource(id: "chart", in: chartZoom) Picker(L("Period"), selection: $timebase.animation(.easeInOut(duration: 0.2))) { ForEach(timebases, id: \.self) { Text($0.label).tag($0) } } @@ -131,7 +166,7 @@ struct NodeView: View { .accessibilityIdentifier("node.period") .padding(.horizontal, 20) .padding(.top, 12) - RangeLine(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, readout: readout) + RangeLine(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, text: text) .padding(.horizontal, 20) .padding(.top, 12) if node == .battery { @@ -145,6 +180,7 @@ struct NodeView: View { } if fill { Spacer(minLength: 8) } NodeFooter(site: site, node: node, live: models.live) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) .padding(.horizontal, 20) .padding(.top, fill ? 0 : 16) .padding(.bottom, 8) @@ -154,6 +190,12 @@ struct NodeView: View { .padding(.top, 8) } + /// The series a node's history can show: the box's tiles, and the car + /// on its own view, from its hours. + static func chartable(_ node: EnergyNode) -> [EnergyNode] { + EnergyNode.allCases.filter { $0 != .car || node == .car } + } + /// Ahead shows only where the box plans or forecasts for this node. private var hasAhead: Bool { switch node { @@ -239,6 +281,7 @@ private struct NodeHero: View { let trace: NodeTraceModel let scrub: ScrubModel let timebase: NodeView.Timebase + let text: PeriodText @ScaledMetric(relativeTo: .largeTitle) private var figureSize: CGFloat = 60 @ScaledMetric(relativeTo: .title2) private var unitSize: CGFloat = 22 @@ -253,8 +296,15 @@ private struct NodeHero: View { hasCar: !loadpoints.points.isEmpty ).first { $0.node == node } let live = reading?.freshness.live ?? false - let under = timebase == .live ? fingered : nil - let watts = under?.watts ?? reading?.watts + // Under a finger the hero says what the finger is on, on Live and + // on the measured past alike. The hours ahead are a plan, not a + // reading, so there the hero stays on now. + let under: (watts: Double?, signed: Double, word: String)? = switch timebase { + case .live: fingered.map { ($0.watts, $0.signed, $0.word) } + case .today, .twoDays: text.hero(scrub).map { ($0.watts, $0.signed, $0.word) } + case .ahead: nil + } + let watts = under.map(\.watts) ?? reading?.watts let word = under?.word ?? reading?.word ?? L("Your box sends no reading for this.") let tone = under.map { Readouts.tone(node, $0.signed) } ?? reading?.tone ?? .muted @@ -274,7 +324,7 @@ private struct NodeHero: View { SecondFact(site: site, node: node, home: home, reading: reading, live: live) } .transaction { $0.animation = scrub.isDown ? nil : Theme.spring } - .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) .accessibilityElement(children: .combine) .accessibilityIdentifier("node.hero") } @@ -424,16 +474,17 @@ private struct NodeStatusRow: View { .tracking(0.5) .lineLimit(1) .minimumScaleFactor(0.75) - .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) .accessibilityIdentifier("node.status") .contentTransition(.opacity) .animation(.default, value: text) } } -/// One place for the node's chart. Live streams the trace; today, the -/// last two days and the hours ahead draw their own charts in the same -/// frame. A switch cross-fades in 0.2 s and the scale does not morph. +/// One place for the node's chart. Live streams the trace; today and the +/// last two days draw the chosen series on the engine; the hours ahead +/// draw the plan or the forecast with the price. A switch cross-fades in +/// 0.2 s and the scale does not morph. private struct NodeChart: View { let site: SiteModel let node: EnergyNode @@ -441,7 +492,8 @@ private struct NodeChart: View { let trace: NodeTraceModel let scrub: ScrubModel let timebase: NodeView.Timebase - @Binding var readout: ChartReadout? + let series: [EnergyNode] + let text: PeriodText var body: some View { ZStack { @@ -449,13 +501,95 @@ private struct NodeChart: View { case .live: LiveChartArea(site: site, node: node, live: home.nodes.live, trace: trace, scrub: scrub) .transition(.opacity) - default: - PeriodChart(site: site, node: node, period: timebase, models: home.nodes, prices: home.prices, loadpoints: home.loadpoints, onScrub: { readout = $0 }) - .padding(.horizontal, 12) + case .today, .twoDays: + HistoryChartArea(site: site, node: node, home: home, scrub: scrub, period: timebase, series: series, text: text) + .transition(.opacity) + .accessibilityIdentifier("node.period.chart") + case .ahead: + AheadChartArea(site: site, node: node, home: home, scrub: scrub, text: text) .transition(.opacity) .accessibilityIdentifier("node.period.chart") } } + // A chart's words stop growing at xxLarge: they are drawn in the + // Canvas, and said again under the chart and to VoiceOver. Set here, + // above the charts, so their scaled sizes read it. + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + } +} + +/// The chart on the whole screen, grown out of the small one: the same +/// timebases, series and finger, with room to read. +private struct ChartFullScreen: View { + let site: SiteModel + let node: EnergyNode + let home: HomeModels + @Binding var timebase: NodeView.Timebase + @Binding var series: [EnergyNode] + let timebases: [NodeView.Timebase] + @State private var scrub = ScrubModel() + @State private var trace = NodeTraceModel() + @State private var text = PeriodText() + @Environment(\.dismiss) private var dismiss + @Environment(\.dynamicTypeSize) private var typeSize + + var body: some View { + NavigationStack { + Group { + // The accessibility sizes scroll, with the chart at a fixed + // height; every other size is one page. + if typeSize.isAccessibilitySize { + ScrollView { content(chart: 360) } + } else { + content(chart: nil) + } + } + .background(Theme.ground) + .navigationTitle(node.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { dismiss() } label: { Image(systemName: "xmark") } + .accessibilityLabel(L("Close")) + .accessibilityIdentifier("node.chart.close") + } + } + } + .onChange(of: timebase) { _, _ in scrub.lift() } + .onChange(of: scrub.isDown) { _, down in + if down { trace.hold(atMs: Date().timeIntervalSince1970 * 1000) } else { trace.release() } + } + } + + private func content(chart height: CGFloat?) -> some View { + VStack(alignment: .leading, spacing: 0) { + NodeHero(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, text: text) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .padding(.horizontal, 20) + NodeStatusRow(site: site, node: node) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + .padding(.horizontal, 20) + .padding(.top, 12) + SeriesChips(own: node, available: NodeView.chartable(node), chosen: $series) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + .padding(.horizontal, 14) + .opacity(timebase == .today || timebase == .twoDays ? 1 : 0) + .allowsHitTesting(timebase == .today || timebase == .twoDays) + NodeChart(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, series: series, text: text) + .frame(height: height) + .frame(maxHeight: height == nil ? .infinity : nil) + .padding(.horizontal, 4) + Picker(L("Period"), selection: $timebase.animation(.easeInOut(duration: 0.2))) { + ForEach(timebases, id: \.self) { Text($0.label).tag($0) } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("node.chart.period") + .padding(.horizontal, 20) + .padding(.top, 12) + RangeLine(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, text: text) + .padding(.horizontal, 20) + .padding(.vertical, 12) + } } } @@ -475,7 +609,8 @@ private struct LiveChartArea: View { var body: some View { let revision = live.revision let chartFrame = ChartFrame(top: tag * 1.35 + 8, bottom: 6, trailing: axis * 3.2) - let look = Self.look(node) + // The same colours as every other chart of this node. + let look = NodeChartLook.look(node) let isLive = site.freshness(node).live ZStack { // Takes the whole frame before the first reading, so the @@ -499,7 +634,7 @@ private struct LiveChartArea: View { ) } } - .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) .onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 } .onChange(of: revision, initial: true) { rebuild(chartFrame) } .onChange(of: size) { rebuild(chartFrame) } @@ -552,18 +687,6 @@ private struct LiveChartArea: View { trace.build(live.points(node.fid, sinceMs: Date().timeIntervalSince1970 * 1000 - LiveTrace.windowMs - 10_000), node: node, carrier: site.carrier, size: plot) } - /// Each side of zero in its direction's colour: the battery cyan - /// storing and gold giving, the grid magenta both ways. - static func look(_ node: EnergyNode) -> TraceLook { - let words = node.directions - switch node { - case .battery: return TraceLook(up: Theme.stores, down: Theme.gives, upWord: words.map { "↑ " + $0.up.uppercased() }, downWord: words.map { "↓ " + $0.down.uppercased() }) - case .grid: return TraceLook(up: Theme.grid, down: Theme.grid, upWord: words.map { "↑ " + $0.up.uppercased() }, downWord: words.map { "↓ " + $0.down.uppercased() }) - default: - let c = Theme.color(NodeLook.tone(node)) - return TraceLook(up: c, down: c) - } - } } /// The trace as VoiceOver reads it: every reading, in watts. @@ -595,12 +718,12 @@ private struct RangeLine: View { let trace: NodeTraceModel let scrub: ScrubModel let timebase: NodeView.Timebase - let readout: ChartReadout? + let text: PeriodText var body: some View { ZStack(alignment: .topLeading) { Text(verbatim: "\n") - Text(text) + Text(line) .accessibilityIdentifier("chart.readout") } .font(.subheadline) @@ -610,9 +733,9 @@ private struct RangeLine: View { .transaction { $0.animation = nil } } - private var text: String { + private var line: String { guard timebase == .live else { - if let readout { return readout.when + " " + readout.what } + if let under = text.text(scrub) { return under } return PeriodWords.summary(site: site, node: node, period: timebase, models: home.nodes, loadpoints: home.loadpoints) } guard let frame = trace.shown else { return " " } @@ -649,7 +772,7 @@ private struct NodeFooter: View { .foregroundStyle(Theme.ink3) .lineLimit(2) .minimumScaleFactor(0.75) - .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) .accessibilityIdentifier("node.footer") } } @@ -731,110 +854,6 @@ private struct CarRow: View { } } -// MARK: - Today, two days, ahead - -/// Today, the last two days or the hours ahead, in the live chart's frame. -/// These keep their own charts until WP7 moves them onto the engine. -private struct PeriodChart: View { - let site: SiteModel - let node: EnergyNode - let period: NodeView.Timebase - let models: NodeModels - let prices: PriceModel - let loadpoints: LoadpointsModel - let onScrub: (ChartReadout?) -> Void - - var body: some View { - Group { - switch period { - case .ahead: ahead - default: past - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private var fromMs: Double { - period == .today ? models.history.midnightMs : site.nowMs - HistoryModel.spanMs - } - - /// The hour the history is drawn to: the tiles end at the last five - /// minutes, and the axis at now. - private var toMs: Double { (site.nowMs / 60_000).rounded(.up) * 60_000 } - - @ViewBuilder private var past: some View { - let stride = period == .today ? 6 : 12 - if node == .car { - if models.carEnergy.loaded { - CarHoursChart(hours: models.carEnergy.hours, fromMs: fromMs, toMs: toMs, strideHours: stride, onScrub: onScrub).equatable() - } else { - Placeholder(text: models.carEnergy.problem) - } - } else if !models.history.available { - Placeholder(text: L("Your box keeps no history.")) - } else if models.history.frame != nil { - HistoryChart(node: node, samples: models.history.samples(node, fromMs: fromMs), fromMs: fromMs, toMs: toMs, look: NodeLook.look(node), strideHours: stride, onScrub: onScrub) - .equatable() - } else { - Placeholder(text: models.history.problem) - } - } - - @ViewBuilder private var ahead: some View { - let now = site.nowMs - let window = prices.prices - let priceBars = (window?.slots ?? []).map { AheadChart.Price(startMs: $0.startMs, endMs: $0.startMs + $0.durationMs, minor: $0.totalMinor) } - switch node { - case .battery, .grid: - let slots = models.plan.ahead(nowMs: now) - if slots.isEmpty { - Placeholder(text: models.plan.loaded ? nil : models.plan.problem) - } else { - AheadChart( - node: node, - bars: slots.map { AheadChart.Bar(startMs: $0.startMs, endMs: $0.endMs, watts: node == .battery ? $0.batteryW : $0.gridW, reason: $0.reason) }, - prices: slots.compactMap { s in s.priceMinor.map { AheadChart.Price(startMs: s.startMs, endMs: s.endMs, minor: $0) } }, - currency: window?.currency ?? "SEK", - nowMs: now, - look: NodeLook.look(node), - onScrub: onScrub - ).equatable() - } - case .solar, .home: - let slots = models.plan.forecastAhead(nowMs: now) - if slots.isEmpty { - Placeholder(text: models.plan.forecastLoaded ? L("No forecast for the hours ahead yet.") : nil) - } else { - AheadChart( - node: node, - bars: slots.map { AheadChart.Bar(startMs: $0.startMs, endMs: $0.startMs + $0.durationMs, watts: node == .solar ? $0.pvW : $0.loadW) }, - prices: slots.compactMap { s in s.priceMinor.map { AheadChart.Price(startMs: s.startMs, endMs: s.startMs + s.durationMs, minor: $0) } }, - currency: window?.currency ?? "SEK", - nowMs: now, - look: NodeLook.look(node), - forecast: true, - onScrub: onScrub - ).equatable() - } - case .car: - let windows = loadpoints.points.flatMap { loadpoints.windows[$0.id] ?? $0.planWindows } - AheadChart( - node: .car, - bars: windows.filter { $0.toMs > now }.map { w in - // A window that names no peak is drawn at its mean. - AheadChart.Bar(startMs: w.fromMs, endMs: w.toMs, watts: w.peakW ?? (w.energyWh ?? 0) * 3_600_000 / max(1, w.toMs - w.fromMs)) - }, - prices: priceBars, - currency: window?.currency ?? "SEK", - nowMs: now, - look: NodeLook.look(.car), - onScrub: onScrub - ).equatable() - } - } - -} - /// The line under today, the last two days and the hours ahead. private enum PeriodWords { @MainActor @@ -875,25 +894,6 @@ private enum PeriodWords { } } -/// The shape of a chart that has not arrived, with what is happening. -private struct Placeholder: View { - let text: String? - - var body: some View { - VStack(spacing: 6) { - if let text { - Text(text).font(.footnote).foregroundStyle(Theme.ink3).multilineTextAlignment(.center) - } else { - RoundedRectangle(cornerRadius: 6) - .fill(Theme.hairline) - .redacted(reason: .placeholder) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} - - /// The energy line under a node's history, in the node's own words. enum NodeSummary { static func energy(_ node: EnergyNode, _ e: EnergyToday, today: Bool) -> String { @@ -913,36 +913,3 @@ enum NodeSummary { return text } } - -extension NodeLook { - /// The colour of a node's curve. - static func tone(_ node: EnergyNode) -> Flow.Tone { - switch node { - case .solar: return .solar - case .battery: return .battery - case .home: return .house - case .grid: return .importing - case .car: return .ev - } - } - - /// One colour pair per node for all its charts: the battery in its own - /// colour, giving power in the same colour lighter; the grid red when - /// buying and green when selling, as on Now. Grey when not live. - static func look(_ node: EnergyNode, live: Bool = true) -> ChartLook { - let words = node.directions - guard live else { - return ChartLook(up: Theme.ink3, down: Theme.ink3.opacity(0.6), line: Theme.ink3, upWord: words?.up, downWord: words?.down) - } - switch node { - case .battery: - return ChartLook(up: Theme.stores, down: Theme.gives, line: Theme.ink2, upWord: words?.up, downWord: words?.down) - case .grid: - return ChartLook(up: Theme.grid, down: Theme.grid, line: Theme.grid, upWord: words?.up, downWord: words?.down) - default: - let c = Theme.color(tone(node)) - return ChartLook(up: c, down: c, line: c) - } - } - -} diff --git a/appleApp/FTW/UI/PlanCard.swift b/appleApp/FTW/UI/PlanCard.swift index b027969..e9575ab 100644 --- a/appleApp/FTW/UI/PlanCard.swift +++ b/appleApp/FTW/UI/PlanCard.swift @@ -148,3 +148,11 @@ struct PlanLanes: View, Equatable { private func date(_ ms: Double) -> Date { Date(timeIntervalSince1970: ms / 1000) } } + +/// "15", on the 24-hour clock FTW uses in both languages: an English phone +/// would otherwise label the afternoon "03". +private func hourLabel(_ value: AxisValue) -> some View { + Text(value.as(Date.self).map { String(format: "%02d", Calendar.current.component(.hour, from: $0)) } ?? "") + .font(.caption2) + .foregroundStyle(Theme.ink3) +} diff --git a/appleApp/FTW/UI/PriceView.swift b/appleApp/FTW/UI/PriceView.swift index d6418e7..5bb91dd 100644 --- a/appleApp/FTW/UI/PriceView.swift +++ b/appleApp/FTW/UI/PriceView.swift @@ -84,6 +84,7 @@ private struct PricePage: View, Equatable { let included = PriceGlance.text(PriceGlance.included(window.slots)) VStack(alignment: .leading, spacing: 0) { PriceHero(chart: chart, zone: window.zone, scrub: scrub) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) if chart.isEmpty { Text(L("No prices ahead yet.")) .font(.subheadline) diff --git a/appleApp/FTWCharts/HistoryCharts.swift b/appleApp/FTWCharts/HistoryCharts.swift new file mode 100644 index 0000000..b6ac58f --- /dev/null +++ b/appleApp/FTWCharts/HistoryCharts.swift @@ -0,0 +1,312 @@ +import FTWShared +import SwiftUI + +/// The fixed words a history or ahead chart draws, worded by the app. +struct ChartLabels: Equatable { + /// "FETCHING…", over a hole on its way. + var fetching = "FETCHING…" + /// "AS OF 15:30", where the newest data ends when it is old. + var asOf: String? + /// "NOW", under the now rule. + var now = "NOW" +} + +/// Today or the last two days, in one Canvas, in the live trace's +/// material (`TraceInk`): per series its band of the five-minute means, +/// the chart's own series' body, and the line through the exact slot +/// means. Glow means live, so only the slot still filling glows, and only +/// while the node is live. A hole on its way is a hatch with a dashed +/// estimate across it; a hole the box has no record for is a dotted +/// baseline. Axes are drawn here too, so they never reflow: zero as one +/// device pixel, the scale's ends in the right gutter, the direction words +/// when both signs show, the hours, and NOW. +/// +/// When a tile lands in the same window, each slot slides from its old +/// mean to its new one (`from`, `slide`), as a new reading slides into the +/// live trace; a new scale and a series coming or going move as one spring +/// (`spring`). A new window draws at once, on the scale's spring. +struct HistoryDrawing: ChartDrawing { + let picture: HistoryPicture + /// The colours per series key, each side of zero. + let looks: [String: TraceLook] + let frame: ChartFrame + var words = ChartLabels() + var axis: CGFloat = 10 + /// Whether the node's reading is live now: the slot still filling glows. + var live = false + var from: HistoryPicture? + var slide: Double = 1 + var spring: Double = 1 + + func draw(in g: inout GraphicsContext, size: CGSize) { + let plot = frame.plot(in: size) + let px = 1 / max(g.environment.displayScale, 1) + let primary = picture.layers.first?.key + + let k = min(max(spring, 0), 1) + let shown = from.map { TraceInk.between($0.domain, picture.domain, k) } ?? picture.domain + let toShown = TraceInk.rescale(picture.domain, shown, height: plot.height) + let base = YMap(domain: shown, height: plot.height).baseline + + ChartAxes.draw(in: &g, plot: plot, domain: picture.domain, px: px, axis: axis, look: primary.flatMap { looks[$0] }, shown: shown) + + var p = g + p.clip(to: Path(CGRect(x: plot.minX, y: plot.minY - 12, width: plot.width, height: plot.height + 24))) + p.translateBy(x: plot.minX, y: plot.minY) + let above = Path(CGRect(x: -10, y: -40, width: plot.width + 20, height: base + 40)) + let below = Path(CGRect(x: -10, y: base, width: plot.width + 20, height: plot.height - base + 40)) + + // The slot still filling, the only part of the day that is live. + var filling: Path? + if TraceInk.glows(live: live, in: g), let now = picture.nowMs, let i = picture.grid.index(atMs: now) { + let x = picture.grid.x(atMs: max(picture.grid.startMs(i), picture.grid.fromMs), width: plot.width) + filling = Path(CGRect(x: x, y: -40, width: plot.width - x + 20, height: plot.height + 80)) + } + + // A series taken away, fading out on the moving scale. + if let from, k < 1 { + for gone in from.layers where !picture.layers.contains(where: { $0.key == gone.key }) { + let look = looks[gone.key] ?? TraceLook(up: Theme.ink2, down: Theme.ink2) + var s = p + s.opacity = 1 - k + TraceInk.line(Path(gone.series.line.cgPath).applying(TraceInk.rescale(from.domain, shown, height: plot.height)), look: look, width: TraceInk.otherLine, glows: false, above: above, below: below, in: s) + } + } + + // The others first, the chart's own series last, on top. + for layer in picture.layers.reversed() { + let look = looks[layer.key] ?? TraceLook(up: Theme.ink, down: Theme.ink) + let series = layer.series + let own = layer.key == primary + var line = Path(series.line.cgPath), body = Path(series.body.cgPath), band = Path(series.band.cgPath) + let before = from?.layers.first(where: { $0.key == layer.key })?.series + if let before, let mixed = series.blended(from: before, progress: slide) { + line = Path(mixed.line.cgPath) + body = Path(mixed.body.cgPath) + band = Path(mixed.band.cgPath) + } + if toShown != .identity { + line = line.applying(toShown) + body = body.applying(toShown) + band = band.applying(toShown) + } + var p = p + // A series just added fades in. + if from != nil, before == nil, k < 1 { p.opacity = k } + TraceInk.band(band, look: look, above: above, below: below, in: p) + if own { + TraceInk.body(body, look: look, baseline: base, height: plot.height, above: above, below: below, in: p) + } + // Holes: a hatch on its way with a dashed estimate, a dotted + // baseline where the box has no record. + if own { + for r in series.fetching { + let x0 = series.grid.x(atMs: series.grid.startMs(r.lowerBound), width: plot.width) + let x1 = series.grid.x(atMs: series.grid.endMs(r.upperBound), width: plot.width) + let rect = CGRect(x: x0, y: 0, width: max(px, x1 - x0), height: plot.height) + var hatch = p + hatch.clip(to: Path(rect)) + hatch.fill(Path(rect), with: .color(Theme.ink.opacity(0.04))) + hatch.stroke(Self.hatch(rect), with: .color(Theme.ink.opacity(0.10)), style: StrokeStyle(lineWidth: px, lineCap: .butt, lineJoin: .round)) + let text = p.resolve(ChartText.label(words.fetching, size: axis)) + p.draw(text, at: CGPoint(x: rect.midX, y: 4), anchor: .top) + } + var dotted = Path() + for r in series.missing { + dotted.move(to: CGPoint(x: series.grid.x(atMs: series.grid.startMs(r.lowerBound), width: plot.width), y: base)) + dotted.addLine(to: CGPoint(x: series.grid.x(atMs: series.grid.endMs(r.upperBound), width: plot.width), y: base)) + } + p.stroke(dotted, with: .color(Theme.ink.opacity(0.35)), style: StrokeStyle(lineWidth: px, lineCap: .butt, lineJoin: .round, dash: [1, 3])) + } + p.stroke(Path(series.estimate.cgPath).applying(toShown), with: .color(look.up.opacity(0.6)), style: StrokeStyle(lineWidth: 1.75, lineCap: .round, lineJoin: .round, dash: [3, 4])) + TraceInk.line(line, look: look, width: own ? TraceInk.line : TraceInk.otherLine, opacity: own ? 1 : 0.85, glows: own && filling != nil, glowClip: filling, above: above, below: below, in: p) + } + + // Now, and where old data ends. + if let now = picture.nowMs, now > picture.grid.fromMs, now < picture.grid.toMs { + let x = plot.minX + (picture.grid.x(atMs: now, width: plot.width) / px).rounded() * px + px / 2 + var rule = Path() + rule.move(to: CGPoint(x: x, y: plot.minY)) + rule.addLine(to: CGPoint(x: x, y: plot.maxY)) + g.stroke(rule, with: .color(Theme.ink.opacity(0.5)), style: StrokeStyle(lineWidth: px, lineCap: .butt, lineJoin: .round)) + } + if let asOf = picture.asOfMs, let text = words.asOf { + let x = plot.minX + picture.grid.x(atMs: asOf, width: plot.width) + let label = g.resolve(ChartText.label(text, size: axis)) + let w = label.measure(in: size).width + g.draw(label, at: CGPoint(x: min(max(plot.minX, x - w / 2), plot.maxX - w), y: plot.minY + 2), anchor: .topLeading) + } + ChartAxes.time(in: &g, plot: plot, size: size, grid: picture.grid, ticks: picture.ticks.map { ($0.ms, $0.label) }, nowMs: picture.nowMs, nowWord: words.now, axis: axis) + } + + /// Diagonal hairlines 4 pt apart across a rect. + static func hatch(_ rect: CGRect) -> Path { + var path = Path() + var x = rect.minX - rect.height + while x < rect.maxX { + path.move(to: CGPoint(x: x, y: rect.maxY)) + path.addLine(to: CGPoint(x: x + rect.height, y: rect.minY)) + x += 4 + } + return path + } +} + +/// The hours ahead, in one Canvas: a plan's quarters as ghost bars (a +/// faint fill with a 1 px cap, equal neighbours merged), or a forecast's +/// dashed line, and the price under the same slots as block bars in ink by +/// thirds. The now rule sits at the left edge. A new plan or forecast for +/// the same slots slides each slot to its new value, and a new scale +/// springs, as on the live trace. +struct AheadDrawing: ChartDrawing { + let picture: AheadPicture + let look: TraceLook + let frame: ChartFrame + var axis: CGFloat = 10 + var nowWord = "NOW" + /// The price strip's height inside the frame's bottom. + var strip: CGFloat = 18 + var from: AheadPicture? + var slide: Double = 1 + var spring: Double = 1 + + func draw(in g: inout GraphicsContext, size: CGSize) { + let plot = frame.plot(in: size) + let scale = max(g.environment.displayScale, 1) + let px = 1 / scale + let shown = from.map { TraceInk.between($0.domain, picture.domain, min(max(spring, 0), 1)) } ?? picture.domain + let map = YMap(domain: shown, height: plot.height) + let mixed = from.flatMap { picture.blended(from: $0, progress: slide) } + let values = mixed?.values ?? picture.values + + ChartAxes.draw(in: &g, plot: plot, domain: picture.domain, px: px, axis: axis, look: look, shown: shown) + + var p = g + p.translateBy(x: plot.minX, y: plot.minY) + switch picture.kind { + case .plan: + for run in ChartPaths.runs(values, grid: picture.grid, width: plot.width, map: map, scale: scale, maxRadius: 4) { + let color = run.value >= 0 ? look.up : look.down + let r = run.rect + let radii = run.value > 0 + ? RectangleCornerRadii(topLeading: run.radius, bottomLeading: 0, bottomTrailing: 0, topTrailing: run.radius) + : RectangleCornerRadii(topLeading: 0, bottomLeading: run.radius, bottomTrailing: run.radius, topTrailing: 0) + p.fill(Path(roundedRect: r, cornerRadii: radii, style: .circular), with: .color(color.opacity(0.14))) + var cap = Path() + let y = run.value > 0 ? r.minY + px / 2 : r.maxY - px / 2 + cap.move(to: CGPoint(x: r.minX + run.radius, y: y)) + cap.addLine(to: CGPoint(x: r.maxX - run.radius, y: y)) + p.stroke(cap, with: .color(color.opacity(0.7)), style: StrokeStyle(lineWidth: px, lineCap: .round, lineJoin: .round)) + } + case .forecast: + // An estimate, so dashed, like the estimate across a hole. + let line = mixed == nil && shown == picture.domain ? picture.line : AheadPicture.forecastLine(values, grid: picture.grid, domain: shown, size: plot.size) + p.stroke(Path(line.cgPath), with: .color(look.up.opacity(0.5)), style: StrokeStyle(lineWidth: 1.75, lineCap: .round, lineJoin: .round, dash: [3, 4])) + } + + // The price strip: block bars in eight steps, ink by thirds. + let stripTop = plot.maxY + 6 + let w = plot.width / CGFloat(max(1, picture.grid.count)) + for i in 0.. 0, let look { + if let up = look.upWord { g.draw(g.resolve(ChartText.label(up, size: axis)), at: CGPoint(x: plot.minX + 2, y: plot.minY + 2), anchor: .topLeading) } + if let down = look.downWord { g.draw(g.resolve(ChartText.label(down, size: axis)), at: CGPoint(x: plot.minX + 2, y: plot.maxY - 2), anchor: .bottomLeading) } + } + } + + /// The hour labels on the bottom row, and NOW under the now rule; a + /// label that would collide with one already placed is left out. + static func time(in g: inout GraphicsContext, plot: CGRect, size: CGSize, grid: SlotGrid, ticks: [(Double, String)], nowMs: Double?, nowWord: String, axis: CGFloat) { + var taken: [ClosedRange] = [] + if let now = nowMs, now >= grid.fromMs, now < grid.toMs { + let label = g.resolve(ChartText.label(nowWord, size: axis, weight: .semibold, color: Theme.ink2)) + let w = label.measure(in: size).width + let x = plot.minX + grid.x(atMs: now, width: plot.width) + let left = min(max(plot.minX, x - w / 2), plot.maxX - w) + taken.append(left...(left + w)) + g.draw(label, at: CGPoint(x: left, y: size.height), anchor: .bottomLeading) + } + for (ms, text) in ticks { + let label = g.resolve(ChartText.label(text, size: axis)) + let w = label.measure(in: size).width + // Centred on its hour, but kept inside the chart at either end. + let x = min(max(plot.minX + grid.x(atMs: ms, width: plot.width), plot.minX + w / 2), size.width - w / 2) + let span = (x - w / 2 - 4)...(x + w / 2 + 4) + guard !taken.contains(where: { $0.overlaps(span) }) else { continue } + taken.append(span) + g.draw(label, at: CGPoint(x: x, y: size.height), anchor: .bottom) + } + } +} + +/// A chart whose data moves as the live trace's does: a change for the +/// same slots slides over `TraceInk.slide`, a new scale or a series coming +/// or going springs over `TraceInk.spring`, at the display's rate, and +/// then the chart holds still. A new window draws at once, on the scale's +/// spring. Data from the cache starts settled. +struct SlidingSurface: View { + let picture: Picture + /// The drawing for the picture on its way from `from`, with the slide's + /// and the spring's progress. + let drawing: (_ from: Picture?, _ slide: Double, _ spring: Double) -> Drawing + @State private var old: Picture? + @State private var changedAt = Date.distantPast + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + let moving = old != nil && !reduceMotion + TimelineView(.animation(paused: !moving)) { tick in + let t = tick.date.timeIntervalSince(changedAt) + let spring = TraceInk.ease(t, TraceInk.spring) + ChartSurface(drawing: drawing(moving && spring < 1 ? old : nil, TraceInk.ease(t, TraceInk.slide), spring)) + .equatable() + } + .onChange(of: picture) { before, _ in + old = before + changedAt = .now + } + .task(id: changedAt) { + try? await Task.sleep(for: .milliseconds(400)) + old = nil + } + } +} diff --git a/appleApp/FTWCharts/LiveTrace.swift b/appleApp/FTWCharts/LiveTrace.swift index 3edbc00..329082e 100644 --- a/appleApp/FTWCharts/LiveTrace.swift +++ b/appleApp/FTWCharts/LiveTrace.swift @@ -107,16 +107,14 @@ struct LiveTraceSurface: View { var catchUp: (fromMs: Double, at: Date)? var axisSize: CGFloat = 10 - @Environment(\.colorScheme) private var scheme @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.accessibilityReduceTransparency) private var reduceTransparency @Environment(\.scenePhase) private var phase /// Set 0.3 s after a reading came or a finger lifted, so the rest of /// the second runs at 15 fps. @State private var settledAt: Date? - static let slide: TimeInterval = 0.25 - static let spring: TimeInterval = 0.35 + static let slide = TraceInk.slide + static let spring = TraceInk.spring var body: some View { let lowPower = ProcessInfo.processInfo.isLowPowerModeEnabled @@ -137,19 +135,15 @@ struct LiveTraceSurface: View { } } - /// A critically damped ease from 0 to 1 over `duration`. static func ease(_ elapsed: TimeInterval, _ duration: TimeInterval) -> Double { - guard elapsed < duration else { return 1 } - let w = 6.0 / duration, t = max(0, elapsed) - return min(1, 1 - (1 + w * t) * exp(-w * t)) + TraceInk.ease(elapsed, duration) } private func draw(in g: inout GraphicsContext, size: CGSize, at date: Date, slides: Bool) { let series = frame.series let plot = chartFrame.plot(in: size) let px = 1 / max(g.environment.displayScale, 1) - let dark = scheme == .dark - let glows = dark && live && !reduceTransparency + let glows = TraceInk.glows(live: live, in: g) // The window's moment: held under a finger, springing back on lift. var viewMs = frozenAtMs ?? date.timeIntervalSince1970 * 1000 @@ -164,22 +158,17 @@ struct LiveTraceSurface: View { // A new scale moves as one spring, never jumps: y on the new scale // back to watts, then onto the scale on its way. var scale = CGAffineTransform.identity + var shown = series.domain if let previous = frame.previous, previous.domain != series.domain { let k = Self.ease(date.timeIntervalSince(frame.arrivedAt), Self.spring) - let h = series.size.height - let lower = previous.domain.lower + (series.domain.lower - previous.domain.lower) * k - let upper = previous.domain.upper + (series.domain.upper - previous.domain.upper) * k - let span = max(upper - lower, 1) - let aNew = -h / CGFloat(series.domain.span), bNew = h * CGFloat(series.domain.upper / series.domain.span) - let aNow = -h / CGFloat(span), bNow = h * CGFloat(upper / span) - let d = aNow / aNew - scale = CGAffineTransform(a: 1, b: 0, c: 0, d: d, tx: 0, ty: bNow - d * bNew) + shown = TraceInk.between(previous.domain, series.domain, k) + scale = TraceInk.rescale(series.domain, shown, height: series.size.height) } let baseline = CGPoint(x: 0, y: base).applying(scale).y // Axes first, still: zero, the magnitudes in the gutter, and the - // direction words when both signs show. - drawAxes(in: &g, plot: plot, px: px, baseline: plot.minY + baseline) + // direction words when both signs show, as on every chart. + ChartAxes.draw(in: &g, plot: plot, domain: series.domain, px: px, axis: axisSize, look: look, shown: shown) var plotting = g plotting.clip(to: Path(CGRect(x: plot.minX, y: plot.minY - 12, width: plot.width, height: plot.height + 24))) @@ -216,45 +205,22 @@ struct LiveTraceSurface: View { let width = series.size.width + shift + 40 let above = Path(CGRect(x: -width, y: -40, width: 3 * width, height: baseline + 40)) let below = Path(CGRect(x: -width, y: baseline, width: 3 * width, height: series.size.height - baseline + 40)) - let peak = dark ? 0.30 : 0.18 - // The band, under everything: how far the raw readings swung. - for (side, color) in [(above, look.up), (below, look.down)] { - var s = plotting - s.clip(to: side) - s.fill(p.band, with: .color(color.opacity(dark ? 0.14 : 0.10))) - } - // The body: one gradient from nothing at zero to `peak` at the - // scale's edge, in each direction's colour. - do { - var s = plotting - s.clip(to: above) - s.fill(p.body, with: .linearGradient(Gradient(colors: [look.up.opacity(0), look.up.opacity(peak)]), startPoint: CGPoint(x: 0, y: baseline), endPoint: CGPoint(x: 0, y: 0))) - s.fill(p.bodyUnseen, with: .linearGradient(Gradient(colors: [Theme.ink.opacity(0), Theme.ink.opacity(peak * 0.5)]), startPoint: CGPoint(x: 0, y: baseline), endPoint: CGPoint(x: 0, y: 0))) - } - do { - var s = plotting - s.clip(to: below) - s.fill(p.body, with: .linearGradient(Gradient(colors: [look.down.opacity(0), look.down.opacity(peak)]), startPoint: CGPoint(x: 0, y: baseline), endPoint: CGPoint(x: 0, y: series.size.height))) - s.fill(p.bodyUnseen, with: .linearGradient(Gradient(colors: [Theme.ink.opacity(0), Theme.ink.opacity(peak * 0.5)]), startPoint: CGPoint(x: 0, y: baseline), endPoint: CGPoint(x: 0, y: series.size.height))) - } + // The band, under everything: how far the raw readings swung. Then + // the body; what this phone did not see live in ink. + TraceInk.band(p.band, look: look, above: above, below: below, in: plotting) + TraceInk.body(p.body, look: look, baseline: baseline, height: series.size.height, above: above, below: below, in: plotting) + TraceInk.body(p.bodyUnseen, look: look, ink: true, baseline: baseline, height: series.size.height, above: above, below: below, in: plotting) // A silence the line does not join: a dotted baseline across it. plotting.stroke(p.gaps, with: .color(Theme.ink.opacity(0.35)), style: StrokeStyle(lineWidth: px, lineCap: .butt, lineJoin: .round, dash: [1, 3])) // What this phone did not see live: ink, no glow. - plotting.stroke(p.lineUnseen, with: .color(Theme.ink2), style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) + plotting.stroke(p.lineUnseen, with: .color(Theme.ink2), style: StrokeStyle(lineWidth: TraceInk.line, lineCap: .round, lineJoin: .round)) - // The line, each side of zero in its colour; glowing only while live. - for (side, color) in [(above, look.up), (below, look.down)] { - var s = plotting - s.clip(to: side) - if glows { - s.stroke(p.line, with: .color(color.opacity(0.12)), style: StrokeStyle(lineWidth: 7, lineCap: .round, lineJoin: .round)) - s.stroke(p.line, with: .color(color.opacity(0.25)), style: StrokeStyle(lineWidth: 4.5, lineCap: .round, lineJoin: .round)) - } - s.stroke(p.line, with: .color(live ? color : Theme.ink2), style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) - } + // The line, each side of zero in its colour; glowing only while + // live, and in ink once the reading is not. + TraceInk.line(p.line, look: live ? look : TraceLook(up: Theme.ink2, down: Theme.ink2), glows: glows, above: above, below: below, in: plotting) // The head: quiet, on the raw last reading, a small halo only while // live in the dark. @@ -267,32 +233,6 @@ struct LiveTraceSurface: View { plotting.fill(Path(ellipseIn: CGRect(x: head.x - r, y: head.y - r, width: 2 * r, height: 2 * r)), with: .color(live ? Theme.ink : Theme.ink2)) } } - - private func drawAxes(in g: inout GraphicsContext, plot: CGRect, px: CGFloat, baseline: CGFloat) { - let domain = frame.series.domain - var zero = Path() - zero.move(to: CGPoint(x: plot.minX, y: (baseline / px).rounded() * px + px / 2)) - zero.addLine(to: CGPoint(x: plot.maxX, y: (baseline / px).rounded() * px + px / 2)) - g.stroke(zero, with: .color(Theme.ink.opacity(0.35)), style: StrokeStyle(lineWidth: px, lineCap: .butt, lineJoin: .round)) - let map = YMap(domain: domain, height: plot.height) - for v in [domain.upper, domain.lower] where v != 0 { - let y = plot.minY + map.y(v) - var grid = Path() - grid.move(to: CGPoint(x: plot.minX, y: y)) - grid.addLine(to: CGPoint(x: plot.maxX, y: y)) - g.stroke(grid, with: .color(Theme.ink.opacity(0.12)), style: StrokeStyle(lineWidth: px, lineCap: .butt, lineJoin: .round, dash: [1, 3])) - let label = g.resolve(ChartText.label(PowerFormat.scale(abs(v)), size: axisSize)) - g.draw(label, at: CGPoint(x: plot.maxX + 4, y: y), anchor: .leading) - } - if domain.lower < 0, domain.upper > 0 { - if let up = look.upWord { - g.draw(g.resolve(ChartText.label(up, size: axisSize)), at: CGPoint(x: plot.minX + 2, y: plot.minY + 2), anchor: .topLeading) - } - if let down = look.downWord { - g.draw(g.resolve(ChartText.label(down, size: axisSize)), at: CGPoint(x: plot.minX + 2, y: plot.maxY - 2), anchor: .bottomLeading) - } - } - } } /// The finger on the live trace. The window holds still under it; the dot diff --git a/appleApp/FTWCharts/ScrubOverlay.swift b/appleApp/FTWCharts/ScrubOverlay.swift index e35c6ed..1e36f03 100644 --- a/appleApp/FTWCharts/ScrubOverlay.swift +++ b/appleApp/FTWCharts/ScrubOverlay.swift @@ -14,14 +14,17 @@ struct ScrubTrack: Equatable { /// "02:15 31,0 öre", per slot. var tags: [String] var landmarks: Set - /// The dot's colour. + /// The dot's colour, and below zero when that differs, as on the live + /// trace. var color: Color = Theme.ink - /// Dims what lies right of the finger, for a history's "so far". - var dimsAfter = false + var downColor: Color? /// A value that holds across its slot (a price, a plan quarter): the /// slot under the finger lights up as its whole bar and the rest dims, /// in place of a dot on a line. var bars = false + /// The last slot a finger may reach: the slots after it have not + /// happened yet. + var limit: Int? func x(_ i: Int, plot: CGRect) -> CGFloat { if let atMs, atMs.indices.contains(i) { return plot.minX + grid.x(atMs: atMs[i], width: plot.width) } @@ -51,6 +54,11 @@ struct ScrubOverlay: View { @GestureState private var touching = false @State private var size: CGSize = .zero + #if DEBUG + /// The newest track, for the debug driver: its task keeps the view + /// value it started with, often from before the data came. + @State private var newest: ScrubTrack? + #endif var body: some View { let down = model.isDown @@ -88,6 +96,7 @@ struct ScrubOverlay: View { .sensoryFeedback(.selection, trigger: model.ticks) .sensoryFeedback(.impact(weight: .light), trigger: model.bumps) #if DEBUG + .onChange(of: track, initial: true) { _, now in newest = now } .task { await autoScrub() } #endif } @@ -103,17 +112,19 @@ struct ScrubOverlay: View { } } - private func slot(_ x: CGFloat) -> Int? { + private func slot(_ x: CGFloat, on given: ScrubTrack? = nil) -> Int? { + let track = given ?? track let plot = frame.plot(in: size) - return track.grid.index(atX: x - plot.minX, width: plot.width) + guard let i = track.grid.index(atX: x - plot.minX, width: plot.width) else { return nil } + return track.limit.map { min(i, $0) } ?? i } - private func follow(_ x: CGFloat) { - guard let i = slot(x) else { + private func follow(_ x: CGFloat, on given: ScrubTrack? = nil) { + guard let i = slot(x, on: given) else { ScrubModel.log.error("touching with no slot at x \(x)") return } - model.move(to: i, landmark: track.landmarks.contains(i)) + model.move(to: i, landmark: (given ?? track).landmarks.contains(i)) } private func draw(_ i: Int, in g: inout GraphicsContext, size: CGSize) { @@ -127,15 +138,12 @@ struct ScrubOverlay: View { tag(track.tags.indices.contains(i) ? track.tags[i] : "", over: x, plot: plot, in: &g, size: size) return } - if track.dimsAfter { - g.fill(Path(CGRect(x: x, y: 0, width: max(0, plot.maxX - x), height: size.height)), with: .color(Theme.ground.opacity(0.55))) - } rule(at: x, plot: plot, px: px, in: &g) if track.values.indices.contains(i), let v = track.values[i] { let y = plot.minY + YMap(domain: track.domain, height: plot.height).y(v) let dot = CGPoint(x: x, y: y) - LockOn.dot(at: dot, color: track.color, in: &g) + LockOn.dot(at: dot, color: v < 0 ? track.downColor ?? track.color : track.color, in: &g) } tag(track.tags.indices.contains(i) ? track.tags[i] : "", over: x, plot: plot, in: &g, size: size) } @@ -212,11 +220,11 @@ struct ScrubOverlay: View { let t = (ContinuousClock.now - began) / .milliseconds(800) let phase = t.truncatingRemainder(dividingBy: 2) let along = phase < 1 ? phase : 2 - phase - follow(CGFloat(-0.05 + 1.1 * along) * size.width) + follow(CGFloat(-0.05 + 1.1 * along) * size.width, on: newest) try? await Task.sleep(for: .milliseconds(8)) } // Then two fingers, held for three seconds. - if let a = slot(0.3 * size.width), let b = slot(0.55 * size.width) { model.spread(a, b) } + if let a = slot(0.3 * size.width, on: newest), let b = slot(0.55 * size.width, on: newest) { model.spread(a, b) } try? await Task.sleep(for: .seconds(3)) model.lift() } diff --git a/appleApp/FTWCharts/TraceInk.swift b/appleApp/FTWCharts/TraceInk.swift new file mode 100644 index 0000000..6b05268 --- /dev/null +++ b/appleApp/FTWCharts/TraceInk.swift @@ -0,0 +1,86 @@ +import FTWShared +import SwiftUI + +/// The material every power trace is drawn in, live or past: the band, the +/// body, the line and its glow, and how a change of data moves. One place, +/// so a viewer who goes from Live to Today sees the time change and +/// nothing else. +enum TraceInk { + /// The line's width. + static let line: CGFloat = 2.5 + /// A second series on the same chart: thinner, under the node's own. + static let otherLine: CGFloat = 2 + /// A new value slides in over this long. + static let slide: TimeInterval = 0.25 + /// A new scale, a series coming or going, moves as one spring over this. + static let spring: TimeInterval = 0.35 + + /// A critically damped ease from 0 to 1 over `duration`. + static func ease(_ elapsed: TimeInterval, _ duration: TimeInterval) -> Double { + guard elapsed < duration else { return 1 } + let w = 6.0 / duration, t = max(0, elapsed) + return min(1, 1 - (1 + w * t) * exp(-w * t)) + } + + /// Glow means live: only while the reading is live, only in the dark, + /// and never with Reduce Transparency. + static func glows(live: Bool, in g: GraphicsContext) -> Bool { + live && g.environment.colorScheme == .dark && !g.environment.accessibilityReduceTransparency + } + + /// How far the readings swung, under everything, in each direction's + /// colour. + static func band(_ band: Path, look: TraceLook, above: Path, below: Path, in g: GraphicsContext) { + let dark = g.environment.colorScheme == .dark + for (side, color) in [(above, look.up), (below, look.down)] { + var s = g + s.clip(to: side) + s.fill(band, with: .color(color.opacity(dark ? 0.14 : 0.10))) + } + } + + /// The body: one gradient from nothing at zero to the scale's edge, in + /// each direction's colour, or in ink at half strength. + static func body(_ body: Path, look: TraceLook, ink: Bool = false, baseline: CGFloat, height: CGFloat, above: Path, below: Path, in g: GraphicsContext) { + let peak = g.environment.colorScheme == .dark ? 0.30 : 0.18 + for (side, color, edge) in [(above, look.up, CGFloat(0)), (below, look.down, height)] { + var s = g + s.clip(to: side) + let c = ink ? Theme.ink : color + let top = ink ? peak * 0.5 : peak + s.fill(body, with: .linearGradient(Gradient(colors: [c.opacity(0), c.opacity(top)]), startPoint: CGPoint(x: 0, y: baseline), endPoint: CGPoint(x: 0, y: edge))) + } + } + + /// The line, each side of zero in its colour, with the glow under it + /// when `glows`. `glowClip` limits the glow, say to the slot still + /// filling. + static func line(_ line: Path, look: TraceLook, width: CGFloat = TraceInk.line, opacity: Double = 1, glows: Bool, glowClip: Path? = nil, above: Path, below: Path, in g: GraphicsContext) { + for (side, color) in [(above, look.up), (below, look.down)] { + var s = g + s.clip(to: side) + if glows { + var glow = s + if let glowClip { glow.clip(to: glowClip) } + glow.stroke(line, with: .color(color.opacity(0.12)), style: StrokeStyle(lineWidth: 7, lineCap: .round, lineJoin: .round)) + glow.stroke(line, with: .color(color.opacity(0.25)), style: StrokeStyle(lineWidth: 4.5, lineCap: .round, lineJoin: .round)) + } + s.stroke(line, with: .color(color.opacity(opacity)), style: StrokeStyle(lineWidth: width, lineCap: .round, lineJoin: .round)) + } + } + + /// Values on scale `a` to the same values on scale `b`, in plot points. + static func rescale(_ a: YDomain, _ b: YDomain, height: CGFloat) -> CGAffineTransform { + guard a != b, a.span > 0, b.span > 0 else { return .identity } + let aa = -height / CGFloat(a.span), ab = height * CGFloat(a.upper / a.span) + let ba = -height / CGFloat(b.span), bb = height * CGFloat(b.upper / b.span) + let d = ba / aa + return CGAffineTransform(a: 1, b: 0, c: 0, d: d, tx: 0, ty: bb - d * ab) + } + + /// The scale on its way from `a` to `b`. + static func between(_ a: YDomain, _ b: YDomain, _ k: Double) -> YDomain { + guard a != b, k < 1 else { return b } + return YDomain(lower: a.lower + (b.lower - a.lower) * k, upper: a.upper + (b.upper - a.upper) * k) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift index 74ea607..34d3cc1 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift @@ -50,42 +50,6 @@ public enum Readouts { /// "14:35:12", for the live curve's seconds. public static func seconds(_ ms: Double) -> String { ZoneClock.of(.current).seconds(ms) } - /// A step of history: its power and which way, and the energy since the - /// chart's start up to the end of the step. - public static func history(_ node: EnergyNode, _ samples: [HistorySample], fromMs: Double, at ms: Double) -> ChartReadout? { - guard let i = samples.lastIndex(where: { $0.startMs <= ms }) else { return nil } - let s = samples[i] - let when = clock(max(fromMs, s.startMs)) - guard let w = s.watts else { - return ChartReadout(when: when, what: s.fetching ? NodeWords.text("fetching…") : NodeWords.text("no data")) - } - let e = EnergyToday.sum(Array(samples[...i]), fromMs: fromMs) - var what = "\(AppWords.power(abs(w))) \(word(node, w))" - if node.directions != nil { - what += " · " + NodeWords.format("↑ %@ ↓ %@ so far", AppWords.energy(e.inWh), AppWords.energy(e.outWh)) - } else { - what += " · " + NodeWords.format("%@ so far", AppWords.energy(e.inWh)) - } - return ChartReadout(when: when, what: what) - } - - /// Two fingers on history: the mean power and the energy between them. - public static func history(_ node: EnergyNode, _ samples: [HistorySample], range: ClosedRange) -> ChartReadout? { - let inside = samples.filter { $0.startMs + $0.stepMs > range.lowerBound && $0.startMs < range.upperBound } - guard !inside.isEmpty else { return nil } - let e = EnergyToday.sum(inside, fromMs: range.lowerBound) - let measured = inside.compactMap(\.watts) - guard !measured.isEmpty else { return ChartReadout(when: span(range.lowerBound, range.upperBound), what: NodeWords.text("no data")) } - let mean = measured.reduce(0, +) / Double(measured.count) - var what = NodeWords.format("average %@", AppWords.power(abs(mean))) - if node.directions != nil { - what += " · " + NodeWords.format("↑ %@ ↓ %@", AppWords.energy(e.inWh), AppWords.energy(e.outWh)) - } else { - what += " · " + AppWords.energy(e.inWh) - } - return ChartReadout(when: span(range.lowerBound, range.upperBound), what: what) - } - /// "65.5 öre/kWh" public static func price(_ minor: Double, _ currency: String) -> String { "\(AppWords.decimal(PriceStrip.text(minor, currency))) \(PriceUnits.unit(currency).perKwh)" @@ -125,11 +89,6 @@ public enum Readouts { return ChartReadout(when: span(range.lowerBound, range.upperBound), what: parts.joined(separator: " · ")) } - /// An hour of the car's charging. - public static func carHour(_ hours: [CarEnergyModel.Hour], at ms: Double) -> ChartReadout? { - guard let h = hours.first(where: { $0.startMs <= ms && ms < $0.startMs + $0.lenMs }) else { return nil } - return ChartReadout(when: span(h.startMs, h.startMs + h.lenMs), what: h.wh > 0 ? NodeWords.format("charged %@", AppWords.energy(h.wh)) : NodeWords.text("not charging")) - } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeLive.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeLive.swift index f52bd57..7ba7a1a 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeLive.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeLive.swift @@ -122,3 +122,28 @@ public enum NodeLive { Readouts.seconds(ms) + " " + AppWords.power(abs(watts)) } } + +/// Which series a node's history shows: its own first, the others in Now's +/// order, and never none. +public enum SeriesPick { + public static func ordered(_ series: [EnergyNode], own: EnergyNode) -> [EnergyNode] { + let rest = EnergyNode.allCases.filter { $0 != own && series.contains($0) } + return (series.contains(own) ? [own] : []) + rest + } + + /// A tap on a chip: in or out, but the last one stays. + public static func toggled(_ series: [EnergyNode], _ tapped: EnergyNode, own: EnergyNode) -> [EnergyNode] { + if series.contains(tapped) { + guard series.count > 1 else { return series } + return ordered(series.filter { $0 != tapped }, own: own) + } + return ordered(series + [tapped], own: own) + } + + /// What was kept, read back: unknown names dropped, the node's own + /// series when nothing is left. + public static func read(_ stored: String?, own: EnergyNode) -> [EnergyNode] { + let kept = (stored ?? "").split(separator: ",").compactMap { EnergyNode(rawValue: String($0)) } + return ordered(kept.isEmpty ? [own] : kept, own: own) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index 8a71bec..d4aa975 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -67,6 +67,11 @@ public enum NodeWords { "Live, the last five minutes": "Live, de senaste fem minuterna", "calculated": "beräknad", "Play as sound": "Spela som ljud", + "%@-minute means": "%@-minutersmedel", + "Close": "Stäng", + "Enlarge the chart": "Förstora diagrammet", + "FETCHING…": "HÄMTAS …", + "NOW": "NU", "Drawing as readings arrive…": "Ritas när mätningarna kommer …", "No live readings while your box is out of reach.": "Inga livevärden medan boxen inte går att nå.", "No reading yet": "Ingen mätning än", diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/AheadPicture.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/AheadPicture.swift new file mode 100644 index 0000000..74350f0 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/AheadPicture.swift @@ -0,0 +1,130 @@ +import CoreGraphics +import Foundation + +/// The hours ahead on one chart: what the box plans for each slot, or what +/// it forecasts, with the price under the same slots. A plan quarter is a +/// command that holds across its slot, so it is a ghost bar; a forecast is +/// an estimate, so it is a dashed line. Both start at the slot running now. +public struct AheadPicture: Equatable, Sendable { + public enum Kind: Equatable, Sendable { + case plan + case forecast + } + + public struct Tick: Equatable, Sendable { + public var ms: Double + public var label: String + } + + public var kind: Kind + public var grid: SlotGrid + public var domain: YDomain + public var size: CGSize + /// Watts per slot, signed as the node shows them; nil where the box + /// says nothing. + public var values: [Double?] + /// A forecast's dashed line through each slot's middle, one subpath per + /// run; empty for a plan. + public var line: ChartPath + /// The price per slot by third of the window's range, and its height in + /// eighths of the window's range: the block bars of the strip. + public var priceThirds: [PriceChart.Third?] + public var priceLevels: [Int?] + public var nowMs: Double + public var ticks: [Tick] + + /// `slots` and `prices` as they come; the window runs `spanMs` from the + /// slot that holds `nowMs`. + public static func build( + kind: Kind, + slots: [(startMs: Double, endMs: Double, watts: Double?)], + prices: [(startMs: Double, endMs: Double, minor: Double)], + nowMs: Double, + spanMs: Double = 12 * 3_600_000, + size: CGSize, + previous: YDomain? = nil, + floor: Double = 1, + clock: ZoneClock, + everyHours: Int = 3 + ) -> AheadPicture { + let ahead = slots.filter { $0.endMs > nowMs } + let stepMs = max(60_000, (ahead.map { $0.endMs - $0.startMs } + prices.filter { $0.startMs + ($0.endMs - $0.startMs) > nowMs }.map { $0.endMs - $0.startMs }).min() ?? 900_000) + let fromMs = ahead.first { $0.startMs <= nowMs }?.startMs ?? clock.alignDown(nowMs, stepMs: stepMs) + let count = max(1, Int((spanMs / stepMs).rounded(.up))) + let grid = SlotGrid(firstMs: fromMs, stepMs: stepMs, count: count, fromMs: fromMs, toMs: fromMs + Double(count) * stepMs) + + func value(_ items: [T], _ start: (T) -> Double, _ end: (T) -> Double, at ms: Double) -> T? { + items.first { start($0) <= ms && ms < end($0) } + } + var values = [Double?](repeating: nil, count: count) + var minor = [Double?](repeating: nil, count: count) + for i in 0.. lo else { return .middle } + return m < lo + (hi - lo) / 3 ? .cheap : m >= lo + 2 * (hi - lo) / 3 ? .dear : .middle + } + let levels: [Int?] = minor.map { m in + guard let m else { return nil } + guard hi > lo else { return 4 } + return 1 + min(7, Int(((m - lo) / (hi - lo) * 8).rounded(.down))) + } + + var ticks: [Tick] = [] + let hour = 3_600_000.0 + var t = clock.alignDown(fromMs, stepMs: hour) + hour + while t <= grid.toMs - hour { + let minute = clock.minuteOfDay(t) + if minute % 60 == 0, (minute / 60) % max(1, everyHours) == 0, t - nowMs >= 1.5 * hour { + ticks.append(Tick(ms: t, label: String(clock.label(t).prefix(2)))) + } + t += hour + } + + return AheadPicture(kind: kind, grid: grid, domain: domain, size: size, values: values, line: line, priceThirds: thirds, priceLevels: levels, nowMs: nowMs, ticks: ticks) + } + + /// A forecast's line through each slot's middle, one monotone subpath + /// per run of values. + public static func forecastLine(_ values: [Double?], grid: SlotGrid, domain: YDomain, size: CGSize) -> ChartPath { + let map = YMap(domain: domain, height: size.height) + var line = ChartPath() + var run: [CGPoint] = [] + for i in values.indices { + guard let v = values[i] else { + line.addMonotone(run) + run = [] + continue + } + run.append(CGPoint(x: grid.midX(i, width: size.width), y: map.y(v))) + } + line.addMonotone(run) + return line + } + + /// A new plan or forecast for the same slots, on its way from `old`: + /// each slot moves from its old value to its new one as `progress` + /// runs from 0 to 1, drawn on this picture's scale. A slot new to the + /// picture starts at zero; one gone is not drawn. Nil when the slots + /// differ (a new window draws at once) or the move is over. + public func blended(from old: AheadPicture, progress: Double) -> (values: [Double?], line: ChartPath)? { + guard progress < 1, old.kind == kind, old.grid == grid, old.size == size, old.values != values else { return nil } + let k = max(0, progress) + let mixed: [Double?] = zip(old.values, values).map { a, b in + guard let b else { return nil } + return (a ?? 0) + (b - (a ?? 0)) * k + } + return (mixed, kind == .forecast ? Self.forecastLine(mixed, grid: grid, domain: domain, size: size) : ChartPath()) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/ChartSeries.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/ChartSeries.swift index 542ec40..ce4a1b4 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/ChartSeries.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/ChartSeries.swift @@ -5,10 +5,13 @@ import Foundation public struct ChartSample: Equatable, Sendable { public var startMs: Double public var value: Double? + /// A hole whose tile is on its way, not one the box has no record for. + public var fetching: Bool - public init(startMs: Double, value: Double?) { + public init(startMs: Double, value: Double?, fetching: Bool = false) { self.startMs = startMs self.value = value + self.fetching = fetching } } @@ -31,6 +34,8 @@ public struct ChartBucket: Equatable, Sendable { /// Energy into and out of the site over its means, in watt-hours. public var inWh: Double public var outWh: Double + /// It holds no mean, and one of its holes is on its way. + public var fetching = false } /// The smallest bucket of 5, 15, 30 or 60 minutes that leaves each point @@ -56,9 +61,13 @@ public enum DisplayBucket { var sum = [Double](repeating: 0, count: count), at = sum, inWh = sum, outWh = sum var low = [Double](repeating: .infinity, count: count), high = [Double](repeating: -.infinity, count: count) var held = [Int](repeating: 0, count: count) + var coming = [Bool](repeating: false, count: count) let whPerW = sampleMs / 3_600_000 for s in samples where s.startMs >= fromMs && s.startMs < toMs { - guard let v = s.value else { continue } + guard let v = s.value else { + if s.fetching { coming[min(max(Int(((s.startMs - origin) / stepMs).rounded(.down)), 0), count - 1)] = true } + continue + } let k = min(max(Int(((s.startMs - origin) / stepMs).rounded(.down)), 0), count - 1) sum[k] += v held[k] += 1 @@ -81,11 +90,24 @@ public enum DisplayBucket { count: n, atMs: n > 0 ? at[k] / Double(n) : (max(start, fromMs) + min(start + stepMs, toMs)) / 2, inWh: inWh[k], - outWh: outWh[k] + outWh: outWh[k], + fetching: n == 0 && coming[k] )) } return out } + + /// The lowest and highest five-minute mean over the buckets, with zero. + public static func extent(_ buckets: [ChartBucket]) -> (low: Double, high: Double) { + var low = 0.0, high = 0.0 + for b in buckets { + if let lo = b.low, let hi = b.high { + low = min(low, lo) + high = max(high, hi) + } + } + return (low, high) + } } /// A history chart's geometry, built once per change of data off the main @@ -107,6 +129,18 @@ public struct ChartSeries: Equatable, Sendable { public var band: ChartPath /// Where each slot's value sits on the line; nil in a gap. public var points: [CGPoint?] + /// Where each slot's highest and lowest five-minute mean sit. + public var highs: [CGPoint?] + public var lows: [CGPoint?] + /// Holes on their way, and holes the box has no record for, as slots. + /// A slot after now is neither: it has not happened. + public var fetching: [ClosedRange] + public var missing: [ClosedRange] + /// A dashed straight line across each hole on its way, from the + /// measured slot before it to the one after: an estimate, never a value. + public var estimate: ChartPath + /// The last slot with data. + public var lastData: Int? public var readouts: ReadoutTable /// The history of one node between `fromMs` and `toMs` on a plot of @@ -125,6 +159,7 @@ public struct ChartSeries: Equatable, Sendable { floor: Double = 1, clock: ZoneClock, landmarksMs: [Double] = [], + domain forced: YDomain? = nil, words: ReadoutWords ) -> ChartSeries { let stepMs = DisplayBucket.stepMs(spanMs: toMs - fromMs, width: size.width) @@ -132,50 +167,47 @@ public struct ChartSeries: Equatable, Sendable { let grid = SlotGrid(firstMs: buckets.first?.startMs ?? fromMs, stepMs: stepMs, count: buckets.count, fromMs: fromMs, toMs: toMs) // The domain holds the band, not just the means, so the highest - // five-minute mean always fits. - var low = 0.0, high = 0.0 - for b in buckets { - if let lo = b.low, let hi = b.high { - low = min(low, lo) - high = max(high, hi) - } - } - let domain = YDomain.stable(low: low, high: high, previous: previous, floor: floor) + // five-minute mean always fits. Several series on one chart share + // one, given. + let extent = DisplayBucket.extent(buckets) + let domain = forced ?? YDomain.stable(low: extent.low, high: extent.high, previous: previous, floor: floor) let map = YMap(domain: domain, height: size.height) - var line = ChartPath(), body = ChartPath(), band = ChartPath() var points = [CGPoint?](repeating: nil, count: buckets.count) - var run: [CGPoint] = [], highs: [CGPoint] = [], lows: [CGPoint] = [] - run.reserveCapacity(buckets.count) - let base = map.baseline - func endRun() { - let start = line.elements.count - line.addMonotone(run) - // The body is the same curve, closed on zero: made once. - if run.count > 1 { - body.elements.append(contentsOf: line.elements[start...]) - body.elements.append(.line(CGPoint(x: run[run.count - 1].x, y: base))) - body.elements.append(.line(CGPoint(x: run[0].x, y: base))) - body.elements.append(.close) - band.addBand(highs: highs, lows: lows) - } - run.removeAll(keepingCapacity: true) - highs.removeAll(keepingCapacity: true) - lows.removeAll(keepingCapacity: true) - } + var highPoints = points, lowPoints = points for (i, b) in buckets.enumerated() { - guard let m = b.mean, let lo = b.low, let hi = b.high else { - endRun() + guard let m = b.mean, let lo = b.low, let hi = b.high else { continue } + let x = grid.x(atMs: b.atMs, width: size.width) + points[i] = CGPoint(x: x, y: map.y(m)) + highPoints[i] = CGPoint(x: x, y: map.y(hi)) + lowPoints[i] = CGPoint(x: x, y: map.y(lo)) + } + let (line, body, band) = paths(points: points, highs: highPoints, lows: lowPoints, baseline: map.baseline) + + // Holes: on their way, or none on record, but only before now. + let until = nowMs ?? toMs + var fetching: [ClosedRange] = [], missing: [ClosedRange] = [] + var estimate = ChartPath() + var i = 0 + while i < buckets.count { + guard buckets[i].mean == nil, buckets[i].startMs < until else { + i += 1 continue } - let x = grid.x(atMs: b.atMs, width: size.width) - let p = CGPoint(x: x, y: map.y(m)) - points[i] = p - run.append(p) - highs.append(CGPoint(x: x, y: map.y(hi))) - lows.append(CGPoint(x: x, y: map.y(lo))) + let coming = buckets[i].fetching + var j = i + while j + 1 < buckets.count, buckets[j + 1].mean == nil, buckets[j + 1].startMs < until, buckets[j + 1].fetching == coming { j += 1 } + if coming { + fetching.append(i...j) + if i > 0, j + 1 < buckets.count, let a = points[i - 1], let b = points[j + 1] { + estimate.elements += [.move(a), .line(b)] + } + } else { + missing.append(i...j) + } + i = j + 1 } - endRun() + let lastData = buckets.lastIndex { $0.mean != nil } var when: [String] = [], what: [String] = [] var inWh: [Double] = [0], outWh: [Double] = [0] @@ -200,9 +232,16 @@ public struct ChartSeries: Equatable, Sendable { outWh.append(outWh[i] + b.outWh) } + // As on the live trace: the high, the low, and the slots at each end + // of a run of data, where a hole begins or ends. var landmarks = Set() if let highest { landmarks.insert(highest.i) } if let lowest { landmarks.insert(lowest.i) } + for (i, b) in buckets.enumerated() where b.mean != nil { + let before = i == 0 || buckets[i - 1].mean == nil + let after = i == buckets.count - 1 || buckets[i + 1].mean == nil + if before || after { landmarks.insert(i) } + } for ms in (nowMs.map { [$0] } ?? []) + landmarksMs where ms >= fromMs && ms < toMs { if let i = grid.index(atMs: ms) { landmarks.insert(i) } } @@ -216,7 +255,66 @@ public struct ChartSeries: Equatable, Sendable { body: body, band: band, points: points, + highs: highPoints, + lows: lowPoints, + fetching: fetching, + missing: missing, + estimate: estimate, + lastData: lastData, readouts: ReadoutTable(when: when, what: what, inWh: inWh, outWh: outWh, landmarks: landmarks) ) } } + +extension ChartSeries { + /// The line, body and band through slot points: one run per stretch of + /// slots with data, so a hole stays a break. The body is the line's own + /// curve closed on zero; the band runs from the highs' curve back along + /// the lows'. + public static func paths(points: [CGPoint?], highs: [CGPoint?], lows: [CGPoint?], baseline: CGFloat) -> (line: ChartPath, body: ChartPath, band: ChartPath) { + var line = ChartPath(), body = ChartPath(), band = ChartPath() + var run: [CGPoint] = [], hi: [CGPoint] = [], lo: [CGPoint] = [] + func endRun() { + let start = line.elements.count + line.addMonotone(run) + if run.count > 1 { + body.elements.append(contentsOf: line.elements[start...]) + body.elements.append(.line(CGPoint(x: run[run.count - 1].x, y: baseline))) + body.elements.append(.line(CGPoint(x: run[0].x, y: baseline))) + body.elements.append(.close) + band.addBand(highs: hi, lows: lo) + } + run.removeAll(keepingCapacity: true) + hi.removeAll(keepingCapacity: true) + lo.removeAll(keepingCapacity: true) + } + for i in points.indices { + guard let p = points[i], let h = highs[i], let l = lows[i] else { + endRun() + continue + } + run.append(p) + hi.append(h) + lo.append(l) + } + endRun() + return (line, body, band) + } + + /// Part way from `old` to this series, `progress` from 0 to 1: each slot + /// both have moves from its old mean and swing to its new one, matched + /// by slot; a slot only this one has is drawn as it is. Nil when the two + /// do not share their slots and scale: then nothing morphs. + public func blended(from old: ChartSeries, progress: Double) -> (line: ChartPath, body: ChartPath, band: ChartPath)? { + guard progress < 1, old.grid == grid, old.domain == domain, old.points.count == points.count else { return nil } + let k = CGFloat(min(max(progress, 0), 1)) + func mix(_ a: [CGPoint?], _ b: [CGPoint?]) -> [CGPoint?] { + zip(a, b).map { o, n in + guard let n else { return nil } + guard let o else { return n } + return CGPoint(x: o.x + (n.x - o.x) * k, y: o.y + (n.y - o.y) * k) + } + } + return Self.paths(points: mix(old.points, points), highs: mix(old.highs, highs), lows: mix(old.lows, lows), baseline: YMap(domain: domain, height: size.height).baseline) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/HistoryPicture.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/HistoryPicture.swift new file mode 100644 index 0000000..dc642bb --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/HistoryPicture.swift @@ -0,0 +1,116 @@ +import CoreGraphics +import Foundation + +/// Today or the last two days for one or more series on one chart, built +/// once per change of data off the main actor. Every series has its own +/// line, band, body and holes; they share the slots and one stable scale +/// that holds every band, so adding a series re-fits the scale by the same +/// rules and taking one away never changes what the others say. +/// +/// Today sits on a fixed day from midnight to midnight: the line grows to +/// the right through the day and the hours ahead stay empty, so neither +/// the scale nor the slots move as the day goes on. +public struct HistoryPicture: Equatable, Sendable { + /// One series on the chart, by the key its caller gave it. + public struct Layer: Equatable, Sendable { + public var key: String + public var series: ChartSeries + } + + /// A time label: "06", or a weekday at midnight on two days. + public struct Tick: Equatable, Sendable { + public var ms: Double + public var label: String + } + + /// In the order given; the first is the chart's own series. + public var layers: [Layer] + public var grid: SlotGrid + public var domain: YDomain + public var size: CGSize + public var nowMs: Double? + /// The last slot with data in any series: a finger stops there, since + /// the slots after it have not happened. + public var limit: Int? + /// When the newest data is more than a slot old: where it ends. + public var asOfMs: Double? + public var ticks: [Tick] + + public var stepMs: Double { grid.stepMs } + + /// `inputs` in drawing order, the chart's own series first. `everyHours` + /// spaces the time labels; `weekday` names a midnight on two days. + public static func build( + _ inputs: [(key: String, samples: [ChartSample])], + fromMs: Double, + toMs: Double, + nowMs: Double?, + size: CGSize, + previous: YDomain? = nil, + floor: Double = 1, + clock: ZoneClock, + everyHours: Int = 6, + weekday: (@Sendable (Double) -> String)? = nil, + words: ReadoutWords + ) -> HistoryPicture { + // One scale for every series: the union of their bands. + let stepMs = DisplayBucket.stepMs(spanMs: toMs - fromMs, width: size.width) + var low = 0.0, high = 0.0 + for input in inputs { + let e = DisplayBucket.extent(DisplayBucket.buckets(input.samples, stepMs: stepMs, fromMs: fromMs, toMs: toMs, clock: clock)) + low = min(low, e.low) + high = max(high, e.high) + } + let domain = YDomain.stable(low: low, high: high, previous: previous, floor: floor) + let layers = inputs.map { input in + Layer(key: input.key, series: ChartSeries.history(input.samples, fromMs: fromMs, toMs: toMs, nowMs: nowMs, size: size, clock: clock, domain: domain, words: words)) + } + let grid = layers.first?.series.grid ?? SlotGrid(firstMs: fromMs, stepMs: stepMs, count: 0, fromMs: fromMs, toMs: toMs) + let limit = layers.compactMap(\.series.lastData).max() + + // The newest data, and whether it is more than a slot behind now. + var asOf: Double? + if let nowMs, let limit { + // A slot still filling ends where its five-minute means end. + let newest = layers.compactMap { layer -> Double? in + guard let i = layer.series.lastData else { return nil } + let b = layer.series.buckets[i] + return min(b.endMs, b.startMs + Double(b.count) * 300_000) + }.max() ?? grid.endMs(limit) + if nowMs - newest > stepMs { asOf = newest } + } + + // Whole local hours a few apart, a weekday at midnight when asked, + // and none within an hour of the right edge. + var ticks: [Tick] = [] + let hour = 3_600_000.0 + var t = clock.alignDown(fromMs, stepMs: hour) + if t < fromMs { t += hour } + while t <= toMs - hour { + let minute = clock.minuteOfDay(t) + if minute % 60 == 0, (minute / 60) % max(1, everyHours) == 0 { + let label = minute == 0 && weekday != nil ? weekday!(t) : String(clock.label(t).prefix(2)) + ticks.append(Tick(ms: t, label: label)) + } + t += hour + } + + return HistoryPicture(layers: layers, grid: grid, domain: domain, size: size, nowMs: nowMs, limit: limit, asOfMs: asOf, ticks: ticks) + } + + /// Local midnight before `ms` and the next one, through any turn of the + /// clocks: Today's fixed window. + public static func day(_ ms: Double, clock: ZoneClock) -> (fromMs: Double, toMs: Double) { + let date = Date(timeIntervalSince1970: ms / 1000) + let start = clock.calendar.startOfDay(for: date) + let next = clock.calendar.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(86_400) + return (start.timeIntervalSince1970 * 1000, next.timeIntervalSince1970 * 1000) + } + + /// The last two days to now, on the five-minute step, so the window + /// changes once per tile and not every second. + public static func twoDays(_ nowMs: Double) -> (fromMs: Double, toMs: Double) { + let to = ((nowMs / 300_000).rounded(.up)) * 300_000 + return (to - 48 * 3_600_000, to) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/NodeLiveTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/NodeLiveTests.swift index 3e884d2..a66b794 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/NodeLiveTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/NodeLiveTests.swift @@ -76,6 +76,21 @@ import Testing #expect(sunOnly?.sun == 1) } + @Test func aChipAddsOrTakesAwayASeriesAndTheLastOneStays() { + var series = SeriesPick.read(nil, own: .battery) + #expect(series == [.battery]) + series = SeriesPick.toggled(series, .grid, own: .battery) + series = SeriesPick.toggled(series, .solar, own: .battery) + // The node's own series first, the rest in Now's order. + #expect(series == [.battery, .solar, .grid]) + series = SeriesPick.toggled(series, .battery, own: .battery) + #expect(series == [.solar, .grid]) + series = SeriesPick.toggled(series, .solar, own: .battery) + #expect(SeriesPick.toggled(series, .grid, own: .battery) == [.grid]) + #expect(SeriesPick.read("grid,bogus,home", own: .battery) == [.home, .grid]) + #expect(SeriesPick.read("", own: .solar) == [.solar]) + } + @Test func everyNewWordHasSwedish() { for key in ["LIVE", "CALCULATED", "NO READING YET", "AS OF %@", "VIA RELAY", "VIA FTW CLOUD", "READS EVERY %@ S", "LIVE · LAST 5 MIN", "5 min: made %@", "earns ≈ %@", "%@ of forecast"] { #expect(NodeWords.sv[key] != nil, "\(key)") diff --git a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift index 1f23da9..e7ee66d 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift @@ -401,28 +401,6 @@ import FTWShared HistorySample(startMs: Double(i) * Self.step, stepMs: Self.step, watts: w, fetching: fetching) } - @Test func aStepSaysItsPowerItsWayAndTheEnergySoFar() { - AppWords.$language.withValue(.en) { - let samples = [sample(0, 1_200), sample(1, -600), sample(2, nil), sample(3, nil, fetching: true), sample(4, 2_400)] - let r = Readouts.history(.battery, samples, fromMs: 0, at: Self.step * 1.5) - #expect(r?.when == AppWords.clock(Self.step, zone: .current)) - #expect(r?.what == "600\u{00A0}W giving power · ↑ \(AppWords.energy(100)) ↓ \(AppWords.energy(50)) so far") - // A hole says which kind it is. - #expect(Readouts.history(.battery, samples, fromMs: 0, at: Self.step * 2.5)?.what == "no data") - #expect(Readouts.history(.battery, samples, fromMs: 0, at: Self.step * 3.5)?.what == "fetching…") - // One way only for the sun. - #expect(Readouts.history(.solar, [sample(0, 1_200)], fromMs: 0, at: 1)?.what == "1.2\u{00A0}kW making · \(AppWords.energy(100)) so far") - } - } - - @Test func twoFingersSayTheMeanAndTheEnergyBetweenThem() { - AppWords.$language.withValue(.sv) { - let samples = [sample(0, 1_200), sample(1, 600), sample(2, 0)] - let r = Readouts.history(.home, samples, range: 0...(Self.step * 2)) - #expect(r?.what == "i snitt 900\u{00A0}W · \(AppWords.energy(150))") - } - } - @Test func aSlotAheadSaysWhatThePlanDoesWhyAndThePrice() { AppWords.$language.withValue(.en) { let hour = 3_600_000.0 @@ -771,13 +749,13 @@ import FTWShared @Test func everySentenceHasSwedish() throws { let files = [ "appleApp/FTW/UI/NowView.swift", "appleApp/FTW/UI/FlowDiagram.swift", "appleApp/FTW/UI/NodeView.swift", - "appleApp/FTW/UI/NodeCharts.swift", "appleApp/FTW/UI/ModeSheet.swift", + "appleApp/FTW/UI/NodeHistory.swift", "appleApp/FTW/UI/ModeSheet.swift", "appleApp/FTW/UI/PlanCard.swift", "appleApp/FTW/UI/PlanView.swift", "appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift", "appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift", "appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift", "appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift", "appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift", "appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift", "appleApp/FTW/UI/PairView.swift", "appleApp/FTW/UI/RootView.swift", "appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift", - "appleApp/FTW/UI/ChartScrub.swift", "appleApp/FTW/Car/CarView.swift", + "appleApp/FTW/Car/CarView.swift", "appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift", "appleApp/FTW/UI/PriceView.swift", ] let pattern = #/(?:\bL|NodeWords\.(?:text|format)|AppWords\.(?:text|format))\("((?:[^"\\]|\\.)*)"/# diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/ChartGeometryTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/ChartGeometryTests.swift index 2665189..cbe5662 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/ChartGeometryTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/ChartGeometryTests.swift @@ -339,7 +339,7 @@ import Testing #expect(t.what == ["567 W · 0–1100", "1000 W", "-1500 W · -2000–-1000"]) #expect(t.soFar(0).inWh == 1_700.0 / 12) #expect(t.between(2, 1).outWh == 4_500.0 / 12) - // The highest mean, the lowest and now. + // The highest mean, the lowest, now, and the ends of the run. #expect(t.landmarks == [0, 2]) #expect(series.grid.index(atMs: from + 2_000_000) == 2) } diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/HistoryRecipeTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/HistoryRecipeTests.swift new file mode 100644 index 0000000..6bac2d7 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/HistoryRecipeTests.swift @@ -0,0 +1,191 @@ +import CoreGraphics +import Foundation +import Testing +@testable import FTWShared + +/// History and the hours ahead on the engine (WP7 of the polish release): +/// holes, one scale for several series, Today's fixed day, the ahead +/// picture, and calm on the home box's own days. +@Suite struct HistoryRecipeTests { + static let clock = ChartGeometryTests.clock + static let words = ChartGeometryTests.words + static let size = CGSize(width: 350, height: 240) + + static func day(_ day: Int) -> (fromMs: Double, toMs: Double) { + HistoryPicture.day(ChartGeometryTests.at(day, 12), clock: clock) + } + + static func samples(from: Double, count: Int, _ value: (Int) -> Double?) -> [ChartSample] { + (0.. 80 && $0 < 200 ? 4_500 : 0 } + let both = HistoryPicture.build([("battery", battery), ("solar", solar)], fromMs: from, toMs: to, nowMs: to, size: Self.size, clock: Self.clock, words: Self.words) + let alone = HistoryPicture.build([("battery", battery)], fromMs: from, toMs: to, nowMs: to, size: Self.size, clock: Self.clock, words: Self.words) + #expect(both.domain.upper >= 4_500 && alone.domain.upper < 4_500) + #expect(both.layers.map(\.key) == ["battery", "solar"]) + #expect(both.layers.allSatisfy { $0.series.domain == both.domain }) + // The battery's readout is the same whether the sun is shown or not. + let a = try #require(both.layers.first?.series.readouts), b = try #require(alone.layers.first?.series.readouts) + #expect(a.what == b.what && a.when == b.when && a.inWh == b.inWh) + // Each series keeps its own band. + #expect(both.layers.allSatisfy { !$0.series.band.isEmpty }) + } + + @Test func aTileThatLandsMovesEachSlotFromItsOldMeanToItsNewOne() throws { + let (from, to) = Self.day(13) + let before = Self.samples(from: from, count: 120) { _ in 500 } + var after = before + for i in 108..<120 { after[i].value = 1_500 } + let a = ChartSeries.history(before, fromMs: from, toMs: to, nowMs: to, size: Self.size, clock: Self.clock, domain: YDomain(lower: 0, upper: 2_000), words: Self.words) + let b = ChartSeries.history(after, fromMs: from, toMs: to, nowMs: to, size: Self.size, clock: Self.clock, domain: YDomain(lower: 0, upper: 2_000), words: Self.words) + let start = try #require(b.blended(from: a, progress: 0)) + #expect(start.line == ChartSeries.paths(points: a.points, highs: a.highs, lows: a.lows, baseline: YMap(domain: a.domain, height: Self.size.height).baseline).line) + let half = try #require(b.blended(from: a, progress: 0.5)) + let ys = half.line.flattened().map(\.y) + let y500 = YMap(domain: b.domain, height: Self.size.height).y(500), y1500 = YMap(domain: b.domain, height: Self.size.height).y(1_500) + #expect((ys.min() ?? 0) > y1500 - 1e-6 && (ys.max() ?? 0) < y500 + 1e-6) + #expect(b.blended(from: a, progress: 1) == nil) + // Another window or scale morphs nothing: it cross-fades. + let other = ChartSeries.history(after, fromMs: from, toMs: to, nowMs: to, size: Self.size, clock: Self.clock, domain: YDomain(lower: 0, upper: 3_000), words: Self.words) + #expect(other.blended(from: a, progress: 0.5) == nil) + } + + @Test func todayIsAFixedDayFromMidnightToMidnight() { + let (from, to) = Self.day(13) + #expect(to - from == 86_400_000) + // The day the clocks go back is 25 hours long. + let (a, b) = HistoryPicture.day(ChartGeometryTests.clock.calendar.date(from: DateComponents(year: 2026, month: 10, day: 25, hour: 12))!.timeIntervalSince1970 * 1000, clock: Self.clock) + #expect(b - a == 25 * 3_600_000) + let now = from + 15 * 3_600_000 + 20 * 60_000 + let picture = HistoryPicture.build([("home", Self.samples(from: from, count: 184) { _ in 600 })], fromMs: from, toMs: to, nowMs: now, size: Self.size, clock: Self.clock, words: Self.words) + #expect(picture.ticks.map(\.label) == ["00", "06", "12", "18"]) + #expect(picture.limit == 30) + #expect(picture.asOfMs == nil) + // An hour without tiles before now says where the data ends. + let stale = HistoryPicture.build([("home", Self.samples(from: from, count: 160) { _ in 600 })], fromMs: from, toMs: to, nowMs: now, size: Self.size, clock: Self.clock, words: Self.words) + #expect(stale.asOfMs == from + 160 * 300_000) + let window = HistoryPicture.twoDays(now) + #expect(window.toMs - window.fromMs == 48 * 3_600_000 && window.toMs.truncatingRemainder(dividingBy: 300_000) == 0) + } + + @Test func twoDaysNameEachMidnight() { + let now = ChartGeometryTests.at(14, 10, 3) + let (from, to) = HistoryPicture.twoDays(now) + let picture = HistoryPicture.build([("grid", Self.samples(from: from, count: 576) { _ in 100 })], fromMs: from, toMs: to, nowMs: now, size: Self.size, clock: Self.clock, everyHours: 12, weekday: { _ in "MON" }, words: Self.words) + #expect(picture.grid.stepMs == 3_600_000) + #expect(picture.ticks.map(\.label) == ["12", "MON", "12", "MON"]) + } + + // MARK: Ahead + + @Test func thePlanAheadIsGhostSlotsAndAForecastIsAnEstimatedLine() { + let now = ChartGeometryTests.at(14, 10, 7) + let start = ChartGeometryTests.at(14, 10) + func watts(_ i: Int) -> Double { i < 8 ? 3_000 : i < 20 ? -2_000 : 0 } + var slots: [(startMs: Double, endMs: Double, watts: Double?)] = [] + var prices: [(startMs: Double, endMs: Double, minor: Double)] = [] + for i in 0..<48 { + let a = start + Double(i) * 900_000 + slots.append((a, a + 900_000, watts(i))) + prices.append((a, a + 900_000, 40 + Double(i))) + } + let plan = AheadPicture.build(kind: .plan, slots: slots, prices: prices, nowMs: now, size: Self.size, floor: 3_000, clock: Self.clock) + #expect(plan.grid.firstMs == start && plan.grid.count == 48) + #expect(plan.values[0] == 3_000 && plan.values[10] == -2_000) + #expect(plan.domain.lower <= -2_000 && plan.domain.upper >= 3_000) + #expect(plan.line.isEmpty) + #expect(plan.priceThirds[0] == PriceChart.Third.cheap && plan.priceThirds[47] == PriceChart.Third.dear) + #expect(plan.priceLevels[0] == 1 && plan.priceLevels[47] == 8) + #expect(plan.ticks.map(\.label) == ["12", "15", "18", "21"]) + let made: [(startMs: Double, endMs: Double, watts: Double?)] = slots.map { ($0.startMs, $0.endMs, abs($0.watts ?? 0)) } + let forecast = AheadPicture.build(kind: .forecast, slots: made, prices: [], nowMs: now, size: Self.size, clock: Self.clock) + #expect(!forecast.line.isEmpty) + #expect(forecast.priceThirds.allSatisfy { (t: PriceChart.Third?) in t == nil }) + } + + @Test func aNewPlanForTheSameSlotsMovesEachSlotToItsNewValue() throws { + let now = ChartGeometryTests.at(14, 10, 7) + let start = ChartGeometryTests.at(14, 10) + func slots(_ w: @escaping (Int) -> Double?) -> [(startMs: Double, endMs: Double, watts: Double?)] { + (0..<48).map { i in (start + Double(i) * 900_000, start + Double(i + 1) * 900_000, w(i)) } + } + let old = AheadPicture.build(kind: .forecast, slots: slots { _ in 1_000 }, prices: [], nowMs: now, size: Self.size, floor: 3_000, clock: Self.clock) + let new = AheadPicture.build(kind: .forecast, slots: slots { $0 == 5 ? nil : 2_000 }, prices: [], nowMs: now, size: Self.size, previous: old.domain, floor: 3_000, clock: Self.clock) + let half = try #require(new.blended(from: old, progress: 0.5)) + #expect(half.values[0] == 1_500 && half.values[5] == nil) + #expect(!half.line.isEmpty) + #expect(new.blended(from: old, progress: 1) == nil) + // A window that moved on draws at once. + let later = AheadPicture.build(kind: .forecast, slots: slots { _ in 2_000 }, prices: [], nowMs: now + 900_000, size: Self.size, floor: 3_000, clock: Self.clock) + #expect(later.blended(from: old, progress: 0.5) == nil) + } + + // MARK: Calm on the home box's days + + struct Days: Decodable { + var t0_ms: Double + var series: [String: [[Double]]] + } + + static func recorded() throws -> Days { + let url = try #require(Bundle.module.url(forResource: "sungrow-days", withExtension: "json", subdirectory: "Resources")) + return try JSONDecoder().decode(Days.self, from: Data(contentsOf: url)) + } + + /// Today on the home box, 12 and 13 October 2026, drawn as WP7 does + /// (30-minute means) and as 0.3.2 did (every five-minute mean through a + /// monotone curve). + @Test func todayIsCalmOnTheHomeBoxsOwnDays() throws { + let days = try Self.recorded() + for name in ["battery_w", "pv_w", "meter_w"] { + let rows = try #require(days.series[name]) + let samples = rows.map { ChartSample(startMs: days.t0_ms + $0[0] * 1_000, value: $0[1]) } + for d in [12, 13] { + let (from, to) = Self.day(d) + let series = ChartSeries.history(samples, fromMs: from, toMs: to, nowMs: to, size: CGSize(width: 350, height: 280), floor: 3_000, clock: Self.clock, words: Self.words) + let turns = Calm.turnsPer100(series.line.flattened(steps: 8)) + let map = YMap(domain: series.domain, height: 280) + let fine = samples.filter { $0.startMs >= from && $0.startMs < to }.compactMap { s in s.value.map { CGPoint(x: series.grid.x(atMs: s.startMs + 150_000, width: 350), y: map.y($0)) } } + let before = Calm.turnsPer100(ChartPaths.monotone(fine).flattened(steps: 8)) + #expect(turns <= 12, "\(name) on \(d) Oct: \(turns) turns per 100 pt") + #expect(turns < before / 2 || before < 4, "\(name) on \(d) Oct: \(turns) against \(before) in 0.3.2") + } + } + } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/Resources/sungrow-days.json b/appleApp/FTWKit/Tests/FTWSharedTests/Resources/sungrow-days.json new file mode 100644 index 0000000..e3775e4 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/Resources/sungrow-days.json @@ -0,0 +1 @@ +{"source":"home box, sungrow driver, /api/series, 5-minute means","step_ms":300000,"series":{"battery_w":[[-300,194],[0,1274],[300,2223],[600,4802],[900,1512],[1200,-259],[1500,388],[1800,457],[2100,-296],[2400,1307],[2700,399],[3000,335],[3300,422],[3600,1316],[3900,1628],[4200,443],[4500,758],[4800,3166],[5100,1210],[5400,1810],[5700,2357],[6000,2496],[6300,855],[6600,1137],[6900,-1618],[7200,-653],[7500,230],[7800,372],[8100,2335],[8400,1481],[8700,495],[9000,2],[9300,-294],[9600,-267],[9900,59],[10200,-44],[10500,361],[10800,4027],[11100,4887],[11400,4754],[11700,4806],[12000,3775],[12300,4078],[12600,3633],[12900,2239],[13200,18],[13500,-325],[13800,3615],[14100,4768],[14400,4130],[14700,1629],[15000,1059],[15300,1023],[15600,1065],[15900,2628],[16200,2256],[16500,0],[16800,562],[17100,0],[17400,18],[17700,14],[18000,41],[18300,0],[18600,0],[18900,19],[19200,0],[19500,5],[19800,3],[20100,0],[20400,24],[20700,-656],[21000,-1323],[21300,-2581],[21600,2094],[21900,755],[22200,676],[22500,1149],[22800,86],[23100,9],[23400,168],[23700,0],[24000,0],[24300,0],[24600,0],[24900,0],[25200,0],[25500,-1527],[25800,-2134],[26100,-3229],[26400,-3102],[26700,-3450],[27000,-3455],[27300,-2577],[27600,-1494],[27900,-1557],[28200,-3642],[28500,-2797],[28800,-1755],[29100,-1936],[29400,-1520],[29700,-1415],[30000,-1460],[30300,-1428],[30600,-1401],[30900,-1349],[31200,-1807],[31500,-3354],[31800,-3354],[32100,-1639],[32400,-1401],[32700,-1519],[33000,-1492],[33300,-1452],[33600,-1485],[33900,-1143],[34200,-686],[34500,-640],[34800,-520],[35100,-509],[35400,-509],[35700,-509],[36000,-509],[36300,-503],[36600,-454],[36900,-464],[37200,-480],[37500,-524],[37800,-506],[38100,-517],[38400,-494],[38700,-435],[39000,-396],[39300,-339],[39600,-335],[39900,-335],[40200,-421],[40500,-468],[40800,-506],[41100,-462],[41400,-375],[41700,-375],[42000,-376],[42300,-376],[42600,-376],[42900,-414],[43200,-458],[43500,-373],[43800,-302],[44100,-230],[44400,-232],[44700,-233],[45000,-231],[45300,-30],[45600,0],[45900,0],[46200,-356],[46500,-397],[46800,-361],[47100,-297],[47400,-337],[47700,-366],[48000,-45],[48300,0],[48600,0],[48900,0],[49200,0],[49500,0],[49800,0],[50100,0],[50400,0],[50700,0],[51000,0],[51300,0],[51600,3131],[51900,3419],[52200,3622],[52500,1583],[52800,1023],[53100,1612],[53400,442],[53700,0],[54000,0],[54300,0],[54600,0],[54900,0],[55200,0],[55500,0],[55800,-15],[56100,294],[56400,0],[56700,1513],[57000,379],[57300,0],[57600,0],[57900,0],[58200,0],[58500,0],[58800,3188],[59100,3622],[59400,3626],[59700,662],[60000,459],[60300,2748],[60600,456],[60900,0],[61200,0],[61500,585],[61800,647],[62100,383],[62400,3173],[62700,3806],[63000,3580],[63300,1746],[63600,435],[63900,840],[64200,1482],[64500,1188],[64800,1262],[65100,3599],[65400,3561],[65700,3545],[66000,1392],[66300,140],[66600,292],[66900,3623],[67200,3831],[67500,3785],[67800,503],[68100,152],[68400,336],[68700,304],[69000,222],[69300,155],[69600,2140],[69900,3095],[70200,2699],[70500,0],[70800,0],[71100,0],[71400,0],[71700,0],[72000,0],[72300,-1125],[72600,-1312],[72900,-1386],[73200,-1255],[73500,-1316],[73800,-1222],[74100,-1232],[74400,-1397],[74700,-1488],[75000,-1349],[75300,-1341],[75600,-1373],[75900,-1388],[76200,-937],[76500,-738],[76800,-766],[77100,-804],[77400,-587],[77700,-619],[78000,-505],[78300,-473],[78600,-496],[78900,-507],[79200,-554],[79500,-546],[79800,-151],[80100,0],[80400,0],[80700,-104],[81000,122],[81300,96],[81600,0],[81900,0],[82200,0],[82500,0],[82800,0],[83100,0],[83400,0],[83700,0],[84000,0],[84300,0],[84600,-491],[84900,-1259],[85200,-1508],[85500,-1451],[85800,-1222],[86100,-1111],[86400,-1126],[86700,-1453],[87000,-1650],[87300,-1764],[87600,-170],[87900,1611],[88200,3216],[88500,510],[88800,251],[89100,1467],[89400,2127],[89700,801],[90000,9],[90300,0],[90600,249],[90900,1504],[91200,927],[91500,951],[91800,212],[92100,2723],[92400,652],[92700,488],[93000,411],[93300,827],[93600,44],[93900,366],[94200,790],[94500,172],[94800,0],[95100,606],[95400,1251],[95700,1011],[96000,831],[96300,1336],[96600,2035],[96900,1440],[97200,899],[97500,549],[97800,650],[98100,787],[98400,780],[98700,998],[99000,1005],[99300,613],[99600,209],[99900,269],[100200,2062],[100500,1890],[100800,735],[101100,9],[101400,0],[101700,15],[102000,0],[102300,0],[102600,0],[102900,0],[103200,0],[103500,0],[103800,0],[104100,0],[104400,0],[104700,0],[105000,0],[105300,0],[105600,0],[105900,0],[106200,0],[106500,0],[106800,0],[107100,0],[107400,0],[107700,0],[108000,0],[108300,0],[108600,0],[108900,0],[109200,-952],[109500,0],[109800,0],[110100,-962],[110400,-2760],[110700,-2192],[111000,-181],[111300,0],[111600,0],[111900,-433],[112200,-514],[112500,-639],[112800,-832],[113100,-862],[113400,-981],[113700,-1590],[114000,-1489],[114300,-1479],[114600,-1512],[114900,-1550],[115200,-1571],[115500,-2107],[115800,-3684],[116100,-1620],[116400,-1196],[116700,-3224],[117000,-2548],[117300,-830],[117600,-792],[117900,-951],[118200,-2754],[118500,-2135],[118800,-801],[119100,-727],[119400,-715],[119700,-704],[120000,-617],[120300,-509],[120600,-632],[120900,-700],[121200,-1088],[121500,-2545],[121800,-2481],[122100,-943],[122400,-429],[122700,-512],[123000,-631],[123300,-631],[123600,-504],[123900,-455],[124200,-477],[124500,-476],[124800,-481],[125100,-552],[125400,-629],[125700,-562],[126000,-413],[126300,-469],[126600,-469],[126900,-448],[127200,-495],[127500,-527],[127800,-615],[128100,-69],[128400,0],[128700,0],[129000,-419],[129300,-511],[129600,-498],[129900,-65],[130200,0],[130500,0],[130800,0],[131100,0],[131400,0],[131700,-16],[132000,0],[132300,0],[132600,0],[132900,0],[133200,0],[133500,0],[133800,0],[134100,0],[134400,0],[134700,0],[135000,0],[135300,0],[135600,0],[135900,0],[136200,0],[136500,0],[136800,0],[137100,0],[137400,0],[137700,0],[138000,0],[138300,0],[138600,0],[138900,0],[139200,-22],[139500,0],[139800,0],[140100,0],[140400,0],[140700,0],[141000,0],[141300,0],[141600,0],[141900,0],[142200,0],[142500,0],[142800,0],[143100,0],[143400,0],[143700,0],[144000,0],[144300,0],[144600,0],[144900,0],[145200,0],[145500,0],[145800,0],[146100,0],[146400,0],[146700,0],[147000,0],[147300,0],[147600,0],[147900,0],[148200,0],[148500,0],[148800,0],[149100,0],[149400,0],[149700,0],[150000,0],[150300,0],[150600,0],[150900,0],[151200,0],[151500,0],[151800,0],[152100,0],[152400,0],[152700,0],[153000,0],[153300,0],[153600,0],[153900,0],[154200,0],[154500,0],[154800,0],[155100,0],[155400,0],[155700,0],[156000,0],[156300,0],[156600,0],[156900,194],[157200,612],[157500,1582],[157800,161],[158100,0],[158400,0],[158700,0],[159000,0],[159300,0],[159600,-967],[159900,-804],[160200,0],[160500,0],[160800,0],[161100,0],[161400,-960],[161700,-896],[162000,0],[162300,-1051],[162600,-895],[162900,0],[163200,-1060],[163500,-875],[163800,0],[164100,0],[164400,0],[164700,0],[165000,0],[165300,-296],[165600,-1267],[165900,-148],[166200,0],[166500,73],[166800,0],[167100,0],[167400,0],[167700,0],[168000,0],[168300,0],[168600,0],[168900,0],[169200,0],[169500,0],[169800,0],[170100,0],[170400,0],[170700,0],[171000,0],[171300,0],[171600,0],[171900,0],[172200,0],[172500,0],[172800,0],[173100,0],[173400,0],[173700,0],[174000,122],[174300,0],[174600,0],[174900,-1032],[175200,-327],[175500,574],[175800,623],[176100,721],[176400,905],[176700,1053],[177000,828],[177300,644],[177600,455],[177900,191],[178200,195],[178500,195],[178800,341],[179100,458],[179400,796],[179700,679],[180000,657],[180300,699],[180600,1014],[180900,936],[181200,1036],[181500,952],[181800,1038],[182100,879],[182400,811],[182700,765],[183000,866],[183300,673],[183600,694],[183900,688],[184200,830],[184500,521],[184800,631],[185100,607],[185400,595],[185700,582],[186000,396],[186300,576],[186600,2684],[186900,3659],[187200,3662],[187500,1092],[187800,1051],[188100,959],[188400,789],[188700,868],[189000,868],[189300,889],[189600,905],[189900,866],[190200,703],[190500,451],[190800,361],[191100,242],[191400,101],[191700,17],[192000,43],[192300,0],[192600,0],[192900,0],[193200,0],[193500,-188],[193800,-28],[194100,0],[194400,0],[194700,0],[195000,0],[195300,0],[195600,-673],[195900,-1767],[196200,-2200],[196500,-994],[196800,0],[197100,0],[197400,-2018],[197700,-2363],[198000,-2400],[198300,-288],[198600,0],[198900,0],[199200,0],[199500,0],[199800,0],[200100,0],[200400,0],[200700,0],[201000,-2047],[201300,-2143],[201600,-1892],[201900,-1532],[202200,-1475],[202500,-1606],[202800,-1624],[203100,-1604],[203400,-1626],[203700,-1476],[204000,-3080],[204300,-2367],[204600,-1607],[204900,-2270],[205200,-2961],[205500,-348],[205800,0],[206100,0],[206400,0],[206700,0],[207000,0],[207300,0],[207600,0],[207900,0],[208200,0],[208500,0],[208800,0],[209100,0],[209400,0],[209700,0],[210000,0],[210300,0],[210600,0],[210900,0],[211200,0],[211500,0],[211800,0],[212100,0],[212400,0],[212700,0],[213000,0],[213300,0],[213600,0],[213900,0],[214200,0],[214500,0],[214800,0],[215100,0],[215400,0],[215700,0],[216000,0],[216300,0],[216600,0],[216900,0],[217200,0],[217500,0],[217800,0],[218100,0],[218400,0],[218700,0],[219000,0],[219300,0],[219600,0],[219900,0],[220200,0],[220500,0],[220800,0],[221100,0],[221400,0],[221700,0],[222000,0],[222300,0],[222600,3170],[222900,3593],[223200,3553],[223500,3124],[223800,3234],[224100,3234],[224400,3510],[224700,3717],[225000,3724],[225300,3868],[225600,3859],[225900,3831],[226200,3823],[226500,3733],[226800,3634],[227100,3753],[227400,3870],[227700,3874],[228000,3841],[228300,3327],[228600,3376],[228900,3583],[229200,3529],[229500,3525],[229800,59],[230100,0],[230400,0],[230700,0],[231000,0],[231300,0],[231600,0],[231900,0],[232200,0],[232500,0],[232800,0],[233100,0],[233400,0],[233700,0],[234000,0],[234300,20],[234600,0],[234900,0],[235200,0],[235500,0],[235800,0],[236100,0],[236400,0],[236700,0],[237000,10],[237300,0],[237600,0],[237900,0],[238200,0],[238500,0],[238800,0],[239100,0],[239400,0],[239700,0],[240000,0],[240300,0],[240600,17],[240900,0],[241200,0],[241500,0],[241800,0],[242100,0],[242400,0],[242700,0],[243000,0],[243300,0],[243600,0],[243900,0],[244200,18],[244500,0],[244800,0],[245100,4],[245400,0],[245700,0],[246000,-1129],[246300,-1340],[246600,-1348],[246900,-1294],[247200,-1288],[247500,-1324],[247800,-1332],[248100,-1317],[248400,-1242],[248700,-4090],[249000,-4447],[249300,-4426],[249600,-4403],[249900,-4381],[250200,-4363],[250500,-4349],[250800,-4335],[251100,-4321],[251400,-4304],[251700,-4283],[252000,-4257],[252300,-4226],[252600,-4190],[252900,-4149],[253200,-2416],[253500,-2013],[253800,-2013],[254100,-389],[254400,110],[254700,818],[255000,853],[255300,478],[255600,730],[255900,-946],[256200,-1518],[256500,-1518],[256800,1973],[257100,2486],[257400,1683],[257700,2160],[258000,2210],[258300,3273],[258600,-1512]],"pv_w":[[-300,-1842],[0,-2889],[300,-4217],[600,-6927],[900,-3110],[1200,-1303],[1500,-2021],[1800,-2103],[2100,-1310],[2400,-2454],[2700,-1368],[3000,-1338],[3300,-1242],[3600,-2367],[3900,-3055],[4200,-1355],[4500,-1670],[4800,-5102],[5100,-1868],[5400,-2521],[5700,-3218],[6000,-3551],[6300,-1892],[6600,-1857],[6900,-1370],[7200,-1370],[7500,-1134],[7800,-1740],[8100,-3605],[8400,-2842],[8700,-1679],[9000,-1004],[9300,-859],[9600,-1000],[9900,-1273],[10200,-1459],[10500,-2333],[10800,-6772],[11100,-7576],[11400,-6833],[11700,-6932],[12000,-5719],[12300,-5947],[12600,-4820],[12900,-3038],[13200,-824],[13500,-499],[13800,-641],[14100,-1320],[14400,-1377],[14700,-1062],[15000,-814],[15300,-951],[15600,-2578],[15900,-4799],[16200,-6082],[16500,-3981],[16800,-2921],[17100,-4498],[17400,-4684],[17700,-3877],[18000,-4684],[18300,-2272],[18600,-600],[18900,-661],[19200,-997],[19500,-1662],[19800,-1405],[20100,-1177],[20400,-857],[20700,-641],[21000,-521],[21300,-387],[21600,-285],[21900,-282],[22200,-297],[22500,-375],[22800,-901],[23100,-1215],[23400,-1236],[23700,-1380],[24000,-1174],[24300,-789],[24600,-511],[24900,-373],[25200,-284],[25500,-225],[25800,-186],[26100,-164],[26400,-130],[26700,-98],[27000,-66],[27300,-35],[27600,0],[27900,0],[28200,0],[28500,0],[28800,0],[29100,0],[29400,0],[29700,0],[30000,0],[30300,0],[30600,0],[30900,0],[31200,0],[31500,0],[31800,0],[32100,0],[32400,0],[32700,0],[33000,0],[33300,0],[33600,0],[33900,0],[34200,0],[34500,0],[34800,0],[35100,0],[35400,0],[35700,0],[36000,0],[36300,0],[36600,0],[36900,0],[37200,0],[37500,0],[37800,0],[38100,0],[38400,0],[38700,0],[39000,0],[39300,0],[39600,0],[39900,0],[40200,0],[40500,0],[40800,0],[41100,0],[41400,0],[41700,0],[42000,0],[42300,0],[42600,0],[42900,0],[43200,0],[43500,0],[43800,0],[44100,0],[44400,0],[44700,0],[45000,0],[45300,0],[45600,0],[45900,0],[46200,0],[46500,0],[46800,0],[47100,0],[47400,0],[47700,0],[48000,0],[48300,0],[48600,0],[48900,0],[49200,0],[49500,0],[49800,0],[50100,0],[50400,0],[50700,0],[51000,0],[51300,0],[51600,0],[51900,0],[52200,0],[52500,0],[52800,0],[53100,0],[53400,0],[53700,0],[54000,0],[54300,0],[54600,0],[54900,0],[55200,0],[55500,0],[55800,0],[56100,0],[56400,0],[56700,0],[57000,0],[57300,0],[57600,0],[57900,0],[58200,0],[58500,0],[58800,0],[59100,0],[59400,0],[59700,0],[60000,0],[60300,0],[60600,0],[60900,0],[61200,0],[61500,0],[61800,0],[62100,0],[62400,0],[62700,0],[63000,0],[63300,0],[63600,0],[63900,0],[64200,0],[64500,0],[64800,0],[65100,0],[65400,0],[65700,0],[66000,0],[66300,0],[66600,0],[66900,0],[67200,0],[67500,0],[67800,0],[68100,0],[68400,0],[68700,0],[69000,0],[69300,0],[69600,0],[69900,0],[70200,0],[70500,0],[70800,0],[71100,0],[71400,0],[71700,0],[72000,0],[72300,0],[72600,0],[72900,0],[73200,0],[73500,0],[73800,0],[74100,0],[74400,0],[74700,0],[75000,0],[75300,0],[75600,0],[75900,0],[76200,0],[76500,0],[76800,0],[77100,0],[77400,0],[77700,-59],[78000,-228],[78300,-239],[78600,-221],[78900,-250],[79200,-337],[79500,-312],[79800,-465],[80100,-644],[80400,-835],[80700,-574],[81000,-262],[81300,-3007],[81600,-4164],[81900,-4083],[82200,-1972],[82500,-1490],[82800,-1115],[83100,-1162],[83400,-1345],[83700,-1715],[84000,-2040],[84300,-1772],[84600,-1449],[84900,-1321],[85200,-1002],[85500,-1086],[85800,-1159],[86100,-1336],[86400,-1318],[86700,-1089],[87000,-1007],[87300,-1119],[87600,-1381],[87900,-3947],[88200,-6122],[88500,-2853],[88800,-2523],[89100,-2164],[89400,-3671],[89700,-3210],[90000,-1824],[90300,-1238],[90600,-1043],[90900,-2163],[91200,-1467],[91500,-1642],[91800,-1701],[92100,-3429],[92400,-1205],[92700,-1223],[93000,-1754],[93300,-2021],[93600,-1274],[93900,-1679],[94200,-1942],[94500,-1413],[94800,-1278],[95100,-1679],[95400,-1960],[95700,-1689],[96000,-1526],[96300,-2058],[96600,-2691],[96900,-2045],[97200,-1407],[97500,-1197],[97800,-1273],[98100,-1391],[98400,-1535],[98700,-1721],[99000,-1631],[99300,-1186],[99600,-1112],[99900,-1118],[100200,-1355],[100500,-1338],[100800,-1271],[101100,-1290],[101400,-1136],[101700,-917],[102000,-862],[102300,-838],[102600,-1062],[102900,-1174],[103200,-1102],[103500,-966],[103800,-1227],[104100,-929],[104400,-1086],[104700,-893],[105000,-555],[105300,-384],[105600,-446],[105900,-819],[106200,-694],[106500,-597],[106800,-642],[107100,-421],[107400,-294],[107700,-435],[108000,-698],[108300,-641],[108600,-542],[108900,-481],[109200,-434],[109500,-411],[109800,-354],[110100,-297],[110400,-275],[110700,-213],[111000,-159],[111300,-114],[111600,-80],[111900,-70],[112200,-45],[112500,-33],[112800,0],[113100,0],[113400,0],[113700,0],[114000,0],[114300,0],[114600,0],[114900,0],[115200,0],[115500,0],[115800,0],[116100,0],[116400,0],[116700,0],[117000,0],[117300,0],[117600,0],[117900,0],[118200,0],[118500,0],[118800,0],[119100,0],[119400,0],[119700,0],[120000,0],[120300,0],[120600,0],[120900,0],[121200,0],[121500,0],[121800,0],[122100,0],[122400,0],[122700,0],[123000,0],[123300,0],[123600,0],[123900,0],[124200,0],[124500,0],[124800,0],[125100,0],[125400,0],[125700,0],[126000,0],[126300,0],[126600,0],[126900,0],[127200,0],[127500,0],[127800,0],[128100,0],[128400,0],[128700,0],[129000,0],[129300,0],[129600,0],[129900,0],[130200,0],[130500,0],[130800,0],[131100,0],[131400,0],[131700,0],[132000,0],[132300,0],[132600,0],[132900,0],[133200,0],[133500,0],[133800,0],[134100,0],[134400,0],[134700,0],[135000,0],[135300,0],[135600,0],[135900,0],[136200,0],[136500,0],[136800,0],[137100,0],[137400,0],[137700,0],[138000,0],[138300,0],[138600,0],[138900,0],[139200,0],[139500,0],[139800,0],[140100,0],[140400,0],[140700,0],[141000,0],[141300,0],[141600,0],[141900,0],[142200,0],[142500,0],[142800,0],[143100,0],[143400,0],[143700,0],[144000,0],[144300,0],[144600,0],[144900,0],[145200,0],[145500,0],[145800,0],[146100,0],[146400,0],[146700,0],[147000,0],[147300,0],[147600,0],[147900,0],[148200,0],[148500,0],[148800,0],[149100,0],[149400,0],[149700,0],[150000,0],[150300,0],[150600,0],[150900,0],[151200,0],[151500,0],[151800,0],[152100,0],[152400,0],[152700,0],[153000,0],[153300,0],[153600,0],[153900,0],[154200,0],[154500,0],[154800,0],[155100,0],[155400,0],[155700,0],[156000,0],[156300,0],[156600,0],[156900,0],[157200,0],[157500,0],[157800,0],[158100,0],[158400,0],[158700,0],[159000,0],[159300,0],[159600,0],[159900,0],[160200,0],[160500,0],[160800,0],[161100,0],[161400,0],[161700,0],[162000,0],[162300,0],[162600,0],[162900,0],[163200,-33],[163500,-166],[163800,-346],[164100,-618],[164400,-859],[164700,-1397],[165000,-1776],[165300,-2024],[165600,-2247],[165900,-2471],[166200,-2749],[166500,-2975],[166800,-3244],[167100,-3442],[167400,-3629],[167700,-3815],[168000,-4002],[168300,-4167],[168600,-4330],[168900,-4491],[169200,-4651],[169500,-4805],[169800,-4950],[170100,-5106],[170400,-5254],[170700,-5411],[171000,-5465],[171300,-5015],[171600,-4196],[171900,-2489],[172200,-2139],[172500,-2276],[172800,-2440],[173100,-2695],[173400,-2807],[173700,-3034],[174000,-2046],[174300,-1856],[174600,-1767],[174900,-1516],[175200,-1171],[175500,-1082],[175800,-1132],[176100,-1232],[176400,-1400],[176700,-1453],[177000,-1261],[177300,-1093],[177600,-1049],[177900,-1055],[178200,-1019],[178500,-996],[178800,-1125],[179100,-1218],[179400,-1465],[179700,-1419],[180000,-1418],[180300,-1456],[180600,-1547],[180900,-1523],[181200,-1585],[181500,-1450],[181800,-1420],[182100,-1338],[182400,-1286],[182700,-1243],[183000,-1265],[183300,-1260],[183600,-1279],[183900,-1239],[184200,-1227],[184500,-1312],[184800,-1397],[185100,-1352],[185400,-1251],[185700,-1364],[186000,-1268],[186300,-1397],[186600,-1427],[186900,-1373],[187200,-1503],[187500,-1538],[187800,-1609],[188100,-1407],[188400,-1471],[188700,-1579],[189000,-1731],[189300,-1628],[189600,-1441],[189900,-1304],[190200,-1157],[190500,-928],[190800,-768],[191100,-773],[191400,-786],[191700,-631],[192000,-594],[192300,-580],[192600,-635],[192900,-541],[193200,-441],[193500,-425],[193800,-432],[194100,-452],[194400,-369],[194700,-106],[195000,-87],[195300,-80],[195600,-186],[195900,-341],[196200,-351],[196500,-358],[196800,-318],[197100,-283],[197400,-227],[197700,-173],[198000,-143],[198300,-103],[198600,-12],[198900,0],[199200,0],[199500,0],[199800,0],[200100,0],[200400,0],[200700,0],[201000,0],[201300,0],[201600,0],[201900,0],[202200,0],[202500,0],[202800,0],[203100,0],[203400,0],[203700,0],[204000,0],[204300,0],[204600,0],[204900,0],[205200,0],[205500,0],[205800,0],[206100,0],[206400,0],[206700,0],[207000,0],[207300,0],[207600,0],[207900,0],[208200,0],[208500,0],[208800,0],[209100,0],[209400,0],[209700,0],[210000,0],[210300,0],[210600,0],[210900,0],[211200,0],[211500,0],[211800,0],[212100,0],[212400,0],[212700,0],[213000,0],[213300,0],[213600,0],[213900,0],[214200,0],[214500,0],[214800,0],[215100,0],[215400,0],[215700,0],[216000,0],[216300,0],[216600,0],[216900,0],[217200,0],[217500,0],[217800,0],[218100,0],[218400,0],[218700,0],[219000,0],[219300,0],[219600,0],[219900,0],[220200,0],[220500,0],[220800,0],[221100,0],[221400,0],[221700,0],[222000,0],[222300,0],[222600,0],[222900,0],[223200,0],[223500,0],[223800,0],[224100,0],[224400,0],[224700,0],[225000,0],[225300,0],[225600,0],[225900,0],[226200,0],[226500,0],[226800,0],[227100,0],[227400,0],[227700,0],[228000,0],[228300,0],[228600,0],[228900,0],[229200,0],[229500,0],[229800,0],[230100,0],[230400,0],[230700,0],[231000,0],[231300,0],[231600,0],[231900,0],[232200,0],[232500,0],[232800,0],[233100,0],[233400,0],[233700,0],[234000,0],[234300,0],[234600,0],[234900,0],[235200,0],[235500,0],[235800,0],[236100,0],[236400,0],[236700,0],[237000,0],[237300,0],[237600,0],[237900,0],[238200,0],[238500,0],[238800,0],[239100,0],[239400,0],[239700,0],[240000,0],[240300,0],[240600,0],[240900,0],[241200,0],[241500,0],[241800,0],[242100,0],[242400,0],[242700,0],[243000,0],[243300,0],[243600,0],[243900,0],[244200,0],[244500,0],[244800,0],[245100,-6],[245400,0],[245700,0],[246000,0],[246300,0],[246600,0],[246900,-1],[247200,0],[247500,0],[247800,0],[248100,0],[248400,0],[248700,0],[249000,0],[249300,0],[249600,-26],[249900,-64],[250200,-158],[250500,-295],[250800,-428],[251100,-327],[251400,-191],[251700,-107],[252000,-62],[252300,-39],[252600,-4],[252900,-84],[253200,-86],[253500,-79],[253800,-80],[254100,-299],[254400,-958],[254700,-1882],[255000,-3410],[255300,-2715],[255600,-3464],[255900,-2371],[256200,-4486],[256500,-4619],[256800,-4802],[257100,-4955],[257400,-3902],[257700,-4411],[258000,-4423],[258300,-5487],[258600,-5974]],"meter_w":[[-300,-52],[0,42],[300,-237],[600,-416],[900,78],[1200,12],[1500,17],[1800,10],[2100,-16],[2400,-29],[2700,7],[3000,2],[3300,-14],[3600,-210],[3900,-499],[4200,35],[4500,-32],[4800,-1193],[5100,83],[5400,-27],[5700,-45],[6000,-232],[6300,-171],[6600,109],[6900,85],[7200,-38],[7500,-19],[7800,-45],[8100,-2],[8400,-33],[8700,21],[9000,264],[9300,55],[9600,1],[9900,35],[10200,10],[10500,-60],[10800,-672],[11100,-592],[11400,-84],[11700,-104],[12000,-26],[12300,-352],[12600,-326],[12900,47],[13200,10],[13500,9],[13800,3791],[14100,4332],[14400,4290],[14700,1867],[15000,1482],[15300,1259],[15600,10],[15900,-700],[16200,-2482],[16500,-2455],[16800,-1129],[17100,-3415],[17400,-3849],[17700,-2836],[18000,-3252],[18300,-888],[18600,728],[18900,701],[19200,359],[19500,-299],[19800,70],[20100,191],[20400,-21],[20700,122],[21000,1564],[21300,65],[21600,3562],[21900,2163],[22200,1956],[22500,2214],[22800,1017],[23100,1524],[23400,3600],[23700,2331],[24000,2326],[24300,1192],[24600,1152],[24900,1206],[25200,1238],[25500,-93],[25800,-15],[26100,486],[26400,58],[26700,533],[27000,153],[27300,64],[27600,3],[27900,26],[28200,49],[28500,-1],[28800,73],[29100,-99],[29400,-23],[29700,21],[30000,9],[30300,-9],[30600,-10],[30900,7],[31200,56],[31500,-12],[31800,12],[32100,-101],[32400,55],[32700,5],[33000,-5],[33300,10],[33600,-9],[33900,-49],[34200,-33],[34500,7],[34800,2],[35100,18],[35400,-16],[35700,-24],[36000,-45],[36300,-34],[36600,-23],[36900,10],[37200,-1],[37500,14],[37800,-18],[38100,-6],[38400,-30],[38700,-46],[39000,-50],[39300,4],[39600,2],[39900,8],[40200,-9],[40500,-25],[40800,-33],[41100,-33],[41400,-39],[41700,-35],[42000,-38],[42300,-40],[42600,-31],[42900,-6],[43200,7],[43500,5],[43800,-5],[44100,-7],[44400,-5],[44700,-1],[45000,-5],[45300,298],[45600,417],[45900,406],[46200,54],[46500,11],[46800,-31],[47100,37],[47400,-1],[47700,6],[48000,317],[48300,331],[48600,379],[48900,384],[49200,381],[49500,344],[49800,314],[50100,342],[50400,361],[50700,335],[51000,306],[51300,500],[51600,3933],[51900,4151],[52200,4322],[52500,8685],[52800,8707],[53100,9007],[53400,1006],[53700,549],[54000,572],[54300,638],[54600,627],[54900,680],[55200,626],[55500,573],[55800,544],[56100,7012],[56400,8150],[56700,4493],[57000,1034],[57300,685],[57600,690],[57900,631],[58200,571],[58500,569],[58800,3873],[59100,4322],[59400,4323],[59700,9218],[60000,9114],[60300,3231],[60600,855],[60900,335],[61200,310],[61500,923],[61800,993],[62100,768],[62400,3656],[62700,4332],[63000,4327],[63300,2509],[63600,7922],[63900,8199],[64200,2110],[64500,1838],[64800,1934],[65100,4321],[65400,4322],[65700,4320],[66000,9017],[66300,8995],[66600,8880],[66900,4942],[67200,4330],[67500,4326],[67800,8620],[68100,10412],[68400,10736],[68700,11445],[69000,11897],[69300,11700],[69600,5442],[69900,4301],[70200,3778],[70500,919],[70800,852],[71100,937],[71400,881],[71700,933],[72000,2188],[72300,174],[72600,-11],[72900,58],[73200,146],[73500,-32],[73800,-25],[74100,29],[74400,0],[74700,-9],[75000,-20],[75300,31],[75600,31],[75900,9],[76200,-28],[76500,12],[76800,13],[77100,-32],[77400,22],[77700,-9],[78000,-29],[78300,9],[78600,-17],[78900,-6],[79200,-17],[79500,-33],[79800,-4],[80100,8],[80400,-140],[80700,121],[81000,466],[81300,-2041],[81600,-3222],[81900,-3013],[82200,-1098],[82500,-874],[82800,-442],[83100,-475],[83400,-721],[83700,-1055],[84000,-1258],[84300,-897],[84600,-98],[84900,4],[85200,28],[85500,-21],[85800,18],[86100,-21],[86400,29],[86700,-16],[87000,19],[87300,-42],[87600,1189],[87900,415],[88200,-165],[88500,220],[88800,64],[89100,5],[89400,-289],[89700,81],[90000,522],[90300,1106],[90600,83],[90900,-37],[91200,22],[91500,-9],[91800,32],[92100,-72],[92400,12],[92700,-31],[93000,-28],[93300,13],[93600,4],[93900,-42],[94200,16],[94500,4],[94800,14],[95100,-52],[95400,-24],[95700,14],[96000,-15],[96300,-41],[96600,3],[96900,29],[97200,33],[97500,-36],[97800,6],[98100,-20],[98400,-22],[98700,21],[99000,-4],[99300,9],[99600,-1],[99900,-45],[100200,1596],[100500,1466],[100800,248],[101100,-599],[101400,-463],[101700,-289],[102000,-276],[102300,-241],[102600,-484],[102900,-589],[103200,-447],[103500,-353],[103800,-553],[104100,-221],[104400,-414],[104700,-204],[105000,77],[105300,235],[105600,110],[105900,-121],[106200,-12],[106500,-4],[106800,69],[107100,340],[107400,365],[107700,201],[108000,-24],[108300,18],[108600,123],[108900,806],[109200,1550],[109500,3518],[109800,943],[110100,107],[110400,24],[110700,-4],[111000,1139],[111300,437],[111600,509],[111900,56],[112200,-19],[112500,36],[112800,3],[113100,-4],[113400,-1],[113700,-17],[114000,-12],[114300,10],[114600,-8],[114900,21],[115200,-9],[115500,67],[115800,-97],[116100,-10],[116400,85],[116700,-8],[117000,-70],[117300,-16],[117600,8],[117900,71],[118200,-12],[118500,-73],[118800,-8],[119100,-9],[119400,36],[119700,-9],[120000,-9],[120300,5],[120600,26],[120900,4],[121200,51],[121500,-35],[121800,-21],[122100,-68],[122400,-11],[122700,27],[123000,3],[123300,6],[123600,-25],[123900,19],[124200,11],[124500,-2],[124800,-20],[125100,22],[125400,-1],[125700,-11],[126000,17],[126300,17],[126600,22],[126900,16],[127200,-1],[127500,24],[127800,-13],[128100,333],[128400,413],[128700,434],[129000,68],[129300,-37],[129600,19],[129900,471],[130200,488],[130500,388],[130800,546],[131100,788],[131400,750],[131700,657],[132000,748],[132300,701],[132600,701],[132900,685],[133200,452],[133500,507],[133800,463],[134100,455],[134400,447],[134700,445],[135000,527],[135300,628],[135600,593],[135900,465],[136200,369],[136500,414],[136800,483],[137100,521],[137400,434],[137700,488],[138000,536],[138300,571],[138600,577],[138900,829],[139200,726],[139500,736],[139800,604],[140100,672],[140400,697],[140700,801],[141000,493],[141300,522],[141600,487],[141900,443],[142200,360],[142500,407],[142800,450],[143100,518],[143400,427],[143700,516],[144000,510],[144300,467],[144600,402],[144900,390],[145200,599],[145500,865],[145800,749],[146100,687],[146400,695],[146700,747],[147000,698],[147300,642],[147600,744],[147900,528],[148200,491],[148500,552],[148800,1048],[149100,1013],[149400,1081],[149700,1006],[150000,1091],[150300,1135],[150600,1185],[150900,710],[151200,411],[151500,767],[151800,723],[152100,657],[152400,775],[152700,806],[153000,781],[153300,715],[153600,393],[153900,425],[154200,442],[154500,445],[154800,504],[155100,565],[155400,567],[155700,487],[156000,398],[156300,489],[156600,686],[156900,1361],[157200,1642],[157500,2672],[157800,1263],[158100,1108],[158400,1722],[158700,1227],[159000,990],[159300,1007],[159600,123],[159900,188],[160200,1077],[160500,1276],[160800,1158],[161100,1114],[161400,126],[161700,261],[162000,1155],[162300,142],[162600,286],[162900,1266],[163200,129],[163500,188],[163800,262],[164100,-31],[164400,-255],[164700,-706],[165000,-1008],[165300,-1625],[165600,-2807],[165900,-2057],[166200,-2214],[166500,-2437],[166800,-2764],[167100,-2909],[167400,-2999],[167700,-3161],[168000,-3490],[168300,-3650],[168600,-3804],[168900,-3938],[169200,-4192],[169500,-4282],[169800,-4339],[170100,-4417],[170400,-4267],[170700,-4582],[171000,-4650],[171300,-4201],[171600,-3391],[171900,-1837],[172200,-1425],[172500,-1432],[172800,-1568],[173100,-1992],[173400,-2306],[173700,-2552],[174000,-1457],[174300,-1488],[174600,-1330],[174900,-2032],[175200,-881],[175500,-8],[175800,-3],[176100,-10],[176400,-6],[176700,-7],[177000,6],[177300,-1],[177600,14],[177900,2],[178200,0],[178500,-2],[178800,-8],[179100,-8],[179400,-9],[179700,3],[180000,-5],[180300,-9],[180600,-5],[180900,-6],[181200,-3],[181500,-5],[181800,-2],[182100,0],[182400,-1],[182700,-7],[183000,6],[183300,-5],[183600,-3],[183900,-12],[184200,-5],[184500,8],[184800,-9],[185100,-2],[185400,-6],[185700,5],[186000,-5],[186300,-11],[186600,1908],[186900,2822],[187200,2703],[187500,-45],[187800,-127],[188100,24],[188400,-150],[188700,-202],[189000,-327],[189300,-197],[189600,-132],[189900,12],[190200,14],[190500,2],[190800,-3],[191100,-4],[191400,-16],[191700,-10],[192000,-6],[192300,-27],[192600,-46],[192900,-14],[193200,32],[193500,19],[193800,204],[194100,400],[194400,582],[194700,843],[195000,858],[195300,724],[195600,45],[195900,58],[196200,5],[196500,1015],[196800,2061],[197100,2181],[197400,353],[197700,-20],[198000,-18],[198300,2135],[198600,2514],[198900,4799],[199200,3526],[199500,2693],[199800,3408],[200100,3461],[200400,2656],[200700,2392],[201000,241],[201300,1],[201600,-3],[201900,-18],[202200,14],[202500,-21],[202800,-15],[203100,17],[203400,-5],[203700,-16],[204000,75],[204300,-46],[204600,17],[204900,65],[205200,4],[205500,919],[205800,828],[206100,845],[206400,2085],[206700,2475],[207000,809],[207300,687],[207600,774],[207900,1059],[208200,967],[208500,963],[208800,883],[209100,730],[209400,540],[209700,2076],[210000,2371],[210300,1398],[210600,438],[210900,396],[211200,579],[211500,408],[211800,399],[212100,347],[212400,360],[212700,402],[213000,400],[213300,418],[213600,707],[213900,613],[214200,578],[214500,575],[214800,582],[215100,597],[215400,659],[215700,701],[216000,712],[216300,634],[216600,495],[216900,309],[217200,310],[217500,309],[217800,306],[218100,408],[218400,436],[218700,408],[219000,422],[219300,483],[219600,317],[219900,542],[220200,626],[220500,609],[220800,690],[221100,805],[221400,756],[221700,629],[222000,583],[222300,585],[222600,3862],[222900,4320],[223200,4318],[223500,3900],[223800,4014],[224100,4015],[224400,4284],[224700,4186],[225000,4175],[225300,4320],[225600,4337],[225900,4332],[226200,4330],[226500,4324],[226800,4319],[227100,4317],[227400,4322],[227700,4324],[228000,4328],[228300,4309],[228600,4298],[228900,4306],[229200,4305],[229500,4304],[229800,718],[230100,657],[230400,764],[230700,624],[231000,823],[231300,868],[231600,838],[231900,919],[232200,1086],[232500,957],[232800,782],[233100,733],[233400,737],[233700,787],[234000,927],[234300,1039],[234600,940],[234900,1002],[235200,1025],[235500,957],[235800,317],[236100,309],[236400,420],[236700,531],[237000,364],[237300,313],[237600,389],[237900,458],[238200,554],[238500,879],[238800,609],[239100,594],[239400,652],[239700,645],[240000,543],[240300,530],[240600,639],[240900,527],[241200,523],[241500,442],[241800,442],[242100,495],[242400,490],[242700,784],[243000,1047],[243300,1085],[243600,1061],[243900,1090],[244200,1171],[244500,1165],[244800,1993],[245100,1462],[245400,1290],[245700,1192],[246000,117],[246300,-19],[246600,-32],[246900,18],[247200,-35],[247500,0],[247800,-3],[248100,5],[248400,-5],[248700,-2664],[249000,-2951],[249300,-2942],[249600,-2799],[249900,-2830],[250200,-3420],[250500,-3749],[250800,-3800],[251100,-3332],[251400,-2978],[251700,-2825],[252000,-2626],[252300,-2560],[252600,-2620],[252900,-2963],[253200,-1700],[253500,-1393],[253800,-1446],[254100,-42],[254400,-131],[254700,-330],[255000,-1786],[255300,-1460],[255600,-2009],[255900,-2617],[256200,-5170],[256500,-5262],[256800,-2093],[257100,-1672],[257400,-1450],[257700,-1500],[258000,-1415],[258300,-1366],[258600,-6610]]},"t0_ms":1791705900000} \ No newline at end of file diff --git a/appleApp/FTWUITests/HistoryTour.swift b/appleApp/FTWUITests/HistoryTour.swift new file mode 100644 index 0000000..9208a96 --- /dev/null +++ b/appleApp/FTWUITests/HistoryTour.swift @@ -0,0 +1,73 @@ +import XCTest + +/// A node from Live to Today: a finger on the live trace, then the same +/// finger on Today, the chart small, a second series added by its chip, +/// then the chart grown to the whole screen and scrubbed there. For +/// screenshots and recordings. Skipped unless asked for: +/// +/// TEST_RUNNER_FTW_HISTORY=1 xcodebuild test … -only-testing:FTWUITests/HistoryTour +final class HistoryTour: XCTestCase { + @MainActor + func testCompactThenEnlarged() throws { + let env = ProcessInfo.processInfo.environment + try XCTSkipUnless(env["FTW_HISTORY"] == "1", "Set TEST_RUNNER_FTW_HISTORY=1 to drive the history.") + let app = XCUIApplication() + app.launchArguments = ["-FTWDemo", "YES", "-FTWNode", env["FTW_NODE"] ?? "battery", "-FTWPeriod", "live", "-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launch() + // Live first, with a finger on it, then Today in the same material. + let live = app.descendants(matching: .any)["node.live.chart"].firstMatch + XCTAssertTrue(live.waitForExistence(timeout: 15)) + Thread.sleep(forTimeInterval: 8) + shoot(app, "live") + drag(live) + app.segmentedControls["node.period"].buttons["Today"].tap() + let chart = app.descendants(matching: .any)["node.period.chart"].firstMatch + XCTAssertTrue(chart.waitForExistence(timeout: 15)) + Thread.sleep(forTimeInterval: 2) + shoot(app, "compact") + drag(chart) + // A second series, then a third, by their chips. + let solar = app.buttons["chart.series.solar"] + XCTAssertTrue(solar.waitForExistence(timeout: 5)) + solar.tap() + Thread.sleep(forTimeInterval: 1.2) + shoot(app, "compact-two") + app.buttons["chart.series.grid"].tap() + Thread.sleep(forTimeInterval: 1.2) + // A finger across the small chart. + drag(chart) + // Grown to the whole screen out of the small one. + app.buttons["node.chart.enlarge"].tap() + let close = app.buttons["node.chart.close"] + XCTAssertTrue(close.waitForExistence(timeout: 5)) + Thread.sleep(forTimeInterval: 1.5) + shoot(app, "enlarged") + drag(app.descendants(matching: .any)["node.period.chart"].firstMatch) + app.segmentedControls["node.chart.period"].buttons["48 h"].tap() + Thread.sleep(forTimeInterval: 1.5) + shoot(app, "enlarged-48h") + // Back to one series, then shut. + app.buttons["chart.series.grid"].firstMatch.tap() + app.buttons["chart.series.solar"].firstMatch.tap() + Thread.sleep(forTimeInterval: 1) + close.tap() + Thread.sleep(forTimeInterval: 1.5) + shoot(app, "compact-again") + } + + @MainActor + private func drag(_ element: XCUIElement) { + let from = element.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.5)) + let to = element.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5)) + from.press(forDuration: 0.2, thenDragTo: to, withVelocity: .slow, thenHoldForDuration: 1) + Thread.sleep(forTimeInterval: 0.8) + } + + @MainActor + private func shoot(_ app: XCUIApplication, _ name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } +} diff --git a/appleApp/FTWUITests/NowFitsTests.swift b/appleApp/FTWUITests/NowFitsTests.swift index fb50357..974461d 100644 --- a/appleApp/FTWUITests/NowFitsTests.swift +++ b/appleApp/FTWUITests/NowFitsTests.swift @@ -142,6 +142,8 @@ final class NowFitsTests: XCTestCase { let app = launchDemo() app.buttons["now.node.battery"].tap() XCTAssertTrue(app.navigationBars["Battery"].waitForExistence(timeout: 5)) + // The view opens on the timebase this phone chose last; Live is one tap. + app.segmentedControls["node.period"].buttons["Live"].tap() XCTAssertTrue(app.descendants(matching: .any)["node.live.chart"].waitForExistence(timeout: 5)) let ahead = app.segmentedControls["node.period"].buttons["Ahead"] XCTAssertTrue(ahead.isHittable)