-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathMapControls.jsx
More file actions
364 lines (325 loc) · 17.1 KB
/
MapControls.jsx
File metadata and controls
364 lines (325 loc) · 17.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
import { useEffect, useRef, useState, useCallback, useMemo, memo } from 'react';
import PropTypes from 'prop-types';
import './MapControls.scss';
import { useIsDesktop } from '../../../hooks/useIsDesktop';
import CustomPositionProvider from '../../../utils/CustomPositionProvider';
import MapZoomControl from '../MapZoomControl/MapZoomControl';
import TextSizeButton from '../TextSizeButton/TextSizeButton';
import FullScreenButton from '../FullScreenButton/FullScreenButton';
// Define UI element configuration objects with class names
// This is a single source of truth for the UI elements and their class names
const UI_ELEMENTS = {
venueSelector: { key: 'venue-selector', className: 'venue-selector-portal' },
viewSelector: { key: 'view-selector', className: 'view-selector-portal' },
languageSelector: { key: 'language-selector', className: 'language-selector-portal' },
viewModeSwitch: { key: 'viewmode-switch', className: 'viewmode-switch-portal' },
myPosition: { key: 'my-position', className: 'my-position-element-portal' },
floorSelector: { key: 'floor-selector', className: 'floor-selector-portal' },
resetView: { key: 'reset-view', className: 'reset-view-portal' },
chatButton: { key: 'chat-button', className: 'chat-button-portal' },
zoomControls: { key: 'zoom-controls', className: null }, // React component imported directly, no portal needed
textSizeButton: { key: 'text-size-button', className: null }, // React component imported directly, no portal needed
fullScreenButton: { key: 'fullscreen-button', className: null } // React component imported directly, no portal needed
};
MapControls.propTypes = {
mapType: PropTypes.oneOf(['google', 'mapbox']).isRequired,
mapsIndoorsInstance: PropTypes.object.isRequired,
mapInstance: PropTypes.object.isRequired,
onPositionControl: PropTypes.func,
brandingColor: PropTypes.string,
devicePosition: PropTypes.object,
excludedElements: PropTypes.string,
isKiosk: PropTypes.bool,
enableAccessibilityKioskControls: PropTypes.bool,
enableFullScreenButton: PropTypes.bool
};
/**
* MapControls component manages the positioning and rendering of map control elements.
* It handles both desktop and mobile layouts, and manages creation of the web components for floor selection
* and position control.
*
* @param {Object} props - Component properties
* @param {'google'|'mapbox'} props.mapType - The type of map being used
* @param {Object} props.mapsIndoorsInstance - MapsIndoors SDK instance
* @param {Object} props.mapInstance - Map instance (Google Maps or Mapbox)
* @param {Function} [props.onPositionControl] - Callback function for position control events
* @param {string} [props.brandingColor] - Custom branding color for controls
* @param {Object} [props.devicePosition] - Device position data (if available)
* @param {string} [props.excludedElements] - Comma-separated string of element names to exclude from rendering, defaults to empty string -> renders all elements. Use "fullScreenButton" in excludeFromUI (App Config) to hide the fullscreen button.
* @param {boolean} [props.enableFullScreenButton] - Set to true to show the fullscreen button. Configurable via App Config enableFullScreenButton under appSettings. When false or unset, button is hidden.
* @param {boolean} [props.isKiosk] - Set to true to enable kiosk layout
* @param {boolean} [props.enableAccessibilityKioskControls] - Set to true to enable accessibility kiosk controls, defaults to false
*
* @returns {JSX.Element} Map controls container with venue selector, floor selector,
* position button, and view mode switch, arranged differently for desktop, kiosk, and mobile layouts
*/
function MapControls({ mapType, mapsIndoorsInstance, mapInstance, onPositionControl, brandingColor, devicePosition, excludedElements = '', isKiosk, enableAccessibilityKioskControls = false, enableFullScreenButton = false }) {
const isDesktop = useIsDesktop();
const floorSelectorRef = useRef(null);
const positionButtonRef = useRef(null);
const bottomControlsRef = useRef(null);
const overlapTimerRef = useRef(null);
const [isFloorSelectorExpanded, setIsFloorSelectorExpanded] = useState(false);
// Helper function to check if an element should be rendered
const shouldRenderElement = useCallback((elementName) => {
// Check if the element is in the excluded list
if (!excludedElements || typeof excludedElements !== 'string') {
return true;
}
// Split by comma and check for exact matches
const excludedList = excludedElements.split(',').map(item => item.trim());
return !excludedList.includes(elementName);
}, [excludedElements]);
// Create UI elements inside component based on the UI_ELEMENTS configuration object.
// This way we can easily manage the elements and their class names in one place, and we can also easily add new elements in the future if needed.
const uiElements = useMemo(() => {
const elements = {};
for (const [elementName, elementDetails] of Object.entries(UI_ELEMENTS)) {
elements[elementName] = <div key={elementDetails.key} className={elementDetails.className} />;
}
return elements;
}, []);
// Set position and handle floor changes.
// These are combined because floor should only change if position is successfully set.
const setPositionAndHandleFloor = (devicePosition) => {
// Set the position and start watching if successful
if (positionButtonRef.current.customPositionProvider.setPosition(devicePosition)) {
positionButtonRef.current.watchPosition();
}
};
// Create and configure web components
// This useEffect will run when the component mounts and when the mapsIndoorsInstance or mapInstance changes.
// It will create the web components and set their properties.
// The web components are created only once and reused when the component re-renders.
useEffect(() => {
if (!mapsIndoorsInstance || !mapInstance) return;
// Create the web components if they don't exist
if (!floorSelectorRef.current) {
const floorSelector = document.createElement('mi-floor-selector');
floorSelectorRef.current = floorSelector;
}
// Create the position button if it doesn't exist
if (!positionButtonRef.current) {
const positionButton = document.createElement('mi-my-position');
positionButtonRef.current = positionButton;
}
// Update properties of the floor selector and position button
floorSelectorRef.current.mapsindoors = mapsIndoorsInstance;
positionButtonRef.current.mapsindoors = mapsIndoorsInstance;
if (brandingColor) {
floorSelectorRef.current.primaryColor = brandingColor;
}
// Setup position control
if (onPositionControl && positionButtonRef.current) {
onPositionControl(positionButtonRef.current);
}
}, [mapType, mapsIndoorsInstance, mapInstance, onPositionControl, brandingColor]);
// Sync the custom position provider with the latest devicePosition prop.
// If devicePosition is provided, ensure the custom provider exists and update its position.
// This enables the position button to reflect the current device position.
useEffect(() => {
if (!positionButtonRef.current) return;
// Stop any existing position listeners before setting up new ones
if (positionButtonRef.current.stopListeningForPosition && typeof positionButtonRef.current.stopListeningForPosition === 'function') {
positionButtonRef.current.stopListeningForPosition();
}
if (devicePosition && typeof devicePosition === 'object') {
// Initialize the provider if it doesn't exist (for both empty objects and valid positions)
if (!positionButtonRef.current.customPositionProvider) {
positionButtonRef.current.customPositionProvider = new CustomPositionProvider();
}
// Handle empty object case - just initialize the provider as starting point
if (Object.keys(devicePosition).length === 0) {
// Don't call watchPosition() for empty objects - this keeps the icon in POSITION_UNKNOWN state
return;
}
// Set position and handle floor changes (works for both new and existing providers)
setPositionAndHandleFloor(devicePosition);
}
// Cleanup function to stop position listeners when devicePosition changes or component unmounts
return () => {
if (positionButtonRef.current &&
positionButtonRef.current.stopListeningForPosition &&
typeof positionButtonRef.current.stopListeningForPosition === 'function') {
positionButtonRef.current.stopListeningForPosition();
}
};
}, [devicePosition]);
/*
* Handle layout changes and element movement, this handles moving the elements to the correct DOM location based on the layout
* and ensures that the elements are not duplicated in the DOM.
* This is important for performance and to avoid issues with the web components.
* The useEffect will run when the component mounts and when the layout changes (isDesktop or isKiosk changes).
*/
useEffect(() => {
if (!floorSelectorRef.current || !positionButtonRef.current) return;
// Function to move elements to the target container
// This function will check if the element is already in the target container and move it if not.
const moveElementToTarget = (element, targetClass) => {
const target = document.querySelector(`.${targetClass}`);
if (target && !target.contains(element)) {
element.remove();
target.appendChild(element);
}
};
requestAnimationFrame(() => {
// Only move elements if their portals are visible
if (shouldRenderElement('floorSelector')) {
moveElementToTarget(floorSelectorRef.current, UI_ELEMENTS.floorSelector.className);
}
if (shouldRenderElement('myPosition')) {
moveElementToTarget(positionButtonRef.current, UI_ELEMENTS.myPosition.className);
}
});
}, [isDesktop, isKiosk, shouldRenderElement]); // Re-run when layout changes or visibility logic changes
// Handle visibility of portal elements based on excludedElements
useEffect(() => {
Object.entries(UI_ELEMENTS).forEach(([elementName, config]) => {
// Skip elements without a portal (e.g., React components like zoomControls)
if (!config.className) return;
const portal = document.querySelector(`.${config.className}`);
if (portal) {
const shouldShow = shouldRenderElement(elementName);
if (shouldShow) {
portal.style.display = '';
} else {
portal.style.display = 'none';
}
}
});
}, [excludedElements, shouldRenderElement, isDesktop]);
// Observe the floor selector's toggle button class to detect expansion,
// then check whether it actually overlaps the bottom controls before hiding them.
useEffect(() => {
const floorSelector = floorSelectorRef.current;
if (!floorSelector) return;
const checkOverlap = () => {
if (overlapTimerRef.current) {
clearTimeout(overlapTimerRef.current);
}
const button = floorSelector.querySelector('.mi-floor-selector__button');
if (!button) return;
const isOpen = button.classList.contains('mi-floor-selector__button--open');
if (!isOpen) {
setIsFloorSelectorExpanded(false);
return;
}
// Wait for the list expansion animation (50ms) to finish before measuring
overlapTimerRef.current = setTimeout(() => {
const bottomControls = bottomControlsRef.current;
if (!bottomControls) {
setIsFloorSelectorExpanded(false);
return;
}
const selectorEl = floorSelector.querySelector('.mi-floor-selector') || floorSelector;
const selectorRect = selectorEl.getBoundingClientRect();
const controlsRect = bottomControls.getBoundingClientRect();
setIsFloorSelectorExpanded(selectorRect.bottom > controlsRect.top);
}, 50);
};
const observer = new MutationObserver(checkOverlap);
observer.observe(floorSelector, {
subtree: true,
attributes: true,
attributeFilter: ['class']
});
return () => {
observer.disconnect();
if (overlapTimerRef.current) {
clearTimeout(overlapTimerRef.current);
}
};
}, [mapsIndoorsInstance, mapInstance]);
if (isKiosk) {
if (enableAccessibilityKioskControls) {
return (
<>
{/* Top right kiosk controls */}
<div className="map-controls-container kiosk top-right">
</div>
{/* Bottom right kiosk controls */}
<div className="map-controls-container kiosk bottom-right">
{uiElements.venueSelector}
{uiElements.viewSelector}
{uiElements.myPosition}
{shouldRenderElement('zoomControls') && (
<MapZoomControl mapType={mapType} mapInstance={mapInstance} />
)}
{uiElements.languageSelector}
{shouldRenderElement('textSizeButton') && (
<TextSizeButton mapsIndoorsInstance={mapsIndoorsInstance} />
)}
{uiElements.viewModeSwitch}
{uiElements.floorSelector}
{uiElements.resetView}
</div>
</>
);
} else {
{/* For kiosk layout, render the controls in bottom right */ }
{/* If enableAccessibilityKioskControls is false, render the default kiosk layout */ }
return (
<>
{/* Top right kiosk controls */}
<div className="map-controls-container kiosk top-right">
{uiElements.venueSelector}
{uiElements.viewSelector}
{uiElements.languageSelector}
{uiElements.viewModeSwitch}
{uiElements.myPosition}
{uiElements.floorSelector}
</div>
{/* Bottom right kiosk controls */}
<div className="map-controls-container kiosk bottom-right">
{uiElements.resetView}
</div>
</>
);
}
} else if (isDesktop) {
{/* For desktop layout, render the controls in the correct container based on the layout */ }
return (
<>
{/* Top right desktop controls */}
<div className="map-controls-container desktop top-right">
{uiElements.venueSelector}
{uiElements.viewSelector}
{uiElements.languageSelector}
{uiElements.viewModeSwitch}
{uiElements.myPosition}
{uiElements.chatButton}
{uiElements.floorSelector}
</div>
{/* Bottom right desktop controls */}
<div ref={bottomControlsRef} className={`map-controls-container desktop bottom-right ${isFloorSelectorExpanded ? 'map-controls-container--floor-selector-open' : ''}`}>
{shouldRenderElement('zoomControls') && (
<MapZoomControl mapType={mapType} mapInstance={mapInstance} />
)}
{enableFullScreenButton && shouldRenderElement('fullScreenButton') && <FullScreenButton />}
{uiElements.resetView}
</div>
</>
);
} else {
{/* For mobile layout, we split controls into two columns */ }
return (
<>
<div className="map-controls-left-column mobile-column">
{uiElements.venueSelector}
{uiElements.viewModeSwitch}
{uiElements.viewSelector}
{uiElements.languageSelector}
</div>
<div className="map-controls-right-column mobile-column">
{uiElements.myPosition}
{uiElements.floorSelector}
</div>
<div className="map-controls-right-bottom mobile-column">
{uiElements.chatButton}
</div>
</>
);
}
}
// Memoize the component to prevent unnecessary re-renders when props haven't changed
export default memo(MapControls);