main #1

Merged
luke merged 4 commits from main into production 2026-09-14 20:48:23 +01:00
Owner
No description provided.
Make the map and the search faster, measured in the production build
All checks were successful
Deploy / test (push) Has been skipped
Deploy / build-and-push (push) Has been skipped
Deploy / deploy-test (push) Has been skipped
Deploy / deploy-production (push) Has been skipped
Deploy / build-dev (push) Successful in 4m5s
Deploy / deploy-dev (push) Successful in 29s
d987ce6568
Dev numbers were misleading (Vite compiles on demand), so everything here
was measured with `npm run build && node build`.

The map was built inside a hidden card. A phone downloads 279KB of tiles
for a map the design never shows, because the container is display:none but
MapView still initialised. It now waits for a container with a size
(ResizeObserver) before creating anything: mobile 2 tiles/279KB -> 0.

Tiles were shipping detail nobody can read at island scale: at z11, 2763 of
2929 landuse features were tree cover and 1302 of 4361 road segments were
housing-estate cul-de-sacs, drawn as sub-pixel speckle. Both are now dropped
below z12 (LOW_DETAIL_ZOOM) before encoding, so they never cross the wire.
z11 103KB -> 55KB, z10 77KB -> 51KB, the island view 409KB -> 313KB. High
zooms are untouched. This does change how the island reads at a glance and
the threshold is a single constant if it wants tuning.

The search was the slow part of the map's page, and not because of the map.
/search?q=cobo+to+town took 1.53s server-side; timing the stages showed
matching at ~1ms and searchJourneys at 1483ms of it. The one-change search
ran a leg-2 query per candidate transfer (an N+1 loop, up to 200 round
trips). Rewritten as a single four-way join: cobo to town 1447ms -> 206ms,
little chapel to deerhound inn 1200ms -> 82ms, vazon to st martin's
1390ms -> 123ms. /search first contentful paint 1620ms -> 408ms.

Equivalence was checked, not assumed: 8 queries baselined against the old
code, 5 byte-identical, the other 3 differing only by returning more options
because the arbitrary per-leg LIMIT 20 is gone (cobo bay to PEH went from 9
results to 20).

The two-change search is deliberately left alone. The same join rewrite was
SLOWER there (9.5s against 5.3s) because three joined trips materialise a
large combination space, and it is the tight inner limits that bound the
capped nested loops. It only runs when direct plus one change yields fewer
than five journeys.

709 unit tests, 47 e2e, type check clean.
Live buses become interactive, and the app gets a proper icon set
All checks were successful
Deploy / test (push) Has been skipped
Deploy / build-and-push (push) Has been skipped
Deploy / deploy-test (push) Has been skipped
Deploy / deploy-production (push) Has been skipped
Deploy / build-dev (push) Successful in 3m52s
Deploy / deploy-dev (push) Successful in 35s
9bbfb82b77
Clicking a live bus on the home map:

- opens a card for that bus (route, destination, registration, when that
  particular vehicle was last heard from) with a "View timetable" button
- highlights its route on the map, isolated and in the route's own colour
- highlights the matching rows in the "Next from ..." timetable
- clicking the map background or Close dismisses it

The timetable link carries the bus's position and heading, so the route
timetable opens on the right direction, marks the stop that bus is nearest
to, and scrolls to it. The page now says which direction it is showing in
words — "Showing the outbound timetable (Town Terminus to Vazon Bay)" — and
the direction chips read "Outbound / to Vazon Bay" rather than a bare
"Outbound".

Bus dots are clickable through MapView's new onSelectBus, mirroring the
existing onSelectStop; live/positions now emits each vehicle's own
reportedAt instead of a snapshot-wide time, and the home page refreshes
positions every 30s like the explorer does, so a bus card cannot show
minutes-old data.

Layout fixes found while testing that flow:

- the route page's map was position:sticky, so it sat over the timetable
  for the whole scroll and rows passed behind it (342px of a 900px
  viewport). The map is back in normal flow at its full 300px and the page
  scrolls, so nothing covers the table
- the mobile tab bar carried a 34px floor on its bottom padding that
  belonged to the design preview's own device frame: 100px tall with 44px
  of it dead space, now 64px with 8px. Tabs stay 90x49

Icons, all generated from one source:

- the bus mark is now a front view (rounded body, destination display,
  mirrors standing off each side, windscreen base, headlights, two wheels)
  following the reference the project chose, as a single BusIcon component
  rather than the same SVG pasted in three places
