maplibre-gl-indoor
Version:
A MapLibre plugin to visualize multi-level buildings
271 lines (223 loc) • 7.15 kB
text/typescript
import type { BBox } from "geojson";
import type {
ExpressionSpecification,
LayerSpecification,
Map,
} from "maplibre-gl";
import { default as turfDistance } from "@turf/distance";
import type { Level } from "./Types";
import { bboxCenter, overlap } from "./bbox";
import IndoorMap from "./IndoorMap";
import { filterWithLevel } from "./levelFilter";
type SavedFilter = {
filter: ExpressionSpecification;
layerId: string;
};
const SOURCE_ID = "indoor";
/**
* Manage indoor levels
* @param {Map} map the Maplibre map
*/
export default class IndoorLayer {
_indoorMaps: Array<IndoorMap>;
_level: Level | null;
_map: Map;
_mapLoadedPromise: Promise<void>;
_previousSelectedLevel: Level | null;
_previousSelectedMap: IndoorMap | null;
_savedFilters: Array<SavedFilter>;
_selectedMap: IndoorMap | null;
_updateMapPromise: Promise<void>;
constructor(map: Map) {
this._map = map;
this._level = null;
this._indoorMaps = [];
this._savedFilters = [];
this._selectedMap = null;
this._previousSelectedMap = null;
this._previousSelectedLevel = null;
this._updateMapPromise = Promise.resolve();
if (this._map.loaded()) {
this._mapLoadedPromise = Promise.resolve();
} else {
this._mapLoadedPromise = new Promise((resolve) =>
this._map.on("load", resolve),
);
}
this._map.on("moveend", () => this._updateSelectedMapIfNeeded());
}
_addLayerForFiltering(layer: LayerSpecification, beforeLayerId?: string) {
this._map.addLayer(layer, beforeLayerId);
this._savedFilters.push({
filter: (this._map.getFilter(layer.id) as ExpressionSpecification) || [
"all",
],
layerId: layer.id,
});
}
_closestMap() {
// TODO enhance this condition
if (this._map.getZoom() < 16.5) {
return null;
}
const cameraBounds = this._map.getBounds();
const cameraBoundsTurf = [
cameraBounds.getWest(),
cameraBounds.getSouth(),
cameraBounds.getEast(),
cameraBounds.getNorth(),
] as BBox;
const mapsInBounds = this._indoorMaps.filter((indoorMap) =>
overlap(indoorMap.bounds, cameraBoundsTurf),
);
if (mapsInBounds.length === 0) {
return null;
}
if (mapsInBounds.length === 1) {
return mapsInBounds[0];
}
/*
* If there is multiple maps at this step, select the closest
*/
let minDist = Number.POSITIVE_INFINITY;
let closestMap = mapsInBounds[0];
for (const map of mapsInBounds) {
const _dist = turfDistance(
bboxCenter(map.bounds),
bboxCenter(cameraBoundsTurf),
);
if (_dist < minDist) {
closestMap = map;
minDist = _dist;
}
}
return closestMap;
}
_removeLayerForFiltering(layerId: string) {
this._savedFilters = this._savedFilters.filter(
({ layerId: id }) => layerId !== id,
);
this._map.removeLayer(layerId);
}
/**
* ***********************
* Handle level change
* ***********************
*/
_updateFiltering() {
const level = this._level;
let filterFn: (filter: ExpressionSpecification) => ExpressionSpecification;
if (level !== null) {
const showFeaturesWithEmptyLevel = this._selectedMap
? this._selectedMap.showFeaturesWithEmptyLevel
: false;
filterFn = (filter: ExpressionSpecification) =>
filterWithLevel(filter, level, showFeaturesWithEmptyLevel);
} else {
filterFn = (filter: ExpressionSpecification): ExpressionSpecification =>
filter;
}
this._savedFilters.forEach(({ filter, layerId }) => {
this._map.setFilter(layerId, filterFn(filter));
});
// one can use the following to see the generated style
// console.log(structuredClone(this._map.getStyle()));
}
_updateSelectedMap(indoorMap: IndoorMap | null) {
const previousMap = this._selectedMap;
// Remove the previous selected map if it exists
if (previousMap !== null) {
previousMap.layersToHide.forEach((layerId) =>
this._map.setLayoutProperty(layerId, "visibility", "visible"),
);
previousMap.layers.forEach(({ id }) => this._removeLayerForFiltering(id));
this._map.removeSource(SOURCE_ID);
if (!indoorMap) {
// Save the previous map level.
// It enables the user to exit and re-enter, keeping the same level shown.
this._previousSelectedLevel = this._level;
this._previousSelectedMap = previousMap;
}
this.setLevel(null, false);
this._map.fire("indoor.map.unloaded", { indoorMap: previousMap });
}
this._selectedMap = indoorMap;
if (!indoorMap) {
return;
}
const { beforeLayerId, geojson, layers, levelsRange } = indoorMap;
// Add map source
this._map.addSource(SOURCE_ID, {
data: geojson,
lineMetrics: true,
type: "geojson",
});
// Add layers and save filters
layers.forEach((layer) => this._addLayerForFiltering(layer, beforeLayerId));
// Hide layers which can overlap for rendering
indoorMap.layersToHide.forEach((layerId) =>
this._map.setLayoutProperty(layerId, "visibility", "none"),
);
// Restore the same level when the previous selected map is the same.
const level =
this._previousSelectedMap === indoorMap
? this._previousSelectedLevel
: Math.max(
Math.min(indoorMap.defaultLevel, levelsRange.max),
levelsRange.min,
);
this.setLevel(level, false);
this._map.fire("indoor.map.loaded", { indoorMap });
}
async _updateSelectedMapIfNeeded() {
await this._mapLoadedPromise;
// Avoid to call "closestMap" or "updateSelectedMap" if the previous call is not finished yet
await this._updateMapPromise;
this._updateMapPromise = (async () => {
const closestMap = this._closestMap();
if (closestMap !== this._selectedMap) {
this._updateSelectedMap(closestMap);
}
})();
await this._updateMapPromise;
}
addLayerForFiltering(layer: LayerSpecification, beforeLayerId?: string) {
this._addLayerForFiltering(layer, beforeLayerId);
this._updateFiltering();
}
async addMap(map: IndoorMap) {
this._indoorMaps.push(map);
await this._updateSelectedMapIfNeeded();
}
/**
* **************
* Handle maps
* **************
*/
getLevel(): Level | null {
return this._level;
}
getSelectedMap(): IndoorMap | null {
return this._selectedMap;
}
removeLayerForFiltering(layerId: string) {
this._removeLayerForFiltering(layerId);
this._updateFiltering();
}
async removeMap(map: IndoorMap) {
this._indoorMaps = this._indoorMaps.filter(
(_indoorMap) => _indoorMap !== map,
);
await this._updateSelectedMapIfNeeded();
}
setLevel(level: Level | null, fireEvent: boolean = true): void {
if (this._selectedMap === null) {
throw new Error("Cannot set level, no map has been selected");
}
this._level = level;
this._updateFiltering();
if (fireEvent) {
this._map.fire("indoor.level.changed", { level });
}
}
}