Files
openstapps/src/app/modules/map/page/map-page.component.ts
2022-04-14 10:53:29 +00:00

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:
'&copy; <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());
}
}