From 12a0f716c6ef54d7e34b6572a4e6f74de022e92f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8rgen=20Loe=20Kvalberg?= Date: Fri, 22 May 2026 15:00:04 +0200 Subject: [PATCH 1/2] POC for egen analyse-side. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mye av koden er KI-generert. Denne PRen kan lukkes om det trengs. Lager PRen for å sende en PR-bygg-link til mala. --- src/app/core/guards/native-block.guard.ts | 17 + src/app/core/guards/snow-only.guard.ts | 21 ++ .../analysis-filter.service.ts | 46 +++ src/app/pages/analysis/analysis.page.html | 29 ++ src/app/pages/analysis/analysis.page.scss | 11 + src/app/pages/analysis/analysis.page.ts | 314 ++++++++++++++++++ .../analysis-filter-menu.component.html | 66 ++++ .../analysis-filter-menu.component.scss | 20 ++ .../analysis-filter-menu.component.ts | 102 ++++++ .../analysis-time-slider.component.html | 21 ++ .../analysis-time-slider.component.scss | 35 ++ .../analysis-time-slider.component.ts | 41 +++ src/app/pages/tabs/tabs.page.html | 13 + src/app/pages/tabs/tabs.page.ts | 5 +- src/app/pages/tabs/tabs.routes.ts | 7 + src/app/pages/tabs/tabs.service.ts | 3 + src/assets/i18n/en.json | 12 + src/assets/i18n/nb.json | 12 + 18 files changed, 774 insertions(+), 1 deletion(-) create mode 100644 src/app/core/guards/native-block.guard.ts create mode 100644 src/app/core/guards/snow-only.guard.ts create mode 100644 src/app/core/services/analysis-filter/analysis-filter.service.ts create mode 100644 src/app/pages/analysis/analysis.page.html create mode 100644 src/app/pages/analysis/analysis.page.scss create mode 100644 src/app/pages/analysis/analysis.page.ts create mode 100644 src/app/pages/analysis/components/analysis-filter-menu/analysis-filter-menu.component.html create mode 100644 src/app/pages/analysis/components/analysis-filter-menu/analysis-filter-menu.component.scss create mode 100644 src/app/pages/analysis/components/analysis-filter-menu/analysis-filter-menu.component.ts create mode 100644 src/app/pages/analysis/components/analysis-time-slider/analysis-time-slider.component.html create mode 100644 src/app/pages/analysis/components/analysis-time-slider/analysis-time-slider.component.scss create mode 100644 src/app/pages/analysis/components/analysis-time-slider/analysis-time-slider.component.ts diff --git a/src/app/core/guards/native-block.guard.ts b/src/app/core/guards/native-block.guard.ts new file mode 100644 index 000000000..c0e49b55c --- /dev/null +++ b/src/app/core/guards/native-block.guard.ts @@ -0,0 +1,17 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { Capacitor } from '@capacitor/core'; + +/** + * Blocks navigation on native platforms by redirecting to the root route. + * Use for pages that should only be available on web/desktop. + */ +export const nativeBlockGuard: CanActivateFn = () => { + const router = inject(Router); + + if (Capacitor.isNativePlatform()) { + router.navigate(['/']); + return false; + } + return true; +}; diff --git a/src/app/core/guards/snow-only.guard.ts b/src/app/core/guards/snow-only.guard.ts new file mode 100644 index 000000000..f73395a0c --- /dev/null +++ b/src/app/core/guards/snow-only.guard.ts @@ -0,0 +1,21 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { firstValueFrom } from 'rxjs'; +import { GeoHazard } from 'src/app/modules/common-core/models'; +import { UserSettingService } from '../services/user-setting/user-setting.service'; + +/** + * Allow activation only when the current geohazard selection includes Snow. + * Other geohazards are redirected to root. + */ +export const snowOnlyGuard: CanActivateFn = async () => { + const router = inject(Router); + const userSettingService = inject(UserSettingService); + + const geoHazards = await firstValueFrom(userSettingService.currentGeoHazard$); + if (geoHazards?.includes(GeoHazard.Snow)) { + return true; + } + router.navigate(['/']); + return false; +}; diff --git a/src/app/core/services/analysis-filter/analysis-filter.service.ts b/src/app/core/services/analysis-filter/analysis-filter.service.ts new file mode 100644 index 000000000..462315a10 --- /dev/null +++ b/src/app/core/services/analysis-filter/analysis-filter.service.ts @@ -0,0 +1,46 @@ +import { Injectable, computed, linkedSignal, signal } from '@angular/core'; +import moment from 'moment'; + +/** + * State holder for the Analyse-tab filters. + * + * Holds: + * - which observation types (faretegn / skred) to render + * - the reference date defining the end of a 14-day window + * - the currently active day inside that window (used by the time slider for dimming) + */ +@Injectable({ providedIn: 'root' }) +export class AnalysisFilterService { + /** Number of days the reference date looks back. */ + static readonly DAYS_BACK = 14; + + readonly showDangerSigns = signal(true); + readonly showAvalanches = signal(true); + + /** End of the 14-day analysis window. ISO date (yyyy-MM-dd). Defaults to today. */ + readonly referenceDate = signal(moment().format('YYYY-MM-DD')); + + /** Start of the analysis window (referenceDate - 14 days). */ + readonly fromDate = computed(() => + moment(this.referenceDate()).subtract(AnalysisFilterService.DAYS_BACK, 'days').format('YYYY-MM-DD') + ); + + /** All dates (oldest -> newest) inside the analysis window, inclusive. */ + readonly windowDates = computed(() => { + const end = moment(this.referenceDate()); + const dates: string[] = []; + for (let i = AnalysisFilterService.DAYS_BACK; i >= 0; i--) { + dates.push(end.clone().subtract(i, 'days').format('YYYY-MM-DD')); + } + return dates; + }); + + /** + * The currently selected "active" day in the analysis window. + * Re-syncs to the reference date whenever it changes (linkedSignal). + */ + readonly activeDate = linkedSignal({ + source: this.referenceDate, + computation: (ref) => ref, + }); +} diff --git a/src/app/pages/analysis/analysis.page.html b/src/app/pages/analysis/analysis.page.html new file mode 100644 index 000000000..4b384933d --- /dev/null +++ b/src/app/pages/analysis/analysis.page.html @@ -0,0 +1,29 @@ + + + + + +
+ + + + + + + + + + + +
+ + +
+
+
+
diff --git a/src/app/pages/analysis/analysis.page.scss b/src/app/pages/analysis/analysis.page.scss new file mode 100644 index 000000000..0e61233ac --- /dev/null +++ b/src/app/pages/analysis/analysis.page.scss @@ -0,0 +1,11 @@ +.analysis-map-host { + position: relative; + width: 100%; + height: 100%; +} + +app-map { + display: block; + width: 100%; + height: 100%; +} diff --git a/src/app/pages/analysis/analysis.page.ts b/src/app/pages/analysis/analysis.page.ts new file mode 100644 index 000000000..d8f8e071e --- /dev/null +++ b/src/app/pages/analysis/analysis.page.ts @@ -0,0 +1,314 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + OnDestroy, + OnInit, + effect, + inject, + viewChild, +} from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { IonButtons, IonContent, IonIcon, IonMenu, IonMenuButton, IonSplitPane } from '@ionic/angular/standalone'; +import L from 'leaflet'; +import { EMPTY, Observable, combineLatest, of } from 'rxjs'; +import { catchError, expand, map, reduce, switchMap, takeUntil, timeout } from 'rxjs/operators'; +import { AnalysisFilterService } from 'src/app/core/services/analysis-filter/analysis-filter.service'; +import { RouterPage } from 'src/app/core/helpers/routed-page'; +import { SearchCriteriaService } from 'src/app/core/services/search-criteria/search-criteria.service'; +import { GeoHazard } from 'src/app/modules/common-core/models'; +import { SearchService } from 'src/app/modules/common-regobs-api'; +import { RegistrationViewModel, SearchCriteriaRequestDto } from 'src/app/modules/common-regobs-api/models'; +import { MapComponent } from 'src/app/modules/map/components/map/map.component'; +import { HeaderComponent } from 'src/app/modules/shared/components/header/header.component'; +import { LoggingService } from 'src/app/modules/shared/services/logging/logging.service'; +import { AnalysisFilterMenuComponent } from './components/analysis-filter-menu/analysis-filter-menu.component'; +import { AnalysisTimeSliderComponent } from './components/analysis-time-slider/analysis-time-slider.component'; +import { toObservable } from '@angular/core/rxjs-interop'; +import moment from 'moment'; + +const DEBUG_TAG = 'AnalysisPage'; + +const PAGE_SIZE = 1000; +const MAX_PAGES = 10; + +// Snow group / sub-type IDs (verified in src/assets/json/searchcriteria.nb.json): +// Avalanche/Skredhendelse: Id=80 (Hendelser) + SubType 26 +// Faretegn: Id=81 (Skred og faretegn) + SubType 13 +const GROUP_ID_AVALANCHE = 80; +const GROUP_ID_DANGER_SIGN = 81; +const SUBTYPE_AVALANCHE = 26; +const SUBTYPE_DANGER_SIGN = 13; + +// Marker colors +const COLOR_DANGER = '#000000'; +const COLOR_AVALANCHE = '#d9322a'; + +// Opacity for active vs dimmed dots +const OPACITY_ACTIVE_FILL = 1; +const OPACITY_ACTIVE_STROKE = 1; +const OPACITY_DIMMED_FILL = 0.2; +const OPACITY_DIMMED_STROKE = 1; + +// Radius mapping for avalanche by DestructiveSizeTID +const AVALANCHE_RADIUS_BY_SIZE: Record = { + 1: 6, + 2: 9, + 3: 13, + 4: 17, + 5: 22, +}; +const DEFAULT_AVALANCHE_RADIUS = 6; +const DANGER_SIGN_RADIUS = 6; + +interface AnalysisMarker { + marker: L.CircleMarker; + dateKey: string; // yyyy-MM-dd of DtObsTime +} + +@Component({ + selector: 'app-analysis', + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: 'analysis.page.html', + styleUrls: ['analysis.page.scss'], + imports: [ + AnalysisFilterMenuComponent, + AnalysisTimeSliderComponent, + HeaderComponent, + IonButtons, + IonContent, + IonIcon, + IonMenu, + IonMenuButton, + IonSplitPane, + MapComponent, + ], +}) +export class AnalysisPage extends RouterPage implements OnDestroy { + private searchService = inject(SearchService); + private searchCriteriaService = inject(SearchCriteriaService); + private analysisFilterService = inject(AnalysisFilterService); + private loggingService = inject(LoggingService); + + readonly mapComponent = viewChild.required(MapComponent); + readonly mapHost = viewChild>('mapHost'); + + private map?: L.Map; + private canvasRenderer = L.canvas({ padding: 0.2 }); + private dangerLayer = L.layerGroup(); + private avalancheLayer = L.layerGroup(); + private dangerMarkers: AnalysisMarker[] = []; + private avalancheMarkers: AnalysisMarker[] = []; + + // Effect: re-render markers when active date changes + private activeDate = this.analysisFilterService.activeDate; + // Disclaimer / loading state are kept simple here; can be expanded later. + showDangerSigns = this.analysisFilterService.showDangerSigns; + showAvalanches = this.analysisFilterService.showAvalanches; + + constructor() { + const router = inject(Router); + const route = inject(ActivatedRoute); + super(router, route); + + this.initSearch(); + + effect(() => { + const date = this.activeDate(); + this.applyActiveDateOpacity(date); + }); + + effect(() => { + this.dangerLayer && this.toggleLayer(this.dangerLayer, this.showDangerSigns()); + }); + + effect(() => { + this.avalancheLayer && this.toggleLayer(this.avalancheLayer, this.showAvalanches()); + }); + } + + onEnter(): void { + // No-op; the search pipeline is active for the lifetime of the component. + } + + onLeave(): void { + // No-op + } + + onMapReady(map: L.Map) { + this.map = map; + this.dangerLayer.addTo(map); + this.avalancheLayer.addTo(map); + this.toggleLayer(this.dangerLayer, this.showDangerSigns()); + this.toggleLayer(this.avalancheLayer, this.showAvalanches()); + } + + private toggleLayer(layer: L.LayerGroup, show: boolean) { + if (!this.map) return; + if (show && !this.map.hasLayer(layer)) { + this.map.addLayer(layer); + } else if (!show && this.map.hasLayer(layer)) { + this.map.removeLayer(layer); + } + } + + private initSearch() { + const baseCriteria$ = this.searchCriteriaService.searchCriteria$; + const showDanger$ = toObservable(this.analysisFilterService.showDangerSigns); + const showAvalanche$ = toObservable(this.analysisFilterService.showAvalanches); + const refDate$ = toObservable(this.analysisFilterService.referenceDate); + const fromDate$ = toObservable(this.analysisFilterService.fromDate); + + combineLatest([baseCriteria$, showDanger$, showAvalanche$, refDate$, fromDate$]) + .pipe( + takeUntil(this.ngUnsubscribe), + map(([base, showDanger, showAvalanche, refDate, fromDate]) => + this.buildAnalysisCriteria(base, showDanger, showAvalanche, refDate, fromDate) + ), + switchMap((criteria) => { + if (!criteria) { + // No types selected → clear + this.clearMarkers(); + return of([] as RegistrationViewModel[]); + } + return this.fetchAllPages(criteria); + }) + ) + .subscribe((registrations) => { + this.renderRegistrations(registrations); + }); + } + + private buildAnalysisCriteria( + base: SearchCriteriaRequestDto, + showDanger: boolean, + showAvalanche: boolean, + refDate: string, + fromDate: string + ): SearchCriteriaRequestDto | null { + if (!showDanger && !showAvalanche) { + return null; + } + + const selectedTypes: { Id: number; SubTypes?: number[] }[] = []; + if (showDanger) { + selectedTypes.push({ Id: GROUP_ID_DANGER_SIGN, SubTypes: [SUBTYPE_DANGER_SIGN] }); + } + if (showAvalanche) { + selectedTypes.push({ Id: GROUP_ID_AVALANCHE, SubTypes: [SUBTYPE_AVALANCHE] }); + } + + return { + ...base, + SelectedGeoHazards: [GeoHazard.Snow], + SelectedRegistrationTypes: selectedTypes, + FromDtObsTime: moment(fromDate).startOf('day').toISOString(true), + ToDtObsTime: moment(refDate).endOf('day').toISOString(true), + NumberOfRecords: PAGE_SIZE, + Offset: 0, + }; + } + + private fetchAllPages(criteria: SearchCriteriaRequestDto): Observable { + let pagesFetched = 0; + return this.searchService.SearchSearch(criteria).pipe( + timeout(120000), + expand((page) => { + pagesFetched++; + if (page.length < PAGE_SIZE || pagesFetched >= MAX_PAGES) { + return EMPTY; + } + const nextCriteria: SearchCriteriaRequestDto = { + ...criteria, + Offset: (criteria.Offset ?? 0) + pagesFetched * PAGE_SIZE, + }; + return this.searchService.SearchSearch(nextCriteria).pipe(timeout(120000)); + }), + reduce((acc: RegistrationViewModel[], page: RegistrationViewModel[]) => acc.concat(page), []), + catchError((err) => { + this.loggingService.error(err, DEBUG_TAG, 'Failed to fetch analysis registrations'); + return of([] as RegistrationViewModel[]); + }) + ); + } + + private renderRegistrations(registrations: RegistrationViewModel[]) { + this.clearMarkers(); + + for (const reg of registrations) { + const lat = reg.ObsLocation?.Latitude; + const lon = reg.ObsLocation?.Longitude; + if (lat == null || lon == null) continue; + const dateKey = reg.DtObsTime ? reg.DtObsTime.slice(0, 10) : ''; + + // Faretegn (filter out DangerSignTID === 1 = "Ingen faretegn observert") + const dangerObs = (reg.DangerObs ?? []).filter( + (o) => o.GeoHazardTID === GeoHazard.Snow && o.DangerSignTID !== 1 && o.DangerSignTID != null + ); + if (dangerObs.length > 0) { + const dangerNames = dangerObs + .map((o) => o.DangerSignName) + .filter((n) => !!n) + .join(', '); + const tooltip = `Faretegn: ${dangerNames}`; + const marker = this.createMarker(lat, lon, DANGER_SIGN_RADIUS, COLOR_DANGER, tooltip); + marker.addTo(this.dangerLayer); + this.dangerMarkers.push({ marker, dateKey }); + } + + // Skredhendelse (AvalancheObs is a single object on the registration) + const ava = reg.AvalancheObs; + if (ava) { + const size = ava.DestructiveSizeTID; + const radius = (size != null && AVALANCHE_RADIUS_BY_SIZE[size]) || DEFAULT_AVALANCHE_RADIUS; + const parts: string[] = []; + if (ava.DestructiveSizeName) parts.push(ava.DestructiveSizeName); + if (ava.AvalancheName) parts.push(ava.AvalancheName); + if (ava.AvalancheTriggerName) parts.push(ava.AvalancheTriggerName); + const tooltip = `Skred: ${parts.join(', ')}`; + const marker = this.createMarker(lat, lon, radius, COLOR_AVALANCHE, tooltip); + marker.addTo(this.avalancheLayer); + this.avalancheMarkers.push({ marker, dateKey }); + } + } + + this.applyActiveDateOpacity(this.activeDate()); + } + + private createMarker(lat: number, lon: number, radius: number, color: string, tooltip: string): L.CircleMarker { + const marker = L.circleMarker([lat, lon], { + renderer: this.canvasRenderer, + radius, + color, + weight: 1, + fillColor: color, + fillOpacity: OPACITY_ACTIVE_FILL, + opacity: OPACITY_ACTIVE_STROKE, + }); + if (tooltip) { + marker.bindTooltip(tooltip, { direction: 'top', opacity: 0.9 }); + } + return marker; + } + + private applyActiveDateOpacity(activeDate: string) { + const apply = (markers: AnalysisMarker[]) => { + for (const m of markers) { + const isActive = m.dateKey === activeDate; + m.marker.setStyle({ + fillOpacity: isActive ? OPACITY_ACTIVE_FILL : OPACITY_DIMMED_FILL, + opacity: isActive ? OPACITY_ACTIVE_STROKE : OPACITY_DIMMED_STROKE, + }); + } + }; + apply(this.dangerMarkers); + apply(this.avalancheMarkers); + } + + private clearMarkers() { + this.dangerLayer.clearLayers(); + this.avalancheLayer.clearLayers(); + this.dangerMarkers = []; + this.avalancheMarkers = []; + } +} diff --git a/src/app/pages/analysis/components/analysis-filter-menu/analysis-filter-menu.component.html b/src/app/pages/analysis/components/analysis-filter-menu/analysis-filter-menu.component.html new file mode 100644 index 000000000..57e314d29 --- /dev/null +++ b/src/app/pages/analysis/components/analysis-filter-menu/analysis-filter-menu.component.html @@ -0,0 +1,66 @@ + + + + {{ "ANALYSIS_FILTER.TITLE" | translate }} + + + + + + +
+ + + + {{ "ANALYSIS_FILTER.DANGER_SIGN" | translate }} + + + + {{ "ANALYSIS_FILTER.AVALANCHE" | translate }} + + +
+
+ + + + +
+ + + {{ + "ANALYSIS_FILTER.REFERENCE_DATE_HINT" | translate + }} + +
+
+ + + +
+
+
diff --git a/src/app/pages/analysis/components/analysis-filter-menu/analysis-filter-menu.component.scss b/src/app/pages/analysis/components/analysis-filter-menu/analysis-filter-menu.component.scss new file mode 100644 index 000000000..e9c7c7725 --- /dev/null +++ b/src/app/pages/analysis/components/analysis-filter-menu/analysis-filter-menu.component.scss @@ -0,0 +1,20 @@ +.analysis-color-dot { + display: inline-block; + width: 14px; + height: 14px; + border-radius: 50%; + margin-right: 12px; + + &--danger { + background-color: #000; + } + + &--avalanche { + background-color: #d9322a; + } +} + +.analysis-date-hint { + font-size: 0.85em; + color: var(--ion-color-medium); +} diff --git a/src/app/pages/analysis/components/analysis-filter-menu/analysis-filter-menu.component.ts b/src/app/pages/analysis/components/analysis-filter-menu/analysis-filter-menu.component.ts new file mode 100644 index 000000000..5e438812b --- /dev/null +++ b/src/app/pages/analysis/components/analysis-filter-menu/analysis-filter-menu.component.ts @@ -0,0 +1,102 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { + CheckboxCustomEvent, + DatetimeCustomEvent, + IonAccordion, + IonAccordionGroup, + IonCheckbox, + IonContent, + IonDatetime, + IonItem, + IonLabel, + IonList, + IonListHeader, +} from '@ionic/angular/standalone'; +import { TranslatePipe } from '@ngx-translate/core'; +import { AnalysisFilterService } from 'src/app/core/services/analysis-filter/analysis-filter.service'; +import { SearchCriteriaService } from 'src/app/core/services/search-criteria/search-criteria.service'; +import { HeaderWithSelectedItemsComponent } from 'src/app/modules/side-menu/components/header-with-selected-items/header-with-selected-items.component'; +import { RegionFilterComponent } from 'src/app/modules/side-menu/components/region-filter/region-filter.component'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { TranslateService } from '@ngx-translate/core'; +import { combineLatest, map } from 'rxjs'; + +/** + * Side-menu for the Analyse-tab. + * Mirrors look & feel of `FilterMenuComponent` but exposes only: + * - faretegn/skred checkboxes + * - reference date picker (14-day window end) + * - reused region filter + * - nickname (observer) searchbar + */ +@Component({ + selector: 'app-analysis-filter-menu', + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './analysis-filter-menu.component.html', + styleUrls: ['./analysis-filter-menu.component.scss'], + host: { style: 'display: flex; flex-direction: column; height: 100%' }, + imports: [ + HeaderWithSelectedItemsComponent, + IonAccordion, + IonAccordionGroup, + IonCheckbox, + IonContent, + IonDatetime, + IonItem, + IonLabel, + IonList, + IonListHeader, + RegionFilterComponent, + TranslatePipe, + ], +}) +export class AnalysisFilterMenuComponent { + private analysisFilterService = inject(AnalysisFilterService); + private searchCriteriaService = inject(SearchCriteriaService); + + showDangerSigns = this.analysisFilterService.showDangerSigns; + showAvalanches = this.analysisFilterService.showAvalanches; + referenceDate = this.analysisFilterService.referenceDate; + + private translateService = inject(TranslateService); + private translations = toSignal( + combineLatest([ + this.translateService.stream('ANALYSIS_FILTER.DANGER_SIGN'), + this.translateService.stream('ANALYSIS_FILTER.AVALANCHE'), + ]).pipe(map(([dangerSign, avalanche]) => ({ dangerSign, avalanche }))), + { initialValue: { dangerSign: '', avalanche: '' } } + ); + + selectedTypesSummary = computed(() => { + const result: string[] = []; + const t = this.translations(); + if (this.showDangerSigns()) { + result.push(t.dangerSign); + } + if (this.showAvalanches()) { + result.push(t.avalanche); + } + return result; + }); + + onDangerSignsChange(event: CheckboxCustomEvent) { + this.analysisFilterService.showDangerSigns.set(event.detail.checked); + } + + onAvalanchesChange(event: CheckboxCustomEvent) { + this.analysisFilterService.showAvalanches.set(event.detail.checked); + } + + onReferenceDateChange(event: DatetimeCustomEvent) { + const value = event.detail.value; + if (typeof value === 'string') { + this.analysisFilterService.referenceDate.set(value.slice(0, 10)); + } + } + + resetFilters() { + this.analysisFilterService.showDangerSigns.set(true); + this.analysisFilterService.showAvalanches.set(true); + this.searchCriteriaService.resetSearchCriteria(); + } +} diff --git a/src/app/pages/analysis/components/analysis-time-slider/analysis-time-slider.component.html b/src/app/pages/analysis/components/analysis-time-slider/analysis-time-slider.component.html new file mode 100644 index 000000000..0c013fbcc --- /dev/null +++ b/src/app/pages/analysis/components/analysis-time-slider/analysis-time-slider.component.html @@ -0,0 +1,21 @@ +
+
+ {{ "ANALYSIS.TIME_SLIDER_LABEL" | translate }} + {{ activeDate() }} +
+ +
+ {{ windowDates()[0] }} + {{ windowDates()[max()] }} +
+
diff --git a/src/app/pages/analysis/components/analysis-time-slider/analysis-time-slider.component.scss b/src/app/pages/analysis/components/analysis-time-slider/analysis-time-slider.component.scss new file mode 100644 index 000000000..2cf30c820 --- /dev/null +++ b/src/app/pages/analysis/components/analysis-time-slider/analysis-time-slider.component.scss @@ -0,0 +1,35 @@ +.analysis-time-slider { + position: absolute; + bottom: 16px; + left: 16px; + right: 16px; + z-index: 600; + padding: 12px 16px; + background: rgba(255, 255, 255, 0.95); + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + pointer-events: auto; + + &__label { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.9em; + margin-bottom: 4px; + } + + &__range { + display: flex; + justify-content: space-between; + font-size: 0.75em; + color: var(--ion-color-medium); + } + + ion-range { + padding: 0; + --bar-background: #ccc; + --bar-background-active: var(--ion-color-primary); + --knob-background: var(--ion-color-primary); + --pin-background: var(--ion-color-primary); + } +} diff --git a/src/app/pages/analysis/components/analysis-time-slider/analysis-time-slider.component.ts b/src/app/pages/analysis/components/analysis-time-slider/analysis-time-slider.component.ts new file mode 100644 index 000000000..dcd93e6e8 --- /dev/null +++ b/src/app/pages/analysis/components/analysis-time-slider/analysis-time-slider.component.ts @@ -0,0 +1,41 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { IonRange, RangeCustomEvent } from '@ionic/angular/standalone'; +import { TranslatePipe } from '@ngx-translate/core'; +import { AnalysisFilterService } from 'src/app/core/services/analysis-filter/analysis-filter.service'; + +/** + * Bottom-of-map slider that lets the user pick the "active" day inside the + * 14-day analyse-window. + */ +@Component({ + selector: 'app-analysis-time-slider', + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './analysis-time-slider.component.html', + styleUrls: ['./analysis-time-slider.component.scss'], + imports: [IonRange, TranslatePipe], +}) +export class AnalysisTimeSliderComponent { + private analysisFilterService = inject(AnalysisFilterService); + + windowDates = this.analysisFilterService.windowDates; + activeDate = this.analysisFilterService.activeDate; + + max = computed(() => this.windowDates().length - 1); + + /** Index of activeDate inside the window. */ + activeIndex = computed(() => { + const dates = this.windowDates(); + const i = dates.indexOf(this.activeDate()); + return i >= 0 ? i : dates.length - 1; + }); + + onSliderChange(event: RangeCustomEvent) { + const value = event.detail.value; + if (typeof value === 'number') { + const date = this.windowDates()[value]; + if (date) { + this.analysisFilterService.activeDate.set(date); + } + } + } +} diff --git a/src/app/pages/tabs/tabs.page.html b/src/app/pages/tabs/tabs.page.html index 38a7b7cfa..4149c876a 100644 --- a/src/app/pages/tabs/tabs.page.html +++ b/src/app/pages/tabs/tabs.page.html @@ -45,6 +45,19 @@ {{ "TABS.WARNINGS" | translate }} } + @if (showAnalysisTab()) { + + + {{ "TABS.ANALYSIS" | translate }} + + }