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