From a3ec5e7b7f7806ca6b8f275780fb6a9a2f381070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Gonz=C3=A1lez=20Mu=C3=B1oz?= Date: Mon, 11 Sep 2023 18:33:34 +0200 Subject: [PATCH] adds inventory legend --- app/components/collapsible/index.tsx | 9 + .../map/legend/component.stories.tsx | 83 ---------- app/components/map/legend/component.tsx | 96 ----------- app/components/map/legend/group/index.tsx | 58 +++++++ app/components/map/legend/index.ts | 1 - app/components/map/legend/index.tsx | 63 +++++++ app/components/map/legend/item/index.ts | 1 - .../legend/item/{component.tsx => index.tsx} | 30 ++-- .../map/legend/types/basic/index.ts | 1 - .../types/basic/{component.tsx => index.tsx} | 2 + .../map/legend/types/choropleth/index.ts | 1 - .../choropleth/{component.tsx => index.tsx} | 0 .../map/legend/types/gradient/index.ts | 1 - .../gradient/{component.tsx => index.tsx} | 2 +- .../map/legend/types/matrix/index.ts | 1 - .../types/matrix/{component.tsx => index.tsx} | 0 app/hooks/cost-surface/index.ts | 4 +- app/hooks/map/constants.tsx | 154 ++++++++++++++++++ app/hooks/map/index.ts | 18 +- app/hooks/map/types.ts | 3 +- .../inventory-panel/cost-surfaces/index.tsx | 4 +- .../inventory-panel/features/index.tsx | 14 +- app/layout/projects/show/map/index.tsx | 150 ++++++++++++----- .../projects/show/map/legend/hooks/index.ts | 149 +++++++++++++++++ app/layout/scenarios/edit/map/component.tsx | 2 + app/package.json | 1 + app/store/slices/projects/[id].ts | 12 +- app/yarn.lock | 28 ++++ 28 files changed, 627 insertions(+), 261 deletions(-) create mode 100644 app/components/collapsible/index.tsx delete mode 100644 app/components/map/legend/component.stories.tsx delete mode 100644 app/components/map/legend/component.tsx create mode 100644 app/components/map/legend/group/index.tsx delete mode 100644 app/components/map/legend/index.ts create mode 100644 app/components/map/legend/index.tsx delete mode 100644 app/components/map/legend/item/index.ts rename app/components/map/legend/item/{component.tsx => index.tsx} (87%) delete mode 100644 app/components/map/legend/types/basic/index.ts rename app/components/map/legend/types/basic/{component.tsx => index.tsx} (95%) delete mode 100644 app/components/map/legend/types/choropleth/index.ts rename app/components/map/legend/types/choropleth/{component.tsx => index.tsx} (100%) delete mode 100644 app/components/map/legend/types/gradient/index.ts rename app/components/map/legend/types/gradient/{component.tsx => index.tsx} (96%) delete mode 100644 app/components/map/legend/types/matrix/index.ts rename app/components/map/legend/types/matrix/{component.tsx => index.tsx} (100%) create mode 100644 app/layout/projects/show/map/legend/hooks/index.ts diff --git a/app/components/collapsible/index.tsx b/app/components/collapsible/index.tsx new file mode 100644 index 0000000000..9605c4e41a --- /dev/null +++ b/app/components/collapsible/index.tsx @@ -0,0 +1,9 @@ +import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'; + +const Collapsible = CollapsiblePrimitive.Root; + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/app/components/map/legend/component.stories.tsx b/app/components/map/legend/component.stories.tsx deleted file mode 100644 index 50761445d8..0000000000 --- a/app/components/map/legend/component.stories.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import React, { useCallback, useMemo, useState } from 'react'; - -import { Story } from '@storybook/react/types-6-0'; - -import Legend, { LegendProps } from './component'; -import LegendItem from './item'; -import ITEMS from './mock'; -import LegendTypeBasic from './types/basic'; -import LegendTypeChoropleth from './types/choropleth'; -import LegendTypeGradient from './types/gradient'; -import LegendTypeMatrix from './types/matrix'; - -export default { - title: 'Components/Map/Legend', - component: Legend, -}; - -const Template: Story = (args) => { - const { sortable } = args; - const [sortArray, setSortArray] = useState([]); - // Sorted - const sortedItems = useMemo(() => { - return ITEMS.sort((a, b) => { - return sortArray.indexOf(a.id) - sortArray.indexOf(b.id); - }); - }, [sortArray]); - - // Callbacks - const onChangeOrder = useCallback((ids) => { - setSortArray(ids); - }, []); - - return ( - - {sortedItems.map((i) => { - const { type, items, intersections } = i; - - return ( - - {type === 'matrix' && ( - - )} - {type === 'basic' && ( - - )} - {type === 'choropleth' && ( - - )} - {type === 'gradient' && ( - - )} - - ); - })} - - ); -}; - -export const Default = Template.bind({}); -Default.args = { - className: '', -}; - -export const Sortable = Template.bind({}); -Sortable.args = { - className: '', - sortable: { - enabled: true, - }, -}; - -export const SortableHandle = Template.bind({}); -SortableHandle.args = { - className: '', - sortable: { - enabled: true, - handle: true, - }, -}; diff --git a/app/components/map/legend/component.tsx b/app/components/map/legend/component.tsx deleted file mode 100644 index 174c35de6a..0000000000 --- a/app/components/map/legend/component.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import React, { useMemo, useCallback, Children, isValidElement } from 'react'; - -import cx from 'classnames'; - -import Icon from 'components/icon'; - -import LEGEND_SVG from 'svgs/map/legend.svg?sprite'; -import ARROW_DOWN_SVG from 'svgs/ui/arrow-down.svg?sprite'; - -import SortableList from './sortable/list'; - -export interface LegendProps { - open: boolean; - className?: string; - children: React.ReactNode; - maxHeight: string | number; - sortable?: { - enabled: boolean; - handle: boolean; - handleIcon: React.ReactNode; - }; - onChangeOrder?: (id: string[]) => void; - onChangeOpen?: (open: boolean) => void; -} - -export const Legend: React.FC = ({ - open, - children, - className = '', - maxHeight, - sortable, - onChangeOpen, - onChangeOrder, -}: LegendProps) => { - const isChildren = useMemo(() => { - return !!Children.count(Children.toArray(children).filter((c) => isValidElement(c))); - }, [children]); - - const onToggleOpen = useCallback(() => { - onChangeOpen(!open); - }, [open, onChangeOpen]); - - return ( -
- - - {open && isChildren && ( -
-
-
-
- {!!sortable && ( - - {children} - - )} - - {!sortable && children} -
-
-
-
- )} -
- ); -}; - -export default Legend; diff --git a/app/components/map/legend/group/index.tsx b/app/components/map/legend/group/index.tsx new file mode 100644 index 0000000000..3bf3ad38df --- /dev/null +++ b/app/components/map/legend/group/index.tsx @@ -0,0 +1,58 @@ +import { ComponentProps, PropsWithChildren, useState } from 'react'; + +import { HiChevronDown, HiChevronUp } from 'react-icons/hi'; + +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from 'components/collapsible'; +import { cn } from 'utils/cn'; + +const ICON_COMMON_CLASSES = + 'text-white group-data-[state=open]:text-blue-400 group-data-[disabled]:hidden'; + +const LegendGroup = ({ + title, + children, + defaultOpen = true, + className, + disabled = false, + ...props +}: PropsWithChildren< + ComponentProps & { + title: string; + } +>): JSX.Element => { + const [isOpen, setOpen] = useState(defaultOpen && !disabled); + + return ( + + +
+ {isOpen ? ( + + ) : ( + + )} +

+ {title} +

+
+
+ {children} +
+ ); +}; + +export default LegendGroup; diff --git a/app/components/map/legend/index.ts b/app/components/map/legend/index.ts deleted file mode 100644 index b404d7fd44..0000000000 --- a/app/components/map/legend/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './component'; diff --git a/app/components/map/legend/index.tsx b/app/components/map/legend/index.tsx new file mode 100644 index 0000000000..5c9773e813 --- /dev/null +++ b/app/components/map/legend/index.tsx @@ -0,0 +1,63 @@ +import React, { useMemo, Children, isValidElement } from 'react'; + +import { ScrollArea } from 'components/scroll-area'; +import { cn } from 'utils/cn'; + +import SortableList from './sortable/list'; + +export interface LegendProps { + open: boolean; + className?: string; + children: React.ReactNode; + maxHeight: string | number; + sortable?: { + enabled: boolean; + handle: boolean; + handleIcon: React.ReactNode; + }; + onChangeOrder?: (id: string[]) => void; + onChangeOpen?: (open: boolean) => void; +} + +export const Legend: React.FC = ({ + open, + children, + className = '', + maxHeight, + sortable, + onChangeOrder, +}: LegendProps) => { + const isChildren = useMemo(() => { + return !!Children.count(Children.toArray(children).filter((c) => isValidElement(c))); + }, [children]); + + return ( +
+ {open && isChildren && ( + +
+ {!!sortable && ( + + {children} + + )} + + {!sortable && children} +
+
+ )} +
+ ); +}; + +export default Legend; diff --git a/app/components/map/legend/item/index.ts b/app/components/map/legend/item/index.ts deleted file mode 100644 index b404d7fd44..0000000000 --- a/app/components/map/legend/item/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './component'; diff --git a/app/components/map/legend/item/component.tsx b/app/components/map/legend/item/index.tsx similarity index 87% rename from app/components/map/legend/item/component.tsx rename to app/components/map/legend/item/index.tsx index 2fa98812aa..42f2db4bbf 100644 --- a/app/components/map/legend/item/component.tsx +++ b/app/components/map/legend/item/index.tsx @@ -1,6 +1,7 @@ import React, { Children, isValidElement, ReactNode, useMemo, useState } from 'react'; import { useNumberFormatter } from '@react-aria/i18n'; +import { HiEye, HiEyeOff } from 'react-icons/hi'; import Slider from 'components/forms/slider'; import Icon from 'components/icon'; @@ -35,7 +36,7 @@ export interface LegendItemProps { }; theme?: 'dark' | 'light'; className?: string; - onChangeOpacity?: () => void; + onChangeOpacity?: (opacity: number) => void; onChangeVisibility?: () => void; } @@ -74,21 +75,20 @@ export const LegendItem: React.FC = ({
-
+
- {icon &&
{icon}
} + {icon ?? null}
= ({ )} -
+
{settingsManager?.opacity && (
= ({ aria-label="manage-opacity" type="button" className={cn({ - 'flex h-5 w-5 items-center justify-center text-white': true, - 'text-gray-300': opacity !== 1, + 'flex h-5 w-5 items-center justify-center text-gray-300': true, + 'text-white': opacity, })} > @@ -183,7 +183,11 @@ export const LegendItem: React.FC = ({ 'text-gray-300': !visibility, })} > - + {visibility ? ( + + ) : ( + + )}
@@ -191,9 +195,9 @@ export const LegendItem: React.FC = ({
-
{description}
+ {description &&
{description}
} - {validChildren &&
{children}
} + {validChildren && children}
); }; diff --git a/app/components/map/legend/types/basic/index.ts b/app/components/map/legend/types/basic/index.ts deleted file mode 100644 index b404d7fd44..0000000000 --- a/app/components/map/legend/types/basic/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './component'; diff --git a/app/components/map/legend/types/basic/component.tsx b/app/components/map/legend/types/basic/index.tsx similarity index 95% rename from app/components/map/legend/types/basic/component.tsx rename to app/components/map/legend/types/basic/index.tsx index ef6e8dbc8e..e1d9ecebea 100644 --- a/app/components/map/legend/types/basic/component.tsx +++ b/app/components/map/legend/types/basic/index.tsx @@ -14,6 +14,8 @@ export const LegendTypeBasic: React.FC = ({ className = '', items, }: LegendTypeBasicProps) => { + if (items.length === 0) return null; + return (
= ({ >
( }, params, }).then(({ data }) => mockData), - enabled: Boolean(pid), + // TODO: enable this when the endpoint is ready + enabled: false, + // enabled: Boolean(pid), ...queryOptions, }); } diff --git a/app/hooks/map/constants.tsx b/app/hooks/map/constants.tsx index f19de55c71..634d6da722 100644 --- a/app/hooks/map/constants.tsx +++ b/app/hooks/map/constants.tsx @@ -1,6 +1,7 @@ import React from 'react'; import chroma from 'chroma-js'; +import { FaSquare } from 'react-icons/fa'; import Icon from 'components/icon'; @@ -37,6 +38,27 @@ export const COLORS = { 'wdpa-preview': '#00f', features: '#6F53F7', highlightFeatures: '#BE6BFF', + abundance: { + default: '#FFF', + ramp: [ + '#4b5eef', + '#f15100', + '#31a904', + '#2c18bd', + '#bf3220', + '#9d2e38', + '#e5e001', + '#f15100', + '#f4af00', + '#218134', + '#775b32', + '#cb9c00', + '#294635', + '#ba5da9', + '#5c3b85', + '#de4210', + ], + }, include: '#03E7D1', exclude: '#FF472E', available: '#FFCA42', @@ -187,6 +209,51 @@ export const LEGEND_LAYERS = { visibility: true, }, }), + 'designated-areas': (options: { items: { name: string }[] }) => { + const { items } = options; + + return items.map(({ name }) => ({ + id: `designated-areas-${name}`, + name, + type: 'basic', + icon: ( + + ), + settingsManager: { + opacity: true, + visibility: true, + }, + items: [], + })); + }, + + 'features-preview-new': (options: { items: { id: string; name: string }[] }) => { + const { items } = options; + + return items.map(({ name, id }, index) => { + const COLOR = + items.length > COLORS['features-preview'].ramp.length + ? chroma.scale(COLORS['features-preview'].ramp).colors(items.length)[index] + : COLORS['features-preview'].ramp[index]; + + return { + id, + name, + type: 'basic', + icon: ( + + ), + settingsManager: { + opacity: true, + visibility: true, + }, + items: [], + }; + }); + }, 'features-preview': (options: UseLegend['options']) => { const { items } = options; @@ -244,6 +311,93 @@ export const LEGEND_LAYERS = { }), // ANALYSIS + ['features-abundance']: (options: { items: { min: number; max: number; name: string }[] }) => { + const { items } = options; + + return items.map(({ name, min, max }, index) => ({ + id: `features-abundance-${name}`, + name, + type: 'gradient', + settingsManager: { + opacity: true, + visibility: true, + }, + items: [ + { + color: COLORS.abundance.default, + value: `${min === max ? 0 : min}`, + }, + { + color: COLORS.abundance.ramp[index], + value: `${max}`, + }, + ], + })); + + // return { + // id: 'features-abundance', + // name: options.abundance.name, + // type: 'gradient', + // settingsManager: { + // opacity: true, + // visibility: true, + // }, + // items: [ + // { + // color: COLORS.abundance.default, + // value: `${abundance.min === abundance.max ? 0 : abundance.min}`, + // }, + // { + // color: COLORS.abundance[options.abundance.name.index], + // value: `${abundance.max}`, + // }, + // ], + // }; + }, + // !this config aims to replace the original cost config + 'cost-surface': (options: { items: { min: number; max: number; name: string }[] }) => { + const { items } = options; + + return items.map(({ name, min, max }) => ({ + id: `cost-surface-${name}`, + name, + type: 'gradient', + settingsManager: { + opacity: true, + visibility: true, + }, + items: [ + { + color: COLORS.cost[0], + value: `${min === max ? 0 : min}`, + }, + { + color: COLORS.cost[1], + value: `${max}`, + }, + ], + })); + + // return { + // id: 'cost-surface', + // name: 'Cost Surface', + // type: 'gradient', + // settingsManager: { + // opacity: true, + // visibility: true, + // }, + // items: [ + // { + // color: COLORS.cost[0], + // value: `${min === max ? 0 : min}`, + // }, + // { + // color: COLORS.cost[1], + // value: `${max}`, + // }, + // ], + // }; + }, cost: (options) => { const { cost = { diff --git a/app/hooks/map/index.ts b/app/hooks/map/index.ts index 1fbe551d07..8c2133e6d2 100644 --- a/app/hooks/map/index.ts +++ b/app/hooks/map/index.ts @@ -281,7 +281,7 @@ export function useFeaturePreviewLayers({ return useMemo(() => { if (!active || !bbox || !features) return []; - const { featuresRecipe = [], selectedFeatures = [] } = options; + const { featuresRecipe = [], selectedFeatures = [], layerSettings } = options; const FEATURES = [...features] .filter((ft) => selectedFeatures.includes(ft.id as string)) @@ -291,12 +291,13 @@ export function useFeaturePreviewLayers({ return bIndex - aIndex; }); - const { opacity = 1, visibility = true } = options || {}; - - const getLayerVisibility = () => { + const getLayerVisibility = ( + visibility: (typeof layerSettings)[string]['visibility'] + ): 'visible' | 'none' => { if (!visibility) { return 'none'; } + return 'visible'; }; @@ -304,6 +305,7 @@ export function useFeaturePreviewLayers({ const { id } = f; const F = featuresRecipe.find((fr) => fr.id === id) || f; + const settings = layerSettings[id] || {}; const COLOR = selectedFeatures.length > COLORS['features-preview'].ramp.length @@ -337,11 +339,11 @@ export function useFeaturePreviewLayers({ ], }), layout: { - visibility: getLayerVisibility(), + visibility: getLayerVisibility(settings?.visibility), }, paint: { 'fill-color': COLOR, - 'fill-opacity': opacity, + 'fill-opacity': settings?.opacity, }, }, { @@ -358,11 +360,11 @@ export function useFeaturePreviewLayers({ ], }), layout: { - visibility: getLayerVisibility(), + visibility: getLayerVisibility(settings?.visibility), }, paint: { 'line-color': '#000', - 'line-opacity': 0.5 * opacity, + 'line-opacity': 0.5 * settings?.opacity, }, }, ], diff --git a/app/hooks/map/types.ts b/app/hooks/map/types.ts index 0de5ab27e8..6df168e01a 100644 --- a/app/hooks/map/types.ts +++ b/app/hooks/map/types.ts @@ -89,8 +89,7 @@ export interface UseFeaturePreviewLayers { featuresRecipe?: Record[]; featureHoverId?: string; selectedFeatures?: Array; - opacity?: number; - visibility?: boolean; + layerSettings?: Record; }; } export interface UseTargetedPreviewLayers { diff --git a/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx index 7fc338cc86..5fb39a4864 100644 --- a/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx @@ -3,7 +3,7 @@ import { useState, useCallback, useEffect, ChangeEvent } from 'react'; import { useRouter } from 'next/router'; import { useAppDispatch, useAppSelector } from 'store/hooks'; -import { setSelectedCostSurface as setVisibleCostSurface } from 'store/slices/projects/[id]'; +import { setSelectedCostSurfaces as setVisibleCostSurface } from 'store/slices/projects/[id]'; import { useProjectCostSurfaces } from 'hooks/cost-surface'; @@ -22,7 +22,7 @@ const COST_SURFACE_TABLE_COLUMNS = [ const InventoryPanelCostSurface = ({ noData: noDataMessage }: { noData: string }): JSX.Element => { const dispatch = useAppDispatch(); - const { selectedCostSurface: visibleCostSurface, search } = useAppSelector( + const { selectedCostSurfaces: visibleCostSurface, search } = useAppSelector( (state) => state['/projects/[id]'] ); 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 b5d4071cba..615417b3f6 100644 --- a/app/layout/project/sidebar/project/inventory-panel/features/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/features/index.tsx @@ -3,7 +3,10 @@ import { useCallback, useState, ChangeEvent, useEffect } from 'react'; import { useRouter } from 'next/router'; import { useAppDispatch, useAppSelector } from 'store/hooks'; -import { setSelectedFeatures as setVisibleFeatures } from 'store/slices/projects/[id]'; +import { + setSelectedFeatures as setVisibleFeatures, + setLayerSettings, +} from 'store/slices/projects/[id]'; import { useAllFeatures } from 'hooks/features'; @@ -98,6 +101,15 @@ const InventoryPanelFeatures = ({ noData: noDataMessage }: { noData: string }): const newSelectedFeatures = [...visibleFeatures]; if (!newSelectedFeatures.includes(featureId)) { newSelectedFeatures.push(featureId); + + dispatch( + setLayerSettings({ + id: featureId, + settings: { + visibility: true, + }, + }) + ); } else { const i = newSelectedFeatures.indexOf(featureId); newSelectedFeatures.splice(i, 1); diff --git a/app/layout/projects/show/map/index.tsx b/app/layout/projects/show/map/index.tsx index ff001b3b19..e639deb8e7 100644 --- a/app/layout/projects/show/map/index.tsx +++ b/app/layout/projects/show/map/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { ComponentProps, useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/router'; @@ -9,6 +9,7 @@ import PluginMapboxGl from '@vizzuality/layer-manager-plugin-mapboxgl'; import { LayerManager, Layer } from '@vizzuality/layer-manager-react'; import { AnimatePresence, motion } from 'framer-motion'; import pick from 'lodash/pick'; +import { FiLayers } from 'react-icons/fi'; import { useAccessToken } from 'hooks/auth'; import { useProjectCostSurfaces } from 'hooks/cost-surface'; @@ -35,6 +36,7 @@ import FitBoundsControl from 'components/map/controls/fit-bounds'; import LoadingControl from 'components/map/controls/loading'; import ZoomControl from 'components/map/controls/zoom'; import Legend from 'components/map/legend'; +import LegendGroup from 'components/map/legend/group'; import LegendItem from 'components/map/legend/item'; import LegendTypeBasic from 'components/map/legend/types/basic'; import LegendTypeChoropleth from 'components/map/legend/types/choropleth'; @@ -46,22 +48,27 @@ import { cn } from 'utils/cn'; import PRINT_SVG from 'svgs/ui/print.svg?sprite'; +// import InventoryLegend from './legend'; +import { useInventoryLegend } from './legend/hooks'; + +const minZoom = 2; +const maxZoom = 20; + export const ProjectMap = (): JSX.Element => { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(true); const [sid1, setSid1] = useState(null); const [sid2, setSid2] = useState(null); const { isSidebarOpen, layerSettings, selectedFeatures: selectedFeaturesIds, - selectedCostSurface: selectedCostSurfaceId, + selectedCostSurfaces: selectedCostSurfaceIds, selectedWDPAs: selectedWDPAsIds, } = useAppSelector((state) => state['/projects/[id]']); + const legendConfig = useInventoryLegend(); const accessToken = useAccessToken(); - const minZoom = 2; - const maxZoom = 20; const [viewport, setViewport] = useState({}); const [bounds, setBounds] = useState(null); const [mapInteractive, setMapInteractive] = useState(false); @@ -113,7 +120,7 @@ export const ProjectMap = (): JSX.Element => { include: 'results,cost', sublayers: [ ...(sid1 && !sid2 ? ['frequency'] : []), - ...(!!selectedCostSurfaceId ? ['cost'] : []), + ...(!!selectedCostSurfaceIds ? ['cost'] : []), ], options: { cost: { min: 1, max: 100 }, @@ -142,7 +149,7 @@ export const ProjectMap = (): JSX.Element => { bbox, options: { selectedFeatures: selectedFeaturesIds, - ...layerSettings['features-preview'], + layerSettings, }, }); @@ -176,7 +183,7 @@ export const ProjectMap = (): JSX.Element => { id: cs.id, name: cs.name, })) - .find((cs) => cs.id === selectedCostSurfaceId), + .find((cs) => selectedCostSurfaceIds.includes(cs.id)), keepPreviousData: true, placeholderData: [], } @@ -193,7 +200,7 @@ export const ProjectMap = (): JSX.Element => { const LEGEND = useLegend({ layers: [ ...(!!selectedFeaturesData?.length ? ['features-preview'] : []), - ...(!!selectedCostSurfaceId ? ['cost'] : []), + ...(!!selectedCostSurfaceIds ? ['cost'] : []), ...(!!selectedWDPAsIds?.length ? ['wdpa-preview'] : []), ...(!!sid1 && !sid2 ? ['frequency'] : []), @@ -336,7 +343,7 @@ export const ProjectMap = (): JSX.Element => { ); const onChangeVisibility = useCallback( - (lid) => { + (lid: string) => { const { visibility = true } = layerSettings[lid] || {}; dispatch( setLayerSettings({ @@ -395,6 +402,41 @@ export const ProjectMap = (): JSX.Element => { ); }, [pid, sid1, sid2, projectName, downloadScenarioComparisonReportMutation, addToast]); + const legendMaxHeight = typeof window === 'undefined' ? 0 : window.innerHeight * 0.65; + + const renderLegendItems = ({ + type, + intersections, + items, + }: { + type: 'matrix' | 'basic' | 'choropleth' | 'gradient'; + intersections?: ComponentProps['intersections']; + items: + | ComponentProps['items'] + | ComponentProps['items'] + | ComponentProps['items'] + | ComponentProps['items']; + }) => { + switch (type) { + case 'basic': + return ; + case 'choropleth': + return ; + case 'gradient': + return ; + case 'matrix': + return ( + + ); + default: + return null; + } + }; + return ( {id && ( @@ -495,45 +537,69 @@ export const ProjectMap = (): JSX.Element => { )} {/* Legend */} -
+
+ setOpen(!open)} > - {LEGEND.map((i) => { - const { type, items, intersections } = i; - + {legendConfig.map((c) => { return ( - onChangeOpacity(opacity, i.id)} - onChangeVisibility={() => onChangeVisibility(i.id)} - {...i} - > - {type === 'matrix' && ( - - )} - {type === 'basic' && ( - - )} - {type === 'choropleth' && ( - + - )} - + disabled={!c.layers?.length && !c.subgroups?.[0]?.layers?.length} + > + {c.layers?.map((layer) => ( + onChangeOpacity(opacity, layer.id)} + onChangeVisibility={() => onChangeVisibility(layer.id)} + settings={layerSettings[layer.id]} + {...layer} + > + {renderLegendItems(layer)} + + ))} + {c.subgroups?.map((subgroup) => { + return ( + +
+ {subgroup.layers?.map((layer) => ( + onChangeOpacity(opacity, layer.id)} + onChangeVisibility={() => onChangeVisibility(layer.id)} + settings={layerSettings[layer.id]} + {...layer} + > + {renderLegendItems(layer)} + + ))} +
+
+ ); + })} + ); })}
diff --git a/app/layout/projects/show/map/legend/hooks/index.ts b/app/layout/projects/show/map/legend/hooks/index.ts new file mode 100644 index 0000000000..7e452b5a3f --- /dev/null +++ b/app/layout/projects/show/map/legend/hooks/index.ts @@ -0,0 +1,149 @@ +import { useRouter } from 'next/router'; + +import { useAppSelector } from 'store/hooks'; + +import { useProjectCostSurfaces } from 'hooks/cost-surface'; +import { useProjectFeatures } from 'hooks/features'; +import { LEGEND_LAYERS } from 'hooks/map/constants'; +import { useProjectWDPAs } from 'hooks/wdpa'; + +export const useCostSurfaceLegend = () => { + const { selectedCostSurfaces } = useAppSelector((state) => state['/projects/[id]']); + const { query } = useRouter(); + const { pid } = query as { pid: string }; + + const costSurfaceQuery = useProjectCostSurfaces( + pid, + {}, + { + select: (data) => data.filter((cs) => selectedCostSurfaces.includes(cs.id)), + } + ); + + // todo: uncomment when API is ready + // return LEGEND_LAYERS['cost-surface']({ + // items: costSurfaceQuery.data?.map(({ name, min = 1, max = 8 }) => ({ name, min, max })) || [], + // }); + + return LEGEND_LAYERS['cost-surface']({ + items: [ + { name: 'Cost Surface 2', min: 1, max: 22 }, + { name: 'Cost Surface 4', min: 1, max: 11 }, + { name: 'Cost Surface 5', min: 1, max: 5 }, + ], + }); +}; + +export const useConservationAreasLegend = () => { + const { query } = useRouter(); + const { pid } = query as { pid: string }; + const { selectedWDPAs } = useAppSelector((state) => state['/projects/[id]']); + + const protectedAreaQuery = useProjectWDPAs( + pid, + {}, + { select: (data) => data.filter(({ id }) => selectedWDPAs.includes(id)) } + ); + + // todo: uncomment when API is ready + // return LEGEND_LAYERS['designated-areas']({ + // items: protectedAreaQuery.data?.map(({ id }) => ({ name: id })) || [], + // }); + + return LEGEND_LAYERS['designated-areas']({ + items: [{ name: 'WDPA 1' }, { name: 'WDPA 2' }, { name: 'WDPA 3' }], + }); +}; + +export const useFeatureAbundanceLegend = () => { + const { selectedFeatures } = useAppSelector((state) => state['/projects/[id]']); + const { query } = useRouter(); + const { pid } = query as { pid: string }; + + const projectFeaturesQuery = useProjectFeatures(pid, selectedFeatures); + + // todo: uncomment when API is ready + // return projectFeaturesQuery.data?.map( + // ({ featureClassName: name }, index) => + // LEGEND_LAYERS['features-abundance']({ + // abundance: { + // min: 1, + // max: 8, + // name, + // index, + // }, + // }) || [] + // ); + + return LEGEND_LAYERS['features-abundance']({ + items: [ + { + min: 1, + max: 8, + name: 'feature abundance A', + }, + { + min: 2, + max: 5, + name: 'feature abundance B', + }, + { + min: 8, + max: 34, + name: 'feature abundance C', + }, + ], + }); +}; + +export const useFeatureLegend = () => { + const { selectedFeatures } = useAppSelector((state) => state['/projects/[id]']); + const { query } = useRouter(); + const { pid } = query as { pid: string }; + + const projectFeaturesQuery = useProjectFeatures(pid, selectedFeatures); + + return LEGEND_LAYERS['features-preview-new']({ + items: + projectFeaturesQuery.data?.map(({ id, featureClassName: name }) => ({ + id, + name, + })) || [], + }); +}; + +export const useInventoryLegend = () => { + return [ + { + name: 'Planning Grid', + layers: [LEGEND_LAYERS['pugrid']()], + subgroups: [ + { + name: 'Cost Surface', + layers: useCostSurfaceLegend(), + }, + ], + }, + { + name: 'Designated Areas', + subgroups: [ + { + name: 'Conservation Areas', + layers: useConservationAreasLegend(), + }, + { + name: 'Conservation Areas 2', + layers: useConservationAreasLegend(), + }, + ], + }, + { + name: 'Features (Continuous)', + layers: useFeatureAbundanceLegend(), + }, + { + name: 'Features (Binary)', + layers: useFeatureLegend(), + }, + ]; +}; diff --git a/app/layout/scenarios/edit/map/component.tsx b/app/layout/scenarios/edit/map/component.tsx index 68ec8b4927..d27fe03892 100644 --- a/app/layout/scenarios/edit/map/component.tsx +++ b/app/layout/scenarios/edit/map/component.tsx @@ -613,6 +613,8 @@ export const ScenariosEditMap = (): JSX.Element => { [setLayerSettings, dispatch, layerSettings] ); + console.log(LEGEND); + return (
| []; sort: string; layerSettings: Record; - selectedCostSurface: CostSurface['id']; + selectedCostSurfaces: CostSurface['id']; selectedFeatures: Feature['id'][]; selectedWDPAs: WDPA['id'][]; isSidebarOpen: boolean; @@ -21,7 +21,7 @@ const initialState: ProjectShowStateProps = { sort: '-lastModifiedAt', layerSettings: {}, selectedFeatures: [], - selectedCostSurface: null, + selectedCostSurfaces: null, selectedWDPAs: [], isSidebarOpen: true, } satisfies ProjectShowStateProps; @@ -71,11 +71,11 @@ const projectsDetailSlice = createSlice({ state.selectedFeatures = action.payload; }, // COST SURFACE - setSelectedCostSurface: ( + setSelectedCostSurfaces: ( state, - action: PayloadAction + action: PayloadAction ) => { - state.selectedCostSurface = action.payload; + state.selectedCostSurfaces = action.payload; }, // WDPAs setSelectedWDPAs: (state, action: PayloadAction) => { @@ -90,7 +90,7 @@ export const { setSort, setLayerSettings, setSelectedFeatures, - setSelectedCostSurface, + setSelectedCostSurfaces, setSelectedWDPAs, setSidebarVisibility, } = projectsDetailSlice.actions; diff --git a/app/yarn.lock b/app/yarn.lock index 41b16a6cb7..9399af6b0f 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -3552,6 +3552,33 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-collapsible@npm:1.0.3": + version: 1.0.3 + resolution: "@radix-ui/react-collapsible@npm:1.0.3" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/primitive": 1.0.1 + "@radix-ui/react-compose-refs": 1.0.1 + "@radix-ui/react-context": 1.0.1 + "@radix-ui/react-id": 1.0.1 + "@radix-ui/react-presence": 1.0.1 + "@radix-ui/react-primitive": 1.0.3 + "@radix-ui/react-use-controllable-state": 1.0.1 + "@radix-ui/react-use-layout-effect": 1.0.1 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 26976e4a72a3e0f4b2c62af2898b3e205c3652af46a3b41cda9a43567fe8381d9ef6afb0b29e3214c450b847f4f2099a533cffc5045844ecab290e9fa6114ca9 + languageName: node + linkType: hard + "@radix-ui/react-compose-refs@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-compose-refs@npm:1.0.1" @@ -7887,6 +7914,7 @@ __metadata: "@math.gl/web-mercator": ^3.3.2 "@playwright/test": 1.36.2 "@popperjs/core": ^2.6.0 + "@radix-ui/react-collapsible": 1.0.3 "@radix-ui/react-popover": 1.0.6 "@radix-ui/react-scroll-area": 1.0.4 "@radix-ui/react-switch": 1.0.3