first commit

This commit is contained in:
phil 2024-10-19 11:53:15 +02:00
commit 62506c830a
1207 changed files with 40706 additions and 0 deletions

16
.editorconfig Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

65
package.json Normal file
View 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

File diff suppressed because it is too large Load diff

18
proxy.conf.json Normal file
View 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
}
}

View 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>

View file

@ -0,0 +1,4 @@
.h {
display: inline;
font-weight: 600;
}

View 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();
});
});

View 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
View 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()
}
}

View 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>

View file

@ -0,0 +1,11 @@
.actions {
text-align: center;
flex-direction: column;
button {
margin: 0.2em;
}
}
.h {
font-weight: bold;
}

View 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();
});
});

View 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()
}
}

View 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 { }

View 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())
}
}

View file

@ -0,0 +1,2 @@
<app-nav>
</app-nav>

View file

View 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
View 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
View 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
View 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 })
)
}
}

View 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
View 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}).`)
}
}
})
}
)
}
)
}
}
)
}
}

View 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);
}
}
}

View 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();
});
});

View 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
}
}

View 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>

View 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;
}
}
}

View 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();
});
});

View 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)
}
}

View 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>

View file

@ -0,0 +1,6 @@
.container {
display: flex;
flex-direction: row;
align-items: center;
font-size: 1.5em;
}

View 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();
});
});

View 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()
)
}
}

View 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>

View 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;
}

View 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 {
}
}

View 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>

View 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;
}

View 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();
});
});

View 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()
}
})
}
}

View file

@ -0,0 +1 @@
<!--<app-indicator></app-indicator>-->

View file

@ -0,0 +1,6 @@
:host {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: center;
}

View 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 {
}
}

View file

@ -0,0 +1,4 @@
<div>
<app-map></app-map>
<app-map-info></app-map-info>
</div>

View file

@ -0,0 +1,13 @@
:host {
display: flex;
height: 100%;
width: 100%;
}
:host > div {
width: 100%;
}
app-map-info {
height: 0%;
}

View 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
View 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
})
])
)
])
])

View 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;
}

View 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);
}
}
}

View 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>

View 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;
}

View 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()
}
)
}
}

View 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
)
})
}
}

View file

@ -0,0 +1,5 @@
<mat-icon
[class.active]="mapEditService.active"
(click)="toggle()">
edit
</mat-icon>

View file

@ -0,0 +1,10 @@
:host {
cursor: pointer;
.mat-icon {
margin: 3px 2px -3px 3px;
color: grey;
}
.active {
color: red!important;
}
}

View 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();
});
});

View 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()
}
}

View 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]
)
)
}
}

View 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>

View 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;
}

View 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)
}
}
}
}
}

View 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>

View 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;
}

View 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();
});
});

View 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()
}
}

View 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();
});
});

View 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() { }
}

View file

@ -0,0 +1,5 @@
<mat-spinner
*ngIf="messageService.spinner | async"
color="accent"
[diameter]="25"
></mat-spinner>

View file

@ -0,0 +1,3 @@
mat-spinner {
margin-right: 1em;
}

View 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();
});
});

View 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
View 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
}
*/

View 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>

View 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;
}
}

View 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();
});
});

View 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())
}
}

View 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;
}
}

View 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>;
};

View 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;
};

View 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(),
},
};

View 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
View 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';

View 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;

View 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'
}
});
}
}

View 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;
};
};
};
};

View 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>

View 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;
}

View 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();
});
});

View 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
}
}

View 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>

View 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