Skip to content

Commit 99e5aa4

Browse files
committed
use precise shapes
1 parent 37b26d1 commit 99e5aa4

5 files changed

Lines changed: 194 additions & 48 deletions

File tree

via-ui/src/api/game/manager.ts

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,27 @@
1-
import type { Departure, Stop, TripDetails, TripStop } from '@/api/gtfs/types.ts'
1+
import type { Departure, ShapePoint, Stop, TripDetails, TripStop } from '@/api/gtfs/types.ts'
22
import { Service } from '@/api/gtfs/service.ts'
33
import { readonly, ref } from 'vue'
44

55
const START_DATE = new Date('2026-01-01T05:00:00')
66

77
export interface UserTrip {
8-
start_station: Stop,
9-
end_station: Stop,
10-
details: TripDetails,
11-
distance_before: number,
12-
distance_after: number,
8+
start_station: Stop
9+
end_station: Stop
10+
details: TripDetails
11+
distance_before: number
12+
distance_after: number
13+
shape: ShapePoint[]
1314
}
1415

1516
export interface GameState {
16-
currentTime: Date,
17-
currentStop: Stop|null,
18-
trip: UserTrip[],
19-
startStation: Stop|null,
20-
targetStation: Stop|null,
21-
departures: Departure[],
22-
chosenTrain: TripDetails|null,
23-
currentDistance: number|null,
17+
currentTime: Date
18+
currentStop: Stop | null
19+
trip: UserTrip[]
20+
startStation: Stop | null
21+
targetStation: Stop | null
22+
departures: Departure[]
23+
chosenTrain: TripDetails | null
24+
currentDistance: number | null
2425
}
2526

2627
const state = ref<GameState>({
@@ -70,32 +71,47 @@ export function useGameManager() {
7071
state.value.startStation = await service.getRandomStop()
7172
state.value.targetStation = await service.getRandomStop()
7273
state.value.currentStop = state.value.startStation
73-
state.value.departures = await service.getUpcomingDepartures(state.value.startStation.stop_id, START_DATE)
74+
state.value.departures = await service.getUpcomingDepartures(
75+
state.value.startStation.stop_id,
76+
START_DATE,
77+
)
7478
state.value.currentDistance = computeDistanceToTarget(state.value.startStation)
7579
}
7680

77-
async function chooseTrain(departure: Departure){
78-
state.value.chosenTrain = await service.getTripDetails(departure.trip_id, state.value.currentStop?.stop_id)
81+
async function chooseTrain(departure: Departure) {
82+
state.value.chosenTrain = await service.getTripDetails(
83+
departure.trip_id,
84+
state.value.currentStop?.stop_id,
85+
)
7986
console.log(state.value.chosenTrain)
8087
}
8188

82-
async function chooseStop(stop: TripStop){
89+
async function chooseStop(stop: TripStop) {
8390
const endStation = (await service.getStopDetails(stop.stop_id))!!
8491
const distanceBefore = computeDistanceToTarget(state.value.currentStop!!)
8592
const distanceAfter = computeDistanceToTarget(endStation)
93+
const shape = await service.getTripShape(
94+
state.value.chosenTrain!!.trip_id,
95+
state.value.currentStop!!.stop_id,
96+
endStation!!.stop_id,
97+
)
8698

8799
const trip: UserTrip = {
88100
start_station: state.value.currentStop!!,
89101
end_station: endStation!!,
90102
details: state.value.chosenTrain!!,
91103
distance_before: distanceBefore,
92104
distance_after: distanceAfter,
105+
shape: shape,
93106
}
94107

95108
state.value.chosenTrain = null
96109
state.value.currentStop = endStation
97110
state.value.currentTime = stringToTimeObject(stop.arrival_time)
98-
state.value.departures = await service.getUpcomingDepartures(stop.stop_id, state.value.currentTime)
111+
state.value.departures = await service.getUpcomingDepartures(
112+
stop.stop_id,
113+
state.value.currentTime,
114+
)
99115
state.value.currentDistance = distanceAfter
100116
state.value.trip.push(trip)
101117
}

via-ui/src/api/gtfs/service.ts

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
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'
33

44
const GTFS_FILES = ['stops', 'routes', 'trips', 'stop_times'] as const
55

66
export class Service {
77
private db: duckdb.AsyncDuckDB | null = null
88
private conn: duckdb.AsyncDuckDBConnection | null = null
99
private readyPromise: Promise<void> | null = null
10+
private shapeCache = new Map<string, ShapePoint[]>()
1011

1112
/**
1213
* @param baseUrl URL (absolute or root-relative) of the directory holding
@@ -171,6 +172,122 @@ export class Service {
171172

172173
return { ...meta[0]!!, stops }
173174
}
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+
}
174291

175292
// --------------------------------------------------------------------------
176293
// Internals

via-ui/src/api/gtfs/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,10 @@ export interface TripDetails {
3333
destination: string
3434
stops: TripStop[] // ordered by stop_sequence
3535
}
36+
37+
export interface ShapePoint {
38+
lat: number
39+
lon: number
40+
sequence: number
41+
dist_traveled: number | null
42+
}

via-ui/src/components/Stop.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ function stopSelected() {
1818

1919
<template>
2020
<div tabindex="0" class="collapse bg-base-100 border border-base-300 mx-2 my-1">
21-
<div class="collapse-title font-semibold"> {{stop.arrival_time}} {{stop.stop_name}}</div>
21+
<div class="collapse-title font-semibold"> {{stop.arrival_time.slice(0,5)}} {{stop.stop_name}}</div>
2222
<div class="collapse-content text-sm z-1">
2323
<button class="btn btn-soft btn-error" @click="stopSelected()">Get out</button>
2424
</div>

via-ui/src/components/SwissMap.vue

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ const cities = computed(() => {
1515
if (trips.length === 0) return []
1616
1717
const first = {
18-
name: trips[0]!!.start_station.stop_name,
19-
lat: trips[0]!!.start_station.stop_lat,
20-
lon: trips[0]!!.start_station.stop_lon,
21-
distance: trips[0]!!.distance_before,
18+
name: trips[0]!.start_station.stop_name,
19+
lat: trips[0]!.start_station.stop_lat,
20+
lon: trips[0]!.start_station.stop_lon,
21+
distance: trips[0]!.distance_before,
2222
}
2323
const rest = trips.map((t) => ({
2424
name: t.end_station.stop_name,
@@ -29,6 +29,11 @@ const cities = computed(() => {
2929
return [first, ...rest]
3030
})
3131
32+
// One polyline per trip segment, in the GTFS shape's own coordinates.
33+
const legs = computed(() =>
34+
gameState.value.trip.map((t) => t.shape.map((p) => ({ lat: p.lat, lon: p.lon }))),
35+
)
36+
3237
// Persistent d3 handles.
3338
let svg: any = null
3439
let zoomLayer: any = null
@@ -79,7 +84,7 @@ onMounted(async () => {
7984
zoomLayer.attr('transform', event.transform)
8085
// Keep visual weight roughly constant as the user zooms in.
8186
const k = event.transform.k
82-
zoomLayer.selectAll('.trip-links line').attr('stroke-width', 5 / k)
87+
zoomLayer.selectAll('.trip-links path').attr('stroke-width', 5 / k)
8388
zoomLayer
8489
.selectAll('.city')
8590
.attr('r', 8 / k)
@@ -106,6 +111,9 @@ function drawTrip() {
106111
const layer = zoomLayer.select('.trip-layer')
107112
layer.selectAll('*').remove()
108113
114+
// Reflect current zoom level so newly-drawn elements match the others' sizing.
115+
const k = d3.zoomTransform(svg.node()).k || 1
116+
109117
const projectedCities = cities.value
110118
.map((city) => {
111119
const point = projection([city.lon, city.lat])
@@ -119,37 +127,35 @@ function drawTrip() {
119127
d !== null,
120128
)
121129
122-
const cityLinks = projectedCities
123-
.map((city, idx) => {
124-
const next = projectedCities[idx + 1]
125-
return next ? { source: city, target: next } : null
126-
})
127-
.filter(
128-
(
129-
d,
130-
): d is {
131-
source: { name: string; lat: number; lon: number; distance: number; x: number; y: number }
132-
target: { name: string; lat: number; lon: number; distance: number; x: number; y: number }
133-
} => d !== null,
130+
const line = d3
131+
.line<{ x: number; y: number }>()
132+
.x((d) => d.x)
133+
.y((d) => d.y)
134+
135+
const projectedLegs = legs.value
136+
.map((leg) =>
137+
leg
138+
.map((pt) => {
139+
const point = projection([pt.lon, pt.lat])
140+
return point ? { x: point[0], y: point[1] } : null
141+
})
142+
.filter((p): p is { x: number; y: number } => p !== null),
134143
)
135-
136-
// Reflect current zoom level so newly-drawn elements match the others' sizing.
137-
const k = d3.zoomTransform(svg.node()).k || 1
144+
.filter((leg) => leg.length >= 2)
138145
139146
layer
140147
.append('g')
141148
.attr('class', 'trip-links')
142-
.selectAll('line')
143-
.data(cityLinks)
149+
.selectAll('path')
150+
.data(projectedLegs)
144151
.enter()
145-
.append('line')
146-
.attr('x1', (d: any) => d.source.x)
147-
.attr('y1', (d: any) => d.source.y)
148-
.attr('x2', (d: any) => d.target.x)
149-
.attr('y2', (d: any) => d.target.y)
152+
.append('path')
153+
.attr('d', (d: { x: number; y: number }[]) => line(d))
154+
.attr('fill', 'none')
150155
.attr('stroke', '#d60f11')
151156
.attr('stroke-width', 5 / k)
152157
.attr('stroke-linecap', 'round')
158+
.attr('stroke-linejoin', 'round')
153159
.attr('opacity', 0.9)
154160
155161
const cityGroups = layer

0 commit comments

Comments
 (0)