Compare commits

..

8 Commits

Author SHA1 Message Date
69868663cb feat: update to node 22 2024-06-03 13:16:16 +02:00
Rainer Killinger
a6b88d3534 refactor: add f-u specific changelogs 2024-05-27 16:27:37 +02:00
Rainer Killinger
be863daaef refactor: updated used licences within the app 2024-05-27 15:36:00 +02:00
2f64d69693 feat: migrate to protomaps and maplibre 2024-05-27 15:07:27 +02:00
964516d3cf fix: remove noUnused* TSConfig options
TSConfig options prevent Angular from compiling the app. This is
specifically harsh with the noUnused* rules, which require you to
strictly remove any unused variables even in dev mode while testing.
Since this case is already covered by ESLint, the TSConfig option was
removed.
2024-05-27 15:07:27 +02:00
71ff9fd960 fix: favorite button 2024-05-27 15:07:27 +02:00
abf9999461 feat: type-safe sc-icons 2024-05-27 15:07:26 +02:00
53c3d0ba0c refactor: replace rfdc with native structuredClone 2024-05-27 15:07:25 +02:00
158 changed files with 2864 additions and 2344 deletions

View File

@@ -2,7 +2,13 @@
/** @type {import('syncpack').RcFile} */
const config = {
semverGroups: [{range: ''}],
semverGroups: [
{
range: '',
dependencies: ['**'],
packages: ['**'],
}
],
source: ['package.json', '**/package.json'],
indent: ' ',
sortFirst: [

View File

@@ -1,3 +1,3 @@
nodejs 18.19.1
nodejs 22.2.0
pnpm 8.15.4
python 3.11.5
python 3.11.5

View File

@@ -0,0 +1,15 @@
# Goethe-Uni App 2.5
Die Goethe-Uni App ist noch besser geworden!
## Komplett neue Kartenansicht
Wir haben die Karte überarbeitet, um eine klarere und schnellere Übersicht zu bieten.
## Deutschlandticket mit an Bord
Wenn du das Upgrade des Semesterticket zum Deutschlandticket gemacht hast und eingeloggt bist, findet es sich jetzt auch in der App.
## Bibliotheksdienste sind wieder voll funktionsfähig
Aufgrund einiger Adhoc-Änderungen im Bibliothekssystem haben wir die App so angepasst, dass sie damit umgehen kann.

View File

@@ -0,0 +1,15 @@
# Goethe-Uni App 2.5
The Goethe-Uni App got even better!
## Completelty new map view
We overhauled the map to offer you a clearer and faster and overview.
## Deutschlandticket included
If you upgraded your Semesterticket to a Deutschlandticket it will now reside in the App if you are logged in.
## Library services are fully functional again
Due to some adhoc changes in the library system we adjusted the app to handle them properly.

View File

@@ -13,15 +13,34 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<Point | Polygon, SupplementaryGeoJSONThing>;
export type SupplementaryGeoJSONThing = Pick<
Extract<SCThings, SCPlace>,
Exclude<
keyof SCPlaceWithoutReferences | keyof SCThingWithCategoriesWithoutReferences<never, never>,
'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<SCSearchResponse>;
/**
* Get geo info for display on a map
*/
geo(): Promise<SupplementaryGeoJSON>;
}

View File

@@ -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<SupplementaryGeoJSON> {
const searchResponse = await this.client.search<Extract<SCThings, {geo: unknown}>>({
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<Polygon | Point, SupplementaryGeoJSONThing>)
: undefined;
})
.filter(noUndefined),
};
}
}

View File

@@ -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'>,
],
},
};
}

View File

@@ -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<Express> {
* An elasticsearch mock
*/
export class ElasticsearchMock implements Database {
// @ts-expect-error never read
private bulk: Bulk | undefined;
private storageMock = new Map<string, SCThings>();
@@ -67,6 +66,10 @@ export class ElasticsearchMock implements Database {
// Nothing to do here
}
geo(): Promise<SupplementaryGeoJSON> {
throw new Error('Method not implemented.');
}
bulkCreated(bulk: Bulk): Promise<void> {
this.bulk = bulk;
return Promise.resolve(undefined);

View File

@@ -44,7 +44,6 @@ describe('Search route', async function () {
});
it('should reject GET, PUT with a valid search query', async function () {
// const expectedParams = JSON.parse(JSON.stringify(defaultParams));
const {status} = await testApp.get('/search').set('Accept', 'application/json').send({
query: 'Some search terms',
});

View File

@@ -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,
},
},
],
},
};

View File

@@ -19,15 +19,21 @@
"allowSyntheticDefaultImports": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"outDir": "../../../lib/",
"lib": ["ES2022", "DOM"],
"lib": [
"ES2022",
"DOM"
],
"strict": true,
"target": "ES2022"
},
"ts-node": {
"transpileOnly": true
},
"exclude": ["../../../app.js", "../../../lib/"]
"exclude": [
"../../../app.js",
"../../../lib/"
]
}

12
flake.lock generated
View File

