mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-20 08:33:11 +00:00
feat: add map module
This commit is contained in:
472
src/app/modules/map/page/map-page.component.ts
Normal file
472
src/app/modules/map/page/map-page.component.ts
Normal file
@@ -0,0 +1,472 @@
|
||||
/*
|
||||
* Copyright (C) 2019-2021 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, ModalController, Platform} 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 {
|
||||
LocationStatus,
|
||||
MapPosition,
|
||||
PositionService,
|
||||
} from '../position.service';
|
||||
import {MapListModalComponent} from './modals/map-list-modal.component';
|
||||
import {MapSingleModalComponent} from './modals/map-single-modal.component';
|
||||
import Timeout = NodeJS.Timeout;
|
||||
|
||||
/**
|
||||
* 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: LocationStatus = {enabled: undefined, allowed: undefined};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Modal for additional information on places or for a list of places
|
||||
*/
|
||||
modal: HTMLIonModalElement;
|
||||
|
||||
/**
|
||||
* 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,
|
||||
private platform: Platform,
|
||||
) {
|
||||
// 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,
|
||||
);
|
||||
// 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.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 this.modal.dismiss();
|
||||
await 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: error => {
|
||||
if (error.code === 1) {
|
||||
this.locationStatus.allowed = false;
|
||||
}
|
||||
// eslint-disable-next-line unicorn/no-null
|
||||
this.position = null;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// get detailed location status (diagnostics only supports devices)
|
||||
if (this.platform.is('cordova')) {
|
||||
this.locationStatus = await this.positionService.getLocationStatus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: Timeout = 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.translateService
|
||||
.get(['map.page.geolocation', 'app.errors.UNKNOWN'])
|
||||
.subscribe(async translations => {
|
||||
const [location, unknownError] = [
|
||||
translations['map.page.geolocation'],
|
||||
translations['app.errors.UNKNOWN'],
|
||||
];
|
||||
const {enabled, allowed} = this.locationStatus;
|
||||
await (
|
||||
await this.alertController.create({
|
||||
header: location.TITLE,
|
||||
subHeader: location.SUBTITLE,
|
||||
message: `${
|
||||
enabled === false
|
||||
? location.NOT_ENABLED
|
||||
: allowed === false
|
||||
? location.NOT_ALLOWED
|
||||
: 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search event of search bar
|
||||
*/
|
||||
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(['/map/', uid]).toString();
|
||||
this.location.go(url);
|
||||
// center the selected place
|
||||
this.focus(geoJSON(this.items[0].geo.point).getBounds().getCenter());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a single place
|
||||
*/
|
||||
async showItemModal(item: SCPlace) {
|
||||
const placeWithoutGeo = {...item, geo: undefined};
|
||||
this.modal = await this.modalController.create({
|
||||
component: MapSingleModalComponent,
|
||||
swipeToClose: true,
|
||||
componentProps: {
|
||||
item: placeWithoutGeo,
|
||||
dismissAction: () => {
|
||||
this.modal.dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await this.modal.present();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the list of shown places
|
||||
*/
|
||||
async showListModal() {
|
||||
this.modal = await this.modalController.create({
|
||||
component: MapListModalComponent,
|
||||
swipeToClose: true,
|
||||
componentProps: {
|
||||
filterQuery: this.filterQuery,
|
||||
queryText: this.queryText,
|
||||
dismissAction: () => {
|
||||
this.modal.dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await this.modal.present();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user