Basic SVG Hover
Description
In this scatterplot, we're showing off the following features:
- interacting by mousing directly over a point
- the tooltip doesn't interfere with the mouse thanks to
pointer-events: none
- separating the
HoverPoint
from thePoints
for a performance boost (+React.memo
) - x and y gridlines baked into the axis components
- dynamically darkening a color for an outline
- a basic ordinal legend with CSS grid
- predefining metrics and dimensions ("fields") to quickly switch between them
- an approach to outlining text in SVG that renders nicely
There are some remaining issues:
- The y-axis title is covered by the data. Perhaps we should pad our y domain to have some additional spacing?
- The tooltip gets cut off at the edges of the chart
- Our basic formatters work well enough for the axes, but aren't the best for the tooltip.
- There are too many categories! We should try to stick to around 7 max, bundling the smaller categories into "Other"
- All of our data is bunched up and hard to explore. Maybe we can trim some outliers? Add zooming? Lasso summaries? Make the chart wider?
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, scaleOrdinal, scaleSequential, scaleSqrt } from 'd3-scale' // v^3.2.47import { interpolateInferno, schemeTableau10 } from 'd3-scale-chromatic' // v^2.0.089// prettier-ignore10const tableau20 = ['#4e79a7', '#a0cbe8', '#f28e2b', '#ffbe7d', '#59a14f', '#8cd17d', '#b6992d', '#f1ce63', '#499894', '#86bcb6', '#e15759', '#ff9d9a', '#79706e', '#bab0ac', '#d37295', '#fabfd2', '#b07aa1', '#d4a6c8', '#9d7660', '#d7b5a6']1112const Scatterplot = ({}) => {13 const data = useMovieData()14 const [hoverPoint, setHoverPoint] = React.useState(undefined)1516 const width = 65017 const height = 40018 const margin = { top: 10, right: 100, bottom: 30, left: 50 }19 const innerWidth = width - margin.left - margin.right20 const innerHeight = height - margin.top - margin.bottom2122 // read from pre-defined metric/dimension ("fields") bundles23 const xField = fields.revenue24 const yField = fields.vote_average25 const rField = fields.vote_count26 const colorField = fields.primary_genre27 const labelField = fields.original_title2829 // optionally pull out values into local variables30 const { accessor: xAccessor, title: xTitle, formatter: xFormatter } = xField31 const { accessor: yAccessor, title: yTitle, formatter: yFormatter } = yField32 const { accessor: rAccessor } = rField33 const { accessor: colorAccessor } = colorField3435 // memoize creating our scales so we can optimize re-renders with React.memo36 // (e.g. <Points> only re-renders when its props change)37 const { xScale, yScale, rScale, colorScale } = React.useMemo(() => {38 if (!data) return {}39 const xExtent = extent(data, xAccessor)40 const yExtent = extent(data, yAccessor)41 const rExtent = extent(data, rAccessor)42 // const colorExtent = extent(data, colorAccessor)43 const colorDomain = Array.from(new Set(data.map(colorAccessor))).sort()4445 // const radius = 446 const xScale = scaleLinear().domain(xExtent).range([0, innerWidth])47 const yScale = scaleLinear().domain(yExtent).range([innerHeight, 0])48 const rScale = scaleSqrt().domain(rExtent).range([2, 16])49 // const colorScale = scaleSequential(interpolateInferno).domain(colorExtent)50 const colorScale = scaleOrdinal().domain(colorDomain).range(tableau20)5152 return {53 xScale,54 yScale,55 rScale,56 colorScale,57 }58 }, [59 colorAccessor,60 data,61 innerHeight,62 innerWidth,63 rAccessor,64 xAccessor,65 yAccessor,66 ])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 onHover={setHoverPoint}98 />99 <HoverPoint100 labelField={labelField}101 xScale={xScale}102 xField={xField}103 yScale={yScale}104 yField={yField}105 rScale={rScale}106 rField={rField}107 colorScale={colorScale}108 colorField={colorField}109 hoverPoint={hoverPoint}110 />111 </g>112 </svg>113 <div className="mt-2">114 <OrdinalLegend colorScale={colorScale} />115 </div>116 </div>117 )118}119120export default Scatterplot121122/** draws our hover marks: a crosshair + point + basic tooltip */123const HoverPoint = ({124 hoverPoint,125 xScale,126 xField,127 yField,128 yScale,129 rScale,130 rField,131 labelField,132 color = 'cyan',133}) => {134 if (!hoverPoint) return null135136 const d = hoverPoint137 const x = xScale(xField.accessor(d))138 const y = yScale(yField.accessor(d))139 const r = rScale?.(rField.accessor(d))140 const darkerColor = darker(color)141142 const [xPixelMin, xPixelMax] = xScale.range()143 const [yPixelMin, yPixelMax] = yScale.range()144145 return (146 <g className="pointer-events-none">147 <g data-testid="xCrosshair">148 <line149 x1={xPixelMin}150 x2={xPixelMax}151 y1={y}152 y2={y}153 stroke="#fff"154 strokeWidth={4}155 />156 <line157 x1={xPixelMin}158 x2={xPixelMax}159 y1={y}160 y2={y}161 stroke={darkerColor}162 strokeWidth={1}163 />164 </g>165 <g data-testid="yCrosshair">166 <line167 y1={yPixelMin}168 y2={yPixelMax}169 x1={x}170 x2={x}171 stroke="#fff"172 strokeWidth={4}173 />174 <line175 y1={yPixelMin}176 y2={yPixelMax}177 x1={x}178 x2={x}179 stroke={darkerColor}180 strokeWidth={1}181 />182 </g>183 <circle cx={x} cy={y} r={r} fill={color} stroke="#fff" strokeWidth={4} />184 <circle185 cx={x}186 cy={y}187 r={r}188 fill={color}189 stroke={darkerColor}190 strokeWidth={2}191 />192 <g transform={`translate(${x + 8} ${y + 4})`}>193 <OutlinedSvgText194 stroke="#fff"195 strokeWidth={5}196 className="text-sm font-bold"197 dy="0.8em"198 >199 {labelField.accessor(d)}200 </OutlinedSvgText>201 <OutlinedSvgText202 stroke="#fff"203 strokeWidth={5}204 className="text-xs"205 dy="0.8em"206 y={16}207 >208 {`${xField.title}: ${xField.formatter(xField.accessor(d))}`}209 </OutlinedSvgText>210 <OutlinedSvgText211 stroke="#fff"212 strokeWidth={5}213 className="text-xs"214 dy="0.8em"215 y={30}216 >217 {`${yField.title}: ${yField.formatter(yField.accessor(d))}`}218 </OutlinedSvgText>219 </g>220 </g>221 )222}223224/**225 * A memoized component that renders all our points, but only re-renders226 * when its props change.227 */228const Points = React.memo(229 ({230 data,231 xScale,232 xAccessor,233 yAccessor,234 yScale,235 rScale,236 rAccessor,237 radius = 8,238 colorScale,239 colorAccessor,240 defaultColor = 'tomato',241 onHover,242 }) => {243 return (244 <g data-testid="Points">245 {data.map((d, i) => {246 // const x = (width * (d.revenue - minRevenue)) / (maxRevenue - minRevenue)247 const x = xScale(xAccessor(d))248 const y = yScale(yAccessor(d))249 const r = rScale?.(rAccessor(d)) ?? radius250 const color = colorScale?.(colorAccessor(d)) ?? defaultColor251 const darkerColor = darker(color)252253 return (254 <circle255 key={d.id ?? i}256 cx={x}257 cy={y}258 r={r}259 fill={color}260 stroke={darkerColor}261 strokeWidth={1}262 strokeOpacity={1}263 fillOpacity={1}264 onClick={() => console.log(d)}265 onMouseEnter={() => onHover(d)}266 onMouseLeave={() => onHover(undefined)}267 />268 )269 })}270 </g>271 )272 }273)274275/** dynamically create a darker color */276function darker(color, factor = 0.85) {277 const labColor = lab(color)278 labColor.l *= factor279280 // rgb doesn't correspond to visual perception, but is281 // easy for computers282 // const rgbColor = rgb(color)283 // rgbColor.r *= 0.8284 // rgbColor.g *= 0.8285 // rgbColor.b *= 0.8286287 // rgb(100, 50, 50);288 // rgb(75, 25, 25); // is this half has light perceptually?289 return labColor.toString()290}291292/** fancier way of getting a nice svg text stroke */293const OutlinedSvgText = ({ stroke, strokeWidth, children, ...other }) => {294 return (295 <>296 <text stroke={stroke} strokeWidth={strokeWidth} {...other}>297 {children}298 </text>299 <text {...other}>{children}</text>300 </>301 )302}303304/** basic legend component that uses CSS grid */305const OrdinalLegend = ({ colorScale }) => {306 const domain = colorScale.domain()307 return (308 <div309 className="grid grid-flow-row gap-1 text-xs leading-none text-gray-600 auto-cols-max"310 style={{311 gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',312 }}313 >314 {domain.map((category) => {315 return (316 <div key={category} className="flex items-center space-x-1">317 <div318 style={{ backgroundColor: colorScale(category) }}319 className="w-2 h-2 rounded-sm"320 />321 <div>{category}</div>322 </div>323 )324 })}325 </div>326 )327}328329/** determine number of ticks based on space available */330function numTicksForPixels(pixelsAvailable, pixelsPerTick = 70) {331 return Math.floor(Math.abs(pixelsAvailable) / pixelsPerTick)332}333334/** Y-axis with title and grid lines */335const YAxis = ({ yScale, title, formatter, gridLineWidth }) => {336 const [yMin, yMax] = yScale.range()337 const ticks = yScale.ticks(numTicksForPixels(yMax - yMin, 50))338339 return (340 <g data-testid="YAxis">341 <OutlinedSvgText342 stroke="#fff"343 strokeWidth={2.5}344 dx={4}345 dy="0.8em"346 fill="var(--gray-600)"347 className="font-semibold text-2xs"348 >349 {title}350 </OutlinedSvgText>351352 <line x1={0} x2={0} y1={yMin} y2={yMax} stroke="var(--gray-400)" />353 {ticks.map((tick) => {354 const y = yScale(tick)355 return (356 <g key={tick} transform={`translate(0 ${y})`}>357 <text358 dy="0.34em"359 textAnchor="end"360 dx={-12}361 fill="currentColor"362 className="text-gray-400 text-2xs"363 >364 {formatter(tick)}365 </text>366 <line367 x1={0}368 x2={-8}369 stroke="var(--gray-300)"370 data-testid="tickmark"371 />372 {gridLineWidth ? (373 <line374 x1={0}375 x2={gridLineWidth}376 stroke="var(--gray-200)"377 strokeOpacity={0.8}378 data-testid="gridline"379 />380 ) : null}381 </g>382 )383 })}384 </g>385 )386}387388/** X-axis with title and grid lines */389const XAxis = ({ xScale, title, formatter, innerHeight, gridLineHeight }) => {390 const [xMin, xMax] = xScale.range()391 const ticks = xScale.ticks(numTicksForPixels(xMax - xMin))392393 return (394 <g data-testid="XAxis" transform={`translate(0 ${innerHeight})`}>395 <text396 x={xMax}397 textAnchor="end"398 dy={-4}399 fill="var(--gray-600)"400 className="font-semibold text-2xs text-shadow-white-stroke"401 >402 {title}403 </text>404405 <line x1={xMin} x2={xMax} y1={0} y2={0} stroke="var(--gray-400)" />406 {ticks.map((tick) => {407 const x = xScale(tick)408 return (409 <g key={tick} transform={`translate(${x} 0)`}>410 <text411 y={10}412 dy="0.8em"413 textAnchor="middle"414 fill="currentColor"415 className="text-gray-400 text-2xs"416 >417 {formatter(tick)}418 </text>419 <line420 y1={0}421 y2={8}422 stroke="var(--gray-300)"423 data-testid="tickmark"424 />425 {gridLineHeight ? (426 <line427 y1={0}428 y2={-gridLineHeight}429 stroke="var(--gray-200)"430 strokeOpacity={0.8}431 data-testid="gridline"432 />433 ) : null}434 </g>435 )436 })}437 </g>438 )439}440441// fetch our data from CSV and translate to JSON442const useMovieData = () => {443 const [data, setData] = React.useState(undefined)444445 React.useEffect(() => {446 fetch('/datasets/tmdb_1000_movies_small.csv')447 // fetch('/datasets/tmdb_5000_movies.csv')448 .then((response) => response.text())449 .then((csvString) => {450 const data = csvParse(csvString, (row) => {451 return {452 budget: +row.budget,453 vote_average: +row.vote_average,454 vote_count: +row.vote_count,455 genres: JSON.parse(row.genres),456 primary_genre: JSON.parse(row.genres)[0]?.name,457 revenue: +row.revenue,458 original_title: row.original_title,459 }460 }).filter((d) => d.revenue > 0)461462 console.log('[data]', data)463464 setData(data)465 })466 }, [])467468 return data469}470471// very lazy large number money formatter ($1.5M, $1.65B etc)472const bigMoneyFormat = (value) => {473 if (value == null) return value474 const formatted = format('$~s')(value)475 return formatted.replace(/G$/, 'B')476}477478// metrics (numeric) + dimensions (non-numeric) = fields479const fields = {480 revenue: {481 accessor: (d) => d.revenue,482 title: 'Revenue',483 formatter: bigMoneyFormat,484 },485 budget: {486 accessor: (d) => d.budget,487 title: 'Budget',488 formatter: bigMoneyFormat,489 },490 vote_average: {491 accessor: (d) => d.vote_average,492 title: 'Vote Average out of 10',493 formatter: format('.1f'),494 },495 vote_count: {496 accessor: (d) => d.vote_count,497 title: 'Vote Count',498 formatter: format('.1f'),499 },500 primary_genre: {501 accessor: (d) => d.primary_genre,502 title: 'Primary Genre',503 formatter: (d) => d,504 },505 original_title: {506 accessor: (d) => d.original_title,507 title: 'Original Title',508 formatter: (d) => d,509 },510}511