streetmix/streetmix

View on GitHub
assets/scripts/info_bubble/InfoBubble.jsx

Summary

Maintainability
C
1 day
Test Coverage
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { IntlProvider, FormattedMessage } from 'react-intl'
import debounce from 'just-debounce-it'
import { registerKeypress } from '../app/keypress'
import { BUILDINGS } from '../segments/buildings'
import {
  BUILDING_LEFT_POSITION,
  BUILDING_RIGHT_POSITION
} from '../segments/constants'
import { getSegmentInfo, getSegmentVariantInfo } from '../segments/info'
import { getSegmentEl } from '../segments/view'
import { loseAnyFocus } from '../util/focus'
import { getElAbsolutePos } from '../util/helpers'
import {
  setInfoBubbleMouseInside,
  updateHoverPolygon
} from '../store/slices/infoBubble'
import EditableLabel from './EditableLabel'
import Triangle from './Triangle'
import RemoveButton from './RemoveButton'
import Variants from './Variants'
import WidthControl from './WidthControl'
import BuildingHeightControl from './BuildingHeightControl'
import Warnings from './Warnings'
import Description from './Description'
import { infoBubble } from './info_bubble'
import {
  INFO_BUBBLE_TYPE_SEGMENT,
  INFO_BUBBLE_TYPE_LEFT_BUILDING,
  INFO_BUBBLE_TYPE_RIGHT_BUILDING
} from './constants'
import './InfoBubble.scss'

const INFO_BUBBLE_MARGIN_BUBBLE = 20
const INFO_BUBBLE_MARGIN_MOUSE = 10

const DESCRIPTION_HOVER_POLYGON_MARGIN = 200

const MIN_TOP_MARGIN_FROM_VIEWPORT = 150

// The menu bar has extended to 30px margin, but we can't change
// this because a lesser number will currently cause the description
// panel to potentially be offscreen.
const MIN_SIDE_MARGIN_FROM_VIEWPORT = 50

const HOVER_POLYGON_DEBOUNCE = 50

export class InfoBubble extends React.Component {
  static propTypes = {
    // Provided by Redux connect mapStateToProps
    visible: PropTypes.bool.isRequired,
    descriptionVisible: PropTypes.bool,
    mouseInside: PropTypes.bool,
    position: PropTypes.oneOfType([
      PropTypes.number,
      PropTypes.oneOf([BUILDING_LEFT_POSITION, BUILDING_RIGHT_POSITION])
    ]),
    street: PropTypes.object,
    locale: PropTypes.object,

    // Provided by Redux connect mapDispatchToProps
    setInfoBubbleMouseInside: PropTypes.func,
    updateHoverPolygon: PropTypes.func
  }

  static defaultProps = {
    visible: false
  }

  constructor (props) {
    super(props)

    // Stores a ref to the element
    this.el = React.createRef()
    this.segmentEl = getSegmentEl(props.position)
    this.streetOuterEl = null

    this.state = {
      type: null,
      highlightTriangle: false
    }

    // Register keyboard shortcuts to hide info bubble
    // Only hide if it's currently visible, and if the
    // description is NOT visible. (If the description
    // is visible, the escape key should hide that first.)
    registerKeypress(
      'esc',
      {
        condition: () => this.props.visible && !this.props.descriptionVisible
      },
      () => {
        infoBubble.hide()
        infoBubble.hideSegment(false)
      }
    )
  }

  /**
   * Sets state when the infobubble is pointing at a building
   *
   * @param {Object} nextProps
   * @param {Object} prevState
   */
  static getDerivedStateFromProps (nextProps, prevState) {
    if (nextProps.position === BUILDING_LEFT_POSITION) {
      return {
        type: INFO_BUBBLE_TYPE_LEFT_BUILDING
      }
    } else if (nextProps.position === BUILDING_RIGHT_POSITION) {
      return {
        type: INFO_BUBBLE_TYPE_RIGHT_BUILDING
      }
    } else if (Number.isFinite(nextProps.position)) {
      return {
        type: INFO_BUBBLE_TYPE_SEGMENT
      }
    } else {
      return {
        type: null
      }
    }
  }

  componentDidMount () {
    // This listener hides the info bubble when the mouse leaves the
    // document area. Do not normalize it to a pointerleave event
    // because it doesn't make sense for other pointer types
    document.addEventListener('mouseleave', this.hide)

    // Cache reference to this element
    this.streetOuterEl = document.querySelector('#street-section-outer')
  }

  getSnapshotBeforeUpdate (prevProps, prevState) {
    const wasBuilding = prevState.type !== INFO_BUBBLE_TYPE_SEGMENT

    if (!this.el || !this.el.current) return null

    if (!wasBuilding && this.props.position === BUILDING_RIGHT_POSITION) {
      return window.innerWidth - MIN_SIDE_MARGIN_FROM_VIEWPORT
    }
    return null
  }

  componentDidUpdate (prevProps, prevState, snapshot) {
    this.segmentEl = getSegmentEl(this.props.position)
    this.setInfoBubblePosition()
    this.updateBubbleDimensions(snapshot)

    // Add or remove event listener based on whether infobubble was shown or hidden
    if (prevProps.visible === false && this.props.visible === true) {
      document.body.addEventListener('mousemove', this.handleBodyMouseMove)
    } else if (prevProps.visible === true && this.props.visible === false) {
      document.body.removeEventListener('mousemove', this.handleBodyMouseMove)
    }

    // This appears to be needed to prevent a flicker during mouseover of the infobubble.
    // However because this affects props, it triggers a secondary render() in React and
    // incurs a small performance hit.
    // TODO: can we optimize this away without introducing the flicker?
    this.updateHoverPolygon(
      infoBubble.considerMouseX,
      infoBubble.considerMouseY
    )
  }

  componentWillUnmount () {
    // Clean up event listener
    document.removeEventListener('mouseleave', this.hide)
  }

  componentDidCatch (error) {
    console.error(error)
  }

  hide = () => {
    infoBubble.hide()
  }

  // TODO: verify this continues to work with pointer / touch taps
  handleMouseEnter = (event) => {
    this.props.setInfoBubbleMouseInside(true)

    this.updateHoverPolygon()
  }

  handleMouseLeave = (event) => {
    this.props.setInfoBubbleMouseInside(false)

    // Returns focus to body when pointer leaves the info bubble area
    // so that keyboard commands respond to pointer position rather than
    // any focused buttons/inputs
    loseAnyFocus()
  }

  handleBodyMouseMove = (event) => {
    const mouseX = event.pageX
    const mouseY = event.pageY

    if (this.props.visible) {
      if (!infoBubble._withinHoverPolygon(mouseX, mouseY)) {
        infoBubble.show(false)
      }
    }

    this.debouncedUpdateHoverPolygon(mouseX, mouseY)
  }

  handleDescriptionOver = (event) => {
    this.setState({ highlightTriangle: true })
  }

  handleDescriptionOut = (event) => {
    this.setState({ highlightTriangle: false })
  }

  updateHoverPolygon = (mouseX, mouseY) => {
    const hoverPolygon = this.createHoverPolygon(mouseX, mouseY)
    this.props.updateHoverPolygon(hoverPolygon)
  }

  debouncedUpdateHoverPolygon = debounce(
    this.updateHoverPolygon,
    HOVER_POLYGON_DEBOUNCE
  )

