The heat map draws grid squares, so diagonal streets render as staircases #62

Open
opened 2026-08-11 12:03:11 -04:00 by agent · 1 comment
Member

The heat map draws one filled square per grid cell. On the ground that reads as blocky, and any street that is not aligned to the grid's axes renders as a staircase rather than a line.

Tabled deliberately. iOS is closing out after #60 and Stash pivots to the stash-docs contracts. This is filed so it is not lost, not to be picked up now.

What it looks like

Observed by Wes on device, 2026-08-11, on a library of walks around one town:

"I don't like how the paths are being applied to a grid and are 'blocky' instead of being smoothed and matched to the road/sidewalk paths... I understand that I may not always be walking on a sidewalk/road, but I feel like there's a better way to handle the visualization."

North-south and east-west streets look acceptable because they happen to run along the grid's axes. A street at roughly 45° renders as a visible flight of steps, each tread 25 m — the cell size. Junctions bloom into squares wider than any road.

Where it is

Stash/Views/HeatOverlay.swift:194-209, in draw(_:zoomScale:in:):

for (cell, count) in grid.cells(in: mapRect) {
    context.setFillColor(Self.heat.withAlphaComponent(alpha).cgColor)
    let ground = HeatGrid.aligned(grid.rect(of: cell), toTile: mapRect)
    context.fill(square.insetBy(...))
}

One context.fill of an axis-aligned rectangle per cell, at HeatGrid.metresPerCell = 25.

The grid itself is not the problem and should not be changed. HeatGrid's two load-bearing rules are right and this issue does not touch them: a cell's weight is the number of distinct recordings that crossed it, not the number of fixes, so standing still does not outrank a street walked a hundred times; and the colour curve is logarithmic, so a street walked five times is distinguishable from one never walked. The defect is purely that the counting model is also being used as the drawing model.

Approaches considered

Snap to the road network — rejected

The literal reading of "matched to the road/sidewalk paths", and the wrong answer here for two independent reasons.

It cannot be done privately. MapKit exposes no map-matching API. Doing it means either an external service (Mapbox Map Matching, Valhalla, OSRM) — which means sending a user's location history off the phone, against the property this project exists to hold — or bundling road geometry for offline matching, which is a large data problem for a feature that draws a picture.

It would be false. Wes named this himself: he is not always on a road. His own capture crosses open green space between Longfellow and Greenbrier. Map matching does not represent that as "walked across the park"; it snaps it to the nearest street and asserts a route that was never taken. The heat map's question is where do I actually go, and where do I not — a renderer that relocates the honest answer onto the nearest road defeats it.

Convolve the cell counts with a Gaussian and render the smooth field. Small change, fully on device, removes the hard edges.

It removes the staircase and replaces it with soft blobs that still follow the grid's ghost rather than the walked line. It is the least work and the least payoff.

Stop drawing the grid. Keep HeatGrid exactly as it is, as the counting model, and draw the actual routes:

  1. Read the fixes for every track in the library, applying the same leg rules the grid already enforces — mostALegEstablishes (200 m) and mostALegMaySpan (30 s) — so a gap stays a gap and no line is drawn through a building.
  2. Smooth each run of fixes (Chaikin, or Catmull-Rom through the points) so GPS scatter does not render as a jagged line.
  3. Stroke it with round caps and round joins, at a width in metres rather than points so it scales with zoom.
  4. Take each segment's alpha from HeatGrid's count for the cell the segment passes through, through the existing logarithmic intensity(of:).

The line then follows the ground actually covered — on the street where the walk was on the street, across the park where it was across the park — and no external data is consulted. Colour keeps meaning what it means today, because the number behind it is unchanged.

Taking alpha from the grid rather than from overlapping strokes is the part that matters: additive alpha over repeated passes would render GPS jitter as a fat fuzzy band and make a street walked once beside a street walked fifty times hard to tell apart.

What this has to keep working

  • The date filter.
  • TrackFraming and the initial camera framing.
  • The centring fix from #39 — the floating-point tie that put the overlay's origin a world east of where MapKit looks for it.
  • Light and dark, with the alpha ramp chosen from the colour scheme. The faint end is a street walked once, and it does not survive the wrong ramp.
  • The truncation disclosure when the library stops taking new ground.

Open

Whether an MKOverlayRenderer drawing many smoothed strokes performs acceptably against a large library at low zoom, where today's renderer draws one rect per visible cell and culls cheaply. Worth measuring before committing to the approach; if it does not hold, the fallback is the density field above.

