Skip to content

Commit 67e08e8

Browse files
chtituxclaude
andcommitted
Release v0.7.0
Add buildGraph() for building a directed stop-to-stop graph from a set of trips. Edges are deduplicated; each edge carries the originating trips with route_id and direction_id. Uses LEAD() so non-contiguous stop_sequence values are handled correctly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 67579dc commit 67e08e8

6 files changed

Lines changed: 391 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Upcoming release
44

5+
## 0.7.0
6+
7+
- Add `buildGraph(tripIds)` method and `Graph` / `EdgeTrip` / `EdgeData` types. Builds a directed stop-to-stop graph from the given trips, with each deduplicated edge carrying the list of originating trips (plus `route_id` and `direction_id`). Handles non-contiguous `stop_sequence` values via `LEAD()`. Helpers `edgeCount()` and `edges()` are also exported.
8+
59
## 0.6.0
610

711
### Breaking changes — pluggable database adapter

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "gtfs-sqljs",
3-
"version": "0.6.0",
3+
"version": "0.7.0",
44
"description": "Load GTFS data into a SQLite database (sql.js / better-sqlite3 / op-sqlite / expo-sqlite) via a pluggable adapter",
55
"type": "module",
66
"main": "./dist/index.js",

src/gtfs-sqljs.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
} from './queries/calendar';
2929
import { getTrips, type TripFilters, type TripWithRealtime } from './queries/trips';
3030
import { getStopTimes, buildOrderedStopList, type StopTimeFilters, type StopTimeWithRealtime } from './queries/stop-times';
31+
import { buildGraph, type Graph } from './queries/graph';
3132
import { getShapes, getShapesToGeojson, type ShapeFilters, type GeoJsonFeatureCollection } from './queries/shapes';
3233
import { getAlerts as getAlertsQuery, getAllAlerts, type AlertFilters } from './queries/rt-alerts';
3334
import { getVehiclePositions as getVehiclePositionsQuery, getAllVehiclePositions, type VehiclePositionFilters } from './queries/rt-vehicle-positions';
@@ -766,6 +767,18 @@ export class GtfsSqlJs {
766767
return buildOrderedStopList(this.db, tripIds);
767768
}
768769

770+
/**
771+
* Build a directed stop-to-stop graph for the given trips.
772+
*
773+
* Edges connect consecutive stops in a trip (paired via `stop_sequence`,
774+
* whose values need not be contiguous). Each edge carries the list of
775+
* trips traversing it, with route_id and direction_id attached.
776+
*/
777+
async buildGraph(tripIds: string[]): Promise<Graph> {
778+
if (!this.db) throw new Error('Database not initialized');
779+
return buildGraph(this.db, tripIds);
780+
}
781+
769782
// ==================== Realtime Methods ====================
770783

771784
/**

src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ export type {
3636
} from './adapters/types';
3737
export { ExportNotSupportedError } from './adapters/types';
3838

39+
// Export graph types
40+
export type {
41+
EdgeTrip,
42+
EdgeData,
43+
Graph,
44+
} from './queries/graph';
45+
export { edgeCount, edges } from './queries/graph';
46+
3947
// Export GTFS types
4048
export type {
4149
Agency,

src/queries/graph.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* Graph query methods
3+
*
4+
* Build a directed graph from GTFS trips: nodes are stops, edges connect
5+
* consecutive stops within a trip (ordered by `stop_sequence`, which is only
6+
* guaranteed monotonic — gaps are allowed). Edges are deduplicated; each
7+
* edge carries the list of trips that traverse it, so callers can recover
8+
* frequency, route, and direction information without a second query.
9+
*/
10+
11+
import type { GtfsDatabase } from '../adapters/types';
12+
13+
export interface EdgeTrip {
14+
tripId: string;
15+
routeId: string;
16+
directionId: number | null;
17+
}
18+
19+
export interface EdgeData {
20+
trips: EdgeTrip[];
21+
}
22+
23+
/** Directed graph: from stop_id -> to stop_id -> edge data. */
24+
export type Graph = Map<string, Map<string, EdgeData>>;
25+
26+
/**
27+
* Build a directed stop-to-stop graph for the given trips.
28+
*
29+
* - Uses `LEAD()` to pair each stop with its successor in `stop_sequence`
30+
* order, so non-contiguous sequences (e.g. 1, 5, 10) are handled correctly.
31+
* - Edges are deduplicated on `(from, to)`; per-edge `trips[]` preserves the
32+
* originating trip/route/direction.
33+
* - A trip that traverses the same edge twice (e.g. a loop) contributes two
34+
* entries in `trips[]`.
35+
* - An empty `tripIds` array returns an empty graph without touching the DB.
36+
*/
37+
export async function buildGraph(
38+
db: GtfsDatabase,
39+
tripIds: string[]
40+
): Promise<Graph> {
41+
const graph: Graph = new Map();
42+
if (tripIds.length === 0) return graph;
43+
44+
const placeholders = tripIds.map(() => '?').join(', ');
45+
const sql = `
46+
WITH ordered AS (
47+
SELECT st.trip_id,
48+
st.stop_id,
49+
LEAD(st.stop_id) OVER (
50+
PARTITION BY st.trip_id ORDER BY st.stop_sequence
51+
) AS next_stop,
52+
t.route_id,
53+
t.direction_id
54+
FROM stop_times st
55+
INNER JOIN trips t ON st.trip_id = t.trip_id
56+
WHERE st.trip_id IN (${placeholders})
57+
)
58+
SELECT stop_id AS from_stop,
59+
next_stop AS to_stop,
60+
trip_id,
61+
route_id,
62+
direction_id
63+
FROM ordered
64+
WHERE next_stop IS NOT NULL
65+
`;
66+
67+
const stmt = await db.prepare(sql);
68+
await stmt.bind(tripIds);
69+
70+
while (await stmt.step()) {
71+
const row = await stmt.getAsObject();
72+
const from = String(row.from_stop);
73+
const to = String(row.to_stop);
74+
const edgeTrip: EdgeTrip = {
75+
tripId: String(row.trip_id),
76+
routeId: String(row.route_id),
77+
directionId: row.direction_id !== null ? Number(row.direction_id) : null,
78+
};
79+
80+
let inner = graph.get(from);
81+
if (!inner) {
82+
inner = new Map();
83+
graph.set(from, inner);
84+
}
85+
let edge = inner.get(to);
86+
if (!edge) {
87+
edge = { trips: [] };
88+
inner.set(to, edge);
89+
}
90+
edge.trips.push(edgeTrip);
91+
}
92+
93+
await stmt.free();
94+
return graph;
95+
}
96+
97+
/** Number of distinct directed edges in the graph. */
98+
export function edgeCount(graph: Graph): number {
99+
let n = 0;
100+
for (const inner of graph.values()) n += inner.size;
101+
return n;
102+
}
103+
104+
/** Iterate all edges as `{ from, to, data }` tuples. */
105+
export function* edges(
106+
graph: Graph
107+
): Generator<{ from: string; to: string; data: EdgeData }> {
108+
for (const [from, inner] of graph) {
109+
for (const [to, data] of inner) {
110+
yield { from, to, data };
111+
}
112+
}
113+
}

0 commit comments

Comments
 (0)