mirror of
https://github.com/DOI-DO/j40-cejst-2.git
synced 2025-07-30 06:41:18 -07:00
Issue 191 - Multi-state visualization (#226)
Addresses issue #191 - As a stakeholder interested in the cumulative impact score, I want to see more states in the map, so that I can further analyze the score results. Introduces gradient coloration on limited 5-state dataset as well as the core of a few key visual aspects of the map to be expanded upon later.
This commit is contained in:
parent
8b8314aeb3
commit
f12ab4d3b7
5 changed files with 712 additions and 464 deletions
872
client/package-lock.json
generated
872
client/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -2,4 +2,84 @@
|
|||
height: 676px;
|
||||
margin-bottom: 29px;
|
||||
max-width: revert;
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
.popupContainer {
|
||||
position: absolute;
|
||||
background-color: white;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #cccccc;
|
||||
bottom: 12px;
|
||||
left: -50px;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.popupContainer:after,
|
||||
.popupContainer:before {
|
||||
top: 100%;
|
||||
border: solid transparent;
|
||||
content: " ";
|
||||
height: 0;
|
||||
width: 0;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.popupContainer:after {
|
||||
border-top-color: white;
|
||||
border-width: 10px;
|
||||
left: 48px;
|
||||
margin-left: -10px;
|
||||
}
|
||||
|
||||
.popupContainer:before {
|
||||
border-top-color: #cccccc;
|
||||
border-width: 11px;
|
||||
left: 48px;
|
||||
margin-left: -11px;
|
||||
}
|
||||
|
||||
.popupCloser {
|
||||
text-decoration: none;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.popupCloser:after {
|
||||
content: "✖";
|
||||
}
|
||||
|
||||
.popupContent {
|
||||
max-height: 300px;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.popupHeaderTable {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
font: normal 12px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.popupHeaderTable tbody td:first {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.zoomWarning {
|
||||
background-color: #953a10;
|
||||
height: 8%;
|
||||
width: 66%;
|
||||
margin: auto;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.zoomWarning > img {
|
||||
filter: invert(100%);
|
||||
}
|
||||
|
|
6
client/src/components/map.module.scss.d.ts
vendored
6
client/src/components/map.module.scss.d.ts
vendored
|
@ -1,6 +1,12 @@
|
|||
declare namespace MapModuleScssNamespace {
|
||||
export interface IMapModuleScss {
|
||||
mapContainer: string;
|
||||
popupContainer: string;
|
||||
popupCloser: string;
|
||||
popupContent: string;
|
||||
popupHeaderTable: string;
|
||||
zoomWarning:string;
|
||||
mapWrapperContainer:string;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,17 +1,18 @@
|
|||
import React, {useState, useEffect, useRef} from 'react';
|
||||
import Map from 'ol/Map';
|
||||
import View from 'ol/View';
|
||||
import Feature from 'ol/Feature';
|
||||
import Feature, {FeatureLike} from 'ol/Feature';
|
||||
import Geometry from 'ol/geom/Geometry';
|
||||
import VectorLayer from 'ol/layer/Vector';
|
||||
import VectorSource from 'ol/source/Vector';
|
||||
import {fromLonLat} from 'ol/proj';
|
||||
import * as styles from './map.module.scss';
|
||||
import olms from 'ol-mapbox-style';
|
||||
import Overlay from 'ol/Overlay';
|
||||
// import {Table} from '@trussworks/react-uswds';
|
||||
|
||||
interface IMapWrapperProps {
|
||||
features: Feature<Geometry>[],
|
||||
};
|
||||
// @ts-ignore
|
||||
import zoomIcon from '/node_modules/uswds/dist/img/usa-icons/zoom_in.svg';
|
||||
|
||||
const mapConfig = {
|
||||
'version': 8,
|
||||
|
@ -20,17 +21,27 @@ const mapConfig = {
|
|||
'carto-light': {
|
||||
'type': 'raster',
|
||||
'tiles': [
|
||||
'https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
|
||||
'https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
|
||||
'https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
|
||||
'https://d.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
|
||||
'https://a.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png',
|
||||
'https://b.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png',
|
||||
'https://c.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png',
|
||||
'https://d.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png',
|
||||
],
|
||||
},
|
||||
'custom': {
|
||||
'projection': 'EPSG:3857',
|
||||
'type': 'vector',
|
||||
'tiles': [
|
||||
'https://gis.data.census.gov/arcgis/rest/services/Hosted/VT_2019_150_00_PY_D1/VectorTileServer/tile/{z}/{y}/{x}.mvt',
|
||||
'http://usds-geoplatform-justice40-website.s3-website-us-east-1.amazonaws.com/0624_demo/{z}/{x}/{y}.pbf',
|
||||
// For local development, use:
|
||||
// 'http://localhost:8080/data/tl_2010_bg_with_data/{z}/{x}/{y}.pbf',
|
||||
],
|
||||
},
|
||||
'labels': {
|
||||
'type': 'raster',
|
||||
'tiles': [
|
||||
'https://cartodb-basemaps-a.global.ssl.fastly.net/light_only_labels/{z}/{x}/{y}@2x.png',
|
||||
'https://cartodb-basemaps-b.global.ssl.fastly.net/light_only_labels/{z}/{x}/{y}@2x.png',
|
||||
'https://cartodb-basemaps-c.global.ssl.fastly.net/light_only_labels/{z}/{x}/{y}@2x.png',
|
||||
'https://cartodb-basemaps-d.global.ssl.fastly.net/light_only_labels/{z}/{x}/{y}@2x.png',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
@ -44,33 +55,71 @@ const mapConfig = {
|
|||
},
|
||||
{
|
||||
'id': 'blocks',
|
||||
'type': 'line',
|
||||
'type': 'fill',
|
||||
'source': 'custom',
|
||||
'source-layer': 'BlockGroup',
|
||||
'source-layer': 'blocks',
|
||||
'minzoom': 0,
|
||||
'layout': {
|
||||
'line-cap': 'round',
|
||||
'line-join': 'round',
|
||||
},
|
||||
|
||||
// 01=AL, 30=MT, 34=NJ, 35=NM, 36=NY
|
||||
'filter': ['in', 'STATEFP10', '01', '30', '34', '35', '36'],
|
||||
'paint': {
|
||||
'line-opacity': 0.6,
|
||||
'line-color': 'red',
|
||||
'line-width': 1,
|
||||
'fill-color': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['to-number', [
|
||||
'get',
|
||||
'Score C (percentile)',
|
||||
]],
|
||||
0.0,
|
||||
'white',
|
||||
1,
|
||||
'blue',
|
||||
],
|
||||
'fill-opacity': 0.75,
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': 'labels-only',
|
||||
'type': 'raster',
|
||||
'source': 'labels',
|
||||
'minzoom': 0,
|
||||
'maxzoom': 22,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface IMapWrapperProps {
|
||||
features: Feature<Geometry>[],
|
||||
};
|
||||
|
||||
// The below adapted from
|
||||
// https://taylor.callsen.me/using-openlayers-with-react-functional-components/
|
||||
const MapWrapper = ({features}: IMapWrapperProps) => {
|
||||
const [map, setMap] = useState<Map>();
|
||||
const [featuresLayer, setFeaturesLayer] = useState<VectorLayer>();
|
||||
const [currentOverlay, setCurrentOverlay] = useState<Overlay>();
|
||||
const [selectedFeature, setSelectedFeature] = useState<FeatureLike>();
|
||||
const [currentZoom, setCurrentZoom] = useState<number>(0);
|
||||
|
||||
const mapElement = useRef() as
|
||||
React.MutableRefObject<HTMLInputElement>;
|
||||
React.MutableRefObject<HTMLInputElement>;
|
||||
|
||||
// create state ref that can be accessed in OpenLayers onclick callback function
|
||||
// https://stackoverflow.com/a/60643670
|
||||
const mapRef = useRef() as React.MutableRefObject<Map>;
|
||||
if (map) {
|
||||
mapRef.current = map;
|
||||
}
|
||||
|
||||
const popupContainer = React.useRef<HTMLDivElement>(null);
|
||||
const popupCloser = React.useRef<HTMLAnchorElement>(null);
|
||||
const popupContent = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const overlayRef = useRef() as React.MutableRefObject<Overlay>;
|
||||
overlayRef.current = currentOverlay!;
|
||||
|
||||
useEffect( () => {
|
||||
// create and add initial vector source layer, to be replaced layer
|
||||
|
@ -78,6 +127,24 @@ const MapWrapper = ({features}: IMapWrapperProps) => {
|
|||
source: new VectorSource(),
|
||||
});
|
||||
|
||||
if (!popupCloser || !popupContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
popupCloser.current!.onclick = function() {
|
||||
overlay.setPosition(undefined);
|
||||
popupCloser.current!.blur();
|
||||
return false;
|
||||
};
|
||||
|
||||
const overlay = new Overlay({
|
||||
// Using the non-null assertion operator as we check for null above
|
||||
element: popupContainer.current!,
|
||||
autoPan: true,
|
||||
autoPanAnimation: {
|
||||
duration: 250,
|
||||
},
|
||||
});
|
||||
const initialMap = new Map({
|
||||
target: mapElement.current,
|
||||
view: new View({
|
||||
|
@ -85,9 +152,15 @@ const MapWrapper = ({features}: IMapWrapperProps) => {
|
|||
zoom: 4,
|
||||
}),
|
||||
controls: [],
|
||||
overlays: [overlay],
|
||||
});
|
||||
const currentZoom = Math.floor(initialMap.getView().getZoom());
|
||||
|
||||
initialMap.on('moveend', handleMoveEnd);
|
||||
initialMap.on('click', handleMapClick);
|
||||
setMap(initialMap);
|
||||
setCurrentZoom(currentZoom);
|
||||
setCurrentOverlay(overlay);
|
||||
setFeaturesLayer(initialFeaturesLayer);
|
||||
olms(initialMap, mapConfig);
|
||||
}, []);
|
||||
|
@ -112,8 +185,116 @@ const MapWrapper = ({features}: IMapWrapperProps) => {
|
|||
}
|
||||
}, [features]);
|
||||
|
||||
const handleMapClick = (event: { pixel: any; }) => {
|
||||
const clickedCoord = mapRef.current.getCoordinateFromPixel(event.pixel);
|
||||
|
||||
mapRef.current.forEachFeatureAtPixel(event.pixel, (feature) => {
|
||||
setSelectedFeature(feature);
|
||||
return true;
|
||||
});
|
||||
|
||||
overlayRef.current.setPosition(clickedCoord);
|
||||
};
|
||||
|
||||
const handleMoveEnd = () => {
|
||||
const newZoom = Math.floor(mapRef.current.getView().getZoom()!);
|
||||
if (currentZoom != newZoom) {
|
||||
setCurrentZoom(newZoom);
|
||||
}
|
||||
};
|
||||
|
||||
const readablePercent = (percent: number) => {
|
||||
return `${(percent * 100).toFixed(2)}%`;
|
||||
};
|
||||
|
||||
let properties;
|
||||
if (selectedFeature) {
|
||||
properties = selectedFeature.getProperties();
|
||||
}
|
||||
|
||||
const getCategorization = (percentile: number) => {
|
||||
let categorization = '';
|
||||
if (percentile >= 0.75 ) {
|
||||
categorization = 'Prioritized';
|
||||
} else if (0.60 <= percentile && percentile < 0.75) {
|
||||
categorization = 'Threshold';
|
||||
} else {
|
||||
categorization = 'Non-prioritized';
|
||||
}
|
||||
return categorization;
|
||||
};
|
||||
|
||||
const getTitleContent = (properties: { [key: string]: any; }) => {
|
||||
const blockGroup = properties['GEOID10'];
|
||||
const score = properties['Score C (percentile)'];
|
||||
return (
|
||||
<table className={styles.popupHeaderTable}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Census Block Group:</strong></td>
|
||||
<td>{blockGroup}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Just Progress Categorization:</strong></td>
|
||||
<td>{getCategorization(score)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Cumulative Index Score:</strong></td>
|
||||
<td>{readablePercent(score)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
// const propertyTest = (propertyName:string) => {
|
||||
// // Filter out properties in all caps
|
||||
// return !propertyName.match(/^[A-Z0-9]+$/);
|
||||
// };
|
||||
|
||||
return (
|
||||
<div ref={mapElement} className={styles.mapContainer}></div>
|
||||
<div className={styles.mapWrapperContainer}>
|
||||
<div ref={mapElement} className={styles.mapContainer} />
|
||||
|
||||
<div ref={popupContainer} className={styles.popupContainer}>
|
||||
<a href="#" ref={popupCloser} className={styles.popupCloser}></a>
|
||||
<div ref={popupContent} className={styles.popupContent}>
|
||||
{(selectedFeature && properties) ?
|
||||
<div>
|
||||
{getTitleContent(properties)}
|
||||
{/* <Table bordered={false}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Indicator</th>
|
||||
<th scope="col">Percentile(0-100)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
Object.keys(properties).map((key, index) => (
|
||||
(propertyTest(key)) &&
|
||||
<tr key={key} >
|
||||
<td>{key}</td>
|
||||
<td>{properties[key]}</td>
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
</tbody>
|
||||
</Table> */}
|
||||
</div> :
|
||||
''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ currentZoom < 5 ?
|
||||
<div className={styles.zoomWarning}>
|
||||
<img
|
||||
src={zoomIcon} alt={'zoom icon'}/>
|
||||
Zoom in to the state or regional level to see prioritized communities on the map.
|
||||
</div> :
|
||||
''
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -13,7 +13,6 @@ interface IMapPageProps {
|
|||
}
|
||||
|
||||
const CEJSTPage = ({location}: IMapPageProps) => {
|
||||
|
||||
// We temporarily removed MapControls, which would enable you to `setFeatures` also, for now
|
||||
// We will bring back later when we have interactive controls.
|
||||
const [features] = useState<Feature<Geometry>[]>([]);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue