streetmix/streetmix

View on GitHub
assets/scripts/segments/view.js

Summary

Maintainability
F
4 days
Test Coverage
import { images } from '../app/load_resources'
import { formatMessage } from '../locales/locale'
import { saveStreetToServerIfNecessary } from '../streets/data_model'
import { recalculateWidth } from '../streets/width'
import store from '../store'
import { updateSegments, changeSegmentProperties } from '../store/slices/street'
import { percentToNumber } from '../util/number'
import { getSegmentInfo, getSegmentVariantInfo, getSpriteDef } from './info'
import { drawScatteredSprites } from './scatter'
import {
  TILE_SIZE,
  TILESET_POINT_PER_PIXEL,
  TILE_SIZE_ACTUAL,
  MAX_SEGMENT_LABEL_LENGTH,
  BUILDING_LEFT_POSITION,
  BUILDING_RIGHT_POSITION
} from './constants'
import PEOPLE from './people.json'

// Adjust spacing between people to be slightly closer
const PERSON_SPACING_ADJUSTMENT = -0.1 // in meters
const PERSON_SPRITE_OFFSET_Y = 10 // in pixels

/**
 * Draws SVG sprite to canvas
 *
 * @param {string} id - identifier of sprite
 * @param {CanvasRenderingContext2D} ctx
 * @param {Number} sx - x position of sprite to read from (default = 0)
 * @param {Number} sy - y position of sprite to read from (default = 0)
 * @param {Number} sw - sub-rectangle width to draw
 * @param {Number} sh - sub-rectangle height to draw
 * @param {Number} dx - x position on canvas
 * @param {Number} dy - y position on canvas
 * @param {Number} dw - destination width to draw
 * @param {Number} dh - destination height to draw
 * @param {Number} multiplier - scale to draw at (default = 1)
 * @param {Number} dpi
 */
export function drawSegmentImage (
  id,
  ctx,
  sx = 0,
  sy = 0,
  sw,
  sh,
  dx,
  dy,
  dw,
  dh,
  multiplier = 1,
  dpi
) {
  // If asked to render a source or destination image with width or height
  // that is equal to or less than 0, bail. Attempting to render such an image
  // will throw an IndexSizeError error in Firefox.
  if (sw <= 0 || sh <= 0 || dw <= 0 || dh <= 0) return

  // Settings
  const state = store.getState()
  dpi = dpi || state.system.devicePixelRatio || 1
  const debugRect = state.flags.DEBUG_SEGMENT_CANVAS_RECTANGLES.value || false

  // Get image definition
  const svg = images.get(id)
  // Source width and height is based off of intrinsic image width and height,
  // but it can be overridden in the parameters, e.g. when repeating sprites
  // in a sequence and the last sprite needs to be truncated
  sw = sw === undefined ? svg.width : sw * TILESET_POINT_PER_PIXEL
  sh = sh === undefined ? svg.height : sh * TILESET_POINT_PER_PIXEL

  // We can't read `.naturalWidth` and `.naturalHeight` properties from
  // the image in IE11, which returns 0. This is why width and height are
  // stored as properties from when the image is first cached
  // All images are drawn at 2x pixel dimensions so divide in half to get
  // actual width / height value then multiply by system pixel density
  //
  // dw/dh (and later sw/sh) can be 0, so don't use falsy checks
  dw = dw === undefined ? svg.width / TILESET_POINT_PER_PIXEL : dw
  dh = dh === undefined ? svg.height / TILESET_POINT_PER_PIXEL : dh
  dw *= multiplier * dpi
  dh *= multiplier * dpi

  // Set render dimensions based on pixel density
  dx *= dpi
  dy *= dpi

  // These rectangles are telling us that we're drawing at the right places.
  if (debugRect === true) {
    ctx.fillStyle = 'rgba(0, 0, 0, 0.2)'
    ctx.fillRect(dx, dy, dw, dh)
  }

  // Round all values to whole numbers when we render. Decimal values can
  // introduce tiny "seams" at tiled assets, for example.
  ctx.drawImage(
    svg.img,
    Math.round(sx),
    Math.round(sy),
    Math.round(sw),
    Math.round(sh),
    Math.round(dx),
    Math.round(dy),
    // destination sprites round up to prevent gaps in tiled sprites
    Math.ceil(dw),
    Math.ceil(dh)
  )
}

/**
 * When rendering a stack of sprites, sometimes the resulting image will extend
 * beyond the left or right edge of the segment's width. (For instance, a
 * "tree" segment might be 0.5m wide, but the actual width of the tree sprite
 * will need more than 0.5m width to render.) This calculates the actual left,
 * right, and center Y-values needed to render sprites so that they are not
 * truncated at the edge of the segment.
 *
 * @param {Object} variantInfo - segment variant info
 * @param {Number} actualWidth - segment's actual real life width
 * @returns {VariantInfoDimensions}
 */
export function getVariantInfoDimensions (variantInfo, actualWidth = 0) {
  // Convert actualWidth to units that work with images' intrinsic dimensions
  const displayWidth = actualWidth * TILE_SIZE_ACTUAL

  const center = displayWidth / 2
  let left = center
  let right = center
  let newLeft, newRight

  const graphics = variantInfo.graphics

  if (graphics.center) {
    const sprites = Array.isArray(graphics.center)
      ? graphics.center
      : [graphics.center]

    for (let l = 0; l < sprites.length; l++) {
      const sprite = getSpriteDef(sprites[l])
      const svg = images.get(sprite.id)

      // If svg is missing, let it not affect this calculation
      if (svg) {
        newLeft = center - svg.width / 2 + (sprite.offsetX || 0)
        newRight = center + svg.width / 2 + (sprite.offsetX || 0)

        if (newLeft < left) {
          left = newLeft
        }
        if (newRight > right) {
          right = newRight
        }
      }
    }
  }

  if (graphics.left) {
    const sprites = Array.isArray(graphics.left)
      ? graphics.left
      : [graphics.left]

    for (let l = 0; l < sprites.length; l++) {
      const sprite = getSpriteDef(sprites[l])
      const svg = images.get(sprite.id)

      if (svg) {
        newLeft = sprite.offsetX || 0
        newRight = svg.width + (sprite.offsetX || 0)

        if (newLeft < left) {
          left = newLeft
        }
        if (newRight > right) {
          right = newRight
        }
      }
    }
  }

  if (graphics.right) {
    const sprites = Array.isArray(graphics.right)
      ? graphics.right
      : [graphics.right]

    for (let l = 0; l < sprites.length; l++) {
      const sprite = getSpriteDef(sprites[l])
      const svg = images.get(sprite.id)

      if (svg) {
        newLeft = displayWidth - (sprite.offsetX || 0) - svg.width
        newRight = displayWidth - (sprite.offsetX || 0)

        if (newLeft < left) {
          left = newLeft
        }
        if (newRight > right) {
          right = newRight
        }
      }
    }
  }

  if (graphics.repeat && graphics.repeat[0]) {
    newLeft = center - displayWidth / 2
    newRight = center + displayWidth / 2

    if (newLeft < left) {
      left = newLeft
    }
    if (newRight > right) {
      right = newRight
    }
  }

  // If a segment has a "minimum renderable width", the left and right values
  // are overridden based on the `quirks.minWidth` property. This prevents
  // left/right assets from rendering in strange positions.
  if (graphics.quirks?.minWidth) {
    const minDimension = graphics.quirks.minWidth * TILE_SIZE_ACTUAL
    if (displayWidth < minDimension) {
      left = (displayWidth - minDimension) / 2
      right = (minDimension - displayWidth) / 2 + displayWidth
    }
  }

  return {
    left: left / TILE_SIZE_ACTUAL,
    right: right / TILE_SIZE_ACTUAL,
    center: center / TILE_SIZE_ACTUAL
  }
}

const GROUND_LEVEL_OFFSETY = {
  ASPHALT: 0,
  CURB: 18,
  RAISED_CURB: 94,
  DRAINAGE: -50
}

/**
 * Originally a sprite's dy position was calculated using: dy = offsetTop +
 * (multiplier * TILE_SIZE * (sprite.offsetY || 0)). In order to remove
 * `offsetY` from `SPRITE_DEF`, we are defining the `offsetY` for all "ground",
 * or "lane", sprites in pixels in `GROUND_LEVEL_OFFSETY`. This was calculated
 * by taking the difference of the `offsetY` value for ground level 0 and the
 * `offsetY` for the elevation of the current segment. Using `elevation`, which
 * is defined for each segment based on the "ground" component being used, this
 * function returns the `GROUND_LEVEL_OFFSETY` for that `elevation`. If not
 * found, it returns null.
 *
 * @param {Number} elevation
 * @returns {?Number} groundLevelOffset
 */
function getGroundLevelOffset (elevation) {
  switch (elevation) {
    case -2:
      return GROUND_LEVEL_OFFSETY.DRAINAGE
    case 0:
      return GROUND_LEVEL_OFFSETY.ASPHALT
    case 1:
      return GROUND_LEVEL_OFFSETY.CURB
    case 2:
      return GROUND_LEVEL_OFFSETY.RAISED_CURB
    default:
      return null
  }
}

/**
 *
 * @param {CanvasRenderingContext2D} ctx
 * @param {string} type
 * @param {string} variantString
 * @param {Number} actualWidth - The real-world width of a segment, in meters
 * @param {Number} offsetLeft
 * @param {Number} groundBaseline
 * @param {string} randSeed
 * @param {Number} multiplier
 * @param {Number} dpi
 */
export function drawSegmentContents (
  ctx,
  type,
  variantString,
  actualWidth,
  offsetLeft,
  groundBaseline,
  elevation = 0,
  randSeed,
  multiplier,
  dpi
) {
  const variantInfo = getSegmentVariantInfo(type, variantString)
  const graphics = variantInfo.graphics

  // TODO: refactor this variable
  const segmentWidth = actualWidth * TILE_SIZE

  const dimensions = getVariantInfoDimensions(variantInfo, actualWidth)
  const left = dimensions.left
  const minWidthQuirk = graphics.quirks?.minWidth

  const groundLevelOffset = getGroundLevelOffset(elevation)
  const groundLevel =
    groundBaseline -
    multiplier * (groundLevelOffset / TILESET_POINT_PER_PIXEL || 0)

  if (graphics.repeat) {
    // Convert single string or object values to single-item array
    let sprites = Array.isArray(graphics.repeat)
      ? graphics.repeat
      : [graphics.repeat]
    // Convert array of strings into array of objects
    // If already an object, pass through
    sprites = sprites.map((def) =>
      typeof def === 'string' ? { id: def } : def
    )

    for (let l = 0; l < sprites.length; l++) {
      const sprite = getSpriteDef(sprites[l].id)
      const svg = images.get(sprite.id)

      // Skip drawing if sprite is missing
      if (!svg) continue

      let width = (svg.width / TILE_SIZE_ACTUAL) * TILE_SIZE
      const padding = sprites[l].padding ?? 0

      let drawWidth
      // If quirks.minWidth is defined, and the segment width is less than that
      // value, then the draw width is the minimum renderable width, not the
      // segment width. Skip this for ground assets.
      if (
        minWidthQuirk &&
        actualWidth < minWidthQuirk &&
        !sprite.id.includes('ground')
      ) {
        drawWidth = minWidthQuirk * TILE_SIZE - padding * 2 * TILE_SIZE
      } else {
        drawWidth = segmentWidth - padding * 2 * TILE_SIZE
      }

      const countX = Math.floor(drawWidth / width) + 1

      let repeatStartX
      if (left < 0) {
        // If quirks.minWidth is defined, and the segment width is less than
        // that value, then render the left edge at minimum width boundary,
        // don't reposition it along the segment's left edge. Skip this process
        // if it's a ground asset.
        if (
          minWidthQuirk &&
          actualWidth < minWidthQuirk &&
          !sprite.id.includes('ground')
        ) {
          repeatStartX = 0
        } else {
          // This is for rendering beyond the left edge of the segment
          repeatStartX = -left * TILE_SIZE
        }
      } else {
        // This is the normal render logic.
        repeatStartX = 0
      }

      // The distance between the top of the sprite and the ground is
      // calculated by subtracting the height of the sprite with the # of
      // pixels to get to the point of the sprite which should align with
      // the ground.
      const distanceFromGround =
        multiplier *
        TILE_SIZE *
        ((svg.height - (sprite.originY || 0)) / TILE_SIZE_ACTUAL)

      // Right now only ground items repeat in the Y direction
      const height = (svg.height / TILE_SIZE_ACTUAL) * TILE_SIZE
      // countY should always be at minimum 1.
      const countY = sprite.id.startsWith('ground--')
        ? Math.ceil((ctx.canvas.height / dpi - groundLevel) / height)
        : 1

      for (let i = 0; i < countX; i++) {
        // remainder
        if (i === countX - 1) {
          width = drawWidth - (countX - 1) * width
        }

        for (let j = 0; j < countY; j++) {
          // If the sprite being rendered is the ground, dy is equal to the
          // groundLevel. If not, dy is equal to the groundLevel minus the
          // distance the sprite will be from the ground.
          drawSegmentImage(
            sprite.id,
            ctx,
            undefined,
            undefined,
            width,
            undefined,
            offsetLeft +
              padding * TILE_SIZE * multiplier +
              (repeatStartX + i * (svg.width / TILE_SIZE_ACTUAL) * TILE_SIZE) *
                multiplier,
            sprite.id.startsWith('ground--')
              ? groundLevel + height * j
              : groundLevel - distanceFromGround,
            width,
            undefined,
            multiplier,
            dpi
          )
        }
      }
    }
  }

  if (graphics.left) {
    const sprites = Array.isArray(graphics.left)
      ? graphics.left
      : [graphics.left]
    for (let l = 0; l < sprites.length; l++) {
      const sprite = getSpriteDef(sprites[l])
      const svg = images.get(sprite.id)

      // Skip drawing if sprite is missing
      if (!svg) continue

      let x
      // If quirks.minWidth is defined, and the segment width is less than that
      // value, then render a left-aligned asset at minimum width boundary,
      // don't reposition it along the segment's left edge.
      // Note: this doesn't take into account the sprite.offsetX because no
      // asset with quirks.minWidth has offsetX defined right now.
      if (minWidthQuirk && actualWidth < minWidthQuirk) {
        x = 0
      } else {
        // This is the normal render logic.
        x =
          0 +
          (-left + (sprite.offsetX / TILE_SIZE_ACTUAL || 0)) *
            TILE_SIZE *
            multiplier
      }

      const distanceFromGround =
        multiplier *
        TILE_SIZE *
        ((svg.height - (sprite.originY || 0)) / TILE_SIZE_ACTUAL)

      drawSegmentImage(
        sprite.id,
        ctx,
        undefined,
        undefined,
        undefined,
        undefined,
        offsetLeft + x,
        groundLevel - distanceFromGround,
        undefined,
        undefined,
        multiplier,
        dpi
      )
    }
  }

  if (graphics.right) {
    const sprites = Array.isArray(graphics.right)
      ? graphics.right
      : [graphics.right]
    for (let l = 0; l < sprites.length; l++) {
      const sprite = getSpriteDef(sprites[l])
      const svg = images.get(sprite.id)

      // Skip drawing if sprite is missing
      if (!svg) continue

      let x
      // If quirks.minWidth is defined, and the segment width is less than that
      // value, then render a right-aligned asset at minimum width boundary,
      // don't reposition it along the segment's right edge.
      // Note: this doesn't take into account the sprite.offsetX because no
      // asset with quirks.minWidth has offsetX defined right now.
      if (minWidthQuirk && actualWidth < minWidthQuirk) {
        x =
          (minWidthQuirk - svg.width / TILE_SIZE_ACTUAL) *
          TILE_SIZE *
          multiplier
      } else {
        // This is the normal render logic.
        x =
          (-left +
            actualWidth -
            svg.width / TILE_SIZE_ACTUAL -
            (sprite.offsetX / TILE_SIZE_ACTUAL || 0)) *
          TILE_SIZE *
          multiplier
      }

      const distanceFromGround =
        multiplier *
        TILE_SIZE *
        ((svg.height - (sprite.originY || 0)) / TILE_SIZE_ACTUAL)

      drawSegmentImage(
        sprite.id,
        ctx,
        undefined,
        undefined,
        undefined,
        undefined,
        offsetLeft + x,
        groundLevel - distanceFromGround,
        undefined,
        undefined,
        multiplier,
        dpi
      )
    }
  }

  if (graphics.center) {
    const sprites = Array.isArray(graphics.center)
      ? graphics.center
      : [graphics.center]
    for (let l = 0; l < sprites.length; l++) {
      const sprite = getSpriteDef(sprites[l])
      const svg = images.get(sprite.id)

      // Skip drawing if sprite is missing
      if (!svg) continue

      const center = dimensions.center
      const offsetByPercentage =
        sprite.offsetX &&
        typeof sprite.offsetX === 'string' &&
        sprite.offsetX.endsWith('%')
      const offsetX = offsetByPercentage ? 0 : sprite.offsetX
      const x =
        (center -
          svg.width / TILE_SIZE_ACTUAL / 2 -
          left -
          (offsetByPercentage ? percentToNumber(sprite.offsetX) * center : 0) -
          (offsetX / TILE_SIZE_ACTUAL || 0)) *
        TILE_SIZE *
        multiplier
      const distanceFromGround =
        multiplier *
        TILE_SIZE *
        ((svg.height - (sprite.originY || 0)) / TILE_SIZE_ACTUAL)

      drawSegmentImage(
        sprite.id,
        ctx,
        undefined,
        undefined,
        undefined,
        undefined,
        offsetLeft + x,
        groundLevel - distanceFromGround,
        undefined,
        undefined,
        multiplier,
        dpi
      )
    }
  }

  // Draw items that are scattered from a pool of assets
  // Only used for random people generation right now
  if (graphics.scatter) {
    if (graphics.scatter.pool === 'people') {
      const originY =
        (graphics.scatter.originY ??
          graphics.scatter.originY + PERSON_SPRITE_OFFSET_Y) ||
        PERSON_SPRITE_OFFSET_Y
      const people = PEOPLE.map((person) => {
        return {
          ...person,
          id: `people--${person.id}`,
          originY
        }
      })

      drawScatteredSprites(
        people,
        ctx,
        actualWidth,
        offsetLeft - left * TILE_SIZE * multiplier,
        groundLevel,
        randSeed,
        graphics.scatter.minSpacing,
        graphics.scatter.maxSpacing,
        PERSON_SPACING_ADJUSTMENT,
        graphics.scatter.padding,
        multiplier,
        dpi
      )
    }

    if (graphics.scatter.sprites) {
      drawScatteredSprites(
        graphics.scatter.sprites,
        ctx,
        actualWidth,
        offsetLeft - left * TILE_SIZE * multiplier,
        groundLevel,
        randSeed,
        graphics.scatter.minSpacing,
        graphics.scatter.maxSpacing,
        0,
        graphics.scatter.padding,
        multiplier,
        dpi
      )
    }
  }
}

export function getLocaleSegmentName (type, variantString) {
  const segmentInfo = getSegmentInfo(type)
  const variantInfo = getSegmentVariantInfo(type, variantString)
  const defaultName = variantInfo.name || segmentInfo.name
  const nameKey = variantInfo.nameKey || segmentInfo.nameKey
  const key = `segments.${nameKey}`

  return formatMessage(key, defaultName, { ns: 'segment-info' })
}

/**
 * TODO: remove this
 */
export function segmentsChanged () {
  const street = store.getState().street
  const updatedStreet = recalculateWidth(street)

  store.dispatch(
    updateSegments(
      updatedStreet.segments,
      updatedStreet.occupiedWidth,
      updatedStreet.remainingWidth
    )
  )

  saveStreetToServerIfNecessary()
}

/**
 * Process / sanitize segment labels
 *
 * @params {string} name - Segment label to check
 * @returns {string} - normalized / sanitized segment label
 */
function normalizeSegmentLabel (label) {
  if (!label) return ''

  label = label.trim()

  if (label.length > MAX_SEGMENT_LABEL_LENGTH) {
    label = label.substr(0, MAX_SEGMENT_LABEL_LENGTH) + '…'
  }

  return label
}

/**
 * Uses browser prompt to change the segment label
 *
 * @param {Object} segment - object describing the segment to edit
 * @param {Number} position - index of segment to edit
 */
export function editSegmentLabel (segment, position) {
  const prevLabel =
    segment.label || getLocaleSegmentName(segment.type, segment.variantString)
  const label = normalizeSegmentLabel(
    window.prompt(
      formatMessage('prompt.segment-label', 'New segment label:'),
      prevLabel
    )
  )

  if (label && label !== prevLabel) {
    store.dispatch(changeSegmentProperties(position, { label }))
    segmentsChanged()
  }
}

/**
 * Given the position of a segment or building, retrieve a reference to its
 * DOM element.
 *
 * @param {Number|string} position - either "left" or "right" for building,
 *              or a number for the position of the segment. Should be
 *              the `dataNo` or `position` variables.
 */
export function getSegmentEl (position) {
  if (!position && position !== 0) return

  let segmentEl
  if (position === BUILDING_LEFT_POSITION) {
    segmentEl = document.querySelectorAll('.street-section-building')[0]
  } else if (position === BUILDING_RIGHT_POSITION) {
    segmentEl = document.querySelectorAll('.street-section-building')[1]
  } else {
    const segments = document
      .getElementById('street-section-editable')
      .querySelectorAll('.segment')
    segmentEl = segments[position]
  }
  return segmentEl
}