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.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, rgb } from 'd3-color' // v^2.0.06import { scaleLinear, scaleSequential, scaleSqrt } from 'd3-scale' // v^3.2.47import { interpolateTurbo } from 'd3-scale-chromatic' // v^2.0.08import { Delaunay } from 'd3-delaunay' // v^5.3.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)40 // const colorDomain = Array.from(new Set(data.map(colorAccessor))).sort()4142 // const radius = 443 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)4849 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 ])6465 // interaction setup66 const [hoverPoint, setHoverPoint] = React.useState(undefined)6768 if (!data) return <div style={{ width, height }} />6970 return (71 <div style={{ width }} className="relative">72 <svg width={width} height={height}>73 <g transform={`translate(${margin.left} ${margin.top})`}>74 <XAxis75 xScale={xScale}76 formatter={xFormatter}77 title={xTitle}78 innerHeight={innerHeight}79 gridLineHeight={innerHeight}80 />81 <YAxis82 gridLineWidth={innerWidth}83 yScale={yScale}84 formatter={yFormatter}85 title={yTitle}86 />87 <Points88 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 <VoronoiDiagram99 data={data}100 xScale={xScale}101 xAccessor={xAccessor}102 yScale={yScale}103 yAccessor={yAccessor}104 onHover={setHoverPoint}105 />106 <HoverPoint107 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}123124export default Scatterplot125126/**127 * Draw a voronoi diagram on top of our chart and use it to capture128 * mouse events. Note we do not need to make it visible, just set129 * fillOpacity to 0 and turn off the stroke. I'm leaving it visible130 * for educational purposes.131 */132const VoronoiDiagram = React.memo(133 ({ data, xScale, xAccessor, yScale, yAccessor, onHover }) => {134 // precompute the Delaunay triangulation and voronoi poylgons135 const voronoiPolygons = React.useMemo(() => {136 if (data == null) return null137138 // map our data to pixel positions139 const points = data.map((d) => [140 xScale(xAccessor(d)),141 yScale(yAccessor(d)),142 ])143144 // create a Delaunay triangulation from our pixel positions145 const delaunay = Delaunay.from(points)146147 // find the bounds of the voronoi (optional)148 const [xMin, xMax] = xScale.range()149 const [yMax, yMin] = yScale.range() // yScale is flipped150151 // create a voronoi diagram from the Delaunay triangulation152 const voronoi = delaunay.voronoi([xMin, yMin, xMax, yMax])153154 // get the polygons as an array for simple react-style mapping over155 return Array.from(voronoi.cellPolygons())156 }, [data, xScale, xAccessor, yScale, yAccessor])157158 if (voronoiPolygons == null) return null159160 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(' ')165166 return (167 <polygon168 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 hover176 onMouseEnter={() => onHover?.(data[points.index])}177 onMouseLeave={() => onHover?.(undefined)}178 />179 )180 })}181 </g>182 )183 }184)185186/** 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 null199200 const d = hoverPoint201 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)205206 const [xPixelMin, xPixelMax] = xScale.range()207 const [yPixelMin, yPixelMax] = yScale.range()208209 return (210 <g className="pointer-events-none">211 <g data-testid="xCrosshair">212 <line213 x1={xPixelMin}214 x2={xPixelMax}215 y1={y}216 y2={y}217 stroke="#fff"218 strokeWidth={4}219 />220 <line221 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 <line231 y1={yPixelMin}232 y2={yPixelMax}233 x1={x}234 x2={x}235 stroke="#fff"236 strokeWidth={4}237 />238 <line239 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 <circle249 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 <OutlinedSvgText258 stroke="#fff"259 strokeWidth={5}260 className="text-sm font-bold"261 dy="0.8em"262 >263 {labelField.accessor(d)}264 </OutlinedSvgText>265 <OutlinedSvgText266 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 <OutlinedSvgText275 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}287288/**289 * A memoized component that renders all our points, but only re-renders290 * 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)) ?? radius314 const color = colorScale?.(colorAccessor(d)) ?? defaultColor315 const darkerColor = darker(color)316317 return (318 <circle319 // 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)338339/** dynamically create a darker color */340function darker(color, factor = 0.85) {341 const labColor = lab(color)342 labColor.l *= factor343344 // rgb doesn't correspond to visual perception, but is345 // easy for computers346 // const rgbColor = rgb(color)347 // rgbColor.r *= 0.8348 // rgbColor.g *= 0.8349 // rgbColor.b *= 0.8350351 // rgb(100, 50, 50);352 // rgb(75, 25, 25); // is this half has light perceptually?353 return labColor.toString()354}355356/** 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}367368/** determine number of ticks based on space available */369function numTicksForPixels(pixelsAvailable, pixelsPerTick = 70) {370 return Math.floor(Math.abs(pixelsAvailable) / pixelsPerTick)371}372373/** 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))377378 return (379 <g data-testid="YAxis">380 <OutlinedSvgText381 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>390391 <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 <text397 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 <line406 x1={0}407 x2={-8}408 stroke="var(--gray-300)"409 data-testid="tickmark"410 />411 {gridLineWidth ? (412 <line413 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}426427/** 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))431432 return (433 <g data-testid="XAxis" transform={`translate(0 ${innerHeight})`}>434 <text435 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>443444 <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 <text450 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 <line459 y1={0}460 y2={8}461 stroke="var(--gray-300)"462 data-testid="tickmark"463 />464 {gridLineHeight ? (465 <line466 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}479480// fetch our data from CSV and translate to JSON481const useMovieData = () => {482 const [data, setData] = React.useState(undefined)483484 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)502503 console.log('[data]', data)504505 setData(data)506 })507 }, [])508509 return data510}511512// very lazy large number money formatter ($1.5M, $1.65B etc)513const bigMoneyFormat = (value) => {514 if (value == null) return value515 const formatted = format('$~s')(value)516 return formatted.replace(/G$/, 'B')517}518519// metrics (numeric) + dimensions (non-numeric) = fields520const 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