diff --git a/.syncpackrc.cjs b/.syncpackrc.cjs index b9b89cdc..b28db580 100644 --- a/.syncpackrc.cjs +++ b/.syncpackrc.cjs @@ -2,7 +2,13 @@ /** @type {import('syncpack').RcFile} */ const config = { - semverGroups: [{range: ''}], + semverGroups: [ + { + range: '', + dependencies: ['**'], + packages: ['**'], + } + ], source: ['package.json', '**/package.json'], indent: ' ', sortFirst: [ diff --git a/backend/backend/src/storage/database.ts b/backend/backend/src/storage/database.ts index 770088b7..98027455 100644 --- a/backend/backend/src/storage/database.ts +++ b/backend/backend/src/storage/database.ts @@ -13,15 +13,34 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import {SCConfigFile, SCSearchQuery, SCSearchResponse, SCThings, SCUuid} from '@openstapps/core'; +import { + SCConfigFile, + SCPlace, + SCPlaceWithoutReferences, + SCSearchQuery, + SCSearchResponse, + SCThingWithCategoriesWithoutReferences, + SCThings, + SCUuid, +} from '@openstapps/core'; import {MailQueue} from '../notification/mail-queue.js'; import {Bulk} from './bulk-storage.js'; +import {FeatureCollection, Point, Polygon} from 'geojson'; /** * Creates an instance of a database */ export type DatabaseConstructor = new (config: SCConfigFile, mailQueue?: MailQueue) => Database; +export type SupplementaryGeoJSON = FeatureCollection; +export type SupplementaryGeoJSONThing = Pick< + Extract, + Exclude< + keyof SCPlaceWithoutReferences | keyof SCThingWithCategoriesWithoutReferences, + 'geo' | 'origin' | 'translations' + > +>; + /** * Defines what one database class needs to have defined */ @@ -82,4 +101,9 @@ export interface Database { * @param params Parameters which form a search query to search the backend data */ search(parameters: SCSearchQuery): Promise; + + /** + * Get geo info for display on a map + */ + geo(): Promise; } diff --git a/backend/backend/src/storage/elasticsearch/elasticsearch.ts b/backend/backend/src/storage/elasticsearch/elasticsearch.ts index 37d58185..dad86eef 100644 --- a/backend/backend/src/storage/elasticsearch/elasticsearch.ts +++ b/backend/backend/src/storage/elasticsearch/elasticsearch.ts @@ -26,7 +26,7 @@ import {Logger} from '@openstapps/logger'; import moment from 'moment'; import {MailQueue} from '../../notification/mail-queue.js'; import {Bulk} from '../bulk-storage.js'; -import {Database} from '../database.js'; +import {Database, SupplementaryGeoJSON, SupplementaryGeoJSONThing} from '../database.js'; import {parseAggregations} from './aggregations.js'; import * as Monitoring from './monitoring.js'; import {buildQuery} from './query/query.js'; @@ -46,6 +46,7 @@ import { } from './util/index.js'; import {noUndefined} from './util/no-undefined.js'; import {retryCatch, RetryOptions} from './util/retry.js'; +import {Feature, Point, Polygon} from 'geojson'; /** * A database interface for elasticsearch @@ -405,4 +406,49 @@ export class Elasticsearch implements Database { }, }; } + + async geo(): Promise { + const searchResponse = await this.client.search>({ + body: { + query: { + exists: { + field: 'geo', + }, + }, + }, + from: 0, + allow_no_indices: true, + index: ACTIVE_INDICES_ALIAS, + size: 1, + }); + + return { + type: 'FeatureCollection', + features: searchResponse.hits.hits + .map(thing => { + return thing._source?.geo + ? ({ + id: Number(thing._source.identifiers?.['OSM']) || undefined, + type: 'Feature', + geometry: thing._source.geo.polygon ?? thing._source.geo.point, + properties: { + name: thing._source.name, + sameAs: thing._source.sameAs, + image: thing._source.image, + alternateNames: thing._source.alternateNames, + description: thing._source.description, + identifiers: thing._source.identifiers, + categories: thing._source.categories, + categorySpecificValues: thing._source.categorySpecificValues, + openingHours: thing._source.openingHours, + address: thing._source.address, + uid: thing._source.uid, + type: thing._source.type, + }, + } satisfies Feature) + : undefined; + }) + .filter(noUndefined), + }; + } } diff --git a/backend/backend/src/storage/elasticsearch/query/filters/geo.ts b/backend/backend/src/storage/elasticsearch/query/filters/geo.ts index c9f8cb07..a6c87ccd 100644 --- a/backend/backend/src/storage/elasticsearch/query/filters/geo.ts +++ b/backend/backend/src/storage/elasticsearch/query/filters/geo.ts @@ -19,14 +19,29 @@ import {QueryDslSpecificQueryContainer} from '../../types/util.js'; * Converts a geo filter to elasticsearch syntax * @param filter A search filter for the retrieval of the data */ -export function buildGeoFilter(filter: SCGeoFilter): QueryDslSpecificQueryContainer<'geo_shape'> { +export function buildGeoFilter(filter: SCGeoFilter): QueryDslSpecificQueryContainer<'bool'> { return { - geo_shape: { - ignore_unmapped: true, - [`${filter.arguments.field}.polygon`]: { - shape: filter.arguments.shape, - relation: filter.arguments.spatialRelation, - }, + bool: { + should: [ + { + geo_shape: { + ignore_unmapped: true, + [`${filter.arguments.field}.polygon`]: { + shape: filter.arguments.shape, + relation: filter.arguments.spatialRelation, + }, + }, + } satisfies QueryDslSpecificQueryContainer<'geo_shape'>, + { + geo_shape: { + ignore_unmapped: true, + [`${filter.arguments.field}.point`]: { + shape: filter.arguments.shape, + relation: filter.arguments.spatialRelation, + }, + }, + } satisfies QueryDslSpecificQueryContainer<'geo_shape'>, + ], }, }; } diff --git a/backend/backend/test/common.ts b/backend/backend/test/common.ts index d576ae48..f68f4f47 100644 --- a/backend/backend/test/common.ts +++ b/backend/backend/test/common.ts @@ -22,7 +22,7 @@ import http from 'http'; import {MailQueue} from '../src/notification/mail-queue.js'; import {Bulk, BulkStorage} from '../src/storage/bulk-storage.js'; import getPort from 'get-port'; -import {Database} from '../src/storage/database.js'; +import {Database, SupplementaryGeoJSON} from '../src/storage/database.js'; import {v4} from 'uuid'; import {backendConfig} from '../src/config.js'; import {getIndexUID} from '../src/storage/elasticsearch/util/index.js'; @@ -58,7 +58,6 @@ export async function startApp(): Promise { * An elasticsearch mock */ export class ElasticsearchMock implements Database { - // @ts-expect-error never read private bulk: Bulk | undefined; private storageMock = new Map(); @@ -67,6 +66,10 @@ export class ElasticsearchMock implements Database { // Nothing to do here } + geo(): Promise { + throw new Error('Method not implemented.'); + } + bulkCreated(bulk: Bulk): Promise { this.bulk = bulk; return Promise.resolve(undefined); diff --git a/backend/backend/test/storage/elasticsearch/query.spec.ts b/backend/backend/test/storage/elasticsearch/query.spec.ts index af7087cb..00ebb9cf 100644 --- a/backend/backend/test/storage/elasticsearch/query.spec.ts +++ b/backend/backend/test/storage/elasticsearch/query.spec.ts @@ -479,18 +479,39 @@ describe('Query', function () { it('should build geo filter for shapes and points', function () { const filter = buildFilter(searchFilters.geoPoint); const expectedFilter = { - geo_shape: { - 'geo.polygon': { - relation: undefined, - shape: { - type: 'envelope', - coordinates: [ - [50.123, 8.123], - [50.123, 8.123], - ], + bool: { + should: [ + { + geo_shape: { + 'geo.polygon': { + relation: undefined, + shape: { + coordinates: [ + [50.123, 8.123], + [50.123, 8.123], + ], + type: 'envelope', + }, + }, + 'ignore_unmapped': true, + }, }, - }, - 'ignore_unmapped': true, + { + geo_shape: { + 'geo.point': { + relation: undefined, + shape: { + coordinates: [ + [50.123, 8.123], + [50.123, 8.123], + ], + type: 'envelope', + }, + }, + 'ignore_unmapped': true, + }, + }, + ], }, }; @@ -500,18 +521,39 @@ describe('Query', function () { it('should build geo filter for shapes only', function () { const filter = buildFilter(searchFilters.geoShape); const expectedFilter = { - geo_shape: { - 'geo.polygon': { - relation: 'contains', - shape: { - type: 'envelope', - coordinates: [ - [50.123, 8.123], - [50.123, 8.123], - ], + bool: { + should: [ + { + geo_shape: { + 'geo.polygon': { + relation: 'contains', + shape: { + coordinates: [ + [50.123, 8.123], + [50.123, 8.123], + ], + type: 'envelope', + }, + }, + 'ignore_unmapped': true, + }, }, - }, - 'ignore_unmapped': true, + { + geo_shape: { + 'geo.point': { + relation: 'contains', + shape: { + coordinates: [ + [50.123, 8.123], + [50.123, 8.123], + ], + type: 'envelope', + }, + }, + 'ignore_unmapped': true, + }, + }, + ], }, }; diff --git a/flake.lock b/flake.lock index e3eacb52..3ac48479 100644 --- a/flake.lock +++ b/flake.lock @@ -5,11 +5,11 @@ "systems": "systems" }, "locked": { - "lastModified": 1701680307, - "narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=", + "lastModified": 1709126324, + "narHash": "sha256-q6EQdSeUZOG26WelxqkmR7kArjgWCdw5sfJVHPH/7j8=", "owner": "numtide", "repo": "flake-utils", - "rev": "4022d587cbbfd70fe950c1e2083a02621806a725", + "rev": "d465f4819400de7c8d874d50b982301f28a84605", "type": "github" }, "original": { @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1701626906, - "narHash": "sha256-ugr1QyzzwNk505ICE4VMQzonHQ9QS5W33xF2FXzFQ00=", + "lastModified": 1709747860, + "narHash": "sha256-RT4zuBy579m+l8VyIQFOR66WXfcs4g1jntZUHjh6eoI=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "0c6d8c783336a59f4c59d4a6daed6ab269c4b361", + "rev": "58ae79ea707579c40102ddf62d84b902a987c58b", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 12461e7d..0fe1f26c 100644 --- a/flake.nix +++ b/flake.nix @@ -28,6 +28,7 @@ hash = "sha256-9o0nprGcJhudS1LNm+T7Vf0Dwd1RBauYKI+w1FBQ3ZM="; }; }); + nodejs = prev.nodejs_18; }) ]; config = { @@ -49,21 +50,16 @@ devShell = pkgs.mkShell rec { nativeBuildInputs = [androidFhs]; buildInputs = with pkgs; [ - nodejs-18_x - nodePackages.pnpm + nodejs + corepack # tools curl jq fontMin - # browsers - firefox - google-chrome - epiphany # Safari-ish browser cypress # android jdk17 android.androidsdk - musl ]; ANDROID_JAVA_HOME = "${pkgs.jdk.home}"; ANDROID_SDK_ROOT = "${pkgs.android.androidsdk}/libexec/android-sdk"; diff --git a/frontend/app/angular.json b/frontend/app/angular.json index 037e4e50..ce335c00 100644 --- a/frontend/app/angular.json +++ b/frontend/app/angular.json @@ -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/" } ] } diff --git a/frontend/app/package.json b/frontend/app/package.json index f21738ac..c32257ef 100644 --- a/frontend/app/package.json +++ b/frontend/app/package.json @@ -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", diff --git a/frontend/app/src/app/app.module.ts b/frontend/app/src/app/app.module.ts index fd6549a6..80c0fcd6 100644 --- a/frontend/app/src/app/app.module.ts +++ b/frontend/app/src/app/app.module.ts @@ -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, diff --git a/frontend/app/src/app/modules/about/about.module.ts b/frontend/app/src/app/modules/about/about.module.ts index 5c975fa3..0dc3876d 100644 --- a/frontend/app/src/app/modules/about/about.module.ts +++ b/frontend/app/src/app/modules/about/about.module.ts @@ -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 {} diff --git a/frontend/app/src/app/modules/config/config.module.ts b/frontend/app/src/app/modules/config/config.module.ts deleted file mode 100644 index cd3ee9cb..00000000 --- a/frontend/app/src/app/modules/config/config.module.ts +++ /dev/null @@ -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 . - */ -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 {} diff --git a/frontend/app/src/app/modules/config/config.provider.ts b/frontend/app/src/app/modules/config/config.provider.ts index eed00bec..46671e03 100644 --- a/frontend/app/src/app/modules/config/config.provider.ts +++ b/frontend/app/src/app/modules/config/config.provider.ts @@ -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); } diff --git a/frontend/app/src/app/modules/data/data.module.ts b/frontend/app/src/app/modules/data/data.module.ts index 09697966..a8f89ec5 100644 --- a/frontend/app/src/app/modules/data/data.module.ts +++ b/frontend/app/src/app/modules/data/data.module.ts @@ -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, diff --git a/frontend/app/src/app/modules/data/detail/data-detail-content.scss b/frontend/app/src/app/modules/data/detail/data-detail-content.scss index 09eac6e3..3586c18d 100644 --- a/frontend/app/src/app/modules/data/detail/data-detail-content.scss +++ b/frontend/app/src/app/modules/data/detail/data-detail-content.scss @@ -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; } } } diff --git a/frontend/app/src/app/modules/data/detail/data-detail.scss b/frontend/app/src/app/modules/data/detail/data-detail.scss index 5887f46f..dac662b4 100644 --- a/frontend/app/src/app/modules/data/detail/data-detail.scss +++ b/frontend/app/src/app/modules/data/detail/data-detail.scss @@ -17,7 +17,6 @@ ion-content > div { display: flex; flex: 1; flex-direction: column; - min-height: 100%; } ion-title { diff --git a/frontend/app/src/app/modules/data/elements/origin-detail.component.ts b/frontend/app/src/app/modules/data/elements/origin-detail.component.ts index 5ede56ec..903477ef 100644 --- a/frontend/app/src/app/modules/data/elements/origin-detail.component.ts +++ b/frontend/app/src/app/modules/data/elements/origin-detail.component.ts @@ -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 { /** diff --git a/frontend/app/src/app/modules/data/elements/origin-detail.html b/frontend/app/src/app/modules/data/elements/origin-detail.html index 40cba4da..6648e044 100644 --- a/frontend/app/src/app/modules/data/elements/origin-detail.html +++ b/frontend/app/src/app/modules/data/elements/origin-detail.html @@ -14,71 +14,61 @@ --> @if (origin.type === 'user') { - - {{ 'data.types.origin.TITLE' | translate | titlecase }}: - {{ 'data.types.origin.USER' | translate | titlecase }} - -

- {{ 'data.types.origin.detail.CREATED' | translate | titlecase }}: - {{ origin.created | amDateFormat: 'll' }} -

- @if (origin.updated) { -

- {{ 'data.types.origin.detail.UPDATED' | translate | titlecase }}: - {{ origin.updated | amDateFormat: 'll' }} -

- } - @if (origin.modified) { -

- {{ 'data.types.origin.detail.MODIFIED' | translate | titlecase }}: - {{ origin.modified | amDateFormat: 'll' }} -

- } - @if (origin.maintainer) { -

- {{ 'data.types.origin.detail.MAINTAINER' | translate }}: - {{ origin.maintainer.name }} -

- } -
-
+

+ {{ 'data.types.origin.TITLE' | translate | sentencecase }}: + {{ 'data.types.origin.USER' | translate | sentencecase }} +

+

+ {{ 'data.types.origin.detail.CREATED' | translate | sentencecase }}: + {{ origin.created | amDateFormat: 'll' }} +

+ @if (origin.updated) { +

+ {{ 'data.types.origin.detail.UPDATED' | translate | sentencecase }}: + {{ origin.updated | amDateFormat: 'll' }} +

+ } + @if (origin.modified) { +

+ {{ 'data.types.origin.detail.MODIFIED' | translate | sentencecase }}: + {{ origin.modified | amDateFormat: 'll' }} +

+ } + @if (origin.maintainer) { +

+ {{ 'data.types.origin.detail.MAINTAINER' | translate | sentencecase }}: + {{ origin.maintainer.name }} +

+ } } @if (origin.type === 'remote') { - - {{ 'data.types.origin.TITLE' | translate | titlecase }}: - {{ 'data.types.origin.REMOTE' | translate | titlecase }} - -

- {{ 'data.types.origin.detail.INDEXED' | translate | titlecase }}: - {{ origin.indexed | amDateFormat: 'll' }} -

- @if (origin.modified) { -

- {{ 'data.types.origin.detail.MODIFIED' | translate | titlecase }}: - {{ origin.modified | amDateFormat: 'll' }} -

- } - @if (origin.name) { -

{{ 'data.types.origin.detail.MAINTAINER' | translate }}: {{ origin.name }}

- } - @if (origin.maintainer) { -

- {{ 'data.types.origin.detail.MAINTAINER' | translate | titlecase }}: - {{ origin.maintainer.name }} -

- } - @if (origin.responsibleEntity) { -

- {{ 'data.types.origin.detail.RESPONSIBLE' | translate | titlecase }}: - {{ - origin.responsibleEntity.name - }} -

- } -
-
+

+ {{ 'data.types.origin.TITLE' | translate | sentencecase }}: + {{ 'data.types.origin.REMOTE' | translate | sentencecase }} +

+

+ {{ 'data.types.origin.detail.INDEXED' | translate | sentencecase }}: + {{ origin.indexed | amDateFormat: 'll' }} +

+ @if (origin.modified) { +

+ {{ 'data.types.origin.detail.MODIFIED' | translate | sentencecase }}: + {{ origin.modified | amDateFormat: 'll' }} +

+ } + @if (origin.name) { +

{{ 'data.types.origin.detail.MAINTAINER' | translate | sentencecase }}: {{ origin.name }}

+ } + @if (origin.maintainer) { +

+ {{ 'data.types.origin.detail.MAINTAINER' | translate | sentencecase }}: + {{ origin.maintainer.name }} +

+ } + @if (origin.responsibleEntity) { +

+ {{ 'data.types.origin.detail.RESPONSIBLE' | translate | sentencecase }}: + {{ origin.responsibleEntity.name }} +

+ } } diff --git a/frontend/app/src/app/modules/data/elements/origin-detail.scss b/frontend/app/src/app/modules/data/elements/origin-detail.scss new file mode 100644 index 00000000..049ff9d0 --- /dev/null +++ b/frontend/app/src/app/modules/data/elements/origin-detail.scss @@ -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; +} diff --git a/frontend/app/src/app/modules/data/types/date-series/date-series-detail-content.html b/frontend/app/src/app/modules/data/types/date-series/date-series-detail-content.html index 9ca39b93..e824836b 100644 --- a/frontend/app/src/app/modules/data/types/date-series/date-series-detail-content.html +++ b/frontend/app/src/app/modules/data/types/date-series/date-series-detail-content.html @@ -68,5 +68,5 @@ } @if (item.inPlace && item.inPlace.geo) { - + } diff --git a/frontend/app/src/app/modules/data/types/place/place-list-item.component.ts b/frontend/app/src/app/modules/data/types/place/place-list-item.component.ts index b15d6c01..63e4f263 100644 --- a/frontend/app/src/app/modules/data/types/place/place-list-item.component.ts +++ b/frontend/app/src/app/modules/data/types/place/place-list-item.component.ts @@ -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), ); } diff --git a/frontend/app/src/app/modules/data/types/place/special/mensa/place-mensa-detail.component.ts b/frontend/app/src/app/modules/data/types/place/special/mensa/place-mensa-detail.component.ts index 15bd2bd0..4a739f5d 100644 --- a/frontend/app/src/app/modules/data/types/place/special/mensa/place-mensa-detail.component.ts +++ b/frontend/app/src/app/modules/data/types/place/special/mensa/place-mensa-detail.component.ts @@ -12,7 +12,6 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ -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) { diff --git a/frontend/app/src/app/modules/jobs/jobs.module.ts b/frontend/app/src/app/modules/jobs/jobs.module.ts index 90703617..5d00f5be 100644 --- a/frontend/app/src/app/modules/jobs/jobs.module.ts +++ b/frontend/app/src/app/modules/jobs/jobs.module.ts @@ -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 {} diff --git a/frontend/app/src/app/modules/map/cluster-leaves.pipe.ts b/frontend/app/src/app/modules/map/cluster-leaves.pipe.ts new file mode 100644 index 00000000..4f960438 --- /dev/null +++ b/frontend/app/src/app/modules/map/cluster-leaves.pipe.ts @@ -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(1); + + feature = new ReplaySubject(1); + + limit = new ReplaySubject(1); + + offset = new ReplaySubject(1); + + leaves: Observable[]> = 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[]> { + // 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; + } +} diff --git a/frontend/app/src/app/modules/map/controls/attribution.component.ts b/frontend/app/src/app/modules/map/controls/attribution.component.ts new file mode 100644 index 00000000..7c5186b2 --- /dev/null +++ b/frontend/app/src/app/modules/map/controls/attribution.component.ts @@ -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(); + + hideAttribution = race( + this.manualVisible, + inject(MapService).mapCreated$.pipe(delay(5000), mergeWith(this.manualVisible)), + ).pipe(map((_, i) => i % 2 === 0)); +} diff --git a/frontend/app/src/app/modules/map/controls/attribution.html b/frontend/app/src/app/modules/map/controls/attribution.html new file mode 100644 index 00000000..0dd33c15 --- /dev/null +++ b/frontend/app/src/app/modules/map/controls/attribution.html @@ -0,0 +1,8 @@ +@if ((hideAttribution | async) === null) { + © OpenStreetMap +} + + + diff --git a/frontend/app/src/app/modules/map/controls/attribution.scss b/frontend/app/src/app/modules/map/controls/attribution.scss new file mode 100644 index 00000000..131e6fbd --- /dev/null +++ b/frontend/app/src/app/modules/map/controls/attribution.scss @@ -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; + } +} diff --git a/frontend/app/src/app/modules/map/controls/compass-control.component.ts b/frontend/app/src/app/modules/map/controls/compass-control.component.ts new file mode 100644 index 00000000..9a931ba3 --- /dev/null +++ b/frontend/app/src/app/modules/map/controls/compass-control.component.ts @@ -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( + handler => this.mapService.mapInstance.on('rotate', handler), + handler => this.mapService.mapInstance.off('rotate', handler), + ), + fromEventPattern( + 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) {} +} diff --git a/frontend/app/src/app/modules/map/controls/compass-control.html b/frontend/app/src/app/modules/map/controls/compass-control.html new file mode 100644 index 00000000..032ad172 --- /dev/null +++ b/frontend/app/src/app/modules/map/controls/compass-control.html @@ -0,0 +1,7 @@ + + + > + + + + diff --git a/frontend/app/src/app/modules/map/controls/compass-control.scss b/frontend/app/src/app/modules/map/controls/compass-control.scss new file mode 100644 index 00000000..2806a70c --- /dev/null +++ b/frontend/app/src/app/modules/map/controls/compass-control.scss @@ -0,0 +1,11 @@ +path { + stroke: none; + + &:first-child { + fill: var(--ion-color-primary); + } + + &:last-child { + fill: var(--ion-color-medium); + } +} diff --git a/frontend/app/src/app/modules/map/controls/geolocate-control.component.ts b/frontend/app/src/app/modules/map/controls/geolocate-control.component.ts new file mode 100644 index 00000000..98b955d9 --- /dev/null +++ b/frontend/app/src/app/modules/map/controls/geolocate-control.component.ts @@ -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['_watchState']; + +class CustomGeolocateControl extends GeolocateControl { + constructor( + public _container: HTMLElement, + watchState: BehaviorSubject, + 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('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); + } +} diff --git a/frontend/app/src/app/modules/map/controls/geolocate-control.html b/frontend/app/src/app/modules/map/controls/geolocate-control.html new file mode 100644 index 00000000..b48772c9 --- /dev/null +++ b/frontend/app/src/app/modules/map/controls/geolocate-control.html @@ -0,0 +1,18 @@ +
+ + @switch (watchState | async) { + @case ('ACTIVE_LOCK') { + + } + @case ('BACKGROUND') { + + } + @case ('WAITING_ACTIVE') { + + } + @default { + + } + } + +
diff --git a/frontend/app/src/app/modules/map/controls/geolocate-control.scss b/frontend/app/src/app/modules/map/controls/geolocate-control.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/app/src/app/modules/map/elements/building-markers.component.ts b/frontend/app/src/app/modules/map/elements/building-markers.component.ts new file mode 100644 index 00000000..d64bc6e8 --- /dev/null +++ b/frontend/app/src/app/modules/map/elements/building-markers.component.ts @@ -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( + 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 = this.haloColor.pipe( + map(haloColor => ({ + 'text-color': this.accentColor, + 'text-halo-color': haloColor, + 'text-halo-width': 1, + })), + ); + + @Input({required: true}) data: FeatureCollection; + + 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); + } +} diff --git a/frontend/app/src/app/modules/map/elements/building-markers.html b/frontend/app/src/app/modules/map/elements/building-markers.html new file mode 100644 index 00000000..df243714 --- /dev/null +++ b/frontend/app/src/app/modules/map/elements/building-markers.html @@ -0,0 +1,26 @@ + + + + diff --git a/frontend/app/src/app/modules/map/elements/building-markers.scss b/frontend/app/src/app/modules/map/elements/building-markers.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/app/src/app/modules/map/elements/poi-marker.component.ts b/frontend/app/src/app/modules/map/elements/poi-marker.component.ts new file mode 100644 index 00000000..e4ad1132 --- /dev/null +++ b/frontend/app/src/app/modules/map/elements/poi-marker.component.ts @@ -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; + + @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)); + } +} diff --git a/frontend/app/src/app/modules/map/elements/poi-marker.html b/frontend/app/src/app/modules/map/elements/poi-marker.html new file mode 100644 index 00000000..f2829b42 --- /dev/null +++ b/frontend/app/src/app/modules/map/elements/poi-marker.html @@ -0,0 +1,9 @@ + + + {{ feature.properties.name | addWordBreakOpportunities }} + diff --git a/frontend/app/src/app/modules/map/elements/poi-marker.scss b/frontend/app/src/app/modules/map/elements/poi-marker.scss new file mode 100644 index 00000000..4c1655f5 --- /dev/null +++ b/frontend/app/src/app/modules/map/elements/poi-marker.scss @@ -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; +} diff --git a/frontend/app/src/app/modules/map/elements/poi-markers.component.ts b/frontend/app/src/app/modules/map/elements/poi-markers.component.ts new file mode 100644 index 00000000..d44b709b --- /dev/null +++ b/frontend/app/src/app/modules/map/elements/poi-markers.component.ts @@ -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; + + @Input() clusterPreviewCount = 3; +} diff --git a/frontend/app/src/app/modules/map/elements/poi-markers.html b/frontend/app/src/app/modules/map/elements/poi-markers.html new file mode 100644 index 00000000..2b4c6749 --- /dev/null +++ b/frontend/app/src/app/modules/map/elements/poi-markers.html @@ -0,0 +1,33 @@ + + + +
+ +
+
+ +
+ @if ( + 'pois' + | mglClusterLeaves + : feature + : clusterPreviewCount - (feature.properties.point_count > clusterPreviewCount ? 1 : 0) + | async; + as leaves + ) { + @for (feature of leaves; track feature.id) { + + } + @if (feature.properties.point_count > leaves.length) { +
+{{ feature.properties.point_count - leaves.length }}
+ } + } +
+
+
diff --git a/frontend/app/src/app/modules/map/elements/poi-markers.scss b/frontend/app/src/app/modules/map/elements/poi-markers.scss new file mode 100644 index 00000000..63382381 --- /dev/null +++ b/frontend/app/src/app/modules/map/elements/poi-markers.scss @@ -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; +} diff --git a/frontend/app/src/app/modules/map/feature-collection.pipe.ts b/frontend/app/src/app/modules/map/feature-collection.pipe.ts new file mode 100644 index 00000000..1ecb8c55 --- /dev/null +++ b/frontend/app/src/app/modules/map/feature-collection.pipe.ts @@ -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 { + return { + type: 'FeatureCollection', + features: things + .filter(thing => 'geo' in thing && thing.geo.polygon === undefined) + .map>(thing => ({ + type: 'Feature', + properties: { + name: findBestName(thing), + category: 'categories' in thing ? thing.categories[0] : undefined, + uid: thing.uid, + }, + geometry: (thing as Extract).geo.point, + id: simpleHash(thing.uid), + })), + }; + } +} + +@Pipe({ + name: 'thingPolygonFeatureCollection', + standalone: true, + pure: true, +}) +export class ThingPolygonFeatureCollectionPipe implements PipeTransform { + transform(things: SCThings[]): FeatureCollection { + return { + type: 'FeatureCollection', + features: things + .filter(thing => 'geo' in thing && thing.geo.polygon !== undefined) + .map>(thing => ({ + type: 'Feature', + geometry: (thing as Extract).geo.polygon!, + properties: {uid: thing.uid, name: findBestName(thing)}, + id: simpleHash(thing.uid), + })), + }; + } +} diff --git a/frontend/app/src/app/modules/map/map-auto-3d.directive.ts b/frontend/app/src/app/modules/map/map-auto-3d.directive.ts new file mode 100644 index 00000000..b1ae9f50 --- /dev/null +++ b/frontend/app/src/app/modules/map/map-auto-3d.directive.ts @@ -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); + } + } + } +} diff --git a/frontend/app/src/app/modules/map/map-data.provider.ts b/frontend/app/src/app/modules/map/map-data.provider.ts new file mode 100644 index 00000000..b797f276 --- /dev/null +++ b/frontend/app/src/app/modules/map/map-data.provider.ts @@ -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(undefined); + + readonly currentBounds = new BehaviorSubject< + [[minLon: number, maxLat: number], [maxLon: number, minLat: number]] | undefined + >(undefined); + + override async search(query: SCSearchRequest): Promise { + 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), + }, + }; + } +} diff --git a/frontend/app/src/app/modules/map/map-icon.directive.ts b/frontend/app/src/app/modules/map/map-icon.directive.ts new file mode 100644 index 00000000..795d8a55 --- /dev/null +++ b/frontend/app/src/app/modules/map/map-icon.directive.ts @@ -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['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; + } +} diff --git a/frontend/app/src/app/modules/map/map-maximize-animation.ts b/frontend/app/src/app/modules/map/map-maximize-animation.ts new file mode 100644 index 00000000..46ee5f53 --- /dev/null +++ b/frontend/app/src/app/modules/map/map-maximize-animation.ts @@ -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 . + */ +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; + }; +} diff --git a/frontend/app/src/app/modules/map/map-page.component.ts b/frontend/app/src/app/modules/map/map-page.component.ts new file mode 100644 index 00000000..81d6c939 --- /dev/null +++ b/frontend/app/src/app/modules/map/map-page.component.ts @@ -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 . + */ +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()], + ]); + } +} diff --git a/frontend/app/src/app/modules/map/map-page.html b/frontend/app/src/app/modules/map/map-page.html new file mode 100644 index 00000000..d8865525 --- /dev/null +++ b/frontend/app/src/app/modules/map/map-page.html @@ -0,0 +1,50 @@ + + + + + + @if (dataProvider.current | async; as result) { + + + } + + + + + + + + + + diff --git a/frontend/app/src/app/modules/map/map-page.scss b/frontend/app/src/app/modules/map/map-page.scss new file mode 100644 index 00000000..b9ca5fdb --- /dev/null +++ b/frontend/app/src/app/modules/map/map-page.scss @@ -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 . + */ +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; +} diff --git a/frontend/app/src/app/modules/map/map-size-fix.directive.ts b/frontend/app/src/app/modules/map/map-size-fix.directive.ts new file mode 100644 index 00000000..20d15b08 --- /dev/null +++ b/frontend/app/src/app/modules/map/map-size-fix.directive.ts @@ -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(); + } +} diff --git a/frontend/app/src/app/modules/map/map-style.directive.ts b/frontend/app/src/app/modules/map/map-style.directive.ts new file mode 100644 index 00000000..ffddd478 --- /dev/null +++ b/frontend/app/src/app/modules/map/map-style.directive.ts @@ -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; + } + } +} diff --git a/frontend/app/src/app/modules/map/map-widget.component.ts b/frontend/app/src/app/modules/map/map-widget.component.ts new file mode 100644 index 00000000..675d1788 --- /dev/null +++ b/frontend/app/src/app/modules/map/map-widget.component.ts @@ -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 . + */ +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; + + maximizeAnimation = mapMaximizeAnimation(inject(AnimationController)); +} diff --git a/frontend/app/src/app/modules/map/map-widget.html b/frontend/app/src/app/modules/map/map-widget.html new file mode 100644 index 00000000..003efeef --- /dev/null +++ b/frontend/app/src/app/modules/map/map-widget.html @@ -0,0 +1,51 @@ + + + @if (place.geo.polygon) { + + } @else if (place.geo.point) { + + } + + + + {{ 'map.directions.TITLE' | translate }} + + + + + + + + + + + diff --git a/frontend/app/src/app/modules/map/widget/map-widget.scss b/frontend/app/src/app/modules/map/map-widget.scss similarity index 83% rename from frontend/app/src/app/modules/map/widget/map-widget.scss rename to frontend/app/src/app/modules/map/map-widget.scss index 7cbe7ef2..f41efc51 100644 --- a/frontend/app/src/app/modules/map/widget/map-widget.scss +++ b/frontend/app/src/app/modules/map/map-widget.scss @@ -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%)); } diff --git a/frontend/app/src/app/modules/map/map.module.ts b/frontend/app/src/app/modules/map/map.module.ts index 6d631630..6cd76209 100644 --- a/frontend/app/src/app/modules/map/map.module.ts +++ b/frontend/app/src/app/modules/map/map.module.ts @@ -12,67 +12,14 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ -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 {} diff --git a/frontend/app/src/app/modules/map/map.provider.spec.ts b/frontend/app/src/app/modules/map/map.provider.spec.ts deleted file mode 100644 index 566eeaa5..00000000 --- a/frontend/app/src/app/modules/map/map.provider.spec.ts +++ /dev/null @@ -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 . - */ -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; - - 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(); - }); -}); diff --git a/frontend/app/src/app/modules/map/map.provider.ts b/frontend/app/src/app/modules/map/map.provider.ts deleted file mode 100644 index ee488dce..00000000 --- a/frontend/app/src/app/modules/map/map.provider.ts +++ /dev/null @@ -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 . - */ -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: `${SCIcon`location_on`}`, - 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 - ? `${SCIcon`person_pin_circle`}` - : `${SCIcon`navigation`}`, - 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 { - 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 { - 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; - } -} diff --git a/frontend/app/src/app/modules/map/page/map-list-modal.component.ts b/frontend/app/src/app/modules/map/page/map-list-modal.component.ts deleted file mode 100644 index 66b98f0c..00000000 --- a/frontend/app/src/app/modules/map/page/map-list-modal.component.ts +++ /dev/null @@ -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 . - */ -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[]; - }); - } -} diff --git a/frontend/app/src/app/modules/map/page/map-list-modal.html b/frontend/app/src/app/modules/map/page/map-list-modal.html deleted file mode 100644 index d137f0aa..00000000 --- a/frontend/app/src/app/modules/map/page/map-list-modal.html +++ /dev/null @@ -1,28 +0,0 @@ - - -
- - - {{ 'map.modals.list.TITLE' | translate }} - - {{ 'app.ui.CLOSE' | translate }} - - - - - - -
diff --git a/frontend/app/src/app/modules/map/page/map-list-modal.scss b/frontend/app/src/app/modules/map/page/map-list-modal.scss deleted file mode 100644 index 04a923c5..00000000 --- a/frontend/app/src/app/modules/map/page/map-list-modal.scss +++ /dev/null @@ -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 . - */ - -.container { - display: flex; - flex-direction: column; - height: 100%; -} diff --git a/frontend/app/src/app/modules/map/page/map-page.component.ts b/frontend/app/src/app/modules/map/page/map-page.component.ts deleted file mode 100644 index 4a2453d0..00000000 --- a/frontend/app/src/app/modules/map/page/map-page.component.ts +++ /dev/null @@ -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 . - */ -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: '© OpenStreetMap 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 { - 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, 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()); - }); - } -} diff --git a/frontend/app/src/app/modules/map/page/map-page.html b/frontend/app/src/app/modules/map/page/map-page.html deleted file mode 100644 index 3e3bf90d..00000000 --- a/frontend/app/src/app/modules/map/page/map-page.html +++ /dev/null @@ -1,106 +0,0 @@ - - - - - - - - - - {{ 'map.page.TITLE' | translate }} - - - - - - - - - - - -
- @if (position) { -
- } -
-
-
- @if (items.length > 1) { - -   {{ 'map.page.buttons.SHOW_LIST' | translate }} - - } - - @if (position !== null) { - - } @else { - @if (locationStatus?.location !== 'denied') { - - } @else { - - } - } - -
- - @if (items.length === 1) { - - } - - - - -
- - - - - - -
diff --git a/frontend/app/src/app/modules/map/page/map-page.scss b/frontend/app/src/app/modules/map/page/map-page.scss deleted file mode 100644 index 071bcd4f..00000000 --- a/frontend/app/src/app/modules/map/page/map-page.scss +++ /dev/null @@ -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 . - */ -@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; - } -} diff --git a/frontend/app/src/app/modules/map/position.service.ts b/frontend/app/src/app/modules/map/position.service.ts index 53d36b1a..418145f2 100644 --- a/frontend/app/src/app/modules/map/position.service.ts +++ b/frontend/app/src/app/modules/map/position.service.ts @@ -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(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> = 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], ); } diff --git a/frontend/app/src/app/modules/map/thing-bounds.pipe.ts b/frontend/app/src/app/modules/map/thing-bounds.pipe.ts new file mode 100644 index 00000000..ec05c033 --- /dev/null +++ b/frontend/app/src/app/modules/map/thing-bounds.pipe.ts @@ -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]]; + } + } +} diff --git a/frontend/app/src/app/modules/map/widget/map-widget.component.ts b/frontend/app/src/app/modules/map/widget/map-widget.component.ts deleted file mode 100644 index 74477408..00000000 --- a/frontend/app/src/app/modules/map/widget/map-widget.component.ts +++ /dev/null @@ -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 . - */ -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; - - /** - * 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: '© OpenStreetMap 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); - }); - } -} diff --git a/frontend/app/src/app/modules/map/widget/map-widget.html b/frontend/app/src/app/modules/map/widget/map-widget.html deleted file mode 100644 index e35b89d7..00000000 --- a/frontend/app/src/app/modules/map/widget/map-widget.html +++ /dev/null @@ -1,33 +0,0 @@ - - -
-@if (showExpandButton) { -
- - - {{ 'map.directions.TITLE' | translate }} - - - - -
-} diff --git a/frontend/app/src/app/modules/menu/context/context-menu.component.spec.ts b/frontend/app/src/app/modules/menu/context/context-menu.component.spec.ts index 5e6e1597..58d2e78c 100644 --- a/frontend/app/src/app/modules/menu/context/context-menu.component.spec.ts +++ b/frontend/app/src/app/modules/menu/context/context-menu.component.spec.ts @@ -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: ` -`, + template: ` `, }) class ContextMenuContainerComponent {} diff --git a/frontend/app/src/app/modules/menu/navigation/tabs-transition.ts b/frontend/app/src/app/modules/menu/navigation/tabs-transition.ts index 8bb354a9..e9c6ba00 100644 --- a/frontend/app/src/app/modules/menu/navigation/tabs-transition.ts +++ b/frontend/app/src/app/modules/menu/navigation/tabs-transition.ts @@ -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; }; } diff --git a/frontend/app/src/app/modules/news/page/news-page.html b/frontend/app/src/app/modules/news/page/news-page.html index 6bb214f3..5f7d1214 100644 --- a/frontend/app/src/app/modules/news/page/news-page.html +++ b/frontend/app/src/app/modules/news/page/news-page.html @@ -32,7 +32,7 @@ > - + @if (settings) { diff --git a/frontend/app/src/app/modules/news/page/news-page.scss b/frontend/app/src/app/modules/news/page/news-page.scss index cfe9a606..6a80e144 100644 --- a/frontend/app/src/app/modules/news/page/news-page.scss +++ b/frontend/app/src/app/modules/news/page/news-page.scss @@ -32,3 +32,7 @@ ion-content.ios > div > .news-grid { gap: var(--spacing-lg); margin: var(--spacing-lg); } + +.filter { + flex: 0; +} diff --git a/frontend/app/src/app/modules/settings/settings.module.ts b/frontend/app/src/app/modules/settings/settings.module.ts index 911f4978..85b00f58 100644 --- a/frontend/app/src/app/modules/settings/settings.module.ts +++ b/frontend/app/src/app/modules/settings/settings.module.ts @@ -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 {} diff --git a/frontend/app/src/app/translation/property-name-translate.pipe.ts b/frontend/app/src/app/translation/property-name-translate.pipe.ts index d75876ac..e476ec89 100644 --- a/frontend/app/src/app/translation/property-name-translate.pipe.ts +++ b/frontend/app/src/app/translation/property-name-translate.pipe.ts @@ -43,6 +43,7 @@ export class PropertyNameTranslatePipe implements PipeTransform, OnDestroy { transform>(query: Q, type: K): string; transform(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; diff --git a/frontend/app/src/app/util/ion-icon/icon-match.spec.ts b/frontend/app/src/app/util/ion-icon/icon-match.spec.ts index 6b594c5a..edf8adef 100644 --- a/frontend/app/src/app/util/ion-icon/icon-match.spec.ts +++ b/frontend/app/src/app/util/ion-icon/icon-match.spec.ts @@ -12,6 +12,7 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ +/* eslint-disable unicorn/no-null */ import {matchPropertyAccess, matchPropertyContent, matchTagProperties} from './icon-match.mjs'; describe('matchTagProperties', function () { diff --git a/frontend/app/src/app/util/ion-icon/icon.ts b/frontend/app/src/app/util/ion-icon/icon.ts index 3da2fd38..627143a7 100644 --- a/frontend/app/src/app/util/ion-icon/icon.ts +++ b/frontend/app/src/app/util/ion-icon/icon.ts @@ -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, }); diff --git a/frontend/app/src/app/util/media-query.pipe.ts b/frontend/app/src/app/util/media-query.pipe.ts new file mode 100644 index 00000000..b4a98bcb --- /dev/null +++ b/frontend/app/src/app/util/media-query.pipe.ts @@ -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 { + const match = window.matchMedia(query); + return fromEvent(match, 'change').pipe( + map(event => event.matches), + startWith(match.matches), + ); + } +} diff --git a/frontend/app/src/app/util/word-break-opportunities.pipe.ts b/frontend/app/src/app/util/word-break-opportunities.pipe.ts new file mode 100644 index 00000000..a6163b1c --- /dev/null +++ b/frontend/app/src/app/util/word-break-opportunities.pipe.ts @@ -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'); + } +} diff --git a/frontend/app/src/assets/i18n/de.json b/frontend/app/src/assets/i18n/de.json index 214344ad..08d7db92 100644 --- a/frontend/app/src/assets/i18n/de.json +++ b/frontend/app/src/assets/i18n/de.json @@ -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", diff --git a/frontend/app/src/assets/i18n/en.json b/frontend/app/src/assets/i18n/en.json index 9c7abe84..0591313f 100644 --- a/frontend/app/src/assets/i18n/en.json +++ b/frontend/app/src/assets/i18n/en.json @@ -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", diff --git a/frontend/app/src/assets/icon/favicon.png b/frontend/app/src/assets/icon/favicon.png deleted file mode 100644 index b7705366..00000000 Binary files a/frontend/app/src/assets/icon/favicon.png and /dev/null differ diff --git a/frontend/app/src/assets/icons.min.woff2 b/frontend/app/src/assets/icons.min.woff2 index a82bf8d3..76cd6dc3 100644 Binary files a/frontend/app/src/assets/icons.min.woff2 and b/frontend/app/src/assets/icons.min.woff2 differ diff --git a/frontend/app/src/global.scss b/frontend/app/src/global.scss index 77be0de7..af853d92 100644 --- a/frontend/app/src/global.scss +++ b/frontend/app/src/global.scss @@ -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'; diff --git a/frontend/app/src/theme/common/_ion-content-parallax.scss b/frontend/app/src/theme/common/_ion-content-parallax.scss index 3b0aff70..5bca3750 100644 --- a/frontend/app/src/theme/common/_ion-content-parallax.scss +++ b/frontend/app/src/theme/common/_ion-content-parallax.scss @@ -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) { diff --git a/frontend/app/src/theme/common/_leaflet-tile.scss b/frontend/app/src/theme/common/_leaflet-tile.scss deleted file mode 100644 index ec083aac..00000000 --- a/frontend/app/src/theme/common/_leaflet-tile.scss +++ /dev/null @@ -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 . - */ -@import '../util/dark'; - -@include dark-only { - .leaflet-tile-pane { - filter: invert(1); - } -} - -.map-container.leaflet-container { - background: var(--ion-background-color); -} diff --git a/frontend/app/src/theme/common/_maplibre.scss b/frontend/app/src/theme/common/_maplibre.scss index 7d79b0dc..0a4b1149 100644 --- a/frontend/app/src/theme/common/_maplibre.scss +++ b/frontend/app/src/theme/common/_maplibre.scss @@ -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 { diff --git a/frontend/app/src/theme/variables.scss b/frontend/app/src/theme/variables.scss index 7741f1cd..f8c4c644 100644 --- a/frontend/app/src/theme/variables.scss +++ b/frontend/app/src/theme/variables.scss @@ -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 diff --git a/frontend/app/tsconfig.json b/frontend/app/tsconfig.json index 99603852..6e06e031 100644 --- a/frontend/app/tsconfig.json +++ b/frontend/app/tsconfig.json @@ -5,7 +5,6 @@ "baseUrl": "./", "outDir": "./dist/out-tsc", "declaration": false, - "skipLibCheck": false, "isolatedModules": false, "checkJs": false, "allowJs": false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08880060..c284639e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -719,12 +719,6 @@ importers: '@angular/router': specifier: 17.3.0 version: 17.3.0(@angular/common@17.3.0)(@angular/core@17.3.0)(@angular/platform-browser@17.3.0)(rxjs@7.8.1) - '@asymmetrik/ngx-leaflet': - specifier: 17.0.0 - version: 17.0.0(@angular/common@17.3.0)(@angular/core@17.3.0)(leaflet@1.9.4) - '@asymmetrik/ngx-leaflet-markercluster': - specifier: 17.0.0 - version: 17.0.0(@angular/common@17.3.0)(@angular/core@17.3.0)(@asymmetrik/ngx-leaflet@17.0.0)(leaflet.markercluster@1.5.3)(leaflet@1.9.4) '@awesome-cordova-plugins/calendar': specifier: 6.6.0 version: 6.6.0(@awesome-cordova-plugins/core@6.6.0)(rxjs@7.8.1) @@ -785,6 +779,9 @@ importers: '@ionic/storage-angular': specifier: 4.0.0 version: 4.0.0(@angular/core@17.3.0)(rxjs@7.8.1) + '@maplibre/ngx-maplibre-gl': + specifier: 17.4.1 + version: 17.4.1(@angular/common@17.3.0)(@angular/core@17.3.0)(maplibre-gl@4.0.2)(rxjs@7.8.1) '@ngx-translate/core': specifier: 15.0.0 version: 15.0.0(@angular/common@17.3.0)(@angular/core@17.3.0)(rxjs@7.8.1) @@ -833,15 +830,12 @@ importers: jsonpath-plus: specifier: 6.0.1 version: 6.0.1 - leaflet: - specifier: 1.9.4 - version: 1.9.4 - leaflet.markercluster: - specifier: 1.5.3 - version: 1.5.3(leaflet@1.9.4) + maplibre-gl: + specifier: 4.0.2 + version: 4.0.2 material-symbols: - specifier: 0.17.0 - version: 0.17.0 + specifier: 0.17.1 + version: 0.17.1 moment: specifier: 2.30.1 version: 2.30.1 @@ -860,6 +854,9 @@ importers: opening_hours: specifier: 3.8.0 version: 3.8.0 + pmtiles: + specifier: 3.0.3 + version: 3.0.3 rxjs: specifier: 7.8.1 version: 7.8.1 @@ -978,12 +975,6 @@ importers: '@types/karma-jasmine': specifier: 4.0.5 version: 4.0.5 - '@types/leaflet': - specifier: 1.9.8 - version: 1.9.8 - '@types/leaflet.markercluster': - specifier: 1.5.4 - version: 1.5.4 '@types/node': specifier: 18.15.3 version: 18.15.3 @@ -1066,20 +1057,20 @@ importers: specifier: 3.1.1 version: 3.1.1 stylelint: - specifier: 16.2.1 - version: 16.2.1(typescript@5.4.2) + specifier: 16.3.1 + version: 16.3.1(typescript@5.4.2) stylelint-config-clean-order: specifier: 5.4.1 - version: 5.4.1(stylelint@16.2.1) + version: 5.4.1(stylelint@16.3.1) stylelint-config-prettier-scss: specifier: 1.0.0 - version: 1.0.0(stylelint@16.2.1) + version: 1.0.0(stylelint@16.3.1) stylelint-config-recommended-scss: specifier: 14.0.0 - version: 14.0.0(postcss@8.4.35)(stylelint@16.2.1) + version: 14.0.0(postcss@8.4.38)(stylelint@16.3.1) stylelint-config-standard-scss: specifier: 13.0.0 - version: 13.0.0(postcss@8.4.35)(stylelint@16.2.1) + version: 13.0.0(postcss@8.4.38)(stylelint@16.3.1) surge: specifier: 0.23.1 version: 0.23.1 @@ -2584,36 +2575,6 @@ packages: tslib: 2.6.2 dev: false - /@asymmetrik/ngx-leaflet-markercluster@17.0.0(@angular/common@17.3.0)(@angular/core@17.3.0)(@asymmetrik/ngx-leaflet@17.0.0)(leaflet.markercluster@1.5.3)(leaflet@1.9.4): - resolution: {integrity: sha512-lYp4B3bCBPlYbs8Kz82VFJJ50c2xzF6yL7+sX1bgtY6x094kb5EZvzZLuwczFQ0v1N8nRDfeokKQfZfQ7s47mw==} - peerDependencies: - '@angular/common': '17' - '@angular/core': '17' - '@asymmetrik/ngx-leaflet': '17' - leaflet: '1' - leaflet.markercluster: '1' - dependencies: - '@angular/common': 17.3.0(@angular/core@17.3.0)(rxjs@7.8.1) - '@angular/core': 17.3.0(rxjs@7.8.1)(zone.js@0.14.4) - '@asymmetrik/ngx-leaflet': 17.0.0(@angular/common@17.3.0)(@angular/core@17.3.0)(leaflet@1.9.4) - leaflet: 1.9.4 - leaflet.markercluster: 1.5.3(leaflet@1.9.4) - tslib: 2.6.2 - dev: false - - /@asymmetrik/ngx-leaflet@17.0.0(@angular/common@17.3.0)(@angular/core@17.3.0)(leaflet@1.9.4): - resolution: {integrity: sha512-Tg09780yg1pPRR7k9Z0B0Fb3Mr4SMXYfi+hii8S0McHiqiUqkB+ZhhB4hJq83v4cuvcYgZjtkz+p06lCJY/z+w==} - peerDependencies: - '@angular/common': '17' - '@angular/core': '17' - leaflet: '1' - dependencies: - '@angular/common': 17.3.0(@angular/core@17.3.0)(rxjs@7.8.1) - '@angular/core': 17.3.0(rxjs@7.8.1)(zone.js@0.14.4) - leaflet: 1.9.4 - tslib: 2.6.2 - dev: false - /@awesome-cordova-plugins/calendar@6.6.0(@awesome-cordova-plugins/core@6.6.0)(rxjs@7.8.1): resolution: {integrity: sha512-NobAl4xvmq2zBeOnLI+pqRVpC66p7OpCwd3jzrQ26h8kqhr0o5wqaNcWN6WBjmgD+/AInVnLUzsziL2QpcmD7g==} peerDependencies: @@ -4286,7 +4247,7 @@ packages: /@changesets/apply-release-plan@6.1.4: resolution: {integrity: sha512-FMpKF1fRlJyCZVYHr3CbinpZZ+6MwvOtWUuO8uo+svcATEoc1zRDcj23pAurJ2TZ/uVz1wFHH6K3NlACy0PLew==} dependencies: - '@babel/runtime': 7.22.6 + '@babel/runtime': 7.23.2 '@changesets/config': 2.3.1 '@changesets/get-version-range-type': 0.3.2 '@changesets/git': 2.0.0 @@ -4304,7 +4265,7 @@ packages: /@changesets/assemble-release-plan@5.2.4: resolution: {integrity: sha512-xJkWX+1/CUaOUWTguXEbCDTyWJFECEhmdtbkjhn5GVBGxdP/JwaHBIU9sW3FR6gD07UwZ7ovpiPclQZs+j+mvg==} dependencies: - '@babel/runtime': 7.22.6 + '@babel/runtime': 7.23.2 '@changesets/errors': 0.1.4 '@changesets/get-dependents-graph': 1.3.6 '@changesets/types': 5.2.1 @@ -4388,7 +4349,7 @@ packages: /@changesets/get-release-plan@3.0.17: resolution: {integrity: sha512-6IwKTubNEgoOZwDontYc2x2cWXfr6IKxP3IhKeK+WjyD6y3M4Gl/jdQvBw+m/5zWILSOCAaGLu2ZF6Q+WiPniw==} dependencies: - '@babel/runtime': 7.22.6 + '@babel/runtime': 7.23.2 '@changesets/assemble-release-plan': 5.2.4 '@changesets/config': 2.3.1 '@changesets/pre': 1.0.14 @@ -4404,7 +4365,7 @@ packages: /@changesets/git@2.0.0: resolution: {integrity: sha512-enUVEWbiqUTxqSnmesyJGWfzd51PY4H7mH9yUw0hPVpZBJ6tQZFMU3F3mT/t9OJ/GjyiM4770i+sehAn6ymx6A==} dependencies: - '@babel/runtime': 7.22.6 + '@babel/runtime': 7.23.2 '@changesets/errors': 0.1.4 '@changesets/types': 5.2.1 '@manypkg/get-packages': 1.1.3 @@ -4429,7 +4390,7 @@ packages: /@changesets/pre@1.0.14: resolution: {integrity: sha512-dTsHmxQWEQekHYHbg+M1mDVYFvegDh9j/kySNuDKdylwfMEevTeDouR7IfHNyVodxZXu17sXoJuf2D0vi55FHQ==} dependencies: - '@babel/runtime': 7.22.6 + '@babel/runtime': 7.23.2 '@changesets/errors': 0.1.4 '@changesets/types': 5.2.1 '@manypkg/get-packages': 1.1.3 @@ -4439,7 +4400,7 @@ packages: /@changesets/read@0.5.9: resolution: {integrity: sha512-T8BJ6JS6j1gfO1HFq50kU3qawYxa4NTbI/ASNVVCBTsKquy2HYwM9r7ZnzkiMe8IEObAJtUVGSrePCOxAK2haQ==} dependencies: - '@babel/runtime': 7.22.6 + '@babel/runtime': 7.23.2 '@changesets/git': 2.0.0 '@changesets/logger': 0.0.5 '@changesets/parse': 0.3.16 @@ -4460,7 +4421,7 @@ packages: /@changesets/write@0.2.3: resolution: {integrity: sha512-Dbamr7AIMvslKnNYsLFafaVORx4H0pvCA2MHqgtNCySMe1blImEyAEOzDmcgKAkgz4+uwoLz7demIrX+JBr/Xw==} dependencies: - '@babel/runtime': 7.22.6 + '@babel/runtime': 7.23.2 '@changesets/types': 5.2.1 fs-extra: 7.0.1 human-id: 1.0.2 @@ -4658,6 +4619,10 @@ packages: engines: {node: '>=10.0.0'} dev: true + /@dual-bundle/import-meta-resolve@4.0.0: + resolution: {integrity: sha512-ZKXyJeFAzcpKM2kk8ipoGIPUqx9BX52omTGnfwjJvxOCaZTM2wtDK7zN0aIgPRbT9XYAlha0HtmZ+XKteuh0Gw==} + dev: true + /@effect/schema@0.56.1(effect@2.0.0-next.62)(fast-check@3.15.0): resolution: {integrity: sha512-cfEyHLXPdzSKzJU/yYrPjFd0iVHPydh+NT0sgTe4bzXbcvOsKZuvQ86cAAwXN7lxY0N5cPes9ACxUxTdkEaNlw==} peerDependencies: @@ -5926,7 +5891,7 @@ packages: /@manypkg/get-packages@1.1.3: resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} dependencies: - '@babel/runtime': 7.22.6 + '@babel/runtime': 7.23.2 '@changesets/types': 4.1.0 '@manypkg/find-root': 1.1.0 fs-extra: 8.1.0 @@ -5934,6 +5899,69 @@ packages: read-yaml-file: 1.1.0 dev: true + /@mapbox/geojson-rewind@0.5.2: + resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==} + hasBin: true + dependencies: + get-stream: 6.0.1 + minimist: 1.2.8 + dev: false + + /@mapbox/jsonlint-lines-primitives@2.0.2: + resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==} + engines: {node: '>= 0.6'} + dev: false + + /@mapbox/point-geometry@0.1.0: + resolution: {integrity: sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==} + dev: false + + /@mapbox/tiny-sdf@2.0.6: + resolution: {integrity: sha512-qMqa27TLw+ZQz5Jk+RcwZGH7BQf5G/TrutJhspsca/3SHwmgKQ1iq+d3Jxz5oysPVYTGP6aXxCo5Lk9Er6YBAA==} + dev: false + + /@mapbox/unitbezier@0.0.1: + resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==} + dev: false + + /@mapbox/vector-tile@1.3.1: + resolution: {integrity: sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==} + dependencies: + '@mapbox/point-geometry': 0.1.0 + dev: false + + /@mapbox/whoots-js@3.1.0: + resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==} + engines: {node: '>=6.0.0'} + dev: false + + /@maplibre/maplibre-gl-style-spec@20.1.1: + resolution: {integrity: sha512-z85ARNPCBI2Cs5cPOS3DSbraTN+ue8zrcYVoSWBuNrD/mA+2SKAJ+hIzI22uN7gac6jBMnCdpPKRxS/V0KSZVQ==} + hasBin: true + dependencies: + '@mapbox/jsonlint-lines-primitives': 2.0.2 + '@mapbox/unitbezier': 0.0.1 + json-stringify-pretty-compact: 4.0.0 + minimist: 1.2.8 + rw: 1.3.3 + sort-object: 3.0.3 + dev: false + + /@maplibre/ngx-maplibre-gl@17.4.1(@angular/common@17.3.0)(@angular/core@17.3.0)(maplibre-gl@4.0.2)(rxjs@7.8.1): + resolution: {integrity: sha512-FpMph7i03ZHMtZ7yOyUFuFcU/0T0bcTQrHFu9M6vm6DxxsB45Zk/slSwQmuP/c28ntVzuJBzlKf/ivltYNYyoA==} + peerDependencies: + '@angular/common': '>= 17.0.0' + '@angular/core': '>= 17.0.0' + maplibre-gl: '>= 3.6.0' + rxjs: '>= 7.8.1' + dependencies: + '@angular/common': 17.3.0(@angular/core@17.3.0)(rxjs@7.8.1) + '@angular/core': 17.3.0(rxjs@7.8.1)(zone.js@0.14.4) + maplibre-gl: 4.0.2 + rxjs: 7.8.1 + tslib: 2.6.2 + dev: false + /@ngtools/webpack@17.3.0(@angular/compiler-cli@17.3.0)(typescript@5.4.2)(webpack@5.90.3): resolution: {integrity: sha512-wNTCDPPEtjP4mxYerLVLCMwOCTEOD2HqZMVXD8pJbarrGPMuoyglUZuqNSIS5KVqR+fFez6JEUnMvC3QSqf58w==} engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} @@ -6843,9 +6871,19 @@ packages: dependencies: '@types/node': 18.15.3 + /@types/geojson-vt@3.2.5: + resolution: {integrity: sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==} + dependencies: + '@types/geojson': 1.0.6 + dev: false + /@types/geojson@1.0.6: resolution: {integrity: sha512-Xqg/lIZMrUd0VRmSRbCAewtwGZiAk3mEUDvV4op1tGl+LvyPcb/MIOSxTl9z+9+J+R4/vpjiCAT4xeKzH9ji1w==} + /@types/geojson@7946.0.14: + resolution: {integrity: sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==} + dev: false + /@types/glob@8.1.0: resolution: {integrity: sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==} dependencies: @@ -6951,17 +6989,23 @@ packages: - supports-color dev: true - /@types/leaflet.markercluster@1.5.4: - resolution: {integrity: sha512-tfMP8J62+wfsVLDLGh5Zh1JZxijCaBmVsMAX78MkLPwvPitmZZtSin5aWOVRhZrCS+pEOZwNzexbfWXlY+7yjg==} - dependencies: - '@types/leaflet': 1.9.8 - dev: true - /@types/leaflet@1.9.8: resolution: {integrity: sha512-EXdsL4EhoUtGm2GC2ZYtXn+Fzc6pluVgagvo2VC1RHWToLGlTRwVYoDpqS/7QXa01rmDyBjJk3Catpf60VMkwg==} dependencies: '@types/geojson': 1.0.6 - dev: true + dev: false + + /@types/mapbox__point-geometry@0.1.4: + resolution: {integrity: sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==} + dev: false + + /@types/mapbox__vector-tile@1.3.4: + resolution: {integrity: sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==} + dependencies: + '@types/geojson': 1.0.6 + '@types/mapbox__point-geometry': 0.1.4 + '@types/pbf': 3.0.5 + dev: false /@types/mdast@3.0.15: resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==} @@ -7035,6 +7079,10 @@ packages: /@types/normalize-package-data@2.4.1: resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} + /@types/pbf@3.0.5: + resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==} + dev: false + /@types/promise-queue@2.2.0: resolution: {integrity: sha512-9QLtid6GxEWqpF+QImxBRG6bSVOHtpAm2kXuIyEvZBbSOupLvqhhJv8uaHbS8kUL8FDjzH3RWcSyC/52WOVtGw==} dev: false @@ -7168,6 +7216,12 @@ packages: '@types/node': 18.15.3 dev: true + /@types/supercluster@7.1.3: + resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==} + dependencies: + '@types/geojson': 1.0.6 + dev: false + /@types/supertest@2.0.12: resolution: {integrity: sha512-X3HPWTwXRerBZS7Mo1k6vMVR1Z6zmJcDVn5O/31whe0tnjE4te6ZJSJGq1RiqHPjzPdMTfjCFogDJmwng9xHaQ==} dependencies: @@ -7926,6 +7980,11 @@ packages: dequal: 2.0.3 dev: true + /arr-union@3.1.0: + resolution: {integrity: sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==} + engines: {node: '>=0.10.0'} + dev: false + /array-buffer-byte-length@1.0.0: resolution: {integrity: sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==} dependencies: @@ -7990,6 +8049,11 @@ packages: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} dev: true + /assign-symbols@1.0.0: + resolution: {integrity: sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==} + engines: {node: '>=0.10.0'} + dev: false + /ast-transform@0.0.0: resolution: {integrity: sha512-e/JfLiSoakfmL4wmTGPjv0HpTICVmxwXgYOB8x+mzozHL8v+dSfCbrJ8J8hJ0YBP0XcYu1aLZ6b/3TnxNK3P2A==} dependencies: @@ -8488,6 +8552,19 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + /bytewise-core@1.2.3: + resolution: {integrity: sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA==} + dependencies: + typewise-core: 1.2.0 + dev: false + + /bytewise@1.1.0: + resolution: {integrity: sha512-rHuuseJ9iQ0na6UDhnrRVDh8YnWVlU6xM3VH6q/+yHDeUH2zIhUzP+2/h3LIrhLDBtTqzWpE3p3tP/boefskKQ==} + dependencies: + bytewise-core: 1.2.3 + typewise: 1.0.3 + dev: false + /c8@7.14.0: resolution: {integrity: sha512-i04rtkkcNcCf7zsQcSv/T9EbUn4RXQ6mropeMcjFOsQXQ0iGLAr/xT6TImQg4+U9hmNpN9XdvPkjUL1IzbgxJw==} engines: {node: '>=10.12.0'} @@ -10533,6 +10610,10 @@ packages: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} dev: true + /earcut@2.2.4: + resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} + dev: false + /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -11345,6 +11426,21 @@ packages: type: 2.7.2 dev: true + /extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + dependencies: + is-extendable: 0.1.1 + dev: false + + /extend-shallow@3.0.2: + resolution: {integrity: sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==} + engines: {node: '>=0.10.0'} + dependencies: + assign-symbols: 1.0.0 + is-extendable: 1.0.1 + dev: false + /extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} dev: true @@ -11460,6 +11556,10 @@ packages: pend: 1.2.0 dev: true + /fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + dev: false + /figures@2.0.0: resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==} engines: {node: '>=4'} @@ -11866,6 +11966,10 @@ packages: engines: {node: '>=6.9.0'} dev: true + /geojson-vt@3.2.1: + resolution: {integrity: sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==} + dev: false + /geojson@0.5.0: resolution: {integrity: sha512-/Bx5lEn+qRF4TfQ5aLu6NH+UKtvIv7Lhc487y/c8BdludrCTpiWf9wyI0RTyqg49MFefIAvFDuEi5Dfd/zgNxQ==} engines: {node: '>= 0.10'} @@ -11955,6 +12059,11 @@ packages: - supports-color dev: true + /get-value@2.0.6: + resolution: {integrity: sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==} + engines: {node: '>=0.10.0'} + dev: false + /getos@3.2.1: resolution: {integrity: sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==} dependencies: @@ -12006,6 +12115,10 @@ packages: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} dev: true + /gl-matrix@3.4.3: + resolution: {integrity: sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA==} + dev: false + /glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -12107,7 +12220,6 @@ packages: ini: 1.3.8 kind-of: 6.0.3 which: 1.3.1 - dev: true /globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} @@ -12728,7 +12840,6 @@ packages: /ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - dev: true /ini@2.0.0: resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} @@ -12983,6 +13094,18 @@ packages: resolution: {integrity: sha512-SpMppC2XR3YdxSzczXReBjqs2zGscWQpBIKqwXYBFic0ERaxNVgwLCHwOLZeESfdJQjX0RDvrJ1lBXX2ij+G1Q==} dev: false + /is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + dev: false + + /is-extendable@1.0.1: + resolution: {integrity: sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==} + engines: {node: '>=0.10.0'} + dependencies: + is-plain-object: 2.0.4 + dev: false + /is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -13067,7 +13190,6 @@ packages: engines: {node: '>=0.10.0'} dependencies: isobject: 3.0.1 - dev: true /is-plain-object@5.0.0: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} @@ -13203,7 +13325,6 @@ packages: /isobject@3.0.1: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} - dev: true /isstream@0.1.2: resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} @@ -13435,6 +13556,10 @@ packages: /json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + /json-stringify-pretty-compact@4.0.0: + resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==} + dev: false + /json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} @@ -13629,6 +13754,10 @@ packages: dev: false optional: true + /kdbush@4.0.2: + resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==} + dev: false + /keycharm@0.2.0: resolution: {integrity: sha512-i/XBRTiLqRConPKioy2oq45vbv04e8x59b0mnsIRQM+7Ec/8BC7UcL5pnC4FMeGb8KwG7q4wOMw7CtNZf5tiIg==} dev: true @@ -13653,7 +13782,6 @@ packages: /kind-of@6.0.3: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} - dev: true /kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} @@ -13673,6 +13801,10 @@ packages: resolution: {integrity: sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==} dev: true + /known-css-properties@0.30.0: + resolution: {integrity: sha512-VSWXYUnsPu9+WYKkfmJyLKtIvaRJi1kXUqVmBACORXZQxT5oZDsoZ2vQP+bQFDnWtpI/4eq3MLoRMjI2fnLzTQ==} + dev: true + /launch-editor@2.6.1: resolution: {integrity: sha512-eB/uXmFVpY4zezmGp5XtU21kwo7GBbKB+EQ+UZeWtGb9yAM5xt/Evk+lYH3eRNAtId+ej4u7TYPFZ07w4s7rRw==} dependencies: @@ -13691,18 +13823,6 @@ packages: engines: {node: '> 0.8'} dev: true - /leaflet.markercluster@1.5.3(leaflet@1.9.4): - resolution: {integrity: sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==} - peerDependencies: - leaflet: ^1.3.1 - dependencies: - leaflet: 1.9.4 - dev: false - - /leaflet@1.9.4: - resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} - dev: false - /leek@0.0.24: resolution: {integrity: sha512-6PVFIYXxlYF0o6hrAsHtGpTmi06otkwNrMcmQ0K96SeSRHPREPa9J3nJZ1frliVH7XT0XFswoJFQoXsDukzGNQ==} dependencies: @@ -13790,8 +13910,6 @@ packages: peerDependenciesMeta: webpack: optional: true - webpack-sources: - optional: true dependencies: webpack: 5.90.3(esbuild@0.20.1) webpack-sources: 3.2.3 @@ -14212,6 +14330,38 @@ packages: resolution: {integrity: sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==} dev: true + /maplibre-gl@4.0.2: + resolution: {integrity: sha512-1HlJJyfPIbAwK1OlNzKDyuNf1NKlEwsjZZhPYpazX4yoP6ud1aC7DNct62fMSkn+hd6mRekIqzYEzIfOCC31QQ==} + engines: {node: '>=16.14.0', npm: '>=8.1.0'} + dependencies: + '@mapbox/geojson-rewind': 0.5.2 + '@mapbox/jsonlint-lines-primitives': 2.0.2 + '@mapbox/point-geometry': 0.1.0 + '@mapbox/tiny-sdf': 2.0.6 + '@mapbox/unitbezier': 0.0.1 + '@mapbox/vector-tile': 1.3.1 + '@mapbox/whoots-js': 3.1.0 + '@maplibre/maplibre-gl-style-spec': 20.1.1 + '@types/geojson': 7946.0.14 + '@types/geojson-vt': 3.2.5 + '@types/mapbox__point-geometry': 0.1.4 + '@types/mapbox__vector-tile': 1.3.4 + '@types/pbf': 3.0.5 + '@types/supercluster': 7.1.3 + earcut: 2.2.4 + geojson-vt: 3.2.1 + gl-matrix: 3.4.3 + global-prefix: 3.0.0 + kdbush: 4.0.2 + murmurhash-js: 1.0.0 + pbf: 3.2.1 + potpack: 2.0.0 + quickselect: 2.0.0 + supercluster: 8.0.1 + tinyqueue: 2.0.3 + vt-pbf: 3.1.3 + dev: false + /marked@1.2.9: resolution: {integrity: sha512-H8lIX2SvyitGX+TRdtS06m1jHMijKN/XjfH6Ooii9fvxMlh8QdqBfBDkGUpMWH2kQNrtixjzYUa3SH8ROTgRRw==} engines: {node: '>= 8.16.2'} @@ -14236,8 +14386,8 @@ packages: hasBin: true dev: false - /material-symbols@0.17.0: - resolution: {integrity: sha512-CwDddz58cIH/svt8/wjAegQTp9bfNNIkIPjkkr0MAmA1oMlmUkG+C2ss2/yR22mlDzUX4DnXIq5MrgcpKTXoyA==} + /material-symbols@0.17.1: + resolution: {integrity: sha512-1kJan8t3U3Fmuu/YPu2MVsL/ODSja71o+J7ODROQfMaCzzal0izY4SATafEKgXUXU+jL0zIiBQdyzsno7vXBvA==} dev: false /mathml-tag-names@2.1.3: @@ -14957,6 +15107,10 @@ packages: thunky: 1.1.0 dev: true + /murmurhash-js@1.0.0: + resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==} + dev: false + /mustache@4.2.0: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true @@ -16039,6 +16193,14 @@ packages: through: 2.3.8 dev: true + /pbf@3.2.1: + resolution: {integrity: sha512-ClrV7pNOn7rtmoQVF4TS1vyU0WhYRnP92fzbfF75jAIwpnzdJXf8iTd4CMEqO4yUenH6NDqLiwjqlh6QgZzgLQ==} + hasBin: true + dependencies: + ieee754: 1.2.1 + resolve-protobuf-schema: 2.1.0 + dev: false + /pdfjs-dist@2.12.313: resolution: {integrity: sha512-1x6iXO4Qnv6Eb+YFdN5JdUzt4pAkxSp3aLAYPX93eQCyg/m7QFzXVWJHJVtoW48CI8HCXju4dSkhQZwoheL5mA==} peerDependencies: @@ -16135,6 +16297,13 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + /pmtiles@3.0.3: + resolution: {integrity: sha512-tj4l3HHJd6/qf9VefzlPK2eYEQgbf+4uXPzNlrj3k7hHvLtibYSxfp51TF6ALt4YezM8MCdiOminnHvdAyqyGg==} + dependencies: + '@types/leaflet': 1.9.8 + fflate: 0.8.2 + dev: false + /png-js@1.0.0: resolution: {integrity: sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==} dev: true @@ -16238,22 +16407,22 @@ packages: resolution: {integrity: sha512-HvExULSwLqHLgUy1rl3ANIqCsvMS0WHss2UOsXhXnQaZ9VCc2oBvIpXrl00IUFT5ZDITME0o6oiXeiHr2SAIfw==} dev: true - /postcss-safe-parser@7.0.0(postcss@8.4.35): + /postcss-safe-parser@7.0.0(postcss@8.4.38): resolution: {integrity: sha512-ovehqRNVCpuFzbXoTb4qLtyzK3xn3t/CUBxOs8LsnQjQrShaB4lKiHoVqY8ANaC0hBMHq5QVWk77rwGklFUDrg==} engines: {node: '>=18.0'} peerDependencies: postcss: ^8.4.31 dependencies: - postcss: 8.4.35 + postcss: 8.4.38 dev: true - /postcss-scss@4.0.9(postcss@8.4.35): + /postcss-scss@4.0.9(postcss@8.4.38): resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} engines: {node: '>=12.0'} peerDependencies: postcss: ^8.4.29 dependencies: - postcss: 8.4.35 + postcss: 8.4.38 dev: true /postcss-selector-parser@6.0.13: @@ -16293,6 +16462,19 @@ packages: source-map-js: 1.0.2 dev: true + /postcss@8.4.38: + resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.7 + picocolors: 1.0.0 + source-map-js: 1.2.0 + dev: true + + /potpack@2.0.0: + resolution: {integrity: sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==} + dev: false + /prebuild-install@7.1.1: resolution: {integrity: sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==} engines: {node: '>=10'} @@ -16439,6 +16621,10 @@ packages: hammerjs: 2.0.8 dev: true + /protocol-buffers-schema@3.6.0: + resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==} + dev: false + /proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -16563,6 +16749,10 @@ packages: engines: {node: '>=10'} dev: false + /quickselect@2.0.0: + resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==} + dev: false + /quote-stream@1.0.2: resolution: {integrity: sha512-kKr2uQ2AokadPjvTyKJQad9xELbZwYzWlNfI3Uz2j/ib5u6H9lDP7fUUR//rMycd0gv4Z5P1qXMfXR8YpIxrjQ==} hasBin: true @@ -16769,7 +16959,7 @@ packages: resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} engines: {node: '>= 0.10'} dependencies: - resolve: 1.22.2 + resolve: 1.22.8 dev: false /redent@3.0.0: @@ -16936,6 +17126,12 @@ packages: engines: {node: '>=8'} dev: true + /resolve-protobuf-schema@2.1.0: + resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} + dependencies: + protocol-buffers-schema: 3.6.0 + dev: false + /resolve-url-loader@5.0.0: resolution: {integrity: sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==} engines: {node: '>=12'} @@ -16966,7 +17162,6 @@ packages: is-core-module: 2.13.0 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - dev: true /responselike@3.0.0: resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==} @@ -17101,7 +17296,6 @@ packages: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} requiresBuild: true dev: false - optional: true /rxjs@6.6.7: resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==} @@ -17355,6 +17549,16 @@ packages: has-property-descriptors: 1.0.2 dev: true + /set-value@2.0.1: + resolution: {integrity: sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==} + engines: {node: '>=0.10.0'} + dependencies: + extend-shallow: 2.0.1 + is-extendable: 0.1.1 + is-plain-object: 2.0.4 + split-string: 3.1.0 + dev: false + /setprototypeof@1.1.0: resolution: {integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==} dev: true @@ -17672,11 +17876,38 @@ packages: ip: 2.0.0 smart-buffer: 4.2.0 + /sort-asc@0.2.0: + resolution: {integrity: sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA==} + engines: {node: '>=0.10.0'} + dev: false + + /sort-desc@0.2.0: + resolution: {integrity: sha512-NqZqyvL4VPW+RAxxXnB8gvE1kyikh8+pR+T+CXLksVRN9eiQqkQlPwqWYU0mF9Jm7UnctShlxLyAt1CaBOTL1w==} + engines: {node: '>=0.10.0'} + dev: false + + /sort-object@3.0.3: + resolution: {integrity: sha512-nK7WOY8jik6zaG9CRwZTaD5O7ETWDLZYMM12pqY8htll+7dYeqGfEUPcUBHOpSJg2vJOrvFIY2Dl5cX2ih1hAQ==} + engines: {node: '>=0.10.0'} + dependencies: + bytewise: 1.1.0 + get-value: 2.0.6 + is-extendable: 0.1.1 + sort-asc: 0.2.0 + sort-desc: 0.2.0 + union-value: 1.0.1 + dev: false + /source-map-js@1.0.2: resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} engines: {node: '>=0.10.0'} dev: true + /source-map-js@1.2.0: + resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} + engines: {node: '>=0.10.0'} + dev: true + /source-map-loader@5.0.0(webpack@5.90.3): resolution: {integrity: sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==} engines: {node: '>= 18.12.0'} @@ -17811,6 +18042,13 @@ packages: resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} dev: false + /split-string@3.1.0: + resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==} + engines: {node: '>=0.10.0'} + dependencies: + extend-shallow: 3.0.2 + dev: false + /split2@3.2.2: resolution: {integrity: sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==} dependencies: @@ -18087,26 +18325,26 @@ packages: through: 2.3.8 dev: true - /stylelint-config-clean-order@5.4.1(stylelint@16.2.1): + /stylelint-config-clean-order@5.4.1(stylelint@16.3.1): resolution: {integrity: sha512-C9E94vFk7QKqPshXik3iNU5cYz7vm0Up4/wu1biRjThWLWJ3gYRdXfyV/1fFU7u4ThfSIf2/ijNhk0pf0ErPWQ==} peerDependencies: stylelint: '>=14' dependencies: - stylelint: 16.2.1(typescript@5.4.2) - stylelint-order: 6.0.4(stylelint@16.2.1) + stylelint: 16.3.1(typescript@5.4.2) + stylelint-order: 6.0.4(stylelint@16.3.1) dev: true - /stylelint-config-prettier-scss@1.0.0(stylelint@16.2.1): + /stylelint-config-prettier-scss@1.0.0(stylelint@16.3.1): resolution: {integrity: sha512-Gr2qLiyvJGKeDk0E/+awNTrZB/UtNVPLqCDOr07na/sLekZwm26Br6yYIeBYz3ulsEcQgs5j+2IIMXCC+wsaQA==} engines: {node: 14.* || 16.* || >= 18} hasBin: true peerDependencies: stylelint: '>=15.0.0' dependencies: - stylelint: 16.2.1(typescript@5.4.2) + stylelint: 16.3.1(typescript@5.4.2) dev: true - /stylelint-config-recommended-scss@14.0.0(postcss@8.4.35)(stylelint@16.2.1): + /stylelint-config-recommended-scss@14.0.0(postcss@8.4.38)(stylelint@16.3.1): resolution: {integrity: sha512-HDvpoOAQ1RpF+sPbDOT2Q2/YrBDEJDnUymmVmZ7mMCeNiFSdhRdyGEimBkz06wsN+HaFwUh249gDR+I9JR7Onw==} engines: {node: '>=18.12.0'} peerDependencies: @@ -18116,23 +18354,23 @@ packages: postcss: optional: true dependencies: - postcss: 8.4.35 - postcss-scss: 4.0.9(postcss@8.4.35) - stylelint: 16.2.1(typescript@5.4.2) - stylelint-config-recommended: 14.0.0(stylelint@16.2.1) - stylelint-scss: 6.2.1(stylelint@16.2.1) + postcss: 8.4.38 + postcss-scss: 4.0.9(postcss@8.4.38) + stylelint: 16.3.1(typescript@5.4.2) + stylelint-config-recommended: 14.0.0(stylelint@16.3.1) + stylelint-scss: 6.2.1(stylelint@16.3.1) dev: true - /stylelint-config-recommended@14.0.0(stylelint@16.2.1): + /stylelint-config-recommended@14.0.0(stylelint@16.3.1): resolution: {integrity: sha512-jSkx290CglS8StmrLp2TxAppIajzIBZKYm3IxT89Kg6fGlxbPiTiyH9PS5YUuVAFwaJLl1ikiXX0QWjI0jmgZQ==} engines: {node: '>=18.12.0'} peerDependencies: stylelint: ^16.0.0 dependencies: - stylelint: 16.2.1(typescript@5.4.2) + stylelint: 16.3.1(typescript@5.4.2) dev: true - /stylelint-config-standard-scss@13.0.0(postcss@8.4.35)(stylelint@16.2.1): + /stylelint-config-standard-scss@13.0.0(postcss@8.4.38)(stylelint@16.3.1): resolution: {integrity: sha512-WaLvkP689qSYUpJQPCo30TFJSSc3VzvvoWnrgp+7PpVby5o8fRUY1cZcP0sePZfjrFl9T8caGhcKg0GO34VDiQ==} engines: {node: '>=18.12.0'} peerDependencies: @@ -18142,33 +18380,33 @@ packages: postcss: optional: true dependencies: - postcss: 8.4.35 - stylelint: 16.2.1(typescript@5.4.2) - stylelint-config-recommended-scss: 14.0.0(postcss@8.4.35)(stylelint@16.2.1) - stylelint-config-standard: 36.0.0(stylelint@16.2.1) + postcss: 8.4.38 + stylelint: 16.3.1(typescript@5.4.2) + stylelint-config-recommended-scss: 14.0.0(postcss@8.4.38)(stylelint@16.3.1) + stylelint-config-standard: 36.0.0(stylelint@16.3.1) dev: true - /stylelint-config-standard@36.0.0(stylelint@16.2.1): + /stylelint-config-standard@36.0.0(stylelint@16.3.1): resolution: {integrity: sha512-3Kjyq4d62bYFp/Aq8PMKDwlgUyPU4nacXsjDLWJdNPRUgpuxALu1KnlAHIj36cdtxViVhXexZij65yM0uNIHug==} engines: {node: '>=18.12.0'} peerDependencies: stylelint: ^16.1.0 dependencies: - stylelint: 16.2.1(typescript@5.4.2) - stylelint-config-recommended: 14.0.0(stylelint@16.2.1) + stylelint: 16.3.1(typescript@5.4.2) + stylelint-config-recommended: 14.0.0(stylelint@16.3.1) dev: true - /stylelint-order@6.0.4(stylelint@16.2.1): + /stylelint-order@6.0.4(stylelint@16.3.1): resolution: {integrity: sha512-0UuKo4+s1hgQ/uAxlYU4h0o0HS4NiQDud0NAUNI0aa8FJdmYHA5ZZTFHiV5FpmE3071e9pZx5j0QpVJW5zOCUA==} peerDependencies: stylelint: ^14.0.0 || ^15.0.0 || ^16.0.1 dependencies: postcss: 8.4.35 postcss-sorting: 8.0.2(postcss@8.4.35) - stylelint: 16.2.1(typescript@5.4.2) + stylelint: 16.3.1(typescript@5.4.2) dev: true - /stylelint-scss@6.2.1(stylelint@16.2.1): + /stylelint-scss@6.2.1(stylelint@16.3.1): resolution: {integrity: sha512-ZoGLbVb1keZYRVGQlhB8G6sZOoNqw61whzzzGFWp05N12ErqLFfBv3JPrXiMLZaW98sBS7K/vUQhRnvUj4vwdw==} engines: {node: '>=18.12.0'} peerDependencies: @@ -18179,11 +18417,11 @@ packages: postcss-resolve-nested-selector: 0.1.1 postcss-selector-parser: 6.0.16 postcss-value-parser: 4.2.0 - stylelint: 16.2.1(typescript@5.4.2) + stylelint: 16.3.1(typescript@5.4.2) dev: true - /stylelint@16.2.1(typescript@5.4.2): - resolution: {integrity: sha512-SfIMGFK+4n7XVAyv50CpVfcGYWG4v41y6xG7PqOgQSY8M/PgdK0SQbjWFblxjJZlN9jNq879mB4BCZHJRIJ1hA==} + /stylelint@16.3.1(typescript@5.4.2): + resolution: {integrity: sha512-/JOwQnBvxEKOT2RtNgGpBVXnCSMBgKOL2k7w0K52htwCyJls4+cHvc4YZgXlVoAZS9QJd2DgYAiRnja96pTgxw==} engines: {node: '>=18.12.0'} hasBin: true dependencies: @@ -18191,6 +18429,7 @@ packages: '@csstools/css-tokenizer': 2.2.4 '@csstools/media-query-list-parser': 2.1.9(@csstools/css-parser-algorithms@2.6.1)(@csstools/css-tokenizer@2.2.4) '@csstools/selector-specificity': 3.0.2(postcss-selector-parser@6.0.16) + '@dual-bundle/import-meta-resolve': 4.0.0 balanced-match: 2.0.0 colord: 2.9.3 cosmiconfig: 9.0.0(typescript@5.4.2) @@ -18207,15 +18446,15 @@ packages: ignore: 5.3.1 imurmurhash: 0.1.4 is-plain-object: 5.0.0 - known-css-properties: 0.29.0 + known-css-properties: 0.30.0 mathml-tag-names: 2.1.3 meow: 13.2.0 micromatch: 4.0.5 normalize-path: 3.0.0 picocolors: 1.0.0 - postcss: 8.4.35 + postcss: 8.4.38 postcss-resolve-nested-selector: 0.1.1 - postcss-safe-parser: 7.0.0(postcss@8.4.35) + postcss-safe-parser: 7.0.0(postcss@8.4.38) postcss-selector-parser: 6.0.16 postcss-value-parser: 4.2.0 resolve-from: 5.0.0 @@ -18290,6 +18529,12 @@ packages: - supports-color dev: true + /supercluster@8.0.1: + resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==} + dependencies: + kdbush: 4.0.2 + dev: false + /supertest@6.3.3: resolution: {integrity: sha512-EMCG6G8gDu5qEqRQ3JjjPs6+FYT1a7Hv5ApHvtSghmOFJYtsU5S+pSb6Y2EUeCEY3CmEL3mmQ8YWlPOzQomabA==} engines: {node: '>=6.4.0'} @@ -18642,6 +18887,10 @@ packages: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} dev: true + /tinyqueue@2.0.3: + resolution: {integrity: sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==} + dev: false + /tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -19170,6 +19419,16 @@ packages: engines: {node: '>=14.17'} hasBin: true + /typewise-core@1.2.0: + resolution: {integrity: sha512-2SCC/WLzj2SbUwzFOzqMCkz5amXLlxtJqDKTICqg30x+2DZxcfZN2MvQZmGfXWKNWaKK9pBPsvkcwv8bF/gxKg==} + dev: false + + /typewise@1.0.3: + resolution: {integrity: sha512-aXofE06xGhaQSPzt8hlTY+/YWQhm9P0jYUp1f2XtmW/3Bk0qzXcyFWAtPoo2uTGQj1ZwbDuSyuxicq+aDo8lCQ==} + dependencies: + typewise-core: 1.2.0 + dev: false + /ua-parser-js@0.7.36: resolution: {integrity: sha512-CPPLoCts2p7D8VbybttE3P2ylv0OBZEAy7a12DsulIEcAiMtWJy+PBgMXgWDI80D5UwqE8oQPHYnk13tm38M2Q==} dev: true @@ -19242,6 +19501,16 @@ packages: tiny-inflate: 1.0.3 dev: true + /union-value@1.0.1: + resolution: {integrity: sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==} + engines: {node: '>=0.10.0'} + dependencies: + arr-union: 3.1.0 + get-value: 2.0.6 + is-extendable: 0.1.1 + set-value: 2.0.1 + dev: false + /union@0.5.0: resolution: {integrity: sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==} engines: {node: '>= 0.8.0'} @@ -19546,6 +19815,14 @@ packages: resolution: {integrity: sha512-eOpPHogvorZRobNqJGhapa0JdwaxpjVvyBp0QIUMRMSf8ZAlqOdEquKuRmw9Qwu0qXtJIWqFtMkmvJjUZmMjVA==} dev: true + /vt-pbf@3.1.3: + resolution: {integrity: sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==} + dependencies: + '@mapbox/point-geometry': 0.1.0 + '@mapbox/vector-tile': 1.3.1 + pbf: 3.2.1 + dev: false + /wait-on@6.0.1: resolution: {integrity: sha512-zht+KASY3usTY5u2LgaNqn/Cd8MukxLGjdcZxT2ns5QzDmTFc4XoWBgC+C/na+sMRZTuVygQoMYwdcVjHnYIVw==} engines: {node: '>=10.0.0'} @@ -19842,7 +20119,6 @@ packages: hasBin: true dependencies: isexe: 2.0.0 - dev: true /which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}