stations: T and J badges for metro and commuter rail (issue #25) #50
No reviewers
Labels
No labels
claude-wip
map-report
map:is
map:se
needs-review
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
art/ismap!50
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "night/issue-25-station-icons"
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 #25 for Stockholm (metro T + commuter J badges); the issue stays open for the wider ask — icons for any local transit in any city we map — tracked by #24 (Warszawa) and follow-ups.
What this does
Sweden's stations layer now knows the Stockholm pendeltåg, and the style draws badge icons instead of plain dots: the standard T for tunnelbana stations and a matching J for commuter-rail stations, from z11 up.
Data: how the J got its stations
505000000000000001. That route_type is shared with a dozen other operators (Östgötapendeln, Pågatåg, Krösatåg…), soreadGTFSStopsgained a per-route_type agency filter (agencies: {106: [SL]}). 900 (tram) is deliberately left unfiltered — Västtrafik/Östgötatrafiken/Skånetrafiken trams ride the same code and their stops belong on the map.commuter. The station keeps its survey-grade NJDB position, name and importance; NJDB rows keep mode NULL everywhere else.transit_modegainedcommuter, andenumTypesSQLnow emitsALTER TYPE … ADD VALUE IF NOT EXISTSso a live database built by an older config migrates instead of needing a rebuild.Style: the icons
station_icons(z11+) draws sprite["get","mode"]— the sprite names equal the enum labels, so metro→rail-metro (Maki, CC0) and commuter→a hand-drawn J in the same box style (commuter-j.svg).station_dotskeeps tram + heavy-rail dots at all zooms; metro/commuter show as dots below z11 and badges above.icon-allow-overlap+icon-ignore-placement): a badge is a point mark and must not vanish because a road name sits near it. Without the exemption the icon collided with its own label in the neighbouring symbol layer and silently never drew — caught by driving Chrome over CDP, where the icons layer rendered 0 features until the exemption was added.Verification
{metro,tram,commuter}; z141595 NULL / 309 tram / 100 metro / 55 commuter, total 2059 — unchanged, so no station was lost or doubled.Sweden's stations layer now tags the Stockholm pendeltåg and the style draws badge icons instead of plain dots from z11. Data — how the J got its stations: - NJDB cannot tell pendeltåg from mainline stations; the GTFS feed can: SL's pendeltåg (40/41/43/48) is route_type 106 under agency 505000000000000001. readGTFSStops gained a per-route_type agency filter (agencies: {106: [SL]}) because 106 is shared with a dozen other operators and 900 (tram) is shared with Västtrafik/Östgötatrafiken/ Skånetrafiken, whose stops must stay. - The stations layer became one query over two staging tables (NJDB now loads into pg like Poland's feeds): each of the 55 pendeltåg stops claims its nearest NJDB station (<=500 m) as commuter, keeping the survey-grade NJDB position, name and importance. One-claim-per-stop, not a radius: the feeds' positions differ by up to ~220 m, and a radius also tagged the neighbouring twin (Uppsala C's freight yard at 92 m). - transit_mode gained commuter, and enumTypesSQL now emits ALTER TYPE ... ADD VALUE IF NOT EXISTS so live DBs migrate instead of needing a rebuild. Style: - station_icons (z11+) draws sprite [get, mode]; sprite names equal the enum labels: metro -> rail-metro (Maki, CC0), commuter -> a hand-drawn J in the same box style (commuter-j.svg). - station_dots keeps tram + heavy-rail dots at all zooms; metro/commuter show as dots below z11 and badges above. - Icons are placement-exempt: a badge must not vanish because a road name sits near it. Without the exemption the icon collided with its own label in the neighbouring symbol layer and silently never drew (found by driving Chrome over CDP: 0 rendered icons until it was added). Verified against the live DB: enum {metro,tram,commuter}; z14 1595 NULL / 309 tram / 100 metro / 55 commuter, total 2059 unchanged (nothing lost or doubled); Stockholm z12-z14 tiles carry the modes; Chrome-over-CDP shows 23 badges at z13 and 10 at z14 T-Centralen with zero console errors.Reviewed at
629121a, against the live database and a running server rather than by reading alone.The feature works end to end. What I verified:
/sprite.jsoncarriesmetroandcommuterat 15×15.tramis absent by design: thestation_iconsfilter passes only metro/commuter and tram keeps the dot treatment./tiles/map/12/2253/1204(Stockholm) decodes tostationswith mode metro 20, tram 13, commuter 4, null 9.importanceis preserved, not flattened — commuter stations come out 25 at importance 2 and 30 at 3, so a pendeltåg stop replacing a z9 mainline station does not drop to z11, exactly as the config comment promises.(name, geom), 0 stations sharing a point.style.jsonvalid, 64 layers, and thestation_dots→ icon handover at z11 reads correctly.1. Two commuter stops can claim the same NJDB station, duplicating it
In
conf/semap.yaml, theclaimedCTE:DISTINCT ON (c.geom)guarantees each stop claims one station. Nothing guarantees each station is claimed by at most one stop — andLEFT JOIN claimed cl ON cl.r_id = r.idthen emits that station twice.(The
DISTINCT ONis also close to vacuous as written: the LATERAL already returns at most one row per stop, so it only does anything when two stops share a geometry.)It does not bite today — 55 stops resolve to 55 distinct stations — but it is one data change away. Stockholms central already has two commuter stops within 500 m: Stockholm Centralstation at 435 m and Stockholm City at 383 m. The double-claim is avoided only because Stockholm City's nearest is its own NJDB station at 173 m.
The layer comment reasons carefully about one stop matching many stations — it names Uppsala C's freight yard, Södertälje syd's second platform, Stockholm C vs City — but not many stops matching one station, which is the direction that multiplies rows.
Since
claimedis used purely as an existence test, the fix is one word:Same 55 rows on current data, immune to the case above.
2. The enum-migration comment gives a false reason
importer/main.gosays "PG 12+ allows the new value to be used later in the same transaction, so a fresh import just works on a migrated database."Tested against the live server (PG 18.4):
The code is correct — the DDL runs as its own
dest.Execatmain.go:172, well before any layer insert, so it commits first. But the comment documents why it is safe and the stated reason is the opposite of true. Anyone later folding that Exec into a transaction with the data load would trust it and get a hard failure on exactly the migration path this was added for.Suggested rewording: the ALTER runs in its own statement, well before any layer uses the label, because PG will not let a new enum value be used in the transaction that added it.
Worth noting the
DOblock wrapper itself is fine on PG 18 — that was the part I expected to be a problem and it is not.3. The agency filter fails silently, and has no test
importer/has six test files and none touches GTFS. The newagencies:filter decides which operator's commuter stops appear, and its only guard is:routeModeaccumulates across all wanted route types. So if agency505000000000000001stops matching — feed change, ID change, aroutes.txtwith noagency_idcolumn — route_type 106 contributes nothing, 401 and 900 still fill the map, no error fires, and commuter stations quietly vanish. Given the ID is an opaque 18-digit literal in the config, I would make the emptiness check per route type rather than over the union, and add one test over a small synthetic feed.Smaller notes
mapsrv/icons/commuter-j.svghas no stated license. The README documents provenance ("drawn to match rail-metro — Maki has no J") but every other icon there is CC0-from-Maki, so a hand-drawn addition in that list is ambiguous about its terms.TestDumpGlyphPNGfailure — it writes to/tmpand only fails in my sandbox, onmainas well as here.Recommendation
1 and 2 are small enough to fix on the branch now — one word and one comment. 3 is better as a follow-up issue than a blocker.
629121aed8to211133fc2eReview addressed — all three findings fixed on the branch (
211133f, pushed; force-with-lease because the branch was rebased onto main-with-#49 before the review):Duplicate claims —
claimednow usesSELECT DISTINCT r.idinstead ofDISTINCT ON (c.geom). You were right on both counts: the LATERAL already returns one row per stop (so DISTINCT ON was vacuous), and two stops can pick the same station — Stockholm Centralstation and Stockholm City are both within 500 m of the NJDB Stockholm C point. Re-imported: still exactly 55 commuter / 1595 NULL / 309 tram / 100 metro, and 0 duplicate (name, geom), matching your measurement.Enum comment — rewritten. The ALTER runs as its own statement before any insert, which is the load-bearing fact; PG refuses to use a new enum value in the transaction that added it. Thank you for actually testing that against 18.4 — I'd stated the opposite.
Agency filter silence — fixed on the branch rather than filed: a route_type narrowed by an
agencies:filter must match at least one route or the load errors (route_type 106 with agencies [...] matched no routes), so a feed change or a mistyped id can no longer quietly drop a whole mode. Also added the first GTFS tests (importer/gtfs_test.go, synthetic feed): agency filter, per-route-type mismatch, no-routes error, and the metro-wins parent collapse.Smaller notes:
Closes #25; the issue stays open for the wider 'any city' ask, with #24 as the next instance.Verified:
go testgreen (importer incl. new tests, mapsrv),make build, stations layer re-imported against the live DB.