Skip to content

Commit

Permalink
Merge pull request #33
Browse files Browse the repository at this point in the history
Line charts for temporal layers
  • Loading branch information
clementprdhomme authored Nov 22, 2024
2 parents 3489de0 + c68d225 commit 7446b0b
Show file tree
Hide file tree
Showing 5 changed files with 862 additions and 15 deletions.
7 changes: 7 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@
"@turf/helpers": "7.1.0",
"@turf/meta": "7.1.0",
"@types/mapbox-gl": "3.4.0",
"@visx/axis": "3.12.0",
"@visx/gradient": "3.12.0",
"@visx/grid": "3.12.0",
"@visx/responsive": "3.12.0",
"@visx/scale": "3.12.0",
"@visx/shape": "3.12.0",
"@visx/text": "3.12.0",
"apng-js": "1.1.4",
"axios": "1.7.7",
"class-variance-authority": "0.7.0",
Expand Down
45 changes: 31 additions & 14 deletions client/src/components/dataset-card/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} 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 useMapLayers from "@/hooks/use-map-layers";
import { cn } from "@/lib/utils";
import CalendarDaysIcon from "@/svgs/calendar-days.svg";
Expand Down Expand Up @@ -100,6 +101,18 @@ const DatasetCard = ({ id, name, defaultLayerId, layers, metadata }: DatasetCard
[layers, selectedLayerId],
);

const onToggleAnimation = useCallback(() => {
const newIsAnimated = !isAnimated;

if (newIsAnimated) {
dateBeforeAnimationRef.current = selectedDate !== undefined ? selectedDate : null;
} else {
dateBeforeAnimationRef.current = null;
}

setIsAnimated(newIsAnimated);
}, [selectedDate, isAnimated, setIsAnimated]);

