Search code examples
javascriptd3.jsobservablehq

Independent scales for small multiple line chart


Link to the notebook.

I'm working on a small multiple line chart using d3.v5 on Observable, with the dataset structured like follows:

enter image description here

For visualization, the y scale takes num from the values array for the domain. There are several rows with unique key values, which I wanted to use to produce the small multiples. The image above shows the first key.

After visualizing the small multiple, I noticed that all the line charts are using the same y scale, which is not what I intended to do. This is what I currently have:

  const y_scale = d3
    .scaleLinear()
    .domain([0, d3.max(series, d => d3.max(d.values, m => m.num))])
    .range([width/2, width/2 - start_y - margin.bottom]);

Is there a way to adjust the domain so that each chart would have its own scale based on its own num values?

Edit 1: Notebook link added on top


Solution

  • The idiomatic D3 solution here would be using local variables. However, there are several different working alternatives.

    For using local variables, we first declare them...

    const localScale = d3.local();
    const localLine = d3.local();
    

    Then, we set the different scales in the "enter" selection:

    var enter = my_group
        .enter()
        .append("g")
        .attr("class", "chart_group")
        .each(function(d) {
            const yScale = localScale.set(this, d3
                .scaleLinear()
                .domain([0, d3.max(d.values, d => d.num)])
                .range([panel_width / 2, panel_width / 2 - start_y - margin]));
    
            localLine.set(this, d3
                .line()
                .x(d => x_scale(d.date))
                .y(d => yScale(d.num)));
        });
    

    Finally, we get those scales:

    sub_group
        .select(".chart_line")
        .attr("d", function(d) {
            return localLine.get(this)(d)
        })
    

    Here is the whole cell, copy/paste this in your notebook, replacing your cell:

    chart = {
        const panels_per_row = 4;
        const panel_width = (width - margin * 8) / panels_per_row;
        const height =
            margin + (panel_width + margin) * (parseInt(my_data.length / 2) + 1);
    
        const svg = d3.create("svg").attr("viewBox", [0, 0, width, height]);
        const start_x = 2;
        const start_y = panel_width / 3 + margin;
    
        const x_scale = d3
            .scaleBand()
            .domain(d3.set(series[0].values, d => d.date).values())
            .range([0, panel_width]);
        const localScale = d3.local();
        const localLine = d3.local();
    
        //join
        var my_group = svg.selectAll('.chart_group').data(series, d => d.key);
    
        //exit and remove
        my_group.exit().remove();
        //enter new groups
        var enter = my_group
            .enter()
            .append("g")
            .attr("class", "chart_group")
            .each(function(d) {
                const yScale = localScale.set(this, d3
                    .scaleLinear()
                    .domain([0, d3.max(d.values, d => d.num)])
                    .range([panel_width / 2, panel_width / 2 - start_y - margin]));
    
                localLine.set(this, d3
                    .line()
                    .x(d => x_scale(d.date))
                    .y(d => yScale(d.num)));
            });
    
        //append elements to new group
        enter.append("rect").attr("class", "group_rect");
        enter.append("text").attr("class", "group_text");
        enter.append("g").attr("class", "sub_chart_group");
    
        //merge
        my_group = my_group.merge(enter);
    
        position_group_elements(my_group);
    
        //join
        var sub_group = my_group
            .select(".sub_chart_group")
            .selectAll('.sub_chart_elements_group')
            .data(d => [d.values]); // data is wrapped in an array because this is a line/area chart
    
        //exit and remove
        sub_group.exit().remove();
        //enter new groups
        var sub_enter = sub_group
            .enter()
            .append("g")
            .attr("class", "sub_chart_elements_group");
    
        //append elements to new group
        sub_enter.append("path").attr("class", "chart_line");
    
        //merge
        sub_group = sub_group.merge(sub_enter);
    
        sub_group
            .select(".chart_line")
            .attr("d", function(d) {
                return localLine.get(this)(d)
            })
            .attr("fill", "none")
            .attr("stroke", "black")
            .attr("stroke-width", 1)
            .attr("transform", "translate(" + start_x + "," + start_y + ")");
    
        function position_group_elements(my_group) {
            //position rectangle
            my_group
                .select(".group_rect")
                .attr("x", function(d, i) {
                    //two groups per row so
                    var position = i % panels_per_row;
                    d.x_pos = position * (panel_width + margin) + margin;
                    d.y_pos =
                        parseInt(i / panels_per_row) * (panel_width + margin) + margin;
                    return d.x_pos;
                })
                .attr("y", d => d.y_pos)
                .attr("fill", "#eee")
                .attr("stroke", "#aaa")
                .attr("stroke-width", 1)
                .attr("width", panel_width)
                .attr("height", panel_width);
    
            //then position sub groups
            my_group
                .select(".sub_chart_group")
                .attr("id", d => d.key)
                .attr("transform", d => "translate(" + d.x_pos + "," + d.y_pos + ")");
        }
    
        return svg.node();
    }