Last active
April 12, 2023 11:38
-
-
Save brense/bafdbcf0b656bb101be3ab5c31242ea9 to your computer and use it in GitHub Desktop.
Travel assistent project i've been working on recently
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import fetch from 'node-fetch' | |
import Trip from '../customTypes/Trip' | |
import Station, { StationType } from '../customTypes/Station' | |
import moment from 'moment' | |
const { NS_API_PRIMARY_KEY } = process.env | |
const headers = { ['Ocp-Apim-Subscription-Key' as string]: NS_API_PRIMARY_KEY || '' } | |
const apiUrl = 'https://gateway.apiportal.ns.nl' | |
// temporary use a cache to prevent excess requests to NS API | |
const tempCache: any = {} | |
export async function getPlaces(q?: string, station_code?: string, lat?: number, long?: number) { | |
const find = q ? { q } : station_code ? { station_code } : { lat, lng: long } | |
const response = await fetch(`${apiUrl}/places-api/v2/places?limit=100&${serialize(find)}&radius=5000&lang=nl&type=stationV2`, { headers }) // removed: stop,address | |
const data: { payload: any[] } = await response.json() | |
return data.payload.reduce((places, { type, locations }) => { | |
locations.forEach(({ stationCode, UICCode, link, name, lat, lng, ...rest }: any) => places.push({ ...rest, name, UICCode, stationCode, link: link.uri, type, position: { lat, long: lng } })) | |
return places | |
}, []) | |
} | |
export async function getStations() { | |
const response = await fetch(`${apiUrl}/reisinformatie-api/api/v2/stations`, { headers }) | |
const data: { payload: any[] } = await response.json() | |
return data.payload.map(mapStationPayload) | |
} | |
type TripArgs = { | |
from: string, | |
to: string, | |
dateTime?: Date, | |
searchForArrival?: boolean, | |
excludeTrainsWithReservationRequired?: boolean, | |
searchForAccessibleTrip?: boolean, | |
localTrainsOnly?: boolean, | |
addChangeTime?: number | |
} | |
export async function getTrips({ from, to, dateTime, searchForArrival, excludeTrainsWithReservationRequired, localTrainsOnly, searchForAccessibleTrip, addChangeTime }: TripArgs) { | |
const formatted = moment(dateTime).format('YYYY-MM-DDTHH:mm:00') | |
try { | |
const url = `${apiUrl}/reisinformatie-api/api/v3/trips? | |
passing=true& | |
fromStation=${from}& | |
toStation=${to}& | |
dateTime=${formatted}& | |
searchForArrival=${typeof searchForArrival !== 'undefined' ? searchForArrival : false}& | |
searchForAccessibleTrip=${typeof searchForAccessibleTrip !== 'undefined' ? searchForAccessibleTrip : false}& | |
excludeTrainsWithReservationRequired=${typeof excludeTrainsWithReservationRequired !== 'undefined' ? excludeTrainsWithReservationRequired : true}& | |
localTrainsOnly=${typeof localTrainsOnly !== 'undefined' ? localTrainsOnly : false}& | |
addChangeTime=${typeof addChangeTime !== 'undefined' ? addChangeTime : 0} | |
`.replace(/[ +\r\n\t]/g, '') | |
console.log('URL', url) | |
if (tempCache[url]) { | |
return tempCache[url] | |
} else { | |
const response = await fetch(url, { headers }) | |
const data: { trips: any[] } = await response.json() | |
if (response.status !== 200) { | |
throw new Error('') | |
} | |
const mapped = data.trips.map(mapTripPayload) | |
tempCache[url] = mapped | |
return mapped | |
} | |
} catch (e) { | |
console.log(e) | |
} | |
} | |
function mapTripPayload(trip: any): Trip { | |
const { legs, ...rest } = trip | |
return { | |
...rest, | |
legs: legs.map(({ stops, destination, origin, ...leg }: any) => { | |
return { | |
...leg, | |
destination: { | |
...destination, | |
position: { | |
lat: destination.lat, | |
long: destination.lng | |
} | |
}, | |
origin: { | |
...origin, | |
position: { | |
lat: origin.lat, | |
long: origin.lng | |
} | |
}, | |
stops: stops.map(({ actualDepartureDateTime, actualArrivalDateTime, ...stop }: any) => { | |
return { | |
...stop, | |
actualDepartureDateTime: moment(actualDepartureDateTime).toDate(), | |
actualArrivalDateTime: moment(actualArrivalDateTime).toDate() | |
} | |
}) | |
} | |
}) | |
} | |
} | |
function mapStationPayload(station: any): Station { | |
const { | |
UICCode, | |
stationType, | |
EVACode, | |
code, | |
sporen, | |
synoniemen, | |
heeftFaciliteiten, | |
heeftVertrektijden, | |
heeftReisassistentie, | |
namen, | |
land, | |
lat, | |
lng, | |
radius, | |
naderenRadius, | |
ingangsDatum | |
} = station | |
return { | |
code, | |
UICCode, | |
EVACode, | |
stationType: StationType[stationType as keyof typeof StationType], | |
tracks: sporen.map(({ spoorNummer }: any) => ({ number: spoorNummer })), | |
synonyms: synoniemen, | |
hasFacilities: heeftFaciliteiten, | |
hasDepartures: heeftVertrektijden, | |
hasTravelAssistance: heeftReisassistentie, | |
names: { long: namen.lang, medium: namen.middel, short: namen.kort }, | |
country: land, | |
position: { lat, long: lng }, | |
radius, | |
approachRadius: naderenRadius, | |
startDate: new Date(ingangsDatum) | |
} | |
} | |
function serialize(obj: any) { | |
const str = [] | |
for (const p in obj) | |
if (obj.hasOwnProperty(p)) { | |
str.push(encodeURIComponent(p) + '=' + encodeURIComponent(obj[p])) | |
} | |
return str.join('&') | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useLazyQuery } from '@apollo/client' | |
import { TextField } from '@material-ui/core' | |
import { Autocomplete } from '@material-ui/lab' | |
import React, { useCallback, useEffect, useMemo } from 'react' | |
import { QUERY_PLACES } from '../queries' | |
let timeout: NodeJS.Timeout | |
function PlaceSearch<T extends { name: string, type: string }>({ endAdornment, label, margin, value, onChange }: { endAdornment?: React.ReactNode, label: React.ReactNode, margin?: 'dense' | 'none' | 'normal', value: T | null, onChange: (event: React.ChangeEvent<{}>, value: T | null) => void }) { | |
const [getPlaces, { loading, data }] = useLazyQuery<{ places: T[] }>(QUERY_PLACES) | |
useEffect(() => { | |
return function cleanup() { | |
clearTimeout(timeout) | |
} | |
}, []) | |
const search = useCallback((q: string) => { | |
if (value) { | |
getPlaces({ variables: { q: value.name } }) | |
} else { | |
clearTimeout(timeout) | |
timeout = setTimeout(() => { | |
if (q.length > 2) { | |
getPlaces({ variables: { q } }) | |
} | |
}, 500) | |
} | |
}, [getPlaces, value]) | |
const filteredOptions = useMemo(() => { | |
if (data) { | |
const stations = data.places.filter(({ type }) => type === 'stationV2') | |
return stations.length > 0 ? stations : data.places | |
} | |
return [] | |
}, [data]) | |
return <Autocomplete | |
renderInput={(props) => ( | |
<TextField | |
{...props} | |
label={label} | |
margin={margin} | |
variant="outlined" | |
InputProps={{ ...props.InputProps, style: { paddingRight: 8 }, endAdornment }} | |
/> | |
)} | |
value={value} | |
loading={loading} | |
loadingText="Zoeken..." | |
noOptionsText="Zoek naar een station" | |
onChange={onChange} | |
onInputChange={(e, v) => search(v)} | |
options={filteredOptions} | |
getOptionLabel={opt => opt.name} | |
getOptionSelected={(opt, v) => opt.name === v.name} | |
/> | |
} | |
export default PlaceSearch |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'reflect-metadata' | |
import 'dotenv/config' | |
import { createConnection } from 'typeorm' | |
import * as express from 'express' | |
import * as bodyParser from 'body-parser' | |
import { ApolloServer } from 'apollo-server-express' | |
import { createServer } from 'http' | |
import { buildSchema } from 'type-graphql' | |
import { verfifyAuthBearer, objectToArray } from './utils' | |
import * as resolversObj from './resolvers' | |
const port = process.env.PORT ? Number(process.env.PORT) : 3000; | |
(async () => { | |
// init database connection | |
await createConnection() | |
// init express app | |
const app = express() | |
// serve static files | |
app.use('/static', express.static(__dirname + '/static')) | |
app.use(bodyParser.json()) | |
app.use(bodyParser.urlencoded({ extended: false })) | |
app.get('*', (req, res, next) => { | |
const path = __dirname + '/index.html' | |
res.sendFile(path) | |
}) | |
// build graphql schema | |
const resolvers = objectToArray(resolversObj) as [Function, ...Function[]] | |
const schema = await buildSchema({ resolvers }) | |
// init apollo server | |
const server = new ApolloServer({ | |
schema, | |
context, | |
introspection: true, | |
playground: true, | |
subscriptions: { | |
path: '/graphql', | |
keepAlive: 10000, | |
onConnect | |
} | |
}) | |
// apply middleware and start server | |
server.applyMiddleware({ app, path: '/graphql' }) | |
const httpServer = createServer(app) | |
server.installSubscriptionHandlers(httpServer) | |
httpServer.listen(port, '0.0.0.0', () => { | |
console.log(`Server ready at: http://localhost:${port}${server.graphqlPath}, subscriptions: ws://localhost:${port}${server.subscriptionsPath}`) | |
}) | |
})() | |
async function context({ req }: { req: express.Request }) { | |
// TODO: get additional user info? | |
return { | |
user: await verfifyAuthBearer(req.headers.authorization) | |
} | |
} | |
function onConnect(connectionParams: { authorization?: string }) { | |
return verfifyAuthBearer(connectionParams.authorization) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Checkbox, Dialog, DialogContent, DialogProps, DialogTitle as MuiDialogTitle, DialogTitleProps, Divider, FormControlLabel, Icon, IconButton, makeStyles, Radio, RadioGroup, Slide, Slider, Typography } from '@material-ui/core' | |
import { TransitionProps } from '@material-ui/core/transitions'; | |
import React, { useCallback, useContext } from 'react' | |
import { useHistory } from 'react-router-dom'; | |
import { AppContext } from '../App'; | |
const Transition = React.forwardRef(function Transition( | |
props: TransitionProps & { children?: React.ReactElement<any, any> }, | |
ref: React.Ref<unknown>, | |
) { | |
return <Slide direction="up" ref={ref} {...props} /> | |
}) | |
const useStyles = makeStyles(theme => ({ | |
root: { | |
margin: 0, | |
padding: theme.spacing(2) | |
}, | |
closeButton: { | |
position: 'absolute', | |
right: theme.spacing(1), | |
top: theme.spacing(1), | |
color: theme.palette.grey[500] | |
} | |
})) | |
function DialogTitle(props: DialogTitleProps & { onClose: () => void }) { | |
const classes = useStyles() | |
const { children, onClose, ...other } = props | |
return ( | |
<MuiDialogTitle disableTypography {...other} className={classes.root}> | |
<Typography variant="h6">{children}</Typography> | |
{onClose ? ( | |
<IconButton aria-label="close" onClick={onClose} className={classes.closeButton}> | |
<Icon>close</Icon> | |
</IconButton> | |
) : null} | |
</MuiDialogTitle> | |
) | |
} | |
const marks = [ | |
{ value: 0, label: '0' }, | |
{ value: 5, label: '+5' }, | |
{ value: 10, label: '+10' }, | |
{ value: 15, label: '+15' }, | |
{ value: 20, label: '+20' } | |
] | |
function SettingsDialog(props: DialogProps) { | |
const { preferences, setPreferences } = useContext(AppContext) | |
const history = useHistory() | |
const handleChange = useCallback((property: string, value: any) => { | |
setPreferences({ ...preferences, [property]: value }) | |
}, [preferences, setPreferences]) | |
return <Dialog {...props} TransitionComponent={Transition}> | |
<DialogTitle onClose={() => history.goBack()}>Voorkeuren</DialogTitle> | |
<Divider /> | |
<DialogContent style={{ flex: 'none' }}> | |
<Typography variant="body2" gutterBottom={true}>Geef voorkeur aan:</Typography> | |
<RadioGroup name="travel-options" value={preferences.connection} onChange={v => handleChange('connection', v.target.value as 'fastest')}> | |
<FormControlLabel value="fastest" control={<Radio />} label="Snelste verbinding" /> | |
<FormControlLabel value="transfers" control={<Radio />} label="Minste overstappen" /> | |
</RadioGroup> | |
</DialogContent> | |
<Divider /> | |
<DialogContent style={{ flex: 'none' }}> | |
<FormControlLabel | |
control={<Checkbox checked={preferences.fewerStops} onChange={v => handleChange('fewerStops', v.target.checked)} name="fewerStops" />} | |
label="Zo min mogelijk tussenstops" | |
/> | |
</DialogContent> | |
<Divider /> | |
<DialogContent style={{ flex: 'none' }}> | |
<FormControlLabel | |
control={<Checkbox checked={preferences.excludeTrainsWithReservationRequired} onChange={v => handleChange('excludeTrainsWithReservationRequired', v.target.checked)} name="excludeTrainsWithReservationRequired" />} | |
label="Geen treinen met verplichte reservering" | |
/> | |
</DialogContent> | |
<Divider /> | |
<DialogContent style={{ flex: 'none' }}> | |
<FormControlLabel | |
control={<Checkbox checked={preferences.localTrainsOnly} onChange={v => handleChange('localTrainsOnly', v.target.checked)} name="localTrainsOnly" />} | |
label="Reis alleen met stoptrein(en)" | |
/> | |
</DialogContent> | |
<Divider /> | |
<DialogContent style={{ flex: 'none' }}> | |
<FormControlLabel | |
control={<Checkbox checked={preferences.searchForAccessibleTrip} onChange={v => handleChange('searchForAccessibleTrip', v.target.checked)} name="searchForAccessibleTrip" />} | |
label="Toegankelijk reizen" | |
/> | |
</DialogContent> | |
<Divider /> | |
<DialogContent style={{ flex: 'none' }}> | |
<Typography variant="body2" gutterBottom={true}>Aantal minuten overstaptijd:</Typography> | |
<Slider | |
value={preferences.addChangeTime} | |
onChange={(e, v) => handleChange('addChangeTime', v as number)} | |
min={0} | |
max={20} | |
step={5} | |
marks={marks} | |
valueLabelDisplay="off" | |
color="secondary" | |
/> | |
</DialogContent> | |
<Divider /> | |
</Dialog> | |
} | |
export default SettingsDialog |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// <reference types="googlemaps" /> | |
import React, { useCallback, useEffect, useState } from 'react' | |
import { GoogleMap, LoadScript } from '@react-google-maps/api' | |
import { Step, Trip } from '../types' | |
import { Box, Divider, Icon, LinearProgress, Typography } from '@material-ui/core' | |
import moment from 'moment' | |
import { ToggleButton, ToggleButtonGroup } from '@material-ui/lab' | |
const { REACT_APP_GOOGLE_API_KEY } = process.env | |
type TrackerProps = { | |
timeToStation?: number, | |
distanceToStation?: number, | |
mode: 'walking' | 'bicycling' | 'driving', | |
onModeChange: (mode: 'walking' | 'bicycling' | 'driving') => void, | |
trips: Trip[], start: Step, finish: Step, when: 'departure' | 'arrival' | |
} | |
const modes = { | |
'walking': { label: 'lopen', icon: 'directions_walk' }, | |
'bicycling': { label: 'fietsen', icon: 'directions_bike' }, | |
'driving': { label: 'rijden', icon: 'directions_car' } | |
} | |
let directionsService: google.maps.DirectionsService | |
let directionsRenderer: google.maps.DirectionsRenderer | |
let myLocation: google.maps.Marker | |
function Tracker({ trips, start, finish, when, timeToStation, distanceToStation, onModeChange, mode }: TrackerProps) { | |
const [loading, setLoading] = useState(true) | |
useEffect(() => { | |
navigator.geolocation.watchPosition(position => { | |
// TODO: check distance with start location, if distance less than 10m switch to: did you miss your train? | |
myLocation && myLocation.setPosition({ lat: position.coords.latitude, lng: position.coords.longitude }) | |
}) | |
}, []) | |
const handleModeChange = useCallback((mode: keyof typeof modes, updateParent = false) => { | |
setLoading(true) | |
if (updateParent) { | |
onModeChange(mode) | |
} | |
navigator.geolocation.getCurrentPosition(position => { | |
const request = { | |
origin: new google.maps.LatLng(position.coords.latitude, position.coords.longitude), | |
destination: new google.maps.LatLng(start.origin?.position.lat || 0, start.origin?.position.long || 0), | |
travelMode: mode.toUpperCase() as google.maps.TravelMode | |
} | |
directionsService.route(request, function (result: any, status: any) { | |
if (status === 'OK') { | |
console.log(result) | |
directionsRenderer.setDirections(result) | |
setLoading(false) | |
} | |
}) | |
}) | |
}, [start, onModeChange]) | |
const handleMapLoad = useCallback((map: any) => { | |
directionsService = new google.maps.DirectionsService() | |
directionsRenderer = new google.maps.DirectionsRenderer() | |
directionsRenderer.setMap(map) | |
myLocation = new google.maps.Marker({ | |
clickable: false, | |
zIndex: 999, | |
map | |
}) | |
handleModeChange(mode) | |
}, [handleModeChange, mode]) | |
return <Box height="100%" display="flex" flexDirection="column"> | |
<Box flex={1}> | |
<LoadScript googleMapsApiKey={REACT_APP_GOOGLE_API_KEY || ''}> | |
<GoogleMap | |
mapContainerStyle={{ width: '100%', height: '100%' }} | |
zoom={10} | |
onLoad={handleMapLoad} | |
> | |
</GoogleMap> | |
</LoadScript> | |
</Box> | |
{loading ? <LinearProgress color="secondary" variant="indeterminate" /> : <Divider />} | |
<Box padding={2} display="flex" flexDirection="column" alignItems="center"> | |
<ToggleButtonGroup value={mode} onChange={(e, v) => handleModeChange(v, true)} exclusive={true} style={{ marginBottom: 16 }}> | |
{Object.keys(modes).map(key => <ToggleButton key={key} value={key}> | |
<Icon>{modes[key as keyof typeof modes].icon}</Icon> | |
{modes[key as keyof typeof modes].label} | |
</ToggleButton>)} | |
</ToggleButtonGroup> | |
<Typography>{moment.duration(timeToStation || 0, 'seconds').humanize()} {modes[mode].label} naar station {start.origin?.name}</Typography> | |
</Box> | |
</Box> | |
} | |
export default Tracker |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment