From eed9e01ffa7ef891ef9834524dbdf514d7c1ebec Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 13:10:48 +0200 Subject: [PATCH 1/2] iPhone: history and ahead on the engine, the chart small and growing (WP7) Today and 48 h draw from HistoryPicture: a fixed day with 30-minute slots, 48 h with 60-minute slots, each slot the mean of the box's five-minute means through Steffen, with its band. One stable scale for the chosen series. Holes on their way are hatched with a dashed estimate; holes with no record are a dotted baseline. AS OF marks old data. A tile that lands moves each slot to its new mean. Ahead draws from AheadPicture: plan quarters as ghost bars, forecasts as a dashed line, the price strip under the same slots. Fredrik's note 16: the chart is compact and a tap zooms it to the whole screen. Chips add or take away series, kept per viewer per node; the last one stays and a hidden series changes nothing the others say. The scale re-fits as one spring. Size caps for the largest text move to where each view is placed, so @ScaledMetric sees them; this fixes the price and node heroes too. NodeCharts, ChartMotion and ChartScrub are gone. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 + appleApp/FTW/UI/ChartMotion.swift | 139 ------ appleApp/FTW/UI/ChartScrub.swift | 122 ----- appleApp/FTW/UI/NodeCharts.swift | 441 ------------------ appleApp/FTW/UI/NodeHistory.swift | 369 +++++++++++++++ appleApp/FTW/UI/NodeView.swift | 319 ++++++------- appleApp/FTW/UI/PlanCard.swift | 8 + appleApp/FTW/UI/PriceView.swift | 1 + appleApp/FTWCharts/HistoryCharts.swift | 314 +++++++++++++ appleApp/FTWCharts/ScrubOverlay.swift | 6 +- .../Sources/FTWKit/State/ChartWords.swift | 41 -- .../Sources/FTWKit/State/NodeLive.swift | 25 + .../Sources/FTWKit/State/NodeWords.swift | 5 + .../FTWShared/Charts/AheadPicture.swift | 110 +++++ .../FTWShared/Charts/ChartSeries.swift | 171 +++++-- .../FTWShared/Charts/HistoryPicture.swift | 116 +++++ .../Tests/FTWKitTests/NodeLiveTests.swift | 15 + .../FTWKit/Tests/FTWKitTests/NodeTests.swift | 26 +- .../FTWSharedTests/HistoryRecipeTests.swift | 171 +++++++ .../Resources/sungrow-days.json | 1 + appleApp/FTWUITests/HistoryTour.swift | 64 +++ appleApp/FTWUITests/NowFitsTests.swift | 2 + 22 files changed, 1495 insertions(+), 976 deletions(-) delete mode 100644 appleApp/FTW/UI/ChartMotion.swift delete mode 100644 appleApp/FTW/UI/ChartScrub.swift delete mode 100644 appleApp/FTW/UI/NodeCharts.swift create mode 100644 appleApp/FTW/UI/NodeHistory.swift create mode 100644 appleApp/FTWCharts/HistoryCharts.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/Charts/AheadPicture.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/Charts/HistoryPicture.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/HistoryRecipeTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/Resources/sungrow-days.json create mode 100644 appleApp/FTWUITests/HistoryTour.swift diff --git a/CLAUDE.md b/CLAUDE.md index e9bbba9..e396c41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,6 +117,11 @@ 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. - **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..9e6cd36 --- /dev/null +++ b/appleApp/FTW/UI/NodeHistory.swift @@ -0,0 +1,369 @@ +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 costs a subscript per slot. +@Observable +@MainActor +final class PeriodText { + private(set) var slots: [String] = [] + private(set) var spans: (ClosedRange) -> String = { _ in "" } + + func set(_ slots: [String], spans: @escaping (ClosedRange) -> String) { + self.slots = slots + self.spans = spans + } + + 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 + } +} + +// MARK: - Today and two days + +/// Today on its fixed day, or the last two days, on the engine: 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 with what lies after it dimmed. +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 { + HistorySurface( + picture: picture, + looks: Dictionary(uniqueKeysWithValues: picture.layers.compactMap { l in EnergyNode(rawValue: l.key).map { (l.key, NodeChartLook.look($0)) } }), + frame: frame, + words: ChartLabels(fetching: L("FETCHING…"), asOf: picture.asOfMs.map { NodeWords.format("AS OF %@", AppWords.clock($0, zone: .current)) }, now: NodeWords.text("NOW")), + axis: axis + ) + 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.color(node), + dimsAfter: true, + limit: picture.limit + ), + frame: frame, + model: scrub, + tagSize: tag, + spanTag: { r in own.series.readouts.when[r.lowerBound].components(separatedBy: "–").first! + "–" + (own.series.readouts.when[r.upperBound].components(separatedBy: "–").last ?? "") } + ) + } 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) } + } + + /// The line under the chart for each slot: the node's own mean and range, + /// then each other series' mean, in its own words. A hidden series + /// changes nothing here. + private func words(_ picture: HistoryPicture?) { + guard let picture, let own = picture.layers.first else { return text.set([], spans: { _ in "" }) } + let others = picture.layers.dropFirst().compactMap { l in EnergyNode(rawValue: l.key).map { ($0, l.series) } } + let slots = own.series.readouts.when.indices.map { i -> String in + var parts = [own.series.readouts.when[i], own.series.readouts.what[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 readouts = own.series.readouts + let directions = node.directions != nil + text.set(slots) { r in + let e = readouts.between(r.lowerBound, r.upperBound) + let span = readouts.when[r.lowerBound].components(separatedBy: "–").first! + "–" + (readouts.when[r.upperBound].components(separatedBy: "–").last ?? "") + return span + " · " + (directions ? NodeWords.format("↑ %@ ↓ %@", AppWords.energy(e.inWh), AppWords.energy(e.outWh)) : AppWords.energy(e.inWh)) + } + } +} + +/// 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 + ChartSurface(drawing: AheadDrawing(picture: picture, look: NodeChartLook.look(node), frame: frame, axis: axis, nowWord: NodeWords.text("NOW"), strip: strip)) + .equatable() + 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..d110afb 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) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) .padding(.horizontal, 20) - NodeStatusRow(site: site, node: node) - .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 { @@ -274,7 +316,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 +466,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 +484,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,12 +493,94 @@ 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) + .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) } } } @@ -499,7 +625,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) } @@ -595,12 +721,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 +736,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 +775,7 @@ private struct NodeFooter: View { .foregroundStyle(Theme.ink3) .lineLimit(2) .minimumScaleFactor(0.75) - .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) .accessibilityIdentifier("node.footer") } } @@ -731,110 +857,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 +897,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 { @@ -925,24 +928,4 @@ extension NodeLook { 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..6759b3f --- /dev/null +++ b/appleApp/FTWCharts/HistoryCharts.swift @@ -0,0 +1,314 @@ +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: per series its band of the +/// five-minute means, the chart's own series' body, and a 2.5 pt line +/// through the exact slot means. 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. The past does not glow. 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 moves from its old mean +/// to its new one (`from`, `progress`); a new window cross-fades instead. +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 + var from: HistoryPicture? + var progress: Double = 1 + + /// 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) + } + + func draw(in g: inout GraphicsContext, size: CGSize) { + let plot = frame.plot(in: size) + let px = 1 / max(g.environment.displayScale, 1) + let dark = g.environment.colorScheme == .dark + let primary = picture.layers.first?.key + + // A series added or taken away re-fits the scale: it moves as one + // spring, the new series fades in and the old one out. + let k = min(max(progress, 0), 1) + var shown = picture.domain + if let from, from.domain != picture.domain, k < 1 { + shown = YDomain(lower: from.domain.lower + (picture.domain.lower - from.domain.lower) * k, upper: from.domain.upper + (picture.domain.upper - from.domain.upper) * k) + } + let toShown = Self.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 - 2, width: plot.width, height: plot.height + 4))) + p.translateBy(x: plot.minX, y: plot.minY) + let above = Path(CGRect(x: -10, y: -10, width: plot.width + 20, height: base + 10)) + let below = Path(CGRect(x: -10, y: base, width: plot.width + 20, height: plot.height - base + 10)) + + // 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) + let t = Self.rescale(from.domain, shown, height: plot.height) + var s = p + s.opacity = 1 - k + s.stroke(Path(gone.series.line.cgPath).applying(t), with: .color(look.up), style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + } + } + + // 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: progress) { + 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 } + for (side, color) in [(above, look.up), (below, look.down)] { + var s = p + s.clip(to: side) + s.fill(band, with: .color(color.opacity(dark ? 0.16 : 0.12))) + if own { + let edge = side == above ? CGFloat(0) : plot.height + s.fill(body, with: .linearGradient(Gradient(colors: [color.opacity(0), color.opacity(dark ? 0.24 : 0.16)]), startPoint: CGPoint(x: 0, y: base), endPoint: CGPoint(x: 0, y: edge))) + } + } + // 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])) + for (side, color) in [(above, look.up), (below, look.down)] { + var s = p + s.clip(to: side) + s.stroke(line, with: .color(own ? color : color.opacity(0.85)), style: StrokeStyle(lineWidth: own ? 2.5 : 2, lineCap: .round, lineJoin: .round)) + } + } + + // 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. +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 + + 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 map = YMap(domain: picture.domain, height: plot.height) + + ChartAxes.draw(in: &g, plot: plot, domain: picture.domain, px: px, axis: axis, look: look) + + var p = g + p.translateBy(x: plot.minX, y: plot.minY) + switch picture.kind { + case .plan: + for run in ChartPaths.runs(picture.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: + p.stroke(Path(picture.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 history chart that moves between tiles: when a tile lands in the same +/// window, each slot slides from its old mean to its new one over 0.35 s, +/// critically damped, at the display's rate, and then the chart holds +/// still. Data from the cache starts settled. +struct HistorySurface: View { + let picture: HistoryPicture + let looks: [String: TraceLook] + let frame: ChartFrame + var words = ChartLabels() + var axis: CGFloat = 10 + @State private var old: HistoryPicture? + @State private var changedAt = Date.distantPast + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + static let spring: TimeInterval = 0.35 + + var body: some View { + let moving = old != nil && !reduceMotion + TimelineView(.animation(paused: !moving)) { tick in + let k = LiveTraceSurface.ease(tick.date.timeIntervalSince(changedAt), Self.spring) + ChartSurface(drawing: HistoryDrawing(picture: picture, looks: looks, frame: frame, words: words, axis: axis, from: moving && k < 1 ? old : nil, progress: k)) + .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/ScrubOverlay.swift b/appleApp/FTWCharts/ScrubOverlay.swift index e35c6ed..75e76be 100644 --- a/appleApp/FTWCharts/ScrubOverlay.swift +++ b/appleApp/FTWCharts/ScrubOverlay.swift @@ -22,6 +22,9 @@ struct ScrubTrack: Equatable { /// 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) } @@ -105,7 +108,8 @@ struct ScrubOverlay: View { private func slot(_ x: CGFloat) -> Int? { 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) { 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..5f10a36 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/AheadPicture.swift @@ -0,0 +1,110 @@ +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) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/ChartSeries.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/ChartSeries.swift index 542ec40..3b45b07 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] @@ -216,7 +248,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/HistoryRecipeTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/HistoryRecipeTests.swift new file mode 100644 index 0000000..6171d44 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/HistoryRecipeTests.swift @@ -0,0 +1,171 @@ +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 }) + } + + // 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..0697c74 --- /dev/null +++ b/appleApp/FTWUITests/HistoryTour.swift @@ -0,0 +1,64 @@ +import XCTest + +/// Today on a node: 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", env["FTW_PERIOD"] ?? "today", "-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launch() + let chart = app.descendants(matching: .any)["node.period.chart"].firstMatch + XCTAssertTrue(chart.waitForExistence(timeout: 15)) + Thread.sleep(forTimeInterval: 3) + shoot(app, "compact") + // 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) From 2e4e90f87be6007b0fb03ac60f90d5bf85f75dbe Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 14:06:37 +0200 Subject: [PATCH 2/2] iPhone: every power trace in the live trace's material (WP7) Fredrik on 0.3.5: the live view feels right, so every other chart should feel and look the same. Live, Today and 48 h, small and enlarged, now draw through one place, TraceInk: the same band, body, 2.5 pt line, and glow only while live (on Today, only the slot still filling). Live uses ChartAxes and NodeChartLook like the rest. SlidingSurface gives history and Ahead Live's motion: a change for the same slots slides over 0.25 s, a new scale or a series coming or going springs over 0.35 s, with the same ease. AheadPicture.blended moves a new plan or forecast slot by slot. Under a finger the hero follows on Today and 48 h, as on Live, and the line under the chart says the energy so far or between, from the same functions. The dot takes its side's colour, the span tag gives the average, and nothing dims behind the finger. History's landmarks are the high, the low, now and each end of a run of data, as on Live. The debug finger reads the newest track; HistoryTour starts on Live. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 + appleApp/FTW/UI/NodeHistory.swift | 101 ++++++++++---- appleApp/FTW/UI/NodeView.swift | 44 ++---- appleApp/FTWCharts/HistoryCharts.swift | 130 +++++++++--------- appleApp/FTWCharts/LiveTrace.swift | 96 +++---------- appleApp/FTWCharts/ScrubOverlay.swift | 30 ++-- appleApp/FTWCharts/TraceInk.swift | 86 ++++++++++++ .../FTWShared/Charts/AheadPicture.swift | 48 +++++-- .../FTWShared/Charts/ChartSeries.swift | 7 + .../FTWSharedTests/ChartGeometryTests.swift | 2 +- .../FTWSharedTests/HistoryRecipeTests.swift | 20 +++ appleApp/FTWUITests/HistoryTour.swift | 15 +- 12 files changed, 351 insertions(+), 234 deletions(-) create mode 100644 appleApp/FTWCharts/TraceInk.swift diff --git a/CLAUDE.md b/CLAUDE.md index e396c41..bd704c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,6 +122,12 @@ native, and smooth enough to impress. 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/NodeHistory.swift b/appleApp/FTW/UI/NodeHistory.swift index 9e6cd36..482b5a5 100644 --- a/appleApp/FTW/UI/NodeHistory.swift +++ b/appleApp/FTW/UI/NodeHistory.swift @@ -90,16 +90,29 @@ enum NodeChartLook { // 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 costs a subscript per slot. +/// 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) { + 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? { @@ -107,13 +120,21 @@ final class PeriodText { 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: 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 with what lies after it dimmed. +/// 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 @@ -135,13 +156,12 @@ struct HistoryChartArea: View { ZStack { Color.clear if let picture, let own { - HistorySurface( - picture: picture, - looks: Dictionary(uniqueKeysWithValues: picture.layers.compactMap { l in EnergyNode(rawValue: l.key).map { (l.key, NodeChartLook.look($0)) } }), - frame: frame, - words: ChartLabels(fetching: L("FETCHING…"), asOf: picture.asOfMs.map { NodeWords.format("AS OF %@", AppWords.clock($0, zone: .current)) }, now: NodeWords.text("NOW")), - axis: axis - ) + 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, @@ -150,14 +170,20 @@ struct HistoryChartArea: View { 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.color(node), - dimsAfter: true, + color: NodeChartLook.look(node).up, + downColor: NodeChartLook.look(node).down, limit: picture.limit ), frame: frame, model: scrub, tagSize: tag, - spanTag: { r in own.series.readouts.when[r.lowerBound].components(separatedBy: "–").first! + "–" + (own.series.readouts.when[r.upperBound].components(separatedBy: "–").last ?? "") } + // 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) @@ -204,26 +230,40 @@ struct HistoryChartArea: View { return home.nodes.history.samples(node, fromMs: fromMs).map { ChartSample(startMs: $0.startMs, value: $0.watts, fetching: $0.fetching) } } - /// The line under the chart for each slot: the node's own mean and range, - /// then each other series' mean, in its own words. A hidden series - /// changes nothing here. + /// 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 slots = own.series.readouts.when.indices.map { i -> String in - var parts = [own.series.readouts.when[i], own.series.readouts.what[i]] + 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 readouts = own.series.readouts - let directions = node.directions != nil - text.set(slots) { r in - let e = readouts.between(r.lowerBound, r.upperBound) - let span = readouts.when[r.lowerBound].components(separatedBy: "–").first! + "–" + (readouts.when[r.upperBound].components(separatedBy: "–").last ?? "") - return span + " · " + (directions ? NodeWords.format("↑ %@ ↓ %@", AppWords.energy(e.inWh), AppWords.energy(e.outWh)) : AppWords.energy(e.inWh)) + 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)))) + }) } } @@ -272,8 +312,10 @@ struct AheadChartArea: View { Color.clear if let made { let picture = made.picture, bars = made.bars, prices = made.prices, currency = made.currency, forecast = made.forecast - ChartSurface(drawing: AheadDrawing(picture: picture, look: NodeChartLook.look(node), frame: frame, axis: axis, nowWord: NodeWords.text("NOW"), strip: strip)) - .equatable() + 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, @@ -283,7 +325,8 @@ struct AheadChartArea: View { Readouts.clock(picture.grid.startMs(i)) + (picture.values[i].map { " " + AppWords.power(abs($0)) } ?? "") }, landmarks: [0, max(0, picture.grid.count - 1)], - color: NodeChartLook.color(node), + color: NodeChartLook.look(node).up, + downColor: NodeChartLook.look(node).down, bars: picture.kind == .plan ), frame: frame, diff --git a/appleApp/FTW/UI/NodeView.swift b/appleApp/FTW/UI/NodeView.swift index d110afb..ebcc671 100644 --- a/appleApp/FTW/UI/NodeView.swift +++ b/appleApp/FTW/UI/NodeView.swift @@ -123,7 +123,7 @@ struct NodeView: View { private func page(fill: Bool) -> some View { return VStack(alignment: .leading, spacing: 0) { - NodeHero(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase) + NodeHero(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, text: text) .dynamicTypeSize(...DynamicTypeSize.accessibility1) .padding(.horizontal, 20) HStack(spacing: 8) { @@ -281,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 @@ -295,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 @@ -555,7 +563,7 @@ private struct ChartFullScreen: View { 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) + 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) @@ -601,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 @@ -678,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. @@ -916,16 +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 - } - } -} diff --git a/appleApp/FTWCharts/HistoryCharts.swift b/appleApp/FTWCharts/HistoryCharts.swift index 6759b3f..b6ac58f 100644 --- a/appleApp/FTWCharts/HistoryCharts.swift +++ b/appleApp/FTWCharts/HistoryCharts.swift @@ -11,16 +11,20 @@ struct ChartLabels: Equatable { var now = "NOW" } -/// Today or the last two days, in one Canvas: per series its band of the -/// five-minute means, the chart's own series' body, and a 2.5 pt line -/// through the exact slot means. A hole on its way is a hatch with a dashed +/// 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. The past does not glow. 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. +/// 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 moves from its old mean -/// to its new one (`from`, `progress`); a new window cross-fades instead. +/// 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. @@ -28,50 +32,44 @@ struct HistoryDrawing: ChartDrawing { 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 progress: Double = 1 - - /// 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) - } + 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 dark = g.environment.colorScheme == .dark let primary = picture.layers.first?.key - // A series added or taken away re-fits the scale: it moves as one - // spring, the new series fades in and the old one out. - let k = min(max(progress, 0), 1) - var shown = picture.domain - if let from, from.domain != picture.domain, k < 1 { - shown = YDomain(lower: from.domain.lower + (picture.domain.lower - from.domain.lower) * k, upper: from.domain.upper + (picture.domain.upper - from.domain.upper) * k) - } - let toShown = Self.rescale(picture.domain, shown, height: plot.height) + 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 - 2, width: plot.width, height: plot.height + 4))) + 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: -10, width: plot.width + 20, height: base + 10)) - let below = Path(CGRect(x: -10, y: base, width: plot.width + 20, height: plot.height - base + 10)) + 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) - let t = Self.rescale(from.domain, shown, height: plot.height) var s = p s.opacity = 1 - k - s.stroke(Path(gone.series.line.cgPath).applying(t), with: .color(look.up), style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + 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) } } @@ -82,7 +80,7 @@ struct HistoryDrawing: ChartDrawing { 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: progress) { + 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) @@ -95,14 +93,9 @@ struct HistoryDrawing: ChartDrawing { var p = p // A series just added fades in. if from != nil, before == nil, k < 1 { p.opacity = k } - for (side, color) in [(above, look.up), (below, look.down)] { - var s = p - s.clip(to: side) - s.fill(band, with: .color(color.opacity(dark ? 0.16 : 0.12))) - if own { - let edge = side == above ? CGFloat(0) : plot.height - s.fill(body, with: .linearGradient(Gradient(colors: [color.opacity(0), color.opacity(dark ? 0.24 : 0.16)]), startPoint: CGPoint(x: 0, y: base), endPoint: CGPoint(x: 0, y: edge))) - } + 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. @@ -126,11 +119,7 @@ struct HistoryDrawing: ChartDrawing { 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])) - for (side, color) in [(above, look.up), (below, look.down)] { - var s = p - s.clip(to: side) - s.stroke(line, with: .color(own ? color : color.opacity(0.85)), style: StrokeStyle(lineWidth: own ? 2.5 : 2, lineCap: .round, lineJoin: .round)) - } + 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. @@ -166,7 +155,9 @@ struct HistoryDrawing: ChartDrawing { /// 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. +/// 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 @@ -175,20 +166,26 @@ struct AheadDrawing: ChartDrawing { 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 map = YMap(domain: picture.domain, height: plot.height) + 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) + 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(picture.values, grid: picture.grid, width: plot.width, map: map, scale: scale, maxRadius: 4) { + 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 @@ -202,7 +199,9 @@ struct AheadDrawing: ChartDrawing { p.stroke(cap, with: .color(color.opacity(0.7)), style: StrokeStyle(lineWidth: px, lineCap: .round, lineJoin: .round)) } case .forecast: - p.stroke(Path(picture.line.cgPath), with: .color(look.up.opacity(0.5)), style: StrokeStyle(lineWidth: 1.75, lineCap: .round, lineJoin: .round, dash: [3, 4])) + // 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. @@ -279,27 +278,26 @@ enum ChartAxes { } } -/// A history chart that moves between tiles: when a tile lands in the same -/// window, each slot slides from its old mean to its new one over 0.35 s, -/// critically damped, at the display's rate, and then the chart holds -/// still. Data from the cache starts settled. -struct HistorySurface: View { - let picture: HistoryPicture - let looks: [String: TraceLook] - let frame: ChartFrame - var words = ChartLabels() - var axis: CGFloat = 10 - @State private var old: HistoryPicture? +/// 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 - static let spring: TimeInterval = 0.35 - var body: some View { let moving = old != nil && !reduceMotion TimelineView(.animation(paused: !moving)) { tick in - let k = LiveTraceSurface.ease(tick.date.timeIntervalSince(changedAt), Self.spring) - ChartSurface(drawing: HistoryDrawing(picture: picture, looks: looks, frame: frame, words: words, axis: axis, from: moving && k < 1 ? old : nil, progress: k)) + 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 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 75e76be..1e36f03 100644 --- a/appleApp/FTWCharts/ScrubOverlay.swift +++ b/appleApp/FTWCharts/ScrubOverlay.swift @@ -14,10 +14,10 @@ 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. @@ -54,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 @@ -91,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 } @@ -106,18 +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) 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) { @@ -131,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) } @@ -216,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/FTWShared/Charts/AheadPicture.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/AheadPicture.swift index 5f10a36..74350f0 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/AheadPicture.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/AheadPicture.swift @@ -66,20 +66,7 @@ public struct AheadPicture: Equatable, Sendable { let known = values.compactMap { $0 } let domain = YDomain.stable(low: min(0, known.min() ?? 0), high: max(0, known.max() ?? 0), previous: previous, floor: floor) - var line = ChartPath() - if kind == .forecast { - let map = YMap(domain: domain, height: size.height) - var run: [CGPoint] = [] - for i in 0.. 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 3b45b07..ce4a1b4 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/ChartSeries.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/ChartSeries.swift @@ -232,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) } } 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 index 6171d44..6bac2d7 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/HistoryRecipeTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/HistoryRecipeTests.swift @@ -34,6 +34,9 @@ import Testing // One straight estimate across the hole on its way, from slot 3 to 6. #expect(series.estimate.elements == [.move(series.points[3]!), .line(series.points[6]!)]) #expect(series.points[4] == nil && series.points[12] == nil) + // As on the live trace, a finger feels each end of a run of data. + #expect(series.readouts.landmarks.isSuperset(of: [0, 3, 6, 11, 14, 47])) + #expect(!series.readouts.landmarks.contains(4) && !series.readouts.landmarks.contains(12)) } @Test func slotsAfterNowAreNeitherHolesNorData() { @@ -136,6 +139,23 @@ import Testing #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 { diff --git a/appleApp/FTWUITests/HistoryTour.swift b/appleApp/FTWUITests/HistoryTour.swift index 0697c74..9208a96 100644 --- a/appleApp/FTWUITests/HistoryTour.swift +++ b/appleApp/FTWUITests/HistoryTour.swift @@ -1,6 +1,7 @@ import XCTest -/// Today on a node: the chart small, a second series added by its chip, +/// 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: /// @@ -11,12 +12,20 @@ final class HistoryTour: XCTestCase { 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", env["FTW_PERIOD"] ?? "today", "-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + 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: 3) + 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))