javascriptd3.jsscatterplot3d

D3.js custom ticks on x axis of scatterplot


I have three issues:

More context: I would like to visualize meetings of six organizations grouped by years from 2014 to 2020 with D3.js. Each dot of a scatterplot represents a meeting and the six colors match with the organizations. Currently I've added the x position within the scatterplot for each dot manually into the input data file, so that there are gaps between the years.

Current result: enter image description here

Desired result: enter image description here

Current code:

import axios from "axios";

let meetings
let colors = { a: "brown", b: "orange", c: "red", d: "purple", e: "blue", f: "green" };

window.addEventListener("load", () => {
    initUI();
});

function initUI() {
    // parse input data
    axios
        .get("assets/example.txt")
        .then(async (res) => {
            var rawData = res.data
                .split("\n")
                // filter header row
                .filter((row) => (row.indexOf("label") >= 0 ? false : true))
                .map((row) => {
                    var items = row.split(";");
                    return {
                        label: items[0],
                        year: items[1],
                        xPosition: items[2],
                    };
                });

            meetings = addYPositionForAllOrganizations(rawData);
            scatterplot = await showScatterPlot(meetings);
        })
        .then(() => {
            // always executed
        });
}

// Add counter for amount of meetings for one organziation per year for y axis position
function addYPosition(organizationList) {    
    organizationList.sort((a, b) => (a.year > b.year) ? 1 : -1)

    var yPosition = 1;
    var year = 2014;

    organizationList.forEach(element => {
        if (year < element.year) {
            // reset counter for next year
            yPosition = 1;
        }
        element.yPosition = 0;
        element.yPosition += yPosition;
        yPosition++;

        year = element.year;
    });
}

function addYPositionForAllOrganizations(data) {
    let a = data.filter(row => row.label == "a");
    addYPosition(a);
    let b = data.filter(row => row.label == "b");
    addYPosition(b);
    let c = data.filter(row => row.label == "c");
    addYPosition(c);
    let d = data.filter(row => row.label == "d");
    addYPosition(d);
    let e = data.filter(row => row.label == "e");
    addYPosition(e);
    let f = data.filter(row => row.label == "f");
    addYPosition(f);

    return a.concat(b).concat(c).concat(d).concat(e).concat(f);
}

async function showScatterPlot(data) {
    let margin = { top: 10, right: 30, bottom: 100, left: 60 },
        width = 1000 - margin.left - margin.right,
        height = 400 - margin.top - margin.bottom;

    // append the svg object to the body of the page
    let svg = d3
        .select("#scatter-plot")
        .append("svg")
        .attr("width", width + margin.left + margin.right)
        .attr("height", height + margin.top + margin.bottom)
        .append("g")
        .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

    // Add x axis
    var x = d3.scaleLinear().domain([-2, 75]).range([0, width]);
    //FIXME this destroys ticks so that they are "invisible"
    var xAxis = d3.axisBottom(x).tickValues(2014, 2015, 2016, 2017, 2018, 2019, 2020);
    svg.append("g")
        .attr("class", "x axis")
        .attr("transform", "translate(0," + height + ")")
        .call(xAxis);
    // Add text label for x axis
    svg.append("text")
        .attr("transform", "translate(" + (width / 2) + "," + (height - (-100 / 3)) + ")")
        .attr("text-anchor", "middle")
        .style("font-family", "sans-serif")
        .text("Years 2014 - 2020");

    // Add y axis
    var y = d3.scaleLinear().domain([0.5, 10]).range([height, 0]);
    svg.append("g").attr("class", "y axis").call(d3.axisLeft(y));
    // Add text label for the y axis
    svg.append("text")
        .attr("transform", "rotate(-90)")
        .attr("y", 0 - margin.left)
        .attr("x", 0 - (height / 2))
        .attr("dy", "1em")
        .style("text-anchor", "middle")
        .style("font-family", "sans-serif")
        .text("Amount");

    // Add meetings as dots
    let meetings = svg.append('g')
        .selectAll("dot")
        .data(data)
        .enter()
        .append("circle")
        .attr("cx", function (d) { return x(d.xPosition); })
        .attr("cy", function (d) { return y(d.yPosition); })
        .attr("r", 5.5)
        .style("fill", getColorForMeeting);
    return { svg, x, y };    
}

function getColorForMeeting(data) {
    return colors[data.label];
}
<script src="https://d3js.org/d3.v4.js"></script>

<div id="scatter-plot"></div>

Extract of the input data file: enter image description here

The running project can be investigated here.