const onToggleDataset = useCallback(
(active: boolean) => {
if (selectedLayerId === undefined) {
Expand All @@ -108,11 +121,22 @@ const DatasetCard = ({ id, name, defaultLayerId, layers, metadata }: DatasetCard

if (!active) {
removeLayer(selectedLayerId);
if (isAnimated) {
onToggleAnimation();
}
} else {
addLayer(selectedLayerId, { ["return-period"]: selectedReturnPeriod, date: selectedDate });
}
},
[selectedLayerId, addLayer, removeLayer, selectedReturnPeriod, selectedDate],
[
selectedLayerId,
addLayer,
removeLayer,
selectedReturnPeriod,
selectedDate,
isAnimated,
onToggleAnimation,
],
);

const onChangeSelectedLayer = useCallback(
Expand Down Expand Up @@ -183,18 +207,6 @@ const DatasetCard = ({ id, name, defaultLayerId, layers, metadata }: DatasetCard
[selectedLayerId, isDatasetActive, addLayer, updateLayer, layers, layersConfiguration],
);

const onToggleAnimation = useCallback(() => {
const newIsAnimated = !isAnimated;

if (newIsAnimated) {
dateBeforeAnimationRef.current = selectedDate !== undefined ? selectedDate : null;
} else {
dateBeforeAnimationRef.current = null;
}

setIsAnimated(newIsAnimated);
}, [selectedDate, isAnimated, setIsAnimated]);

// When the layer is animated, show each month of the year in a loop
useEffect(() => {
if (isAnimated && selectedDate !== undefined && selectedLayerId !== undefined) {
Expand Down Expand Up @@ -315,8 +327,13 @@ const DatasetCard = ({ id, name, defaultLayerId, layers, metadata }: DatasetCard
</SelectContent>
</Select>
)}
{selectedDate !== undefined && selectedLayerId !== undefined && (
<div className="mt-3">
<YearChart layerId={selectedLayerId} date={selectedDate} active={isDatasetActive} />
</div>
)}
{selectedDate !== undefined && dateRange !== undefined && isDatasetActive && (
<div className="flex items-center justify-between gap-4">
<div className="mt-1 flex items-center justify-between gap-4">
<Button
type="button"
variant="ghost"
Expand Down
272 changes: 272 additions & 0 deletions client/src/components/year-chart/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
"use client";

import { AxisBottom, AxisLeft, AxisTop } from "@visx/axis";
import { LinearGradient } from "@visx/gradient";
import { GridColumns, GridRows } from "@visx/grid";
import { Group } from "@visx/group";
import { useParentSize } from "@visx/responsive";
import { scaleLinear, scalePoint } from "@visx/scale";
import { AreaClosed, Line, LinePath } from "@visx/shape";
import { Text, TextProps } from "@visx/text";
import { extent } from "d3-array";
import { interpolateRgb, piecewise } from "d3-interpolate";
import { format } from "date-fns/format";
import { ComponentProps, useCallback, useMemo } from "react";

import { Skeleton } from "@/components/ui/skeleton";
import useYearChartData from "@/hooks/use-year-chart-data";
import tailwindConfig from "@/lib/tailwind-config";
import { cn } from "@/lib/utils";

interface YearChartProps {
layerId: number;
date: string;
active: 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_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 GRADIENT_OPACITY_EXTENT = [0.9, 0.7];

const YearChart = ({ layerId, date, active }: YearChartProps) => {
const { parentRef, width } = useParentSize({ ignoreDimensions: ["height"] });
const height = useMemo(
() => Math.max(Math.min(width / 2.7, CHART_MAX_HEIGHT), CHART_MIN_HEIGHT),
[width],
);

const { data, isLoading } = useYearChartData(layerId, date);

const unitWidth = useMemo(() => {
if (isLoading || !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, isLoading]);

const xScale = useMemo(() => {
if (isLoading || !data) {
return undefined;
}

return scalePoint({
range: [0, width - Y_AXIS_WIDTH - X_AXIS_OFFSET_RIGHT - unitWidth],
domain: data.data.map(({ x }) => x),
});
}, [width, data, isLoading, unitWidth]);

const yScale = useMemo(() => {
if (isLoading || !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, isLoading]);

const fillColorScale = useMemo(() => {
if (isLoading || !data) {
return undefined;
}

return scaleLinear({
range: data.colorRange,
domain: data.colorDomain,
}).interpolate(() => piecewise(interpolateRgb, data.colorRange));
}, [data, isLoading]);

const xAxisTickLabelProps = useCallback<
NonNullable<Exclude<ComponentProps<typeof AxisBottom>["tickLabelProps"], Partial<TextProps>>>
>((tick, index, ticks) => {
let textAnchor: ComponentProps<typeof Text>["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<Exclude<ComponentProps<typeof AxisLeft>["tickLabelProps"], Partial<TextProps>>>
>((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",
};
}, []);

const gradientColorStops = useMemo(() => {
if (!fillColorScale || !yScale) {
return [];
}

const yScaleDomainReversed = yScale.domain().reverse();

return fillColorScale.range().map((color, index) => {
const normalizedIndex = index / (fillColorScale.range().length - 1);

const stopValue =
yScaleDomainReversed[0] +
normalizedIndex * (yScaleDomainReversed[1] - yScaleDomainReversed[0]);

const stopOpacity =
GRADIENT_OPACITY_EXTENT[0] -
normalizedIndex * (GRADIENT_OPACITY_EXTENT[0] - GRADIENT_OPACITY_EXTENT[1]);

return {
offset: `${normalizedIndex * 100}%`,
stopColor: fillColorScale(stopValue),
stopOpacity,
};
});
}, [fillColorScale, yScale]);

return (
<div ref={parentRef}>
{isLoading && !data && <Skeleton style={{ width: `${width}px`, height: `${height}px` }} />}
{!isLoading && !!data && !!xScale && !!yScale && !!fillColorScale && (
<svg
width={width}
height={height}
className={cn({
"transition-all duration-500": true,
grayscale: !active,
"grayscale-0": active,
})}
>
<LinearGradient id={`year-chart-gradient-${layerId}`}>
{gradientColorStops.map((stop, index) => (
<stop key={index} {...stop} />
))}
</LinearGradient>
<Group left={Y_AXIS_WIDTH}>
<GridRows
width={width - Y_AXIS_WIDTH - unitWidth}
height={height - X_AXIS_HEIGHT - Y_AXIS_OFFSET_TOP}
scale={yScale}
numTicks={Y_AXIS_TICK_COUNT}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
stroke={tailwindConfig.theme.colors["casper-blue"]["400"]}
strokeOpacity={0.5}
/>
<GridColumns
top={Y_AXIS_OFFSET_TOP}
width={width - X_AXIS_OFFSET_RIGHT - unitWidth}
height={height - X_AXIS_HEIGHT - Y_AXIS_OFFSET_TOP}
scale={xScale}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
stroke={tailwindConfig.theme.colors["casper-blue"]["400"]}
strokeOpacity={0.5}
/>
<AreaClosed<(typeof data.data)[0]>
data={data.data}
x={(d) => xScale(d.x) ?? 0}
y={(d) => yScale(d.y) ?? 0}
yScale={yScale}
fill={`url('#year-chart-gradient-${layerId}')`}
/>
<LinePath
data={data.data}
x={(d) => xScale(d.x) ?? 0}
y={(d) => yScale(d.y) ?? 0}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
stroke={data.colorRange.slice(-1)[0]}
strokeWidth={1}
/>
</Group>
<AxisLeft
scale={yScale}
left={Y_AXIS_WIDTH}
tickLength={Y_AXIS_TICK_WIDTH}
numTicks={Y_AXIS_TICK_COUNT}
tickClassName="[&>line]:stroke-casper-blue-400/50"
tickLabelProps={yAxisTickLabelProps}
axisLineClassName="opacity-0"
/>
<Group left={Y_AXIS_WIDTH}>
{/* This axis serves to extend the grid vertically towards the top */}
<AxisTop
scale={xScale}
top={Y_AXIS_OFFSET_TOP}
tickLength={Y_AXIS_OFFSET_TOP}
tickComponent={() => null}
tickLabelProps={xAxisTickLabelProps}
tickClassName="[&>line]:stroke-casper-blue-400/50"
axisLineClassName="opacity-0"
/>
<AxisBottom
scale={xScale}
top={height - X_AXIS_HEIGHT}
tickLength={X_AXIS_TICK_HEIGHT}
tickLabelProps={xAxisTickLabelProps}
tickClassName="[&>line]:stroke-casper-blue-400/50"
axisLineClassName="stroke-casper-blue-400/50"
/>
<Line
from={{ x: xScale(format(date, "MMM")), y: yScale.range()[1] - Y_AXIS_OFFSET_TOP }}
to={{ x: xScale(format(date, "MMM")), y: yScale.range()[0] + X_AXIS_TICK_HEIGHT }}
className={cn({
"stroke-supernova-yellow-400 stroke-2 transition-opacity duration-500": true,
"opacity-0": !active,
"opacity-100": active,
})}
/>
</Group>
<Text
x={width}
y={Y_AXIS_OFFSET_TOP}
dy={5}
textAnchor="end"
className="text-right font-sans text-[11px] text-rhino-blue-950"
>
{data.unit}
</Text>
</svg>
)}
</div>
);
};

export default YearChart;
Loading

0 comments on commit 7446b0b

Please sign in to comment.