diff --git a/example/css/style.css b/example/css/style.css new file mode 100644 index 00000000..34916d3c --- /dev/null +++ b/example/css/style.css @@ -0,0 +1,121 @@ +html, +body { + margin: 0; + height: 100%; + width: 100%; +} + +#mapid { + height: 100%; +} + +#results { + position: absolute; + left: 10px; + bottom: 10px; + border-radius: 4px; + display: flex; + flex-direction: column; + font-family: sans-serif; + max-height: 500px; + z-index: 500; + overflow-y: scroll; + width: 500px; +} + +#results > .path { + margin: 10px; + background: rgba(255, 255, 255, .8); + border: 1px solid #ccc; + border-top: none; + border-radius: 4px; + box-shadow: 0 4px 10px -2px rgba(0, 0, 0, 0.2); + flex-wrap: wrap; +} + +#results > .path > .header { + padding: 10px; + background: rgba(0, 0, 0, .25); + color: #fff; + text-shadow: 0 0 1px black; +} + +#results > .path > .step { + display: flex; + flex-direction: row; + margin: 10px; +} + +.travelMode { + width: 45px; + flex-shrink: 0; + background-size: 20px 20px; + background-repeat: no-repeat; +} + +.details { +} + +.travelMode.walking { + background-image: url(data:image/svg+xml;base64,PHN2ZyBhcmlhLWhpZGRlbj0idHJ1ZSIgZGF0YS1wcmVmaXg9ImZhcyIgZGF0YS1pY29uPSJ3YWxraW5nIiBjbGFzcz0ic3ZnLWlubGluZS0tZmEgZmEtd2Fsa2luZyBmYS13LTEwIiByb2xlPSJpbWciIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDMyMCA1MTIiPjxwYXRoIGZpbGw9ImN1cnJlbnRDb2xvciIgZD0iTTIwOCA5NmMyNi41IDAgNDgtMjEuNSA0OC00OFMyMzQuNSAwIDIwOCAwcy00OCAyMS41LTQ4IDQ4IDIxLjUgNDggNDggNDh6bTk0LjUgMTQ5LjFsLTIzLjMtMTEuOC05LjctMjkuNGMtMTQuNy00NC42LTU1LjctNzUuOC0xMDIuMi03NS45LTM2LS4xLTU1LjkgMTAuMS05My4zIDI1LjItMjEuNiA4LjctMzkuMyAyNS4yLTQ5LjcgNDYuMkwxNy42IDIxM2MtNy44IDE1LjgtMS41IDM1IDE0LjIgNDIuOSAxNS42IDcuOSAzNC42IDEuNSA0Mi41LTE0LjNMODEgMjI4YzMuNS03IDkuMy0xMi41IDE2LjUtMTUuNGwyNi44LTEwLjgtMTUuMiA2MC43Yy01LjIgMjAuOC40IDQyLjkgMTQuOSA1OC44bDU5LjkgNjUuNGM3LjIgNy45IDEyLjMgMTcuNCAxNC45IDI3LjdsMTguMyA3My4zYzQuMyAxNy4xIDIxLjcgMjcuNiAzOC44IDIzLjMgMTcuMS00LjMgMjcuNi0yMS43IDIzLjMtMzguOGwtMjIuMi04OWMtMi42LTEwLjMtNy43LTE5LjktMTQuOS0yNy43bC00NS41LTQ5LjcgMTcuMi02OC43IDUuNSAxNi41YzUuMyAxNi4xIDE2LjcgMjkuNCAzMS43IDM3bDIzLjMgMTEuOGMxNS42IDcuOSAzNC42IDEuNSA0Mi41LTE0LjMgNy43LTE1LjcgMS40LTM1LjEtMTQuMy00M3pNNzMuNiAzODUuOGMtMy4yIDguMS04IDE1LjQtMTQuMiAyMS41bC01MCA1MC4xYy0xMi41IDEyLjUtMTIuNSAzMi44IDAgNDUuM3MzMi43IDEyLjUgNDUuMiAwbDU5LjQtNTkuNGM2LjEtNi4xIDEwLjktMTMuNCAxNC4yLTIxLjVsMTMuNS0zMy44Yy01NS4zLTYwLjMtMzguNy00MS44LTQ3LjQtNTMuN2wtMjAuNyA1MS41eiI+PC9wYXRoPjwvc3ZnPg==); +} + +.travelMode.train { + background-image: url(data:image/svg+xml;base64,PHN2ZyBhcmlhLWhpZGRlbj0idHJ1ZSIgZGF0YS1wcmVmaXg9ImZhcyIgZGF0YS1pY29uPSJ0cmFpbiIgY2xhc3M9InN2Zy1pbmxpbmUtLWZhIGZhLXRyYWluIGZhLXctMTQiIHJvbGU9ImltZyIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB2aWV3Qm94PSIwIDAgNDQ4IDUxMiI+PHBhdGggZmlsbD0iY3VycmVudENvbG9yIiBkPSJNNDQ4IDk2djI1NmMwIDUxLjgxNS02MS42MjQgOTYtMTMwLjAyMiA5Nmw2Mi45OCA0OS43MjFDMzg2LjkwNSA1MDIuNDE3IDM4My41NjIgNTEyIDM3NiA1MTJINzJjLTcuNTc4IDAtMTAuODkyLTkuNTk0LTQuOTU3LTE0LjI3OUwxMzAuMDIyIDQ0OEM2MS44MiA0NDggMCA0MDMuOTU0IDAgMzUyVjk2QzAgNDIuOTgxIDY0IDAgMTI4IDBoMTkyYzY1IDAgMTI4IDQyLjk4MSAxMjggOTZ6bS00OCAxMzZWMTIwYzAtMTMuMjU1LTEwLjc0NS0yNC0yNC0yNEg3MmMtMTMuMjU1IDAtMjQgMTAuNzQ1LTI0IDI0djExMmMwIDEzLjI1NSAxMC43NDUgMjQgMjQgMjRoMzA0YzEzLjI1NSAwIDI0LTEwLjc0NSAyNC0yNHptLTE3NiA2NGMtMzAuOTI4IDAtNTYgMjUuMDcyLTU2IDU2czI1LjA3MiA1NiA1NiA1NiA1Ni0yNS4wNzIgNTYtNTYtMjUuMDcyLTU2LTU2LTU2eiI+PC9wYXRoPjwvc3ZnPg==); +} + +.travelMode.bus { + background-image: url(data:image/svg+xml;base64,PHN2ZyBhcmlhLWhpZGRlbj0idHJ1ZSIgZm9jdXNhYmxlPSJmYWxzZSIgZGF0YS1wcmVmaXg9ImZhcyIgZGF0YS1pY29uPSJidXMiIGNsYXNzPSJzdmctaW5saW5lLS1mYSBmYS1idXMgZmEtdy0xNiIgcm9sZT0iaW1nIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj48cGF0aCBmaWxsPSJjdXJyZW50Q29sb3IiIGQ9Ik00ODggMTI4aC04VjgwYzAtNDQuOC05OS4yLTgwLTIyNC04MFMzMiAzNS4yIDMyIDgwdjQ4aC04Yy0xMy4yNSAwLTI0IDEwLjc0LTI0IDI0djgwYzAgMTMuMjUgMTAuNzUgMjQgMjQgMjRoOHYxNjBjMCAxNy42NyAxNC4zMyAzMiAzMiAzMnYzMmMwIDE3LjY3IDE0LjMzIDMyIDMyIDMyaDMyYzE3LjY3IDAgMzItMTQuMzMgMzItMzJ2LTMyaDE5MnYzMmMwIDE3LjY3IDE0LjMzIDMyIDMyIDMyaDMyYzE3LjY3IDAgMzItMTQuMzMgMzItMzJ2LTMyaDYuNGMxNiAwIDI1LjYtMTIuOCAyNS42LTI1LjZWMjU2aDhjMTMuMjUgMCAyNC0xMC43NSAyNC0yNHYtODBjMC0xMy4yNi0xMC43NS0yNC0yNC0yNHpNMTEyIDQwMGMtMTcuNjcgMC0zMi0xNC4zMy0zMi0zMnMxNC4zMy0zMiAzMi0zMiAzMiAxNC4zMyAzMiAzMi0xNC4zMyAzMi0zMiAzMnptMTYtMTEyYy0xNy42NyAwLTMyLTE0LjMzLTMyLTMyVjEyOGMwLTE3LjY3IDE0LjMzLTMyIDMyLTMyaDI1NmMxNy42NyAwIDMyIDE0LjMzIDMyIDMydjEyOGMwIDE3LjY3LTE0LjMzIDMyLTMyIDMySDEyOHptMjcyIDExMmMtMTcuNjcgMC0zMi0xNC4zMy0zMi0zMnMxNC4zMy0zMiAzMi0zMiAzMiAxNC4zMyAzMiAzMi0xNC4zMyAzMi0zMiAzMnoiPjwvcGF0aD48L3N2Zz4=); +} + +.duration { + margin-bottom: 5px; + margin-top: 5px; +} + +.enterConnectionId, +.exitConnectionId { + opacity: 0.5; +} + +#actions { + position: absolute; + top: 10px; + right: 10px; + z-index: 500; +} + +#actions > button { + border-radius: 100px; + background: rgba(255, 255, 255, .8); + font-size: 2em; + padding: .25em 1em; +} + +#prefetch { + position: absolute; + top: 10px; + left: 54px; + z-index: 500; +} + +.prefetch-view { + height: 6px; + margin-bottom: 3px; + border-radius: 30px; + background: #3556a9; + color: #fff; + font-family: sans-serif; + padding: 3px 5px; + transition: 50ms width ease-in-out; +} + +.prefetch-view::after { + content: attr(data-last); + color: #fff; + float: right; +} + +#prefetch-bar { + height: 18px; +} diff --git a/example/index.html b/example/index.html new file mode 100644 index 00000000..12ee0426 --- /dev/null +++ b/example/index.html @@ -0,0 +1,26 @@ + + + + + Map + + + + + +
+
+
+ +
+
+
+
+ + + + diff --git a/example/js/index.js b/example/js/index.js new file mode 100644 index 00000000..a0343ce5 --- /dev/null +++ b/example/js/index.js @@ -0,0 +1,475 @@ +const map = L.map("mapid").setView([51.050043, 3.719926], 10); + +L.tileLayer( + "https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token={accessToken}", + { + attribution: + "Map data © OpenStreetMap contributors, CC-BY-SA, Imagery © Mapbox", + maxZoom: 18, + id: "mapbox.streets", + accessToken: + "pk.eyJ1IjoibWF4aW10bWFydGluIiwiYSI6ImNqcHdqbjdhaDAzYzc0Mm04eDFhamkzenMifQ.0uNbKJ2WHATkKBBSADuhyQ" + } +).addTo(map); + +const planner = new Planner(); + +planner.prefetchStops(); +planner.prefetchConnections(); + +let plannerResult; +const resetButton = document.querySelector("#reset"); +const results = document.querySelector("#results"); +const prefetchWrapper = document.querySelector("#prefetch"); +const prefetchBar = document.querySelector("#prefetch-bar"); + +let lines = []; +let polyLines = []; +let resultObjects = []; +let query = []; +let allStops = []; +let prefetchViews = []; + +let firstPrefetch; + +const removeLines = () => { + for (const line of polyLines) { + line.remove(); + } + + lines = []; + polyLines = []; + lines = []; +}; + +const removeResultObjects = () => { + for (const obj of resultObjects) { + obj.remove(); + } + + resultObjects = []; +}; + +const removePrefetchView = () => { + const view = document.getElementById("prefetch"); + + if (!view.hasChildNodes()) { + return; + } + + for (const child of [...view.childNodes]) { + if (!child.id) { + child.parentNode.removeChild(child); + } + } +}; + +resetButton.onclick = e => { + removeLines(); + removeResultObjects(); + query = []; + results.innerHTML = ""; + + for (const stop of allStops) { + stop.addTo(map); + } + + if (plannerResult) { + plannerResult.close(); + } + + removePrefetchView(); +}; + +const pxPerMs = .00005; +const getPrefetchViewWidth = (start, stop) => { + if (!start || !stop) { + return 0; + } + + return (stop.valueOf() - start.valueOf()) * pxPerMs; +}; + +planner.getAllStops().then(stops => { + for (const stop of stops) { + if (stop["http://semweb.mmlab.be/ns/stoptimes#avgStopTimes"] > 100) { + const marker = L.marker([stop.latitude, stop.longitude]).addTo(map); + + marker.bindPopup(stop.name); + + allStops.push(marker); + + marker.on("click", e => { + selectRoute(e, stop.id); + }); + } + } +}); + +planner + .on("query", query => { + console.log("Query", query); + }) + .on("sub-query", query => { + const { minimumDepartureTime, maximumArrivalTime, maximumTravelDuration } = query; + + console.log( + "[Subquery]", + minimumDepartureTime, + maximumArrivalTime, + maximumArrivalTime - minimumDepartureTime, + maximumTravelDuration, + ); + + removeLines(); + }) + .on("initial-reachable-stops", reachableStops => { + console.log("initial", reachableStops); + reachableStops.map(({ stop }) => { + const startMarker = L.marker([stop.latitude, stop.longitude]).addTo(map); + + startMarker.bindPopup("initialreachable: " + stop.name); + + resultObjects.push(startMarker); + }); + }) + .on("final-reachable-stops", reachableStops => { + console.log("final", reachableStops); + + reachableStops.map(({ stop }) => { + const startMarker = L.marker([stop.latitude, stop.longitude]).addTo(map); + + startMarker.bindPopup("finalreachable: " + stop.name); + + resultObjects.push(startMarker); + }); + }) + .on("added-new-transfer-profile", ({ departureStop, arrivalStop, amountOfTransfers }) => { + + const newLine = [ + [departureStop.latitude, departureStop.longitude], + [arrivalStop.latitude, arrivalStop.longitude] + ]; + + let lineExists = lines.length > 0 && lines + .some((line) => + line[0][0] === newLine[0][0] + && line[0][1] === newLine[0][1] + && line[1][0] === newLine[1][0] + && line[1][1] === newLine[1][1] + ); + + if (!lineExists) { + const polyline = new L.Polyline(newLine, { + color: "#000", + weight: 1, + smoothFactor: 1, + opacity: 0.5, + dashArray: "10 10" + }).addTo(map); + + lines.push(newLine); + polyLines.push(polyline); + } + }) + .on("connection-prefetch", (departureTime) => { + if (!firstPrefetch) { + firstPrefetch = departureTime; + + prefetchBar.innerHTML = departureTime.toLocaleTimeString(); + + } else { + const width = getPrefetchViewWidth(firstPrefetch, departureTime); + + prefetchBar.style.width = `${width}px`; + prefetchBar.setAttribute("data-last", departureTime.toLocaleTimeString()); + } + }) + .on("connection-iterator-view", (lowerBound, upperBound, completed) => { + if (!lowerBound || !upperBound) { + return; + } + + if (!completed) { + const width = getPrefetchViewWidth(lowerBound, upperBound); + const offset = getPrefetchViewWidth(firstPrefetch, lowerBound); + + const prefetchView = document.createElement("div"); + prefetchView.className = "prefetch-view"; + prefetchView.style.marginLeft = `${offset}px`; + prefetchView.style.width = `${width}px`; + prefetchView.style.backgroundColor = "red"; + + prefetchWrapper.appendChild(prefetchView); + prefetchViews.push({ lowerBound, upperBound, elem: prefetchView }); + + } else { + const { elem } = prefetchViews + .find((view) => view.lowerBound === lowerBound && view.upperBound === upperBound); + + if (!elem) { + console.warn("Wut"); + return; + } + + elem.style.backgroundColor = "limegreen"; + } + }); + +function onMapClick(e) { + selectRoute(e); +} + +function selectRoute(e, id) { + if (query.length === 2) { + return; + } + + let marker = L.marker(e.latlng).addTo(map); + + resultObjects.push(marker); + + if (query.length < 2) { + const { lat, lng } = e.latlng; + + let item = { + latitude: lat, + longitude: lng + }; + + if (id) { + item.id = id; + } + + query.push(item); + } + + if (query.length === 2) { + runQuery(query); + + for (const marker of allStops) { + marker.remove(); + } + } +} + +function dateToTimeString(date) { + const hours = date.getHours(); + const minutes = date.getMinutes(); + return `${hours < 10 ? "0" + hours : hours}:${minutes < 10 ? "0" + minutes : minutes}`; +} + +map.on("click", onMapClick); + +function getRandomColor() { + const letters = "0123456789ABCDEF"; + let color = "#"; + for (let i = 0; i < 6; i++) { + color += letters[Math.floor(Math.random() * 16)]; + } + return color; +} + +function getTravelTime(path) { + return path.steps.reduce((time, step) => time + step.duration.minimum, 0) / 60000; +} + +function getTransferTime(path) { + let time = 0; + + if (path.steps.length < 2) { + return time; + } + + for (let i = 0; i < path.steps.length - 1; i++) { + let stepX = path.steps[i]; + let stepY = path.steps[i + 1]; + + time += stepY.startTime - stepX.stopTime; + } + + return time / 60000; +} + +function addResultPanel(path, color) { + const pathElement = document.createElement("div"); + pathElement.className = "path"; + + const firstStep = path.steps[0]; + const lastStep = path.steps[path.steps.length - 1]; + + const headerElement = document.createElement("div"); + headerElement.className = "header"; + + headerElement.innerHTML = ` + Departure: ${dateToTimeString(firstStep.startTime)}
+ Arrival: ${dateToTimeString(lastStep.stopTime)}
+ Travel time: ${getTravelTime(path)} min
+ Transfer time: ${getTransferTime(path)} min + `; + + pathElement.appendChild(headerElement); + + path.steps.forEach(step => { + const stepElement = document.createElement("div"); + stepElement.className = "step"; + + const travelMode = document.createElement("div"); + travelMode.className = "travelMode " + step.travelMode; + stepElement.appendChild(travelMode); + + const details = document.createElement("div"); + details.className = "details"; + stepElement.appendChild(details); + + const startLocation = document.createElement("div"); + startLocation.className = "startLocation"; + startLocation.innerHTML = + "Start location: " + step.startLocation.name; + details.appendChild(startLocation); + + if (step.startTime) { + const startTime = document.createElement("div"); + startTime.className = "startTime"; + startTime.innerHTML = step.startTime; + details.appendChild(startTime); + } + + if (step.enterConnectionId) { + const enterConnectionId = document.createElement("div"); + enterConnectionId.className = "enterConnectionId"; + enterConnectionId.innerHTML = + "Enter connection: " + step.enterConnectionId; + details.appendChild(enterConnectionId); + } + + if (step.duration) { + const duration = document.createElement("div"); + duration.className = "duration"; + duration.innerHTML = + "Duration: minimum " + + step.duration.minimum / (60 * 1000) + + "min"; + details.appendChild(duration); + } + + const stopLocation = document.createElement("div"); + stopLocation.className = "stopLocation"; + stopLocation.innerHTML = "Stop location: " + step.stopLocation.name; + details.appendChild(stopLocation); + + if (step.stopTime) { + const stopTime = document.createElement("div"); + stopTime.className = "stopTime"; + stopTime.innerHTML = step.stopTime; + details.appendChild(stopTime); + } + + if (step.exitConnectionId) { + const exitConnectionId = document.createElement("div"); + exitConnectionId.className = "exitConnectionId"; + exitConnectionId.innerHTML = + "Exit connection: " + step.exitConnectionId; + details.appendChild(exitConnectionId); + } + + pathElement.style.borderLeft = "5px solid " + color; + + pathElement.appendChild(stepElement); + }); + + results.appendChild(pathElement); +} + +function addResultToMap(path, color) { + path.steps.forEach(step => { + const { startLocation, stopLocation, travelMode } = step; + + const startMarker = L.marker([ + startLocation.latitude, + startLocation.longitude + ]).addTo(map); + + startMarker.bindPopup(startLocation.name); + + const stopMarker = L.marker([ + stopLocation.latitude, + stopLocation.longitude + ]).addTo(map); + + stopMarker.bindPopup(stopLocation.name); + const line = [ + [startLocation.latitude, startLocation.longitude], + [stopLocation.latitude, stopLocation.longitude] + ]; + + const polyline = new L.Polyline(line, { + color, + weight: 5, + smoothFactor: 1, + opacity: 0.7, + dashArray: travelMode === "walking" ? "8 8" : null + }).addTo(map); + + resultObjects.push(startMarker, stopMarker, polyline); + }); +} + +function runQuery(query) { + console.log(query); + + const maximumWalkingDistance = 200; + + const departureCircle = L.circle([query[0].latitude, query[0].longitude], { + color: "limegreen", + fillColor: "limegreen", + fillOpacity: 0.5, + radius: maximumWalkingDistance + }).addTo(map); + + const arrivalCircle = L.circle([query[1].latitude, query[1].longitude], { + color: "red", + fillColor: "red", + fillOpacity: 0.5, + radius: maximumWalkingDistance + }).addTo(map); + + resultObjects.push(departureCircle, arrivalCircle); + + let i = 0; + let amount = 4; + + planner + .query({ + publicTransportOnly: true, + from: query[0], // Brussels North + to: query[1], // Ghent-Sint-Pieters + minimumDepartureTime: new Date(), + maximumWalkingDistance, + maximumTransferDuration: 30 * 60 * 1000, // 30 minutes + minimumWalkingSpeed: 3 + }) + .take(amount) + .on("error", (error) => { + console.error(error); + }) + .on("data", path => { + i++; + + const color = getRandomColor(); + + addResultPanel(path, color); + addResultToMap(path, color); + + }) + .on("end", () => { + if (i < amount) { + const noMore = document.createElement("div"); + noMore.className = "path"; + noMore.style.padding = "10px"; + noMore.innerHTML = "No more results"; + + results.appendChild(noMore); + } + }); +} diff --git a/package-lock.json b/package-lock.json index 4164c09c..86eb6fa8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "plannerjs", - "version": "0.0.1-alpha", + "version": "0.0.2-alpha", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -33,18 +33,21 @@ } }, "@rdfjs/data-model": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rdfjs/data-model/-/data-model-1.1.0.tgz", - "integrity": "sha512-vK7TlSFBJQihqMoMgXK/VtcRV+rCQSOLB9VGAfv4Pr6BzUbBcR0CeM/RpkD4bHGX3816eaaURH1CNgN7togWdw==" - }, - "@types/asynciterator": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@types/asynciterator/-/asynciterator-1.1.1.tgz", - "integrity": "sha512-KgjXxTtWbMW7UA4oZauIfg2rCl5+5LbsoVF5DwwpXVQxzSbez2PQ9NAlbJlBjIHqKLOpWcdjqL+wyrNepvTZOg==", - "dev": true, + "resolved": "https://registry.npmjs.org/@rdfjs/data-model/-/data-model-1.1.1.tgz", + "integrity": "sha512-4jb1zc77f27u/MLVhpE/zHR1uvdH4XElXG63rJP/kVnvKoHtVfyJSEqN9oRLANgqHJ9SNwKj9FXeNFZ4+GGfiw==", "requires": { - "@types/events": "*", - "@types/node": "*" + "@types/rdf-js": "^2.0.1" + }, + "dependencies": { + "@types/rdf-js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/rdf-js/-/rdf-js-2.0.1.tgz", + "integrity": "sha512-x3Qct8TPilUos4znM1gANmtTvjOFdDRItmpEM2Nu9QgAx258FN9k22OvOu2TmPzOlx8a1FLdEW3o33UXHQt5ow==", + "requires": { + "@types/node": "*" + } + } } }, "@types/events": { @@ -74,9 +77,9 @@ } }, "@types/handlebars": { - "version": "4.0.39", - "resolved": "https://registry.npmjs.org/@types/handlebars/-/handlebars-4.0.39.tgz", - "integrity": "sha512-vjaS7Q0dVqFp85QhyPSZqDKnTTCemcSHNHFvDdalO1s0Ifz5KuE64jQD5xoUkfdWwF4WpqdJEl7LsWH8rzhKJA==", + "version": "4.0.40", + "resolved": "https://registry.npmjs.org/@types/handlebars/-/handlebars-4.0.40.tgz", + "integrity": "sha512-sGWNtsjNrLOdKha2RV1UeF8+UbQnPSG7qbe5wwbni0mw4h2gHXyPFUMOC+xwGirIiiydM/HSqjDO4rk6NFB18w==", "dev": true }, "@types/haversine": { @@ -98,9 +101,9 @@ "dev": true }, "@types/lodash": { - "version": "4.14.118", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.118.tgz", - "integrity": "sha512-iiJbKLZbhSa6FYRip/9ZDX6HXhayXLDGY2Fqws9cOkEQ6XeKfaxB0sC541mowZJueYyMnVUmmG+al5/4fCDrgw==", + "version": "4.14.120", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.120.tgz", + "integrity": "sha512-jQ21kQ120mo+IrDs1nFNVm/AsdFxIx2+vZ347DbogHJPd/JzKNMOqU6HCYin1W6v8l5R9XSO2/e9cxmn7HAnVw==", "dev": true }, "@types/marked": { @@ -118,8 +121,7 @@ "@types/node": { "version": "10.12.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.0.tgz", - "integrity": "sha512-3TUHC3jsBAB7qVRGxT6lWyYo2v96BMmD2PTcl47H25Lu7UXtFH/2qqmKiVrnel6Ne//0TFYf6uvNX+HW2FRkLQ==", - "dev": true + "integrity": "sha512-3TUHC3jsBAB7qVRGxT6lWyYo2v96BMmD2PTcl47H25Lu7UXtFH/2qqmKiVrnel6Ne//0TFYf6uvNX+HW2FRkLQ==" }, "@types/rdf-js": { "version": "1.0.1", @@ -131,9 +133,9 @@ } }, "@types/shelljs": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@types/shelljs/-/shelljs-0.8.0.tgz", - "integrity": "sha512-vs1hCC8RxLHRu2bwumNyYRNrU3o8BtZhLysH5A4I98iYmA2APl6R3uNQb5ihl+WiwH0xdC9LLO+vRrXLs/Kyxg==", + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@types/shelljs/-/shelljs-0.8.1.tgz", + "integrity": "sha512-1lQw+48BuVgp6c1+z8EMipp18IdnV2dLh6KQGwOm+kJy9nPjEkaqRKmwbDNEYf//EKBvKcwOC6V2cDrNxVoQeQ==", "dev": true, "requires": { "@types/glob": "*", @@ -843,6 +845,14 @@ "resolved": "https://registry.npmjs.org/asynciterator/-/asynciterator-2.0.1.tgz", "integrity": "sha512-aVLheZsDNU5qpOv6jZEHnFv79GfEi+N0w/OLmMmXZfGD8XFFmPsRhkSqleNl9jS6mqy/DNoV7tXGcI0S3cUvHQ==" }, + "asynciterator-promiseproxy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/asynciterator-promiseproxy/-/asynciterator-promiseproxy-2.0.0.tgz", + "integrity": "sha512-C0ub2jkCId4a66R9OuPa3Cvu+PF73yXEBErZc3NUlfLVXbYMmPF9vBUPzmCo3UuMdFmJLcfOjGxJpQ5a7z/G9A==", + "requires": { + "asynciterator": "^2.0.0" + } + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1204,11 +1214,6 @@ "integrity": "sha512-DYWGk01lDcxeS/K9IHPGWfT8PsJmbXRtRd2Sx72Tnb8pcYZQFF1oSDb8hJtS1vhp212q1Rzi5dUf9+nq0o9UIg==", "dev": true }, - "bindings": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.3.0.tgz", - "integrity": "sha512-DpLh5EzMR2kzvX1KIlVC0VkC3iZtHKTgdtZ0a3pglBZdaQFjt5S9g9xd1lE+YvXyfd6mtCeRnrUfOLYiTMlNSw==" - }, "bluebird": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.3.tgz", @@ -2523,9 +2528,9 @@ } }, "follow-redirects": { - "version": "1.5.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.9.tgz", - "integrity": "sha512-Bh65EZI/RU8nx0wbYF9shkFZlqLP+6WT/5FnA3cE/djNSuKNHJEinGGZgu/cQEkeeb2GdFOgenAmn8qaqYke2w==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.6.1.tgz", + "integrity": "sha512-t2JCjbzxQpWvbhts3l6SH1DKzSrx8a+SsaVf4h6bG4kOXUuPYS/kg2Lr4gQSb7eemaHqJkOThF1BGyjlUkO1GQ==", "requires": { "debug": "=3.1.0" } @@ -4396,13 +4401,13 @@ } }, "jsonld": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsonld/-/jsonld-1.1.0.tgz", - "integrity": "sha512-tx87xNtu2hGabr7mhSyXTI8q+Fz3pl+50B/JislFPwAz8ud0KTTQpNjU74tJIegFAHve9UFYzzj4YVTIrac0Hw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/jsonld/-/jsonld-1.5.0.tgz", + "integrity": "sha512-7jF9WXK4nuHvhz/qT6A4DEZ58tUYgrV98xBJEgHFhQ6GQaNT+oU1zqkFXKtDZsKsiEs/1K/VShNnat6SISb3jg==", "requires": { - "rdf-canonize": "^0.2.1", - "request": "^2.83.0", - "semver": "^5.5.0", + "rdf-canonize": "^1.0.1", + "request": "^2.88.0", + "semver": "^5.6.0", "xmldom": "0.1.19" }, "dependencies": { @@ -4827,15 +4832,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, - "n3": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/n3/-/n3-0.11.2.tgz", - "integrity": "sha512-ICSiOmFLbZ4gI35+4H3e2vYGHDC944WZkCa1iVNRAx/mRZESEevQNFhfHaui/lhqynoZYvBVDNjM/2Tfd3TICQ==" - }, "nan": { "version": "2.11.1", "resolved": "https://registry.npmjs.org/nan/-/nan-2.11.1.tgz", - "integrity": "sha512-iji6k87OSXa0CcrLl9z+ZiYSuR2o+c0bGuNmXdrhTQTakxytAFsC56SArGYoiHlJlFoHSnvmhpceZJaXkVuOtA==" + "integrity": "sha512-iji6k87OSXa0CcrLl9z+ZiYSuR2o+c0bGuNmXdrhTQTakxytAFsC56SArGYoiHlJlFoHSnvmhpceZJaXkVuOtA==", + "dev": true, + "optional": true }, "nanomatch": { "version": "1.2.13", @@ -5469,9 +5471,9 @@ "dev": true }, "progress": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.1.tgz", - "integrity": "sha512-OE+a6vzqazc+K6LxJrX5UPyKFvGnL5CYmq2jFGNIBWHpc4QyE49/YOumcrpQFJpfejmvRtbJzgO1zPmMCqlbBg==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", "dev": true }, "promise-inflight": { @@ -5614,14 +5616,12 @@ } }, "rdf-canonize": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/rdf-canonize/-/rdf-canonize-0.2.4.tgz", - "integrity": "sha512-xwAEHJt8FTe4hP9CjFgwQPFdszu4iwEintk31+9eh0rljC28vm9EhoaIlC1rQx5LaCB5oHom4+yoei4+DTdRjQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rdf-canonize/-/rdf-canonize-1.0.1.tgz", + "integrity": "sha512-vQq6q7BIUwrVQijKRYdunxlodkn0Btjv2MnJ4S3rOUELsghq7fGuDaWuqBNbXca3KRbcRS6HwTIT2hJbJej2UA==", "requires": { - "bindings": "^1.3.0", - "nan": "^2.10.0", - "node-forge": "^0.7.1", - "semver": "^5.4.1" + "node-forge": "^0.7.6", + "semver": "^5.6.0" } }, "rdfa-processor": { @@ -5633,9 +5633,9 @@ }, "dependencies": { "n3": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/n3/-/n3-1.0.0-beta.2.tgz", - "integrity": "sha512-dY0Q21JhNJozPMSijKI9phfgocXb7hF9F8zOPnsRu0Dy61eQwQVKcFEw59V0sn2F/neJjNg7WHmNtGLMkPpKPA==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.0.3.tgz", + "integrity": "sha512-IdcCNqb/1gj9fX63hoVEn3/Z1u9iLJiYLSpesmqT3+5UrxcYG5YldUP6T2okNRZKzyVdqz+I0PExGkWkz9gcZw==" } } }, @@ -5648,9 +5648,9 @@ }, "dependencies": { "n3": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/n3/-/n3-1.0.0-beta.2.tgz", - "integrity": "sha512-dY0Q21JhNJozPMSijKI9phfgocXb7hF9F8zOPnsRu0Dy61eQwQVKcFEw59V0sn2F/neJjNg7WHmNtGLMkPpKPA==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.0.3.tgz", + "integrity": "sha512-IdcCNqb/1gj9fX63hoVEn3/Z1u9iLJiYLSpesmqT3+5UrxcYG5YldUP6T2okNRZKzyVdqz+I0PExGkWkz9gcZw==" } } }, @@ -7647,9 +7647,9 @@ "dev": true }, "typedoc": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.13.0.tgz", - "integrity": "sha512-jQWtvPcV+0fiLZAXFEe70v5gqjDO6pJYJz4mlTtmGJeW2KRoIU/BEfktma6Uj8Xii7UakuZjbxFewl3UYOkU/w==", + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.14.2.tgz", + "integrity": "sha512-aEbgJXV8/KqaVhcedT7xG6d2r+mOvB5ep3eIz1KuB5sc4fDYXcepEEMdU7XSqLFO5hVPu0nllHi1QxX2h/QlpQ==", "dev": true, "requires": { "@types/fs-extra": "^5.0.3", @@ -7661,14 +7661,14 @@ "@types/shelljs": "^0.8.0", "fs-extra": "^7.0.0", "handlebars": "^4.0.6", - "highlight.js": "^9.0.0", + "highlight.js": "^9.13.1", "lodash": "^4.17.10", "marked": "^0.4.0", "minimatch": "^3.0.0", "progress": "^2.0.0", "shelljs": "^0.8.2", "typedoc-default-themes": "^0.5.0", - "typescript": "3.1.x" + "typescript": "3.2.x" } }, "typedoc-default-themes": { @@ -7678,9 +7678,9 @@ "dev": true }, "typescript": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.1.6.tgz", - "integrity": "sha512-tDMYfVtvpb96msS1lDX9MEdHrW4yOuZ4Kdc4Him9oU796XldPYF/t2+uKoX0BBa0hXXwDlqYQbXY5Rzjzc5hBA==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.2.4.tgz", + "integrity": "sha512-0RNDbSdEokBeEAkgNbxJ+BLwSManFy9TeXz8uW+48j/xhEXv1ePME60olyzw2XzUqUBNAYFeJadIqAgNqIACwg==", "dev": true }, "uglify-js": { diff --git a/package.json b/package.json index d1f8ba83..a7a71109 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plannerjs", - "version": "0.0.2-alpha", + "version": "0.0.3-alpha", "description": "The JavaScript framework for journey planning.", "main": "lib/index.js", "license": "MIT", @@ -21,16 +21,17 @@ "lint": "./node_modules/tslint/bin/tslint --project .", "webpack": "webpack --config webpack.config.js --mode=production", "webpack-stats": "npm run webpack -- --display-modules --json > stats.json", - "typedoc": "typedoc --options typedoc.js", - "doc": "npm run typedoc & npm run browser && cp dist/bundle.js docs/js/planner-latest.js && cp dist/bundle.js.map docs/js/planner-latest.js.map" + "typedoc": "typedoc --options typedoc.config.js", + "doc": "npm run typedoc && npm run browser && cp dist/bundle.js docs/js/planner-latest.js && cp dist/bundle.js.map docs/js/planner-latest.js.map", + "example": "npm run browser && cp dist/bundle.js example/js/bundle.js && cp dist/bundle.js.map example/js/bundle.js.map" }, "dependencies": { "asynciterator": "^2.0.1", + "asynciterator-promiseproxy": "^2.0.0", "haversine": "^1.1.0", "inversify": "^5.0.1", "isomorphic-fetch": "^2.2.1", "ldfetch": "^1.1.1-alpha", - "n3": "0.11.2", "reflect-metadata": "^0.1.12", "uritemplate": "^0.3.4" }, @@ -38,7 +39,6 @@ "lint" ], "devDependencies": { - "@types/asynciterator": "^1.1.1", "@types/haversine": "^1.1.0", "@types/jest": "^23.3.7", "@types/rdf-js": "^1.0.1", @@ -48,8 +48,8 @@ "ts-jest": "^23.10.4", "ts-loader": "^5.3.0", "tslint": "^5.11.0", - "typedoc": "^0.13.0", - "typescript": "^3.1.6", + "typedoc": "^0.14.2", + "typescript": "^3.2.4", "webpack": "^4.25.1", "webpack-cli": "^3.1.2" } diff --git a/src/Catalog.ts b/src/Catalog.ts index c02fdd6b..0d2aeb2c 100644 --- a/src/Catalog.ts +++ b/src/Catalog.ts @@ -1,26 +1,40 @@ -import TravelMode from "./TravelMode"; +import TravelMode from "./enums/TravelMode"; +/** + * A Catalog instance holds the stops source and connections source configs. + * These configs get passed to the [[StopsFetcherFactory]] and [[ConnectionsFetcherFactory]] to construct + * respectively [[IStopsFetcher]] and [[IConnectionsFetcher]] instances + */ export default class Catalog { public static combine(...catalogs: Catalog[]): Catalog { const combinedCatalog = new Catalog(); for (const sourceCatalog of catalogs) { - combinedCatalog.stopsFetcherConfigs.push(...sourceCatalog.stopsFetcherConfigs); - combinedCatalog.connectionsFetcherConfigs.push(...sourceCatalog.connectionsFetcherConfigs); + combinedCatalog.stopsSourceConfigs.push(...sourceCatalog.stopsSourceConfigs); + combinedCatalog.connectionsSourceConfigs.push(...sourceCatalog.connectionsSourceConfigs); } return combinedCatalog; } - public stopsFetcherConfigs = []; - public connectionsFetcherConfigs = []; + public stopsSourceConfigs: IStopsSourceConfig[] = []; + public connectionsSourceConfigs: IConnectionsSourceConfig[] = []; - public addStopsFetcher(accessUrl: string) { - this.stopsFetcherConfigs.push({accessUrl}); + public addStopsSource(accessUrl: string) { + this.stopsSourceConfigs.push({accessUrl}); } - public addConnectionsFetcher(accessUrl: string, travelMode: TravelMode) { - this.connectionsFetcherConfigs.push({accessUrl, travelMode}); + public addConnectionsSource(accessUrl: string, travelMode: TravelMode) { + this.connectionsSourceConfigs.push({accessUrl, travelMode}); } } + +export interface IStopsSourceConfig { + accessUrl: string; +} + +export interface IConnectionsSourceConfig { + accessUrl: string; + travelMode: TravelMode; +} diff --git a/src/Context.ts b/src/Context.ts index 00fb1d6e..38a6a079 100644 --- a/src/Context.ts +++ b/src/Context.ts @@ -1,11 +1,13 @@ // @ts-ignore import { EventEmitter, Listener } from "events"; import { Container, injectable } from "inversify"; +import EventType from "./enums/EventType"; /** * The Context serves as event pass through and holder of the inversify container object. - * Proxies an internal EventEmitter because ´decorate(injectable(), EventEmitter)´ causes - * errors when running tests in Jest + * + * It proxies an internal EventEmitter (instead of extending EventEmitter) because + * ´decorate(injectable(), EventEmitter)´ causes errors when running tests in Jest */ @injectable() // @ts-ignore @@ -35,6 +37,10 @@ export default class Context implements EventEmitter { return this.emitter.emit(type, ...args); } + public emitWarning(...args: any[]): boolean { + return this.emit(EventType.Warning, ...args); + } + public listenerCount(type: string | symbol): number { return this.emitter.listenerCount(type); } diff --git a/src/Defaults.ts b/src/Defaults.ts index 2be8ecc1..3b2d8709 100644 --- a/src/Defaults.ts +++ b/src/Defaults.ts @@ -1,5 +1,8 @@ import Units from "./util/Units"; +/** + * This class holds the default [[IQuery]]/[[IResolvedQuery]] parameters + */ export default class Defaults { public static readonly defaultMinimumWalkingSpeed = 3; public static readonly defaultMaximumWalkingSpeed = 6; diff --git a/src/Planner.ts b/src/Planner.ts index 441fede4..ef9108e5 100644 --- a/src/Planner.ts +++ b/src/Planner.ts @@ -1,8 +1,10 @@ import { AsyncIterator } from "asynciterator"; +import { PromiseProxyIterator } from "asynciterator-promiseproxy"; // @ts-ignore import { EventEmitter, Listener } from "events"; import Context from "./Context"; -import EventType from "./EventType"; +import EventType from "./enums/EventType"; +import IConnectionsProvider from "./fetcher/connections/IConnectionsProvider"; import IStop from "./fetcher/stops/IStop"; import IStopsProvider from "./fetcher/stops/IStopsProvider"; import IPath from "./interfaces/IPath"; @@ -11,12 +13,8 @@ import defaultContainer from "./inversify.config"; import IQueryRunner from "./query-runner/IQueryRunner"; import TYPES from "./types"; -if (!Symbol.asyncIterator) { - (Symbol as any).asyncIterator = Symbol.for("Symbol.asyncIterator"); -} - /** - * Allows to ask route planning queries over our knowledge graphs + * Allows to ask route planning queries. Emits events defined in [[EventType]] */ // @ts-ignore export default class Planner implements EventEmitter { @@ -36,19 +34,25 @@ export default class Planner implements EventEmitter { } /** - * Given an [[IQuery]], it will evaluate the query and eventually produce an [[IQueryResult]] + * Given an [[IQuery]], it will evaluate the query and return a promise for an AsyncIterator of [[IPath]] instances * @param query An [[IQuery]] specifying a route planning query - * @returns An AsyncIterator of [[IPath]]s + * @returns An AsyncIterator of [[IPath]] instances */ - public async query(query: IQuery): Promise> { + public query(query: IQuery): AsyncIterator { this.emit(EventType.Query, query); - const iterator = await this.queryRunner.run(query); + const iterator = new PromiseProxyIterator(() => this.queryRunner.run(query)); - this.once(EventType.QueryAbort, () => { + this.once(EventType.AbortQuery, () => { iterator.close(); }); + iterator.on("error", (e) => { + if (e && e.eventType) { + this.emit(e.eventType, e.message); + } + }); + return iterator; } @@ -100,13 +104,33 @@ export default class Planner implements EventEmitter { return this; } + public prefetchStops(): void { + const container = this.context.getContainer(); + const stopsProvider = container.get(TYPES.StopsProvider); + + if (stopsProvider) { + stopsProvider.prefetchStops(); + } + } + + public prefetchConnections(): void { + const container = this.context.getContainer(); + const connectionsProvider = container.get(TYPES.ConnectionsProvider); + + if (connectionsProvider) { + connectionsProvider.prefetchConnections(); + } + } + public getAllStops(): Promise { - const provider = this.context.getContainer().get(TYPES.StopsProvider); + const container = this.context.getContainer(); + const stopsProvider = container.get(TYPES.StopsProvider); - if (provider) { - return provider.getAllStops(); + if (stopsProvider) { + return stopsProvider.getAllStops(); } return Promise.reject(); } + } diff --git a/src/asynciterator.d.ts b/src/asynciterator.d.ts new file mode 100755 index 00000000..bcbc70b7 --- /dev/null +++ b/src/asynciterator.d.ts @@ -0,0 +1,195 @@ +/* tslint:disable */ + +// Type definitions for asynciterator 2.0.1 + +declare module "asynciterator" { + + import { EventEmitter } from "events"; + + export abstract class AsyncIterator extends EventEmitter { + static STATES: ["INIT", "OPEN", "CLOSING", "CLOSED", "ENDED"]; + static INIT: 0; + static OPEN: 1; + static CLOSING: 2; + static CLOSED: 3; + static ENDED: 4; + + _state: number; + _readable: boolean; + _destination?: AsyncIterator; + + readable: boolean; + closed: boolean; + ended: boolean; + + constructor(); + + read(): T; + + each(callback: (data: T) => void, self?: any): void; + + close(): void; + + _changeState(newState: number, eventAsync?: boolean): void; + + private _hasListeners(eventName: string | symbol): boolean; + + // tslint:disable-next-line ban-types + private _addSingleListener(eventName: string | symbol, listener: Function): void; + + _end(): void; + + getProperty(propertyName: string, callback?: (value: any) => void): any; + + setProperty(propertyName: string, value: any): void; + + getProperties(): { [id: string]: any }; + + setProperties(properties: { [id: string]: any }): void; + + copyProperties(source: AsyncIterator, propertyNames: string[]): void; + + toString(): string; + + _toStringDetails(): string; + + transform(options?: SimpleTransformIteratorOptions): SimpleTransformIterator; + + map(mapper: (item: T) => T2, self?: object): SimpleTransformIterator; + + filter(filter: (item: T) => boolean, self?: object): SimpleTransformIterator; + + prepend(items: T[] | AsyncIterator): SimpleTransformIterator; + + append(items: T[] | AsyncIterator): SimpleTransformIterator; + + surround(prepend: T[] | AsyncIterator, append: T[] | AsyncIterator): SimpleTransformIterator; + + skip(offset: number): SimpleTransformIterator; + + take(limit: number): SimpleTransformIterator; + + range(start: number, end: number): SimpleTransformIterator; + + clone(): ClonedIterator; + + static range(start?: number, end?: number, step?: number): IntegerIterator; + } + + export class EmptyIterator extends AsyncIterator { + _state: 4; + } + + export class SingletonIterator extends AsyncIterator { + constructor(item?: T); + } + + export class ArrayIterator extends AsyncIterator { + constructor(items?: T[]); + } + + export interface IntegerIteratorOptions { + step?: number; + end?: number; + start?: number; + } + + export class IntegerIterator extends AsyncIterator { + _step: number; + _last: number; + _next: number; + + constructor(options?: IntegerIteratorOptions); + } + + export interface BufferedIteratorOptions { + maxBufferSize?: number; + autoStart?: boolean; + } + + export class BufferedIterator extends AsyncIterator { + maxBufferSize: number; + _pushedCount: number; + _buffer: T[]; + + _init(autoStart: boolean): void; + + _begin(done: () => void): void; + + _read(count: number, done: () => void): void; + + _push(item: T): void; + + _fillBuffer(): void; + + _completeClose(): void; + + _flush(done: () => void): void; + + constructor(options?: BufferedIteratorOptions); + } + + export interface TransformIteratorOptions extends BufferedIteratorOptions { + optional?: boolean; + source?: AsyncIterator; + destroySource?: boolean; + } + + export class TransformIterator extends BufferedIterator { + _optional: boolean; + source: AsyncIterator; + + _validateSource(source: AsyncIterator, allowDestination?: boolean): void; + + _transform(item: S, done: (result: T) => void): void; + + _closeWhenDone(): void; + + constructor(source?: AsyncIterator | TransformIteratorOptions, options?: TransformIteratorOptions); + } + + export interface SimpleTransformIteratorOptions extends TransformIteratorOptions { + offset?: number; + limit?: number; + prepend?: T[]; + append?: T[]; + + filter?(item: S): boolean; + + map?(item: S): T; + + transform?(item: S, callback: (result: T) => void): void; + } + + export class SimpleTransformIterator extends TransformIterator { + _offset: number; + _limit: number; + _prepender?: ArrayIterator; + _appender?: ArrayIterator; + + _filter?(item: S): boolean; + + _map?(item: S): T; + + _transform(item: S, done: (result: T) => void): void; + + _insert(inserter: AsyncIterator, done: () => void): void; + + constructor(source?: AsyncIterator | SimpleTransformIteratorOptions, + options?: SimpleTransformIteratorOptions); + } + + export class MultiTransformIterator extends TransformIterator { + _transformerQueue: S[]; + + _createTransformer(element: S): AsyncIterator; + + constructor(source?: AsyncIterator | TransformIteratorOptions, options?: TransformIteratorOptions); + } + + export class ClonedIterator extends TransformIterator { + _readPosition: number; + + constructor(source?: AsyncIterator); + } +} diff --git a/src/catalog.delijn.ts b/src/catalog.delijn.ts index 95726afd..386a6ab1 100644 --- a/src/catalog.delijn.ts +++ b/src/catalog.delijn.ts @@ -1,18 +1,20 @@ import Catalog from "./Catalog"; -import TravelMode from "./TravelMode"; +import TravelMode from "./enums/TravelMode"; -const catalog = new Catalog(); +/* tslint:disable:max-line-length */ -catalog.addStopsFetcher("http://openplanner.ilabt.imec.be/delijn/Antwerpen/stops"); -catalog.addStopsFetcher("http://openplanner.ilabt.imec.be/delijn/Limburg/stops"); -catalog.addStopsFetcher("http://openplanner.ilabt.imec.be/delijn/Oost-Vlaanderen/stops"); -catalog.addStopsFetcher("http://openplanner.ilabt.imec.be/delijn/Vlaams-Brabant/stops"); -catalog.addStopsFetcher("http://openplanner.ilabt.imec.be/delijn/West-Vlaanderen/stops"); +const catalogDeLijn = new Catalog(); -catalog.addConnectionsFetcher("http://openplanner.ilabt.imec.be/delijn/Antwerpen/connections", TravelMode.Bus); -catalog.addConnectionsFetcher("http://openplanner.ilabt.imec.be/delijn/Limburg/connections", TravelMode.Bus); -catalog.addConnectionsFetcher("http://openplanner.ilabt.imec.be/delijn/Oost-Vlaanderen/connections", TravelMode.Bus); -catalog.addConnectionsFetcher("http://openplanner.ilabt.imec.be/delijn/Vlaams-Brabant/connections", TravelMode.Bus); -catalog.addConnectionsFetcher("http://openplanner.ilabt.imec.be/delijn/West-Vlaanderen/connections", TravelMode.Bus); +catalogDeLijn.addStopsSource("https://openplanner.ilabt.imec.be/delijn/Antwerpen/stops"); +catalogDeLijn.addStopsSource("https://openplanner.ilabt.imec.be/delijn/Limburg/stops"); +catalogDeLijn.addStopsSource("https://openplanner.ilabt.imec.be/delijn/Oost-Vlaanderen/stops"); +catalogDeLijn.addStopsSource("https://openplanner.ilabt.imec.be/delijn/Vlaams-Brabant/stops"); +catalogDeLijn.addStopsSource("https://openplanner.ilabt.imec.be/delijn/West-Vlaanderen/stops"); -export default catalog; +catalogDeLijn.addConnectionsSource("https://openplanner.ilabt.imec.be/delijn/Antwerpen/connections", TravelMode.Bus); +catalogDeLijn.addConnectionsSource("https://openplanner.ilabt.imec.be/delijn/Limburg/connections", TravelMode.Bus); +catalogDeLijn.addConnectionsSource("https://openplanner.ilabt.imec.be/delijn/Oost-Vlaanderen/connections", TravelMode.Bus); +catalogDeLijn.addConnectionsSource("https://openplanner.ilabt.imec.be/delijn/Vlaams-Brabant/connections", TravelMode.Bus); +catalogDeLijn.addConnectionsSource("https://openplanner.ilabt.imec.be/delijn/West-Vlaanderen/connections", TravelMode.Bus); + +export default catalogDeLijn; diff --git a/src/catalog.nmbs.ts b/src/catalog.nmbs.ts index f9edf2e8..7d4b1846 100644 --- a/src/catalog.nmbs.ts +++ b/src/catalog.nmbs.ts @@ -1,8 +1,9 @@ import Catalog from "./Catalog"; -import TravelMode from "./TravelMode"; +import TravelMode from "./enums/TravelMode"; -const catalog = new Catalog(); -catalog.addStopsFetcher("https://irail.be/stations/NMBS"); -catalog.addConnectionsFetcher("https://graph.irail.be/sncb/connections", TravelMode.Train); +const catalogNmbs = new Catalog(); -export default catalog; +catalogNmbs.addStopsSource("https://irail.be/stations/NMBS"); +catalogNmbs.addConnectionsSource("https://graph.irail.be/sncb/connections", TravelMode.Train); + +export default catalogNmbs; diff --git a/src/demo.cli.ts b/src/demo.cli.ts index d9bb265e..6d8e2780 100644 --- a/src/demo.cli.ts +++ b/src/demo.cli.ts @@ -8,6 +8,6 @@ const isDebugging = process && process.argv.includes("--debug"); } runDemo(true) - .then(() => console.log("Success")) + .then((success) => console.log(success ? "Success" : "Fail")) .catch((e) => console.error(e)); })(); diff --git a/src/demo.ts b/src/demo.ts index 240f8605..118cac8e 100644 --- a/src/demo.ts +++ b/src/demo.ts @@ -1,4 +1,4 @@ -import EventType from "./EventType"; +import EventType from "./enums/EventType"; import Planner from "./index"; import IPath from "./interfaces/IPath"; import Units from "./util/Units"; @@ -7,17 +7,34 @@ export default async (logResults) => { const planner = new Planner(); + planner.prefetchStops(); + planner.prefetchConnections(); + if (logResults) { let scannedPages = 0; let scannedConnections = 0; + // let logFetch = true; + + if (logResults) { + console.log("Start prefetch"); + } + planner - .on(EventType.Query, (query) => { - console.log("Query", query); + .on(EventType.InvalidQuery, (error) => { + console.log("InvalidQuery", error); + }) + .on(EventType.AbortQuery, (reason) => { + console.log("AbortQuery", reason); }) - .on(EventType.QueryExponential, (query) => { + .on(EventType.Query, (Query) => { + console.log("Query", Query); + }) + .on(EventType.SubQuery, (query) => { const { minimumDepartureTime, maximumArrivalTime } = query; + // logFetch = true; + console.log("Total scanned pages", scannedPages); console.log("Total scanned connections", scannedConnections); console.log("[Subquery]", minimumDepartureTime, maximumArrivalTime, maximumArrivalTime - minimumDepartureTime); @@ -25,45 +42,63 @@ export default async (logResults) => { .on(EventType.LDFetchGet, (url, duration) => { scannedPages++; console.log(`[GET] ${url} (${duration}ms)`); + + // if (logFetch) { + // console.log(`[GET] ${url} (${duration}ms)`); + // logFetch = false; + // } }) .on(EventType.ConnectionScan, (connection) => { scannedConnections++; + }) + .on(EventType.Warning, (e) => { + console.warn(e); }); } - const publicTransportResult = await planner.query({ - publicTransportOnly: true, - // from: "https://data.delijn.be/stops/201657", - // to: "https://data.delijn.be/stops/205910", - // from: "https://data.delijn.be/stops/200455", // Deinze weg op Grammene +456 - // to: "https://data.delijn.be/stops/502481", // Tielt Metaalconstructie Goossens - // from: "https://data.delijn.be/stops/509927", // Tield Rameplein perron 1 - // to: "https://data.delijn.be/stops/200455", // Deinze weg op Grammene +456 - from: "http://irail.be/stations/NMBS/008896925", // Ingelmunster - to: "http://irail.be/stations/NMBS/008892007", // Ghent-Sint-Pieters - minimumDepartureTime: new Date(), - maximumTransferDuration: Units.fromHours(0.5), - }); - - return new Promise((resolve, reject) => { - let i = 0; - - publicTransportResult.take(3) - .on("data", (path: IPath) => { - ++i; - - if (logResults) { - console.log(i); - console.log(JSON.stringify(path, null, " ")); - console.log("\n"); - } - - if (i === 3) { - resolve(true); - } + return wait(5000) + .then(() => new Promise((resolve, reject) => { + if (logResults) { + console.log("Start query"); + } + + const amount = 3; + let i = 0; + + planner.query({ + publicTransportOnly: true, + // from: "https://data.delijn.be/stops/201657", + // to: "https://data.delijn.be/stops/205910", + // from: "https://data.delijn.be/stops/200455", // Deinze weg op Grammene +456 + // to: "https://data.delijn.be/stops/502481", // Tielt Metaalconstructie Goossens + // from: "https://data.delijn.be/stops/509927", // Tield Rameplein perron 1 + // to: "https://data.delijn.be/stops/200455", // Deinze weg op Grammene +456 + from: "Ingelmunster", // Ingelmunster + to: "http://irail.be/stations/NMBS/008892007", // Ghent-Sint-Pieters + minimumDepartureTime: new Date(), + maximumTransferDuration: Units.fromHours(0.5), }) - .on("end", () => { - resolve(false); - }); - }); + .take(amount) + .on("error", (error) => { + resolve(false); + }) + .on("data", (path: IPath) => { + ++i; + + if (logResults) { + console.log(i); + console.log(JSON.stringify(path, null, " ")); + console.log("\n"); + } + + if (i === amount) { + resolve(true); + } + }) + .on("end", () => { + resolve(false); + }); + })); }; + +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/src/fetcher/connections/DropOffType.ts b/src/enums/DropOffType.ts similarity index 100% rename from src/fetcher/connections/DropOffType.ts rename to src/enums/DropOffType.ts diff --git a/src/EventType.ts b/src/enums/EventType.ts similarity index 57% rename from src/EventType.ts rename to src/enums/EventType.ts index d7cfe1a7..ba92ca51 100644 --- a/src/EventType.ts +++ b/src/enums/EventType.ts @@ -1,8 +1,16 @@ enum EventType { Query = "query", - QueryExponential = "query-exponential", - QueryAbort = "query-abort", + SubQuery = "sub-query", + AbortQuery = "abort-query", + InvalidQuery = "invalid-query", + LDFetchGet = "ldfetch-get", + + Warning = "warning", + + ConnectionPrefetch = "connection-prefetch", + ConnectionIteratorView = "connection-iterator-view", + ConnectionScan = "connection-scan", FinalReachableStops = "final-reachable-stops", InitialReachableStops = "initial-reachable-stops", diff --git a/src/fetcher/connections/PickupType.ts b/src/enums/PickupType.ts similarity index 100% rename from src/fetcher/connections/PickupType.ts rename to src/enums/PickupType.ts diff --git a/src/planner/stops/ReachableStopsFinderMode.ts b/src/enums/ReachableStopsFinderMode.ts similarity index 100% rename from src/planner/stops/ReachableStopsFinderMode.ts rename to src/enums/ReachableStopsFinderMode.ts diff --git a/src/planner/stops/ReachableStopsSearchPhase.ts b/src/enums/ReachableStopsSearchPhase.ts similarity index 100% rename from src/planner/stops/ReachableStopsSearchPhase.ts rename to src/enums/ReachableStopsSearchPhase.ts diff --git a/src/TravelMode.ts b/src/enums/TravelMode.ts similarity index 100% rename from src/TravelMode.ts rename to src/enums/TravelMode.ts diff --git a/src/errors/InvalidQueryError.ts b/src/errors/InvalidQueryError.ts new file mode 100644 index 00000000..13339a13 --- /dev/null +++ b/src/errors/InvalidQueryError.ts @@ -0,0 +1,5 @@ +import EventType from "../enums/EventType"; + +export default class InvalidQueryError extends Error { + public eventType = EventType.InvalidQuery; +} diff --git a/src/errors/LocationResolverError.ts b/src/errors/LocationResolverError.ts new file mode 100644 index 00000000..65175f81 --- /dev/null +++ b/src/errors/LocationResolverError.ts @@ -0,0 +1,3 @@ +export default class LocationResolverError extends Error { + +} diff --git a/src/fetcher/LDFetch.ts b/src/fetcher/LDFetch.ts index fa5492a7..71ab21d2 100644 --- a/src/fetcher/LDFetch.ts +++ b/src/fetcher/LDFetch.ts @@ -2,7 +2,7 @@ import { inject, injectable } from "inversify"; import LDFetchBase from "ldfetch"; import { Triple } from "rdf-js"; import Context from "../Context"; -import EventType from "../EventType"; +import EventType from "../enums/EventType"; import TYPES from "../types"; export interface ILDFetchResponse { diff --git a/src/fetcher/connections/merge/ConnectionsProviderMerge.ts b/src/fetcher/connections/ConnectionsProviderMerge.ts similarity index 70% rename from src/fetcher/connections/merge/ConnectionsProviderMerge.ts rename to src/fetcher/connections/ConnectionsProviderMerge.ts index 9841fc99..7e0fb64b 100644 --- a/src/fetcher/connections/merge/ConnectionsProviderMerge.ts +++ b/src/fetcher/connections/ConnectionsProviderMerge.ts @@ -1,17 +1,18 @@ import { AsyncIterator } from "asynciterator"; import { inject, injectable } from "inversify"; -import Catalog from "../../../Catalog"; -import TYPES, { ConnectionsFetcherFactory } from "../../../types"; -import MergeIterator from "../../../util/iterators/MergeIterator"; -import IConnection from "../IConnection"; -import IConnectionsFetcher from "../IConnectionsFetcher"; -import IConnectionsFetcherConfig from "../IConnectionsFetcherConfig"; +import Catalog from "../../Catalog"; +import TYPES, { ConnectionsFetcherFactory } from "../../types"; +import MergeIterator from "../../util/iterators/MergeIterator"; +import IConnection from "./IConnection"; +import IConnectionsFetcher from "./IConnectionsFetcher"; +import IConnectionsIteratorOptions from "./IConnectionsIteratorOptions"; +import IConnectionsProvider from "./IConnectionsProvider"; /** * Instantiates and merge sorts all registered connection fetchers */ @injectable() -export default class ConnectionsProviderMerge implements IConnectionsFetcher { +export default class ConnectionsProviderMerge implements IConnectionsProvider { private static forwardsConnectionSelector(connections: IConnection[]): number { if (connections.length === 1) { @@ -51,7 +52,7 @@ export default class ConnectionsProviderMerge implements IConnectionsFetcher { return latestIndex; } - private config: IConnectionsFetcherConfig; + private options: IConnectionsIteratorOptions; private connectionsFetchers: IConnectionsFetcher[]; constructor( @@ -60,17 +61,21 @@ export default class ConnectionsProviderMerge implements IConnectionsFetcher { ) { this.connectionsFetchers = []; - for (const { accessUrl, travelMode } of catalog.connectionsFetcherConfigs) { + for (const { accessUrl, travelMode } of catalog.connectionsSourceConfigs) { this.connectionsFetchers.push(connectionsFetcherFactory(accessUrl, travelMode)); } } + public prefetchConnections(): void { + return; + } + public createIterator(): AsyncIterator { const iterators = this.connectionsFetchers .map((fetcher) => fetcher.createIterator()); - const selector = this.config.backward ? + const selector = this.options.backward ? ConnectionsProviderMerge.backwardsConnectionsSelector : ConnectionsProviderMerge.forwardsConnectionSelector; @@ -78,10 +83,10 @@ export default class ConnectionsProviderMerge implements IConnectionsFetcher { return new MergeIterator(iterators, selector, true); } - public setConfig(config: IConnectionsFetcherConfig): void { - this.config = config; + public setIteratorOptions(options: IConnectionsIteratorOptions): void { + this.options = options; this.connectionsFetchers.forEach((fetcher) => { - fetcher.setConfig(config); + fetcher.setIteratorOptions(options); }); } } diff --git a/src/fetcher/connections/ConnectionsProviderPassthrough.ts b/src/fetcher/connections/ConnectionsProviderPassthrough.ts index e7c7c43c..c6718081 100644 --- a/src/fetcher/connections/ConnectionsProviderPassthrough.ts +++ b/src/fetcher/connections/ConnectionsProviderPassthrough.ts @@ -4,11 +4,11 @@ import Catalog from "../../Catalog"; import TYPES, { ConnectionsFetcherFactory } from "../../types"; import IConnection from "./IConnection"; import IConnectionsFetcher from "./IConnectionsFetcher"; -import IConnectionsFetcherConfig from "./IConnectionsFetcherConfig"; +import IConnectionsIteratorOptions from "./IConnectionsIteratorOptions"; import IConnectionsProvider from "./IConnectionsProvider"; /** - * Passes through one [[IConnectionsFetcher]], the first one if there are multiple + * Passes through any method calls to a *single* [[IConnectionsFetcher]], the first if there are multiple source configs * This provider is most/only useful if there is only one fetcher */ @injectable() @@ -20,16 +20,20 @@ export default class ConnectionsProviderPassthrough implements IConnectionsProvi @inject(TYPES.ConnectionsFetcherFactory) connectionsFetcherFactory: ConnectionsFetcherFactory, @inject(TYPES.Catalog) catalog: Catalog, ) { - const { accessUrl, travelMode } = catalog.connectionsFetcherConfigs[0]; + const { accessUrl, travelMode } = catalog.connectionsSourceConfigs[0]; this.connectionsFetcher = connectionsFetcherFactory(accessUrl, travelMode); } + public prefetchConnections(): void { + this.connectionsFetcher.prefetchConnections(); + } + public createIterator(): AsyncIterator { return this.connectionsFetcher.createIterator(); } - public setConfig(config: IConnectionsFetcherConfig): void { - this.connectionsFetcher.setConfig(config); + public setIteratorOptions(options: IConnectionsIteratorOptions): void { + this.connectionsFetcher.setIteratorOptions(options); } } diff --git a/src/fetcher/connections/IConnection.ts b/src/fetcher/connections/IConnection.ts index 1be060e7..19c9669c 100644 --- a/src/fetcher/connections/IConnection.ts +++ b/src/fetcher/connections/IConnection.ts @@ -1,7 +1,7 @@ +import DropOffType from "../../enums/DropOffType"; +import PickupType from "../../enums/PickupType"; +import TravelMode from "../../enums/TravelMode"; import { DurationMs } from "../../interfaces/units"; -import TravelMode from "../../TravelMode"; -import DropOffType from "./DropOffType"; -import PickupType from "./PickupType"; /** * Interface for a Connection. This describes an actual transport vehicle going from its diff --git a/src/fetcher/connections/IConnectionsFetcherConfig.ts b/src/fetcher/connections/IConnectionsFetcherConfig.ts deleted file mode 100644 index cbb27f2d..00000000 --- a/src/fetcher/connections/IConnectionsFetcherConfig.ts +++ /dev/null @@ -1,5 +0,0 @@ -export default interface IConnectionsFetcherConfig { - upperBoundDate?: Date; - lowerBoundDate?: Date; - backward?: boolean; -} diff --git a/src/fetcher/connections/IConnectionsIteratorOptions.ts b/src/fetcher/connections/IConnectionsIteratorOptions.ts new file mode 100644 index 00000000..0060e196 --- /dev/null +++ b/src/fetcher/connections/IConnectionsIteratorOptions.ts @@ -0,0 +1,9 @@ +/** + * Options passed to [[IConnectionsProvider]] and [[IConnectionsFetcher]] instances + * for creating AsyncIterators of [[IConnection]] instances. + */ +export default interface IConnectionsIteratorOptions { + upperBoundDate?: Date; + lowerBoundDate?: Date; + backward?: boolean; +} diff --git a/src/fetcher/connections/IConnectionsProvider.ts b/src/fetcher/connections/IConnectionsProvider.ts index 7d89c6c7..054c1c53 100644 --- a/src/fetcher/connections/IConnectionsProvider.ts +++ b/src/fetcher/connections/IConnectionsProvider.ts @@ -1,6 +1,6 @@ import { AsyncIterator } from "asynciterator"; import IConnection from "./IConnection"; -import IConnectionsFetcherConfig from "./IConnectionsFetcherConfig"; +import IConnectionsIteratorOptions from "./IConnectionsIteratorOptions"; /** * A IConnectionsProvider serves as interface to other classes that want to use [[IConnection]] instances @@ -8,6 +8,7 @@ import IConnectionsFetcherConfig from "./IConnectionsFetcherConfig"; * instances */ export default interface IConnectionsProvider { + prefetchConnections: () => void; createIterator: () => AsyncIterator; - setConfig: (config: IConnectionsFetcherConfig) => void; + setIteratorOptions: (options: IConnectionsIteratorOptions) => void; } diff --git a/src/fetcher/connections/ld-fetch/ConnectionsPageParser.ts b/src/fetcher/connections/hydra/ConnectionsPageParser.ts similarity index 96% rename from src/fetcher/connections/ld-fetch/ConnectionsPageParser.ts rename to src/fetcher/connections/hydra/ConnectionsPageParser.ts index a3a25fe9..084d34f2 100644 --- a/src/fetcher/connections/ld-fetch/ConnectionsPageParser.ts +++ b/src/fetcher/connections/hydra/ConnectionsPageParser.ts @@ -1,10 +1,10 @@ import { Triple } from "rdf-js"; -import TravelMode from "../../../TravelMode"; +import DropOffType from "../../../enums/DropOffType"; +import PickupType from "../../../enums/PickupType"; +import TravelMode from "../../../enums/TravelMode"; import Rdf from "../../../util/Rdf"; import Units from "../../../util/Units"; -import DropOffType from "../DropOffType"; import IConnection from "../IConnection"; -import PickupType from "../PickupType"; interface IEntity { } diff --git a/src/fetcher/connections/hydra/HydraPageIterator.ts b/src/fetcher/connections/hydra/HydraPageIterator.ts new file mode 100644 index 00000000..ea962292 --- /dev/null +++ b/src/fetcher/connections/hydra/HydraPageIterator.ts @@ -0,0 +1,69 @@ +import { BufferedIterator } from "asynciterator"; +import LdFetch from "ldfetch"; +import UriTemplate from "uritemplate"; +import HydraPageParser2 from "./HydraPageParser"; +import IHydraPage from "./IHydraPage"; +import IHydraPageIteratorConfig from "./IHydraPageIteratorConfig"; + +export default class HydraPageIterator extends BufferedIterator { + private readonly baseUrl: string; + private readonly ldFetch: LdFetch; + private readonly config: IHydraPageIteratorConfig; + + private currentPage: IHydraPage; + + constructor( + baseUrl: string, + ldFetch: LdFetch, + config: IHydraPageIteratorConfig, + ) { + super({ + autoStart: true, + }); + + this.baseUrl = baseUrl; + this.ldFetch = ldFetch; + this.config = config; + } + + public _begin(done: () => void): void { + this.ldFetch.get(this.baseUrl) + .then((response) => { + const parser = new HydraPageParser2(response.triples); + const searchTemplate: UriTemplate = parser.getSearchTemplate(); + + const firstPageIri = searchTemplate.expand(this.config.initialTemplateVariables); + + this.loadPage(firstPageIri) + .then(() => done()); + }); + } + + public _read(count: number, done: () => void): void { + + const pageIri = this.config.backward ? + this.currentPage.previousPageIri : this.currentPage.nextPageIri; + + this.loadPage(pageIri) + .then(() => done()); + } + + private async loadPage(url: string) { + await this.ldFetch.get(url) + .then((response) => { + + const parser = new HydraPageParser2(response.triples); + const page = parser.getPage(0); + + if (this.config.backward) { + page.previousPageIri = parser.getPreviousPageIri(); + + } else { + page.nextPageIri = parser.getNextPageIri(); + } + + this.currentPage = page; + this._push(this.currentPage); + }); + } +} diff --git a/src/fetcher/connections/ld-fetch/HydraPageParser.ts b/src/fetcher/connections/hydra/HydraPageParser.ts similarity index 87% rename from src/fetcher/connections/ld-fetch/HydraPageParser.ts rename to src/fetcher/connections/hydra/HydraPageParser.ts index e28779a3..45ac8261 100644 --- a/src/fetcher/connections/ld-fetch/HydraPageParser.ts +++ b/src/fetcher/connections/hydra/HydraPageParser.ts @@ -1,8 +1,6 @@ import { Triple } from "rdf-js"; import UriTemplate from "uritemplate"; -import TravelMode from "../../../TravelMode"; import Rdf from "../../../util/Rdf"; -import ConnectionsPageParser from "./ConnectionsPageParser"; import IHydraPage from "./IHydraPage"; /** @@ -18,14 +16,11 @@ export default class HydraPageParser { this.documentIri = this.getDocumentIri(); } - public getPage(index: number, travelMode: TravelMode): IHydraPage { - const connectionsParser = new ConnectionsPageParser(this.documentIri, this.triples); - const connections = connectionsParser.getConnections(travelMode); - + public getPage(index: number): IHydraPage { return { index, documentIri: this.documentIri, - connections, + triples: this.triples, }; } diff --git a/src/fetcher/connections/ld-fetch/IHydraPage.ts b/src/fetcher/connections/hydra/IHydraPage.ts similarity index 64% rename from src/fetcher/connections/ld-fetch/IHydraPage.ts rename to src/fetcher/connections/hydra/IHydraPage.ts index be94cad5..a629686a 100644 --- a/src/fetcher/connections/ld-fetch/IHydraPage.ts +++ b/src/fetcher/connections/hydra/IHydraPage.ts @@ -1,9 +1,9 @@ -import IConnection from "../IConnection"; +import { Triple } from "rdf-js"; export default interface IHydraPage { index: number; documentIri: string; nextPageIri?: string; previousPageIri?: string; - connections: IConnection[]; + triples: Triple[]; } diff --git a/src/fetcher/connections/hydra/IHydraPageIteratorConfig.ts b/src/fetcher/connections/hydra/IHydraPageIteratorConfig.ts new file mode 100644 index 00000000..871acfe8 --- /dev/null +++ b/src/fetcher/connections/hydra/IHydraPageIteratorConfig.ts @@ -0,0 +1,4 @@ +export default interface IHydraPageIteratorConfig { + backward: boolean; + initialTemplateVariables: object; +} diff --git a/src/fetcher/connections/ld-fetch/ConnectionsFetcherLazy.ts b/src/fetcher/connections/lazy/ConnectionsFetcherLazy.ts similarity index 74% rename from src/fetcher/connections/ld-fetch/ConnectionsFetcherLazy.ts rename to src/fetcher/connections/lazy/ConnectionsFetcherLazy.ts index 18dd7e66..8e36dbd6 100644 --- a/src/fetcher/connections/ld-fetch/ConnectionsFetcherLazy.ts +++ b/src/fetcher/connections/lazy/ConnectionsFetcherLazy.ts @@ -1,11 +1,11 @@ import { AsyncIterator } from "asynciterator"; import { inject, injectable } from "inversify"; import LDFetch from "ldfetch"; -import TravelMode from "../../../TravelMode"; +import TravelMode from "../../../enums/TravelMode"; import TYPES from "../../../types"; import IConnection from "../IConnection"; import IConnectionsFetcher from "../IConnectionsFetcher"; -import IConnectionsFetcherConfig from "../IConnectionsFetcherConfig"; +import IConnectionsIteratorOptions from "../IConnectionsIteratorOptions"; import ConnectionsIteratorLazy from "./ConnectionsIteratorLazy"; /** @@ -16,7 +16,7 @@ import ConnectionsIteratorLazy from "./ConnectionsIteratorLazy"; export default class ConnectionsFetcherLazy implements IConnectionsFetcher { protected readonly ldFetch: LDFetch; - protected config: IConnectionsFetcherConfig; + protected options: IConnectionsIteratorOptions; private travelMode: TravelMode; private accessUrl: string; @@ -32,16 +32,20 @@ export default class ConnectionsFetcherLazy implements IConnectionsFetcher { this.accessUrl = accessUrl; } + public prefetchConnections(): void { + return; + } + public createIterator(): AsyncIterator { return new ConnectionsIteratorLazy( this.accessUrl, this.travelMode, this.ldFetch, - this.config, + this.options, ); } - public setConfig(config: IConnectionsFetcherConfig): void { - this.config = config; + public setIteratorOptions(options: IConnectionsIteratorOptions): void { + this.options = options; } } diff --git a/src/fetcher/connections/ld-fetch/ConnectionsIteratorLazy.test.ts b/src/fetcher/connections/lazy/ConnectionsIteratorLazy.test.ts similarity index 84% rename from src/fetcher/connections/ld-fetch/ConnectionsIteratorLazy.test.ts rename to src/fetcher/connections/lazy/ConnectionsIteratorLazy.test.ts index 3195d75c..4366f1ed 100644 --- a/src/fetcher/connections/ld-fetch/ConnectionsIteratorLazy.test.ts +++ b/src/fetcher/connections/lazy/ConnectionsIteratorLazy.test.ts @@ -1,7 +1,8 @@ import "jest"; import LdFetch from "ldfetch"; -import TravelMode from "../../../TravelMode"; +import TravelMode from "../../../enums/TravelMode"; import IConnection from "../IConnection"; +import IConnectionsIteratorOptions from "../IConnectionsIteratorOptions"; import ConnectionsIteratorLazy from "./ConnectionsIteratorLazy"; const CONNECTIONS_TO_LOAD = 500; // Should be more than contained on first page @@ -9,7 +10,7 @@ const CONNECTIONS_TO_LOAD = 500; // Should be more than contained on first page test("[ConnectionsIteratorLazy] iterate forwards", (done) => { jest.setTimeout(90000); - const config = { + const options: IConnectionsIteratorOptions = { backward: false, lowerBoundDate: new Date(2018, 10, 22, 10), }; @@ -17,7 +18,7 @@ test("[ConnectionsIteratorLazy] iterate forwards", (done) => { "https://graph.irail.be/sncb/connections", TravelMode.Train, new LdFetch(), - config, + options, ); let i = 0; diff --git a/src/fetcher/connections/lazy/ConnectionsIteratorLazy.ts b/src/fetcher/connections/lazy/ConnectionsIteratorLazy.ts new file mode 100644 index 00000000..d3c27ec3 --- /dev/null +++ b/src/fetcher/connections/lazy/ConnectionsIteratorLazy.ts @@ -0,0 +1,51 @@ +import { ArrayIterator } from "asynciterator"; +import LdFetch from "ldfetch"; +import TravelMode from "../../../enums/TravelMode"; +import FlatMapIterator from "../../../util/iterators/FlatMapIterator"; +import ConnectionsPageParser from "../hydra/ConnectionsPageParser"; +import HydraPageIterator from "../hydra/HydraPageIterator"; +import IHydraPage from "../hydra/IHydraPage"; +import IHydraPageIteratorConfig from "../hydra/IHydraPageIteratorConfig"; +import IConnection from "../IConnection"; +import IConnectionsIteratorOptions from "../IConnectionsIteratorOptions"; + +/** + * Base class for fetching linked connections with LDFetch and letting the caller iterate over them asynchronously + * through implementing the AsyncIterator protocol. + * LDFetch returns documents as an array of RDF triples. + * The meta Hydra triples are used for paginating to the next or previous page. + * The triples that describe linked connections get deserialized to instances of [[IConnection]] + */ +export default class ConnectionsIteratorLazy extends FlatMapIterator { + constructor( + baseUrl: string, + travelMode: TravelMode, + ldFetch: LdFetch, + options: IConnectionsIteratorOptions, + ) { + const departureTimeDate = options.backward ? + options.upperBoundDate : options.lowerBoundDate; + + const pageIteratorConfig: IHydraPageIteratorConfig = { + backward: options.backward, + initialTemplateVariables: { + departureTime: departureTimeDate.toISOString(), + }, + }; + + const pageIterator = new HydraPageIterator(baseUrl, ldFetch, pageIteratorConfig); + const parsePageConnections = (page: IHydraPage) => { + const connectionsParser = new ConnectionsPageParser(page.documentIri, page.triples); + + const connections = connectionsParser.getConnections(travelMode); + + if (options.backward) { + connections.reverse(); + } + + return new ArrayIterator(connections); + }; + + super(pageIterator, parsePageConnections); + } +} diff --git a/src/fetcher/connections/ld-fetch/ConnectionsIteratorLazy.ts b/src/fetcher/connections/ld-fetch/ConnectionsIteratorLazy.ts deleted file mode 100644 index 5308d950..00000000 --- a/src/fetcher/connections/ld-fetch/ConnectionsIteratorLazy.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { BufferedIterator } from "asynciterator"; -import LdFetch from "ldfetch"; -import UriTemplate from "uritemplate"; -import TravelMode from "../../../TravelMode"; -import IConnection from "../IConnection"; -import IConnectionsFetcherConfig from "../IConnectionsFetcherConfig"; -import HydraPageParser from "./HydraPageParser"; -import IHydraPage from "./IHydraPage"; - -/** - * Base class for fetching linked connections with LDFetch and letting the caller iterate over them asynchronously - * through implementing the AsyncIterator protocol. - * LDFetch returns documents as an array of RDF triples. - * The meta Hydra triples are used for paginating to the next or previous page. - * The triples that describe linked connections get deserialized to instances of [[IConnection]] - */ -export default class ConnectionsIteratorLazy extends BufferedIterator { - private readonly baseUrl: string; - private readonly travelMode: TravelMode; - private readonly ldFetch: LdFetch; - private readonly config: IConnectionsFetcherConfig; - - private currentPage: IHydraPage; - - constructor( - baseUrl: string, - travelMode: TravelMode, - ldFetch: LdFetch, - config: IConnectionsFetcherConfig, - ) { - super({ - autoStart: true, - }); - - this.baseUrl = baseUrl; - this.travelMode = travelMode; - this.ldFetch = ldFetch; - this.config = config; - } - - public _begin(done: () => void): void { - this.ldFetch.get(this.baseUrl) - .then((response) => { - const parser = new HydraPageParser(response.triples); - const searchTemplate: UriTemplate = parser.getSearchTemplate(); - - const departureTimeDate = this.config.backward ? - this.config.upperBoundDate : this.config.lowerBoundDate; - - const firstPageIri = searchTemplate.expand({ - departureTime: departureTimeDate.toISOString(), - }); - - this.loadPage(firstPageIri) - .then(() => done()); - }); - } - - public _read(count: number, done: () => void): void { - - const pageIri = this.config.backward ? - this.currentPage.previousPageIri : this.currentPage.nextPageIri; - - this.loadPage(pageIri) - .then(() => done()); - } - - private async loadPage(url: string) { - await this.ldFetch.get(url) - .then((response) => { - - const parser = new HydraPageParser(response.triples); - const page = parser.getPage(0, this.travelMode); - - if (this.config.backward) { - page.previousPageIri = parser.getPreviousPageIri(); - - } else { - page.nextPageIri = parser.getNextPageIri(); - } - - this.currentPage = page; - this.pushCurrentPage(); - }); - } - - private pushCurrentPage(): void { - const { connections } = this.currentPage; - - if (this.config.backward) { - let c = connections.length - 1; - - while (c >= 0) { - this._push(connections[c]); - c--; - } - - // Forwards - } else { - for (const connection of connections) { - this._push(connection); - } - } - } - -} diff --git a/src/fetcher/connections/prefetch/ArrayViewIterator.test.ts b/src/fetcher/connections/prefetch/ArrayViewIterator.test.ts new file mode 100644 index 00000000..e0fdfed6 --- /dev/null +++ b/src/fetcher/connections/prefetch/ArrayViewIterator.test.ts @@ -0,0 +1,80 @@ +import "jest"; +import ArrayViewIterator from "./ArrayViewIterator"; + +describe("[ArrayViewIterator]", () => { + + const sourceArray = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + + it("step +1 and start < stop", (done) => { + + const viewIterator = new ArrayViewIterator( + sourceArray, 1, 5, +1, + ); + + const expected = [2, 3, 4, 5, 6]; + let currentRead = 0; + + viewIterator.each((str: number) => { + expect(expected[currentRead++]).toBe(str); + }); + + viewIterator.on("end", () => { + expect(currentRead).toBe(expected.length); + + done(); + }); + }); + + it("step +1 and start > stop", (done) => { + + const viewIterator = new ArrayViewIterator( + sourceArray, 5, 1, +1, + ); + + const each = jest.fn(); + viewIterator.each(each); + + viewIterator.on("end", () => { + expect(each).not.toHaveBeenCalled(); + + done(); + }); + }); + + it("step -1 and start > stop", (done) => { + + const viewIterator = new ArrayViewIterator( + sourceArray, 5, 1, -1, + ); + + const expected = [2, 3, 4, 5, 6]; + let currentRead = expected.length - 1; + + viewIterator.each((str: number) => { + expect(expected[currentRead--]).toBe(str); + }); + + viewIterator.on("end", () => { + expect(currentRead).toBe(-1); + + done(); + }); + }); + + it("step -1 and start < stop", (done) => { + + const viewIterator = new ArrayViewIterator( + sourceArray, 1, 5, -1, + ); + + const each = jest.fn(); + viewIterator.each(each); + + viewIterator.on("end", () => { + expect(each).not.toHaveBeenCalled(); + + done(); + }); + }); + +}); diff --git a/src/fetcher/connections/prefetch/ArrayViewIterator.ts b/src/fetcher/connections/prefetch/ArrayViewIterator.ts new file mode 100644 index 00000000..b8d0f856 --- /dev/null +++ b/src/fetcher/connections/prefetch/ArrayViewIterator.ts @@ -0,0 +1,47 @@ +import { AsyncIterator } from "asynciterator"; + +export default class ArrayViewIterator extends AsyncIterator { + + private readonly source: T[]; + private readonly startIndex: number; + private readonly stopIndex: number; + private readonly step: number; + + private currentIndex: number; + + constructor(source: T[], startIndex: number, stopIndex: number, step: -1 | 1) { + super(); + + this.source = source; + this.startIndex = startIndex; + this.stopIndex = stopIndex; + this.step = step; + + if (step > 0 ? stopIndex < startIndex : stopIndex > startIndex) { + this.close(); + return; + } + + this.currentIndex = startIndex; + this.readable = true; + } + + public read(): T { + if (this.closed) { + return null; + } + + const {source, step, currentIndex, stopIndex} = this; + + if (step > 0 ? currentIndex > stopIndex : currentIndex < stopIndex) { + this.close(); + return null; + } + + const item = source[currentIndex]; + + this.currentIndex += step; + + return item; + } +} diff --git a/src/fetcher/connections/prefetch/ConnectionsProviderPrefetch.ts b/src/fetcher/connections/prefetch/ConnectionsProviderPrefetch.ts new file mode 100644 index 00000000..bb2b8bdb --- /dev/null +++ b/src/fetcher/connections/prefetch/ConnectionsProviderPrefetch.ts @@ -0,0 +1,75 @@ +import { AsyncIterator } from "asynciterator"; +import { inject, injectable } from "inversify"; +import Catalog from "../../../Catalog"; +import Context from "../../../Context"; +import TYPES, { ConnectionsFetcherFactory } from "../../../types"; +import IConnection from "../IConnection"; +import IConnectionsFetcher from "../IConnectionsFetcher"; +import IConnectionsIteratorOptions from "../IConnectionsIteratorOptions"; +import IConnectionsProvider from "../IConnectionsProvider"; +import ConnectionsStore from "./ConnectionsStore"; + +/** + * This connections provider implements the [[IConnectionsProvider.prefetchConnections]] method. + * When called, it asks an AsyncIterator from the instantiated [[IConnectionsFetcher]]. + * All items from that iterator get appended to a [[ConnectionsStore]] + * + * When [[IConnectionsProvider.createIterator]] is called, it returns an iterator *view* from the [[ConnectionsStore]] + */ +@injectable() +export default class ConnectionsProviderPrefetch implements IConnectionsProvider { + + private static MAX_CONNECTIONS = 20000; + + private readonly context: Context; + private readonly connectionsFetcher: IConnectionsFetcher; + private readonly connectionsStore: ConnectionsStore; + + private startedPrefetching: boolean; + private connectionsIterator: AsyncIterator; + private connectionsIteratorOptions: IConnectionsIteratorOptions; + + constructor( + @inject(TYPES.ConnectionsFetcherFactory) connectionsFetcherFactory: ConnectionsFetcherFactory, + @inject(TYPES.Catalog) catalog: Catalog, + @inject(TYPES.Context) context: Context, + ) { + const { accessUrl, travelMode } = catalog.connectionsSourceConfigs[0]; + + this.context = context; + this.connectionsFetcher = connectionsFetcherFactory(accessUrl, travelMode); + this.connectionsStore = new ConnectionsStore(context); + } + + public prefetchConnections(): void { + if (!this.startedPrefetching) { + this.startedPrefetching = true; + + setTimeout(() => { + const options: IConnectionsIteratorOptions = { + backward: false, + lowerBoundDate: new Date(), + }; + + this.connectionsFetcher.setIteratorOptions(options); + this.connectionsIterator = this.connectionsFetcher.createIterator(); + this.connectionsStore.setSourceIterator(this.connectionsIterator); + this.connectionsStore.startPrimaryPush(ConnectionsProviderPrefetch.MAX_CONNECTIONS); + }, 0); + } + } + + public createIterator(): AsyncIterator { + if (this.startedPrefetching) { + return this.connectionsStore + .getIterator(this.connectionsIteratorOptions); + } + + throw new Error("TODO"); + } + + public setIteratorOptions(options: IConnectionsIteratorOptions): void { + this.connectionsIteratorOptions = options; + } + +} diff --git a/src/fetcher/connections/prefetch/ConnectionsStore.test.ts b/src/fetcher/connections/prefetch/ConnectionsStore.test.ts new file mode 100644 index 00000000..4348e148 --- /dev/null +++ b/src/fetcher/connections/prefetch/ConnectionsStore.test.ts @@ -0,0 +1,126 @@ +import { ArrayIterator, AsyncIterator } from "asynciterator"; +import "jest"; +import IConnection from "../IConnection"; +import IConnectionsIteratorOptions from "../IConnectionsIteratorOptions"; +import ConnectionsStore from "./ConnectionsStore"; + +describe("[ConnectionsStore]", () => { + + /** + * In this test, departureTime dates are substituted for numbers for simplicity + * Inside the ConnectionsStore, #valueOf() gets called on the connection.departureTime + * and both Dates and Numbers return a number + */ + + let connectionsStore; + let createIterator; + + beforeEach(() => { + const fakeDepartureTimes = [1, 2, 3, 3, 5, 6, 6, 6, 6, 7, 7]; + // @ts-ignore + const fakeConnections: IConnection[] = fakeDepartureTimes + .map((departureTime) => ({ departureTime })); + + const fakeSourceIterator = new ArrayIterator(fakeConnections); + + connectionsStore = new ConnectionsStore(); + + connectionsStore.setSourceIterator(fakeSourceIterator); + connectionsStore.startPrimaryPush(500); + + createIterator = (backward, lowerBoundDate, upperBoundDate): Promise> => { + return new Promise((resolve) => { + // Primary push start async, so get iterator async + // Running a query is most often initiated by a user event, while prefetching start automatically + setTimeout(() => { + const iteratorOptions: IConnectionsIteratorOptions = { + backward, + }; + + if (lowerBoundDate) { + iteratorOptions.lowerBoundDate = (lowerBoundDate as unknown) as Date; + } + + if (upperBoundDate) { + iteratorOptions.upperBoundDate = (upperBoundDate as unknown) as Date; + } + + resolve(connectionsStore.getIterator(iteratorOptions)); + }, 100); + }); + }; + }); + + describe("backward", () => { + + it("upperBoundDate is loaded & exists in store", async (done) => { + const iteratorView = await createIterator(true, null, 6); + + const expected = [1, 2, 3, 3, 5, 6, 6, 6, 6]; + let current = expected.length - 1; + + iteratorView.each((str: IConnection) => { + expect(expected[current--]).toBe(str.departureTime); + }); + + iteratorView.on("end", () => { + expect(current).toBe(-1); + done(); + }); + }); + + it("upperBoundDate is loaded but doesn\'t exist in store", async (done) => { + const iteratorView = await createIterator(true, null, 4); + + const expected = [1, 2, 3, 3]; + let current = expected.length - 1; + + iteratorView.each((str: IConnection) => { + expect(expected[current--]).toBe(str.departureTime); + }); + + iteratorView.on("end", () => { + expect(current).toBe(-1); + done(); + }); + }); + + }); + + describe("forward", () => { + + it("lowerBoundDate is loaded & exists in store", async (done) => { + const iteratorView = await createIterator(false, 3, 6); + + const expected = [3, 3, 5, 6, 6, 6, 6]; + let current = 0; + + iteratorView.each((str: IConnection) => { + expect(expected[current++]).toBe(str.departureTime); + }); + + iteratorView.on("end", () => { + expect(current).toBe(expected.length); + done(); + }); + }); + + it("lowerBoundDate is loaded but doesn\'t exist in store", async (done) => { + const iteratorView = await createIterator(false, 4, 6); + + const expected = [5, 6, 6, 6, 6]; + let current = 0; + + iteratorView.each((str: IConnection) => { + expect(expected[current++]).toBe(str.departureTime); + }); + + iteratorView.on("end", () => { + expect(current).toBe(expected.length); + done(); + }); + }); + + }); + +}); diff --git a/src/fetcher/connections/prefetch/ConnectionsStore.ts b/src/fetcher/connections/prefetch/ConnectionsStore.ts new file mode 100644 index 00000000..33612d79 --- /dev/null +++ b/src/fetcher/connections/prefetch/ConnectionsStore.ts @@ -0,0 +1,355 @@ +import { AsyncIterator, EmptyIterator } from "asynciterator"; +import { PromiseProxyIterator } from "asynciterator-promiseproxy"; +import Context from "../../../Context"; +import EventType from "../../../enums/EventType"; +import BinarySearch from "../../../util/BinarySearch"; +import ExpandingIterator from "../../../util/iterators/ExpandingIterator"; +import Units from "../../../util/Units"; +import IConnection from "../IConnection"; +import IConnectionsIteratorOptions from "../IConnectionsIteratorOptions"; +import ArrayViewIterator from "./ArrayViewIterator"; +import IDeferredBackwardView from "./IDeferredBackwardView"; +import IExpandingForwardView from "./IExpandingForwardView"; + +/** + * Class used while prefetching [[IConnection]] instances. It allows appending connections + * and creating iterator *views*. Iterator *views* are AsyncIterators that emit references to connections in the store. + * + * It is assumed that all connections are appended in ascending order by `departureTime`. + * + * Consequently this connections store serves as an in-memory cache for connections + */ +export default class ConnectionsStore { + + private static REPORTING_THRESHOLD = Units.fromHours(.25); + + private readonly context: Context; + private readonly store: IConnection[]; + private readonly binarySearch: BinarySearch; + + private sourceIterator: AsyncIterator; + private deferredBackwardViews: IDeferredBackwardView[]; + private expandingForwardViews: IExpandingForwardView[]; + + private hasFinishedPrimary: boolean; + private isContinuing: boolean; + private lastReportedDepartureTime: Date; + + constructor(context?: Context) { + this.context = context; + this.store = []; + this.binarySearch = new BinarySearch(this.store, (connection) => connection.departureTime.valueOf()); + this.deferredBackwardViews = []; + this.expandingForwardViews = []; + this.hasFinishedPrimary = false; + } + + public setSourceIterator(iterator: AsyncIterator): void { + this.sourceIterator = iterator; + } + + public startPrimaryPush(maxConnections: number): void { + + this.sourceIterator + .transform({ + limit: maxConnections, + destroySource: false, + }) + .on("end", () => this.finishPrimaryPush()) + .each((connection: IConnection) => { + if (this.context) { + this.maybeEmitPrefetchEvent(connection); + } + + this.append(connection); + }); + } + + public getIterator(iteratorOptions: IConnectionsIteratorOptions): AsyncIterator { + const { backward } = iteratorOptions; + let { lowerBoundDate, upperBoundDate } = iteratorOptions; + + if (this.hasFinishedPrimary && this.store.length === 0) { + return new EmptyIterator(); + } + + const firstConnection = this.store[0]; + const firstDepartureTime = firstConnection && firstConnection.departureTime; + + const lastConnection = this.store[this.store.length - 1]; + const lastDepartureTime = lastConnection && lastConnection.departureTime; + + if (lowerBoundDate && lowerBoundDate < firstDepartureTime) { + throw new Error("Must supply a lowerBoundDate after the first prefetched connection"); + } + + if (backward) { + + if (!upperBoundDate) { + throw new Error("Must supply upperBoundDate when iterating backward"); + } + + if (!lowerBoundDate) { + lowerBoundDate = firstDepartureTime; + } + + this.emitConnectionViewEvent(lowerBoundDate, upperBoundDate, false); + + // If the store is still empty or the latest departure time isn't later than the upperBoundDate, + // then return a promise proxy iterator + const notFinishedScenario = !this.hasFinishedPrimary + && (!lastDepartureTime || lastDepartureTime <= upperBoundDate); + + const finishedScenario = this.hasFinishedPrimary + && lastDepartureTime < upperBoundDate; + + if (notFinishedScenario || finishedScenario) { + const { deferred, promise } = this.createDeferredBackwardView(lowerBoundDate, upperBoundDate); + + this.deferredBackwardViews.push(deferred); + + if (this.hasFinishedPrimary) { + this.continueAfterFinishing(); + } + + return new PromiseProxyIterator(() => promise); + } + + } else { + + if (!lowerBoundDate) { + throw new Error("Must supply lowerBoundDate when iterating forward"); + } + + if (!upperBoundDate) { + // Mock +infinity + upperBoundDate = new Date(lowerBoundDate.valueOf() + Units.fromHours(24)); + } + + this.emitConnectionViewEvent(lowerBoundDate, upperBoundDate, false); + + // If the store is still empty or the latest departure time isn't later than the upperBoundDate, + // then return a an expanding iterator view + const notFinishedScenario = !this.hasFinishedPrimary + && (!lastDepartureTime || lastDepartureTime <= upperBoundDate); + + const finishedScenario = this.hasFinishedPrimary + && lastDepartureTime < upperBoundDate; + + if (notFinishedScenario || finishedScenario) { + const { view, iterator } = this.createExpandingForwardView(lowerBoundDate, upperBoundDate); + + this.expandingForwardViews.push(view); + + if (this.hasFinishedPrimary) { + this.continueAfterFinishing(); + } + + return iterator; + } + } + + // If the whole interval fits inside the prefetched window, return an iterator view + if (lowerBoundDate >= firstDepartureTime && upperBoundDate < lastDepartureTime) { + const { iterator } = this.getIteratorView(backward, lowerBoundDate, upperBoundDate); + + this.emitConnectionViewEvent(lowerBoundDate, upperBoundDate, true); + + return iterator; + } + + throw new Error("This shouldn\'t happen"); + } + + /** + * Add a new [[IConnection]] to the store. + * + * Additionally, this method checks if any forward iterator views can be expanded or if any backward iterator can be + * resolved + * + * @returns the number of unsatisfied views + */ + private append(connection: IConnection): number { + this.store.push(connection); + + // Check if any deferred backward views are satisfied + if (this.deferredBackwardViews.length) { + this.deferredBackwardViews = this.deferredBackwardViews + .filter(({ lowerBoundDate, upperBoundDate, resolve }) => { + + if (connection.departureTime > upperBoundDate) { + const { iterator } = this.getIteratorView(true, lowerBoundDate, upperBoundDate); + + this.emitConnectionViewEvent(lowerBoundDate, upperBoundDate, true); + + resolve(iterator); + return false; + } + + return true; + }); + } + + // Check if any forward views can be expanded + if (this.expandingForwardViews.length) { + this.expandingForwardViews = this.expandingForwardViews + .filter(({ tryExpand }) => tryExpand(connection, this.store.length - 1)); + } + + return this.deferredBackwardViews.length + this.expandingForwardViews.length; + } + + /** + * Signals that the store will no longer be appended. + * [[getIterator]] never returns a deferred backward view after this, because those would never get resolved + */ + private finishPrimaryPush(): void { + this.hasFinishedPrimary = true; + + if (this.deferredBackwardViews.length || this.expandingForwardViews.length) { + this.continueAfterFinishing(); + } + } + + private finishSecondaryPush(): void { + this.isContinuing = false; + } + + private continueAfterFinishing(): void { + if (!this.isContinuing) { + this.isContinuing = true; + + setTimeout(() => this.startSecondaryPush(), 0); + } + } + + private startSecondaryPush(): void { + const secondaryPushIterator = this.sourceIterator + .transform({}) + .on("end", () => this.finishSecondaryPush()); + + secondaryPushIterator.each((connection: IConnection) => { + if (this.context) { + this.maybeEmitPrefetchEvent(connection); + } + + const unsatisfiedViewCount = this.append(connection); + + if (unsatisfiedViewCount === 0) { + secondaryPushIterator.close(); + } + }); + } + + private createDeferredBackwardView(lowerBoundDate, upperBoundDate): + { deferred: IDeferredBackwardView, promise: Promise> } { + + const deferred: Partial = { + lowerBoundDate, + upperBoundDate, + }; + + const promise = new Promise>((resolve) => { + deferred.resolve = resolve; + }); + + return { + deferred: deferred as IDeferredBackwardView, + promise, + }; + } + + private createExpandingForwardView(lowerBoundDate, upperBoundDate): + { view: IExpandingForwardView, iterator: AsyncIterator } { + + const { iterator: existingIterator, upperBoundIndex } = this.getIteratorView(false, lowerBoundDate, upperBoundDate); + const expandingIterator = new ExpandingIterator(); + + const iterator = expandingIterator.prepend(existingIterator); + + let lastStoreIndex = upperBoundIndex; + + const view: IExpandingForwardView = { + lowerBoundDate, + upperBoundDate, + tryExpand: (connection: IConnection, storeIndex: number): boolean => { + + if (storeIndex - lastStoreIndex > 1) { + // No idea if this can happen + console.warn("Skipped", storeIndex - lastStoreIndex); + } + + lastStoreIndex = storeIndex; + + // No need to keep trying to expand if the consumer has closed it + if (iterator.closed) { + expandingIterator.close(); + + return false; // Remove from expanding forward views + } + + if (connection.departureTime <= upperBoundDate) { + expandingIterator.write(connection); + + return true; // Keep in expanding forward views + + } else { + expandingIterator.closeAfterFlush(); + // iterator.close(); + + this.emitConnectionViewEvent(lowerBoundDate, upperBoundDate, true); + + return false; // Remove from expanding forward views + } + }, + }; + + return { view, iterator }; + } + + private getIteratorView(backward: boolean, lowerBoundDate: Date, upperBoundDate: Date): + { iterator: AsyncIterator, lowerBoundIndex: number, upperBoundIndex: number } { + + const lowerBoundIndex = this.getLowerBoundIndex(lowerBoundDate); + const upperBoundIndex = this.getUpperBoundIndex(upperBoundDate); + + const start = backward ? upperBoundIndex : lowerBoundIndex; + const stop = backward ? lowerBoundIndex : upperBoundIndex; + const step = backward ? -1 : 1; + + const iterator = new ArrayViewIterator(this.store, start, stop, step); + + return { iterator, lowerBoundIndex, upperBoundIndex }; + } + + private getLowerBoundIndex(date: Date): number { + return this.binarySearch.findFirstIndex(date.valueOf(), 0, this.store.length - 1); + } + + private getUpperBoundIndex(date: Date): number { + return this.binarySearch.findLastIndex(date.valueOf(), 0, this.store.length - 1); + } + + private emitConnectionViewEvent(lowerBoundDate: Date, upperBoundDate: Date, completed: boolean) { + if (this.context) { + this.context.emit(EventType.ConnectionIteratorView, lowerBoundDate, upperBoundDate, completed); + } + } + + private maybeEmitPrefetchEvent(connection: IConnection): void { + if (!this.lastReportedDepartureTime) { + this.lastReportedDepartureTime = connection.departureTime; + + this.context.emit(EventType.ConnectionPrefetch, this.lastReportedDepartureTime); + return; + } + + const timeSinceLastEvent = connection.departureTime.valueOf() - this.lastReportedDepartureTime.valueOf(); + + if (timeSinceLastEvent > ConnectionsStore.REPORTING_THRESHOLD) { + this.lastReportedDepartureTime = connection.departureTime; + + this.context.emit(EventType.ConnectionPrefetch, this.lastReportedDepartureTime); + } + } +} diff --git a/src/fetcher/connections/prefetch/IDeferredBackwardView.ts b/src/fetcher/connections/prefetch/IDeferredBackwardView.ts new file mode 100644 index 00000000..781878ea --- /dev/null +++ b/src/fetcher/connections/prefetch/IDeferredBackwardView.ts @@ -0,0 +1,8 @@ +import { AsyncIterator } from "asynciterator"; +import IConnection from "../IConnection"; + +export default interface IDeferredBackwardView { + lowerBoundDate: Date; + upperBoundDate: Date; + resolve: (iterator: AsyncIterator) => void; +} diff --git a/src/fetcher/connections/prefetch/IExpandingForwardView.ts b/src/fetcher/connections/prefetch/IExpandingForwardView.ts new file mode 100644 index 00000000..7e376e1a --- /dev/null +++ b/src/fetcher/connections/prefetch/IExpandingForwardView.ts @@ -0,0 +1,7 @@ +import IConnection from "../IConnection"; + +export default interface IExpandingForwardView { + lowerBoundDate: Date; + upperBoundDate: Date; + tryExpand: (connection: IConnection, index: number) => boolean; +} diff --git a/src/fetcher/connections/tests/ConnectionsFetcherNMBSTest.ts b/src/fetcher/connections/tests/ConnectionsFetcherNMBSTest.ts index f512dc0f..5be1c39e 100644 --- a/src/fetcher/connections/tests/ConnectionsFetcherNMBSTest.ts +++ b/src/fetcher/connections/tests/ConnectionsFetcherNMBSTest.ts @@ -2,27 +2,31 @@ import { ArrayIterator, AsyncIterator } from "asynciterator"; import { injectable } from "inversify"; import IConnection from "../IConnection"; import IConnectionsFetcher from "../IConnectionsFetcher"; -import IConnectionsFetcherConfig from "../IConnectionsFetcherConfig"; +import IConnectionsIteratorOptions from "../IConnectionsIteratorOptions"; @injectable() export default class ConnectionsFetcherNMBSTest implements IConnectionsFetcher { private connections: Array> = []; - private config: IConnectionsFetcherConfig = {}; + private options: IConnectionsIteratorOptions = {}; constructor(connections: Array>) { this.connections = connections; } - public setConfig(config: IConnectionsFetcherConfig): void { - this.config = config; + public prefetchConnections(): void { + return; + } + + public setIteratorOptions(options: IConnectionsIteratorOptions): void { + this.options = options; } public createIterator(): AsyncIterator { let array = this.connections .map((r) => r.value); - if (this.config.backward) { + if (this.options.backward) { array = array.reverse(); } diff --git a/src/fetcher/connections/tests/data/ingelmunster-ghent.ts b/src/fetcher/connections/tests/data/ingelmunster-ghent.ts index 23ff7413..d4c5f019 100644 --- a/src/fetcher/connections/tests/data/ingelmunster-ghent.ts +++ b/src/fetcher/connections/tests/data/ingelmunster-ghent.ts @@ -1,4 +1,4 @@ -import TravelMode from "../../../../TravelMode"; +import TravelMode from "../../../../enums/TravelMode"; const connections = [ { diff --git a/src/fetcher/connections/tests/data/joining.ts b/src/fetcher/connections/tests/data/joining.ts index 99a31faa..f237967b 100644 --- a/src/fetcher/connections/tests/data/joining.ts +++ b/src/fetcher/connections/tests/data/joining.ts @@ -1,6 +1,6 @@ -import TravelMode from "../../../../TravelMode"; -import DropOffType from "../../DropOffType"; -import PickupType from "../../PickupType"; +import DropOffType from "../../../../enums/DropOffType"; +import PickupType from "../../../../enums/PickupType"; +import TravelMode from "../../../../enums/TravelMode"; const connections = [ { diff --git a/src/fetcher/connections/tests/data/splitting.ts b/src/fetcher/connections/tests/data/splitting.ts index 56fae16a..d625329d 100644 --- a/src/fetcher/connections/tests/data/splitting.ts +++ b/src/fetcher/connections/tests/data/splitting.ts @@ -1,6 +1,6 @@ -import TravelMode from "../../../../TravelMode"; -import DropOffType from "../../DropOffType"; -import PickupType from "../../PickupType"; +import DropOffType from "../../../../enums/DropOffType"; +import PickupType from "../../../../enums/PickupType"; +import TravelMode from "../../../../enums/TravelMode"; const connections = [ { diff --git a/src/fetcher/stops/IStopsProvider.ts b/src/fetcher/stops/IStopsProvider.ts index 95ef130f..6f467568 100644 --- a/src/fetcher/stops/IStopsProvider.ts +++ b/src/fetcher/stops/IStopsProvider.ts @@ -7,6 +7,7 @@ import IStop from "./IStop"; * @method getAllStops Returns concatenated array of [[IStop]]s from all [[IStopsFetcher]]s it mediates */ export default interface IStopsProvider { + prefetchStops: () => void; getStopById: (stopId: string) => Promise; getAllStops: () => Promise; } diff --git a/src/fetcher/stops/StopsProviderDefault.ts b/src/fetcher/stops/StopsProviderDefault.ts index ffe9b65a..a7fa1cea 100644 --- a/src/fetcher/stops/StopsProviderDefault.ts +++ b/src/fetcher/stops/StopsProviderDefault.ts @@ -9,18 +9,26 @@ import IStopsProvider from "./IStopsProvider"; export default class StopsProviderDefault implements IStopsProvider { private readonly stopsFetchers: IStopsFetcher[]; + private cachedStops: IStop[]; constructor( @inject(TYPES.StopsFetcherFactory) stopsFetcherFactory: StopsFetcherFactory, @inject(TYPES.Catalog) catalog: Catalog, ) { this.stopsFetchers = []; + this.cachedStops = []; - for (const { accessUrl } of catalog.stopsFetcherConfigs) { + for (const { accessUrl } of catalog.stopsSourceConfigs) { this.stopsFetchers.push(stopsFetcherFactory(accessUrl)); } } + public prefetchStops(): void { + for (const stopsFetcher of this.stopsFetchers) { + stopsFetcher.prefetchStops(); + } + } + public async getStopById(stopId: string): Promise { return Promise.all(this.stopsFetchers .map((stopsFetcher: IStopsFetcher) => stopsFetcher.getStopById(stopId)), @@ -28,8 +36,16 @@ export default class StopsProviderDefault implements IStopsProvider { } public async getAllStops(): Promise { + if (this.cachedStops.length > 0) { + return Promise.resolve(this.cachedStops); + } + return Promise.all(this.stopsFetchers .map((stopsFetcher: IStopsFetcher) => stopsFetcher.getAllStops()), - ).then((results: IStop[][]) => [].concat(...results)); + ).then((results: IStop[][]) => { + this.cachedStops = [].concat(...results); + + return this.cachedStops; + }); } } diff --git a/src/fetcher/stops/ld-fetch/StopsFetcherLDFetch.ts b/src/fetcher/stops/ld-fetch/StopsFetcherLDFetch.ts index d7ef11b9..8812fc53 100644 --- a/src/fetcher/stops/ld-fetch/StopsFetcherLDFetch.ts +++ b/src/fetcher/stops/ld-fetch/StopsFetcherLDFetch.ts @@ -34,6 +34,10 @@ export default class StopsFetcherLDFetch implements IStopsFetcher { this.accessUrl = accessUrl; } + public prefetchStops(): void { + this.ensureStopsLoaded(); + } + public async getStopById(stopId: string): Promise { await this.ensureStopsLoaded(); diff --git a/src/interfaces/IQuery.ts b/src/interfaces/IQuery.ts index 1142c149..78bbbc7e 100644 --- a/src/interfaces/IQuery.ts +++ b/src/interfaces/IQuery.ts @@ -1,5 +1,5 @@ import ILocation from "./ILocation"; -import { DistanceM, DurationMs, SpeedkmH } from "./units"; +import { DistanceM, DurationMs, SpeedKmH } from "./units"; export default interface IQuery { from?: string | string[] | ILocation | ILocation[]; @@ -8,12 +8,13 @@ export default interface IQuery { maximumArrivalTime?: Date; roadOnly?: boolean; publicTransportOnly?: boolean; - walkingSpeed?: SpeedkmH; - minimumWalkingSpeed?: SpeedkmH; - maximumWalkingSpeed?: SpeedkmH; + walkingSpeed?: SpeedKmH; + minimumWalkingSpeed?: SpeedKmH; + maximumWalkingSpeed?: SpeedKmH; maximumWalkingDuration?: DurationMs; maximumWalkingDistance?: DistanceM; minimumTransferDuration?: DurationMs; maximumTransferDuration?: DurationMs; + maximumTransferDistance?: DistanceM; maximumTransfers?: number; } diff --git a/src/interfaces/IStep.ts b/src/interfaces/IStep.ts index bf475b41..db6bbaa8 100644 --- a/src/interfaces/IStep.ts +++ b/src/interfaces/IStep.ts @@ -1,4 +1,4 @@ -import TravelMode from "../TravelMode"; +import TravelMode from "../enums/TravelMode"; import ILocation from "./ILocation"; import IProbabilisticValue from "./IProbabilisticValue"; import { DistanceM, DurationMs } from "./units"; diff --git a/src/interfaces/units.ts b/src/interfaces/units.ts index 23f52d27..9cb37ed0 100644 --- a/src/interfaces/units.ts +++ b/src/interfaces/units.ts @@ -1,6 +1,14 @@ -// duration ms +/** + * Represents duration in ms (milliseconds) + */ export type DurationMs = number; -// distance in m + +/** + * Represents distance in m (meters) + */ export type DistanceM = number; -// speed in km/h -export type SpeedkmH = number; + +/** + * Represents duration in km/h (kilometers per hour) + */ +export type SpeedKmH = number; diff --git a/src/inversify.config.ts b/src/inversify.config.ts index 7209beb3..a5ec3b50 100644 --- a/src/inversify.config.ts +++ b/src/inversify.config.ts @@ -3,59 +3,57 @@ import Catalog from "./Catalog"; import catalogDeLijn from "./catalog.delijn"; import catalogNmbs from "./catalog.nmbs"; import Context from "./Context"; +import ReachableStopsSearchPhase from "./enums/ReachableStopsSearchPhase"; +import TravelMode from "./enums/TravelMode"; +import ConnectionsProviderMerge from "./fetcher/connections/ConnectionsProviderMerge"; import IConnectionsFetcher from "./fetcher/connections/IConnectionsFetcher"; import IConnectionsProvider from "./fetcher/connections/IConnectionsProvider"; -import ConnectionsFetcherLazy from "./fetcher/connections/ld-fetch/ConnectionsFetcherLazy"; -import ConnectionsProviderMerge from "./fetcher/connections/merge/ConnectionsProviderMerge"; +import ConnectionsFetcherLazy from "./fetcher/connections/lazy/ConnectionsFetcherLazy"; +import ConnectionsProviderPrefetch from "./fetcher/connections/prefetch/ConnectionsProviderPrefetch"; import LDFetch from "./fetcher/LDFetch"; import IStopsFetcher from "./fetcher/stops/IStopsFetcher"; import IStopsProvider from "./fetcher/stops/IStopsProvider"; import StopsFetcherLDFetch from "./fetcher/stops/ld-fetch/StopsFetcherLDFetch"; import StopsProviderDefault from "./fetcher/stops/StopsProviderDefault"; +import CSAProfile from "./planner/public-transport/CSAProfile"; import IJourneyExtractor from "./planner/public-transport/IJourneyExtractor"; import IPublicTransportPlanner from "./planner/public-transport/IPublicTransportPlanner"; -import JourneyExtractionPhase from "./planner/public-transport/JourneyExtractionPhase"; -import JourneyExtractorDefault from "./planner/public-transport/JourneyExtractorDefault"; -import PublicTransportPlannerCSAProfile from "./planner/public-transport/PublicTransportPlannerCSAProfile"; +import JourneyExtractorProfile from "./planner/public-transport/JourneyExtractorProfile"; import IRoadPlanner from "./planner/road/IRoadPlanner"; import RoadPlannerBirdsEye from "./planner/road/RoadPlannerBirdsEye"; import IReachableStopsFinder from "./planner/stops/IReachableStopsFinder"; -import ReachableStopsFinderBirdsEyeCached from "./planner/stops/ReachableStopsFinderBirdsEyeCached"; -import ReachableStopsSearchPhase from "./planner/stops/ReachableStopsSearchPhase"; +import ReachableStopsFinderOnlySelf from "./planner/stops/ReachableStopsFinderOnlySelf"; +import ReachableStopsFinderRoadPlannerCached from "./planner/stops/ReachableStopsFinderRoadPlannerCached"; import QueryRunnerExponential from "./query-runner/exponential/QueryRunnerExponential"; import ILocationResolver from "./query-runner/ILocationResolver"; import IQueryRunner from "./query-runner/IQueryRunner"; -import LocationResolverDefault from "./query-runner/LocationResolverDefault"; -import TravelMode from "./TravelMode"; +import LocationResolverConvenience from "./query-runner/LocationResolverConvenience"; import TYPES from "./types"; const container = new Container(); container.bind(TYPES.Context).to(Context).inSingletonScope(); container.bind(TYPES.QueryRunner).to(QueryRunnerExponential); -container.bind(TYPES.LocationResolver).to(LocationResolverDefault); +container.bind(TYPES.LocationResolver).to(LocationResolverConvenience); container.bind(TYPES.PublicTransportPlanner) - .to(PublicTransportPlannerCSAProfile); + .to(CSAProfile); container.bind>(TYPES.PublicTransportPlannerFactory) .toAutoFactory(TYPES.PublicTransportPlanner); -container.bind(TYPES.JourneyExtractor) - .to(JourneyExtractorDefault); -container.bind(TYPES.RoadPlanner) - .to(RoadPlannerBirdsEye).whenTargetTagged("phase", JourneyExtractionPhase.Initial); -container.bind(TYPES.RoadPlanner) - .to(RoadPlannerBirdsEye).whenTargetTagged("phase", JourneyExtractionPhase.Transfer); container.bind(TYPES.RoadPlanner) - .to(RoadPlannerBirdsEye).whenTargetTagged("phase", JourneyExtractionPhase.Final); + .to(RoadPlannerBirdsEye); + +container.bind(TYPES.JourneyExtractor) + .to(JourneyExtractorProfile); container.bind(TYPES.ReachableStopsFinder) - .to(ReachableStopsFinderBirdsEyeCached).whenTargetTagged("phase", ReachableStopsSearchPhase.Initial); + .to(ReachableStopsFinderRoadPlannerCached).whenTargetTagged("phase", ReachableStopsSearchPhase.Initial); container.bind(TYPES.ReachableStopsFinder) - .to(ReachableStopsFinderBirdsEyeCached).whenTargetTagged("phase", ReachableStopsSearchPhase.Transfer); + .to(ReachableStopsFinderOnlySelf).whenTargetTagged("phase", ReachableStopsSearchPhase.Transfer); container.bind(TYPES.ReachableStopsFinder) - .to(ReachableStopsFinderBirdsEyeCached).whenTargetTagged("phase", ReachableStopsSearchPhase.Final); + .to(ReachableStopsFinderRoadPlannerCached).whenTargetTagged("phase", ReachableStopsSearchPhase.Final); -container.bind(TYPES.ConnectionsProvider).to(ConnectionsProviderMerge).inSingletonScope(); +container.bind(TYPES.ConnectionsProvider).to(ConnectionsProviderPrefetch).inSingletonScope(); container.bind(TYPES.ConnectionsFetcher).to(ConnectionsFetcherLazy); container.bind>(TYPES.ConnectionsFetcherFactory) .toFactory( diff --git a/src/planner/IPlanner.ts b/src/planner/IPlanner.ts index 28a85a84..d670860f 100644 --- a/src/planner/IPlanner.ts +++ b/src/planner/IPlanner.ts @@ -2,6 +2,10 @@ import { AsyncIterator } from "asynciterator"; import IPath from "../interfaces/IPath"; import IResolvedQuery from "../query-runner/IResolvedQuery"; +/** + * This interface functions as base interface for both the [[IPublicTransportPlanner]] + * and the [[IRoadPlanner]] interface + */ export default interface IPlanner { plan: (query: IResolvedQuery) => Promise>; } diff --git a/src/planner/Path.ts b/src/planner/Path.ts index ac79d24c..3174b223 100644 --- a/src/planner/Path.ts +++ b/src/planner/Path.ts @@ -1,7 +1,12 @@ import IPath from "../interfaces/IPath"; import IStep from "../interfaces/IStep"; +import { DurationMs } from "../interfaces/units"; import Step from "./Step"; +/** + * This Path class serves as an implementation of the [[IPath]] interface and as a home for some helper functions + * related to [[IPath]] instances + */ export default class Path implements IPath { public static create(): Path { @@ -10,6 +15,22 @@ export default class Path implements IPath { ); } + /** + * Compare two [[IPath]] instances + * @returns true if the two paths are the same + */ + public static compareEquals(path: IPath, otherPath: IPath): boolean { + if (path.steps.length !== otherPath.steps.length) { + return false; + } + + return path.steps.every((step, stepIndex) => { + const otherStep = otherPath.steps[stepIndex]; + + return Step.compareEquals(step, otherStep); + }); + } + public steps: IStep[]; constructor(steps: IStep[]) { @@ -20,16 +41,23 @@ export default class Path implements IPath { this.steps.push(step); } - public equals(path: IPath): boolean { + public addPath(path: IPath): void { + this.steps.push(...path.steps); + } - if (this.steps.length !== path.steps.length) { - return false; - } + public reverse(): void { + this.steps.reverse(); + } - return this.steps.every((step, stepIndex) => { - const otherStep = path.steps[stepIndex]; + public getStartLocationId(): string { + return (" " + this.steps[0].startLocation.id).slice(1); + } - return Step.compareEquals(step, otherStep); - }); + public addTime(duration: DurationMs): void { + this.steps = this.steps.map((step: IStep) => ({ + ...step, + startTime: new Date(step.startTime.getTime() + duration), + stopTime: new Date(step.stopTime.getTime() + duration), + })); } } diff --git a/src/planner/PlannerPhase.ts b/src/planner/PlannerPhase.ts deleted file mode 100644 index f4b23ef5..00000000 --- a/src/planner/PlannerPhase.ts +++ /dev/null @@ -1,6 +0,0 @@ -import JourneyExtractionPhase from "./public-transport/JourneyExtractionPhase"; -import ReachableStopsSearchPhase from "./stops/ReachableStopsSearchPhase"; - -type PlannerPhase = JourneyExtractionPhase | ReachableStopsSearchPhase; - -export default PlannerPhase; diff --git a/src/planner/Step.ts b/src/planner/Step.ts index d9a9b770..bfd7af1b 100644 --- a/src/planner/Step.ts +++ b/src/planner/Step.ts @@ -1,10 +1,14 @@ +import TravelMode from "../enums/TravelMode"; import IConnection from "../fetcher/connections/IConnection"; import ILocation from "../interfaces/ILocation"; import IProbabilisticValue from "../interfaces/IProbabilisticValue"; import IStep from "../interfaces/IStep"; import { DistanceM, DurationMs } from "../interfaces/units"; -import TravelMode from "../TravelMode"; +/** + * This Step class serves as an implementation of the [[IStep]] interface and as a home for some helper functions + * related to [[IStep]] instances + */ export default class Step implements IStep { public static create( @@ -46,6 +50,10 @@ export default class Step implements IStep { ); } + /** + * Compare two [[IStep]] instances + * @returns true if the two steps are the same + */ public static compareEquals(step: IStep, otherStep: IStep): boolean { if (otherStep.travelMode !== step.travelMode) { return false; diff --git a/src/planner/public-transport/CSA/data-structure/stops/IProfileByStop.ts b/src/planner/public-transport/CSA/data-structure/stops/IProfileByStop.ts new file mode 100644 index 00000000..75da4cf8 --- /dev/null +++ b/src/planner/public-transport/CSA/data-structure/stops/IProfileByStop.ts @@ -0,0 +1,8 @@ +import ITransferProfile from "./ITransferProfile"; + +/** + * Stores multiple [[IProfile]]'s ordered by departure time for an [[IStop]]. + */ +export default interface IProfileByStop { + [stop: string]: ITransferProfile; +} diff --git a/src/planner/public-transport/CSA/data-structure/stops/ITransferProfile.ts b/src/planner/public-transport/CSA/data-structure/stops/ITransferProfile.ts index 9d8b65f9..06b021cf 100644 --- a/src/planner/public-transport/CSA/data-structure/stops/ITransferProfile.ts +++ b/src/planner/public-transport/CSA/data-structure/stops/ITransferProfile.ts @@ -1,4 +1,5 @@ import IConnection from "../../../../../fetcher/connections/IConnection"; +import Path from "../../../../Path"; /** * Interface for the CSA profile for a specific amount of transfers that can be made. @@ -11,6 +12,7 @@ import IConnection from "../../../../../fetcher/connections/IConnection"; export default interface ITransferProfile { departureTime: number; arrivalTime: number; - exitConnection: IConnection; - enterConnection: IConnection; + exitConnection?: IConnection; + enterConnection?: IConnection; + path?: Path; } diff --git a/src/planner/public-transport/CSA/data-structure/trips/IEarliestArrivalByTrip.ts b/src/planner/public-transport/CSA/data-structure/trips/IEarliestArrivalByTrip.ts index ac2c4bf7..31cd4627 100644 --- a/src/planner/public-transport/CSA/data-structure/trips/IEarliestArrivalByTrip.ts +++ b/src/planner/public-transport/CSA/data-structure/trips/IEarliestArrivalByTrip.ts @@ -1,8 +1,8 @@ -import IEarliestArrivalByTransfers from "./IEarliestArrivalByTransfers"; - /** * Stores for each gtfs:trip the earliest arrival [[IEarliestArrivalByTransfers]] to the target [[IStop]]. */ +import IEarliestArrivalByTransfers from "./IEarliestArrivalByTransfers"; + export default interface IEarliestArrivalByTrip { [trip: string]: IEarliestArrivalByTransfers; } diff --git a/src/planner/public-transport/CSA/data-structure/trips/IEnterConnectionByTrip.ts b/src/planner/public-transport/CSA/data-structure/trips/IEnterConnectionByTrip.ts new file mode 100644 index 00000000..78a3c402 --- /dev/null +++ b/src/planner/public-transport/CSA/data-structure/trips/IEnterConnectionByTrip.ts @@ -0,0 +1,10 @@ +import IConnection from "../../../../../fetcher/connections/IConnection"; + +/** + * @property arrivalTime Describes the earliest arrival time in milliseconds to the target [[IStop]]. + * @property connection Describes the [[IConnection]] that should be taken to arrive + * at the arrivalTime in the target location. + */ +export default interface IEnterConnectionByTrip { + [trip: string]: IConnection; +} diff --git a/src/planner/public-transport/CSA/util/ProfileUtil.ts b/src/planner/public-transport/CSA/util/ProfileUtil.ts index f8317df9..a8b38705 100644 --- a/src/planner/public-transport/CSA/util/ProfileUtil.ts +++ b/src/planner/public-transport/CSA/util/ProfileUtil.ts @@ -1,4 +1,4 @@ -import DropOffType from "../../../../fetcher/connections/DropOffType"; +import DropOffType from "../../../../enums/DropOffType"; import IConnection from "../../../../fetcher/connections/IConnection"; import { DurationMs } from "../../../../interfaces/units"; import IArrivalTimeByTransfers from "../data-structure/IArrivalTimeByTransfers"; @@ -17,6 +17,10 @@ export default class ProfileUtil { result[stop] = profilesByStop[stop].filter((profile) => profile.departureTime !== Infinity, ); + + if (result[stop].length === 0) { + delete result[stop]; + } } } return result; diff --git a/src/planner/public-transport/CSAEarliestArrival.test.ts b/src/planner/public-transport/CSAEarliestArrival.test.ts new file mode 100644 index 00000000..ac04174a --- /dev/null +++ b/src/planner/public-transport/CSAEarliestArrival.test.ts @@ -0,0 +1,282 @@ +import "jest"; +import LDFetch from "ldfetch"; +import Defaults from "../../Defaults"; +import TravelMode from "../../enums/TravelMode"; +import ConnectionsFetcherLazy from "../../fetcher/connections/lazy/ConnectionsFetcherLazy"; +import ConnectionsFetcherNMBSTest from "../../fetcher/connections/tests/ConnectionsFetcherNMBSTest"; +import connectionsIngelmunsterGhent from "../../fetcher/connections/tests/data/ingelmunster-ghent"; +import connectionsJoining from "../../fetcher/connections/tests/data/joining"; +import connectionsSplitting from "../../fetcher/connections/tests/data/splitting"; +import StopsFetcherLDFetch from "../../fetcher/stops/ld-fetch/StopsFetcherLDFetch"; +import IPath from "../../interfaces/IPath"; +import IQuery from "../../interfaces/IQuery"; +import IStep from "../../interfaces/IStep"; +import IResolvedQuery from "../../query-runner/IResolvedQuery"; +import LocationResolverDefault from "../../query-runner/LocationResolverDefault"; +import QueryRunnerDefault from "../../query-runner/QueryRunnerDefault"; +import Iterators from "../../util/Iterators"; +import ReachableStopsFinderBirdsEyeCached from "../stops/ReachableStopsFinderBirdsEyeCached"; +import CSAEarliestArrival from "./CSAEarliestArrival"; +import JourneyExtractorEarliestArrival from "./JourneyExtractorEarliestArrival"; + +describe("[PublicTransportPlannerCSAEarliestArrival]", () => { + describe("mock data", () => { + jest.setTimeout(100000); + + const createCSA = (connections) => { + const ldFetch = new LDFetch({ headers: { Accept: "application/ld+json" } }); + + const connectionFetcher = new ConnectionsFetcherNMBSTest(connections); + connectionFetcher.setIteratorOptions({ backward: false }); + + const stopsFetcher = new StopsFetcherLDFetch(ldFetch); + stopsFetcher.setAccessUrl("https://irail.be/stations/NMBS"); + + const locationResolver = new LocationResolverDefault(stopsFetcher); + const reachableStopsFinder = new ReachableStopsFinderBirdsEyeCached(stopsFetcher); + const journeyExtractor = new JourneyExtractorEarliestArrival( + locationResolver, + ); + + return new CSAEarliestArrival( + connectionFetcher, + locationResolver, + reachableStopsFinder, + reachableStopsFinder, + reachableStopsFinder, + journeyExtractor, + ); + }; + + describe("basic test", () => { + let result: IPath[]; + + const query: IResolvedQuery = { + publicTransportOnly: true, + from: [{latitude: 50.914326, longitude: 3.255415, id: "http://irail.be/stations/NMBS/008896925" }], + to: [{ latitude: 51.035896, longitude: 3.710875, id: "http://irail.be/stations/NMBS/008892007" }], + minimumDepartureTime: new Date("2018-11-06T09:00:00.000Z"), + maximumArrivalTime: new Date("2018-11-06T19:00:00.000Z"), + maximumTransfers: 8, + minimumWalkingSpeed: Defaults.defaultMinimumWalkingSpeed, + maximumWalkingSpeed: Defaults.defaultMaximumWalkingSpeed, + maximumTransferDuration: Defaults.defaultMaximumTransferDuration, + minimumTransferDuration: Defaults.defaultMinimumTransferDuration, + }; + + beforeAll(async () => { + const CSA = createCSA(connectionsIngelmunsterGhent); + const iterator = await CSA.plan(query); + result = await Iterators.toArray(iterator); + }); + + it("Correct departure and arrival stop", () => { + expect(result).toBeDefined(); + + for (const path of result) { + expect(path.steps).toBeDefined(); + expect(path.steps[0]).toBeDefined(); + expect(query.from.map((from) => from.id)).toContain(path.steps[0].startLocation.id); + expect(query.to.map((to) => to.id)).toContain(path.steps[path.steps.length - 1].stopLocation.id); + } + }); + }); + + describe("splitting", () => { + let result: IPath[]; + + const query: IResolvedQuery = { + publicTransportOnly: true, + from: [{ + id: "http://irail.be/stations/NMBS/008821006", + latitude: 51.2172, + longitude: 4.421101, + }], + to: [{ + id: "http://irail.be/stations/NMBS/008812005", + latitude: 50.859663, + longitude: 4.360846, + }], + minimumDepartureTime: new Date("2017-12-19T15:50:00.000Z"), + maximumArrivalTime: new Date("2017-12-19T16:50:00.000Z"), + maximumTransfers: 1, + minimumWalkingSpeed: Defaults.defaultMinimumWalkingSpeed, + maximumWalkingSpeed: Defaults.defaultMaximumWalkingSpeed, + maximumTransferDuration: Defaults.defaultMaximumTransferDuration, + minimumTransferDuration: Defaults.defaultMinimumTransferDuration, + }; + + beforeAll(async () => { + const CSA = createCSA(connectionsSplitting); + const iterator = await CSA.plan(query); + result = await Iterators.toArray(iterator); + }); + + it("Correct departure and arrival stop", () => { + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThanOrEqual(1); + + for (const path of result) { + expect(path.steps).toBeDefined(); + + expect(path.steps.length).toEqual(1); + expect(path.steps[0]).toBeDefined(); + + expect(query.from.map((from) => from.id)).toContain(path.steps[0].startLocation.id); + expect(query.to.map((to) => to.id)).toContain(path.steps[0].stopLocation.id); + } + }); + }); + + describe("joining", () => { + let result: IPath[]; + + const query: IResolvedQuery = { + publicTransportOnly: true, + from: [{ + id: "http://irail.be/stations/NMBS/008812005", + latitude: 50.859663, + longitude: 4.360846, + }], + to: [{ + id: "http://irail.be/stations/NMBS/008821006", + latitude: 51.2172, + longitude: 4.421101, + }], + minimumDepartureTime: new Date("2017-12-19T16:20:00.000Z"), + maximumArrivalTime: new Date("2017-12-19T16:50:00.000Z"), + maximumTransfers: 1, + minimumWalkingSpeed: Defaults.defaultMinimumWalkingSpeed, + maximumWalkingSpeed: Defaults.defaultMaximumWalkingSpeed, + maximumTransferDuration: Defaults.defaultMaximumTransferDuration, + minimumTransferDuration: Defaults.defaultMinimumTransferDuration, + }; + + beforeAll(async () => { + const CSA = createCSA(connectionsJoining); + const iterator = await CSA.plan(query); + result = await Iterators.toArray(iterator); + }); + + it("Correct departure and arrival stop", () => { + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThanOrEqual(1); + + for (const path of result) { + expect(path.steps).toBeDefined(); + + expect(path.steps.length).toEqual(1); + expect(path.steps[0]).toBeDefined(); + + expect(query.from.map((from) => from.id)).toContain(path.steps[0].startLocation.id); + expect(query.to.map((to) => to.id)).toContain(path.steps[0].stopLocation.id); + } + }); + }); + }); + + describe("real-time data", () => { + const createQueryRunner = () => { + const ldFetch = new LDFetch({ headers: { Accept: "application/ld+json" } }); + + const connectionFetcher = new ConnectionsFetcherLazy(ldFetch); + connectionFetcher.setTravelMode(TravelMode.Train); + connectionFetcher.setAccessUrl("https://graph.irail.be/sncb/connections"); + + const stopsFetcher = new StopsFetcherLDFetch(ldFetch); + stopsFetcher.setAccessUrl("https://irail.be/stations/NMBS"); + + const locationResolver = new LocationResolverDefault(stopsFetcher); + const reachableStopsFinder = new ReachableStopsFinderBirdsEyeCached(stopsFetcher); + const journeyExtractor = new JourneyExtractorEarliestArrival( + locationResolver, + ); + + const CSA = new CSAEarliestArrival( + connectionFetcher, + locationResolver, + reachableStopsFinder, + reachableStopsFinder, + reachableStopsFinder, + journeyExtractor, + ); + + return new QueryRunnerDefault(locationResolver, CSA); + }; + + const checkStops = (result, query) => { + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThanOrEqual(1); + + for (const path of result) { + expect(path.steps).toBeDefined(); + + expect(path.steps.length).toBeGreaterThanOrEqual(1); + + let currentLocation = query.from; + path.steps.forEach((step: IStep) => { + expect(step).toBeDefined(); + expect(currentLocation).toEqual(step.startLocation.id); + currentLocation = step.stopLocation.id; + }); + + expect(query.to).toEqual(currentLocation); + } + }; + + const checkTimes = (result, minimumDepartureTime) => { + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThanOrEqual(1); + + for (const path of result) { + expect(path.steps).toBeDefined(); + + expect(path.steps.length).toBeGreaterThanOrEqual(1); + expect(path.steps[0]).toBeDefined(); + expect(path.steps[path.steps.length - 1]).toBeDefined(); + + let currentTime = minimumDepartureTime.getTime(); + path.steps.forEach((step: IStep) => { + expect(step).toBeDefined(); + if (step.travelMode === TravelMode.Walking) { + currentTime += step.duration.minimum; + } else { + expect(currentTime).toBeLessThanOrEqual(step.startTime.getTime()); + currentTime = step.stopTime.getTime(); + } + }); + + expect(path.steps[0].startTime.getTime()).toBeGreaterThanOrEqual(minimumDepartureTime.getTime()); + } + }; + + describe("Departure Time now - Arrival Time now+2h", () => { + jest.setTimeout(100000); + + const minimumDepartureTime = new Date(); + + const query: IQuery = { + publicTransportOnly: true, + from: "http://irail.be/stations/NMBS/008896925", // Ingelmunster + to: "http://irail.be/stations/NMBS/008892007", // Ghent-Sint-Pieters + minimumDepartureTime, + }; + let result: IPath[]; + + beforeAll(async () => { + const queryRunner = createQueryRunner(); + const iterator = await queryRunner.run(query); + + result = await Iterators.toArray(iterator); + }); + + it("Correct departure and arrival stop", () => { + checkStops(result, query); + }); + + it("Correct departure and arrival time", () => { + checkTimes(result, minimumDepartureTime); + }); + }); + }); +}); diff --git a/src/planner/public-transport/CSAEarliestArrival.ts b/src/planner/public-transport/CSAEarliestArrival.ts new file mode 100644 index 00000000..ff997909 --- /dev/null +++ b/src/planner/public-transport/CSAEarliestArrival.ts @@ -0,0 +1,514 @@ +import { ArrayIterator, AsyncIterator } from "asynciterator"; +import { inject, injectable, tagged } from "inversify"; +import Context from "../../Context"; +import DropOffType from "../../enums/DropOffType"; +import EventType from "../../enums/EventType"; +import PickupType from "../../enums/PickupType"; +import ReachableStopsFinderMode from "../../enums/ReachableStopsFinderMode"; +import ReachableStopsSearchPhase from "../../enums/ReachableStopsSearchPhase"; +import TravelMode from "../../enums/TravelMode"; +import IConnection from "../../fetcher/connections/IConnection"; +import IConnectionsProvider from "../../fetcher/connections/IConnectionsProvider"; +import IStop from "../../fetcher/stops/IStop"; +import ILocation from "../../interfaces/ILocation"; +import IPath from "../../interfaces/IPath"; +import IStep from "../../interfaces/IStep"; +import ILocationResolver from "../../query-runner/ILocationResolver"; +import IResolvedQuery from "../../query-runner/IResolvedQuery"; +import TYPES from "../../types"; +import Geo from "../../util/Geo"; +import Path from "../Path"; +import Step from "../Step"; +import IReachableStopsFinder, { IReachableStop } from "../stops/IReachableStopsFinder"; +import IProfileByStop from "./CSA/data-structure/stops/IProfileByStop"; +import ITransferProfile from "./CSA/data-structure/stops/ITransferProfile"; +import IEnterConnectionByTrip from "./CSA/data-structure/trips/IEnterConnectionByTrip"; +import IJourneyExtractor from "./IJourneyExtractor"; +import IPublicTransportPlanner from "./IPublicTransportPlanner"; + +/** + * An implementation of the Connection Scan Algorithm (CSA). + * + * @implements [[IPublicTransportPlanner]] + * @property profilesByStop Describes the CSA profiles for each scanned stop. + * @property enterConnectionByTrip Describes the connection you should enter at a departure location for each trip. + * + * @returns multiple [[IPath]]s that consist of several [[IStep]]s. + */ +@injectable() +export default class CSAEarliestArrival implements IPublicTransportPlanner { + private readonly connectionsProvider: IConnectionsProvider; + private readonly locationResolver: ILocationResolver; + private readonly initialReachableStopsFinder: IReachableStopsFinder; + private readonly finalReachableStopsFinder: IReachableStopsFinder; + private readonly transferReachableStopsFinder: IReachableStopsFinder; + private readonly journeyExtractor: IJourneyExtractor; + private readonly context: Context; + + private profilesByStop: IProfileByStop = {}; // S + private enterConnectionByTrip: IEnterConnectionByTrip = {}; // T + private gtfsTripsByConnection = {}; + + private initialReachableStops: IReachableStop[] = []; + private finalReachableStops: IReachableStop[] = []; + + private query: IResolvedQuery; + private connectionsIterator: AsyncIterator; + + constructor( + @inject(TYPES.ConnectionsProvider) + connectionsProvider: IConnectionsProvider, + @inject(TYPES.LocationResolver) + locationResolver: ILocationResolver, + @inject(TYPES.ReachableStopsFinder) + @tagged("phase", ReachableStopsSearchPhase.Initial) + initialReachableStopsFinder: IReachableStopsFinder, + @inject(TYPES.ReachableStopsFinder) + @tagged("phase", ReachableStopsSearchPhase.Transfer) + transferReachableStopsFinder: IReachableStopsFinder, + @inject(TYPES.ReachableStopsFinder) + @tagged("phase", ReachableStopsSearchPhase.Final) + finalReachableStopsFinder: IReachableStopsFinder, + @inject(TYPES.JourneyExtractor) + journeyExtractor: IJourneyExtractor, + @inject(TYPES.Context) + context?: Context, + ) { + this.connectionsProvider = connectionsProvider; + this.locationResolver = locationResolver; + this.initialReachableStopsFinder = initialReachableStopsFinder; + this.transferReachableStopsFinder = transferReachableStopsFinder; + this.finalReachableStopsFinder = finalReachableStopsFinder; + this.journeyExtractor = journeyExtractor; + this.context = context; + } + + public async plan(query: IResolvedQuery): Promise> { + this.query = query; + + this.setBounds(); + + return this.calculateJourneys(); + } + + private setBounds() { + const { + minimumDepartureTime: lowerBoundDate, + maximumArrivalTime: upperBoundDate, + } = this.query; + + this.connectionsProvider.setIteratorOptions({ + upperBoundDate, + lowerBoundDate, + }); + } + + private async calculateJourneys(): Promise> { + const hasInitialReachableStops: boolean = await this.initInitialReachableStops(); + const hasFinalReachableStops: boolean = await this.initFinalReachableStops(); + + if (!hasInitialReachableStops || !hasFinalReachableStops) { + return Promise.resolve(new ArrayIterator([])); + } + + this.connectionsIterator = this.connectionsProvider.createIterator(); + + const self = this; + + return new Promise((resolve, reject) => { + let isDone = false; + + const done = () => { + if (!isDone) { + self.connectionsIterator.close(); + + self.journeyExtractor.extractJourneys(self.profilesByStop, self.query) + .then((resultIterator) => { + resolve(resultIterator); + }); + + isDone = true; + } + }; + + this.connectionsIterator.on("readable", () => + self.processNextConnection(done), + ); + + this.connectionsIterator.on("end", () => done()); + + }) as Promise>; + } + + private async processNextConnection(done: () => void) { + let connection = this.connectionsIterator.read(); + + while (connection) { + this.discoverConnection(connection); + + const { + to, + minimumDepartureTime, + maximumWalkingDuration, + minimumTransferDuration, + maximumTransferDuration, + } = this.query; + + const arrivalStopId: string = to[0].id; + if (this.profilesByStop[arrivalStopId].arrivalTime <= connection.departureTime.getTime()) { + this.connectionsIterator.close(); + done(); + break; + } + + if (connection.departureTime < minimumDepartureTime && !this.connectionsIterator.closed) { + connection = this.connectionsIterator.read(); + continue; + } + + const tripIds = this.getTripIdsFromConnection(connection); + for (const tripId of tripIds) { + + const canRemainSeated = this.enterConnectionByTrip[tripId]; + + const initialStop = this.initialReachableStops.find(({ stop }) => + stop.id === connection.departureStop, + ); + + const departure = connection.departureTime.getTime(); + const arrival = this.profilesByStop[connection.departureStop].arrivalTime; + + const transferDuration = departure - arrival; + + const canTakeTransfer = ( + transferDuration > -Infinity && + (transferDuration >= minimumTransferDuration || initialStop && transferDuration >= 0) && + (transferDuration <= maximumTransferDuration || initialStop && transferDuration <= maximumWalkingDuration) && + connection["gtfs:pickupType"] !== PickupType.NotAvailable + ); + + if (canRemainSeated || canTakeTransfer) { + this.updateTrips(connection, tripId); + + if (connection["gtfs:dropOffType"] !== DropOffType.NotAvailable) { + await this.updateProfiles(connection, tripId); + } + } + + } + + if (!this.connectionsIterator.closed) { + connection = this.connectionsIterator.read(); + continue; + } + + connection = undefined; + } + } + + private async initInitialReachableStops(): Promise { + const fromLocation: IStop = this.query.from[0] as IStop; + + // Making sure the departure location has an id + const geoId = Geo.getId(this.query.from[0]); + if (!fromLocation.id) { + this.query.from[0].id = geoId; + this.query.from[0].name = "Departure location"; + } + + this.initialReachableStops = await this.initialReachableStopsFinder.findReachableStops( + fromLocation, + ReachableStopsFinderMode.Source, + this.query.maximumWalkingDuration, + this.query.minimumWalkingSpeed, + ); + + // Abort when we can't reach a single stop. + if (this.initialReachableStops.length <= 1 && this.initialReachableStops[0].stop.id === geoId && this.context) { + this.context.emit(EventType.AbortQuery, "No reachable stops at departure location"); + + return false; + } + + // Check if departure location is a stop. + for (const reachableStop of this.initialReachableStops) { + if (reachableStop.duration === 0) { + this.query.from[0] = reachableStop.stop; + } + } + + if (this.context) { + this.context.emit(EventType.InitialReachableStops, this.initialReachableStops); + } + + this.initialReachableStops.forEach(({ stop, duration }: IReachableStop) => { + const departureTime = this.query.minimumDepartureTime.getTime(); + const arrivalTime = this.query.minimumDepartureTime.getTime() + duration; + + this.profilesByStop[stop.id] = { + departureTime, + arrivalTime, + }; + + if (duration > 0) { + const path: Path = Path.create(); + + const footpath: IStep = Step.create( + this.query.from[0], + stop, + TravelMode.Walking, + { + minimum: arrivalTime - departureTime, + }, + new Date(departureTime), + new Date(arrivalTime), + ); + + path.addStep(footpath); + + this.profilesByStop[stop.id].path = path; + } + + }); + + return true; + } + + private async initFinalReachableStops(): Promise { + const arrivalStop: IStop = this.query.to[0] as IStop; + + const geoId = Geo.getId(this.query.to[0]); + if (!this.query.to[0].id) { + this.query.to[0].id = geoId; + this.query.to[0].name = "Arrival location"; + } + + this.finalReachableStops = await this.finalReachableStopsFinder + .findReachableStops( + arrivalStop, + ReachableStopsFinderMode.Target, + this.query.maximumWalkingDuration, + this.query.minimumWalkingSpeed, + ); + + if (this.finalReachableStops.length <= 1 && this.finalReachableStops[0].stop.id === geoId && this.context) { + this.context.emit(EventType.AbortQuery, "No reachable stops at arrival location"); + + return false; + } + + if (this.context) { + this.context.emit(EventType.FinalReachableStops, this.finalReachableStops); + } + + // Check if arrival location is a stop. + for (const reachableStop of this.finalReachableStops) { + if (reachableStop.duration === 0) { + this.query.to[0] = reachableStop.stop; + } + } + + this.profilesByStop[this.query.to[0].id] = { + departureTime: Infinity, + arrivalTime: Infinity, + }; + + return true; + } + + private discoverConnection(connection: IConnection) { + this.setTripIdsByConnectionId(connection); + + if (!this.profilesByStop[connection.departureStop]) { + this.profilesByStop[connection.departureStop] = { + departureTime: Infinity, + arrivalTime: Infinity, + }; + } + + if (!this.profilesByStop[connection.arrivalStop]) { + this.profilesByStop[connection.arrivalStop] = { + departureTime: Infinity, + arrivalTime: Infinity, + }; + } + } + + private getTripIdsFromConnection(connection: IConnection): string[] { + return this.gtfsTripsByConnection[connection.id]; + } + + private setTripIdsByConnectionId(connection: IConnection): void { + if (!this.gtfsTripsByConnection.hasOwnProperty(connection.id)) { + this.gtfsTripsByConnection[connection.id] = []; + } + + this.gtfsTripsByConnection[connection.id].push(connection["gtfs:trip"]); + + let nextConnectionIndex = 0; + while (connection.nextConnection && nextConnectionIndex < connection.nextConnection.length) { + const connectionId = connection.nextConnection[nextConnectionIndex]; + + if (!this.gtfsTripsByConnection.hasOwnProperty(connectionId)) { + this.gtfsTripsByConnection[connectionId] = []; + + } + + this.gtfsTripsByConnection[connectionId].push(connection["gtfs:trip"]); + nextConnectionIndex++; + } + + } + + private updateTrips(connection: IConnection, tripId: string): void { + const isInitialReachableStop = this.initialReachableStops.find(({ stop }: IReachableStop) => + stop.id === connection.departureStop, + ); + + if (!this.enterConnectionByTrip[tripId] || isInitialReachableStop) { + this.enterConnectionByTrip[tripId] = connection; + } + } + + private async updateProfiles(connection: IConnection, tripId: string): Promise { + try { + const arrivalStop: ILocation = await this.locationResolver.resolve(connection.arrivalStop); + const reachableStops: IReachableStop[] = await this.transferReachableStopsFinder.findReachableStops( + arrivalStop as IStop, + ReachableStopsFinderMode.Source, + this.query.maximumTransferDuration, + this.query.minimumWalkingSpeed, + ); + + reachableStops.forEach((reachableStop: IReachableStop) => { + const { stop, duration } = reachableStop; + + if (!this.profilesByStop[stop.id]) { + this.profilesByStop[stop.id] = { + departureTime: Infinity, + arrivalTime: Infinity, + }; + } + + if (stop.id === this.query.to[0].id) { + return; + } + + const reachableStopArrival = this.profilesByStop[stop.id].arrivalTime; + + const departureTime = connection.departureTime.getTime(); + const arrivalTime = connection.arrivalTime.getTime() + duration; + + if (reachableStopArrival > arrivalTime) { + const transferProfile: ITransferProfile = { + departureTime, + arrivalTime, + exitConnection: connection, + enterConnection: this.enterConnectionByTrip[tripId], + }; + + if (duration > 0) { + const path: Path = Path.create(); + + const footpath: IStep = Step.create( + arrivalStop, + stop, + TravelMode.Walking, + { + minimum: duration, + }, + new Date(arrivalTime - duration), + new Date(arrivalTime), + ); + + path.addStep(footpath); + + transferProfile.path = path; + } + + if (this.context && this.context.listenerCount(EventType.AddedNewTransferProfile) > 0) { + this.emitTransferProfile(transferProfile); + } + + this.profilesByStop[stop.id] = transferProfile; + } + }); + + this.checkIfArrivalStopIsReachable(connection, tripId, arrivalStop); + + } catch (e) { + if (this.context) { + this.context.emitWarning(e); + } + } + } + + private checkIfArrivalStopIsReachable(connection: IConnection, tripId: string, arrivalStop: ILocation): void { + const canReachArrivalStop = this.finalReachableStops.find((reachableStop: IReachableStop) => + reachableStop.stop.id === arrivalStop.id, + ); + + if (canReachArrivalStop) { + const finalLocationId = this.query.to[0].id; + + if (canReachArrivalStop.stop.id === finalLocationId) { + const departureTime = connection.departureTime.getTime(); + const arrivalTime = connection.arrivalTime.getTime(); + const reachableStopArrival = this.profilesByStop[connection.arrivalStop].arrivalTime; + + if (reachableStopArrival > arrivalTime) { + this.profilesByStop[finalLocationId] = { + departureTime, + arrivalTime, + exitConnection: connection, + enterConnection: this.enterConnectionByTrip[tripId], + }; + } + + } + + const finalProfile = this.profilesByStop[finalLocationId]; + + const departureTime = connection.arrivalTime.getTime(); + const arrivalTime = connection.arrivalTime.getTime() + canReachArrivalStop.duration; + + if ((!finalProfile || finalProfile.arrivalTime > arrivalTime) && canReachArrivalStop.duration > 0) { + const path: Path = Path.create(); + + const footpath: IStep = Step.create( + arrivalStop, + this.query.to[0], + TravelMode.Walking, + { + minimum: arrivalTime - departureTime, + }, + new Date(departureTime), + new Date(arrivalTime), + ); + + path.addStep(footpath); + + this.profilesByStop[finalLocationId] = { + departureTime, + arrivalTime, + path, + }; + } + } + } + + private async emitTransferProfile(transferProfile: ITransferProfile): Promise { + try { + const departureStop = await this.locationResolver.resolve(transferProfile.enterConnection.departureStop); + const arrivalStop = await this.locationResolver.resolve(transferProfile.exitConnection.arrivalStop); + + this.context.emit(EventType.AddedNewTransferProfile, { + departureStop, + arrivalStop, + }); + + } catch (e) { + this.context.emitWarning(e); + } + } +} diff --git a/src/planner/public-transport/PublicTransportPlannerCSAProfile.test.ts b/src/planner/public-transport/CSAProfile.test.ts similarity index 94% rename from src/planner/public-transport/PublicTransportPlannerCSAProfile.test.ts rename to src/planner/public-transport/CSAProfile.test.ts index 85927f8b..8bcba17e 100644 --- a/src/planner/public-transport/PublicTransportPlannerCSAProfile.test.ts +++ b/src/planner/public-transport/CSAProfile.test.ts @@ -1,7 +1,8 @@ import "jest"; import LDFetch from "ldfetch"; import Defaults from "../../Defaults"; -import ConnectionsFetcherLazy from "../../fetcher/connections/ld-fetch/ConnectionsFetcherLazy"; +import TravelMode from "../../enums/TravelMode"; +import ConnectionsFetcherLazy from "../../fetcher/connections/lazy/ConnectionsFetcherLazy"; import ConnectionsFetcherNMBSTest from "../../fetcher/connections/tests/ConnectionsFetcherNMBSTest"; import connectionsIngelmunsterGhent from "../../fetcher/connections/tests/data/ingelmunster-ghent"; import connectionsJoining from "../../fetcher/connections/tests/data/joining"; @@ -13,11 +14,10 @@ import IStep from "../../interfaces/IStep"; import IResolvedQuery from "../../query-runner/IResolvedQuery"; import LocationResolverDefault from "../../query-runner/LocationResolverDefault"; import QueryRunnerDefault from "../../query-runner/QueryRunnerDefault"; -import TravelMode from "../../TravelMode"; import Iterators from "../../util/Iterators"; import ReachableStopsFinderBirdsEyeCached from "../stops/ReachableStopsFinderBirdsEyeCached"; -import JourneyExtractorDefault from "./JourneyExtractorDefault"; -import PublicTransportPlannerCSAProfile from "./PublicTransportPlannerCSAProfile"; +import CSAProfile from "./CSAProfile"; +import JourneyExtractorProfile from "./JourneyExtractorProfile"; describe("[PublicTransportPlannerCSAProfile]", () => { describe("mock data", () => { @@ -27,18 +27,18 @@ describe("[PublicTransportPlannerCSAProfile]", () => { const ldFetch = new LDFetch({ headers: { Accept: "application/ld+json" } }); const connectionFetcher = new ConnectionsFetcherNMBSTest(connections); - connectionFetcher.setConfig({ backward: true }); + connectionFetcher.setIteratorOptions({ backward: true }); const stopsFetcher = new StopsFetcherLDFetch(ldFetch); stopsFetcher.setAccessUrl("https://irail.be/stations/NMBS"); const locationResolver = new LocationResolverDefault(stopsFetcher); const reachableStopsFinder = new ReachableStopsFinderBirdsEyeCached(stopsFetcher); - const journeyExtractor = new JourneyExtractorDefault( + const journeyExtractor = new JourneyExtractorProfile( locationResolver, ); - return new PublicTransportPlannerCSAProfile( + return new CSAProfile( connectionFetcher, locationResolver, reachableStopsFinder, @@ -185,11 +185,11 @@ describe("[PublicTransportPlannerCSAProfile]", () => { const locationResolver = new LocationResolverDefault(stopsFetcher); const reachableStopsFinder = new ReachableStopsFinderBirdsEyeCached(stopsFetcher); - const journeyExtractor = new JourneyExtractorDefault( + const journeyExtractor = new JourneyExtractorProfile( locationResolver, ); - const CSA = new PublicTransportPlannerCSAProfile( + const CSA = new CSAProfile( connectionFetcher, locationResolver, reachableStopsFinder, @@ -259,7 +259,7 @@ describe("[PublicTransportPlannerCSAProfile]", () => { const query: IQuery = { publicTransportOnly: true, from: "http://irail.be/stations/NMBS/008896925", // Ingelmunster - to: "http://irail.be/stations/NMBS/008892007", // Antwerpen + to: "http://irail.be/stations/NMBS/008892007", // Ghent-Sint-Pieters maximumArrivalTime, minimumDepartureTime, }; @@ -291,7 +291,7 @@ describe("[PublicTransportPlannerCSAProfile]", () => { const query: IQuery = { publicTransportOnly: true, from: "http://irail.be/stations/NMBS/008896925", // Ingelmunster - to: "http://irail.be/stations/NMBS/008892007", // Antwerpen + to: "http://irail.be/stations/NMBS/008892007", // Ghent-Sint-Pieters maximumArrivalTime, minimumDepartureTime, }; diff --git a/src/planner/public-transport/PublicTransportPlannerCSAProfile.ts b/src/planner/public-transport/CSAProfile.ts similarity index 80% rename from src/planner/public-transport/PublicTransportPlannerCSAProfile.ts rename to src/planner/public-transport/CSAProfile.ts index e8c7db71..25eb5d64 100644 --- a/src/planner/public-transport/PublicTransportPlannerCSAProfile.ts +++ b/src/planner/public-transport/CSAProfile.ts @@ -1,11 +1,13 @@ -import { AsyncIterator } from "asynciterator"; +import { ArrayIterator, AsyncIterator } from "asynciterator"; import { inject, injectable, tagged } from "inversify"; import Context from "../../Context"; -import EventType from "../../EventType"; -import DropOffType from "../../fetcher/connections/DropOffType"; +import DropOffType from "../../enums/DropOffType"; +import EventType from "../../enums/EventType"; +import PickupType from "../../enums/PickupType"; +import ReachableStopsFinderMode from "../../enums/ReachableStopsFinderMode"; +import ReachableStopsSearchPhase from "../../enums/ReachableStopsSearchPhase"; import IConnection from "../../fetcher/connections/IConnection"; import IConnectionsProvider from "../../fetcher/connections/IConnectionsProvider"; -import PickupType from "../../fetcher/connections/PickupType"; import IStop from "../../fetcher/stops/IStop"; import ILocation from "../../interfaces/ILocation"; import IPath from "../../interfaces/IPath"; @@ -13,10 +15,9 @@ import { DurationMs } from "../../interfaces/units"; import ILocationResolver from "../../query-runner/ILocationResolver"; import IResolvedQuery from "../../query-runner/IResolvedQuery"; import TYPES from "../../types"; +import Geo from "../../util/Geo"; import Vectors from "../../util/Vectors"; import IReachableStopsFinder, { IReachableStop } from "../stops/IReachableStopsFinder"; -import ReachableStopsFinderMode from "../stops/ReachableStopsFinderMode"; -import ReachableStopsSearchPhase from "../stops/ReachableStopsSearchPhase"; import IArrivalTimeByTransfers from "./CSA/data-structure/IArrivalTimeByTransfers"; import IProfilesByStop from "./CSA/data-structure/stops/IProfilesByStop"; import ITransferProfile from "./CSA/data-structure/stops/ITransferProfile"; @@ -39,7 +40,7 @@ import IPublicTransportPlanner from "./IPublicTransportPlanner"; * @returns multiple [[IPath]]s that consist of several [[IStep]]s. */ @injectable() -export default class PublicTransportPlannerCSAProfile implements IPublicTransportPlanner { +export default class CSAProfile implements IPublicTransportPlanner { private readonly connectionsProvider: IConnectionsProvider; private readonly locationResolver: ILocationResolver; private readonly initialReachableStopsFinder: IReachableStopsFinder; @@ -88,18 +89,6 @@ export default class PublicTransportPlannerCSAProfile implements IPublicTranspor public async plan(query: IResolvedQuery): Promise> { this.query = query; - const departureLocation = this.query.from[0]; - if (!departureLocation.id) { - departureLocation.id = "geo:" + departureLocation.latitude + "," + departureLocation.longitude; - departureLocation.name = "Departure location"; - } - - const arrivalLocation = this.query.to[0]; - if (!arrivalLocation.id) { - arrivalLocation.id = "geo:" + arrivalLocation.latitude + "," + arrivalLocation.longitude; - arrivalLocation.name = "Arrival location"; - } - this.setBounds(); return this.calculateJourneys(); @@ -111,7 +100,7 @@ export default class PublicTransportPlannerCSAProfile implements IPublicTranspor maximumArrivalTime: upperBoundDate, } = this.query; - this.connectionsProvider.setConfig({ + this.connectionsProvider.setIteratorOptions({ backward: true, upperBoundDate, lowerBoundDate, @@ -119,8 +108,12 @@ export default class PublicTransportPlannerCSAProfile implements IPublicTranspor } private async calculateJourneys(): Promise> { - await this.initDurationToTargetByStop(); - await this.initInitialReachableStops(); + const hasInitialReachableStops: boolean = await this.initDurationToTargetByStop(); + const hasFinalReachableStops: boolean = await this.initInitialReachableStops(); + + if (!hasInitialReachableStops || !hasFinalReachableStops) { + return Promise.resolve(new ArrayIterator([])); + } this.connectionsIterator = this.connectionsProvider.createIterator(); @@ -128,6 +121,7 @@ export default class PublicTransportPlannerCSAProfile implements IPublicTranspor return new Promise((resolve, reject) => { let isDone = false; + const done = () => { if (!isDone) { self.connectionsIterator.close(); @@ -136,6 +130,7 @@ export default class PublicTransportPlannerCSAProfile implements IPublicTranspor .then((resultIterator) => { resolve(resultIterator); }); + isDone = true; } }; @@ -149,19 +144,19 @@ export default class PublicTransportPlannerCSAProfile implements IPublicTranspor }) as Promise>; } - private processNextConnection(done: () => void) { - const connection = this.connectionsIterator.read(); + private async processNextConnection(done: () => void) { + let connection = this.connectionsIterator.read(); - if (connection) { - if (connection.arrivalTime > this.query.maximumArrivalTime) { - this.maybeProcessNextConnection(done); - return; + while (connection) { + if (connection.arrivalTime > this.query.maximumArrivalTime && !this.connectionsIterator.closed) { + connection = this.connectionsIterator.read(); + continue; } if (connection.departureTime < this.query.minimumDepartureTime) { this.connectionsIterator.close(); done(); - return; + break; } if (this.context) { @@ -174,18 +169,15 @@ export default class PublicTransportPlannerCSAProfile implements IPublicTranspor this.updateEarliestArrivalByTrip(connection, earliestArrivalTime); if (!this.isDominated(connection, earliestArrivalTime)) { - this.getFootpathsForDepartureStop(connection, earliestArrivalTime) - .then(() => this.maybeProcessNextConnection(done)); + await this.getFootpathsForDepartureStop(connection, earliestArrivalTime); + } - } else { - this.maybeProcessNextConnection(done); + if (!this.connectionsIterator.closed) { + connection = this.connectionsIterator.read(); + continue; } - } - } - private maybeProcessNextConnection(done: () => void) { - if (!this.connectionsIterator.closed) { - this.processNextConnection(done); + connection = undefined; } } @@ -224,7 +216,7 @@ export default class PublicTransportPlannerCSAProfile implements IPublicTranspor private calculateEarliestArrivalTime(connection: IConnection): IArrivalTimeByTransfers { const remainSeatedTime = this.remainSeated(connection); - if (connection["gtfs:dropOffType"] === "gtfs:NotAvailable") { + if (connection["gtfs:dropOffType"] === DropOffType.NotAvailable) { return remainSeatedTime; } @@ -237,6 +229,12 @@ export default class PublicTransportPlannerCSAProfile implements IPublicTranspor private async initDurationToTargetByStop(): Promise { const arrivalStop: IStop = this.query.to[0] as IStop; + const geoId = Geo.getId(this.query.to[0]); + if (!this.query.to[0].id) { + this.query.to[0].id = geoId; + this.query.to[0].name = "Arrival location"; + } + const reachableStops = await this.finalReachableStopsFinder .findReachableStops( arrivalStop, @@ -245,8 +243,10 @@ export default class PublicTransportPlannerCSAProfile implements IPublicTranspor this.query.minimumWalkingSpeed, ); - if (reachableStops.length === 0 && this.context) { - this.context.emit(EventType.QueryAbort); + if (reachableStops.length <= 1 && reachableStops[0].stop.id === geoId) { + if (this.context) { + this.context.emit(EventType.AbortQuery, "No reachable stops at arrival location"); + } return false; } @@ -256,6 +256,10 @@ export default class PublicTransportPlannerCSAProfile implements IPublicTranspor } for (const reachableStop of reachableStops) { + if (reachableStop.duration === 0) { + this.query.to[0] = reachableStop.stop; + } + this.durationToTargetByStop[reachableStop.stop.id] = reachableStop.duration; } @@ -265,6 +269,12 @@ export default class PublicTransportPlannerCSAProfile implements IPublicTranspor private async initInitialReachableStops(): Promise { const fromLocation: IStop = this.query.from[0] as IStop; + const geoId = Geo.getId(this.query.from[0]); + if (!this.query.from[0].id) { + this.query.from[0].id = geoId; + this.query.from[0].name = "Departure location"; + } + this.initialReachableStops = await this.initialReachableStopsFinder.findReachableStops( fromLocation, ReachableStopsFinderMode.Source, @@ -272,8 +282,16 @@ export default class PublicTransportPlannerCSAProfile implements IPublicTranspor this.query.minimumWalkingSpeed, ); - if (this.initialReachableStops.length === 0 && this.context) { - this.context.emit(EventType.QueryAbort); + for (const reachableStop of this.initialReachableStops) { + if (reachableStop.duration === 0) { + this.query.from[0] = reachableStop.stop; + } + } + + if (this.initialReachableStops.length <= 1 && this.initialReachableStops[0].stop.id === geoId) { + if (this.context) { + this.context.emit(EventType.AbortQuery, "No reachable stops at departure location"); + } return false; } @@ -402,35 +420,47 @@ export default class PublicTransportPlannerCSAProfile implements IPublicTranspor ); } - const departureStop: ILocation = await this.locationResolver.resolve(connection.departureStop); - const reachableStops: IReachableStop[] = await this.transferReachableStopsFinder.findReachableStops( - departureStop as IStop, - ReachableStopsFinderMode.Source, - this.query.maximumWalkingDuration, - this.query.minimumWalkingSpeed, - ); + try { + const departureStop: ILocation = await this.locationResolver.resolve(connection.departureStop); + const reachableStops: IReachableStop[] = await this.transferReachableStopsFinder.findReachableStops( + departureStop as IStop, + ReachableStopsFinderMode.Source, + this.query.maximumTransferDuration, + this.query.minimumWalkingSpeed, + ); - reachableStops.forEach((reachableStop: IReachableStop) => { - if (reachableStop.stop.id !== departureLocation.id) { - this.incorporateInProfile( - connection, - reachableStop.duration, - reachableStop.stop, - earliestArrivalTimeByTransfers, - ); + reachableStops.forEach((reachableStop: IReachableStop) => { + if (reachableStop.stop.id !== departureLocation.id) { + this.incorporateInProfile( + connection, + reachableStop.duration, + reachableStop.stop, + earliestArrivalTimeByTransfers, + ); + } + }); + + } catch (e) { + if (this.context) { + this.context.emitWarning(e); } - }); + } } private async emitTransferProfile(transferProfile: ITransferProfile, amountOfTransfers: number): Promise { - const departureStop = await this.locationResolver.resolve(transferProfile.enterConnection.departureStop); - const arrivalStop = await this.locationResolver.resolve(transferProfile.exitConnection.arrivalStop); + try { + const departureStop = await this.locationResolver.resolve(transferProfile.enterConnection.departureStop); + const arrivalStop = await this.locationResolver.resolve(transferProfile.exitConnection.arrivalStop); - this.context.emit(EventType.AddedNewTransferProfile, { - departureStop, - arrivalStop, - amountOfTransfers, - }); + this.context.emit(EventType.AddedNewTransferProfile, { + departureStop, + arrivalStop, + amountOfTransfers, + }); + + } catch (e) { + this.context.emitWarning(e); + } } private incorporateInProfile( @@ -472,7 +502,11 @@ export default class PublicTransportPlannerCSAProfile implements IPublicTranspor const possibleExitConnection = this.earliestArrivalByTrip[connection["gtfs:trip"]] [amountOfTransfers].connection || connection; + const isValidRoute = !this.query.maximumTravelDuration || + (arrivalTimeByTransfers[amountOfTransfers].arrivalTime - departureTime <= this.query.maximumTravelDuration); + if ( + isValidRoute && arrivalTimeByTransfers[amountOfTransfers].arrivalTime < transferProfile.arrivalTime && connection["gtfs:pickupType"] !== PickupType.NotAvailable && possibleExitConnection["gtfs:dropOfType"] !== DropOffType.NotAvailable diff --git a/src/planner/public-transport/IJourneyExtractor.ts b/src/planner/public-transport/IJourneyExtractor.ts index 5febd168..67a50362 100644 --- a/src/planner/public-transport/IJourneyExtractor.ts +++ b/src/planner/public-transport/IJourneyExtractor.ts @@ -1,8 +1,12 @@ import { AsyncIterator } from "asynciterator"; import IPath from "../../interfaces/IPath"; import IResolvedQuery from "../../query-runner/IResolvedQuery"; +import IProfileByStop from "./CSA/data-structure/stops/IProfileByStop"; import IProfilesByStop from "./CSA/data-structure/stops/IProfilesByStop"; export default interface IJourneyExtractor { - extractJourneys: (profilesByStop: IProfilesByStop, query: IResolvedQuery) => Promise>; + extractJourneys: ( + profilesByStop: IProfilesByStop | IProfileByStop, + query: IResolvedQuery, + ) => Promise>; } diff --git a/src/planner/public-transport/JourneyExtractionPhase.ts b/src/planner/public-transport/JourneyExtractionPhase.ts deleted file mode 100644 index 603882da..00000000 --- a/src/planner/public-transport/JourneyExtractionPhase.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Signifies different phases of the journey extraction algorithm - * Dependencies of [[IJourneyExtractor]] implementations can be tagged with one of these phases, - * so the algorithm knows which dependency to use at which time. - * This is especially useful when the algorithm could potentially use different implementations of - * the same interface at each phase - */ -enum JourneyExtractionPhase { - Initial = "journeyExtractionInitial", - Transfer = "journeyExtractionTransfer", - Final = "journeyExtractionFinal", -} - -export default JourneyExtractionPhase; diff --git a/src/planner/public-transport/JourneyExtractorEarliestArrival.ts b/src/planner/public-transport/JourneyExtractorEarliestArrival.ts new file mode 100644 index 00000000..30f9bc89 --- /dev/null +++ b/src/planner/public-transport/JourneyExtractorEarliestArrival.ts @@ -0,0 +1,92 @@ +import { AsyncIterator, EmptyIterator, SingletonIterator } from "asynciterator"; +import { inject, injectable } from "inversify"; +import Context from "../../Context"; +import ILocation from "../../interfaces/ILocation"; +import IPath from "../../interfaces/IPath"; +import IStep from "../../interfaces/IStep"; +import ILocationResolver from "../../query-runner/ILocationResolver"; +import IResolvedQuery from "../../query-runner/IResolvedQuery"; +import TYPES from "../../types"; +import Path from "../Path"; +import Step from "../Step"; +import IProfileByStop from "./CSA/data-structure/stops/IProfileByStop"; +import ITransferProfile from "./CSA/data-structure/stops/ITransferProfile"; +import IJourneyExtractor from "./IJourneyExtractor"; + +/** + * Creates journeys based on the profiles and query from [[PublicTransportPlannerCSAProfile]]. + * A journey is an [[IPath]] that consist of several [[IStep]]s. + * The [[JourneyExtractor]] takes care of initial, intermediate and final footpaths. + * + * @property bestArrivalTime Stores the best arrival time for each pair of departure-arrival stops. + */ +@injectable() +export default class JourneyExtractorEarliestArrival implements IJourneyExtractor { + private readonly locationResolver: ILocationResolver; + + private context: Context; + + constructor( + @inject(TYPES.LocationResolver) locationResolver: ILocationResolver, + @inject(TYPES.Context)context?: Context, + ) { + this.locationResolver = locationResolver; + this.context = context; + } + + public async extractJourneys( + profilesByStop: IProfileByStop, + query: IResolvedQuery, + ): Promise> { + const path: Path = Path.create(); + + const departureStopId: string = query.from[0].id; + + let currentStopId: string = query.to[0].id; + let currentProfile: ITransferProfile = profilesByStop[currentStopId]; + + while (currentStopId !== departureStopId && (currentProfile.enterConnection || currentProfile.path)) { + const { enterConnection, exitConnection, path: profilePath } = currentProfile; + + if (profilePath) { + currentStopId = profilePath.getStartLocationId(); + + if (currentStopId === departureStopId) { + const lastStep = path.steps[path.steps.length - 1]; + const timeToAdd = lastStep.startTime.getTime() - profilePath.steps[0].stopTime.getTime(); + + profilePath.addTime(timeToAdd); + } + + path.addPath(profilePath); + } + + if (currentProfile.enterConnection && currentProfile.exitConnection) { + const enterLocation: ILocation = await this.locationResolver.resolve(enterConnection.departureStop); + const exitLocation: ILocation = await this.locationResolver.resolve(exitConnection.arrivalStop); + + const step: IStep = Step.createFromConnections( + enterConnection, + exitConnection, + ); + + step.startLocation = enterLocation; + step.stopLocation = exitLocation; + path.addStep(step); + + currentStopId = enterConnection.departureStop; + } + + currentProfile = profilesByStop[currentStopId]; + } + + if (!path.steps.length) { + return new EmptyIterator(); + } + + path.reverse(); + + return new SingletonIterator(path); + } + +} diff --git a/src/planner/public-transport/JourneyExtractorDefault.ts b/src/planner/public-transport/JourneyExtractorProfile.ts similarity index 81% rename from src/planner/public-transport/JourneyExtractorDefault.ts rename to src/planner/public-transport/JourneyExtractorProfile.ts index e987b17a..37eebe48 100644 --- a/src/planner/public-transport/JourneyExtractorDefault.ts +++ b/src/planner/public-transport/JourneyExtractorProfile.ts @@ -1,12 +1,13 @@ import { ArrayIterator, AsyncIterator } from "asynciterator"; import { inject, injectable } from "inversify"; +import Context from "../../Context"; +import TravelMode from "../../enums/TravelMode"; import IConnection from "../../fetcher/connections/IConnection"; import ILocation from "../../interfaces/ILocation"; import IPath from "../../interfaces/IPath"; import IStep from "../../interfaces/IStep"; import ILocationResolver from "../../query-runner/ILocationResolver"; import IResolvedQuery from "../../query-runner/IResolvedQuery"; -import TravelMode from "../../TravelMode"; import TYPES from "../../types"; import Path from "../Path"; import Step from "../Step"; @@ -24,13 +25,18 @@ import IJourneyExtractor from "./IJourneyExtractor"; * @property bestArrivalTime Stores the best arrival time for each pair of departure-arrival stops. */ @injectable() -export default class JourneyExtractorDefault implements IJourneyExtractor { +export default class JourneyExtractorProfile implements IJourneyExtractor { private readonly locationResolver: ILocationResolver; private bestArrivalTime: number[][] = []; + private context: Context; - constructor(@inject(TYPES.LocationResolver) locationResolver: ILocationResolver) { + constructor( + @inject(TYPES.LocationResolver) locationResolver: ILocationResolver, + @inject(TYPES.Context)context?: Context, + ) { this.locationResolver = locationResolver; + this.context = context; } public async extractJourneys( @@ -71,13 +77,14 @@ export default class JourneyExtractorDefault implements IJourneyExtractor { arrivalLocation, transferProfile.arrivalTime, ); + } catch (e) { - console.warn(e); + this.context.emitWarning(e); } } } - } + return new ArrayIterator(paths.reverse()); } @@ -125,7 +132,7 @@ export default class JourneyExtractorDefault implements IJourneyExtractor { const path: Path = Path.create(); let currentTransferProfile: ITransferProfile = transferProfile; - let departureTime: Date = new Date(transferProfile.departureTime); + let departureTime: number = transferProfile.departureTime; let remainingTransfers: number = transfers; @@ -139,18 +146,24 @@ export default class JourneyExtractorDefault implements IJourneyExtractor { const exitLocation: ILocation = await this.locationResolver.resolve(exitConnection.arrivalStop); // Initial or transfer footpath. - const transferDepartureTime: Date = enterConnection.departureTime; + const transferDepartureTime: number = enterConnection.departureTime.getTime(); + + if (departureTime !== transferDepartureTime) { + + let timeToSubtract = 0; + if (path.steps.length > 0) { + timeToSubtract = departureTime - path.steps[path.steps.length - 1].stopTime.getTime(); + } - if (departureTime.getTime() !== transferDepartureTime.getTime()) { const footpath: IStep = Step.create( currentLocation, enterLocation, TravelMode.Walking, { - minimum: transferDepartureTime.getTime() - departureTime.getTime(), + minimum: transferDepartureTime - departureTime, }, - departureTime, - transferDepartureTime, + new Date(departureTime - timeToSubtract), + new Date(transferDepartureTime - timeToSubtract), ); path.addStep(footpath); @@ -173,17 +186,17 @@ export default class JourneyExtractorDefault implements IJourneyExtractor { // Get next profile based on the arrival time at the current location. if (remainingTransfers >= 0) { const currentProfiles: IProfile[] = profilesByStop[currentLocation.id]; - let i: number = currentProfiles.length - 1; + let profileIndex: number = currentProfiles.length - 1; - currentTransferProfile = currentProfiles[i].transferProfiles[remainingTransfers]; - departureTime = new Date(currentTransferProfile.departureTime); + currentTransferProfile = currentProfiles[profileIndex].transferProfiles[remainingTransfers]; + departureTime = currentTransferProfile.departureTime; - while (i >= 0 && departureTime < exitConnection.arrivalTime) { - currentTransferProfile = currentProfiles[--i].transferProfiles[remainingTransfers]; - departureTime = new Date(currentTransferProfile.departureTime); + while (profileIndex >= 0 && departureTime < exitConnection.arrivalTime.getTime()) { + currentTransferProfile = currentProfiles[--profileIndex].transferProfiles[remainingTransfers]; + departureTime = currentTransferProfile.departureTime; } - if (i === -1) { + if (profileIndex === -1) { // This should never happen. return Promise.reject("Can't find next connection"); } diff --git a/src/planner/road/RoadPlannerBirdsEye.ts b/src/planner/road/RoadPlannerBirdsEye.ts index 06cc4f09..fdf23eed 100644 --- a/src/planner/road/RoadPlannerBirdsEye.ts +++ b/src/planner/road/RoadPlannerBirdsEye.ts @@ -1,20 +1,27 @@ import { ArrayIterator, AsyncIterator } from "asynciterator"; import { injectable } from "inversify"; +import TravelMode from "../../enums/TravelMode"; import ILocation from "../../interfaces/ILocation"; import IPath from "../../interfaces/IPath"; import IProbabilisticValue from "../../interfaces/IProbabilisticValue"; -import { DurationMs, SpeedkmH } from "../../interfaces/units"; +import { DurationMs, SpeedKmH } from "../../interfaces/units"; import IResolvedQuery from "../../query-runner/IResolvedQuery"; -import TravelMode from "../../TravelMode"; import Geo from "../../util/Geo"; import Units from "../../util/Units"; +import Path from "../Path"; import IRoadPlanner from "./IRoadPlanner"; @injectable() export default class RoadPlannerBirdsEye implements IRoadPlanner { public async plan(query: IResolvedQuery): Promise> { - const { from: fromLocations, to: toLocations, minimumWalkingSpeed, maximumWalkingSpeed} = query; + const { + from: fromLocations, + to: toLocations, + minimumWalkingSpeed, + maximumWalkingSpeed, + maximumWalkingDuration, + } = query; const paths = []; @@ -22,7 +29,18 @@ export default class RoadPlannerBirdsEye implements IRoadPlanner { for (const from of fromLocations) { for (const to of toLocations) { - paths.push(this.getPathBetweenLocations(from, to, minimumWalkingSpeed, maximumWalkingSpeed)); + + const path = this.getPathBetweenLocations( + from, + to, + minimumWalkingSpeed, + maximumWalkingSpeed, + maximumWalkingDuration, + ); + + if (path) { + paths.push(path); + } } } } @@ -33,8 +51,9 @@ export default class RoadPlannerBirdsEye implements IRoadPlanner { private getPathBetweenLocations( from: ILocation, to: ILocation, - minWalkingSpeed: SpeedkmH, - maxWalkingSpeed: SpeedkmH, + minWalkingSpeed: SpeedKmH, + maxWalkingSpeed: SpeedKmH, + maxWalkingDuration: DurationMs, ): IPath { const distance = Geo.getDistanceBetweenLocations(from, to); @@ -47,14 +66,16 @@ export default class RoadPlannerBirdsEye implements IRoadPlanner { average: (minDuration + maxDuration) / 2, }; - return { - steps: [{ - startLocation: from, - stopLocation: to, - duration, - distance, - travelMode: TravelMode.Walking, - }], - }; + if (duration.maximum > maxWalkingDuration) { + return; + } + + return new Path([{ + startLocation: from, + stopLocation: to, + duration, + distance, + travelMode: TravelMode.Walking, + }]); } } diff --git a/src/planner/stops/IReachableStopsFinder.ts b/src/planner/stops/IReachableStopsFinder.ts index 5ce8deb7..99ed8105 100644 --- a/src/planner/stops/IReachableStopsFinder.ts +++ b/src/planner/stops/IReachableStopsFinder.ts @@ -1,16 +1,23 @@ +import ReachableStopsFinderMode from "../../enums/ReachableStopsFinderMode"; import IStop from "../../fetcher/stops/IStop"; -import { DurationMs, SpeedkmH } from "../../interfaces/units"; -import ReachableStopsFinderMode from "./ReachableStopsFinderMode"; +import { DurationMs, SpeedKmH } from "../../interfaces/units"; +/** + * A reachable stops finder searches for stops that are reachable "on foot" . This can mean e.g. a folding bike if + * that scenario is implemented by the registered [[IReachableStopsFinder]] + */ export default interface IReachableStopsFinder { findReachableStops: ( sourceOrTargetStop: IStop, mode: ReachableStopsFinderMode, maximumDuration: DurationMs, - minimumSpeed: SpeedkmH, + minimumSpeed: SpeedKmH, ) => Promise; } +/** + * An [[IReachableStop]] wraps an [[IStop]] instance and the estimated duration to get to that stop + */ export interface IReachableStop { stop: IStop; duration: DurationMs; diff --git a/src/planner/stops/ReachableStopsFinderBirdsEye.test.ts b/src/planner/stops/ReachableStopsFinderBirdsEye.test.ts index 86523581..9abf9ea5 100644 --- a/src/planner/stops/ReachableStopsFinderBirdsEye.test.ts +++ b/src/planner/stops/ReachableStopsFinderBirdsEye.test.ts @@ -1,9 +1,9 @@ import "jest"; import LDFetch from "ldfetch"; +import ReachableStopsFinderMode from "../../enums/ReachableStopsFinderMode"; import StopsFetcherLDFetch from "../../fetcher/stops/ld-fetch/StopsFetcherLDFetch"; import Units from "../../util/Units"; import ReachableStopsFinderBirdsEye from "./ReachableStopsFinderBirdsEye"; -import ReachableStopsFinderMode from "./ReachableStopsFinderMode"; const DE_LIJN_STOPS_URLS = [ "http://openplanner.ilabt.imec.be/delijn/Antwerpen/stops", diff --git a/src/planner/stops/ReachableStopsFinderBirdsEye.ts b/src/planner/stops/ReachableStopsFinderBirdsEye.ts index defb77a9..f306d005 100644 --- a/src/planner/stops/ReachableStopsFinderBirdsEye.ts +++ b/src/planner/stops/ReachableStopsFinderBirdsEye.ts @@ -1,13 +1,17 @@ import { inject, injectable } from "inversify"; +import ReachableStopsFinderMode from "../../enums/ReachableStopsFinderMode"; import IStop from "../../fetcher/stops/IStop"; import IStopsProvider from "../../fetcher/stops/IStopsProvider"; -import { DurationMs, SpeedkmH } from "../../interfaces/units"; +import { DurationMs, SpeedKmH } from "../../interfaces/units"; import TYPES from "../../types"; import Geo from "../../util/Geo"; import Units from "../../util/Units"; import IReachableStopsFinder, { IReachableStop } from "./IReachableStopsFinder"; -import ReachableStopsFinderMode from "./ReachableStopsFinderMode"; +/** + * This [[IReachableStopsFinder]] determines its reachable stops based on the birds's-eye distance + * to the source or target stop. + */ @injectable() export default class ReachableStopsFinderBirdsEye implements IReachableStopsFinder { private readonly stopsProvider: IStopsProvider; @@ -22,24 +26,24 @@ export default class ReachableStopsFinderBirdsEye implements IReachableStopsFind sourceOrTargetStop: IStop, mode: ReachableStopsFinderMode, maximumDuration: DurationMs, - minimumSpeed: SpeedkmH, + minimumSpeed: SpeedKmH, ): Promise { // Mode can be ignored since birds eye view distance is identical - const allStops = await this.stopsProvider.getAllStops(); + const reachableStops: IReachableStop[] = [{stop: sourceOrTargetStop, duration: 0}]; - return allStops.map((possibleTarget: IStop): IReachableStop => { - if (possibleTarget.id === sourceOrTargetStop.id) { - return {stop: sourceOrTargetStop, duration: 0}; - } + const allStops = await this.stopsProvider.getAllStops(); + allStops.forEach((possibleTarget: IStop) => { const distance = Geo.getDistanceBetweenStops(sourceOrTargetStop, possibleTarget); const duration = Units.toDuration(distance, minimumSpeed); if (duration <= maximumDuration) { - return {stop: possibleTarget, duration}; + reachableStops.push({stop: possibleTarget, duration}); } - }).filter((reachableStop) => !!reachableStop); + }); + + return reachableStops; } } diff --git a/src/planner/stops/ReachableStopsFinderBirdsEyeCached.ts b/src/planner/stops/ReachableStopsFinderBirdsEyeCached.ts index cc290197..2f474856 100644 --- a/src/planner/stops/ReachableStopsFinderBirdsEyeCached.ts +++ b/src/planner/stops/ReachableStopsFinderBirdsEyeCached.ts @@ -1,12 +1,16 @@ import { inject, injectable } from "inversify"; +import ReachableStopsFinderMode from "../../enums/ReachableStopsFinderMode"; import IStop from "../../fetcher/stops/IStop"; import IStopsProvider from "../../fetcher/stops/IStopsProvider"; -import { DurationMs, SpeedkmH } from "../../interfaces/units"; +import { DurationMs, SpeedKmH } from "../../interfaces/units"; import TYPES from "../../types"; import IReachableStopsFinder, { IReachableStop } from "./IReachableStopsFinder"; import ReachableStopsFinderBirdsEye from "./ReachableStopsFinderBirdsEye"; -import ReachableStopsFinderMode from "./ReachableStopsFinderMode"; +/** + * This [[IReachableStopsFinder]] forms a caching layer in front of a [[ReachableStopsFinderBirdsEye]] instance, + * so all the distances don't have to be calculated repeatedly + */ @injectable() export default class ReachableStopsFinderBirdsEyeCached implements IReachableStopsFinder { private readonly reachableStopsFinder: ReachableStopsFinderBirdsEye; @@ -23,7 +27,7 @@ export default class ReachableStopsFinderBirdsEyeCached implements IReachableSto sourceOrTargetStop: IStop, mode: ReachableStopsFinderMode, maximumDuration: DurationMs, - minimumSpeed: SpeedkmH, + minimumSpeed: SpeedKmH, ): Promise { // Mode can be ignored since birds eye view distance is identical diff --git a/src/planner/stops/ReachableStopsFinderOnlySelf.ts b/src/planner/stops/ReachableStopsFinderOnlySelf.ts new file mode 100644 index 00000000..dd23ffa5 --- /dev/null +++ b/src/planner/stops/ReachableStopsFinderOnlySelf.ts @@ -0,0 +1,23 @@ +import { injectable } from "inversify"; +import ReachableStopsFinderMode from "../../enums/ReachableStopsFinderMode"; +import IStop from "../../fetcher/stops/IStop"; +import { DurationMs, SpeedKmH } from "../../interfaces/units"; +import IReachableStopsFinder, { IReachableStop } from "./IReachableStopsFinder"; + +/** + * This [[IReachableStopsFinder]] just returns the passed source or target stop. + * + * This can be a valid strategy to optimize speed if the user doesn't want to travel by foot to another stop + */ +@injectable() +export default class ReachableStopsFinderOnlySelf implements IReachableStopsFinder { + + public async findReachableStops( + sourceOrTargetStop: IStop, + mode: ReachableStopsFinderMode, + maximumDuration: DurationMs, + minimumSpeed: SpeedKmH, + ): Promise { + return [{ stop: sourceOrTargetStop, duration: 0 }]; + } +} diff --git a/src/planner/stops/ReachableStopsFinderRoadPlanner.test.ts b/src/planner/stops/ReachableStopsFinderRoadPlanner.test.ts index e567b4a9..5d83f7b2 100644 --- a/src/planner/stops/ReachableStopsFinderRoadPlanner.test.ts +++ b/src/planner/stops/ReachableStopsFinderRoadPlanner.test.ts @@ -1,9 +1,9 @@ import "jest"; import LDFetch from "ldfetch"; +import ReachableStopsFinderMode from "../../enums/ReachableStopsFinderMode"; import StopsFetcherLDFetch from "../../fetcher/stops/ld-fetch/StopsFetcherLDFetch"; import Units from "../../util/Units"; import RoadPlannerBirdsEye from "../road/RoadPlannerBirdsEye"; -import ReachableStopsFinderMode from "./ReachableStopsFinderMode"; import ReachableStopsFinderRoadPlanner from "./ReachableStopsFinderRoadPlanner"; const ldFetch = new LDFetch({ headers: { Accept: "application/ld+json" } }); @@ -20,7 +20,7 @@ test("[ReachableStopsFinderRoadPlanner] reachable stops", async () => { expect(sourceStop).toBeDefined(); - // Get reachable stops in 50 km (10h at 5km/h) + // Get reachable stops in 5 km (1h at 5km/h) const reachableStops = await reachableStopsFinder.findReachableStops( sourceStop, ReachableStopsFinderMode.Source, diff --git a/src/planner/stops/ReachableStopsFinderRoadPlanner.ts b/src/planner/stops/ReachableStopsFinderRoadPlanner.ts index 9c9206dc..c1327d83 100644 --- a/src/planner/stops/ReachableStopsFinderRoadPlanner.ts +++ b/src/planner/stops/ReachableStopsFinderRoadPlanner.ts @@ -1,17 +1,24 @@ import { AsyncIterator } from "asynciterator"; import { inject, injectable } from "inversify"; +import ReachableStopsFinderMode from "../../enums/ReachableStopsFinderMode"; import IStop from "../../fetcher/stops/IStop"; import IStopsProvider from "../../fetcher/stops/IStopsProvider"; import ILocation from "../../interfaces/ILocation"; import IPath from "../../interfaces/IPath"; -import { DurationMs, SpeedkmH } from "../../interfaces/units"; +import { DurationMs, SpeedKmH } from "../../interfaces/units"; import IResolvedQuery from "../../query-runner/IResolvedQuery"; import TYPES from "../../types"; +import Geo from "../../util/Geo"; import Iterators from "../../util/Iterators"; +import Units from "../../util/Units"; import IRoadPlanner from "../road/IRoadPlanner"; import IReachableStopsFinder, { IReachableStop } from "./IReachableStopsFinder"; -import ReachableStopsFinderMode from "./ReachableStopsFinderMode"; +/** + * This [[IReachableStopsFinder]] uses the registered [[IRoadPlanner]] to find reachable stops. + * It makes an initial selection of stops based on bird's-eye distance, after which a road planner query gets executed + * for each of these stops. + */ @injectable() export default class ReachableStopsFinderRoadPlanner implements IReachableStopsFinder { private readonly stopsProvider: IStopsProvider; @@ -29,7 +36,7 @@ export default class ReachableStopsFinderRoadPlanner implements IReachableStopsF sourceOrTargetStop: IStop, mode: ReachableStopsFinderMode, maximumDuration: DurationMs, - minimumSpeed: SpeedkmH, + minimumSpeed: SpeedKmH, ): Promise { const minimumDepartureTime = new Date(); @@ -45,10 +52,21 @@ export default class ReachableStopsFinderRoadPlanner implements IReachableStopsF minimumWalkingSpeed: minimumSpeed, }; - const allStops = await this.stopsProvider.getAllStops(); + const allStops: IStop[] = await this.stopsProvider.getAllStops(); + + const stopsInsideCircleArea: IStop[] = []; + for (const stop of allStops) { + const distance = Geo.getDistanceBetweenStops(sourceOrTargetStop, stop); + const duration = Units.toDuration(distance, minimumSpeed); + + if (duration <= maximumDuration) { + stopsInsideCircleArea.push(stop); + } + } + const reachableStops: IReachableStop[] = [{stop: sourceOrTargetStop, duration: 0}]; - await Promise.all(allStops.map(async (possibleTarget: IStop) => { + await Promise.all(stopsInsideCircleArea.map(async (possibleTarget: IStop) => { if (possibleTarget.id !== sourceOrTargetStop.id) { const query = Object.assign({}, baseQuery, { diff --git a/src/planner/stops/ReachableStopsFinderRoadPlannerCached.ts b/src/planner/stops/ReachableStopsFinderRoadPlannerCached.ts index 9a475f07..f373f29a 100644 --- a/src/planner/stops/ReachableStopsFinderRoadPlannerCached.ts +++ b/src/planner/stops/ReachableStopsFinderRoadPlannerCached.ts @@ -1,13 +1,17 @@ import { inject, injectable } from "inversify"; +import ReachableStopsFinderMode from "../../enums/ReachableStopsFinderMode"; import IStop from "../../fetcher/stops/IStop"; import IStopsProvider from "../../fetcher/stops/IStopsProvider"; -import { DurationMs, SpeedkmH } from "../../interfaces/units"; +import { DurationMs, SpeedKmH } from "../../interfaces/units"; import TYPES from "../../types"; import IRoadPlanner from "../road/IRoadPlanner"; import IReachableStopsFinder, { IReachableStop } from "./IReachableStopsFinder"; -import ReachableStopsFinderMode from "./ReachableStopsFinderMode"; import ReachableStopsFinderRoadPlanner from "./ReachableStopsFinderRoadPlanner"; +/** + * This [[IReachableStopsFinder]] forms a caching layer in front of a [[ReachableStopsFinderRoadPlanner]] instance, + * so all the queries don't have to be executed repeatedly + */ @injectable() export default class ReachableStopsFinderRoadPlannerCached implements IReachableStopsFinder { private readonly reachableStopsFinder: ReachableStopsFinderRoadPlanner; @@ -25,7 +29,7 @@ export default class ReachableStopsFinderRoadPlannerCached implements IReachable sourceOrTargetStop: IStop, mode: ReachableStopsFinderMode, maximumDuration: DurationMs, - minimumSpeed: SpeedkmH, + minimumSpeed: SpeedKmH, ): Promise { const cacheKey = `${sourceOrTargetStop.id} ${mode} ${maximumDuration} ${minimumSpeed}`; diff --git a/src/query-runner/ILocationResolver.ts b/src/query-runner/ILocationResolver.ts index a4f4f93c..1115a20e 100644 --- a/src/query-runner/ILocationResolver.ts +++ b/src/query-runner/ILocationResolver.ts @@ -1,6 +1,9 @@ import IStop from "../fetcher/stops/IStop"; import ILocation from "../interfaces/ILocation"; +/** + * A location resolver turns an [[ILocation]], [[IStop]] or a string into an [[ILocation]] + */ export default interface ILocationResolver { resolve: (location: ILocation | IStop | string) => Promise; } diff --git a/src/query-runner/IQueryRunner.ts b/src/query-runner/IQueryRunner.ts index 6103a303..c245f3ba 100644 --- a/src/query-runner/IQueryRunner.ts +++ b/src/query-runner/IQueryRunner.ts @@ -2,7 +2,10 @@ import { AsyncIterator } from "asynciterator"; import IPath from "../interfaces/IPath"; import IQuery from "../interfaces/IQuery"; +/** + * A query runner has a `run` method that turns an [[IQuery]] into an AsyncIterator of [[IPath]] instances. + * It does this by executing one or more algorithms on the query, depending on the implementation. + */ export default interface IQueryRunner { - run(query: IQuery): Promise>; } diff --git a/src/query-runner/IResolvedQuery.ts b/src/query-runner/IResolvedQuery.ts index 210fe29f..f8f625dd 100644 --- a/src/query-runner/IResolvedQuery.ts +++ b/src/query-runner/IResolvedQuery.ts @@ -1,6 +1,11 @@ import ILocation from "../interfaces/ILocation"; -import { DurationMs, SpeedkmH } from "../interfaces/units"; +import { DurationMs, SpeedKmH } from "../interfaces/units"; +/** + * A resolved query is the result of transforming an input [[IQuery]] by adding defaults for any missing parameters and + * by resolving the endpoints (`from` and `to`). Classes using this resolved query don't have to worry about any missing + * or unrealistic parameters + */ export default interface IResolvedQuery { from?: ILocation[]; to?: ILocation[]; @@ -8,10 +13,11 @@ export default interface IResolvedQuery { maximumArrivalTime?: Date; roadOnly?: boolean; publicTransportOnly?: boolean; - minimumWalkingSpeed?: SpeedkmH; - maximumWalkingSpeed?: SpeedkmH; + minimumWalkingSpeed?: SpeedKmH; + maximumWalkingSpeed?: SpeedKmH; maximumWalkingDuration?: DurationMs; minimumTransferDuration?: DurationMs; maximumTransferDuration?: DurationMs; maximumTransfers?: number; + maximumTravelDuration?: DurationMs; } diff --git a/src/query-runner/LocationResolverConvenience.test.ts b/src/query-runner/LocationResolverConvenience.test.ts new file mode 100644 index 00000000..3da63c76 --- /dev/null +++ b/src/query-runner/LocationResolverConvenience.test.ts @@ -0,0 +1,60 @@ +import "jest"; +import LDFetch from "ldfetch"; +import StopsFetcherLDFetch from "../fetcher/stops/ld-fetch/StopsFetcherLDFetch"; +import LocationResolverConvenience from "./LocationResolverConvenience"; + +const ldFetch = new LDFetch({ headers: { Accept: "application/ld+json" } }); + +const stopsFetcher = new StopsFetcherLDFetch(ldFetch); +stopsFetcher.setAccessUrl("https://irail.be/stations/NMBS"); + +const locationResolver = new LocationResolverConvenience(stopsFetcher); + +describe("[LocationResolverConvenience]", () => { + + it("Input 'Kortrijk' (exact stop name)", async () => { + + const location = await locationResolver.resolve("Kortrijk"); + + expect(location).toBeDefined(); + expect(location.latitude).toBeCloseTo(50.82, 2); + }); + + it("Input 'Narnia' (non-existent stop name)", async () => { + expect.assertions(1); + expect(locationResolver.resolve( + "Narnia", + )).rejects.toBeDefined(); + }); + + it("Input {id: 'http://...'}", async () => { + + const location = await locationResolver.resolve( + { id: "http://irail.be/stations/NMBS/008896008" }, + ); + + expect(location).toBeDefined(); + expect(location.latitude).toBeCloseTo(50.82, 2); + }); + + it("Input 'http://...'", async () => { + + const location = await locationResolver.resolve( + "http://irail.be/stations/NMBS/008896008", + ); + + expect(location).toBeDefined(); + expect(location.latitude).toBeCloseTo(50.82, 2); + }); + + it("Input {longitude: ..., latitude: ...}", async () => { + + const location = await locationResolver.resolve( + { latitude: 50.824506, longitude: 3.264549 }, + ); + + expect(location).toBeDefined(); + expect(location.latitude).toBeCloseTo(50.82, 2); + }); + +}); diff --git a/src/query-runner/LocationResolverConvenience.ts b/src/query-runner/LocationResolverConvenience.ts new file mode 100644 index 00000000..992bc859 --- /dev/null +++ b/src/query-runner/LocationResolverConvenience.ts @@ -0,0 +1,53 @@ +import { inject, injectable } from "inversify"; +import LocationResolverError from "../errors/LocationResolverError"; +import IStop from "../fetcher/stops/IStop"; +import IStopsProvider from "../fetcher/stops/IStopsProvider"; +import ILocation from "../interfaces/ILocation"; +import TYPES from "../types"; +import ILocationResolver from "./ILocationResolver"; +import LocationResolverDefault from "./LocationResolverDefault"; + +/** + * Location resolver that allows stop names as input + * Falls back to LocationResolverDefault + */ +@injectable() +export default class LocationResolverConvenience implements ILocationResolver { + private readonly stopsProvider: IStopsProvider; + private readonly defaultLocationResolver: ILocationResolver; + + private allStops: IStop[]; + + constructor( + @inject(TYPES.StopsProvider) stopsProvider: IStopsProvider, + ) { + this.stopsProvider = stopsProvider; + this.defaultLocationResolver = new LocationResolverDefault(this.stopsProvider); + } + + public async resolve(input: ILocation | IStop | string): Promise { + + if (typeof input === "string" && !this.isId(input)) { + + if (!this.allStops) { + this.allStops = await this.stopsProvider.getAllStops(); + } + + const matchingStop = this.allStops.find((stop: IStop) => stop.name === input); + + if (matchingStop) { + return matchingStop; + } + + return Promise.reject( + new LocationResolverError(`Location "${input}" is a string, but not an ID and not a valid stop name`), + ); + } + + return this.defaultLocationResolver.resolve(input); + } + + private isId(testString: string): boolean { + return testString.indexOf("http://") === 0 || testString.indexOf("https://") === 0; + } +} diff --git a/src/query-runner/LocationResolverDefault.ts b/src/query-runner/LocationResolverDefault.ts index 8283d19e..88513161 100644 --- a/src/query-runner/LocationResolverDefault.ts +++ b/src/query-runner/LocationResolverDefault.ts @@ -1,10 +1,18 @@ import { inject, injectable } from "inversify"; +import LocationResolverError from "../errors/LocationResolverError"; import IStop from "../fetcher/stops/IStop"; import IStopsProvider from "../fetcher/stops/IStopsProvider"; import ILocation from "../interfaces/ILocation"; import TYPES from "../types"; import ILocationResolver from "./ILocationResolver"; +/** + * This default location resolver resolves [[ILocation]] instances by their `id` (`http(s)://...`) + * + * If only an `id` string is passed, it returns an [[ILocation]] with all available information. + * + * If an incomplete [[ILocation]] (but with an `id`) is passed, it gets supplemented as well. + */ @injectable() export default class LocationResolverDefault implements ILocationResolver { private readonly stopsProvider: IStopsProvider; @@ -23,7 +31,7 @@ export default class LocationResolverDefault implements ILocationResolver { return this.resolveById(input); } - return Promise.reject(`Location "${input}" is a string, but not an ID`); + return Promise.reject(new LocationResolverError(`Location "${input}" is a string, but not an ID`)); } const location: ILocation = input; @@ -37,7 +45,9 @@ export default class LocationResolverDefault implements ILocationResolver { } if (!hasCoords) { - return Promise.reject(`Location "${JSON.stringify(input)}" should have latitude and longitude`); + return Promise.reject( + new LocationResolverError(`Location "${JSON.stringify(input)}" should have latitude and longitude`), + ); } return location; @@ -55,7 +65,7 @@ export default class LocationResolverDefault implements ILocationResolver { }; } - throw new Error(`No fetcher for id ${id}`); + return Promise.reject(new LocationResolverError(`No fetcher for id ${id}`)); } private isId(testString: string): boolean { diff --git a/src/query-runner/QueryRunnerDefault.ts b/src/query-runner/QueryRunnerDefault.ts index d78e60cf..829e47da 100644 --- a/src/query-runner/QueryRunnerDefault.ts +++ b/src/query-runner/QueryRunnerDefault.ts @@ -1,6 +1,8 @@ import { AsyncIterator } from "asynciterator"; import { inject, injectable } from "inversify"; +import { cat } from "shelljs"; import Defaults from "../Defaults"; +import InvalidQueryError from "../errors/InvalidQueryError"; import ILocation from "../interfaces/ILocation"; import IPath from "../interfaces/IPath"; import IQuery from "../interfaces/IQuery"; @@ -11,6 +13,12 @@ import ILocationResolver from "./ILocationResolver"; import IQueryRunner from "./IQueryRunner"; import IResolvedQuery from "./IResolvedQuery"; +/** + * This default query runner only accepts public transport queries (`publicTransportOnly = true`). + * It uses the registered [[IPublicTransportPlanner]] to execute them. + * + * The default `minimumDepartureTime` is *now*. The default `maximumArrivalTime` is `minimumDepartureTime + 2 hours`. + */ @injectable() export default class QueryRunnerDefault implements IQueryRunner { private locationResolver: ILocationResolver; @@ -31,7 +39,7 @@ export default class QueryRunnerDefault implements IQueryRunner { return this.publicTransportPlanner.plan(resolvedQuery); } else { - return Promise.reject("Query not supported"); + throw new InvalidQueryError("Query should have publicTransportOnly = true"); } } @@ -56,7 +64,8 @@ export default class QueryRunnerDefault implements IQueryRunner { from, to, minimumWalkingSpeed, maximumWalkingSpeed, walkingSpeed, maximumWalkingDuration, maximumWalkingDistance, - minimumTransferDuration, maximumTransferDuration, maximumTransfers, + minimumTransferDuration, maximumTransferDuration, maximumTransferDistance, + maximumTransfers, minimumDepartureTime, maximumArrivalTime, ...other } = query; @@ -76,15 +85,25 @@ export default class QueryRunnerDefault implements IQueryRunner { resolvedQuery.maximumArrivalTime = newMaximumArrivalTime; } - resolvedQuery.from = await this.resolveEndpoint(from); - resolvedQuery.to = await this.resolveEndpoint(to); + try { + resolvedQuery.from = await this.resolveEndpoint(from); + resolvedQuery.to = await this.resolveEndpoint(to); + + } catch (e) { + return Promise.reject(new InvalidQueryError(e)); + } + resolvedQuery.minimumWalkingSpeed = minimumWalkingSpeed || walkingSpeed || Defaults.defaultMinimumWalkingSpeed; resolvedQuery.maximumWalkingSpeed = maximumWalkingSpeed || walkingSpeed || Defaults.defaultMaximumWalkingSpeed; + resolvedQuery.maximumWalkingDuration = maximumWalkingDuration || Units.toDuration(maximumWalkingDistance, resolvedQuery.minimumWalkingSpeed) || Defaults.defaultWalkingDuration; resolvedQuery.minimumTransferDuration = minimumTransferDuration || Defaults.defaultMinimumTransferDuration; - resolvedQuery.maximumTransferDuration = maximumTransferDuration || Defaults.defaultMaximumTransferDuration; + resolvedQuery.maximumTransferDuration = maximumTransferDuration || + Units.toDuration(maximumTransferDistance, resolvedQuery.minimumWalkingSpeed) || + Defaults.defaultMaximumTransferDuration; + resolvedQuery.maximumTransfers = maximumTransfers || Defaults.defaultMaximumTransfers; return resolvedQuery; diff --git a/src/query-runner/earliest-arrival-first/LinearQueryIterator.ts b/src/query-runner/earliest-arrival-first/LinearQueryIterator.ts new file mode 100644 index 00000000..3f6abd73 --- /dev/null +++ b/src/query-runner/earliest-arrival-first/LinearQueryIterator.ts @@ -0,0 +1,40 @@ +import { AsyncIterator } from "asynciterator"; +import { DurationMs } from "../../interfaces/units"; +import IResolvedQuery from "../IResolvedQuery"; + +// Inspired by IntegerIterator +export default class LinearQueryIterator extends AsyncIterator { + private readonly baseQuery: IResolvedQuery; + private timespan: DurationMs; + private index: number; + private readonly a: DurationMs; + private readonly b: DurationMs; + + constructor(baseQuery: IResolvedQuery, a: DurationMs, b: DurationMs) { + super(); + + this.baseQuery = baseQuery; + this.index = 1; + this.a = a; + this.b = b; + + this.timespan = a * this.index + b; + + this.readable = true; + } + + public read(): IResolvedQuery { + if (this.closed) { + return null; + } + + const {minimumDepartureTime} = this.baseQuery; + const maximumArrivalTime = new Date(minimumDepartureTime.getTime() + this.timespan); + + this.index++; + this.timespan = this.a * this.index + this.b; + + return Object.assign({}, this.baseQuery, {maximumArrivalTime}); + } + +} diff --git a/src/query-runner/earliest-arrival-first/QueryRunnerEarliestArrivalFirst.test.ts b/src/query-runner/earliest-arrival-first/QueryRunnerEarliestArrivalFirst.test.ts new file mode 100644 index 00000000..2d86bcc8 --- /dev/null +++ b/src/query-runner/earliest-arrival-first/QueryRunnerEarliestArrivalFirst.test.ts @@ -0,0 +1,111 @@ +import "jest"; +import LDFetch from "ldfetch"; +import Context from "../../Context"; +import TravelMode from "../../enums/TravelMode"; +import ConnectionsFetcherLazy from "../../fetcher/connections/lazy/ConnectionsFetcherLazy"; +import StopsFetcherLDFetch from "../../fetcher/stops/ld-fetch/StopsFetcherLDFetch"; +import IPath from "../../interfaces/IPath"; +import IStep from "../../interfaces/IStep"; +import CSAProfile from "../../planner/public-transport/CSAProfile"; +import JourneyExtractorProfile from "../../planner/public-transport/JourneyExtractorProfile"; +import ReachableStopsFinderBirdsEyeCached from "../../planner/stops/ReachableStopsFinderBirdsEyeCached"; +import Units from "../../util/Units"; +import LocationResolverDefault from "../LocationResolverDefault"; +import QueryRunnerEarliestArrivalFirst from "./QueryRunnerEarliestArrivalFirst"; +describe("[QueryRunnerExponential]", () => { + jest.setTimeout(100000); + + let publicTransportResult; + + const query = { + publicTransportOnly: true, + from: "http://irail.be/stations/NMBS/008896925", // Ingelmunster + to: "http://irail.be/stations/NMBS/008892007", // Ghent-Sint-Pieters + minimumDepartureTime: new Date(), + maximumTransferDuration: Units.fromHours(.5), + }; + + const createEarliestArrivalQueryRunner = () => { + const ldFetch = new LDFetch({ headers: { Accept: "application/ld+json" } }); + + const connectionFetcher = new ConnectionsFetcherLazy(ldFetch); + connectionFetcher.setTravelMode(TravelMode.Train); + connectionFetcher.setAccessUrl("https://graph.irail.be/sncb/connections"); + + const stopsFetcher = new StopsFetcherLDFetch(ldFetch); + stopsFetcher.setAccessUrl("https://irail.be/stations/NMBS"); + + const locationResolver = new LocationResolverDefault(stopsFetcher); + const reachableStopsFinder = new ReachableStopsFinderBirdsEyeCached(stopsFetcher); + + const context = new Context(); + + const createJourneyExtractor = () => { + return new JourneyExtractorProfile( + locationResolver, + ); + }; + + const createPlanner = () => { + return new CSAProfile( + connectionFetcher, + locationResolver, + reachableStopsFinder, + reachableStopsFinder, + reachableStopsFinder, + createJourneyExtractor(), + ); + }; + + return new QueryRunnerEarliestArrivalFirst( + context, + connectionFetcher, + locationResolver, + createPlanner, + reachableStopsFinder, + reachableStopsFinder, + reachableStopsFinder, + ); + }; + + const result: IPath[] = []; + + beforeAll(async (done) => { + + const queryRunner = createEarliestArrivalQueryRunner(); + + publicTransportResult = await queryRunner.run(query); + + await publicTransportResult.take(3) + .on("data", (path: IPath) => { + result.push(path); + }) + .on("end", () => { + done(); + }); + }); + + it("Correct departure and arrival stop", () => { + checkStops(result, query); + }); +}); + +const checkStops = (result, query) => { + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThanOrEqual(1); + + for (const path of result) { + expect(path.steps).toBeDefined(); + + expect(path.steps.length).toBeGreaterThanOrEqual(1); + + let currentLocation = query.from; + path.steps.forEach((step: IStep) => { + expect(step).toBeDefined(); + expect(currentLocation).toEqual(step.startLocation.id); + currentLocation = step.stopLocation.id; + }); + + expect(query.to).toEqual(currentLocation); + } +}; diff --git a/src/query-runner/earliest-arrival-first/QueryRunnerEarliestArrivalFirst.ts b/src/query-runner/earliest-arrival-first/QueryRunnerEarliestArrivalFirst.ts new file mode 100644 index 00000000..62527c1d --- /dev/null +++ b/src/query-runner/earliest-arrival-first/QueryRunnerEarliestArrivalFirst.ts @@ -0,0 +1,200 @@ +import { AsyncIterator } from "asynciterator"; +import { PromiseProxyIterator } from "asynciterator-promiseproxy"; +import { inject, injectable, interfaces, tagged } from "inversify"; +import Context from "../../Context"; +import Defaults from "../../Defaults"; +import EventType from "../../enums/EventType"; +import ReachableStopsSearchPhase from "../../enums/ReachableStopsSearchPhase"; +import InvalidQueryError from "../../errors/InvalidQueryError"; +import IConnectionsProvider from "../../fetcher/connections/IConnectionsProvider"; +import ILocation from "../../interfaces/ILocation"; +import IPath from "../../interfaces/IPath"; +import IQuery from "../../interfaces/IQuery"; +import { DurationMs } from "../../interfaces/units"; +import Path from "../../planner/Path"; +import CSAEarliestArrival from "../../planner/public-transport/CSAEarliestArrival"; +import IPublicTransportPlanner from "../../planner/public-transport/IPublicTransportPlanner"; +import JourneyExtractorEarliestArrival from "../../planner/public-transport/JourneyExtractorEarliestArrival"; +import IReachableStopsFinder from "../../planner/stops/IReachableStopsFinder"; +import TYPES from "../../types"; +import FilterUniqueIterator from "../../util/iterators/FilterUniqueIterator"; +import FlatMapIterator from "../../util/iterators/FlatMapIterator"; +import Units from "../../util/Units"; +import ILocationResolver from "../ILocationResolver"; +import IQueryRunner from "../IQueryRunner"; +import IResolvedQuery from "../IResolvedQuery"; +import LinearQueryIterator from "./LinearQueryIterator"; + +@injectable() +export default class QueryRunnerEarliestArrivalFirst implements IQueryRunner { + + private readonly context: Context; + private readonly connectionsProvider: IConnectionsProvider; + private readonly locationResolver: ILocationResolver; + private readonly publicTransportPlannerFactory: interfaces.Factory; + + private readonly journeyExtractorEarliestArrival: JourneyExtractorEarliestArrival; + + private readonly initialReachableStopsFinder: IReachableStopsFinder; + private readonly transferReachableStopsFinder: IReachableStopsFinder; + private readonly finalReachableStopsFinder: IReachableStopsFinder; + + constructor( + @inject(TYPES.Context) + context: Context, + @inject(TYPES.ConnectionsProvider) + connectionsProvider: IConnectionsProvider, + @inject(TYPES.LocationResolver) + locationResolver: ILocationResolver, + @inject(TYPES.PublicTransportPlannerFactory) + publicTransportPlannerFactory: interfaces.Factory, + @inject(TYPES.ReachableStopsFinder) + @tagged("phase", ReachableStopsSearchPhase.Initial) + initialReachableStopsFinder: IReachableStopsFinder, + @inject(TYPES.ReachableStopsFinder) + @tagged("phase", ReachableStopsSearchPhase.Transfer) + transferReachableStopsFinder: IReachableStopsFinder, + @inject(TYPES.ReachableStopsFinder) + @tagged("phase", ReachableStopsSearchPhase.Final) + finalReachableStopsFinder: IReachableStopsFinder, + ) { + this.context = context; + this.connectionsProvider = connectionsProvider; + this.locationResolver = locationResolver; + this.publicTransportPlannerFactory = publicTransportPlannerFactory; + + this.initialReachableStopsFinder = initialReachableStopsFinder; + this.transferReachableStopsFinder = transferReachableStopsFinder; + this.finalReachableStopsFinder = finalReachableStopsFinder; + + this.journeyExtractorEarliestArrival = new JourneyExtractorEarliestArrival( + locationResolver, + context, + ); + } + + public async run(query: IQuery): Promise> { + const baseQuery: IResolvedQuery = await this.resolveBaseQuery(query); + + if (baseQuery.publicTransportOnly) { + + const earliestArrivalPlanner = new CSAEarliestArrival( + this.connectionsProvider, + this.locationResolver, + this.initialReachableStopsFinder, + this.transferReachableStopsFinder, + this.finalReachableStopsFinder, + this.journeyExtractorEarliestArrival, + this.context, + ); + + const earliestArrivalIterator = await earliestArrivalPlanner.plan(baseQuery); + + const path: IPath = await new Promise((resolve) => { + earliestArrivalIterator + .take(1) + .on("data", (result: IPath) => { + resolve(result); + }) + .on("end", () => { + resolve(null); + }); + }); + + if (path === null && this.context) { + this.context.emit(EventType.AbortQuery, "This query has no results"); + } + + let initialTimeSpan: DurationMs = Units.fromHours(1); + let travelDuration: DurationMs; + + if (path && path.steps && path.steps.length > 0) { + const firstStep = path.steps[0]; + const lastStep = path.steps[path.steps.length - 1]; + + initialTimeSpan = lastStep.stopTime.getTime() - baseQuery.minimumDepartureTime.getTime(); + travelDuration = lastStep.stopTime.getTime() - firstStep.startTime.getTime(); + } + + baseQuery.maximumTravelDuration = travelDuration * 2; + + const queryIterator = new LinearQueryIterator(baseQuery, Units.fromHours(1.5), initialTimeSpan); + + const subQueryIterator = new FlatMapIterator( + queryIterator, + this.runSubquery.bind(this), + ); + + const prependedIterator = subQueryIterator.prepend([path]); + + return new FilterUniqueIterator(prependedIterator, Path.compareEquals); + + } else { + throw new InvalidQueryError("Query should have publicTransportOnly = true"); + } + } + + private runSubquery(query: IResolvedQuery): AsyncIterator { + this.context.emit(EventType.SubQuery, query); + + const planner = this.publicTransportPlannerFactory() as IPublicTransportPlanner; + + return new PromiseProxyIterator(() => planner.plan(query)); + } + + private async resolveEndpoint(endpoint: string | string[] | ILocation | ILocation[]): Promise { + + if (Array.isArray(endpoint)) { + const promises = (endpoint as Array) + .map((singleEndpoint: string | ILocation) => + this.locationResolver.resolve(singleEndpoint), + ); + + return await Promise.all(promises); + + } else { + return [await this.locationResolver.resolve(endpoint)]; + } + } + + private async resolveBaseQuery(query: IQuery): Promise { + // tslint:disable:trailing-comma + const { + from, to, + minimumWalkingSpeed, maximumWalkingSpeed, walkingSpeed, + maximumWalkingDuration, maximumWalkingDistance, + minimumTransferDuration, maximumTransferDuration, maximumTransferDistance, + maximumTransfers, + minimumDepartureTime, + ...other + } = query; + // tslint:enable:trailing-comma + + const resolvedQuery: IResolvedQuery = Object.assign({}, other); + + resolvedQuery.minimumDepartureTime = minimumDepartureTime || new Date(); + + try { + resolvedQuery.from = await this.resolveEndpoint(from); + resolvedQuery.to = await this.resolveEndpoint(to); + + } catch (e) { + return Promise.reject(new InvalidQueryError(e)); + } + + resolvedQuery.minimumWalkingSpeed = minimumWalkingSpeed || walkingSpeed || Defaults.defaultMinimumWalkingSpeed; + resolvedQuery.maximumWalkingSpeed = maximumWalkingSpeed || walkingSpeed || Defaults.defaultMaximumWalkingSpeed; + + resolvedQuery.maximumWalkingDuration = maximumWalkingDuration || + Units.toDuration(maximumWalkingDistance, resolvedQuery.minimumWalkingSpeed) || Defaults.defaultWalkingDuration; + + resolvedQuery.minimumTransferDuration = minimumTransferDuration || Defaults.defaultMinimumTransferDuration; + resolvedQuery.maximumTransferDuration = maximumTransferDuration || + Units.toDuration(maximumTransferDistance, resolvedQuery.minimumWalkingSpeed) || + Defaults.defaultMaximumTransferDuration; + + resolvedQuery.maximumTransfers = maximumTransfers || Defaults.defaultMaximumTransfers; + + return resolvedQuery; + } +} diff --git a/src/query-runner/exponential/ExponentialQueryIterator.ts b/src/query-runner/exponential/ExponentialQueryIterator.ts index 5f874b8f..a5525a2b 100644 --- a/src/query-runner/exponential/ExponentialQueryIterator.ts +++ b/src/query-runner/exponential/ExponentialQueryIterator.ts @@ -2,7 +2,10 @@ import { AsyncIterator } from "asynciterator"; import { DurationMs } from "../../interfaces/units"; import IResolvedQuery from "../IResolvedQuery"; -// Inspired by IntegerIterator +/** + * This AsyncIterator emits [[IResolvedQuery]] instances with exponentially increasing `maximumArrivalTime`. + * For each emitted query, the time frame gets doubled (x2). + */ export default class ExponentialQueryIterator extends AsyncIterator { private readonly baseQuery: IResolvedQuery; private timespan: DurationMs; @@ -28,5 +31,4 @@ export default class ExponentialQueryIterator extends AsyncIterator { - - private store: Path[]; - - constructor(source: AsyncIterator) { - super(source, { - maxBufferSize: 1, - autoStart: false, - }); - - this.store = []; - } - - public _filter(path: IPath): boolean { - - const isUnique = !this.store - .some((storedPath: Path) => storedPath.equals(path)); - - if (isUnique) { - this.store.push(path as Path); - } - - return isUnique; - } -} diff --git a/src/query-runner/exponential/QueryRunnerExponential.test.ts b/src/query-runner/exponential/QueryRunnerExponential.test.ts index 06985aef..32e71ada 100644 --- a/src/query-runner/exponential/QueryRunnerExponential.test.ts +++ b/src/query-runner/exponential/QueryRunnerExponential.test.ts @@ -1,14 +1,14 @@ import "jest"; import LDFetch from "ldfetch"; import Context from "../../Context"; -import ConnectionsFetcherLazy from "../../fetcher/connections/ld-fetch/ConnectionsFetcherLazy"; +import TravelMode from "../../enums/TravelMode"; +import ConnectionsFetcherLazy from "../../fetcher/connections/lazy/ConnectionsFetcherLazy"; import StopsFetcherLDFetch from "../../fetcher/stops/ld-fetch/StopsFetcherLDFetch"; import IPath from "../../interfaces/IPath"; import IStep from "../../interfaces/IStep"; -import JourneyExtractorDefault from "../../planner/public-transport/JourneyExtractorDefault"; -import PublicTransportPlannerCSAProfile from "../../planner/public-transport/PublicTransportPlannerCSAProfile"; +import CSAProfile from "../../planner/public-transport/CSAProfile"; +import JourneyExtractorProfile from "../../planner/public-transport/JourneyExtractorProfile"; import ReachableStopsFinderBirdsEyeCached from "../../planner/stops/ReachableStopsFinderBirdsEyeCached"; -import TravelMode from "../../TravelMode"; import Units from "../../util/Units"; import LocationResolverDefault from "../LocationResolverDefault"; import QueryRunnerExponential from "./QueryRunnerExponential"; @@ -42,13 +42,13 @@ describe("[QueryRunnerExponential]", () => { const context = new Context(); const createJourneyExtractor = () => { - return new JourneyExtractorDefault( + return new JourneyExtractorProfile( locationResolver, ); }; const createPlanner = () => { - return new PublicTransportPlannerCSAProfile( + return new CSAProfile( connectionFetcher, locationResolver, reachableStopsFinder, diff --git a/src/query-runner/exponential/QueryRunnerExponential.ts b/src/query-runner/exponential/QueryRunnerExponential.ts index cde5f40b..c834d12e 100644 --- a/src/query-runner/exponential/QueryRunnerExponential.ts +++ b/src/query-runner/exponential/QueryRunnerExponential.ts @@ -1,27 +1,42 @@ import { AsyncIterator } from "asynciterator"; +import { PromiseProxyIterator } from "asynciterator-promiseproxy"; import { inject, injectable, interfaces } from "inversify"; import Context from "../../Context"; import Defaults from "../../Defaults"; -import EventType from "../../EventType"; +import EventType from "../../enums/EventType"; +import InvalidQueryError from "../../errors/InvalidQueryError"; import ILocation from "../../interfaces/ILocation"; import IPath from "../../interfaces/IPath"; import IQuery from "../../interfaces/IQuery"; +import Path from "../../planner/Path"; import IPublicTransportPlanner from "../../planner/public-transport/IPublicTransportPlanner"; import TYPES from "../../types"; -import Emiterator from "../../util/iterators/Emiterator"; +import FilterUniqueIterator from "../../util/iterators/FilterUniqueIterator"; +import FlatMapIterator from "../../util/iterators/FlatMapIterator"; import Units from "../../util/Units"; import ILocationResolver from "../ILocationResolver"; import IQueryRunner from "../IQueryRunner"; import IResolvedQuery from "../IResolvedQuery"; import ExponentialQueryIterator from "./ExponentialQueryIterator"; -import FilterUniquePathsIterator from "./FilterUniquePathsIterator"; -import SubqueryIterator from "./SubqueryIterator"; +/** + * This exponential query runner only accepts public transport queries (`publicTransportOnly = true`). + * It uses the registered [[IPublicTransportPlanner]] to execute them. + * + * To improve the user perceived performance, the query gets split into subqueries + * with exponentially increasing time frames: + * + * ``` + * minimumDepartureTime + 15 minutes, 30 minutes, 60 minutes, 120 minutes... + * ``` + * + * In the current implementation, the `maximumArrivalTime` is ignored + */ @injectable() export default class QueryRunnerExponential implements IQueryRunner { - private locationResolver: ILocationResolver; - private publicTransportPlannerFactory: interfaces.Factory; - private context: Context; + private readonly locationResolver: ILocationResolver; + private readonly publicTransportPlannerFactory: interfaces.Factory; + private readonly context: Context; constructor( @inject(TYPES.Context) @@ -40,33 +55,27 @@ export default class QueryRunnerExponential implements IQueryRunner { const baseQuery: IResolvedQuery = await this.resolveBaseQuery(query); if (baseQuery.publicTransportOnly) { - const queryIterator = new ExponentialQueryIterator(baseQuery, 15 * 60 * 1000); - // const emitQueryIterator = new Emiterator( - // queryIterator, - // this.context, - // EventType.QueryExponential, - // ); - const subqueryIterator = new SubqueryIterator( + const subqueryIterator = new FlatMapIterator( queryIterator, this.runSubquery.bind(this), ); - return new FilterUniquePathsIterator(subqueryIterator); + return new FilterUniqueIterator(subqueryIterator, Path.compareEquals); } else { - return Promise.reject("Query not supported"); + throw new InvalidQueryError("Query should have publicTransportOnly = true"); } } - private async runSubquery(query: IResolvedQuery): Promise> { + private runSubquery(query: IResolvedQuery): AsyncIterator { // TODO investigate if publicTransportPlanner can be reused or reuse some of its aggregated data - this.context.emit(EventType.QueryExponential, query); + this.context.emit(EventType.SubQuery, query); const planner = this.publicTransportPlannerFactory() as IPublicTransportPlanner; - return planner.plan(query); + return new PromiseProxyIterator(() => planner.plan(query)); } private async resolveEndpoint(endpoint: string | string[] | ILocation | ILocation[]): Promise { @@ -90,7 +99,8 @@ export default class QueryRunnerExponential implements IQueryRunner { from, to, minimumWalkingSpeed, maximumWalkingSpeed, walkingSpeed, maximumWalkingDuration, maximumWalkingDistance, - minimumTransferDuration, maximumTransferDuration, maximumTransfers, + minimumTransferDuration, maximumTransferDuration, maximumTransferDistance, + maximumTransfers, minimumDepartureTime, ...other } = query; @@ -100,15 +110,25 @@ export default class QueryRunnerExponential implements IQueryRunner { resolvedQuery.minimumDepartureTime = minimumDepartureTime || new Date(); - resolvedQuery.from = await this.resolveEndpoint(from); - resolvedQuery.to = await this.resolveEndpoint(to); + try { + resolvedQuery.from = await this.resolveEndpoint(from); + resolvedQuery.to = await this.resolveEndpoint(to); + + } catch (e) { + return Promise.reject(new InvalidQueryError(e)); + } + resolvedQuery.minimumWalkingSpeed = minimumWalkingSpeed || walkingSpeed || Defaults.defaultMinimumWalkingSpeed; resolvedQuery.maximumWalkingSpeed = maximumWalkingSpeed || walkingSpeed || Defaults.defaultMaximumWalkingSpeed; + resolvedQuery.maximumWalkingDuration = maximumWalkingDuration || Units.toDuration(maximumWalkingDistance, resolvedQuery.minimumWalkingSpeed) || Defaults.defaultWalkingDuration; resolvedQuery.minimumTransferDuration = minimumTransferDuration || Defaults.defaultMinimumTransferDuration; - resolvedQuery.maximumTransferDuration = maximumTransferDuration || Defaults.defaultMaximumTransferDuration; + resolvedQuery.maximumTransferDuration = maximumTransferDuration || + Units.toDuration(maximumTransferDistance, resolvedQuery.minimumWalkingSpeed) || + Defaults.defaultMaximumTransferDuration; + resolvedQuery.maximumTransfers = maximumTransfers || Defaults.defaultMaximumTransfers; return resolvedQuery; diff --git a/src/query-runner/exponential/SubqueryIterator.test.ts b/src/query-runner/exponential/SubqueryIterator.test.ts deleted file mode 100644 index 4d6632b5..00000000 --- a/src/query-runner/exponential/SubqueryIterator.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ArrayIterator } from "asynciterator"; -import "jest"; -import SubqueryIterator from "./SubqueryIterator"; - -const ALPHABET = "abc"; - -const queryIterator = new ArrayIterator([1, 2, 3]); - -const subqueryIterator = new SubqueryIterator(queryIterator, (num) => { - return new Promise((resolve) => { - const array = Array(num).fill(ALPHABET[num - 1]); - - resolve(new ArrayIterator(array)); - }); -}); - -test("[SubqueryIterator]", (done) => { - - let current = 0; - const expected = ["a", "b", "b", "c", "c", "c"]; - - subqueryIterator.each((str) => { - expect(expected[current++]).toBe(str); - }); - - subqueryIterator.on("end", () => done()); - -}); diff --git a/src/query-runner/exponential/SubqueryIterator.ts b/src/query-runner/exponential/SubqueryIterator.ts deleted file mode 100644 index e749e5e3..00000000 --- a/src/query-runner/exponential/SubqueryIterator.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { AsyncIterator, BufferedIterator } from "asynciterator"; - -export default class SubqueryIterator extends BufferedIterator { - private queryIterator: AsyncIterator; - private callback: (query: Q) => Promise>; - - private currentResultIterator: AsyncIterator; - private currentResultPushed: number; - private isLastResultIterator = false; - - constructor(queryIterator: AsyncIterator, run: (query: Q) => Promise>) { - super(); - - this.queryIterator = queryIterator; - this.callback = run; - - this.queryIterator.once("end", () => { - this.isLastResultIterator = true; - }); - } - - public _read(count: number, done: () => void) { - if (!this.currentResultIterator) { - const query = this.queryIterator.read(); - - if (query) { - this.runSubquery(query, done); - - } else { - this.waitForSubquery(done); - } - - return; - } - - this.pushItemsAsync(done); - } - - private runSubquery(subquery: Q, done: () => void) { - const self = this; - - this.callback(subquery) - .then((resultIterator: AsyncIterator) => { - self.currentResultIterator = resultIterator; - self.currentResultPushed = 0; - - self.currentResultIterator.once("end", () => { - delete self.currentResultIterator; - - // Close if last iterator - if (self.isLastResultIterator) { - self.close(); - } - - // Iterator was empty - if (self.currentResultPushed === 0 && !this.closed) { - self._read(null, done); - } - }); - - this.pushItemsAsync(done); - }); - } - - private waitForSubquery(done: () => void) { - this.queryIterator.once("readable", () => { - const query = this.queryIterator.read(); - - this.runSubquery(query, done); - }); - } - - private pushItemsAsync(done) { - this.currentResultIterator.on("readable", () => { - this.pushItemsSync(); - done(); - }); - } - - private pushItemsSync(): boolean { - let item = this.currentResultIterator.read(); - let hasPushed = false; - - while (item) { - this._push(item); - this.currentResultPushed++; - hasPushed = true; - - item = this.currentResultIterator.read(); - } - - return hasPushed; - } -} diff --git a/src/test/test-connections-iterator-2.ts b/src/test/test-connections-iterator-2.ts index ac9a9cfb..9172efd9 100644 --- a/src/test/test-connections-iterator-2.ts +++ b/src/test/test-connections-iterator-2.ts @@ -1,6 +1,6 @@ import LDFetch from "ldfetch"; -import ConnectionsIteratorLazy from "../fetcher/connections/ld-fetch/ConnectionsIteratorLazy"; -import TravelMode from "../TravelMode"; +import TravelMode from "../enums/TravelMode"; +import ConnectionsIteratorLazy from "../fetcher/connections/lazy/ConnectionsIteratorLazy"; const ldFetch = new LDFetch({ headers: { Accept: "application/ld+json" } }); diff --git a/src/test/test-connections-iterator.ts b/src/test/test-connections-iterator.ts index 19c9f10a..decba271 100644 --- a/src/test/test-connections-iterator.ts +++ b/src/test/test-connections-iterator.ts @@ -1,6 +1,6 @@ import LDFetch from "ldfetch"; import "reflect-metadata"; -import TravelMode from "../TravelMode"; +import TravelMode from "../enums/TravelMode"; /* const ldFetch = new LDFetch({ headers: { Accept: "application/ld+json" } }); @@ -16,7 +16,7 @@ const config = { const connectionsFetcher = new ConnectionsFetcherLDFetch(ldFetch); connectionsFetcher.setTravelMode(TravelMode.Train); connectionsFetcher.setAccessUrl("https://graph.irail.be/sncb/connections"); -connectionsFetcher.setConfig(config); +connectionsFetcher.setIteratorOptions(config); // iterator.setLowerBound(new Date(2018, 10, 2, 10)); (async () => { diff --git a/src/types.ts b/src/types.ts index 146cd498..d8835c42 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,6 @@ +import TravelMode from "./enums/TravelMode"; import IConnectionsFetcher from "./fetcher/connections/IConnectionsFetcher"; import IStopsFetcher from "./fetcher/stops/IStopsFetcher"; -import TravelMode from "./TravelMode"; const TYPES = { Context: Symbol("Context"), diff --git a/src/util/BinarySearch.test.ts b/src/util/BinarySearch.test.ts new file mode 100644 index 00000000..de946f39 --- /dev/null +++ b/src/util/BinarySearch.test.ts @@ -0,0 +1,73 @@ +import "jest"; +import BinarySearch from "./BinarySearch"; + +describe("[BinarySearch]", () => { + + const array = [1, 2, 3, 3, 5, 6, 6, 6, 6, 7, 7]; + const search = new BinarySearch(array, (a) => a); + + describe("findFirstIndex", () => { + + it("key exists", () => { + const resultIndex = search.findFirstIndex(6); + const expectedIndex = 5; + + expect(resultIndex).toBe(expectedIndex); + }); + + it("key doesn\'t exist / belongs at the start", () => { + const resultIndex = search.findFirstIndex(0); + const expectedIndex = 0; + + expect(resultIndex).toBe(expectedIndex); + }); + + it("key doesn\'t exist / belongs in middle", () => { + const resultIndex = search.findFirstIndex(4); + const expectedIndex = 4; + + expect(resultIndex).toBe(expectedIndex); + }); + + it("key doesn\'t exist / belongs at the end", () => { + const resultIndex = search.findFirstIndex(8); + const expectedIndex = 10; + + expect(resultIndex).toBe(expectedIndex); + }); + + }); + + describe("findLastIndex", () => { + + it("key exists", () => { + const resultIndex = search.findLastIndex(6); + const expectedIndex = 8; + + expect(resultIndex).toBe(expectedIndex); + }); + + it("key doesn\'t exist / belongs at the start", () => { + const resultIndex = search.findLastIndex(0); + const expectedIndex = 0; + + expect(resultIndex).toBe(expectedIndex); + }); + + it("key doesn\'t exist / belongs in middle", () => { + const resultIndex = search.findLastIndex(4); + const expectedIndex = 3; + + expect(resultIndex).toBe(expectedIndex); + }); + + it("key doesn\'t exist / belongs at the end", () => { + const resultIndex = search.findLastIndex(8); + const expectedIndex = 10; + + expect(resultIndex).toBe(expectedIndex); + }); + + }); + +}); diff --git a/src/util/BinarySearch.ts b/src/util/BinarySearch.ts new file mode 100644 index 00000000..0e19e4db --- /dev/null +++ b/src/util/BinarySearch.ts @@ -0,0 +1,52 @@ +/** + * Util class with binary search procedures + * Assumes that array is in ascending order according to predicate + */ +export default class BinarySearch { + private readonly array: T[]; + private readonly predicate: (item: T) => number; + + constructor(array: T[], predicate: (item: T) => number) { + this.array = array; + this.predicate = predicate; + } + + /** + * Find the first index of the given key, or the index before which that key would be hypothetically spliced in + * Adapted from: https://algorithmsandme.com/first-occurrence-of-element/ + */ + public findFirstIndex(key: number, start: number = 0, end: number = (this.array.length - 1)): number { + while (start < end) { + const mid = start + Math.floor((end - start) / 2); + + if (this.predicate(this.array[mid]) >= key) { + end = mid; + + } else { + start = mid + 1; + } + } + + return start; + } + + /** + * Find the last index of the given key, or the index after which that key would be hypothetically spliced in + * Adapted from: https://www.algorithmsandme.com/last-occurrence-of-element-with-binary-search/ + */ + public findLastIndex(key: number, start: number = 0, end: number = (this.array.length - 1)): number { + while (start < end) { + const mid = start + Math.floor(((end - start) + 1) / 2); + + if (this.predicate(this.array[mid]) <= key) { + start = mid; + + } else { + end = mid - 1; + } + } + + return start; + } + +} diff --git a/src/util/Geo.ts b/src/util/Geo.ts index 583cd194..6ab118a5 100644 --- a/src/util/Geo.ts +++ b/src/util/Geo.ts @@ -3,7 +3,15 @@ import IStop from "../fetcher/stops/IStop"; import ILocation from "../interfaces/ILocation"; import { DistanceM } from "../interfaces/units"; +/** + * Utility class with geographic functions + */ export default class Geo { + + /** + * Calculate the distance between two [[ILocation]] instances using the haversine formula + * @returns distance is meters ([[DistanceM]]) + */ public static getDistanceBetweenLocations(start: ILocation, stop: ILocation): DistanceM { const { longitude: depLongitude, latitude: depLatitude } = start; const { longitude: arrLongitude, latitude: arrLatitude } = stop; @@ -24,7 +32,20 @@ export default class Geo { }); } + /** + * Calculate tge distance between two [[IStop]] instances using the haversine formula + * @returns distance is meters ([[DistanceM]]) + */ public static getDistanceBetweenStops(start: IStop, stop: IStop) { return this.getDistanceBetweenLocations(start as ILocation, stop as ILocation); } + + /** + * Get the geo id of an [[ILocation]] + * @param location + * @returns geo id string + */ + public static getId(location: ILocation): string { + return `geo:${location.latitude},${location.longitude}`; + } } diff --git a/src/util/Iterators.ts b/src/util/Iterators.ts index 7947ea5c..28147de0 100644 --- a/src/util/Iterators.ts +++ b/src/util/Iterators.ts @@ -1,9 +1,15 @@ import { AsyncIterator } from "asynciterator"; +/** + * Utility class with functions to operate on AsyncIterator instances + */ export default class Iterators { + /** + * Returns an array representation of an AsyncIterator. + * Assumes the iterator will end sometime + */ public static toArray(iterator: AsyncIterator): Promise { - const array = []; iterator.each((item: T) => array.push(item)); @@ -12,6 +18,9 @@ export default class Iterators { }); } + /** + * Returns the first element of an AsyncIterator. + */ public static getFirst(iterator: AsyncIterator): Promise { return new Promise((resolve) => { iterator.on("readable", () => { @@ -20,12 +29,15 @@ export default class Iterators { }); } - public static find(iterator: AsyncIterator, callback: (element: T) => boolean): Promise { + /** + * Iterates over elements of an AsyncIterator, returning the first element ´predicate´ returns truthy for. + */ + public static find(iterator: AsyncIterator, predicate: (element: T) => boolean): Promise { return new Promise((resolve, reject) => { iterator.on("readable", () => { let element = iterator.read(); - while (element && !callback(element)) { + while (element && !predicate(element)) { element = iterator.read(); } diff --git a/src/util/Rdf.ts b/src/util/Rdf.ts index f776afc3..f4b8d1b3 100644 --- a/src/util/Rdf.ts +++ b/src/util/Rdf.ts @@ -1,8 +1,27 @@ import { Triple } from "rdf-js"; +/** + * Utility class with functions dealing with rdf triples + */ export default class Rdf { - public static matchesTriple(subject: string, predicate: string, object: string): (triple: Triple) => boolean { + /** + * Creates a triple matcher callback function for use in e.g. an Array#filter() expression + * + * For example: + * ``` + * tripleArray.filter(Rdf.matchesTriple('someSubject', null, null)); + * ``` + * @param subject can be null if not wanting to match by subject + * @param predicate can be null if not wanting to match by predicate + * @param object can be null if not wanting to match by object + */ + public static matchesTriple( + subject: string | null, + predicate: string | null, + object: string | null, + ): (triple: Triple) => boolean { + return (triple: Triple) => { if (subject && triple.subject.value !== subject) { return false; @@ -20,6 +39,18 @@ export default class Rdf { }; } + /** + * Rename the predicate of a triple based on a map with original predicates as keys and + * replacement predicates as values + * + * For example: + * ``` + * const transformedTriple = Rdf.transformPredicate({ + * "oldPredicate1": "newPredicate1", + * "oldPredicate2": "newPredicate2", + * }, someTriple)); + * ``` + */ public static transformPredicate(transformMap: { [oldPredicate: string]: string }, triple: Triple): Triple { if (triple.predicate.value in transformMap) { triple.predicate.value = transformMap[triple.predicate.value]; @@ -27,6 +58,18 @@ export default class Rdf { return triple; } + /** + * Rename the object of a triple based on a map with original objects as keys and + * replacement objects as values + * + * For example: + * ``` + * const transformedTriple = Rdf.transformObject({ + * "oldObject1": "newObject1", + * "oldObject2": "newObject2", + * }, someTriple)); + * ``` + */ public static transformObject(transformMap: { [oldObject: string]: string }, triple: Triple): Triple { if (triple.object.value in transformMap) { triple.object.value = transformMap[triple.object.value]; @@ -34,6 +77,9 @@ export default class Rdf { return triple; } + /** + * Log an array of triples to the console as a table with three columns: subject, predicate and object + */ public static logTripleTable(triples: Triple[]): void { console.table(triples.map((triple: Triple) => ({ subject: triple.subject.value, diff --git a/src/util/Units.test.ts b/src/util/Units.test.ts index fc8ee6d1..7b6da3b5 100644 --- a/src/util/Units.test.ts +++ b/src/util/Units.test.ts @@ -1,12 +1,12 @@ import "jest"; -import { DistanceM, DurationMs, SpeedkmH } from "../interfaces/units"; +import { DistanceM, DurationMs, SpeedKmH } from "../interfaces/units"; import Units from "./Units"; test("[Units] toSpeed", () => { const distance: DistanceM = 10000; const duration: DurationMs = 3600000; - const speed: SpeedkmH = Units.toSpeed(distance, duration); + const speed: SpeedKmH = Units.toSpeed(distance, duration); expect(speed).toBeDefined(); expect(speed).toEqual(10); @@ -15,7 +15,7 @@ test("[Units] toSpeed", () => { test("[Units] toDuration", () => { const distance: DistanceM = 10000; - const speed: SpeedkmH = 10; + const speed: SpeedKmH = 10; const duration: DurationMs = Units.toDuration(distance, speed); expect(duration).toBeDefined(); @@ -25,7 +25,7 @@ test("[Units] toDuration", () => { test("[Units] toDistance", () => { const duration: DurationMs = 3600000; - const speed: SpeedkmH = 10; + const speed: SpeedKmH = 10; const distance: DistanceM = Units.toDistance(speed, duration); expect(distance).toBeDefined(); diff --git a/src/util/Units.ts b/src/util/Units.ts index 4a4337fb..efbda615 100644 --- a/src/util/Units.ts +++ b/src/util/Units.ts @@ -1,15 +1,19 @@ -import { DistanceM, DurationMs, SpeedkmH } from "../interfaces/units"; +import { DistanceM, DurationMs, SpeedKmH } from "../interfaces/units"; +/** + * Utility class with calculation functions dealing with [[DistanceM]], [[DurationMs]] and [[SpeedKmH]] + */ export default class Units { - public static toSpeed(distance: DistanceM, duration: DurationMs): SpeedkmH { + + public static toSpeed(distance: DistanceM, duration: DurationMs): SpeedKmH { return (distance / duration) * 3600; } - public static toDistance(duration: DurationMs, speed: SpeedkmH): DistanceM { + public static toDistance(duration: DurationMs, speed: SpeedKmH): DistanceM { return (speed * duration) / 3600; } - public static toDuration(distance: DistanceM, speed: SpeedkmH): DurationMs { + public static toDuration(distance: DistanceM, speed: SpeedKmH): DurationMs { // tslint:disable-next-line:no-bitwise return ((distance / speed) * 3600 | 0); } diff --git a/src/util/iterators/AsyncArrayIterator.ts b/src/util/iterators/AsyncArrayIterator.ts new file mode 100644 index 00000000..243dfa55 --- /dev/null +++ b/src/util/iterators/AsyncArrayIterator.ts @@ -0,0 +1,35 @@ +import { BufferedIterator } from "asynciterator"; +import { DurationMs } from "../../interfaces/units"; + +/** + * An AsyncIterator that emits the items of a given array, asynchronously. + * Optionally accepts an interval (in ms) between each emitted item + * + * This class is most useful in tests + */ +export default class AsyncArrayIterator extends BufferedIterator { + private currentIndex: number = 0; + private readonly array: T[]; + private readonly interval: DurationMs; + + constructor(array: T[], interval: DurationMs = 0) { + super(); + + this.array = array; + this.interval = interval; + } + + public _read(count: number, done: () => void): void { + if (this.currentIndex === this.array.length) { + this.close(); + return done(); + } + + const self = this; + + setTimeout(() => { + self._push(self.array[self.currentIndex++]); + done(); + }, this.interval); + } +} diff --git a/src/util/iterators/Emiterator.ts b/src/util/iterators/Emiterator.ts index 3b54f8a3..7d3d51d3 100644 --- a/src/util/iterators/Emiterator.ts +++ b/src/util/iterators/Emiterator.ts @@ -1,6 +1,6 @@ import { AsyncIterator, SimpleTransformIterator, SimpleTransformIteratorOptions } from "asynciterator"; import Context from "../../Context"; -import EventType from "../../EventType"; +import EventType from "../../enums/EventType"; /** * Lazily emits an event of specified type for each item that passes through source iterator diff --git a/src/util/iterators/ExpandingIterator.test.ts b/src/util/iterators/ExpandingIterator.test.ts new file mode 100644 index 00000000..75f1aea3 --- /dev/null +++ b/src/util/iterators/ExpandingIterator.test.ts @@ -0,0 +1,98 @@ +import "jest"; +import ExpandingIterator from "./ExpandingIterator"; + +describe("[ExpandingIterator]", () => { + + it("async push", (done) => { + + const expandingIterator = new ExpandingIterator(); + const expected = [1, 2, 3, 4, 5, 6, 8]; + + let currentWrite = 0; + let currentRead = 0; + + const interval = setInterval(() => { + // console.log("Writing", expected[currentWrite]); + expandingIterator.write(expected[currentWrite]); + + if (++currentWrite === expected.length) { + // console.log("Closing"); + expandingIterator.close(); + clearInterval(interval); + } + }, 1); + + expandingIterator.each((str: number) => { + // console.log("Reading", str); + expect(expected[currentRead++]).toBe(str); + }); + + expandingIterator.on("end", () => done()); + }); + + it("sync push", (done) => { + + const expandingIterator = new ExpandingIterator(); + const expected = [1, 2, 3, 4, 5, 6, 8]; + + let currentWrite = 0; + let currentRead = 0; + + for (; currentWrite < expected.length; currentWrite++) { + // console.log("Writing", expected[currentWrite]); + expandingIterator.write(expected[currentWrite]); + } + + expandingIterator.close(); + + expandingIterator.each((str: number) => { + // console.log("Reading", str); + expect(expected[currentRead++]).toBe(str); + }); + + expandingIterator.on("end", () => done()); + }); + + it("mixed push", (done) => { + + const expandingIterator = new ExpandingIterator(); + const expected = [1, 2, 3, 4, 5, 6, 8]; + + let currentWrite = 0; + let currentRead = 0; + + for (; currentWrite < 3; currentWrite++) { + // console.log("Writing", expected[currentWrite]); + expandingIterator.write(expected[currentWrite]); + } + + const interval = setInterval(() => { + // console.log("Writing", expected[currentWrite]); + expandingIterator.write(expected[currentWrite]); + + if (++currentWrite < expected.length) { + // console.log("Writing", expected[currentWrite]); + expandingIterator.write(expected[currentWrite]); + + } else { + // console.log("Closing"); + expandingIterator.close(); + clearInterval(interval); + } + + if (++currentWrite === expected.length) { + // console.log("Closing"); + expandingIterator.close(); + clearInterval(interval); + } + }, 1); + + expandingIterator.each((str: number) => { + // console.log("Reading", str); + expect(expected[currentRead++]).toBe(str); + }); + + expandingIterator.on("end", () => done()); + }); + +}); diff --git a/src/util/iterators/ExpandingIterator.ts b/src/util/iterators/ExpandingIterator.ts new file mode 100644 index 00000000..28153cb4 --- /dev/null +++ b/src/util/iterators/ExpandingIterator.ts @@ -0,0 +1,45 @@ +import { AsyncIterator } from "asynciterator"; + +export default class ExpandingIterator extends AsyncIterator { + + private buffer: T[]; + private shouldClose: boolean; + + constructor() { + super(); + + this.buffer = []; + this.shouldClose = false; + } + + public read(): T { + let item; + + if (this.buffer.length) { + item = this.buffer.shift(); + + } else { + item = null; + + if (this.shouldClose) { + this.close(); + + } + + this.readable = false; + } + + return item; + } + + public write(item: T): void { + if (!this.shouldClose) { + this.buffer.push(item); + this.readable = true; + } + } + + public closeAfterFlush(): void { + this.shouldClose = true; + } +} diff --git a/src/util/iterators/FilterUniqueIterator.test.ts b/src/util/iterators/FilterUniqueIterator.test.ts new file mode 100644 index 00000000..41bb127c --- /dev/null +++ b/src/util/iterators/FilterUniqueIterator.test.ts @@ -0,0 +1,25 @@ +import { ArrayIterator } from "asynciterator"; +import "jest"; +import FilterUniqueIterator from "./FilterUniqueIterator"; + +describe("[FilterUniqueIterator]", () => { + + it("basic", (done) => { + + const numberIterator = new ArrayIterator([1, 1, 2, 3, 4, 5, 5, 5, 5, 6, 1, 5, 3, 5, 8]); + const filterUniqueIterator = new FilterUniqueIterator( + numberIterator, + (a, b) => a === b, + ); + + let current = 0; + const expected = [1, 2, 3, 4, 5, 6, 8]; + + filterUniqueIterator.each((str: number) => { + expect(expected[current++]).toBe(str); + }); + + filterUniqueIterator.on("end", () => done()); + }); + +}); diff --git a/src/util/iterators/FilterUniqueIterator.ts b/src/util/iterators/FilterUniqueIterator.ts new file mode 100644 index 00000000..14c8bec5 --- /dev/null +++ b/src/util/iterators/FilterUniqueIterator.ts @@ -0,0 +1,35 @@ +import { AsyncIterator, SimpleTransformIterator } from "asynciterator"; + +/** + * An AsyncIterator that emits only the unique items emitted by a source iterator. + * Uniqueness is determined by a comparator callback function + * + * Note: All (unique) items get stored in an array internally + */ +export default class FilterUniqueIterator extends SimpleTransformIterator { + + private readonly comparator: (object: T, otherObject: T) => boolean; + private store: T[]; + + constructor(source: AsyncIterator, comparator: (object: T, otherObject: T) => boolean) { + super(source, { + maxBufferSize: 1, + autoStart: false, + }); + + this.comparator = comparator; + this.store = []; + } + + public _filter(object: T): boolean { + + const isUnique = !this.store + .some((storedObject: T) => this.comparator(object, storedObject)); + + if (isUnique) { + this.store.push(object); + } + + return isUnique; + } +} diff --git a/src/util/iterators/FlatMapIterator.test.ts b/src/util/iterators/FlatMapIterator.test.ts new file mode 100644 index 00000000..c9cb4d80 --- /dev/null +++ b/src/util/iterators/FlatMapIterator.test.ts @@ -0,0 +1,40 @@ +import { ArrayIterator } from "asynciterator"; +import "jest"; +import AsyncArrayIterator from "./AsyncArrayIterator"; +import FlatMapIterator from "./FlatMapIterator"; + +const ALPHABET = "abc"; +const expected = ["a", "b", "b", "c", "c", "c"]; + +describe("[FlatMapIterator]", () => { + + const runTest = (QueryIterator, ResultIterator, done) => { + const queryIterator = new QueryIterator([1, 2, 3], 10); + + const flatMapIterator = new FlatMapIterator(queryIterator, (num) => { + const array = Array(num).fill(ALPHABET[num - 1]); + + return new ResultIterator(array, 10); + }); + + let current = 0; + + flatMapIterator.each((str) => { + expect(expected[current++]).toBe(str); + }); + + flatMapIterator.on("end", () => done()); + }; + + it("Subqueries from ArrayIterator / Results from ArrayIterator", (done) => { + runTest(ArrayIterator, ArrayIterator, done); + }); + + it("Subqueries from ArrayIterator / Results from BufferedIterator", (done) => { + runTest(ArrayIterator, AsyncArrayIterator, done); + }); + + it("Subqueries from BufferedIterator / Results from BufferedIterator", (done) => { + runTest(AsyncArrayIterator, AsyncArrayIterator, done); + }); +}); diff --git a/src/util/iterators/FlatMapIterator.ts b/src/util/iterators/FlatMapIterator.ts new file mode 100644 index 00000000..59eb7f4d --- /dev/null +++ b/src/util/iterators/FlatMapIterator.ts @@ -0,0 +1,90 @@ +import { AsyncIterator } from "asynciterator"; + +/** + * This AsyncIterator maps every item of a query AsyncIterator to a result AsyncIterator by passing it through a + * `run` function. All result AsyncIterator get concatenated to form the FlatMapIterator + * + * ```javascript + * +-----+ +-----+ + * queryIterator |0 & 9| +---+ + |1 & 8| +---+ + ... + * +-----+ | +-----+ | + * v v + * +-----------------------+ +-----------------------+ + * resultIterators |01|02|04|05|06|07|08|09| + |11|12|13|14|15|16|17|18| + ... + * +-----------------------+ +-----------------------+ + * + * +-----------------------------------------------+ + * FlatMapIterator |01|02|04|05|06|07|08|09|11|12|13|14|15|16|17|18| ... + * +-----------------------------------------------+ + * ``` + */ +export default class FlatMapIterator extends AsyncIterator { + private queryIterator: AsyncIterator; + private callback: (query: Q) => AsyncIterator; + + private currentResultIterator: AsyncIterator; + private isLastResultIterator = false; + + constructor(queryIterator: AsyncIterator, run: (query: Q) => AsyncIterator) { + super(); + + this.queryIterator = queryIterator; + this.callback = run; + + this.queryIterator.once("end", () => { + this.isLastResultIterator = true; + }); + + this.readable = true; + } + + public read(): R { + if (this.closed) { + return null; + } + + if (!this.currentResultIterator) { + const query: Q = this.queryIterator.read(); + + if (query) { + this.runQuery(query); + + } else { + this.readable = false; + this.queryIterator.once("readable", () => { + this.readable = true; + }); + } + } + + if (this.currentResultIterator) { + const item = this.currentResultIterator.read(); + + if (!item) { + this.readable = false; + } + + return item; + } + + return null; + } + + private runQuery(query: Q) { + this.currentResultIterator = this.callback(query); + this.readable = this.currentResultIterator.readable; + + this.currentResultIterator.on("readable", () => { + this.readable = true; + }); + + this.currentResultIterator.on("end", () => { + if (this.isLastResultIterator) { + this.close(); + } + + this.currentResultIterator = null; + this.readable = true; + }); + } +} diff --git a/src/util/iterators/MergeIterator.ts b/src/util/iterators/MergeIterator.ts index 384ff3a7..8416a79a 100644 --- a/src/util/iterators/MergeIterator.ts +++ b/src/util/iterators/MergeIterator.ts @@ -1,10 +1,10 @@ import { AsyncIterator, BufferedIterator } from "asynciterator"; /** - * Asynciterator that merges a number of source asynciterators based on the passed selector function. + * AsyncIterator that merges a number of source asynciterators based on the passed selector function. * The selector function gets passed an array of values read from each of the asynciterators. * Values can be undefined if their respective source iterator has ended. - * It should return the index in that array of the value to select. + * The selector function should return the index in that array of the value to select. * @param condensed When true, undefined values are filtered from the array passed to the selector function */ export default class MergeIterator extends BufferedIterator { diff --git a/tsconfig.json b/tsconfig.json index b381d735..e2adfa36 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "ES2017", "lib": ["esnext", "dom"], "types": ["reflect-metadata"], "module": "commonjs", diff --git a/tslint.json b/tslint.json index 8d17f7e4..81d71924 100644 --- a/tslint.json +++ b/tslint.json @@ -7,7 +7,13 @@ "rules": { "no-console": false, "object-literal-sort-keys": false, - "no-empty-interface": false + "no-empty-interface": false, + "no-shadowed-variable": [ + true, + { + "temporalDeadZone": false + } + ] }, "rulesDirectory": [] } diff --git a/typedoc.config.js b/typedoc.config.js new file mode 100644 index 00000000..fa7e335d --- /dev/null +++ b/typedoc.config.js @@ -0,0 +1,17 @@ +module.exports = { + out: 'docs/code', + includes: 'src/', + exclude: [ + '**/*+(test).ts', + '**/src/demo.*', + '**/src/inversify.config.ts', + '**/src/test/*', + '**/fetcher/connections/tests/**/*', + ], + mode: 'file', + excludePrivate: true, + excludeNotExported: false, + excludeExternals: false, + includeDeclarations: false, + theme: 'minimal', +}; diff --git a/typedoc.js b/typedoc.js deleted file mode 100644 index f888992e..00000000 --- a/typedoc.js +++ /dev/null @@ -1,12 +0,0 @@ -module.exports = { - out: 'docs/code', - includes: 'src/', - exclude: [ - '**/*+(config|test).ts', - ], - mode: 'file', - excludePrivate: true, - excludeNotExported: true, - excludeExternals: true, - theme: 'minimal', -}; diff --git a/webpack.config.js b/webpack.config.js index cd163ddc..0a356e30 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,8 +1,22 @@ const path = require("path"); +// These modules are imported by ldfetch, but are never actually used because (right now) we only fetch jsonld files +const excludeModules = [ + "rdfa-processor", + //"rdf-canonize", + "rdfxmlprocessor", + "xmldom", + 'n3' +]; + +const excludeAlias = excludeModules.reduce((alias, moduleName) => { + alias[moduleName] = path.resolve(__dirname, "webpack/mockModule.js"); + return alias; +}, {}); + module.exports = { entry: "./src/index.ts", - devtool: 'cheap-module-source-map', + devtool: "source-map",//"cheap-module-source-map", module: { rules: [ { @@ -13,13 +27,17 @@ module.exports = { ] }, resolve: { - extensions: [".ts", ".js"] + extensions: [".ts", ".js"], + alias: { + ...excludeAlias, + "q": path.resolve(__dirname, "webpack/shimQ.js") + } }, output: { filename: "bundle.js", - path: path.resolve(__dirname, 'dist'), - library: 'Planner', - libraryTarget: 'umd', - libraryExport: 'default', + path: path.resolve(__dirname, "dist"), + library: "Planner", + libraryTarget: "umd", + libraryExport: "default" } }; diff --git a/webpack/mockModule.js b/webpack/mockModule.js new file mode 100644 index 00000000..f053ebf7 --- /dev/null +++ b/webpack/mockModule.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/webpack/shimQ.js b/webpack/shimQ.js new file mode 100644 index 00000000..174bd4d0 --- /dev/null +++ b/webpack/shimQ.js @@ -0,0 +1,14 @@ +// Mocks the (only) user of the q promise library by ldfetch + +module.exports = { + defer: () => { + const deferred = {}; + + deferred.promise = new Promise((resolve, reject) => { + deferred.resolve = resolve; + deferred.reject = reject; + }); + + return deferred; + } +};