Demos interactive data visualization examples

Fish Eye Chart source Home

Data files: data.json

Code:

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

    import { extent, scalePow } from "d3"
    
    import { FishEyeChart } from "./fish-eye-chart"
    import { RANDOM_UPDATE_ID, fetchData, getChartConfig } from "./fish-eye-config"
    import { CONTAINER_ID } from "./ui-constants"
    
    const main = async () => {
      const incomeMetrics = await fetchData()
      const chartConfig = getChartConfig(incomeMetrics)
    
      const chart = new FishEyeChart(chartConfig)
    
      document.getElementById(RANDOM_UPDATE_ID)?.addEventListener("click", () => {
        const getCommonExtent = (
          property: "income" | "lifeExpectancy" | "population"
        ) =>
          extent(chartConfig.chartItems, (chartItem) => chartItem[property]) as [
            number,
            number
          ]
    
        const populationExtent = getCommonExtent("population")
        const incomeExtent = getCommonExtent("income")
        const lifeExpectancyExtent = getCommonExtent("lifeExpectancy")
    
        const populationScale = scalePow().exponent(20).range(populationExtent)
        const incomeScale = scalePow().exponent(5).range(incomeExtent)
        const lifeExpectancyScale = scalePow()
          .exponent(2)
          .range(lifeExpectancyExtent)
    
        chartConfig.chartItems.forEach((incomeMetric) => {
          if (Math.random() < 0.1) {
            incomeMetric.population = populationScale(Math.random())
            incomeMetric.income = incomeScale(Math.random())
            incomeMetric.lifeExpectancy = lifeExpectancyScale(Math.random())
          }
        })
    
        chart.refresh()
      })
    }
    
    export { CONTAINER_ID, RANDOM_UPDATE_ID }
    
    export default main
    
  • src/demos/fish-eye/fish-eye-chart.ts | Code in Github | TypeScript coverage report

    import {
      Axis,
      AxisScale,
      ScaleOrdinal,
      ScalePower,
      Selection,
      axisBottom,
      axisLeft,
      format,
      pointer as pointerD3,
      scaleLinear,
      scaleLog,
      scaleOrdinal,
      scaleSqrt,
      schemePastel2,
      select,
    } from "d3"
    
    import d3Fisheye, { FishEyeScale } from "@/utils/fishEye"
    
    import * as styles from "./fish-eye.module.css"
    
    const margin = {
      bottom: 70,
      left: 70,
      right: 50,
      top: 80,
    }
    const LEFT_OFFSET_SMALL_DEVICE = 20
    const height = 700 - margin.top - margin.bottom
    
    type FishEyeChartOpts<ChartData> = Readonly<{
      chartItems: ChartData[]
      colorDomain: string[]
      getCircleTitle: (chartItem: ChartData) => string
      getColorValue: (chartItem: ChartData) => string
      getRadiusValue: (chartItem: ChartData) => number
      getXValue: (chartItem: ChartData) => number
      getYValue: (chartItem: ChartData) => number
      rootElId: string
      titles: {
        long: string
        short: string
      }
      xAxisLabel: string
      yAxisLabel: string
    }>
    
    class FishEyeChart<ChartData> {
      private readonly config: FishEyeChartOpts<ChartData>
    
      private width = 0
    
      private dom!: {
        dot?: Selection<SVGCircleElement, ChartData, SVGGElement, unknown>
        pointer?: Selection<SVGTextElement, unknown, HTMLElement, unknown>
        svg: Selection<SVGSVGElement, unknown, HTMLElement, unknown>
        svgG: Selection<SVGGElement, unknown, HTMLElement, unknown>
        xAxis?: Axis<number>
        yAxis?: Axis<number>
      }
    
      private vars!: {
        colorScale: ScaleOrdinal<string, string>
        focused: boolean
        radiusScale: ScalePower<number, number>
        xScale: FishEyeScale
        yScale: FishEyeScale
      }
    
      public constructor(chartConfig: FishEyeChartOpts<ChartData>) {
        this.config = chartConfig
    
        this.setupRootEl()
        this.setVars()
        this.setDom()
    
        this.setChartTitle()
        this.setBackground()
        this.setPointer()
        this.setAxis()
        this.setLabels()
        this.setDots()
        this.setTitles()
        this.updateDimensions()
        this.bindMousemove()
        this.bindMouseLeave()
        this.bindClick()
        this.bindResize()
    
        this.setZoom({
          animationDuration: 0,
          distortion: 0,
          focus: [0, 0],
        })
      }
    
      private static isTouchDevice() {
        return (
          "ontouchstart" in window ||
          navigator.maxTouchPoints > 0 ||
          (navigator as any).msMaxTouchPoints > 0 // eslint-disable-line @typescript-eslint/no-explicit-any
        )
      }
    
      public refresh() {
        this.updateDimensions(1000)
      }
    
      private setupRootEl() {
        const rootEl = document.getElementById(this.config.rootElId) as HTMLElement
    
        rootEl.classList.add(styles.fishEyeChart)
    
        this.width =
          rootEl.getBoundingClientRect().width - margin.left - margin.right
      }
    
      private isSmallDevice() {
        return this.width < 500
      }
    
      private setDom() {
        const svg = select(`#${this.config.rootElId}`).append("svg")
        const svgG = svg.append("g")
    
        this.dom = {
          svg,
          svgG,
        }
      }
    
      private setChartTitle() {
        this.dom.svgG
          .append("text")
          .attr("class", styles.chartTitle)
          .attr("text-anchor", "middle")
          .style("font-weight", "bold")
      }
    
      private setVars() {
        const colorScale = scaleOrdinal<string>()
          .domain(this.config.colorDomain)
          .range(schemePastel2)
    
        const radiusScale = scaleSqrt().domain([0, 5e8]).range([5, 60])
        const xScale = d3Fisheye
          .scale(scaleLog)
          .domain([200, 1e5])
          .range([0, this.width]) as FishEyeScale
        const yScale = d3Fisheye
          .scale(scaleLinear)
          .domain([20, 90])
          .range([height, 0]) as FishEyeScale
    
        this.vars = {
          colorScale,
          focused: false,
          radiusScale,
          xScale,
          yScale,
        }
      }
    
      private setAxis() {
        const formatFn = format(",d")
    
        this.dom.xAxis = axisBottom(this.vars.xScale as AxisScale<number>)
          .tickFormat((tickNumber) => {
            if (tickNumber < 1000) {
              return formatFn(tickNumber)
            }
    
            const reducedNum = Math.round(tickNumber / 1000)
    
            return `${formatFn(reducedNum)}k`
          })
          .tickSize(-height)
        this.dom.yAxis = axisLeft(this.vars.yScale as AxisScale<number>).tickSize(
          -this.width
        )
        this.dom.svgG
          .append("g")
          .attr("class", `x ${styles.axis}`)
          .attr("transform", `translate(0,${height})`)
          .call(this.dom.xAxis)
        this.dom.svgG
          .append("g")
          .attr("class", `y ${styles.axis}`)
          .call(this.dom.yAxis)
      }
    
      private setBackground() {
        return this.dom.svgG.append("rect").attr("class", styles.background)
      }
    
      private setLabels() {
        this.dom.svgG
          .append("text")
          .attr("class", "x label")
          .attr("text-anchor", "middle")
          .text(this.config.xAxisLabel)
    
        this.dom.svgG
          .append("text")
          .attr("class", "y label")
          .attr("text-anchor", "middle")
          .attr("x", -height / 2)
          .attr("y", -40)
          .attr("dy", ".75em")
          .attr("transform", "rotate(-90)")
          .text(this.config.yAxisLabel)
      }
    
      private position(animationDuration: number) {
        this.dom.svgG.attr(
          "transform",
          `translate(${
            margin.left - (this.isSmallDevice() ? LEFT_OFFSET_SMALL_DEVICE : 0)
          },${margin.top})`
        )
        this.dom
          // Sort the circles by radius, so the largest circles appear below
          .dot!.sort(
            (...[chartItemA, chartItemB]) =>
              this.config.getRadiusValue(chartItemB) -
              this.config.getRadiusValue(chartItemA)
          )
          .transition()
          .duration(animationDuration)
          .attr("cx", (chartItem) => {
            const xValue = this.config.getXValue(chartItem)
    
            return this.vars.xScale(xValue) as number
          })
          .attr("cy", (chartItem) => {
            const yValue = this.config.getYValue(chartItem)
    
            return this.vars.yScale(yValue) as number
          })
          .attr("r", (chartItem) => {
            const radiusValue = this.config.getRadiusValue(chartItem)
    
            return (
              this.vars.radiusScale(radiusValue) / (this.isSmallDevice() ? 2 : 1)
            )
          })
        this.dom.xAxis!.ticks(this.isSmallDevice() ? 2 : undefined)
        this.dom.svgG
          .select<SVGGElement>(`.x.${styles.axis}`)
          .transition()
          .duration(animationDuration)
          .call(this.dom.xAxis!)
        this.dom.svgG
          .select<SVGGElement>(`.y.${styles.axis}`)
          .transition()
          .duration(animationDuration)
          .call(this.dom.yAxis!)
      }
    
      private setDots() {
        this.dom.dot = this.dom.svgG
          .append("g")
          .attr("class", "dots")
          .selectAll(".dot")
          .data<ChartData>(this.config.chartItems)
          .enter()
          .append("circle")
          .attr("class", "dot")
          .style("fill", (chartItem) => {
            const colorValue = this.config.getColorValue(chartItem)
    
            return this.vars.colorScale(colorValue)
          })
          .style("stroke", "black")
          .style('"stroke-width"', "1px")
    
        this.position(0)
      }
    
      private setTitles() {
        this.dom.dot!.append("title").attr("class", "dot-title")
        this.updateTitles()
      }
    
      private setZoom({
        animationDuration,
        distortion,
        focus,
      }: {
        animationDuration: number
        distortion: number
        focus: [number, number]
      }) {
        this.vars.xScale.distortion(distortion).focus(focus[0])
        this.vars.yScale.distortion(distortion).focus(focus[1])
        this.position(animationDuration)
      }
    
      private updateTitles() {
        this.dom
          .dot!.selectAll<SVGTitleElement, ChartData>(".dot-title")
          .text((chartItem) => this.config.getCircleTitle(chartItem))
    
        this.dom.svgG
          .select<SVGTitleElement>(`.${styles.chartTitle}`)
          .text(
            this.isSmallDevice()
              ? this.config.titles.short
              : this.config.titles.long
          )
      }
    
      private zoom({
        animationDuration,
        interactionEvent,
      }: {
        animationDuration: number
        interactionEvent: Event
      }) {
        const focus = pointerD3(interactionEvent)
    
        this.setZoom({
          animationDuration,
          distortion: 2.5,
          focus,
        })
      }
    
      private setPointer() {
        this.dom.pointer = this.dom.svgG
          .append("text")
          .text("+")
          .attr("class", styles.pointer)
      }
    
      private bindMousemove() {
        return this.dom.svgG.on("mousemove", (interactionEvent) => {
          if (FishEyeChart.isTouchDevice()) {
            return
          }
    
          if (!this.vars.focused) {
            this.zoom({
              animationDuration: 0,
              interactionEvent,
            })
          }
        })
      }
    
      private bindMouseLeave() {
        return this.dom.svgG.on("mouseleave", () => {
          if (!this.vars.focused) {
            this.setZoom({
              animationDuration: 1000,
              distortion: 0,
              focus: [0, 0],
            })
          }
        })
      }
    
      private bindClick() {
        this.dom.svgG.on("click", (interactionEvent: Event) => {
          const isTouchDevice = FishEyeChart.isTouchDevice()
    
          if (!isTouchDevice) {
            this.vars.focused = !this.vars.focused
    
            if (this.vars.focused) {
              const pointer = pointerD3(this)
    
              this.dom
                .pointer!.attr("x", pointer[0])
                .attr("y", pointer[1])
                .style("opacity", 1)
    
              return
            }
          }
    
          this.dom.pointer!.style("opacity", 0)
    
          this.zoom({
            animationDuration: isTouchDevice ? 1000 : 0,
            interactionEvent,
          })
        })
      }
    
      private updateDimensions(animationDuration = 0) {
        this.setupRootEl()
    
        const isSmallDevice = this.isSmallDevice()
        const widthOffset = isSmallDevice ? LEFT_OFFSET_SMALL_DEVICE : 0
        const totalWidth = this.width + widthOffset
    
        this.dom.svg
          .attr("width", this.width + margin.left + margin.right)
          .attr("height", height + margin.top + margin.bottom)
        this.dom.svgG
          .select(`.${styles.chartTitle}`)
          .attr("transform", `translate(${totalWidth / 2},-40)`)
    
        this.dom.svgG
          .select(`.${styles.background}`)
          .attr("width", this.width)
          .attr("height", height)
    
        this.dom.svgG
          .select(".x.label")
          .attr("y", height + 26)
          .attr("x", this.width / 2)
    
        this.vars.xScale.range([0, totalWidth])
        this.updateTitles()
        this.position(animationDuration)
      }
    
      private bindResize() {
        window.addEventListener("resize", () => {
          this.updateDimensions()
        })
      }
    }
    
    export { FishEyeChart, FishEyeChartOpts }
    
  • src/demos/fish-eye/fish-eye-config.ts | Code in Github | TypeScript coverage report

    import { json } from "d3"
    
    import { FishEyeChartOpts } from "./fish-eye-chart"
    import { CONTAINER_ID } from "./ui-constants"
    
    const RANDOM_UPDATE_ID = "random-update"
    
    type IncomeMetric = {
      income: number
      lifeExpectancy: number
      name: string
      population: number
      region: string
    }
    
    const fetchData = async () =>
      json(`${ROOT_PATH}data/d3js/fish-eye/data.json`) as Promise<IncomeMetric[]>
    
    type Opts = FishEyeChartOpts<IncomeMetric>
    
    const getXValue: Opts["getXValue"] = (incomeMetric) => incomeMetric.income
    const getYValue: Opts["getYValue"] = (incomeMetric) =>
      incomeMetric.lifeExpectancy
    const getRadiusValue: Opts["getRadiusValue"] = (incomeMetric) =>
      incomeMetric.population
    const getColorValue: Opts["getColorValue"] = (incomeMetric) =>
      incomeMetric.region
    
    const humanizeNumber = (initialN: number): string => {
      let numStr = initialN.toFixed(0)
    
      while (true) {
        const numStrFormatted = numStr.replace(/(\d)(\d{3})($|,|\.)/g, "$1,$2$3")
    
        if (numStrFormatted === numStr) {
          break
        }
    
        numStr = numStrFormatted
      }
    
      return numStr
    }
    
    const getCircleTitle: Opts["getCircleTitle"] = (incomeMetric) =>
      `${incomeMetric.name}:\n- Income: ${humanizeNumber(
        incomeMetric.income
      )} $/P.C.\n` +
      `- Population: ${humanizeNumber(incomeMetric.population)}\n` +
      `- Life expectancy: ${incomeMetric.lifeExpectancy} years`
    
    const regions = [
      "Sub-Saharan Africa",
      "South Asia",
      "Middle East & North Africa",
      "America",
      "Europe & Central Asia",
      "East Asia & Pacific",
    ]
    
    const longTitle =
      "Income Per Capita vs " +
      "Life Expectancy vs Population vs Region - 180 Countries"
    const shortTitle = "Income Per Capita vs Life Expectancy"
    
    const xAxisLabel = "income per capita, inflation-adjusted (dollars)"
    const yAxisLabel = "life expectancy (years)"
    
    const getChartConfig = (incomeMetrics: IncomeMetric[]): Opts => ({
      chartItems: incomeMetrics,
      colorDomain: regions,
      getCircleTitle,
      getColorValue,
      getRadiusValue,
      getXValue,
      getYValue,
      rootElId: CONTAINER_ID,
      titles: {
        long: longTitle,
        short: shortTitle,
      },
      xAxisLabel,
      yAxisLabel,
    })
    
    export { RANDOM_UPDATE_ID, fetchData, getChartConfig }
    
  • src/demos/fish-eye/ui-constants.ts | Code in Github | TypeScript coverage report

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

    import React from "react"
    
    import { DemoPageProps } from "@/common"
    
    import Demo from "@/components/demo"
    
    import main, { CONTAINER_ID, RANDOM_UPDATE_ID } from "@/demos/fish-eye/fish-eye"
    
    const FishEye = ({ pageContext }: DemoPageProps) => (
      <Demo main={main} pageContext={pageContext}>
        <div style={{ marginBottom: 20, textAlign: "center" }}>
          <button className="btn btn-primary" id={RANDOM_UPDATE_ID}>
            Update random values
          </button>
        </div>
        <div id={CONTAINER_ID} />
      </Demo>
    )
    
    export default FishEye
    
  • src/demos/fish-eye/fish-eye.module.css | Code in Github

    .fishEyeChart {
      text {
        font: 10px sans-serif;
        text-shadow: 1px 1px 1px #ccc;
      }
    
      .axis path,
      .axis line {
        fill: none;
        shape-rendering: crispedges;
        stroke: #eee;
      }
    
      .background {
        fill: none;
        pointer-events: all;
      }
    
      .chartTitle {
        font-size: 14px;
      }
    
      .pointer {
        fill: #7aae61;
        font-size: 15px;
        opacity: 0;
      }
    }