Skip to content

Add bounds to API so that frontend can auto-zoom #68

@jpmckinney

Description

@jpmckinney

This will allow the frontend map to auto-zoom based on the actual geographic extent, using fitBounds.

I'm leaving this out of #66 as that PR is already big enough.

The frontend currently has code that would fitBounds if there is a bounds property on district/subdistrict features, but the input GeoJSON files don't set this property, and the API only sets it for a subset of districts (not sure why). This diff would define that property for all districts and subdistricts, so that fitBounds is run.

diff --git a/layer/schema.py b/layer/schema.py
index 7895b58..6a9dd30 100644
--- a/layer/schema.py
+++ b/layer/schema.py
@@ -384,6 +384,11 @@ def get_revenue_map_data(
     # Iterate over GeoJSON features and populate with indicator data
     for rc in geo_json["features"]:
         rc_code = rc["properties"]["code"]
+
+        rc["properties"]["bounds"] = bounding_box(
+            list(geojson.utils.coords(geojson.Polygon(rc["geometry"]["coordinates"])))
+        )
+
         if rc_code in rc_data_map:
             data = rc_data_map[rc_code]
             geo_object = data.geography
@@ -450,22 +455,21 @@ def get_district_map_data(
     # Iterate over GeoJSON features and populate with indicator data
     for district in geo_json["features"]:
         district_code = district["properties"]["code"]
+
+        district["properties"]["bounds"] = bounding_box(
+            list(geojson.utils.coords(geojson.Polygon(district["geometry"]["coordinates"])))
+        )
+
         if district_code in district_data_map:
             data = district_data_map[district_code]
 
-            # Add bounding box of district
-            poly = geojson.Polygon(district["geometry"]["coordinates"])
-            district["properties"]["bounds"] = bounding_box(
-                list(geojson.utils.coords(poly))
-            )
-
             # Add indicator slug and value to properties
             district["properties"][data.indicator.slug] = data.value
 
-            # Remove unnecessary keys
-            district["properties"].pop("parentId", None)
-            district["properties"].pop("pk", None)
-            district.pop("id", None)
+        # Remove unnecessary keys
+        district["properties"].pop("parentId", None)
+        district["properties"].pop("pk", None)
+        district.pop("id", None)
 
     return geo_json
 
@@ -632,6 +636,13 @@ def get_states():
             "union_geometry"
         ]
         state_centroid = state_geometry.centroid if state_geometry else None
+        extent = state_geometry.extent if state_geometry else None
+        bounds = (
+            # `extent` is: minX (west), minY (south), maxX (east), maxY (north).
+            # The frontend consumes Leaflet bounds as [[south, west], [north, east]].
+            [[extent[1], extent[0]], [extent[3], extent[2]]]
+            if extent else None
+        )
         grandchild = Geography.objects.filter(parentId__parentId__code=state_code).first()
         states.append({
             "name": state_geography.name,
@@ -639,6 +650,7 @@ def get_states():
             "code": state_code,
             "child_type": grandchild.type if grandchild else None,
             "center": (state_centroid.y, state_centroid.x) if state_centroid else None,
+            "bounds": bounds,
             "resource_id": visible.get(state_geography.name.lower(), ""),
             "time_periods": time_periods,
             "latest_time_period": time_periods[0] if time_periods else None,

Then in the frontend, in app/[locale]/[state]/analytics/components/map-component.tsx, I think we can be more lax about varying zoom according to mobile vs desktop, since the auto-zoom will take precedence, and the zoom will only be relevant as a fallback (which is not expected to ever occur). Also, I think we can use the same minZoom and maxZoom for both mobile and desktop. There's currently an effect for districtCode that mixes fitBounds with setMapFeatures. If we split it up, we get two near-identical effects for districtCode and revenueCode, which is easier to read and maintain.

--- a/app/[locale]/[state]/analytics/components/map-component.tsx
+++ b/app/[locale]/[state]/analytics/components/map-component.tsx
@@ -74,6 +74,7 @@ export const MapComponent = ({
 
   const params = new URLSearchParams(window.location.search);
   const districtCode = params.get('district-code');
+  const revenueCode = params.get('revenue-code');
 
   const { width } = useWindowSize();
   const isMobile = width < 1023;
@@ -241,40 +242,56 @@ export const MapComponent = ({
   }
 
   React.useEffect(() => {
-    const getBoundsData = mapData.features.filter(
-      (feature: { properties: { [x: string]: string } }) =>
-        feature.properties['code'] === districtCode
+    if (!districtCode || !map || !map.getContainer()) return;
+    const feature = mapData.features.find(
+      (f: { properties: { [x: string]: string } }) =>
+        f.properties['code'] === districtCode
     );
+    if (!feature?.properties?.bounds) return;
+    map.whenReady(() => {
+      try {
+        map.fitBounds(feature.properties.bounds);
+      } catch (error) {
+        console.warn('Error fitting bounds:', error);
+      }
+    });
+  }, [districtCode, map, mapData?.features]);
 
-    if (
-      getBoundsData.length > 0 &&
-      getBoundsData[0]?.properties?.bounds &&
-      map &&
-      map.getContainer()
-    ) {
-      map.whenReady(() => {
-        try {
-          map.fitBounds(getBoundsData[0]?.properties?.bounds);
-        } catch (error) {
-          console.warn('Error fitting bounds:', error);
-        }
-      });
-    }
+  React.useEffect(() => {
+    if (!revenueCode || !map || !map.getContainer()) return;
+    const feature = revenueMapData?.features.find(
+      (f: { properties: { [x: string]: string } }) =>
+        f.properties['code'] === revenueCode
+    );
+    if (!feature?.properties?.bounds) return;
+    map.whenReady(() => {
+      try {
+        map.fitBounds(feature.properties.bounds);
+      } catch (error) {
+        console.warn('Error fitting bounds:', error);
+      }
+    });
+  }, [revenueCode, map, revenueMapData?.features]);
 
+  React.useEffect(() => {
     const filterMapData = revenueMapData?.features.filter(
       (feature: { properties: { [x: string]: string } }) =>
         feature.properties['district-code'] === districtCode
     );
-
     setMapFeatures(districtCode ? filterMapData : mapData?.features);
-  }, [districtCode, map, mapData?.features, revenueMapData?.features]);
+  }, [districtCode, mapData?.features, revenueMapData?.features]);
 
   React.useEffect(() => {
     if (!map) return;
     if (districtCode) return;
     if (!currentSelectedState?.center) return;
 
-    map.setView(currentSelectedState.center, 7.4);
+    if (currentSelectedState.bounds) {
+      map.fitBounds(currentSelectedState.bounds);
+    } else {
+      const state = states.find((s) => s.slug === currentSelectedState.slug);
+      map.setView(currentSelectedState.center, state?.zoom ?? 6);
+    }
   }, [map, districtCode, currentSelectedState]);
 
   if (mapDataloading || revenueMapDataLoading)
@@ -321,7 +343,7 @@ export const MapComponent = ({
           features={mapFeatures || mapData.features}
           addlFeaturesArray={overlayFeatures ? [overlayFeatures] : []}
           addlFeaturesStyleArray={addlFeaturesStyleArray}
-          mapZoom={isMobile ? 8 : 7.4}
+          mapZoom={6}
           mapProperty={indicator}
           zoomOnClick={false}
           isCustomColor={!Factors.includes(indicator)}
@@ -342,8 +364,8 @@ export const MapComponent = ({
           legendData={
             Factors.includes(indicator) ? legendData : customLegendData
           }
-          minZoom={isMobile ? 3 : 6}
-          maxZoom={isMobile ? 6.3 : 8}
+          minZoom={states.find((s) => s.slug === currentSelectedState?.slug)?.minZoom}
+          maxZoom={states.find((s) => s.slug === currentSelectedState?.slug)?.maxZoom}
           mapDataFn={mapDataFn}
           mouseover={(layer) => {
             const regionName = layer.feature?.properties.name;

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions