|
1 | 1 | import * as duckdb from '@duckdb/duckdb-wasm' |
2 | | -import type { Departure, Stop, TripDetails, TripStop } from '@/api/gtfs/types.ts' |
| 2 | +import type { Departure, ShapePoint, Stop, TripDetails, TripStop } from '@/api/gtfs/types.ts' |
3 | 3 |
|
4 | 4 | const GTFS_FILES = ['stops', 'routes', 'trips', 'stop_times'] as const |
5 | 5 |
|
6 | 6 | export class Service { |
7 | 7 | private db: duckdb.AsyncDuckDB | null = null |
8 | 8 | private conn: duckdb.AsyncDuckDBConnection | null = null |
9 | 9 | private readyPromise: Promise<void> | null = null |
| 10 | + private shapeCache = new Map<string, ShapePoint[]>() |
10 | 11 |
|
11 | 12 | /** |
12 | 13 | * @param baseUrl URL (absolute or root-relative) of the directory holding |
@@ -171,6 +172,122 @@ export class Service { |
171 | 172 |
|
172 | 173 | return { ...meta[0]!!, stops } |
173 | 174 | } |
| 175 | + /** |
| 176 | + * Ordered polyline for a trip. Shapes live at `${baseUrl}/shapes/<shape_id>.txt` |
| 177 | + * and are fetched + cached on first use. |
| 178 | + * |
| 179 | + * Pass `fromStopId` / `toStopId` to clip the shape to the segment between two |
| 180 | + * stops, using GTFS `shape_dist_traveled`. If the data lacks that column, the |
| 181 | + * full shape is returned regardless of the stop arguments. |
| 182 | + */ |
| 183 | + async getTripShape( |
| 184 | + tripId: string, |
| 185 | + fromStopId?: string, |
| 186 | + toStopId?: string, |
| 187 | + ): Promise<ShapePoint[]> { |
| 188 | + await this.init() |
| 189 | + |
| 190 | + // trip -> shape_id |
| 191 | + const shapeRows = await this.prepareAndQuery<{ shape_id: string }>( |
| 192 | + `SELECT shape_id FROM trips WHERE trip_id = ? LIMIT 1`, |
| 193 | + [tripId], |
| 194 | + ) |
| 195 | + const shapeId = shapeRows[0]?.shape_id |
| 196 | + if (!shapeId) return [] |
| 197 | + |
| 198 | + const points = await this.loadShape(shapeId) |
| 199 | + if (!fromStopId && !toStopId) return points |
| 200 | + |
| 201 | + const fromDist = fromStopId ? await this.stopDistance(tripId, fromStopId) : null |
| 202 | + const toDist = toStopId ? await this.stopDistance(tripId, toStopId) : null |
| 203 | + |
| 204 | + // Can't clip without distances on both the stops and the shape points. |
| 205 | + if (fromDist === null && toDist === null) return points |
| 206 | + if (points.some((p) => p.dist_traveled === null)) return points |
| 207 | + |
| 208 | + let lo = fromDist |
| 209 | + let hi = toDist |
| 210 | + if (lo !== null && hi !== null && lo > hi) [lo, hi] = [hi, lo] |
| 211 | + |
| 212 | + return points.filter((p) => { |
| 213 | + const d = p.dist_traveled as number |
| 214 | + if (lo !== null && d < lo) return false |
| 215 | + if (hi !== null && d > hi) return false |
| 216 | + return true |
| 217 | + }) |
| 218 | + } |
| 219 | + |
| 220 | + /** shape_dist_traveled of a stop on a trip (first occurrence). Null if absent. */ |
| 221 | + private async stopDistance(tripId: string, stopId: string): Promise<number | null> { |
| 222 | + const rows = await this.prepareAndQuery<{ dist: number | null }>( |
| 223 | + ` |
| 224 | + SELECT TRY_CAST(shape_dist_traveled AS DOUBLE) AS dist |
| 225 | + FROM stop_times |
| 226 | + WHERE trip_id = ? AND stop_id = ? |
| 227 | + ORDER BY CAST(stop_sequence AS INTEGER) |
| 228 | + LIMIT 1 |
| 229 | + `, |
| 230 | + [tripId, stopId], |
| 231 | + ) |
| 232 | + return rows[0]?.dist ?? null |
| 233 | + } |
| 234 | + |
| 235 | + /** Fetches + parses + caches a single shape file. */ |
| 236 | + private async loadShape(shapeId: string): Promise<ShapePoint[]> { |
| 237 | + const cached = this.shapeCache.get(shapeId) |
| 238 | + if (cached) return cached |
| 239 | + |
| 240 | + const base = this.baseUrl.replace(/\/$/, '') |
| 241 | + const url = `${base}/shapes/${shapeId}.txt` |
| 242 | + const res = await fetch(url) |
| 243 | + if (!res.ok) { |
| 244 | + throw new Error(`GtfsService: GET ${url} returned ${res.status}.`) |
| 245 | + } |
| 246 | + const text = await res.text() |
| 247 | + if (text.trimStart().toLowerCase().startsWith('<!doctype')) { |
| 248 | + throw new Error(`GtfsService: ${url} returned HTML, not CSV (shape file missing?).`) |
| 249 | + } |
| 250 | + |
| 251 | + const points = this.parseShapeCsv(text) |
| 252 | + this.shapeCache.set(shapeId, points) |
| 253 | + return points |
| 254 | + } |
| 255 | + |
| 256 | + /** Minimal CSV parse for a GTFS shapes file (numeric fields, no quoting). */ |
| 257 | + private parseShapeCsv(text: string): ShapePoint[] { |
| 258 | + const lines = text.split(/\r?\n/).filter((l) => l.trim() !== '') |
| 259 | + if (lines.length === 0) return [] |
| 260 | + |
| 261 | + const header = lines[0]!! |
| 262 | + .replace(/^\uFEFF/, '') |
| 263 | + .split(',') |
| 264 | + .map((h) => h.trim()) |
| 265 | + const iLat = header.indexOf('shape_pt_lat') |
| 266 | + const iLon = header.indexOf('shape_pt_lon') |
| 267 | + const iSeq = header.indexOf('shape_pt_sequence') |
| 268 | + const iDist = header.indexOf('shape_dist_traveled') |
| 269 | + |
| 270 | + const points: ShapePoint[] = [] |
| 271 | + for (let i = 1; i < lines.length; i++) { |
| 272 | + const cols = lines[i]!!.split(',') |
| 273 | + const lat = Number(cols[iLat]) |
| 274 | + const lon = Number(cols[iLon]) |
| 275 | + if (Number.isNaN(lat) || Number.isNaN(lon)) continue |
| 276 | + |
| 277 | + let dist: number | null = null |
| 278 | + if (iDist >= 0) { |
| 279 | + const raw = cols[iDist]?.trim() |
| 280 | + if (raw) { |
| 281 | + const n = Number(raw) |
| 282 | + dist = Number.isNaN(n) ? null : n |
| 283 | + } |
| 284 | + } |
| 285 | + points.push({ lat, lon, sequence: Number(cols[iSeq]), dist_traveled: dist }) |
| 286 | + } |
| 287 | + |
| 288 | + points.sort((a, b) => a.sequence - b.sequence) |
| 289 | + return points |
| 290 | + } |
174 | 291 |
|
175 | 292 | // -------------------------------------------------------------------------- |
176 | 293 | // Internals |
|
0 commit comments