  // TODO: make this a pure(r) function
  createHoverPolygon = (mouseX, mouseY) => {
    // `hoverPolygon` is an array of points as [x, y] values. Values should
    // draw a shape counter-clockwise. The final value must match the first
    // value in order to create an enclosed polygon.
    let hoverPolygon = []

    if (!this.props.visible) {
      return hoverPolygon
    }

    // Bail if any reference to an element no longer exists
    if (!this.el || !this.el.current || !this.segmentEl) return

    const bubbleWidth = this.el.current.offsetWidth
    const bubbleHeight = this.el.current.offsetHeight
    const bubbleX = Number.parseInt(this.el.current.style.left)
    const bubbleY = Number.parseInt(this.el.current.style.top)

    if (this.props.mouseInside && !this.props.descriptionVisible) {
      const pos = getElAbsolutePos(this.segmentEl)

      // Left X position of segment element
      const segmentLeftX =
        pos[0] - document.querySelector('#street-section-outer').scrollLeft
      // Right X position of segment element
      const segmentRightX = segmentLeftX + this.segmentEl.offsetWidth
      // Left X position of segment element with margin (edge of the hover polygon)
      const hitboxLeftX = segmentLeftX - INFO_BUBBLE_MARGIN_BUBBLE
      // Right X position of segment element with margin (edge of the hover polygon)
      const hitboxRightX = segmentRightX + INFO_BUBBLE_MARGIN_BUBBLE

      // Top Y position of segment element
      const segmentTopY = pos[1]
      // Bottom Y position of segment element
      const segmentBottomY = segmentTopY + this.segmentEl.offsetHeight
      // Bottom Y position of segment element with margin
      const hitboxBottomY = segmentBottomY + INFO_BUBBLE_MARGIN_BUBBLE

      hoverPolygon = [
        // Top left of infobubble
        [
          bubbleX - INFO_BUBBLE_MARGIN_BUBBLE,
          bubbleY - INFO_BUBBLE_MARGIN_BUBBLE
        ],
        // Bottom left of infobubble
        [
          bubbleX - INFO_BUBBLE_MARGIN_BUBBLE,
          bubbleY + bubbleHeight + INFO_BUBBLE_MARGIN_BUBBLE
        ],
        // Diagonal line to hit edge of segment
        [hitboxLeftX, bubbleY + bubbleHeight + INFO_BUBBLE_MARGIN_BUBBLE + 120],
        // Bottom left of segment
        [hitboxLeftX, hitboxBottomY],
        // Bottom right of segment
        [hitboxRightX, hitboxBottomY],
        // Point at which to begin diagonal line to infobubble
        [
          hitboxRightX,
          bubbleY + bubbleHeight + INFO_BUBBLE_MARGIN_BUBBLE + 120
        ],
        // Bottom right of infobubble
        [
          bubbleX + bubbleWidth + INFO_BUBBLE_MARGIN_BUBBLE,
          bubbleY + bubbleHeight + INFO_BUBBLE_MARGIN_BUBBLE
        ],
        // Top right of infobubble
        [
          bubbleX + bubbleWidth + INFO_BUBBLE_MARGIN_BUBBLE,
          bubbleY - INFO_BUBBLE_MARGIN_BUBBLE
        ],
        // Top left of infobubble (starting point)
        [
          bubbleX - INFO_BUBBLE_MARGIN_BUBBLE,
          bubbleY - INFO_BUBBLE_MARGIN_BUBBLE
        ]
      ]
    } else {
      let bottomY = mouseY - INFO_BUBBLE_MARGIN_MOUSE
      if (bottomY < bubbleY + bubbleHeight + INFO_BUBBLE_MARGIN_BUBBLE) {
        bottomY = bubbleY + bubbleHeight + INFO_BUBBLE_MARGIN_BUBBLE
      }
      let bottomY2 = mouseY + INFO_BUBBLE_MARGIN_MOUSE
      if (bottomY2 < bubbleY + bubbleHeight + INFO_BUBBLE_MARGIN_BUBBLE) {
        bottomY2 = bubbleY + bubbleHeight + INFO_BUBBLE_MARGIN_BUBBLE
      }

      if (this.props.descriptionVisible) {
        bottomY =
          bubbleY + bubbleHeight + DESCRIPTION_HOVER_POLYGON_MARGIN + 300
        bottomY2 = bottomY

        hoverPolygon = [
          [
            bubbleX - DESCRIPTION_HOVER_POLYGON_MARGIN,
            bubbleY - DESCRIPTION_HOVER_POLYGON_MARGIN
          ],
          [
            bubbleX - DESCRIPTION_HOVER_POLYGON_MARGIN,
            bubbleY + bubbleHeight + DESCRIPTION_HOVER_POLYGON_MARGIN + 300
          ],
          [
            bubbleX + bubbleWidth + DESCRIPTION_HOVER_POLYGON_MARGIN,
            bubbleY + bubbleHeight + DESCRIPTION_HOVER_POLYGON_MARGIN + 300
          ],
          [
            bubbleX + bubbleWidth + DESCRIPTION_HOVER_POLYGON_MARGIN,
            bubbleY - DESCRIPTION_HOVER_POLYGON_MARGIN
          ],
          [
            bubbleX - DESCRIPTION_HOVER_POLYGON_MARGIN,
            bubbleY - DESCRIPTION_HOVER_POLYGON_MARGIN
          ]
        ]
      } else {
        let diffX = 60 - (mouseY - bubbleY) / 5
        if (diffX < 0) {
          diffX = 0
        } else if (diffX > 50) {
          diffX = 50
        }

        hoverPolygon = [
          [
            bubbleX - INFO_BUBBLE_MARGIN_BUBBLE,
            bubbleY - INFO_BUBBLE_MARGIN_BUBBLE
          ],
          [
            bubbleX - INFO_BUBBLE_MARGIN_BUBBLE,
            bubbleY + bubbleHeight + INFO_BUBBLE_MARGIN_BUBBLE
          ],
          [
            (bubbleX -
              INFO_BUBBLE_MARGIN_BUBBLE +
              mouseX -
              INFO_BUBBLE_MARGIN_MOUSE -
              diffX) /
              2,
            bottomY +
              (bubbleY + bubbleHeight + INFO_BUBBLE_MARGIN_BUBBLE - bottomY) *
                0.2
          ],
          [mouseX - INFO_BUBBLE_MARGIN_MOUSE - diffX, bottomY],
          [mouseX - INFO_BUBBLE_MARGIN_MOUSE, bottomY2],
          [mouseX + INFO_BUBBLE_MARGIN_MOUSE, bottomY2],
          [mouseX + INFO_BUBBLE_MARGIN_MOUSE + diffX, bottomY],
          [
            (bubbleX +
              bubbleWidth +
              INFO_BUBBLE_MARGIN_BUBBLE +
              mouseX +
              INFO_BUBBLE_MARGIN_MOUSE +
              diffX) /
              2,
            bottomY +
              (bubbleY + bubbleHeight + INFO_BUBBLE_MARGIN_BUBBLE - bottomY) *
                0.2
          ],
          [
            bubbleX + bubbleWidth + INFO_BUBBLE_MARGIN_BUBBLE,
            bubbleY + bubbleHeight + INFO_BUBBLE_MARGIN_BUBBLE
          ],
          [
            bubbleX + bubbleWidth + INFO_BUBBLE_MARGIN_BUBBLE,
            bubbleY - INFO_BUBBLE_MARGIN_BUBBLE
          ],
          [
            bubbleX - INFO_BUBBLE_MARGIN_BUBBLE,
            bubbleY - INFO_BUBBLE_MARGIN_BUBBLE
          ]
        ]
      }
    }

    return hoverPolygon
  }

  /**
   * TODO: consolidate this with the dim calc in updateBubbleDimensions? do we need snapshot here?
   */
  setInfoBubblePosition = () => {
    if (
      !this.segmentEl ||
      !this.el ||
      !this.el.current ||
      !this.props.visible
    ) {
      return
    }

    // Determine dimensions and X/Y layout
    const bubbleWidth = this.el.current.offsetWidth
    const bubbleHeight = this.el.current.offsetHeight
    const pos = getElAbsolutePos(this.segmentEl)

    let bubbleX = pos[0] - this.streetOuterEl.scrollLeft
    let bubbleY = pos[1]

    // TODO const
    bubbleY -= bubbleHeight - 20
    if (bubbleY < MIN_TOP_MARGIN_FROM_VIEWPORT) {
      bubbleY = MIN_TOP_MARGIN_FROM_VIEWPORT
    }

    bubbleX += this.segmentEl.offsetWidth / 2
    bubbleX -= bubbleWidth / 2

    if (bubbleX < MIN_SIDE_MARGIN_FROM_VIEWPORT) {
      bubbleX = MIN_SIDE_MARGIN_FROM_VIEWPORT
    } else if (
      bubbleX >
      window.innerWidth - bubbleWidth - MIN_SIDE_MARGIN_FROM_VIEWPORT
    ) {
      bubbleX = window.innerWidth - bubbleWidth - MIN_SIDE_MARGIN_FROM_VIEWPORT
    }

    this.el.current.style.left = bubbleX + 'px'
    this.el.current.style.top = bubbleY + 'px'
  }

  updateBubbleDimensions = (snapshot) => {
    if (!this.el || !this.el.current) return

    let bubbleHeight

    const el = this.el.current.querySelector('.description-canvas')

    if (this.props.descriptionVisible && el) {
      const pos = getElAbsolutePos(el)
      bubbleHeight = pos[1] + el.offsetHeight - 38
    } else {
      bubbleHeight = this.el.current.offsetHeight
    }

    const height = bubbleHeight + 30

    this.el.current.style.webkitTransformOrigin = '50% ' + height + 'px'
    this.el.current.style.MozTransformOrigin = '50% ' + height + 'px'
    this.el.current.style.transformOrigin = '50% ' + height + 'px'

    // When the infoBubble needed to be shown for the right building, the offsetWidth
    // used to calculate the left style was from the previous rendering of this component.
    // This meant that if the last time the infoBubble was shown was for a segment, then the
    // offsetWidth used to calculate the new left style would be smaller than it should be.
    // The current solution is to manually recalculate the left style and set the style
    // when hovering over the right building.

    if (snapshot) {
      const bubbleX = snapshot - this.el.current.offsetWidth
      this.el.current.style.left = bubbleX + 'px'
    }
  }

  /**
   * Retrieve name from segment data. It should also find the equivalent strings from the
   * translation files if provided.
   */
  getLabel = () => {
    let id
    let defaultMessage = ''

    // Return label if provided
    if (this.state.type === INFO_BUBBLE_TYPE_SEGMENT) {
      const segment = this.props.street.segments[this.props.position]
      if (segment && segment.label) {
        return segment.label
      }
    }

    // Otherwise need to do a lookup
    switch (this.state.type) {
      case INFO_BUBBLE_TYPE_SEGMENT: {
        const segment = this.props.street.segments[this.props.position]
        if (segment) {
          const segmentInfo = getSegmentInfo(segment.type)
          const variantInfo = getSegmentVariantInfo(
            segment.type,
            segment.variantString
          )
          const key = variantInfo.nameKey || segmentInfo.nameKey

          id = `segments.${key}`
          defaultMessage = variantInfo.name || segmentInfo.name
        }
        break
      }
      case INFO_BUBBLE_TYPE_LEFT_BUILDING: {
        const key = this.props.street.leftBuildingVariant

        id = `buildings.${key}.name`
        defaultMessage = BUILDINGS[key].label

        break
      }
      case INFO_BUBBLE_TYPE_RIGHT_BUILDING: {
        const key = this.props.street.rightBuildingVariant

        id = `buildings.${key}.name`
        defaultMessage = BUILDINGS[key].label

        break
      }
      default:
        break
    }

    return id
      ? (
        <IntlProvider
          locale={this.props.locale.locale}
          messages={this.props.locale.segmentInfo}
        >
          <FormattedMessage id={id} defaultMessage={defaultMessage} />
        </IntlProvider>
        )
      : null
  }

  render () {
    const type = this.state.type
    const canBeDeleted =
      type === INFO_BUBBLE_TYPE_SEGMENT && this.props.position !== null

    // Set class names
    const classNames = ['info-bubble']

    classNames.push(
      type === INFO_BUBBLE_TYPE_SEGMENT
        ? 'info-bubble-type-segment'
        : 'info-bubble-type-building'
    )
    if (this.props.visible) {
      classNames.push('visible')
    }
    if (this.props.descriptionVisible) {
      classNames.push('show-description')
    }

    // Determine position
    let position
    switch (type) {
      case INFO_BUBBLE_TYPE_SEGMENT:
        position = this.props.position
        break
      case INFO_BUBBLE_TYPE_LEFT_BUILDING:
        position = BUILDING_LEFT_POSITION
        break
      case INFO_BUBBLE_TYPE_RIGHT_BUILDING:
        position = BUILDING_RIGHT_POSITION
        break
      default:
        position = null
        break
    }

    // Determine width or height control type
    let widthOrHeightControl
    switch (type) {
      case INFO_BUBBLE_TYPE_SEGMENT:
        widthOrHeightControl = <WidthControl position={position} />
        break
      case INFO_BUBBLE_TYPE_LEFT_BUILDING:
      case INFO_BUBBLE_TYPE_RIGHT_BUILDING:
        widthOrHeightControl = <BuildingHeightControl position={position} />
        break
      default:
        widthOrHeightControl = null
        break
    }

    const segment = this.props.street.segments[this.props.position] || {}

    return (
      <div
        className={classNames.join(' ')}
        onMouseEnter={this.handleMouseEnter}
        onMouseLeave={this.handleMouseLeave}
        onTouchStart={this.handleTouchStart}
        ref={this.el}
      >
        <Triangle highlight={this.state.highlightTriangle} />
        <header>
          <EditableLabel
            label={this.getLabel()}
            segment={segment}
            position={this.props.position}
          />
          {canBeDeleted && <RemoveButton segment={this.props.position} />}
        </header>
        <div className="info-bubble-controls">
          <IntlProvider
            locale={this.props.locale.locale}
            messages={this.props.locale.segmentInfo}
          >
            <Variants type={type} position={position} />
          </IntlProvider>
          {widthOrHeightControl}
        </div>
        <Warnings segment={segment} />
        <Description
          type={segment.type}
          variantString={segment.variantString}
          updateBubbleDimensions={this.updateBubbleDimensions}
          onMouseOver={this.handleDescriptionOver}
          onMouseOut={this.handleDescriptionOut}
          infoBubbleEl={this.el.current}
          updateHoverPolygon={this.updateHoverPolygon}
        />
      </div>
    )
  }
}

function mapStateToProps (state) {
  return {
    visible: state.infoBubble.visible,
    descriptionVisible: state.infoBubble.descriptionVisible,
    mouseInside: state.infoBubble.mouseInside,
    position: state.ui.activeSegment,
    street: state.street,
    system: state.system,
    locale: state.locale
  }
}

const mapDispatchToProps = {
  setInfoBubbleMouseInside,
  updateHoverPolygon
}

export default connect(mapStateToProps, mapDispatchToProps)(InfoBubble)