d3 / d3-axis

Human-readable reference marks for scales.
https://d3js.org/d3-axis
ISC License
207 stars 106 forks source link

Ability to use `d3.timeXXX` on a scaleBand #85

Open IPWright83 opened 2 years ago

IPWright83 commented 2 years ago

A fairly common use case is to want to displays bucketed data (bars) along a time axis. d3.scaleBand works really well for this and handles a number of alignment issues if you were to try and use d3.scaleTime to plot bars.

However the downside of d3.scaleBand is that it doesn't allow controlling the tick values anywhere near as easily as d3.scaleTime. I think it would be incredibly useful if we could either add support (or add a new d3.scaleTimeBand) which is able to better accommodate time and filtering on the x-axis. I think being able to call .ticks(d3.timeHour.every(4)) for example on a column chart would be very useful.

I think there's assumptions that need to be made to get this working. The way I envisage this working is by using some sort of automatic filter (like one would normally provide to .tickValues() which checks if the time period matches one provided by a helper function such as d3.timeHour.every(4). If the value isn't an hour, that is a multiple of 4 from the start of the first day in the domain then the tick would be filtered out.

curran commented 2 years ago

Here's an example using scaleTime only, which computes the length of one bar based on time intervals.

https://vizhub.com/curran/14055e4902844acca03ba97d02d1bc72?edit=files&file=index.html#L177

      const now = new Date();
      const oneMonthWidth =
        xScale(d3.timeMonth.ceil(now)) -
        xScale(d3.timeMonth.floor(now));

      const marks = g
        .selectAll('.mark')
        .data(data);
      marks
        .enter()
        .append('rect')
        .attr('class', 'mark')
        .merge(marks)
        .attr('x', (d) => xScale(xValue(d)))
        .attr('y', (d) => yScale(yValue(d)))
        .attr('width', oneMonthWidth)
        .attr(
          'height',
          (d) => innerHeight - yScale(yValue(d))
        )

Here's another example that leverages bin, which has a nice API for getting the min and max values for each bin.

https://vizhub.com/curran/a95f227912474d4a9bbe88a3c6c33ab9?edit=files

Padding is the only "unsolved problem" in this space, but that could be added by adding a constant to X and subtracting double that constant to width.

What I'm saying here is, you can accomplish the thing you describe using the existing D3 API. So I'm not sure the argument to add more API surface area to D3 for this is very strong (but it's a cool idea for sure!).

IPWright83 commented 1 year ago

I'm just revisiting this, I built an example here https://ipwright83.github.io/chart-io/?path=/story/xycharts-mixedplots--mixed-stacked-column-plots and haven't yet managed to crack padding.

If you go for the adding a constant to X, would that not pick the ticks out of whack with the data points? I tried an option tweaking the range/domain to add padding which allows you to render data points fully (but was Jacky to get working) and then left my axis line happy at the start/end :(

Fil commented 1 year ago

I tend to use the same solution as @curran’s, often with a bit of padding to leave a pixel between the bars.

With Plot you would write (https://observablehq.com/@recifs/shifted-time-bars):

Plot.rectY(
  aapl,
  Plot.mapX(
    (d) => d.map((d) => d3.utcDay.offset(d, -14)), // shift bars left by half a month
    Plot.binX(
      { y: "median", title: "min" },
      { title: "Date", x: "Date", y: "Close", interval: "month" }
    )
  )
).plot()

untitled (1)

It would be cool if interval.offset supported fractional offsets, but dealing with time is so difficult that I don't see this happening.

mbostock commented 1 year ago

I don’t recommend using a time axis if you want to represent time as ordinal (i.e., with bars centered over the ticks); offsetting the position by 14 days won’t work well because months are different lengths (28–31 days, and days are 23–25 hours depending on your time zone).

In Plot, I recommend using a band (ordinal) scale, combining barX and groupX:

untitled (57)

Plot.barY(
  aapl,
  Plot.groupX(
    { y: "median", title: "min" },
    { title: "Date", x: "Date", y: "Close" }
  )
).plot({
  marginBottom: 80,
  x: {
    interval: "month",
    tickRotate: 90
  }
})

Plot is smart enough to know that the ordinal scale represents time thanks to the interval option, but unfortunately it’s still not smart enough to reduce the number of ticks shown automatically, or to apply the new multi-line time axis by default. You can get a better result by setting the ticks explicitly:

untitled (58)

Plot.barY(
  aapl,
  Plot.groupX(
    { y: "median", title: "min" },
    { title: "Date", x: "Date", y: "Close" }
  )
).plot({
  x: {
    interval: "month",
    tickFormat: "%Y",
    ticks: d3.utcYears(...d3.extent(aapl, (d) => d.Date))
  }
})

There should be a way to get the equivalent of the time axis for ordinal time scales… I’ll file an issue over in the Plot repo.

IPWright83 commented 1 year ago

Is there any equivalent approach for applying an interval like property in D3? I'm not using Plot, but I might look at the source for some inspiration. A band scale may work, but I'd like to get an axis rendering with the knowledge of time so we don't need every tick

Fil commented 1 year ago

https://github.com/d3/d3-time#_interval

IPWright83 commented 1 year ago

Thanks @Fil, I'll see if I can apply that to my band scale somehow (and also try some padding on a linear scale) and share my results.

mbostock commented 1 year ago

Sure, the same thing in D3 would look like this:

untitled (59)

{
  const width = 640;
  const height = 400;
  const marginTop = 20;
  const marginRight = 20;
  const marginBottom = 30;
  const marginLeft = 40;

  const x = d3.scaleBand()
      .domain(d3.utcMonths(
        d3.min(aapl, (d) => d3.utcMonth(d.Date)),
        d3.max(aapl, (d) => d3.utcMonth.offset(d3.utcMonth(d.Date), 1))))
      .range([marginLeft, width - marginRight]);

  const y = d3.scaleLinear()
      .domain([0, d3.max(aapl, (d) => d.Close)])
      .range([height - marginBottom, marginTop]);

  const svg = d3.create("svg")
      .attr("width", width)
      .attr("height", height)
      .attr("viewBox", [0, 0, width, height])
      .attr("style", "max-width: 100%; height: auto;");

  svg.append("g")
      .attr("transform", `translate(0,${height - marginBottom})`)
      .call(d3.axisBottom(x)
           .tickFormat(d3.utcFormat("%Y"))
           .tickValues(d3.utcYears(...d3.extent(x.domain()))))
      .call((g) => g.select(".domain").remove());

  svg.append("g")
      .attr("transform", `translate(${marginLeft},0)`)
      .call(d3.axisLeft(y))
      .call((g) => g.select(".domain").remove());

  svg.append("g")
    .selectAll()
    .data(d3.rollup(aapl, (D) => d3.median(D, (d) => d.Close), (d) => d3.utcMonth(d.Date)))
    .join("rect")
      .attr("x", ([key]) => x(key) + 0.5)
      .attr("width", x.bandwidth() - 1)
      .attr("y", ([, value]) => y(value))
      .attr("height", ([, value]) => y(0) - y(value));

  return svg.node();
}

https://observablehq.com/d/681d761602f1014d

It’d be a bit harder to get Plot’s multi-line time axis, since d3-axis doesn’t support multi-line text (which is implemented by Plot’s text mark).

Also I think there’s an off-by-one above in some cases: it’s a little tricky to get d3.utcMonths/d3.utcYears etc. to return an inclusive upper bound, since they like to treat the upper bound as exclusive.