feat: migrate to protomaps and maplibre

This commit is contained in:
2024-01-12 10:47:53 +01:00
committed by Rainer Killinger
parent 964516d3cf
commit 2f64d69693
90 changed files with 2203 additions and 1693 deletions

View File

@@ -35,20 +35,13 @@
"glob": "**/*",
"input": "src/assets",
"output": "assets"
},
{
"glob": "**/*",
"input": "./node_modules/leaflet/dist/images",
"output": "assets/"
}
],
"styles": [
{
"input": "src/global.scss",
"inject": true
},
"./node_modules/leaflet/dist/leaflet.css",
"./node_modules/leaflet.markercluster/dist/MarkerCluster.Default.css"
}
]
},
"configurations": {
@@ -131,11 +124,6 @@
"glob": "**/*",
"input": "src/assets",
"output": "/assets"
},
{
"glob": "**/*",
"input": "./node_modules/leaflet/dist/images",
"output": "assets/"
}
]
}

View File

@@ -38,13 +38,7 @@
"licenses": "license-checker --json > src/assets/about/licenses.json && node ./scripts/accumulate-licenses.mjs && git add src/assets/about/licenses.json",
"lint": "ng lint && stylelint \"**/*.scss\"",
"lint:fix": "eslint --fix -c .eslintrc.json --ignore-path .eslintignore --ext .ts,.html src/ && stylelint --fix \"**/*.scss\"",
<<<<<<< HEAD
"minify-icons": "ts-node-esm scripts/minify-icon-font.ts",
||||||| parent of 7b431b8f (feat: type-safe sc-icons)
"minify-icons": "ts-node scripts/minify-icon-font.ts",
=======
"minify-icons": "node scripts/minify-icon-font.mjs",
>>>>>>> 7b431b8f (feat: type-safe sc-icons)
"postinstall": "jetify && echo \"skipping jetify in production mode\"",
"preview": "http-server www --p 8101 -o",
"push": "git push && git push origin \"v$npm_package_version\"",
@@ -65,8 +59,6 @@
"@angular/forms": "17.3.0",
"@angular/platform-browser": "17.3.0",
"@angular/router": "17.3.0",
"@asymmetrik/ngx-leaflet": "17.0.0",
"@asymmetrik/ngx-leaflet-markercluster": "17.0.0",
"@awesome-cordova-plugins/calendar": "6.6.0",
"@awesome-cordova-plugins/core": "6.6.0",
"@capacitor/app": "5.0.7",
@@ -87,6 +79,7 @@
"@ionic-native/core": "5.36.0",
"@ionic/angular": "7.8.0",
"@ionic/storage-angular": "4.0.0",
"@maplibre/ngx-maplibre-gl": "17.4.1",
"@ngx-translate/core": "15.0.0",
"@ngx-translate/http-loader": "8.0.0",
"@openid/appauth": "1.3.1",
@@ -103,15 +96,15 @@
"geojson": "0.5.0",
"ionic-appauth": "0.9.0",
"jsonpath-plus": "6.0.1",
"leaflet": "1.9.4",
"leaflet.markercluster": "1.5.3",
"material-symbols": "0.17.0",
"maplibre-gl": "4.0.2",
"material-symbols": "0.17.1",
"moment": "2.30.1",
"ngx-date-fns": "11.0.0",
"ngx-logger": "5.0.12",
"ngx-markdown": "17.1.1",
"ngx-moment": "6.0.2",
"opening_hours": "3.8.0",
"pmtiles": "3.0.3",
"rxjs": "7.8.1",
"semver": "7.6.0",
"swiper": "8.4.5",
@@ -153,8 +146,6 @@
"@types/karma": "6.3.8",
"@types/karma-coverage": "2.0.3",
"@types/karma-jasmine": "4.0.5",
"@types/leaflet": "1.9.8",
"@types/leaflet.markercluster": "1.5.4",
"@types/node": "18.15.3",
"@types/semver": "7.5.8",
"@typescript-eslint/eslint-plugin": "7.2.0",
@@ -181,7 +172,7 @@
"karma-junit-reporter": "2.0.1",
"karma-mocha-reporter": "2.2.5",
"license-checker": "25.0.1",
"stylelint": "16.2.1",
"stylelint": "16.3.1",
"stylelint-config-clean-order": "5.4.1",
"stylelint-config-prettier-scss": "1.0.0",
"stylelint-config-recommended-scss": "14.0.0",

View File

@@ -30,7 +30,6 @@ import {environment} from '../environments/environment';
import {AppRoutingModule} from './app-routing.module';
import {AppComponent} from './app.component';
import {CatalogModule} from './modules/catalog/catalog.module';
import {ConfigModule} from './modules/config/config.module';
import {ConfigProvider} from './modules/config/config.provider';
import {DashboardModule} from './modules/dashboard/dashboard.module';
import {DataModule} from './modules/data/data.module';
@@ -70,6 +69,8 @@ import {setDefaultOptions} from 'date-fns';
import {DateFnsConfigurationService} from 'ngx-date-fns';
import {Capacitor} from '@capacitor/core';
import {SplashScreen} from '@capacitor/splash-screen';
import maplibregl from 'maplibre-gl';
import {Protocol} from 'pmtiles';
registerLocaleData(localeDe);
@@ -91,6 +92,7 @@ export function initializerFactory(
) {
return async () => {
try {
maplibregl.addProtocol('pmtiles', new Protocol().tile);
initLogger(logger);
await storageProvider.init();
await configProvider.init();
@@ -151,7 +153,6 @@ export function createTranslateLoader(http: HttpClient) {
BrowserAnimationsModule,
CatalogModule,
CommonModule,
ConfigModule,
DashboardModule,
DataModule,
HebisModule,

View File

@@ -19,7 +19,6 @@ import {FormsModule} from '@angular/forms';
import {IonicModule} from '@ionic/angular';
import {TranslateModule} from '@ngx-translate/core';
import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {ConfigProvider} from '../config/config.provider';
import {AboutPageComponent} from './about-page/about-page.component';
import {MarkdownModule} from 'ngx-markdown';
import {AboutPageContentComponent} from './about-page/about-page-content.component';
@@ -64,6 +63,5 @@ const settingsRoutes: Routes = [
ScrollingModule,
UtilModule,
],
providers: [ConfigProvider],
})
export class AboutModule {}

View File

@@ -1,27 +0,0 @@
/*
* Copyright (C) 2019 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 {NgModule} from '@angular/core';
import {DataModule} from '../data/data.module';
import {StorageModule} from '../storage/storage.module';
import {ConfigProvider} from './config.provider';
/**
* TODO
*/
@NgModule({
imports: [StorageModule, DataModule],
providers: [ConfigProvider],
})
export class ConfigModule {}

View File

@@ -73,6 +73,7 @@ export class ConfigProvider {
swHttpClient: StAppsWebHttpClient,
private readonly logger: NGXLogger,
) {
console.log('config init');
this.client = new Client(swHttpClient, environment.backend_url, environment.backend_version);
}

View File

@@ -17,7 +17,6 @@ import {CommonModule} from '@angular/common';
import {HttpClientModule} from '@angular/common/http';
import {NgModule} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {LeafletModule} from '@asymmetrik/ngx-leaflet';
import {IonicModule, Platform} from '@ionic/angular';
import {TranslateModule} from '@ngx-translate/core';
import {MarkdownModule} from 'ngx-markdown';
@@ -30,7 +29,7 @@ import {UtilModule} from '../../util/util.module';
import {CalendarService} from '../calendar/calendar.service';
import {ScheduleProvider} from '../calendar/schedule.provider';
import {GeoNavigationDirective} from '../map/geo-navigation.directive';
import {MapWidgetComponent} from '../map/widget/map-widget.component';
import {MapWidgetComponent} from '../map/map-widget.component';
import {MenuModule} from '../menu/menu.module';
import {SettingsProvider} from '../settings/settings.provider';
import {StorageModule} from '../storage/storage.module';
@@ -142,7 +141,6 @@ import {ShareButtonComponent} from './elements/share-button.component';
FoodDataListComponent,
LocateActionChipComponent,
LongInlineTextComponent,
MapWidgetComponent,
MessageDetailContentComponent,
MessageListItemComponent,
JobPostingDetailContentComponent,
@@ -187,9 +185,9 @@ import {ShareButtonComponent} from './elements/share-button.component';
CommonModule,
DataRoutingModule,
FormsModule,
MapWidgetComponent,
HttpClientModule,
IonicModule.forRoot(),
LeafletModule,
MarkdownModule.forRoot(),
MenuModule,
IonIconModule,

View File

@@ -14,6 +14,10 @@
*/
@import '../../../../theme/util/mixins';
:host {
display: contents;
}
stapps-origin-detail {
// css hack to make the element stick to the bottom of the scroll container even
// when the content is not filling it
@@ -49,13 +53,10 @@ stapps-origin-detail {
background: var(--ion-color-primary);
}
// Firefox doesn't support this yet...
@supports selector(:has(*)) {
& > .expand-when-space,
&:has(> .expand-when-space) {
flex: 1;
height: unset;
}
& > .expand-when-space,
&:has(> .expand-when-space) {
flex: 1;
height: unset;
}
}
}

View File

@@ -17,7 +17,6 @@ ion-content > div {
display: flex;
flex: 1;
flex-direction: column;
min-height: 100%;
}
ion-title {

View File

@@ -21,6 +21,7 @@ import {SCThingUserOrigin, SCThingRemoteOrigin} from '@openstapps/core';
@Component({
selector: 'stapps-origin-detail',
templateUrl: 'origin-detail.html',
styleUrl: 'origin-detail.scss',
})
export class OriginDetailComponent {
/**

View File

@@ -14,71 +14,61 @@
-->
@if (origin.type === 'user') {
<ion-card>
<ion-card-header
>{{ 'data.types.origin.TITLE' | translate | titlecase }}:
{{ 'data.types.origin.USER' | translate | titlecase }}</ion-card-header
>
<ion-card-content>
<p>
{{ 'data.types.origin.detail.CREATED' | translate | titlecase }}:
{{ origin.created | amDateFormat: 'll' }}
</p>
@if (origin.updated) {
<p>
{{ 'data.types.origin.detail.UPDATED' | translate | titlecase }}:
{{ origin.updated | amDateFormat: 'll' }}
</p>
}
@if (origin.modified) {
<p>
{{ 'data.types.origin.detail.MODIFIED' | translate | titlecase }}:
{{ origin.modified | amDateFormat: 'll' }}
</p>
}
@if (origin.maintainer) {
<p>
{{ 'data.types.origin.detail.MAINTAINER' | translate }}:
<a [routerLink]="['/data-detail', origin.maintainer.uid]">{{ origin.maintainer.name }}</a>
</p>
}
</ion-card-content>
</ion-card>
<h3>
{{ 'data.types.origin.TITLE' | translate | sentencecase }}:
{{ 'data.types.origin.USER' | translate | sentencecase }}
</h3>
<p>
{{ 'data.types.origin.detail.CREATED' | translate | sentencecase }}:
{{ origin.created | amDateFormat: 'll' }}
</p>
@if (origin.updated) {
<p>
{{ 'data.types.origin.detail.UPDATED' | translate | sentencecase }}:
{{ origin.updated | amDateFormat: 'll' }}
</p>
}
@if (origin.modified) {
<p>
{{ 'data.types.origin.detail.MODIFIED' | translate | sentencecase }}:
{{ origin.modified | amDateFormat: 'll' }}
</p>
}
@if (origin.maintainer) {
<p>
{{ 'data.types.origin.detail.MAINTAINER' | translate | sentencecase }}:
<a [routerLink]="['/data-detail', origin.maintainer.uid]">{{ origin.maintainer.name }}</a>
</p>
}
}
@if (origin.type === 'remote') {
<ion-card>
<ion-card-header
>{{ 'data.types.origin.TITLE' | translate | titlecase }}:
{{ 'data.types.origin.REMOTE' | translate | titlecase }}</ion-card-header
>
<ion-card-content>
<p>
{{ 'data.types.origin.detail.INDEXED' | translate | titlecase }}:
{{ origin.indexed | amDateFormat: 'll' }}
</p>
@if (origin.modified) {
<p>
{{ 'data.types.origin.detail.MODIFIED' | translate | titlecase }}:
{{ origin.modified | amDateFormat: 'll' }}
</p>
}
@if (origin.name) {
<p>{{ 'data.types.origin.detail.MAINTAINER' | translate }}: {{ origin.name }}</p>
}
@if (origin.maintainer) {
<p>
{{ 'data.types.origin.detail.MAINTAINER' | translate | titlecase }}:
<a [routerLink]="['/data-detail', origin.maintainer.uid]">{{ origin.maintainer.name }}</a>
</p>
}
@if (origin.responsibleEntity) {
<p>
{{ 'data.types.origin.detail.RESPONSIBLE' | translate | titlecase }}:
<a [routerLink]="['/data-detail', origin.responsibleEntity.uid]">{{
origin.responsibleEntity.name
}}</a>
</p>
}
</ion-card-content>
</ion-card>
<h3>
{{ 'data.types.origin.TITLE' | translate | sentencecase }}:
{{ 'data.types.origin.REMOTE' | translate | sentencecase }}
</h3>
<p>
{{ 'data.types.origin.detail.INDEXED' | translate | sentencecase }}:
{{ origin.indexed | amDateFormat: 'll' }}
</p>
@if (origin.modified) {
<p>
{{ 'data.types.origin.detail.MODIFIED' | translate | sentencecase }}:
{{ origin.modified | amDateFormat: 'll' }}
</p>
}
@if (origin.name) {
<p>{{ 'data.types.origin.detail.MAINTAINER' | translate | sentencecase }}: {{ origin.name }}</p>
}
@if (origin.maintainer) {
<p>
{{ 'data.types.origin.detail.MAINTAINER' | translate | sentencecase }}:
<a [routerLink]="['/data-detail', origin.maintainer.uid]">{{ origin.maintainer.name }}</a>
</p>
}
@if (origin.responsibleEntity) {
<p>
{{ 'data.types.origin.detail.RESPONSIBLE' | translate | sentencecase }}:
<a [routerLink]="['/data-detail', origin.responsibleEntity.uid]">{{ origin.responsibleEntity.name }}</a>
</p>
}
}

View File

@@ -0,0 +1,15 @@
:host {
padding: var(--spacing-lg);
padding-top: 0;
}
h3 {
font-weight: bold;
}
h3,
p {
margin: 0;
font-size: 0.8em;
opacity: 0.8;
}

View File

@@ -68,5 +68,5 @@
</ion-card>
}
@if (item.inPlace && item.inPlace.geo) {
<stapps-map-widget [place]="item.inPlace"></stapps-map-widget>
<stapps-map-widget [place]="$any(item.inPlace)"></stapps-map-widget>
}

View File

@@ -17,7 +17,6 @@ import {PositionService} from '../../../map/position.service';
import {filter, Observable} from 'rxjs';
import {hasValidLocation, isSCFloor, PlaceTypes, PlaceTypesWithDistance} from './place-types';
import {map} from 'rxjs/operators';
import {LatLng, geoJSON} from 'leaflet';
import {trigger, transition, style, animate} from '@angular/animations';
/**
@@ -39,13 +38,14 @@ export class PlaceListItemComponent {
@Input() set item(item: PlaceTypes) {
this._item = item;
if (!isSCFloor(item) && hasValidLocation(item)) {
this.distance = this.positionService.watchCurrentLocation().pipe(
map(position =>
new LatLng(position.latitude, position.longitude).distanceTo(
geoJSON(item.geo.point).getBounds().getCenter(),
),
this.distance = this.positionService.geoLocation.pipe(
map(
position =>
Math.hypot(
position.coords.latitude - item.geo.point.coordinates[1],
position.coords.longitude - item.geo.point.coordinates[0],
) * 111_139,
),
filter(it => it !== undefined),
);
}

View File

@@ -12,7 +12,6 @@
* 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 moment, {Moment} from 'moment';
import {AfterViewInit, Component, DestroyRef, inject, Input} from '@angular/core';
import {SCDish, SCISO8601Date, SCPlace} from '@openstapps/core';
import {PlaceMensaService} from './place-mensa-service';
@@ -53,11 +52,6 @@ export class PlaceMensaDetailComponent implements AfterViewInit {
*/
selectedDay: string;
/**
* First day to display menu items for
*/
startingDay: Moment;
destroy$ = inject(DestroyRef);
constructor(
@@ -65,9 +59,7 @@ export class PlaceMensaDetailComponent implements AfterViewInit {
protected router: Router,
readonly routerOutlet: IonRouterOutlet,
private readonly dataRoutingService: DataRoutingService,
) {
this.startingDay = moment().startOf('day');
}
) {}
ngAfterViewInit() {
if (!this.openAsModal) {

View File

@@ -6,7 +6,6 @@ import {MomentModule} from 'ngx-moment';
import {DataModule} from '../data/data.module';
import {UtilModule} from '../../util/util.module';
import {IonIconModule} from '../../util/ion-icon/ion-icon.module';
import {ConfigProvider} from '../config/config.provider';
import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {RouterModule, Routes} from '@angular/router';
import {JobsPageComponent} from './page/jobs-page.component';
@@ -26,6 +25,5 @@ const jobsRoutes: Routes = [{path: 'jobs', component: JobsPageComponent}];
DataModule,
UtilModule,
],
providers: [ConfigProvider],
})
export class JobModule {}

View File

@@ -0,0 +1,51 @@
import {Pipe, PipeTransform} from '@angular/core';
import {MapService} from '@maplibre/ngx-maplibre-gl';
import {Feature, Point} from 'geojson';
import {MapGeoJSONFeature, type GeoJSONSource} from 'maplibre-gl';
import {combineLatest, distinctUntilChanged, map, mergeMap, from, Observable, ReplaySubject} from 'rxjs';
import {SCFeatureProperties} from './feature-collection.pipe';
@Pipe({
name: 'mglClusterLeaves',
standalone: true,
pure: true,
})
export class MglClusterLeavesPipe implements PipeTransform {
source = new ReplaySubject<string>(1);
feature = new ReplaySubject<MapGeoJSONFeature>(1);
limit = new ReplaySubject<number>(1);
offset = new ReplaySubject<number>(1);
leaves: Observable<Feature<Point, SCFeatureProperties>[]> = combineLatest([
this.source.pipe(
distinctUntilChanged(),
map(source => this.mapService.getSource(source) as GeoJSONSource),
),
this.feature.pipe(distinctUntilChanged(it => it.properties.cluster_id)),
this.limit.pipe(distinctUntilChanged()),
this.offset.pipe(distinctUntilChanged()),
]).pipe(
mergeMap(([source, feature, limit, offset]) =>
from(source.getClusterLeaves(feature.properties.cluster_id, limit, offset)),
),
);
constructor(private mapService: MapService) {}
transform(
source: string,
feature: MapGeoJSONFeature,
limit = 0,
offset = 0,
): Observable<Feature<Point, SCFeatureProperties>[]> {
// MapLibre triggers change detection when the map moves, so this is to prevent flicker
this.source.next(source);
this.feature.next(feature);
this.limit.next(limit);
this.offset.next(offset);
return this.leaves;
}
}

View File

@@ -0,0 +1,38 @@
import {animate, style, transition, trigger} from '@angular/animations';
import {AsyncPipe} from '@angular/common';
import {ChangeDetectionStrategy, Component, Input, inject} from '@angular/core';
import {IonicModule} from '@ionic/angular';
import {MapService} from '@maplibre/ngx-maplibre-gl';
import {map, delay, Subject, race, mergeWith} from 'rxjs';
import {IonIconModule} from 'src/app/util/ion-icon/ion-icon.module';
@Component({
selector: 'stapps-map-attribution',
templateUrl: './attribution.html',
styleUrl: './attribution.scss',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [IonicModule, IonIconModule, AsyncPipe],
animations: [
trigger('fade', [
transition(':enter', [
style({opacity: 0, scale: '0.8 1'}),
animate('0.2s ease', style({opacity: 1, scale: 1})),
]),
transition(':leave', [
style({opacity: 1, scale: 1}),
animate('0.2s ease', style({opacity: 0, scale: '0.8 1'})),
]),
]),
],
})
export class AttributionComponent {
@Input() direction: 'left' | 'right' = 'right';
manualVisible = new Subject<void>();
hideAttribution = race(
this.manualVisible,
inject(MapService).mapCreated$.pipe(delay(5000), mergeWith(this.manualVisible)),
).pipe(map((_, i) => i % 2 === 0));
}

View File

@@ -0,0 +1,8 @@
@if ((hideAttribution | async) === null) {
<ion-button [attr.direction]="direction" @fade class="attribution" color="light"
>© OpenStreetMap</ion-button
>
}
<ion-button class="info" shape="round" color="light" (click)="this.manualVisible.next()">
<ion-icon slot="icon-only" name="info"></ion-icon>
</ion-button>

View File

@@ -0,0 +1,36 @@
:host {
position: relative;
}
ion-button {
height: 28px;
min-height: 28px;
margin: 0;
font-size: 10px;
}
ion-button.info {
--padding-start: 4px;
--padding-end: 4px;
}
ion-button.attribution {
position: absolute;
top: -3px;
&[direction='right'] {
--border-radius: 0 14px 14px 0;
--padding-start: 14px;
left: 16px;
transform-origin: 0 0;
}
&[direction='left'] {
--border-radius: 14px 0 0 14px;
--padding-end: 14px;
right: 16px;
transform-origin: 100% 0;
}
}

View File

@@ -0,0 +1,40 @@
import {AsyncPipe} from '@angular/common';
import {ChangeDetectionStrategy, Component} from '@angular/core';
import {IonicModule} from '@ionic/angular';
import {MapService} from '@maplibre/ngx-maplibre-gl';
import {MapEventType} from 'maplibre-gl';
import {map, mergeMap, fromEventPattern, merge} from 'rxjs';
import {IonIconModule} from 'src/app/util/ion-icon/ion-icon.module';
@Component({
selector: 'stapps-compass-control',
templateUrl: './compass-control.html',
styleUrl: './compass-control.scss',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [AsyncPipe, IonicModule, IonIconModule],
})
export class CompassControlComponent {
transform = this.mapService.mapCreated$.pipe(
mergeMap(() =>
merge(
fromEventPattern<MapEventType['rotate']>(
handler => this.mapService.mapInstance.on('rotate', handler),
handler => this.mapService.mapInstance.off('rotate', handler),
),
fromEventPattern<MapEventType['pitch']>(
handler => this.mapService.mapInstance.on('pitch', handler),
handler => this.mapService.mapInstance.off('pitch', handler),
),
),
),
map(event => {
const pitch = event.target.transform.pitch;
const angle = event.target.transform.angle;
return `rotateX(${pitch}deg) rotateZ(${angle}rad)`;
}),
);
constructor(readonly mapService: MapService) {}
}

View File

@@ -0,0 +1,7 @@
<ion-fab-button (click)="mapService.mapInstance.resetNorthPitch()" size="small" color="light">
<svg [style.transform]="transform | async" width="24" height="24" viewBox="0 0 32 32">
>
<path d="m8.5 15 6.56-13.13a1.05 1.05 180 0 1 1.88 0L23.5 15 16 12.5Z" />
<path d="m8.5 15 6.56-13.13a1.05 1.05 180 0 1 1.88 0L23.5 15 16 12.5Z" transform="rotate(180 16 16)" />
</svg>
</ion-fab-button>

View File

@@ -0,0 +1,11 @@
path {
stroke: none;
&:first-child {
fill: var(--ion-color-primary);
}
&:last-child {
fill: var(--ion-color-medium);
}
}

View File

@@ -0,0 +1,91 @@
import {AsyncPipe} from '@angular/common';
import {
AfterContentInit,
ChangeDetectionStrategy,
Component,
ElementRef,
Input,
OnDestroy,
ViewChild,
} from '@angular/core';
import {IonicModule} from '@ionic/angular';
import {MapService} from '@maplibre/ngx-maplibre-gl';
import {FitBoundsOptions, GeolocateControl, GeolocateControlOptions} from 'maplibre-gl';
import {Map as MapLibre} from 'maplibre-gl';
import {BehaviorSubject} from 'rxjs';
import {IonIconModule} from 'src/app/util/ion-icon/ion-icon.module';
type WatchState = InstanceType<typeof GeolocateControl>['_watchState'];
class CustomGeolocateControl extends GeolocateControl {
constructor(
public _container: HTMLElement,
watchState: BehaviorSubject<WatchState>,
options: GeolocateControlOptions,
) {
super(options);
Object.defineProperty(this, '_watchState', {
get() {
return watchState.value;
},
set(value: WatchState) {
watchState.next(value);
},
});
}
override onAdd(map: MapLibre): HTMLElement {
const container = this._container;
this._container = document.createElement('div');
this._map = map;
this._setupUI(true);
this._container = container;
return this._container;
}
override onRemove() {}
}
@Component({
selector: 'stapps-geolocate-control',
templateUrl: './geolocate-control.html',
styleUrl: './geolocate-control.scss',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [AsyncPipe, IonicModule, IonIconModule],
})
export class GeolocateControlComponent implements AfterContentInit, OnDestroy {
@Input() position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
@Input() positionOptions?: PositionOptions;
@Input() fitBoundsOptions?: FitBoundsOptions;
@Input() trackUserLocation?: boolean;
@Input() showUserLocation?: boolean;
@ViewChild('content', {static: true}) content: ElementRef;
watchState = new BehaviorSubject<WatchState>('OFF');
control: CustomGeolocateControl;
constructor(private mapService: MapService) {}
ngAfterContentInit() {
this.control = new CustomGeolocateControl(this.content.nativeElement, this.watchState, {
positionOptions: this.positionOptions,
fitBoundsOptions: this.fitBoundsOptions,
trackUserLocation: this.trackUserLocation,
showUserLocation: this.showUserLocation,
});
this.mapService.mapCreated$.subscribe(() => {
this.mapService.addControl(this.control, this.position);
});
}
ngOnDestroy(): void {
this.mapService.removeControl(this.control);
}
}

View File

@@ -0,0 +1,18 @@
<div #content class="maplibregl-ctrl">
<ion-fab-button color="light" (click)="control.trigger()">
@switch (watchState | async) {
@case ('ACTIVE_LOCK') {
<ion-icon name="my_location" color="primary"></ion-icon>
}
@case ('BACKGROUND') {
<ion-icon name="location_searching"></ion-icon>
}
@case ('WAITING_ACTIVE') {
<ion-icon name="location_searching"></ion-icon>
}
@default {
<ion-icon name="location_disabled"></ion-icon>
}
}
</ion-fab-button>
</div>

View File

@@ -0,0 +1,86 @@
import {ChangeDetectionStrategy, Component, Input, Optional} from '@angular/core';
import {GeoJSONSourceComponent, LayerComponent, MapService} from '@maplibre/ngx-maplibre-gl';
import {FeatureCollection, Polygon} from 'geojson';
import {SCFeatureProperties} from '../feature-collection.pipe';
import {
FillLayerSpecification,
LineLayerSpecification,
MapLayerMouseEvent,
SymbolLayerSpecification,
} from 'maplibre-gl';
import {DataRoutingService} from '../../data/data-routing.service';
import {MapDataProvider} from '../map-data.provider';
import {fromEvent, map, startWith, Observable} from 'rxjs';
import {AsyncPipe} from '@angular/common';
/**
* Get a CCS variable value
*/
function getCssVariable(color: string) {
return getComputedStyle(document.documentElement).getPropertyValue(color);
}
@Component({
selector: 'stapps-building-markers',
templateUrl: './building-markers.html',
styleUrl: './building-markers.scss',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [GeoJSONSourceComponent, LayerComponent, AsyncPipe],
})
export class BuildingMarkersComponent {
accentColor = getCssVariable('--ion-color-primary');
haloColor = fromEvent<MediaQueryListEvent>(
window.matchMedia('(prefers-color-scheme: dark)'),
'change',
).pipe(
map(() => getCssVariable('--ion-background-color')),
startWith(getCssVariable('--ion-background-color')),
);
buildingPaint: LineLayerSpecification['paint'] = {
'line-color': this.accentColor,
'line-width': 2,
};
buildingFillPaint: FillLayerSpecification['paint'] = {
'fill-color': `${this.accentColor}22`,
};
buildingLabelLayout: SymbolLayerSpecification['layout'] = {
'text-field': '{name}',
'text-font': ['barlow-700-normal'],
'text-max-width': 8,
'text-size': 13,
};
buildingLabelPaint: Observable<SymbolLayerSpecification['paint']> = this.haloColor.pipe(
map(haloColor => ({
'text-color': this.accentColor,
'text-halo-color': haloColor,
'text-halo-width': 1,
})),
);
@Input({required: true}) data: FeatureCollection<Polygon, SCFeatureProperties>;
constructor(
@Optional() readonly dataProvider: MapDataProvider | null,
readonly dataRoutingService: DataRoutingService,
readonly mapService: MapService,
) {}
async featureClick(event: MapLayerMouseEvent) {
if (this.dataProvider === null) return;
if (event.originalEvent.target !== event.target._canvas) return;
const feature = event.features?.[0];
if (!feature) return;
const item = this.dataProvider.current.value?.data.find(it => it.uid === feature.properties.uid);
if (item === undefined) return;
this.dataRoutingService.emitChildEvent(item);
}
}

View File

@@ -0,0 +1,26 @@
<mgl-geojson-source id="polygons" [data]="data"></mgl-geojson-source>
<mgl-layer
id="stapps-building-fill"
type="fill"
source="polygons"
before="poi_label"
[paint]="buildingFillPaint"
(layerClick)="featureClick($event)"
(layerMouseEnter)="dataProvider !== null && mapService.changeCanvasCursor('pointer')"
(layerMouseLeave)="dataProvider !== null && mapService.changeCanvasCursor('grab')"
></mgl-layer>
<mgl-layer
id="stapps-building-outline"
type="line"
source="polygons"
before="poi_label"
[paint]="buildingPaint"
></mgl-layer>
<mgl-layer
id="stapps-building-label"
type="symbol"
source="polygons"
after="poi_label"
[paint]="(buildingLabelPaint | async) || {}"
[layout]="buildingLabelLayout"
></mgl-layer>

View File

@@ -0,0 +1,43 @@
import {ChangeDetectionStrategy, Component, HostBinding, Input, OnInit, Optional} from '@angular/core';
import {IonicModule} from '@ionic/angular';
import {IonIconModule} from 'src/app/util/ion-icon/ion-icon.module';
import {MapIconDirective} from '../map-icon.directive';
import {Feature, Point} from 'geojson';
import {SCFeatureProperties} from '../feature-collection.pipe';
import {MapDataProvider} from '../map-data.provider';
import {DataRoutingService} from '../../data/data-routing.service';
import {AddWordBreakOpportunitiesPipe} from '../../../util/word-break-opportunities.pipe';
@Component({
selector: 'stapps-poi-marker',
templateUrl: './poi-marker.html',
styleUrl: './poi-marker.scss',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [IonicModule, IonIconModule, MapIconDirective, AddWordBreakOpportunitiesPipe],
})
export class PoiMarkerComponent implements OnInit {
@Input({required: true}) feature: Feature<Point, SCFeatureProperties>;
@HostBinding('disabled') disabled = this.dataProvider === null;
fontSize = 0;
constructor(
@Optional() readonly dataProvider: MapDataProvider | null,
readonly dataRoutingService: DataRoutingService,
) {}
async featureClick() {
if (this.dataProvider === null) return;
const item = this.dataProvider.current.value?.data.find(it => it.uid === this.feature.properties.uid);
if (item === undefined) return;
this.dataRoutingService.emitChildEvent(item);
}
ngOnInit() {
this.fontSize = Math.max(10, 12 - Math.max(0, this.feature.properties.name.length - 16));
}
}

View File

@@ -0,0 +1,9 @@
<ion-button shape="round" (click)="featureClick()">
<ion-icon
slot="start"
[size]="16"
[fill]="true"
[name]="feature.properties.category | stappsMapIcon"
></ion-icon>
<ion-label>{{ feature.properties.name | addWordBreakOpportunities }}</ion-label>
</ion-button>

View File

@@ -0,0 +1,38 @@
ion-button {
--padding-top: 0;
--padding-bottom: 0;
--padding-start: var(--spacing-md);
--padding-end: var(--spacing-sm);
max-width: 120px;
min-height: 0;
margin: 0;
margin-block-end: 4px;
font: inherit;
font-size: 0.9em;
font-weight: bold;
&::part(native) {
height: 32px;
line-height: 1.2;
}
}
ion-icon {
flex-shrink: 0;
}
ion-label {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
padding: 4px 0;
text-align: left;
overflow-wrap: normal;
white-space: wrap;
-webkit-line-clamp: 2;
}

View File

@@ -0,0 +1,43 @@
import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
import {MapIconDirective} from '../map-icon.directive';
import {FeatureCollection, Point} from 'geojson';
import {SCFeatureProperties} from '../feature-collection.pipe';
import {animate, style, transition, trigger} from '@angular/animations';
import {MglClusterLeavesPipe} from '../cluster-leaves.pipe';
import {
ClusterPointDirective,
GeoJSONSourceComponent,
MarkersForClustersComponent,
PointDirective,
} from '@maplibre/ngx-maplibre-gl';
import {AsyncPipe} from '@angular/common';
import {PoiMarkerComponent} from './poi-marker.component';
@Component({
selector: 'stapps-poi-markers',
templateUrl: './poi-markers.html',
styleUrl: './poi-markers.scss',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MapIconDirective,
MglClusterLeavesPipe,
GeoJSONSourceComponent,
MarkersForClustersComponent,
AsyncPipe,
ClusterPointDirective,
PointDirective,
PoiMarkerComponent,
],
animations: [
trigger('fade', [
transition(':enter', [style({opacity: 0}), animate('200ms', style({opacity: 1}))]),
transition(':leave', [style({opacity: 1}), animate('200ms', style({opacity: 0}))]),
]),
],
})
export class PoiMarkersComponent {
@Input({required: true}) data: FeatureCollection<Point, SCFeatureProperties>;
@Input() clusterPreviewCount = 3;
}

View File

@@ -0,0 +1,33 @@
<mgl-geojson-source
id="pois"
[cluster]="true"
[clusterMaxZoom]="20"
[clusterRadius]="100"
[data]="data"
></mgl-geojson-source>
<mgl-markers-for-clusters source="pois">
<ng-template mglPoint let-feature>
<div class="marker">
<stapps-poi-marker @fade [feature]="feature"></stapps-poi-marker>
</div>
</ng-template>
<ng-template mglClusterPoint let-feature>
<div class="marker">
@if (
'pois'
| mglClusterLeaves
: feature
: clusterPreviewCount - (feature.properties.point_count > clusterPreviewCount ? 1 : 0)
| async;
as leaves
) {
@for (feature of leaves; track feature.id) {
<stapps-poi-marker @fade [feature]="feature"></stapps-poi-marker>
}
@if (feature.properties.point_count > leaves.length) {
<div @fade class="ellipsis">+{{ feature.properties.point_count - leaves.length }}</div>
}
}
</div>
</ng-template>
</mgl-markers-for-clusters>

View File

@@ -0,0 +1,24 @@
.ellipsis {
display: flex;
align-items: center;
justify-content: flex-start;
width: fit-content;
padding-inline: var(--spacing-md);
font-size: 0.8em;
color: var(--ion-color-primary-contrast);
opacity: 0.8;
background: var(--ion-color-primary);
border-radius: 15px;
}
button:not(:only-child) {
margin-bottom: 2px;
}
.marker {
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,81 @@
import {Pipe, PipeTransform} from '@angular/core';
import {SCThing, SCThings} from '@openstapps/core';
import {Feature, FeatureCollection, Point, Polygon} from 'geojson';
/**
* Very simple hash function
*
* MapLibre cannot use strings as feature ids because of
* vector tile spec limitations
*/
function simpleHash(value: string): number {
let hash = 0;
for (let i = 0; i < value.length; i++) {
hash = Math.trunc((hash << 5) - hash + value.codePointAt(i)!);
}
return hash >>> 0;
}
/**
* Finds the best name for a thing to display on the map
*/
function findBestName(thing: SCThing, targetLength = 14): string {
if (!thing.alternateNames || thing.name.length <= targetLength) return thing.name;
return thing.alternateNames.reduce(
(accumulator, current) =>
accumulator.length <= targetLength || accumulator.length <= current.length ? accumulator : current,
thing.name,
);
}
export interface SCFeatureProperties {
name: string;
category?: string;
uid: string;
}
@Pipe({
name: 'thingPoiFeatureCollection',
standalone: true,
pure: true,
})
export class ThingPoiFeatureCollectionPipe implements PipeTransform {
transform(things: SCThings[]): FeatureCollection<Point, SCFeatureProperties> {
return {
type: 'FeatureCollection',
features: things
.filter(thing => 'geo' in thing && thing.geo.polygon === undefined)
.map<Feature<Point, SCFeatureProperties>>(thing => ({
type: 'Feature',
properties: {
name: findBestName(thing),
category: 'categories' in thing ? thing.categories[0] : undefined,
uid: thing.uid,
},
geometry: (thing as Extract<SCThings, {geo: object}>).geo.point,
id: simpleHash(thing.uid),
})),
};
}
}
@Pipe({
name: 'thingPolygonFeatureCollection',
standalone: true,
pure: true,
})
export class ThingPolygonFeatureCollectionPipe implements PipeTransform {
transform(things: SCThings[]): FeatureCollection<Polygon, SCFeatureProperties> {
return {
type: 'FeatureCollection',
features: things
.filter(thing => 'geo' in thing && thing.geo.polygon !== undefined)
.map<Feature<Polygon, SCFeatureProperties>>(thing => ({
type: 'Feature',
geometry: (thing as Extract<SCThings, {geo: object}>).geo.polygon!,
properties: {uid: thing.uid, name: findBestName(thing)},
id: simpleHash(thing.uid),
})),
};
}
}

View File

@@ -0,0 +1,36 @@
import {Directive, HostListener} from '@angular/core';
import {Map, MapMouseEvent, MapStyleDataEvent} from 'maplibre-gl';
@Directive({
selector: 'mgl-map[auto-3d]',
standalone: true,
})
export class MapAuto3dDirective {
@HostListener('styleData', ['$event'])
styleData(event: MapStyleDataEvent) {
this.updatePitch(event.target);
}
@HostListener('pitchEvt', ['$event'])
pitch(event: MapMouseEvent) {
this.updatePitch(event.target);
}
updatePitch(map: Map) {
if (map.getPitch() === 0) {
const layer = map.getLayer('building-3d');
if (layer && layer?.visibility !== 'none') {
layer.visibility = 'none';
map.setPaintProperty('building', 'fill-opacity', 1);
map.setLayerZoomRange('building', 13, 24);
}
} else {
const layer = map.getLayer('building-3d');
if (layer && layer?.visibility !== 'visible') {
layer.visibility = 'visible';
map.setPaintProperty('building', 'fill-opacity', ['interpolate', ['linear'], ['zoom'], 15, 1, 16, 0]);
map.setLayerZoomRange('building', 13, 16);
}
}
}
}

View File

@@ -0,0 +1,59 @@
import {Injectable} from '@angular/core';
import {DataProvider} from '../data/data.provider';
import {SCGeoFilter, SCSearchRequest, SCSearchResponse} from '@openstapps/core';
import {BehaviorSubject} from 'rxjs';
@Injectable()
export class MapDataProvider extends DataProvider {
readonly current = new BehaviorSubject<SCSearchResponse | undefined>(undefined);
readonly currentBounds = new BehaviorSubject<
[[minLon: number, maxLat: number], [maxLon: number, minLat: number]] | undefined
>(undefined);
override async search(query: SCSearchRequest): Promise<SCSearchResponse> {
if (query.query && this.currentBounds.value !== undefined) {
const boundsFilter: SCGeoFilter = {
type: 'geo',
arguments: {
field: 'geo',
shape: {
type: 'envelope',
coordinates: this.currentBounds.value,
},
},
};
query.filter = query.filter
? {
type: 'boolean',
arguments: {
operation: 'and',
filters: [query.filter, boundsFilter],
},
}
: boundsFilter;
}
if (query.from === 0 || this.current.value === undefined) {
this.current.next(
await super.search({
...query,
size: undefined,
}),
);
}
if (query.from === undefined || query.size === undefined) {
return this.current.value!;
}
return {
...this.current.value!,
data: this.current.value!.data.slice(query.from, query.from + query.size),
pagination: {
...this.current.value!.pagination,
offset: query.from,
count: Math.min(query.size, this.current.value!.data.length - query.from),
},
};
}
}

View File

@@ -0,0 +1,37 @@
import {SCThings, SCPlace} from '@openstapps/core';
import {SCIcon} from '../../util/ion-icon/icon';
import {Pipe, PipeTransform} from '@angular/core';
import {MaterialSymbol} from 'material-symbols';
const mapIcons: Record<Extract<SCThings, SCPlace>['categories'][number], MaterialSymbol> = {
'cafe': SCIcon.local_cafe,
'learn': SCIcon.school,
'canteen': SCIcon.restaurant,
'computer': SCIcon.computer,
'education': SCIcon.school,
'laboratory': SCIcon.science,
'library': SCIcon.local_library,
'lounge': SCIcon.weekend,
'office': SCIcon.meeting_room,
'restaurant': SCIcon.restaurant,
'restroom': SCIcon.wc,
'student canteen': SCIcon.restaurant,
'student union': SCIcon.groups,
'validator': SCIcon.badge,
'card charger': SCIcon.credit_card,
'printer': SCIcon.print,
'disabled access': SCIcon.accessible,
};
const defaultIcon = SCIcon.not_listed_location;
@Pipe({
name: 'stappsMapIcon',
standalone: true,
pure: true,
})
export class MapIconDirective implements PipeTransform {
transform(value: keyof typeof mapIcons | string | undefined): MaterialSymbol {
return mapIcons[value as keyof typeof mapIcons] ?? defaultIcon;
}
}

View File

@@ -0,0 +1,109 @@
/*
* Copyright (C) 2024 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 type {AnimationBuilder} from '@ionic/angular';
import {AnimationController} from '@ionic/angular';
import type {AnimationOptions} from '@ionic/angular/common/providers/nav-controller';
import {iosDuration, iosEasing, mdDuration, mdEasing} from 'src/app/animation/easings';
/**
* Get the center of an element
*/
function center(element: HTMLElement): {x: number; y: number} {
const bounds = element.getBoundingClientRect();
return {
x: bounds.left + bounds.width / 2,
y: bounds.top + bounds.height / 2,
};
}
/**
* Get the flip transform for an element
*/
function flipTransform(from: HTMLElement, to: HTMLElement): string {
const fromCenter = center(from);
const toCenter = center(to);
const dx = fromCenter.x - toCenter.x;
const dy = fromCenter.y - toCenter.y;
return `translate(${dx}px, ${dy}px)`;
}
/**
* Get the flip clip path for an element
*/
function flipClipPath(from: HTMLElement, to: HTMLElement): string {
const fromBounds = from.getBoundingClientRect();
const toBounds = to.getBoundingClientRect();
const y = Math.max(0, (toBounds.height - fromBounds.height) / 2);
const x = Math.max(0, (toBounds.width - fromBounds.width) / 2);
return `inset(${y}px ${x}px)`;
}
/**
* Animation of the map maximize
*/
export function mapMaximizeAnimation(animationController: AnimationController): AnimationBuilder {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (_baseElement: HTMLElement, options: AnimationOptions | any) => {
const rootTransition = animationController
.create()
.duration(options.duration ?? (options.mode === 'ios' ? iosDuration * 1.5 : mdDuration * 2.5))
.easing(options.mode === 'ios' ? iosEasing : mdEasing);
const enteringMap = options.enteringEl.querySelector('mgl-map') as HTMLElement;
const leavingMap = options.leavingEl.querySelector('mgl-map') as HTMLElement;
if (!enteringMap.classList.contains('ready')) {
rootTransition.delay(2000);
enteringMap.addEventListener(
'ready',
event => {
event.preventDefault();
setTimeout(() => {
if (rootTransition.isRunning()) {
rootTransition.stop();
rootTransition.delay(0);
rootTransition.play();
}
});
},
{once: true},
);
}
const mapEnterTransition = animationController
.create()
.fromTo('transform', flipTransform(leavingMap, enteringMap), 'translate(0, 0)')
.fromTo('clipPath', flipClipPath(leavingMap, enteringMap), 'inset(0px 0px)')
.addElement(enteringMap);
const mapExitTransition = animationController
.create()
.fromTo('transform', 'translate(0, 0)', flipTransform(enteringMap, leavingMap))
.fromTo('clipPath', 'inset(0px 0px)', flipClipPath(enteringMap, leavingMap))
.addElement(leavingMap);
const enterTransition = animationController
.create()
.fromTo('opacity', options.direction === 'back' ? '1' : '0', '1')
.addElement(options.enteringEl);
const exitTransition = animationController
.create()
.fromTo('opacity', '1', options.direction === 'back' ? '0' : '1')
.addElement(options.leavingEl);
rootTransition.addAnimation([enterTransition, exitTransition, mapEnterTransition, mapExitTransition]);
return rootTransition;
};
}

View File

@@ -0,0 +1,144 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {ChangeDetectionStrategy, Component} from '@angular/core';
import {IonicModule} from '@ionic/angular';
import {LngLatBoundsLike, MapLibreEvent} from 'maplibre-gl';
import {
ControlComponent,
GeolocateControlDirective,
MapComponent,
ScaleControlDirective,
} from '@maplibre/ngx-maplibre-gl';
import {TranslateModule} from '@ngx-translate/core';
import {ActivatedRoute, RouterLink} from '@angular/router';
import {MapAuto3dDirective} from './map-auto-3d.directive';
import {MediaQueryPipe} from '../../util/media-query.pipe';
import {MapStyleDirective} from './map-style.directive';
import {DataProvider} from '../data/data.provider';
import {SCSearchFilter, SCThingType} from '@openstapps/core';
import {IonIconModule} from '../../util/ion-icon/ion-icon.module';
import {DataModule} from '../data/data.module';
import {AsyncPipe} from '@angular/common';
import {GeolocateControlComponent} from './controls/geolocate-control.component';
import {CompassControlComponent} from './controls/compass-control.component';
import {MapSizeFixDirective} from './map-size-fix.directive';
import {MapDataProvider} from './map-data.provider';
import {ThingPoiFeatureCollectionPipe, ThingPolygonFeatureCollectionPipe} from './feature-collection.pipe';
import {BuildingMarkersComponent} from './elements/building-markers.component';
import {PoiMarkersComponent} from './elements/poi-markers.component';
import {AttributionComponent} from './controls/attribution.component';
import {filter, map} from 'rxjs';
/**
* The main page of the map
*/
@Component({
styleUrls: ['./map-page.scss'],
templateUrl: './map-page.html',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
MapDataProvider,
{
provide: DataProvider,
useExisting: MapDataProvider,
},
],
imports: [
AsyncPipe,
AttributionComponent,
BuildingMarkersComponent,
CompassControlComponent,
ControlComponent,
DataModule,
GeolocateControlComponent,
GeolocateControlDirective,
IonIconModule,
IonicModule,
MapAuto3dDirective,
MapComponent,
MapSizeFixDirective,
MapStyleDirective,
MediaQueryPipe,
PoiMarkersComponent,
RouterLink,
ScaleControlDirective,
ThingPoiFeatureCollectionPipe,
ThingPolygonFeatureCollectionPipe,
TranslateModule,
],
})
export class MapPageComponent {
forcedFilter: SCSearchFilter = {
type: 'boolean',
arguments: {
operation: 'or',
filters: [
{
type: 'value',
arguments: {
field: 'type',
value: [SCThingType.Building],
},
},
{
type: 'boolean',
arguments: {
operation: 'and',
filters: [
{
type: 'value',
arguments: {
field: 'categories',
value: ['restaurant', 'library', 'canteen', 'cafe'],
},
},
{
type: 'value',
arguments: {
field: 'type',
value: [SCThingType.Building, SCThingType.Room, SCThingType.PointOfInterest],
},
},
],
},
},
],
},
};
bounds = this.activatedRoute.queryParams.pipe(
map(
parameters =>
(parameters?.bounds as [string, string])?.map(it =>
it.split(',').map(Number.parseFloat),
) as LngLatBoundsLike,
),
filter(uid => uid !== undefined),
);
constructor(
readonly dataProvider: MapDataProvider,
readonly activatedRoute: ActivatedRoute,
) {}
mapMove(event: MapLibreEvent) {
const bounds = event.target.getBounds();
this.dataProvider.currentBounds.next([
[bounds.getWest(), bounds.getNorth()],
[bounds.getEast(), bounds.getSouth()],
]);
}
}

View File

@@ -0,0 +1,50 @@
<!--
~ Copyright (C) 2023 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/>.
-->
<stapps-search-page
[showDefaultData]="true"
[title]="'map.page.TITLE' | translate"
[placeholder]="'map.page.search_bar.placeholder' | translate"
[forcedFilter]="forcedFilter"
[navigation]="[]"
></stapps-search-page>
<ion-content id="map">
<mgl-map
[styleName]="('(prefers-color-scheme: dark)' | mediaQuery | async) ? 'dark' : 'light'"
[antialias]="true"
[attributionControl]="false"
[fitBounds]="(bounds | async) ?? undefined"
[fitBoundsOptions]="{maxZoom: 15, padding: {top: 100, bottom: 50, left: 50, right: 50}, animate: false}"
(move)="mapMove($event)"
auto-3d
>
@if (dataProvider.current | async; as result) {
<stapps-building-markers [data]="result.data | thingPolygonFeatureCollection"></stapps-building-markers>
<stapps-poi-markers [data]="result.data | thingPoiFeatureCollection"></stapps-poi-markers>
}
<mgl-control position="bottom-left" mglScale></mgl-control>
<mgl-control position="top-left">
<stapps-map-attribution></stapps-map-attribution>
</mgl-control>
<mgl-control position="top-right">
<stapps-compass-control></stapps-compass-control>
</mgl-control>
<stapps-geolocate-control
position="bottom-right"
[trackUserLocation]="true"
[showUserLocation]="true"
></stapps-geolocate-control>
</mgl-map>
</ion-content>

View File

@@ -0,0 +1,101 @@
/*!
* Copyright (C) 2023 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/>.
*/
ion-content {
position: fixed;
transition: opacity 0.2s ease;
}
stapps-search-page {
::ng-deep ion-content {
transform: translateY(48px);
transition: transform 0.2s ease;
}
::ng-deep ion-back-button {
transform: translateX(24px);
display: block;
opacity: 0;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
}
stapps-search-page:has(::ng-deep .searchbar-has-focus),
stapps-search-page:has(::ng-deep ion-content:focus-within) {
+ ion-content {
pointer-events: none;
opacity: 0;
}
::ng-deep ion-content {
transform: translateY(0);
}
::ng-deep ion-back-button {
pointer-events: none;
transform: translateX(0);
opacity: 1;
}
}
:host.can-go-back stapps-search-page ::ng-deep ion-back-button {
transform: translateX(0);
opacity: 1;
}
mgl-map {
width: 100%;
height: 100%;
}
ion-toolbar:first-of-type {
padding: 0 var(--spacing-md) var(--spacing-xs);
}
.filter-options {
overflow: hidden;
display: flex;
flex-direction: row;
border-radius: var(--border-radius-default);
label {
cursor: pointer;
user-select: none;
padding: var(--spacing-xs) var(--spacing-md);
font-family: inherit;
color: var(--ion-item-color);
background-color: var(--ion-item-background);
transition: all 0.2s ease;
input[type='radio'] {
display: none;
}
&:has(:checked) {
color: var(--ion-color-primary-contrast);
background-color: var(--ion-color-primary);
}
}
}
:host ::ng-deep .maplibregl-ctrl-top-left,
:host ::ng-deep .maplibregl-ctrl-top-right {
top: 92px;
}

View File

@@ -0,0 +1,39 @@
import {AfterViewInit, Directive, Host, HostBinding, HostListener, ViewContainerRef} from '@angular/core';
import {MapComponent} from '@maplibre/ngx-maplibre-gl';
/**
* Fixes an issue related to page transitions where
* the map would only appear with a size of 300x400px
*/
@Directive({
selector: 'mgl-map',
standalone: true,
})
export class MapSizeFixDirective implements AfterViewInit {
private animation: Animation;
@HostBinding('class.ready') ready = false;
constructor(
@Host() private map: MapComponent,
private viewContainerRef: ViewContainerRef,
) {}
@HostListener('mapLoad')
mapLoad() {
this.map.mapInstance.resize();
this.ready = true;
const element = this.viewContainerRef.element.nativeElement as HTMLElement;
if (element.dispatchEvent(new CustomEvent('ready', {cancelable: true}))) {
this.animation.play();
} else {
this.animation.cancel();
}
}
ngAfterViewInit() {
const element: HTMLDivElement = this.viewContainerRef.element.nativeElement;
this.animation = element.animate([{opacity: 0}, {opacity: 1}], {duration: 200, fill: 'backwards'});
this.animation.pause();
}
}

View File

@@ -0,0 +1,20 @@
import {Directive, Input, Host} from '@angular/core';
import {MapComponent} from '@maplibre/ngx-maplibre-gl';
@Directive({
selector: 'mgl-map[styleName]',
standalone: true,
})
export class MapStyleDirective {
constructor(@Host() readonly map: MapComponent) {}
@Input()
set styleName(name: string) {
const style = `https://maps.server.uni-frankfurt.de/static/styles/${name}/style.json`;
if (this.map.style) {
this.map.mapInstance.setStyle(style);
} else {
this.map.style = style;
}
}
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {ChangeDetectionStrategy, Component, HostBinding, Input, inject} from '@angular/core';
import {RouterLink} from '@angular/router';
import {ControlComponent, MapComponent, MarkerComponent} from '@maplibre/ngx-maplibre-gl';
import {AnimationController, IonicModule} from '@ionic/angular';
import {GeoNavigationDirective} from './geo-navigation.directive';
import {TranslateModule} from '@ngx-translate/core';
import {CommonModule} from '@angular/common';
import {IonIconModule} from '../../util/ion-icon/ion-icon.module';
import {MapAuto3dDirective} from './map-auto-3d.directive';
import {MediaQueryPipe} from 'src/app/util/media-query.pipe';
import {MapStyleDirective} from './map-style.directive';
import {MapSizeFixDirective} from './map-size-fix.directive';
import {SCThings} from '@openstapps/core';
import {ThingPolygonFeatureCollectionPipe, ThingPoiFeatureCollectionPipe} from './feature-collection.pipe';
import {PoiMarkersComponent} from './elements/poi-markers.component';
import {BuildingMarkersComponent} from './elements/building-markers.component';
import {ThingBoundsPipe} from './thing-bounds.pipe';
import {AttributionComponent} from './controls/attribution.component';
import {mapMaximizeAnimation} from './map-maximize-animation';
/**
* The map widget (needs a container with explicit size)
*/
@Component({
selector: 'stapps-map-widget',
styleUrls: ['./map-widget.scss'],
templateUrl: './map-widget.html',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AttributionComponent,
BuildingMarkersComponent,
CommonModule,
ControlComponent,
GeoNavigationDirective,
IonIconModule,
IonicModule,
MapAuto3dDirective,
MapComponent,
MapSizeFixDirective,
MapStyleDirective,
MarkerComponent,
MediaQueryPipe,
PoiMarkersComponent,
RouterLink,
ThingBoundsPipe,
ThingPoiFeatureCollectionPipe,
ThingPolygonFeatureCollectionPipe,
TranslateModule,
],
})
export class MapWidgetComponent {
@HostBinding('class.expand-when-space') expandWhenSpace = true;
/**
* A place to show on the map
*/
@Input({required: true}) place: Extract<SCThings, {geo: object}>;
maximizeAnimation = mapMaximizeAnimation(inject(AnimationController));
}

View File

@@ -0,0 +1,51 @@
<!--
~ Copyright (C) 2022 StApps
~ This program is free software: you can redistribute it and/or modify it
~ under the terms of the GNU General Public License as published by the Free
~ Software Foundation, version 3.
~
~ This program is distributed in the hope that it will be useful, but WITHOUT
~ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
~ FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
~ more details.
~
~ You should have received a copy of the GNU General Public License along with
~ this program. If not, see <https://www.gnu.org/licenses/>.
-->
<mgl-map
auto-3d
[styleName]="('(prefers-color-scheme: dark)' | mediaQuery | async) ? 'dark' : 'light'"
[interactive]="false"
[attributionControl]="false"
[center]="$any(place.geo.point.coordinates)"
[zoom]="[16]"
[fitBounds]="place.geo | thingBounds"
[fitBoundsOptions]="{maxZoom: 15, padding: {top: 100, bottom: 50, left: 50, right: 50}}"
>
@if (place.geo.polygon) {
<stapps-building-markers [data]="[place] | thingPolygonFeatureCollection"></stapps-building-markers>
} @else if (place.geo.point) {
<stapps-poi-markers [data]="[place] | thingPoiFeatureCollection"></stapps-poi-markers>
}
<mgl-control position="top-left">
<ion-button color="primary" shape="round" size="small" [geoNavigation]="place">
<ion-icon name="directions" slot="start"></ion-icon>
{{ 'map.directions.TITLE' | translate }}
</ion-button>
</mgl-control>
<mgl-control>
<ion-button
color="primary"
shape="round"
size="small"
[routerLink]="['/map']"
[queryParams]="{bounds: place.geo | thingBounds}"
[routerAnimation]="maximizeAnimation"
>
<ion-icon name="zoom_out_map"></ion-icon>
</ion-button>
</mgl-control>
<mgl-control position="bottom-right">
<stapps-map-attribution direction="left"></stapps-map-attribution>
</mgl-control>
</mgl-map>

View File

@@ -19,16 +19,11 @@
min-height: 300px;
}
div.map-container {
pointer-events: none;
display: block;
mgl-map {
width: 100%;
height: 100%;
}
div.map-buttons {
position: absolute;
z-index: 10000;
top: 10px;
right: 10px;
.marker {
filter: drop-shadow(0 0 2px rgba(0 0 0 / 50%));
}

View File

@@ -12,67 +12,14 @@
* 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 {FormsModule} from '@angular/forms';
import {RouterModule, Routes} from '@angular/router';
import {LeafletModule} from '@asymmetrik/ngx-leaflet';
import {LeafletMarkerClusterModule} from '@asymmetrik/ngx-leaflet-markercluster';
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/map-list-modal.component';
import {RouterModule} from '@angular/router';
import {MapPageComponent} from './map-page.component';
import {NgModule} from '@angular/core';
import {UtilModule} from '../../util/util.module';
import {IonIconModule} from '../../util/ion-icon/ion-icon.module';
import {GeoNavigationDirective} from './geo-navigation.directive';
/**
* 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],
exports: [],
imports: [
CommonModule,
IonicModule.forRoot(),
LeafletModule,
IonIconModule,
LeafletMarkerClusterModule,
RouterModule.forChild(mapRoutes),
TranslateModule.forChild(),
MenuModule,
DataModule,
FormsModule,
ThingTranslateModule,
UtilModule,
GeoNavigationDirective,
GeoNavigationDirective,
],
providers: [Geolocation, MapProvider, DataProvider, DataFacetsProvider, StAppsWebHttpClient],
imports: [RouterModule.forChild([{path: 'map', component: MapPageComponent}])],
})
export class MapModule {}

View File

@@ -1,60 +0,0 @@
/*
* Copyright (C) 2023 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 {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 {LoggerModule, NGXLogger, NgxLoggerLevel} from 'ngx-logger';
import {ConfigProvider} from '../config/config.provider';
import {sampleDefaultPolygon} from '../../_helpers/data/sample-configuration';
import {RouterModule} from '@angular/router';
describe('MapProvider', () => {
let provider: MapProvider;
let configProvider: jasmine.SpyObj<ConfigProvider>;
beforeEach(() => {
configProvider = jasmine.createSpyObj('ConfigProvider', ['getValue']);
TestBed.configureTestingModule({
imports: [
MapModule,
HttpClientModule,
StorageModule,
LoggerModule.forRoot({level: NgxLoggerLevel.TRACE}),
RouterModule.forRoot([]),
],
providers: [
{
provide: ConfigProvider,
useValue: configProvider,
},
StAppsWebHttpClient,
StorageProvider,
NGXLogger,
],
});
configProvider.getValue.and.returnValue(sampleDefaultPolygon);
provider = TestBed.inject(MapProvider);
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
});

View File

@@ -1,237 +0,0 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {ElementRef, Injectable} from '@angular/core';
import {
SCBuilding,
SCSearchFilter,
SCSearchQuery,
SCSearchResponse,
SCThingType,
SCUuid,
} from '@openstapps/core';
import {Point, Polygon} from 'geojson';
import {divIcon, geoJSON, LatLng, Map, marker, Marker} from 'leaflet';
import {DataProvider} from '../data/data.provider';
import {MapPosition, PositionService} from './position.service';
import {hasValidLocation} from '../data/types/place/place-types';
import {ConfigProvider} from '../config/config.provider';
import {SCIcon} from '../../util/ion-icon/icon';
/**
* 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
* @param className CSS class name
* @param iconSize Size of the position icon
*/
static getPointMarker(point: Point, className: string, iconSize: number) {
return marker(geoJSON(point).getBounds().getCenter(), {
icon: divIcon({
className: className,
html: `<span
name="${SCIcon`location_on`}"
class="material-symbols-rounded map-location-pin"
style="font-size: ${iconSize}px;"
>${SCIcon`location_on`}</span>`,
iconSize: [iconSize, iconSize],
iconAnchor: [iconSize / 2, iconSize],
}),
});
}
/**
* 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:
position.heading === undefined
? `<span
name="${SCIcon`person_pin_circle`}"
class="material-symbols-rounded map-location-pin"
style="font-size: ${iconSize}px; color: var(--ion-color-primary);"
>${SCIcon`person_pin_circle`}</span>`
: `<span
class="material-symbols-rounded map-location-pin"
style="
transform-origin: center;
transform: rotate(${position.heading}deg);
font-size: ${iconSize}px;
color: var(--ion-color-primary);
"
>${SCIcon`navigation`}</span>`,
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: number) => {
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,
private configProvider: ConfigProvider,
) {
this.defaultPolygon = this.configProvider.getValue('campusPolygon') as Polygon;
}
/**
* 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) const result = await this.dataProvider.search(query);
* @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 (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',
position: [this.positionService.position.longitude, this.positionService.position.latitude],
},
},
];
}
const result = await this.dataProvider.search(query);
result.data = result.data.filter(place => hasValidLocation(place as SCBuilding));
return result;
}
}

View File

@@ -1,93 +0,0 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Component, Input, OnInit} from '@angular/core';
import {SCSearchBooleanFilter, SCPlace, SCSearchFilter} from '@openstapps/core';
import {MapProvider} from '../map.provider';
import {ModalController} from '@ionic/angular';
import {LatLngBounds} from 'leaflet';
/**
* Modal showing a provided list of places
*/
@Component({
selector: 'map-list-modal',
templateUrl: 'map-list-modal.html',
styleUrls: ['map-list-modal.scss'],
})
export class MapListModalComponent implements OnInit {
/**
* Used for creating the search for the shown list
*/
@Input() filterQuery?: SCSearchFilter;
/**
* Map visible boundaries limiting items in lust
*/
@Input() mapBounds?: LatLngBounds;
/**
* Places to show in the list
*/
items: SCPlace[];
/**
* Used for creating the search for the shown list
*/
@Input() queryText?: string;
constructor(
private mapProvider: MapProvider,
readonly modalController: ModalController,
) {}
/**
* Populate the list with the results from the search
*/
ngOnInit() {
let geofencedFilter: SCSearchBooleanFilter | undefined;
if (this.mapBounds !== undefined) {
geofencedFilter = {
arguments: {
operation: 'and',
filters: [
{
type: 'geo',
arguments: {
field: 'geo',
shape: {
coordinates: [
[this.mapBounds.getNorthWest().lng, this.mapBounds.getNorthWest().lat],
[this.mapBounds.getSouthEast().lng, this.mapBounds.getSouthEast().lat],
],
type: 'envelope',
},
spatialRelation: 'intersects',
},
},
],
},
type: 'boolean',
};
if (this.filterQuery !== undefined) {
geofencedFilter.arguments.filters.push(this.filterQuery);
}
}
const geofencedFilterQuery = geofencedFilter ?? this.filterQuery;
this.mapProvider.searchPlaces(geofencedFilterQuery, this.queryText).then(result => {
this.items = result.data as SCPlace[];
});
}
}

View File

@@ -1,28 +0,0 @@
<!--
~ Copyright (C) 2022 StApps
~ This program is free software: you can redistribute it and/or modify it
~ under the terms of the GNU General Public License as published by the Free
~ Software Foundation, version 3.
~
~ This program is distributed in the hope that it will be useful, but WITHOUT
~ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
~ FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
~ more details.
~
~ You should have received a copy of the GNU General Public License along with
~ this program. If not, see <https://www.gnu.org/licenses/>.
-->
<div class="container">
<ion-header translucent>
<ion-toolbar color="primary" mode="ios">
<ion-title>{{ 'map.modals.list.TITLE' | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="modalController.dismiss()">{{ 'app.ui.CLOSE' | translate }}</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content fullscreen>
<stapps-data-list [loading]="!items" [items]="$any(items)"></stapps-data-list>
</ion-content>
</div>

View File

@@ -1,20 +0,0 @@
/*!
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
.container {
display: flex;
flex-direction: column;
height: 100%;
}

View File

@@ -1,430 +0,0 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Location} from '@angular/common';
import {ChangeDetectorRef, Component, DestroyRef, ElementRef, inject, OnInit, ViewChild} from '@angular/core';
import {ActivatedRoute, Router} from '@angular/router';
import {Keyboard} from '@capacitor/keyboard';
import {AlertController, IonRouterOutlet, ModalController} from '@ionic/angular';
import {TranslateService} from '@ngx-translate/core';
import {SCBuilding, SCPlace, SCRoom, SCSearchFilter, SCUuid} from '@openstapps/core';
import {featureGroup, geoJSON, LatLng, Layer, Map, MapOptions, Marker, tileLayer} from 'leaflet';
import {BehaviorSubject} from 'rxjs';
import {DataRoutingService} from '../../data/data-routing.service';
import {ContextMenuService} from '../../menu/context/context-menu.service';
import {MapProvider} from '../map.provider';
import {MapPosition, PositionService} from '../position.service';
import {Geolocation, PermissionStatus} from '@capacitor/geolocation';
import {Capacitor} from '@capacitor/core';
import {pauseWhen} from '../../../util/rxjs/pause-when';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {startViewTransition} from '../../../util/view-transition';
/**
* The main page of the map
*/
@Component({
styleUrls: ['./map-page.scss'],
templateUrl: './map-page.html',
providers: [ContextMenuService],
})
export class MapPageComponent implements OnInit {
/**
* Default map zoom level
*/
DEFAULT_ZOOM = 16;
/**
* Distance to the shown place
*/
distance?: number;
/**
* Api query filter
*/
filterQuery?: SCSearchFilter;
/**
* Places to show
*/
items: SCPlace[] = [];
/**
* Leaflet (map) layers to show items on (not the position)
*/
layers: Layer[] = [];
/**
* Location settings on the user's device
*/
locationStatus?: PermissionStatus;
/**
* The leaflet map
*/
map: Map;
/**
* Container element of the map
*/
@ViewChild('mapContainer') mapContainer: ElementRef;
/**
* Map layers to show as marker clusters
*/
markerClusterData: Layer[] = [];
/**
* Options how to show the marker clusters
*/
markerClusterOptions = {
// don't show rectangles containing the markers in a cluster
showCoverageOnHover: false,
};
/**
* Maximal map zoom level
*/
MAX_ZOOM = 18;
/**
* Options of the leaflet map
*/
options: MapOptions = {
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,
};
/**
* 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;
isNotInView$ = new BehaviorSubject(true);
destroy$ = inject(DestroyRef);
constructor(
private translateService: TranslateService,
private router: Router,
private mapProvider: MapProvider,
public location: Location,
private ref: ChangeDetectorRef,
private readonly contextMenuService: ContextMenuService,
private alertController: AlertController,
private route: ActivatedRoute,
private modalController: ModalController,
private dataRoutingService: DataRoutingService,
private positionService: PositionService,
readonly routerOutlet: IonRouterOutlet,
) {}
ngOnInit() {
this.dataRoutingService
.itemSelectListener()
.pipe(pauseWhen(this.isNotInView$), takeUntilDestroyed(this.destroy$))
.subscribe(async item => {
// in case the list item is clicked
if (this.items.length > 1) {
await Promise.all([this.modalController.dismiss(), this.showItem(item.uid)]);
} else {
void this.router.navigate(['/data-detail', item.uid], {state: {item}});
}
});
this.positionService
.watchCurrentLocation({enableHighAccuracy: true, maximumAge: 1000})
.pipe(pauseWhen(this.isNotInView$), takeUntilDestroyed(this.destroy$))
.subscribe({
next: (position: MapPosition) => {
this.position = position;
this.positionMarker = MapProvider.getPositionMarker(position, 'stapps-device-location', 32);
},
error: async _error => {
this.locationStatus = await Geolocation.checkPermissions();
// eslint-disable-next-line unicorn/no-null
this.position = null;
},
});
}
/**
* Animate to coordinates
* @param latLng Coordinates to animate to
*/
private focus(latLng?: LatLng) {
if (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 (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, 'stapps-location', 32);
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 (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 fetchAll Should fetch all items
* @param animate Should the fly animation be used
*/
async fetchAndUpdateItems(fetchAll = false, animate?: boolean): Promise<void> {
try {
const result = await this.mapProvider.searchPlaces(this.filterQuery, fetchAll ? '' : this.queryText);
if (result.data.length === 0) {
const alert = await this.alertController.create({
buttons: [this.translateService.instant('ok')],
header: this.translateService.instant('search.nothing_found'),
});
await alert.present();
return;
}
// 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 (result.facets !== undefined) {
this.contextMenuService.updateContextFilter(result.facets);
}
} catch (error) {
const alert: HTMLIonAlertElement = await this.alertController.create({
buttons: ['Dismiss'],
header: 'Error',
subHeader: (error as Error).message,
});
await alert.present();
}
}
/**
* Hides keyboard in native app environments
*/
hideKeyboard() {
if (Capacitor.isNativePlatform()) {
Keyboard.hide();
}
}
/**
* Subscribe to needed observables and get the location status when user is entering the page
*/
async ionViewWillEnter() {
this.isNotInView$.next(false);
if (this.positionService.position) {
this.position = this.positionService.position;
this.positionMarker = MapProvider.getPositionMarker(this.position, 'stapps-device-location', 32);
}
// get detailed location status (diagnostics only supports devices)
this.locationStatus = await Geolocation.checkPermissions();
}
ionViewWillLeave() {
this.isNotInView$.next(true);
}
/**
* What happens when the leaflet map is ready (note: doesn't mean that tiles are loaded)
*/
async onMapReady(map: Map) {
this.map = map;
this.map.attributionControl.setPosition('topright');
const interval = window.setInterval(() =>
MapProvider.invalidateWhenRendered(map, this.mapContainer, interval),
);
const uid = this.route.snapshot.paramMap.get('uid');
const response = await (uid === null
? this.mapProvider.searchPlaces()
: this.mapProvider.searchPlace(uid));
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.contextMenuService.filterQueryChanged$
.pipe(pauseWhen(this.isNotInView$), takeUntilDestroyed(this.destroy$))
.subscribe(query => {
this.filterQuery = query;
this.fetchAndUpdateItems(false, true);
});
this.distance = this.positionService.getDistance(this.items[0].geo.point);
}
/**
* What happens when position button is clicked
*/
async onPositionClick() {
if (this.position) {
this.focus(this.positionMarker.getLatLng());
return;
}
this.locationStatus = await (Capacitor.isNativePlatform()
? Geolocation.requestPermissions()
: Geolocation.checkPermissions());
this.translateService
.get(['map.page.geolocation', 'app.errors.UNKNOWN'])
.subscribe(async translations => {
const [location, unknownError] = [
translations['map.page.geolocation'],
translations['app.errors.UNKNOWN'],
];
await (
await this.alertController.create({
header: location.TITLE,
message: `${
this.locationStatus?.location === 'denied'
? location.NOT_ALLOWED
: this.locationStatus?.location === 'granted'
? unknownError
: location.NOT_ENABLED
}`,
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() {
startViewTransition(async () => {
this.location.go('/map');
await this.fetchAndUpdateItems(this.items.length > 0);
this.ref.detectChanges();
});
}
/**
* On enter key up do the search
* @param event Keyboard keyup event
*/
searchKeyUp(event: KeyboardEvent) {
if (event.key === 'Enter') {
this.searchStringChanged((event.target as HTMLInputElement).value);
}
}
/**
* Search event of search bar
* @param queryText New query text to be set
*/
searchStringChanged(queryText?: string) {
this.queryText = queryText || '';
void this.fetchAndUpdateItems(false, true);
}
/**
* Show an single place
* @param uid Uuid of the place
*/
async showItem(uid: SCUuid) {
startViewTransition(async () => {
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());
});
}
}

View File

@@ -1,106 +0,0 @@
<!--
~ Copyright (C) 2023 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/>.
-->
<stapps-context contentId="map"></stapps-context>
<ion-header class="ion-no-border" translucent="true">
<ion-toolbar color="primary" mode="ios">
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
</ion-buttons>
<ion-title>{{ 'map.page.TITLE' | translate }}</ion-title>
</ion-toolbar>
<ion-toolbar color="primary">
<ion-searchbar
(keyup)="searchKeyUp($event)"
(keyup.enter)="hideKeyboard()"
(search)="hideKeyboard()"
[(ngModel)]="queryText"
(ionClear)="searchStringChanged()"
mode="md"
placeholder="{{ 'map.page.search_bar.placeholder' | translate }}"
showClearButton="always"
type="search"
enterkeyhint="search"
class="filterable"
>
<ion-menu-button menu="context" auto-hide="false">
<ion-icon name="tune"></ion-icon>
</ion-menu-button>
</ion-searchbar>
</ion-toolbar>
</ion-header>
<ion-content id="map">
<div
class="map-container"
#mapContainer
leaflet
(leafletMapReady)="onMapReady($event)"
(leafletClick)="resetView()"
[leafletOptions]="options"
[leafletMarkerCluster]="markerClusterData"
[leafletMarkerClusterOptions]="markerClusterOptions"
>
@if (position) {
<div [leafletLayer]="positionMarker"></div>
}
</div>
<div class="floating-content">
<div class="map-buttons">
@if (items.length > 1) {
<ion-button color="light" shape="round" size="small" (click)="mapListModal.present()">
<ion-icon name="list"></ion-icon>&nbsp;&nbsp;{{ 'map.page.buttons.SHOW_LIST' | translate }}
</ion-button>
}
<ion-button
color="light"
shape="round"
size="small"
(click)="onPositionClick()"
class="location-button"
>
@if (position !== null) {
<ion-icon name="my_location"></ion-icon>
} @else {
@if (locationStatus?.location !== 'denied') {
<ion-icon name="location_searching"></ion-icon>
} @else {
<ion-icon name="location_disabled"></ion-icon>
}
}
</ion-button>
</div>
<ion-card class="map-item">
@if (items.length === 1) {
<stapps-data-list-item [item]="$any(items[0])"></stapps-data-list-item>
}
<ion-button fill="clear" class="close" (click)="resetView()">
<ion-icon [size]="22" name="close" slot="icon-only"></ion-icon>
</ion-button>
</ion-card>
</div>
<ion-modal [canDismiss]="true" #mapListModal>
<ng-template>
<map-list-modal
style="height: 100%"
[filterQuery]="filterQuery"
[mapBounds]="this.map.getBounds()"
[queryText]="queryText"
></map-list-modal>
</ng-template>
</ion-modal>
</ion-content>

View File

@@ -1,99 +0,0 @@
/*!
* Copyright (C) 2023 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 '../../../../theme/util/mixins';
$bottom-offset: 7px; // no idea what happened here
.map-container {
width: 100%;
height: 100%;
}
ion-toolbar:first-of-type {
padding: 0 var(--spacing-md) var(--spacing-xs);
}
.floating-content {
position: fixed;
z-index: 1000;
right: 0;
bottom: 0;
left: 0;
display: flex;
flex-flow: row-reverse wrap;
align-items: flex-end;
justify-content: space-between;
}
.map-buttons {
display: flex;
justify-content: flex-end;
ion-button {
// important for iOS
// TODO: find an option that is better suited for the iOS theme
--box-shadow: var(--map-box-shadow);
align-self: flex-end;
margin: var(--spacing-md);
&.location-button {
view-transition-name: location-button;
}
}
}
.map-item {
position: relative;
flex: 1 0 auto;
max-width: 550px;
margin: var(--spacing-md);
.close {
position: absolute;
top: 0;
right: 0;
}
::ng-deep ion-item {
margin: 0;
}
}
@include ion-md-down {
.md {
ion-content {
--padding-bottom: $bottom-offset;
}
.floating-content {
bottom: $bottom-offset;
}
}
.map-buttons ion-button {
margin: var(--spacing-sm);
}
.map-item {
width: 100%;
max-width: unset;
margin: 0;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
}

View File

@@ -14,7 +14,6 @@
*/
import {Injectable} from '@angular/core';
import {Point} from 'geojson';
import {geoJSON, LatLng} from 'leaflet';
import {Observable} from 'rxjs';
import {Geolocation, Position} from '@capacitor/geolocation';
@@ -40,16 +39,35 @@ export interface MapPosition extends Coordinates {
providedIn: 'root',
})
export class PositionService {
geoLocation = new Observable<Position>(subscriber => {
const watcherID = Geolocation.checkPermissions().then(permissions => {
if (permissions.location === 'granted') {
return Geolocation.watchPosition({}, (position, error) => {
if (error) {
subscriber.error(position);
} else if (position) {
subscriber.next(position);
}
});
}
return;
});
return {
unsubscribe() {
watcherID.then(id => {
if (id) {
Geolocation.clearWatch({id});
}
});
},
};
});
/**
* Current position
*/
position?: MapPosition;
/**
* Map of callers and their running watchers. Both by their ID
*/
watchers: Map<string, Promise<string>> = new Map();
/**
* Gets current coordinates information of the device
* @param options Options which define which data should be provided (e.g. how accurate or how old)
@@ -79,8 +97,9 @@ export class PositionService {
return undefined;
}
return new LatLng(this.position.latitude, this.position.longitude).distanceTo(
geoJSON(point).getBounds().getCenter(),
return Math.hypot(
this.position.longitude - point.coordinates[0],
this.position.latitude - point.coordinates[1],
);
}

View File

@@ -0,0 +1,23 @@
import {Pipe, PipeTransform} from '@angular/core';
import {SCGeoInformation} from '@openstapps/core';
@Pipe({
name: 'thingBounds',
standalone: true,
pure: true,
})
export class ThingBoundsPipe implements PipeTransform {
transform(geo: SCGeoInformation): [[number, number], [number, number]] {
if (geo.polygon) {
const lngs = geo.polygon.coordinates[0].map(it => it[0]);
const lats = geo.polygon.coordinates[0].map(it => it[1]);
return [
[Math.max(...lngs), Math.max(...lats)],
[Math.min(...lngs), Math.min(...lats)],
];
} else {
return [geo.point.coordinates as [number, number], geo.point.coordinates as [number, number]];
}
}
}

View File

@@ -1,93 +0,0 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Component, ElementRef, HostBinding, Input, OnInit, ViewChild} from '@angular/core';
import {Router} from '@angular/router';
import {SCPlaceWithoutReferences, SCThingWithoutReferences} from '@openstapps/core';
import {geoJSON, Map, MapOptions, tileLayer} from 'leaflet';
import {MapProvider} from '../map.provider';
/**
* 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 {
@HostBinding('class.expand-when-space') expandWhenSpace = true;
/**
* A leaflet map showed
*/
map: Map;
/**
* Container element of the map
*/
@ViewChild('mapContainer') mapContainer: ElementRef;
/**
* Options of the leaflet map
*/
options: MapOptions;
/**
* A place to show on the map
*/
@Input() place: SCThingWithoutReferences & Pick<SCPlaceWithoutReferences, 'geo' | 'address'>;
/**
* Indicates if the expand button should be visible
*/
showExpandButton = true;
constructor(private router: Router) {}
/**
* Prepare the map
*/
ngOnInit() {
const markerLayer = MapProvider.getPointMarker(this.place.geo.point, 'stapps-location', 32);
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,
};
if (this.router) {
this.showExpandButton = !this.router.url.startsWith('/map');
}
}
/**
* What happens when the leaflet map is ready (note: doesn't mean that tiles are loaded)
*/
onMapReady(map: Map) {
this.map = map;
this.map.dragging.disable();
const interval = window.setInterval(() => {
MapProvider.invalidateWhenRendered(map, this.mapContainer, interval);
});
}
}

View File

@@ -1,33 +0,0 @@
<!--
~ Copyright (C) 2022 StApps
~ This program is free software: you can redistribute it and/or modify it
~ under the terms of the GNU General Public License as published by the Free
~ Software Foundation, version 3.
~
~ This program is distributed in the hope that it will be useful, but WITHOUT
~ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
~ FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
~ more details.
~
~ You should have received a copy of the GNU General Public License along with
~ this program. If not, see <https://www.gnu.org/licenses/>.
-->
<div
class="map-container"
(leafletMapReady)="onMapReady($event)"
leaflet
#mapContainer
[leafletOptions]="options"
></div>
@if (showExpandButton) {
<div class="map-buttons">
<ion-button color="primary" shape="round" size="small" [geoNavigation]="place">
<ion-icon name="directions" slot="start"></ion-icon>
{{ 'map.directions.TITLE' | translate }}
</ion-button>
<ion-button color="primary" shape="round" size="small" [routerLink]="['/map', place.uid]">
<ion-icon name="zoom_out_map"></ion-icon>
</ion-button>
</div>
}

View File

@@ -27,11 +27,8 @@ import {FilterContext, SortContext} from './context-type';
import {Component} from '@angular/core';
import {By} from '@angular/platform-browser';
// prettier-ignore
@Component({
template: `<ion-content id="foo"></ion-content
><stapps-context contentId="foo"></stapps-context>
`,
template: `<ion-content id="foo"></ion-content><stapps-context contentId="foo"></stapps-context> `,
})
class ContextMenuContainerComponent {}

View File

@@ -16,6 +16,7 @@
import type {AnimationBuilder} from '@ionic/angular';
import {AnimationController} from '@ionic/angular';
import type {AnimationOptions} from '@ionic/angular/common/providers/nav-controller';
import {iosDuration, iosEasing, mdDuration, mdEasing} from 'src/app/animation/easings';
/**
*
@@ -23,42 +24,19 @@ import type {AnimationOptions} from '@ionic/angular/common/providers/nav-control
export function tabsTransition(animationController: AnimationController): AnimationBuilder {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (_baseElement: HTMLElement, options: AnimationOptions | any) => {
const duration = options.duration || 350;
const contentExitDuration = options.contentExitDuration || 100;
const rootTransition = animationController
.create()
.duration(options.duration ?? (options.mode === 'ios' ? iosDuration : mdDuration * 1.4))
.easing(options.mode === 'ios' ? iosEasing : mdEasing);
const rootTransition = animationController.create().duration(duration);
const enterTransition = animationController
.create()
.fromTo('opacity', '1', '1')
.addElement(options.enteringEl);
const exitZIndex = animationController
.create()
.beforeStyles({zIndex: 0})
.afterClearStyles(['zIndex'])
.addElement(options.leavingEl);
const exitTransition = animationController
.create()
.duration(contentExitDuration * 2)
.easing('cubic-bezier(0.87, 0, 0.13, 1)')
.fromTo('opacity', '1', '0')
.addElement(options.leavingEl.querySelector('ion-header'));
const contentExit = animationController
.create()
.easing('linear')
.duration(contentExitDuration)
.fromTo('opacity', '1', '0')
.addElement(options.leavingEl.querySelectorAll(':scope > *:not(ion-header)'));
const exitZIndex = animationController.create().fromTo('opacity', '1', '0').addElement(options.leavingEl);
const contentEnter = animationController
.create()
.delay(contentExitDuration)
.duration(duration - contentExitDuration)
.easing('cubic-bezier(0.16, 1, 0.3, 1)')
.fromTo('transform', 'scale(1.025)', 'scale(1)')
.fromTo('transform', 'scale(1.05)', 'scale(1)')
.fromTo('opacity', '0', '1')
.addElement(options.enteringEl.querySelectorAll(':scope > *:not(ion-header)'));
.addElement(options.enteringEl);
rootTransition.addAnimation([enterTransition, contentExit, contentEnter, exitTransition, exitZIndex]);
rootTransition.addAnimation([contentEnter, exitZIndex]);
return rootTransition;
};
}

View File

@@ -32,7 +32,7 @@
>
</ion-refresher-content>
</ion-refresher>
<ion-grid>
<ion-grid class="filter">
<ion-row>
<ion-col size="12">
@if (settings) {

View File

@@ -32,3 +32,7 @@ ion-content.ios > div > .news-grid {
gap: var(--spacing-lg);
margin: var(--spacing-lg);
}
.filter {
flex: 0;
}

View File

@@ -20,7 +20,6 @@ import {IonicModule} from '@ionic/angular';
import {TranslateModule} from '@ngx-translate/core';
import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {ConfigProvider} from '../config/config.provider';
import {SettingsItemComponent} from './item/settings-item.component';
import {SettingsPageComponent} from './page/settings-page.component';
import {SettingTranslatePipe} from './setting-translate.pipe';
@@ -60,13 +59,6 @@ const settingsRoutes: Routes = [{path: 'settings', component: SettingsPageCompon
RouterModule.forChild(settingsRoutes),
UtilModule,
],
providers: [
ScheduleSyncService,
ConfigProvider,
SettingsProvider,
CalendarService,
ScheduleProvider,
ThingTranslatePipe,
],
providers: [ScheduleSyncService, SettingsProvider, CalendarService, ScheduleProvider, ThingTranslatePipe],
})
export class SettingsModule {}

View File

@@ -43,6 +43,7 @@ export class PropertyNameTranslatePipe implements PipeTransform, OnDestroy {
transform<K extends string, Q extends keyof Extract<SCThings, {type: K}>>(query: Q, type: K): string;
transform<T extends SCThings, K extends keyof T>(query: K, thing: T): string;
transform(query: string, thingOrType: string): string;
transform(query: unknown, thingOrType: SCThings | string | unknown): unknown {
if (typeof query !== 'string' || query.length <= 0) {
return query;

View File

@@ -12,6 +12,7 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
/* eslint-disable unicorn/no-null */
import {matchPropertyAccess, matchPropertyContent, matchTagProperties} from './icon-match.mjs';
describe('matchTagProperties', function () {

View File

@@ -16,5 +16,5 @@
import {MaterialSymbol} from 'material-symbols';
export const SCIcon = new Proxy({} as {[key in MaterialSymbol]: key}, {
get: (_target, prop: string) => prop as MaterialSymbol,
get: (_target, property: string) => property as MaterialSymbol,
});

View File

@@ -0,0 +1,18 @@
import {PipeTransform} from '@angular/core';
import {Pipe} from '@angular/core';
import {Observable, fromEvent, map, startWith} from 'rxjs';
@Pipe({
name: 'mediaQuery',
pure: true,
standalone: true,
})
export class MediaQueryPipe implements PipeTransform {
transform(query: string): Observable<boolean> {
const match = window.matchMedia(query);
return fromEvent<MediaQueryListEvent>(match, 'change').pipe(
map(event => event.matches),
startWith(match.matches),
);
}
}

View File

@@ -0,0 +1,11 @@
import {Pipe, PipeTransform} from '@angular/core';
@Pipe({
name: 'addWordBreakOpportunities',
standalone: true,
})
export class AddWordBreakOpportunitiesPipe implements PipeTransform {
transform(value: string): string {
return value.replaceAll('/', '/').replaceAll(/([a-z])([A-Z])/g, '$1$2');
}
}

View File

@@ -276,7 +276,7 @@
"page": {
"TITLE": "Karte",
"search_bar": {
"placeholder": "Gebäude, Points of Interest, Mensen und mehr"
"placeholder": "Diesen Bereich durchsuchen"
},
"buttons": {
"SHOW_LIST": "Als Liste ansehen",

View File

@@ -276,7 +276,7 @@
"page": {
"TITLE": "Map",
"search_bar": {
"placeholder": "Buildings, points of interests, canteens and more"
"placeholder": "Search this area"
},
"buttons": {
"SHOW_LIST": "Show as list",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -26,6 +26,8 @@
@import '~@ionic/angular/css/flex-utils.css';
@import '~@ionic/angular/css/display.css';
@import '~maplibre-gl/dist/maplibre-gl.css';
@import 'swiper/scss';
@import 'swiper/scss/controller';
@import 'swiper/scss/navigation';

View File

@@ -24,6 +24,11 @@ ion-content::part(parallax-scroll) {
ion-content::part(parallax-parent) {
position: relative;
transform-style: preserve-3d;
display: flex;
flex-direction: column;
height: 100%;
}
ion-content::part(parallax) {

View File

@@ -1,25 +0,0 @@
/*!
* Copyright (C) 2023 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 '../util/dark';
@include dark-only {
.leaflet-tile-pane {
filter: invert(1);
}
}
.map-container.leaflet-container {
background: var(--ion-background-color);
}

View File

@@ -9,10 +9,9 @@
font-size: 12px;
font-weight: bold;
color: var(--ion-text-color);
color: var(--ion-color-dark);
background: none;
filter: drop-shadow(0 0 2px rgba(0 0 0 / 60%));
border: none;
&::after {

View File

@@ -16,21 +16,6 @@
// http://ionicframework.com/docs/theming/
@import './colors';
@import './fonts';
@import './common/typo';
@import './common/helper';
@import './common/ion-button';
@import './common/ion-header';
@import './common/ion-input';
@import './common/ion-modal';
@import './common/ion-popover';
@import './common/ion-refresher';
@import './common/ion-toolbar';
@import './common/ion-menu';
@import './common/swiper';
@import './common/typography';
@import './common/leaflet-tile';
@import './common/ion-searchbar';
@import './components/image-dark';
:root {
// Fonts

View File

@@ -5,7 +5,6 @@
"baseUrl": "./",
"outDir": "./dist/out-tsc",
"declaration": false,
"skipLibCheck": false,
"isolatedModules": false,
"checkJs": false,
"allowJs": false,