Allow user to select multiple tracts to eventually create a report

This commit is contained in:
Carlos Felix 2025-01-23 09:14:47 -05:00 committed by Carlos Felix
parent c3257858f6
commit 723d9515b7
7 changed files with 307 additions and 48 deletions

View file

@ -0,0 +1,52 @@
@use '../../styles/design-system.scss' as *;
.createReportContainer {
display: flex;
flex-direction: column;
margin: 0;
padding: 1.2rem 1rem 0 1.2rem;
font-size: medium;
height: 100%;
}
.tractListContainer {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
justify-content: flex-start;
align-items: stretch;
height: 50%;
overflow-y: scroll;
border: grey solid thin;
border-radius: 4px;
margin-top: 0.5rem;
}
.tractListItem {
flex-wrap: nowrap;
padding: 0.25rem;
}
.tractListItemHighlight {
flex-wrap: nowrap;
padding: 0.25rem;
font-weight: bold;
background-color: rgba(195, 222, 251, .5);
}
.tractListItemDelete {
align-content: center;
button:focus {
outline-width: 0;
}
}
.createReportButton {
align-self: center;
padding-top: 1rem;
}
.startOver {
margin-top: 1rem;
align-self: center;
}

View file

@ -0,0 +1,18 @@
declare namespace CreateReportPanelNamespace {
export interface ICreateReportPanelScss {
createReportButton: string;
createReportContainer: string;
startOver: string;
tractListContainer: string;
tractListItem: string;
tractListItemDelete: string;
tractListItemHighlight: string;
}
}
declare const CreateReportPanelScssModule: CreateReportPanelNamespace.ICreateReportPanelScss & {
/** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */
locals: CreateReportPanelNamespace.ICreateReportPanelScss;
};
export = CreateReportPanelScssModule;

View file

@ -0,0 +1,96 @@
import React, {useEffect, useState} from 'react';
import {MapGeoJSONFeature} from 'maplibre-gl';
import {Button, Alert, Grid} from '@trussworks/react-uswds';
import * as styles from './CreateReportPanel.module.scss';
import * as constants from '../../data/constants';
// @ts-ignore
import deleteIcon from '/node_modules/uswds/dist/img/usa-icons/close.svg';
interface ICreateReportPanel {
deleteTractHandler: (feature: MapGeoJSONFeature) => void,
className: string,
exitHandler: () => void,
featureList: MapGeoJSONFeature[],
maxNumTracts: number,
showTooManyTractsAlert: boolean,
}
const CreateReportPanel = ({
className,
featureList,
maxNumTracts,
showTooManyTractsAlert,
deleteTractHandler,
exitHandler,
}: ICreateReportPanel,
) => {
const [numPrevTracts, setNumPrevTracts] = useState<number>(0);
useEffect(() => {
// If adding a tract then scroll to the bottom of the tract list to always show the last added tract
if (numPrevTracts < featureList.length) {
const container = document.getElementById('j40-create-report-tract-list');
if (container) container.scrollTop = container.scrollHeight;
}
setNumPrevTracts(featureList.length);
}, [featureList, numPrevTracts]);
/**
* Handle the creation of a report.
*/
const handleCreateReport = () => {
if (featureList.length === 1) {
// TODO: One tract report
} else {
// TODO: Multi tract report
}
};
return (
<div id='create-report-panel' className={className}>
<div className={styles.createReportContainer}>
<h4>Create Report</h4>
{showTooManyTractsAlert ?
<Alert type='error' slim headingLevel='h4'>
You can only select up to {maxNumTracts} tracts for a report.
</Alert> :
<Alert type='info' slim headingLevel='h4'>
Select <strong>up to {maxNumTracts}</strong> tracts in the map
</Alert>
}
<p>
<span><strong>{featureList.length} tract{featureList.length === 1 ? '' : 's'}</strong> selected</span>
</p>
<div id='j40-create-report-tract-list' className={styles.tractListContainer}>
{featureList.map((item, index) => (
<Grid row key={index}
className={index === featureList.length - 1 ? styles.tractListItemHighlight : styles.tractListItem}>
<Grid col="auto">
{item.id}, {item.properties[constants.STATE_NAME]}</Grid>
<Grid col="fill" />
<Grid col="auto" className={styles.tractListItemDelete}>
<Button type='button' unstyled
onClick={() => deleteTractHandler(item)}>
<img tabIndex={0} src={deleteIcon}
alt='Need alt message'
/>
</Button>
</Grid>
</Grid>
))}
</div>
<div className={styles.createReportButton} >
<Button type='button' onClick={handleCreateReport}
disabled={featureList.length == 0}>Create Report</Button>
</div>
<div className={styles.startOver}>
<Button type='button' unstyled onClick={exitHandler}>Start Over</Button>
</div>
</div>
</div>
);
};
export default CreateReportPanel;

