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.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 { interpolatePuBuGn } 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 { usePopper } from 'react-popper' // v^2.2.51112const Scatterplot = ({ width = 650, height = 400 }) => {13 const data = useMovieData()1415 const margin = { top: 10, right: 10, bottom: 30, left: 50 }16 const innerWidth = width - margin.left - margin.right17 const innerHeight = height - margin.top - margin.bottom1819 // read from pre-defined metric/dimension ("fields") bundles20 const xField = fields.revenue21 const yField = fields.vote_average22 const rField = fields.count23 const colorField = fields.count24 const labelField = fields.primary_genre2526 // optionally pull out values into local variables27 const { accessor: xAccessor, title: xTitle, formatter: xFormatter } = xField28 const { accessor: yAccessor, title: yTitle, formatter: yFormatter } = yField29 const { accessor: rAccessor } = rField30 const { accessor: colorAccessor } = colorField3132 // memoize creating our scales so we can optimize re-renders with React.memo33 // (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)4243 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)4748 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 ])6364 // interaction setup65 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 })7576 if (!data) return <div style={{ width, height }} />7778 return (79 <div style={{ width }} className="relative">80 <svg width={width} height={height}>81 <g transform={`translate(${margin.left} ${margin.top})`}>82 <XAxis83 xScale={xScale}84 formatter={xFormatter}85 title={xTitle}86 innerHeight={innerHeight}87 gridLineHeight={innerHeight}88 />8990 <YAxis91 gridLineWidth={innerWidth}92 yScale={yScale}93 formatter={yFormatter}94 title={yTitle}95 />96 <XAxisTitle97 title={xTitle}98 xScale={xScale}99 innerHeight={innerHeight}100 />101 <YAxisTitle title={yTitle} />102 <Points103 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 <HoverPoint114 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 />125126 <rect127 /* 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 <HoverPointTooltip140 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 Scatterplot154155/**156 * Make use of our XYPopper class but handle our exact use case157 * 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 null171 const x = xScale(xField.accessor(hoverPoint)) + margin.left172 const y = yScale(yField.accessor(hoverPoint)) + margin.top173174 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 <div181 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 <span194 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}210211/**212 * Generic popper wrapper that places a popper at an x y position213 * Uses a dummy element to handle this. An alternative is to provide214 * the referenceElement node yourself and not use a dummy element,215 * which can be helpful for things where you interact directly like in216 * 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 = 0225 const offsetY = 10226 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 )237238 // force the popper to update its reference element when x and y change239 // since we are using a dummy element240 React.useEffect(() => {241 if (x !== null && y !== null) {242 update?.()243 }244 }, [x, y, update])245246 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 <div260 ref={setPopperElement}261 className="xy-popper"262 style={styles.popper}263 {...attributes.popper}264 >265 <div266 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}279280function padExtent([min, max], paddingFactor) {281 const delta = Math.abs(max - min)282 const padding = delta * paddingFactor283284 return [min - padding, max + padding]285 // option to treat [0, 1] as a special case286 // return [min === 0 ? 0 : min - padding, max === 1 ? 1 : max + padding]287}288289/**290 * Custom hook to get the closest point to the mouse based on291 * iterating through all points. Supports a max distance from292 * the mouse via the radius prop. You must provide an ref to a293 * DOM node that can be used to capture the mouse, typically a294 * <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 none306 const [hoverPoint, setHoverPoint] = React.useState(undefined)307308 // we can throttle our updates by using requestAnimationFrame (raf)309 const rafRef = React.useRef(null)310311 React.useEffect(() => {312 const interactionRect = interactionRef.current313 if (interactionRect == null) return314315 const handleMouseMove = (evt) => {316 // here we use d3-selection's pointer. You could also try react-use useMouse.317 const [mouseX, mouseY] = pointer(evt)318319 // if we already had a pending update, cancel it in favour of this one320 if (rafRef.current) {321 cancelAnimationFrame(rafRef.current)322 }323324 rafRef.current = requestAnimationFrame(() => {325 // naive iterate over all points method326 const newHoverPoint = findClosestPoint({327 data,328 xScale,329 xAccessor,330 yScale,331 yAccessor,332 radius,333 pixelX: mouseX,334 pixelY: mouseY,335 })336337 setHoverPoint(newHoverPoint)338 })339 }340 interactionRect.addEventListener('mousemove', handleMouseMove)341342 // make sure we handle when the mouse leaves the interaction area to remove343 // our active hover point344 const handleMouseLeave = () => setHoverPoint(undefined)345 interactionRect.addEventListener('mouseleave', handleMouseLeave)346347 // cleanup our listeners348 return () => {349 interactionRect.removeEventListener('mousemove', handleMouseMove)350 interactionRect.removeEventListener('mouseleave', handleMouseLeave)351 }352 }, [interactionRef, data, xScale, yScale, radius, xAccessor, yAccessor])353354 return hoverPoint355}356357// simple algorithm for finding the nearest point. uses fancy Math.hypot358// 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 closestPoint371 let minDistance = Infinity372 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 = d378 minDistance = distance379 }380 }381382 return closestPoint383}384385/** 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 null398399 const d = hoverPoint400 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)404405 const [xPixelMin, xPixelMax] = xScale.range()406 const [yPixelMin, yPixelMax] = yScale.range()407408 return (409 <g className="pointer-events-none">410 <g data-testid="xCrosshair">411 <line412 x1={xPixelMin}413 x2={xPixelMax}414 y1={y}415 y2={y}416 stroke="#fff"417 strokeWidth={4}418 />419 <line420 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 <line430 y1={yPixelMin}431 y2={yPixelMax}432 x1={x}433 x2={x}434 stroke="#fff"435 strokeWidth={4}436 />437 <line438 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 <circle448 cx={x}449 cy={y}450 r={r}451 fill={color}452 stroke={darkerColor}453 strokeWidth={2}454 />455 </g>456 )457}458459/**460 * A memoized component that renders all our points, but only re-renders461 * 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)) ?? radius485 const color = colorScale?.(colorAccessor(d)) ?? defaultColor486487 return (488 <circle489 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)508509function isDarkColor(color) {510 const labColor = lab(color)511 return labColor.l < 75512}513514/** dynamically create a darker color */515function darker(color, factor = 0.85) {516 const labColor = lab(color)517 labColor.l *= factor518519 // rgb doesn't correspond to visual perception, but is520 // easy for computers521 // const rgbColor = rgb(color)522 // rgbColor.r *= 0.8523 // rgbColor.g *= 0.8524 // rgbColor.b *= 0.8525526 // rgb(100, 50, 50);527 // rgb(75, 25, 25); // is this half has light perceptually?528 return labColor.toString()529}530531/** 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}542543/** determine number of ticks based on space available */544function numTicksForPixels(pixelsAvailable, pixelsPerTick = 70) {545 return Math.floor(Math.abs(pixelsAvailable) / pixelsPerTick)546}547548const YAxisTitle = ({ title }) => {549 return (550 <OutlinedSvgText551 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}562563/** 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))567568 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 <text576 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 <line585 x1={0}586 x2={-8}587 stroke="var(--gray-300)"588 data-testid="tickmark"589 />590 {gridLineWidth ? (591 <line592 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}605606const XAxisTitle = ({ xScale, title, innerHeight }) => {607 const [, xMax] = xScale.range()608 return (609 <text610 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}621622/** 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))626627 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 <text635 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 <line644 y1={0}645 y2={8}646 stroke="var(--gray-300)"647 data-testid="tickmark"648 />649 {gridLineHeight ? (650 <line651 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}664665// fetch our data from CSV and translate to JSON666const useMovieData = () => {667 const [data, setData] = React.useState(undefined)668669 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)686687 // group by genre and summarize688 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 )701702 console.log('groupedData', groupedData)703704 setData(groupedData)705 })706 }, [])707708 return data709}710711// very lazy large number money formatter ($1.5M, $1.65B etc)712const bigMoneyFormat = (value) => {713 if (value == null) return value714 const formatted = format('$~s')(value)715 return formatted.replace(/G$/, 'B')716}717718// metrics (numeric) + dimensions (non-numeric) = fields719const 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 },750751 count: {752 accessor: (d) => d.count,753 title: 'Num Movies in Group',754 formatter: (d) => d,755 },756}757