How do I implement d3's enter update exit pattern for an area chart with focus and context?

Viewed 196

I have an area chart with a focus and context section. The context section allows the user to change the time period shown in the focus section.

The data includes information on a population over time, and includes gender. I want to be able to toggle between Men, Women, and All. I have the start of this working, in that I'm grabbing the data I need and feeding it to the chart. However, the area charts don't exit when I click another option, so the graphs for Men/Women/All get stacked on top of each other.

Can anyone help me untangle this?

Code is below, and here is a fiddle: https://jsfiddle.net/3wtrsegp/

<!DOCTYPE html>
<head>
<meta charset="utf-8">
<style>
    .brush rect.selection {
        fill: none;
        opacity: 1;
    }
    rect.handle{
        fill: #666;
    }

</style>
</head>
<body>

<div id="buttons">
    <button id="All" class="button selected">All</button>
    <button id="Men" class="button">Men</button>
    <button id="Women" class="button">Women</button>
</div>
<div id='ptchart'></div>

<script src="https://d3js.org/d3.v4.min.js"></script>
<script>

var json = [
    {
      "date": "2020-01-02",
      "gender": "Men",
      "value": 4320
    },
    {
      "date": "2020-01-02",
      "gender": "Women",
      "value": 984
    },
    {
      "date": "2020-01-15",
      "gender": "Men",
      "value": 4624
    },
    {
      "date": "2020-01-15",
      "gender": "Women",
      "value": 1005
    },
    {
      "date": "2020-02-03",
      "gender": "Men",
      "value": 5488
    },
    {
      "date": "2020-02-03",
      "gender": "Women",
      "value": 978
    },
    {
      "date": "2020-02-18",
      "gender": "Men",
      "value": 5842
    },
    {
      "date": "2020-02-18",
      "gender": "Women",
      "value": 1006
    },
    {
      "date": "2020-03-02",
      "gender": "Men",
      "value": 6925
    },
    {
      "date": "2020-03-02",
      "gender": "Women",
      "value": 1004
    },
    {
      "date": "2020-03-16",
      "gender": "Men",
      "value": 6132
    },
    {
      "date": "2020-03-16",
      "gender": "Women",
      "value": 948
    },
    {
      "date": "2020-04-01",
      "gender": "Men",
      "value": 5852
    },
    {
      "date": "2020-04-01",
      "gender": "Women",
      "value": 685
    },
    {
      "date": "2020-04-15",
      "gender": "Men",
      "value": 8697
    },
    {
      "date": "2020-04-15",
      "gender": "Women",
      "value": 497
    },
    {
      "date": "2020-05-01",
      "gender": "Men",
      "value": 4547
    },
    {
      "date": "2020-05-01",
      "gender": "Women",
      "value": 468
    }
]

var margin = { top: 30, right: 210, bottom: 110, left: 50 },
    margin2 = { top: 380, right: 200, bottom: 70, left: 50 },
    width = 1000 - margin.left - margin.right,
    height = 440 - margin.top - margin.bottom,
    height2 = 490 - margin2.top - margin2.bottom;

var x = d3.scaleTime().range([0, width]),
    x2 = d3.scaleTime().range([0, width]),
    y = d3.scaleLinear().range([height, 0]),
    y2 = d3.scaleLinear().range([height2, 0]);

var xAxis = d3.axisBottom(x).tickFormat(d3.timeFormat("%b %Y")).ticks(5),
    xAxis2 = d3.axisBottom(x2).ticks(0).tickSize(0),
    yAxis = d3.axisLeft(y);

var brush = d3.brushX()
    .extent([[0, 0], [width, height2]])
    .on("brush", brushed);

var svg = d3.select("#ptchart").append("svg")
    .attr("width", width + margin.left + margin.right)
    .attr("height", height + margin.top + margin.bottom);

svg.append("defs").append("clipPath")
    .attr("id", "clip")
    .append("rect")
    .attr("width", width)
    .attr("height", height);

