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,28 @@
<ion-card>
<ion-card-header>
<stapps-data-list-item
[item]="item"
(click)="showMore()"
></stapps-data-list-item>
<stapps-skeleton-list-item *ngIf="!item"></stapps-skeleton-list-item>
</ion-card-header>
<ion-card-content>
<ion-grid>
<ion-row>
<ion-col size="7">
<ion-note>
<span *ngIf="item.address as address">
<span *ngIf="item.inPlace">{{ item.inPlace.name }},</span>
{{ address.streetAddress }}, {{ address.addressLocality }}
</span>
</ion-note>
</ion-col>
<ion-col size="5">
<ion-button size="small" (click)="showMore()"
>More&nbsp;<ion-icon name="information-circle"></ion-icon
></ion-button>
</ion-col>
</ion-row>
</ion-grid>
</ion-card-content>
</ion-card>

View File

@@ -0,0 +1,8 @@
ion-col:nth-child(2) {
display: flex;
justify-content: flex-end;
ion-button {
margin-top: auto;
}
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright (C) 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 {Component, EventEmitter, Input, Output} from '@angular/core';
import {SCPlace} from '@openstapps/core';
@Component({
selector: 'stapps-map-item',
templateUrl: './map-item.component.html',
styleUrls: ['./map-item.component.scss'],
})
export class MapItemComponent {
/**
* An item to show
*/
@Input() item: SCPlace;
/**
* An item to show
*/
@Output() showDetails = new EventEmitter<SCPlace>();
/**
* Emit event to signalize to show more information
*/
showMore() {
this.showDetails.emit(this.item);
}
}

View File

@@ -0,0 +1,97 @@
/*
* 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 {CommonModule} from '@angular/common';
import {APP_INITIALIZER, NgModule} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {RouterModule, Routes} from '@angular/router';
import {LeafletModule} from '@asymmetrik/ngx-leaflet';
import {LeafletMarkerClusterModule} from '@asymmetrik/ngx-leaflet-markercluster';
import {Geolocation} from '@ionic-native/geolocation/ngx';
import {IonicModule} from '@ionic/angular';
import {TranslateModule} from '@ngx-translate/core';
import {Polygon} from 'geojson';
import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {ConfigProvider} from '../config/config.provider';
import {DataFacetsProvider} from '../data/data-facets.provider';
import {DataModule} from '../data/data.module';
import {DataProvider} from '../data/data.provider';
import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider';
import {MenuModule} from '../menu/menu.module';
import {MapProvider} from './map.provider';
import {MapPageComponent} from './page/map-page.component';
import {MapListModalComponent} from './page/modals/map-list-modal.component';
import {MapSingleModalComponent} from './page/modals/map-single-modal.component';
import {MapItemComponent} from './item/map-item.component';
/**
* Initializes the default area to show in advance (before components are initialized)
*
* @param configProvider An instance of the ConfigProvider to read the campus polygon from
* @param mapProvider An instance of the MapProvider to set the default polygon (area to show on the map)
*/
export function initMapConfigFactory(
configProvider: ConfigProvider,
mapProvider: MapProvider,
) {
return async () => {
mapProvider.defaultPolygon = (await configProvider.getValue(
'campusPolygon',
)) as Polygon;
};
}
const mapRoutes: Routes = [
{path: 'map', component: MapPageComponent},
{path: 'map/:uid', component: MapPageComponent},
];
/**
* Module containing map related stuff
*/
@NgModule({
declarations: [
MapPageComponent,
MapListModalComponent,
MapSingleModalComponent,
MapItemComponent,
],
exports: [],
imports: [
CommonModule,
IonicModule.forRoot(),
LeafletModule,
LeafletMarkerClusterModule,
RouterModule.forChild(mapRoutes),
TranslateModule.forChild(),
MenuModule,
DataModule,
FormsModule,
ThingTranslateModule,
],
providers: [
Geolocation,
MapProvider,
DataProvider,
DataFacetsProvider,
StAppsWebHttpClient,
{
provide: APP_INITIALIZER,
multi: true,
deps: [ConfigProvider, MapProvider],
useFactory: initMapConfigFactory,
},
],
})
export class MapModule {}

View File

@@ -0,0 +1,48 @@
/*
* 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 {TestBed} from '@angular/core/testing';
import {Geolocation} from '@ionic-native/geolocation/ngx';
import {Diagnostic} from '@ionic-native/diagnostic/ngx';
import {MapProvider} from './map.provider';
import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider';
import {HttpClientModule} from '@angular/common/http';
import {StorageProvider} from '../storage/storage.provider';
import {MapModule} from './map.module';
import {StorageModule} from '../storage/storage.module';
import {LoggerConfig, LoggerModule, NGXLogger} from 'ngx-logger';
describe('MapProvider', () => {
let provider: MapProvider;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [MapModule, HttpClientModule, StorageModule, LoggerModule],
providers: [
Geolocation,
Diagnostic,
StAppsWebHttpClient,
StorageProvider,
NGXLogger,
LoggerConfig,
],
});
provider = TestBed.inject(MapProvider);
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
});

View File

@@ -0,0 +1,232 @@
/*
* 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 {ElementRef, Injectable} from '@angular/core';
import {
SCSearchFilter,
SCSearchQuery,
SCSearchResponse,
SCThingType,
SCUuid,
} from '@openstapps/core';
import {Point, Polygon} from 'geojson';
import {divIcon, geoJSON, icon, LatLng, Map, marker, Marker} from 'leaflet';
import {DataProvider} from '../data/data.provider';
import {MapPosition, PositionService} from './position.service';
import Timeout = NodeJS.Timeout;
/**
* Provides methods for presenting the map
*/
@Injectable({
providedIn: 'root',
})
export class MapProvider {
/**
* Area to show when the map is initialized (shown for the first time)
*/
defaultPolygon: Polygon;
/**
* Provide a point marker for a leaflet map
*
* @param point Point to get marker for
*/
static getPointMarker(point: Point) {
return marker(geoJSON(point).getBounds().getCenter(), {
icon: icon({
// tslint:disable-next-line:no-magic-numbers
iconAnchor: [13, 41],
// tslint:disable-next-line:no-magic-numbers
iconSize: [25, 41],
iconUrl: '../assets/marker-icon.png',
shadowUrl: '../assets/marker-shadow.png',
}),
});
}
/**
* Provide a position marker for a leaflet map
*
* @param position Current position
* @param className CSS class name
* @param iconSize Size of the position icon
*/
static getPositionMarker(
position: MapPosition,
className: string,
iconSize: number,
) {
return new Marker(new LatLng(position.latitude, position.longitude), {
icon: divIcon({
className: className,
html:
typeof position.heading !== 'undefined'
? `<ion-icon name="navigate-straight"
style="transform-origin: center; transform: rotate(${position.heading}deg);">
</ion-icon>`
: '<ion-icon name="locate"></ion-icon>',
iconSize: [iconSize, iconSize],
}),
zIndexOffset: 1000,
});
}
/**
* Fixes the issue of missing tiles when map renders before its container element
*
* @param map The initialized map
* @param element The element containing the map
* @param interval Interval to clear when map's appearance is corrected
*/
static invalidateWhenRendered = (
map: Map,
element: ElementRef,
interval: Timeout,
) => {
if (element.nativeElement.offsetWidth === 0) {
return;
}
// map's container is ready
map.invalidateSize();
// stop repeating when it's rendered and invalidateSize done
clearInterval(interval);
};
constructor(
private dataProvider: DataProvider,
private positionService: PositionService,
) {}
/**
* Provide the specific place by its UID
*
* @param uid UUID of the place to look for
*/
async searchPlace(uid: SCUuid): Promise<SCSearchResponse> {
const uidFilter: SCSearchFilter = {
arguments: {
field: 'uid',
value: uid,
},
type: 'value',
};
return this.dataProvider.search({filter: uidFilter});
}
/**
* Provide places (buildings and canteens)
*
* @param contextFilter Additional contextual filter (e.g. from the context menu)
* @param queryText Query (text) of the search query
*/
async searchPlaces(
contextFilter?: SCSearchFilter,
queryText?: string,
): Promise<SCSearchResponse> {
const buildingFilter: SCSearchFilter = {
arguments: {
field: 'type',
value: SCThingType.Building,
},
type: 'value',
};
const mensaFilter: SCSearchFilter = {
arguments: {
filters: [
{
arguments: {
field: 'categories',
value: 'canteen',
},
type: 'value',
},
{
arguments: {
field: 'categories',
value: 'student canteen',
},
type: 'value',
},
{
arguments: {
field: 'categories',
value: 'cafe',
},
type: 'value',
},
{
arguments: {
field: 'categories',
value: 'restaurant',
},
type: 'value',
},
],
operation: 'or',
},
type: 'boolean',
};
// initial filter for the places
const baseFilter: SCSearchFilter = {
arguments: {
operation: 'or',
filters: [buildingFilter, mensaFilter],
},
type: 'boolean',
};
let filter = baseFilter;
if (typeof contextFilter !== 'undefined') {
filter = {
arguments: {
operation: 'and',
filters: [baseFilter, contextFilter],
},
type: 'boolean',
};
}
const query: SCSearchQuery = {
filter,
};
if (queryText && queryText.length > 0) {
query.query = queryText;
}
if (this.positionService.position) {
query.sort = [
{
type: 'distance',
order: 'asc',
arguments: {
field: 'geo.point.coordinates',
position: [
this.positionService.position.longitude,
this.positionService.position.latitude,
],
},
},
];
}
return this.dataProvider.search(query);
}
}

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();
}
}

