Skip to content

Commit

Permalink
feat(export): add France interactive map (#31)
Browse files Browse the repository at this point in the history
* wip

* wip 2

* hover : select department

* feat: add a centroid tooltip

* rework all

* feat: create final hook to compute departments

* fix import
  • Loading branch information
maximallain authored Nov 9, 2023
1 parent 08b4db2 commit faa651e
Show file tree
Hide file tree
Showing 11 changed files with 414 additions and 43 deletions.
17 changes: 12 additions & 5 deletions src/api/cosiaApi.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import axios from "axios";
import applyCaseMiddleware from "axios-case-converter";
import { getCookie } from "../utils";
import { getCookie } from "../utils/utils";

const cosiaApiAxiosInstance = applyCaseMiddleware(
axios.create({
Expand All @@ -9,15 +9,22 @@ const cosiaApiAxiosInstance = applyCaseMiddleware(
}),
);

export enum DepartmentStatus {
Available = "available",
Soon = "soon",
NotAvailable = "not_available",
}

export type Department = {
name: string;
number: string;
status: string;
geom: string;
status: DepartmentStatus;
geomGeojson: string;
centroidGeojson: string;
};

export const getAllDepartments = (): Promise<{ data: Department[] }> => {
return cosiaApiAxiosInstance.get("departments");
export const getAllDepartments = (): Promise<Department[]> => {
return cosiaApiAxiosInstance.get("departments").then((res: { data: Department[] }) => res.data);
};

type DepartmentDataResponse = {
Expand Down
35 changes: 4 additions & 31 deletions src/components/DownloadForm.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { fr } from "@codegouvfr/react-dsfr";
import Button from "@codegouvfr/react-dsfr/Button";
import { FormEvent, memo, useEffect, useMemo, useState } from "react";
import { FormEvent, memo, useEffect, useState } from "react";
import { makeStyles } from "tss-react/dsfr";
import { useMutation, useQuery } from "react-query";
import { CircularProgress } from "@mui/material";

import { DepartmentData, createDepartementDataDownload, getAllDepartmentData } from "../api/cosiaApi";
import { useSnackbar } from "../hooks/useSnackbar";
import { isCorrectEmail } from "../utils";
import { isCorrectEmail } from "../utils/utils";
import { DownloadFormFields } from "./DownloadFormFields";
import { LoaderOrErrorContainer } from "./ui/LoaderOrErrorContainer";

const useStyles = makeStyles()({
container: {
Expand All @@ -23,18 +24,6 @@ const useStyles = makeStyles()({
button: {
alignSelf: "end",
},
loaderOrErrorContainer: {
width: "100%",
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
},
errorContainer: {
alignItems: "center",
display: "flex",
flexDirection: "column",
},
});

const DownloadForm = () => {
Expand Down Expand Up @@ -123,29 +112,13 @@ const DownloadForm = () => {
}
}, [isSuccess, isCreationError]);

const loaderOrErrorContainer = useMemo(
() => (
<div className={classes.loaderOrErrorContainer}>
{isLoading ? (
<CircularProgress />
) : isError ? (
<div className={classes.errorContainer}>
<p>Un problème est survenu.</p>
<Button onClick={() => refetch()}>Réessayer</Button>
</div>
) : null}
</div>
),
[isLoading, isError],
);

return (
<div className={classes.container}>
<SnackbarComponent />

<h6 className={classes.h6}>Télécharger un département</h6>
{isLoading || isError ? (
loaderOrErrorContainer
<LoaderOrErrorContainer isLoading={isLoading} isError={isError} refetch={refetch} />
) : (
<form onSubmit={handleSubmit}>
<DownloadFormFields
Expand Down
74 changes: 74 additions & 0 deletions src/components/FrenchMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { useEffect } from "react";
import { makeStyles } from "tss-react/dsfr";
import { useDepartments } from "../hooks/useDepartments";
import { useMap } from "../hooks/useMap";
import { useToolTipMap } from "../hooks/useTooltipMap";
import { LoaderOrErrorContainer } from "./ui/LoaderOrErrorContainer";

const useStyles = makeStyles()(theme => ({
container: {
display: "grid",
gridTemplateRow: "1fr",
gridTemplateColumn: "1fr",
/* width: "100%",
height: "100%", */
maxWidth: 800,
minHeight: 350,
maxHeight: 800,
},
mapContainer: {
height: "100%",
width: "100%",
borderWidth: 1,
borderStyle: "solid",
borderColor: theme.decisions.artwork.motif.grey.default,
gridColumn: 1,
gridRow: 1,
},
loadingContainer: {
gridColumn: 1,
gridRow: 1,
zIndex: 2,
display: "flex",
alignSelf: "center",
justifyContent: "center",
},
}));

type Props = {
className?: string;
};

export const FrenchMap = ({ className }: Props) => {
const { classes, cx } = useStyles();
const { isLoading, isError, refetch, departmentLayer, departmentsExtent } = useDepartments();
const { map } = useMap("map");
useToolTipMap({ map, layer: departmentLayer });

useEffect(() => {
if (map === undefined) return;
if (departmentLayer === undefined) return;

map.addLayer(departmentLayer);

return () => {
map.removeLayer(departmentLayer);
};
}, [departmentLayer]);

useEffect(() => {
if (map === undefined || departmentsExtent === undefined) return;
map.getView().fit(departmentsExtent, { padding: [10, 10, 10, 10] });
}, [map, departmentsExtent]);

return (
<div className={cx(classes.container, className)}>
{(isLoading || isError) && (
<div className={cx(classes.mapContainer, classes.loadingContainer)}>
<LoaderOrErrorContainer isLoading={isLoading} isError={isError} refetch={refetch} />
</div>
)}
<div id="map" className={classes.mapContainer}></div>
</div>
);
};
44 changes: 44 additions & 0 deletions src/components/ui/LoaderOrErrorContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import Button from "@codegouvfr/react-dsfr/Button";
import { CircularProgress } from "@mui/material";
import { useMemo } from "react";
import { makeStyles } from "tss-react/dsfr";

type Props = {
isLoading: boolean;
isError: boolean;
refetch(): void;
};

const useStyles = makeStyles()({
loaderOrErrorContainer: {
width: "100%",
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
},
errorContainer: {
alignItems: "center",
display: "flex",
flexDirection: "column",
},
});

export const LoaderOrErrorContainer = ({ isLoading, isError, refetch }: Props) => {
const { classes } = useStyles();
console.error(isError);

const content = useMemo(() => {
if (isLoading) return <CircularProgress />;
if (isError)
return (
<div className={classes.errorContainer}>
<p>Un problème est survenu.</p>
<Button onClick={refetch}>Réessayer</Button>
</div>
);
return null;
}, [isLoading, isError, refetch]);

return <div className={classes.loaderOrErrorContainer}>{content}</div>;
};
77 changes: 77 additions & 0 deletions src/hooks/useDepartments.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import Feature from "ol/Feature";
import GeoJSON from "ol/format/GeoJSON";
import { Geometry } from "ol/geom";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import Fill from "ol/style/Fill";
import Stroke from "ol/style/Stroke";
import Style, { StyleFunction } from "ol/style/Style";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useQuery } from "react-query";
import { getAllDepartments } from "../api/cosiaApi";
import {
computeDepartmentFeature,
getAllDepartmentsExtent,
getFillColorFromStatus,
} from "../utils/departments";

export const useDepartments = () => {
const [departementFeatures, setdepartementFeatures] = useState<Feature<Geometry>[]>([]);
const [departmentLayer, setDepartmentLayer] = useState<VectorLayer<VectorSource> | undefined>(
undefined,
);

const {
isLoading,
isError,
data: departments,
refetch,
} = useQuery({
queryKey: ["department"],
queryFn: () => getAllDepartments(),
staleTime: 60_000,
});

const departmentsExtent = useMemo(
() => getAllDepartmentsExtent(departementFeatures),
[departementFeatures],
);

useEffect(() => {
if (departments === undefined || departments.length === 0) return;

const format = new GeoJSON();
const features: Feature<Geometry>[] = departments.map(dep => computeDepartmentFeature(dep, format));

setdepartementFeatures(features);
}, [departments]);

const setStyle: StyleFunction = useCallback(feature => {
const status = feature.get("status");
const fillColor = getFillColorFromStatus(status);
return new Style({
stroke: new Stroke({
color: "#FFF",
width: 1,
}),
fill: new Fill({
color: fillColor,
}),
});
}, []);

useEffect(() => {
if (departementFeatures.length === 0) return;

const newSource = new VectorSource({
features: departementFeatures,
});

const departmentLayer = new VectorLayer({ source: newSource });
departmentLayer.setStyle(setStyle);

setDepartmentLayer(departmentLayer);
}, [departementFeatures]);

return { isLoading, isError, refetch, departmentLayer, departmentsExtent };
};
34 changes: 34 additions & 0 deletions src/hooks/useMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Map, View } from "ol";
import { fromLonLat } from "ol/proj";
import { useEffect, useMemo, useState } from "react";

const MIN_ZOOM = 4;
const MAX_ZOOM = 12;
const MAX_EXTENT = [...fromLonLat([-3.2, 45.8]), ...fromLonLat([15.8, 55.8])];

export const useMap = (target: string, minZoom = MIN_ZOOM, maxZoom = MAX_ZOOM, extent = MAX_EXTENT) => {
const [map, setMap] = useState<Map | undefined>(undefined);

const view = useMemo(
() =>
new View({
minZoom: minZoom,
maxZoom: maxZoom,
extent: extent,
}),
[],
);

useEffect(() => {
const map = new Map({
target,
view,
});

setMap(map);

return () => map.setTarget(undefined);
}, []);

return { map };
};
Loading

0 comments on commit faa651e

Please sign in to comment.