var focus = svg.append("g")
    .attr("class", "focus")
    .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

var context = svg.append("g")
    .attr("class", "context")
    .attr("transform", "translate(" + margin2.left + "," + margin2.top + ")");

    const parseDate = d3.timeParse("%Y-%m-%d");

    let data = json
    updateChart(data)

    d3.selectAll(".button").on("click", function () {
        var section = d3.select(this).attr("id");
        
        if (section == 'All') { data = json } else {
            data = json.filter(d => d.gender == section)
        }

        updateChart(data);
    })

    updateChart(json);


    function updateChart(selectedData) {

        const data = d3.nest()
                .key(d => d.date)
                .rollup(v => d3.sum(v, d => d.value))
                .entries(selectedData);

            data.forEach((d) => {
                d.date = parseDate(d.key);
                d.value = +d.value;
            })

            x.domain(d3.extent(data, d => d.date));
            y.domain([0, d3.max(data, d => d.value) + 500]);
            x2.domain(x.domain());
            y2.domain(y.domain());

        var area = d3.area()
            .x(function (d) { return x(d.date); })
            .y0(height)
            .y1(function (d) { return y(d.value); })

        var line = d3.line()
            .x(d => x(d.date))
            .y(d => y(d.value))

        
        // This is what the area looked like before I started to try and get the enter/update/exit pattern working:

        // var area = focus.append("g");
        // area.attr("clip-path", "url(#clip)");
        // area.selectAll('path')
        //     .data([data])
        //     .enter().append("path")
        //     .attr("class", "area")
        //     .attr("d", area)
        //     .attr('fill', '#eeeeee')

        var area = focus.append("g").selectAll('path')
        .data([data])

        area.enter().append("path")
            .attr("class", "area")
            .attr('fill', '#eeeeee')
        
        area.transition()
            .attr("d", area)
            .attr("clip-path", "url(#clip)");
        
        area.exit().remove()


        var arealine = focus.append("g");
        arealine.attr("clip-path", "url(#clip)");
        arealine.selectAll('path')
            .data([data])
            .enter().append("path")
            .attr("class", "line")
            .attr("d", line)
            .attr('fill', 'none')
            .attr('stroke', '#333')

        var capacityline = focus.append('g')
        capacityline.selectAll('line')
            .data(data)
            .enter().append('line')
            .attr('class', 'capacityline')
            .attr('x1', 0)
            .attr('y1', d => y(12500))
            .attr('x2', width)
            .attr('y2', d => y(12500))
            .attr('stroke-width', '1px')
            .attr('stroke', '#4D4E56')
            .attr('stroke-dasharray', 4)

        var stooltip = d3.select("body").append("div")
            .attr("class", "tooltip");

        svg.selectAll(".axis").remove();

        focus.append("g")
            .attr("class", "axis axis--x")
            .attr("transform", "translate(0," + height + ")")
            .call(xAxis);

        focus.append("g")
            .attr("class", "axis axis--y")
            .call(yAxis);

            var area2 = d3.area()
            .x(d => x2(d.date))
            .y0(height2)
            .y1(d => y2(d.value))

        var line2 = d3.line()
            .x(d => x2(d.date))
            .y(d => y2(d.value))

        var area = context.append("g");
        area.attr("clip-path", "url(#clip)");
        area.selectAll('path')
            .data([data])
            .enter().append("path")
            .attr("class", "area")
            .attr("d", area2)
            .attr('fill', '#eeeeee')

        var arealine = context.append("g");
        arealine.attr("clip-path", "url(#clip)");
        arealine.selectAll('path')
            .data([data])
            .enter().append("path")
            .attr("class", "line")
            .attr("d", line2)
            .attr('fill', 'none')
            .attr('stroke', '#333')

        context.append("g")
            .attr("class", "axis axis--x")
            .attr("transform", "translate(0," + height2 + ")")
            .call(xAxis2);

        context.append("g")
            .attr("class", "brush")
            .call(brush)
            .call(brush.move, x.range());


}

function brushed() {
    var area = d3.area()
        .x(function (d) { return x(d.date); })
        .y0(height)
        .y1(function (d) { return y(d.value); })

    var line = d3.line()
        .x(function (d) { return x(d.date); })
        .y(function (d) { return y(d.value); })

    var selection = d3.event.selection;
    x.domain(selection.map(x2.invert, x2));
    focus.selectAll(".area")
        .attr("d", area)
    focus.selectAll(".line")
        .attr("d", line)

    focus.select(".axis--x").call(xAxis);
}

</script>
</body>
</html>
1 Answers

Your immediate problem is an easy miss:

Every update you create new parent g elements and conduct a enter/update/exit cycle on those new g elements:

 var area = focus.append("g").selectAll('path')

The newly appended g element above will be empty as it has no children yet; therefore, the enter selection will contain one element for every item in the data array. As you never remove or re-select the old g elements, they just sit there untouched by the new selections and any subsequent enter/update/exit cycles. The result is we just layer on new data every timne we run the update function.

The parent g elements should be appended outside of the update function: they don't need updating: where there was one there will still be one and the parent's properties remain unchanged. The parent g elements are independent of the data, they only hold children that represent data. We should set up the parent g elements once outside the update function, alongside all the other things that need to be set up once. Then we can use those parents to set up our enter/update/exit cycle since we'll be selecting elements in the same parents each update (as opposed to using new empty parents each update).

Below I've taken a subset of your chart (for ease of demonstration) and appended all your parent g elements that only need be appended once and appended them prior to the update function being called. I also took your area and line generators and removed them from the update function: they don't change either here. This applies to tooltip div as well, and the axes themselves: the parent g is appended once outside the update function but the update function calls the axis generators on those g elements.

As an aside, there's a lot of overlapping variable names in your code, I may have altered names to avoid that. I've used the merge method when entering and updating the line.

<!DOCTYPE html>
<head>
<meta charset="utf-8">
<style>
    .brush rect.selection {
        fill: none;
        opacity: 1;
    }
    rect.handle{
        fill: #666;
    }

</style>
</head>
<body>

<div id="buttons">
    <button id="All" class="button selected">All</button>
    <button id="Men" class="button">Men</button>
    <button id="Women" class="button">Women</button>
</div>
<div id='ptchart'></div>

<script src="https://d3js.org/d3.v4.min.js"></script>
<script>

var json = [
    {
      "date": "2020-01-02",
      "gender": "Men",
      "value": 4320
    },
    {
      "date": "2020-01-02",
      "gender": "Women",
      "value": 984
    },
    {
      "date": "2020-01-15",
      "gender": "Men",
      "value": 4624
    },
    {
      "date": "2020-01-15",
      "gender": "Women",
      "value": 1005
    },
    {
      "date": "2020-02-03",
      "gender": "Men",
      "value": 5488
    },
    {
      "date": "2020-02-03",
      "gender": "Women",
      "value": 978
    },
    {
      "date": "2020-02-18",
      "gender": "Men",
      "value": 5842
    },
    {
      "date": "2020-02-18",
      "gender": "Women",
      "value": 1006
    },
    {
      "date": "2020-03-02",
      "gender": "Men",
      "value": 6925
    },
    {
      "date": "2020-03-02",
      "gender": "Women",
      "value": 1004
    },
    {
      "date": "2020-03-16",
      "gender": "Men",
      "value": 6132
    },
    {
      "date": "2020-03-16",
      "gender": "Women",
      "value": 948
    },
    {
      "date": "2020-04-01",
      "gender": "Men",
      "value": 5852
    },
    {
      "date": "2020-04-01",
      "gender": "Women",
      "value": 685
    },
    {
      "date": "2020-04-15",
      "gender": "Men",
      "value": 8697
    },
    {
      "date": "2020-04-15",
      "gender": "Women",
      "value": 497
    },
    {
      "date": "2020-05-01",
      "gender": "Men",
      "value": 4547
    },
    {
      "date": "2020-05-01",
      "gender": "Women",
      "value": 468
    }
]

