Skip to content

Commit 2b13636

Browse files
aleksprogergithub-actions[bot]
authored andcommitted
Make indoor control UI trivial (internal-9489)
GitOrigin-RevId: 6c5fa581083a5ab5f10b794937f7da7b1023324d
1 parent f211ebf commit 2b13636

3 files changed

Lines changed: 111 additions & 71 deletions

File tree

src/style/indoor_manager.ts

Lines changed: 9 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,27 @@
11
import {bindAll} from '../util/util';
22
import {Event, Evented} from '../util/evented';
33
import LngLat from '../geo/lng_lat';
4-
import IndoorControl from '../ui/control/indoor_control';
54

65
import type {IndoorBuilding, IndoorData, IndoorEvents, IndoorState, IndoorTileOptions} from './indoor_data';
76
import type Style from './style';
87
import type {LngLatBounds} from '../geo/lng_lat';
9-
108
export default class IndoorManager extends Evented<IndoorEvents> {
119
_style: Style;
1210
_selectedFloorId: string | null;
1311
_closestBuildingId: string | null;
1412
_buildings: Record<string, IndoorBuilding> | null;
15-
_indoorControl: IndoorControl | null;
16-
1713
_activeFloors: Set<string> | null;
1814
_indoorState: IndoorState | null;
1915
_activeFloorsVisible: boolean;
20-
2116
constructor(style: Style) {
2217
super();
2318
this._style = style;
2419
this._buildings = {};
25-
this._indoorControl = null;
20+
2621
this._activeFloors = new Set();
2722
this._activeFloorsVisible = true;
2823
this._indoorState = {selectedFloorId: null, lastActiveFloors: null, activeFloorsVisible: true};
2924
bindAll(['_updateUI'], this);
30-
3125
const setupObservers = () => {
3226
if (this._style.isIndoorEnabled()) {
3327
this._style.map.on('load', this._updateUI);
@@ -36,33 +30,26 @@ export default class IndoorManager extends Evented<IndoorEvents> {
3630
this._updateUI();
3731
}
3832
};
39-
4033
this._style.on('style.load', setupObservers);
4134
}
42-
4335
destroy() {
4436
this._buildings = {};
4537
this._activeFloors = new Set();
4638
this._indoorState = null;
47-
this._removeIndoorControl();
4839
}
49-
5040
selectFloor(floorId: string | null) {
5141
if (floorId === this._selectedFloorId && this._activeFloorsVisible) {
5242
return;
5343
}
54-
5544
this._selectedFloorId = floorId;
5645
this._activeFloorsVisible = true;
5746
this._updateActiveFloors();
5847
}
59-
6048
setActiveFloorsVisibility(activeFloorsVisible: boolean) {
6149
this._activeFloorsVisible = activeFloorsVisible;
6250
this._updateActiveFloors();
6351
this._updateIndoorSelector();
6452
}
65-
6653
/// Fan in data sent from different tiles and sources and merge it into the one state
6754
/// Both buildings and active floors updates are additive-only
6855
setIndoorData(indoorData: IndoorData) {
@@ -78,42 +65,22 @@ export default class IndoorManager extends Evented<IndoorEvents> {
7865
this._buildings[id] = building;
7966
}
8067
}
81-
8268
for (const floorId of indoorData.activeFloors) {
8369
this._activeFloors.add(floorId);
8470
}
85-
8671
// Next step: Add debouncing to prevent excessive selector updates, though not visible
8772
this._updateIndoorSelector();
8873
}
89-
9074
getIndoorTileOptions(sourceId: string, scope: string): IndoorTileOptions | null {
9175
const sourceLayers = this._style.getIndoorSourceLayers(sourceId, scope);
9276
if (!sourceLayers || !this._indoorState) {
9377
return null;
9478
}
95-
9679
return {
9780
sourceLayers,
9881
indoorState: this._indoorState
9982
};
10083
}
101-
102-
_addIndoorControl() {
103-
if (!this._indoorControl) {
104-
this._indoorControl = new IndoorControl();
105-
this._style.map.addControl(this._indoorControl, 'right');
106-
}
107-
}
108-
109-
_removeIndoorControl() {
110-
if (!this._indoorControl) {
111-
return;
112-
}
113-
this._indoorControl.onRemove();
114-
this._indoorControl = null;
115-
}
116-
11784
_updateUI() {
11885
const transform = this._style.map.transform;
11986
const closestBuildingId = findClosestBuildingId(this._buildings, transform.center, transform.getBounds(), transform.zoom);
@@ -122,31 +89,24 @@ export default class IndoorManager extends Evented<IndoorEvents> {
12289
this._updateIndoorSelector();
12390
}
12491
}
125-
126-
_updateIndoorSelector() {
92+
getControlState() {
12793
const buildings = this._buildings;
12894
const closestBuildingId = this._closestBuildingId;
12995
const closestBuilding = (closestBuildingId && buildings) ? buildings[closestBuildingId] : undefined;
130-
13196
if (!closestBuilding) {
132-
this._removeIndoorControl();
133-
this.fire(new Event('selector-update', {
97+
return {
13498
selectedFloorId: null,
13599
activeFloorsVisible: this._activeFloorsVisible,
136100
floors: []
137-
}));
138-
return;
101+
};
139102
}
140-
141-
this._addIndoorControl();
142103
let buildingActiveFloorId: string | null = null;
143104
for (const floorId of closestBuilding.floorIds) {
144105
if (this._activeFloors && this._activeFloors.has(floorId)) {
145106
buildingActiveFloorId = floorId;
146107
break;
147108
}
148109
}
149-
150110
// Dedupe floors by zIndex before sending to the control:
151111
// we should only show one floor per zIndex for a given building.
152112
const floors = Array.from(closestBuilding.floorIds)
@@ -157,14 +117,15 @@ export default class IndoorManager extends Evented<IndoorEvents> {
157117
}))
158118
.sort((a, b) => b.zIndex - a.zIndex)
159119
.filter((floor, idx, arr) => idx === 0 || floor.zIndex !== arr[idx - 1].zIndex);
160-
161-
this.fire(new Event('selector-update', {
120+
return {
162121
selectedFloorId: buildingActiveFloorId,
163122
activeFloorsVisible: this._activeFloorsVisible,
164123
floors
165-
}));
124+
};
125+
}
126+
_updateIndoorSelector() {
127+
this.fire(new Event('selector-update', this.getControlState()));
166128
}
167-
168129
// Update previous state and pass it to tiles
169130
// Resolve active floors to construct new set based on data from tiles
170131
// Tiles will resolve individual subsets of activeFloors and send it in setIndoorData
@@ -175,16 +136,13 @@ export default class IndoorManager extends Evented<IndoorEvents> {
175136
this._style.updateIndoorDependentLayers();
176137
}
177138
}
178-
179139
function findClosestBuildingId(buildings: Record<string, IndoorBuilding>, mapCenter: LngLat, mapBounds: LngLatBounds, zoom: number): string | null {
180140
const indoorMinimumZoom: number = 16.0;
181141
let closestBuildingId: string | null = null;
182142
let minDistance = Number.MAX_SAFE_INTEGER;
183-
184143
if (zoom < indoorMinimumZoom) {
185144
return null;
186145
}
187-
188146
for (const [id, building] of Object.entries(buildings)) {
189147
const buildingCenter = building.center;
190148
if (buildingCenter) {
@@ -195,6 +153,5 @@ function findClosestBuildingId(buildings: Record<string, IndoorBuilding>, mapCen
195153
}
196154
}
197155
}
198-
199156
return closestBuildingId;
200157
}

