Skip to content

More efficient lookup of points on Ways e.g. bus and tram stops - #1065

Merged
davecraig merged 10 commits into
Scottish-Tech-Army:mainfrom
davecraig:main
Sep 6, 2026
Merged

More efficient lookup of points on Ways e.g. bus and tram stops#1065
davecraig merged 10 commits into
Scottish-Tech-Army:mainfrom
davecraig:main

Conversation

@davecraig

Copy link
Copy Markdown
Contributor

No description provided.

davecraig and others added 8 commits September 5, 2026 18:44
Crossings were stored as five loose string-keyed entries in the flat
MvtFeature.properties map - crossing_type, crossing_name,
crossing_position, crossing_latitude and crossing_longitude. That shape
had real limits: one crossing per Way with last-write-wins, a lat/lng
split into two Doubles only because the Moshi adapter writes any
non-primitive property value as JSON null, stringly-typed
"waterway"/"railway" and "over"/"under" re-parsed at every read site, and
no position along the Way at all.

Way now carries alongWayFeatures: a list of AlongWayFeature sorted by
distance in metres from the Way's START intersection, with AlongWayKind
and AlongWayPosition replacing the strings. A sorted list rather than a
map keyed by distance because commonMain has no sorted-map type and two
features can share a distance. This is the structure bus stops, stations
and highway junctions will move onto, so that finding the next one along
the road becomes a lookup instead of a geographic search.

The distance walk StreetDescription.distanceAlongLine already did is
extracted as distanceAlongLineString and shared with the new
Way.distanceAlongWay.

Attachment rules and every callout trigger are unchanged, so nothing
fires at a different moment. Waterway crossings move from being set on
the pre-split MvtFeature to WayGenerator.attachCrossings, called after
generateWays - the distance along a Way is only meaningful once the
parent feature has been split into Ways with their own geometry.

Two behaviour improvements fall out of the shape rather than being aimed
at: a Way can now hold every crossing it makes rather than only the last
one written (a bridge over a double-track line records both tracks; a
road crossing a meandering burn twice records both), and where a Way
carries both, the waterway is now preferred over the railway as the
bigger landmark instead of whichever happened to be written last.

Transit stops, junctions and pedestrian crossings are not populated yet,
and the rtree searches in buildCalloutForTrainCrossing stay - they find
crossings on other Ways the train passes, not on the matched Way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQyoTtPL9ENAf6PiJKmhXf
buildCalloutForTrainCrossing did two rtree queries per update, both
redundant. The first searched TreeId.TRANSIT for railway Ways near the
user and kept the ones whose name matched the line being ridden - but
the map matcher has already decided which railway Way that is, so it can
just be read. The second searched TreeId.ROADS at the trigger radius and
kept the ones whose recorded crossing named this line, which on a
railway means sifting every road nearby, and most of those run
*alongside* the line rather than crossing it.

The road half needed the data moving before the search could go: a
railway/road crossing was recorded only on the road, and the road the
train is matched to is the one running parallel, not the one crossing.
attachRailwayCrossings now records both sides from the same single
geometric test - the existing RAILWAY_CROSSING on the road, and a
mirrored ROAD_CROSSING on the railway carrying the road Way itself and
the train's own over/under sense. The road Way rather than its name, so
the callout still names it with the user's localized strings.

The callout is now a walk of two short pre-sorted lists on one known Way.
In a central Glasgow grid that is at most 7 entries, against an rtree
query over 18169 road Ways.

applyCrossing gains an explicit spreadAcrossOsmId rather than inferring
it from position, since the railway side always wants every piece: a
road bridge is a short Way, and at line speed a train can cross one
between two location updates without ever being matched to the single
railway piece that intersects it.

testTrainCrossingNamesTheRoadNotTheRailway now uses the real railway Way
from the grid rather than a synthetic one, and asserts the mirrored
crossing is on it with the inverted position.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQyoTtPL9ENAf6PiJKmhXf
The user's position along the matched Way is now tracked as a WayCursor -
which Way, how far from its START, and which way they're heading - and
the crossing callouts query from it instead of measuring straight-line
distance to a crossing point.

Crow-fly was only ever an approximation of "how far until I reach it",
and it gets worse the less straight the road is: on a road curving back
towards a bridge it reads small while the distance still to drive is
large. It also can't tell ahead from behind, so a crossing just passed
looked identical to one coming up.

The new queries live in utils/AlongWay.kt:

