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.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 { scaleLinear } from 'd3-scale' // v^3.2.4
6
7const Scatterplot = ({}) => {
8 const data = useMovieData()
9
10 const width = 800
11 const height = 400
12 const margin = { top: 10, right: 100, bottom: 30, left: 50 }
13 const innerWidth = width - margin.left - margin.right
14 const innerHeight = height - margin.top - margin.bottom
15
16 // read from pre-defined metric/dimension ("fields") bundles
17 const xField = fields.revenue
18 const yField = fields.vote_average
19
20 // optionally pull out values into local variables
21 const { accessor: xAccessor, title: xTitle, formatter: xFormatter } = xField
22
23 const yAccessor = yField.accessor
24 const yTitle = yField.title
25 const yFormatter = yField.formatter
26
27 if (!data) {
28 return <div style={{ width, height }} />
29 }
30
31 const radius = 4
32 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])
36
37 return (
38 <svg width={width} height={height}>
39 <g transform={`translate(${margin.left} ${margin.top})`}>
40 <XAxis
41 xScale={xScale}
42 formatter={xFormatter}
43 title={xTitle}
44 innerHeight={innerHeight}
45 />
46 <Points
47 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 Scatterplot
60
61/** 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 ourselves
67 // const x = (width * (d.revenue - minRevenue)) / (maxRevenue - minRevenue)
68 // but scales make it easier for us to think about.
69
70 const x = xScale(xAccessor(d))
71 const y = yScale(yAccessor(d))
72 return (
73 <circle
74 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}
91
92/** 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}
103
104/** determine number of ticks based on space available */
105function numTicksForPixels(pixelsAvailable, pixelsPerTick = 70) {
106 return Math.floor(Math.abs(pixelsAvailable) / pixelsPerTick)
107}
108
109/** Y-axis with title */
110const YAxis = ({ yScale, title, formatter }) => {
111 const [yMin, yMax] = yScale.range()
112 const ticks = yScale.ticks(numTicksForPixels(yMin - yMax, 50))
113
114 return (
115 <g data-testid="YAxis">
116 <OutlinedSvgText
117 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>
126
127 <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 <text
133 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}
148
149/** 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))
153
154 // we could try and compute ticks ourselves to get the exact number
155 // 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)
159
160 return (
161 <g data-testid="XAxis" transform={`translate(0 ${innerHeight})`}>
162 <text
163 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 outline
169 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>
178
179 <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 <text
185 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}
200
201// fetch our data from CSV and translate to JSON
202const useMovieData = () => {
203 const [data, setData] = React.useState(undefined)
204
205 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 })
217
218 console.log('[data]', data)
219
220 setData(data)
221 })
222 }, [])
223
224 return data
225}
226
227// very lazy large number money formatter ($1.5M, $1.65B etc)
228const bigMoneyFormat = (value) => {
229 if (value == null) return value
230 const formatted = format('$~s')(value)
231 return formatted.replace(/G$/, 'B')
232}
233
234// metrics (numeric) + dimensions (non-numeric) = fields
235const 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