Last active
March 10, 2026 12:52
-
-
Save gbluv/2b4968328c659e9269afb5ccda9db253 to your computer and use it in GitHub Desktop.
line chart d3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| .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; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| '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