reactjsd3.jsstacked-area-chartarea-chart

D3.js AreaChart incorrect graphic plotting


My friends! I have the AreaChart React component. If i'm using data which date starts with time u can divide by three it's ok (like 09:00, 12:00 etc.), but if date starts with time u can't divide by 3 (like 10:00, 14:00 etc.), domain start to shift to the right or left sides.

Correct data:

const correctDataBy3 = [
  { time: new Date(2023, 10, 25, 18, 0, 0, 0), value: 230 },
  { time: new Date(2023, 10, 25, 19, 0, 0, 0), value: 10 },
  { time: new Date(2023, 10, 25, 20, 0, 0, 0), value: 341 },
  { time: new Date(2023, 10, 25, 21, 0, 0, 0), value: 23 },
  { time: new Date(2023, 10, 25, 22, 0, 0, 0), value: 10 },
  { time: new Date(2023, 10, 25, 23, 0, 0, 0), value: 45 },
  { time: new Date(2023, 10, 26, 0, 0, 0, 0), value: 24 },
  { time: new Date(2023, 10, 26, 1, 0, 0, 0), value: 450 },
  { time: new Date(2023, 10, 26, 2, 0, 0, 0), value: 23 },
  { time: new Date(2023, 10, 26, 3, 0, 0, 0), value: 24 },
  { time: new Date(2023, 10, 26, 4, 0, 0, 0), value: 15 },
  { time: new Date(2023, 10, 26, 5, 0, 0, 0), value: 24 },
  { time: new Date(2023, 10, 26, 6, 0, 0, 0), value: 45 },
  { time: new Date(2023, 10, 26, 7, 0, 0, 0), value: 83 },
  { time: new Date(2023, 10, 26, 8, 0, 0, 0), value: 24 },
  { time: new Date(2023, 10, 26, 9, 0, 0, 0), value: 30 },
  { time: new Date(2023, 10, 26, 10, 0, 0, 0), value: 180 },
  { time: new Date(2023, 10, 26, 11, 0, 0, 0), value: 105 },
  { time: new Date(2023, 10, 26, 12, 0, 0, 0), value: 45 },
  { time: new Date(2023, 10, 26, 13, 0, 0, 0), value: 230 },
  { time: new Date(2023, 10, 26, 14, 0, 0, 0), value: 15 },
  { time: new Date(2023, 10, 26, 15, 0, 0, 0), value: 10 },
  { time: new Date(2023, 10, 26, 16, 0, 0, 0), value: 0 },
  { time: new Date(2023, 10, 26, 17, 0, 0, 0), value: 0 },
  { time: new Date(2023, 10, 26, 18, 0, 0, 0), value: 0 }
];

Incorrect data:

const incorrectDataNotBy3 = [
  { time: new Date(2023, 10, 25, 19, 0, 0, 0), value: 230 },
  { time: new Date(2023, 10, 25, 20, 0, 0, 0), value: 10 },
  { time: new Date(2023, 10, 25, 21, 0, 0, 0), value: 341 },
  { time: new Date(2023, 10, 25, 22, 0, 0, 0), value: 23 },
  { time: new Date(2023, 10, 25, 23, 0, 0, 0), value: 10 },
  { time: new Date(2023, 10, 26, 0, 0, 0, 0), value: 45 },
  { time: new Date(2023, 10, 26, 1, 0, 0, 0), value: 24 },
  { time: new Date(2023, 10, 26, 2, 0, 0, 0), value: 450 },
  { time: new Date(2023, 10, 26, 3, 0, 0, 0), value: 23 },
  { time: new Date(2023, 10, 26, 4, 0, 0, 0), value: 24 },
  { time: new Date(2023, 10, 26, 5, 0, 0, 0), value: 15 },
  { time: new Date(2023, 10, 26, 6, 0, 0, 0), value: 24 },
  { time: new Date(2023, 10, 26, 7, 0, 0, 0), value: 45 },
  { time: new Date(2023, 10, 26, 8, 0, 0, 0), value: 83 },
  { time: new Date(2023, 10, 26, 9, 0, 0, 0), value: 24 },
  { time: new Date(2023, 10, 26, 10, 0, 0, 0), value: 30 },
  { time: new Date(2023, 10, 26, 11, 0, 0, 0), value: 180 },
  { time: new Date(2023, 10, 26, 12, 0, 0, 0), value: 105 },
  { time: new Date(2023, 10, 26, 13, 0, 0, 0), value: 45 },
  { time: new Date(2023, 10, 26, 14, 0, 0, 0), value: 230 },
  { time: new Date(2023, 10, 26, 15, 0, 0, 0), value: 15 },
  { time: new Date(2023, 10, 26, 16, 0, 0, 0), value: 10 },
  { time: new Date(2023, 10, 26, 17, 0, 0, 0), value: 0 },
  { time: new Date(2023, 10, 26, 18, 0, 0, 0), value: 0 },
  { time: new Date(2023, 10, 26, 10, 0, 0, 0), value: 0 },
];

AreaChart component:

import * as d3 from 'd3';
import dayjs from 'dayjs';
import React, { useEffect, useRef } from 'react';

import declOfNum from '../../utils/declOfNum';
import styles from './areachart.module.css';

type AreaChartType = 'date' | 'time';

interface DataPoint {
  /**
   * The time type.
   * */
  time: Date;
  /**
   * The value type.
   * */
  value: number;
}

interface AreaChartProps {
  /**
   * The AreaChart type.
   * */
  areaChartType: AreaChartType;
  /**
   * The AreaColor prop.
   * */
  mainAreaColor: string;
  /**
   * The data prop.
   * */
  mainData: DataPoint[];
  /**
   * The LineColor prop.
   * */
  mainLineColor: string;
  /**
   * The CompetitorArea prop.
   * */
  secondaryAreaColor?: string;
  /**
   * The secondaryData prop.
   * */
  secondaryData?: DataPoint[];
  /**
   * The Competitor line color prop.
   * */
  secondaryLineColor?: string;
}

export function AreaChart({
  areaChartType,
  mainAreaColor,
  mainData,
  mainLineColor,
  secondaryAreaColor,
  secondaryData,
  secondaryLineColor
}: AreaChartProps) {
  const svgRef = useRef<null | SVGSVGElement>(null);

  useEffect(() => {
    if (!svgRef.current) return;

    const margin = { bottom: 40, left: 25, right: 20, top: 20 };
    const chartWidth = svgRef.current.clientWidth - margin.left - margin.right;
    const chartHeight = svgRef.current.clientHeight - margin.top - margin.bottom;

    const svg = d3.select(svgRef.current);

    let timeFormat;

    areaChartType === 'time' ? timeFormat = '%H:%M' : timeFormat = '%d.%m.%Y';

    const xScale = d3
      .scaleUtc()
      .domain([
        d3.min([...mainData, ...(secondaryData || [])], (d) => new Date(d.time) as Date),
        d3.max([...mainData, ...(secondaryData || [])], (d) => new Date(d.time) as Date)
      ])
      .range([0, chartWidth]);

    const xAxis = d3.axisBottom(xScale).tickFormat(d3.timeFormat(timeFormat))

    const yMax = d3.max([...mainData, ...(secondaryData || [])], (d) => d.value) <= 5 ? 5 : d3.max([...mainData, ...(secondaryData || [])], (d) => d.value);

    const yScale = d3.scaleLinear().domain([0, yMax]).nice().range([chartHeight, 0]);

    const area = d3
      .area<DataPoint>()
      .x((d) => xScale(d.time))
      .y0(chartHeight)
      .y1((d) => yScale(d.value))
      .curve(d3.curveCatmullRom.alpha(0.8));

    const line = d3
      .line<DataPoint>()
      .x((d) => xScale(d.time))
      .y((d) => yScale(d.value))
      .curve(d3.curveCatmullRom.alpha(0.8));

    const xTicks = xScale.ticks()

    svg
      .selectAll('.vertical-line')
      .data(xTicks)
      .enter()
      .append('line')
      .attr('class', 'vertical-line')
      .attr('x1', (d) => xScale(new Date(d)))
      .attr('x2', (d) => xScale(new Date(d)))
      .attr('y1', chartHeight + margin.top * 2)
      .attr('y2', 0)
      .attr('stroke', '#E2E2E2')
      .attr('stroke-dasharray', '0')
      .attr('transform', `translate(${margin.left}, ${-margin.top})`);

    svg
      .append('line')
      .attr('class', 'first-line')
      .attr('x1', xTicks[xTicks.length - 1])
      .attr('x2', xTicks[xTicks.length - 1])
      .attr('y1', chartHeight + margin.top * 2)
      .attr('y2', 0)
      .attr('stroke', '#E2E2E2')
      .attr('stroke-dasharray', '0')
      .attr('transform', `translate(${margin.left}, ${-margin.top})`);

    svg
      .append('line')
      .attr('class', 'last-line')
      .attr('x1', xTicks[xTicks.length + 1])
      .attr('x2', xTicks[xTicks.length + 1])
      .attr('y1', chartHeight + margin.top * 2)
      .attr('y2', 0)
      .attr('stroke', '#E2E2E2')
      .attr('stroke-dasharray', '0')
      .attr('transform', `translate(${chartWidth + margin.right + 5}, ${-margin.top})`);

    const yTicks = yScale.ticks();

    svg
      .selectAll('.horizontal-line')
      .data(yTicks)
      .enter()
      .append('line')
      .attr('class', 'horizontal-line')
      .attr('x1', 0)
      .attr('x2', chartWidth)
      .attr('y1', (d) => yScale(d))
      .attr('y2', (d) => yScale(d))
      .attr('stroke', '#E2E2E2')
      .attr('stroke-dasharray', '0')
      .attr('transform', `translate(${margin.left}, ${margin.top})`);

    svg
      .selectAll('.last-horizontal-line')
      .data(yTicks)
      .enter()
      .append('line')
      .attr('class', 'horizontal-line')
      .attr('x1', 0)
      .attr('x2', chartWidth)
      .attr('y1', yTicks[yTicks.length - 1])
      .attr('y2', yTicks[yTicks.length - 1])
      .attr('stroke', '#E2E2E2')
      .attr('stroke-dasharray', '0')
      .attr('transform', `translate(${margin.left}, -5)`);

    const chart = svg.append('g').attr('transform', `translate(${margin.left}, ${margin.top})`);

    chart
      .append('path')
      .datum(mainData)
      .attr('d', area)
      .attr('fill', mainAreaColor);

    chart
      .append('path')
      .datum(mainData)
      .attr('fill', 'none')
      .attr('stroke', mainLineColor)
      .attr('stroke-width', 1)
      .attr('d', line);

    if (secondaryData) {
      chart
        .append('path')
        .datum(secondaryData)
        .attr('d', area)
        .attr('fill', secondaryAreaColor);

      chart
        .append('path')
        .datum(secondaryData)
        .attr('fill', 'none')
        .attr('stroke', secondaryLineColor)
        .attr('stroke-width', 1)
        .attr('d', line);
    }

    chart
      .append('g')
      .attr('class', 'x-axis')
      .attr('transform', `translate(0, ${chartHeight})`)
      .call(xAxis);

    if (xTicks.length > 8) {
      chart.selectAll('.tick:first-of-type text').remove();
      chart.selectAll('.tick:last-of-type text').remove();
    }

    chart
      .selectAll('text')
      .attr('font-family', 'var(--font-default)')
      .attr('font-size', '10px')
      .attr('line-height', '14px')
      .attr('letter-spacing', '0.3px')
      .attr('color', 'var(--old-color-neutral-dark)')
      .attr('display', 'block')
      .attr('text-anchor', 'middle')
      .attr('transform', 'translate(0, 5)');

    chart
      .append('g')
      .attr('class', 'y-axis')
      .call(d3.axisLeft(yScale))
      .attr('font-family', 'var(--font-default)')
      .attr('font-size', '10px')
      .attr('line-height', '14px')
      .attr('letter-spacing', '0.3px')
      .attr('color', 'var(--old-color-neutral-dark)')
      .attr('display', 'block')
      .attr('transform', 'translate(5, 0)');

    chart.selectAll('.domain').attr('display', 'none');
    chart.selectAll('line').attr('display', 'none');

    const tooltip = d3
      .select('body')
      .append('div')
      .attr('class', styles.tooltip)
      .style('opacity', 0);

    const linesGroup = svg
      .append('g')
      .attr('class', 'perpendicular-lines')
      .attr('transform', `translate(${margin.left}, ${margin.top})`);

    chart
      .selectAll('circle')
      .data(mainData)
      .enter()
      .append('circle')
      .attr('cx', (d) => xScale(d.time))
      .attr('cy', (d) => yScale(d.value))
      .attr('r', 1)
      .style('fill', 'white')
      .style('stroke', mainLineColor)
      .style('stroke-width', 5)
      .attr('transform', `translate(0, 0)`)
      .attr('z-index', '1000')
      .attr('opacity', '0')
      .style('cursor', 'pointer')
      .on('mouseover', (event, d) => {
        const x = xScale(d.time);
        const y = yScale(d.value);
        d3.select(event.currentTarget).attr('opacity', 1);
        tooltip.style('opacity', 0.9);
        tooltip
          .style('position', 'absolute')
          .html(`
          <span class=${styles.areachart__tooltip__text}>${d.value} ${declOfNum(d.value, [
            'клик',
            'клика',
            'кликов',
          ])}
          </span >
          <span class=${styles.areachart__tooltip__text}>${dayjs(d.time, { locale: 'ru' }).format('DD MMMM HH:00')}</span>
          `)
          .style('left', `${event.pageX + 28}px`)
          .style('top', `${event.pageY}px`)
          .style('opacity', '1');

        linesGroup
          .selectAll('.perpendicular-line')
          .data([d])
          .join('line')
          .attr('class', 'perpendicular-line')
          .attr('x1', x)
          .attr('x2', x)
          .attr('y1', y)
          .attr('y2', chartHeight)
          .attr('stroke', mainLineColor)
          .attr('stroke-width', 1)
          .attr('stroke-dasharray', '8 8');
      })
      .on('mouseout', () => {
        d3.select(event.currentTarget).attr('opacity', 0);
        tooltip.transition().duration(100).style('opacity', 0);
        linesGroup.selectAll('.perpendicular-line').remove();
      });
  }, [mainData, areaChartType, secondaryAreaColor, secondaryLineColor, mainAreaColor, mainLineColor, secondaryData]);

  return (
    <svg
      ref={svgRef}
      style={{ height: '230px', width: '100%' }}
    >
    </svg>
  );
}

AreaChart with incorrect data screenshot: Incorrect AreaChart screenshot

AreaChart with correct data screenshot: Correct AreaChart screenshot

Thanks a lot to everyone!

I'm tried to change xScale.domain and xScale.range but got no results.


Solution

  • Solution! It was a problem with domain.

    I replaced this part of code:

    const xScale = d3
          .scaleUtc()
          .domain([
            d3.min([...mainData, ...(secondaryData || [])], (d) => new Date(d.time) as Date),
            d3.max([...mainData, ...(secondaryData || [])], (d) => new Date(d.time) as Date)
          ])
          .range([0, chartWidth]);
    

    with:

     const xValue = (d: DataPoint) => d.time;
    
        const xScale = d3.scaleTime()
          .domain(d3.extent([...mainData, ...(secondaryData || [])], xValue) as [Date, Date])
          .range([0, chartWidth])
          .nice();
    

    It's work for me as i expected.