Skip to content

Commit

Permalink
Merge pull request #7 from Vizzuality/SKY30-56-fe-implement-the-locat…
Browse files Browse the repository at this point in the history
…ion-selector

[SKY30-56] Data tool page, location selector, location atoms/store, minor design tweaks
  • Loading branch information
SARodrigues authored Oct 23, 2023
2 parents 6886eaf + 94665f4 commit 5353a14
Show file tree
Hide file tree
Showing 16 changed files with 459 additions and 3 deletions.
5 changes: 5 additions & 0 deletions frontend/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ const nextConfig = {
destination: '/dashboard/worldwide',
permanent: false,
},
{
source: '/data-tool',
destination: '/data-tool/GLOB',
permanent: false,
},
];
},
};
Expand Down
68 changes: 68 additions & 0 deletions frontend/src/components/charts/horizontal-bar-chart/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useMemo } from 'react';

import { cn } from '@/lib/utils';

const DEFAULT_BAR_COLOR = '#1E1E1E';
const DEFAULT_MAX_PERCENTAGE = 55;
const PROTECTION_TARGET = 30;

type HorizontalBarChartProps = {
className: string;
data: {
barColor: string;
title: string;
totalArea: number;
protectedArea: number;
};
};

const HorizontalBarChart: React.FC<HorizontalBarChartProps> = ({ className, data }) => {
const { title, barColor, totalArea, protectedArea } = data;

const targetPositionPercentage = useMemo(() => {
return (PROTECTION_TARGET * 100) / DEFAULT_MAX_PERCENTAGE;
}, []);

const protectedAreaPercentage = useMemo(() => {
return ((protectedArea * 100) / totalArea).toFixed(1);
}, [totalArea, protectedArea]);

const barFillPercentage = useMemo(() => {
return Math.round((protectedArea * DEFAULT_MAX_PERCENTAGE) / totalArea);
}, [protectedArea, totalArea]);

return (
<div className={cn(className)}>
<div className="flex justify-end text-3xl font-bold">{protectedAreaPercentage}%</div>
<div className="flex justify-between text-xs">
<span>{title} (i)</span>
<span>
of {totalArea} km<sup>2</sup>
</span>
</div>
<div className="relative my-2 flex h-3">
<span className="absolute top-1/2 h-px w-full border-b border-dashed border-black"></span>
<span
className="absolute top-0 bottom-0 left-0"
style={{ backgroundColor: barColor || DEFAULT_BAR_COLOR, width: `${barFillPercentage}%` }}
></span>
<span
className="absolute top-0 bottom-0 border-r-2 border-orange"
style={{
width: `${targetPositionPercentage}%`,
}}
>
<span className="absolute right-0 top-5 whitespace-nowrap text-xs text-orange">
30% target
</span>
</span>
</div>
<div className="flex justify-between text-xs">
<span>0%</span>
<span>{DEFAULT_MAX_PERCENTAGE}%</span>
</div>
</div>
);
};

export default HorizontalBarChart;
7 changes: 4 additions & 3 deletions frontend/src/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ import { cn } from '@/lib/utils';
import ArrowRight from '@/styles/icons/arrow-right.svg?sprite';

