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/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/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/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..fb5658a --- /dev/null +++ b/src/app/components/community-hotspot/community-hotspot.component.html @@ -0,0 +1,25 @@ + + +
+

{{ hotspot.species }}

+
+
+ Beobachungsdatum:{{ hotspot.date }}, {{ 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/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 => { 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/components/walk/walk.component.ts b/src/app/components/walk/walk.component.ts index 73c1941..6f8a6c6 100644 --- a/src/app/components/walk/walk.component.ts +++ b/src/app/components/walk/walk.component.ts @@ -1,4 +1,6 @@ -import { Component, OnInit } from '@angular/core'; +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'; @@ -8,18 +10,35 @@ 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(); + 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.subscribe(hotspot => this.hotspot = hotspot); - this.hotspotService.loadHotspots(); + this.hotspotService.trigger + .pipe(takeUntil(this.destroy)) + .subscribe(hotspot => this.hotspot = hotspot); + this.hotspotService.loadHotspots(this.mode); + } + + ngOnDestroy(): void { + this.destroy.next(null); + this.destroy.complete(); } } 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 d48aef1..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'; @@ -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 @@ -107,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>; @@ -119,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' }) @@ -141,8 +165,30 @@ export class HotspotService { }) } - loadHotspots() { - this.dataService.getWalkHotspots(1).subscribe(hotspots => this.hotspots = hotspots); + 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( + 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; + this.typeFilter.next([5]); + }); + } } chooseHotspot() { @@ -193,6 +239,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 },