basic shapes.txt with brouter #120
Labels
No labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
gtfs.zone/coloring-book#120
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Summary
Add shape editing support by integrating with brouter-web as an external router. Users open the ShapesManager (new nav bar button), see all shapes in their feed, can delete them or replace them by uploading a GPX exported from brouter. The timetable view gains a brouter deep-link (all stops as waypoints, profile auto-detected from route_type) and the existing shape_id property row per trip becomes a
<select>dropdown populated with all shape IDs in the database.No custom routing or geometry drawing is built into the editor — brouter handles that externally.
Relevant context
Existing utilities to reuse — do not reinvent:
showModal(options)insrc/modules/modal-utils.ts— the only way to show modals in this app. Takes{ title, body, actions, onMount, escapeAction, enterAction, boxClassName }. TheonMount(close)callback runs after the modal is in the DOM and is where event listeners and sub-modals are wired up. Returns aPromise<void>. No<dialog>elements needed in index.html.patchUpdate(db, pm, table, id, before, after)insrc/utils/patch-utils.ts— the correct helper for all user-initiated updates. Callsdb.updateRow()thenpm.recordUpdate()atomically. Use this instead of calling patchManager directly.renderFormFields(configs)/readFormValues()insrc/utils/field-component.ts— auto-generate form HTML from FieldConfig arrays; used by fares-modal for all add/edit sub-modals. Reuse for the "new shape" name input form.showFormError()— shows validation errors inline; returntruefrom anonClickhandler to keep the modal open.data-actionattributes. Use the same pattern in the shapes manager.showModal()from inside anonMounthandler of a parent modal (see fares-modal.ts).shapes DB:
src/modules/gtfs-database.ts—shapestable; rows are individual points keyed by compositeshape_id:shape_pt_sequence. To list unique shapes: query all rows and deduplicate byshape_id. Delete a shape: delete all rows whereshape_idmatches.invalidateShape(shapeId)insrc/modules/route-renderer.ts— must be called after any shape insert/delete to update the map.Timetable:
src/modules/timetable-renderer.ts—renderPropertyCell()(lines 267–329) renders trip property inputs. Dispatches onconfig.type:'select'→<select>,'number'→<input type="number">, default →<input type="text">. Theshape_idfield currently falls through to text input. Need a special branch forconfig.field === 'shape_id'to render a<select>with available shape IDs.updateTripProperty(trip_id, field, value)insrc/modules/schedule-controller.ts— already handles the onchange logic including patchManager; reuse as-is for the shape_id select's onchange handler.renderScheduleHeader()intimetable-renderer.ts— the right place to add the brouter link; already receives route entity (hasroute_type) and stop data is inTimetableData.stops.https://brouter.de/brouter-web/#map={zoom}/{lat}/{lon}/standard&lonlats={lon1},{lat1};{lon2},{lat2}&profile={profile}(brouter uses lon,lat order in lonlats).Nav bar:
<button>in the navbar-end ofsrc/index.html; click handler wired insrc/index.ts.Phase 1: GPX parser utility
Create a utility to parse a GPX file into
shapes.txtrows using the browser's nativeDOMParser(no new dependencies).src/utils/gpx-parser.tsparseGPX(file: File, shapeId: string): Promise<Shapes[]>DOMParserto parse the file text as XML<trkpt lat="..." lon="...">elements in document order across all<trk>/<trkseg>elementsShapes[]withshape_id,shape_pt_lat(number),shape_pt_lon(number),shape_pt_sequence(1-indexed)Errorif no track points are foundGotcha:
shape_pt_lat/shape_pt_loncome from GPXlat/lonattributes (lat/lon order), but the brouterlonlatsparam is lon,lat — these are different concerns; the parser just reads lat/lon from GPX attributes correctly.Phase 2: ShapesManager module
New module that uses
showModal()from modal-utils.ts. No new<dialog>elements in index.html — everything is rendered dynamically.Create
src/modules/shapes-manager.tsgtfsParser: GTFSParser,patchManager: PatchManager(no routeRenderer needed — map invalidation is automatic via the patch system'smapController.handlePatchChange)ShapesManagerclass with a single public method:open(): Promise<void>open()implementation:shape_idto getMap<string, number>(shape_id → point count)showModal({ title: 'Shapes', body: ..., escapeAction: 0, onMount, actions: [{ label: 'Close', ... }] })bodyrenders a DaisyUI table: columns — Shape ID | Points | Actions. Each row hasdata-shape-idand buttons withdata-action="replace"/data-action="delete".data-action="new".onMount, wire a single click listener on#shapes-panelusing event delegationDelete action (inside onMount handler):
showModalsub-modal ("Delete shape X and all its N points?") — Delete/Cancelshape_id, delete viadeleteRows()+recordBatchDelete(), map updates automaticallyReplace action (inside onMount handler):
pickGPXFile()helper creates a temporary<input type="file">element (not in DOM), triggers.click(), handleschange/cancel/window-focus eventsparseGPX(file, shapeId), delete old rows viadeleteRows()+recordBatchDelete(), insert new viainsertRows()+recordBatchInsert(), re-renderNew shape action (inside onMount handler):
Add shapes nav bar button in
src/index.htmlnavbar-end (before fares button) with a route/map SVG iconIn
src/index.ts:ShapesManager(injectgtfsParser,patchManager)shapesManager.open()Discovery:
routeRendereris not needed in ShapesManager — the patch system already handles shape invalidation automatically inMapController.handlePatchChange. The batch patch methods (recordBatchDelete,recordBatchInsert) are the correct API for multi-row shape operations, producing a single undoable batch entry.Phase 3: Brouter link in the timetable
Add a brouter deep-link to the timetable schedule header. The link opens brouter-web pre-populated with all stops as waypoints.
Add
getBrouterProfile(routeType: string | number): stringas a module-level function insrc/modules/timetable-renderer.ts:0(tram),1(metro),2(rail),12(monorail) →'rail'3(bus),11(trolleybus) →'car-fast'4(ferry) →'river''car-fast'In
renderDirectionTabs()intimetable-renderer.ts(notrenderScheduleHeader()— that method is unused; the direction tabs section is the actual rendered header):buildBrouterUrl(data: TimetableData): string | nullhelper (module-level) that filters stops with valid lat/lon, buildslonlats, averages coords for map center, callsgetBrouterProfile<a href="{url}" target="_blank" rel="noopener" class="btn btn-xs btn-outline ml-auto">Open in brouter ↗</a>in the tabs row when ≥2 stops have coordinatesGotcha: Stops are stored as strings in GTFS — parse as float before averaging and formatting to avoid precision issues.
Discovery:
renderScheduleHeader()is defined but never called — the direction tabs div is the actual visible header. The brouter link was added there instead, usingml-autoto push it to the right of the tabs.Phase 4: shape_id dropdown in the timetable
The shape_id row in trip property columns currently renders as a plain text input. Make it a
<select>populated from the DB.In
TimetableRenderer, addavailableShapeIds: string[] = []as an instance field.In
schedule-controller.ts, before callingrenderTimetableHTML(), load shape IDs:queryRows('shapes', {}), deduplicate byshape_id, sort, and store onthis.renderer.availableShapeIds.In
renderPropertyCell(), added a branch before the existing type dispatch forconfig.field === 'shape_id':<select>with— none —first option + allavailableShapeIdsshape_idas selectedonchangeattribute pattern as the existing select branchGotcha: Loading shape IDs is async. Ensure
availableShapeIdsis populated beforerenderTimetableHTML()is called — since the timetable rendering chain is already async,awaitthe query there.Phase 5: UI polish — per-trip brouter link, upload icon, click-away modal
Three small but related UI improvements.
Per-trip brouter link (move from direction tabs to each trip header column):
buildBrouterUrlintimetable-renderer.tsto accept(stops: Stops[], routeType: string | number): string | nullinstead of(data: TimetableData).renderDirectionTabs(), remove thebrouterUrl/brouterLinkblock and theml-autolink entirely.renderTimetableHeader(), for each trip computetripStops = data.stops.filter((_, i) => trip.stopTimes.has(i))(supersequence indexipresent intrip.stopTimesmeans this trip visits that stop). PasstripStopsanddata.route.route_typeto the refactoredbuildBrouterUrl.<td>, below the trip ID and delete button, as<a href="{url}" target="_blank" rel="noopener" class="btn btn-xs btn-outline mt-1" title="Open in brouter">↗</a>(icon-only to keep the header compact).Gotcha:
trip.stopTimeskeys are supersequence positions (numbers), same as the index indata.stops. A stop is only included if the trip actually has a time at that position. Trips with fewer than 2 geocoded stops get no link.Upload icon for Replace button (
shapes-manager.ts):renderUploadIcon(sizeClass = 'h-4 w-4'): stringtomodal-utils.ts, exporting an upload/arrow-up-tray SVG from heroicons inline (same pattern asrenderTrashIcon).renderUploadIconinshapes-manager.ts. Replace the↑ Replacetext button with an icon-only button:<button class="btn btn-xs btn-ghost" data-action="replace" data-shape-id="…" title="Replace with GPX">${renderUploadIcon()}</button>.Click-away to close modals (
modal-utils.ts):escapeActionis defined, add a'click'listener on the.modaldiv (the backdrop element). In the handler, checke.target === modal(click landed on the backdrop, not inside the box) and callvoid triggerAction(options.escapeAction!). This fixes all modals that useshowModalincluding the fares modal.Gotcha: Must guard with
e.target === modal— not!modal-box.contains(target)— because clicks on scrollbar tracks can also satisfy that check. The simplest reliable test is pointer on the outermost element.Phase 6: Bug fix — shape not re-rendered after GPX replace
Root cause:
replaceShape()does a delete then an insert as two separate patch records. The delete firesinvalidateShape(shapeId, 'delete')→handleShapeRemoved(shapeId), which removes the shape's features fromrouteFeaturesand reassigns affected trips to stop-sequence geometry. The subsequent insert firesinvalidateShape(shapeId, 'insert'), updatesshapeIndex, but finds no features inrouteFeaturesusing::shape:${shapeId}(they were removed), so the trips remain on stop-sequence geometry until a full page reload.Fix (in
route-renderer.ts,invalidateShape):this.shapeIndex.set(shape_id, newCoords)and updating existing features in-place, count how many features were actually updated. If zero features matched the geomKey (i.e., the shape was deleted earlier and feature entries were removed byhandleShapeRemoved), find all trips in the in-memory trips data that reference thisshape_idand callthis.invalidateTrip(trip.trip_id, 'insert', null, shape_id)for each. This re-creates the shape-based features for those trips.Specifically:
let anyUpdated = falsein thepts.length >= 2branch. SetanyUpdated = trueinside the loop overrouteFeatureswhen a matching feature is found.!anyUpdated: readthis.gtfsParser.getFileDataSyncTyped<Trips>('trips.txt'), filter to trips wheretrip.shape_id === shape_id, and callthis.invalidateTrip(trip.trip_id, 'insert', null, shape_id)for each.console.lognoting the re-assignment (e.g.[RouteRenderer] invalidateShape: no existing features for shape ${shape_id}, re-assigning ${n} trips).Implemented:
invalidateShapenow tracksanyUpdatedin thepts.length >= 2branch. When no features matched (post-delete scenario), it reads trips.txt, filters to trips referencing the shape, and callsinvalidateTrip('insert')for each.invalidateTripcallsaddTripToBucketwhich uses the newly-set shapeIndex entry, so trips are re-bucketed with shape geometry without a reload.scheduleSetData()is still called once at the end ofinvalidateShape.Gotcha:
invalidateTripis already defined onRouteRendererand handles the'insert'case by setting up shape-based geometry whenafterShapeIdis provided. Calling it here reuses that existing logic without new paths. Do not callscheduleSetData()an extra time —invalidateTripcalls it internally, and the outerinvalidateShapealso calls it at the end (so it may fire twice; that's a no-op since it debounces).Original Issue
To support editing shapes.txt, instead of building a fancy router and editor into the tool, lets leverage brouter-web.
We can link the user to:
https://brouter.de/brouter-web/#map=8/47.294/7.855/standard&lonlats=6.383057,47.953145;8.580322,47.465236;9.673462,47.479329&profile=car-fast
or profile=rail for train routes. Here is the repo for brouter: https://github.com/abrensch/brouter
They will be able to export the route as gpx, then import it in the gtfs editor.
We will likely need a shapes manager modal and nav bar button for managing shapes, allowing some sort of filtering, as well as replacing them with uploaded gpx files and uploading new ones. In the timetable, we should change the shape id select to a dropdown and provide all possible shapes.
We should link to brouter from the timetable that includes every stop for that trip.
maxtkc referenced this issue2026-05-11 22:34:43 +00:00