@@ -5,11 +5,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1701680307,
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1701626906,
"narHash": "sha256-ugr1QyzzwNk505ICE4VMQzonHQ9QS5W33xF2FXzFQ00=",
"lastModified": 1717112898,
"narHash": "sha256-7R2ZvOnvd9h8fDd65p0JnB7wXfUvreox3xFdYWd1BnY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0c6d8c783336a59f4c59d4a6daed6ab269c4b361",
"rev": "6132b0f6e344ce2fe34fc051b72fb46e34f668e0",
"type": "github"
},
"original": {

145
flake.nix
View File

@@ -4,72 +4,85 @@
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = {
self,
nixpkgs,
flake-utils,
}: let
aapt2buildToolsVersion = "33.0.2";
in
flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs {
inherit system;
overlays = [
(final: prev: rec {
fontMin = prev.python311.withPackages (ps: with ps; [brotli fonttools] ++ (with fonttools.optional-dependencies; [woff]));
android = prev.androidenv.composeAndroidPackages {
buildToolsVersions = ["30.0.3" aapt2buildToolsVersion];
platformVersions = ["33"];
};
cypress = prev.cypress.overrideAttrs (cyPrev: rec {
version = "13.2.0";
src = prev.fetchzip {
url = "https://cdn.cypress.io/desktop/${version}/linux-x64/cypress.zip";
hash = "sha256-9o0nprGcJhudS1LNm+T7Vf0Dwd1RBauYKI+w1FBQ3ZM=";
outputs =
{
self,
nixpkgs,
flake-utils,
}:
let
aapt2buildToolsVersion = "33.0.2";
in
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [
(final: prev: rec {
fontMin = prev.python311.withPackages (
ps:
with ps;
[
brotli
fonttools
]
++ (with fonttools.optional-dependencies; [ woff ])
);
android = prev.androidenv.composeAndroidPackages {
buildToolsVersions = [
"30.0.3"
aapt2buildToolsVersion
];
platformVersions = [ "33" ];
};
});
})
];
config = {
allowUnfree = true;
android_sdk.accept_license = true;
cypress = prev.cypress.overrideAttrs (cyPrev: rec {
version = "13.2.0";
src = prev.fetchzip {
url = "https://cdn.cypress.io/desktop/${version}/linux-x64/cypress.zip";
hash = "sha256-9o0nprGcJhudS1LNm+T7Vf0Dwd1RBauYKI+w1FBQ3ZM=";
};
});
nodejs = prev.nodejs_22;
})
];
config = {
allowUnfree = true;
android_sdk.accept_license = true;
};
};
};
androidFhs = pkgs.buildFHSUserEnv {
name = "android-env";
targetPkgs = pkgs: with pkgs; [];
runScript = "bash";
profile = ''
export ALLOW_NINJA_ENV=true
export USE_CCACHE=1
export LD_LIBRARY_PATH=/usr/lib:/usr/lib32
'';
};
in {
devShell = pkgs.mkShell rec {
nativeBuildInputs = [androidFhs];
buildInputs = with pkgs; [
nodejs-18_x
nodePackages.pnpm
# 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";
GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_SDK_ROOT}/build-tools/${aapt2buildToolsVersion}/aapt2";
CYPRESS_INSTALL_BINARY = "0";
CYPRESS_RUN_BINARY = "${pkgs.cypress}/bin/Cypress";
};
});
androidFhs = pkgs.buildFHSUserEnv {
name = "android-env";
targetPkgs = pkgs: with pkgs; [ ];
runScript = "bash";
profile = ''
export ALLOW_NINJA_ENV=true
export USE_CCACHE=1
export LD_LIBRARY_PATH=/usr/lib:/usr/lib32
'';
};
in
{
devShell = pkgs.mkShell rec {
nativeBuildInputs = [ androidFhs ];
buildInputs = with pkgs; [
nodejs
corepack
# tools
curl
jq
fontMin
cypress
# android
jdk17
android.androidsdk
];
ANDROID_JAVA_HOME = "${pkgs.jdk.home}";
ANDROID_SDK_ROOT = "${pkgs.android.androidsdk}/libexec/android-sdk";
GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_SDK_ROOT}/build-tools/${aapt2buildToolsVersion}/aapt2";
CYPRESS_INSTALL_BINARY = "0";
CYPRESS_RUN_BINARY = "${pkgs.cypress}/bin/Cypress";
};
}
);
}

View File

@@ -50,11 +50,11 @@ the config file.
Icon font minification is done automatically, but requires you to
follow a few simple rules:
1. Use the tagged template literal for referencing icon names in
1. Use the type-safe proxy for referencing icon names in
TypeScript files and code
```ts
SCIcon`icon_name`;
SCIcon.icon_name;
```
2. When using `ion-icon` in HTML, reference either icons that went through

View File

@@ -21,10 +21,13 @@
"allowedCommonJsDependencies": [
"moment",
"opening_hours",
"leaflet",
"leaflet.markercluster",
"localforge",
"guid-typescript"
"localforage",
"i18next",
"semver",
"suncalc",
"guid-typescript",
"fast-deep-equal",
"maplibre-gl"
],
"aot": true,
"assets": [
@@ -32,24 +35,13 @@
"glob": "**/*",
"input": "src/assets",
"output": "assets"
},
{
"glob": "**/*",
"input": "./node_modules/leaflet/dist/images",
"output": "assets/"
}
],
"styles": [
{
"input": "src/theme/variables.scss",
"inject": true
},
{
"input": "src/global.scss",
"inject": true
},
"./node_modules/leaflet/dist/leaflet.css",
"./node_modules/leaflet.markercluster/dist/MarkerCluster.Default.css"
}
]
},
"configurations": {
@@ -132,11 +124,6 @@
"glob": "**/*",
"input": "src/assets",
"output": "/assets"
},
{
"glob": "**/*",
"input": "./node_modules/leaflet/dist/images",
"output": "assets/"
}
]
}

View File

@@ -12,9 +12,9 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type {IconConfig} from './scripts/icon-config';
const config: IconConfig = {
/** @type {import('./scripts/icon-config').IconConfig} */
const config = {
inputPath: 'node_modules/material-symbols/material-symbols-rounded.woff2',
outputPath: 'src/assets/icons.min.woff2',
htmlGlob: 'src/**/*.html',
@@ -38,10 +38,6 @@ const config: IconConfig = {
'work',
],
},
codePoints: {
ios_share: 'e6b8',
fact_check: 'f0c5',
},
};
export default config;

View File

@@ -21,7 +21,7 @@
"build:prod": "ng build --configuration=production",
"build:stats": "ng build --configuration=production --stats-json",
"changelog": "conventional-changelog -p angular -i src/assets/about/CHANGELOG.md -s -r 0",
"check-icons": "ts-node scripts/check-icon-correctness.ts",
"check-icons": "node scripts/check-icon-correctness.mjs",
"chromium:no-cors": "chromium --disable-web-security --user-data-dir=\".browser-data/chromium\"",
"chromium:virtual-host": "chromium --host-resolver-rules=\"MAP mobile.app.uni-frankfurt.de:* localhost:8100\" --ignore-certificate-errors",
"cypress:open": "cypress open",
@@ -35,10 +35,10 @@
"e2e": "ng e2e",
"format": "prettier . -c",
"format:fix": "prettier --write .",
"licenses": "license-checker --json > src/assets/about/licenses.json && ts-node ./scripts/accumulate-licenses.ts && git add src/assets/about/licenses.json",
"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\"",
"minify-icons": "ts-node-esm scripts/minify-icon-font.ts",
"minify-icons": "node scripts/minify-icon-font.mjs",
"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\"",
@@ -59,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",
@@ -81,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",
@@ -97,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",
@@ -147,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",
@@ -175,7 +172,7 @@
"karma-junit-reporter": "2.0.1",
"karma-mocha-reporter": "2.2.5",
"license-checker": "25.0.1",
"stylelint": "16.2.1",
"stylelint": "16.3.1",
"stylelint-config-clean-order": "5.4.1",
"stylelint-config-prettier-scss": "1.0.0",
"stylelint-config-recommended-scss": "14.0.0",

View File

