Demos interactive data visualization examples

Multi-Line Voronoi Chart source Home

Data files: data.tsv

Code:

  • src/demos/multiline-voronoi/multiline-voronoi.ts | Code in Github | TypeScript coverage report | E2E tests

    import { select } from "d3"
    
    import { MultilineVoronoiChart } from "./multiline-voronoi-chart"
    import {
      SHOW_VORONOI_ID,
      fetchData,
      getChartConfig,
    } from "./multiline-voronoi-chart-config"
    import * as styles from "./multiline-voronoi.module.css"
    import { CONTAINER_ID } from "./ui-constants"
    
    const main = async () => {
      const { cities, months } = await fetchData()
      const chartConfig = getChartConfig({ cities, months })
    
      const chart = new MultilineVoronoiChart(chartConfig)
    
      const form = document.getElementById(styles.formVoronoi) as HTMLElement
      const chartEl = document.getElementById(CONTAINER_ID) as HTMLElement
    
      chartEl.appendChild(form)
    
      select(`#${SHOW_VORONOI_ID}`)
        .property("disabled", false)
        .on("change", (mouseEvent: MouseEvent) => {
          chart.setVoronoi((mouseEvent.target as HTMLInputElement).checked || false)
        })
    }
    
    export { CONTAINER_ID, SHOW_VORONOI_ID }
    
    export default main
    
  • src/demos/multiline-voronoi/multiline-voronoi-chart-config.ts | Code in Github | TypeScript coverage report

    import { timeFormat, timeParse, tsv } from "d3"
    
    import { ChartConfig } from "./multiline-voronoi-chart"
    import { CONTAINER_ID } from "./ui-constants"
    
    const SHOW_VORONOI_ID = "show-voronoi"
    
    type InitialDataItem = {
      [monthKey: string]: string
      name: string
    }
    
    type CityMetric = {
      cityName: string
      date: Date
      employmentRate: number
    }
    
    type City = {
      metrics: CityMetric[]
      name: string
    }
    
    const monthNames = [
      "January",
      "February",
      "March",
      "April",
      "May",
      "June",
      "July",
      "August",
      "September",
      "October",
      "November",
      "December",
    ]
    
    const formatStr = "%Y-%m"
    
    const fetchData = async () => {
      const monthFormat = timeFormat(formatStr)
      const monthParse = timeParse(formatStr)
    
      const dataItems = (await tsv(
        `${ROOT_PATH}data/d3js/multiline-voronoi/data.tsv`
      )) as unknown as InitialDataItem[]
    
      const months: Date[] = Object.keys(dataItems[0])
        .map((v) => monthParse(v)!)
        .filter(Number)
    
      const cities: City[] = dataItems.map((initialCity: InitialDataItem) => {
        const name = initialCity.name
          .replace(/(msa|necta div|met necta|met div)$/i, "")
          .trim()
    
        return {
          metrics: months.map((date: Date) => {
            const itemKey = monthFormat(date)
            const { [itemKey as keyof InitialDataItem]: itemValue } = initialCity
            const employmentRate: number = Number(itemValue) / 100
    
            return {
              cityName: name,
              date,
              employmentRate,
            }
          }),
          name,
        }
      })
    
      return { cities, months }
    }
    
    type Config = ChartConfig<City, City["metrics"][number]>
    
    const getLineId: Config["getLineId"] = (city) => city.name
    
    const getLinePoints: Config["getLinePoints"] = (city) => city.metrics
    const getPointYValue: Config["getPointYValue"] = (cityMetric) =>
      cityMetric.employmentRate
    
    const getTooltipPart1: Config["getTooltipPart1"] = (cityMetric: CityMetric) =>
      `${cityMetric.cityName.trim()}: `
    
    const getTooltipPart2: Config["getTooltipPart2"] = (cityMetric: CityMetric) => {
      const date = `${
        monthNames[cityMetric.date.getMonth()]
      } of ${cityMetric.date.getFullYear()}`
    
      return ` ${(cityMetric.employmentRate * 100).toFixed(2)}% - ${date}`
    }
    
    const getLineIdFromPoint: Config["getLineIdFromPoint"] = (cityMetric) =>
      cityMetric.cityName
    
    const getPointXValue: Config["getPointXValue"] = (cityMetric) => cityMetric.date
    
    const getChartConfig = ({
      cities,
      months,
    }: {
      cities: City[]
      months: Date[]
    }): Config => ({
      chartTitle: "US Unemployment Rate",
      getLineId,
      getLineIdFromPoint,
      getLinePoints,
      getPointXValue,
      getPointYValue,
      getTooltipPart1,
      getTooltipPart2,
      lines: cities,
      rootElId: CONTAINER_ID,
      times: months,
    })
    
    export { SHOW_VORONOI_ID, fetchData, getChartConfig }
    
  • src/demos/multiline-voronoi/multiline-voronoi-chart.ts | Code in Github | TypeScript coverage report

    import {
      Selection,
      axisBottom,
      axisLeft,
      extent,
      line as lineD3,
      max,
      scaleLinear,
      scaleOrdinal,
      scaleTime,
      schemePastel2,
      select,
    } from "d3"
    import { Delaunay } from "d3-delaunay"
    
    import * as styles from "./multiline-voronoi.module.css"
    
    type LineId = string
    
    type ChartConfig<ChartLine, ChartPoint> = Readonly<{
      chartTitle: string
      getLineId: (line: ChartLine) => LineId
      getLineIdFromPoint: (point: ChartPoint) => LineId
      getLinePoints: (line: ChartLine) => ChartPoint[]
      getPointXValue: (point: ChartPoint) => Date
      getPointYValue: (point: ChartPoint) => number
      getTooltipPart1: (point: ChartPoint) => string
      getTooltipPart2: (point: ChartPoint) => string
      lines: ChartLine[]
      rootElId: string
      times: Date[]
    }>
    
    const addFilter = (
      svg: Selection<SVGGElement, unknown, HTMLElement, unknown>
    ) => {
      const defs = svg.append("defs")
      const filter = defs.append("filter").attr("id", "drop-shadow")
    
      filter
        .append("feGaussianBlur")
        .attr("in", "SourceAlpha")
        .attr("stdDeviation", 1)
    
      filter.append("feOffset").attr("dx", 1).attr("dy", 1)
      filter
        .append("feComponentTransfer")
        .append("feFuncA")
        .attr("slope", "1")
        .attr("type", "linear")
    
      const feMerge = filter.append("feMerge")
    
      feMerge.append("feMergeNode")
      feMerge.append("feMergeNode").attr("in", "SourceGraphic")
    }
    
    const tooltipWidth = 300
    const tooltipWidthHalf = tooltipWidth / 2
    
    const buildTooltip = (
      svgG: Selection<SVGGElement, unknown, HTMLElement, unknown>
    ) => {
      const tooltip = svgG
        .append("g")
        .attr("class", styles.tooltip)
        .attr("transform", "translate(-100,-100)")
    
      tooltip
        .append("rect")
        .attr("transform", "translate(-150,-50)")
        .attr("fill", "white")
        .attr("height", 50)
        .attr("width", tooltipWidth)
        .attr("rx", 5)
        .attr("ry", 5)
        .style("filter", "url(#drop-shadow)")
        .style("opacity", "0.65")
        .style("pointer-events", "none")
        .style("cursor", "default")
    
      tooltip.append("text").attr("class", "text1").attr("y", -30)
      tooltip.append("text").attr("class", "text2").attr("y", -10)
    
      return tooltip
    }
    
    type ChartElements = Readonly<{
      circle: Selection<SVGCircleElement, unknown, HTMLElement, unknown>
      linesWrapper: Selection<SVGGElement, unknown, HTMLElement, unknown>
      svg: Selection<SVGSVGElement, unknown, HTMLElement, unknown>
      svgG: Selection<SVGGElement, unknown, HTMLElement, unknown>
      tooltip: Selection<SVGGElement, unknown, HTMLElement, unknown>
      voronoiGroup: Selection<SVGGElement, unknown, HTMLElement, unknown>
      xAxis: Selection<SVGGElement, unknown, HTMLElement, unknown>
      yAxis: Selection<SVGGElement, unknown, HTMLElement, unknown>
    }>
    
    class MultilineVoronoiChart<ChartLine, ChartPoint> {
      private readonly config: ChartConfig<ChartLine, ChartPoint>
      private readonly elements: ChartElements
      private readonly state: {
        clickToggle: boolean
        usedLines: ChartLine[]
      }
    
      public constructor(config: ChartConfig<ChartLine, ChartPoint>) {
        this.config = config
    
        const svg = select(`#${this.config.rootElId}`).append("svg")
        const svgG = svg.append("g")
        const xAxis = svgG.append("g").attr("class", `${styles.axis} axis--x`)
        const yAxis = svgG.append("g").attr("class", `${styles.axis} axis--y`)
    
        svgG
          .append("text")
          .attr("x", 20)
          .attr("dy", ".32em")
          .style("font-weight", "bold")
          .text(this.config.chartTitle)
    
        addFilter(svgG)
    
        const linesWrapper = svgG.append("g").attr("class", styles.lines)
        const circle = svgG.append("circle").attr("r", 3.5)
        const voronoiGroup = svgG.append("g").attr("class", styles.voronoi)
    
        const tooltip = buildTooltip(svgG)
    
        this.elements = {
          circle,
          linesWrapper,
          svg,
          svgG,
          tooltip,
          voronoiGroup,
          xAxis,
          yAxis,
        }
    
        this.state = {
          clickToggle: false,
          usedLines: this.config.lines,
        }
    
        this.render()
    
        window.addEventListener("resize", this.handleResize)
      }
    
      private static getMargin(width: number) {
        const defaultMargin = {
          bottom: 70,
          left: 80,
          right: 70,
          top: 60,
        }
    
        if (width < 530) {
          return {
            ...defaultMargin,
            left: 35,
            right: 5,
          }
        }
    
        return defaultMargin
      }
    
      public setVoronoi(newValue: boolean) {
        this.elements.voronoiGroup.classed(styles.voronoiShow, newValue)
      }
    
      private render() {
        const {
          config: { lines, rootElId, times },
          elements,
        } = this
        const color = scaleOrdinal(schemePastel2)
    
        const rootEl = document.getElementById(rootElId) as HTMLElement
    
        rootEl.classList.add(styles.multilineVoronoiChart)
    
        const { width: elWidth } = rootEl.getBoundingClientRect()
        const margin = MultilineVoronoiChart.getMargin(elWidth)
        const width =
          rootEl.getBoundingClientRect().width - margin.left - margin.right
        const isSmallDevice = width < 530
    
        const height = 500 - margin.top - margin.bottom
    
        const xScale = scaleTime().range([0, width])
        const yScale = scaleLinear().range([height, 0])
    
        const lineXTransformer = (point: ChartPoint) =>
          xScale(this.config.getPointXValue(point))
        const lineYTransformer = (point: ChartPoint) =>
          yScale(this.config.getPointYValue(point))
    
        const lineIdToElement: { [lineId: string]: SVGPathElement } = {}
    
        elements.svg
          .attr("width", width + margin.left + margin.right)
          .attr("height", height + margin.top + margin.bottom)
    
        elements.svgG.attr("transform", `translate(${margin.left},${margin.top})`)
    
        xScale.domain(extent<Date>(times) as [Date, Date])
        yScale
          .domain([
            0,
            max(lines, (line) =>
              max(this.config.getLinePoints(line), this.config.getPointYValue)
            ) as number,
          ])
          .nice()
    
        elements.xAxis
          .attr("transform", `translate(0,${height})`)
          .call(axisBottom(xScale).ticks(isSmallDevice ? 2 : undefined))
    
        elements.yAxis.call(axisLeft(yScale).ticks(10, "%"))
    
        const line = lineD3<ChartPoint>().x(lineXTransformer).y(lineYTransformer)
    
        const {
          config,
          state: { usedLines },
        } = this
    
        const updatedLines = elements.linesWrapper.selectAll("path").data(usedLines)
    
        updatedLines.enter().append("path").style("filter", "url(#drop-shadow)")
        updatedLines.exit().remove()
    
        elements.linesWrapper
          .selectAll<SVGPathElement, ChartLine>("path")
          .attr("d", function generateLine(usedLine) {
            const usedLineId = config.getLineId(usedLine)
    
            lineIdToElement[usedLineId] = this
    
            const points = config.getLinePoints(usedLine)
    
            return line(points)
          })
          .style("stroke", (...[, lineIndex]) => color(lineIndex.toString()))
    
        const mouseout = (...[, point]: [unknown, ChartPoint]) => {
          const lineId = config.getLineIdFromPoint(point)
          const { [lineId]: linePath } = lineIdToElement
    
          select(linePath).classed(styles.lineHover, false)
    
          return elements.tooltip.attr("transform", "translate(-100,-100)")
        }
    
        const clicked = (...[, point]: [unknown, ChartPoint]) => {
          this.state.clickToggle = !this.state.clickToggle
    
          this.state.usedLines = (() => {
            if (this.state.clickToggle) {
              const lineData = lines.find(
                (lineItem) =>
                  config.getLineId(lineItem) === config.getLineIdFromPoint(point)
              ) as ChartLine
    
              return [lineData]
            }
    
            return lines
          })()
    
          elements.tooltip.on("mouseover", null).on("click", null)
    
          this.render()
        }
    
        const mouseover = (...[, point]: [unknown, ChartPoint]) => {
          const lineId = config.getLineIdFromPoint(point)
          const { [lineId]: linePath } = lineIdToElement
    
          select(linePath).classed(styles.lineHover, true)
          ;(linePath.parentNode as SVGGElement).appendChild(linePath)
    
          const rawTranslateX = lineXTransformer(point)
          const rawTranslateY = lineYTransformer(point)
    
          const translateX = Math.min(
            width - tooltipWidth / 2,
            Math.max(tooltipWidthHalf, rawTranslateX)
          )
          const translateY = rawTranslateY
    
          elements.tooltip
            .attr("transform", `translate(${translateX},${translateY})`)
            .on("mouseover", () => {
              mouseover(null, point)
            })
            .on("click", () => {
              clicked(null, point)
            })
          elements.circle.attr(
            "transform",
            `translate(${rawTranslateX},${rawTranslateY})`
          )
    
          elements.tooltip.select(".text1").text(config.getTooltipPart1(point))
          elements.tooltip.select(".text2").text(config.getTooltipPart2(point))
        }
    
        const flatPoints = usedLines.reduce<ChartPoint[]>((...[acc, usedLine]) => {
          const points = config.getLinePoints(usedLine)
    
          points.forEach((point) => {
            acc.push(point)
          })
    
          return acc
        }, [])
    
        const voronoi = Delaunay.from(
          flatPoints,
          lineXTransformer,
          lineYTransformer
        ).voronoi([
          -margin.left,
          -margin.top,
          width + margin.right,
          height + margin.bottom,
        ])
    
        const updatedVoronoi = this.elements.voronoiGroup
          .selectAll<SVGPathElement, ChartPoint>("path")
          .data(
            flatPoints,
            (point) =>
              `${config.getLineIdFromPoint(point)}-${config.getPointXValue(point)}`
          )
    
        updatedVoronoi.enter().append("path")
        updatedVoronoi.exit().remove()
    
        this.elements.voronoiGroup
          .selectAll<SVGPathElement, ChartPoint>("path")
          .attr("d", (...[, pointIndex]) => voronoi.renderCell(pointIndex))
          .on("mouseover", mouseover)
          .on("mouseout", mouseout)
          .on("click", clicked)
      }
    
      private readonly handleResize = () => {
        this.render()
      }
    }
    
    export { MultilineVoronoiChart, ChartConfig }
    
  • src/demos/multiline-voronoi/ui-constants.ts | Code in Github | TypeScript coverage report

    const CONTAINER_ID = "chart"
    
    export { CONTAINER_ID }
    
  • pages/d3js/multiline-voronoi.tsx | Code in Github | TypeScript coverage report

    import React from "react"
    
    import { DemoPageProps } from "@/common"
    
    import Demo from "@/components/demo"
    
    import main, {
      CONTAINER_ID,
      SHOW_VORONOI_ID,
    } from "@/demos/multiline-voronoi/multiline-voronoi"
    import * as styles from "@/demos/multiline-voronoi/multiline-voronoi.module.css"
    
    const MultilineVoronoi = ({ pageContext }: DemoPageProps) => (
      <Demo main={main} pageContext={pageContext}>
        <form id={styles.formVoronoi}>
          <input
            className="form-check-input"
            id={SHOW_VORONOI_ID}
            type="checkbox"
          />{" "}
          <label htmlFor={SHOW_VORONOI_ID}>Show Voronoi lines</label>
        </form>
        <div id={CONTAINER_ID} />
      </Demo>
    )
    
    export default MultilineVoronoi
    
  • src/demos/multiline-voronoi/multiline-voronoi.module.css | Code in Github

    .multilineVoronoiChart {
      position: relative;
    
      .axis path,
      .axis line {
        fill: none;
        shape-rendering: crispedges;
        stroke: #000;
      }
    
      .lines {
        fill: none;
        stroke: #aaa;
        stroke-linecap: round;
        stroke-linejoin: round;
        stroke-width: 1.5px;
      }
    
      .lineHover {
        stroke: #000;
      }
    
      .tooltip text {
        text-anchor: middle;
        text-shadow: 0 1px 0 #fff, 1px 0 0 #fff, 0 -1px 0 #fff, -1px 0 0 #fff;
      }
    
      .voronoi path {
        fill: none;
        pointer-events: all;
      }
    
      .voronoiShow path {
        stroke: red;
        stroke-opacity: 0.2;
      }
    }
    
    #formVoronoi {
      position: absolute;
      right: 30px;
      top: 20px;
    
      input {
        cursor: pointer;
        margin-right: 5px;
      }
    }