├── .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 | You need to enable JavaScript to run this app.
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 |
9 |
10 | Home
11 |
12 |
13 | JustD3
14 |
15 |
16 | WithSelectD3
17 |
18 |
19 | WithComponents
20 |
21 |
22 | WithContext
23 |
24 |
29 | RenderProps
30 |
31 |
36 | HOC
37 |
38 |
39 | Hex Map
40 |
41 |
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 |
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 |
--------------------------------------------------------------------------------