├── .gitignore ├── .prettierrc ├── README.md ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html └── robots.txt ├── snowpack.config.js ├── src ├── App.test.tsx ├── App.tsx ├── Navbar.tsx ├── Patterns │ ├── HexMap │ │ ├── HexAtlas.tsx │ │ ├── Util │ │ │ ├── lookUpM49.ts │ │ │ ├── useResizeObserver.tsx │ │ │ └── useWorldAtlas.tsx │ │ ├── index.css │ │ ├── index.tsx │ │ └── styles │ │ │ └── colours.tsx │ ├── HigherOrderComponent │ │ └── index.tsx │ ├── JustD3 │ │ ├── Charts │ │ │ ├── BarChart.tsx │ │ │ ├── LineChart.tsx │ │ │ └── WorldMap.tsx │ │ ├── Dashboard.tsx │ │ ├── Util │ │ │ ├── useCSVData.tsx │ │ │ ├── useResizeObserver.tsx │ │ │ └── useWorldAtlas.tsx │ │ ├── index.css │ │ └── index.tsx │ ├── RenderProps │ │ └── index.tsx │ ├── WithComponents │ │ ├── Axis │ │ │ └── XTimeAxis.tsx │ │ ├── Charts │ │ │ ├── BarChart.tsx │ │ │ ├── LineChart.tsx │ │ │ └── WorldMap.tsx │ │ ├── Dashboard.tsx │ │ ├── Marks │ │ │ ├── BaseMap.tsx │ │ │ ├── Line.tsx │ │ │ └── SelectCountry.tsx │ │ ├── Util │ │ │ ├── useCSVData.tsx │ │ │ ├── useResizeObserver.tsx │ │ │ └── useWorldAtlas.tsx │ │ ├── index.css │ │ └── index.tsx │ ├── WithContext │ │ ├── Axis │ │ │ ├── XLinearAxis.tsx │ │ │ ├── XTimeAxis.tsx │ │ │ ├── YBandAxis.tsx │ │ │ └── YLinearAxis.tsx │ │ ├── Charts │ │ │ ├── BarChart.tsx │ │ │ ├── LineChart.tsx │ │ │ └── WorldMap.tsx │ │ ├── Dashboard.tsx │ │ ├── Marks │ │ │ ├── Bar.tsx │ │ │ ├── BaseMap.tsx │ │ │ ├── Line.tsx │ │ │ └── SelectCountry.tsx │ │ ├── Util │ │ │ ├── useCSVData.tsx │ │ │ ├── useResizeObserver.tsx │ │ │ └── useWorldAtlas.tsx │ │ ├── index.css │ │ └── index.tsx │ └── WithSelectD3 │ │ ├── Axis │ │ ├── XLinearAxis.tsx │ │ ├── XTimeAxis.tsx │ │ ├── YBandAxis.tsx │ │ └── YLinearAxis.tsx │ │ ├── Charts │ │ ├── BarChart.tsx │ │ ├── LineChart.tsx │ │ └── WorldMap.tsx │ │ ├── Dashboard.tsx │ │ ├── Marks │ │ ├── Bar.tsx │ │ ├── BaseMap.tsx │ │ ├── Line.tsx │ │ └── SelectCountry.tsx │ │ ├── Util │ │ ├── useCSVData.tsx │ │ ├── useResizeObserver.tsx │ │ └── useWorldAtlas.tsx │ │ ├── index.css │ │ └── index.tsx ├── Style │ ├── App.css │ └── index.css ├── home.tsx ├── index.tsx └── logo.svg ├── tsconfig.json ├── types └── static.d.ts └── web-test-runner.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | .snowpack 2 | build 3 | node_modules -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # D3-React Patterns 2 | 3 | > ✨ Bootstrapped with Create Snowpack App (CSA). 4 | 5 | [Live Site](https://d3-react-patterns.vercel.app/) 6 | 7 | Collection of various techniques and patterns for organizing a D3 project inside a larger React Framework. This will be an open repo for anyone to contribute to, more detail can be found in the DVS slack. 8 | 9 | The challenge here is to represent different ways of implementing D3 into React. This is not to see which is 'best' but rather to see different techniques and evaluate their strengths and weaknesses. In order to make the comparison, each Pattern should use the same data source and present it in three separate chart, that can be interacted with to update all the charts at once. 10 | 11 | While viewing the pages on the web is nice, the real show is here in the repo where you can see the code and compare the different implementations 12 | 13 | ## Available Scripts 14 | 15 | ### npm start 16 | 17 | Runs the app in the development mode. 18 | Open http://localhost:8080 to view it in the browser. 19 | 20 | The page will reload if you make edits. 21 | You will also see any lint errors in the console. 22 | 23 | ### npm run build 24 | 25 | Builds a static copy of your site to the `build/` folder. 26 | Your app is ready to be deployed! 27 | 28 | **For the best production performance:** Add a build bundler plugin like "@snowpack/plugin-webpack" to your `snowpack.config.js` config file. 29 | 30 | ### npm test 31 | 32 | Launches the application test runner. 33 | Run with the `--watch` flag (`npm test -- --watch`) to run in interactive watch mode. 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "snowpack dev", 4 | "build": "snowpack build", 5 | "test": "web-test-runner \"src/**/*.test.tsx\"", 6 | "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"", 7 | "lint": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"" 8 | }, 9 | "homepage": "https://lloydrichards.github.io/D3-React-Patterns", 10 | "dependencies": { 11 | "d3": "^6.5.0", 12 | "d3-collection": "^1.0.7", 13 | "d3-hexbin": "^0.2.2", 14 | "d3-scale": "^3.2.3", 15 | "d3-selection": "^2.0.0", 16 | "geojson": "^0.5.0", 17 | "react": "^17.0.0", 18 | "react-dom": "^17.0.0", 19 | "react-router-dom": "^5.2.0", 20 | "resize-observer-polyfill": "^1.5.1", 21 | "topojson": "^3.0.2" 22 | }, 23 | "devDependencies": { 24 | "@snowpack/plugin-dotenv": "^2.0.5", 25 | "@snowpack/plugin-react-refresh": "^2.4.0", 26 | "@snowpack/plugin-typescript": "^1.2.0", 27 | "@snowpack/web-test-runner-plugin": "^0.2.0", 28 | "@testing-library/react": "^11.0.0", 29 | "@types/chai": "^4.2.13", 30 | "@types/d3": "^6.3.0", 31 | "@types/d3-hexbin": "^0.2.3", 32 | "@types/d3-selection": "^2.0.0", 33 | "@types/mocha": "^8.2.0", 34 | "@types/react": "^17.0.0", 35 | "@types/react-dom": "^17.0.0", 36 | "@types/react-router-dom": "^5.1.7", 37 | "@types/snowpack-env": "^2.3.2", 38 | "@types/topojson": "^3.2.2", 39 | "@web/test-runner": "^0.12.0", 40 | "chai": "^4.2.0", 41 | "prettier": "^2.0.5", 42 | "snowpack": "^3.0.1", 43 | "typescript": "^4.0.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lloydrichards/D3-React-Patterns/da1a2c1d8df87610d3b22b323379ba72d9e9a9f4/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | D3-React Patterns 12 | 13 | 17 | 18 | 19 |
20 | 21 | 22 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /snowpack.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import("snowpack").SnowpackUserConfig } */ 2 | module.exports = { 3 | mount: { 4 | public: { url: '/', static: true }, 5 | src: { url: '/dist' }, 6 | }, 7 | plugins: [ 8 | '@snowpack/plugin-react-refresh', 9 | '@snowpack/plugin-dotenv', 10 | '@snowpack/plugin-typescript', 11 | ], 12 | routes: [ 13 | /* Enable an SPA Fallback in development: */ 14 | { match: 'routes', src: '.*', dest: '/index.html' }, 15 | ], 16 | optimize: { 17 | /* Example: Bundle your final build: */ 18 | // "bundle": true, 19 | }, 20 | packageOptions: { 21 | /* ... */ 22 | }, 23 | devOptions: { 24 | /* ... */ 25 | }, 26 | buildOptions: { 27 | // baseUrl: '/something', 28 | // out: 'docs', 29 | // metaUrlPath: 'snowpack', 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import { expect } from 'chai'; 4 | import App from './App'; 5 | 6 | describe('', () => { 7 | it('renders learn react link', () => { 8 | const { getByText } = render(); 9 | const linkElement = getByText(/learn react/i); 10 | expect(document.body.contains(linkElement)); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { lazy, Suspense } from 'react'; 2 | import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'; 3 | import Home from './home'; 4 | import Navbar from './Navbar'; 5 | 6 | const JustD3 = lazy(() => import('./Patterns/JustD3/index')); 7 | const WithComponents = lazy(() => import('./Patterns/WithComponents/index')); 8 | const WithSelectD3 = lazy(() => import('./Patterns/WithSelectD3/index')); 9 | const WithContext = lazy(() => import('./Patterns/WithContext/index')); 10 | const RenderProps = lazy(() => import('./Patterns/RenderProps/index')); 11 | const HigherOrderComponent = lazy( 12 | () => import('./Patterns/HigherOrderComponent/index'), 13 | ); 14 | const HexMap = lazy(() => import('./Patterns/HexMap/index')); 15 | 16 | const App = () => { 17 | return ( 18 | 19 | 20 | Loading...}> 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | export default App; 41 | -------------------------------------------------------------------------------- /src/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import { NavLink } from 'react-router-dom'; 3 | 4 | interface Props {} 5 | 6 | function Navbar({}: Props): ReactElement { 7 | return ( 8 | 42 | ); 43 | } 44 | 45 | export default Navbar; 46 | -------------------------------------------------------------------------------- /src/Patterns/HexMap/HexAtlas.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | GeoContext, 3 | geoNaturalEarth1, 4 | geoPath, 5 | max, 6 | polygonContains, 7 | scaleLinear, 8 | scaleQuantize, 9 | select, 10 | } from 'd3'; 11 | import { hexbin } from 'd3-hexbin'; 12 | import type { Feature, Geometry, MultiPolygon, Polygon } from 'geojson'; 13 | import React, { useEffect, useRef } from 'react'; 14 | import { 15 | COLOUR_ACCENT, 16 | COLOUR_BRAND, 17 | COLOUR_DARK, 18 | COLOUR_SUBTLE, 19 | } from './styles/colours'; 20 | import useResizeObserver from './Util/useResizeObserver'; 21 | 22 | interface Data { 23 | category: string; 24 | quantity: number; 25 | } 26 | 27 | interface Props { 28 | data: Array; 29 | world: { 30 | land: Feature; 31 | countries: Array>; 32 | } | null; 33 | } 34 | 35 | const HexAtlas: React.FC = ({ data, world }) => { 36 | const svgRef = useRef(null); 37 | const canvasRef = useRef(null); 38 | const wrapperRef = useRef(null); 39 | const dimensions = useResizeObserver(wrapperRef); 40 | 41 | useEffect(() => { 42 | const svg = select(svgRef.current); 43 | if (!dimensions) return; 44 | if (!world) return; 45 | if (!canvasRef) return; 46 | //D3 Code goes here! 47 | const context = canvasRef.current?.getContext('2d'); 48 | const projection = geoNaturalEarth1().fitSize( 49 | [dimensions.width, dimensions.height * 1.15], 50 | { 51 | type: 'Sphere', 52 | }, 53 | ); 54 | const hexScale = hexbin<{ x: number; y: number }>() 55 | .extent([ 56 | [0, 0], 57 | [dimensions.width, dimensions.height], 58 | ]) 59 | .radius(8) 60 | .x((d) => d.x) 61 | .y((d) => d.y); 62 | 63 | const colours = scaleLinear() 64 | .domain([0, max(data, (d) => d.quantity) || 0]) 65 | .range([COLOUR_ACCENT, COLOUR_BRAND]); 66 | 67 | const hex_points = (selected?: Array) => { 68 | //Set up the projections 69 | const pathGenerator = geoPath() 70 | .projection(projection) 71 | .context(context as GeoContext); 72 | 73 | //Create a land shape | Filter out Antarctica 74 | const world_features = world.countries 75 | .filter((i) => i.id !== '010') 76 | .filter((i) => (selected ? selected.includes(i.id) : true)); 77 | 78 | // Initialize the context’s path with the desired boundary (nothing is drawn to the screen) 79 | context?.beginPath(); 80 | world_features.forEach((d) => pathGenerator(d)); 81 | 82 | //Figure out the hexagon grid dimensions 83 | const hexGrid = hexScale 84 | .centers() 85 | .filter((h) => context?.isPointInPath(h[0], h[1])) 86 | .map((h) => ({ x: h[0], y: h[1] })); 87 | 88 | return hexGrid; 89 | }; 90 | 91 | svg 92 | .selectAll('.base-grid') 93 | .data([1]) 94 | .join('g') 95 | .attr('class', 'base-grid') 96 | .selectAll('path') 97 | .data(hexScale(hex_points())) 98 | .join('path') 99 | .attr('d', (d) => hexScale.hexagon()) 100 | .attr('transform', function (d) { 101 | return 'translate(' + d.x + ',' + d.y + ')'; 102 | }) 103 | .attr('fill', COLOUR_DARK) 104 | .style('stroke', COLOUR_SUBTLE) 105 | .style('stroke-width', 1); 106 | 107 | svg 108 | .selectAll('.hexagon') 109 | .data(data) 110 | .join('g') 111 | .attr('class', 'hexagon') 112 | .selectAll('path') 113 | .data((d) => hexScale(hex_points([d.code])).map((m) => ({ ...m, ...d }))) 114 | .join('path') 115 | .attr('d', (d) => hexScale.hexagon()) 116 | .attr('transform', function (d) { 117 | return 'translate(' + d.x + ',' + d.y + ')'; 118 | }) 119 | .attr('fill', (d) => colours(d.quantity) || COLOUR_SUBTLE) 120 | .style('stroke', COLOUR_DARK) 121 | .style('stroke-width', 1); 122 | }, [data, dimensions, world, canvasRef]); 123 | 124 | return ( 125 |
126 | 135 | 141 | 142 | 143 |
144 | ); 145 | }; 146 | 147 | export default HexAtlas; 148 | -------------------------------------------------------------------------------- /src/Patterns/HexMap/Util/useResizeObserver.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import ResizeObserver from 'resize-observer-polyfill'; 3 | 4 | const useResizeObserver = ( 5 | ref: React.MutableRefObject, 6 | ) => { 7 | const [dimensions, setDimensions] = useState(); 8 | useEffect(() => { 9 | const observeTarget = ref.current; 10 | const resizeObserver = new ResizeObserver((entries: Array) => { 11 | entries.forEach((entry) => setDimensions(entry.contentRect)); 12 | }); 13 | if (observeTarget != null) { 14 | resizeObserver.observe(observeTarget); 15 | } else { 16 | } 17 | return () => { 18 | resizeObserver.disconnect(); 19 | }; 20 | }, [ref]); 21 | return dimensions; 22 | }; 23 | 24 | export default useResizeObserver; 25 | -------------------------------------------------------------------------------- /src/Patterns/HexMap/Util/useWorldAtlas.tsx: -------------------------------------------------------------------------------- 1 | import { json } from "d3"; 2 | import type { Feature, FeatureCollection, Geometry, MultiPolygon, Polygon } from "geojson"; 3 | import { useEffect, useState } from "react"; 4 | import { feature } from "topojson"; 5 | 6 | const jsonUrl = "https://unpkg.com/world-atlas@2.0.2/countries-50m.json"; 7 | 8 | export const useWorldAtlas = () => { 9 | const [data, setData] = 10 | useState<{ 11 | land: Feature; 12 | countries: Array>; 13 | } | null>(null); 14 | 15 | useEffect(() => { 16 | json(jsonUrl).then((topology: any) => { 17 | const { countries, land } = topology.objects; 18 | setData({ 19 | land: (feature(topology, land) as unknown as FeatureCollection) 20 | .features[0], 21 | countries: ( 22 | feature(topology, countries) as unknown as FeatureCollection 23 | ).features, 24 | }); 25 | }); 26 | }, []); 27 | 28 | return data; 29 | }; 30 | -------------------------------------------------------------------------------- /src/Patterns/HexMap/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #184240; 3 | color: #c9d1c1; 4 | font-family: 'Lato', sans-serif; 5 | } 6 | 7 | p code { 8 | background: #c9d1c1; 9 | color: #184240; 10 | padding: 0 0.2em; 11 | } 12 | 13 | h2 { 14 | color: #cb862b; 15 | } 16 | pre { 17 | background: #c9d1c1; 18 | border: 1px solid #ddd; 19 | border-left: 6px solid #cb862b; 20 | color: #184240; 21 | page-break-inside: avoid; 22 | font-family: monospace; 23 | font-size: 15px; 24 | line-height: 1.6; 25 | margin-bottom: 1.6em; 26 | max-width: 100%; 27 | overflow: auto; 28 | padding: 1em 1.5em; 29 | display: block; 30 | word-wrap: break-word; 31 | } 32 | -------------------------------------------------------------------------------- /src/Patterns/HexMap/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import HexAtlas from './HexAtlas'; 3 | import './index.css'; 4 | import { countryLookUp } from './Util/lookUpM49'; 5 | import { useWorldAtlas } from './Util/useWorldAtlas'; 6 | 7 | const JustD3 = () => { 8 | const world = useWorldAtlas(); 9 | 10 | const data = [ 11 | { category: 'Germany', quantity: 10 }, 12 | { category: 'Brazil', quantity: 100 }, 13 | { category: 'China', quantity: 500 }, 14 | ]; 15 | 16 | const parsedData = data 17 | .map((d) => { 18 | const code = countryLookUp.find((i) => i.country == d.category); 19 | return { ...d, code: `${code?.m49_code}` }; 20 | }) 21 | .filter((f) => f.code !== 'undefined'); 22 | 23 | return ( 24 |
29 |
30 | 31 |
32 |
33 |

Hex Atlas

34 |

35 | This is a pretty cool example of using not only D3 to control the svg 36 | in the virtual DOM, but also to create a canvas elements and then pass 37 | that ref so that the context2d can be used too. By setting the canvas 38 | to position absolute and then using the useResizeObserver to match its 39 | dimensions to the SVG, I was able to use it in the background without 40 | having to show it. 41 |

42 |

43 | The code works in that the HexAtlas takes the world topo data from the 44 | useWorldAtlas hook as well as an array of countries and values. The 45 | countries are matched with their M49 code which can be used with the 46 | atlas to select specific countries by their id. 47 |

48 |

49 | The magic happens in the hex_points() where the context 50 | from the canvasRef is used to draw the map and the use the 51 | context.isPointInPath() to match any{' '} 52 | hexbin.center() and filter out the others. By calling 53 | this funtions with an array of M49 codes, we can alternate between the 54 | full map or just parts of it. This allows D3 to render differnt 55 | versions of the hexagons and manipulate the attr such as colour for 56 | data visualizations. 57 |

58 |
59 |
60 | ); 61 | }; 62 | 63 | export default JustD3; 64 | -------------------------------------------------------------------------------- /src/Patterns/HexMap/styles/colours.tsx: -------------------------------------------------------------------------------- 1 | export const COLOUR_BRAND = "#cb862b" 2 | export const COLOUR_SECONDARY = "#5781CB" 3 | export const COLOUR_DARK = "#282927" 4 | export const COLOUR_SUBTLE = "#184240" 5 | export const COLOUR_ACCENT = "#A1A1A1" 6 | export const COLOUR_LIGHT = "#F4F4F4" 7 | 8 | export const COLOUR_DAT_BLUE1 = "#4E79A7" 9 | export const COLOUR_DAT_BLUE2 = "#789AC0" 10 | export const COLOUR_DAT_BLUE3 = "#A5BCD5" 11 | export const COLOUR_DAT_BLUE4 = "#D2DDEA" 12 | 13 | export const COLOUR_DAT_ORANGE1 = "#F28E2C" 14 | export const COLOUR_DAT_ORANGE2 = "#F5AA61" 15 | export const COLOUR_DAT_ORANGE3 = "#F9C796" 16 | export const COLOUR_DAT_ORANGE4 = "#FCE3CA" 17 | 18 | export const COLOUR_DAT_RED1 = "#E15759" 19 | export const COLOUR_DAT_RED2 = "#E98182" 20 | export const COLOUR_DAT_RED3 = "#F0ABAC" 21 | export const COLOUR_DAT_RED4 = "#F8D5D5" 22 | 23 | export const COLOUR_DAT_GREEN1 = "#76B7B2" 24 | export const COLOUR_DAT_GREEN2 = "#98C9C5" 25 | export const COLOUR_DAT_GREEN3 = "#BBDBD9" 26 | export const COLOUR_DAT_GREEN4 = "#DDEDEC" -------------------------------------------------------------------------------- /src/Patterns/HigherOrderComponent/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function HigherOrderComponent() { 4 | return ( 5 |
6 | 7 |
8 | ) 9 | } 10 | 11 | export default HigherOrderComponent 12 | -------------------------------------------------------------------------------- /src/Patterns/JustD3/Charts/BarChart.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | axisBottom, 3 | axisLeft, 4 | curveBasis, 5 | extent, 6 | line, 7 | scaleBand, 8 | scaleLinear, 9 | scaleLog, 10 | scaleTime, 11 | select, 12 | } from 'd3'; 13 | import React, { useEffect, useRef } from 'react'; 14 | import useResizeObserver from '../Util/useResizeObserver'; 15 | 16 | interface Props { 17 | data: Array< 18 | Array<{ date: Date; population: number; country: string; code: string }> 19 | >; 20 | selected: string | null; 21 | onSelect: React.Dispatch>; 22 | } 23 | 24 | const BarChart: React.FC = ({ data, selected, onSelect }) => { 25 | const svgRef = useRef(null); 26 | const wrapperRef = useRef(null); 27 | const dimensions = useResizeObserver(wrapperRef); 28 | useEffect(() => { 29 | if (!data || data.length === 0) return; 30 | const svg = select(svgRef.current); 31 | if (!dimensions) return; 32 | 33 | const latestData = data.map((d) => d[d.length - 1]).slice(0, 25); 34 | 35 | // Define Scales 36 | const xScale = scaleLinear() 37 | .domain(extent(latestData, (d) => +d.population) as [number, number]) 38 | .range([0, dimensions.width]) 39 | .nice(); 40 | const yScale = scaleBand() 41 | .domain(latestData.map((i) => i.country)) 42 | .range([0, dimensions.height]) 43 | .paddingInner(0.2); 44 | 45 | // Define Axis 46 | const xAxis = axisBottom(xScale).tickFormat( 47 | (d) => `${(d as number) / 1000000}M`, 48 | ); 49 | const yAxis = axisLeft(yScale).ticks(5); 50 | 51 | // Draw Axis 52 | svg 53 | .select('.x-axis') 54 | .style('transform', `translate(0px, ${dimensions.height}px)`) 55 | .attr('color', 'cadetblue') 56 | .call(xAxis); 57 | svg.select('.y-axis').attr('color', 'cadetblue').call(yAxis); 58 | 59 | //Draw labels 60 | svg 61 | .selectAll('.x-labels') 62 | .data([0]) 63 | .join('text') 64 | .attr('class', 'x-labels') 65 | .attr( 66 | 'transform', 67 | `translate(${dimensions.width / 2}, ${dimensions.height + 40})`, 68 | ) 69 | .attr('fill', 'cadetblue') 70 | .style('text-anchor', 'middle') 71 | .text('Population'); 72 | 73 | // Define Shapes 74 | 75 | // Draw Marks 76 | svg 77 | .selectAll('.bars') 78 | .data(latestData) 79 | .join('rect') 80 | .attr('class', 'bars') 81 | .attr('x', 0) 82 | .attr('y', (d) => yScale(d.country) || 0) 83 | .attr('width', (d) => xScale(d.population)) 84 | .attr('height', yScale.bandwidth()) 85 | .attr('fill', (d) => (d.code === selected ? 'tomato' : 'grey')) 86 | .on('click', (i, d: any) => onSelect(d.code)); 87 | }, [dimensions, data, selected]); 88 | 89 | return ( 90 |
94 | 95 | 96 | 97 | 98 |
99 | ); 100 | }; 101 | 102 | export default BarChart; 103 | -------------------------------------------------------------------------------- /src/Patterns/JustD3/Charts/LineChart.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | axisBottom, 3 | axisLeft, 4 | curveBasis, 5 | extent, 6 | line, 7 | scaleLinear, 8 | scaleLog, 9 | scaleTime, 10 | select, 11 | } from 'd3'; 12 | import React, { useEffect, useRef } from 'react'; 13 | import useResizeObserver from '../Util/useResizeObserver'; 14 | 15 | interface Props { 16 | data: Array< 17 | Array<{ date: Date; population: number; country: string; code: string }> 18 | >; 19 | selected: string | null; 20 | } 21 | 22 | const LineChart: React.FC = ({ data, selected }) => { 23 | const svgRef = useRef(null); 24 | const wrapperRef = useRef(null); 25 | const dimensions = useResizeObserver(wrapperRef); 26 | useEffect(() => { 27 | if (!data || data.length === 0) return; 28 | const svg = select(svgRef.current); 29 | if (!dimensions) return; 30 | 31 | const allData = data.reduce( 32 | (accumulator, countryTimeseries) => accumulator.concat(countryTimeseries), 33 | [], 34 | ); 35 | 36 | // Define Scales 37 | const xScale = scaleTime() 38 | .domain(extent(allData, (d) => d.date) as [Date, Date]) 39 | .range([0, dimensions.width]) 40 | .nice(); 41 | const yScale = scaleLinear() 42 | .domain(extent(allData, (d) => +d.population) as [number, number]) 43 | .range([dimensions.height, 0]) 44 | .nice(); 45 | 46 | // Define Axis 47 | const xAxis = axisBottom(xScale).ticks(5); 48 | const yAxis = axisLeft(yScale).tickFormat( 49 | (d) => `${(d as number) / 1000000}M`, 50 | ); 51 | 52 | // Draw Axis 53 | svg 54 | .select('.x-axis') 55 | .style('transform', `translate(0px, ${dimensions.height}px)`) 56 | .attr('color', 'cadetblue') 57 | .call(xAxis); 58 | svg.select('.y-axis').attr('color', 'cadetblue').call(yAxis); 59 | svg 60 | .selectAll('.center-line') 61 | .data([0]) 62 | .join('line') 63 | .attr('class', 'center-line') 64 | .attr('x1', 0) 65 | .attr('x2', dimensions.width) 66 | .attr('y1', yScale(0)) 67 | .attr('y2', yScale(0)) 68 | .attr('stroke', 'cadetblue') 69 | .attr('stroke-width', 1); 70 | 71 | //Draw labels 72 | svg 73 | .selectAll('.x-labels') 74 | .data([0]) 75 | .join('text') 76 | .attr('class', 'x-labels') 77 | .attr( 78 | 'transform', 79 | `translate(${dimensions.width / 2}, ${dimensions.height + 40})`, 80 | ) 81 | .attr('fill', 'cadetblue') 82 | .style('text-anchor', 'middle') 83 | .text('Year'); 84 | svg 85 | .selectAll('.y-labels') 86 | .data([0]) 87 | .join('text') 88 | .attr('class', 'y-labels') 89 | .attr('fill', 'cadetblue') 90 | .attr('text-anchor', 'middle') 91 | .attr('transform', `translate(-45, ${dimensions.height / 2}) rotate(-90)`) 92 | .style('text-anchor', 'end') 93 | .text('Population'); 94 | 95 | // Define Shapes 96 | const lineGenerator = line<{ 97 | date: Date; 98 | population: number; 99 | country: string; 100 | code: string; 101 | }>() 102 | .x((d) => xScale(d.date)) 103 | .y((d) => yScale(d.population)); 104 | 105 | // Draw Marks 106 | svg 107 | .selectAll('.selected-line') 108 | .data(data.filter((i) => i[0].code === selected)) 109 | .join('path') 110 | .attr('class', 'selected-line') 111 | .attr('fill', 'none') 112 | .attr('stroke', 'tomato') 113 | .attr('stroke-width', 3) 114 | .attr('stroke-linejoin', 'round') 115 | .attr('stroke-linecap', 'round') 116 | .attr('d', (d) => lineGenerator(d)); 117 | svg 118 | .selectAll('.line') 119 | .data(data) 120 | .join('path') 121 | .attr('class', 'line') 122 | .attr('fill', 'none') 123 | .attr('stroke', 'grey') 124 | .attr('opacity', 0.3) 125 | .attr('stroke-width', 1) 126 | .attr('stroke-linejoin', 'round') 127 | .attr('stroke-linecap', 'round') 128 | .attr('d', (d) => lineGenerator(d)); 129 | }, [dimensions, data, selected]); 130 | 131 | return ( 132 |
136 | 137 | 138 | 139 | 140 |
141 | ); 142 | }; 143 | 144 | export default LineChart; 145 | -------------------------------------------------------------------------------- /src/Patterns/JustD3/Charts/WorldMap.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | axisBottom, 3 | axisLeft, 4 | curveBasis, 5 | extent, 6 | geoGraticule, 7 | geoNaturalEarth1, 8 | geoPath, 9 | line, 10 | scaleLinear, 11 | scaleLog, 12 | scaleTime, 13 | select, 14 | } from 'd3'; 15 | import React, { useEffect, useRef } from 'react'; 16 | import useResizeObserver from '../Util/useResizeObserver'; 17 | 18 | interface Props { 19 | worldAtlas: { 20 | land: any; 21 | countries: any; 22 | interiors: any; 23 | } | null; 24 | selected: string | null; 25 | onSelect: React.Dispatch>; 26 | } 27 | 28 | const WorldMap: React.FC = ({ worldAtlas, selected, onSelect }) => { 29 | const svgRef = useRef(null); 30 | const wrapperRef = useRef(null); 31 | const dimensions = useResizeObserver(wrapperRef); 32 | useEffect(() => { 33 | if (!worldAtlas) return; 34 | const svg = select(svgRef.current); 35 | if (!dimensions) return; 36 | 37 | const projection = geoNaturalEarth1().fitSize( 38 | [dimensions.width, dimensions.height], 39 | { 40 | type: 'Sphere', 41 | }, 42 | ); 43 | const pathGenerator = geoPath().projection(projection); 44 | const graticule = geoGraticule(); 45 | 46 | svg 47 | .selectAll('.sphere') 48 | .data([1]) 49 | .join('path') 50 | .attr('class', 'sphere') 51 | .attr('fill', 'rgba(255,255,255,0.05)') 52 | .attr('d', pathGenerator({ type: 'Sphere' }) || ''); 53 | svg 54 | .selectAll('.graticule') 55 | .data([1]) 56 | .join('path') 57 | .attr('class', 'graticule') 58 | .attr('fill', 'none') 59 | .attr('stroke', 'grey') 60 | .attr('opacity', 0.2) 61 | .attr('d', pathGenerator(graticule()) || ''); 62 | 63 | svg 64 | .selectAll('.land') 65 | .data(worldAtlas.land.features.map((d: any) => d)) 66 | .join('path') 67 | .attr('class', 'land') 68 | .attr('fill', 'grey') 69 | .attr('opacity', 0.8) 70 | .attr('d', (d: any) => pathGenerator(d)); 71 | 72 | svg 73 | .selectAll('.interior') 74 | .data([worldAtlas.interiors]) 75 | .join('path') 76 | .attr('class', 'interior') 77 | .attr('fill', 'none') 78 | .attr('stroke', '#184240') 79 | .attr('d', (d: any) => pathGenerator(d)); 80 | 81 | svg 82 | .selectAll('.features') 83 | .data(worldAtlas.countries.features) 84 | .join('path') 85 | .attr('class', 'features') 86 | .attr('opacity', (d: any) => (d.id === selected ? 1 : 0)) 87 | .attr('fill', 'tomato') 88 | .attr('d', (d: any) => pathGenerator(d)) 89 | .on('click', (i, d: any) => onSelect(d.id)); 90 | }, [dimensions, worldAtlas, selected]); 91 | 92 | return ( 93 |
94 | 95 | 96 | 97 | 98 |
99 | ); 100 | }; 101 | 102 | export default WorldMap; 103 | -------------------------------------------------------------------------------- /src/Patterns/JustD3/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { DSVRowArray, timeParse } from 'd3'; 2 | import React, { useState } from 'react'; 3 | import BarChart from './Charts/BarChart'; 4 | import LineChart from './Charts/LineChart'; 5 | import WorldMap from './Charts/WorldMap'; 6 | import { useCSVData } from './Util/useCSVData'; 7 | import { useWorldAtlas } from './Util/useWorldAtlas'; 8 | 9 | const parseYear = timeParse('%Y'); 10 | 11 | const Dashboard = () => { 12 | const [selected, setSelected] = useState(null); 13 | const transform = (raw: DSVRowArray) => { 14 | const years = raw?.columns?.slice(2); 15 | 16 | return raw.map((d) => { 17 | return years.map((year) => ({ 18 | date: parseYear(year), 19 | population: d[year] ? +(d[year] || 0) * 1000 : null, 20 | country: d.Country, 21 | code: 22 | (d['Country code'] || 0) < 100 23 | ? `0${d['Country code']}` 24 | : d['Country code'], 25 | })); 26 | }); 27 | }; 28 | const data = useCSVData( 29 | 'https://gist.githubusercontent.com/curran/0ac4077c7fc6390f5dd33bf5c06cb5ff/raw/605c54080c7a93a417a3cea93fd52e7550e76500/UN_Population_2019.csv', 30 | transform, 31 | ); 32 | 33 | const worldAtlas = useWorldAtlas(); 34 | 35 | if (!data || !worldAtlas)
Loading...
; 36 | 37 | console.log(selected); 38 | return ( 39 |
40 | 45 | 46 | 47 |
48 | ); 49 | }; 50 | 51 | export default Dashboard; 52 | -------------------------------------------------------------------------------- /src/Patterns/JustD3/Util/useCSVData.tsx: -------------------------------------------------------------------------------- 1 | import { csv, DSVRowArray } from 'd3'; 2 | import { useEffect, useState } from 'react'; 3 | 4 | export const useCSVData = ( 5 | url: string, 6 | transform: (rawRow: DSVRowArray) => any, 7 | ) => { 8 | const [data, setData] = useState(null); 9 | useEffect(() => { 10 | csv(url).then((resp) => setData(transform(resp))); 11 | }, []); 12 | 13 | return data; 14 | }; 15 | -------------------------------------------------------------------------------- /src/Patterns/JustD3/Util/useResizeObserver.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import ResizeObserver from 'resize-observer-polyfill'; 3 | 4 | const useResizeObserver = ( 5 | ref: React.MutableRefObject, 6 | ) => { 7 | const [dimensions, setDimensions] = useState(); 8 | useEffect(() => { 9 | const observeTarget = ref.current; 10 | const resizeObserver = new ResizeObserver((entries: Array) => { 11 | entries.forEach((entry) => setDimensions(entry.contentRect)); 12 | }); 13 | if (observeTarget != null) { 14 | resizeObserver.observe(observeTarget); 15 | } else { 16 | } 17 | return () => { 18 | resizeObserver.disconnect(); 19 | }; 20 | }, [ref]); 21 | return dimensions; 22 | }; 23 | 24 | export default useResizeObserver; 25 | -------------------------------------------------------------------------------- /src/Patterns/JustD3/Util/useWorldAtlas.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { json } from 'd3'; 3 | import { feature, mesh } from 'topojson'; 4 | 5 | const jsonUrl = 'https://unpkg.com/world-atlas@2.0.2/countries-50m.json'; 6 | 7 | export const useWorldAtlas = () => { 8 | const [data, setData] = useState<{ 9 | land: any; 10 | countries: any; 11 | interiors: any; 12 | } | null>(null); 13 | 14 | useEffect(() => { 15 | json(jsonUrl).then((topology: any) => { 16 | const { countries, land } = topology.objects; 17 | setData({ 18 | land: feature(topology, land), 19 | countries: { 20 | ...feature(topology, countries), 21 | id: feature(topology, countries), 22 | }, 23 | interiors: mesh(topology, countries, (a, b) => a !== b), 24 | }); 25 | }); 26 | }, []); 27 | 28 | return data; 29 | }; 30 | -------------------------------------------------------------------------------- /src/Patterns/JustD3/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #184240; 3 | color: #c9d1c1; 4 | font-family: 'Lato', sans-serif; 5 | } 6 | 7 | p code { 8 | background: #c9d1c1; 9 | color: #184240; 10 | padding: 0 0.2em; 11 | } 12 | 13 | h2 { 14 | color: #cb862b; 15 | } 16 | pre { 17 | background: #c9d1c1; 18 | border: 1px solid #ddd; 19 | border-left: 6px solid #cb862b; 20 | color: #184240; 21 | page-break-inside: avoid; 22 | font-family: monospace; 23 | font-size: 15px; 24 | line-height: 1.6; 25 | margin-bottom: 1.6em; 26 | max-width: 100%; 27 | overflow: auto; 28 | padding: 1em 1.5em; 29 | display: block; 30 | word-wrap: break-word; 31 | } 32 | -------------------------------------------------------------------------------- /src/Patterns/JustD3/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Dashboard from './Dashboard'; 3 | import './index.css'; 4 | 5 | const JustD3 = () => { 6 | return ( 7 |
15 |
16 | 17 |
18 |
19 |

