diff --git a/appleApp/FTW/UI/NodeHistory.swift b/appleApp/FTW/UI/NodeHistory.swift index c6f6666..39aa727 100644 --- a/appleApp/FTW/UI/NodeHistory.swift +++ b/appleApp/FTW/UI/NodeHistory.swift @@ -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 @@ -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 @@ -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) @@ -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 @@ -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 { @@ -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.. 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) @@ -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") } @@ -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 @@ -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, @@ -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 ?? [])) @@ -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 } } } diff --git a/appleApp/FTW/UI/PlanDialCard.swift b/appleApp/FTW/UI/PlanDialCard.swift index 7638422..5de8c58 100644 --- a/appleApp/FTW/UI/PlanDialCard.swift +++ b/appleApp/FTW/UI/PlanDialCard.swift @@ -14,6 +14,18 @@ private let signposts = OSSignposter(subsystem: "energy.ftw.app", category: .poi /// car's goal. On lift the hand springs back to now. VoiceOver steps it an /// hour at a time. /// +/// The dial sits in the plan's scroll, so a finger on the ring scrubs only +/// once it moves along the ring or rests 0.1 s (`RingIntent`); a move up or +/// down, or across the ring, scrolls the page. While it scrubs, the page +/// holds still (`holds`). The screen's leading 20 pt are the back swipe's. +/// +/// The ring has no gesture of its own: any gesture there, even one beside +/// the scroll's, held the page still for every swipe that started on the +/// ring (the simulator showed it for a drag, a long press, raw touches and +/// a 6 pt drag alike). The page takes every touch beside its scroll and +/// hands it here (`DialTouch`); the dial asks whether it started on the +/// ring. +/// /// The face never sees the finger. Only the hand and the middle read the /// scrub model, so a drag redraws those two, once per quarter. struct PlanDialCard: View { @@ -22,12 +34,15 @@ struct PlanDialCard: View { let grey: Bool /// The dial before the person's last change, while its plan stands. var fade: DialFade? + /// True while a finger scrubs: the page around holds still. + @Binding var holds: Bool + /// The page's touches, handed on. + let touch: DialTouch @State private var scrub = ScrubModel() /// The quarter the finger was last on, so it cannot wind past the seam. /// Written on every drag event and read by nothing that draws. @State private var finger = Finger() - @State private var side: CGFloat = 0 /// Where VoiceOver has stepped to. @State private var spoken: Int? @@ -48,11 +63,27 @@ struct PlanDialCard: View { .aspectRatio(1, contentMode: .fit) .frame(maxWidth: 340) .frame(maxWidth: .infinity) - .onGeometryChange(for: CGFloat.self) { min($0.size.width, $0.size.height) } action: { side = $0 } - // Only the ring takes a finger, so a drag from the middle scrolls - // the page. - .contentShape(DialRing(), eoFill: true) - .gesture(drag) + // Where the dial is on the screen, for the page's touches: written + // to a plain reference, so a scroll redraws nothing here. + .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { finger.frame = $0 } + .onChange(of: dial, initial: true) { _, now in finger.dial = now } + .onAppear { + touch.changed = { value in + // A new touch has a new start; one the system took away + // never ended. + if finger.startAt != value.startLocation { + end() + start(value) + } + move(value) + } + touch.ended = { end() } + } + .onDisappear { + touch.changed = nil + touch.ended = nil + end() + } .accessibilityElement(children: .ignore) .accessibilityLabel(PlanDial.title) .accessibilityValue(spokenValue(spoken ?? 0)) @@ -64,26 +95,97 @@ struct PlanDialCard: View { #endif } - /// Each drag event costs a little arithmetic and one lookup and writes - /// nothing a view reads. A change of quarter moves the scrub model. - private var drag: some Gesture { - DragGesture(minimumDistance: 0) - .onChanged { follow($0.location) } - .onEnded { _ in - finger.previous = nil - withAnimation(Theme.spring) { scrub.lift() } - } + /// The dial's side and its centre in its own frame, which is the whole + /// width the dial sits in. + private var side: CGFloat { min(finger.frame.width, finger.frame.height) } + private var centre: CGPoint { CGPoint(x: finger.frame.width / 2, y: finger.frame.height / 2) } + + /// Where a point on the screen sits in the dial's frame. + private func local(_ p: CGPoint) -> CGPoint { + CGPoint(x: p.x - finger.frame.minX, y: p.y - finger.frame.minY) + } + + /// A finger came down somewhere on the page. The dial takes it only on + /// the ring (`DialRing`), and never within 20 pt of the screen's + /// leading edge, where the back swipe starts. + private func start(_ v: DragGesture.Value) { + finger.startAt = v.startLocation + finger.startMs = ProcessInfo.processInfo.systemUptime * 1000 + finger.decision = nil + guard v.startLocation.x >= EdgeRule.edge, DialRing.contains(local(v.startLocation), in: CGRect(origin: .zero, size: finger.frame.size)) else { + finger.decision = .pass + return + } + finger.hold = Task { + try? await Task.sleep(for: .milliseconds(Int(RingIntent.holdMs))) + guard !Task.isCancelled else { return } + rested() + } + } + + /// Each event costs a little arithmetic and writes nothing a view + /// reads, until the touch is a scrub. A change of quarter moves the + /// scrub model. + private func move(_ v: DragGesture.Value) { + let at = local(v.location) + finger.last = at + guard finger.decision == nil else { + if finger.decision == .scrub { follow(at) } + return + } + let ms = ProcessInfo.processInfo.systemUptime * 1000 + let held = ms - (finger.startMs ?? ms) + let decision = RingIntent.decide(start: local(v.startLocation), now: at, centre: centre, heldMs: held) + if decision != nil { + DialLog.log.debug("dial decides after \(Int(held)) ms on a move of \(Double(v.translation.width), format: .fixed(precision: 1)), \(Double(v.translation.height), format: .fixed(precision: 1)) pt") + } + decide(decision, at: at) + } + + /// No move came, and the finger rests on the ring: it scrubs. + private func rested() { + guard finger.decision == nil, let at = finger.last else { return } + DialLog.log.debug("dial rested \(Int(RingIntent.holdMs)) ms") + decide(.scrub, at: at) + } + + private func decide(_ decision: RingIntent.Decision?, at p: CGPoint) { + guard let decision else { return } + finger.decision = decision + finger.hold?.cancel() + switch decision { + case .scrub: + DialLog.log.debug("dial scrubs") + holds = true + follow(p) + case .pass: + DialLog.log.debug("dial passes the touch to the page") + } + } + + private func end() { + guard finger.startMs != nil else { return } + finger.hold?.cancel() + finger.hold = nil + finger.startAt = nil + finger.startMs = nil + finger.decision = nil + finger.last = nil + finger.previous = nil + if holds { holds = false } + if scrub.isDown { withAnimation(Theme.spring) { scrub.lift() } } } private func follow(_ p: CGPoint) { let event = signposts.beginInterval("dial drag") defer { signposts.endInterval("dial drag", event) } guard side > 0 else { return } - let i = dial.index(x: p.x - side / 2, y: p.y - side / 2, from: finger.previous) + let now = finger.dial ?? dial + let i = now.index(x: p.x - centre.x, y: p.y - centre.y, from: finger.previous) finger.previous = i guard i != scrub.index else { return } signposts.emitEvent("dial quarter") - scrub.move(to: i, landmark: dial.landmarks.contains(i)) + scrub.move(to: i, landmark: now.landmarks.contains(i)) } // MARK: VoiceOver @@ -116,7 +218,7 @@ struct PlanDialCard: View { let t = (ContinuousClock.now - began) / .milliseconds(6_000) let along = t < 1 ? t : 2 - t let a = dial.seam + 0.01 + along * (2 * .pi - 0.02) - follow(CGPoint(x: side / 2 + r * sin(a), y: side / 2 - r * cos(a))) + follow(CGPoint(x: centre.x + r * sin(a), y: centre.y - r * cos(a))) try? await Task.sleep(for: .milliseconds(8)) } finger.previous = nil @@ -125,11 +227,34 @@ struct PlanDialCard: View { #endif } -/// The finger's last quarter: a plain reference, so a drag event writes -/// nothing SwiftUI watches. +/// The finger's last quarter, what its touch is, and where the dial sits: +/// a plain reference, so a touch or a scroll writes nothing SwiftUI +/// watches. @MainActor private final class Finger { var previous: Int? + /// Where and when the touch came down, on the screen. + var startAt: CGPoint? + var startMs: Double? + var decision: RingIntent.Decision? + var last: CGPoint? + var hold: Task? + /// On the screen. + var frame: CGRect = .zero + /// The dial on screen: the page's handler outlives a minute's dial. + var dial: PlanDial? +} + +/// The plan page's touches, handed to the dial: the page holds the one +/// gesture, beside its scroll, and the dial says what each touch is. +@MainActor +final class DialTouch { + var changed: ((DragGesture.Value) -> Void)? + var ended: (() -> Void)? +} + +private enum DialLog { + static let log = Logger(subsystem: "energy.ftw.app", category: "scrub") } /// The hand and the lock-on, the only marks that move with a finger. @@ -181,13 +306,10 @@ private struct DialCentre: View { } /// Where a finger scrubs: the ring of ticks and the rim, not the middle. -private struct DialRing: Shape { - func path(in rect: CGRect) -> Path { +private enum DialRing { + static func contains(_ p: CGPoint, in rect: CGRect) -> Bool { let side = min(rect.width, rect.height) - let inner = side * 0.26 - var path = Path() - path.addEllipse(in: CGRect(x: rect.midX - side / 2, y: rect.midY - side / 2, width: side, height: side)) - path.addEllipse(in: CGRect(x: rect.midX - inner, y: rect.midY - inner, width: inner * 2, height: inner * 2)) - return path + let r = hypot(p.x - rect.midX, p.y - rect.midY) + return r >= side * 0.26 && r <= side / 2 } } diff --git a/appleApp/FTW/UI/PlanView.swift b/appleApp/FTW/UI/PlanView.swift index 0cf9ca1..4a49e6f 100644 --- a/appleApp/FTW/UI/PlanView.swift +++ b/appleApp/FTW/UI/PlanView.swift @@ -14,6 +14,9 @@ struct PlanView: View { /// The viewer's own choice, kept on this phone. `-FTWPlanStyle lanes` /// at launch holds it there, for screenshots and tests. @AppStorage("FTWPlanStyle") private var style: PlanStyle = .dial + /// A finger scrubs the dial: the page holds still under it. + @State private var dialHolds = false + @State private var dialTouch = DialTouch() private var models: NodeModels { home.nodes } @@ -21,7 +24,7 @@ struct PlanView: View { ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 16) { - PlanBody(site: home.site, models: models, loadpoints: home.loadpoints, prices: home.prices, style: style, chooseMode: { choosingMode = true }) { levers in + PlanBody(site: home.site, models: models, loadpoints: home.loadpoints, prices: home.prices, style: style, chooseMode: { choosingMode = true }, dialHolds: $dialHolds, dialTouch: dialTouch) { levers in withAnimation(.easeInOut(duration: 0.35)) { proxy.scrollTo(levers ? PlanBody.leversID : PlanBody.topID, anchor: levers ? .bottom : .top) } } } @@ -30,6 +33,16 @@ struct PlanView: View { .frame(maxWidth: 640, alignment: .topLeading) .frame(maxWidth: .infinity) } + .scrollDisabled(dialHolds) + // Every touch, beside the scroll, for the dial's ring: a + // gesture on the ring itself stops the page scrolling. None + // from the screen's leading 20 pt, which are the back swipe's. + .leavesTheEdge() + .simultaneousGesture( + DragGesture(minimumDistance: 0, coordinateSpace: .global) + .onChanged { dialTouch.changed?($0) } + .onEnded { _ in dialTouch.ended?() } + ) } .scrollBounceBehavior(.basedOnSize) .background(Theme.ground) @@ -95,6 +108,8 @@ private struct PlanBody: View { let prices: PriceModel let style: PlanStyle let chooseMode: () -> Void + @Binding var dialHolds: Bool + let dialTouch: DialTouch /// Scrolls to the levers, or back to the plan, for the style scale's /// recording. let reveal: (Bool) -> Void @@ -113,7 +128,7 @@ private struct PlanBody: View { case .dial: let dial = dials.dial(models.planShare.entry, nowMs: minute) VStack(alignment: .leading, spacing: 12) { - PlanDialCard(dial: dial, grey: moment.isGrey, fade: DialFade.make(models.planShare.before, dial: dial, nowMs: minute)) + PlanDialCard(dial: dial, grey: moment.isGrey, fade: DialFade.make(models.planShare.before, dial: dial, nowMs: minute), holds: $dialHolds, touch: dialTouch) VStack(alignment: .leading, spacing: 6) { // The words are the dial's legend: each verb takes // the colour its ticks have. diff --git a/appleApp/FTW/UI/PriceView.swift b/appleApp/FTW/UI/PriceView.swift index 5bb91dd..c4e1fe2 100644 --- a/appleApp/FTW/UI/PriceView.swift +++ b/appleApp/FTW/UI/PriceView.swift @@ -4,7 +4,11 @@ import SwiftUI /// The price: what power costs now as the hero, the next 24 hours as a bar /// per slot, and what the plan does in each. A finger on the bars gives /// the hero the slot's price and time, and the lines under the chart what -/// the plan does then. One screen, no cards. +/// the plan does then. Under them one mono line says what the plan does at +/// these prices. One screen, no cards. The chart takes a little over half +/// the screen's height, or what the rest leaves, never under 240 pt; what +/// is left sits between the chart and the lines under it, never at the +/// foot. struct PriceView: View { let home: HomeModels @State private var scrub = ScrubModel() @@ -16,9 +20,13 @@ struct PriceView: View { // page. Chosen by the size, never by a fit, so the page never // swaps branches under a finger. if typeSize.isAccessibilitySize { - ScrollView { page(fill: false) } + ScrollView { page(chartMax: nil) } } else { - page(fill: true) + // 0.55 of the screen: at half, a Pro Max kept 129 pt of ground + // between the chart and its lines. + GeometryReader { geo in + page(chartMax: (geo.size.height + geo.safeAreaInsets.top + geo.safeAreaInsets.bottom) * 0.55) + } } } .background(Theme.ground) @@ -35,13 +43,15 @@ struct PriceView: View { } } - private func page(fill: Bool) -> some View { - PriceBody(site: home.site, prices: home.prices, plan: home.nodes.planShare, scrub: scrub, fill: fill) + /// `chartMax` is the chart's most on the fixed page, nil where the page + /// scrolls. + private func page(chartMax: CGFloat?) -> some View { + PriceBody(site: home.site, prices: home.prices, plan: home.nodes.planShare, scrub: scrub, chartMax: chartMax) .padding(.horizontal, 16) .padding(.top, 12) .padding(.bottom, 16) .frame(maxWidth: 640, alignment: .topLeading) - .frame(maxWidth: .infinity, maxHeight: fill ? .infinity : nil, alignment: .top) + .frame(maxWidth: .infinity, maxHeight: chartMax == nil ? nil : .infinity, alignment: .top) } } @@ -52,11 +62,11 @@ private struct PriceBody: View { let prices: PriceModel let plan: PlanShare let scrub: ScrubModel - let fill: Bool + let chartMax: CGFloat? var body: some View { if let window = prices.prices { - PricePage(window: window, minuteMs: site.minuteMs, plan: plan.entry, scrub: scrub, fill: fill) + PricePage(window: window, minuteMs: site.minuteMs, plan: plan.entry, scrub: scrub, chartMax: chartMax) .equatable() } else { Text(L("Reading the prices from your box…")) @@ -72,10 +82,10 @@ private struct PricePage: View, Equatable { let minuteMs: Double let plan: PlanEntry? let scrub: ScrubModel - let fill: Bool + let chartMax: CGFloat? nonisolated static func == (a: Self, b: Self) -> Bool { - a.window == b.window && a.minuteMs == b.minuteMs && a.plan == b.plan && a.fill == b.fill && a.scrub === b.scrub + a.window == b.window && a.minuteMs == b.minuteMs && a.plan == b.plan && a.chartMax == b.chartMax && a.scrub === b.scrub } var body: some View { @@ -85,25 +95,34 @@ private struct PricePage: View, Equatable { VStack(alignment: .leading, spacing: 0) { PriceHero(chart: chart, zone: window.zone, scrub: scrub) .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .fixedSize(horizontal: false, vertical: true) if chart.isEmpty { Text(L("No prices ahead yet.")) .font(.subheadline) .foregroundStyle(Theme.ink2) .padding(.top, 24) } else { - // 240 pt on an SE, up to 340 on a tall phone. + // What the rest leaves, up to 0.55 of the screen: about + // 330 pt on an SE, 526 on a Pro Max. PriceBarsChart(chart: chart, scrub: scrub) - .containerRelativeFrame(.vertical) { height, _ in min(max(height * 0.38, 240), 340) } + .frame(minHeight: 240, idealHeight: 240, maxHeight: chartMax ?? 240) + .layoutPriority(1) .padding(.top, 24) .dynamicTypeSize(...DynamicTypeSize.accessibility1) .accessibilityElement() .accessibilityLabel(L("Electricity price")) .accessibilityChartDescriptor(PriceDescriptor(chart: chart)) .accessibilityIdentifier("price.chart") - PriceReadout(chart: chart, included: included, planned: plan != nil, scrub: scrub) - .padding(.top, 16) + if chartMax != nil { Spacer(minLength: 0) } + VStack(alignment: .leading, spacing: 0) { + PriceReadout(chart: chart, included: included, planned: plan != nil, scrub: scrub) + .padding(.top, 16) + PlanAtThesePrices(line: chart.planLine, short: chart.planLineShort) + .padding(.top, 8) + } + .fixedSize(horizontal: false, vertical: true) } - if fill { Spacer(minLength: 0) } + if chartMax != nil, chart.isEmpty { Spacer(minLength: 0) } } } } @@ -164,7 +183,7 @@ private struct PriceBarsChart: View { @ScaledMetric(relativeTo: .caption) private var tag: CGFloat = 11 var body: some View { - let frame = PriceBarsDrawing.frame(axis: axis, kicker: kicker, tag: tag) + let frame = PriceBarsDrawing.frame(axis: axis, kicker: kicker, tag: tag, labels: [PriceChart.averageWord, chart.meanText]) ZStack { ChartSurface(drawing: PriceBarsDrawing(chart: chart, frame: frame, axis: axis, kicker: kicker)) .equatable() @@ -179,6 +198,35 @@ private struct PriceBarsChart: View { } } +/// `STORES 09:15–15:30 AT 58–90 · GIVES 17–20 AT 150–176 ÖRE`: what the +/// plan already read does at these prices, in the kickers' mono. Where the +/// line has no room it drops its second part, never ends in "…". Nothing +/// when there is no plan. +private struct PlanAtThesePrices: View { + let line: String + let short: String + @ScaledMetric(relativeTo: .caption2) private var size: CGFloat = 10.5 + + var body: some View { + if !line.isEmpty { + ViewThatFits(in: .horizontal) { + ForEach([line, short], id: \.self) { form in + Text(form) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } + .font(.system(size: min(size, 15), weight: .medium, design: .monospaced)) + .tracking(0.5) + .foregroundStyle(Theme.ink2) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .ignore) + .accessibilityLabel(line) + .accessibilityIdentifier("price.plan") + } + } +} + /// Two reserved lines under the chart: what the price holds, or under a /// finger what the plan does in that slot. The page never moves. private struct PriceReadout: View { diff --git a/appleApp/FTW/UI/StyleScale.swift b/appleApp/FTW/UI/StyleScale.swift index 2b5c018..5b6e1c6 100644 --- a/appleApp/FTW/UI/StyleScale.swift +++ b/appleApp/FTW/UI/StyleScale.swift @@ -84,7 +84,9 @@ struct StyleScale: View { track .frame(height: look == .line ? 32 : 84) .onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 } - .contentShape(Rectangle()) + // The screen's leading 20 pt are the back swipe's; a finger + // past the careful end still lands on its stop, clamped. + .leavesTheEdge() .gesture(drag, isEnabled: live) .onChange(of: dragging) { _, now in if !now { release() } } caption diff --git a/appleApp/FTWCharts/HistoryCharts.swift b/appleApp/FTWCharts/HistoryCharts.swift index f9aa579..e31a788 100644 --- a/appleApp/FTWCharts/HistoryCharts.swift +++ b/appleApp/FTWCharts/HistoryCharts.swift @@ -48,7 +48,7 @@ struct HistoryDrawing: ChartDrawing { 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) + ChartAxes.draw(in: &g, plot: plot, domain: picture.domain, px: px, axis: axis, look: primary.flatMap { looks[$0] }, shown: shown, reach: picture.reach(width: plot.width)) var p = g p.clip(to: Path(CGRect(x: plot.minX, y: plot.minY - 12, width: plot.width, height: plot.height + 24))) @@ -73,6 +73,26 @@ struct HistoryDrawing: ChartDrawing { } } + // The rest of the day: the plan's or the forecast's ghost, as Ahead + // draws it, from now on. + if let now = picture.nowMs { + let x = picture.grid.x(atMs: now, width: plot.width) + var ahead = p + ahead.clip(to: Path(CGRect(x: x, y: -40, width: plot.width - x + 20, height: plot.height + 80))) + for layer in picture.layers.reversed() { + guard let ghost = layer.ghost else { continue } + let look = looks[layer.key] ?? TraceLook(up: Theme.ink2, down: Theme.ink2) + var faded = ahead + if from != nil, from?.layers.first(where: { $0.key == layer.key })?.ghost == nil, k < 1 { faded.opacity = k } + switch ghost.kind { + case .plan: + Ghost.bars(ghost.values, grid: ghost.grid, look: look, width: plot.width, map: YMap(domain: shown, height: plot.height), scale: 1 / px, px: px, in: faded) + case .forecast: + Ghost.forecast(Path(ghost.line.cgPath).applying(toShown), look: look, in: faded) + } + } + } + // 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) @@ -154,22 +174,26 @@ 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. 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. +/// dashed line through its own slots, and the price under the same slots +/// as a strip of block bars, flat in ink at 0.32 and as tall as Now's, so +/// it never speaks louder than the plan. The now rule sits at the left +/// edge. A new plan or forecast for the same slots slides each slot to its +/// new value, and a new scale springs, as on the live trace. struct AheadDrawing: ChartDrawing { let picture: AheadPicture let look: TraceLook let frame: ChartFrame var axis: CGFloat = 10 var nowWord = "NOW" - /// The price strip's height inside the frame's bottom. - var strip: CGFloat = 18 + /// The price strip's height inside the frame's bottom: Now's price row. + var strip: CGFloat = AheadDrawing.stripHeight var from: AheadPicture? var slide: Double = 1 var spring: Double = 1 + /// Now's price row at its least: eight levels of 2.5 pt. + static let stripHeight: CGFloat = 20 + func draw(in g: inout GraphicsContext, size: CGSize) { let plot = frame.plot(in: size) let scale = max(g.environment.displayScale, 1) @@ -179,41 +203,31 @@ struct AheadDrawing: ChartDrawing { let mixed = from.flatMap { picture.blended(from: $0, progress: slide) } let values = mixed?.values ?? picture.values - ChartAxes.draw(in: &g, plot: plot, domain: picture.domain, px: px, axis: axis, look: look, shown: shown) + let reach = values.indices.compactMap { i in values[i].map { ChartReach(x: picture.grid.x(atMs: picture.grid.startMs(i), width: plot.width), low: $0, high: $0) } } + ChartAxes.draw(in: &g, plot: plot, domain: picture.domain, px: px, axis: axis, look: look, shown: shown, reach: reach) var p = g p.translateBy(x: plot.minX, y: plot.minY) switch picture.kind { case .plan: - for run in ChartPaths.runs(values, grid: picture.grid, width: plot.width, map: map, scale: scale, maxRadius: 4) { - let color = run.value >= 0 ? look.up : look.down - let r = run.rect - let radii = run.value > 0 - ? RectangleCornerRadii(topLeading: run.radius, bottomLeading: 0, bottomTrailing: 0, topTrailing: run.radius) - : RectangleCornerRadii(topLeading: 0, bottomLeading: run.radius, bottomTrailing: run.radius, topTrailing: 0) - p.fill(Path(roundedRect: r, cornerRadii: radii, style: .circular), with: .color(color.opacity(0.14))) - var cap = Path() - let y = run.value > 0 ? r.minY + px / 2 : r.maxY - px / 2 - cap.move(to: CGPoint(x: r.minX + run.radius, y: y)) - cap.addLine(to: CGPoint(x: r.maxX - run.radius, y: y)) - p.stroke(cap, with: .color(color.opacity(0.7)), style: StrokeStyle(lineWidth: px, lineCap: .round, lineJoin: .round)) - } + Ghost.bars(values, grid: picture.grid, look: look, width: plot.width, map: map, scale: scale, px: px, in: p) case .forecast: // An estimate, so dashed, like the estimate across a hole. - let line = mixed == nil && shown == picture.domain ? picture.line : AheadPicture.forecastLine(values, grid: picture.grid, domain: shown, size: plot.size) - p.stroke(Path(line.cgPath), with: .color(look.up.opacity(0.5)), style: StrokeStyle(lineWidth: 1.75, lineCap: .round, lineJoin: .round, dash: [3, 4])) + let line = Path((mixed?.line ?? picture.line).cgPath).applying(TraceInk.rescale(picture.domain, shown, height: plot.height)) + Ghost.forecast(line, look: look, in: p) } - // The price strip: block bars in eight steps, ink by thirds. + // The price strip: block bars in eight steps, flat in ink at 0.32. let stripTop = plot.maxY + 6 let w = plot.width / CGFloat(max(1, picture.grid.count)) + var bars = Path() for i in 0..= 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) + g.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)) + g.stroke(cap, with: .color(color.opacity(0.7)), style: StrokeStyle(lineWidth: px, lineCap: .round, lineJoin: .round)) + } + } + + /// A forecast: an estimate, so a dashed line, at half strength. + static func forecast(_ line: Path, look: TraceLook, in g: GraphicsContext) { + g.stroke(line, with: .color(look.up.opacity(0.5)), style: StrokeStyle(lineWidth: 1.75, lineCap: .round, lineJoin: .round, dash: [3, 4])) + } +} + /// The axes every chart on the engine draws in its own Canvas. enum ChartAxes { /// Zero as one device pixel; a dotted gridline and a magnitude in the /// right gutter at each end of the scale; the direction words, only - /// when the scale holds both signs. - static func draw(in g: inout GraphicsContext, plot: CGRect, domain: YDomain, px: CGFloat, axis: CGFloat, look: TraceLook?, shown: YDomain? = nil) { + /// when the scale holds both signs and, given `reach`, only in a corner + /// no data runs through (`CornerWords`). `zero` false leaves zero to + /// the chart: a source at rest draws it dotted. + static func draw(in g: inout GraphicsContext, plot: CGRect, domain: YDomain, px: CGFloat, axis: CGFloat, look: TraceLook?, shown: YDomain? = nil, reach: [ChartReach]? = nil, zero drawsZero: Bool = true) { // Labels name the scale the chart is going to; zero sits where the // scale on its way puts it. let map = YMap(domain: domain, height: plot.height) let base = plot.minY + (YMap(domain: shown ?? domain, height: plot.height).baseline / px).rounded() * px + px / 2 - var zero = Path() - zero.move(to: CGPoint(x: plot.minX, y: base)) - zero.addLine(to: CGPoint(x: plot.maxX, y: base)) - g.stroke(zero, with: .color(Theme.ink.opacity(0.35)), style: StrokeStyle(lineWidth: px, lineCap: .butt, lineJoin: .round)) + if drawsZero { + var zero = Path() + zero.move(to: CGPoint(x: plot.minX, y: base)) + zero.addLine(to: CGPoint(x: plot.maxX, y: base)) + g.stroke(zero, with: .color(Theme.ink.opacity(0.35)), style: StrokeStyle(lineWidth: px, lineCap: .butt, lineJoin: .round)) + } for v in [domain.upper, domain.lower] where v != 0 { let y = plot.minY + map.y(v) var line = Path() @@ -248,11 +294,25 @@ enum ChartAxes { g.draw(g.resolve(ChartText.label(PowerFormat.scale(abs(v)), size: axis)), at: CGPoint(x: plot.maxX + 4, y: y), anchor: .leading) } if domain.lower < 0, domain.upper > 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) } + let on = YMap(domain: shown ?? domain, height: plot.height) + func clear(_ word: String, top: Bool) -> Bool { + reach.map { CornerWords.clear(word, size: axis, top: top, data: $0, map: on) } ?? true + } + let inset = CornerWords.inset + if let up = look.upWord, clear(up, top: true) { + g.draw(g.resolve(ChartText.label(up, size: axis)), at: CGPoint(x: plot.minX + inset, y: plot.minY + inset), anchor: .topLeading) + } + if let down = look.downWord, clear(down, top: false) { + g.draw(g.resolve(ChartText.label(down, size: axis)), at: CGPoint(x: plot.minX + inset, y: plot.maxY - inset), anchor: .bottomLeading) + } } } + /// The right gutter for a power scale's labels at `axis`. + static func gutter(_ domain: YDomain?, axis: CGFloat) -> CGFloat { + ChartGutter.trailing(domain.map(ChartGutter.scaleLabels) ?? ["0 W"], size: axis) + } + /// 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) { diff --git a/appleApp/FTWCharts/LiveTrace.swift b/appleApp/FTWCharts/LiveTrace.swift index a559877..ff79915 100644 --- a/appleApp/FTWCharts/LiveTrace.swift +++ b/appleApp/FTWCharts/LiveTrace.swift @@ -40,6 +40,8 @@ struct TraceFrame: Equatable { var beforeBodyUnseen = Path() /// Dotted baselines across the silences. var gaps = Path() + /// A source at rest: the dotted baseline under its readings. + var rest = Path() } init(series: LiveSeries, previous: LiveSeries?, arrivedAt: Date) { @@ -64,6 +66,13 @@ struct TraceFrame: Equatable { p.gaps.move(to: CGPoint(x: series.x(gap.fromMs), y: base)) p.gaps.addLine(to: CGPoint(x: series.x(gap.toMs), y: base)) } + if series.atRest { + for run in series.runs { + guard let first = run.first, let last = run.last else { continue } + p.rest.move(to: CGPoint(x: series.x(first.t), y: base)) + p.rest.addLine(to: CGPoint(x: series.x(last.t), y: base)) + } + } paths = p let all = series.readings readings = all @@ -93,6 +102,10 @@ struct TraceFrame: Equatable { /// only in dark mode. What this phone did not see live is drawn in ink. No /// dot ever travels along the line. /// +/// A source at rest (`LiveSeries.atRest`) draws no line: zero sits +/// mid-plot as a dotted baseline under its readings, in place of the solid +/// zero, as Now draws a node at rest, with the head on the last reading. +/// /// While a finger is down the window holds still (`frozenAtMs`); on lift /// it catches up with the spring. struct LiveTraceSurface: View { @@ -170,8 +183,11 @@ struct LiveTraceSurface: View { 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, as on every chart. - ChartAxes.draw(in: &g, plot: plot, domain: series.domain, px: px, axis: axisSize, look: look, shown: shown) + // direction words when both signs show and no reading runs under + // them, as on every chart. + // Only the left of the window can run under a word. + let reach = frame.readings.prefix { series.x($0.t) - shift <= 120 }.map { ChartReach(x: series.x($0.t) - shift, low: $0.v, high: $0.v) } + ChartAxes.draw(in: &g, plot: plot, domain: series.domain, px: px, axis: axisSize, look: look, shown: shown, reach: reach, zero: !series.atRest) var plotting = g plotting.clip(to: Path(CGRect(x: plot.minX, y: plot.minY - 12, width: plot.width, height: plot.height + 24))) @@ -202,6 +218,7 @@ struct LiveTraceSurface: View { p.bodyUnseen = p.bodyUnseen.applying(scale) p.band = p.band.applying(scale) p.gaps = p.gaps.applying(scale) + p.rest = p.rest.applying(scale) head = head?.applying(scale) } @@ -209,6 +226,15 @@ struct LiveTraceSurface: View { 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)) + // At rest: the dotted baseline under the readings, the head on the + // last, and nothing else. + if series.atRest { + TraceInk.dotted(p.rest, px: px, in: plotting) + TraceInk.dotted(p.gaps, px: px, in: plotting) + if let head { Self.head(head, baseline: baseline, look: look, live: live, glows: glows, in: plotting) } + return + } + // 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) @@ -225,16 +251,18 @@ struct LiveTraceSurface: View { // 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. - if let head { - let color = head.y <= baseline ? look.up : look.down - if glows { - plotting.fill(Path(ellipseIn: CGRect(x: head.x - 4.5, y: head.y - 4.5, width: 9, height: 9)), with: .color(color.opacity(0.3))) - } - let r: CGFloat = 1.75 - 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)) + if let head { Self.head(head, baseline: baseline, look: look, live: live, glows: glows, in: plotting) } + } + + /// The head: quiet, on the raw last reading, a small halo only while + /// live in the dark. + private static func head(_ head: CGPoint, baseline: CGFloat, look: TraceLook, live: Bool, glows: Bool, in g: GraphicsContext) { + let color = head.y <= baseline ? look.up : look.down + if glows { + g.fill(Path(ellipseIn: CGRect(x: head.x - 4.5, y: head.y - 4.5, width: 9, height: 9)), with: .color(color.opacity(0.3))) } + let r: CGFloat = 1.75 + g.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)) } } @@ -242,7 +270,9 @@ struct LiveTraceSurface: View { /// sits on the nearest raw reading, so it may sit a little off the /// smoothed line, inside the band. A tick per reading crossed, a light /// impact at the window's high and low and at the edges of a silence. Two -/// fingers hold a span of readings. +/// fingers hold a span of readings. As on every chart, a tap is a tap and +/// never a scrub (`FingerGate`), and the screen's leading 20 pt are the +/// back swipe's. struct LiveScrubOverlay: View { let frame: TraceFrame let chartFrame: ChartFrame @@ -255,9 +285,12 @@ struct LiveScrubOverlay: View { let tag: (LiveSample) -> String /// "14:30:10–14:31:40 avg 1.2 kW" let spanTag: (ClosedRange) -> String + /// A touch that lifted before it scrubbed. + var onTap: (() -> Void)? @GestureState private var touching = false @State private var size: CGSize = .zero + @State private var gate = FingerGate() var body: some View { let down = model.isDown @@ -282,15 +315,23 @@ struct LiveScrubOverlay: View { } .opacity(down ? 1 : 0) .animation(.easeOut(duration: down ? 0.08 : 0.15), value: down) - .contentShape(Rectangle()) + .leavesTheEdge() .onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 } .gesture( SpatialEventGesture() .updating($touching) { _, state, _ in state = true } - .onChanged { events in follow(events) } - .onEnded { _ in model.lift() } + .onChanged { events in gate.changed(events) { follow($0) } } + .onEnded { _ in + if gate.ended() { onTap?() } + model.lift() + } ) - .onChange(of: touching) { _, now in if !now { model.lift() } } + .onChange(of: touching) { _, now in + if !now { + gate.cancelled() + model.lift() + } + } .sensoryFeedback(.selection, trigger: model.ticks) .sensoryFeedback(.impact(weight: .light), trigger: model.bumps) } diff --git a/appleApp/FTWCharts/PriceBars.swift b/appleApp/FTWCharts/PriceBars.swift index ae1bcbc..e15724b 100644 --- a/appleApp/FTWCharts/PriceBars.swift +++ b/appleApp/FTWCharts/PriceBars.swift @@ -112,9 +112,10 @@ struct PriceBarsDrawing: ChartDrawing { var axis: CGFloat = 10 var kicker: CGFloat = 10.5 - /// The frame this chart draws in, for axis and kicker sizes. - static func frame(axis: CGFloat, kicker: CGFloat, tag: CGFloat) -> ChartFrame { - ChartFrame(top: tag * 1.35 + 6, bottom: 6 + 3 + kicker * 1.35 + 3 + axis * 1.35, trailing: axis * 2.8) + /// The frame this chart draws in, for axis and kicker sizes; the right + /// gutter holds the widest of `labels` (the average's word and figure). + static func frame(axis: CGFloat, kicker: CGFloat, tag: CGFloat, labels: [String]) -> ChartFrame { + ChartFrame(top: tag * 1.35 + 6, bottom: 6 + 3 + kicker * 1.35 + 3 + axis * 1.35, trailing: ChartGutter.trailing(labels, size: axis)) } func draw(in g: inout GraphicsContext, size: CGSize) { @@ -150,25 +151,12 @@ struct PriceBarsDrawing: ChartDrawing { g.draw(figure, at: CGPoint(x: x, y: y + 1), anchor: .topLeading) } - // The time axis: NOW first, then each hour label that has room. - var taken: [ClosedRange] = [] - if let x = p.nowX { + // The time axis as every chart draws it: NOW under its rule, then + // each hour label that has room. + if p.nowX != nil { g.stroke(p.nowRule, with: .color(Theme.ink), style: StrokeStyle(lineWidth: px, lineCap: .butt, lineJoin: .round)) - let now = g.resolve(ChartText.label(PriceChart.nowWord, size: axis, weight: .semibold, color: Theme.ink2)) - let w = now.measure(in: size).width - let left = min(max(plot.minX, x - w / 2), plot.maxX - w) - taken.append(left...(left + w)) - g.draw(now, at: CGPoint(x: left, y: size.height), anchor: .bottomLeading) - } - for tick in chart.hours { - let text = g.resolve(ChartText.label(tick.label, size: axis)) - let w = text.measure(in: size).width - let x = plot.minX + chart.grid.x(atMs: tick.ms, width: plot.width) - let span = (x - w / 2 - 4)...(x + w / 2 + 4) - guard span.lowerBound >= plot.minX, span.upperBound <= size.width, !taken.contains(where: { $0.overlaps(span) }) else { continue } - taken.append(span) - g.draw(text, at: CGPoint(x: x, y: size.height), anchor: .bottom) } + ChartAxes.time(in: &g, plot: plot, size: size, grid: chart.grid, ticks: chart.hours.map { ($0.ms, $0.label) }, nowMs: p.nowX == nil ? nil : chart.nowMs, nowWord: PriceChart.nowWord, axis: axis) if let span = p.bracketSpan { g.stroke(p.bracket, with: .color(Theme.ink.opacity(0.7)), style: StrokeStyle(lineWidth: 1, lineCap: .round, lineJoin: .round)) diff --git a/appleApp/FTWCharts/ScrubOverlay.swift b/appleApp/FTWCharts/ScrubOverlay.swift index dfd76fa..e7fa094 100644 --- a/appleApp/FTWCharts/ScrubOverlay.swift +++ b/appleApp/FTWCharts/ScrubOverlay.swift @@ -42,6 +42,11 @@ struct ScrubTrack: Equatable { /// never vanishes while it is down. It reads only the scrub model and /// redraws once per slot change, however fast the finger moves. /// +/// It owns the tap too (`FingerGate`): a touch that lifts before it moves +/// 6 pt and before 150 ms calls `onTap` and never moves the scrub, so the +/// marker, the hero and the haptics stay still. A touch that starts within +/// 20 pt of the screen's leading edge is the system's back swipe. +/// /// The marks are the lock-on: a 1 px rule, a dot in the series colour with /// a ring of the ground, corner brackets that close on it and an /// inverse-video tag at the top of the rule. The position never animates; @@ -55,9 +60,13 @@ struct ScrubOverlay: View { /// The tag over two fingers' span: "02:00–04:00 51,9 öre". Called once /// per change of the span. var spanTag: (ClosedRange) -> String = { _ in "" } + /// A touch that lifted before it scrubbed: the node view opens the chart + /// on the whole screen. + var onTap: (() -> Void)? @GestureState private var touching = false @State private var size: CGSize = .zero + @State private var gate = FingerGate() #if DEBUG /// The newest track, for the debug driver: its task keeps the view /// value it started with, often from before the data came. @@ -86,17 +95,26 @@ struct ScrubOverlay: View { } .opacity(down ? 1 : 0) .animation(.easeOut(duration: down ? 0.08 : 0.15), value: down) - .contentShape(Rectangle()) + .leavesTheEdge() .onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 } .gesture( - // Every touch at once: one finger scrubs, two hold a span. + // Every touch at once: one finger scrubs, two hold a span, once + // the gate says it is no tap. SpatialEventGesture() .updating($touching) { _, state, _ in state = true } - .onChanged { events in follow(events) } - .onEnded { _ in model.lift() } + .onChanged { events in gate.changed(events) { follow($0) } } + .onEnded { _ in + if gate.ended() { onTap?() } + model.lift() + } ) // A gesture the system takes away ends without onEnded. - .onChange(of: touching) { _, now in if !now { model.lift() } } + .onChange(of: touching) { _, now in + if !now { + gate.cancelled() + model.lift() + } + } .sensoryFeedback(.selection, trigger: model.ticks) .sensoryFeedback(.impact(weight: .light), trigger: model.bumps) #if DEBUG @@ -240,6 +258,111 @@ struct ScrubOverlay: View { #endif } +/// A touch on a chart, held until `TouchIntent` says it is a scrub or a +/// tap. Nothing that draws reads it, so a touch costs no redraw while it +/// waits. A finger that rests is a scrub after 150 ms, with no event to +/// say so; one short sleep per touch decides it. +@MainActor +final class FingerGate { + private var intent: TouchIntent? + private var hold: Task? + private var last: SpatialEventCollection? + private var scrub: ((SpatialEventCollection) -> Void)? + + private static func ms() -> Double { ProcessInfo.processInfo.systemUptime * 1000 } + + /// The fingers moved. `scrub` takes every event once the touch is a + /// scrub. + func changed(_ events: SpatialEventCollection, scrub: @escaping (SpatialEventCollection) -> Void) { + let down = events.filter { $0.phase == .active } + self.scrub = scrub + last = events + guard let first = down.first else { + if intent?.state == .scrub { scrub(events) } + return + } + if intent == nil { + intent = TouchIntent(at: first.location, ms: Self.ms()) + hold = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(Int(TouchIntent.holdMs))) + guard !Task.isCancelled else { return } + self?.rested() + } + } + guard var touch = intent else { return } + let was = touch.state + touch.moved(to: first.location, atMs: Self.ms(), fingers: down.count) + intent = touch + guard touch.state == .scrub else { return } + if was == .waiting { + ScrubModel.log.debug("scrub starts: moved \(Double(touch.travel), format: .fixed(precision: 1)) pt, fingers \(down.count)") + hold?.cancel() + } + scrub(events) + } + + /// The finger rested 150 ms where it came down. + private func rested() { + guard var touch = intent, touch.state == .waiting else { return } + touch.held(atMs: Self.ms()) + intent = touch + guard touch.state == .scrub, let last else { return } + ScrubModel.log.debug("scrub starts: rested \(Int(TouchIntent.holdMs)) ms, moved \(Double(touch.travel), format: .fixed(precision: 1)) pt") + scrub?(last) + } + + /// Every finger lifted. True when the touch never scrubbed: a tap. + func ended() -> Bool { + defer { reset() } + guard var touch = intent else { return false } + let tap = touch.lifted() == .tap + if tap { + ScrubModel.log.debug("tap: lifted after \(Int(Self.ms() - touch.startMs)) ms, moved \(Double(touch.travel), format: .fixed(precision: 1)) pt, no scrub") + } + return tap + } + + /// The system took the touch: no tap, no scrub. + func cancelled() { + reset() + } + + private func reset() { + hold?.cancel() + hold = nil + intent = nil + last = nil + scrub = nil + } +} + +/// The view takes no touch that starts within 20 pt of the screen's +/// leading edge (`EdgeRule`), so the system's back swipe goes back. +struct LeavesTheEdge: ViewModifier { + @State private var minX: CGFloat = 0 + + func body(content: Content) -> some View { + content + .contentShape(EdgeClear(leading: EdgeRule.inset(minX: minX))) + .onGeometryChange(for: CGFloat.self) { $0.frame(in: .global).minX } action: { minX = $0 } + } +} + +/// A rectangle with its leading `leading` points cut away. +struct EdgeClear: Shape { + var leading: CGFloat + + func path(in rect: CGRect) -> Path { + let cut = min(max(0, leading), rect.width) + return Path(CGRect(x: rect.minX + cut, y: rect.minY, width: rect.width - cut, height: rect.height)) + } +} + +extension View { + /// Leaves the screen's leading 20 pt to the system's back swipe. + func leavesTheEdge() -> some View { modifier(LeavesTheEdge()) } +} + /// The lock-on, one of the six TUI details: what the finger is on, drawn /// the same on every chart. A 1 px rule, a dot in the series colour with a /// ring of the ground and corner brackets closing on it, and an diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index 3d6bac4..a220be6 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -832,6 +832,9 @@ extension SimulatedBox { // another planning style. if UserDefaults.standard.bool(forKey: "FTWDemoUnpublished") { box.publishHour = 24 } if UserDefaults.standard.object(forKey: "FTWDemoSafetyK") != nil { box.house.safetyK = UserDefaults.standard.double(forKey: "FTWDemoSafetyK") } + // For the live chart's pictures: `-FTWDemoBatteryW 450` holds the + // battery near 450 W, charging, with a few watts of noise. + if UserDefaults.standard.object(forKey: "FTWDemoBatteryW") != nil { box.house.heldBatteryW = UserDefaults.standard.double(forKey: "FTWDemoBatteryW") } // For recordings of light and its decay: `-FTWDemoQuiet 8` keeps // the box quiet for 8 s in every 16, so Now goes from live to quiet // and back. diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift index ddf198a..de52510 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift @@ -16,6 +16,9 @@ public final class SimulatedHouse { /// A clear midsummer's day whatever the date, for showing a day that /// sells more than it buys. public var sunny = false + /// Debug pictures: the battery held near this many watts, whatever the + /// mode says. + public var heldBatteryW: Double? public private(set) var socFraction: Double = 0.62 public private(set) var batteryW: Double = 0 @@ -261,6 +264,7 @@ public final class SimulatedHouse { } if modeKey == "planner_passive_arbitrage", want < 0, priceAt(nowMs) < 45 { want = 0 } } + if let held = heldBatteryW { want = held + (nowMs / 1_000).truncatingRemainder(dividingBy: 7) * 2 - 6 } want = max(-batteryMaxW, min(batteryMaxW, want)) if socFraction >= 0.995, want > 0 { want = 0 } if socFraction <= 0.05, want < 0 { want = 0 } diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/AheadPicture.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/AheadPicture.swift index 74350f0..ec413be 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/AheadPicture.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/AheadPicture.swift @@ -16,6 +16,19 @@ public struct AheadPicture: Equatable, Sendable { public var label: String } + /// One slot as the box gave it. + public struct Mark: Equatable, Sendable { + public var startMs: Double + public var endMs: Double + public var watts: Double + + public init(startMs: Double, endMs: Double, watts: Double) { + self.startMs = startMs + self.endMs = endMs + self.watts = watts + } + } + public var kind: Kind public var grid: SlotGrid public var domain: YDomain @@ -23,8 +36,11 @@ public struct AheadPicture: Equatable, Sendable { /// 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. + /// A forecast's own slots, which may be longer than the grid's: its + /// line runs through their middles. + public var marks: [Mark] + /// A forecast's dashed line through its own slots' middles, 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. @@ -34,7 +50,9 @@ public struct AheadPicture: Equatable, Sendable { public var ticks: [Tick] /// `slots` and `prices` as they come; the window runs `spanMs` from the - /// slot that holds `nowMs`. + /// slot that holds `nowMs`. `top`, when known, fixes the scale's top + /// (the car's charger at its most), so the scale never flips with + /// what the plan holds. public static func build( kind: Kind, slots: [(startMs: Double, endMs: Double, watts: Double?)], @@ -44,6 +62,7 @@ public struct AheadPicture: Equatable, Sendable { size: CGSize, previous: YDomain? = nil, floor: Double = 1, + top: Double? = nil, clock: ZoneClock, everyHours: Int = 3 ) -> AheadPicture { @@ -64,9 +83,10 @@ public struct AheadPicture: Equatable, Sendable { minor[i] = value(prices, { $0.startMs }, { $0.endMs }, at: ms)?.minor } 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) + let domain = Self.domain(low: min(0, known.min() ?? 0), high: max(0, known.max() ?? 0), previous: previous, floor: floor, top: top) - let line = kind == .forecast ? forecastLine(values, grid: grid, domain: domain, size: size) : ChartPath() + let marks = kind == .forecast ? ahead.compactMap { s in s.watts.map { Mark(startMs: s.startMs, endMs: s.endMs, watts: $0) } } : [] + let line = forecastLine(marks, fromMs: grid.fromMs, toMs: grid.toMs, domain: domain, size: size) let prices = minor.compactMap { $0 } let lo = prices.min() ?? 0, hi = prices.max() ?? 0 @@ -92,22 +112,39 @@ public struct AheadPicture: Equatable, Sendable { t += hour } - return AheadPicture(kind: kind, grid: grid, domain: domain, size: size, values: values, line: line, priceThirds: thirds, priceLevels: levels, nowMs: nowMs, ticks: ticks) + return AheadPicture(kind: kind, grid: grid, domain: domain, size: size, values: values, marks: marks, line: line, priceThirds: thirds, priceLevels: levels, nowMs: nowMs, ticks: ticks) } - /// A forecast's line through each slot's middle, one monotone subpath - /// per run of values. - public static func forecastLine(_ values: [Double?], grid: SlotGrid, domain: YDomain, size: CGSize) -> ChartPath { + /// The scale for values from `low` to `high`: as `YDomain.stable`, or + /// with its top fixed at `top` unless a value goes past it. + public static func domain(low: Double, high: Double, previous: YDomain?, floor: Double, top: Double?) -> YDomain { + let stable = YDomain.stable(low: low, high: high, previous: previous, floor: floor) + guard let top, top.isFinite, top > 0 else { return stable } + return YDomain(lower: stable.lower, upper: high > top ? YDomain.nice(high * 1.1) : top) + } + + /// A forecast's line, Steffen through the middle of each of its own + /// slots across a window from `fromMs` to `toMs`, so an hourly forecast + /// on a grid of quarters bends instead of stepping. A slot that holds + /// the window's start also gives it a point there, so the line starts + /// at the left edge. One subpath per run: where the slots stop touching, + /// the line breaks. + public static func forecastLine(_ marks: [Mark], fromMs: Double, toMs: Double, domain: YDomain, size: CGSize) -> ChartPath { + guard toMs > fromMs else { return ChartPath() } let map = YMap(domain: domain, height: size.height) + func x(_ ms: Double) -> CGFloat { CGFloat((ms - fromMs) / (toMs - fromMs)) * size.width } var line = ChartPath() var run: [CGPoint] = [] - for i in values.indices { - guard let v = values[i] else { + var end = -Double.infinity + for m in marks.sorted(by: { $0.startMs < $1.startMs }) where m.endMs > fromMs && m.startMs < toMs { + if m.startMs > end + 1 { line.addMonotone(run) run = [] - continue } - run.append(CGPoint(x: grid.midX(i, width: size.width), y: map.y(v))) + let mid = (m.startMs + m.endMs) / 2 + if run.isEmpty, m.startMs <= fromMs, mid > fromMs { run.append(CGPoint(x: 0, y: map.y(m.watts))) } + run.append(CGPoint(x: x(mid), y: map.y(m.watts))) + end = m.endMs } line.addMonotone(run) return line @@ -119,12 +156,18 @@ public struct AheadPicture: Equatable, Sendable { /// 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 } + guard progress < 1, old.kind == kind, old.grid == grid, old.size == size, old.values != values || old.marks != marks 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()) + guard kind == .forecast else { return (mixed, ChartPath()) } + var before = [Double: Double](minimumCapacity: old.marks.count) + for m in old.marks { before[m.startMs] = m.watts } + let moving = marks.map { m in + Mark(startMs: m.startMs, endMs: m.endMs, watts: (before[m.startMs] ?? 0) + (m.watts - (before[m.startMs] ?? 0)) * k) + } + return (mixed, Self.forecastLine(moving, fromMs: grid.fromMs, toMs: grid.toMs, domain: domain, size: size)) } } diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/ChartGutter.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/ChartGutter.swift new file mode 100644 index 0000000..b60e504 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/ChartGutter.swift @@ -0,0 +1,70 @@ +import CoreGraphics +import Foundation + +/// The right gutter and the corner words of a chart, worked out from the +/// labels it draws. Every glyph of SF Mono has one advance, so a label's +/// width is its length times that, and the plot never reflows to fit one. +public enum ChartGutter { + /// SF Mono's advance as a share of its size, at every weight: 6.18 pt + /// at 10 pt. + public static let monoAdvance: CGFloat = 0.62 + /// Between the plot's right edge and a label. + public static let gap: CGFloat = 4 + /// After the label, so its last glyph is never cut. + public static let end: CGFloat = 2 + + /// A label in SF Mono at `size`. + public static func width(_ text: String, size: CGFloat) -> CGFloat { + CGFloat(text.count) * monoAdvance * size + } + + /// The gutter that holds the widest of `labels`. + public static func trailing(_ labels: [String], size: CGFloat) -> CGFloat { + gap + (labels.map { width($0, size: size) }.max() ?? 0) + end + } + + /// The labels a power scale draws at its ends: "20 kW", never "0 W". + public static func scaleLabels(_ domain: YDomain) -> [String] { + [domain.upper, domain.lower].filter { $0 != 0 }.map { PowerFormat.scale(abs($0)) } + } +} + +/// One x of a chart's data, with the lowest and highest value it reaches +/// there, so a corner word can tell whether the data runs under it. +public struct ChartReach: Equatable, Sendable { + public var x: CGFloat + public var low: Double + public var high: Double + + public init(x: CGFloat, low: Double, high: Double) { + self.x = x + self.low = low + self.high = high + } +} + +/// The direction words (`↑ STORING` top left, `↓ GIVING POWER` bottom +/// left) are drawn only where no data runs: a line through a word reads as +/// neither. +public enum CornerWords { + /// The word's inset from the plot's corner. + public static let inset: CGFloat = 2 + + /// Whether `word`, in SF Mono at `size`, has its corner to itself: no + /// point of `data` under it reaches into its band. `top` is the up + /// word's corner. The first point past the word counts too, since the + /// line runs to it from under the word. + public static func clear(_ word: String, size: CGFloat, top: Bool, data: [ChartReach], map: YMap) -> Bool { + let right = inset + ChartGutter.width(word, size: size) + ChartGutter.gap + let band = inset + size * 1.3 + 2 + var passed = false + for p in data.sorted(by: { $0.x < $1.x }) { + if p.x > right { + if passed { break } + passed = true + } + if top ? map.y(p.high) <= band : map.y(p.low) >= map.height - band { return false } + } + return true + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/HistoryPicture.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/HistoryPicture.swift index dc642bb..05c4e87 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/HistoryPicture.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/HistoryPicture.swift @@ -15,6 +15,40 @@ public struct HistoryPicture: Equatable, Sendable { public struct Layer: Equatable, Sendable { public var key: String public var series: ChartSeries + /// What the box plans or forecasts for the rest of the window, + /// drawn as Ahead draws it, from now on. + public var ghost: Ghost? + } + + /// The hours after now as the ghost of the plan or the forecast: a + /// plan's slots as faint bars, a forecast as a dashed line through its + /// own slots' middles. On the window's own x, so it meets the measured + /// line at now. + public struct Ghost: Equatable, Sendable { + public var kind: AheadPicture.Kind + /// The plan's slots across the window: `fromMs` and `toMs` are the + /// window's, so a slot's x is the chart's. + public var grid: SlotGrid + public var values: [Double?] + /// A forecast's line on the picture's scale; empty for a plan. + public var line: ChartPath + + /// The ghost of `slots` in a window from `fromMs` to `toMs`, from + /// the slot that holds `nowMs`: one grid step, the shortest slot. + static func build(_ kind: AheadPicture.Kind, slots: [AheadPicture.Mark], fromMs: Double, toMs: Double, nowMs: Double, domain: YDomain, size: CGSize) -> Ghost? { + let after = slots.filter { $0.endMs > nowMs && $0.startMs < toMs && $0.endMs > $0.startMs }.sorted { $0.startMs < $1.startMs } + guard let first = after.first else { return nil } + let stepMs = max(60_000, after.map { $0.endMs - $0.startMs }.min() ?? 900_000) + let count = max(1, Int(((min(toMs, after.map(\.endMs).max() ?? toMs) - first.startMs) / stepMs).rounded(.up))) + let grid = SlotGrid(firstMs: first.startMs, stepMs: stepMs, count: count, fromMs: fromMs, toMs: toMs) + var values = [Double?](repeating: nil, count: count) + for i in 0.. nowMs && m.startMs < toMs { + low = min(low, m.watts) + high = max(high, m.watts) + } + } } 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)) + Layer( + key: input.key, + series: ChartSeries.history(input.samples, fromMs: fromMs, toMs: toMs, nowMs: nowMs, size: size, clock: clock, domain: domain, words: words), + ghost: nowMs.flatMap { now in ahead[input.key].flatMap { Ghost.build($0.kind, slots: $0.slots, fromMs: fromMs, toMs: toMs, nowMs: now, domain: domain, size: size) } } + ) } let grid = layers.first?.series.grid ?? SlotGrid(firstMs: fromMs, stepMs: stepMs, count: 0, fromMs: fromMs, toMs: toMs) let limit = layers.compactMap(\.series.lastData).max() @@ -98,6 +146,24 @@ public struct HistoryPicture: Equatable, Sendable { return HistoryPicture(layers: layers, grid: grid, domain: domain, size: size, nowMs: nowMs, limit: limit, asOfMs: asOf, ticks: ticks) } + /// Where the data reaches across a plot `width` wide, for the corner + /// words: each slot's band of every series, and each ghost's slots. + public func reach(width: CGFloat) -> [ChartReach] { + var out: [ChartReach] = [] + for layer in layers { + for b in layer.series.buckets { + guard let lo = b.low, let hi = b.high else { continue } + out.append(ChartReach(x: grid.x(atMs: b.atMs, width: width), low: lo, high: hi)) + } + guard let ghost = layer.ghost else { continue } + for i in ghost.values.indices { + guard let v = ghost.values[i] else { continue } + out.append(ChartReach(x: ghost.grid.x(atMs: ghost.grid.startMs(i), width: width), low: v, high: v)) + } + } + return out + } + /// 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) { diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/LiveSeries.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/LiveSeries.swift index 336264f..2e3cb46 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/LiveSeries.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/LiveSeries.swift @@ -80,6 +80,9 @@ public struct LiveSeries: Equatable, Sendable { /// The last raw reading, where the head sits. public var head: CGPoint? public var last: LiveSample? + /// Every reading in the window is at rest: the chart draws the dotted + /// baseline mid-plot, as Now draws a node at rest, and no line. + public var atRest = false /// Points per ms across the plot. public var ptPerMs: CGFloat { windowMs > 0 ? (size.width - inset) / CGFloat(windowMs) : 0 } @@ -118,7 +121,8 @@ public struct LiveSeries: Equatable, Sendable { /// relay); the device's own read period, from its readings unless /// `periodMs` is given, can only lengthen it. `previous` is the domain /// on screen, so the axis holds still; `floor` is the node's least - /// scale. + /// scale. With `restW`, the scale comes from the window alone + /// (`domain(low:high:restW:previous:)`) and `floor` is not used. public static func build( _ frames: [LiveSample], sourceGapMs: Double, @@ -128,6 +132,7 @@ public struct LiveSeries: Equatable, Sendable { size: CGSize, previous: YDomain? = nil, floor: Double = 1, + restW: Double? = nil, sigma: CGFloat = LiveSeries.sigma, inset: CGFloat = LiveSeries.headInset ) -> LiveSeries { @@ -148,13 +153,15 @@ public struct LiveSeries: Equatable, Sendable { high = max(high, f.high) } } - let domain = YDomain.stable(low: low, high: high, previous: previous, floor: floor) + let domain = restW.map { Self.domain(low: low, high: high, restW: $0, previous: previous) } + ?? YDomain.stable(low: low, high: high, previous: previous, floor: floor) var series = LiveSeries( nowMs: nowMs, windowMs: windowMs, size: size, inset: inset, domain: domain, periodMs: period, gapMs: gap, runs: runs, curves: [], frames: frameBands, gaps: [], line: ChartPath(), lineUnseen: ChartPath(), body: ChartPath(), bodyUnseen: ChartPath(), band: ChartPath(), head: nil, last: runs.last?.last ) + if let restW, !runs.isEmpty { series.atRest = high <= restW && low >= -restW } let pt = series.ptPerMs // 2.5 pt is about 2 s: enough to calm a 1 Hz meter. A slower device // leaves its readings further apart than that, so the Gaussian @@ -179,6 +186,27 @@ public struct LiveSeries: Equatable, Sendable { return series } + /// The live scale, from the window's own readings: each side ends on + /// nice(1.1 × its peak), and a side is there only when the window goes + /// past rest that way, so a window of one sign keeps zero at the edge + /// and a 450 W battery fills most of the plot. A window at rest has no + /// peak to fit: zero sits mid-plot, on the least scale either side. + /// The scale holds still as `YDomain.stable` does, except that the + /// rest scale is never held. + public static func domain(low: Double, high: Double, restW: Double, previous: YDomain?) -> YDomain { + let rest = restDomain(restW) + let up = high > restW ? high : 0 + let down = low < -restW ? low : 0 + guard up > 0 || down < 0 else { return rest } + return YDomain.stable(low: down, high: up, previous: previous == rest ? nil : previous, floor: max(restW, 1)) + } + + /// Zero mid-plot, on the least scale a side may have. + public static func restDomain(_ restW: Double) -> YDomain { + let r = YDomain.nice(max(restW, 1) * 1.1) + return YDomain(lower: -r, upper: r) + } + /// One run's curve into the line and body paths, split where this /// phone's view changes between live and not. The curve is one Steffen /// cubic over the whole run, so the colour changes and the shape does diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/PriceChart.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PriceChart.swift index 97bc5e3..c0a6fc1 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/PriceChart.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PriceChart.swift @@ -52,6 +52,14 @@ public struct PriceChart: Equatable, Sendable { public var tag: [String] /// Per slot: a negative price in words, and what the plan does then. public var doing: [String] + /// What the plan does at these prices, in one mono line: the first time + /// in the window the battery stores and the first it gives power, each + /// with the prices it meets, `STORES 09:15–15:30 AT 58–90 · GIVES + /// 17–20 AT 150–176 ÖRE`. Empty with no plan, or none for the battery. + public var planLine: String + /// The same with only its first part, for a width with no room for + /// both: the line drops a part rather than end in "…". + public var planLineShort: String public var currency: String /// The clock at each slot's edge: entry `i` starts slot `i`, the last /// ends the window. @@ -198,6 +206,11 @@ public struct PriceChart: Equatable, Sendable { doing.append(parts.joined(separator: " · ")) } + let planParts = plan.map { Self.planParts($0, grid: grid, minor: minor, currency: currency, clock: clock) } ?? [] + let unitWord = " " + PriceUnits.unit(currency).label.uppercased() + let planLine = planParts.isEmpty ? "" : planParts.joined(separator: " · ") + unitWord + let planLineShort = planParts.first.map { $0 + unitWord } ?? "" + var sums = [0.0], counts = [0] for m in minor { sums.append(sums[sums.count - 1] + (m ?? 0)) @@ -224,6 +237,8 @@ public struct PriceChart: Equatable, Sendable { figure: figures, tag: tags, doing: doing, + planLine: planLine, + planLineShort: planLineShort, currency: currency, edges: edges, sums: sums, @@ -232,6 +247,27 @@ public struct PriceChart: Equatable, Sendable { ) } + /// The plan's first run of storing and of giving power inside the + /// grid's window, in the order they come, each with the range of the + /// prices under it in whole units. + static func planParts(_ plan: PlanEntry, grid: SlotGrid, minor: [Double?], currency: String, clock: ZoneClock) -> [String] { + let runs = PlanGlance.events(plan, fromMs: grid.fromMs, toMs: grid.toMs).filter { $0.kind == .charge || $0.kind == .discharge } + var parts: [(startMs: Double, text: String)] = [] + for kind in [PlanGlance.Kind.charge, .discharge] { + guard let e = runs.first(where: { $0.kind == kind }) else { continue } + let a = max(e.startMs, grid.fromMs), b = min(e.endMs, grid.toMs) + guard b > a else { continue } + let prices = (0.. a }.compactMap { minor[$0] } + guard let lo = prices.min(), let hi = prices.max() else { continue } + func whole(_ m: Double) -> String { String(Int(PriceUnits.display(max(0, m), currency).rounded())) } + let range = whole(lo) == whole(hi) ? whole(lo) : whole(lo) + "–" + whole(hi) + let onHours = clock.minuteOfDay(a) % 60 == 0 && clock.minuteOfDay(b) % 60 == 0 + let span = onHours ? String(clock.label(a).prefix(2)) + "–" + String(clock.label(b).prefix(2)) : clock.label(a) + "–" + clock.label(b) + parts.append((a, (kind == .charge ? PriceWord.stores : PriceWord.gives).format(span, range))) + } + return parts.sorted { $0.startMs < $1.startMs }.map(\.text) + } + /// The same from the bars a summary or a widget moment already holds. public static func build(bars: [PriceStrip.Bar], currency: String, nowMs: Double, spanMs: Double = 24 * 3_600_000, everyHours: Int = 6, plan: PlanEntry? = nil, clock: ZoneClock) -> PriceChart { build(bars.map { PriceSlot(startMs: $0.startMs, durationMs: $0.endMs - $0.startMs, spotMinor: $0.minor, totalMinor: $0.minor) }, currency: currency, nowMs: nowMs, spanMs: spanMs, everyHours: everyHours, plan: plan, clock: clock) @@ -248,6 +284,8 @@ enum PriceWord: String, CaseIterable { case tomorrow = "TOMORROW" case noPriceYet = "No price yet" case paid = "Below zero: you are paid to use power" + case stores = "STORES %@ AT %@" + case gives = "GIVES %@ AT %@" var text: String { Words.bundle.localizedString(forKey: rawValue, value: rawValue, table: "Price") } diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/TouchIntent.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/TouchIntent.swift new file mode 100644 index 0000000..71cea80 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/TouchIntent.swift @@ -0,0 +1,100 @@ +import CoreGraphics +import Foundation + +/// What a finger on a chart means, decided before the chart moves. A touch +/// that lifts before it has moved 6 pt and before 150 ms is a tap: it opens +/// the chart and never moves the scrub, the hero or the haptics. Until one +/// of the two comes, the scrub waits; a second finger is a span at once. +public struct TouchIntent: Equatable, Sendable { + /// How far a finger moves before it scrubs. + public static let slop: CGFloat = 6 + /// How long a finger rests before it scrubs. + public static let holdMs: Double = 150 + + public enum State: Equatable, Sendable { + /// Down, not yet a scrub: nothing moves. + case waiting + case scrub + /// Lifted while waiting. + case tap + } + + public let start: CGPoint + public let startMs: Double + public private(set) var state: State = .waiting + /// How far the finger got from where it came down, the most so far. + public private(set) var travel: CGFloat = 0 + + public init(at start: CGPoint, ms: Double) { + self.start = start + startMs = ms + } + + /// The finger is at `p`, with `fingers` down. + @discardableResult + public mutating func moved(to p: CGPoint, atMs ms: Double, fingers: Int = 1) -> State { + travel = max(travel, hypot(p.x - start.x, p.y - start.y)) + if state == .waiting, fingers >= 2 || travel >= Self.slop || ms - startMs >= Self.holdMs { state = .scrub } + return state + } + + /// No event came, and the finger is still down at `ms`. + @discardableResult + public mutating func held(atMs ms: Double) -> State { + if state == .waiting, ms - startMs >= Self.holdMs { state = .scrub } + return state + } + + /// Every finger lifted: a tap when the scrub never started. + @discardableResult + public mutating func lifted() -> State { + if state == .waiting { state = .tap } + return state + } +} + +/// The system's back swipe starts at the screen's leading edge. A chart, +/// the dial and the style scale take no touch that starts within 20 pt of +/// it, so the swipe goes back instead of scrubbing. A finger that starts +/// further in and slides past the end still has the first slot, clamped. +public enum EdgeRule { + public static let edge: CGFloat = 20 + + /// How much of a view whose leading side sits `minX` from the screen's + /// leading edge leaves its touches to the system. + public static func inset(minX: CGFloat) -> CGFloat { + guard minX.isFinite else { return 0 } + return max(0, edge - minX) + } +} + +/// A finger on the plan dial's ring, inside the plan's scroll. The ring +/// scrubs on a move along it, or after the finger rests 0.1 s; a move that +/// is mostly up or down, or across the ring, is the page's, and it scrolls. +public enum RingIntent { + public static let holdMs: Double = 100 + + public enum Decision: Equatable, Sendable { + case scrub + /// The page's: this touch never scrubs. + case pass + } + + /// `start` and `now` are the finger's points, `centre` the ring's + /// centre, all in one space; `heldMs` the time since it came down. Nil + /// while it is too soon to tell. + public static func decide(start: CGPoint, now: CGPoint, centre: CGPoint, heldMs: Double) -> Decision? { + let dx = now.x - start.x, dy = now.y - start.y + let travel = hypot(dx, dy) + // A finger that rested scrubs, wherever it goes next. + if heldMs >= holdMs { return .scrub } + guard travel >= TouchIntent.slop else { return nil } + let rx = start.x - centre.x, ry = start.y - centre.y + let r = hypot(rx, ry) + guard r > 0 else { return .pass } + // Along the ring is across the radius. + let along = abs((dx * -ry + dy * rx) / r) + let across = abs((dx * rx + dy * ry) / r) + return along >= across && abs(dx) >= abs(dy) ? .scrub : .pass + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Price.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Price.strings index 8b5de8f..f5af5aa 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Price.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Price.strings @@ -6,3 +6,5 @@ "TOMORROW" = "TOMORROW"; "No price yet" = "No price yet"; "Below zero: you are paid to use power" = "Below zero: you are paid to use power"; +"STORES %@ AT %@" = "STORES %@ AT %@"; +"GIVES %@ AT %@" = "GIVES %@ AT %@"; diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Price.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Price.strings index 85e745b..9927df6 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Price.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Price.strings @@ -6,3 +6,5 @@ "TOMORROW" = "I MORGON"; "No price yet" = "Inget pris än"; "Below zero: you are paid to use power" = "Under noll: du får betalt för att använda el"; +"STORES %@ AT %@" = "LAGRAR %@ VID %@"; +"GIVES %@ AT %@" = "GER %@ VID %@"; diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/ChartFingerTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/ChartFingerTests.swift new file mode 100644 index 0000000..91e6e5d --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/ChartFingerTests.swift @@ -0,0 +1,232 @@ +import CoreGraphics +import Foundation +import Testing +@testable import FTWShared + +/// UX pass 3, U4: a tap enlarges, the edge goes back, no empty plot. The +/// finger's rules, the live scale from its own five minutes, Today's ghost +/// of the rest of the day, the forecast's curve, the car's fixed scale, the +/// gutter, the corner words and the price view's plan line. +@Suite struct ChartFingerTests { + static let clock = ChartGeometryTests.clock + static let words = ChartGeometryTests.words + static let size = CGSize(width: 350, height: 240) + + // MARK: Tap or scrub + + @Test func aTouchThatLiftsSoonAndStillIsATapAndNeverAScrub() { + var touch = TouchIntent(at: CGPoint(x: 100, y: 80), ms: 1_000) + #expect(touch.moved(to: CGPoint(x: 103, y: 82), atMs: 1_060) == .waiting) + #expect(touch.held(atMs: 1_140) == .waiting) + #expect(touch.lifted() == .tap) + } + + @Test func sixPointsOrAHundredAndFiftyMillisecondsStartTheScrub() { + var moved = TouchIntent(at: CGPoint(x: 100, y: 80), ms: 0) + #expect(moved.moved(to: CGPoint(x: 105.9, y: 80), atMs: 40) == .waiting) + #expect(moved.moved(to: CGPoint(x: 106, y: 80), atMs: 50) == .scrub) + // Once it scrubs, a lift is the end of a scrub, not a tap. + #expect(moved.lifted() == .scrub) + + var held = TouchIntent(at: CGPoint(x: 100, y: 80), ms: 0) + #expect(held.held(atMs: 149) == .waiting) + #expect(held.held(atMs: 150) == .scrub) + + var two = TouchIntent(at: CGPoint(x: 100, y: 80), ms: 0) + #expect(two.moved(to: CGPoint(x: 100, y: 80), atMs: 10, fingers: 2) == .scrub) + } + + @Test func theTouchesNearTheLeadingEdgeAreTheBackSwipes() { + // The node chart sits 4 pt in, the price 16, the style scale 16. + #expect(EdgeRule.inset(minX: 4) == 16) + #expect(EdgeRule.inset(minX: 16) == 4) + #expect(EdgeRule.inset(minX: 20) == 0) + #expect(EdgeRule.inset(minX: 120) == 0) + #expect(EdgeRule.inset(minX: .nan) == 0) + } + + @Test func theRingScrubsAlongItAndTheScrollTakesUpAndDown() { + let c = CGPoint(x: 170, y: 170) + let top = CGPoint(x: 170, y: 20), left = CGPoint(x: 20, y: 170), upperRight = CGPoint(x: 276, y: 64) + // Too soon to tell. + #expect(RingIntent.decide(start: top, now: CGPoint(x: 173, y: 21), centre: c, heldMs: 40) == nil) + // Along the ring at the top: sideways. + #expect(RingIntent.decide(start: top, now: CGPoint(x: 180, y: 21), centre: c, heldMs: 40) == .scrub) + // At the top, straight down is across the ring: the page's. + #expect(RingIntent.decide(start: top, now: CGPoint(x: 171, y: 34), centre: c, heldMs: 40) == .pass) + // At the left, up or down runs along the ring but is a scroll. + #expect(RingIntent.decide(start: left, now: CGPoint(x: 21, y: 150), centre: c, heldMs: 40) == .pass) + // Half way round, along the ring and as much sideways as down. + #expect(RingIntent.decide(start: upperRight, now: CGPoint(x: 284, y: 72), centre: c, heldMs: 40) == .scrub) + // A finger that rests 0.1 s scrubs, wherever it goes next. + #expect(RingIntent.decide(start: left, now: left, centre: c, heldMs: 100) == .scrub) + #expect(RingIntent.decide(start: left, now: CGPoint(x: 21, y: 150), centre: c, heldMs: 120) == .scrub) + } + + // MARK: The live scale + + static let liveSize = CGSize(width: 350, height: 280) + + static func steady(_ watts: (Int) -> Double, seconds: Int = 300, now: Double = 1_000_000) -> [LiveSample] { + (0.. LiveSeries { + LiveSeries.build(frames, sourceGapMs: 3_500, windowMs: 300_000, nowMs: frames.last!.t, size: liveSize, previous: previous, floor: 3_000, restW: 42) + } + + @Test func a450WattBatteryFillsMostOfThePlot() throws { + let series = Self.build(Self.steady { 440 + Double($0 % 3) * 5 }) + #expect(series.domain == YDomain(lower: 0, upper: 500)) + #expect(!series.atRest) + let head = try #require(series.head) + // The reading spans at least half the plot, from zero at the foot. + #expect(series.map.baseline - head.y >= Self.liveSize.height / 2) + // The old rule, on the node's 3 kW floor, kept it in the bottom sixth. + let floored = LiveSeries.build(Self.steady { 440 + Double($0 % 3) * 5 }, sourceGapMs: 3_500, windowMs: 300_000, nowMs: 1_000_000, size: Self.liveSize, floor: 3_000) + #expect(floored.domain.upper == 3_000) + } + + @Test func oneSignKeepsZeroAtTheEdgeAndABlipAtRestOpensNoSecondSide() { + let series = Self.build(Self.steady { $0 == 120 ? -6 : 2_400 }) + #expect(series.domain.lower == 0 && series.domain.upper == 3_000) + let both = Self.build(Self.steady { $0 < 150 ? 1_200 : -800 }) + #expect(both.domain == YDomain(lower: -1_000, upper: 1_500)) + } + + @Test func aSourceAtRestPutsZeroMidPlot() { + let night = Self.build(Self.steady { _ in 0 }) + #expect(night.atRest) + #expect(night.domain == YDomain(lower: -50, upper: 50)) + #expect(abs(night.map.baseline - Self.liveSize.height / 2) < 1e-9) + // A battery that idles a few watts either way rests too. + #expect(Self.build(Self.steady { Double($0 % 7) * 6 - 18 }).atRest) + // The rest scale is not held once the source moves; the scale + // grows at once and holds while the data fills it. + let woke = Self.build(Self.steady { $0 < 280 ? 0 : 900 }, previous: night.domain) + #expect(!woke.atRest && woke.domain == YDomain(lower: 0, upper: 1_000)) + let held = Self.build(Self.steady { _ in 700 }, previous: woke.domain) + #expect(held.domain == woke.domain) + // The trace without a rest keeps the node's floor: the watch's. + let watch = LiveSeries.build(Self.steady { _ in 0 }, sourceGapMs: 3_500, windowMs: 300_000, nowMs: 1_000_000, size: Self.liveSize, floor: 3_000) + #expect(!watch.atRest && watch.domain == YDomain(lower: 0, upper: 3_000)) + } + + // MARK: Gutter and corner words + + @Test func theGutterHoldsTheWidestLabelWhole() { + // 0.4.3 gave 32 pt at 10 pt, and "20 kW" lost its W. + let gutter = ChartGutter.trailing(ChartGutter.scaleLabels(YDomain(lower: -15_000, upper: 20_000)), size: 10) + #expect(gutter >= ChartGutter.gap + 5 * 6.18) + #expect(gutter < 40) + #expect(ChartGutter.scaleLabels(YDomain(lower: 0, upper: 3_000)) == ["3 kW"]) + #expect(ChartGutter.trailing(["1,5 kW"], size: 10) > ChartGutter.trailing(["3 kW"], size: 10)) + } + + @Test func aDirectionWordStaysOffTheLine() { + let map = YMap(domain: YDomain(lower: -1_000, upper: 1_000), height: 200) + // A grid that sells 950 W at the left runs through "↓ SELLING". + let selling = (0..<40).map { ChartReach(x: CGFloat($0) * 9, low: $0 < 8 ? -950 : 100, high: $0 < 8 ? -950 : 100) } + #expect(!CornerWords.clear("↓ SELLING", size: 10, top: false, data: selling, map: map)) + #expect(CornerWords.clear("↑ BUYING", size: 10, top: true, data: selling, map: map)) + // The same dip further right leaves the word alone. + let later = (0..<40).map { ChartReach(x: CGFloat($0) * 9, low: $0 > 20 ? -950 : 100, high: 100) } + #expect(CornerWords.clear("↓ SELLING", size: 10, top: false, data: later, map: map)) + } + + // MARK: Today's ghost + + static func quarters(from: Double, count: Int, _ watts: (Int) -> Double) -> [AheadPicture.Mark] { + (0.. Double) -> [AheadPicture.Mark] { + (0..= 4_000 && picture.domain.lower <= -3_000) + let plan = try #require(picture.layers[0].ghost) + #expect(plan.kind == .plan && plan.grid.stepMs == 900_000) + #expect(plan.grid.fromMs == from && plan.grid.toMs == to) + #expect(plan.grid.firstMs == ChartGeometryTests.at(14, 6)) + let at10 = try #require(plan.grid.index(atMs: ChartGeometryTests.at(14, 10))) + #expect(plan.values[at10] == 2_500) + // On the chart's own x: 10:00 sits at 10/24 of the width. + #expect(abs(plan.grid.x(atMs: ChartGeometryTests.at(14, 10), width: 240) - 100) < 1e-9) + let forecast = try #require(picture.layers[1].ghost) + #expect(forecast.kind == .forecast && !forecast.line.isEmpty) + // The finger still stops at the last measured slot: a plan is not + // a reading. + #expect(picture.limit == 12) + // No plan, no ghost; and 48 h, which ends at now, has none. + let bare = HistoryPicture.build([("battery", measured)], fromMs: from, toMs: to, nowMs: now, size: Self.size, clock: Self.clock, words: Self.words) + #expect(bare.layers[0].ghost == nil) + let twoDays = HistoryPicture.twoDays(now) + let past = HistoryPicture.build([("battery", measured)], ahead: ["battery": (.plan, battery.filter { $0.endMs <= now })], fromMs: twoDays.fromMs, toMs: twoDays.toMs, nowMs: now, size: Self.size, clock: Self.clock, words: Self.words) + #expect(past.layers[0].ghost == nil) + } + + // MARK: Ahead + + @Test func anHourlyForecastBendsThroughItsHoursInsteadOfStepping() { + let now = ChartGeometryTests.at(14, 10, 7) + let sun = Self.hours(from: ChartGeometryTests.at(14, 10), count: 12) { h in 600 + Double(h) * 300 } + let prices = Self.quarters(from: ChartGeometryTests.at(14, 10), count: 48) { _ in 50 }.map { (startMs: $0.startMs, endMs: $0.endMs, minor: $0.watts) } + let picture = AheadPicture.build(kind: .forecast, slots: sun.map { ($0.startMs, $0.endMs, Optional($0.watts)) }, prices: prices, nowMs: now, size: Self.size, clock: Self.clock) + // The grid is the prices' quarters; the line is the forecast's hours. + #expect(picture.grid.stepMs == 900_000) + let points = picture.line.elements.filter { if case .move = $0 { return false } else { return true } }.count + // One point at the left edge, then one per hour: 12 curves, not 47. + #expect(points == 12) + #expect(picture.marks.count == 12) + } + + @Test func theCarsScaleIsTheChargersMostWithOrWithoutAPlan() { + let now = ChartGeometryTests.at(14, 10, 7) + let night: [(startMs: Double, endMs: Double, watts: Double?)] = [(ChartGeometryTests.at(14, 18), ChartGeometryTests.at(14, 21), 11_000)] + let planned = AheadPicture.build(kind: .plan, slots: night, prices: [], nowMs: now, size: Self.size, floor: 3_700, top: 11_000, clock: Self.clock) + let none = AheadPicture.build(kind: .plan, slots: [], prices: [], nowMs: now, size: Self.size, floor: 3_700, top: 11_000, clock: Self.clock) + #expect(planned.domain == YDomain(lower: 0, upper: 11_000)) + #expect(none.domain == planned.domain) + // Without a charger's most, the scale fits the plan as before. + let fitted = AheadPicture.build(kind: .plan, slots: night, prices: [], nowMs: now, size: Self.size, floor: 3_700, clock: Self.clock) + #expect(fitted.domain.upper == 15_000) + } + + // MARK: The price view's plan line + + @Test func thePlanLineSaysWhenTheBatteryStoresAndGivesAndAtWhatPrice() { + let from = ChartGeometryTests.at(14, 9) + let slots = (0..<96).map { i -> PriceSlot in + let p: Double = i < 26 ? 58 + Double(i) : i >= 32 && i < 44 ? 150 + Double(i - 32) * 2 : 120 + return PriceSlot(startMs: from + Double(i) * 900_000, durationMs: 900_000, spotMinor: p, totalMinor: p) + } + // Stores 09:15–15:30, gives 17:00–20:00. + let battery = (1..<26).map { PlanEntry.Slot(startMs: from + Double($0) * 900_000, durationMs: 900_000, batteryW: 2_500, reason: "") } + + (32..<44).map { PlanEntry.Slot(startMs: from + Double($0) * 900_000, durationMs: 900_000, batteryW: -3_000, reason: "") } + let plan = PlanEntry(battery: battery, charges: [], prices: [], currency: "SEK", boxStale: false) + let chart = PriceChart.build(slots, currency: "SEK", nowMs: from, plan: plan, clock: Self.clock) + let unit = PriceUnits.unit("SEK").label.uppercased() + #expect(chart.planLine == PriceWord.stores.format("09:15–15:30", "59–83") + " · " + PriceWord.gives.format("17–20", "150–172") + " " + unit) + // Where both do not fit, the first part alone, never "…". + #expect(chart.planLineShort == PriceWord.stores.format("09:15–15:30", "59–83") + " " + unit) + #expect(PriceChart.build(slots, currency: "SEK", nowMs: from, clock: Self.clock).planLine.isEmpty) + #expect(PriceChart.build(slots, currency: "SEK", nowMs: from, clock: Self.clock).planLineShort.isEmpty) + let idle = PlanEntry(battery: [], charges: [], prices: [], currency: "SEK", boxStale: false) + #expect(PriceChart.build(slots, currency: "SEK", nowMs: from, plan: idle, clock: Self.clock).planLine.isEmpty) + } +} diff --git a/appleApp/FTWUITests/ChartFingerTests.swift b/appleApp/FTWUITests/ChartFingerTests.swift new file mode 100644 index 0000000..f4774aa --- /dev/null +++ b/appleApp/FTWUITests/ChartFingerTests.swift @@ -0,0 +1,83 @@ +import XCTest + +/// The finger on the charts (UX pass 3, U4). A tap on a node chart opens +/// it on the whole screen and a drag only scrubs; a swipe up that starts +/// on the plan dial's ring scrolls the plan, and once the ring scrubs, the +/// page holds still. +/// +/// The back swipe from the screen's edge is not here: a swipe the test +/// runner makes from the edge does not go back even where no chart is, so +/// it proves nothing. That and a real thumb's timing need a device. +final class ChartFingerTests: XCTestCase { + override func setUp() { + continueAfterFailure = false + } + + @MainActor + private func launch(_ more: [String]) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = [ + "-FTWDemo", "YES", + "-AppleLanguages", "(en)", "-AppleLocale", "en_US", + "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL", + ] + more + app.launch() + return app + } + + @MainActor + func testATapOnEachChartEnlargesItAndADragOnlyScrubs() throws { + for period in ["live", "today", "ahead"] { + let app = launch(["-FTWNode", "battery", "-FTWPeriod", period]) + let chart = app.descendants(matching: .any)[period == "live" ? "node.live.chart" : "node.period.chart"] + XCTAssertTrue(chart.waitForExistence(timeout: 15), "no \(period) chart") + Thread.sleep(forTimeInterval: 2) + let close = app.buttons["node.chart.close"] + // A drag across the chart scrubs and opens nothing. + chart.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.55)) + .press(forDuration: 0.05, thenDragTo: chart.coordinate(withNormalizedOffset: CGVector(dx: 0.7, dy: 0.55)), withVelocity: .default, thenHoldForDuration: 0.2) + XCTAssertFalse(close.waitForExistence(timeout: 1.5), "a drag on \(period) opened the chart") + // A tap opens it. + chart.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.55)).tap() + XCTAssertTrue(close.waitForExistence(timeout: 3), "a tap on \(period) did not open the chart") + close.tap() + XCTAssertTrue(close.waitForNonExistence(timeout: 3)) + app.terminate() + } + } + + @MainActor + func testTheDialRingScrollsUpAndDownAndHoldsThePageWhileItScrubs() throws { + let app = launch(["-FTWTab", "plan", "-FTWPlanStyle", "dial"]) + let dial = app.descendants(matching: .any)["plan.dial"] + XCTAssertTrue(dial.waitForExistence(timeout: 15)) + Thread.sleep(forTimeInterval: 2) + let frame = dial.frame + let side = min(frame.width, frame.height) + // Points on the ring, by the clock, in the dial's frame. + func ring(_ hour: Double) -> XCUICoordinate { + let a = hour / 12 * 2 * .pi + let r = side * 0.4 + return dial.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: frame.width / 2 + r * sin(a), dy: frame.height / 2 - r * cos(a))) + } + // A finger that rests on the ring, then sweeps round to three + // o'clock and down: the dial scrubs and the page holds still. + let top = dial.frame.minY + ring(1).press(forDuration: 0.3, thenDragTo: ring(4), withVelocity: .slow, thenHoldForDuration: 0.2) + XCTAssertEqual(dial.frame.minY, top, accuracy: 1, "the page moved under a scrub") + // A quick move along the top of the ring scrubs too, at once. + ring(0).press(forDuration: 0.02, thenDragTo: ring(0).withOffset(CGVector(dx: 60, dy: 8)), withVelocity: .slow, thenHoldForDuration: 0.2) + XCTAssertEqual(dial.frame.minY, top, accuracy: 1, "the page moved under a scrub") + // A swipe up from the ring at nine o'clock scrolls the plan. + ring(9).press(forDuration: 0.02, thenDragTo: ring(9).withOffset(CGVector(dx: 0, dy: -220)), withVelocity: .fast, thenHoldForDuration: 0) + Thread.sleep(forTimeInterval: 1) + guard dial.frame.minY >= top - 40 else { return } + // It did not: the same swipe under the dial tells whether the page + // scrolls at all. On a tall phone the whole plan fits. + let below = app.windows.firstMatch.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: frame.midX, dy: frame.maxY + 60)) + below.press(forDuration: 0.02, thenDragTo: below.withOffset(CGVector(dx: 0, dy: -220)), withVelocity: .fast, thenHoldForDuration: 0) + Thread.sleep(forTimeInterval: 1) + if dial.frame.minY >= top - 40 { throw XCTSkip("The whole plan fits this screen, so nothing scrolls; the ring is tried on a smaller phone.") } + XCTFail("a swipe up on the ring did not scroll the plan, one under the dial did") + } +}