diff --git a/docker-compose.yml b/docker-compose.yml index a743cce..1c541f5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,7 @@ services: postgres: container_name: map-postgres image: postgis/postgis + platform: linux/amd64 healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s diff --git a/map/serializers.py b/map/serializers.py index 03dd912..ee85857 100644 --- a/map/serializers.py +++ b/map/serializers.py @@ -6,29 +6,19 @@ class CommunityAreaSerializer(serializers.ModelSerializer): class Meta: model = CommunityArea - fields = ["name", "num_permits"] + fields = ["name", "num_permits", "area_id"] num_permits = serializers.SerializerMethodField() def get_num_permits(self, obj): - """ - TODO: supplement each community area object with the number - of permits issued in the given year. + year = self.context.get("year") + permits = RestaurantPermit.objects.filter(community_area_id=str(obj.area_id)) - e.g. The endpoint /map-data/?year=2017 should return something like: - [ - { - "ROGERS PARK": { - area_id: 17, - num_permits: 2 - }, - "BEVERLY": { - area_id: 72, - num_permits: 2 - }, - ... - } - ] - """ - - pass + if year: + permits = permits.filter(issue_date__year=year) + return permits.count() + + def to_representation(self, instance): + data = super().to_representation(instance) + name = data.pop("name") + return {name: data} diff --git a/map/static/images/chicagoStars.svg b/map/static/images/chicagoStars.svg new file mode 100644 index 0000000..68303e5 --- /dev/null +++ b/map/static/images/chicagoStars.svg @@ -0,0 +1,3 @@ + + + diff --git a/map/static/images/chicagoStarsError.svg b/map/static/images/chicagoStarsError.svg new file mode 100644 index 0000000..dd61240 --- /dev/null +++ b/map/static/images/chicagoStarsError.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/map/static/js/RestaurantPermitMap.js b/map/static/js/RestaurantPermitMap.js index 57f8ea0..c998053 100644 --- a/map/static/js/RestaurantPermitMap.js +++ b/map/static/js/RestaurantPermitMap.js @@ -1,109 +1,143 @@ -import React, { useEffect, useState } from "react" +import { useEffect, useState } from "react"; +import { MapContainer, TileLayer, GeoJSON, Popup } from "react-leaflet"; -import { MapContainer, TileLayer, GeoJSON } from "react-leaflet" +import YearSelect from "./components/YearSelect"; +import YearlyPermitInfo from "./components/YearlyPermitInfo"; +import Loading from "./components/Loading"; +import Error from "./components/Error"; +import LeafletPopUp from "./components/LeafletPopUp"; +import Legend from "./components/MapLegend"; -import "leaflet/dist/leaflet.css" +import getMaxNumberOfPermits from "./utils/getMaxNumberOfPermits"; +import getMapAreaColor from "./utils/getMapAreaColor"; +import generateAreaIdPermitObject from "./utils/generateAreaIdPermitObject"; +import getTotalPermitsPerYear from "./utils/getTotalPermitsPerYear"; -import RAW_COMMUNITY_AREAS from "../../../data/raw/community-areas.geojson" +import "leaflet/dist/leaflet.css"; -function YearSelect({ setFilterVal }) { - // Filter by the permit issue year for each restaurant - const startYear = 2026 - const years = [...Array(11).keys()].map((increment) => { - return startYear - increment - }) - const options = years.map((year) => { - return ( - - ) - }) - - return ( - <> - - - - ) -} +import RAW_COMMUNITY_AREAS from "../../../data/raw/community-areas.geojson"; export default function RestaurantPermitMap() { - const communityAreaColors = ["#eff3ff", "#bdd7e7", "#6baed6", "#2171b5"] - - const [currentYearData, setCurrentYearData] = useState([]) - const [year, setYear] = useState(2026) + const [isLoading, setIsLoading] = useState(true); + const [error, setIsError] = useState(false); + const [activeArea, setActiveArea] = useState(); + const [totalPermits, setTotalPermits] = useState(0); + const [currentYearData, setCurrentYearData] = useState([]); + const [areaIdMap, setAreaIdMap] = useState({}); + const [year, setYear] = useState(2026); - const yearlyDataEndpoint = `/map-data/?year=${year}` + const yearlyDataEndpoint = `/map-data/?year=${year}`; useEffect(() => { - fetch() - .then((res) => res.json()) - .then((data) => { - /** - * TODO: Fetch the data needed to supply to map with data - */ - }) - }, [yearlyDataEndpoint]) - - - function getColor(percentageOfPermits) { - /** - * TODO: Use this function in setAreaInteraction to set a community - * area's color using the communityAreaColors constant above - */ + const fetchMapData = async () => { + setIsError(false); + setIsLoading(true); + try { + const response = await fetch(yearlyDataEndpoint); + + if (!response.ok) { + throw new Error("Network response was NOT ok"); + } + + const data = await response.json(); + const areaIdObject = generateAreaIdPermitObject(data); + const totalPermitsForTheYear = getTotalPermitsPerYear(data); + setCurrentYearData(data); + setAreaIdMap(areaIdObject); + setTotalPermits(totalPermitsForTheYear); + } catch (err) { + console.error("Error details:", err); + setIsError(true); + } finally { + setIsLoading(false); + } + }; + + fetchMapData(); + }, [year]); + + if (error) { + return ; } + const maxNumPermits = getMaxNumberOfPermits(currentYearData); + function setAreaInteraction(feature, layer) { - /** - * TODO: Use the methods below to: - * 1) Shade each community area according to what percentage of - * permits were issued there in the selected year - * 2) On hover, display a popup with the community area's raw - * permit count for the year - */ - layer.setStyle() - layer.on("", () => { - layer.bindPopup("") - layer.openPopup() - }) + const communityName = feature.properties.community; + const area_id = feature.properties.area_num_1; + const permitPercentage = + maxNumPermits.max > 0 + ? (areaIdMap[area_id] / maxNumPermits.max) * 100 + : 0; + const defaultStyle = { + fillColor: getMapAreaColor(permitPercentage), + weight: 1, + color: "red", + fillOpacity: 0.8, + }; + + const hoverStyle = { + ...defaultStyle, + color: "green", + fillOpacity: 1, + }; + + layer.setStyle(defaultStyle); + layer.on({ + mouseover: (e) => { + layer.setStyle(hoverStyle); + setActiveArea({ + position: e.latlng, + name: communityName, + id: area_id, + color: "green", + }); + }, + mouseout: () => { + layer.setStyle(defaultStyle); + }, + }); } return ( <> - -

- Restaurant permits issued this year: {/* TODO: display this value */} -

-

- Maximum number of restaurant permits in a single area: - {/* TODO: display this value */} -

- +

Chicago Restaurant Permits

+ + + + + {isLoading && } + {currentYearData.length > 0 ? ( - + <> + + {activeArea && ( + setActiveArea(null)} + > + + + )} + + ) : null} - ) + ); } diff --git a/map/static/js/components/Error.jsx b/map/static/js/components/Error.jsx new file mode 100644 index 0000000..bd12243 --- /dev/null +++ b/map/static/js/components/Error.jsx @@ -0,0 +1,38 @@ +const Error = () => { + const handleRefresh = () => { + window.location.reload(); + }; + + return ( +
+

Oh no, something went wrong

+ + +
+ ); +}; + +export default Error; diff --git a/map/static/js/components/LeafletPopUp.jsx b/map/static/js/components/LeafletPopUp.jsx new file mode 100644 index 0000000..786c965 --- /dev/null +++ b/map/static/js/components/LeafletPopUp.jsx @@ -0,0 +1,10 @@ +const LeafletPopUp = ({ communityName, permits }) => { + return ( +
+

{communityName || "Name not available"}

+

{`Permits this year: ${permits ?? "not available"}`}

+
+ ); +}; + +export default LeafletPopUp; diff --git a/map/static/js/components/Loading.jsx b/map/static/js/components/Loading.jsx new file mode 100644 index 0000000..e812038 --- /dev/null +++ b/map/static/js/components/Loading.jsx @@ -0,0 +1,28 @@ +const Loading = () => { + return ( +
+

Loading Data...

+ red stars found on Chicago flag +
+ ); +}; + +export default Loading; diff --git a/map/static/js/components/MapLegend.jsx b/map/static/js/components/MapLegend.jsx new file mode 100644 index 0000000..34dc5c7 --- /dev/null +++ b/map/static/js/components/MapLegend.jsx @@ -0,0 +1,64 @@ +import { createPortal } from "react-dom"; +import { useEffect, useState } from "react"; +import { useMap } from "react-leaflet"; +import L from "leaflet"; + +const MapLegend = () => { + const map = useMap(); + const [container, setContainer] = useState(null); + const grades = [0, 25, 50, 75]; + const colors = ["#eff3ff", "#bdd7e7", "#6baed6", "#2171b5"]; + + useEffect(() => { + const legend = L.control({ position: "topright" }); + + legend.onAdd = () => { + const div = L.DomUtil.create("div", "info legend"); + setContainer(div); + return div; + }; + + legend.addTo(map); + return () => legend.remove(); + }, [map]); + + return container + ? createPortal( +
+

Permits (%)

+ {grades?.map((grade, i) => ( +
+
+ + {grade} + {grades[i + 1] ? `-${grades[i + 1]}%` : "+%"} + +
+ ))} +
, + container, + ) + : null; +}; + +export default MapLegend; diff --git a/map/static/js/components/YearSelect.jsx b/map/static/js/components/YearSelect.jsx new file mode 100644 index 0000000..455c1ee --- /dev/null +++ b/map/static/js/components/YearSelect.jsx @@ -0,0 +1,31 @@ +const YearSelect = ({ year, setYear }) => { + const startYear = 2026; + const years = [...Array(11).keys()].map((increment) => { + return startYear - increment; + }); + const options = years.map((year) => { + return ( + + ); + }); + + return ( + <> + + + + ); +}; + +export default YearSelect; diff --git a/map/static/js/components/YearlyPermitInfo.jsx b/map/static/js/components/YearlyPermitInfo.jsx new file mode 100644 index 0000000..f167565 --- /dev/null +++ b/map/static/js/components/YearlyPermitInfo.jsx @@ -0,0 +1,16 @@ +const YearlyPermitInfo = ({ totalPermits, maxPermits }) => { + const { max, name } = maxPermits; + return ( +
+

+ Restaurant permits issued this year: {totalPermits ?? "N/A"} +

+

+ Maximum number of restaurant permits in a single area:{" "} + {`${max} (${name.join(", ")})` || "N/A"} +

+
+ ); +}; + +export default YearlyPermitInfo; diff --git a/map/static/js/utils/generateAreaIdPermitObject.js b/map/static/js/utils/generateAreaIdPermitObject.js new file mode 100644 index 0000000..fa4bb61 --- /dev/null +++ b/map/static/js/utils/generateAreaIdPermitObject.js @@ -0,0 +1,12 @@ +const generateAreaIdPermitObject = (mapData) => { + const map = {}; + + for (let res of mapData) { + const [_, value] = Object.entries(res)[0]; + map[value.area_id] = value.num_permits; + } + + return map; +}; + +export default generateAreaIdPermitObject; diff --git a/map/static/js/utils/getMapAreaColor.js b/map/static/js/utils/getMapAreaColor.js new file mode 100644 index 0000000..614fd97 --- /dev/null +++ b/map/static/js/utils/getMapAreaColor.js @@ -0,0 +1,23 @@ +const getMapAreaColor = (percentageOfPermits) => { + const communityAreaColors = ["#eff3ff", "#bdd7e7", "#6baed6", "#2171b5"]; + + if (typeof percentageOfPermits !== "number") { + return "#d3500a"; + } + + let fillColor; + + if (percentageOfPermits < 25) { + fillColor = communityAreaColors[0]; + } else if (percentageOfPermits >= 25 && percentageOfPermits < 50) { + fillColor = communityAreaColors[1]; + } else if (percentageOfPermits >= 50 && percentageOfPermits < 75) { + fillColor = communityAreaColors[2]; + } else { + fillColor = communityAreaColors[3]; + } + + return fillColor; +}; + +export default getMapAreaColor; diff --git a/map/static/js/utils/getMaxNumberOfPermits.js b/map/static/js/utils/getMaxNumberOfPermits.js new file mode 100644 index 0000000..e231290 --- /dev/null +++ b/map/static/js/utils/getMaxNumberOfPermits.js @@ -0,0 +1,20 @@ +const getMaxNumberOfPermits = (currentYearMapData) => { + let maxNumPermits = { max: 0, name: [] }; + + for (let data of currentYearMapData) { + const [key, value] = Object.entries(data)[0]; + + if (value.num_permits > maxNumPermits.max) { + maxNumPermits = { + max: value.num_permits, + name: [key], // reset + }; + } else if (value.num_permits === maxNumPermits.max) { + maxNumPermits.name.push(key); // append + } + } + + return maxNumPermits; +}; + +export default getMaxNumberOfPermits; diff --git a/map/static/js/utils/getTotalPermitsPerYear.js b/map/static/js/utils/getTotalPermitsPerYear.js new file mode 100644 index 0000000..0527853 --- /dev/null +++ b/map/static/js/utils/getTotalPermitsPerYear.js @@ -0,0 +1,9 @@ +const getTotalPermitsPerYear = (mapData) => { + + return mapData.reduce((sum, areaObj) => { + const area = Object.values(areaObj)[0]; + return sum + area.num_permits; + }, 0); +}; + +export default getTotalPermitsPerYear; diff --git a/tests/test_views.py b/tests/test_views.py index 24cc64e..ea294a4 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -32,10 +32,14 @@ def test_map_data_view(): community_area_id=area2.area_id, issue_date=date(2021, 6, 22) ) - # Query the map data endpoint client = APIClient() response = client.get(reverse("map_data", query={"year": 2021})) - - # TODO: Complete the test by asserting that the /map-data/ endpoint - # returns the correct number of permits for Beverly and Lincoln - # Park in 2021 + assert response.status_code == 200 + + data = response.json() + results = {list(item.keys())[0]: list(item.values())[0] for item in data} + + assert results["Beverly"]["num_permits"] == 2 + assert results["Beverly"]["area_id"] == 1 + assert results["Lincoln Park"]["num_permits"] == 3 + assert results["Lincoln Park"]["area_id"] == 2 diff --git a/webpack.config.js b/webpack.config.js index cb6c7bb..7c3f288 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,6 +1,6 @@ -const path = require("path") -const webpack = require("webpack") // eslint-disable-line no-unused-vars -const BundleTracker = require("webpack-bundle-tracker") +const path = require("path"); +const webpack = require("webpack"); // eslint-disable-line no-unused-vars +const BundleTracker = require("webpack-bundle-tracker"); const config = { context: __dirname, @@ -46,7 +46,15 @@ const config = { exclude: /node_modules/, loader: "babel-loader", options: { - presets: ["@babel/preset-env", "@babel/preset-react"], + presets: [ + "@babel/preset-env", + [ + "@babel/preset-react", + { + runtime: "automatic", + }, + ], + ], }, }, { @@ -99,7 +107,7 @@ const config = { }, ], }, -} +}; module.exports = (env, argv) => { /* @@ -109,12 +117,12 @@ module.exports = (env, argv) => { * /app/static/bundles for bundles. */ if (argv.mode === "development") { - config.output.publicPath = "http://localhost:3000/static/bundles/" + config.output.publicPath = "http://localhost:3000/static/bundles/"; } if (argv.mode === "production") { - config.output.publicPath = "/static/bundles/" + config.output.publicPath = "/static/bundles/"; } - return config -} + return config; +};