How to draw area/fill between two curved lines of different lengths (different x values) in d3

Viewed 158

Problem

I am trying to build a line chart where I shade between two lines (different colors based on which line is above the other). This works perfectly for linear curving in all cases tested.

However, this needs to work with actual curving (such as curveBasis, as shown below). This works perfectly if the lines have the exact same x values; but we have cases in which a) one line is longer/shorter than another and/or b) one line may be missing one or more x values in the middle of the line that the other line isn't missing. This is because how a line is drawn between two points changes based on what points come before/after when it's non-linear.

Generally speaking, I understand why this is happening; but I'm having a hard time finding a good solution to make this actually work the way I'd like. I'd love to at least be pointed in the right direction or given some ideas (one idea I considered is listed at the bottom)!

Examples

Here's how it works with curveLinear (looks good):

How it works with curve linear (straight lines)

Here's how it looks with curveBasis if the x values are the same for both lines (looks good):

How it should look

Here's how it actually looks with curveBasis if the x values are not the same for both lines (doesn't look good):

How it currently looks (not good)

Current Code/Strategy

Here's the current strategy (note that I refer to the lines as good/bad, where good line on top results in green fill and bad line on top results in red fill) (some stuff removed, like class names, etc to reduce clutter):

// I also set the domain and range appropriately for x/y--not shown here:
const x = d3.scaleTime();
const y = d3.scaleLinear();

// 1. Draw the lines "normally" (this is in a loop to build each line)
const lineData = d3.line()
  .defined(point => !isNaN(point.y))
  .x(point => x(point.x))
  .y(point => y(point.y))
  .curve(d3[lineCurve]);

d3Chart.append('path')
  .datum(points)
  .attr('d', lineData);

// 2. "Normalize" lines into the following format for each point (logic not shown here): {x, goodY, badY}
// Bind this data to a new svg group
const areaElement = d3Chart.append('g').datum(normlaizedData);

// 3. Clip paths and area paths

// Create the green fill clip path.
const goodLineClipPathId = `good-line-clip-path`;
areaElement.append('clipPath')
    .attr('id', goodLineClipPathId)
    .append('path')
    .attr('d', d3.area()
        .curve(lineCurve)
        .x(point => x(point.x))
        .y0(0)
        .y1(point => y(point.badY))
    );

// Create the red fill clip path.
const badLineClipPathId = `bad-line-clip-path`;
areaElement.append('clipPath')
    .attr('id', badLineClipPathId)
    .append('path')
    .attr('d', d3.area()
        .curve(lineCurve)
        .x(point => x(point.x))
        .y0(height)
        .y1(point => y(point.badY))
    );

// Create the red fill.
areaElement.append('path')
    .attr('clip-path', `url(#${badLineClipPathId})`)
    .attr('d', d3.area()
        .curve(lineCurve)
        .x(point => x(point.x))
        .y0(point => y(point.goodY))
        .y1(point => y(point.badY))
    );

// Create the green fill.
areaElement.append('path')
    .attr('clip-path', `url(#${goodLineClipPathId})`)
    .attr('d', d3.area()
        .curve(lineCurve)
        .x(point => x(point.x))
        .y0(point => y(point.badY))
        .y1(point => y(point.goodY))
    );

Considered Solutions

One idea I had was to "clone" the exact svg lines but cut off the beginning/end (leaving the remainder of the line the same) and use those lines as the top/bottom of the areas (and close the ends with straight vertical lines); but the path data itself makes use of curving, so changing the start/end would still affect the line (unless there's a way around this).

Here's another idea I thought of: don't "normalize" the lines and create additional clipping to "cut off" the ends (at the vertical black lines drawn on the screenshot); but even if I did that, there would still be issues (as indicated by the arrows).

Possible solution--still has problems

2 Answers

This is not a great solution, but I'm posting because it is a solution (or a partial one at least).

Without getting into too much detail, I noticed that there were actually two possible causes of the issue (one or both could cause the gaps or bleeding):

  1. The lines don't start and/or end at the same x values.
  2. The lines don't share a 1 to 1 mapping of x values with actual y values (in other words, at least one of the two lines is "missing" a y value for a corresponding x value that is not missing in the other line).

"Solution"

Note: I've only tested this with curveBasis--other curving types may not work the same way.

  1. "Fill in" the missing y values (fixes cause #2 above)--I did this by linear interpolation between closest left/right non-missing points (if the missing value was at the end or beginning of a line, the closest non-missing point value was re-used). Do this in both your normalized and original lines, but don't interpolate between a point in the original line and point that's not in the normalized line--just re-use in this case (see example below).
  2. "Triplicate" the normalized start/end points (fixes cause #1 above)--I did this by literally cloning the same point two additional times at the x values where the normalized data starts and ends of the line data. Only need to do this in your original (non-normalized) lines--the normalized lines already start/end where they need to. This works for curveBasis, because of which points are taken into consideration for the curve--this solution essentially forces a sort of line "end" even if it's in the middle of a line (source for documentation quote below):

[curveBasis] Produces a cubic basis spline using the specified control points. The first and last points are triplicated such that the spline starts at the first point and ends at the last point...

Example

Line 1 (x values):

[3, 4, 5, 7, 8, 9, 10, 11] original

[3, 4, 5, 6 (null y), 7, 8, 9, 10, 11] normalized, pre-manipulation

[3, 4, 5, 6 (interpolated y), 7, 8, 9, 10, 11] normalized, post-manipulation

[3, 3, 3, 4, 5, 6 (interpolated y), 7, 8, 9, 10, 11, 11, 11] original, post-manipulation

Note that for line 1, we could skip triplicating the start/end points, since they're already the start/end of the original line

Line 2 (x values):

[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 13] original

[3, 4, 5, 6, 7, 8, 9, 10, 11 (null y)] normalized, pre-manipulation

[3, 4, 5, 6, 7, 8, 9, 10, 11 (re-used y from 10)] normalized, post-manipulation

[1, 2, 3, 3, 3, 4, 5, 6, 7, 8, 9, 10, 11 (re-used y from 10), 11 (re-used y from 10), 11 (re-used y from 10), 13] original, post-manipulation (note that we do not interpolate between 10 and 13 to get 11, because 13 doesn't exist in the normalized line)

What does it look like?

Not really that great--but, hey, there are no gaps or bleed overs! The arrows point to where we've "triplicated" points to force the curve to "end" there.

enter image description here

Are we going to use this?

Not likely. I'm still searching for a better solution; but this is what I've come up with so far.

This is a far better solution--it actually works exactly how I would expect. My only complaint is that it seems to me there should be a better way (I end up building/manipulating path data manually, which I don't like--I can say that I learned a good bit about many of the commands for defining SVG paths).

Solution

Note: I changed the way I generate the clipPath elements below from how I did it in my original question, because I was running into some edge case problems where there was some bleed above/below the line--the comments throughout should hopefully explain my solution.

// Generate path definition `d` for the "bad" line in reverse
const badLineReversedD = myD3LineFunction(badLineData.slice().reverse());

// First shared x value.
const leftBoundaryX = myXFunction(firstSharedXValue);
// Last shared x value.
const rightBoundaryX = myXFunction(lastSharedXValue);

// Create the good clipPath to contain the line shading to its proper area.
// This clip is all of the area ABOVE the bad line.
const goodLineClipPathId = `good-line-clip-path`;
areaElement.append('clipPath')
    .attr('id', goodLineClipPathId)
    .append('path')
    // Commands:
    // Start with reversed bad line path
    // Line to upper left corner (0,0) - L
    // Line to upper right corner (width,0) - L
    // Close path - Z
    .attr('d', `${badLineReversedD}L0,0L${width},0Z`);

// Create the bad clipPath to contain the line shading to its proper area.
// This clip is all of the area BELOW the bad line.
const badLineClipPathId = `bad-line-clip-path`;
areaElement.append('clipPath')
    .attr('id', badLineClipPathId)
    .append('path')
    // Commands:
    // Start with reversed bad line path
    // Line to lower left corner (0,height) - L
    // Line to lower right corner (width,height) - L
    // Close path - Z
    .attr('d', `${badLineReversedD}L0,${height}L${width},${height}Z`);

// Create a rectangular mask so only that which is within is visible.
// This rectangle cuts off the left/right of good and bad shading if
// the good/bad lines don't start/end at the same points. This hides
// any bleeding of shading to the left/right.
const maskId = `good-bad-mask`;
areaElement.append('mask')
    .attr('id', maskId)
    .append('rect')
    .attr('fill', 'white') // 'white' means show everything inside this rect (outside is hidden)
    .attr('x', leftBoundaryX) // Start at the first x value shared by both good/bad lines
    .attr('y', 0) // Start at the top
    .attr('width', rightBoundaryX - leftBoundaryX) // End at the last x value shared by both good/bad lines
    .attr('height', height); // End at the bottom

/*
    Create SVG path data for a shape bounded by the good and bad lines with straight lines drawn from the
    start/end of each line (start of good to start of bad--end of good to end of bad).
    Grab the line data 'd' from the good line and concatenate it with the line data 'd' from the reversed
    bad line with it's starting point 'M' replaced with a Line command 'L' (to draw a line from the end of
    the good line to the end--since it's reversed--of the bad line) and add a close path command 'Z' at the
    end (which will draw a straight line from the start--since it's reversed--of the bad line to the start
    of the good line, which creates a closed path within the good/bad lines).
*/
const goodBadAreaD = goodLinePath.getAttribute('d') + badLineReversedD.replace('M', 'L') + 'Z';

// Create the bad fill.
badArea = areaElement.append('path')
    .attr('clip-path', `url(#${badLineClipPathId})`)
    .attr('mask', `url(#${maskId})`)
    .attr('class', 'bad-area')
    .attr('d', goodBadAreaD);

// Create the good fill.
goodArea = areaElement.append('path')
    .attr('clip-path', `url(#${goodLineClipPathId})`)
    .attr('mask', `url(#${maskId})`)
    .attr('class', 'good-area')
    .attr('d', goodBadAreaD);

What does it look like?

Exactly as expected--and it works for all curve types I've tested (linear, cardinal, basis), and I'd expect it to work for any curve!

Curved lines with shading between

Are We Going to Use This?

Absolutely! I'm open to hearing a "better" d3-way to do this, but this works just fine and isn't crazy complicated.

Related