- alongWayFeaturesAfter/Before slice a Way's sorted list with a binary
  search rather than a scan. The lists are short while only crossings are
  recorded, but this is the primitive transit stops and junctions will
  use, and a busy road carries many more of those than it does bridges.
- forEachAlongWayFeatureAhead walks on into the Ways beyond the current
  one, through intersections joining exactly two Ways and stopping at a
  real junction - the rule Way.followWays uses and Street Preview
  follows. Walked directly rather than by calling followWays, which seeds
  from the intersection behind the first Way and so can't start from a
  Way that has nothing behind it.
- With no travel heading - stationary, or the first fix - the walk goes
  both ways and the nearest wins, rather than guessing a direction.

Because the walk crosses Way boundaries by itself, spreading a crossing
onto every piece sharing an osmId is no longer needed - and is now
actively wrong, since those extra entries sit at a distance clamped to
one of the piece's ends and the queries would read that as a real
position, reporting one crossing several times over at wrong distances.
Each crossing is attached to the single Way its point lies on.

AutoCallout also walks back to where the user was on the previous fix. A
crossing that falls entirely between two fixes was never inside the
lookahead on either of them - a GPS gap, or line speed against the 150m
clamp - and saying "Passing under X" a moment late beats never saying it.
The window is gated on elapsed time, not distance moved: 400m between
fixes is thirteen seconds of motorway, whereas a minute without a fix is
a gap in tracking rather than a long step.

New AlongWayTest covers the queries against hand-built Way graphs, and
testCrossingSkippedBetweenFixesIsStillCalledOut pins the backward walk
against real Castlecary data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQyoTtPL9ENAf6PiJKmhXf
crossingToAnnounce short-circuited when the matched Way carried no
crossing of its own, returning before the along-way walk ever ran. That
kept the road and walking callouts reading only the Way they were matched
to - the behaviour the walk was meant to replace - so a crossing on the
Way ahead was invisible until the map matcher moved onto it.

The branch could only ever return null in that case anyway: it leads to
wayCrossingInfo, whose water-polygon fallback needs a brunnel as its
evidence, so a Way with neither a crossing nor a brunnel had nothing to
give. Gating on the brunnel alone keeps the structure edge-trigger exactly
as it was and lets everything else fall through to the walk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQyoTtPL9ENAf6PiJKmhXf
Bus stops are now recorded against the road they serve, as TRANSIT_STOP
AlongWayFeatures at their position along it, and announced on the
approach rather than as they go past - being told about a stop as it
disappears behind you is too late to do anything with. The 100m is a
single constant and is expected to be tuned once it has been ridden with.

That replaces sweeping the path travelled since the last fix against the
stop tree. Proximity to the route was all that search had to go on, so it
could not tell a stop on this road from one on the street behind the
hedge, and it needed a bearing comparison per candidate to guess which
kerb each was on.

Which kerb is now settled once, where the geometry is to hand: attaching
a stop records the side it sits on relative to the road's own direction,
so the callout only flips that for the direction of travel. A stop on the
far kerb serves the opposite direction and is skipped, and the test
covers the case where the far-side stop is also the nearer one, so
getting it right means genuinely passing over a closer stop.

Looking a hundred metres up an urban main road means crossing side
streets, which the existing walk refused to do - it stops at any real
junction, which is the right answer for something you are about to
arrive at but leaves almost every stop out of reach. So the walk gained
WayContinuation: STRAIGHT_ON as before, or SAME_ROAD, which follows
whichever Way continues the road by name or ref. A tile-boundary JOINER
has no name to match on, so it is followed when nothing else at that
intersection continues the road; two same-named arms of a staggered
junction stop the walk rather than being guessed between.

Two supporting changes:

- The announced-features list is now shared by the crossings and the
  stops, and pruned once per update rather than in each builder.
  CalloutHistory cannot express a 100m approach at all: its trim() drops
  entries more than 50m from the user, so the stop would be re-announced
  every fix on the way in.
- WayCursor's direction falls back to the bearing from the previous fix
  when the GPS fix carries no usable travel heading. The swept search
  derived direction that way, and requiring travelHeading would have been
  a regression in robustness.

Measured on a Milngavie Road drive, 20 updates: 50 FeatureTree searches
before, 32 after.

