exercism/website

View on GitHub
app/javascript/components/impact/chart-elements/plugins/canvas-custom-points.ts

Summary

Maintainability
B
5 hrs
Test Coverage
import { Chart } from 'chart.js'

const POINT_BACKGROUND_COLOR = '#FFD38F'
const TOOLTIP_BACKGROUND_COLOR = '#302B42'
const TW_MD = 768

export const CANVAS_CUSTOM_POINTS = {
  id: 'custom_canvas_points',
  afterDraw: (chart: Chart<'line'>): void => {
    const ctx: CanvasRenderingContext2D = chart.ctx
    // 0 = index of our dataset, since we only draw one line, this is 0
    const points = chart.getDatasetMeta(0).data
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    // this is a meta key, so it isn't present on the type signature
    const { milestones, dateMap } = chart.config._config
    const radius = 32
    const fontSize = 32
    const borderRadius = 8

    for (const milestone of milestones) {
      if (dateMap[milestone.date]) {
        const { x, y } = points[dateMap[milestone.date]]
        const customTooltipOptions: CustomTooltipOptions = {
          x,
          y: y - radius,
          font: { weight: 600, size: 18, family: 'Poppins' },
          radius: borderRadius,
          fillColor: TOOLTIP_BACKGROUND_COLOR,
          text: milestone.text,
          bottomMargin: 12,
          lineHeight: 160,
          paddingX: 12,
          paddingY: 6,
          chartWidth: chart.width,
        }
        drawCircleWithEmoji(ctx, x, y, radius, fontSize, milestone.emoji)
        if (chart.width > TW_MD - 16) {
          drawCustomTooltip(ctx, customTooltipOptions)
        }
      } else continue
    }
  },
}

function drawCircleWithEmoji(
  ctx: CanvasRenderingContext2D,
  x: number,
  y: number,
  radius: number,
  fontSize: number,
  emoji: string
) {
  ctx.beginPath()
  ctx.arc(x, y, radius, 0, 2 * Math.PI, false)
  ctx.fillStyle = POINT_BACKGROUND_COLOR
  setShadow(ctx, '#0F0923', 0, 2, 4)
  ctx.fill()
  // clean it up, so won't be applied to emoji
  cleanUpShadow(ctx)
  ctx.font = `${fontSize}px Arial`
  ctx.textAlign = 'center'
  ctx.textBaseline = 'alphabetic'
  ctx.strokeText(emoji, x, y + 12)
  ctx.closePath()
}

function setShadow(
  ctx: CanvasRenderingContext2D,
  color: string,
  offsetX: number,
  offsetY: number,
  blur: number
) {
  ctx.shadowColor = color
  ctx.shadowOffsetX = offsetX
  ctx.shadowOffsetY = offsetY
  ctx.shadowBlur = blur
}

function cleanUpShadow(ctx: CanvasRenderingContext2D) {
  // initially the shadowColor is set to transparent black
  setShadow(ctx, 'rgba(0,0,0,0)', 0, 0, 0)
}

type TooltipFont = {
  size: number
  family: string
  weight: number
}

type CustomTooltipOptions = {
  x: number
  y: number
  paddingX?: number
  paddingY?: number
  lineHeight?: number
  radius: number
  fill?: boolean
  fillColor?: string | CanvasPattern | CanvasGradient
  stroke?: boolean
  text: string
  font: TooltipFont
  bottomMargin: number
  chartWidth: number
}

function drawCustomTooltip(
  ctx: CanvasRenderingContext2D,
  options: CustomTooltipOptions
) {
  const {
    x: origoX,
    y: origoY,
    paddingX = 0,
    paddingY = 0,
    lineHeight = 100,
    bottomMargin = 0,
    radius = 0,
    fillColor = 'black',
    text = '',
    chartWidth,
    font = { weight: 500, size: 12, family: 'Arial' },
  } = options

  ctx.beginPath()

  ctx.font = `${font.weight} ${font.size}px ${font.family}`
  const metrics = ctx.measureText(text)
  const rawWidth =
    Math.abs(metrics.actualBoundingBoxLeft) +
    Math.abs(metrics.actualBoundingBoxRight)

  const tipHeight = 10
  const tipWidth = 10 //x2
  const lineHeightCent = lineHeight / 100
  const width = rawWidth + paddingX * 2
  const height = lineHeightCent * font.size + paddingY * 2

  // center tooltip
  const centeredX = origoX - width / 2

  // we need this, so the tip won't disattach itself from rect on screen width change
  const RESPONSIVE_MARGIN = Math.min(
    chartWidth - origoX - tipWidth,
    origoX - tipWidth
  )
  const MARGIN_X = Math.min(10, RESPONSIVE_MARGIN)

  // Make sure tooltip stays on screen on both ends
  const x = Math.max(
    MARGIN_X,
    centeredX - Math.max(0, centeredX + width - chartWidth + MARGIN_X)
  )
  const y = origoY - height - bottomMargin - tipHeight

  // triangle constants
  const bottom = y + height
  const tipTopLeft = origoX - tipWidth
  const tipTopRight = origoX + tipWidth
  const tipRightToRectEnd = x + width - tipTopRight
  const tipLeftToRectStart = tipTopLeft - x
  // adaptive border radius, that changes when tooltip is nearer to the end of rectangle
  const brBorderRadius = Math.min(radius, tipRightToRectEnd)
  const blBorderRadius = Math.min(radius, tipLeftToRectStart)

  // draw a rectangle with borderRadius
  ctx.moveTo(x + radius, y)
  ctx.lineTo(x + width - radius, y)
  // tr
  ctx.quadraticCurveTo(x + width, y, x + width, y + radius)
  ctx.lineTo(x + width, y + height - radius)
  // br
  ctx.quadraticCurveTo(
    x + width,
    y + height,
    x + width - brBorderRadius,
    y + height
  )
  ctx.lineTo(x + radius, y + height)
  // bl
  ctx.quadraticCurveTo(x, y + height, x, y + height - blBorderRadius)
  ctx.lineTo(x, y + radius)
  // tl
  ctx.quadraticCurveTo(x, y, x + radius, y)
  ctx.fillStyle = fillColor
  ctx.fill()

  // draw a lil triangle a.k.a. "tip" that points towards the origo of custom point
  ctx.moveTo(origoX, bottom + tipHeight)
  ctx.lineTo(tipTopLeft, bottom)
  ctx.lineTo(tipTopRight, bottom)
  ctx.fillStyle = fillColor
  ctx.fill()

  // text
  ctx.fillStyle = 'white'
  ctx.textBaseline = 'alphabetic'
  ctx.textAlign = 'left'
  ctx.fillText(text, x + paddingX, y + height / 2 + paddingY)
  ctx.closePath()
}