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…
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