Compare commits

...

8 Commits

6 changed files with 6444 additions and 5040 deletions

11141
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,25 +3,29 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.17",
"@mui/material": "^5.15.6",
"@mui/x-date-pickers": "^6.19.2",
"@tanstack/query-sync-storage-persister": "^5.18.0",
"@tanstack/react-query": "^5.18.0",
"@tanstack/react-query-persist-client": "^5.18.0",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"dayjs": "^1.11.10",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.3",
"react-scripts": "5.0.1",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@mapbox/polyline": "^1.2.1",
"@mui/icons-material": "^6.1.6",
"@mui/material": "^6.1.6",
"@mui/x-date-pickers": "^7.22.2",
"@tanstack/react-query": "^5.59.20",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@turf/rhumb-bearing": "^7.1.0",
"@turf/rhumb-distance": "^7.1.0",
"@types/leaflet": "^1.9.14",
"dayjs": "^1.11.13",
"leaflet": "^1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.28.0",
"react-scripts": "^5.0.1",
"react-transition-group": "^4.4.5",
"sass": "^1.70.0",
"web-vitals": "^2.1.4"
"sass": "^1.80.6",
"web-vitals": "^4.2.4"
},
"scripts": {
"start": "react-scripts start",

View File

@@ -2,12 +2,13 @@ import {createBrowserRouter, RouterProvider} from "react-router-dom"
import Station from "./Station"
import {createTheme, CssBaseline, ThemeProvider, useMediaQuery} from "@mui/material"
import React, {useMemo} from "react"
import {frFR, LocalizationProvider} from "@mui/x-date-pickers"
import {LocalizationProvider} from "@mui/x-date-pickers"
import {AdapterDayjs} from "@mui/x-date-pickers/AdapterDayjs"
import 'dayjs/locale/fr'
import './App.css'
import {QueryClient, QueryClientProvider} from "@tanstack/react-query"
import Home from "./Home"
import TrainMap from "./Map"
import dayjs from "dayjs"
function App() {
@@ -19,6 +20,10 @@ function App() {
{
path: "/station/:theme/:stationId",
element: <Station />
},
{
path: "/map",
element: <TrainMap />
}
])
@@ -58,7 +63,7 @@ function App() {
return <>
<ThemeProvider theme={theme}>
<CssBaseline />
<LocalizationProvider dateAdapter={AdapterDayjs} localeText={frFR.components.MuiLocalizationProvider.defaultProps.localeText} adapterLocale="fr">
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="fr">
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>

194
src/Map.js Normal file
View File

@@ -0,0 +1,194 @@
import "leaflet/dist/leaflet.css"
import L from 'leaflet'
import {MapContainer, Marker, TileLayer, useMapEvents} from 'react-leaflet'
import {useEffect, useMemo, useState} from "react"
import dayjs from "dayjs"
import polyline from "@mapbox/polyline"
import getDistance from '@turf/rhumb-distance'
import getBearing from '@turf/rhumb-bearing'
export default function TrainMap () {
return <>
<MapContainer center={[46.47, 2.37]} zoom={6} style={{height: "100vh"}}>
<TileLayer
attribution='Données cartographiques : &copy; Les contributeurices <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<TileLayer
attribution="Rendu : OpenRailwayMap"
url="https://{s}.tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png"></TileLayer>
<MapContent />
</MapContainer>
</>
}
function MapContent () {
const [latitude, setLatitude] = useState(46.47)
const [longitude, setLongitude] = useState(2.37)
const [zoom, setZoom] = useState(6)
useEffect(() => {
fetch(`${process.env.REACT_APP_MOTIS_SERVER}/api/v1/map/initial`)
.then(response => response.json())
.then(data => {
setLatitude(data['lat'])
setLongitude(data['lon'])
setZoom(data['zoom'])
})
}, [])
const map = useMapEvents({
moveend: () => {
updateTrips(map, setTrips)
},
zoomend: () => {
updateTrips(map, setTrips)
}
})
useEffect(() => {
map.flyTo([latitude, longitude], zoom)
}, [map, latitude, longitude, zoom])
const [trips, setTrips] = useState([])
useEffect(() => {
updateTrips(map, setTrips)
setInterval(() => updateTrips(map, setTrips), 30000)
}, [map])
return <>
{trips.map(trip => <TripMarker trip={trip} />)}
</>
}
function TripMarker ({trip}) {
const [position, setPosition] = useState([trip.from.lat, trip.from.lon])
const [heading, setHeading] = useState(0)
const style = getModeStyle(trip.mode)
const keyframes = useMemo(() => {
const keyframes = []
const departure = dayjs(trip.departure)
const arrival = dayjs(trip.arrival)
const coordinates = polyline.decode(trip.polyline)
const totalDuration = arrival.diff(departure, 'seconds')
let currDistance = 0
let totalDistance = 0
for (let i = 0; i < coordinates.length - 1; i++) {
let from = coordinates[i]
let to = coordinates[i + 1]
totalDistance += getDistance(from, to, { units: 'meters' })
}
for (let i = 0; i < coordinates.length - 1; i++) {
let from = coordinates[i]
let to = coordinates[i + 1]
const distance = getDistance(from, to, { units: 'meters' })
const heading = getBearing(from, to)
const r = currDistance / totalDistance
keyframes.push({ point: from, time: departure.add(r * totalDuration, 'seconds'), heading: heading })
currDistance += distance
}
keyframes.push({ point: coordinates[coordinates.length - 1], time: arrival, heading: 0 })
return keyframes
}, [trip])
useEffect(() => {
const interval = setInterval(() => {
const now = dayjs()
const index = keyframes.findIndex((kf) => kf.time >= now)
if (index === -1 || index === 0)
return
const startState = keyframes[index - 1]
const endState = keyframes[index]
const r = (now.diff(startState.time)) / (endState.time.diff(startState.time))
const lat = startState.point[0] * (1 - r) + endState.point[0] * r
const lon = startState.point[1] * (1 - r) + endState.point[1] * r
setPosition([lat, lon])
setHeading(startState.heading)
}, 100)
return () => clearInterval(interval)
}, [keyframes])
const icon = L.divIcon({
html: `<svg fill="${style[1]}" fill-opacity="0.8" xmlns="http://www.w3.org/2000/svg"
\t width="36px" height="36px" viewBox="0 0 512 512" xml:space="preserve">
<g transform="rotate(${-heading - 90}, 256, 256)">
\t<path d="M256 17.108c-75.73 0-137.122 61.392-137.122 137.122.055 23.25 6.022 46.107 11.58 56.262L256 494.892l119.982-274.244h-.063c11.27-20.324 17.188-43.18 17.202-66.418C393.122 78.5 331.73 17.108 256 17.108zm0 68.56a68.56 68.56 0 0 1 68.56 68.562A68.56 68.56 0 0 1 256 222.79a68.56 68.56 0 0 1-68.56-68.56A68.56 68.56 0 0 1 256 85.67z" />
</g>
</svg>`,
className: "",
iconSize: [36, 36],
iconAnchor: [36, 36],
})
return <Marker position={position} icon={icon} />
}
function getModeStyle (mode) {
switch (mode) {
case 'WALK':
case 'FLEXIBLE':
return ['walk', 'hsl(var(--foreground) / 1)', 'hsl(var(--background) / 1)']
case 'BIKE':
case 'BIKE_TO_PARK':
case 'BIKE_RENTAL':
case 'SCOOTER_RENTAL':
return ['bike', '#075985', 'white']
case 'CAR':
case 'CAR_TO_PARK':
case 'CAR_HAILING':
case 'CAR_SHARING':
case 'CAR_PICKUP':
case 'CAR_RENTAL':
return ['car', '#333', 'white']
case 'TRANSIT':
case 'BUS':
return ['bus', '#ff9800', 'white']
case 'COACH':
return ['bus', '#9ccc65', 'white']
case 'TRAM':
return ['tram', '#ff9800', 'white']
case 'METRO':
return ['sbahn', '#4caf50', 'white']
case 'SUBWAY':
return ['ubahn', '#3f51b5', 'white']
case 'FERRY':
return ['ship', '#00acc1', 'white']
case 'AIRPLANE':
return ['plane', '#90a4ae', 'white']
case 'HIGHSPEED_RAIL':
return ['train', '#9c27b0', 'white']
case 'LONG_DISTANCE':
return ['train', '#e91e63', 'white']
case 'NIGHT_RAIL':
return ['train', '#1a237e', 'white']
case 'REGIONAL_FAST_RAIL':
case 'REGIONAL_RAIL':
case 'RAIL':
return ['train', '#f44336', 'white']
}
return ['train', '#000000', 'white']
}
function updateTrips(map, setTrips) {
const bounds = map.getBounds()
const now = dayjs()
const now_plus_1_min = now.add(60000)
const query_params = new URLSearchParams({
min: `${bounds.getNorth()},${bounds.getWest()}`,
max: `${bounds.getSouth()},${bounds.getEast()}`,
zoom: map.getZoom(),
startTime: now.format(),
endTime: now_plus_1_min.format(),
}).toString()
fetch(`${process.env.REACT_APP_MOTIS_SERVER}/api/v1/map/trips?${query_params}`)
.then(data => data.json())
.then(setTrips)
}

View File

@@ -1,14 +1,13 @@
import {useNavigate, useParams, useSearchParams} from "react-router-dom"
import TrainsTable from "./TrainsTable"
import TripsFilter from "./TripsFilter"
import {useState} from "react"
import {Box, Button, FormLabel} from "@mui/material"
import {useEffect, useState} from "react"
import {Box, Checkbox, FormLabel} from "@mui/material"
import {DateTimePicker} from "@mui/x-date-pickers"
import dayjs from "dayjs"
import {useQuery, useQueryClient} from "@tanstack/react-query"
import AutocompleteStation from "./AutocompleteStation"
function DateTimeSelector({datetime, setDatetime}) {
function DateTimeSelector({datetime, setDatetime, realtime, setRealtime}) {
const navigate = useNavigate()
function onStationSelected(event, station) {
@@ -18,15 +17,18 @@ function DateTimeSelector({datetime, setDatetime}) {
return <>
<Box component="form" display="flex" alignItems="center" sx={{'& .MuiTextField-root': { m: 1, width: '25ch' },}}>
<FormLabel>
Changer la gare recherchée :
</FormLabel>
<AutocompleteStation onChange={onStationSelected} />
<FormLabel>
Modifier la date et l'heure de recherche :
</FormLabel>
<DateTimePicker name="date" label="Date" onChange={setDatetime} value={datetime} />
<Button type="submit">Rechercher</Button>
<FormLabel>
Changer la gare recherchée :
</FormLabel>
<AutocompleteStation onChange={onStationSelected} />
<FormLabel>
Modifier la date et l'heure de recherche :
</FormLabel>
<DateTimePicker name="date" label="Date" onChange={setDatetime} value={datetime} disabled={realtime} readOnly={realtime} />
<Checkbox onChange={event => setRealtime(event.target.checked)} checked={realtime} />
<FormLabel>
Temps réel
</FormLabel>
</Box>
</>
}
@@ -36,7 +38,20 @@ function Station() {
let {theme, stationId} = useParams()
// eslint-disable-next-line no-unused-vars
let [searchParams, setSearchParams] = useSearchParams()
const [datetime, setDatetime] = useState(dayjs())
const [realtime, setRealtime] = useState(searchParams.get('realtime') === "1" || false)
const [datetime, setDatetime] = useState(dayjs(searchParams.get('time') || undefined))
if ((searchParams.get('realtime') === null || searchParams.get('realtime') === "0")
&& (searchParams.get('time') === null || realtime)) {
searchParams.set('realtime', "1")
searchParams.delete("time")
setRealtime(true)
window.history.replaceState({}, '', '?' + searchParams.toString())
}
else if (datetime.format() !== searchParams.get('time') && !realtime) {
searchParams.set('time', datetime.format())
searchParams.set('realtime', "0")
window.history.replaceState({}, '', '?' + searchParams.toString())
}
useQueryClient()
const stationQuery = useQuery({
@@ -47,11 +62,14 @@ function Station() {
})
const station = stationQuery.data?.stopTimes[0].place ?? {name: "Chargement…"}
if (searchParams.get("time") === undefined) {
setInterval(() => {
setDatetime(dayjs())
}, 5000)
}
useEffect(() => {
if (realtime) {
const interval = setInterval(() => {
setDatetime(dayjs())
}, 5000)
return () => clearInterval(interval)
}
}, [realtime])
return (
<div className="Station">
@@ -60,10 +78,10 @@ function Station() {
</header>
<main>
<DateTimeSelector datetime={datetime} setDatetime={setDatetime} />
<TripsFilter />
<TrainsTable station={station} datetime={datetime} tableType="departures" />
<TrainsTable station={station} datetime={datetime} tableType="arrivals" />
<DateTimeSelector datetime={datetime} setDatetime={setDatetime} realtime={realtime} setRealtime={setRealtime} />
{/*<TripsFilter />*/}
<TrainsTable station={station} datetime={datetime} realtime={realtime} tableType="departures" />
<TrainsTable station={station} datetime={datetime} realtime={realtime} tableType="arrivals" />
</main>
</div>
)

View File

@@ -27,12 +27,12 @@ const StyledTableRow = styled(TableRow)(({ theme, tabletype }) => ({
},
}));
function TrainsTable({station, datetime, tableType}) {
function TrainsTable({station, datetime, realtime, tableType}) {
return <>
<TableContainer>
<Table>
<TrainsTableHeader tableType={tableType} />
<TrainsTableBody station={station} datetime={datetime} tableType={tableType} />
<TrainsTableBody station={station} datetime={datetime} realtime={realtime} tableType={tableType} />
</Table>
</TableContainer>
</>
@@ -50,7 +50,7 @@ function TrainsTableHeader({tableType}) {
</>
}
function TrainsTableBody({station, datetime, tableType}) {
function TrainsTableBody({station, datetime, realtime, tableType}) {
const filterTime = useCallback((train) => {
if (tableType === "departures")
return dayjs(train.place.departure) >= datetime
@@ -59,17 +59,20 @@ function TrainsTableBody({station, datetime, tableType}) {
}, [datetime, tableType])
const updateTrains = useCallback(() => {
const query_params = new URLSearchParams({
const params = {
stopId: station.stopId,
arriveBy: tableType === "arrivals",
time: datetime.format(),
direction: "LATER",
n: 20,
}).toString()
}
if (!realtime)
params['time'] = datetime.format()
const query_params = new URLSearchParams(params).toString()
return fetch(`${process.env.REACT_APP_MOTIS_SERVER}/api/v1/stoptimes?${query_params}`)
.then(response => response.json())
.then(data => data.stopTimes)
.then(data => [...data])
}, [station.stopId, datetime, tableType])
}, [station.stopId, tableType, datetime, realtime])
const trainsQuery = useQuery({
queryKey: ['trains', station.stopId, tableType],
@@ -79,10 +82,12 @@ function TrainsTableBody({station, datetime, tableType}) {
const trains = useMemo(() => trainsQuery.data ?? [], [trainsQuery.data])
useEffect(() => {
let validTrains = trains?.filter(filterTime) ?? []
if (trains?.length > 0 && validTrains.length < trains?.length)
trainsQuery.refetch().then()
}, [trains, filterTime, trainsQuery])
if (realtime) {
let validTrains = trains?.filter(filterTime) ?? []
if ((trains?.length > 0 && validTrains.length < trains?.length))
trainsQuery.refetch().then()
}
}, [trains, filterTime, trainsQuery, realtime])
const nullRef = useRef(null)
let table_rows = trains.map((train) => <CSSTransition key={train.id} timeout={500} classNames="shrink" nodeRef={nullRef}>
@@ -202,9 +207,13 @@ function getTrainType(train) {
default:
return trainType
}
case "FR-IDF-IDFM":
case "FR-IDFM":
const route_split = train.routeShortName.split(" ")
if (route_split[0] === "Bus")
return route_split[1]
return route_split[0]
case "FR-GES-CTS":
return "A"
return train.routeShortName.split(" ")[1]
case "FR-EUROSTAR":
return "Eurostar"
case "IT-FRA-TI":
@@ -216,7 +225,6 @@ function getTrainType(train) {
return "NJ"
return "ÖBB"
case "CH-ALL":
return "A"
default:
return train.routeShortName?.split(" ")[0]
}