Compare commits
8 Commits
1c99c5ca47
...
main
Author | SHA1 | Date | |
---|---|---|---|
0a5bec6c4b
|
|||
6389406744
|
|||
41441a7803
|
|||
f0964d8fb7
|
|||
e58ad34e43
|
|||
af61173e9d
|
|||
2e5b5970a9
|
|||
ec9ac8d7ab
|
11141
package-lock.json
generated
11141
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
40
package.json
40
package.json
@@ -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",
|
||||
|
@@ -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
194
src/Map.js
Normal 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 : © 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)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
|
@@ -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]
|
||||
}
|
||||
|
Reference in New Issue
Block a user