- scripts/generate-icons.mjs (npm run icons) reads that mark and produces
  favicon.ico (16+32+48), 16/32/48 PNGs, apple-touch-icon (opaque for iOS),
  android-chrome 192/512, a maskable 512 whose glyph stays inside Android's
  80% safe circle, and a monochrome safari-pinned-tab.svg. Rounded tiles
  keep transparent corners (Chromium was flattening them to white)
- app.html and manifest.json wire them up; the manifest's theme colour was
  still the pre-design orange

709 unit tests, 49 e2e, type check clean.

Also included: .research/features.research-brief.yaml, a rider-features
brief from another session that was sitting untracked.
Sharing a position used to change the "Next from" card's title and nothing
else: no confirmation of which stop we picked, no way to correct it, and the
reader still had to type where they were. It is a two-step question, so the
second step is now set up for them.

When a position arrives on the island:

- a strip above the search box names the stop and its distance, so a wrong
  pick is visible rather than silent
- the search box opens holding "from <that stop> to ", focused with the caret
  at the end, and asks "Where to?" instead of restating the whole journey.
  Typing a destination and pressing Enter is the entire second step
- "Change" lists the three next-nearest stops (Bouverie, Cobo Coast Road,
  La Neuve Rue from Cobo Bay), each with its own distance and each carrying
  the position forward so the distance is re-measured from it
- the search box follows the strip when the stop changes, and gives back the
  sentence when the location is turned off, but stops following the moment
  the reader types

Two bugs this turned up:

- switching stops or turning the location off left the old stop's name in the
  search box, so the strip and the box disagreed about where the reader was
- the Change and clear controls were 27px tall and 21px wide, under the 44px
  touch target the rest of the app is held to. Now 56x44 and 44x44

Off-island is handled rather than ignored: the stop list is Guernsey-only, so
a position in London still resolves to a stop 300km away. Past 20km the page
says so and offers name search instead of announcing that as "nearest".

The distance now follows the stop actually shown, not whichever was nearest
(placeDistance in matching.ts), and the loader returns the four nearest plus
the coordinates so the strip's links re-measure rather than losing them.

Tests: tests/e2e/use-my-location.spec.ts drives the real flow with a granted
geolocation at a Cobo Bay coordinate — strip, matching departures card,
primed+focused box, typing through to results, correcting the stop, turning
it off, and the off-island case (6 tests, both projects). Plus placeDistance
unit tests.

711 unit tests, e2e 61 passed / 9 skipped, type check clean.
Point at a row, light that row; and the map shows a chosen stop's routes
All checks were successful
Deploy / test (push) Has been skipped
Deploy / build-and-push (push) Has been skipped
Deploy / deploy-test (push) Has been skipped
Deploy / deploy-production (push) Has been skipped
Deploy / build-dev (push) Successful in 4m9s
Deploy / deploy-dev (push) Successful in 37s
c7ccfdfc06
Two fixes from using the "use my location" flow:

Hovering a departure lit every row on the same route, because the highlight
compared by line name — and a stop served by one route is all one line, so
the whole card lit up. The row under the pointer now lights by trip; a bus
pinned from the map still lights its route's rows, which is what "matching
rows" means there.

And once the reader has a stop in mind (their nearest, or one they picked),
the map now opens on the routes that stop can offer — isolated in green and
fitted to their combined extent — instead of waiting for a hover to reveal
them one at a time. The island overview stays the nobody-has-asked-anything
view; picking a different nearby stop re-fits the map to its routes.

The resting view goes through `bounds`, not `focusBounds`, because the focus
ease and the initial island fit race at map-ready and the instant fit cancels
the 600ms ease before it moves — measured: the zoom never left 11.242. The
derived bounds ignore hover and selection, so pointing at a row never re-fits
the map out from under the cursor.

Tests: tests/e2e/available-routes.spec.ts — hovering lights exactly one row
even when a route repeats in the list (desktop and mobile), and sharing a
location filters the map to the stop's routes and zooms past the island view
(desktop; the home map is desktop-only). Verified against the live map via
the __routesggMap handle.

711 unit tests, e2e 64 passed / 10 skipped, type check clean.
luke merged commit 5dc112a88d into production 2026-09-14 20:48:23 +01:00
Sign in to join this conversation.
No reviewers
No labels
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
gsylabsgg/routes.gg!1
No description provided.