Skip to content

Multiline Chart

Multiple series on the same chart, each rendered as its own line. The data shape is an array of series objects, and each series participates in the enter/exit lifecycle. Add or remove a series and it appears or disappears smoothly. Axes are handled by a reusable AxisChart attachment.

Live Preview

Full Source

js
import { select, pointer } from 'd3-selection';
import { D3Blueprint } from 'd3-blueprint';
import { scaleLinear, scaleOrdinal } from 'd3-scale';
import { max } from 'd3-array';
import { line, curveCatmullRom } from 'd3-shape';
import { AxisChart } from './charts/AxisChart.js';
import { tooltipPlugin } from './plugins/Tooltip.js';

const COLORS = ['steelblue', '#e45858', '#50a060'];
const MARGIN = { top: 20, right: 80, bottom: 30, left: 45 };

class MultilineChart extends D3Blueprint {
  initialize() {
    this.xScale = scaleLinear();
    this.yScale = scaleLinear();
    this.colorScale = scaleOrdinal().range(COLORS);
    this.lineFn = line().curve(curveCatmullRom);

    this.chart = this.base
      .append('g')
      .attr('transform', `translate(${MARGIN.left},${MARGIN.top})`);

    // Reusable axis component
    this.attach('axes', AxisChart, this.chart);

    // Layer 1: one path per series
    const linesGroup = this.chart.append('g').attr('class', 'lines');

    this.layer('lines', linesGroup, {
      dataBind: (selection, data) => {
        return selection.selectAll('path').data(data, (d) => d.name);
      },
      insert: (selection) => {
        return selection.append('path');
      },
      events: {
        enter: (selection) => {
          selection
            .attr('fill', 'none')
            .attr('stroke', (d) => this.colorScale(d.name))
            .attr('stroke-width', 2)
            .attr('d', (d) => this.lineFn(d.values));
        },
        'merge:transition': (transition) => {
          transition
            .duration(800)
            .attr('stroke', (d) => this.colorScale(d.name))
            .attr('d', (d) => this.lineFn(d.values));
        },
        'exit:transition': (transition) => {
          transition.duration(200).attr('opacity', 0).remove();
        },
      },
    });

    // Layer 2: inline labels at the end of each line
    const labelsGroup = this.chart.append('g').attr('class', 'labels');

    this.layer('labels', labelsGroup, {
      dataBind: (selection, data) => {
        return selection.selectAll('text').data(data, (d) => d.name);
      },
      insert: (selection) => {
        return selection.append('text');
      },
      events: {
        enter: (selection) => {
          selection
            .attr('dy', '0.35em')
            .attr('font-size', '11px')
            .attr('font-weight', '500')
            .attr('fill', (d) => this.colorScale(d.name))
            .text((d) => d.name);
          this._positionLabels(selection);
        },
        'merge:transition': (transition) => {
          this._positionLabels(transition);
          transition.duration(800);
        },
        'exit:transition': (transition) => {
          transition.duration(200).attr('opacity', 0).remove();
        },
      },
    });

    // Crosshair + overlay for mouse tracking
    const innerWidth = 600 - MARGIN.left - MARGIN.right;
    const innerHeight = 400 - MARGIN.top - MARGIN.bottom;

    this.crosshair = this.chart
      .append('line')
      .attr('class', 'crosshair')
      .attr('y1', 0)
      .attr('y2', innerHeight)
      .attr('stroke', '#999')
      .attr('stroke-dasharray', '4,3')
      .attr('stroke-width', 1)
      .style('display', 'none');

    this.tooltip = tooltipPlugin(this.chart);

    this.overlay = this.chart
      .append('rect')
      .attr('class', 'overlay')
      .attr('width', innerWidth)
      .attr('height', innerHeight)
      .attr('fill', 'none')
      .style('pointer-events', 'all');
  }

  _positionLabels(sel) {
    sel
      .attr('x', (d) => {
        const last = d.values[d.values.length - 1];
        return this.xScale(last.x) + 8;
      })
      .attr('y', (d) => {
        const last = d.values[d.values.length - 1];
        return this.yScale(last.value);
      });
  }

  preDraw(data) {
    const innerWidth = 600 - MARGIN.left - MARGIN.right;
    const innerHeight = 400 - MARGIN.top - MARGIN.bottom;

    const allValues = data.flatMap((s) => s.values);
    const pointCount = data[0].values.length;

    this.xScale.domain([0, pointCount - 1]).range([0, innerWidth]);
    this.yScale.domain([0, max(allValues, (d) => d.value) * 1.1]).range([innerHeight, 0]);
    this.colorScale.domain(data.map((d) => d.name));

    this.lineFn
      .x((d) => this.xScale(d.x))
      .y((d) => this.yScale(d.value));

    this.attached.axes.config({
      xScale: this.xScale,
      yScale: this.yScale,
      innerWidth,
      innerHeight,
      duration: 800,
      xTickCount: 6,
      yTickCount: 5,
    });
  }

  postDraw(data) {
    const { xScale, yScale, colorScale, tooltip, crosshair, overlay } = this;
    const pointCount = data[0].values.length;

    overlay
      .on('mousemove', function (event) {
        const [mx] = pointer(event, this);
        const idx = Math.max(0, Math.min(Math.round(xScale.invert(mx)), pointCount - 1));
        const cx = xScale(idx);

        crosshair.attr('x1', cx).attr('x2', cx).style('display', null);

        const lines = data.map((s) => ({
          text: `${s.name}: ${s.values[idx].value}`,
          color: colorScale(s.name),
        }));
        tooltip.show(cx, yScale(data[0].values[idx].value), lines);
      })
      .on('mouseleave', () => {
        crosshair.style('display', 'none');
        tooltip.hide();
      });
  }
}

Usage

js
import { select } from 'd3-selection';

const chart = new MultilineChart(
  select('#chart').append('svg').attr('width', 600).attr('height', 400),
);

// Each series has a name and an array of { x, value } points
await chart.draw([
  {
    name: 'Product A',
    values: Array.from({ length: 20 }, (_, i) => ({
      x: i,
      value: Math.round(50 + Math.random() * 100),
    })),
  },
  {
    name: 'Product B',
    values: Array.from({ length: 20 }, (_, i) => ({
      x: i,
      value: Math.round(50 + Math.random() * 100),
    })),
  },
]);

Key Takeaways

  • AxisChart handles axis rendering: the parent configures it with scales and tick counts in preDraw().
  • Each series is a single datum: the lines layer binds the array of series objects, so each <path> maps to one series keyed by d.name.
  • Inline labels: a second layer places a <text> at the end of each line, positioned from the last data point. Extra right margin makes room for them.
  • Color scale: scaleOrdinal assigns consistent colors by series name.
  • curveCatmullRom: smooth interpolation that passes through each data point.
  • Crosshair tooltip: a transparent overlay captures mousemove events, xScale.invert() finds the nearest data index, and the tooltip displays each series value colored by colorScale.