Search code examples
d3.jsgraphchartsdata-visualizationtimeline

D3 Y-axis with multiple labels


I want to create a d3 graph where on y axis labels will come as shown in the given image

they are months -> below that respective quarter -> below that respective year

Please help me how to create this.


Solution

  • Here's one way to achieve this.

    <html>
        <head>
            <script src="https://d3js.org/d3.v7.min.js"></script>
        </head>
        
        <body>
            <div id="chart"></div>
            <script>
                // dimensions
    
                const width = 800;
                const height = 100;
    
                const margin = { left: 20, right: 20 };
    
                // add main svg element
    
                const svg = d3.select('#chart')
                  .append('svg')
                    .attr('width', width)
                    .attr('height', height);
    
                // create x scale
    
                const startDate = new Date(2019, 1, 01);
                const endDate = new Date(2022, 0, 01);
    
                const months = d3.timeMonth.range(startDate, endDate);
    
                const x = d3.scaleTime()
                    .domain([startDate, endDate])
                    .range([margin.left, width - margin.right]);
    
                // Month label
    
                const monthAxis = g => g.append('g')
                    .call(d3.axisBottom(x)
                                   // every month, show abbreviation
                            .ticks(d3.timeMonth.every(1), d3.timeFormat('%b')));
    
                // Quarter label
    
                // map from month index (Jan is 0) to quarter label
                const monthToQuarter = new Map([
                  [2, 'Q1'], // March
                  [5, 'Q2'], // June
                  [8, 'Q3'], // September
                  [11, 'Q4'] // December
                ]);
    
                const quarterAxis = g => g.append('g')
                    // move these labels 30 pixels down
                    .attr('transform', `translate(0,30)`)
                    .call(d3.axisBottom(x)
                            // don't add room for ticks
                            .tickSize(0)
                            // only have labels for Mar, Jun, Sep, and Dec
                            .tickValues(months.filter(d => monthToQuarter.has(d.getMonth())))
                            // show the quarter instead of the date
                            .tickFormat(d => monthToQuarter.get(d.getMonth())))
                    // remove the baseline
                    .call(g => g.select('.domain').remove())
                    // remove tick lines
                    .call(g => g.selectAll('.tick>line').remove())
                
                // Year label
                
                const yearAxis = g => {
                  const group = g.append('g')
                      // move these labels 60 pixels down
                      .attr('transform', `translate(0,60)`);
    
                  // data for line segments for year ranges
                  // lines go from february to january
                  const segments = months.filter(d => d.getMonth() === 1)
                    .map(feb => {
                      const jan = d3.timeMonth.offset(feb, 11);
                      return [feb, jan];
                    });
                  
                  const y = 6;
                  
                  // add lines
                  group.append('g')
                    .selectAll('line')
                    .data(segments)
                    .join('line')
                      .attr('x1', d => x(d[0]))
                      .attr('x2', d => x(d[1]))
                      .attr('y1', y)
                      .attr('y2', y)
                      .attr('stroke', 'lightgray');
    
                  // add circles
                  group.append('g')
                    .selectAll('circle')
                    .data(segments.flat())
                    .join('circle')
                      .attr('cx', d => x(d))
                      .attr('cy', y)
                      .attr('r', 3)
                      .attr('fill', 'lightgray');
    
                  // add labels for years
                  group.append('g')
                      .call(d3.axisBottom(x)
                              // don't add room for ticks
                              .tickSize(0)
                              // center year labels between July and August
                              .tickValues(
                                months
                                  .filter(d => d.getMonth() === 6)
                                  .map(d => d3.timeDay.offset(d, 15))
                              )
                              .tickFormat(d => d.getFullYear()))
                      // remove the baseline
                      .call(g => g.select('.domain').remove())
                      // remove tick lines
                      .call(g => g.selectAll('.tick>line').remove())
                      /*
                        Duplicate the label and make it have a thick
                        white stroke. This provides a white background
                        to the labels so that the line does not show
                        behind them.
    
                        The same technique is used here:
                        https://observablehq.com/@d3/collapsible-tree
                      */
                      .call(g => g.selectAll('.tick>text').clone()
                                     .lower()
                                     .attr('stroke', 'white')
                                     .attr('stroke-width', '4')
                                     .attr('fill', null)
                                     .text(d => d.getFullYear()))
                }
    
                // Draw axes
    
                svg.append('g')
                  .call(monthAxis)
                  .call(quarterAxis)
                  .call(yearAxis);
            </script>
        </body>
    </html>
    

    This adds three axes, one beneath the other. The axis for the years requires a bit more work to handle the lines and circles.

    Here's what the output looks like:

    This image contains a screenshot of the axes created by the above code.