Tooltips via Popper

Description

When working with tooltips in a chart, we can sometimes get away with just rendering text within the SVG itself. This is the easiest approach, but has limitations: we are limited to including SVG (okay maybe some foreign objects may work) where text is very difficult to work with (e.g. it doesn't support natural text wrapping), and we need to make sure our tooltip doesn't exceed the bounds of our chart or it will get clipped. As we saw in Basic SVG Hover, the clipping was real.

A more natural experience is to be able to use normal html styling (hello CSS grid) and have our tooltips get placed in the best spot based on the dimensions of the browser window – not the chart. This is exactly what Popper.js does. It doesn't tell us anything about how to draw our tooltip, it simply makes sure the tooltip (and arrow!) are positioned in a visible place on the screen.

There's some complexity getting this to work as Popper typically anchors a tooltip to a DOM node and I'd like for it to essentially map to our hover point's x and y position based on the data. This example shows how to go from X and Y to a popper-placed tooltip without including any DOM target nodes along the way, which is suitable for many charts (but may present other challenges on bandscales). Be sure to check out the CSS tab since the arrow requires some custom CSS to work.

Note when you hover on the uppermost point ("Western") that the tooltip flips or when you head to the right edge of the browser ("Family"), the arrow shifts, but the tooltip is still readable. Thanks Popper!

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 { interpolatePuBuGn } 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 { usePopper } from 'react-popper' // v^2.2.5
11
12const Scatterplot = ({ width = 650, height = 400 }) => {
13 const data = useMovieData()
14
15 const margin = { top: 10, right: 10, bottom: 30, left: 50 }
16 const innerWidth = width - margin.left - margin.right
17 const innerHeight = height - margin.top - margin.bottom
18
19 // read from pre-defined metric/dimension ("fields") bundles
20 const xField = fields.revenue
21 const yField = fields.vote_average
22 const rField = fields.count
23 const colorField = fields.count
24 const labelField = fields.primary_genre
25
26 // optionally pull out values into local variables
27 const { accessor: xAccessor, title: xTitle, formatter: xFormatter } = xField
28 const { accessor: yAccessor, title: yTitle, formatter: yFormatter } = yField
29 const { accessor: rAccessor } = rField
30 const { accessor: colorAccessor } = colorField
31
32 // memoize creating our scales so we can optimize re-renders with React.memo
33 // (e.g. <Points> only re-renders when its props change)
34 const { xScale, yScale, rScale, colorScale } = React.useMemo(() => {
35 if (!data) return {}
36 const xExtent = extent(data, xAccessor)
37 const xDomain = padExtent(xExtent, 0.125)
38 const yExtent = extent(data, yAccessor)
39 const yDomain = padExtent(yExtent, 0.125)
40 const rExtent = extent(data, rAccessor)
41 const colorExtent = extent(data, colorAccessor)
42
43 const xScale = scaleLinear().domain(xDomain).range([0, innerWidth])
44 const yScale = scaleLinear().domain(yDomain).range([innerHeight, 0])
45 const rScale = scaleSqrt().domain(rExtent).range([2, 16])
46 const colorScale = scaleSequential(interpolatePuBuGn).domain(colorExtent)
47
48 return {
49 xScale,
50 yScale,
51 rScale,
52 colorScale,
53 }
54 }, [
55 colorAccessor,
56 data,
57 innerHeight,
58 innerWidth,
59 rAccessor,
60 xAccessor,
61 yAccessor,
62 ])
63
64 // interaction setup
65 const interactionRef = React.useRef(null)
66 const hoverPoint = useClosestHoverPoint({
67 interactionRef,
68 data,
69 xScale,
70 xAccessor,
71 yScale,
72 yAccessor,
73 radius: 60,
74 })
75
76 if (!data) return <div style={{ width, height }} />
77
78 return (
79 <div style={{ width }} className="relative">
80 <svg width={width} height={height}>
81 <g transform={`translate(${margin.left} ${margin.top})`}>
82 <XAxis
83 xScale={xScale}
84 formatter={xFormatter}
85 title={xTitle}
86 innerHeight={innerHeight}
87 gridLineHeight={innerHeight}
88 />
89
90 <YAxis
91 gridLineWidth={innerWidth}
92 yScale={yScale}
93 formatter={yFormatter}
94 title={yTitle}
95 />
96 <XAxisTitle
97 title={xTitle}
98 xScale={xScale}
99 innerHeight={innerHeight}
100 />
101 <YAxisTitle title={yTitle} />
102 <Points
103 data={data}
104 xScale={xScale}
105 xAccessor={xAccessor}
106 yScale={yScale}
107 yAccessor={yAccessor}
108 rScale={rScale}
109 rAccessor={rAccessor}
110 colorScale={colorScale}
111 colorAccessor={colorAccessor}
112 />
113 <HoverPoint
114 labelField={labelField}
115 xScale={xScale}
116 xField={xField}
117 yScale={yScale}
118 yField={yField}
119 rScale={rScale}
120 rField={rField}
121 colorScale={colorScale}
122 colorField={colorField}
123 hoverPoint={hoverPoint}
124 />
125
126 <rect
127 /* this node absorbs all mouse events */
128 ref={interactionRef}
129 width={innerWidth}
130 height={innerHeight}
131 x={0}
132 y={0}
133 fill="tomato"
134 fillOpacity={0}
135 />
136 </g>
137 </svg>
138 {/* Note that we add this in the HTML area */}
139 <HoverPointTooltip
140 margin={margin}
141 xScale={xScale}
142 xField={xField}
143 yScale={yScale}
144 yField={yField}
145 colorField={colorField}
146 colorScale={colorScale}
147 labelField={labelField}
148 hoverPoint={hoverPoint}
149 />
150 </div>
151 )
152}
153export default Scatterplot
154
155/**
156 * Make use of our XYPopper class but handle our exact use case
157 * for what we want in the tooltip based on the hoverPoint.
158 */
159const HoverPointTooltip = ({
160 margin,
161 xScale,
162 xField,
163 yScale,
164 yField,
165 labelField,
166 colorScale,
167 colorField,
168 hoverPoint,
169}) => {
170 if (!hoverPoint) return null
171 const x = xScale(xField.accessor(hoverPoint)) + margin.left
172 const y = yScale(yField.accessor(hoverPoint)) + margin.top
173
174 return (
175 <XYPopper x={x} y={y}>
176 {/* the content of the tooltip goes here */}
177 <div className="pb-1 mb-1 text-sm font-bold leading-none border-b border-gray-200 border-opacity-20">
178 {labelField.accessor(hoverPoint)}
179 </div>
180 <div
181 className="grid gap-x-2"
182 style={{ gridTemplateColumns: 'max-content 1fr' }}
183 >
184 <span className="font-semibold text-right">
185 {xField.formatter(xField.accessor(hoverPoint))}
186 </span>
187 <span>{xField.title}</span>
188 <span className="font-semibold text-right">
189 {yField.formatter(yField.accessor(hoverPoint))}
190 </span>
191 <span>{yField.title}</span>
192 <span className="text-right">
193 <span
194 className="px-1 font-semibold rounded"
195 style={{
196 backgroundColor: colorScale(colorField.accessor(hoverPoint)),
197 color: isDarkColor(colorScale(colorField.accessor(hoverPoint)))
198 ? 'white'
199 : 'black',
200 }}
201 >
202 {colorField.formatter(colorField.accessor(hoverPoint))}
203 </span>
204 </span>
205 <span>{colorField.title}</span>
206 </div>
207 </XYPopper>
208 )
209}
210
211/**
212 * Generic popper wrapper that places a popper at an x y position
213 * Uses a dummy element to handle this. An alternative is to provide
214 * the referenceElement node yourself and not use a dummy element,
215 * which can be helpful for things where you interact directly like in
216 * bar charts.
217 *
218 * FYI this requires custom css to work, see CSS code.
219 */
220const XYPopper = ({ x, y, children }) => {
221 const [referenceElement, setReferenceElement] = React.useState(null)
222 const [popperElement, setPopperElement] = React.useState(null)
223 const [arrowElement, setArrowElement] = React.useState(null)
224 const offsetX = 0
225 const offsetY = 10
226 const { styles, attributes, update } = usePopper(
227 referenceElement,
228 popperElement,
229 {
230 placement: 'top',
231 modifiers: [
232 { name: 'offset', options: { offset: [offsetX, offsetY] } },
233 { name: 'arrow', options: { element: arrowElement, padding: 8 } },
234 ],
235 }
236 )
237
238 // force the popper to update its reference element when x and y change
239 // since we are using a dummy element
240 React.useEffect(() => {
241 if (x !== null && y !== null) {
242 update?.()
243 }
244 }, [x, y, update])
245
246 return (
247 <>
248 <div /** dummy element to position popper with */
249 ref={setReferenceElement}
250 style={{
251 position: 'absolute',
252 left: x,
253 top: y,
254 width: 0,
255 height: 0,
256 pointerEvents: 'none',
257 }}
258 />
259 <div
260 ref={setPopperElement}
261 className="xy-popper"
262 style={styles.popper}
263 {...attributes.popper}
264 >
265 <div
266 ref={setArrowElement}
267 style={styles.arrow}
268 {...attributes.arrow}
269 className="xy-popper-arrow"
270 />
271 <div className="xy-popper-content">
272 {/* the actual tooltip contents go here */}
273 {children}
274 </div>
275 </div>
276 </>
277 )
278}
279
280function padExtent([min, max], paddingFactor) {
281 const delta = Math.abs(max - min)
282 const padding = delta * paddingFactor
283
284 return [min - padding, max + padding]
285 // option to treat [0, 1] as a special case
286 // return [min === 0 ? 0 : min - padding, max === 1 ? 1 : max + padding]
287}
288
289/**
290 * Custom hook to get the closest point to the mouse based on
291 * iterating through all points. Supports a max distance from
292 * the mouse via the radius prop. You must provide an ref to a
293 * DOM node that can be used to capture the mouse, typically a
294 * <rect> or <g> that covers the entire visualization.
295 */
296function useClosestHoverPoint({
297 interactionRef,
298 data,
299 xScale,
300 xAccessor,
301 yScale,
302 yAccessor,
303 radius,
304}) {
305 // capture our hover point or undefined if none
306 const [hoverPoint, setHoverPoint] = React.useState(undefined)
307
308 // we can throttle our updates by using requestAnimationFrame (raf)
309 const rafRef = React.useRef(null)
310
311 React.useEffect(() => {
312 const interactionRect = interactionRef.current
313 if (interactionRect == null) return
314
315 const handleMouseMove = (evt) => {
316 // here we use d3-selection's pointer. You could also try react-use useMouse.
317 const [mouseX, mouseY] = pointer(evt)
318
319 // if we already had a pending update, cancel it in favour of this one
320 if (rafRef.current) {
321 cancelAnimationFrame(rafRef.current)
322 }
323
324 rafRef.current = requestAnimationFrame(() => {
325 // naive iterate over all points method
326 const newHoverPoint = findClosestPoint({
327 data,
328 xScale,
329 xAccessor,
330 yScale,
331 yAccessor,
332 radius,
333 pixelX: mouseX,
334 pixelY: mouseY,
335 })
336
337 setHoverPoint(newHoverPoint)
338 })
339 }
340 interactionRect.addEventListener('mousemove', handleMouseMove)
341
342 // make sure we handle when the mouse leaves the interaction area to remove
343 // our active hover point
344 const handleMouseLeave = () => setHoverPoint(undefined)
345 interactionRect.addEventListener('mouseleave', handleMouseLeave)
346
347 // cleanup our listeners
348 return () => {
349 interactionRect.removeEventListener('mousemove', handleMouseMove)
350 interactionRect.removeEventListener('mouseleave', handleMouseLeave)
351 }
352 }, [interactionRef, data, xScale, yScale, radius, xAccessor, yAccessor])
353
354 return hoverPoint
355}
356
357// simple algorithm for finding the nearest point. uses fancy Math.hypot
358// to compute distance between a target (pixelX, pixelY) and each point.
359// supports a max distance via the radius prop.
360function findClosestPoint({
361 data,
362 xScale,
363 yScale,
364 xAccessor,
365 yAccessor,
366 pixelX,
367 pixelY,
368 radius,
369}) {
370 let closestPoint
371 let minDistance = Infinity
372 for (const d of data) {
373 const pointPixelX = xScale(xAccessor(d))
374 const pointPixelY = yScale(yAccessor(d))
375 const distance = Math.hypot(pointPixelX - pixelX, pointPixelY - pixelY)
376 if (distance < minDistance && radius != null && distance < radius) {
377 closestPoint = d
378 minDistance = distance
379 }
380 }
381
382 return closestPoint
383}
384
385/** draws our hover marks: a crosshair + point + basic tooltip */
386const HoverPoint = ({
387 hoverPoint,
388 xScale,
389 xField,
390 yField,
391 yScale,
392 rScale,
393 rField,
394 labelField,
395 color = 'cyan',
396}) => {
397 if (!hoverPoint) return null
398
399 const d = hoverPoint
400 const x = xScale(xField.accessor(d))
401 const y = yScale(yField.accessor(d))
402 const r = rScale?.(rField.accessor(d))
403 const darkerColor = darker(color)
404
405 const [xPixelMin, xPixelMax] = xScale.range()
406 const [yPixelMin, yPixelMax] = yScale.range()
407
408 return (
409 <g className="pointer-events-none">
410 <g data-testid="xCrosshair">
411 <line
412 x1={xPixelMin}
413 x2={xPixelMax}
414 y1={y}
415 y2={y}
416 stroke="#fff"
417 strokeWidth={4}
418 />
419 <line
420 x1={xPixelMin}
421 x2={xPixelMax}
422 y1={y}
423 y2={y}
424 stroke={darkerColor}
425 strokeWidth={1}
426 />
427 </g>
428 <g data-testid="yCrosshair">
429 <line
430 y1={yPixelMin}
431 y2={yPixelMax}
432 x1={x}
433 x2={x}
434 stroke="#fff"
435 strokeWidth={4}
436 />
437 <line
438 y1={yPixelMin}
439 y2={yPixelMax}
440 x1={x}
441 x2={x}
442 stroke={darkerColor}
443 strokeWidth={1}
444 />
445 </g>
446 <circle cx={x} cy={y} r={r} fill={color} stroke="#fff" strokeWidth={4} />
447 <circle
448 cx={x}
449 cy={y}
450 r={r}
451 fill={color}
452 stroke={darkerColor}
453 strokeWidth={2}
454 />
455 </g>
456 )
457}
458
459/**
460 * A memoized component that renders all our points, but only re-renders
461 * when its props change.
462 */
463const Points = React.memo(
464 ({
465 data,
466 xScale,
467 xAccessor,
468 yAccessor,
469 yScale,
470 rScale,
471 rAccessor,
472 radius = 8,
473 colorScale,
474 colorAccessor,
475 defaultColor = 'tomato',
476 onHover,
477 }) => {
478 return (
479 <g data-testid="Points">
480 {data.map((d, i) => {
481 // const x = (width * (d.revenue - minRevenue)) / (maxRevenue - minRevenue)
482 const x = xScale(xAccessor(d))
483 const y = yScale(yAccessor(d))
484 const r = rScale?.(rAccessor(d)) ?? radius
485 const color = colorScale?.(colorAccessor(d)) ?? defaultColor
486
487 return (
488 <circle
489 key={d.id ?? i}
490 r={r}
491 cx={x}
492 cy={y}
493 fill={color}
494 stroke={darker(color)}
495 strokeWidth={1}
496 strokeOpacity={1}
497 fillOpacity={1}
498 onClick={() => console.log(d)}
499 onMouseEnter={onHover ? () => onHover(d) : null}
500 onMouseLeave={onHover ? () => onHover(undefined) : null}
501 />
502 )
503 })}
504 </g>
505 )
506 }
507)
508
509function isDarkColor(color) {
510 const labColor = lab(color)
511 return labColor.l < 75
512}
513
514/** dynamically create a darker color */
515function darker(color, factor = 0.85) {
516 const labColor = lab(color)
517 labColor.l *= factor
518
519 // rgb doesn't correspond to visual perception, but is
520 // easy for computers
521 // const rgbColor = rgb(color)
522 // rgbColor.r *= 0.8
523 // rgbColor.g *= 0.8
524 // rgbColor.b *= 0.8
525
526 // rgb(100, 50, 50);
527 // rgb(75, 25, 25); // is this half has light perceptually?
528 return labColor.toString()
529}
530
531/** fancier way of getting a nice svg text stroke */
532const OutlinedSvgText = ({ stroke, strokeWidth, children, ...other }) => {
533 return (
534 <>
535 <text stroke={stroke} strokeWidth={strokeWidth} {...other}>
536 {children}
537 </text>
538 <text {...other}>{children}</text>
539 </>
540 )
541}
542
543/** determine number of ticks based on space available */
544function numTicksForPixels(pixelsAvailable, pixelsPerTick = 70) {
545 return Math.floor(Math.abs(pixelsAvailable) / pixelsPerTick)
546}
547
548const YAxisTitle = ({ title }) => {
549 return (
550 <OutlinedSvgText
551 stroke="#fff"
552 strokeWidth={2.5}
553 dx={4}
554 dy="0.8em"
555 fill="var(--gray-600)"
556 className="font-semibold text-2xs"
557 >
558 {title}
559 </OutlinedSvgText>
560 )
561}
562
563/** Y-axis with title and grid lines */
564const YAxis = ({ yScale, formatter, gridLineWidth }) => {
565 const [yMin, yMax] = yScale.range()
566 const ticks = yScale.ticks(numTicksForPixels(yMax - yMin, 50))
567
568 return (
569 <g data-testid="YAxis">
570 <line x1={0} x2={0} y1={yMin} y2={yMax} stroke="var(--gray-400)" />
571 {ticks.map((tick) => {
572 const y = yScale(tick)
573 return (
574 <g key={tick} transform={`translate(0 ${y})`}>
575 <text
576 dy="0.34em"
577 textAnchor="end"
578 dx={-12}
579 fill="currentColor"
580 className="text-gray-400 text-2xs"
581 >
582 {formatter(tick)}
583 </text>
584 <line
585 x1={0}
586 x2={-8}
587 stroke="var(--gray-300)"
588 data-testid="tickmark"
589 />
590 {gridLineWidth ? (
591 <line
592 x1={0}
593 x2={gridLineWidth}
594 stroke="var(--gray-200)"
595 strokeOpacity={0.8}
596 data-testid="gridline"
597 />
598 ) : null}
599 </g>
600 )
601 })}
602 </g>
603 )
604}
605
606const XAxisTitle = ({ xScale, title, innerHeight }) => {
607 const [, xMax] = xScale.range()
608 return (
609 <text
610 x={xMax}
611 y={innerHeight}
612 textAnchor="end"
613 dy={-4}
614 fill="var(--gray-600)"
615 className="font-semibold text-2xs text-shadow-white-stroke"
616 >
617 {title}
618 </text>
619 )
620}
621
622/** X-axis with title and grid lines */
623const XAxis = ({ xScale, title, formatter, innerHeight, gridLineHeight }) => {
624 const [xMin, xMax] = xScale.range()
625 const ticks = xScale.ticks(numTicksForPixels(xMax - xMin))
626
627 return (
628 <g data-testid="XAxis" transform={`translate(0 ${innerHeight})`}>
629 <line x1={xMin} x2={xMax} y1={0} y2={0} stroke="var(--gray-400)" />
630 {ticks.map((tick) => {
631 const x = xScale(tick)
632 return (
633 <g key={tick} transform={`translate(${x} 0)`}>
634 <text
635 y={10}
636 dy="0.8em"
637 textAnchor="middle"
638 fill="currentColor"
639 className="text-gray-400 text-2xs"
640 >
641 {formatter(tick)}
642 </text>
643 <line
644 y1={0}
645 y2={8}
646 stroke="var(--gray-300)"
647 data-testid="tickmark"
648 />
649 {gridLineHeight ? (
650 <line
651 y1={0}
652 y2={-gridLineHeight}
653 stroke="var(--gray-200)"
654 strokeOpacity={0.8}
655 data-testid="gridline"
656 />
657 ) : null}
658 </g>
659 )
660 })}
661 </g>
662 )
663}
664
665// fetch our data from CSV and translate to JSON
666const useMovieData = () => {
667 const [data, setData] = React.useState(undefined)
668
669 React.useEffect(() => {
670 fetch('/datasets/tmdb_1000_movies_small.csv')
671 // fetch('/datasets/tmdb_5000_movies.csv')
672 .then((response) => response.text())
673 .then((csvString) => {
674 const data = csvParse(csvString, (row) => {
675 return {
676 budget: +row.budget,
677 vote_average: +row.vote_average,
678 vote_count: +row.vote_count,
679 genres: JSON.parse(row.genres),
680 primary_genre: JSON.parse(row.genres)[0]?.name,
681 revenue: +row.revenue,
682 original_title: row.original_title,
683 }
684 }).filter((d) => d.revenue > 0)
685 console.log('[data]', data)
686
687 // group by genre and summarize
688 const groupedData = tidy(
689 data,
690 groupBy(
691 ['primary_genre'],
692 [
693 summarize({
694 revenue: mean('revenue'),
695 vote_average: mean('vote_average'),
696 count: n(),
697 }),
698 ]
699 )
700 )
701
702 console.log('groupedData', groupedData)
703
704 setData(groupedData)
705 })
706 }, [])
707
708 return data
709}
710
711// very lazy large number money formatter ($1.5M, $1.65B etc)
712const bigMoneyFormat = (value) => {
713 if (value == null) return value
714 const formatted = format('$~s')(value)
715 return formatted.replace(/G$/, 'B')
716}
717
718// metrics (numeric) + dimensions (non-numeric) = fields
719const fields = {
720 revenue: {
721 accessor: (d) => d.revenue,
722 title: 'Revenue',
723 formatter: bigMoneyFormat,
724 },
725 budget: {
726 accessor: (d) => d.budget,
727 title: 'Budget',
728 formatter: bigMoneyFormat,
729 },
730 vote_average: {
731 accessor: (d) => d.vote_average,
732 title: 'Vote Average out of 10',
733 formatter: format('.1f'),
734 },
735 vote_count: {
736 accessor: (d) => d.vote_count,
737 title: 'Vote Count',
738 formatter: format('.1f'),
739 },
740 primary_genre: {
741 accessor: (d) => d.primary_genre,
742 title: 'Primary Genre',
743 formatter: (d) => d,
744 },
745 original_title: {
746 accessor: (d) => d.original_title,
747 title: 'Original Title',
748 formatter: (d) => d,
749 },
750
751 count: {
752 accessor: (d) => d.count,
753 title: 'Num Movies in Group',
754 formatter: (d) => d,
755 },
756}
757