Optimizing Data Visualization Performance with NextJS, D3, and Canvas
Introduction
In the previous article, I guided you on how to use D3 and Canvas to Visualize Data. Now we will continue to dive deeper to understand some solutions that help optimize performance, aiming for a State-of-the-art" approach for building Real-time Data Visualization on the Web today.Some techniques I will apply to optimize performance are as follows:
- Data Buffering (Data Layer Throttling): utilizing requestAnimationFrame to ensure smooth processing and rendering according to the screen refresh rate, such as 60Hz or 120Hz.
- Utilizing Context Alpha Optimization: getContext('2d', { alpha: false }). When you turn off the alpha channel (transparency), the GPU will not have to calculate color blending with elements behind the canvas, significantly reducing the load on the graphics chip.
- Offscreen Canvas: Using an implicit Canvas to calculate beforehand before pushing to the main screen. The issue with drawing directly on the Canvas is that if you have to draw too much, users can still see your drawing process, causing slight lag. However, when applying this technique, it means we will draw on an auxiliary canvas then just copy these bitmaps to the main canvas (show to user), making the display process very smooth.
- Supplementing with a Web Worker part to push all SSE data processing to a separate thread, completely separate from the main thread.
- Zero copy:
- By default, when transferring data between threads, it uses Structured Clone, which means the content of that object will be copied to a new memory area. This obviously consumes CPU and RAM to process.
- When using Transferable Objects, here I use Float32Array to allow "transferring ownership" of memory from Worker to Main Thread without wasting any effort to copy a single byte.
- Utilizing the Visibility API to check document.visibilityState === 'visible' then only to call the api and let the chart operate, and vice versa to stop the api call and chart render and any other calculations.
- Meaning only when you are opening that tab on the browser it runs, but when you switch to another tab, minimize the browser it will stop.
- Utilizing the Intersection Observer API to check if the canvas shows 10% in the user's eyes then it needs to run the call api to get data and render, otherwise stop all operations.
Detail
Creating file app/d3/chart.worker.ts, this is a worker thread if your computer has multiple CPU cores then OS will try to push this workload processing in parallel in another core/// <reference lib="webworker" />
import type {Candle} from './type'
let eventSource: EventSource | null = null
let lastBackupBuffer: ArrayBuffer | null = null
let accumulatedData: number[][] = []
const MAX_ITEMS = 10_000
function connect() {
if (eventSource) return
eventSource = new EventSource('/api/sse')
console.log('Worker: SSE Connected')
eventSource.onmessage = event => {
const newTicks: Candle[] = JSON.parse(event.data)
newTicks.forEach(t => {
accumulatedData.push([t.time, t.open, t.high, t.low, t.close])
})
if (accumulatedData.length > MAX_ITEMS) {
accumulatedData = accumulatedData.slice(-MAX_ITEMS)
}
const data = new Float32Array(accumulatedData.length * 6)
for (let i = 0; i < accumulatedData.length; i++) {
const offset = i * 6
data.set(accumulatedData[i], offset)
}
lastBackupBuffer = data.buffer.slice(0)
sendData(data.buffer)
}
eventSource.onerror = () => {
console.error('Worker: SSE Error, disconnecting...')
disconnect()
}
}
function disconnect() {
if (eventSource) {
eventSource.close()
eventSource = null
console.log('Worker: SSE Disconnected')
}
}
self.onmessage = (e: MessageEvent) => {
const {type} = e.data
switch (type) {
case 'CONNECT':
case 'RESUME':
connect()
if (lastBackupBuffer) {
sendData(lastBackupBuffer.slice(0))
}
break
case 'SLEEP':
disconnect()
break
}
}
function sendData(data: ArrayBuffer) {
self.postMessage(data, [data])
}
The code above initializes a Web Worker to handle Server-Sent Events (SSE) data stream separate from the Main Thread. Main functionalities include:
- Connection management: Setting up and closing SSE connection via connect() and disconnect() functions.
- Data accumulation: Storing candles in accumulatedData array, limited to a maximum of 10,000 items to avoid memory overflow.
- Transmission optimization (Zero-copy): Converting data into Float32Array and using Transferable Objects (sending ArrayBuffer) to transfer memory ownership to Main Thread without copying data.
- State management: Responding to CONNECT, RESUME, and SLEEP signals from Main Thread to save resources when tab is hidden or canvas is not visible.
Creating file app/d3/OptimizedChart.tsx
The code above displays candlestick chart (Candlestick Chart) with high performance optimization techniques:
'use client'
import * as d3 from 'd3'
import {useEffect, useRef, useState} from 'react'
export default function OptimizedChart() {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const workerRef = useRef<Worker>(null)
const dataRef = useRef<Float32Array | null>(null)
const [dimensions, setDimensions] = useState({width: 0, height: 0})
const [dpr, setDpr] = useState(1)
const activity = useRef({isVisible: true, isIntersecting: false})
const margin = {top: 20, right: 30, bottom: 30, left: 50}
useEffect(() => {
setDpr(window.devicePixelRatio || 1)
workerRef.current = new Worker(
new URL('./chart.worker.ts', import.meta.url)
)
workerRef.current.onmessage = (e: MessageEvent) => {
dataRef.current = new Float32Array(e.data)
}
workerRef.current.postMessage({type: 'CONNECT'})
const updateWorkerState = () => {
const shouldRun =
activity.current.isVisible && activity.current.isIntersecting
workerRef.current?.postMessage({type: shouldRun ? 'RESUME' : 'SLEEP'})
}
const handleVisibility = () => {
activity.current.isVisible = document.visibilityState === 'visible'
updateWorkerState()
}
const intersectionObserver = new IntersectionObserver(
([entry]) => {
activity.current.isIntersecting = entry.isIntersecting
updateWorkerState()
},
{threshold: 0.1}
)
if (canvasRef.current) intersectionObserver.observe(canvasRef.current)
document.addEventListener('visibilitychange', handleVisibility)
return () => {
workerRef.current?.terminate()
intersectionObserver.disconnect()
document.removeEventListener('visibilitychange', handleVisibility)
}
}, [])
useEffect(() => {
if (!containerRef.current) return
const resizeObserver = new ResizeObserver(entries => {
for (let entry of entries) {
const {width, height} = entry.contentRect
if (width > 0 && height > 0) setDimensions({width, height})
}
})
resizeObserver.observe(containerRef.current)
return () => resizeObserver.disconnect()
}, [])
useEffect(() => {
if (!canvasRef.current || dimensions.width === 0) return
const mainCanvas = canvasRef.current
const mainCtx = mainCanvas.getContext('2d', {alpha: false})!
const offscreen = document.createElement('canvas')
const offCtx = offscreen.getContext('2d', {alpha: false})!
mainCanvas.width = dimensions.width * dpr
mainCanvas.height = dimensions.height * dpr
offscreen.width = dimensions.width * dpr
offscreen.height = dimensions.height * dpr
offCtx.scale(dpr, dpr)
let animId: number
const render = () => {
const isActivelyRunning =
activity.current.isVisible && activity.current.isIntersecting
const data = dataRef.current
if (isActivelyRunning && data && data.length > 0) {
const {width, height} = dimensions
offCtx.fillStyle = '#0b0e11'
offCtx.fillRect(0, 0, width, height)
const xScale = d3
.scaleLinear()
.domain([0, data.length / 6])
.range([margin.left, width - margin.right])
let minPrice = Infinity,
maxPrice = -Infinity
for (let i = 0; i < data.length; i += 6) {
if (data[i + 3] < minPrice) minPrice = data[i + 3]
if (data[i + 2] > maxPrice) maxPrice = data[i + 2]
}
const yScale = d3
.scaleLinear()
.domain([minPrice * 0.999, maxPrice * 1.001])
.range([height - margin.bottom, margin.top])
const candleWidth = Math.max(
1,
((width - margin.left - margin.right) / (data.length / 6)) * 0.8
)
for (let i = 0; i < data.length; i += 6) {
const open = data[i + 1],
high = data[i + 2],
low = data[i + 3],
close = data[i + 4]
const color = close > open ? '#26a69a' : '#ef5350'
const xPos = xScale(i / 6)
offCtx.strokeStyle = color
offCtx.beginPath()
offCtx.moveTo(xPos, yScale(low))
offCtx.lineTo(xPos, yScale(high))
offCtx.stroke()
offCtx.fillStyle = color
const yOpen = yScale(open),
yClose = yScale(close)
offCtx.fillRect(
xPos - candleWidth / 2,
Math.min(yOpen, yClose),
candleWidth,
Math.max(Math.abs(yOpen - yClose), 1)
)
}
mainCtx.drawImage(
offscreen,
0,
0,
dimensions.width * dpr,
dimensions.height * dpr
)
}
animId = requestAnimationFrame(render)
}
animId = requestAnimationFrame(render)
return () => cancelAnimationFrame(animId)
}, [dimensions, dpr])
return (
<div className="flex flex-col w-full h-full bg-[#0b0e11] text-white overflow-hidden">
<div className="p-3 bg-[#161a1e] border-b border-gray-800 flex justify-between items-center shrink-0">
<span className="font-bold text-sm tracking-widest text-blue-400 uppercase">
Candles Tick Chart
</span>
<div className="flex gap-2">
<span className="text-[10px] bg-blue-500/10 text-blue-500 px-2 py-0.5 rounded border border-blue-500/20">
DPR: {dpr}
</span>
</div>
</div>
<div
ref={containerRef}
className="flex-1 w-full h-full relative bg-black cursor-crosshair"
>
<canvas ref={canvasRef} className="w-full h-full block" />
</div>
<div className="p-2 text-[9px] text-gray-500 flex justify-between bg-[#161a1e] border-t border-gray-800">
<span>
RESOLUTION: {Math.round(dimensions.width)} x{' '}
{Math.round(dimensions.height)}
</span>
</div>
</div>
)
}
- Web Worker Integration: Receiving candle data from worker and storing in dataRef to avoid continuous React re-render.
- Render Loop Control: Utilizing requestAnimationFrame to synchronize drawing with screen refresh rate.
- Double Buffering Technique: Drawing data on an intermediate offscreen canvas before copying to main canvas to eliminate lag/flicker phenomenon.
- GPU Optimization: Utilizing { alpha: false } when initializing 2D context to reduce color blending calculation load for GPU.
- Smart Resource Saving: Combining Visibility API and Intersection Observer to only perform calculation and render when user is actually looking at the chart.
- Resolution Handling: Automatically adjusting Canvas size according to Device Pixel Ratio (DPR) (is the ratio between physical pixels on screen and logical pixels (CSS)) to ensure sharp images on Retina/High-DPI screens.
- Example: Retina screen of Macbook or iPhone has DPR of 2 or 3.
- Effect: If you draw a $100 \times 100$ Canvas on DPR 2 screen without scaling ratio, image will be blurred. Our code multiplies Canvas size with DPR to ensure candles and lines are always sharp (Crisp rendering).
The result look like this, you could switch to another tab and back to see the SSE api to recall
You can check the Memory tab in Chrome DevTools to monitor the memory allocation and deallocation process. The blue upward lines represent memory allocation (initialization), while the gray downward lines indicate that the memory has been cleaned up (garbage collected). This demonstrates that the zero-copy technique is working successfully, as transferring data to another thread means that the memory space has been released from the original thread.
Comments
Post a Comment