diff --git a/package-lock.json b/package-lock.json index b1a8116c..8fdbdcf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21465,7 +21465,7 @@ }, "packages/map-template": { "name": "@mapsindoors/map-template", - "version": "1.66.2", + "version": "1.66.3", "devDependencies": { "@googlemaps/js-api-loader": "^1.15.1", "@mapsindoors/components": "*", diff --git a/packages/map-template/releaseTools/webcomponent.js b/packages/map-template/releaseTools/webcomponent.js index d467a79f..a01fa9b4 100644 --- a/packages/map-template/releaseTools/webcomponent.js +++ b/packages/map-template/releaseTools/webcomponent.js @@ -35,7 +35,8 @@ const WebMapsIndoorsMap = r2wc(MapsIndoorsMap, { hideNonMatches: "boolean", showExternalIDs: "boolean", showRoadNames: "boolean", - searchExternalLocations: "boolean" + searchExternalLocations: "boolean", + center: "string" } }) diff --git a/packages/map-template/src/atoms/centerState.js b/packages/map-template/src/atoms/centerState.js new file mode 100644 index 00000000..caeb7a16 --- /dev/null +++ b/packages/map-template/src/atoms/centerState.js @@ -0,0 +1,8 @@ +import { atom } from 'recoil'; + +const centerState = atom({ + key: 'center', + default: null +}); + +export default centerState; \ No newline at end of file diff --git a/packages/map-template/src/components/MapTemplate/MapTemplate.jsx b/packages/map-template/src/components/MapTemplate/MapTemplate.jsx index 3c78e9e8..abb51478 100644 --- a/packages/map-template/src/components/MapTemplate/MapTemplate.jsx +++ b/packages/map-template/src/components/MapTemplate/MapTemplate.jsx @@ -57,6 +57,7 @@ import showExternalIDsState from '../../atoms/showExternalIDsState.js' import showRoadNamesState from '../../atoms/showRoadNamesState.js'; import searchExternalLocationsState from '../../atoms/searchExternalLocationsState.js'; import isNullOrUndefined from '../../helpers/isNullOrUndefined.js'; +import centerState from '../../atoms/centerState.js'; // Define the Custom Elements from our components package. defineCustomElements(); @@ -92,8 +93,9 @@ defineCustomElements(); * @param {boolean} [props.showRoadNames] - A boolean parameter that dictates whether Mapbox road names should be shown. By default, Mapbox road names are hidden when MapsIndoors data is shown. It is dictated by `mi-transition-level` which default value is 17. * @param {boolean} [props.showExternalIDs] - Determine whether the location details on the map should have an external ID visible. The default value is set to false. * @param {boolean} [props.searchExternalLocations] - If you want to perform search for external locations in the Wayfinding mode. If set to true, Mapbox/Google places will be displayed depending on the Map Provider you are using. If set to false, the results returned will only be MapsIndoors results. The default is true. + * @param {string} [props.center] - Specifies the coordinates where the map should load, represented as latitude and longitude values separated by a comma. If the specified coordinates intersect with a Venue, that Venue will be set as the current Venue. */ -function MapTemplate({ apiKey, gmApiKey, mapboxAccessToken, venue, locationId, primaryColor, logo, appUserRoles, directionsFrom, directionsTo, externalIDs, tileStyle, startZoomLevel, bearing, pitch, gmMapId, useMapProviderModule, kioskOriginLocationId, language, supportsUrlParameters, useKeyboard, timeout, miTransitionLevel, category, searchAllVenues, hideNonMatches, showRoadNames, showExternalIDs, searchExternalLocations }) { +function MapTemplate({ apiKey, gmApiKey, mapboxAccessToken, venue, locationId, primaryColor, logo, appUserRoles, directionsFrom, directionsTo, externalIDs, tileStyle, startZoomLevel, bearing, pitch, gmMapId, useMapProviderModule, kioskOriginLocationId, language, supportsUrlParameters, useKeyboard, timeout, miTransitionLevel, category, searchAllVenues, hideNonMatches, showRoadNames, showExternalIDs, searchExternalLocations, center }) { const [, setApiKey] = useRecoilState(apiKeyState); const [, setGmApiKey] = useRecoilState(gmApiKeyState); @@ -122,6 +124,7 @@ function MapTemplate({ apiKey, gmApiKey, mapboxAccessToken, venue, locationId, p const [, setshowExternalIDs] = useRecoilState(showExternalIDsState); const [, setShowRoadNames] = useRecoilState(showRoadNamesState); const [, setSearchExternalLocations] = useRecoilState(searchExternalLocationsState); + const [, setCenter] = useRecoilState(centerState); const [viewModeSwitchVisible, setViewModeSwitchVisible] = useState(); const [showVenueSelector, setShowVenueSelector] = useState(true); @@ -553,6 +556,13 @@ function MapTemplate({ apiKey, gmApiKey, mapboxAccessToken, venue, locationId, p setSearchExternalLocations(searchExternalLocations); }, [searchExternalLocations]); + /* + * React on changes to the center prop. + */ + useEffect(() => { + setCenter(center); + }, [center]); + /** * When map position is known while initializing the data, * set map to be ready. diff --git a/packages/map-template/src/components/MapsIndoorsMap/MapsIndoorsMap.jsx b/packages/map-template/src/components/MapsIndoorsMap/MapsIndoorsMap.jsx index 34ba972b..387a0915 100644 --- a/packages/map-template/src/components/MapsIndoorsMap/MapsIndoorsMap.jsx +++ b/packages/map-template/src/components/MapsIndoorsMap/MapsIndoorsMap.jsx @@ -36,6 +36,7 @@ import getBooleanValue from "../../helpers/GetBooleanValue.js"; * @param {boolean} [props.showExternalIDs] - Determine whether the location details on the map should have an external ID visible. The default value is set to false. * @param {boolean} [props.showRoadNames] - A boolean parameter that dictates whether Mapbox road names should be shown. By default, Mapbox road names are hidden when MapsIndoors data is shown. It is dictated by `mi-transition-level` which default value is 17. * @param {boolean} [props.searchExternalLocations] - If you want to perform search for external results in the Wayfinding mode. If set to true, Mapbox/Google places will be displayed depending on the Map Provider you are using. If set to false, the results returned will only be MapsIndoors results. The default is true. + * @param {string} [props.center] - Specifies the coordinates where the map should load, represented as latitude and longitude values separated by a comma. If the specified coordinates intersect with a Venue, that Venue will be set as the current Venue. */ function MapsIndoorsMap(props) { @@ -92,9 +93,10 @@ function MapsIndoorsMap(props) { const showExternalIDsQueryParameter = queryStringParams.get('showExternalIDs'); const showRoadNamesQueryParameter = queryStringParams.get('showRoadNames'); const searchExternalLocationsQueryParameter = queryStringParams.get('searchExternalLocations'); + const centerQueryParameter = queryStringParams.get('center'); // Set the initial props on the Map Template component. - + // For the apiKey and venue, set the venue to "AUSTINOFFICE" if the apiKey is "mapspeople3d" and no venue is provided. We want this as the default venue for the "mapspeople3d" apiKey. const apiKey = props.supportsUrlParameters && apiKeyQueryParameter ? apiKeyQueryParameter : (props.apiKey || defaultProps.apiKey); let venue = props.supportsUrlParameters && venueQueryParameter ? venueQueryParameter : (props.venue || defaultProps.venue); @@ -124,6 +126,7 @@ function MapsIndoorsMap(props) { language: props.supportsUrlParameters && languageQueryParameter ? languageQueryParameter : props.language, miTransitionLevel: props.supportsUrlParameters && miTransitionLevelQueryParameter ? miTransitionLevelQueryParameter : props.miTransitionLevel, category: props.supportsUrlParameters && categoryQueryParameter ? categoryQueryParameter : props.category, + center: props.supportsUrlParameters && centerQueryParameter ? centerQueryParameter : props.center, // Handle boolean values useKeyboard: getBooleanValue(props.supportsUrlParameters, defaultProps.useKeyboard, props.useKeyboard, useKeyboardQueryParameter), useMapProviderModule: getBooleanValue(props.supportsUrlParameters, defaultProps.useMapProviderModule, props.useMapProviderModule, useMapProviderModuleQueryParameter), @@ -134,6 +137,7 @@ function MapsIndoorsMap(props) { searchExternalLocations: getBooleanValue(props.supportsUrlParameters, defaultProps.searchExternalLocations, props.searchExternalLocations, searchExternalLocationsQueryParameter), supportsUrlParameters: props.supportsUrlParameters, }); + }, [props]); return ( diff --git a/packages/map-template/src/hooks/useCurrentVenue.js b/packages/map-template/src/hooks/useCurrentVenue.js index 8139e4f8..d95f99b2 100644 --- a/packages/map-template/src/hooks/useCurrentVenue.js +++ b/packages/map-template/src/hooks/useCurrentVenue.js @@ -92,10 +92,12 @@ export const useCurrentVenue = () => { for (const key of keys) { // Get the categories from the App Config that have a matching key. - const appConfigCategory = appConfig?.menuInfo.mainmenu.find(category => category.categoryKey === key); + if (appConfig?.menuInfo?.mainmenu) { + const appConfigCategory = appConfig.menuInfo.mainmenu.find(category => category.categoryKey === key); - if (appConfigCategory) { - uniqueCategories.set(appConfigCategory.categoryKey, { displayName: location.properties.categories[key], iconUrl: appConfigCategory?.iconUrl }) + if (appConfigCategory) { + uniqueCategories.set(appConfigCategory.categoryKey, { displayName: location.properties.categories[key], iconUrl: appConfigCategory?.iconUrl }) + } } } } diff --git a/packages/map-template/src/hooks/useMapBoundsDeterminer.js b/packages/map-template/src/hooks/useMapBoundsDeterminer.js index d0aa8939..6b9c34c0 100644 --- a/packages/map-template/src/hooks/useMapBoundsDeterminer.js +++ b/packages/map-template/src/hooks/useMapBoundsDeterminer.js @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { useRecoilValue } from 'recoil'; +import { useRecoilValue, useRecoilState } from 'recoil'; import getDesktopPaddingBottom from '../helpers/GetDesktopPaddingBottom'; // Recoil atoms @@ -13,6 +13,7 @@ import startZoomLevelState from '../atoms/startZoomLevelState'; import currentVenueNameState from '../atoms/currentVenueNameState'; import venuesInSolutionState from '../atoms/venuesInSolutionState'; import venueWasSelectedState from '../atoms/venueWasSelectedState'; +import isMapReadyState from '../atoms/isMapReadyState.js'; // Hooks import getMobilePaddingBottom from '../helpers/GetMobilePaddingBottom'; @@ -22,6 +23,11 @@ import { useIsDesktop } from './useIsDesktop'; // Selectors import currentPitchSelector from '../selectors/currentPitch'; +import centerState from '../atoms/centerState'; +import isNullOrUndefined from '../helpers/isNullOrUndefined'; + +// Turf +import * as turf from '@turf/turf'; /** * Determine where in the world to pan the map, based on the combination of venueName, locationId and kioskOriginLocationId. @@ -44,11 +50,13 @@ const useMapBoundsDeterminer = () => { const mapsIndoorsInstance = useRecoilValue(mapsIndoorsInstanceState); const pitch = useRecoilValue(pitchState); const startZoomLevel = useRecoilValue(startZoomLevelState); - const currentVenueName = useRecoilValue(currentVenueNameState); const venuesInSolution = useRecoilValue(venuesInSolutionState); const currentPitch = useRecoilValue(currentPitchSelector); const venueWasSelected = useRecoilValue(venueWasSelectedState); const [kioskLocationDisplayRuleWasChanged, setKioskLocationDisplayRuleWasChanged] = useState(false); + const [currentVenueName, setCurrentVenueName] = useRecoilState(currentVenueNameState); + const isMapReady = useRecoilState(isMapReadyState); + const [center, setCenter] = useRecoilState(centerState); /** * If the app is inactive, run code to reset to initial map position. @@ -62,9 +70,9 @@ const useMapBoundsDeterminer = () => { /* * When relevant state changes, run code to go to a location in the world. */ - useEffect(() => { + useEffect(() => { determineMapBounds(); - }, [mapsIndoorsInstance, currentVenueName, locationId, kioskOriginLocationId, pitch, bearing, startZoomLevel, categories]); + }, [mapsIndoorsInstance, currentVenueName, locationId, kioskOriginLocationId, pitch, bearing, startZoomLevel, categories, center]); /** * Based on the combination of the states for venueName, locationId & kioskOriginLocationId, @@ -72,51 +80,127 @@ const useMapBoundsDeterminer = () => { */ function determineMapBounds() { const currentVenue = venuesInSolution.find(venue => venue.name.toLowerCase() === currentVenueName.toLowerCase()); + if (mapsIndoorsInstance && currentVenue) { setMapPositionInvestigating(true); if (kioskOriginLocationId && isDesktop) { - // When in Kiosk mode (which can only happen on desktop), the map is fitted to the bounds of the given Location with some bottom padding to accommodate - // for the bottom-centered modal. - window.mapsindoors.services.LocationsService.getLocation(kioskOriginLocationId).then(kioskLocation => { - if (kioskLocation) { - // Set the floor to the one that the Location belongs to. - const locationFloor = kioskLocation.properties.floor; - mapsIndoorsInstance.setFloor(locationFloor); - setKioskDisplayRule(kioskLocation); - - getDesktopPaddingBottom().then(desktopPaddingBottom => { - setMapPositionKnown(kioskLocation.geometry); - goTo(kioskLocation.geometry, mapsIndoorsInstance, desktopPaddingBottom, 0, startZoomLevel, currentPitch, bearing); + if (!isNullOrUndefined(center)) { + // When in Kiosk mode and center prop is defined, set centerPoint to be center prop. + getDesktopPaddingBottom().then(desktopPaddingBottom => { + setMapPositionKnown(getCenterPoint().geometry); + goTo(getCenterPoint().geometry, mapsIndoorsInstance, desktopPaddingBottom, 0, getZoomLevel(startZoomLevel), currentPitch, bearing); + }); + } else { + // When in Kiosk mode (which can only happen on desktop), the map is fitted to the bounds of the given Location with some bottom padding to accommodate + // for the bottom-centered modal. + window.mapsindoors.services.LocationsService.getLocation(kioskOriginLocationId).then(kioskLocation => { + if (kioskLocation) { + // Set the floor to the one that the Location belongs to. + const locationFloor = kioskLocation.properties.floor; + mapsIndoorsInstance.setFloor(locationFloor); + setKioskDisplayRule(kioskLocation); + + getDesktopPaddingBottom().then(desktopPaddingBottom => { + setMapPositionKnown(kioskLocation.geometry); + goTo(kioskLocation.geometry, mapsIndoorsInstance, desktopPaddingBottom, 0, getZoomLevel(startZoomLevel), currentPitch, bearing); + }); + } + }); + } + } else if (locationId && !venueWasSelected) { + if (!isNullOrUndefined(center)) { + // When locationId is defined and center prop is defined, set centerPoint to be center prop. + if (isDesktop) { + getDesktopPaddingLeft().then(desktopPaddingLeft => { + setMapPositionKnown(getCenterPoint().geometry); + goTo(getCenterPoint().geometry, mapsIndoorsInstance, 0, desktopPaddingLeft, getZoomLevel(startZoomLevel), currentPitch, bearing); + }); + } else { + getMobilePaddingBottom().then(mobilePaddingBottom => { + setMapPositionKnown(getCenterPoint().geometry); + goTo(getCenterPoint().geometry, mapsIndoorsInstance, mobilePaddingBottom, 0, getZoomLevel(startZoomLevel), currentPitch, bearing); }); } - }); - } else if (locationId && !venueWasSelected) { - // When a LocationID is set, the map is centered fitted to the bounds of the given Location with some padding, - // either bottom (on mobile to accommodate for the bottom sheet) or to the left (on desktop to accommodate for the modal). - window.mapsindoors.services.LocationsService.getLocation(locationId).then(location => { - if (location) { - // Set the floor to the one that the Location belongs to. - const locationFloor = location.properties.floor; - mapsIndoorsInstance.setFloor(locationFloor); + } else { + // When a LocationID is set, the map is centered fitted to the bounds of the given Location with some padding, + // either bottom (on mobile to accommodate for the bottom sheet) or to the left (on desktop to accommodate for the modal). + window.mapsindoors.services.LocationsService.getLocation(locationId).then(location => { + if (location) { + // Set the floor to the one that the Location belongs to. + const locationFloor = location.properties.floor; + mapsIndoorsInstance.setFloor(locationFloor); + if (isDesktop) { + getDesktopPaddingLeft().then(desktopPaddingLeft => { + setMapPositionKnown(location.geometry); + goTo(location.geometry, mapsIndoorsInstance, 0, desktopPaddingLeft, getZoomLevel(startZoomLevel), currentPitch, bearing); + }); + } else { + getMobilePaddingBottom().then(mobilePaddingBottom => { + setMapPositionKnown(location.geometry); + goTo(location.geometry, mapsIndoorsInstance, mobilePaddingBottom, 0, getZoomLevel(startZoomLevel), currentPitch, bearing); + }); + } + } + }); + } + } else if (currentVenue) { + if (venueWasSelected) { + if (isDesktop) { + getDesktopPaddingLeft().then(desktopPaddingLeft => { + setMapPositionKnown(currentVenue.geometry); + goTo(currentVenue.geometry, mapsIndoorsInstance, 0, desktopPaddingLeft, getZoomLevel(startZoomLevel), currentPitch, bearing); + }); + } else { + getMobilePaddingBottom().then(mobilePaddingBottom => { + setMapPositionKnown(currentVenue.geometry); + goTo(currentVenue.geometry, mapsIndoorsInstance, mobilePaddingBottom, 0, getZoomLevel(startZoomLevel), currentPitch, bearing); + }); + } + } else { + // Returns Venue that intersects with center prop. + const intersectingVenueWithCenterPoint = venuesInSolution.find(venue => { + return turf.booleanIntersects(venue.geometry, getCenterPoint().geometry); + }); + + if (isNullOrUndefined(center)) { + // If center prop is not defined, pan to currentVenue. + setMapPositionKnown(currentVenue.geometry); + goTo(currentVenue.geometry, mapsIndoorsInstance, 0, 0, getZoomLevel(startZoomLevel), currentPitch, bearing); + } else if (isNullOrUndefined(intersectingVenueWithCenterPoint)) { + // If center prop is defined, but it does not intersects with any Venue, pan to value that is defined by center prop. + if (isDesktop) { + getDesktopPaddingLeft().then(desktopPaddingLeft => { + setMapPositionKnown(getCenterPoint().geometry); + goTo(getCenterPoint().geometry, mapsIndoorsInstance, 0, desktopPaddingLeft, getZoomLevel(startZoomLevel), currentPitch, bearing); + }); + } else { + getMobilePaddingBottom().then(mobilePaddingBottom => { + setMapPositionKnown(getCenterPoint().geometry); + goTo(getCenterPoint().geometry, mapsIndoorsInstance, mobilePaddingBottom, 0, getZoomLevel(startZoomLevel), currentPitch, bearing); + }); + } + } else { + // If center prop is defined and it does intersects with a Venue, pan to value that is defined by center prop and + // when map is ready, setCurrentVenueName to the Venue that center prop is intersecting with. if (isDesktop) { getDesktopPaddingLeft().then(desktopPaddingLeft => { - setMapPositionKnown(location.geometry); - goTo(location.geometry, mapsIndoorsInstance, 0, desktopPaddingLeft, startZoomLevel, currentPitch, bearing); + setMapPositionKnown(getCenterPoint().geometry); + goTo(getCenterPoint().geometry, mapsIndoorsInstance, 0, desktopPaddingLeft, getZoomLevel(startZoomLevel), currentPitch, bearing); }); } else { getMobilePaddingBottom().then(mobilePaddingBottom => { - setMapPositionKnown(location.geometry); - goTo(location.geometry, mapsIndoorsInstance, mobilePaddingBottom, 0, startZoomLevel, currentPitch, bearing); + setMapPositionKnown(getCenterPoint().geometry); + goTo(getCenterPoint().geometry, mapsIndoorsInstance, mobilePaddingBottom, 0, getZoomLevel(startZoomLevel), currentPitch, bearing); }); } + + if (isMapReady) { + setCurrentVenueName(intersectingVenueWithCenterPoint.name); + } } - }); - } else if (currentVenue) { - // When showing a venue, the map is fitted to the bounds of the Venue with no padding. - setMapPositionKnown(currentVenue.geometry); - goTo(currentVenue.geometry, mapsIndoorsInstance, 0, 0, startZoomLevel, currentPitch, bearing); + } } } } @@ -142,6 +226,35 @@ const useMapBoundsDeterminer = () => { setKioskLocationDisplayRuleWasChanged(true); } + /** + * Gets center point GeoJSON object based on latitude and longitude. + * If such a point does not exists, undefined is returned. + * + * @param {number} latitude + * @param {number} longitude + * @returns {GeoJSON.Point} + */ + function getCenterPoint() { + // Parse center prop into coordinates. If it is not included in the URL, latLng are undefined. + const [latitude, longitude] = center + ? center.split(",").map(Number) + : [undefined, undefined]; + + const centerPoint = { geometry: { type: 'Point', coordinates: [latitude, longitude] } }; + return centerPoint; + } + + /** + * Returns startZoomLevel if it is defined. Otherwise it returns default zoom level + * + * @param {number} startZoomLevel + * @returns {number} + */ + function getZoomLevel(startZoomLevel) { + const defaultZoomLevel = 18; + return isNullOrUndefined(startZoomLevel) ? defaultZoomLevel : startZoomLevel; + } + return [mapPositionInvestigating, mapPositionKnown]; }; @@ -161,7 +274,7 @@ export default useMapBoundsDeterminer; function goTo(geometry, mapsIndoorsInstance, paddingBottom, paddingLeft, zoomLevel, pitch, bearing) { mapsIndoorsInstance.getMapView().tilt(pitch || 0); mapsIndoorsInstance.getMapView().rotate(bearing || 0); - mapsIndoorsInstance.goTo({ type: 'Feature', geometry, properties: {}}, { + mapsIndoorsInstance.goTo({ type: 'Feature', geometry, properties: {} }, { maxZoom: zoomLevel ?? 22, padding: { top: 0, right: 0, bottom: paddingBottom, left: paddingLeft }, }).then(() => { @@ -169,4 +282,4 @@ function goTo(geometry, mapsIndoorsInstance, paddingBottom, paddingLeft, zoomLev mapsIndoorsInstance.setZoom(zoomLevel); } }); -} +} \ No newline at end of file