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 the Points 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.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, scaleOrdinal, scaleSequential, scaleSqrt } from 'd3-scale' // v^3.2.4
7import { interpolateInferno, schemeTableau10 } from 'd3-scale-chromatic' // v^2.0.0
8
9// prettier-ignore
10const tableau20 = ['#4e79a7', '#a0cbe8', '#f28e2b', '#ffbe7d', '#59a14f', '#8cd17d', '#b6992d', '#f1ce63', '#499894', '#86bcb6', '#e15759', '#ff9d9a', '#79706e', '#bab0ac', '#d37295', '#fabfd2', '#b07aa1', '#d4a6c8', '#9d7660', '#d7b5a6']
11
12const Scatterplot = ({}) => {
13 const data = useMovieData()
14 const [hoverPoint, setHoverPoint] = React.useState(undefined)
15
16 const width = 650
17 const height = 400
18 const margin = { top: 10, right: 100, bottom: 30, left: 50 }
19 const innerWidth = width - margin.left - margin.right
20 const innerHeight = height - margin.top - margin.bottom
21
22 // read from pre-defined metric/dimension ("fields") bundles
23 const xField = fields.revenue
24 const yField = fields.vote_average
25 const rField = fields.vote_count
26 const colorField = fields.primary_genre
27 const labelField = fields.original_title
28
29 // optionally pull out values into local variables
30 const { accessor: xAccessor, title: xTitle, formatter: xFormatter } = xField
31 const { accessor: yAccessor, title: yTitle, formatter: yFormatter } = yField
32 const { accessor: rAccessor } = rField
33 const { accessor: colorAccessor } = colorField
34
35 // memoize creating our scales so we can optimize re-renders with React.memo
36 // (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()
44
45 // const radius = 4
46 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)
51
52 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 ])
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 onHover={setHoverPoint}
98 />
99 <HoverPoint
100 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}
119
120export default Scatterplot
121
122/** 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 null
135
136 const d = hoverPoint
137 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)
141
142 const [xPixelMin, xPixelMax] = xScale.range()
143 const [yPixelMin, yPixelMax] = yScale.range()
144
145 return (
146 <g className="pointer-events-none">
147 <g data-testid="xCrosshair">
148 <line
149 x1={xPixelMin}
150 x2={xPixelMax}
151 y1={y}
152 y2={y}
153 stroke="#fff"
154 strokeWidth={4}
155 />
156 <line
157 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 <line
167 y1={yPixelMin}
168 y2={yPixelMax}
169 x1={x}
170 x2={x}
171 stroke="#fff"
172 strokeWidth={4}
173 />
174 <line
175 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 <circle
185 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 <OutlinedSvgText
194 stroke="#fff"
195 strokeWidth={5}
196 className="text-sm font-bold"
197 dy="0.8em"
198 >
199 {labelField.accessor(d)}
200 </OutlinedSvgText>
201 <OutlinedSvgText
202 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 <OutlinedSvgText
211 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}
223
224/**
225 * A memoized component that renders all our points, but only re-renders
226 * 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)) ?? radius
250 const color = colorScale?.(colorAccessor(d)) ?? defaultColor
251 const darkerColor = darker(color)
252
253 return (
254 <circle
255 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)
274
275/** dynamically create a darker color */
276function darker(color, factor = 0.85) {
277 const labColor = lab(color)
278 labColor.l *= factor
279
280 // rgb doesn't correspond to visual perception, but is
281 // easy for computers
282 // const rgbColor = rgb(color)
283 // rgbColor.r *= 0.8
284 // rgbColor.g *= 0.8
285 // rgbColor.b *= 0.8
286
287 // rgb(100, 50, 50);
288 // rgb(75, 25, 25); // is this half has light perceptually?
289 return labColor.toString()
290}
291
292/** 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}
303
304/** basic legend component that uses CSS grid */
305const OrdinalLegend = ({ colorScale }) => {
306 const domain = colorScale.domain()
307 return (
308 <div
309 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 <div
318 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}
328
329/** determine number of ticks based on space available */
330function numTicksForPixels(pixelsAvailable, pixelsPerTick = 70) {
331 return Math.floor(Math.abs(pixelsAvailable) / pixelsPerTick)
332}
333
334/** 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))
338
339 return (
340 <g data-testid="YAxis">
341 <OutlinedSvgText
342 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>
351
352 <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 <text
358 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 <line
367 x1={0}
368 x2={-8}
369 stroke="var(--gray-300)"
370 data-testid="tickmark"
371 />
372 {gridLineWidth ? (
373 <line
374 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}
387
388/** 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))
392
393 return (
394 <g data-testid="XAxis" transform={`translate(0 ${innerHeight})`}>
395 <text
396 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>
404
405 <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 <text
411 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 <line
420 y1={0}
421 y2={8}
422 stroke="var(--gray-300)"
423 data-testid="tickmark"
424 />
425 {gridLineHeight ? (
426 <line
427 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}
440
441// fetch our data from CSV and translate to JSON
442const useMovieData = () => {
443 const [data, setData] = React.useState(undefined)
444
445 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)
461
462 console.log('[data]', data)
463
464 setData(data)
465 })
466 }, [])
467
468 return data
469}
470
471// very lazy large number money formatter ($1.5M, $1.65B etc)
472const bigMoneyFormat = (value) => {
473 if (value == null) return value
474 const formatted = format('$~s')(value)
475 return formatted.replace(/G$/, 'B')
476}
477
478// metrics (numeric) + dimensions (non-numeric) = fields
479const 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