Skip to content

Instantly share code, notes, and snippets.

@volodalexey
Forked from tommaybe/LICENSE.md
Last active November 17, 2023 16:22
Show Gist options
  • Save volodalexey/2028d658ec6aeebee7b684e3e05f8f1a to your computer and use it in GitHub Desktop.
Save volodalexey/2028d658ec6aeebee7b684e3e05f8f1a to your computer and use it in GitHub Desktop.
D3 v5 ES6 Heatmap (optimized for React or Vue)

Heatmap Forked from https://gist.github.com/tjdecke/5558084

optimized for React or Vue

It means you can invoke renderChart() as many times as you wish.

E.g. in React:

import React, { Component } from 'react'
import { renderChart, destroyChart } from '...'

class Chart extends Component {
  ...
  render () {
    return <div ref={(el) => this.$wrapper = el}/>
  }
  
  componentDidUpdate () {
    renderChart(this.$wrapper, this.props.data)
  }
  
  componentWillUnmount () {
    destroyChart(this.$wrapper)
  }
  ...
}

E.g. in Vue:

<template>
  <div ref="$wrapper"></div>
</template>

<script>
import { renderChart, destroyChart } from '...'

export default {
  name: 'chart',
  ...
  watch: {
    data () {
      renderChart(this.$refs.$wrapper, this.data)
    }
  },
  beforeDestroy () {
    destroyChart(this.$refs.$wrapper)
  }
  ...
}
</script>
<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='UTF-8'>
<title>D3 v5 ES6 Heatmap (optimized for React or Vue)</title>
<script src='//d3js.org/d3.v5.min.js'></script>
</head>
<body>
<div id='wrapper'></div>
<script>
function renderChart (wrapper, curData) {
if (!wrapper) {
return
}
const {
select: d3Select, scaleBand: d3ScaleBand,
scaleLinear: d3ScaleLinear, rgb: d3Rgb,
axisBottom: d3AxisBottom, axisLeft: d3AxisLeft,
} = d3
const parsedDaysHours = curData.reduce((memo, item) => {
if (!memo.weekDays.includes(item.day)) {
memo.weekDays.push(item.day)
}
if (!memo.hours.includes(item.hour)) {
memo.hours.push(item.hour)
}
return memo
}, { weekDays: [], hours: [] })
const margin = { top: 0, right: 0, bottom: 50, left: 50 }
const width = 800 - margin.left - margin.right
const height = 500 - margin.top - margin.bottom
const DURATION = 2000
const xGridSize = Math.floor(width / parsedDaysHours.weekDays.length)
const yGidSize = Math.floor(height / parsedDaysHours.hours.length)
const svgData = d3Select(wrapper).selectAll('svg').data(['dummy data'])
const svgEnter = svgData.enter().append('svg')
svgEnter.attr("width", width + margin.left + margin.right)
svgEnter.attr("height", height + margin.top + margin.bottom)
const gEnter = svgEnter.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")")
.attr('class', 'heatmap')
const svgMerge = svgData.merge(svgEnter)
const gMerge = svgMerge.selectAll('g.heatmap')
const freeColor = '#00ff00'
const loadColor = '#ff0000'
const colorScale = d3ScaleLinear()
.domain([0, 100]).range([d3Rgb(freeColor), d3Rgb(loadColor)])
const xScale = d3ScaleBand()
.domain(parsedDaysHours.weekDays)
.range([0, parsedDaysHours.weekDays.length * xGridSize])
const xAxis = d3AxisBottom(xScale)
gEnter.append("g")
.attr('class', 'x')
.attr('transform', `translate(0, ${height})`)
gMerge
.select('g.x')
.transition()
.duration(DURATION)
.call(xAxis)
const yScale = d3ScaleBand()
.domain(parsedDaysHours.hours)
.range([height, 0])
const yAxis = d3AxisLeft(yScale)
gEnter.append("g")
.attr('class', 'y')
gMerge
.select('g.y')
.transition()
.duration(DURATION)
.call(yAxis)
const tooltipData = d3Select(wrapper)
.selectAll('div').data(['dummy data'])
const tooltipEnter = tooltipData.enter()
.append('div')
.attr('class', 'tooltip')
.style('background', '#ffffff')
.style('color', '#000000')
.style('display', 'none')
.style('top', 0)
.style('left', 0)
.style('padding', '10px')
.style('position', 'absolute')
const tooltipMerge = tooltipData.merge(tooltipEnter)
const heatBoxData = gMerge.selectAll('g.hour')
.data(curData, (d) => `${d.day}:${d.hour}`)
heatBoxData.exit().remove()
const heatBoxEnter = heatBoxData.enter()
.append('g')
.attr('class', 'hour')
heatBoxEnter.append("rect")
.attr("x", (d) => xScale(d.day))
.attr("y", (d) => yScale(d.hour))
.attr("rx", 4)
.attr("ry", 4)
.attr("width", xGridSize)
.attr("height", yGidSize)
.attr("stroke", '#000000')
.attr("stroke-width", 1)
.style("fill", freeColor)
const heatBoxMerge = heatBoxData.merge(heatBoxEnter)
heatBoxMerge.select('rect')
.transition().duration(DURATION)
.style("fill", (d) => colorScale(d.value))
heatBoxEnter.append('text')
.style('font-size', '10px')
.attr('text-anchor', 'middle')
.attr('alignment-baseline', 'middle')
.attr("x", (d) => xScale(d.day) + xGridSize/2)
.attr("y", (d) => yScale(d.hour) + yGidSize/2)
heatBoxMerge.select('text')
.text((d) => d.value)
heatBoxMerge
.on('mouseover', (d) => {
tooltipMerge
.text(`${d.value} at ${d.day} ${d.hour}`)
.style('box-shadow', `0 0 5px ${colorScale(d.value)}`)
.style('border', `1px solid ${colorScale(d.value)}`)
.style('display', 'block')
})
.on('mousemove', () => {
tooltipMerge
.style('top', (d3.event.layerY + 10) + 'px') // always 10px below the cursor
.style('left', (d3.event.layerX + 10) + 'px'); // always 10px to the right of the mouse
})
.on('mouseout', () => {
tooltipMerge.style('display', 'none')
})
}
function destroyChart (wrapper) {
const {select: d3Select} = d3
d3Select(wrapper).selectAll('*').remove()
}
document.addEventListener('DOMContentLoaded', () => {
function generateDayData (day) {
const times = ["01am", "02am", "03am", "04am", "05am", "06am", "07am", "08am", "09am", "10am", "11am", "12am", "01pm", "02pm", "03pm", "04pm", "05pm", "06pm", "07pm", "08pm", "09pm", "10pm", "11pm", "12pm"]
return times.map(hour => { return { day, value: Math.random()*100, hour }})
}
setInterval(() => {
renderChart(document.querySelector('#wrapper'), [
...generateDayData('Monday'),
...generateDayData('Tuesday'),
...generateDayData('Wednesday'),
...generateDayData('Thursday'),
...generateDayData('Friday'),
...generateDayData('Saturday'),
...generateDayData('Sunday'),
])
}, 5000)
renderChart(document.querySelector('#wrapper'), [
...generateDayData('Monday'),
...generateDayData('Tuesday'),
...generateDayData('Wednesday'),
...generateDayData('Thursday'),
...generateDayData('Friday'),
...generateDayData('Saturday'),
...generateDayData('Sunday'),
])
})
</script>
</body>
</html>
@volodalexey
Copy link
Author

The whole heatMap.ts in one file:

import { select, scaleBand, scaleLinear, rgb, axisBottom, axisLeft, RGBColor } from 'd3';

type TOptions = { width?: number; height?: number; margin?: { top?: number; right?: number; bottom?: number; left?: number } };

export function renderChart(
  wrapper: HTMLElement,
  curData: TWeekData,
  { width, height, margin = { top: 0, right: 0, bottom: 50, left: 50 } }: TOptions = {
    width: wrapper.offsetWidth > 0 ? wrapper.offsetWidth : 800,
    height: wrapper.offsetHeight > 0 ? wrapper.offsetHeight : 500,
  },
) {
  const parsedDaysHours: { weekDays: TWeekDay[]; hours: string[] } = curData.reduce(
    (memo, item) => {
      if (!memo.weekDays.includes(item.day)) {
        memo.weekDays.push(item.day);
      }
      if (!memo.hours.includes(item.hour)) {
        memo.hours.push(item.hour);
      }
      return memo;
    },
    { weekDays: [], hours: [] },
  );
  const outerWidth = width - margin.left - margin.right;
  const outerHeight = height - margin.top - margin.bottom;
  const DURATION = 2000;
  const xGridSize = Math.floor(outerWidth / parsedDaysHours.weekDays.length);
  const yGidSize = Math.floor(outerHeight / parsedDaysHours.hours.length);

  const svgData = select(wrapper).selectAll<SVGSVGElement, string[]>('svg').data(['dummy data']);
  const svgEnter = svgData.enter().append('svg');
  svgEnter.attr('width', outerWidth + margin.left + margin.right);
  svgEnter.attr('height', outerHeight + margin.top + margin.bottom);
  const gEnter = svgEnter
    .append('g')
    .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')')
    .attr('class', 'heatmap');
  const svgMerge = svgData.merge(svgEnter);
  const gMerge = svgMerge.selectAll<SVGGElement, string[]>('g.heatmap');

  const freeColor = '#00ff00';
  const loadColor = '#ff0000';
  const colorScale = scaleLinear<RGBColor>()
    .domain([0, 100])
    .range([rgb(freeColor), rgb(loadColor)]);

  const xScale = scaleBand<TWeekDay>()
    .domain(parsedDaysHours.weekDays)
    .range([0, parsedDaysHours.weekDays.length * xGridSize]);

  const xAxisGenerator = axisBottom(xScale);

  gEnter.append('g').attr('class', 'x').attr('transform', `translate(0, ${outerHeight})`);
  gMerge.select<SVGGElement>('g.x').transition().duration(DURATION).call(xAxisGenerator);

  const yScale = scaleBand().domain(parsedDaysHours.hours).range([outerHeight, 0]);

  const yAxisGenerator = axisLeft(yScale);

  gEnter.append('g').attr('class', 'y');
  gMerge.select<SVGGElement>('g.y').transition().duration(DURATION).call(yAxisGenerator);

  const tooltipData = select(wrapper).selectAll<HTMLDivElement, string[]>('div').data(['dummy data']);

  const tooltipEnter = tooltipData
    .enter()
    .append('div')
    .attr('class', 'tooltip')
    .style('background', '#ffffff')
    .style('color', '#000000')
    .style('display', 'none')
    .style('top', 0)
    .style('left', 0)
    .style('padding', '10px')
    .style('position', 'absolute');

  const tooltipMerge = tooltipData.merge(tooltipEnter);

  const heatBoxData = gMerge.selectAll<SVGGElement, THourData>('g.hour').data(curData, (d) => `${d.day}:${d.hour}`);

  heatBoxData.exit().remove();

  const heatBoxEnter = heatBoxData.enter().append('g').attr('class', 'hour');

  heatBoxEnter
    .append('rect')
    .attr('x', (d) => xScale(d.day))
    .attr('y', (d) => yScale(d.hour))
    .attr('rx', 4)
    .attr('ry', 4)
    .attr('width', xGridSize)
    .attr('height', yGidSize)
    .attr('stroke', '#000000')
    .attr('stroke-width', 1)
    .style('fill', freeColor);

  const heatBoxMerge = heatBoxData.merge(heatBoxEnter);
  heatBoxMerge
    .select<SVGRectElement>('rect')
    .transition()
    .duration(DURATION)
    .style('fill', (d) => colorScale(d.value).toString());

  heatBoxEnter
    .append('text')
    .style('font-size', '10px')
    .attr('text-anchor', 'middle')
    .attr('alignment-baseline', 'middle')
    .attr('x', (d) => xScale(d.day) + xGridSize / 2)
    .attr('y', (d) => yScale(d.hour) + yGidSize / 2);

  heatBoxMerge.select('text').text((d) => d.value);

  heatBoxMerge
    .on('mouseover', (_, d) => {
      tooltipMerge
        .text(`${d.value} at ${d.day} ${d.hour}`)
        .style('box-shadow', `0 0 5px ${colorScale(d.value)}`)
        .style('border', `1px solid ${colorScale(d.value)}`)
        .style('display', 'block');
    })
    .on('mousemove', (event: MouseEvent) => {
      const tooltipElement = tooltipMerge.node();
      tooltipMerge
        .style('top', event.offsetY - tooltipElement.offsetHeight + 'px') // always below the cursor
        .style('left', event.offsetX - tooltipElement.offsetWidth / 2 + 'px'); // always to the right of the mouse
    })
    .on('mouseout', () => {
      tooltipMerge.style('display', 'none');
    });
}

export function destroyChart(wrapper: HTMLElement) {
  select(wrapper).selectAll('*').remove();
}

type TWeekDay = 'Monday' | 'Tuesday' | 'Wednesday' | 'Thursday' | 'Friday' | 'Saturday' | 'Sunday';

type THourData = { day: TWeekDay; value: number; hour: string };

type TDayData = THourData[];

type TWeekData = THourData[];

function generateDayData(day: TWeekDay): TDayData {
  const times = [
    '01am',
    '02am',
    '03am',
    '04am',
    '05am',
    '06am',
    '07am',
    '08am',
    '09am',
    '10am',
    '11am',
    '12am',
    '01pm',
    '02pm',
    '03pm',
    '04pm',
    '05pm',
    '06pm',
    '07pm',
    '08pm',
    '09pm',
    '10pm',
    '11pm',
    '12pm',
  ];
  return times.map((hour) => {
    return { day, value: Number((Math.random() * 100).toFixed(2)), hour };
  });
}

export function generateWeekData(): TWeekData {
  return [
    ...generateDayData('Monday'),
    ...generateDayData('Tuesday'),
    ...generateDayData('Wednesday'),
    ...generateDayData('Thursday'),
    ...generateDayData('Friday'),
    ...generateDayData('Saturday'),
    ...generateDayData('Sunday'),
  ];
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment