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.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 } 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 { pointer } from 'd3-selection' // v^2.0.09import { groupBy, mean, n, summarize, tidy } from '@tidyjs/tidy' // v^2.1.01011const Scatterplot = ({ width = 650, height = 400 }) => {12 const data = useMovieData()1314 const margin = { top: 10, right: 10, bottom: 30, left: 50 }15 const innerWidth = width - margin.left - margin.right16 const innerHeight = height - margin.top - margin.bottom1718 // read from pre-defined metric/dimension ("fields") bundles19 const xField = fields.revenue20 const yField = fields.vote_average21 const rField = fields.count22 const colorField = fields.count23 const labelField = fields.primary_genre2425 // optionally pull out values into local variables26 const { accessor: xAccessor, title: xTitle, formatter: xFormatter } = xField27 const { accessor: yAccessor, title: yTitle, formatter: yFormatter } = yField28 const { accessor: rAccessor } = rField29 const { accessor: colorAccessor } = colorField3031 // memoize creating our scales so we can optimize re-renders with React.memo32 // (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)4142 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)4647 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 ])6263 // interaction setup64 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 })7475 if (!data) return <div style={{ width, height }} />7677 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> so83 it doesn't actually draw it on screen. */}84 <path85 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 <XAxis95 xScale={xScale}96 formatter={xFormatter}97 title={xTitle}98 innerHeight={innerHeight}99 gridLineHeight={innerHeight}100 />101102 <YAxis103 gridLineWidth={innerWidth}104 yScale={yScale}105 formatter={yFormatter}106 title={yTitle}107 />108 <XAxisTitle109 title={xTitle}110 xScale={xScale}111 innerHeight={innerHeight}112 />113 <YAxisTitle title={yTitle} />114 <Points115 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 <HoverPoint126 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 />137138 <rect139 /* 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 Scatterplot154155function padExtent([min, max], paddingFactor) {156 const delta = Math.abs(max - min)157 const padding = delta * paddingFactor158159 return [min - padding, max + padding]160 // option to treat [0, 1] as a special case161 // return [min === 0 ? 0 : min - padding, max === 1 ? 1 : max + padding]162}163164/**165 * Custom hook to get the closest point to the mouse based on166 * iterating through all points. Supports a max distance from167 * the mouse via the radius prop. You must provide an ref to a168 * DOM node that can be used to capture the mouse, typically a169 * <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 none181 const [hoverPoint, setHoverPoint] = React.useState(undefined)182183 // we can throttle our updates by using requestAnimationFrame (raf)184 const rafRef = React.useRef(null)185186 React.useEffect(() => {187 const interactionRect = interactionRef.current188 if (interactionRect == null) return189190 const handleMouseMove = (evt) => {191 // here we use d3-selection's pointer. You could also try react-use useMouse.192 const [mouseX, mouseY] = pointer(evt)193194 // if we already had a pending update, cancel it in favour of this one195 if (rafRef.current) {196 cancelAnimationFrame(rafRef.current)197 }198199 rafRef.current = requestAnimationFrame(() => {200 // naive iterate over all points method201 const newHoverPoint = findClosestPoint({202 data,203 xScale,204 xAccessor,205 yScale,206 yAccessor,207 radius,208 pixelX: mouseX,209 pixelY: mouseY,210 })211212 setHoverPoint(newHoverPoint)213 })214 }215 interactionRect.addEventListener('mousemove', handleMouseMove)216217 // make sure we handle when the mouse leaves the interaction area to remove218 // our active hover point219 const handleMouseLeave = () => setHoverPoint(undefined)220 interactionRect.addEventListener('mouseleave', handleMouseLeave)221222 // cleanup our listeners223 return () => {224 interactionRect.removeEventListener('mousemove', handleMouseMove)225 interactionRect.removeEventListener('mouseleave', handleMouseLeave)226 }227 }, [interactionRef, data, xScale, yScale, radius, xAccessor, yAccessor])228229 return hoverPoint230}231232// simple algorithm for finding the nearest point. uses fancy Math.hypot233// 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 closestPoint246 let minDistance = Infinity247 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 = d253 minDistance = distance254 }255 }256257 return closestPoint258}259260/** 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 null273274 const d = hoverPoint275 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)279280 const [xPixelMin, xPixelMax] = xScale.range()281 const [yPixelMin, yPixelMax] = yScale.range()282283 return (284 <g className="pointer-events-none">285 <g data-testid="xCrosshair">286 <line287 x1={xPixelMin}288 x2={xPixelMax}289 y1={y}290 y2={y}291 stroke="#fff"292 strokeWidth={4}293 />294 <line295 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 <line305 y1={yPixelMin}306 y2={yPixelMax}307 x1={x}308 x2={x}309 stroke="#fff"310 strokeWidth={4}311 />312 <line313 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 <circle323 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 <OutlinedSvgText332 stroke="#fff"333 strokeWidth={5}334 className="text-sm font-bold"335 dy="0.8em"336 >337 {labelField.accessor(d)}338 </OutlinedSvgText>339 <OutlinedSvgText340 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 <OutlinedSvgText349 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}361362/**363 * A memoized component that renders all our points, but only re-renders364 * 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)) ?? radius388 const color = colorScale?.(colorAccessor(d)) ?? defaultColor389 const scale = r / 9 // approximate width of icon is 18px390391 return (392 <use393 href="#pointIcon"394 key={d.id ?? i}395 transform={`translate(0 0) scale(${scale}) translate(${396 x / scale397 } ${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)413414function isDarkColor(color) {415 const labColor = lab(color)416 return labColor.l < 75417}418419/** dynamically create a darker color */420function darker(color, factor = 0.85) {421 const labColor = lab(color)422 labColor.l *= factor423424 // rgb doesn't correspond to visual perception, but is425 // easy for computers426 // const rgbColor = rgb(color)427 // rgbColor.r *= 0.8428 // rgbColor.g *= 0.8429 // rgbColor.b *= 0.8430431 // rgb(100, 50, 50);432 // rgb(75, 25, 25); // is this half has light perceptually?433 return labColor.toString()434}435436/** 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}447448/** determine number of ticks based on space available */449function numTicksForPixels(pixelsAvailable, pixelsPerTick = 70) {450 return Math.floor(Math.abs(pixelsAvailable) / pixelsPerTick)451}452453const YAxisTitle = ({ title }) => {454 return (455 <OutlinedSvgText456 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}467468/** 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))472473 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 <text481 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 <line490 x1={0}491 x2={-8}492 stroke="var(--gray-300)"493 data-testid="tickmark"494 />495 {gridLineWidth ? (496 <line497 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}510511const XAxisTitle = ({ xScale, title, innerHeight }) => {512 const [, xMax] = xScale.range()513 return (514 <text515 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}526527/** 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))531532 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 <text540 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 <line549 y1={0}550 y2={8}551 stroke="var(--gray-300)"552 data-testid="tickmark"553 />554 {gridLineHeight ? (555 <line556 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}569570// fetch our data from CSV and translate to JSON571const useMovieData = () => {572 const [data, setData] = React.useState(undefined)573574 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)591592 // group by genre and summarize593 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 )606607 console.log('groupedData', groupedData)608609 setData(groupedData)610 })611 }, [])612613 return data614}615616// very lazy large number money formatter ($1.5M, $1.65B etc)617const bigMoneyFormat = (value) => {618 if (value == null) return value619 const formatted = format('$~s')(value)620 return formatted.replace(/G$/, 'B')621}622623// metrics (numeric) + dimensions (non-numeric) = fields624const 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 },655656 count: {657 accessor: (d) => d.count,658 title: 'Num Movies in Group',659 formatter: (d) => d,660 },661}662