Viewport selection, polyline clip, project and simplify #40

Closed
opened 2026-08-31 17:14:51 +02:00 by robert · 1 comment
robert commented 2026-08-31 17:14:51 +02:00 (Migrated from git.butzei.de)

Goal

Prepare a map frame small enough to send once a second.

Acceptance criteria

  • Heading-up viewport, roughly 200 m across, centred on the rider
  • Route polyline clipped to the viewport
  • Projected to 200x228 screen pixels
  • Douglas-Peucker simplification to the pixel grid, so sub-pixel detail is never sent
  • Quantised to int16 pairs
  • Upcoming cue positions included as markers
  • Typical frame stays around 1 KB

Files

  • companion/.../map/ViewportRenderer.kt

Notes

Vector, not raster: a 200x228 bitmap is roughly 45 KB against an 8000 byte AppMessage cap and a few KB/s of Bluetooth.

Update — 2026-09-02: send geometry, not screen coordinates

The phone no longer projects (D39). It sends a slice of the route in a stable metric frame and the
watch projects it.

Three problems this fixes: the phone was never told the watch's screen geometry; zoom and re-centre
were Bluetooth round-trips, making NFR-P6 unsatisfiable; and a dropout froze the map.

  • Emit MAP_ANCHOR_LAT / MAP_ANCHOR_LON plus int16 decimetre offsets east/north of the
    anchor — ±3.2 km of range, more than any viewport offered
  • Clip and Douglas–Peucker simplify in the metric frame, to a tolerance derived from
    SCREEN_W/SCREEN_H, not to a hard-coded 200×228 pixel grid
  • Slices emitted on exhaustion — when the rider nears the edge of the sent slice, order once
    per few hundred metres — not at 1 Hz
  • Upcoming cues as MAP_CUES, rider position as MAP_POS, in the same frame
  • Typical slice stays around 1 KB

This is what takes the map off the critical path for bandwidth: link traffic for it drops by roughly
an order of magnitude.

## Goal Prepare a map frame small enough to send once a second. ## Acceptance criteria - [ ] Heading-up viewport, roughly 200 m across, centred on the rider - [ ] Route polyline clipped to the viewport - [ ] Projected to 200x228 screen pixels - [ ] Douglas-Peucker simplification to the pixel grid, so sub-pixel detail is never sent - [ ] Quantised to `int16` pairs - [ ] Upcoming cue positions included as markers - [ ] Typical frame stays around 1 KB ## Files - `companion/.../map/ViewportRenderer.kt` ## Notes Vector, not raster: a 200x228 bitmap is roughly 45 KB against an 8000 byte AppMessage cap and a few KB/s of Bluetooth. ## Update — 2026-09-02: send geometry, not screen coordinates The phone no longer projects (D39). It sends a **slice of the route in a stable metric frame** and the watch projects it. Three problems this fixes: the phone was never told the watch's screen geometry; zoom and re-centre were Bluetooth round-trips, making NFR-P6 unsatisfiable; and a dropout froze the map. - [ ] Emit `MAP_ANCHOR_LAT` / `MAP_ANCHOR_LON` plus `int16` **decimetre** offsets east/north of the anchor — ±3.2 km of range, more than any viewport offered - [ ] Clip and Douglas–Peucker simplify **in the metric frame**, to a tolerance derived from `SCREEN_W`/`SCREEN_H`, not to a hard-coded 200×228 pixel grid - [ ] Slices emitted **on exhaustion** — when the rider nears the edge of the sent slice, order once per few hundred metres — not at 1 Hz - [ ] Upcoming cues as `MAP_CUES`, rider position as `MAP_POS`, in the same frame - [ ] Typical slice stays around 1 KB This is what takes the map off the critical path for bandwidth: link traffic for it drops by roughly an order of magnitude.
Owner

Closed by PR #117 (area/map-viewport-geometry). Scoped to D39's 2026-09-02 update, which supersedes this issue's original "project to 200x228 screen pixels" body — the phone now sends a slice of route geometry in a stable metric frame; the watch does its own rotation/scaling.

