diff --git a/client/src/components/CreateReportPanel/CreateReportPanel.module.scss b/client/src/components/CreateReportPanel/CreateReportPanel.module.scss new file mode 100644 index 00000000..8e9f222c --- /dev/null +++ b/client/src/components/CreateReportPanel/CreateReportPanel.module.scss @@ -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; +} \ No newline at end of file diff --git a/client/src/components/CreateReportPanel/CreateReportPanel.module.scss.d.ts b/client/src/components/CreateReportPanel/CreateReportPanel.module.scss.d.ts new file mode 100644 index 00000000..13252df7 --- /dev/null +++ b/client/src/components/CreateReportPanel/CreateReportPanel.module.scss.d.ts @@ -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; diff --git a/client/src/components/CreateReportPanel/CreateReportPanel.tsx b/client/src/components/CreateReportPanel/CreateReportPanel.tsx new file mode 100644 index 00000000..f1ecfafa --- /dev/null +++ b/client/src/components/CreateReportPanel/CreateReportPanel.tsx @@ -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(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 ( +
+
+

Create Report

+ {showTooManyTractsAlert ? + + You can only select up to {maxNumTracts} tracts for a report. + : + + Select up to {maxNumTracts} tracts in the map + + } +

+ {featureList.length} tract{featureList.length === 1 ? '' : 's'} selected +

+
+ {featureList.map((item, index) => ( + + + {item.id}, {item.properties[constants.STATE_NAME]} + + + + + + ))} +
+
+ +
+
+ +
+
+
+ ); +}; + +export default CreateReportPanel; diff --git a/client/src/components/CreateReportPanel/index.tsx b/client/src/components/CreateReportPanel/index.tsx new file mode 100644 index 00000000..20ca1647 --- /dev/null +++ b/client/src/components/CreateReportPanel/index.tsx @@ -0,0 +1,3 @@ +import CreateReportPanel from './CreateReportPanel'; + +export default CreateReportPanel; diff --git a/client/src/components/J40Map.tsx b/client/src/components/J40Map.tsx index 6dce1f21..bc3ae0a0 100644 --- a/client/src/components/J40Map.tsx +++ b/client/src/components/J40Map.tsx @@ -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(); + const [selectedFeatures, setSelectedFeatures] = useState([]); const [detailViewData, setDetailViewData] = useState(); const [transitionInProgress, setTransitionInProgress] = useState(false); const [geolocationInProgress, setGeolocationInProgress] = useState(false); const [isMobileMapState, setIsMobileMapState] = useState(false); + const [inMultiSelectMode, setInMultiSelectMode] = useState(false); + const [showTooManyTractsAlert, setShowTooManyTractsAlert] = useState(false); const [selectTractId, setSelectTractId] = useState(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) => { } {/* This is the first overlayed row on the map: Search and Geolocation */}
- + {/* Geolocate Icon */}
@@ -420,8 +505,8 @@ const J40Map = ({location}: IJ40Interface) => { windowWidth > constants.USWDS_BREAKPOINTS.MOBILE_LG - 1 &&
{intl.formatMessage(EXPLORE_COPY.MAP.GEOLOC_MSG_LOCATING)}
@@ -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)} />
@@ -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 && constants.USWDS_BREAKPOINTS.MOBILE_LG && } + />} {/* 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 && - } + {windowWidth > constants.USWDS_BREAKPOINTS.MOBILE_LG && + } {/* Enable fullscreen pop-up behind a feature flag */} {('fs' in flags && detailViewData && !transitionInProgress) && ( @@ -468,18 +552,27 @@ const J40Map = ({location}: IJ40Interface) => { /> )} - {'fs' in flags ? :'' } + {'fs' in flags ? : ''} - + {inMultiSelectMode ? + : + + } ); diff --git a/client/src/components/MapTractLayers/MapTractLayers.tsx b/client/src/components/MapTractLayers/MapTractLayers.tsx index f7e2f268..925e99a1 100644 --- a/client/src/components/MapTractLayers/MapTractLayers.tsx +++ b/client/src/components/MapTractLayers/MapTractLayers.tsx @@ -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 ( <> diff --git a/client/src/components/mapInfoPanel.tsx b/client/src/components/mapInfoPanel.tsx index 60e9c121..aae23832 100644 --- a/client/src/components/mapInfoPanel.tsx +++ b/client/src/components/mapInfoPanel.tsx @@ -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) ?