The heat map draws one filled square per grid cell. On the ground that reads as blocky, and any street that is not aligned to the grid's axes renders as a staircase rather than a line. **Tabled deliberately.** iOS is closing out after #60 and Stash pivots to the `stash-docs` contracts. This is filed so it is not lost, not to be picked up now. ## What it looks like Observed by Wes on device, 2026-08-11, on a library of walks around one town: > "I don't like how the paths are being applied to a grid and are 'blocky' instead of being smoothed and matched to the road/sidewalk paths... I understand that I may not always be walking on a sidewalk/road, but I feel like there's a better way to handle the visualization." North-south and east-west streets look acceptable because they happen to run along the grid's axes. A street at roughly 45° renders as a visible flight of steps, each tread 25 m — the cell size. Junctions bloom into squares wider than any road. ## Where it is `Stash/Views/HeatOverlay.swift:194-209`, in `draw(_:zoomScale:in:)`: ```swift for (cell, count) in grid.cells(in: mapRect) { context.setFillColor(Self.heat.withAlphaComponent(alpha).cgColor) let ground = HeatGrid.aligned(grid.rect(of: cell), toTile: mapRect) context.fill(square.insetBy(...)) } ``` One `context.fill` of an axis-aligned rectangle per cell, at `HeatGrid.metresPerCell = 25`. **The grid itself is not the problem and should not be changed.** `HeatGrid`'s two load-bearing rules are right and this issue does not touch them: a cell's weight is the number of **distinct recordings** that crossed it, not the number of fixes, so standing still does not outrank a street walked a hundred times; and the colour curve is logarithmic, so a street walked five times is distinguishable from one never walked. The defect is purely that the counting model is also being used as the drawing model. ## Approaches considered ### Snap to the road network — rejected The literal reading of "matched to the road/sidewalk paths", and the wrong answer here for two independent reasons. **It cannot be done privately.** MapKit exposes no map-matching API. Doing it means either an external service (Mapbox Map Matching, Valhalla, OSRM) — which means sending a user's location history off the phone, against the property this project exists to hold — or bundling road geometry for offline matching, which is a large data problem for a feature that draws a picture. **It would be false.** Wes named this himself: he is not always on a road. His own capture crosses open green space between Longfellow and Greenbrier. Map matching does not represent that as "walked across the park"; it snaps it to the nearest street and asserts a route that was never taken. The heat map's question is *where do I actually go, and where do I not* — a renderer that relocates the honest answer onto the nearest road defeats it. ### Blur the grid into a density field — viable, cheap, not recommended Convolve the cell counts with a Gaussian and render the smooth field. Small change, fully on device, removes the hard edges. It removes the staircase and replaces it with soft blobs that still follow the grid's ghost rather than the walked line. It is the least work and the least payoff. ### Render the tracks, weighted by the grid — recommended Stop drawing the grid. Keep `HeatGrid` exactly as it is, as the counting model, and draw the actual routes: 1. Read the fixes for every track in the library, applying the same leg rules the grid already enforces — `mostALegEstablishes` (200 m) and `mostALegMaySpan` (30 s) — so a gap stays a gap and no line is drawn through a building. 2. Smooth each run of fixes (Chaikin, or Catmull-Rom through the points) so GPS scatter does not render as a jagged line. 3. Stroke it with round caps and round joins, at a width in metres rather than points so it scales with zoom. 4. Take each segment's alpha from `HeatGrid`'s count for the cell the segment passes through, through the existing logarithmic `intensity(of:)`. The line then follows the ground actually covered — on the street where the walk was on the street, across the park where it was across the park — and no external data is consulted. Colour keeps meaning what it means today, because the number behind it is unchanged. Taking alpha from the grid rather than from overlapping strokes is the part that matters: additive alpha over repeated passes would render GPS jitter as a fat fuzzy band and make a street walked once beside a street walked fifty times hard to tell apart. ## What this has to keep working - The date filter. - `TrackFraming` and the initial camera framing. - The centring fix from #39 — the floating-point tie that put the overlay's origin a world east of where MapKit looks for it. - Light and dark, with the alpha ramp chosen from the colour scheme. The faint end is a street walked once, and it does not survive the wrong ramp. - The truncation disclosure when the library stops taking new ground. ## Open Whether an `MKOverlayRenderer` drawing many smoothed strokes performs acceptably against a large library at low zoom, where today's renderer draws one rect per visible cell and culls cheaply. Worth measuring before committing to the approach; if it does not hold, the fallback is the density field above.
Author
Member

The artifact on device

Screenshot, 2026-08-11, the "Where I Walk" screen against the current library.

Heat map drawing 25 m grid squares, with diagonal streets rendered as staircases

Streets running north-south and east-west read as continuous bands, because they happen to run along the grid's axes. The diagonal streets in the west of the frame render as a clear flight of steps, each tread one 25 m cell. Junctions bloom into squares several times wider than the roads meeting at them, and a walk across open green space reads as the same blocks as a walk down a street — which is correct as data, and is exactly the case the rejected map-matching approach would have drawn wrongly.

Posted with Wes's explicit agreement, having been asked first: this repository is public and the image shows his routine walking routes with street names legible.

## The artifact on device Screenshot, 2026-08-11, the "Where I Walk" screen against the current library. ![Heat map drawing 25 m grid squares, with diagonal streets rendered as staircases](https://git.wes.today/attachments/3cf97475-998b-4c7c-89fe-1d9d7256c294) Streets running north-south and east-west read as continuous bands, because they happen to run along the grid's axes. The diagonal streets in the west of the frame render as a clear flight of steps, each tread one 25 m cell. Junctions bloom into squares several times wider than the roads meeting at them, and a walk across open green space reads as the same blocks as a walk down a street — which is correct as data, and is exactly the case the rejected map-matching approach would have drawn wrongly. Posted with Wes's explicit agreement, having been asked first: this repository is public and the image shows his routine walking routes with street names legible.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Stash/stash-ios#62
No description provided.