Skip to content

Commit

Permalink
Merge pull request #20
Browse files Browse the repository at this point in the history
Display layers and their legend
  • Loading branch information
clementprdhomme authored Nov 4, 2024
2 parents 9e27dc9 + 89aa0b1 commit 560deb3
Show file tree
Hide file tree
Showing 44 changed files with 3,215 additions and 761 deletions.
9 changes: 9 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,22 @@
},
"dependencies": {
"@artsy/fresnel": "7.1.4",
"@deck.gl/core": "9.0.34",
"@deck.gl/json": "9.0.34",
"@deck.gl/layers": "9.0.34",
"@dnd-kit/core": "6.1.0",
"@dnd-kit/modifiers": "7.0.0",
"@dnd-kit/sortable": "8.0.0",
"@radix-ui/react-checkbox": "1.1.2",
"@radix-ui/react-collapsible": "1.1.1",
"@radix-ui/react-dialog": "1.1.2",
"@radix-ui/react-label": "2.1.0",
"@radix-ui/react-popover": "1.1.2",
"@radix-ui/react-radio-group": "1.2.1",
"@radix-ui/react-separator": "1.1.0",
"@radix-ui/react-slider": "1.2.1",
"@radix-ui/react-slot": "1.1.0",
"@radix-ui/react-switch": "1.1.1",
"@radix-ui/react-tooltip": "1.1.3",
"@t3-oss/env-nextjs": "0.11.1",
"@tanstack/react-query": "5.59.16",
Expand Down
21 changes: 18 additions & 3 deletions client/src/components/map/controls/index.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,31 @@
import useMapLayers from "@/hooks/use-map-layers";
import { cn } from "@/lib/utils";

import ContextualLayersControls from "./contextual-layers";
import LegendControls from "./legend";
import MapSettingsControls from "./map-settings";
import ZoomControls from "./zoom";

const Controls = () => {
const [layers] = useMapLayers();

return (
<>
<div className="absolute bottom-20 right-5 z-10 leading-none xl:bottom-auto xl:right-10 xl:top-6">
<div
className={cn({
"absolute right-5 z-10 leading-none xl:bottom-auto xl:right-10 xl:top-6": true,
"bottom-[120px]": layers.length > 0,
"bottom-[80px]": layers.length === 0,
})}
>
<ContextualLayersControls />
</div>
<div className="absolute bottom-10 right-5 z-10 flex flex-col gap-2 xl:bottom-6 xl:right-10">
<div className="absolute bottom-10 right-5 z-10 flex flex-col items-end gap-2 xl:bottom-6 xl:right-10">
<ZoomControls />
<MapSettingsControls />
<div className="flex flex-col gap-2 xl:flex-row-reverse xl:gap-6">
<MapSettingsControls />
<LegendControls />
</div>
</div>
</>
);
Expand Down
57 changes: 57 additions & 0 deletions client/src/components/map/controls/legend.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import Legend from "@/components/map/legend";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import useMapLayers from "@/hooks/use-map-layers";
import { Media, MediaContextProvider } from "@/media";
import ChevronDownIcon from "@/svgs/chevron-down.svg";
import ListBulletIcon from "@/svgs/list-bullet.svg";

const LegendControls = () => {
const [layers] = useMapLayers();

if (layers.length === 0) {
return null;
}

return (
<MediaContextProvider>
<Media lessThan="xl" className="leading-none">
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="yellow" className="w-8 font-sans">
<span className="sr-only">Legend</span>
<ListBulletIcon aria-hidden />
</Button>
</PopoverTrigger>
<PopoverContent
side="left"
align="end"
className="max-h-[500px] w-[300px] max-w-[calc(100vw_-_theme(spacing.8)_-_2_*_theme(spacing.5)_-_24px)] overflow-y-auto"
>
<Legend />
</PopoverContent>
</Popover>
</Media>
<Media greaterThanOrEqual="xl" className="relative">
<Collapsible className="absolute bottom-0 right-0" defaultOpen>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="yellow-alt"
className="group w-[300px] justify-between px-2.5 font-sans"
>
<span>Legend</span>
<ChevronDownIcon aria-hidden className="group-data-[state=closed]:rotate-180" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="max-h-[500px] w-[300px] overflow-y-auto border-t border-t-casper-blue-400 bg-white">
<Legend />
</CollapsibleContent>
</Collapsible>
</Media>
</MediaContextProvider>
);
};

export default LegendControls;
2 changes: 2 additions & 0 deletions client/src/components/map/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactMapGL from "react-map-gl";

import LayerManager from "@/components/map/layer-manager";
import { SIDEBAR_WIDTH } from "@/components/ui/sidebar";
import { env } from "@/env";
import useApplyMapSettings from "@/hooks/use-apply-map-settings";
Expand Down Expand Up @@ -72,6 +73,7 @@ const Map = () => {
logoPosition="bottom-right"
onLoad={() => setMap(mapRef.current)}
>
<LayerManager />
<Controls />
</ReactMapGL>
);
Expand Down
48 changes: 48 additions & 0 deletions client/src/components/map/layer-manager/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useMemo } from "react";
import { Layer } from "react-map-gl";

import useMapLayers from "@/hooks/use-map-layers";

import LayerManagerItem from "./item";

const LayerManager = () => {
const [layers] = useMapLayers();

/**
* These layers are here to aid with positioning the real data layers between themselves.
* See more: https://github.com/visgl/react-map-gl/issues/939#issuecomment-625290200
*/
const positioningLayers = useMemo(() => {
return layers.map((layer, index) => {
const beforeId = index === 0 ? "data-layers" : `layer-position-${layers[index - 1].id}`;
return (
<Layer
key={`layer-position-${layer.id}`}
id={`layer-position-${layer.id}`}
type="background"
layout={{ visibility: "none" }}
beforeId={beforeId}
/>
);
});
}, [layers]);

const layerManagerItems = useMemo(() => {
return layers.map((layer, index) => {
const beforeId = index === 0 ? "data-layers" : `layer-position-${layers[index - 1].id}`;
const { id, ...settings } = layer;
return (
<LayerManagerItem key={`layer-${id}`} id={id} settings={settings} beforeId={beforeId} />
);
});
}, [layers]);

return (
<>
{positioningLayers}
{layerManagerItems}
</>
);
};

export default LayerManager;
28 changes: 28 additions & 0 deletions client/src/components/map/layer-manager/item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Layer, Source } from "react-map-gl";

