diff --git a/client/package.json b/client/package.json index 15d5285..0c1b31d 100644 --- a/client/package.json +++ b/client/package.json @@ -25,10 +25,12 @@ "@radix-ui/react-label": "2.1.0", "@radix-ui/react-popover": "1.1.2", "@radix-ui/react-radio-group": "1.2.1", + "@radix-ui/react-select": "2.1.2", "@radix-ui/react-separator": "1.1.0", "@radix-ui/react-slider": "1.2.1", "@radix-ui/react-slot": "1.1.0", "@radix-ui/react-switch": "1.1.1", + "@radix-ui/react-tabs": "1.1.1", "@radix-ui/react-tooltip": "1.1.3", "@t3-oss/env-nextjs": "0.11.1", "@tanstack/react-query": "5.59.16", @@ -46,6 +48,7 @@ "react-map-gl": "7.1.7", "tailwind-merge": "2.5.4", "tailwindcss-animate": "1.0.7", + "tailwindcss-border-image": "1.1.2", "typescript-eslint": "8.9.0", "zod": "3.23.8" }, diff --git a/client/public/assets/images/border-image.svg b/client/public/assets/images/border-image.svg new file mode 100644 index 0000000..d7a7791 --- /dev/null +++ b/client/public/assets/images/border-image.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/client/src/app/globals.css b/client/src/app/globals.css index c961aca..acf900f 100644 --- a/client/src/app/globals.css +++ b/client/src/app/globals.css @@ -13,10 +13,10 @@ body { @layer base { :root { --radius: 0.5rem; - --sidebar-background: 0 0% 98%; + --sidebar-background: 0 0% 100%; --sidebar-foreground: 240 5.3% 26.1%; --sidebar-primary: 240 5.9% 10%; - --sidebar-primary-foreground: 0 0% 98%; + --sidebar-primary-foreground: 0 0% 100%; --sidebar-accent: 240 4.8% 95.9%; --sidebar-accent-foreground: 240 5.9% 10%; --sidebar-border: 220 13% 91%; diff --git a/client/src/components/dataset-card/index.tsx b/client/src/components/dataset-card/index.tsx new file mode 100644 index 0000000..168811a --- /dev/null +++ b/client/src/components/dataset-card/index.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; + +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import useMapLayers from "@/hooks/use-map-layers"; +import { DatasetLayersDataItem } from "@/types/generated/strapi.schemas"; + +import { getDefaultReturnPeriod, getDefaultSelectedLayerId, getReturnPeriods } from "./utils"; + +interface DatasetCardProps { + id: number; + name: string; + defaultLayerId: number | undefined; + layers: DatasetLayersDataItem[]; +} + +const DatasetCard = ({ id, name, defaultLayerId, layers }: DatasetCardProps) => { + const [layersConfiguration, { addLayer, updateLayer, removeLayer }] = useMapLayers(); + + const defaultSelectedLayerId = useMemo( + () => getDefaultSelectedLayerId(defaultLayerId, layers, layersConfiguration), + [layers, defaultLayerId, layersConfiguration], + ); + + const defaultSelectedReturnPeriod = useMemo( + () => getDefaultReturnPeriod(defaultSelectedLayerId, layers, layersConfiguration), + [layers, layersConfiguration, defaultSelectedLayerId], + ); + + const [selectedLayerId, setSelectedLayerId] = useState(defaultSelectedLayerId); + const [selectedReturnPeriod, setSelectedReturnPeriod] = useState(defaultSelectedReturnPeriod); + + const isDatasetActive = useMemo(() => { + if (selectedLayerId === undefined) { + return false; + } + + return layersConfiguration.findIndex(({ id }) => id === selectedLayerId) !== -1; + }, [selectedLayerId, layersConfiguration]); + + const layerReturnPeriods = useMemo( + () => getReturnPeriods(selectedLayerId, layers), + [layers, selectedLayerId], + ); + + const onToggleDataset = useCallback( + (active: boolean) => { + if (selectedLayerId === undefined) { + return; + } + + if (!active) { + removeLayer(selectedLayerId); + } else { + addLayer(selectedLayerId, { ["return-period"]: selectedReturnPeriod }); + } + }, + [selectedLayerId, addLayer, removeLayer, selectedReturnPeriod], + ); + + const onChangeSelectedLayer = useCallback( + (stringId: string) => { + const id = Number.parseInt(stringId); + const previousId = selectedLayerId; + const returnPeriod = getDefaultReturnPeriod(id, layers, layersConfiguration); + + setSelectedLayerId(id); + setSelectedReturnPeriod(returnPeriod); + + // If the dataset was active and the layer is changed, we replace the current layer by the new + // one keeping all the same settings (visibility, opacity, etc.) + if (isDatasetActive && previousId !== undefined) { + updateLayer(previousId, { id, ["return-period"]: returnPeriod }); + } + }, + [ + selectedLayerId, + setSelectedLayerId, + isDatasetActive, + updateLayer, + layers, + layersConfiguration, + ], + ); + + const onChangeSelectedReturnPeriod = useCallback( + (stringReturnPeriod: string) => { + const returnPeriod = Number.parseInt(stringReturnPeriod); + + setSelectedReturnPeriod(returnPeriod); + + if (isDatasetActive && selectedLayerId !== undefined) { + updateLayer(selectedLayerId, { ["return-period"]: returnPeriod }); + } + }, + [selectedLayerId, setSelectedReturnPeriod, isDatasetActive, updateLayer], + ); + + return ( +
+
+ +
+ +
+
+
+ + {!!layerReturnPeriods && ( + + )} +
+
+ ); +}; + +export default DatasetCard; diff --git a/client/src/components/dataset-card/utils.ts b/client/src/components/dataset-card/utils.ts new file mode 100644 index 0000000..2d4e0b3 --- /dev/null +++ b/client/src/components/dataset-card/utils.ts @@ -0,0 +1,76 @@ +import useMapLayers from "@/hooks/use-map-layers"; +import { DatasetLayersDataItem } from "@/types/generated/strapi.schemas"; +import { LayerParamsConfig } from "@/types/layer"; + +export const getDefaultSelectedLayerId = ( + defaultLayerId: number | undefined, + layers: DatasetLayersDataItem[], + layersConfiguration: ReturnType[0], +) => { + // The ids of the layers that belong to the dataset + const datasetLayerIds = layers.map(({ id }) => id!); + // The ids of the layers active on the map, probably not from this dataset + const activeLayerIds = layersConfiguration.map(({ id }) => id!); + // The id of the layer that belongs to the dataset and is active, if any + const activeDatasetLayerId = datasetLayerIds.find((id) => activeLayerIds.includes(id)); + + if (activeDatasetLayerId) { + return activeDatasetLayerId; + } + + return defaultLayerId; +}; + +export const getDefaultReturnPeriod = ( + layerId: number | undefined, + layers: DatasetLayersDataItem[], + layersConfiguration: ReturnType[0], +) => { + const layerConfiguration = layersConfiguration.find(({ id }) => id === layerId); + + // If the layer is active and already has a selected return period, we return it + if (layerConfiguration?.["return-period"] !== undefined) { + return layerConfiguration["return-period"]; + } + + // Else we look for the default return period stored in `params_config` + const layer = layers.find(({ id }) => id === layerId); + const defaultReturnPeriod = ( + layer?.attributes!.params_config as LayerParamsConfig | undefined + )?.find(({ key }) => key === "return-period"); + + if ( + !defaultReturnPeriod || + defaultReturnPeriod.default === undefined || + defaultReturnPeriod.default === null + ) { + return undefined; + } + + return defaultReturnPeriod.default as number; +}; + +export const getReturnPeriods = (layerId: number | undefined, layers: DatasetLayersDataItem[]) => { + const layer = layers.find(({ id }) => id === layerId); + if (!layer) { + return undefined; + } + + const returnPeriod = (layer.attributes!.params_config as LayerParamsConfig | undefined)?.find( + ({ key }) => key === "return-period", + ); + if ( + !returnPeriod || + returnPeriod.default === undefined || + returnPeriod.default === null || + returnPeriod.options === undefined || + returnPeriod.options === null + ) { + return undefined; + } + + return { + defaultOption: returnPeriod.default as number, + options: [...(returnPeriod.options as number[])].sort((a, b) => a - b), + }; +}; diff --git a/client/src/components/map/layer-manager/item.tsx b/client/src/components/map/layer-manager/item.tsx index 73f78e5..89daceb 100644 --- a/client/src/components/map/layer-manager/item.tsx +++ b/client/src/components/map/layer-manager/item.tsx @@ -12,12 +12,17 @@ interface LayerManagerItemProps { const LayerManagerItem = ({ id, beforeId, settings }: LayerManagerItemProps) => { const config = useLayerConfig(id, settings); - if (!config) { + if (!config?.styles) { return null; } return ( - + {config.styles.map((style) => ( ))} diff --git a/client/src/components/map/legend/item/basic-legend.tsx b/client/src/components/map/legend/item/basic-legend.tsx index 8e76f97..bc240da 100644 --- a/client/src/components/map/legend/item/basic-legend.tsx +++ b/client/src/components/map/legend/item/basic-legend.tsx @@ -9,7 +9,18 @@ const BasicLegend = (data: BasicLegendProps) => { } if (data.items.length === 1) { - return ; + const item = data.items[0]; + + if (item.value !== null) { + return ( +
+ +
{item.value}
+
+ ); + } else { + return ; + } } return ( diff --git a/client/src/components/map/legend/item/values.tsx b/client/src/components/map/legend/item/values.tsx index 0037229..c877d8c 100644 --- a/client/src/components/map/legend/item/values.tsx +++ b/client/src/components/map/legend/item/values.tsx @@ -16,9 +16,10 @@ const Values = (data: ValuesProps) => { title={item.value} className={cn({ "flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap": true, - "text-left": index === 0, - "text-center": index > 0 && index + 1 < data.items!.length, - "text-right": index + 1 === data.items!.length, + "text-left": data.type === "gradient" && index === 0, + "text-center": + data.type !== "gradient" || (index > 0 && index + 1 < data.items!.length), + "text-right": data.type === "gradient" && index + 1 === data.items!.length, })} > {item.value} diff --git a/client/src/components/navigation/navigation-desktop/index.tsx b/client/src/components/navigation/navigation-desktop/index.tsx index ac241c4..5571b67 100644 --- a/client/src/components/navigation/navigation-desktop/index.tsx +++ b/client/src/components/navigation/navigation-desktop/index.tsx @@ -15,7 +15,7 @@ const NavigationDesktop = () => { -
+
diff --git a/client/src/components/navigation/navigation-mobile/index.tsx b/client/src/components/navigation/navigation-mobile/index.tsx index 5a8f5e4..f3c37bf 100644 --- a/client/src/components/navigation/navigation-mobile/index.tsx +++ b/client/src/components/navigation/navigation-mobile/index.tsx @@ -18,7 +18,10 @@ const NavigationMobile = () => { <> - + {TABS[tab].name} diff --git a/client/src/components/panels/drought/index.tsx b/client/src/components/panels/drought/index.tsx new file mode 100644 index 0000000..6bd7cc2 --- /dev/null +++ b/client/src/components/panels/drought/index.tsx @@ -0,0 +1,9 @@ +const DroughtPanel = () => { + return ( +
+
Coming soon!
+
+ ); +}; + +export default DroughtPanel; diff --git a/client/src/components/panels/flood/index.tsx b/client/src/components/panels/flood/index.tsx new file mode 100644 index 0000000..a51a1c3 --- /dev/null +++ b/client/src/components/panels/flood/index.tsx @@ -0,0 +1,36 @@ +import DatasetCard from "@/components/dataset-card"; +import { Skeleton } from "@/components/ui/skeleton"; +import useDatasetsBySubTopic from "@/hooks/use-datasets-by-sub-topic"; + +const FloodPanel = () => { + const { data, isLoading } = useDatasetsBySubTopic("flood", ["name", "params_config"]); + + return ( +
+ {isLoading && ( +
+ + + + + + +
+ )} + {!isLoading && ( +
+ {data.map(({ subTopic, datasets }) => ( +
+

{subTopic}

+ {datasets.map((dataset) => ( + + ))} +
+ ))} +
+ )} +
+ ); +}; + +export default FloodPanel; diff --git a/client/src/components/panels/hydrometeorological/index.tsx b/client/src/components/panels/hydrometeorological/index.tsx new file mode 100644 index 0000000..db09845 --- /dev/null +++ b/client/src/components/panels/hydrometeorological/index.tsx @@ -0,0 +1,9 @@ +const HydrometeorologicalPanel = () => { + return ( +
+
Coming soon!
+
+ ); +}; + +export default HydrometeorologicalPanel; diff --git a/client/src/components/panels/main/index.tsx b/client/src/components/panels/main/index.tsx index c8fbfd6..dbcb478 100644 --- a/client/src/components/panels/main/index.tsx +++ b/client/src/components/panels/main/index.tsx @@ -1,5 +1,14 @@ +import DatasetTabs from "@/components/topic-tabs"; +import useTopicTabLayerManagement from "@/hooks/use-topic-tab-layer-management"; + const MainPanel = () => { - return
Coming soon!
; + useTopicTabLayerManagement(); + + return ( +
+ +
+ ); }; export default MainPanel; diff --git a/client/src/components/topic-tabs/index.tsx b/client/src/components/topic-tabs/index.tsx new file mode 100644 index 0000000..5f2e472 --- /dev/null +++ b/client/src/components/topic-tabs/index.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { ReactNode, useCallback, useMemo } from "react"; + +import DroughtPanel from "@/components/panels/drought"; +import FloodPanel from "@/components/panels/flood"; +import HydrometeorologicalPanel from "@/components/panels/hydrometeorological"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import useTopicTab from "@/hooks/use-topic-tab"; + +import { TopicTab } from "./types"; + +const TopicTabs = () => { + const [tab, setTab] = useTopicTab(); + + const tabTriggers = useMemo( + () => + Object.entries(TopicTab).map(([name, value]) => ( + + {name} + + )), + [], + ); + + const tabContents = useMemo(() => { + const contentByTopic: Record = { + [TopicTab.Flood]: , + [TopicTab.Drought]: , + [TopicTab.Hydrometeorological]: , + }; + + return Object.values(TopicTab).map((value) => ( + + {contentByTopic[value]} + + )); + }, []); + + const onChangeTab = useCallback( + (tab: string) => { + setTab(tab as TopicTab); + }, + [setTab], + ); + + return ( + + {tabTriggers} + {tabContents} + + ); +}; + +export default TopicTabs; diff --git a/client/src/components/topic-tabs/types.ts b/client/src/components/topic-tabs/types.ts new file mode 100644 index 0000000..6430f78 --- /dev/null +++ b/client/src/components/topic-tabs/types.ts @@ -0,0 +1,5 @@ +export enum TopicTab { + Flood = "flood", + Drought = "drought", + Hydrometeorological = "hydrometeorological", +} diff --git a/client/src/components/ui/select.tsx b/client/src/components/ui/select.tsx new file mode 100644 index 0000000..f7b271f --- /dev/null +++ b/client/src/components/ui/select.tsx @@ -0,0 +1,153 @@ +"use client"; + +import * as SelectPrimitive from "@radix-ui/react-select"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; +import CheckIcon from "@/svgs/check.svg"; +import ChevronDownIcon from "@/svgs/chevron-down.svg"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className, + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/client/src/components/ui/tabs.tsx b/client/src/components/ui/tabs.tsx new file mode 100644 index 0000000..180e52c --- /dev/null +++ b/client/src/components/ui/tabs.tsx @@ -0,0 +1,55 @@ +"use client"; + +import * as TabsPrimitive from "@radix-ui/react-tabs"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/client/src/hooks/use-datasets-by-sub-topic.ts b/client/src/hooks/use-datasets-by-sub-topic.ts index 3566b6c..a2d9724 100644 --- a/client/src/hooks/use-datasets-by-sub-topic.ts +++ b/client/src/hooks/use-datasets-by-sub-topic.ts @@ -3,7 +3,12 @@ import { DatasetLayersDataItem } from "@/types/generated/strapi.schemas"; type DatasetsBySubTopic = { subTopic: string; - datasets: { id: number; name: string; layers: DatasetLayersDataItem[] }[]; + datasets: { + id: number; + name: string; + defaultLayerId: number | undefined; + layers: DatasetLayersDataItem[]; + }[]; }; export default function useDatasetsBySubTopic(topicSlug: string, layersFields = ["name"]) { @@ -18,8 +23,12 @@ export default function useDatasetsBySubTopic(topicSlug: string, layersFields = sub_topic: { fields: ["name"], }, + default_layer: { + fields: ["id"], + }, layers: { fields: layersFields, + sort: "name", }, }, filters: { @@ -47,6 +56,7 @@ export default function useDatasetsBySubTopic(topicSlug: string, layersFields = for (const item of data.data) { const subTopic = item.attributes!.sub_topic!.data!.attributes!.name! as string; const dataset = item.attributes!.name; + const defaultLayerId = item.attributes!.default_layer!.data?.id; const layers = item.attributes!.layers!.data!; if (currentSubTopic === null || currentSubTopic !== subTopic) { @@ -58,6 +68,7 @@ export default function useDatasetsBySubTopic(topicSlug: string, layersFields = res[currentIndex].datasets.push({ id: item.id!, name: dataset, + defaultLayerId, layers, }); } diff --git a/client/src/hooks/use-layer-config.ts b/client/src/hooks/use-layer-config.ts index 27090cc..9f104b2 100644 --- a/client/src/hooks/use-layer-config.ts +++ b/client/src/hooks/use-layer-config.ts @@ -44,6 +44,9 @@ const resolveLayerConfig = ( setVisibility({ v }: { v: boolean }) { return v ? "visible" : "none"; }, + match({ input, outputs }: { input: unknown; outputs: [unknown, unknown][] }) { + return outputs.find(([value]) => input === value)?.[1]; + }, }, enumerations: { params: resolvedParamsConfig, diff --git a/client/src/hooks/use-map-layers.ts b/client/src/hooks/use-map-layers.ts index c55cbb9..1d18480 100644 --- a/client/src/hooks/use-map-layers.ts +++ b/client/src/hooks/use-map-layers.ts @@ -8,6 +8,7 @@ const schema = z.object({ id: z.number(), visibility: z.boolean(), opacity: z.number().min(0).max(1), + "return-period": z.number().int().optional(), }); export default function useMapLayers() { @@ -17,12 +18,13 @@ export default function useMapLayers() { ); const addLayer = useCallback( - (id: number) => { + (id: number, attributes?: Partial>) => { setLayers((layers) => [ { id, visibility: true, opacity: 1, + ...attributes, }, ...layers, ]); diff --git a/client/src/hooks/use-topic-tab-layer-management.ts b/client/src/hooks/use-topic-tab-layer-management.ts new file mode 100644 index 0000000..2fe3ef3 --- /dev/null +++ b/client/src/hooks/use-topic-tab-layer-management.ts @@ -0,0 +1,81 @@ +import { useEffect, useMemo, useRef } from "react"; + +import useDatasetsBySubTopic from "@/hooks/use-datasets-by-sub-topic"; +import useMapLayers from "@/hooks/use-map-layers"; +import useTopicTab from "@/hooks/use-topic-tab"; + +export default function useTopicTabLayerManagement() { + const [tab] = useTopicTab(); + const previousTabRef = useRef(tab); + + const [layers, { addLayer, removeLayer }] = useMapLayers(); + + const { data, isLoading } = useDatasetsBySubTopic(tab); + + // The ids of all the layers that belong to the topic + const topicLayerIds = useMemo(() => { + if (isLoading) { + return []; + } + + return data + .map(({ datasets }) => datasets.map(({ layers }) => layers.map(({ id }) => id!))) + .flat(Infinity) as number[]; + }, [data, isLoading]); + + const previousTopicLayerIdsRef = useRef(topicLayerIds); + + // The ids of all the active layers that belong to the topic + const activeTopicLayerIds = useMemo(() => { + const activeLayerIds = layers.map(({ id }) => id!); + return topicLayerIds.filter((id) => activeLayerIds.includes(id)); + }, [topicLayerIds, layers]); + + // The id of the layer that should be active by default + const defaultActiveLayerId = useMemo(() => { + if (isLoading) { + return undefined; + } + + const firstDataset = data[0]?.datasets[0]; + return firstDataset?.defaultLayerId; + }, [data, isLoading]); + + const previousDefaultActiveLayerIdRef = useRef(defaultActiveLayerId); + + // We toggle on the default layer of the first dataset when entering the tab (topic) i.e. when: + // 1. There is no active layer from the topic + // 2. We have a default layer (i.e. `defaultActiveLayerId !== undefined`) + // 3. One of these two (OR condition): + // a) We've just gotten a value for `defaultActiveLayerId` + // b) We've just entered the tab (topic) + // Condition 3a is important to avoid activating the default layer when the user remove all the + // topic's layers from the map + useEffect(() => { + if ( + activeTopicLayerIds.length === 0 && + defaultActiveLayerId !== undefined && + (previousDefaultActiveLayerIdRef.current === undefined || tab !== previousTabRef.current) + ) { + addLayer(defaultActiveLayerId); + } + + previousDefaultActiveLayerIdRef.current = defaultActiveLayerId; + }, [activeTopicLayerIds, defaultActiveLayerId, tab, addLayer]); + + // We remove the tab's (topic's) layers from the map when switching to a different tab (topic) + useEffect(() => { + if ( + tab !== previousTabRef.current && + !!previousTabRef.current && + !!previousTopicLayerIdsRef.current + ) { + previousTopicLayerIdsRef.current.forEach((id) => { + removeLayer(id); + }); + } + + previousTabRef.current = tab; + previousTopicLayerIdsRef.current = topicLayerIds; + }, [tab, topicLayerIds, removeLayer]); +} diff --git a/client/src/hooks/use-topic-tab.ts b/client/src/hooks/use-topic-tab.ts new file mode 100644 index 0000000..d89e01d --- /dev/null +++ b/client/src/hooks/use-topic-tab.ts @@ -0,0 +1,10 @@ +import { parseAsStringEnum, useQueryState } from "nuqs"; + +import { TopicTab } from "@/components/topic-tabs/types"; + +export default function useTopicTab() { + return useQueryState( + "tab", + parseAsStringEnum(Object.values(TopicTab)).withDefault(TopicTab.Flood), + ); +} diff --git a/client/src/types/layer.ts b/client/src/types/layer.ts index 69ab1b5..93635dd 100644 --- a/client/src/types/layer.ts +++ b/client/src/types/layer.ts @@ -3,6 +3,7 @@ import { AnyLayer, AnySource, SkyLayer } from "react-map-gl"; export interface LayerSettings { visibility: boolean; opacity: number; + "return-period"?: number; } export interface LayerConfig { @@ -13,6 +14,7 @@ export interface LayerConfig { export interface LayerParamsConfigValue { key: string; default: unknown; + options?: unknown[]; } export type LayerParamsConfig = LayerParamsConfigValue[]; diff --git a/client/tailwind.config.ts b/client/tailwind.config.ts index 0607e31..cee6d33 100644 --- a/client/tailwind.config.ts +++ b/client/tailwind.config.ts @@ -1,5 +1,8 @@ import defaultTheme from "tailwindcss/defaultTheme"; import TailwindAnimate from "tailwindcss-animate"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import TailwindBorderImage from "tailwindcss-border-image"; import type { Config } from "tailwindcss"; @@ -78,7 +81,7 @@ const config: Config = { }, }, extend: {}, - plugins: [TailwindAnimate], + plugins: [TailwindAnimate, TailwindBorderImage], }; export default config; diff --git a/client/yarn.lock b/client/yarn.lock index d6b918f..038e3d1 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -2783,6 +2783,45 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-select@npm:2.1.2": + version: 2.1.2 + resolution: "@radix-ui/react-select@npm:2.1.2" + dependencies: + "@radix-ui/number": "npm:1.1.0" + "@radix-ui/primitive": "npm:1.1.0" + "@radix-ui/react-collection": "npm:1.1.0" + "@radix-ui/react-compose-refs": "npm:1.1.0" + "@radix-ui/react-context": "npm:1.1.1" + "@radix-ui/react-direction": "npm:1.1.0" + "@radix-ui/react-dismissable-layer": "npm:1.1.1" + "@radix-ui/react-focus-guards": "npm:1.1.1" + "@radix-ui/react-focus-scope": "npm:1.1.0" + "@radix-ui/react-id": "npm:1.1.0" + "@radix-ui/react-popper": "npm:1.2.0" + "@radix-ui/react-portal": "npm:1.1.2" + "@radix-ui/react-primitive": "npm:2.0.0" + "@radix-ui/react-slot": "npm:1.1.0" + "@radix-ui/react-use-callback-ref": "npm:1.1.0" + "@radix-ui/react-use-controllable-state": "npm:1.1.0" + "@radix-ui/react-use-layout-effect": "npm:1.1.0" + "@radix-ui/react-use-previous": "npm:1.1.0" + "@radix-ui/react-visually-hidden": "npm:1.1.0" + aria-hidden: "npm:^1.1.1" + react-remove-scroll: "npm:2.6.0" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/cb9d510cbbcc64ec56e1aa19da83220e21e7a101857be423cfb6c159b11bcd8f29b3f67c473df81b1a5203700731ab5f5861f4633ff3f1dec3d58ec74825b16a + languageName: node + linkType: hard + "@radix-ui/react-separator@npm:1.1.0": version: 1.1.0 resolution: "@radix-ui/react-separator@npm:1.1.0" @@ -2871,6 +2910,32 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-tabs@npm:1.1.1": + version: 1.1.1 + resolution: "@radix-ui/react-tabs@npm:1.1.1" + dependencies: + "@radix-ui/primitive": "npm:1.1.0" + "@radix-ui/react-context": "npm:1.1.1" + "@radix-ui/react-direction": "npm:1.1.0" + "@radix-ui/react-id": "npm:1.1.0" + "@radix-ui/react-presence": "npm:1.1.1" + "@radix-ui/react-primitive": "npm:2.0.0" + "@radix-ui/react-roving-focus": "npm:1.1.0" + "@radix-ui/react-use-controllable-state": "npm:1.1.0" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/86fa6beda5ac5fbc6cede483e198641fbba0b1e4ad30db3488fbfefdf460ca4e35d765f5b22f73ded1849252b2432cfa755783218f282721462f90f2ad1adf30 + languageName: node + linkType: hard + "@radix-ui/react-tooltip@npm:1.1.3": version: 1.1.3 resolution: "@radix-ui/react-tooltip@npm:1.1.3" @@ -4648,10 +4713,12 @@ __metadata: "@radix-ui/react-label": "npm:2.1.0" "@radix-ui/react-popover": "npm:1.1.2" "@radix-ui/react-radio-group": "npm:1.2.1" + "@radix-ui/react-select": "npm:2.1.2" "@radix-ui/react-separator": "npm:1.1.0" "@radix-ui/react-slider": "npm:1.2.1" "@radix-ui/react-slot": "npm:1.1.0" "@radix-ui/react-switch": "npm:1.1.1" + "@radix-ui/react-tabs": "npm:1.1.1" "@radix-ui/react-tooltip": "npm:1.1.3" "@svgr/webpack": "npm:8.1.0" "@t3-oss/env-nextjs": "npm:0.11.1" @@ -4690,6 +4757,7 @@ __metadata: tailwind-merge: "npm:2.5.4" tailwindcss: "npm:3.4.14" tailwindcss-animate: "npm:1.0.7" + tailwindcss-border-image: "npm:1.1.2" typescript: "npm:5.6.3" typescript-eslint: "npm:8.9.0" zod: "npm:3.23.8" @@ -10043,6 +10111,15 @@ __metadata: languageName: node linkType: hard +"tailwindcss-border-image@npm:1.1.2": + version: 1.1.2 + resolution: "tailwindcss-border-image@npm:1.1.2" + peerDependencies: + tailwindcss: ">=3.0.0" + checksum: 10c0/83d7613d61fe158389feff90939d8a5a9af3901817c8318203b8cd7877d403b36795590aec477c4d81ebb989aca2708d7a33a74f85d478af10a4793dfd2d0f85 + languageName: node + linkType: hard + "tailwindcss@npm:3.4.14": version: 3.4.14 resolution: "tailwindcss@npm:3.4.14" diff --git a/cms/config/sync/core-store.plugin_content_manager_configuration_content_types##api##dataset.dataset.json b/cms/config/sync/core-store.plugin_content_manager_configuration_content_types##api##dataset.dataset.json index 60fd47f..d7c6c17 100644 --- a/cms/config/sync/core-store.plugin_content_manager_configuration_content_types##api##dataset.dataset.json +++ b/cms/config/sync/core-store.plugin_content_manager_configuration_content_types##api##dataset.dataset.json @@ -153,18 +153,8 @@ } }, "layouts": { - "list": [ - "id", - "name", - "default_layer", - "topic" - ], "edit": [ [ - { - "name": "layers", - "size": 6 - }, { "name": "name", "size": 6 @@ -172,20 +162,30 @@ ], [ { - "name": "default_layer", + "name": "topic", "size": 6 }, { - "name": "topic", + "name": "sub_topic", "size": 6 } ], [ { - "name": "sub_topic", + "name": "layers", + "size": 6 + }, + { + "name": "default_layer", "size": 6 } ] + ], + "list": [ + "id", + "topic", + "sub_topic", + "name" ] }, "uid": "api::dataset.dataset" diff --git a/cms/config/sync/core-store.plugin_content_manager_configuration_content_types##api##topic.topic.json b/cms/config/sync/core-store.plugin_content_manager_configuration_content_types##api##topic.topic.json index 945adc5..1852b02 100644 --- a/cms/config/sync/core-store.plugin_content_manager_configuration_content_types##api##topic.topic.json +++ b/cms/config/sync/core-store.plugin_content_manager_configuration_content_types##api##topic.topic.json @@ -107,11 +107,6 @@ } }, "layouts": { - "list": [ - "id", - "name", - "slug" - ], "edit": [ [ { @@ -123,6 +118,11 @@ "size": 6 } ] + ], + "list": [ + "id", + "slug", + "name" ] }, "uid": "api::topic.topic" diff --git a/cms/src/extensions/documentation/documentation/1.0.0/full_documentation.json b/cms/src/extensions/documentation/documentation/1.0.0/full_documentation.json index 067d4d2..3cbe46d 100644 --- a/cms/src/extensions/documentation/documentation/1.0.0/full_documentation.json +++ b/cms/src/extensions/documentation/documentation/1.0.0/full_documentation.json @@ -14,7 +14,7 @@ "name": "Apache 2.0", "url": "https://www.apache.org/licenses/LICENSE-2.0.html" }, - "x-generation-date": "2024-11-01T10:04:13.891Z" + "x-generation-date": "2024-11-05T12:51:46.101Z" }, "x-strapi-config": { "path": "/documentation",