mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-10 19:52:53 +00:00
452 lines
12 KiB
TypeScript
452 lines
12 KiB
TypeScript
/*
|
|
* Copyright (C) 2022 StApps
|
|
* This program is free software: you can redistribute it and/or modify it
|
|
* under the terms of the GNU General Public License as published by the Free
|
|
* Software Foundation, version 3.
|
|
*
|
|
* This program is distributed in the hope that it will be useful, but WITHOUT
|
|
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
* more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License along with
|
|
* this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
import {Location} from '@angular/common';
|
|
import {
|
|
ChangeDetectorRef,
|
|
Component,
|
|
ElementRef,
|
|
ViewChild,
|
|
} from '@angular/core';
|
|
import {ActivatedRoute, Router} from '@angular/router';
|
|
import {
|
|
AlertController,
|
|
IonRouterOutlet,
|
|
ModalController,
|
|
} from '@ionic/angular';
|
|
import {TranslateService} from '@ngx-translate/core';
|
|
import {
|
|
SCBuilding,
|
|
SCPlace,
|
|
SCRoom,
|
|
SCSearchFilter,
|
|
SCUuid,
|
|
} from '@openstapps/core';
|
|
import {
|
|
featureGroup,
|
|
geoJSON,
|
|
LatLng,
|
|
Layer,
|
|
Map,
|
|
MapOptions,
|
|
Marker,
|
|
tileLayer,
|
|
} from 'leaflet';
|
|
import {Subscription} from 'rxjs';
|
|
import {DataRoutingService} from '../../data/data-routing.service';
|
|
import {ContextMenuService} from '../../menu/context/context-menu.service';
|
|
import {MapProvider} from '../map.provider';
|
|
import {MapPosition, PositionService} from '../position.service';
|
|
import {Geolocation, PermissionStatus} from '@capacitor/geolocation';
|
|
import {Capacitor} from '@capacitor/core';
|
|
|
|
/**
|
|
* The main page of the map
|
|
*/
|
|
@Component({
|
|
styleUrls: ['./map-page.scss'],
|
|
templateUrl: './map-page.html',
|
|
providers: [ContextMenuService],
|
|
})
|
|
export class MapPageComponent {
|
|
/**
|
|
* Default map zoom level
|
|
*/
|
|
DEFAULT_ZOOM = 16;
|
|
|
|
/**
|
|
* Distance to the shown place
|
|
*/
|
|
distance?: number;
|
|
|
|
/**
|
|
* Api query filter
|
|
*/
|
|
filterQuery?: SCSearchFilter;
|
|
|
|
/**
|
|
* Places to show
|
|
*/
|
|
items: SCPlace[] = [];
|
|
|
|
/**
|
|
* Leaflet (map) layers to show items on (not the position)
|
|
*/
|
|
layers: Layer[] = [];
|
|
|
|
/**
|
|
* Location settings on the user's device
|
|
*/
|
|
locationStatus?: PermissionStatus;
|
|
|
|
/**
|
|
* The leaflet map
|
|
*/
|
|
map: Map;
|
|
|
|
/**
|
|
* Container element of the map
|
|
*/
|
|
@ViewChild('mapContainer') mapContainer: ElementRef;
|
|
|
|
/**
|
|
* Map layers to show as marker clusters
|
|
*/
|
|
markerClusterData: Layer[] = [];
|
|
|
|
/**
|
|
* Options how to show the marker clusters
|
|
*/
|
|
markerClusterOptions = {
|
|
// don't show rectangles containing the markers in a cluster
|
|
showCoverageOnHover: false,
|
|
};
|
|
|
|
/**
|
|
* Maximal map zoom level
|
|
*/
|
|
MAX_ZOOM = 18;
|
|
|
|
/**
|
|
* Options of the leaflet map
|
|
*/
|
|
options: MapOptions;
|
|
|
|
/**
|
|
* Position of the user on the map
|
|
*/
|
|
position: MapPosition | null;
|
|
|
|
/**
|
|
* Marker showing the position of the user on the map
|
|
*/
|
|
positionMarker: Marker;
|
|
|
|
/**
|
|
* Search value from search bar
|
|
*/
|
|
queryText: string;
|
|
|
|
/**
|
|
* Subscriptions used by the page
|
|
*/
|
|
subscriptions: Subscription[] = [];
|
|
|
|
constructor(
|
|
private translateService: TranslateService,
|
|
private router: Router,
|
|
private mapProvider: MapProvider,
|
|
public location: Location,
|
|
private ref: ChangeDetectorRef,
|
|
private readonly contextMenuService: ContextMenuService,
|
|
private alertController: AlertController,
|
|
private route: ActivatedRoute,
|
|
private modalController: ModalController,
|
|
private dataRoutingService: DataRoutingService,
|
|
private positionService: PositionService,
|
|
readonly routerOutlet: IonRouterOutlet,
|
|
) {
|
|
// initialize the options
|
|
this.options = {
|
|
center: geoJSON(this.mapProvider.defaultPolygon).getBounds().getCenter(),
|
|
layers: [
|
|
tileLayer(
|
|
'https://osm.server.uni-frankfurt.de/tiles/roads/x={x}&y={y}&z={z}',
|
|
{
|
|
attribution:
|
|
'© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors',
|
|
maxZoom: this.MAX_ZOOM,
|
|
},
|
|
),
|
|
],
|
|
zoom: this.DEFAULT_ZOOM,
|
|
zoomControl: false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Animate to coordinates
|
|
*
|
|
* @param latLng Coordinates to animate to
|
|
*/
|
|
private focus(latLng?: LatLng) {
|
|
if (typeof latLng !== 'undefined') {
|
|
this.map.flyTo(latLng, this.MAX_ZOOM);
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add places to the map
|
|
*
|
|
* @param items Places to add to the map
|
|
* @param clean Remove everything from the map first
|
|
* @param focus Animate to the item(s)
|
|
*/
|
|
addToMap(items: SCPlace[], clean = false, focus = false) {
|
|
if (clean) {
|
|
this.removeAll();
|
|
}
|
|
const addSCPlace = (place: SCPlace): Layer | Marker => {
|
|
if (typeof place.geo.polygon !== 'undefined') {
|
|
const polygonLayer = geoJSON(place.geo.polygon, {
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore
|
|
bubblingMouseEvents: false,
|
|
}).getLayers()[0];
|
|
|
|
return polygonLayer.on('click', this.showItem.bind(this, place.uid));
|
|
}
|
|
|
|
const markerLayer = MapProvider.getPointMarker(place.geo.point);
|
|
|
|
return markerLayer.on('click', this.showItem.bind(this, place.uid));
|
|
};
|
|
|
|
items.map(thing => {
|
|
// IMPORTANT: change this to support inPlace.geo when there is a need to show floors (the building of the floor)
|
|
if (typeof thing.geo !== 'undefined') {
|
|
this.layers.push(addSCPlace(thing as SCPlace));
|
|
}
|
|
});
|
|
|
|
this.markerClusterData = this.layers;
|
|
|
|
if (!focus || this.items.length === 0) {
|
|
return;
|
|
}
|
|
|
|
if (this.items.length === 1) {
|
|
this.focus(geoJSON(this.items[0].geo.point).getBounds().getCenter());
|
|
|
|
return;
|
|
}
|
|
|
|
const groupedLayers = featureGroup(this.layers);
|
|
this.map.flyToBounds(groupedLayers.getBounds());
|
|
}
|
|
|
|
/**
|
|
* Fetches items with set query configuration
|
|
*
|
|
* @param animate Should the fly animation be used
|
|
*/
|
|
async fetchAndUpdateItems(animate?: boolean): Promise<void> {
|
|
try {
|
|
const result = await this.mapProvider.searchPlaces(
|
|
this.filterQuery,
|
|
this.queryText,
|
|
);
|
|
if (result.data.length === 0) {
|
|
const alert = await this.alertController.create({
|
|
buttons: [this.translateService.instant('ok')],
|
|
header: this.translateService.instant('map.page.NO_RESULTS'),
|
|
});
|
|
await alert.present();
|
|
|
|
return;
|
|
}
|
|
// override items with results
|
|
this.items = result.data as SCPlace[];
|
|
this.addToMap(result.data as Array<SCBuilding | SCRoom>, true, animate);
|
|
// update filter options if result contains facets
|
|
if (typeof result.facets !== 'undefined') {
|
|
this.contextMenuService.updateContextFilter(result.facets);
|
|
}
|
|
} catch (error) {
|
|
const alert: HTMLIonAlertElement = await this.alertController.create({
|
|
buttons: ['Dismiss'],
|
|
header: 'Error',
|
|
subHeader: (error as Error).message,
|
|
});
|
|
|
|
await alert.present();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Subscribe to needed observables and get the location status when user is entering the page
|
|
*/
|
|
async ionViewWillEnter() {
|
|
this.subscriptions.push(
|
|
this.dataRoutingService.itemSelectListener().subscribe(async item => {
|
|
// in case the list item is clicked
|
|
if (this.items.length > 1) {
|
|
await Promise.all([
|
|
this.modalController.dismiss(),
|
|
this.showItem(item.uid),
|
|
]);
|
|
}
|
|
}),
|
|
this.positionService.watchCurrentLocation({maximumAge: 3000}).subscribe({
|
|
next: (position: MapPosition) => {
|
|
this.position = position;
|
|
this.positionMarker = MapProvider.getPositionMarker(
|
|
position,
|
|
'stapps-device-location',
|
|
30,
|
|
);
|
|
},
|
|
error: async _error => {
|
|
this.locationStatus = await Geolocation.checkPermissions();
|
|
// eslint-disable-next-line unicorn/no-null
|
|
this.position = null;
|
|
},
|
|
}),
|
|
);
|
|
|
|
// get detailed location status (diagnostics only supports devices)
|
|
this.locationStatus = await Geolocation.checkPermissions();
|
|
}
|
|
|
|
/**
|
|
* Unsubscribe from all subscriptions when user leaves page
|
|
*/
|
|
ionViewWillLeave() {
|
|
for (const sub of this.subscriptions) {
|
|
sub.unsubscribe();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* What happens when the leaflet map is ready (note: doesn't mean that tiles are loaded)
|
|
*/
|
|
async onMapReady(map: Map) {
|
|
this.map = map;
|
|
const interval = window.setInterval(() =>
|
|
MapProvider.invalidateWhenRendered(map, this.mapContainer, interval),
|
|
);
|
|
|
|
const uid = this.route.snapshot.paramMap.get('uid');
|
|
const response = await (uid !== null
|
|
? this.mapProvider.searchPlace(uid)
|
|
: this.mapProvider.searchPlaces());
|
|
|
|
if (response.data.length === 0) {
|
|
return;
|
|
}
|
|
|
|
this.items = response.data as SCBuilding[];
|
|
this.addToMap(this.items, true, uid !== null);
|
|
this.contextMenuService.updateContextFilter(response.facets);
|
|
|
|
this.subscriptions.push(
|
|
this.contextMenuService.filterQueryChanged$.subscribe(query => {
|
|
this.filterQuery = query;
|
|
this.fetchAndUpdateItems(true);
|
|
}),
|
|
);
|
|
|
|
this.distance = this.positionService.getDistance(this.items[0].geo.point);
|
|
}
|
|
|
|
/**
|
|
* What happens when position button is clicked
|
|
*/
|
|
async onPositionClick() {
|
|
if (this.position) {
|
|
this.focus(this.positionMarker.getLatLng());
|
|
|
|
return;
|
|
}
|
|
|
|
this.locationStatus = await (!Capacitor.isNativePlatform()
|
|
? Geolocation.checkPermissions()
|
|
: Geolocation.requestPermissions());
|
|
|
|
this.translateService
|
|
.get(['map.page.geolocation', 'app.errors.UNKNOWN'])
|
|
.subscribe(async translations => {
|
|
const [location, unknownError] = [
|
|
translations['map.page.geolocation'],
|
|
translations['app.errors.UNKNOWN'],
|
|
];
|
|
await (
|
|
await this.alertController.create({
|
|
header: location.TITLE,
|
|
subHeader: location.SUBTITLE,
|
|
message: `${
|
|
this.locationStatus?.location === 'denied'
|
|
? location.NOT_ALLOWED
|
|
: this.locationStatus?.location !== 'granted'
|
|
? location.NOT_ENABLED
|
|
: unknownError
|
|
}.`,
|
|
buttons: ['OK'],
|
|
})
|
|
).present();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Remove all of the layers
|
|
*/
|
|
removeAll() {
|
|
for (const layer of this.layers) {
|
|
this.map.removeLayer(layer);
|
|
}
|
|
|
|
this.layers = [];
|
|
}
|
|
|
|
/**
|
|
* Resets the map = fetch all the items based on the filters (and go to component's base location)
|
|
*/
|
|
async resetView() {
|
|
this.location.go('/map');
|
|
await this.fetchAndUpdateItems();
|
|
|
|
this.ref.detectChanges();
|
|
}
|
|
|
|
/**
|
|
* On enter key up do the search
|
|
*
|
|
* @param event Keyboard keyup event
|
|
*/
|
|
searchKeyUp(event: KeyboardEvent) {
|
|
if (event.key === 'Enter') {
|
|
this.searchStringChanged((event.target as HTMLInputElement).value);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search event of search bar
|
|
*
|
|
* @param queryText New query text to be set
|
|
*/
|
|
searchStringChanged(queryText?: string) {
|
|
this.queryText = queryText || '';
|
|
void this.fetchAndUpdateItems(true);
|
|
}
|
|
|
|
/**
|
|
* Show an single place
|
|
*
|
|
* @param uid Uuid of the place
|
|
*/
|
|
async showItem(uid: SCUuid) {
|
|
const response = await this.mapProvider.searchPlace(uid);
|
|
this.items = response.data as SCPlace[];
|
|
this.distance = this.positionService.getDistance(this.items[0].geo.point);
|
|
this.addToMap(this.items, true);
|
|
this.ref.detectChanges();
|
|
const url = this.router.createUrlTree([[], uid]).toString();
|
|
this.location.go(url);
|
|
// center the selected place
|
|
this.focus(geoJSON(this.items[0].geo.point).getBounds().getCenter());
|
|
}
|
|
}
|