View file

@ -0,0 +1,3 @@
import CreateReportPanel from './CreateReportPanel';
export default CreateReportPanel;

View file

@ -12,7 +12,8 @@ import ReactMapGL, {
Popup,
FlyToInterpolator,
FullscreenControl,
MapRef} from 'react-map-gl';
MapRef,
} from 'react-map-gl';
import {useIntl} from 'gatsby-plugin-intl';
import bbox from '@turf/bbox';
import * as d3 from 'd3-ease';
@ -36,6 +37,7 @@ import 'maplibre-gl/dist/maplibre-gl.css';
import * as constants from '../data/constants';
import * as styles from './J40Map.module.scss';
import * as EXPLORE_COPY from '../data/copy/explore';
import CreateReportPanel from './CreateReportPanel';
declare global {
interface Window {
@ -63,6 +65,8 @@ export interface IMapFeature {
type: string;
}
const MAX_SELECTED_TRACTS = 20;
const J40Map = ({location}: IJ40Interface) => {
/**
* Initializes the zoom, and the map's center point (lat, lng) via the URL hash #{z}/{lat}/{long}
@ -86,11 +90,13 @@ const J40Map = ({location}: IJ40Interface) => {
zoom: zoom && parseFloat(zoom) ? parseFloat(zoom) : constants.GLOBAL_MIN_ZOOM,
});
const [selectedFeature, setSelectedFeature] = useState<MapGeoJSONFeature>();
const [selectedFeatures, setSelectedFeatures] = useState<MapGeoJSONFeature[]>([]);
const [detailViewData, setDetailViewData] = useState<IDetailViewInterface>();
const [transitionInProgress, setTransitionInProgress] = useState<boolean>(false);
const [geolocationInProgress, setGeolocationInProgress] = useState<boolean>(false);
const [isMobileMapState, setIsMobileMapState] = useState<boolean>(false);
const [inMultiSelectMode, setInMultiSelectMode] = useState<boolean>(false);
const [showTooManyTractsAlert, setShowTooManyTractsAlert] = useState<boolean>(false);
const [selectTractId, setSelectTractId] = useState<string | undefined>(undefined);
const {width: windowWidth} = useWindowSize();
@ -110,23 +116,84 @@ const J40Map = ({location}: IJ40Interface) => {
const flags = useFlags();
const intl = useIntl();
const selectedFeatureId = (selectedFeature && selectedFeature.id) || '';
const zoomLatLngHash = mapRef.current?.getMap()._hash._getCurrentHash();
/**
* Get the bounding box for one or more features.
* @param featureList the list of features
* @returns the bounding box
*/
const getFeaturesBbox = (featureList: MapGeoJSONFeature[]): number[] => {
if (featureList.length === 0) {
throw new Error('featureList must be a non-empty array to get a bounding box.');
}
// Calculate a max and min lat/lon from all the selected features.
const minLngList: number[] = [];
const minLatList: number[] = [];
const maxLngList: number[] = [];
const maxLatList: number[] = [];
featureList.forEach((feature) => {
const [featMinLng, featMinLat, featMaxLng, featMaxLat] = bbox(feature);
minLngList.push(featMinLng);
minLatList.push(featMinLat);
maxLngList.push(featMaxLng);
maxLatList.push(featMaxLat);
});
const minLng: number = Math.min(...minLngList);
const minLat: number = Math.max(...minLatList);
const maxLng: number = Math.max(...maxLngList);
const maxLat: number = Math.min(...maxLatList);
return [minLng, minLat, maxLng, maxLat];
};
/**
* Updates the state with the list of selected features. This function will:
* - Add the feature to the list if in multi select and the feature does not already exist
* - Remove the feature from the list if in multi select and the feature does already exist
* @param feature the feature to add or remove
* @param isMultiSelect true if in multiselect mode
* @returns the list of zero or more features
*/
const updateSelectedFeatures = (feature: MapGeoJSONFeature, isMultiSelect: boolean): MapGeoJSONFeature[] => {
if (!feature) return selectedFeatures;
// If the feature is in the list then remove it as it is being deselected
const exists = selectedFeatures.some((item) => item.id === feature.id);
let featureList: MapGeoJSONFeature[] = selectedFeatures;
if (exists) {
featureList = selectedFeatures.filter((item) => item.id !== feature.id);
setShowTooManyTractsAlert(false);
} else if (selectedFeatures.length < MAX_SELECTED_TRACTS) {
// Add the feature to the list if in multi select, otherwise replace the list
// with just this one feature.
featureList = isMultiSelect ?
[...selectedFeatures, feature] :
[feature];
} else {
setShowTooManyTractsAlert(true);
}
setSelectedFeatures(featureList);
if (!inMultiSelectMode) {
// Turn on multi select mode any time we select more than one tract.
setInMultiSelectMode(featureList.length > 1);
}
return featureList;
};
/**
* Selects the provided feature on the map.
* @param feature the feature to select
* @param isMultiSelectKeyDown true if the multi select key is down
*/
const selectFeatureOnMap = (feature: IMapFeature) => {
if (feature) {
// Get the current selected feature's bounding box:
const [minLng, minLat, maxLng, maxLat] = bbox(feature);
const selectFeaturesOnMap = (feature: IMapFeature, isMultiSelectKeyDown: boolean = false) => {
const featuresList = updateSelectedFeatures(feature, isMultiSelectKeyDown || inMultiSelectMode);
if (featuresList.length > 0) {
const [minLng, minLat, maxLng, maxLat] = getFeaturesBbox(featuresList);
// Set the selectedFeature ID
setSelectedFeature(feature);
// Go to the newly selected feature (as long as it's not an Alaska Point)
// Go to area of the selected feature(s)
goToPlace([
[minLng, minLat],
[maxLng, maxLat],
@ -213,14 +280,15 @@ const J40Map = ({location}: IJ40Interface) => {
default:
break;
}
} else if (event.target && (event.target as HTMLElement).nodeName == 'DIV' ) {
} else if (event.target && (event.target as HTMLElement).nodeName == 'DIV') {
// This else clause will fire when the user clicks on the map and will ignore other controls
// such as the search box and buttons.
// @ts-ignore
const feature = event.features && event.features[0];
selectFeatureOnMap(feature);
// @ts-ignore
selectFeaturesOnMap(feature, event.srcEvent.ctrlKey);
}
};
@ -251,8 +319,8 @@ const J40Map = ({location}: IJ40Interface) => {
const newViewPort = new WebMercatorViewport({height: viewport.height!, width: viewport.width!});
const {longitude, latitude, zoom} = newViewPort.fitBounds(
bounds as [[number, number], [number, number]], {
// padding: 200, // removing padding and offset in favor of a zoom offset below
// offset: [0, -100],
// padding: 200, // removing padding and offset in favor of a zoom offset below
// offset: [0, -100],
});
/**
@ -266,8 +334,8 @@ const J40Map = ({location}: IJ40Interface) => {
*/
// eslint-disable-next-line max-len
const featureSelectionZoomLevel = (zoom - 1) < constants.GLOBAL_MIN_ZOOM_FEATURE_BORDER + .1 ?
constants.GLOBAL_MIN_ZOOM_FEATURE_BORDER :
zoom - 1;
constants.GLOBAL_MIN_ZOOM_FEATURE_BORDER :
zoom - 1;
setViewport({
...viewport,
@ -304,7 +372,8 @@ const J40Map = ({location}: IJ40Interface) => {
filter: ['==', constants.GEOID_PROPERTY, selectTractId],
});
if (geoidSearchResults && geoidSearchResults.length > 0) {
selectFeatureOnMap(geoidSearchResults[0]);
// TODO, support searching for a list of tracts
selectFeaturesOnMap(geoidSearchResults[0]);
}
setSelectTractId(undefined);
}
@ -321,6 +390,23 @@ const J40Map = ({location}: IJ40Interface) => {
setGeolocationInProgress(true);
};
/**
* Handler for when there is a change in the multi select side panel.
* @param feature the feature that was added or removed
*/
const onReportDeleteTract = (feature: MapGeoJSONFeature) => {
updateSelectedFeatures(feature, true);
};
/**
* Handler for when the multi select is finished.
*/
const onReportExit = () => {
// Clear everything
setSelectedFeatures([]);
setDetailViewData(undefined);
setInMultiSelectMode(false);
};
return (
<>
@ -355,15 +441,15 @@ const J40Map = ({location}: IJ40Interface) => {
// access token is j40StylesReadToken
mapboxApiAccessToken={
process.env.MAPBOX_STYLES_READ_TOKEN ?
process.env.MAPBOX_STYLES_READ_TOKEN : ''}
process.env.MAPBOX_STYLES_READ_TOKEN : ''}
// ****** Map state props: ******
// http://visgl.github.io/react-map-gl/docs/api-reference/interactive-map#map-state
{...viewport}
mapStyle={
process.env.MAPBOX_STYLES_READ_TOKEN ?
'mapbox://styles/justice40/cl9g30qh7000p15l9cp1ftw16' :
'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json'
'mapbox://styles/justice40/cl9g30qh7000p15l9cp1ftw16' :
'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json'
}
width="100%"
// Ajusting this height with a conditional statement will not render the map on staging.
@ -406,13 +492,12 @@ const J40Map = ({location}: IJ40Interface) => {
}
<MapTractLayers
selectedFeature={selectedFeature}
selectedFeatureId={selectedFeatureId}
selectedFeatures={selectedFeatures}
/>
{/* This is the first overlayed row on the map: Search and Geolocation */}
<div className={styles.mapHeaderRow}>
<MapSearch goToPlace={goToPlace}/>
<MapSearch goToPlace={goToPlace} />
{/* Geolocate Icon */}
<div className={styles.geolocateBox}>
@ -420,8 +505,8 @@ const J40Map = ({location}: IJ40Interface) => {
windowWidth > constants.USWDS_BREAKPOINTS.MOBILE_LG - 1 &&
<div className={
(geolocationInProgress && !isGeolocateLocked) ?
styles.geolocateMessage :
styles.geolocateMessageHide
styles.geolocateMessage :
styles.geolocateMessageHide
}>
{intl.formatMessage(EXPLORE_COPY.MAP.GEOLOC_MSG_LOCATING)}
</div>
@ -432,7 +517,6 @@ const J40Map = ({location}: IJ40Interface) => {
onClick={onClickGeolocate}
trackUserLocation={windowWidth < constants.USWDS_BREAKPOINTS.MOBILE_LG}
showUserHeading={windowWidth < constants.USWDS_BREAKPOINTS.MOBILE_LG}
disabledLabel={intl.formatMessage(EXPLORE_COPY.MAP.GEOLOC_MSG_DISABLED)}
/>
</div>
@ -440,15 +524,15 @@ const J40Map = ({location}: IJ40Interface) => {
{/* This is the second row overlayed on the map, it will add the navigation controls
of the zoom in and zoom out buttons */}
{ windowWidth > constants.USWDS_BREAKPOINTS.MOBILE_LG && <NavigationControl
{windowWidth > constants.USWDS_BREAKPOINTS.MOBILE_LG && <NavigationControl
showCompass={false}
className={styles.navigationControl}
/> }
/>}
{/* This is the third row overlayed on the map, it will show shortcut buttons to
pan/zoom to US territories */}
{ windowWidth > constants.USWDS_BREAKPOINTS.MOBILE_LG &&
<TerritoryFocusControl onClick={onClick}/> }
{windowWidth > constants.USWDS_BREAKPOINTS.MOBILE_LG &&
<TerritoryFocusControl onClick={onClick} />}
{/* Enable fullscreen pop-up behind a feature flag */}
{('fs' in flags && detailViewData && !transitionInProgress) && (
@ -468,18 +552,27 @@ const J40Map = ({location}: IJ40Interface) => {
/>
</Popup>
)}
{'fs' in flags ? <FullscreenControl className={styles.fullscreenControl}/> :'' }
{'fs' in flags ? <FullscreenControl className={styles.fullscreenControl} /> : ''}
</ReactMapGL>
</Grid>
<Grid desktop={{col: 3}}>
<MapInfoPanel
className={styles.mapInfoPanel}
featureProperties={detailViewData?.properties}
selectedFeatureId={selectedFeature?.id}
hash={zoomLatLngHash}
/>
{inMultiSelectMode ?
<CreateReportPanel
className={styles.mapInfoPanel}
featureList={selectedFeatures}
deleteTractHandler={onReportDeleteTract}
exitHandler={onReportExit}
maxNumTracts={MAX_SELECTED_TRACTS}
showTooManyTractsAlert={showTooManyTractsAlert}
/> :
<MapInfoPanel
className={styles.mapInfoPanel}
featureProperties={detailViewData?.properties}
hash={zoomLatLngHash}
/>
}
</Grid>
</>
);

View file

@ -1,4 +1,4 @@
import React, {useMemo} from 'react';
import React from 'react';
import {Source, Layer} from 'react-map-gl';
import {MapGeoJSONFeature} from 'maplibre-gl';
@ -9,8 +9,7 @@ import * as constants from '../../data/constants';
import * as COMMON_COPY from '../../data/copy/common';
interface IMapTractLayers {
selectedFeatureId: string | number,
selectedFeature: MapGeoJSONFeature | undefined,
selectedFeatures: MapGeoJSONFeature[] | undefined,
}
/**
@ -65,10 +64,10 @@ export const featureURLForTilesetName = (tilesetName: string): string => {
* @return {Style}
*/
const MapTractLayers = ({
selectedFeatureId,
selectedFeature,
selectedFeatures,
}: IMapTractLayers) => {
const filter = useMemo(() => ['in', constants.GEOID_PROPERTY, selectedFeatureId], [selectedFeature]);
const selectedFeatureIds = selectedFeatures ? (selectedFeatures.map((feat) => feat.id)) : [''];
const filter = ['in', constants.GEOID_PROPERTY, ...selectedFeatureIds];
return (
<>

View file

@ -5,14 +5,12 @@ import SidePanelInfo from './SidePanelInfo';
interface IMapInfoPanelProps {
className: string,
featureProperties: { [key:string]: string | number } | undefined,
selectedFeatureId: string | number | undefined
hash: string[],
}
const MapInfoPanel = ({
className,
featureProperties,
selectedFeatureId,
hash,
}:IMapInfoPanelProps) => {
return (
@ -22,7 +20,7 @@ const MapInfoPanel = ({
there are two states, namely showing the AreaDetail or SidePanelInfo. When a feature
is selected, show the AreaDetail. When not selected show SidePanelInfo
*/}
{(featureProperties && selectedFeatureId) ?
{(featureProperties) ?
<AreaDetail
properties={featureProperties}
hash={hash}