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.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 { interpolateInferno } from 'd3-scale-chromatic' // v^2.0.0
8import { pointer } from 'd3-selection' // v^2.0.0
9
10const Scatterplot = ({}) => {
11 const data = useMovieData()
12
13 const width = 650
14 const height = 400
15 const margin = { top: 10, right: 100, 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.vote_count
23 const colorField = fields.vote_average
24 const labelField = fields.original_title
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 yExtent = extent(data, yAccessor)
38 const rExtent = extent(data, rAccessor)
39 const colorExtent = extent(data, colorAccessor)
40
41 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)
45
46 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 ])
61
62 // interaction setup
63 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 })
73
74 if (!data) return <div style={{ width, height }} />
75
76 return (
77 <div style={{ width }} className="relative">
78 <svg width={width} height={height}>
79 <g transform={`translate(${margin.left} ${margin.top})`}>
80 <XAxis
81 xScale={xScale}
82 formatter={xFormatter}
83 title={xTitle}
84 innerHeight={innerHeight}
85 gridLineHeight={innerHeight}
86 />
87 <YAxis
88 gridLineWidth={innerWidth}
89 yScale={yScale}
90 formatter={yFormatter}
91 title={yTitle}
92 />
93 <Points
94 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 <HoverPoint
105 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 />
116
117 <rect
118 /* 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}
132
133export default Scatterplot
134
135/**
136 * Custom hook to get the closest point to the mouse based on
137 * iterating through all points. Supports a max distance from
138 * the mouse via the radius prop. You must provide an ref to a
139 * DOM node that can be used to capture the mouse, typically a
140 * <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 none
152 const [hoverPoint, setHoverPoint] = React.useState(undefined)
153
154 // we can throttle our updates by using requestAnimationFrame (raf)
155 const rafRef = React.useRef(null)
156
157 React.useEffect(() => {
158 const interactionRect = interactionRef.current
159 if (interactionRect == null) return
160
161 const handleMouseMove = (evt) => {
162 // here we use d3-selection's pointer. You could also try react-use useMouse.
163 const [mouseX, mouseY] = pointer(evt)
164
165 // if we already had a pending update, cancel it in favour of this one
166 if (rafRef.current) {
167 cancelAnimationFrame(rafRef.current)
168 }
169
170 rafRef.current = requestAnimationFrame(() => {
171 // naive iterate over all points method
172 const newHoverPoint = findClosestPoint({
173 data,
174 xScale,
175 xAccessor,
176 yScale,
177 yAccessor,
178 radius,
179 pixelX: mouseX,
180 pixelY: mouseY,
181 })
182
183 setHoverPoint(newHoverPoint)
184 })
185 }
186 interactionRect.addEventListener('mousemove', handleMouseMove)
187
188 // make sure we handle when the mouse leaves the interaction area to remove
189 // our active hover point
190 const handleMouseLeave = () => setHoverPoint(undefined)
191 interactionRect.addEventListener('mouseleave', handleMouseLeave)
192
193 // cleanup our listeners
194 return () => {
195 interactionRect.removeEventListener('mousemove', handleMouseMove)
196 interactionRect.removeEventListener('mouseleave', handleMouseLeave)
197 }
198 }, [interactionRef, data, xScale, yScale, radius, xAccessor, yAccessor])
199
200 return hoverPoint
201}
202
203// simple algorithm for finding the nearest point. uses fancy Math.hypot
204// 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 closestPoint
217 let minDistance = Infinity
218 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 = d
224 minDistance = distance
225 }
226 }
227
228 return closestPoint
229}
230
231/** 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 null
244
245 const d = hoverPoint
246 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)
250
251 const [xPixelMin, xPixelMax] = xScale.range()
252 const [yPixelMin, yPixelMax] = yScale.range()
253
254 return (
255 <g className="pointer-events-none">
256 <g data-testid="xCrosshair">
257 <line
258 x1={xPixelMin}
259 x2={xPixelMax}
260 y1={y}
261 y2={y}
262 stroke="#fff"
263 strokeWidth={4}
264 />
265 <line
266 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 <line
276 y1={yPixelMin}
277 y2={yPixelMax}
278 x1={x}
279 x2={x}
280 stroke="#fff"
281 strokeWidth={4}
282 />
283 <line
284 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 <circle
294 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 <OutlinedSvgText
303 stroke="#fff"
304 strokeWidth={5}
305 className="text-sm font-bold"
306 dy="0.8em"
307 >
308 {labelField.accessor(d)}
309 </OutlinedSvgText>
310 <OutlinedSvgText
311 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 <OutlinedSvgText
320 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}
332
333/**
334 * A memoized component that renders all our points, but only re-renders
335 * 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)) ?? radius
359 const color = colorScale?.(colorAccessor(d)) ?? defaultColor
360 const darkerColor = darker(color)
361
362 return (
363 <circle
364 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)
383
384/** dynamically create a darker color */
385function darker(color, factor = 0.85) {
386 const labColor = lab(color)
387 labColor.l *= factor
388
389 // rgb doesn't correspond to visual perception, but is
390 // easy for computers
391 // const rgbColor = rgb(color)
392 // rgbColor.r *= 0.8
393 // rgbColor.g *= 0.8
394 // rgbColor.b *= 0.8
395
396 // rgb(100, 50, 50);
397 // rgb(75, 25, 25); // is this half has light perceptually?
398 return labColor.toString()
399}
400
401/** 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}
412
413/** determine number of ticks based on space available */
414function numTicksForPixels(pixelsAvailable, pixelsPerTick = 70) {
415 return Math.floor(Math.abs(pixelsAvailable) / pixelsPerTick)
416}
417
418/** 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))
422
423 return (
424 <g data-testid="YAxis">
425 <OutlinedSvgText
426 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>
435
436 <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 <text
442 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 <line
451 x1={0}
452 x2={-8}
453 stroke="var(--gray-300)"
454 data-testid="tickmark"
455 />
456 {gridLineWidth ? (
457 <line
458 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}
471
472/** 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))
476
477 return (
478 <g data-testid="XAxis" transform={`translate(0 ${innerHeight})`}>
479 <text
480 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>
488
489 <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 <text
495 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 <line
504 y1={0}
505 y2={8}
506 stroke="var(--gray-300)"
507 data-testid="tickmark"
508 />
509 {gridLineHeight ? (
510 <line
511 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}
524
525// fetch our data from CSV and translate to JSON
526const useMovieData = () => {
527 const [data, setData] = React.useState(undefined)
528
529 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)
547
548 console.log('[data]', data)
549
550 setData(data)
551 })
552 }, [])
553
554 return data
555}
556
557// very lazy large number money formatter ($1.5M, $1.65B etc)
558const bigMoneyFormat = (value) => {
559 if (value == null) return value
560 const formatted = format('$~s')(value)
561 return formatted.replace(/G$/, 'B')
562}
563
564// metrics (numeric) + dimensions (non-numeric) = fields
565const 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