Responsive via AutoSizer
Description
It's common that we need our charts to size themselves to fill an area, often called "responsive sizing", but since we are creating them using fixed width and height numbers (required for our scales to work), we have to go through additional steps to get it to work.
My favoured approach is to build my charts with a fixed width and height that can be configured via props. This keeps the code for the chart itself as simple as possible and leaves the magic of sizing to fit to live outside of the component. The simplest, most reliable method for measuring how big our chart should be comes from react-virtualized, a package made by Brian Vaughn from the React core team.
react-virtualized provides a component called AutoSizer that uses a render function to provide the measured width and height to its children. e.g.,
<AutoSizer>
{({ width, height}) => <Scatterplot width={width} height={height} />}
</AutoSizer>
This API allows us to keep our code for our chart working with direct numbers for width and height and hands off the responsiveness to a battle-tested third party component. I love it. Brian has published the AutoSizer component as a standalone package (react-virtualized-auto-sizer) for easy installation even if you don't care about the rest of react-virtualized.
It can be a bit cumbersome to wrap our charts with AutoSizer every time we want to use them, so I've taken to also using a withAutoSizer
HOC to simplify the process. I also cover an approach that uses ResizeObserver, which seems like a more modern style, but may require polyfills. I read at some point that AutoSizer was going to switch over but they had issues and I haven't since checked if they've been resolved.
Code
1import * as React from 'react' // v17.0.22import { extent } from 'd3-array' // v^2.12.13import { csvParse } from 'd3-dsv' // v^2.0.04import { format } from 'd3-format' // v^2.0.05import { lab } from 'd3-color' // v^2.0.06import { scaleLinear, scaleSequential, scaleSqrt } from 'd3-scale' // v^3.2.47import { interpolatePlasma } from 'd3-scale-chromatic' // v^2.0.08import { pointer } from 'd3-selection' // v^2.0.09import { groupBy, mean, n, summarize, tidy } from '@tidyjs/tidy' // v^2.1.010import AutoSizer from 'react-virtualized-auto-sizer' // v^1.0.51112const ContainerRenderFn = ({}) => {13 return (14 <div className="border border-dashed border-cyan-500">15 {/* We wrap our component in this AutoSizer component to get16 width and height measured externally. Our actual plot still just17 expects a number for width and height and we don't have to change18 its code at all. */}19 <AutoSizer disableHeight>20 {({ width, height = 400 }) => (21 <Scatterplot width={width} height={height} />22 )}23 </AutoSizer>24 </div>25 )26}2728export default ContainerRenderFn2930const Scatterplot = ({ width = 650, height = 400 }) => {31 const data = useMovieData()3233 const margin = { top: 10, right: 10, bottom: 30, left: 50 }34 const innerWidth = width - margin.left - margin.right35 const innerHeight = height - margin.top - margin.bottom3637 // read from pre-defined metric/dimension ("fields") bundles38 const xField = fields.revenue39 const yField = fields.vote_average40 const rField = fields.count41 const colorField = fields.count42 const labelField = fields.primary_genre4344 // optionally pull out values into local variables45 const { accessor: xAccessor, title: xTitle, formatter: xFormatter } = xField46 const { accessor: yAccessor, title: yTitle, formatter: yFormatter } = yField47 const { accessor: rAccessor } = rField48 const { accessor: colorAccessor } = colorField4950 // memoize creating our scales so we can optimize re-renders with React.memo51 // (e.g. <Points> only re-renders when its props change)52 const { xScale, yScale, rScale, colorScale } = React.useMemo(() => {53 if (!data) return {}54 const xExtent = extent(data, xAccessor)55 const xDomain = padExtent(xExtent, 0.125)56 const yExtent = extent(data, yAccessor)57 const yDomain = padExtent(yExtent, 0.125)58 const rExtent = extent(data, rAccessor)59 const colorExtent = extent(data, colorAccessor)6061 const xScale = scaleLinear().domain(xDomain).range([0, innerWidth])62 const yScale = scaleLinear().domain(yDomain).range([innerHeight, 0])63 const rScale = scaleSqrt().domain(rExtent).range([2, 16])64 const colorScale = scaleSequential(interpolatePlasma).domain(colorExtent)6566 return {67 xScale,68 yScale,69 rScale,70 colorScale,71 }72 }, [73 colorAccessor,74 data,75 innerHeight,76 innerWidth,77 rAccessor,78 xAccessor,79 yAccessor,80 ])8182 // interaction setup83 const interactionRef = React.useRef(null)84 const hoverPoint = useClosestHoverPoint({85 interactionRef,86 data,87 xScale,88 xAccessor,89 yScale,90 yAccessor,91 radius: 60,92 })9394 if (!data) return <div style={{ width, height }} />9596 return (97 <div style={{ width }} className="relative">98 <svg width={width} height={height}>99 <g transform={`translate(${margin.left} ${margin.top})`}>100 <XAxis101 xScale={xScale}102 formatter={xFormatter}103 title={xTitle}104 innerHeight={innerHeight}105 gridLineHeight={innerHeight}106 />107108 <YAxis109 gridLineWidth={innerWidth}110 yScale={yScale}111 formatter={yFormatter}112 title={yTitle}113 />114 <XAxisTitle115 title={xTitle}116 xScale={xScale}117 innerHeight={innerHeight}118 />119 <YAxisTitle title={yTitle} />120 <Points121 data={data}122 xScale={xScale}123 xAccessor={xAccessor}124 yScale={yScale}125 yAccessor={yAccessor}126 rScale={rScale}127 rAccessor={rAccessor}128 colorScale={colorScale}129 colorAccessor={colorAccessor}130 />131 <HoverPoint132 labelField={labelField}133 xScale={xScale}134 xField={xField}135 yScale={yScale}136 yField={yField}137 rScale={rScale}138 rField={rField}139 colorScale={colorScale}140 colorField={colorField}141 hoverPoint={hoverPoint}142 />143144 <rect145 /* this node absorbs all mouse events */146 ref={interactionRef}147 width={innerWidth}148 height={innerHeight}149 x={0}150 y={0}151 fill="tomato"152 fillOpacity={0}153 />154 </g>155 </svg>156 </div>157 )158}159160function padExtent([min, max], paddingFactor) {161 const delta = Math.abs(max - min)162 const padding = delta * paddingFactor163164 return [min - padding, max + padding]165 // option to treat [0, 1] as a special case166 // return [min === 0 ? 0 : min - padding, max === 1 ? 1 : max + padding]167}168169/**170 * Custom hook to get the closest point to the mouse based on171 * iterating through all points. Supports a max distance from172 * the mouse via the radius prop. You must provide an ref to a173 * DOM node that can be used to capture the mouse, typically a174 * <rect> or <g> that covers the entire visualization.175 */176function useClosestHoverPoint({177 interactionRef,178 data,179 xScale,180 xAccessor,181 yScale,182 yAccessor,183 radius,184}) {185 // capture our hover point or undefined if none186 const [hoverPoint, setHoverPoint] = React.useState(undefined)187188 // we can throttle our updates by using requestAnimationFrame (raf)189 const rafRef = React.useRef(null)190191 React.useEffect(() => {192 const interactionRect = interactionRef.current193 if (interactionRect == null) return194195 const handleMouseMove = (evt) => {196 // here we use d3-selection's pointer. You could also try react-use useMouse.197 const [mouseX, mouseY] = pointer(evt)198199 // if we already had a pending update, cancel it in favour of this one200 if (rafRef.current) {201 cancelAnimationFrame(rafRef.current)202 }203204 rafRef.current = requestAnimationFrame(() => {205 // naive iterate over all points method206 const newHoverPoint = findClosestPoint({207 data,208 xScale,209 xAccessor,210 yScale,211 yAccessor,212 radius,213 pixelX: mouseX,214 pixelY: mouseY,215 })216217 setHoverPoint(newHoverPoint)218 })219 }220 interactionRect.addEventListener('mousemove', handleMouseMove)221222 // make sure we handle when the mouse leaves the interaction area to remove223 // our active hover point224 const handleMouseLeave = () => setHoverPoint(undefined)225 interactionRect.addEventListener('mouseleave', handleMouseLeave)226227 // cleanup our listeners228 return () => {229 interactionRect.removeEventListener('mousemove', handleMouseMove)230 interactionRect.removeEventListener('mouseleave', handleMouseLeave)231 }232 }, [interactionRef, data, xScale, yScale, radius, xAccessor, yAccessor])233234 return hoverPoint235}236237// simple algorithm for finding the nearest point. uses fancy Math.hypot238// to compute distance between a target (pixelX, pixelY) and each point.239// supports a max distance via the radius prop.240function findClosestPoint({241 data,242 xScale,243 yScale,244 xAccessor,245 yAccessor,246 pixelX,247 pixelY,248 radius,249}) {250 let closestPoint251 let minDistance = Infinity252 for (const d of data) {253 const pointPixelX = xScale(xAccessor(d))254 const pointPixelY = yScale(yAccessor(d))255 const distance = Math.hypot(pointPixelX - pixelX, pointPixelY - pixelY)256 if (distance < minDistance && radius != null && distance < radius) {257 closestPoint = d258 minDistance = distance259 }260 }261262 return closestPoint263}264265/** draws our hover marks: a crosshair + point + basic tooltip */266const HoverPoint = ({267 hoverPoint,268 xScale,269 xField,270 yField,271 yScale,272 rScale,273 rField,274 labelField,275 color = 'cyan',276}) => {277 if (!hoverPoint) return null278279 const d = hoverPoint280 const x = xScale(xField.accessor(d))281 const y = yScale(yField.accessor(d))282 const r = rScale?.(rField.accessor(d))283 const darkerColor = darker(color)284285 const [xPixelMin, xPixelMax] = xScale.range()286 const [yPixelMin, yPixelMax] = yScale.range()287288 return (289 <g className="pointer-events-none">290 <g data-testid="xCrosshair">291 <line292 x1={xPixelMin}293 x2={xPixelMax}294 y1={y}295 y2={y}296 stroke="#fff"297 strokeWidth={4}298 />299 <line300 x1={xPixelMin}301 x2={xPixelMax}302 y1={y}303 y2={y}304 stroke={darkerColor}305 strokeWidth={1}306 />307 </g>308 <g data-testid="yCrosshair">309 <line310 y1={yPixelMin}311 y2={yPixelMax}312 x1={x}313 x2={x}314 stroke="#fff"315 strokeWidth={4}316 />317 <line318 y1={yPixelMin}319 y2={yPixelMax}320 x1={x}321 x2={x}322 stroke={darkerColor}323 strokeWidth={1}324 />325 </g>326 <circle cx={x} cy={y} r={r} fill={color} stroke="#fff" strokeWidth={4} />327 <circle328 cx={x}329 cy={y}330 r={r}331 fill={color}332 stroke={darkerColor}333 strokeWidth={2}334 />335 <g transform={`translate(${x + 8} ${y + 4})`}>336 <OutlinedSvgText337 stroke="#fff"338 strokeWidth={5}339 className="text-sm font-bold"340 dy="0.8em"341 >342 {labelField.accessor(d)}343 </OutlinedSvgText>344 <OutlinedSvgText345 stroke="#fff"346 strokeWidth={5}347 className="text-xs"348 dy="0.8em"349 y={16}350 >351 {`${xField.title}: ${xField.formatter(xField.accessor(d))}`}352 </OutlinedSvgText>353 <OutlinedSvgText354 stroke="#fff"355 strokeWidth={5}356 className="text-xs"357 dy="0.8em"358 y={30}359 >360 {`${yField.title}: ${yField.formatter(yField.accessor(d))}`}361 </OutlinedSvgText>362 </g>363 </g>364 )365}366367/**368 * A memoized component that renders all our points, but only re-renders369 * when its props change.370 */371const Points = React.memo(372 ({373 data,374 xScale,375 xAccessor,376 yAccessor,377 yScale,378 rScale,379 rAccessor,380 radius = 8,381 colorScale,382 colorAccessor,383 defaultColor = 'tomato',384 onHover,385 }) => {386 return (387 <g data-testid="Points">388 {data.map((d, i) => {389 // const x = (width * (d.revenue - minRevenue)) / (maxRevenue - minRevenue)390 const x = xScale(xAccessor(d))391 const y = yScale(yAccessor(d))392 const r = rScale?.(rAccessor(d)) ?? radius393 const color = colorScale?.(colorAccessor(d)) ?? defaultColor394395 return (396 <circle397 key={d.id ?? i}398 r={r}399 cx={x}400 cy={y}401 fill={color}402 stroke={darker(color)}403 strokeWidth={1}404 strokeOpacity={1}405 fillOpacity={0.5}406 onClick={() => console.log(d)}407 onMouseEnter={onHover ? () => onHover(d) : null}408 onMouseLeave={onHover ? () => onHover(undefined) : null}409 />410 )411 })}412 </g>413 )414 }415)416417function isDarkColor(color) {418 const labColor = lab(color)419 return labColor.l < 75420}421422/** dynamically create a darker color */423function darker(color, factor = 0.85) {424 const labColor = lab(color)425 labColor.l *= factor426427 // rgb doesn't correspond to visual perception, but is428 // easy for computers429 // const rgbColor = rgb(color)430 // rgbColor.r *= 0.8431 // rgbColor.g *= 0.8432 // rgbColor.b *= 0.8433434 // rgb(100, 50, 50);435 // rgb(75, 25, 25); // is this half has light perceptually?436 return labColor.toString()437}438439/** fancier way of getting a nice svg text stroke */440const OutlinedSvgText = ({ stroke, strokeWidth, children, ...other }) => {441 return (442 <>443 <text stroke={stroke} strokeWidth={strokeWidth} {...other}>444 {children}445 </text>446 <text {...other}>{children}</text>447 </>448 )449}450451/** determine number of ticks based on space available */452function numTicksForPixels(pixelsAvailable, pixelsPerTick = 70) {453 return Math.floor(Math.abs(pixelsAvailable) / pixelsPerTick)454}455456const YAxisTitle = ({ title }) => {457 return (458 <OutlinedSvgText459 stroke="#fff"460 strokeWidth={2.5}461 dx={4}462 dy="0.8em"463 fill="var(--gray-600)"464 className="font-semibold text-2xs"465 >466 {title}467 </OutlinedSvgText>468 )469}470471/** Y-axis with title and grid lines */472const YAxis = ({ yScale, formatter, gridLineWidth }) => {473 const [yMin, yMax] = yScale.range()474 const ticks = yScale.ticks(numTicksForPixels(yMax - yMin, 50))475476 return (477 <g data-testid="YAxis">478 <line x1={0} x2={0} y1={yMin} y2={yMax} stroke="var(--gray-400)" />479 {ticks.map((tick) => {480 const y = yScale(tick)481 return (482 <g key={tick} transform={`translate(0 ${y})`}>483 <text484 dy="0.34em"485 textAnchor="end"486 dx={-12}487 fill="currentColor"488 className="text-gray-400 text-2xs"489 >490 {formatter(tick)}491 </text>492 <line493 x1={0}494 x2={-8}495 stroke="var(--gray-300)"496 data-testid="tickmark"497 />498 {gridLineWidth ? (499 <line500 x1={0}501 x2={gridLineWidth}502 stroke="var(--gray-200)"503 strokeOpacity={0.8}504 data-testid="gridline"505 />506 ) : null}507 </g>508 )509 })}510 </g>511 )512}513514const XAxisTitle = ({ xScale, title, innerHeight }) => {515 const [, xMax] = xScale.range()516 return (517 <text518 x={xMax}519 y={innerHeight}520 textAnchor="end"521 dy={-4}522 fill="var(--gray-600)"523 className="font-semibold text-2xs text-shadow-white-stroke"524 >525 {title}526 </text>527 )528}529530/** X-axis with title and grid lines */531const XAxis = ({ xScale, title, formatter, innerHeight, gridLineHeight }) => {532 const [xMin, xMax] = xScale.range()533 const ticks = xScale.ticks(numTicksForPixels(xMax - xMin))534535 return (536 <g data-testid="XAxis" transform={`translate(0 ${innerHeight})`}>537 <line x1={xMin} x2={xMax} y1={0} y2={0} stroke="var(--gray-400)" />538 {ticks.map((tick) => {539 const x = xScale(tick)540 return (541 <g key={tick} transform={`translate(${x} 0)`}>542 <text543 y={10}544 dy="0.8em"545 textAnchor="middle"546 fill="currentColor"547 className="text-gray-400 text-2xs"548 >549 {formatter(tick)}550 </text>551 <line552 y1={0}553 y2={8}554 stroke="var(--gray-300)"555 data-testid="tickmark"556 />557 {gridLineHeight ? (558 <line559 y1={0}560 y2={-gridLineHeight}561 stroke="var(--gray-200)"562 strokeOpacity={0.8}563 data-testid="gridline"564 />565 ) : null}566 </g>567 )568 })}569 </g>570 )571}572573// fetch our data from CSV and translate to JSON574const useMovieData = () => {575 const [data, setData] = React.useState(undefined)576577 React.useEffect(() => {578 fetch('/datasets/tmdb_1000_movies_small.csv')579 // fetch('/datasets/tmdb_5000_movies.csv')580 .then((response) => response.text())581 .then((csvString) => {582 const data = csvParse(csvString, (row) => {583 return {584 budget: +row.budget,585 vote_average: +row.vote_average,586 vote_count: +row.vote_count,587 genres: JSON.parse(row.genres),588 primary_genre: JSON.parse(row.genres)[0]?.name,589 revenue: +row.revenue,590 original_title: row.original_title,591 }592 }).filter((d) => d.revenue > 0)593 console.log('[data]', data)594595 // group by genre and summarize596 const groupedData = tidy(597 data,598 groupBy(599 ['primary_genre'],600 [601 summarize({602 revenue: mean('revenue'),603 vote_average: mean('vote_average'),604 count: n(),605 }),606 ]607 )608 )609610 console.log('groupedData', groupedData)611612 setData(groupedData)613 })614 }, [])615616 return data617}618619// very lazy large number money formatter ($1.5M, $1.65B etc)620const bigMoneyFormat = (value) => {621 if (value == null) return value622 const formatted = format('$~s')(value)623 return formatted.replace(/G$/, 'B')624}625626// metrics (numeric) + dimensions (non-numeric) = fields627const fields = {628 revenue: {629 accessor: (d) => d.revenue,630 title: 'Revenue',631 formatter: bigMoneyFormat,632 },633 budget: {634 accessor: (d) => d.budget,635 title: 'Budget',636 formatter: bigMoneyFormat,637 },638 vote_average: {639 accessor: (d) => d.vote_average,640 title: 'Vote Average out of 10',641 formatter: format('.1f'),642 },643 vote_count: {644 accessor: (d) => d.vote_count,645 title: 'Vote Count',646 formatter: format('.1f'),647 },648 primary_genre: {649 accessor: (d) => d.primary_genre,650 title: 'Primary Genre',651 formatter: (d) => d,652 },653 original_title: {654 accessor: (d) => d.original_title,655 title: 'Original Title',656 formatter: (d) => d,657 },658659 count: {660 accessor: (d) => d.count,661 title: 'Num Movies in Group',662 formatter: (d) => d,663 },664}665