import useLayerConfig from "@/hooks/use-layer-config";
import { LayerSettings } from "@/types/layer";

interface LayerManagerItemProps {
id: number;
beforeId: string;
settings: LayerSettings;
}

const LayerManagerItem = ({ id, beforeId, settings }: LayerManagerItemProps) => {
const config = useLayerConfig(id, settings);

if (!config) {
return null;
}

return (
<Source {...config.source}>
{config.styles.map((style) => (
<Layer key={style.id} {...style} beforeId={beforeId} />
))}
</Source>
);
};

export default LayerManagerItem;
125 changes: 125 additions & 0 deletions client/src/components/map/legend/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"use client";

import {
Announcements,
closestCenter,
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
ScreenReaderInstructions,
} from "@dnd-kit/core";
import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useCallback, useMemo, useState } from "react";

import SortableItem from "@/components/map/legend/sortable-item";
import useMapLayers from "@/hooks/use-map-layers";

import LegendItem from "./item";

const Legend = () => {
const [layers, { updateLayerOrder }] = useMapLayers();
const [draggedLayerId, setDraggedLayerId] = useState<number | null>(null);

const legendItems = useMemo(() => {
return layers.map(({ id, ...settings }) => (
<SortableItem key={id} id={id} settings={settings} />
));
}, [layers]);

const draggedLegendItem = useMemo(() => {
if (draggedLayerId === null) {
return null;
}

const layer = layers.find(({ id }) => id === draggedLayerId);
if (!layer) {
return null;
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { id, ...settings } = layer;

return (
<div className="shadow">
<LegendItem id={draggedLayerId} settings={settings} />
</div>
);
}, [layers, draggedLayerId]);

const layerIds = useMemo(() => layers.map(({ id }) => id), [layers]);

const accessibility: {
announcements: Announcements;
screenReaderInstructions: ScreenReaderInstructions;
} = useMemo(
() => ({
announcements: {
onDragStart({ active }) {
const position = layers.findIndex(({ id }) => id === active.id);
return `Picked up layer in position ${position} of ${layers.length}`;
},
onDragOver({ over }) {
if (over) {
const position = layers.findIndex(({ id }) => id === over.id);
return `Layer was moved into position ${position} of ${layers.length}`;
}
},
onDragEnd({ over }) {
if (over) {
const position = layers.findIndex(({ id }) => id === over.id);
return `Layer was dropped at position ${position} of ${layers.length}`;
}
},
onDragCancel() {
return `Dragging was cancelled.`;
},
},
screenReaderInstructions: {
draggable:
"Press space or enter to grab the layer. Use the arrow keys to move the layer up or down. Press space or enter again to drop the layer. Press escape to cancel.",
},
}),
[layers],
);

const onDragStart = useCallback(
({ active }: DragStartEvent) => {
setDraggedLayerId(active.id as number);
},
[setDraggedLayerId],
);

const onDragEnd = useCallback(
({ active, over }: DragEndEvent) => {
if (over === null || active.id === over.id) {
return;
}

const id = active.id as number;
const index = layerIds.indexOf(over.id as number);

updateLayerOrder(id, index);
setDraggedLayerId(null);
},
[layerIds, updateLayerOrder, setDraggedLayerId],
);

return (
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
accessibility={accessibility}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<SortableContext strategy={verticalListSortingStrategy} items={layerIds}>
{legendItems}
</SortableContext>
<DragOverlay>{draggedLegendItem}</DragOverlay>
</DndContext>
);
};

export default Legend;
27 changes: 27 additions & 0 deletions client/src/components/map/legend/item/basic-legend.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Square from "@/components/map/legend/item/square";
import useLayerLegend from "@/hooks/use-layer-legend";

type BasicLegendProps = ReturnType<typeof useLayerLegend>["data"];

const BasicLegend = (data: BasicLegendProps) => {
if (!data.items || data.items.length === 0) {
return null;
}

if (data.items.length === 1) {
return <Square item={data.items[0]} className="h-3" />;
}

return (
<div className="columns-2">
{data.items.map((item) => (
<div key={item.color} className="flex items-start gap-2">
<Square item={item} className="relative top-0.5 h-3 w-5 shrink-0" />
<div className="text-2xs text-gray-500">{item.value}</div>
</div>
))}
</div>
);
};

export default BasicLegend;
26 changes: 26 additions & 0 deletions client/src/components/map/legend/item/choropleth-legend.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Square from "@/components/map/legend/item/square";
import Unit from "@/components/map/legend/item/unit";
import Values from "@/components/map/legend/item/values";
import useLayerLegend from "@/hooks/use-layer-legend";

type ChoroplethLegendProps = ReturnType<typeof useLayerLegend>["data"];

const ChoroplethLegend = (data: ChoroplethLegendProps) => {
if (!data.items || data.items.length === 0) {
return null;
}

return (
<>
<Unit {...data} />
<div className="flex h-3">
{data.items.map((item) => (
<Square key={item.color} item={item} className="flex-1" />
))}
</div>
<Values {...data} />
</>
);
};

export default ChoroplethLegend;
Loading

0 comments on commit 560deb3

Please sign in to comment.