diff --git a/CLAUDE.md b/CLAUDE.md index abf14bb..d0aa018 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,6 +112,12 @@ native, and smooth enough to impress. changed, and compute the points once per change. - **Every chart takes a finger.** The point under it is marked, and the line under the chart says what was there. Two fingers give the span. +- **Charts draw on the engine.** Geometry is a value built once per data + change (`FTWShared/Charts`, tested). A chart is one Canvas + (`ChartSurface`) that never sees the finger and a small `ScrubOverlay` + that alone redraws under it, reading a `ScrubModel`. About one point + per 6 pt of width, and every stroke joins round (`ChartStrokeTests`). + Widgets draw the same shapes as still paths. - **Haptics are sparse.** `.selection` per slot while scrubbing, at most about 25 a second. `.impact(weight: .light)` at now, the edges of the cheapest window, the day's high and low, the car's goal and the ends of diff --git a/appleApp/FTW-Info.plist b/appleApp/FTW-Info.plist index 0682a4c..e13f671 100644 --- a/appleApp/FTW-Info.plist +++ b/appleApp/FTW-Info.plist @@ -14,6 +14,8 @@ fetch + CADisableMinimumFrameDurationOnPhone + CFBundleLocalizations en diff --git a/appleApp/FTW/UI/FrameGapProbe.swift b/appleApp/FTW/UI/FrameGapProbe.swift new file mode 100644 index 0000000..7690b0f --- /dev/null +++ b/appleApp/FTW/UI/FrameGapProbe.swift @@ -0,0 +1,52 @@ +#if DEBUG +import os +import QuartzCore +import SwiftUI + +/// A debug-only measure of late frames, for where Instruments' Animation +/// Hitches cannot run (the simulator says it is not supported there). +/// With `-FTWFrameLog YES` it wakes at the display's rate +/// and logs, once a second, how many milliseconds the frames came late: +/// each gap longer than 1.5 frames counts for its time past one frame. It +/// sees the main thread's frames, not the render server's. +struct FrameGapProbe: View { + static var enabled: Bool { UserDefaults.standard.bool(forKey: "FTWFrameLog") } + + var body: some View { + TimelineView(.animation) { context in + Color.clear.onChange(of: context.date) { FrameGaps.shared.frame(CACurrentMediaTime()) } + } + .frame(width: 1, height: 1) + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + +@MainActor +final class FrameGaps { + static let shared = FrameGaps() + private static let log = Logger(subsystem: "energy.ftw.app", category: "frames") + private var last: CFTimeInterval? + private var windowStart: CFTimeInterval? + private var gaps: [Double] = [] + + func frame(_ t: CFTimeInterval) { + defer { last = t } + guard let last else { + windowStart = t + return + } + gaps.append(t - last) + guard let start = windowStart, t - start >= 1 else { return } + // The median gap is the display's own frame; a gap past 1.5 of + // them counts for the time beyond one frame. + let frame = gaps.sorted()[gaps.count / 2] + let late = gaps.filter { $0 > frame * 1.5 }.reduce(0) { $0 + ($1 - frame) } * 1000 + let seconds = t - start + let draws = ChartDraws.take() + Self.log.info("frames \(self.gaps.count) in \(seconds, format: .fixed(precision: 2)) s, late \(late / seconds, format: .fixed(precision: 2)) ms/s, worst gap \((self.gaps.max() ?? 0) * 1000, format: .fixed(precision: 1)) ms, frame \(frame * 1000, format: .fixed(precision: 1)) ms, surface draws \(draws.surface), slot changes \(draws.changes), overlay draws \(draws.overlay), overlay draw \(draws.overlayMs, format: .fixed(precision: 2)) ms, worst \(draws.worstMs, format: .fixed(precision: 2)) ms") + windowStart = t + gaps.removeAll(keepingCapacity: true) + } +} +#endif diff --git a/appleApp/FTW/UI/PriceView.swift b/appleApp/FTW/UI/PriceView.swift index 52d75e2..d6418e7 100644 --- a/appleApp/FTW/UI/PriceView.swift +++ b/appleApp/FTW/UI/PriceView.swift @@ -1,18 +1,27 @@ -import Charts import FTWKit import SwiftUI -/// The price: what power costs now, in one header row, and the hours ahead -/// as one line coloured by level, with now, the cheapest hours and the -/// average marked. A finger on the line says the price there and what the -/// plan does then. One screen. +/// 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. struct PriceView: View { let home: HomeModels + @State private var scrub = ScrubModel() + @Environment(\.dynamicTypeSize) private var typeSize var body: some View { - FitScreen(spacing: 10) { fill in - PriceBody(site: home.site, prices: home.prices, plan: home.nodes.planShare, fill: fill) + Group { + // The accessibility sizes scroll; every other size is one fixed + // 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) } + } else { + page(fill: true) + } } + .background(Theme.ground) .navigationTitle(L("Electricity price")) .navigationBarTitleDisplayMode(.inline) .onAppear { @@ -25,176 +34,199 @@ struct PriceView: View { home.nodes.plan.deactivate() } } + + private func page(fill: Bool) -> some View { + PriceBody(site: home.site, prices: home.prices, plan: home.nodes.planShare, scrub: scrub, fill: fill) + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 16) + .frame(maxWidth: 640, alignment: .topLeading) + .frame(maxWidth: .infinity, maxHeight: fill ? .infinity : nil, alignment: .top) + } } +/// Reads the minute, the prices and the plan, and hands them on as values, +/// so the page below redraws once a minute at most. private struct PriceBody: View { let site: SiteModel let prices: PriceModel let plan: PlanShare + let scrub: ScrubModel let fill: Bool - @State private var readout: ChartReadout? var body: some View { - // The chart marks the minute: the now dot moves once a minute. - let minute = (site.nowMs / 60_000).rounded(.down) * 60_000 if let window = prices.prices { - let summary = PriceStrip.summary(window, nowMs: minute) - PriceHeader(window: window, current: summary.current) - VStack(alignment: .leading, spacing: 8) { - if summary.bars.isEmpty { - Text(L("No prices ahead yet.")) - .font(.footnote) - .foregroundStyle(Theme.fgMuted) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - PriceChart(bars: summary.bars, mean: summary.meanMinor, cheapest: summary.cheapest, currency: window.currency, nowMs: minute, plan: plan.entry, onScrub: { readout = $0 }) - .equatable() - .frame(minHeight: fill ? 160 : 220) - .accessibilityIdentifier("price.chart") - } - ReadoutLine(readout: readout, otherwise: line(summary, window.currency)) - } - .padding(.vertical, 10) - .padding(.horizontal, 12) - .frame(maxHeight: fill ? .infinity : nil) - .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) - .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1)) + PricePage(window: window, minuteMs: site.minuteMs, plan: plan.entry, scrub: scrub, fill: fill) + .equatable() } else { Text(L("Reading the prices from your box…")) - .font(.footnote) - .foregroundStyle(Theme.fgMuted) + .font(.subheadline) + .foregroundStyle(Theme.ink2) + .frame(maxWidth: .infinity, alignment: .leading) } } +} - /// "Cheapest 02–04 · 59.0 öre/kWh on average" - private func line(_ summary: PriceStrip.Summary, _ currency: String) -> String { - guard let c = summary.cheapest else { return " " } - return NodeWords.format("Cheapest %@ · %@ on average", Readouts.span(c.startMs, c.endMs), Readouts.price(c.meanMinor, currency)) +private struct PricePage: View, Equatable { + let window: Prices + let minuteMs: Double + let plan: PlanEntry? + let scrub: ScrubModel + let fill: Bool + + 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 + } + + var body: some View { + // Built once per minute or change of prices or plan. + let chart = PriceChart.build(window.slots, currency: window.currency, nowMs: minuteMs, plan: plan, clock: ZoneClock.of(.current)) + let included = PriceGlance.text(PriceGlance.included(window.slots)) + VStack(alignment: .leading, spacing: 0) { + PriceHero(chart: chart, zone: window.zone, scrub: scrub) + 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. + PriceBarsChart(chart: chart, scrub: scrub) + .containerRelativeFrame(.vertical) { height, _ in min(max(height * 0.38, 240), 340) } + .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 fill { Spacer(minLength: 0) } + } } } -/// "Elpris 82,0 öre/kWh" in one row, and the zone and what the price holds -/// under it. -private struct PriceHeader: View { - let window: Prices - let current: PriceSlot? +/// "128 öre/kWh", and under it "Now · SE3". Under a finger it gives way to +/// the slot's price and time with no animation; on lift it comes back with +/// the spring. +private struct PriceHero: View { + let chart: PriceChart + let zone: String + let scrub: ScrubModel + @ScaledMetric(relativeTo: .largeTitle) private var figureSize: CGFloat = 60 + @ScaledMetric(relativeTo: .title2) private var unit: CGFloat = 22 var body: some View { - let unit = PriceUnits.unit(window.currency) + let i = scrub.index ?? (scrub.span == nil ? chart.nowIndex : nil) + let spanMean = scrub.span.flatMap(chart.mean) + // A slot with no price yet says so in quiet ink, never a figure. + let priced = spanMean != nil || (i.map { chart.minor[$0] != nil } ?? false) + let figure = spanMean.map(chart.figure) ?? i.map { chart.figure[$0] } ?? "—" + let word = scrub.span.map(chart.when) ?? scrub.index.map { chart.when[$0] } ?? (zone.isEmpty ? L("Now") : NodeWords.format("Now · %@", zone)) VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(L("Electricity price")) - .font(.headline) - .foregroundStyle(Theme.fg) - Spacer(minLength: 8) - Text(current.map { AppWords.decimal(PriceStrip.text($0.totalMinor, window.currency)) } ?? "—") - .font(.system(size: 34, weight: .semibold, design: .rounded).monospacedDigit()) - .foregroundStyle(Theme.fg) + Text(figure) + .font(Theme.figure(figureSize)) + .tracking(-1) + .foregroundStyle(priced ? Theme.ink : Theme.ink3) .contentTransition(.numericText()) .lineLimit(1) + .minimumScaleFactor(0.5) + .layoutPriority(1) + Text(chart.unit) + .font(.system(size: unit, weight: .medium)) + .foregroundStyle(Theme.ink2) + .lineLimit(1) .minimumScaleFactor(0.6) - Text(unit.perKwh) - .font(.footnote) - .foregroundStyle(Theme.fgDim) } - Text([window.zone.isEmpty ? nil : window.zone, PriceGlance.text(PriceGlance.included(window.slots))].compactMap { $0 }.joined(separator: " · ")) - .font(.footnote) - .foregroundStyle(Theme.fgDim) + Text(word) + .font(.headline) + .foregroundStyle(Theme.ink2) + .monospacedDigit() .lineLimit(1) - .minimumScaleFactor(0.85) } - .padding(.vertical, 10) - .padding(.horizontal, 14) .frame(maxWidth: .infinity, alignment: .leading) - .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) - .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1)) + .transaction { $0.animation = scrub.isDown ? nil : Theme.spring } + .dynamicTypeSize(...DynamicTypeSize.accessibility1) .accessibilityElement(children: .combine) } } -/// The app's price chart: the shared line, a finger that reads it, and -/// the line growing into shape when prices change. -struct PriceChart: View, Equatable { - let bars: [PriceStrip.Bar] - let mean: Double? - let cheapest: PriceStrip.Block? - let currency: String - let nowMs: Double - let plan: PlanEntry? - var onScrub: (ChartReadout?) -> Void = { _ in } - @State private var selected: Date? - @State private var range: ClosedRange? - - nonisolated static func == (a: Self, b: Self) -> Bool { - a.bars == b.bars && a.mean == b.mean && a.cheapest == b.cheapest && a.currency == b.currency && a.nowMs == b.nowMs && a.plan == b.plan - } +/// The surface and the finger's overlay on the same frame. Mono labels are +/// sized with the text and stop growing at accessibility1. +private struct PriceBarsChart: View { + let chart: PriceChart + let scrub: ScrubModel + @ScaledMetric(relativeTo: .caption2) private var axis: CGFloat = 10 + @ScaledMetric(relativeTo: .caption2) private var kicker: CGFloat = 10.5 + @ScaledMetric(relativeTo: .caption) private var tag: CGFloat = 11 var body: some View { - let points = bars.map { HistoryPlot.Point(t: $0.startMs, v: PriceUnits.display($0.minor, currency), kind: .measured, run: 0, id: $0.startMs) } - let ends = Dictionary(bars.map { ($0.startMs, $0.endMs) }, uniquingKeysWith: { a, _ in a }) - MovingPoints(points: points) { drawn, _, _ in - PriceLineChart( - points: drawn.map { PricePoint(startMs: $0.id, endMs: ends[$0.id] ?? $0.id, value: $0.v) }, - nowMs: nowMs, - mean: mean.map { PriceUnits.display($0, currency) }, - cheapest: cheapest.map { $0.startMs...$0.endMs }, - cheapestLabel: L("Cheapest"), - meanLabel: mean.map { NodeWords.format("avg %@", AppWords.decimal(PriceStrip.text($0, currency))) }, - palette: .ink, - zone: .current, - selected: selected + let frame = PriceBarsDrawing.frame(axis: axis, kicker: kicker, tag: tag) + ZStack { + ChartSurface(drawing: PriceBarsDrawing(chart: chart, frame: frame, axis: axis, kicker: kicker)) + .equatable() + ScrubOverlay( + track: ScrubTrack(grid: chart.grid, domain: chart.domain, values: chart.shown, tags: chart.tag, landmarks: chart.landmarks, bars: true), + frame: frame, + model: scrub, + tagSize: tag, + spanTag: { [chart] r in chart.when(r) + " " + (chart.mean(r).map { NodeWords.format("avg %@", chart.figure($0)) } ?? "—") } ) } - .chartScrub(selected: $selected, range: $range, slot: { d in - SlotLookup.clamped(bars, at: d.timeIntervalSince1970 * 1000, start: \.startMs).map { bars[$0].startMs } - }, landmark: { slot in landmarks.contains(slot) }) - .onChange(of: selected) { _, _ in report() } - .onChange(of: range) { _, _ in report() } - .dynamicTypeSize(...chartTextCap) - .accessibilityChartDescriptor(PriceDescriptor(bars: bars, currency: currency)) } +} - private var chartPrices: [ChartPrice] { bars.map { ChartPrice(startMs: $0.startMs, endMs: $0.endMs, minor: $0.minor) } } +/// 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 { + let chart: PriceChart + let included: String + let planned: Bool + let scrub: ScrubModel - /// Now, the edges of the cheapest hours, the dearest and the cheapest. - private var landmarks: Set { - var out = Set() - if let now = bars.first(where: { $0.current }) { out.insert(now.startMs) } - if let c = cheapest { - out.insert(c.startMs) - if let last = bars.last(where: { $0.startMs < c.endMs }) { out.insert(last.startMs) } + var body: some View { + ZStack(alignment: .topLeading) { + // Two lines held open, so a longer readout pushes nothing. + Text(verbatim: "\n") + Text(text) + .accessibilityIdentifier("chart.readout") } - if let hi = bars.max(by: { $0.minor < $1.minor }) { out.insert(hi.startMs) } - if let lo = bars.min(by: { $0.minor < $1.minor }) { out.insert(lo.startMs) } - return out + .font(.subheadline) + .foregroundStyle(Theme.ink2) + .lineLimit(2, reservesSpace: true) + .frame(maxWidth: .infinity, alignment: .leading) + .transaction { $0.animation = nil } } - private func report() { - if let range { - onScrub(Readouts.price(chartPrices, currency: currency, range: range.lowerBound.timeIntervalSince1970 * 1000...range.upperBound.timeIntervalSince1970 * 1000)) - } else if let selected { - onScrub(Readouts.price(chartPrices, currency: currency, plan: plan, at: selected.timeIntervalSince1970 * 1000)) - } else { - onScrub(nil) + private var text: String { + if let r = scrub.span { + guard let mean = chart.mean(r) else { return included } + let average = NodeWords.format("average %@", chart.figure(mean) + " " + chart.unit) + return mean < 0 ? PriceChart.paidWord + " · " + average : average } + guard let i = scrub.index else { return included } + let doing = chart.doing[i] + if !doing.isEmpty { return doing } + return planned ? L("Nothing planned then") : included } } /// The prices as VoiceOver reads them: a slot a step. struct PriceDescriptor: AXChartDescriptorRepresentable { - let bars: [PriceStrip.Bar] - let currency: String + let chart: PriceChart func makeChartDescriptor() -> AXChartDescriptor { - let values = bars.map { PriceUnits.display($0.minor, currency) } + let values = chart.shown.compactMap { $0 } let lo = min(0, values.min() ?? 0), hi = max(lo + 1, values.max() ?? 1) - let start = bars.first?.startMs ?? 0, end = max(start + 1, bars.last?.endMs ?? 1) - let unit = PriceUnits.unit(currency).perKwh + let start = chart.grid.fromMs, end = max(start + 1, chart.grid.toMs) let x = AXNumericDataAxisDescriptor(title: L("Time"), range: start...end, gridlinePositions: []) { Readouts.clock($0) } - let y = AXNumericDataAxisDescriptor(title: L("Electricity price"), range: lo...hi, gridlinePositions: []) { "\(AppWords.decimal(PowerFormat.fixed($0, 1))) \(unit)" } - let series = AXDataSeriesDescriptor(name: L("Electricity price"), isContinuous: false, dataPoints: bars.map { - AXDataPoint(x: $0.startMs, y: PriceUnits.display($0.minor, currency)) - }) - return AXChartDescriptor(title: L("Electricity price"), summary: nil, xAxis: x, yAxis: y, additionalAxes: [], series: [series]) + let y = AXNumericDataAxisDescriptor(title: L("Electricity price"), range: lo...hi, gridlinePositions: []) { "\(AppWords.decimal(PowerFormat.fixed($0, 1))) \(chart.unit)" } + let points = chart.shown.indices.compactMap { i in + chart.shown[i].map { AXDataPoint(x: chart.grid.startMs(i), y: $0, additionalValues: [], label: chart.doing[i].isEmpty ? nil : chart.doing[i]) } + } + let series = AXDataSeriesDescriptor(name: L("Electricity price"), isContinuous: false, dataPoints: points) + return AXChartDescriptor(title: L("Electricity price"), summary: chart.cheapestText.isEmpty ? nil : chart.cheapestText, xAxis: x, yAxis: y, additionalAxes: [], series: [series]) } } diff --git a/appleApp/FTW/UI/RootView.swift b/appleApp/FTW/UI/RootView.swift index b646a7e..3c00f3d 100644 --- a/appleApp/FTW/UI/RootView.swift +++ b/appleApp/FTW/UI/RootView.swift @@ -20,6 +20,9 @@ struct RootView: View { .tint(Theme.accent) .onAppear { LaunchClock.mark("first frame") } .cloudConsent(app) + #if DEBUG + .overlay(alignment: .topLeading) { if FrameGapProbe.enabled { FrameGapProbe() } } + #endif } } diff --git a/appleApp/FTWCharts/ChartSurface.swift b/appleApp/FTWCharts/ChartSurface.swift new file mode 100644 index 0000000..a97e1c0 --- /dev/null +++ b/appleApp/FTWCharts/ChartSurface.swift @@ -0,0 +1,75 @@ +import FTWShared +import SwiftUI + +/// What a chart draws, as a value: equal values draw the same picture, so +/// the surface redraws only when its data changes, never under a finger. +protocol ChartDrawing: Equatable { + func draw(in g: inout GraphicsContext, size: CGSize) +} + +/// Where the plot sits inside a chart's frame: room above for the scrub +/// tag, below for the time axis, and a gutter on the right for the mono +/// labels. The surface and the overlay take the same frame, so a finger +/// and a bar meet at the same x. +struct ChartFrame: Equatable { + var top: CGFloat = 0 + var bottom: CGFloat = 0 + var trailing: CGFloat = 0 + + func plot(in size: CGSize) -> CGRect { + CGRect(x: 0, y: top, width: max(0, size.width - trailing), height: max(0, size.height - top - bottom)) + } +} + +/// One chart's picture: a single Canvas that never sees the scrub. The +/// finger has its own small overlay on top (`ScrubOverlay`), so a touch +/// redraws that and nothing here. +struct ChartSurface: View, Equatable { + let drawing: Drawing + + nonisolated static func == (a: Self, b: Self) -> Bool { a.drawing == b.drawing } + + var body: some View { + Canvas(rendersAsynchronously: false) { g, size in + #if DEBUG + ChartDraws.surface += 1 + #endif + drawing.draw(in: &g, size: size) + } + } +} + +#if DEBUG +/// How often the surfaces and the overlays drew, and what the overlays +/// cost, for the debug frame log. Main thread only. +@MainActor +enum ChartDraws { + static var surface = 0 + static var overlay = 0 + /// Slot or span changes under a finger. + static var changes = 0 + static var overlayMs = 0.0 + static var worstMs = 0.0 + + static func take() -> (surface: Int, overlay: Int, changes: Int, overlayMs: Double, worstMs: Double) { + defer { + surface = 0 + overlay = 0 + changes = 0 + overlayMs = 0 + worstMs = 0 + } + return (surface, overlay, changes, overlayMs, worstMs) + } +} +#endif + +/// The mono labels of a chart, resolved inside its Canvas so they never +/// reflow the layout. +enum ChartText { + static func label(_ text: String, size: CGFloat = 10, weight: Font.Weight = .regular, color: Color = Theme.ink3) -> Text { + Text(verbatim: text) + .font(.system(size: size, weight: weight, design: .monospaced)) + .foregroundStyle(color) + } +} diff --git a/appleApp/FTWCharts/Palette.swift b/appleApp/FTWCharts/Palette.swift index f1d9795..3c94280 100644 --- a/appleApp/FTWCharts/Palette.swift +++ b/appleApp/FTWCharts/Palette.swift @@ -74,9 +74,3 @@ enum Theme { return Color(color.resolve(in: environment)) } } - -extension PriceChartPalette { - /// The price line in ink, stronger as it gets dearer, for the app and - /// the widget alike. - static let ink = PriceChartPalette(cheap: Theme.ink3, flat: Theme.ink2, dear: Theme.ink, now: Theme.ink, quiet: Theme.ink3, labelBackground: Theme.raised) -} diff --git a/appleApp/FTWCharts/PriceBars.swift b/appleApp/FTWCharts/PriceBars.swift new file mode 100644 index 0000000..ae1bcbc --- /dev/null +++ b/appleApp/FTWCharts/PriceBars.swift @@ -0,0 +1,225 @@ +import FTWShared +import SwiftUI + +/// The price chart's shapes on one plot, from its `PriceChart`. A price +/// holds across its slot, so each slot is a bar: slot width less 1 px, top +/// corners rounded to 1.25 pt, square on zero, equal neighbours merged into +/// one pixel-snapped shape. The app's Canvas and the widget's still shapes +/// both draw these, so the two never differ. +struct PricePaths { + /// The bars by third of the window's range, the now slot apart. + var cheap = Path() + var middle = Path() + var dear = Path() + var now = Path() + /// Zero, one device pixel. + var zero = Path() + /// The window's average, across the plot. + var average = Path() + var averageY: CGFloat? + /// The now rule, full height. + var nowRule = Path() + var nowX: CGFloat? + /// Stubs where tomorrow's prices are still to come, and their hatch. + var pending = Path() + var pendingHatch = Path() + /// `└──┘` under the cheapest two hours. + var bracket = Path() + var bracketSpan: ClosedRange? + var baseline: CGFloat = 0 + + static let stub: CGFloat = 6 + + init(_ chart: PriceChart, plot: CGRect, scale: CGFloat) { + let scale = max(scale, 1) + func snap(_ v: CGFloat) -> CGFloat { (v * scale).rounded() / scale } + let origin = CGPoint(x: snap(plot.minX), y: snap(plot.minY)) + let map = YMap(domain: chart.domain, height: plot.height) + let width = plot.width + baseline = origin.y + snap(map.baseline) + let px = 1 / scale + + func bars(_ keep: (Int) -> Bool) -> Path { + let values = chart.shown.indices.map { keep($0) ? chart.shown[$0] : nil } + var path = Path() + for run in ChartPaths.runs(values, grid: chart.grid, width: width, map: map, scale: scale, maxRadius: 1.25) { + let r = run.rect.offsetBy(dx: origin.x, dy: origin.y) + 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) + path.addPath(Path(roundedRect: r, cornerRadii: radii, style: .circular)) + } + return path + } + let nowIndex = chart.nowIndex + cheap = bars { $0 != nowIndex && chart.thirds[$0] == .cheap } + middle = bars { $0 != nowIndex && chart.thirds[$0] == .middle } + dear = bars { $0 != nowIndex && chart.thirds[$0] == .dear } + now = bars { $0 == nowIndex } + + zero.move(to: CGPoint(x: origin.x, y: baseline + px / 2)) + zero.addLine(to: CGPoint(x: origin.x + width, y: baseline + px / 2)) + + if let mean = chart.meanShown { + let y = origin.y + snap(map.y(mean)) + px / 2 + averageY = y + average.move(to: CGPoint(x: origin.x, y: y)) + average.addLine(to: CGPoint(x: origin.x + width, y: y)) + } + + if nowIndex != nil { + let x = origin.x + snap(chart.grid.x(atMs: chart.nowMs, width: width)) + px / 2 + nowX = x + nowRule.move(to: CGPoint(x: x, y: origin.y)) + nowRule.addLine(to: CGPoint(x: x, y: origin.y + plot.height)) + } + + if let p = chart.pending { + let x0 = origin.x + snap(chart.grid.x(atMs: chart.grid.startMs(p.lowerBound), width: width)) + let x1 = origin.x + snap(chart.grid.x(atMs: chart.grid.endMs(p.upperBound), width: width)) + let rect = CGRect(x: x0, y: baseline - Self.stub, width: max(0, x1 - x0), height: Self.stub) + pending = Path(rect) + var x = x0 - Self.stub + while x < x1 { + pendingHatch.move(to: CGPoint(x: x, y: rect.maxY)) + pendingHatch.addLine(to: CGPoint(x: x + Self.stub, y: rect.minY)) + x += 3 + } + } + + if let c = chart.cheapest { + let x0 = origin.x + snap(chart.grid.x(atMs: chart.grid.startMs(c.lowerBound), width: width)) + px / 2 + let x1 = origin.x + snap(chart.grid.x(atMs: chart.grid.endMs(c.upperBound), width: width)) - px * 1.5 + let y = origin.y + plot.height + 6 + bracketSpan = x0...max(x0, x1) + bracket.move(to: CGPoint(x: x0, y: y - 3)) + bracket.addLine(to: CGPoint(x: x0, y: y)) + bracket.addLine(to: CGPoint(x: max(x0, x1), y: y)) + bracket.addLine(to: CGPoint(x: max(x0, x1), y: y - 3)) + } + } +} + +/// The app's price chart, drawn in one Canvas: bars in ink by thirds, the +/// now slot in full ink with its rule, the average as a dotted rule with +/// its figure in the right gutter, the cheapest two hours bracketed and +/// named, tomorrow's slots as hatched stubs, and the hours under it. +struct PriceBarsDrawing: ChartDrawing { + let chart: PriceChart + let frame: ChartFrame + /// The axis labels' mono size and the bracket line's, scaled with the + /// text and held at accessibility1. + 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) + } + + func draw(in g: inout GraphicsContext, size: CGSize) { + let plot = frame.plot(in: size) + let scale = max(g.environment.displayScale, 1) + let px = 1 / scale + let p = PricePaths(chart, plot: plot, scale: scale) + + g.fill(p.cheap, with: .color(Theme.ink.opacity(0.32))) + g.fill(p.middle, with: .color(Theme.ink.opacity(0.5))) + g.fill(p.dear, with: .color(Theme.ink.opacity(0.8))) + g.fill(p.now, with: .color(Theme.ink)) + g.stroke(p.zero, with: .color(Theme.ink.opacity(0.35)), style: StrokeStyle(lineWidth: px, lineCap: .butt, lineJoin: .round)) + + if !p.pending.isEmpty { + var hatch = g + hatch.clip(to: p.pending) + hatch.stroke(p.pendingHatch, with: .color(Theme.ink3), style: StrokeStyle(lineWidth: px, lineCap: .butt, lineJoin: .round)) + if !chart.pendingText.isEmpty { + // From the first stub on, never over a bar to its left. + let span = p.pending.boundingRect + let text = Self.fitted(chart.pendingText, size: axis, width: size.width - span.minX - 2, in: g) + g.draw(text, at: CGPoint(x: span.minX + 2, y: span.minY - 4), anchor: .bottomLeading) + } + } + + if let y = p.averageY { + g.stroke(p.average, with: .color(Theme.ink2), style: StrokeStyle(lineWidth: px * 2, lineCap: .butt, lineJoin: .round, dash: [1, 3])) + let word = g.resolve(ChartText.label(PriceChart.averageWord, size: axis)) + let figure = g.resolve(ChartText.label(chart.meanText, size: axis, color: Theme.ink2)) + let x = plot.maxX + 4 + g.draw(word, at: CGPoint(x: x, y: y - 1), anchor: .bottomLeading) + 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 { + 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) + } + + if let span = p.bracketSpan { + g.stroke(p.bracket, with: .color(Theme.ink.opacity(0.7)), style: StrokeStyle(lineWidth: 1, lineCap: .round, lineJoin: .round)) + let text = Self.fitted(chart.cheapestText, size: kicker, width: size.width - plot.minX, color: Theme.ink2, in: g) + let w = text.measure(in: size).width + let x = min(max(span.lowerBound, plot.minX), max(plot.minX, size.width - w)) + g.draw(text, at: CGPoint(x: x, y: plot.maxY + 6 + 3), anchor: .topLeading) + } + } + + /// A mono label that fits `width`: at its size if it can, else as much + /// smaller as it needs, down to 70 %. + static func fitted(_ string: String, size: CGFloat, width: CGFloat, color: Color = Theme.ink3, in g: GraphicsContext) -> GraphicsContext.ResolvedText { + let text = g.resolve(ChartText.label(string, size: size, color: color)) + let w = text.measure(in: CGSize(width: CGFloat.infinity, height: .infinity)).width + guard w > width, w > 0 else { return text } + return g.resolve(ChartText.label(string, size: max(size * 0.7, (size * width / w).rounded(.down)), color: color)) + } +} + +/// The same bars as still shapes, for a widget: no Canvas, no motion and +/// no finger. Tinted and clear home screens draw them in one colour. +struct PriceBarsStill: View { + let chart: PriceChart + var monochrome = false + @Environment(\.displayScale) private var scale + + var body: some View { + GeometryReader { geo in + let frame = ChartFrame(top: 2, bottom: 22, trailing: 0) + let plot = frame.plot(in: geo.size) + let p = PricePaths(chart, plot: plot, scale: scale) + let ink: Color = monochrome ? .primary : Theme.ink + ZStack(alignment: .topLeading) { + p.cheap.fill(ink.opacity(0.32)) + p.middle.fill(ink.opacity(0.5)) + p.dear.fill(ink.opacity(0.8)) + p.now.fill(ink).widgetAccentable() + p.zero.stroke(ink.opacity(0.35), style: StrokeStyle(lineWidth: 1 / max(scale, 1), lineCap: .butt, lineJoin: .round)) + p.pendingHatch.stroke(ink.opacity(0.4), style: StrokeStyle(lineWidth: 1 / max(scale, 1), lineCap: .butt, lineJoin: .round)) + .clipShape(p.pending) + p.average.stroke(ink.opacity(0.6), style: StrokeStyle(lineWidth: 1, lineCap: .butt, lineJoin: .round, dash: [1, 3])) + p.bracket.stroke(ink.opacity(0.7), style: StrokeStyle(lineWidth: 1, lineCap: .round, lineJoin: .round)) + ForEach(chart.hours, id: \.ms) { tick in + Text(verbatim: tick.label) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(monochrome ? AnyShapeStyle(.secondary) : AnyShapeStyle(Theme.ink3)) + .fixedSize() + .position(x: plot.minX + chart.grid.x(atMs: tick.ms, width: plot.width), y: geo.size.height - 6) + } + } + } + } +} diff --git a/appleApp/FTWCharts/PriceLineChart.swift b/appleApp/FTWCharts/PriceLineChart.swift deleted file mode 100644 index c84d0f1..0000000 --- a/appleApp/FTWCharts/PriceLineChart.swift +++ /dev/null @@ -1,206 +0,0 @@ -import Charts -import FTWShared -import SwiftUI - -/// The colours a price chart draws with, given by the app or the widget so -/// each keeps its own palette. -struct PriceChartPalette { - var cheap: Color - var flat: Color - var dear: Color - /// The now rule and dot. - var now: Color - /// The average rule and its label, the hour labels. - var quiet: Color - /// Behind a label that sits on the line. - var labelBackground: Color -} - -/// One price slot as the chart draws it: the time it starts, its price in -/// display units (öre, cents), and an id that stays with the slot. -struct PricePoint: Identifiable, Equatable { - let startMs: Double - let endMs: Double - let value: Double - var id: Double { startMs } -} - -/// The price ahead as one stepped line over a soft area, coloured by level: -/// green under the average, grey around it, red above. Now is a thin rule -/// with a dot on the line; the cheapest hours are a soft green band with a -/// small label; the average is a faint rule named at the right edge. No -/// gridlines and few hour labels. The app and the price widget draw the -/// same chart; only the app lets a finger move over it. -struct PriceLineChart: View { - let points: [PricePoint] - let nowMs: Double - let mean: Double? - let cheapest: ClosedRange? - /// "Cheapest", over the band. - let cheapestLabel: String? - /// "avg 221", at the right end of the average rule. - let meanLabel: String? - let palette: PriceChartPalette - let zone: TimeZone - /// Hours between labels on the time axis. - var labelEveryHours = 6 - /// Where the finger is, drawn as a rule and a dot. - var selected: Date? - /// One colour only, for tinted widgets and StandBy at night. - var monochrome = false - - var body: some View { - let domain = yDomain - Chart { - if let cheapest { - RectangleMark( - xStart: .value("From", date(max(cheapest.lowerBound, start))), - xEnd: .value("To", date(min(cheapest.upperBound, end))), - yStart: .value("Low", domain.lowerBound), - yEnd: .value("High", domain.upperBound) - ) - .foregroundStyle(palette.cheap.opacity(monochrome ? 0.18 : 0.12)) - .annotation(position: .overlay, alignment: .top, spacing: 0) { - if let cheapestLabel { - Text(cheapestLabel) - .font(.caption2.weight(.semibold)) - .foregroundStyle(monochrome ? AnyShapeStyle(.primary) : AnyShapeStyle(palette.cheap)) - .lineLimit(1) - .fixedSize() - .padding(.top, 2) - } - } - } - // A gradient spans the mark it colours, so each takes its own - // stops: the area from its base, the line from its own lowest. - let lineSpan = (points.map(\.value).min() ?? 0)...(points.map(\.value).max() ?? 1) - let areaSpan = domain.lowerBound...lineSpan.upperBound - ForEach(drawn) { p in - AreaMark(x: .value("Time", date(p.startMs)), yStart: .value("Base", domain.lowerBound), yEnd: .value("Price", p.value)) - .foregroundStyle(level(areaSpan, area: true)) - .interpolationMethod(.stepEnd) - LineMark(x: .value("Time", date(p.startMs)), y: .value("Price", p.value)) - .foregroundStyle(level(lineSpan, area: false)) - .lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) - .interpolationMethod(.stepEnd) - } - // After the line, so its label sits on top of it. - if let mean { - RuleMark(y: .value("Average", mean)) - .foregroundStyle(palette.quiet.opacity(0.5)) - .lineStyle(StrokeStyle(lineWidth: 0.8, dash: [2, 3])) - .annotation(position: .top, alignment: .trailing, spacing: 1) { - if let meanLabel { - Text(meanLabel) - .font(.caption2.monospacedDigit()) - .foregroundStyle(palette.quiet) - .lineLimit(1) - .fixedSize() - .padding(.horizontal, 3) - .background(palette.labelBackground.opacity(0.85), in: Capsule()) - } - } - } - if let current = value(at: nowMs) { - RuleMark(x: .value("Now", date(nowMs))) - .foregroundStyle(palette.now.opacity(0.6)) - .lineStyle(StrokeStyle(lineWidth: 1)) - PointMark(x: .value("Now", date(nowMs)), y: .value("Price", current)) - .foregroundStyle(palette.now) - .symbolSize(36) - } - // While a finger is down there is always a slot under it: past - // either edge the marker stays at that edge, and in a gap it - // sits on the step the line draws there. - if let selected, let i = SlotLookup.clamped(points, at: selected.timeIntervalSince1970 * 1000, start: \.startMs) { - let at = date(min(max(selected.timeIntervalSince1970 * 1000, start), end)) - RuleMark(x: .value("Selected", at)) - .foregroundStyle(palette.quiet.opacity(0.7)) - .lineStyle(StrokeStyle(lineWidth: 1)) - PointMark(x: .value("Selected", at), y: .value("Price", points[i].value)) - .foregroundStyle(palette.now) - .symbolSize(64) - } - } - .chartXScale(domain: date(start)...date(end)) - .chartYScale(domain: domain) - .chartYAxis(.hidden) - .chartXAxis { - AxisMarks(values: hourTicks) { value in - AxisValueLabel(anchor: .top) { - if let d = value.as(Date.self) { - Text(String(format: "%02d", calendar.component(.hour, from: d))) - .font(.caption2.monospacedDigit()) - .foregroundStyle(palette.quiet) - } - } - } - } - .chartLegend(.hidden) - } - - // MARK: Geometry - - private var start: Double { points.first?.startMs ?? nowMs } - private var end: Double { points.last?.endMs ?? nowMs } - - /// The points plus one at the end of the last slot, so a stepped line - /// draws the last slot's whole width. - private var drawn: [PricePoint] { - guard let last = points.last else { return [] } - return points + [PricePoint(startMs: last.endMs, endMs: last.endMs, value: last.value)] - } - - /// From zero, or below it when a price is negative, to a little over - /// the dearest. - private var yDomain: ClosedRange { - let lo = min(0, points.map(\.value).min() ?? 0) - let hi = max(lo + 1, (points.map(\.value).max() ?? 1) * 1.12) - return lo...hi - } - - /// Green under the average, grey around it, red above, by height - /// within `span`, the values the mark covers. An area is light and - /// fades toward its base. - private func level(_ span: ClosedRange, area: Bool) -> LinearGradient { - let fill: (Color, Double) -> Color = { c, at in area ? c.opacity(0.04 + 0.16 * at) : c } - guard !monochrome, let mean, span.upperBound > span.lowerBound else { - let c = monochrome ? Color.primary : palette.flat - return LinearGradient(colors: [fill(c, 0), fill(c, 1)], startPoint: .bottom, endPoint: .top) - } - let width = span.upperBound - span.lowerBound - let low = max(0, min(1, (mean * 0.95 - span.lowerBound) / width)) - let high = max(low, min(1, (mean * 1.05 - span.lowerBound) / width)) - return LinearGradient(stops: [ - .init(color: fill(palette.cheap, 0), location: 0), - .init(color: fill(palette.cheap, low), location: low), - .init(color: fill(palette.flat, (low + high) / 2), location: (low + high) / 2), - .init(color: fill(palette.dear, high), location: high), - .init(color: fill(palette.dear, 1), location: 1), - ], startPoint: .bottom, endPoint: .top) - } - - private func value(at ms: Double) -> Double? { - points.first { $0.startMs <= ms && ms < $0.endMs }?.value - } - - private var calendar: Calendar { - var c = Calendar(identifier: .gregorian) - c.timeZone = zone - return c - } - - /// Whole hours a few apart, kept clear of the right edge. - private var hourTicks: [Date] { - let hour: Double = 3_600_000 - var t = (start / hour).rounded(.up) * hour - var out: [Date] = [] - while t < end - hour { - if calendar.component(.hour, from: date(t)) % labelEveryHours == 0 { out.append(date(t)) } - t += hour - } - return out - } - - private func date(_ ms: Double) -> Date { Date(timeIntervalSince1970: ms / 1000) } -} diff --git a/appleApp/FTWCharts/ScrubModel.swift b/appleApp/FTWCharts/ScrubModel.swift new file mode 100644 index 0000000..36d7684 --- /dev/null +++ b/appleApp/FTWCharts/ScrubModel.swift @@ -0,0 +1,84 @@ +import FTWShared +import Observation +import os + +/// The finger on one chart. The overlay that draws the marker and the hero +/// that reads the value are the only views that read it, so a touch +/// redraws those two and nothing else. The slot is a clamped index that +/// never animates; the haptic counters move in the same update as it. +/// Two fingers hold a span of slots instead. +@Observable +@MainActor +final class ScrubModel { + /// The slot under one finger; nil when no finger is down or two are. + private(set) var index: Int? + /// The slots between two fingers, both ends included. + private(set) var span: ClosedRange? + /// What was last touched, kept so the marker fades out where it was. + private(set) var shown: Shown? + /// Bumped per `.selection` and per light impact. + private(set) var ticks = 0 + private(set) var bumps = 0 + + enum Shown: Equatable { + case slot(Int) + case span(ClosedRange) + } + + var isDown: Bool { index != nil || span != nil } + + @ObservationIgnored private var step = ScrubStep() + @ObservationIgnored private var spanStep = ScrubStep() + + static let log = Logger(subsystem: "energy.ftw.app", category: "scrub") + + /// One finger is over slot `next`. Work happens only on a new slot. + func move(to next: Int, landmark: Bool) { + guard next != index || span != nil else { return } + if span != nil { + span = nil + spanStep.lift() + } + step.move(to: next, landmark: landmark) + index = next + show(.slot(next)) + Self.log.debug("touching index \(next) ticks \(self.ticks) bumps \(self.bumps)") + } + + /// Two fingers hold slots `a` and `b`. A span that forms gives a light + /// impact, as its ends do; a moved end ticks. + func spread(_ a: Int, _ b: Int) { + let next = min(a, b)...max(a, b) + guard next != span else { return } + let forming = span == nil + if index != nil { + index = nil + step.lift() + } + // One number per span, so the step's floor and crossing rules hold. + spanStep.move(to: next.lowerBound &* 65_536 &+ next.upperBound, landmark: forming) + span = next + show(.span(next)) + Self.log.debug("touching span \(next.lowerBound)–\(next.upperBound) ticks \(self.ticks) bumps \(self.bumps)") + } + + /// Every finger lifted, or the gesture ended. + func lift() { + guard isDown else { return } + step.lift() + spanStep.lift() + index = nil + span = nil + Self.log.debug("lifted") + } + + private func show(_ next: Shown) { + #if DEBUG + ChartDraws.changes += 1 + #endif + if shown != next { shown = next } + let t = step.ticks + spanStep.ticks, b = step.bumps + spanStep.bumps + if ticks != t { ticks = t } + if bumps != b { bumps = b } + } +} diff --git a/appleApp/FTWCharts/ScrubOverlay.swift b/appleApp/FTWCharts/ScrubOverlay.swift new file mode 100644 index 0000000..ad1179a --- /dev/null +++ b/appleApp/FTWCharts/ScrubOverlay.swift @@ -0,0 +1,246 @@ +import FTWShared +import SwiftUI + +/// What the finger can land on in one chart, worked out with its data: +/// the slots, where each one's dot sits, the tag it shows and the slots +/// that earn a firmer haptic. +struct ScrubTrack: Equatable { + var grid: SlotGrid + var domain: YDomain + /// The dot's value per slot, in the domain's unit; nil draws no dot. + var values: [Double?] + /// Where each slot's dot sits in time; nil puts it in the slot's middle. + var atMs: [Double]? + /// "02:15 31,0 öre", per slot. + var tags: [String] + var landmarks: Set + /// The dot's colour. + var color: Color = Theme.ink + /// Dims what lies right of the finger, for a history's "so far". + var dimsAfter = false + /// A value that holds across its slot (a price, a plan quarter): the + /// slot under the finger lights up as its whole bar and the rest dims, + /// in place of a dot on a line. + var bars = false + + func x(_ i: Int, plot: CGRect) -> CGFloat { + if let atMs, atMs.indices.contains(i) { return plot.minX + grid.x(atMs: atMs[i], width: plot.width) } + return plot.minX + grid.midX(i, width: plot.width) + } +} + +/// The finger on a chart, in one small Canvas over the surface. It owns +/// the gesture across the whole frame, so a finger past either end or +/// above and below the plot still has a slot, clamped, and the marker +/// never vanishes while it is down. It reads only the scrub model and +/// redraws once per slot change, however fast the finger moves. +/// +/// 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; +/// the marker fades in over 80 ms and out over 150 ms. +struct ScrubOverlay: View { + let track: ScrubTrack + let frame: ChartFrame + let model: ScrubModel + /// The tag's mono size, scaled with the text. + var tagSize: CGFloat = 11 + /// 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 "" } + + @GestureState private var touching = false + @State private var size: CGSize = .zero + + var body: some View { + let down = model.isDown + let shown = model.shown + let spanText = shown.flatMap { if case let .span(r) = $0 { return spanTag(r) } else { return nil } } + Canvas(rendersAsynchronously: false) { g, size in + #if DEBUG + let began = ContinuousClock.now + defer { + let ms = (ContinuousClock.now - began) / .milliseconds(1) + ChartDraws.overlay += 1 + ChartDraws.overlayMs += ms + ChartDraws.worstMs = max(ChartDraws.worstMs, ms) + } + #endif + switch shown { + case let .slot(i)? where i < track.grid.count: draw(i, in: &g, size: size) + case let .span(r)? where r.upperBound < track.grid.count: draw(r, tag: spanText ?? "", in: &g, size: size) + default: break + } + } + .opacity(down ? 1 : 0) + .animation(.easeOut(duration: down ? 0.08 : 0.15), value: down) + .contentShape(Rectangle()) + .onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 } + .gesture( + // Every touch at once: one finger scrubs, two hold a span. + SpatialEventGesture() + .updating($touching) { _, state, _ in state = true } + .onChanged { events in follow(events) } + .onEnded { _ in model.lift() } + ) + // A gesture the system takes away ends without onEnded. + .onChange(of: touching) { _, now in if !now { model.lift() } } + .sensoryFeedback(.selection, trigger: model.ticks) + .sensoryFeedback(.impact(weight: .light), trigger: model.bumps) + #if DEBUG + .task { await autoScrub() } + #endif + } + + private func follow(_ events: SpatialEventCollection) { + let down = events.filter { $0.phase == .active }.sorted { $0.location.x < $1.location.x } + if down.count >= 2, let a = slot(down[0].location.x), let b = slot(down[down.count - 1].location.x) { + model.spread(a, b) + } else if let one = down.first { + follow(one.location.x) + } else { + model.lift() + } + } + + private func slot(_ x: CGFloat) -> Int? { + let plot = frame.plot(in: size) + return track.grid.index(atX: x - plot.minX, width: plot.width) + } + + private func follow(_ x: CGFloat) { + guard let i = slot(x) else { + ScrubModel.log.error("touching with no slot at x \(x)") + return + } + model.move(to: i, landmark: track.landmarks.contains(i)) + } + + private func draw(_ i: Int, in g: inout GraphicsContext, size: CGSize) { + let plot = frame.plot(in: size) + let px = 1 / max(g.environment.displayScale, 1) + let x = (track.x(i, plot: plot) / px).rounded() * px + px / 2 + + if track.bars { + lit(i...i, plot: plot, px: px, in: &g) + rule(at: x, plot: plot, px: px, in: &g) + tag(track.tags.indices.contains(i) ? track.tags[i] : "", over: x, plot: plot, in: &g, size: size) + return + } + if track.dimsAfter { + g.fill(Path(CGRect(x: x, y: 0, width: max(0, plot.maxX - x), height: size.height)), with: .color(Theme.ground.opacity(0.55))) + } + rule(at: x, plot: plot, px: px, in: &g) + + if track.values.indices.contains(i), let v = track.values[i] { + let y = plot.minY + YMap(domain: track.domain, height: plot.height).y(v) + let dot = CGPoint(x: x, y: y) + g.fill(Path(ellipseIn: CGRect(x: x - 6, y: y - 6, width: 12, height: 12)), with: .color(Theme.ground)) + g.fill(Path(ellipseIn: CGRect(x: x - 4, y: y - 4, width: 8, height: 8)), with: .color(track.color)) + g.stroke(Self.brackets(around: dot, half: 11, arm: 5), with: .color(Theme.ink.opacity(0.8)), style: StrokeStyle(lineWidth: 1.25, lineCap: .round, lineJoin: .round)) + } + tag(track.tags.indices.contains(i) ? track.tags[i] : "", over: x, plot: plot, in: &g, size: size) + } + + /// Two fingers: the slots between them under a faint wash, a rule at + /// each end, and the span's tag over its middle. + private func draw(_ r: ClosedRange, tag text: String, in g: inout GraphicsContext, size: CGSize) { + let plot = frame.plot(in: size) + let px = 1 / max(g.environment.displayScale, 1) + func snap(_ v: CGFloat) -> CGFloat { (v / px).rounded() * px + px / 2 } + let x0 = snap(plot.minX + track.grid.x(atMs: max(track.grid.startMs(r.lowerBound), track.grid.fromMs), width: plot.width)) + let x1 = snap(plot.minX + track.grid.x(atMs: min(track.grid.endMs(r.upperBound), track.grid.toMs), width: plot.width)) + if track.bars { + lit(r, plot: plot, px: px, in: &g) + } else { + g.fill(Path(CGRect(x: x0, y: plot.minY, width: max(px, x1 - x0), height: plot.height)), with: .color(Theme.ink.opacity(0.08))) + } + rule(at: x0, plot: plot, px: px, in: &g) + rule(at: x1, plot: plot, px: px, in: &g) + tag(text, over: (x0 + x1) / 2, plot: plot, in: &g, size: size) + } + + /// Bars: the slots in `r` light up. Everything else in the plot dims + /// under the ground at half strength, each lit slot's column takes a + /// faint wash from top to bottom, and its bar is drawn again in full + /// ink, its whole width and height. A slot with no value keeps its + /// wash, so the finger still sees where it is. + private func lit(_ r: ClosedRange, plot: CGRect, px: CGFloat, in g: inout GraphicsContext) { + func snap(_ v: CGFloat) -> CGFloat { (v / px).rounded() * px } + let grid = track.grid + let x0 = snap(plot.minX + grid.x(atMs: max(grid.startMs(r.lowerBound), grid.fromMs), width: plot.width)) + let x1 = snap(plot.minX + grid.x(atMs: min(grid.endMs(r.upperBound), grid.toMs), width: plot.width)) + g.fill(Path(CGRect(x: plot.minX, y: plot.minY, width: max(0, x0 - plot.minX), height: plot.height)), with: .color(Theme.ground.opacity(0.5))) + g.fill(Path(CGRect(x: x1, y: plot.minY, width: max(0, plot.maxX - x1), height: plot.height)), with: .color(Theme.ground.opacity(0.5))) + g.fill(Path(CGRect(x: x0, y: plot.minY, width: max(px, x1 - x0 - px), height: plot.height)), with: .color(Theme.ink.opacity(0.16))) + let map = YMap(domain: track.domain, height: plot.height) + let base = plot.minY + snap(map.baseline) + for i in r where track.values.indices.contains(i) { + guard let v = track.values[i], v != 0 else { continue } + let a = snap(plot.minX + grid.x(atMs: max(grid.startMs(i), grid.fromMs), width: plot.width)) + let b = snap(plot.minX + grid.x(atMs: min(grid.endMs(i), grid.toMs), width: plot.width)) - px + let y = plot.minY + snap(map.y(v)) + let bar = CGRect(x: a, y: min(y, base), width: max(px, b - a), height: abs(y - base)) + let radius = min(1.25, bar.width / 2, bar.height) + let radii = v > 0 + ? RectangleCornerRadii(topLeading: radius, bottomLeading: 0, bottomTrailing: 0, topTrailing: radius) + : RectangleCornerRadii(topLeading: 0, bottomLeading: radius, bottomTrailing: radius, topTrailing: 0) + g.fill(Path(roundedRect: bar, cornerRadii: radii, style: .circular), with: .color(Theme.ink)) + } + } + + private func rule(at x: CGFloat, plot: CGRect, px: CGFloat, in g: inout GraphicsContext) { + var rule = Path() + rule.move(to: CGPoint(x: x, y: plot.minY)) + rule.addLine(to: CGPoint(x: x, y: plot.maxY)) + g.stroke(rule, with: .color(Theme.ink.opacity(0.5)), style: StrokeStyle(lineWidth: px, lineCap: .round, lineJoin: .round)) + } + + /// The tag rides the top of the rule, clear of the thumb, kept inside + /// the frame: inverse video, an ink capsule with the ground for words. + private func tag(_ string: String, over x: CGFloat, plot: CGRect, in g: inout GraphicsContext, size: CGSize) { + let text = g.resolve(Text(verbatim: string) + .font(.system(size: tagSize, weight: .semibold, design: .monospaced)) + .foregroundStyle(Theme.ground)) + let t = text.measure(in: CGSize(width: size.width, height: .infinity)) + let w = t.width + 8, h = t.height + 3 + let box = CGRect(x: min(max(x - w / 2, 0), max(0, size.width - w)), y: max(0, plot.minY - h - 2), width: w, height: h) + g.fill(Path(roundedRect: box, cornerRadius: 3), with: .color(Theme.ink)) + g.draw(text, at: CGPoint(x: box.midX, y: box.midY), anchor: .center) + } + + /// Four corners of a square around `p`, each an arm long. + static func brackets(around p: CGPoint, half: CGFloat, arm: CGFloat) -> Path { + var path = Path() + for (sx, sy) in [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)] { + let corner = CGPoint(x: p.x + sx * half, y: p.y + sy * half) + path.move(to: CGPoint(x: corner.x, y: corner.y - sy * arm)) + path.addLine(to: corner) + path.addLine(to: CGPoint(x: corner.x - sx * arm, y: corner.y)) + } + return path + } + + #if DEBUG + /// With `-FTWAutoScrub YES`, ten seconds of a fast thumb, back and + /// forth past both edges, then a two-finger span, through the same path + /// touches take after they arrive. For measuring frames where nothing + /// can touch. + private func autoScrub() async { + guard UserDefaults.standard.bool(forKey: "FTWAutoScrub") else { return } + try? await Task.sleep(for: .seconds(2)) + let began = ContinuousClock.now + while ContinuousClock.now - began < .seconds(10) { + let t = (ContinuousClock.now - began) / .milliseconds(800) + let phase = t.truncatingRemainder(dividingBy: 2) + let along = phase < 1 ? phase : 2 - phase + follow(CGFloat(-0.05 + 1.1 * along) * size.width) + try? await Task.sleep(for: .milliseconds(8)) + } + // Then two fingers, held for three seconds. + if let a = slot(0.3 * size.width), let b = slot(0.55 * size.width) { model.spread(a, b) } + try? await Task.sleep(for: .seconds(3)) + model.lift() + } + #endif +} diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index 282cce2..e7a850a 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -271,7 +271,9 @@ public final class SimulatedBox { // MARK: Prices func pricesCBOR(from: Double, to: Double) -> CBOR { - let slotMs: Double = 3_600_000 + // Quarter hours, as the Nordic day-ahead market has priced since + // October 2025. + let slotMs: Double = 900_000 // Tomorrow's rates publish at 13:00 local. let midnight = scheduler.nowMs - house.hourOfDay(scheduler.nowMs) * 3_600_000 let published = midnight + (house.hourOfDay(scheduler.nowMs) >= 13 ? 48 : 24) * 3_600_000 diff --git a/appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift index 29a10fd..1b10c00 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift @@ -118,28 +118,6 @@ public enum Readouts { return ChartReadout(when: span(h.startMs, h.startMs + h.lenMs), what: h.wh > 0 ? NodeWords.format("charged %@", AppWords.energy(h.wh)) : NodeWords.text("not charging")) } - /// A price slot: the price, and what the plan does then. Past either - /// edge it is the edge's slot, and in a gap the slot before it, as the - /// stepped line draws it. - public static func price(_ prices: [ChartPrice], currency: String, plan: PlanEntry?, at ms: Double) -> ChartReadout? { - guard let i = SlotLookup.clamped(prices, at: ms, start: \.startMs) else { return nil } - let slot = prices[i] - var parts = [price(slot.minor, currency)] - if let plan { - let doing = PlanGlance.events(plan, fromMs: slot.startMs, toMs: slot.endMs).filter { $0.kind != .cheap } - parts += doing.prefix(2).map(PlanGlance.what) - } - return ChartReadout(when: span(slot.startMs, slot.endMs), what: parts.joined(separator: " · ")) - } - - /// Two fingers on the prices: their mean between them. - public static func price(_ prices: [ChartPrice], currency: String, range: ClosedRange) -> ChartReadout? { - let inside = prices.filter { $0.endMs > range.lowerBound && $0.startMs < range.upperBound } - guard !inside.isEmpty else { return nil } - let mean = inside.map(\.minor).reduce(0, +) / Double(inside.count) - return ChartReadout(when: span(range.lowerBound, range.upperBound), what: NodeWords.format("average %@", price(mean, currency))) - } - /// A moment of the live curve. public static func live(_ node: EnergyNode, _ points: [LiveTrace.Point], at ms: Double) -> ChartReadout? { guard let p = points.min(by: { abs($0.t - ms) < abs($1.t - ms) }), abs(p.t - ms) < LiveTrace.gapMs else { diff --git a/appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift b/appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift index bc54376..ec7fa37 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift @@ -60,6 +60,22 @@ public enum EnergyNode: String, CaseIterable, Hashable, Sendable, Identifiable, } } + /// The least its chart's scale reaches, in watts, so a quiet node's + /// noise stays small: a grid held at 0 ± 60 W by the battery spreads + /// 40 pt on a scale fitted to the noise and 3 pt on 3 kW. A known + /// rating (the inverter, the panels' peak, the charger's maximum, the + /// main fuse) takes the place of the default. + public func scaleFloorW(rating: Double? = nil) -> Double { + if let rating, rating.isFinite, rating > 0 { return rating } + switch self { + case .home: return 2_000 + case .grid: return 3_000 + case .battery: return 3_000 + case .solar: return 3_000 + case .car: return 3_700 + } + } + /// Which way is up on its curve, for the two whose curve crosses zero. public var directions: (up: String, down: String)? { switch self { diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index 3ca0991..0dc653e 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -213,6 +213,8 @@ public enum NodeWords { "Reading the prices from your box…": "Läser priserna från boxen …", "Cheapest %@ · %@ on average": "Billigast %@ · %@ i snitt", "avg %@": "snitt %@", + "Nothing planned then": "Inget planerat då", + "Now · %@": "Nu · %@", "not charging": "laddar inte", "Time": "Tid", "Power": "Effekt", diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/ChartPaths.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/ChartPaths.swift index 8a12269..c987df6 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/ChartPaths.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/ChartPaths.swift @@ -124,6 +124,64 @@ extension ChartPath { elements.append(.close) } + /// A band between two curves at the same x: Steffen through `highs` + /// left to right, then back along Steffen through `lows`, closed. Each + /// edge is exactly the monotone curve of its own points, so the band + /// invents no swing. Both run left to right and have the same count; + /// fewer than two points draw nothing. + public mutating func addBand(highs: [CGPoint], lows: [CGPoint]) { + guard highs.count > 1, lows.count == highs.count, let last = lows.last else { return } + addMonotone(highs) + elements.append(.line(last)) + elements.append(contentsOf: ChartPaths.monotone(lows).reversed().elements.dropFirst()) + elements.append(.close) + } + + /// The same path walked backwards: the last point first, each curve + /// with its controls swapped, so it draws the same shape the other + /// way. A subpath that was closed stays closed. + public func reversed() -> ChartPath { + var subpaths: [[Element]] = [] + var start = CGPoint.zero, at = CGPoint.zero + var segments: [(from: CGPoint, element: Element)] = [] + var open = false, closed = false + func flush() { + guard open else { return } + var sub: [Element] = [.move(at)] + for (from, e) in segments.reversed() { + switch e { + case .line: sub.append(.line(from)) + case let .quad(_, c): sub.append(.quad(from, control: c)) + case let .curve(_, c1, c2): sub.append(.curve(from, control1: c2, control2: c1)) + case .move, .close: break + } + } + if closed { sub.append(.close) } + subpaths.append(sub) + segments.removeAll() + open = false + closed = false + } + for e in elements { + switch e { + case let .move(to): + flush() + start = to + at = to + open = true + case let .line(to), let .quad(to, _), let .curve(to, _, _): + segments.append((at, e)) + at = to + case .close: + closed = true + if at != start { segments.append((at, .line(start))) } + at = start + } + } + flush() + return ChartPath(subpaths.reversed().flatMap { $0 }) + } + /// A polyline with its corners rounded. A corner's arc is cut no /// deeper than `maxShift` from its point, starts no further out than /// half of either segment, and is no rounder than a circle of diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/ChartSeries.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/ChartSeries.swift index 891022a..542ec40 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/ChartSeries.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/ChartSeries.swift @@ -34,10 +34,12 @@ public struct ChartBucket: Equatable, Sendable { } /// The smallest bucket of 5, 15, 30 or 60 minutes that leaves each point -/// at least 2.5 pt of width: Today on a phone is 15 minutes, 48 hours 30. +/// at least 6 pt of width: Today on a phone is 30 minutes and 48 hours 60, +/// about 48 slots of 7 pt each, the thumb rule of the plan strip. A line +/// with a point every 1 pt draws every step between neighbours as a tooth. public enum DisplayBucket { public static let stepsMs: [Double] = [300_000, 900_000, 1_800_000, 3_600_000] - public static let minWidth: CGFloat = 2.5 + public static let minWidth: CGFloat = 6 public static func stepMs(spanMs: Double, width: CGFloat) -> Double { guard spanMs > 0, width > 0 else { return stepsMs[0] } @@ -87,7 +89,7 @@ public enum DisplayBucket { } /// A history chart's geometry, built once per change of data off the main -/// actor: its slots, its stable domain, the line and body in screen +/// actor: its slots, its stable domain, the line, band and body in screen /// points, where each slot's value sits, and its readout table. A view /// only reads it. public struct ChartSeries: Equatable, Sendable { @@ -99,6 +101,10 @@ public struct ChartSeries: Equatable, Sendable { public var line: ChartPath /// The same curves closed on zero. public var body: ChartPath + /// How far the five-minute means swung inside each slot: one shape per + /// run, from the slot highs' curve over to the slot lows' curve. A + /// steady load hides it under the line; an on/off load shows it. + public var band: ChartPath /// Where each slot's value sits on the line; nil in a gap. public var points: [CGPoint?] public var readouts: ReadoutTable @@ -125,19 +131,21 @@ public struct ChartSeries: Equatable, Sendable { let buckets = DisplayBucket.buckets(samples, sampleMs: sampleMs, stepMs: stepMs, fromMs: fromMs, toMs: toMs, clock: clock) let grid = SlotGrid(firstMs: buckets.first?.startMs ?? fromMs, stepMs: stepMs, count: buckets.count, fromMs: fromMs, toMs: toMs) + // The domain holds the band, not just the means, so the highest + // five-minute mean always fits. var low = 0.0, high = 0.0 for b in buckets { - if let m = b.mean { - low = min(low, m) - high = max(high, m) + if let lo = b.low, let hi = b.high { + low = min(low, lo) + high = max(high, hi) } } let domain = YDomain.stable(low: low, high: high, previous: previous, floor: floor) let map = YMap(domain: domain, height: size.height) - var line = ChartPath(), body = ChartPath() + var line = ChartPath(), body = ChartPath(), band = ChartPath() var points = [CGPoint?](repeating: nil, count: buckets.count) - var run: [CGPoint] = [] + var run: [CGPoint] = [], highs: [CGPoint] = [], lows: [CGPoint] = [] run.reserveCapacity(buckets.count) let base = map.baseline func endRun() { @@ -149,17 +157,23 @@ public struct ChartSeries: Equatable, Sendable { body.elements.append(.line(CGPoint(x: run[run.count - 1].x, y: base))) body.elements.append(.line(CGPoint(x: run[0].x, y: base))) body.elements.append(.close) + band.addBand(highs: highs, lows: lows) } run.removeAll(keepingCapacity: true) + highs.removeAll(keepingCapacity: true) + lows.removeAll(keepingCapacity: true) } for (i, b) in buckets.enumerated() { - guard let m = b.mean else { + guard let m = b.mean, let lo = b.low, let hi = b.high else { endRun() continue } - let p = CGPoint(x: grid.x(atMs: b.atMs, width: size.width), y: map.y(m)) + let x = grid.x(atMs: b.atMs, width: size.width) + let p = CGPoint(x: x, y: map.y(m)) points[i] = p run.append(p) + highs.append(CGPoint(x: x, y: map.y(hi))) + lows.append(CGPoint(x: x, y: map.y(lo))) } endRun() @@ -200,6 +214,7 @@ public struct ChartSeries: Equatable, Sendable { size: size, line: line, body: body, + band: band, points: points, readouts: ReadoutTable(when: when, what: what, inWh: inWh, outWh: outWh, landmarks: landmarks) ) diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/LiveSeries.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/LiveSeries.swift new file mode 100644 index 0000000..abce82d --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/LiveSeries.swift @@ -0,0 +1,225 @@ +import CoreGraphics +import Foundation + +/// One live frame of a value: when it was stamped, and the watts. +public struct LiveSample: Equatable, Sendable { + public var t: Double + public var v: Double + + public init(t: Double, v: Double) { + self.t = t + self.v = v + } +} + +/// The live trace's geometry, built once per reading off the main actor. +/// +/// It draws one point per reading, not per frame: a slow device repeats its +/// value in every 1 Hz frame, and a frame that repeats is no new truth. The +/// readings pass through a Gaussian in screen space, 2.5 pt wide, whose +/// reach shrinks to nothing at the head, so the head is the raw last reading +/// and the line never leaves the range of the readings near it. A band +/// shows the lowest and highest raw reading of each 2 s frame on the clock, +/// so it holds still as the trace scrolls. A gap stays a break. +/// +/// x is placed for `nowMs`: now sits `inset` inside the right edge. A view +/// scrolls by translating by `shift(at:)`; nothing rebuilds per frame. +public struct LiveSeries: Equatable, Sendable { + public var nowMs: Double + public var windowMs: Double + public var size: CGSize + public var inset: CGFloat + public var domain: YDomain + /// Each run's readings, oldest first, as drawn: one per reading. The + /// scrub's dot sits on these. + public var runs: [[LiveSample]] + /// The smoothed line, one subpath per run. + public var line: ChartPath + /// The same curves closed on zero. + public var body: ChartPath + /// The lowest and highest raw reading per 2 s frame, one shape per run. + public var band: ChartPath + /// The last raw reading, where the head's dot sits. + public var head: CGPoint? + + /// Points per ms across the plot. + public var ptPerMs: CGFloat { windowMs > 0 ? (size.width - inset) / CGFloat(windowMs) : 0 } + + /// Where a moment sits, for the geometry's own now. + public func x(_ t: Double) -> CGFloat { size.width - inset - CGFloat(nowMs - t) * ptPerMs } + + /// How far the drawing has moved left by `ms`. + public func shift(at ms: Double) -> CGFloat { CGFloat(ms - nowMs) * ptPerMs } + + /// Every reading, oldest first, for one binary search under a finger. + public var readings: [LiveSample] { runs.flatMap { $0 } } + + public static let sigma: CGFloat = 2.5 + public static let headInset: CGFloat = 6 + public static let bandFrameMs: Double = 2_000 + /// After smoothing, one drawn point per this many points of width. + public static let step: CGFloat = 1.5 + + /// The trace of `frames` (sorted by time) in a window `windowMs` long + /// that ends at `nowMs`, on a plot of `size`. `periodMs` is how often + /// the device itself reads, `gapMs` how long a silence may last before + /// the line breaks. `previous` is the domain on screen, so the axis + /// holds still; `floor` is the node's least scale. + public static func build( + _ frames: [LiveSample], + periodMs: Double = 1_000, + gapMs: Double, + windowMs: Double, + nowMs: Double, + size: CGSize, + previous: YDomain? = nil, + floor: Double = 1, + sigma: CGFloat = LiveSeries.sigma, + inset: CGFloat = LiveSeries.headInset + ) -> LiveSeries { + // Keep the reading before the window too, so the line runs off the + // left edge instead of starting inside it. + let fromMs = nowMs - windowMs + let firstInside = frames.firstIndex { $0.t >= fromMs } ?? frames.count + let kept = Array(frames[max(0, firstInside - 1)...].prefix { $0.t <= nowMs }) + let runs = readings(kept, periodMs: periodMs, gapMs: gapMs) + let frameBands = runs.map { bandFrames($0, frameMs: bandFrameMs) } + + var low = 0.0, high = 0.0 + for run in frameBands { + for f in run { + low = min(low, f.low) + high = max(high, f.high) + } + } + let domain = YDomain.stable(low: low, high: high, previous: previous, floor: floor) + var series = LiveSeries(nowMs: nowMs, windowMs: windowMs, size: size, inset: inset, domain: domain, runs: runs, line: ChartPath(), body: ChartPath(), band: ChartPath(), head: nil) + let map = YMap(domain: domain, height: size.height) + let base = map.baseline + + for (run, frames) in zip(runs, frameBands) { + let raw = run.map { CGPoint(x: series.x($0.t), y: map.y($0.v)) } + let drawn = decimated(smoothed(raw, sigma: sigma), step: step) + let start = series.line.elements.count + series.line.addMonotone(drawn) + if drawn.count > 1 { + series.body.elements.append(contentsOf: series.line.elements[start...]) + series.body.elements.append(.line(CGPoint(x: drawn[drawn.count - 1].x, y: base))) + series.body.elements.append(.line(CGPoint(x: drawn[0].x, y: base))) + series.body.elements.append(.close) + } + // A frame's point sits at its middle on the clock, kept inside + // the run, so the band never reaches past the head. + guard let first = raw.first?.x, let last = raw.last?.x else { continue } + let xs = frames.map { min(max(series.x($0.startMs + bandFrameMs / 2), first), last) } + series.band.addBand( + highs: zip(xs, frames).map { CGPoint(x: $0, y: map.y($1.high)) }, + lows: zip(xs, frames).map { CGPoint(x: $0, y: map.y($1.low)) } + ) + } + if let last = runs.last?.last { + series.head = CGPoint(x: series.x(last.t), y: map.y(last.v)) + } + return series + } + + /// Frames to readings, split into runs at each silence longer than + /// `gapMs`. A run of equal frames is one reading at its first frame. If + /// it lasts longer than 1.5 read periods it also keeps a point one + /// period before the next value, so a steady value draws flat and only + /// the last period slopes. A run's last frame stays, so the line holds + /// its value up to the silence or the head. + public static func readings(_ frames: [LiveSample], periodMs: Double, gapMs: Double) -> [[LiveSample]] { + var runs: [[LiveSample]] = [] + var run: [LiveSample] = [] + var i = 0 + while i < frames.count { + // The equal frames that start at i, inside one run. + var j = i + while j + 1 < frames.count, frames[j + 1].v == frames[i].v, frames[j + 1].t - frames[j].t <= gapMs { j += 1 } + let first = frames[i], lastEqual = frames[j] + run.append(first) + let next = j + 1 < frames.count ? frames[j + 1] : nil + if let next, next.t - lastEqual.t <= gapMs { + // The value changes inside the run. + let held = next.t - periodMs + if next.t - first.t > 1.5 * periodMs, held > first.t { run.append(LiveSample(t: held, v: first.v)) } + } else { + // A silence or the head: the run ends on its last frame. + if lastEqual.t > first.t { run.append(lastEqual) } + runs.append(run) + run = [] + } + i = j + 1 + } + return runs + } + + /// Zero-phase Gaussian in screen space, for one run sorted by x. The + /// reach shrinks to nothing at the head, so the last reading is drawn + /// raw. It weights real readings only, so it stays inside their range. + public static func smoothed(_ p: [CGPoint], sigma: CGFloat) -> [CGPoint] { + guard p.count > 2, sigma > 0, let head = p.last?.x else { return p } + var out = p + for i in p.indices { + let reach = min(3 * sigma, head - p[i].x) + guard reach > 0 else { continue } + var sw: CGFloat = 0, sy: CGFloat = 0 + var j = i + while j >= 0, p[i].x - p[j].x <= reach { + let d = (p[j].x - p[i].x) / sigma, w = exp(-0.5 * d * d) + sw += w + sy += w * p[j].y + j -= 1 + } + j = i + 1 + while j < p.count, p[j].x - p[i].x <= reach { + let d = (p[j].x - p[i].x) / sigma, w = exp(-0.5 * d * d) + sw += w + sy += w * p[j].y + j += 1 + } + out[i].y = sy / sw + } + return out + } + + /// At most one point per `step` of width, the first and the last kept. + public static func decimated(_ p: [CGPoint], step: CGFloat) -> [CGPoint] { + guard p.count > 2, step > 0 else { return p } + var out: [CGPoint] = [p[0]] + out.reserveCapacity(p.count) + for q in p.dropFirst().dropLast() where q.x - out[out.count - 1].x >= step { + out.append(q) + } + let last = p[p.count - 1] + if out.count > 1, last.x - out[out.count - 1].x < step / 2 { out.removeLast() } + out.append(last) + return out + } + + /// One 2 s frame of the band: where it starts on the clock and the + /// lowest and highest reading in it. + public struct Frame: Equatable, Sendable { + public var startMs: Double + public var low: Double + public var high: Double + } + + /// The readings of one run in frames `frameMs` long that start on the + /// clock (a multiple of `frameMs` since 1970), never at the window's + /// edge, so a frame holds the same readings however the window moves. + public static func bandFrames(_ run: [LiveSample], frameMs: Double) -> [Frame] { + var out: [Frame] = [] + for s in run { + let start = (s.t / frameMs).rounded(.down) * frameMs + if let last = out.last, last.startMs == start { + out[out.count - 1].low = min(last.low, s.v) + out[out.count - 1].high = max(last.high, s.v) + } else { + out.append(Frame(startMs: start, low: s.v, high: s.v)) + } + } + return out + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/PriceChart.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PriceChart.swift new file mode 100644 index 0000000..97bc5e3 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PriceChart.swift @@ -0,0 +1,271 @@ +import CoreGraphics +import Foundation + +/// The price chart, worked out once per change of prices, plan or minute, +/// for the app and the widget alike. A price holds across its slot, so +/// each slot is a bar on a uniform grid that starts at the current slot. +/// Bars are ink by thirds of the window's range. It carries the average, +/// the cheapest two hours, now, tomorrow's slots still to come, and what a +/// finger reads on every slot, so a touch costs a subscript. A view only +/// maps it to its size. +public struct PriceChart: Equatable, Sendable { + /// Which third of the window's range a price falls in: the dearer, the + /// stronger the ink. No red and no green. + public enum Third: Int, Equatable, Sendable { + case cheap, middle, dear + } + + /// An hour label on the time axis. + public struct Tick: Equatable, Sendable { + public var ms: Double + public var label: String + } + + public var grid: SlotGrid + /// Minor units per kWh, per slot; nil where no price is published yet. + public var minor: [Double?] + /// The same in the unit the chart draws (öre, cent). + public var shown: [Double?] + public var thirds: [Third?] + public var domain: YDomain + /// The window's average, in the unit drawn, and as a figure: "101". + public var meanShown: Double? + public var meanText: String + /// The cheapest two whole hours in a row inside the window. + public var cheapest: ClosedRange? + /// "CHEAPEST 00:00–02:00 · 28,5 ÖRE" + public var cheapestText: String + public var nowMs: Double + public var nowIndex: Int? + /// The slots at the end of the window that have no price yet. + public var pending: ClosedRange? + /// "TOMORROW ~13:00", over the pending slots. + public var pendingText: String + public var hours: [Tick] + /// "öre/kWh" + public var unit: String + /// Per slot: "02:15–02:30". + public var when: [String] + /// Per slot: "31,0", never with a minus sign. + public var figure: [String] + /// Per slot, for the tag on the rule: "02:15 31,0 öre". + public var tag: [String] + /// Per slot: a negative price in words, and what the plan does then. + public var doing: [String] + public var currency: String + /// The clock at each slot's edge: entry `i` starts slot `i`, the last + /// ends the window. + public var edges: [String] + /// Prefix sums of the known prices and of how many there are, so two + /// fingers' mean costs a subtraction. + public var sums: [Double] + public var counts: [Int] + /// Slots that give a light impact under a finger: now, the cheapest + /// window's edges, both ends, and each whole hour when the slots are + /// shorter than an hour. + public var landmarks: Set + + /// Whether any slot has a price. + public var isEmpty: Bool { !minor.contains { $0 != nil } } + + /// The mean of the known prices between two fingers, in minor units. + public func mean(_ r: ClosedRange) -> Double? { + let lo = max(0, r.lowerBound), hi = min(minor.count - 1, r.upperBound) + guard hi >= lo, counts[hi + 1] > counts[lo] else { return nil } + return (sums[hi + 1] - sums[lo]) / Double(counts[hi + 1] - counts[lo]) + } + + /// "02:00–04:00", for two fingers. + public func when(_ r: ClosedRange) -> String { + edges[max(0, r.lowerBound)] + "–" + edges[min(edges.count - 1, r.upperBound + 1)] + } + + /// "51,9", never with a minus sign. + public func figure(_ minor: Double) -> String { + Words.decimal(PriceStrip.text(abs(minor), currency)) + } + + /// The chart of `slots` from the current slot to `spanMs` ahead. + /// `everyHours` spaces the hour labels. + public static func build( + _ slots: [PriceSlot], + currency: String, + nowMs: Double, + spanMs: Double = 24 * 3_600_000, + everyHours: Int = 6, + plan: PlanEntry? = nil, + clock: ZoneClock + ) -> PriceChart { + let valid = slots.filter { $0.totalMinor.isFinite && $0.durationMs > 0 }.sorted { $0.startMs < $1.startMs } + let ahead = valid.filter { $0.startMs + $0.durationMs > nowMs } + // One grid step for the window: the shortest slot ahead, so an hour + // of quarters and an hour slot sit on the same grid. + let stepMs = max(60_000, ahead.map(\.durationMs).min() ?? 900_000) + let current = ahead.first { $0.startMs <= nowMs } + let fromMs = current?.startMs ?? clock.alignDown(nowMs, stepMs: stepMs) + let count = max(1, Int((spanMs / stepMs).rounded(.up))) + let toMs = fromMs + Double(count) * stepMs + let grid = SlotGrid(firstMs: fromMs, stepMs: stepMs, count: count, fromMs: fromMs, toMs: toMs) + + var minor = [Double?](repeating: nil, count: count) + for i in 0.. lo else { return .middle } + return v < lo + third ? .cheap : v >= lo + 2 * third ? .dear : .middle + } + let shownKnown = shown.compactMap { $0 } + let domain = YDomain.stable(low: min(0, shownKnown.min() ?? 0), high: max(0, shownKnown.max() ?? 0)) + let meanMinor = known.isEmpty ? nil : known.reduce(0, +) / Double(known.count) + + let unit = PriceUnits.unit(currency) + func figure(_ m: Double) -> String { Words.decimal(PriceStrip.text(abs(m), currency)) } + func span(_ a: Double, _ b: Double) -> String { clock.label(a) + "–" + clock.label(b) } + + var cheapest: ClosedRange? + var cheapestText = "" + let inside = valid.filter { $0.startMs + $0.durationMs > fromMs && $0.startMs < toMs } + if let block = PriceStrip.bestBlock(inside, hours: 2, cheapest: true), + let a = grid.index(atMs: max(block.startMs, fromMs)), let b = grid.index(atMs: min(block.endMs, toMs) - 1) { + cheapest = a...b + cheapestText = PriceWord.cheapest.format(span(block.startMs, block.endMs), figure(block.meanMinor) + " " + unit.label.uppercased()) + } + + let nowIndex = nowMs >= fromMs && nowMs < toMs ? grid.index(atMs: nowMs) : nil + + var pending: ClosedRange? + var pendingText = "" + if case .some(nil) = minor.last, let lastKnown = minor.lastIndex(where: { $0 != nil }) { + pending = (lastKnown + 1)...(count - 1) + if clock.minuteOfDay(grid.startMs(lastKnown + 1)) == 0 { + pendingText = clock.minuteOfDay(nowMs) < 14 * 60 ? PriceWord.tomorrowAround.text : PriceWord.tomorrow.text + } + } + + // Whole local hours a few apart, clear of now's label and at least + // an hour from the right edge. + var hours: [Tick] = [] + let hour = 3_600_000.0 + var t = clock.alignDown(fromMs, stepMs: hour) + hour + while t <= toMs - hour { + let minute = clock.minuteOfDay(t) + if minute % 60 == 0, (minute / 60) % max(1, everyHours) == 0, t - nowMs >= 1.5 * hour { + hours.append(Tick(ms: t, label: String(clock.label(t).prefix(2)))) + } + t += hour + } + + var when: [String] = [], figures: [String] = [], tags: [String] = [], doing: [String] = [] + when.reserveCapacity(count) + figures.reserveCapacity(count) + tags.reserveCapacity(count) + doing.reserveCapacity(count) + var landmarks: Set = [0, count - 1] + if let nowIndex { landmarks.insert(nowIndex) } + if let cheapest { + landmarks.insert(cheapest.lowerBound) + landmarks.insert(cheapest.upperBound) + } + // One label per edge: each slot ends where the next starts. + let edges = (0...count).map { clock.label(grid.startMs($0)) } + for i in 0.. 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) + } +} + +/// The price chart's words. The English is the key; the Swedish lives in +/// `Resources/sv.lproj/Price.strings`, and a test checks each one has it. +enum PriceWord: String, CaseIterable { + case cheapest = "CHEAPEST %@ · %@" + case average = "AVG" + case now = "NOW" + case tomorrowAround = "TOMORROW ~13:00" + case tomorrow = "TOMORROW" + case noPriceYet = "No price yet" + case paid = "Below zero: you are paid to use power" + + var text: String { Words.bundle.localizedString(forKey: rawValue, value: rawValue, table: "Price") } + + func format(_ args: String...) -> String { + var out = text + for a in args { + guard let r = out.range(of: "%@") else { break } + out.replaceSubrange(r, with: a) + } + return out + } +} + +extension PriceChart { + /// "AVG", over the average's figure in the right gutter. + public static var averageWord: String { PriceWord.average.text } + /// "NOW", under the now rule. + public static var nowWord: String { PriceWord.now.text } + /// A negative price, in words. + public static var paidWord: String { PriceWord.paid.text } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Price.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Price.strings new file mode 100644 index 0000000..8b5de8f --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Price.strings @@ -0,0 +1,8 @@ +/* The price chart. Keys are the English (PriceWord). */ +"CHEAPEST %@ · %@" = "CHEAPEST %@ · %@"; +"AVG" = "AVG"; +"NOW" = "NOW"; +"TOMORROW ~13:00" = "TOMORROW ~13:00"; +"TOMORROW" = "TOMORROW"; +"No price yet" = "No price yet"; +"Below zero: you are paid to use power" = "Below zero: you are paid to use power"; diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Price.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Price.strings new file mode 100644 index 0000000..85e745b --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Price.strings @@ -0,0 +1,8 @@ +/* Prisdiagrammet. Nyckeln är engelskan (PriceWord). */ +"CHEAPEST %@ · %@" = "BILLIGAST %@ · %@"; +"AVG" = "SNITT"; +"NOW" = "NU"; +"TOMORROW ~13:00" = "I MORGON ~13:00"; +"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"; diff --git a/appleApp/FTWKit/Tests/FTWKitTests/ChartStrokeTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/ChartStrokeTests.swift new file mode 100644 index 0000000..e0dc1da --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/ChartStrokeTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Testing +@testable import FTWKit + +/// Every stroke in `FTWCharts` joins round. SwiftUI joins with a miter by +/// default, and on a polyline a sharp turn then draws a point past the data: +/// 4.8 pt beyond the vertex for a 2.5 pt stroke turning 30°. +@Suite struct ChartStrokeTests { + static let folder = "appleApp/FTWCharts" + + static func sources() throws -> [(name: String, text: String)] { + let dir = RepoFiles.root.appendingPathComponent(folder) + let names = try FileManager.default.contentsOfDirectory(atPath: dir.path).filter { $0.hasSuffix(".swift") }.sorted() + return try names.map { ($0, try RepoFiles.read("\(folder)/\($0)")) } + } + + /// The text of each call that opens at `open`, up to its closing + /// parenthesis. + static func calls(_ open: String, in text: String) -> [String] { + var out: [String] = [] + var rest = text[...] + while let r = rest.range(of: open) { + var depth = 1, i = r.upperBound + while i < rest.endIndex, depth > 0 { + if rest[i] == "(" { depth += 1 } else if rest[i] == ")" { depth -= 1 } + i = rest.index(after: i) + } + out.append(String(rest[r.lowerBound.. 0, "no StrokeStyle found in \(Self.folder)") + } + + @Test func eachNodeHasAScaleFloorAndARatingTakesItsPlace() { + #expect(EnergyNode.home.scaleFloorW() == 2_000) + #expect(EnergyNode.grid.scaleFloorW() == 3_000) + #expect(EnergyNode.allCases.allSatisfy { $0.scaleFloorW() >= 2_000 }) + #expect(EnergyNode.car.scaleFloorW(rating: 11_000) == 11_000) + #expect(EnergyNode.battery.scaleFloorW(rating: .nan) == 3_000) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift index 41299cf..1f23da9 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift @@ -441,25 +441,6 @@ import FTWShared #expect(both?.what.hasPrefix("↑ \(AppWords.energy(2_500)) ↓ \(AppWords.energy(0)) · average") == true) } } - - /// The price under a finger is never missing while the finger is down: - /// the audit found the marker vanished at the right edge and in gaps. - @Test func aPriceUnderTheFingerHoldsAtTheEdgesAndInAGap() { - AppWords.$language.withValue(.en) { - let q = 900_000.0 - // 00:00, 00:15, then a gap, then 00:45. - let prices = [ChartPrice(startMs: 0, endMs: q, minor: 50.5), ChartPrice(startMs: q, endMs: 2 * q, minor: 70.5), ChartPrice(startMs: 3 * q, endMs: 4 * q, minor: 90.5)] - func what(_ ms: Double) -> String? { Readouts.price(prices, currency: "SEK", plan: nil, at: ms)?.what } - #expect(what(10) == "50.5 öre/kWh") - #expect(what(-q) == "50.5 öre/kWh") - // The gap reads as the step the line draws there. - #expect(what(2.5 * q) == "70.5 öre/kWh") - // The right edge and past it stay on the last slot. - #expect(what(4 * q) == "90.5 öre/kWh") - #expect(what(9 * q) == "90.5 öre/kWh") - #expect(Readouts.price([], currency: "SEK", plan: nil, at: 0) == nil) - } - } } @Suite struct HistoryWireTests { diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/ChartGeometryTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/ChartGeometryTests.swift index 21d325c..06925f9 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/ChartGeometryTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/ChartGeometryTests.swift @@ -256,13 +256,13 @@ import Testing // readout says so. let from = Self.at(13, 0) let samples = (0..<12).map { ChartSample(startMs: from + Double($0) * 300_000, value: (3..<6).contains($0) ? nil : 500) } - let series = ChartSeries.history(samples, fromMs: from, toMs: from + 3_600_000, size: CGSize(width: 20, height: 100), clock: Self.clock, words: Self.words) + let series = ChartSeries.history(samples, fromMs: from, toMs: from + 3_600_000, size: CGSize(width: 40, height: 100), clock: Self.clock, words: Self.words) #expect(series.grid.stepMs == 900_000) let gap = series.grid.index(atMs: from + 1_200_000) #expect(gap == 1) #expect(series.points[1] == nil) #expect(series.readouts.what[1] == "no data") - #expect(series.grid.index(atX: 1_000, width: 20) == 3) + #expect(series.grid.index(atX: 1_000, width: 40) == 3) // The live curve: one binary search, clamped, nearest either way. let times: [Double] = [0, 1_000, 2_000, 9_000] @@ -275,10 +275,12 @@ import Testing // MARK: Buckets - @Test func displayBucketsAreTheSmallestThatLeaveTwoAndAHalfPoints() { + @Test func displayBucketsAreTheSmallestThatLeaveSixPoints() { let day = 86_400_000.0 - #expect(DisplayBucket.stepMs(spanMs: day, width: 370) == 900_000) - #expect(DisplayBucket.stepMs(spanMs: 2 * day, width: 370) == 1_800_000) + // About 48 slots of 7 pt across a phone, the plan strip's rule. + #expect(DisplayBucket.stepMs(spanMs: day, width: 370) == 1_800_000) + #expect(DisplayBucket.stepMs(spanMs: 2 * day, width: 370) == 3_600_000) + #expect(DisplayBucket.stepMs(spanMs: day, width: 800) == 900_000) #expect(DisplayBucket.stepMs(spanMs: 3_600_000, width: 370) == 300_000) #expect(DisplayBucket.stepMs(spanMs: 30 * day, width: 370) == 3_600_000) } @@ -304,7 +306,8 @@ import Testing inWh += held.filter { $0 >= 0 }.reduce(0, +) / 12 outWh -= held.filter { $0 < 0 }.reduce(0, +) / 12 } - let series = ChartSeries.history(samples, fromMs: from, toMs: to, size: CGSize(width: 370, height: 200), clock: Self.clock, words: Self.words) + // 15-minute slots need 8 pt each: an iPad's or a landscape width. + let series = ChartSeries.history(samples, fromMs: from, toMs: to, size: CGSize(width: 800, height: 200), clock: Self.clock, words: Self.words) #expect(series.buckets == buckets) let all = series.readouts.soFar(95) #expect(abs(all.inWh - inWh) < 1e-6 && abs(all.outWh - outWh) < 1e-6) @@ -407,11 +410,11 @@ import Testing let from = Self.at(13, 0) var dice = Dice(state: 5) let samples = (0..<720).map { ChartSample(startMs: from + Double($0) * 300_000, value: dice.next() < 0.05 ? nil : (dice.next() - 0.3) * 4_000) } - // 2.5 pt a point: 720 five-minute points, more than any phone - // chart draws. + // 6 pt a point: 720 five-minute points, more than any phone chart + // draws. var points = 0 let history = Self.cost { - let series = ChartSeries.history(samples, fromMs: from, toMs: from + 720 * 300_000, nowMs: from + 600 * 300_000, size: CGSize(width: 1_800, height: 300), clock: Self.clock, words: Self.words) + let series = ChartSeries.history(samples, fromMs: from, toMs: from + 720 * 300_000, nowMs: from + 600 * 300_000, size: CGSize(width: 4_320, height: 300), clock: Self.clock, words: Self.words) points = series.points.count } #expect(points == 720) @@ -420,5 +423,9 @@ import Testing let noisy = Self.heater(720) let trace = Self.cost { _ = ChartPaths.trace(noisy) } #expect(trace < Self.budget, "trace built in \(trace)") + // The live series at 720 frames: readings, Gaussian, Steffen, band. + let frames = (0..<720).map { LiveSample(t: Double($0) * 1_000, v: Double(200 - noisy[$0].y) * 6) } + let live = Self.cost { _ = LiveSeries.build(frames, gapMs: 3_500, windowMs: 720_000, nowMs: 719_000, size: CGSize(width: 350, height: 300)) } + #expect(live < Self.budget, "live series built in \(live)") } } diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/ChartRecipeTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/ChartRecipeTests.swift new file mode 100644 index 0000000..e180cca --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/ChartRecipeTests.swift @@ -0,0 +1,325 @@ +import CoreGraphics +import Foundation +import Testing +@testable import FTWShared + +/// How a line looks, in numbers: how often it turns and how much ink it +/// spends. A turn counts once the line travels more than one stroke width +/// back from its last extreme; ink is line length per 100 pt of width. +enum Calm { + static let stroke: CGFloat = 2.5 + + static func turnsPer100(_ pts: [CGPoint], travel: CGFloat = stroke) -> Double { + guard pts.count > 1, let first = pts.first, let last = pts.last, last.x > first.x else { return 0 } + var dir = 0, ext = first.y, lo = first.y, hi = first.y, turns = 0 + for p in pts.dropFirst() { + switch dir { + case 1: + if p.y > ext { ext = p.y } else if ext - p.y > travel { dir = -1; ext = p.y; turns += 1 } + case -1: + if p.y < ext { ext = p.y } else if p.y - ext > travel { dir = 1; ext = p.y; turns += 1 } + default: + lo = min(lo, p.y) + hi = max(hi, p.y) + if p.y - lo > travel { dir = 1; ext = p.y } else if hi - p.y > travel { dir = -1; ext = p.y } + } + } + return Double(turns) / Double(last.x - first.x) * 100 + } + + static func inkPer100(_ pts: [CGPoint]) -> Double { + guard pts.count > 1, let first = pts.first, let last = pts.last, last.x > first.x else { return 0 } + var length: CGFloat = 0 + for i in 1.. [ChartSample] { + var dice = ChartGeometryTests.Dice(state: seed) + return (0..<288).map { i in + let on = (i / 3) % 2 == 0 + let v: Double? = (96..<102).contains(i) ? nil : 300 + (on ? 1_800 : 0) + dice.next() * 80 + return ChartSample(startMs: from + Double(i) * 300_000, value: v) + } + } + + @Test func everySlotMeanSitsInsideItsBandAndTheDomainHoldsEveryEdge() { + let from = ChartGeometryTests.at(13, 0) + let series = ChartSeries.history(Self.heaterDay(from: from), fromMs: from, toMs: from + 86_400_000, size: CGSize(width: 350, height: 280), clock: Self.clock, words: Self.words) + #expect(series.grid.stepMs == 1_800_000) + #expect(series.grid.count == 48) + let map = YMap(domain: series.domain, height: 280) + for (b, p) in zip(series.buckets, series.points) { + guard let m = b.mean, let lo = b.low, let hi = b.high, let p else { continue } + #expect(lo <= m && m <= hi) + #expect(map.y(hi) <= p.y + 1e-9 && p.y <= map.y(lo) + 1e-9) + #expect(series.domain.lower <= lo && hi <= series.domain.upper) + } + // One closed band per run: the hole makes two. + let moves = series.band.elements.filter { if case .move = $0 { return true } else { return false } }.count + let closes = series.band.elements.filter { $0 == .close }.count + #expect(moves == 2 && closes == 2) + // The band's vertices are the slot highs and lows, nothing past them. + let ys = series.band.flattened().map(\.y) + let edges = series.buckets.compactMap { b in b.low.map { (map.y(b.high!), map.y($0)) } } + #expect((ys.min() ?? 0) >= (edges.map(\.0).min() ?? 0) - 1e-6) + #expect((ys.max() ?? 0) <= (edges.map(\.1).max() ?? 0) + 1e-6) + } + + @Test func theDomainFitsTheHighestFiveMinuteMeanNotJustTheSlotMean() { + let from = ChartGeometryTests.at(13, 0) + // One spike of 4 kW in a quiet half hour: its slot mean is 1 kW. + let samples = (0..<6).map { ChartSample(startMs: from + Double($0) * 300_000, value: $0 == 2 ? 4_000 : 400) } + let series = ChartSeries.history(samples, fromMs: from, toMs: from + 1_800_000, size: CGSize(width: 10, height: 100), clock: Self.clock, words: Self.words) + #expect(series.domain.upper >= 4_000) + } + + @Test func aHoleNeverCountsAsZeroInABand() { + let from = ChartGeometryTests.at(13, 0) + let samples = [500.0, nil, 700, nil, 600, 650].enumerated().map { ChartSample(startMs: from + Double($0.offset) * 300_000, value: $0.element) } + let b = DisplayBucket.buckets(samples, stepMs: 1_800_000, fromMs: from, toMs: from + 1_800_000, clock: Self.clock) + #expect(b.count == 1) + #expect(b[0].low == 500 && b[0].high == 700 && b[0].count == 4) + } + + @Test func aReversedPathDrawsTheSameShapeTheOtherWay() { + var p = ChartPaths.monotone([CGPoint(x: 0, y: 10), CGPoint(x: 5, y: 2), CGPoint(x: 9, y: 7), CGPoint(x: 14, y: 7)]) + p.elements.append(.quad(CGPoint(x: 20, y: 0), control: CGPoint(x: 17, y: 9))) + let back = p.reversed() + #expect(back.reversed() == p) + let a = p.flattened(), b = back.flattened() + #expect(a.count == b.count) + for (x, y) in zip(a, b.reversed()) { #expect(hypot(x.x - y.x, x.y - y.y) < 1e-9) } + // Two subpaths come back in the other order, a closed one closed. + let two = ChartPath([.move(CGPoint(x: 0, y: 0)), .line(CGPoint(x: 1, y: 1)), .close, .move(CGPoint(x: 5, y: 5)), .line(CGPoint(x: 6, y: 5))]) + #expect(two.reversed().elements == [.move(CGPoint(x: 6, y: 5)), .line(CGPoint(x: 5, y: 5)), .move(CGPoint(x: 0, y: 0)), .line(CGPoint(x: 1, y: 1)), .line(CGPoint(x: 0, y: 0)), .close]) + } + + // MARK: Live + + /// The research's live trace: house 450 W, meter noise ±25 W, a 2 kW + /// heater 40 s on and 50 s off, five minutes at 1 Hz. + static func liveHeater(seconds: Int = 300, seed: UInt64 = 9) -> (frames: [LiveSample], edges: Int) { + var dice = ChartGeometryTests.Dice(state: seed) + var edges = 0, was = false + let frames = (0.. LiveSample in + let on = s % 90 < 40 + if s > 0, on != was { edges += 1 } + was = on + return LiveSample(t: Double(s) * 1_000, v: 450 + (on ? 2_000 : 0) + (dice.next() - 0.5) * 50) + } + return (frames, edges) + } + + static let liveSize = CGSize(width: 350, height: 280) + + static func live(_ frames: [LiveSample], nowMs: Double? = nil, gapMs: Double = 3_500, periodMs: Double = 1_000) -> LiveSeries { + LiveSeries.build(frames, periodMs: periodMs, gapMs: gapMs, windowMs: 300_000, nowMs: nowMs ?? frames.last!.t, size: liveSize, floor: 3_000) + } + + @Test func theLiveLineIsCalmOnAnOnOffLoad() { + let (frames, edges) = Self.liveHeater() + let series = Self.live(frames) + let line = series.line.flattened(steps: 8) + let edgesPer100 = Double(edges) / Double(Self.liveSize.width) * 100 + let turns = Calm.turnsPer100(line) + // Every frame drawn raw, as 0.3.2 did, for comparison. + let map = YMap(domain: series.domain, height: Self.liveSize.height) + let raw = frames.map { CGPoint(x: series.x($0.t), y: map.y($0.v)) } + #expect(turns <= 2 * edgesPer100 + 2, "live turns \(turns) per 100 pt, edges \(edgesPer100)") + #expect(Calm.turnsPer100(raw) > 5 * turns, "raw \(Calm.turnsPer100(raw)) against \(turns)") + #expect(Calm.inkPer100(line) < Calm.inkPer100(raw)) + } + + @Test func theHeadIsTheLastRawReadingAndTheLineEndsOnIt() throws { + let (frames, _) = Self.liveHeater() + let series = Self.live(frames) + let last = try #require(frames.last) + let map = YMap(domain: series.domain, height: Self.liveSize.height) + let head = try #require(series.head) + #expect(head == CGPoint(x: series.x(last.t), y: map.y(last.v))) + #expect(abs(head.x - (Self.liveSize.width - LiveSeries.headInset)) < 1e-9) + let end = try #require(series.line.flattened().last) + #expect(hypot(end.x - head.x, end.y - head.y) < 1e-9) + } + + @Test func theLiveLineNeverLeavesTheReadingsNearIt() { + let (frames, _) = Self.liveHeater(seed: 21) + let series = Self.live(frames) + let map = YMap(domain: series.domain, height: Self.liveSize.height) + let raw = series.readings.map { CGPoint(x: series.x($0.t), y: map.y($0.v)) } + let reach = 3 * LiveSeries.sigma + LiveSeries.step + for p in series.line.flattened(steps: 6) { + let near = raw.filter { abs($0.x - p.x) <= reach }.map(\.y) + guard let lo = near.min(), let hi = near.max() else { continue } + #expect(p.y >= lo - 1e-6 && p.y <= hi + 1e-6, "at x \(p.x): \(p.y) outside \(lo)…\(hi)") + } + } + + @Test func theBandSitsOnTheClockNotOnTheWindowEdge() { + let (frames, _) = Self.liveHeater(seconds: 400) + let a = Self.live(frames, nowMs: 380_000) + let b = Self.live(frames, nowMs: 380_300) + // Frames inside both windows hold the same readings. + let from = 380_300 - 300_000 + LiveSeries.bandFrameMs + let fa = a.runs.flatMap { LiveSeries.bandFrames($0, frameMs: LiveSeries.bandFrameMs) }.filter { $0.startMs >= from && $0.startMs < 378_000 } + let fb = b.runs.flatMap { LiveSeries.bandFrames($0, frameMs: LiveSeries.bandFrameMs) }.filter { $0.startMs >= from && $0.startMs < 378_000 } + #expect(!fa.isEmpty && fa == fb) + #expect(fa.allSatisfy { $0.startMs.truncatingRemainder(dividingBy: LiveSeries.bandFrameMs) == 0 }) + // And they draw at the same place once the 0.3 s scroll is taken + // off. + for (x, y) in zip(fa, fb) { #expect(abs(a.x(x.startMs) - (b.x(y.startMs) + 300 * b.ptPerMs)) < 1e-9) } + } + + @Test func aSilenceBreaksTheLineAndIsNeverBridged() { + var (frames, _) = Self.liveHeater() + frames.removeAll { $0.t > 100_000 && $0.t < 112_000 } + let series = Self.live(frames) + #expect(series.runs.count == 2) + let moves = series.line.elements.filter { if case .move = $0 { return true } else { return false } }.count + #expect(moves == 2) + let gap = (series.x(100_000)...series.x(112_000)) + // Nothing drawn inside the silence but the ends of the two runs. + let inside = series.line.flattened().filter { gap.contains($0.x) && $0.x > gap.lowerBound + 0.01 && $0.x < gap.upperBound - 0.01 } + #expect(inside.isEmpty) + } + + @Test func oneReadingPerValueAndASteadyValueStaysFlat() { + // A device read every 10 s, its value in every 1 Hz frame. + let values: [Double] = [100, 300, 300, 200] + let frames = (0..<40).map { LiveSample(t: Double($0) * 1_000, v: values[$0 / 10]) } + let runs = LiveSeries.readings(frames, periodMs: 10_000, gapMs: 3_500) + #expect(runs.count == 1) + // 100 at 0; 300 at 10 s, held until one period before 200 arrives; + // 200 at 30 s and its last frame at the head. + #expect(runs[0] == [LiveSample(t: 0, v: 100), LiveSample(t: 10_000, v: 300), LiveSample(t: 20_000, v: 300), LiveSample(t: 30_000, v: 200), LiveSample(t: 39_000, v: 200)]) + // At 1 Hz an equal neighbour is a reading of its own, kept as a flat. + let steady = [5.0, 5, 5, 5, 9].enumerated().map { LiveSample(t: Double($0.offset) * 1_000, v: $0.element) } + #expect(LiveSeries.readings(steady, periodMs: 1_000, gapMs: 3_500)[0].map(\.t) == [0, 3_000, 4_000]) + } + + @Test func theLiveDomainHoldsTheBandOnTheNodesFloor() { + let quiet = (0..<120).map { LiveSample(t: Double($0) * 1_000, v: Double($0 % 7) * 20 - 60) } + let series = Self.live(quiet) + #expect(series.domain.upper == 3_000 && series.domain.lower == -3_000) + } + + // MARK: Price + + static func quarters(from: Double, count: Int, price: (Int) -> Double) -> [PriceSlot] { + (0..= 100 && chart.domain.lower == 0) + #expect(chart.meanShown == 54.666666666666664) + } + + @Test func aNegativePriceHangsBelowZeroAndIsSaidInWords() throws { + let from = ChartGeometryTests.at(14, 0) + let slots = Self.quarters(from: from, count: 96) { $0 == 8 ? -12.5 : 40 } + let chart = PriceChart.build(slots, currency: "SEK", nowMs: from, clock: Self.clock) + #expect(chart.domain.lower < -12.5) + for s in chart.figure + chart.tag { #expect(!s.contains("-") && !s.contains("−")) } + #expect(chart.doing[8].hasPrefix(PriceWord.paid.text)) + #expect(chart.doing[9].isEmpty) + } + + @Test func hourlyPricesFillTheQuarterGridAndTheCheapestTwoHoursAreFound() throws { + let from = ChartGeometryTests.at(14, 0) + // Quarters today, hours tomorrow, as some markets publish. + let today = Self.quarters(from: from, count: 96) { $0 >= 80 && $0 < 88 ? 5 : 50 } + let tomorrow = (0..<24).map { PriceSlot(startMs: from + 86_400_000 + Double($0) * 3_600_000, durationMs: 3_600_000, spotMinor: 60, totalMinor: 60) } + let chart = PriceChart.build(today + tomorrow, currency: "SEK", nowMs: ChartGeometryTests.at(14, 18), clock: Self.clock) + #expect(chart.grid.stepMs == 900_000 && chart.pending == nil) + #expect(chart.minor[95] == 60) + let cheap = try #require(chart.cheapest) + #expect(chart.when[cheap.lowerBound].hasPrefix("20:00") && chart.when[cheap.upperBound].hasSuffix("22:00")) + #expect(chart.landmarks.contains(cheap.lowerBound) && chart.landmarks.contains(cheap.upperBound)) + #expect(chart.cheapestText.contains("20:00–22:00")) + } + + @Test func thePlanSaysWhatHappensInEachSlot() throws { + let from = ChartGeometryTests.at(14, 0) + let slots = Self.quarters(from: from, count: 96) { _ in 40 } + let plan = PlanEntry(battery: [PlanEntry.Slot(startMs: from + 3_600_000, durationMs: 900_000, batteryW: 3_000, reason: "")], charges: [], prices: [], currency: "SEK", boxStale: false) + let chart = PriceChart.build(slots, currency: "SEK", nowMs: from, plan: plan, clock: Self.clock) + #expect(chart.doing[4] == PlanGlance.what(PlanGlance.Event(kind: .charge, startMs: 0, endMs: 1))) + #expect(chart.doing[3].isEmpty && chart.doing[5].isEmpty) + } + + /// The audit found the price marker vanished at the right edge and in + /// a hole. Past either edge the finger keeps the edge's slot; a hole + /// keeps its own slot and says it has no price. + @Test func aFingerAlwaysHasAPriceSlot() { + let from = ChartGeometryTests.at(14, 0) + var slots = Self.quarters(from: from, count: 96) { 50 + Double($0) } + slots.remove(at: 2) + let chart = PriceChart.build(slots, currency: "SEK", nowMs: from, clock: Self.clock) + #expect(chart.grid.index(atX: -40, width: 300) == 0) + #expect(chart.grid.index(atX: 340, width: 300) == 95) + #expect(chart.minor[2] == nil && chart.minor[1] == 51 && chart.minor[3] == 53) + #expect(chart.figure[2] == "—" && chart.doing[2] == PriceWord.noPriceYet.text) + #expect(chart.pending == nil) + // Two fingers over the hole average the prices that exist. + #expect(chart.mean(1...3) == 52) + #expect(chart.mean(2...2) == nil) + #expect(chart.when(0...7) == "00:00–02:00") + } + + @Test func everyPriceWordHasSwedish() throws { + let url = try #require(Words.bundle.url(forResource: "Price", withExtension: "strings", subdirectory: nil, localization: "sv")) + let sv = try #require(NSDictionary(contentsOf: url) as? [String: String]) + for word in PriceWord.allCases { + let swedish = try #require(sv[word.rawValue], "no Swedish for \"\(word.rawValue)\"") + #expect(!swedish.isEmpty) + #expect(word.rawValue.components(separatedBy: "%@").count == swedish.components(separatedBy: "%@").count, "\(word.rawValue)") + } + } +} diff --git a/appleApp/FTWUITests/PriceScrubTour.swift b/appleApp/FTWUITests/PriceScrubTour.swift new file mode 100644 index 0000000..91d266c --- /dev/null +++ b/appleApp/FTWUITests/PriceScrubTour.swift @@ -0,0 +1,60 @@ +import XCTest + +/// A finger on the price chart: a fast sweep past both edges and back, +/// then ten seconds of scrubbing under the hitch metric. For recordings and +/// the hitch number in a pull request. Skipped unless asked for: +/// +/// TEST_RUNNER_FTW_SCRUB=1 xcodebuild test … -only-testing:FTWUITests/PriceScrubTour +/// +/// The scrub log (`log stream --level debug --predicate 'category == "scrub"'`) +/// shows one slot per change while the finger is down, and "touching with +/// no slot" if the marker were ever lost. `TEST_RUNNER_FTW_FRAMES=YES` +/// logs late frames each second (category "frames"), where Instruments' +/// hitch tools cannot run. +final class PriceScrubTour: XCTestCase { + @MainActor + private func open() throws -> (XCUIApplication, XCUIElement) { + let env = ProcessInfo.processInfo.environment + try XCTSkipUnless(env["FTW_SCRUB"] == "1", "Set TEST_RUNNER_FTW_SCRUB=1 to drive the price chart.") + let lang = env["FTW_LANG"] ?? "sv" + let app = XCUIApplication() + app.launchArguments = ["-FTWDemo", "YES", "-FTWTab", "price", "-FTWFrameLog", env["FTW_FRAMES"] ?? "NO", "-AppleLanguages", "(\(lang))", "-AppleLocale", lang == "sv" ? "sv_SE" : "en_US"] + app.launch() + let chart = app.descendants(matching: .any)["price.chart"].firstMatch + XCTAssertTrue(chart.waitForExistence(timeout: 15)) + return (app, chart) + } + + @MainActor + private func sweep(_ chart: XCUIElement, from a: CGFloat, to b: CGFloat, velocity: XCUIGestureVelocity, hold: TimeInterval = 0.4) { + let from = chart.coordinate(withNormalizedOffset: CGVector(dx: a, dy: 0.5)) + let to = chart.coordinate(withNormalizedOffset: CGVector(dx: b, dy: 0.5)) + from.press(forDuration: 0.05, thenDragTo: to, withVelocity: velocity, thenHoldForDuration: hold) + } + + /// Fast across the whole chart and past the right edge, then back past + /// the left one. + @MainActor + func testFastSweepPastBothEdges() throws { + let (_, chart) = try open() + sweep(chart, from: 0.03, to: 1.08, velocity: .fast, hold: 1) + sweep(chart, from: 0.97, to: -0.05, velocity: .fast, hold: 1) + sweep(chart, from: 0.5, to: 0.6, velocity: .slow, hold: 1.5) + } + + /// Ten seconds of scrubbing back and forth, at the speeds a thumb uses. + @MainActor + func testTenSecondsOfScrubbing() throws { + let (app, chart) = try open() + let options = XCTMeasureOptions() + options.iterationCount = 1 + measure(metrics: [XCTHitchMetric(application: app)], options: options) { + let began = Date() + var right = true + while Date().timeIntervalSince(began) < 10 { + sweep(chart, from: right ? 0.02 : 0.98, to: right ? 1.05 : -0.03, velocity: right ? .fast : .default, hold: 0.2) + right.toggle() + } + } + } +} diff --git a/appleApp/FTWWidgets/PriceViews.swift b/appleApp/FTWWidgets/PriceViews.swift index 9e5a3b1..3c627c6 100644 --- a/appleApp/FTWWidgets/PriceViews.swift +++ b/appleApp/FTWWidgets/PriceViews.swift @@ -1,4 +1,3 @@ -import Charts import FTWShared import SwiftUI import WidgetKit @@ -182,30 +181,21 @@ struct Empty: View { } } -/// The next twelve hours as the app draws them (`PriceLineChart`, shared -/// with the app): one stepped line coloured by level, now as a rule and a -/// dot, the cheapest hours as a soft band, the average as a faint rule. +/// The next twelve hours as the app draws them (`PricePaths`, shared with +/// the app): a bar per slot in ink by thirds, the now slot in full ink, +/// the average as a dotted rule and the cheapest two hours bracketed. /// -/// Tinted and clear home screens and StandBy at night draw in one colour, -/// so there the line is one colour and the band carries the cheap hours. +/// Tinted and clear home screens and StandBy at night draw in one colour; +/// the bars keep their three strengths. struct Curve: View { let moment: PriceGlance.Moment @Environment(\.widgetRenderingMode) private var renderingMode var body: some View { - PriceLineChart( - points: moment.curve.map { PricePoint(startMs: $0.startMs, endMs: $0.endMs, value: PriceUnits.display($0.minor, moment.currency)) }, - nowMs: moment.atMs, - mean: moment.meanMinor.map { PriceUnits.display($0, moment.currency) }, - cheapest: moment.cheapest.map { $0.startMs...$0.endMs }, - cheapestLabel: nil, - meanLabel: nil, - palette: .ink, - zone: moment.timeZone, - labelEveryHours: 3, + PriceBarsStill( + chart: PriceChart.build(bars: moment.curve, currency: moment.currency, nowMs: moment.atMs, spanMs: PriceGlance.curveMs, everyHours: 3, clock: ZoneClock.of(moment.timeZone)), monochrome: renderingMode != .fullColor ) - .widgetAccentable() .accessibilityHidden(true) } }