/* * 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 . */ 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: '© OpenStreetMap 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 { 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, 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()); } }