Just D3 Pattern

20 |

21 | With the Just D3 Pattern we break the specific D3 22 | charts up into their own component and then using{' '} 23 | useEffect() and useRef() allow D3 to control 24 | a specific SVG element and then construct the chart in the same way it 25 | would be done in JS. With Typescript there are some odd moments and 26 | the need to create more safe fall backs for the charts, but it works 27 | quite closely to examples you can find in ObservableHQ or bl.ocks.org 28 |

29 |

30 | The file structure for this pattern is pretty easy. Basically, there 31 | is one top level component Dashboard.tsx which handles 32 | the data fetching and state management. Then each chart is self 33 | contained in its own file with various helper functions usually in a{' '} 34 | Util folder. After building several graphs I end up with 35 | a colder filled with various charts that are each customized to the 36 | specific data and visualizations. Though its easy to fork these into 37 | other projects and adapt them to other forms, they are ultimately not 38 | very generic or flexible. 39 |

40 | 41 |
42 |             {`JustD3
43 | --Charts
44 | ----BarChart.tsx
45 | ----LineChart.tsx
46 | ----WorldMap.tsx
47 | --Util
48 | ----useData.tsx
49 | ----useResizeObserver.tsx
50 | ----useWorldAtlas.tsx
51 | --Dashboard.tsx
52 | --index.tsx`}
53 |           
54 |
55 |
56 |
57 | ); 58 | }; 59 | 60 | export default JustD3; 61 | -------------------------------------------------------------------------------- /src/Patterns/RenderProps/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function RenderProps() { 4 | return ( 5 |
6 | 7 |
8 | ) 9 | } 10 | 11 | export default RenderProps 12 | -------------------------------------------------------------------------------- /src/Patterns/WithComponents/Axis/XTimeAxis.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react' 2 | 3 | interface Props { 4 | 5 | } 6 | 7 | const XTimeAxis = ({}: Props): ReactElement =>{ 8 | return ( 9 |
10 | 11 |
12 | ) 13 | } 14 | 15 | export default XTimeAxis 16 | 17 | -------------------------------------------------------------------------------- /src/Patterns/WithComponents/Charts/BarChart.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | axisBottom, 3 | axisLeft, 4 | curveBasis, 5 | extent, 6 | line, 7 | scaleBand, 8 | scaleLinear, 9 | scaleLog, 10 | scaleTime, 11 | select, 12 | } from 'd3'; 13 | import React, { useEffect, useRef } from 'react'; 14 | import useResizeObserver from '../Util/useResizeObserver'; 15 | 16 | interface Props { 17 | data: Array< 18 | Array<{ date: Date; population: number; country: string; code: string }> 19 | >; 20 | selected: string | null; 21 | onSelect: React.Dispatch>; 22 | width: number; 23 | } 24 | 25 | const BarChart: React.FC = ({ data, selected, onSelect, width }) => { 26 | return ( 27 |
34 | Bar Chart 35 |
36 | ); 37 | }; 38 | 39 | export default BarChart; 40 | -------------------------------------------------------------------------------- /src/Patterns/WithComponents/Charts/LineChart.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | axisBottom, 3 | axisLeft, 4 | curveBasis, 5 | extent, 6 | line, 7 | scaleLinear, 8 | scaleLog, 9 | scaleTime, 10 | select, 11 | } from 'd3'; 12 | import React, { useEffect, useRef } from 'react'; 13 | import Line from '../Marks/Line'; 14 | import useResizeObserver from '../Util/useResizeObserver'; 15 | 16 | interface Props { 17 | data: Array< 18 | Array<{ date: Date; population: number; country: string; code: string }> 19 | >; 20 | selected: string | null; 21 | width: number; 22 | height?: number; 23 | } 24 | 25 | const margin = { top: 10, right: 10, bottom: 40, left: 40 }; 26 | 27 | const LineChart: React.FC = ({ 28 | data, 29 | selected, 30 | width, 31 | height = 250, 32 | }) => { 33 | const innerWidth = width - margin.left - margin.right; 34 | const innerHeight = height - margin.top - margin.bottom; 35 | 36 | const allData = data.reduce( 37 | (accumulator, countryTimeseries) => accumulator.concat(countryTimeseries), 38 | [], 39 | ); 40 | 41 | // Define Scales 42 | const xScale = scaleTime() 43 | .domain(extent(allData, (d) => d.date) as [Date, Date]) 44 | .range([0, innerWidth]) 45 | .nice(); 46 | const yScale = scaleLinear() 47 | .domain(extent(allData, (d) => +d.population) as [number, number]) 48 | .range([innerHeight, 0]) 49 | .nice(); 50 | 51 | // Define Axis 52 | const xAxis = axisBottom(xScale).ticks(5); 53 | const yAxis = axisLeft(yScale).tickFormat( 54 | (d) => `${(d as number) / 1000000}M`, 55 | ); 56 | 57 | // Define Shapes 58 | const lineGenerator = line<{ 59 | date: Date; 60 | population: number; 61 | country: string; 62 | code: string; 63 | }>() 64 | .x((d) => xScale(d.date)) 65 | .y((d) => yScale(d.population)); 66 | 67 | const selectedData = data.find((countries) => countries[0].code === selected); 68 | return ( 69 | 70 | 71 | {data.map((country) => ( 72 | 73 | ))} 74 | {selectedData && ( 75 | 76 | )} 77 | 78 | 79 | ); 80 | }; 81 | 82 | export default LineChart; 83 | -------------------------------------------------------------------------------- /src/Patterns/WithComponents/Charts/WorldMap.tsx: -------------------------------------------------------------------------------- 1 | import { geoNaturalEarth1, geoPath } from 'd3'; 2 | import React from 'react'; 3 | import BaseMap from '../Marks/BaseMap'; 4 | import SelectCountry from '../Marks/SelectCountry'; 5 | 6 | interface Props { 7 | worldAtlas: { 8 | land: any; 9 | countries: any; 10 | interiors: any; 11 | } | null; 12 | selected: string | null; 13 | onSelect: React.Dispatch>; 14 | width: number; 15 | height?: number; 16 | } 17 | 18 | const WorldMap: React.FC = ({ 19 | worldAtlas, 20 | selected, 21 | onSelect, 22 | width, 23 | height = 300, 24 | }) => { 25 | const projection = geoNaturalEarth1().fitSize([width, height], { 26 | type: 'Sphere', 27 | }); 28 | const pathGenerator = geoPath().projection(projection); 29 | 30 | return ( 31 | 32 | 33 | 39 | 40 | ); 41 | }; 42 | 43 | export default WorldMap; 44 | -------------------------------------------------------------------------------- /src/Patterns/WithComponents/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { DSVRowArray, timeParse } from 'd3'; 2 | import React, { useRef, useState } from 'react'; 3 | import BarChart from './Charts/BarChart'; 4 | import LineChart from './Charts/LineChart'; 5 | import WorldMap from './Charts/WorldMap'; 6 | import { useCSVData } from './Util/useCSVData'; 7 | import useResizeObserver from './Util/useResizeObserver'; 8 | import { useWorldAtlas } from './Util/useWorldAtlas'; 9 | 10 | const parseYear = timeParse('%Y'); 11 | 12 | export const ChartComponent = () => { 13 | const [selected, setSelected] = useState(null); 14 | const wrapperRef = useRef(null); 15 | const dimensions = useResizeObserver(wrapperRef); 16 | 17 | const transform = (raw: DSVRowArray) => { 18 | const years = raw?.columns?.slice(2); 19 | 20 | return raw.map((d) => { 21 | return years.map((year) => ({ 22 | date: parseYear(year), 23 | population: d[year] ? +(d[year] || 0) * 1000 : null, 24 | country: d.Country, 25 | code: 26 | (d['Country code'] || 0) < 100 27 | ? `0${d['Country code']}` 28 | : d['Country code'], 29 | })); 30 | }); 31 | }; 32 | const data = useCSVData( 33 | 'https://gist.githubusercontent.com/curran/0ac4077c7fc6390f5dd33bf5c06cb5ff/raw/605c54080c7a93a417a3cea93fd52e7550e76500/UN_Population_2019.csv', 34 | transform, 35 | ); 36 | 37 | const worldAtlas = useWorldAtlas(); 38 | 39 | if (!data || !worldAtlas)
Loading...
; 40 | 41 | return ( 42 |
43 | 49 | {data && ( 50 | <> 51 | 56 | 62 | 63 | )} 64 |
65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /src/Patterns/WithComponents/Marks/BaseMap.tsx: -------------------------------------------------------------------------------- 1 | import { geoGraticule, GeoPath, GeoPermissibleObjects } from 'd3'; 2 | import React from 'react'; 3 | 4 | interface Props { 5 | pathGenerator: GeoPath; 6 | data: { 7 | land: any; 8 | countries: any; 9 | interiors: any; 10 | } | null; 11 | } 12 | 13 | const BaseMap: React.FC = ({ pathGenerator, data }) => { 14 | const graticule = geoGraticule(); 15 | 16 | return ( 17 | 18 | 23 | 28 | {data && 29 | data.land.features.map((d: any) => ( 30 | 36 | ))} 37 | 43 | 44 | ); 45 | }; 46 | 47 | export default BaseMap; 48 | -------------------------------------------------------------------------------- /src/Patterns/WithComponents/Marks/Line.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | interface Props { 3 | d: string; 4 | selected?: boolean; 5 | } 6 | const Line: React.FC = ({ d, selected }) => { 7 | return ( 8 | 15 | ); 16 | }; 17 | 18 | export default Line; 19 | -------------------------------------------------------------------------------- /src/Patterns/WithComponents/Marks/SelectCountry.tsx: -------------------------------------------------------------------------------- 1 | import type { GeoPath, GeoPermissibleObjects } from 'd3'; 2 | import React from 'react'; 3 | 4 | interface Props { 5 | pathGenerator: GeoPath; 6 | data: { 7 | land: any; 8 | countries: any; 9 | interiors: any; 10 | } | null; 11 | selected: string | null; 12 | onSelect: React.Dispatch>; 13 | } 14 | 15 | const SelectCountry: React.FC = ({ 16 | pathGenerator, 17 | data, 18 | selected, 19 | onSelect, 20 | }) => { 21 | return ( 22 | 23 | {data && 24 | data.countries.features.map((d: any) => ( 25 | onSelect(d.id)} 31 | /> 32 | ))} 33 | 34 | ); 35 | }; 36 | 37 | export default SelectCountry; 38 | -------------------------------------------------------------------------------- /src/Patterns/WithComponents/Util/useCSVData.tsx: -------------------------------------------------------------------------------- 1 | import { csv, DSVRowArray } from 'd3'; 2 | import { useEffect, useState } from 'react'; 3 | 4 | export const useCSVData = ( 5 | url: string, 6 | transform: (rawRow: DSVRowArray) => any, 7 | ) => { 8 | const [data, setData] = useState(null); 9 | useEffect(() => { 10 | csv(url).then((resp) => setData(transform(resp))); 11 | }, []); 12 | 13 | return data; 14 | }; 15 | -------------------------------------------------------------------------------- /src/Patterns/WithComponents/Util/useResizeObserver.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import ResizeObserver from 'resize-observer-polyfill'; 3 | 4 | const useResizeObserver = ( 5 | ref: React.MutableRefObject, 6 | ) => { 7 | const [dimensions, setDimensions] = useState(); 8 | useEffect(() => { 9 | const observeTarget = ref.current; 10 | const resizeObserver = new ResizeObserver((entries: Array) => { 11 | entries.forEach((entry) => setDimensions(entry.contentRect)); 12 | }); 13 | if (observeTarget != null) { 14 | resizeObserver.observe(observeTarget); 15 | } else { 16 | } 17 | return () => { 18 | resizeObserver.disconnect(); 19 | }; 20 | }, [ref]); 21 | return dimensions; 22 | }; 23 | 24 | export default useResizeObserver; 25 | -------------------------------------------------------------------------------- /src/Patterns/WithComponents/Util/useWorldAtlas.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { json } from 'd3'; 3 | import { feature, mesh } from 'topojson'; 4 | 5 | const jsonUrl = 'https://unpkg.com/world-atlas@2.0.2/countries-50m.json'; 6 | 7 | export const useWorldAtlas = () => { 8 | const [data, setData] = useState<{ 9 | land: any; 10 | countries: any; 11 | interiors: any; 12 | } | null>(null); 13 | 14 | useEffect(() => { 15 | json(jsonUrl).then((topology: any) => { 16 | const { countries, land } = topology.objects; 17 | setData({ 18 | land: feature(topology, land), 19 | countries: { 20 | ...feature(topology, countries), 21 | id: feature(topology, countries), 22 | }, 23 | interiors: mesh(topology, countries, (a, b) => a !== b), 24 | }); 25 | }); 26 | }, []); 27 | 28 | return data; 29 | }; 30 | -------------------------------------------------------------------------------- /src/Patterns/WithComponents/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #184240; 3 | color: #c9d1c1; 4 | font-family: 'Lato', sans-serif; 5 | } 6 | 7 | p code { 8 | background: #c9d1c1; 9 | color: #184240; 10 | padding: 0 0.2em; 11 | } 12 | 13 | h2 { 14 | color: #cb862b; 15 | } 16 | pre { 17 | background: #c9d1c1; 18 | border: 1px solid #ddd; 19 | border-left: 6px solid #cb862b; 20 | color: #184240; 21 | page-break-inside: avoid; 22 | font-family: monospace; 23 | font-size: 15px; 24 | line-height: 1.6; 25 | margin-bottom: 1.6em; 26 | max-width: 100%; 27 | overflow: auto; 28 | padding: 1em 1.5em; 29 | display: block; 30 | word-wrap: break-word; 31 | } 32 | -------------------------------------------------------------------------------- /src/Patterns/WithComponents/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ChartComponent } from './Dashboard'; 3 | import './index.css'; 4 | 5 | function WithComponents() { 6 | return ( 7 |
15 |
16 | 17 |
18 |
19 |

With Components Pattern

20 | 21 |

22 | Rather than letting D3 have control of the DOM, in this pattern just 23 | uses React Components to returning JSX. In this way, rather than 24 | feeding data into the SVG, data can be stored and manipulated in the 25 | components and each part can be either smart of dumb to their context. 26 |

27 |
28 |           {`With Componets
29 | --Axis
30 | ----XLinearAxis.tsx
31 | ----XTimeAxis.tsx
32 | ----YBandAxis
33 | ----YLinearAxis
34 | --Charts
35 | ----BarChart.tsx
36 | ----LineChart.tsx
37 | ----WorldMap.tsx
38 | --Marks
39 | ----Bar.tsx
40 | ----BaseMap.tsx
41 | ----Line.tsx
42 | ----SelectCountry.tsx
43 | --Util
44 | ----useCSVData.tsx
45 | ----useResizeObserver.tsx
46 | ----useWorldAtlas.tsx
47 | --Dashboard.tsx
48 | --index.tsx
49 | --index.css`}
50 |         
51 |
52 |
53 | ); 54 | } 55 | 56 | export default WithComponents; 57 | -------------------------------------------------------------------------------- /src/Patterns/WithContext/Axis/XLinearAxis.tsx: -------------------------------------------------------------------------------- 1 | import { axisBottom, axisLeft, select } from 'd3'; 2 | import type { NumberValue, ScaleLinear, ScaleTime } from 'd3-scale'; 3 | import React, { ReactElement, useEffect, useRef } from 'react'; 4 | 5 | interface Props { 6 | xScale: ScaleLinear; 7 | tickFormat: (domainValue: NumberValue, index: number) => string; 8 | innerWidth: number; 9 | margin: { top: number; left: number; bottom: number; right: number }; 10 | innerHeight: number; 11 | } 12 | 13 | const XLinearAxis = ({ 14 | xScale, 15 | tickFormat, 16 | innerWidth, 17 | innerHeight, 18 | margin, 19 | }: Props): ReactElement => { 20 | const svgRef = useRef(null); 21 | 22 | useEffect(() => { 23 | const svg = select(svgRef.current); 24 | 25 | // Define Axis 26 | const xAxis = axisBottom(xScale).tickFormat(tickFormat); 27 | // Draw Axis 28 | svg 29 | .select('.x-axis') 30 | .style( 31 | 'transform', 32 | `translate(${margin.left}px, ${innerHeight + margin.top}px)`, 33 | ) 34 | .attr('color', 'cadetblue') 35 | .call(xAxis); 36 | 37 | //Draw labels 38 | svg 39 | .selectAll('.x-labels') 40 | .data([0]) 41 | .join('text') 42 | .attr('class', 'x-labels') 43 | .attr('transform', `translate(${innerWidth / 2}, ${innerHeight + 40})`) 44 | .attr('fill', 'cadetblue') 45 | .style('text-anchor', 'middle') 46 | .text('Population'); 47 | }, [innerWidth]); 48 | 49 | return ( 50 | 51 | 52 | 53 | ); 54 | }; 55 | 56 | export default XLinearAxis; 57 | -------------------------------------------------------------------------------- /src/Patterns/WithContext/Axis/XTimeAxis.tsx: -------------------------------------------------------------------------------- 1 | import { axisBottom, select } from 'd3'; 2 | import type { NumberValue, ScaleTime } from 'd3-scale'; 3 | import React, { ReactElement, useEffect, useRef } from 'react'; 4 | 5 | interface Props { 6 | xScale: ScaleTime; 7 | innerWidth: number; 8 | margin: { top: number; left: number; bottom: number; right: number }; 9 | innerHeight: number; 10 | ticks?: number; 11 | } 12 | 13 | const XTimeAxis = ({ 14 | xScale, 15 | innerWidth, 16 | innerHeight, 17 | margin, 18 | ticks = 10, 19 | }: Props): ReactElement => { 20 | const svgRef = useRef(null); 21 | 22 | useEffect(() => { 23 | const svg = select(svgRef.current); 24 | 25 | // Define Axis 26 | const xAxis = axisBottom(xScale).ticks(ticks); 27 | 28 | // Draw Axis 29 | svg 30 | .select('.x-axis') 31 | .style( 32 | 'transform', 33 | `translate(${margin.left}px, ${innerHeight + margin.top}px)`, 34 | ) 35 | .attr('color', 'cadetblue') 36 | .call(xAxis); 37 | 38 | //Draw labels 39 | svg 40 | .selectAll('.x-labels') 41 | .data([0]) 42 | .join('text') 43 | .attr('class', 'x-labels') 44 | .attr('transform', `translate(${innerWidth / 2}, ${innerHeight + 40})`) 45 | .attr('fill', 'cadetblue') 46 | .style('text-anchor', 'middle') 47 | .text('Year'); 48 | }, [innerWidth]); 49 | 50 | return ( 51 | 52 | 53 | 54 | ); 55 | }; 56 | 57 | export default XTimeAxis; 58 | -------------------------------------------------------------------------------- /src/Patterns/WithContext/Axis/YBandAxis.tsx: -------------------------------------------------------------------------------- 1 | import { axisBottom, axisLeft, select } from 'd3'; 2 | import type { NumberValue, ScaleBand, ScaleLinear, ScaleTime } from 'd3-scale'; 3 | import React, { ReactElement, useEffect, useRef } from 'react'; 4 | 5 | interface Props { 6 | yScale: ScaleBand; 7 | innerWidth: number; 8 | margin: { top: number; left: number; bottom: number; right: number }; 9 | innerHeight: number; 10 | ticks?: number; 11 | } 12 | 13 | const YLinearAxis = ({ 14 | yScale, 15 | innerWidth, 16 | innerHeight, 17 | margin, 18 | ticks = 10, 19 | }: Props): ReactElement => { 20 | const svgRef = useRef(null); 21 | 22 | useEffect(() => { 23 | const svg = select(svgRef.current); 24 | 25 | // Define Axis 26 | const yAxis = axisLeft(yScale).ticks(ticks); 27 | // Draw Axis 28 | svg 29 | .select('.y-axis') 30 | .style('transform', `translate(${margin.left}px, ${margin.top}px)`) 31 | .attr('color', 'cadetblue') 32 | .call(yAxis); 33 | }, [innerWidth]); 34 | 35 | return ( 36 | 37 | 38 | 39 | ); 40 | }; 41 | 42 | export default YLinearAxis; 43 | -------------------------------------------------------------------------------- /src/Patterns/WithContext/Axis/YLinearAxis.tsx: -------------------------------------------------------------------------------- 1 | import { axisBottom, axisLeft, select } from 'd3'; 2 | import type { NumberValue, ScaleLinear, ScaleTime } from 'd3-scale'; 3 | import React, { ReactElement, useEffect, useRef } from 'react'; 4 | 5 | interface Props { 6 | yScale: ScaleLinear; 7 | tickFormat: (domainValue: NumberValue, index: number) => string; 8 | innerWidth: number; 9 | margin: { top: number; left: number; bottom: number; right: number }; 10 | innerHeight: number; 11 | ticks?: number; 12 | } 13 | 14 | const YLinearAxis = ({ 15 | yScale, 16 | tickFormat, 17 | innerWidth, 18 | innerHeight, 19 | margin, 20 | ticks = 10, 21 | }: Props): ReactElement => { 22 | const svgRef = useRef(null); 23 | 24 | useEffect(() => { 25 | const svg = select(svgRef.current); 26 | 27 | // Define Axis 28 | const yAxis = axisLeft(yScale).tickFormat(tickFormat); 29 | // Draw Axis 30 | svg 31 | .select('.y-axis') 32 | .style('transform', `translate(${margin.left}px, ${margin.top}px)`) 33 | .attr('color', 'cadetblue') 34 | .call(yAxis); 35 | 36 | //Draw labels 37 | svg 38 | .selectAll('.y-labels') 39 | .data([0]) 40 | .join('text') 41 | .attr('class', 'y-labels') 42 | .attr('fill', 'cadetblue') 43 | .attr('text-anchor', 'middle') 44 | .attr( 45 | 'transform', 46 | `translate(0, ${(innerHeight - margin.top) / 2}) rotate(-90)`, 47 | ) 48 | .style('text-anchor', 'end') 49 | .text('Population'); 50 | }, [innerWidth]); 51 | 52 | return ( 53 | 54 | 55 | 56 | ); 57 | }; 58 | 59 | export default YLinearAxis; 60 | -------------------------------------------------------------------------------- /src/Patterns/WithContext/Charts/BarChart.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | axisBottom, 3 | axisLeft, 4 | curveBasis, 5 | extent, 6 | line, 7 | NumberValue, 8 | scaleBand, 9 | scaleLinear, 10 | scaleLog, 11 | scaleTime, 12 | select, 13 | tickFormat, 14 | } from 'd3'; 15 | import React, { useEffect, useRef } from 'react'; 16 | import XLinearAxis from '../Axis/XLinearAxis'; 17 | import YBandAxis from '../Axis/YBandAxis'; 18 | import { useDashboard } from '../Dashboard'; 19 | import Bar from '../Marks/Bar'; 20 | import useResizeObserver from '../Util/useResizeObserver'; 21 | 22 | interface Props { 23 | height?: number; 24 | } 25 | 26 | const margin = { top: 10, right: 10, bottom: 40, left: 40 }; 27 | 28 | const BarChart: React.FC = ({ height = 300 }) => { 29 | const { width, selected, setSelected, data, scales } = useDashboard(); 30 | const innerWidth = width || 0 - margin.left - margin.right; 31 | const innerHeight = height - margin.top - margin.bottom; 32 | 33 | const latestData = data && data.map((d) => d[d.length - 1]).slice(0, 25); 34 | 35 | // Format 36 | const tickFormat = (domainValue: NumberValue, index: number) => 37 | `${(domainValue as number) / 1000000}M`; 38 | 39 | //Scale 40 | const xScale = scaleLinear() 41 | .domain(extent(latestData, (d) => +d.population) as [number, number]) 42 | .range([0, innerWidth]) 43 | .nice(); 44 | const yScale = scaleBand() 45 | .domain(latestData.map((i) => i.country)) 46 | .range([0, innerHeight]) 47 | .paddingInner(0.2); 48 | 49 | return ( 50 | 51 | 52 | {latestData && 53 | latestData.map((country) => ( 54 | setSelected(country.code)} 61 | /> 62 | ))} 63 | 64 | 71 | 77 | 78 | ); 79 | }; 80 | 81 | export default BarChart; 82 | -------------------------------------------------------------------------------- /src/Patterns/WithContext/Charts/LineChart.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | axisBottom, 3 | axisLeft, 4 | extent, 5 | line, 6 | NumberValue, 7 | scaleLinear, 8 | scaleTime, 9 | } from 'd3'; 10 | import React, { useMemo } from 'react'; 11 | import XTimeAxis from '../Axis/XTimeAxis'; 12 | import YLinearAxis from '../Axis/YLinearAxis'; 13 | import { useDashboard } from '../Dashboard'; 14 | import Line from '../Marks/Line'; 15 | 16 | interface Props { 17 | height?: number; 18 | } 19 | 20 | const margin = { top: 10, right: 10, bottom: 40, left: 40 }; 21 | 22 | const LineChart: React.FC = ({ height = 300 }) => { 23 | const { width, selected, data, scales } = useDashboard(); 24 | 25 | const innerWidth = width || 0 - margin.left - margin.right; 26 | const innerHeight = height - margin.top - margin.bottom; 27 | 28 | const allData = useMemo( 29 | () => 30 | data.reduce( 31 | (accumulator, countryTimeseries) => 32 | accumulator.concat(countryTimeseries), 33 | [], 34 | ), 35 | [data], 36 | ); 37 | 38 | // Define Scales 39 | const xScale = scaleTime() 40 | .domain(extent(allData, (d) => d.date) as [Date, Date]) 41 | .range([0, innerWidth]) 42 | .nice(); 43 | const yScale = scaleLinear() 44 | .domain(extent(allData, (d) => +d.population) as [number, number]) 45 | .range([innerHeight, 0]) 46 | .nice(); 47 | 48 | // Format 49 | const tickFormat = (domainValue: NumberValue, index: number) => 50 | `${(domainValue as number) / 1000000}M`; 51 | 52 | // Define Shapes 53 | const lineGenerator = useMemo( 54 | () => 55 | line<{ 56 | date: Date; 57 | population: number; 58 | country: string; 59 | code: string; 60 | }>() 61 | .x((d) => scales.TimeScale.range([0, innerWidth])(d.date)) 62 | .y((d) => scales.PopulationScale.range([innerHeight, 0])(d.population)), 63 | [scales, innerWidth, innerHeight], 64 | ); 65 | 66 | const selectedData = useMemo( 67 | () => data.find((countries) => countries[0].code === selected), 68 | [data, selected], 69 | ); 70 | return ( 71 | 72 | 73 | {data.map((country) => ( 74 | 75 | ))} 76 | {selectedData && ( 77 | 78 | )} 79 | 80 | 86 | 93 | 94 | ); 95 | }; 96 | 97 | export default LineChart; 98 | -------------------------------------------------------------------------------- /src/Patterns/WithContext/Charts/WorldMap.tsx: -------------------------------------------------------------------------------- 1 | import { geoNaturalEarth1, geoPath } from 'd3'; 2 | import React, { useMemo } from 'react'; 3 | import { useDashboard } from '../Dashboard'; 4 | import BaseMap from '../Marks/BaseMap'; 5 | import SelectCountry from '../Marks/SelectCountry'; 6 | 7 | interface Props { 8 | worldAtlas: { 9 | land: any; 10 | countries: any; 11 | interiors: any; 12 | } | null; 13 | height?: number; 14 | } 15 | 16 | const WorldMap: React.FC = ({ worldAtlas, height = 300 }) => { 17 | const { width } = useDashboard(); 18 | 19 | const pathGenerator = useMemo(() => { 20 | const projection = geoNaturalEarth1().fitSize([width || 0, height], { 21 | type: 'Sphere', 22 | }); 23 | return geoPath().projection(projection); 24 | }, [width, height]); 25 | 26 | return ( 27 | 28 | 29 | 30 | 31 | ); 32 | }; 33 | 34 | export default WorldMap; 35 | -------------------------------------------------------------------------------- /src/Patterns/WithContext/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DSVRowArray, 3 | extent, 4 | ScaleBand, 5 | scaleBand, 6 | ScaleLinear, 7 | scaleLinear, 8 | ScaleTime, 9 | scaleTime, 10 | timeParse, 11 | } from 'd3'; 12 | import React, { useContext, useMemo, useRef, useState } from 'react'; 13 | import BarChart from './Charts/BarChart'; 14 | import LineChart from './Charts/LineChart'; 15 | import WorldMap from './Charts/WorldMap'; 16 | import { useCSVData } from './Util/useCSVData'; 17 | import useResizeObserver from './Util/useResizeObserver'; 18 | import { useWorldAtlas } from './Util/useWorldAtlas'; 19 | 20 | const parseYear = timeParse('%Y'); 21 | 22 | type ContextProps = { 23 | selected: string | null; 24 | setSelected: React.Dispatch>; 25 | data: { 26 | date: Date; 27 | population: number; 28 | country: string; 29 | code: string; 30 | }[][]; 31 | width: number | undefined; 32 | scales: { 33 | PopulationScale: ScaleLinear; 34 | TimeScale: ScaleTime; 35 | CountryCategories: ScaleBand; 36 | }; 37 | }; 38 | 39 | export const AuthContext = React.createContext( 40 | {} as ContextProps, 41 | ); 42 | 43 | export const useDashboard = () => useContext(AuthContext); 44 | 45 | export const ChartComponent = () => { 46 | const [selected, setSelected] = useState(null); 47 | const wrapperRef = useRef(null); 48 | const dimensions = useResizeObserver(wrapperRef); 49 | 50 | const width = dimensions?.width || 450; 51 | // Parse Data 52 | const transform = (raw: DSVRowArray) => { 53 | const years = raw?.columns?.slice(2); 54 | 55 | return raw.map((d) => { 56 | return years.map((year) => ({ 57 | date: parseYear(year), 58 | population: d[year] ? +(d[year] || 0) * 1000 : null, 59 | country: d.Country, 60 | code: 61 | (d['Country code'] || 0) < 100 62 | ? `0${d['Country code']}` 63 | : d['Country code'], 64 | })); 65 | }); 66 | }; 67 | 68 | const data: { 69 | date: Date; 70 | population: number; 71 | country: string; 72 | code: string; 73 | }[][] = useCSVData( 74 | 'https://gist.githubusercontent.com/curran/0ac4077c7fc6390f5dd33bf5c06cb5ff/raw/605c54080c7a93a417a3cea93fd52e7550e76500/UN_Population_2019.csv', 75 | transform, 76 | ); 77 | const worldAtlas = useWorldAtlas(); 78 | 79 | const allData = useMemo( 80 | () => 81 | data && 82 | data.reduce( 83 | (accumulator, countryTimeseries) => 84 | accumulator.concat(countryTimeseries), 85 | [], 86 | ), 87 | [data], 88 | ); 89 | 90 | if (data === null || !worldAtlas) { 91 | return
Loading...
; 92 | } 93 | 94 | // Define Scales 95 | const TimeScale = scaleTime() 96 | .domain(extent(allData, (d) => d.date) as [Date, Date]) 97 | .range([0, innerWidth]) 98 | .nice(); 99 | const PopulationScale = scaleLinear() 100 | .domain(extent(allData, (d) => +d.population) as [number, number]) 101 | .range([innerHeight, 0]) 102 | .nice(); 103 | const CountryCategories = scaleBand() 104 | .domain( 105 | data 106 | .map((d) => d[d.length - 1]) 107 | .slice(0, 25) 108 | .map((i) => i.country), 109 | ) 110 | .range([0, innerHeight]) 111 | .paddingInner(0.2); 112 | 113 | return ( 114 | 123 |
124 | 125 | {data && ( 126 | <> 127 | 128 | 129 | 130 | )} 131 |
132 |
133 | ); 134 | }; 135 | -------------------------------------------------------------------------------- /src/Patterns/WithContext/Marks/Bar.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | 3 | interface Props { 4 | y: number; 5 | width: number; 6 | height: number; 7 | selected: boolean; 8 | onSelect: () => void; 9 | } 10 | 11 | function Bar({ y, width, height, selected, onSelect }: Props): ReactElement { 12 | return ( 13 | 20 | ); 21 | } 22 | 23 | export default Bar; 24 | -------------------------------------------------------------------------------- /src/Patterns/WithContext/Marks/BaseMap.tsx: -------------------------------------------------------------------------------- 1 | import { geoGraticule, GeoPath, GeoPermissibleObjects } from 'd3'; 2 | import React from 'react'; 3 | 4 | interface Props { 5 | pathGenerator: GeoPath; 6 | data: { 7 | land: any; 8 | countries: any; 9 | interiors: any; 10 | } | null; 11 | } 12 | 13 | const BaseMap: React.FC = ({ pathGenerator, data }) => { 14 | const graticule = geoGraticule(); 15 | return ( 16 | 17 | 22 | 29 | {data && 30 | data.land.features.map((d: any, i: number) => ( 31 | 38 | ))} 39 | 45 | 46 | ); 47 | }; 48 | 49 | export default BaseMap; 50 | -------------------------------------------------------------------------------- /src/Patterns/WithContext/Marks/Line.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | interface Props { 3 | d: string; 4 | selected?: boolean; 5 | } 6 | const Line: React.FC = ({ d, selected }) => { 7 | return ( 8 | 15 | ); 16 | }; 17 | 18 | export default Line; 19 | -------------------------------------------------------------------------------- /src/Patterns/WithContext/Marks/SelectCountry.tsx: -------------------------------------------------------------------------------- 1 | import type { GeoPath, GeoPermissibleObjects } from 'd3'; 2 | import React from 'react'; 3 | import { useDashboard } from '../Dashboard'; 4 | 5 | interface Props { 6 | pathGenerator: GeoPath; 7 | data: { 8 | land: any; 9 | countries: any; 10 | interiors: any; 11 | } | null; 12 | } 13 | 14 | const SelectCountry: React.FC = ({ pathGenerator, data }) => { 15 | const { selected, setSelected } = useDashboard(); 16 | return ( 17 | 18 | {data && 19 | data.countries.features.map((d: any, i: number) => ( 20 | setSelected(d.id)} 27 | /> 28 | ))} 29 | 30 | ); 31 | }; 32 | 33 | export default SelectCountry; 34 | -------------------------------------------------------------------------------- /src/Patterns/WithContext/Util/useCSVData.tsx: -------------------------------------------------------------------------------- 1 | import { csv, DSVRowArray } from 'd3'; 2 | import { useEffect, useState } from 'react'; 3 | 4 | export const useCSVData = ( 5 | url: string, 6 | transform: (rawRow: DSVRowArray) => any, 7 | ) => { 8 | const [data, setData] = useState(null); 9 | useEffect(() => { 10 | csv(url).then((resp) => setData(transform(resp))); 11 | }, []); 12 | 13 | return data; 14 | }; 15 | -------------------------------------------------------------------------------- /src/Patterns/WithContext/Util/useResizeObserver.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import ResizeObserver from 'resize-observer-polyfill'; 3 | 4 | const useResizeObserver = ( 5 | ref: React.MutableRefObject, 6 | ) => { 7 | const [dimensions, setDimensions] = useState(); 8 | useEffect(() => { 9 | const observeTarget = ref.current; 10 | const resizeObserver = new ResizeObserver((entries: Array) => { 11 | entries.forEach((entry) => setDimensions(entry.contentRect)); 12 | }); 13 | if (observeTarget != null) { 14 | resizeObserver.observe(observeTarget); 15 | } else { 16 | } 17 | return () => { 18 | resizeObserver.disconnect(); 19 | }; 20 | }, [ref]); 21 | return dimensions; 22 | }; 23 | 24 | export default useResizeObserver; 25 | -------------------------------------------------------------------------------- /src/Patterns/WithContext/Util/useWorldAtlas.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { json } from 'd3'; 3 | import { feature, mesh } from 'topojson'; 4 | 5 | const jsonUrl = 'https://unpkg.com/world-atlas@2.0.2/countries-50m.json'; 6 | 7 | export const useWorldAtlas = () => { 8 | const [data, setData] = useState<{ 9 | land: any; 10 | countries: any; 11 | interiors: any; 12 | } | null>(null); 13 | 14 | useEffect(() => { 15 | json(jsonUrl).then((topology: any) => { 16 | const { countries, land } = topology.objects; 17 | setData({ 18 | land: feature(topology, land), 19 | countries: { 20 | ...feature(topology, countries), 21 | id: feature(topology, countries), 22 | }, 23 | interiors: mesh(topology, countries, (a, b) => a !== b), 24 | }); 25 | }); 26 | }, []); 27 | 28 | return data; 29 | }; 30 | -------------------------------------------------------------------------------- /src/Patterns/WithContext/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #184240; 3 | color: #c9d1c1; 4 | font-family: 'Lato', sans-serif; 5 | } 6 | 7 | p code { 8 | background: #c9d1c1; 9 | color: #184240; 10 | padding: 0 0.2em; 11 | } 12 | 13 | h2 { 14 | color: #cb862b; 15 | } 16 | pre { 17 | background: #c9d1c1; 18 | border: 1px solid #ddd; 19 | border-left: 6px solid #cb862b; 20 | color: #184240; 21 | page-break-inside: avoid; 22 | font-family: monospace; 23 | font-size: 15px; 24 | line-height: 1.6; 25 | margin-bottom: 1.6em; 26 | max-width: 100%; 27 | overflow: auto; 28 | padding: 1em 1.5em; 29 | display: block; 30 | word-wrap: break-word; 31 | } 32 | -------------------------------------------------------------------------------- /src/Patterns/WithContext/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ChartComponent } from './Dashboard'; 3 | import './index.css'; 4 | 5 | function WithSelectD3() { 6 | return ( 7 |
15 |
16 | 17 |
18 |
19 |

