← Back to list

Visualizing HOS Logs: Building a Basic ELD Duty Status Chart with D3.js in Angular

In this post I’ll show you how to build a fully functional ELD duty status chart using D3.js in an Angular standalone component, step by…

Đorđe Krstić · 2026-06-10 19:58 · 3 claps · 6.1 min read
#d3js #web-development #angular #typescript #javascript
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Visualizing HOS Logs: Building a Basic ELD Duty Status Chart with D3.js in Angular

In this post I’ll show you how to build a fully functional ELD duty status chart using D3.js in an Angular standalone component, step by step, the way I actually built it.

1. Prerequisites

I’m assuming you already have an Angular app set up. If not, run:

npx @angular/cli@latest new eld-chart --standalone --skip-tests

Then install D3 and its TypeScript types:

npm install d3
npm install --save-dev @types/d3

Generate the chart component:

npx ng g c eld-chart

And add it to your App component

app.ts:

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [EldChartComponent],
  template: `<app-eld-chart />`
})
export class AppComponent {}

app.html

<app-eld-chart />

2. Data Structure

Let’s define what our data looks like. Each log entry represents a period of time a driver spent in a specific duty status:

interface LogEntry {
  startDate: string;
  endDate: string;
  yLeftAxis: 'OFF' | 'SB' | 'ON' | 'D';
  eventCode: number;
}

Here’s the sample data we’ll use:

logs: LogEntry[] = [
  { startDate: '2026-02-15T00:00:00+01:00', endDate: '2026-02-15T04:12:00+01:00', yLeftAxis: 'OFF', eventCode: 11 },
  { startDate: '2026-02-15T04:12:00+01:00', endDate: '2026-02-15T08:23:00+01:00', yLeftAxis: 'SB', eventCode: 12 },
  { startDate: '2026-02-15T08:23:00+01:00', endDate: '2026-02-15T09:21:00+01:00', yLeftAxis: 'ON', eventCode: 13 },
  { startDate: '2026-02-15T09:21:00+01:00', endDate: '2026-02-15T14:38:00+01:00', yLeftAxis: 'D', eventCode: 21 },
];

3. Component Setup

Let’s set up the component with all the properties we’ll need:

@Component({
  selector: 'app-eld-chart',
  standalone: true,
  templateUrl: './eld-chart.html',
  styleUrls: ['./eld-chart.css'],
})
export class EldChart implements OnInit {
  @ViewChild('chart', { static: true }) chartRef!: ElementRef;

  private svg!: d3.Selection<SVGGElement, unknown, null, undefined>;
  private x!: d3.ScaleLinear<number, number>;
  private y!: d3.ScalePoint<string>;
  private width!: number;
  private height!: number;
  private cellSize!: number;

  readonly statuses = ['OFF', 'SB', 'D', 'ON'];
  readonly margin = { top: 30, right: 80, bottom: 10, left: 60 };
  readonly xAxisLabels = ['M','1','2','3','4','5','6','7','8','9','10','11','N','1','2','3','4','5','6','7','8','9','10','11','M'];

  ngOnInit(): void {
    this.buildChart();
  }
}

Add this to the template:

eld-chart.html

<div #chart class="chart-container"></div>

eld-chart.css

.chart-container {
  width: 100%;
  height: 300px;
}

4. Setting Up the SVG

The buildChart() sets up the SVG and calls everything else in order:

private buildChart(): void {
  const element = this.chartRef.nativeElement;
  this.width = element.offsetWidth - this.margin.left - this.margin.right;
  this.cellSize = this.width / 24;
  this.height = 4 * this.cellSize;

  this.svg = d3
    .select(element)
    .append('svg')
    .attr('width', this.width + this.margin.left + this.margin.right)
    .attr('height', this.height + this.margin.top + this.margin.bottom)
    .append('g')
    .attr('transform', `translate(${this.margin.left},${this.margin.top})`);

  this.buildScales();
  this.buildAxes();
  this.buildMinorLines();
  this.buildSteppedLine();
}

5. Building Scales

private buildScales(): void {
  this.x = d3.scaleLinear().domain([0, 1440]).range([0, this.width]);
  this.y = d3.scalePoint().domain(this.statuses).range([0, this.height]).padding(0.5);
}
  • The X scale maps 0 to 1440 (total minutes in a day) to the chart width.
  • The Y scale uses scalePoint that distributes four status labels evenly across the chart height with padding so they don't sit at the very edges.

6. Building the X Top Axis

We’re adding and changing ticks text so it is simpler with better visibility.

const xAxis = d3
  .axisTop(this.x)
  .tickValues(d3.range(0, 1441, 60))
  .tickFormat((_, i) => this.xAxisLabels[i])
  .tickSize(-this.height);

this.svg.append('g').call(xAxis);

7. Building the Y Left Axis

The Y left axis shows the four status labels. We want the tick lines to extend all the way across to the right, but the text labels should stay in their original position:

const yLeftAxis = d3.axisLeft(this.y).tickSize(-this.width);
const yLeftGroup = this.svg.append('g').call(yLeftAxis);

yLeftGroup.selectAll('.tick line').attr('transform', 'translate(0, 28)');
yLeftGroup.select('.domain').remove();

8. Building the Y Right Axis

The Y right axis shows combined time for each status. For now we hardcode them, but in a real application these would come from your data:

const hardcodedTimes = ['04:12', '04:11', '05:17', '00:58'];
const yRight = d3.scalePoint().domain(hardcodedTimes).range([0, this.height]).padding(0.5);
const yRightGroup = this.svg
  .append('g')
  .attr('transform', `translate(${this.width},0)`)
  .call(d3.axisRight(yRight).tickSize(0));

yRightGroup.select('.domain').remove();

9. Building Minor Lines

Now we’ll add minor ticks for every 15 and 30 minutes.

We need helper method to avoid repeating the same SVG line code:

private appendMinorLine(xPos: number, y1: number, y2: number): void {
  this.svg
    .append('line')
    .attr('x1', xPos)
    .attr('y1', y1)
    .attr('x2', xPos)
    .attr('y2', y2)
    .attr('stroke', '#000')
    .attr('stroke-width', 0.5);
}

Now the minor lines method:

private buildMinorLines(): void {
  const offY = this.y('OFF')!;
  const sbY = this.y('SB')!;
  const onY = this.y('ON')!;
  const dY = this.y('D')!;
  const halfCell = this.cellSize / 2;
  const quarterCell = this.cellSize / 4;

  // Half hour lines
  d3.range(30, 1440, 60).forEach((minutes) => {
    const xPos = this.x(minutes);
    this.appendMinorLine(xPos, offY, offY - halfCell);
    this.appendMinorLine(xPos, sbY, sbY - halfCell);
    this.appendMinorLine(xPos, onY, onY + halfCell);
    this.appendMinorLine(xPos, dY, dY + halfCell);
  });

  // Quarter hour lines
  d3.range(15, 1440, 30)
    .filter((m) => m % 60 !== 30 && m % 60 !== 0)
    .forEach((minutes) => {
      const xPos = this.x(minutes);
      this.appendMinorLine(xPos, offY - halfCell + quarterCell, offY - halfCell);
      this.appendMinorLine(xPos, sbY - halfCell + quarterCell, sbY - halfCell);
      this.appendMinorLine(xPos, onY + halfCell - quarterCell, onY + halfCell);
      this.appendMinorLine(xPos, dY + halfCell - quarterCell, dY + halfCell);
    });
}

10. Building the Stepped Line

The stepped line shows the driver’s duty status changes across the day.

We need a helper to parse ISO date strings into minutes:

private parseTime(dateString: string): number {
  const timePart = dateString.split('T')[1];
  const [hours, minutes] = timePart.split(':').map(Number);
  const total = hours * 60 + minutes;
  return total === 0 && dateString !== this.logs[0].startDate ? 1440 : total;
}

Now the stepped line:

private buildSteppedLine(): void {
  const points: [number, number][] = [];

  this.logs.forEach((log, i) => {
    const startX = this.x(this.parseTime(log.startDate));
    const endX = this.x(this.parseTime(log.endDate));
    const statusY = this.y(log.yLeftAxis)!;

    if (i === 0) points.push([startX, statusY]);
    points.push([endX, statusY]);

    if (i < this.logs.length - 1) {
      const nextStatusY = this.y(this.logs[i + 1].yLeftAxis)!;
      points.push([endX, nextStatusY]);
    }
  });

  const lineGenerator = d3.line<[number, number]>()
    .x((d) => d[0])
    .y((d) => d[1]);

  this.svg
    .append('path')
    .datum(points)
    .attr('fill', 'none')
    .attr('stroke', '#336699')
    .attr('stroke-width', 2)
    .attr('d', lineGenerator);
}

11. Full Component

Here’s the complete eld-chart.ts for reference:

import { Component, OnInit, ElementRef, ViewChild } from '@angular/core';
import * as d3 from 'd3';

interface LogEntry {
  startDate: string;
  endDate: string;
  yLeftAxis: 'OFF' | 'SB' | 'ON' | 'D';
  eventCode: number;
}

@Component({
  selector: 'app-eld-chart',
  standalone: true,
  templateUrl: './eld-chart.html',
  styleUrls: ['./eld-chart.css'],
})
export class EldChart implements OnInit {
  @ViewChild('chart', { static: true }) chartRef!: ElementRef;

  logs: LogEntry[] = [
    {
      startDate: '2026-02-15T00:00:00+01:00',
      endDate: '2026-02-15T04:12:00+01:00',
      yLeftAxis: 'OFF',
      eventCode: 11,
    },
    {
      startDate: '2026-02-15T04:12:00+01:00',
      endDate: '2026-02-15T08:23:00+01:00',
      yLeftAxis: 'SB',
      eventCode: 12,
    },
    {
      startDate: '2026-02-15T08:23:00+01:00',
      endDate: '2026-02-15T09:21:00+01:00',
      yLeftAxis: 'ON',
      eventCode: 13,
    },
    {
      startDate: '2026-02-15T09:21:00+01:00',
      endDate: '2026-02-15T14:38:00+01:00',
      yLeftAxis: 'D',
      eventCode: 21,
    },
  ];

  private svg!: d3.Selection<SVGGElement, unknown, null, undefined>;
  private x!: d3.ScaleLinear<number, number>;
  private y!: d3.ScalePoint<string>;
  private width!: number;
  private height!: number;
  private cellSize!: number;

  readonly statuses = ['OFF', 'SB', 'D', 'ON'];
  readonly margin = { top: 30, right: 80, bottom: 10, left: 60 };
  readonly xAxisLabels = [
    'M',
    '1',
    '2',
    '3',
    '4',
    '5',
    '6',
    '7',
    '8',
    '9',
    '10',
    '11',
    'N',
    '1',
    '2',
    '3',
    '4',
    '5',
    '6',
    '7',
    '8',
    '9',
    '10',
    '11',
    'M',
  ];

  ngOnInit(): void {
    this.buildChart();
  }

  private parseTime(dateString: string): number {
    const timePart = dateString.split('T')[1];
    const [hours, minutes] = timePart.split(':').map(Number);
    const total = hours * 60 + minutes;
    return total === 0 && dateString !== this.logs[0].startDate ? 1440 : total;
  }

  private buildChart(): void {
    const element = this.chartRef.nativeElement;
    this.width = element.offsetWidth - this.margin.left - this.margin.right;
    this.cellSize = this.width / 24;
    this.height = 4 * this.cellSize;

    this.svg = d3
      .select(element)
      .append('svg')
      .attr('width', this.width + this.margin.left + this.margin.right)
      .attr('height', this.height + this.margin.top + this.margin.bottom)
      .append('g')
      .attr('transform', `translate(${this.margin.left},${this.margin.top})`);

    this.buildScales();
    this.buildAxes();
    this.buildMinorLines();
    this.buildSteppedLine();
  }

  private buildScales(): void {
    this.x = d3.scaleLinear().domain([0, 1440]).range([0, this.width]);
    this.y = d3.scalePoint().domain(this.statuses).range([0, this.height]).padding(0.5);
  }

  private buildAxes(): void {
    // X top axis
    const xAxis = d3
      .axisTop(this.x)
      .tickValues(d3.range(0, 1441, 60))
      .tickFormat((_, i) => this.xAxisLabels[i])
      .tickSize(-this.height);

    this.svg.append('g').call(xAxis);

    // Y left axis
    const yLeftAxis = d3.axisLeft(this.y).tickSize(-this.width);
    const yLeftGroup = this.svg.append('g').call(yLeftAxis);
    yLeftGroup.selectAll('.tick line').attr('transform', 'translate(0, 28)');
    yLeftGroup.select('.domain').remove();

    // Y right axis
    const hardcodedTimes = ['04:12', '04:11', '05:17', '00:58'];
    const yRight = d3.scalePoint().domain(hardcodedTimes).range([0, this.height]).padding(0.5);
    const yRightGroup = this.svg
      .append('g')
      .attr('transform', `translate(${this.width},0)`)
      .call(d3.axisRight(yRight).tickSize(0));
    yRightGroup.select('.domain').remove();
  }

  private buildMinorLines(): void {
    const offY = this.y('OFF')!;
    const sbY = this.y('SB')!;
    const onY = this.y('ON')!;
    const dY = this.y('D')!;
    const halfCell = this.cellSize / 2;
    const quarterCell = this.cellSize / 4;

    // Half hour lines
    d3.range(30, 1440, 60).forEach((minutes) => {
      const xPos = this.x(minutes);
      this.appendMinorLine(xPos, offY, offY - halfCell);
      this.appendMinorLine(xPos, sbY, sbY - halfCell);
      this.appendMinorLine(xPos, onY, onY + halfCell);
      this.appendMinorLine(xPos, dY, dY + halfCell);
    });

    // Quarter hour lines
    d3.range(15, 1440, 30)
      .filter((m) => m % 60 !== 30 && m % 60 !== 0)
      .forEach((minutes) => {
        const xPos = this.x(minutes);
        this.appendMinorLine(xPos, offY - halfCell + quarterCell, offY - halfCell);
        this.appendMinorLine(xPos, sbY - halfCell + quarterCell, sbY - halfCell);
        this.appendMinorLine(xPos, onY + halfCell - quarterCell, onY + halfCell);
        this.appendMinorLine(xPos, dY + halfCell - quarterCell, dY + halfCell);
      });
  }

  private appendMinorLine(xPos: number, y1: number, y2: number): void {
    this.svg
      .append('line')
      .attr('x1', xPos)
      .attr('y1', y1)
      .attr('x2', xPos)
      .attr('y2', y2)
      .attr('stroke', '#000')
      .attr('stroke-width', 0.5);
  }

  private buildSteppedLine(): void {
    const points: [number, number][] = [];

    this.logs.forEach((log, i) => {
      const startX = this.x(this.parseTime(log.startDate));
      const endX = this.x(this.parseTime(log.endDate));
      const statusY = this.y(log.yLeftAxis)!;

      if (i === 0) points.push([startX, statusY]);
      points.push([endX, statusY]);

      if (i < this.logs.length - 1) {
        const nextStatusY = this.y(this.logs[i + 1].yLeftAxis)!;
        points.push([endX, nextStatusY]);
      }
    });

    const lineGenerator = d3
      .line<[number, number]>()
      .x((d) => d[0])
      .y((d) => d[1]);

    this.svg
      .append('path')
      .datum(points)
      .attr('fill', 'none')
      .attr('stroke', '#336699')
      .attr('stroke-width', 2)
      .attr('d', lineGenerator);
  }
}

In future posts I’ll extend this chart with tooltips, zoom and pan, and partitions. If you have an idea for a chart you’d like to see built, write it in the comments.


메타데이터
post_id
ebd022f238b3
slug
visualizing-hos-logs-building-a-basic-eld-duty-status-chart-with-d3-js-in-angular-ebd022f238b3
url
https://medium.com/@djkrstic9/visualizing-hos-logs-building-a-basic-eld-duty-status-chart-with-d3-js-in-angular-ebd022f238b3
canonical_url
https://medium.com/@djkrstic9/visualizing-hos-logs-building-a-basic-eld-duty-status-chart-with-d3-js-in-angular-ebd022f238b3
author_url
https://medium.com/@djkrstic9
status
ok
fetched_at
2026-06-16 19:09:56