initial commit
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/config.js
|
||||||
|
/_testdata
|
||||||
|
.parcel-cache
|
||||||
|
/node_modules
|
||||||
|
/.idea
|
||||||
|
/dist
|
||||||
444
client/MhdMapClient.js
Normal file
444
client/MhdMapClient.js
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
import L from 'leaflet';
|
||||||
|
|
||||||
|
import '@bagage/leaflet.restoreview/leaflet.restoreview.js';
|
||||||
|
|
||||||
|
import 'leaflet-search';
|
||||||
|
import 'leaflet-layervisibility';
|
||||||
|
|
||||||
|
// import glify from 'leaflet.glify';
|
||||||
|
|
||||||
|
const VehicleIcon = L.Icon.extend({
|
||||||
|
options: {
|
||||||
|
shadowUrl: null,
|
||||||
|
iconSize: [38, 95],
|
||||||
|
iconAnchor: [22, 94],
|
||||||
|
popupAnchor: [-3, -76]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const VehicleTypes = {
|
||||||
|
'bus': 'Autobusy',
|
||||||
|
'tram': 'Električky',
|
||||||
|
'trolleybus': 'Trolejbusy',
|
||||||
|
'stop': 'Zastávky',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class MhdMapClient {
|
||||||
|
|
||||||
|
constructor(cont) {
|
||||||
|
this.ws = null;
|
||||||
|
this.wsMessages = {};
|
||||||
|
this.cont = cont;
|
||||||
|
this.mapCont = cont.querySelector('#map');
|
||||||
|
this.map = null;
|
||||||
|
this.markers = {};
|
||||||
|
this.markerLayers = {};
|
||||||
|
this.baseMapLayer = null;
|
||||||
|
this.layerControl = null;
|
||||||
|
this.searchControl = null;
|
||||||
|
this.searchableLayer = null;
|
||||||
|
this.currentLinePoints = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
this.startMap();
|
||||||
|
|
||||||
|
const loc = document.location;
|
||||||
|
this.wsUrl = 'ws' + (loc.protocol === 'https:' ? 's' : '')+ '://' + loc.host + loc.pathname + '/ws';
|
||||||
|
|
||||||
|
await this.connectWs();
|
||||||
|
setInterval(() => {
|
||||||
|
if (this.ws === null) {
|
||||||
|
this.connectWs();
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
startMap() {
|
||||||
|
this.map = L.map(this.mapCont, {
|
||||||
|
center: [48.1530667, 17.1033202],
|
||||||
|
zoom: 13,
|
||||||
|
minZoom: 11,
|
||||||
|
maxZoom: 19,
|
||||||
|
zoomDelta: 0.5,
|
||||||
|
zoomSnap: 0.5,
|
||||||
|
doubleClickZoom: false,
|
||||||
|
preferCanvas: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.baseMapLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap',
|
||||||
|
maxZoom: 19
|
||||||
|
}).addTo(this.map);
|
||||||
|
|
||||||
|
this.georefLayer = L.tileLayer('https://maps.georeferencer.com/georeferences/e886f898-04e0-4479-8cd3-bbe4ae7537b3/2022-06-22T09:24:26.012877Z/map/{z}/{x}/{y}.png?key=c0WCCJEkXmpgh7TxHg2U', {
|
||||||
|
maxZoom: 17
|
||||||
|
});
|
||||||
|
|
||||||
|
this.layerControl = L.control.layers({
|
||||||
|
"OpenStreetMaps": this.baseMapLayer,
|
||||||
|
"MHD mapa elek": this.georefLayer,
|
||||||
|
}, {}).addTo(this.map);
|
||||||
|
|
||||||
|
this.searchableLayer = L.layerGroup();
|
||||||
|
this.searchControl = new L.Control.Search({
|
||||||
|
layer: this.searchableLayer,
|
||||||
|
buildTip: (text, val) => {
|
||||||
|
const veh = val.layer?.options?.data?.vehicle;
|
||||||
|
if (veh) {
|
||||||
|
let lineNumber = veh.lineNumber || '?';
|
||||||
|
return `<a href="#" class="result-vehicle veh-type-${veh.type}">
|
||||||
|
<span><b>${text}</b> @ <span class="mhd-linenr veh-type-${veh.type}">${lineNumber}</span></span>
|
||||||
|
<img src="${document.location.pathname}/imhd/icons/${veh.img}.png" alt="" />
|
||||||
|
</a>`;
|
||||||
|
} else {
|
||||||
|
return `<a href="#">${text}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// return text;
|
||||||
|
},
|
||||||
|
marker: false,
|
||||||
|
moveToLocation: (latlng, title, map) => {
|
||||||
|
map.setView(latlng.layer.getLatLng(), map.getZoom());
|
||||||
|
latlng.layer.fire('click');
|
||||||
|
}
|
||||||
|
})/*.addTo(this.map);*/
|
||||||
|
this.map.addControl(this.searchControl);
|
||||||
|
|
||||||
|
/*this.searchControl.on('search:locationfound', function(e) {
|
||||||
|
console.log(e);
|
||||||
|
if(e.layer._popup)
|
||||||
|
e.layer.openPopup();
|
||||||
|
|
||||||
|
}).*/
|
||||||
|
|
||||||
|
this.map.on('popupopen', this._popupOpenEvent.bind(this));
|
||||||
|
this.map.on('popupclose', this._popupCloseEvent.bind(this));
|
||||||
|
|
||||||
|
this.map.restoreView();
|
||||||
|
|
||||||
|
this.map.on('click', event => {
|
||||||
|
if (!window.over || !event.originalEvent.ctrlKey && !event.originalEvent.shiftKey)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const clicked = this.map.mouseEventToLatLng(event.originalEvent);
|
||||||
|
const bounds = window.over.getBounds();
|
||||||
|
if (event.originalEvent.ctrlKey) {
|
||||||
|
window.over.setBounds([
|
||||||
|
bounds.getSouthWest(), clicked
|
||||||
|
]);
|
||||||
|
} else if (event.originalEvent.shiftKey) {
|
||||||
|
window.over.setBounds([
|
||||||
|
clicked, bounds.getNorthEast()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
console.log(window.over.getBounds());
|
||||||
|
});
|
||||||
|
|
||||||
|
this.map.on('baselayerchange', layer => {
|
||||||
|
if (layer.layer === this.georefLayer) {
|
||||||
|
this.markerLayers['bus'].hide();
|
||||||
|
} else {
|
||||||
|
this.markerLayers['bus'].show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// proj. EPSG:3857
|
||||||
|
|
||||||
|
/* window.over = L.imageOverlay('/map_min.jpg', [[48.078703, 17.01291], [48.229636, 17.226939]], {
|
||||||
|
opacity: 0.7,
|
||||||
|
}).addTo(this.map); */
|
||||||
|
|
||||||
|
// this.updateTest();
|
||||||
|
this.currentLinePoints = new L.LayerGroup().addTo(this.map);
|
||||||
|
this.currentLinePointsRenderer = L.canvas({ padding: 0.5 }).addTo(this.map);
|
||||||
|
this.currentVehicleTrace = new L.LayerGroup().addTo(this.map);
|
||||||
|
this.currentVehicleTraceRenderer = L.canvas({ padding: 0.5 }).addTo(this.map);
|
||||||
|
//this.currentLinePointsRenderer = L.canvas({ padding: 0.5 });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateVehicles(vehicles) {
|
||||||
|
let markersByType = {};
|
||||||
|
|
||||||
|
for (let vehId in vehicles) {
|
||||||
|
const veh = vehicles[vehId];
|
||||||
|
if (this.markers.hasOwnProperty(vehId)) {
|
||||||
|
let marker = this.markers[vehId];
|
||||||
|
if (veh.gpsLatitude < -90) {
|
||||||
|
// invalid position -- vehicle left?
|
||||||
|
marker._icon && L.DomUtil.addClass(marker._icon, 'veh-left');
|
||||||
|
} else {
|
||||||
|
marker._icon && L.DomUtil.removeClass(marker._icon, 'veh-notinservice');
|
||||||
|
marker.setLatLng([veh.gpsLatitude, veh.gpsLongitude]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let iconClass = 'veh-icon mhd-linenr';
|
||||||
|
if (veh.type) {
|
||||||
|
iconClass += ' veh-type-' + veh.type;
|
||||||
|
}
|
||||||
|
if (!veh.lineNumber) {
|
||||||
|
iconClass += ' veh-notinservice';
|
||||||
|
}
|
||||||
|
|
||||||
|
let popupHtml = `<b>#${veh.vehicleNumber}</b>`;
|
||||||
|
if (veh.img && veh.imhdinfo) {
|
||||||
|
popupHtml += `
|
||||||
|
<a href="https://imhd.sk${veh.imhdinfo}" target="_blank">
|
||||||
|
<img class="veh-model" src="${document.location.pathname}/imhd/icons/${veh.img}.png" alt="" />
|
||||||
|
</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lineNumber = veh.lineNumber || '?';
|
||||||
|
|
||||||
|
const newMarker =
|
||||||
|
new L.Marker([veh.gpsLatitude, veh.gpsLongitude], {
|
||||||
|
icon: new L.DivIcon({
|
||||||
|
className: iconClass,
|
||||||
|
html: `${lineNumber}`,
|
||||||
|
iconSize: null
|
||||||
|
}),
|
||||||
|
title: `${vehId}`,
|
||||||
|
// TODO: Kinda hack, would be better to extend L.Marker instead.
|
||||||
|
data: {
|
||||||
|
vehicle: veh,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.bindPopup(popupHtml)
|
||||||
|
.addTo(this.map);
|
||||||
|
|
||||||
|
this.markers[vehId] = newMarker;
|
||||||
|
// this.searchableLayer.addLayer(newMarker);
|
||||||
|
|
||||||
|
if (veh.type) {
|
||||||
|
markersByType[veh.type] ??= [];
|
||||||
|
markersByType[veh.type].push(newMarker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let type in markersByType) {
|
||||||
|
if (this.markerLayers.hasOwnProperty(type)) {
|
||||||
|
markersByType[type].forEach(el => this.markerLayers[type].addLayer(el));
|
||||||
|
} else {
|
||||||
|
this.markerLayers[type] = L.layerGroup(markersByType[type]).addTo(this.map);
|
||||||
|
this.searchableLayer.addLayer(this.markerLayers[type]);
|
||||||
|
this.layerControl.addOverlay(this.markerLayers[type], VehicleTypes[type] ?? type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//this.markerLayers[]
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStops(stops) {
|
||||||
|
const markers = [];
|
||||||
|
|
||||||
|
for (let stop of stops) {
|
||||||
|
let popupHtml = `
|
||||||
|
<b>${stop.name}</b><br/>
|
||||||
|
stationId: ${stop.stationId}<br />
|
||||||
|
stationStopId: ${stop.stationStopId}<br />
|
||||||
|
tag: ${stop.tag}<br />
|
||||||
|
`;
|
||||||
|
const newMarker =
|
||||||
|
L.circleMarker([stop.gpsLat, stop.gpsLon], {
|
||||||
|
icon: new L.DivIcon({
|
||||||
|
className: 'stop-icon',
|
||||||
|
html: `${stop.name}`,
|
||||||
|
iconSize: null,
|
||||||
|
}),
|
||||||
|
title: `${stop.name}`,
|
||||||
|
// TODO: Kinda hack, would be better to extend L.Marker instead.
|
||||||
|
data: {
|
||||||
|
stop: stop,
|
||||||
|
},
|
||||||
|
renderer: this.currentVehicleTraceRenderer,
|
||||||
|
color: '#0F95D8',
|
||||||
|
opacity: 1,
|
||||||
|
fill: true,
|
||||||
|
radius: 5,
|
||||||
|
})
|
||||||
|
.bindPopup(popupHtml)
|
||||||
|
.addTo(this.map);
|
||||||
|
|
||||||
|
markers.push(newMarker);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('stops', stops);
|
||||||
|
if (this.markerLayers.hasOwnProperty('stop')) {
|
||||||
|
this.markerLayers['stop'].clearLayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.markerLayers['stop'] = L.layerGroup(markers).addTo(this.map);
|
||||||
|
this.searchableLayer.addLayer(this.markerLayers['stop']);
|
||||||
|
this.layerControl.addOverlay(this.markerLayers['stop'], VehicleTypes['stop']);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async drawVehicleTrace(vehId) {
|
||||||
|
this.currentVehicleTrace.clearLayers();
|
||||||
|
if (vehId === null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
let vehPos = await this.sendWs('requestVehicleTrace', {
|
||||||
|
vehicle: vehId,
|
||||||
|
count: 1000,
|
||||||
|
});
|
||||||
|
vehPos = vehPos.map(row => [row.lat, row.long]);
|
||||||
|
|
||||||
|
let trace = vehPos.slice(0, 10);
|
||||||
|
trace = this._splitArray(trace, row => row[0] < -90 || row[1] < -180);
|
||||||
|
|
||||||
|
let points = new L.LayerGroup();
|
||||||
|
vehPos.forEach(point => points.addLayer(L.circleMarker(point, {
|
||||||
|
renderer: this.currentVehicleTraceRenderer,
|
||||||
|
color: '#ff00ff',
|
||||||
|
opacity: 0.7,
|
||||||
|
fill: false,
|
||||||
|
radius: 2,
|
||||||
|
})));
|
||||||
|
|
||||||
|
this.currentVehicleTrace.addLayer(points);
|
||||||
|
this.currentVehicleTrace.addLayer(L.polyline(trace, {
|
||||||
|
color: 'red',
|
||||||
|
renderer: this.currentVehicleTraceRenderer,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async drawLinePoints(line) {
|
||||||
|
this.currentLinePoints.clearLayers();
|
||||||
|
if (line === null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
let trace = await this.sendWs('requestLineTrace', {
|
||||||
|
line: line,
|
||||||
|
count: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* this.currentLinePoints = L.glify.points({
|
||||||
|
this.map,
|
||||||
|
data: pointsOrGeoJson
|
||||||
|
});*/
|
||||||
|
|
||||||
|
trace = trace.map(row => [row.lat, row.long]);
|
||||||
|
let points = new L.LayerGroup();
|
||||||
|
trace.forEach(point => points.addLayer(L.circleMarker(point, {
|
||||||
|
renderer: this.currentLinePointsRenderer,
|
||||||
|
color: '#0000ff',
|
||||||
|
fill: false,
|
||||||
|
radius: 3,
|
||||||
|
})));
|
||||||
|
|
||||||
|
this.currentLinePoints.addLayer(points);
|
||||||
|
|
||||||
|
//trace = trace.map(row => [row.lat, row.long]);
|
||||||
|
//this.currentTrace = L.polyline(trace, { color: 'red' }).addTo(this.map);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectWs() {
|
||||||
|
if (this.ws) {
|
||||||
|
await this.ws.terminate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise ((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
this.ws = new WebSocket(this.wsUrl);
|
||||||
|
this.ws.addEventListener('open', event => {
|
||||||
|
resolve(this.ws);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.addEventListener('message', event => {
|
||||||
|
const obj = JSON.parse(event.data);
|
||||||
|
if (obj.action) {
|
||||||
|
this.handleAction(obj.action, obj.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.addEventListener('close', event => {
|
||||||
|
this.ws = null;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
this.ws = null;
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAction(action, data) {
|
||||||
|
if (['updateVehicles', 'updateStops'].includes(action)) {
|
||||||
|
return this[action](data);
|
||||||
|
} else if (action === 'response') {
|
||||||
|
if (this.wsMessages.hasOwnProperty(data.id)) {
|
||||||
|
this.wsMessages[data.id](data.msg);
|
||||||
|
delete this.wsMessages[data.id];
|
||||||
|
} else {
|
||||||
|
console.error('Got a response to something we didn\'t ask for: ', data);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
console.error('Unknown action requested by the server: ', action, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendWs(action, data) {
|
||||||
|
if (!this.ws)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
let msgid = Math.random().toString(36).slice(2, 14);
|
||||||
|
return new Promise ((resolve, reject) => {
|
||||||
|
this.wsMessages[msgid] = resolve;
|
||||||
|
|
||||||
|
this.ws.send(JSON.stringify({
|
||||||
|
id: msgid,
|
||||||
|
action,
|
||||||
|
msg: data,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_popupOpenEvent(event) {
|
||||||
|
L.DomUtil.addClass(this.map._container,'popup-open');
|
||||||
|
|
||||||
|
let icon = event?.popup?._source?._icon;
|
||||||
|
if (icon) {
|
||||||
|
this._hadMarkerOpen = icon;
|
||||||
|
L.DomUtil.addClass(icon,'marker-popup-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
let veh = event?.popup?._source?.options?.data?.vehicle;
|
||||||
|
if (veh) {
|
||||||
|
L.DomUtil.addClass(this.map._container, 'drawing-trace');
|
||||||
|
// the user has clicked a vehicle
|
||||||
|
this.drawLinePoints(veh.lineNumber);
|
||||||
|
this.drawVehicleTrace(veh.vehicleNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
_popupCloseEvent(event) {
|
||||||
|
L.DomUtil.removeClass(this.map._container,'popup-open');
|
||||||
|
L.DomUtil.removeClass(this.map._container, 'drawing-trace');
|
||||||
|
|
||||||
|
if (this._hadMarkerOpen) {
|
||||||
|
L.DomUtil.removeClass(this._hadMarkerOpen,'marker-popup-open');
|
||||||
|
this._hadMarkerOpen = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.drawVehicleTrace(null);
|
||||||
|
this.drawLinePoints(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
_splitArray(arr, predicate) {
|
||||||
|
const ret = [];
|
||||||
|
let arrNow = [];
|
||||||
|
for (let el of arr) {
|
||||||
|
if (predicate(el)) {
|
||||||
|
ret.push(arrNow);
|
||||||
|
arrNow = [];
|
||||||
|
} else {
|
||||||
|
arrNow.push(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ret.push(arrNow);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
13
client/client.js
Normal file
13
client/client.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import MhdMapClient from "./MhdMapClient.js";
|
||||||
|
|
||||||
|
const DOMReady = function(callback) {
|
||||||
|
document.readyState === "interactive" || document.readyState === "complete" ? callback() : document.addEventListener("DOMContentLoaded", callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
DOMReady(() => {
|
||||||
|
const client = new MhdMapClient(document.getElementById('app'));
|
||||||
|
client.start();
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
21
client/index.html
Normal file
21
client/index.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<title>Real-time mapa MHD</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="../node_modules/leaflet/dist/leaflet.css">
|
||||||
|
<link rel="stylesheet" href="../node_modules/leaflet-search/dist/leaflet-search.min.css">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
|
||||||
|
<script src="client.js" type="module"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div id="app">
|
||||||
|
<div id="map"></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
89
client/style.css
Normal file
89
client/style.css
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/* @import '~leaflet/dist/leaflet.css'; */
|
||||||
|
|
||||||
|
body {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
background: #fff;
|
||||||
|
font-family: sans-serif;
|
||||||
|
|
||||||
|
--mhd-color-tram: #e31e24;
|
||||||
|
--mhd-color-bus: #5071b9;
|
||||||
|
--mhd-color-trolleybus: #0E562C;
|
||||||
|
}
|
||||||
|
html, body, main, #app, #map {
|
||||||
|
height: 100%;
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map .mhd-linenr {
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 0 0 2px #00000080;
|
||||||
|
padding: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map .veh-icon {
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map .mhd-linenr.veh-type-tram {
|
||||||
|
background: var(--mhd-color-tram);
|
||||||
|
}
|
||||||
|
#map .mhd-linenr.veh-type-bus {
|
||||||
|
background: var(--mhd-color-bus);
|
||||||
|
}
|
||||||
|
#map .mhd-linenr.veh-type-trolleybus {
|
||||||
|
background: var(--mhd-color-trolleybus);
|
||||||
|
}
|
||||||
|
|
||||||
|
#map .veh-icon.veh-notinservice {
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
#map .veh-icon.veh-left {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map .leaflet-popup-content {
|
||||||
|
margin: 5px 10px 5px 10px;
|
||||||
|
min-width: 130px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#map .leaflet-popup-content img.veh-model {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map .search-tooltip {
|
||||||
|
min-width: 175px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
max-height: calc(((1em * 1.3) + 6px) * 8);
|
||||||
|
overflow-x: visible;
|
||||||
|
}
|
||||||
|
#map .search-tooltip > a {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
#map .search-tooltip .result-vehicle img {
|
||||||
|
height: 1em;
|
||||||
|
margin-left: 0.5em;
|
||||||
|
}
|
||||||
|
#map .search-tooltip .result-vehicle .mhd-linenr {
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0.1em 0.4em;
|
||||||
|
}
|
||||||
|
#map.popup-open.drawing-trace .leaflet-marker-icon.veh-icon {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity .3s ease-in-out;
|
||||||
|
}
|
||||||
|
#map.popup-open.drawing-trace .leaflet-marker-icon.veh-icon.marker-popup-open {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
16
config.sample.js
Normal file
16
config.sample.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
const config = {
|
||||||
|
urlPrefix: "/mhd",
|
||||||
|
openDataKey: 'INSERT_YOUR_OPENDATA_API_KEY_HERE',
|
||||||
|
database: {
|
||||||
|
client: 'pg',
|
||||||
|
connection: {
|
||||||
|
host : '127.0.0.1',
|
||||||
|
user : 'erik',
|
||||||
|
password : '',
|
||||||
|
database : 'mhd'
|
||||||
|
},
|
||||||
|
searchPath: ['knex', 'public'],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
6513
package-lock.json
generated
Normal file
6513
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "mhdmap",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Real-time vehicle tracking for Bratislava public transport",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server/index.js",
|
||||||
|
"watch": "parcel client/index.html",
|
||||||
|
"build": "parcel build client/index.html"
|
||||||
|
},
|
||||||
|
"targets": {
|
||||||
|
"default": {
|
||||||
|
"publicUrl": "/mhd"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"keywords": ["mhd", "public transport", "gps", "map", "realtime", "tracking"],
|
||||||
|
"author": "ericek111",
|
||||||
|
"license": "GPL-3.0-only",
|
||||||
|
"devDependencies": {
|
||||||
|
"express": "^4.18.1",
|
||||||
|
"http-proxy-middleware": "^2.0.6",
|
||||||
|
"knex": "^2.1.0",
|
||||||
|
"nanoid": "^4.0.0",
|
||||||
|
"parcel": "^2.6.0",
|
||||||
|
"pg": "^8.7.3",
|
||||||
|
"tinyws": "^0.1.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@bagage/leaflet.restoreview": "^1.0.1",
|
||||||
|
"leaflet": "^1.8.0",
|
||||||
|
"leaflet-geometryutil": "^0.10.1",
|
||||||
|
"leaflet-layervisibility": "^0.1.0-post1",
|
||||||
|
"leaflet-search": "^3.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
46
server/BratislavaOpendata.js
Normal file
46
server/BratislavaOpendata.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export const OPENDATA_URL = 'http://opendata.bratislava.sk/api/mhd';
|
||||||
|
|
||||||
|
export default class BratislavaOpendata {
|
||||||
|
|
||||||
|
constructor(apiKey) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchOneStop(stationId) {
|
||||||
|
return this.request('/stationstop/' + stationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchAllStops() {
|
||||||
|
return this.request('/stationstop');
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchAllVehicles() {
|
||||||
|
return this.request('/vehicle');
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(uri) {
|
||||||
|
let url = OPENDATA_URL + '/' + uri;
|
||||||
|
let resp = await fetch(url, {
|
||||||
|
cache: 'no-cache',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'node_mhdmap/1.0',
|
||||||
|
'Key': this.apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.warn('Error while fetching', url, 'status:', resp.status, resp.statusText);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
resp = await resp.json();
|
||||||
|
return resp;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Incorrect response for fetching', url, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
136
server/ImhdClient.js
Normal file
136
server/ImhdClient.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import path from "path";
|
||||||
|
import https from 'https';
|
||||||
|
import {fileURLToPath} from "url";
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
|
||||||
|
const imhdData = path.dirname(path.dirname(fileURLToPath(import.meta.url))) + '/dist/imhd';
|
||||||
|
|
||||||
|
export default class ImhdClient {
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
fs.mkdirSync(imhdData + '/icons', { recursive: true });
|
||||||
|
|
||||||
|
this.cache = {};
|
||||||
|
this.loadCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
getVehicleData(vehicleId) {
|
||||||
|
if (this.cache.hasOwnProperty(vehicleId)) {
|
||||||
|
return this.cache[vehicleId];
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(phrase) {
|
||||||
|
let resp = await fetch('https://imhd.sk/ba/api/sk/vyhladavanie?q=' + encodeURIComponent(phrase));
|
||||||
|
if (!resp.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadVehicleIcon(vehicleId) {
|
||||||
|
if (this.cache.hasOwnProperty(vehicleId)) {
|
||||||
|
if (this.cache[vehicleId].type)
|
||||||
|
return this.cache[vehicleId].url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const randId = nanoid(5);
|
||||||
|
const iconPath = imhdData + '/icons/' + randId + '.png';
|
||||||
|
if (fs.existsSync(iconPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = await this.search(vehicleId);
|
||||||
|
if (!results) {
|
||||||
|
console.log('Search for vehicle unsuccessful:', vehicleId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
results = results.results || [];
|
||||||
|
|
||||||
|
for (let obj of results) {
|
||||||
|
if ((typeof obj.img) !== 'string' || obj.img.length < 10)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const urlParts = obj.url.trim().split('/');
|
||||||
|
if (urlParts.length < 3)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (urlParts[2] !== 'vozidlo')
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!obj.name.endsWith('#' + vehicleId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
let matches = obj.img.match(/.*src="([^"]*)".*/);
|
||||||
|
if (!matches)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const url = 'https://imhd.sk' + matches[1];
|
||||||
|
const existing = Object.values(this.cache).find(el => el.url === url);
|
||||||
|
if (!existing) {
|
||||||
|
// TODO: Path traversal vulnerability here!
|
||||||
|
await this.downloadFile(url, iconPath);
|
||||||
|
fs.appendFile(
|
||||||
|
imhdData + '/names.txt',
|
||||||
|
vehicleId + '|' + obj.name + "\n",
|
||||||
|
err => { err && console.error(err); }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cache[vehicleId] = {
|
||||||
|
img: existing ? existing.img : randId,
|
||||||
|
url: url,
|
||||||
|
type: obj.class,
|
||||||
|
info: obj.url,
|
||||||
|
};
|
||||||
|
this.saveCache();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadFile(url, target) {
|
||||||
|
return new Promise ((resolve, reject) => {
|
||||||
|
const file = fs.createWriteStream(target);
|
||||||
|
https.get(url, resp => {
|
||||||
|
resp.pipe(file);
|
||||||
|
|
||||||
|
file.on('finish', () => {
|
||||||
|
file.close();
|
||||||
|
console.log('Downloaded', url);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCache() {
|
||||||
|
try {
|
||||||
|
let cacheFile = fs.readFileSync(imhdData + '/iconsdb.json');
|
||||||
|
if (cacheFile) {
|
||||||
|
let parsed = JSON.parse(cacheFile);
|
||||||
|
if (parsed) {
|
||||||
|
this.cache = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Cannot read iconsdb.json for ImhdClient', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCache() {
|
||||||
|
fs.writeFile(imhdData + '/iconsdb.json', JSON.stringify(this.cache, null, 2), err => {
|
||||||
|
if (err) {
|
||||||
|
console.log('Cannot save cache for ImhdClient', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
223
server/MhdMapApp.js
Normal file
223
server/MhdMapApp.js
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import BratislavaOpendata from "./BratislavaOpendata.js";
|
||||||
|
import config from "../config.js";
|
||||||
|
import {bearing, removeMilis} from "./math.js";
|
||||||
|
import ImhdClient from "./ImhdClient.js";
|
||||||
|
import Recorder from "./Recorder.js";
|
||||||
|
import getDatabase from "./getDatabase.js";
|
||||||
|
|
||||||
|
export default class MhdMapApp {
|
||||||
|
|
||||||
|
constructor(websockets) {
|
||||||
|
this.opendata = new BratislavaOpendata(config.openDataKey);
|
||||||
|
this.websockets = websockets;
|
||||||
|
|
||||||
|
this.stops = null;
|
||||||
|
this.oldVehicles = null;
|
||||||
|
this.vehicles = null;
|
||||||
|
this.lastUpdatedStatic = null;
|
||||||
|
this.lastUpdatedDynamic = null;
|
||||||
|
this.lastDelta = null;
|
||||||
|
|
||||||
|
this.imhd = new ImhdClient();
|
||||||
|
this.recorder = new Recorder(getDatabase());
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
this.websockets.on('join', ws => {
|
||||||
|
this.sendAction(ws, 'updateStops', this.stops);
|
||||||
|
this.sendAction(ws, 'updateVehicles', this.vehicles);
|
||||||
|
});
|
||||||
|
this.websockets.on('message', this._onWsMessage.bind(this));
|
||||||
|
|
||||||
|
await this.recorder.start();
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
this.updateStatic();
|
||||||
|
}, 1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
|
await this.updateStatic();
|
||||||
|
await this.updateDynamic();
|
||||||
|
|
||||||
|
setInterval(async () => {
|
||||||
|
await this.updateDynamic();
|
||||||
|
if (this.lastDelta.length > 0) {
|
||||||
|
this.broadcastAction('updateVehicles', this.lastDelta);
|
||||||
|
}
|
||||||
|
}, 1000 * 5);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatic() {
|
||||||
|
let tmp = await this.opendata.fetchAllStops();
|
||||||
|
if (tmp) {
|
||||||
|
this.stops = tmp;
|
||||||
|
this.lastUpdatedStatic = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDynamic() {
|
||||||
|
let tmp = await this.opendata.fetchAllVehicles();
|
||||||
|
if (!tmp)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// TODO: We're in Slovakia.
|
||||||
|
tmp = tmp.filter(el => el.gpsLongitude > 10);
|
||||||
|
tmp = this._vehiclesToObj(tmp);
|
||||||
|
|
||||||
|
this.oldVehicles = this.vehicles;
|
||||||
|
this.vehicles = tmp;
|
||||||
|
this.lastUpdatedDynamic = new Date();
|
||||||
|
if (this.oldVehicles) {
|
||||||
|
this.lastDelta = this.calculateVehiclesDelta(this.oldVehicles, this.vehicles);
|
||||||
|
this.vehicles = this.addRotationToVehicles(this.oldVehicles, this.vehicles);
|
||||||
|
|
||||||
|
for (let veh of Object.values(this.lastDelta)) {
|
||||||
|
await this.recorder.putVehicle(veh);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let veh of Object.values(this.vehicles)) {
|
||||||
|
await this.recorder.putVehicle(veh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.vehicles = this.addImhdDataToVehicles(this.vehicles);
|
||||||
|
|
||||||
|
let down = 0;
|
||||||
|
for (let vehId in this.vehicles) {
|
||||||
|
if (await this.imhd.downloadVehicleIcon(vehId) === true) {
|
||||||
|
if (down++ === 100) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateVehiclesDelta(beforeObj, afterObj) {
|
||||||
|
let delta = {};
|
||||||
|
|
||||||
|
for (let vehId in beforeObj) {
|
||||||
|
let beforeVeh = beforeObj[vehId];
|
||||||
|
if (afterObj.hasOwnProperty(vehId)) {
|
||||||
|
let afterVeh = afterObj[vehId];
|
||||||
|
if (!this.isVehicleObjectEqual(beforeVeh, afterVeh)) {
|
||||||
|
delta[vehId] = afterVeh;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// removed vehicle
|
||||||
|
beforeVeh.gpsLatitude = -1000;
|
||||||
|
beforeVeh.gpsLongitude = -1000;
|
||||||
|
delta[vehId] = beforeVeh;
|
||||||
|
console.log('vehicle left', beforeVeh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let vehId in afterObj) {
|
||||||
|
if (!beforeObj.hasOwnProperty(vehId)) {
|
||||||
|
// new vehicle
|
||||||
|
delta[vehId] = afterObj[vehId];
|
||||||
|
// console.log('new vehicle joined', afterObj[vehId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
_vehiclesToObj(arr) {
|
||||||
|
let obj = {};
|
||||||
|
|
||||||
|
for (let vehicle of arr) {
|
||||||
|
let id = this._vehicleToId(vehicle);
|
||||||
|
vehicle.lastModified = removeMilis(new Date(vehicle.lastModified));
|
||||||
|
|
||||||
|
if (obj.hasOwnProperty(id)) {
|
||||||
|
console.log('DUPLICATE vehicle?!', 'old:', obj[id], 'new:', vehicle);
|
||||||
|
}
|
||||||
|
obj[id] = vehicle;
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
_vehicleToId(vehicle) {
|
||||||
|
return vehicle.vehicleNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
isVehicleObjectEqual(a, b, checkLastModified = false) {
|
||||||
|
return (
|
||||||
|
a.vehicleNumber === b.vehicleNumber
|
||||||
|
&& a.lineNumber === b.lineNumber
|
||||||
|
&& a.gpsLatitude === b.gpsLatitude
|
||||||
|
&& a.gpsLongitude === b.gpsLongitude
|
||||||
|
&& (!checkLastModified || (a.lastModified === b.lastModified))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
isVehiclePositionEqual(a, b) {
|
||||||
|
return a.gpsLatitude === b.gpsLatitude && a.gpsLongitude === b.gpsLongitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
addImhdDataToVehicles(vehicles) {
|
||||||
|
for (let vehId in vehicles) {
|
||||||
|
const data = this.imhd.getVehicleData(vehId);
|
||||||
|
if (!data)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
let vehicle = vehicles[vehId];
|
||||||
|
vehicle.img = data.img;
|
||||||
|
vehicle.type = data.type;
|
||||||
|
vehicle.imhdinfo = data.info;
|
||||||
|
}
|
||||||
|
return vehicles;
|
||||||
|
}
|
||||||
|
|
||||||
|
addRotationToVehicles(before, after) {
|
||||||
|
for (let vehId in after) {
|
||||||
|
if (before.hasOwnProperty(vehId)) {
|
||||||
|
after[vehId].bearing = bearing(before[vehId], after[vehId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastAction(action, data) {
|
||||||
|
this.websockets.broadcast(JSON.stringify({ action, data }));
|
||||||
|
}
|
||||||
|
|
||||||
|
sendAction(ws, action, data) {
|
||||||
|
ws.send(JSON.stringify({ action, data }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async _onWsMessage(ws, data) {
|
||||||
|
// Note: before sending the response, it should be checked if this ws is still open
|
||||||
|
try {
|
||||||
|
data = JSON.parse(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Malformed request: ', data, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msgid = data.id;
|
||||||
|
|
||||||
|
let res = await this.handleWsRequest(ws, data.action, data.msg);
|
||||||
|
|
||||||
|
if (ws.readyState !== 1) { // 1 = WebSocket.OPEN
|
||||||
|
// closed while handling the request
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendAction(ws, 'response', {
|
||||||
|
id: msgid,
|
||||||
|
msg: res,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleWsRequest(ws, action, data) {
|
||||||
|
if (action === 'requestVehicleTrace') {
|
||||||
|
return await this.recorder.getVehicleTrace(data.vehicle, data.count || 20);
|
||||||
|
} else if (action === 'requestLineTrace') {
|
||||||
|
return await this.recorder.getSimpleLineTrace(data.line, data.count || 20);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
122
server/Recorder.js
Normal file
122
server/Recorder.js
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
export default class Recorder {
|
||||||
|
|
||||||
|
constructor(db) {
|
||||||
|
this.db = db;
|
||||||
|
this.lines = {};
|
||||||
|
this.vehModified = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
await this._createTables();
|
||||||
|
await this._loadLast();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _createTables() {
|
||||||
|
if (!this.db)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!await this.db.schema.hasTable('veh_lines')) {
|
||||||
|
await this.db.schema.createTable('veh_lines', t => {
|
||||||
|
t.smallint('vehicle').notNullable();
|
||||||
|
t.string('line', 32).nullable(); // 4, 9....
|
||||||
|
t.timestamp('date', { useTz: false }).defaultTo(this.db.fn.now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await this.db.schema.hasTable('veh_pos')) {
|
||||||
|
await this.db.schema.createTable('veh_pos', t => {
|
||||||
|
t.smallint('vehicle').notNullable();
|
||||||
|
t.smallint('line').nullable();
|
||||||
|
t.timestamp('date', { useTz: false }).defaultTo(this.db.fn.now());
|
||||||
|
t.float('lat').nullable();
|
||||||
|
t.float('long').nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _loadLast() {
|
||||||
|
const lineData = await this.db('veh_lines')
|
||||||
|
.distinctOn('vehicle')
|
||||||
|
.orderBy([
|
||||||
|
{ column: 'vehicle', order: 'asc' },
|
||||||
|
{ column: 'date', order: 'desc' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (let row of lineData) {
|
||||||
|
this.lines[row.vehicle.toString()] = parseInt(row.line) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vehData = await this.db
|
||||||
|
.select('vehicle', 'date')
|
||||||
|
.from('veh_pos')
|
||||||
|
.distinctOn('vehicle')
|
||||||
|
.orderBy([
|
||||||
|
{ column: 'vehicle', order: 'asc' },
|
||||||
|
{ column: 'date', order: 'desc' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (let row of vehData) {
|
||||||
|
this.vehModified[row.vehicle.toString()] = new Date(row.date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async putVehicle(veh) {
|
||||||
|
// TODO: Add filters for data integrity (vehicleNumber === null
|
||||||
|
let lineNumber = parseInt(veh.lineNumber) || null;
|
||||||
|
if (this.lines[veh.vehicleNumber.toString()] !== lineNumber) {
|
||||||
|
await this.db('veh_lines').insert({
|
||||||
|
vehicle: veh.vehicleNumber,
|
||||||
|
line: veh.lineNumber,
|
||||||
|
date: veh.lastModified,
|
||||||
|
});
|
||||||
|
this.lines[veh.vehicleNumber.toString()] = lineNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.vehModified[veh.vehicleNumber]?.getTime() !== veh.lastModified.getTime()) {
|
||||||
|
// console.log('for', veh.vehicleNumber, this.vehModified[veh.vehicleNumber], ' vs. from open', veh.lastModified)
|
||||||
|
await this.db('veh_pos').insert({
|
||||||
|
vehicle: veh.vehicleNumber,
|
||||||
|
line: veh.lineNumber,
|
||||||
|
date: veh.lastModified,
|
||||||
|
lat: veh.gpsLatitude,
|
||||||
|
long: veh.gpsLongitude,
|
||||||
|
});
|
||||||
|
this.vehModified[veh.vehicleNumber] = veh.lastModified;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVehicleTrace(vehId, count) {
|
||||||
|
return await this.db
|
||||||
|
.select('date', 'lat', 'long')
|
||||||
|
.from('veh_pos')
|
||||||
|
.where({
|
||||||
|
vehicle: parseInt(vehId),
|
||||||
|
})
|
||||||
|
// .andWhere('date', '<', '2022-06-24T14:07:00')
|
||||||
|
.orderBy('date', 'desc')
|
||||||
|
.limit(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLineTrace(line, count) {
|
||||||
|
return await this.db
|
||||||
|
.select('date', 'lat', 'long')
|
||||||
|
.from('veh_pos')
|
||||||
|
.where({
|
||||||
|
line: parseInt(line),
|
||||||
|
})
|
||||||
|
.orderBy('date', 'desc')
|
||||||
|
.limit(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSimpleLineTrace(line, count) {
|
||||||
|
return await this.db
|
||||||
|
.select('lat', 'long')
|
||||||
|
.from('veh_pos')
|
||||||
|
.where({
|
||||||
|
line: parseInt(line),
|
||||||
|
})
|
||||||
|
.limit(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
92
server/SocketHandler.js
Normal file
92
server/SocketHandler.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import {tinyws} from "tinyws";
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
export default class SocketHandler {
|
||||||
|
|
||||||
|
constructor(path, args) {
|
||||||
|
this.path = path;
|
||||||
|
this.connections = {};
|
||||||
|
this.handlers = {
|
||||||
|
'join': [],
|
||||||
|
'leave': [],
|
||||||
|
'message': [],
|
||||||
|
};
|
||||||
|
|
||||||
|
this.pingInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(express) {
|
||||||
|
express.use(tinyws());
|
||||||
|
express.use(this.path, this._receiveRequest.bind(this));
|
||||||
|
|
||||||
|
this.pingInterval = setInterval(() => {
|
||||||
|
Object.values(this.connections).forEach(client => {
|
||||||
|
if (++client.failedPings > 3) {
|
||||||
|
this.terminateClient(client.id);
|
||||||
|
} else {
|
||||||
|
client.ws.ping();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
// this.pingInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
clearInterval(this.pingInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast(msg) {
|
||||||
|
Object.values(this.connections).forEach(client => {
|
||||||
|
client.ws.send(msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async _receiveRequest(req, res) {
|
||||||
|
if (req.ws) {
|
||||||
|
const ws = await req.ws()
|
||||||
|
return this._receiveWebsocket(req, res, ws);
|
||||||
|
} else {
|
||||||
|
// not handling HTTP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _receiveWebsocket(req, res, ws) {
|
||||||
|
ws.id = randomUUID();
|
||||||
|
this.connections[ws.id] = {
|
||||||
|
ws: ws,
|
||||||
|
id: ws.id,
|
||||||
|
joined: new Date(),
|
||||||
|
failedPings: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
this._fire('leave', ws);
|
||||||
|
delete this.connections[ws.id];
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('message', (data, ...args) => {
|
||||||
|
this._fire('message', ws, data, ...args);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('pong', () => {
|
||||||
|
this.failedPings = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
this._fire('join', ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
terminateClient(uuid) {
|
||||||
|
this.connections[uuid].ws.terminate();
|
||||||
|
delete this.connections[uuid];
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event, callback) {
|
||||||
|
this.handlers[event].push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
_fire(event, ...args) {
|
||||||
|
this.handlers[event].forEach(cb => cb(...args));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
33
server/getDatabase.js
Normal file
33
server/getDatabase.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import knex from 'knex';
|
||||||
|
import config from "../config.js";
|
||||||
|
|
||||||
|
let database = null;
|
||||||
|
let lastFailed = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {Knex}
|
||||||
|
*/
|
||||||
|
export default function getDatabase() {
|
||||||
|
if (database) {
|
||||||
|
return database;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastFailed && (new Date().getTime() - lastFailed.getTime() < 60000)) {
|
||||||
|
// attempt to reconnect every 1 minute, not more frequently
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('connecting db');
|
||||||
|
database = knex({
|
||||||
|
...config.database,
|
||||||
|
});
|
||||||
|
lastFailed = null;
|
||||||
|
return database;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Cannot connect to database". e);
|
||||||
|
database = null;
|
||||||
|
lastFailed = new Date();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
63
server/index.js
Normal file
63
server/index.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import ParcelCore from "@parcel/core";
|
||||||
|
const { default: Parcel } = ParcelCore;
|
||||||
|
|
||||||
|
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||||
|
import express from "express";
|
||||||
|
import { createRequire } from "module";
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import config from "../config.js";
|
||||||
|
import MhdMapApp from "./MhdMapApp.js";
|
||||||
|
import SocketHandler from "./SocketHandler.js";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const devPort = 5000;
|
||||||
|
const servicePort = 5001;
|
||||||
|
const staticFilePath = './static'
|
||||||
|
|
||||||
|
// Development Server Settings
|
||||||
|
const frontEndDevServerOptions = {
|
||||||
|
defaultConfig: createRequire(import.meta.url).resolve(
|
||||||
|
"@parcel/config-default"
|
||||||
|
),
|
||||||
|
entries: path.join(__dirname, '../client/index.html'),
|
||||||
|
mode: 'development',
|
||||||
|
logLevel: 4,
|
||||||
|
serveOptions: {
|
||||||
|
port: devPort,
|
||||||
|
},
|
||||||
|
/* hmrOptions: {
|
||||||
|
port: devPort,
|
||||||
|
}, */
|
||||||
|
publicUrl: config.urlPrefix,
|
||||||
|
defaultTargetOptions: {
|
||||||
|
publicUrl: config.urlPrefix,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const bundler = new Parcel(frontEndDevServerOptions);
|
||||||
|
(async () => {
|
||||||
|
await bundler.watch();
|
||||||
|
})();
|
||||||
|
|
||||||
|
const server = express();
|
||||||
|
const socketHandler = new SocketHandler(config.urlPrefix + '/ws');
|
||||||
|
socketHandler.start(server);
|
||||||
|
|
||||||
|
const app = new MhdMapApp(socketHandler);
|
||||||
|
app.start();
|
||||||
|
|
||||||
|
server.use(config.urlPrefix + '/static', express.static(staticFilePath));
|
||||||
|
|
||||||
|
const parcelMiddleware = createProxyMiddleware({
|
||||||
|
target: `http://localhost:${devPort}/`,
|
||||||
|
pathRewrite: {'^/mhd' : ''} // TODO: Maybe fix this with Parcel.
|
||||||
|
});
|
||||||
|
server.use('/', parcelMiddleware);
|
||||||
|
|
||||||
|
// Run your Express server
|
||||||
|
server.listen(servicePort, () => {
|
||||||
|
console.log(`Listening to port ${servicePort}...`);
|
||||||
|
});
|
||||||
18
server/math.js
Normal file
18
server/math.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export function bearing (latlng1, latlng2) {
|
||||||
|
const rad = Math.PI / 180
|
||||||
|
const lat1 = latlng1.gpsLatitude * rad
|
||||||
|
const lat2 = latlng2.gpsLatitude * rad
|
||||||
|
const lon1 = latlng1.gpsLongitude * rad
|
||||||
|
const lon2 = latlng2.gpsLongitude * rad
|
||||||
|
const y = Math.sin(lon2 - lon1) * Math.cos(lat2)
|
||||||
|
const x = Math.cos(lat1) * Math.sin(lat2) -
|
||||||
|
Math.sin(lat1) * Math.cos(lat2) * Math.cos(lon2 - lon1)
|
||||||
|
|
||||||
|
const bearing = ((Math.atan2(y, x) * 180 / Math.PI) + 360) % 360
|
||||||
|
return bearing >= 180 ? bearing - 360 : bearing
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeMilis(date) {
|
||||||
|
date.setSeconds(date.getSeconds(), 0);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user