diff --git a/package.json b/package.json
index 19fa814..d01f29d 100644
--- a/package.json
+++ b/package.json
@@ -49,7 +49,7 @@
"d3-shape": "^1.2.0",
"d3-time": "^1.0.8",
"d3-time-format": "^2.1.1",
- "d3-timer": "^1.0.7",
+ "d3-timer": "^1.0.9",
"d3-zoom": "^1.7.1",
"dat.gui": "^0.7.0",
"jquery": "^3.3.1",
diff --git a/src/index.html b/src/index.html
index bdbfe1e..7c9107b 100644
--- a/src/index.html
+++ b/src/index.html
@@ -37,7 +37,16 @@
Public Transport Decision Support System
+
+
+
+
+
+
+
+
+
diff --git a/src/js/app.js b/src/js/app.js
index e399a16..2558fca 100644
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -43,6 +43,15 @@ const getSelectedDatasetURL = () => {
return `${publicationInUse.url}${datasetInUse.filename}`;
};
+const getSelectedDatasetMarkersURL = () => {
+ // Get the publication currently selected (date)
+ const publicationInUse = indexData.publications
+ .find(pub => pub.date === document.getElementById('day').value);
+ // Get the dataset currently selected (group of lines) within the publication selected
+ // Compute URL of dataset selected
+ return `${publicationInUse.url}markers.json`;
+};
+
// Load the available line-directions within this group of lines
const loadAvailableLineDirections = () => {
fetch(getSelectedDatasetURL())
@@ -107,10 +116,35 @@ const processIndex = () => {
/* eslint no-new: "off" */
// Fetch default dataset and create its corresponding visualization
- const defaultDatasetURL = `${publications[0].url}${publications[0].datasets[0].filename}`;
+ // const defaultDatasetURL = `${publications[0].url}${publications[0].datasets[0].filename}`;
Object.assign(options, { selectedDate: publications[0].date });
- fetch(defaultDatasetURL).then(r => r.json())
- .then((defaultData) => { new PTDS(defaultData, options); });
+ // fetch(defaultDatasetURL).then(r => r.json())
+ // .then((defaultData) => { new PTDS(defaultData, options, null); });
+};
+
+// URL Hash trip selection
+const urlHashTripSelection = (url, tripCode, date) => {
+ // Load the chosen dataset
+ fetch(url).then(r => r.json())
+ .then((data) => {
+ // Empty the main div element
+ document.getElementById('main').innerHTML = '';
+ // Remove the dat.GUI widget(s) if present
+ for (const dg of document.getElementsByClassName('dg main')) dg.remove();
+
+ const journeyPattern = data.journeyPatterns[data.vehicleJourneys[tripCode].journeyPatternRef];
+
+ // Create new visualization, using the specified mode.
+ const selectedMode = 'marey';
+ options.mode = selectedMode;
+ options.line = journeyPattern.lineRef;
+ options.direction = journeyPattern.direction;
+ options.overlap = true;
+ options.realtime = true;
+ options.trip = tripCode;
+ Object.assign(options, { selectedDate: date });
+ new PTDS(data, options, null);
+ });
};
// Form submission handler
@@ -136,11 +170,22 @@ const formSubmit = (event) => {
const [line, direction] = document.getElementById('line-direction').value.split(' - ');
options.line = line;
options.direction = parseInt(direction, 10);
+ options.overlap = document.getElementById('line-direction-overlap').checked;
+ options.realtime = document.getElementById('realtime').checked;
} else {
options.mode = 'spiralSimulation';
}
Object.assign(options, { selectedDate: document.getElementById('day').value });
- new PTDS(data, options);
+
+ const urlMarkersSelected = getSelectedDatasetMarkersURL();
+ fetch(urlMarkersSelected).then(r => r.json())
+ .then((markerdata) => {
+ new PTDS(data, options, markerdata);
+ })
+ .catch(() => {
+ /* When developing remove this catch */
+ new PTDS(data, options, null);
+ });
});
};
@@ -149,11 +194,39 @@ $(document).ready(() => {
const indexFileURL = 'https://services.opengeo.nl/ptds/index.json';
fetch(indexFileURL).then(r => r.json())
// Process the index file when finished loading it
- .then((data) => { indexData = data; processIndex(); });
+ .then((data) => {
+ indexData = data;
+ processIndex();
+
+ // Handle loading a trip from a URL
+ const { hash } = window.location;
+ if (hash !== '' && hash !== '#') {
+ const tripCode = hash.substring(1, hash.length);
+ const parts = tripCode.split(':');
+ const lineCode = parts[1];
+ const date = parts[3];
+ document.getElementById('day').value = date;
+ document.getElementById('mode').value = 'marey';
+
+ const publication = indexData.publications.filter(e => (e.date === date));
+ if (publication.length > 0) {
+ const publicationInUse = publication[0];
+ const dataset = publicationInUse.datasets.filter(e => (e.lines.includes(lineCode)));
+ if (dataset.length > 0) {
+ const datasetInUse = dataset[0];
+ document.getElementById('lines-groups').value = datasetInUse.filename;
+ const url = `${publicationInUse.url}${datasetInUse.filename}`;
+ urlHashTripSelection(url, tripCode, date);
+ }
+ }
+ }
+ });
+
+ const { hash } = window.location;
// Activate sidebar plugin
$('#sidebar').simplerSidebar({
- init: 'opened',
+ init: (hash === '' || hash === '#') ? 'opened' : 'closed',
selectors: {
trigger: '#toggle-sidebar',
quitter: '.close-sidebar',
diff --git a/src/js/models/stoparea.js b/src/js/models/stoparea.js
index ed2b5c4..cf7b9e1 100644
--- a/src/js/models/stoparea.js
+++ b/src/js/models/stoparea.js
@@ -14,6 +14,7 @@ export default class StopArea {
this.code = code;
this.stops = stops;
this.center = this.computeCenter();
+ this.name = stops[0].name; /* TODO: This might need to be improved */
}
/**
diff --git a/src/js/models/vehiclejourney.js b/src/js/models/vehiclejourney.js
index 866acd9..fb016dd 100644
--- a/src/js/models/vehiclejourney.js
+++ b/src/js/models/vehiclejourney.js
@@ -72,6 +72,15 @@ export default class VehicleJourney {
};
}
+ get tripLabel() {
+ if (this.isRealTime) {
+ /* TODO: Temporary workaround, we should have all blocks */
+ const firstRt = this.rt[Object.keys(this.rt)[0]];
+ return `${this.journeyPattern.line.code} - ${(firstRt.blockNumber !== undefined ? firstRt.blockNumber : '?')} (${firstRt.vehicleNumber})`;
+ }
+ return `${this.journeyPattern.line.code}`;
+ }
+
/**
* Check if given a specific time, the journey is active
* @param {Date} time - Time
@@ -132,6 +141,7 @@ export default class VehicleJourney {
* Computes the realtime positions information of the vehicles belonging to this journey
* @return {Array.<{
* vehicleNumber: number,
+ * blockNumber: number,
* positions: {time: Date, distanceSinceLastStop: number, distanceFromStart: number,
* status: string, prognosed: boolean}
* }>} - List of enriched realtime position info
@@ -142,8 +152,11 @@ export default class VehicleJourney {
// Extract array of static schedule distances at each stop
const staticDistances = this.journeyPattern.distances;
- return Object.values(this.rt).map(({ vehicleNumber, times, distances }) => ({
+ return Object.values(this.rt).map(({ vehicleNumber, blockNumber, times,
+ distances, markers }) => ({
vehicleNumber,
+ blockNumber,
+ markers,
// Enrich the vehicles position data with the distance since the last stop
// and the index of that stop, as well as the status compared to the schedule
positions: times.map((time, index) => {
diff --git a/src/js/ptdataset.js b/src/js/ptdataset.js
index 1d21a61..3b39d30 100644
--- a/src/js/ptdataset.js
+++ b/src/js/ptdataset.js
@@ -14,12 +14,18 @@ import TimeUtils from './timeutils';
* Class representing a public transport dataset
*/
export default class PTDataset {
- constructor(inputData, referenceDate) {
+ constructor(inputData, referenceDate, markerData) {
+ this.updateUrl = inputData.updateUrl;
this.referenceDate = referenceDate;
Object.assign(this, PTDataset.computeStopsAndStopAreas(inputData.scheduledStopPoints));
Object.assign(this, this.computeLinesJourneyPatterns(inputData.journeyPatterns));
this.vehicleJourneys = this.computeVehicleJourneys(inputData.vehicleJourneys);
this.stopsLinks = this.computeLinks();
+ this.markers = null;
+ if (markerData != null && markerData.markers != null) {
+ this.markers = this.computeMarkers(markerData.markers);
+ this.addMarkersToDataset(this.markers);
+ }
// Compute times of the first and last stop of any journey in the dataset
this.earliestTime = Math.min(...Object.values(this.journeyPatterns)
@@ -28,6 +34,33 @@ export default class PTDataset {
.map(jp => jp.firstAndLastTimes.last));
}
+ addMarkersToDataset(markers) {
+ for (const marker of markers) {
+ const { vehicleJourneyCode, vehicleNumber } = marker.reference;
+ if (Object.prototype.hasOwnProperty.call(this.vehicleJourneys, vehicleJourneyCode)) {
+ const vehicleJourneyData = this.vehicleJourneys[vehicleJourneyCode];
+ const { rt } = vehicleJourneyData;
+ if (rt != null && vehicleNumber != null
+ && Object.prototype.hasOwnProperty.call(rt, vehicleNumber)) {
+ const vehicleData = rt[vehicleNumber];
+ if (Object.prototype.hasOwnProperty.call(vehicleData, 'markers')) {
+ const markersData = vehicleData.markers;
+ markersData.push(marker);
+ } else {
+ vehicleData.markers = [marker];
+ }
+ } else if (vehicleNumber == null) {
+ if (Object.prototype.hasOwnProperty.call(vehicleJourneyData, 'markers')) {
+ const markersData = vehicleJourneyData.markers;
+ markersData.push(marker);
+ } else {
+ vehicleJourneyData.markers = [marker];
+ }
+ }
+ }
+ }
+ }
+
/**
* Convert raw stops data into rich Stop and StopArea objects,
* storing them in an object indexed by their code for fast lookup
@@ -190,4 +223,40 @@ export default class PTDataset {
vehicleJourney => vehicleJourney.code,
);
}
+
+ /**
+ * Update raw vehicle journey realtime data into an existing VehicleJourney object,
+ * fetch each object indexed by their code for fast lookup and update that object
+ * @param {Object} _vehicleJourneys Raw vehicle realtime data
+ */
+ updateVehicleJourneys(_vehicleJourneys) {
+ for (const [code, { realtime, cancelled }] of Object.entries(_vehicleJourneys)) {
+ // Convert time in seconds since noon minus 12h to Date object
+ for (const rtVehicle of Object.values(realtime)) {
+ rtVehicle.times = rtVehicle.times.map(time => TimeUtils
+ .secondsToDateObject(time, this.referenceDate));
+ }
+
+ const vehicleJourney = this.vehicleJourneys[code];
+ if (typeof vehicleJourney !== 'undefined') {
+ vehicleJourney.rt = realtime;
+ vehicleJourney.cancelled = cancelled;
+ }
+ }
+ }
+
+ /**
+ * Transform the time used in the markers into Java Date.
+ * @param {Object} markers Raw marker data
+ * @return {Object} - Enriched markers data
+ */
+ computeMarkers(markers) {
+ return markers.map(({ id, reference, time, message, url }) => ({
+ id,
+ reference,
+ time: TimeUtils.secondsToDateObject(time, this.referenceDate),
+ message,
+ url,
+ }));
+ }
}
diff --git a/src/js/ptds.js b/src/js/ptds.js
index 8672bed..61015f4 100644
--- a/src/js/ptds.js
+++ b/src/js/ptds.js
@@ -1,6 +1,6 @@
import { select } from 'd3-selection';
import { timeFormat } from 'd3-time-format';
-import { timer } from 'd3-timer';
+import { timer, interval } from 'd3-timer';
import dat from 'dat.gui';
import PTDataset from './ptdataset';
@@ -11,19 +11,32 @@ const d3 = Object.assign({}, {
select,
timeFormat,
timer,
+ interval,
});
/**
* Main class
*/
export default class PTDS {
- constructor(inputData, options) {
- this.data = new PTDataset(inputData, options.selectedDate);
+ constructor(inputData, options, markerData) {
+ this.marey = null;
this.options = options;
+ this.data = new PTDataset(inputData, this.options.selectedDate, markerData);
+
+ if (this.options.realtime === true && this.data.updateUrl !== undefined) {
+ this.dataUpdateTimer = d3.interval(() => {
+ if (this.marey !== null) {
+ fetch(this.data.updateUrl).then(r => r.json()).then((updateData) => {
+ this.data.updateVehicleJourneys(updateData.vehicleJourneys);
+ });
+ this.marey.update();
+ }
+ }, 15000, 15000);
+ }
- if (['dual', 'marey'].includes(options.mode)) {
+ if (['dual', 'marey'].includes(this.options.mode)) {
this.journeyPatternMix = this.computeJourneyPatternMix();
- } else if (options.mode === 'spiralSimulation') {
+ } else if (this.options.mode === 'spiralSimulation') {
this.widgetTimeFormat = d3.timeFormat('%Y-%m-%d %H:%M:%S');
this.createSimulationWidget();
}
@@ -66,7 +79,8 @@ export default class PTDS {
// Compute the shared sequences between the longest journey pattern and all the other ones
for (const journeyPattern of Object.values(this.data.journeyPatterns)) {
- if (journeyPattern.code !== maxNstopsJP.code) {
+ if (journeyPattern.code !== maxNstopsJP.code
+ && (this.options.overlap || journeyPattern.line.code === maxNstopsJP.line.code)) {
const sharedSequences = maxNstopsJP.sharedSequences(journeyPattern);
if (sharedSequences) journeyPatternMix.otherJPs.push({ journeyPattern, sharedSequences });
}
@@ -131,6 +145,10 @@ export default class PTDS {
this.dims.marey.innerWidth = this.dims.marey.outerWidth - margins.marey.left
- margins.marey.right - this.dims.mareyScroll.width
- this.dims.mareyStopSelection.width - 30;
+ margins.mareyLabel = {
+ left: margins.marey.left + this.dims.marey.innerWidth + 50,
+ top: 50,
+ };
margins.mareyScroll = {
left: margins.marey.left + this.dims.marey.innerWidth + 100,
top: margins.marey.top,
@@ -158,8 +176,39 @@ export default class PTDS {
.attr('width', this.dims.marey.outerWidth)
.attr('height', this.dims.marey.outerHeight);
+ const label = mareySVG.append('g')
+ .attr('transform', `translate(${margins.mareyLabel.left}, ${margins.mareyLabel.top})`);
+
+ label.append('text')
+ .text(`${this.options.line} - ${this.options.direction}`)
+ .attr('font-size', '16')
+ .attr('font-weight', 'bold');
+
+ label.append('text')
+ .attr('transform', 'translate(100, 0)')
+ .text('reverse')
+ .on('click', () => {
+ d3.select('#map').remove();
+ d3.select('#marey-container').remove();
+ this.options.trip = null;
+ this.options.direction = (this.options.direction === 1 ? 2 : 1);
+ this.journeyPatternMix = this.computeJourneyPatternMix();
+ this.createVisualizations();
+ });
+
+ label.append('text')
+ .attr('transform', 'translate(150, 0)')
+ .text('realtime')
+ .on('click', () => {
+ d3.select('#map').remove();
+ d3.select('#marey-container').remove();
+ this.options.realtime = !this.options.realtime;
+ this.createVisualizations();
+ });
+
// Create transformed groups and store their reference
this.mareySVGgroups = {
+ label,
diagram: mareySVG.append('g')
.attr('transform', `translate(${margins.marey.left},${margins.marey.top})`),
scroll: mareySVG.append('g')
@@ -271,6 +320,11 @@ export default class PTDS {
);
}
+ let selectedTrip = null;
+ if (this.options.trip !== undefined) {
+ selectedTrip = this.data.vehicleJourneys[this.options.trip];
+ }
+
// If we are in "dual" mode, draw the Marey diagram of the chosen journey pattern
if (this.options.mode === 'dual') {
// Callback that updates the map when the timeline is moved in the Marey diagram
@@ -297,6 +351,8 @@ export default class PTDS {
this.mareySVGgroups,
this.dims,
timelineChangeCallback,
+ selectedTrip,
+ this.options.realtime,
);
} else if (this.options.mode === 'marey') {
// Creation of the Marey diagram
@@ -304,6 +360,9 @@ export default class PTDS {
this.journeyPatternMix,
this.mareySVGgroups,
this.dims,
+ null,
+ selectedTrip,
+ this.options.realtime,
);
}
}
diff --git a/src/js/viz_components/interactivemap.js b/src/js/viz_components/interactivemap.js
index 5fcc31c..86c3a93 100644
--- a/src/js/viz_components/interactivemap.js
+++ b/src/js/viz_components/interactivemap.js
@@ -219,7 +219,8 @@ export default class InteractiveMap {
// Stoparea selection
const stopAreasSel = this.stopAreasGroup.selectAll('g.stopArea')
.data(
- this.data.stopAreas.map(({ code, center }) => ({ code, center: this.mapToCanvas(center) })),
+ this.data.stopAreas.map(({ code, center, name }) => (
+ { code, center: this.mapToCanvas(center), name })),
({ code }) => code,
);
@@ -248,7 +249,7 @@ export default class InteractiveMap {
.append('text')
.attr('x', 0)
.attr('y', -1.5)
- .text(({ code }) => code);
+ .text(({ name }) => name);
}
/**
diff --git a/src/js/viz_components/mareydiagram.js b/src/js/viz_components/mareydiagram.js
index 513b308..c8f9891 100644
--- a/src/js/viz_components/mareydiagram.js
+++ b/src/js/viz_components/mareydiagram.js
@@ -26,6 +26,14 @@ const d3 = Object.assign({}, {
zoomIdentity,
});
+// TODO: move these constants in a separate config file
+const selectedTripStaticStopRadius = 3;
+const selectedTripRTposRadius = 3;
+const selectedTripRadius = 6;
+const deSelectedTripStaticStopRadius = 2;
+const deSelectedTripRTposRadius = 2;
+const deSelectedTripRadius = 3;
+
/**
* This class manages the Marey diagram visualization.
*/
@@ -33,15 +41,21 @@ export default class MareyDiagram {
/**
*
* @param {Object} journeyPatternMix - Information to draw on the diagram
- * @param {{diagram: Object, scroll: Object, stopSelection: Object}} svgGroups - SVG groups
+ * @param {{label: Object, diagram: Object,
+ * scroll: Object, stopSelection: Object}} svgGroups - SVG groups
* for the diagram, the scroll and the stop selection
* @param {Object} dims - Dimensions of the diagram
* @param {Function} changeCallback - Callback for the time change
+ * @param {Object} trip - Initial selected trip
+ * @param {Boolean} realtime - Show realtime information
*/
- constructor(journeyPatternMix, svgGroups, dims, changeCallback) {
+ constructor(journeyPatternMix, svgGroups, dims, changeCallback, trip, realtime) {
this.journeyPatternMix = journeyPatternMix;
this.g = svgGroups;
this.dims = dims;
+ this.realtime = realtime;
+
+ this.trip = (trip !== undefined ? trip : null);
// Compute information needed to draw the trips
this.trips = this.computeTrips();
@@ -49,6 +63,42 @@ export default class MareyDiagram {
this.initialSetup(changeCallback);
// Draw the trips in the diagram
this.drawTrips();
+
+ // Refactor this, so tripClick and below is the same
+ if (this.trip !== null) {
+ let { first, last } = this.trip.firstAndLastTimes;
+ first = d3.timeMinute.offset(first, -1);
+ last = d3.timeMinute.offset(last, +1);
+ // Update zoom status to reflect change in domain
+ this.g.diagram.call(this.zoomBehaviour.transform, d3.zoomIdentity
+ .scale(this.lastK)
+ .translate(0, -this.yScrollScale(first)));
+ // Update brush status to reflect change in domain
+ this.g.scroll
+ .call(this.brushBehaviour.move, [
+ this.yScrollScale(first),
+ this.yScrollScale(last),
+ ]);
+ // Update Marey diagram domain
+ this.yScale.domain([first, last]);
+
+ const tripSel = d3.select(`g.trip[data-trip-code='${this.trip.code}']`);
+ // Add 'selected' class to the trip SVG group
+ tripSel.classed('selected', true);
+ tripSel.selectAll('circle.static-stop').attr('r', selectedTripStaticStopRadius);
+ tripSel.selectAll('circle.rt-position').attr('r', selectedTripRTposRadius);
+ // In the map, highlight the vehicle
+ d3.select(`#map g.trip[data-code='${this.trip.code}'] circle`).attr('r', selectedTripRadius);
+ }
+ }
+
+ /**
+ * Updates the current dataset in the Marey diagram, and redraw.
+ * TODO: investigate if we could improve the update speed by only updating realtime.
+ */
+ update() {
+ this.trips = this.computeTrips();
+ this.drawTrips();
}
/**
@@ -472,7 +522,7 @@ export default class MareyDiagram {
// Truncate the tick label if longer than maxChars chars
const maxChars = 25;
const stop = this.journeyPatternMix.referenceJP.stops[index];
- let label = `${stop.code} ${stop.name}`;
+ let label = `${stop.name}`;
if (label.length > maxChars) label = `${label.substr(0, maxChars - 3)}...`;
return label;
});
@@ -495,7 +545,7 @@ export default class MareyDiagram {
this.xAxis = d3.axisTop(this.xScale)
.tickSize(-this.dims.marey.innerHeight)
.tickValues(this.journeyPatternMix.referenceJP.distances)
- .tickFormat((_, index) => this.journeyPatternMix.referenceJP.stops[index].code);
+ .tickFormat((_, index) => this.journeyPatternMix.referenceJP.stops[index].name);
}
// Enhance vertical lines representing stops adding the stop code as attribute
@@ -657,9 +707,43 @@ export default class MareyDiagram {
* @return {Array.