Skip to content

Instantly share code, notes, and snippets.

@gbluv
Last active March 10, 2026 12:52
Show Gist options
  • Select an option

  • Save gbluv/2b4968328c659e9269afb5ccda9db253 to your computer and use it in GitHub Desktop.

Select an option

Save gbluv/2b4968328c659e9269afb5ccda9db253 to your computer and use it in GitHub Desktop.
line chart d3
.chartContainer {
position: relative;
width: 100%;
background-color: #0a1a14;
border-radius: 8px;
overflow: hidden;
}
.chart {
display: block;
}
.mainArea {
transition: opacity 0.2s ease;
}
.mainLine {
transition: opacity 0.2s ease;
}
.forecastArea {
transition: opacity 0.2s ease;
}
.forecastLine {
transition: opacity 0.2s ease;
}
.benchmarkLine {
transition: opacity 0.2s ease;
}
.xAxis path,
.xAxis line {
stroke: rgba(255, 255, 255, 0.2);
}
.xAxis text {
fill: rgba(255, 255, 255, 0.6);
font-size: 12px;
}
.yAxis path,
.yAxis line {
stroke: rgba(255, 255, 255, 0.2);
}
.yAxis text {
fill: rgba(255, 255, 255, 0.6);
font-size: 12px;
}
.grid line {
stroke: rgba(255, 255, 255, 0.1);
stroke-dasharray: 2, 4;
}
.grid path {
display: none;
}
.marker {
pointer-events: none;
transition: opacity 0.15s ease;
}
.verticalLine {
pointer-events: none;
}
.overlay {
cursor: crosshair;
}
.tooltip {
position: fixed;
pointer-events: none;
opacity: 0;
background-color: rgba(20, 30, 25, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
padding: 10px 14px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
z-index: 1000;
transition: opacity 0.15s ease;
backdrop-filter: blur(8px);
}
.tooltipContent {
display: flex;
flex-direction: column;
gap: 4px;
}
.tooltipDate {
color: rgba(255, 255, 255, 0.8);
font-size: 13px;
font-weight: 500;
}
.tooltipValue {
display: flex;
align-items: center;
gap: 6px;
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
font-weight: 600;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.countDot {
background-color: #4ade80;
}
.benchmarkDot {
background-color: white;
}
.forecastDot {
background-color: #5eead4;
}
/* Legend styles */
.legend {
display: flex;
gap: 24px;
padding: 12px 16px;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 8px;
margin-bottom: 16px;
}
.legendItem {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 6px 12px;
border-radius: 4px;
transition: background-color 0.2s ease;
user-select: none;
}
.legendItem:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.legendItemActive {
background-color: rgba(255, 255, 255, 0.15);
}
.legendItemDisabled {
opacity: 0.4;
}
.legendColor {
width: 16px;
height: 3px;
border-radius: 2px;
}
.legendColorCount {
background-color: #4ade80;
}
.legendColorBenchmark {
background: repeating-linear-gradient(
90deg,
white 0px,
white 6px,
transparent 6px,
transparent 12px
);
}
.legendColorForecast {
background: linear-gradient(90deg, #1a5c5c, #5eead4);
}
.legendLabel {
color: rgba(255, 255, 255, 0.8);
font-size: 13px;
font-weight: 500;
}
/* Responsive styles */
@media (max-width: 768px) {
.legend {
flex-wrap: wrap;
gap: 12px;
}
.legendItem {
padding: 4px 8px;
}
.tooltipDate {
font-size: 12px;
}
.tooltipValue {
font-size: 13px;
}
}
'use client';
import React, { useRef, useEffect, useState, useCallback } from 'react';
import * as d3 from 'd3';
import styles from './D3Chart.module.css';
const D3Chart = ({
data = [],
benchmarkData = [],
forecastData = [],
width = 1200,
height = 400,
margin = { top: 20, right: 30, bottom: 40, left: 60 }
}) => {
const svgRef = useRef(null);
const tooltipRef = useRef(null);
const [dimensions, setDimensions] = useState({ width, height });
const formatValue = useCallback((value) => {
if (value >= 1000) {
return d3.format(',.0f')(value);
}
return value;
}, []);
const formatAxisValue = useCallback((value) => {
if (value >= 1000) {
return `${value / 1000}K`;
}
return value;
}, []);
const formatDate = useCallback((date) => {
const options = { weekday: 'long', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false };
return date.toLocaleDateString('en-US', options).replace(',', ',');
}, []);
const formatTime = useCallback((date) => {
return d3.timeFormat('%H:%M')(date);
}, []);
useEffect(() => {
if (!svgRef.current) return;
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
const innerWidth = dimensions.width - margin.left - margin.right;
const innerHeight = dimensions.height - margin.top - margin.bottom;
// Combine all data for scales
const allData = [...data, ...benchmarkData, ...forecastData];
if (allData.length === 0) return;
// Create scales
const xExtent = d3.extent(allData, d => d.date);
const xScale = d3.scaleTime()
.domain(xExtent)
.range([0, innerWidth]);
const yMax = d3.max(allData, d => d.value) * 1.1;
const yScale = d3.scaleLinear()
.domain([0, yMax])
.range([innerHeight, 0]);
// Create main group
const g = svg.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
// Create gradient for forecast area
const defs = svg.append('defs');
// Forecast gradient (dark teal)
const forecastGradient = defs.append('linearGradient')
.attr('id', 'forecastGradient')
.attr('x1', '0%')
.attr('y1', '0%')
.attr('x2', '0%')
.attr('y2', '100%');
forecastGradient.append('stop')
.attr('offset', '0%')
.attr('stop-color', '#1a5c5c')
.attr('stop-opacity', 0.9);
forecastGradient.append('stop')
.attr('offset', '100%')
.attr('stop-color', '#0a2020')
.attr('stop-opacity', 0.7);
// Main data gradient (green)
const mainGradient = defs.append('linearGradient')
.attr('id', 'mainGradient')
.attr('x1', '0%')
.attr('y1', '0%')
.attr('x2', '0%')
.attr('y2', '100%');
mainGradient.append('stop')
.attr('offset', '0%')
.attr('stop-color', '#4ade80')
.attr('stop-opacity', 0.6);
mainGradient.append('stop')
.attr('offset', '100%')
.attr('stop-color', '#166534')
.attr('stop-opacity', 0.3);
// Area generator for main data
const areaGenerator = d3.area()
.x(d => xScale(d.date))
.y0(innerHeight)
.y1(d => yScale(d.value))
.curve(d3.curveStepAfter);
// Line generator
const lineGenerator = d3.line()
.x(d => xScale(d.date))
.y(d => yScale(d.value))
.curve(d3.curveStepAfter);
// Draw forecast area first (background)
if (forecastData.length > 0) {
g.append('path')
.datum(forecastData)
.attr('class', styles.forecastArea)
.attr('d', areaGenerator)
.attr('fill', 'url(#forecastGradient)');
g.append('path')
.datum(forecastData)
.attr('class', styles.forecastLine)
.attr('d', lineGenerator)
.attr('fill', 'none')
.attr('stroke', '#5eead4')
.attr('stroke-width', 2);
}
// Draw main data area
if (data.length > 0) {
g.append('path')
.datum(data)
.attr('class', styles.mainArea)
.attr('d', areaGenerator)
.attr('fill', 'url(#mainGradient)');
g.append('path')
.datum(data)
.attr('class', styles.mainLine)
.attr('d', lineGenerator)
.attr('fill', 'none')
.attr('stroke', '#4ade80')
.attr('stroke-width', 2);
}
// Draw benchmark line (dashed white)
if (benchmarkData.length > 0) {
const benchmarkLineGenerator = d3.line()
.x(d => xScale(d.date))
.y(d => yScale(d.value))
.curve(d3.curveLinear);
g.append('path')
.datum(benchmarkData)
.attr('class', styles.benchmarkLine)
.attr('d', benchmarkLineGenerator)
.attr('fill', 'none')
.attr('stroke', 'white')
.attr('stroke-width', 2)
.attr('stroke-dasharray', '8,8');
}
// X Axis
const xAxis = d3.axisBottom(xScale)
.tickFormat(d3.timeFormat('%H:%M'))
.ticks(d3.timeHour.every(2));
g.append('g')
.attr('class', styles.xAxis)
.attr('transform', `translate(0,${innerHeight})`)
.call(xAxis);
// Y Axis
const yAxis = d3.axisLeft(yScale)
.tickFormat(formatAxisValue)
.ticks(5);
g.append('g')
.attr('class', styles.yAxis)
.call(yAxis);
// Grid lines
g.append('g')
.attr('class', styles.grid)
.call(
d3.axisLeft(yScale)
.tickSize(-innerWidth)
.tickFormat('')
.ticks(5)
);
// Tooltip and interaction
const tooltip = d3.select(tooltipRef.current);
// Create marker elements
const circleMarker = g.append('circle')
.attr('class', styles.marker)
.attr('r', 6)
.attr('fill', '#4ade80')
.attr('stroke', '#4ade80')
.attr('stroke-width', 2)
.style('opacity', 0);
const squareMarker = g.append('rect')
.attr('class', styles.marker)
.attr('width', 10)
.attr('height', 10)
.attr('fill', 'white')
.attr('stroke', 'white')
.attr('stroke-width', 2)
.style('opacity', 0);
const diamondMarker = g.append('rect')
.attr('class', styles.marker)
.attr('width', 10)
.attr('height', 10)
.attr('fill', '#5eead4')
.attr('stroke', '#5eead4')
.attr('stroke-width', 2)
.attr('transform', 'rotate(45)')
.style('opacity', 0);
// Vertical line for hover
const verticalLine = g.append('line')
.attr('class', styles.verticalLine)
.attr('y1', 0)
.attr('y2', innerHeight)
.attr('stroke', 'rgba(255,255,255,0.3)')
.attr('stroke-width', 1)
.style('opacity', 0);
// Overlay for mouse events
const overlay = g.append('rect')
.attr('class', styles.overlay)
.attr('width', innerWidth)
.attr('height', innerHeight)
.attr('fill', 'transparent');
overlay.on('mousemove', function(event) {
const [mouseX] = d3.pointer(event);
const hoveredDate = xScale.invert(mouseX);
// Find closest point in each dataset
const bisect = d3.bisector(d => d.date).left;
let closestPoint = null;
let closestDistance = Infinity;
let dataType = null;
// Check main data
if (data.length > 0) {
const idx = bisect(data, hoveredDate);
const d0 = data[idx - 1];
const d1 = data[idx];
const d = d0 && d1
? (hoveredDate - d0.date > d1.date - hoveredDate ? d1 : d0)
: (d0 || d1);
if (d) {
const distance = Math.abs(xScale(d.date) - mouseX);
if (distance < closestDistance) {
closestDistance = distance;
closestPoint = d;
dataType = 'count';
}
}
}
// Check forecast data
if (forecastData.length > 0) {
const idx = bisect(forecastData, hoveredDate);
const d0 = forecastData[idx - 1];
const d1 = forecastData[idx];
const d = d0 && d1
? (hoveredDate - d0.date > d1.date - hoveredDate ? d1 : d0)
: (d0 || d1);
if (d) {
const distance = Math.abs(xScale(d.date) - mouseX);
// Prefer forecast if we're in the forecast region
if (d.date >= forecastData[0].date && xScale(d.date) <= mouseX + 50) {
closestDistance = distance;
closestPoint = d;
dataType = 'forecast';
}
}
}
// Check benchmark data
if (benchmarkData.length > 0) {
const idx = bisect(benchmarkData, hoveredDate);
const d0 = benchmarkData[idx - 1];
const d1 = benchmarkData[idx];
const d = d0 && d1
? (hoveredDate - d0.date > d1.date - hoveredDate ? d1 : d0)
: (d0 || d1);
if (d) {
const yPos = yScale(d.value);
const mouseY = d3.pointer(event)[1];
// Only show benchmark if mouse is near the line
if (Math.abs(mouseY - yPos) < 20) {
closestPoint = d;
dataType = 'benchmark';
}
}
}
if (closestPoint) {
const x = xScale(closestPoint.date);
const y = yScale(closestPoint.value);
// Update vertical line
verticalLine
.attr('x1', x)
.attr('x2', x)
.style('opacity', 1);
// Hide all markers first
circleMarker.style('opacity', 0);
squareMarker.style('opacity', 0);
diamondMarker.style('opacity', 0);
// Show appropriate marker
if (dataType === 'count') {
circleMarker
.attr('cx', x)
.attr('cy', y)
.style('opacity', 1);
} else if (dataType === 'benchmark') {
squareMarker
.attr('x', x - 5)
.attr('y', y - 5)
.style('opacity', 1);
} else if (dataType === 'forecast') {
diamondMarker
.attr('x', x - 5)
.attr('y', y - 5)
.attr('transform', `translate(${x}, ${y}) rotate(45) translate(${-x}, ${-y})`)
.style('opacity', 1);
}
// Update tooltip
let label = '';
let colorClass = '';
if (dataType === 'count') {
label = 'Count';
colorClass = styles.countDot;
} else if (dataType === 'benchmark') {
label = 'Benchmark';
colorClass = styles.benchmarkDot;
} else if (dataType === 'forecast') {
label = 'Forecast';
colorClass = styles.forecastDot;
}
const dateStr = dataType === 'benchmark'
? `T-1 day at ${formatTime(closestPoint.date)}`
: formatDate(closestPoint.date);
tooltip
.style('opacity', 1)
.style('left', `${event.pageX + 15}px`)
.style('top', `${event.pageY - 40}px`)
.html(`
<div class="${styles.tooltipContent}">
<div class="${styles.tooltipDate}">${dateStr}</div>
<div class="${styles.tooltipValue}">
<span class="${styles.dot} ${colorClass}"></span>
${label}: ${formatValue(closestPoint.value)}
</div>
</div>
`);
}
});
overlay.on('mouseleave', function() {
tooltip.style('opacity', 0);
verticalLine.style('opacity', 0);
circleMarker.style('opacity', 0);
squareMarker.style('opacity', 0);
diamondMarker.style('opacity', 0);
});
}, [data, benchmarkData, forecastData, dimensions, margin, formatValue, formatAxisValue, formatDate, formatTime]);
return (
<div className={styles.chartContainer}>
<svg
ref={svgRef}
width={dimensions.width}
height={dimensions.height}
className={styles.chart}
/>
<div ref={tooltipRef} className={styles.tooltip} />
</div>
);
};
export default D3Chart;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment