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.2
2import { extent } from 'd3-array' // v^2.12.1
3import { csvParse } from 'd3-dsv' // v^2.0.0
4import { format } from 'd3-format' // v^2.0.0
5import { lab } from 'd3-color' // v^2.0.0
6import { scaleLinear, scaleSequential, scaleSqrt } from 'd3-scale' // v^3.2.4
7import { interpolateViridis } from 'd3-scale-chromatic' // v^2.0.0
8import { pointer } from 'd3-selection' // v^2.0.0
9import { groupBy, mean, n, summarize, tidy } from '@tidyjs/tidy' // v^2.1.0
10import AutoSizer from 'react-virtualized-auto-sizer' // v^1.0.5
11
12const 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 ContainerHOC
21
22/**
23 * A higher order component (HOC) that interprets when a `width`
24 * or `height` prop is set to "100%" and replaces it with a measured
25 * 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%'
31
32 // no measurement needed, so don't bother wrapping
33 if (disableWidth && disableHeight) {
34 return <Component width={width} height={height} {...other} />
35 }
36
37 return (
38 <AutoSizer disableHeight={disableHeight} disableWidth={disableWidth}>
39 {({
40 width: responsiveWidth = width,
41 height: responsiveHeight = height,
42 }) => (
43 <Component
44 width={responsiveWidth}
45 height={responsiveHeight}
46 {...other}
47 />
48 )}
49 </AutoSizer>
50 )
51 }
52
53 // to make debugging easier, we add in a name for our wrapped component
54 const componentName = Component.displayName || Component.name
55 WrappedComponent.displayName = `WithAutoSizer(${componentName || ''})`
56
57 return WrappedComponent
58}
59
60// 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()
64
65 const margin = { top: 10, right: 10, bottom: 30, left: 50 }
66 const innerWidth = width - margin.left - margin.right
67 const innerHeight = height - margin.top - margin.bottom
68
69 // read from pre-defined metric/dimension ("fields") bundles
70 const xField = fields.revenue
71 const yField = fields.vote_average
72 const rField = fields.count
73 const colorField = fields.count
74 const labelField = fields.primary_genre
75
76 // optionally pull out values into local variables
77 const { accessor: xAccessor, title: xTitle, formatter: xFormatter } = xField
78 const { accessor: yAccessor, title: yTitle, formatter: yFormatter } = yField
79 const { accessor: rAccessor } = rField
80 const { accessor: colorAccessor } = colorField
81
82 // memoize creating our scales so we can optimize re-renders with React.memo
83 // (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)
92
93 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)
97
98 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 ])
113
114 // interaction setup
115 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 })
125
126 if (!data) return <div style={{ width, height }} />
127
128 return (
129 <div style={{ width }} className="relative">
130 <svg width={width} height={height}>
131 <g transform={`translate(${margin.left} ${margin.top})`}>
132 <XAxis
133 xScale={xScale}
134 formatter={xFormatter}
135 title={xTitle}
136 innerHeight={innerHeight}
137 gridLineHeight={innerHeight}
138 />
139
140 <YAxis
141 gridLineWidth={innerWidth}
142 yScale={yScale}
143 formatter={yFormatter}
144 title={yTitle}
145 />
146 <XAxisTitle
147 title={xTitle}
148 xScale={xScale}
149 innerHeight={innerHeight}
150 />
151 <YAxisTitle title={yTitle} />
152 <Points
153 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 <HoverPoint
164 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 />
175
176 <rect
177 /* 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})
191
192function padExtent([min, max], paddingFactor) {
193 const delta = Math.abs(max - min)
194 const padding = delta * paddingFactor
195
196 return [min - padding, max + padding]
197 // option to treat [0, 1] as a special case
198 // return [min === 0 ? 0 : min - padding, max === 1 ? 1 : max + padding]
199}
200
201/**
202 * Custom hook to get the closest point to the mouse based on
203 * iterating through all points. Supports a max distance from
204 * the mouse via the radius prop. You must provide an ref to a
205 * DOM node that can be used to capture the mouse, typically a
206 * <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 none
218 const [hoverPoint, setHoverPoint] = React.useState(undefined)
219
220 // we can throttle our updates by using requestAnimationFrame (raf)
221 const rafRef = React.useRef(null)
222
223 React.useEffect(() => {
224 const interactionRect = interactionRef.current
225 if (interactionRect == null) return
226
227 const handleMouseMove = (evt) => {
228 // here we use d3-selection's pointer. You could also try react-use useMouse.
229 const [mouseX, mouseY] = pointer(evt)
230
231 // if we already had a pending update, cancel it in favour of this one
232 if (rafRef.current) {
233 cancelAnimationFrame(rafRef.current)
234 }
235
236 rafRef.current = requestAnimationFrame(() => {
237 // naive iterate over all points method
238 const newHoverPoint = findClosestPoint({
239 data,
240 xScale,
241 xAccessor,
242 yScale,
243 yAccessor,
244 radius,
245 pixelX: mouseX,
246 pixelY: mouseY,
247 })
248
249 setHoverPoint(newHoverPoint)
250 })
251 }
252 interactionRect.addEventListener('mousemove', handleMouseMove)
253
254 // make sure we handle when the mouse leaves the interaction area to remove
255 // our active hover point
256 const handleMouseLeave = () => setHoverPoint(undefined)
257 interactionRect.addEventListener('mouseleave', handleMouseLeave)
258
259 // cleanup our listeners
260 return () => {
261 interactionRect.removeEventListener('mousemove', handleMouseMove)
262 interactionRect.removeEventListener('mouseleave', handleMouseLeave)
263 }
264 }, [interactionRef, data, xScale, yScale, radius, xAccessor, yAccessor])
265
266 return hoverPoint
267}
268
269// simple algorithm for finding the nearest point. uses fancy Math.hypot
270// 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 closestPoint
283 let minDistance = Infinity
284 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 = d
290 minDistance = distance
291 }
292 }
293
294 return closestPoint
295}
296
297/** 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 null
310
311 const d = hoverPoint
312 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)
316
317 const [xPixelMin, xPixelMax] = xScale.range()
318 const [yPixelMin, yPixelMax] = yScale.range()
319
320 return (
321 <g className="pointer-events-none">
322 <g data-testid="xCrosshair">
323 <line
324 x1={xPixelMin}
325 x2={xPixelMax}
326 y1={y}
327 y2={y}
328 stroke="#fff"
329 strokeWidth={4}
330 />
331 <line
332 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 <line
342 y1={yPixelMin}
343 y2={yPixelMax}
344 x1={x}
345 x2={x}
346 stroke="#fff"
347 strokeWidth={4}
348 />
349 <line
350 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 <circle
360 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 <OutlinedSvgText
369 stroke="#fff"
370 strokeWidth={5}
371 className="text-sm font-bold"
372 dy="0.8em"
373 >
374 {labelField.accessor(d)}
375 </OutlinedSvgText>
376 <OutlinedSvgText
377 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 <OutlinedSvgText
386 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}
398
399/**
400 * A memoized component that renders all our points, but only re-renders
401 * 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)) ?? radius
425 const color = colorScale?.(colorAccessor(d)) ?? defaultColor
426
427 return (
428 <circle
429 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)
448
449function isDarkColor(color) {
450 const labColor = lab(color)
451 return labColor.l < 75
452}
453
454/** dynamically create a darker color */
455function darker(color, factor = 0.85) {
456 const labColor = lab(color)
457 labColor.l *= factor
458
459 // rgb doesn't correspond to visual perception, but is
460 // easy for computers
461 // const rgbColor = rgb(color)
462 // rgbColor.r *= 0.8
463 // rgbColor.g *= 0.8
464 // rgbColor.b *= 0.8
465
466 // rgb(100, 50, 50);
467 // rgb(75, 25, 25); // is this half has light perceptually?
468 return labColor.toString()
469}
470
471/** 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}
482
483/** determine number of ticks based on space available */
484function numTicksForPixels(pixelsAvailable, pixelsPerTick = 70) {
485 return Math.floor(Math.abs(pixelsAvailable) / pixelsPerTick)
486}
487
488const YAxisTitle = ({ title }) => {
489 return (
490 <OutlinedSvgText
491 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}
502
503/** 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))
507
508 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 <text
516 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 <line
525 x1={0}
526 x2={-8}
527 stroke="var(--gray-300)"
528 data-testid="tickmark"
529 />
530 {gridLineWidth ? (
531 <line
532 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}
545
546const XAxisTitle = ({ xScale, title, innerHeight }) => {
547 const [, xMax] = xScale.range()
548 return (
549 <text
550 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}
561
562/** 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))
566
567 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 <text
575 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 <line
584 y1={0}
585 y2={8}
586 stroke="var(--gray-300)"
587 data-testid="tickmark"
588 />
589 {gridLineHeight ? (
590 <line
591 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}
604
605// fetch our data from CSV and translate to JSON
606const useMovieData = () => {
607 const [data, setData] = React.useState(undefined)
608
609 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)
626
627 // group by genre and summarize
628 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 )
641
642 console.log('groupedData', groupedData)
643
644 setData(groupedData)
645 })
646 }, [])
647
648 return data
649}
650
651// very lazy large number money formatter ($1.5M, $1.65B etc)
652const bigMoneyFormat = (value) => {
653 if (value == null) return value
654 const formatted = format('$~s')(value)
655 return formatted.replace(/G$/, 'B')
656}
657
658// metrics (numeric) + dimensions (non-numeric) = fields
659const 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 },
690
691 count: {
692 accessor: (d) => d.count,
693 title: 'Num Movies in Group',
694 formatter: (d) => d,
695 },
696}
697