Skip to content

iPhone: the finger owns the chart, and the price chart on it (WP3) - #38

Merged
frahlg merged 2 commits into
mainfrom
claude/ios-wp3-scrub
Oct 14, 2026
Merged

frahlg merged 2 commits into
mainfrom
claude/ios-wp3-scrub

Conversation

@frahlg

@frahlg frahlg commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

WP3 of the iPhone polish release (Phosphor): the finger owns the chart. The price chart is the first chart on the new engine, and the engine gains what the chart research asks for, so WP6 (live) and WP7 (history) can build on it.

What changes

The price view. A 60 pt hero gives the price now, and under a finger the price and time of the slot it is on. Below it, the next 24 hours as a bar per slot:

  • bars in ink by thirds of the window's range (0.32, 0.50, 0.80), the now slot in full ink with its rule;
  • equal neighbours merged into one pixel-snapped shape, slot width less 1 px, 1.25 pt top corners;
  • the average as a dotted rule, with AVG and its figure in the right gutter;
  • the cheapest two hours bracketed, with CHEAPEST 11:00–13:00 · 51,9 ÖRE under the bracket;
  • tomorrow's slots as hatched stubs with TOMORROW ~13:00 until they are published;
  • two reserved lines under the chart: what the price holds, or what the plan does in the slot under the finger.

No cards. The chart is 240 pt on an SE and grows to 340 pt on a tall phone.

The slot under the finger lights up (Fredrik on 0.3.2: "It should clearly colour the whole 15-minute slot you are on, not just a line."). The overlay dims the rest of the plot under the ground at half strength, washes the slot's column from top to bottom, and draws its bar again in full ink, its whole width and height. Two fingers light every bar between them. The rule stays as a hairline.

The finger. ChartSurface is one Canvas that never sees the finger. ScrubOverlay is a small Canvas on top that owns the gesture over the whole frame and reads only ScrubModel, so a touch redraws the overlay and the hero and nothing else. The slot is a clamped index that never animates. One finger scrubs; two hold a span with its mean. .selection per slot crossed, at most one per 40 ms; a light impact at now, the cheapest window's edges, both ends, and each whole hour when the slots are quarters.

The engine (FTWShared/Charts, all tested):

  • DisplayBucket.minWidth 2.5 → 6: Today becomes 30-minute slots, 48 h 60-minute slots.
  • ChartSeries.band: the slot's lowest to highest five-minute mean, two Steffen edges closed into one shape. The domain now fits the band's edges, not just the means.
  • ChartPath.reversed() and addBand(highs:lows:).
  • LiveSeries: one point per reading, not per frame; a Gaussian 2.5 pt wide in screen space whose reach shrinks to zero at the head; Steffen; a raw min–max band in 2 s frames on the clock; a 6 pt head inset. WP6 draws it.
  • EnergyNode.scaleFloorW(rating:): home 2 kW, grid 3 kW, battery 3 kW, solar 3 kW, car 3.7 kW, or the device's rating when known.
  • PriceChart: the price chart's geometry and readouts, for the app and the widget.

Also:

  • CADisableMinimumFrameDurationOnPhone in FTW-Info.plist, so a chart may move above 60 Hz on ProMotion.
  • ChartStrokeTests fails on any stroke in FTWCharts that does not join round.
  • The medium price widget draws the same bars as still paths (no Canvas). PriceLineChart and Swift Charts leave the price chart.
  • The demo prices in quarter hours, as the Nordic day-ahead market has since October 2025, so the demo shows 96 bars as a real box does.
  • A rule in CLAUDE.md: charts draw on the engine.
  • Debug only: -FTWFrameLog YES logs late frames and chart draws each second; -FTWAutoScrub YES drives the overlay through the path a touch takes. PriceScrubTour (UI test, skipped unless asked) drives real synthetic touches.

Evidence

Tests. swift test: FTWShared 146, FTWKit 293, all green. NowFitsTests 8/8 on an SE (3rd gen), iOS 26.5.

New tests hold: every slot mean inside its band and every band edge inside the domain; a hole never counts as 0 in a band; the live head is the last raw reading and the line ends on it; the live line never leaves the readings within 3σ of it; the band does not change when the window moves 0.3 s; a silence is never bridged; one reading per value, a steady value flat; the price grid, thirds, hatched slots, hour labels, negative prices in words and the cheapest window; a finger past either edge or over a hole always has a slot; every stroke joins round.

Calm, on the research's synthetic live trace (a 2 kW heater 40 s on, 50 s off, meter noise ±25 W, 5 min at 1 Hz, 350 × 280 pt, 1.7 true edges per 100 pt):

Drawing Turns per 100 pt Ink per 100 pt
Every frame raw (0.3.2) 29.2 508
LiveSeries 1.5 405

Thresholds on recorded traces from the home box come with WP7.

The marker never lost. PriceScrubTour.testFastSweepPastBothEdges on a 17 Pro Max simulator: a fast sweep from the left edge to past the right, held; back past the left, held; then a slow drag. The scrub log reads 3 5 8 … 93 95 | 95 92 … 1 0 | 51 52 … 61 (| = lifted): 60 slot changes, no nil while touching, no "touching with no slot". 18 ticks and 13 impacts for those 60 changes.