The spoken wording is unchanged ("Near <stop>"), which now reads as a
heads-up rather than a note of passing. Changing it means new strings
across every translation, so it is left as a separate decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQyoTtPL9ENAf6PiJKmhXf
The railway=stop nodes now in the tiles sit on the line itself, at the
point a train actually stops, so a line knows its own stops exactly.
A station POI could only ever be matched to a line by proximity, and
where lines run close together the nearest station to a train is often
one its line runs straight past.

They arrive as transportation-layer points with class=rail, subclass=stop
- which the existing parse already turns into featureType/featureValue -
but nothing claimed them, so they were dropped before reaching a tree.
Added TreeId.RAILWAY_STOPS, deliberately not folded into TRANSIT_STOPS or
POIS: a stop node is not a place in its own right, and the station POI is
already there, so putting both in would name the same station twice in
any list of what's nearby.

attachRailwayStopsToWays records each one against the railway Way it lies
on, as a RAILWAY_STOP AlongWayFeature. The tolerance is 2m rather than
the 20m used for bus stops: these are on the line, so a genuine one is at
zero distance and anything further out is a tagging mistake - taking it
would put a stop on a line that doesn't serve it, the very thing these
nodes avoid.

Two consumers:

- buildCalloutForTrainStop announces the next station on the approach,
  500m out. Longer than the road equivalent because a train covers ground
  faster; about seventeen seconds at line speed, and tunable like the
  other. Keyed on the station name rather than the node, since a station
  is commonly several stop nodes, one per platform.
- LastStationTracker prefers a stop on the line being ridden over the 50m
  nearest-station poll, falling back to it where there is no stop node -
  older tiles carry none at all, and not every station is tagged with one.

announcedForgetDistanceMetres goes from 300m to 1500m: it has to exceed
the largest lookahead, or something announced at range is forgotten while
still being approached and announced again on the next fix.

Verified against a scotland.pmtiles carrying the new data: 11 stop nodes
across the Clydebank grid, all 11 attached to the North Clyde Line with
none left over, and "Near Singer" spoken once at 400m out and not
repeated. The committed test fixtures predate the tag, so the unit tests
here cover the along-way lookahead against hand-built graphs only - real
coverage waits on the fixtures being rebuilt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQyoTtPL9ENAf6PiJKmhXf
"Near X" was the wording inherited from the old passed-it-already sweep.
Both stop callouts now fire while the stop is still ahead, where "Near"
reads as a note of passing something rather than a warning that it is
coming up, so both say "Approaching X". One new string; the translations
will follow through Weblate.

Tram stops come in alongside the train ones. railway=tram_stop sits on
the tramway exactly as railway=stop sits on a railway, and a tramway is
already part of the transit network the map matcher tracks - so trams
need nothing of their own beyond accepting the tag.

Accepting it needed both halves of the match, not just the value. The
poi layer has carried its own tram_stop entries all along - the platform
beside the track rather than the point on the line - and matching on the
value alone pulled those in too, attaching every tram stop to its line
twice.

Verified against a scotland.pmtiles carrying tram_stop: 42 stops across
an Edinburgh grid (26 rail, 16 tram), all attached with none left over
and none duplicated, and "Approaching Princes Street" spoken once at
300m out while riding the tram line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQyoTtPL9ENAf6PiJKmhXf
Way.getName falls back to a generic word for any unnamed railway, since a
line's real name is an OSM route-relation concept the tile schema doesn't
carry onto individual Ways yet. That word was always "train", and the
Edinburgh tram line is unnamed in the tile data, so riding it announced
"On tram stop..." as "On train and close to Old Town" - the wrong vehicle.

Only tram takes the new word. light_rail, monorail and funicular have no
one word that reads right across the systems tagged with them, and
"train" is at least not wrong for those, so they are left alone and the
test pins that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQyoTtPL9ENAf6PiJKmhXf
Removes the Way-change edge trigger, and with it the state and the
special cases it needed. A crossing is now a point recorded on a Way
like any other along-way feature, so deciding whether to announce one is
a single question about distance along the road or the rails.

Fixes found by review, each with a test that fails without its fix:

- sweepHeading() could only ever return null: updateSweepWindow runs
  first and overwrites lastSweepLocation with the current location
  before any builder reads it. The fallback heading was dead, so a fix
  with no travel bearing produced no transit stop or train stop callout
  at all. The previous location is now held separately.

- AlongWayFeature.side was read against the cursor's direction rather
  than the walk's direction on the Way the stop is actually on. The
  pieces a road is split into are not all digitised the same way round,
  so this announced the far kerb for stops on a reversed continuation.
  AlongWayFeatureAhead now carries the walk's per-Way direction.

- buildCalloutForVehicleTransitStop had no train guard, so a rail
  passenger was told about the bus stops on the road beside the line.

- A bridge split into several Ways shares one osmId, and attachCrossings
  recorded the crossing against only the piece nearest it, so entering
  by another piece lost the callout. It now attaches to every piece at
  the crossing. Drymen Road over the Milngavie Branch is the case; a
  water crossing usually survived it because of the polygon fallback.

- cursorOn projected from the road-matched point even when asked about
  the railway, carrying the road matcher's offset into the distances a
  train passenger's lookaheads are measured from.

- A feature exactly on the node a Way was split at was invisible to a
  walk arriving from the previous piece, which is where railway=stop
  nodes commonly sit.

Then the simplification the fixes made possible:

- Crossing callouts were placed at the user's own position, which meant
  TrackedCallout equality could never match two callouts about the same
  crossing, which is why the anti-repeat bookkeeping existed. They are
  now placed at the crossing, and CalloutHistory does the work.
  CalloutHistory gains a trim radius and TrackedCallout a match radius,
  both defaulted, which is what lets one history serve callouts made
  about things up to 150m away.

- GridState.attachWaterPolygonCrossings records the firths and bays that
  extractCrossings cannot see, because they are water polygons rather
  than waterway lines. That was the only thing the edge trigger was
  still needed for: with no recorded point there was no distance to
  measure. Note the collection holds LineStrings too - the Menai Strait
  arrives as the line of its shore.

- railwaySideCrossingKinds and roadSideCrossingKinds become one set. The
  two railway kinds are mirrors recorded on opposite Ways, so which of
  them a lookup can find is already settled by the Way it asks about.

- crossingToAnnounce, buildCalloutForTrainCrossing and the two road
  builders become one buildCalloutForCrossingOn plus per-kind naming
  rules. The train path had its own copy of crossingCalloutText.

Behaviour changes worth knowing about:

- A crossing is announced when it comes within the lookahead rather than
  on arriving at the structure, so a second or two earlier.
- Stepping off a bridge and straight back on no longer re-announces; the
  forget distance and time govern that now, not the Way change.
- The road side steps over a crossing that is not worth announcing
  instead of giving up on the search, as the train side already did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQyoTtPL9ENAf6PiJKmhXf
buildCalloutForTrainStop walks the line being ridden for a RAILWAY_STOP
along-way feature, which comes from an OSM railway=stop node. It has no
fallback: no node, no callout, and it never touches the grid to look for
one another way.

The tag is missing from any tileset built before it was carried - the
committed fixtures included, where TreeId.RAILWAY_STOPS is empty and not
one stop is attached across 731 railway Ways in central Glasgow - and it
is genuinely absent from parts of OSM regardless. So a ToTown replay
announced no stations at all.

That was masked until recently. Station POIs are folded into
TRANSIT_STOPS and attached to roads, so buildCalloutForVehicleTransitStop
used to name a station to a passenger whose road matcher had latched onto
a road running past it. Guarding that builder against trains in 1ce6c10
was right - reading out the bus stops on a parallel road is not what a
rail passenger wants - but it took the station names with it.

So resolve it where the other along-way features are resolved rather
than searching at callout time. attachStationsAsRailwayStops runs after
attachRailwayStopsToWays, and for each named station with no stop node
within reach it attaches the station itself to every line running past.
A station serves several platforms and often several lines, and the train
may be map-matched to any one of them.

This is knowingly the guess railway=stop exists to remove: a line that
runs past a station without calling there is given a stop. It applies
only where there is no stop-node data at all, where the alternative is
silence, and it is decided per station rather than per grid, so a mixture
of the two works and it stops doing anything station by station as the
tiles improve.

The 100m radius reaches across a full width of platforms without reaching
a line on a separate alignment. King's Cross and St Pancras are about
100m apart with through lines between them, so they are the case to
re-check when real data is available.

ToTown now announces Hillfoot, Bearsden, Westerton Railway Station,
Anniesland, Jordanhill, Hyndland and Partick (Rail) in journey order.

Both new tests fail with the pass disabled; there was no real-data
coverage of this path before. One of them also asserts the fixtures carry
no railway=stop, so that it fails loudly rather than quietly testing
nothing once they do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQyoTtPL9ENAf6PiJKmhXf
@davecraig
davecraig merged commit 5c5bac8 into Scottish-Tech-Army:main Sep 6, 2026
4 checks passed
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