Skip to content

Commit

Permalink
Merge pull request #843 from CodeForAfrica/ft/hurumap-choropleth-map
Browse files Browse the repository at this point in the history
Choropleth Map
  • Loading branch information
kelvinkipruto authored Aug 23, 2024
2 parents 69427fe + 929d778 commit c25db05
Show file tree
Hide file tree
Showing 7 changed files with 264 additions and 10 deletions.
11 changes: 8 additions & 3 deletions apps/climatemappedafrica/src/lib/hurumap/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,17 @@ export async function fetchProfile() {
);

const locations = configuration?.featured_locations?.map(
({ name, code, level }) => {
return { name, level, code: code.toLowerCase() };
({ name, code, level, count = null }) => {
return { name, level, code: code.toLowerCase(), count };
},
);

return { locations, preferredChildren: configuration.preferred_children };
return {
locations,
preferredChildren: configuration.preferred_children,
mapType: configuration?.map_type ?? "default",
choropleth: configuration?.choropleth ?? null,
};
}

function formatProfileGeographyData(data, parent) {
Expand Down
5 changes: 4 additions & 1 deletion apps/climatemappedafrica/src/pages/explore/[[...slug]].js
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,8 @@ export async function getStaticProps({ params }) {
ghostkitSR: "",
},
};
const { locations, preferredChildren } = await fetchProfile();
const { locations, preferredChildren, mapType, choropleth } =
await fetchProfile();
const [originalCode] = params?.slug || [""];
const code = originalCode.trim().toLowerCase();

Expand Down Expand Up @@ -239,7 +240,9 @@ export async function getStaticProps({ params }) {
props: {
...props,
blocks,
choropleth,
locations,
mapType,
profile,
variant: "explore",
preferredChildren,
Expand Down
24 changes: 19 additions & 5 deletions packages/hurumap-next/src/Map/Layers.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
function Layers({
PinnedLocationTagProps,
PopUpLocationTagProps,
choropleth,
geography,
isPinOrCompare = false,
locationCodes: locationCodesProp,
Expand Down Expand Up @@ -63,11 +64,14 @@ function Layers({

const onEachFeature = useCallback(
(feature, layer) => {
const choroplethColor = choropleth?.find(
(c) => c.code.toLowerCase() === feature.properties.code.toLowerCase(),
);
let geoStyles =
isPinOrCompare && feature.properties.code === secondaryGeography?.code
? secondaryGeoStyles
: primaryGeoStyles;
// assume ISO 3166-1 codes so comparing uppercase should be ggood
// assume ISO 3166-1 codes so comparing uppercase should be good
const locationCodes =
locationCodesProp?.map((c) => c.toUpperCase()) || [];
if (!locationCodes?.includes(feature.properties.code.toUpperCase())) {
Expand Down Expand Up @@ -118,6 +122,10 @@ function Layers({
let style;
if (feature?.properties?.selected) {
style = geoStyles.selected.out;
style = {
...style,
...(choroplethColor && { ...choroplethColor }),
};
} else if (
isPinOrCompare &&
feature.properties.code === secondaryGeography?.code
Expand All @@ -130,17 +138,22 @@ function Layers({

layer.on("mouseover", () => {
geoStyles = isPinOrCompare ? secondaryGeoStyles : primaryGeoStyles;
layer.setStyle(
feature?.properties?.selected
layer.setStyle({
...(feature?.properties?.selected
? geoStyles.selected.over
: geoStyles.hoverOnly.over,
);
: geoStyles.hoverOnly.over),
...(choroplethColor && { ...choroplethColor }),
});
});
layer.on("mouseout", () => {
geoStyles = isPinOrCompare ? secondaryGeoStyles : primaryGeoStyles;
let outStyle;
if (feature?.properties?.selected) {
outStyle = geoStyles.selected.out;
outStyle = {
...outStyle,
...(choroplethColor && { ...choroplethColor }),
};
} else if (
isPinOrCompare &&
feature.properties.code === secondaryGeography?.code
Expand All @@ -164,6 +177,7 @@ function Layers({
},
[
PopUpLocationTagProps,
choropleth,
geography,
isPinOrCompare,
locationCodesProp,
Expand Down
13 changes: 13 additions & 0 deletions packages/hurumap-next/src/Map/LazyMap.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import React, { useCallback, useEffect, useState } from "react";
import { MapContainer, Pane, TileLayer, ZoomControl } from "react-leaflet";

import Layers from "./Layers";
import Legend from "./Legend";
import { generateChoropleth } from "./utils";

import "leaflet/dist/leaflet.css";

Expand All @@ -11,10 +13,12 @@ import "leaflet/dist/leaflet.css";
const LazyMap = React.forwardRef(function LazyMap(props, ref) {
const {
center,
choropleth: choroplethProps,
geography,
geometries,
isPinOrCompare,
locations,
mapType,
preferredChildren,
styles = {
height: "100%",
Expand Down Expand Up @@ -46,6 +50,12 @@ const LazyMap = React.forwardRef(function LazyMap(props, ref) {
[preferredChildren, isPinOrCompare],
);

const { choropleth, legend } = generateChoropleth(
choroplethProps,
locations,
mapType,
);

useEffect(() => {
let selectedBound =
getSelectedBoundary(geography.level, geometries) ?? geometries.boundary;
Expand Down Expand Up @@ -79,6 +89,7 @@ const LazyMap = React.forwardRef(function LazyMap(props, ref) {
}, [geometries, geography, getSelectedBoundary]);

const locationCodes = locations?.map(({ code }) => code);

return (
<MapContainer
center={center}
Expand All @@ -103,10 +114,12 @@ const LazyMap = React.forwardRef(function LazyMap(props, ref) {
<TileLayer url={url} />
</Pane>
))}
{mapType === "choropleth" && <Legend legend={legend} />}
<ZoomControl position="bottomright" />
<Layers
{...LayersProps}
geography={geography}
choropleth={choropleth}
locationCodes={locationCodes}
parentsGeometries={geometries.parents}
selectedBoundary={selectedBoundary}
Expand Down
58 changes: 58 additions & 0 deletions packages/hurumap-next/src/Map/Legend.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Typography, Grid } from "@mui/material";

export default function Legend({ legend, title = "Average Temperature", sx }) {
return (
<Grid
spacing={2}
sx={(theme) => ({
position: "absolute",
zIndex: 1000,
width: "fit-content",
bottom: theme.spacing(30),
right: theme.spacing(20),
backgroundColor: theme.palette.background.paper,
padding: theme.spacing(1),
boxShadow: theme.shadows[3],
...sx,
})}
>
<Typography
variant="subtitle2"
sx={(theme) => ({
color: theme.palette.text.primary,
marginBottom: theme.spacing(2),
})}
>
{title}
</Typography>
{legend?.map(({ min, max, color }) => (
<Grid
container
key={`${min}-${max}`}
alignItems="center"
spacing={1}
sx={(theme) => ({
padding: theme.spacing(1),
})}
>
<Grid
item
sx={(theme) => ({
backgroundColor: color,
width: theme.spacing(4),
height: theme.spacing(4),
})}
/>
<Grid item xs>
<Typography
variant="subtitle1"
sx={(theme) => ({ color: theme.palette.text.primary })}
>
{min} - {max}
</Typography>
</Grid>
</Grid>
))}
</Grid>
);
}
30 changes: 29 additions & 1 deletion packages/hurumap-next/src/Map/geoStyles.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,32 @@ const defaultSecondaryGeoStyles = {
},
};

export { defaultPrimaryGeoStyles, defaultSecondaryGeoStyles };
const defaultChoroplethStyles = {
negative_color_range: [
"#FEA502",
"#FFAA54",
"#FD928E",
"#DFB494",
"#9BFAFA",
"#64F9F9",
"#01F8F8",
],
positive_color_range: [
"#021AFE",
"#5455FF",
"#928EFD",
"#B494DF",
"#FA9B9B",
"#F96264",
"#F80701",
],
opacity: 0.7,
opacity_hover: 1,
zero_color: "white",
};

export {
defaultPrimaryGeoStyles,
defaultSecondaryGeoStyles,
defaultChoroplethStyles,
};
133 changes: 133 additions & 0 deletions packages/hurumap-next/src/Map/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/* eslint-disable import/prefer-default-export */

import { defaultChoroplethStyles } from "./geoStyles";

const roundToNearestHalf = (num) => {
return Math.round(num * 2) / 2;
};

const calculateThresholds = (min, max, steps) => {
const stepSize = (max - min) / steps;
const thresholds = [];

for (let i = 0; i < steps; i += 1) {
thresholds.push({
min: roundToNearestHalf(min + i * stepSize),
max: roundToNearestHalf(min + (i + 1) * stepSize),
});
}

return thresholds;
};

const generateLegend = (
min,
max,
positiveThresholds,
positiveColorRange,
negativeThresholds,
negativeColorRange,
zeroColor,
) => {
const legend = [];

if (negativeThresholds.length) {
negativeThresholds.forEach((threshold, index) => {
legend.push({
min: threshold.min,
max: threshold.max,
color: negativeColorRange[index],
});
});
}

if (min <= 0 && max >= 0) {
legend.push({
min: 0,
max: 0,
color: zeroColor,
});
}

if (positiveThresholds.length) {
positiveThresholds.forEach((threshold, index) => {
legend.push({
min: threshold.min,
max: threshold.max,
color: positiveColorRange[index],
});
});
}

return legend;
};

export const generateChoropleth = (colors, locations, mapType) => {
if (mapType !== "choropleth") return null;

const filteredLocations = locations.filter(({ count }) => count !== null);
const counts = filteredLocations.map(({ count }) => count);
const hasNegativeValues = counts.some((count) => count < 0);
const hasPositiveValues = counts.some((count) => count > 0);
const maxCount = Math.max(...counts);
const minCount = Math.min(...counts);
const roundedMinCount = Math.floor(minCount);
const roundedMaxCount = Math.ceil(maxCount);

const negativeColorRange =
colors?.negative_color_range ||
defaultChoroplethStyles.negative_color_range;
const positiveColorRange =
colors?.positive_color_range ||
defaultChoroplethStyles.positive_color_range;
const zeroColor = colors?.zero_color || defaultChoroplethStyles.zero_color;
const opacity = colors?.opacity || defaultChoroplethStyles.opacity;

const positiveThresholds = hasPositiveValues
? calculateThresholds(
roundedMinCount,
roundedMaxCount,
positiveColorRange.length,
)
: [];
const negativeThresholds = hasNegativeValues
? calculateThresholds(
roundedMinCount,
roundedMaxCount,
negativeColorRange.length,
)
: [];

const legend = generateLegend(
roundedMinCount,
roundedMaxCount,
positiveThresholds,
positiveColorRange,
negativeThresholds,
negativeColorRange,
zeroColor,
);

const getColor = (count) => {
if (count === 0) return zeroColor;
const colorRange = count > 0 ? positiveColorRange : negativeColorRange;
const thresholds = count > 0 ? positiveThresholds : negativeThresholds;
const index = thresholds.findIndex(
(threshold) => count >= threshold.min && count < threshold.max,
);
return colorRange[index];
};

const choroplethData = filteredLocations.map(({ code, count }) => {
const color = getColor(count);

return {
code,
count,
fillColor: color,
opacity,
};
});

return { choropleth: choroplethData, legend };
};

0 comments on commit c25db05

Please sign in to comment.