From e9052809798cfcd106247758dc6a9d0efbf85c2d Mon Sep 17 00:00:00 2001 From: Daniel von Atzigen Date: Fri, 28 Jun 2024 06:01:01 +0200 Subject: [PATCH] Rewrite map component --- apps/client-asset-sg/src/app/i18n/it.ts | 2 +- apps/client-asset-sg/src/app/i18n/rm.ts | 2 +- .../asset-editor-tab-geometries.component.ts | 78 ++- .../src/lib/asset-viewer.module.ts | 6 +- .../asset-picker/asset-picker.component.html | 4 +- .../asset-picker/asset-picker.component.ts | 12 +- .../asset-search-results.component.html | 4 +- .../asset-search-results.component.ts | 11 +- .../asset-viewer-page.component.html | 17 +- .../asset-viewer-page.component.scss | 1 + .../asset-viewer-page.component.ts | 21 +- .../components/map-controls/draw-controls.ts | 109 ++++ .../map-controls/map-controls.component.html | 20 + .../map-controls/map-controls.component.scss | 32 + .../map-controls/map-controls.component.ts | 33 + .../components/map-controls/zoom-control.ts | 46 ++ .../src/lib/components/map/index.ts | 1 - .../src/lib/components/map/map-controller.ts | 481 ++++++++++++++ .../src/lib/components/map/map.component.html | 6 +- .../src/lib/components/map/map.component.scss | 26 +- .../src/lib/components/map/map.component.ts | 617 +++++------------- libs/client-shared/src/lib/utils/map.ts | 77 ++- libs/shared/src/lib/models/study.ts | 3 + 23 files changed, 1000 insertions(+), 609 deletions(-) create mode 100644 libs/asset-viewer/src/lib/components/map-controls/draw-controls.ts create mode 100644 libs/asset-viewer/src/lib/components/map-controls/map-controls.component.html create mode 100644 libs/asset-viewer/src/lib/components/map-controls/map-controls.component.scss create mode 100644 libs/asset-viewer/src/lib/components/map-controls/map-controls.component.ts create mode 100644 libs/asset-viewer/src/lib/components/map-controls/zoom-control.ts delete mode 100644 libs/asset-viewer/src/lib/components/map/index.ts create mode 100644 libs/asset-viewer/src/lib/components/map/map-controller.ts diff --git a/apps/client-asset-sg/src/app/i18n/it.ts b/apps/client-asset-sg/src/app/i18n/it.ts index eb54438e..01e5289c 100644 --- a/apps/client-asset-sg/src/app/i18n/it.ts +++ b/apps/client-asset-sg/src/app/i18n/it.ts @@ -29,7 +29,7 @@ export const itAppTranslations: AppTranslations = { userManagement: 'IT Benutzer Verwaltung', }, map: { - zoomIn: 'IT Hineinoomen', + zoomIn: 'IT Hineinzoomen', zoomOut: 'IT Herauszoomen', zoomToOrigin: 'IT Zur Ursprungsposition zoomen', drawingModeOn: 'IT Zeichenmodus ein', diff --git a/apps/client-asset-sg/src/app/i18n/rm.ts b/apps/client-asset-sg/src/app/i18n/rm.ts index 8d381d9e..39e499b4 100644 --- a/apps/client-asset-sg/src/app/i18n/rm.ts +++ b/apps/client-asset-sg/src/app/i18n/rm.ts @@ -29,7 +29,7 @@ export const rmAppTranslations: AppTranslations = { userManagement: 'RM Benutzer Verwaltung', }, map: { - zoomIn: 'RM Hineinoomen', + zoomIn: 'RM Hineinzoomen', zoomOut: 'RM Herauszoomen', zoomToOrigin: 'RM Zur Ursprungsposition zoomen', drawingModeOn: 'RM Zeichenmodus ein', diff --git a/libs/asset-editor/src/lib/components/asset-editor-tab-geometries/asset-editor-tab-geometries.component.ts b/libs/asset-editor/src/lib/components/asset-editor-tab-geometries/asset-editor-tab-geometries.component.ts index 42b2c150..4e913bba 100644 --- a/libs/asset-editor/src/lib/components/asset-editor-tab-geometries/asset-editor-tab-geometries.component.ts +++ b/libs/asset-editor/src/lib/components/asset-editor-tab-geometries/asset-editor-tab-geometries.component.ts @@ -414,9 +414,9 @@ export class AssetEditorTabGeometriesComponent implements OnInit { this._vectorSourceAssetGeoms.clear(); const { studies } = this._form.getRawValue(); const studiesWithFeature = createFeaturesFromStudies(studies, { - point: featureStyles.bigPointStyleAsset, - polygon: featureStyles.polygonStyleAsset, - lineString: featureStyles.lineStringStyleAsset, + point: featureStyles.bigPointAsset, + polygon: featureStyles.polygonAsset, + lineString: featureStyles.lineStringAsset, }); this._vectorSourceAssetGeoms.addFeatures(studiesWithFeature.map((s) => s.olGeometry)); zoomToStudies(this._windowService, olMap, studies, 1); @@ -523,15 +523,15 @@ export class AssetEditorTabGeometriesComponent implements OnInit { } this._selectInteraction.getFeatures().push(f); } - f.setStyle(featureStyles.bigPointStyleAssetSelected); + f.setStyle(featureStyles.bigPointAssetHighlighted); } else { - f.setStyle(featureStyles.bigPointStyleAssetNotSelected); + f.setStyle(featureStyles.bigPointAssetNotSelected); } } else { f.setStyle( this._state.get().selectedStudyGeometrySelected - ? featureStyles.bigPointStyleAssetSelected - : featureStyles.bigPointStyleAsset + ? featureStyles.bigPointAssetHighlighted + : featureStyles.bigPointAsset ); } } @@ -540,14 +540,12 @@ export class AssetEditorTabGeometriesComponent implements OnInit { geomStyle( selectedStudy.geom, selectedStudyGeometrySelected - ? featureStyles.bigPointStyleAssetSelected - : featureStyles.bigPointStyleAsset, + ? featureStyles.bigPointAssetHighlighted + : featureStyles.bigPointAsset, + selectedStudyGeometrySelected ? featureStyles.polygonAssetHighlighted : featureStyles.polygonAsset, selectedStudyGeometrySelected - ? featureStyles.polygonStyleAssetSelected - : featureStyles.polygonStyleAsset, - selectedStudyGeometrySelected - ? featureStyles.lineStringStyleAssetSelected - : featureStyles.lineStringStyleAsset + ? featureStyles.lineStringAssetHighlighted + : featureStyles.lineStringAsset ) ); } @@ -574,15 +572,15 @@ export class AssetEditorTabGeometriesComponent implements OnInit { const id = getFeatureId(f); if (O.isSome(id)) { if (id.value.startsWith(makeCoordFeatureIdStartWithId(a.selectedStudy.studyId))) { - f.setStyle(featureStyles.bigPointStyleAssetSelected); + f.setStyle(featureStyles.bigPointAssetHighlighted); } if (id.value === a.selectedStudy.studyId) { f.setStyle( geomStyle( a.selectedStudy.geom, - featureStyles.bigPointStyleAssetSelected, - featureStyles.polygonStyleAssetSelected, - featureStyles.lineStringStyleAssetNotSelected + featureStyles.bigPointAssetHighlighted, + featureStyles.polygonAssetHighlighted, + featureStyles.lineStringAssetNotSelected ) ); } @@ -606,9 +604,9 @@ export class AssetEditorTabGeometriesComponent implements OnInit { if (O.isSome(study)) { const newStudies = [...this._state.get().studies, study.value]; const studiesWithFeature = createFeaturesFromStudies([study.value], { - point: featureStyles.bigPointStyleAsset, - polygon: featureStyles.polygonStyleAsset, - lineString: featureStyles.lineStringStyleAsset, + point: featureStyles.bigPointAsset, + polygon: featureStyles.polygonAsset, + lineString: featureStyles.lineStringAsset, }); this._vectorSourceAssetGeoms.addFeatures(studiesWithFeature.map((s) => s.olGeometry)); this._state.set( @@ -701,7 +699,7 @@ export class AssetEditorTabGeometriesComponent implements OnInit { const marker = this._vectorSourceAssetGeoms.getFeatureById(makeCoordFeatureId(study, i)); if (marker) { marker.setGeometry(new Point(olCoordsFromLV95Array([coord])[0])); - marker.setStyle(featureStyles.bigPointStyleAssetSelected); + marker.setStyle(featureStyles.bigPointAssetHighlighted); } else { makeCoordinateMarker(this._vectorSourceAssetGeoms, coord, i, study); } @@ -723,7 +721,7 @@ export class AssetEditorTabGeometriesComponent implements OnInit { const marker = this._vectorSourceAssetGeoms.getFeatureById(makeCoordFeatureId(study, i)); if (marker) { marker.setGeometry(new Point(olCoordsFromLV95Array([coord])[0])); - marker.setStyle(featureStyles.bigPointStyleAssetSelected); + marker.setStyle(featureStyles.bigPointAssetHighlighted); } else { makeCoordinateMarker(this._vectorSourceAssetGeoms, coord, i, study); } @@ -748,7 +746,7 @@ export class AssetEditorTabGeometriesComponent implements OnInit { const draw = new Draw({ source: this._vectorSourceDraw, type: 'Point', - style: featureStyles.bigPointStyleAssetSelected, + style: featureStyles.bigPointAssetHighlighted, }); olMap.addInteraction(draw); @@ -762,7 +760,7 @@ export class AssetEditorTabGeometriesComponent implements OnInit { if (O.isSome(study)) { const point = new Point(olCoordsFromLV95Array(study.value.geom.coords)[currentPointIndex]); const feature = decorateFeature(new Feature({ geometry: point }), { - style: featureStyles.bigPointStyleAsset, + style: featureStyles.bigPointAsset, id: `point_${currentPointIndex}`, }); this._vectorSourceDraw.addFeature(feature); @@ -775,7 +773,7 @@ export class AssetEditorTabGeometriesComponent implements OnInit { ]), }), { - style: featureStyles.polygonStyleAsset, + style: featureStyles.polygonAsset, id: `line_${currentPointIndex - 1}`, } ); @@ -792,7 +790,7 @@ export class AssetEditorTabGeometriesComponent implements OnInit { ]), }), { - style: featureStyles.polygonStyleAssetNotSelected, + style: featureStyles.polygonAssetNotSelected, id: `line_final`, } ); @@ -811,7 +809,7 @@ export class AssetEditorTabGeometriesComponent implements OnInit { .subscribe(() => { olMap.addInteraction(draw); const finalFeature = this._vectorSourceDraw.getFeatureById(`line_final`); - finalFeature && finalFeature.setStyle(featureStyles.polygonStyleAssetNotSelected); + finalFeature && finalFeature.setStyle(featureStyles.polygonAssetNotSelected); }); fromEvent(this.mapDiv.nativeElement, 'mouseout') @@ -822,7 +820,7 @@ export class AssetEditorTabGeometriesComponent implements OnInit { const preview2LineFeature = this._vectorSourceDraw.getFeatureById(`line_preview2`); preview2LineFeature && this._vectorSourceDraw.removeFeature(preview2LineFeature); const finalFeature = this._vectorSourceDraw.getFeatureById(`line_final`); - finalFeature && finalFeature.setStyle(featureStyles.polygonStyleAsset); + finalFeature && finalFeature.setStyle(featureStyles.polygonAsset); olMap.removeInteraction(draw); @@ -1014,7 +1012,7 @@ export class AssetEditorTabGeometriesComponent implements OnInit { if (pointFeature) { this._selectInteraction.getFeatures().clear(); this._selectInteraction.getFeatures().push(pointFeature); - pointFeature.setStyle(featureStyles.bigPointStyleAssetSelected); + pointFeature.setStyle(featureStyles.bigPointAssetHighlighted); } } } else { @@ -1052,9 +1050,9 @@ export class AssetEditorTabGeometriesComponent implements OnInit { const study = getCurrentStudy(this._state.get()); if (O.isSome(study)) { const studyWithFeature = createFeaturesFromStudy(study.value, { - point: featureStyles.bigPointStyleAsset, - polygon: featureStyles.polygonStyleAsset, - lineString: featureStyles.lineStringStyleAsset, + point: featureStyles.bigPointAsset, + polygon: featureStyles.polygonAsset, + lineString: featureStyles.lineStringAsset, }); this._vectorSourceAssetGeoms.addFeatures([studyWithFeature.olGeometry]); this.updateMarkersForStudy(study.value); @@ -1084,9 +1082,9 @@ export class AssetEditorTabGeometriesComponent implements OnInit { f.setStyle( geomStyle( study.value.geom, - featureStyles.bigPointStyleAsset, - featureStyles.polygonStyleAsset, - featureStyles.lineStringStyleAsset + featureStyles.bigPointAsset, + featureStyles.polygonAsset, + featureStyles.lineStringAsset ) ); } @@ -1158,7 +1156,7 @@ const makeCoordinateMarker = (vectorSource: VectorSource, coord: LV95, decorateFeature( new Feature(new Point(olCoordsFromLV95Array([coord])[0])), { - style: featureStyles.bigPointStyleAsset, + style: featureStyles.bigPointAsset, id: makeCoordFeatureId(study, index), }, { @@ -1170,9 +1168,9 @@ const makeCoordinateMarker = (vectorSource: VectorSource, coord: LV95, const setFeatureStyle = (feature: Feature, study: Study, selected: boolean) => { const style = Geom.matchStrict({ - Point: () => (selected ? featureStyles.bigPointStyleAsset : featureStyles.bigPointStyleAssetNotSelected), - Polygon: () => (selected ? featureStyles.polygonStyleAsset : featureStyles.polygonStyleAssetNotSelected), - LineString: () => (selected ? featureStyles.lineStringStyleAsset : featureStyles.lineStringStyleAssetNotSelected), + Point: () => (selected ? featureStyles.bigPointAsset : featureStyles.bigPointAssetNotSelected), + Polygon: () => (selected ? featureStyles.polygonAsset : featureStyles.polygonAssetNotSelected), + LineString: () => (selected ? featureStyles.lineStringAsset : featureStyles.lineStringAssetNotSelected), })(study.geom); feature.setStyle(style); }; diff --git a/libs/asset-viewer/src/lib/asset-viewer.module.ts b/libs/asset-viewer/src/lib/asset-viewer.module.ts index cd7cf8ff..c2bb03ee 100644 --- a/libs/asset-viewer/src/lib/asset-viewer.module.ts +++ b/libs/asset-viewer/src/lib/asset-viewer.module.ts @@ -21,6 +21,7 @@ import { MatRowDef, MatTable, } from '@angular/material/table'; +import { MatTooltip } from '@angular/material/tooltip'; import { MatDateFnsModule } from '@angular/material-date-fns-adapter'; import { RouterModule } from '@angular/router'; import { @@ -53,7 +54,8 @@ import { AssetSearchFilterListComponent } from './components/asset-search-filter import { AssetSearchRefineComponent } from './components/asset-search-refine'; import { AssetSearchResultsComponent } from './components/asset-search-results'; import { AssetViewerPageComponent } from './components/asset-viewer-page'; -import { MapComponent } from './components/map'; +import { MapComponent } from './components/map/map.component'; +import { MapControlsComponent } from './components/map-controls/map-controls.component'; import { AssetSearchEffects } from './state/asset-search/asset-search.effects'; import { assetSearchReducer } from './state/asset-search/asset-search.reducer'; @@ -61,6 +63,7 @@ import { assetSearchReducer } from './state/asset-search/asset-search.reducer'; declarations: [ AssetViewerPageComponent, MapComponent, + MapControlsComponent, AssetSearchDetailComponent, AssetSearchRefineComponent, AssetSearchFilterListComponent, @@ -119,6 +122,7 @@ import { assetSearchReducer } from './state/asset-search/asset-search.reducer'; MatRowDef, SmartTranslatePipe, CdkMonitorFocus, + MatTooltip, ], providers: [ TranslatePipe, diff --git a/libs/asset-viewer/src/lib/components/asset-picker/asset-picker.component.html b/libs/asset-viewer/src/lib/components/asset-picker/asset-picker.component.html index a740767f..e8f290b7 100644 --- a/libs/asset-viewer/src/lib/components/asset-picker/asset-picker.component.html +++ b/libs/asset-viewer/src/lib/components/asset-picker/asset-picker.component.html @@ -18,8 +18,8 @@ diff --git a/libs/asset-viewer/src/lib/components/asset-picker/asset-picker.component.ts b/libs/asset-viewer/src/lib/components/asset-picker/asset-picker.component.ts index aaac363c..b9cfa3dc 100644 --- a/libs/asset-viewer/src/lib/components/asset-picker/asset-picker.component.ts +++ b/libs/asset-viewer/src/lib/components/asset-picker/asset-picker.component.ts @@ -74,7 +74,7 @@ export class AssetPickerComponent extends RxState { this.connect('currentAssetId', currentAssetId$); } - @Output() assetMouseOver = new EventEmitter>(); + @Output() assetMouseOver = new EventEmitter(); constructor() { super(); @@ -158,19 +158,11 @@ export class AssetPickerComponent extends RxState { ) ); - this.closePicker$.pipe(untilDestroyed(this)).subscribe(() => this.onAssetMouseOut()); + this.closePicker$.pipe(untilDestroyed(this)).subscribe(() => this.assetMouseOver.emit(null)); } public selectAndClose(assetId: number) { this._store.dispatch(actions.searchForAssetDetail({ assetId })); this.closePicker$.next(); } - - public onAssetMouseOver(assetId: number) { - this.assetMouseOver.emit(O.some(assetId)); - } - - public onAssetMouseOut() { - this.assetMouseOver.emit(O.none); - } } diff --git a/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.html b/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.html index 3ca7249a..9b3f6dc3 100644 --- a/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.html +++ b/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.html @@ -78,8 +78,8 @@ mat-row *matRowDef="let row; columns: COLUMNS" (click)="searchForAsset(row.assetId)" - (mouseover)="onAssetMouseOver(row.assetId)" - (mouseleave)="onAssetMouseOut()" + (mouseover)="assetMouseOver.emit(row.assetId)" + (mouseleave)="assetMouseOver.emit(null)" > diff --git a/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.ts b/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.ts index 6518ff99..b7c49ad0 100644 --- a/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.ts +++ b/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.ts @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, EventEmitter, inject, Output } from import { ActivatedRoute, Router } from '@angular/router'; import { Store } from '@ngrx/store'; import * as O from 'fp-ts/Option'; +import { Observable } from 'rxjs'; import * as actions from '../../state/asset-search/asset-search.actions'; import { AppStateWithAssetSearch, LoadingState } from '../../state/asset-search/asset-search.reducer'; import { @@ -19,7 +20,7 @@ import { }) export class AssetSearchResultsComponent { @Output() closeSearchResultsClicked = new EventEmitter(); - @Output() assetMouseOver = new EventEmitter>(); + @Output() assetMouseOver = new EventEmitter(); protected readonly COLUMNS = [ 'favourites', @@ -39,16 +40,8 @@ export class AssetSearchResultsComponent { public loadingState = this._store.select(selectSearchLoadingState); public pageStats$ = this._store.select(selectAssetSearchPageData); - public onAssetMouseOver(assetId: number) { - this.assetMouseOver.emit(O.some(assetId)); - } - protected readonly LoadingState = LoadingState; - public onAssetMouseOut() { - this.assetMouseOver.emit(O.none); - } - public searchForAsset(assetId: number) { this._store.dispatch(actions.searchForAssetDetail({ assetId })); } diff --git a/libs/asset-viewer/src/lib/components/asset-viewer-page/asset-viewer-page.component.html b/libs/asset-viewer/src/lib/components/asset-viewer-page/asset-viewer-page.component.html index 490b5f04..9ba628fa 100644 --- a/libs/asset-viewer/src/lib/components/asset-viewer-page/asset-viewer-page.component.html +++ b/libs/asset-viewer/src/lib/components/asset-viewer-page/asset-viewer-page.component.html @@ -1,16 +1,15 @@ + [highlightedAssetId]="highlightedAssetId" + (assetsClick)="assetClicked$.next($event)" + (assetsHover)="highlightedAssetId = $event[0]" + (initializeEnd)="handleMapInitialised()" +/> @@ -24,6 +23,6 @@ - + diff --git a/libs/asset-viewer/src/lib/components/asset-viewer-page/asset-viewer-page.component.scss b/libs/asset-viewer/src/lib/components/asset-viewer-page/asset-viewer-page.component.scss index 40d5b4bd..bf96f7fd 100644 --- a/libs/asset-viewer/src/lib/components/asset-viewer-page/asset-viewer-page.component.scss +++ b/libs/asset-viewer/src/lib/components/asset-viewer-page/asset-viewer-page.component.scss @@ -14,6 +14,7 @@ grid-template-columns: auto 1fr auto; } +asset-sg-map, asset-sg-map { grid-area: map; } diff --git a/libs/asset-viewer/src/lib/components/asset-viewer-page/asset-viewer-page.component.ts b/libs/asset-viewer/src/lib/components/asset-viewer-page/asset-viewer-page.component.ts index 08a7e496..4311cafc 100644 --- a/libs/asset-viewer/src/lib/components/asset-viewer-page/asset-viewer-page.component.ts +++ b/libs/asset-viewer/src/lib/components/asset-viewer-page/asset-viewer-page.component.ts @@ -1,4 +1,3 @@ -import { ENTER } from '@angular/cdk/keycodes'; import { TemplatePortal } from '@angular/cdk/portal'; import { AfterViewInit, @@ -13,7 +12,6 @@ import { ViewChild, ViewContainerRef, } from '@angular/core'; -import { Router } from '@angular/router'; import { AppPortalService, appSharedStateActions, @@ -22,7 +20,7 @@ import { LifecycleHooksDirective, } from '@asset-sg/client-shared'; import { isTruthy } from '@asset-sg/core'; -import { AssetEditDetail, LV95 } from '@asset-sg/shared'; +import { AssetEditDetail } from '@asset-sg/shared'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { Store } from '@ngrx/store'; import * as A from 'fp-ts/Array'; @@ -47,7 +45,6 @@ import { import * as actions from '../../state/asset-search/asset-search.actions'; import { - selectAssetSearchPolygon, selectAssetSearchQuery, selectAssetSearchResultData, selectCurrentAssetDetail, @@ -61,7 +58,7 @@ import { styleUrls: ['./asset-viewer-page.component.scss'], hostDirectives: [LifecycleHooksDirective], }) -export class AssetViewerPageComponent implements OnDestroy, AfterViewInit { +export class AssetViewerPageComponent implements AfterViewInit, OnDestroy { @ViewChild('templateAppBarPortalContent') templateAppBarPortalContent!: TemplateRef; @ViewChild('searchInput') searchInput!: ElementRef; @@ -72,32 +69,28 @@ export class AssetViewerPageComponent implements OnDestroy, AfterViewInit { private _appRef = inject(ApplicationRef); private _cd = inject(ChangeDetectorRef); private _ngZone = inject(NgZone); - private _router = inject(Router); - public searchPolygon$ = this._store.select(selectAssetSearchPolygon).pipe(map(O.fromNullable)); public currentAssetId$ = this._store.select(selectCurrentAssetDetail).pipe( map((currentAsset) => currentAsset?.assetId), map(O.fromNullable) ); public currentAsset$ = this._store.select(selectCurrentAssetDetail); - public removePolygon$ = new Subject(); public isFiltersOpen$ = this._store.select(selectIsFiltersOpen); public _searchTextKeyDown$ = new Subject(); private _searchTextChanged$ = this._searchTextKeyDown$.pipe( - filter((ev) => ev.keyCode === ENTER), + filter((ev) => ev.key === 'Enter'), map((ev) => { const value = (ev.target as HTMLInputElement).value; return value ? O.some(value) : O.none; }) ); - public polygonChanged$ = new Subject(); public assetClicked$ = new Subject(); public closeSearchResultsClicked$ = new Subject(); public closeInstructions$ = new Subject(); public assetsForPicker$: Observable; - public highlightAssetStudies$ = new Subject>(); + public highlightedAssetId: number | null = null; public ngAfterViewInit() { this._store.dispatch(actions.initializeSearch()); @@ -166,11 +159,7 @@ export class AssetViewerPageComponent implements OnDestroy, AfterViewInit { O.getOrElseW(() => actions.clearSearchText()) ) ) - ), - this.polygonChanged$.pipe( - map((polygon) => actions.searchByFilterConfiguration({ filterConfiguration: { polygon } })) - ), - this.removePolygon$.pipe(map(() => actions.removePolygon())) + ) ) .pipe(untilDestroyed(this)) .subscribe(this._store); diff --git a/libs/asset-viewer/src/lib/components/map-controls/draw-controls.ts b/libs/asset-viewer/src/lib/components/map-controls/draw-controls.ts new file mode 100644 index 00000000..08a204fb --- /dev/null +++ b/libs/asset-viewer/src/lib/components/map-controls/draw-controls.ts @@ -0,0 +1,109 @@ +import { olCoordsFromLV95, toLonLat, WGStoLV95 } from '@asset-sg/client-shared'; +import { isNotNull, isNull } from '@asset-sg/core'; +import { LV95 } from '@asset-sg/shared'; +import { Control } from 'ol/control'; +import Feature from 'ol/Feature'; +import { Polygon } from 'ol/geom'; +import Draw, { DrawEvent } from 'ol/interaction/Draw'; +import Map from 'ol/Map'; +import MapBrowserEvent from 'ol/MapBrowserEvent'; +import VectorSource from 'ol/source/Vector'; +import View from 'ol/View'; +import { BehaviorSubject, distinctUntilChanged, filter, fromEventPattern, map, Observable, Subscription } from 'rxjs'; + +export class DrawControl extends Control { + private readonly polygonSource: VectorSource; + private readonly draw: Draw; + + private readonly subscription = new Subscription(); + + private readonly _polygon$ = new BehaviorSubject(null); + private readonly _isDrawing$ = new BehaviorSubject(false); + + constructor({ polygonSource, ...options }: Options) { + super(options); + + this.polygonSource = polygonSource; + this.draw = new Draw({ source: this.polygonSource, type: 'Polygon' }); + + // Toggle the draw interaction based on whether the control is active. + this.isDrawing$.subscribe((isDrawing) => { + const map = this.getMap(); + if (map == null) { + return; + } + if (isDrawing) { + map.addInteraction(this.draw); + } else { + map.removeInteraction(this.draw); + } + }); + + // Clear the previous polygon when a new one is started. + fromEventPattern((h) => this.draw.on('drawstart', h)).subscribe(() => { + this.polygonSource.clear(); + }); + + // Remove the polygon feature when the polygon is cleared. + this._polygon$.pipe(filter(isNull)).subscribe(() => { + this.polygonSource.clear(); + }); + + // Add or update the polygon feature when the polygon changes. + this._polygon$.pipe(filter(isNotNull)).subscribe((polygon) => { + const geometry = new Polygon([polygon.map(olCoordsFromLV95)]); + const features = this.polygonSource.getFeatures(); + if (features.length > 1) { + throw new Error('expected exactly one feature on the polygon layer'); + } + if (features.length === 0) { + this.polygonSource.addFeature(new Feature({ geometry })); + return; + } + features[0].setGeometry(geometry); + }); + + // Replace the control's polygon when a full polygon is drawn on the map. + fromEventPattern((h) => this.draw.on('drawend', h)) + .pipe( + map((e) => { + const flatCoords = (e.feature.getGeometry() as Polygon).getFlatCoordinates(); + const polygon: LV95[] = []; + for (let i = 0; i < flatCoords.length; i += 2) { + const coords = WGStoLV95(toLonLat([flatCoords[i], flatCoords[i + 1]])); + polygon.push(coords); + } + return polygon; + }) + ) + .subscribe(this.setPolygon.bind(this)); + } + + toggle(): void { + this._isDrawing$.next(!this._isDrawing$.value); + } + + get polygon$(): Observable { + return this._polygon$.asObservable(); + } + + get isDrawing$(): Observable { + return this._isDrawing$.asObservable(); + } + + setPolygon(polygon: LV95[] | null) { + this._isDrawing$.next(false); + this._polygon$.next(polygon); + } + + override dispose(): void { + super.dispose(); + this.subscription.unsubscribe(); + } +} + +type ControlOptions = ConstructorParameters[0]; + +interface Options extends ControlOptions { + polygonSource: VectorSource; +} diff --git a/libs/asset-viewer/src/lib/components/map-controls/map-controls.component.html b/libs/asset-viewer/src/lib/components/map-controls/map-controls.component.html new file mode 100644 index 00000000..5967c1ea --- /dev/null +++ b/libs/asset-viewer/src/lib/components/map-controls/map-controls.component.html @@ -0,0 +1,20 @@ + + + + + + + diff --git a/libs/asset-viewer/src/lib/components/map-controls/map-controls.component.scss b/libs/asset-viewer/src/lib/components/map-controls/map-controls.component.scss new file mode 100644 index 00000000..282e8523 --- /dev/null +++ b/libs/asset-viewer/src/lib/components/map-controls/map-controls.component.scss @@ -0,0 +1,32 @@ +@use "../../styles/variables"; + +:host { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.25rem 0 0.75rem 0; + + color: variables.$cyan-09; + background-color: variables.$grey-01; + border-radius: 8px; + box-shadow: 4px 4px 2px #00000029; +} + +.divider { + height: 2px; + width: 1.5rem; + background-color: variables.$grey-03; + margin: 0.5rem 0; +} + +:host > button:not([aria-disabled="true"]):not(:hover) { + &, + &:focus { + &.is-active { + color: variables.$red; + } + &:not(is-active) { + color: #357183; + } + } +} diff --git a/libs/asset-viewer/src/lib/components/map-controls/map-controls.component.ts b/libs/asset-viewer/src/lib/components/map-controls/map-controls.component.ts new file mode 100644 index 00000000..d36b1db3 --- /dev/null +++ b/libs/asset-viewer/src/lib/components/map-controls/map-controls.component.ts @@ -0,0 +1,33 @@ +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { Subscription } from 'rxjs'; +import { DrawControl } from './draw-controls'; +import { ZoomControl } from './zoom-control'; + +@Component({ + selector: 'asset-sg-map-controls', + templateUrl: './map-controls.component.html', + styleUrl: './map-controls.component.scss', +}) +export class MapControlsComponent implements OnInit, OnDestroy { + @Input({ required: true }) + zoom!: ZoomControl; + + @Input({ required: true }) + draw!: DrawControl; + + isDrawing = false; + + private readonly subscription = new Subscription(); + + ngOnInit(): void { + this.subscription.add( + this.draw.isDrawing$.subscribe((isDrawing) => { + this.isDrawing = isDrawing; + }) + ); + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } +} diff --git a/libs/asset-viewer/src/lib/components/map-controls/zoom-control.ts b/libs/asset-viewer/src/lib/components/map-controls/zoom-control.ts new file mode 100644 index 00000000..df355e26 --- /dev/null +++ b/libs/asset-viewer/src/lib/components/map-controls/zoom-control.ts @@ -0,0 +1,46 @@ +import { fitToSwitzerland } from '@asset-sg/client-shared'; +import { Control } from 'ol/control'; +import { easeOut } from 'ol/easing'; +import View from 'ol/View'; + +export class ZoomControl extends Control { + zoomIn(): void { + this.changeZoom(1); + } + + zoomOut(): void { + this.changeZoom(-1); + } + + resetZoom(): void { + const view = this.view; + if (view == null) { + return; + } + fitToSwitzerland(view, true); + } + + private changeZoom(delta: number): void { + const view = this.view; + if (view == null) { + return; + } + const zoom = view.getZoom(); + if (zoom == null) { + return; + } + + if (view.getAnimating()) { + view.cancelAnimations(); + } + view.animate({ + zoom: view.getConstrainedZoom(zoom + delta), + duration: 250, + easing: easeOut, + }); + } + + private get view(): View | null { + return this.getMap()?.getView() ?? null; + } +} diff --git a/libs/asset-viewer/src/lib/components/map/index.ts b/libs/asset-viewer/src/lib/components/map/index.ts deleted file mode 100644 index 13ec035e..00000000 --- a/libs/asset-viewer/src/lib/components/map/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './map.component'; diff --git a/libs/asset-viewer/src/lib/components/map/map-controller.ts b/libs/asset-viewer/src/lib/components/map/map-controller.ts new file mode 100644 index 00000000..11dc9f76 --- /dev/null +++ b/libs/asset-viewer/src/lib/components/map/map-controller.ts @@ -0,0 +1,481 @@ +import { featureStyles, fitToSwitzerland, makeRhombusImage, olCoordsFromLV95 } from '@asset-sg/client-shared'; +import { isNotUndefined } from '@asset-sg/core'; +import { AssetEditDetail, getCoordsFromStudy, Study } from '@asset-sg/shared'; +import { Control } from 'ol/control'; +import { Coordinate } from 'ol/coordinate'; +import Feature from 'ol/Feature'; +import { Geometry, LineString, Point, Polygon } from 'ol/geom'; +import { fromExtent as polygonFromExtent } from 'ol/geom/Polygon'; +import { Heatmap, Tile as TileLayer, Vector as VectorLayer } from 'ol/layer'; +import OlMap from 'ol/Map'; +import MapBrowserEvent from 'ol/MapBrowserEvent'; +import { Cluster, Tile, Vector as VectorSource, XYZ } from 'ol/source'; +import { Circle } from 'ol/style'; +import Style from 'ol/style/Style'; +import View from 'ol/View'; +import { BehaviorSubject, distinctUntilChanged, filter, fromEventPattern, map, Observable, switchMap } from 'rxjs'; +import { AllStudyDTO } from '../../models'; +import { wktToGeoJSON } from '../../state/asset-search/asset-search.selector'; + +export class MapController { + private readonly map: OlMap; + + readonly layers: MapLayers; + + readonly sources: MapLayerSources; + + private readonly assetsById = new Map(); + private readonly assetsByStudyId = new Map(); + + private readonly _isInitialized$ = new BehaviorSubject(false); + readonly assetsClick$: Observable; + readonly assetsHover$: Observable; + + constructor(element: HTMLElement) { + const view = new View({ + center: [900000, 5900000], + zoom: 9, + projection: 'EPSG:3857', + minZoom: 8.5, + }); + + this.layers = this.makeLayers(); + this.sources = makeSources(this.layers); + + this.map = new OlMap({ + target: element, + layers: [ + this.layers.raster, + this.layers.heatmap, + this.layers.studies, + this.layers.assets, + this.layers.activeAsset, + this.layers.polygon, + this.layers.picker, + ], + view: view, + }); + + fromEventPattern((h) => view.on('change:resolution', h)) + .pipe( + map(() => view.getZoom()), + filter(isNotUndefined), + map((zoom) => parseFloat(zoom.toFixed(3))), + distinctUntilChanged() + ) + .subscribe(this.handleZoomChange.bind(this)); + + this.assetsClick$ = this.makeAssetsClick$(); + this.assetsHover$ = this.makeAssetsHover$(); + + this.map.once('loadend', () => { + fitToSwitzerland(view, false); + const zoom = view.getZoom(); + if (zoom != null) { + view.setMinZoom(zoom); + } + this._isInitialized$.next(true); + }); + } + + get isInitialized$(): Observable { + return this._isInitialized$.asObservable(); + } + + addControl(control: Control): void { + this.map.addControl(control); + } + + setStudies(studies: AllStudyDTO[]): void { + const studyFeatures: Feature[] = Array(studies.length); + const heatmapFeatures: Feature[] = Array(studies.length); + + for (let i = 0; i < studies.length; i++) { + const study = studies[i]; + const geometry = new Point(olCoordsFromLV95(study.centroid)); + + const heatmapFeature = new Feature(geometry); + heatmapFeature.setId(study.studyId); + heatmapFeatures[i] = heatmapFeature; + + const studyFeature = new Feature(geometry); + studyFeature.setId(study.studyId); + studyFeature.setStyle(study.isPoint ? featureStyles.point : featureStyles.rhombus); + studyFeature.setProperties({ 'swisstopo.type': 'StudyPoint' }); + studyFeatures[i] = studyFeature; + } + + window.requestAnimationFrame(() => { + this.sources.heatmap.clear(); + this.sources.heatmap.addFeatures(heatmapFeatures); + + this.sources.studies.clear(); + this.sources.studies.addFeatures(studyFeatures); + }); + } + + setAssets(assets: AssetEditDetail[]): void { + this.assetsById.clear(); + this.assetsByStudyId.clear(); + + const features: Feature[] = []; + const studies: Study[] = []; + for (let i = 0; i < assets.length; i++) { + const asset = assets[i]; + this.assetsById.set(asset.assetId, asset); + for (const assetStudy of asset.studies) { + const study = { studyId: assetStudy.studyId, geom: wktToGeoJSON(assetStudy.geomText) }; + features.push( + makeStudyFeature(study, { + point: featureStyles.bigPoint, + polygon: featureStyles.polygon, + lineString: featureStyles.lineString, + }) + ); + + const studyFeature = this.sources.studies.getFeatureById(study.studyId); + if (studyFeature != null) { + studyFeature.set('previousStyle', studyFeature.getStyle()); + studyFeature.setStyle(featureStyles.hidden); + } + + this.assetsByStudyId.set(study.studyId, asset); + } + } + window.requestAnimationFrame(() => { + this.sources.assets.clear(); + this.sources.assets.addFeatures(features); + zoomToStudies(this.map, studies); + }); + } + + clearAssets(): void { + this.assetsById.clear(); + this.assetsByStudyId.clear(); + window.requestAnimationFrame(() => { + this.sources.assets.clear(); + this.sources.polygon.clear(); + this.sources.studies.forEachFeature((feature) => { + const previousStyle = feature.get('previousStyle'); + if (previousStyle == null) { + return; + } + feature.setStyle(previousStyle); + feature.unset('previousStyle'); + }); + }); + } + + setHighlightedAsset(assetId: number): void { + const asset = this.assetsById.get(assetId); + if (asset == null) { + this.sources.picker.clear(); + return; + } + const features = asset.studies.map((assetStudy) => { + const study = { studyId: assetStudy.studyId, geom: wktToGeoJSON(assetStudy.geomText) }; + return makeStudyFeature(study, { + point: featureStyles.bigPointAssetHighlighted, + polygon: featureStyles.polygonAssetHighlighted, + lineString: featureStyles.lineStringAssetHighlighted, + }); + }); + this.sources.picker.clear(); + this.sources.picker.addFeatures(features); + } + + clearHighlightedAsset(): void { + this.sources.picker.clear(); + } + + setActiveAsset(asset: AssetEditDetail): void { + this.sources.activeAsset.clear(); + this.layers.assets.setOpacity(0.5); + this.layers.studies.setOpacity(0.5); + + const studies: Study[] = []; + const features: Feature[] = []; + for (const assetStudy of asset.studies) { + const study = { + ...assetStudy, + geom: wktToGeoJSON(assetStudy.geomText), + }; + studies.push(study); + + const feature = makeStudyFeature(study, { + point: featureStyles.bigPointAsset, + polygon: featureStyles.polygonAsset, + lineString: featureStyles.lineStringAsset, + }); + features.push(feature); + } + + window.requestAnimationFrame(() => { + this.sources.activeAsset.addFeatures(features); + zoomToStudies(this.map, studies); + }); + } + + clearActiveAsset(): void { + this.sources.activeAsset.clear(); + this.layers.assets.setOpacity(1); + this.layers.studies.setOpacity(1); + window.requestAnimationFrame(() => { + fitToSwitzerland(this.map.getView(), true); + }); + } + + dispose(): void { + this.map.dispose(); + } + + private handleZoomChange(zoom: number): void { + (featureStyles.point.getImage() as Circle).setRadius(zoom < 12 ? 4 : 4 * (zoom / 7.5)); + featureStyles.rhombus.setImage(makeRhombusImage(zoom < 12 ? 5 : 5 * (zoom / 7.5))); + } + + private makeLayers(): MapLayers { + return { + raster: new TileLayer({ + source: new XYZ({ + url: `https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.pixelkarte-farbe/default/current/3857/{z}/{x}/{y}.jpeg`, + }), + }), + heatmap: this.makeHeatmapLayer(), + studies: makeSimpleLayer({ minZoom: 11 }), + polygon: makeSimpleLayer(), + assets: makeSimpleLayer(), + activeAsset: makeSimpleLayer(), + picker: makeSimpleLayer(), + }; + } + + private makeHeatmapLayer(): MapLayer { + const source = new VectorSource({ wrapX: false }); + const cluster = new Cluster({ + distance: 2, + source: source, + }) as unknown as VectorSource; + + return new Heatmap({ + source: cluster, + weight: (feature) => (feature.get('features') == null ? 0 : 1), + maxZoom: 12, + blur: 20, + radius: 5, + opacity: 0.7, + }) as MapLayer; + } + + private makeAssetsClick$(): Observable { + return fromEventPattern>( + (h) => this.map.on('click', h), + (h) => this.map.un('click', h) + ).pipe( + // Extract the ids of the assets that have been clicked. + map((event) => { + const assetIds: number[] = []; + this.map.forEachFeatureAtPixel( + event.pixel, + (feature): void => { + const featureId = feature.getId(); + if (featureId == null) { + return; + } + const asset = this.assetsByStudyId.get(`${featureId}`); + if (asset != null) { + assetIds.push(asset.assetId); + } + }, + { + layerFilter: (layer) => layer === this.layers.assets, + } + ); + return assetIds; + }) + ); + } + + private makeAssetsHover$(): Observable { + return fromEventPattern>( + (h) => this.map.on('pointermove', h), + (h) => this.map.un('pointermove', h) + ).pipe( + switchMap((event) => this.layers.assets.getFeatures(event.pixel)), + + // Extract the ids of the assets that have been hovered. + map((features) => { + const assetIds: number[] = []; + for (const feature of features) { + const featureId = feature.getId(); + if (featureId == null) { + continue; + } + const asset = this.assetsByStudyId.get(`${featureId}`); + if (asset != null) { + assetIds.push(asset.assetId); + } + } + return assetIds; + }) + ); + } +} + +interface MapLayers { + /** + * The actual map. + */ + raster: TileLayer; + + /** + * A heatmap of studies. + */ + heatmap: MapLayer; + + /** + * A layer displaying a single point for each study. + * Only shown when zooming in close enough. + */ + studies: MapLayer; + + /** + * A layer displaying the geometries of all visible assets. + */ + assets: MapLayer; + + /** + * A layer displaying the geometries of the currently selected asset. + * This allows all other visible assets to be transparent + * while the selected one stays fully opaque. + */ + activeAsset: MapLayer; + + /** + * The currently drawn polygon. + */ + polygon: MapLayer; + + picker: MapLayer; +} + +type MapLayerSources = { + [K in keyof MapLayers]: MapLayers[K] extends { getSource(): infer S | null } ? S : never; +}; + +type MapLayer = VectorLayer>; + +type LayerOptions = ConstructorParameters[0]; + +const makeSimpleLayer = (options: LayerOptions = {}): MapLayer => + new VectorLayer({ + ...options, + source: new VectorSource({ wrapX: false }), + }) as MapLayer; + +const requireSource = (layer: { getSource(): S | null }): S => { + const source = layer.getSource(); + if (source == null) { + throw new Error('expected source to be present'); + } + return source; +}; + +const makeSources = (layers: MapLayers): MapLayerSources => ({ + raster: requireSource(layers.raster), + heatmap: requireSource(requireSource(layers.heatmap) as unknown as Cluster) as VectorSource, + studies: requireSource(layers.studies), + polygon: requireSource(layers.polygon), + assets: requireSource(layers.assets), + activeAsset: requireSource(layers.activeAsset), + picker: requireSource(layers.picker), +}); + +const makeStudyFeature = (study: Study, styles: { point: Style; polygon: Style; lineString: Style }): Feature => { + const [geometry, style] = ((): [Geometry, Style] => { + switch (study.geom._tag) { + case 'Point': + return [new Point(olCoordsFromLV95(study.geom.coord)), styles.point]; + case 'LineString': + return [new LineString(study.geom.coords.map(olCoordsFromLV95)), styles.lineString]; + case 'Polygon': + return [new Polygon([study.geom.coords.map(olCoordsFromLV95)]), styles.polygon]; + } + })(); + + const feature = new Feature({ geometry }); + feature.setId(study.studyId); + feature.setStyle(style); + feature.setProperties({ 'swisstopo.type': 'StudyGeometry' }); + return feature; +}; + +const zoomToStudies = (map: OlMap, studies: Study[]): void => { + if (studies.length === 0) { + return; + } + + const view = map.getView(); + const size = map.getSize(); + const oldCenter = view.getCenter(); + const oldZoom = view.getZoom(); + if (size == null) { + return; + } + + if (studies.length === 1 && studies[0].geom._tag === 'Point') { + const coord = olCoordsFromLV95(studies[0].geom.coord); + view.setZoom(18); + view.centerOn(coord, size, [size[0] * 0.5, size[1] / 2]); + } else { + const extent = { + min: { x: Number.MAX_VALUE, y: Number.MAX_VALUE }, + max: { x: Number.MIN_VALUE, y: Number.MIN_VALUE }, + }; + + for (const study of studies) { + const lv95Coords = getCoordsFromStudy(study); + for (const lv95Coord of lv95Coords) { + const [x, y] = olCoordsFromLV95(lv95Coord); + if (extent.min.x > x) { + extent.min.x = x; + } + if (extent.min.y > y) { + extent.min.y = y; + } + if (extent.max.x < x) { + extent.max.x = x; + } + if (extent.max.y < y) { + extent.max.y = y; + } + } + } + + const horizontalPadding = size[0] * 0.1; + const verticalPadding = size[1] * 0.1; + const polygon = polygonFromExtent([extent.min.x, extent.min.y, extent.max.x, extent.max.y]); + view.fit(polygon, { + padding: [verticalPadding, horizontalPadding, verticalPadding, horizontalPadding], + maxZoom: 18, + }); + } + + const newCenter = view.getCenter(); + const newZoom = view.getZoom(); + if (oldCenter != null) { + view.setCenter(oldCenter); + } + if (oldZoom != null) { + view.setZoom(oldZoom); + } + if (newCenter != null && newZoom != null) { + zoomToCenter(map, { center: newCenter, zoom: newZoom }); + } +}; + +const zoomToCenter = (map: OlMap, { center, zoom }: { center: Coordinate; zoom: number }): void => { + window.requestAnimationFrame(() => { + map.getView().animate({ center, zoom, duration: 600 }); + }); +}; diff --git a/libs/asset-viewer/src/lib/components/map/map.component.html b/libs/asset-viewer/src/lib/components/map/map.component.html index 721c6140..e5240668 100644 --- a/libs/asset-viewer/src/lib/components/map/map.component.html +++ b/libs/asset-viewer/src/lib/components/map/map.component.html @@ -1,4 +1,6 @@ + +
-
- +
+
diff --git a/libs/asset-viewer/src/lib/components/map/map.component.scss b/libs/asset-viewer/src/lib/components/map/map.component.scss index 188e4812..5daa5f82 100644 --- a/libs/asset-viewer/src/lib/components/map/map.component.scss +++ b/libs/asset-viewer/src/lib/components/map/map.component.scss @@ -7,29 +7,19 @@ .map { height: 100%; width: 100%; + transition: opacity ease-in 1.5s; } -mat-progress-bar { - position: absolute; - top: 1px; - left: 1px; - right: 1px; - width: calc(100% - 2px); +:host.is-loading .map { + opacity: 0; } -.map-loading-spinner { +asset-sg-map-controls { position: absolute; - inset: 0; - z-index: 100; - display: grid; - place-items: center; - background-color: variables.$grey-03; + top: 2rem; + right: 2.5rem; } -::ng-deep { - asset-sg-map-zoom-controls { - position: absolute; - top: 2rem; - right: 2.5rem; - } +:host.is-loading asset-sg-map-controls { + opacity: 0; } diff --git a/libs/asset-viewer/src/lib/components/map/map.component.ts b/libs/asset-viewer/src/lib/components/map/map.component.ts index 34528cb8..eaac39f4 100644 --- a/libs/asset-viewer/src/lib/components/map/map.component.ts +++ b/libs/asset-viewer/src/lib/components/map/map.component.ts @@ -1,514 +1,205 @@ -import { DOCUMENT } from '@angular/common'; -import { Component, ElementRef, EventEmitter, inject, Input, Output, ViewChild, ViewContainerRef } from '@angular/core'; import { - createFeaturesFromStudies, - decorateFeature, - featureStyles, - fitToSwitzerland, - isoWGSLat, - isoWGSLng, - LifecycleHooks, - LifecycleHooksDirective, - lv95ToWGS, - olCoordsFromLV95Array, - olZoomControls, - toLonLat, - WGStoLV95, - WindowService, - ZoomControlsComponent, - zoomToStudies, -} from '@asset-sg/client-shared'; -import { makePairs, OO, ORD } from '@asset-sg/core'; -import { AssetEditDetail, eqLV95Array, LV95 } from '@asset-sg/shared'; -import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { concatLatestFrom } from '@ngrx/effects'; + AfterViewInit, + Component, + ElementRef, + EventEmitter, + HostBinding, + inject, + Input, + OnChanges, + OnDestroy, + Output, + SimpleChanges, + ViewChild, +} from '@angular/core'; +import { AppState } from '@asset-sg/client-shared'; +import { ORD } from '@asset-sg/core'; import { Store } from '@ngrx/store'; -import { RxState } from '@rx-angular/state'; -import * as A from 'fp-ts/Array'; -import { Lazy, pipe } from 'fp-ts/function'; -import * as NEA from 'fp-ts/NonEmptyArray'; -import * as O from 'fp-ts/Option'; -import Feature from 'ol/Feature'; -import { Geometry, Point, Polygon } from 'ol/geom'; -import Draw, { DrawEvent } from 'ol/interaction/Draw'; -import { Heatmap, Tile as TileLayer, Vector as VectorLayer } from 'ol/layer'; -import Map from 'ol/Map'; -import MapBrowserEvent from 'ol/MapBrowserEvent'; -import { fromLonLat } from 'ol/proj'; -import { Cluster, Vector as VectorSource, XYZ } from 'ol/source'; -import { Icon } from 'ol/style'; -import CircleStyle from 'ol/style/Circle'; -import Style from 'ol/style/Style'; -import View from 'ol/View'; -import { - asyncScheduler, - delay, - distinctUntilChanged, - filter, - fromEventPattern, - identity, - map, - merge, - Observable, - share, - shareReplay, - subscribeOn, - switchMap, - take, - withLatestFrom, -} from 'rxjs'; - -import { AllStudyDTO } from '../../models'; +import { asapScheduler, first, identity, skip, Subscription, switchMap } from 'rxjs'; import { AllStudyService } from '../../services/all-study.service'; +import * as searchActions from '../../state/asset-search/asset-search.actions'; import { + selectAssetSearchPolygon, + selectAssetSearchResultData, selectCurrentAssetDetail, - selectStudies, - StudyVM, - wktToGeoJSON, } from '../../state/asset-search/asset-search.selector'; +import { DrawControl } from '../map-controls/draw-controls'; +import { ZoomControl } from '../map-controls/zoom-control'; +import { MapController } from './map-controller'; -interface MapState { - currentAssetDetail: AssetEditDetail | undefined; - studiesFromSearch: StudyVM[]; - isMapInitialised: boolean; - drawingMode: boolean; - polygon: O.Option; - highlightAssetStudies: O.Option; -} - -const initialMapState: MapState = { - currentAssetDetail: undefined, - studiesFromSearch: [], - isMapInitialised: false, - drawingMode: false, - polygon: O.none, - highlightAssetStudies: O.none, -}; - -@UntilDestroy() @Component({ selector: 'asset-sg-map', templateUrl: './map.component.html', - styleUrls: ['./map.component.scss'], - hostDirectives: [LifecycleHooksDirective], - providers: [RxState], + styleUrl: './map.component.scss', }) -export class MapComponent { - @ViewChild('map', { static: true }) mapDiv!: ElementRef; - @Output() public polygonChanged = new EventEmitter(); - - private _lc = inject(LifecycleHooks); - private _dcmnt = inject(DOCUMENT); - private _viewContainerRef = inject(ViewContainerRef); - private _allStudyService = inject(AllStudyService); - private _windowService = inject(WindowService); - private _store = inject(Store); - private state: RxState = inject(RxState); - - public _isMapInitialised$ = this.state.select('isMapInitialised'); +export class MapComponent implements AfterViewInit, OnChanges, OnDestroy { + /** + * The id of an asset that should be highlighted. + */ + @Input() + highlightedAssetId: number | null = null; + + /** + * Event emitted when one or more assets are clicked. + * The event consists of an array containing the IDs of all clicked assets. + * + * Note that each event corresponds to a single click, + * which might target multiple overlapping assets. + * + * Clicks that don't hit any assets don't emit an event. + */ + @Output() + readonly assetsClick = new EventEmitter(); + /** + * Event emitted when one or more assets are hovered. + * The event consists of an array containing the IDs of all hovered assets. + */ @Output() - public mapInitialized = this.state.$.pipe( - map((s) => s.isMapInitialised), - distinctUntilChanged(), - shareReplay({ bufferSize: 1, refCount: true }) - ); + readonly assetsHover = new EventEmitter(); + /** + * Event emitted once the map is fully initialized. + */ @Output() - public assetClicked = new EventEmitter(); + readonly initializeEnd = new EventEmitter(); - @Input() - public set searchPolygon$(value: Observable>) { - this.state.connect('polygon', value); - } + @ViewChild('map', { static: true }) + mapElement!: ElementRef; - @Input() set highlightAssetStudies(value: Observable>) { - this.state.connect('highlightAssetStudies', value); - } + @ViewChild('mapControls', { static: true }) + controlsElement!: ElementRef; - private readonly studiesFromSearch$ = this._store.select(selectStudies); - private readonly currentAssetDetail$ = this._store.select(selectCurrentAssetDetail); + private readonly store = inject(Store); - constructor() { - this.state.set(initialMapState); + private controller!: MapController; - this.state.connect('studiesFromSearch', this.studiesFromSearch$); - this.state.connect('currentAssetDetail', this.currentAssetDetail$); + controls!: { + zoom: ZoomControl; + draw: DrawControl; + }; - this._lc.afterViewInit$ - .pipe(take(1), subscribeOn(asyncScheduler), untilDestroyed(this)) - .subscribe(() => this._ngAfterViewInit()); - } + isInitialized = false; - _ngAfterViewInit(): void { - const raster = new TileLayer({ - source: new XYZ({ - url: `https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.pixelkarte-farbe/default/current/3857/{z}/{x}/{y}.jpeg`, - }), - }); + private readonly allStudyService = inject(AllStudyService); - const origin = { - center: [900000, 5900000], - zoom: 9, - }; - const view = new View({ - projection: 'EPSG:3857', - ...origin, - minZoom: 7, - }); + private readonly subscription = new Subscription(); - setTimeout(() => { - fitToSwitzerland(view, false); - const zoom = view.getZoom(); - if (zoom != null) { - view.setMinZoom(zoom); - } + constructor() { + this.initializeEnd.subscribe(() => { + this.isInitialized = true; }); + } - const vectorSourceAllStudies = new VectorSource({ wrapX: false }); - const vectorLayerAllStudies = new VectorLayer({ source: vectorSourceAllStudies, minZoom: 11 }); - - const heatmapClusterSource = new VectorSource({ wrapX: false }); - const source = new Cluster({ - distance: 2, - source: heatmapClusterSource, - }) as unknown as VectorSource; - const heatmapLayer = new Heatmap({ - source, - weight: (feature) => feature.get('features').length, - maxZoom: 12, - blur: 20, - radius: 5, - opacity: 0.7, + ngAfterViewInit(): void { + asapScheduler.schedule(() => { + this.initializeMap(); }); + } - const vectorSourcePolygonSelection = new VectorSource({ wrapX: false }); - const vectorLayerPolygonSelection = new VectorLayer({ source: vectorSourcePolygonSelection }); - - const vectorSourceGeoms = new VectorSource({ wrapX: false }); - const vectorLayerGeoms = new VectorLayer({ source: vectorSourceGeoms }); - - const vectorSourceAssetGeoms = new VectorSource({ wrapX: false }); - const vectorLayerAssetGeoms = new VectorLayer({ source: vectorSourceAssetGeoms }); - - const vectorSourcePickerMouseOver = new VectorSource({ wrapX: false }); - const vectorLayerPickerMouseOver = new VectorLayer({ source: vectorSourcePickerMouseOver }); - - const zoomControlsInstance = this.createZoomControlsComponent().instance; - const olMap = new Map({ - target: this.mapDiv.nativeElement, - controls: olZoomControls(this._dcmnt, zoomControlsInstance), - layers: [ - raster, - heatmapLayer, - vectorLayerAllStudies, - vectorLayerGeoms, - vectorLayerAssetGeoms, - vectorLayerPolygonSelection, - vectorLayerPickerMouseOver, - ], - view: view, - }); + ngOnChanges(changes: SimpleChanges): void { + if (this.controller == null) { + return; + } + if ('highlightedAssetId' in changes) { + this.handleHighlightedAssetIdChange(); + } + } - fromEventPattern( - (h) => olMap.getView().on('change:resolution', h), - (h) => olMap.getView().un('change:resolution', h) - ) - .pipe( - map(() => olMap.getView().getZoom()), - distinctUntilChanged(), - untilDestroyed(this) - ) - .subscribe((zoom) => { - if (zoom == null) return; - vectorSourceAllStudies.forEachFeature((f) => { - const style = f.getStyle(); - if (style instanceof Style) { - const image = style.getImage(); - if (image instanceof Icon) { - image.setScale(zoom < 12 ? 1 : zoom / 7.5); - } - if (image instanceof CircleStyle) { - image.setRadius(zoom < 12 ? 4 : 4 * (zoom / 7.5)); - } - } - }); - }); + ngOnDestroy(): void { + this.subscription.unsubscribe(); + this.controller.dispose(); + } - type AllStudyDTOWithGeometry = AllStudyDTO & { geometry: Point }; - const addPointGeometry = (study: AllStudyDTO): AllStudyDTOWithGeometry => { - const lonLat = lv95ToWGS(study.centroid); - return { - ...study, - geometry: new Point(fromLonLat([isoWGSLng.unwrap(lonLat.lng), isoWGSLat.unwrap(lonLat.lat)])), - }; - }; + private initializeMap(): void { + this.controller = new MapController(this.mapElement.nativeElement); - const mapInitialised$ = fromEventPattern( - (h) => olMap.once('loadend', h), - (h) => olMap.un('loadend', h) - ).pipe( - switchMap(() => this._allStudyService.getAllStudies()), - ORD.fromFilteredSuccess, - map(A.map(addPointGeometry)), - withLatestFrom(this.state.select('studiesFromSearch')), - switchMap(([allStudies, studiesFromSearch]) => { - const makeHeatmapFeatures = () => allStudies.map((s) => new Feature({ geometry: s.geometry })); - clearVectorSourceThenAddFeatures(heatmapClusterSource, makeHeatmapFeatures); - return this._windowService.delayRequestAnimationFrame(300, () => { - const makeVectorFeatures = () => - allStudies.map((s) => - decorateFeature(new Feature({ geometry: s.geometry }), { - id: s.studyId, - style: s.isPoint ? featureStyles.pointStyle : featureStyles.rhombusStyle, - }) - ); - clearVectorSourceThenAddFeatures(vectorSourceAllStudies, makeVectorFeatures); - if (studiesFromSearch.length > 0) { - studiesFromSearch.forEach((s) => { - const f = vectorSourceAllStudies.getFeatureById(s.studyId); - f?.set('previousStyle', f?.getStyle()); - f?.setStyle(featureStyles.undefinedStyle); - }); - } - }); + this.controls = { + zoom: new ZoomControl({ + element: this.controlsElement.nativeElement, }), - take(1) - ); - this.state.connect(mapInitialised$, () => ({ isMapInitialised: true })); - - this.state.connect('polygon', this.polygonChanged.pipe(map(O.some))); + draw: new DrawControl({ + element: this.controlsElement.nativeElement, + polygonSource: this.controller.sources.polygon, + }), + }; - const draw = new Draw({ source: vectorSourcePolygonSelection, type: 'Polygon' }); + this.controller.addControl(this.controls.zoom); + this.controller.addControl(this.controls.draw); - this.state - .select(['currentAssetDetail', 'studiesFromSearch'], identity) - .pipe(untilDestroyed(this)) - .subscribe(({ currentAssetDetail, studiesFromSearch }) => { - if (!currentAssetDetail) { - vectorSourceAssetGeoms.clear(); - vectorLayerGeoms.setOpacity(1); - vectorLayerAllStudies.setOpacity(1); - if (studiesFromSearch.length > 0) { - zoomToStudies(this._windowService, olMap, studiesFromSearch, 1); - } - } else { - vectorSourceAssetGeoms.clear(); - vectorLayerGeoms.setOpacity(0.5); - vectorLayerAllStudies.setOpacity(0.5); - const studiesWithGeometry = currentAssetDetail.studies.map((study) => { - return { - ...study, - geom: wktToGeoJSON(study.geomText), - }; - }); - zoomToStudies(this._windowService, olMap, studiesWithGeometry, 1); - const studiesWithFeature = createFeaturesFromStudies(studiesWithGeometry, { - point: featureStyles.bigPointStyleAsset, - polygon: featureStyles.polygonStyleAsset, - lineString: featureStyles.lineStringStyleAsset, - }); - vectorSourceAssetGeoms.addFeatures(studiesWithFeature.map((s) => s.olGeometry)); - } - }); + this.subscription.add(this.controller.assetsClick$.subscribe(this.assetsClick)); + this.subscription.add(this.controller.assetsHover$.subscribe(this.assetsHover)); - zoomControlsInstance._zoomOriginClicked$.pipe(untilDestroyed(this)).subscribe(() => { - fitToSwitzerland(view, true); + // Some bindings can be initialized only after the map has fully loaded, + // since they modify the map's zoom level. + this.initializeEnd.pipe(first()).subscribe(() => { + this.initializePolygonBindings(); + this.initializeStoreBindings(); + this.handleHighlightedAssetIdChange(); }); - // For some reason, the 'currentAssetDetail' property in the state does not get updated when the value is set to undefiend, thus, i have a separate subscription listening directly to the selector from the store - // This should be removed in order not to have to much code dublication - this.currentAssetDetail$ - .pipe( - untilDestroyed(this), - concatLatestFrom(() => this.state.select('studiesFromSearch')) - ) - .subscribe(([currentAssetDetail, studiesFromSearch]) => { - if (currentAssetDetail === undefined) { - this.state.set('currentAssetDetail', (oldState) => (oldState.currentAssetDetail = undefined)); - vectorSourceAssetGeoms.clear(); - vectorLayerGeoms.setOpacity(1); - vectorLayerAllStudies.setOpacity(1); - zoomToStudies(this._windowService, olMap, studiesFromSearch, 1); - } - }); - - this.state - .select('studiesFromSearch') - .pipe(untilDestroyed(this)) - .subscribe((studiesFromSearch) => { - if (studiesFromSearch.length === 0) { - vectorSourcePolygonSelection.clear(); - } - if (studiesFromSearch.length > 0) { - vectorSourceGeoms.clear(); - zoomToStudies(this._windowService, olMap, studiesFromSearch, 1); - const studiesWithFeature = createFeaturesFromStudies(studiesFromSearch, { - point: featureStyles.bigPointStyle, - polygon: featureStyles.polygonStyle, - lineString: featureStyles.lineStringStyle, - }); - studiesWithFeature - .map((s) => vectorSourceAllStudies.getFeatureById(s.studyId)) - .forEach((f) => { - f?.set('previousStyle', f?.getStyle()); - f?.setStyle(featureStyles.undefinedStyle); - }); - vectorSourceGeoms.addFeatures(studiesWithFeature.map((s) => s.olGeometry)); - } else { - vectorSourceAllStudies.forEachFeature((f) => { - if (f.get('previousStyle')) { - f.setStyle(f.get('previousStyle')); - f.set('previousStyle', undefined); - } - }); - vectorSourceGeoms.forEachFeature((f) => vectorSourceGeoms.removeFeature(f)); - } - }); - - const polygon$ = this.state.select('polygon'); - - merge( - fromEventPattern( - (h) => draw.on('drawstart', h), - (h) => draw.un('drawstart', h) - ), - polygon$.pipe(filter(O.isNone)) - ) - .pipe(untilDestroyed(this)) - .subscribe(() => { - vectorSourcePolygonSelection.clear(); - }); - - const drawEnd$ = fromEventPattern( - (h) => draw.on('drawend', h), - (h) => draw.un('drawend', h) - ).pipe(share()); - - const featureToLV95Polygon = (f: Feature) => - pipe( - f.getGeometry(), - O.fromNullable, - O.chain((geometry) => (geometry.getType() === 'Polygon' ? O.some(geometry as Polygon) : O.none)), - O.map((g) => pipe(g.getFlatCoordinates(), makePairs, A.map(toLonLat), A.map(WGStoLV95))) - ); - - polygon$.pipe(OO.fromFilteredSome, untilDestroyed(this)).subscribe((polygon) => { - const polygonFromMap = pipe( - vectorSourcePolygonSelection.getFeatures(), - NEA.fromArray, - O.map(NEA.head), - O.chain(featureToLV95Polygon) - ); - - if (!O.getEq(eqLV95Array).equals(O.some(polygon), polygonFromMap)) { - vectorSourcePolygonSelection.clear(); - vectorSourcePolygonSelection.addFeature( - new Feature({ - geometry: new Polygon([olCoordsFromLV95Array(polygon)]), - }) - ); - } - }); + const studies$ = this.allStudyService.getAllStudies().pipe(ORD.fromFilteredSuccess); + this.subscription.add( + studies$.subscribe((studies) => { + this.controller.setStudies(studies); + }) + ); - drawEnd$ + this.controller.isInitialized$ .pipe( - map((e) => - pipe((e.feature.getGeometry() as Polygon).getFlatCoordinates(), makePairs, A.map(toLonLat), A.map(WGStoLV95)) - ), - untilDestroyed(this) + first(identity), + switchMap(() => studies$) ) - .subscribe((polygon) => { - zoomControlsInstance.setDrawingMode(false); - setTimeout(() => { - this.polygonChanged.emit(polygon); - }); - }); + .subscribe(() => this.initializeEnd.emit()); + } - this.state - .select('drawingMode') - .pipe(untilDestroyed(this)) - .subscribe((drawMode) => { - if (drawMode) { - olMap.addInteraction(draw); + private initializeStoreBindings() { + this.subscription.add( + this.store.select(selectAssetSearchResultData).subscribe((assets) => { + if (assets.length === 0) { + this.controller.clearAssets(); } else { - olMap.removeInteraction(draw); + this.controller.setAssets(assets); } - }); - - fromEventPattern>( - (h) => olMap.on('click', h), - (h) => olMap.un('click', h) - ) - .pipe( - withLatestFrom(this.state.select('drawingMode')), - filter(([, drawingMode]) => !drawingMode), - map(([event]) => event), - withLatestFrom(this.state.select('studiesFromSearch')), - map(([event, studies]) => { - const clickedFeatureIds: string[] = []; - olMap.forEachFeatureAtPixel( - event.pixel, - (feature) => { - const id = feature.getId(); - id && clickedFeatureIds.push(String(id)); - }, - { - layerFilter: (layer) => layer === vectorLayerGeoms, - } - ); - return pipe( - studies, - A.filter((s) => clickedFeatureIds.includes(s.studyId)), - A.map((s) => s.assetId) - ); - }), - untilDestroyed(this) - ) - .subscribe(this.assetClicked); + }) + ); - this.state - .select(['highlightAssetStudies', 'studiesFromSearch'], ({ studiesFromSearch, highlightAssetStudies }) => - pipe( - highlightAssetStudies, - O.map((assetId) => - pipe( - studiesFromSearch, - A.filter((s) => s.assetId === assetId) - ) - ) - ) - ) - .pipe(untilDestroyed(this)) - .subscribe((studies) => { - if (O.isSome(studies)) { - const studiesWithFeature = createFeaturesFromStudies(studies.value, { - point: featureStyles.bigPointStyleAssetSelected, - polygon: featureStyles.polygonStyleAssetSelected, - lineString: featureStyles.lineStringStyleAssetSelected, - }); - vectorSourcePickerMouseOver.addFeatures(studiesWithFeature.map((s) => s.olGeometry)); + this.subscription.add( + this.store.select(selectCurrentAssetDetail).subscribe((asset) => { + if (asset == null) { + this.controller.clearActiveAsset(); } else { - vectorSourcePickerMouseOver.clear(); + this.controller.setActiveAsset(asset); } - }); + }) + ); } - createZoomControlsComponent() { - const zoomControlsComponent = this._viewContainerRef.createComponent(ZoomControlsComponent); - this.state.connect('drawingMode', zoomControlsComponent.instance.drawingMode$.pipe(delay(0))); - return zoomControlsComponent; + private initializePolygonBindings(): void { + this.subscription.add( + this.store.select(selectAssetSearchPolygon).subscribe((polygon) => { + this.controls.draw.setPolygon(polygon ?? null); + }) + ); + this.controls.draw.polygon$.pipe(skip(1)).subscribe((polygon) => { + this.store.dispatch( + searchActions.searchByFilterConfiguration({ + filterConfiguration: { polygon: polygon ?? undefined }, + }) + ); + }); } -} -const clearVectorSourceThenAddFeatures = (vectorSource: VectorSource, f: Lazy) => { - const addFeatures = () => vectorSource.addFeatures(f()); - if (vectorSource.getFeatures().length === 0) { - addFeatures(); - } else { - vectorSource.once('clear', () => { - addFeatures(); - }); - vectorSource.clear(false); + private handleHighlightedAssetIdChange() { + if (this.highlightedAssetId == null) { + this.controller.clearHighlightedAsset(); + } else { + this.controller.setHighlightedAsset(this.highlightedAssetId); + } + } + + @HostBinding('class.is-loading') + get isLoading(): boolean { + return !this.isInitialized; } -}; +} diff --git a/libs/client-shared/src/lib/utils/map.ts b/libs/client-shared/src/lib/utils/map.ts index 4e18fd3f..122d3e3a 100644 --- a/libs/client-shared/src/lib/utils/map.ts +++ b/libs/client-shared/src/lib/utils/map.ts @@ -12,7 +12,7 @@ import { LineString, Point, Polygon, SimpleGeometry } from 'ol/geom'; import { fromExtent as polygonFromExtent } from 'ol/geom/Polygon'; import Map from 'ol/Map'; import { fromLonLat } from 'ol/proj'; -import { Circle, Fill, Icon, Stroke, Style } from 'ol/style'; +import { Circle, Fill, Icon, RegularShape, Stroke, Style } from 'ol/style'; import View from 'ol/View'; import { isoWGSLat, isoWGSLng } from '../models'; @@ -67,48 +67,57 @@ export const decorateFeature = ( return feature; }; -export const olCoordsFromLV95Array = (coords: LV95[]): Coordinate[] => - coords.map(lv95ToWGS).map((l) => fromLonLat([isoWGSLng.unwrap(l.lng), isoWGSLat.unwrap(l.lat)])); +export const olCoordsFromLV95Array = (coords: LV95[]): Coordinate[] => coords.map(olCoordsFromLV95); + +export const olCoordsFromLV95 = (lv95Coords: LV95): Coordinate => { + const wgsCoords = lv95ToWGS(lv95Coords); + return fromLonLat([isoWGSLng.unwrap(wgsCoords.lng), isoWGSLat.unwrap(wgsCoords.lat)]); +}; + +export const makeRhombusImage = (radius: number) => + new RegularShape({ + points: 4, + radius, + angle: 0, + fill: new Fill({ color: '#194ed0' }), + stroke: new Stroke({ color: 'black' }), + }); export const featureStyles = { - undefinedStyle: new Style(undefined), + hidden: new Style(undefined), - pointStyle: new Style({ + point: new Style({ image: new Circle({ radius: 4, fill: new Fill({ color: '#194ed0' }), - stroke: new Stroke({ color: '#194ed0' }), + stroke: new Stroke({ color: 'black' }), }), }), - - rhombusStyle: new Style({ - image: new Icon({ - src: `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 12' height='9'%3E%3Cpath d='M8 0 0 6l8 6 8-6Z' fill='%23194ed0'/%3E%3C/svg%3E%0A`, - }), + rhombus: new Style({ + image: makeRhombusImage(5), }), - bigPointStyle: new Style({ + bigPoint: new Style({ image: new Circle({ radius: 20, stroke: new Stroke({ color: 'red', width: 2.5 }), fill: new Fill({ color: 'transparent' }), }), }), - bigPointStyleAsset: new Style({ + bigPointAsset: new Style({ image: new Circle({ radius: 20, stroke: new Stroke({ color: 'red', width: 6 }), fill: new Fill({ color: '#ffffff88' }), }), }), - bigPointStyleAssetSelected: new Style({ + bigPointAssetHighlighted: new Style({ image: new Circle({ radius: 20, stroke: new Stroke({ color: '#0b7285', width: 6 }), fill: new Fill({ color: '#eafc5288' }), }), }), - - bigPointStyleAssetNotSelected: new Style({ + bigPointAssetNotSelected: new Style({ image: new Circle({ radius: 20, stroke: new Stroke({ color: '#ff0000', width: 6, lineDash: [10, 5] }), @@ -116,39 +125,39 @@ export const featureStyles = { }), }), - polygonStyle: new Style({ + polygon: new Style({ stroke: new Stroke({ color: 'red', width: 2.5 }), fill: new Fill({ color: 'transparent' }), }), - polygonStyleAsset: new Style({ - stroke: new Stroke({ color: 'red', width: 6 }), + polygonAsset: new Style({ + stroke: new Stroke({ color: 'red', width: 3 }), fill: new Fill({ color: '#ffffff88' }), }), linePreview: new Style({ - stroke: new Stroke({ color: '#0b7285', width: 6, lineDash: [10, 10] }), + stroke: new Stroke({ color: '#0b7285', width: 3, lineDash: [10, 10] }), }), - polygonStyleAssetSelected: new Style({ - stroke: new Stroke({ color: '#0b7285', width: 6 }), + polygonAssetHighlighted: new Style({ + stroke: new Stroke({ color: '#0b7285', width: 4 }), fill: new Fill({ color: '#eafc5288' }), }), - polygonStyleAssetNotSelected: new Style({ - stroke: new Stroke({ color: '#ff0000', width: 6, lineDash: [10, 10] }), + polygonAssetNotSelected: new Style({ + stroke: new Stroke({ color: '#ff0000', width: 3, lineDash: [10, 10] }), fill: new Fill({ color: '#ffffff88' }), }), - lineStringStyle: new Style({ - stroke: new Stroke({ color: 'red', width: 6 }), + lineString: new Style({ + stroke: new Stroke({ color: 'red', width: 3 }), fill: new Fill({ color: 'transparent' }), }), - - lineStringStyleAsset: new Style({ - stroke: new Stroke({ color: 'red', width: 6 }), + lineStringAsset: new Style({ + stroke: new Stroke({ color: 'red', width: 3 }), }), - lineStringStyleAssetSelected: new Style({ - stroke: new Stroke({ color: '#0b7285', width: 6 }), + lineStringAssetHighlighted: new Style({ + stroke: new Stroke({ color: '#0b7285', width: 4 }), + fill: new Fill({ color: '#eafc5288' }), }), - lineStringStyleAssetNotSelected: new Style({ - stroke: new Stroke({ color: '#ff0000', width: 6, lineDash: [10, 10] }), + lineStringAssetNotSelected: new Style({ + stroke: new Stroke({ color: '#ff0000', width: 3, lineDash: [10, 10] }), }), }; @@ -214,7 +223,7 @@ export const zoomToStudies = ( const newZoom = view.getZoom(); oldCenter && view.setCenter(oldCenter); oldZoom && view.setZoom(oldZoom); - windowService.delayRequestAnimationFrame(1, () => { + window.requestAnimationFrame(() => { view.animate({ zoom: newZoom, center: newCenter, duration: 600 }); }); } diff --git a/libs/shared/src/lib/models/study.ts b/libs/shared/src/lib/models/study.ts index 44c36baf..34e7840f 100644 --- a/libs/shared/src/lib/models/study.ts +++ b/libs/shared/src/lib/models/study.ts @@ -149,3 +149,6 @@ export const eqStudyByStudyId = contramap((s: Study) => s.studyId)(eqString); export const Studies = D.array(Study); export type Studies = D.TypeOf; export const eqStudies: Eq = A.getEq(eqStudy); + +export const getCoordsFromStudy = (study: Study): LV95[] => + study.geom._tag === 'Point' ? [study.geom.coord] : study.geom.coords;