diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..6f3c990b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "chrome", + "request": "launch", + "name": "Launch Chrome against localhost", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/backend/.env.template b/backend/.env.template index 5dd86fe3..ddb4a933 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -1,4 +1,4 @@ APP_URL="127.0.0.1" APP_PORT=8080 EDITOR_PORT=4040 -DATABASE_URL=postgres://username:password@host:5432/database \ No newline at end of file +DATABASE_URL=postgres://username:password@localhost:5432/database \ No newline at end of file diff --git a/backend/migrations/20230713151745_change-subcategories.sql b/backend/migrations/20230713151745_change-subcategories.sql new file mode 100644 index 00000000..8ab6ae8a --- /dev/null +++ b/backend/migrations/20230713151745_change-subcategories.sql @@ -0,0 +1,39 @@ +UPDATE + subcategory +SET + "order" = 3 +WHERE + "order" = 2; + +INSERT INTO + subcategory (name, "order") +VALUES + ('Transitional Risk Metrics', 2); + +UPDATE + map_visualization +SET + subcategory = 3 +WHERE + id IN (64, 90); + +UPDATE + data_category +SET + name = 'combinatory metrics' +WHERE + "order" = -1; + +UPDATE + subcategory +SET + name = 'Diversity and Equity Metrics' +WHERE + name = 'Environmental Equity'; + +UPDATE + map_visualization +SET + subcategory = 3 +WHERE + id = 64; \ No newline at end of file diff --git a/backend/migrations/20230830102420_rename_risk_metrics.sql b/backend/migrations/20230830102420_rename_risk_metrics.sql new file mode 100644 index 00000000..2fe08486 --- /dev/null +++ b/backend/migrations/20230830102420_rename_risk_metrics.sql @@ -0,0 +1,6 @@ +UPDATE + subcategory +SET + name = 'Physical Risk Metrics' +WHERE + name = 'Risk Metrics'; \ No newline at end of file diff --git a/backend/src/controller/data_controller.rs b/backend/src/controller/data_controller.rs index ced8c1aa..d83e4756 100644 --- a/backend/src/controller/data_controller.rs +++ b/backend/src/controller/data_controller.rs @@ -9,6 +9,7 @@ use serde::Deserialize; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service(get_by_dataset); cfg.service(get_percentiles); + cfg.service(get_state_percentiles); cfg.service(get_by_map_visualization); } @@ -104,6 +105,22 @@ async fn get_percentiles( } } +#[get("/state_percentile")] +async fn get_state_percentiles( + info: web::Query, + app_state: web::Data>, +) -> impl Responder { + let data = app_state.database.data.state_percentile(info.into_inner()).await; + + match data { + Ok(data) => match csv_converter::convert(data) { + Ok(csv) => HttpResponse::Ok().content_type("text/csv").body(csv), + Err(_) => HttpResponse::NotFound().finish(), + }, + Err(_) => HttpResponse::NotFound().finish(), + } +} + #[delete("/dataset/{dataset}/data")] async fn delete(app_state: web::Data>, dataset: web::Path) -> impl Responder { let result = app_state diff --git a/backend/src/dao/data_dao.rs b/backend/src/dao/data_dao.rs index 8f53c6ef..5e442598 100644 --- a/backend/src/dao/data_dao.rs +++ b/backend/src/dao/data_dao.rs @@ -174,6 +174,129 @@ impl<'c> Table<'c, Data> { .await } + /** + * The state-level percentile for a given geo-id for all datasets in a category + */ + pub async fn state_percentile( + &self, + info: PercentileInfo, + ) -> Result, sqlx::Error> { + sqlx::query_as!( + data::Percentile, + r#" + SELECT + entry.dataset, + entry.dataset_name, + entry.source as "source!", + entry.start_date as "start_date!", + entry.end_date as "end_date!", + entry.units, + entry.formatter_type, + entry.decimals, + ( + SELECT + value + FROM + (SELECT * FROM data + WHERE FLOOR(id / 1000) = FLOOR($1 / 1000)) AS state_data + WHERE + id =($1) + AND dataset = entry.dataset + AND source = entry.source + AND start_date = entry.start_date + AND end_date = entry.end_date + ), + CASE WHEN ( + SELECT + value + FROM + (SELECT * FROM data + WHERE FLOOR(id / 1000) = FLOOR($1 / 1000)) AS state_data + WHERE + id =($1) + AND dataset = entry.dataset + AND source = entry.source + AND start_date = entry.start_date + AND end_date = entry.end_date + ) IS NULL THEN NULL ELSE + percent_rank( + ( + SELECT + CASE WHEN entry.invert_normalized THEN -value ELSE value END as value + FROM + (SELECT * FROM data + WHERE FLOOR(id / 1000) = FLOOR($1 / 1000)) AS state_data + WHERE + id =($1) + AND dataset = entry.dataset + AND source = entry.source + AND start_date = entry.start_date + AND end_date = entry.end_date + ) + ) within GROUP ( + ORDER BY CASE WHEN entry.invert_normalized THEN -value ELSE value END + ) + END + FROM + (SELECT * FROM data + WHERE FLOOR(id / 1000) = FLOOR($1 / 1000)) AS state_data, + ( + SELECT + map_visualization.dataset, + dataset.name as dataset_name, + COALESCE(default_source, source) AS source, + COALESCE(default_start_date, start_date) AS start_date, + COALESCE(default_end_date, end_date) AS end_date, + invert_normalized, + units, + formatter_type, + decimals + FROM + map_visualization, + map_visualization_collection, + dataset, + ( + SELECT + dataset, + MAX(end_date) AS end_date, + MAX(start_date) AS start_date, + MAX("source") AS source + FROM + data + GROUP BY + dataset + ) AS cd + WHERE + map_visualization_collection.category = $2 + AND map_visualization.dataset = dataset.id + AND cd.dataset = map_visualization.dataset + AND map_visualization_collection.map_visualization = map_visualization.id + AND dataset.geography_type = $3 + ) AS entry + WHERE + state_data.dataset = entry.dataset + AND state_data.source = entry.source + AND state_data.start_date = entry.start_date + AND state_data.end_date = entry.end_date + GROUP BY + entry.dataset, + entry.dataset_name, + entry.source, + entry.start_date, + entry.end_date, + entry.units, + entry.formatter_type, + entry.decimals, + entry.invert_normalized; + "#, + info.geo_id, + info.category, + info.geography_type + ) + .fetch_all(&*self.pool) + .await + } + pub async fn insert(&self, data: &HashSet) -> Result { let mut ids: Vec = Vec::with_capacity(data.len()); let mut sources: Vec = Vec::with_capacity(data.len()); diff --git a/frontend/src/ChoroplethMap.tsx b/frontend/src/ChoroplethMap.tsx index 8382411a..d6f5dd5c 100644 --- a/frontend/src/ChoroplethMap.tsx +++ b/frontend/src/ChoroplethMap.tsx @@ -11,6 +11,7 @@ import { getDomain } from './DataProcessor' import { getLegendFormatter } from './Formatter' import Legend from './Legend' import { MapType, MapVisualization } from './MapVisualization' +import { ZOOM_TRANSITION } from './MapWrapper' import ProbabilityDensity from './ProbabilityDensity' import StateMap from './StateMap' import { TopoJson } from './TopoJson' @@ -87,7 +88,7 @@ function ChoroplethMap( return ( <> - + {borders.map((region) => ( ( [0.05, 0.25, 0.75, 0.95], [...schemeRdYlBu[5]].reverse() ) +export const redBlueReportCard = scaleThreshold( + [0.01, 0.05, 0.25, 0.33, 0.5, 0.66, 0.75, 0.95, 0.99], + [...schemeRdYlBu[10]].reverse() +) const colorScheme = ( map: MapVisualization, diff --git a/frontend/src/CountryNavigation.tsx b/frontend/src/CountryNavigation.tsx index 7eb72d3a..e0658166 100644 --- a/frontend/src/CountryNavigation.tsx +++ b/frontend/src/CountryNavigation.tsx @@ -1,4 +1,4 @@ -import { ToggleButton, ToggleButtonGroup } from '@mui/material' +import { Select, MenuItem, SelectChangeEvent, InputLabel, FormControl } from '@mui/material' import { useEffect } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useParams } from 'react-router-dom' @@ -25,23 +25,41 @@ export default function RegionNavigation() { return ( ) } diff --git a/frontend/src/EmptyMap.tsx b/frontend/src/EmptyMap.tsx index 002f7893..0fa87491 100644 --- a/frontend/src/EmptyMap.tsx +++ b/frontend/src/EmptyMap.tsx @@ -2,6 +2,7 @@ import { geoPath } from 'd3' import type { GeoJsonProperties } from 'geojson' import { feature } from 'topojson-client' import type { GeometryCollection } from 'topojson-specification' +import { ZOOM_TRANSITION } from './MapWrapper' import { GeoMap } from './appSlice' const path = geoPath() @@ -15,7 +16,7 @@ function EmptyMap({ map, transform }: { map: GeoMap; transform?: string }) { ).features return ( - + {borders.map((region) => (
-

