From cbe601abc9b9bded10ca59893c3ddae8bed9216b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 08:30:06 +0200 Subject: [PATCH 1/2] iPhone: the finger owns the chart, and the price chart on it (WP3) The price chart now draws in one Canvas that never sees the finger, with a small overlay that alone redraws under it. A fast sweep past both edges keeps its marker, a slot change costs one overlay draw, and the surface draws once a minute. - FTWCharts: ChartSurface, ScrubOverlay (one finger scrubs, two hold a span), ScrubModel, and PriceBars: a bar per slot in ink by thirds, the now slot in full ink, the average as a dotted rule with its figure in the gutter, the cheapest two hours bracketed and named, tomorrow's slots as hatched stubs. The widget draws the same shapes as still paths; PriceLineChart and Swift Charts leave the price chart. - The price view: a 60 pt hero that gives way to the slot under the finger, the chart, and two reserved lines for what the plan does then. - Engine, from the chart research: DisplayBucket.minWidth 6 (Today 30 min, 48 h 60 min), ChartSeries.band and a domain that fits the band's edges, ChartPath.reversed, LiveSeries (one point per reading, a Gaussian 2.5 pt wide with a raw head, a 2 s band on the clock), a scale floor per node, and PriceChart. - CADisableMinimumFrameDurationOnPhone, so a chart may move above 60 Hz. - A test holds round joins on every stroke in FTWCharts. - The demo prices in quarter hours, as the Nordic market does. - Debug only: -FTWFrameLog logs late frames and chart draws each second, and -FTWAutoScrub drives the overlay, for the simulator, where Instruments' hitch tools do not run. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 + appleApp/FTW-Info.plist | 2 + appleApp/FTW/UI/FrameGapProbe.swift | 52 +++ appleApp/FTW/UI/PriceView.swift | 282 ++++++++------- appleApp/FTW/UI/RootView.swift | 3 + appleApp/FTWCharts/ChartSurface.swift | 72 ++++ appleApp/FTWCharts/Palette.swift | 6 - appleApp/FTWCharts/PriceBars.swift | 225 ++++++++++++ appleApp/FTWCharts/PriceLineChart.swift | 206 ----------- appleApp/FTWCharts/ScrubModel.swift | 81 +++++ appleApp/FTWCharts/ScrubOverlay.swift | 203 +++++++++++ .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 4 +- .../Sources/FTWKit/State/ChartWords.swift | 22 -- .../Sources/FTWKit/State/EnergyNode.swift | 16 + .../Sources/FTWKit/State/NodeWords.swift | 2 + .../Sources/FTWShared/Charts/ChartPaths.swift | 58 ++++ .../FTWShared/Charts/ChartSeries.swift | 35 +- .../Sources/FTWShared/Charts/LiveSeries.swift | 225 ++++++++++++ .../Sources/FTWShared/Charts/PriceChart.swift | 271 +++++++++++++++ .../Resources/en.lproj/Price.strings | 8 + .../Resources/sv.lproj/Price.strings | 8 + .../Tests/FTWKitTests/ChartStrokeTests.swift | 56 +++ .../FTWKit/Tests/FTWKitTests/NodeTests.swift | 19 - .../FTWSharedTests/ChartGeometryTests.swift | 25 +- .../FTWSharedTests/ChartRecipeTests.swift | 325 ++++++++++++++++++ appleApp/FTWUITests/PriceScrubTour.swift | 60 ++++ appleApp/FTWWidgets/PriceViews.swift | 24 +- 27 files changed, 1881 insertions(+), 415 deletions(-) create mode 100644 appleApp/FTW/UI/FrameGapProbe.swift create mode 100644 appleApp/FTWCharts/ChartSurface.swift create mode 100644 appleApp/FTWCharts/PriceBars.swift delete mode 100644 appleApp/FTWCharts/PriceLineChart.swift create mode 100644 appleApp/FTWCharts/ScrubModel.swift create mode 100644 appleApp/FTWCharts/ScrubOverlay.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/Charts/LiveSeries.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/Charts/PriceChart.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Price.strings create mode 100644 appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Price.strings create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/ChartStrokeTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/ChartRecipeTests.swift create mode 100644 appleApp/FTWUITests/PriceScrubTour.swift 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..df735f7 --- /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), 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..1d64c68 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), + 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..5b914a8 --- /dev/null +++ b/appleApp/FTWCharts/ChartSurface.swift @@ -0,0 +1,72 @@ +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 + static var overlayMs = 0.0 + static var worstMs = 0.0 + + static func take() -> (surface: Int, overlay: Int, overlayMs: Double, worstMs: Double) { + defer { + surface = 0 + overlay = 0 + overlayMs = 0 + worstMs = 0 + } + return (surface, overlay, 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..62ea4bd --- /dev/null +++ b/appleApp/FTWCharts/ScrubModel.swift @@ -0,0 +1,81 @@ +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 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..4e76ecc --- /dev/null +++ b/appleApp/FTWCharts/ScrubOverlay.swift @@ -0,0 +1,203 @@ +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 + + 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.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)) + 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) + } + + 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) } } From 319fcd0fb4de7a89db24a34a508435e3ba185cf8 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 08:45:13 +0200 Subject: [PATCH 2/2] iPhone: the price slot under the finger lights up as its whole bar Fredrik on 0.3.2: the price scrub should colour the whole 15-minute slot the finger is on, not just a line. The overlay now dims the rest of the plot under the ground at half strength, washes the slot's column from top to bottom and draws its bar again in full ink, its whole width and height. Two fingers light every bar between them. The rule stays as a hairline. The debug frame log counts slot changes beside overlay draws. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/UI/FrameGapProbe.swift | 2 +- appleApp/FTW/UI/PriceView.swift | 2 +- appleApp/FTWCharts/ChartSurface.swift | 7 +++-- appleApp/FTWCharts/ScrubModel.swift | 3 ++ appleApp/FTWCharts/ScrubOverlay.swift | 45 ++++++++++++++++++++++++++- 5 files changed, 54 insertions(+), 5 deletions(-) diff --git a/appleApp/FTW/UI/FrameGapProbe.swift b/appleApp/FTW/UI/FrameGapProbe.swift index df735f7..7690b0f 100644 --- a/appleApp/FTW/UI/FrameGapProbe.swift +++ b/appleApp/FTW/UI/FrameGapProbe.swift @@ -44,7 +44,7 @@ final class FrameGaps { 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), overlay draws \(draws.overlay), overlay draw \(draws.overlayMs, format: .fixed(precision: 2)) ms, worst \(draws.worstMs, format: .fixed(precision: 2)) ms") + 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) } diff --git a/appleApp/FTW/UI/PriceView.swift b/appleApp/FTW/UI/PriceView.swift index 1d64c68..d6418e7 100644 --- a/appleApp/FTW/UI/PriceView.swift +++ b/appleApp/FTW/UI/PriceView.swift @@ -168,7 +168,7 @@ private struct PriceBarsChart: View { 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), + 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, diff --git a/appleApp/FTWCharts/ChartSurface.swift b/appleApp/FTWCharts/ChartSurface.swift index 5b914a8..a97e1c0 100644 --- a/appleApp/FTWCharts/ChartSurface.swift +++ b/appleApp/FTWCharts/ChartSurface.swift @@ -46,17 +46,20 @@ struct ChartSurface: View, Equatable { 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, overlayMs: Double, worstMs: Double) { + 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, overlayMs, worstMs) + return (surface, overlay, changes, overlayMs, worstMs) } } #endif diff --git a/appleApp/FTWCharts/ScrubModel.swift b/appleApp/FTWCharts/ScrubModel.swift index 62ea4bd..36d7684 100644 --- a/appleApp/FTWCharts/ScrubModel.swift +++ b/appleApp/FTWCharts/ScrubModel.swift @@ -73,6 +73,9 @@ final class ScrubModel { } 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 } diff --git a/appleApp/FTWCharts/ScrubOverlay.swift b/appleApp/FTWCharts/ScrubOverlay.swift index 4e76ecc..ad1179a 100644 --- a/appleApp/FTWCharts/ScrubOverlay.swift +++ b/appleApp/FTWCharts/ScrubOverlay.swift @@ -18,6 +18,10 @@ struct ScrubTrack: Equatable { 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) } @@ -117,6 +121,12 @@ struct ScrubOverlay: View { 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))) } @@ -140,12 +150,45 @@ struct ScrubOverlay: View { 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)) - g.fill(Path(CGRect(x: x0, y: plot.minY, width: max(px, x1 - x0), height: plot.height)), with: .color(Theme.ink.opacity(0.08))) + 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))