const navigation = [
{ name: 'Map', href: '/map', colorClassName: 'text-blue fill-blue' },
{ name: 'Dashboard', href: '/dashboard', colorClassName: 'text-blue fill-blue' },
{ name: 'Data tool', href: '/data-tool', colorClassName: 'text-blue fill-blue' },
// { name: 'Map', href: '/map', colorClassName: 'text-blue fill-blue' },
// { name: 'Dashboard', href: '/dashboard', colorClassName: 'text-blue fill-blue' },
{ name: 'Knowledge Hub', href: '/knowledge-hub', colorClassName: 'text-green fill-green' },
{ name: 'About', href: '/about', colorClassName: 'text-black fill-black' },
{ name: 'Contact', href: '/contact', colorClassName: 'text-black fill-black' },
Expand All @@ -27,7 +28,7 @@ const navigation = [
const Header: React.FC = () => (
<header className="border-b border-black bg-white font-mono text-sm text-black">
<nav
className="mx-auto flex max-w-7xl items-center justify-between p-6 py-2.5 md:py-4 lg:px-10"
className="mx-auto flex items-center justify-between p-6 py-2.5 md:py-4 lg:px-10"
aria-label="Global"
>
<Link
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/components/widget/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { PropsWithChildren } from 'react';

type WidgetProps = {
title: string;
lastUpdated: string;
};

const Widget: React.FC<PropsWithChildren<WidgetProps>> = ({ title, lastUpdated, children }) => {
return (
<div>
<div>
<h2 className="font-sans text-xl font-bold">{title}</h2>
<span className="text-xs">Data last updated: {lastUpdated}</span>
</div>
<div>{children}</div>
</div>
);
};

export default Widget;
8 changes: 8 additions & 0 deletions frontend/src/constants/habitat-chart-colors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const HABITAT_CHART_COLORS = {
'warm-water corals': '#AD6CFF',
'cold-water corals': '#04D8F4',
mangroves: '#FD8E28',
seagrasses: '#02B07C',
saltmarshes: '#717B00',
seamounts: '#9B5400',
};
3 changes: 3 additions & 0 deletions frontend/src/constants/pages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const PAGES = {
dataTool: '/data-tool',
};
8 changes: 8 additions & 0 deletions frontend/src/containers/data-tool/content/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import Map from './map';
// import Table from './table';

const DataToolContent: React.FC = () => {
return <Map />;
};

export default DataToolContent;
48 changes: 48 additions & 0 deletions frontend/src/containers/data-tool/content/map/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useCallback } from 'react';

import { useMap } from 'react-map-gl';

import LayerManager from '@/components/layer-manager';
import Map, { ZoomControls, LayersDropdown, Legend, Attributions, Drawing } from '@/components/map';
import { useSyncMapSettings } from '@/containers/map/sync-settings';

const DataToolMap: React.FC = () => {
const [{ bbox }, setMapSettings] = useSyncMapSettings();
const { default: map } = useMap();

const handleMoveEnd = useCallback(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
setMapSettings((prev) => ({
...prev,
bbox: map
.getBounds()
.toArray()
.flat()
.map((b) => parseFloat(b.toFixed(2))) as typeof bbox,
}));
}, [map, setMapSettings]);

return (
<Map
initialViewState={{
bounds: bbox,
}}
onMoveEnd={handleMoveEnd}
renderWorldCopies={false}
attributionControl={false}
>
<>
<div>
<LayersDropdown />
<Legend />
</div>
<ZoomControls />
<LayerManager />
<Drawing />
<Attributions />
</>
</Map>
);
};

export default DataToolMap;
5 changes: 5 additions & 0 deletions frontend/src/containers/data-tool/content/table/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const DatatoolTable: React.FC = () => {
return <div>Table</div>;
};

export default DatatoolTable;
48 changes: 48 additions & 0 deletions frontend/src/containers/data-tool/sidebar/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useState } from 'react';

import { useAtomValue } from 'jotai';
import { ChevronLeft } from 'lucide-react';

import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import { locationAtom } from '@/store/location';

import LocationSelector from './location-selector';
import Widgets from './widgets';

const DataToolSidebar: React.FC = () => {
const location = useAtomValue(locationAtom);

const [sidebarOpen, setSidebarOpen] = useState(true);

return (
<Collapsible open={sidebarOpen} onOpenChange={setSidebarOpen}>
<CollapsibleTrigger asChild>
<Button
type="button"
size="icon"
className={cn('absolute bottom-3 z-10', {
'hidden md:flex': true,
'left-0': !sidebarOpen,
'left-[430px] transition-[left] delay-500': sidebarOpen,
})}
>
<ChevronLeft className={cn('h-6 w-6', { 'rotate-180': !sidebarOpen })} aria-hidden />
<span className="sr-only">Toggle sidebar</span>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="relative top-0 left-0 z-20 h-full w-[430px] flex-shrink-0 bg-white py-4 fill-mode-none data-[state=closed]:animate-out-absolute data-[state=open]:animate-in-absolute data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left">
<div className="border-b border-black px-8 py-2">
<h1 className="text-5xl font-black">{location.name}</h1>
<LocationSelector className="my-2" />
</div>
<div className="px-8 py-4">
<Widgets />
</div>
</CollapsibleContent>
</Collapsible>
);
};

