diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift index 9b723e1..b66f387 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift @@ -295,14 +295,11 @@ public enum AppWords { /// "07:00" in a zone, the 24-hour clock both languages use for FTW. public static func clock(_ ms: Double, zone: TimeZone) -> String { - var cal = Calendar(identifier: .gregorian) - cal.timeZone = zone - let c = cal.dateComponents([.hour, .minute], from: Date(timeIntervalSince1970: ms / 1000)) - return String(format: "%02d:%02d", c.hour ?? 0, c.minute ?? 0) + ZoneClock.of(zone).label(ms) } public static func clock(minute: Int) -> String { - String(format: "%02d:%02d", (minute / 60) % 24, minute % 60) + ZoneClock.label(minute: minute) } /// "today 07:00", "tomorrow 07:00", "Tue 07:00", else "12 Oct 07:00". diff --git a/appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift index bccb54d..3d141d3 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift @@ -35,10 +35,7 @@ public enum Readouts { public static func span(_ a: Double, _ b: Double) -> String { "\(clock(a))–\(clock(b))" } /// "14:35:12", for the live curve's seconds. - public static func seconds(_ ms: Double) -> String { - let c = Calendar.current.dateComponents([.hour, .minute, .second], from: Date(timeIntervalSince1970: ms / 1000)) - return String(format: "%02d:%02d:%02d", c.hour ?? 0, c.minute ?? 0, c.second ?? 0) - } + public static func seconds(_ ms: Double) -> String { ZoneClock.of(.current).seconds(ms) } /// A step of history: its power and which way, and the energy since the /// chart's start up to the end of the step. diff --git a/appleApp/FTWKit/Sources/FTWShared/ChargeNow.swift b/appleApp/FTWKit/Sources/FTWShared/ChargeNow.swift index 9f43ca5..30a9e2e 100644 --- a/appleApp/FTWKit/Sources/FTWShared/ChargeNow.swift +++ b/appleApp/FTWKit/Sources/FTWShared/ChargeNow.swift @@ -190,12 +190,7 @@ public enum ChargeNowActivity { ChargeNowWord.confirmed.format(clock(state.confirmedAtMs, zone: zone)) } - static func clock(_ ms: Double, zone: TimeZone) -> String { - var cal = Calendar(identifier: .gregorian) - cal.timeZone = zone - let c = cal.dateComponents([.hour, .minute], from: Date(timeIntervalSince1970: ms / 1000)) - return String(format: "%02d:%02d", c.hour ?? 0, c.minute ?? 0) - } + static func clock(_ ms: Double, zone: TimeZone) -> String { ZoneClock.of(zone).label(ms) } } /// What the Charge now Control shows, from the car record the app left in diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/ChartPaths.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/ChartPaths.swift new file mode 100644 index 0000000..8a12269 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/ChartPaths.swift @@ -0,0 +1,345 @@ +import CoreGraphics +import Foundation + +/// A path as plain values in screen points, so it can be made off the +/// main actor, kept in a `Sendable` geometry and tested. A view draws it +/// with `Path(chartPath.cgPath)`. +public struct ChartPath: Equatable, Sendable { + public enum Element: Equatable, Sendable { + case move(CGPoint) + case line(CGPoint) + case quad(CGPoint, control: CGPoint) + case curve(CGPoint, control1: CGPoint, control2: CGPoint) + case close + } + + public var elements: [Element] = [] + + public init(_ elements: [Element] = []) { + self.elements = elements + } + + public var isEmpty: Bool { elements.isEmpty } + + public var cgPath: CGPath { + let p = CGMutablePath() + for e in elements { + switch e { + case let .move(to): p.move(to: to) + case let .line(to): p.addLine(to: to) + case let .quad(to, c): p.addQuadCurve(to: to, control: c) + case let .curve(to, c1, c2): p.addCurve(to: to, control1: c1, control2: c2) + case .close: p.closeSubpath() + } + } + return p + } + + /// Points along the path, each curve cut into `steps` straight pieces. + /// For tests and hit checks, not for drawing. + func flattened(steps: Int = 24) -> [CGPoint] { + var out: [CGPoint] = [] + var at = CGPoint.zero, start = CGPoint.zero + for e in elements { + switch e { + case let .move(to): + at = to + start = to + out.append(to) + case let .line(to): + at = to + out.append(to) + case let .quad(to, c): + for k in 1...steps { + let s = CGFloat(k) / CGFloat(steps), r = 1 - s + out.append(CGPoint(x: r * r * at.x + 2 * r * s * c.x + s * s * to.x, y: r * r * at.y + 2 * r * s * c.y + s * s * to.y)) + } + at = to + case let .curve(to, c1, c2): + for k in 1...steps { + let s = CGFloat(k) / CGFloat(steps), r = 1 - s + let a = r * r * r, b = 3 * r * r * s, c = 3 * r * s * s, d = s * s * s + out.append(CGPoint(x: a * at.x + b * c1.x + c * c2.x + d * to.x, y: a * at.y + b * c1.y + c * c2.y + d * to.y)) + } + at = to + case .close: + at = start + out.append(start) + } + } + return out + } +} + +// MARK: - Lines + +extension ChartPath { + /// One run as a smooth curve that invents nothing: Steffen's monotone + /// cubic. Between two points the curve stays between their values, an + /// extreme sits only on a data point, and a flat stretch on either side + /// of a step gives the step level shoulders. `pts` run left to right; + /// a gap is two runs, so it stays a break. A lone point is a line of no + /// length, which a round cap draws as a dot. + public mutating func addMonotone(_ pts: [CGPoint]) { + guard let first = pts.first else { return } + elements.append(.move(first)) + let n = pts.count + guard n > 1 else { + elements.append(.line(first)) + return + } + elements.reserveCapacity(elements.count + n) + var h = [CGFloat](repeating: 0, count: n - 1), s = h + for i in 0..<(n - 1) { + h[i] = max(pts[i + 1].x - pts[i].x, 1e-6) + s[i] = (pts[i + 1].y - pts[i].y) / h[i] + } + var m = [CGFloat](repeating: 0, count: n) + m[0] = s[0] + m[n - 1] = s[n - 2] + if n > 2 { + for i in 1..<(n - 1) { + let p = (s[i - 1] * h[i] + s[i] * h[i - 1]) / (h[i - 1] + h[i]) + let sign: CGFloat = (s[i - 1] > 0 ? 1 : s[i - 1] < 0 ? -1 : 0) + (s[i] > 0 ? 1 : s[i] < 0 ? -1 : 0) + m[i] = sign * min(abs(s[i - 1]), abs(s[i]), 0.5 * abs(p)) + } + } + for i in 0..<(n - 1) { + let third = h[i] / 3 + elements.append(.curve( + pts[i + 1], + control1: CGPoint(x: pts[i].x + third, y: pts[i].y + m[i] * third), + control2: CGPoint(x: pts[i + 1].x - third, y: pts[i + 1].y - m[i + 1] * third) + )) + } + } + + /// The same curve closed on `baseline`, so the body and the line always + /// agree. A signed series fills it twice, clipped above and below zero. + public mutating func addMonotoneBody(_ pts: [CGPoint], baseline: CGFloat) { + guard pts.count > 1, let first = pts.first, let last = pts.last else { return } + addMonotone(pts) + elements.append(.line(CGPoint(x: last.x, y: baseline))) + elements.append(.line(CGPoint(x: first.x, y: baseline))) + elements.append(.close) + } + + /// 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 + /// `maxRadius`. The arc stays inside the corner, so it can trim an + /// extreme but never add one or go past one. + public mutating func addRoundedPolyline(_ pts: [CGPoint], maxRadius: CGFloat = 5, maxShift: CGFloat = 0.5) { + guard let first = pts.first else { return } + elements.append(.move(first)) + let n = pts.count + guard n > 1 else { return } + elements.reserveCapacity(elements.count + 2 * n) + if n > 2 { + for i in 1..<(n - 1) { + let p = pts[i - 1], v = pts[i], q = pts[i + 1] + let ax = p.x - v.x, ay = p.y - v.y, bx = q.x - v.x, by = q.y - v.y + let la = (ax * ax + ay * ay).squareRoot(), lb = (bx * bx + by * by).squareRoot() + guard la > 0, lb > 0 else { + elements.append(.line(v)) + continue + } + // θ is the angle inside the corner; the line turns by π − θ. + let cosInside = min(max((ax * bx + ay * by) / (la * lb), -1), 1) + let sinHalfTurn = ((1 + cosInside) / 2).squareRoot() + let cosHalfTurn = ((1 - cosInside) / 2).squareRoot() + guard sinHalfTurn > 1e-6 else { + elements.append(.line(v)) + continue + } + // A quad with its control on the corner passes the corner + // at t·sin(turn/2)/2: that sets the shift cap. + var t = min(la / 2, lb / 2, 2 * maxShift / sinHalfTurn) + if cosHalfTurn > 1e-9 { t = min(t, maxRadius * sinHalfTurn / cosHalfTurn) } + guard t > 1e-6 else { + elements.append(.line(v)) + continue + } + elements.append(.line(CGPoint(x: v.x + ax / la * t, y: v.y + ay / la * t))) + elements.append(.quad(CGPoint(x: v.x + bx / lb * t, y: v.y + by / lb * t), control: v)) + } + } + elements.append(.line(pts[n - 1])) + } + + /// A value per slot, constant across it (prices, the plan), as steps + /// with rounded corners. A corner's radius is at most `radius`, half + /// a slot and half the rise, so a step still reads as a step and no + /// part of the line sits between two slots' values except the riser. + /// A gap between slots breaks the line. + public mutating func addRoundedSteps(_ steps: [Step], radius: CGFloat = 3) { + var drawing = false + for (i, s) in steps.enumerated() { + if !drawing { + elements.append(.move(CGPoint(x: s.x0, y: s.y))) + drawing = true + } + let next = i + 1 < steps.count ? steps[i + 1] : nil + guard let next, next.x0 - s.x1 <= 0.5 else { + elements.append(.line(CGPoint(x: s.x1, y: s.y))) + drawing = false + continue + } + let rise = next.y - s.y + let r = min(radius, (s.x1 - s.x0) / 2, (next.x1 - next.x0) / 2, abs(rise) / 2) + guard r > 0 else { + elements.append(.line(CGPoint(x: s.x1, y: s.y))) + continue + } + let dir: CGFloat = rise > 0 ? 1 : -1 + elements.append(.line(CGPoint(x: s.x1 - r, y: s.y))) + elements.append(.quad(CGPoint(x: s.x1, y: s.y + dir * r), control: CGPoint(x: s.x1, y: s.y))) + elements.append(.line(CGPoint(x: s.x1, y: next.y - dir * r))) + elements.append(.quad(CGPoint(x: s.x1 + r, y: next.y), control: CGPoint(x: s.x1, y: next.y))) + } + } + + /// One slot of a stepped line, in screen points. + public struct Step: Equatable, Sendable { + public var x0: CGFloat + public var x1: CGFloat + public var y: CGFloat + + public init(x0: CGFloat, x1: CGFloat, y: CGFloat) { + self.x0 = x0 + self.x1 = x1 + self.y = y + } + } +} + +// MARK: - Builders + +public enum ChartPaths { + public static func monotone(_ pts: [CGPoint]) -> ChartPath { + var p = ChartPath() + p.addMonotone(pts) + return p + } + + public static func roundedPolyline(_ pts: [CGPoint], maxRadius: CGFloat = 5, maxShift: CGFloat = 0.5) -> ChartPath { + var p = ChartPath() + p.addRoundedPolyline(pts, maxRadius: maxRadius, maxShift: maxShift) + return p + } + + public static func roundedSteps(_ steps: [ChartPath.Step], radius: CGFloat = 3) -> ChartPath { + var p = ChartPath() + p.addRoundedSteps(steps, radius: radius) + return p + } + + /// The live trace: simplified, then rounded, with half of the + /// 0.5 pt budget for each step, so no sample ends up more than 0.5 pt + /// from the line drawn through it. + public static func trace(_ pts: [CGPoint], maxRadius: CGFloat = 5) -> ChartPath { + roundedPolyline(simplify(pts, epsilon: 0.25), maxRadius: maxRadius, maxShift: 0.25) + } + + /// Ramer–Douglas–Peucker: drops every point that lies within `epsilon` + /// of the line kept through its neighbours. Every point of `pts` stays + /// within `epsilon` of the result, and the ends are kept. + public static func simplify(_ pts: [CGPoint], epsilon: CGFloat = 0.5) -> [CGPoint] { + let n = pts.count + guard n > 2 else { return pts } + var keep = [Bool](repeating: false, count: n) + keep[0] = true + keep[n - 1] = true + let e2 = epsilon * epsilon + var stack = [(0, n - 1)] + while let (a, b) = stack.popLast() { + guard b - a > 1 else { continue } + let pa = pts[a], dx = pts[b].x - pa.x, dy = pts[b].y - pa.y + let len2 = dx * dx + dy * dy + var worst: CGFloat = -1, at = a + for i in (a + 1).. 0 { + t = ((p.x - pa.x) * dx + (p.y - pa.y) * dy) / len2 + t = t < 0 ? 0 : t > 1 ? 1 : t + } + let ex = pa.x + t * dx - p.x, ey = pa.y + t * dy - p.y + let d = ex * ex + ey * ey + if d > worst { + worst = d + at = i + } + } + if worst > e2 { + keep[at] = true + stack.append((a, at)) + stack.append((at, b)) + } + } + var out: [CGPoint] = [] + out.reserveCapacity(n) + for i in 0.. CGFloat { + let dx = b.x - a.x, dy = b.y - a.y + let len2 = dx * dx + dy * dy + var t: CGFloat = 0 + if len2 > 0 { t = min(max(((p.x - a.x) * dx + (p.y - a.y) * dy) / len2, 0), 1) } + let x = a.x + t * dx - p.x, y = a.y + t * dy - p.y + return x * x + y * y + } + + /// Slots with equal values merged into one shape each, from the + /// baseline to the value, with every edge on a device pixel and a gap of + /// `gapPx` device pixels after each shape, so neighbours never blur into + /// one another or leave a seam inside a run. A slot with no value or a + /// zero value draws nothing. + public static func runs(_ values: [Double?], grid: SlotGrid, width: CGFloat, map: YMap, scale: CGFloat, maxRadius: CGFloat, gapPx: CGFloat = 1) -> [BarRun] { + let scale = max(scale, 1) + func snap(_ v: CGFloat) -> CGFloat { (v * scale).rounded() / scale } + let base = snap(map.baseline) + var out: [BarRun] = [] + var i = 0 + let n = min(values.count, grid.count) + while i < n { + guard let v = values[i], v != 0 else { + i += 1 + continue + } + var j = i + 1 + while j < n, values[j] == v { j += 1 } + let x0 = snap(max(0, grid.x(atMs: grid.startMs(i), width: width))) + let x1 = snap(min(width, grid.x(atMs: grid.endMs(j - 1), width: width))) - gapPx / scale + let y = snap(map.y(v)) + let rect = CGRect(x: x0, y: min(y, base), width: x1 - x0, height: abs(y - base)) + if rect.width > 0, rect.height > 0 { + out.append(BarRun(first: i, count: j - i, value: v, rect: rect, radius: min(maxRadius, rect.width / 2, rect.height))) + } + i = j + } + return out + } +} + +/// Equal neighbouring slots drawn as one shape. +public struct BarRun: Equatable, Sendable { + public var first: Int + public var count: Int + public var value: Double + /// Snapped to device pixels, the gap already taken off its right side. + public var rect: CGRect + /// For the corners away from the baseline. + public var radius: CGFloat + + public init(first: Int, count: Int, value: Double, rect: CGRect, radius: CGFloat) { + self.first = first + self.count = count + self.value = value + self.rect = rect + self.radius = radius + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/ChartSeries.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/ChartSeries.swift new file mode 100644 index 0000000..891022a --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/ChartSeries.swift @@ -0,0 +1,207 @@ +import CoreGraphics +import Foundation + +/// One of the box's five-minute means, or none where it has no record. +public struct ChartSample: Equatable, Sendable { + public var startMs: Double + public var value: Double? + + public init(startMs: Double, value: Double?) { + self.startMs = startMs + self.value = value + } +} + +/// A display bucket: the five-minute means that fall in one slot of the +/// chart, and what they add up to. Nothing in it is invented. +public struct ChartBucket: Equatable, Sendable { + public var startMs: Double + public var endMs: Double + /// The exact mean of the five-minute means it holds; nil when it holds + /// none. + public var mean: Double? + /// The lowest and the highest of those means. + public var low: Double? + public var high: Double? + /// How many five-minute means it holds. + public var count: Int + /// Where its point sits: the middle of the means it holds, so a bucket + /// still filling draws its mean so far where that data is. + public var atMs: Double + /// Energy into and out of the site over its means, in watt-hours. + public var inWh: Double + public var outWh: Double +} + +/// 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. +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 func stepMs(spanMs: Double, width: CGFloat) -> Double { + guard spanMs > 0, width > 0 else { return stepsMs[0] } + return stepsMs.first { CGFloat($0 / spanMs) * width >= minWidth } ?? stepsMs[stepsMs.count - 1] + } + + /// Five-minute means gathered into buckets `stepMs` long on the local + /// clock, from the one that holds `fromMs` to the one that holds the + /// moment before `toMs`. A mean outside the window counts nowhere. + public static func buckets(_ samples: [ChartSample], sampleMs: Double = 300_000, stepMs: Double, fromMs: Double, toMs: Double, clock: ZoneClock) -> [ChartBucket] { + guard toMs > fromMs, stepMs > 0 else { return [] } + let origin = clock.alignDown(fromMs, stepMs: stepMs) + let count = Int(((toMs - origin) / stepMs).rounded(.up)) + var sum = [Double](repeating: 0, count: count), at = sum, inWh = sum, outWh = sum + var low = [Double](repeating: .infinity, count: count), high = [Double](repeating: -.infinity, count: count) + var held = [Int](repeating: 0, count: count) + let whPerW = sampleMs / 3_600_000 + for s in samples where s.startMs >= fromMs && s.startMs < toMs { + guard let v = s.value else { continue } + let k = min(max(Int(((s.startMs - origin) / stepMs).rounded(.down)), 0), count - 1) + sum[k] += v + held[k] += 1 + low[k] = min(low[k], v) + high[k] = max(high[k], v) + at[k] += s.startMs + sampleMs / 2 + if v >= 0 { inWh[k] += v * whPerW } else { outWh[k] -= v * whPerW } + } + var out: [ChartBucket] = [] + out.reserveCapacity(count) + for k in 0.. 0 ? sum[k] / Double(n) : nil, + low: n > 0 ? low[k] : nil, + high: n > 0 ? high[k] : nil, + count: n, + atMs: n > 0 ? at[k] / Double(n) : (max(start, fromMs) + min(start + stepMs, toMs)) / 2, + inWh: inWh[k], + outWh: outWh[k] + )) + } + return out + } +} + +/// 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 +/// points, where each slot's value sits, and its readout table. A view +/// only reads it. +public struct ChartSeries: Equatable, Sendable { + public var grid: SlotGrid + public var buckets: [ChartBucket] + public var domain: YDomain + public var size: CGSize + /// The line, one subpath per run of data: a gap stays a break. + public var line: ChartPath + /// The same curves closed on zero. + public var body: ChartPath + /// Where each slot's value sits on the line; nil in a gap. + public var points: [CGPoint?] + public var readouts: ReadoutTable + + /// The history of one node between `fromMs` and `toMs` on a plot of + /// `size`. `previous` is the domain on screen (or the cached day's), + /// so the axis holds still. `nowMs` and `landmarksMs` mark slots that + /// earn a light impact, besides the slots with the highest and lowest + /// five-minute mean. + public static func history( + _ samples: [ChartSample], + sampleMs: Double = 300_000, + fromMs: Double, + toMs: Double, + nowMs: Double? = nil, + size: CGSize, + previous: YDomain? = nil, + floor: Double = 1, + clock: ZoneClock, + landmarksMs: [Double] = [], + words: ReadoutWords + ) -> ChartSeries { + let stepMs = DisplayBucket.stepMs(spanMs: toMs - fromMs, width: size.width) + 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) + + var low = 0.0, high = 0.0 + for b in buckets { + if let m = b.mean { + low = min(low, m) + high = max(high, m) + } + } + 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 points = [CGPoint?](repeating: nil, count: buckets.count) + var run: [CGPoint] = [] + run.reserveCapacity(buckets.count) + let base = map.baseline + func endRun() { + let start = line.elements.count + line.addMonotone(run) + // The body is the same curve, closed on zero: made once. + if run.count > 1 { + body.elements.append(contentsOf: line.elements[start...]) + 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) + } + run.removeAll(keepingCapacity: true) + } + for (i, b) in buckets.enumerated() { + guard let m = b.mean else { + endRun() + continue + } + let p = CGPoint(x: grid.x(atMs: b.atMs, width: size.width), y: map.y(m)) + points[i] = p + run.append(p) + } + endRun() + + var when: [String] = [], what: [String] = [] + var inWh: [Double] = [0], outWh: [Double] = [0] + when.reserveCapacity(buckets.count) + what.reserveCapacity(buckets.count) + inWh.reserveCapacity(buckets.count + 1) + outWh.reserveCapacity(buckets.count + 1) + var highest: (i: Int, v: Double)?, lowest: (i: Int, v: Double)? + // Each slot ends where the next starts: one clock label per edge. + var edges: [String] = buckets.map { clock.label(max($0.startMs, fromMs)) } + if let last = buckets.last { edges.append(clock.label(min(last.endMs, toMs))) } + for (i, b) in buckets.enumerated() { + when.append(edges[i] + "–" + edges[i + 1]) + if let m = b.mean, let lo = b.low, let hi = b.high { + what.append(lo == hi ? words.value(m) : words.value(m) + " · " + words.range(lo, hi)) + if hi > 0, hi > highest?.v ?? 0 { highest = (i, hi) } + if lo < 0, lo < lowest?.v ?? 0 { lowest = (i, lo) } + } else { + what.append(words.none) + } + inWh.append(inWh[i] + b.inWh) + outWh.append(outWh[i] + b.outWh) + } + + var landmarks = Set() + if let highest { landmarks.insert(highest.i) } + if let lowest { landmarks.insert(lowest.i) } + for ms in (nowMs.map { [$0] } ?? []) + landmarksMs where ms >= fromMs && ms < toMs { + if let i = grid.index(atMs: ms) { landmarks.insert(i) } + } + + return ChartSeries( + grid: grid, + buckets: buckets, + domain: domain, + size: size, + line: line, + body: body, + points: points, + readouts: ReadoutTable(when: when, what: what, inWh: inWh, outWh: outWh, landmarks: landmarks) + ) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/ReadoutTable.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/ReadoutTable.swift new file mode 100644 index 0000000..cd90a25 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/ReadoutTable.swift @@ -0,0 +1,57 @@ +import Foundation + +/// What a chart says under the finger, worked out once per change of data, +/// so a slot change costs a subscript: the readout's two parts per slot, +/// prefix sums of energy for "so far" and for a span, and the slots that +/// earn a firmer haptic. +public struct ReadoutTable: Equatable, Sendable { + /// "14:30–14:45", per slot. + public var when: [String] + /// "0,6 kW · 0,0–1,1", per slot; the gap word where a slot has no data. + public var what: [String] + /// Watt-hours into the site through the end of each slot: entry `i + 1` + /// holds slots 0 through `i`. + public var inWh: [Double] + /// Watt-hours out of the site, the same way. + public var outWh: [Double] + /// Slots that give a light impact under the finger. + public var landmarks: Set + + public init(when: [String], what: [String], inWh: [Double] = [], outWh: [Double] = [], landmarks: Set = []) { + self.when = when + self.what = what + self.inWh = inWh + self.outWh = outWh + self.landmarks = landmarks + } + + /// Energy from the window's start through slot `i`. + public func soFar(_ i: Int) -> (inWh: Double, outWh: Double) { + between(0, i) + } + + /// Energy over slots `a` through `b`, both included, in either order. + public func between(_ a: Int, _ b: Int) -> (inWh: Double, outWh: Double) { + guard inWh.count > 1 else { return (0, 0) } + let lo = min(max(min(a, b), 0), inWh.count - 2) + let hi = min(max(max(a, b), 0), inWh.count - 2) + return (inWh[hi + 1] - inWh[lo], outWh[hi + 1] - outWh[lo]) + } +} + +/// How a readout words its numbers. The table keeps the strings it made, +/// not these. +public struct ReadoutWords: Sendable { + /// "0,6 kW" + public var value: @Sendable (Double) -> String + /// "0,0–1,1", the lowest and highest five-minute mean in a slot. + public var range: @Sendable (Double, Double) -> String + /// "no data" + public var none: String + + public init(value: @escaping @Sendable (Double) -> String, range: @escaping @Sendable (Double, Double) -> String, none: String) { + self.value = value + self.range = range + self.none = none + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/ScrubStep.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/ScrubStep.swift new file mode 100644 index 0000000..1929a8e --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/ScrubStep.swift @@ -0,0 +1,48 @@ +import Foundation + +/// The rules for a finger on a chart, kept apart from the view so they can +/// be tested. Work happens only when the finger crosses into another slot. +/// A crossing ticks (`.selection`), at most one tick per 40 ms; a landmark +/// (now, the cheapest window's edges, the day's high and low, the car's +/// goal, the ends of a span) gives a light impact every time. The view +/// bumps its haptic triggers from `ticks` and `bumps` in the same update +/// as the index. +public struct ScrubStep: Equatable, Sendable { + public enum Haptic: Equatable, Sendable { + case none + case selection + case impact + } + + /// The least time between two ticks: about 25 a second at most. + public static let floor: Duration = .milliseconds(40) + + public private(set) var index: Int? + public private(set) var ticks = 0 + public private(set) var bumps = 0 + private var lastHaptic: ContinuousClock.Instant? + + public init() {} + + /// The finger is over slot `next`. Returns what to play, if anything. + @discardableResult + public mutating func move(to next: Int, landmark: Bool, at now: ContinuousClock.Instant = .now) -> Haptic { + guard next != index else { return .none } + index = next + if landmark { + bumps += 1 + lastHaptic = now + return .impact + } + if let last = lastHaptic, now - last < Self.floor { return .none } + ticks += 1 + lastHaptic = now + return .selection + } + + /// The finger lifted. The counters keep counting, so the next touch's + /// first tick still changes them. + public mutating func lift() { + index = nil + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/SlotGrid.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/SlotGrid.swift new file mode 100644 index 0000000..badee6d --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/SlotGrid.swift @@ -0,0 +1,74 @@ +import Foundation + +/// The slots of a chart and where they sit across the plot. A finger's x +/// maps to a slot by arithmetic, clamped to the first and last slot, so a +/// finger past either edge or over a gap still has a slot under it and the +/// marker never vanishes. A gap is a slot with no value: the rule stays on +/// it and the readout says so. +public struct SlotGrid: Equatable, Sendable { + /// Where slot 0 starts. It may start before `fromMs` when the window + /// cuts the first slot. + public var firstMs: Double + public var stepMs: Double + public var count: Int + /// The window the plot's width shows. + public var fromMs: Double + public var toMs: Double + + public init(firstMs: Double, stepMs: Double, count: Int, fromMs: Double, toMs: Double) { + self.firstMs = firstMs + self.stepMs = stepMs + self.count = count + self.fromMs = fromMs + self.toMs = toMs + } + + public func startMs(_ i: Int) -> Double { firstMs + Double(i) * stepMs } + public func endMs(_ i: Int) -> Double { firstMs + Double(i + 1) * stepMs } + + /// The slot that holds `ms`, clamped to the first and last. Nil only + /// when there are no slots. + public func index(atMs ms: Double) -> Int? { + guard count > 0, stepMs > 0 else { return nil } + let raw = ((ms - firstMs) / stepMs).rounded(.down) + guard raw.isFinite else { return raw > 0 ? count - 1 : 0 } + return Int(min(max(raw, 0), Double(count - 1))) + } + + /// The slot under a finger at `x` on a plot `width` wide, clamped. + public func index(atX x: CGFloat, width: CGFloat) -> Int? { + guard width > 0 else { return nil } + return index(atMs: ms(atX: x, width: width)) + } + + /// The moment at `x`, kept inside the window. + public func ms(atX x: CGFloat, width: CGFloat) -> Double { + guard width > 0 else { return fromMs } + return fromMs + Double(min(max(x, 0), width) / width) * (toMs - fromMs) + } + + public func x(atMs ms: Double, width: CGFloat) -> CGFloat { + toMs > fromMs ? CGFloat((ms - fromMs) / (toMs - fromMs)) * width : 0 + } + + /// The middle of the part of a slot the window shows. + public func midX(_ i: Int, width: CGFloat) -> CGFloat { + x(atMs: (max(startMs(i), fromMs) + min(endMs(i), toMs)) / 2, width: width) + } + + /// The live curve is 1 Hz with jitter, not a grid: the sample nearest + /// `t` by one binary search over sorted times, clamped to the first and + /// last. Nil only when there are none. Whether the nearest is too far + /// to count (a gap) is the caller's rule. + public static func nearest(_ times: [Double], to t: Double) -> Int? { + guard !times.isEmpty else { return nil } + var lo = 0, hi = times.count + while lo < hi { + let mid = (lo + hi) / 2 + if times[mid] < t { lo = mid + 1 } else { hi = mid } + } + if lo == 0 { return 0 } + if lo == times.count { return times.count - 1 } + return t - times[lo - 1] <= times[lo] - t ? lo - 1 : lo + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/YDomain.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/YDomain.swift new file mode 100644 index 0000000..7d28144 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/YDomain.swift @@ -0,0 +1,68 @@ +import Foundation + +/// A chart's value axis, from zero or below it to zero or above it. It +/// comes from the data the chart is going to, never from a point in the +/// middle of a spring, and it holds still while data arrives. +public struct YDomain: Equatable, Sendable { + public var lower: Double + public var upper: Double + + public init(lower: Double, upper: Double) { + self.lower = lower + self.upper = upper + } + + public var span: Double { upper - lower } + + /// The steps a domain may end on: 1, 1.5, 2, 3, 5 or 7.5 times a power + /// of ten. + static let steps: [Double] = [1, 1.5, 2, 3, 5, 7.5, 10] + + /// The smallest step at or above `x`. + public static func nice(_ x: Double) -> Double { + guard x > 0, x.isFinite else { return 0 } + let e = pow(10, (log10(x)).rounded(.down)) + // 0.9999 keeps 300 from becoming 500 through rounding in log10. + return (steps.first { $0 * e >= x * 0.9999 } ?? 10) * e + } + + /// The domain for data from `low` to `high`. Each side of zero ends on + /// a nice step at least 10 % past its peak. It grows at once, and + /// shrinks only when the data fills less than 55 % of it, so the labels + /// stay put for hours. `floor`, raised to a nice step, is the least a + /// side with data may reach, and the top when no side has data. + public static func stable(low: Double, high: Double, previous: YDomain? = nil, floor: Double = 1) -> YDomain { + let floor = floor > 0 ? nice(floor) : 1 + let up = max(0, high.isFinite ? high : 0) + let down = max(0, low.isFinite ? -low : 0) + func side(_ peak: Double, _ before: Double?) -> Double { + let target = peak > 0 ? max(nice(peak * 1.1), floor) : 0 + guard let before else { return target } + if target > before { return target } + if peak < before * 0.55 { return target } + return before + } + var top = side(up, previous.map { max(0, $0.upper) }) + let bottom = side(down, previous.map { max(0, -$0.lower) }) + if top == 0, bottom == 0 { top = floor } + return YDomain(lower: -bottom, upper: top) + } +} + +/// Values to screen: y grows down from the top of the plot. +public struct YMap: Equatable, Sendable { + public var domain: YDomain + public var height: CGFloat + + public init(domain: YDomain, height: CGFloat) { + self.domain = domain + self.height = height + } + + public func y(_ v: Double) -> CGFloat { + domain.span > 0 ? CGFloat(1 - (v - domain.lower) / domain.span) * height : height + } + + /// Where zero sits. + public var baseline: CGFloat { y(0) } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/ZoneClock.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/ZoneClock.swift new file mode 100644 index 0000000..3620e1b --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/ZoneClock.swift @@ -0,0 +1,70 @@ +import Foundation +import Synchronization + +/// The clock of one time zone: one Calendar, made once and kept, and +/// "07:00" labels from minute-of-day arithmetic over a table. Nothing that +/// runs per touch or per slot builds a Calendar or calls +/// `String(format:)`. +public struct ZoneClock: Sendable { + public let zone: TimeZone + /// For what arithmetic cannot do, such as the start of a day or a + /// weekday. Made once per zone. + public let calendar: Calendar + + init(zone: TimeZone) { + self.zone = zone + var c = Calendar(identifier: .gregorian) + c.timeZone = zone + calendar = c + } + + private static let clocks = Mutex<[String: ZoneClock]>([:]) + + /// The clock of a zone, made the first time anyone asks for it. + public static func of(_ zone: TimeZone) -> ZoneClock { + clocks.withLock { made in + if let c = made[zone.identifier] { return c } + let c = ZoneClock(zone: zone) + made[zone.identifier] = c + return c + } + } + + /// How far the zone's clock is ahead of UTC at a moment. + public func offsetMs(_ ms: Double) -> Double { + Double(zone.secondsFromGMT(for: Date(timeIntervalSince1970: ms / 1000))) * 1000 + } + + /// The local minute of the day, 0 to 1439. + public func minuteOfDay(_ ms: Double) -> Int { + Self.wrap(Int(((ms + offsetMs(ms)) / 60_000).rounded(.down)), 1440) + } + + /// "07:00". + public func label(_ ms: Double) -> String { Self.label(minute: minuteOfDay(ms)) } + + /// "07:00:42", for the live curve's seconds. + public func seconds(_ ms: Double) -> String { + let local = ms + offsetMs(ms) + let s = Self.wrap(Int((local / 1000).rounded(.down)), 60) + return Self.label(minute: Self.wrap(Int((local / 60_000).rounded(.down)), 1440)) + ":" + Self.twoDigits[s] + } + + /// The start of the local slot `stepMs` long that holds `ms`, so a + /// half hour starts at :00 or :30 on the wall clock in every zone. + public func alignDown(_ ms: Double, stepMs: Double) -> Double { + let off = offsetMs(ms) + return ((ms + off) / stepMs).rounded(.down) * stepMs - off + } + + /// "07:00" for a minute of the day, wrapping past midnight. + public static func label(minute: Int) -> String { labels[wrap(minute, 1440)] } + + private static func wrap(_ v: Int, _ n: Int) -> Int { + let r = v % n + return r < 0 ? r + n : r + } + + private static let twoDigits: [String] = (0..<60).map { $0 < 10 ? "0\($0)" : "\($0)" } + private static let labels: [String] = (0..<1440).map { twoDigits[$0 / 60] + ":" + twoDigits[$0 % 60] } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/ChartGeometryTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/ChartGeometryTests.swift new file mode 100644 index 0000000..21d325c --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/ChartGeometryTests.swift @@ -0,0 +1,424 @@ +import CoreGraphics +import Foundation +import Testing +@testable import FTWShared + +/// The chart engine's geometry: the rules WP1 of the polish release must +/// prove before any chart draws with it. +@Suite struct ChartGeometryTests { + static let stockholm = TimeZone(identifier: "Europe/Stockholm")! + static let clock = ZoneClock.of(stockholm) + static let words = ReadoutWords(value: { "\(Int($0.rounded())) W" }, range: { "\(Int($0.rounded()))–\(Int($1.rounded()))" }, none: "no data") + + /// Wall clock in Stockholm, as ms. + static func at(_ day: Int, _ hour: Int, _ minute: Int = 0) -> Double { + clock.calendar.date(from: DateComponents(year: 2026, month: 10, day: day, hour: hour, minute: minute))!.timeIntervalSince1970 * 1000 + } + + /// The same numbers on every run. + struct Dice { + var state: UInt64 + mutating func next() -> Double { + state = state &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407 + return Double(state >> 11) / Double(1 << 53) + } + } + + /// A home with an on/off heater and noise: the shape that looked like a + /// saw in 0.3.0, one point every 1.2 pt. + static func heater(_ n: Int, seed: UInt64 = 7) -> [CGPoint] { + var dice = Dice(state: seed) + var on = false + return (0.. CGFloat { + guard line.count > 1 else { return line.first.map { hypot($0.x - p.x, $0.y - p.y) } ?? .infinity } + var lo = 0, hi = line.count + while lo < hi { + let mid = (lo + hi) / 2 + if line[mid].x < p.x - near { lo = mid + 1 } else { hi = mid } + } + var best = CGFloat.infinity + var i = max(lo, 1) + while i < line.count { + best = min(best, ChartPaths.distance2(p, line[i - 1], line[i])) + if line[i - 1].x > p.x + near { break } + i += 1 + } + return best.squareRoot() + } + + /// How often a line turns from rising to falling or back. + static func turns(_ pts: [CGPoint], tolerance: CGFloat = 1e-6) -> Int { + var count = 0, last: CGFloat = 0 + for i in 1.. tolerance else { continue } + if last != 0, (dy > 0) != (last > 0) { count += 1 } + last = dy + } + return count + } + + // MARK: Steffen + + @Test func steffenNeverLeavesItsNeighboursRange() { + var dice = Dice(state: 42) + var series: [[CGPoint]] = [Self.heater(300)] + series.append((0..<200).map { CGPoint(x: CGFloat($0) * 3.85, y: CGFloat(dice.next() * 240)) }) + series.append([0, 0, 0, 100, 100, 100, 20, 20, 240].enumerated().map { CGPoint(x: CGFloat($0.offset) * 4, y: CGFloat($0.element)) }) + // Uneven steps, as a bucket still filling sits nearer its data. + series.append((0..<50).map { CGPoint(x: CGFloat($0) * 4 + CGFloat(dice.next() * 3), y: CGFloat(dice.next() * 100)) }) + for pts in series { + let path = ChartPaths.monotone(pts) + #expect(path.elements.count == pts.count) + for k in 1..= lo && y <= hi) + #expect(x >= a.x - 1e-9 && x <= to.x + 1e-9) + } + } + } + } + + @Test func aStepBetweenFlatsTurnsWithLevelShoulders() { + let pts = [0, 0, 100, 100].enumerated().map { CGPoint(x: CGFloat($0.offset) * 10, y: CGFloat($0.element)) } + let path = ChartPaths.monotone(pts) + guard case let .curve(_, c1, c2) = path.elements[2] else { + Issue.record("no curve over the step") + return + } + // Both control points level with their ends: no overshoot, soft turn. + #expect(c1.y == 0) + #expect(c2.y == 100) + } + + @Test func aLonePointIsADotAndAGapIsABreak() { + let p = CGPoint(x: 5, y: 5) + #expect(ChartPaths.monotone([p]).elements == [.move(p), .line(p)]) + var path = ChartPath() + path.addMonotone([CGPoint(x: 0, y: 0), CGPoint(x: 4, y: 1)]) + path.addMonotone([CGPoint(x: 12, y: 0), CGPoint(x: 16, y: 1)]) + #expect(path.elements.filter { if case .move = $0 { return true } else { return false } }.count == 2) + } + + // MARK: RDP and the rounded polyline + + @Test func simplifyingKeepsEverySampleWithinEpsilon() { + let pts = Self.heater(300) + for epsilon: CGFloat in [0.25, 0.5] { + let kept = ChartPaths.simplify(pts, epsilon: epsilon) + #expect(kept.first == pts.first && kept.last == pts.last) + #expect(kept.count < pts.count) + for p in pts { #expect(Self.distance(p, to: kept) <= epsilon + 1e-9) } + } + let straight = (0..<50).map { CGPoint(x: CGFloat($0), y: CGFloat($0) * 2) } + #expect(ChartPaths.simplify(straight, epsilon: 0.5).count == 2) + } + + @Test func theRoundedPolylineMovesNoPointMoreThanHalfAPointAndAddsNoExtreme() { + for seed: UInt64 in [1, 7, 99] { + let pts = ChartPaths.simplify(Self.heater(300, seed: seed), epsilon: 0.5) + let drawn = ChartPaths.roundedPolyline(pts).flattened(steps: 64) + for p in pts { #expect(Self.distance(p, to: drawn) <= 0.5 + 1e-6) } + #expect(Self.turns(drawn) <= Self.turns(pts)) + let ys = pts.map(\.y), drawnYs = drawn.map(\.y) + #expect(drawnYs.min()! >= ys.min()! - 1e-9 && drawnYs.max()! <= ys.max()! + 1e-9) + } + } + + @Test func theTraceKeepsEverySampleWithinHalfAPoint() { + let samples = Self.heater(300) + let drawn = ChartPaths.trace(samples).flattened(steps: 64) + for p in samples { #expect(Self.distance(p, to: drawn) <= 0.5 + 1e-6) } + #expect(Self.turns(drawn) <= Self.turns(samples)) + } + + @Test func aCornerIsRoundedWithinItsCaps() { + // A gentle bend: the 5 pt radius caps it, not the shift. + let gentle = [CGPoint(x: 0, y: 0), CGPoint(x: 100, y: 0), CGPoint(x: 200, y: 10)] + guard case let .line(a) = ChartPaths.roundedPolyline(gentle).elements[1] else { + Issue.record("no rounded corner") + return + } + let turn = atan2(10.0, 100.0) + #expect(abs((100 - a.x) - 5 * tan(turn / 2)) < 1e-6) + // A right angle: the shift caps it, and the corner is cut. + let square = [CGPoint(x: 0, y: 0), CGPoint(x: 100, y: 0), CGPoint(x: 100, y: 100)] + let drawn = ChartPaths.roundedPolyline(square).flattened(steps: 256) + let corner = Self.distance(CGPoint(x: 100, y: 0), to: drawn) + #expect(corner > 0.4 && corner <= 0.5 + 1e-6) + // Short segments: half of each, so two corners never overlap. + let zig = [CGPoint(x: 0, y: 0), CGPoint(x: 1, y: 40), CGPoint(x: 2, y: 0)] + guard case let .line(b) = ChartPaths.roundedPolyline(zig).elements[1] else { return } + #expect(hypot(b.x - 1, b.y - 40) <= hypot(1, 40) / 2 + 1e-9) + } + + // MARK: Rounded steps and merged runs + + @Test func roundedStepsHoldEachSlotsValueAndBreakAtAGap() { + let steps = [ChartPath.Step(x0: 0, x1: 10, y: 50), ChartPath.Step(x0: 10, x1: 20, y: 20), ChartPath.Step(x0: 20, x1: 30, y: 20), ChartPath.Step(x0: 40, x1: 50, y: 80)] + let path = ChartPaths.roundedSteps(steps) + let drawn = path.flattened() + for s in steps { + let mid = CGPoint(x: (s.x0 + s.x1) / 2, y: s.y) + #expect(Self.distance(mid, to: drawn.filter { abs($0.x - mid.x) <= 6 }) < 1e-9) + } + #expect(drawn.allSatisfy { $0.y >= 20 && $0.y <= 80 }) + #expect(path.elements.filter { if case .move = $0 { return true } else { return false } }.count == 2) + } + + @Test func equalSlotsMergeIntoOnePixelSnappedRun() { + let grid = SlotGrid(firstMs: 0, stepMs: 900_000, count: 8, fromMs: 0, toMs: 8 * 900_000) + let map = YMap(domain: YDomain(lower: -3_000, upper: 3_000), height: 100) + let values: [Double?] = [2_000, 2_000, 2_000, nil, -1_000, -1_000, 0, 1_500] + let runs = ChartPaths.runs(values, grid: grid, width: 301, map: map, scale: 3, maxRadius: 4) + #expect(runs.map(\.first) == [0, 4, 7]) + #expect(runs.map(\.count) == [3, 2, 1]) + for r in runs { + for edge in [r.rect.minX, r.rect.maxX, r.rect.minY, r.rect.maxY] { + #expect(abs(edge * 3 - (edge * 3).rounded()) < 1e-9) + } + #expect(r.radius <= 4 && r.radius <= r.rect.width / 2) + } + // One device pixel between a run and the next slot's start. + #expect(abs((runs[0].rect.maxX + 1.0 / 3) - (301.0 * 3 / 8 * 3).rounded() / 3) < 1e-9) + // A negative run hangs below zero. + #expect(runs[1].rect.minY == (map.baseline * 3).rounded() / 3) + } + + // MARK: The domain + + @Test func theDomainGrowsAtOnceShrinksBelowFiftyFivePercentAndEndsOnNiceSteps() { + let first = YDomain.stable(low: 0, high: 900) + #expect(first == YDomain(lower: 0, upper: 1_000)) + // More than fits: it grows in the same step. + #expect(YDomain.stable(low: 0, high: 1_200, previous: first).upper == 1_500) + // Down to 60 % and to 55 %: it holds. + #expect(YDomain.stable(low: 0, high: 600, previous: first) == first) + #expect(YDomain.stable(low: 0, high: 550, previous: first) == first) + // Under 55 %: it shrinks to fit. + #expect(YDomain.stable(low: 0, high: 540, previous: first).upper == 750) + // Each side on its own; a side with no data is zero. + let signed = YDomain.stable(low: -2_600, high: 400, previous: nil) + #expect(signed == YDomain(lower: -3_000, upper: 500)) + #expect(YDomain.stable(low: -1_800, high: 400, previous: signed).lower == -3_000) + #expect(YDomain.stable(low: -100, high: 400, previous: signed).lower == -150) + #expect(YDomain.stable(low: 0, high: 400, previous: signed).lower == 0) + // No data at all: the floor. + #expect(YDomain.stable(low: 0, high: 0, floor: 100) == YDomain(lower: 0, upper: 100)) + #expect(YDomain.stable(low: -4, high: 3, floor: 100) == YDomain(lower: -100, upper: 100)) + + let nice: Set = [1, 1.5, 2, 3, 5, 7.5] + var dice = Dice(state: 3) + var previous: YDomain? + for _ in 0..<2_000 { + let peak = pow(10, dice.next() * 6) + let d = YDomain.stable(low: -peak * dice.next(), high: peak, previous: previous) + for side in [d.upper, -d.lower] where side > 0 { + let mantissa = side / pow(10, log10(side).rounded(.down)) + #expect(nice.contains((mantissa * 1e6).rounded() / 1e6)) + } + #expect(d.upper >= peak) + previous = d + } + } + + // MARK: Slots under a finger + + @Test func theIndexClampsAtBothEdgesAndInGaps() { + let grid = SlotGrid(firstMs: 0, stepMs: 900_000, count: 4, fromMs: 0, toMs: 3_600_000) + #expect(grid.index(atX: -40, width: 300) == 0) + #expect(grid.index(atX: 0, width: 300) == 0) + #expect(grid.index(atX: 300, width: 300) == 3) + #expect(grid.index(atX: 900, width: 300) == 3) + #expect(grid.index(atMs: -.infinity) == 0) + #expect(grid.index(atMs: .infinity) == 3) + #expect(grid.index(atMs: .nan) == 0) + #expect(SlotGrid(firstMs: 0, stepMs: 1, count: 0, fromMs: 0, toMs: 1).index(atMs: 0) == nil) + + // A gap keeps its slot: the rule stays, the dot does not, the + // 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) + #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) + + // The live curve: one binary search, clamped, nearest either way. + let times: [Double] = [0, 1_000, 2_000, 9_000] + #expect(SlotGrid.nearest(times, to: -500) == 0) + #expect(SlotGrid.nearest(times, to: 1e12) == 3) + #expect(SlotGrid.nearest(times, to: 5_000) == 2) + #expect(SlotGrid.nearest(times, to: 5_600) == 3) + #expect(SlotGrid.nearest([], to: 0) == nil) + } + + // MARK: Buckets + + @Test func displayBucketsAreTheSmallestThatLeaveTwoAndAHalfPoints() { + 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) + #expect(DisplayBucket.stepMs(spanMs: 3_600_000, width: 370) == 300_000) + #expect(DisplayBucket.stepMs(spanMs: 30 * day, width: 370) == 3_600_000) + } + + @Test func aBucketIsTheExactMeanOfItsFiveMinuteMeans() { + let from = Self.at(13, 0), to = Self.at(14, 0) + var dice = Dice(state: 11) + let samples = (0..<288).map { i in + ChartSample(startMs: from + Double(i) * 300_000, value: dice.next() < 0.1 ? nil : (dice.next() - 0.4) * 5_000) + } + let buckets = DisplayBucket.buckets(samples, stepMs: 900_000, fromMs: from, toMs: to, clock: Self.clock) + #expect(buckets.count == 96) + var inWh = 0.0, outWh = 0.0 + for b in buckets { + let held = samples.filter { $0.startMs >= b.startMs && $0.startMs < b.endMs }.compactMap(\.value) + #expect(b.count == held.count) + if held.isEmpty { + #expect(b.mean == nil) + continue + } + #expect(b.mean == held.reduce(0, +) / Double(held.count)) + #expect(b.low == held.min() && b.high == held.max()) + 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) + #expect(series.buckets == buckets) + let all = series.readouts.soFar(95) + #expect(abs(all.inWh - inWh) < 1e-6 && abs(all.outWh - outWh) < 1e-6) + } + + @Test func bucketsSitOnTheLocalClockAndAnOpenOneWhereItsDataIs() { + // India is 5:30 ahead: an hour still starts at :00 there. + let kolkata = ZoneClock.of(TimeZone(identifier: "Asia/Kolkata")!) + var c = kolkata.calendar + c.timeZone = kolkata.zone + let from = c.date(from: DateComponents(year: 2026, month: 10, day: 13, hour: 9, minute: 10))!.timeIntervalSince1970 * 1000 + let buckets = DisplayBucket.buckets([], stepMs: 3_600_000, fromMs: from, toMs: from + 7_200_000, clock: kolkata) + #expect(kolkata.minuteOfDay(buckets[0].startMs) == 9 * 60) + // A bucket with two of its three means sits between those two. + let open = DisplayBucket.buckets([ChartSample(startMs: 0, value: 1), ChartSample(startMs: 300_000, value: 3)], stepMs: 900_000, fromMs: 0, toMs: 900_000, clock: ZoneClock.of(TimeZone(identifier: "UTC")!)) + #expect(open[0].atMs == 300_000) + #expect(open[0].mean == 2) + } + + // MARK: Readouts + + @Test func theReadoutTableSaysWhenWhatAndSoFar() { + let from = Self.at(13, 14) + let values: [Double?] = [0, 600, 1_100, 1_000, nil, nil, -2_000, -1_000, -1_500] + let samples = values.enumerated().map { ChartSample(startMs: from + Double($0.offset) * 300_000, value: $0.element) } + let series = ChartSeries.history(samples, fromMs: from, toMs: from + 2_700_000, nowMs: from + 2_000_000, size: CGSize(width: 20, height: 100), clock: Self.clock, words: Self.words) + let t = series.readouts + #expect(t.when == ["14:00–14:15", "14:15–14:30", "14:30–14:45"]) + #expect(t.what == ["567 W · 0–1100", "1000 W", "-1500 W · -2000–-1000"]) + #expect(t.soFar(0).inWh == 1_700.0 / 12) + #expect(t.between(2, 1).outWh == 4_500.0 / 12) + // The highest mean, the lowest and now. + #expect(t.landmarks == [0, 2]) + #expect(series.grid.index(atMs: from + 2_000_000) == 2) + } + + // MARK: Scrub rules + + @Test func aTickOnlyOnACrossingAtMostOnePer40Ms() { + var step = ScrubStep() + let t0 = ContinuousClock.now + #expect(step.move(to: 3, landmark: false, at: t0) == .selection) + #expect(step.move(to: 3, landmark: false, at: t0 + .milliseconds(100)) == .none) + #expect(step.move(to: 4, landmark: false, at: t0 + .milliseconds(10)) == .none) + #expect(step.index == 4) + #expect(step.move(to: 5, landmark: false, at: t0 + .milliseconds(45)) == .selection) + // A landmark always lands, and holds the next tick off. + #expect(step.move(to: 6, landmark: true, at: t0 + .milliseconds(50)) == .impact) + #expect(step.move(to: 7, landmark: false, at: t0 + .milliseconds(60)) == .none) + #expect(step.ticks == 2 && step.bumps == 1) + step.lift() + #expect(step.index == nil) + #expect(step.move(to: 7, landmark: false, at: t0 + .seconds(1)) == .selection) + } + + // MARK: The clock + + @Test func theZoneClockReadsAsTheCalendarDoesAcrossTheTurnOfTheClocks() { + // Summer time ends in Stockholm on 25 October 2026. + let start = Self.at(24, 0) + var c = Calendar(identifier: .gregorian) + c.timeZone = Self.stockholm + for k in 0..<(96 * 3) { + let ms = start + Double(k) * 900_000 + 37_000 + let d = c.dateComponents([.hour, .minute, .second], from: Date(timeIntervalSince1970: ms / 1000)) + #expect(Self.clock.label(ms) == String(format: "%02d:%02d", d.hour!, d.minute!)) + #expect(Self.clock.seconds(ms) == String(format: "%02d:%02d:%02d", d.hour!, d.minute!, d.second!)) + } + #expect(ZoneClock.label(minute: 1_439) == "23:59") + #expect(ZoneClock.label(minute: 1_440 + 7 * 60) == "07:00") + #expect(ZoneClock.label(minute: -1) == "23:59") + #expect(ZoneClock.of(Self.stockholm).calendar == Self.clock.calendar) + } + + // MARK: Cost + + #if DEBUG + /// Plain `swift test` builds without optimisation, which runs this + /// code 20 to 60 times slower than the app ships it, and other suites + /// run beside it. There the bound catches a slip in kind, such as a + /// loop inside a loop. `swift test -c release -Xswiftc -enable-testing` + /// checks the 2 ms itself. + static let budget: Duration = .milliseconds(20) + #else + static let budget: Duration = .milliseconds(2) + #endif + + /// The best of a few runs, so a busy machine does not decide. + static func cost(_ body: () -> Void) -> Duration { + var best = Duration.seconds(1) + for _ in 0..<9 { + let began = ContinuousClock.now + body() + best = min(best, ContinuousClock.now - began) + } + return best + } + + @Test func geometryForSevenHundredTwentyPointsBuildsInUnderTwoMilliseconds() { + 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. + 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) + points = series.points.count + } + #expect(points == 720) + #expect(history < Self.budget, "history built in \(history)") + // The live trace at 720 samples, more than twice its 300. + let noisy = Self.heater(720) + let trace = Self.cost { _ = ChartPaths.trace(noisy) } + #expect(trace < Self.budget, "trace built in \(trace)") + } +}