Responsive withAutoSizer HOC
Description
To improve the developer experience of using AutoSizer for responsive charts, we can create a simple higher-order component (HOC) that allows us to use responsive sizing when the width or height is set to "100%". While this is a bit less flexible than using the AutoSizer directly, it works well enough for the majority of cases.
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 { interpolateViridis } 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 ContainerHOC = ({}) => {13 return (14 <div className="border border-dashed border-cyan-500">15 {/* by setting width to "100%", we opt-in to responsive sizing */}16 <Scatterplot width="100%" height={400} />17 </div>18 )19}20export default ContainerHOC2122/**23 * A higher order component (HOC) that interprets when a `width`24 * or `height` prop is set to "100%" and replaces it with a measured25 * pixel value based on its container.26 */27function withAutoSizer(Component) {28 const WrappedComponent = ({ width, height, ...other }) => {29 const disableWidth = width !== '100%'30 const disableHeight = height !== '100%'3132 // no measurement needed, so don't bother wrapping33 if (disableWidth && disableHeight) {34 return <Component width={width} height={height} {...other} />35 }3637 return (38 <AutoSizer disableHeight={disableHeight} disableWidth={disableWidth}>39 {({40 width: responsiveWidth = width,41 height: responsiveHeight = height,42 }) => (43 <Component44 width={responsiveWidth}45 height={responsiveHeight}46 {...other}47 />48 )}49 </AutoSizer>50 )51 }5253 // to make debugging easier, we add in a name for our wrapped component54 const componentName = Component.displayName || Component.name55 WrappedComponent.displayName = `WithAutoSizer(${componentName || ''})`5657 return WrappedComponent58}5960// note we wrap our component with the HOC withAutoSizer((props) => { ... })61// so that it never has to deal with width or height being a string.62const Scatterplot = withAutoSizer(({ width = 650, height = 400 }) => {63 const data = useMovieData()6465 const margin = { top: 10, right: 10, bottom: 30, left: 50 }66 const innerWidth = width - margin.left - margin.right67 const innerHeight = height - margin.top - margin.bottom6869 // read from pre-defined metric/dimension ("fields") bundles70 const xField = fields.revenue71 const yField = fields.vote_average72 const rField = fields.count73 const colorField = fields.count74 const labelField = fields.primary_genre7576 // optionally pull out values into local variables77 const { accessor: xAccessor, title: xTitle, formatter: xFormatter } = xField78 const { accessor: yAccessor, title: yTitle, formatter: yFormatter } = yField79 const { accessor: rAccessor } = rField80 const { accessor: colorAccessor } = colorField8182 // memoize creating our scales so we can optimize re-renders with React.memo83 // (e.g. <Points> only re-renders when its props change)84 const { xScale, yScale, rScale, colorScale } = React.useMemo(() => {85 if (!data) return {}86 const xExtent = extent(data, xAccessor)87 const xDomain = padExtent(xExtent, 0.125)88 const yExtent = extent(data, yAccessor)89 const yDomain = padExtent(yExtent, 0.125)90 const rExtent = extent(data, rAccessor)91 const colorExtent = extent(data, colorAccessor)9293 const xScale = scaleLinear().domain(xDomain).range([0, innerWidth])94 const yScale = scaleLinear().domain(yDomain).range([innerHeight, 0])95 const rScale = scaleSqrt().domain(rExtent).range([2, 16])96 const colorScale = scaleSequential(interpolateViridis).domain(colorExtent)9798 return {99 xScale,100 yScale,101 rScale,102 colorScale,103 }104 }, [105 colorAccessor,106 data,107 innerHeight,108 innerWidth,109 rAccessor,110 xAccessor,111 yAccessor,112 ])113114 // interaction setup115 const interactionRef = React.useRef(null)116 const hoverPoint = useClosestHoverPoint({117 interactionRef,118 data,119 xScale,120 xAccessor,121 yScale,122 yAccessor,123 radius: 60,124 })125126 if (!data) return <div style={{ width, height }} />127128 return (129 <div style={{ width }} className="relative">130 <svg width={width} height={height}>131 <g transform={`translate(${margin.left} ${margin.top})`}>132 <XAxis133 xScale={xScale}134 formatter={xFormatter}135 title={xTitle}136 innerHeight={innerHeight}137 gridLineHeight={innerHeight}138 />139140 <YAxis141 gridLineWidth={innerWidth}142 yScale={yScale}143 formatter={yFormatter}144 title={yTitle}145 />146 <XAxisTitle147 title={xTitle}148 xScale={xScale}149 innerHeight={innerHeight}150 />151 <YAxisTitle title={yTitle} />152 <Points153 data={data}154 xScale={xScale}155 xAccessor={xAccessor}156 yScale={yScale}157 yAccessor={yAccessor}158 rScale={rScale}159 rAccessor={rAccessor}160 colorScale={colorScale}161 colorAccessor={colorAccessor}162 />163 <HoverPoint164 labelField={labelField}165 xScale={xScale}166 xField={xField}167 yScale={yScale}168 yField={yField}169 rScale={rScale}170 rField={rField}171 colorScale={colorScale}172 colorField={colorField}173 hoverPoint={hoverPoint}174 />175176 <rect177 /* this node absorbs all mouse events */178 ref={interactionRef}179 width={innerWidth}180 height={innerHeight}181 x={0}182 y={0}183 fill="tomato"184 fillOpacity={0}185 />186 </g>187 </svg>188 </div>189 )190})191192function padExtent([min, max], paddingFactor) {193 const delta = Math.abs(max - min)194 const padding = delta * paddingFactor195196 return [min - padding, max + padding]197 // option to treat [0, 1] as a special case198 // return [min === 0 ? 0 : min - padding, max === 1 ? 1 : max + padding]199}200201/**202 * Custom hook to get the closest point to the mouse based on203 * iterating through all points. Supports a max distance from204 * the mouse via the radius prop. You must provide an ref to a205 * DOM node that can be used to capture the mouse, typically a206 * <rect> or <g> that covers the entire visualization.207 */208function useClosestHoverPoint({209 interactionRef,210 data,211 xScale,212 xAccessor,213 yScale,214 yAccessor,215 radius,216}) {217 // capture our hover point or undefined if none218 const [hoverPoint, setHoverPoint] = React.useState(undefined)219220 // we can throttle our updates by using requestAnimationFrame (raf)221 const rafRef = React.useRef(null)222223 React.useEffect(() => {224 const interactionRect = interactionRef.current225 if (interactionRect == null) return226227 const handleMouseMove = (evt) => {228 // here we use d3-selection's pointer. You could also try react-use useMouse.229 const [mouseX, mouseY] = pointer(evt)230231 // if we already had a pending update, cancel it in favour of this one232 if (rafRef.current) {233 cancelAnimationFrame(rafRef.current)234 }235236 rafRef.current = requestAnimationFrame(() => {237 // naive iterate over all points method238 const newHoverPoint = findClosestPoint({239 data,240 xScale,241 xAccessor,242 yScale,243 yAccessor,244 radius,245 pixelX: mouseX,246 pixelY: mouseY,247 })248249 setHoverPoint(newHoverPoint)250 })251 }252 interactionRect.addEventListener('mousemove', handleMouseMove)253254 // make sure we handle when the mouse leaves the interaction area to remove255 // our active hover point256 const handleMouseLeave = () => setHoverPoint(undefined)257 interactionRect.addEventListener('mouseleave', handleMouseLeave)258259 // cleanup our listeners260 return () => {261 interactionRect.removeEventListener('mousemove', handleMouseMove)262 interactionRect.removeEventListener('mouseleave', handleMouseLeave)263 }264 }, [interactionRef, data, xScale, yScale, radius, xAccessor, yAccessor])265266 return hoverPoint267}268269// simple algorithm for finding the nearest point. uses fancy Math.hypot270// to compute distance between a target (pixelX, pixelY) and each point.271// supports a max distance via the radius prop.272function findClosestPoint({273 data,274 xScale,275 yScale,276 xAccessor,277 yAccessor,278 pixelX,279 pixelY,280 radius,281}) {282 let closestPoint283 let minDistance = Infinity284 for (const d of data) {285 const pointPixelX = xScale(xAccessor(d))286 const pointPixelY = yScale(yAccessor(d))287 const distance = Math.hypot(pointPixelX - pixelX, pointPixelY - pixelY)288 if (distance < minDistance && radius != null && distance < radius) {289 closestPoint = d290 minDistance = distance291 }292 }293294 return closestPoint295}296297/** draws our hover marks: a crosshair + point + basic tooltip */298const HoverPoint = ({299 hoverPoint,300 xScale,301 xField,302 yField,303 yScale,304 rScale,305 rField,306 labelField,307 color = 'cyan',308}) => {309 if (!hoverPoint) return null310311 const d = hoverPoint312 const x = xScale(xField.accessor(d))313 const y = yScale(yField.accessor(d))314 const r = rScale?.(rField.accessor(d))315 const darkerColor = darker(color)316317 const [xPixelMin, xPixelMax] = xScale.range()318 const [yPixelMin, yPixelMax] = yScale.range()319320 return (321 <g className="pointer-events-none">322 <g data-testid="xCrosshair">323 <line324 x1={xPixelMin}325 x2={xPixelMax}326 y1={y}327 y2={y}328 stroke="#fff"329 strokeWidth={4}330 />331 <line332 x1={xPixelMin}333 x2={xPixelMax}334 y1={y}335 y2={y}336 stroke={darkerColor}337 strokeWidth={1}338 />339 </g>340 <g data-testid="yCrosshair">341 <line342 y1={yPixelMin}343 y2={yPixelMax}344 x1={x}345 x2={x}346 stroke="#fff"347 strokeWidth={4}348 />349 <line350 y1={yPixelMin}351 y2={yPixelMax}352 x1={x}353 x2={x}354 stroke={darkerColor}355 strokeWidth={1}356 />357 </g>358 <circle cx={x} cy={y} r={r} fill={color} stroke="#fff" strokeWidth={4} />359 <circle360 cx={x}361 cy={y}362 r={r}363 fill={color}364 stroke={darkerColor}365 strokeWidth={2}366 />367 <g transform={`translate(${x + 8} ${y + 4})`}>368 <OutlinedSvgText369 stroke="#fff"370 strokeWidth={5}371 className="text-sm font-bold"372 dy="0.8em"373 >374 {labelField.accessor(d)}375 </OutlinedSvgText>376 <OutlinedSvgText377 stroke="#fff"378 strokeWidth={5}379 className="text-xs"380 dy="0.8em"381 y={16}382 >383 {`${xField.title}: ${xField.formatter(xField.accessor(d))}`}384 </OutlinedSvgText>385 <OutlinedSvgText386 stroke="#fff"387 strokeWidth={5}388 className="text-xs"389 dy="0.8em"390 y={30}391 >392 {`${yField.title}: ${yField.formatter(yField.accessor(d))}`}393 </OutlinedSvgText>394 </g>395 </g>396 )397}398399/**400 * A memoized component that renders all our points, but only re-renders401 * when its props change.402 */403const Points = React.memo(404 ({405 data,406 xScale,407 xAccessor,408 yAccessor,409 yScale,410 rScale,411 rAccessor,412 radius = 8,413 colorScale,414 colorAccessor,415 defaultColor = 'tomato',416 onHover,417 }) => {418 return (419 <g data-testid="Points">420 {data.map((d, i) => {421 // const x = (width * (d.revenue - minRevenue)) / (maxRevenue - minRevenue)422 const x = xScale(xAccessor(d))423 const y = yScale(yAccessor(d))424 const r = rScale?.(rAccessor(d)) ?? radius425 const color = colorScale?.(colorAccessor(d)) ?? defaultColor426427 return (428 <circle429 key={d.id ?? i}430 r={r}431 cx={x}432 cy={y}433 fill={color}434 stroke={darker(color)}435 strokeWidth={1}436 strokeOpacity={1}437 fillOpacity={0.5}438 onClick={() => console.log(d)}439 onMouseEnter={onHover ? () => onHover(d) : null}440 onMouseLeave={onHover ? () => onHover(undefined) : null}441 />442 )443 })}444 </g>445 )446 }447)448449function isDarkColor(color) {450 const labColor = lab(color)451 return labColor.l < 75452}453454/** dynamically create a darker color */455function darker(color, factor = 0.85) {456 const labColor = lab(color)457 labColor.l *= factor458459 // rgb doesn't correspond to visual perception, but is460 // easy for computers461 // const rgbColor = rgb(color)462 // rgbColor.r *= 0.8463 // rgbColor.g *= 0.8464 // rgbColor.b *= 0.8465466 // rgb(100, 50, 50);467 // rgb(75, 25, 25); // is this half has light perceptually?468 return labColor.toString()469}470471/** fancier way of getting a nice svg text stroke */472const OutlinedSvgText = ({ stroke, strokeWidth, children, ...other }) => {473 return (474 <>475 <text stroke={stroke} strokeWidth={strokeWidth} {...other}>476 {children}477 </text>478 <text {...other}>{children}</text>479 </>480 )481}482483/** determine number of ticks based on space available */484function numTicksForPixels(pixelsAvailable, pixelsPerTick = 70) {485 return Math.floor(Math.abs(pixelsAvailable) / pixelsPerTick)486}487488const YAxisTitle = ({ title }) => {489 return (490 <OutlinedSvgText491 stroke="#fff"492 strokeWidth={2.5}493 dx={4}494 dy="0.8em"495 fill="var(--gray-600)"496 className="font-semibold text-2xs"497 >498 {title}499 </OutlinedSvgText>500 )501}502503/** Y-axis with title and grid lines */504const YAxis = ({ yScale, formatter, gridLineWidth }) => {505 const [yMin, yMax] = yScale.range()506 const ticks = yScale.ticks(numTicksForPixels(yMax - yMin, 50))507508 return (509 <g data-testid="YAxis">510 <line x1={0} x2={0} y1={yMin} y2={yMax} stroke="var(--gray-400)" />511 {ticks.map((tick) => {512 const y = yScale(tick)513 return (514 <g key={tick} transform={`translate(0 ${y})`}>515 <text516 dy="0.34em"517 textAnchor="end"518 dx={-12}519 fill="currentColor"520 className="text-gray-400 text-2xs"521 >522 {formatter(tick)}523 </text>524 <line525 x1={0}526 x2={-8}527 stroke="var(--gray-300)"528 data-testid="tickmark"529 />530 {gridLineWidth ? (531 <line532 x1={0}533 x2={gridLineWidth}534 stroke="var(--gray-200)"535 strokeOpacity={0.8}536 data-testid="gridline"537 />538 ) : null}539 </g>540 )541 })}542 </g>543 )544}545546const XAxisTitle = ({ xScale, title, innerHeight }) => {547 const [, xMax] = xScale.range()548 return (549 <text550 x={xMax}551 y={innerHeight}552 textAnchor="end"553 dy={-4}554 fill="var(--gray-600)"555 className="font-semibold text-2xs text-shadow-white-stroke"556 >557 {title}558 </text>559 )560}561562/** X-axis with title and grid lines */563const XAxis = ({ xScale, title, formatter, innerHeight, gridLineHeight }) => {564 const [xMin, xMax] = xScale.range()565 const ticks = xScale.ticks(numTicksForPixels(xMax - xMin))566567 return (568 <g data-testid="XAxis" transform={`translate(0 ${innerHeight})`}>569 <line x1={xMin} x2={xMax} y1={0} y2={0} stroke="var(--gray-400)" />570 {ticks.map((tick) => {571 const x = xScale(tick)572 return (573 <g key={tick} transform={`translate(${x} 0)`}>574 <text575 y={10}576 dy="0.8em"577 textAnchor="middle"578 fill="currentColor"579 className="text-gray-400 text-2xs"580 >581 {formatter(tick)}582 </text>583 <line584 y1={0}585 y2={8}586 stroke="var(--gray-300)"587 data-testid="tickmark"588 />589 {gridLineHeight ? (590 <line591 y1={0}592 y2={-gridLineHeight}593 stroke="var(--gray-200)"594 strokeOpacity={0.8}595 data-testid="gridline"596 />597 ) : null}598 </g>599 )600 })}601 </g>602 )603}604605// fetch our data from CSV and translate to JSON606const useMovieData = () => {607 const [data, setData] = React.useState(undefined)608609 React.useEffect(() => {610 fetch('/datasets/tmdb_1000_movies_small.csv')611 // fetch('/datasets/tmdb_5000_movies.csv')612 .then((response) => response.text())613 .then((csvString) => {614 const data = csvParse(csvString, (row) => {615 return {616 budget: +row.budget,617 vote_average: +row.vote_average,618 vote_count: +row.vote_count,619 genres: JSON.parse(row.genres),620 primary_genre: JSON.parse(row.genres)[0]?.name,621 revenue: +row.revenue,622 original_title: row.original_title,623 }624 }).filter((d) => d.revenue > 0)625 console.log('[data]', data)626627 // group by genre and summarize628 const groupedData = tidy(629 data,630 groupBy(631 ['primary_genre'],632 [633 summarize({634 revenue: mean('revenue'),635 vote_average: mean('vote_average'),636 count: n(),637 }),638 ]639 )640 )641642 console.log('groupedData', groupedData)643644 setData(groupedData)645 })646 }, [])647648 return data649}650651// very lazy large number money formatter ($1.5M, $1.65B etc)652const bigMoneyFormat = (value) => {653 if (value == null) return value654 const formatted = format('$~s')(value)655 return formatted.replace(/G$/, 'B')656}657658// metrics (numeric) + dimensions (non-numeric) = fields659const fields = {660 revenue: {661 accessor: (d) => d.revenue,662 title: 'Revenue',663 formatter: bigMoneyFormat,664 },665 budget: {666 accessor: (d) => d.budget,667 title: 'Budget',668 formatter: bigMoneyFormat,669 },670 vote_average: {671 accessor: (d) => d.vote_average,672 title: 'Vote Average out of 10',673 formatter: format('.1f'),674 },675 vote_count: {676 accessor: (d) => d.vote_count,677 title: 'Vote Count',678 formatter: format('.1f'),679 },680 primary_genre: {681 accessor: (d) => d.primary_genre,682 title: 'Primary Genre',683 formatter: (d) => d,684 },685 original_title: {686 accessor: (d) => d.original_title,687 title: 'Original Title',688 formatter: (d) => d,689 },690691 count: {692 accessor: (d) => d.count,693 title: 'Num Movies in Group',694 formatter: (d) => d,695 },696}697