Issue #40: phone-side map slice geometry (clip, project, simplify) #117
No reviewers
Labels
No labels
area:companion
area:docs
area:shared
area:tooling
area:watchapp
blocker
kind:chore
kind:feature
kind:spike
kind:test
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
robert/PedalPebble!117
Loading…
Reference in a new issue
No description provided.
Delete branch "area/map-viewport-geometry"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implements #40 per D39's 2026-09-02 update, which supersedes the issue's own original acceptance criteria (screen-coordinate projection, hard-coded 200x228 grid). Scope is strictly the phone-side geometry pipeline: anchor selection, clip, metric-frame project, Douglas-Peucker simplify, cue placement, and the refresh decision. #41 (chunked MAP_POLYLINE transport) and #42 (watch-side rendering) are untouched.
New types (
companion/map)MapAnchor+metersEastNorthOf: plain signed lat/lon anchor; flat-earth projection of aGpxPointto meters east/north of it, shared across one whole slice (not re-derived per segment, unlike core's per-segment projections).MetricOffsetMeters(pre-quantisationDoublemeters) vs.MetricPoint(the wire's ownint16decimetre pair) -- kept as two types on purpose so Douglas-Peucker runs before quantisation, not after.MetricPolylineSimplifier: Douglas-Peucker over the already-projected Cartesian metric frame.MapCueMarker/MapSlice: output types, withMapSlice.needsRefresh()implementing D39's "as the rider nears the edge of the sent slice" decision, including a route-end special case.MapSliceBuilder: the pipeline itself.Reused vs. new, and why
Reused directly:
RouteSnapper/RouteSnap(#31) for "where is the rider right now" --MapSliceBuildernever re-derives a position from a raw GPS fix; it only readsRouteSnap.distanceAlongRouteMeters(notsegmentStartIndex, which it doesn't need since it re-scans its own clip window independently).Cue/Direction/EnrichmentTieras-is.Directionis carried through toMapCueMarkerunchanged, not pre-encoded to a wire byte -- that numeric mapping is #41's call (andDirection's own KDoc already says nothing should depend on its ordinal for a wire value).cumulativeDistancesMeters, widened frominternaltopublicinRouteGeodesy.kt-- its first cross-module consumer.internalin Kotlin is module-private, not package-private, so this single, behaviour-preserving visibility change was necessary for a straight reuse rather than a reimplementation. (haversineMeters,locateAlongPolyline,PolylineLocationwere leftinternal-- no call site here needed them directly.)Deliberately NOT reused, with the reason documented in code:
PolylineSimplifier(core, GPX import's Douglas-Peucker): operates onGpxPoint(lat/lon) and re-derives a local equirectangular projection (cos(lat)) per RDP run, because its callers hand it raw lat/lon with no shared frame. This issue's input is already Cartesian (every point already shares one fixed anchor), so there is no lat/lon and no trigonometry left to do -- reusing it would mean re-deriving lat/lon just to have it immediately re-projected back to meters. It is alsointernalto:companion:corein any case, so a straight cross-module call isn't even available.MetricPolylineSimplifieris a second, small, well-justified implementation, mirroringPolylineSimplifier's own iterative-stack design for the identical reason (avoiding recursion-depth blowup on a pathological zigzag).locateAlongPolyline: not needed --RouteSnapperalready solves "where is the rider" (including the self-crossing disambiguation from #39's audit), and cues are placed viaroute.points[cue.polylineIndex]directly (aCuealready carries the exact vertex it was enriched against).Key design decisions
distanceAlongRouteMetersRouteSnapreports --MapSliceBuilder.pointAtDistanceis the (new, small) inverse oflocateAlongPolyline: distance -> point, which nothing existing provides. One direct, tested consequence:MapSlice.riderPositionis always exactlyMetricPoint(0, 0).+90/+180-offset-to-unsigned trick (MAP_ANCHOR_LAT/MAP_ANCHOR_LONareuint32). That offset is an AppMessage encoding necessity applied once, at serialization time -- #41's job -- not a domain concept; baking it in here would just make #41 undo it.screenWidthPx/screenHeightPx(received once at handshake,SCREEN_W/SCREEN_Hper docs/PROTOCOL.md section 3), passed intoMapSliceBuilder.buildas plain parameters -- never a hard-coded 200x228 constant, which is exactly what D39's update killed.screenDerivedToleranceMetersusesmax(width, height)as the reference dimension (notmin): heading-up rotation means either axis can end up aligned with the direction of travel, so the longer axis (more pixels, finer resolution) is the conservative choice -- using the shorter axis would discard detail the watch's longer axis could actually render. One pixel-equivalent of the clip window's real-world span is the tolerance, floored atMIN_TOLERANCE_METERS(0.1 m) -- the wire's own decimetre quantisation grain, below which a finer tolerance is meaningless.MetricPoint'sinithard-requires both axes intoShort.MIN_VALUE..Short.MAX_VALUE(+-3.2 km, D39's own number) rather than silently clamping -- an out-of-range point is a bug in the caller (too large a clip window), not something to paper over.MapSliceBuilder's default window (100 m behind + 400 m ahead = 500 m) sits far inside that range by construction (arc length along the route is always >= the anchor-to-point straight-line distance). Verified directly inMetricPointTestat the exact boundary: 32767 dm (3276.7 m) accepted, 32768 dm (3276.8 m) rejected.Wire shapes verified, not guessed
Checked
shared/message_keys.jsonanddocs/PROTOCOL.mdsection 2.5 forMAP_ANCHOR_LAT/MAP_ANCHOR_LON/MAP_POLYLINE/MAP_CUES/MAP_POS/MAP_HEADINGbefore writing any type --MetricPointmatchesMAP_POLYLINE/MAP_CUES/MAP_POS'sint16-decimetre-pair shape exactly,MapCueMarkermatchesMAP_CUES'seast, north, directiontriple.Tests -- real host runs, not hand-review
./gradlew :companion:map:test --rerun-tasks: 39 cases, all green. Also ran:companion:core:test,:companion:route:test(unaffected, still green) and:companion:assembleDebug(whole app still assembles after removing theMapViewport.ktplaceholder).Covers: int16 range/overflow at the exact +-3.2 km boundary; the anchor-relative projection round-tripping to known real-world distances (0.001 deg latitude ~= 111.32 m, longitude scaled by
cos(lat)); Douglas-Peucker genuinely dropping a sub-tolerance wiggle while preserving a real corner (both synthetic, exact-value tests, and against the real fixture); cue markers independently re-derived and checked against the identical anchor/projection the builder used; the fullneedsRefreshdecision matrix including the route-end special case; and an end-to-end run wiring a realRouteSnapperintoMapSliceBuilderagainst the same real komoot exportRouteSnapperTestuses (real-komoot-havelchaussee-glienicker-bruecke.gpx, copied into this module's own test resources -- no sharedjava-test-fixturessetup exists yet to avoid the duplication cleanly).Note on test design: most scenarios construct
RouteSnapdirectly at a caller-chosendistanceAlongRouteMetersrather than driving a freshRouteSnapperto an arbitrary position --RouteSnapper.snapalways starts its search window at cursor 0 (by design, per its own KDoc), so reaching a position kilometers into a real route from a fresh instance either needs a long walked fix sequence or isn't representative of whatMapSliceBuilderactually consumes (only the distance number). One genuine end-to-end test exercises the realRouteSnapper->MapSliceBuilderpath near the route's start, where a fresh snap naturally lands correctly.Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt