javascriptd3.jstimeserieschart

How do I colorize the area only under the timeseries line?


Using D3 JS, I am making a timeseries plot, and trying to colorize the area only under the timseries line. The code below adds horizontal colored blocks, first, below the green threshold, second between the green and red threshold, and the last, over the red threshold, but I would like the color only to be the added to the area falling under the timeseries line, whereas the areas over the timeseries line should appear white (no colors).

I googled but couldn't find a working example or tutorial to achieve this. Can someone suggest a working example?

Update

I found this example, but it is different than what i would like to achieve.

Simple example using D3 Js

// Generate realistic dummy data for y axis (1 day, hourly)
const hours = d3.range(0, 24);
const values = Array.from({
  length: 24
}, () => Math.random() * 45);

const data = hours.map((hour, index) => ({
  hour,
  value: values[index]
}));

const margin = {
    top: 20,
    right: 30,
    bottom: 30,
    left: 40
  },
  width = 800 - margin.left - margin.right,
  height = 400 - margin.top - margin.bottom;

const svg = d3.select("svg")
  .attr("width", width + margin.left + margin.right)
  .attr("height", height + margin.top + margin.bottom)
  .append("g")
  .attr("transform", `translate(${margin.left},${margin.top})`);

const x = d3.scaleLinear()
  .domain(d3.extent(data, d => d.hour))
  .range([0, width]);

const y = d3.scaleLinear()
  .domain([0, d3.max(data, d => d.value)])
  .range([height, 0]);

const xAxis = d3.axisBottom(x).tickFormat(d => `${d}h`);
const yAxis = d3.axisLeft(y);

svg.append("g")
  .attr("transform", `translate(0,${height})`)
  .call(xAxis)
  .append("text")
  .attr("fill", "#000")
  .attr("x", width / 2)
  .attr("y", margin.bottom - 10)
  .attr("text-anchor", "middle")
  .style("font-size", "13px")
  .style("font-weight", "bold")
  .text("Hours");

svg.append("g")
  .call(yAxis)
  .append("text")
  .attr("fill", "#000")
  .attr("transform", "rotate(-90)")
  .attr("x", -height / 2)
  .attr("y", -margin.left + 10)
  .attr("text-anchor", "middle")
  .style("font-size", "13px")
  .style("font-weight", "bold")
  .text("Values");

const line = d3.line()
  .x(d => x(d.hour))
  .y(d => y(d.value));

// Calculate percentiles
const sortedValues = values.slice().sort((a, b) => a - b);
const percentile25 = d3.quantile(sortedValues, 0.25);
const median = d3.median(sortedValues);

// Add background rectangles
svg.append("rect")
  .attr("class", "background-blue")
  .attr("x", 0)
  .attr("y", y(percentile25))
  .attr("width", width)
  .attr("height", height - y(percentile25));

svg.append("rect")
  .attr("class", "background-lightgreen")
  .attr("x", 0)
  .attr("y", y(median))
  .attr("width", width)
  .attr("height", y(percentile25) - y(median));

svg.append("rect")
  .attr("class", "background-maroon")
  .attr("x", 0)
  .attr("y", 0)
  .attr("width", width)
  .attr("height", y(median));

// Add the line
svg.append("path")
  .datum(data)
  .attr("class", "line")
  .attr("d", line);

// Add threshold lines
svg.append("line")
  .attr("class", "threshold threshold-25")
  .attr("x1", 0)
  .attr("x2", width)
  .attr("y1", y(percentile25))
  .attr("y2", y(percentile25));

svg.append("line")
  .attr("class", "threshold threshold-50")
  .attr("x1", 0)
  .attr("x2", width)
  .attr("y1", y(median))
  .attr("y2", y(median));
.line {
  fill: none;
  stroke: black;
  stroke-width: 2px;
}

.threshold {
  stroke-width: 2px;
  stroke-dasharray: 4;
}

.threshold-25 {
  stroke: green;
}

.threshold-50 {
  stroke: red;
}

.background-blue {
  fill: blue;
  opacity: 0.1;
}

.background-lightgreen {
  fill: lightgreen;
  opacity: 0.1;
}

.background-maroon {
  fill: maroon;
  opacity: 0.1;
}
<script src="https://d3js.org/d3.v7.min.js"></script>

<svg width="800" height="400"></svg>


Solution

  • I would use a combination of an SVG clip path and a d3 area generator to clip your rectangles to the area under the line. Note, I had to convert "threshold lines" to path so that the clip path could be used on those as well.

    Working example:

    <!DOCTYPE html>
    
    <html>
      <head>
        <style>
          .line {
            fill: none;
            stroke: black;
            stroke-width: 2px;
          }
    
          .threshold {
            stroke-width: 2px;
            stroke-dasharray: 4;
          }
    
          .threshold-25 {
            stroke: green;
          }
    
          .threshold-50 {
            stroke: red;
          }
    
          .background-blue {
            fill: blue;
            opacity: 0.1;
          }
    
          .background-lightgreen {
            fill: lightgreen;
            opacity: 0.1;
          }
    
          .background-maroon {
            fill: maroon;
            opacity: 0.1;
          }
        </style>
      </head>
    
      <body>
        <script src="https://d3js.org/d3.v7.min.js"></script>
    
        <svg width="800" height="400"></svg>
        <script>
          // Generate realistic dummy data for y axis (1 day, hourly)
          const hours = d3.range(0, 24);
          const values = Array.from(
            {
              length: 24,
            },
            () => Math.random() * 45
          );
    
          const data = hours.map((hour, index) => ({
            hour,
            value: values[index],
          }));
    
          const margin = {
              top: 20,
              right: 30,
              bottom: 30,
              left: 40,
            },
            width = 800 - margin.left - margin.right,
            height = 400 - margin.top - margin.bottom;
    
          const svg = d3
            .select('svg')
            .attr('width', width + margin.left + margin.right)
            .attr('height', height + margin.top + margin.bottom)
            .append('g')
            .attr('transform', `translate(${margin.left},${margin.top})`);
    
          const clipPath = svg.append('clipPath').attr('id', 'clip-path');
    
          const x = d3
            .scaleLinear()
            .domain(d3.extent(data, (d) => d.hour))
            .range([0, width]);
    
          const y = d3
            .scaleLinear()
            .domain([0, d3.max(data, (d) => d.value)])
            .range([height, 0]);
    
          const xAxis = d3.axisBottom(x).tickFormat((d) => `${d}h`);
          const yAxis = d3.axisLeft(y);
    
          svg
            .append('g')
            .attr('transform', `translate(0,${height})`)
            .call(xAxis)
            .append('text')
            .attr('fill', '#000')
            .attr('x', width / 2)
            .attr('y', margin.bottom - 10)
            .attr('text-anchor', 'middle')
            .style('font-size', '13px')
            .style('font-weight', 'bold')
            .text('Hours');
    
          svg
            .append('g')
            .call(yAxis)
            .append('text')
            .attr('fill', '#000')
            .attr('transform', 'rotate(-90)')
            .attr('x', -height / 2)
            .attr('y', -margin.left + 10)
            .attr('text-anchor', 'middle')
            .style('font-size', '13px')
            .style('font-weight', 'bold')
            .text('Values');
    
          const line = d3
            .line()
            .x((d) => x(d.hour))
            .y((d) => y(d.value));
    
          // Calculate percentiles
          const sortedValues = values.slice().sort((a, b) => a - b);
          const percentile25 = d3.quantile(sortedValues, 0.25);
          const median = d3.median(sortedValues);
    
          // Add background rectangles
          svg
            .append('rect')
            .attr('class', 'background-blue')
            .attr('x', 0)
            .attr('y', y(percentile25))
            .attr('width', width)
            .attr('height', height - y(percentile25))
            .attr("clip-path", "url(#clip-path)")
    
          svg
            .append('rect')
            .attr('class', 'background-lightgreen')
            .attr('x', 0)
            .attr('y', y(median))
            .attr('width', width)
            .attr('height', y(percentile25) - y(median))
            .attr("clip-path", "url(#clip-path)")
    
          svg
            .append('rect')
            .attr('class', 'background-maroon')
            .attr('x', 0)
            .attr('y', 0)
            .attr('width', width)
            .attr('height', y(median))        
            .attr("clip-path", "url(#clip-path)")
    
          // Add the line
          svg.append('path').datum(data).attr('class', 'line').attr('d', line);
          clipPath.append('path')
            .datum(data)
            .attr('d', (d) => {
              const area = d3.area()
              .x((d) => x(d.hour))
              .y0(y(0))
              .y1((d) => y(d.value));
    
              return area(d);
            });
    
          // Add threshold lines
          svg.append("line")
            .attr("class", "threshold threshold-25")
            .attr("x1", 0)
            .attr("x2", width)
            .attr("y1", y(percentile25))
            .attr("y2", y(percentile25));
    
          svg.append("line")
            .attr("class", "threshold threshold-50")
            .attr("x1", 0)
            .attr("x2", width)
            .attr("y1", y(median))
            .attr("y2", y(median));
    
        </script>
      </body>
    </html>