@@ -12,30 +12,26 @@ const height = 600
1212const pathWidth = 2
1313const mapContainer = ref <HTMLElement | null >(null )
1414
15- const cities = computed (() => {
16- const trips = gameState .value .trip
17- if (trips .length === 0 ) return []
18-
19- const first = {
20- name: trips [0 ]! .start_station .stop_name ,
21- lat: trips [0 ]! .start_station .stop_lat ,
22- lon: trips [0 ]! .start_station .stop_lon ,
23- distance: trips [0 ]! .distance_before ,
24- }
25- const rest = trips .map ((t ) => ({
15+ // Numbered dots = the stations reached by completing each leg (its end_station).
16+ // The origin is drawn separately as a dedicated start marker.
17+ const cities = computed (() =>
18+ gameState .value .trip .map ((t ) => ({
2619 name: t .end_station .stop_name ,
2720 lat: t .end_station .stop_lat ,
2821 lon: t .end_station .stop_lon ,
2922 distance: t .distance_after ,
30- }))
31- return [first , ... rest ]
32- })
23+ })),
24+ )
3325
3426// One polyline per trip segment, in the GTFS shape's own coordinates.
3527const legs = computed (() =>
3628 gameState .value .trip .map ((t ) => t .shape .map ((p ) => ({ lat: p .lat , lon: p .lon }))),
3729)
3830
31+ const startStation = computed (() => gameState .value .startStation )
32+ const targetStation = computed (() => gameState .value .targetStation )
33+ const currentStop = computed (() => gameState .value .currentStop )
34+
3935// Persistent d3 handles.
4036let svg: any = null
4137let zoomLayer: any = null
@@ -92,15 +88,29 @@ onMounted(async () => {
9288 .attr (' r' , 8 / k )
9389 .attr (' stroke-width' , 2 / k )
9490 zoomLayer .selectAll (' .city-number' ).attr (' font-size' , 10 / k )
91+ zoomLayer
92+ .selectAll (' .special-dot' )
93+ .attr (' r' , 10 / k )
94+ .attr (' stroke-width' , 2.5 / k )
95+ zoomLayer .selectAll (' .special-label' ).attr (' font-size' , 11 / k )
96+ zoomLayer
97+ .selectAll (' .current-ring' )
98+ .attr (' r' , 13 / k )
99+ .attr (' stroke-width' , 2 / k )
100+ zoomLayer
101+ .selectAll (' .current-dot' )
102+ .attr (' r' , 5 / k )
103+ .attr (' stroke-width' , 2 / k )
95104 })
96105
97106 svg .call (zoomBehavior )
98107
99108 drawTrip ()
100109})
101110
111+ // Redraw on trip progress and on start/target/current changes.
102112watch (
103- cities ,
113+ [ cities , startStation , targetStation , currentStop ] ,
104114 () => {
105115 if (svg && projection ) drawTrip ()
106116 },
@@ -115,20 +125,9 @@ function drawTrip() {
115125
116126 // Reflect current zoom level so newly-drawn elements match the others' sizing.
117127 const k = d3 .zoomTransform (svg .node ()).k || 1
128+ const tooltip = d3 .select (' #tooltip' )
118129
119- const projectedCities = cities .value
120- .map ((city ) => {
121- const point = projection ([city .lon , city .lat ])
122- if (! point ) return null
123- return { ... city , x: point [0 ], y: point [1 ] }
124- })
125- .filter (
126- (
127- d ,
128- ): d is { name: string ; lat : number ; lon : number ; distance : number ; x : number ; y : number } =>
129- d !== null ,
130- )
131-
130+ // --- leg polylines (precise shapes) ---------------------------------------
132131 const line = d3
133132 .line <{ x: number ; y: number }>()
134133 .x ((d ) => d .x )
@@ -160,6 +159,20 @@ function drawTrip() {
160159 .attr (' stroke-linejoin' , ' round' )
161160 .attr (' opacity' , 0.9 )
162161
162+ // --- numbered "reached" stations ------------------------------------------
163+ const projectedCities = cities .value
164+ .map ((city ) => {
165+ const point = projection ([city .lon , city .lat ])
166+ if (! point ) return null
167+ return { ... city , x: point [0 ], y: point [1 ] }
168+ })
169+ .filter (
170+ (
171+ d ,
172+ ): d is { name: string ; lat : number ; lon : number ; distance : number ; x : number ; y : number } =>
173+ d !== null ,
174+ )
175+
163176 const cityGroups = layer
164177 .selectAll (' .city-group' )
165178 .data (projectedCities )
@@ -177,7 +190,6 @@ function drawTrip() {
177190 .attr (' stroke' , ' #fff' )
178191 .attr (' stroke-width' , 2 / k )
179192
180- // Step number inside the dot — never collides with neighbours, no matter how dense the area.
181193 cityGroups
182194 .append (' text' )
183195 .attr (' class' , ' city-number' )
@@ -189,8 +201,6 @@ function drawTrip() {
189201 .attr (' pointer-events' , ' none' )
190202 .text ((_d : any , i : number ) => i + 1 )
191203
192- const tooltip = d3 .select (' #tooltip' )
193-
194204 cityGroups
195205 .on (' mouseover' , (_event : any , d : any ) => {
196206 tooltip
@@ -204,6 +214,104 @@ function drawTrip() {
204214 .on (' mouseout' , () => {
205215 tooltip .style (' opacity' , 0 )
206216 })
217+
218+ // --- start, target, current (always shown, drawn on top) ------------------
219+ drawSpecialMarker (layer , startStation .value , ' #1e8e3e' , ' S' , ' Start' , k , tooltip )
220+ drawSpecialMarker (layer , targetStation .value , ' #1a73e8' , ' T' , ' Target' , k , tooltip )
221+ drawCurrentMarker (layer , currentStop .value , k , tooltip )
222+ }
223+
224+ function drawSpecialMarker(
225+ layer : any ,
226+ stop : { stop_name: string ; stop_lat: number ; stop_lon: number } | null ,
227+ color : string ,
228+ label : string ,
229+ tooltipLabel : string ,
230+ k : number ,
231+ tooltip : any ,
232+ ) {
233+ if (! stop ) return
234+ const point = projection ([stop .stop_lon , stop .stop_lat ])
235+ if (! point ) return
236+ const [x, y] = point
237+
238+ const g = layer
239+ .append (' g' )
240+ .attr (' class' , ' special-marker' )
241+ .attr (' transform' , ` translate(${x }, ${y }) ` )
242+ .style (' cursor' , ' pointer' )
243+
244+ g .append (' circle' )
245+ .attr (' class' , ' special-dot' )
246+ .attr (' r' , 10 / k )
247+ .attr (' fill' , color )
248+ .attr (' stroke' , ' #fff' )
249+ .attr (' stroke-width' , 2.5 / k )
250+
251+ g .append (' text' )
252+ .attr (' class' , ' special-label' )
253+ .attr (' text-anchor' , ' middle' )
254+ .attr (' dominant-baseline' , ' central' )
255+ .attr (' font-size' , 11 / k )
256+ .attr (' font-weight' , 700 )
257+ .attr (' fill' , ' #fff' )
258+ .attr (' pointer-events' , ' none' )
259+ .text (label )
260+
261+ g .on (' mouseover' , () => {
262+ tooltip .style (' opacity' , 1 ).html (` <strong>${stop .stop_name }</strong><br>${tooltipLabel } ` )
263+ })
264+ .on (' mousemove' , (event : any ) => {
265+ const [mx, my] = d3 .pointer (event , mapContainer .value )
266+ tooltip .style (' left' , mx + 15 + ' px' ).style (' top' , my - 15 + ' px' )
267+ })
268+ .on (' mouseout' , () => {
269+ tooltip .style (' opacity' , 0 )
270+ })
271+ }
272+
273+ function drawCurrentMarker(
274+ layer : any ,
275+ stop : { stop_name: string ; stop_lat: number ; stop_lon: number } | null ,
276+ k : number ,
277+ tooltip : any ,
278+ ) {
279+ if (! stop ) return
280+ const point = projection ([stop .stop_lon , stop .stop_lat ])
281+ if (! point ) return
282+ const [x, y] = point
283+
284+ const g = layer
285+ .append (' g' )
286+ .attr (' class' , ' current-marker' )
287+ .attr (' transform' , ` translate(${x }, ${y }) ` )
288+ .style (' cursor' , ' pointer' )
289+
290+ // Outer ring + inner dot reads as "you are here".
291+ g .append (' circle' )
292+ .attr (' class' , ' current-ring' )
293+ .attr (' r' , 13 / k )
294+ .attr (' fill' , ' none' )
295+ .attr (' stroke' , ' #f9ab00' )
296+ .attr (' stroke-width' , 2 / k )
297+
298+ g .append (' circle' )
299+ .attr (' class' , ' current-dot' )
300+ .attr (' r' , 5 / k )
301+ .attr (' fill' , ' #f9ab00' )
302+ .attr (' stroke' , ' #fff' )
303+ .attr (' stroke-width' , 2 / k )
304+
305+ g .on (' mouseover' , () => {
306+ tooltip .style (' opacity' , 1 ).html (` <strong>${stop .stop_name }</strong><br>You are here ` )
307+ })
308+ .on (' mousemove' , (event : any ) => {
309+ const [mx, my] = d3 .pointer (event , mapContainer .value )
310+ tooltip .style (' left' , mx + 15 + ' px' ).style (' top' , my - 15 + ' px' )
311+ })
312+ .on (' mouseout' , () => {
313+ tooltip .style (' opacity' , 0 )
314+ })
207315}
208316
209317function resetZoom() {
0 commit comments