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.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 { interpolateViridis } from 'd3-scale-chromatic' // v^2.0.08import { pointer } from 'd3-selection' // v^2.0.09import { Delaunay } from 'd3-delaunay' // v^5.3.01011const Scatterplot = ({}) => {12 const data = useMovieData()1314 const width = 65015 const height = 40016 const margin = { top: 10, right: 100, bottom: 30, left: 50 }17 const innerWidth = width - margin.left - margin.right18 const innerHeight = height - margin.top - margin.bottom1920 // read from pre-defined metric/dimension ("fields") bundles21 const xField = fields.revenue22 const yField = fields.vote_average23 const rField = fields.vote_count24 const colorField = fields.vote_average25 const labelField = fields.original_title2627 // optionally pull out values into local variables28 const { accessor: xAccessor, title: xTitle, formatter: xFormatter } = xField29 const { accessor: yAccessor, title: yTitle, formatter: yFormatter } = yField30 const { accessor: rAccessor } = rField31 const { accessor: colorAccessor } = colorField3233 // memoize creating our scales so we can optimize re-renders with React.memo34 // (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()4243 // const radius = 444 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)4950 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 ])6566 // interaction setup67 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 })7778 if (!data) return <div style={{ width, height }} />7980 return (81 <div style={{ width }} className="relative">82 <svg width={width} height={height}>83 <g transform={`translate(${margin.left} ${margin.top})`}>84 <XAxis85 xScale={xScale}86 formatter={xFormatter}87 title={xTitle}88 innerHeight={innerHeight}89 gridLineHeight={innerHeight}90 />91 <YAxis92 gridLineWidth={innerWidth}93 yScale={yScale}94 formatter={yFormatter}95 title={yTitle}96 />97 <Points98 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 <HoverPoint109 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 />120121 <rect122 /* 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}136137export default Scatterplot138139/**140 * Custom hook to get the closest point to the mouse based on141 * a Delaunay triangulation. Supports a max distance from142 * the mouse via the radius prop. You must provide an ref to a143 * DOM node that can be used to capture the mouse, typically a144 * <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 none156 const [hoverPoint, setHoverPoint] = React.useState(undefined)157158 // we can throttle our updates by using requestAnimationFrame (raf)159 const rafRef = React.useRef(null)160161 // precompute the Delaunay triangulation162 const delaunay = React.useMemo(() => {163 if (data == null) return null164 const points = data.map((d) => [xScale(xAccessor(d)), yScale(yAccessor(d))])165 const delaunay = Delaunay.from(points)166 return delaunay167 }, [data, xScale, xAccessor, yScale, yAccessor])168169 React.useEffect(() => {170 const interactionRect = interactionRef.current171 if (interactionRect == null) return172173 const handleMouseMove = (evt) => {174 // here we use d3-selection's pointer. You could also try react-use useMouse.175 const [mouseX, mouseY] = pointer(evt)176177 // if we already had a pending update, cancel it in favour of this one178 if (rafRef.current) {179 cancelAnimationFrame(rafRef.current)180 }181182 rafRef.current = requestAnimationFrame(() => {183 // find closest point via handy Delaunay triangulation184 const newHoverPointIndex = delaunay.find(mouseX, mouseY)185 let newHoverPoint = data[newHoverPointIndex]186187 // enforce our radius constraint188 if (189 radius != null &&190 Math.hypot(191 xScale(xAccessor(newHoverPoint)) - mouseX,192 yScale(yAccessor(newHoverPoint)) - mouseY193 ) > radius194 ) {195 newHoverPoint = undefined196 }197198 setHoverPoint(newHoverPoint)199 })200 }201 interactionRect.addEventListener('mousemove', handleMouseMove)202203 // make sure we handle when the mouse leaves the interaction area to remove204 // our active hover point205 const handleMouseLeave = () => setHoverPoint(undefined)206 interactionRect.addEventListener('mouseleave', handleMouseLeave)207208 // cleanup our listeners209 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 ])223224 return hoverPoint225}226227/** 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 null240241 const d = hoverPoint242 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)246247 const [xPixelMin, xPixelMax] = xScale.range()248 const [yPixelMin, yPixelMax] = yScale.range()249250 return (251 <g className="pointer-events-none">252 <g data-testid="xCrosshair">253 <line254 x1={xPixelMin}255 x2={xPixelMax}256 y1={y}257 y2={y}258 stroke="#fff"259 strokeWidth={4}260 />261 <line262 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 <line272 y1={yPixelMin}273 y2={yPixelMax}274 x1={x}275 x2={x}276 stroke="#fff"277 strokeWidth={4}278 />279 <line280 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 <circle290 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 <OutlinedSvgText299 stroke="#fff"300 strokeWidth={5}301 className="text-sm font-bold"302 dy="0.8em"303 >304 {labelField.accessor(d)}305 </OutlinedSvgText>306 <OutlinedSvgText307 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 <OutlinedSvgText316 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}328329/**330 * A memoized component that renders all our points, but only re-renders331 * 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)) ?? radius355 const color = colorScale?.(colorAccessor(d)) ?? defaultColor356 const darkerColor = darker(color)357358 return (359 <circle360 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)379380/** dynamically create a darker color */381function darker(color, factor = 0.85) {382 const labColor = lab(color)383 labColor.l *= factor384385 // rgb doesn't correspond to visual perception, but is386 // easy for computers387 // const rgbColor = rgb(color)388 // rgbColor.r *= 0.8389 // rgbColor.g *= 0.8390 // rgbColor.b *= 0.8391392 // rgb(100, 50, 50);393 // rgb(75, 25, 25); // is this half has light perceptually?394 return labColor.toString()395}396397/** 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}408409/** determine number of ticks based on space available */410function numTicksForPixels(pixelsAvailable, pixelsPerTick = 70) {411 return Math.floor(Math.abs(pixelsAvailable) / pixelsPerTick)412}413414/** 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))418419 return (420 <g data-testid="YAxis">421 <OutlinedSvgText422 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>431432 <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 <text438 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 <line447 x1={0}448 x2={-8}449 stroke="var(--gray-300)"450 data-testid="tickmark"451 />452 {gridLineWidth ? (453 <line454 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}467468/** 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))472473 return (474 <g data-testid="XAxis" transform={`translate(0 ${innerHeight})`}>475 <text476 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>484485 <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 <text491 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 <line500 y1={0}501 y2={8}502 stroke="var(--gray-300)"503 data-testid="tickmark"504 />505 {gridLineHeight ? (506 <line507 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}520521// fetch our data from CSV and translate to JSON522const useMovieData = () => {523 const [data, setData] = React.useState(undefined)524525 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)543544 console.log('[data]', data)545546 setData(data)547 })548 }, [])549550 return data551}552553// very lazy large number money formatter ($1.5M, $1.65B etc)554const bigMoneyFormat = (value) => {555 if (value == null) return value556 const formatted = format('$~s')(value)557 return formatted.replace(/G$/, 'B')558}559560// metrics (numeric) + dimensions (non-numeric) = fields561const 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