Add routing, state mgmt and train types

This commit is contained in:
Conor McNamara
2023-03-04 17:02:06 +00:00
parent b0b5ecd36a
commit 8af615a429
12 changed files with 606 additions and 89 deletions

View File

@ -8,6 +8,70 @@ admin.initializeApp();
const axios = require('axios');
const parseString = require('xml2js').parseString;
// function to fetch station data from the Firestore database
exports.getStationData = functions.https.onRequest((request, response) => {
response.set('Access-Control-Allow-Origin', '*');
response.set('Access-Control-Allow-Credentials', 'true');
let jsonData = [];
cors(request, response, () => {
// fetch the "stations" collection
admin.firestore().collection('stations').get().then((snapshot) => {
if (snapshot.empty) {
response.send({data: "Error fetching station data from the database"})
return;
}
// iterate through each of the collection's documents
snapshot.forEach(doc => {
jsonData.push(doc.data());
});
response.json({data: jsonData});
})
});
})
// function to populate the Firestore database with station data from Irish Rail
exports.postStationData = functions.https.onRequest((request, response) => {
response.set('Access-Control-Allow-Origin', '*');
response.set('Access-Control-Allow-Credentials', 'true');
cors(request, response, () => {
axios.get('http://api.irishrail.ie/realtime/realtime.asmx/getAllStationsXML')
.then((res) => {
// XML to JSON
parseString(res.data, function(err, result) {
let jsonStr = JSON.stringify(result);
let jsonObj = JSON.parse(jsonStr);
let jsonData = jsonObj.ArrayOfObjStation.objStation;
// batch delete all of the "stations" collection's documents
var db = admin.firestore();
admin.firestore().collection('stations').get().then((snapshot) => {
var batchDelete = db.batch();
snapshot.forEach(doc => {
batchDelete.delete(doc.ref);
});
batchDelete.commit().then(function() {
// batch write all station JSON objects to the "stations" collection
var batchWrite = db.batch();
jsonData.forEach((doc) => {
// set the station's ID as the document ID
var docID = db.collection('stations').doc(doc["StationCode"][0]);
batchWrite.set(docID, doc);
});
batchWrite.commit().then(function () {
response.send({data: "Successfully fetched and uploaded station data from Irish Rail"});
});
})
})
})
})
})
})
// function to fetch live train data from the Firestore database
exports.getLiveTrainData = functions.https.onRequest((request, response) => {
response.set('Access-Control-Allow-Origin', '*');
@ -30,86 +94,75 @@ exports.getLiveTrainData = functions.https.onRequest((request, response) => {
});
})
// function to fetch station data from the Firestore database
exports.getStationData = functions.https.onRequest((request, response) => {
// function to populate the Firestore database with live train data from Irish Rail
exports.postLiveTrainData = functions.https.onRequest((request, response) => {
// helper function to parse train objects
function parseJSON(result) {
let jsonStr = JSON.stringify(result);
let jsonObj = JSON.parse(jsonStr);
let jsonData = jsonObj.ArrayOfObjTrainPositions.objTrainPositions;
return jsonData;
}
// helper function to write to the database
function batchWriteDB(request, response, db, jsonData, trainTypeCode) {
response.set('Access-Control-Allow-Origin', '*');
response.set('Access-Control-Allow-Credentials', 'true');
cors(request, response, () => {
var batchWrite = db.batch();
jsonData.forEach((doc) => {
// ignore trains with longitudes or latitudes equal zero
if (!(doc["TrainLongitude"] == 0 || doc["TrainLatitude"] == 0)) {
doc["TrainType"] = [trainTypeCode]
var docID = db.collection('liveTrainData').doc(doc["TrainCode"][0]);
batchWrite.set(docID, doc);
}
});
batchWrite.commit()
})
}
response.set('Access-Control-Allow-Origin', '*');
response.set('Access-Control-Allow-Credentials', 'true');
let jsonData = [];
cors(request, response, () => {
// fetch the "stations" collection
admin.firestore().collection('stations').get().then((snapshot) => {
if (snapshot.empty) {
response.status(404).send({data: "Error fetching station data from the database"})
return;
}
// iterate through each of the collection's documents
snapshot.forEach(doc => {
jsonData.push(doc.data());
});
response.json({data: jsonData});
})
});
})
// helper function to fetch data from Irish Rail given a train type (mainland, suburban, dart)
function callIrishRail(request, response, db, trainTypeCode) {
cors(request, response, () => {
let url = 'https://api.irishrail.ie/realtime/realtime.asmx/getCurrentTrainsXML_WithTrainType?TrainType=' + trainTypeCode
axios.get(url)
.then((res) => {
var batchWrite = db.batch();
// fetch mainland trains
axios.get('https://api.irishrail.ie/realtime/realtime.asmx/getCurrentTrainsXML_WithTrainType?TrainType=M').then(res => {
// XML to JSON
parseString(res.data, function(err, result) {
let jsonStr = JSON.stringify(result);
let jsonObj = JSON.parse(jsonStr);
let jsonData = jsonObj.ArrayOfObjTrainPositions.objTrainPositions;
let jsonData = parseJSON(result)
jsonData.forEach((doc) => {
// ignore trains with longitudes or latitudes equal zero
if (!(doc["TrainLongitude"] == 0 || doc["TrainLatitude"] == 0)) {
doc["TrainType"] = [trainTypeCode]
// set the train's code as the document ID
var docID = db.collection('liveTrainData').doc(doc["TrainCode"][0]);
batchWrite.set(docID, doc);
}
});
// batch delete all of the liveTrainData collections's documents
var db = admin.firestore();
admin.firestore().collection('liveTrainData').get().then((snapshot) => {
var batchDelete = db.batch();
snapshot.forEach(doc => {
batchDelete.delete(doc.ref);
});
batchWrite.commit()
.catch((error) => {
return false;
batchDelete.commit().then(function() {
// batch write all station JSON objects to the liveTrainData collection
batchWriteDB(request, response, db, jsonData, "M");
// fetch suburban trains
axios.get('https://api.irishrail.ie/realtime/realtime.asmx/getCurrentTrainsXML_WithTrainType?TrainType=S').then(res => {
parseString(res.data, function(err, result) {
let jsonData = parseJSON(result)
batchWriteDB(request, response, db, jsonData, "S");
// fetch dart trains
axios.get('https://api.irishrail.ie/realtime/realtime.asmx/getCurrentTrainsXML_WithTrainType?TrainType=D').then(res => {
parseString(res.data, function(err, result) {
let jsonData = parseJSON(result)
batchWriteDB(request, response, db, jsonData, "D");
response.send({data: "Successfully fetched and uploaded live train data from Irish Rail"});
})
})
})
})
})
})
})
})
.catch((error) => {
return false;
})
})
}
// function to populate the Firestore database with live train data from Irish Rail
exports.postLiveTrainData = functions.https.onRequest((request, response) => {
response.set('Access-Control-Allow-Origin', '*');
response.set('Access-Control-Allow-Credentials', 'true');
cors(request, response, () => {
// batch delete all of the "liveTrainData" collections's documents
var db = admin.firestore();
admin.firestore().collection('liveTrainData').get().then((snapshot) => {
var batchDelete = db.batch();
snapshot.forEach(doc => {
batchDelete.delete(doc.ref);
});
// fetch data using codes M (mainland), S (suburban), D (dart)
batchDelete.commit().then(function() {
if (callIrishRail(request, response, db, "M") == false ||
callIrishRail(request, response, db, "S") == false ||
callIrishRail(request, response, db, "D") == false) {
response.send({data: "Error fetching data from the IrishRail API"});
}
response.send({data: "Successfully fetched and uploaded live train data from Irish Rail"});
})
})
})
})

View File

@ -9,6 +9,7 @@
"axios": "^1.3.3",
"chai": "^4.3.7",
"chai-http": "^4.3.0",
"cors": "^2.8.5",
"firebase": "^9.17.1",
"firebase-admin": "^11.5.0",
"firebase-functions": "^4.2.0",

View File

@ -17,6 +17,7 @@
"axios": "^1.3.3",
"chai": "^4.3.7",
"chai-http": "^4.3.0",
"cors": "^2.8.5",
"firebase": "^9.17.1",
"firebase-admin": "^11.5.0",
"firebase-functions": "^4.2.0",

View File

@ -6,7 +6,7 @@ const expect = chai.expect;
describe('Firebase cloud function tests', function() {
this.timeout(100000);
it('Test getting live train data', async() => {
it('Test getting live train data from the database', async() => {
const result = await chai.request('https://us-central1-irishrailtracker.cloudfunctions.net')
.get('/getLiveTrainData')
expect(result.statusCode).to.equal(200);
@ -18,6 +18,20 @@ describe('Firebase cloud function tests', function() {
expect(result.body.data[0]).haveOwnProperty('TrainLongitude');
expect(result.body.data[0]).haveOwnProperty('TrainCode');
expect(result.body.data[0]).haveOwnProperty('TrainDate');
expect(result.body.data[0]).haveOwnProperty('TrainType');
}),
this.timeout(100000);
it('Test getting station data from the database', async() => {
const result = await chai.request('https://us-central1-irishrailtracker.cloudfunctions.net')
.get('/getStationData')
expect(result.statusCode).to.equal(200);
expect(result.body.data).to.be.an('Array');
expect(result.body.data[0]).haveOwnProperty('StationDesc');
expect(result.body.data[0]).haveOwnProperty('StationLatitude');
expect(result.body.data[0]).haveOwnProperty('StationLongitude');
expect(result.body.data[0]).haveOwnProperty('StationCode');
expect(result.body.data[0]).haveOwnProperty('StationId');
}),
this.timeout(100000);
@ -27,19 +41,6 @@ describe('Firebase cloud function tests', function() {
expect(result.statusCode).to.equal(200);
}),
this.timeout(100000);
it('test getting station data', async() => {
const result = await chai.request('https://us-central1-irishrailtracker.cloudfunctions.net')
.get('/getStationData')
expect(result.statusCode).to.equal(200);
expect(result.body.data).to.be.an('Array');
expect(result.body.data[0]).haveOwnProperty('StationDesc');
expect(result.body.data[0]).haveOwnProperty('StationLatitude');
expect(result.body.data[0]).haveOwnProperty('StationLongitude');
expect(result.body.data[0]).haveOwnProperty('StationCode');
expect(result.body.data[0]).haveOwnProperty('StationId');
})
this.timeout(100000);
it('Test updating the database with live station data', async() => {
const result = await chai.request('https://us-central1-irishrailtracker.cloudfunctions.net')

20
package-lock.json generated
View File

@ -13,6 +13,7 @@
"ol": "^7.2.2",
"vue": "^3.2.45",
"vue-loading-overlay": "^6.0.3",
"vue-router": "^4.1.6",
"vue3-openlayers": "^0.1.71"
},
"devDependencies": {
@ -1121,6 +1122,11 @@
"@vue/shared": "3.2.45"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.5.0.tgz",
"integrity": "sha512-o9KfBeaBmCKl10usN4crU53fYtC1r7jJwdGKjPT24t348rHxgfpZ0xL3Xm/gLUYnc0oTp8LAmrxOeLyu6tbk2Q=="
},
"node_modules/@vue/reactivity": {
"version": "3.2.45",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.2.45.tgz",
@ -2201,6 +2207,20 @@
"vue": "^3.2.0"
}
},
"node_modules/vue-router": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.1.6.tgz",
"integrity": "sha512-DYWYwsG6xNPmLq/FmZn8Ip+qrhFEzA14EI12MsMgVxvHFDYvlr4NXpVF5hrRH1wVcDP8fGi5F4rxuJSl8/r+EQ==",
"dependencies": {
"@vue/devtools-api": "^6.4.5"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/vue3-openlayers": {
"version": "0.1.71",
"resolved": "https://registry.npmjs.org/vue3-openlayers/-/vue3-openlayers-0.1.71.tgz",

View File

@ -13,6 +13,7 @@
"ol": "^7.2.2",
"vue": "^3.2.45",
"vue-loading-overlay": "^6.0.3",
"vue-router": "^4.1.6",
"vue3-openlayers": "^0.1.71"
},
"devDependencies": {

View File

@ -1,9 +1,8 @@
<script setup>
import MapsOverlay from './components/Map.vue'
</script>
<template>
<MapsOverlay></MapsOverlay>
<router-view></router-view>
</template>
<style scoped>

View File

@ -7,7 +7,16 @@ import 'vue3-openlayers/dist/vue3-openlayers.css'
import { LoadingPlugin } from 'vue-loading-overlay'
import 'vue-loading-overlay/dist/css/index.css'
import { createRouter, createWebHistory } from 'vue-router';
import routes from './router/routes';
let router = createRouter({
history: createWebHistory(),
routes: routes
})
const app = createApp(App);
app.use(router);
app.use(OpenLayersMap)
app.use(LoadingPlugin)
app.mount('#app')

View File

@ -0,0 +1,44 @@
<template>
<router-link to="/">Home</router-link>
<router-link to="/insights">Insights</router-link>
<h1>Insights</h1>
<div v-if="this.insights">
<p>Total number of trains: {{ this.insights["totalNumTrains"] }}</p>
<p>Number of actively running trains: {{ this.insights["numRunningTrains"] }}</p>
<p>Percentage late: {{ this.insights["percentageLate"] }}%</p>
<p>Percentage early or ontime: {{ this.insights["percentageNotLate"] }}%</p>
<p v-if="this.latestTrain['TrainCode']">Latest train: {{ this.latestTrain["TrainCode"][0] }}, {{ this.insights["latestTime"] }} mins late</p>
<p v-if="this.earliestTrain['TrainCode']">Earliest train: {{ this.earliestTrain["TrainCode"][0] }}, {{ this.insights["earliestTime"] * -1 }} mins early</p>
<p>Mainland: {{ this.insights["numMainland"] }}</p>
<p>Suburban: {{ this.insights["numSuburban"] }}</p>
<p>Darts: {{ this.insights["numDart"] }}</p>
</div>
</template>
<script>
import {store} from '../store/store'
export default {
name: "InsightsPage",
data() {
return {
insights: {},
latestTrain: {},
earliestTrain: {},
store
}
},
created() {
this.insights = store.insights
this.latestTrain = store.latestTrain
this.earliestTrain = store.earliestTrain
}
}
</script>
<style scoped>
</style>

364
src/pages/MapPage.vue Normal file
View File

@ -0,0 +1,364 @@
<template>
<router-link to="/">Home</router-link>
<router-link to="/insights">Insights</router-link>
<button @click="postLiveTrainData">Populate Database</button>
<!--Sidebar, fades out on click of X button-->
<transition id="sidebar" name="slideLeft">
<div v-if="this.display" id= "sidebarDiv">
<div id = "sidebarHeader">
<img id = "headerImage" src="../assets/train-solid.svg" alt="Train Icon">
<div v-on:click="this.display = false" id="xButton">X</div>
</div>
<div id= "sidebarDiv">
<h2>Train Code: {{ selectedDataMap["TrainCode"] }}</h2>
<p>Date: {{ selectedDataMap["TrainDate"] }}</p>
<p>Status: {{ selectedDataMap["TrainStatus"] }}</p>
<p>Longitude: {{ selectedDataMap["TrainLongitude"] }}</p>
<p>Latitude: {{ selectedDataMap["TrainLatitude"] }}</p>
<p>Direction: {{ selectedDataMap["Direction"] }}</p>
<p>Public Message: {{ selectedDataMap["PublicMessage"] }}</p>
</div>
</div>
</transition>
<ol-map :loadTilesWhileAnimating="true" :loadTilesWhileInteracting="true" style="height: 100vh; width: 100vw">
<ol-view ref="view" :center="center" :rotation="rotation" :zoom="zoom" :projection="projection" />
<ol-tile-layer>
<ol-source-osm />
</ol-tile-layer>
<template v-for="coordinate, i in coordinates" :position="inline-block">
<!-- overlay offset is the size of the image so that it is centered-->
<ol-overlay :position="coordinate" :positioning="center-center" :offset="[-14,-16]">
<div class="overlay-content" @click="getSelectedTrain(i)">
<img v-if="isTrainLate(i)" src="../assets/red-train-solid.png" class="trainMapIcon" alt="Train Icon">
<img v-else src="../assets/green-train-solid.png" class="trainMapIcon" alt="Train Icon">
</div>
</ol-overlay>
</template>
</ol-map>
</template>
<script>
import {store} from '../store/store'
import { ref } from 'vue';
import {fromLonLat, toLonLat} from 'ol/proj.js';
import app from '../api/firebase';
import { getFunctions, httpsCallable, connectFunctionsEmulator } from "firebase/functions";
export default {
name: "MapPage",
data() {
const center = ref(fromLonLat([-7.5029786, 53.4494762]))
const projection = ref('EPSG:3857')
const zoom = ref(7)
const rotation = ref(0)
const radius = ref(10)
const strokeWidth = ref(1)
const strokeColor = ref('black')
const fillColor = ref('red')
return {
center,
projection,
zoom,
rotation,
radius,
strokeWidth,
strokeColor,
fillColor,
coordinates: [],
dbLiveTrainData: [],
allDataMap: {},
selectedDataMap: {},
display: false,
store
}
},
created() {
let host = window.location.hostname
if (host === '127.0.0.1' || host === 'localhost') {
this.postLiveTrainData();
}
else {
this.getLiveTrainData();
}
// request new data every 60 seconds
// window.setInterval(this.getLiveTrainData, 60000);
},
methods: {
// method to assign a single train's data to the selected hashmap
getSelectedTrain(i) {
this.display = true;
this.selectedDataMap = this.allDataMap[i];
},
// method to determine whether or not a selected train is late
isTrainLate(i) {
// check if the train is running
if (this.allDataMap[i]["TrainStatus"][0] == "R") {
let publicMessage = this.allDataMap[i]["PublicMessage"][0];
let startTimeStr = publicMessage.indexOf("(");
// check if the train is late
if (publicMessage[startTimeStr+1] != "-" && publicMessage[startTimeStr+1] != "0") {
return true;
}
}
return false;
},
// method to fetch live train data from the database
getLiveTrainData() {
const functions = getFunctions(app);
let host = window.location.hostname
if (host === '127.0.0.1' || host == 'localhost') {
connectFunctionsEmulator(functions, host, 5001);
}
const getData = httpsCallable(functions, 'getLiveTrainData');
let loader = this.$loading.show({
loader: 'dots',
container: this.$refs.container,
canCancel: false
});
getData().then((response) => {
try {
this.dbLiveTrainData = response.data;
if (!this.dbLiveTrainData) throw new Error("Error fetching live train data from the database");
var insights = {"numRunningTrains": 0,
"numLateRunningTrains": 0,
"numMainland": 0,
"numSuburban": 0,
"numDart": 0}
var latest = null
var earliest = null
var currLatestTime = null
var currEarliestTime = null
// create an array of coordinates and hashmap with the key-values {index: JSON obj}
for (var i=0; i<this.dbLiveTrainData.length; i++) {
let train = this.dbLiveTrainData[i];
this.coordinates[i] = ref(fromLonLat([train["TrainLongitude"][0], train["TrainLatitude"][0]]))
this.allDataMap[i] = train;
if (train["TrainType"][0] == "M") insights["numMainland"] += 1;
else if (train["TrainType"][0] == "S") insights["numSuburban"] += 1;
else if (train["TrainType"][0] == "D") insights["numDart"] += 1;
// check if the train is running
if (this.dbLiveTrainData[i]["TrainStatus"][0] == "R") {
insights["numRunningTrains"] += 1;
let publicMessage = train["PublicMessage"][0];
let startTimeStr = publicMessage.indexOf("(");
// check if the train is late
if (publicMessage[startTimeStr+1] != "-" && publicMessage[startTimeStr+1] != "0") {
insights["numLateRunningTrains"] += 1;
if (!latest) latest = train;
let timeEnd = publicMessage.indexOf(" ", startTimeStr+1);
let num = parseInt(publicMessage.substring(startTimeStr+1, timeEnd))
// check for a new latest train
if (num > currLatestTime) {
latest = train
currLatestTime = num
}
}
// train is early or ontime
else {
if (!earliest) earliest = train;
let timeEnd = publicMessage.indexOf(" ", startTimeStr+1);
let num = parseInt(publicMessage.substring(startTimeStr+1, timeEnd))
// check for a new earliest train (early trains a -x mins late)
if (num < currEarliestTime) {
earliest = train
currEarliestTime = num
}
}
}
}
insights["percentageLate"] = ((insights["numLateRunningTrains"] / insights["numRunningTrains"]) * 100).toFixed(2);
insights["percentageNotLate"] = (100 - insights["percentageLate"]).toFixed(2);
insights["totalNumTrains"] = Object.keys(this.allDataMap).length;
insights["latestTime"] = currLatestTime;
insights["earliestTime"] = currEarliestTime
// assign the results to the Vue Store
store.setInsights(insights);
store.setEarliestTrain(earliest);
store.setLatestTrain(latest);
loader.hide();
}
catch (error) {
console.log(error)
loader.hide();
}
})
},
// method to populate the database for local testing
postLiveTrainData() {
const functions = getFunctions(app);
let host = window.location.hostname
if (host === '127.0.0.1' || host === 'localhost') {
connectFunctionsEmulator(functions, host, 5001);
}
const postData = httpsCallable(functions, 'postLiveTrainData');
postData().then((response) => {
this.getLiveTrainData()
})
}
}
}
</script>
<style scoped>
.overlay-content {
width: 1%;
}
.trainMapIcon {
width: 28px;
height: 32px;
}
.slideLeft-enter-active, .slideLeft-leave-active {
transition: opacity .5s;
transition: all 0.8s;
}
.slideLeft-enter-from, .slideLeft-leave-to{
opacity: 0;
transform: translateX(-100px);
}
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
#sidebar{
position: absolute;
height: 85%;
width: 20%;
left: 2%;
top: 12%;
z-index: 2;
text-align: center;
animation: gradient 15s ease infinite;
background: linear-gradient(45deg, #000000, #111111, #222222, #333333, #444444, #555555);
background-size: 400%, 400%;
box-shadow: 0 0 4px 2px #333333;
}
#sidebarDiv{
position: relative;
height: 100%;
width: 100%;
color: white;
}
#sidebarHeader{
position: relative;
top:0%;
height: 10%;
width: 100%;
overflow: hidden;
}
#headerImage{
height: 100%;
width: auto;
overflow: hidden;
}
#xButton{
font-size: 80%;
font-family: Georgia;
color: white;
position: absolute;
top:10px;
right:10px;
}
#xButton:hover{
color:red;
}
#sidebarFooter{
position: relative;
bottom:0%;
height:10%;
text-align: center;
color: azure;
}
#sidebarMain{
position: relative;
height:80%;
width:100%;
overflow: hidden;
}
#sidebarContent{
position: relative;
size: 6px;
color: white;
overflow-wrap: break-word;
font-family: Georgia, 'Times New Roman', Times, serif ;
}
#mapDiv{
background-color: black;
position: absolute;
float: right;
right: 0%;
top: 0%;
width:100%;
overflow: hidden;
height: 100%;
}
#mapIFrame{
position: relative;
height: 100%;
width: 100%;
top: 0%;
z-index: 0;
}
#buttonDiv{
position: absolute;
float: right;
right: 10%;
top: 0%;
width:10%;
height:10px;
}
#buttonElement{
position: relative;
top: 50%;
left: 50%;
z-index: 0;
}
</style>

7
src/router/routes.js Normal file
View File

@ -0,0 +1,7 @@
function loadPage(component) {
return () => import(`@/pages/${component}.vue`)
}
export default [
{path: "/", component:loadPage("MapPage")},
{path: "/insights", component:loadPage("InsightsPage")}
]

17
src/store/store.js Normal file
View File

@ -0,0 +1,17 @@
import { reactive } from 'vue'
export const store = reactive({
insights: {},
latestTrain: {},
earliestTrain: {},
setInsights(insights) {
this.insights = insights
},
setLatestTrain(latestTrain) {
this.latestTrain = latestTrain
},
setEarliestTrain(earliestTrain) {
this.earliestTrain = earliestTrain
}
})