Frames: simulator, not a device. Instruments refuses Animation Hitches in the simulator ("Hitches is not supported on this platform"), and XCTHitchMetric records nothing there. So I measured with the debug frame log while -FTWAutoScrub swept the chart back and forth past both edges for 10 s, about 50 slot changes a second:

  • surface draws per touch: 0 (one per minute, when the minute turns);
  • overlay draws: one per frame in which the slot changed, never more, about 0.2 ms each. The sweep crosses about 94 slots a second, faster than the 60 Hz display, so two changes between frames become one draw of the newest slot: the lit slot is never more than one frame behind the finger;
  • late frames, with the lit slot: 0 ms/s in all 10 seconds (load average about 30).

An earlier run under a load average of 130 to 250 from other sessions' simulators gave 0 ms/s in 7 of 10 seconds and 6 ms/s on average; an idle price view under that load showed the same spikes. The simulator cannot stand in for the device. Fredrik's iPhone is paired with this Mac; I did not install a debug build on it, since that would replace the TestFlight app. A run there with Instruments (Animation Hitches, 10 s scrub) is the remaining proof.

Screenshots

From the demo, dark, light and the largest text, SE (3rd gen) and 17 Pro Max, idle and under a finger (one finger, two fingers).

SE, with the lit slot: SE

Pro Max, with the lit slot: Pro Max

One finger and two fingers, Pro Max, dark: lit

Before the lit slot (same build otherwise): SE, Pro Max.

The widget, drawing the same bars as still shapes: widget

Single shots are in the shots branch.

Recordings

sweep

Recorded before the lit slot:

Departures from the design document

  • ScrubModel lives in FTWCharts, not FTW/UI: the overlay reads it, and FTWCharts builds into the widget too.
  • The span uses SpatialEventGesture (every touch at once) in place of DragGesture, so two fingers keep the span the old Swift Charts price chart had.
  • An hour bump only when the slots are shorter than an hour; on hourly prices every slot would be a landmark.
  • Thirds are thirds of the window's range, not of its rank.
  • The price chart's geometry is built in the view body, once a minute or on new prices or plan: about 0.3 ms with a plan, on the main actor, so the first frame has its chart.

Overlap

#27 (plan dial, paused draft) touches ChartWords.swift and NodeWords.swift. This PR removes Readouts.price(_:currency:plan:at:) and Readouts.price(_:currency:range:), which only the old price chart used, and adds two Swedish words. WP11 reworks #27 and will meet these.

🤖 Generated with Claude Code

The price chart now draws in one Canvas that never sees the finger, with
a small overlay that alone redraws under it. A fast sweep past both
edges keeps its marker, a slot change costs one overlay draw, and the
surface draws once a minute.

- FTWCharts: ChartSurface, ScrubOverlay (one finger scrubs, two hold a
  span), ScrubModel, and PriceBars: a bar per slot in ink by thirds,
  the now slot in full ink, the average as a dotted rule with its figure
  in the gutter, the cheapest two hours bracketed and named, tomorrow's
  slots as hatched stubs. The widget draws the same shapes as still
  paths; PriceLineChart and Swift Charts leave the price chart.
- The price view: a 60 pt hero that gives way to the slot under the
  finger, the chart, and two reserved lines for what the plan does then.
- Engine, from the chart research: DisplayBucket.minWidth 6 (Today 30
  min, 48 h 60 min), ChartSeries.band and a domain that fits the band's
  edges, ChartPath.reversed, LiveSeries (one point per reading, a
  Gaussian 2.5 pt wide with a raw head, a 2 s band on the clock), a
  scale floor per node, and PriceChart.
- CADisableMinimumFrameDurationOnPhone, so a chart may move above 60 Hz.
- A test holds round joins on every stroke in FTWCharts.
- The demo prices in quarter hours, as the Nordic market does.
- Debug only: -FTWFrameLog logs late frames and chart draws each second,
  and -FTWAutoScrub drives the overlay, for the simulator, where
  Instruments' hitch tools do not run.

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

chatgpt-codex-connector Bot commented Oct 14, 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-14T06:41:37.466252Z cbe601a 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: cbe601abc9

ℹ️ 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 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))

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 Use the household time zone for chart labels

When the phone is outside the home's time zone, constructing the chart with .current shifts every axis, scrub, and cheapest-window label to the phone's clock and also evaluates the TOMORROW ~13:00 state against the wrong local time. The price cache already preserves the box zone in PriceEntry.timeZone, and the widget passes that zone into PriceChart.build; thread the same household zone through the in-app price view.

Useful? React with 👍 / 👎.

Comment on lines +124 to +126
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))

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 Clamp the retained scrub index after grid changes

If the chart changes granularity while a finger is down—for example, the supported mixed quarter-hour/hour window crosses into its hourly-only portion—the new grid shrinks from 96 slots to 24 while ScrubModel.index retains the old value. These unchecked subscripts (and the corresponding chart.doing[i] read below) then trap for an index above the new count; reset or clamp the selection whenever the rebuilt track no longer contains it.

Useful? React with 👍 / 👎.

Fredrik on 0.3.2: the price scrub should colour the whole 15-minute slot
the finger is on, not just a line. The overlay now dims the rest of the
plot under the ground at half strength, washes the slot's column from
top to bottom and draws its bar again in full ink, its whole width and
height. Two fingers light every bar between them. The rule stays as a
hairline. The debug frame log counts slot changes beside overlay draws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg merged commit b281b99 into main Oct 14, 2026
1 check passed
@frahlg frahlg mentioned this pull request Oct 14, 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