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

499 lines (428 loc) 21.4 kB
'use strict'; var R = require('ramda'); var Zousan = require('zousan'); var venueLoadingUtils = require('./venueLoadingUtils.js'); function _interopNamespaceDefault(e) { var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var R__namespace = /*#__PURE__*/_interopNamespaceDefault(R); const USE_AUTH_WHEN_NOT_PROD_STAGE = false; // turning this off for now (per Jessica request) async function create (app, config) { const log = app.log.sublog('venueDataLoader'); let venueDataLoaded = new Zousan(); let mapDataLoaded = new Zousan(); const getDefaultStructureId = venueData => venueData.defaultStructureId || R__namespace.path(['structureOrder', 0], venueData) || R__namespace.path(['selectorOrder', 0], venueData) || R__namespace.pipe(R__namespace.prop('structures'), Object.values, R__namespace.path([0, 'id']))(venueData); const mergeWithProp = (prop, toAdd, o) => R__namespace.over(R__namespace.lensProp(prop), old => R__namespace.mergeRight(old || {}, toAdd), o); // This function counts the number of characters that match at the beginning of two strings. const countMatchedPrefix = (a, b) => a.split('').reduce((max, _, i) => a.substring(0, i + 1) === b.substring(0, i + 1) ? i : max, -1) + 1; // This function returns the venue definition for a specific language. It does this by filtering the venueList for the specified language, // sorting the results by how many characters match at the beginning of the venueId, and // returning the first one. If no venues match, undefined is returned. const getVenueDefForLang = (venueData, lang) => Object.values(venueData.venueList) .filter(venueDef => venueDef.locale === lang) // select all the venues for the specified language .sort((a, b) => countMatchedPrefix(b.id, venueData.id) - countMatchedPrefix(a.id, venueData.id)) // sort by how many characters match at the beginning of the venueId - most first .at(0); // take the first one, which is the one that matches the most characters at the beginning of the venueId // This function determines the "base venue ID", which currently is the venue ID of the Engilsh version of the venue. // We can use the venueList to find the English version of the venue, and then return its ID. const calculateBaseVenueId = venueData => getVenueDefForLang(venueData, 'en')?.id; async function loadVenueData (vConfig, languagesToTry) { // For all non-production stages, require SSO (if no assetStage defined we default to 'prod') if (vConfig.assetStage && vConfig.assetStage !== 'prod' && location.hostname !== 'localhost' && USE_AUTH_WHEN_NOT_PROD_STAGE) ; const fetchJson = venueLoadingUtils.createFetchJson(); const fetchText = venueLoadingUtils.createFetchText(); const venueData = await venueLoadingUtils.getVenueDataFromUrls(vConfig, fetchJson, languagesToTry); const { accountId, venueId } = vConfig; venueData.assetStage = vConfig.assetStage; venueData.defaultOrdinal = venueData.defaultOrdinal || getDefaultOrdinal(venueData); venueData.structures = venueLoadingUtils.buildStructures(venueData); venueData.baseVenueId = calculateBaseVenueId(venueData); venueData.getTranslatedContentPath = contentType => `https://content.locuslabs.com/${venueData.category}/${contentType}/${venueId}/${accountId}`; venueData.fetchJson = fetchJson; venueData.fetchText = fetchText; if (app.config.debug && app.env.isBrowser) window._venueData = venueData; if (venueData.queueTypes) venueData.securityQueueTypes = (() => { const secLane = venueData.queueTypes.find(qt => qt.id === 'SecurityLane'); if (secLane) return secLane.subtypes.map(t => t.id) return [] })(); app.bus.send('venueData/venueDataLoaded', { venueData }); venueDataLoaded.resolve(venueData); return venueDataLoaded } function getDefaultOrdinal (venueData) { const defaultStructureId = getDefaultStructureId(venueData); const defaultStructure = Object.values(venueData.structures).find(R__namespace.propEq(defaultStructureId, 'id')); return defaultStructure.levels[defaultStructure.defaultLevelId].ordinal } function notifyState (venueData) { const state = { id: 'venueDataLoader' }; if (venueData.id !== config.venueId) state.vid = venueData.id; state.lang = app.i18n().language; if (venueData.assetStage !== 'prod') state.stage = venueData.assetStage; app.bus.send('deepLinking/notifyState', state); return venueData } app.bus.on('debugTools/fileDrop', async ({ file, content }) => { if (file.type === 'application/json') { const jsonOb = JSON.parse(content); if (jsonOb.basemap && jsonOb['basemap.venue']) return replaceTheme(JSON.parse(content)) // looks like a theme! if (jsonOb.metadata && jsonOb.metadata['mapbox:type']) return replaceStyle(content) // looks like a style! } }); // Returns true if category c1 is within category c2. // "within" here means they either are the same category // or c2 is a subcategory of c1 // i.e. withinCategory("eat", "eat") = true // withinCategory("eat.coffee", "eat") = true // withinCategory("eat", "eat.coffee") = false const withinCategory = (c1, c2) => c1 === c2 || c1.indexOf(c2 + '.') === 0; function poiMapNameXForm (poi) { let name = poi.name; if (!config.poiMapNameXForm) return name // no transforms for me today, thanks Object.keys(config.poiMapNameXForm) .filter(c2 => withinCategory(poi.category, c2)) .forEach(c2 => { const xforms = config.poiMapNameXForm[c2]; // an array of xforms xforms.forEach(xform => (name = name.replace(new RegExp(xform.replace), xform.with))); }); return name } /* This function replaces POI labels with the poi.mapLabel (if it exists). If it does not exist and the config.copyPOINamesToMap is not explicitly set to `false` - then we copy the name from poi.name (and then put it through a set of transformations) */ async function copyPOINames (mapFeatures) { const newMapFeatures = { ...mapFeatures }; const pois = await app.bus.get('poi/getAll'); Object.values(newMapFeatures) .forEach(layerArray => layerArray .filter(f => f.properties.aiLayer === 'poi' && f.geometry.type === 'Point') .forEach(f => { const poi = pois[f.properties.id]; if (!poi) log.warn(`Unknown poi in style: ${f.properties.id}`); else { if (poi.mapLabel) f.properties.text = poi.mapLabel; else if (config.copyPOINamesToMap !== false) f.properties.text = poiMapNameXForm(poi); } })); return newMapFeatures } // pass in theme object (parsed from theme file) function replaceTheme (mapThemeSource) { app.bus.send('map/replaceTheme', { theme: mapThemeSource }); } function replaceStyle (mapStyleSource) { app.bus.send('map/replaceStyle', { styleSrc: mapStyleSource }); } /** * Creates list of source ids from all floor ids and venue id and fetches GeoJSON features for each id. * Returns dictionary of source id to list of GeoJson features * @returns {Promise<Object<string, Array.<GeoJson>>>} */ async function getMapFeatures (venueData) { return R__namespace.pipe( R__namespace.prop('structures'), R__namespace.map(R__namespace.prop('levels')), R__namespace.chain(R__namespace.keys), // Generate list of level IDs plus the venue ID to fetch feature JSON for each R__namespace.prepend(venueData.id), // eslint-disable-next-line no-template-curly-in-string R__namespace.map(geoJsonId => venueData.files.geoJson.replace('${geoJsonId}', geoJsonId)), R__namespace.map(venueData.fetchJson), R__namespace.map(R__namespace.andThen(featureJSON => [featureJSON.id, enrichFeaturesForLevel(featureJSON.id, featureJSON.features, venueData.id, venueData.structures)])), promises => Zousan.all(promises), R__namespace.andThen(R__namespace.fromPairs) )(venueData) } const enrichFeaturesForLevel = (levelId, features, venueId, structures) => { const structureId = levelId.replace(/-[^-]*$/, ''); const ordinalId = structureId === venueId ? 'landscape-background' : `ordinal: ${structures.find(R__namespace.hasPath(['levels', levelId])).levels[levelId].ordinal}`; const enrichFeature = (feature) => { feature = mergeWithProp('properties', ({ venueId, structureId, ordinalId, levelId }), feature); feature = R__namespace.assoc('id', feature.properties.subid, feature); return feature }; return features.map(enrichFeature) }; /** * Fetches map style, map theme, map GeoJson feature sources. * Transforms data to convenient format and sends an event venueData/mapDataLoaded with map data. */ app.bus.on('venueData/loadMap', async () => { venueDataLoaded.then(async venueData => { const mapStyleSource = await venueData.fetchText(venueData.files.style); const mapTheme = await venueData.fetchJson(venueData.files.theme); const badgesSpriteUrl = venueData.files.spritesheet; const mapGlyphsUrl = venueData.files.glyphs; const { id, bounds, structures, venueCenter, venueRadius, defaultOrdinal } = venueData; const mapFeatures = venueData.venueList[id].mapTokens ? { [id]: [] } : await getMapFeatures(venueData).then(copyPOINames); const venueBounds = { n: bounds.ne.lat, s: bounds.sw.lat, e: bounds.ne.lng, w: bounds.sw.lng }; const mapData = { mapFeatures, mapStyleSource, mapTheme, badgesSpriteUrl, mapGlyphsUrl, structures, defaultOrdinal, venueBounds, venueId: id, venueCenter, venueRadius, accountId: config.accountId, secure: config.auth !== undefined, tileServerAuthInfo: venueData.tileServerAuthInfo // only defined for tile maps }; mapDataLoaded.resolve(mapData); app.bus.send('venueData/mapDataLoaded', mapData); }); }); // accept when shouldDisplay is null or undefined or true const shouldDisplayPredicate = building => building.shouldDisplay == null || building.shouldDisplay; // todo check if all async events are still needed (events that sends a new event as result, like 'venueData/buildingSelectorDataLoaded') app.bus.on('venueData/loadBuildingSelectorData', () => { return venueDataLoaded.then(async venueData => { // displayable buildings with levels list ordered by ordinal desc. const buildings = venueData.structures .filter(shouldDisplayPredicate) .map(R__namespace.evolve({ // creates copy of structure with modified levels levels: R__namespace.pipe(R__namespace.values, R__namespace.sortWith([R__namespace.descend(R__namespace.prop('ordinal'))])) })); // todo order buildings using structureOrder and selectorOrder // currently structures ordering is duplicated in level selectors handlers // then we can remove structureOrder and selectorOrder from result const result = { buildings, structureOrder: venueData.structureOrder, selectorOrder: venueData.selectorOrder }; app.bus.send('venueData/buildingSelectorDataLoaded', result); return result }) }); app.bus.on('venueData/normalizeCoords', ({ coords }) => venueDataLoaded.then(venueData => venueLoadingUtils.normalizeCoords(coords, venueData.bounds))); const noNavInfo = { edges: [], nodes: [] }; app.bus.on('venueData/loadNavGraph', async () => { return venueDataLoaded.then(async venueData => { const navGraphData = venueData.files.nav ? (await venueData.fetchJson(venueData.files.nav)) : noNavInfo; const navGraphOb = { navGraphData, structures: venueData.structures }; app.bus.send('venueData/navGraphLoaded', navGraphOb); return navGraphOb }) }); app.bus.on('venueData/loadPoiData', async () => { return venueDataLoaded.then(async venueData => { const poisUrl = config.useOldDataModel ? venueData.files.poisOld || venueData.files.pois : venueData.files.pois || venueData.files.poisOld; if (poisUrl) { const pois = await venueData.fetchJson(poisUrl); app.bus.send('venueData/poiDataLoaded', { pois, structures: venueData.structures }); } }) }); app.bus.on('venueData/getVenueCenter', async () => venueDataLoaded.then(async venueData => ( { lat: venueData.venueCenter[0], lng: venueData.venueCenter[1], ordinal: 0 }))); app.bus.on('venueData/getContentUrl', ({ type, name = '' }) => venueDataLoaded.then(venueData => venueData.files[type] + name)); app.bus.on('venueData/getFloorIdToNameMap', () => venueDataLoaded.then(R__namespace.pipe( R__namespace.prop('structures'), R__namespace.map(R__namespace.prop('levels')), // get levels for each structure R__namespace.chain(R__namespace.values), // flatten structures levels into single array R__namespace.map(R__namespace.props(['id', 'name'])), // create pairs [id, name] R__namespace.fromPairs // create map of 'id' to 'name' ))); app.bus.on('venueData/getFloorIdName', ({ floorId }) => { return venueDataLoaded.then(async venueData => { const structure = R__namespace.pipe(R__namespace.values, R__namespace.find(R__namespace.hasPath(['levels', floorId])))(venueData.structures); if (!structure) return null return { structureId: structure.id, structureName: structure.name, floorName: structure.levels[floorId].name } }) }); const getVenueDataProp = (propName, defaultValue) => () => venueDataLoaded.then(R__namespace.pipe(R__namespace.prop(propName), R__namespace.defaultTo(defaultValue))); app.bus.on('venueData/getVenueData', () => venueDataLoaded); app.bus.on('venueData/getVenueName', getVenueDataProp('name')); app.bus.on('venueData/getVenueCategory', getVenueDataProp('category')); app.bus.on('venueData/getVenueTimezone', getVenueDataProp('tz')); app.bus.on('venueData/getAccountId', () => config.accountId); app.bus.on('venueData/getVenueId', getVenueDataProp('id')); app.bus.on('venueData/getPositioningSupported', getVenueDataProp('positioningSupported')); app.bus.on('venueData/getStructures', getVenueDataProp('structures')); app.bus.on('venueData/loadNewVenue', async ({ venueId, accountId, assetStage = config.assetStage }) => { venueDataLoaded.reject(new Error('loadNewVenue called - previous loading ignored')); mapDataLoaded.reject(new Error('loadNewVenue called - previous loading ignored')); venueDataLoaded = new Zousan(); mapDataLoaded = new Zousan(); loadVenueData({ ...config, venueId, accountId, assetStage }, []) .then(notifyState); }); // returns a full URL to an image hosted on img.locuslabs.com, size has to be a string of format ${width}x${height} app.bus.on('venueData/getPoiImageUrl', ({ imageName, size }) => { return `https://img.locuslabs.com/resize/${config.accountId}/png/transparent/${size}contain/poi/${imageName}` }); // This is an utility function that returns a unique ID used to distinguish certain, installation/deployment specific parts // for now it uses venueId and accountId and is used to fix collision when storing data in localStorage app.bus.on('venueData/getDistributionId', () => { return venueDataLoaded.then(venueData => { return `${venueData.baseVenueId}-${config.accountId}` }) }); app.bus.on('venueData/getCustomKeywords', () => venueDataLoaded.then(venueData => { const searchUrl = config.useOldDataModel ? venueData.files.searchOld || venueData.files.search : venueData.files.search; return venueData.fetchJson(searchUrl) })); app.bus.on('venueData/isGrabEnabled', getVenueDataProp('enableGrab')); app.bus.on('venueData/getGrabPoiIds', getVenueDataProp('grabPoiIds', [])); app.bus.on('venueData/getAssetsTimestamp', getVenueDataProp('version')); app.bus.on('venueData/getTranslatedFloorId', async ({ floorId }) => venueDataLoaded.then(venueData => getTranslatedFloorId(floorId, venueData))); /** * Takes a floorId that may be from the same venue but localized differently * so it must be converted to this venue's floorId. FloorIds are of the form * <venueId>-<buildingId>-<levelId> where <venueId> has a 2-letter suffice from * its baseVenueId. * * e.g. dfw-building1-level1 -> dfwxx-building1-level1 * or dfwxx-building1-level1 -> dfw-building1-level1 * or dfwxx-building1-level1 -> dfwyy-building1-level1 * * @param {string} floorId - The floor ID to translate. * @param {Object} venueData - The venue data object containing the baseVenueId and structures. * @returns {string} - The translated floor ID that matches the venue's baseVenue */ function getTranslatedFloorId (floorId, venueData) { const allFloorIds = getAllFloorIds(venueData); if (allFloorIds.includes(floorId)) return floorId // already in this venue const buildingLevel = floorId.split('-').slice(1).join('-'); // get the building-level part of the floorId // find a floor in the current venueData that matches the specified floorId with just an adjustment to the venueId portion in floorId string construct return allFloorIds.find(fid => new RegExp(`${venueData.baseVenueId}.?.?-${buildingLevel}`).test(fid)) } // returns a list of all floor ids in the venue data const getAllFloorIds = venueData => venueData.structures.reduce((floors, s) => floors.concat(Object.keys(s.levels)), []); /** * Returns object with queue types (security and immigration lanes) that are present in the venue defined in venue data. * Also adds image id for lanes with images. * * @typedef QueueType * @property {boolean} default * @property {string} defaultText * @property {string} id * @property {string} imageId * * @typedef QueueTypes * @property {QueueType[]} SecurityLane - list of security categories * @property {QueueType[]} ImmigrationLane - list of immigration categories * * @return {QueueTypes} queueTypes */ app.bus.on('venueData/getQueueTypes', () => { return venueDataLoaded.then(venueData => { const lanesWithImages = ['tsapre', 'clear', 'globalEntry']; if (venueData.queueTypes) { return venueData.queueTypes.reduce((obj, category) => { const { id, subtypes } = category; const typesWithImages = subtypes.map(type => { const imageId = lanesWithImages.includes(type.id) && `security-logo-${type.id.toLowerCase()}`; return { ...type, imageId } }); obj[id] = typesWithImages; return obj }, {}) } else return {} }) }); const runTest = async ({ testRoutine, reset = false, venueData = null }) => { if (reset || venueData) { venueDataLoaded = new Zousan(); mapDataLoaded = new Zousan(); } if (venueData) venueDataLoaded = Zousan.resolve(venueData); await testRoutine(); let venueDataObj, mapDataObj; if (venueDataLoaded.v) venueDataObj = await venueDataLoaded; if (mapDataLoaded.v) mapDataObj = await mapDataLoaded; return { venueDataObj, mapDataObj } }; const init = async () => { const params = new URLSearchParams(typeof window === 'undefined' ? '' : window.location.search); const lang = params.get('lang'); // On first load there is no URL param for lang unless a customer has created one on purposed, in which case that languauge will be loaded in the UI and the map. If it is first load and no language is specified by the param, grab the browser's list of preferred languages. We will then try to display a map venue for their preferred languages in order if we have it. let languagesToTry = []; if (lang === null) { // So if it is first load languagesToTry = typeof window === 'undefined' ? [] : navigator.languages; // grab languages to use for alternate maps from the browser's list of preffered languages } const deepLinkProps = config.deepLinkProps || {}; if (deepLinkProps.lang) languagesToTry.unshift(deepLinkProps.lang); const venueId = deepLinkProps.vid || config.venueId; const assetStage = config.useDynamicUrlParams && deepLinkProps.stage ? deepLinkProps.stage : config.assetStage; const accountId = deepLinkProps.accountId || (assetStage === 'alpha' ? 'A1VPTJKREFJWX5' : config.accountId); loadVenueData({ ...config, venueId, accountId, assetStage }, languagesToTry) .then(notifyState); }; return { init, runTest, internal: { calculateBaseVenueId, getDefaultStructureId, getTranslatedFloorId, setConfigProperty: (key, value) => { config[key] = value; } } } } exports.create = create;