feat: add map module

This commit is contained in:
Jovan Krunić
2021-07-13 07:57:09 +00:00
parent d696215d08
commit c1c9a92ec9
44 changed files with 2138 additions and 93 deletions

View 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:
'&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,
);
// 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();
}
}