UNPKG

atriusmaps-node-sdk

Version:

This project provides an API to Atrius Personal Wayfinder maps within a Node environment. See the README.md for more information

419 lines (364 loc) 15.9 kB
'use strict'; var R = require('ramda'); var geohasher = require('../../../src/extModules/geohasher.js'); var geodesy = require('../../../src/utils/geodesy.js'); var minPriorityQueue = require('./minPriorityQueue.js'); const DEFAULT_WALKING_SPEED_M_PER_MIN = 60; const CLOSED_CHECKPOINT_EDGE_WEIGHT = 9999; /** * @typedef NavNode * @property {number} ordinal * @property {Array.<NavEdge>} edges * @property {string} id * @property {number} lat * @property {number} lng * @property {string} floorId * @property {structureId} structureId * * @typedef NavEdge * @property {string} dst - id of node at edge end * @property {string} src - id of node at edge start * @property {number} distance * @property {string|undefined} o - id of security lane POI associated with this edge * @property {CurvedPath|null} path - list of Bezier points if edge represents curve * @property {boolean} isAccessible * @property {boolean} isDriveway - true if edge points to POI, false if edge just connects other edges * @property {number} transitTime * @property {string} type * @property {number} weight - value used by Dijkstra algorithm (usually edge time or distance) * * @param {RawNavGraph} data * @param {function} floorIdToOrdinal * @param {function} floorIdToStructureId * @param {Array.<SecurityLane>} securityLanesMap - list of security lanes * @return {Object} */ // todo remove dead commented code function createNavGraph (data, floorIdToOrdinal, floorIdToStructureId, securityLanesMap) { const nodes = { }; const geoDb = { }; const nodesToAvoid = new Set(); let securityWaitTimes = {}; data.nodes.forEach(nodeData => { const ordinal = floorIdToOrdinal(nodeData.floorId); const structureId = floorIdToStructureId(nodeData.floorId); const node = { ...R.pick(['id', 'lat', 'lng', 'floorId'], nodeData), edges: [], ordinal, structureId }; addNode(node); }); data.edges.forEach(ed => nodes[ed.s].edges.push(createEdge(ed, nodes))); function addNode (node) { const largeGeo = node.floorId + ':' + geohasher.encode(node.lat, node.lng).substr(0, 7); const mediumGeo = node.floorId + ':' + geohasher.encode(node.lat, node.lng).substr(0, 8); if (!geoDb[largeGeo]) geoDb[largeGeo] = []; geoDb[largeGeo].push(node); if (!geoDb[mediumGeo]) geoDb[mediumGeo] = []; geoDb[mediumGeo].push(node); nodes[node.id] = node; } function createEdge (data, nodes) { const type = getEdgeType(data); const isAccessible = type.toLowerCase() !== 'escalator' && type.toLowerCase() !== 'stairs'; // todo consider calculating edge distance with 'path' differently const distance = distanceBetweenNodes(data.s, data.d, nodes); const transitTime = data.l || distance / DEFAULT_WALKING_SPEED_M_PER_MIN; const buildCurvedPath = points => points.map(point => { return { start: { lat: point.s[0], lng: point.s[1] }, out: { lat: point.o[0], lng: point.o[1] }, in: { lat: point.i[0], lng: point.i[1] }, end: { lat: point.e[0], lng: point.e[1] } } }); const path = data.p ? buildCurvedPath(data.p) : null; return { distance, dst: data.d, o: data.o, isAccessible, isDriveway: !R.isNil(data.h) && !data.h, src: data.s, transitTime, type, path, weight: transitTime } } function getEdgeType (data) { if (data.x) return 'Security Checkpoint' if (data.t === '') return 'Ground' return data.t } const findClosestNode = endpoint => { if (endpoint.floorId === undefined && endpoint.ordinal === undefined) // one of these must be present throw Error('Endpoint specified in findRoute without floorId nor an ordinal') const lat = endpoint.lat || endpoint.latitude; // handle bluedot location const lng = endpoint.lng || endpoint.longitude; // handle bluedot location return endpoint.floorId ? findClosestNodeByFloor(endpoint.floorId, lat, lng, geoDb, nodes) : findClosestNodeByOrdinal(endpoint.ordinal, lat, lng, nodes) }; function findShortestPathEntry (start, end, nodes, options = {}) { const startNode = findClosestNode(start); const endNode = findClosestNode(end); // This code section does improve performance by about 10-20%, but comes at a cost // of making the caching unusable in some cases // if (start.structureId === end.structureId) { // options.minOrd = Math.min(start.ordinal, end.ordinal) // options.maxOrd = Math.max(start.ordinal, end.ordinal) // options.structureId = start.structureId // } return findShortestPath(startNode, endNode, nodes, nodesToAvoid, securityWaitTimes, securityLanesMap, options) } /** * * @param {Array.<string>} nodes - Array of nodes to avoid */ function updateNodesToAvoid (nodes) { // We want to replace entire list when we get new nodes, not just add nodesToAvoid.clear(); nodes.forEach(n => nodesToAvoid.add(n)); } /** * @param {Endpoint} start - start endpoint * @param {Array.<Endpoint>} destArray - list of destinations * @param {RouteOptions} options extra options (such as requireAccessibility) * @returns {Array.<Array.<NavNode>>} array of routes corresponding to destinations specified */ function findAllShortestPaths (start, destArray, options) { const startNode = findClosestNode(start); const destNodeArray = destArray.map(dest => findClosestNode(dest)); if (!startNode || !destNodeArray.length) return [] return findAllShortestPathsImpl(startNode, destNodeArray, nodes, nodesToAvoid, securityWaitTimes, securityLanesMap, options) } function updateWithSecurityWaitTime (waitTimesData) { securityWaitTimes = R.map(R.omit(['lastUpdated']), waitTimesData); clearCache(); } return { _nodes: nodes, _geoDb: geoDb, _nodesToAvoid: nodesToAvoid, addNodesToAvoid: (nodes) => updateNodesToAvoid(nodes), findClosestNode: (floorId, lat, lng) => findClosestNodeByFloor(floorId, lat, lng, geoDb, nodes), findShortestPath: (start, end, options) => findShortestPathEntry(start, end, nodes, options), findAllShortestPaths, floorIdToOrdinal, // todo lets get rid of this... floorIdToStructureId, // todo lets get rid of this..., updateWithSecurityWaitTime, clearCache } } function distanceBetweenNodes (n1, n2, nodes) { const node1 = nodes[n1]; const node2 = nodes[n2]; const distance = geodesy.distance(node1.lat, node1.lng, node2.lat, node2.lng); return distance } /** * @param {Object.<Node>} start - a node in the navGraph to start on * @param {Array.<Node>} destinations - array of nodes to find path to * @param {Object.<string, NavNode>} nodes - dictionary of nodes by id * @param {Set.<string>} nodesToAvoid - set of nodes to avoid * @param {Object.<string, SecurityWaitTime>} securityWaitTimes - map of POI id to security wait time * @param {Object.<string, SecurityLane>} securityLanesMap - map of POI id to security lane * @param {RouteOptions} [options={}] extra options (such as requireAccessibility) * @returns {Array.<Array.<Node>>} list of shortest path to each destination */ function findAllShortestPathsImpl (start, destinations, nodes, nodesToAvoid, securityWaitTimes = {}, securityLanesMap = {}, options = {}) { // const previous = findPaths(start, start, nodes, options) // const backtrackPath = node => buildBacktrackPath(nodes, previous, node) // const poiNodeTuples = Array.from(destinations.entries()) // const poiPathTuples = poiNodeTuples.map(([poi, node]) => [poi, backtrackPath(node)]) // return new Map(poiPathTuples) return destinations.map(d => { try { return findShortestPath(start, d, nodes, nodesToAvoid, securityWaitTimes, securityLanesMap, options) } catch (e) { return null } }) } let cost, prev, visited, visitQueue, lastStartId, lastOptionsStr; const clearCache = () => { cost = { }; prev = { }; visited = { }; visitQueue = new minPriorityQueue(); lastStartId = null; lastOptionsStr = {}; }; // This is a temporary name during a "probation" period - then I will // ditch findShortestPath and rewrite findAllShortestPaths and ditch // backtrackPath, etc. // NOTE: export just for testing /** * @param {Object<Node>} start a node in the navGraph to start on * @param {Object<Node>} end a node in the navGraph to find path to * @param {Object.<string, Node>} nodes dictionary of nodes by id * @param {Set.<string>} nodesToAvoid - set of nodes to avoid * @param {Object.<string, SecurityWaitTime>} securityWaitTimes - map of POI id to security wait time * @param {Object.<string, SecurityLane>} securityLanesMap - map of POI id to security lane * @param {RouteOptions} options={} extra options (such as requireAccessibility) * @returns {Array.<Node>} an array of nodes that represent the shortest route from start to end. null if no route exists. */ function findShortestPath (start, end, nodes, nodesToAvoid, securityWaitTimes = {}, securityLanesMap = {}, options = { }) { if (start.id !== lastStartId || lastOptionsStr !== JSON.stringify(options)) { clearCache(); visitQueue.offerWithPriority(start.id, 0); cost[start.id] = 0; visited[start.id] = true; lastStartId = start.id; lastOptionsStr = JSON.stringify(options); } // continue crawling paths - but stop once we found destination while (!visitQueue.isEmpty() && !visited[end.id]) { const node = nodes[visitQueue.poll()]; // pop const ccost = cost[node.id]; // current cost to this node for (let ei = 0; ei < node.edges.length; ei++) { const e = node.edges[ei]; // next edge from this node if (nodesToAvoid.size > 0 && nodesToAvoid.has(e.dst)) { continue } if (visited[e.dst]) continue if (options.requiresAccessibility && !e.isAccessible) { // ignore not accessible edges if we're looking for an accessible route continue } // This code section does improve performance by about 10-20%, but comes at a cost // of making the caching unusable in some cases // if ((options.minOrd !== undefined && nodes[e.dst].ordinal < options.minOrd) || // (options.maxOrd !== undefined && nodes[e.dst].ordinal > options.maxOrd) || // (options.structureId !== undefined && nodes[e.dst].structureId !== options.structureId)) // continue let weight = e.weight; if (e.o && securityWaitTimes[e.o]) { const dynamicData = securityWaitTimes[e.o]; if (dynamicData.queueTime) weight = dynamicData.queueTime; if (dynamicData.isTemporarilyClosed) weight = CLOSED_CHECKPOINT_EDGE_WEIGHT; e.securityWaitTimes = dynamicData; } if (e.o && securityLanesMap[e.o]) { e.securityLane = securityLanesMap[e.o]; const { type, id } = securityLanesMap[e.o]; const securityLanesIds = R.path(['selectedSecurityLanes', type], options); if (securityLanesIds && !securityLanesIds.includes(id)) continue } if (cost[e.dst] === undefined) { prev[e.dst] = node; cost[e.dst] = ccost + weight; visitQueue.offerWithPriority(e.dst, ccost + weight); // add node to the toCheck array } else if (cost[e.dst] > (ccost + weight)) { // is this a shorter path? Relaxation... // if so, update the cost and parent cost[e.dst] = ccost + weight; prev[e.dst] = node; visitQueue.raisePriority(e.dst, ccost + weight); } } visited[node.id] = true; // we have now been selected } if (!visited[end.id]) // if we never found our endpoint, it was inaccessible return null // build the path and return it const path = []; let node = end; while (node) { path.push(node); node = prev[node.id]; } return path.reverse() } function geohashSearch (floorId, geohash, geoDb, size) { const geohashPrefix = geohash.substr(0, size); const searchGeos = []; searchGeos.push(floorId + ':' + geohasher.calculateAdjacent(geohasher.calculateAdjacent(geohashPrefix, 'top'), 'left')); searchGeos.push(floorId + ':' + geohasher.calculateAdjacent(geohashPrefix, 'top')); searchGeos.push(floorId + ':' + geohasher.calculateAdjacent(geohasher.calculateAdjacent(geohashPrefix, 'top'), 'right')); searchGeos.push(floorId + ':' + geohasher.calculateAdjacent(geohashPrefix, 'left')); searchGeos.push(floorId + ':' + geohashPrefix); searchGeos.push(floorId + ':' + geohasher.calculateAdjacent(geohashPrefix, 'right')); searchGeos.push(floorId + ':' + geohasher.calculateAdjacent(geohasher.calculateAdjacent(geohashPrefix, 'bottom'), 'left')); searchGeos.push(floorId + ':' + geohasher.calculateAdjacent(geohashPrefix, 'bottom')); searchGeos.push(floorId + ':' + geohasher.calculateAdjacent(geohasher.calculateAdjacent(geohashPrefix, 'bottom'), 'right')); const nodes = []; for (let i = 0; i < searchGeos.length; i++) { const nodesFound = geoDb[searchGeos[i]]; if (nodesFound) { for (let j = 0; j < nodesFound.length; j++) nodes.push(nodesFound[j]); } } return nodes } function findNodesByGeohash (floorId, geohash, geoDb, nodes) { let foundNodes = geohashSearch(floorId, geohash, geoDb, 8); if (foundNodes.length > 0) return foundNodes // broaden our search a bit and try again... foundNodes = geohashSearch(floorId, geohash, geoDb, 7); if (foundNodes.length > 0) return foundNodes // give up and let someone else try... return null } /** * @param floorId * @param lat * @param lng * @param geoDb * @param {Object<string, NavNode>} nodes - dictionary of node id to node object * @return {NavNode} - node that is on the same floor and closest to the lat,lng point */ function findClosestNodeByFloor (floorId, lat, lng, geoDb, nodes) { const cnodes = findNodesByGeohash(floorId, geohasher.encode(lat, lng), geoDb) || findClosestNodeByFloor2(floorId, lat, lng, nodes); const nodeWithDistance = []; for (let i = 0; i < cnodes.length; i++) { // Attached distance to each node from the origin. const distance = geodesy.distance(lat, lng, cnodes[i].lat, cnodes[i].lng); nodeWithDistance.push([cnodes[i], distance]); } // todo do not sort, just find min node // Sort by distance. nodeWithDistance.sort(function (a, b) { return a[1] - b[1] }); const nodesSortedByDistance = []; for (let i = 0; i < nodeWithDistance.length; i++) nodesSortedByDistance.push(nodeWithDistance[i][0]); return nodesSortedByDistance[0] } // A slightly slower alternative to findClosestNode (an experiment - its simpler, but slower) function findClosestNodeByFloor2 (floorId, lat, lng, nodes) { const floorNodes = Object.values(nodes) .filter(n => n.floorId === floorId) .map(n => [n, geodesy.distance(n.lat, n.lng, lat, lng)]); if (!floorNodes.length) throw Error(`findClosestNodeByFloor2 found no nodes on floor ${floorId}`) return selectShortest(floorNodes) } function findClosestNodeByOrdinal (ord, lat, lng, nodes) { const ordNodes = Object.values(nodes) .filter(n => n.ordinal === ord) .map(n => [n, geodesy.distance(n.lat, n.lng, lat, lng)]); if (!ordNodes.length) throw Error(`findClosestNodeByOrdinal found no nodes on ordinal ${ord}`) return selectShortest(ordNodes) } // companion to above function - it returns closest node // ar is 2-dim array - ar[i][0] = node, ar[i][1] = distance // TODO: use this approach in findClosestNode - no need to sort first! function selectShortest (ar) { let shortest = ar[0]; for (let i = 1; i < ar.length; i++) if (ar[i][1] < shortest[1]) shortest = ar[i]; return shortest[0] } exports.createNavGraph = createNavGraph; exports.findClosestNodeByOrdinal = findClosestNodeByOrdinal; exports.findShortestPath = findShortestPath;