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 da53b447..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,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 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'; @@ -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 = useResolvedConfig(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/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 06f82a0d..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,12 +1,16 @@ -import { ReactElement, isValidElement, useMemo } from 'react'; +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 { 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'; @@ -15,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, LegendConfig } from '@/types/layers'; +import { LayerTyped, ParamsConfig } from '@/types/layers'; export interface LegendItemsProps { config: LayerTyped['legend_config']; + paramsConfig: ParamsConfig; } const ICONS_MAPPING = { @@ -31,24 +36,44 @@ const ICONS_MAPPING = { 'establishment-implemented': EstablishmentImplementedIcon, }; -const LegendItem: FCWithMessages = ({ config }) => { - const { type, items } = config || {}; - - const LEGEND_ITEM_COMPONENT = useMemo(() => { - const l = parseConfig({ - config, - params_config: [], - settings: {}, - }); - - if (!l) return null; - - if (isValidElement(l)) { - return l; - } - - return null; - }, [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': @@ -112,10 +137,14 @@ const LegendItem: FCWithMessages = ({ config }) => {
    - {items.map(({ value }) => ( + {items.map(({ color, value }, index) => (
  • = ({ config }) => { ); default: - return LEGEND_ITEM_COMPONENT; + return null; } }; diff --git a/frontend/src/containers/map/content/map/popup/item.tsx b/frontend/src/containers/map/content/map/popup/item.tsx index 95b6d70e..7a0e1c4b 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 useResolvedConfig from '@/hooks/use-resolved-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 = useResolvedConfig(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-resolved-config.ts b/frontend/src/hooks/use-resolved-config.ts new file mode 100644 index 00000000..a27610aa --- /dev/null +++ b/frontend/src/hooks/use-resolved-config.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'react'; + +import { parseConfig } from '@/lib/json-converter'; + +export default function useResolvedConfig( + 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/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 e4aabb9a..59805c67 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,14 +46,11 @@ 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 {}; } + return params_config.reduce( (acc, p) => { return { @@ -60,6 +62,76 @@ 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), + }; +}; + +/** + * 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 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) { + 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: finalParamsConfig, settings })[ + (name as string).replace('@@#params.', '') + ], + }; + }, {}) + ); + newParams.push(param); + } + + finalParamsConfig = [...finalParamsConfig, ...newParams]; + } + + return finalParamsConfig; +}; + +interface ParseConfigurationProps { + config: unknown; + params_config: ParamsConfig; + settings: Record; +} + /** * *`parseConfig`* * Parse config with params_config @@ -68,16 +140,11 @@ export const getParams = ({ params_config, settings = {} }: GetParamsProps) => { * @returns {Object} config * */ -interface ParseConfigurationProps { - config: unknown; - 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; } @@ -86,13 +153,17 @@ export const parseConfig = ({ configuration: JSON_CONFIGURATION, }); - const pc = params_config as ParamsConfig; - const params = getParams({ params_config: pc, settings }); + const params = getParams({ + params_config: await resolveParamsConfig(params_config, settings), + settings, + }); + // Merge enumerations with config JSON_CONVERTER.mergeConfiguration({ enumerations: { params, }, }); + return JSON_CONVERTER.convert(config); }; 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 new file mode 100644 index 00000000..90f350ee --- /dev/null +++ b/frontend/src/lib/utils/getters.ts @@ -0,0 +1,103 @@ +import { formatNumber } from '@/lib/utils/formats'; + +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 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]; +}; + +const GETTERS = { + injectZoom, + injectLastYearRange, + getFishingEffortBins, + getFishingEffortLegendConfig, + 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';