Solution

  • Here's an example that resolves your three issues.

    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="UTF-8">
        <script src="https://d3js.org/d3.v7.js"></script>
    </head>
    
    <body>
        <div id="legend"></div>
        <div id="chart"></div>
    
        <script>
          // margin convention set up
    
          const margin = { top: 20, bottom: 20, left: 20, right: 20 };
    
          const width = 600 - margin.left - margin.right;
          const height = 125 - margin.top - margin.bottom;
    
          const svg = d3.select('#chart')
            .append('svg')
              .attr('width', width + margin.left + margin.right)
              .attr('height', height + margin.top + margin.bottom);
    
          const g = svg.append('g')
              .attr('transform', `translate(${margin.left},${margin.top})`);
    
    
          // data
    
          const data = [
            { label: "a", year: 2014 },
            { label: "a", year: 2014 },
            { label: "a", year: 2014 },
            { label: "a", year: 2014 },
            { label: "a", year: 2014 },
            { label: "a", year: 2014 },
            { label: "a", year: 2014 },
            { label: "a", year: 2014 },
            { label: "b", year: 2014 },
            { label: "b", year: 2014 },
            { label: "b", year: 2014 },
            { label: "c", year: 2014 },
            { label: "c", year: 2014 },
            { label: "c", year: 2014 },
            { label: "d", year: 2014 },
            { label: "d", year: 2014 },
            { label: "d", year: 2014 },
            { label: "d", year: 2014 },
            { label: "e", year: 2014 },
            { label: "e", year: 2014 },
            { label: "e", year: 2014 },
            { label: "e", year: 2014 },
            { label: "a", year: 2015 },
            { label: "a", year: 2015 },
            { label: "a", year: 2015 },
            { label: "a", year: 2015 },
            { label: "a", year: 2015 },
            { label: "a", year: 2015 },
            { label: "b", year: 2015 },
            { label: "b", year: 2015 },
            { label: "b", year: 2015 },
            { label: "b", year: 2015 },
            { label: "b", year: 2015 },
            { label: "b", year: 2015 },
            { label: "b", year: 2015 },
            { label: "b", year: 2015 },
            { label: "c", year: 2015 },
            { label: "c", year: 2015 },
            { label: "c", year: 2015 },
            { label: "c", year: 2015 },
            { label: "c", year: 2015 },
            { label: "c", year: 2015 },
            { label: "c", year: 2015 },
            { label: "d", year: 2015 },
            { label: "d", year: 2015 },
            { label: "d", year: 2015 },
            { label: "d", year: 2015 },
            { label: "d", year: 2015 },
            { label: "e", year: 2015 },
            { label: "a", year: 2016 },
            { label: "a", year: 2016 },
            { label: "a", year: 2016 },
            { label: "a", year: 2016 },
            { label: "a", year: 2016 },
            { label: "a", year: 2016 },
            { label: "b", year: 2016 },
            { label: "b", year: 2016 },
            { label: "b", year: 2016 },
            { label: "b", year: 2016 },
            { label: "b", year: 2016 },
            { label: "b", year: 2016 },
            { label: "c", year: 2016 },
            { label: "c", year: 2016 },
            { label: "c", year: 2016 },
            { label: "c", year: 2016 },
            { label: "c", year: 2016 },
            { label: "d", year: 2016 },
            { label: "d", year: 2016 },
            { label: "d", year: 2016 },
            { label: "d", year: 2016 },
            { label: "a", year: 2017 },
            { label: "a", year: 2017 },
            { label: "a", year: 2017 },
            { label: "a", year: 2017 },
            { label: "a", year: 2017 },
            { label: "a", year: 2017 },
            { label: "a", year: 2017 },
            { label: "a", year: 2017 },
            { label: "a", year: 2017 },
            { label: "b", year: 2017 },
            { label: "b", year: 2017 },
            { label: "b", year: 2017 },
            { label: "b", year: 2017 },
            { label: "b", year: 2017 },
            { label: "b", year: 2017 },
            { label: "b", year: 2017 },
            { label: "b", year: 2017 },
            { label: "b", year: 2017 },
            { label: "c", year: 2017 },
            { label: "c", year: 2017 },
            { label: "c", year: 2017 },
            { label: "c", year: 2017 },
            { label: "c", year: 2017 },
            { label: "d", year: 2017 },
            { label: "d", year: 2017 },
            { label: "d", year: 2017 },
            { label: "d", year: 2017 },
            { label: "d", year: 2017 },
            { label: "d", year: 2017 },
            { label: "d", year: 2017 },
            { label: "a", year: 2018 },
            { label: "a", year: 2018 },
            { label: "a", year: 2018 },
            { label: "a", year: 2018 },
            { label: "b", year: 2018 },
            { label: "b", year: 2018 },
            { label: "b", year: 2018 },
            { label: "c", year: 2018 },
            { label: "c", year: 2018 },
            { label: "c", year: 2018 },
            { label: "c", year: 2018 },
            { label: "e", year: 2018 },
            { label: "a", year: 2019 },
            { label: "a", year: 2019 },
            { label: "a", year: 2019 },
            { label: "a", year: 2019 },
            { label: "b", year: 2019 },
            { label: "b", year: 2019 },
            { label: "b", year: 2019 },
            { label: "b", year: 2019 },
            { label: "b", year: 2019 },
            { label: "b", year: 2019 },
            { label: "b", year: 2019 },
            { label: "c", year: 2019 },
            { label: "c", year: 2019 },
            { label: "c", year: 2019 },
            { label: "e", year: 2019 },
            { label: "e", year: 2019 },
            { label: "f", year: 2019 },
            { label: "a", year: 2020 },
            { label: "a", year: 2020 },
            { label: "a", year: 2020 },
            { label: "a", year: 2020 },
            { label: "a", year: 2020 },
            { label: "b", year: 2020 },
            { label: "b", year: 2020 },
            { label: "b", year: 2020 },
            { label: "b", year: 2020 },
            { label: "b", year: 2020 },
            { label: "b", year: 2020 },
            { label: "c", year: 2020 },
            { label: "c", year: 2020 },
            { label: "c", year: 2020 },
            { label: "d", year: 2020 },
            { label: "d", year: 2020 },
            { label: "e", year: 2020 },
          ];
    
          // map from the year to the label to the array
          // of meetings for that year and label
          const yearToLabelToMeetings = d3.rollup(
            data,
            // group is an array of all of the meetings
            // that have the same year and label.
            // add the y index for each meeting
            group => group.map((d, i) => ({...d, y: i + 1})),
            // first group by year
            d => d.year,
            // then group by label
            d => d.label
          );
    
          // get the max number of meetings for any year and label
          const maxCount = d3.max(
            yearToLabelToMeetings,
            ([year, labelToMeetings]) => d3.max(
              labelToMeetings,
              ([label, meetings]) => meetings.length
            )
          );
    
          // sorted lists of the labels and years
          const labels = [...new Set(data.map(d => d.label))].sort();
          const years = [...new Set(data.map(d => d.year))].sort(d3.ascending);
    
    
          // scales
    
          // for setting the y position of the dots
          const y = d3.scaleLinear()
              .domain([0, maxCount])
              .range([height, 0]);
    
          // for setting the x position of the groups for the years
          const yearX = d3.scaleBand()
              .domain(years)
              .range([0, width])
              .padding(0.3);
    
          // for setting the x position of the columns of dots
          // within a year group
          const labelX = d3.scalePoint()
              .domain(labels)
              .range([0, yearX.bandwidth()]);
    
          // for setting the color of the dots
          const color = d3.scaleOrdinal()
              .domain(labels)
              .range(d3.schemeCategory10);
    
    
          // drawing the data
    
          // create one group for each year and set the group's horizontal position
          const yearGroups = g.selectAll('g')
            .data(yearToLabelToMeetings)
            .join('g')
              .attr('transform', ([year, labelToMeetings]) => `translate(${yearX(year)})`);
    
          // inside each year group, create one group for each label and set its
          // horizontal position in the group
          const labelGroups = yearGroups.selectAll('g')
            .data(([year, labelToMeetings]) => labelToMeetings)
            .join('g')
              .attr('transform', ([label, meetings]) => `translate(${labelX(label)})`);
    
          // add the dots
          labelGroups.selectAll('circle')
            .data(([label, meetings]) => meetings)
            .join('circle')
              .attr('cy', d => y(d.y))
              .attr('r', 4)
              .attr('fill', d => color(d.label));
    
    
          // axes
    
          // x axis
          g.append('g')
              // move the axis to the bottom of the chart
              .attr('transform', `translate(0,${height})`)
              // add the axis
              .call(d3.axisBottom(yearX).tickSizeOuter(0))
              // move the tick marks to be in between the groups
              .call(g =>
                g.selectAll('line')
                    .attr('x1', yearX.step() / 2)
                    .attr('x2', yearX.step() / 2)
                  // remove the last tick mark
                  .filter(d => d === years[years.length - 1])
                  .remove()
              );
    
          // y axis
          g.append('g')
              .call(d3.axisLeft(y));
    
    
          // color legend
    
          const size = '10px';
    
          // create div for the legend to go in
          const legend = d3.select('#legend')
            .append('div')
              .style('display', 'flex')
              .style('font-family', 'sans-serif')
              .style('font-size', size);
    
          // create one div for each entry in the color scale
          const cell = legend.selectAll('div')
            .data(color.domain())
            .join('div')
              .style('margin-right', '1em')
              .style('display', 'flex')
              .style('align-items', 'center');
    
          // add the colored square for each entry
          cell.append('div')
              .style('background', d => color(d))
              .style('min-width', size)
              .style('min-height', size)
              .style('margin-right', '0.5em');
    
          // add the text label for each entry
          cell.append('div')
              .text(d => d);
        </script>
    </body>
    </html>