diff --git a/src/MonitorMapContainer.tsx b/src/MonitorMapContainer.tsx index d654a156..0f85c2ef 100644 --- a/src/MonitorMapContainer.tsx +++ b/src/MonitorMapContainer.tsx @@ -1,59 +1,35 @@ -import React, { FC, useEffect, useRef, useState } from 'react'; +import React, { FC } from 'react'; import cx from 'classnames'; import MonitorMap from './ui/monitorMap'; import { IMapSettings } from './util/Interfaces'; -import { changeTopics, startMqtt, stopMqtt } from './util/mqttUtils'; -import { useMergeState } from './util/utilityHooks'; +import { IDeparture } from './ui/MonitorRow'; interface IProps { preview?: boolean; mapSettings: IMapSettings; modal?: boolean; updateMap?: (settings: IMapSettings) => void; - topics?: string[]; - departures?: any; + departuresForMap?: Array; lang: string; + mqttProps?: any; } const MonitorMapContainer: FC = ({ preview, mapSettings, modal, updateMap, - topics, - departures, + departuresForMap, lang, + mqttProps, }) => { - const [state, setState] = useMergeState({ - client: undefined, - messages: [], - }); - const [started, setStarted] = useState(false); - const clientRef = useRef(null); - const topicRef = useRef(null); - - if (state.client) { - clientRef.current = state.client; - if (!started) { - setStarted(true); - } else if (topicRef.current.length === 0) { - // We have new topics and current topics are empty, so client needs to be updated - const settings = { - client: clientRef.current, - oldTopics: [], - options: topics, - }; - changeTopics(settings, topicRef); - } - } - useEffect(() => { - if ((topics && topics.length) || (!state.client && !started && topics)) { - startMqtt(topics, setState, clientRef, topicRef); - } - return () => { - stopMqtt(clientRef.current, topicRef.current); - }; - }, []); const isLandscape = true; - + const { + messages, + clientRef, + newTopics, + topicRef, + vehicleMarkerState, + setVehicleMarkerState, + } = mqttProps || { messages: [] }; return (
= ({ mapSettings={mapSettings} modal={modal} updateMap={updateMap} - messages={state.messages} + messages={messages} clientRef={clientRef} - newTopics={topics} + newTopics={newTopics} topicRef={topicRef} - departures={departures} + departuresForMap={departuresForMap} lang={lang} + vehicleMarkerState={vehicleMarkerState} + setVehicleMarkerState={setVehicleMarkerState} />
); diff --git a/src/defaultConfig.js b/src/defaultConfig.js index 1c4efbbb..248aa556 100644 --- a/src/defaultConfig.js +++ b/src/defaultConfig.js @@ -77,4 +77,5 @@ export default { inUse: false, }, lineCodeMaxLength: 7, // Maximum length of line code to show in the monitor, values larger than 7 are not supported by horizontal layouts + rtVehicleOffsetSeconds: 120, // How many seconds in the future the real-time vehicle should be shown }; diff --git a/src/monitorConfig.js b/src/monitorConfig.js index 972e53fd..56b37c41 100644 --- a/src/monitorConfig.js +++ b/src/monitorConfig.js @@ -55,6 +55,7 @@ export default { map: { inUse: false, }, + rtVehicleOffsetSeconds: 120, }, jyvaskyla: { fonts: { diff --git a/src/ui/CarouselContainer.tsx b/src/ui/CarouselContainer.tsx index 789407b8..ac1d6d9b 100644 --- a/src/ui/CarouselContainer.tsx +++ b/src/ui/CarouselContainer.tsx @@ -6,13 +6,8 @@ import { IDeparture } from './MonitorRow'; import MonitorAlertRow from './MonitorAlertRow'; import { getLayout } from '../util/getResources'; import cx from 'classnames'; -import uniqBy from 'lodash/uniqBy'; -import { - stopTimeAbsoluteDepartureTime, - stoptimeSpecificDepartureId, -} from '../util/monitorUtils'; +import { sortAndFilter } from '../util/monitorUtils'; import MonitorAlertRowStatic from './MonitorAlertRowStatic'; -import { getCurrentSeconds } from '../time'; interface IProps { stationDepartures: Array>>; // First array is for individual cards, next array for the two columns inside each card @@ -21,45 +16,9 @@ interface IProps { preview?: boolean; closedStopViews: Array; trainsWithTrack?: Array; + mqttProps: any; } -const sortAndFilter = (departures, trainsWithTrack) => { - const sortedAndFiltered = uniqBy( - departures.sort( - (stopTimeA, stopTimeB) => - stopTimeAbsoluteDepartureTime(stopTimeA) - - stopTimeAbsoluteDepartureTime(stopTimeB), - ), - departure => stoptimeSpecificDepartureId(departure), - ).filter( - departure => - departure.serviceDay + departure.realtimeDeparture >= getCurrentSeconds(), - ); - const sortedAndFilteredWithTrack = trainsWithTrack ? [] : sortedAndFiltered; - if (sortedAndFiltered.length > 0 && trainsWithTrack) { - sortedAndFiltered.forEach(sf => { - const trackDataFound = trainsWithTrack.filter( - tt => - (tt.lineId === sf.trip.route.shortName || - tt.trainNumber.toString() === sf.trip.route.shortName) && - tt.timeInSecs === sf.serviceDay + sf.scheduledDeparture, - ); - if (trackDataFound.length === 0) { - sortedAndFilteredWithTrack.push({ ...sf }); - } else { - sortedAndFilteredWithTrack.push({ - ...sf, - stop: { - ...sf.stop, - platformCode: trackDataFound[0].track, - }, - }); - } - }); - } - return sortedAndFilteredWithTrack; -}; - const CarouselContainer: FC = ({ stopDepartures, stationDepartures, @@ -67,6 +26,7 @@ const CarouselContainer: FC = ({ preview = false, closedStopViews, trainsWithTrack, + mqttProps, }) => { const { cards: views, languages, mapSettings } = useContext(MonitorContext); const mapLanguage = languages.length === 1 ? languages[0] : 'fi'; @@ -111,6 +71,12 @@ const CarouselContainer: FC = ({ const index = Math.floor(current / 2) % finalViews.length; + // show vehicles that have passed the stop + const departuresForMap = [ + ...stationDepartures[index][0], + ...stopDepartures[index][0], + ]; + const departures = [ sortAndFilter( [...stationDepartures[index][0], ...stopDepartures[index][0]], @@ -121,63 +87,6 @@ const CarouselContainer: FC = ({ trainsWithTrack, ), ]; - let initialTopics = []; - if (mapSettings?.showMap) { - // Todo. This is a hacky solution to easiest way of figuring out all the departures. - // Map keeps record of all it's stops, so it has all their departures. This should be done - // more coherent way when there is time. - const allDep = []; - - for (let i = 0; i < views.length; i++) { - const element = [ - sortAndFilter( - [...stationDepartures[i][0], ...stopDepartures[i][0]], - trainsWithTrack, - ), - sortAndFilter( - [...stationDepartures[i][1], ...stopDepartures[i][1]], - trainsWithTrack, - ), - ]; - allDep.push(element); - } - - const mapDepartures = allDep - .map(o => o.flatMap(a => a)) - .reduce((a, b) => (a.length > b.length ? a : b)); - initialTopics = mapDepartures - .filter(t => t.realtime) - .map(dep => { - const feedId = dep.trip.gtfsId.split(':')[0]; - const topic = { - feedId: feedId, - route: dep.trip.route?.gtfsId?.split(':')[1], - tripId: dep.trip.gtfsId.split(':')[1], - shortName: dep.trip.route.shortName, - type: 3, - ...dep, - }; - if (feedId.toLowerCase() === 'hsl') { - const i = dep.stops.findIndex(d => dep.stop.gtfsId === d.gtfsId); - if (i !== dep.stops.length - 1) { - const additionalStop = dep.stops[i + 1]; - topic.additionalStop = additionalStop; - } - } - return topic; - }); - } - const topics = initialTopics; - initialTopics.forEach(t => { - if (t.additionalStop) { - const additionalTopic = { - ...t, - stop: t.additionalStop, - additionalStop: null, - }; - topics.push(additionalTopic); - } - }); const lan = languages[language] === 'en' ? 'fi' : languages[language]; // for easy testing of different layouts const newView = { @@ -256,8 +165,9 @@ const CarouselContainer: FC = ({ alertRowSpan={alertSpan} closedStopViews={closedStopViews} mapSettings={mapSettings} - topics={topics} + mqttProps={mqttProps} mapLanguage={mapLanguage} + departuresForMap={departuresForMap} /> ); }; diff --git a/src/ui/CarouselDataContainer.tsx b/src/ui/CarouselDataContainer.tsx index 53bbb58f..8fd848c8 100644 --- a/src/ui/CarouselDataContainer.tsx +++ b/src/ui/CarouselDataContainer.tsx @@ -1,4 +1,11 @@ -import React, { FC, useState, useEffect, useContext } from 'react'; +import React, { + FC, + useState, + useEffect, + useContext, + useRef, + useCallback, +} from 'react'; import { useQuery } from '@apollo/client'; import { GetDeparturesForStopsDocument, @@ -15,6 +22,16 @@ import { uniqBy } from 'lodash'; import { useTranslation } from 'react-i18next'; import CarouselContainer from './CarouselContainer'; import { MonitorContext } from '../contexts'; +import { + changeTopics, + startMqtt, + stopMqtt, + getMqttTopics, + setMqttTopics, +} from '../util/mqttUtils'; +import { useMergeState } from '../util/utilityHooks'; +import { ConfigContext } from '../contexts'; +import { DateTime } from 'luxon'; interface IProps { preview?: boolean; @@ -41,7 +58,7 @@ const CarouselDataContainer: FC = ({ setQueryError, queryError, }) => { - const { cards: views } = useContext(MonitorContext); + const { cards: views, mapSettings } = useContext(MonitorContext); const [t] = useTranslation(); const pollInterval = 30000; const emptyDepartureArrays = []; @@ -63,6 +80,9 @@ const CarouselDataContainer: FC = ({ const [stationsFetched, setStationsFetched] = useState(stationIds.length < 1); const [alerts, setAlerts] = useState([]); const [closedStopViews, setClosedStopViews] = useState([]); + const [topicsFound, setTopicsFound] = useState(false); + const [topicState, setTopicState] = useState({ topics: [], oldTopics: [] }); + const config = useContext(ConfigContext); const stationsState = useQuery(GetDeparturesForStationsDocument, { variables: { ids: stationIds, numberOfDepartures: largest }, @@ -77,6 +97,14 @@ const CarouselDataContainer: FC = ({ context: { clientName: 'default' }, }); const [forceUpdate, setforceUpdate] = useState(false); + + const handleTopicStateChange = useCallback( + data => { + setTopicState(data); + }, + [setTopicState], + ); + useEffect(() => { const stops = stopsState?.data?.stops; if (stopsState?.error && !queryError && queryError != undefined) { @@ -103,6 +131,17 @@ const CarouselDataContainer: FC = ({ ); setStopsFetched(true); setClosedStopViews(closedStopViews); + + setMqttTopics( + views, + mapSettings, + stationDepartures, + newDepartureArray, + trainsWithTrack, + config, + topicState, + handleTopicStateChange, + ); } // Force update interval for itineraries that needs to be filtered by timeShift setting. const intervalId = setInterval(() => { @@ -132,6 +171,17 @@ const CarouselDataContainer: FC = ({ uniqBy(filterEffectiveAlerts(arr), alert => alert.alertHeaderText), ); setStationsFetched(true); + + setMqttTopics( + views, + mapSettings, + newDepartureArray, + stopDepartures, + trainsWithTrack, + config, + topicState, + handleTopicStateChange, + ); } // Force update interval for itineraries that needs to be filtered by timeShift setting. const intervalId = setInterval(() => { @@ -140,9 +190,76 @@ const CarouselDataContainer: FC = ({ return () => clearInterval(intervalId); }, [stationsState.data, forceUpdate]); + const topics = + topicState.topics.length > 0 + ? topicState.topics + : getMqttTopics( + views, + mapSettings, + stationDepartures, + stopDepartures, + trainsWithTrack, + config.rtVehicleOffsetSeconds, + ); + + if (topics.length > 0 && !topicsFound) { + setTopicsFound(true); + } + + const [state, setState] = useMergeState({ + client: undefined, + messages: [], + }); + + const clientRef = useRef(null); + const topicRef = useRef(null); + const [vehicleMarkerState, setVehicleMarkerState] = useState(new Map()); + + const mqttStateHandler = data => { + setState(data); + }; + + const markerHandler = markerData => { + setVehicleMarkerState(markerData); + }; + + useEffect(() => { + if (state.client) { + clientRef.current = state.client; + if (topicRef.current.length === 0) { + // We have new topics and current topics are empty, so client needs to be updated + const settings = { + client: clientRef.current, + oldTopics: [], + options: topics, + }; + changeTopics(settings, topicRef); + } + } + }, [topics, state.client, topicRef.current?.length, clientRef]); + + useEffect(() => { + if ((topics && topics.length) || (!state.client && topics)) { + startMqtt(topics, mqttStateHandler, clientRef, topicRef); + } + return () => { + stopMqtt(clientRef.current, topicRef.current); + }; + }, [topicsFound]); // mqtt won't really start without topics + if (!stopsFetched || !stationsFetched) { return ; } + + const mqttProps = { + newTopics: topics, + messages: state.messages, + clientRef, + topicRef, + vehicleMarkerState, + setVehicleMarkerState: markerHandler, + }; + return ( = ({ preview={preview} closedStopViews={closedStopViews} trainsWithTrack={trainsWithTrack} + mqttProps={mqttProps} /> ); }; diff --git a/src/ui/Monitor.tsx b/src/ui/Monitor.tsx index 0d8c0f9f..7bd1277a 100644 --- a/src/ui/Monitor.tsx +++ b/src/ui/Monitor.tsx @@ -29,8 +29,9 @@ interface IProps { alertRowSpan: number; closedStopViews: Array; mapSettings?: IMapSettings; - topics?: Array; mapLanguage?: string; + mqttProps?: any; + departuresForMap?: Array; } let to; @@ -44,8 +45,9 @@ const Monitor: FC = ({ alertRowSpan, closedStopViews, mapSettings, - topics, mapLanguage, + mqttProps, + departuresForMap, }) => { const config = useContext(ConfigContext); const { cards } = useContext(MonitorContext); @@ -152,9 +154,9 @@ const Monitor: FC = ({ ) : ( { const alertElements = document.getElementsByClassName('single-alert'); - const elementArray = alertElements; let animationWidth = 0; - for (let i = 0; i < elementArray.length; i++) { + for (let i = 0; i < alertElements.length; i++) { if (orientation === 'vertical') { - animationWidth += elementArray[i].clientHeight + 10; + animationWidth += alertElements[i].clientHeight + 10; } else { - animationWidth += elementArray[i].clientWidth; + animationWidth += alertElements[i].clientWidth; } } return animationWidth; @@ -54,12 +53,15 @@ const MonitorAlertRow: FC = ({ // --------------------------------- useEffect(() => { updateAnimation(); - const to = setTimeout(() => setUpdate(false), 100); - window.addEventListener('resize', () => { + const handleResize = () => { updateAnimation(); - setTimeout(() => setUpdate(false), 100); - }); - return () => clearTimeout(to); + const resizeTo = setTimeout(() => setUpdate(false), 100); + return () => clearTimeout(resizeTo); + }; + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + }; }, []); const DEFAULT_LANGUAGE = 'fi'; diff --git a/src/ui/QueryError.tsx b/src/ui/QueryError.tsx index 03089a74..79e9bde2 100644 --- a/src/ui/QueryError.tsx +++ b/src/ui/QueryError.tsx @@ -30,7 +30,8 @@ const QueryError: FC = ({ setQueryError, preview }) => { }); }, 15000); return () => { - clearTimeout(id); + clearTimeout(to); + clearInterval(id); controller.abort(); }; }, []); diff --git a/src/ui/WithDatabaseConnection.tsx b/src/ui/WithDatabaseConnection.tsx index d45f7ad5..3d4e4db8 100644 --- a/src/ui/WithDatabaseConnection.tsx +++ b/src/ui/WithDatabaseConnection.tsx @@ -1,4 +1,4 @@ -import React, { FC, useState, useEffect } from 'react'; +import React, { FC, useState, useEffect, useCallback } from 'react'; import monitorAPI from '../api'; import { ISides, ITitle, ICard } from '../util/Interfaces'; import CarouselDataContainer from './CarouselDataContainer'; @@ -50,6 +50,11 @@ const WithDatabaseConnection: FC = ({ location }) => { stations: undefined, stops: undefined, }); + + const errorHandler = error => { + setQueryError(error); + }; + useEffect(() => { if (location && !location?.state?.view?.cards) { const { url, cont: hash } = getParams(location.search); @@ -98,7 +103,7 @@ const WithDatabaseConnection: FC = ({ location }) => { if (queryError) { return ( - + ); } @@ -123,13 +128,13 @@ const WithDatabaseConnection: FC = ({ location }) => { ) : ( )} diff --git a/src/ui/monitorMap.tsx b/src/ui/monitorMap.tsx index ed4ffce5..2562022e 100644 --- a/src/ui/monitorMap.tsx +++ b/src/ui/monitorMap.tsx @@ -1,6 +1,6 @@ import L, { LatLng } from 'leaflet'; import 'leaflet/dist/leaflet.css'; -import React, { useEffect, FC, useContext, useState } from 'react'; +import React, { useEffect, FC, useContext, useState, useRef } from 'react'; import ReactDOMServer from 'react-dom/server'; import Icon from './Icon'; import { ConfigContext } from '../contexts'; @@ -15,6 +15,7 @@ import VehicleIcon from '../Vehicleicon'; import { DateTime } from 'luxon'; import { changeTopics } from '../util/mqttUtils'; import monitorAPI from '../api'; +import { IDeparture } from './MonitorRow'; interface IProps { preview?: boolean; @@ -25,8 +26,10 @@ interface IProps { clientRef: any; newTopics?: any; topicRef: any; - departures?: any; + departuresForMap?: Array; lang: string; + vehicleMarkerState?: any; + setVehicleMarkerState?: any; } const getVehicleIcon = message => { const { heading, shortName, color } = message; @@ -66,8 +69,7 @@ function shouldShowVehicle(message, direction, tripStart, pattern, headsign) { } function getVehicle(departures, id) { - const flatDeps = departures.flat(); - const veh = flatDeps.find(d => d.trip.route.gtfsId === id); + const veh = departures.find(d => d.trip.route.gtfsId === id); if (veh) { const vehicleProps = { direction: veh.trip.directionId, @@ -88,15 +90,16 @@ const MonitorMap: FC = ({ clientRef, newTopics, topicRef, - departures, + departuresForMap, lang, + vehicleMarkerState, + setVehicleMarkerState, }) => { const config = useContext(ConfigContext); - const [map, setMap] = useState(); + const mapRef = useRef(null); const [vehicleMarkers, setVehicleMarkers] = useState([]); - const feed = newTopics && newTopics[0]?.feedId.toLowerCase(); - const EXPIRE_TIME_SEC = feed === 'hsl' ? 10 : 120; // HSL Uses different broker and we need to handle HSL messages differently - const icons = mapSettings.stops.map(stop => { + const EXPIRE_TIME_SEC = config.rtVehicleOffsetSeconds; // HSL Uses different broker and we need to handle HSL messages differently + const icons = mapSettings.stops?.map(stop => { const color = config.modeIcons.colors[ `${stop.mode @@ -119,69 +122,83 @@ const MonitorMap: FC = ({ useEffect(() => { const center = mapSettings?.center ? mapSettings.center - : mapSettings.bounds[0]; + : mapSettings.bounds?.[0]; const zoom = mapSettings.zoom ? mapSettings.zoom : 14; - if (!map) { - setMap( - L.map('map', { zoomControl: false, zoomAnimation: false }).setView( - center, - zoom, - ), + mapRef.current = L.map('map', { + zoomControl: false, + zoomAnimation: false, + }).setView(center, zoom); + + const map = mapRef.current; + map.setView(center, zoom); + monitorAPI.getMapSettings(lang).then((r: string) => { + L.tileLayer(r, { + attribution: + 'Map data © OpenStreetMap contributors', + maxZoom: 18, + }).addTo(map); + map.fitBounds(mapSettings.bounds); + icons.forEach(icon => + L.marker(icon.coords, { icon: icon.icon }).addTo(map), ); - } else { - monitorAPI.getMapSettings(lang).then((r: string) => { - L.tileLayer(r, { - attribution: - 'Map data © OpenStreetMap contributors', - maxZoom: 18, - }).addTo(map); - map.fitBounds(mapSettings.bounds); - icons.forEach(icon => - L.marker(icon.coords, { icon: icon.icon }).addTo(map), - ); - map.on('move', () => { - if (updateMap) { - const NE: Coordinate = [ - map.getBounds().getNorthEast().lat, - map.getBounds().getNorthEast().lng, - ]; - const SW: Coordinate = [ - map.getBounds().getSouthWest().lat, - map.getBounds().getSouthWest().lng, - ]; - const bounds: BoundingBox = [NE, SW]; - updateMap({ - center: map.getCenter(), - zoom: map.getZoom(), - bounds: bounds, - }); - } - }); - map.on('zoomend', () => { - if (updateMap) { - updateMap({ zoom: map.getZoom() }); - } - }); - return () => { - if (map && map !== undefined) { - map?.remove(); - } - }; + map.on('move', () => { + if (updateMap) { + const NE: Coordinate = [ + map.getBounds().getNorthEast().lat, + map.getBounds().getNorthEast().lng, + ]; + const SW: Coordinate = [ + map.getBounds().getSouthWest().lat, + map.getBounds().getSouthWest().lng, + ]; + const bounds: BoundingBox = [NE, SW]; + updateMap({ + center: map.getCenter(), + zoom: map.getZoom(), + bounds: bounds, + }); + } }); - } - }, [map]); + map.on('zoomend', () => { + if (updateMap) { + updateMap({ zoom: map.getZoom() }); + } + }); + }); + // } + return () => { + // Remove all vehicle markers from the map + vehicleMarkers.forEach(marker => { + marker.marker.remove(); + }); + + if (mapRef.current && mapRef.current !== undefined) { + // Remove all layers from the map + mapRef.current.eachLayer(layer => { + mapRef.current.removeLayer(layer); + }); + + mapRef.current.off(); + mapRef.current.remove(); + mapRef.current = null; + } + }; + }, []); useEffect(() => { - const markers = vehicleMarkers ? vehicleMarkers : []; + const markersOnMap = vehicleMarkers ? vehicleMarkers : []; const stopIDs = mapSettings.stops.map(stop => stop.gtfsId); const now = DateTime.now().toSeconds(); + const markerState = vehicleMarkerState; + const flatDeps = departuresForMap.flat(); messages.forEach(m => { const { id, lat, long, next_stop, route } = m; const nextStop = stopIDs.includes(next_stop); - const vehicle = getVehicle(departures, route); - const exists = markers.find(marker => { + const vehicle = getVehicle(flatDeps, route); + let existingMarker = markersOnMap.find(marker => { return marker.id === id; }); + const markerToRemove = markerState.get(id) || {}; let marker; const showVehicle = route.split(':')[0] === 'HSL' @@ -193,46 +210,68 @@ const MonitorMap: FC = ({ vehicle.headsign, ) : true; - if (exists && showVehicle) { - updateVehiclePosition(exists, getVehicleIcon(m), lat, long, now); - if (exists.nextStop) { - exists.passed = nextStop ? false : true; - } else if (nextStop && !exists.nextStop) { - exists.nextStop = true; - } - // exists.passed = nextStop ? false : undefined; - } else if (showVehicle && !exists) { + if (!!mapRef.current && showVehicle && !existingMarker) { marker = { id: id, marker: L.marker([lat, long], { icon: getVehicleIcon(m), }), }; - marker.marker.addTo(map); - markers.push(marker); + marker.marker.addTo(mapRef.current); + markersOnMap.push(marker); + existingMarker = marker; } - if (exists?.passed) { - // Expiry handling. Mark those vehicles that have passed stop for expiry. - // After a minute, remove vehicles from map. - const marker = markers.find(marker => marker.id === id); - if (marker) { - if (marker.expire) { - if (marker.expire <= now) { - marker.remove = true; - } else { - updateVehiclePosition(marker, getVehicleIcon(m), lat, long, now); - } - } else { - marker.expire = now + EXPIRE_TIME_SEC; - updateVehiclePosition(marker, getVehicleIcon(m), lat, long, now); + + if (existingMarker && showVehicle) { + updateVehiclePosition( + existingMarker, + getVehicleIcon(m), + lat, + long, + now, + ); + + if (markerToRemove.nextStop === true) { + markerToRemove.passed = nextStop ? false : true; + } else if (nextStop && !markerToRemove.nextStop) { + markerToRemove.id = id; + markerToRemove.nextStop = true; + } + + if (markerToRemove.passed === true) { + // Expiry handling. Mark those vehicles that have passed stop for expiry. + // After the set time limit, remove vehicles from map. + if (markerToRemove.id && !markerToRemove.expire) { + markerToRemove.expire = + DateTime.now().toSeconds() + EXPIRE_TIME_SEC; } } + + if (markerToRemove.id) { + markerState.set(markerToRemove.id, markerToRemove); + } + } + + if (existingMarker && showVehicle) { + updateVehiclePosition( + existingMarker, + getVehicleIcon(m), + lat, + long, + DateTime.now().toSeconds(), + ); } }); - // Handle vehicle removoal + + // Handle vehicle removal + const currentSeconds = DateTime.now().toSeconds(); const markersToRemove = []; - const filtered = markers.filter(m => { - if (now - m.lastUpdatedAt >= EXPIRE_TIME_SEC || m.remove) { + markersOnMap.filter(m => { + if ( + markerState.get(m.id)?.expire <= currentSeconds || + currentSeconds - m.lastUpdatedAt >= EXPIRE_TIME_SEC // Remove vehicles that have not been updated for a while (likely reached the end of the line) + ) { + markerState.delete(m.id); markersToRemove.push(m.marker); return false; } @@ -240,8 +279,19 @@ const MonitorMap: FC = ({ }); if (markersToRemove.length > 0) { for (let index = 0; index < markersToRemove.length; index++) { - map.removeLayer(markersToRemove[index]); + const marker = markersToRemove[index]; + marker.remove(); + mapRef.current.removeLayer(marker); } + } + if (markerState) { + setVehicleMarkerState(markerState); + } + setVehicleMarkers(markersOnMap); + }, [messages, mapRef.current]); + + useEffect(() => { + if (topicRef?.current && clientRef?.current && newTopics) { const settings = { oldTopics: topicRef.current, client: clientRef.current, @@ -249,9 +299,7 @@ const MonitorMap: FC = ({ }; changeTopics(settings, topicRef); } - - setVehicleMarkers(filtered); - }, [messages]); + }, [newTopics, topicRef]); return (
{ + const sortedAndFiltered = uniqBy( + departures.sort( + (stopTimeA, stopTimeB) => + stopTimeAbsoluteDepartureTime(stopTimeA) - + stopTimeAbsoluteDepartureTime(stopTimeB), + ), + departure => stoptimeSpecificDepartureId(departure), + ).filter( + departure => + departure.serviceDay + departure.realtimeDeparture + offsetSeconds >= + getCurrentSeconds(), + ); + const sortedAndFilteredWithTrack = trainsWithTrack ? [] : sortedAndFiltered; + if (sortedAndFiltered.length > 0 && trainsWithTrack) { + sortedAndFiltered.forEach(sf => { + const trackDataFound = trainsWithTrack.filter( + tt => + (tt.lineId === sf.trip.route.shortName || + tt.trainNumber.toString() === sf.trip.route.shortName) && + tt.timeInSecs === sf.serviceDay + sf.scheduledDeparture, + ); + if (trackDataFound.length === 0) { + sortedAndFilteredWithTrack.push({ ...sf }); + } else { + sortedAndFilteredWithTrack.push({ + ...sf, + stop: { + ...sf.stop, + platformCode: trackDataFound[0].track, + }, + }); + } + }); + } + return sortedAndFilteredWithTrack; +}; diff --git a/src/util/mqttUtils.ts b/src/util/mqttUtils.ts index 1a4fed82..d8515a1c 100644 --- a/src/util/mqttUtils.ts +++ b/src/util/mqttUtils.ts @@ -1,6 +1,7 @@ import mqtt from 'mqtt/dist/mqtt'; import settings from './realTimeUtils'; import { DateTime } from 'luxon'; +import { sortAndFilter } from '../util/monitorUtils'; export const startMqtt = (routes, setState, setClient, topicRef) => { if (routes?.length === 0) { @@ -56,7 +57,11 @@ export const startMqtt = (routes, setState, setClient, topicRef) => { enqueueMessage(parsedMessages); } }); - setInterval(processBatch, batchDelay); + const intervalId = setInterval(processBatch, batchDelay); + + client.on('close', () => { + clearInterval(intervalId); + }); }); }; @@ -304,3 +309,109 @@ function compareTopics(oldTopics, newTopics, topicRef) { return { toSubscribe, toUnsubscribe }; } + +export function getMqttTopics( + views, + mapSettings, + stationDepartures, + stopDepartures, + trainsWithTrack, + offsetSeconds, +) { + let initialTopics = []; + if (mapSettings?.showMap) { + // Todo. This is a hacky solution to easiest way of figuring out all the departures. + // Map keeps record of all it's stops, so it has all their departures. This should be done + // more coherent way when there is time. + const allDep = []; + + const offsetWithBuffer = offsetSeconds + 120; // data might be a bit older than real time data so we add a buffer to keep the vehicles moving + for (let i = 0; i < views.length; i++) { + const element = [ + sortAndFilter( + [...stationDepartures[i][0], ...stopDepartures[i][0]], + trainsWithTrack, + offsetWithBuffer, + ), + sortAndFilter( + [...stationDepartures[i][1], ...stopDepartures[i][1]], + trainsWithTrack, + offsetWithBuffer, + ), + ]; + allDep.push(element); + } + + const departuresForMap = allDep + .map(o => o.flatMap(a => a)) + .reduce((a, b) => (a.length > b.length ? a : b)); + initialTopics = departuresForMap + .filter(t => t.realtime) + .map(dep => { + const feedId = dep.trip.gtfsId.split(':')[0]; + const topic = { + feedId: feedId, + route: dep.trip.route?.gtfsId?.split(':')[1], + tripId: dep.trip.gtfsId.split(':')[1], + shortName: dep.trip.route.shortName, + type: 3, + ...dep, + }; + if (feedId.toLowerCase() === 'hsl') { + const i = dep.stops.findIndex(d => dep.stop.gtfsId === d.gtfsId); + if (i !== dep.stops.length - 1) { + const additionalStop = dep.stops[i + 1]; + topic.additionalStop = additionalStop; + } + } + return topic; + }); + } + const topics = initialTopics; + initialTopics.forEach(t => { + if (t.additionalStop) { + const additionalTopic = { + ...t, + stop: t.additionalStop, + additionalStop: null, + }; + topics.push(additionalTopic); + } + }); + return topics; +} + +export function setMqttTopics( + views, + mapSettings, + stationDepartures, + stopDepartures, + trainsWithTrack, + config, + topicState, + handleTopicStateChange, +) { + const newTopics = getMqttTopics( + views, + mapSettings, + stationDepartures, + stopDepartures, + trainsWithTrack, + config.rtVehicleOffsetSeconds, + ); + const oldTopics = topicState.topics; + // Keep topics that are still relevant and not in newTopics + oldTopics.forEach(topic => { + if ( + !newTopics.find(t => t.tripId === topic.tripId) && + topic.serviceDay + + topic.scheduledDeparture + + config.rtVehicleOffsetSeconds > + DateTime.now().toSeconds() + ) { + newTopics.push(topic); + } + }); + + handleTopicStateChange({ topics: newTopics, oldTopics: oldTopics }); +}