View File

@@ -0,0 +1,105 @@
<stapps-context [contentId]="'map'"></stapps-context>
<ion-header class="ion-no-border" translucent="true">
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button
*ngIf="items.length === 1"
default-href="/"
(click)="resetView()"
></ion-back-button>
<ion-menu-button></ion-menu-button>
</ion-buttons>
<ion-searchbar
(keyup.enter)="searchStringChanged($event.target.value)"
[(ngModel)]="queryText"
(ionClear)="searchStringChanged()"
placeholder="{{ 'map.page.search.PLACEHOLDER' | translate }}"
showClearButton="always"
>
</ion-searchbar>
<ion-buttons slot="end">
<ion-menu-button menu="context" auto-hide="false">
<ion-icon name="filter"></ion-icon>
</ion-menu-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content fullscreen id="map">
<div
class="map-container"
#mapContainer
leaflet
(leafletMapReady)="onMapReady($event)"
(leafletClick)="resetView()"
[leafletOptions]="options"
[leafletMarkerCluster]="markerClusterData"
[leafletMarkerClusterOptions]="markerClusterOptions"
>
<div *ngIf="position" [leafletLayer]="positionMarker"></div>
</div>
<div class="floating-content">
<div class="map-buttons above">
<ion-button
*ngIf="items.length > 1"
color="light"
shape="round"
size="small"
(click)="showListModal()"
>
<ion-icon name="list"></ion-icon>&nbsp;&nbsp;{{
'map.page.buttons.SHOW_LIST' | translate
}}
</ion-button>
<ion-button
[disabled]="position === undefined"
color="light"
shape="round"
size="small"
(click)="onPositionClick()"
>
<ion-icon
*ngIf="position !== null; else questionIcon"
name="locate"
></ion-icon>
<ng-template #questionIcon>
<ion-icon name="help-circle-outline"></ion-icon>
</ng-template>
</ion-button>
</div>
<stapps-map-item
*ngIf="items.length === 1"
[item]="items[0]"
(showDetails)="showItemModal($event)"
></stapps-map-item>
</div>
<div class="map-buttons floating-buttons">
<ion-button
*ngIf="items.length > 1"
color="light"
shape="round"
size="small"
(click)="showListModal()"
>
<ion-icon name="list"></ion-icon>&nbsp;&nbsp;{{
'map.page.buttons.SHOW_LIST' | translate
}}
</ion-button>
<ion-button
[disabled]="position === undefined"
color="light"
shape="round"
size="small"
(click)="onPositionClick()"
>
<ion-icon
*ngIf="position !== null; else questionIcon"
name="locate"
></ion-icon>
<ng-template #questionIcon>
<ion-icon name="help-circle-outline"></ion-icon>
</ng-template>
</ion-button>
</div>
</ion-content>

