Skip to content

iPhone: chart geometry and scrub rules in FTWShared (WP1) - #32

Merged
frahlg merged 1 commit into
mainfrom
claude/ios-wp1-charts
Oct 13, 2026
Merged

frahlg merged 1 commit into
mainfrom
claude/ios-wp1-charts

Conversation

@frahlg

@frahlg frahlg commented Oct 13, 2026

Copy link
Copy Markdown
Member

Problem

On 0.3.0 Fredrik found the charts "angular and ugly, not smooth at all", the lines too thin, and the scrub slow: "it lagged and felt sluggish when I dragged over the plan". The charts audit traced most of it to Swift Charts doing all the work on every touch. Each chart rebuilds 600 to 2,200 marks per touch, makes a Calendar and calls String(format:) per readout, and draws hairy lines through raw five-minute noise.

The polish release moves the charts onto a small engine: one Canvas for the chart and one for the finger. This PR is WP1, the engine's geometry with no UI. WP3, WP6 and WP7 draw with it.

Change

New, under appleApp/FTWKit/Sources/FTWShared/Charts/. All values are Sendable, and the code is nonisolated, so a model can build them off the main actor once per change of data.

File What it holds
SlotGrid.swift Finger x or a moment to a slot by arithmetic, clamped to the first and last slot. One binary search for the nearest live sample.
YDomain.swift YDomain.stable: each side of zero ends on 1, 1.5, 2, 3, 5 or 7.5 × 10^k, at least 10 % past its peak. It grows at once and shrinks only below 55 %. YMap maps values to screen.
ChartSeries.swift DisplayBucket: the smallest of 5, 15, 30 or 60 min that leaves 2.5 pt a point, on the local clock. ChartSeries.history: buckets, grid, domain, line, body, each slot's point and its readout table.
ChartPaths.swift ChartPath as plain elements (Path(p.cgPath) draws it). Steffen's monotone cubic and its body, rounded steps, RDP, the rounded polyline, the live trace, and equal slots merged into pixel-snapped runs.
ReadoutTable.swift Per slot: when, and what (mean and range of the five-minute means); prefix sums of energy for "so far" and a span; landmarks.
ScrubStep.swift A tick only when the slot changes, at most one per 40 ms; a landmark always gives a light impact.
ZoneClock.swift One Calendar per zone, kept; "07:00" from minute-of-day arithmetic over a table.

AppWords.clock, Readouts.seconds and ChargeNowActivity.clock now use ZoneClock, so none of them builds a Calendar or calls String(format:) per call.

Choices the design left open

  • Rounded polyline. Section 7.3 asks for corners up to 5 pt round, cut at most at half of each segment, with no point moved more than 0.5 pt. At a sharp on/off corner the 0.5 pt rule wins, so the cut is small and the 2.5 pt round join does most of the softening. On gentle bends the 5 pt radius wins. ChartPaths.trace (RDP, then rounding) gives each step half of the 0.5 pt, so no live sample ends up more than 0.5 pt from the drawn line. If the corners look too sharp on a device, the 0.5 pt rule is the one to relax.
  • Words. The table holds strings, but the words (0,6 kW, decimal comma) live in FTWKit. So ChartSeries.history takes a ReadoutWords and keeps only the strings it made.
  • Where a bucket's point sits. At the middle of the five-minute means it holds. A full bucket sits at its centre; the bucket still filling sits where its data is, not out in the future.
  • High and low landmarks. The slot with the highest five-minute mean above zero and the one with the lowest below zero. A solar chart gets its peak, not a night slot.
  • File name. ReadoutTable.swift, not Readouts.swift, since FTWKit already has enum Readouts and both reach the app.

Evidence

ChartGeometryTests (18 tests) proves the WP1 rules:

  • Steffen never leaves its neighbours' range (four series, 32 samples per curve), and a step between flats has level shoulders.
  • The rounded polyline moves no point more than 0.5 pt and adds no extreme (three seeds of an on/off heater with noise); the trace keeps every raw sample within 0.5 pt.
  • The domain grows at once, holds at 55 %, shrinks below it, and ends on a nice step for 2,000 random peaks.
  • The index clamps past both edges, at ±∞ and NaN, and keeps its slot in a gap, where the point is nil and the readout says "no data".
  • A bucket is the exact mean of its five-minute means, with matching low, high, count and energy.
  • 720 points build in under 2 ms. Optimised (swift test -c release -Xswiftc -enable-testing): about 0.09 ms for the history series and 0.02 ms for the trace. Plain swift test builds without optimisation and runs 20 to 60 times slower beside other suites, so its bound there is 20 ms. That still catches a slip such as a loop inside a loop. Debug runs took 1.1 to 2.1 ms.

