From 3f6b6edd9b5dd60b8dc6adf16311a2d10ae3e9a3 Mon Sep 17 00:00:00 2001 From: Jim O'Donnell Date: Mon, 18 Nov 2024 14:10:23 +0000 Subject: [PATCH 1/2] feat: protected feature API - a new API, `/features/{protector-feature-id}/protected-by`, which returns list of adaptation options, by feature and layer, for all features protected by `protector-feature-id`. - a new recoil selector, which runs the query with the current selected feature ID. - new recoil selectors that filter the results by RCP and protection level. --- backend/backend/app/routers/features.py | 49 +++++++++++++++++ backend/backend/app/schemas.py | 17 ++++++ backend/backend/db/models.py | 5 ++ .../api-client/services/FeaturesService.ts | 22 ++++++++ frontend/src/lib/data-map/DataMap.tsx | 19 +++++++ .../state/interactions/interaction-state.ts | 55 ++++++++++++++++++- 6 files changed, 166 insertions(+), 1 deletion(-) diff --git a/backend/backend/app/routers/features.py b/backend/backend/app/routers/features.py index 0f1835de..b8b6cede 100644 --- a/backend/backend/app/routers/features.py +++ b/backend/backend/app/routers/features.py @@ -78,3 +78,52 @@ def read_sorted_features( ).order_by(desc("value")) return paginate(session, q, page_params) + + +@router.get( + "/{protector_id}/protected-by", + response_model=list[schemas.ProtectedFeatureListItem], +) +def read_protected_features( + protector_id: int, + session: SessionDep, +): + """ + Get all adaptation options, by feature ID and layer, for features + protected by a given protector feature. + """ + + adaptation_options = select( + models.Feature.id.label("id"), + models.Feature.string_id.label("string_id"), + models.Feature.layer.label("layer"), + models.AdaptationCostBenefit.adaptation_cost.label( + "adaptation_cost" + ), + models.AdaptationCostBenefit.adaptation_protection_level.label( + "adaptation_protection_level" + ), + models.AdaptationCostBenefit.adaptation_name.label( + "adaptation_name" + ), + models.AdaptationCostBenefit.avoided_ead_mean.label( + "avoided_ead_mean" + ), + models.AdaptationCostBenefit.avoided_eael_mean.label( + "avoided_eael_mean" + ), + models.AdaptationCostBenefit.rcp.label("rcp"), + models.AdaptationCostBenefit.hazard.label("hazard"), + ).select_from( + models.Feature + ).join( + models.FeatureLayer + ).join( + models.Feature.adaptation + ).filter( + models.AdaptationCostBenefit.adaptation_name == + "Flood defence around asset" # test query + # models.AdaptationCostBenefit.protector_feature_id == protector_id + ) + print(adaptation_options) + return session.execute(adaptation_options) diff --git a/backend/backend/app/schemas.py b/backend/backend/app/schemas.py index 01290503..81f72804 100644 --- a/backend/backend/app/schemas.py +++ b/backend/backend/app/schemas.py @@ -175,3 +175,20 @@ class FeatureListItemOut(BaseModel, Generic[SortFieldT]): AttributeT = TypeVar("AttributeT") AttributeLookup = dict[int, AttributeT] + +# Protected Features + + +class ProtectedFeatureListItem(BaseModel): + id: int + string_id: str + layer: str + adaptation_name: str + adaptation_protection_level: float + adaptation_cost: float + avoided_ead_mean: float + avoided_eael_mean: float + hazard: str + rcp: float + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/backend/db/models.py b/backend/backend/db/models.py index 4833c59f..c7e06bbe 100644 --- a/backend/backend/db/models.py +++ b/backend/backend/db/models.py @@ -113,6 +113,11 @@ class AdaptationCostBenefit(Base): primary_key=True, index=True ) + protector_feature_id = Column( + Integer, + primary_key=True, + index=True + ) hazard = Column(String(8), nullable=False, primary_key=True) rcp = Column(String(8), nullable=False, primary_key=True) diff --git a/frontend/src/lib/api-client/services/FeaturesService.ts b/frontend/src/lib/api-client/services/FeaturesService.ts index 0c9040df..bc3341d9 100644 --- a/frontend/src/lib/api-client/services/FeaturesService.ts +++ b/frontend/src/lib/api-client/services/FeaturesService.ts @@ -84,4 +84,26 @@ export class FeaturesService { }); } + /** + * Read Protected Features + * @returns any Successful Response + * @throws ApiError + */ + public featuresReadProtectedFeatures({ + protectorId, + }: { + protectorId: number, + }): CancelablePromise { + return this.httpRequest.request({ + method: 'GET', + url: '/features/{protector_id}/protected-by', + path: { + 'protector_id': protectorId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + } \ No newline at end of file diff --git a/frontend/src/lib/data-map/DataMap.tsx b/frontend/src/lib/data-map/DataMap.tsx index bce90058..4478d4de 100644 --- a/frontend/src/lib/data-map/DataMap.tsx +++ b/frontend/src/lib/data-map/DataMap.tsx @@ -3,6 +3,12 @@ import { useMap } from 'react-map-gl/maplibre'; import { FC, useRef } from 'react'; import { useRecoilValue } from 'recoil'; +import { + protectedFeatureDetailsState, + protectedFeatureProtectionLevelState, + protectedFeatureRCPState, + protectedFeatureDetailsQuery, +} from 'lib/state/interactions/interaction-state'; import { useInteractions } from 'lib/state/interactions/use-interactions'; import { useDataLoadTrigger } from 'lib/data-map/use-data-load-trigger'; import { InteractionGroupConfig } from 'lib/data-map/types'; @@ -74,6 +80,19 @@ export const DataMap: FC<{ const viewLayersParams = useRecoilValue(viewLayersParamsState); const saveViewLayers = useSaveViewLayers(); + const protectedFeatures = useRecoilValue(protectedFeatureDetailsState); + const protectedFeatureProtectionLevel = useRecoilValue(protectedFeatureProtectionLevelState); + const protectedFeatureRCP = useRecoilValue(protectedFeatureRCPState); + const protectedFeatureDetails = useRecoilValue( + protectedFeatureDetailsQuery({ rcp: 2.6, protectionLevel: 1 }), + ); + console.log({ + protectedFeatures, + protectedFeatureProtectionLevel, + protectedFeatureRCP, + protectedFeatureDetails, + }); + useTrigger(viewLayers); const { onHover, onClick, layerFilter, pickingRadius } = useInteractions( diff --git a/frontend/src/lib/state/interactions/interaction-state.ts b/frontend/src/lib/state/interactions/interaction-state.ts index 6a3c77bb..dcd7f4f3 100644 --- a/frontend/src/lib/state/interactions/interaction-state.ts +++ b/frontend/src/lib/state/interactions/interaction-state.ts @@ -1,7 +1,7 @@ import forEach from 'lodash/forEach'; import { atom, atomFamily, selector, selectorFamily } from 'recoil'; -import { InteractionLayer } from 'lib/data-map/types'; +import { InteractionLayer, VectorTarget } from 'lib/data-map/types'; import { isReset } from 'lib/recoil/is-reset'; import { ApiClient } from 'lib/api-client'; @@ -90,6 +90,59 @@ export const selectedAssetDetails = selectorFamily({ }, }); +/** + * Fetch a list of adaptation options, by feature ID and layer, + * for features protected by the current selected feature. + */ +export const protectedFeatureDetailsState = selector({ + key: 'protectedFeatureDetails', + get: async ({ get }) => { + const selection = get(selectionState('assets')); + const target = selection?.target as VectorTarget; + if (!target?.feature?.id) { + return null; + } + const featureDetails = await apiClient.features.featuresReadProtectedFeatures({ + protectorId: target.feature.id, + }); + return featureDetails; + }, +}); + +/** + * A set of unique RCP values for the protected feature list. + */ +export const protectedFeatureRCPState = selector({ + key: 'protectedFeatureRCP', + get: ({ get }) => new Set(get(protectedFeatureDetailsState)?.map((feature) => feature.rcp)), +}); + +/** + * A set of unique protection levels for the protected feature list. + */ +export const protectedFeatureProtectionLevelState = selector({ + key: 'protectedFeatureProtectionLevel', + get: ({ get }) => + new Set( + get(protectedFeatureDetailsState)?.map((feature) => feature.adaptation_protection_level), + ), +}); + +type ProtectedFeatureDetailsQuery = { rcp: number; protectionLevel: number }; +/** + * A list of adaptation options, by feature ID and layer, + * for a specific RCP and protection level. + */ +export const protectedFeatureDetailsQuery = selectorFamily({ + key: 'protectedFeatureDetailsQuery', + get: + ({ rcp = 2.6, protectionLevel = 1 }: ProtectedFeatureDetailsQuery) => + ({ get }) => + get(protectedFeatureDetailsState)?.filter( + (item) => item.rcp === rcp && item.adaptation_protection_level === protectionLevel, + ), +}); + type AllowedGroupLayers = Record; const allowedGroupLayersImpl = atom({ From ba2c774ed6cd31192a519e72aabf189636519a72 Mon Sep 17 00:00:00 2001 From: Jim O'Donnell Date: Thu, 5 Dec 2024 17:19:13 +0000 Subject: [PATCH 2/2] WIP: preloaded data for assets --- frontend/src/app/map/DataMap.tsx | 1 - .../networks/sidebar/NetworkControl.tsx | 30 ++++- frontend/src/lib/data-loader/data-loader.ts | 56 ++++++-- frontend/src/lib/data-map/DataMap.tsx | 30 ++--- .../layers/assets/asset-view-layer.ts | 6 +- .../lib/data-map/layers/assets/data-access.ts | 3 + frontend/src/lib/data-map/view-layers.ts | 3 +- .../src/lib/deck/layers/data-loader-layer.ts | 10 +- .../state/interactions/interaction-state.ts | 55 +------- frontend/src/lib/state/layers/view-layers.ts | 2 +- frontend/src/lib/state/protected-features.ts | 126 ++++++++++++++++++ 11 files changed, 222 insertions(+), 100 deletions(-) create mode 100644 frontend/src/lib/state/protected-features.ts diff --git a/frontend/src/app/map/DataMap.tsx b/frontend/src/app/map/DataMap.tsx index 579ce7da..62dd4982 100644 --- a/frontend/src/app/map/DataMap.tsx +++ b/frontend/src/app/map/DataMap.tsx @@ -18,7 +18,6 @@ export const DataMapContainer: FC = () => { const setViewLayersFlat = useSetRecoilState(viewLayersFlatState); const { firstLabelId } = useBasemapStyle(background, showLabels); const interactionGroups = useRecoilValue(interactionGroupsState); - useEffect(() => { setViewLayersFlat(flattenConfig(viewLayers)); }, [viewLayers, setViewLayersFlat]); diff --git a/frontend/src/data-layers/networks/sidebar/NetworkControl.tsx b/frontend/src/data-layers/networks/sidebar/NetworkControl.tsx index d3adec1f..c181a919 100644 --- a/frontend/src/data-layers/networks/sidebar/NetworkControl.tsx +++ b/frontend/src/data-layers/networks/sidebar/NetworkControl.tsx @@ -1,9 +1,9 @@ import { Box } from '@mui/system'; import { Alert } from '@mui/material'; import { FC } from 'react'; -import { useRecoilState, useRecoilValue } from 'recoil'; +import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; -import { CheckboxTree } from 'lib/controls/checkbox-tree/CheckboxTree'; +import { CheckboxTree, recalculateCheckboxStates } from 'lib/controls/checkbox-tree/CheckboxTree'; import { useUpdateDataParam } from 'lib/state/data-params'; import { LayerLabel } from 'lib/sidebar/ui/LayerLabel'; @@ -17,6 +17,31 @@ import { NETWORK_LAYERS_HIERARCHY } from './hierarchy'; import { NETWORKS_METADATA } from '../metadata'; import { showAdaptationsState } from '../state/layer'; import adaptationSectorLayers from '../adaptation-sector-layers.json'; +import { protectedFeatureLayersState } from 'lib/state/protected-features'; + +/** + * Set the checkbox tree state to true for protected feature layers. + * @param checkBoxState network checkbox tree state. + */ +function useSyncProtectedFeatureLayers(checkboxState) { + const protectedFeatureLayers = useRecoilValue(protectedFeatureLayersState); + const setCheckboxState = useSetRecoilState(networkTreeCheckboxState); + protectedFeatureLayers.forEach((layer: string) => { + if (!checkboxState.checked[layer]) { + setCheckboxState((prev) => { + const newState = { + indeterminate: {}, + checked: { + ...prev.checked, + [layer]: true, + }, + }; + const resolvedTreeState = recalculateCheckboxStates(newState, networkTreeConfig); + return resolvedTreeState; + }); + } + }); +} /** * Sync adaptation parameters to the infrastructure checkbox tree, so that @@ -56,6 +81,7 @@ export const NetworkControl: FC = () => { const disableCheck = showAdaptations; useSyncAdaptationParameters(checkboxState); + useSyncProtectedFeatureLayers(checkboxState); return ( <> diff --git a/frontend/src/lib/data-loader/data-loader.ts b/frontend/src/lib/data-loader/data-loader.ts index bd20ba7b..07f085f4 100644 --- a/frontend/src/lib/data-loader/data-loader.ts +++ b/frontend/src/lib/data-loader/data-loader.ts @@ -3,9 +3,40 @@ import { FieldSpec } from 'lib/data-map/view-layers'; export type DataLoaderSubscriber = (loader: DataLoader) => void; +export type DataFetcher = ( + ids?: number[], + layer?: string, + fieldSpec?: FieldSpec, +) => Promise>; + const apiClient = new ApiClient({ BASE: '/api', }); + +/** + * Fetch adaptation option values for a list of feature IDs in a given layer. + * @param ids A list of feature IDs. + * @param layer An asset layer ID. + * @param fieldSpec A field specification, containing the adaptation parameters for this query. + * @returns A list of feature IDs and values for a specific adaptation option and variable. + */ +const defaultDataFetcher: DataFetcher = async ( + ids: number[], + layer: string, + fieldSpec: FieldSpec, +) => { + if (ids.length === 0) return {}; + const { fieldGroup, field, fieldDimensions, fieldParams } = fieldSpec; + return await apiClient.attributes.attributesReadAttributes({ + layer: layer, + fieldGroup, + field, + dimensions: JSON.stringify(fieldDimensions), + parameters: JSON.stringify(fieldParams), + requestBody: ids, + }); +}; + /** * Data loader that fetches data from the attributes API for a layer and field spec. * The data is stored in a map with feature IDs as keys. @@ -36,6 +67,8 @@ export class DataLoader { private subscribers: DataLoaderSubscriber[]; + private dataFetcher: DataFetcher = defaultDataFetcher; + getData(id: number) { const data = this.data.get(id); @@ -50,6 +83,11 @@ export class DataLoader { return this.data.size > 0; } + async loadData(dataFetcher: DataFetcher, ids?: number[]) { + this.dataFetcher = dataFetcher || defaultDataFetcher; + this.loadDataForIds(ids); + } + subscribe(callback: DataLoaderSubscriber) { this.subscribers ??= []; this.subscribers.push(callback); @@ -82,28 +120,20 @@ export class DataLoader { const tempMissingIds = ids.filter( (id) => this.data.get(id) === undefined && !this.loadingIds.has(id), ); - if (tempMissingIds.length === 0) return; const loadedData = await this.requestMissingData(tempMissingIds); this.updateData(loadedData); } + private fetchData(ids: number[]) { + return this.dataFetcher(ids, this.layer, this.fieldSpec); + } + private async requestMissingData(requestedIds: number[]): Promise> { - const { fieldGroup, field, fieldDimensions, fieldParams } = this.fieldSpec; const missingIds = requestedIds.filter((id) => !this.loadingIds.has(id)); - - if (missingIds.length === 0) return {}; - missingIds.forEach((id) => this.loadingIds.add(id)); - return await apiClient.attributes.attributesReadAttributes({ - layer: this.layer, - fieldGroup, - field, - dimensions: JSON.stringify(fieldDimensions), - parameters: JSON.stringify(fieldParams), - requestBody: missingIds, - }); + return await this.fetchData(missingIds); } private updateData(loadedData: Record) { diff --git a/frontend/src/lib/data-map/DataMap.tsx b/frontend/src/lib/data-map/DataMap.tsx index 4478d4de..9f5a9a58 100644 --- a/frontend/src/lib/data-map/DataMap.tsx +++ b/frontend/src/lib/data-map/DataMap.tsx @@ -1,14 +1,9 @@ import type { MapboxOverlay } from '@deck.gl/mapbox/typed'; +import { LayersList } from 'deck.gl/typed'; import { useMap } from 'react-map-gl/maplibre'; import { FC, useRef } from 'react'; import { useRecoilValue } from 'recoil'; -import { - protectedFeatureDetailsState, - protectedFeatureProtectionLevelState, - protectedFeatureRCPState, - protectedFeatureDetailsQuery, -} from 'lib/state/interactions/interaction-state'; import { useInteractions } from 'lib/state/interactions/use-interactions'; import { useDataLoadTrigger } from 'lib/data-map/use-data-load-trigger'; import { InteractionGroupConfig } from 'lib/data-map/types'; @@ -16,7 +11,7 @@ import { useSaveViewLayers, viewLayersFlatState } from 'lib/state/layers/view-la import { viewLayersParamsState } from 'lib/state/layers/view-layers-params'; import { DeckGLOverlay } from 'lib/map/DeckGLOverlay'; import { ViewLayer, ViewLayerParams } from 'lib/data-map/view-layers'; -import { LayersList } from 'deck.gl/typed'; +import { protectedFeatureLayerDataState } from 'lib/state/protected-features'; // set a convention where the view layer id is either the first part of the deck id before the @ sign, or it's the whole id function lookupViewForDeck(deckLayerId: string) { @@ -29,6 +24,7 @@ function lookupViewForDeck(deckLayerId: string) { * @param viewLayersParams - View layer selection and style parameters, mapped by view layer ID. * @param zoom - Current map zoom level. * @param beforeId - ID of the first labels layer. + * @param viewLayersData - Optional adaptation data for each view layer, mapped by view layer ID. * @returns Array of Deck.GL layers. */ function buildLayers( @@ -36,9 +32,12 @@ function buildLayers( viewLayersParams: Map, zoom: number, beforeId: string | undefined, + viewLayersData?: Map>, ): LayersList { return viewLayers.map((viewLayer) => { const viewLayerParams = viewLayersParams.get(viewLayer.id); + const data = viewLayersData?.get(viewLayer.id); + const dataFetcher = data ? async () => data : undefined; const deckProps = { id: viewLayer.id, pickable: !!viewLayer.interactionGroup, @@ -48,6 +47,7 @@ function buildLayers( deckProps, zoom, ...viewLayerParams, + dataFetcher, }); }); } @@ -78,21 +78,9 @@ export const DataMap: FC<{ const zoom = map.getMap().getZoom(); const viewLayers = useRecoilValue(viewLayersFlatState); const viewLayersParams = useRecoilValue(viewLayersParamsState); + const viewLayersData = useRecoilValue(protectedFeatureLayerDataState); const saveViewLayers = useSaveViewLayers(); - const protectedFeatures = useRecoilValue(protectedFeatureDetailsState); - const protectedFeatureProtectionLevel = useRecoilValue(protectedFeatureProtectionLevelState); - const protectedFeatureRCP = useRecoilValue(protectedFeatureRCPState); - const protectedFeatureDetails = useRecoilValue( - protectedFeatureDetailsQuery({ rcp: 2.6, protectionLevel: 1 }), - ); - console.log({ - protectedFeatures, - protectedFeatureProtectionLevel, - protectedFeatureRCP, - protectedFeatureDetails, - }); - useTrigger(viewLayers); const { onHover, onClick, layerFilter, pickingRadius } = useInteractions( @@ -101,7 +89,7 @@ export const DataMap: FC<{ interactionGroups, ); - const layers = buildLayers(viewLayers, viewLayersParams, zoom, firstLabelId); + const layers = buildLayers(viewLayers, viewLayersParams, zoom, firstLabelId, viewLayersData); const onClickFeature = (info: any) => { deckRef.current && onClick?.(info, deckRef.current); saveViewLayers(viewLayers); diff --git a/frontend/src/lib/data-map/layers/assets/asset-view-layer.ts b/frontend/src/lib/data-map/layers/assets/asset-view-layer.ts index 88601475..d0b96a1e 100644 --- a/frontend/src/lib/data-map/layers/assets/asset-view-layer.ts +++ b/frontend/src/lib/data-map/layers/assets/asset-view-layer.ts @@ -35,10 +35,11 @@ export function assetViewLayer( params: { assetId, }, - fn({ deckProps, zoom, selection }: ViewLayerFunctionOptions) { + fn({ deckProps, zoom, selection, dataFetcher }: ViewLayerFunctionOptions) { const styleParams = this?.styleParams; const target = selection?.target as VectorTarget; const selectedFeatureIds = [target?.feature.id]; + const dataLoader = customDataAccessFn?.(styleParams?.colorMap?.fieldSpec)?.dataLoader; return selectableMvtLayer( { selectionOptions: { @@ -46,7 +47,8 @@ export function assetViewLayer( polygonOffset: selectionPolygonOffset, }, dataLoaderOptions: { - dataLoader: customDataAccessFn?.(styleParams?.colorMap?.fieldSpec)?.dataLoader, + dataLoader, + dataFetcher, }, }, deckProps, diff --git a/frontend/src/lib/data-map/layers/assets/data-access.ts b/frontend/src/lib/data-map/layers/assets/data-access.ts index 746f6e05..f05c0d20 100644 --- a/frontend/src/lib/data-map/layers/assets/data-access.ts +++ b/frontend/src/lib/data-map/layers/assets/data-access.ts @@ -58,6 +58,9 @@ export function getAssetDataAccessor(layer: string, fieldSpec: FieldSpec) { } else if (fieldGroup === 'adaptation') { const dataLoader = dataLoaderManager.getDataLoader(layer, fieldSpec); return withLoaderTriggers((f) => dataLoader.getData(f.id), dataLoader); + } else if (fieldGroup === 'protected_features') { + const dataLoader = dataLoaderManager.getDataLoader(layer, fieldSpec); + return withLoaderTriggers((f) => dataLoader.getData(f.id), dataLoader); } else { // field other than damages - use field name as key return featureProperty(field); diff --git a/frontend/src/lib/data-map/view-layers.ts b/frontend/src/lib/data-map/view-layers.ts index 3b2f10a3..f3c08d0b 100644 --- a/frontend/src/lib/data-map/view-layers.ts +++ b/frontend/src/lib/data-map/view-layers.ts @@ -1,5 +1,5 @@ import { ScaleSequential } from 'd3-scale'; -import { DataLoader } from 'lib/data-loader/data-loader'; +import { DataFetcher, DataLoader } from 'lib/data-loader/data-loader'; import { Accessor } from 'lib/deck/props/getters'; import { InteractionTarget, VectorTarget, RasterTarget } from './types'; import { Layer } from 'deck.gl/typed'; @@ -32,6 +32,7 @@ export interface ViewLayerFunctionOptions { zoom: number; styleParams?: StyleParams; selection?: InteractionTarget | InteractionTarget; + dataFetcher?: DataFetcher; } export interface DataManager { diff --git a/frontend/src/lib/deck/layers/data-loader-layer.ts b/frontend/src/lib/deck/layers/data-loader-layer.ts index ba832edd..931807d2 100644 --- a/frontend/src/lib/deck/layers/data-loader-layer.ts +++ b/frontend/src/lib/deck/layers/data-loader-layer.ts @@ -1,21 +1,21 @@ -import { DataLoader } from 'lib/data-loader/data-loader'; +import { DataFetcher, DataLoader } from 'lib/data-loader/data-loader'; import { MapGeoJSONFeature } from 'maplibre-gl'; export interface DataLoaderOptions { dataLoader: DataLoader; + dataFetcher?: DataFetcher; } /** * Load data from the attributes API for every feature in a tile. */ -export function dataLoaderLayer(tileProps, { dataLoader }: DataLoaderOptions) { +export function dataLoaderLayer(tileProps, { dataLoader, dataFetcher }: DataLoaderOptions) { const { tile: { content }, } = tileProps; if (content && dataLoader) { - const ids: number[] = !dataLoader.hasData ? content.map((f: MapGeoJSONFeature) => f.id) : []; - - dataLoader.loadDataForIds(ids); + const ids: number[] = content.map((f: MapGeoJSONFeature) => f.id); + dataLoader?.loadData(dataFetcher, ids); } return null; diff --git a/frontend/src/lib/state/interactions/interaction-state.ts b/frontend/src/lib/state/interactions/interaction-state.ts index dcd7f4f3..6a3c77bb 100644 --- a/frontend/src/lib/state/interactions/interaction-state.ts +++ b/frontend/src/lib/state/interactions/interaction-state.ts @@ -1,7 +1,7 @@ import forEach from 'lodash/forEach'; import { atom, atomFamily, selector, selectorFamily } from 'recoil'; -import { InteractionLayer, VectorTarget } from 'lib/data-map/types'; +import { InteractionLayer } from 'lib/data-map/types'; import { isReset } from 'lib/recoil/is-reset'; import { ApiClient } from 'lib/api-client'; @@ -90,59 +90,6 @@ export const selectedAssetDetails = selectorFamily({ }, }); -/** - * Fetch a list of adaptation options, by feature ID and layer, - * for features protected by the current selected feature. - */ -export const protectedFeatureDetailsState = selector({ - key: 'protectedFeatureDetails', - get: async ({ get }) => { - const selection = get(selectionState('assets')); - const target = selection?.target as VectorTarget; - if (!target?.feature?.id) { - return null; - } - const featureDetails = await apiClient.features.featuresReadProtectedFeatures({ - protectorId: target.feature.id, - }); - return featureDetails; - }, -}); - -/** - * A set of unique RCP values for the protected feature list. - */ -export const protectedFeatureRCPState = selector({ - key: 'protectedFeatureRCP', - get: ({ get }) => new Set(get(protectedFeatureDetailsState)?.map((feature) => feature.rcp)), -}); - -/** - * A set of unique protection levels for the protected feature list. - */ -export const protectedFeatureProtectionLevelState = selector({ - key: 'protectedFeatureProtectionLevel', - get: ({ get }) => - new Set( - get(protectedFeatureDetailsState)?.map((feature) => feature.adaptation_protection_level), - ), -}); - -type ProtectedFeatureDetailsQuery = { rcp: number; protectionLevel: number }; -/** - * A list of adaptation options, by feature ID and layer, - * for a specific RCP and protection level. - */ -export const protectedFeatureDetailsQuery = selectorFamily({ - key: 'protectedFeatureDetailsQuery', - get: - ({ rcp = 2.6, protectionLevel = 1 }: ProtectedFeatureDetailsQuery) => - ({ get }) => - get(protectedFeatureDetailsState)?.filter( - (item) => item.rcp === rcp && item.adaptation_protection_level === protectionLevel, - ), -}); - type AllowedGroupLayers = Record; const allowedGroupLayersImpl = atom({ diff --git a/frontend/src/lib/state/layers/view-layers.ts b/frontend/src/lib/state/layers/view-layers.ts index cf81179d..401b974a 100644 --- a/frontend/src/lib/state/layers/view-layers.ts +++ b/frontend/src/lib/state/layers/view-layers.ts @@ -3,7 +3,7 @@ import { atom, atomFamily, selectorFamily, useRecoilTransaction_UNSTABLE } from import { ViewLayer, ViewLayerParams } from 'lib/data-map/view-layers'; import { selectionState } from 'lib/state/interactions/interaction-state'; -const viewLayerState = atomFamily({ +export const viewLayerState = atomFamily({ key: 'viewLayerState', default: null, }); diff --git a/frontend/src/lib/state/protected-features.ts b/frontend/src/lib/state/protected-features.ts new file mode 100644 index 00000000..8b7d0a8b --- /dev/null +++ b/frontend/src/lib/state/protected-features.ts @@ -0,0 +1,126 @@ +import { noWait, selector, selectorFamily } from 'recoil'; + +import { ApiClient } from 'lib/api-client'; +import { selectionState } from './interactions/interaction-state'; +import { VectorTarget } from 'lib/data-map/types'; +import { dataParamState } from './data-params'; +import { viewLayerState } from './layers/view-layers'; + +const apiClient = new ApiClient({ + BASE: '/api', +}); + +/** + * Fetch a list of all adaptation options, by feature ID and layer, + * for features protected by the current selected feature. + */ +const protectedFeatureAdaptationOptionsQuery = selector({ + key: 'protectedFeatureDetails', + get: ({ get }) => { + const selection = get(selectionState('assets')); + const target = selection?.target as VectorTarget; + if (!target?.feature?.id) { + return []; + } + return apiClient.features.featuresReadProtectedFeatures({ + protectorId: target.feature.id, + }); + }, +}); + +/** + * A list of all adaptation options, by feature ID and layer, + * for features protected by the current selected feature. + * Components using this selector will not suspend while waiting for the API. + */ +const protectedFeatureAdaptationOptionsState = selector({ + key: 'protectedFeatureDetailsState', + get: ({ get }) => { + const loadable = get(noWait(protectedFeatureAdaptationOptionsQuery)); + const data = loadable.state === 'hasValue' ? loadable.contents : []; + const error = loadable.state === 'hasError' ? loadable.contents : null; + return { data, error }; + }, +}); + +/** + * Fetch a list of layer IDs for the current protected feature query set. + */ +export const protectedFeatureLayersQuery = selector({ + key: 'protectedFeatureLayersQuery', + get: ({ get }) => + new Set(get(protectedFeatureAdaptationOptionsQuery)?.map((feature) => feature.layer)), +}); + +/** + * A set of unique feature layer IDs for the protected feature list. + */ +export const protectedFeatureLayersState = selector({ + key: 'protectedFeatureLayers', + get: ({ get }) => { + const loadable = get(noWait(protectedFeatureLayersQuery)); + return loadable.state === 'hasValue' ? loadable.contents : new Set(); + }, +}); + +type ProtectedFeatureDetailsQuery = { rcp: number; protectionLevel: number }; +/** + * A list of adaptation options, by feature ID and layer, + * filtered by RCP and protection level. + */ +export const protectedFeatureAdaptationsQuery = selectorFamily({ + key: 'protectedFeatureDetailsQuery', + get: + ({ rcp = 2.6, protectionLevel = 1 }: ProtectedFeatureDetailsQuery) => + ({ get }) => { + const { data } = get(protectedFeatureAdaptationOptionsState); + return data.filter( + (item) => item.rcp === rcp && item.adaptation_protection_level === protectionLevel, + ); + }, +}); + +/** + * A list of adaptation options, by feature ID and layer, for the RCP and protection level + * set by the adaptations sidebar control state. + */ +export const protectedFeatureAdaptationsState = selector({ + key: 'protectedFeatureAdaptations', + get: ({ get }) => { + const rcpAsString = get(dataParamState({ group: 'adaptation', param: 'rcp' })); + const rcp = parseFloat(rcpAsString); + const protectionLevel = get( + dataParamState({ group: 'adaptation', param: 'adaptation_protection_level' }), + ); + return get(protectedFeatureAdaptationsQuery({ rcp, protectionLevel })); + }, +}); + +const viewLayerAdaptationField = selectorFamily({ + key: 'viewLayerAdaptationField', + get: + (viewLayerID: string) => + ({ get }) => { + const viewLayer = get(viewLayerState(viewLayerID)); + return viewLayer?.styleParams?.colorMap?.fieldSpec?.field; + }, +}); + +export const protectedFeatureLayerDataState = selector({ + key: 'protectedFeatureLayerData', + get: ({ get }) => { + const viewLayersData = new Map(); + const protectedFeatureAdaptations = get(protectedFeatureAdaptationsState); + const viewLayerIDs = get(protectedFeatureLayersState); + viewLayerIDs.forEach((viewLayerID: string) => { + const field = get(viewLayerAdaptationField(viewLayerID)); + const viewLayerDataEntries = protectedFeatureAdaptations + ?.filter((row) => row.layer === viewLayerID && !!row[field]) + .map((row) => [row.id, row[field]]); + const data = + viewLayerDataEntries.length > 0 ? Object.fromEntries(viewLayerDataEntries) : null; + viewLayersData.set(viewLayerID, data); + }); + return viewLayersData; + }, +});