src/ui/control/indoor_control.ts

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,31 @@ class IndoorControl implements IControl {
2020
_model: IndoorControlModel | null;
2121

2222
constructor() {
23-
bindAll(['_onIndoorUpdate'], this);
23+
bindAll(['_onIndoorUpdate', '_onStyleData'], this);
2424
}
2525

2626
onAdd(map: Map): HTMLElement {
2727
this._map = map;
2828
this._container = DOM.create('div', 'mapboxgl-ctrl mapboxgl-ctrl-group');
29-
if (this._map.style) {
30-
this._map.style.indoorManager.on('selector-update', (controlModel: IndoorControlModel) => this._onIndoorUpdate(controlModel));
31-
}
29+
this._container.style.display = 'none';
30+
this._map.on('styledata', this._onStyleData);
31+
this._updateConnection();
3232
return this._container;
3333
}
3434

35+
_onStyleData() {
36+
this._updateConnection();
37+
}
38+
39+
_updateConnection() {
40+
if (this._map && this._map.style && this._map.style.indoorManager) {
41+
const manager = this._map.style.indoorManager;
42+
manager.off('selector-update', this._onIndoorUpdate);
43+
manager.on('selector-update', this._onIndoorUpdate);
44+
this._onIndoorUpdate(manager.getControlState());
45+
}
46+
}
47+
3548
_createButton(className: string, fn: (e: Event) => unknown): HTMLButtonElement {
3649
const a = DOM.create('button', className, this._container);
3750
a.type = 'button';
@@ -53,15 +66,17 @@ class IndoorControl implements IControl {
5366
if (this._container) {
5467
this._container.remove();
5568
}
56-
57-
if (this._map && this._map.style) {
58-
this._map.style.indoorManager.off('selector-update', this._onIndoorUpdate);
69+
if (this._map) {
70+
this._map.off('styledata', this._onStyleData);
71+
if (this._map.style) {
72+
this._map.style.indoorManager.off('selector-update', this._onIndoorUpdate);
73+
}
5974
this._map = null;
6075
}
6176
}
6277

6378
getDefaultPosition(): ControlPosition {
64-
return 'right';
79+
return 'top-right';
6580
}
6681

6782
_onIndoorUpdate(model: IndoorControlModel | null) {
@@ -70,16 +85,12 @@ class IndoorControl implements IControl {
7085
this._container.style.display = 'none';
7186
return;
7287
}
73-
7488
const oldModel = this._model;
7589
this._model = model;
7690
this._container.style.display = 'inline-block';
77-
this._container.style.borderRadius = '8px';
78-
7991
if (oldModel) {
8092
Array.from(this._container.children).forEach(child => child.remove());
8193
}
82-
8394
if (model.floors.length > 0) {
8495
this.addBuildingsToggleButton();
8596
this.addCurrentFloors(model.floors, model.activeFloorsVisible);
@@ -95,15 +106,11 @@ class IndoorControl implements IControl {
95106
}
96107
});
97108
DOM.create('span', `mapboxgl-ctrl-icon`, buildingsButton).setAttribute('aria-hidden', 'true');
98-
99109
buildingsButton.classList.add('mapboxgl-ctrl-level-button', 'mapboxgl-ctrl-buildings-toggle');
100-
101110
if (this._model && !this._model.activeFloorsVisible) {
102111
buildingsButton.classList.add('mapboxgl-ctrl-level-button-selected');
103112
}
104-
105113
this._container.append(buildingsButton);
106-
this._createSeparator();
107114
}
108115

109116
_updateBuildingsButtonState() {
@@ -128,11 +135,9 @@ class IndoorControl implements IControl {
128135
const zIndexText = floor.zIndex.toString();
129136
const buttonTitle = floorName ? Array.from(floorName).slice(0, 3).join('') : zIndexText;
130137
this._setButtonTitle(levelButton, buttonTitle);
131-
132138
if (this._model && floor.id === this._model.selectedFloorId && showSelectedFloor) {
133139
levelButton.classList.add('mapboxgl-ctrl-level-button-selected');
134140
}
135-
136141
this._container.append(levelButton);
137142

138143
// Add separator after each button except the last one
@@ -142,5 +147,4 @@ class IndoorControl implements IControl {
142147
}
143148
}
144149
}
145-
146150
export default IndoorControl;
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import {test, describe, expect, vi} from '../../../util/vitest';
2+
import IndoorControl from '../../../../src/ui/control/indoor_control';
3+
4+
import type {Map} from '../../../../src/ui/map';
5+
6+
const createMockIndoorManager = (overrides = {}) => ({
7+
on: vi.fn(),
8+
off: vi.fn(),
9+
getControlState: vi.fn().mockReturnValue({floors: [], activeFloorsVisible: true}),
10+
updateControl: vi.fn(),
11+
...overrides
12+
});
13+
14+
type Mock = ReturnType<typeof vi.fn>;
15+
16+
type MockIndoorManager = ReturnType<typeof createMockIndoorManager>;
17+
18+
const createMockMap = (indoorManager?: MockIndoorManager, overrides = {}) => ({
19+
on: vi.fn(),
20+
off: vi.fn(),
21+
style: indoorManager ? {indoorManager} : undefined,
22+
...overrides
23+
} as unknown as Map);
24+
25+
describe('IndoorControl', () => {
26+
test('default position is top-right', () => {
27+
const control = new IndoorControl();
28+
expect(control.getDefaultPosition()).toBe('top-right');
29+
});
30+
31+
test('onAdd subscribes to styledata and requests initial state', () => {
32+
const indoorManager = createMockIndoorManager();
33+
const map = createMockMap(indoorManager);
34+
35+
const control = new IndoorControl();
36+
control.onAdd(map);
37+
38+
expect(map.on).toHaveBeenCalledWith('styledata', expect.any(Function));
39+
expect(indoorManager.on).toHaveBeenCalledWith('selector-update', expect.any(Function));
40+
expect(indoorManager.getControlState).toHaveBeenCalled();
41+
});
42+
43+
test('re-binds on styledata event', () => {
44+
const indoorManager = createMockIndoorManager();
45+
const map = createMockMap(indoorManager);
46+
47+
const control = new IndoorControl();
48+
control.onAdd(map);
49+
50+
const styleDataListener = (map.on as Mock).mock.calls[0][1] as () => void;
51+
52+
styleDataListener();
53+
54+
expect(indoorManager.off).toHaveBeenCalledWith('selector-update', expect.any(Function));
55+
expect(indoorManager.on).toHaveBeenCalledTimes(2);
56+
expect(indoorManager.getControlState).toHaveBeenCalledTimes(2);
57+
});
58+
59+
test('onRemove cleans up listeners', () => {
60+
const indoorManager = createMockIndoorManager();
61+
const map = createMockMap(indoorManager);
62+
63+
const control = new IndoorControl();
64+
control.onAdd(map);
65+
control.onRemove();
66+
67+
expect(map.off).toHaveBeenCalledWith('styledata', expect.any(Function));
68+
expect(indoorManager.off).toHaveBeenCalledWith('selector-update', expect.any(Function));
69+
});
70+
71+
test('handles missing style or indoorManager gracefully', () => {
72+
const map = createMockMap(undefined);
73+
74+
const control = new IndoorControl();
75+
control.onAdd(map);
76+
77+
expect(map.on).toHaveBeenCalledWith('styledata', expect.any(Function));
78+
});
79+
});

0 commit comments

Comments
 (0)