diff --git a/client/package.json b/client/package.json index 8a0874a..a86aa9f 100644 --- a/client/package.json +++ b/client/package.json @@ -57,6 +57,7 @@ "cmdk": "1.0.0", "date-fns": "4.1.0", "express": "4.21.1", + "jotai": "2.10.3", "lodash-es": "4.17.21", "mapbox-gl": "3.7.0", "next": "14.2.15", diff --git a/client/src/app/layout.tsx b/client/src/app/layout.tsx index 810e742..74736c7 100644 --- a/client/src/app/layout.tsx +++ b/client/src/app/layout.tsx @@ -1,7 +1,6 @@ import { Jost, DM_Serif_Text } from "next/font/google"; -import { NuqsAdapter } from "nuqs/adapters/next/app"; -import ReactQueryProvider from "@/app/react-query-provider"; +import Providers from "@/app/providers"; import Head from "@/components/head"; import type { Metadata } from "next"; @@ -38,9 +37,7 @@ export default function RootLayout({ - - {children} - + {children} ); diff --git a/client/src/app/providers.tsx b/client/src/app/providers.tsx new file mode 100644 index 0000000..4edbe8d --- /dev/null +++ b/client/src/app/providers.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { Provider as JotaiProvider } from "jotai"; +import { NuqsAdapter } from "nuqs/adapters/next/app"; +import { PropsWithChildren } from "react"; + +import ReactQueryProvider from "@/app/react-query-provider"; + +const Providers = ({ children }: PropsWithChildren) => { + return ( + + + {children} + + + ); +}; + +export default Providers; diff --git a/client/src/components/dataset-card/chart-sentence.tsx b/client/src/components/dataset-card/chart-sentence.tsx new file mode 100644 index 0000000..ed8d3ad --- /dev/null +++ b/client/src/components/dataset-card/chart-sentence.tsx @@ -0,0 +1,30 @@ +import { GeoJsonProperties } from "geojson"; +import { useMemo } from "react"; + +interface ChartSentenceprops { + sentence: string; + feature?: GeoJsonProperties; +} + +const ChartSentence = ({ sentence, feature }: ChartSentenceprops) => { + const resolvedSentence = useMemo(() => { + let res = `${sentence}`; // Creating a copy + + if (!!feature) { + Object.entries(feature).forEach(([key, value]) => { + res = res.replace(`{${key}}`, value); + }); + } + + return res; + }, [sentence, feature]); + + return ( +
+ {!!feature &&
Selected point
} +
{resolvedSentence}
+
+ ); +}; + +export default ChartSentence; diff --git a/client/src/components/dataset-card/date-controls.tsx b/client/src/components/dataset-card/date-controls.tsx new file mode 100644 index 0000000..07d22da --- /dev/null +++ b/client/src/components/dataset-card/date-controls.tsx @@ -0,0 +1,132 @@ +import { getMonth } from "date-fns"; +import { format } from "date-fns/format"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import MonthPicker from "@/components/ui/month-picker"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import CalendarDaysIcon from "@/svgs/calendar-days.svg"; +import ChevronDownIcon from "@/svgs/chevron-down.svg"; +import PauseIcon from "@/svgs/pause.svg"; +import PlayIcon from "@/svgs/play.svg"; +import { DatasetLayersDataItem } from "@/types/generated/strapi.schemas"; +import { LayerParamsConfig } from "@/types/layer"; + +interface DateControlsProps { + layer: DatasetLayersDataItem; + date: string; + onChangeDate: (date: string) => void; +} + +const DateControls = ({ layer, date, onChangeDate }: DateControlsProps) => { + const [isAnimated, setIsAnimated] = useState(false); + + const animationIntervalRef = useRef(null); + // Date that was selected before the animation is played + const dateBeforeAnimationRef = useRef(null); + + const dateRange = useMemo(() => { + const paramsConfig = layer.attributes!.params_config! as LayerParamsConfig; + return paramsConfig.find(({ key }) => key === "date-range")?.default as [string, string]; + }, [layer]); + + const onToggleAnimation = useCallback(() => { + const newIsAnimated = !isAnimated; + + if (newIsAnimated) { + dateBeforeAnimationRef.current = date; + } else { + dateBeforeAnimationRef.current = null; + } + + setIsAnimated(newIsAnimated); + }, [date, isAnimated, setIsAnimated]); + + const onChangeSelectedDate = useCallback( + (date: string) => { + onChangeDate(date); + }, + [onChangeDate], + ); + + // When the layer is animated, show each month of the year in a loop + useEffect(() => { + if (isAnimated) { + animationIntervalRef.current = setInterval(() => { + const newDate = format(new Date(date).setMonth((getMonth(date) + 1) % 12), "yyyy-MM-dd"); + + onChangeDate(newDate); + }, 500); + } else if (animationIntervalRef.current !== null) { + clearInterval(animationIntervalRef.current); + } + + return () => { + if (animationIntervalRef.current !== null) { + clearInterval(animationIntervalRef.current); + } + }; + }, [layer.id, date, isAnimated, onChangeDate]); + + if (date === undefined && dateRange === undefined) { + return null; + } + + return ( +
+ +
+ + + + + + + + + +
+
+ ); +}; + +export default DateControls; diff --git a/client/src/components/dataset-card/download-chart-button.tsx b/client/src/components/dataset-card/download-chart-button.tsx new file mode 100644 index 0000000..4bfeaab --- /dev/null +++ b/client/src/components/dataset-card/download-chart-button.tsx @@ -0,0 +1,48 @@ +import { useCallback } from "react"; + +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import GraphIcon from "@/svgs/graph.svg"; + +interface DownloadChartButtonProps { + data: unknown; + fileName: string; + disabled: boolean; +} + +const DownloadChartButton = ({ data, fileName, disabled }: DownloadChartButtonProps) => { + const onClickSaveChartData = useCallback(() => { + const blob = new Blob([JSON.stringify(data)], { type: "application/json" }); + + const link = document.createElement("a"); + link.download = fileName; + link.href = URL.createObjectURL(blob); + link.click(); + link.remove(); + }, [data, fileName]); + + return ( + + + + + + Save chart data + + + ); +}; + +export default DownloadChartButton; diff --git a/client/src/components/dataset-card/download-layer-button.tsx b/client/src/components/dataset-card/download-layer-button.tsx new file mode 100644 index 0000000..6e3bc03 --- /dev/null +++ b/client/src/components/dataset-card/download-layer-button.tsx @@ -0,0 +1,33 @@ +import Link from "next/link"; + +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import DownloadIcon from "@/svgs/download.svg"; + +interface DownloadLayerButtonProps { + link: string; + fileName: string; +} + +const DownloadLayerButton = ({ link, fileName }: DownloadLayerButtonProps) => { + return ( + + + + + + Download dataset + + + ); +}; + +export default DownloadLayerButton; diff --git a/client/src/components/dataset-card/index.tsx b/client/src/components/dataset-card/index.tsx index b0722b6..d9b23e8 100644 --- a/client/src/components/dataset-card/index.tsx +++ b/client/src/components/dataset-card/index.tsx @@ -1,18 +1,12 @@ "use client"; -import { getMonth, getYear } from "date-fns"; -import { format } from "date-fns/format"; +import { getYear } from "date-fns"; import { camelCase } from "lodash-es"; -import Link from "next/link"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import * as React from "react"; +import { useCallback, useMemo, useState } from "react"; -import DatasetMetadata from "@/components/dataset-metadata"; -import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; +import InteractionChart from "@/components/interaction-chart"; import { Label } from "@/components/ui/label"; -import MonthPicker from "@/components/ui/month-picker"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, @@ -21,28 +15,26 @@ import { SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import YearChart from "@/components/year-chart"; +import useInteractionChartData from "@/hooks/use-interaction-chart-data"; +import useLayerInteractionState from "@/hooks/use-layer-interaction-state"; import useLocation from "@/hooks/use-location"; import { useLocationByCodes } from "@/hooks/use-location-by-codes"; import useMapLayers from "@/hooks/use-map-layers"; import useYearChartData from "@/hooks/use-year-chart-data"; -import { cn } from "@/lib/utils"; -import CalendarDaysIcon from "@/svgs/calendar-days.svg"; -import ChevronDownIcon from "@/svgs/chevron-down.svg"; -import DownloadIcon from "@/svgs/download.svg"; -import GraphIcon from "@/svgs/graph.svg"; -import PauseIcon from "@/svgs/pause.svg"; -import PlayIcon from "@/svgs/play.svg"; -import QuestionMarkIcon from "@/svgs/question-mark.svg"; +import CursorArrowRaysIcon from "@/svgs/cursor-arrow-rays.svg"; import { DatasetLayersDataItem, MetadataItemComponent } from "@/types/generated/strapi.schemas"; -import { LayerParamsConfig } from "@/types/layer"; +import ChartSentence from "./chart-sentence"; +import DateControls from "./date-controls"; +import DownloadChartButton from "./download-chart-button"; +import DownloadLayerButton from "./download-layer-button"; +import MetadataButton from "./metadata-button"; import { + getDefaultDate, getDefaultReturnPeriod, getDefaultSelectedLayerId, getReturnPeriods, - getDefaultDate, } from "./utils"; interface DatasetCardProps { @@ -83,24 +75,19 @@ const DatasetCard = ({ const [selectedLayerId, setSelectedLayerId] = useState(defaultSelectedLayerId); const [selectedReturnPeriod, setSelectedReturnPeriod] = useState(defaultSelectedReturnPeriod); const [selectedDate, setSelectedDate] = useState(defaultSelectedDate); - const [isAnimated, setIsAnimated] = useState(false); - const animationIntervalRef = useRef(null); - // Date that was selected before the animation is played - const dateBeforeAnimationRef = useRef(null); + + const [{ selectedFeature }, { setHoveredFeature, setSelectedFeature }] = + useLayerInteractionState(selectedLayerId); const selectedLayer = useMemo( () => layers.find(({ id }) => id === selectedLayerId), [layers, selectedLayerId], ); - const dateRange = useMemo(() => { - if (!selectedLayer) { - return undefined; - } - - const paramsConfig = selectedLayer.attributes!.params_config! as LayerParamsConfig; - return paramsConfig.find(({ key }) => key === "date-range")?.default as [string, string]; - }, [selectedLayer]); + const showChartOnInteraction = useMemo( + () => selectedLayer?.attributes!.show_chart_on_interaction ?? false, + [selectedLayer], + ); const isDatasetActive = useMemo(() => { if (selectedLayerId === undefined) { @@ -115,26 +102,108 @@ const DatasetCard = ({ [layers, selectedLayerId], ); - const { data: chartData, isLoading: chartIsLoading } = useYearChartData( + const { data: yearChartData, isLoading: yearChartIsLoading } = useYearChartData( selectedLayerId, selectedDate, ); + const { data: interactionChartData, isLoading: interactionChartIsLoading } = + useInteractionChartData(selectedLayer, selectedFeature); + const { data: locationData, isLoading: locationIsLoading } = useLocationByCodes( location.code.slice(-1), ); - const onToggleAnimation = useCallback(() => { - const newIsAnimated = !isAnimated; + const isChartDownloadVisible = useMemo(() => { + return ( + (showChartOnInteraction && selectedFeature) || + (!showChartOnInteraction && selectedDate !== undefined && selectedLayerId !== undefined) + ); + }, [selectedDate, selectedFeature, selectedLayerId, showChartOnInteraction]); + + const isChartDownloadDisabled = useMemo(() => { + const isInteractionChartDownloadDisabled = + showChartOnInteraction && (interactionChartIsLoading || !interactionChartData); + + const isYearChartDownloadDisabled = + !showChartOnInteraction && + (yearChartIsLoading || + !yearChartData || + locationIsLoading || + !locationData || + locationData.length === 0); + + return isInteractionChartDownloadDisabled || isYearChartDownloadDisabled; + }, [ + interactionChartData, + interactionChartIsLoading, + locationData, + locationIsLoading, + showChartOnInteraction, + yearChartData, + yearChartIsLoading, + ]); + + const downloadableChartData = useMemo(() => { + if (isChartDownloadDisabled) { + return; + } - if (newIsAnimated) { - dateBeforeAnimationRef.current = selectedDate !== undefined ? selectedDate : null; - } else { - dateBeforeAnimationRef.current = null; + return { + dataset: name, + datasetMetadata: Object.entries(metadata ?? {}).reduce((res, [key, value]) => { + if (key === "id") { + return res; + } + + return { + ...res, + [camelCase(key)]: value, + }; + }, {}), + ...(!showChartOnInteraction + ? { + year: getYear(selectedDate!), + location: locationData![0].name, + ...yearChartData, + } + : {}), + ...(showChartOnInteraction + ? { + feature: selectedFeature, + ...interactionChartData, + } + : {}), + }; + }, [ + interactionChartData, + isChartDownloadDisabled, + locationData, + metadata, + name, + selectedDate, + selectedFeature, + showChartOnInteraction, + yearChartData, + ]); + + const downloadableChartDataFileName = useMemo(() => { + if (isChartDownloadDisabled) { + return ""; } - setIsAnimated(newIsAnimated); - }, [selectedDate, isAnimated, setIsAnimated]); + return `${name}${!showChartOnInteraction ? ` - ${locationData![0].name}` : ""}.json`; + }, [isChartDownloadDisabled, locationData, name, showChartOnInteraction]); + + const isInteractionChartVisible = useMemo( + () => showChartOnInteraction && selectedLayer !== undefined && !!selectedFeature, + [selectedFeature, selectedLayer, showChartOnInteraction], + ); + + const isYearChartVisible = useMemo( + () => selectedDate !== undefined && selectedLayerId !== undefined, + [selectedDate, selectedLayerId], + ); const onToggleDataset = useCallback( (active: boolean) => { @@ -144,9 +213,8 @@ const DatasetCard = ({ if (!active) { removeLayer(selectedLayerId); - if (isAnimated) { - onToggleAnimation(); - } + setHoveredFeature(null); + setSelectedFeature(null); } else { addLayer(selectedLayerId, { ["return-period"]: selectedReturnPeriod, date: selectedDate }); } @@ -157,8 +225,8 @@ const DatasetCard = ({ removeLayer, selectedReturnPeriod, selectedDate, - isAnimated, - onToggleAnimation, + setHoveredFeature, + setSelectedFeature, ], ); @@ -169,6 +237,10 @@ const DatasetCard = ({ const returnPeriod = getDefaultReturnPeriod(id, layers, layersConfiguration); const date = getDefaultDate(id, layers, layersConfiguration); + // We reset the hovered and selected features for the previous layer + setHoveredFeature(null); + setSelectedFeature(null); + setSelectedLayerId(id); setSelectedReturnPeriod(returnPeriod); @@ -188,6 +260,8 @@ const DatasetCard = ({ addLayer, layers, layersConfiguration, + setHoveredFeature, + setSelectedFeature, ], ); @@ -215,81 +289,16 @@ const DatasetCard = ({ ], ); - const onChangeSelectedDate = useCallback( + const onChangeDate = useCallback( (date: string) => { - const returnPeriod = getDefaultReturnPeriod(selectedLayerId, layers, layersConfiguration); - setSelectedDate(date); - - if (isDatasetActive && selectedLayerId !== undefined) { + if (selectedLayerId !== undefined) { updateLayer(selectedLayerId, { date }); - } else if (selectedLayerId !== undefined) { - addLayer(selectedLayerId, { ["return-period"]: returnPeriod, date }); } }, - [selectedLayerId, isDatasetActive, addLayer, updateLayer, layers, layersConfiguration], + [updateLayer, selectedLayerId, setSelectedDate], ); - const onClickSaveChartData = useCallback(() => { - if ( - chartIsLoading || - !chartData || - locationIsLoading || - !locationData?.length || - !selectedDate - ) { - return; - } - - const data = { - dataset: name, - datasetMetadata: Object.entries(metadata ?? {}).reduce((res, [key, value]) => { - if (key === "id") { - return res; - } - - return { - ...res, - [camelCase(key)]: value, - }; - }, {}), - year: getYear(selectedDate), - location: locationData[0].name, - ...chartData, - }; - - const blob = new Blob([JSON.stringify(data)], { type: "application/json" }); - - const link = document.createElement("a"); - link.download = `${name} - ${locationData[0].name}.json`; - link.href = URL.createObjectURL(blob); - link.click(); - link.remove(); - }, [chartIsLoading, chartData, locationIsLoading, locationData, selectedDate, name, metadata]); - - // When the layer is animated, show each month of the year in a loop - useEffect(() => { - if (isAnimated && selectedDate !== undefined && selectedLayerId !== undefined) { - animationIntervalRef.current = setInterval(() => { - const date = format( - new Date(selectedDate).setMonth((getMonth(selectedDate) + 1) % 12), - "yyyy-MM-dd", - ); - - setSelectedDate(date); - updateLayer(selectedLayerId, { date }); - }, 500); - } else if (animationIntervalRef.current !== null) { - clearInterval(animationIntervalRef.current); - } - - return () => { - if (animationIntervalRef.current !== null) { - clearInterval(animationIntervalRef.current); - } - }; - }, [selectedLayerId, selectedDate, isAnimated, setSelectedDate, updateLayer]); - return (
@@ -297,73 +306,20 @@ const DatasetCard = ({ {name}
- {selectedDate !== undefined && selectedLayerId !== undefined && ( - - - - - - Save chart data - - - )} - {!!selectedLayer?.attributes!.download_link && ( - - - - - - Download dataset - - + {isChartDownloadVisible && ( + )} - {!!metadata && ( - - - - - - - - - More info - - - - - - + {selectedLayer !== undefined && !!selectedLayer.attributes!.download_link && ( + )} + {!!metadata && } {(!!selectedLayer?.attributes!.download_link || !!metadata) && (
)} @@ -410,71 +366,40 @@ const DatasetCard = ({ )} - {selectedDate !== undefined && selectedLayerId !== undefined && ( + {isDatasetActive && showChartOnInteraction && selectedLayer !== undefined && ( +
+ + Select a point on the map for details. +
+ )} + {isInteractionChartVisible && ( +
+ +
+ )} + {isYearChartVisible && (
)} - {selectedDate !== undefined && dateRange !== undefined && isDatasetActive && ( -
- -
- - - - - - - - - + {isDatasetActive && + (isInteractionChartVisible || isYearChartVisible) && + selectedLayer !== undefined && + !!selectedLayer.attributes!.chart_sentence && ( +
+
-
+ )} + {isDatasetActive && selectedLayer !== undefined && selectedDate !== undefined && ( + )}
diff --git a/client/src/components/dataset-card/metadata-button.tsx b/client/src/components/dataset-card/metadata-button.tsx new file mode 100644 index 0000000..f49558a --- /dev/null +++ b/client/src/components/dataset-card/metadata-button.tsx @@ -0,0 +1,39 @@ +import DatasetMetadata from "@/components/dataset-metadata"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import QuestionMarkIcon from "@/svgs/question-mark.svg"; +import { MetadataItemComponent } from "@/types/generated/strapi.schemas"; + +interface MetadataButtonProps { + datasetName: string; + metadata: MetadataItemComponent; +} + +const MetadataButton = ({ datasetName, metadata }: MetadataButtonProps) => { + return ( + + + + + + + + + More info + + + + + + + ); +}; + +export default MetadataButton; diff --git a/client/src/components/interaction-chart/index.tsx b/client/src/components/interaction-chart/index.tsx new file mode 100644 index 0000000..4eaeded --- /dev/null +++ b/client/src/components/interaction-chart/index.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { AxisBottom, AxisLeft, AxisTop } from "@visx/axis"; +import { GridColumns, GridRows } from "@visx/grid"; +import { Group } from "@visx/group"; +import { useParentSize } from "@visx/responsive"; +import { scaleLinear, scaleTime } from "@visx/scale"; +import { LinePath } from "@visx/shape"; +import { Text, TextProps } from "@visx/text"; +import { extent } from "d3-array"; +import { ComponentProps, useCallback, useMemo } from "react"; + +import { Skeleton } from "@/components/ui/skeleton"; +import useInteractionChartData from "@/hooks/use-interaction-chart-data"; +import tailwindConfig from "@/lib/tailwind-config"; +import { cn } from "@/lib/utils"; + +interface InteractionChartProps { + data: ReturnType["data"]; + loading: boolean; +} + +const CHART_MIN_HEIGHT = 120; +const CHART_MAX_HEIGHT = 250; +const X_AXIS_HEIGHT = 22; +const X_AXIS_OFFSET_RIGHT = 5; +const X_AXIS_TICK_COUNT = 5; +const X_AXIS_TICK_HEIGHT = 5; +const Y_AXIS_WIDTH = 34; +const Y_AXIS_OFFSET_TOP = 5; +const Y_AXIS_TICK_WIDTH = 5; +const Y_AXIS_TICK_COUNT = 5; + +const InteractionChart = ({ data, loading }: InteractionChartProps) => { + const { parentRef, width } = useParentSize({ ignoreDimensions: ["height"] }); + const height = useMemo( + () => Math.max(Math.min(width / 2.7, CHART_MAX_HEIGHT), CHART_MIN_HEIGHT), + [width], + ); + + const unitWidth = useMemo(() => { + if (loading || !data) { + return 0; + } + + const subtract = + data.unit?.split("").reduce((res, char) => { + if (char === "˚" || char === "/" || char === " ") { + return res - 6; + } + + return res; + }, Y_AXIS_TICK_WIDTH) ?? Y_AXIS_TICK_WIDTH; + + return (data.unit?.length ?? 0) * 8 + subtract; + }, [data, loading]); + + const xScale = useMemo(() => { + if (loading || !data) { + return undefined; + } + + const xValues = data.data.map(({ x }) => x).sort(); + + return scaleTime({ + range: [0, width - Y_AXIS_WIDTH - X_AXIS_OFFSET_RIGHT - unitWidth], + domain: [new Date(xValues[0]), new Date(xValues.slice(-1)[0])], + nice: X_AXIS_TICK_COUNT, + }); + }, [width, data, loading, unitWidth]); + + const yScale = useMemo(() => { + if (loading || !data) { + return undefined; + } + + return scaleLinear({ + range: [height - X_AXIS_HEIGHT, Y_AXIS_OFFSET_TOP], + domain: extent(data.data.map(({ y }) => y)) as [number, number], + nice: Y_AXIS_TICK_COUNT, + }); + }, [height, data, loading]); + + const xAxisTickLabelProps = useCallback< + NonNullable["tickLabelProps"], Partial>> + >((tick, index, ticks) => { + let textAnchor: ComponentProps["textAnchor"] = "middle"; + let dx = 0; + if (index === 0) { + textAnchor = "start"; + } else if (index + 1 === ticks.length) { + textAnchor = "end"; + dx = X_AXIS_OFFSET_RIGHT; + } + + return { + dx, + dy: 3.5, + textAnchor, + className: cn({ + "text-right font-sans text-[11px] text-rhino-blue-950": true, + "invisible sm:visible": index % 2 === 1, + }), + }; + }, []); + + const yAxisTickLabelProps = useCallback< + NonNullable["tickLabelProps"], Partial>> + >((tick, index) => { + let dy = 3.5; + if (index === 0) { + dy = 0; + } + + return { + dx: -4, + dy, + textAnchor: "end", + className: "text-right font-sans text-[11px] text-rhino-blue-950", + }; + }, []); + + return ( +
+ {loading && !data && } + {!loading && !!data && !!xScale && !!yScale && ( + + + + + xScale(new Date(d.x)) ?? 0} + y={(d) => yScale(d.y) ?? 0} + defined={(d) => d.y !== null} + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + strokeWidth={1} + className="stroke-supernova-yellow-600" + /> + + + + {/* This axis serves to extend the grid vertically towards the top */} + null} + tickLabelProps={xAxisTickLabelProps} + tickClassName="[&>line]:stroke-casper-blue-400/50" + axisLineClassName="opacity-0" + /> + + + + {data.unit} + + + )} +
+ ); +}; + +export default InteractionChart; diff --git a/client/src/components/map/layer-manager/item.tsx b/client/src/components/map/layer-manager/item.tsx index ed28c54..14d5966 100644 --- a/client/src/components/map/layer-manager/item.tsx +++ b/client/src/components/map/layer-manager/item.tsx @@ -1,4 +1,5 @@ import useLayerConfig from "@/hooks/use-layer-config"; +import useLayerInteractionState from "@/hooks/use-layer-interaction-state"; import { LayerSettings } from "@/types/layer"; import AnimatedLayer from "./animated-layer"; @@ -12,13 +13,15 @@ interface LayerManagerItemProps { } const LayerManagerItem = ({ id, beforeId, settings }: LayerManagerItemProps) => { - const layerConfig = useLayerConfig(id, settings); + const [interactionState, { setHoveredFeature, setSelectedFeature }] = + useLayerInteractionState(id); + const layerConfig = useLayerConfig(id, settings, interactionState); if (!layerConfig) { return null; } - const { type, config } = layerConfig; + const { type, config, interactive } = layerConfig; if (!config.styles) { return null; @@ -33,7 +36,15 @@ const LayerManagerItem = ({ id, beforeId, settings }: LayerManagerItemProps) => } if (config.source.type === "vector") { - return ; + return ( + + ); } console.warn(`Unsupported layer type (${config.source.type})`); diff --git a/client/src/components/map/layer-manager/vector-layer.tsx b/client/src/components/map/layer-manager/vector-layer.tsx index 8934f3a..3b9b1a0 100644 --- a/client/src/components/map/layer-manager/vector-layer.tsx +++ b/client/src/components/map/layer-manager/vector-layer.tsx @@ -1,5 +1,6 @@ +import { GetPickingInfoParams } from "@deck.gl/core"; import { MaskExtension } from "@deck.gl/extensions"; -import { MVTLayer, MVTLayerProps } from "@deck.gl/geo-layers"; +import { MVTLayer, MVTLayerPickingInfo, MVTLayerProps } from "@deck.gl/geo-layers"; import { ScatterplotLayer } from "@deck.gl/layers"; import { BinaryFeatureCollection } from "@loaders.gl/schema"; import { useContext, useEffect } from "react"; @@ -7,17 +8,24 @@ import { VectorSourceRaw as IVectorTileSource } from "react-map-gl"; import { env } from "@/env"; import useMapZoom from "@/hooks/use-map-zoom"; -import { LayerConfig } from "@/types/layer"; -import { convertBinaryToPointGeoJSON, resolveDeckglProperties } from "@/utils/mapbox-deckgl-bridge"; +import { LayerConfig, LayerInteractionState } from "@/types/layer"; +import { + convertBinaryToPointGeoJSON, + resolveDeckglProperties, + resolveInteractive, +} from "@/utils/mapbox-deckgl-bridge"; import { DeckGLMapboxOverlayContext } from "../deckgl-mapbox-provider"; interface VectorLayerProps { config: LayerConfig; beforeId: string; + interactive: boolean; + onHover: (properties: LayerInteractionState["hoveredFeature"]) => void; + onClick: (properties: LayerInteractionState["selectedFeature"]) => void; } -const VectorLayer = ({ config, beforeId }: VectorLayerProps) => { +const VectorLayer = ({ config, beforeId, interactive, onHover, onClick }: VectorLayerProps) => { const { addLayer, removeLayer } = useContext(DeckGLMapboxOverlayContext); const zoom = useMapZoom(); @@ -51,6 +59,23 @@ const VectorLayer = ({ config, beforeId }: VectorLayerProps) => { ...resolveDeckglProperties(style, zoom), extensions: [new MaskExtension()], maskId: "mask", + pickable: interactive, + onHover: ({ picked, object }) => { + if (picked) { + onHover(object.properties); + } else { + onHover(null); + } + }, + onClick: ({ picked, object }) => { + const isFeatureInteractive = resolveInteractive(style, zoom, object); + + if (picked && isFeatureInteractive) { + onClick(object.properties); + } else { + onClick(null); + } + }, }; // Here's an edge case: when the vector layer contains polygons and lines, Mapbox allows @@ -77,7 +102,20 @@ const VectorLayer = ({ config, beforeId }: VectorLayerProps) => { }; } - layers.push(new MVTLayer(layerProps)); + // We extend the MVTLayer class to make sure that when interacting with the layer, we pick + // the correct feature information, which is decoded binary data (i.e. GeoJSON properties) for + // the circle layers + class MVTJSONLayer extends MVTLayer { + getPickingInfo(params: GetPickingInfoParams) { + if (style.type === "circle") { + return params.info as MVTLayerPickingInfo; + } + + return super.getPickingInfo(params); + } + } + + layers.push(new MVTJSONLayer(layerProps)); }); layers.map((layer) => { @@ -89,7 +127,7 @@ const VectorLayer = ({ config, beforeId }: VectorLayerProps) => { removeLayer(layer.id); }); }; - }, [config, beforeId, addLayer, removeLayer, zoom]); + }, [config, beforeId, addLayer, removeLayer, zoom, interactive, onHover, onClick]); return null; }; diff --git a/client/src/components/map/legend/item/index.tsx b/client/src/components/map/legend/item/index.tsx index 33833cb..0efff08 100644 --- a/client/src/components/map/legend/item/index.tsx +++ b/client/src/components/map/legend/item/index.tsx @@ -131,11 +131,9 @@ const LegendItem = ({ id, settings, sortableAttributes, sortableListeners }: Leg
)} - {!isLoading && - data.topicSlug !== "contextual" && - data.topicSlug !== "hydrometeorological" && ( -
{data.name}
- )} + {!isLoading && data.topicSlug !== "contextual" && data.datasetLayersCount > 1 && ( +
{data.name}
+ )}
{isLoading && ( <> diff --git a/client/src/components/panels/drought/index.tsx b/client/src/components/panels/drought/index.tsx index c5341da..dc098e9 100644 --- a/client/src/components/panels/drought/index.tsx +++ b/client/src/components/panels/drought/index.tsx @@ -8,7 +8,7 @@ import useDatasetsBySubTopic from "@/hooks/use-datasets-by-sub-topic"; const DroughtPanel = () => { const { data, isLoading } = useDatasetsBySubTopic( "drought", - ["name", "params_config", "download_link"], + ["name", "params_config", "download_link", "show_chart_on_interaction", "chart_sentence"], true, ); diff --git a/client/src/components/panels/flood/index.tsx b/client/src/components/panels/flood/index.tsx index 0b8db01..1a8c1b5 100644 --- a/client/src/components/panels/flood/index.tsx +++ b/client/src/components/panels/flood/index.tsx @@ -8,7 +8,7 @@ import useDatasetsBySubTopic from "@/hooks/use-datasets-by-sub-topic"; const FloodPanel = () => { const { data, isLoading } = useDatasetsBySubTopic( "flood", - ["name", "params_config", "download_link"], + ["name", "params_config", "download_link", "show_chart_on_interaction", "chart_sentence"], true, ); diff --git a/client/src/components/panels/hydrometeorological/index.tsx b/client/src/components/panels/hydrometeorological/index.tsx index 6828639..9d9cbc4 100644 --- a/client/src/components/panels/hydrometeorological/index.tsx +++ b/client/src/components/panels/hydrometeorological/index.tsx @@ -9,7 +9,7 @@ import HydrometeorologicalImage from "../../../../public/assets/images/hydromete const HydrometeorologicalPanel = () => { const { data, isLoading } = useDatasetsBySubTopic( "hydrometeorological", - ["name", "params_config", "download_link"], + ["name", "params_config", "download_link", "show_chart_on_interaction", "chart_sentence"], true, ); diff --git a/client/src/hooks/use-deckgl-mapbox-overlay.ts b/client/src/hooks/use-deckgl-mapbox-overlay.ts index af04a97..efc5d0b 100644 --- a/client/src/hooks/use-deckgl-mapbox-overlay.ts +++ b/client/src/hooks/use-deckgl-mapbox-overlay.ts @@ -1,17 +1,46 @@ +import { PickingInfo } from "@deck.gl/core"; import { MapboxOverlay, MapboxOverlayProps } from "@deck.gl/mapbox"; -import { useEffect, useMemo } from "react"; +import { useSetAtom } from "jotai"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { useMap } from "react-map-gl"; +import { selectedFeatureByLayerAtom } from "@/hooks/use-layer-interaction-state"; + export default function useDeckGLMapboxOverlay(props: MapboxOverlayProps = { interleaved: true }) { const { current: map } = useMap(); + const cursorRef = useRef("grab"); + const setSelectedFeatureByLayer = useSetAtom(selectedFeatureByLayerAtom); + + const onClick = useCallback( + ({ picked }: PickingInfo) => { + if (!picked) { + // If the user clicks on the map and no geometry was below the cursor, then we make sure to + // reset all the layers' selected features + setSelectedFeatureByLayer({}); + } + }, + [setSelectedFeatureByLayer], + ); + + const onHover = useCallback( + ({ picked }: PickingInfo) => { + cursorRef.current = picked ? "pointer" : "grab"; + if (map) { + map.getCanvas().style.cursor = cursorRef.current; + } + }, + [map], + ); const mapboxOverlay = useMemo( () => new MapboxOverlay({ ...props, - getCursor: () => map?.getCanvas().style.cursor || "", + getCursor: () => cursorRef.current, + onClick, + onHover, }), - [props, map], + [props, onClick, onHover], ); useEffect(() => { diff --git a/client/src/hooks/use-interaction-chart-data.ts b/client/src/hooks/use-interaction-chart-data.ts new file mode 100644 index 0000000..718d719 --- /dev/null +++ b/client/src/hooks/use-interaction-chart-data.ts @@ -0,0 +1,97 @@ +import { GeoJsonProperties } from "geojson"; +import { useMemo } from "react"; + +import { useGetLayers } from "@/types/generated/layer"; +import { + ChartDataLayerDataAttributesChartDataDataItemAttributes, + DatasetLayersDataItem, +} from "@/types/generated/strapi.schemas"; +import { LayerParamsConfig } from "@/types/layer"; + +interface InteractionChartData { + data: { + x: string; + y: number; + }[]; + unit: string | undefined; +} + +export default function useInteractionChartData( + layer?: DatasetLayersDataItem, + feature?: GeoJsonProperties | null, +) { + const identifier = useMemo(() => { + if (!layer || !feature) { + return undefined; + } + + const paramsConfig = layer.attributes!.params_config as LayerParamsConfig; + const featureId = paramsConfig.find(({ key }) => key === "feature-id")?.default as + | string + | undefined; + + if (!featureId) { + return undefined; + } + + return feature[featureId]; + }, [layer, feature]); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error + const { data, isLoading } = useGetLayers( + { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error + fields: ["chart_unit"], + populate: { + chart_data: { + fields: ["x_values", "y_values"], + filters: { + unique_identifier: { + $eq: identifier, + }, + }, + "pagination[limit]": 1, + }, + }, + filters: { + id: { + $eq: layer?.id, + }, + }, + "pagination[limit]": 1, + }, + { + query: { + enabled: layer !== undefined && feature !== undefined && feature !== null, + placeholderData: { data: [] }, + select: (data) => { + if (!data?.data?.length) { + return undefined; + } + + const { attributes: layerAttributes } = data.data[0]; + + if (!layerAttributes!.chart_data?.data?.length) { + return undefined; + } + + const { data: chartData } = layerAttributes!.chart_data; + const chartAttributes = chartData[0] + .attributes! as ChartDataLayerDataAttributesChartDataDataItemAttributes; + + return { + data: (chartAttributes.x_values as number[]).map((x, index) => ({ + x, + y: (chartAttributes.y_values as number[])[index], + })), + unit: layerAttributes!.chart_unit, + }; + }, + }, + }, + ); + + return { data, isLoading }; +} diff --git a/client/src/hooks/use-layer-config.ts b/client/src/hooks/use-layer-config.ts index 0796090..86d95f1 100644 --- a/client/src/hooks/use-layer-config.ts +++ b/client/src/hooks/use-layer-config.ts @@ -6,6 +6,7 @@ import { useGetLayersId } from "@/types/generated/layer"; import { LayerType } from "@/types/generated/strapi.schemas"; import { LayerConfig, + LayerInteractionState, LayerParamsConfig, LayerResolvedParamsConfig, LayerSettings, @@ -14,8 +15,9 @@ import { const resolveLayerParamsConfig = ( paramsConfig: LayerParamsConfig, settings: LayerSettings, + interaction: LayerInteractionState, ): LayerResolvedParamsConfig => { - return paramsConfig.reduce((res, param) => { + const config: LayerResolvedParamsConfig = paramsConfig.reduce((res, param) => { const hasSettings = (key: string): key is keyof LayerSettings => { return key in settings; }; @@ -31,6 +33,17 @@ const resolveLayerParamsConfig = ( [param.key]: settings[param.key] ?? param.default, }; }, {}); + + const interactive = config["interactive"] as boolean | undefined | null; + const featureId = config["feature-id"] as string | undefined | null; + + // If the layer is interactive, we compute the properties related to the interaction + if (interactive === true && featureId !== undefined && featureId !== null) { + config["hovered-feature-id"] = interaction.hoveredFeature?.[featureId] ?? ""; + config["selected-feature-id"] = interaction.selectedFeature?.[featureId] ?? ""; + } + + return config; }; const resolveLayerConfig = ( @@ -73,7 +86,11 @@ const resolveLayerConfig = ( return converter.convertJson(config); }; -export default function useLayerConfig(layerId: number, settings: LayerSettings) { +export default function useLayerConfig( + layerId: number, + settings: LayerSettings, + interaction: LayerInteractionState, +) { const { data, isLoading } = useGetLayersId(layerId, { query: { select: (data) => { @@ -97,8 +114,8 @@ export default function useLayerConfig(layerId: number, settings: LayerSettings) return undefined; } - return resolveLayerParamsConfig(data.paramsConfig, settings); - }, [data, isLoading, settings]); + return resolveLayerParamsConfig(data.paramsConfig, settings, interaction); + }, [data, isLoading, settings, interaction]); const resolvedConfig = useMemo(() => { if (isLoading || !data || !resolvedParamsConfig) { @@ -116,9 +133,17 @@ export default function useLayerConfig(layerId: number, settings: LayerSettings) return data.type; }, [data, isLoading]); + const interactive = useMemo(() => { + if (!resolvedParamsConfig) { + return false; + } + + return "interactive" in resolvedParamsConfig && resolvedParamsConfig.interactive === true; + }, [resolvedParamsConfig]); + if (!type || !resolvedConfig) { return undefined; } - return { type, config: resolvedConfig }; + return { type, config: resolvedConfig, interactive }; } diff --git a/client/src/hooks/use-layer-interaction-state.ts b/client/src/hooks/use-layer-interaction-state.ts new file mode 100644 index 0000000..e38ea41 --- /dev/null +++ b/client/src/hooks/use-layer-interaction-state.ts @@ -0,0 +1,56 @@ +"use client"; + +import { atom, useAtom } from "jotai"; +import { useCallback, useMemo } from "react"; + +import { LayerInteractionState } from "@/types/layer"; + +// NOTE: prefer using the hook below instead of the atom directly, unless you need to access all the +// hovered features at once +export const hoveredFeatureByLayerAtom = atom< + Record +>({}); + +// NOTE: prefer using the hook below instead of the atom directly, unless you need to access all the +// selected features at once +export const selectedFeatureByLayerAtom = atom< + Record +>({}); + +export default function useLayerInteractionState(layerId: number | undefined) { + const [hoveredFeatureByLayer, setHoveredFeatureByLayer] = useAtom(hoveredFeatureByLayerAtom); + const [selectedFeatureByLayer, setSelectedFeatureByLayer] = useAtom(selectedFeatureByLayerAtom); + + const hoveredFeature = useMemo( + () => (layerId === undefined ? null : (hoveredFeatureByLayer[layerId] ?? null)), + [layerId, hoveredFeatureByLayer], + ); + + const selectedFeature = useMemo( + () => (layerId === undefined ? null : (selectedFeatureByLayer[layerId] ?? null)), + [layerId, selectedFeatureByLayer], + ); + + const setHoveredFeature = useCallback( + (value: LayerInteractionState["hoveredFeature"]) => { + if (layerId !== undefined) { + setHoveredFeatureByLayer((prev) => ({ ...prev, [layerId]: value })); + } + }, + [layerId, setHoveredFeatureByLayer], + ); + + const setSelectedFeature = useCallback( + (value: LayerInteractionState["selectedFeature"]) => { + if (layerId !== undefined) { + setSelectedFeatureByLayer((prev) => ({ ...prev, [layerId]: value })); + } + }, + [layerId, setSelectedFeatureByLayer], + ); + + return [ + { hoveredFeature, selectedFeature } as LayerInteractionState, + { setHoveredFeature, setSelectedFeature }, + ] as const; +} diff --git a/client/src/hooks/use-layer-legend.ts b/client/src/hooks/use-layer-legend.ts index 1740dae..c3d9caf 100644 --- a/client/src/hooks/use-layer-legend.ts +++ b/client/src/hooks/use-layer-legend.ts @@ -7,6 +7,7 @@ export default function useLayerLegend(id: number) { return useGetLayers<{ name: string; dataset: string; + datasetLayersCount: number; topicSlug: string; type: LegendLegendConfigComponent["type"]; unit: string; @@ -23,6 +24,9 @@ export default function useLayerLegend(id: number) { dataset: { fields: ["name"], populate: { + layers: { + fields: ["id"], + }, topic: { fields: ["slug"], }, @@ -44,13 +48,14 @@ export default function useLayerLegend(id: number) { } const { name, dataset, legend_config } = data.data[0].attributes!; - const { name: datasetName, topic } = dataset!.data!.attributes!; + const { name: datasetName, topic, layers } = dataset!.data!.attributes!; const { slug: topicSlug } = topic!.data!.attributes!; const { type, unit, items } = legend_config!; return { name, dataset: datasetName, + datasetLayersCount: layers!.data!.length, topicSlug, type, unit, diff --git a/client/src/svgs/cursor-arrow-rays.svg b/client/src/svgs/cursor-arrow-rays.svg new file mode 100644 index 0000000..0c306eb --- /dev/null +++ b/client/src/svgs/cursor-arrow-rays.svg @@ -0,0 +1,4 @@ + + + diff --git a/client/src/types/generated/strapi.schemas.ts b/client/src/types/generated/strapi.schemas.ts index 520e021..19253a9 100644 --- a/client/src/types/generated/strapi.schemas.ts +++ b/client/src/types/generated/strapi.schemas.ts @@ -1145,6 +1145,7 @@ export const LayerType = { export interface Layer { chart_data?: LayerChartData; + chart_sentence?: string; chart_unit?: string; createdAt?: string; createdBy?: LayerCreatedBy; @@ -1154,6 +1155,7 @@ export interface Layer { mapbox_config: unknown; name: string; params_config: unknown; + show_chart_on_interaction?: boolean; type: LayerType; updatedAt?: string; updatedBy?: LayerUpdatedBy; @@ -1318,6 +1320,7 @@ export const LayerDatasetDataAttributesLayersDataItemAttributesType = { export type LayerDatasetDataAttributesLayersDataItemAttributes = { chart_data?: LayerDatasetDataAttributesLayersDataItemAttributesChartData; + chart_sentence?: string; chart_unit?: string; createdAt?: string; createdBy?: LayerDatasetDataAttributesLayersDataItemAttributesCreatedBy; @@ -1327,6 +1330,7 @@ export type LayerDatasetDataAttributesLayersDataItemAttributes = { mapbox_config?: unknown; name?: string; params_config?: unknown; + show_chart_on_interaction?: boolean; type?: LayerDatasetDataAttributesLayersDataItemAttributesType; updatedAt?: string; updatedBy?: LayerDatasetDataAttributesLayersDataItemAttributesUpdatedBy; @@ -1386,6 +1390,7 @@ export type LayerDatasetDataAttributesLayersDataItemAttributesChartDataDataItemA createdBy?: LayerDatasetDataAttributesLayersDataItemAttributesChartDataDataItemAttributesCreatedBy; layer?: LayerDatasetDataAttributesLayersDataItemAttributesChartDataDataItemAttributesLayer; location_code?: string; + unique_identifier?: string; updatedAt?: string; updatedBy?: LayerDatasetDataAttributesLayersDataItemAttributesChartDataDataItemAttributesUpdatedBy; x_values?: unknown; @@ -1667,6 +1672,7 @@ export type LayerRequestDataChartDataItem = number | string; export type LayerRequestData = { chart_data?: LayerRequestDataChartDataItem[]; + chart_sentence?: string; chart_unit?: string; dataset?: LayerRequestDataDataset; download_link?: string; @@ -1674,6 +1680,7 @@ export type LayerRequestData = { mapbox_config: unknown; name: string; params_config: unknown; + show_chart_on_interaction?: boolean; type: LayerRequestDataType; }; @@ -1776,6 +1783,7 @@ export const DatasetLayersDataItemAttributesType = { export type DatasetLayersDataItemAttributes = { chart_data?: DatasetLayersDataItemAttributesChartData; + chart_sentence?: string; chart_unit?: string; createdAt?: string; createdBy?: DatasetLayersDataItemAttributesCreatedBy; @@ -1785,6 +1793,7 @@ export type DatasetLayersDataItemAttributes = { mapbox_config?: unknown; name?: string; params_config?: unknown; + show_chart_on_interaction?: boolean; type?: DatasetLayersDataItemAttributesType; updatedAt?: string; updatedBy?: DatasetLayersDataItemAttributesUpdatedBy; @@ -2176,6 +2185,7 @@ export type DatasetLayersDataItemAttributesChartDataDataItemAttributes = { createdBy?: DatasetLayersDataItemAttributesChartDataDataItemAttributesCreatedBy; layer?: DatasetLayersDataItemAttributesChartDataDataItemAttributesLayer; location_code?: string; + unique_identifier?: string; updatedAt?: string; updatedBy?: DatasetLayersDataItemAttributesChartDataDataItemAttributesUpdatedBy; x_values?: unknown; @@ -2289,12 +2299,13 @@ export interface ChartData { createdAt?: string; createdBy?: ChartDataCreatedBy; layer?: ChartDataLayer; - location_code: string; + location_code?: string; + unique_identifier?: string; updatedAt?: string; updatedBy?: ChartDataUpdatedBy; x_values: unknown; y_values: unknown; - year: number; + year?: number; } export type ChartDataLayerDataAttributesUpdatedByDataAttributes = { [key: string]: unknown }; @@ -2310,6 +2321,7 @@ export type ChartDataLayerDataAttributesUpdatedBy = { export type ChartDataLayerDataAttributes = { chart_data?: ChartDataLayerDataAttributesChartData; + chart_sentence?: string; chart_unit?: string; createdAt?: string; createdBy?: ChartDataLayerDataAttributesCreatedBy; @@ -2319,6 +2331,7 @@ export type ChartDataLayerDataAttributes = { mapbox_config?: unknown; name?: string; params_config?: unknown; + show_chart_on_interaction?: boolean; type?: ChartDataLayerDataAttributesType; updatedAt?: string; updatedBy?: ChartDataLayerDataAttributesUpdatedBy; @@ -2692,6 +2705,7 @@ export type ChartDataLayerDataAttributesChartDataDataItemAttributes = { createdBy?: ChartDataLayerDataAttributesChartDataDataItemAttributesCreatedBy; layer?: ChartDataLayerDataAttributesChartDataDataItemAttributesLayer; location_code?: string; + unique_identifier?: string; updatedAt?: string; updatedBy?: ChartDataLayerDataAttributesChartDataDataItemAttributesUpdatedBy; x_values?: unknown; @@ -2779,10 +2793,11 @@ export type ChartDataRequestDataLayer = number | string; export type ChartDataRequestData = { layer?: ChartDataRequestDataLayer; - location_code: string; + location_code?: string; + unique_identifier?: string; x_values: unknown; y_values: unknown; - year: number; + year?: number; }; export interface ChartDataRequest { diff --git a/client/src/types/layer.ts b/client/src/types/layer.ts index 54d3556..52c0b39 100644 --- a/client/src/types/layer.ts +++ b/client/src/types/layer.ts @@ -1,3 +1,4 @@ +import { GeoJsonProperties } from "geojson"; import { CircleLayerSpecification, FillLayerSpecification, @@ -14,6 +15,11 @@ export interface LayerSettings { date?: string; } +export interface LayerInteractionState { + hoveredFeature: GeoJsonProperties | null; + selectedFeature: GeoJsonProperties | null; +} + export interface LayerConfig { source: AnySource; styles: ( diff --git a/client/src/utils/mapbox-deckgl-bridge.ts b/client/src/utils/mapbox-deckgl-bridge.ts index 2436642..4ae687f 100644 --- a/client/src/utils/mapbox-deckgl-bridge.ts +++ b/client/src/utils/mapbox-deckgl-bridge.ts @@ -3,6 +3,7 @@ import { binaryToGeojson } from "@loaders.gl/gis"; import { BinaryFeatureCollection } from "@loaders.gl/schema"; import { featureCollection, point } from "@turf/helpers"; import { coordAll } from "@turf/meta"; +import { GeoJsonProperties } from "geojson"; import { DataDrivenPropertyValueSpecification } from "mapbox-gl"; import { expression as mapboxExpression, @@ -235,6 +236,41 @@ const resolveIconSizeScale = ( return resolvedValue; }; +export const resolveInteractive = ( + style: LayerConfig["styles"][0], + zoom: number, + feature: GeoJsonProperties, + defaultValue = false, +) => { + let value: DataDrivenPropertyValueSpecification | undefined; + + if ( + style.type === "fill" || + style.type === "circle" || + style.type === "line" || + style.type === "symbol" + ) { + // NOTE: this property is custom, that's why we need to disable the error + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + value = style.layout?.["interactive"]; + } else { + return undefined; + } + + if (value === undefined) { + return defaultValue; + } + + const resolvedValue = resolveMapboxExpression(value, zoom, feature, "boolean"); + + if (resolvedValue === null || resolvedValue === undefined) { + return defaultValue; + } + + return resolvedValue; +}; + export const resolveDeckglProperties = (style: LayerConfig["styles"][0], zoom: number) => { const resolvedProperties = { visible: resolveVisible(style), @@ -266,16 +302,23 @@ export const resolveDeckglProperties = (style: LayerConfig["styles"][0], zoom: n resolvedProperties.stroked = false; } - return Object.entries(resolvedProperties).reduce((res, [key, value]) => { - if (value === undefined) { - return res; - } - - return { - ...res, - [key]: value, - }; - }, {}) as Partial; + return Object.entries(resolvedProperties).reduce( + (res, [key, value]) => { + if (value === undefined) { + return res; + } + + return { + ...res, + [key]: value, + updateTriggers: { + ...res["updateTriggers"], + [key]: [style], + }, + }; + }, + { updateTriggers: {} }, + ) as Partial; }; export const convertBinaryToPointGeoJSON = (data: BinaryFeatureCollection) => { diff --git a/client/tailwind.config.ts b/client/tailwind.config.ts index 829ca64..39e1275 100644 --- a/client/tailwind.config.ts +++ b/client/tailwind.config.ts @@ -25,6 +25,7 @@ const config: Config = { "300": "#b6cbda", "400": "#a4bdd0", "500": "#7999b8", + "800": "#4E5F7F", "950": "#2b3340", }, "rhino-blue": { @@ -41,6 +42,7 @@ const config: Config = { "supernova-yellow": { "300": "#ffe043", "400": "#ffcc15", + "600": "#CE8800", }, }, extend: { diff --git a/client/yarn.lock b/client/yarn.lock index 233e0db..9e89960 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -5950,6 +5950,7 @@ __metadata: express: "npm:4.21.1" husky: "npm:9.1.6" jiti: "npm:1.21.6" + jotai: "npm:2.10.3" lodash-es: "npm:4.17.21" mapbox-gl: "npm:3.7.0" next: "npm:14.2.15" @@ -8876,6 +8877,21 @@ __metadata: languageName: node linkType: hard +"jotai@npm:2.10.3": + version: 2.10.3 + resolution: "jotai@npm:2.10.3" + peerDependencies: + "@types/react": ">=17.0.0" + react: ">=17.0.0" + peerDependenciesMeta: + "@types/react": + optional: true + react: + optional: true + checksum: 10c0/64f6536aaa91f77dacd8d9714fb846f254bfed6e5354b3005375433d72844f3ae1d6d893ee4dd423d5bddd109d393dd338e562da914605b31190989a3d47db35 + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" diff --git a/cms/config/sync/admin-role.strapi-super-admin.json b/cms/config/sync/admin-role.strapi-super-admin.json index 0cff370..7ec113e 100644 --- a/cms/config/sync/admin-role.strapi-super-admin.json +++ b/cms/config/sync/admin-role.strapi-super-admin.json @@ -13,7 +13,8 @@ "year", "x_values", "y_values", - "layer" + "layer", + "unique_identifier" ] }, "conditions": [] @@ -35,7 +36,8 @@ "year", "x_values", "y_values", - "layer" + "layer", + "unique_identifier" ] }, "conditions": [] @@ -50,7 +52,8 @@ "year", "x_values", "y_values", - "layer" + "layer", + "unique_identifier" ] }, "conditions": [] @@ -160,7 +163,9 @@ "dataset", "download_link", "chart_data", - "chart_unit" + "chart_unit", + "show_chart_on_interaction", + "chart_sentence" ] }, "conditions": [] @@ -192,7 +197,9 @@ "dataset", "download_link", "chart_data", - "chart_unit" + "chart_unit", + "show_chart_on_interaction", + "chart_sentence" ] }, "conditions": [] @@ -217,7 +224,9 @@ "dataset", "download_link", "chart_data", - "chart_unit" + "chart_unit", + "show_chart_on_interaction", + "chart_sentence" ] }, "conditions": [] diff --git a/cms/config/sync/core-store.plugin_content_manager_configuration_content_types##api##chart-data.chart-data.json b/cms/config/sync/core-store.plugin_content_manager_configuration_content_types##api##chart-data.chart-data.json index cd14bb8..d6aed27 100644 --- a/cms/config/sync/core-store.plugin_content_manager_configuration_content_types##api##chart-data.chart-data.json +++ b/cms/config/sync/core-store.plugin_content_manager_configuration_content_types##api##chart-data.chart-data.json @@ -22,7 +22,7 @@ "location_code": { "edit": { "label": "location_code", - "description": "", + "description": "Required if the chart's visibility doesn't depend on interaction with the layer. This is the case when the layer's show_chart_on_interaction attribute is false.", "placeholder": "", "visible": true, "editable": true @@ -36,7 +36,7 @@ "year": { "edit": { "label": "year", - "description": "", + "description": "Required if the chart's visibility doesn't depend on interaction with the layer. This is the case when the layer's show_chart_on_interaction attribute is false.", "placeholder": "", "visible": true, "editable": true @@ -90,6 +90,20 @@ "sortable": true } }, + "unique_identifier": { + "edit": { + "label": "unique_identifier", + "description": "Required if the chart's visibility depends on interaction with the layer. This is the case when the layer's show_chart_on_interaction attribute is true.", + "placeholder": "", + "visible": true, + "editable": true + }, + "list": { + "label": "unique_identifier", + "searchable": true, + "sortable": true + } + }, "createdAt": { "edit": { "label": "createdAt", @@ -155,16 +169,20 @@ { "name": "layer", "size": 6 - }, - { - "name": "location_code", - "size": 6 } ], [ + { + "name": "location_code", + "size": 4 + }, { "name": "year", "size": 4 + }, + { + "name": "unique_identifier", + "size": 4 } ], [ 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 8d3e28e..b9831d0 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 @@ -195,15 +195,17 @@ } }, "layouts": { + "list": [ + "id", + "topic", + "sub_topic", + "name" + ], "edit": [ [ { "name": "name", "size": 6 - }, - { - "name": "order", - "size": 6 } ], [ @@ -237,14 +239,13 @@ "name": "metadata", "size": 12 } + ], + [ + { + "name": "order", + "size": 4 + } ] - ], - "list": [ - "id", - "topic", - "sub_topic", - "name", - "order" ] }, "uid": "api::dataset.dataset" @@ -252,4 +253,4 @@ "type": "object", "environment": null, "tag": null -} \ No newline at end of file +} diff --git a/cms/config/sync/core-store.plugin_content_manager_configuration_content_types##api##layer.layer.json b/cms/config/sync/core-store.plugin_content_manager_configuration_content_types##api##layer.layer.json index 9116df2..470517b 100644 --- a/cms/config/sync/core-store.plugin_content_manager_configuration_content_types##api##layer.layer.json +++ b/cms/config/sync/core-store.plugin_content_manager_configuration_content_types##api##layer.layer.json @@ -147,6 +147,34 @@ "sortable": true } }, + "show_chart_on_interaction": { + "edit": { + "label": "show_chart_on_interaction", + "description": "Whether the chart should only be shown once the user has interacted with the layer", + "placeholder": "", + "visible": true, + "editable": true + }, + "list": { + "label": "show_chart_on_interaction", + "searchable": true, + "sortable": true + } + }, + "chart_sentence": { + "edit": { + "label": "chart_sentence", + "description": "Sentence displayed below the chart. If show_chart_on_interaction is true, the selected feature's attributes can be accessed as follows: {name_of_attribute}.", + "placeholder": "", + "visible": true, + "editable": true + }, + "list": { + "label": "chart_sentence", + "searchable": true, + "sortable": true + } + }, "createdAt": { "edit": { "label": "createdAt", @@ -207,12 +235,6 @@ } }, "layouts": { - "list": [ - "id", - "dataset", - "type", - "name" - ], "edit": [ [ { @@ -263,7 +285,25 @@ "name": "chart_unit", "size": 6 } + ], + [ + { + "name": "show_chart_on_interaction", + "size": 6 + } + ], + [ + { + "name": "chart_sentence", + "size": 12 + } ] + ], + "list": [ + "id", + "dataset", + "type", + "name" ] }, "uid": "api::layer.layer" diff --git a/cms/config/sync/core-store.plugin_content_manager_configuration_content_types##api##sub-topic.sub-topic.json b/cms/config/sync/core-store.plugin_content_manager_configuration_content_types##api##sub-topic.sub-topic.json index ad87547..b136cfb 100644 --- a/cms/config/sync/core-store.plugin_content_manager_configuration_content_types##api##sub-topic.sub-topic.json +++ b/cms/config/sync/core-store.plugin_content_manager_configuration_content_types##api##sub-topic.sub-topic.json @@ -107,6 +107,11 @@ } }, "layouts": { + "list": [ + "id", + "name", + "order" + ], "edit": [ [ { @@ -118,11 +123,6 @@ "size": 4 } ] - ], - "list": [ - "id", - "name", - "order" ] }, "uid": "api::sub-topic.sub-topic" @@ -130,4 +130,4 @@ "type": "object", "environment": null, "tag": null -} \ No newline at end of file +} diff --git a/cms/src/api/chart-data/content-types/chart-data/schema.json b/cms/src/api/chart-data/content-types/chart-data/schema.json index 5d224e4..5536283 100644 --- a/cms/src/api/chart-data/content-types/chart-data/schema.json +++ b/cms/src/api/chart-data/content-types/chart-data/schema.json @@ -14,12 +14,12 @@ "attributes": { "location_code": { "type": "string", - "required": true + "required": false }, "year": { "type": "integer", "min": 0, - "required": true + "required": false }, "x_values": { "type": "json", @@ -34,6 +34,9 @@ "relation": "manyToOne", "target": "api::layer.layer", "inversedBy": "chart_data" + }, + "unique_identifier": { + "type": "string" } } } diff --git a/cms/src/api/layer/content-types/layer/schema.json b/cms/src/api/layer/content-types/layer/schema.json index 2a22619..2b705e9 100644 --- a/cms/src/api/layer/content-types/layer/schema.json +++ b/cms/src/api/layer/content-types/layer/schema.json @@ -55,6 +55,13 @@ }, "chart_unit": { "type": "string" + }, + "show_chart_on_interaction": { + "type": "boolean", + "default": false + }, + "chart_sentence": { + "type": "text" } } } 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 3785c14..1196f28 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-22T14:01:53.948Z" + "x-generation-date": "2024-11-26T13:48:27.187Z" }, "x-strapi-config": { "path": "/documentation", @@ -94,8 +94,6 @@ "properties": { "data": { "required": [ - "location_code", - "year", "x_values", "y_values" ], @@ -119,6 +117,9 @@ } ], "example": "string or id" + }, + "unique_identifier": { + "type": "string" } } } @@ -173,8 +174,6 @@ "ChartData": { "type": "object", "required": [ - "location_code", - "year", "x_values", "y_values" ], @@ -824,6 +823,9 @@ } } }, + "unique_identifier": { + "type": "string" + }, "createdAt": { "type": "string", "format": "date-time" @@ -876,6 +878,12 @@ "chart_unit": { "type": "string" }, + "show_chart_on_interaction": { + "type": "boolean" + }, + "chart_sentence": { + "type": "string" + }, "createdAt": { "type": "string", "format": "date-time" @@ -924,6 +932,9 @@ } } }, + "unique_identifier": { + "type": "string" + }, "createdAt": { "type": "string", "format": "date-time" @@ -1759,6 +1770,9 @@ } } }, + "unique_identifier": { + "type": "string" + }, "createdAt": { "type": "string", "format": "date-time" @@ -1811,6 +1825,12 @@ "chart_unit": { "type": "string" }, + "show_chart_on_interaction": { + "type": "boolean" + }, + "chart_sentence": { + "type": "string" + }, "createdAt": { "type": "string", "format": "date-time" @@ -2082,6 +2102,12 @@ }, "chart_unit": { "type": "string" + }, + "show_chart_on_interaction": { + "type": "boolean" + }, + "chart_sentence": { + "type": "string" } } } @@ -2304,6 +2330,9 @@ } } }, + "unique_identifier": { + "type": "string" + }, "createdAt": { "type": "string", "format": "date-time" @@ -2608,6 +2637,12 @@ "chart_unit": { "type": "string" }, + "show_chart_on_interaction": { + "type": "boolean" + }, + "chart_sentence": { + "type": "string" + }, "createdAt": { "type": "string", "format": "date-time" @@ -2921,6 +2956,12 @@ "chart_unit": { "type": "string" }, + "show_chart_on_interaction": { + "type": "boolean" + }, + "chart_sentence": { + "type": "string" + }, "createdAt": { "type": "string", "format": "date-time" diff --git a/cms/types/generated/components.d.ts b/cms/types/generated/components.d.ts index 0fcf675..ff7c4e4 100644 --- a/cms/types/generated/components.d.ts +++ b/cms/types/generated/components.d.ts @@ -1,5 +1,24 @@ import type { Schema, Attribute } from '@strapi/strapi'; +export interface MetadataItem extends Schema.Component { + collectionName: 'components_metadata_item'; + info: { + displayName: 'Item'; + description: ''; + }; + attributes: { + source: Attribute.String; + website: Attribute.String; + description: Attribute.Text; + main_applications: Attribute.RichText; + temporal_coverage: Attribute.RichText; + spatial_resolution: Attribute.String; + units: Attribute.String; + full_name: Attribute.String & Attribute.Required; + temporal_resolution: Attribute.RichText; + }; +} + export interface LegendLegendConfig extends Schema.Component { collectionName: 'components_legend_legend_configs'; info: { @@ -35,31 +54,12 @@ export interface LegendItems extends Schema.Component { }; } -export interface MetadataItem extends Schema.Component { - collectionName: 'components_metadata_item'; - info: { - displayName: 'Item'; - description: ''; - }; - attributes: { - source: Attribute.String; - website: Attribute.String; - description: Attribute.Text; - main_applications: Attribute.RichText; - temporal_coverage: Attribute.RichText; - spatial_resolution: Attribute.String; - units: Attribute.String; - full_name: Attribute.String & Attribute.Required; - temporal_resolution: Attribute.RichText; - }; -} - declare module '@strapi/types' { export module Shared { export interface Components { + 'metadata.item': MetadataItem; 'legend.legend-config': LegendLegendConfig; 'legend.items': LegendItems; - 'metadata.item': MetadataItem; } } } diff --git a/cms/types/generated/contentTypes.d.ts b/cms/types/generated/contentTypes.d.ts index 72c5e1c..c552e91 100644 --- a/cms/types/generated/contentTypes.d.ts +++ b/cms/types/generated/contentTypes.d.ts @@ -800,9 +800,8 @@ export interface ApiChartDataChartData extends Schema.CollectionType { draftAndPublish: false; }; attributes: { - location_code: Attribute.String & Attribute.Required; + location_code: Attribute.String; year: Attribute.Integer & - Attribute.Required & Attribute.SetMinMax< { min: 0; @@ -816,6 +815,7 @@ export interface ApiChartDataChartData extends Schema.CollectionType { 'manyToOne', 'api::layer.layer' >; + unique_identifier: Attribute.String; createdAt: Attribute.DateTime; updatedAt: Attribute.DateTime; createdBy: Attribute.Relation< @@ -919,6 +919,8 @@ export interface ApiLayerLayer extends Schema.CollectionType { 'api::chart-data.chart-data' >; chart_unit: Attribute.String; + show_chart_on_interaction: Attribute.Boolean & Attribute.DefaultTo; + chart_sentence: Attribute.Text; createdAt: Attribute.DateTime; updatedAt: Attribute.DateTime; createdBy: Attribute.Relation<