UNPKG

mobility-toolbox-js

Version:

Toolbox for JavaScript applications in the domains of mobility and logistics.

647 lines (646 loc) 27 kB
import debounce from 'lodash.debounce'; import throttle from 'lodash.throttle'; import { buffer, containsCoordinate, intersects } from 'ol/extent'; import GeoJSON from 'ol/format/GeoJSON'; import { fromLonLat } from 'ol/proj'; import { RealtimeAPI, RealtimeModes } from '../../api'; import realtimeStyle from '../styles/realtimeStyle'; import { MOTS_ONLY_RAIL, MOTS_WITHOUT_CABLE, styleOptionsForMot, } from './realtimeStyleUtils'; import renderTrajectories from './renderTrajectories'; /** * Basic style options used by default in the RealtimeEngine. */ export const defaultStyleOptions = Object.assign({ delayDisplay: 300000, delayOutlineColor: '#000', getArrowSize: (trajectory, viewState, radius = 0) => { return [(radius * 3) / 4, radius]; }, getColor: () => { return '#000'; }, getDelayColor: () => { return '#000'; }, getDelayFont: (traj, viewState, fontSize) => { return `bold ${fontSize}px arial, sans-serif`; }, getDelayText: () => { return ''; }, getDelayTextColor: () => { return '#000'; }, getImage: () => { return null; }, getMaxRadiusForStrokeAndDelay: () => { return 7; }, getMaxRadiusForText: () => { return 10; }, getRadius: () => { return 5; }, getText: ((traj) => { var _a, _b; return ((_b = (_a = traj === null || traj === void 0 ? void 0 : traj.properties) === null || _a === void 0 ? void 0 : _a.line) === null || _b === void 0 ? void 0 : _b.name) || 'U'; }), getTextColor: () => { return '#fff'; }, getTextFont: (trajectory, viewState, fontSize) => { return `bold ${fontSize}px arial, sans-serif`; }, getTextSize: () => { return 14; }, showDelayBg: true, showDelayText: true, showHeading: false, useDelayStyle: false }, styleOptionsForMot); /** * This class is responsible for drawing trajectories from a realtime API in a canvas, * depending on the map's view state and at a specific time. * * This class is totally agnostic from Maplibre or OpenLayers and must stay taht way. */ class RealtimeEngine { get mode() { return this._mode; } set mode(newMode) { var _a, _b; if (newMode === this._mode) { return; } this._mode = newMode; if ((_b = (_a = this.api) === null || _a === void 0 ? void 0 : _a.wsApi) === null || _b === void 0 ? void 0 : _b.open) { this.stop(); this.start(); } } get speed() { return this._speed; } set speed(newSpeed) { this._speed = newSpeed; this.start(); } get style() { return this._style; } set style(newStyle) { this._style = newStyle; this.renderTrajectories(); } get time() { return this._time; } set time(newTime) { this._time = (newTime === null || newTime === void 0 ? void 0 : newTime.getTime) ? newTime : new Date(newTime); this.renderTrajectories(); } constructor(options) { var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s; this.isIdle = false; this.getViewState = () => { return {}; }; this.shouldRender = () => { return true; }; this._mode = options.mode || RealtimeModes.TOPOGRAPHIC; this._speed = (_a = options.speed) !== null && _a !== void 0 ? _a : 1; // If live property is true. The speed is ignored. this._style = (_b = options.style) !== null && _b !== void 0 ? _b : realtimeStyle; this._time = (_c = options.time) !== null && _c !== void 0 ? _c : new Date(); this.api = (_d = options.api) !== null && _d !== void 0 ? _d : new RealtimeAPI(options); this.bboxParameters = options.bboxParameters; this.canvas = (_e = options.canvas) !== null && _e !== void 0 ? _e : (typeof document !== 'undefined' ? document.createElement('canvas') : undefined); this.debug = (_f = options.debug) !== null && _f !== void 0 ? _f : false; this.filter = options.filter; this.hoverVehicleId = options.hoverVehicleId; /** * If true. The layer will always use Date.now() on the next tick to render the trajectories. * When true, setting the time property has no effect. */ this.live = options.live !== false; this.minZoomInterpolation = (_g = options.minZoomInterpolation) !== null && _g !== void 0 ? _g : 8; // Min zoom level from which trains positions are not interpolated. this.pixelRatio = (_h = options.pixelRatio) !== null && _h !== void 0 ? _h : (typeof window !== 'undefined' ? window.devicePixelRatio : 1); this.selectedVehicleId = options.selectedVehicleId; this.sort = options.sort; /** * Custom options to pass as last parameter of the style function. */ this.styleOptions = Object.assign(Object.assign({}, defaultStyleOptions), ((_j = options.styleOptions) !== null && _j !== void 0 ? _j : {})); this.tenant = (_k = options.tenant) !== null && _k !== void 0 ? _k : ''; // sbb,sbh or sbm this.trajectories = {}; this.useDebounce = (_l = options.useDebounce) !== null && _l !== void 0 ? _l : false; this.useRequestAnimationFrame = (_m = options.useRequestAnimationFrame) !== null && _m !== void 0 ? _m : false; this.useThrottle = options.useThrottle !== false; // the default behavior this.getViewState = (_o = options.getViewState) !== null && _o !== void 0 ? _o : (() => { return {}; }); this.shouldRender = (_p = options.shouldRender) !== null && _p !== void 0 ? _p : (() => { return true; }); this.getRefreshTimeInMs = (_q = options.getRefreshTimeInMs) !== null && _q !== void 0 ? _q : this.getRefreshTimeInMs.bind(this); this.onRender = options.onRender; this.onIdle = options.onIdle; this.onStart = options.onStart; this.onStop = options.onStop; this.format = new GeoJSON(); // Mots by zoom // Server will block non train before zoom 9 this.motsByZoom = (_r = options.motsByZoom) !== null && _r !== void 0 ? _r : [ MOTS_ONLY_RAIL, MOTS_ONLY_RAIL, MOTS_ONLY_RAIL, MOTS_ONLY_RAIL, MOTS_ONLY_RAIL, MOTS_ONLY_RAIL, MOTS_ONLY_RAIL, MOTS_ONLY_RAIL, MOTS_ONLY_RAIL, MOTS_WITHOUT_CABLE, MOTS_WITHOUT_CABLE, ]; this.getMotsByZoom = (zoom) => { if (options.getMotsByZoom) { return options.getMotsByZoom(zoom, this.motsByZoom); } if (zoom > this.motsByZoom.length - 1) { return this.motsByZoom[this.motsByZoom.length - 1]; } return this.motsByZoom[zoom]; }; // Generalization levels by zoom this.generalizationLevelByZoom = options.generalizationLevelByZoom || []; this.getGeneralizationLevelByZoom = (zoom) => { if (options.getGeneralizationLevelByZoom) { return options.getGeneralizationLevelByZoom(zoom, this.generalizationLevelByZoom); } if (zoom > this.generalizationLevelByZoom.length - 1) { return this.generalizationLevelByZoom[this.generalizationLevelByZoom.length - 1]; } return this.generalizationLevelByZoom[zoom]; }; // Graph by zoom this.graphByZoom = (_s = options.graphByZoom) !== null && _s !== void 0 ? _s : []; this.getGraphByZoom = (zoom) => { var _a, _b; if (options.getGraphByZoom) { return options.getGraphByZoom(zoom, this.graphByZoom); } if (zoom > this.graphByZoom.length - 1) { return (_a = this.graphByZoom) === null || _a === void 0 ? void 0 : _a[this.graphByZoom.length - 1]; } return (_b = this.graphByZoom) === null || _b === void 0 ? void 0 : _b[zoom]; }; // Render time interval by zoom this.renderTimeIntervalByZoom = options.renderTimeIntervalByZoom || [ 100000, 50000, 40000, 30000, 20000, 15000, 10000, 5000, 2000, 1000, 400, 300, 250, 180, 90, 60, 50, 50, 50, 50, 50, ]; this.getRenderTimeIntervalByZoom = (zoom) => { if (options.getRenderTimeIntervalByZoom) { return options.getRenderTimeIntervalByZoom(zoom, this.renderTimeIntervalByZoom); } return this.renderTimeIntervalByZoom[zoom]; }; // This property will call api.setBbox on each movend event this.isUpdateBboxOnMoveEnd = options.isUpdateBboxOnMoveEnd !== false; // Define throttling and debounce render function this.throttleRenderTrajectories = throttle(this.renderTrajectoriesInternal, 50, { leading: false, trailing: true }); this.debounceRenderTrajectories = debounce(this.renderTrajectoriesInternal, 50, { leading: true, maxWait: 5000, trailing: true }); this.renderState = { center: [0, 0], rotation: 0, zoom: undefined, }; this.onTrajectoryMessage = this.onTrajectoryMessage.bind(this); this.onDeleteTrajectoryMessage = this.onDeleteTrajectoryMessage.bind(this); this.onDocumentVisibilityChange = this.onDocumentVisibilityChange.bind(this); } /** * Add a trajectory. * @param {RealtimeTrajectory} trajectory The trajectory to add. * @private */ addTrajectory(trajectory) { if (!this.trajectories) { this.trajectories = {}; } const id = trajectory.properties.train_id; if (id !== undefined) { this.trajectories[id] = trajectory; } this.renderTrajectories(); } attachToMap() { // To avoid browser hanging when the tab is not visible for a certain amount of time, // We stop the rendering and the websocket when hide and start again when show. document.addEventListener('visibilitychange', this.onDocumentVisibilityChange); } detachFromMap() { document.removeEventListener('visibilitychange', this.onDocumentVisibilityChange); this.stop(); if (this.canvas) { const context = this.canvas.getContext('2d'); if (context) { context.clearRect(0, 0, this.canvas.width, this.canvas.height); } } } /** * Get the duration before the next update depending on zoom level. * * @private */ getRefreshTimeInMs() { var _a, _b; const viewState = this.getViewState(); const zoom = (_a = viewState.zoom) !== null && _a !== void 0 ? _a : 0; const roundedZoom = zoom !== undefined ? Math.round(zoom) : -1; const timeStep = this.getRenderTimeIntervalByZoom(roundedZoom) || 25; const nextTick = Math.max(25, timeStep / (this.speed || 1)); const nextThrottleTick = Math.min(nextTick, 500); // TODO: see if this should go elsewhere. if (this.useThrottle) { this.throttleRenderTrajectories = throttle(this.renderTrajectoriesInternal, nextThrottleTick, { leading: true, trailing: true }); } else if (this.useDebounce) { this.debounceRenderTrajectories = debounce(this.renderTrajectoriesInternal, nextThrottleTick, { leading: true, maxWait: 5000, trailing: true }); } if ((_b = this.api) === null || _b === void 0 ? void 0 : _b.buffer) { const [, size] = this.api.buffer; this.api.buffer = [nextThrottleTick, size]; } return nextTick; } /** * Get vehicle. * @param {function} filterFc A function use to filter results. * @return {Array<Object>} Array of vehicle. */ getVehicles(filterFc) { return ((this.trajectories && // @ts-expect-error good type must be defined Object.values(this.trajectories).filter(filterFc)) || []); } /** * Request feature information for a given coordinate. * * @param {ol/coordinate~Coordinate} coordinate Coordinate. * @param {Object} options Options See child classes to see which options are supported. * @param {number} [options.resolution=1] The resolution of the map. * @param {number} [options.nb=Infinity] The max number of vehicles to return. * @return {Promise<FeatureInfo>} Promise with features, layer and coordinate. */ getVehiclesAtCoordinate(coordinate, options) { const { resolution } = this.getViewState(); const { hitTolerance, nb } = options || {}; const extent = buffer([...coordinate, ...coordinate], (hitTolerance !== null && hitTolerance !== void 0 ? hitTolerance : 5) * (resolution !== null && resolution !== void 0 ? resolution : 1)); let trajectories = Object.values(this.trajectories || {}); if (this.sort) { // @ts-expect-error good type must be defined trajectories = trajectories.sort(this.sort); } const vehicles = []; // Theoretically 'for' is faster then 'for-of and it is important here // eslint-disable-next-line @typescript-eslint/prefer-for-of for (let i = 0; i < trajectories.length; i += 1) { const trajectory = trajectories[i]; const { coordinate: trajcoord } = trajectory.properties; if (trajcoord && containsCoordinate(extent, trajcoord)) { vehicles.push(trajectory); } if (vehicles.length === nb) { break; } } return { features: vehicles, type: 'FeatureCollection' }; } /** * Callback on websocket's deleted_vehicles channel events. * It removes the trajectory from the list. * * @private * @override */ onDeleteTrajectoryMessage(data) { if (!data.content) { return; } this.removeTrajectory(data.content); } onDocumentVisibilityChange() { if (document.hidden) { this.stop(); // Since we don't receive deleted_vehicles event when docuement // is hidden. We have to clean all the trajectories for a fresh // start when the document is visible again. this.trajectories = {}; } else { const viewState = this.getViewState(); if (!viewState.visible) { return; } this.start(); } } /** * Callback on websocket's trajectory channel events. * It adds a trajectory to the list. * * @private */ onTrajectoryMessage(data) { this.updateIdleState(); if (!data.content) { return; } const trajectory = data.content; const { geometry, properties: { raw_coordinates: rawCoordinates, time_since_update: timeSinceUpdate, }, } = trajectory; // ignore old events [SBAHNM-97] // @ts-expect-error can be undefined if (timeSinceUpdate < 0) { return; } // console.time(`onTrajectoryMessage${data.content.properties.train_id}`); if (this.purgeTrajectory(trajectory)) { return; } if (this.debug && this.mode === RealtimeModes.TOPOGRAPHIC && rawCoordinates) { // @ts-expect-error missing type definition trajectory.properties.olGeometry = this.format.readGeometry({ coordinates: fromLonLat(rawCoordinates), type: 'Point', }); } else { // @ts-expect-error missing type definition trajectory.properties.olGeometry = this.format.readGeometry(geometry); } // TODO Make sure the timeOffset is useful. May be we can remove it. // @ts-expect-error missing type definition trajectory.properties.timeOffset = Date.now() - data.timestamp; this.addTrajectory(trajectory); } /** * On zoomend we adjust the time interval of the update of vehicles positions. * * @private */ onZoomEnd() { this.startUpdateTime(); } /** * Remove all trajectories that are in the past. */ purgeOutOfDateTrajectories() { Object.entries(this.trajectories || {}).forEach(([key, trajectory]) => { var _a; const timeIntervals = (_a = trajectory === null || trajectory === void 0 ? void 0 : trajectory.properties) === null || _a === void 0 ? void 0 : _a.time_intervals; if (this.time && (timeIntervals === null || timeIntervals === void 0 ? void 0 : timeIntervals.length)) { const lastTimeInterval = timeIntervals[timeIntervals.length - 1][0]; if (lastTimeInterval < this.time.getTime()) { this.removeTrajectory(key); } } }); } /** * Determine if the trajectory is useless and should be removed from the list or not. * By default, this function exclude vehicles: * - that have their trajectory outside the current extent and * - that aren't in the MOT list. * * @param {RealtimeTrajectory} trajectory * @return {boolean} if the trajectory must be displayed or not. * @private */ purgeTrajectory(trajectory) { const viewState = this.getViewState(); const extent = viewState.extent; const { bounds, type } = trajectory.properties; if ((this.isUpdateBboxOnMoveEnd && extent && !intersects(extent, bounds)) || (this.mots && !this.mots.includes(type))) { this.removeTrajectory(trajectory); return true; } return false; } removeTrajectory(trajectoryOrId) { var _a; let id; if (typeof trajectoryOrId !== 'string') { id = (_a = trajectoryOrId === null || trajectoryOrId === void 0 ? void 0 : trajectoryOrId.properties) === null || _a === void 0 ? void 0 : _a.train_id; } else { id = trajectoryOrId; } if (id !== undefined && this.trajectories) { delete this.trajectories[id]; } } /** * Render the trajectories requesting an animation frame and cancelling the previous one. * This function must be overrided by children to provide the correct parameters. * * @param {boolean} noInterpolate If true trajectories are not interpolated but * drawn at the last known coordinate. Use this for performance optimization * during map navigation. * @private */ renderTrajectories(noInterpolate) { const viewState = this.getViewState(); if (this.requestId) { cancelAnimationFrame(this.requestId); this.requestId = undefined; } if (!(viewState === null || viewState === void 0 ? void 0 : viewState.center) || !(viewState === null || viewState === void 0 ? void 0 : viewState.extent) || !(viewState === null || viewState === void 0 ? void 0 : viewState.size)) { return; } if (!noInterpolate && this.useRequestAnimationFrame) { this.requestId = requestAnimationFrame(() => { this.renderTrajectoriesInternal(viewState, noInterpolate); }); } else if (!noInterpolate && this.useDebounce) { this.debounceRenderTrajectories(viewState, noInterpolate); } else if (!noInterpolate && this.useThrottle) { this.throttleRenderTrajectories(viewState, noInterpolate); } else { this.renderTrajectoriesInternal(viewState, noInterpolate); } } /** * Launch renderTrajectories. it avoids duplicating code in renderTrajectories method. * * @param {object} viewState The view state of the map. * @param {number[2]} viewState.center Center coordinate of the map in mercator coordinate. * @param {number[4]} viewState.extent Extent of the map in mercator coordinates. * @param {number[2]} viewState.size Size ([width, height]) of the canvas to render. * @param {number} [viewState.rotation = 0] Rotation of the map to render. * @param {number} viewState.resolution Resolution of the map to render. * @param {boolean} noInterpolate If true trajectories are not interpolated but * drawn at the last known coordinate. Use this for performance optimization * during map navigation. * @private */ renderTrajectoriesInternal(viewState, noInterpolate = false) { var _a; if (!this.trajectories || !this.shouldRender()) { return false; } let time = new Date(); if (this.live) { // Save the time internally, to keep trace of the time rendered for purge. this._time = time; } else if (this.time) { time = this.time; } const trajectories = Object.values(this.trajectories); // console.time('sort'); if (this.sort) { // @ts-expect-error type problem trajectories.sort(this.sort); } // console.timeEnd('sort'); if (!this.canvas || !this.style) { return true; } this.renderState = renderTrajectories(this.canvas, trajectories, this.style, Object.assign(Object.assign({}, viewState), { pixelRatio: this.pixelRatio || 1, time: time.getTime() }), Object.assign(Object.assign({}, this.styleOptions), { filter: this.filter, hoverVehicleId: this.hoverVehicleId, noInterpolate: (viewState.zoom || 0) < this.minZoomInterpolation ? true : noInterpolate, selectedVehicleId: this.selectedVehicleId })); (_a = this.onRender) === null || _a === void 0 ? void 0 : _a.call(this, this.renderState, viewState); // console.timeEnd('render'); return true; } setBbox() { var _a; this.updateIdleState(); const viewState = this.getViewState(); const extent = viewState.extent; const zoom = (_a = viewState.zoom) !== null && _a !== void 0 ? _a : 0; if (!extent || Number.isNaN(zoom)) { return; } // Clean trajectories before sending the new bbox // Purge trajectories: // - which are outside the extent // - when it's bus and zoom level is too low for them if (this.trajectories && extent && zoom) { const keys = Object.keys(this.trajectories); for (let i = keys.length - 1; i >= 0; i -= 1) { this.purgeTrajectory(this.trajectories[keys[i]]); } } // The backend only supports non float value const zoomFloor = Math.floor(zoom); if (!extent || Number.isNaN(zoomFloor)) { return; } // The extent does not need to be precise under meter, so we round floor/ceil the values. const [minX, minY, maxX, maxY] = extent; const bbox = [ Math.floor(minX), Math.floor(minY), Math.ceil(maxX), Math.ceil(maxY), zoomFloor, ]; /* @private */ this.generalizationLevel = this.getGeneralizationLevelByZoom(zoomFloor); if (this.generalizationLevel) { bbox.push(`gen=${this.generalizationLevel}`); } /* @private */ this.mots = this.getMotsByZoom(zoomFloor); if (this.mots) { bbox.push(`mots=${this.mots.toString()}`); } if (this.tenant) { bbox.push(`tenant=${this.tenant}`); } if (this.mode !== 'topographic') { bbox.push(`channel_prefix=${this.mode}`); } const graph = this.getGraphByZoom(zoomFloor); if (graph) { bbox.push(`graph=${graph}`); } if (this.bboxParameters) { Object.entries(this.bboxParameters).forEach(([key, value]) => { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions bbox.push(`${key}=${value}`); }); } // Extent and zoom level are mandatory. this.api.bbox = bbox; } start() { this.stop(); // Before starting to update trajectories, we remove trajectories that have // a time_intervals in the past, it will // avoid phantom train that are at the end of their route because we never // received the deleted_vehicle event because we have changed the browser tab. this.purgeOutOfDateTrajectories(); this.renderTrajectories(); this.startUpdateTime(); this.api.open(); this.api.subscribeTrajectory(this.mode, this.onTrajectoryMessage, undefined, this.isUpdateBboxOnMoveEnd); this.api.subscribeDeletedVehicles(this.mode, this.onDeleteTrajectoryMessage, undefined, this.isUpdateBboxOnMoveEnd); // Update the bbox on each move end if (this.isUpdateBboxOnMoveEnd) { this.setBbox(); } if (this.onStart) { this.onStart(this); } } /** * Start the clock. * @private */ startUpdateTime() { this.stopUpdateTime(); this.updateTimeDelay = this.getRefreshTimeInMs() || 0; this.updateTimeInterval = window.setInterval(() => { // When live=true, we update the time with new Date(); if (this.live) { this.time = new Date(); } else if (this.time && this.updateTimeDelay && this.speed) { this.time = new Date(this.time.getTime() + this.updateTimeDelay * this.speed); } }, this.updateTimeDelay); } stop() { this.api.unsubscribeTrajectory(this.onTrajectoryMessage); this.api.unsubscribeDeletedVehicles(this.onDeleteTrajectoryMessage); this.api.close(); if (this.onStop) { this.onStop(this); } } /** * Stop the clock. * @private */ stopUpdateTime() { if (this.updateTimeInterval) { clearInterval(this.updateTimeInterval); this.updateTimeInterval = undefined; } } updateIdleState() { this.isIdle = false; clearTimeout(this._idleTimeout); this._idleTimeout = window.setTimeout(() => { var _a; this.isIdle = true; (_a = this.onIdle) === null || _a === void 0 ? void 0 : _a.call(this, this); }, 1000); } } export default RealtimeEngine;