Source

src/components/Panels/VariablePanel.jsx

import React, { useLayoutEffect, useRef } from 'react'
import { useSelector, useDispatch } from 'react-redux'

import InputLabel from '@mui/material/InputLabel'
import MenuItem from '@mui/material/MenuItem'
import FormLabel from '@mui/material/FormLabel'
import FormControlLabel from '@mui/material/FormControlLabel'
import Select from '@mui/material/Select'
import Radio from '@mui/material/Radio'
import RadioGroup from '@mui/material/RadioGroup'
import Switch from '@mui/material/Switch'
import Slider from '@mui/material/Slider'

import styled from 'styled-components'

import Tooltip from '../Interface/Tooltip'
import { BinsContainer, Gutter } from '..'
import { StyledDropDown } from '..'
import colors from '../../config/colors'
import { findIn } from '../../utils'
import { Button, Grid } from '@mui/material'
import { paramsSelectors, paramsActions } from '../../stores/paramsStore'

const {
    setMapParams,
    setCurrentData,
    setPanelState,
    setMapType,
    toggleDotDensityMode,
    toggleDotDensityRace,
    setVariableMenuWidth,
    changeVariable,
    changeGeography,
    setDotDensityBgOpacity,
    setDateRangeType,
} = paramsActions
const {
    selectPartialMapParam,
    selectCurrentData,
    selectPanelState,
    selectPartialDataParam,
    selectDatasets,
    selectVariableTree,
    selectUrlParamsTree,
} = paramsSelectors

/** STYLES */
const VariablePanelContainer = styled.div`
    flex: 0.0001 1 auto;
    width: auto;
    background-color: ${colors.gray}fa;
    padding: 0;
    box-sizing: border-box;
    font: 'Lato', sans-serif;
    max-height: calc(100vh - 50px);
    color: white;
    z-index: 10;
    display: flex;
    flex-direction: column;
    h1,
    h2,
    h3,
    h4 {
        margin: 0 0 10px 0;
    }
    p {
        margin: 10px 0;
    }
    @media (max-width: 1024px) {
        min-width: 50vw;
    }
    @media (max-width: 600px) {
        width: 100%;
        display: ${(props) => (props.otherPanels ? 'none' : 'initial')};
        position: fixed;
        overflow-y: auto;
        padding-bottom: 20vh;
    }

    &.hidden {
        display: none;
    }
    user-select: none;
`
const NoteContainer = styled.div`
    flex: 1 1 1;
    padding: 0.5em 0 0.5em 1.25em;
    box-sizing: border-box;
    background: ${colors.gray};
    width: calc(100%);
    border-top: 1px solid black;
    a {
        color: ${colors.yellow};
        -webkit-text-decoration: none;
        text-decoration: none;
    }
    p.note {
        font-family: 'Lato', sans-serif;
        font-weight: 300;
        font-size: 90%;
        max-width: 42ch;
    }

    div.poweredByGeoda {
        color: white;
        width: 100%;
        text-align: center;
        @media (max-height: 900px) {
        }
        a {
            color: white;
            margin: 0 auto;
            text-decoration: none;
            letter-spacing: 2px;
            font-size: 75%;
            img {
                width: 23px;
                height: 27px;
                transform: translate(-50%, 40%);
            }
        }
    }
`

const DotDensityControls = styled.div`
    border: 1px solid ${colors.white}77;
    max-width: 20em;
    padding: 0 0.5em 1.5em 0.5em;
    p.help-text {
        text-transform: uppercase;
        font-size: 0.75rem;
        font-weight: bold;
        text-align: center;
    }
    span.MuiSlider-root {
        margin: 1em 1em 0 1em;
        max-width: calc(100% - 2em);
        padding: 0;
        color: ${colors.white};
    }
`

const DateSelectorContainer = styled.div`
    opacity: ${(props) => (props.disabled ? 0.25 : 1)};
    pointer-events: ${(props) => (props.disabled ? 'none' : 'initial')};
`

const TwoUp = styled.div`
    width: 100%;
    .MuiFormControl-root {
        width: auto;
        min-width: 8rem;
        margin-right: 5px;
    }
`