STRESS Tool

+

STRESS Platform

- System for the Triage of Risks from Environmental and Socio-Economic - Stressors. + System for the Triage of Risks from E + nvironmental and Socio-economic Stressors

diff --git a/frontend/src/Home.module.css b/frontend/src/Home.module.css index a239676d..ec6681ff 100644 --- a/frontend/src/Home.module.css +++ b/frontend/src/Home.module.css @@ -9,8 +9,16 @@ margin-bottom: 1em; } +.nav-div { + display: flex; + justify-content: space-between; +} + @media (max-width: 1000px) { .content { flex-direction: column-reverse; } + .nav-div { + flex-direction: column-reverse; + } } diff --git a/frontend/src/Home.test.tsx b/frontend/src/Home.test.tsx index c7630ff5..63fceeba 100644 --- a/frontend/src/Home.test.tsx +++ b/frontend/src/Home.test.tsx @@ -86,7 +86,7 @@ test('It shows header and loads data selector', async () => { ) - expect(screen.getByText(/STRESS Tool/i)).toBeInTheDocument() + expect(screen.getByText(/STRESS Platform/i)).toBeInTheDocument() // map title, data selector, and description expander all have the same text expect( await screen.findAllByText(/Exposure to airborne particulate matter/i, undefined, { diff --git a/frontend/src/Home.tsx b/frontend/src/Home.tsx index 0c531d1c..471770a5 100644 --- a/frontend/src/Home.tsx +++ b/frontend/src/Home.tsx @@ -69,21 +69,23 @@ function Home() { return ( <>
- - {tabs ? ( - dispatch(setTab(tab))} - selectedTabId={tab?.id} - /> - ) : ( - - )} +
+ {tabs ? ( + dispatch(setTab(tab))} + selectedTabId={tab?.id} + /> + ) : ( + + )} + +
{isNormalized && ( diff --git a/frontend/src/MapApi.ts b/frontend/src/MapApi.ts index 48867e38..e6941c60 100644 --- a/frontend/src/MapApi.ts +++ b/frontend/src/MapApi.ts @@ -76,7 +76,7 @@ export type PercentileRow = { decimals: number } -type Percentiles = { +export type Percentiles = { [key in DatasetId]: PercentileRow } @@ -221,6 +221,18 @@ export const mapApi = createApi({ ) }, }), + getStatePercentiles: builder.query({ + queryFn: ({ geoId, category, geographyType }) => { + const loadingCsv = loadCsv( + `/api/state_percentile?geo_id=${geoId}&category=${category}&geography_type=${geographyType}`, + autoType + ) + return loadingCsv.then(transformCountySummary).then( + (data) => ({ data }), + (failure) => ({ error: failure }) + ) + }, + }), getData: builder.query, DataQueryParams[]>({ queryFn: (queryParams) => { const loadingCsvs = queryParams.map( @@ -463,6 +475,7 @@ export const { useGetCountiesQuery, useGetStatesQuery, useGetPercentilesQuery, + useGetStatePercentilesQuery, useGetSubcategoriesQuery, useUnpublishMapVisualizationMutation, usePublishMapVisualizationMutation, diff --git a/frontend/src/MapControls.module.css b/frontend/src/MapControls.module.css index cd23e0d4..ac1652cd 100644 --- a/frontend/src/MapControls.module.css +++ b/frontend/src/MapControls.module.css @@ -5,4 +5,5 @@ #map-controls button { margin-right: 1em; + margin-top: 1em; } diff --git a/frontend/src/MapControls.tsx b/frontend/src/MapControls.tsx index 7f373b28..6d3bfcf7 100644 --- a/frontend/src/MapControls.tsx +++ b/frontend/src/MapControls.tsx @@ -13,7 +13,7 @@ import { saveAs } from 'file-saver' import { Map } from 'immutable' import { Fragment } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { useNavigate, useParams } from 'react-router-dom' +import { useParams } from 'react-router-dom' import counties from './Counties' import { getLegendTitle } from './FullMap' import css from './MapControls.module.css' @@ -27,6 +27,7 @@ import { OverlayName, Region, TransmissionLineType, + clickMap, setDetailedView, setShowOverlay, setTransmissionLineType, @@ -145,10 +146,10 @@ function MapControls({ data, isNormalized, maps }: Props) { const detailedView = useSelector((state: RootState) => state.app.detailedView) const countyId = useSelector((state: RootState) => state.app.county) + const zoomTo = useSelector((state: RootState) => state.app.zoomTo) const region: Region = maps[0]?.geography_type === 1 ? 'USA' : 'World' const params = useParams() const { tabId } = params - const navigate = useNavigate() const UsaCsv = (sortedData: Map | undefined) => { const objectData = sortedData @@ -198,38 +199,51 @@ function MapControls({ data, isNormalized, maps }: Props) { return (
- {countyId && ( - - )} - {region === 'USA' && } - {isNormalized && data && ( - dispatch(setDetailedView(value))} - name="detailed-view" - color="primary" - /> - } - label="Detailed View" - /> - )} - {data && ( - - )} - {data && ( - - )} +
+ {countyId && ( + + )} + {zoomTo && ( + // creates a button that zooms back out if zoomed into a state or country + + )} +
+
+ {region === 'USA' && } + {isNormalized && data && ( + dispatch(setDetailedView(value))} + name="detailed-view" + color="primary" + /> + } + label="Detailed View" + /> + )} +
+
+ {data && ( + + )} + {data && ( + + )} +
) } diff --git a/frontend/src/MapWrapper.tsx b/frontend/src/MapWrapper.tsx index 33a074ec..4a30d537 100644 --- a/frontend/src/MapWrapper.tsx +++ b/frontend/src/MapWrapper.tsx @@ -1,6 +1,6 @@ import { skipToken } from '@reduxjs/toolkit/dist/query' import { useMemo, useRef } from 'react' -import { useSelector } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import DataDescription from './DataDescription' import DataProcessor from './DataProcessor' import DataSourceDescription from './DataSourceDescription' @@ -13,7 +13,7 @@ import MapTooltip from './MapTooltip' import { MapVisualization, MapVisualizationId } from './MapVisualization' import css from './MapWrapper.module.css' import Overlays from './Overlays' -import { selectMapTransform, selectSelections, stateId } from './appSlice' +import { clickMap, selectMapTransform, selectSelections, stateId } from './appSlice' import { RootState } from './store' export const ZOOM_TRANSITION = { transition: 'all 0.5s cubic-bezier(0.4, 0, 0.2, 1)' } @@ -25,6 +25,7 @@ function MapWrapper({ allMapVisualizations: Record isNormalized: boolean }) { + const dispatch = useDispatch() const map = useSelector((state: RootState) => state.app.map) const detailedView = useSelector((state: RootState) => state.app.detailedView) const zoomTo = useSelector((rootState: RootState) => rootState.app.zoomTo) @@ -60,7 +61,10 @@ function MapWrapper({ invertNormalized: map.invert_normalized, })), normalize: isNormalized, - filter: zoomTo && region ? (geoId) => stateId(geoId) === zoomTo : undefined, + filter: + zoomTo && region === 'USA' + ? (geoId) => stateId(geoId) === zoomTo + : undefined, }) : undefined, [data, maps, dataWeights, zoomTo, isNormalized, region] @@ -71,6 +75,7 @@ function MapWrapper({ if (map === undefined) { return

Loading

} + // rectangle ("rect") below is made so that the user can zoom out by clicking on the map background return ( <>
@@ -87,6 +92,15 @@ function MapWrapper({ xmlnsXlink="http://www.w3.org/1999/xlink" viewBox="0, 0, 1175, 610" > + dispatch(clickMap(Number(-1)))} + style={{ opacity: 0 }} + /> {processedData ? ( + ) => { if (state.region === 'USA') { - if (state.zoomTo) { + if ( + // Zooms out if the currently selected county is clicked, + // or if the "Zoom Out" button is clicked (which is the -1 placeholder value) + (state.county && state.county === payload) || + payload === -1 + ) { state.zoomTo = undefined state.county = undefined } else { @@ -174,13 +179,24 @@ export const appSlice = createSlice({ state.county = payload } } else { - state.zoomTo = state.zoomTo === undefined ? payload : undefined + // For the World map, zooms out if the currently selected country is clicked, + // or if the "Zoom Out" button is clicked + state.zoomTo = state.zoomTo === payload || payload === -1 ? undefined : payload } }, selectRegion: (state, { payload }: PayloadAction) => { state.region = payload state.zoomTo = undefined state.county = undefined + // removes overlays when switching regions + state.overlays = { + Highways: { shouldShow: false }, + 'Major railroads': { shouldShow: false }, + 'Transmission lines': { shouldShow: false }, + 'Marine highways': { shouldShow: false }, + 'Critical water habitats': { shouldShow: false }, + 'Endangered species': { shouldShow: false }, + } }, }, extraReducers: (builder) => { diff --git a/frontend/src/editor/MapOptions.tsx b/frontend/src/editor/MapOptions.tsx index 1fc5d036..1783fdc9 100644 --- a/frontend/src/editor/MapOptions.tsx +++ b/frontend/src/editor/MapOptions.tsx @@ -174,7 +174,7 @@ function MapOptions({ mapVisualization }: { mapVisualization: MapVisualization } value={mapVisualization.decimals ?? ''} onChange={(e) => { const decimals = parseInt(e.target.value, 10) - if (Number.isInteger(decimals)) { + if (Number.isInteger(decimals) && decimals >= 0) { updateMap({ ...mapVisualization, decimals }) } }} diff --git a/frontend/src/report-card/ReportCard.module.css b/frontend/src/report-card/ReportCard.module.css index df6f7643..a50fca8c 100644 --- a/frontend/src/report-card/ReportCard.module.css +++ b/frontend/src/report-card/ReportCard.module.css @@ -11,12 +11,20 @@ h1 { font-weight: bold; } +.county-metrics thead td { + padding-left: .25em; +} + .county-metrics tr:hover { background: #f2f2f2; } .county-metrics td{ - padding:1em; + padding: 0em; +} + +.county-metrics td + td { + border-left: 2px solid #ddd; } .report-card { @@ -35,9 +43,21 @@ h1 { .county-metrics .percentile-bar-column { padding-left: 0; + border-left-style: hidden!important; + padding-right: .5em; + width: 15% } .county-metrics .percentile-column { width: 2em; padding-right: .5em; + padding-left: .25em; +} + +.county-metrics .raw-val-column { + padding-left: .25em; +} + +.county-metrics .metric-column { + padding-left: .25em; } \ No newline at end of file diff --git a/frontend/src/report-card/ReportCard.tsx b/frontend/src/report-card/ReportCard.tsx index 158005f5..efb1e173 100644 --- a/frontend/src/report-card/ReportCard.tsx +++ b/frontend/src/report-card/ReportCard.tsx @@ -1,19 +1,29 @@ import { Autocomplete, TextField } from '@mui/material' import { useEffect, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' -import { redBlue } from '../Color' +import { redBlueReportCard } from '../Color' import { formatData } from '../Formatter' import { County, PercentileRow, useGetCountiesQuery, useGetPercentilesQuery, + useGetStatePercentilesQuery, useGetStatesQuery, useGetTabsQuery, } from '../MapApi' import { stateId } from '../appSlice' import css from './ReportCard.module.css' +let showStatePercentiles = false +let StatePercentileData: PercentileRow[] + +// gets the PercentileRow with the given name in StatePercentileData +function GetStatePercentileData(name: String) { + return StatePercentileData.find((row) => row.name === name) +} + +// generates the numerical percent value visual and percentile bar visual for the given value function Percentile({ value }: { value: number }) { return ( <> @@ -26,28 +36,42 @@ function Percentile({ value }: { value: number }) {
) } +// generates an empty space for null data function EmptyPercentile() { return } +// generates a row of data in the table including the metric name, percentile visual, and raw value function SingleMetric({ data }: { data: PercentileRow }) { return ( - {data.name} + + {data.name} + {data.percentRank == null ? ( ) : ( )} - + {showStatePercentiles && + StatePercentileData && + GetStatePercentileData(data.name) === undefined && } + + {showStatePercentiles && + StatePercentileData && + GetStatePercentileData(data.name) !== undefined && ( + + )} + + {formatData(data.value, { type: data.formatter_type, decimals: data.decimals, @@ -59,6 +83,7 @@ function SingleMetric({ data }: { data: PercentileRow }) { ) } +// generates the table which contains the Report Card for the given county and data category function PercentileReport({ category, geoId, @@ -73,12 +98,25 @@ function PercentileReport({ category, geographyType, }) + const { data: countySummaryByState } = useGetStatePercentilesQuery({ + geoId, + category, + geographyType, + }) + + // if enabled, display the state-level percentile data + if (showStatePercentiles && countySummaryByState) { + StatePercentileData = Object.entries(countySummaryByState) + .flat(1) + .filter((e) => !(typeof e === 'string')) as PercentileRow[] + } return ( + {showStatePercentiles && } @@ -101,6 +139,11 @@ function PercentileReport({ const fipsCode = (county: County) => String(county.id).padStart(5, '0') export default function ReportCard() { + const [checked, setChecked] = useState(false) + const handleChange = () => { + setChecked(!checked) + showStatePercentiles = !showStatePercentiles + } const params = useParams() const { data: counties } = useGetCountiesQuery(undefined) const { data: states } = useGetStatesQuery(undefined) @@ -139,6 +182,12 @@ export default function ReportCard() { }} value={selectedRegion} /> +
+ +
{selectedRegion && states && categoryId !== undefined && (
Metric National PercentileState PercentileValue