Skip to content

Commit da8c574

Browse files
committed
enhance map and sidebar UI
1 parent e4a8d2b commit da8c574

5 files changed

Lines changed: 187 additions & 53 deletions

File tree

via-ui/src/App.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import SideBar from '@/components/SideBar.vue'
99
<div class="h-screen w-screen grid grid-rows-[auto_1fr] bg-base-300 overflow-hidden">
1010
<TopBar />
1111

12-
<div class="grid grid-cols-1 md:grid-cols-[1fr_320px] overflow-hidden">
12+
<div class="grid grid-cols-1 md:grid-cols-[1fr_350px] overflow-hidden">
1313
<Main class="overflow-auto" />
1414
<SideBar class="overflow-y-auto" />
1515
</div>

via-ui/src/components/Departure.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ function departureSelected() {
2828
</div>
2929

3030
<div class="collapse-content text-sm z-1">
31-
<button class="btn btn-soft btn-primary" @click="departureSelected()">Board train</button>
31+
<button class="btn btn-primary btn-outline w-full" @click="departureSelected()">Board this train</button>
3232
</div>
3333
</div>
3434
</template>

via-ui/src/components/SideBar.vue

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,32 +8,58 @@ import { getIconFilename } from '@/api/game/icons.ts'
88
const { gameState } = useGameManager()
99
1010
const departureChosen = computed(() => {
11-
return gameState.value.chosenTrain !== null
11+
return !!gameState.value?.chosenTrain
1212
})
1313
</script>
1414

1515
<template>
16-
<div class="flex flex-col">
17-
<div v-if="!departureChosen" class="text-2xl font-bold m-4">
18-
Gare de {{ gameState.currentStop?.stop_name }}
19-
</div>
20-
<div v-else class="text-2xl font-bold m-4">
21-
<img
22-
class="inline-block h-5 mx-2 w-auto object-contain align-middle"
23-
:src="getIconFilename(gameState.chosenTrain?.route_short_name!!)"
24-
:alt="gameState.chosenTrain?.route_short_name"
25-
/>
26-
<span> Direction {{ gameState.chosenTrain?.trip_headsign }}</span>
27-
</div>
16+
<div
17+
class="flex flex-col h-full w-full min-w-0 box-border bg-base-200 rounded-2xl border border-base-200 overflow-hidden shadow-sm"
18+
>
19+
<div
20+
class="sticky top-0 bg-base-200 z-10 p-4 pr-8 pb-3 flex flex-col gap-2 shrink-0 border-b border-base-100"
21+
>
22+
<div v-if="!departureChosen" class="w-full min-w-0">
23+
<h2 class="text-2xl font-bold">
24+
<p
25+
v-if="gameState?.currentStop"
26+
class="text-xs tracking-wide uppercase opacity-60 font-semibold mb-1 truncate"
27+
>
28+
Current location
29+
</p>
30+
31+
<span v-if="!gameState?.currentStop" class="opacity-50 text-base font-normal">
32+
Loading...
33+
</span>
34+
35+
<span v-else class="block truncate" :title="gameState.currentStop?.stop_name">
36+
{{ gameState.currentStop?.stop_name }}
37+
</span>
38+
</h2>
39+
</div>
2840

29-
<div v-if="!departureChosen">
30-
<div v-for="d in gameState.departures" :key="d.trip_id">
31-
<Departure :departure="d" />
41+
<div v-if="departureChosen" class="w-full min-w-0">
42+
<p class="text-xs tracking-wide uppercase opacity-60 font-semibold mb-1 truncate">
43+
Current train
44+
</p>
45+
<div class="text-lg font-bold flex items-center">
46+
<img
47+
class="inline-block h-5 mr-2 w-auto object-contain shrink-0"
48+
:src="getIconFilename(gameState.chosenTrain?.route_short_name!!)"
49+
:alt="gameState.chosenTrain?.route_short_name"
50+
/>
51+
<span class="truncate"> Direction {{ gameState.chosenTrain?.trip_headsign }}</span>
52+
</div>
3253
</div>
3354
</div>
34-
<div v-else>
35-
<div v-for="s in gameState.chosenTrain?.stops" :key="s.stop_id">
36-
<Stop :stop="s" />
55+
56+
<div class="flex-1 overflow-y-auto p-1 pr-6 pt-2">
57+
<div v-if="!departureChosen" class="flex flex-col gap-1 w-full min-w-0">
58+
<Departure v-for="d in gameState.departures" :key="d.trip_id" :departure="d" />
59+
</div>
60+
61+
<div v-else class="flex flex-col gap-1 w-full min-w-0">
62+
<Stop v-for="s in gameState.chosenTrain?.stops" :key="s.stop_id" :stop="s" />
3763
</div>
3864
</div>
3965
</div>

via-ui/src/components/Stop.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ function stopSelected() {
2020
<div tabindex="0" class="collapse bg-base-100 border border-base-300 mx-2 my-1">
2121
<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">
23-
<button class="btn btn-soft btn-secondary" @click="stopSelected()">Get out</button>
23+
<button class="btn btn-outline btn-secondary w-full" @click="stopSelected()">Get off here</button>
2424
</div>
2525
</div>
2626
</template>

via-ui/src/components/SwissMap.vue

Lines changed: 139 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,30 +12,26 @@ const height = 600
1212
const pathWidth = 2
1313
const 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.
3527
const 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.
4036
let svg: any = null
4137
let 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.
102112
watch(
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
209317
function resetZoom() {

0 commit comments

Comments
 (0)