From 854a225fde444909372b08f35ee8028d1d8a28f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Prod=27homme?= Date: Wed, 23 Oct 2024 15:10:19 +0200 Subject: [PATCH 1/4] SKY30-404 - Add a GFW layer --- frontend/.env.default | 1 + frontend/src/components/map/index.tsx | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/frontend/.env.default b/frontend/.env.default index 13fe0d4d..528f1a6b 100644 --- a/frontend/.env.default +++ b/frontend/.env.default @@ -3,6 +3,7 @@ NEXT_PUBLIC_GOOGLE_ANALYTICS= NEXT_PUBLIC_MAPBOX_API_TOKEN= HUBSPOT_TOKEN= +NEXT_PUBLIC_GLOBAL_FISHING_WATCH_TOKEN= LOCALAZY_CDN= diff --git a/frontend/src/components/map/index.tsx b/frontend/src/components/map/index.tsx index e1ad69bf..be3c27e9 100644 --- a/frontend/src/components/map/index.tsx +++ b/frontend/src/components/map/index.tsx @@ -135,6 +135,33 @@ export const Map: FC = ({ mapStyle="mapbox://styles/skytruth/clnud2d3100nr01pl3b4icpyw" dragRotate={false} touchZoomRotate={false} + transformRequest={(url) => { + // Global Fishing Watch tilers require authorization token and we're also passing the past + // 12 months as a parameter + if (url.startsWith('https://gateway.api.globalfishingwatch.org/')) { + const endDate = new Date(); + const startDate = new Date(endDate); + startDate.setMonth(endDate.getMonth() - 12); + + const formatDate = (date: Date): string => { + return date.toISOString().split('T')[0]; + }; + + const newURL = url.replace( + '{{LAST_YEAR}}', + `${formatDate(startDate)},${formatDate(endDate)}` + ); + + return { + url: newURL, + headers: { + Authorization: `Bearer ${process.env.NEXT_PUBLIC_GLOBAL_FISHING_WATCH_TOKEN}`, + }, + }; + } + + return null; + }} {...mapboxProps} {...localViewState} > From 3ce653f2a4c543b0b7e1417d227aed401a683553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Prod=27homme?= Date: Wed, 30 Oct 2024 14:52:52 +0100 Subject: [PATCH 2/4] chore(map): Add `useConfig` hook to handle async `parseConfig` --- .../map/content/map/layer-manager/item.tsx | 54 +++++++++---------- .../map/layers-toolbox/legend/item.tsx | 21 +++++--- .../containers/map/content/map/popup/item.tsx | 21 +++++--- frontend/src/hooks/use-config.ts | 17 ++++++ frontend/src/lib/json-converter/index.ts | 4 +- 5 files changed, 71 insertions(+), 46 deletions(-) create mode 100644 frontend/src/hooks/use-config.ts diff --git a/frontend/src/containers/map/content/map/layer-manager/item.tsx b/frontend/src/containers/map/content/map/layer-manager/item.tsx index da53b447..73df681b 100644 --- a/frontend/src/containers/map/content/map/layer-manager/item.tsx +++ b/frontend/src/containers/map/content/map/layer-manager/item.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { useParams } from 'next/navigation'; @@ -8,7 +8,7 @@ import { useLocale } from 'next-intl'; import DeckJsonLayer from '@/components/map/layers/deck-json-layer'; import MapboxLayer from '@/components/map/layers/mapbox-layer'; import { layersInteractiveAtom, layersInteractiveIdsAtom } from '@/containers/map/store'; -import { parseConfig } from '@/lib/json-converter'; +import useConfig from '@/hooks/use-config'; import { useGetLayersId } from '@/types/generated/layer'; import { LayerResponseDataObject } from '@/types/generated/strapi.schemas'; import { Config, LayerTyped } from '@/types/layers'; @@ -31,6 +31,23 @@ const LayerManagerItem = ({ id, beforeId, settings }: LayerManagerItemProps) => const [, setLayersInteractiveIds] = useAtom(layersInteractiveIdsAtom); const { locationCode = 'GLOB' } = useParams(); + const { type, config, params_config } = + (data?.data?.attributes as LayerTyped) ?? ({} as LayerTyped); + + const configParams = useMemo( + () => ({ + config, + params_config, + settings: { + ...settings, + location: locationCode, + }, + }), + [config, locationCode, params_config, settings] + ); + + const parsedConfig = useConfig(configParams); + const handleAddMapboxLayer = useCallback( ({ styles }: Config) => { if (!data?.data?.attributes) return null; @@ -63,29 +80,16 @@ const LayerManagerItem = ({ id, beforeId, settings }: LayerManagerItemProps) => [data?.data?.attributes, id, setLayersInteractive, setLayersInteractiveIds] ); - if (!data?.data?.attributes) return null; - - const { type } = data.data.attributes as LayerTyped; + if (!parsedConfig) { + return null; + } if (type === 'mapbox') { - const { config, params_config } = data.data.attributes; - - const c = parseConfig({ - config, - params_config, - settings: { - ...settings, - location: locationCode, - }, - }); - - if (!c) return null; - return ( @@ -93,16 +97,10 @@ const LayerManagerItem = ({ id, beforeId, settings }: LayerManagerItemProps) => } if (type === 'deckgl') { - const { config, params_config } = data.data.attributes; - const c = parseConfig({ - // TODO: type - config, - params_config, - settings, - }); - - return ; + return ; } + + return null; }; export default LayerManagerItem; diff --git a/frontend/src/containers/map/content/map/layers-toolbox/legend/item.tsx b/frontend/src/containers/map/content/map/layers-toolbox/legend/item.tsx index 06f82a0d..8be64fc5 100644 --- a/frontend/src/containers/map/content/map/layers-toolbox/legend/item.tsx +++ b/frontend/src/containers/map/content/map/layers-toolbox/legend/item.tsx @@ -5,8 +5,8 @@ import Icon from '@/components/ui/icon'; import BoundariesPopup from '@/containers/map/content/map/popup/boundaries'; import GenericPopup from '@/containers/map/content/map/popup/generic'; import ProtectedAreaPopup from '@/containers/map/content/map/popup/protected-area'; +import useConfig from '@/hooks/use-config'; import { cn } from '@/lib/classnames'; -import { parseConfig } from '@/lib/json-converter'; import CircleWithDottedRedStrokeIcon from '@/styles/icons/circle-with-dotted-red-stroke.svg'; import CircleWithFillIcon from '@/styles/icons/circle-with-fill.svg'; import CircleWithoutFillIcon from '@/styles/icons/circle-without-fill.svg'; @@ -34,21 +34,26 @@ const ICONS_MAPPING = { const LegendItem: FCWithMessages = ({ config }) => { const { type, items } = config || {}; - const LEGEND_ITEM_COMPONENT = useMemo(() => { - const l = parseConfig({ + const configParams = useMemo( + () => ({ config, params_config: [], settings: {}, - }); + }), + [config] + ); + + const parsedConfig = useConfig(configParams); - if (!l) return null; + const LEGEND_ITEM_COMPONENT = useMemo(() => { + if (!parsedConfig) return null; - if (isValidElement(l)) { - return l; + if (isValidElement(parsedConfig)) { + return parsedConfig; } return null; - }, [config]); + }, [parsedConfig]); switch (type) { case 'basic': diff --git a/frontend/src/containers/map/content/map/popup/item.tsx b/frontend/src/containers/map/content/map/popup/item.tsx index 95b6d70e..8dab7f1d 100644 --- a/frontend/src/containers/map/content/map/popup/item.tsx +++ b/frontend/src/containers/map/content/map/popup/item.tsx @@ -5,7 +5,7 @@ import { useLocale } from 'next-intl'; import BoundariesPopup from '@/containers/map/content/map/popup/boundaries'; import GenericPopup from '@/containers/map/content/map/popup/generic'; import ProtectedAreaPopup from '@/containers/map/content/map/popup/protected-area'; -import { parseConfig } from '@/lib/json-converter'; +import useConfig from '@/hooks/use-config'; import { FCWithMessages } from '@/types'; import { useGetLayersId } from '@/types/generated/layer'; import { InteractionConfig, LayerTyped } from '@/types/layers'; @@ -27,24 +27,29 @@ const PopupItem: FCWithMessages = ({ id }) => { const { interaction_config, params_config } = attributes; - const INTERACTION_COMPONENT = useMemo(() => { - const l = parseConfig({ + const configParams = useMemo( + () => ({ config: { ...interaction_config, layerId: id, }, params_config, settings: {}, - }); + }), + [id, interaction_config, params_config] + ); + + const parsedConfig = useConfig(configParams); - if (!l) return null; + const INTERACTION_COMPONENT = useMemo(() => { + if (!parsedConfig) return null; - if (isValidElement(l)) { - return l; + if (isValidElement(parsedConfig)) { + return parsedConfig; } return null; - }, [interaction_config, params_config, id]); + }, [parsedConfig]); return INTERACTION_COMPONENT; }; diff --git a/frontend/src/hooks/use-config.ts b/frontend/src/hooks/use-config.ts new file mode 100644 index 00000000..bee65be3 --- /dev/null +++ b/frontend/src/hooks/use-config.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +import { parseConfig } from '@/lib/json-converter'; + +export default function useConfig(params: Parameters>[0]) { + const [config, setConfig] = useState(null); + + useEffect(() => { + const updateConfig = async () => { + setConfig(await parseConfig(params)); + }; + + updateConfig(); + }, [params, setConfig]); + + return config; +} diff --git a/frontend/src/lib/json-converter/index.ts b/frontend/src/lib/json-converter/index.ts index e4aabb9a..934f10ff 100644 --- a/frontend/src/lib/json-converter/index.ts +++ b/frontend/src/lib/json-converter/index.ts @@ -73,11 +73,11 @@ interface ParseConfigurationProps { params_config: unknown; settings: Record; } -export const parseConfig = ({ +export const parseConfig = async ({ config, params_config, settings, -}: ParseConfigurationProps): T | null => { +}: ParseConfigurationProps): Promise => { if (!config || !params_config) { return null; } From c91090190d6bee80c03b76132a499d14fbb79be0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Prod=27homme?= Date: Wed, 30 Oct 2024 18:33:55 +0100 Subject: [PATCH 3/4] feat(map): Allow dynamic params_config objects --- frontend/src/components/map/index.tsx | 15 +--- .../map/content/map/layer-manager/index.tsx | 53 +++++++++---- .../map/content/map/layer-manager/item.tsx | 2 + .../map/layers-toolbox/legend/item.tsx | 28 +------ frontend/src/lib/json-converter/index.ts | 77 ++++++++++++++++--- frontend/src/lib/utils/getters.ts | 59 ++++++++++++++ frontend/src/lib/utils/index.ts | 2 + frontend/src/types/layers.ts | 2 +- 8 files changed, 172 insertions(+), 66 deletions(-) create mode 100644 frontend/src/lib/utils/getters.ts diff --git a/frontend/src/components/map/index.tsx b/frontend/src/components/map/index.tsx index be3c27e9..2eb48172 100644 --- a/frontend/src/components/map/index.tsx +++ b/frontend/src/components/map/index.tsx @@ -139,21 +139,8 @@ export const Map: FC = ({ // Global Fishing Watch tilers require authorization token and we're also passing the past // 12 months as a parameter if (url.startsWith('https://gateway.api.globalfishingwatch.org/')) { - const endDate = new Date(); - const startDate = new Date(endDate); - startDate.setMonth(endDate.getMonth() - 12); - - const formatDate = (date: Date): string => { - return date.toISOString().split('T')[0]; - }; - - const newURL = url.replace( - '{{LAST_YEAR}}', - `${formatDate(startDate)},${formatDate(endDate)}` - ); - return { - url: newURL, + url, headers: { Authorization: `Bearer ${process.env.NEXT_PUBLIC_GLOBAL_FISHING_WATCH_TOKEN}`, }, diff --git a/frontend/src/containers/map/content/map/layer-manager/index.tsx b/frontend/src/containers/map/content/map/layer-manager/index.tsx index b032155f..d1e51172 100644 --- a/frontend/src/containers/map/content/map/layer-manager/index.tsx +++ b/frontend/src/containers/map/content/map/layer-manager/index.tsx @@ -1,4 +1,6 @@ -import { Layer } from 'react-map-gl'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { Layer, useMap } from 'react-map-gl'; import { DeckMapboxOverlayProvider } from '@/components/map/provider'; import { CustomMapProps } from '@/components/map/types'; @@ -8,12 +10,45 @@ import { useSyncMapLayers, } from '@/containers/map/content/map/sync-settings'; -const LayerManager = ({ cursor }: { cursor: CustomMapProps['cursor'] }) => { +const LayerManager = ({}: { cursor: CustomMapProps['cursor'] }) => { + const { default: map } = useMap(); + + const [zoom, setZoom] = useState(map?.getZoom() ?? 1); + const [activeLayers] = useSyncMapLayers(); const [layersSettings] = useSyncMapLayerSettings(); + const getSettings = useCallback( + (l: number) => ({ + ...(layersSettings[l] ?? { opacity: 1, visibility: true, expand: true }), + zoom, + }), + [layersSettings, zoom] + ); + + const layerManagerItems = useMemo( + () => + activeLayers.map((l, i) => { + const beforeId = i === 0 ? 'custom-layers' : `${activeLayers[i - 1]}-layer`; + return ; + }), + [activeLayers, getSettings] + ); + + useEffect(() => { + const onZoom = () => { + setZoom(map.getZoom()); + }; + + map.on('zoomend', onZoom); + + return () => { + map.off('zoomend', onZoom); + }; + }, [map, setZoom]); + return ( - + <> {/* Generate all transparent backgrounds to be able to sort by layers without an error @@ -36,17 +71,7 @@ const LayerManager = ({ cursor }: { cursor: CustomMapProps['cursor'] }) => { Loop through active layers. The id is gonna be used to fetch the current layer and know how to order the layers. The first item will always be at the top of the layers stack */} - {activeLayers.map((l, i) => { - const beforeId = i === 0 ? 'custom-layers' : `${activeLayers[i - 1]}-layer`; - return ( - - ); - })} + {layerManagerItems} ); diff --git a/frontend/src/containers/map/content/map/layer-manager/item.tsx b/frontend/src/containers/map/content/map/layer-manager/item.tsx index 73df681b..6f8af8a1 100644 --- a/frontend/src/containers/map/content/map/layer-manager/item.tsx +++ b/frontend/src/containers/map/content/map/layer-manager/item.tsx @@ -1,5 +1,7 @@ import { useCallback, useMemo } from 'react'; +import { useMap } from 'react-map-gl'; + import { useParams } from 'next/navigation'; import { useAtom } from 'jotai'; diff --git a/frontend/src/containers/map/content/map/layers-toolbox/legend/item.tsx b/frontend/src/containers/map/content/map/layers-toolbox/legend/item.tsx index 8be64fc5..2b28e0f3 100644 --- a/frontend/src/containers/map/content/map/layers-toolbox/legend/item.tsx +++ b/frontend/src/containers/map/content/map/layers-toolbox/legend/item.tsx @@ -1,11 +1,8 @@ -import { ReactElement, isValidElement, useMemo } from 'react'; - import TooltipButton from '@/components/tooltip-button'; import Icon from '@/components/ui/icon'; import BoundariesPopup from '@/containers/map/content/map/popup/boundaries'; import GenericPopup from '@/containers/map/content/map/popup/generic'; import ProtectedAreaPopup from '@/containers/map/content/map/popup/protected-area'; -import useConfig from '@/hooks/use-config'; import { cn } from '@/lib/classnames'; import CircleWithDottedRedStrokeIcon from '@/styles/icons/circle-with-dotted-red-stroke.svg'; import CircleWithFillIcon from '@/styles/icons/circle-with-fill.svg'; @@ -15,7 +12,7 @@ import EstablishmentImplementedIcon from '@/styles/icons/implemented.svg'; import EstablishmentManagedIcon from '@/styles/icons/managed.svg'; import EstablishmentProposedIcon from '@/styles/icons/proposed.svg'; import { FCWithMessages } from '@/types'; -import { LayerTyped, LegendConfig } from '@/types/layers'; +import { LayerTyped } from '@/types/layers'; export interface LegendItemsProps { config: LayerTyped['legend_config']; @@ -34,27 +31,6 @@ const ICONS_MAPPING = { const LegendItem: FCWithMessages = ({ config }) => { const { type, items } = config || {}; - const configParams = useMemo( - () => ({ - config, - params_config: [], - settings: {}, - }), - [config] - ); - - const parsedConfig = useConfig(configParams); - - const LEGEND_ITEM_COMPONENT = useMemo(() => { - if (!parsedConfig) return null; - - if (isValidElement(parsedConfig)) { - return parsedConfig; - } - - return null; - }, [parsedConfig]); - switch (type) { case 'basic': return ( @@ -155,7 +131,7 @@ const LegendItem: FCWithMessages = ({ config }) => { ); default: - return LEGEND_ITEM_COMPONENT; + return null; } }; diff --git a/frontend/src/lib/json-converter/index.ts b/frontend/src/lib/json-converter/index.ts index 934f10ff..da49c1d7 100644 --- a/frontend/src/lib/json-converter/index.ts +++ b/frontend/src/lib/json-converter/index.ts @@ -12,7 +12,7 @@ import BoundariesPopup from '@/containers/map/content/map/popup/boundaries'; import GenericPopup from '@/containers/map/content/map/popup/generic'; import ProtectedAreaPopup from '@/containers/map/content/map/popup/protected-area'; import FUNCTIONS from '@/lib/utils'; -import { ParamsConfig } from '@/types/layers'; +import { ParamsConfig, ParamsConfigValue } from '@/types/layers'; export const JSON_CONFIGURATION = new JSONConfiguration({ React, @@ -34,6 +34,11 @@ export const JSON_CONFIGURATION = new JSONConfiguration({ }, }); +export interface GetParamsProps { + settings: Record; + params_config: ParamsConfig; +} + /** * *`getParams`* * Get params from params_config @@ -41,10 +46,6 @@ export const JSON_CONFIGURATION = new JSONConfiguration({ * @returns {Object} params * */ -export interface GetParamsProps { - settings: Record; - params_config: ParamsConfig; -} export const getParams = ({ params_config, settings = {} }: GetParamsProps) => { if (!params_config) { return {}; @@ -60,6 +61,29 @@ export const getParams = ({ params_config, settings = {} }: GetParamsProps) => { ); }; +/** + * Create a new parameter for the params_config array + * @param name Name of the new parameter + * @param methodName Name of the (async) function that will compute the value of the parameter + * @param params Parameters for the (async) function that will compute the value of the parameter + */ +const createNewParamsConfigParam = async ( + name: string, + methodName: string, + params: Record +): Promise => { + return { + key: name, + default: await FUNCTIONS[methodName](params), + }; +}; + +interface ParseConfigurationProps { + config: unknown; + params_config: unknown; + settings: Record; +} + /** * *`parseConfig`* * Parse config with params_config @@ -68,11 +92,6 @@ export const getParams = ({ params_config, settings = {} }: GetParamsProps) => { * @returns {Object} config * */ -interface ParseConfigurationProps { - config: unknown; - params_config: unknown; - settings: Record; -} export const parseConfig = async ({ config, params_config, @@ -86,13 +105,49 @@ export const parseConfig = async ({ configuration: JSON_CONFIGURATION, }); - const pc = params_config as ParamsConfig; + let pc = params_config as ParamsConfig; + const initParameter = pc.find(({ key }) => key === '_INIT_'); + + // If `initParameter` is defined, then we want to asynchronously create new parameters within `pc` + if (initParameter) { + const initParameterConfig = initParameter.default as Record< + string, + { ['@@function']: string; [attr: string]: unknown } + >; + + const newParams: ParamsConfigValue[] = []; + + for (const [name, attrs] of Object.entries(initParameterConfig)) { + const param = await createNewParamsConfigParam( + name, + attrs['@@function'], + Object.entries(attrs).reduce((res, [key, name]) => { + if (key === '@@function') { + return res; + } + + return { + ...res, + [key]: getParams({ params_config: pc, settings })[ + (name as string).replace('@@#params.', '') + ], + }; + }, {}) + ); + newParams.push(param); + } + + pc = [...pc, ...newParams]; + } + const params = getParams({ params_config: pc, settings }); + // Merge enumerations with config JSON_CONVERTER.mergeConfiguration({ enumerations: { params, }, }); + return JSON_CONVERTER.convert(config); }; diff --git a/frontend/src/lib/utils/getters.ts b/frontend/src/lib/utils/getters.ts new file mode 100644 index 00000000..a6e0bbe2 --- /dev/null +++ b/frontend/src/lib/utils/getters.ts @@ -0,0 +1,59 @@ +export const injectLastYearRange = ({ url }: { url: string }) => { + const endDate = new Date(); + const startDate = new Date(endDate); + startDate.setMonth(endDate.getMonth() - 12); + + const formatDate = (date: Date): string => { + return date.toISOString().split('T')[0]; + }; + + return url.replace('{{LAST_YEAR_RANGE}}', `${formatDate(startDate)},${formatDate(endDate)}`); +}; + +export const injectZoom = ({ url, zoom }: { url: string; zoom: number }) => { + return url.replace('{{ZOOM}}', `${zoom}`); +}; + +export const getFishingEffortBins = async ({ + url, + colors, + zoom, +}: { + url: string; + colors: string[]; + zoom: number; +}) => { + const data = await fetch( + injectZoom({ url: injectLastYearRange({ url }), zoom: Math.min(12, Math.round(zoom)) }), + { + headers: { + Authorization: `Bearer ${process.env.NEXT_PUBLIC_GLOBAL_FISHING_WATCH_TOKEN}`, + }, + } + ).then((res) => res.json()); + + const entries = data.entries[0] as number[]; + + const res = []; + for (let i = 0, j = colors.length - 1; i < j; i++) { + res.push(colors[i] ?? '#FFFFFF'); + res.push(entries?.[i] ?? i * 10000000); + } + + res.push(colors[entries.length - 1] ?? '#FFFFFF'); + + return res; +}; + +export const getAtIndex = ({ array, index }: { array: T[]; index: number }) => { + return array[index]; +}; + +const GETTERS = { + injectZoom, + injectLastYearRange, + getFishingEffortBins, + getAtIndex, +}; + +export default GETTERS; diff --git a/frontend/src/lib/utils/index.ts b/frontend/src/lib/utils/index.ts index 8dadf31a..f2743cfb 100644 --- a/frontend/src/lib/utils/index.ts +++ b/frontend/src/lib/utils/index.ts @@ -1,7 +1,9 @@ import FORMATS from './formats'; +import GETTERS from './getters'; import SETTERS from './setters'; const ALL = { + ...GETTERS, ...SETTERS, ...FORMATS, }; diff --git a/frontend/src/types/layers.ts b/frontend/src/types/layers.ts index bda4dff3..fd4375c1 100644 --- a/frontend/src/types/layers.ts +++ b/frontend/src/types/layers.ts @@ -13,7 +13,7 @@ export type ParamsConfigValue = { default: unknown; }; -export type ParamsConfig = Record[]; +export type ParamsConfig = ParamsConfigValue[]; export type LegendConfig = { type?: 'basic' | 'icon' | 'gradient' | 'choropleth'; From d4ecd7557f7d2df3a625fc5ef9bf6a72db391d42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Prod=27homme?= Date: Wed, 30 Oct 2024 19:44:13 +0100 Subject: [PATCH 4/4] feat(map): Allow dynamic legend_config objects --- .../map/content/map/layer-manager/item.tsx | 6 +- .../map/layers-toolbox/legend/index.tsx | 57 +++++++++----- .../map/layers-toolbox/legend/item.tsx | 60 +++++++++++++-- .../containers/map/content/map/popup/item.tsx | 4 +- .../{use-config.ts => use-resolved-config.ts} | 4 +- .../src/hooks/use-resolved-params-config.ts | 21 +++++ frontend/src/lib/json-converter/index.ts | 76 +++++++++++-------- frontend/src/lib/utils/formats.ts | 6 ++ frontend/src/lib/utils/getters.ts | 44 +++++++++++ 9 files changed, 218 insertions(+), 60 deletions(-) rename frontend/src/hooks/{use-config.ts => use-resolved-config.ts} (76%) create mode 100644 frontend/src/hooks/use-resolved-params-config.ts diff --git a/frontend/src/containers/map/content/map/layer-manager/item.tsx b/frontend/src/containers/map/content/map/layer-manager/item.tsx index 6f8af8a1..8e1258fa 100644 --- a/frontend/src/containers/map/content/map/layer-manager/item.tsx +++ b/frontend/src/containers/map/content/map/layer-manager/item.tsx @@ -1,7 +1,5 @@ import { useCallback, useMemo } from 'react'; -import { useMap } from 'react-map-gl'; - import { useParams } from 'next/navigation'; import { useAtom } from 'jotai'; @@ -10,7 +8,7 @@ import { useLocale } from 'next-intl'; import DeckJsonLayer from '@/components/map/layers/deck-json-layer'; import MapboxLayer from '@/components/map/layers/mapbox-layer'; import { layersInteractiveAtom, layersInteractiveIdsAtom } from '@/containers/map/store'; -import useConfig from '@/hooks/use-config'; +import useResolvedConfig from '@/hooks/use-resolved-config'; import { useGetLayersId } from '@/types/generated/layer'; import { LayerResponseDataObject } from '@/types/generated/strapi.schemas'; import { Config, LayerTyped } from '@/types/layers'; @@ -48,7 +46,7 @@ const LayerManagerItem = ({ id, beforeId, settings }: LayerManagerItemProps) => [config, locationCode, params_config, settings] ); - const parsedConfig = useConfig(configParams); + const parsedConfig = useResolvedConfig(configParams); const handleAddMapboxLayer = useCallback( ({ styles }: Config) => { diff --git a/frontend/src/containers/map/content/map/layers-toolbox/legend/index.tsx b/frontend/src/containers/map/content/map/layers-toolbox/legend/index.tsx index 654076dc..b10e1f87 100644 --- a/frontend/src/containers/map/content/map/layers-toolbox/legend/index.tsx +++ b/frontend/src/containers/map/content/map/layers-toolbox/legend/index.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { useLocale, useTranslations } from 'next-intl'; import { HiEye, HiEyeOff } from 'react-icons/hi'; @@ -25,7 +25,7 @@ import { LayerListResponseDataItem, LayerResponseDataObject, } from '@/types/generated/strapi.schemas'; -import { LayerTyped } from '@/types/layers'; +import { LayerTyped, ParamsConfig } from '@/types/layers'; import LegendItem from './item'; @@ -43,7 +43,7 @@ const Legend: FCWithMessages = () => { sort: 'title:asc', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - fields: ['title'], + fields: ['title', 'params_config'], // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore populate: { @@ -147,18 +147,15 @@ const Legend: FCWithMessages = () => { [activeLayers, setMapLayers] ); - return ( -
- {!layersQuery.data?.length && ( -

- {t.rich('open-layers-to-add-to-map', { - b: (chunks) => {chunks}, - })} -

- )} - {layersQuery.data?.length > 0 && ( -
- {layersQuery.data?.map(({ id, attributes: { title, legend_config } }, index) => { + const legendItems = useMemo(() => { + if (!layersQuery.data?.length) { + return null; + } + + return ( +
+ {layersQuery.data?.map( + ({ id, attributes: { title, legend_config, params_config } }, index) => { const isFirst = index === 0; const isLast = index + 1 === layersQuery.data.length; @@ -274,13 +271,39 @@ const Legend: FCWithMessages = () => {
- +
); + } + )} +
+ ); + }, [ + activeLayers.length, + layerSettings, + layersQuery.data, + onChangeLayerOpacity, + onMoveLayerDown, + onMoveLayerUp, + onRemoveLayer, + onToggleLayerVisibility, + t, + ]); + + return ( +
+ {!layersQuery.data?.length && ( +

+ {t.rich('open-layers-to-add-to-map', { + b: (chunks) => {chunks}, })} -

+

)} + {legendItems} ); }; diff --git a/frontend/src/containers/map/content/map/layers-toolbox/legend/item.tsx b/frontend/src/containers/map/content/map/layers-toolbox/legend/item.tsx index 2b28e0f3..e797f4a6 100644 --- a/frontend/src/containers/map/content/map/layers-toolbox/legend/item.tsx +++ b/frontend/src/containers/map/content/map/layers-toolbox/legend/item.tsx @@ -1,8 +1,15 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { useMap } from 'react-map-gl'; + +import { useLocale } from 'next-intl'; + import TooltipButton from '@/components/tooltip-button'; import Icon from '@/components/ui/icon'; import BoundariesPopup from '@/containers/map/content/map/popup/boundaries'; import GenericPopup from '@/containers/map/content/map/popup/generic'; import ProtectedAreaPopup from '@/containers/map/content/map/popup/protected-area'; +import useResolvedParamsConfig from '@/hooks/use-resolved-params-config'; import { cn } from '@/lib/classnames'; import CircleWithDottedRedStrokeIcon from '@/styles/icons/circle-with-dotted-red-stroke.svg'; import CircleWithFillIcon from '@/styles/icons/circle-with-fill.svg'; @@ -12,10 +19,11 @@ import EstablishmentImplementedIcon from '@/styles/icons/implemented.svg'; import EstablishmentManagedIcon from '@/styles/icons/managed.svg'; import EstablishmentProposedIcon from '@/styles/icons/proposed.svg'; import { FCWithMessages } from '@/types'; -import { LayerTyped } from '@/types/layers'; +import { LayerTyped, ParamsConfig } from '@/types/layers'; export interface LegendItemsProps { config: LayerTyped['legend_config']; + paramsConfig: ParamsConfig; } const ICONS_MAPPING = { @@ -28,8 +36,44 @@ const ICONS_MAPPING = { 'establishment-implemented': EstablishmentImplementedIcon, }; -const LegendItem: FCWithMessages = ({ config }) => { - const { type, items } = config || {}; +const LegendItem: FCWithMessages = ({ config, paramsConfig }) => { + const locale = useLocale(); + const { current: map } = useMap(); + + const [zoom, setZoom] = useState(map?.getZoom() ?? 1); + + const resolvedParamsConfigParams = useMemo( + () => ({ + locale, + zoom, + }), + [locale, zoom] + ); + + const resolvedParamsConfig = useResolvedParamsConfig(paramsConfig, resolvedParamsConfigParams); + const dynamicLegendConfig = useMemo( + () => + resolvedParamsConfig?.find(({ key }) => key === 'legend_config')?.default as + | LayerTyped['legend_config']['items'] + | undefined, + [resolvedParamsConfig] + ); + + useEffect(() => { + const onZoom = () => { + setZoom(map.getZoom()); + }; + + map.on('zoomend', onZoom); + + return () => { + map.off('zoomend', onZoom); + }; + }, [map, setZoom]); + + const { type } = config ?? {}; + let { items } = config ?? {}; + items = dynamicLegendConfig ?? items; switch (type) { case 'basic': @@ -93,10 +137,14 @@ const LegendItem: FCWithMessages = ({ config }) => {
    - {items.map(({ value }) => ( + {items.map(({ color, value }, index) => (
  • = ({ id }) => { [id, interaction_config, params_config] ); - const parsedConfig = useConfig(configParams); + const parsedConfig = useResolvedConfig(configParams); const INTERACTION_COMPONENT = useMemo(() => { if (!parsedConfig) return null; diff --git a/frontend/src/hooks/use-config.ts b/frontend/src/hooks/use-resolved-config.ts similarity index 76% rename from frontend/src/hooks/use-config.ts rename to frontend/src/hooks/use-resolved-config.ts index bee65be3..a27610aa 100644 --- a/frontend/src/hooks/use-config.ts +++ b/frontend/src/hooks/use-resolved-config.ts @@ -2,7 +2,9 @@ import { useEffect, useState } from 'react'; import { parseConfig } from '@/lib/json-converter'; -export default function useConfig(params: Parameters>[0]) { +export default function useResolvedConfig( + params: Parameters>[0] +) { const [config, setConfig] = useState(null); useEffect(() => { diff --git a/frontend/src/hooks/use-resolved-params-config.ts b/frontend/src/hooks/use-resolved-params-config.ts new file mode 100644 index 00000000..758ea618 --- /dev/null +++ b/frontend/src/hooks/use-resolved-params-config.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from 'react'; + +import { resolveParamsConfig } from '@/lib/json-converter'; +import { ParamsConfig } from '@/types/layers'; + +export default function useResolvedParamsConfig( + params_config: ParamsConfig, + settings: Record +) { + const [paramsConfig, setParamsConfig] = useState(null); + + useEffect(() => { + const updateParamsConfig = async () => { + setParamsConfig(await resolveParamsConfig(params_config, settings)); + }; + + updateParamsConfig(); + }, [params_config, settings, setParamsConfig]); + + return paramsConfig; +} diff --git a/frontend/src/lib/json-converter/index.ts b/frontend/src/lib/json-converter/index.ts index da49c1d7..59805c67 100644 --- a/frontend/src/lib/json-converter/index.ts +++ b/frontend/src/lib/json-converter/index.ts @@ -50,6 +50,7 @@ export const getParams = ({ params_config, settings = {} }: GetParamsProps) => { if (!params_config) { return {}; } + return params_config.reduce( (acc, p) => { return { @@ -78,35 +79,17 @@ const createNewParamsConfigParam = async ( }; }; -interface ParseConfigurationProps { - config: unknown; - params_config: unknown; - settings: Record; -} - /** - * *`parseConfig`* - * Parse config with params_config - * @param {Object} config - * @param {Object} params_config - * @returns {Object} config - * + * Resolve the final params_config after creating any new eventual dynamic parameter + * @param params_config Initial params_config + * @param settings Values to replace the defaults of the parameters */ -export const parseConfig = async ({ - config, - params_config, - settings, -}: ParseConfigurationProps): Promise => { - if (!config || !params_config) { - return null; - } - - const JSON_CONVERTER = new JSONConverter({ - configuration: JSON_CONFIGURATION, - }); - - let pc = params_config as ParamsConfig; - const initParameter = pc.find(({ key }) => key === '_INIT_'); +export const resolveParamsConfig = async ( + params_config: ParamsConfig, + settings: Record +) => { + let finalParamsConfig = params_config; + const initParameter = finalParamsConfig.find(({ key }) => key === '_INIT_'); // If `initParameter` is defined, then we want to asynchronously create new parameters within `pc` if (initParameter) { @@ -128,7 +111,7 @@ export const parseConfig = async ({ return { ...res, - [key]: getParams({ params_config: pc, settings })[ + [key]: getParams({ params_config: finalParamsConfig, settings })[ (name as string).replace('@@#params.', '') ], }; @@ -137,10 +120,43 @@ export const parseConfig = async ({ newParams.push(param); } - pc = [...pc, ...newParams]; + finalParamsConfig = [...finalParamsConfig, ...newParams]; } - const params = getParams({ params_config: pc, settings }); + return finalParamsConfig; +}; + +interface ParseConfigurationProps { + config: unknown; + params_config: ParamsConfig; + settings: Record; +} + +/** + * *`parseConfig`* + * Parse config with params_config + * @param {Object} config + * @param {Object} params_config + * @returns {Object} config + * + */ +export const parseConfig = async ({ + config, + params_config, + settings, +}: ParseConfigurationProps): Promise => { + if (!config || !params_config) { + return null; + } + + const JSON_CONVERTER = new JSONConverter({ + configuration: JSON_CONFIGURATION, + }); + + const params = getParams({ + params_config: await resolveParamsConfig(params_config, settings), + settings, + }); // Merge enumerations with config JSON_CONVERTER.mergeConfiguration({ diff --git a/frontend/src/lib/utils/formats.ts b/frontend/src/lib/utils/formats.ts index f3e5b7df..144af89f 100644 --- a/frontend/src/lib/utils/formats.ts +++ b/frontend/src/lib/utils/formats.ts @@ -32,6 +32,11 @@ export function formatPercentage( return v.format(displayPercentageSign ? value / 100 : value); } +export function formatNumber(locale: string, value: number, options?: Intl.NumberFormatOptions) { + const formatter = Intl.NumberFormat(locale === 'en' ? 'en-US' : locale, options); + return formatter.format(value); +} + export function formatKM(locale: string, value: number, options?: Intl.NumberFormatOptions) { if (value < 1 && value > 0) return '<1'; @@ -49,6 +54,7 @@ export function formatKM(locale: string, value: number, options?: Intl.NumberFor const FORMATS = { formatPercentage, + formatNumber, formatKM, } as const; diff --git a/frontend/src/lib/utils/getters.ts b/frontend/src/lib/utils/getters.ts index a6e0bbe2..90f350ee 100644 --- a/frontend/src/lib/utils/getters.ts +++ b/frontend/src/lib/utils/getters.ts @@ -1,3 +1,5 @@ +import { formatNumber } from '@/lib/utils/formats'; + export const injectLastYearRange = ({ url }: { url: string }) => { const endDate = new Date(); const startDate = new Date(endDate); @@ -45,6 +47,47 @@ export const getFishingEffortBins = async ({ return res; }; +export const getFishingEffortLegendConfig = async ({ + url, + colors, + zoom, + locale, +}: { + url: string; + colors: string[]; + zoom: number; + locale: string; +}) => { + const data = await fetch( + injectZoom({ url: injectLastYearRange({ url }), zoom: Math.min(12, Math.round(zoom)) }), + { + headers: { + Authorization: `Bearer ${process.env.NEXT_PUBLIC_GLOBAL_FISHING_WATCH_TOKEN}`, + }, + } + ).then((res) => res.json()); + + const entries = data.entries[0] as number[]; + + const res = []; + for (let i = 0, j = colors.length - 1; i <= j; i++) { + const value = entries?.[i] ?? i * 10000000; + res.push({ + color: colors[i] ?? '#FFFFFF', + value: + i % 3 === 0 + ? `${formatNumber(locale, value, { + notation: 'compact', + compactDisplay: 'short', + maximumFractionDigits: 1, + })}${i === colors.length - 1 ? '>=' : ''}` + : '', + }); + } + + return res; +}; + export const getAtIndex = ({ array, index }: { array: T[]; index: number }) => { return array[index]; }; @@ -53,6 +96,7 @@ const GETTERS = { injectZoom, injectLastYearRange, getFishingEffortBins, + getFishingEffortLegendConfig, getAtIndex, };