var margin = { top: 30, right: 210, bottom: 110, left: 50 },
    width = 1000 - margin.left - margin.right,
    height = 440 - margin.top - margin.bottom;

var x = d3.scaleTime().range([0, width]),
    y = d3.scaleLinear().range([height, 0]);

var xAxis = d3.axisBottom(x).tickFormat(d3.timeFormat("%b %Y")).ticks(5),
    yAxis = d3.axisLeft(y);

var svg = d3.select("#ptchart").append("svg")
    .attr("width", width + margin.left + margin.right)
    .attr("height", height + margin.top + margin.bottom);

svg.append("defs").append("clipPath")
    .attr("id", "clip")
    .append("rect")
    .attr("width", width)
    .attr("height", height);

// Add all the `g` parent elements now:
var focus = svg.append("g")
    .attr("class", "focus")
    .attr("transform", "translate(" + margin.left + "," + margin.top + ")");
    
var areaG = focus.append("g");
var lineG = focus.append("g")
                 .attr("clip-path", "url(#clip)");

var stooltip = d3.select("body").append("div")
    .attr("class", "tooltip");
            
var xAxisG = focus.append("g")
            .attr("class", "axis axis--x")
            .attr("transform", "translate(0," + height + ")")           

var yAxisG = focus.append("g")
             .attr("class", "axis axis--y")         
            
    
// area and line generators:
var area = d3.area()
    .x(function (d) { return x(d.date); })
    .y0(height)
    .y1(function (d) { return y(d.value); })

var line = d3.line()
    .x(function (d) { return x(d.date); })
    .y(function (d) { return y(d.value); }) 


const parseDate = d3.timeParse("%Y-%m-%d");

let data = json
updateChart(data)

    d3.selectAll(".button").on("click", function () {
        var section = d3.select(this).attr("id");
        
        if (section == 'All') { data = json } else {
            data = json.filter(d => d.gender == section)
        }

        updateChart(data);
    })

    updateChart(json);


function updateChart(selectedData) {
    
    // Manage data
    const data = d3.nest()
        .key(d => d.date)
        .rollup(v => d3.sum(v, d => d.value))
        .entries(selectedData);

    data.forEach((d) => {
        d.date = parseDate(d.key);
        d.value = +d.value;
    })

    // Calculate new scale values:
    x.domain(d3.extent(data, d => d.date));
    y.domain([0, d3.max(data, d => d.value) + 500]);

    // Main chart:
    // Do the areas:
    var areaPaths = areaG.selectAll('path')
        .data([data])

        areaPaths.enter().append("path")
            .attr("class", "area")
            .attr('fill', '#eeeeee')
        
        areaPaths.transition()
            .attr("d", area)
            .attr("clip-path", "url(#clip)");
        
        areaPaths.exit().remove()

    // Do the lines:
    var linePaths = lineG.selectAll('path')
            .data([data]);
         
            // Update/exit
            linePaths.enter().append("path")
            .merge(linePaths)       
            .attr("class", "line")
            .attr("d", line)
            .attr('fill', 'none')
            .attr('stroke', '#333')
            
   linePaths.exit().remove();

    // Update the axes:
    xAxisG.call(xAxis);
    yAxisG.call(yAxis);

}

</script>
</body>
</html>

But a more fundamental issue is that you don't need an enter/update/exit cycle for most of what you're doing. This is most pronounced for the capacity line: it is independent of the data (its properties are hardcoded). But also, the number of lines is fixed at one - there is no need to exit a line or area, and once appended there is no need to enter a line.

The purpose of the enter/update/exit cycle is to ensure that the number of elements in the selection match the number of items in the data array. If we always have the same number of elements, then enter/update/exit is overkill. We can simplify the code so that your update function merely updates existing elements. Instead of appending just a parent g we can append a child line initially as well. This way our update function is cleaner and the code overall simpler (only appends, no explicit enter/update/exit here), while still retaining the functionality and spirit of D3:

