From b2a50943b7e1a9ca90abc792bf8e62ef19724ff7 Mon Sep 17 00:00:00 2001 From: Cedric Spindler Date: Wed, 6 Dec 2023 15:25:24 +0100 Subject: [PATCH 1/7] Handle subscription lifecycle --- src/app/components/walk/walk.component.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/app/components/walk/walk.component.ts b/src/app/components/walk/walk.component.ts index 73c1941..9373fd4 100644 --- a/src/app/components/walk/walk.component.ts +++ b/src/app/components/walk/walk.component.ts @@ -1,4 +1,5 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Subject, takeUntil } from 'rxjs'; import { HotspotService, HotspotType } from 'src/app/services/hotspot.service'; import { hotspotCrossfadeAnimation } from 'src/app/shared'; @@ -8,8 +9,9 @@ import { hotspotCrossfadeAnimation } from 'src/app/shared'; styleUrls: ['./walk.component.css'], animations: [hotspotCrossfadeAnimation], }) -export class WalkComponent implements OnInit { +export class WalkComponent implements OnInit, OnDestroy { + private destroy = new Subject(); hotspot?: HotspotType|false; constructor( @@ -18,8 +20,15 @@ export class WalkComponent implements OnInit { ngOnInit(): void { // "false" could be triggering a side effect to fade out audio, wait, then continue delivering "false" - this.hotspotService.trigger.subscribe(hotspot => this.hotspot = hotspot); + this.hotspotService.trigger + .pipe(takeUntil(this.destroy)) + .subscribe(hotspot => this.hotspot = hotspot); this.hotspotService.loadHotspots(); } + ngOnDestroy(): void { + this.destroy.next(null); + this.destroy.complete(); + } + } From 9ce877bb5694debc08625367798c5cb6fe2c3677 Mon Sep 17 00:00:00 2001 From: Cedric Spindler Date: Wed, 6 Dec 2023 16:06:26 +0100 Subject: [PATCH 2/7] Add community hotspot component --- src/app/app.module.ts | 2 + .../community-hotspot.component.css | 93 +++++++++++++++++++ .../community-hotspot.component.html | 26 ++++++ .../community-hotspot.component.ts | 56 +++++++++++ src/app/components/walk/walk.component.html | 2 + src/app/services/hotspot.service.ts | 40 +++++++- 6 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 src/app/components/community-hotspot/community-hotspot.component.css create mode 100644 src/app/components/community-hotspot/community-hotspot.component.html create mode 100644 src/app/components/community-hotspot/community-hotspot.component.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 3b52c18..88de002 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -31,6 +31,7 @@ import { SingleImageComponent } from './components/single-image/single-image.com import { InfoTextComponent } from './components/info-text/info-text.component'; import { AudioTextComponent } from './components/audio-text/audio-text.component'; import { DataHotspotComponent } from './components/data-hotspot/data-hotspot.component'; +import { CommunityHotspotComponent } from './components/community-hotspot/community-hotspot.component'; import { InstructionsComponent } from './components/instructions.component'; import { ToolsComponent } from './components/tools.component'; import { CopyrightComponent } from './components/copyright/copyright.component'; @@ -96,6 +97,7 @@ function initializeKeycloak(keycloak: KeycloakService) { InfoTextComponent, AudioTextComponent, DataHotspotComponent, + CommunityHotspotComponent, InstructionsComponent, CopyrightComponent, ToolsComponent, diff --git a/src/app/components/community-hotspot/community-hotspot.component.css b/src/app/components/community-hotspot/community-hotspot.component.css new file mode 100644 index 0000000..49037e8 --- /dev/null +++ b/src/app/components/community-hotspot/community-hotspot.component.css @@ -0,0 +1,93 @@ + +.carousel-container { + position: fixed; + top: calc(48px); + left: 0; + right: 0; + bottom: 0; + margin: 0; + padding: 0; +} + +.carousel-wrapper { + height: 100%; + width: 100%; + + white-space: nowrap; + + overflow-x: scroll; + overflow-y: hidden; + scroll-snap-type: x mandatory; +} + +.carousel-image { + width: 100%; + height: 100%; + display: inline-block; + position: relative; + overflow: hidden; + scroll-snap-align: center; +} + +.carousel-image img { + object-fit: cover; + object-position: center; + height: 100%; + width: 100%; +} + +.carousel-arrow { + position: absolute; + top: 50%; + transform: translateY(-50%); + color: white; + cursor: pointer; + background: rgba(0, 0, 0, 0.5); + padding: 8px; + border-radius: 50%; +} + +.carousel-arrow.left { + left: 10px; +} + +.carousel-arrow.right { + right: 10px; +} + +#text { + display: grid; + grid-template-rows: 36px calc(100% - 36px); + position: absolute; + bottom: 0; + left: 0; + right: 0; + max-height: 33%; + background-color: rgba(255, 255, 255, 0.7); + border-radius: 7px 7px 0 0; + padding: 8px; + box-shadow: 0px -3px 14px 2px rgba(0, 0, 0, 0.12); +} + +#text p { + overflow-y: scroll; +} + +#text .infotable { + display: grid; + grid-template-columns: 1fr 1fr; + column-gap: 10px; + margin-bottom: 16px; +} + +#text .mdc-button { + background-color: #ffffff88; + margin-bottom: 16px; +} + +app-copyright { + position: absolute; + top: 16px; + right: 16px; + z-index: 999; +} diff --git a/src/app/components/community-hotspot/community-hotspot.component.html b/src/app/components/community-hotspot/community-hotspot.component.html new file mode 100644 index 0000000..d1c09c0 --- /dev/null +++ b/src/app/components/community-hotspot/community-hotspot.component.html @@ -0,0 +1,26 @@ + + +
+

{{ hotspot.species }}

+
+
+ Datum:{{ hotspot.date }} + Beobachuntszeitraum:{{ hotspot.time_range }} +
+ Meldung + Artenportrait +
+
diff --git a/src/app/components/community-hotspot/community-hotspot.component.ts b/src/app/components/community-hotspot/community-hotspot.component.ts new file mode 100644 index 0000000..ec023e6 --- /dev/null +++ b/src/app/components/community-hotspot/community-hotspot.component.ts @@ -0,0 +1,56 @@ +import { Component, ElementRef, ViewChild } from '@angular/core'; +import { filter } from 'rxjs'; +import { HotspotCommunity, HotspotService } from 'src/app/services/hotspot.service'; +import { slideUpDownAnimation } from 'src/app/shared'; + +@Component({ + selector: 'app-community-hotspot', + templateUrl: './community-hotspot.component.html', + styleUrls: ['./community-hotspot.component.css'], + animations: [slideUpDownAnimation] +}) +export class CommunityHotspotComponent { + + hotspot?: HotspotCommunity; + + @ViewChild('wrapper') + wrapper?: ElementRef; + + constructor(private hotspotService: HotspotService) { + this.hotspotService.trigger + .pipe(filter(h => h !== false && h.type === 5)) + .subscribe(hotspot => { + console.log(hotspot); + if (hotspot && hotspot.type === 5) this.hotspot = hotspot; + }) + } + + swipeTransition(direction: 'left'|'right') { + if (this.wrapper) { + const imgs = this.wrapper.nativeElement.querySelectorAll('.carousel-image'); + let current: number|null = null; + imgs.forEach(img => { + if (this.isElementInViewport(img)) current = Number(img.id.substring(4)); + }); + if (current !== null) { + let next: number; + if (direction === 'right') next = (current+1)%imgs.length; + else next = (((current-1)%imgs.length)+imgs.length)%imgs.length; + imgs.forEach(img => { + if (img.id === 'img-'+next) img.scrollIntoView({ behavior: 'smooth'}); + }); + } + } + } + + private isElementInViewport (el: any) { + const rect = el.getBoundingClientRect(); + const condition = + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && /* or $(window).height() */ + rect.right <= (window.innerWidth || document.documentElement.clientWidth); /* or $(window).width() */ + return condition; + } + +} diff --git a/src/app/components/walk/walk.component.html b/src/app/components/walk/walk.component.html index 41c54df..ebfd3f8 100644 --- a/src/app/components/walk/walk.component.html +++ b/src/app/components/walk/walk.component.html @@ -8,6 +8,8 @@ + + diff --git a/src/app/services/hotspot.service.ts b/src/app/services/hotspot.service.ts index d48aef1..9100958 100644 --- a/src/app/services/hotspot.service.ts +++ b/src/app/services/hotspot.service.ts @@ -43,17 +43,36 @@ export interface HotspotAudiotext extends Hotspot { speakerFunction?: string; contentSubject?: string; } + +export interface WildeNachbarnProperties { + ID: number; + Art: string; + Datum: string; + Zeitraum: string; + Meldung: string; + Artenportraet?: string; + weight: number; + media: string[]; +} export interface HotspotCommunity extends Hotspot { type: 5; - dataUrl: string; + id: number; + species: string; + date: string; + time_range: string; + post_url: string; + portrait_url?: string; + weight: number; + media: string[]; } + export interface HotspotData extends Hotspot { type: 6; endpoint: string; title: string; text: string; } -export type HotspotType = HotspotImageSingle|HotspotImageSequence|HotspotInfotext|HotspotAudiotext|HotspotData; +export type HotspotType = HotspotImageSingle|HotspotImageSequence|HotspotInfotext|HotspotAudiotext|HotspotData|HotspotCommunity; export interface HotspotBarchartData { tag: string @@ -193,6 +212,23 @@ export class HotspotService { contentSubject: 'Untersuchung der Auswirkungen von Klimawandel auf Biodiversität', }) break; + case 5: + this.trigger.next({ + id: 87, type, + location: { lat: 1, lon: 4}, + species: 'Biber', + date: '03.12.2016', + time_range: '14.00 - 14.59', + post_url: 'https://beidebasel.wildenachbarn.ch/beobachtung/50451', + portrait_url: 'https://beidebasel.wildenachbarn.ch/artportraet/biber', + weight: 1, + media: [ + 'https://beidebasel.wildenachbarn.ch/system/files/styles/beobachtungcolorbox_large/private/medien_zu_meldungen/7051/2018-09/DSCN1128%20-%20Arbeitskopie%202.jpg?itok=O5sTokIp', + 'https://beidebasel.wildenachbarn.ch/system/files/styles/beobachtungcolorbox_large/private/medien_zu_meldungen/7051/2018-09/DSCN1133.jpg?itok=kBf3rZ93', + 'https://beidebasel.wildenachbarn.ch/system/files/styles/beobachtungcolorbox_large/private/medien_zu_meldungen/7051/2018-09/DSCN1115.jpg?itok=dzRKC9tJ', + ] + }) + break; case 6: this.trigger.next({ location: { lon: 0, lat: 0 }, From 191be563735141e293fbe6c54d072f54ba648529 Mon Sep 17 00:00:00 2001 From: Cedric Spindler Date: Wed, 6 Dec 2023 16:10:13 +0100 Subject: [PATCH 3/7] Load data community hotspot data from REST API --- src/app/services/data.service.ts | 29 ++++++++++++++++++++++++++++- src/app/services/hotspot.service.ts | 8 +++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/app/services/data.service.ts b/src/app/services/data.service.ts index 143ca4c..443c55f 100644 --- a/src/app/services/data.service.ts +++ b/src/app/services/data.service.ts @@ -2,7 +2,8 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable, map, tap } from 'rxjs'; import { Deployment, Note, ImageStack, SectionText, StackImage, WalkPath } from '../shared'; -import { HotspotDataPayload, HotspotType } from './hotspot.service'; +import { HotspotCommunity, HotspotDataPayload, HotspotType, WildeNachbarnProperties } from './hotspot.service'; +import { Feature, FeatureCollection, Point } from 'geojson'; @Injectable({ providedIn: 'root' @@ -100,6 +101,32 @@ export class DataService { })) } + public getCommunityHotspots(): Observable { + const url = `${this.apiUrl}/walk/community-hotspots`; + return this.http.get(url).pipe(map(fc => { + return fc.features.map((f: Feature) => { + const p = (f.properties as WildeNachbarnProperties); + const hotspot: HotspotCommunity = { + type: 5, + location: { + lat: (f.geometry as Point).coordinates[1], + lon: (f.geometry as Point).coordinates[0] + }, + subject: `WN: ${p.Art}`, + id: p.ID, + species: p.Art, + date: p.Datum, + time_range: p.Zeitraum, + post_url: p.Meldung, + portrait_url: p.Artenportraet, + weight: p.weight, + media: p.media + } + return hotspot; + }) + })); + } + public getImageResource(url: string) { return this.http.get(`${this.apiUrl}/files/walk/${url}`, {responseType: 'blob'}); } diff --git a/src/app/services/hotspot.service.ts b/src/app/services/hotspot.service.ts index 9100958..03f1d37 100644 --- a/src/app/services/hotspot.service.ts +++ b/src/app/services/hotspot.service.ts @@ -161,7 +161,13 @@ export class HotspotService { } loadHotspots() { - this.dataService.getWalkHotspots(1).subscribe(hotspots => this.hotspots = hotspots); + this.dataService.getWalkHotspots(1).pipe( + switchMap(hotspots => { + return this.dataService.getCommunityHotspots().pipe( + map(communityHotspots => { + this.hotspots = hotspots.concat(communityHotspots); + })) + })).subscribe(); } chooseHotspot() { From d3b4007e0825f7638d4ea01f9e28e4f85f162cca Mon Sep 17 00:00:00 2001 From: Cedric Spindler Date: Wed, 6 Dec 2023 16:11:59 +0100 Subject: [PATCH 4/7] Load walk component in different modes, "community", or "walk" --- src/app/app-routing.module.ts | 1 + .../choose-channel.component.ts | 2 +- src/app/components/walk/walk.component.ts | 16 +++++++++--- src/app/services/hotspot.service.ts | 26 +++++++++++++------ 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index efd221f..a4c9559 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -10,6 +10,7 @@ const routes: Routes = [ { path: 'info', component: InfoComponent }, { path: 'map', component: MapComponent }, { path: 'walk', component: WalkComponent }, + { path: 'community', component: WalkComponent }, { path: 'stack-fade', component: StackFadeComponent }, { path: '', component: OverviewComponent, children: [] } ]; diff --git a/src/app/components/choose-channel/choose-channel.component.ts b/src/app/components/choose-channel/choose-channel.component.ts index 1d5e47f..dd46af9 100644 --- a/src/app/components/choose-channel/choose-channel.component.ts +++ b/src/app/components/choose-channel/choose-channel.component.ts @@ -18,7 +18,7 @@ export class ChooseChannelComponent implements OnInit { '', 'walk', 'walk', // TODO: change to 'stopmotion' when ready (add to routes) - 'walk', // TODO: change to 'community' when ready (add to routes) + 'community', // TODO: change to 'community' when ready (add to routes) 'map', 'walk', // TODO: change to 'audiowalk' when ready (add to routes) ] diff --git a/src/app/components/walk/walk.component.ts b/src/app/components/walk/walk.component.ts index 9373fd4..6f8a6c6 100644 --- a/src/app/components/walk/walk.component.ts +++ b/src/app/components/walk/walk.component.ts @@ -1,4 +1,5 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { Subject, takeUntil } from 'rxjs'; import { HotspotService, HotspotType } from 'src/app/services/hotspot.service'; import { hotspotCrossfadeAnimation } from 'src/app/shared'; @@ -12,18 +13,27 @@ import { hotspotCrossfadeAnimation } from 'src/app/shared'; export class WalkComponent implements OnInit, OnDestroy { private destroy = new Subject(); + private mode: 'walk'|'community'|'audiowalk' = 'walk'; hotspot?: HotspotType|false; constructor( - public hotspotService: HotspotService - ) { } + public hotspotService: HotspotService, + public route: ActivatedRoute, + ) { + const modes = ['walk', 'community', 'audiowalk']; + if (modes.includes(this.route.snapshot.url[0].path)) { + this.mode = this.route.snapshot.url[0].path as 'walk'|'community'|'audiowalk'; + } else { + console.warn('Unknown mode, defaulting to "walk"'); + } + } ngOnInit(): void { // "false" could be triggering a side effect to fade out audio, wait, then continue delivering "false" this.hotspotService.trigger .pipe(takeUntil(this.destroy)) .subscribe(hotspot => this.hotspot = hotspot); - this.hotspotService.loadHotspots(); + this.hotspotService.loadHotspots(this.mode); } ngOnDestroy(): void { diff --git a/src/app/services/hotspot.service.ts b/src/app/services/hotspot.service.ts index 03f1d37..58b156f 100644 --- a/src/app/services/hotspot.service.ts +++ b/src/app/services/hotspot.service.ts @@ -160,14 +160,24 @@ export class HotspotService { }) } - loadHotspots() { - this.dataService.getWalkHotspots(1).pipe( - switchMap(hotspots => { - return this.dataService.getCommunityHotspots().pipe( - map(communityHotspots => { - this.hotspots = hotspots.concat(communityHotspots); - })) - })).subscribe(); + loadHotspots(mode: 'walk'|'community'|'audiowalk' = 'walk') { + if (mode === 'walk') { + this.dataService.getWalkHotspots(1).subscribe(hotspots => { + this.hotspots = hotspots; + }); + /* // if we want to load community hotspots as well + this.dataService.getWalkHotspots(1).pipe( + switchMap(hotspots => { + return this.dataService.getCommunityHotspots().pipe( + map(communityHotspots => { + this.hotspots = hotspots.concat(communityHotspots); + })) + })).subscribe(); */ + } else if (mode === 'community') { + this.dataService.getCommunityHotspots().subscribe(hotspots => { + this.hotspots = hotspots; + }); + } } chooseHotspot() { From 47143f5baa05255d58480ab7dba0dcadc74853d4 Mon Sep 17 00:00:00 2001 From: Cedric Spindler Date: Wed, 6 Dec 2023 16:12:26 +0100 Subject: [PATCH 5/7] Filter hotspot types depending on mode --- src/app/services/hotspot.service.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/app/services/hotspot.service.ts b/src/app/services/hotspot.service.ts index 58b156f..6c8cf5e 100644 --- a/src/app/services/hotspot.service.ts +++ b/src/app/services/hotspot.service.ts @@ -2,7 +2,7 @@ import { Component, Injectable } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { TriggerHotspotDialogComponent } from '../components/trigger-hotspot-dialog.component'; import { CoordinatePoint } from '../shared'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, combineLatest, map, switchMap } from 'rxjs'; import { ParcoursService } from './parcours.service'; import { DataService } from './data.service'; import { AudioService } from './audio.service'; @@ -126,6 +126,7 @@ export class HotspotService { private hotspots: Array = []; private currentHotspot: HotspotType | null = null; + private typeFilter = new BehaviorSubject([1,2,3,4,6]); public trigger: BehaviorSubject; public closeHotspots: BehaviorSubject>; @@ -138,9 +139,13 @@ export class HotspotService { ) { this.trigger = new BehaviorSubject(false); this.closeHotspots = new BehaviorSubject>([]); - this.parcoursService.location.subscribe(location => { + + combineLatest([this.parcoursService.location, this.typeFilter]) + .subscribe(([location, typeFilter]) => { if (this.hotspots.length > 0) { - const c = this.hotspots.map(hotspot => Object.assign(hotspot, { distance: distance( + const c = this.hotspots.filter(hotspot => { + return typeFilter.includes(hotspot.type); + }).map(hotspot => Object.assign(hotspot, { distance: distance( [hotspot.location.lon, hotspot.location.lat], [location.coords.longitude, location.coords.latitude], { units: 'meters' }) @@ -160,10 +165,15 @@ export class HotspotService { }) } + set typeFilterValue(value: number[]) { + this.typeFilter.next(value); + } + loadHotspots(mode: 'walk'|'community'|'audiowalk' = 'walk') { if (mode === 'walk') { this.dataService.getWalkHotspots(1).subscribe(hotspots => { this.hotspots = hotspots; + this.typeFilter.next([1, 2, 3, 4, 6]); }); /* // if we want to load community hotspots as well this.dataService.getWalkHotspots(1).pipe( @@ -176,6 +186,7 @@ export class HotspotService { } else if (mode === 'community') { this.dataService.getCommunityHotspots().subscribe(hotspots => { this.hotspots = hotspots; + this.typeFilter.next([5]); }); } } From 4b2230c6cef164ed60457199cc4318dd285a6106 Mon Sep 17 00:00:00 2001 From: Cedric Spindler Date: Wed, 6 Dec 2023 16:12:59 +0100 Subject: [PATCH 6/7] Display community hotspots in map --- src/app/components/map/map.component.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/app/components/map/map.component.ts b/src/app/components/map/map.component.ts index 035d27e..4a364aa 100644 --- a/src/app/components/map/map.component.ts +++ b/src/app/components/map/map.component.ts @@ -6,7 +6,7 @@ import { Map, Marker, GeoJSONSource, Popup } from 'maplibre-gl'; import { Feature, Position } from 'geojson'; import { DataService, NoteService, OidcService, ParcoursService, TrackRecorderService } from 'src/app/services'; import { CoordinatePoint, Deployment, MAP_STYLE_CONFIG } from 'src/app/shared'; -import { map, of, Subject, switchMap, takeUntil } from 'rxjs'; +import { map as rxjsMap, of, Subject, switchMap, takeUntil } from 'rxjs'; @Component({ selector: 'app-map', @@ -39,7 +39,7 @@ export class MapComponent implements AfterViewInit, OnDestroy { // draw track this.trackRecorder.track.pipe( takeUntil(this.destroy), - map(track => track.map(p => [p.coords.longitude, p.coords.latitude]))) + rxjsMap(track => track.map(p => [p.coords.longitude, p.coords.latitude]))) .subscribe(track => { const s = this.map?.getSource('route'); if (s) s.setData({ @@ -148,7 +148,13 @@ export class MapComponent implements AfterViewInit, OnDestroy { } private drawHotspots(map: Map) { - this.dataService.getWalkHotspots(1).subscribe(hotspots => { + this.dataService.getWalkHotspots(1).pipe( + switchMap(hotspots => { + return this.dataService.getCommunityHotspots().pipe( + rxjsMap(communityHotspots => { + return hotspots.concat(communityHotspots); + })); + })).subscribe(hotspots => { const radius = 20; // hotspot radius in meters const metersToPixelsAtMaxZoom = (meters:number, latitude:number) => meters / 0.075 / Math.cos(latitude * Math.PI / 180); const features = hotspots.map(h => { From f62f9c7480310af2f4c13c936ee907c3141e28e7 Mon Sep 17 00:00:00 2001 From: Cedric Spindler Date: Sat, 9 Dec 2023 13:57:24 +0100 Subject: [PATCH 7/7] Update content display --- .../community-hotspot/community-hotspot.component.html | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/app/components/community-hotspot/community-hotspot.component.html b/src/app/components/community-hotspot/community-hotspot.component.html index d1c09c0..fb5658a 100644 --- a/src/app/components/community-hotspot/community-hotspot.component.html +++ b/src/app/components/community-hotspot/community-hotspot.component.html @@ -2,7 +2,7 @@ @@ -17,8 +17,7 @@

{{ hotspot.species }}

- Datum:{{ hotspot.date }} - Beobachuntszeitraum:{{ hotspot.time_range }} + Beobachungsdatum:{{ hotspot.date }}, {{ hotspot.time_range }}
Meldung Artenportrait