Brady Dowling
Brady Dowling

Reputation: 5562

Offset x-axis ticks with scaleTime

I'm trying to figure out how I can offset the x-axis ticks, as shown in this example, to be in the center of the bar when the x-axis uses scaleTime.

x-axis ticks offset

Right now, I'm applying a transform to the axis when I append it to the svg as such:

// x-axis code
const x = d3.scaleTime()
    .domain([d3.min(dates), d3.max(dates)])
    .range([margin.left, width - margin.right]);

const x_axis = d3.axisBottom()
    .scale(x);

...

// offsetting the axis horizontally when I append it with bandwidth / 2
svg.append('g')
    .attr('transform', `translate(${bandwidth / 2},${height - margin.bottom})`)
    .call(x_axis);

But this feels hacky and leaves space between the x-axis and the y-axis.

enter image description here

It seems like the example I mentioned has this right because it's not using scaleTime but once scaleTime comes into the picture then things get bad. How can offset my scaleTime ticks so they line up with the middle of my bars?

Full code below:

import * as d3 from 'd3';
import rawData from './data/readings.json';
import {
    barSpacing,
    margin,
    getBandwidth,
} from './helpers';

const width = 1000;
const height = 500;
const animationDurationRatio = 5;

const barStyle = {
    color: 'steelblue',
    opacity: {
        default: .9,
        hover: 1
    }
};

const getStepData = (data, stepNum) => {
    return data.map((item, i) => {
        const value = i < stepNum ? item.value : 0
        return {
            ...item,
            value
        };
    });
};

const data = rawData.map(item => {
    return {
        date: new Date(item.date),
        value: item.breakfast
    }
});

const dates = data.map(d => d.date);

const x = d3.scaleTime()
    .domain([d3.min(dates), d3.max(dates)])
    .range([margin.left, width - margin.right]);

const y = d3.scaleLinear()
    .domain([0, d3.max(data.map(d => d.value))]).nice()
    .range([height - margin.bottom, margin.top]);

const color = d3.scaleSequential(d3.interpolateRdYlGn)
  .domain([140, 115]);
  // Got these values using trial and error
  // Still not 100% sure how this domain works

const x_axis = d3.axisBottom()
    .scale(x);

const y_axis = d3.axisLeft()
    .scale(y);

const chartWidth = x.range()[1];
const bandwidth = getBandwidth(chartWidth, data, barSpacing);

const svg = d3.create('svg')
    .attr('width', chartWidth)
    .attr('height', height)
    .attr('font-family', 'sans-serif')
    .attr('font-size', 10)
    .attr('text-anchor', 'end');

const bar = svg.selectAll('g')
    .data(getStepData(data, 0))
    .join('g');

bar.append('rect')
    .attr('fill', d => {
      return color(d.value);
    })
    .attr('opacity', barStyle.opacity.default)
    .attr('x', d => {
      return x(d.date)
    })
    .attr('y', d => y(d.value))
    .attr('width', bandwidth)
    .attr('height', d => y(0) - y(d.value))
    .on('mouseover', function() {
        d3.select(this)
            .transition(30)
            .attr('opacity', barStyle.opacity.hover);
        })
        .on('mouseout', function() {
            d3.select(this)
            .transition()
            .attr('opacity', barStyle.opacity.default);
    });

bar.append('text')
    .attr('fill', 'white')
    .attr('x', (d, i) => x(d.date) + bandwidth / 2)
    .attr('y', d => y(0) - 10)
    .attr('dx', d => `0.${d.value.toString().length * 50}em`)
    .text((d, i) => data[i].value);

svg.append('g')
    .attr('transform', `translate(${bandwidth / 2},${height - margin.bottom})`)
    .call(x_axis);

svg.append('g')
    .attr('transform', `translate(${margin.left},0)`)
    .call(y_axis);

document.querySelector('body').appendChild(svg.node());

function animateBars (data) {
  const bars = svg.selectAll('rect')
    .data(data);
  bars
    .transition()
    .ease(d3.easeLinear)
    .duration(d => animationDurationRatio * d.value)
      .attr('y', d => y(d.value))
      .attr('fill', d => {
        return color(d.value);
      })
      .attr('height', d => y(0) - y(d.value));
}

animateBars(data)

Upvotes: 1

Views: 857

Answers (2)

Brady Dowling
Brady Dowling

Reputation: 5562

Because scaleTime is a continuous scale, not a banded one, this isn't supported without some sort of hack or workaround (see workarounds below).


Note: Perhaps one reason for this is that some think bar charts aren't a good fit for time scales. Instead time is better visualized with a line chart or an area chart.


Solution

The best solution for this is to switch to scaleBand or something else that supports discrete bands (here's an example).

Workarounds

One workaround is to make the first bar half the size and then offset all bars by half their width:

    .attr('x', d => {
      return x(d.date) - bandwidth / 2
    })
    .attr('width', (d, i) => i === 0 ? bandwidth / 2 : bandwidth)

Other hacks/workarounds might include adding an extra day, hiding the first tick, or messing with the axis offset.


Source: https://observablehq.com/@d3/stacked-bar-chart#comment-af5453e2ab24d987

Upvotes: 1

Zarko
Zarko

Reputation: 30

You need to translate both the axes with the same x units.

svg.append('g')
.attr('transform', `translate(${margin.left},${height-margin.bottom})`)
.call(x_axis);

svg.append('g')
.attr('transform', `translate(${margin.left},0)`)
.call(y_axis);

const numberOfTicks = 5;
const x_axis = d3.axisBottom()
    .scale(x)
    .ticks(numberOfTicks - 1);

You can set the number of ticks in x-axis. The number of ticks generated on graph will be numberOfTicks + 1

Upvotes: 0

Related Questions