Source

src/components/Map/Legend.jsx

import React from 'react'
import { Stack } from '@mui/material'
import styled from 'styled-components'
import { useDispatch, useSelector } from 'react-redux'
import { BinsList, Gutter, Tooltip, Icon } from '..'
import colors from '../../config/colors'
import { paramsSelectors, paramsActions } from '../../stores/paramsStore'
const { selectColorFilter } = paramsSelectors
const { setColorFilter } = paramsActions

const BottomPanel = styled.div`
  position: fixed;
  bottom: 0;
  left: 50%;
  background: ${colors.gray};
  transform: translateX(-50%);
  width: 38vw;
  max-width: 500px;
  box-sizing: border-box;
  padding: 0 1em 0.5em 1em;
  margin: 0;
  border: 1px solid ${colors.black};
  border-bottom: none;
  transition: 250ms all;
  color: white;
  hr {
    opacity: 0.5;
  }
  @media (max-width: 1024px) {
    width: 50vw;
    div {
      padding-bottom: 5px;
    }
    #binModeSwitch {
      position: absolute !important;
      right: 10px !important;
      top: 10px !important;
    }
    #dateRangeSelector {
      position: absolute !important;
      left: 66% !important;
      transform: translateX(-50%) !important;
      top: 10px !important;
    }
  }

  @media (max-width: 768px) {
    width: 100%;
    max-width: 100%;
    padding: 0;
    left: 0;
    transform: none;
  }
  @media (max-width: 750px) and (orientation: landscape) {
    // bottom all the way down for landscape phone
  }
  user-select: none;
`

const LegendContainer = styled.div`
  width: 100%;
  padding: 5px 0 0 0;
  box-sizing: border-box;
  div.MuiGrid-item {
    padding: 0 5px;
  }
`

const IconContainer = styled.div`
  padding: 5px 10px;
  span.icons-title {
    margin-right: 10px;
    font-weight: bold;
  }
  img {
    width: 20px;
    height: 20px;
    transform: translateY(4px);
    padding: 2px;
  }
  span.icons-text {
    margin: 0 25px 0 5px;
  }
`

const LegendTitle = styled.h3`
  text-align: center;
  font-family: 'Lato', sans-serif;
  font-weight: bold;
  padding: 0;
  margin: 0;
`

const BinLabels = styled.div`
  width: 100%;
  display: flex;
  margin-top: 0px;
  box-sizing: border-box;
  padding: 0
    ${(props) => (props.binLength > 6 ? 100 / props.binLength / 2 - 2 : 0)}%;
  .bin {
    height: 10px;
    display: inline;
    border: 0;
    margin: 0;
    flex: 2;
    font-size: 10px;
    text-align: center;
    background: none;
    text-align: center;
  }
  .bin:nth-of-type(1) {
    transform: ${(props) => (props.firstBinZero ? 'translateX(-45%)' : 'none')};
  }
  .tooltipText {
    margin-top: -5px;
    padding-bottom: 25px;
  }
`
const BinBars = styled.div`
  width: 100%;
  display: flex;
  margin-top: 3px;
  box-sizing: border-box;
  .bin {
    height: 45px;
    display: inline;
    flex: 1;
    border: 0;
    padding: 20px 0;
    margin: -20px 0;
    background: none;
    transition: 125ms padding, 125ms margin;
    &.active {
      padding-top: 10px;
      span {
        box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.7);
      }
    }
    span {
      width: 100%;
      height: 100%;
      display: block;
    }
  }
  .bin:nth-of-type(1) {
    transform: ${(props) => (props.firstBinZero ? 'scaleX(0.35)' : 'none')};
  }
`

const DataNote = styled.p`
  margin-top: 1.5rem;
  text-align: center;
  svg {
    height: 20px;
    width: 20px;
    display: inline-block;
    transform: translateY(5px);
    margin-right: 5px;
    fill: ${colors.yellow};
  }
`

const ZERO_COLOR = [240, 240, 240]

/**
 * Inner renderer for Legend
 *
 * @category Components/Map
 * @param {Object} props
 * @param {string[]} props.currentBins - String text for each break point
 * @param {number[][]} props.colorScale - Color values for bins in [r,g,b]
 *   format or [r,g,b,a] (0-255 scale)
 * @param {function} props.handleHover - Function for handling hover events on
 *   color bin buttons
 * @param {number[]} props.colorFilter - Active map color filter, if using
 * @param {boolean} props.shouldSeparateZero - If using quantitiatve data that
 *   separates zero, shows zero slightly differently
 * @component
 */
export const LegendInner = ({
  currentBins,
  colorScale,
  handleHover = () => {},
  colorFilter,
  shouldSeparateZero,
}) => (
  <span>
    <BinBars firstBinZero={shouldSeparateZero}>
      {shouldSeparateZero && (
        <button
          onMouseEnter={() => handleHover(ZERO_COLOR)}
          onMouseLeave={() => handleHover(null)}
          onFocus={() => handleHover(ZERO_COLOR)}
          onBlur={() => handleHover(null)}
          className={`bin color ${colorFilter === ZERO_COLOR && 'active'}`}
          key={`${ZERO_COLOR[0]}${ZERO_COLOR[1]}`}
        >
          <span
            style={{
              backgroundColor: `rgb(${ZERO_COLOR[0]},${ZERO_COLOR[1]},${ZERO_COLOR[2]})`,
            }}
          ></span>
        </button>
      )}
      {colorScale.map((color) => (
        <button
          onMouseEnter={() => handleHover(color)}
          onMouseLeave={() => handleHover(null)}
          onFocus={() => handleHover(color)}
          onBlur={() => handleHover(null)}
          className={`bin color ${colorFilter === color && 'active'}`}
          key={`${color[0]}${color[1]}`}
        >
          <span
            style={{
              backgroundColor: `rgb(${color[0]},${color[1]},${color[2]})`,
            }}
          ></span>
        </button>
      ))}
    </BinBars>
    <BinLabels
      firstBinZero={shouldSeparateZero}
      binLength={currentBins?.length}
    >
      {shouldSeparateZero && <div className="bin firstBin">0</div>}
      {currentBins !== undefined && <BinsList data={currentBins} />}
    </BinLabels>
  </span>
)

/**
 * Legend for the map section of the Atlas. Positioned on the bottom of the
 * screen
 *
 * @category Components/Map
 * @example
 *   function Example() {
 *     return (
 *       <Legend
 *         variableName="Population"
 *         colorScale={[
 *           [0, 0, 0],
 *           [120, 120, 120],
 *           [255, 255, 255],
 *         ]}
 *         bins={{ bins: [500, 1500, 2500] }}
 *         resource={'cinics'}
 *         note={'This is a note'}
 *         shouldSeparateZero={true}
 *       />
 *     )
 *   }
 *
 * @param {object} props
 * @param {string} props.variableName - Text for the legend title displaying
 *   variable name
 * @param {number[][]} props.colorScale - Color values for bins in [r,g,b]
 *   format or [r,g,b,a] (0-255 scale)
 * @param {object} props.bins - String text for each break point
 * @param {string[]} props.bins.bins - String text for each break point
 * @param {string[]} props.resource - Icons for resource layers, like hospitals
 *   and clinics
 * @param {string} props.note - For special cases or data issues,
 * @param {boolean} props.shouldSeparateZero - If using quantitiatve data that
 *   separates zero, shows zero slightly differently
 * @component
 */
const Legend = ({
  variableName,
  colorScale,
  bins,
  resource,
  note,
  shouldSeparateZero,
}) => {
  const dispatch = useDispatch()
  const colorFilter = useSelector(selectColorFilter)
  const handleHover = (color) => {
    dispatch(setColorFilter(color))
  }
  const { bins: currentBins } = bins

  return (
    <BottomPanel id="bottomPanel">
      <LegendContainer>
        <Stack direction="column" spacing={1} id="legend-bins-container">
          <LegendTitle>{variableName}</LegendTitle>
          {colorScale !== undefined && (
            <LegendInner
              {...{
                currentBins,
                colorScale,
                handleHover,
                colorFilter,
                shouldSeparateZero,
              }}
            />
          )}
          {resource && (
            <>
              <Gutter h={20} />
              <IconContainer>
                <span className="icons-title">Icons:</span>

                {resource.includes('clinics') && (
                  <>
                    <img
                      src={`${process.env.PUBLIC_URL}/assets/img/clinic_icon.png`}
                      alt=""
                    />
                    <span className="icons-text">Clinics</span>
                  </>
                )}
                {resource.includes('hospitals') && (
                  <>
                    <img
                      src={`${process.env.PUBLIC_URL}/assets/img/hospital_icon.png`}
                      alt=""
                    />
                    <span className="icons-text">Hospital</span>
                  </>
                )}
                {resource.includes('vaccination') && (
                  <>
                    <img
                      src={`${process.env.PUBLIC_URL}/assets/img/federal_site.png`}
                      alt=""
                    />
                    <span className="icons-text">
                      Vaccine Center
                      <Tooltip id="vaccineCenter" />
                    </span>
                  </>
                )}
                {resource.includes('vaccination') && (
                  <>
                    <img
                      src={`${process.env.PUBLIC_URL}/assets/img/participating_clinic.png`}
                      alt=""
                    />
                    <span className="icons-text">
                      Clinic
                      <Tooltip id="vaccineClinic" />
                    </span>
                  </>
                )}
                {resource.includes('vaccination') && (
                  <>
                    <img
                      src={`${process.env.PUBLIC_URL}/assets/img/invited_clinic.png`}
                      alt=""
                    />
                    <span className="icons-text">
                      Invited Clinic
                      <Tooltip id="vaccineClinicInvited" />
                    </span>
                  </>
                )}
              </IconContainer>
            </>
          )}
          {note && (
            <DataNote>
              <Icon symbol="alert" />
              {note}
            </DataNote>
          )}
        </Stack>
      </LegendContainer>
    </BottomPanel>
  )
}

export default React.memo(Legend)