With Context Pattern

20 |

21 | While working on the With Component Pattern it was evident that some 22 | props, like the setSelected would need to be passed down into nested 23 | components. This made for redundant and deeply nested code that was 24 | hard to organize and change. In an effort to centralize everything, 25 | here I use the useContext hook in order to create a 26 | central place for things like data, scales and interactivity which can 27 | then be called on by a custom useDashboard hook. This 28 | eliminates a lot of prop passing as any nested component, anywhere, 29 | can have access to the whole dashboards props and functions. 30 |

31 |

32 | This was particularly useful things like scales where I could setup 33 | the domain of the scale in the Dashboard.jsx and then 34 | call this later on and pass in the .range() with the 35 | inner dimensions.{' '} 36 |

37 |
38 |           {`export const ChartComponent = () => {
39 |   const [selected, setSelected] = useState(null);
40 |   const wrapperRef = useRef(null);
41 |   const dimensions = useResizeObserver(wrapperRef);
42 | 
43 |   const width = dimensions?.width || 450;
44 |   // Parse Data
45 |   const data = useCSVData()
46 | 
47 |   // Define Scales
48 |   const TimeScale = scaleTime()
49 |     .domain(extent(allData, (d) => d.date) as [Date, Date])
50 |     .range([0, innerWidth])
51 |     .nice();
52 |   const PopulationScale = scaleLinear()
53 |     .domain(extent(allData, (d) => +d.population) as [number, number])
54 |     .range([innerHeight, 0])
55 |     .nice();
56 |   const CountryCategories = scaleBand()
57 |     .domain(
58 |       data
59 |         .map((d) => d[d.length - 1])
60 |         .slice(0, 25)
61 |         .map((i) => i.country),
62 |     )
63 |     .range([0, innerHeight])
64 |     .paddingInner(0.2);
65 | 
66 |   return (
67 |     
76 |       
77 | 78 | {data && ( 79 | <> 80 | 81 | 82 | 83 | )} 84 |
85 |
86 | ); 87 | }; 88 | `}
89 |
90 |
91 |
92 | ); 93 | } 94 | 95 | export default WithSelectD3; 96 | -------------------------------------------------------------------------------- /src/Patterns/WithSelectD3/Axis/XLinearAxis.tsx: -------------------------------------------------------------------------------- 1 | import { axisBottom, axisLeft, select } from 'd3'; 2 | import type { NumberValue, ScaleLinear, ScaleTime } from 'd3-scale'; 3 | import React, { ReactElement, useEffect, useRef } from 'react'; 4 | 5 | interface Props { 6 | xScale: ScaleLinear; 7 | tickFormat: (domainValue: NumberValue, index: number) => string; 8 | innerWidth: number; 9 | margin: { top: number; left: number; bottom: number; right: number }; 10 | innerHeight: number; 11 | } 12 | 13 | const XLinearAxis = ({ 14 | xScale, 15 | tickFormat, 16 | innerWidth, 17 | innerHeight, 18 | margin, 19 | }: Props): ReactElement => { 20 | const svgRef = useRef(null); 21 | 22 | useEffect(() => { 23 | const svg = select(svgRef.current); 24 | 25 | // Define Axis 26 | const xAxis = axisBottom(xScale).tickFormat(tickFormat); 27 | // Draw Axis 28 | svg 29 | .select('.x-axis') 30 | .style( 31 | 'transform', 32 | `translate(${margin.left}px, ${innerHeight + margin.top}px)`, 33 | ) 34 | .attr('color', 'cadetblue') 35 | .call(xAxis); 36 | 37 | //Draw labels 38 | svg 39 | .selectAll('.x-labels') 40 | .data([0]) 41 | .join('text') 42 | .attr('class', 'x-labels') 43 | .attr('transform', `translate(${innerWidth / 2}, ${innerHeight + 40})`) 44 | .attr('fill', 'cadetblue') 45 | .style('text-anchor', 'middle') 46 | .text('Population'); 47 | }, [innerWidth]); 48 | 49 | return ( 50 | 51 | 52 | 53 | ); 54 | }; 55 | 56 | export default XLinearAxis; 57 | -------------------------------------------------------------------------------- /src/Patterns/WithSelectD3/Axis/XTimeAxis.tsx: -------------------------------------------------------------------------------- 1 | import { axisBottom, select } from 'd3'; 2 | import type { NumberValue, ScaleTime } from 'd3-scale'; 3 | import React, { ReactElement, useEffect, useRef } from 'react'; 4 | 5 | interface Props { 6 | xScale: ScaleTime; 7 | innerWidth: number; 8 | margin: { top: number; left: number; bottom: number; right: number }; 9 | innerHeight: number; 10 | ticks?: number; 11 | } 12 | 13 | const XTimeAxis = ({ 14 | xScale, 15 | innerWidth, 16 | innerHeight, 17 | margin, 18 | ticks = 10, 19 | }: Props): ReactElement => { 20 | const svgRef = useRef(null); 21 | 22 | useEffect(() => { 23 | const svg = select(svgRef.current); 24 | 25 | // Define Axis 26 | const xAxis = axisBottom(xScale).ticks(ticks); 27 | 28 | // Draw Axis 29 | svg 30 | .select('.x-axis') 31 | .style( 32 | 'transform', 33 | `translate(${margin.left}px, ${innerHeight + margin.top}px)`, 34 | ) 35 | .attr('color', 'cadetblue') 36 | .call(xAxis); 37 | 38 | //Draw labels 39 | svg 40 | .selectAll('.x-labels') 41 | .data([0]) 42 | .join('text') 43 | .attr('class', 'x-labels') 44 | .attr('transform', `translate(${innerWidth / 2}, ${innerHeight + 40})`) 45 | .attr('fill', 'cadetblue') 46 | .style('text-anchor', 'middle') 47 | .text('Year'); 48 | }, [innerWidth]); 49 | 50 | return ( 51 | 52 | 53 | 54 | ); 55 | }; 56 | 57 | export default XTimeAxis; 58 | -------------------------------------------------------------------------------- /src/Patterns/WithSelectD3/Axis/YBandAxis.tsx: -------------------------------------------------------------------------------- 1 | import { axisBottom, axisLeft, select } from 'd3'; 2 | import type { NumberValue, ScaleBand, ScaleLinear, ScaleTime } from 'd3-scale'; 3 | import React, { ReactElement, useEffect, useRef } from 'react'; 4 | 5 | interface Props { 6 | yScale: ScaleBand; 7 | innerWidth: number; 8 | margin: { top: number; left: number; bottom: number; right: number }; 9 | innerHeight: number; 10 | ticks?: number; 11 | } 12 | 13 | const YLinearAxis = ({ 14 | yScale, 15 | innerWidth, 16 | innerHeight, 17 | margin, 18 | ticks = 10, 19 | }: Props): ReactElement => { 20 | const svgRef = useRef(null); 21 | 22 | useEffect(() => { 23 | const svg = select(svgRef.current); 24 | 25 | // Define Axis 26 | const yAxis = axisLeft(yScale).ticks(ticks); 27 | // Draw Axis 28 | svg 29 | .select('.y-axis') 30 | .style('transform', `translate(${margin.left}px, ${margin.top}px)`) 31 | .attr('color', 'cadetblue') 32 | .call(yAxis); 33 | }, [innerWidth]); 34 | 35 | return ( 36 | 37 | 38 | 39 | ); 40 | }; 41 | 42 | export default YLinearAxis; 43 | -------------------------------------------------------------------------------- /src/Patterns/WithSelectD3/Axis/YLinearAxis.tsx: -------------------------------------------------------------------------------- 1 | import { axisBottom, axisLeft, select } from 'd3'; 2 | import type { NumberValue, ScaleLinear, ScaleTime } from 'd3-scale'; 3 | import React, { ReactElement, useEffect, useRef } from 'react'; 4 | 5 | interface Props { 6 | yScale: ScaleLinear; 7 | tickFormat: (domainValue: NumberValue, index: number) => string; 8 | innerWidth: number; 9 | margin: { top: number; left: number; bottom: number; right: number }; 10 | innerHeight: number; 11 | ticks?: number; 12 | } 13 | 14 | const YLinearAxis = ({ 15 | yScale, 16 | tickFormat, 17 | innerWidth, 18 | innerHeight, 19 | margin, 20 | ticks = 10, 21 | }: Props): ReactElement => { 22 | const svgRef = useRef(null); 23 | 24 | useEffect(() => { 25 | const svg = select(svgRef.current); 26 | 27 | // Define Axis 28 | const yAxis = axisLeft(yScale).tickFormat(tickFormat); 29 | // Draw Axis 30 | svg 31 | .select('.y-axis') 32 | .style('transform', `translate(${margin.left}px, ${margin.top}px)`) 33 | .attr('color', 'cadetblue') 34 | .call(yAxis); 35 | 36 | //Draw labels 37 | svg 38 | .selectAll('.y-labels') 39 | .data([0]) 40 | .join('text') 41 | .attr('class', 'y-labels') 42 | .attr('fill', 'cadetblue') 43 | .attr('text-anchor', 'middle') 44 | .attr( 45 | 'transform', 46 | `translate(0, ${(innerHeight - margin.top) / 2}) rotate(-90)`, 47 | ) 48 | .style('text-anchor', 'end') 49 | .text('Population'); 50 | }, [innerWidth]); 51 | 52 | return ( 53 | 54 | 55 | 56 | ); 57 | }; 58 | 59 | export default YLinearAxis; 60 | -------------------------------------------------------------------------------- /src/Patterns/WithSelectD3/Charts/BarChart.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | axisBottom, 3 | axisLeft, 4 | curveBasis, 5 | extent, 6 | line, 7 | NumberValue, 8 | scaleBand, 9 | scaleLinear, 10 | scaleLog, 11 | scaleTime, 12 | select, 13 | tickFormat, 14 | } from 'd3'; 15 | import React, { useEffect, useRef } from 'react'; 16 | import XLinearAxis from '../Axis/XLinearAxis'; 17 | import YBandAxis from '../Axis/YBandAxis'; 18 | import Bar from '../Marks/Bar'; 19 | import useResizeObserver from '../Util/useResizeObserver'; 20 | 21 | interface Props { 22 | data: Array< 23 | Array<{ date: Date; population: number; country: string; code: string }> 24 | >; 25 | selected: string | null; 26 | onSelect: React.Dispatch>; 27 | width: number; 28 | height?: number; 29 | } 30 | 31 | const margin = { top: 10, right: 10, bottom: 40, left: 40 }; 32 | 33 | const BarChart: React.FC = ({ 34 | data, 35 | selected, 36 | onSelect, 37 | width, 38 | height = 300, 39 | }) => { 40 | const innerWidth = width - margin.left - margin.right; 41 | const innerHeight = height - margin.top - margin.bottom; 42 | 43 | const latestData = data.map((d) => d[d.length - 1]).slice(0, 25); 44 | 45 | // Format 46 | const tickFormat = (domainValue: NumberValue, index: number) => 47 | `${(domainValue as number) / 1000000}M`; 48 | 49 | //Scale 50 | const xScale = scaleLinear() 51 | .domain(extent(latestData, (d) => +d.population) as [number, number]) 52 | .range([0, innerWidth]) 53 | .nice(); 54 | const yScale = scaleBand() 55 | .domain(latestData.map((i) => i.country)) 56 | .range([0, innerHeight]) 57 | .paddingInner(0.2); 58 | 59 | return ( 60 | 61 | 62 | {latestData && 63 | latestData.map((country) => ( 64 | onSelect(country.code)} 71 | /> 72 | ))} 73 | 74 | 81 | 87 | 88 | ); 89 | }; 90 | 91 | export default BarChart; 92 | -------------------------------------------------------------------------------- /src/Patterns/WithSelectD3/Charts/LineChart.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | axisBottom, 3 | axisLeft, 4 | extent, 5 | line, 6 | NumberValue, 7 | scaleLinear, 8 | scaleTime, 9 | } from 'd3'; 10 | import React from 'react'; 11 | import XTimeAxis from '../Axis/XTimeAxis'; 12 | import YLinearAxis from '../Axis/YLinearAxis'; 13 | import Line from '../Marks/Line'; 14 | 15 | interface Props { 16 | data: Array< 17 | Array<{ date: Date; population: number; country: string; code: string }> 18 | >; 19 | selected: string | null; 20 | width: number; 21 | height?: number; 22 | } 23 | 24 | const margin = { top: 10, right: 10, bottom: 40, left: 40 }; 25 | 26 | const LineChart: React.FC = ({ 27 | data, 28 | selected, 29 | width, 30 | height = 300, 31 | }) => { 32 | const innerWidth = width - margin.left - margin.right; 33 | const innerHeight = height - margin.top - margin.bottom; 34 | 35 | const allData = data.reduce( 36 | (accumulator, countryTimeseries) => accumulator.concat(countryTimeseries), 37 | [], 38 | ); 39 | 40 | // Define Scales 41 | const xScale = scaleTime() 42 | .domain(extent(allData, (d) => d.date) as [Date, Date]) 43 | .range([0, innerWidth]) 44 | .nice(); 45 | const yScale = scaleLinear() 46 | .domain(extent(allData, (d) => +d.population) as [number, number]) 47 | .range([innerHeight, 0]) 48 | .nice(); 49 | 50 | // Format 51 | const tickFormat = (domainValue: NumberValue, index: number) => 52 | `${(domainValue as number) / 1000000}M`; 53 | 54 | // Define Shapes 55 | const lineGenerator = line<{ 56 | date: Date; 57 | population: number; 58 | country: string; 59 | code: string; 60 | }>() 61 | .x((d) => xScale(d.date)) 62 | .y((d) => yScale(d.population)); 63 | 64 | const selectedData = data.find((countries) => countries[0].code === selected); 65 | return ( 66 | 67 | 68 | {data.map((country) => ( 69 | 70 | ))} 71 | {selectedData && ( 72 | 73 | )} 74 | 75 | 81 | 88 | 89 | ); 90 | }; 91 | 92 | export default LineChart; 93 | -------------------------------------------------------------------------------- /src/Patterns/WithSelectD3/Charts/WorldMap.tsx: -------------------------------------------------------------------------------- 1 | import { geoNaturalEarth1, geoPath } from 'd3'; 2 | import React from 'react'; 3 | import BaseMap from '../Marks/BaseMap'; 4 | import SelectCountry from '../Marks/SelectCountry'; 5 | 6 | interface Props { 7 | worldAtlas: { 8 | land: any; 9 | countries: any; 10 | interiors: any; 11 | } | null; 12 | selected: string | null; 13 | onSelect: React.Dispatch>; 14 | width: number; 15 | height?: number; 16 | } 17 | 18 | const WorldMap: React.FC = ({ 19 | worldAtlas, 20 | selected, 21 | onSelect, 22 | width, 23 | height = 300, 24 | }) => { 25 | const projection = geoNaturalEarth1().fitSize([width, height], { 26 | type: 'Sphere', 27 | }); 28 | const pathGenerator = geoPath().projection(projection); 29 | 30 | return ( 31 | 32 | 33 | 39 | 40 | ); 41 | }; 42 | 43 | export default WorldMap; 44 | -------------------------------------------------------------------------------- /src/Patterns/WithSelectD3/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { DSVRowArray, timeParse } from 'd3'; 2 | import React, { useRef, useState } from 'react'; 3 | import BarChart from './Charts/BarChart'; 4 | import LineChart from './Charts/LineChart'; 5 | import WorldMap from './Charts/WorldMap'; 6 | import { useCSVData } from './Util/useCSVData'; 7 | import useResizeObserver from './Util/useResizeObserver'; 8 | import { useWorldAtlas } from './Util/useWorldAtlas'; 9 | 10 | const parseYear = timeParse('%Y'); 11 | 12 | export const ChartComponent = () => { 13 | const [selected, setSelected] = useState(null); 14 | const wrapperRef = useRef(null); 15 | const dimensions = useResizeObserver(wrapperRef); 16 | 17 | const transform = (raw: DSVRowArray) => { 18 | const years = raw?.columns?.slice(2); 19 | 20 | return raw.map((d) => { 21 | return years.map((year) => ({ 22 | date: parseYear(year), 23 | population: d[year] ? +(d[year] || 0) * 1000 : null, 24 | country: d.Country, 25 | code: 26 | (d['Country code'] || 0) < 100 27 | ? `0${d['Country code']}` 28 | : d['Country code'], 29 | })); 30 | }); 31 | }; 32 | const data = useCSVData( 33 | 'https://gist.githubusercontent.com/curran/0ac4077c7fc6390f5dd33bf5c06cb5ff/raw/605c54080c7a93a417a3cea93fd52e7550e76500/UN_Population_2019.csv', 34 | transform, 35 | ); 36 | 37 | const worldAtlas = useWorldAtlas(); 38 | 39 | if (!data || !worldAtlas)
Loading...
; 40 | 41 | return ( 42 |
43 | 49 | {data && ( 50 | <> 51 | 56 | 62 | 63 | )} 64 |
65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /src/Patterns/WithSelectD3/Marks/Bar.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | 3 | interface Props { 4 | y: number; 5 | width: number; 6 | height: number; 7 | selected: boolean; 8 | onSelect: () => void; 9 | } 10 | 11 | function Bar({ y, width, height, selected, onSelect }: Props): ReactElement { 12 | return ( 13 | 20 | ); 21 | } 22 | 23 | export default Bar; 24 | -------------------------------------------------------------------------------- /src/Patterns/WithSelectD3/Marks/BaseMap.tsx: -------------------------------------------------------------------------------- 1 | import { geoGraticule, GeoPath, GeoPermissibleObjects } from 'd3'; 2 | import React from 'react'; 3 | 4 | interface Props { 5 | pathGenerator: GeoPath; 6 | data: { 7 | land: any; 8 | countries: any; 9 | interiors: any; 10 | } | null; 11 | } 12 | 13 | const BaseMap: React.FC = ({ pathGenerator, data }) => { 14 | const graticule = geoGraticule(); 15 | 16 | return ( 17 | 18 | 23 | 28 | {data && 29 | data.land.features.map((d: any, i: number) => ( 30 | 37 | ))} 38 | 44 | 45 | ); 46 | }; 47 | 48 | export default BaseMap; 49 | -------------------------------------------------------------------------------- /src/Patterns/WithSelectD3/Marks/Line.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | interface Props { 3 | d: string; 4 | selected?: boolean; 5 | } 6 | const Line: React.FC = ({ d, selected }) => { 7 | return ( 8 | 15 | ); 16 | }; 17 | 18 | export default Line; 19 | -------------------------------------------------------------------------------- /src/Patterns/WithSelectD3/Marks/SelectCountry.tsx: -------------------------------------------------------------------------------- 1 | import type { GeoPath, GeoPermissibleObjects } from 'd3'; 2 | import React from 'react'; 3 | 4 | interface Props { 5 | pathGenerator: GeoPath; 6 | data: { 7 | land: any; 8 | countries: any; 9 | interiors: any; 10 | } | null; 11 | selected: string | null; 12 | onSelect: React.Dispatch>; 13 | } 14 | 15 | const SelectCountry: React.FC = ({ 16 | pathGenerator, 17 | data, 18 | selected, 19 | onSelect, 20 | }) => { 21 | return ( 22 | 23 | {data && 24 | data.countries.features.map((d: any, i: number) => ( 25 | onSelect(d.id)} 32 | /> 33 | ))} 34 | 35 | ); 36 | }; 37 | 38 | export default SelectCountry; 39 | -------------------------------------------------------------------------------- /src/Patterns/WithSelectD3/Util/useCSVData.tsx: -------------------------------------------------------------------------------- 1 | import { csv, DSVRowArray } from 'd3'; 2 | import { useEffect, useState } from 'react'; 3 | 4 | export const useCSVData = ( 5 | url: string, 6 | transform: (rawRow: DSVRowArray) => any, 7 | ) => { 8 | const [data, setData] = useState(null); 9 | useEffect(() => { 10 | csv(url).then((resp) => setData(transform(resp))); 11 | }, []); 12 | 13 | return data; 14 | }; 15 | -------------------------------------------------------------------------------- /src/Patterns/WithSelectD3/Util/useResizeObserver.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import ResizeObserver from 'resize-observer-polyfill'; 3 | 4 | const useResizeObserver = ( 5 | ref: React.MutableRefObject, 6 | ) => { 7 | const [dimensions, setDimensions] = useState(); 8 | useEffect(() => { 9 | const observeTarget = ref.current; 10 | const resizeObserver = new ResizeObserver((entries: Array) => { 11 | entries.forEach((entry) => setDimensions(entry.contentRect)); 12 | }); 13 | if (observeTarget != null) { 14 | resizeObserver.observe(observeTarget); 15 | } else { 16 | } 17 | return () => { 18 | resizeObserver.disconnect(); 19 | }; 20 | }, [ref]); 21 | return dimensions; 22 | }; 23 | 24 | export default useResizeObserver; 25 | -------------------------------------------------------------------------------- /src/Patterns/WithSelectD3/Util/useWorldAtlas.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { json } from 'd3'; 3 | import { feature, mesh } from 'topojson'; 4 | 5 | const jsonUrl = 'https://unpkg.com/world-atlas@2.0.2/countries-50m.json'; 6 | 7 | export const useWorldAtlas = () => { 8 | const [data, setData] = useState<{ 9 | land: any; 10 | countries: any; 11 | interiors: any; 12 | } | null>(null); 13 | 14 | useEffect(() => { 15 | json(jsonUrl).then((topology: any) => { 16 | const { countries, land } = topology.objects; 17 | setData({ 18 | land: feature(topology, land), 19 | countries: { 20 | ...feature(topology, countries), 21 | id: feature(topology, countries), 22 | }, 23 | interiors: mesh(topology, countries, (a, b) => a !== b), 24 | }); 25 | }); 26 | }, []); 27 | 28 | return data; 29 | }; 30 | -------------------------------------------------------------------------------- /src/Patterns/WithSelectD3/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #184240; 3 | color: #c9d1c1; 4 | font-family: 'Lato', sans-serif; 5 | } 6 | 7 | p code { 8 | background: #c9d1c1; 9 | color: #184240; 10 | padding: 0 0.2em; 11 | } 12 | 13 | h2 { 14 | color: #cb862b; 15 | } 16 | pre { 17 | background: #c9d1c1; 18 | border: 1px solid #ddd; 19 | border-left: 6px solid #cb862b; 20 | color: #184240; 21 | page-break-inside: avoid; 22 | font-family: monospace; 23 | font-size: 15px; 24 | line-height: 1.6; 25 | margin-bottom: 1.6em; 26 | max-width: 100%; 27 | overflow: auto; 28 | padding: 1em 1.5em; 29 | display: block; 30 | word-wrap: break-word; 31 | } 32 | -------------------------------------------------------------------------------- /src/Patterns/WithSelectD3/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ChartComponent } from './Dashboard'; 3 | import './index.css'; 4 | 5 | function WithSelectD3() { 6 | return ( 7 |
15 |
16 | 17 |
18 |
19 |

With Select D3 Pattern

20 |

21 | When it comes to certain chart elements such as axises and brushes, 22 | its just a lot easier to let D3 have control and render the{' '} 23 | {``} element. This is done by having a wrapped React 24 | Component that outputs an {``} that D3 can control 25 | and manipulate this part of the DOM. 26 |

27 |

28 | The file structure is more atomic as you can group together all the 29 | top level charts which are made up of smaller and smaller components. 30 | These could also be organized per chart with the chart at the top 31 | level and then nested Axis and Marks folders. But if you wanted to 32 | make different types of the same category of charts, then its better 33 | to have them broken out atomically. 34 |

35 |
36 |           {`With Select D3
37 | --Axis
38 | ----XLinearAxis.tsx
39 | ----XTimeAxis.tsx
40 | ----YBandAxis
41 | ----YLinearAxis
42 | --Charts
43 | ----BarChart.tsx
44 | ----LineChart.tsx
45 | ----WorldMap.tsx
46 | --Marks
47 | ----Bar.tsx
48 | ----BaseMap.tsx
49 | ----Line.tsx
50 | ----SelectCountry.tsx
51 | --Util
52 | ----useCSVData.tsx
53 | ----useResizeObserver.tsx
54 | ----useWorldAtlas.tsx
55 | --Dashboard.tsx
56 | --index.tsx
57 | --index.css`}
58 |         
59 |
60 |
61 | ); 62 | } 63 | 64 | export default WithSelectD3; 65 | -------------------------------------------------------------------------------- /src/Style/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #282c34; 3 | } 4 | .App { 5 | text-align: left; 6 | background-color: #282c34; 7 | min-height: 50vh; 8 | display: flex; 9 | flex-direction: column; 10 | align-items: center; 11 | justify-content: center; 12 | color: white; 13 | } 14 | .App code { 15 | background: #fff3; 16 | padding: 4px 8px; 17 | border-radius: 4px; 18 | } 19 | .App p { 20 | margin: 0.4rem; 21 | } 22 | 23 | .App-logo { 24 | height: 40vmin; 25 | padding: 1rem; 26 | pointer-events: none; 27 | } 28 | 29 | @media (prefers-reduced-motion: no-preference) { 30 | .App-logo { 31 | animation: App-logo-spin infinite 20s linear; 32 | } 33 | } 34 | 35 | .App-header { 36 | background-color: #282c34; 37 | min-height: 35vh; 38 | display: flex; 39 | flex-direction: column; 40 | align-items: center; 41 | justify-content: center; 42 | font-size: calc(10px + 2vmin); 43 | color: #f5814e; 44 | } 45 | 46 | .App-body { 47 | font-size: 1rem; 48 | } 49 | 50 | .App-body a { 51 | color: #61dafb; 52 | } 53 | 54 | .App-link { 55 | color: #f5814e; 56 | } 57 | .route-link a { 58 | color: #61dafb; 59 | margin-top: 0.5em; 60 | font-size: 1.5rem; 61 | } 62 | 63 | @keyframes App-logo-spin { 64 | from { 65 | transform: rotate(0deg); 66 | } 67 | to { 68 | transform: rotate(360deg); 69 | } 70 | } 71 | nav { 72 | display: flex; 73 | justify-content: space-between; 74 | padding: 0 2em; 75 | } 76 | nav a { 77 | color: #61dafb; 78 | font-size: 1.2em; 79 | text-decoration: none; 80 | padding: 0.5em; 81 | } 82 | nav .selected { 83 | color: #f5814e; 84 | font-size: 1.2em; 85 | text-decoration: none; 86 | padding: 0.5em; 87 | border-bottom: 6px solid #cb862b; 88 | } 89 | 90 | nav .disabled { 91 | color: #525a6b; 92 | 93 | } 94 | -------------------------------------------------------------------------------- /src/Style/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/home.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import logo from './logo.svg'; 4 | import './Style/App.css'; 5 | 6 | interface AppProps {} 7 | 8 | function Home({}: AppProps) { 9 | // Create the count state. 10 | const [count, setCount] = useState(0); 11 | // Create the counter (+1 every second). 12 | useEffect(() => { 13 | const timer = setTimeout(() => setCount(count + 1), 1000); 14 | return () => clearTimeout(timer); 15 | }, [count, setCount]); 16 | // Return the App component. 17 | return ( 18 |
19 |
20 | logo 21 | 22 |

Time to build some D3 chart patterns!

23 |
24 |
31 |

32 | The challenge here is to represent different ways of implementing D3 33 | into React. This is not to see which is 'best' but rather to see 34 | different techniques and evaluate their strengths and weaknesses. In 35 | order to make the comparison, each Pattern should use the same data 36 | source and present it in three separate chart, that can be interacted 37 | with to update all the charts at once. 38 |

39 |

40 | While viewing the pages on the web is nice, the real show is in the{' '} 41 | 42 | Github D3-React-Patterns 43 | {' '} 44 | repo where you can see the code and compare the different 45 | implementations 46 |

47 |

48 | To get started, place a new folder in the src/Patterns{' '} 49 | directory and create a index.tsx file for your 50 | development. 51 |

52 |

53 | Next add a route in the src/App.tsx and lazy load your 54 | pattern's index.tsx. From here you will have a code split bundle that 55 | shouldn't load any extra components. 56 |

57 |

58 | Then at a Link tag to the route in this file,{' '} 59 | src/home.tsx 60 |

61 |

62 | Good Luck! 63 |

64 |
65 |
73 |

Patterns

74 | JustD3 75 | WithSelectD3 76 | WithComponents 77 | WithContext 78 | RenderProps (coming soon) 79 | HOC (coming soon) 80 |
81 |

82 | 88 | Learn D3 89 | 90 |

91 |

92 | 98 | Learn React 99 | 100 |

101 |
102 | ); 103 | } 104 | 105 | export default Home; 106 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import './Style/index.css'; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root'), 11 | ); 12 | 13 | // Hot Module Replacement (HMR) - Remove this snippet to remove HMR. 14 | // Learn more: https://snowpack.dev/concepts/hot-module-replacement 15 | if (import.meta.hot) { 16 | import.meta.hot.accept(); 17 | } 18 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "target": "esnext", 6 | "moduleResolution": "node", 7 | "jsx": "preserve", 8 | "baseUrl": "./", 9 | /* paths - import rewriting/resolving */ 10 | "paths": { 11 | // If you configured any Snowpack aliases, add them here. 12 | // Add this line to get types for streaming imports (packageOptions.source="remote"): 13 | // "*": [".snowpack/types/*"] 14 | // More info: https://www.snowpack.dev/guides/streaming-imports 15 | }, 16 | /* noEmit - Snowpack builds (emits) files, not tsc. */ 17 | "noEmit": true, 18 | /* Additional Options */ 19 | "strict": true, 20 | "skipLibCheck": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "resolveJsonModule": true, 23 | "allowSyntheticDefaultImports": true, 24 | "importsNotUsedAsValues": "error", 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /types/static.d.ts: -------------------------------------------------------------------------------- 1 | /* Use this file to declare any custom file extensions for importing */ 2 | /* Use this folder to also add/extend a package d.ts file, if needed. */ 3 | 4 | /* CSS MODULES */ 5 | declare module '*.module.css' { 6 | const classes: { [key: string]: string }; 7 | export default classes; 8 | } 9 | declare module '*.module.scss' { 10 | const classes: { [key: string]: string }; 11 | export default classes; 12 | } 13 | declare module '*.module.sass' { 14 | const classes: { [key: string]: string }; 15 | export default classes; 16 | } 17 | declare module '*.module.less' { 18 | const classes: { [key: string]: string }; 19 | export default classes; 20 | } 21 | declare module '*.module.styl' { 22 | const classes: { [key: string]: string }; 23 | export default classes; 24 | } 25 | 26 | /* CSS */ 27 | declare module '*.css'; 28 | declare module '*.scss'; 29 | declare module '*.sass'; 30 | declare module '*.less'; 31 | declare module '*.styl'; 32 | 33 | /* IMAGES */ 34 | declare module '*.svg' { 35 | const ref: string; 36 | export default ref; 37 | } 38 | declare module '*.bmp' { 39 | const ref: string; 40 | export default ref; 41 | } 42 | declare module '*.gif' { 43 | const ref: string; 44 | export default ref; 45 | } 46 | declare module '*.jpg' { 47 | const ref: string; 48 | export default ref; 49 | } 50 | declare module '*.jpeg' { 51 | const ref: string; 52 | export default ref; 53 | } 54 | declare module '*.png' { 55 | const ref: string; 56 | export default ref; 57 | } 58 | 59 | /* CUSTOM: ADD YOUR OWN HERE */ 60 | -------------------------------------------------------------------------------- /web-test-runner.config.js: -------------------------------------------------------------------------------- 1 | // NODE_ENV=test - Needed by "@snowpack/web-test-runner-plugin" 2 | process.env.NODE_ENV = 'test'; 3 | 4 | module.exports = { 5 | plugins: [require('@snowpack/web-test-runner-plugin')()], 6 | }; 7 | --------------------------------------------------------------------------------