Skip to content
87 changes: 77 additions & 10 deletions appleApp/FTW/UI/NodeHistory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,9 @@ final class PeriodText {
/// 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.
/// below speak as on Live. The rest of today is the ghost of what the box
/// plans or forecasts for each series, so the whole day reads as one
/// picture. A tap opens the chart on the whole screen.
struct HistoryChartArea: View {
let site: SiteModel
let node: EnergyNode
Expand All @@ -143,15 +145,29 @@ struct HistoryChartArea: View {
let period: NodeView.Timebase
let series: [EnergyNode]
let text: PeriodText
var onTap: (() -> Void)?
@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

/// The frame for a scale: the right gutter holds its widest label.
private func frame(_ domain: YDomain?) -> ChartFrame {
ChartFrame(top: tag * 1.35 + 8, bottom: axis * 1.4 + 4, trailing: ChartAxes.gutter(domain, axis: axis))
}

/// The picture and its frame: built for the scale on screen, and again
/// in the rare case the new scale's labels need another gutter.
private func made() -> (frame: ChartFrame, picture: HistoryPicture?) {
let first = frame(shown)
let picture = build(first.plot(in: size).size)
guard let domain = picture?.domain, frame(domain) != first else { return (first, picture) }
let fitted = frame(domain)
return (fitted, build(fitted.plot(in: size).size))
}

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 (frame, picture) = made()
let own = picture?.layers.first
ZStack {
Color.clear
Expand Down Expand Up @@ -183,7 +199,8 @@ struct HistoryChartArea: View {
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))))
}
},
onTap: onTap
)
} else if let problem = home.nodes.history.problem {
Text(problem).font(.footnote).foregroundStyle(Theme.ink3).multilineTextAlignment(.center)
Expand All @@ -205,15 +222,41 @@ struct HistoryChartArea: View {
let now = site.minuteMs + 60_000
let window = period == .today ? HistoryPicture.day(now, clock: clock) : HistoryPicture.twoDays(now)
var inputs: [(key: String, samples: [ChartSample])] = []
var ahead: [String: (kind: AheadPicture.Kind, slots: [AheadPicture.Mark])] = [:]
for n in series {
let samples = Self.samples(n, home: home, fromMs: window.fromMs)
if !samples.isEmpty || n == node { inputs.append((n.rawValue, samples)) }
// Today runs to midnight: the rest of it as the box sees it.
if period == .today, let plan = Self.ahead(n, home: home, nowMs: site.minuteMs) { ahead[n.rawValue] = plan }
}
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)
return HistoryPicture.build(inputs, ahead: ahead, 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)
}

/// What the box plans or forecasts for a node from now on, as Ahead
/// draws it: the battery's and the grid's plan, the car's windows, the
/// sun's and the home's forecast. Nil where it has none.
static func ahead(_ node: EnergyNode, home: HomeModels, nowMs: Double) -> (kind: AheadPicture.Kind, slots: [AheadPicture.Mark])? {
let plan = home.nodes.plan
let marks: [AheadPicture.Mark]
var kind = AheadPicture.Kind.plan
switch node {
case .battery, .grid:
marks = (plan.plan?.slots ?? []).filter { $0.endMs > nowMs }.map { AheadPicture.Mark(startMs: $0.startMs, endMs: $0.endMs, watts: node == .battery ? $0.batteryW : $0.gridW) }
case .solar, .home:
kind = .forecast
marks = plan.forecast.filter { $0.startMs + $0.durationMs > nowMs }.map { AheadPicture.Mark(startMs: $0.startMs, endMs: $0.startMs + $0.durationMs, watts: node == .solar ? $0.pvW : $0.loadW) }
case .car:
marks = home.loadpoints.points.flatMap { lp in
(home.loadpoints.windows[lp.id] ?? lp.planWindows).filter { $0.toMs > nowMs }.map { w in
AheadPicture.Mark(startMs: w.fromMs, endMs: w.toMs, watts: w.peakW ?? (w.energyWh ?? 0) * 3_600_000 / max(1, w.toMs - w.fromMs))
}
}
}
return marks.isEmpty ? nil : (kind, marks)
}

