Map Data Loading and Rendering
The application efficiently loads, filters, and renders stop markers on the interactive map using spatial queries, caching, and marker diffing.
Data Pipeline
| Step | Component | Purpose |
|---|---|---|
| 1 | PostGIS | Spatial index queries via GiST |
| 2 | API | /api/data with viewport bounds |
| 3 | Cache | Avoid redundant requests |
| 4 | Diff | Update only changed markers |
PostGIS Spatial Query
SELECT * FROM stops_matched
WHERE ST_Intersects(
geom,
ST_MakeEnvelope(min_lon, min_lat, max_lon, max_lat, 4326)
)
The real /api/data query also includes a second branch for matched rows whose OSM point is inside the viewport even when the ATLAS-side display geometry is outside it. The geom column has a GiST spatial index for the primary lookup path.
Zoom Level Modes
| Zoom | Mode | Behavior |
|---|---|---|
| < 13 | Overview / Low Zoom | Unmatched ATLAS only (if no filters) or capped filtered results |
| 13–14 | Capped / Mid Zoom | All types (or filters), limited to a capped count to maintain performance |
| ≥ 15 | Full / Uncapped Zoom | All filtered viewport rows rendered without an artificial result cap |
Zoom Banner Policy
The application intelligently restricts the number of rendered markers to maintain performance, and communicates this state via the map's zoom banner.
- High Zoom (≥ 15): The banner is hidden. The data isn't artificially capped.
- Mid-Zoom (13-14): Results are capped (currently 1,800 rows). If filters are inactive, the banner advises the user to zoom in. If filters are active, it indicates that results are capped (for example,
Showing first 1800 filtered results...). If the real query size naturally drops below the cap, the banner is hidden. - Low Zoom (< 13): By default, the map runs an "Overview mode" displaying only unmatched ATLAS components to save resources. The banner indicates this mode. If explicit active filters are applied, the map respects the filter instead of Overview mode, applies the same cap, and updates the banner accordingly.
- Top N Filters: Skip full viewport loading and hide the zoom banner, since the data slice is loaded through the targeted Top N overlay path.
Frontend Caching
Cache reuse logic:
- Same zoom + viewport remains inside the buffered request bounds → Reuse
- Zoom in + previous response was not capped + viewport remains inside the buffered request bounds → Reuse
- Zoom out, a capped zoom change, or movement beyond the buffered request bounds → Fetch new data
- Filters changed → Fetch new data
Implementation details:
MapViewportLoaderstores the last response data, visible bounds, buffered request bounds, zoom, and request identity- the request identity is built from the viewport policy's non-bounding-box parameters plus its mode; this includes
offset,zoom,include_meta, and any activelimit - bounding-box coordinates are kept separately in the buffered request bounds and are not part of the identity
- capped state is derived from the cached response metadata when deciding whether a zoom-in reuse is safe
- the cache is invalidated explicitly on filter changes via
invalidateViewportCache()
Cache ownership boundaries:
- this cache applies only to
/api/dataviewport payload reuse in the browser /api/global_statsis requested independently by the header summary and is not served from viewport cache- both request paths are driven by the same active filter semantics, so viewport data and summary counts stay logically aligned
Marker Diffing
Instead of recreating all markers:
- Track markers by key (
atlas-{sloid}orosm-{node_id}) - Existing: Update position with
setLatLng() - New: Create and add to map
- Stale: Remove from map
Benefits: No flickering, smooth transitions, efficient DOM operations
Canvas vs DOM Markers
| Zoom | Marker Type | Implementation |
|---|---|---|
| < 18 | L.circleMarker |
Lightweight circle rendering |
| ≥ 18 | L.marker for labeled D/P/S markers; L.circleMarker otherwise |
Label-capable categories switch to DOM icons, while ordinary circles keep the same circle marker implementation |
At zoom 18, markers are recreated so duplicate ATLAS markers and selected OSM marker types can switch to their labeled icon form.
Labeled Marker Policy (D / P / S)
When marker rendering is in DOM mode (zoom >= 18), selected marker categories render a lettered icon:
| Side | Condition | Label |
|---|---|---|
| ATLAS | API has_atlas_duplicate flag is set from AtlasStop.duplicate_group_sloids |
D |
| OSM | osm_node_type = platform |
P |
| OSM | osm_node_type = railway_station |
S |
All other marker categories keep the standard circle style.
Marker Colors
| Type | Color |
|---|---|
| Matched ATLAS | Navy (#174092) |
| Matched OSM | Green (#4CAF50) |
| Unmatched ATLAS | Red (#DC3545) |
| Unmatched OSM | Gray (#6C757D) |
Line Rendering Thresholds
| Line Type | Min Zoom |
|---|---|
| ATLAS↔OSM matched connection lines | 13 |
| OSM group / trio connector lines | 17 |
Overlap Handling
The MarkerClusterManager handles stops at identical or near-identical coordinates. When a Leaflet map projection is available, it projects coordinates at a stable reference zoom, groups nearby points using a spatial grid, and offsets each group in pixel space:
const point = map.project([lat, lon], referenceZoom);
const cellX = Math.floor(point.x / overlapDistance);
const cellY = Math.floor(point.y / overlapDistance);
const angle = (2 * Math.PI * index) / groupSize;
const displayPoint = {
x: point.x + offsetRadius * Math.cos(angle),
y: point.y + offsetRadius * Math.sin(angle)
};
const displayLatLng = map.unproject(displayPoint, referenceZoom);
Entries are sorted by entity type and stable key before layout, which keeps displacement deterministic. The configured coordinate tolerance (0.00001) is only the fallback grouping threshold when map projection is unavailable. At lower zooms, subpixel offsets are skipped because they would not be visible.
Map Layers
The interactive map provides 3 layer options.
| Layer | URL |
|---|---|
| OpenStreetMap | tile.openstreetmap.org |
| Transport | tile.memomaps.de |
| Satellite | arcgisonline.com |
Code Reference
| File | Purpose |
|---|---|
| backend/blueprints/data.py | API endpoint |
| static/js/pages/main.js | Map initialization |
| static/js/components/map-renderer.js | Marker rendering |
| matching_and_import_db/database/importer.py | Geometry import |
Related Documentation
- 6. Web app – Application overview
- 5. Database – Database schema