diff --git a/apps/climatemappedafrica/src/lib/hurumap/index.js b/apps/climatemappedafrica/src/lib/hurumap/index.js index bb03b451a..3332d79ca 100644 --- a/apps/climatemappedafrica/src/lib/hurumap/index.js +++ b/apps/climatemappedafrica/src/lib/hurumap/index.js @@ -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) { diff --git a/apps/climatemappedafrica/src/pages/explore/[[...slug]].js b/apps/climatemappedafrica/src/pages/explore/[[...slug]].js index 07d0ab8af..8bb73f377 100644 --- a/apps/climatemappedafrica/src/pages/explore/[[...slug]].js +++ b/apps/climatemappedafrica/src/pages/explore/[[...slug]].js @@ -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(); @@ -239,7 +240,9 @@ export async function getStaticProps({ params }) { props: { ...props, blocks, + choropleth, locations, + mapType, profile, variant: "explore", preferredChildren, diff --git a/packages/hurumap-next/src/Map/Layers.js b/packages/hurumap-next/src/Map/Layers.js index 5cba19185..9d9a01f40 100644 --- a/packages/hurumap-next/src/Map/Layers.js +++ b/packages/hurumap-next/src/Map/Layers.js @@ -15,6 +15,7 @@ import { function Layers({ PinnedLocationTagProps, PopUpLocationTagProps, + choropleth, geography, isPinOrCompare = false, locationCodes: locationCodesProp, @@ -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())) { @@ -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 @@ -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 @@ -164,6 +177,7 @@ function Layers({ }, [ PopUpLocationTagProps, + choropleth, geography, isPinOrCompare, locationCodesProp, diff --git a/packages/hurumap-next/src/Map/LazyMap.js b/packages/hurumap-next/src/Map/LazyMap.js index 8ebae97c8..25329f0a2 100644 --- a/packages/hurumap-next/src/Map/LazyMap.js +++ b/packages/hurumap-next/src/Map/LazyMap.js @@ -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"; @@ -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%", @@ -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; @@ -79,6 +89,7 @@ const LazyMap = React.forwardRef(function LazyMap(props, ref) { }, [geometries, geography, getSelectedBoundary]); const locationCodes = locations?.map(({ code }) => code); + return ( ))} + {mapType === "choropleth" && } ({ + 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, + })} + > + ({ + color: theme.palette.text.primary, + marginBottom: theme.spacing(2), + })} + > + {title} + + {legend?.map(({ min, max, color }) => ( + ({ + padding: theme.spacing(1), + })} + > + ({ + backgroundColor: color, + width: theme.spacing(4), + height: theme.spacing(4), + })} + /> + + ({ color: theme.palette.text.primary })} + > + {min} - {max} + + + + ))} + + ); +} diff --git a/packages/hurumap-next/src/Map/geoStyles.js b/packages/hurumap-next/src/Map/geoStyles.js index 21c4d0892..aed496603 100644 --- a/packages/hurumap-next/src/Map/geoStyles.js +++ b/packages/hurumap-next/src/Map/geoStyles.js @@ -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, +}; diff --git a/packages/hurumap-next/src/Map/utils.js b/packages/hurumap-next/src/Map/utils.js new file mode 100644 index 000000000..21d347907 --- /dev/null +++ b/packages/hurumap-next/src/Map/utils.js @@ -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 }; +};