/// One node's five-minute means as the chart reads them. The box keeps
Expand Down Expand Up @@ -301,14 +344,32 @@ struct AheadChartArea: View {
let home: HomeModels
let scrub: ScrubModel
let text: PeriodText
var onTap: (() -> Void)?
@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

private static let strip = AheadDrawing.stripHeight

/// The frame for a scale: the right gutter holds its widest label.
private func frame(_ domain: YDomain?) -> ChartFrame {
ChartFrame(top: tag * 1.35 + 8, bottom: 6 + Self.strip + axis * 1.4 + 4, trailing: ChartAxes.gutter(domain, axis: axis))
}

/// What is drawn and its frame, as on Today: built again only when the
/// new scale's labels need another gutter.
private func fitted() -> (frame: ChartFrame, made: Built?) {
let first = frame(shown)
let made = build(first.plot(in: size).size)
guard let domain = made?.picture.domain, frame(domain) != first else { return (first, made) }
let fitted = frame(domain)
return (fitted, build(fitted.plot(in: size).size))
}

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.8)
let made = build(frame.plot(in: size).size)
let strip = Self.strip
let (frame, made) = fitted()
ZStack {
Color.clear
if let made {
Expand All @@ -333,7 +394,8 @@ struct AheadChartArea: View {
frame: frame,
model: scrub,
tagSize: tag,
spanTag: { r in Readouts.span(picture.grid.startMs(r.lowerBound), picture.grid.endMs(r.upperBound)) }
spanTag: { r in Readouts.span(picture.grid.startMs(r.lowerBound), picture.grid.endMs(r.upperBound)) },
onTap: onTap
)
.onChange(of: picture, initial: true) { _, now in
let slots = (0..<now.grid.count).map { i in
Expand All @@ -350,6 +412,7 @@ struct AheadChartArea: View {
}
.dynamicTypeSize(...DynamicTypeSize.xxLarge)
.onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 }
.onChange(of: made?.picture.domain) { _, now in shown = now }
.accessibilityElement()
.accessibilityLabel(L("Ahead"))
}
Expand Down Expand Up @@ -405,7 +468,11 @@ struct AheadChartArea: View {
nowMs: now,
spanMs: PlanModel.aheadMs,
size: plot,
previous: shown,
floor: node.scaleFloorW(),
// The car's scale is its charger at its most, so it never flips
// with what the plan holds.
top: node == .car ? home.loadpoints.points.compactMap(\.maxChargeW).max() : nil,
clock: ZoneClock.of(.current)
)
return Built(picture: picture, bars: bars, prices: prices, currency: currency, forecast: forecast)
Expand Down
87 changes: 57 additions & 30 deletions appleApp/FTW/UI/NodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ struct NodeView: View {
.navigationBarTitleDisplayMode(.inline)
.onAppear {
models.history.activate()
models.plan.activate(forecast: node == .solar || node == .home)
// Every node: Today draws the sun's and the home's forecast as
// the rest of the day, beside whatever series it shows.
models.plan.activate(forecast: true)
models.mode.activate()
models.sources.activate(node)
home.prices.activate()
Expand All @@ -101,7 +103,7 @@ struct NodeView: View {
}
.onDisappear {
models.history.deactivate()
models.plan.deactivate(forecast: node == .solar || node == .home)
models.plan.deactivate(forecast: true)
models.mode.deactivate()
models.sources.deactivate(node)
home.prices.deactivate()
Expand Down Expand Up @@ -159,11 +161,10 @@ struct NodeView: View {
.accessibilityHidden(!chips)
.fixedSize(horizontal: false, vertical: true)
// 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)
// The chart's own overlay tells a tap from a scrub.
NodeChart(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, series: series, text: text, onTap: { enlarged = true })
.frame(minHeight: 200, idealHeight: 200, maxHeight: chartMax ?? 200)
.padding(.horizontal, 4)
.contentShape(Rectangle())
.simultaneousGesture(TapGesture().onEnded { enlarged = true })
.overlay(alignment: .topTrailing) {
EnlargeButton(scrub: scrub) { enlarged = true }
.dynamicTypeSize(...DynamicTypeSize.xxLarge)
Expand Down Expand Up @@ -246,21 +247,37 @@ final class NodeTraceModel {
var shown: TraceFrame? { held ?? frame }

/// `gapMs` is the silence the source in use allows before the line
/// breaks.
func build(_ samples: [LiveSample], node: EnergyNode, gapMs: Double, size: CGSize) {
/// breaks. `plot` gives the plot's size for a scale, since the right
/// gutter holds the scale's widest label: the trace is built for the
/// scale on screen, and again when the new scale needs another width.
/// The scale comes from the window's own readings; a node other than
/// the home rests below `Flow.idleW`.
func build(_ samples: [LiveSample], node: EnergyNode, gapMs: Double, plot: (YDomain?) -> CGSize) {
let previous = frame?.series.domain
var size = plot(previous)
guard size.width > 0, size.height > 0 else { return }
let resized = size != self.size
self.size = size
let now = Date()
let series = LiveSeries.build(
samples,
sourceGapMs: gapMs,
windowMs: LiveTrace.windowMs,
nowMs: now.timeIntervalSince1970 * 1000,
size: size,
previous: frame?.series.domain,
floor: node.scaleFloorW()
)
func make(_ size: CGSize) -> LiveSeries {
LiveSeries.build(
samples,
sourceGapMs: gapMs,
windowMs: LiveTrace.windowMs,
nowMs: now.timeIntervalSince1970 * 1000,
size: size,
previous: previous,
restW: node == .home ? 0 : Flow.idleW
)
}
var built = make(size)
if plot(built.domain) != size {
size = plot(built.domain)
built = make(size)
}
// A new height starts over; a new width keeps the slide, which
// works in time and watts.
let resized = size.height != self.size.height
self.size = size
let series = built
frame = TraceFrame(series: series, previous: resized ? nil : frame?.series, arrivedAt: now)
if playing, let last = series.last, series.domain.span > 0 {
AXLiveAudioGraph.updateValue((last.v - series.domain.lower) / series.domain.span)
Expand Down Expand Up @@ -539,19 +556,21 @@ private struct NodeChart: View {
let timebase: NodeView.Timebase
let series: [EnergyNode]
let text: PeriodText
/// A tap that never became a scrub; nil on the whole screen.
var onTap: (() -> Void)?

var body: some View {
ZStack {
switch timebase {
case .live:
LiveChartArea(site: site, node: node, live: home.nodes.live, trace: trace, scrub: scrub)
LiveChartArea(site: site, node: node, live: home.nodes.live, trace: trace, scrub: scrub, onTap: onTap)
.transition(.opacity)
case .today, .twoDays:
HistoryChartArea(site: site, node: node, home: home, scrub: scrub, period: timebase, series: series, text: text)
HistoryChartArea(site: site, node: node, home: home, scrub: scrub, period: timebase, series: series, text: text, onTap: onTap)
.transition(.opacity)
.accessibilityIdentifier("node.period.chart")
case .ahead:
AheadChartArea(site: site, node: node, home: home, scrub: scrub, text: text)
AheadChartArea(site: site, node: node, home: home, scrub: scrub, text: text, onTap: onTap)
.transition(.opacity)
.accessibilityIdentifier("node.period.chart")
}
Expand Down Expand Up @@ -650,13 +669,20 @@ private struct LiveChartArea: View {
let live: LiveTrace
let trace: NodeTraceModel
let scrub: ScrubModel
var onTap: (() -> Void)?
@State private var size: CGSize = .zero
@ScaledMetric(relativeTo: .caption2) private var axis: CGFloat = 10
@ScaledMetric(relativeTo: .caption) private var tag: CGFloat = 11

/// The frame for a scale: the right gutter holds its widest label.
private func chartFrame(_ domain: YDomain?) -> ChartFrame {
ChartFrame(top: tag * 1.35 + 8, bottom: 6, trailing: ChartAxes.gutter(domain, axis: axis))
}

var body: some View {
let revision = live.revision
let chartFrame = ChartFrame(top: tag * 1.35 + 8, bottom: 6, trailing: axis * 3.2)
// The frame the trace on screen was built for.
let drawn = chartFrame(trace.shown?.series.domain)
// The same colours as every other chart of this node.
let look = NodeChartLook.look(node)
let isLive = site.freshness(node).live
Expand All @@ -665,10 +691,10 @@ private struct LiveChartArea: View {
// geometry knows its size.
Color.clear
if let frame = trace.shown {
LiveTraceSurface(frame: frame, look: look, live: isLive, chartFrame: chartFrame, frozenAtMs: trace.frozenAtMs, catchUp: trace.catchUp, axisSize: axis)
LiveTraceSurface(frame: frame, look: look, live: isLive, chartFrame: drawn, frozenAtMs: trace.frozenAtMs, catchUp: trace.catchUp, axisSize: axis)
LiveScrubOverlay(
frame: frame,
chartFrame: chartFrame,
chartFrame: drawn,
model: scrub,
viewMs: trace.frozenAtMs ?? Date().timeIntervalSince1970 * 1000,
look: look,
Expand All @@ -678,14 +704,15 @@ private struct LiveChartArea: View {
let slice = frame.readings[r]
let mean = slice.map(\.v).reduce(0, +) / Double(max(1, slice.count))
return Readouts.seconds(slice.first!.t) + "–" + Readouts.seconds(slice.last!.t) + " " + NodeWords.format("avg %@", AppWords.power(abs(mean)))
}
},
onTap: onTap
)
}
}
.dynamicTypeSize(...DynamicTypeSize.xxLarge)
.onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 }
.onChange(of: revision, initial: true) { rebuild(chartFrame) }
.onChange(of: size) { rebuild(chartFrame) }
.onChange(of: revision, initial: true) { rebuild() }
.onChange(of: size) { rebuild() }
.accessibilityElement()
.accessibilityLabel(L("Live, the last five minutes"))
.accessibilityChartDescriptor(LiveDescriptor(node: node, readings: trace.frame?.readings ?? []))
Expand Down Expand Up @@ -726,13 +753,13 @@ private struct LiveChartArea: View {
}
#endif

private func rebuild(_ chartFrame: ChartFrame) {
let plot = chartFrame.plot(in: size).size
private func rebuild() {
let size = size
#if DEBUG
let began = ContinuousClock.now
defer { TraceCost.log.debug("trace built in \((ContinuousClock.now - began) / .microseconds(1), format: .fixed(precision: 0)) µs") }
#endif
trace.build(live.samples(node, sinceMs: Date().timeIntervalSince1970 * 1000 - LiveTrace.windowMs - 10_000), node: node, gapMs: site.readingGapMs, size: plot)
trace.build(live.samples(node, sinceMs: Date().timeIntervalSince1970 * 1000 - LiveTrace.windowMs - 10_000), node: node, gapMs: site.readingGapMs) { chartFrame($0).plot(in: size).size }
}

}
Expand Down
Loading
Loading