Closest Point Hover
Description
In this scatterplot, we're building off of Basic Hover Scatterplot and switching from hover via mouse directly over an SVG node to being nearby. There are a number of ways of getting this done, as always, and in this case we're exploring using the pointer
function from d3-selection. An alternative approach would be to try using useMouse
from react-use. We also put in a max-distance (radius
from point) constraint that ensures we only highlight a point if we are reasonably close to it (60 pixels).
Note we do something that is perhaps a bit unsual: we encapsulate our hover behavior in a custom hook called useClosestHoverPoint
and attach our mouse listeners to a rectangle captured by interactionRef
. It's important that the rectangle have a fill (but be transparent so we don't see it) so it captures mouse events. The main benefit of this approach is that we can easily move this hover behavior around between components and I find it's a pretty re-usable approach. Also, by using a single interaction rectangle to capture all mouse events, we will later be able to stack in zooming and brushing behavior.
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 { interpolateInferno } from 'd3-scale-chromatic' // v^2.0.08import { pointer } from 'd3-selection' // v^2.0.0910const Scatterplot = ({}) => {11 const data = useMovieData()1213 const width = 65014 const height = 40015 const margin = { top: 10, right: 100, 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.vote_count23 const colorField = fields.vote_average24 const labelField = fields.original_title2526 // 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 yExtent = extent(data, yAccessor)38 const rExtent = extent(data, rAccessor)39 const colorExtent = extent(data, colorAccessor)4041 const xScale = scaleLinear().domain(xExtent).range([0, innerWidth])42 const yScale = scaleLinear().domain(yExtent).range([innerHeight, 0])43 const rScale = scaleSqrt().domain(rExtent).range([2, 16])44 const colorScale = scaleSequential(interpolateInferno).domain(colorExtent)4546 return {47 xScale,48 yScale,49 rScale,50 colorScale,51 }52 }, [53 colorAccessor,54 data,55 innerHeight,56 innerWidth,57 rAccessor,58 xAccessor,59 yAccessor,60 ])6162 // interaction setup63 const interactionRef = React.useRef(null)64 const hoverPoint = useClosestHoverPoint({65 interactionRef,66 data,67 xScale,68 xAccessor,69 yScale,70 yAccessor,71 radius: 60,72 })7374 if (!data) return <div style={{ width, height }} />7576 return (77 <div style={{ width }} className="relative">78 <svg width={width} height={height}>79 <g transform={`translate(${margin.left} ${margin.top})`}>80 <XAxis81 xScale={xScale}82 formatter={xFormatter}83 title={xTitle}84 innerHeight={innerHeight}85 gridLineHeight={innerHeight}86 />87 <YAxis88 gridLineWidth={innerWidth}89 yScale={yScale}90 formatter={yFormatter}91 title={yTitle}92 />93 <Points94 data={data}95 xScale={xScale}96 xAccessor={xAccessor}97 yScale={yScale}98 yAccessor={yAccessor}99 rScale={rScale}100 rAccessor={rAccessor}101 colorScale={colorScale}102 colorAccessor={colorAccessor}103 />104 <HoverPoint105 labelField={labelField}106 xScale={xScale}107 xField={xField}108 yScale={yScale}109 yField={yField}110 rScale={rScale}111 rField={rField}112 colorScale={colorScale}113 colorField={colorField}114 hoverPoint={hoverPoint}115 />116117 <rect118 /* this node absorbs all mouse events */119 ref={interactionRef}120 width={innerWidth}121 height={innerHeight}122 x={0}123 y={0}124 fill="tomato"125 fillOpacity={0}126 />127 </g>128 </svg>129 </div>130 )131}132133export default Scatterplot134135/**136 * Custom hook to get the closest point to the mouse based on137 * iterating through all points. Supports a max distance from138 * the mouse via the radius prop. You must provide an ref to a139 * DOM node that can be used to capture the mouse, typically a140 * <rect> or <g> that covers the entire visualization.141 */142function useClosestHoverPoint({143 interactionRef,144 data,145 xScale,146 xAccessor,147 yScale,148 yAccessor,149 radius,150}) {151 // capture our hover point or undefined if none152 const [hoverPoint, setHoverPoint] = React.useState(undefined)153154 // we can throttle our updates by using requestAnimationFrame (raf)155 const rafRef = React.useRef(null)156157 React.useEffect(() => {158 const interactionRect = interactionRef.current159 if (interactionRect == null) return160161 const handleMouseMove = (evt) => {162 // here we use d3-selection's pointer. You could also try react-use useMouse.163 const [mouseX, mouseY] = pointer(evt)164165 // if we already had a pending update, cancel it in favour of this one166 if (rafRef.current) {167 cancelAnimationFrame(rafRef.current)168 }169170 rafRef.current = requestAnimationFrame(() => {171 // naive iterate over all points method172 const newHoverPoint = findClosestPoint({173 data,174 xScale,175 xAccessor,176 yScale,177 yAccessor,178 radius,179 pixelX: mouseX,180 pixelY: mouseY,181 })182183 setHoverPoint(newHoverPoint)184 })185 }186 interactionRect.addEventListener('mousemove', handleMouseMove)187188 // make sure we handle when the mouse leaves the interaction area to remove189 // our active hover point190 const handleMouseLeave = () => setHoverPoint(undefined)191 interactionRect.addEventListener('mouseleave', handleMouseLeave)192193 // cleanup our listeners194 return () => {195 interactionRect.removeEventListener('mousemove', handleMouseMove)196 interactionRect.removeEventListener('mouseleave', handleMouseLeave)197 }198 }, [interactionRef, data, xScale, yScale, radius, xAccessor, yAccessor])199200 return hoverPoint201}202203// simple algorithm for finding the nearest point. uses fancy Math.hypot204// to compute distance between a target (pixelX, pixelY) and each point.205// supports a max distance via the radius prop.206function findClosestPoint({207 data,208 xScale,209 yScale,210 xAccessor,211 yAccessor,212 pixelX,213 pixelY,214 radius,215}) {216 let closestPoint217 let minDistance = Infinity218 for (const d of data) {219 const pointPixelX = xScale(xAccessor(d))220 const pointPixelY = yScale(yAccessor(d))221 const distance = Math.hypot(pointPixelX - pixelX, pointPixelY - pixelY)222 if (distance < minDistance && radius != null && distance < radius) {223 closestPoint = d224 minDistance = distance225 }226 }227228 return closestPoint229}230231/** draws our hover marks: a crosshair + point + basic tooltip */232const HoverPoint = ({233 hoverPoint,234 xScale,235 xField,236 yField,237 yScale,238 rScale,239 rField,240 labelField,241 color = 'cyan',242}) => {243 if (!hoverPoint) return null244245 const d = hoverPoint246 const x = xScale(xField.accessor(d))247 const y = yScale(yField.accessor(d))248 const r = rScale?.(rField.accessor(d))249 const darkerColor = darker(color)250251 const [xPixelMin, xPixelMax] = xScale.range()252 const [yPixelMin, yPixelMax] = yScale.range()253254 return (255 <g className="pointer-events-none">256 <g data-testid="xCrosshair">257 <line258 x1={xPixelMin}259 x2={xPixelMax}260 y1={y}261 y2={y}262 stroke="#fff"263 strokeWidth={4}264 />265 <line266 x1={xPixelMin}267 x2={xPixelMax}268 y1={y}269 y2={y}270 stroke={darkerColor}271 strokeWidth={1}272 />273 </g>274 <g data-testid="yCrosshair">275 <line276 y1={yPixelMin}277 y2={yPixelMax}278 x1={x}279 x2={x}280 stroke="#fff"281 strokeWidth={4}282 />283 <line284 y1={yPixelMin}285 y2={yPixelMax}286 x1={x}287 x2={x}288 stroke={darkerColor}289 strokeWidth={1}290 />291 </g>292 <circle cx={x} cy={y} r={r} fill={color} stroke="#fff" strokeWidth={4} />293 <circle294 cx={x}295 cy={y}296 r={r}297 fill={color}298 stroke={darkerColor}299 strokeWidth={2}300 />301 <g transform={`translate(${x + 8} ${y + 4})`}>302 <OutlinedSvgText303 stroke="#fff"304 strokeWidth={5}305 className="text-sm font-bold"306 dy="0.8em"307 >308 {labelField.accessor(d)}309 </OutlinedSvgText>310 <OutlinedSvgText311 stroke="#fff"312 strokeWidth={5}313 className="text-xs"314 dy="0.8em"315 y={16}316 >317 {`${xField.title}: ${xField.formatter(xField.accessor(d))}`}318 </OutlinedSvgText>319 <OutlinedSvgText320 stroke="#fff"321 strokeWidth={5}322 className="text-xs"323 dy="0.8em"324 y={30}325 >326 {`${yField.title}: ${yField.formatter(yField.accessor(d))}`}327 </OutlinedSvgText>328 </g>329 </g>330 )331}332333/**334 * A memoized component that renders all our points, but only re-renders335 * when its props change.336 */337const Points = React.memo(338 ({339 data,340 xScale,341 xAccessor,342 yAccessor,343 yScale,344 rScale,345 rAccessor,346 radius = 8,347 colorScale,348 colorAccessor,349 defaultColor = 'tomato',350 onHover,351 }) => {352 return (353 <g data-testid="Points">354 {data.map((d, i) => {355 // const x = (width * (d.revenue - minRevenue)) / (maxRevenue - minRevenue)356 const x = xScale(xAccessor(d))357 const y = yScale(yAccessor(d))358 const r = rScale?.(rAccessor(d)) ?? radius359 const color = colorScale?.(colorAccessor(d)) ?? defaultColor360 const darkerColor = darker(color)361362 return (363 <circle364 key={d.id ?? i}365 cx={x}366 cy={y}367 r={r}368 fill={color}369 stroke={darkerColor}370 strokeWidth={1}371 strokeOpacity={1}372 fillOpacity={1}373 onClick={() => console.log(d)}374 onMouseEnter={onHover ? () => onHover(d) : null}375 onMouseLeave={onHover ? () => onHover(undefined) : null}376 />377 )378 })}379 </g>380 )381 }382)383384/** dynamically create a darker color */385function darker(color, factor = 0.85) {386 const labColor = lab(color)387 labColor.l *= factor388389 // rgb doesn't correspond to visual perception, but is390 // easy for computers391 // const rgbColor = rgb(color)392 // rgbColor.r *= 0.8393 // rgbColor.g *= 0.8394 // rgbColor.b *= 0.8395396 // rgb(100, 50, 50);397 // rgb(75, 25, 25); // is this half has light perceptually?398 return labColor.toString()399}400401/** fancier way of getting a nice svg text stroke */402const OutlinedSvgText = ({ stroke, strokeWidth, children, ...other }) => {403 return (404 <>405 <text stroke={stroke} strokeWidth={strokeWidth} {...other}>406 {children}407 </text>408 <text {...other}>{children}</text>409 </>410 )411}412413/** determine number of ticks based on space available */414function numTicksForPixels(pixelsAvailable, pixelsPerTick = 70) {415 return Math.floor(Math.abs(pixelsAvailable) / pixelsPerTick)416}417418/** Y-axis with title and grid lines */419const YAxis = ({ yScale, title, formatter, gridLineWidth }) => {420 const [yMin, yMax] = yScale.range()421 const ticks = yScale.ticks(numTicksForPixels(yMax - yMin, 50))422423 return (424 <g data-testid="YAxis">425 <OutlinedSvgText426 stroke="#fff"427 strokeWidth={2.5}428 dx={4}429 dy="0.8em"430 fill="var(--gray-600)"431 className="font-semibold text-2xs"432 >433 {title}434 </OutlinedSvgText>435436 <line x1={0} x2={0} y1={yMin} y2={yMax} stroke="var(--gray-400)" />437 {ticks.map((tick) => {438 const y = yScale(tick)439 return (440 <g key={tick} transform={`translate(0 ${y})`}>441 <text442 dy="0.34em"443 textAnchor="end"444 dx={-12}445 fill="currentColor"446 className="text-gray-400 text-2xs"447 >448 {formatter(tick)}449 </text>450 <line451 x1={0}452 x2={-8}453 stroke="var(--gray-300)"454 data-testid="tickmark"455 />456 {gridLineWidth ? (457 <line458 x1={0}459 x2={gridLineWidth}460 stroke="var(--gray-200)"461 strokeOpacity={0.8}462 data-testid="gridline"463 />464 ) : null}465 </g>466 )467 })}468 </g>469 )470}471472/** X-axis with title and grid lines */473const XAxis = ({ xScale, title, formatter, innerHeight, gridLineHeight }) => {474 const [xMin, xMax] = xScale.range()475 const ticks = xScale.ticks(numTicksForPixels(xMax - xMin))476477 return (478 <g data-testid="XAxis" transform={`translate(0 ${innerHeight})`}>479 <text480 x={xMax}481 textAnchor="end"482 dy={-4}483 fill="var(--gray-600)"484 className="font-semibold text-2xs text-shadow-white-stroke"485 >486 {title}487 </text>488489 <line x1={xMin} x2={xMax} y1={0} y2={0} stroke="var(--gray-400)" />490 {ticks.map((tick) => {491 const x = xScale(tick)492 return (493 <g key={tick} transform={`translate(${x} 0)`}>494 <text495 y={10}496 dy="0.8em"497 textAnchor="middle"498 fill="currentColor"499 className="text-gray-400 text-2xs"500 >501 {formatter(tick)}502 </text>503 <line504 y1={0}505 y2={8}506 stroke="var(--gray-300)"507 data-testid="tickmark"508 />509 {gridLineHeight ? (510 <line511 y1={0}512 y2={-gridLineHeight}513 stroke="var(--gray-200)"514 strokeOpacity={0.8}515 data-testid="gridline"516 />517 ) : null}518 </g>519 )520 })}521 </g>522 )523}524525// fetch our data from CSV and translate to JSON526const useMovieData = () => {527 const [data, setData] = React.useState(undefined)528529 React.useEffect(() => {530 fetch('/datasets/tmdb_1000_movies_small.csv')531 // fetch('/datasets/tmdb_5000_movies.csv')532 .then((response) => response.text())533 .then((csvString) => {534 const data = csvParse(csvString, (row) => {535 return {536 budget: +row.budget,537 vote_average: +row.vote_average,538 vote_count: +row.vote_count,539 genres: JSON.parse(row.genres),540 primary_genre: JSON.parse(row.genres)[0]?.name,541 revenue: +row.revenue,542 original_title: row.original_title,543 }544 })545 .filter((d) => d.revenue > 0)546 .slice(0, 30)547548 console.log('[data]', data)549550 setData(data)551 })552 }, [])553554 return data555}556557// very lazy large number money formatter ($1.5M, $1.65B etc)558const bigMoneyFormat = (value) => {559 if (value == null) return value560 const formatted = format('$~s')(value)561 return formatted.replace(/G$/, 'B')562}563564// metrics (numeric) + dimensions (non-numeric) = fields565const fields = {566 revenue: {567 accessor: (d) => d.revenue,568 title: 'Revenue',569 formatter: bigMoneyFormat,570 },571 budget: {572 accessor: (d) => d.budget,573 title: 'Budget',574 formatter: bigMoneyFormat,575 },576 vote_average: {577 accessor: (d) => d.vote_average,578 title: 'Vote Average out of 10',579 formatter: format('.1f'),580 },581 vote_count: {582 accessor: (d) => d.vote_count,583 title: 'Vote Count',584 formatter: format('.1f'),585 },586 primary_genre: {587 accessor: (d) => d.primary_genre,588 title: 'Primary Genre',589 formatter: (d) => d,590 },591 original_title: {592 accessor: (d) => d.original_title,593 title: 'Original Title',594 formatter: (d) => d,595 },596}597