Delaunay Hover

Description

In this scatterplot, we're building off of Basic Hover Scatterplot and mirroring the approach done in Closest Point Hover. The difference here is that instead of doing a brute-force search-all-points method to find our nearest point, we are going to precompute a Delaunay triangulation via the d3-delaunay package. The Delaunay triangulation enables a faster lookup time (i.e. finding the point when the mouse moves), but requires an upfront cost to create the triangulation.

While this may sound like a better choice when working with a large number of points, it may not be. The upfront cost you pay to precompute the Delaunay triangulation may never pay off. It's best to test your use-cases directly and only do this optimization if it's actually worth it for you. See this twitter discussion and post by Adam Pearce for details.

Note that 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).

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, rgb } from 'd3-color' // v^2.0.0
6import { scaleLinear, scaleSequential, scaleSqrt } from 'd3-scale' // v^3.2.4
7import { interpolateViridis } from 'd3-scale-chromatic' // v^2.0.0
8import { pointer } from 'd3-selection' // v^2.0.0
9import { Delaunay } from 'd3-delaunay' // v^5.3.0
10
11const Scatterplot = ({}) => {
12 const data = useMovieData()
13
14 const width = 650
15 const height = 400
16 const margin = { top: 10, right: 100, bottom: 30, left: 50 }
17 const innerWidth = width - margin.left - margin.right
18 const innerHeight = height - margin.top - margin.bottom
19
20 // read from pre-defined metric/dimension ("fields") bundles
21 const xField = fields.revenue
22 const yField = fields.vote_average
23 const rField = fields.vote_count
24 const colorField = fields.vote_average
25 const labelField = fields.original_title
26
27 // optionally pull out values into local variables
28 const { accessor: xAccessor, title: xTitle, formatter: xFormatter } = xField
29 const { accessor: yAccessor, title: yTitle, formatter: yFormatter } = yField
30 const { accessor: rAccessor } = rField
31 const { accessor: colorAccessor } = colorField
32
33 // memoize creating our scales so we can optimize re-renders with React.memo
34 // (e.g. <Points> only re-renders when its props change)
35 const { xScale, yScale, rScale, colorScale } = React.useMemo(() => {
36 if (!data) return {}
37 const xExtent = extent(data, xAccessor)
38 const yExtent = extent(data, yAccessor)
39 const rExtent = extent(data, rAccessor)
40 const colorExtent = extent(data, colorAccessor)
41 // const colorDomain = Array.from(new Set(data.map(colorAccessor))).sort()
42
43 // const radius = 4
44 const xScale = scaleLinear().domain(xExtent).range([0, innerWidth])
45 const yScale = scaleLinear().domain(yExtent).range([innerHeight, 0])
46 const rScale = scaleSqrt().domain(rExtent).range([2, 16])
47 const colorScale = scaleSequential(interpolateViridis).domain(colorExtent)
48 // const colorScale = scaleOrdinal().domain(colorDomain).range(tableau20)
49
50 return {
51 xScale,
52 yScale,
53 rScale,
54 colorScale,
55 }
56 }, [
57 colorAccessor,
58 data,
59 innerHeight,
60 innerWidth,
61 rAccessor,
62 xAccessor,
63 yAccessor,
64 ])
65
66 // interaction setup
67 const interactionRef = React.useRef(null)
68 const hoverPoint = useClosestHoverPointDelaunay({
69 interactionRef,
70 data,
71 xScale,
72 xAccessor,
73 yScale,
74 yAccessor,
75 radius: 60,
76 })
77
78 if (!data) return <div style={{ width, height }} />
79
80 return (
81 <div style={{ width }} className="relative">
82 <svg width={width} height={height}>
83 <g transform={`translate(${margin.left} ${margin.top})`}>
84 <XAxis
85 xScale={xScale}
86 formatter={xFormatter}
87 title={xTitle}
88 innerHeight={innerHeight}
89 gridLineHeight={innerHeight}
90 />
91 <YAxis
92 gridLineWidth={innerWidth}
93 yScale={yScale}
94 formatter={yFormatter}
95 title={yTitle}
96 />
97 <Points
98 data={data}
99 xScale={xScale}
100 xAccessor={xAccessor}
101 yScale={yScale}
102 yAccessor={yAccessor}
103 rScale={rScale}
104 rAccessor={rAccessor}
105 colorScale={colorScale}
106 colorAccessor={colorAccessor}
107 />
108 <HoverPoint
109 labelField={labelField}
110 xScale={xScale}
111 xField={xField}
112 yScale={yScale}
113 yField={yField}
114 rScale={rScale}
115 rField={rField}
116 colorScale={colorScale}
117 colorField={colorField}
118 hoverPoint={hoverPoint}
119 />
120
121 <rect
122 /* this node absorbs all mouse events */
123 ref={interactionRef}
124 width={innerWidth}
125 height={innerHeight}
126 x={0}
127 y={0}
128 fill="tomato"
129 fillOpacity={0}
130 />
131 </g>
132 </svg>
133 </div>
134 )
135}
136
137export default Scatterplot
138
139/**
140 * Custom hook to get the closest point to the mouse based on
141 * a Delaunay triangulation. Supports a max distance from
142 * the mouse via the radius prop. You must provide an ref to a
143 * DOM node that can be used to capture the mouse, typically a
144 * <rect> or <g> that covers the entire visualization.
145 */
146function useClosestHoverPointDelaunay({
147 interactionRef,
148 data,
149 xScale,
150 xAccessor,
151 yScale,
152 yAccessor,
153 radius,
154}) {
155 // capture our hover point or undefined if none
156 const [hoverPoint, setHoverPoint] = React.useState(undefined)
157
158 // we can throttle our updates by using requestAnimationFrame (raf)
159 const rafRef = React.useRef(null)
160
161 // precompute the Delaunay triangulation
162 const delaunay = React.useMemo(() => {
163 if (data == null) return null
164 const points = data.map((d) => [xScale(xAccessor(d)), yScale(yAccessor(d))])
165 const delaunay = Delaunay.from(points)
166 return delaunay
167 }, [data, xScale, xAccessor, yScale, yAccessor])
168
169 React.useEffect(() => {
170 const interactionRect = interactionRef.current
171 if (interactionRect == null) return
172
173 const handleMouseMove = (evt) => {
174 // here we use d3-selection's pointer. You could also try react-use useMouse.
175 const [mouseX, mouseY] = pointer(evt)
176
177 // if we already had a pending update, cancel it in favour of this one
178 if (rafRef.current) {
179 cancelAnimationFrame(rafRef.current)
180 }
181
182 rafRef.current = requestAnimationFrame(() => {
183 // find closest point via handy Delaunay triangulation
184 const newHoverPointIndex = delaunay.find(mouseX, mouseY)
185 let newHoverPoint = data[newHoverPointIndex]
186
187 // enforce our radius constraint
188 if (
189 radius != null &&
190 Math.hypot(
191 xScale(xAccessor(newHoverPoint)) - mouseX,
192 yScale(yAccessor(newHoverPoint)) - mouseY
193 ) > radius
194 ) {
195 newHoverPoint = undefined
196 }
197
198 setHoverPoint(newHoverPoint)
199 })
200 }
201 interactionRect.addEventListener('mousemove', handleMouseMove)
202
203 // make sure we handle when the mouse leaves the interaction area to remove
204 // our active hover point
205 const handleMouseLeave = () => setHoverPoint(undefined)
206 interactionRect.addEventListener('mouseleave', handleMouseLeave)
207
208 // cleanup our listeners
209 return () => {
210 interactionRect.removeEventListener('mousemove', handleMouseMove)
211 interactionRect.removeEventListener('mouseleave', handleMouseLeave)
212 }
213 }, [
214 interactionRef,
215 data,
216 xScale,
217 delaunay,
218 yScale,
219 radius,
220 xAccessor,
221 yAccessor,
222 ])
223
224 return hoverPoint
225}
226
227/** draws our hover marks: a crosshair + point + basic tooltip */
228const HoverPoint = ({
229 hoverPoint,
230 xScale,
231 xField,
232 yField,
233 yScale,
234 rScale,
235 rField,
236 labelField,
237 color = 'cyan',
238}) => {
239 if (!hoverPoint) return null
240
241 const d = hoverPoint
242 const x = xScale(xField.accessor(d))
243 const y = yScale(yField.accessor(d))
244 const r = rScale?.(rField.accessor(d))
245 const darkerColor = darker(color)
246
247 const [xPixelMin, xPixelMax] = xScale.range()
248 const [yPixelMin, yPixelMax] = yScale.range()
249
250 return (
251 <g className="pointer-events-none">
252 <g data-testid="xCrosshair">
253 <line
254 x1={xPixelMin}
255 x2={xPixelMax}
256 y1={y}
257 y2={y}
258 stroke="#fff"
259 strokeWidth={4}
260 />
261 <line
262 x1={xPixelMin}
263 x2={xPixelMax}
264 y1={y}
265 y2={y}
266 stroke={darkerColor}
267 strokeWidth={1}
268 />
269 </g>
270 <g data-testid="yCrosshair">
271 <line
272 y1={yPixelMin}
273 y2={yPixelMax}
274 x1={x}
275 x2={x}
276 stroke="#fff"
277 strokeWidth={4}
278 />
279 <line
280 y1={yPixelMin}
281 y2={yPixelMax}
282 x1={x}
283 x2={x}
284 stroke={darkerColor}
285 strokeWidth={1}
286 />
287 </g>
288 <circle cx={x} cy={y} r={r} fill={color} stroke="#fff" strokeWidth={4} />
289 <circle
290 cx={x}
291 cy={y}
292 r={r}
293 fill={color}
294 stroke={darkerColor}
295 strokeWidth={2}
296 />
297 <g transform={`translate(${x + 8} ${y + 4})`}>
298 <OutlinedSvgText
299 stroke="#fff"
300 strokeWidth={5}
301 className="text-sm font-bold"
302 dy="0.8em"
303 >
304 {labelField.accessor(d)}
305 </OutlinedSvgText>
306 <OutlinedSvgText
307 stroke="#fff"
308 strokeWidth={5}
309 className="text-xs"
310 dy="0.8em"
311 y={16}
312 >
313 {`${xField.title}: ${xField.formatter(xField.accessor(d))}`}
314 </OutlinedSvgText>
315 <OutlinedSvgText
316 stroke="#fff"
317 strokeWidth={5}
318 className="text-xs"
319 dy="0.8em"
320 y={30}
321 >
322 {`${yField.title}: ${yField.formatter(yField.accessor(d))}`}
323 </OutlinedSvgText>
324 </g>
325 </g>
326 )
327}
328
329/**
330 * A memoized component that renders all our points, but only re-renders
331 * when its props change.
332 */
333const Points = React.memo(
334 ({
335 data,
336 xScale,
337 xAccessor,
338 yAccessor,
339 yScale,
340 rScale,
341 rAccessor,
342 radius = 8,
343 colorScale,
344 colorAccessor,
345 defaultColor = 'tomato',
346 onHover,
347 }) => {
348 return (
349 <g data-testid="Points">
350 {data.map((d, i) => {
351 // const x = (width * (d.revenue - minRevenue)) / (maxRevenue - minRevenue)
352 const x = xScale(xAccessor(d))
353 const y = yScale(yAccessor(d))
354 const r = rScale?.(rAccessor(d)) ?? radius
355 const color = colorScale?.(colorAccessor(d)) ?? defaultColor
356 const darkerColor = darker(color)
357
358 return (
359 <circle
360 key={d.id ?? i}
361 cx={x}
362 cy={y}
363 r={r}
364 fill={color}
365 stroke={darkerColor}
366 strokeWidth={1}
367 strokeOpacity={1}
368 fillOpacity={1}
369 onClick={() => console.log(d)}
370 onMouseEnter={onHover ? () => onHover(d) : null}
371 onMouseLeave={onHover ? () => onHover(undefined) : null}
372 />
373 )
374 })}
375 </g>
376 )
377 }
378)
379
380/** dynamically create a darker color */
381function darker(color, factor = 0.85) {
382 const labColor = lab(color)
383 labColor.l *= factor
384
385 // rgb doesn't correspond to visual perception, but is
386 // easy for computers
387 // const rgbColor = rgb(color)
388 // rgbColor.r *= 0.8
389 // rgbColor.g *= 0.8
390 // rgbColor.b *= 0.8
391
392 // rgb(100, 50, 50);
393 // rgb(75, 25, 25); // is this half has light perceptually?
394 return labColor.toString()
395}
396
397/** fancier way of getting a nice svg text stroke */
398const OutlinedSvgText = ({ stroke, strokeWidth, children, ...other }) => {
399 return (
400 <>
401 <text stroke={stroke} strokeWidth={strokeWidth} {...other}>
402 {children}
403 </text>
404 <text {...other}>{children}</text>
405 </>
406 )
407}
408
409/** determine number of ticks based on space available */
410function numTicksForPixels(pixelsAvailable, pixelsPerTick = 70) {
411 return Math.floor(Math.abs(pixelsAvailable) / pixelsPerTick)
412}
413
414/** Y-axis with title and grid lines */
415const YAxis = ({ yScale, title, formatter, gridLineWidth }) => {
416 const [yMin, yMax] = yScale.range()
417 const ticks = yScale.ticks(numTicksForPixels(yMax - yMin, 50))
418
419 return (
420 <g data-testid="YAxis">
421 <OutlinedSvgText
422 stroke="#fff"
423 strokeWidth={2.5}
424 dx={4}
425 dy="0.8em"
426 fill="var(--gray-600)"
427 className="font-semibold text-2xs"
428 >
429 {title}
430 </OutlinedSvgText>
431
432 <line x1={0} x2={0} y1={yMin} y2={yMax} stroke="var(--gray-400)" />
433 {ticks.map((tick) => {
434 const y = yScale(tick)
435 return (
436 <g key={tick} transform={`translate(0 ${y})`}>
437 <text
438 dy="0.34em"
439 textAnchor="end"
440 dx={-12}
441 fill="currentColor"
442 className="text-gray-400 text-2xs"
443 >
444 {formatter(tick)}
445 </text>
446 <line
447 x1={0}
448 x2={-8}
449 stroke="var(--gray-300)"
450 data-testid="tickmark"
451 />
452 {gridLineWidth ? (
453 <line
454 x1={0}
455 x2={gridLineWidth}
456 stroke="var(--gray-200)"
457 strokeOpacity={0.8}
458 data-testid="gridline"
459 />
460 ) : null}
461 </g>
462 )
463 })}
464 </g>
465 )
466}
467
468/** X-axis with title and grid lines */
469const XAxis = ({ xScale, title, formatter, innerHeight, gridLineHeight }) => {
470 const [xMin, xMax] = xScale.range()
471 const ticks = xScale.ticks(numTicksForPixels(xMax - xMin))
472
473 return (
474 <g data-testid="XAxis" transform={`translate(0 ${innerHeight})`}>
475 <text
476 x={xMax}
477 textAnchor="end"
478 dy={-4}
479 fill="var(--gray-600)"
480 className="font-semibold text-2xs text-shadow-white-stroke"
481 >
482 {title}
483 </text>
484
485 <line x1={xMin} x2={xMax} y1={0} y2={0} stroke="var(--gray-400)" />
486 {ticks.map((tick) => {
487 const x = xScale(tick)
488 return (
489 <g key={tick} transform={`translate(${x} 0)`}>
490 <text
491 y={10}
492 dy="0.8em"
493 textAnchor="middle"
494 fill="currentColor"
495 className="text-gray-400 text-2xs"
496 >
497 {formatter(tick)}
498 </text>
499 <line
500 y1={0}
501 y2={8}
502 stroke="var(--gray-300)"
503 data-testid="tickmark"
504 />
505 {gridLineHeight ? (
506 <line
507 y1={0}
508 y2={-gridLineHeight}
509 stroke="var(--gray-200)"
510 strokeOpacity={0.8}
511 data-testid="gridline"
512 />
513 ) : null}
514 </g>
515 )
516 })}
517 </g>
518 )
519}
520
521// fetch our data from CSV and translate to JSON
522const useMovieData = () => {
523 const [data, setData] = React.useState(undefined)
524
525 React.useEffect(() => {
526 fetch('/datasets/tmdb_1000_movies_small.csv')
527 // fetch('/datasets/tmdb_5000_movies.csv')
528 .then((response) => response.text())
529 .then((csvString) => {
530 const data = csvParse(csvString, (row) => {
531 return {
532 budget: +row.budget,
533 vote_average: +row.vote_average,
534 vote_count: +row.vote_count,
535 genres: JSON.parse(row.genres),
536 primary_genre: JSON.parse(row.genres)[0]?.name,
537 revenue: +row.revenue,
538 original_title: row.original_title,
539 }
540 })
541 .filter((d) => d.revenue > 0)
542 .slice(0, 30)
543
544 console.log('[data]', data)
545
546 setData(data)
547 })
548 }, [])
549
550 return data
551}
552
553// very lazy large number money formatter ($1.5M, $1.65B etc)
554const bigMoneyFormat = (value) => {
555 if (value == null) return value
556 const formatted = format('$~s')(value)
557 return formatted.replace(/G$/, 'B')
558}
559
560// metrics (numeric) + dimensions (non-numeric) = fields
561const fields = {
562 revenue: {
563 accessor: (d) => d.revenue,
564 title: 'Revenue',
565 formatter: bigMoneyFormat,
566 },
567 budget: {
568 accessor: (d) => d.budget,
569 title: 'Budget',
570 formatter: bigMoneyFormat,
571 },
572 vote_average: {
573 accessor: (d) => d.vote_average,
574 title: 'Vote Average out of 10',
575 formatter: format('.1f'),
576 },
577 vote_count: {
578 accessor: (d) => d.vote_count,
579 title: 'Vote Count',
580 formatter: format('.1f'),
581 },
582 primary_genre: {
583 accessor: (d) => d.primary_genre,
584 title: 'Primary Genre',
585 formatter: (d) => d,
586 },
587 original_title: {
588 accessor: (d) => d.original_title,
589 title: 'Original Title',
590 formatter: (d) => d,
591 },
592}
593