const ControlsContainer = styled.div`
    overflow-y: scroll;
    padding: 20px;
    flex: 1 1 5;

    ::-webkit-scrollbar {
        width: 10px;
    }

    /* Track */
    ::-webkit-scrollbar-track {
        background: #2b2b2b;
    }

    /* Handle */
    ::-webkit-scrollbar-thumb {
        background: url('${process.env.PUBLIC_URL}/icons/grip.png'), #999;
        background-position: center center;
        background-repeat: no-repeat, no-repeat;
        background-size: 50%, 100%;
        transition: 125ms all;
    }

    /* Handle on hover */
    ::-webkit-scrollbar-thumb:hover {
        background: url('${process.env.PUBLIC_URL}/icons/grip.png'), #f9f9f9;
        background-position: center center;
        background-repeat: no-repeat, no-repeat;
        background-size: 50%, 100%;
    }
`

const ListSubheader = styled(MenuItem)`
    font-variant: small-caps;
    font-weight: 800;
`

const storiesButtonStyles = {
    background: colors.teal,
    textTransform: 'none',
    color: colors.white,
    width: 'calc(100% - 1em)',
    fontWeight: 'bold',
    fontSize: '16px',
}

const AcsRaceButton = styled.button`
    background: ${(props) =>
        props.active ? `rgb(${props.bgColor.join(',')})` : colors.darkgray};
    color: ${(props) => (props.active ? colors.black : colors.white)};
    text-align: left;
    border: none;
    outline: none;
    margin: 0.25em;
    padding: 0.5em;
    border-radius: 0.5em;
    cursor: pointer;
`
// END STYLES

const dotDensityAcsGroups = [
    {
        idx: 3,
        name: 'Black or African American',
    },
    {
        idx: 4,
        name: 'Hispanic or Latino',
    },
    {
        idx: 2,
        name: 'Asian',
    },
    {
        idx: 8,
        name: 'White',
    },
    {
        idx: 1,
        name: 'American Indian or Alaska Native',
    },
    {
        idx: 5,
        name: 'Native Hawaiian or Other Pacific Islander',
    },
    {
        idx: 6,
        name: 'Other',
    },
    {
        idx: 7,
        name: 'Two or more',
    },
]

const onlyUnique = (value, index, self) => self.indexOf(value) === index

const DotDensityControlSection = ({ isCustom = false }) => {
    const dispatch = useDispatch()
    const handleDotDensitySlider = (e, newValue) =>
        dispatch(setDotDensityBgOpacity(newValue))
    const dotDensityParams = useSelector(
        selectPartialMapParam('dotDensityParams')
    )
    return (
        <DotDensityControls>
            <p className="help-text">1 Dot = 500 People</p>
            <BinsContainer>
                {!isCustom && (
                    <>
                        <Switch
                            checked={dotDensityParams.colorCOVID}
                            onChange={() => dispatch(toggleDotDensityMode())}
                            name="dot density mode"
                            disabled={isCustom}
                        />
                        <p>
                            {dotDensityParams.colorCOVID
                                ? 'Color by COVID Data'
                                : 'Color by ACS Race / Ethnicity'}
                        </p>
                        <Gutter h={10} />
                        <p className="help-text">
                            Toggle ACS Race / Ethnicity Groups
                        </p>
                    </>
                )}
                <Gutter h={5} />
                {dotDensityAcsGroups.map((group) => (
                    <AcsRaceButton
                        active={dotDensityParams.raceCodes[group.idx]}
                        bgColor={colors.dotDensity[group.idx]}
                        key={group.name + 'dd-button'}
                        onClick={() =>
                            dispatch(toggleDotDensityRace(group.idx))
                        }
                    >
                        {group.name}
                    </AcsRaceButton>
                ))}
            </BinsContainer>
            <Gutter h={20} />
            <p className="help-text">Background Opacity</p>
            <Slider
                value={dotDensityParams.backgroundTransparency}
                min={0}
                step={0.01}
                max={1}
                onChange={handleDotDensitySlider}
            />
        </DotDensityControls>
    )
}

/**
 * Self-contained component to manage paramsSlice and chance variable, data,
 * etc.
 *
 * TODO: This component _should_ be refactored and cleaned up. It's a bit of a
 * mess.
 *
 * @category Components/Map
 * @component
 */
function VariablePanel() {
    const dispatch = useDispatch()

    const variablePanelRef = useRef(null)

    const currentData = useSelector(selectCurrentData)
    const binMode = useSelector(selectPartialMapParam('binMode'))
    const mapType = useSelector(selectPartialMapParam('mapType'))
    const vizType = useSelector(selectPartialMapParam('vizType'))
    const overlay = useSelector(selectPartialMapParam('overlay'))
    const resource = useSelector(selectPartialMapParam('resource'))
    const panelState = useSelector(selectPanelState)
    const variableName = useSelector(selectPartialDataParam('variableName'))
    const nType = useSelector(selectPartialDataParam('nType'))
    const nRange = useSelector(selectPartialDataParam('nRange'))
    const rangeType = useSelector(selectPartialDataParam('rangeType'))
    const datasets = useSelector(selectDatasets)
    const variableTree = useSelector(selectVariableTree)
    const urlParamsTree = useSelector(selectUrlParamsTree)

    // derived state
    const currentPreset = findIn(datasets, 'file', currentData)
    const notTooltipText = (text) => text !== "Tooltip"
    const allGeographies = Object.values(variableTree)
        .flatMap((o) => Object.keys(o))
        .filter(onlyUnique)
        .filter(notTooltipText)
    const allDatasets = Object.entries(variableTree)
        // ignore tooltip values
        .map(([_, geographies]) => {
            let dataObj = {}
            Object.keys(geographies).forEach(key => {
                if (key === "Tooltip") return 
                dataObj[key] = geographies[key]
            })
            return dataObj
        })
        .flatMap((o) => Object.values(o))
        .flatMap((o) => o)
        .filter(onlyUnique)
        
    const isCustom = !['State', 'County'].includes(currentPreset.geography)

    const availableData = currentPreset.geography
        ? allDatasets.filter(
              (dataset) =>
                  variableTree[variableName][currentPreset.geography].indexOf(
                      dataset
                  ) !== -1
          )
        : []
    const dataName = availableData.includes(urlParamsTree[currentData].name)
        ? urlParamsTree[currentData].name
        : availableData[0]

    const isTimeSeriesNonCumulative =
        variableName.indexOf('Testing') !== -1 ||
        variableName.indexOf('Workdays') !== -1

    // manage width offset
    useLayoutEffect(() => {
        dispatch(setVariableMenuWidth(variablePanelRef.current.offsetWidth))
    }, [])

    useLayoutEffect(() => {
        dispatch(setVariableMenuWidth(variablePanelRef.current.offsetWidth))
    }, [panelState.variables])

    // handlers
    const handleMapType = (_event, newValue) => dispatch(setMapType(newValue))
    const handleMapOverlay = (event) => {
        dispatch(
            setMapParams({
                overlay: event.target.value,
            })
        )
    }
    const handleMapResource = (event) => {
        dispatch(
            setMapParams({
                resource: event.target.value,
            })
        )
    }
    const handleVariable = (e) => dispatch(changeVariable(e.target.value))
    const handleGeography = (e) => dispatch(changeGeography(e.target.value))
    const handleVizType = (e) =>
        dispatch(setMapParams({ vizType: e.target.value }))
    const handleDataset = (e) => dispatch(setCurrentData(e.target.value))
    const handleRangeButton = (e) => dispatch(setDateRangeType(e.target.value))
    const handleSwitch = () =>
        dispatch(
            setMapParams({ binMode: binMode === 'dynamic' ? '' : 'dynamic' })
        )

    const handleToggleStories = () => {
        if (panelState.storiesPane) {
            dispatch(setPanelState({ storiesPane: false, lineChart: true }))
            dispatch(setMapParams({ overlay: '' }))
        } else {
            dispatch(setPanelState({ storiesPane: true, lineChart: false }))
            dispatch(setMapParams({ overlay: 'stories' }))
        }
    }

    return (
        <VariablePanelContainer
            className={panelState.variables ? '' : 'hidden'}
            otherPanels={panelState.info}
            id="variablePanel"
            ref={variablePanelRef}
        >
            {panelState.variables && (
                <ControlsContainer>
                    <Grid item xs={12} md={12} lg={6}>
                        <h2>
                            Data Sources &amp;
                            <br /> Map Variables
                        </h2>
                    </Grid>
                    <Grid item xs={12} md={12} lg={6}>
                        <Button
                            variant="contained"
                            sx={storiesButtonStyles}
                            onClick={handleToggleStories}
                        >
                            <Switch
                                checked={panelState.storiesPane}
                                onChange={handleToggleStories}
                                name="stories mode switch"
                                label="Atlas Stories"
                            />
                            Atlas Stories
                        </Button>
                    </Grid>
                    <Gutter h={20} />
                    <StyledDropDown id="variableSelect">
                        <InputLabel htmlFor="variableSelect">
                            Variable
                        </InputLabel>
                        <Select
                            value={variableName}
                            onChange={handleVariable}
                            MenuProps={{ id: 'variableMenu' }}
                        >
                            {Object.keys(variableTree).map((variable) => {
                                if (variable.split(':')[0] === 'HEADER') {
                                    return (
                                        <ListSubheader
                                            key={variable.split(':')[1]}
                                            disabled
                                        >
                                            {variable.split(':')[1]}
                                        </ListSubheader>
                                    )
                                } else {
                                    return (
                                        <MenuItem
                                            value={variable}
                                            key={variable}
                                        >
                                            {variable}
                                            {!!variableTree[variable]?.Tooltip && <Tooltip id={variableTree[variable].Tooltip} />}
                                        </MenuItem>
                                    )
                                }
                            })}
                        </Select>
                    </StyledDropDown>
                    <Gutter h={35} />
                    <DateSelectorContainer
                        disabled={nType === 'characteristic'}
                    >
                        <StyledDropDown id="dateSelector">
                            <InputLabel htmlFor="date-select">
                                Date Range
                            </InputLabel>
                            <Select
                                id="date-select"
                                value={
                                    nRange === null ||
                                    rangeType === 'custom' ||
                                    isTimeSeriesNonCumulative
                                        ? 'x'
                                        : nRange
                                }
                                onChange={handleRangeButton}
                                displayEmpty
                                inputProps={{ 'aria-label': 'Without label' }}
                            >
                                <MenuItem
                                    value="x"
                                    disabled
                                    style={{ display: 'none' }}
                                >
                                    {rangeType === 'custom' && (
                                        <span>Custom Range</span>
                                    )}
                                    {nRange === null &&
                                        !isTimeSeriesNonCumulative && (
                                            <span>Cumulative</span>
                                        )}
                                    {variableName.indexOf('Testing') !== -1 && (
                                        <span>7-Day Average</span>
                                    )}
                                    {variableName.indexOf('Workdays') !==
                                        -1 && <span>Daily Average</span>}
                                </MenuItem>
                                <MenuItem
                                    value={null}
                                    key={'cumulative'}
                                    disabled={isTimeSeriesNonCumulative}
                                >
                                    Cumulative
                                </MenuItem>
                                <MenuItem
                                    value={1}
                                    key={'daily'}
                                    disabled={isTimeSeriesNonCumulative}
                                >
                                    Daily New
                                </MenuItem>
                                <MenuItem
                                    value={7}
                                    key={'7-day-ave'}
                                    disabled={isTimeSeriesNonCumulative}
                                >
                                    7-Day Average
                                </MenuItem>
                                <MenuItem
                                    value={'custom'}
                                    key={'customRange'}
                                    disabled={isTimeSeriesNonCumulative}
                                >
                                    Custom Range
                                </MenuItem>
                            </Select>
                        </StyledDropDown>
                        <br />
                        <BinsContainer
                            id="binModeSwitch"
                            disabled={
                                isTimeSeriesNonCumulative || mapType === 'lisa'
                            }
                        >
                            <Switch
                                checked={binMode === 'dynamic'}
                                onChange={handleSwitch}
                                name="bin chart switch"
                            />
                            <p>
                                {binMode === 'dynamic'
                                    ? 'Dynamic'
                                    : 'Fixed Bins'}
                                <Tooltip id="BinModes" />
                            </p>
                        </BinsContainer>
                    </DateSelectorContainer>
                    <Gutter h={35} />

                    <StyledDropDown
                        id="geographySelect"
                        style={{ marginRight: '20px' }}
                    >
                        <InputLabel htmlFor="geographySelect">
                            Geography
                        </InputLabel>
                        <Select
                            value={currentPreset.geography}
                            onChange={handleGeography}
                        >
                            {allGeographies.map((geography) => (
                                <MenuItem
                                    value={geography}
                                    key={geography}
                                    disabled={
                                        !variableTree[
                                            variableName
                                        ].hasOwnProperty(geography)
                                    }
                                >
                                    {geography}
                                </MenuItem>
                            ))}
                        </Select>
                    </StyledDropDown>
                    <StyledDropDown id="datasetSelect">
                        <InputLabel htmlFor="datasetSelect">
                            Data Source
                        </InputLabel>
                        <Select value={dataName} onChange={handleDataset}>
                            {allDatasets.map((dataset) => (
                                <MenuItem
                                    value={dataset}
                                    key={dataset}
                                    disabled={
                                        variableTree[variableName][
                                            currentPreset.geography
                                        ] === undefined ||
                                        variableTree[variableName][
                                            currentPreset.geography
                                        ].indexOf(dataset) === -1
                                    }
                                >
                                    {dataset}
                                </MenuItem>
                            ))}
                        </Select>
                    </StyledDropDown>
                    <Gutter h={35} />
                    <StyledDropDown component="Radio" id="mapType">
                        <FormLabel component="legend">Map Type</FormLabel>
                        <RadioGroup
                            aria-label="maptype"
                            name="maptype1"
                            onChange={handleMapType}
                            value={mapType}
                            className="radioContainer"
                        >
                            <FormControlLabel
                                value="natural_breaks"
                                key="natural_breaks"
                                control={<Radio />}
                                label="Natural Breaks"
                            />
                            <Tooltip id="NaturalBreaks" />
                            <br />
                            <FormControlLabel
                                value="hinge15_breaks"
                                key="hinge15_breaks"
                                control={<Radio />}
                                label="Box Map"
                            />
                            <Tooltip id="BoxMap" />
                            <br />
                            <FormControlLabel
                                value="lisa"
                                key="lisa"
                                control={<Radio />}
                                label="Hotspot"
                            />
                            <Tooltip id="Hotspot" />
                            <br />
                        </RadioGroup>
                    </StyledDropDown>
                    <Gutter h={15} />
                    <StyledDropDown style={{ minWidth: '100%' }}>
                        <InputLabel htmlFor="viz-type-select">
                            Visualization Type
                        </InputLabel>
                        <Select
                            id="viz-type-select"
                            value={vizType}
                            onChange={handleVizType}
                        >
                            <MenuItem value={'2D'} key={'2D'}>
                                2D
                            </MenuItem>
                            <MenuItem value={'3D'} key={'3D'}>
                                3D
                            </MenuItem>
                            <MenuItem value={'dotDensity'} key={'dotDensity'}>
                                Dasymetric (Dot Density)
                            </MenuItem>
                            <MenuItem value={'cartogram'} key={'cartogram'}>
                                Cartogram
                            </MenuItem>
                        </Select>
                    </StyledDropDown>
                    {vizType === 'dotDensity' && (
                        <DotDensityControlSection isCustom={isCustom} />
                    )}
                    <Gutter h={20} />
                    <TwoUp id="overlaysResources">
                        <StyledDropDown style={{ minWidth: '100%' }}>
                            <InputLabel htmlFor="overlay-select">
                                Overlay
                            </InputLabel>
                            <Select
                                id="overlay-select"
                                value={overlay}
                                onChange={handleMapOverlay}
                            >
                                <MenuItem value="" key={'None'}>
                                    None
                                </MenuItem>
                                <MenuItem value={'stories'} key={'stories'}>
                                    Stories
                                    <Tooltip id="Stories" />
                                </MenuItem>
                                <MenuItem
                                    value={'native_american_reservations'}
                                    key={'native_american_reservations'}
                                >
                                    Native American Reservations
                                </MenuItem>
                                <MenuItem
                                    value={'segregated_cities'}
                                    key={'segregated_cities'}
                                >
                                    Hypersegregated Cities
                                    <Tooltip id="Hypersegregated" />
                                </MenuItem>
                                <MenuItem value={'blackbelt'} key={'blackbelt'}>
                                    Black Belt Counties
                                    <Tooltip id="BlackBelt" />
                                </MenuItem>
                                <MenuItem
                                    value={'uscongress-districts'}
                                    key={'uscongress-districts'}
                                >
                                    US Congressional Districts (2018){' '}
                                    <Tooltip id="USCongress" />
                                </MenuItem>
                                <MenuItem
                                    value={'clicB'}
                                    key={'clicB'}
                                >
                                    Black or African American CLICs
                                    <Tooltip id="CLICs" />
                                </MenuItem>
                                <MenuItem
                                    value={'clicH'}
                                    key={'clicH'}
                                >
                                    Hispanic or Latinx CLICs
                                    <Tooltip id="CLICs" />
                                </MenuItem>
                                <MenuItem
                                    value={'clicW'}
                                    key={'clicW'}
                                >
                                    White CLICs
                                    <Tooltip id="CLICs" />
                                </MenuItem>
                                {/* <MenuItem value={'mobility-county'} key={'mobility-county'}>Mobility Flows (County) WARNING BIG DATA</MenuItem> */}
                            </Select>
                        </StyledDropDown>
                        <Gutter h={20} />
                        <StyledDropDown style={{ minWidth: '100%' }}>
                            <InputLabel htmlFor="resource-select">
                                Resource
                            </InputLabel>
                            <Select
                                id="resource-select"
                                value={resource}
                                onChange={handleMapResource}
                            >
                                <MenuItem value="" key="None">
                                    None
                                </MenuItem>
                                <MenuItem
                                    value={'clinics_hospitals'}
                                    key={'variable1'}
                                >
                                    Clinics and Hospitals
                                    <Tooltip id="ClinicsAndHospitals" />
                                </MenuItem>
                                <MenuItem value={'clinics'} key={'variable2'}>
                                    Clinics
                                    <Tooltip id="Clinics" />
                                </MenuItem>
                                <MenuItem value={'hospitals'} key={'variable3'}>
                                    Hospitals
                                    <Tooltip id="Hospitals" />
                                </MenuItem>
                                <MenuItem
                                    value={'vaccinationSites'}
                                    key={'variable4'}
                                >
                                    Federal Vaccination Sites (as of April, 2023)
                                    <Tooltip id="vaccinationSites" />
                                </MenuItem>
                            </Select>
                        </StyledDropDown>
                    </TwoUp>
                </ControlsContainer>
            )}

            {panelState.variables && (
                <NoteContainer>
                    {/* <h3>Help us improve the Atlas!</h3>
          <p>
            <a href="https://docs.google.com/forms/d/e/1FAIpQLSf0KdYeVyvwnz0RLnZijY3kdyFe1SwXukPc--a1HFPE1NRxyw/viewform?usp=sf_link" target="_blank" rel="noopener noreferrer">Take the Atlas v2 survey here </a>
            or share your thoughts at <a href="mailto:contact@theuscovidatlas.org" target="_blank" rel="noopener noreferrer">contact@theuscovidatlas.org.</a>
          </p>
          <hr></hr> */}
                    <p className="note">
                        Data is updated with freshest available data at 3pm CST
                        daily, at minimum. In case of data discrepancy, local
                        health departments are considered most accurate as per
                        CDC recommendations. More information on{' '}
                        <a href="data">data</a>, <a href="methods">methods</a>,
                        and <a href="FAQ">FAQ</a> at main site.
                    </p>
                    <div className="poweredByGeoda">
                        <a
                            href="https://geodacenter.github.io"
                            target="_blank"
                            rel="noopener noreferrer"
                        >
                            <img
                                src={`${process.env.PUBLIC_URL}/assets/img/geoda-logo.png`}
                                alt="Geoda Logo"
                            />
                            POWERED BY GEODA
                        </a>
                    </div>
                </NoteContainer>
            )}
        </VariablePanelContainer>
    )
}

export default React.memo(VariablePanel)