@@ -12,33 +12,34 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import fs from 'fs';
import {omit} from '../src/app/_helpers/collections/omit';
import {pickBy} from '../src/app/_helpers/collections/pick';
import {readFileSync, writeFileSync} from 'fs';
import {omit, pickBy} from '@openstapps/collection-utils';
/**
* accumulate and transform licenses based on two license files
* @param {string} path
* @param {string} additionalLicensesPath
*/
function accumulateFile(path: string, additionalLicensesPath: string) {
const packageJson = JSON.parse(fs.readFileSync('./package.json').toString());
function accumulateFile(path, additionalLicensesPath) {
const packageJson = JSON.parse(readFileSync('./package.json').toString());
const dependencies = packageJson.dependencies;
console.log(`Accumulating licenses from ${path}`);
fs.writeFileSync(
writeFileSync(
path,
JSON.stringify(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Object.entries<any>({
...pickBy(JSON.parse(fs.readFileSync(path).toString()), (_, key: string) => {
const parts = key.split('@');
Object.entries({
...pickBy(JSON.parse(readFileSync(path).toString()), (_, key) => {
const parts = /** @type {string} */ (key).split('@');
return dependencies[parts.slice(0, -1).join('@')] === parts[parts.length - 1];
}),
...JSON.parse(fs.readFileSync(additionalLicensesPath).toString()),
...JSON.parse(readFileSync(additionalLicensesPath).toString()),
})
.map(([key, value]) => ({
licenseText: value.licenseFile && fs.readFileSync(value.licenseFile, 'utf8'),
licenseText: value.licenseFile && readFileSync(value.licenseFile, 'utf8'),
name: key,
...omit(value, 'licenseFile', 'path'),
}))

View File

@@ -12,18 +12,20 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import fontkit, {Font} from 'fontkit';
import config from '../icons.config';
import {openSync} from 'fontkit';
import config from '../icons.config.mjs';
import {existsSync} from 'fs';
import {getUsedIconsHtml, getUsedIconsTS} from './gather-used-icons';
import {getUsedIconsHtml, getUsedIconsTS} from './gather-used-icons.mjs';
import {fetchCodePointMap} from './get-code-points.mjs';
const commandName = '"npm run minify-icons"';
const originalFont = fontkit.openSync(config.inputPath);
if (!existsSync(config.outputPath)) {
console.error(`Minified font not found. Run ${commandName} first.`);
process.exit(-1);
}
const modifiedFont = fontkit.openSync(config.outputPath);
/** @type {import('fontkit').Font} */
const modifiedFont = openSync(config.outputPath);
let success = true;
@@ -48,25 +50,16 @@ async function checkAll() {
}
/**
*
* @param {Record<string, string[]>} icons
*/
function check(icons: Record<string, string[]>) {
for (const [purpose, iconSet] of Object.entries(icons)) {
for (const icon of iconSet) {
if (!hasIcon(originalFont, icon)) {
success = false;
console.error(`${purpose}: ${icon} does not exist. Typo?`);
} else if (!hasIcon(modifiedFont, icon)) {
success = false;
console.error(`${purpose}: ${icon} not found in minified font. Run ${commandName} to regenerate it.`);
}
async function check(icons) {
const codePoints = await fetchCodePointMap();
for (const icon of Object.values(icons).flat()) {
const codePoint = codePoints.get(icon);
if (!codePoint) throw new Error(`"${icon}" is not a valid icon`);
if (!modifiedFont.getGlyph(Number.parseInt(codePoint, 16))) {
throw new Error(`"${icon}" (code point ${codePoint}) is missing`);
}
}
}
/**
*
*/
function hasIcon(font: Font, icon: string) {
return font.layout(icon).glyphs.some(it => it.isLigature);
}

View File

@@ -14,34 +14,39 @@
*/
import {glob} from 'glob';
import {readFileSync} from 'fs';
import {matchPropertyContent, matchTagProperties} from '../src/app/util/ion-icon/icon-match';
import {
matchPropertyAccess,
matchPropertyContent,
matchTagProperties,
} from '../src/app/util/ion-icon/icon-match.mjs';
/**
*
* @returns {Promise<Record<string, string[]>>}
*/
export async function getUsedIconsHtml(pattern = 'src/**/*.html'): Promise<Record<string, string[]>> {
export async function getUsedIconsHtml(pattern = 'src/**/*.html') {
return Object.fromEntries(
(await glob(pattern))
.map(file => [
file,
(readFileSync(file, 'utf8')
readFileSync(file, 'utf8')
.match(matchTagProperties('ion-icon'))
?.flatMap(match => {
return match.match(matchPropertyContent(['name', 'md', 'ios']));
})
.filter(it => !!it) as string[]) || [],
.filter(it => !!it) || [],
])
.filter(([, values]) => values.length > 0),
.filter(([, values]) => values && values.length > 0),
);
}
/**
*
* @returns {Promise<Record<string, string[]>>}
*/
export async function getUsedIconsTS(pattern = 'src/**/*.ts'): Promise<Record<string, string[]>> {
export async function getUsedIconsTS(pattern = 'src/**/*.ts') {
const regex = matchPropertyAccess('SCIcon');
return Object.fromEntries(
(await glob(pattern))
.map(file => [file, readFileSync(file, 'utf8').match(/(?<=Icon`)[\w-]+(?=`)/g) || []])
.filter(([, values]) => values.length > 0),
.map(file => [file, readFileSync(file, 'utf8').match(regex) || []])
.filter(([, values]) => values && values.length > 0),
);
}

View File

@@ -0,0 +1,23 @@
const url =
'https://raw.githubusercontent.com/google/material-design-icons/master/' +
'variablefont/MaterialSymbolsRounded%5BFILL%2CGRAD%2Copsz%2Cwght%5D.codepoints';
export async function fetchCodePointMap() {
const icons = await fetch(url)
.then(it => it.text())
.then(it => new Map(it.split('\n').map(it => /** @type {[string, string]} */ (it.split(' ')))));
if (icons.size < 100) throw new Error(`Code point map is very small, is the URL incorrect? ${url}`);
return icons;
}
/**
* @param {string[]} icons
*/
export async function getCodePoints(icons) {
const codePoints = await fetchCodePointMap();
return icons.map(icon => {
const code = codePoints.get(icon);
if (!code) throw new Error(`Code point for icon ${icon} not found`);
return code;
});
}

View File

@@ -19,5 +19,4 @@ export interface IconConfig {
inputPath: string;
outputPath: string;
additionalIcons?: {[purpose: string]: string[]};
codePoints?: {[name: string]: string};
}

View File

@@ -14,16 +14,17 @@
*/
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import fontkit from 'fontkit';
import {exec} from 'child_process';
import config from '../icons.config';
import config from '../icons.config.mjs';
import {statSync} from 'fs';
import {getUsedIconsHtml, getUsedIconsTS} from './gather-used-icons';
import {getUsedIconsHtml, getUsedIconsTS} from './gather-used-icons.mjs';
import {getCodePoints} from './get-code-points.mjs';
/**
*
* @param {string[] | string} command
* @returns {Promise<string>}
*/
async function run(command: string[] | string): Promise<string> {
async function run(command) {
const fullCommand = Array.isArray(command) ? command.join(' ') : command;
console.log(`>> ${fullCommand}`);
@@ -44,7 +45,8 @@ async function run(command: string[] | string): Promise<string> {
*
*/
async function minifyIconFont() {
const icons = new Set<string>();
/** @type {Set<string>} */
const icons = new Set();
for (const iconSet of [
...Object.values(config.additionalIcons || []),
@@ -56,35 +58,7 @@ async function minifyIconFont() {
}
}
console.log('Icons used:', [...icons.values()].sort());
const font = fontkit.openSync(config.inputPath);
const glyphs: string[] = ['5f-7a', '30-39'];
for (const icon of icons) {
const iconGlyphs = font.layout(icon).glyphs;
if (iconGlyphs.length === 0) {
console.error(`${icon} not found in font. Typo?`);
process.exit(-1);
}
const codePoints = iconGlyphs
.flatMap(it => font.stringsForGlyph(it.id))
.flatMap(it => [...it])
.map(it => it.codePointAt(0)!.toString(16));
if (codePoints.length === 0) {
if (config.codePoints?.[icon]) {
glyphs.push(config.codePoints[icon]);
} else {
console.log();
console.error(`${icon} code point could not be determined. Add it to config.codePoints.`);
process.exit(-1);
}
}
glyphs.push(...codePoints);
}
glyphs.sort();
const glyphs = ['5f-7a', '30-39', ...(await getCodePoints([...icons]))].sort();
console.log(
await run([
@@ -114,8 +88,10 @@ minifyIconFont();
/**
* Bytes to respective units
* @param {number} value
* @returns {string}
*/
function toByteUnit(value: number): string {
function toByteUnit(value) {
if (value < 1024) {
return `${value}B`;
} else if (value < 1024 * 1024) {

View File

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

View File

@@ -49,7 +49,7 @@
@if (content.type === 'router link') {
<ion-item [routerLink]="content.link">
@if (content.icon) {
<ion-icon [name]="content.icon" slot="start"></ion-icon>
<ion-icon [name]="$any(content.icon)" slot="start"></ion-icon>
}
<ion-label>{{ 'title' | translateSimple: content }}</ion-label>
</ion-item>

View File

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

View File

@@ -15,7 +15,6 @@
ion-segment-button {
max-width: 100%;
text-transform: none;
}
.margin-top {

View File

@@ -1,27 +0,0 @@
/*
* Copyright (C) 2019 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {NgModule} from '@angular/core';
import {DataModule} from '../data/data.module';
import {StorageModule} from '../storage/storage.module';
import {ConfigProvider} from './config.provider';
/**
* TODO
*/
@NgModule({
imports: [StorageModule, DataModule],
providers: [ConfigProvider],
})
export class ConfigModule {}

View File

@@ -104,9 +104,9 @@ describe('ConfigProvider', () => {
it('should throw error on wrong config version in storage', async () => {
storageProviderSpy.has.and.returnValue(Promise.resolve(true));
const wrongConfig = JSON.parse(JSON.stringify(sampleIndexResponse));
const wrongConfig = structuredClone(sampleIndexResponse);
wrongConfig.backend.SCVersion = '0.1.0';
storageProviderSpy.get.and.returnValue(wrongConfig);
storageProviderSpy.get.and.returnValue(Promise.resolve(wrongConfig));
spyOn(configProvider.client, 'handshake').and.returnValue(Promise.resolve(sampleIndexResponse));
await configProvider.init();

View File

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

View File

@@ -26,6 +26,7 @@ import {AddEventStates, AddEventStatesMap} from './add-event-action-chip.config'
import {EditEventSelectionComponent} from '../edit-event-selection.component';
import {AddEventReviewModalComponent} from '../../../calendar/add-event-review-modal.component';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {MaterialSymbol} from 'material-symbols';
/**
* Shows a horizontal list of action chips
@@ -55,7 +56,7 @@ export class AddEventActionChipComponent {
/**
* Icon
*/
icon: string;
icon: MaterialSymbol;
/**
* Current state of icon fill

View File

@@ -24,28 +24,28 @@ export enum AddEventStates {
export const AddEventStatesMap = {
[AddEventStates.ADDED_ALL]: {
icon: SCIcon`event_available`,
icon: SCIcon.event_available,
fill: true,
label: 'data.chips.add_events.ADDED_ALL',
disabled: false,
color: 'success',
},
[AddEventStates.ADDED_SOME]: {
icon: SCIcon`event`,
icon: SCIcon.event,
fill: true,
label: 'data.chips.add_events.ADDED_SOME',
disabled: false,
color: 'success',
},
[AddEventStates.REMOVED_ALL]: {
icon: SCIcon`calendar_today`,
icon: SCIcon.calendar_today,
fill: false,
label: 'data.chips.add_events.REMOVED_ALL',
disabled: false,
color: 'primary',
},
[AddEventStates.UNAVAILABLE]: {
icon: SCIcon`event_busy`,
icon: SCIcon.event_busy,
fill: false,
label: 'data.chips.add_events.UNAVAILABLE',
disabled: true,

View File

@@ -14,37 +14,38 @@
*/
import {SCThingType} from '@openstapps/core';
import {SCIcon} from '../../util/ion-icon/icon';
import {MaterialSymbol} from 'material-symbols';
export const DataIcons: Record<SCThingType, string> = {
'academic event': SCIcon`school`,
'assessment': SCIcon`fact_check`,
'article': SCIcon`article`,
'book': SCIcon`book`,
'building': SCIcon`location_city`,
'certification': SCIcon`contract`,
'catalog': SCIcon`inventory_2`,
'contact point': SCIcon`contact_page`,
'course of study': SCIcon`school`,
'date series': SCIcon`event`,
'dish': SCIcon`lunch_dining`,
'favorite': SCIcon`favorite`,
'floor': SCIcon`foundation`,
'id card': SCIcon`badge`,
'message': SCIcon`newspaper`,
'organization': SCIcon`business_center`,
'periodical': SCIcon`feed`,
'person': SCIcon`person`,
'point of interest': SCIcon`pin_drop`,
'publication event': SCIcon`campaign`,
'room': SCIcon`meeting_room`,
'semester': SCIcon`date_range`,
'setting': SCIcon`settings`,
'sport course': SCIcon`sports_soccer`,
'study module': SCIcon`view_module`,
'ticket': SCIcon`confirmation_number`,
'todo': SCIcon`task`,
'tour': SCIcon`tour`,
'video': SCIcon`movie`,
'diff': SCIcon`difference`,
'job posting': SCIcon`work`,
};
export const DataIcons = {
'academic event': SCIcon.school,
'assessment': SCIcon.fact_check,
'article': SCIcon.article,
'book': SCIcon.book,
'building': SCIcon.location_city,
'certification': SCIcon.contract,
'catalog': SCIcon.inventory_2,
'contact point': SCIcon.contact_page,
'course of study': SCIcon.school,
'date series': SCIcon.event,
'dish': SCIcon.lunch_dining,
'favorite': SCIcon.favorite,
'floor': SCIcon.foundation,
'id card': SCIcon.badge,
'message': SCIcon.newspaper,
'organization': SCIcon.business_center,
'periodical': SCIcon.feed,
'person': SCIcon.person,
'point of interest': SCIcon.pin_drop,
'publication event': SCIcon.campaign,
'room': SCIcon.meeting_room,
'semester': SCIcon.date_range,
'setting': SCIcon.settings,
'sport course': SCIcon.sports_soccer,
'study module': SCIcon.view_module,
'ticket': SCIcon.confirmation_number,
'todo': SCIcon.task,
'tour': SCIcon.tour,
'video': SCIcon.movie,
'diff': SCIcon.difference,
'job posting': SCIcon.work,
} satisfies Record<SCThingType, MaterialSymbol>;

View File

@@ -31,7 +31,7 @@ export class DataIconPipe implements PipeTransform {
/**
* Provide the icon name from the data type
*/
transform(type: SCThingType): string {
transform(type: SCThingType) {
return this.typeIconMap[type];
}
}

View File

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

View File

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

View File

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

View File

@@ -13,10 +13,11 @@
~ this program. If not, see <https://www.gnu.org/licenses/>.
-->
<ion-button (click)="toggle($event)" color="medium" size="small" fill="clear">
<ion-button (click)="toggle($event)" fill="clear" shape="round">
<ion-icon
slot="icon-only"
[fill]="(isFavorite$ | async) ?? false"
[size]="24"
[fill]="(isFavorite$ | async) || false"
[class.selected]="isFavorite$ | async"
name="grade"
></ion-icon>

View File

@@ -13,22 +13,25 @@
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
:host {
ion-button {
--border-radius: 50%;
ion-button {
--background-activated: currentcolor;
--background-hover: currentcolor;
--background-focused: currentcolor;
width: 50px;
height: 50px;
}
aspect-ratio: 1;
color: inherit;
}
.selected {
// TODO
color: #fbc02d;
}
.selected {
color: #fbc02d;
}
@media (hover: hover) {
ion-button:hover ::ng-deep stapps-icon {
--fill: 1;
}
@media (hover: hover) {
ion-button:hover ::ng-deep stapps-icon {
--fill: 1 !important;
}
}
::ng-deep ion-item stapps-favorite-button ion-button {
color: var(--ion-color-medium) !important;
}

View File

@@ -12,7 +12,7 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Component, Input} from '@angular/core';
import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
import {SCIndexableThings} from '@openstapps/core';
import {FavoritesService} from '../../favorites/favorites.service';
import {Observable} from 'rxjs';
@@ -25,6 +25,7 @@ import {map, take} from 'rxjs/operators';
selector: 'stapps-favorite-button',
templateUrl: './favorite-button.component.html',
styleUrls: ['./favorite-button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FavoriteButtonComponent {
/**

View File

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

View File

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

View File

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

View File

@@ -18,8 +18,8 @@ import {SCThings} from '@openstapps/core';
import {SCIcon} from '../../../util/ion-icon/icon';
const AccordionButtonState = {
collapsed: SCIcon`expand_more`,
expanded: SCIcon`expand_less`,
collapsed: SCIcon.expand_more,
expanded: SCIcon.expand_less,
};
@Component({
@@ -35,7 +35,8 @@ export class TitleCardComponent implements OnInit, OnChanges {
@ViewChild('accordionTextArea') accordionTextArea: ElementRef;
buttonState = AccordionButtonState.collapsed;
buttonState: (typeof AccordionButtonState)[keyof typeof AccordionButtonState] =
AccordionButtonState.collapsed;
buttonShown = true;

View File

@@ -38,7 +38,6 @@ ion-toolbar:first-of-type {
ion-button {
width: 50%;
margin: 0;
text-transform: none;
}
}
}

View File

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

View File

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

View File

@@ -12,7 +12,6 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import moment, {Moment} from 'moment';
import {AfterViewInit, Component, DestroyRef, inject, Input} from '@angular/core';
import {SCDish, SCISO8601Date, SCPlace} from '@openstapps/core';
import {PlaceMensaService} from './place-mensa-service';
@@ -53,11 +52,6 @@ export class PlaceMensaDetailComponent implements AfterViewInit {
*/
selectedDay: string;
/**
* First day to display menu items for
*/
startingDay: Moment;
destroy$ = inject(DestroyRef);
constructor(
@@ -65,9 +59,7 @@ export class PlaceMensaDetailComponent implements AfterViewInit {
protected router: Router,
readonly routerOutlet: IonRouterOutlet,
private readonly dataRoutingService: DataRoutingService,
) {
this.startingDay = moment().startOf('day');
}
) {}
ngAfterViewInit() {
if (!this.openAsModal) {

View File

@@ -13,10 +13,6 @@
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
ion-segment-button {
text-transform: none;
}
ion-segment {
overflow: auto;
justify-content: space-between;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,109 @@
/*
* Copyright (C) 2024 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AnimationBuilder} from '@ionic/angular';
import {AnimationController} from '@ionic/angular';
import type {AnimationOptions} from '@ionic/angular/common/providers/nav-controller';
import {iosDuration, iosEasing, mdDuration, mdEasing} from 'src/app/animation/easings';
/**
* Get the center of an element
*/
function center(element: HTMLElement): {x: number; y: number} {
const bounds = element.getBoundingClientRect();
return {
x: bounds.left + bounds.width / 2,
y: bounds.top + bounds.height / 2,
};
}
/**
* Get the flip transform for an element
*/
function flipTransform(from: HTMLElement, to: HTMLElement): string {
const fromCenter = center(from);
const toCenter = center(to);
const dx = fromCenter.x - toCenter.x;
const dy = fromCenter.y - toCenter.y;
return `translate(${dx}px, ${dy}px)`;
}
/**
* Get the flip clip path for an element
*/
function flipClipPath(from: HTMLElement, to: HTMLElement): string {
const fromBounds = from.getBoundingClientRect();
const toBounds = to.getBoundingClientRect();
const y = Math.max(0, (toBounds.height - fromBounds.height) / 2);
const x = Math.max(0, (toBounds.width - fromBounds.width) / 2);
return `inset(${y}px ${x}px)`;
}
/**
* Animation of the map maximize
*/
export function mapMaximizeAnimation(animationController: AnimationController): AnimationBuilder {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (_baseElement: HTMLElement, options: AnimationOptions | any) => {
const rootTransition = animationController
.create()
.duration(options.duration ?? (options.mode === 'ios' ? iosDuration * 1.5 : mdDuration * 2.5))
.easing(options.mode === 'ios' ? iosEasing : mdEasing);
const enteringMap = options.enteringEl.querySelector('mgl-map') as HTMLElement;
const leavingMap = options.leavingEl.querySelector('mgl-map') as HTMLElement;
if (!enteringMap.classList.contains('ready')) {
rootTransition.delay(2000);
enteringMap.addEventListener(
'ready',
event => {
event.preventDefault();
setTimeout(() => {
if (rootTransition.isRunning()) {
rootTransition.stop();
rootTransition.delay(0);
rootTransition.play();
}
});
},
{once: true},
);
}
const mapEnterTransition = animationController
.create()
.fromTo('transform', flipTransform(leavingMap, enteringMap), 'translate(0, 0)')
.fromTo('clipPath', flipClipPath(leavingMap, enteringMap), 'inset(0px 0px)')
.addElement(enteringMap);
const mapExitTransition = animationController
.create()
.fromTo('transform', 'translate(0, 0)', flipTransform(enteringMap, leavingMap))
.fromTo('clipPath', 'inset(0px 0px)', flipClipPath(enteringMap, leavingMap))
.addElement(leavingMap);
const enterTransition = animationController
.create()
.fromTo('opacity', options.direction === 'back' ? '1' : '0', '1')
.addElement(options.enteringEl);
const exitTransition = animationController
.create()
.fromTo('opacity', '1', options.direction === 'back' ? '0' : '1')
.addElement(options.leavingEl);
rootTransition.addAnimation([enterTransition, exitTransition, mapEnterTransition, mapExitTransition]);
return rootTransition;
};
}

View File

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

View File

@@ -0,0 +1,50 @@
<!--
~ Copyright (C) 2023 StApps
~ This program is free software: you can redistribute it and/or modify it
~ under the terms of the GNU General Public License as published by the Free
~ Software Foundation, version 3.
~
~ This program is distributed in the hope that it will be useful, but WITHOUT
~ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
~ FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
~ more details.
~
~ You should have received a copy of the GNU General Public License along with
~ this program. If not, see <https://www.gnu.org/licenses/>.
-->
<stapps-search-page
[showDefaultData]="true"
[title]="'map.page.TITLE' | translate"
[placeholder]="'map.page.search_bar.placeholder' | translate"
[forcedFilter]="forcedFilter"
[navigation]="[]"
></stapps-search-page>
<ion-content id="map">
<mgl-map
[styleName]="('(prefers-color-scheme: dark)' | mediaQuery | async) ? 'dark' : 'light'"
[antialias]="true"
[attributionControl]="false"
[fitBounds]="(bounds | async) ?? undefined"
[fitBoundsOptions]="{maxZoom: 15, padding: {top: 100, bottom: 50, left: 50, right: 50}, animate: false}"
(move)="mapMove($event)"
auto-3d
>
@if (dataProvider.current | async; as result) {
<stapps-building-markers [data]="result.data | thingPolygonFeatureCollection"></stapps-building-markers>
<stapps-poi-markers [data]="result.data | thingPoiFeatureCollection"></stapps-poi-markers>
}
<mgl-control position="bottom-left" mglScale></mgl-control>
<mgl-control position="top-left">
<stapps-map-attribution></stapps-map-attribution>
</mgl-control>
<mgl-control position="top-right">
<stapps-compass-control></stapps-compass-control>
</mgl-control>
<stapps-geolocate-control
position="bottom-right"
[trackUserLocation]="true"
[showUserLocation]="true"
></stapps-geolocate-control>
</mgl-map>
</ion-content>

View File

@@ -0,0 +1,101 @@
/*!
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
ion-content {
position: fixed;
transition: opacity 0.2s ease;
}
stapps-search-page {
::ng-deep ion-content {
transform: translateY(48px);
transition: transform 0.2s ease;
}
::ng-deep ion-back-button {
transform: translateX(24px);
display: block;
opacity: 0;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
}
stapps-search-page:has(::ng-deep .searchbar-has-focus),
stapps-search-page:has(::ng-deep ion-content:focus-within) {
+ ion-content {
pointer-events: none;
opacity: 0;
}
::ng-deep ion-content {
transform: translateY(0);
}
::ng-deep ion-back-button {
pointer-events: none;
transform: translateX(0);
opacity: 1;
}
}
:host.can-go-back stapps-search-page ::ng-deep ion-back-button {
transform: translateX(0);
opacity: 1;
}
mgl-map {
width: 100%;
height: 100%;
}
ion-toolbar:first-of-type {
padding: 0 var(--spacing-md) var(--spacing-xs);
}
.filter-options {
overflow: hidden;
display: flex;
flex-direction: row;
border-radius: var(--border-radius-default);
label {
cursor: pointer;
user-select: none;
padding: var(--spacing-xs) var(--spacing-md);
font-family: inherit;
color: var(--ion-item-color);
background-color: var(--ion-item-background);
transition: all 0.2s ease;
input[type='radio'] {
display: none;
}
&:has(:checked) {
color: var(--ion-color-primary-contrast);
background-color: var(--ion-color-primary);
}
}
}
:host ::ng-deep .maplibregl-ctrl-top-left,
:host ::ng-deep .maplibregl-ctrl-top-right {
top: 92px;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,67 +12,14 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {RouterModule, Routes} from '@angular/router';
import {LeafletModule} from '@asymmetrik/ngx-leaflet';
import {LeafletMarkerClusterModule} from '@asymmetrik/ngx-leaflet-markercluster';
import {IonicModule} from '@ionic/angular';
import {TranslateModule} from '@ngx-translate/core';
import {Polygon} from 'geojson';
import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {ConfigProvider} from '../config/config.provider';
import {DataFacetsProvider} from '../data/data-facets.provider';
import {DataModule} from '../data/data.module';
import {DataProvider} from '../data/data.provider';
import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider';
import {MenuModule} from '../menu/menu.module';
import {MapProvider} from './map.provider';
import {MapPageComponent} from './page/map-page.component';
import {MapListModalComponent} from './page/map-list-modal.component';
import {RouterModule} from '@angular/router';
import {MapPageComponent} from './map-page.component';
import {NgModule} from '@angular/core';
import {UtilModule} from '../../util/util.module';
import {IonIconModule} from '../../util/ion-icon/ion-icon.module';
import {GeoNavigationDirective} from './geo-navigation.directive';
/**
* Initializes the default area to show in advance (before components are initialized)
* @param configProvider An instance of the ConfigProvider to read the campus polygon from
* @param mapProvider An instance of the MapProvider to set the default polygon (area to show on the map)
*/
export function initMapConfigFactory(configProvider: ConfigProvider, mapProvider: MapProvider) {
return async () => {
mapProvider.defaultPolygon = (await configProvider.getValue('campusPolygon')) as Polygon;
};
}
const mapRoutes: Routes = [
{path: 'map', component: MapPageComponent},
{path: 'map/:uid', component: MapPageComponent},
];
/**
* Module containing map related stuff
*/
@NgModule({
declarations: [MapPageComponent, MapListModalComponent],
exports: [],
imports: [
CommonModule,
IonicModule.forRoot(),
LeafletModule,
IonIconModule,
LeafletMarkerClusterModule,
RouterModule.forChild(mapRoutes),
TranslateModule.forChild(),
MenuModule,
DataModule,
FormsModule,
ThingTranslateModule,
UtilModule,
GeoNavigationDirective,
GeoNavigationDirective,
],
providers: [Geolocation, MapProvider, DataProvider, DataFacetsProvider, StAppsWebHttpClient],
imports: [RouterModule.forChild([{path: 'map', component: MapPageComponent}])],
})
export class MapModule {}

View File

@@ -1,60 +0,0 @@
/*
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {TestBed} from '@angular/core/testing';
import {MapProvider} from './map.provider';
import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider';
import {HttpClientModule} from '@angular/common/http';
import {StorageProvider} from '../storage/storage.provider';
import {MapModule} from './map.module';
import {StorageModule} from '../storage/storage.module';
import {LoggerModule, NGXLogger, NgxLoggerLevel} from 'ngx-logger';
import {ConfigProvider} from '../config/config.provider';
import {sampleDefaultPolygon} from '../../_helpers/data/sample-configuration';
import {RouterModule} from '@angular/router';
describe('MapProvider', () => {
let provider: MapProvider;
let configProvider: jasmine.SpyObj<ConfigProvider>;
beforeEach(() => {
configProvider = jasmine.createSpyObj('ConfigProvider', ['getValue']);
TestBed.configureTestingModule({
imports: [
MapModule,
HttpClientModule,
StorageModule,
LoggerModule.forRoot({level: NgxLoggerLevel.TRACE}),
RouterModule.forRoot([]),
],
providers: [
{
provide: ConfigProvider,
useValue: configProvider,
},
StAppsWebHttpClient,
StorageProvider,
NGXLogger,
],
});
configProvider.getValue.and.returnValue(sampleDefaultPolygon);
provider = TestBed.inject(MapProvider);
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
});

View File

@@ -1,237 +0,0 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {ElementRef, Injectable} from '@angular/core';
import {
SCBuilding,
SCSearchFilter,
SCSearchQuery,
SCSearchResponse,
SCThingType,
SCUuid,
} from '@openstapps/core';
import {Point, Polygon} from 'geojson';
import {divIcon, geoJSON, LatLng, Map, marker, Marker} from 'leaflet';
import {DataProvider} from '../data/data.provider';
import {MapPosition, PositionService} from './position.service';
import {hasValidLocation} from '../data/types/place/place-types';
import {ConfigProvider} from '../config/config.provider';
import {SCIcon} from '../../util/ion-icon/icon';
/**
* Provides methods for presenting the map
*/
@Injectable({
providedIn: 'root',
})
export class MapProvider {
/**
* Area to show when the map is initialized (shown for the first time)
*/
defaultPolygon: Polygon;
/**
* Provide a point marker for a leaflet map
* @param point Point to get marker for
* @param className CSS class name
* @param iconSize Size of the position icon
*/
static getPointMarker(point: Point, className: string, iconSize: number) {
return marker(geoJSON(point).getBounds().getCenter(), {
icon: divIcon({
className: className,
html: `<span
name="${SCIcon`location_on`}"
class="material-symbols-rounded map-location-pin"
style="font-size: ${iconSize}px;"
>${SCIcon`location_on`}</span>`,
iconSize: [iconSize, iconSize],
iconAnchor: [iconSize / 2, iconSize],
}),
});
}
/**
* Provide a position marker for a leaflet map
* @param position Current position
* @param className CSS class name
* @param iconSize Size of the position icon
*/
static getPositionMarker(position: MapPosition, className: string, iconSize: number) {
return new Marker(new LatLng(position.latitude, position.longitude), {
icon: divIcon({
className: className,
html:
position.heading === undefined
? `<span
name="${SCIcon`person_pin_circle`}"
class="material-symbols-rounded map-location-pin"
style="font-size: ${iconSize}px; color: var(--ion-color-primary);"
>${SCIcon`person_pin_circle`}</span>`
: `<span
class="material-symbols-rounded map-location-pin"
style="
transform-origin: center;
transform: rotate(${position.heading}deg);
font-size: ${iconSize}px;
color: var(--ion-color-primary);
"
>${SCIcon`navigation`}</span>`,
iconSize: [iconSize, iconSize],
}),
zIndexOffset: 1000,
});
}
/**
* Fixes the issue of missing tiles when map renders before its container element
* @param map The initialized map
* @param element The element containing the map
* @param interval Interval to clear when map's appearance is corrected
*/
static invalidateWhenRendered = (map: Map, element: ElementRef, interval: number) => {
if (element.nativeElement.offsetWidth === 0) {
return;
}
// map's container is ready
map.invalidateSize();
// stop repeating when it's rendered and invalidateSize done
clearInterval(interval);
};
constructor(
private dataProvider: DataProvider,
private positionService: PositionService,
private configProvider: ConfigProvider,
) {
this.defaultPolygon = this.configProvider.getValue('campusPolygon') as Polygon;
}
/**
* Provide the specific place by its UID
* @param uid UUID of the place to look for
*/
async searchPlace(uid: SCUuid): Promise<SCSearchResponse> {
const uidFilter: SCSearchFilter = {
arguments: {
field: 'uid',
value: uid,
},
type: 'value',
};
return this.dataProvider.search({filter: uidFilter});
}
/**
* Provide places (buildings and canteens) const result = await this.dataProvider.search(query);
* @param contextFilter Additional contextual filter (e.g. from the context menu)
* @param queryText Query (text) of the search query
*/
async searchPlaces(contextFilter?: SCSearchFilter, queryText?: string): Promise<SCSearchResponse> {
const buildingFilter: SCSearchFilter = {
arguments: {
field: 'type',
value: SCThingType.Building,
},
type: 'value',
};
const mensaFilter: SCSearchFilter = {
arguments: {
filters: [
{
arguments: {
field: 'categories',
value: 'canteen',
},
type: 'value',
},
{
arguments: {
field: 'categories',
value: 'student canteen',
},
type: 'value',
},
{
arguments: {
field: 'categories',
value: 'cafe',
},
type: 'value',
},
{
arguments: {
field: 'categories',
value: 'restaurant',
},
type: 'value',
},
],
operation: 'or',
},
type: 'boolean',
};
// initial filter for the places
const baseFilter: SCSearchFilter = {
arguments: {
operation: 'or',
filters: [buildingFilter, mensaFilter],
},
type: 'boolean',
};
let filter = baseFilter;
if (contextFilter !== undefined) {
filter = {
arguments: {
operation: 'and',
filters: [baseFilter, contextFilter],
},
type: 'boolean',
};
}
const query: SCSearchQuery = {
filter,
};
if (queryText && queryText.length > 0) {
query.query = queryText;
}
if (this.positionService.position) {
query.sort = [
{
type: 'distance',
order: 'asc',
arguments: {
field: 'geo',
position: [this.positionService.position.longitude, this.positionService.position.latitude],
},
},
];
}
const result = await this.dataProvider.search(query);
result.data = result.data.filter(place => hasValidLocation(place as SCBuilding));
return result;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,106 +0,0 @@
<!--
~ Copyright (C) 2023 StApps
~ This program is free software: you can redistribute it and/or modify it
~ under the terms of the GNU General Public License as published by the Free
~ Software Foundation, version 3.
~
~ This program is distributed in the hope that it will be useful, but WITHOUT
~ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
~ FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
~ more details.
~
~ You should have received a copy of the GNU General Public License along with
~ this program. If not, see <https://www.gnu.org/licenses/>.
-->
<stapps-context contentId="map"></stapps-context>
<ion-header class="ion-no-border" translucent="true">
<ion-toolbar color="primary" mode="ios">
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
</ion-buttons>
<ion-title>{{ 'map.page.TITLE' | translate }}</ion-title>
</ion-toolbar>
<ion-toolbar color="primary">
<ion-searchbar
(keyup)="searchKeyUp($event)"
(keyup.enter)="hideKeyboard()"
(search)="hideKeyboard()"
[(ngModel)]="queryText"
(ionClear)="searchStringChanged()"
mode="md"
placeholder="{{ 'map.page.search_bar.placeholder' | translate }}"
showClearButton="always"
type="search"
enterkeyhint="search"
class="filterable"
>
<ion-menu-button menu="context" auto-hide="false">
<ion-icon name="tune"></ion-icon>
</ion-menu-button>
</ion-searchbar>
</ion-toolbar>
</ion-header>
<ion-content id="map">
<div
class="map-container"
#mapContainer
leaflet
(leafletMapReady)="onMapReady($event)"
(leafletClick)="resetView()"
[leafletOptions]="options"
[leafletMarkerCluster]="markerClusterData"
[leafletMarkerClusterOptions]="markerClusterOptions"
>
@if (position) {
<div [leafletLayer]="positionMarker"></div>
}
</div>
<div class="floating-content">
<div class="map-buttons">
@if (items.length > 1) {
<ion-button color="light" shape="round" size="small" (click)="mapListModal.present()">
<ion-icon name="list"></ion-icon>&nbsp;&nbsp;{{ 'map.page.buttons.SHOW_LIST' | translate }}
</ion-button>
}
<ion-button
color="light"
shape="round"
size="small"
(click)="onPositionClick()"
class="location-button"
>
@if (position !== null) {
<ion-icon name="my_location"></ion-icon>
} @else {
@if (locationStatus?.location !== 'denied') {
<ion-icon name="location_searching"></ion-icon>
} @else {
<ion-icon name="location_disabled"></ion-icon>
}
}
</ion-button>
</div>
<ion-card class="map-item">
@if (items.length === 1) {
<stapps-data-list-item [item]="$any(items[0])"></stapps-data-list-item>
}
<ion-button fill="clear" class="close" (click)="resetView()">
<ion-icon [size]="22" name="close" slot="icon-only"></ion-icon>
</ion-button>
</ion-card>
</div>
<ion-modal [canDismiss]="true" #mapListModal>
<ng-template>
<map-list-modal
style="height: 100%"
[filterQuery]="filterQuery"
[mapBounds]="this.map.getBounds()"
[queryText]="queryText"
></map-list-modal>
</ng-template>
</ion-modal>
</ion-content>

View File

@@ -1,99 +0,0 @@
/*!
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
@import '../../../../theme/util/mixins';
$bottom-offset: 7px; // no idea what happened here
.map-container {
width: 100%;
height: 100%;
}
ion-toolbar:first-of-type {
padding: 0 var(--spacing-md) var(--spacing-xs);
}
.floating-content {
position: fixed;
z-index: 1000;
right: 0;
bottom: 0;
left: 0;
display: flex;
flex-flow: row-reverse wrap;
align-items: flex-end;
justify-content: space-between;
}
.map-buttons {
display: flex;
justify-content: flex-end;
ion-button {
// important for iOS
// TODO: find an option that is better suited for the iOS theme
--box-shadow: var(--map-box-shadow);
align-self: flex-end;
margin: var(--spacing-md);
&.location-button {
view-transition-name: location-button;
}
}
}
.map-item {
position: relative;
flex: 1 0 auto;
max-width: 550px;
margin: var(--spacing-md);
.close {
position: absolute;
top: 0;
right: 0;
}
::ng-deep ion-item {
margin: 0;
}
}
@include ion-md-down {
.md {
ion-content {
--padding-bottom: $bottom-offset;
}
.floating-content {
bottom: $bottom-offset;
}
}
.map-buttons ion-button {
margin: var(--spacing-sm);
}
.map-item {
width: 100%;
max-width: unset;
margin: 0;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
}

View File

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

View File

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

View File

@@ -1,93 +0,0 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Component, ElementRef, HostBinding, Input, OnInit, ViewChild} from '@angular/core';
import {Router} from '@angular/router';
import {SCPlaceWithoutReferences, SCThingWithoutReferences} from '@openstapps/core';
import {geoJSON, Map, MapOptions, tileLayer} from 'leaflet';
import {MapProvider} from '../map.provider';
/**
* The map widget (needs a container with explicit size)
*/
@Component({
selector: 'stapps-map-widget',
styleUrls: ['./map-widget.scss'],
templateUrl: './map-widget.html',
})
export class MapWidgetComponent implements OnInit {
@HostBinding('class.expand-when-space') expandWhenSpace = true;
/**
* A leaflet map showed
*/
map: Map;
/**
* Container element of the map
*/
@ViewChild('mapContainer') mapContainer: ElementRef;
/**
* Options of the leaflet map
*/
options: MapOptions;
/**
* A place to show on the map
*/
@Input() place: SCThingWithoutReferences & Pick<SCPlaceWithoutReferences, 'geo' | 'address'>;
/**
* Indicates if the expand button should be visible
*/
showExpandButton = true;
constructor(private router: Router) {}
/**
* Prepare the map
*/
ngOnInit() {
const markerLayer = MapProvider.getPointMarker(this.place.geo.point, 'stapps-location', 32);
this.options = {
center: geoJSON(this.place.geo.polygon || this.place.geo.point)
.getBounds()
.getCenter(),
layers: [
tileLayer('https://osm.server.uni-frankfurt.de/tiles/roads/x={x}&y={y}&z={z}', {
attribution: '&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 18,
}),
markerLayer,
],
zoom: 16,
zoomControl: false,
};
if (this.router) {
this.showExpandButton = !this.router.url.startsWith('/map');
}
}
/**
* What happens when the leaflet map is ready (note: doesn't mean that tiles are loaded)
*/
onMapReady(map: Map) {
this.map = map;
this.map.dragging.disable();
const interval = window.setInterval(() => {
MapProvider.invalidateWhenRendered(map, this.mapContainer, interval);
});
}
}

View File

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

View File

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

View File

@@ -35,13 +35,13 @@
lines="none"
class="menu-category"
>
<ion-icon slot="end" [name]="category.icon"></ion-icon>
<ion-icon slot="end" [name]="$any(category.icon)"></ion-icon>
<ion-label> {{ category.translations[language]?.title | titlecase }} </ion-label>
</ion-item>
}
@for (item of category.items; track item) {
<ion-item [rootLink]="item.route" [redirectedFrom]="item.route">
<ion-icon slot="end" [name]="item.icon"></ion-icon>
<ion-icon slot="end" [name]="$any(item.icon)"></ion-icon>
<ion-label> {{ item.translations[language]?.title | titlecase }} </ion-label>
</ion-item>
}

View File

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

View File

@@ -41,7 +41,7 @@
</ion-menu-toggle>
@for (category of menu; track category; let isFirst = $first) {
<ion-tab-button [rootLink]="category.route" [redirectedFrom]="category.route" [tab]="category.title">
<ion-icon [name]="category.icon"></ion-icon>
<ion-icon [name]="$any(category.icon)"></ion-icon>
<ion-label>{{ category.translations[language]?.title | titlecase }}</ion-label>
</ion-tab-button>
}

View File

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

Some files were not shown because too many files have changed in this diff Show More