Basic Scatterplot
Description
This scatterplot is relatively minimal and is empasizing the basics of drawing a chart in React with the help of d3 libraries.
In this scatterplot, we're showing off the following features:
- standard margin + transform(translate) chart setup
- composing your chart with relatively simple, small components
- basic x and y axes
- leveraging CSS
:hover
for coloring a point on hover - predefining metrics and dimensions ("fields") to quickly switch between them
- an approach to outlining text in SVG that renders nicely and a CSS alternative
There are many areas of improvement. Some ideas:
- What is the point we are hovering over? Currently you can click and it logs to console.
- Can we encode additional information into the color of the points or the radius?
- How can we add grid lines to the chart?
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 { scaleLinear } from 'd3-scale' // v^3.2.467const Scatterplot = ({}) => {8 const data = useMovieData()910 const width = 80011 const height = 40012 const margin = { top: 10, right: 100, bottom: 30, left: 50 }13 const innerWidth = width - margin.left - margin.right14 const innerHeight = height - margin.top - margin.bottom1516 // read from pre-defined metric/dimension ("fields") bundles17 const xField = fields.revenue18 const yField = fields.vote_average1920 // optionally pull out values into local variables21 const { accessor: xAccessor, title: xTitle, formatter: xFormatter } = xField2223 const yAccessor = yField.accessor24 const yTitle = yField.title25 const yFormatter = yField.formatter2627 if (!data) {28 return <div style={{ width, height }} />29 }3031 const radius = 432 const xExtent = extent(data, xAccessor)33 const yExtent = extent(data, yAccessor)34 const xScale = scaleLinear().domain(xExtent).range([0, innerWidth])35 const yScale = scaleLinear().domain(yExtent).range([innerHeight, 0])3637 return (38 <svg width={width} height={height}>39 <g transform={`translate(${margin.left} ${margin.top})`}>40 <XAxis41 xScale={xScale}42 formatter={xFormatter}43 title={xTitle}44 innerHeight={innerHeight}45 />46 <Points47 radius={radius}48 data={data}49 xScale={xScale}50 yScale={yScale}51 xAccessor={xAccessor}52 yAccessor={yAccessor}53 />54 <YAxis yScale={yScale} formatter={yFormatter} title={yTitle} />55 </g>56 </svg>57 )58}59export default Scatterplot6061/** Draws a circle for each point in our data */62const Points = ({ data, xScale, xAccessor, yAccessor, yScale, radius = 8 }) => {63 return (64 <g data-testid="Points">65 {data.map((d) => {66 // without a scale, we have to compute the math ourselves67 // const x = (width * (d.revenue - minRevenue)) / (maxRevenue - minRevenue)68 // but scales make it easier for us to think about.6970 const x = xScale(xAccessor(d))71 const y = yScale(yAccessor(d))72 return (73 <circle74 key={d.original_title}75 cx={x}76 cy={y}77 r={radius}78 className="text-indigo-500 hover:text-yellow-500"79 fill="currentColor"80 stroke="white"81 strokeWidth={0.5}82 strokeOpacity={1}83 fillOpacity={0.8}84 onClick={() => console.log(d)}85 />86 )87 })}88 </g>89 )90}9192/** fancier way of getting a nice svg text stroke */93const OutlinedSvgText = ({ stroke, strokeWidth, children, ...other }) => {94 return (95 <>96 <text stroke={stroke} strokeWidth={strokeWidth} {...other}>97 {children}98 </text>99 <text {...other}>{children}</text>100 </>101 )102}103104/** determine number of ticks based on space available */105function numTicksForPixels(pixelsAvailable, pixelsPerTick = 70) {106 return Math.floor(Math.abs(pixelsAvailable) / pixelsPerTick)107}108109/** Y-axis with title */110const YAxis = ({ yScale, title, formatter }) => {111 const [yMin, yMax] = yScale.range()112 const ticks = yScale.ticks(numTicksForPixels(yMin - yMax, 50))113114 return (115 <g data-testid="YAxis">116 <OutlinedSvgText117 stroke="#fff"118 strokeWidth={2.5}119 dx={4}120 dy="0.8em"121 fill="var(--gray-600)"122 className="font-semibold text-2xs"123 >124 {title}125 </OutlinedSvgText>126127 <line x1={0} x2={0} y1={yMin} y2={yMax} stroke="var(--gray-400)" />128 {ticks.map((tick) => {129 const y = yScale(tick)130 return (131 <g key={tick} transform={`translate(0 ${y})`}>132 <text133 dy="0.34em"134 textAnchor="end"135 dx={-12}136 fill="currentColor"137 className="text-gray-400 text-2xs"138 >139 {formatter(tick)}140 </text>141 <line x1={0} x2={-8} stroke="var(--gray-300)" />142 </g>143 )144 })}145 </g>146 )147}148149/** X-axis with title, uses CSS for text stroke */150const XAxis = ({ xScale, title, formatter, innerHeight }) => {151 const [xMin, xMax] = xScale.range()152 const ticks = xScale.ticks(numTicksForPixels(xMax - xMin))153154 // we could try and compute ticks ourselves to get the exact number155 // but they won't be as nice to look at (e.g. 923.321 instead of 1000 or 900)156 // const [xDomainMin, xDomainMax] = xScale.domain()157 // const xIncrement = (xDomainMax - xDomainMin) / (numTicks - 1)158 // const ticks = range(numTicks).map((i) => xIncrement * i)159160 return (161 <g data-testid="XAxis" transform={`translate(0 ${innerHeight})`}>162 <text163 x={xMax}164 textAnchor="end"165 dy={-4}166 fill="var(--gray-600)"167 className="font-semibold text-2xs"168 // lazy CSS approach to getting a 1px white outline169 style={{170 textShadow: `-1px -1px 1px #fff,171 1px -1px 1px #fff,172 1px 1px 1px #fff,173 -1px 1px 1px #fff`,174 }}175 >176 {title}177 </text>178179 <line x1={xMin} x2={xMax} y1={0} y2={0} stroke="var(--gray-400)" />180 {ticks.map((tick) => {181 const x = xScale(tick)182 return (183 <g key={tick} transform={`translate(${x} 0)`}>184 <text185 y={10}186 dy="0.8em"187 textAnchor="middle"188 fill="currentColor"189 className="text-gray-400 text-2xs"190 >191 {formatter(tick)}192 </text>193 <line y1={0} y2={8} stroke="var(--gray-300)" />194 </g>195 )196 })}197 </g>198 )199}200201// fetch our data from CSV and translate to JSON202const useMovieData = () => {203 const [data, setData] = React.useState(undefined)204205 React.useEffect(() => {206 fetch('/datasets/tmdb_1000_movies_small.csv')207 .then((response) => response.text())208 .then((csvString) => {209 const data = csvParse(csvString, (row) => {210 return {211 budget: +row.budget,212 vote_average: +row.vote_average,213 revenue: +row.revenue,214 original_title: row.original_title,215 }216 })217218 console.log('[data]', data)219220 setData(data)221 })222 }, [])223224 return data225}226227// very lazy large number money formatter ($1.5M, $1.65B etc)228const bigMoneyFormat = (value) => {229 if (value == null) return value230 const formatted = format('$~s')(value)231 return formatted.replace(/G$/, 'B')232}233234// metrics (numeric) + dimensions (non-numeric) = fields235const fields = {236 revenue: {237 accessor: (d) => d.revenue,238 title: 'Revenue',239 formatter: bigMoneyFormat,240 },241 budget: {242 accessor: (d) => d.budget,243 title: 'Budget',244 formatter: bigMoneyFormat,245 },246 vote_average: {247 accessor: (d) => d.vote_average,248 title: 'Vote Average out of 10',249 formatter: format('.1f'),250 },251}252