From e12658d44a3b211c8a452790ae3d137a7e229661 Mon Sep 17 00:00:00 2001 From: Daniel von Atzigen Date: Wed, 3 Jul 2024 14:45:55 +0200 Subject: [PATCH] Allow selection of points via click --- .../src/features/asset-old/asset.service.ts | 19 +- .../asset-viewer-page.component.html | 1 - .../src/lib/components/map/map-controller.ts | 162 ++++++++++++++---- .../src/lib/models/all-study-dto.ts | 2 + libs/client-shared/src/lib/utils/map.ts | 10 +- libs/shared/src/lib/models/all-study.ts | 1 + 6 files changed, 156 insertions(+), 39 deletions(-) diff --git a/apps/server-asset-sg/src/features/asset-old/asset.service.ts b/apps/server-asset-sg/src/features/asset-old/asset.service.ts index fb19ccfc..8d2b7d53 100644 --- a/apps/server-asset-sg/src/features/asset-old/asset.service.ts +++ b/apps/server-asset-sg/src/features/asset-old/asset.service.ts @@ -44,13 +44,24 @@ export class AssetService { } async getAllStudies() { - const rawData: { studyId: string; isPoint: boolean; centroidGeomText: string }[] = - await this.prismaService.$queryRawUnsafe( - 'select study_id as "studyId", is_point as "isPoint", centroid_geom_text as "centroidGeomText" from public.all_study' - ); + interface RawStudy { + studyId: string; + assetId: number; + isPoint: boolean; + centroidGeomText: string; + } + const rawData: RawStudy[] = await this.prismaService.$queryRawUnsafe(` + SELECT + study_id AS "studyId", + asset_id AS "assetId", + is_point AS "isPoint", + centroid_geom_text AS "centroidGeomText" + FROM public.all_study + `); return rawData.map((study) => ({ studyId: study.studyId, + assetId: study.assetId, isPoint: study.isPoint, centroid: study.centroidGeomText.replace('POINT(', '').replace(')', ''), })); 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 9ba628fa..486e1c63 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,7 +1,6 @@ diff --git a/libs/asset-viewer/src/lib/components/map/map-controller.ts b/libs/asset-viewer/src/lib/components/map/map-controller.ts index 11dc9f76..a747f06d 100644 --- a/libs/asset-viewer/src/lib/components/map/map-controller.ts +++ b/libs/asset-viewer/src/lib/components/map/map-controller.ts @@ -3,6 +3,7 @@ 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 { containsExtent } from 'ol/extent'; import Feature from 'ol/Feature'; import { Geometry, LineString, Point, Polygon } from 'ol/geom'; import { fromExtent as polygonFromExtent } from 'ol/geom/Polygon'; @@ -21,15 +22,30 @@ export class MapController { private readonly map: OlMap; readonly layers: MapLayers; - readonly sources: MapLayerSources; + readonly assetsClick$: Observable; + readonly assetsHover$: Observable; + + /** + * The id of all visible assets, mapped to their {@link AssetEditDetail} object. + * @private + */ private readonly assetsById = new Map(); - private readonly assetsByStudyId = new Map(); + + /** + * The IDs of all available studies, mapped to the id of the asset that they belong to. + * @private + */ + private readonly assetIdsByStudyId = new Map(); + + /** + * The currently selected asset. + * @private + */ + private activeAsset: AssetEditDetail | null = null; private readonly _isInitialized$ = new BehaviorSubject(false); - readonly assetsClick$: Observable; - readonly assetsHover$: Observable; constructor(element: HTMLElement) { const view = new View({ @@ -44,6 +60,7 @@ export class MapController { this.map = new OlMap({ target: element, + controls: [], layers: [ this.layers.raster, this.layers.heatmap, @@ -87,12 +104,14 @@ export class MapController { } setStudies(studies: AllStudyDTO[]): void { + this.assetIdsByStudyId.clear(); 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)); + this.assetIdsByStudyId.set(study.studyId, study.assetId); const heatmapFeature = new Feature(geometry); heatmapFeature.setId(study.studyId); @@ -116,7 +135,6 @@ export class MapController { setAssets(assets: AssetEditDetail[]): void { this.assetsById.clear(); - this.assetsByStudyId.clear(); const features: Feature[] = []; const studies: Study[] = []; @@ -124,7 +142,7 @@ export class MapController { 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) }; + const study: Study = { studyId: assetStudy.studyId, geom: wktToGeoJSON(assetStudy.geomText) }; features.push( makeStudyFeature(study, { point: featureStyles.bigPoint, @@ -132,36 +150,29 @@ export class MapController { lineString: featureStyles.lineString, }) ); - const studyFeature = this.sources.studies.getFeatureById(study.studyId); if (studyFeature != null) { - studyFeature.set('previousStyle', studyFeature.getStyle()); - studyFeature.setStyle(featureStyles.hidden); + this.hideFeature(studyFeature); } - - this.assetsByStudyId.set(study.studyId, asset); + studies.push(study); } } window.requestAnimationFrame(() => { this.sources.assets.clear(); this.sources.assets.addFeatures(features); + this.sources.picker.clear(); zoomToStudies(this.map, studies); }); } clearAssets(): void { this.assetsById.clear(); - this.assetsByStudyId.clear(); window.requestAnimationFrame(() => { this.sources.assets.clear(); this.sources.polygon.clear(); + this.sources.picker.clear(); this.sources.studies.forEachFeature((feature) => { - const previousStyle = feature.get('previousStyle'); - if (previousStyle == null) { - return; - } - feature.setStyle(previousStyle); - feature.unset('previousStyle'); + this.unhideFeature(feature); }); }); } @@ -180,6 +191,7 @@ export class MapController { lineString: featureStyles.lineStringAssetHighlighted, }); }); + this.sources.picker.clear(); this.sources.picker.addFeatures(features); } @@ -189,6 +201,9 @@ export class MapController { } setActiveAsset(asset: AssetEditDetail): void { + this.resetActiveAssetStyle(); + this.activeAsset = asset; + this.sources.activeAsset.clear(); this.layers.assets.setOpacity(0.5); this.layers.studies.setOpacity(0.5); @@ -208,6 +223,11 @@ export class MapController { lineString: featureStyles.lineStringAsset, }); features.push(feature); + + const studyFeature = this.sources.studies.getFeatureById(study.studyId); + if (studyFeature != null) { + this.hideFeature(studyFeature); + } } window.requestAnimationFrame(() => { @@ -217,6 +237,8 @@ export class MapController { } clearActiveAsset(): void { + this.resetActiveAssetStyle(); + this.activeAsset = null; this.sources.activeAsset.clear(); this.layers.assets.setOpacity(1); this.layers.studies.setOpacity(1); @@ -267,14 +289,52 @@ export class MapController { }) as MapLayer; } + /** + * Creates an observable that emits the ids of assets whose geometries have been clicked. + * + * - If a study point has been clicked, that study's assetId is emitted as the only clicked element. + * - Otherwise, the assetIds of all overlapping studies hit by the click are emitted. + * + * @private + */ 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[] = []; + // Check if the click has hit a study point, and use only that point if so. + switchMap(async (event) => { + let assetId: number | null = null; + this.map.forEachFeatureAtPixel( + event.pixel, + (feature): void => { + if (assetId != null) { + return; + } + const featureId = feature.getId(); + if (featureId == null) { + return; + } + const currentAssetId = this.assetIdsByStudyId.get(`${featureId}`); + if (currentAssetId != null) { + assetId = currentAssetId; + } + }, + { + layerFilter: (layer) => layer === this.layers.studies, + } + ); + return [event, assetId] as const; + }), + + map(([event, assetIdFromStudy]) => { + // Use the study point's asset if one has been clicked. + if (assetIdFromStudy != null) { + return [assetIdFromStudy]; + } + + // Otherwise, extract the assetIds of all overlapping study geometries that have been clicked. + const assetIds = new Set(); this.map.forEachFeatureAtPixel( event.pixel, (feature): void => { @@ -282,16 +342,16 @@ export class MapController { if (featureId == null) { return; } - const asset = this.assetsByStudyId.get(`${featureId}`); - if (asset != null) { - assetIds.push(asset.assetId); + const assetId = this.assetIdsByStudyId.get(`${featureId}`); + if (assetId != null) { + assetIds.add(assetId); } }, { layerFilter: (layer) => layer === this.layers.assets, } ); - return assetIds; + return [...assetIds]; }) ); } @@ -305,21 +365,57 @@ export class MapController { // Extract the ids of the assets that have been hovered. map((features) => { + const viewExtent = this.map.getView().calculateExtent(this.map.getSize()); 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); + + // Ignore geometries that fill up the entire view. + const geometry = feature.getGeometry(); + if (geometry != null && containsExtent(geometry.getExtent(), viewExtent)) { + continue; + } + + const assetId = this.assetIdsByStudyId.get(`${featureId}`); + if (assetId != null) { + assetIds.push(assetId); } } return assetIds; }) ); } + + private resetActiveAssetStyle(): void { + // If we have no active asset, or if the active asset is also contained in the currently visible assets, + // then we either can't or don't need to show the asset's study points. + if (this.activeAsset == null || this.assetsById.has(this.activeAsset.assetId)) { + return; + } + for (const study of this.activeAsset.studies) { + const feature = this.sources.studies.getFeatureById(study.studyId); + if (feature != null) { + this.unhideFeature(feature); + } + } + } + + private hideFeature(feature: Feature): void { + feature.set('previousStyle', feature.getStyle()); + feature.setStyle(featureStyles.hidden); + } + + private unhideFeature(feature: Feature): void { + const previousStyle = feature.get('previousStyle'); + if (previousStyle == null) { + return; + } + feature.setStyle(previousStyle); + feature.unset('previousStyle'); + } } interface MapLayers { @@ -398,12 +494,17 @@ const makeStudyFeature = (study: Study, styles: { point: Style; polygon: Style; 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]; + case 'Polygon': { + const polygon = new Polygon([study.geom.coords.map(olCoordsFromLV95)]); + const style = styles.polygon.clone(); + style.setZIndex((style.getZIndex() ?? 0) + 1 / polygon.getArea()); + return [polygon, style]; + } } })(); const feature = new Feature({ geometry }); + feature.setId(study.studyId); feature.setStyle(style); feature.setProperties({ 'swisstopo.type': 'StudyGeometry' }); @@ -419,6 +520,7 @@ const zoomToStudies = (map: OlMap, studies: Study[]): void => { const size = map.getSize(); const oldCenter = view.getCenter(); const oldZoom = view.getZoom(); + if (size == null) { return; } diff --git a/libs/asset-viewer/src/lib/models/all-study-dto.ts b/libs/asset-viewer/src/lib/models/all-study-dto.ts index ab9f72e5..8641309b 100644 --- a/libs/asset-viewer/src/lib/models/all-study-dto.ts +++ b/libs/asset-viewer/src/lib/models/all-study-dto.ts @@ -5,12 +5,14 @@ import { Equals, assert } from 'tsafe'; export const AllStudyDTO = D.struct({ studyId: D.string, + assetId: D.number, isPoint: D.boolean, centroid: LV95, }); export const eqAllStudyDTO = TEq.struct({ studyId: TEq.string, + assetId: TEq.number, isPoint: TEq.boolean, centroid: eqLV95, }); diff --git a/libs/client-shared/src/lib/utils/map.ts b/libs/client-shared/src/lib/utils/map.ts index 122d3e3a..c4287a80 100644 --- a/libs/client-shared/src/lib/utils/map.ts +++ b/libs/client-shared/src/lib/utils/map.ts @@ -80,17 +80,16 @@ export const makeRhombusImage = (radius: number) => radius, angle: 0, fill: new Fill({ color: '#194ed0' }), - stroke: new Stroke({ color: 'black' }), + stroke: new Stroke({ color: '#194ed0' }), }); export const featureStyles = { hidden: new Style(undefined), - point: new Style({ image: new Circle({ - radius: 4, + radius: 10, fill: new Fill({ color: '#194ed0' }), - stroke: new Stroke({ color: 'black' }), + stroke: new Stroke({ color: '#194ed0' }), }), }), rhombus: new Style({ @@ -102,6 +101,7 @@ export const featureStyles = { stroke: new Stroke({ color: 'red', width: 2.5 }), fill: new Fill({ color: 'transparent' }), }), + zIndex: 3, }), bigPointAsset: new Style({ image: new Circle({ @@ -128,6 +128,7 @@ export const featureStyles = { polygon: new Style({ stroke: new Stroke({ color: 'red', width: 2.5 }), fill: new Fill({ color: 'transparent' }), + zIndex: 1, }), polygonAsset: new Style({ stroke: new Stroke({ color: 'red', width: 3 }), @@ -148,6 +149,7 @@ export const featureStyles = { lineString: new Style({ stroke: new Stroke({ color: 'red', width: 3 }), fill: new Fill({ color: 'transparent' }), + zIndex: 2, }), lineStringAsset: new Style({ stroke: new Stroke({ color: 'red', width: 3 }), diff --git a/libs/shared/src/lib/models/all-study.ts b/libs/shared/src/lib/models/all-study.ts index 4c61a145..d185fe43 100644 --- a/libs/shared/src/lib/models/all-study.ts +++ b/libs/shared/src/lib/models/all-study.ts @@ -4,6 +4,7 @@ import { LV95FromSpaceSeparatedString } from './lv95'; export const AllStudyDTOFromAPI = D.struct({ studyId: D.string, + assetId: D.number, isPoint: D.boolean, centroid: LV95FromSpaceSeparatedString, });