mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-20 16:42:56 +00:00
feat: add map module
This commit is contained in:
28
src/app/modules/map/item/map-item.component.html
Normal file
28
src/app/modules/map/item/map-item.component.html
Normal 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 <ion-icon name="information-circle"></ion-icon
|
||||
></ion-button>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
8
src/app/modules/map/item/map-item.component.scss
Normal file
8
src/app/modules/map/item/map-item.component.scss
Normal file
@@ -0,0 +1,8 @@
|
||||
ion-col:nth-child(2) {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
ion-button {
|
||||
margin-top: auto;
|
||||
}
|
||||
}
|
||||
40
src/app/modules/map/item/map-item.component.ts
Normal file
40
src/app/modules/map/item/map-item.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
97
src/app/modules/map/map.module.ts
Normal file
97
src/app/modules/map/map.module.ts
Normal 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 {}
|
||||
48
src/app/modules/map/map.provider.spec.ts
Normal file
48
src/app/modules/map/map.provider.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
232
src/app/modules/map/map.provider.ts
Normal file
232
src/app/modules/map/map.provider.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
105
src/app/modules/map/page/map-page.html
Normal file
105
src/app/modules/map/page/map-page.html
Normal 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> {{
|
||||
'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> {{
|
||||
'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>
|
||||
119
src/app/modules/map/page/map-page.scss
Normal file
119
src/app/modules/map/page/map-page.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
60
src/app/modules/map/page/modals/map-list-modal.component.ts
Normal file
60
src/app/modules/map/page/modals/map-list-modal.component.ts
Normal 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[];
|
||||
});
|
||||
}
|
||||
}
|
||||
13
src/app/modules/map/page/modals/map-list.html
Normal file
13
src/app/modules/map/page/modals/map-list.html
Normal 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>
|
||||
0
src/app/modules/map/page/modals/map-list.scss
Normal file
0
src/app/modules/map/page/modals/map-list.scss
Normal 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;
|
||||
}
|
||||
13
src/app/modules/map/page/modals/map-single.html
Normal file
13
src/app/modules/map/page/modals/map-single.html
Normal 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>
|
||||
0
src/app/modules/map/page/modals/map-single.scss
Normal file
0
src/app/modules/map/page/modals/map-single.scss
Normal file
103
src/app/modules/map/position.service.spec.ts
Normal file
103
src/app/modules/map/position.service.spec.ts
Normal 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)),
|
||||
);
|
||||
});
|
||||
});
|
||||
163
src/app/modules/map/position.service.ts
Normal file
163
src/app/modules/map/position.service.ts
Normal 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;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
81
src/app/modules/map/widget/map-widget.component.ts
Normal file
81
src/app/modules/map/widget/map-widget.component.ts
Normal 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:
|
||||
'© <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),
|
||||
);
|
||||
}
|
||||
}
|
||||
16
src/app/modules/map/widget/map-widget.html
Normal file
16
src/app/modules/map/widget/map-widget.html
Normal 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>
|
||||
12
src/app/modules/map/widget/map-widget.scss
Normal file
12
src/app/modules/map/widget/map-widget.scss
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user