View File

@@ -0,0 +1,119 @@
ion-header {
ion-toolbar {
--background: transparent;
--ion-color-base: transparent;
ion-buttons {
ion-back-button::part(native), ion-menu-button::part(native) {
box-shadow: var(--map-box-shadow);
//padding: 2px;
}
}
}
ion-searchbar {
--background: white;
// important for iOS
--box-shadow: var(--map-box-shadow);
}
}
ion-content {
// fixes the unexpected issue that the content is not fullscreen (behind the header)
position: absolute;
div.map-container {
width: 100%;
height: 100%;
}
}
ion-back-button, ion-menu-button {
--background: white;
--background-hover: whitesmoke;
--background-focused: whitesmoke;
}
::ng-deep {
.stapps-location {
ion-icon {
color: #fd435c;
width: 100%;
height: 100%;
}
}
.stapps-device-location {
ion-icon {
color: #4387fd;
width: 100%;
height: 100%;
}
}
div.floating-content {
display: grid;
position: absolute;
bottom: 15px;
z-index: 1000;
width: 100%;
padding: 0 20px;
justify-content: center;
div.map-buttons {
display: flex;
justify-content: flex-end;
}
stapps-map-item {
width: 550px;
position: center;
justify-self: center;
margin: 2px;
}
}
}
div.floating-buttons {
z-index: 1000;
position: absolute;
bottom: 15px;
right: 10px;
}
div.map-buttons {
ion-button {
margin: 4px;
// important for iOS
--box-shadow: var(--map-box-shadow);
align-self: flex-end;
}
ion-button::part(native) {
background: white;
}
ion-button::part(native):hover, ion-button::part(native):focus {
background: whitesmoke;
}
}
div.map-buttons.above {
min-width: 70%;
display: none;
}
@media (max-width: 667px) {
div.map-buttons.above {
display: flex;
}
div.floating-content {
justify-content: normal;
stapps-map-item {
width: 100%;
}
}
div.map-buttons.floating-buttons {
display: none;
}
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright (C) 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 {Component, Input, OnInit} from '@angular/core';
import {SCPlace, SCSearchFilter} from '@openstapps/core';
import {MapProvider} from '../../map.provider';
/**
* Modal showing a provided list of places
*/
@Component({
selector: 'map-list-modal',
templateUrl: 'map-list.html',
styleUrls: ['map-list.scss'],
})
export class MapListModalComponent implements OnInit {
/**
* Action when close is pressed
*/
@Input() dismissAction: () => void;
/**
* Used for creating the search for the shown list
*/
@Input() filterQuery?: SCSearchFilter;
/**
* Places to show in the list
*/
items: SCPlace[];
/**
* Used for creating the search for the shown list
*/
@Input() queryText?: string;
constructor(private mapProvider: MapProvider) {}
/**
* Populate the list with the results from the search
*/
ngOnInit() {
this.mapProvider
.searchPlaces(this.filterQuery, this.queryText)
.then(result => {
this.items = result.data as SCPlace[];
});
}
}

View File

@@ -0,0 +1,13 @@
<ion-header translucent>
<ion-toolbar>
<ion-title>{{ 'map.modals.list.TITLE' | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismissAction()">{{
'app.ui.CLOSE' | translate
}}</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<stapps-data-list [items]="items"></stapps-data-list>
</ion-content>

View File

@@ -0,0 +1,33 @@
/*
* Copyright (C) 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 {Component, Input} from '@angular/core';
import {SCPlace} from '@openstapps/core';
@Component({
selector: 'app-map-single-modal',
templateUrl: './map-single.html',
styleUrls: ['./map-single.scss'],
})
export class MapSingleModalComponent {
/**
* Action when close is pressed
*/
@Input() dismissAction: () => void;
/**
* The item to be shown
*/
@Input() item: SCPlace;
}

View File

@@ -0,0 +1,13 @@
<ion-header translucent>
<ion-toolbar>
<ion-title>{{ 'map.modals.single.TITLE' | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismissAction()">{{
'app.ui.CLOSE' | translate
}}</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<stapps-data-detail-content [item]="item"></stapps-data-detail-content>
</ion-content>

View File

@@ -0,0 +1,103 @@
/*
* 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 {TestBed} from '@angular/core/testing';
import {MapModule} from './map.module';
import {Geolocation, Geoposition} from '@ionic-native/geolocation/ngx';
import {defer} from 'rxjs';
import {HttpClientModule} from '@angular/common/http';
import {StorageModule} from '../storage/storage.module';
import {MapPosition, PositionService} from './position.service';
import {Diagnostic} from '@ionic-native/diagnostic/ngx';
import {ConfigProvider} from '../config/config.provider';
import {
LoggerConfig,
LoggerModule,
NGXLogger,
NGXMapperService,
} from 'ngx-logger';
/**
* For faking a promise resolve
*/
function fakeAsyncResponse<T>(data: T) {
return defer(() => Promise.resolve(data));
}
describe('PositionService', () => {
let geolocation: Geolocation;
let positionService: PositionService;
const sampleMapPosition: MapPosition = {
heading: 123,
latitude: 34.12,
longitude: 12.34,
};
const samplePosition: Geoposition = {
coords: {
...sampleMapPosition,
accuracy: 1,
altitude: 123,
altitudeAccuracy: 1,
speed: 1,
},
timestamp: 1_565_275_805_901,
} as Geoposition;
beforeEach(async () => {
const configProvider = {
getValue: () => {
Promise.resolve();
},
};
TestBed.configureTestingModule({
imports: [MapModule, HttpClientModule, StorageModule, LoggerModule],
providers: [
Geolocation,
Diagnostic,
LoggerConfig,
NGXLogger,
NGXMapperService,
{
provider: ConfigProvider,
useValue: configProvider,
},
],
});
positionService = TestBed.inject(PositionService);
geolocation = TestBed.inject(Geolocation);
spyOn(geolocation, 'getCurrentPosition').and.returnValue(
Promise.resolve(samplePosition),
);
spyOn(geolocation, 'watchPosition').and.callFake(() => {
return fakeAsyncResponse(samplePosition);
});
});
it('should provide the current location of the device', async () => {
expect(await positionService.getCurrentLocation()).toEqual(
sampleMapPosition,
);
});
it('should continuously provide (watch) location of the device', async () => {
expect(
positionService
.watchCurrentLocation()
.subscribe(result => expect(result).toEqual(sampleMapPosition)),
);
});
});

View File

@@ -0,0 +1,163 @@
/*
* Copyright (C) 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 {Injectable} from '@angular/core';
import {Diagnostic} from '@ionic-native/diagnostic/ngx';
import {
Geolocation,
GeolocationOptions,
Geoposition,
PositionError,
} from '@ionic-native/geolocation/ngx';
import {Point} from 'geojson';
import {geoJSON, LatLng} from 'leaflet';
import {Observable} from 'rxjs';
import {map} from 'rxjs/operators';
/**
* Check if provided position object is a position error
*
* @param position A position object to be checked
*/
function isPositionError(
position: Geoposition | PositionError,
): position is PositionError {
return (
typeof (position as PositionError).code !== 'undefined' &&
typeof (position as PositionError).message !== 'undefined'
);
}
export interface Coordinates {
/**
* Geographic latitude from a device
*/
latitude: number;
/**
* Geographic longitude from a device
*/
longitude: number;
}
export interface MapPosition extends Coordinates {
/**
* Where is the device pointed
*/
heading?: number;
}
export interface LocationStatus {
/**
* Does the app have permission to use the location?
*/
allowed: boolean | undefined;
/**
* Is location enabled in the OS
*/
enabled: boolean | undefined;
}
@Injectable({
providedIn: 'root',
})
export class PositionService {
/**
* Current location status
*/
locationStatus: LocationStatus;
/**
* Current position
*/
position?: MapPosition;
constructor(
private geolocation: Geolocation,
private diagnostic: Diagnostic,
) {}
/**
* Gets current coordinates information of the device
*
* @param options Options which define which data should be provided (e.g. how accurate or how old)
*/
async getCurrentLocation(options?: GeolocationOptions): Promise<MapPosition> {
const geoPosition = await this.geolocation.getCurrentPosition(options);
this.position = {
heading:
Number.isNaN(geoPosition.coords.heading) ||
geoPosition.coords.heading == undefined
? undefined
: geoPosition.coords.heading,
latitude: geoPosition.coords.latitude,
longitude: geoPosition.coords.longitude,
};
return this.position;
}
/**
* Provides distance from users position
*
* @param point Point to which distance should be calculated
*/
getDistance(point: Point): number | undefined {
if (typeof this.position === 'undefined') {
return undefined;
}
return new LatLng(
this.position.latitude,
this.position.longitude,
).distanceTo(geoJSON(point).getBounds().getCenter());
}
/**
* Provides the information about the availability of the location service (ONLY ON DEVICES / when cordova exists)
*/
async getLocationStatus(): Promise<LocationStatus> {
const enabled = await this.diagnostic.isLocationEnabled();
const allowed = await this.diagnostic.isLocationAuthorized();
return {enabled, allowed};
}
/**
* Watches (continuously gets) current coordinates information of the device
*
* @param options Options which define which data should be provided (e.g. how accurate or how old)
*/
watchCurrentLocation(options?: GeolocationOptions): Observable<MapPosition> {
return this.geolocation.watchPosition(options).pipe(
map(geoPosition => {
if (isPositionError(geoPosition)) {
throw geoPosition;
}
this.position = {
heading:
Number.isNaN(geoPosition.coords.heading) ||
geoPosition.coords.heading == undefined
? undefined
: geoPosition.coords.heading,
latitude: geoPosition.coords.latitude,
longitude: geoPosition.coords.longitude,
};
return this.position;
}),
);
}
}

View File

@@ -0,0 +1,81 @@
/*
* Copyright (C) 2020-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 {Component, ElementRef, Input, OnInit} from '@angular/core';
import {SCPlace} from '@openstapps/core';
import {geoJSON, Map, MapOptions, tileLayer} from 'leaflet';
import {MapProvider} from '../map.provider';
import Timeout = NodeJS.Timeout;
/**
* The map widget (needs a container with explicit size)
*/
@Component({
selector: 'stapps-map-widget',
styleUrls: ['./map-widget.scss'],
templateUrl: './map-widget.html',
})
export class MapWidgetComponent implements OnInit {
/**
* A leaflet map showed
*/
map: Map;
/**
* Options of the leaflet map
*/
options: MapOptions;
/**
* A place to show on the map
*/
@Input() place: SCPlace;
constructor(private element: ElementRef) {}
/**
* Prepare the map
*/
ngOnInit() {
const markerLayer = MapProvider.getPointMarker(this.place.geo.point);
this.options = {
center: geoJSON(this.place.geo.polygon || this.place.geo.point)
.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: 18,
},
),
markerLayer,
],
zoom: 16,
zoomControl: false,
};
}
/**
* What happens when the leaflet map is ready (note: doesn't mean that tiles are loaded)
*/
onMapReady(map: Map) {
this.map = map;
const interval: Timeout = setInterval(() =>
MapProvider.invalidateWhenRendered(map, this.element, interval),
);
}
}

View File

@@ -0,0 +1,16 @@
<div
class="map-container"
(leafletMapReady)="onMapReady($event)"
leaflet
[leafletOptions]="options"
></div>
<div class="map-buttons">
<ion-button
color="light"
shape="round"
size="small"
[routerLink]="['/map', place.uid]"
>
<ion-icon name="expand"></ion-icon>
</ion-button>
</div>

View File

@@ -0,0 +1,12 @@
div.map-container {
height: 100%;
width: 100%;
display: block;
}
div.map-buttons {
position: absolute;
top: 10px;
right: 10px;
z-index: 10000;
}