Points as symbols via `<use>`

Description

Sometimes when fiddling around with SVG, we want to stamp the same shape all over the place. Depending on the complexity of the shape, it can be quite costly to duplicate all the nodes needed. This is where <use> comes in. We can specify a shape once and give it an id, then we can tell the SVG to re-use our original shape as much as we want. AND we can even make minor changes to it like modifying its fill and stroke.

In this example, we replace our basic scatterplot circles with frowny faces via the <use> tag, while still retaining our sizing and coloring. Pretty fun stuff!

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 } 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 { pointer } from 'd3-selection' // v^2.0.0
9import { groupBy, mean, n, summarize, tidy } from '@tidyjs/tidy' // v^2.1.0
10
11const Scatterplot = ({ width = 650, height = 400 }) => {
12 const data = useMovieData()
13
14 const margin = { top: 10, right: 10, bottom: 30, left: 50 }
15 const innerWidth = width - margin.left - margin.right
16 const innerHeight = height - margin.top - margin.bottom
17
18 // read from pre-defined metric/dimension ("fields") bundles
19 const xField = fields.revenue
20 const yField = fields.vote_average
21 const rField = fields.count
22 const colorField = fields.count
23 const labelField = fields.primary_genre
24
25 // optionally pull out values into local variables
26 const { accessor: xAccessor, title: xTitle, formatter: xFormatter } = xField
27 const { accessor: yAccessor, title: yTitle, formatter: yFormatter } = yField
28 const { accessor: rAccessor } = rField
29 const { accessor: colorAccessor } = colorField
30
31 // memoize creating our scales so we can optimize re-renders with React.memo
32 // (e.g. <Points> only re-renders when its props change)
33 const { xScale, yScale, rScale, colorScale } = React.useMemo(() => {
34 if (!data) return {}
35 const xExtent = extent(data, xAccessor)
36 const xDomain = padExtent(xExtent, 0.125)
37 const yExtent = extent(data, yAccessor)
38 const yDomain = padExtent(yExtent, 0.125)
39 const rExtent = extent(data, rAccessor)
40 const colorExtent = extent(data, colorAccessor)
41
42 const xScale = scaleLinear().domain(xDomain).range([0, innerWidth])
43 const yScale = scaleLinear().domain(yDomain).range([innerHeight, 0])
44 const rScale = scaleSqrt().domain(rExtent).range([2, 16])
45 const colorScale = scaleSequential(interpolateTurbo).domain(colorExtent)
46
47 return {
48 xScale,
49 yScale,
50 rScale,
51 colorScale,
52 }
53 }, [
54 colorAccessor,
55 data,
56 innerHeight,
57 innerWidth,
58 rAccessor,
59 xAccessor,
60 yAccessor,
61 ])
62
63 // interaction setup
64 const interactionRef = React.useRef(null)
65 const hoverPoint = useClosestHoverPoint({
66 interactionRef,
67 data,
68 xScale,
69 xAccessor,
70 yScale,
71 yAccessor,
72 radius: 60,
73 })
74
75 if (!data) return <div style={{ width, height }} />
76
77 return (
78 <div style={{ width }} className="relative">
79 <svg width={width} height={height}>
80 <g transform={`translate(${margin.left} ${margin.top})`}>
81 <defs>
82 {/* Make our re-usable icon here. We put it in a <defs> so
83 it doesn't actually draw it on screen. */}
84 <path
85 id="pointIcon"
86 strokeLinecap="round"
87 strokeLinejoin="round"
88 // center it (it is 24x24)
89 transform="translate(-12 -12)"
90 strokeWidth={2}
91 d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
92 />
93 </defs>
94 <XAxis
95 xScale={xScale}
96 formatter={xFormatter}
97 title={xTitle}
98 innerHeight={innerHeight}
99 gridLineHeight={innerHeight}
100 />
101
102 <YAxis
103 gridLineWidth={innerWidth}
104 yScale={yScale}
105 formatter={yFormatter}
106 title={yTitle}
107 />
108 <XAxisTitle
109 title={xTitle}
110 xScale={xScale}
111 innerHeight={innerHeight}
112 />
113 <YAxisTitle title={yTitle} />
114 <Points
115 data={data}
116 xScale={xScale}
117 xAccessor={xAccessor}
118 yScale={yScale}
119 yAccessor={yAccessor}
120 rScale={rScale}
121 rAccessor={rAccessor}
122 colorScale={colorScale}
123 colorAccessor={colorAccessor}
124 />
125 <HoverPoint
126 labelField={labelField}
127 xScale={xScale}
128 xField={xField}
129 yScale={yScale}
130 yField={yField}
131 rScale={rScale}
132 rField={rField}
133 colorScale={colorScale}
134 colorField={colorField}
135 hoverPoint={hoverPoint}
136 />
137
138 <rect
139 /* this node absorbs all mouse events */
140 ref={interactionRef}
141 width={innerWidth}
142 height={innerHeight}
143 x={0}
144 y={0}
145 fill="tomato"
146 fillOpacity={0}
147 />
148 </g>
149 </svg>
150 </div>
151 )
152}
153export default Scatterplot
154
155function padExtent([min, max], paddingFactor) {
156 const delta = Math.abs(max - min)
157 const padding = delta * paddingFactor
158
159 return [min - padding, max + padding]
160 // option to treat [0, 1] as a special case
161 // return [min === 0 ? 0 : min - padding, max === 1 ? 1 : max + padding]
162}
163
164/**
165 * Custom hook to get the closest point to the mouse based on
166 * iterating through all points. Supports a max distance from
167 * the mouse via the radius prop. You must provide an ref to a
168 * DOM node that can be used to capture the mouse, typically a
169 * <rect> or <g> that covers the entire visualization.
170 */
171function useClosestHoverPoint({
172 interactionRef,
173 data,
174 xScale,
175 xAccessor,
176 yScale,
177 yAccessor,
178 radius,
179}) {
180 // capture our hover point or undefined if none
181 const [hoverPoint, setHoverPoint] = React.useState(undefined)
182
183 // we can throttle our updates by using requestAnimationFrame (raf)
184 const rafRef = React.useRef(null)
185
186 React.useEffect(() => {
187 const interactionRect = interactionRef.current
188 if (interactionRect == null) return
189
190 const handleMouseMove = (evt) => {
191 // here we use d3-selection's pointer. You could also try react-use useMouse.
192 const [mouseX, mouseY] = pointer(evt)
193
194 // if we already had a pending update, cancel it in favour of this one
195 if (rafRef.current) {
196 cancelAnimationFrame(rafRef.current)
197 }
198
199 rafRef.current = requestAnimationFrame(() => {
200 // naive iterate over all points method
201 const newHoverPoint = findClosestPoint({
202 data,
203 xScale,
204 xAccessor,
205 yScale,
206 yAccessor,
207 radius,
208 pixelX: mouseX,
209 pixelY: mouseY,
210 })
211
212 setHoverPoint(newHoverPoint)
213 })
214 }
215 interactionRect.addEventListener('mousemove', handleMouseMove)
216
217 // make sure we handle when the mouse leaves the interaction area to remove
218 // our active hover point
219 const handleMouseLeave = () => setHoverPoint(undefined)
220 interactionRect.addEventListener('mouseleave', handleMouseLeave)
221
222 // cleanup our listeners
223 return () => {
224 interactionRect.removeEventListener('mousemove', handleMouseMove)
225 interactionRect.removeEventListener('mouseleave', handleMouseLeave)
226 }
227 }, [interactionRef, data, xScale, yScale, radius, xAccessor, yAccessor])
228
229 return hoverPoint
230}
231
232// simple algorithm for finding the nearest point. uses fancy Math.hypot
233// to compute distance between a target (pixelX, pixelY) and each point.
234// supports a max distance via the radius prop.
235function findClosestPoint({
236 data,
237 xScale,
238 yScale,
239 xAccessor,
240 yAccessor,
241 pixelX,
242 pixelY,
243 radius,
244}) {
245 let closestPoint
246 let minDistance = Infinity
247 for (const d of data) {
248 const pointPixelX = xScale(xAccessor(d))
249 const pointPixelY = yScale(yAccessor(d))
250 const distance = Math.hypot(pointPixelX - pixelX, pointPixelY - pixelY)
251 if (distance < minDistance && radius != null && distance < radius) {
252 closestPoint = d
253 minDistance = distance
254 }
255 }
256
257 return closestPoint
258}
259
260/** draws our hover marks: a crosshair + point + basic tooltip */
261const HoverPoint = ({
262 hoverPoint,
263 xScale,
264 xField,
265 yField,
266 yScale,
267 rScale,
268 rField,
269 labelField,
270 color = 'cyan',
271}) => {
272 if (!hoverPoint) return null
273
274 const d = hoverPoint
275 const x = xScale(xField.accessor(d))
276 const y = yScale(yField.accessor(d))
277 const r = rScale?.(rField.accessor(d))
278 const darkerColor = darker(color)
279
280 const [xPixelMin, xPixelMax] = xScale.range()
281 const [yPixelMin, yPixelMax] = yScale.range()
282
283 return (
284 <g className="pointer-events-none">
285 <g data-testid="xCrosshair">
286 <line
287 x1={xPixelMin}
288 x2={xPixelMax}
289 y1={y}
290 y2={y}
291 stroke="#fff"
292 strokeWidth={4}
293 />
294 <line
295 x1={xPixelMin}
296 x2={xPixelMax}
297 y1={y}
298 y2={y}
299 stroke={darkerColor}
300 strokeWidth={1}
301 />
302 </g>
303 <g data-testid="yCrosshair">
304 <line
305 y1={yPixelMin}
306 y2={yPixelMax}
307 x1={x}
308 x2={x}
309 stroke="#fff"
310 strokeWidth={4}
311 />
312 <line
313 y1={yPixelMin}
314 y2={yPixelMax}
315 x1={x}
316 x2={x}
317 stroke={darkerColor}
318 strokeWidth={1}
319 />
320 </g>
321 <circle cx={x} cy={y} r={r} fill={color} stroke="#fff" strokeWidth={4} />
322 <circle
323 cx={x}
324 cy={y}
325 r={r}
326 fill={color}
327 stroke={darkerColor}
328 strokeWidth={2}
329 />
330 <g transform={`translate(${x + 8} ${y + 4})`}>
331 <OutlinedSvgText
332 stroke="#fff"
333 strokeWidth={5}
334 className="text-sm font-bold"
335 dy="0.8em"
336 >
337 {labelField.accessor(d)}
338 </OutlinedSvgText>
339 <OutlinedSvgText
340 stroke="#fff"
341 strokeWidth={5}
342 className="text-xs"
343 dy="0.8em"
344 y={16}
345 >
346 {`${xField.title}: ${xField.formatter(xField.accessor(d))}`}
347 </OutlinedSvgText>
348 <OutlinedSvgText
349 stroke="#fff"
350 strokeWidth={5}
351 className="text-xs"
352 dy="0.8em"
353 y={30}
354 >
355 {`${yField.title}: ${yField.formatter(yField.accessor(d))}`}
356 </OutlinedSvgText>
357 </g>
358 </g>
359 )
360}
361
362/**
363 * A memoized component that renders all our points, but only re-renders
364 * when its props change.
365 */
366const Points = React.memo(
367 ({
368 data,
369 xScale,
370 xAccessor,
371 yAccessor,
372 yScale,
373 rScale,
374 rAccessor,
375 radius = 8,
376 colorScale,
377 colorAccessor,
378 defaultColor = 'tomato',
379 onHover,
380 }) => {
381 return (
382 <g data-testid="Points">
383 {data.map((d, i) => {
384 // const x = (width * (d.revenue - minRevenue)) / (maxRevenue - minRevenue)
385 const x = xScale(xAccessor(d))
386 const y = yScale(yAccessor(d))
387 const r = rScale?.(rAccessor(d)) ?? radius
388 const color = colorScale?.(colorAccessor(d)) ?? defaultColor
389 const scale = r / 9 // approximate width of icon is 18px
390
391 return (
392 <use
393 href="#pointIcon"
394 key={d.id ?? i}
395 transform={`translate(0 0) scale(${scale}) translate(${
396 x / scale
397 } ${y / scale}) `}
398 fill={color}
399 stroke={darker(color)}
400 strokeWidth={1}
401 strokeOpacity={1}
402 fillOpacity={0.2}
403 onClick={() => console.log(d)}
404 onMouseEnter={onHover ? () => onHover(d) : null}
405 onMouseLeave={onHover ? () => onHover(undefined) : null}
406 />
407 )
408 })}
409 </g>
410 )
411 }
412)
413
414function isDarkColor(color) {
415 const labColor = lab(color)
416 return labColor.l < 75
417}
418
419/** dynamically create a darker color */
420function darker(color, factor = 0.85) {
421 const labColor = lab(color)
422 labColor.l *= factor
423
424 // rgb doesn't correspond to visual perception, but is
425 // easy for computers
426 // const rgbColor = rgb(color)
427 // rgbColor.r *= 0.8
428 // rgbColor.g *= 0.8
429 // rgbColor.b *= 0.8
430
431 // rgb(100, 50, 50);
432 // rgb(75, 25, 25); // is this half has light perceptually?
433 return labColor.toString()
434}
435
436/** fancier way of getting a nice svg text stroke */
437const OutlinedSvgText = ({ stroke, strokeWidth, children, ...other }) => {
438 return (
439 <>
440 <text stroke={stroke} strokeWidth={strokeWidth} {...other}>
441 {children}
442 </text>
443 <text {...other}>{children}</text>
444 </>
445 )
446}
447
448/** determine number of ticks based on space available */
449function numTicksForPixels(pixelsAvailable, pixelsPerTick = 70) {
450 return Math.floor(Math.abs(pixelsAvailable) / pixelsPerTick)
451}
452
453const YAxisTitle = ({ title }) => {
454 return (
455 <OutlinedSvgText
456 stroke="#fff"
457 strokeWidth={2.5}
458 dx={4}
459 dy="0.8em"
460 fill="var(--gray-600)"
461 className="font-semibold text-2xs"
462 >
463 {title}
464 </OutlinedSvgText>
465 )
466}
467
468/** Y-axis with title and grid lines */
469const YAxis = ({ yScale, formatter, gridLineWidth }) => {
470 const [yMin, yMax] = yScale.range()
471 const ticks = yScale.ticks(numTicksForPixels(yMax - yMin, 50))
472
473 return (
474 <g data-testid="YAxis">
475 <line x1={0} x2={0} y1={yMin} y2={yMax} stroke="var(--gray-400)" />
476 {ticks.map((tick) => {
477 const y = yScale(tick)
478 return (
479 <g key={tick} transform={`translate(0 ${y})`}>
480 <text
481 dy="0.34em"
482 textAnchor="end"
483 dx={-12}
484 fill="currentColor"
485 className="text-gray-400 text-2xs"
486 >
487 {formatter(tick)}
488 </text>
489 <line
490 x1={0}
491 x2={-8}
492 stroke="var(--gray-300)"
493 data-testid="tickmark"
494 />
495 {gridLineWidth ? (
496 <line
497 x1={0}
498 x2={gridLineWidth}
499 stroke="var(--gray-200)"
500 strokeOpacity={0.8}
501 data-testid="gridline"
502 />
503 ) : null}
504 </g>
505 )
506 })}
507 </g>
508 )
509}
510
511const XAxisTitle = ({ xScale, title, innerHeight }) => {
512 const [, xMax] = xScale.range()
513 return (
514 <text
515 x={xMax}
516 y={innerHeight}
517 textAnchor="end"
518 dy={-4}
519 fill="var(--gray-600)"
520 className="font-semibold text-2xs text-shadow-white-stroke"
521 >
522 {title}
523 </text>
524 )
525}
526
527/** X-axis with title and grid lines */
528const XAxis = ({ xScale, title, formatter, innerHeight, gridLineHeight }) => {
529 const [xMin, xMax] = xScale.range()
530 const ticks = xScale.ticks(numTicksForPixels(xMax - xMin))
531
532 return (
533 <g data-testid="XAxis" transform={`translate(0 ${innerHeight})`}>
534 <line x1={xMin} x2={xMax} y1={0} y2={0} stroke="var(--gray-400)" />
535 {ticks.map((tick) => {
536 const x = xScale(tick)
537 return (
538 <g key={tick} transform={`translate(${x} 0)`}>
539 <text
540 y={10}
541 dy="0.8em"
542 textAnchor="middle"
543 fill="currentColor"
544 className="text-gray-400 text-2xs"
545 >
546 {formatter(tick)}
547 </text>
548 <line
549 y1={0}
550 y2={8}
551 stroke="var(--gray-300)"
552 data-testid="tickmark"
553 />
554 {gridLineHeight ? (
555 <line
556 y1={0}
557 y2={-gridLineHeight}
558 stroke="var(--gray-200)"
559 strokeOpacity={0.8}
560 data-testid="gridline"
561 />
562 ) : null}
563 </g>
564 )
565 })}
566 </g>
567 )
568}
569
570// fetch our data from CSV and translate to JSON
571const useMovieData = () => {
572 const [data, setData] = React.useState(undefined)
573
574 React.useEffect(() => {
575 fetch('/datasets/tmdb_1000_movies_small.csv')
576 // fetch('/datasets/tmdb_5000_movies.csv')
577 .then((response) => response.text())
578 .then((csvString) => {
579 const data = csvParse(csvString, (row) => {
580 return {
581 budget: +row.budget,
582 vote_average: +row.vote_average,
583 vote_count: +row.vote_count,
584 genres: JSON.parse(row.genres),
585 primary_genre: JSON.parse(row.genres)[0]?.name,
586 revenue: +row.revenue,
587 original_title: row.original_title,
588 }
589 }).filter((d) => d.revenue > 0)
590 console.log('[data]', data)
591
592 // group by genre and summarize
593 const groupedData = tidy(
594 data,
595 groupBy(
596 ['primary_genre'],
597 [
598 summarize({
599 revenue: mean('revenue'),
600 vote_average: mean('vote_average'),
601 count: n(),
602 }),
603 ]
604 )
605 )
606
607 console.log('groupedData', groupedData)
608
609 setData(groupedData)
610 })
611 }, [])
612
613 return data
614}
615
616// very lazy large number money formatter ($1.5M, $1.65B etc)
617const bigMoneyFormat = (value) => {
618 if (value == null) return value
619 const formatted = format('$~s')(value)
620 return formatted.replace(/G$/, 'B')
621}
622
623// metrics (numeric) + dimensions (non-numeric) = fields
624const fields = {
625 revenue: {
626 accessor: (d) => d.revenue,
627 title: 'Revenue',
628 formatter: bigMoneyFormat,
629 },
630 budget: {
631 accessor: (d) => d.budget,
632 title: 'Budget',
633 formatter: bigMoneyFormat,
634 },
635 vote_average: {
636 accessor: (d) => d.vote_average,
637 title: 'Vote Average out of 10',
638 formatter: format('.1f'),
639 },
640 vote_count: {
641 accessor: (d) => d.vote_count,
642 title: 'Vote Count',
643 formatter: format('.1f'),
644 },
645 primary_genre: {
646 accessor: (d) => d.primary_genre,
647 title: 'Primary Genre',
648 formatter: (d) => d,
649 },
650 original_title: {
651 accessor: (d) => d.original_title,
652 title: 'Original Title',
653 formatter: (d) => d,
654 },
655
656 count: {
657 accessor: (d) => d.count,
658 title: 'Num Movies in Group',
659 formatter: (d) => d,
660 },
661}
662