Also covered: ZoneClock matches Calendar across the end of summer time in Stockholm, and hours start at :00 in India.

  • swift test: 397 tests pass, three runs in a row; the geometry suite also passes in release.
  • The app and widgets build for the iOS Simulator.
  • It merges cleanly with srcfl/ftw-app#31 and srcfl/ftw-app#27.

No screen changes, so there is no recording. WP3 is the first package that draws with this and needs one.

Next

WP3 builds ChartSurface, ScrubOverlay and ScrubModel on SlotGrid, ScrubStep and ReadoutTable, and then the price chart. At that point SlotLookup from #31 can give way to SlotGrid.

🤖 Generated with Claude Code

The first step of the charts lane in the polish release. No view uses it
yet: WP3, WP6 and WP7 draw with it.

Under FTWShared/Charts, all Sendable values made by nonisolated code:

- SlotGrid: finger x or a moment to a slot by arithmetic, clamped to the
  first and last slot; one binary search for the nearest live sample.
- YDomain.stable: each side of zero ends on 1, 1.5, 2, 3, 5 or 7.5 times
  a power of ten, at least 10 % past its peak. It grows at once and
  shrinks only when the data fills less than 55 % of it. YMap maps values
  to screen.
- DisplayBucket: the smallest of 5, 15, 30 or 60 minutes that leaves
  2.5 pt a point, on the local clock. A bucket's mean is the exact mean of
  the five-minute means it holds, with their low, high and energy.
- ChartPath and its builders: Steffen's monotone cubic and its body,
  rounded steps, Ramer-Douglas-Peucker, a rounded polyline that cuts a
  corner no deeper than 0.5 pt, the live trace (each step takes half of
  that budget), and equal slots merged into pixel-snapped runs.
- ChartSeries.history: buckets, grid, domain, line, body, the point of
  each slot and a ReadoutTable (when, what with mean and range, prefix
  sums of energy, landmarks).
- ScrubStep: a tick only when the slot changes, at most one per 40 ms; a
  landmark always gives an impact.
- ZoneClock: one Calendar per zone, kept; "07:00" from minute-of-day
  arithmetic over a table. AppWords.clock, Readouts.seconds and
  ChargeNowActivity.clock use it, so none of them builds a Calendar or
  calls String(format:) per call.

ChartGeometryTests proves the WP1 rules. The 720-point build takes about
0.09 ms optimised; the debug test run allows 20 ms, as it runs 20 to 60
times slower.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-13T17:50:35.432920Z 7542101 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 754210154a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include samples overlapping the window start

When fromMs falls inside a five-minute sample, this filter drops that sample entirely even though HistoryModel.samples deliberately returns the overlapping first sample and the existing EnergyToday.sum counts only its covered portion. This occurs for the 48-hour view, whose fromMs is site.nowMs - spanMs and is generally not bucket-aligned, causing the first display bucket's mean/range and every prefix-energy readout to omit up to five minutes of known data; accept samples whose end exceeds fromMs and prorate their energy to the visible overlap.

Useful? React with 👍 / 👎.

var out: [ChartBucket] = []
out.reserveCapacity(count)
for k in 0..<count {
let start = origin + Double(k) * stepMs

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-align buckets after non-hour DST transitions

In zones whose UTC offset changes by less than an hour, such as Australia/Lord_Howe, deriving every boundary by adding a fixed stepMs to the initial origin stops preserving local-clock alignment across the transition. With one-hour buckets on the 2026 fall-back day, starts become 00:00, 01:00, 01:30, 02:30, and so on, so subsequent samples are grouped into :30 buckets rather than local hours; each boundary needs calendar-aware alignment or re-alignment using the offset applicable at that boundary.

Useful? React with 👍 / 👎.

@frahlg
frahlg merged commit b36a4e4 into main Oct 13, 2026
1 check passed
@frahlg frahlg mentioned this pull request Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant