src/chart.js
Ready
localhost:5173
D3.js 7ReadyJSLn 1, Col 1
D3.js 7 playground with local project files, npm-style imports, responsive data visualizations, live browser preview, and a runnable Vite download.
D3.js 7 playground with local project files, npm-style imports, responsive data visualizations, live browser preview, and a runnable Vite download.
Runtime: v7.9.0
Press Enter to evaluate, ↑ and ↓ to browse command history, and Ctrl/⌘ + L to clear the console.
import './styles.css'
import { metrics, performanceData } from './data.js'
import { createBarChart } from './chart.js'
const chartRoot = document.querySelector('#chart-root')
const metricButtons = document.querySelectorAll('[data-metric]')
const summaryLabel = document.querySelector('#summary-label')
const summaryValue = document.querySelector('#summary-value')
const summaryChange = document.querySelector('#summary-change')
const bestPeriod = document.querySelector('#best-period')
const averageValue = document.querySelector('#average-value')
const chartTitle = document.querySelector('#chart-title')
const chartStatus = document.querySelector('#chart-status')
if (!(chartRoot instanceof HTMLElement) || !(chartStatus instanceof HTMLElement)) {
throw new Error('Chart elements were not found')
}
const chart = createBarChart({
container: chartRoot,
data: performanceData,
metrics,
initialMetric: 'revenue',
})
function selectMetric(metricKey) {
const metric = metrics[metricKey]
if (!metric) return
const values = performanceData.map((item) => item[metricKey])
const latest = values.at(-1) ?? 0
const first = values[0] ?? 0
const average = values.reduce((total, value) => total + value, 0) / values.length
const best = performanceData.reduce((leader, item) =>
item[metricKey] > leader[metricKey] ? item : leader
)
const change = Math.round(((latest - first) / first) * 100)
chart.update(metricKey)
summaryLabel.textContent = metric.summaryLabel
summaryValue.textContent = metric.format(latest)
summaryChange.textContent = `↑ ${change}% since January`
bestPeriod.textContent = best.period
averageValue.textContent = metric.format(Math.round(average))
chartTitle.textContent = `${metric.label} by month`
chartStatus.textContent = `Showing ${metric.label.toLowerCase()}`
metricButtons.forEach((button) => {
const isActive = button.getAttribute('data-metric') === metricKey
button.classList.toggle('is-active', isActive)
button.setAttribute('aria-pressed', String(isActive))
})
}
metricButtons.forEach((button) => {
button.addEventListener('click', () => {
selectMetric(button.getAttribute('data-metric'))
})
})
selectMetric('revenue')
window.addEventListener('pagehide', () => chart.destroy(), { once: true })
import {
axisBottom,
axisLeft,
easeCubicOut,
max,
scaleBand,
scaleLinear,
select,
} from 'd3'
export function createBarChart({ container, data, metrics, initialMetric }) {
const svg = select(container).select('#performance-chart')
const tooltip = select(container).select('[data-chart-tooltip]')
const plot = svg.append('g')
const xAxisGroup = plot.append('g').attr('class', 'axis axis-x')
const yAxisGroup = plot.append('g').attr('class', 'axis axis-y')
const gridGroup = plot.append('g').attr('class', 'grid-lines')
const barsGroup = plot.append('g').attr('class', 'bars')
let metricKey = initialMetric
let animationFrame = 0
let hasRendered = false
function render() {
const metric = metrics[metricKey]
const width = Math.max(container.clientWidth, 360)
const height = width < 620 ? 350 : 430
const margin = {
top: 18,
right: width < 620 ? 12 : 24,
bottom: 42,
left: width < 620 ? 48 : 62,
}
const innerWidth = width - margin.left - margin.right
const innerHeight = height - margin.top - margin.bottom
svg.attr('viewBox', `0 0 ${width} ${height}`)
plot.attr('transform', `translate(${margin.left},${margin.top})`)
const x = scaleBand()
.domain(data.map((item) => item.short))
.range([0, innerWidth])
.padding(0.28)
const ceiling = Number(max(data, (item) => item[metricKey]) ?? 0)
const y = scaleLinear()
.domain([0, ceiling * 1.12])
.nice()
.range([innerHeight, 0])
const transition = svg
.transition()
.duration(hasRendered ? 550 : 750)
.ease(easeCubicOut)
xAxisGroup
.attr('transform', `translate(0,${innerHeight})`)
.transition(transition)
.call(axisBottom(x).tickSizeOuter(0))
yAxisGroup
.transition(transition)
.call(axisLeft(y).ticks(5).tickFormat(metric.tickFormat))
gridGroup
.selectAll('line')
.data(y.ticks(5))
.join('line')
.attr('x1', 0)
.attr('x2', innerWidth)
.transition(transition)
.attr('y1', (value) => y(value))
.attr('y2', (value) => y(value))
const bars = barsGroup
.selectAll('rect')
.data(data, (item) => item.period)
.join(
(enter) =>
enter
.append('rect')
.attr('class', 'bar')
.attr('x', (item) => x(item.short))
.attr('width', x.bandwidth())
.attr('y', innerHeight)
.attr('height', 0)
.attr('rx', 7),
(update) => update,
(exit) => exit.transition(transition).attr('height', 0).remove(),
)
.attr('data-period', (item) => item.short)
.attr('data-value', (item) => item[metricKey])
.attr('tabindex', 0)
.attr('role', 'graphics-symbol')
.attr('aria-label', (item) =>
`${item.period}: ${metric.format(item[metricKey])}`
)
.on('pointerenter focus', function (event, item) {
select(this).classed('is-highlighted', true)
container.dataset.hovered = item.period
tooltip
.classed('is-visible', true)
.text(`${item.period} · ${metric.format(item[metricKey])}`)
positionTooltip(event)
})
.on('pointermove', positionTooltip)
.on('pointerleave blur', function () {
select(this).classed('is-highlighted', false)
delete container.dataset.hovered
tooltip.classed('is-visible', false)
})
bars
.transition(transition)
.delay((item, index) => (hasRendered ? 0 : index * 45))
.attr('x', (item) => x(item.short))
.attr('width', x.bandwidth())
.attr('y', (item) => y(item[metricKey]))
.attr('height', (item) => innerHeight - y(item[metricKey]))
.attr('fill', metric.accent)
container.dataset.chartReady = 'true'
container.dataset.metric = metricKey
hasRendered = true
}
function positionTooltip(event) {
if (!event || typeof event.clientX !== 'number') return
const bounds = container.getBoundingClientRect()
tooltip
.style('left', `${event.clientX - bounds.left}px`)
.style('top', `${event.clientY - bounds.top}px`)
}
const resizeObserver = new ResizeObserver(() => {
cancelAnimationFrame(animationFrame)
animationFrame = requestAnimationFrame(render)
})
resizeObserver.observe(container)
render()
return {
update(nextMetric) {
if (!metrics[nextMetric]) return
metricKey = nextMetric
render()
},
destroy() {
cancelAnimationFrame(animationFrame)
resizeObserver.disconnect()
svg.interrupt()
svg.selectAll('*').interrupt()
},
}
}