javascriptreactjsd3.jsvisualizationarea-chart

d3.js ticks on xAxis too close together


My problem is when i have a lot of data, the ticks on xAxis displaying too close together.

Expected result: Screenshot

What i have: Screenshot

My 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;
    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 xValue = (d: DataPoint) => new Date(d.time);

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

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

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

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

    const yTicksCount = 5;
    const yTicks = d3.ticks(0, yMax, yTicksCount).map(Number);

    const yAxis = yMax <= 5 ? d3.axisLeft(yScale).tickValues(yTicks).tickFormat(d3.format('.0f')) : d3.axisLeft(yScale);

    svg.selectAll('*').remove()

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

    const line = d3
      .line<DataPoint>()
      .x((d: DataPoint) => xScale(d.time))
      .y((d: DataPoint) => yScale(d.value))
      .curve(d3.curveBumpX);

    const xTicksLength = xScale.ticks()

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

    const yTicksLength = yScale.ticks();

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

    svg
      .selectAll('.last-horizontal-line')
      .data(yTicksLength)
      .enter()
      .append('line')
      .attr('class', 'horizontal-line')
      .attr('x1', 0)
      .attr('x2', chartWidth)
      .attr('y1', yTicksLength[yTicksLength.length - 1])
      .attr('y2', yTicksLength[yTicksLength.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);

    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(yAxis)
      .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: DataPoint) => xScale(d.time))
      .attr('cy', (d: DataPoint) => 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: MouseEvent, d: DataPoint) => {
        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>
  );
}

My data example, i'm using this data on expected result screenshot:

[
    {
        "time": "2023-11-09T00:00:00.000Z",
        "value": 0
    },
    {
        "time": "2023-11-10T00:00:00.000Z",
        "value": 0
    },
    {
        "time": "2023-11-11T00:00:00.000Z",
        "value": 0
    },
    {
        "time": "2023-11-12T00:00:00.000Z",
        "value": 0
    },
    {
        "time": "2023-11-13T00:00:00.000Z",
        "value": 0
    },
    {
        "time": "2023-11-14T00:00:00.000Z",
        "value": 0
    },
    {
        "time": "2023-11-15T00:00:00.000Z",
        "value": 0
    },
    {
        "time": "2023-11-16T00:00:00.000Z",
        "value": 0
    },
    {
        "time": "2023-11-17T00:00:00.000Z",
        "value": 0
    },
    {
        "time": "2023-11-18T00:00:00.000Z",
        "value": 0
    },
    {
        "time": "2023-11-19T00:00:00.000Z",
        "value": 0
    },
    {
        "time": "2023-11-20T00:00:00.000Z",
        "value": 0
    },
    {
        "time": "2023-11-21T00:00:00.000Z",
        "value": 1
    },
    {
        "time": "2023-11-22T00:00:00.000Z",
        "value": 1
    },
    {
        "time": "2023-11-23T00:00:00.000Z",
        "value": 0
    },
    {
        "time": "2023-11-24T00:00:00.000Z",
        "value": 0
    },
    {
        "time": "2023-11-25T00:00:00.000Z",
        "value": 0
    }
]

I'm tried to change xMin and xMax values and use it in domain, also i tried to use extent function but every time i have the same result.


Solution

  • The solution is:

    1. Remove xScale nice() method
    const xScale = d3.scaleTime()
          .domain(d3.extent([...mainData, ...(secondaryData || [])], xValue) as [Date, Date])
          .range([0, chartWidth])
    
    1. Remove yScale nice() method:
     const yScale = d3.scaleLinear()
          .domain([0, yMax])
          .range([chartHeight, 0])
    
    1. Add ticks calculation
    const ticksAmount = 8;
        const tickStep = (maxValue - minValue) / (ticksAmount);
        const step = Math.ceil(tickStep / 5) * 5;
        const xAxis =
          d3.axisBottom(xScale)
            .ticks(ticksAmount)
            .tickValues(d3.range(minValue, maxValue + step, step))
            .tickFormat(d3.timeFormat(timeFormat))
            .tickSizeInner(-chartWidth)
            .tickSizeOuter(0)
            .tickPadding(5);