how to make responsive labels using D3

Viewed 179

I am working with D3 charts, with react, I created bar chart and made it responsive. When I resize the browser the bars are taking width accordingly.

Issue

One thing I want to a achieve is dynamic label, like when I resize the page, the length of label is bit big so it is getting one over other

Like This

So I want to overcome this, I googled a lot and found One example, there when it is rendered on full screen it looks like This is when full screen

And when I resize the browser to a small screen !it looks like this]3.

For above this is the reference I found

So same I want to achieve but not getting any idea.

What I did

import React, { useRef, useEffect, useState } from 'react';
import { select, axisBottom, axisRight, scaleLinear, scaleBand } from 'd3';
import ResizeObserver from 'resize-observer-polyfill';

const useResizeObserver = (ref) => {  // doing this for resizing
    const [dimensions, setDimensions] = useState(null);
    useEffect(() => {
        const observeTarget = ref.current;
        const resizeObserver = new ResizeObserver((entries) => {
            entries.forEach((entry) => {
                setDimensions(entry.contentRect);
            });
        });
        resizeObserver.observe(observeTarget);
        return () => {
            resizeObserver.unobserve(observeTarget);
        };
    }, [ref]);
    return dimensions;
};

function BarChart({ data }) {
    const svgRef = useRef();
    const wrapperRef = useRef();
    const dimensions = useResizeObserver(wrapperRef);

    // will be called initially and on every data change
    useEffect(() => {
        const svg = select(svgRef.current);

        if (!dimensions) return;

        // scales
        const xScale = scaleBand()
            .domain(data.map((value, index) => index + '2021+01+06'))
            .range([0, dimensions.width]) // change
            .padding(0.5);

        const yScale = scaleLinear()
            .domain([0, 150]) // todo
            .range([dimensions.height, 0]); // change

        // create x-axis

        const xAxis = axisBottom(xScale).ticks(data.length);
        svg
            .select('.x-axis')
            .style('transform', `translateY(${dimensions.height}px)`)
            .call(xAxis);

        // create y-axis
        const yAxis = axisRight(yScale);
        svg
            .select('.y-axis')
            .style('transform', `translateX(${dimensions.width}px)`)
            .call(yAxis);

        // draw the bars
        svg
            .selectAll('.bar')
            .data(data)
            .join('rect')
            .attr('class', 'bar')
            .style('transform', 'scale(1, -1)')
            .attr('x', (value, index) => xScale(index + '2021+01+06'))
            .attr('y', -dimensions.height)
            .attr('width', xScale.bandwidth())

            .transition()
            .attr('fill', '#015e89')
            .attr('height', (value) => dimensions.height - yScale(value));
    }, [data, dimensions]);

    return (
        <div ref={wrapperRef} style={{ marginBottom: '2rem' }}>
            <svg ref={svgRef}>
                <g className="x-axis" />
                <g className="y-axis" />
            </svg>
        </div>
    );
}

export default BarChart;

here is my codesandbox

1 Answers

A common solution is to rotate the labels so they won't overlap when there's no width:

    svg
      .select(".x-axis")
      .selectAll("text")
      .attr("y", 0)
      .attr("x", 0)
      .attr("dy", "2em")
      .attr("transform", "rotate(330)")
      .style("text-anchor", "end");

The benefits of this method is that it's easy to implement and will cover 90% of the cases with some minor tweaking(when you know the data in the labels).
Codesandbox - rotate labels

Edit

I've made an attempt to do what's in the example, it needs a little tweak to work because currently you're generating the labels based on index.

This part is responsible for skipping values on the X scale, I took it from your example and it works as intended. Make sure to set tick width correctly:

    const tickWidth = 80;
    const width = xScale.range()[1];
    const tickN = Math.floor(width / tickWidth);
    const keepEveryNth = Math.ceil(xScale.domain().length / tickN);

    const xScaleDomain = xScale
      .domain()
      .filter((_, i) => i % keepEveryNth === 0);
    xScale.domain(xScaleDomain);

But it will create a visual bug, since you position the bars based on the scale(.attr("x", (value, index) => xScale(index + "2021+01+06"))), but the scale doesn't have that label anymore, so it returns undefined. That leads to the bar being positioned on x = 0.

I've tried filtering out the data for the bars to only include bars that are found on the scale:

    const diplayData = data.filter((_, i) => {
      return xScaleDomain.includes(i + "2021+01+06");
    });

But that won't work, the labels are built with indexes, and indexes are an unreliable way of identification.

Play around with this sandbox, I'm sure you can get what you need from here.
Codesandbox - dynamic ticks

Edit 2

I've got it to work with any data by using the following line when drawing the bars:

      .attr("x", (_, i) => xScale(xScale.domain()[i]))

I've also took the liberty of adding dynamic yScale size:

    const yScale = scaleLinear()
      .domain([0, 1.1 * Math.max(...data.map(({ consumo }) => consumo))])

Edit 3

I think I've achieved what you described. I've added a separate scale for bars, so all bars are always displayed. Labels on the other hand will disappear as less space is available. I'm not sure if this is a good practice to have 2 scales for the same axis.
Make sure to maintain same padding for both of the scales, or else there will be weird offsets.

Here's the final version with the sample data, it should work with any data, as long as you map the items correctly:
Codesandbox - Final version

Related