Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
agnlez committed Mar 12, 2024
1 parent 99faa1a commit b805884
Show file tree
Hide file tree
Showing 15 changed files with 783 additions and 0 deletions.
11 changes: 11 additions & 0 deletions client/src/containers/analysis-eudr-detail/filters/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import YearsRange from './years-range';

const EUDRDetailFilters = () => {
return (
<div className="flex space-x-2">
<YearsRange />
</div>
);
};

export default EUDRDetailFilters;
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import React, { useCallback, useMemo } from 'react';
import { UTCDate } from '@date-fns/utc';
import { ChevronDown } from 'lucide-react';
import { format } from 'date-fns';

import { useAppDispatch, useAppSelector } from 'store/hooks';
import { eudr, setFilters } from 'store/features/eudr';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';

import type { DateRange } from 'react-day-picker';
const dateFormatter = (date: Date) => format(date, 'yyyy-MM-dd');

// ! the date range is hardcoded for now
export const DATES_RANGE = ['2020-12-31', dateFormatter(new Date())];

const DatesRange = (): JSX.Element => {
const dispatch = useAppDispatch();
const {
filters: { dates },
} = useAppSelector(eudr);

const handleDatesChange = useCallback(
(dates: DateRange) => {
if (dates) {
dispatch(
setFilters({
dates: {
from: dateFormatter(dates.from),
to: dateFormatter(dates.to),
},
}),
);
}
},
[dispatch],
);

const datesToDate = useMemo(() => {
return {
from: dates.from ? new UTCDate(dates.from) : undefined,
to: dates.to ? new UTCDate(dates.to) : undefined,
};
}, [dates]);

return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
className="h-auto space-x-1 border border-gray-200 bg-white shadow-sm"
>
<span className="text-gray-500">
Deforestation alerts from <span className="text-gray-900">{dates.from || '-'}</span> to{' '}
<span className="text-gray-900">{dates.to || '-'}</span>
</span>
<ChevronDown className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="flex w-auto space-x-2" align="start">
<Calendar
mode="range"
numberOfMonths={2}
disabled={{
before: new UTCDate(DATES_RANGE[0]),
after: new UTCDate(DATES_RANGE[1]),
}}
selected={datesToDate}
onSelect={handleDatesChange}
/>
</PopoverContent>
</Popover>
);
};

export default DatesRange;
110 changes: 110 additions & 0 deletions client/src/containers/analysis-eudr-detail/map/component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { useEffect, useState, useCallback } from 'react';
import DeckGL from '@deck.gl/react/typed';
import { GeoJsonLayer } from '@deck.gl/layers/typed';
import Map from 'react-map-gl/maplibre';
import { WebMercatorViewport, type MapViewState } from '@deck.gl/core/typed';
import bbox from '@turf/bbox';

import ZoomControl from './zoom';
import LegendControl from './legend';

import BasemapControl from '@/components/map/controls/basemap';
import { INITIAL_VIEW_STATE, MAP_STYLES } from '@/components/map';
import { usePlotGeometries } from '@/hooks/eudr';

import type { BasemapValue } from '@/components/map/controls/basemap/types';
import type { MapStyle } from '@/components/map/types';

const EUDRMap = () => {
const [mapStyle, setMapStyle] = useState<MapStyle>('terrain');
const [viewState, setViewState] = useState<MapViewState>(INITIAL_VIEW_STATE);

const plotGeometries = usePlotGeometries();

const layer: GeoJsonLayer = new GeoJsonLayer({
id: 'geojson-layer',
data: plotGeometries.data,
// Styles
filled: true,
getFillColor: [255, 176, 0, 84],
stroked: true,
getLineColor: [255, 176, 0, 255],
getLineWidth: 1,
lineWidthUnits: 'pixels',
// Interactive props
pickable: true,
autoHighlight: true,
highlightColor: [255, 176, 0, 255],
});

const layers = [layer];

const handleMapStyleChange = useCallback((newStyle: BasemapValue) => {
setMapStyle(newStyle);
}, []);

const handleZoomIn = useCallback(() => {
const zoom = viewState.maxZoom === viewState.zoom ? viewState.zoom : viewState.zoom + 1;
setViewState({ ...viewState, zoom });
}, [viewState]);

const handleZoomOut = useCallback(() => {
const zoom = viewState.maxZoom === viewState.zoom ? viewState.zoom : viewState.zoom - 1;
setViewState({ ...viewState, zoom });
}, [viewState]);

const fitToPlotBounds = useCallback(() => {
if (!plotGeometries.data) return;
const [minLng, minLat, maxLng, maxLat] = bbox(plotGeometries.data);
const newViewport = new WebMercatorViewport(viewState);
const { longitude, latitude, zoom } = newViewport.fitBounds(
[
[minLng, minLat],
[maxLng, maxLat],
],
{
padding: 10,
},
);
if (
viewState.latitude !== latitude ||
viewState.longitude !== longitude ||
viewState.zoom !== zoom
) {
setViewState({ ...viewState, longitude, latitude, zoom });
}
}, [plotGeometries.data, viewState]);

// Fit to bounds when data is loaded or changed
useEffect(() => {
if (plotGeometries.data) {
fitToPlotBounds();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [plotGeometries.data]);

const handleResize = useCallback(() => {
setTimeout(() => fitToPlotBounds(), 0);
}, [fitToPlotBounds]);

return (
<>
<DeckGL
viewState={{ ...viewState }}
onViewStateChange={({ viewState }) => setViewState(viewState as MapViewState)}
controller={{ dragRotate: false }}
layers={layers}
onResize={handleResize}
>
<Map reuseMaps mapStyle={MAP_STYLES[mapStyle]} styleDiffing={false} />
</DeckGL>
<div className="absolute bottom-10 right-6 z-10 w-10 space-y-2">
<BasemapControl value={mapStyle} onChange={handleMapStyleChange} />
<ZoomControl viewState={viewState} onZoomIn={handleZoomIn} onZoomOut={handleZoomOut} />
<LegendControl />
</div>
</>
);
};

export default EUDRMap;
1 change: 1 addition & 0 deletions client/src/containers/analysis-eudr-detail/map/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './component';
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { useState } from 'react';
import classNames from 'classnames';
import { MinusIcon, PlusIcon } from '@heroicons/react/outline';

import LegendItem from './item';

import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import SandwichIcon from '@/components/icons/sandwich';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';

const EURDLegend = () => {
const [isOpen, setIsOpen] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);

return (
<div>
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={classNames(
'relative flex h-10 w-10 items-center justify-center rounded-lg p-1.5 text-black transition-colors hover:text-navy-400',
isOpen ? 'bg-navy-400 text-white hover:text-white' : 'bg-white',
)}
>
<SandwichIcon />
</button>
</PopoverTrigger>
<PopoverContent align="end" side="left" className="p-0">
<div className="divide-y">
<h2 className="px-4 py-2 text-sm font-normal">Legend</h2>
<div>
<LegendItem
title="Suppliers with specified splot of land"
description="Suppliers with necessary geographical data of land to assess deforestation risk linked to a commodity."
iconClassName="border-2 border-orange-500 bg-orange-500/30"
/>
</div>
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-end px-4 py-2">
<button type="button" className="flex items-center space-x-2">
<div className="text-xs text-navy-400">Add contextual layers</div>
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-navy-400">
{isExpanded ? (
<MinusIcon className="h-4 w-4 text-white" />
) : (
<PlusIcon className="h-4 w-4 text-white" />
)}
</div>
</button>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="bg-navy-50">
<LegendItem
title="Forest Cover 2020 (EC JRC)"
description="Spatial explicit description of forest presence and absence in the year 2020."
iconClassName="bg-[#72A950]"
/>
<LegendItem
title="Real time deforestation alerts since 2020 (UMD/GLAD)"
description="Monitor forest disturbance in near-real-time using integrated alerts from three alerting systems."
iconClassName="bg-[#C92A6D]"
/>
</CollapsibleContent>
</Collapsible>
</div>
</PopoverContent>
</Popover>
</div>
);
};

export default EURDLegend;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './component';
33 changes: 33 additions & 0 deletions client/src/containers/analysis-eudr-detail/map/legend/item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import classNames from 'classnames';

import type { FC, PropsWithChildren } from 'react';

type LegendItemProps = { title: string; description: string; iconClassName?: string };

const LegendItem: FC<PropsWithChildren<LegendItemProps>> = ({
title,
description,
children,
iconClassName,
}) => {
return (
<div className="flex space-x-2 p-4">
<div
className={classNames(
'mt-0.5 h-3 w-3 shrink-0',
iconClassName ?? 'border-2 border-gray-500 bg-gray-500/30',
)}
/>
<div className="flex-1 space-y-1">
<div>
<h3 className="text-xs font-normal">{title}</h3>
<div></div>
</div>
<p className="text-xs text-gray-500">{description}</p>
{children}
</div>
</div>
);
};

export default LegendItem;
51 changes: 51 additions & 0 deletions client/src/containers/analysis-eudr-detail/map/zoom/component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { MinusIcon, PlusIcon } from '@heroicons/react/solid';
import cx from 'classnames';

import type { MapViewState } from '@deck.gl/core/typed';
import type { FC } from 'react';

const COMMON_CLASSES =
'p-2 transition-colors bg-white cursor-pointer hover:bg-gray-100 active:bg-navy-50 disabled:bg-gray-100 disabled:opacity-75 disabled:cursor-default';

const ZoomControl: FC<{
viewState: MapViewState;
className?: string;
onZoomIn: () => void;
onZoomOut: () => void;
}> = ({ viewState, className = null, onZoomIn, onZoomOut }) => {
const { zoom, minZoom, maxZoom } = viewState;

return (
<div
className={cx(
'ml-auto flex select-none flex-col justify-center gap-[1.5px] overflow-hidden rounded-lg border border-gray-200 text-4xl text-gray-900',
className,
)}
>
<button
className={COMMON_CLASSES}
aria-label="Zoom in"
type="button"
// ? Sometimes, depending on the viewport, the map will not reach zoom 22 but 21.X.
// ? As we have no control over this, we are assuming, if no maxZoom is set, going further zoom level 21 will be considered as the limit
disabled={zoom >= maxZoom || (!maxZoom && zoom > 21)}
onClick={onZoomIn}
>
<PlusIcon className="h-5 w-5" />
</button>
<button
className={COMMON_CLASSES}
aria-label="Zoom out"
type="button"
// ? Sometimes, depending on the viewport, the map will not reach zoom 0 but 0.X.
// ? As we have no control over this, we are assuming, if no minZoom is set, going below zoom level 1 will be considered as the limit
disabled={zoom <= minZoom || (!minZoom && zoom < 1)}
onClick={onZoomOut}
>
<MinusIcon className="h-5 w-5" />
</button>
</div>
);
};

export default ZoomControl;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './component';
Loading

0 comments on commit b805884

Please sign in to comment.