Voronoi SVG Hover

Description

In this scatterplot, we're building off of Basic Hover Scatterplot and providing an alternative style to the approach done in Closest Point Hover and Delaunay Hover. Instead of using an interaction rectangle to capture all mouse events, we're going to add polygons on top of our chart that correspond to a Voronoi diagram and use direct DOM events like we do with typical React or SVG. This is accomplished through the d3-delaunay package.

I do not really recommend this approach because it adds more nodes to the DOM and doesn't really provide much benefit besides having the simplicity of onMouseEnter and onMouseLeave. Still, I'm providing it here for completeness. It has a slightly higher upfront cost than the Delaunay triangulation example since it also needs to create a Voronoi diagram and render the polygons on the chart.

While this may sound like a better choice than iterating across all points on hover when working with a large number of points, it may not be. The upfront cost you pay to precompute the Delaunay triangulation and Voronoi diagram 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.

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 { interpolateTurbo } from 'd3-scale-chromatic' // v^2.0.0
8import { Delaunay } from 'd3-delaunay' // v^5.3.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 // const colorDomain = Array.from(new Set(data.map(colorAccessor))).sort()
41
42 // const radius = 4
43 const xScale = scaleLinear().domain(xExtent).range([0, innerWidth])
44 const yScale = scaleLinear().domain(yExtent).range([innerHeight, 0])
45 const rScale = scaleSqrt().domain(rExtent).range([2, 16])
46 const colorScale = scaleSequential(interpolateTurbo).domain(colorExtent)
47 // const colorScale = scaleOrdinal().domain(colorDomain).range(tableau20)
48
49 return {
50 xScale,
51 yScale,
52 rScale,
53 colorScale,
54 }
55 }, [
56 colorAccessor,
57 data,
58 innerHeight,
59 innerWidth,
60 rAccessor,
61 xAccessor,
62 yAccessor,
63 ])
64
65 // interaction setup
66 const [hoverPoint, setHoverPoint] = React.useState(undefined)
67
68 if (!data) return <div style={{ width, height }} />
69
70 return (
71 <div style={{ width }} className="relative">
72 <svg width={width} height={height}>
73 <g transform={`translate(${margin.left} ${margin.top})`}>
74 <XAxis
75 xScale={xScale}
76 formatter={xFormatter}
77 title={xTitle}
78 innerHeight={innerHeight}
79 gridLineHeight={innerHeight}
80 />
81 <YAxis
82 gridLineWidth={innerWidth}
83 yScale={yScale}
84 formatter={yFormatter}
85 title={yTitle}
86 />
87 <Points
88 data={data}
89 xScale={xScale}
90 xAccessor={xAccessor}
91 yScale={yScale}
92 yAccessor={yAccessor}
93 rScale={rScale}
94 rAccessor={rAccessor}
95 colorScale={colorScale}
96 colorAccessor={colorAccessor}
97 />
98 <VoronoiDiagram
99 data={data}
100 xScale={xScale}
101 xAccessor={xAccessor}
102 yScale={yScale}
103 yAccessor={yAccessor}
104 onHover={setHoverPoint}
105 />
106 <HoverPoint
107 labelField={labelField}
108 xScale={xScale}
109 xField={xField}
110 yScale={yScale}
111 yField={yField}
112 rScale={rScale}
113 rField={rField}
114 colorScale={colorScale}
115 colorField={colorField}
116 hoverPoint={hoverPoint}
117 />
118 </g>
119 </svg>
120 </div>
121 )
122}
123
124export default Scatterplot
125
126/**
127 * Draw a voronoi diagram on top of our chart and use it to capture
128 * mouse events. Note we do not need to make it visible, just set
129 * fillOpacity to 0 and turn off the stroke. I'm leaving it visible
130 * for educational purposes.
131 */
132const VoronoiDiagram = React.memo(
133 ({ data, xScale, xAccessor, yScale, yAccessor, onHover }) => {
134 // precompute the Delaunay triangulation and voronoi poylgons
135 const voronoiPolygons = React.useMemo(() => {
136 if (data == null) return null
137
138 // map our data to pixel positions
139 const points = data.map((d) => [
140 xScale(xAccessor(d)),
141 yScale(yAccessor(d)),
142 ])
143
144 // create a Delaunay triangulation from our pixel positions
145 const delaunay = Delaunay.from(points)
146
147 // find the bounds of the voronoi (optional)
148 const [xMin, xMax] = xScale.range()
149 const [yMax, yMin] = yScale.range() // yScale is flipped
150
151 // create a voronoi diagram from the Delaunay triangulation
152 const voronoi = delaunay.voronoi([xMin, yMin, xMax, yMax])
153
154 // get the polygons as an array for simple react-style mapping over
155 return Array.from(voronoi.cellPolygons())
156 }, [data, xScale, xAccessor, yScale, yAccessor])
157
158 if (voronoiPolygons == null) return null
159
160 return (
161 <g data-testid="VoronoiDiagram">
162 {voronoiPolygons.map((points) => {
163 // polygons pointsString is of the form "x1,y1 x2,y2 x3,y3"
164 const pointsString = points.map((point) => point.join(',')).join(' ')
165
166 return (
167 <polygon
168 key={points.index}
169 points={pointsString}
170 stroke="tomato"
171 fill="currentColor"
172 fillOpacity={0.2}
173 // use CSS to indicate hover state (via tailwind)
174 className="text-indigo-400 opacity-50 hover:opacity-100"
175 // use normal DOM events for hover
176 onMouseEnter={() => onHover?.(data[points.index])}
177 onMouseLeave={() => onHover?.(undefined)}
178 />
179 )
180 })}
181 </g>
182 )
183 }
184)
185
186/** draws our hover marks: a crosshair + point + basic tooltip */
187const HoverPoint = ({
188 hoverPoint,
189 xScale,
190 xField,
191 yField,
192 yScale,
193 rScale,
194 rField,
195 labelField,
196 color = 'cyan',
197}) => {
198 if (!hoverPoint) return null
199
200 const d = hoverPoint
201 const x = xScale(xField.accessor(d))
202 const y = yScale(yField.accessor(d))
203 const r = rScale?.(rField.accessor(d))
204 const darkerColor = darker(color)
205
206 const [xPixelMin, xPixelMax] = xScale.range()
207 const [yPixelMin, yPixelMax] = yScale.range()
208
209 return (
210 <g className="pointer-events-none">
211 <g data-testid="xCrosshair">
212 <line
213 x1={xPixelMin}
214 x2={xPixelMax}
215 y1={y}
216 y2={y}
217 stroke="#fff"
218 strokeWidth={4}
219 />
220 <line
221 x1={xPixelMin}
222 x2={xPixelMax}
223 y1={y}
224 y2={y}
225 stroke={darkerColor}
226 strokeWidth={1}
227 />
228 </g>
229 <g data-testid="yCrosshair">
230 <line
231 y1={yPixelMin}
232 y2={yPixelMax}
233 x1={x}
234 x2={x}
235 stroke="#fff"
236 strokeWidth={4}
237 />
238 <line
239 y1={yPixelMin}
240 y2={yPixelMax}
241 x1={x}
242 x2={x}
243 stroke={darkerColor}
244 strokeWidth={1}
245 />
246 </g>
247 <circle cx={x} cy={y} r={r} fill={color} stroke="#fff" strokeWidth={4} />
248 <circle
249 cx={x}
250 cy={y}
251 r={r}
252 fill={color}
253 stroke={darkerColor}
254 strokeWidth={2}
255 />
256 <g transform={`translate(${x + 8} ${y + 4})`}>
257 <OutlinedSvgText
258 stroke="#fff"
259 strokeWidth={5}
260 className="text-sm font-bold"
261 dy="0.8em"
262 >
263 {labelField.accessor(d)}
264 </OutlinedSvgText>
265 <OutlinedSvgText
266 stroke="#fff"
267 strokeWidth={5}
268 className="text-xs"
269 dy="0.8em"
270 y={16}
271 >
272 {`${xField.title}: ${xField.formatter(xField.accessor(d))}`}
273 </OutlinedSvgText>
274 <OutlinedSvgText
275 stroke="#fff"
276 strokeWidth={5}
277 className="text-xs"
278 dy="0.8em"
279 y={30}
280 >
281 {`${yField.title}: ${yField.formatter(yField.accessor(d))}`}
282 </OutlinedSvgText>
283 </g>
284 </g>
285 )
286}
287
288/**
289 * A memoized component that renders all our points, but only re-renders
290 * when its props change.
291 */
292const Points = React.memo(
293 ({
294 data,
295 xScale,
296 xAccessor,
297 yAccessor,
298 yScale,
299 rScale,
300 rAccessor,
301 radius = 8,
302 colorScale,
303 colorAccessor,
304 defaultColor = 'tomato',
305 onHover,
306 }) => {
307 return (
308 <g data-testid="Points">
309 {data.map((d, i) => {
310 // const x = (width * (d.revenue - minRevenue)) / (maxRevenue - minRevenue)
311 const x = xScale(xAccessor(d))
312 const y = yScale(yAccessor(d))
313 const r = rScale?.(rAccessor(d)) ?? radius
314 const color = colorScale?.(colorAccessor(d)) ?? defaultColor
315 const darkerColor = darker(color)
316
317 return (
318 <circle
319 // key={d.id ?? i}
320 cx={x}
321 cy={y}
322 r={r}
323 fill={color}
324 stroke={darkerColor}
325 strokeWidth={1}
326 strokeOpacity={1}
327 fillOpacity={1}
328 onClick={() => console.log(d)}
329 onMouseEnter={onHover ? () => onHover(d) : null}
330 onMouseLeave={onHover ? () => onHover(undefined) : null}
331 />
332 )
333 })}
334 </g>
335 )
336 }
337)
338
339/** dynamically create a darker color */
340function darker(color, factor = 0.85) {
341 const labColor = lab(color)
342 labColor.l *= factor
343
344 // rgb doesn't correspond to visual perception, but is
345 // easy for computers
346 // const rgbColor = rgb(color)
347 // rgbColor.r *= 0.8
348 // rgbColor.g *= 0.8
349 // rgbColor.b *= 0.8
350
351 // rgb(100, 50, 50);
352 // rgb(75, 25, 25); // is this half has light perceptually?
353 return labColor.toString()
354}
355
356/** fancier way of getting a nice svg text stroke */
357const OutlinedSvgText = ({ stroke, strokeWidth, children, ...other }) => {
358 return (
359 <>
360 <text stroke={stroke} strokeWidth={strokeWidth} {...other}>
361 {children}
362 </text>
363 <text {...other}>{children}</text>
364 </>
365 )
366}
367
368/** determine number of ticks based on space available */
369function numTicksForPixels(pixelsAvailable, pixelsPerTick = 70) {
370 return Math.floor(Math.abs(pixelsAvailable) / pixelsPerTick)
371}
372
373/** Y-axis with title and grid lines */
374const YAxis = ({ yScale, title, formatter, gridLineWidth }) => {
375 const [yMin, yMax] = yScale.range()
376 const ticks = yScale.ticks(numTicksForPixels(yMax - yMin, 50))
377
378 return (
379 <g data-testid="YAxis">
380 <OutlinedSvgText
381 stroke="#fff"
382 strokeWidth={2.5}
383 dx={4}
384 dy="0.8em"
385 fill="var(--gray-600)"
386 className="font-semibold text-2xs"
387 >
388 {title}
389 </OutlinedSvgText>
390
391 <line x1={0} x2={0} y1={yMin} y2={yMax} stroke="var(--gray-400)" />
392 {ticks.map((tick) => {
393 const y = yScale(tick)
394 return (
395 <g key={tick} transform={`translate(0 ${y})`}>
396 <text
397 dy="0.34em"
398 textAnchor="end"
399 dx={-12}
400 fill="currentColor"
401 className="text-gray-400 text-2xs"
402 >
403 {formatter(tick)}
404 </text>
405 <line
406 x1={0}
407 x2={-8}
408 stroke="var(--gray-300)"
409 data-testid="tickmark"
410 />
411 {gridLineWidth ? (
412 <line
413 x1={0}
414 x2={gridLineWidth}
415 stroke="var(--gray-200)"
416 strokeOpacity={0.8}
417 data-testid="gridline"
418 />
419 ) : null}
420 </g>
421 )
422 })}
423 </g>
424 )
425}
426
427/** X-axis with title and grid lines */
428const XAxis = ({ xScale, title, formatter, innerHeight, gridLineHeight }) => {
429 const [xMin, xMax] = xScale.range()
430 const ticks = xScale.ticks(numTicksForPixels(xMax - xMin))
431
432 return (
433 <g data-testid="XAxis" transform={`translate(0 ${innerHeight})`}>
434 <text
435 x={xMax}
436 textAnchor="end"
437 dy={-4}
438 fill="var(--gray-600)"
439 className="font-semibold text-2xs text-shadow-white-stroke"
440 >
441 {title}
442 </text>
443
444 <line x1={xMin} x2={xMax} y1={0} y2={0} stroke="var(--gray-400)" />
445 {ticks.map((tick) => {
446 const x = xScale(tick)
447 return (
448 <g key={tick} transform={`translate(${x} 0)`}>
449 <text
450 y={10}
451 dy="0.8em"
452 textAnchor="middle"
453 fill="currentColor"
454 className="text-gray-400 text-2xs"
455 >
456 {formatter(tick)}
457 </text>
458 <line
459 y1={0}
460 y2={8}
461 stroke="var(--gray-300)"
462 data-testid="tickmark"
463 />
464 {gridLineHeight ? (
465 <line
466 y1={0}
467 y2={-gridLineHeight}
468 stroke="var(--gray-200)"
469 strokeOpacity={0.8}
470 data-testid="gridline"
471 />
472 ) : null}
473 </g>
474 )
475 })}
476 </g>
477 )
478}
479
480// fetch our data from CSV and translate to JSON
481const useMovieData = () => {
482 const [data, setData] = React.useState(undefined)
483
484 React.useEffect(() => {
485 fetch('/datasets/tmdb_1000_movies_small.csv')
486 // fetch('/datasets/tmdb_5000_movies.csv')
487 .then((response) => response.text())
488 .then((csvString) => {
489 const data = csvParse(csvString, (row) => {
490 return {
491 budget: +row.budget,
492 vote_average: +row.vote_average,
493 vote_count: +row.vote_count,
494 genres: JSON.parse(row.genres),
495 primary_genre: JSON.parse(row.genres)[0]?.name,
496 revenue: +row.revenue,
497 original_title: row.original_title,
498 }
499 })
500 .filter((d) => d.revenue > 0)
501 .slice(0, 30)
502
503 console.log('[data]', data)
504
505 setData(data)
506 })
507 }, [])
508
509 return data
510}
511
512// very lazy large number money formatter ($1.5M, $1.65B etc)
513const bigMoneyFormat = (value) => {
514 if (value == null) return value
515 const formatted = format('$~s')(value)
516 return formatted.replace(/G$/, 'B')
517}
518
519// metrics (numeric) + dimensions (non-numeric) = fields
520const fields = {
521 revenue: {
522 accessor: (d) => d.revenue,
523 title: 'Revenue',
524 formatter: bigMoneyFormat,
525 },
526 budget: {
527 accessor: (d) => d.budget,
528 title: 'Budget',
529 formatter: bigMoneyFormat,
530 },
531 vote_average: {
532 accessor: (d) => d.vote_average,
533 title: 'Vote Average out of 10',
534 formatter: format('.1f'),
535 },
536 vote_count: {
537 accessor: (d) => d.vote_count,
538 title: 'Vote Count',
539 formatter: format('.1f'),
540 },
541 primary_genre: {
542 accessor: (d) => d.primary_genre,
543 title: 'Primary Genre',
544 formatter: (d) => d,
545 },
546 original_title: {
547 accessor: (d) => d.original_title,
548 title: 'Original Title',
549 formatter: (d) => d,
550 },
551}
552