first commit
This commit is contained in:
commit
62506c830a
1207 changed files with 40706 additions and 0 deletions
16
.editorconfig
Normal file
16
.editorconfig
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
# Editor configuration, see https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.ts]
|
||||||
|
quote_type = single
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
# Only exists if Bazel was run
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# profiling files
|
||||||
|
chrome-profiler-events*.json
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
/.idea
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# IDE - VSCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
# misc
|
||||||
|
/.angular/cache
|
||||||
|
/.sass-cache
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
|
||||||
|
# System Files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
7
Containerfile
Normal file
7
Containerfile
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
FROM docker.io/library/nginx:alpine
|
||||||
|
MAINTAINER philo email phil.dev@philome.mooo.com
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY treetrail-app/dist/treetrail/browser /usr/share/nginx/html
|
1
README.md
Normal file
1
README.md
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Front-end for *Tree Trail*, a fun and pedagogic tool to discover the trails and trees around.
|
143
angular.json
Normal file
143
angular.json
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"treetrail": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {
|
||||||
|
"@schematics/angular:component": {
|
||||||
|
"style": "scss"
|
||||||
|
},
|
||||||
|
"@schematics/angular:application": {
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular-devkit/build-angular:application",
|
||||||
|
"options": {
|
||||||
|
"outputPath": {
|
||||||
|
"base": "dist/treetrail"
|
||||||
|
},
|
||||||
|
"index": "src/index.html",
|
||||||
|
"polyfills": [
|
||||||
|
"src/polyfills.ts"
|
||||||
|
],
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "src/assets/",
|
||||||
|
"output": "/assets/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "favicon.ico",
|
||||||
|
"input": "src/",
|
||||||
|
"output": "/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "src/data/",
|
||||||
|
"output": "/data/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "manifest.webmanifest",
|
||||||
|
"input": "src/",
|
||||||
|
"output": "/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"./node_modules/@angular/material/prebuilt-themes/indigo-pink.css",
|
||||||
|
"./node_modules/maplibre-gl/dist/maplibre-gl.css",
|
||||||
|
"src/styles.scss"
|
||||||
|
],
|
||||||
|
"scripts": [],
|
||||||
|
"serviceWorker": "ngsw-config.json",
|
||||||
|
"allowedCommonJsDependencies": [
|
||||||
|
"js-untar",
|
||||||
|
"maplibre-gl"
|
||||||
|
],
|
||||||
|
"browser": "src/main.ts"
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "3mb",
|
||||||
|
"maximumError": "5mb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "2kb",
|
||||||
|
"maximumError": "4kb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.prod.ts"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"namedChunks": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "treetrail:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "treetrail:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"extract-i18n": {
|
||||||
|
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "treetrail:build"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
|
"options": {
|
||||||
|
"main": "src/test.ts",
|
||||||
|
"polyfills": "src/polyfills.ts",
|
||||||
|
"tsConfig": "tsconfig.spec.json",
|
||||||
|
"karmaConfig": "karma.conf.js",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
"src/favicon.ico",
|
||||||
|
"src/assets",
|
||||||
|
"src/manifest.webmanifest"
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"./node_modules/@angular/material/prebuilt-themes/indigo-pink.css",
|
||||||
|
"src/styles.scss"
|
||||||
|
],
|
||||||
|
"scripts": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cli": {
|
||||||
|
"analytics": false,
|
||||||
|
"packageManager": "pnpm"
|
||||||
|
}
|
||||||
|
}
|
44
karma.conf.js
Normal file
44
karma.conf.js
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
// Karma configuration file, see link for more information
|
||||||
|
// https://karma-runner.github.io/1.0/config/configuration-file.html
|
||||||
|
|
||||||
|
module.exports = function (config) {
|
||||||
|
config.set({
|
||||||
|
basePath: '',
|
||||||
|
frameworks: ['jasmine', '@angular-devkit/build-angular'],
|
||||||
|
plugins: [
|
||||||
|
require('karma-jasmine'),
|
||||||
|
require('karma-chrome-launcher'),
|
||||||
|
require('karma-jasmine-html-reporter'),
|
||||||
|
require('karma-coverage'),
|
||||||
|
require('@angular-devkit/build-angular/plugins/karma')
|
||||||
|
],
|
||||||
|
client: {
|
||||||
|
jasmine: {
|
||||||
|
// you can add configuration options for Jasmine here
|
||||||
|
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
|
||||||
|
// for example, you can disable the random execution with `random: false`
|
||||||
|
// or set a specific seed with `seed: 4321`
|
||||||
|
},
|
||||||
|
clearContext: false // leave Jasmine Spec Runner output visible in browser
|
||||||
|
},
|
||||||
|
jasmineHtmlReporter: {
|
||||||
|
suppressAll: true // removes the duplicated traces
|
||||||
|
},
|
||||||
|
coverageReporter: {
|
||||||
|
dir: require('path').join(__dirname, './coverage/treetrail'),
|
||||||
|
subdir: '.',
|
||||||
|
reporters: [
|
||||||
|
{ type: 'html' },
|
||||||
|
{ type: 'text-summary' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
reporters: ['progress', 'kjhtml'],
|
||||||
|
port: 9876,
|
||||||
|
colors: true,
|
||||||
|
logLevel: config.LOG_INFO,
|
||||||
|
autoWatch: true,
|
||||||
|
browsers: ['Chrome'],
|
||||||
|
singleRun: false,
|
||||||
|
restartOnFileChange: true
|
||||||
|
});
|
||||||
|
};
|
90
ngsw-config.json
Normal file
90
ngsw-config.json
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
|
||||||
|
"index": "/index.html",
|
||||||
|
"assetGroups": [
|
||||||
|
{
|
||||||
|
"name": "app",
|
||||||
|
"installMode": "prefetch",
|
||||||
|
"resources": {
|
||||||
|
"files": [
|
||||||
|
"/favicon.ico",
|
||||||
|
"/index.html",
|
||||||
|
"/manifest.webmanifest",
|
||||||
|
"/*.css",
|
||||||
|
"/*.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "assets",
|
||||||
|
"installMode": "lazy",
|
||||||
|
"updateMode": "prefetch",
|
||||||
|
"resources": {
|
||||||
|
"files": [
|
||||||
|
"/assets/**",
|
||||||
|
"/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dataGroups": [
|
||||||
|
{
|
||||||
|
"name": "api",
|
||||||
|
"urls": [
|
||||||
|
"/v1/**"
|
||||||
|
],
|
||||||
|
"cacheConfig": {
|
||||||
|
"strategy": "freshness",
|
||||||
|
"maxSize": 1000,
|
||||||
|
"maxAge": "7d",
|
||||||
|
"timeout": "0u"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tiles",
|
||||||
|
"urls": [
|
||||||
|
"/tiles/**"
|
||||||
|
],
|
||||||
|
"cacheConfig": {
|
||||||
|
"strategy": "performance",
|
||||||
|
"maxSize": 5000,
|
||||||
|
"maxAge": "14d"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "plantekey",
|
||||||
|
"urls": [
|
||||||
|
"/plantekey/**"
|
||||||
|
],
|
||||||
|
"cacheConfig": {
|
||||||
|
"strategy": "performance",
|
||||||
|
"maxSize": 1000,
|
||||||
|
"maxAge": "7d"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cache",
|
||||||
|
"urls": [
|
||||||
|
"/static/cache/**"
|
||||||
|
],
|
||||||
|
"cacheConfig": {
|
||||||
|
"strategy": "freshness",
|
||||||
|
"maxSize": 10000,
|
||||||
|
"maxAge": "7d",
|
||||||
|
"timeout": "0u"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "attachments",
|
||||||
|
"urls": [
|
||||||
|
"/attachment/**"
|
||||||
|
],
|
||||||
|
"cacheConfig": {
|
||||||
|
"strategy": "freshness",
|
||||||
|
"maxSize": 10000,
|
||||||
|
"maxAge": "180d",
|
||||||
|
"timeout": "0u"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
7
openapi-ts.config.ts
Normal file
7
openapi-ts.config.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from '@hey-api/openapi-ts';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
input: 'http://127.0.0.1:5002/v1/openapi.json',
|
||||||
|
output: 'src/app/openapi',
|
||||||
|
client: 'angular',
|
||||||
|
});
|
14714
package-lock.json
generated
Normal file
14714
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
65
package.json
Normal file
65
package.json
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
{
|
||||||
|
"name": "treetrail",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve --proxy-config proxy.conf.json --port 4201",
|
||||||
|
"build": "ng build",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test",
|
||||||
|
"openapi-ts": "openapi-ts"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/animations": "^18.2.6",
|
||||||
|
"@angular/cdk": "^18.2.6",
|
||||||
|
"@angular/common": "^18.2.6",
|
||||||
|
"@angular/compiler": "^18.2.6",
|
||||||
|
"@angular/core": "^18.2.6",
|
||||||
|
"@angular/forms": "^18.2.6",
|
||||||
|
"@angular/material": "18.2.6",
|
||||||
|
"@angular/platform-browser": "^18.2.6",
|
||||||
|
"@angular/platform-browser-dynamic": "^18.2.6",
|
||||||
|
"@angular/pwa": "^18.2.6",
|
||||||
|
"@angular/router": "^18.2.6",
|
||||||
|
"@angular/service-worker": "^18.2.6",
|
||||||
|
"@mapbox/point-geometry": "^0.1.0",
|
||||||
|
"@maplibre/ngx-maplibre-gl": "^17.4.3",
|
||||||
|
"@ng-web-apis/common": "^3.0.6",
|
||||||
|
"@ng-web-apis/geolocation": "3.0.6",
|
||||||
|
"@turf/bbox": "^6.5.0",
|
||||||
|
"@turf/bearing": "^6.5.0",
|
||||||
|
"@turf/distance": "^6.5.0",
|
||||||
|
"@turf/length": "^6.5.0",
|
||||||
|
"@turf/nearest-point": "^6.5.0",
|
||||||
|
"js-untar": "^2.0.0",
|
||||||
|
"maplibre-gl": "^4.3.2",
|
||||||
|
"motion-sensors-polyfill": "^0.3.7",
|
||||||
|
"ngx-image-compress": "^15.1.6",
|
||||||
|
"ngx-indexed-db": "^17.1.0",
|
||||||
|
"rxjs": "~7.8.1",
|
||||||
|
"tslib": "^2.6.2",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
|
"zone.js": "^0.14.10"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular-devkit/build-angular": "^18.2.6",
|
||||||
|
"@angular/cli": "^18.2.6",
|
||||||
|
"@angular/compiler-cli": "^18.2.6",
|
||||||
|
"@hey-api/openapi-ts": "^0.45.1",
|
||||||
|
"@types/geojson": "^7946.0.14",
|
||||||
|
"@types/jasmine": "~5.1.4",
|
||||||
|
"@types/motion-sensors-polyfill": "^0.3.4",
|
||||||
|
"@types/node": "^20.12.7",
|
||||||
|
"@types/uuid": "^9.0.8",
|
||||||
|
"fontnik": "^0.7.2",
|
||||||
|
"jasmine-core": "~5.1.2",
|
||||||
|
"karma": "~6.4.3",
|
||||||
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
|
"karma-coverage": "~2.2.1",
|
||||||
|
"karma-jasmine": "~5.1.0",
|
||||||
|
"karma-jasmine-html-reporter": "^2.1.0",
|
||||||
|
"typescript": "~5.4.5"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.12.2"
|
||||||
|
}
|
9795
pnpm-lock.yaml
generated
Normal file
9795
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
18
proxy.conf.json
Normal file
18
proxy.conf.json
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"/static": {
|
||||||
|
"target": "http://127.0.0.1:5002",
|
||||||
|
"secure": false
|
||||||
|
},
|
||||||
|
"/v1": {
|
||||||
|
"target": "http://127.0.0.1:5002",
|
||||||
|
"secure": false
|
||||||
|
},
|
||||||
|
"/attachment": {
|
||||||
|
"target": "http://127.0.0.1:5002",
|
||||||
|
"secure": false
|
||||||
|
},
|
||||||
|
"/tiles": {
|
||||||
|
"target": "http://127.0.0.1:5002",
|
||||||
|
"secure": false
|
||||||
|
}
|
||||||
|
}
|
10
src/app/about/about.component.html
Normal file
10
src/app/about/about.component.html
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<mat-card>
|
||||||
|
<mat-card-title>
|
||||||
|
About Tree Trail
|
||||||
|
</mat-card-title>
|
||||||
|
<mat-card-content>
|
||||||
|
<h2>Version</h2>
|
||||||
|
<p><span class='h'>Client: </span>{{ (configService.conf | async).bootstrap.client.version }}></p>
|
||||||
|
<p><span class='h'>Server: </span>{{ (configService.conf | async).bootstrap.server.version }}</p>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
4
src/app/about/about.component.scss
Normal file
4
src/app/about/about.component.scss
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
.h {
|
||||||
|
display: inline;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
25
src/app/about/about.component.spec.ts
Normal file
25
src/app/about/about.component.spec.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { AboutComponent } from './about.component';
|
||||||
|
|
||||||
|
describe('AboutComponent', () => {
|
||||||
|
let component: AboutComponent;
|
||||||
|
let fixture: ComponentFixture<AboutComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [ AboutComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(AboutComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
17
src/app/about/about.component.ts
Normal file
17
src/app/about/about.component.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
|
||||||
|
import { ConfigService } from '../config.service'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-about',
|
||||||
|
templateUrl: './about.component.html',
|
||||||
|
styleUrls: ['./about.component.scss']
|
||||||
|
})
|
||||||
|
export class AboutComponent implements OnInit {
|
||||||
|
constructor(
|
||||||
|
public configService: ConfigService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
}
|
||||||
|
}
|
541
src/app/action.service.ts
Normal file
541
src/app/action.service.ts
Normal file
|
@ -0,0 +1,541 @@
|
||||||
|
import { Injectable } from '@angular/core'
|
||||||
|
import { HttpClient } from '@angular/common/http'
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
|
import { Location } from "@angular/common"
|
||||||
|
|
||||||
|
import { Observable, BehaviorSubject, forkJoin, of, from } from 'rxjs'
|
||||||
|
import { first, map } from 'rxjs/operators'
|
||||||
|
|
||||||
|
import { GeoJSONSource } from 'maplibre-gl'
|
||||||
|
|
||||||
|
import { GeolocationService } from '@ng-web-apis/geolocation'
|
||||||
|
import { NgxImageCompressService } from 'ngx-image-compress'
|
||||||
|
|
||||||
|
import * as untar from 'js-untar'
|
||||||
|
|
||||||
|
import { ConfigService } from './config.service'
|
||||||
|
import { DataService, pendingTreeDbName } from './data.service'
|
||||||
|
import { FeatureFinderService } from './feature-finder.service'
|
||||||
|
import {
|
||||||
|
FeatureTarget, TreeTrail, Tree, Trees,
|
||||||
|
Trail, Trails, All, Pois, Poi, Zones, Zone,
|
||||||
|
Styles, Style
|
||||||
|
} from './models'
|
||||||
|
import { PlantekeyService } from './plantekey.service'
|
||||||
|
import { MessageService } from './message.service'
|
||||||
|
import { FeatureCollection } from 'geojson'
|
||||||
|
|
||||||
|
// CACHE_NAME is not exported
|
||||||
|
//import { CACHE_NAME } from 'maplibre-gl/src/util/tile_request_cache'
|
||||||
|
const CACHE_NAME = 'mapbox-tiles'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ActionService {
|
||||||
|
|
||||||
|
// Some cross application observables
|
||||||
|
//public settings = new BehaviorSubject<Settings>(new Settings({}))
|
||||||
|
//settings$ = this.settings.asObservable()
|
||||||
|
|
||||||
|
private _isOnline = new BehaviorSubject<boolean>(navigator.onLine)
|
||||||
|
isOnline$ = this._isOnline.asObservable()
|
||||||
|
|
||||||
|
private _nearestTree = new BehaviorSubject<FeatureTarget>(undefined)
|
||||||
|
nearestTree$ = this._nearestTree.asObservable()
|
||||||
|
|
||||||
|
location: GeolocationPosition | undefined = undefined
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly geolocation$: GeolocationService,
|
||||||
|
public httpClient: HttpClient,
|
||||||
|
public configService: ConfigService,
|
||||||
|
public dataService: DataService,
|
||||||
|
public featureFinderService: FeatureFinderService,
|
||||||
|
private router: Router,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
public browserLocation: Location,
|
||||||
|
public plantekeyService: PlantekeyService,
|
||||||
|
public messageService: MessageService,
|
||||||
|
public imageCompressService: NgxImageCompressService,
|
||||||
|
) {
|
||||||
|
window.addEventListener("online",
|
||||||
|
() => {
|
||||||
|
this._isOnline.next(true)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
window.addEventListener("offline",
|
||||||
|
() => {
|
||||||
|
this._isOnline.next(false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
this.isOnline$.subscribe(
|
||||||
|
isOnline => {
|
||||||
|
if (isOnline) {
|
||||||
|
this.dataService.syncPendingTrees()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
this.geolocation$.subscribe({
|
||||||
|
next: (location: GeolocationPosition) => {
|
||||||
|
this.location = location
|
||||||
|
this.onLocationChange()
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.log(err)
|
||||||
|
this.location = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
this.configService.setUserPref('userName', undefined)
|
||||||
|
this.browserLocation.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
onLocationChange() {
|
||||||
|
let newFeature: FeatureTarget = this.featureFinderService.findNewFeature(this.location)
|
||||||
|
if (newFeature) {
|
||||||
|
this.vibrate()
|
||||||
|
this._nearestTree.next(newFeature)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllGeoJSONPois(): Observable<FeatureCollection> {
|
||||||
|
return this.httpClient.get<FeatureCollection>('v1/poi').pipe(
|
||||||
|
map(data => {
|
||||||
|
this.dataService.poisFeatures.next(data)
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllGeoJSONTrees(): Observable<FeatureCollection> {
|
||||||
|
return this.httpClient.get<FeatureCollection>('v1/tree').pipe(
|
||||||
|
map(data => {
|
||||||
|
this.dataService.treeFeatures.next(data)
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllGeoJSONZones(): Observable<FeatureCollection> {
|
||||||
|
return this.httpClient.get<FeatureCollection>('v1/zone').pipe(
|
||||||
|
map(data => {
|
||||||
|
this.dataService.zoneFeatures.next(data)
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllGeoJSONTrails(): Observable<FeatureCollection> {
|
||||||
|
return this.httpClient.get<FeatureCollection>('v1/trail').pipe(
|
||||||
|
map(data => {
|
||||||
|
this.dataService.trailFeatures.next(data)
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllTrees(): Observable<Trees> {
|
||||||
|
return this.getAllGeoJSONTrees().pipe(map(
|
||||||
|
source => Object.fromEntries(
|
||||||
|
source['features'].map(
|
||||||
|
feature => new Tree(
|
||||||
|
<string>feature['id'],
|
||||||
|
feature,
|
||||||
|
feature['properties']['plantekey_id'],
|
||||||
|
feature['properties']['photo'],
|
||||||
|
feature['properties']['data'],
|
||||||
|
feature['properties']['height'],
|
||||||
|
feature['properties']['comments'],
|
||||||
|
)
|
||||||
|
).map((t: Tree) => [t.id, t]))
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllPois(): Observable<Pois> {
|
||||||
|
return this.getAllGeoJSONPois().pipe(map(
|
||||||
|
source => Object.fromEntries(
|
||||||
|
source['features'].map(
|
||||||
|
feature => new Poi(
|
||||||
|
+feature['id'],
|
||||||
|
feature,
|
||||||
|
feature['properties']['name'],
|
||||||
|
feature['properties']['type'],
|
||||||
|
feature['properties']['description'],
|
||||||
|
feature['properties']['photo'],
|
||||||
|
feature['properties']['data'],
|
||||||
|
)
|
||||||
|
).map((t: Poi) => [t.id, t]))
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllTrails(): Observable<Trails> {
|
||||||
|
return this.getAllGeoJSONTrails().pipe(map(
|
||||||
|
trailsSource => {
|
||||||
|
let tl: Trail[] = trailsSource['features'].map(
|
||||||
|
feature => new Trail(
|
||||||
|
+feature['id'],
|
||||||
|
feature['properties']['name'],
|
||||||
|
feature['properties']['description'],
|
||||||
|
feature,
|
||||||
|
{},
|
||||||
|
feature['properties']['photo']
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let trails: Trails = {}
|
||||||
|
for (let t of tl) {
|
||||||
|
trails[t.id] = t
|
||||||
|
}
|
||||||
|
return trails
|
||||||
|
}
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllTreeTrails(): Observable<TreeTrail[]> {
|
||||||
|
return this.httpClient.get<TreeTrail[]>('v1/tree-trail').pipe(
|
||||||
|
map(data => {
|
||||||
|
this.dataService.set_tree_trail(data)
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllZones(): Observable<Zones> {
|
||||||
|
return this.getAllGeoJSONZones().pipe(map(
|
||||||
|
source => Object.fromEntries(
|
||||||
|
source['features'].map(
|
||||||
|
zone => new Zone(
|
||||||
|
+zone['id'],
|
||||||
|
zone,
|
||||||
|
zone['properties']['name'],
|
||||||
|
zone['properties']['type'],
|
||||||
|
zone['properties']['description'],
|
||||||
|
zone['properties']['photo'],
|
||||||
|
zone['properties']['data'],
|
||||||
|
)
|
||||||
|
).map((t: Zone) => [t.id, t]))
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllStyles(): Observable<Styles> {
|
||||||
|
return this.httpClient.get<Style[]>('v1/style').pipe(map(
|
||||||
|
styles => Object.fromEntries(
|
||||||
|
styles.map(
|
||||||
|
style => new Style(
|
||||||
|
style['layer'],
|
||||||
|
style['paint'] || {},
|
||||||
|
style['layout'] || {},
|
||||||
|
)
|
||||||
|
).map((t: Style) => [t.layer, t]))
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData(): Observable<void> {
|
||||||
|
return forkJoin([
|
||||||
|
this.getAllTrails(),
|
||||||
|
this.getAllTrees(),
|
||||||
|
this.getAllTreeTrails(),
|
||||||
|
this.getAllPois(),
|
||||||
|
this.getAllZones(),
|
||||||
|
this.getAllStyles(),
|
||||||
|
this.plantekeyService.getAllPlants(),
|
||||||
|
]).pipe(map(
|
||||||
|
([trails, trees, tts, pois, zones, styles, plants]) => {
|
||||||
|
Object.values(trails).forEach(
|
||||||
|
trail => {
|
||||||
|
let tl = tts.filter(tt => tt.trail_id == trail.id)
|
||||||
|
.map(tt => trees[tt.tree_id])
|
||||||
|
trail.trees = Object.fromEntries(
|
||||||
|
tts.filter(tt => tt.trail_id == trail.id).map(
|
||||||
|
(tt => [tt.tree_id, trees[tt.tree_id]])
|
||||||
|
))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
tts.forEach(
|
||||||
|
tt => {
|
||||||
|
if (tt.tree_id in trees) {
|
||||||
|
let plantId = trees[tt.tree_id].plantekeyId
|
||||||
|
if (!this.dataService.plant_trail[plantId]) {
|
||||||
|
this.dataService.plant_trail[plantId] = {}
|
||||||
|
}
|
||||||
|
this.dataService.plant_trail[plantId][tt.trail_id] = trails[tt.trail_id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
this.dataService.all.next(new All(trees, trails, tts, plants, pois, zones, styles))
|
||||||
|
}
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
getUpdates(): Observable<Observable<any>> {
|
||||||
|
return from(window.caches.open('v1')).pipe(
|
||||||
|
map(
|
||||||
|
cache =>
|
||||||
|
forkJoin([
|
||||||
|
this.getSimpleUpdates(cache),
|
||||||
|
//this.getComplexUpdates(cache)
|
||||||
|
])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheImages(): Observable<Observable<any>> {
|
||||||
|
return from(window.caches.open('attachments')).pipe(
|
||||||
|
map(
|
||||||
|
cache => forkJoin([
|
||||||
|
this.getPlantekeyImagesTarFile(cache),
|
||||||
|
this.getAllAttachmentsTarFile(cache),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheMapData(): Observable<Observable<any>> {
|
||||||
|
return from(window.caches.open(CACHE_NAME)).pipe(
|
||||||
|
map(
|
||||||
|
cache => this.getMapData(cache)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getMapData(cache: Cache, style = 'osm'): Observable<void> {
|
||||||
|
return this.httpClient.get(`/tiles/${style}/all.tar`, {
|
||||||
|
'responseType': 'blob'
|
||||||
|
}).pipe(
|
||||||
|
map(
|
||||||
|
(data: any) => {
|
||||||
|
from(data.arrayBuffer()).subscribe(
|
||||||
|
buf => from(untar.default(buf)).subscribe(
|
||||||
|
(tiles: any) => {
|
||||||
|
for (let tile of tiles) {
|
||||||
|
cache.put(
|
||||||
|
`/tiles/${tile.name}`,
|
||||||
|
new Response(
|
||||||
|
tile.blob,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'content-type': tile.name == `style/${style}` ? 'application/json' : 'application/octet-stream',
|
||||||
|
'content-length': tile.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlantekeyImagesTarFile(cache: Cache): Observable<void> {
|
||||||
|
return this.httpClient.get('/static/cache/plantekey/thumbnails.tar', {
|
||||||
|
'responseType': 'blob'
|
||||||
|
}).pipe(
|
||||||
|
map(
|
||||||
|
(data: any) => {
|
||||||
|
from(data.arrayBuffer()).subscribe(
|
||||||
|
buf => from(untar.default(buf)).subscribe(
|
||||||
|
(imgs: any) => {
|
||||||
|
for (let img of imgs) {
|
||||||
|
cache.put(
|
||||||
|
`/attachment/plantekey/thumb/${img.name.split('/').pop()}`,
|
||||||
|
new Response(
|
||||||
|
img.blob,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'content-type': 'image/jpeg',
|
||||||
|
'content-length': img.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllAttachmentsTarFile(cache: Cache): Observable<void> {
|
||||||
|
return this.httpClient.get('/static/cache/attachments.tar', {
|
||||||
|
'responseType': 'blob'
|
||||||
|
}).pipe(
|
||||||
|
map(
|
||||||
|
(data: any) => {
|
||||||
|
from(data.arrayBuffer()).subscribe(
|
||||||
|
buf => from(untar.default(buf)).subscribe(
|
||||||
|
(imgs: any) => {
|
||||||
|
for (let img of imgs) {
|
||||||
|
let splitPath = img['name'].split('/')
|
||||||
|
let fileName = splitPath.pop()
|
||||||
|
let id = splitPath.pop()
|
||||||
|
let type = splitPath.pop()
|
||||||
|
cache.put(
|
||||||
|
`/attachment/${type}/${id}/${fileName}`,
|
||||||
|
new Response(
|
||||||
|
img.blob,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'content-type': 'image/jpeg',
|
||||||
|
'content-length': img.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCache() {
|
||||||
|
return from(window.caches.delete('v1'))
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePlantekeyData(): Observable<any> {
|
||||||
|
return this.httpClient.get('v1/plantekey/updateData')
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePlantekeyImages(): Observable<any> {
|
||||||
|
return this.httpClient.get('v1/plantekey/updateImages')
|
||||||
|
}
|
||||||
|
|
||||||
|
makeAttachmentsTarFile(): Observable<any> {
|
||||||
|
return this.httpClient.get('v1/makeAttachmentsTarFile')
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Get simple (atomic) requests
|
||||||
|
*/
|
||||||
|
getSimpleUpdates(cache: Cache): Observable<void> {
|
||||||
|
return from(cache.addAll([
|
||||||
|
'v1/trail',
|
||||||
|
'v1/tree',
|
||||||
|
'v1/plantekey/details',
|
||||||
|
//'v1/plantekey/plant/info',
|
||||||
|
'v1/tree-trail',
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
upload(type: string, field: string, id: string, formData: FormData) {
|
||||||
|
return this.httpClient.post(`v1/upload/${type}/${field}/${id}`, formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Get complex objects from the server, that are expanded and
|
||||||
|
* put in the cache in separate entities
|
||||||
|
*/
|
||||||
|
// XXX: Unused
|
||||||
|
getComplexUpdates(cache: Cache): Observable<any> {
|
||||||
|
return this.httpClient.get('v1/plantekey/plant/info').pipe(
|
||||||
|
map(
|
||||||
|
resp => {
|
||||||
|
for (let id in resp['plant']) {
|
||||||
|
let plant = resp['plant'][id]
|
||||||
|
let family = plant['family'].replace(' ', '-').toLowerCase()
|
||||||
|
let characteristics = resp['characteristics'][id]
|
||||||
|
let images = resp['image'][id]
|
||||||
|
cache.put(
|
||||||
|
`v1/plantekey/plant/info/${family}/${id}`,
|
||||||
|
new Response(
|
||||||
|
plant,
|
||||||
|
{ headers: { 'content-type': 'application/json' } }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return of()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
skipIntro() {
|
||||||
|
this.configService.conf.value.skipIntro = true
|
||||||
|
this.configService.storeUserData()
|
||||||
|
this.router.navigate([''], {relativeTo: this.route});
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
vibrate() {
|
||||||
|
if (this.configService.conf.value.vibrate) {
|
||||||
|
window.navigator.vibrate([200, 100, 200])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLocalData() {
|
||||||
|
this.getUpdates().subscribe(
|
||||||
|
dbFetch => {
|
||||||
|
dbFetch.subscribe({
|
||||||
|
complete: () => {
|
||||||
|
this.messageService.message.next('Update local data successful')
|
||||||
|
},
|
||||||
|
error: error => {
|
||||||
|
console.error(error)
|
||||||
|
this.messageService.message.next('Update failed')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLocalImages() {
|
||||||
|
this.cacheImages().subscribe(
|
||||||
|
dbFetch => {
|
||||||
|
dbFetch.subscribe({
|
||||||
|
complete: () => {
|
||||||
|
this.messageService.message.next('Update local images successful')
|
||||||
|
},
|
||||||
|
error: error => {
|
||||||
|
console.error(error)
|
||||||
|
this.messageService.message.next('Update local images failed')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLocalMapData() {
|
||||||
|
this.cacheMapData().subscribe(
|
||||||
|
dbFetch => {
|
||||||
|
dbFetch.subscribe({
|
||||||
|
complete: () => {
|
||||||
|
this.messageService.message.next('Update map data successful')
|
||||||
|
},
|
||||||
|
error: error => {
|
||||||
|
console.error(error)
|
||||||
|
this.messageService.message.next('Update map data failed')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearLocalData() {
|
||||||
|
this.clearCache().subscribe(
|
||||||
|
result => {
|
||||||
|
this.messageService.message.next('Cache cleared')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Get all data from server needed for offline browsing.
|
||||||
|
* Insert these in the cache storage
|
||||||
|
*/
|
||||||
|
getPicturesForOffline() {
|
||||||
|
this.updateLocalMapData()
|
||||||
|
this.updateLocalImages()
|
||||||
|
this.updateLocalData()
|
||||||
|
}
|
||||||
|
}
|
63
src/app/admin/admin.component.html
Normal file
63
src/app/admin/admin.component.html
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
<mat-accordion>
|
||||||
|
<mat-expansion-panel expanded="true">
|
||||||
|
<mat-expansion-panel-header>
|
||||||
|
<mat-panel-title color="warn">Pending trees</mat-panel-title>
|
||||||
|
<mat-panel-description>Sync trees created locally, not saved on the server</mat-panel-description>
|
||||||
|
</mat-expansion-panel-header>
|
||||||
|
<div class="pendingTrees">
|
||||||
|
Trees pending for syncing to server's database:
|
||||||
|
{{ pendingTreesCount }}.
|
||||||
|
<div class="actions" *ngIf="pendingTreesCount > 0">
|
||||||
|
<button mat-raised-button color="primary" (click)="dataService.syncPendingTrees(true)">
|
||||||
|
Sync now
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="warn" (click)="dataService.deletePendingTrees()">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-expansion-panel>
|
||||||
|
|
||||||
|
<mat-expansion-panel expanded="false">
|
||||||
|
<mat-expansion-panel-header>
|
||||||
|
<mat-panel-title color="warn">Update server data</mat-panel-title>
|
||||||
|
<mat-panel-description>Get updates from plantekey, etc</mat-panel-description>
|
||||||
|
</mat-expansion-panel-header>
|
||||||
|
<div class="actions">
|
||||||
|
<button mat-raised-button (click)="updatePlantekeyData()" color='primary'>
|
||||||
|
Update Plantekey data
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button (click)="updatePlantekeyImages()" color='primary'>
|
||||||
|
Update Plantekey images
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button (click)="makeAttachmentsTarFile()" color='primary'>
|
||||||
|
Make attachments tarfile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</mat-expansion-panel>
|
||||||
|
|
||||||
|
<mat-expansion-panel>
|
||||||
|
<mat-expansion-panel-header>
|
||||||
|
<mat-panel-title>User</mat-panel-title>
|
||||||
|
<mat-panel-description>Show logged-in user information</mat-panel-description>
|
||||||
|
</mat-expansion-panel-header>
|
||||||
|
<div *ngIf="(configService.conf | async).bootstrap?.user; else noUser">
|
||||||
|
<p><span class='h'>User: </span>{{ (configService.conf | async).bootstrap.user.username }}</p>
|
||||||
|
<p><span class='h'>Full name: </span>{{ (configService.conf | async).bootstrap.user.full_name }}</p>
|
||||||
|
<p><span class='h'>Email: </span>{{ (configService.conf | async).bootstrap.user.email }}</p>
|
||||||
|
<p><span class='h'>Roles: </span>
|
||||||
|
<mat-chip *ngFor='let role of (configService.conf | async).bootstrap.user.roles'>
|
||||||
|
{{ role.name }}
|
||||||
|
</mat-chip>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</mat-expansion-panel>
|
||||||
|
</mat-accordion>
|
||||||
|
|
||||||
|
<ng-template #noUser>
|
||||||
|
{{ (configService.conf | async).bootstrap.user.username }}, your session has expired.
|
||||||
|
<button mat-raised-button color="primary" [routerLink]="['/', 'login']">
|
||||||
|
login
|
||||||
|
<mat-icon>login</mat-icon>
|
||||||
|
</button>
|
||||||
|
</ng-template>
|
11
src/app/admin/admin.component.scss
Normal file
11
src/app/admin/admin.component.scss
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
.actions {
|
||||||
|
text-align: center;
|
||||||
|
flex-direction: column;
|
||||||
|
button {
|
||||||
|
margin: 0.2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.h {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
25
src/app/admin/admin.component.spec.ts
Normal file
25
src/app/admin/admin.component.spec.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { AdminComponent } from './admin.component';
|
||||||
|
|
||||||
|
describe('AdminComponent', () => {
|
||||||
|
let component: AdminComponent;
|
||||||
|
let fixture: ComponentFixture<AdminComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [ AdminComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(AdminComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
83
src/app/admin/admin.component.ts
Normal file
83
src/app/admin/admin.component.ts
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'
|
||||||
|
import { map, first } from 'rxjs/operators'
|
||||||
|
|
||||||
|
import { ActionService } from '../action.service'
|
||||||
|
import { MessageService } from '../message.service'
|
||||||
|
|
||||||
|
import { AuthService } from '../services/auth.service'
|
||||||
|
import { ConfigService } from '../config.service'
|
||||||
|
import { User } from '../models'
|
||||||
|
import { DataService, pendingTreeDbName } from '../data.service'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
selector: 'app-admin',
|
||||||
|
templateUrl: './admin.component.html',
|
||||||
|
styleUrls: ['./admin.component.scss']
|
||||||
|
})
|
||||||
|
export class AdminComponent implements OnInit {
|
||||||
|
user: User
|
||||||
|
pendingTreeDbName = pendingTreeDbName
|
||||||
|
pendingTreesCount: number
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public actionService: ActionService,
|
||||||
|
public messageService: MessageService,
|
||||||
|
public authService: AuthService,
|
||||||
|
public cdr: ChangeDetectorRef,
|
||||||
|
public configService: ConfigService,
|
||||||
|
public dataService: DataService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.updatePendingTrees()
|
||||||
|
this.dataService.updatePendingTrees$.subscribe(
|
||||||
|
() => this.updatePendingTrees()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePlantekeyData() {
|
||||||
|
this.actionService.updatePlantekeyData().subscribe({
|
||||||
|
complete: () => {
|
||||||
|
this.messageService.message.next('Update server data successful')
|
||||||
|
},
|
||||||
|
error: error => {
|
||||||
|
console.error(error)
|
||||||
|
this.messageService.message.next(`Update failed: ${error.statusText}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePlantekeyImages() {
|
||||||
|
this.actionService.updatePlantekeyImages().subscribe({
|
||||||
|
complete: () => {
|
||||||
|
this.messageService.message.next('Update server images successful')
|
||||||
|
},
|
||||||
|
error: error => {
|
||||||
|
console.error(error)
|
||||||
|
this.messageService.message.next(`Update failed: ${error.statusText}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
makeAttachmentsTarFile() {
|
||||||
|
this.actionService.makeAttachmentsTarFile().subscribe({
|
||||||
|
complete: () => {
|
||||||
|
this.messageService.message.next('Tar file creation successful')
|
||||||
|
},
|
||||||
|
error: error => {
|
||||||
|
console.error(error)
|
||||||
|
this.messageService.message.next(`Update failed: ${error.statusText}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePendingTrees() {
|
||||||
|
this.dataService.dbService.count(pendingTreeDbName).pipe(first()).pipe(map(
|
||||||
|
count => {
|
||||||
|
this.pendingTreesCount = count
|
||||||
|
this.cdr.markForCheck()
|
||||||
|
}
|
||||||
|
)).subscribe()
|
||||||
|
}
|
||||||
|
}
|
91
src/app/app-routing.module.ts
Normal file
91
src/app/app-routing.module.ts
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
import { NgModule, inject } from '@angular/core'
|
||||||
|
import {
|
||||||
|
ActivatedRouteSnapshot, ResolveFn, RouterModule,
|
||||||
|
RouterStateSnapshot, Routes
|
||||||
|
} from '@angular/router'
|
||||||
|
|
||||||
|
import { HomeComponent } from './home/home.component'
|
||||||
|
import { IntroComponent } from './intro/intro.component'
|
||||||
|
import { MapViewComponent } from './map-view/map-view.component'
|
||||||
|
import { PlantBrowserComponent } from './plant-browser/plant-browser.component'
|
||||||
|
import { PlantListComponent } from './plant-list/plant-list.component'
|
||||||
|
import { PlantDetailComponent } from './plant-detail/plant-detail.component'
|
||||||
|
import { TreeDetailComponent } from './tree-detail/tree-detail.component'
|
||||||
|
import { SettingsComponent } from './settings/settings.component'
|
||||||
|
import { AdminComponent } from './admin/admin.component'
|
||||||
|
import { AboutComponent } from './about/about.component'
|
||||||
|
import { TrailListComponent } from './trail-list/trail-list.component'
|
||||||
|
import { TrailDetailComponent } from './trail-detail/trail-detail.component'
|
||||||
|
import { LoginComponent } from './login/login.component'
|
||||||
|
import { ProfileComponent } from './profile/profile.component'
|
||||||
|
import { AuthGuardService } from './services/auth-guard.service'
|
||||||
|
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{ path: '', redirectTo: '/home', pathMatch: 'full' },
|
||||||
|
{
|
||||||
|
path: 'home',
|
||||||
|
component: HomeComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
component: SettingsComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'login',
|
||||||
|
component: LoginComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'profile',
|
||||||
|
component: ProfileComponent,
|
||||||
|
canActivate: [AuthGuardService]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'trail',
|
||||||
|
component: TrailListComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'trail/:id',
|
||||||
|
component: TrailDetailComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'admin',
|
||||||
|
component: AdminComponent,
|
||||||
|
canActivate: [AuthGuardService]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'map',
|
||||||
|
component: MapViewComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'intro',
|
||||||
|
component: IntroComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'plant-table',
|
||||||
|
component: PlantBrowserComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tree/:pekid',
|
||||||
|
component: TreeDetailComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'plant/:pekid',
|
||||||
|
component: PlantDetailComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'plant',
|
||||||
|
component: PlantListComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'about',
|
||||||
|
component: AboutComponent,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forRoot(routes)],
|
||||||
|
exports: [RouterModule]
|
||||||
|
})
|
||||||
|
export class AppRoutingModule { }
|
39
src/app/app-update.service.ts
Normal file
39
src/app/app-update.service.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import { Injectable } from '@angular/core'
|
||||||
|
import { SwUpdate } from '@angular/service-worker'
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AppUpdateService {
|
||||||
|
constructor(
|
||||||
|
private readonly updates: SwUpdate,
|
||||||
|
) {
|
||||||
|
this.updates.versionUpdates.subscribe(
|
||||||
|
evt => {
|
||||||
|
switch (evt.type) {
|
||||||
|
case 'VERSION_DETECTED':
|
||||||
|
console.log(`Downloading new app version: ${evt.version.hash}`);
|
||||||
|
break;
|
||||||
|
case 'VERSION_READY':
|
||||||
|
console.log(`Current app version: ${evt.currentVersion.hash}`);
|
||||||
|
console.log(`New app version ready for use: ${evt.latestVersion.hash}`);
|
||||||
|
this.showAppUpdateAlert()
|
||||||
|
break;
|
||||||
|
case 'VERSION_INSTALLATION_FAILED':
|
||||||
|
console.log(`Failed to install app version '${evt.version.hash}': ${evt.error}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
showAppUpdateAlert() {
|
||||||
|
const message = 'App Update available - click OK to update'
|
||||||
|
alert(message)
|
||||||
|
this.doAppUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
doAppUpdate() {
|
||||||
|
this.updates.activateUpdate().then(() => document.location.reload())
|
||||||
|
}
|
||||||
|
}
|
2
src/app/app.component.html
Normal file
2
src/app/app.component.html
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<app-nav>
|
||||||
|
</app-nav>
|
0
src/app/app.component.scss
Normal file
0
src/app/app.component.scss
Normal file
35
src/app/app.component.spec.ts
Normal file
35
src/app/app.component.spec.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
|
import { AppComponent } from './app.component';
|
||||||
|
|
||||||
|
describe('AppComponent', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
RouterTestingModule
|
||||||
|
],
|
||||||
|
declarations: [
|
||||||
|
AppComponent
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create the app', () => {
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
expect(app).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should have as title 'treetrail'`, () => {
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
expect(app.title).toEqual('treetrail');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render title', () => {
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const compiled = fixture.nativeElement;
|
||||||
|
expect(compiled.querySelector('.content span').textContent).toContain('treetrail app is running!');
|
||||||
|
});
|
||||||
|
});
|
29
src/app/app.component.ts
Normal file
29
src/app/app.component.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { Component, OnInit,
|
||||||
|
ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core'
|
||||||
|
import { DataService } from './data.service'
|
||||||
|
|
||||||
|
import { ActionService } from './action.service'
|
||||||
|
import { AppUpdateService } from './app-update.service'
|
||||||
|
import { ConfigService, settingsDbName } from './config.service'
|
||||||
|
import { combineLatest } from 'rxjs'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
templateUrl: './app.component.html',
|
||||||
|
styleUrls: ['./app.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class AppComponent implements OnInit {
|
||||||
|
constructor(
|
||||||
|
public dataService: DataService,
|
||||||
|
public configService: ConfigService,
|
||||||
|
public actionService: ActionService,
|
||||||
|
public appUpdateService: AppUpdateService,
|
||||||
|
public cdr: ChangeDetectorRef,
|
||||||
|
) {}
|
||||||
|
title = 'treetrail'
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.actionService.fetchData().subscribe()
|
||||||
|
}
|
||||||
|
}
|
275
src/app/app.module.ts
Normal file
275
src/app/app.module.ts
Normal file
|
@ -0,0 +1,275 @@
|
||||||
|
import { NgModule, APP_INITIALIZER } from '@angular/core'
|
||||||
|
import { BrowserModule } from '@angular/platform-browser'
|
||||||
|
import { APP_BASE_HREF } from '@angular/common'
|
||||||
|
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
import { ServiceWorkerModule } from '@angular/service-worker'
|
||||||
|
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
|
||||||
|
import { Observable, combineLatest, map } from 'rxjs'
|
||||||
|
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
|
|
||||||
|
import { LayoutModule } from '@angular/cdk/layout'
|
||||||
|
import { ScrollingModule } from '@angular/cdk/scrolling'
|
||||||
|
|
||||||
|
import { MatToolbarModule } from '@angular/material/toolbar'
|
||||||
|
import { MatButtonModule } from '@angular/material/button'
|
||||||
|
import { MatButtonToggleModule } from '@angular/material/button-toggle'
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field'
|
||||||
|
import { MatInputModule } from '@angular/material/input'
|
||||||
|
import { MatSidenavModule } from '@angular/material/sidenav'
|
||||||
|
import { MatIconModule } from '@angular/material/icon'
|
||||||
|
import { MatListModule } from '@angular/material/list'
|
||||||
|
import { MatGridListModule } from '@angular/material/grid-list'
|
||||||
|
import { MatCardModule } from '@angular/material/card'
|
||||||
|
import { MatMenuModule } from '@angular/material/menu'
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'
|
||||||
|
import { MatTableModule } from "@angular/material/table"
|
||||||
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle'
|
||||||
|
import { MatSortModule } from '@angular/material/sort'
|
||||||
|
import { MatTooltipModule } from '@angular/material/tooltip'
|
||||||
|
import { MatExpansionModule } from '@angular/material/expansion'
|
||||||
|
import { MatSnackBarModule } from '@angular/material/snack-bar'
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox'
|
||||||
|
import { MatDialogModule } from '@angular/material/dialog'
|
||||||
|
import { MatAutocompleteModule } from '@angular/material/autocomplete'
|
||||||
|
import { MatSelectModule } from '@angular/material/select'
|
||||||
|
import { MatStepperModule } from '@angular/material/stepper'
|
||||||
|
import { MatSliderModule } from '@angular/material/slider'
|
||||||
|
import { MatChipsModule } from '@angular/material/chips'
|
||||||
|
|
||||||
|
import { NgxIndexedDBModule, DBConfig } from 'ngx-indexed-db'
|
||||||
|
import { NgxMapLibreGLModule } from '@maplibre/ngx-maplibre-gl'
|
||||||
|
|
||||||
|
import { environment } from '../environments/environment'
|
||||||
|
|
||||||
|
import { AppRoutingModule } from './app-routing.module'
|
||||||
|
import { AppComponent } from './app.component'
|
||||||
|
import { NavComponent } from './nav/nav.component'
|
||||||
|
|
||||||
|
import { ActionService } from './action.service'
|
||||||
|
import { MessageService } from './message.service'
|
||||||
|
import { FeatureFinderService } from './feature-finder.service'
|
||||||
|
import { AppUpdateService } from './app-update.service'
|
||||||
|
import { ConfigService, dbName, settingsDbName } from './config.service'
|
||||||
|
import { PlantekeyService } from './plantekey.service'
|
||||||
|
import { DndDirective } from './directives/dnd.directive'
|
||||||
|
import { TreeTrailMapEditControlDirective } from './map/map-edit/edit-map-control.directive'
|
||||||
|
import { MapEditService } from './map/map-edit/map-edit.service'
|
||||||
|
|
||||||
|
import { MapComponent } from './map/map.component'
|
||||||
|
import { HomeComponent } from './home/home.component'
|
||||||
|
import { IntroComponent } from './intro/intro.component'
|
||||||
|
import { MessageComponent } from './message/message.component'
|
||||||
|
import { IndicatorComponent } from './indicator/indicator.component'
|
||||||
|
import { PlantBrowserComponent } from './plant-browser/plant-browser.component'
|
||||||
|
import { PlantListItemComponent } from './plant-list-item/plant-list-item.component'
|
||||||
|
import { SettingsComponent } from './settings/settings.component'
|
||||||
|
import { AdminComponent } from './admin/admin.component'
|
||||||
|
import { AboutComponent } from './about/about.component'
|
||||||
|
import { PlantListComponent } from './plant-list/plant-list.component'
|
||||||
|
import { PlantDetailComponent } from './plant-detail/plant-detail.component'
|
||||||
|
import { TrailListComponent } from './trail-list/trail-list.component'
|
||||||
|
import { TrailListItemComponent } from './trail-list-item/trail-list-item.component'
|
||||||
|
import { TrailDetailComponent } from './trail-detail/trail-detail.component'
|
||||||
|
import { TreeDetailComponent } from './tree-detail/tree-detail.component'
|
||||||
|
import { MapInfoComponent } from './map-info/map-info.component'
|
||||||
|
import { MapViewComponent } from './map-view/map-view.component'
|
||||||
|
import { TreetrailDirectionComponent } from './map/direction.component'
|
||||||
|
import { LoginComponent } from './login/login.component'
|
||||||
|
import { ProfileComponent } from './profile/profile.component'
|
||||||
|
import { InterceptorService } from './services/interceptor-service.service'
|
||||||
|
import { TreePopupComponent } from './tree-popup/tree-popup.component'
|
||||||
|
import { PoiPopupComponent } from './poi-popup/poi-popup.component'
|
||||||
|
import { ZonePopupComponent } from './zone-popup/zone-popup.component'
|
||||||
|
import { MapEditComponent } from './map/map-edit/map-edit.component'
|
||||||
|
import { PlantChooserDialogComponent } from './map/map-edit/plant-chooser-dialog'
|
||||||
|
import { AppControlComponent } from './map/app-control.component'
|
||||||
|
|
||||||
|
// import { DefaultService } from './openapi/services'
|
||||||
|
import { DataService } from './data.service'
|
||||||
|
|
||||||
|
const dbConfig: DBConfig = {
|
||||||
|
name: dbName,
|
||||||
|
version: 4,
|
||||||
|
objectStoresMeta: [
|
||||||
|
{
|
||||||
|
store: 'tree',
|
||||||
|
storeConfig: { keyPath: 'id', autoIncrement: false },
|
||||||
|
storeSchema: [
|
||||||
|
//{ name: 'userName', keypath: 'userName', options: { unique: true } },
|
||||||
|
//{ name: 'skipIntre', keypath: 'skipIntre', options: { unique: true } },
|
||||||
|
//{ name: 'showZones', keypath: 'showZones', options: { unique: true } },
|
||||||
|
//{ name: 'vibrate', keypath: 'vibrate', options: { unique: true } },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
store: 'trail',
|
||||||
|
storeConfig: { keyPath: 'id', autoIncrement: false },
|
||||||
|
storeSchema: [
|
||||||
|
//{ name: 'comment', keypath: 'name', options: { unique: false } },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
store: 'plant',
|
||||||
|
storeConfig: { keyPath: 'id', autoIncrement: false },
|
||||||
|
storeSchema: [
|
||||||
|
//{ name: 'comment', keypath: 'name', options: { unique: false } },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
store: 'pendingTree',
|
||||||
|
storeConfig: { keyPath: 'id', autoIncrement: true },
|
||||||
|
storeSchema: [
|
||||||
|
//{ name: 'comment', keypath: 'name', options: { unique: false } },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
store: 'characteristics',
|
||||||
|
storeConfig: { keyPath: 'id', autoIncrement: false },
|
||||||
|
storeSchema: [
|
||||||
|
//{ name: 'comment', keypath: 'name', options: { unique: false } },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
store: 'img',
|
||||||
|
storeConfig: { keyPath: 'id', autoIncrement: false },
|
||||||
|
storeSchema: [
|
||||||
|
//{ name: 'comment', keypath: 'name', options: { unique: false } },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
store: 'settings',
|
||||||
|
storeConfig: { keyPath: 'key', autoIncrement: false },
|
||||||
|
storeSchema: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeAppFactory(
|
||||||
|
configService: ConfigService,
|
||||||
|
dataService: DataService,
|
||||||
|
): () => Observable<void> {
|
||||||
|
return () => combineLatest([
|
||||||
|
dataService.dbService.getAll(settingsDbName),
|
||||||
|
configService.bootstrap(),
|
||||||
|
]).pipe(map(([dbData, bootstrap]) => {
|
||||||
|
configService.loadUserSettings(dbData)
|
||||||
|
if (!configService.conf.value.mapPos) {
|
||||||
|
configService.conf.value.mapPos = {
|
||||||
|
center: { lat: bootstrap.map.lat, lon: bootstrap.map.lng },
|
||||||
|
zoom: bootstrap.map.zoom,
|
||||||
|
bearing: 0,
|
||||||
|
pitch: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!configService.conf.value.background) {
|
||||||
|
configService.conf.value.background = bootstrap.map.background
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
AppComponent,
|
||||||
|
NavComponent,
|
||||||
|
MapComponent,
|
||||||
|
HomeComponent,
|
||||||
|
IntroComponent,
|
||||||
|
MessageComponent,
|
||||||
|
IndicatorComponent,
|
||||||
|
PlantBrowserComponent,
|
||||||
|
PlantListItemComponent,
|
||||||
|
SettingsComponent,
|
||||||
|
AdminComponent,
|
||||||
|
AboutComponent,
|
||||||
|
PlantListComponent,
|
||||||
|
PlantDetailComponent,
|
||||||
|
TrailListComponent,
|
||||||
|
TrailListItemComponent,
|
||||||
|
TrailDetailComponent,
|
||||||
|
TreeDetailComponent,
|
||||||
|
MapInfoComponent,
|
||||||
|
MapViewComponent,
|
||||||
|
TreetrailDirectionComponent,
|
||||||
|
LoginComponent,
|
||||||
|
ProfileComponent,
|
||||||
|
TreePopupComponent,
|
||||||
|
PoiPopupComponent,
|
||||||
|
ZonePopupComponent,
|
||||||
|
DndDirective,
|
||||||
|
TreeTrailMapEditControlDirective,
|
||||||
|
MapEditComponent,
|
||||||
|
PlantChooserDialogComponent,
|
||||||
|
AppControlComponent,
|
||||||
|
],
|
||||||
|
bootstrap: [AppComponent],
|
||||||
|
imports: [
|
||||||
|
BrowserModule,
|
||||||
|
AppRoutingModule,
|
||||||
|
BrowserAnimationsModule,
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
LayoutModule,
|
||||||
|
ScrollingModule,
|
||||||
|
MatToolbarModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatButtonToggleModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSidenavModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatListModule,
|
||||||
|
MatGridListModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatMenuModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatTableModule,
|
||||||
|
MatSlideToggleModule,
|
||||||
|
MatSortModule,
|
||||||
|
MatTooltipModule,
|
||||||
|
MatExpansionModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatAutocompleteModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatStepperModule,
|
||||||
|
MatSliderModule,
|
||||||
|
MatChipsModule,
|
||||||
|
NgxMapLibreGLModule,
|
||||||
|
NgxIndexedDBModule.forRoot(dbConfig),
|
||||||
|
// ServiceWorkerModule.register('ngsw-worker-custom.js', {
|
||||||
|
ServiceWorkerModule.register('ngsw-worker.js', {
|
||||||
|
enabled: environment.production,
|
||||||
|
// Register the ServiceWorker as soon as the app is stable
|
||||||
|
// or after 30 seconds (whichever comes first).
|
||||||
|
registrationStrategy: 'registerWhenStable:30000'
|
||||||
|
})],
|
||||||
|
providers: [
|
||||||
|
ActionService,
|
||||||
|
MessageService,
|
||||||
|
FeatureFinderService,
|
||||||
|
PlantekeyService,
|
||||||
|
MapEditService,
|
||||||
|
AppUpdateService,
|
||||||
|
ConfigService,
|
||||||
|
// DefaultService,
|
||||||
|
{
|
||||||
|
provide: HTTP_INTERCEPTORS,
|
||||||
|
useClass: InterceptorService,
|
||||||
|
multi: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
useFactory: initializeAppFactory,
|
||||||
|
deps: [ConfigService, DataService],
|
||||||
|
multi: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_BASE_HREF,
|
||||||
|
useValue: '/treetrail/'
|
||||||
|
},
|
||||||
|
provideHttpClient(withInterceptorsFromDi())
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class AppModule { }
|
156
src/app/config.service.ts
Normal file
156
src/app/config.service.ts
Normal file
|
@ -0,0 +1,156 @@
|
||||||
|
import { Injectable, Component } from "@angular/core"
|
||||||
|
import { Observable, BehaviorSubject, Subject, ReplaySubject, combineLatest } from 'rxjs'
|
||||||
|
import { map, take } from 'rxjs/operators'
|
||||||
|
|
||||||
|
import { LngLat, LngLatLike } from "maplibre-gl"
|
||||||
|
import { DataService } from "./data.service"
|
||||||
|
import { DefaultService } from "./openapi/services.gen"
|
||||||
|
import { Bootstrap, Map } from "./openapi/types.gen"
|
||||||
|
|
||||||
|
export type MapPos = {
|
||||||
|
center: LngLatLike,
|
||||||
|
zoom: number,
|
||||||
|
bearing: number,
|
||||||
|
pitch: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dbName = 'treetrail' // See also dbConfig in app.module
|
||||||
|
export const settingsDbName = 'settings'
|
||||||
|
|
||||||
|
export class Config {
|
||||||
|
constructor(
|
||||||
|
public user: string = undefined,
|
||||||
|
public skipIntro: boolean = false,
|
||||||
|
public vibrate: boolean = true,
|
||||||
|
public showZones: { [zone: string]: boolean } = {},
|
||||||
|
public alertDistance = 250,
|
||||||
|
public map?: Map,
|
||||||
|
public bootstrap?: Bootstrap,
|
||||||
|
public mapPos?: MapPos,
|
||||||
|
public background: string = undefined,
|
||||||
|
// public server: {} = {},
|
||||||
|
// public client: {} = {},
|
||||||
|
// public app: {} = {},
|
||||||
|
) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ConfigService {
|
||||||
|
constructor(
|
||||||
|
public dataService: DataService,
|
||||||
|
private api: DefaultService,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public conf: BehaviorSubject<Config> // = new BehaviorSubject<Config>(new Config())
|
||||||
|
|
||||||
|
userPrefsKeyList = ['userName', 'skipIntro', 'vibrate', 'showZones', 'background']
|
||||||
|
|
||||||
|
bootstrap(): Observable<Bootstrap> {
|
||||||
|
return this.api.getBootstrapBootstrapGet().pipe(map(
|
||||||
|
resp => {
|
||||||
|
this.conf = new BehaviorSubject<Config>(new Config(
|
||||||
|
'',
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
{},
|
||||||
|
250,
|
||||||
|
resp.map,
|
||||||
|
resp,
|
||||||
|
undefined,
|
||||||
|
))
|
||||||
|
// this.conf.value.bootstrap = resp
|
||||||
|
// this.conf.value.map = resp.map
|
||||||
|
// this.conf.next(this.conf.value)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
getMapCenter(): Observable<LngLatLike> {
|
||||||
|
return this.conf.pipe(map(
|
||||||
|
conf => <LngLatLike>{
|
||||||
|
lng: conf.map.lng,
|
||||||
|
lat: conf.map.lat
|
||||||
|
}
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
loadUserSettings(data: unknown[]) {
|
||||||
|
// TODO: assert the whole idea and use of storing the config in a BahaviourSubject
|
||||||
|
data.forEach(kv => {
|
||||||
|
if (kv['value']) {
|
||||||
|
this.conf.value[kv['key']] = kv['value']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Update the list of types of zones from actual data
|
||||||
|
this.dataService.all.subscribe(
|
||||||
|
all => {
|
||||||
|
let zoneTypes = new Set((Object.values(all.zones).map(
|
||||||
|
zone => zone.type
|
||||||
|
)))
|
||||||
|
zoneTypes.forEach(
|
||||||
|
zoneType => {
|
||||||
|
if (this.conf.value.showZones[zoneType] === undefined) {
|
||||||
|
this.conf.value.showZones[zoneType] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
this.conf.next(this.conf.value)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// if (!this.conf.value.skipIntro) {
|
||||||
|
// this.router.navigate(['intro'], {relativeTo: this.route});
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
storeUserData(): void {
|
||||||
|
this.userPrefsKeyList.forEach(
|
||||||
|
key => this.dataService.dbService.update(
|
||||||
|
settingsDbName, {
|
||||||
|
key: key,
|
||||||
|
value: this.conf.value[key]
|
||||||
|
}).subscribe()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserPrefValue(pref: string, key: string, value: any) {
|
||||||
|
let conf = this.conf.value
|
||||||
|
conf[pref][key] = value
|
||||||
|
this.conf.next(conf)
|
||||||
|
this.storeUserData()
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserPref(pref: string, value: any) {
|
||||||
|
let conf = this.conf.value
|
||||||
|
// userName is special, read (and thus stored) in bootstrap
|
||||||
|
if (pref == 'userName') {
|
||||||
|
conf.bootstrap.user = value
|
||||||
|
this.conf.next(this.conf.value)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
conf[pref] = value
|
||||||
|
}
|
||||||
|
this.conf.next(conf)
|
||||||
|
this.storeUserData()
|
||||||
|
}
|
||||||
|
|
||||||
|
setMapPos(mapPos: MapPos): Observable<unknown> {
|
||||||
|
this.updateConf({mapPos: mapPos})
|
||||||
|
return this.dataService.dbService.update(settingsDbName, {
|
||||||
|
key: 'mapPos',
|
||||||
|
value: {
|
||||||
|
center: mapPos.center,
|
||||||
|
zoom: mapPos.zoom,
|
||||||
|
pitch: mapPos.pitch,
|
||||||
|
bearing: mapPos.bearing,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConf(newConf: Object) {
|
||||||
|
this.conf.pipe(take(1)).subscribe(
|
||||||
|
conf => this.conf.next({ ...conf, ...newConf })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
16
src/app/data.service.spec.ts
Normal file
16
src/app/data.service.spec.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { DataService } from './data.service';
|
||||||
|
|
||||||
|
describe('DataService', () => {
|
||||||
|
let service: DataService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
service = TestBed.inject(DataService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
280
src/app/data.service.ts
Normal file
280
src/app/data.service.ts
Normal file
|
@ -0,0 +1,280 @@
|
||||||
|
import { ApplicationRef, Injectable } from '@angular/core'
|
||||||
|
import { BehaviorSubject, Observable, Subject, combineLatest } from 'rxjs'
|
||||||
|
import { HttpClient } from '@angular/common/http'
|
||||||
|
import { Router } from '@angular/router'
|
||||||
|
import { catchError, first, map } from 'rxjs/operators'
|
||||||
|
import { throwError } from 'rxjs'
|
||||||
|
|
||||||
|
import { v1 as uuidv1 } from 'uuid'
|
||||||
|
import { GeoJSONSource, LngLat } from 'maplibre-gl'
|
||||||
|
import { NgxIndexedDBService } from 'ngx-indexed-db'
|
||||||
|
|
||||||
|
import { PlantsTrails, Trail, Trails, Trees, Pois,
|
||||||
|
Zones, TreeTrail, All, Plant, Tree } from './models'
|
||||||
|
import { MessageService } from './message.service'
|
||||||
|
import { Feature } from 'geojson'
|
||||||
|
|
||||||
|
export const pendingTreeDbName = 'pendingTree'
|
||||||
|
|
||||||
|
export class TreeDef {
|
||||||
|
constructor(
|
||||||
|
public plant: Plant,
|
||||||
|
public trails: Trail[],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NewTree {
|
||||||
|
id: string
|
||||||
|
constructor(
|
||||||
|
public treeDef: TreeDef,
|
||||||
|
public lngLat: LngLat,
|
||||||
|
public dataService: DataService,
|
||||||
|
public picture?: string,
|
||||||
|
public uuid1?: string,
|
||||||
|
public pending?: number,
|
||||||
|
public details: Object = {}
|
||||||
|
) {
|
||||||
|
if (!this.uuid1) {
|
||||||
|
this.uuid1 = uuidv1()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getFeature(): Feature {
|
||||||
|
return {
|
||||||
|
'type': 'Feature',
|
||||||
|
'geometry': {
|
||||||
|
'type': 'Point',
|
||||||
|
'coordinates': [this.lngLat.lng, this.lngLat.lat, 0]
|
||||||
|
},
|
||||||
|
'properties': {
|
||||||
|
'plantekey_id': this.treeDef.plant.id,
|
||||||
|
'symbol': this.dataService.all.value.plants[this.treeDef.plant.id].symbol,
|
||||||
|
'id': this.id || this.uuid1,
|
||||||
|
'pending': this.pending
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getForStorage(): Object {
|
||||||
|
let so = {
|
||||||
|
feature: this.getFeature(),
|
||||||
|
trails: this.treeDef.trails.map(trail => trail.id),
|
||||||
|
uuid1: this.uuid1,
|
||||||
|
id: this.uuid1,
|
||||||
|
details: this.details
|
||||||
|
}
|
||||||
|
if (this.picture) {
|
||||||
|
so['picture'] = this.picture
|
||||||
|
}
|
||||||
|
return so
|
||||||
|
}
|
||||||
|
|
||||||
|
getFormData(): FormData {
|
||||||
|
let formData = new FormData()
|
||||||
|
formData.set('plantekey_id', this.treeDef.plant.id)
|
||||||
|
if (this.treeDef.trails.length > 0) {
|
||||||
|
formData.set('trail_ids', this.treeDef.trails.map(
|
||||||
|
trail => trail.id.toString()).join(',')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
formData.set('lng', this.lngLat.lng.toString())
|
||||||
|
formData.set('lat', this.lngLat.lat.toString())
|
||||||
|
formData.set('uuid1', this.uuid1)
|
||||||
|
if (Object.keys(this.details).length > 0) {
|
||||||
|
formData.set('details', JSON.stringify(this.details))
|
||||||
|
}
|
||||||
|
if (this.picture) {
|
||||||
|
formData.set('picture', this.picture)
|
||||||
|
}
|
||||||
|
return formData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class DataService {
|
||||||
|
constructor(
|
||||||
|
public httpClient: HttpClient,
|
||||||
|
public messageService: MessageService,
|
||||||
|
public dbService: NgxIndexedDBService,
|
||||||
|
public router: Router,
|
||||||
|
public appRef: ApplicationRef,
|
||||||
|
) {
|
||||||
|
this.all.subscribe(
|
||||||
|
all => {
|
||||||
|
// Assign plants and trails to trees
|
||||||
|
Object.values(all.trees).forEach(
|
||||||
|
tree => {
|
||||||
|
tree.plant = all.plants[tree.plantekeyId]
|
||||||
|
let trail_ids = new Set(all.tree_trails.filter(
|
||||||
|
tt => tt.tree_id == tree.id
|
||||||
|
).map(tt => tt.trail_id))
|
||||||
|
tree.trails = Object.values(all.trails).filter(
|
||||||
|
trail => trail_ids.has(+trail.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public trailFeatures: BehaviorSubject<GeoJSON.FeatureCollection> = new BehaviorSubject<GeoJSON.FeatureCollection>(<GeoJSON.FeatureCollection>{})
|
||||||
|
public treeFeatures: BehaviorSubject<GeoJSON.FeatureCollection> = new BehaviorSubject<GeoJSON.FeatureCollection>(<GeoJSON.FeatureCollection>{})
|
||||||
|
public poisFeatures: BehaviorSubject<GeoJSON.FeatureCollection> = new BehaviorSubject<GeoJSON.FeatureCollection>(<GeoJSON.FeatureCollection>{})
|
||||||
|
public zoneFeatures: BehaviorSubject<GeoJSON.FeatureCollection> = new BehaviorSubject<GeoJSON.FeatureCollection>(<GeoJSON.FeatureCollection>{})
|
||||||
|
//public treesUpdated: Observable<void>
|
||||||
|
|
||||||
|
public trails: BehaviorSubject<Trails> = new BehaviorSubject<Trails>({})
|
||||||
|
//public trails$ = this.trails.asObservable()
|
||||||
|
|
||||||
|
public tree_trail: BehaviorSubject<TreeTrail[]> = new BehaviorSubject<TreeTrail[]>([])
|
||||||
|
//public tree_trail$ = this.tree_trail.asObservable()
|
||||||
|
|
||||||
|
//public trees: BehaviorSubject<Tree[]> = new BehaviorSubject<Tree[]>([])
|
||||||
|
public trees: BehaviorSubject<Trees> = new BehaviorSubject<Trees>({})
|
||||||
|
//public trees$ = this.trees.asObservable()
|
||||||
|
public pois: BehaviorSubject<Pois> = new BehaviorSubject<Pois>({})
|
||||||
|
public zones: BehaviorSubject<Zones> = new BehaviorSubject<Zones>({})
|
||||||
|
|
||||||
|
public all: BehaviorSubject<All> = new BehaviorSubject<All>(new All())
|
||||||
|
|
||||||
|
public addTree$: Subject<NewTree> = new Subject<NewTree>()
|
||||||
|
|
||||||
|
public updatePendingTrees$: Subject<void> = new Subject()
|
||||||
|
|
||||||
|
set_tree_trail(data: TreeTrail[]) {
|
||||||
|
this.tree_trail.next(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
plant_trail: PlantsTrails = {}
|
||||||
|
|
||||||
|
plant_in_some_trail(plantId: string): boolean {
|
||||||
|
return plantId in this.plant_trail
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to send to server, falling back to local storage for later sync
|
||||||
|
addTree(location: LngLat, treeDef: TreeDef, picture?: string, details: Object = {}) {
|
||||||
|
const newTree = new NewTree(treeDef, location, this, picture, undefined, 1, details)
|
||||||
|
this.httpClient.post('v1/tree', newTree.getFormData()).pipe(
|
||||||
|
map(data => {
|
||||||
|
// Got an id from the server for that tree
|
||||||
|
newTree.id = data['id']
|
||||||
|
this.addTree$.next(newTree)
|
||||||
|
}),
|
||||||
|
catchError(err => {
|
||||||
|
this.messageService.message.next(`Cannot save: (${err.statusText}). ` +
|
||||||
|
'It is stored in the local device and will be saved later.')
|
||||||
|
this.addTree$.next(newTree)
|
||||||
|
this.addPendingTree(newTree)
|
||||||
|
return new Observable()
|
||||||
|
})
|
||||||
|
).subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in local database to sync it with the server when possible
|
||||||
|
addPendingTree(newTree: NewTree) {
|
||||||
|
this.dbService.add(pendingTreeDbName, newTree.getForStorage()).subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a geo feature source of pending trees (from local indexedDB)
|
||||||
|
getPendingTrees(): Observable<Object[]> {
|
||||||
|
return this.dbService.getAll(pendingTreeDbName)
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingTree(id: string): Observable<Tree> {
|
||||||
|
return this.dbService.getByID(pendingTreeDbName, id).pipe(map(
|
||||||
|
data => {
|
||||||
|
const feature = data['feature']
|
||||||
|
let tree = new Tree(
|
||||||
|
feature['properties']['id'],
|
||||||
|
feature,
|
||||||
|
feature['properties']['plantekey_id'],
|
||||||
|
data['picture'],
|
||||||
|
)
|
||||||
|
tree.plant = this.all.value.plants[feature['properties']['plantekey_id']]
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
deletePendingTrees() {
|
||||||
|
this.getPendingTrees().subscribe(
|
||||||
|
trees => this.dbService.bulkDelete(
|
||||||
|
pendingTreeDbName,
|
||||||
|
trees.map(tree => tree['id'])
|
||||||
|
).subscribe(_ => {
|
||||||
|
this.updatePendingTrees$.next()
|
||||||
|
this.appRef.tick()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempts to send the pending trees to the server
|
||||||
|
syncPendingTrees(canRedirectToLogin: boolean=false) {
|
||||||
|
this.dbService.count(pendingTreeDbName).pipe(first()).subscribe(
|
||||||
|
count => {
|
||||||
|
if (count > 0) {
|
||||||
|
combineLatest([
|
||||||
|
this.getPendingTrees(),
|
||||||
|
this.all
|
||||||
|
]).subscribe(
|
||||||
|
([treeList, all]) => {
|
||||||
|
if (Object.keys(all.trees).length == 0) { return }
|
||||||
|
treeList.map(
|
||||||
|
tr => {
|
||||||
|
const feature = tr['feature']
|
||||||
|
let newTree = new NewTree(
|
||||||
|
new TreeDef(
|
||||||
|
all.plants[feature['properties']['plantekey_id']],
|
||||||
|
[]
|
||||||
|
// this.all.getValue().trails.filter(
|
||||||
|
// trail => trail.
|
||||||
|
// )
|
||||||
|
),
|
||||||
|
new LngLat(
|
||||||
|
feature['geometry']['coordinates'][0],
|
||||||
|
feature['geometry']['coordinates'][1]
|
||||||
|
),
|
||||||
|
this,
|
||||||
|
tr['picture'],
|
||||||
|
tr['uuid1'],
|
||||||
|
undefined,
|
||||||
|
tr['details'],
|
||||||
|
)
|
||||||
|
this.httpClient.post('v1/tree', newTree.getFormData()).pipe(
|
||||||
|
map(data => {
|
||||||
|
this.dbService.deleteByKey(
|
||||||
|
pendingTreeDbName, feature['properties']['id']
|
||||||
|
).subscribe(
|
||||||
|
r => {
|
||||||
|
// Got an id from the server for that tree
|
||||||
|
newTree.id = data['id']
|
||||||
|
this.updatePendingTrees$.next()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
this.appRef.tick()
|
||||||
|
})
|
||||||
|
).subscribe({
|
||||||
|
error: (err) => {
|
||||||
|
if (err.status == 401) {
|
||||||
|
this.messageService.message.next(`Cannot save pending trees, you need to login again.`)
|
||||||
|
if (canRedirectToLogin) {
|
||||||
|
this.router.navigate(['/login'])
|
||||||
|
}
|
||||||
|
// TODO: resubmit pending trees
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.messageService.message.next(`Cannot save pending trees (${err.statusText}).`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
41
src/app/directives/dnd.directive.ts
Normal file
41
src/app/directives/dnd.directive.ts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import {
|
||||||
|
Directive,
|
||||||
|
Output,
|
||||||
|
Input,
|
||||||
|
EventEmitter,
|
||||||
|
HostBinding,
|
||||||
|
HostListener
|
||||||
|
} from '@angular/core';
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[app-dnd]'
|
||||||
|
})
|
||||||
|
export class DndDirective {
|
||||||
|
@HostBinding('class.fileover') fileOver: boolean;
|
||||||
|
@Output() fileDropped = new EventEmitter<any>();
|
||||||
|
|
||||||
|
// Dragover listener
|
||||||
|
@HostListener('dragover', ['$event']) onDragOver(evt) {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
this.fileOver = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dragleave listener
|
||||||
|
@HostListener('dragleave', ['$event']) public onDragLeave(evt) {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
this.fileOver = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop listener
|
||||||
|
@HostListener('drop', ['$event']) public ondrop(evt) {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
this.fileOver = false;
|
||||||
|
let files = evt.dataTransfer.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
this.fileDropped.emit(files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
16
src/app/feature-finder.service.spec.ts
Normal file
16
src/app/feature-finder.service.spec.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { FeatureFinderService } from './feature-finder.service';
|
||||||
|
|
||||||
|
describe('FeatureFinderService', () => {
|
||||||
|
let service: FeatureFinderService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
service = TestBed.inject(FeatureFinderService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
94
src/app/feature-finder.service.ts
Normal file
94
src/app/feature-finder.service.ts
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { BehaviorSubject } from 'rxjs'
|
||||||
|
|
||||||
|
import nearest from '@turf/nearest-point'
|
||||||
|
import distance from '@turf/distance'
|
||||||
|
import bearing from '@turf/bearing'
|
||||||
|
import { Feature } from 'maplibre-gl'
|
||||||
|
import { AbsoluteOrientationSensor } from 'motion-sensors-polyfill/src/motion-sensors'
|
||||||
|
|
||||||
|
import { ConfigService } from './config.service'
|
||||||
|
import { FeatureTarget } from './models'
|
||||||
|
import { DataService } from './data.service'
|
||||||
|
import { FeatureCollection } from 'geojson'
|
||||||
|
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class FeatureFinderService {
|
||||||
|
sensor: AbsoluteOrientationSensor
|
||||||
|
constructor(
|
||||||
|
public dataService: DataService,
|
||||||
|
public configService: ConfigService,
|
||||||
|
) {
|
||||||
|
this.sensor = new AbsoluteOrientationSensor()
|
||||||
|
this.sensor.start()
|
||||||
|
this.sensor.onerror = event => {
|
||||||
|
console.error("Error with AbsoluteOrientationSensor", event)
|
||||||
|
}
|
||||||
|
this.sensor.addEventListener('reading', () => {
|
||||||
|
const q = this.sensor.quaternion
|
||||||
|
const heading = Math.atan2(2*q[0]*q[1] + 2*q[2]*q[3], 1-2*q[1]*q[1] - 2*q[2]*q[2])*(180/Math.PI)
|
||||||
|
this.orientation.next(heading)
|
||||||
|
})
|
||||||
|
this.orientation$.subscribe(
|
||||||
|
orientation => this.findNewFeature()
|
||||||
|
)
|
||||||
|
this.findNewFeature()
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasDirection = new BehaviorSubject<boolean>(true)
|
||||||
|
public hasDirection$ = this.hasDirection.asObservable()
|
||||||
|
public direction = new BehaviorSubject<number>(undefined)
|
||||||
|
public direction$ = this.direction.asObservable()
|
||||||
|
public distance = new BehaviorSubject<number>(undefined)
|
||||||
|
public distance$ = this.distance.asObservable()
|
||||||
|
public orientation = new BehaviorSubject<number>(undefined)
|
||||||
|
public orientation$ = this.orientation.asObservable()
|
||||||
|
public location: GeolocationPosition
|
||||||
|
|
||||||
|
findNewFeature(location?: GeolocationPosition): FeatureTarget|undefined {
|
||||||
|
if (location) {
|
||||||
|
this.location = location
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
location = this.location
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(this.dataService.treeFeatures.getValue()).length != 0 && location) {
|
||||||
|
let loc = {
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": {},
|
||||||
|
"geometry": {
|
||||||
|
"type": "Point",
|
||||||
|
"coordinates": [location.coords.longitude, location.coords.latitude]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let trees: FeatureCollection = <any>this.dataService.treeFeatures.getValue()
|
||||||
|
if (trees['features'].length == 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
let nt = nearest(<any>loc, <any>trees)
|
||||||
|
let d = distance(<any>loc, nt, {units: 'meters'})
|
||||||
|
let b = bearing(<any>loc, nt)
|
||||||
|
if (d < this.configService.conf.value.alertDistance) {
|
||||||
|
this.distance.next(d)
|
||||||
|
if (this.orientation.value != undefined) {
|
||||||
|
this.hasDirection.next(true)
|
||||||
|
this.direction.next(b + this.orientation.value)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.hasDirection.next(false)
|
||||||
|
}
|
||||||
|
return new FeatureTarget(<Feature><any>nt, d, )
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.distance.next(undefined)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.distance.next(undefined)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
67
src/app/home/home.component.html
Normal file
67
src/app/home/home.component.html
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
<p class="intro">
|
||||||
|
Tree Trail is a fun and pedagogic tool to discover the trails and trees around.
|
||||||
|
</p>
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
<div class="content">
|
||||||
|
<h2>How to start?</h2>
|
||||||
|
<p>
|
||||||
|
If the main menu (on the left) is hidden, click on the top left icon <span class="nowrap">(<mat-icon>menu</mat-icon>)</span> to show it.
|
||||||
|
From there, 3 main options are available:
|
||||||
|
</p>
|
||||||
|
<dl>
|
||||||
|
<dt><mat-icon>directions_walk</mat-icon>Trails</dt>
|
||||||
|
<dd>
|
||||||
|
Details of the trails with description, photo, length, visible plants, etc.
|
||||||
|
</dd>
|
||||||
|
<dt><mat-icon>local_florist</mat-icon>Plants</dt>
|
||||||
|
<dd>
|
||||||
|
Each species has a flippable card, with information on the back side.
|
||||||
|
More details are available on the species information page:
|
||||||
|
type of plant, flower, habitat, etc, and where to find specimen.
|
||||||
|
</dd>
|
||||||
|
<dt><mat-icon>map</mat-icon>Map</dt>
|
||||||
|
<dd>
|
||||||
|
The interactive map helps you walk the trails and spot the interesting trees and plants.
|
||||||
|
Thanks to localisation ("GPS"), Tree Trail shows a compass with
|
||||||
|
the direction and distance when you are appraching an interesting specimen.
|
||||||
|
Elements on the map are clickable, giving access to more details.
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
<h2>Offline use</h2>
|
||||||
|
<p>
|
||||||
|
<span *ngIf="actionService.isOnline$ | async; else offLine">
|
||||||
|
Tree Trail can be used without network connection ("Road Warrior"),
|
||||||
|
allowing the exploration of the most remote places without network connectivity.
|
||||||
|
<br>
|
||||||
|
Click this button to download all the data now:
|
||||||
|
<button mat-raised-button (click)="actionService.getPicturesForOffline()">Download data</button>
|
||||||
|
</span>
|
||||||
|
<ng-template #offLine>
|
||||||
|
You are currently offline.
|
||||||
|
</ng-template>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Tree Trail can be installed as a regular application (depending on your device and web browser).
|
||||||
|
In that case all the required data will be downloaded, thus giving the possibility to use Tree Trail
|
||||||
|
without network connectivity.
|
||||||
|
<br>
|
||||||
|
<span *ngIf="this.isRunningStandalone(); else inBrowser">
|
||||||
|
All good, Tree Trail is installed.
|
||||||
|
</span>
|
||||||
|
<ng-template #inBrowser>
|
||||||
|
<span *ngIf="promptEvent; else noInstall">
|
||||||
|
Click on the button below to install it.
|
||||||
|
<button mat-raised-button (click)="installPWA()" *ngIf="shouldInstall()">Install App</button>
|
||||||
|
</span>
|
||||||
|
</ng-template>
|
||||||
|
</p>
|
||||||
|
<h2>What next?</h2>
|
||||||
|
<p>
|
||||||
|
Time to go and explore the environment, the forest and the trees! Have fun!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-template #noInstall>
|
||||||
|
Unfortunately Tree Trail cannot be installed right now due to the browser,
|
||||||
|
you'll be notified when it is possible.
|
||||||
|
</ng-template>
|
46
src/app/home/home.component.scss
Normal file
46
src/app/home/home.component.scss
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
:host {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0 1em;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
// Angular build cannot find the path: removing for now
|
||||||
|
//background-image: url("assets/img/curly_tree_small.webp");
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0.2em 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nowrap {
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
dt {
|
||||||
|
font-weight: bold;
|
||||||
|
.mat-icon {
|
||||||
|
font-size: 150%;
|
||||||
|
line-height: inherit;
|
||||||
|
vertical-align: text-top;
|
||||||
|
padding-right: .3em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nowrap {
|
||||||
|
.mat-icon {
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
vertical-align: text-top;
|
||||||
|
width: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
25
src/app/home/home.component.spec.ts
Normal file
25
src/app/home/home.component.spec.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { HomeComponent } from './home.component';
|
||||||
|
|
||||||
|
describe('HomeComponent', () => {
|
||||||
|
let component: HomeComponent;
|
||||||
|
let fixture: ComponentFixture<HomeComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [ HomeComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(HomeComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
42
src/app/home/home.component.ts
Normal file
42
src/app/home/home.component.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import { Component, OnInit,
|
||||||
|
ChangeDetectorRef, ChangeDetectionStrategy, HostListener } from '@angular/core';
|
||||||
|
|
||||||
|
import { ActionService } from '../action.service'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-home',
|
||||||
|
templateUrl: './home.component.html',
|
||||||
|
styleUrls: ['./home.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class HomeComponent implements OnInit {
|
||||||
|
constructor(
|
||||||
|
private cdr: ChangeDetectorRef,
|
||||||
|
public actionService: ActionService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
}
|
||||||
|
|
||||||
|
public promptEvent
|
||||||
|
|
||||||
|
@HostListener('window:beforeinstallprompt', ['$event'])
|
||||||
|
onbeforeinstallprompt(e) {
|
||||||
|
console.log('Ready to install', e)
|
||||||
|
e.preventDefault()
|
||||||
|
this.promptEvent = e
|
||||||
|
this.cdr.markForCheck()
|
||||||
|
}
|
||||||
|
|
||||||
|
public installPWA() {
|
||||||
|
this.promptEvent.prompt()
|
||||||
|
}
|
||||||
|
|
||||||
|
public shouldInstall(): boolean {
|
||||||
|
return !this.isRunningStandalone() && this.promptEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
public isRunningStandalone(): boolean {
|
||||||
|
return (window.matchMedia('(display-mode: standalone)').matches)
|
||||||
|
}
|
||||||
|
}
|
17
src/app/indicator/indicator.component.html
Normal file
17
src/app/indicator/indicator.component.html
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<div class="container">
|
||||||
|
<mat-icon *ngIf='featureFinderService.hasDirection$ | async' aria-hidden="false"
|
||||||
|
matTooltip="Direction to interesting stuff..."
|
||||||
|
[ngStyle]="{'transform':'rotate(' + (featureFinderService.direction$ | async) + 'deg)'}">
|
||||||
|
north
|
||||||
|
</mat-icon>
|
||||||
|
|
||||||
|
<div *ngIf='distance'>
|
||||||
|
{{ distance }} m
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-icon class="north"
|
||||||
|
matTooltip="Direction of north"
|
||||||
|
[ngStyle]="{'transform':'rotate(' + (featureFinderService.orientation$ | async) + 'deg)'}">
|
||||||
|
north
|
||||||
|
</mat-icon>
|
||||||
|
<div>
|
6
src/app/indicator/indicator.component.scss
Normal file
6
src/app/indicator/indicator.component.scss
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
25
src/app/indicator/indicator.component.spec.ts
Normal file
25
src/app/indicator/indicator.component.spec.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { IndicatorComponent } from './indicator.component';
|
||||||
|
|
||||||
|
describe('IndicatorComponent', () => {
|
||||||
|
let component: IndicatorComponent;
|
||||||
|
let fixture: ComponentFixture<IndicatorComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [ IndicatorComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(IndicatorComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
41
src/app/indicator/indicator.component.ts
Normal file
41
src/app/indicator/indicator.component.ts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import { Component, OnInit,
|
||||||
|
ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core'
|
||||||
|
import { Observable, BehaviorSubject } from 'rxjs'
|
||||||
|
import { map } from 'rxjs/operators'
|
||||||
|
|
||||||
|
import { ActionService } from '../action.service'
|
||||||
|
import { FeatureFinderService } from '../feature-finder.service'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-indicator',
|
||||||
|
templateUrl: './indicator.component.html',
|
||||||
|
styleUrls: ['./indicator.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class IndicatorComponent implements OnInit {
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public actionService: ActionService,
|
||||||
|
public featureFinderService: FeatureFinderService,
|
||||||
|
private cdr: ChangeDetectorRef,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
distance: number
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.featureFinderService.distance$.subscribe(
|
||||||
|
dist => {
|
||||||
|
this.distance = Math.round(dist)
|
||||||
|
this.cdr.markForCheck()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
this.featureFinderService.direction$.subscribe(
|
||||||
|
_ => this.cdr.markForCheck()
|
||||||
|
)
|
||||||
|
|
||||||
|
this.featureFinderService.orientation$.subscribe(
|
||||||
|
_ => this.cdr.markForCheck()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
6
src/app/intro/intro.component.html
Normal file
6
src/app/intro/intro.component.html
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<h2 *ngIf='showSettings'>Welcome to Tree Trail</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<span class='hl'>Tree Trail</span> is a companion for exploring
|
||||||
|
remarkable trees.
|
||||||
|
</p>
|
17
src/app/intro/intro.component.scss
Normal file
17
src/app/intro/intro.component.scss
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
:host>* {
|
||||||
|
padding-left: 1em;
|
||||||
|
padding-right: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl {
|
||||||
|
font-weight: 800;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
23
src/app/intro/intro.component.ts
Normal file
23
src/app/intro/intro.component.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { Component, OnInit, Input,
|
||||||
|
ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core';
|
||||||
|
|
||||||
|
import { ActionService } from '../action.service'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-intro',
|
||||||
|
templateUrl: './intro.component.html',
|
||||||
|
styleUrls: ['./intro.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class IntroComponent implements OnInit {
|
||||||
|
@Input() showSettings: boolean = true
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public actionService: ActionService,
|
||||||
|
private cdr: ChangeDetectorRef,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
19
src/app/login/login.component.html
Normal file
19
src/app/login/login.component.html
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<h1>Login</h1>
|
||||||
|
<p>Enter your access credentials</p>
|
||||||
|
<form [formGroup] = 'form' (ngSubmit) = 'login()'>
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>Username</mat-label>
|
||||||
|
<input type='text' matInput formControlName='username' />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>Password</mat-label>
|
||||||
|
<input type='password' matInput formControlName='password' autocomplete="on"/>
|
||||||
|
</mat-form-field>
|
||||||
|
<div class='msg'>{{ msg }}</div>
|
||||||
|
<div class='actions'>
|
||||||
|
<button mat-raised-button type='submit' color="primary" [disabled]='!this.form.valid'>
|
||||||
|
Login
|
||||||
|
<mat-icon>login</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
21
src/app/login/login.component.scss
Normal file
21
src/app/login/login.component.scss
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
:host {
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, p {
|
||||||
|
text-align: center;
|
||||||
|
}
|
23
src/app/login/login.component.spec.ts
Normal file
23
src/app/login/login.component.spec.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { LoginComponent } from './login.component';
|
||||||
|
|
||||||
|
describe('LoginComponent', () => {
|
||||||
|
let component: LoginComponent;
|
||||||
|
let fixture: ComponentFixture<LoginComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [ LoginComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(LoginComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
62
src/app/login/login.component.ts
Normal file
62
src/app/login/login.component.ts
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core'
|
||||||
|
import { Location } from "@angular/common"
|
||||||
|
import { FormBuilder, FormGroup, Validators } from '@angular/forms'
|
||||||
|
import { AuthService } from '../services/auth.service'
|
||||||
|
import { ConfigService } from '../config.service'
|
||||||
|
import { DataService } from '../data.service'
|
||||||
|
import { DefaultService } from '../openapi/services.gen'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-login',
|
||||||
|
templateUrl: './login.component.html',
|
||||||
|
styleUrls: ['./login.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class LoginComponent implements OnInit {
|
||||||
|
form: FormGroup
|
||||||
|
msg: String
|
||||||
|
constructor(
|
||||||
|
private _auth: AuthService,
|
||||||
|
public configService: ConfigService,
|
||||||
|
private cdr: ChangeDetectorRef,
|
||||||
|
public fb: FormBuilder,
|
||||||
|
public dataService: DataService,
|
||||||
|
private location: Location,
|
||||||
|
public apiService: DefaultService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.form = this.fb.group({
|
||||||
|
username: ['', Validators.required],
|
||||||
|
password: ['', Validators.required]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
login() {
|
||||||
|
this.apiService.loginForAccessTokenTokenPost({
|
||||||
|
formData: {
|
||||||
|
username: this.form.value['username'],
|
||||||
|
password: this.form.value['password'],
|
||||||
|
}
|
||||||
|
}).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
if (res.access_token) {
|
||||||
|
this._auth.setDataInLocalStorage('token', res.access_token)
|
||||||
|
this.msg = undefined
|
||||||
|
this.configService.setUserPref('userName', this.form.value['username'])
|
||||||
|
this.dataService.syncPendingTrees()
|
||||||
|
}
|
||||||
|
// After successful login, bootstrap with user's data
|
||||||
|
this.configService.bootstrap().subscribe(
|
||||||
|
_ => {
|
||||||
|
this.location.back()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
error: err => {
|
||||||
|
this.msg = err.statusText
|
||||||
|
this.cdr.markForCheck()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
1
src/app/map-info/map-info.component.html
Normal file
1
src/app/map-info/map-info.component.html
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<!--<app-indicator></app-indicator>-->
|
6
src/app/map-info/map-info.component.scss
Normal file
6
src/app/map-info/map-info.component.scss
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
15
src/app/map-info/map-info.component.ts
Normal file
15
src/app/map-info/map-info.component.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-map-info',
|
||||||
|
templateUrl: './map-info.component.html',
|
||||||
|
styleUrls: ['./map-info.component.scss']
|
||||||
|
})
|
||||||
|
export class MapInfoComponent implements OnInit {
|
||||||
|
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
4
src/app/map-view/map-view.component.html
Normal file
4
src/app/map-view/map-view.component.html
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<div>
|
||||||
|
<app-map></app-map>
|
||||||
|
<app-map-info></app-map-info>
|
||||||
|
</div>
|
13
src/app/map-view/map-view.component.scss
Normal file
13
src/app/map-view/map-view.component.scss
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host > div {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
app-map-info {
|
||||||
|
height: 0%;
|
||||||
|
}
|
15
src/app/map-view/map-view.component.ts
Normal file
15
src/app/map-view/map-view.component.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-map-view',
|
||||||
|
templateUrl: './map-view.component.html',
|
||||||
|
styleUrls: ['./map-view.component.scss']
|
||||||
|
})
|
||||||
|
export class MapViewComponent implements OnInit {
|
||||||
|
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
30
src/app/map/animation.ts
Normal file
30
src/app/map/animation.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { trigger, transition, animate, style, keyframes, state } from '@angular/animations'
|
||||||
|
|
||||||
|
export const flipAnimation = trigger('flip', [
|
||||||
|
state('front', style({
|
||||||
|
transform: 'rotateY(0deg)'
|
||||||
|
})),
|
||||||
|
state('back', style({
|
||||||
|
transform: 'rotateY(180deg)'
|
||||||
|
})),
|
||||||
|
transition('front => back', [
|
||||||
|
animate('0.4s 0s ease-out',
|
||||||
|
keyframes([
|
||||||
|
style({
|
||||||
|
transform: 'perspective(400px) rotateY(180deg)',
|
||||||
|
offset: 1
|
||||||
|
})
|
||||||
|
])
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
transition('back => front', [
|
||||||
|
animate('0.4s 0s ease-in',
|
||||||
|
keyframes([
|
||||||
|
style({
|
||||||
|
transform: 'perspective(400px) rotateY(0deg)',
|
||||||
|
offset: 1
|
||||||
|
})
|
||||||
|
])
|
||||||
|
)
|
||||||
|
])
|
||||||
|
])
|
18
src/app/map/app-control.component.css
Normal file
18
src/app/map/app-control.component.css
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
.app-maplibregl-ctrl {
|
||||||
|
margin: 5px;
|
||||||
|
float: right;
|
||||||
|
clear: both;
|
||||||
|
color: #333;
|
||||||
|
background-color: hsla(0,0%,100%,.5);
|
||||||
|
padding: 2px 5px;
|
||||||
|
font-size: 105%;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
float: left;
|
||||||
|
}
|
70
src/app/map/app-control.component.ts
Normal file
70
src/app/map/app-control.component.ts
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
import {
|
||||||
|
AfterContentInit, OnInit,
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
Input,
|
||||||
|
OnDestroy,
|
||||||
|
ViewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { IControl, ControlPosition } from 'maplibre-gl'
|
||||||
|
import { MapService } from '@maplibre/ngx-maplibre-gl'
|
||||||
|
|
||||||
|
export class CustomControl implements IControl {
|
||||||
|
constructor(private container: HTMLElement) {}
|
||||||
|
|
||||||
|
onAdd() {
|
||||||
|
return this.container;
|
||||||
|
}
|
||||||
|
|
||||||
|
onRemove() {
|
||||||
|
return this.container.parentNode!.removeChild(this.container);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefaultPosition(): ControlPosition {
|
||||||
|
return 'top-right';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-mgl-control',
|
||||||
|
template:
|
||||||
|
'<div [class]="clsName" #content><ng-content></ng-content></div>',
|
||||||
|
styleUrls: ['app-control.component.css'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class AppControlComponent<T extends IControl>
|
||||||
|
implements OnInit, OnDestroy, AfterContentInit {
|
||||||
|
private controlAdded = false;
|
||||||
|
clsName: string
|
||||||
|
|
||||||
|
/* Init inputs */
|
||||||
|
@Input() position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||||
|
|
||||||
|
@ViewChild('content', { static: true }) content: ElementRef;
|
||||||
|
|
||||||
|
control: T | CustomControl;
|
||||||
|
|
||||||
|
constructor(private MapService: MapService) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.clsName = "app-maplibregl-ctrl " + (this.position.endsWith('left') ? 'left' : 'right')
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterContentInit() {
|
||||||
|
if (this.content.nativeElement.childNodes.length) {
|
||||||
|
this.control = new CustomControl(this.content.nativeElement);
|
||||||
|
this.MapService.mapCreated$.subscribe(() => {
|
||||||
|
this.MapService.addControl(this.control!, this.position);
|
||||||
|
this.controlAdded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
if (this.controlAdded) {
|
||||||
|
this.MapService.removeControl(this.control);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
21
src/app/map/direction.component.html
Normal file
21
src/app/map/direction.component.html
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<div *ngIf="!!distance;else elseBlock"
|
||||||
|
class="container"
|
||||||
|
matTooltip="Direction and distance to the nearest point of interest.
|
||||||
|
The direction depends on your device to identify the North and its calibration.
|
||||||
|
A green arrow indicates that the device reports the North, and hence the direction should be accurate.
|
||||||
|
Otherwise, a calibration of the sensors might be needed."
|
||||||
|
>
|
||||||
|
<mat-icon aria-hidden="false"
|
||||||
|
[class]="orientationClass"
|
||||||
|
[ngStyle]="{'transform':'rotate(' + (featureFinderService.direction$ | async) + 'deg)'}">
|
||||||
|
{{ orientationClass == 'unknown' ? 'cached' : 'north' }}
|
||||||
|
</mat-icon>
|
||||||
|
<div class="distance">
|
||||||
|
{{ distance }} m
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ng-template #elseBlock>
|
||||||
|
<div class="nothing">
|
||||||
|
Nothing around
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
36
src/app/map/direction.component.scss
Normal file
36
src/app/map/direction.component.scss
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
.container {
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 3em;
|
||||||
|
height: 4em;
|
||||||
|
padding: 3px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 0 3px rgb(0 0 0 / 30%);
|
||||||
|
cursor: pointer;
|
||||||
|
.distance {
|
||||||
|
font-size: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.absolute {
|
||||||
|
color: green;
|
||||||
|
}
|
||||||
|
.relative {
|
||||||
|
color: grey
|
||||||
|
}
|
||||||
|
.unknown {
|
||||||
|
color:rgba(226, 22, 22, 0.4)
|
||||||
|
}
|
||||||
|
|
||||||
|
.nothing {
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 4em;
|
||||||
|
height: 3em;
|
||||||
|
padding: 3px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 0 3px rgb(0 0 0 / 30%);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
59
src/app/map/direction.component.ts
Normal file
59
src/app/map/direction.component.ts
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import { Component, ElementRef, OnInit,
|
||||||
|
ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core'
|
||||||
|
|
||||||
|
import { Subscription } from 'rxjs'
|
||||||
|
|
||||||
|
import { FeatureFinderService } from '../feature-finder.service'
|
||||||
|
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-direction',
|
||||||
|
templateUrl: './direction.component.html',
|
||||||
|
styleUrls: ['./direction.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class TreetrailDirectionComponent implements OnInit {
|
||||||
|
direction: number
|
||||||
|
clickSubscription: Subscription
|
||||||
|
distance: number
|
||||||
|
orientationClass: string = 'unknown'
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public elementRef: ElementRef,
|
||||||
|
public featureFinderService: FeatureFinderService,
|
||||||
|
private cdr: ChangeDetectorRef,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.featureFinderService.direction$.subscribe(
|
||||||
|
dir => {
|
||||||
|
this.direction = dir
|
||||||
|
this.cdr.markForCheck()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
this.featureFinderService.distance$.subscribe(
|
||||||
|
dist => {
|
||||||
|
this.distance = Math.round(dist)
|
||||||
|
this.cdr.markForCheck()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
this.featureFinderService.direction$.subscribe(
|
||||||
|
_ => this.cdr.markForCheck()
|
||||||
|
)
|
||||||
|
|
||||||
|
this.featureFinderService.orientation$.subscribe(
|
||||||
|
orientation => {
|
||||||
|
if (typeof(orientation) != 'undefined') {
|
||||||
|
this.orientationClass = 'absolute'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.orientationClass = 'unknown'
|
||||||
|
}
|
||||||
|
this.cdr.markForCheck()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
58
src/app/map/map-edit/edit-map-control.directive.ts
Normal file
58
src/app/map/map-edit/edit-map-control.directive.ts
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import { AfterContentInit, Directive, Host, Input, ApplicationRef, createComponent } from '@angular/core'
|
||||||
|
import { ControlComponent, MapService, CustomControl } from '@maplibre/ngx-maplibre-gl'
|
||||||
|
|
||||||
|
import { MapEditService } from './map-edit.service'
|
||||||
|
import { MapEditComponent } from './map-edit.component'
|
||||||
|
|
||||||
|
export class EditControl extends CustomControl {
|
||||||
|
private _container: HTMLElement
|
||||||
|
constructor(
|
||||||
|
container: HTMLElement,
|
||||||
|
public appRef: ApplicationRef,
|
||||||
|
) {
|
||||||
|
super(container)
|
||||||
|
container.classList.add('maplibregl-ctrl-group')
|
||||||
|
this._container = container
|
||||||
|
}
|
||||||
|
|
||||||
|
onAdd() {
|
||||||
|
const componentRef = createComponent(MapEditComponent, {
|
||||||
|
hostElement: this._container,
|
||||||
|
environmentInjector: this.appRef.injector,
|
||||||
|
})
|
||||||
|
this._container.title = "Add tree"
|
||||||
|
this.appRef.attachView(componentRef.hostView)
|
||||||
|
return this._container
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[treeTrailMapEdit]',
|
||||||
|
})
|
||||||
|
export class TreeTrailMapEditControlDirective implements AfterContentInit {
|
||||||
|
@Input() container?: HTMLElement
|
||||||
|
constructor(
|
||||||
|
private mapService: MapService,
|
||||||
|
public mapEditComponentService: MapEditService,
|
||||||
|
public appRef: ApplicationRef,
|
||||||
|
@Host() private controlComponent: ControlComponent<EditControl>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngAfterContentInit() {
|
||||||
|
this.mapService.mapCreated$.subscribe(() => {
|
||||||
|
if (this.controlComponent.control) {
|
||||||
|
throw new Error('Another control is already set for this control')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.controlComponent.control = new EditControl(
|
||||||
|
this.controlComponent.content.nativeElement, //this.container,
|
||||||
|
this.appRef,
|
||||||
|
)
|
||||||
|
|
||||||
|
this.mapService.addControl(
|
||||||
|
this.controlComponent.control,
|
||||||
|
this.controlComponent.position
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
5
src/app/map/map-edit/map-edit.component.html
Normal file
5
src/app/map/map-edit/map-edit.component.html
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<mat-icon
|
||||||
|
[class.active]="mapEditService.active"
|
||||||
|
(click)="toggle()">
|
||||||
|
edit
|
||||||
|
</mat-icon>
|
10
src/app/map/map-edit/map-edit.component.scss
Normal file
10
src/app/map/map-edit/map-edit.component.scss
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
:host {
|
||||||
|
cursor: pointer;
|
||||||
|
.mat-icon {
|
||||||
|
margin: 3px 2px -3px 3px;
|
||||||
|
color: grey;
|
||||||
|
}
|
||||||
|
.active {
|
||||||
|
color: red!important;
|
||||||
|
}
|
||||||
|
}
|
21
src/app/map/map-edit/map-edit.component.spec.ts
Normal file
21
src/app/map/map-edit/map-edit.component.spec.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { MapEditComponent } from './map-edit.component';
|
||||||
|
|
||||||
|
describe('MapEditComponent', () => {
|
||||||
|
let component: MapEditComponent;
|
||||||
|
let fixture: ComponentFixture<MapEditComponent>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [MapEditComponent]
|
||||||
|
});
|
||||||
|
fixture = TestBed.createComponent(MapEditComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
40
src/app/map/map-edit/map-edit.component.ts
Normal file
40
src/app/map/map-edit/map-edit.component.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import { Component, ChangeDetectorRef,
|
||||||
|
ChangeDetectionStrategy, ApplicationRef, OnInit } from '@angular/core'
|
||||||
|
|
||||||
|
import { MessageService } from '../../message.service'
|
||||||
|
import { MapEditService } from './map-edit.service'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-map-edit',
|
||||||
|
templateUrl: './map-edit.component.html',
|
||||||
|
styleUrls: ['./map-edit.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class MapEditComponent implements OnInit {
|
||||||
|
constructor(
|
||||||
|
public mapEditService: MapEditService,
|
||||||
|
public cdr: ChangeDetectorRef,
|
||||||
|
public appRef: ApplicationRef,
|
||||||
|
public messageService: MessageService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.mapEditService.cancelEditMap$.subscribe(
|
||||||
|
() => {
|
||||||
|
this.mapEditService.active = false
|
||||||
|
this.cdr.markForCheck()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.mapEditService.toggle()
|
||||||
|
if (this.mapEditService.active) {
|
||||||
|
this.messageService.message.next(
|
||||||
|
'Add trees by clicking on their location on the map'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
this.cdr.markForCheck()
|
||||||
|
this.appRef.tick()
|
||||||
|
}
|
||||||
|
}
|
62
src/app/map/map-edit/map-edit.service.ts
Normal file
62
src/app/map/map-edit/map-edit.service.ts
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import { Injectable, NgZone } from '@angular/core'
|
||||||
|
import { MatDialog } from '@angular/material/dialog'
|
||||||
|
import { Subject } from 'rxjs'
|
||||||
|
|
||||||
|
import { LngLat } from 'maplibre-gl'
|
||||||
|
|
||||||
|
import { DataService, TreeDef } from '../../data.service'
|
||||||
|
import { PlantChooserDialogComponent } from './plant-chooser-dialog'
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class MapEditService {
|
||||||
|
public active: boolean = false
|
||||||
|
public treeDef: TreeDef
|
||||||
|
constructor(
|
||||||
|
public ngZone: NgZone,
|
||||||
|
public dataService: DataService,
|
||||||
|
public dialog: MatDialog,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public cancelEditMap$: Subject<void> = new Subject()
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.active = !this.active
|
||||||
|
}
|
||||||
|
|
||||||
|
addTree(lngLat: LngLat) {
|
||||||
|
this.openDialog(lngLat)
|
||||||
|
}
|
||||||
|
|
||||||
|
openDialog(lngLat: LngLat) {
|
||||||
|
this.ngZone.run(
|
||||||
|
() => {
|
||||||
|
const dialogRef = this.dialog.open(PlantChooserDialogComponent, {
|
||||||
|
width: '24em',
|
||||||
|
data: { plantId: undefined },
|
||||||
|
})
|
||||||
|
dialogRef.afterClosed().subscribe(result => {
|
||||||
|
if (result) {
|
||||||
|
this.setTreeDef(result['plant']['plantId'],
|
||||||
|
result['plant']['trails'])
|
||||||
|
this.dataService.addTree(lngLat,
|
||||||
|
this.treeDef,
|
||||||
|
result['picture']['picture'],
|
||||||
|
result['details'])
|
||||||
|
}
|
||||||
|
this.cancelEditMap$.next()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setTreeDef(plantId: string, trailIds: string[]) {
|
||||||
|
this.treeDef = new TreeDef(
|
||||||
|
this.dataService.all.value.plants[plantId],
|
||||||
|
trailIds.map(
|
||||||
|
trailId => this.dataService.all.value.trails[trailId]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
73
src/app/map/map-edit/plant-chooser-dialog.html
Normal file
73
src/app/map/map-edit/plant-chooser-dialog.html
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
<h1 mat-dialog-title>Select a plant to add</h1>
|
||||||
|
<mat-dialog-content [formGroup]="form">
|
||||||
|
<mat-vertical-stepper linear=false>
|
||||||
|
<mat-step [stepControl]="form.get('plant')" formGroupName="plant" errorMessage="Plant is required" label="Plant">
|
||||||
|
<div>
|
||||||
|
<mat-autocomplete #plants="matAutocomplete">
|
||||||
|
<mat-option *ngFor="let plant of filteredOptions | async" [value]="plant.id">
|
||||||
|
{{ plant.getFriendlyName() }}
|
||||||
|
</mat-option>
|
||||||
|
</mat-autocomplete>
|
||||||
|
<div class="fields">
|
||||||
|
<mat-form-field id='plantId' matTooltip="Plant">
|
||||||
|
<mat-label>Plant</mat-label>
|
||||||
|
<input matInput cdkFocusInitial (input)="cdr.markForCheck()" formControlName="plantId"
|
||||||
|
[matAutocomplete]="plants">
|
||||||
|
<button *ngIf="$any(form.controls['plant']).controls['plantId'].value" matSuffix mat-icon-button
|
||||||
|
aria-label="Clear" (click)="$any(form.controls['plant']).controls['plantId'].setValue('')">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field id='trails' matTooltip="Trails">
|
||||||
|
<mat-label>Trail</mat-label>
|
||||||
|
<mat-select #select formControlName="trails" multiple>
|
||||||
|
<mat-option *ngFor="let trail of dataService.all.value.trails | keyvalue" [value]="trail.key">
|
||||||
|
{{ trail.value.name }}
|
||||||
|
</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-step>
|
||||||
|
<mat-step [stepControl]="form.get('picture')" formGroupName="picture" label="Picture">
|
||||||
|
<div>
|
||||||
|
<input type="file" accept="image/*" capture="environment" formControlName="file"
|
||||||
|
(change)="onImageFileSelected($event)">
|
||||||
|
</div>
|
||||||
|
<div class="preview" *ngIf="$any(form.controls['picture']).controls['picture'].value">
|
||||||
|
<img [src]="$any(form.controls['picture']).controls['picture'].value">
|
||||||
|
</div>
|
||||||
|
</mat-step>
|
||||||
|
<mat-step [stepControl]="form.get('details')" formGroupName="details" label="Details">
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>Status</mat-label>
|
||||||
|
<input matInput formControlName="status">
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>Condition</mat-label>
|
||||||
|
<input matInput formControlName="condition">
|
||||||
|
</mat-form-field>
|
||||||
|
<div id="height">
|
||||||
|
<mat-label>Height</mat-label>
|
||||||
|
<mat-slider min="1" max="40" step="0.5" value="5">
|
||||||
|
<input matInput matSliderThumb formControlName="height">
|
||||||
|
</mat-slider>
|
||||||
|
<label class="example-value-label"
|
||||||
|
[innerHTML]="form.get('details').get('height').value + ' m'">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>Comments</mat-label>
|
||||||
|
<input matInput formControlName="comments">
|
||||||
|
</mat-form-field>
|
||||||
|
</mat-step>
|
||||||
|
</mat-vertical-stepper>
|
||||||
|
</mat-dialog-content>
|
||||||
|
<div mat-dialog-actions>
|
||||||
|
<button mat-raised-button (click)="form.reset();dialogRef.close()">Cancel</button>
|
||||||
|
<button mat-raised-button color='primary' type="submit"
|
||||||
|
(click)="dialogRef.close(form.value)"
|
||||||
|
[disabled]="!form.valid">
|
||||||
|
Ok
|
||||||
|
</button>
|
||||||
|
</div>
|
28
src/app/map/map-edit/plant-chooser-dialog.scss
Normal file
28
src/app/map/map-edit/plant-chooser-dialog.scss
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
.fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
text-align: center;
|
||||||
|
img {
|
||||||
|
max-height: 12em;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#plantId, #trails {
|
||||||
|
width: 15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-dialog-actions {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .mat-vertical-content {
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .mat-vertical-stepper-header {
|
||||||
|
padding: 12px 12px;
|
||||||
|
}
|
131
src/app/map/map-edit/plant-chooser-dialog.ts
Normal file
131
src/app/map/map-edit/plant-chooser-dialog.ts
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
import { Component, Inject, OnInit, ChangeDetectorRef, ViewChild } from '@angular/core'
|
||||||
|
import {
|
||||||
|
FormBuilder, FormControl, FormGroup, Validators,
|
||||||
|
AbstractControl, ValidationErrors, ValidatorFn
|
||||||
|
} from '@angular/forms'
|
||||||
|
import { STEPPER_GLOBAL_OPTIONS } from '@angular/cdk/stepper'
|
||||||
|
|
||||||
|
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'
|
||||||
|
import { MatSelect } from '@angular/material/select'
|
||||||
|
import { Observable } from 'rxjs'
|
||||||
|
import { map, startWith } from 'rxjs/operators'
|
||||||
|
|
||||||
|
import { DOC_ORIENTATION } from 'ngx-image-compress'
|
||||||
|
|
||||||
|
import { DataService } from 'src/app/data.service'
|
||||||
|
import { ActionService } from 'src/app/action.service'
|
||||||
|
import { Plant, Trails } from 'src/app/models'
|
||||||
|
|
||||||
|
export interface DialogData {
|
||||||
|
instruction: string,
|
||||||
|
plantId: string,
|
||||||
|
fileName: string,
|
||||||
|
picture: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'plant-chooser-dialog',
|
||||||
|
templateUrl: './plant-chooser-dialog.html',
|
||||||
|
styleUrls: ['./plant-chooser-dialog.scss'],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: STEPPER_GLOBAL_OPTIONS,
|
||||||
|
useValue: { showError: true },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class PlantChooserDialogComponent implements OnInit {
|
||||||
|
form: FormGroup
|
||||||
|
filteredOptions: Observable<Plant[]>
|
||||||
|
trails: Trails
|
||||||
|
file: File
|
||||||
|
@ViewChild('select') private select: MatSelect
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public dialogRef: MatDialogRef<PlantChooserDialogComponent>,
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: DialogData,
|
||||||
|
public dataService: DataService,
|
||||||
|
public actionService: ActionService,
|
||||||
|
public cdr: ChangeDetectorRef,
|
||||||
|
private fb: FormBuilder,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
const formPlant = this.fb.group({
|
||||||
|
plantId: new FormControl('', [
|
||||||
|
Validators.required,
|
||||||
|
this.createPlantValidator()
|
||||||
|
]),
|
||||||
|
trails: new FormControl([]),
|
||||||
|
})
|
||||||
|
const formPicture = this.fb.group({
|
||||||
|
picture: new FormControl(''),
|
||||||
|
file: new FormControl(),
|
||||||
|
})
|
||||||
|
const formDetails = this.fb.group({
|
||||||
|
status: new FormControl(''),
|
||||||
|
condition: new FormControl(''),
|
||||||
|
comments: new FormControl(''),
|
||||||
|
height: new FormControl(),
|
||||||
|
})
|
||||||
|
this.form = this.fb.group({
|
||||||
|
plant: formPlant,
|
||||||
|
picture: formPicture,
|
||||||
|
details: formDetails,
|
||||||
|
})
|
||||||
|
this.filteredOptions = (<FormGroup>this.form.controls['plant']).controls['plantId'].valueChanges.pipe(
|
||||||
|
startWith(''),
|
||||||
|
map(value => this._filter(value || '')),
|
||||||
|
)
|
||||||
|
|
||||||
|
this.form.valueChanges.subscribe(() => {
|
||||||
|
this.select.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createPlantValidator(): ValidatorFn {
|
||||||
|
return (control: AbstractControl): ValidationErrors | null => {
|
||||||
|
const isPlantName = control.value in this.dataService.all.value.plants
|
||||||
|
return !isPlantName ? { plantName: true } : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _filter(value: string): Plant[] {
|
||||||
|
const filterValue = value.toLowerCase()
|
||||||
|
return Object.values(this.dataService.all.value.plants).filter(
|
||||||
|
plant => plant.isMatch(filterValue)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
maxImgSize: number = 300000
|
||||||
|
resizeFactor: number = 50
|
||||||
|
|
||||||
|
onImageFileSelected(e: Event) {
|
||||||
|
const reader = new FileReader()
|
||||||
|
|
||||||
|
if ((<HTMLInputElement>e.target).files
|
||||||
|
&& (<FileList>(<HTMLInputElement>e.target).files).length) {
|
||||||
|
const files = <FileList>(<HTMLInputElement>e.target).files
|
||||||
|
this.file = files[0]
|
||||||
|
reader.readAsDataURL(this.file)
|
||||||
|
|
||||||
|
reader.onload = () => {
|
||||||
|
const imgFile = <string>reader.result
|
||||||
|
if (imgFile.length > this.maxImgSize) {
|
||||||
|
// Image too big => resize (arbitrary set to 50%)
|
||||||
|
this.actionService.imageCompressService.compressFile(
|
||||||
|
imgFile, DOC_ORIENTATION.Up, this.resizeFactor
|
||||||
|
).then(
|
||||||
|
compressedImage => {
|
||||||
|
(<FormGroup>this.form.get('picture')).controls.picture.setValue(compressedImage)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
(<FormGroup>this.form.get('picture')).controls.picture.setValue(imgFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
42
src/app/map/map.component.html
Normal file
42
src/app/map/map.component.html
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
<mgl-map #map
|
||||||
|
[style]="styleUrl"
|
||||||
|
[zoom]="[configService.conf.value.mapPos.zoom]"
|
||||||
|
[center]="configService.conf.value.mapPos.center"
|
||||||
|
[pitch]="[configService.conf.value.mapPos.pitch]"
|
||||||
|
[bearing]="[configService.conf.value.mapPos.bearing]"
|
||||||
|
(mapLoad)="mapLoad.complete()"
|
||||||
|
(mapClick)="onClick($event)"
|
||||||
|
>
|
||||||
|
|
||||||
|
<mgl-control position="top-right"
|
||||||
|
mglNavigation
|
||||||
|
></mgl-control>
|
||||||
|
|
||||||
|
<mgl-control
|
||||||
|
mglGeolocate
|
||||||
|
[positionOptions]="geolocatePositionOptions"
|
||||||
|
[fitBoundsOptions]="geolocateFitBoundsOptions"
|
||||||
|
[trackUserLocation]="geolocateTrackUserLocation"
|
||||||
|
[showUserLocation]="geolocateShowUserLocation"
|
||||||
|
></mgl-control>
|
||||||
|
|
||||||
|
<mgl-control position="top-left" class="directionControl">
|
||||||
|
<app-direction></app-direction>
|
||||||
|
</mgl-control>
|
||||||
|
|
||||||
|
<mgl-control treeTrailMapEdit
|
||||||
|
*ngIf="(configService.conf | async).bootstrap?.user"
|
||||||
|
position="top-right"
|
||||||
|
></mgl-control>
|
||||||
|
|
||||||
|
<mgl-control mglScale position="bottom-right"></mgl-control>
|
||||||
|
<mgl-popup #popup [lngLat]="[0, 90]">
|
||||||
|
<app-tree-popup #treePopupDetail [hidden]="currentPopupLayer!='tree'"></app-tree-popup>
|
||||||
|
<app-trail-list-item #trailPopupDetail [withMapButton]=false [hidden]="currentPopupLayer!='trail'"></app-trail-list-item>
|
||||||
|
<app-poi-popup #poiPopupDetail [hidden]="currentPopupLayer!='poi'"></app-poi-popup>
|
||||||
|
<app-zone-popup #zonePopupDetail [hidden]="currentPopupLayer!='zone'"></app-zone-popup>
|
||||||
|
</mgl-popup>
|
||||||
|
<app-mgl-control position="bottom-left">
|
||||||
|
<div #featureInfo class='featureInfoInner'></div>
|
||||||
|
</app-mgl-control>
|
||||||
|
</mgl-map>
|
32
src/app/map/map.component.scss
Normal file
32
src/app/map/map.component.scss
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
mgl-map {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
:host ::ng-deep div.maplibregl-map.on-item .mapboxgl-canvas-container.maplibregl-interactive {
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
:host ::ng-deep {
|
||||||
|
.popup-content {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.maplibregl-popup-content {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.directionControl {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treetrail-maplibregl-ctrl {
|
||||||
|
margin: 0;
|
||||||
|
float: right;
|
||||||
|
clear: both;
|
||||||
|
color: #333;
|
||||||
|
background-color: hsla(0,0%,100%,.5);
|
||||||
|
padding: 0 5px;
|
||||||
|
}
|
25
src/app/map/map.component.spec.ts
Normal file
25
src/app/map/map.component.spec.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { MapComponent } from './map.component';
|
||||||
|
|
||||||
|
describe('MapComponent', () => {
|
||||||
|
let component: MapComponent;
|
||||||
|
let fixture: ComponentFixture<MapComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [ MapComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(MapComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
528
src/app/map/map.component.ts
Normal file
528
src/app/map/map.component.ts
Normal file
|
@ -0,0 +1,528 @@
|
||||||
|
import {
|
||||||
|
Component, ViewChild, AfterContentInit, NgZone,
|
||||||
|
ChangeDetectorRef, ChangeDetectionStrategy, ElementRef,
|
||||||
|
OnInit,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { ActivatedRoute, Params } from '@angular/router'
|
||||||
|
import { HttpClient } from '@angular/common/http'
|
||||||
|
import { Observable, Subject, forkJoin } from 'rxjs'
|
||||||
|
import { map, mergeMap } from 'rxjs/operators'
|
||||||
|
|
||||||
|
import bbox from '@turf/bbox'
|
||||||
|
import {
|
||||||
|
MapComponent as MapLibreCompomemt,
|
||||||
|
PopupComponent
|
||||||
|
} from '@maplibre/ngx-maplibre-gl'
|
||||||
|
import {
|
||||||
|
MapMouseEvent, GeoJSONSource, FitBoundsOptions,
|
||||||
|
TypedStyleLayer, MapGeoJSONFeature,
|
||||||
|
GeoJSONSourceSpecification, MapLibreEvent
|
||||||
|
} from 'maplibre-gl'
|
||||||
|
|
||||||
|
import { DataService, NewTree } from '../data.service'
|
||||||
|
import { ActionService } from '../action.service'
|
||||||
|
import { MessageService } from '../message.service'
|
||||||
|
import { ConfigService, MapPos } from '../config.service'
|
||||||
|
import { FeatureTarget } from '../models'
|
||||||
|
import { TrailListItemComponent } from '../trail-list-item/trail-list-item.component'
|
||||||
|
import { TreePopupComponent } from '../tree-popup/tree-popup.component'
|
||||||
|
import { PoiPopupComponent } from '../poi-popup/poi-popup.component'
|
||||||
|
import { ZonePopupComponent } from '../zone-popup/zone-popup.component'
|
||||||
|
import { MapEditService } from './map-edit/map-edit.service'
|
||||||
|
|
||||||
|
const myLayers = ['tree', 'poi', 'trail', 'zone']
|
||||||
|
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-map',
|
||||||
|
templateUrl: './map.component.html',
|
||||||
|
styleUrls: ['./map.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class MapComponent implements AfterContentInit, OnInit {
|
||||||
|
constructor(
|
||||||
|
protected activatedRoute: ActivatedRoute,
|
||||||
|
public configService: ConfigService,
|
||||||
|
public dataService: DataService,
|
||||||
|
public actionService: ActionService,
|
||||||
|
public messageService: MessageService,
|
||||||
|
protected ngZone: NgZone,
|
||||||
|
private cdr: ChangeDetectorRef,
|
||||||
|
public mapEditService: MapEditService,
|
||||||
|
public httpClient: HttpClient,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
@ViewChild('map') map: MapLibreCompomemt
|
||||||
|
@ViewChild("popup") popup: PopupComponent
|
||||||
|
@ViewChild("treePopupDetail") treePopupDetail: TreePopupComponent
|
||||||
|
@ViewChild("poiPopupDetail") poiPopupDetail: PoiPopupComponent
|
||||||
|
@ViewChild("trailPopupDetail") trailPopupDetail: TrailListItemComponent
|
||||||
|
@ViewChild("zonePopupDetail") zonePopupDetail: ZonePopupComponent
|
||||||
|
@ViewChild('featureInfo', { static: true }) featureInfo: ElementRef
|
||||||
|
|
||||||
|
public mapLoad = new Subject<boolean>()
|
||||||
|
|
||||||
|
public currentPopupLayer: string = ''
|
||||||
|
|
||||||
|
//styleUrl = 'assets/map/style.json'
|
||||||
|
styleUrl: string // = '/tiles/style/osm'
|
||||||
|
|
||||||
|
geolocateTrackUserLocation = true
|
||||||
|
geolocateShowUserLocation = true
|
||||||
|
geolocatePositionOptions = {
|
||||||
|
"enableHighAccuracy": true
|
||||||
|
}
|
||||||
|
geolocateFitBoundsOptions: FitBoundsOptions = {
|
||||||
|
"maxZoom": 18,
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
let conf = this.configService.conf.value
|
||||||
|
let bms = conf.background
|
||||||
|
if (conf.bootstrap.baseMapStyles.embedded.indexOf(bms) >= 0) {
|
||||||
|
this.styleUrl = `/tiles/style/${bms}`
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.styleUrl = conf.bootstrap.baseMapStyles.external[bms]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterContentInit(): void {
|
||||||
|
// Hide the popups at start
|
||||||
|
this.mapLoad.subscribe({
|
||||||
|
complete: () => {
|
||||||
|
this.popup.popupInstance.remove()
|
||||||
|
// Bypass Angular's service worker
|
||||||
|
// this.map.transformRequest = (url: String, resourceType: String) => {
|
||||||
|
// return {
|
||||||
|
// url: url.replace('http', 'https'),
|
||||||
|
// headers: { 'ngsw-bypass': true },
|
||||||
|
// credentials: 'include' // Include cookies for cross-origin requests
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
forkJoin([
|
||||||
|
this.mapLoad,
|
||||||
|
this.getIcons(),
|
||||||
|
this.actionService.getAllGeoJSONTrails(),
|
||||||
|
this.actionService.getAllGeoJSONPois(),
|
||||||
|
this.actionService.getAllGeoJSONTrees(),
|
||||||
|
this.actionService.getAllGeoJSONZones(),
|
||||||
|
]).subscribe({
|
||||||
|
complete: () => this.setup(),
|
||||||
|
error: err => {
|
||||||
|
this.messageService.message.next(`Network issue (${err.status})`)
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
getIcons(): Observable<any> {
|
||||||
|
return this.httpClient.get('assets/icons/tree.png', {responseType: "blob"}).pipe(mergeMap(
|
||||||
|
treeIconBlob => createImageBitmap(treeIconBlob).then(
|
||||||
|
treeIcon => this.map.mapInstance.addImage("tree", treeIcon)
|
||||||
|
)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
async asyncGetIcons() {
|
||||||
|
let treeIcon = await this.map.mapInstance.loadImage('assets/icons/tree.png')
|
||||||
|
this.map.mapInstance.addImage("tree", treeIcon.data)
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
// this.map.mapInstance.loadImage('assets/icons/tree.png').then(
|
||||||
|
// image => this.map.mapInstance.addImage('tree', image.data)
|
||||||
|
// )
|
||||||
|
this.map.mapInstance.addSource(
|
||||||
|
'trail',
|
||||||
|
<GeoJSONSourceSpecification>{
|
||||||
|
'type': 'geojson',
|
||||||
|
'data': this.dataService.trailFeatures.getValue()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
this.map.mapInstance.addSource(
|
||||||
|
'poi',
|
||||||
|
<GeoJSONSourceSpecification>{
|
||||||
|
'type': 'geojson',
|
||||||
|
'data': this.dataService.poisFeatures.getValue()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
this.map.mapInstance.addSource(
|
||||||
|
'zone',
|
||||||
|
<GeoJSONSourceSpecification>{
|
||||||
|
'type': 'geojson',
|
||||||
|
'data': this.dataService.zoneFeatures.getValue()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add layers when the styles are available
|
||||||
|
this.dataService.all.subscribe(
|
||||||
|
all => {
|
||||||
|
let styles = all.styles
|
||||||
|
|
||||||
|
this.map.mapInstance.addLayer(
|
||||||
|
{
|
||||||
|
id: 'zone',
|
||||||
|
source: 'zone',
|
||||||
|
type: 'fill',
|
||||||
|
paint: styles['zone'].paint,
|
||||||
|
layout: styles['zone'].layout,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
this.map.mapInstance.addLayer(
|
||||||
|
{
|
||||||
|
id: 'trail',
|
||||||
|
source: 'trail',
|
||||||
|
type: 'line',
|
||||||
|
paint: styles['trail'].paint,
|
||||||
|
layout: styles['trail'].layout,
|
||||||
|
/*
|
||||||
|
paint: {
|
||||||
|
'line-color': '#cd861a',
|
||||||
|
'line-width': 6,
|
||||||
|
'line-blur': 2,
|
||||||
|
'line-opacity': 0.9
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
'line-join': 'bevel',
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
this.map.mapInstance.addLayer(
|
||||||
|
{
|
||||||
|
id: 'poi',
|
||||||
|
source: 'poi',
|
||||||
|
type: 'symbol',
|
||||||
|
paint: styles['poi'].paint,
|
||||||
|
layout: styles['poi'].layout,
|
||||||
|
/*
|
||||||
|
paint: {
|
||||||
|
'text-color': '#BB55CC',
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
//'text-field': ['match', ['get', 'plantekey_id'], '', '\ue033', '\ue034'],
|
||||||
|
'text-field': ['get', 'symbol'],
|
||||||
|
'text-overlap': 'always',
|
||||||
|
'text-anchor': 'center',
|
||||||
|
'text-font': ['TreetrailSymbols'],
|
||||||
|
'text-size': 32,
|
||||||
|
},
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
this.dataService.getPendingTrees().subscribe(
|
||||||
|
pendingTrees => {
|
||||||
|
let trees = this.dataService.treeFeatures.getValue()
|
||||||
|
trees['features'].push(...pendingTrees.map(pt => {
|
||||||
|
pt['feature']['properties']['pending'] = 2
|
||||||
|
return pt['feature']
|
||||||
|
}))
|
||||||
|
this.map.mapInstance.addSource(
|
||||||
|
'tree',
|
||||||
|
<GeoJSONSourceSpecification>{
|
||||||
|
'type': 'geojson',
|
||||||
|
'data': trees
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
this.map.mapInstance.addLayer(
|
||||||
|
{
|
||||||
|
id: 'tree',
|
||||||
|
source: 'tree',
|
||||||
|
type: 'symbol',
|
||||||
|
paint: styles['tree'].paint,
|
||||||
|
layout: styles['tree'].layout
|
||||||
|
/*
|
||||||
|
paint: {
|
||||||
|
'text-color': ['match', ['get', 'plantekey_id'], '', '#AAAA33', '#00BB00'],
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
//'text-field': ['match', ['get', 'plantekey_id'], '', '\ue033', '\ue034'],
|
||||||
|
'text-field': ['get', 'symbol'],
|
||||||
|
'text-overlap': 'always',
|
||||||
|
'text-anchor': 'center',
|
||||||
|
'text-font': ['TreetrailSymbols'],
|
||||||
|
'text-size': 32,
|
||||||
|
},
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
this.map.mapInstance.addLayer(
|
||||||
|
{
|
||||||
|
id: 'tree-hl',
|
||||||
|
source: 'tree',
|
||||||
|
type: 'symbol',
|
||||||
|
paint: styles['tree-hl'].paint,
|
||||||
|
layout: styles['tree-hl'].layout,
|
||||||
|
/*
|
||||||
|
paint: {
|
||||||
|
'icon-color': 'green',
|
||||||
|
'text-color': 'green',
|
||||||
|
'text-opacity': 1,
|
||||||
|
'text-halo-color': 'red',
|
||||||
|
'text-halo-width': 0.8,
|
||||||
|
'text-halo-blur': 0.5,
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
'text-field': ['get', 'symbol'],
|
||||||
|
'text-size': 40,
|
||||||
|
'text-overlap': 'always',
|
||||||
|
'text-anchor': 'center',
|
||||||
|
'text-font': ['TreetrailSymbols'],
|
||||||
|
},
|
||||||
|
*/
|
||||||
|
filter: ['==', 'id', ''],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Track mouse overs only when the tree layer has been added
|
||||||
|
this.map.mapInstance.on('mousemove', evt => this.onMouseMove(evt))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
this.map.mapInstance.on('zoomend', evt => this.onMapPosChange(evt))
|
||||||
|
this.map.mapInstance.on('dragend', evt => this.onMapPosChange(evt))
|
||||||
|
this.map.mapInstance.on('pitchend', evt => this.onMapPosChange(evt))
|
||||||
|
|
||||||
|
// Add and filter zones when the data and showZones conf is available
|
||||||
|
// TODO: Fix bootstrap
|
||||||
|
let zl: TypedStyleLayer = <TypedStyleLayer>this.map.mapInstance.getLayer('zone')
|
||||||
|
let visibleTypes = Object.entries(this.configService.conf.value.showZones).filter(
|
||||||
|
([type, visible]) => visible
|
||||||
|
).map(([type, visible]) => type)
|
||||||
|
if (visibleTypes.length == 0) {
|
||||||
|
visibleTypes = ['']
|
||||||
|
}
|
||||||
|
this.map.mapInstance.setFilter(
|
||||||
|
'zone',
|
||||||
|
['match', ['get', 'type'], visibleTypes, true, false]
|
||||||
|
)
|
||||||
|
zl.setLayoutProperty('visibility', 'visible')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle addition of trees
|
||||||
|
this.dataService.addTree$.subscribe(
|
||||||
|
(newTree: NewTree) => {
|
||||||
|
let treeFeatures = this.dataService.treeFeatures.getValue()
|
||||||
|
treeFeatures['features'].push(newTree.getFeature())
|
||||||
|
this.dataService.treeFeatures.next(treeFeatures)
|
||||||
|
const mapSource = this.map.mapInstance.getSource('tree') as GeoJSONSource
|
||||||
|
mapSource.setData(<any>treeFeatures)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Simulate a location change to init system
|
||||||
|
this.actionService.onLocationChange()
|
||||||
|
|
||||||
|
this.actionService.nearestTree$.subscribe(
|
||||||
|
(tree: FeatureTarget) => {
|
||||||
|
if (!this.map.mapInstance.getLayer
|
||||||
|
// For unknown reason, mapInstance might not have getLayer...
|
||||||
|
// Observed on Android Chromium
|
||||||
|
|| !this.map.mapInstance.getLayer('tree-hl')
|
||||||
|
|| tree == undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.map.mapInstance.setFilter(
|
||||||
|
'tree-hl',
|
||||||
|
['==', 'id', tree.feature.properties['id']]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
this.activatedRoute.queryParams.subscribe(
|
||||||
|
(params: Params) => {
|
||||||
|
if ('show' in params) {
|
||||||
|
let featureRef: string[] = params['show'].split(':')
|
||||||
|
let store = featureRef[0]
|
||||||
|
let field = featureRef[1]
|
||||||
|
let value = featureRef[2]
|
||||||
|
// XXX: field is not used and assumed to be 'id'
|
||||||
|
this.dataService.trailFeatures.subscribe(
|
||||||
|
trailSource => {
|
||||||
|
let trail = trailSource['features'].find(
|
||||||
|
f => f['id'] == value
|
||||||
|
)
|
||||||
|
let bounds = bbox(trail)
|
||||||
|
let margin = 0.00025
|
||||||
|
this.map.mapInstance.fitBounds(
|
||||||
|
[
|
||||||
|
[bounds[0] - margin, bounds[1] - margin],
|
||||||
|
[bounds[2] + margin, bounds[3] + margin],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMove(evt: MapMouseEvent) {
|
||||||
|
let features = this.map.mapInstance.queryRenderedFeatures(evt.point,
|
||||||
|
{ layers: ['tree', 'trail'] })
|
||||||
|
let feature = features[0]
|
||||||
|
if (feature) {
|
||||||
|
if (feature.layer['id'] == 'tree') {
|
||||||
|
this.map.mapInstance.getContainer().classList.add('on-item')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.map.mapInstance.getContainer().classList.remove('on-item')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.map.mapInstance.getContainer().classList.remove('on-item')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getUID(layer: string, id: string): string {
|
||||||
|
return `${layer}-${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
getBestFeature(evt): MapGeoJSONFeature {
|
||||||
|
// Kiss Maplibre, which seems to return the features on different layers
|
||||||
|
// with the same order that they were added
|
||||||
|
let features = this.map.mapInstance.queryRenderedFeatures(evt.point, {
|
||||||
|
layers: myLayers
|
||||||
|
})
|
||||||
|
return features[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseMove(evt) {
|
||||||
|
if (this.mapEditService.active) {
|
||||||
|
this.map.mapInstance.getCanvas().style.cursor = 'crosshair'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let features: Map<string, MapGeoJSONFeature[]> = new Map()
|
||||||
|
let msgs: Map<string, string> = new Map()
|
||||||
|
let found: boolean = false
|
||||||
|
for (let layer of myLayers) {
|
||||||
|
let features = this.map.mapInstance.queryRenderedFeatures(evt.point, {
|
||||||
|
layers: [layer]
|
||||||
|
})
|
||||||
|
if (features.length > 0) {
|
||||||
|
found = true
|
||||||
|
if (features.length === 1) {
|
||||||
|
msgs.set(layer, this.getFeatureMsg(layer, features[0].properties))
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
msgs.set(layer, `(${features.length}*)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found) {
|
||||||
|
this.map.mapInstance.getCanvas().style.cursor = 'zoom-in'
|
||||||
|
this.featureInfo.nativeElement.innerHTML = Array.from(msgs).map(
|
||||||
|
([key, value]) => `${key}: ${value}`
|
||||||
|
).join(' - ')
|
||||||
|
} else {
|
||||||
|
this.map.mapInstance.getCanvas().style.cursor = ''
|
||||||
|
this.featureInfo.nativeElement.innerHTML = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFeatureMsg(layer: string, f: Object): string {
|
||||||
|
switch (layer) {
|
||||||
|
case 'tree':
|
||||||
|
let pekid = f['plantekey_id']
|
||||||
|
if (pekid != '') {
|
||||||
|
let plant = this.dataService.all.value.plants[pekid]
|
||||||
|
if (plant) return plant.name
|
||||||
|
else return 'unknown'
|
||||||
|
}
|
||||||
|
else return 'unknown'
|
||||||
|
case 'trail':
|
||||||
|
return f['name']
|
||||||
|
case 'poi':
|
||||||
|
return f['name']
|
||||||
|
case 'zone':
|
||||||
|
return `${f['type'] || 'Zone'}: ${f['name']}`
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickItem(evt) {
|
||||||
|
let feature = this.getBestFeature(evt)
|
||||||
|
if (feature === undefined) {
|
||||||
|
this.popup.popupInstance.remove()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let fid = feature['properties']['id'] || feature['id']
|
||||||
|
let coordinates: any
|
||||||
|
this.ngZone.run(() => {
|
||||||
|
switch (feature.layer.id) {
|
||||||
|
case 'tree': {
|
||||||
|
coordinates = (<any>feature.geometry).coordinates.slice()
|
||||||
|
if (feature['properties']['pending']) {
|
||||||
|
this.dataService.getPendingTree(fid).subscribe(
|
||||||
|
tree => {
|
||||||
|
this.treePopupDetail.tree = tree
|
||||||
|
this.treePopupDetail.cdr.markForCheck()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.treePopupDetail.tree = this.dataService.all.value.trees[fid]
|
||||||
|
}
|
||||||
|
this.treePopupDetail.cdr.markForCheck()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'poi': {
|
||||||
|
coordinates = (<any>feature.geometry).coordinates.slice()
|
||||||
|
this.poiPopupDetail.poi = this.dataService.all.value.pois[fid]
|
||||||
|
this.poiPopupDetail.cdr.markForCheck()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'trail': {
|
||||||
|
coordinates = evt.lngLat
|
||||||
|
this.trailPopupDetail.trail = this.dataService.all.value.trails[fid]
|
||||||
|
this.trailPopupDetail.cdr.markForCheck()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'zone': {
|
||||||
|
coordinates = evt.lngLat
|
||||||
|
this.zonePopupDetail.zone = this.dataService.all.value.zones[fid]
|
||||||
|
this.zonePopupDetail.cdr.markForCheck()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default: { }
|
||||||
|
}
|
||||||
|
this.currentPopupLayer = feature.layer.id
|
||||||
|
})
|
||||||
|
this.ngZone.runOutsideAngular(() => {
|
||||||
|
this.popup.popupInstance.setLngLat(coordinates)
|
||||||
|
setTimeout(() => this.popup.popupInstance.addTo(this.map.mapInstance))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onClick(evt: MapMouseEvent) {
|
||||||
|
if (this.mapEditService.active) {
|
||||||
|
this.mapEditService.addTree(evt.lngLat)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.onClickItem(evt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMapPosChange(evt: MapLibreEvent) {
|
||||||
|
this.configService.setMapPos({
|
||||||
|
center: this.map.mapInstance.getCenter(),
|
||||||
|
zoom: this.map.mapInstance.getZoom(),
|
||||||
|
bearing: this.map.mapInstance.getBearing(),
|
||||||
|
pitch: this.map.mapInstance.getPitch(),
|
||||||
|
}).subscribe()
|
||||||
|
}
|
||||||
|
}
|
16
src/app/message.service.spec.ts
Normal file
16
src/app/message.service.spec.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { MessageService } from './message.service';
|
||||||
|
|
||||||
|
describe('MessageService', () => {
|
||||||
|
let service: MessageService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
service = TestBed.inject(MessageService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
16
src/app/message.service.ts
Normal file
16
src/app/message.service.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable, BehaviorSubject, Subject } from 'rxjs'
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class MessageService {
|
||||||
|
|
||||||
|
public message = new BehaviorSubject<string>(undefined)
|
||||||
|
public message$ = this.message.asObservable()
|
||||||
|
|
||||||
|
public spinner = new BehaviorSubject<boolean>(false)
|
||||||
|
public spinner$ = this.spinner.asObservable()
|
||||||
|
|
||||||
|
constructor() { }
|
||||||
|
}
|
5
src/app/message/message.component.html
Normal file
5
src/app/message/message.component.html
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<mat-spinner
|
||||||
|
*ngIf="messageService.spinner | async"
|
||||||
|
color="accent"
|
||||||
|
[diameter]="25"
|
||||||
|
></mat-spinner>
|
3
src/app/message/message.component.scss
Normal file
3
src/app/message/message.component.scss
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
mat-spinner {
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
25
src/app/message/message.component.spec.ts
Normal file
25
src/app/message/message.component.spec.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { MessageComponent } from './message.component';
|
||||||
|
|
||||||
|
describe('MessageComponent', () => {
|
||||||
|
let component: MessageComponent;
|
||||||
|
let fixture: ComponentFixture<MessageComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [ MessageComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(MessageComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
35
src/app/message/message.component.ts
Normal file
35
src/app/message/message.component.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { Component, AfterViewInit,
|
||||||
|
ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core'
|
||||||
|
|
||||||
|
import { MatSnackBar, MatSnackBarRef } from '@angular/material/snack-bar'
|
||||||
|
|
||||||
|
import { MessageService } from '../message.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-message',
|
||||||
|
templateUrl: './message.component.html',
|
||||||
|
styleUrls: ['./message.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class MessageComponent implements AfterViewInit {
|
||||||
|
constructor(
|
||||||
|
public messageService: MessageService,
|
||||||
|
private cdr: ChangeDetectorRef,
|
||||||
|
private _snackBar: MatSnackBar
|
||||||
|
) {}
|
||||||
|
|
||||||
|
durationInSeconds: number = 2.5
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.messageService.message$.subscribe(
|
||||||
|
message => {
|
||||||
|
if (!!message) {
|
||||||
|
this._snackBar.open(message, 'OK', {
|
||||||
|
duration: this.durationInSeconds * 1000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.cdr.markForCheck()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
260
src/app/models.ts
Normal file
260
src/app/models.ts
Normal file
|
@ -0,0 +1,260 @@
|
||||||
|
import { Feature } from 'maplibre-gl'
|
||||||
|
|
||||||
|
import length from '@turf/length'
|
||||||
|
|
||||||
|
export class FeatureTarget {
|
||||||
|
constructor(
|
||||||
|
public feature: Feature,
|
||||||
|
public distance: number,
|
||||||
|
public direction?: number,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeBucket {
|
||||||
|
[id: string]: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Trail {
|
||||||
|
constructor(
|
||||||
|
public id: number,
|
||||||
|
public name: string,
|
||||||
|
public description: string,
|
||||||
|
public feature: GeoJSON.Feature,
|
||||||
|
public trees: Trees,
|
||||||
|
public photo?: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get length(): number {
|
||||||
|
return length(<any>this.feature, {units: 'meters'})
|
||||||
|
}
|
||||||
|
|
||||||
|
get mapRouteArgs(): Object {
|
||||||
|
return { queryParams: { 'show': `trail:id:${this.id}`}}
|
||||||
|
}
|
||||||
|
|
||||||
|
get treeList(): Tree[] {
|
||||||
|
return Object.values(this.trees)
|
||||||
|
}
|
||||||
|
|
||||||
|
getTreeBucket(): TreeBucket {
|
||||||
|
const counts = {};
|
||||||
|
let plantekeys = this.treeList.map(t => t.plantekeyId)
|
||||||
|
plantekeys.forEach((x) => {
|
||||||
|
counts[x] = (counts[x] || 0) + 1;
|
||||||
|
})
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
getPhotoUrl(): string {
|
||||||
|
return `/attachment/trail/${this.id}/${this.photo}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Trails {
|
||||||
|
[id: string]: Trail
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlantsTrails {
|
||||||
|
[plantId: string]: Trails
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Tree {
|
||||||
|
constructor(
|
||||||
|
public id: string, // UUID
|
||||||
|
public feature: GeoJSON.Feature,
|
||||||
|
public plantekeyId?: string,
|
||||||
|
public photo?: string,
|
||||||
|
public data?: Object,
|
||||||
|
public height?: string,
|
||||||
|
public comments?: string,
|
||||||
|
public plant?: Plant,
|
||||||
|
public trails: Trail[] = [],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getPhotoUrl(): (string | void) {
|
||||||
|
if (this.photo) {
|
||||||
|
if (this.photo.startsWith('data:image')) {
|
||||||
|
return this.photo
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return `/attachment/tree/${this.id}/${this.photo}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return this.plant.get_thumbnail_url()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Trees {
|
||||||
|
[id: string]: Tree
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TreeTrails {
|
||||||
|
[treeId: string]: Trails
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TreeTrail {
|
||||||
|
constructor(
|
||||||
|
public tree_id: string,
|
||||||
|
public trail_id: number,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Poi {
|
||||||
|
constructor(
|
||||||
|
public id: number,
|
||||||
|
public feature: GeoJSON.Feature,
|
||||||
|
public name?: string,
|
||||||
|
public type?: string,
|
||||||
|
public description?: string,
|
||||||
|
public photo?: string,
|
||||||
|
public data?: Object,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getPhotoUrl(): string {
|
||||||
|
return `/attachment/poi/${this.id}/${this.photo}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Pois {
|
||||||
|
[id: string]: Poi
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Zone {
|
||||||
|
constructor(
|
||||||
|
public id: number,
|
||||||
|
public geojson: GeoJSON.Feature,
|
||||||
|
public name?: string,
|
||||||
|
public type?: string,
|
||||||
|
public description?: string,
|
||||||
|
public photo?: string,
|
||||||
|
public data?: Object,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getPhotoUrl(): string {
|
||||||
|
return `/attachment/zone/${this.id}/${this.photo}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Zones {
|
||||||
|
[id: string]: Zone
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Style {
|
||||||
|
constructor(
|
||||||
|
public layer: string,
|
||||||
|
public paint?: Object,
|
||||||
|
public layout?: Object,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Styles {
|
||||||
|
[layer: string]: Style
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Plant {
|
||||||
|
constructor(
|
||||||
|
public ID: string,
|
||||||
|
public id: string,
|
||||||
|
public english: string,
|
||||||
|
public family: string,
|
||||||
|
public hindi: string,
|
||||||
|
public img: string,
|
||||||
|
public name: string,
|
||||||
|
public spiritual: string,
|
||||||
|
public tamil: string,
|
||||||
|
public type: string,
|
||||||
|
public description: string,
|
||||||
|
public habit: string,
|
||||||
|
public landscape: string,
|
||||||
|
public uses: string,
|
||||||
|
public planting: string,
|
||||||
|
public propagation: string,
|
||||||
|
public element: string,
|
||||||
|
public woody: string,
|
||||||
|
public latex: string,
|
||||||
|
public leaf_style: string,
|
||||||
|
public leaf_type: string,
|
||||||
|
public leaf_arrangement: string,
|
||||||
|
public leaf_aroma: string,
|
||||||
|
public leaf_length: string,
|
||||||
|
public leaf_width: string,
|
||||||
|
public flower_color: string,
|
||||||
|
public flower_aroma: string,
|
||||||
|
public flower_size: string,
|
||||||
|
public fruit_color: string,
|
||||||
|
public fruit_size: string,
|
||||||
|
public fruit_type: string,
|
||||||
|
public thorny: string,
|
||||||
|
public images: string[],
|
||||||
|
public symbol: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get_thumbnail_url() {
|
||||||
|
return '/attachment/plantekey/thumb/' + this.img
|
||||||
|
}
|
||||||
|
|
||||||
|
isMatch(searchText: string) {
|
||||||
|
searchText = searchText.toLowerCase()
|
||||||
|
return (
|
||||||
|
this.id.indexOf(searchText) != -1 ||
|
||||||
|
this.name.indexOf(searchText) != -1 ||
|
||||||
|
(this.english && this.english.toLowerCase().indexOf(searchText) != -1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getFriendlyName(): string {
|
||||||
|
if (this.english) {
|
||||||
|
return `${this.name} (${this.english})`
|
||||||
|
}
|
||||||
|
else if (this.spiritual) {
|
||||||
|
return `${this.name} (${this.spiritual})`
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return this.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Plants {
|
||||||
|
[id: string]: Plant
|
||||||
|
}
|
||||||
|
|
||||||
|
export class All {
|
||||||
|
constructor(
|
||||||
|
public trees: Trees = {},
|
||||||
|
public trails: Trails = {},
|
||||||
|
public tree_trails: TreeTrail[] = [],
|
||||||
|
public plants: Plants = {},
|
||||||
|
public pois: Pois = {},
|
||||||
|
public zones: Zones = {},
|
||||||
|
public styles: Styles = {},
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Role {
|
||||||
|
constructor(
|
||||||
|
public name: String,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class User {
|
||||||
|
constructor(
|
||||||
|
public username: String,
|
||||||
|
public roles: Role[],
|
||||||
|
public full_name?: String,
|
||||||
|
public email?: String,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
let layer: LayerSpecification = <LayerSpecification>{
|
||||||
|
id: layerDef.store,
|
||||||
|
type: layerDef.type,
|
||||||
|
source: <GeoJSONSourceSpecification>{
|
||||||
|
type: "geojson",
|
||||||
|
data: data
|
||||||
|
},
|
||||||
|
attribution: resp.style.attribution
|
||||||
|
}
|
||||||
|
*/
|
42
src/app/nav/nav.component.html
Normal file
42
src/app/nav/nav.component.html
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
<mat-sidenav-container class="sidenav-container">
|
||||||
|
<mat-sidenav #drawer class="sidenav" fixedInViewport
|
||||||
|
[attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
|
||||||
|
[mode]="(isHandset$ | async) ? 'over' : 'side'"
|
||||||
|
[opened]="(isHandset$ | async) === false">
|
||||||
|
<!--<mat-toolbar>Menu</mat-toolbar>-->
|
||||||
|
<mat-nav-list>
|
||||||
|
<a mat-list-item routerLink="home"><mat-icon>home</mat-icon>Home</a>
|
||||||
|
<a mat-list-item routerLink="trail"><mat-icon>directions_walk</mat-icon>Trails</a>
|
||||||
|
<a mat-list-item routerLink="plant"><mat-icon>local_florist</mat-icon>Plants</a>
|
||||||
|
<a mat-list-item routerLink="map"><mat-icon>map</mat-icon>Map</a>
|
||||||
|
<a mat-list-item routerLink="settings"><mat-icon>settings</mat-icon>Settings</a>
|
||||||
|
<!--<a mat-list-item routerLink="admin" class='admin'><mat-icon>settings</mat-icon>Admin</a>-->
|
||||||
|
<!--<a mat-list-item routerLink="about"><mat-icon>info</mat-icon>About</a>-->
|
||||||
|
<!--
|
||||||
|
<hr/>
|
||||||
|
<a mat-list-item [routerLink]="trail.id" *ngFor="let trail of dataService.trails">{{ trail.properties.name }}</a>
|
||||||
|
-->
|
||||||
|
<div class="qrcode">
|
||||||
|
Share this site:
|
||||||
|
<img src="assets/img/site-url.png">
|
||||||
|
</div>
|
||||||
|
</mat-nav-list>
|
||||||
|
</mat-sidenav>
|
||||||
|
<mat-sidenav-content style="display: flex; flex-direction: column">
|
||||||
|
<mat-toolbar>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
matTooltip="Toggle sidenav"
|
||||||
|
mat-icon-button
|
||||||
|
(click)="drawer.toggle()"
|
||||||
|
*ngIf="isHandset$ | async">
|
||||||
|
<mat-icon>menu</mat-icon>
|
||||||
|
</button>
|
||||||
|
<div class="appTitle">
|
||||||
|
<div class="title">{{ (configService.conf | async).bootstrap?.app.title }}</div>
|
||||||
|
</div>
|
||||||
|
<app-message></app-message>
|
||||||
|
</mat-toolbar>
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
</mat-sidenav-content>
|
||||||
|
</mat-sidenav-container>
|
98
src/app/nav/nav.component.scss
Normal file
98
src/app/nav/nav.component.scss
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
.sidenav-container {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidenav {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidenav {
|
||||||
|
background-color:rgba(250, 255, 220);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-drawer-container {
|
||||||
|
background-color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-toolbar.mat-primary {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-toolbar {
|
||||||
|
padding: 0;
|
||||||
|
height: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-toolbar .appTitle {
|
||||||
|
font-family: TenderLeaf;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: lighter;
|
||||||
|
font-variant-caps: petite-caps;
|
||||||
|
text-shadow: 5px 3px 5px #3e371c81;
|
||||||
|
opacity: .9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-toolbar app-message {
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-icon {
|
||||||
|
padding-right: 0.5em;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-toolbar {
|
||||||
|
// Angular build cannot find the asset: removing for now
|
||||||
|
//background-image: url("assets/img/banner.jpg");
|
||||||
|
background-color: #a9b15070;
|
||||||
|
background-size: contain;
|
||||||
|
border-bottom: groove;
|
||||||
|
border-color: rgb(187 210 186 / 70%);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-toolbar .mat-icon {
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appTitle {
|
||||||
|
margin: auto;
|
||||||
|
height: inherit;
|
||||||
|
display: flex;
|
||||||
|
align-items: center
|
||||||
|
}
|
||||||
|
|
||||||
|
.appTitle img {
|
||||||
|
height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
img {
|
||||||
|
height: 5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep {
|
||||||
|
.mat-mdc-form-field-subscript-wrapper {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.mdc-line-ripple {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
//.sidenav .mat-drawer-inner-container {
|
||||||
|
//background-image: url('assets/img/sidebar.jpg');
|
||||||
|
//background-position: center;
|
||||||
|
//}
|
||||||
|
|
||||||
|
.mat-sidenav-content > :last-child {
|
||||||
|
overflow: auto;
|
||||||
|
height: inherit;
|
||||||
|
}
|
||||||
|
}
|
40
src/app/nav/nav.component.spec.ts
Normal file
40
src/app/nav/nav.component.spec.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import { LayoutModule } from '@angular/cdk/layout';
|
||||||
|
import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatListModule } from '@angular/material/list';
|
||||||
|
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||||
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
|
|
||||||
|
import { NavComponent } from './nav.component';
|
||||||
|
|
||||||
|
describe('NavComponent', () => {
|
||||||
|
let component: NavComponent;
|
||||||
|
let fixture: ComponentFixture<NavComponent>;
|
||||||
|
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [NavComponent],
|
||||||
|
imports: [
|
||||||
|
NoopAnimationsModule,
|
||||||
|
LayoutModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatListModule,
|
||||||
|
MatSidenavModule,
|
||||||
|
MatToolbarModule,
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(NavComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compile', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
36
src/app/nav/nav.component.ts
Normal file
36
src/app/nav/nav.component.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import { Component, ViewChild,
|
||||||
|
ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core'
|
||||||
|
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout'
|
||||||
|
import { Router, NavigationEnd } from '@angular/router';
|
||||||
|
import { MatSidenav } from '@angular/material/sidenav'
|
||||||
|
import { Observable } from 'rxjs'
|
||||||
|
import { map, shareReplay, withLatestFrom, filter } from 'rxjs/operators'
|
||||||
|
|
||||||
|
import { DataService } from '../data.service'
|
||||||
|
import { ConfigService } from '../config.service'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-nav',
|
||||||
|
templateUrl: './nav.component.html',
|
||||||
|
styleUrls: ['./nav.component.scss'],
|
||||||
|
})
|
||||||
|
export class NavComponent {
|
||||||
|
@ViewChild('drawer') drawer: MatSidenav;
|
||||||
|
isHandset$: Observable<boolean> = this.breakpointObserver.observe(Breakpoints.Handset)
|
||||||
|
.pipe(
|
||||||
|
map(result => result.matches),
|
||||||
|
shareReplay()
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private breakpointObserver: BreakpointObserver,
|
||||||
|
public dataService: DataService,
|
||||||
|
public router: Router,
|
||||||
|
public configService: ConfigService,
|
||||||
|
) {
|
||||||
|
router.events.pipe(
|
||||||
|
withLatestFrom(this.isHandset$),
|
||||||
|
filter(([a, b]) => b && a instanceof NavigationEnd)
|
||||||
|
).subscribe(_ => this.drawer.close())
|
||||||
|
}
|
||||||
|
}
|
21
src/app/openapi/core/ApiError.ts
Normal file
21
src/app/openapi/core/ApiError.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import type { ApiRequestOptions } from './ApiRequestOptions';
|
||||||
|
import type { ApiResult } from './ApiResult';
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
public readonly url: string;
|
||||||
|
public readonly status: number;
|
||||||
|
public readonly statusText: string;
|
||||||
|
public readonly body: unknown;
|
||||||
|
public readonly request: ApiRequestOptions;
|
||||||
|
|
||||||
|
constructor(request: ApiRequestOptions, response: ApiResult, message: string) {
|
||||||
|
super(message);
|
||||||
|
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.url = response.url;
|
||||||
|
this.status = response.status;
|
||||||
|
this.statusText = response.statusText;
|
||||||
|
this.body = response.body;
|
||||||
|
this.request = request;
|
||||||
|
}
|
||||||
|
}
|
13
src/app/openapi/core/ApiRequestOptions.ts
Normal file
13
src/app/openapi/core/ApiRequestOptions.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
export type ApiRequestOptions = {
|
||||||
|
readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH';
|
||||||
|
readonly url: string;
|
||||||
|
readonly path?: Record<string, unknown>;
|
||||||
|
readonly cookies?: Record<string, unknown>;
|
||||||
|
readonly headers?: Record<string, unknown>;
|
||||||
|
readonly query?: Record<string, unknown>;
|
||||||
|
readonly formData?: Record<string, unknown>;
|
||||||
|
readonly body?: any;
|
||||||
|
readonly mediaType?: string;
|
||||||
|
readonly responseHeader?: string;
|
||||||
|
readonly errors?: Record<number, string>;
|
||||||
|
};
|
7
src/app/openapi/core/ApiResult.ts
Normal file
7
src/app/openapi/core/ApiResult.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
export type ApiResult<TData = any> = {
|
||||||
|
readonly body: TData;
|
||||||
|
readonly ok: boolean;
|
||||||
|
readonly status: number;
|
||||||
|
readonly statusText: string;
|
||||||
|
readonly url: string;
|
||||||
|
};
|
55
src/app/openapi/core/OpenAPI.ts
Normal file
55
src/app/openapi/core/OpenAPI.ts
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import type { HttpResponse } from '@angular/common/http';
|
||||||
|
import type { ApiRequestOptions } from './ApiRequestOptions';
|
||||||
|
|
||||||
|
type Headers = Record<string, string>;
|
||||||
|
type Middleware<T> = (value: T) => T | Promise<T>;
|
||||||
|
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
|
||||||
|
|
||||||
|
export class Interceptors<T> {
|
||||||
|
_fns: Middleware<T>[];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._fns = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
eject(fn: Middleware<T>) {
|
||||||
|
const index = this._fns.indexOf(fn);
|
||||||
|
if (index !== -1) {
|
||||||
|
this._fns = [...this._fns.slice(0, index), ...this._fns.slice(index + 1)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use(fn: Middleware<T>) {
|
||||||
|
this._fns = [...this._fns, fn];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OpenAPIConfig = {
|
||||||
|
BASE: string;
|
||||||
|
CREDENTIALS: 'include' | 'omit' | 'same-origin';
|
||||||
|
ENCODE_PATH?: ((path: string) => string) | undefined;
|
||||||
|
HEADERS?: Headers | Resolver<Headers> | undefined;
|
||||||
|
PASSWORD?: string | Resolver<string> | undefined;
|
||||||
|
TOKEN?: string | Resolver<string> | undefined;
|
||||||
|
USERNAME?: string | Resolver<string> | undefined;
|
||||||
|
VERSION: string;
|
||||||
|
WITH_CREDENTIALS: boolean;
|
||||||
|
interceptors: {
|
||||||
|
response: Interceptors<HttpResponse<any>>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OpenAPI: OpenAPIConfig = {
|
||||||
|
BASE: 'v1',
|
||||||
|
CREDENTIALS: 'include',
|
||||||
|
ENCODE_PATH: undefined,
|
||||||
|
HEADERS: undefined,
|
||||||
|
PASSWORD: undefined,
|
||||||
|
TOKEN: undefined,
|
||||||
|
USERNAME: undefined,
|
||||||
|
VERSION: '2023.4.dev53+g737a872.d20240606',
|
||||||
|
WITH_CREDENTIALS: false,
|
||||||
|
interceptors: {
|
||||||
|
response: new Interceptors(),
|
||||||
|
},
|
||||||
|
};
|
327
src/app/openapi/core/request.ts
Normal file
327
src/app/openapi/core/request.ts
Normal file
|
@ -0,0 +1,327 @@
|
||||||
|
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||||
|
import type { HttpResponse, HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { forkJoin, of, throwError } from 'rxjs';
|
||||||
|
import { catchError, map, switchMap } from 'rxjs/operators';
|
||||||
|
import type { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { ApiError } from './ApiError';
|
||||||
|
import type { ApiRequestOptions } from './ApiRequestOptions';
|
||||||
|
import type { ApiResult } from './ApiResult';
|
||||||
|
import type { OpenAPIConfig } from './OpenAPI';
|
||||||
|
|
||||||
|
export const isString = (value: unknown): value is string => {
|
||||||
|
return typeof value === 'string';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isStringWithValue = (value: unknown): value is string => {
|
||||||
|
return isString(value) && value !== '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isBlob = (value: any): value is Blob => {
|
||||||
|
return value instanceof Blob;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isFormData = (value: unknown): value is FormData => {
|
||||||
|
return value instanceof FormData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const base64 = (str: string): string => {
|
||||||
|
try {
|
||||||
|
return btoa(str);
|
||||||
|
} catch (err) {
|
||||||
|
// @ts-ignore
|
||||||
|
return Buffer.from(str).toString('base64');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getQueryString = (params: Record<string, unknown>): string => {
|
||||||
|
const qs: string[] = [];
|
||||||
|
|
||||||
|
const append = (key: string, value: unknown) => {
|
||||||
|
qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const encodePair = (key: string, value: unknown) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
append(key, value.toISOString());
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
value.forEach(v => encodePair(key, v));
|
||||||
|
} else if (typeof value === 'object') {
|
||||||
|
Object.entries(value).forEach(([k, v]) => encodePair(`${key}[${k}]`, v));
|
||||||
|
} else {
|
||||||
|
append(key, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.entries(params).forEach(([key, value]) => encodePair(key, value));
|
||||||
|
|
||||||
|
return qs.length ? `?${qs.join('&')}` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => {
|
||||||
|
const encoder = config.ENCODE_PATH || encodeURI;
|
||||||
|
|
||||||
|
const path = options.url
|
||||||
|
.replace('{api-version}', config.VERSION)
|
||||||
|
.replace(/{(.*?)}/g, (substring: string, group: string) => {
|
||||||
|
if (options.path?.hasOwnProperty(group)) {
|
||||||
|
return encoder(String(options.path[group]));
|
||||||
|
}
|
||||||
|
return substring;
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = config.BASE + path;
|
||||||
|
return options.query ? url + getQueryString(options.query) : url;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFormData = (options: ApiRequestOptions): FormData | undefined => {
|
||||||
|
if (options.formData) {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
const process = (key: string, value: unknown) => {
|
||||||
|
if (isString(value) || isBlob(value)) {
|
||||||
|
formData.append(key, value);
|
||||||
|
} else {
|
||||||
|
formData.append(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.entries(options.formData)
|
||||||
|
.filter(([, value]) => value !== undefined && value !== null)
|
||||||
|
.forEach(([key, value]) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach(v => process(key, v));
|
||||||
|
} else {
|
||||||
|
process(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
|
||||||
|
|
||||||
|
export const resolve = async <T>(options: ApiRequestOptions, resolver?: T | Resolver<T>): Promise<T | undefined> => {
|
||||||
|
if (typeof resolver === 'function') {
|
||||||
|
return (resolver as Resolver<T>)(options);
|
||||||
|
}
|
||||||
|
return resolver;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getHeaders = (config: OpenAPIConfig, options: ApiRequestOptions): Observable<HttpHeaders> => {
|
||||||
|
return forkJoin({
|
||||||
|
token: resolve(options, config.TOKEN),
|
||||||
|
username: resolve(options, config.USERNAME),
|
||||||
|
password: resolve(options, config.PASSWORD),
|
||||||
|
additionalHeaders: resolve(options, config.HEADERS),
|
||||||
|
}).pipe(
|
||||||
|
map(({ token, username, password, additionalHeaders }) => {
|
||||||
|
const headers = Object.entries({
|
||||||
|
Accept: 'application/json',
|
||||||
|
...additionalHeaders,
|
||||||
|
...options.headers,
|
||||||
|
})
|
||||||
|
.filter(([, value]) => value !== undefined && value !== null)
|
||||||
|
.reduce((headers, [key, value]) => ({
|
||||||
|
...headers,
|
||||||
|
[key]: String(value),
|
||||||
|
}), {} as Record<string, string>);
|
||||||
|
|
||||||
|
if (isStringWithValue(token)) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStringWithValue(username) && isStringWithValue(password)) {
|
||||||
|
const credentials = base64(`${username}:${password}`);
|
||||||
|
headers['Authorization'] = `Basic ${credentials}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.body !== undefined) {
|
||||||
|
if (options.mediaType) {
|
||||||
|
headers['Content-Type'] = options.mediaType;
|
||||||
|
} else if (isBlob(options.body)) {
|
||||||
|
headers['Content-Type'] = options.body.type || 'application/octet-stream';
|
||||||
|
} else if (isString(options.body)) {
|
||||||
|
headers['Content-Type'] = 'text/plain';
|
||||||
|
} else if (!isFormData(options.body)) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HttpHeaders(headers);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRequestBody = (options: ApiRequestOptions): unknown => {
|
||||||
|
if (options.body) {
|
||||||
|
if (options.mediaType?.includes('application/json') || options.mediaType?.includes('+json')) {
|
||||||
|
return JSON.stringify(options.body);
|
||||||
|
} else if (isString(options.body) || isBlob(options.body) || isFormData(options.body)) {
|
||||||
|
return options.body;
|
||||||
|
} else {
|
||||||
|
return JSON.stringify(options.body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendRequest = <T>(
|
||||||
|
config: OpenAPIConfig,
|
||||||
|
options: ApiRequestOptions,
|
||||||
|
http: HttpClient,
|
||||||
|
url: string,
|
||||||
|
body: unknown,
|
||||||
|
formData: FormData | undefined,
|
||||||
|
headers: HttpHeaders
|
||||||
|
): Observable<HttpResponse<T>> => {
|
||||||
|
return http.request<T>(options.method, url, {
|
||||||
|
headers,
|
||||||
|
body: body ?? formData,
|
||||||
|
withCredentials: config.WITH_CREDENTIALS,
|
||||||
|
observe: 'response',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getResponseHeader = <T>(response: HttpResponse<T>, responseHeader?: string): string | undefined => {
|
||||||
|
if (responseHeader) {
|
||||||
|
const value = response.headers.get(responseHeader);
|
||||||
|
if (isString(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getResponseBody = <T>(response: HttpResponse<T>): T | undefined => {
|
||||||
|
if (response.status !== 204 && response.body !== null) {
|
||||||
|
return response.body;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => {
|
||||||
|
const errors: Record<number, string> = {
|
||||||
|
400: 'Bad Request',
|
||||||
|
401: 'Unauthorized',
|
||||||
|
402: 'Payment Required',
|
||||||
|
403: 'Forbidden',
|
||||||
|
404: 'Not Found',
|
||||||
|
405: 'Method Not Allowed',
|
||||||
|
406: 'Not Acceptable',
|
||||||
|
407: 'Proxy Authentication Required',
|
||||||
|
408: 'Request Timeout',
|
||||||
|
409: 'Conflict',
|
||||||
|
410: 'Gone',
|
||||||
|
411: 'Length Required',
|
||||||
|
412: 'Precondition Failed',
|
||||||
|
413: 'Payload Too Large',
|
||||||
|
414: 'URI Too Long',
|
||||||
|
415: 'Unsupported Media Type',
|
||||||
|
416: 'Range Not Satisfiable',
|
||||||
|
417: 'Expectation Failed',
|
||||||
|
418: 'Im a teapot',
|
||||||
|
421: 'Misdirected Request',
|
||||||
|
422: 'Unprocessable Content',
|
||||||
|
423: 'Locked',
|
||||||
|
424: 'Failed Dependency',
|
||||||
|
425: 'Too Early',
|
||||||
|
426: 'Upgrade Required',
|
||||||
|
428: 'Precondition Required',
|
||||||
|
429: 'Too Many Requests',
|
||||||
|
431: 'Request Header Fields Too Large',
|
||||||
|
451: 'Unavailable For Legal Reasons',
|
||||||
|
500: 'Internal Server Error',
|
||||||
|
501: 'Not Implemented',
|
||||||
|
502: 'Bad Gateway',
|
||||||
|
503: 'Service Unavailable',
|
||||||
|
504: 'Gateway Timeout',
|
||||||
|
505: 'HTTP Version Not Supported',
|
||||||
|
506: 'Variant Also Negotiates',
|
||||||
|
507: 'Insufficient Storage',
|
||||||
|
508: 'Loop Detected',
|
||||||
|
510: 'Not Extended',
|
||||||
|
511: 'Network Authentication Required',
|
||||||
|
...options.errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = errors[result.status];
|
||||||
|
if (error) {
|
||||||
|
throw new ApiError(options, result, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const errorStatus = result.status ?? 'unknown';
|
||||||
|
const errorStatusText = result.statusText ?? 'unknown';
|
||||||
|
const errorBody = (() => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(result.body, null, 2);
|
||||||
|
} catch (e) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
throw new ApiError(options, result,
|
||||||
|
`Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request method
|
||||||
|
* @param config The OpenAPI configuration object
|
||||||
|
* @param http The Angular HTTP client
|
||||||
|
* @param options The request options from the service
|
||||||
|
* @returns Observable<T>
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
export const request = <T>(config: OpenAPIConfig, http: HttpClient, options: ApiRequestOptions): Observable<T> => {
|
||||||
|
const url = getUrl(config, options);
|
||||||
|
const formData = getFormData(options);
|
||||||
|
const body = getRequestBody(options);
|
||||||
|
|
||||||
|
return getHeaders(config, options).pipe(
|
||||||
|
switchMap(headers => {
|
||||||
|
return sendRequest<T>(config, options, http, url, body, formData, headers);
|
||||||
|
}),
|
||||||
|
switchMap(async response => {
|
||||||
|
for (const fn of config.interceptors.response._fns) {
|
||||||
|
response = await fn(response);
|
||||||
|
}
|
||||||
|
const responseBody = getResponseBody(response);
|
||||||
|
const responseHeader = getResponseHeader(response, options.responseHeader);
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
ok: response.ok,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
body: responseHeader ?? responseBody,
|
||||||
|
} as ApiResult;
|
||||||
|
}),
|
||||||
|
catchError((error: HttpErrorResponse) => {
|
||||||
|
if (!error.status) {
|
||||||
|
return throwError(() => error);
|
||||||
|
}
|
||||||
|
return of({
|
||||||
|
url,
|
||||||
|
ok: error.ok,
|
||||||
|
status: error.status,
|
||||||
|
statusText: error.statusText,
|
||||||
|
body: error.error ?? error.statusText,
|
||||||
|
} as ApiResult);
|
||||||
|
}),
|
||||||
|
map(result => {
|
||||||
|
catchErrorCodes(options, result);
|
||||||
|
return result.body as T;
|
||||||
|
}),
|
||||||
|
catchError((error: ApiError) => {
|
||||||
|
return throwError(() => error);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
6
src/app/openapi/index.ts
Normal file
6
src/app/openapi/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
export { ApiError } from './core/ApiError';
|
||||||
|
export { OpenAPI, type OpenAPIConfig } from './core/OpenAPI';
|
||||||
|
export * from './schemas.gen';
|
||||||
|
export * from './services.gen';
|
||||||
|
export * from './types.gen';
|
584
src/app/openapi/schemas.gen.ts
Normal file
584
src/app/openapi/schemas.gen.ts
Normal file
|
@ -0,0 +1,584 @@
|
||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
export const $App = {
|
||||||
|
properties: {
|
||||||
|
title: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Title',
|
||||||
|
default: 'Tree Trail'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
type: 'object',
|
||||||
|
title: 'App'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const $BaseMapStyles = {
|
||||||
|
properties: {
|
||||||
|
embedded: {
|
||||||
|
items: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
type: 'array',
|
||||||
|
title: 'Embedded'
|
||||||
|
},
|
||||||
|
external: {
|
||||||
|
additionalProperties: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
title: 'External'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
required: ['embedded', 'external'],
|
||||||
|
title: 'BaseMapStyles'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const $Body_addTree_tree_post = {
|
||||||
|
properties: {
|
||||||
|
plantekey_id: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Plantekey Id'
|
||||||
|
},
|
||||||
|
picture: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Picture'
|
||||||
|
},
|
||||||
|
trail_ids: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Trail Ids'
|
||||||
|
},
|
||||||
|
lng: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Lng'
|
||||||
|
},
|
||||||
|
lat: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Lat'
|
||||||
|
},
|
||||||
|
uuid1: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Uuid1'
|
||||||
|
},
|
||||||
|
details: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Details'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
required: ['plantekey_id', 'lng', 'lat'],
|
||||||
|
title: 'Body_addTree_tree_post'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const $Body_login_for_access_token_token_post = {
|
||||||
|
properties: {
|
||||||
|
grant_type: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
pattern: 'password'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Grant Type'
|
||||||
|
},
|
||||||
|
username: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Username'
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Password'
|
||||||
|
},
|
||||||
|
scope: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Scope',
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
client_id: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Client Id'
|
||||||
|
},
|
||||||
|
client_secret: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Client Secret'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
required: ['username', 'password'],
|
||||||
|
title: 'Body_login_for_access_token_token_post'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const $Body_upload_trail_photo_trail_photo__id___file_name__put = {
|
||||||
|
properties: {
|
||||||
|
file: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
format: 'binary'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'File'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
title: 'Body_upload_trail_photo_trail_photo__id___file_name__put'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const $Body_upload_tree_photo_tree_photo__id___file_name__put = {
|
||||||
|
properties: {
|
||||||
|
file: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
format: 'binary'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'File'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
title: 'Body_upload_tree_photo_tree_photo__id___file_name__put'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const $Body_upload_upload__type___field___id__post = {
|
||||||
|
properties: {
|
||||||
|
file: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'binary',
|
||||||
|
title: 'File'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
required: ['file'],
|
||||||
|
title: 'Body_upload_upload__type___field___id__post'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const $Bootstrap = {
|
||||||
|
properties: {
|
||||||
|
client: {
|
||||||
|
'$ref': '#/components/schemas/VersionedComponent'
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
'$ref': '#/components/schemas/VersionedComponent'
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
'$ref': '#/components/schemas/App'
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
'$ref': '#/components/schemas/UserWithRoles'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
map: {
|
||||||
|
'$ref': '#/components/schemas/Map'
|
||||||
|
},
|
||||||
|
baseMapStyles: {
|
||||||
|
'$ref': '#/components/schemas/BaseMapStyles'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
required: ['client', 'server', 'app', 'user', 'map', 'baseMapStyles'],
|
||||||
|
title: 'Bootstrap'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const $HTTPValidationError = {
|
||||||
|
properties: {
|
||||||
|
detail: {
|
||||||
|
items: {
|
||||||
|
'$ref': '#/components/schemas/ValidationError'
|
||||||
|
},
|
||||||
|
type: 'array',
|
||||||
|
title: 'Detail'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
title: 'HTTPValidationError'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const $Map = {
|
||||||
|
properties: {
|
||||||
|
zoom: {
|
||||||
|
type: 'number',
|
||||||
|
title: 'Zoom',
|
||||||
|
default: 14
|
||||||
|
},
|
||||||
|
pitch: {
|
||||||
|
type: 'number',
|
||||||
|
title: 'Pitch',
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
lat: {
|
||||||
|
type: 'number',
|
||||||
|
title: 'Lat',
|
||||||
|
default: 12
|
||||||
|
},
|
||||||
|
lng: {
|
||||||
|
type: 'number',
|
||||||
|
title: 'Lng',
|
||||||
|
default: 79.8106
|
||||||
|
},
|
||||||
|
bearing: {
|
||||||
|
type: 'number',
|
||||||
|
title: 'Bearing',
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Background',
|
||||||
|
default: 'osm'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
type: 'object',
|
||||||
|
title: 'Map'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const $MapStyle = {
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'integer',
|
||||||
|
title: 'Id'
|
||||||
|
},
|
||||||
|
layer: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Layer'
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'object'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Paint'
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'object'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Layout'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
required: ['id', 'layer', 'paint', 'layout'],
|
||||||
|
title: 'MapStyle'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const $POI = {
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'integer',
|
||||||
|
title: 'Id'
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Name'
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Description'
|
||||||
|
},
|
||||||
|
create_date: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time',
|
||||||
|
title: 'Create Date'
|
||||||
|
},
|
||||||
|
geom: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Geom'
|
||||||
|
},
|
||||||
|
photo: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Photo'
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Type'
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: 'object',
|
||||||
|
title: 'Data'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
required: ['id', 'name', 'geom', 'photo', 'type'],
|
||||||
|
title: 'POI'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const $Role = {
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Name'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
required: ['name'],
|
||||||
|
title: 'Role'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const $Token = {
|
||||||
|
properties: {
|
||||||
|
access_token: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Access Token'
|
||||||
|
},
|
||||||
|
token_type: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Token Type'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
required: ['access_token', 'token_type'],
|
||||||
|
title: 'Token'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const $TreeTrail = {
|
||||||
|
properties: {
|
||||||
|
tree_id: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Tree Id'
|
||||||
|
},
|
||||||
|
trail_id: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'integer'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Trail Id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
title: 'TreeTrail'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const $UserWithRoles = {
|
||||||
|
properties: {
|
||||||
|
username: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Username'
|
||||||
|
},
|
||||||
|
full_name: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Full Name'
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Email'
|
||||||
|
},
|
||||||
|
roles: {
|
||||||
|
items: {
|
||||||
|
'$ref': '#/components/schemas/Role'
|
||||||
|
},
|
||||||
|
type: 'array',
|
||||||
|
title: 'Roles'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
required: ['username', 'full_name', 'email', 'roles'],
|
||||||
|
title: 'UserWithRoles'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const $ValidationError = {
|
||||||
|
properties: {
|
||||||
|
loc: {
|
||||||
|
items: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'integer'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
type: 'array',
|
||||||
|
title: 'Location'
|
||||||
|
},
|
||||||
|
msg: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Message'
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Error Type'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
required: ['loc', 'msg', 'type'],
|
||||||
|
title: 'ValidationError'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const $VersionedComponent = {
|
||||||
|
properties: {
|
||||||
|
version: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Version'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
required: ['version'],
|
||||||
|
title: 'VersionedComponent'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const $Zone = {
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'integer',
|
||||||
|
title: 'Id'
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Name'
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Description'
|
||||||
|
},
|
||||||
|
create_date: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time',
|
||||||
|
title: 'Create Date'
|
||||||
|
},
|
||||||
|
geom: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Geom'
|
||||||
|
},
|
||||||
|
photo: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Photo'
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Type'
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'object'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Data'
|
||||||
|
},
|
||||||
|
viewable_role_id: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Viewable Role Id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
required: ['id', 'name', 'description', 'geom', 'photo', 'type', 'viewable_role_id'],
|
||||||
|
title: 'Zone'
|
||||||
|
} as const;
|
276
src/app/openapi/services.gen.ts
Normal file
276
src/app/openapi/services.gen.ts
Normal file
|
@ -0,0 +1,276 @@
|
||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import type { Observable } from 'rxjs';
|
||||||
|
import { OpenAPI } from './core/OpenAPI';
|
||||||
|
import { request as __request } from './core/request';
|
||||||
|
import type { GetBootstrapBootstrapGetResponse, LoginForAccessTokenTokenPostData, LoginForAccessTokenTokenPostResponse, UploadUploadTypeFieldIdPostData, UploadUploadTypeFieldIdPostResponse, MakeAttachmentsTarFileMakeAttachmentsTarFileGetResponse, LogoutLogoutGetResponse, GetTrailsTrailGetResponse, GetTrailAllDetailsTrailDetailsGetResponse, GetTreeTrailTreeTrailGetResponse, GetTreesTreeGetResponse, AddTreeTreePostData, AddTreeTreePostResponse, GetPoisPoiGetResponse, GetZonesZoneGetResponse, GetStylesStyleGetResponse, UploadTrailPhotoTrailPhotoIdFileNamePutData, UploadTrailPhotoTrailPhotoIdFileNamePutResponse, UploadTreePhotoTreePhotoIdFileNamePutData, UploadTreePhotoTreePhotoIdFileNamePutResponse } from './types.gen';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class DefaultService {
|
||||||
|
constructor(public readonly http: HttpClient) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Bootstrap
|
||||||
|
* @returns Bootstrap Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public getBootstrapBootstrapGet(): Observable<GetBootstrapBootstrapGetResponse> {
|
||||||
|
return __request(OpenAPI, this.http, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/bootstrap'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login For Access Token
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.formData
|
||||||
|
* @returns Token Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public loginForAccessTokenTokenPost(data: LoginForAccessTokenTokenPostData): Observable<LoginForAccessTokenTokenPostResponse> {
|
||||||
|
return __request(OpenAPI, this.http, {
|
||||||
|
method: 'POST',
|
||||||
|
url: '/token',
|
||||||
|
formData: data.formData,
|
||||||
|
mediaType: 'application/x-www-form-urlencoded',
|
||||||
|
errors: {
|
||||||
|
422: 'Validation Error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.type
|
||||||
|
* @param data.field
|
||||||
|
* @param data.id
|
||||||
|
* @param data.formData
|
||||||
|
* @returns unknown Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public uploadUploadTypeFieldIdPost(data: UploadUploadTypeFieldIdPostData): Observable<UploadUploadTypeFieldIdPostResponse> {
|
||||||
|
return __request(OpenAPI, this.http, {
|
||||||
|
method: 'POST',
|
||||||
|
url: '/upload/{type}/{field}/{id}',
|
||||||
|
path: {
|
||||||
|
type: data.type,
|
||||||
|
field: data.field,
|
||||||
|
id: data.id
|
||||||
|
},
|
||||||
|
formData: data.formData,
|
||||||
|
mediaType: 'multipart/form-data',
|
||||||
|
errors: {
|
||||||
|
422: 'Validation Error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makeattachmentstarfile
|
||||||
|
* Create a tar file with all photos, used to feed clients' caches
|
||||||
|
* for offline use
|
||||||
|
* @returns unknown Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public makeAttachmentsTarFileMakeAttachmentsTarFileGet(): Observable<MakeAttachmentsTarFileMakeAttachmentsTarFileGetResponse> {
|
||||||
|
return __request(OpenAPI, this.http, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/makeAttachmentsTarFile'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout
|
||||||
|
* @returns unknown Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public logoutLogoutGet(): Observable<LogoutLogoutGetResponse> {
|
||||||
|
return __request(OpenAPI, this.http, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/logout'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Trails
|
||||||
|
* Get all trails
|
||||||
|
* @returns unknown Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public getTrailsTrailGet(): Observable<GetTrailsTrailGetResponse> {
|
||||||
|
return __request(OpenAPI, this.http, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/trail'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Trail All Details
|
||||||
|
* Get details of all trails
|
||||||
|
* @returns unknown Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public getTrailAllDetailsTrailDetailsGet(): Observable<GetTrailAllDetailsTrailDetailsGetResponse> {
|
||||||
|
return __request(OpenAPI, this.http, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/trail/details'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Tree Trail
|
||||||
|
* Get all relations between trees and trails.
|
||||||
|
* Note that these are not checked for permissions, as there's no really
|
||||||
|
* valuable information.
|
||||||
|
* @returns TreeTrail Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public getTreeTrailTreeTrailGet(): Observable<GetTreeTrailTreeTrailGetResponse> {
|
||||||
|
return __request(OpenAPI, this.http, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/tree-trail'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Trees
|
||||||
|
* Get all trees
|
||||||
|
* @returns unknown Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public getTreesTreeGet(): Observable<GetTreesTreeGetResponse> {
|
||||||
|
return __request(OpenAPI, this.http, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/tree'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Addtree
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.formData
|
||||||
|
* @returns unknown Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public addTreeTreePost(data: AddTreeTreePostData): Observable<AddTreeTreePostResponse> {
|
||||||
|
return __request(OpenAPI, this.http, {
|
||||||
|
method: 'POST',
|
||||||
|
url: '/tree',
|
||||||
|
formData: data.formData,
|
||||||
|
mediaType: 'application/x-www-form-urlencoded',
|
||||||
|
errors: {
|
||||||
|
422: 'Validation Error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Pois
|
||||||
|
* Get all POI
|
||||||
|
* @returns POI Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public getPoisPoiGet(): Observable<GetPoisPoiGetResponse> {
|
||||||
|
return __request(OpenAPI, this.http, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/poi'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Zones
|
||||||
|
* Get all Zones
|
||||||
|
* @returns Zone Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public getZonesZoneGet(): Observable<GetZonesZoneGetResponse> {
|
||||||
|
return __request(OpenAPI, this.http, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/zone'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Styles
|
||||||
|
* Get all Styles
|
||||||
|
* @returns MapStyle Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public getStylesStyleGet(): Observable<GetStylesStyleGetResponse> {
|
||||||
|
return __request(OpenAPI, this.http, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/style'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload Trail Photo
|
||||||
|
* This was tested with QGis, provided the properties for the trail layer
|
||||||
|
* have been defined correctly.
|
||||||
|
* This includes: in "Attributes Form", field "photo", "Widget Type"
|
||||||
|
* is set as WebDav storage, with store URL set correcly with a URL like:
|
||||||
|
* * 'http://localhost:4200/v1/trail/photo/' || "id" || '/' || file_name(@selected_file_path)
|
||||||
|
* * 'https://treetrail.avcsr.org/v1/trail/' || "id" || '/' || file_name(@selected_file_path)
|
||||||
|
* ## XXX: probably broken info as paths have changed
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.id
|
||||||
|
* @param data.fileName
|
||||||
|
* @param data.formData
|
||||||
|
* @returns unknown Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public uploadTrailPhotoTrailPhotoIdFileNamePut(data: UploadTrailPhotoTrailPhotoIdFileNamePutData): Observable<UploadTrailPhotoTrailPhotoIdFileNamePutResponse> {
|
||||||
|
return __request(OpenAPI, this.http, {
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/trail/photo/{id}/{file_name}',
|
||||||
|
path: {
|
||||||
|
id: data.id,
|
||||||
|
file_name: data.fileName
|
||||||
|
},
|
||||||
|
formData: data.formData,
|
||||||
|
mediaType: 'multipart/form-data',
|
||||||
|
errors: {
|
||||||
|
422: 'Validation Error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload Tree Photo
|
||||||
|
* This was tested with QGis, provided the properties for the tree layer
|
||||||
|
* have been defined correctly.
|
||||||
|
* This includes: in "Attributes Form", field "photo", "Widget Type"
|
||||||
|
* is set as WebDav storage, with store URL set correcly with a URL like:
|
||||||
|
* * 'http://localhost:4200/v1/tree/photo/' || "id" || '/' || file_name(@selected_file_path)
|
||||||
|
* * 'https://treetrail.avcsr.org/v1/tree/' || "id" || '/' || file_name(@selected_file_path)
|
||||||
|
* ## XXX: probably broken info as paths have changed
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.id
|
||||||
|
* @param data.fileName
|
||||||
|
* @param data.formData
|
||||||
|
* @returns unknown Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public uploadTreePhotoTreePhotoIdFileNamePut(data: UploadTreePhotoTreePhotoIdFileNamePutData): Observable<UploadTreePhotoTreePhotoIdFileNamePutResponse> {
|
||||||
|
return __request(OpenAPI, this.http, {
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/tree/photo/{id}/{file_name}',
|
||||||
|
path: {
|
||||||
|
id: data.id,
|
||||||
|
file_name: data.fileName
|
||||||
|
},
|
||||||
|
formData: data.formData,
|
||||||
|
mediaType: 'multipart/form-data',
|
||||||
|
errors: {
|
||||||
|
422: 'Validation Error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
367
src/app/openapi/types.gen.ts
Normal file
367
src/app/openapi/types.gen.ts
Normal file
|
@ -0,0 +1,367 @@
|
||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
export type App = {
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BaseMapStyles = {
|
||||||
|
embedded: Array<(string)>;
|
||||||
|
external: {
|
||||||
|
[key: string]: (string);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Body_addTree_tree_post = {
|
||||||
|
plantekey_id: string;
|
||||||
|
picture?: string | null;
|
||||||
|
trail_ids?: string | null;
|
||||||
|
lng: string;
|
||||||
|
lat: string;
|
||||||
|
uuid1?: string | null;
|
||||||
|
details?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Body_login_for_access_token_token_post = {
|
||||||
|
grant_type?: string | null;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
scope?: string;
|
||||||
|
client_id?: string | null;
|
||||||
|
client_secret?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Body_upload_trail_photo_trail_photo__id___file_name__put = {
|
||||||
|
file?: (Blob | File) | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Body_upload_tree_photo_tree_photo__id___file_name__put = {
|
||||||
|
file?: (Blob | File) | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Body_upload_upload__type___field___id__post = {
|
||||||
|
file: (Blob | File);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Bootstrap = {
|
||||||
|
client: VersionedComponent;
|
||||||
|
server: VersionedComponent;
|
||||||
|
app: App;
|
||||||
|
user: UserWithRoles | null;
|
||||||
|
map: Map;
|
||||||
|
baseMapStyles: BaseMapStyles;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HTTPValidationError = {
|
||||||
|
detail?: Array<ValidationError>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Map = {
|
||||||
|
zoom?: number;
|
||||||
|
pitch?: number;
|
||||||
|
lat?: number;
|
||||||
|
lng?: number;
|
||||||
|
bearing?: number;
|
||||||
|
background?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapStyle = {
|
||||||
|
id: number;
|
||||||
|
layer: string;
|
||||||
|
paint: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | null;
|
||||||
|
layout: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type POI = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
create_date?: string;
|
||||||
|
geom: string;
|
||||||
|
photo: string;
|
||||||
|
type: string;
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Role = {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Token = {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TreeTrail = {
|
||||||
|
tree_id?: string | null;
|
||||||
|
trail_id?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserWithRoles = {
|
||||||
|
username: string;
|
||||||
|
full_name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
roles: Array<Role>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ValidationError = {
|
||||||
|
loc: Array<(string | number)>;
|
||||||
|
msg: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VersionedComponent = {
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Zone = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
create_date?: string;
|
||||||
|
geom: string;
|
||||||
|
photo: string | null;
|
||||||
|
type: string;
|
||||||
|
data?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | null;
|
||||||
|
viewable_role_id: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetBootstrapBootstrapGetResponse = Bootstrap;
|
||||||
|
|
||||||
|
export type LoginForAccessTokenTokenPostData = {
|
||||||
|
formData: Body_login_for_access_token_token_post;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginForAccessTokenTokenPostResponse = Token;
|
||||||
|
|
||||||
|
export type UploadUploadTypeFieldIdPostData = {
|
||||||
|
field: string;
|
||||||
|
formData: Body_upload_upload__type___field___id__post;
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UploadUploadTypeFieldIdPostResponse = unknown;
|
||||||
|
|
||||||
|
export type MakeAttachmentsTarFileMakeAttachmentsTarFileGetResponse = unknown;
|
||||||
|
|
||||||
|
export type LogoutLogoutGetResponse = unknown;
|
||||||
|
|
||||||
|
export type GetTrailsTrailGetResponse = unknown;
|
||||||
|
|
||||||
|
export type GetTrailAllDetailsTrailDetailsGetResponse = unknown;
|
||||||
|
|
||||||
|
export type GetTreeTrailTreeTrailGetResponse = Array<TreeTrail>;
|
||||||
|
|
||||||
|
export type GetTreesTreeGetResponse = unknown;
|
||||||
|
|
||||||
|
export type AddTreeTreePostData = {
|
||||||
|
formData: Body_addTree_tree_post;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddTreeTreePostResponse = unknown;
|
||||||
|
|
||||||
|
export type GetPoisPoiGetResponse = Array<POI>;
|
||||||
|
|
||||||
|
export type GetZonesZoneGetResponse = Array<Zone>;
|
||||||
|
|
||||||
|
export type GetStylesStyleGetResponse = Array<MapStyle>;
|
||||||
|
|
||||||
|
export type UploadTrailPhotoTrailPhotoIdFileNamePutData = {
|
||||||
|
fileName: string;
|
||||||
|
formData?: Body_upload_trail_photo_trail_photo__id___file_name__put;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UploadTrailPhotoTrailPhotoIdFileNamePutResponse = unknown;
|
||||||
|
|
||||||
|
export type UploadTreePhotoTreePhotoIdFileNamePutData = {
|
||||||
|
fileName: string;
|
||||||
|
formData?: Body_upload_tree_photo_tree_photo__id___file_name__put;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UploadTreePhotoTreePhotoIdFileNamePutResponse = unknown;
|
||||||
|
|
||||||
|
export type $OpenApiTs = {
|
||||||
|
'/bootstrap': {
|
||||||
|
get: {
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: Bootstrap;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'/token': {
|
||||||
|
post: {
|
||||||
|
req: LoginForAccessTokenTokenPostData;
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: Token;
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HTTPValidationError;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'/upload/{type}/{field}/{id}': {
|
||||||
|
post: {
|
||||||
|
req: UploadUploadTypeFieldIdPostData;
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HTTPValidationError;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'/makeAttachmentsTarFile': {
|
||||||
|
get: {
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'/logout': {
|
||||||
|
get: {
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'/trail': {
|
||||||
|
get: {
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'/trail/details': {
|
||||||
|
get: {
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'/tree-trail': {
|
||||||
|
get: {
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: Array<TreeTrail>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'/tree': {
|
||||||
|
get: {
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
post: {
|
||||||
|
req: AddTreeTreePostData;
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HTTPValidationError;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'/poi': {
|
||||||
|
get: {
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: Array<POI>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'/zone': {
|
||||||
|
get: {
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: Array<Zone>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'/style': {
|
||||||
|
get: {
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: Array<MapStyle>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'/trail/photo/{id}/{file_name}': {
|
||||||
|
put: {
|
||||||
|
req: UploadTrailPhotoTrailPhotoIdFileNamePutData;
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HTTPValidationError;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'/tree/photo/{id}/{file_name}': {
|
||||||
|
put: {
|
||||||
|
req: UploadTreePhotoTreePhotoIdFileNamePutData;
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HTTPValidationError;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
46
src/app/plant-browser/plant-browser.component.html
Normal file
46
src/app/plant-browser/plant-browser.component.html
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
<mat-table [dataSource]="plantsTableData" matSort>
|
||||||
|
<!-- Symbol Column -->
|
||||||
|
<ng-container matColumnDef="card">
|
||||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header></th>
|
||||||
|
<td mat-cell *matCellDef="let element"> <app-plant-list-item [plant]="element"></app-plant-list-item></td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="ID">
|
||||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header> ID </th>
|
||||||
|
<td mat-cell *matCellDef="let element"> {{element['ID']}} </td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="english">
|
||||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header> english </th>
|
||||||
|
<td mat-cell *matCellDef="let element"> {{element['english']}} </td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="hindi">
|
||||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header> hindi </th>
|
||||||
|
<td mat-cell *matCellDef="let element"> {{element['hindi']}} </td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="tamil">
|
||||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header> tamil </th>
|
||||||
|
<td mat-cell *matCellDef="let element"> {{element['tamil']}} </td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="family">
|
||||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header> family </th>
|
||||||
|
<td mat-cell *matCellDef="let element"> {{element['family']}} </td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="name">
|
||||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header> name </th>
|
||||||
|
<td mat-cell *matCellDef="let element"> {{element['name']}} </td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="spiritual">
|
||||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header> spiritual </th>
|
||||||
|
<td mat-cell *matCellDef="let element"> {{element['spiritual']}} </td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="type">
|
||||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header> type </th>
|
||||||
|
<td mat-cell *matCellDef="let element"> {{element['type']}} </td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="img">
|
||||||
|
<th mat-header-cell *matHeaderCellDef></th>
|
||||||
|
<td mat-cell *matCellDef="let element"> <img [src]="getImgUrl(element)"></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||||
|
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||||
|
</mat-table>
|
23
src/app/plant-browser/plant-browser.component.scss
Normal file
23
src/app/plant-browser/plant-browser.component.scss
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.mat-sort-header-sorted {
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.mat-mdc-header-row {
|
||||||
|
height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-column-img img {
|
||||||
|
height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-column-card {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.mat-mdc-cell:last-of-type {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
25
src/app/plant-browser/plant-browser.component.spec.ts
Normal file
25
src/app/plant-browser/plant-browser.component.spec.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { PlantBrowserComponent } from './plant-browser.component';
|
||||||
|
|
||||||
|
describe('PlantBrowserComponent', () => {
|
||||||
|
let component: PlantBrowserComponent;
|
||||||
|
let fixture: ComponentFixture<PlantBrowserComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [ PlantBrowserComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(PlantBrowserComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
53
src/app/plant-browser/plant-browser.component.ts
Normal file
53
src/app/plant-browser/plant-browser.component.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core'
|
||||||
|
import { MatTableDataSource } from '@angular/material/table'
|
||||||
|
import { MatSort, Sort } from '@angular/material/sort'
|
||||||
|
|
||||||
|
import { MessageService } from '../message.service'
|
||||||
|
import { PlantekeyService } from '../plantekey.service'
|
||||||
|
import { Plant } from '../models'
|
||||||
|
|
||||||
|
// XXX: not used anymore, needs to bu updated using dataService
|
||||||
|
@Component({
|
||||||
|
selector: 'app-plant-browser',
|
||||||
|
templateUrl: './plant-browser.component.html',
|
||||||
|
styleUrls: ['./plant-browser.component.scss']
|
||||||
|
})
|
||||||
|
export class PlantBrowserComponent implements OnInit, AfterViewInit {
|
||||||
|
@ViewChild(MatSort) sort: MatSort
|
||||||
|
public plantsTableData: MatTableDataSource<Plant> = new MatTableDataSource()
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public plantekeyService: PlantekeyService,
|
||||||
|
public messageService: MessageService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
public displayedColumns = [
|
||||||
|
'card',
|
||||||
|
//'name',
|
||||||
|
//'ID',
|
||||||
|
//'english',
|
||||||
|
//'family',
|
||||||
|
//'hindi',
|
||||||
|
//'spiritual',
|
||||||
|
//'tamil',
|
||||||
|
//'type',
|
||||||
|
'img',
|
||||||
|
]
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.plantekeyService.getAllPlants().subscribe()
|
||||||
|
this.plantekeyService.plants.subscribe(
|
||||||
|
plants => {
|
||||||
|
this.plantsTableData.data = Object.values(plants)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit() {
|
||||||
|
this.plantsTableData.sort = this.sort
|
||||||
|
}
|
||||||
|
|
||||||
|
getImgUrl(plant: Plant) {
|
||||||
|
return '/attachment/plantekey/thumb/' + plant.img
|
||||||
|
}
|
||||||
|
}
|
116
src/app/plant-detail/plant-detail.component.html
Normal file
116
src/app/plant-detail/plant-detail.component.html
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
<div class='container' *ngIf="plant">
|
||||||
|
<h1>{{ plant.english || plant.name }}</h1>
|
||||||
|
<div class="row row1">
|
||||||
|
<table>
|
||||||
|
<tr *ngIf="plant.english">
|
||||||
|
<th>English name</th>
|
||||||
|
<td [innerText]="plant.english"></td>
|
||||||
|
</tr>
|
||||||
|
<tr *ngIf="plant.name">
|
||||||
|
<th>Scientific name</th>
|
||||||
|
<td [innerText]="plant.name"></td>
|
||||||
|
</tr>
|
||||||
|
<tr *ngIf="plant.spiritual">
|
||||||
|
<th>Spiritual name</th>
|
||||||
|
<td [innerText]="plant.spiritual"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Description</th>
|
||||||
|
<td [innerText]="plant.description"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Family</th>
|
||||||
|
<td [innerText]="plant.family"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<td [innerText]="plant.type"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Woody</th>
|
||||||
|
<td [innerText]="plant.woody?'Yes':'No'"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Latex</th>
|
||||||
|
<td [innerText]="plant.latex"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Thorny</th>
|
||||||
|
<td [innerText]="plant.thorny"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<img class='image' [src]="imgUrl" class='img'>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<div class="title">Leaf</div>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<td [innerText]="plant.leaf_type"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Arrangement</th>
|
||||||
|
<td [innerText]="plant.leaf_arrangement"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Aroma</th>
|
||||||
|
<td [innerText]="plant.leaf_aroma?'Yes':'No'"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Length</th>
|
||||||
|
<td>{{ plant.leaf_length | number }} cm</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Width</th>
|
||||||
|
<td>{{ plant.leaf_width | number }} cm</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="title">Flower</div>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Color</th>
|
||||||
|
<td [innerText]="plant.flower_color"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Aroma</th>
|
||||||
|
<td [innerText]="plant.flower_aroma?'Yes':'No'"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Size</th>
|
||||||
|
<td>{{ plant.flower_size | number }} cm</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="title">Fruit</div>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Color</th>
|
||||||
|
<td [innerText]="plant.fruit_color"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Size</th>
|
||||||
|
<td>{{ plant.fruit_size | number }} cm</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<td [innerText]="plant.fruit_type"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
<div class='trails' *ngIf="dataService.plant_trail[plant.id]">
|
||||||
|
<h3>Visible from these trails:</h3>
|
||||||
|
<div class="trails-container">
|
||||||
|
<app-trail-list-item
|
||||||
|
*ngFor="let trail of (dataService.plant_trail[plant.id]) | keyvalue"
|
||||||
|
[trail]="trail.value"
|
||||||
|
>
|
||||||
|
</app-trail-list-item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
83
src/app/plant-detail/plant-detail.component.scss
Normal file
83
src/app/plant-detail/plant-detail.component.scss
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
.container {
|
||||||
|
margin: .5em;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-card-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-card-title {
|
||||||
|
margin: auto;
|
||||||
|
height: 2em;
|
||||||
|
flex: 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-card-title > button {
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row1 th {
|
||||||
|
width: 7.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row > * {
|
||||||
|
margin: 5px;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row table {
|
||||||
|
flex: 1 1 0;
|
||||||
|
border-spacing: 0;
|
||||||
|
width: 100%;
|
||||||
|
border-right: 1px solid grey;
|
||||||
|
border-top: 1px solid grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row th, .row .title {
|
||||||
|
background: rgb(75 200 100 / 100%);
|
||||||
|
text-align: left;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 100;
|
||||||
|
padding: 2px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border-bottom: 1px solid grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row img {
|
||||||
|
border-right: 0;
|
||||||
|
flex-grow: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .title {
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img {
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trails-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-around;
|
||||||
|
margin: 0.5em;
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue