Build Charts with NextJS and D3.js

Introduction

D3.js (Data-Driven Documents) is a powerful JavaScript library for producing dynamic, interactive data visualizations from numerical data. Instead of providing pre-built charts, D3 offers low-level tools that allow you to manipulate DOM elements directly (typically SVGs, Canvas, or HTML).

The advantages:

  • Infinite Customization: You can create any type of chart you can imagine.
  • High Performance: Smoothly handles large data sets and animated transitions.
  • Strong Mathematical Ecosystem: Fully supports scale calculation functions, coordinate axis formatting, and complex geometric algorithms.

Detail

First, let's start with a simple example of drawing a line chart that describes monthly revenue during the year.

Please create the file app/api/revenue/route.ts to simulate a 12-month revenue api with a random amount from 1000-6000

import {NextResponse} from 'next/server'

export async function GET() {
const data = Array.from({length: 12}, (_, i) => ({
month: i + 1,
amount: Math.floor(Math.random() * 5000) + 1000,
}))
return NextResponse.json(data)
}


Create file app/d3/LineChart.tsx

'use client'

import * as d3 from 'd3'
import {useEffect, useRef, useState} from 'react'

type RevenueData = {
month: number
amount: number
}

const MONTH_NAMES = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
]

export default function LineChart() {
const [data, setData] = useState<RevenueData[]>([])
const containerRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
const [dimensions, setDimensions] = useState({width: 0, height: 0})
const margin = {top: 40, right: 40, bottom: 40, left: 60}

useEffect(() => {
fetch('/api/revenue')
.then(res => res.json())
.then(setData)
}, [])

useEffect(() => {
if (!containerRef.current) return
const resizeObserver = new ResizeObserver(entries => {
for (let entry of entries) {
setDimensions({
width: entry.contentRect.width,
height: entry.contentRect.height,
})
}
})
resizeObserver.observe(containerRef.current)
return () => resizeObserver.disconnect()
}, [])

useEffect(() => {
if (!svgRef.current || data.length === 0 || dimensions.width === 0) return

const {width, height} = dimensions
const svg = d3.select(svgRef.current)
svg.selectAll('*').remove()

const xScale = d3
.scaleLinear()
.domain([1, 12])
.range([margin.left, width - margin.right])

const yScale = d3
.scaleLinear()
.domain([0, d3.max(data, d => d.amount)! + 500])
.range([height - margin.bottom, margin.top])

const lineGenerator = d3
.line<RevenueData>()
.x(d => xScale(d.month))
.y(d => yScale(d.amount))
.curve(d3.curveMonotoneX)

const g = svg.append('g')

g.append('g')
.attr('transform', `translate(0,${height - margin.bottom})`)
.call(
d3
.axisBottom(xScale)
.ticks(12)
.tickFormat(d => MONTH_NAMES[(d as number) - 1])
)
.attr('font-family', 'inherit')

g.append('g')
.attr('transform', `translate(${margin.left},0)`)
.call(
d3.axisLeft(yScale).tickFormat(d => `$${d}`)
)

g.append('path')
.datum(data)
.attr('fill', 'none')
.attr('stroke', '#3b82f6')
.attr('stroke-width', 3)
.attr('d', lineGenerator)

g.selectAll('.dot')
.data(data)
.enter()
.append('circle')
.attr('cx', d => xScale(d.month))
.attr('cy', d => yScale(d.amount))
.attr('r', 5)
.attr('fill', '#3b82f6')
.attr('stroke', '#fff')
.attr('stroke-width', 2)
}, [data, dimensions])

return (
<div className="w-full h-full flex flex-col p-6 bg-white rounded-xl shadow-lg border border-gray-100">
<h2 className="text-xl font-bold text-gray-800 mb-4 text-center">
Monthly Revenue Report
</h2>
<div ref={containerRef} className="flex-1 min-h-[300px] relative">
<svg
ref={svgRef}
width={dimensions.width}
height={dimensions.height}
className="overflow-visible"
/>
</div>
<p className="text-xs text-gray-400 mt-4 text-center italic">
* Data is fetched from local REST API and updated dynamically
</p>
</div>
)
}

This component performs fetching revenue data from the API, then uses D3.js to calculate the coordinates (scales) and draw a line chart. It includes drawing coordinate axes, the lines connecting the data points, and circles at each data marker. Additionally, it uses ResizeObserver to ensure the chart is always co-dãn (responsive) according to the size of the parent container.


See the results as follows


You can see that when using D3, you actually create HTML DOM elements


Next, let's look at an example of a Candle tick chart that receives real-time data using the Server-Sent Events (SSE) protocol

Unlike Websocket, SSE only transmits 1-way data and data is text, applying in this case, the client only needs to receive data transmitted from the server.

Please create the file app/api/sse/route.ts to simulate SSE

export const dynamic = 'force-dynamic'

export async function GET() {
let intervalId: NodeJS.Timeout
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
let lastPrice = 50000

intervalId = setInterval(() => {
const data = Array.from({length: 10}).map(() => {
const open = lastPrice
const close = open + (Math.random() - 0.5) * 20
const high = Math.max(open, close) + Math.random() * 5
const low = Math.min(open, close) - Math.random() * 5
lastPrice = close
return {time: Date.now(), open, high, low, close}
})

const payload = `data: ${JSON.stringify(data)}\n\n`
controller.enqueue(encoder.encode(payload))
}, 100)

return () => {
console.log('Client closed connection. Cleaning up...')
clearInterval(intervalId)
}
},
cancel() {
console.log('Stream cancelled')
clearInterval(intervalId)
},
})

return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}

  • This code snippet sets up a Route Handler in NextJS to create a continuous stream of real-time data via Server-Sent Events (SSE). Every 100ms, it generates 10 simulated nến data (candlestick data) records with OHLC parameters (open, high, low, close) and sends them to the client via a continuous stream.
  • When you use force-dynamic, you are ordering NextJS to:
    • Not Cache: Must process this logic every time a new request arrives.
    • Bypass Static Build Mode: Force this Route to run entirely in a real Server environment (Dynamic Rendering).
    • Why does SSE need this? SSE is a long-running data stream. If you do not set force-dynamic, NextJS may misinterpret this as a static API and close the connection immediately after returning the first data, or worse, cache old data, preventing your chart from updating live.


Please create file app/d3/D3CandlestickChart.tsx

'use client'

import * as d3 from 'd3'
import {useEffect, useRef, useState} from 'react'
import type {Candle} from './type'

export default function D3CandlestickChart() {
const [data, setData] = useState<Candle[]>([])
const containerRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
const [dimensions, setDimensions] = useState({width: 0, height: 0})
const margin = {top: 20, right: 30, bottom: 30, left: 50}

useEffect(() => {
const eventSource = new EventSource('/api/sse')
eventSource.onmessage = event => {
const newTicks = JSON.parse(event.data)
setData(prev => [...prev, ...newTicks].slice(-100))
}
return () => eventSource.close()
}, [])

useEffect(() => {
if (!containerRef.current) return

const resizeObserver = new ResizeObserver(entries => {
for (let entry of entries) {
const {width, height} = entry.contentRect
setDimensions({width, height})
}
})

resizeObserver.observe(containerRef.current)
return () => resizeObserver.disconnect()
}, [])

useEffect(() => {
if (!svgRef.current || data.length === 0 || dimensions.width === 0) return

const {width, height} = dimensions
const svg = d3.select(svgRef.current)
svg.selectAll('*').remove()

const x = d3
.scaleLinear()
.domain([0, data.length])
.range([margin.left, width - margin.right])
const y = d3
.scaleLinear()
.domain([d3.min(data, d => d.low)!, d3.max(data, d => d.high)!])
.range([height - margin.bottom, margin.top])
const g = svg.append('g')
const barWidth = Math.max(
2,
(width - margin.left - margin.right) / data.length - 2
)

data.forEach((d, i) => {
const color = d.close > d.open ? '#26a69a' : '#ef5350'

g.append('line')
.attr('x1', x(i))
.attr('x2', x(i))
.attr('y1', y(d.low))
.attr('y2', y(d.high))
.attr('stroke', color)
.attr('stroke-width', 1)

g.append('rect')
.attr('x', x(i) - barWidth / 2)
.attr('y', y(Math.max(d.open, d.close)))
.attr('width', barWidth)
.attr('height', Math.max(1, Math.abs(y(d.open) - y(d.close))))
.attr('fill', color)
})
}, [data, dimensions])

return (
<div className="flex flex-col w-full h-full min-h-[400px] bg-gray-900 text-white p-4">
<h2 className="text-center mb-2 shrink-0">D3Js Responsive Candlestick</h2>
<div
ref={containerRef}
className="flex-1 w-full h-full min-h-0 overflow-hidden relative"
>
<svg
ref={svgRef}
width={dimensions.width}
height={dimensions.height}
className="bg-black border border-gray-700 block"
/>
</div>
<p className="text-xs mt-2 text-gray-400 shrink-0">
Current size: {Math.round(dimensions.width)}px x{' '}
{Math.round(dimensions.height)}px
</p>
</div>
)
}

This component is a real-time candlestick chart. It uses EventSource to listen for data from the SSE API and updates state continuously (limited to the last 100 candles). D3.js is used to draw rectangles (candle body) and lines (candle wick) based on OHLC values. The chart is also integrated with the ability to automatically adjust size and change candle color (green/red) depending on whether the price fluctuations are increasing or decreasing.


The result will be as follows, you can run in real life to see the chart transform according to the changes in data

Happy coding!

See more articles here.

Comments

Popular posts from this blog

All Practice Series

Kubernetes Deployment for Zero Downtime

Deploying a NodeJS Server on Google Kubernetes Engine

Setting up Kubernetes Dashboard with Kind

Practicing with Google Cloud Platform - Google Kubernetes Engine to deploy nginx

Using Kafka with Docker and NodeJS

Monitoring with cAdvisor, Prometheus and Grafana on Docker

Kubernetes Practice Series

Sitemap

NodeJS Practice Series