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.} - Trip drawing information */ computeTrips() { + const trips = []; // Compute drawing information for the trips of the reference journey pattern - const trips = this.journeyPatternMix.referenceJP.vehicleJourneys - .map(({ code, staticSchedule, firstAndLastTimes, realTimeData }) => ({ + for (const { code, staticSchedule, firstAndLastTimes, realTimeData, markers } of + this.journeyPatternMix.referenceJP.vehicleJourneys) { + const tripMarkers = []; + // If there are markers to add, add them + if (markers) { + // Deep clone markers + const leftOverMarkers = markers.slice(); + for (const [indexP, { time, distance }] of staticSchedule.entries()) { + for (const [indexM, marker] of leftOverMarkers.entries()) { + if (indexP !== staticSchedule.length - 1) { + const nextPosition = staticSchedule[indexP + 1]; + if (time <= marker.time && marker.time < nextPosition.time) { + const x1 = distance; + const y1 = time; + const x2 = nextPosition.distance; + const y2 = nextPosition.time; + const yM = marker.time; + const xM = (((x1 - x2) / (y1 - y2)) * yM) + - (((x1 * y2) - (x2 * y1)) / (y1 - y2)); + marker.distance = xM; + tripMarkers.push(marker); + leftOverMarkers.splice(indexM, 1); + } + if ((indexP === staticSchedule.length - 2) + && marker.time.getTime() === nextPosition.time.getTime()) { + marker.distance = nextPosition.distance; + tripMarkers.push(marker); + leftOverMarkers.splice(indexM, 1); + } + if (leftOverMarkers.length === 0) break; + } + } + } + } + trips.push({ code, // For the reference journey pattern there is only one sequence staticSequences: [staticSchedule.map(({ time, distance }) => ({ time, distance }))], @@ -673,14 +757,17 @@ export default class MareyDiagram { prognosed, }))], })), + markers: tripMarkers, firstAndLastTimes, - })); + }); + } // Then compute the trip drawing information for the other journey patterns that share // at least one link with the reference JP for (const otherJP of this.journeyPatternMix.otherJPs) { // Iterate over the trips of the journey pattern for (const vehicleJourney of otherJP.journeyPattern.vehicleJourneys) { + const tripMarkers = []; // Min and max time of every static/realtime position of the current journey, // only for the shared segments let minTime = null; @@ -711,22 +798,61 @@ export default class MareyDiagram { })); } + // If there are markers to add, add them + if (vehicleJourney.markers) { + // Deep clone markers + const leftOverMarkers = vehicleJourney.markers.slice(); + for (const staticSequence of staticSequences) { + for (const [indexP, { time, distance }] of staticSequence.entries()) { + for (const [indexM, marker] of leftOverMarkers.entries()) { + if (indexP !== staticSequence.length - 1) { + const nextPosition = staticSequence[indexP + 1]; + if (time <= marker.time && marker.time < nextPosition.time) { + const x1 = distance; + const y1 = time; + const x2 = nextPosition.distance; + const y2 = nextPosition.time; + const yM = marker.time; + const xM = (((x1 - x2) / (y1 - y2)) * yM) + - (((x1 * y2) - (x2 * y1)) / (y1 - y2)); + marker.distance = xM; + tripMarkers.push(marker); + leftOverMarkers.splice(indexM, 1); + } + if ((indexP === staticSequence.length - 2) + && marker.time.getTime() === nextPosition.time.getTime()) { + marker.distance = nextPosition.distance; + tripMarkers.push(marker); + leftOverMarkers.splice(indexM, 1); + } + if (leftOverMarkers.length === 0) break; + } + } + } + } + } + const realtimeSequences = []; // Iterate over each of the real time vehicles - for (const { vehicleNumber, positions } of vehicleJourney.realTimeData) { + for (const { vehicleNumber, blockNumber, positions, markers } + of vehicleJourney.realTimeData) { const vehicleSequences = []; // Iterate over the shared sequence for (let i = 0; i < referenceSequences.length; i += 1) { - // Filter out last stop of the sequence because it is not valid as "last stop" - const refSequence = referenceSequences[i].slice(0, -1); - const otherSequence = otherSequences[i].slice(0, -1); + const refSequence = referenceSequences[i]; + const otherSequence = otherSequences[i]; + const lastStopIndexTerm = otherSequence[otherSequence.length - 1]; // For each shared sequence, add the positions data of the current trip by mapping // the distance relative to the last stop of the trip to the absolute distance // in the reference journey pattern const vehicleSequence = positions .filter(({ lastStopIndex }) => otherSequence.includes(lastStopIndex)) + // Filter out last stop of the sequence beyond the arrival / departure + .filter(({ distanceSinceLastStop, + lastStopIndex }) => !(distanceSinceLastStop > 0 + && lastStopIndex === lastStopIndexTerm)) .map(({ time, distanceSinceLastStop, lastStopIndex, status, prognosed }) => { // Find the index of the last stop before the current position // in the reference journey pattern @@ -747,6 +873,38 @@ export default class MareyDiagram { }; }); + // If there are markers to add, add them + if (markers) { + // Deep clone markers + const leftOverMarkers = markers.slice(); + for (const [indexP, { time, distance }] of vehicleSequence.entries()) { + for (const [indexM, marker] of leftOverMarkers.entries()) { + if (indexP !== vehicleSequence.length - 1) { + const nextPosition = vehicleSequence[indexP + 1]; + if (time <= marker.time && marker.time < nextPosition.time) { + const x1 = distance; + const y1 = time; + const x2 = nextPosition.distance; + const y2 = nextPosition.time; + const yM = marker.time; + const xM = (((x1 - x2) / (y1 - y2)) * yM) + - (((x1 * y2) - (x2 * y1)) / (y1 - y2)); + marker.distance = xM; + tripMarkers.push(marker); + leftOverMarkers.splice(indexM, 1); + } + if ((indexP === vehicleSequence.length - 2) + && marker.time.getTime() === nextPosition.time.getTime()) { + marker.distance = nextPosition.distance; + tripMarkers.push(marker); + leftOverMarkers.splice(indexM, 1); + } + if (leftOverMarkers.length === 0) break; + } + } + } + } + // Filter out sequences with zero length (can happen that a vehicle belonging to a // journey pattern that shares >1 link(s) with the reference one does not have any // positions to be drawn because the positions are not part of the shared links) @@ -758,6 +916,7 @@ export default class MareyDiagram { if (vehicleSequences.length) { realtimeSequences.push({ vehicleNumber, + blockNumber, sequences: vehicleSequences, }); } @@ -765,8 +924,10 @@ export default class MareyDiagram { trips.push({ code: vehicleJourney.code, + tripLabel: vehicleJourney.tripLabel, staticSequences, realtimeSequences, + markers: tripMarkers, firstAndLastTimes: { first: minTime, last: maxTime }, }); } @@ -780,14 +941,6 @@ export default class MareyDiagram { * @param {number} transitionDuration - Duration of the transition in case of stop selection */ drawTrips(transitionDuration) { - // 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; - // Determines if a trip is in the currently selected domain const tripInSelectedDomain = (trip) => { const [minShownTime, maxShownTime] = this.yScale.domain(); @@ -820,6 +973,7 @@ export default class MareyDiagram { function tripMouseOver(trip) { // Get the SVG g element corresponding to this trip const tripSel = d3.select(this); + // Get the current mouse position const [xPos, yPos] = d3.mouse(overlay.node()); // Add label with the code of the trip next to the mouse cursor @@ -828,7 +982,7 @@ export default class MareyDiagram { .attr('x', xPos) .attr('y', yPos) .attr('dy', -10) - .text(({ code }) => code); + .text(({ tripLabel }) => tripLabel); // Add 'selected' class to the trip SVG group tripSel.classed('selected', true); tripSel.selectAll('circle.static-stop').attr('r', selectedTripStaticStopRadius); @@ -841,12 +995,15 @@ export default class MareyDiagram { function tripMouseOut(trip) { // Similarly as above const tripSel = d3.select(this); - tripSel.select('text.tripLabel').remove(); - tripSel.classed('selected', false); - tripSel.selectAll('circle.static-stop').attr('r', deSelectedTripStaticStopRadius); - tripSel.selectAll('circle.rt-position').attr('r', deSelectedTripRTposRadius); - d3.select(`#map g.trip[data-code='${trip.code}'] circle`).attr('r', deSelectedTripRadius); + if (that.trip === null || that.trip.code !== tripSel.datum().code) { + tripSel.classed('selected', false); + d3.select(`#map g.trip[data-code='${trip.code}'] circle`).attr('r', deSelectedTripRadius); + tripSel.selectAll('circle.static-stop').attr('r', deSelectedTripStaticStopRadius); + tripSel.selectAll('circle.rt-position').attr('r', deSelectedTripRTposRadius); + } + + tripSel.select('text.tripLabel').remove(); } // Handle click on a trip @@ -867,6 +1024,9 @@ export default class MareyDiagram { // Update Marey diagram domain that.yScale.domain([first, last]); tripMouseOut.call(this, trip); + + // Update the Window Location Hash + window.location.hash = trip.code; } // Trip enter @@ -917,64 +1077,97 @@ export default class MareyDiagram { .attr('cx', ({ distance }) => this.xScale(distance)) .attr('cy', ({ time }) => this.yScale(time)); - // Trip enter + update > realtime vehicle sequences selection - const realtimeVehiclesSel = tripsEnterUpdateSel - .selectAll('g.vehicle') - .data(({ realtimeSequences }) => realtimeSequences); - - // Trip enter + update > realtime vehicle sequences exit - realtimeVehiclesSel.exit().remove(); - - // Trip enter + update > realtime vehicle sequences enter - const realtimeVehiclesEnterUpdateSel = realtimeVehiclesSel.enter() - .append('g') - .attr('class', 'vehicle') - .attr('data-vehicle-number', ({ vehicleNumber }) => vehicleNumber) - // Trip enter + update > realtime vehicle sequences enter + update - .merge(realtimeVehiclesSel); - - // Trip enter + update > realtime vehicle sequences > realtime link selection - const realtimeVehiclesLinksSel = realtimeVehiclesEnterUpdateSel - // const realtimeVehiclesEnterUpdateSel - .selectAll('path.rt-sequence') - // Compute the realtime links for each sequence and make a single array out of it - .data(({ sequences }) => flatten(sequences.map(sequence => MareyDiagram - .getRealtimePaths(sequence)))); - - // Trip enter + update > realtime vehicle sequences > realtime link exit - realtimeVehiclesLinksSel.exit().remove(); - - // // Trip enter + update > realtime vehicle sequences > realtime link enter - realtimeVehiclesLinksSel.enter() - .append('path') - // Trip enter + update > realtime vehicle sequences > realtime link enter + update - .merge(realtimeVehiclesLinksSel) - .attr('class', ({ status }) => `rt-sequence ${status}`) - .classed('prognosed', ({ prognosed }) => prognosed) - .transition() - .duration(transitionDuration) - .attr('d', ({ positions }) => this.tripLineGenerator(positions)); + if (this.realtime) { + // Trip enter + update > realtime vehicle sequences selection + const realtimeVehiclesSel = tripsEnterUpdateSel + .selectAll('g.vehicle') + .data(({ realtimeSequences }) => realtimeSequences); + + // Trip enter + update > realtime vehicle sequences exit + realtimeVehiclesSel.exit().remove(); + + // Trip enter + update > realtime vehicle sequences enter + const realtimeVehiclesEnterUpdateSel = realtimeVehiclesSel.enter() + .append('g') + .attr('class', 'vehicle') + .attr('data-vehicle-number', ({ vehicleNumber }) => vehicleNumber) + // Trip enter + update > realtime vehicle sequences enter + update + .merge(realtimeVehiclesSel); + + // Trip enter + update > realtime vehicle sequences > realtime link selection + const realtimeVehiclesLinksSel = realtimeVehiclesEnterUpdateSel + // const realtimeVehiclesEnterUpdateSel + .selectAll('path.rt-sequence') + // Compute the realtime links for each sequence and make a single array out of it + .data(({ sequences }) => flatten(sequences.map(sequence => MareyDiagram + .getRealtimePaths(sequence)))); + + // Trip enter + update > realtime vehicle sequences > realtime link exit + realtimeVehiclesLinksSel.exit().remove(); + + // Trip enter + update > realtime vehicle sequences > realtime link enter + realtimeVehiclesLinksSel.enter() + .append('path') + // Trip enter + update > realtime vehicle sequences > realtime link enter + update + .merge(realtimeVehiclesLinksSel) + .attr('class', ({ status }) => `rt-sequence ${status}`) + .classed('prognosed', ({ prognosed }) => prognosed) + .transition() + .duration(transitionDuration) + .attr('d', ({ positions }) => this.tripLineGenerator(positions)); + + // Trip enter + update > realtime vehicle sequences > realtime position selection + const realtimeVehiclesPositionsSel = realtimeVehiclesEnterUpdateSel + .selectAll('circle.rt-position') + // Draw the circles representing the positions only at the maximum zoom level + .data(({ sequences }) => (this.currentApproximation.showDots ? flatten(sequences) : [])); + + // Trip enter + update > realtime vehicle sequences > realtime position exit + realtimeVehiclesPositionsSel.exit().remove(); - // Trip enter + update > realtime vehicle sequences > realtime position selection - const realtimeVehiclesPositionsSel = realtimeVehiclesEnterUpdateSel - .selectAll('circle.rt-position') - // Draw the circles representing the positions only at the maximum zoom level - .data(({ sequences }) => (this.currentApproximation.showDots ? flatten(sequences) : [])); + // Trip enter + update > realtime vehicle sequences > realtime position enter + realtimeVehiclesPositionsSel.enter() + .append('circle') + .attr('class', ({ status }) => `rt-position ${status}`) + .classed('prognosed', ({ prognosed }) => prognosed) + .attr('r', deSelectedTripRTposRadius) + .merge(realtimeVehiclesPositionsSel) + .transition() + .duration(transitionDuration) + .attr('cx', ({ distance }) => this.xScale(distance)) + // Trip enter + update > realtime vehicle sequences > realtime position enter + .attr('cy', ({ time }) => this.yScale(time)); + } - // Trip enter + update > realtime vehicle sequences > realtime position exit - realtimeVehiclesPositionsSel.exit().remove(); + // Draw the markers at the end so that they are on top of everything else + // Trip enter + update > marker selection + const tripMarkersSel = tripsEnterUpdateSel + .selectAll('g.marker') + .data(({ markers }) => (typeof markers !== 'undefined' ? markers : [])); - // Trip enter + update > realtime vehicle sequences > realtime position enter - realtimeVehiclesPositionsSel.enter() - .append('circle') - .attr('class', ({ status }) => `rt-position ${status}`) - .classed('prognosed', ({ prognosed }) => prognosed) - .attr('r', deSelectedTripRTposRadius) - .merge(realtimeVehiclesPositionsSel) - .transition() - .duration(transitionDuration) - .attr('cx', ({ distance }) => this.xScale(distance)) - // Trip enter + update > realtime vehicle sequences > realtime position enter - .attr('cy', ({ time }) => this.yScale(time)); + // Trip enter + update > marker exit + tripMarkersSel.exit().remove(); + + const referenceJPstopsDistances = this.journeyPatternMix.referenceJP.distances; + const lastStopDistance = referenceJPstopsDistances[referenceJPstopsDistances.length - 1]; + + // Trip enter + update > marker enter + const tripMarkersGroup = tripMarkersSel.enter().append('g').attr('class', 'marker'); + tripMarkersGroup + .on('mouseover', function f() { + d3event.stopPropagation(); + d3.select(this).append('text').attr('class', 'message').text(({ message, url }) => `${message} | ${url}`); + }) + .on('mouseout', function f() { + d3event.stopPropagation(); + d3.select(this).select('text.message').remove(); + }) + .merge(tripMarkersSel) + .attr('transform', ({ distance, time }) => `translate(${this.xScale(distance) + (distance === lastStopDistance ? -15 : 0)},${this.yScale(time)})`); + + tripMarkersGroup.append('image') + .attr('width', 15) + .attr('height', 15) + .attr('xlink:href', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABmJLR0QA/wD/AP+gvaeTAAADC0lEQVR4nO2aPWgUQRiGn1XjT4yQwiBGCAhaxDQSBEEJYqNC1NhEbCQWYikEC1vBaBkQCSKWYqFY2Kig8a8QIdGAiI0/KIiCQUW8i4IJWYvZJXfnTG5mb3ZnQuaBD3Kb2W/f99293dlJIBAIBAKBQCAQCATmiIBjwBgwBcQLtMrAE2C/aQDDHoi3XWd0ze/2QGxepXUl3PRAaF71uNZsJAngC7BeJ6kFSBlYU7lhiWTQ2mK0OOG/Ey4L4HsBQlzxoXaDLIDRAoS4QstbNzCL+xuW7ZoBOnWTGvFAsO0a0TUPsBJ46oFoW/UCaDYJAKANeOeB+EbrE9Buaj6lHfE+4NpE1noJdGQ1n9IMXPXAjGldB1ZnMXxYsf0I8MMDY/XqJ3DU0FsVM8CA4ncbgHsemFTVQ9SX/AAwrRNA2uwS4mlQSwScBH57YDitP8Ap5BO75cDFirHaAcTAa8TESEYnMO6B+VfAVoXGLYhHYOV4owBi4C9iMWGpZOwy4HQypmjjs8AFYIVEVwScQL6aZRxAWs+ATYp9tgNvCjT/Edil0NKBuBeo9s0cQAz8QiQrYxXijOT9HnEDaFVo6Kf+k6qhANK6g3rRZB9iUcW28UngkOKYrcA1zT5WAoiBr0CfokcbcMui+buop7N7gc8GvawFkNYVoEXR6zhQasB4KekhowW4jPlXznoAMfAe2Knot5lsj8vxZF8ZO4C3GbXmEkCMmEGeR0w8amkChhAzsXp9ppOxTYo+55JjZdWZWwBpTQBdit7dzP92OYZ64tWV9G5UX+4BxIip6SDyZfcI6AVuI+7sk8nPvfOMH0x62tBWSABpjSJeoLKyDhGOTU11sXmwGPiGmKCY0p/sa1tP4QFUXg09GsfvAR7kqKMK2XdOK6UGeI5YrbnP3B8qNgJ7EAsW23I+fqT8kJB3AK6p8ixbQFhUhABcC3BNCMC1ANeEACTbpgpXURyl2g2yACYKEOIKLW995DcNdV0HdJMa8kCs7Tqraz7lIOL/6soeiM9aZeARBmc+EAgEAoHA4uEfmPh3WpWTDh8AAAAASUVORK5CYII='); } } diff --git a/src/scss/main.scss b/src/scss/main.scss index 75a5662..2800cbc 100644 --- a/src/scss/main.scss +++ b/src/scss/main.scss @@ -48,6 +48,10 @@ svg { } #marey { + g.marker text{ + font-family: FontAwesome; + } + .marey-scroll { rect.selection { stroke: black; @@ -71,7 +75,7 @@ svg { display: none; } text { - transform: translate(4px, 0px); + transform: translate(8px, 0px); } } rect.selection { diff --git a/yarn.lock b/yarn.lock index 33d5889..bebbaf4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2061,11 +2061,15 @@ d3-time@1, d3-time@^1.0.8: resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.0.8.tgz#dbd2d6007bf416fe67a76d17947b784bffea1e84" integrity sha512-YRZkNhphZh3KcnBfitvF3c6E0JOFGikHZ4YqD+Lzv83ZHn1/u6yGenRU1m+KAk9J1GnZMnKcrtfvSktlA1DXNQ== -d3-timer@1, d3-timer@^1.0.7: +d3-timer@1: version "1.0.7" resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-1.0.7.tgz#df9650ca587f6c96607ff4e60cc38229e8dd8531" integrity sha512-vMZXR88XujmG/L5oB96NNKH5lCWwiLM/S2HyyAQLcjWJCloK5shxta4CwOFYLZoY3AWX73v8Lgv4cCAdWtRmOA== +d3-timer@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-1.0.9.tgz#f7bb8c0d597d792ff7131e1c24a36dd471a471ba" + d3-transition@1: version "1.1.1" resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-1.1.1.tgz#d8ef89c3b848735b060e54a39b32aaebaa421039"