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
JavaScript
;
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;