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