export default DataToolSidebar;
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useState } from 'react';

import { useRouter } from 'next/router';

import { useAtomValue } from 'jotai';
import { Check } from 'lucide-react';

import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandEmpty,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { PAGES } from '@/constants/pages';
import { cn } from '@/lib/utils';
import { locationAtom } from '@/store/location';
import { useGetLocations } from '@/types/generated/location';

type LocationSelectorProps = {
className: string;
};

const LocationSelector: React.FC<LocationSelectorProps> = ({ className }) => {
const location = useAtomValue(locationAtom);
const router = useRouter();

const [locationPopoverOpen, setLocationPopoverOpen] = useState(false);
const { data: locationsData } = useGetLocations();

const locations = locationsData?.data || [];

const handleLocationSelected = (locationCode: string) => {
setLocationPopoverOpen(false);
void router.replace(`${PAGES.dataTool}/${locationCode.toUpperCase()}`);
};

return (
<div className={cn(className)}>
<Popover open={locationPopoverOpen} onOpenChange={setLocationPopoverOpen}>
<PopoverTrigger className="text-sm font-semibold uppercase underline ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2">
Change Location
</PopoverTrigger>
<PopoverContent className="w-96 max-w-screen" align="start">
<Command label="Search country or region">
<CommandInput placeholder="Search country or region" />
<CommandEmpty>No result</CommandEmpty>
<CommandGroup className="mt-4 max-h-64 overflow-y-scroll">
{locations.map(({ attributes }) => {
const { name, code, type } = attributes;

return (
<CommandItem key={code} value={code} onSelect={handleLocationSelected}>
<div className="flex w-full cursor-pointer justify-between gap-x-4">
<div className="flex font-bold underline">
<Check
className={cn(
'relative top-1 mr-2 inline-block h-4 w-4 flex-shrink-0',
location.code === code ? 'opacity-100' : 'opacity-0'
)}
/>
{name}
</div>
<span className="flex-shrink-0 capitalize text-gray-400">{type}</span>
</div>
</CommandItem>
);
})}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
);
};

export default LocationSelector;
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useMemo } from 'react';

import HorizontalBarChart from '@/components/charts/horizontal-bar-chart';
import Widget from '@/components/widget';
import { HABITAT_CHART_COLORS } from '@/constants/habitat-chart-colors';
import { useGetHabitatStats } from '@/types/generated/habitat-stat';
import type { Location } from '@/types/generated/strapi.schemas';

type HabitatWidgetProps = {
location: Location;
};

const HabitatWidget: React.FC<HabitatWidgetProps> = ({ location }) => {
const lastUpdated = 'October 2023';

const { data: habitatStatsResponse } = useGetHabitatStats({
populate: '*',
filters: {
location: {
code: location.code,
},
},
});

const habitatStatsData = habitatStatsResponse?.data;

const widgetChartData = useMemo(() => {
if (!habitatStatsData) return [];

const parsedData = habitatStatsData.map((entry) => {
const stats = entry?.attributes;
const habitat = stats?.habitat?.data.attributes;

return {
title: habitat.name,
slug: habitat.slug,
barColor: HABITAT_CHART_COLORS[habitat.slug],
totalArea: stats.totalArea,
protectedArea: stats.protectedArea,
};
});

return parsedData.reverse();
}, [habitatStatsData]);

// If there is no data for the widget, do not display it.
if (!widgetChartData.length) return null;

return (
<Widget
title="Proportion of Habitat within Protected and Conserved Areas"
lastUpdated={lastUpdated}
>
{widgetChartData.map((chartData) => (
<HorizontalBarChart key={chartData.slug} className="py-2" data={chartData} />
))}
</Widget>
);
};

export default HabitatWidget;
Loading

0 comments on commit 5353a14

Please sign in to comment.