diff --git a/app/hooks/features/index.ts b/app/hooks/features/index.ts index a91829a9b5..66d445407f 100644 --- a/app/hooks/features/index.ts +++ b/app/hooks/features/index.ts @@ -9,15 +9,12 @@ import { } from 'react-query'; import { AxiosRequestConfig } from 'axios'; -import chroma from 'chroma-js'; import Fuse from 'fuse.js'; import flatten from 'lodash/flatten'; import orderBy from 'lodash/orderBy'; import partition from 'lodash/partition'; import { useSession } from 'next-auth/react'; -import { COLORS } from 'hooks/map/constants'; - import { ItemProps as IntersectItemProps } from 'components/features/intersect-item/component'; import { ItemProps as RawItemProps } from 'components/features/raw-item/component'; import { Feature } from 'types/api/feature'; @@ -173,12 +170,12 @@ export function useAllFeatures( ) { const { data: session } = useSession(); - const { filters = {}, search, sort } = options; + const { filters = {}, search, sort, disablePagination } = options; const parsedFilters = Object.keys(filters).reduce((acc, k) => { return { ...acc, - [`filter[${k}]`]: filters[k].toString(), + [k]: filters[k].toString(), }; }, {}); @@ -197,6 +194,9 @@ export function useAllFeatures( ...(sort && { sort, }), + ...(disablePagination && { + disablePagination: true, + }), }, }).then(({ data }) => data); @@ -215,6 +215,11 @@ export function useSelectedFeatures( const { data: session } = useSession(); const { search } = filters; + const queryClient = useQueryClient(); + + const featureColorQueryState = + queryClient.getQueryState<{ id: Feature['id']; color: string }[]>('feature-colors'); + const fetchFeatures = () => SCENARIOS.request({ method: 'GET', @@ -226,7 +231,7 @@ export function useSelectedFeatures( return useQuery(['selected-features', sid], fetchFeatures, { ...queryOptions, - enabled: !!sid, + enabled: !!sid && featureColorQueryState?.status === 'success', select: ({ data }) => { const { features = [] } = data; @@ -238,6 +243,8 @@ export function useSelectedFeatures( featureClassName, tag, description, + amountMin, + amountMax, properties = {}, } = metadata || ({} as GeoFeatureSet['features'][0]['metadata']); @@ -296,18 +303,17 @@ export function useSelectedFeatures( ); } - const color = - features.length > COLORS['features-preview'].ramp.length - ? chroma.scale(COLORS['features-preview'].ramp).colors(features.length)[index] - : COLORS['features-preview'].ramp[index]; - return { ...d, id: featureId, name: alias || featureClassName, type: tag, description, - color, + amountRange: { + min: amountMin, + max: amountMax, + }, + color: featureColorQueryState.data.find(({ id }) => featureId === id)?.color, // SPLIT splitOptions, @@ -331,10 +337,7 @@ export function useSelectedFeatures( }); } - // Sort - parsedData = orderBy(parsedData, ['type', 'name'], ['asc', 'asc']); - - return parsedData; + return orderBy(parsedData, ['type', 'name'], ['asc', 'asc']); }, placeholderData: { data: {} as GeoFeatureSet }, }); @@ -458,7 +461,7 @@ export function useTargetedFeatures( } // Sort - parsedData = orderBy(parsedData, ['type', 'name'], ['asc', 'asc']); + parsedData = orderBy(parsedData, ['name'], ['desc']); parsedData = flatten( parsedData.map((s) => { diff --git a/app/hooks/features/types.ts b/app/hooks/features/types.ts index 7f239a5cbc..4f47a3c8af 100644 --- a/app/hooks/features/types.ts +++ b/app/hooks/features/types.ts @@ -25,4 +25,5 @@ export interface UseFeaturesOptionsProps { search?: string; sort?: string; filters?: Record; + disablePagination?: boolean; } diff --git a/app/hooks/map/constants.tsx b/app/hooks/map/constants.tsx index 595b6ee6a1..8dd9e8a357 100644 --- a/app/hooks/map/constants.tsx +++ b/app/hooks/map/constants.tsx @@ -43,7 +43,7 @@ export const COLORS = { 'wdpa-preview': '#00f', features: '#6F53F7', highlightFeatures: '#BE6BFF', - abundance: { + continuous: { default: '#FFF', ramp: [ '#4b5eef', @@ -335,7 +335,7 @@ export const LEGEND_LAYERS = { }, items: [ { - color: COLORS.abundance.default, + color: COLORS.continuous.default, value: `${amountRange.min === amountRange.max ? 0 : amountRange.min}`, }, { diff --git a/app/hooks/map/index.ts b/app/hooks/map/index.ts index 32484156ce..f47d1e4588 100644 --- a/app/hooks/map/index.ts +++ b/app/hooks/map/index.ts @@ -4,6 +4,7 @@ import chroma from 'chroma-js'; import { Layer } from 'mapbox-gl'; import { CostSurface } from 'types/api/cost-surface'; +import { Feature } from 'types/api/feature'; import { Project } from 'types/api/project'; import { COLORS, LEGEND_LAYERS } from './constants'; @@ -225,6 +226,57 @@ export function useCostSurfaceLayer({ }, [active, pid, costSurfaceId, layerSettings]); } +export function useContinuousFeaturesLayers({ + active, + pid, + features, + layerSettings, +}: { + active: boolean; + pid: Project['id']; + features: Feature['id'][]; + layerSettings: UsePUGridLayer['options']['settings'][0]; +}) { + return useMemo(() => { + if (!active) return []; + + return features.map((fid) => { + const { amountRange, color, opacity = 1 } = layerSettings[fid] || {}; + + return { + id: `continuous-features-layer-${pid}-${fid}`, + type: 'vector', + source: { + type: 'vector', + tiles: [ + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/projects/${pid}/features/${fid}/preview/tiles/{z}/{x}/{y}.mvt`, + ], + }, + render: { + layers: [ + { + type: 'fill', + 'source-layer': 'layer0', + paint: { + 'fill-color': [ + 'interpolate', + ['linear'], + ['get', 'amount'], + amountRange.min, + COLORS.continuous.default, + amountRange.max, + color, + ], + 'fill-opacity': opacity, + }, + }, + ], + }, + }; + }); + }, [active, pid, features, layerSettings]); +} + // WDPA preview layer export function useWDPAPreviewLayer({ pid, @@ -765,7 +817,7 @@ export function usePUGridLayer({ ], }, })), - // features abundance + // continuous features ...selectedFeatures.map((featureId) => { const { visibility = true, @@ -782,7 +834,6 @@ export function usePUGridLayer({ }, filter: ['all', ['in', featureId, ['get', 'featureList']]], paint: { - 'fill-outline-color': 'yellow', 'fill-color': [ 'let', 'amount', @@ -805,7 +856,7 @@ export function usePUGridLayer({ ['linear'], ['var', 'amount'], amountRange.min, - COLORS.abundance.default, + COLORS.continuous.default, amountRange.max, color, ], diff --git a/app/layout/project/sidebar/project/inventory-panel/features/index.tsx b/app/layout/project/sidebar/project/inventory-panel/features/index.tsx index c1973c35cc..1fc1db8392 100644 --- a/app/layout/project/sidebar/project/inventory-panel/features/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/features/index.tsx @@ -1,17 +1,17 @@ import { useCallback, useState, ChangeEvent, useEffect } from 'react'; +import { useQueryClient } from 'react-query'; + import { useRouter } from 'next/router'; import { useAppDispatch, useAppSelector } from 'store/hooks'; import { setSelectedFeatures as setVisibleFeatures, + setSelectedContinuousFeatures, setLayerSettings, } from 'store/slices/projects/[id]'; -import chroma from 'chroma-js'; - import { useAllFeatures } from 'hooks/features'; -import { COLORS } from 'hooks/map/constants'; import ActionsMenu from 'layout/project/sidebar/project/inventory-panel/features/actions-menu'; import FeaturesBulkActionMenu from 'layout/project/sidebar/project/inventory-panel/features/bulk-action-menu'; @@ -36,6 +36,7 @@ const InventoryPanelFeatures = ({ noData: noDataMessage }: { noData: string }): const { selectedFeatures: visibleFeatures, + selectedContinuousFeatures, search, layerSettings, } = useAppSelector((state) => state['/projects/[id]']); @@ -47,6 +48,11 @@ const InventoryPanelFeatures = ({ noData: noDataMessage }: { noData: string }): const { query } = useRouter(); const { pid } = query as { pid: string }; + const queryClient = useQueryClient(); + + const featureColorQueryState = + queryClient.getQueryState<{ id: Feature['id']; color: string }[]>('feature-colors'); + const allFeaturesQuery = useAllFeatures( pid, { @@ -55,11 +61,8 @@ const InventoryPanelFeatures = ({ noData: noDataMessage }: { noData: string }): }, { select: ({ data }) => { - return data?.map((feature, index) => { - const color = - data.length > COLORS['features-preview'].ramp.length - ? chroma.scale(COLORS['features-preview'].ramp).colors(data.length)[index] - : COLORS['features-preview'].ramp[index]; + return data?.map((feature) => { + const { color } = featureColorQueryState.data.find(({ id }) => feature.id === id) || {}; return { id: feature.id, @@ -68,11 +71,13 @@ const InventoryPanelFeatures = ({ noData: noDataMessage }: { noData: string }): tag: feature.tag, isCustom: feature.isCustom, color, + amountRange: feature.amountRange, }; }); }, placeholderData: { data: [] }, keepPreviousData: true, + enabled: featureColorQueryState?.status === 'success', } ); @@ -112,30 +117,58 @@ const InventoryPanelFeatures = ({ noData: noDataMessage }: { noData: string }): const toggleSeeOnMap = useCallback( (featureId: Feature['id']) => { - const newSelectedFeatures = [...visibleFeatures]; - const isIncluded = newSelectedFeatures.includes(featureId); - if (!isIncluded) { - newSelectedFeatures.push(featureId); + const binaryFeatures = [...visibleFeatures]; + const continuousFeatures = [...selectedContinuousFeatures]; + const isIncludedInBinary = binaryFeatures.includes(featureId); + const isIncludedInContinuous = continuousFeatures.includes(featureId); + + const feature = allFeaturesQuery.data?.find(({ id }) => featureId === id); + const isContinuous = feature.amountRange.min !== null && feature.amountRange.max !== null; + + // const isIncluded = newSelectedFeatures.includes(featureId); + // if (!isIncluded) { + // newSelectedFeatures.push(featureId); + // } else { + // const i = newSelectedFeatures.indexOf(featureId); + // newSelectedFeatures.splice(i, 1); + // } + + if (isContinuous) { + if (!isIncludedInContinuous) { + continuousFeatures.push(featureId); + } else { + const i = continuousFeatures.indexOf(featureId); + continuousFeatures.splice(i, 1); + } + + dispatch(setSelectedContinuousFeatures(continuousFeatures)); } else { - const i = newSelectedFeatures.indexOf(featureId); - newSelectedFeatures.splice(i, 1); + if (!isIncludedInBinary) { + binaryFeatures.push(featureId); + } else { + const i = binaryFeatures.indexOf(featureId); + binaryFeatures.splice(i, 1); + } + + dispatch(setVisibleFeatures(binaryFeatures)); } - dispatch(setVisibleFeatures(newSelectedFeatures)); const selectedFeature = allFeaturesQuery.data.find(({ id }) => featureId === id); - const { color } = selectedFeature || {}; dispatch( setLayerSettings({ id: featureId, settings: { - visibility: !isIncluded, - color, + visibility: !(isIncludedInBinary || isIncludedInContinuous), + color: selectedFeature.color, + ...(isContinuous && { + amountRange: feature.amountRange, + }), }, }) ); }, - [dispatch, visibleFeatures, allFeaturesQuery.data] + [dispatch, visibleFeatures, allFeaturesQuery.data, selectedContinuousFeatures] ); const displayBulkActions = selectedFeaturesIds.length > 0; diff --git a/app/layout/project/sidebar/scenario/grid-setup/features/add/list/component.tsx b/app/layout/project/sidebar/scenario/grid-setup/features/add/list/component.tsx index 009760a7a3..96135d8274 100644 --- a/app/layout/project/sidebar/scenario/grid-setup/features/add/list/component.tsx +++ b/app/layout/project/sidebar/scenario/grid-setup/features/add/list/component.tsx @@ -35,13 +35,14 @@ export const ScenariosFeaturesList = ({ onContinue }): JSX.Element => { const { pid, sid } = query as { pid: string; sid: string }; const scenarioSlice = getScenarioEditSlice(sid); - const { setFeatures, setLayerSettings, setSelectedFeatures } = scenarioSlice.actions; - const { selectedFeatures } = useSelector((state) => state[`/scenarios/${sid}/edit`]); + const { setFeatures, setLayerSettings, setSelectedFeatures, setSelectedContinuousFeatures } = + scenarioSlice.actions; + const { selectedFeatures, selectedContinuousFeatures, layerSettings } = useSelector( + (state) => state[`/scenarios/${sid}/edit`] + ); const dispatch = useDispatch(); - const queryClient = useQueryClient(); - const editable = useCanEditScenario(pid, sid); const selectedFeaturesMutation = useSaveSelectedFeatures({}); const saveScenarioMutation = useSaveScenario({ @@ -241,15 +242,33 @@ export const ScenariosFeaturesList = ({ onContinue }): JSX.Element => { const toggleSeeOnMap = useCallback( (id: Feature['id']) => { - const newSelectedFeatures = [...selectedFeatures]; - const isIncluded = newSelectedFeatures.includes(id); - if (!isIncluded) { - newSelectedFeatures.push(id); + const binaryFeatures = [...selectedFeatures]; + const continuousFeatures = [...selectedContinuousFeatures]; + + const isIncludedInBinary = binaryFeatures.includes(id); + const isIncludedInContinuous = continuousFeatures.includes(id); + + const feature = selectedFeaturesData?.find(({ id: featureId }) => featureId === id); + const isContinuous = feature.amountRange.min !== null && feature.amountRange.max !== null; + + if (isContinuous) { + if (!isIncludedInContinuous) { + continuousFeatures.push(id); + } else { + const i = continuousFeatures.indexOf(id); + continuousFeatures.splice(i, 1); + } + + dispatch(setSelectedContinuousFeatures(continuousFeatures)); } else { - const i = newSelectedFeatures.indexOf(id); - newSelectedFeatures.splice(i, 1); + if (!isIncludedInBinary) { + binaryFeatures.push(id); + } else { + const i = binaryFeatures.indexOf(id); + binaryFeatures.splice(i, 1); + } + dispatch(setSelectedFeatures(binaryFeatures)); } - dispatch(setSelectedFeatures(newSelectedFeatures)); const selectedFeature = selectedFeaturesData.find(({ featureId }) => featureId === id); const { color } = selectedFeature || {}; @@ -258,23 +277,24 @@ export const ScenariosFeaturesList = ({ onContinue }): JSX.Element => { setLayerSettings({ id, settings: { - visibility: !isIncluded, + visibility: !(isIncludedInBinary || isIncludedInContinuous), color, + ...(isContinuous && { + amountRange: feature.amountRange, + }), }, }) ); }, - [dispatch, setSelectedFeatures, setLayerSettings, selectedFeatures, selectedFeaturesData] - ); - - const isShown = useCallback( - (id) => { - if (!selectedFeatures.includes(id)) { - return false; - } - return true; - }, - [selectedFeatures] + [ + dispatch, + setSelectedFeatures, + setLayerSettings, + selectedFeatures, + selectedContinuousFeatures, + setSelectedContinuousFeatures, + selectedFeaturesData, + ] ); useEffect(() => { @@ -343,7 +363,7 @@ export const ScenariosFeaturesList = ({ onContinue }): JSX.Element => { onRemove={() => { setDeleteFeature(item); }} - isShown={isShown(item.id)} + isShown={layerSettings[item.id]?.visibility} onSeeOnMap={() => toggleSeeOnMap(item.id)} /> { layerSettings, selectedFeatures: selectedFeaturesIds, selectedCostSurface, + selectedContinuousFeatures, } = useAppSelector((state) => state['/projects/[id]']); const accessToken = useAccessToken(); + const queryClient = useQueryClient(); const [viewport, setViewport] = useState({}); const [bounds, setBounds] = useState(null); @@ -130,6 +137,32 @@ export const ProjectMap = (): JSX.Element => { : null; }, [sid1, rawScenariosData, rawScenariosIsFetched]); + const featuresColorQuery = useAllFeatures( + pid, + { + filters: { + sort: 'featureClassName', + }, + disablePagination: true, + }, + { + select: ({ data }) => { + return data.map((feature, index) => { + const color = + data.length > COLORS['features-preview'].ramp.length + ? chroma.scale(COLORS['features-preview'].ramp).colors(data.length)[index] + : COLORS['features-preview'].ramp[index]; + + return { + id: feature.id, + color, + }; + }); + }, + placeholderData: { data: [] }, + } + ); + const PlanningAreaLayer = useProjectPlanningAreaLayer({ active: rawScenariosIsFetched && rawScenariosData && !rawScenariosData.length, pId: `${pid}`, @@ -195,6 +228,13 @@ export const ProjectMap = (): JSX.Element => { }, }); + const continuousFeaturesLayers = useContinuousFeaturesLayers({ + active: selectedContinuousFeatures.length > 0, + pid, + features: selectedContinuousFeatures, + layerSettings, + }); + const selectedPreviewFeatures = useMemo(() => { return selectedFeaturesData ?.map(({ featureClassName, id }) => ({ name: featureClassName, id })) @@ -212,6 +252,7 @@ export const ProjectMap = (): JSX.Element => { PlanningAreaLayer, WDPAsPreviewLayers, ...FeaturePreviewLayers, + ...continuousFeaturesLayers, ].filter((l) => !!l); const SCENARIOS_RUNNED = useMemo(() => { @@ -244,6 +285,12 @@ export const ProjectMap = (): JSX.Element => { centerMap({ ref: mapRef.current, isSidebarOpen }); }, [isSidebarOpen]); + useEffect(() => { + if (featuresColorQuery.isSuccess) { + queryClient.setQueryData('feature-colors', featuresColorQuery.data); + } + }, [featuresColorQuery, queryClient]); + const handleViewportChange = useCallback( (vw) => { setViewport(vw); diff --git a/app/layout/projects/show/map/legend/hooks/index.ts b/app/layout/projects/show/map/legend/hooks/index.ts index 635efe3348..976e8a335e 100644 --- a/app/layout/projects/show/map/legend/hooks/index.ts +++ b/app/layout/projects/show/map/legend/hooks/index.ts @@ -1,18 +1,19 @@ +import { useQueryClient } from 'react-query'; + import { useRouter } from 'next/router'; import { useAppDispatch, useAppSelector } from 'store/hooks'; import { setSelectedFeatures, + setSelectedContinuousFeatures, setLayerSettings, setSelectedWDPAs as setVisibleWDPAs, setSelectedCostSurface as setVisibleCostSurface, } from 'store/slices/projects/[id]'; -import chroma from 'chroma-js'; - import { useProjectCostSurfaces } from 'hooks/cost-surface'; import { useAllFeatures } from 'hooks/features'; -import { COLORS, LEGEND_LAYERS } from 'hooks/map/constants'; +import { LEGEND_LAYERS } from 'hooks/map/constants'; import { useScenario } from 'hooks/scenarios'; import { useProjectWDPAs } from 'hooks/wdpa'; @@ -117,57 +118,54 @@ export const useConservationAreasLegend = () => { }; export const useFeaturesLegend = () => { - const { selectedFeatures } = useAppSelector((state) => state['/projects/[id]']); + const { selectedFeatures, selectedContinuousFeatures } = useAppSelector( + (state) => state['/projects/[id]'] + ); const { query } = useRouter(); const { pid } = query as { pid: string }; + const queryClient = useQueryClient(); + + const featureColorQueryState = + queryClient.getQueryState<{ id: Feature['id']; color: string }[]>('feature-colors'); + const dispatch = useAppDispatch(); const projectFeaturesQuery = useAllFeatures( pid, { sort: 'feature_class_name' }, { select: ({ data }) => ({ - binaryFeatures: - data?.filter( - (feature) => - !Object.hasOwn(feature.amountRange, 'min') && - !Object.hasOwn(feature.amountRange, 'max') - ) || [], - continuousFeatures: - data?.filter( - (feature) => - Object.hasOwn(feature.amountRange, 'min') && Object.hasOwn(feature.amountRange, 'max') - ) || [], + binaryFeatures: ( + data?.filter(({ amountRange }) => amountRange.min === null && amountRange.max === null) || + [] + ).map((feature) => ({ + ...feature, + color: featureColorQueryState.data?.find(({ id }) => id === feature.id)?.color, + })), + continuousFeatures: ( + data?.filter(({ amountRange }) => amountRange.min !== null && amountRange.max !== null) || + [] + ).map((feature) => ({ + ...feature, + color: featureColorQueryState.data?.find(({ id }) => id === feature.id)?.color, + })), }), + enabled: featureColorQueryState?.status === 'success', } ); - const totalItems = - projectFeaturesQuery.data?.binaryFeatures.length + - projectFeaturesQuery.data?.continuousFeatures.length || 0; - const binaryFeaturesItems = - projectFeaturesQuery.data?.binaryFeatures?.map(({ id, featureClassName }, index) => { - const color = - totalItems > COLORS['features-preview'].ramp.length - ? chroma.scale(COLORS['features-preview'].ramp).colors(totalItems)[index] - : COLORS['features-preview'].ramp[index]; - + projectFeaturesQuery.data?.binaryFeatures?.map(({ id, alias, featureClassName, color }) => { return { id, - name: featureClassName, + name: alias || featureClassName, color, }; }) || []; const continuousFeaturesItems = projectFeaturesQuery.data?.continuousFeatures.map( - ({ id, featureClassName, amountRange = { min: 5000, max: 100000 } }, index) => { - const color = - totalItems > COLORS['features-preview'].ramp.length - ? chroma.scale(COLORS['features-preview'].ramp).colors(totalItems)[index] - : COLORS['features-preview'].ramp[index]; - + ({ id, featureClassName, amountRange, color }) => { return { id, name: featureClassName, @@ -210,7 +208,7 @@ export const useFeaturesLegend = () => { const { color, amountRange } = continuousFeaturesItems.find(({ id }) => id === featureId) || {}; - const newSelectedFeatures = [...selectedFeatures]; + const newSelectedFeatures = [...selectedContinuousFeatures]; const isIncluded = newSelectedFeatures.includes(featureId); if (!isIncluded) { newSelectedFeatures.push(featureId); @@ -218,7 +216,7 @@ export const useFeaturesLegend = () => { const i = newSelectedFeatures.indexOf(featureId); newSelectedFeatures.splice(i, 1); } - dispatch(setSelectedFeatures(newSelectedFeatures)); + dispatch(setSelectedContinuousFeatures(newSelectedFeatures)); dispatch( setLayerSettings({ diff --git a/app/layout/scenarios/edit/map/component.tsx b/app/layout/scenarios/edit/map/component.tsx index ddf2443e31..99f9e16550 100644 --- a/app/layout/scenarios/edit/map/component.tsx +++ b/app/layout/scenarios/edit/map/component.tsx @@ -1,5 +1,7 @@ import React, { ComponentProps, useCallback, useEffect, useState, useMemo, useRef } from 'react'; +import { useQueryClient } from 'react-query'; + import { useRouter } from 'next/router'; import { useAppSelector, useAppDispatch } from 'store/hooks'; @@ -7,11 +9,12 @@ import { getScenarioEditSlice } from 'store/slices/scenarios/edit'; import PluginMapboxGl from '@vizzuality/layer-manager-plugin-mapboxgl'; import { LayerManager, Layer } from '@vizzuality/layer-manager-react'; +import chroma from 'chroma-js'; import { FiLayers } from 'react-icons/fi'; import { useAccessToken } from 'hooks/auth'; import { useProjectCostSurface } from 'hooks/cost-surface'; -import { useSelectedFeatures, useTargetedFeatures } from 'hooks/features'; +import { useAllFeatures, useSelectedFeatures, useTargetedFeatures } from 'hooks/features'; import { useAllGapAnalysis } from 'hooks/gap-analysis'; import { useWDPAPreviewLayer, @@ -20,7 +23,9 @@ import { useBBOX, useTargetedPreviewLayers, useCostSurfaceLayer, + useContinuousFeaturesLayers, } from 'hooks/map'; +import { COLORS } from 'hooks/map/constants'; import { useProject } from 'hooks/projects'; import { useScenario, useScenarioPU } from 'hooks/scenarios'; import { useBestSolution } from 'hooks/solutions'; @@ -61,6 +66,7 @@ export const ScenariosEditMap = (): JSX.Element => { const { isSidebarOpen } = useAppSelector((state) => state['/projects/[id]']); const accessToken = useAccessToken(); + const queryClient = useQueryClient(); const { query } = useRouter(); @@ -98,6 +104,7 @@ export const ScenariosEditMap = (): JSX.Element => { features: featuresRecipe, featureHoverId, selectedFeatures, + selectedContinuousFeatures, preHighlightFeatures, postHighlightFeatures, @@ -215,6 +222,32 @@ export const ScenariosEditMap = (): JSX.Element => { }, }); + const featuresColorQuery = useAllFeatures( + pid, + { + filters: { + sort: 'featureClassName', + }, + disablePagination: true, + }, + { + select: ({ data }) => { + return data.map((feature, index) => { + const color = + data.length > COLORS['features-preview'].ramp.length + ? chroma.scale(COLORS['features-preview'].ramp).colors(data.length)[index] + : COLORS['features-preview'].ramp[index]; + + return { + id: feature.id, + color, + }; + }); + }, + placeholderData: { data: [] }, + } + ); + const TargetedPreviewLayers = useTargetedPreviewLayers({ features: targetedFeaturesData, cache, @@ -247,7 +280,6 @@ export const ScenariosEditMap = (): JSX.Element => { features: [TABS['scenario-features'], TABS['scenario-features-targets-spf']].includes(tab) ? [] : featuresIds, - selectedFeatures, preHighlightFeatures, postHighlightFeatures: postHighlightedFeaturesIds, runId: selectedSolution?.runId || bestSolution?.runId, @@ -277,12 +309,20 @@ export const ScenariosEditMap = (): JSX.Element => { } as Parameters[0]['layerSettings'], }); + const continuousFeaturesLayers = useContinuousFeaturesLayers({ + active: selectedContinuousFeatures.length > 0, + pid, + features: selectedContinuousFeatures, + layerSettings, + }); + const LAYERS = [ PUGridLayer, costSurfaceLayer, WDPApreviewLayer, ...FeaturePreviewLayers, ...TargetedPreviewLayers, + ...continuousFeaturesLayers, ].filter((l) => !!l); useEffect(() => { @@ -304,6 +344,12 @@ export const ScenariosEditMap = (): JSX.Element => { if (tab) dispatch(setCache(Date.now())); }, [tab, dispatch, setCache]); + useEffect(() => { + if (featuresColorQuery.isSuccess) { + queryClient.setQueryData('feature-colors', featuresColorQuery.data); + } + }, [featuresColorQuery, queryClient]); + const handleViewportChange = useCallback((vw) => { setViewport(vw); }, []); diff --git a/app/layout/scenarios/edit/map/legend/hooks/index.ts b/app/layout/scenarios/edit/map/legend/hooks/index.ts index 51d8063178..f5ff02acc9 100644 --- a/app/layout/scenarios/edit/map/legend/hooks/index.ts +++ b/app/layout/scenarios/edit/map/legend/hooks/index.ts @@ -1,3 +1,5 @@ +import { useQueryClient } from 'react-query'; + import { useRouter } from 'next/router'; import { useAppDispatch, useAppSelector } from 'store/hooks'; @@ -123,10 +125,17 @@ export const useFeaturesLegend = () => { const { query } = useRouter(); const { pid, sid } = query as { pid: string; sid: string }; const scenarioSlice = getScenarioEditSlice(sid); - const { setSelectedFeatures, setLayerSettings } = scenarioSlice.actions; - const { selectedFeatures }: { selectedFeatures: Feature['id'][] } = useAppSelector( - (state) => state[`/scenarios/${sid}/edit`] - ); + const { setSelectedFeatures, setSelectedContinuousFeatures, setLayerSettings } = + scenarioSlice.actions; + const { + selectedFeatures, + selectedContinuousFeatures, + }: { selectedFeatures: Feature['id'][]; selectedContinuousFeatures: Feature['id'][] } = + useAppSelector((state) => state[`/scenarios/${sid}/edit`]); + const queryClient = useQueryClient(); + + const featureColorQueryState = + queryClient.getQueryState<{ id: Feature['id']; color: string }[]>('feature-colors'); const dispatch = useAppDispatch(); const projectFeaturesQuery = useAllFeatures( @@ -134,47 +143,37 @@ export const useFeaturesLegend = () => { { sort: 'feature_class_name' }, { select: ({ data }) => ({ - binaryFeatures: - data?.filter( - (feature) => - !Object.hasOwn(feature.amountRange, 'min') && - !Object.hasOwn(feature.amountRange, 'max') - ) || [], - continuousFeatures: - data?.filter( - (feature) => - Object.hasOwn(feature.amountRange, 'min') && Object.hasOwn(feature.amountRange, 'max') - ) || [], + binaryFeatures: ( + data?.filter(({ amountRange }) => amountRange.min === null && amountRange.max === null) || + [] + ).map((feature) => ({ + ...feature, + color: featureColorQueryState.data?.find(({ id }) => id === feature.id)?.color, + })), + continuousFeatures: ( + data?.filter(({ amountRange }) => amountRange.min !== null && amountRange.max !== null) || + [] + ).map((feature) => ({ + ...feature, + color: featureColorQueryState.data?.find(({ id }) => id === feature.id)?.color, + })), }), + enabled: featureColorQueryState?.status === 'success', } ); - const totalItems = - projectFeaturesQuery.data?.binaryFeatures.length + - projectFeaturesQuery.data?.continuousFeatures.length || 0; - const binaryFeaturesItems = - projectFeaturesQuery.data?.binaryFeatures?.map(({ id, featureClassName }, index) => { - const color = - totalItems > COLORS['features-preview'].ramp.length - ? chroma.scale(COLORS['features-preview'].ramp).colors(totalItems)[index] - : COLORS['features-preview'].ramp[index]; - + projectFeaturesQuery.data?.binaryFeatures?.map(({ id, alias, featureClassName, color }) => { return { id, - name: featureClassName, + name: alias || featureClassName, color, }; }) || []; const continuousFeaturesItems = projectFeaturesQuery.data?.continuousFeatures.map( - ({ id, featureClassName, amountRange = { min: 5000, max: 100000 } }, index) => { - const color = - totalItems > COLORS['features-preview'].ramp.length - ? chroma.scale(COLORS['features-preview'].ramp).colors(totalItems)[index] - : COLORS['features-preview'].ramp[index]; - + ({ id, featureClassName, amountRange, color }) => { return { id, name: featureClassName, @@ -217,7 +216,7 @@ export const useFeaturesLegend = () => { const { color, amountRange } = continuousFeaturesItems.find(({ id }) => id === featureId) || {}; - const newSelectedFeatures = [...selectedFeatures]; + const newSelectedFeatures = [...selectedContinuousFeatures]; const isIncluded = newSelectedFeatures.includes(featureId); if (!isIncluded) { newSelectedFeatures.push(featureId); @@ -225,7 +224,7 @@ export const useFeaturesLegend = () => { const i = newSelectedFeatures.indexOf(featureId); newSelectedFeatures.splice(i, 1); } - dispatch(setSelectedFeatures(newSelectedFeatures)); + dispatch(setSelectedContinuousFeatures(newSelectedFeatures)); dispatch( setLayerSettings({ diff --git a/app/store/slices/projects/[id].ts b/app/store/slices/projects/[id].ts index 84bdcccbc4..253de8f50f 100644 --- a/app/store/slices/projects/[id].ts +++ b/app/store/slices/projects/[id].ts @@ -11,6 +11,7 @@ interface ProjectShowStateProps { layerSettings: Record; selectedCostSurface: CostSurface['id']; selectedFeatures: Feature['id'][]; + selectedContinuousFeatures: Feature['id'][]; selectedWDPAs: WDPA['id'][]; isSidebarOpen: boolean; } @@ -26,6 +27,7 @@ const initialState: ProjectShowStateProps = { }, }, selectedFeatures: [], + selectedContinuousFeatures: [], selectedCostSurface: null, selectedWDPAs: [], isSidebarOpen: true, @@ -75,6 +77,12 @@ const projectsDetailSlice = createSlice({ ) => { state.selectedFeatures = action.payload; }, + setSelectedContinuousFeatures: ( + state, + action: PayloadAction + ) => { + state.selectedContinuousFeatures = action.payload; + }, // COST SURFACE setSelectedCostSurface: ( state, @@ -95,6 +103,7 @@ export const { setSort, setLayerSettings, setSelectedFeatures, + setSelectedContinuousFeatures, setSelectedCostSurface, setSelectedWDPAs, setSidebarVisibility, diff --git a/app/store/slices/scenarios/edit.ts b/app/store/slices/scenarios/edit.ts index b2c901c8c4..b5a7a85a46 100644 --- a/app/store/slices/scenarios/edit.ts +++ b/app/store/slices/scenarios/edit.ts @@ -4,6 +4,7 @@ import type { ScenarioPlanningUnit } from 'hooks/scenarios/types'; import { injectReducer } from 'store'; import { CostSurface } from 'types/api/cost-surface'; +import { Feature } from 'types/api/feature'; import { Solution } from 'types/api/solution'; import { ScenarioSidebarTabs } from 'utils/tabs'; @@ -20,7 +21,8 @@ interface ScenarioEditStateProps { // FEATURES features: Record; featureHoverId: string; - selectedFeatures: string[]; + selectedFeatures: Feature['id'][]; + selectedContinuousFeatures: Feature['id'][]; preHighlightFeatures: string[]; postHighlightFeatures: string[]; @@ -72,6 +74,7 @@ const initialState = { features: [], featureHoverId: null, selectedFeatures: [], + selectedContinuousFeatures: [], preHighlightFeatures: [], postHighlightFeatures: [], @@ -139,9 +142,18 @@ export function getScenarioEditSlice(id) { setFeatureHoverId: (state, action: PayloadAction) => { state.featureHoverId = action.payload; }, - setSelectedFeatures: (state, action: PayloadAction) => { + setSelectedFeatures: ( + state, + action: PayloadAction + ) => { state.selectedFeatures = action.payload; }, + setSelectedContinuousFeatures: ( + state, + action: PayloadAction + ) => { + state.selectedContinuousFeatures = action.payload; + }, setPreHighlightFeatures: (state, action: PayloadAction) => { state.preHighlightFeatures = action.payload; },