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.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 { interpolatePlasma } 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 ContainerRenderFn = ({}) => {
13 return (
14 <div className="border border-dashed border-cyan-500">
15 {/* We wrap our component in this AutoSizer component to get
16 width and height measured externally. Our actual plot still just
17 expects a number for width and height and we don't have to change
18 its code at all. */}
19 <AutoSizer disableHeight>
20 {({ width, height = 400 }) => (
21 <Scatterplot width={width} height={height} />
22 )}
23 </AutoSizer>
24 </div>
25 )
26}
27
28export default ContainerRenderFn
29
30const Scatterplot = ({ width = 650, height = 400 }) => {
31 const data = useMovieData()
32
33 const margin = { top: 10, right: 10, bottom: 30, left: 50 }
34 const innerWidth = width - margin.left - margin.right
35 const innerHeight = height - margin.top - margin.bottom
36
37 // read from pre-defined metric/dimension ("fields") bundles
38 const xField = fields.revenue
39 const yField = fields.vote_average
40 const rField = fields.count
41 const colorField = fields.count
42 const labelField = fields.primary_genre
43
44 // optionally pull out values into local variables
45 const { accessor: xAccessor, title: xTitle, formatter: xFormatter } = xField
46 const { accessor: yAccessor, title: yTitle, formatter: yFormatter } = yField
47 const { accessor: rAccessor } = rField
48 const { accessor: colorAccessor } = colorField
49
50 // memoize creating our scales so we can optimize re-renders with React.memo
51 // (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)
60
61 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)
65
66 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 ])
81
82 // interaction setup
83 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 })
93
94 if (!data) return <div style={{ width, height }} />
95
96 return (
97 <div style={{ width }} className="relative">
98 <svg width={width} height={height}>
99 <g transform={`translate(${margin.left} ${margin.top})`}>
100 <XAxis
101 xScale={xScale}
102 formatter={xFormatter}
103 title={xTitle}
104 innerHeight={innerHeight}
105 gridLineHeight={innerHeight}
106 />
107
108 <YAxis
109 gridLineWidth={innerWidth}
110 yScale={yScale}
111 formatter={yFormatter}
112 title={yTitle}
113 />
114 <XAxisTitle
115 title={xTitle}
116 xScale={xScale}
117 innerHeight={innerHeight}
118 />
119 <YAxisTitle title={yTitle} />
120 <Points
121 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 <HoverPoint
132 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 />
143
144 <rect
145 /* 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}
159
160function padExtent([min, max], paddingFactor) {
161 const delta = Math.abs(max - min)
162 const padding = delta * paddingFactor
163
164 return [min - padding, max + padding]
165 // option to treat [0, 1] as a special case
166 // return [min === 0 ? 0 : min - padding, max === 1 ? 1 : max + padding]
167}
168
169/**
170 * Custom hook to get the closest point to the mouse based on
171 * iterating through all points. Supports a max distance from
172 * the mouse via the radius prop. You must provide an ref to a
173 * DOM node that can be used to capture the mouse, typically a
174 * <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 none
186 const [hoverPoint, setHoverPoint] = React.useState(undefined)
187
188 // we can throttle our updates by using requestAnimationFrame (raf)
189 const rafRef = React.useRef(null)
190
191 React.useEffect(() => {
192 const interactionRect = interactionRef.current
193 if (interactionRect == null) return
194
195 const handleMouseMove = (evt) => {
196 // here we use d3-selection's pointer. You could also try react-use useMouse.
197 const [mouseX, mouseY] = pointer(evt)
198
199 // if we already had a pending update, cancel it in favour of this one
200 if (rafRef.current) {
201 cancelAnimationFrame(rafRef.current)
202 }
203
204 rafRef.current = requestAnimationFrame(() => {
205 // naive iterate over all points method
206 const newHoverPoint = findClosestPoint({
207 data,
208 xScale,
209 xAccessor,
210 yScale,
211 yAccessor,
212 radius,
213 pixelX: mouseX,
214 pixelY: mouseY,
215 })
216
217 setHoverPoint(newHoverPoint)
218 })
219 }
220 interactionRect.addEventListener('mousemove', handleMouseMove)
221
222 // make sure we handle when the mouse leaves the interaction area to remove
223 // our active hover point
224 const handleMouseLeave = () => setHoverPoint(undefined)
225 interactionRect.addEventListener('mouseleave', handleMouseLeave)
226
227 // cleanup our listeners
228 return () => {
229 interactionRect.removeEventListener('mousemove', handleMouseMove)
230 interactionRect.removeEventListener('mouseleave', handleMouseLeave)
231 }
232 }, [interactionRef, data, xScale, yScale, radius, xAccessor, yAccessor])
233
234 return hoverPoint
235}
236
237// simple algorithm for finding the nearest point. uses fancy Math.hypot
238// 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 closestPoint
251 let minDistance = Infinity
252 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 = d
258 minDistance = distance
259 }
260 }
261
262 return closestPoint
263}
264
265/** 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 null
278
279 const d = hoverPoint
280 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)
284
285 const [xPixelMin, xPixelMax] = xScale.range()
286 const [yPixelMin, yPixelMax] = yScale.range()
287
288 return (
289 <g className="pointer-events-none">
290 <g data-testid="xCrosshair">
291 <line
292 x1={xPixelMin}
293 x2={xPixelMax}
294 y1={y}
295 y2={y}
296 stroke="#fff"
297 strokeWidth={4}
298 />
299 <line
300 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 <line
310 y1={yPixelMin}
311 y2={yPixelMax}
312 x1={x}
313 x2={x}
314 stroke="#fff"
315 strokeWidth={4}
316 />
317 <line
318 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 <circle
328 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 <OutlinedSvgText
337 stroke="#fff"
338 strokeWidth={5}
339 className="text-sm font-bold"
340 dy="0.8em"
341 >
342 {labelField.accessor(d)}
343 </OutlinedSvgText>
344 <OutlinedSvgText
345 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 <OutlinedSvgText
354 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}
366
367/**
368 * A memoized component that renders all our points, but only re-renders
369 * 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)) ?? radius
393 const color = colorScale?.(colorAccessor(d)) ?? defaultColor
394
395 return (
396 <circle
397 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)
416
417function isDarkColor(color) {
418 const labColor = lab(color)
419 return labColor.l < 75
420}
421
422/** dynamically create a darker color */
423function darker(color, factor = 0.85) {
424 const labColor = lab(color)
425 labColor.l *= factor
426
427 // rgb doesn't correspond to visual perception, but is
428 // easy for computers
429 // const rgbColor = rgb(color)
430 // rgbColor.r *= 0.8
431 // rgbColor.g *= 0.8
432 // rgbColor.b *= 0.8
433
434 // rgb(100, 50, 50);
435 // rgb(75, 25, 25); // is this half has light perceptually?
436 return labColor.toString()
437}
438
439/** 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}
450
451/** determine number of ticks based on space available */
452function numTicksForPixels(pixelsAvailable, pixelsPerTick = 70) {
453 return Math.floor(Math.abs(pixelsAvailable) / pixelsPerTick)
454}
455
456const YAxisTitle = ({ title }) => {
457 return (
458 <OutlinedSvgText
459 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}
470
471/** 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))
475
476 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 <text
484 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 <line
493 x1={0}
494 x2={-8}
495 stroke="var(--gray-300)"
496 data-testid="tickmark"
497 />
498 {gridLineWidth ? (
499 <line
500 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}
513
514const XAxisTitle = ({ xScale, title, innerHeight }) => {
515 const [, xMax] = xScale.range()
516 return (
517 <text
518 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}
529
530/** 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))
534
535 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 <text
543 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 <line
552 y1={0}
553 y2={8}
554 stroke="var(--gray-300)"
555 data-testid="tickmark"
556 />
557 {gridLineHeight ? (
558 <line
559 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}
572
573// fetch our data from CSV and translate to JSON
574const useMovieData = () => {
575 const [data, setData] = React.useState(undefined)
576
577 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)
594
595 // group by genre and summarize
596 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 )
609
610 console.log('groupedData', groupedData)
611
612 setData(groupedData)
613 })
614 }, [])
615
616 return data
617}
618
619// very lazy large number money formatter ($1.5M, $1.65B etc)
620const bigMoneyFormat = (value) => {
621 if (value == null) return value
622 const formatted = format('$~s')(value)
623 return formatted.replace(/G$/, 'B')
624}
625
626// metrics (numeric) + dimensions (non-numeric) = fields
627const 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 },
658
659 count: {
660 accessor: (d) => d.count,
661 title: 'Num Movies in Group',
662 formatter: (d) => d,
663 },
664}
665