New companion/map classes: MapAnchor (plain signed lat/lon), MetricOffsetMeters (pre-quantisation Double meters, kept separate from the quantised type so Douglas-Peucker runs before rounding, not after), MetricPoint (the actual int16 decimetre wire shape, hard required into Short.MIN_VALUE..Short.MAX_VALUE — throws rather than silently clamping on overflow), MetricPolylineSimplifier (a genuinely justified second Douglas-Peucker implementation — operates directly in the already-metric frame with no per-run trigonometry, unlike core's lat/lon version), MapCueMarker, MapSlice (with needsRefresh() implementing D39's "as the rider nears the edge" cadence, correctly suppressed once the slice already reaches the route's end), and MapSliceBuilder (the pipeline: anchor = the rider's own interpolated on-route position, clip window widened to bracketing vertices, DP tolerance derived from the connected watch's actual screen dimensions via max(width,height) — reasoned against heading-up rotation possibly aligning either axis with travel — never a hard-coded 200×228 grid).

Reuses RouteSnapper/RouteSnap (#31, including its #39-audited self-crossing fix) for rider position rather than re-deriving it, and cumulativeDistancesMeters (widened internal→public, its first cross-module consumer) for real clip-window distances. Correctly stayed out of #41 (chunked wire transport), #42 (watch-side rendering), and #43 (zoom/recentre) — produces a real, tested in-memory MapSlice for #41 to serialize, nothing here touches AppMessage.

Verified the wire keys this feeds (MAP_ANCHOR_LAT/LON, MAP_POLYLINE, MAP_SEQ, MAP_CUES, MAP_POS, MAP_HEADING) already existed in shared/message_keys.json/docs/PROTOCOL.md §2.5 from prior codegen — no wire changes needed here, MetricPoint's shape matches them exactly so #41 only serializes, never converts.

Real verification: genuine ./gradlew :companion:map:test :companion:core:test :companion:route:test :companion:assembleDebug --rerun-tasks full rebuild, all green — 40 tests across 5 suites in :companion:map (MapAnchorTest 5, MapSliceBuilderTest 13, MapSliceTest 9, MetricPointTest 7 incl. the exact int16 boundary at 32767/32768 decimetres, MetricPolylineSimplifierTest 6), plus a real end-to-end RouteSnapper→MapSliceBuilder run against the real ~119km komoot GPX fixture.

41 issues closed.

Closed by PR #117 (`area/map-viewport-geometry`). Scoped to D39's 2026-09-02 update, which supersedes this issue's original "project to 200x228 screen pixels" body — the phone now sends a slice of route geometry in a stable metric frame; the watch does its own rotation/scaling. New `companion/map` classes: `MapAnchor` (plain signed lat/lon), `MetricOffsetMeters` (pre-quantisation Double meters, kept separate from the quantised type so Douglas-Peucker runs before rounding, not after), `MetricPoint` (the actual `int16` decimetre wire shape, hard `require`d into `Short.MIN_VALUE..Short.MAX_VALUE` — throws rather than silently clamping on overflow), `MetricPolylineSimplifier` (a genuinely justified second Douglas-Peucker implementation — operates directly in the already-metric frame with no per-run trigonometry, unlike core's lat/lon version), `MapCueMarker`, `MapSlice` (with `needsRefresh()` implementing D39's "as the rider nears the edge" cadence, correctly suppressed once the slice already reaches the route's end), and `MapSliceBuilder` (the pipeline: anchor = the rider's own interpolated on-route position, clip window widened to bracketing vertices, DP tolerance derived from the *connected watch's actual* screen dimensions via `max(width,height)` — reasoned against heading-up rotation possibly aligning either axis with travel — never a hard-coded 200×228 grid). Reuses `RouteSnapper`/`RouteSnap` (#31, including its #39-audited self-crossing fix) for rider position rather than re-deriving it, and `cumulativeDistancesMeters` (widened `internal`→`public`, its first cross-module consumer) for real clip-window distances. Correctly stayed out of #41 (chunked wire transport), #42 (watch-side rendering), and #43 (zoom/recentre) — produces a real, tested in-memory `MapSlice` for #41 to serialize, nothing here touches AppMessage. Verified the wire keys this feeds (`MAP_ANCHOR_LAT/LON`, `MAP_POLYLINE`, `MAP_SEQ`, `MAP_CUES`, `MAP_POS`, `MAP_HEADING`) already existed in `shared/message_keys.json`/`docs/PROTOCOL.md` §2.5 from prior codegen — no wire changes needed here, `MetricPoint`'s shape matches them exactly so #41 only serializes, never converts. **Real verification**: genuine `./gradlew :companion:map:test :companion:core:test :companion:route:test :companion:assembleDebug --rerun-tasks` full rebuild, all green — 40 tests across 5 suites in `:companion:map` (MapAnchorTest 5, MapSliceBuilderTest 13, MapSliceTest 9, MetricPointTest 7 incl. the exact int16 boundary at 32767/32768 decimetres, MetricPolylineSimplifierTest 6), plus a real end-to-end `RouteSnapper`→`MapSliceBuilder` run against the real ~119km komoot GPX fixture. 41 issues closed.
Sign in to join this conversation.
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
robert/PedalPebble#40
No description provided.