I've modified the properties of the capacity line so that it appears in visual range

<!DOCTYPE html>
<head>
<meta charset="utf-8">
<style>
    .brush rect.selection {
        fill: none;
        opacity: 1;
    }
    rect.handle{
        fill: #666;
    }

</style>
</head>
<body>

<div id="buttons">
    <button id="All" class="button selected">All</button>
    <button id="Men" class="button">Men</button>
    <button id="Women" class="button">Women</button>
</div>
<div id='ptchart'></div>

<script src="https://d3js.org/d3.v4.min.js"></script>
<script>

var json = [
    {
      "date": "2020-01-02",
      "gender": "Men",
      "value": 4320
    },
    {
      "date": "2020-01-02",
      "gender": "Women",
      "value": 984
    },
    {
      "date": "2020-01-15",
      "gender": "Men",
      "value": 4624
    },
    {
      "date": "2020-01-15",
      "gender": "Women",
      "value": 1005
    },
    {
      "date": "2020-02-03",
      "gender": "Men",
      "value": 5488
    },
    {
      "date": "2020-02-03",
      "gender": "Women",
      "value": 978
    },
    {
      "date": "2020-02-18",
      "gender": "Men",
      "value": 5842
    },
    {
      "date": "2020-02-18",
      "gender": "Women",
      "value": 1006
    },
    {
      "date": "2020-03-02",
      "gender": "Men",
      "value": 6925
    },
    {
      "date": "2020-03-02",
      "gender": "Women",
      "value": 1004
    },
    {
      "date": "2020-03-16",
      "gender": "Men",
      "value": 6132
    },
    {
      "date": "2020-03-16",
      "gender": "Women",
      "value": 948
    },
    {
      "date": "2020-04-01",
      "gender": "Men",
      "value": 5852
    },
    {
      "date": "2020-04-01",
      "gender": "Women",
      "value": 685
    },
    {
      "date": "2020-04-15",
      "gender": "Men",
      "value": 8697
    },
    {
      "date": "2020-04-15",
      "gender": "Women",
      "value": 497
    },
    {
      "date": "2020-05-01",
      "gender": "Men",
      "value": 4547
    },
    {
      "date": "2020-05-01",
      "gender": "Women",
      "value": 468
    }
]

var margin = { top: 30, right: 210, bottom: 110, left: 50 },
    margin2 = { top: 380, right: 200, bottom: 70, left: 50 },
    width = 1000 - margin.left - margin.right,
    height = 440 - margin.top - margin.bottom,
    height2 = 490 - margin2.top - margin2.bottom;

var x = d3.scaleTime().range([0, width]),
    x2 = d3.scaleTime().range([0, width]),
    y = d3.scaleLinear().range([height, 0]),
    y2 = d3.scaleLinear().range([height2, 0]);

var xAxis = d3.axisBottom(x).tickFormat(d3.timeFormat("%b %Y")).ticks(5),
    xAxis2 = d3.axisBottom(x2).ticks(12).tickSize(0),
    yAxis = d3.axisLeft(y);

var brush = d3.brushX()
    .extent([[0, 0], [width, height2]])
    .on("brush", brushed);

var svg = d3.select("#ptchart").append("svg")
    .attr("width", width + margin.left + margin.right)
    .attr("height", height + margin.top + margin.bottom);

svg.append("defs").append("clipPath")
    .attr("id", "clip")
    .append("rect")
    .attr("width", width)
    .attr("height", height);

// Set up chart elements:
var focus = svg.append("g")
    .attr("class", "focus")
    .attr("transform", "translate(" + margin.left + "," + margin.top + ")");
    
// Set up main chart elements:  
var mainArea =  focus.append("g")
    .attr("clip-path", "url(#clip)")
    .append("path")
    .attr("class", "area")
    .attr('fill', '#eeeeee')
    
var mainLine = focus.append("g")  
    .attr("clip-path", "url(#clip)")
    .append("path")
    .attr("class", "line")
    .attr('fill', 'none')
    .attr('stroke', '#333')    
    
var capacityLine = focus.append("g")
    .append("line")
    .attr('class', 'capacityline')
    .attr('stroke-width', '1px')
    .attr('stroke', '#4D4E56')
    .attr('stroke-dasharray', 4)   

var xAxisG = focus.append("g") 
    .attr("class", "axis axis--x")
    .attr("transform", "translate(0," + height + ")")
    
var yAxisG = focus.append("g")
    .attr("class", "axis axis--y")
        
// Set up brushable chart elements:
var context = svg.append("g")
    .attr("class", "context")
    .attr("transform", "translate(" + margin2.left + "," + margin2.top + ")");
    
var brushableArea = context.append("g")
    .attr("clip-path", "url(#clip)")
    .append("path")
    .attr("class", "area")
    .attr('fill', '#eeeeee');
    
var brushableLine = context.append("g")
    .attr("clip-path", "url(#clip)")
    .append("path")
    .attr("class", "line")
    .attr('fill', 'none')
    .attr('stroke', '#333')  
    
var brushAxisG = context.append("g")
   .attr("class", "axis axis--x")
   .attr("transform", "translate(0," + height2 + ")")

var brushG = context.append("g")
   .attr("class", "brush")
   
// Set up tooltip div:
var stooltip = d3.select("body").append("div")
    .attr("class", "tooltip");

const parseDate = d3.timeParse("%Y-%m-%d");

    
// Area and line generators:
var area = d3.area()
  .x(function (d) { return x(d.date); })
  .y0(height)
  .y1(function (d) { return y(d.value); })

var line = d3.line()
  .x(d => x(d.date))
  .y(d => y(d.value))   
  
var area2 = d3.area()
  .x(d => x2(d.date))
  .y0(height2)
  .y1(d => y2(d.value))

var line2 = d3.line()
  .x(d => x2(d.date))
  .y(d => y2(d.value))

updateChart(json)

d3.selectAll(".button").on("click", function () {
    var section = d3.select(this).attr("id");
        
    if (section == 'All') { data = json } else {
       data = json.filter(d => d.gender == section)
     }

    updateChart(data);
})
    
function updateChart(selectedData) {
    const data = d3.nest()
     .key(d => d.date)
     .rollup(v => d3.sum(v, d => d.value))
     .entries(selectedData);

    data.forEach((d) => {
      d.date = parseDate(d.key);
      d.value = +d.value;
    })

    // Update scales:
    x.domain(d3.extent(data, d => d.date));
    y.domain([0, d3.max(data, d => d.value) + 500]);
    x2.domain(x.domain());
    y2.domain(y.domain());
    
    // Update axes:
    xAxisG.call(xAxis);
    yAxisG.call(yAxis);
    
    // Update area and lines of main chart:
    mainArea.datum(data)
      .attr("d", area);
        
    mainLine.datum(data)
      .attr("d",line);

    capacityLine.transition()
      .attr('x1', 0)
      .attr('y1', d => y(1000))
      .attr('x2', width)
      .attr('y2', d => y(1000))

    // Update brushable area:
    brushableArea.datum(data)
      .attr("d", area2);
    
    brushableLine.datum(data)
      .attr("d", line2);

  // Update brush axis:
  brushAxisG.call(xAxis2);

  // Update brush:
  brushG.call(brush)
     .call(brush.move, x.range());
}

function brushed() {

    var selection = d3.event.selection;
    x.domain(selection.map(x2.invert, x2));
    focus.selectAll(".area")
        .attr("d", area)
    focus.selectAll(".line")
        .attr("d", line)

    focus.select(".axis--x").call(xAxis);
}

</script>
</body>
</html>

The above uses .datum() which passes the binds the provided value to the selected element: it is equivalent to your use of .data([data]) in you enter/update/exit selections in your original code.

Related