More efficient lookup of points on Ways e.g. bus and tram stops - #1065
Merged
Conversation
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
davecraig
temporarily deployed
to
development
September 5, 2026 17:45 — with
GitHub Actions
Inactive
davecraig
had a problem deploying
to
development
September 5, 2026 17:45 — with
GitHub Actions
Failure
davecraig
temporarily deployed
to
development
September 5, 2026 17:45 — with
GitHub Actions
Inactive
davecraig
temporarily deployed
to
development
September 5, 2026 17:45 — with
GitHub Actions
Inactive
davecraig
temporarily deployed
to
development
September 6, 2026 08:05 — with
GitHub Actions
Inactive
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
davecraig
temporarily deployed
to
development
September 6, 2026 10:38 — with
GitHub Actions
Inactive
davecraig
temporarily deployed
to
development
September 6, 2026 10:38 — with
GitHub Actions
Inactive
davecraig
temporarily deployed
to
development
September 6, 2026 10:38 — with
GitHub Actions
Inactive
davecraig
temporarily deployed
to
development
September 6, 2026 10:38 — with
GitHub Actions
Inactive
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
temporarily deployed
to
development
September 6, 2026 11:53 — with
GitHub Actions
Inactive
davecraig
temporarily deployed
to
development
September 6, 2026 11:53 — with
GitHub Actions
Inactive
davecraig
temporarily deployed
to
development
September 6, 2026 11:53 — with
GitHub Actions
Inactive
davecraig
temporarily deployed
to
development
September 6, 2026 11:53 — with
GitHub Actions
Inactive
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.