1
0
mirror of https://github.com/d3/d3.git synced 2025-12-08 19:46:24 +00:00
d3/examples/connected-scatterplot.md
2024-09-24 17:09:14 +02:00

5.9 KiB
Raw Blame History

Connected scatterplot

This is a recreation of Hannah Fairfields Driving Shifts Into Reverse, sans annotations. See also Fairfields Driving Safety, in Fits and Starts, Noah Veltmans variation of this graphic, and a paper on connected scatterplots by Haroz et al.

const replay = view(Inputs.button("Replay"));
replay;
display(ConnectedScatterplot(driving));
ConnectedScatterplot(driving)

We create the chart with the ConnectedScatterplot function shown below. It takes tabular data as input — one row for each data point. The side column indicates where we want the year label to be displayed next to the point in the scatterplot (these values have been hand-picked to limit occlusion). The other columns are the year, the average miles per person and the cost of gas that year. Click on the Array symbol below to inspect the data:

driving
const driving = FileAttachment("/data/driving.csv").csv({typed: true});

👆 This code snippet loads a static Observable Framework CSV FileAttachment, parsing typed values such as numbers. For your own chart youll want to create a similar data structure—maybe by reading from an API with d3.csv, or by running a sql query on a database.

function ConnectedScatterplot(driving) {
  // Declare the chart dimensions and margins.
  const width = 928;
  const height = 720;
  const marginTop = 20;
  const marginRight = 30;
  const marginBottom = 30;
  const marginLeft = 40;

  // Declare the positional encodings.
  const x = d3.scaleLinear()
      .domain(d3.extent(driving, (d) => d.miles))
      .nice()
      .range([marginLeft, width - marginRight]);

  const y = d3.scaleLinear()
      .domain(d3.extent(driving, (d) => d.gas))
      .nice()
      .range([height - marginBottom, marginTop]);

  const line = d3.line()
      .curve(d3.curveCatmullRom)
      .x((d) => x(d.miles))
      .y((d) => y(d.gas));

  const svg = d3.create("svg")
      .attr("width", width)
      .attr("height", height)
      .attr("viewBox", [0, 0, width, height])
      .attr("style", "max-width: 100%; height: auto;");

  const l = length(line(driving));

  svg.append("g")
      .attr("transform", `translate(0,${height - marginBottom})`)
      .call(d3.axisBottom(x).ticks(width / 80))
      .call((g) => g.select(".domain").remove())
      .call((g) => g.selectAll(".tick line").clone()
          .attr("y2", -height)
          .attr("stroke-opacity", 0.1))
      .call((g) => g.append("text")
          .attr("x", width - 4)
          .attr("y", -4)
          .attr("font-weight", "bold")
          .attr("text-anchor", "end")
          .attr("fill", "currentColor")
          .text("Miles per person per year"));

  svg.append("g")
      .attr("transform", `translate(${marginLeft},0)`)
      .call(d3.axisLeft(y).ticks(null, "$.2f"))
      .call((g) => g.select(".domain").remove())
      .call((g) => g.selectAll(".tick line").clone()
          .attr("x2", width).attr("stroke-opacity", 0.1))
      .call((g) => g.select(".tick:last-of-type text").clone()
          .attr("x", 4)
          .attr("text-anchor", "start")
          .attr("font-weight", "bold")
          .text("Cost per gallon"));

  svg.append("path")
      .datum(driving)
      .attr("fill", "none")
      .attr("stroke", "currentColor")
      .attr("stroke-width", 2.5)
      .attr("stroke-linejoin", "round")
      .attr("stroke-linecap", "round")
      .attr("stroke-dasharray", `0,${l}`)
      .attr("d", line)
      .transition()
          .duration(5000)
          .ease(d3.easeLinear)
          .attr("stroke-dasharray", `${l},${l}`);

  svg.append("g")
      .attr("fill", "var(--theme-background)")
      .attr("stroke", "currentColor")
      .attr("stroke-width", 2)
    .selectAll("circle")
    .data(driving)
    .join("circle")
      .attr("cx", (d) => x(d.miles))
      .attr("cy", (d) => y(d.gas))
      .attr("r", 3);

  const label = svg.append("g")
      .attr("font-family", "sans-serif")
      .attr("font-size", 10)
    .selectAll()
    .data(driving)
    .join("text")
      .attr("transform", (d) => `translate(${x(d.miles)},${y(d.gas)})`)
      .attr("fill-opacity", 0)
      .text((d) => d.year)
      .attr("stroke", "var(--theme-background)")
      .attr("paint-order", "stroke")
      .attr("fill", "currentColor")
      .each(function (d) {
        const t = d3.select(this);
        switch (d.side) {
          case "top": t.attr("text-anchor", "middle").attr("dy", "-0.7em"); break;
          case "right": t.attr("dx", "0.5em").attr("dy", "0.32em").attr("text-anchor", "start"); break;
          case "bottom": t.attr("text-anchor", "middle").attr("dy", "1.4em"); break;
          case "left": t.attr("dx", "-0.5em").attr("dy", "0.32em").attr("text-anchor", "end"); break;
        }
      });

  label.transition()
      .delay((d, i) => (length(line(driving.slice(0, i + 1))) / l) * (5000 - 125))
      .attr("fill-opacity", 1);

  return svg.node();
}

The length helper computes the total length of the given SVG path string; this is needed to apply the transition of stroke-dasharray across the length of the stroke.

function length(path) {
  return d3.create("svg:path").attr("d", path).node().getTotalLength();
}

For a simpler approach using Observable Plots concise API, see Plot: Connected scatterplot.